From 4a68f29bb249638196e947bea5b2bd33652901c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:35:15 +0000 Subject: [PATCH] Keep the prefix penalty capped when a later copy replaces the first letter [patch] The prefix refund and capped prefix penalty ran only when the first pattern letter first matched. A later, better-placed copy of that letter could replace it as the best letter, making everything before it prefix, yet those codepoints stayed charged one point each with no cap, plus a point for the superseded letter. "a" + N x "_ab" scored 13 - N for "ab". Track what was charged while the first letter is still provisional, and when a copy replaces it, refund that and charge the capped prefix penalty for the new position instead. Fixes #94 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vpup29Ycn8SGi7LvBjAq1c --- FuzzySearch.Test/FuzzyTests.cs | 37 +++++++++++++++++++- FuzzySearch/Fuzzy.cs | 62 +++++++++++++++++++++++++++++++--- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/FuzzySearch.Test/FuzzyTests.cs b/FuzzySearch.Test/FuzzyTests.cs index c15e22c..6a95f68 100644 --- a/FuzzySearch.Test/FuzzyTests.cs +++ b/FuzzySearch.Test/FuzzyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors [assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] @@ -469,6 +469,41 @@ public void CalculateScore_LongUnmatchedPrefix_PenaltyFlattensAtTheCap() Assert.AreEqual(atCap, wellPastCap, "The prefix penalty must stay flat however long the prefix grows."); } + [TestMethod] + [DataRow(5)] + [DataRow(20)] + [DataRow(100)] + public void CalculateScore_ReplacedFirstLetter_KeepsThePrefixPenaltyCapped(int gapLength) + { + // Arrange: both subjects end in the same "_ab" alignment. The second also starts with a stray copy of the + // pattern's first letter, which is provisionally matched and then replaced by the better-placed "a" in + // "_ab", so everything before that "a" is prefix in both. + string gap = new('x', gapLength); + string pattern = "ab"; + + // Act + int withoutStrayLetter = Fuzzy.CalculateScore(gap + "_ab", pattern, out bool withoutPresent); + int withStrayLetter = Fuzzy.CalculateScore("a" + gap + "_ab", pattern, out bool withPresent); + + // Assert + Assert.IsTrue(withoutPresent, "The pattern is present."); + Assert.IsTrue(withPresent, "The pattern is present."); + Assert.AreEqual(withoutStrayLetter, withStrayLetter, + "A replaced first letter is prefix, so it must cost no more than the capped prefix penalty."); + } + + [TestMethod] + public void Score_StrayEarlyFirstLetter_DoesNotSinkAPathMatch() + { + // Act: the same "_test" match, with and without a 't' early in the path + int withStrayLetter = Fuzzy.CalculateScore("tools/build/scripts/unit_test", "test", out _); + int withoutStrayLetter = Fuzzy.CalculateScore("docs/build/scripts/unit_test", "test", out _); + + // Assert + Assert.AreEqual(withoutStrayLetter, withStrayLetter, + "An early copy of the first letter that the match moves past must not cost more than the prefix cap."); + } + [TestMethod] public void CalculateScore_PrefixPenalty_NeverExceedsTheDocumentedCap() { diff --git a/FuzzySearch/Fuzzy.cs b/FuzzySearch/Fuzzy.cs index 4423889..06a11c3 100644 --- a/FuzzySearch/Fuzzy.cs +++ b/FuzzySearch/Fuzzy.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors using System.Runtime.CompilerServices; using System.Text; @@ -203,6 +203,15 @@ internal static int CalculateScoreCore(ReadOnlySpan subject, ReadOnlySpan< // of them, leaving the score falling without bound as the prefix grows. int prefixPenaltyCharged = 0; + // While the best letter is still the first pattern codepoint and has not been committed, a better-placed + // copy of it can take its place, and then everything before the copy is prefix too. These record what was + // charged since the first match landed — the capped prefix penalty and the uncapped per-codepoint costs + // after it — so a replacement can hand them back and charge the longer prefix through the cap instead. + // Otherwise a stray early copy of the first letter puts the whole gap outside the cap. + bool firstLetterProvisional = false; + int provisionalPrefixPenalty = 0; + int provisionalCharges = 0; + // Loop over codepoints in subject while (strIdx != strLength) { @@ -227,6 +236,7 @@ internal static int CalculateScoreCore(ReadOnlySpan subject, ReadOnlySpan< bestLetterIdx = null; bestLetterLength = 0; bestLetterScore = 0; + firstLetterProvisional = false; } if (nextMatch || rematch) @@ -239,9 +249,13 @@ internal static int CalculateScoreCore(ReadOnlySpan subject, ReadOnlySpan< // so that the capped penalty applied next is all the prefix is charged. score -= prefixPenaltyCharged; prefixPenaltyCharged = 0; - } - score = PenalizeNonPatternCharacters(score, patternIdx, strCodepointIdx); + int scoreBeforePrefix = score; + score = PenalizeNonPatternCharacters(score, patternIdx, strCodepointIdx); + firstLetterProvisional = true; + provisionalPrefixPenalty = score - scoreBeforePrefix; + provisionalCharges = 0; + } newScore = ApplyBonuses(prevMatched, prevLower, prevSeparator, strChar, strLower, strUpper, newScore); @@ -255,10 +269,21 @@ internal static int CalculateScoreCore(ReadOnlySpan subject, ReadOnlySpan< bool displacesRun = rematch && !nextMatch && bestLetterContinuesRun; if (newScore >= bestLetterScore && !displacesRun) { - // Apply penalty for now skipped letter - if (bestLetterIdx is not null) + if (ReplacesProvisionalFirstLetter(firstLetterProvisional, rematch, nextMatch)) + { + // A copy of the first letter replaces it, so the superseded letter and everything up to + // here is prefix: refund what was charged since the first match and charge the cap again. + score -= provisionalPrefixPenalty + provisionalCharges; + int scoreBeforePrefix = score; + score = PenalizeNonPatternCharacters(score, 0, strCodepointIdx); + provisionalPrefixPenalty = score - scoreBeforePrefix; + provisionalCharges = 0; + } + else if (bestLetterIdx is not null) { + // Apply penalty for now skipped letter score += unmatchedLetterPenalty; + ChargeProvisional(firstLetterProvisional, ref provisionalCharges); } bestLetterIdx = strIdx; @@ -271,6 +296,7 @@ internal static int CalculateScoreCore(ReadOnlySpan subject, ReadOnlySpan< // A rematch that loses to the current best letter is skipped too, so charge it like one. // Otherwise repeats of a well-scoring letter are free and can tie an exact match. score += unmatchedLetterPenalty; + ChargeProvisional(firstLetterProvisional, ref provisionalCharges); } prevMatched = true; @@ -287,6 +313,7 @@ internal static int CalculateScoreCore(ReadOnlySpan subject, ReadOnlySpan< prefixPenaltyCharged += unmatchedLetterPenalty; } + ChargeProvisional(firstLetterProvisional, ref provisionalCharges); prevMatched = false; } @@ -310,6 +337,31 @@ internal static int CalculateScoreCore(ReadOnlySpan subject, ReadOnlySpan< return score; } + /// + /// Determines whether a match is a copy of the uncommitted first pattern codepoint taking that codepoint's + /// place, rather than a match of the next pattern codepoint. + /// + /// Whether the best letter is the uncommitted first pattern codepoint. + /// Whether the subject codepoint repeats the best letter. + /// Whether the subject codepoint matches the next pattern codepoint. + /// true if the match replaces the provisional first letter; otherwise, false. + private static bool ReplacesProvisionalFirstLetter(bool firstLetterProvisional, bool rematch, bool nextMatch) => + firstLetterProvisional && rematch && !nextMatch; + + /// + /// Records a per-codepoint charge made while the first matched letter may still be replaced, so that a + /// replacement can refund it. + /// + /// Whether the best letter is the uncommitted first pattern codepoint. + /// The running total of such charges. + private static void ChargeProvisional(bool firstLetterProvisional, ref int provisionalCharges) + { + if (firstLetterProvisional) + { + provisionalCharges += unmatchedLetterPenalty; + } + } + /// /// Determines whether a character separates words, so that a match right after it earns /// .