Raxol. UI. ListScorer
(Raxol v2.6.1)
View Source
General-purpose fuzzy list scorer: (list, query, key_fn) -> ranked.
This is a list-item scorer -- it ranks arbitrary terms (session
structs, block refs, action descriptors, file paths, ...) by how well an
arbitrary key_fn-derived label matches a query string. It is
deliberately not Raxol.Search.Fuzzy: that module searches character
cells inside a rendered terminal buffer (Buffer.t(), {x, y}
positions) -- a different category of problem (buffer-cell search vs.
list-item ranking) that happens to share the word "fuzzy". Folding this
into Search.Fuzzy would conflate the two.
Used by Raxol.UI.Components.Harness.Picker (the overlay picker
primitive, AD-U3) and any future "pick one of N" projection (palette,
session picker, jump-to-block, file mentions -- F2's eventual sources).
Algorithm
An fzf-style Smith-Waterman-lite subsequence aligner: every query grapheme must appear in the key, in order (not necessarily contiguous), but the scoring rewards:
- adjacency -- consecutive matched characters score higher than the same characters scattered across the key (a gap penalty accumulates for each skipped character between two matches);
- word boundaries -- a match at the start of the string, right
after a separator (space/
-/_///./:/etc.), or at a lowercase-to-uppercase transition (camelCase) scores a bonus; - position -- an earlier match in the key scores slightly higher than a later one, all else equal.
Matching is case-insensitive by default (case_sensitive: true opts
out). Unicode-aware: works over String.graphemes/1, not bytes or
codepoints, so multi-codepoint graphemes (combining marks, some emoji)
and CJK text are handled as single units -- positions in the result
are grapheme indices into key, safe to feed to
Raxol.UI.TextMeasure for display-column highlighting (CJK graphemes
are double-width; a byte or codepoint index would misalign).
Result ordering
Results are sorted score descending, ties broken by original list
order (a stable sort made explicit via an {-score, original_index}
sort key, rather than relying on the underlying sort's stability).
Items with no subsequence match are excluded entirely. An empty query
matches everything with score 0.0 in original order (no filtering).
Performance
Every item pays a fixed per-keystroke cost (grapheme-splitting +
case-folding its key_fn label, then a cheap two-pointer subsequence
check) regardless of whether it matches; only items that pass the
subsequence check pay the O(query length key length) DP. This is a
pure, stateless* function -- it re-derives each item's grapheme
representation on every call rather than caching it, so a caller
filtering the same 10k-item list on every keystroke (a picker) is
paying that fixed cost 10k times per keystroke no matter how the DP
itself is tuned. Measured on a realistic corpus (varied labels, most
of which a multi-character query rejects before the DP), 10k items
comfortably clear the 16ms/keystroke target; a degenerate corpus
where literally every item matches (which fuzzy filtering exists to
make rare) costs meaningfully more since the DP then runs for all
10,000 items -- see list_scorer_test.exs's performance tests for
both numbers. A caller that must guarantee 16ms against a large list
on every keystroke under adversarial data should cache each item's
grapheme split alongside the item (invalidated when the item list
changes) rather than re-deriving it here every call; that's a
caller-side concern, not this module's, since rank/4's contract is
a pure function of its arguments.
Summary
Functions
Ranks items against query, using key_fn.(item) to derive each
item's searchable/display label (a String.t()).
Types
@type opt() :: {:case_sensitive, boolean()}
@type result() :: %{ item: term(), key: String.t(), score: float(), positions: [non_neg_integer()] }
Functions
Ranks items against query, using key_fn.(item) to derive each
item's searchable/display label (a String.t()).
Options:
:case_sensitive-- defaults tofalse.
Returns a list of %{item:, key:, score:, positions:} maps, sorted
score-descending with original order as the tiebreak. Items whose key
does not contain query as a subsequence are dropped. An empty query
returns every item, in original order, with score: 0.0 and
positions: [].