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

opt()

@type opt() :: {:case_sensitive, boolean()}

result()

@type result() :: %{
  item: term(),
  key: String.t(),
  score: float(),
  positions: [non_neg_integer()]
}

Functions

rank(items, query, key_fn, opts \\ [])

@spec rank([term()], String.t(), (term() -> String.t()), [opt()]) :: [result()]

Ranks items against query, using key_fn.(item) to derive each item's searchable/display label (a String.t()).

Options:

  • :case_sensitive -- defaults to false.

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: [].