diff --git a/py/utils/utils.py b/py/utils/utils.py index 2f02badd..6f5ea209 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -1,3 +1,4 @@ +from collections import Counter from difflib import SequenceMatcher import functools import logging @@ -409,6 +410,12 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool: """ Check if text matches pattern using fuzzy matching. Returns True if similarity ratio is above threshold. + + Two cheap necessary conditions gate ``SequenceMatcher``: its ratio + ``2*M/(len_a+len_b)`` is bounded both by the length ratio and by the number + of characters the two strings have in common. Skipping pairs that cannot + reach ``threshold`` keeps per-keystroke searches responsive on large + libraries without changing a single result (see #1179). """ if not pattern or not text: return False @@ -428,8 +435,28 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool: # If not found as substring, try fuzzy matching # Check if any part of the text matches this word + word_len = len(word) + word_char_counts = list(Counter(word).items()) found_match = False for text_part in text.split(): + part_len = len(text_part) + total_len = word_len + part_len + min_len = part_len if part_len < word_len else word_len + + # No pairing can beat 2*min_len/total_len, so a text part whose + # length is far off the query word is discarded outright. + if 2 * min_len < threshold * total_len: + continue + + # Matching blocks can only use characters present in both strings, + # so the character-multiset intersection bounds the matched chars. + common = 0 + for char, count in word_char_counts: + part_count = text_part.count(char) + common += count if count < part_count else part_count + if 2 * common < threshold * total_len: + continue + ratio = SequenceMatcher(None, text_part, word).ratio() if ratio >= threshold: found_match = True diff --git a/tests/performance/test_cache_performance.py b/tests/performance/test_cache_performance.py index 65a74d7b..a9927a4c 100644 --- a/tests/performance/test_cache_performance.py +++ b/tests/performance/test_cache_performance.py @@ -150,6 +150,51 @@ class TestFuzzyMatchPerformance: benchmark(match) +class TestFuzzyMatchLibraryScale: + """Benchmark one search query over a large library (issue #1179). + + Before the prefilters, a non-matching query at this size spent well over + 100ms in ``SequenceMatcher`` because every token of every non-matching + entry reached the matcher. + """ + + LIBRARY_SIZE = 20_000 + + @staticmethod + def _build_library(size: int) -> list[str]: + vocabulary = ( + "anime style character realistic portrait detail lineart chinese dark " + "magic girl pixel art photo cinematic illustration concept fantasy armor " + "cyberpunk watercolor sketch vintage retro neon glow soft light shadow " + "chibi cute mecha samurai landscape nature city night flower dragon knight" + ).split() + folders = ("", "anime/", "realistic/", "styles/", "characters/") + rng = random.Random(1179) + library = [] + for _ in range(size): + words = rng.sample(vocabulary, k=rng.randint(2, 4)) + library.append(f"{rng.choice(folders)}{'_'.join(words)}.safetensors") + return library + + def test_non_matching_query(self, benchmark): + """Worst case: nothing matches, so every entry is fully scanned.""" + library = self._build_library(self.LIBRARY_SIZE) + + def search(): + return sum(1 for name in library if fuzzy_match(name, "zzzqq")) + + assert benchmark(search) == 0 + + def test_typo_query(self, benchmark): + """A typo shares characters with many tokens, so it does the most work.""" + library = self._build_library(self.LIBRARY_SIZE) + + def search(): + return sum(1 for name in library if fuzzy_match(name, "anmei")) + + benchmark(search) + + class TestRecipeFingerprintPerformance: """Performance benchmarks for recipe fingerprint calculation.""" diff --git a/tests/utils/test_fuzzy_match_prefilter.py b/tests/utils/test_fuzzy_match_prefilter.py new file mode 100644 index 00000000..2a62ad4e --- /dev/null +++ b/tests/utils/test_fuzzy_match_prefilter.py @@ -0,0 +1,133 @@ +"""Equivalence tests for the ``fuzzy_match`` prefilters added for #1179. + +``fuzzy_match`` now skips ``SequenceMatcher`` when its ratio provably cannot +reach ``threshold``. These tests pin the two properties that make that legal: +the optimised implementation agrees with the pre-optimisation one on every +input, and obvious misses never reach the matcher at all. +""" + +from __future__ import annotations + +from difflib import SequenceMatcher + +import pytest +from hypothesis import given, settings, strategies as st + +import py.utils.utils as utils_module +from py.utils.utils import fuzzy_match + + +def reference_fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool: + """The implementation from before #1179, kept as the oracle.""" + if not pattern or not text: + return False + + text = text.lower() + pattern = pattern.lower() + + for word in pattern.split(): + if word in text: + continue + + found_match = False + for text_part in text.split(): + if SequenceMatcher(None, text_part, word).ratio() >= threshold: + found_match = True + break + + if not found_match: + return False + + return True + + +ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789 -." +TEXT = st.text(alphabet=ALPHABET, max_size=60) +PATTERN = st.text(alphabet=ALPHABET, max_size=24) +THRESHOLDS = st.sampled_from([0.0, 0.5, 0.75, 0.8, 0.85, 0.9, 1.0]) + + +@st.composite +def similar_pairs(draw): + """Two strings sharing a stem, to exercise the threshold boundary.""" + stem = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz_", min_size=1, max_size=10)) + tail_a = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz", max_size=3)) + tail_b = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz", max_size=3)) + return stem + tail_a, stem + tail_b + + +@settings(max_examples=300, deadline=None) +@given(TEXT, PATTERN, THRESHOLDS) +def test_matches_reference_on_arbitrary_input(text, pattern, threshold): + assert fuzzy_match(text, pattern, threshold) == reference_fuzzy_match( + text, pattern, threshold + ) + + +@settings(max_examples=300, deadline=None) +@given(similar_pairs(), THRESHOLDS) +def test_matches_reference_near_threshold(pair, threshold): + text, pattern = pair + assert fuzzy_match(text, pattern, threshold) == reference_fuzzy_match( + text, pattern, threshold + ) + # Both argument orders: the prefilter is not allowed to be directional. + assert fuzzy_match(pattern, text, threshold) == reference_fuzzy_match( + pattern, text, threshold + ) + + +class _CountingSequenceMatcher(SequenceMatcher): + """SequenceMatcher that records how often ``ratio()`` is evaluated.""" + + calls = 0 + + def ratio(self) -> float: + type(self).calls += 1 + return super().ratio() + + +@pytest.fixture +def counting_matcher(monkeypatch): + _CountingSequenceMatcher.calls = 0 + monkeypatch.setattr(utils_module, "SequenceMatcher", _CountingSequenceMatcher) + return _CountingSequenceMatcher + + +def test_obvious_miss_never_reaches_sequence_matcher(counting_matcher): + assert fuzzy_match("photorealistic_anime_v3_fp16", "zzzqq") is False + assert counting_matcher.calls == 0 + + +def test_length_mismatch_never_reaches_sequence_matcher(counting_matcher): + # 2 * 3 / (3 + 18) is far below the 0.85 threshold. + assert fuzzy_match("high_detail_anime_style", "abc") is False + assert counting_matcher.calls == 0 + + +def test_near_match_still_consults_sequence_matcher(counting_matcher): + # "animee" is not a substring of "anime", so only the matcher can accept it. + assert fuzzy_match("anime", "animee") is True + assert counting_matcher.calls >= 1 + + +@pytest.mark.parametrize( + ("text", "pattern", "expected"), + [ + # A leading insertion changes the first character: a prefix/first-char + # prefilter would wrongly reject this, the applied bounds do not. + ("anime", "nanime", True), + ("anime", "animee", True), + # A transposition scores 0.8, below the default threshold. + ("anime", "anmei", False), + ("anime", "animee_", False), + ("Anime", "anime", True), + ("anime style", "anime styl", True), + ("anime", "anime style", False), + ("anime_style_v2", "zzzqq", False), + ("", "anime", False), + ("anime", "", False), + ], +) +def test_threshold_boundaries_are_preserved(text, pattern, expected): + assert fuzzy_match(text, pattern) is expected