Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fuzzy matcher is eliminating valid items #5526

Open
gunslingerfry opened this issue Feb 14, 2025 · 6 comments
Open

Fuzzy matcher is eliminating valid items #5526

gunslingerfry opened this issue Feb 14, 2025 · 6 comments

Comments

@gunslingerfry
Copy link

Have you checked closed issues? Yes

Have you checked against the most recent version of Textual? Yes

The bug

The matcher seems to be favoring random characters from the right side. I'm trying to use it to search through paths and it acts very oddly. I've had to fall back to using fzf to do the searching right now because it is incorrectly eliminating results (but fzf doesn't do highlighting so Matcher is doing the highlighting). See below:

Image

You will see that SimplePropertyLabel, PropertyLabelRetestButton, and RollableLabel aren't highlighted because Matcher incorrectly eliminated those but fzf did not. On PropertyTextInput you see it is picking up text from the right side and as soon as I add 't' to my query it also is incorrectly eliminated.

Textual Diagnostics

Versions

Name Value
Textual 1.0.0
Rich 13.9.4

Python

Name Value
Version 3.13.0
Implementation CPython
Compiler GCC 14.2.1 20240910
Executable /home/matthew/.pyenv/versions/3.13.0/bin/python3.13

Operating System

Name Value
System Linux
Release 6.13.1-arch1-1
Version #1 SMP PREEMPT_DYNAMIC Sun, 02 Feb 2025 01:02:29 +0000

Terminal

Name Value
Terminal Application Unknown
TERM xterm-256color
COLORTERM truecolor
FORCE_COLOR Not set
NO_COLOR Not set

Rich Console options

Name Value
size width=131, height=24
legacy_windows False
min_width 1
max_width 131
is_terminal True
encoding utf-8
max_height 24
justify None
overflow None
no_wrap False
highlight None
markup None
height None
Copy link

We found the following entry in the FAQ which you may find helpful:

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by FAQtory

@TomJGooding
Copy link
Contributor

TomJGooding commented Feb 14, 2025

Here's a quick MRE I came up with. Try typing "text_elements" into the search and notice commands being discarded:

from functools import partial

from textual.app import App
from textual.command import Hit, Hits, Provider

PATHS = [
    "content/controls/text_elements/SetpointText.qml",
    "content/controls/text_elements/SimplePropertyLabel.qml",
    "content/controls/text_elements/PropertyLabelRetestButton.qml",
    "content/controls/text_elements/RollableLabel.qml",
    "content/controls/text_elements/PropertyTextInput.qml",
    "content/controls/text_elements/PropertyLabel.qml",
]


class ExampleCommands(Provider):
    async def search(self, query: str) -> Hits:
        matcher = self.matcher(query)
        app = self.app
        for path in PATHS:
            score = matcher.match(path)
            if score > 0:
                yield Hit(
                    score,
                    matcher.highlight(path),
                    partial(app.notify, path),
                )


class ExampleApp(App):
    COMMANDS = {ExampleCommands}

    def on_mount(self) -> None:
        self.action_command_palette()


if __name__ == "__main__":
    app = ExampleApp()
    app.run()

@TomJGooding
Copy link
Contributor

I found that increasing the limit on the number of loops in the FuzzySearch seems to help:

diff --git a/src/textual/fuzzy.py b/src/textual/fuzzy.py
index 9a7a55014..4a78a9fd5 100644
--- a/src/textual/fuzzy.py
+++ b/src/textual/fuzzy.py
@@ -140,7 +140,7 @@ class FuzzySearch:
         find = candidate.find
         # Limit the number of loops out of an abundance of caution.
         # This would be hard to reach without contrived data.
-        remaining_loops = 200
+        remaining_loops = 300
 
         while stack and (remaining_loops := remaining_loops - 1):
             search = pop()

I'm not sure if this reveals a possible issue with the search algorithm, as I'm bit surprised it needs this many iterations?

I wrote a quick test script using the FuzzySearch directly to show the actual scores against each query/candidate. Here's the results before and after increasing the loop limit:

Code
from rich import box
from rich import print as rich_print
from rich.table import Table
from textual.fuzzy import FuzzySearch

PATHS = [
    "content/controls/text_elements/PropertyLabelRetestButton.qml",
    "content/controls/text_elements/RollableLabel.qml",
    "content/controls/text_elements/SimplePropertyLabel.qml",
    "content/controls/text_elements/PropertyTextInput.qml",
    "content/controls/text_elements/SetpointText.qml",
    "content/controls/text_elements/PropertyLabel.qml",
]

FULL_QUERY = "text_elements"


fuzzy = FuzzySearch()

queries = [FULL_QUERY[:i] for i in range(8, len(FULL_QUERY) + 1)]

for query in queries:
    score_table = Table("Candidate", "Score", box=box.MARKDOWN)

    for candidate in PATHS:
        score = fuzzy.match(query, candidate)[0]
        score_table.add_row(candidate, f"{score:.2f}")

    rich_print(f"### FuzzySearch scores with query '{query}'")
    rich_print(score_table)
Before

FuzzySearch scores with query 'text_ele'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 18.00
content/controls/text_elements/RollableLabel.qml 18.00
content/controls/text_elements/SimplePropertyLabel.qml 18.00
content/controls/text_elements/PropertyTextInput.qml 18.00
content/controls/text_elements/SetpointText.qml 18.00
content/controls/text_elements/PropertyLabel.qml 18.00

FuzzySearch scores with query 'text_elem'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 14.44
content/controls/text_elements/RollableLabel.qml 20.00
content/controls/text_elements/SimplePropertyLabel.qml 20.00
content/controls/text_elements/PropertyTextInput.qml 20.00
content/controls/text_elements/SetpointText.qml 20.00
content/controls/text_elements/PropertyLabel.qml 20.00

FuzzySearch scores with query 'text_eleme'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 0.00
content/controls/text_elements/RollableLabel.qml 0.00
content/controls/text_elements/SimplePropertyLabel.qml 22.00
content/controls/text_elements/PropertyTextInput.qml 22.00
content/controls/text_elements/SetpointText.qml 22.00
content/controls/text_elements/PropertyLabel.qml 22.00

FuzzySearch scores with query 'text_elemen'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 0.00
content/controls/text_elements/RollableLabel.qml 0.00
content/controls/text_elements/SimplePropertyLabel.qml 0.00
content/controls/text_elements/PropertyTextInput.qml 20.03
content/controls/text_elements/SetpointText.qml 24.00
content/controls/text_elements/PropertyLabel.qml 24.00

FuzzySearch scores with query 'text_element'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 0.00
content/controls/text_elements/RollableLabel.qml 0.00
content/controls/text_elements/SimplePropertyLabel.qml 0.00
content/controls/text_elements/PropertyTextInput.qml 0.00
content/controls/text_elements/SetpointText.qml 26.00
content/controls/text_elements/PropertyLabel.qml 26.00

FuzzySearch scores with query 'text_elements'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 0.00
content/controls/text_elements/RollableLabel.qml 0.00
content/controls/text_elements/SimplePropertyLabel.qml 0.00
content/controls/text_elements/PropertyTextInput.qml 0.00
content/controls/text_elements/SetpointText.qml 0.00
content/controls/text_elements/PropertyLabel.qml 28.00
After

FuzzySearch scores with query 'text_ele'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 18.00
content/controls/text_elements/RollableLabel.qml 18.00
content/controls/text_elements/SimplePropertyLabel.qml 18.00
content/controls/text_elements/PropertyTextInput.qml 18.00
content/controls/text_elements/SetpointText.qml 18.00
content/controls/text_elements/PropertyLabel.qml 18.00

FuzzySearch scores with query 'text_elem'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 20.00
content/controls/text_elements/RollableLabel.qml 20.00
content/controls/text_elements/SimplePropertyLabel.qml 20.00
content/controls/text_elements/PropertyTextInput.qml 20.00
content/controls/text_elements/SetpointText.qml 20.00
content/controls/text_elements/PropertyLabel.qml 20.00

FuzzySearch scores with query 'text_eleme'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 22.00
content/controls/text_elements/RollableLabel.qml 22.00
content/controls/text_elements/SimplePropertyLabel.qml 22.00
content/controls/text_elements/PropertyTextInput.qml 22.00
content/controls/text_elements/SetpointText.qml 22.00
content/controls/text_elements/PropertyLabel.qml 22.00

FuzzySearch scores with query 'text_elemen'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 24.00
content/controls/text_elements/RollableLabel.qml 24.00
content/controls/text_elements/SimplePropertyLabel.qml 24.00
content/controls/text_elements/PropertyTextInput.qml 24.00
content/controls/text_elements/SetpointText.qml 24.00
content/controls/text_elements/PropertyLabel.qml 24.00

FuzzySearch scores with query 'text_element'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 26.00
content/controls/text_elements/RollableLabel.qml 26.00
content/controls/text_elements/SimplePropertyLabel.qml 26.00
content/controls/text_elements/PropertyTextInput.qml 26.00
content/controls/text_elements/SetpointText.qml 26.00
content/controls/text_elements/PropertyLabel.qml 26.00

FuzzySearch scores with query 'text_elements'

Candidate Score
content/controls/text_elements/PropertyLabelRetestButton.qml 28.00
content/controls/text_elements/RollableLabel.qml 28.00
content/controls/text_elements/SimplePropertyLabel.qml 28.00
content/controls/text_elements/PropertyTextInput.qml 28.00
content/controls/text_elements/SetpointText.qml 28.00
content/controls/text_elements/PropertyLabel.qml 28.00

@gunslingerfry
Copy link
Author

Very interesting. I can try increasing those iterations as a stopgap but I also suspect there is something wrong with the algorithm itself. Thanks for the awesome diagnosis work @TomJGooding !

@gunslingerfry
Copy link
Author

You tested my exact scenario, but I can also confirm that setting loops to 300 works with my actual script. However, even with the increased iterations I can break this scenario by increasing the length of the query. If I enter 2 more characters, /text_elements/, 300 loops no longer works but 400 does. 400 breaks at rols/text_elements/. Typing in the whole path content/controls/text_elements fails all of them with Matcher but not via fzf.

@TomJGooding
Copy link
Contributor

Here's another silly example but helps demonstrate how the current algorithm works to greedily match the query:

Image

This example would actually need 1,334 loops until the recursive search will try the exact match for "peter piper".

The number of loops quickly increases depending on the length of the query. For example, matching the full string above would need 2,051 loops.


I'm still trying to understand exactly how this fuzzy search works, but am I right in thinking that the recursion starts based on the first character of the query?

Would it help to traverse backwards from the last character of the query instead?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants