Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## v6.0.1

- Fixed `PartialRatio` false exact matches for near-perfect long strings.
- Clarified `PartialRatio` empty-input semantics: two empty inputs score 100; one empty input scores 0.
- Corrected cached `WeightedRatio` token-sort scoring to match uncached results and dispose its owned strategy.
- Added validation for `ExtractTop` limits; zero performs no work and negative limits throw `ArgumentOutOfRangeException`.
- Made the default string preprocessor's casing culture-invariant.

## v6.0.0

*Double-precision scoring and simplified distance APIs*
Expand Down
62 changes: 62 additions & 0 deletions FuzzySharp.Test/FuzzyTests/CachedScorerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System;
using System.Linq;
using Raffinert.FuzzySharp.PreProcess;
using Raffinert.FuzzySharp.SimilarityRatio.Scorer.Composite;
using Raffinert.FuzzySharp.SimilarityRatio.Scorer.StrategySensitive;
using Xunit;

namespace Raffinert.FuzzySharp.Test.FuzzyTests;

public class CachedScorerTests
{
public static TheoryData<string, string> WeightedRatioCases => new()
{
{ "b a", "a b" },
{ "invoice number", "number invoice" },
{ "Acme 123", "123 Acme Ltd" },
{ "red red blue", "blue red red" },
{ "Acme, 123", "123 Acme" },
};

[Theory]
[MemberData(nameof(WeightedRatioCases))]
public void CachedWeightedRatioMatchesUncached(string query, string candidate)
{
using var cached = new CachedWeightedRatioScorer(query);

Assert.Equal(Fuzz.WeightedRatio(query, candidate), cached.Score(candidate), precision: 10);
}

[Fact]
public void CachedPipelineMatchesNormalPipelineForTokenReordering()
{
var query = "b a";
var choices = new[] { "a b", "c d", "b a" };

var normal = Process.Configure().Build()
.ExtractTop(query, choices, StringPreprocessor.None, limit: 3, cutoff: 30)
.ToList();
var cached = Process.Configure().Cached().Build()
.ExtractTop(query, choices, StringPreprocessor.None, limit: 3, cutoff: 30)
.ToList();

Assert.Equal(normal.Count, cached.Count);
for (var i = 0; i < normal.Count; i++)
{
Assert.Equal(normal[i].Value, cached[i].Value);
Assert.Equal(normal[i].Score, cached[i].Score, precision: 10);
Assert.Equal(normal[i].Index, cached[i].Index);
}
}

[Fact]
public void CachedPartialTokenSet_EmptyTokenCollectionScoresZero()
{
using var emptyQuery = new CachedPartialTokenSetScorer("");
Assert.Equal(0, emptyQuery.Score(""));
Assert.Equal(0, emptyQuery.Score("x"));

using var nonEmptyQuery = new CachedPartialTokenSetScorer("x");
Assert.Equal(0, nonEmptyQuery.Score(""));
}
}
42 changes: 35 additions & 7 deletions FuzzySharp.Test/FuzzyTests/ExtractorSelectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,16 +178,37 @@ public void ExtractTop_GenericCachedParallelVariantsMatchLegacyTopN()
}

[Fact]
public void ExtractTop_LimitZeroAndNegative_PreserveLegacyBehavior()
public void ExtractTop_LimitZeroReturnsEmptyWithoutEnumeratingAndNegativeIsRejected()
{
var scorer = new MapScorer(("accepted", 50));
var choices = new[] { "accepted" };
var cachedScorer = new CachedMapScorer(("accepted", 50));

var throwingChoices = new ThrowingEnumerable<string>();
Assert.Empty(ResultExtractor.ExtractTop("query", throwingChoices, IdentityProcessor, scorer, limit: 0));
Assert.Empty(ResultExtractor.Cached.ExtractTop(throwingChoices, IdentityProcessor, cachedScorer, limit: 0));
Assert.Empty(ResultExtractor.Parallel.ExtractTop("query", throwingChoices, IdentityProcessor, scorer, limit: 0, parallelOptions: TestParallelOptions));
Assert.Empty(ResultExtractor.Parallel.Cached.ExtractTop(throwingChoices, IdentityProcessor, cachedScorer, limit: 0, parallelOptions: TestParallelOptions));

Assert.Throws<ArgumentOutOfRangeException>(() => ResultExtractor.ExtractTop("query", [], IdentityProcessor, scorer, limit: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => ResultExtractor.Cached.ExtractTop([], IdentityProcessor, cachedScorer, limit: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => ResultExtractor.Parallel.ExtractTop("query", [], IdentityProcessor, scorer, limit: -1, parallelOptions: TestParallelOptions));
Assert.Throws<ArgumentOutOfRangeException>(() => ResultExtractor.Parallel.Cached.ExtractTop([], IdentityProcessor, cachedScorer, limit: -1, parallelOptions: TestParallelOptions));

Assert.Throws<InvalidOperationException>(() =>
ResultExtractor.ExtractTop("query", choices, IdentityProcessor, scorer, limit: 0).ToList());
Assert.Throws<InvalidOperationException>(() =>
ResultExtractor.ExtractTop("query", choices, IdentityProcessor, scorer, limit: -1).ToList());
Assert.Empty(ResultExtractor.ExtractTop("query", choices, IdentityProcessor, scorer, limit: 0, cutoff: 90));
}

[Fact]
public void ExtractTop_ZeroLimitAppliesToGenericAndProcessRoutes()
{
var choices = new[] { new Choice("accepted") };
var scorer = new MapScorer(("accepted", 50));

Assert.Empty(ResultExtractor.ExtractTop("query", choices, static choice => choice.Name, IdentityProcessor, scorer, limit: 0));
Assert.Empty(Process.ExtractTopBy("query", choices, static choice => choice.Name, IdentityProcessor, scorer, limit: 0));
Assert.Empty(Process.Configure().Build().ExtractTopBy("query", choices, static choice => choice.Name, IdentityProcessor, limit: 0));
Assert.Empty(Process.Configure().Cached().Parallel().Build().ExtractTopBy("query", choices, static choice => choice.Name, IdentityProcessor, limit: 0));

using var cachedScorer = new CachedMapScorer(("accepted", 50));
Assert.Empty(Process.Configure().Cached(cachedScorer).Build().ExtractTopBy(choices, static choice => choice.Name, IdentityProcessor, limit: 0));
}

[Fact]
Expand Down Expand Up @@ -391,4 +412,11 @@ IEnumerator IEnumerable.GetEnumerator()
return GetEnumerator();
}
}

private sealed class ThrowingEnumerable<T> : IEnumerable<T>
{
public IEnumerator<T> GetEnumerator() => throw new InvalidOperationException("Choices must not be enumerated for a zero limit.");

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
3 changes: 3 additions & 0 deletions FuzzySharp.Test/FuzzyTests/RatioIssuesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ public void PartialRatioAlignment()
[Fact]
public void Issue196()
{
// WeightedRatio intentionally keeps its legacy full TokenSort/TokenSet
// partial branch for compatibility; do not replace these with partial
// token scorers as part of a RapidFuzz parity change.
Assert.Equal(81.81818181818181, Fuzz.WeightedRatio("South Korea", "North Korea"), 10);
}

Expand Down
50 changes: 49 additions & 1 deletion FuzzySharp.Test/FuzzyTests/RatioTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Xunit;
using Raffinert.FuzzySharp.PreProcess;
using Raffinert.FuzzySharp.SimilarityRatio.Scorer.StrategySensitive;

namespace Raffinert.FuzzySharp.Test.FuzzyTests;

Expand Down Expand Up @@ -104,6 +105,12 @@ public void TestWeightedRatioPartialMatch()
Assert.Equal(90, Fuzz.WeightedRatio(S1, S3));
}

[Fact]
public void TestWeightedRatioPreservesLegacyPartialTokenBranch()
{
Assert.Equal(79.16666666666667, Fuzz.WeightedRatio("alpha beta gamma", "gamma alpha betx zzz"), precision: 10);
}

[Fact]
public void TestWeightedRatioMisorderedMatch()
{
Expand All @@ -116,7 +123,48 @@ public void TestEmptyStringsScore0()
Assert.Equal(0, Fuzz.Ratio("test_string", ""));
Assert.Equal(0, Fuzz.PartialRatio("test_string", ""));
Assert.Equal(0, Fuzz.Ratio("", ""));
Assert.Equal(0, Fuzz.PartialRatio("", ""));
Assert.Equal(100, Fuzz.PartialRatio("", ""));
Assert.Equal(0, Fuzz.PartialRatio("", "x"));

var scorer = new PartialRatioScorer();
Assert.Equal(100, scorer.Score("", ""));
Assert.Equal(0, scorer.Score("", "x"));
}

[Fact]
public void PartialRatio_PreservesNearPerfectNonExactScores()
{
var almost = new string('a', 199) + "b";
var exact = new string('a', 200);
const double expected = 99.74937343358396;

var almostScore = Fuzz.PartialRatio(almost, exact);
Assert.Equal(expected, almostScore, precision: 10);
Assert.Equal(expected, Fuzz.PartialRatio("b" + new string('a', 199), exact), precision: 10);
Assert.True(almostScore < 100);
Assert.Equal(100, Fuzz.PartialRatio(exact, exact));

var prefixQuery = new string('a', 199) + "b";
var prefixCandidate = new string('a', 198) + "bzx";
Assert.Equal(99.74937343358396, Fuzz.PartialRatio(prefixQuery, prefixCandidate), precision: 10);
}

[Fact]
public void PartialRatio_ProcessedEmptyInputsFollowEmptyContract()
{
Assert.Equal(100, Fuzz.PartialRatio("!!!", "???", StringPreprocessor.Full));
Assert.Equal(0, Fuzz.PartialRatio("!!!", "x", StringPreprocessor.Full));
Assert.Equal(0, Fuzz.PartialRatio("x", "!!!", StringPreprocessor.Full));
}

[Fact]
public void PartialTokenSetRatio_EmptyTokenCollectionScoresZero()
{
Assert.Equal(0, Fuzz.PartialTokenSetRatio("", ""));
Assert.Equal(0, Fuzz.PartialTokenSetRatio("", "x"));
Assert.Equal(0, Fuzz.PartialTokenSetRatio("x", ""));
Assert.Equal(0, Fuzz.PartialTokenSetRatio("!!!", "x", StringPreprocessor.Full));
Assert.Equal(0, Fuzz.PartialTokenSetRatio("x", "!!!", StringPreprocessor.Full));
}

[Fact]
Expand Down
50 changes: 50 additions & 0 deletions FuzzySharp.Test/FuzzyTests/StringPreprocessorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System;
using System.Globalization;
using Raffinert.FuzzySharp.PreProcess;
using Xunit;

namespace Raffinert.FuzzySharp.Test.FuzzyTests;

[Collection("Culture-mutating tests")]
public class StringPreprocessorTests
{
[Fact]
public void FullProcessorIsCultureInvariant()
{
var invariant = RunWithCulture(CultureInfo.InvariantCulture, () => StringPreprocessor.Full("Iİiı"));
var turkish = RunWithCulture(new CultureInfo("tr-TR"), () => StringPreprocessor.Full("Iİiı"));

Assert.Equal(invariant, turkish);
}

[Fact]
public void FullProcessorRetainsNorwegianAndUkrainianLetters()
{
var result = StringPreprocessor.Full(" blåbær їжак ");

Assert.Equal("blåbær їжак", result);
}

private static T RunWithCulture<T>(CultureInfo culture, Func<T> action)
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;

try
{
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
return action();
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
}

[CollectionDefinition("Culture-mutating tests", DisableParallelization = true)]
public sealed class CultureMutatingTestCollection : ICollectionFixture<object>
{
}
2 changes: 2 additions & 0 deletions FuzzySharp/CachedScorerProcessExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public static IEnumerable<ExtractedResult<T>> ExtractTop<T>(
bool useParallel,
ParallelOptions parallelOptions)
{
ResultExtractor.ValidateLimit(limit);
if (useParallel)
{
return ResultExtractor.Parallel.Cached.ExtractTop(
Expand All @@ -75,6 +76,7 @@ public static IEnumerable<ExtractedResult<string>> ExtractTop(
bool useParallel,
ParallelOptions parallelOptions)
{
ResultExtractor.ValidateLimit(limit);
if (useParallel)
{
return ResultExtractor.Parallel.Cached.ExtractTop(
Expand Down
8 changes: 8 additions & 0 deletions FuzzySharp/Extractor/ResultExtractor.Cached.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,19 @@ public static IEnumerable<ExtractedResult<string>> ExtractSorted(IEnumerable<str

public static IEnumerable<ExtractedResult<T>> ExtractTop<T>(IEnumerable<T> choices, Func<T, string> extractor, Func<string, string> processor, ICachedRatioScorer scorer, int limit, double cutoff = 0)
{
ValidateLimit(limit);
if (limit == 0)
return Enumerable.Empty<ExtractedResult<T>>();

return ExtractTopCore(choices, choice => scorer.Score(processor(extractor(choice))), limit, cutoff);
}

public static IEnumerable<ExtractedResult<string>> ExtractTop(IEnumerable<string> choices, Func<string, string> processor, ICachedRatioScorer scorer, int limit, double cutoff = 0)
{
ValidateLimit(limit);
if (limit == 0)
return Enumerable.Empty<ExtractedResult<string>>();

return ExtractTopCore(choices, choice => scorer.Score(processor(choice)), limit, cutoff);
}
}
Expand Down
8 changes: 8 additions & 0 deletions FuzzySharp/Extractor/ResultExtractor.Parallel.Cached.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,21 @@ public static IEnumerable<ExtractedResult<string>> ExtractSorted(IEnumerable<str

public static IEnumerable<ExtractedResult<T>> ExtractTop<T>(IEnumerable<T> choices, Func<T, string> extractor, Func<string, string> processor, ICachedRatioScorer calculator, int limit, double cutoff = 0, ParallelOptions parallelOptions = null)
{
ValidateLimit(limit);
if (limit == 0)
return Enumerable.Empty<ExtractedResult<T>>();

var materializedChoices = choices.ToList();
var scores = ScoreParallel(materializedChoices, choice => calculator.Score(processor(extractor(choice))), parallelOptions);
return ExtractTopParallelCore(materializedChoices, scores, limit, cutoff);
}

public static IEnumerable<ExtractedResult<string>> ExtractTop(IEnumerable<string> choices, Func<string, string> processor, ICachedRatioScorer calculator, int limit, double cutoff = 0, ParallelOptions parallelOptions = null)
{
ValidateLimit(limit);
if (limit == 0)
return Enumerable.Empty<ExtractedResult<string>>();

var materializedChoices = choices.ToList();
var scores = ScoreParallel(materializedChoices, choice => calculator.Score(processor(choice)), parallelOptions);
return ExtractTopParallelCore(materializedChoices, scores, limit, cutoff);
Expand Down
16 changes: 16 additions & 0 deletions FuzzySharp/Extractor/ResultExtractor.Parallel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ private static IEnumerable<ExtractedResult<T>> ExtractTopParallelCore<T>(
int limit,
double cutoff)
{
ValidateLimit(limit);
if (limit == 0)
yield break;

var heap = new MinHeap<ScoredCandidate<T>>(ScoredCandidateComparer<T>.Instance);
var comparer = ScoredCandidateComparer<T>.Instance;

Expand Down Expand Up @@ -165,6 +169,10 @@ public static IEnumerable<ExtractedResult<T>> ExtractSorted<T>(string query, IEn

public static IEnumerable<ExtractedResult<T>> ExtractTop<T>(T query, IEnumerable<T> choices, Func<T, string> extractor, Func<string, string> processor, IRatioScorer calculator, int limit, double cutoff = 0, ParallelOptions parallelOptions = null)
{
ValidateLimit(limit);
if (limit == 0)
return Enumerable.Empty<ExtractedResult<T>>();

var materializedChoices = choices.ToList();
var processedQuery = processor(extractor(query));
var scores = ScoreParallel(materializedChoices, choice => calculator.Score(processedQuery, processor(extractor(choice))), parallelOptions);
Expand All @@ -173,6 +181,10 @@ public static IEnumerable<ExtractedResult<T>> ExtractTop<T>(T query, IEnumerable

public static IEnumerable<ExtractedResult<string>> ExtractTop(string query, IEnumerable<string> choices, Func<string, string> processor, IRatioScorer calculator, int limit, double cutoff = 0, ParallelOptions parallelOptions = null)
{
ValidateLimit(limit);
if (limit == 0)
return Enumerable.Empty<ExtractedResult<string>>();

var materializedChoices = choices.ToList();
var processedQuery = processor(query);
var scores = ScoreParallel(materializedChoices, choice => calculator.Score(processedQuery, processor(choice)), parallelOptions);
Expand All @@ -181,6 +193,10 @@ public static IEnumerable<ExtractedResult<string>> ExtractTop(string query, IEnu

public static IEnumerable<ExtractedResult<T>> ExtractTop<T>(string query, IEnumerable<T> choices, Func<T, string> extractor, Func<string, string> processor, IRatioScorer calculator, int limit, double cutoff = 0, ParallelOptions parallelOptions = null)
{
ValidateLimit(limit);
if (limit == 0)
return Enumerable.Empty<ExtractedResult<T>>();

var materializedChoices = choices.ToList();
var processedQuery = processor(query);
var scores = ScoreParallel(materializedChoices, choice => calculator.Score(processedQuery, processor(extractor(choice))), parallelOptions);
Expand Down
Loading
Loading