2026-10-25 19:16:18 +01:00
# FuzzyMatcher: "which candidate did the user mean"
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
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.
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
## 1. What it is for
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
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 ](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.
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
Design rules it follows (project rules, see `AGENTS.md` ):
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
- 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` .
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
## 2. API
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
```cpp
#include "fuzzy.h"
using detz :: FuzzyMatcher ;
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
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
```
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
| Function | Returns |
2026-10-25 18:13:20 +01:00
|---|---|
2026-10-25 19:16:18 +01:00
| `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` |
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
Empty query or empty text scores 0. Comparison is case-insensitive for ASCII only (section 7).
2026-10-25 18:13:20 +01:00
2026-10-25 19:16:18 +01:00
## 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 cost `kGap` each).
- `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 least `kBonusConsecutive` .
- 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 score `n * 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:
1. score, descending (differences under 1e-9 count as equal);
2. shorter text first (byte length), the default tie rule of fzf (`github-fzf-man-page.md` );
3. 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` ); `zzqq` against 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.