8.8 KiB
FuzzyMatcher: "which candidate did the user mean"
Code: src/util/fuzzy.h, src/util/fuzzy.cpp. Tests: tests/fuzzy_test.cpp (build/fuzzy_test, step
fuzzy.unit of tests/run_all.py). Research: research/fuzzy-matching-selectors/ (12 sources read,
53 listed, conclusion generic-fuzzy-scorer-design). This is a taicpp component; the Python app has no
equivalent. The code is the only definition of the scoring; this page states what it does today.
1. What it is for
One scorer, used wherever a short, typed, possibly wrong word has to pick one item out of a list of
identifiers. Today that is @agent, @agent:model and @model mentions (cap-agents.md).
It is deliberately generic and has no dependency on anything else in the application: pass a query and a
list of strings, get scores or the best index.
Design rules it follows (project rules, see AGENTS.md):
- Static ceilings, no auto tuning (section 6).
- Deterministic: the same inputs always give the same order, ties included.
- Loud limits: nothing is silently truncated beyond the ceilings listed in section 6.
- No dependency: it is a pure function over
std::string.
2. API
#include "fuzzy.h"
using detz::FuzzyMatcher;
double s = FuzzyMatcher::Score("opus", "claude-opus-4-5"); // 0..1
auto ranked = FuzzyMatcher::Rank("opus", {"claude-opus-4-5", "opencode"}); // vector<Ranked{index, score}>
long best = FuzzyMatcher::Best("opus", candidates, /*floor=*/0.3, &score); // index or -1
| Function | Returns |
|---|---|
Score(query, text) |
0 when nothing relates; 1.0 only for a case-insensitive exact match; otherwise the maximum of the four signals below, never above 0.999 |
Subsequence, Typo, TokenTypo, Trigram |
the individual signals, public for tests and tuning discussions |
Rank(query, texts) |
every candidate with score above 0, best first |
Best(query, texts, floor, &score) |
index of the top candidate, or -1 when there is none or its score is not strictly above floor |
Empty query or empty text scores 0. Comparison is case-insensitive for ASCII only (section 7).
3. The four signals
Score = max( Subsequence, Typo, 0.95 x TokenTypo, 0.9 x Trigram ), then capped at 0.999 unless the
folded strings are equal (then exactly 1.0).
3.1 Subsequence (letters in order, boundary aware)
The query bytes must appear in the text in order, case-insensitively, not necessarily next to each other.
The best alignment is found by dynamic programming and scored with the constants of fzf's
FuzzyMatchV2 (research/fuzzy-matching-selectors/github-fzf-algo-go.md), using the two-matrix recurrence
of fzy (github-fzy-match-c.md):
D[i][j]= best score of query[0..i] where query[i] is matched exactly at text[j].M[i][j]= best score of query[0..i] using text[0..j], matched or skipped.- Matching text[j] for query[i]:
i == 0:kScoreMatch + bonus[j] * kFirstMultiplier + kGap * j(the skipped leading bytes costkGapeach).i > 0:max( M[i-1][j-1] + kScoreMatch + bonus[j], D[i-1][j-1] + kScoreMatch + max(bonus[j], kBonusConsecutive) ). The second branch is "continuing a run"; it always earns at leastkBonusConsecutive.
- A skipped byte costs
kGap(-1), at the start, inside and at the end. - The raw result is
M[n-1][m-1]; it is normalized by the perfect scoren * 16 + 10 * 2 + (n-1) * 4(every byte matched, the first at a whitespace boundary, all consecutive) and clamped to [0, 1].
| Constant | Value | Applies when |
|---|---|---|
kScoreMatch |
16 | each matched byte |
kGap |
-1 | each skipped byte |
kBonusWhite |
10 | match at the start of the text or after whitespace |
kBonusBoundary |
8 | match after a byte that is not a letter or digit (-, _, /, :, .) |
kBonusCamel |
7 | lower to Upper, or a non-digit to a digit |
kBonusConsecutive |
4 | minimum bonus for continuing a run |
kFirstMultiplier |
2 | first query byte's bonus is doubled |
Consequences you can rely on: a query matches best at the start of a word; fb finds FooBar (camel
humps) better than fxxxxbxx; shorter texts lose fewer points to skipped bytes. A query longer than the
text cannot be a subsequence and scores 0 here.
3.2 Typo (restricted Damerau-Levenshtein)
1 - d / max(len(query), len(text)) where d is the optimal-string-alignment distance: insert, delete,
substitute, or swap two adjacent bytes, each costing 1. It catches typos and transpositions
(snonet for sonnet) that are not subsequences. It is low for a short query against a long text, which
is what the next signal is for.
3.3 TokenTypo (typo against each word)
The text is split into runs of letters and digits (claude-opus-4-5 gives claude, opus, 4, 5);
the result is the best Typo of the query against any one token, scaled by 0.95. It finds a misspelled
word inside a long identifier (opuss in claude-opus-4-5) and gives an exact word match (opus, free,
pro) a score of 0.95.
3.4 Trigram (pg_trgm)
Each alphanumeric word gets two leading spaces and one trailing space; the set of three-byte windows is
compared by Jaccard (shared / total distinct), the way PostgreSQL's pg_trgm does
(postgresql-pg-trgm.md: word against words is 4/7 = 0.571). Scaled by 0.9. It helps with scrambled or
partial words and is the weakest signal.
4. Ranking and ties
Rank keeps candidates with score above 0 and sorts by:
- score, descending (differences under 1e-9 count as equal);
- shorter text first (byte length), the default tie rule of fzf (
github-fzf-man-page.md); - earlier position in the input list.
So two equal matches never produce an error or a coin flip: free against zen-free and devplace-free
(both 0.95) always picks zen-free.
5. Worked examples
Measured with the code (Subsequence, Typo, TokenTypo before the 0.95 scale, Trigram before the 0.9
scale, then the final Score):
| query | text | subseq | typo | token | trigram | score |
|---|---|---|---|---|---|---|
opus |
claude-opus-4-5 |
0.84 | 0.27 | 1.00 | 0.31 | 0.95 |
opus |
opencode |
0.00 | 0.25 | 0.25 | 0.17 | 0.25 |
son |
claude-sonnet-4-5 |
0.76 | 0.18 | 0.50 | 0.16 | 0.76 |
sonet (dropped letter) |
claude-sonnet-4-5 |
0.83 | 0.29 | 0.83 | 0.26 | 0.83 |
snonet (swap) |
claude-sonnet-4-5 |
0.00 | 0.29 | 0.83 | 0.14 | 0.79 |
opuss (extra letter) |
claude-opus-4-5 |
0.00 | 0.27 | 0.80 | 0.22 | 0.76 |
g4m (word starts) |
gpt-4o-mini |
1.00 | 0.27 | 0.33 | 0.07 | 1.00 |
gpt4 |
gpt-4o |
1.00 | 0.67 | 0.75 | 0.33 | 1.00 |
gpt4 |
gpt-4o-mini |
0.97 | 0.36 | 0.75 | 0.21 | 0.97 |
free |
zen-free |
0.92 | 0.50 | 1.00 | 0.56 | 0.95 |
pro |
gemini-2.5-pro |
0.80 | 0.21 | 1.00 | 0.27 | 0.95 |
fb |
FooBar |
0.98 | 0.33 | 0.33 | 0.11 | 0.98 |
fb |
fxxxxbxx |
0.82 | 0.25 | 0.25 | 0.10 | 0.82 |
clde |
claude |
0.94 | 0.67 | 0.67 | 0.33 | 0.94 |
word |
words |
0.99 | 0.80 | 0.80 | 0.57 | 0.99 |
zzqq |
zen-free |
0.00 | 0.12 | 0.25 | 0.08 | 0.24 |
(g4m and gpt4 show 1.00 because the subsequence is perfect after rounding; the function itself returns
at most 0.999 unless the strings are equal.) Reading the table: opus finds claude-opus-4-5 mainly through
the exact word (token signal) and not at all through opencode; a swap is rescued by the token signal; a
nonsense query tops out around 0.25.
6. Ceilings
| Constant | Value | Effect |
|---|---|---|
kFuzzyQueryMax |
64 bytes | longer queries are cut |
kFuzzyTextMax |
256 bytes | longer candidates are cut |
kFuzzyCandidateMax |
4096 | candidates beyond this are not ranked |
kTrigramScale |
0.9 | |
kTokenScale |
0.95 |
Cost per score is at most 64 x 256 cells for the subsequence DP and for the edit distance, so ranking a full catalog stays in the low milliseconds.
7. Known limits
- ASCII case folding only; bytes of 0x80 and above are treated as lowercase letters and compared as they are. Accented text is not normalized (fzf can).
- The constants are fzf's, tuned for file paths, not for model identifiers. They were not tuned on a real corpus of model ids; the examples above are the evidence. No claim of optimality.
- A subsequence can match scattered letters inside a long identifier; boundary bonuses make this rare, not impossible.
- The caller decides whether a score is good enough:
Best(..., floor)takes the floor. The CAP catalog uses 0.3 (CAP_FUZZY_FLOOR);zzqqagainst everything scores at most 0.24 and is rejected.
8. Reusing it
Anything that picks from a list of names can call it: pass the user's text and the list, take Best
with a floor, show the chosen candidate to the user when it differs from what they typed (the catalog
prints 'tia:zne' resolved to tai:zen-free (fuzzy 0.67)). Do not use it for security decisions or for
free text search; it is built for short identifiers.