Skip to main content

keyhammer/
search.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Robson Trasel
3
4//! Exact best-first top-k search over a [`Trie`] with weighted edit costs.
5//!
6//! Each queued trie node carries one banded row of the edit-distance matrix.
7//! A row cell `k` at trie depth `j` stands for query prefix length
8//! `i = j + k - W`, where `W = budget / c_indel_min` is the band half-width.
9//! A node is queued with a lower bound on the cost of every term below it, so
10//! the first terminal popped is always the best remaining one.
11//!
12//! The budget, the band and the candidates always use the weighted costs of
13//! [`CostModel`]. The order of the results is set by [`Ranking`]: by default
14//! ([`Ranking::Coarse`]) terms are ranked by the weighted cost rounded up to
15//! whole units of 16 ([`whole_units`]), then by higher weight, then by lower
16//! term id, so the weight decides between terms whose costs round to the same
17//! number of units. This is not a count of edits: the x1.5 factor on the
18//! first byte makes an ordinary (non-neighbouring-key) edit there cost 24,
19//! i.e. two units, while a neighbouring-key substitution there costs 12 (one
20//! unit) and a transposition 18 (two units); two cheap edits (8 + 8) count as
21//! one.
22//! [`Ranking::Exact`] ranks by the exact weighted cost instead, then weight,
23//! then id. In both modes [`Hit::cost`] is the exact weighted cost.
24//!
25//! [`Searcher::search_prefix`] is the autocomplete variant: a term costs what its best prefix
26//! costs. See `docs/design/prefix-mode.md`.
27//!
28//! Queries and terms are compared per Unicode scalar value (a symbol), not per
29//! UTF-8 byte; for ASCII the two are the same. See `docs/design/unicode.md`.
30
31use alloc::collections::BinaryHeap;
32use alloc::string::String;
33use alloc::vec::Vec;
34use core::cmp::Ordering;
35use core::fmt;
36
37use crate::cost::{Cost, CostModel, INF, symbol_class, whole_units};
38use crate::highlight::{self, Highlight, HighlightError, HighlightMode};
39use crate::text;
40use crate::trie::{NO_TERM, Trie};
41
42/// Longest accepted query, in symbols (code points; bytes for ASCII).
43pub const MAX_QUERY_LEN: usize = 128;
44/// Largest band half-width: the budget is at most `MAX_W * c_indel_min`.
45pub(crate) const MAX_W: usize = 8;
46const ROW: usize = 2 * MAX_W + 1;
47
48/// How results are ordered.
49#[non_exhaustive]
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub enum Ranking {
52    /// By the weighted cost rounded up to whole units of 16
53    /// ([`whole_units`]), then higher weight, then lower term id. Terms whose
54    /// costs round to the same number of units are ordered by weight. This is
55    /// not a count of edits: the x1.5 factor on the first byte makes an
56    /// ordinary (non-neighbouring-key) edit there cost 24, i.e. two units,
57    /// while a neighbouring-key substitution there costs 12 (one unit) and a
58    /// transposition 18 (two units); two cheap edits (8 + 8) count as one.
59    #[default]
60    Coarse,
61    /// By the exact weighted cost, then higher weight, then lower term id.
62    Exact,
63}
64
65impl Ranking {
66    /// The cost used for ordering.
67    #[inline]
68    fn rank(self, cost: Cost) -> Cost {
69        match self {
70            Ranking::Coarse => whole_units(cost),
71            Ranking::Exact => cost,
72        }
73    }
74}
75
76/// Search parameters.
77///
78/// Build it with `..SearchConfig::default()` so that new fields keep their
79/// defaults: `k = 10`, `budget = 32` (two ordinary edits' worth of cost), `tsb = true`,
80/// `max_nodes = 100_000`, `ranking = Ranking::Coarse`.
81#[derive(Clone, Debug)]
82pub struct SearchConfig {
83    /// Number of results wanted.
84    pub k: usize,
85    /// Largest accepted edit cost (fixed point, 16 = one edit).
86    pub budget: Cost,
87    /// Use the subtree-signature lower bound (on by default).
88    ///
89    /// It never changes the results of a search that finishes within `max_nodes`, only how much work is done; if the node limit stops a search, both modes return a correct prefix of the same ranked list, but its length can differ. In
90    /// `docs/benchmarks/tsb-default.md` it expanded about a third fewer trie
91    /// nodes and lowered p95 latency by about a fifth (one machine, one
92    /// corpus). The per-node data it reads is built by [`Trie::build`]
93    /// whatever this flag says, so turning it off saves no memory; set
94    /// `tsb: false` to compare or to measure the plain bound.
95    pub tsb: bool,
96    /// Hard limit on expanded trie nodes.
97    pub max_nodes: usize,
98    /// How results are ordered.
99    pub ranking: Ranking,
100}
101
102impl SearchConfig {
103    /// An opt-in configuration that trades speed for recall: the default
104    /// settings with `budget = 48` (three ordinary edits' worth of cost). It
105    /// sets the subtree bound on explicitly (`tsb = true`, which does not change
106    /// the results and is also the default).
107    ///
108    /// On the benchmark in `docs/benchmarks/recall-preset.md` (300 Birkbeck
109    /// typo pairs, one machine, median of three runs) it found the right word more often
110    /// than the default at every dictionary size, at the price of expanding
111    /// several times more trie nodes per query and a higher latency; the
112    /// measured numbers are in that file. Measure it on your own dictionary
113    /// before relying on it. The maximum accepted budget is 64 (see
114    /// [`SearchError::BudgetTooLarge`]); 64 is not offered as a preset because
115    /// its measured cost grows much faster than its gain.
116    ///
117    /// ```
118    /// use keyhammer::search::SearchConfig;
119    /// let cfg = SearchConfig { k: 5, ..SearchConfig::high_recall() };
120    /// assert_eq!(cfg.budget, 48);
121    /// assert!(cfg.tsb);
122    /// ```
123    #[must_use]
124    pub fn high_recall() -> Self {
125        Self {
126            budget: 48,
127            tsb: true,
128            ..Self::default()
129        }
130    }
131}
132
133impl Default for SearchConfig {
134    fn default() -> Self {
135        Self {
136            k: 10,
137            budget: 32,
138            tsb: true,
139            max_nodes: 100_000,
140            ranking: Ranking::Coarse,
141        }
142    }
143}
144
145/// One result.
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
147pub struct Hit {
148    /// Term id (see [`Trie::term`]): the term's position in the byte-sorted,
149    /// deduplicated list, not in the slice given to [`Trie::build`];
150    /// [`Trie::input_index`] maps it back.
151    pub id: u32,
152    /// Exact weighted edit cost between the query and the term, whatever the
153    /// [`Ranking`].
154    pub cost: Cost,
155    /// The term's weight.
156    pub weight: u16,
157}
158
159/// Work counters for one search.
160///
161/// Non-exhaustive: fields may be added, so build it with `Stats::default()` if at all.
162#[derive(Clone, Debug, Default, PartialEq, Eq)]
163#[non_exhaustive]
164pub struct Stats {
165    /// Trie nodes expanded.
166    pub nodes_expanded: usize,
167    /// Queue entries created (nodes and terminals).
168    pub nodes_pushed: usize,
169    /// Banded DP rows computed (the root row plus one per child considered).
170    /// The row-free expansions of a prefix search (see
171    /// [`Searcher::search_prefix`]) count in `nodes_expanded` but not here.
172    pub rows_computed: usize,
173    /// The node limit stopped the search early.
174    pub truncated: bool,
175}
176
177/// Hits in ranking order plus work counters.
178#[derive(Clone, Debug)]
179pub struct Output {
180    /// Best first.
181    pub hits: Vec<Hit>,
182    /// Work counters.
183    pub stats: Stats,
184}
185
186/// Why a search was rejected.
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188#[non_exhaustive]
189pub enum SearchError {
190    /// The query is longer than [`MAX_QUERY_LEN`].
191    QueryTooLong {
192        /// Query length in code points (for [`Searcher::search_text`], after
193        /// normalisation).
194        len: usize,
195        /// The limit.
196        max: usize,
197    },
198    /// The budget needs a wider band than the implementation supports.
199    BudgetTooLarge {
200        /// The requested budget.
201        budget: Cost,
202        /// The largest supported budget for this cost model.
203        max: Cost,
204    },
205}
206
207impl fmt::Display for SearchError {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match self {
210            SearchError::QueryTooLong { len, max } => {
211                write!(f, "query has {len} code points, the limit is {max}")
212            }
213            SearchError::BudgetTooLarge { budget, max } => {
214                write!(f, "budget {budget} exceeds the maximum {max}")
215            }
216        }
217    }
218}
219
220struct Entry {
221    key: u64,
222    seq: u32,
223    node: u32,
224    term: u32,
225    /// Exact weighted cost of a terminal entry. For a node entry of a prefix
226    /// search: the best cost of a prefix ending at or above the node (`INF` if
227    /// none); unused by exact search.
228    cost: Cost,
229    /// Prefix search only: every term at or below this node costs exactly
230    /// `cost`, so no row is needed to expand it.
231    settled: bool,
232    depth: u16,
233    cur: [Cost; ROW],
234    prev: [Cost; ROW],
235}
236
237impl PartialEq for Entry {
238    fn eq(&self, other: &Self) -> bool {
239        self.cmp(other) == Ordering::Equal
240    }
241}
242impl Eq for Entry {}
243impl PartialOrd for Entry {
244    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
245        Some(self.cmp(other))
246    }
247}
248impl Ord for Entry {
249    // Reversed so that `BinaryHeap` pops the smallest key first.
250    fn cmp(&self, other: &Self) -> Ordering {
251        (other.key, other.seq).cmp(&(self.key, self.seq))
252    }
253}
254
255/// Queue key: ranking cost, then higher weight, then lower id.
256///
257/// `rank` is the ranking cost ([`Ranking::rank`]) of either a terminal's exact
258/// cost or a node's lower bound. Both ranking functions are monotone
259/// non-decreasing in the cost, so the rank of a node's lower bound never
260/// exceeds the rank of any term below it, and with the subtree's largest
261/// weight the node key stays a lower bound on every key below it: the bound
262/// remains admissible and the first terminal popped is still the best one.
263#[inline]
264fn pack(rank: Cost, weight: u16, id: u32) -> u64 {
265    (u64::from(rank) << 48) | (u64::from(u16::MAX - weight) << 32) | u64::from(id)
266}
267
268#[inline]
269fn cap(v: Cost, budget: Cost) -> Cost {
270    if v > budget { INF } else { v }
271}
272
273/// Index of the substitution (or match) among the moves of [`each_move`].
274pub(crate) const SUB: usize = 0;
275/// Index of the transposition among the moves of [`each_move`].
276pub(crate) const TRANSPOSE: usize = 1;
277/// Index of the insertion (a skipped term symbol) among the moves of [`each_move`].
278pub(crate) const INSERT: usize = 2;
279/// Index of the deletion (an extra query symbol) among the moves of [`each_move`].
280pub(crate) const DELETE: usize = 3;
281
282/// Calls `f(move, value)` for each move into the weighted OSA cell `D[i][j]`
283/// that is available (query prefix `q[..i]`, term prefix of length `j` ending
284/// in `ch`, whose previous symbol is `parent`, `None` when `j <= 1`); `move`
285/// is [`SUB`], [`TRANSPOSE`], [`INSERT`] or [`DELETE`].
286///
287/// `diag`, `up`, `left` and `skip2` are `D[i-1][j-1]`, `D[i][j-1]`,
288/// `D[i-1][j]` and `D[i-2][j-2]` (`INF` when absent or over the budget). This
289/// is the only place where the cost model's step costs enter a DP cell: the
290/// rows of the search ([`cell_min`]) and the traceback of
291/// [`crate::highlight`] ([`moves`]) both go through it, so highlighting
292/// cannot drift from the costs the search reports. A visitor rather than an
293/// array keeps the search's hot loop as fast as a hand-written minimum.
294#[allow(clippy::too_many_arguments)]
295#[inline(always)]
296pub(crate) fn each_move(
297    cm: &CostModel,
298    q: &[u32],
299    i: usize,
300    ch: u32,
301    parent: Option<u32>,
302    diag: Cost,
303    up: Cost,
304    left: Cost,
305    skip2: Cost,
306    mut f: impl FnMut(usize, Cost),
307) {
308    if i >= 1 && diag < INF {
309        f(SUB, diag + cm.sub_cost(q[i - 1], ch, i - 1));
310    }
311    if up < INF {
312        f(INSERT, up + cm.ins_cost(ch, parent, i));
313    }
314    if i >= 1 && left < INF {
315        f(DELETE, left + cm.del_cost(q, i - 1));
316    }
317    if let Some(p) = parent {
318        if i >= 2 && q[i - 1] == p && q[i - 2] == ch && q[i - 1] != q[i - 2] && skip2 < INF {
319            f(TRANSPOSE, skip2 + cm.transpose_cost(i - 2));
320        }
321    }
322}
323
324/// The value of each move of [`each_move`], indexed by [`SUB`],
325/// [`TRANSPOSE`], [`INSERT`] and [`DELETE`]; `INF` for a move that is not
326/// available.
327#[allow(clippy::too_many_arguments)]
328#[inline(always)]
329pub(crate) fn moves(
330    cm: &CostModel,
331    q: &[u32],
332    i: usize,
333    ch: u32,
334    parent: Option<u32>,
335    diag: Cost,
336    up: Cost,
337    left: Cost,
338    skip2: Cost,
339) -> [Cost; 4] {
340    let mut out = [INF; 4];
341    each_move(cm, q, i, ch, parent, diag, up, left, skip2, |k, v| {
342        out[k] = v;
343    });
344    out
345}
346
347/// The value of the cell: the smallest value of the moves of [`each_move`].
348#[allow(clippy::too_many_arguments)]
349#[inline(always)]
350fn cell_min(
351    cm: &CostModel,
352    q: &[u32],
353    i: usize,
354    ch: u32,
355    parent: Option<u32>,
356    diag: Cost,
357    up: Cost,
358    left: Cost,
359    skip2: Cost,
360) -> Cost {
361    let mut best = INF;
362    each_move(cm, q, i, ch, parent, diag, up, left, skip2, |_, v| {
363        best = best.min(v);
364    });
365    best
366}
367
368fn root_row(q: &[u32], cm: &CostModel, w: usize, budget: Cost) -> [Cost; ROW] {
369    let mut row = [INF; ROW];
370    row[w] = 0;
371    for i in 1..=q.len().min(w) {
372        // Row 0 of the term: only deletions (no term symbol, so `ch` is unused).
373        let d = cell_min(cm, q, i, 0, None, INF, INF, row[w + i - 1], INF);
374        row[w + i] = cap(d, budget);
375    }
376    row
377}
378
379#[allow(clippy::too_many_arguments)]
380fn child_row(
381    q: &[u32],
382    cm: &CostModel,
383    w: usize,
384    budget: Cost,
385    depth: usize,
386    parent_label: u32,
387    ch: u32,
388    cur: &[Cost; ROW],
389    prev: &[Cost; ROW],
390) -> [Cost; ROW] {
391    let m = q.len();
392    let j = depth + 1;
393    let parent = if depth >= 1 { Some(parent_label) } else { None };
394    let mut out = [INF; ROW];
395    for k in 0..=2 * w {
396        let i_signed = (j + k) as isize - w as isize;
397        if i_signed < 0 || i_signed as usize > m {
398            continue;
399        }
400        let i = i_signed as usize;
401        // Cell k of this row is D[i][j]; cell k of `cur` is D[i-1][j-1],
402        // k + 1 of `cur` is D[i][j-1], k - 1 of `out` is D[i-1][j] and k of
403        // `prev` is D[i-2][j-2].
404        let up = if k < 2 * w { cur[k + 1] } else { INF };
405        let left = if k >= 1 { out[k - 1] } else { INF };
406        out[k] = cap(
407            cell_min(cm, q, i, ch, parent, cur[k], up, left, prev[k]),
408            budget,
409        );
410    }
411    out
412}
413
414/// Lower bound on the cost of every term at or below `node`.
415///
416/// Any alignment either crosses trie row `depth` (bounded by the cells of
417/// `cur`) or skips it with a transposition from row `depth - 1` (bounded by
418/// `prev` plus the cheapest transposition). With `tsb`, the cells of `cur` are
419/// raised by what the query suffix still has to pay given the letters and the
420/// lengths present below the node.
421#[allow(clippy::too_many_arguments)]
422fn lower_bound(
423    trie: &Trie,
424    node: usize,
425    depth: usize,
426    cur: &[Cost; ROW],
427    prev: &[Cost; ROW],
428    m: usize,
429    w: usize,
430    qmask: &[u64],
431    cm: &CostModel,
432    tsb: bool,
433    prefix: bool,
434) -> Cost {
435    let mut lb = INF;
436    for (k, &d) in cur.iter().enumerate().take(2 * w + 1) {
437        if d >= INF {
438            continue;
439        }
440        let mut extra: Cost = 0;
441        if tsb {
442            let i = depth + k - w;
443            let r = m - i;
444            let lo = usize::from(trie.len_min(node)) - depth;
445            let hi = usize::from(trie.len_max(node)) - depth;
446            // Exact mode: the whole term must be aligned, so its length is in
447            // `lo..=hi`. Prefix mode: any prefix of the term, so only the upper
448            // end constrains the length.
449            let gap = if !prefix && r < lo {
450                lo - r
451            } else {
452                r.saturating_sub(hi)
453            };
454            let comp = (gap.min(usize::from(INF)) as Cost).saturating_mul(cm.c_indel_min());
455            let missing = (qmask[i] & !trie.below_mask(node)).count_ones() as Cost * cm.c_min();
456            extra = comp.max(missing);
457        }
458        lb = lb.min(d.saturating_add(extra));
459    }
460    if depth >= 1 {
461        for &p in prev.iter().take(2 * w + 1) {
462            if p < INF {
463                lb = lb.min(p.saturating_add(cm.c_transpose_min()));
464            }
465        }
466    }
467    lb
468}
469
470/// The per-query inputs of one node expansion. [`Searcher::search`] computes
471/// every row, bound and terminal cost through it, and so do the unit tests
472/// that check the bound against a brute-force oracle.
473struct Expander<'a> {
474    trie: &'a Trie,
475    cm: &'a CostModel,
476    q: &'a [u32],
477    qmask: &'a [u64],
478    w: usize,
479    budget: Cost,
480    tsb: bool,
481    prefix: bool,
482}
483
484impl Expander<'_> {
485    /// Prefix mode: the cost of aligning the whole query with the term prefix
486    /// that ends at this node (depth `depth`, row `cur`), or `INF`.
487    fn prefix_cell(&self, depth: usize, cur: &[Cost; ROW]) -> Cost {
488        match (self.q.len() + self.w).checked_sub(depth) {
489            Some(k) if k <= 2 * self.w => cur[k],
490            _ => INF,
491        }
492    }
493
494    /// The root row and the lower bound of the root.
495    fn root(&self) -> ([Cost; ROW], Cost) {
496        let cur = root_row(self.q, self.cm, self.w, self.budget);
497        let lb = lower_bound(
498            self.trie,
499            0,
500            0,
501            &cur,
502            &[INF; ROW],
503            self.q.len(),
504            self.w,
505            self.qmask,
506            self.cm,
507            self.tsb,
508            self.prefix,
509        );
510        (cur, lb)
511    }
512
513    /// The id and exact cost of the term that ends at `v` (at `depth`, with
514    /// row `cur`), if there is one and its cost is within the budget.
515    fn terminal(&self, v: usize, depth: usize, cur: &[Cost; ROW]) -> Option<(u32, Cost)> {
516        let tid = self.trie.term_id(v);
517        if tid == NO_TERM {
518            return None;
519        }
520        let k = (self.q.len() + self.w).checked_sub(depth)?;
521        if k <= 2 * self.w && cur[k] <= self.budget {
522            Some((tid, cur[k]))
523        } else {
524            None
525        }
526    }
527
528    /// The row and the lower bound of child `c` of node `v`, where `v` is at
529    /// `depth` with row `cur` and its parent's row `prev`.
530    fn child(
531        &self,
532        v: usize,
533        depth: usize,
534        c: usize,
535        cur: &[Cost; ROW],
536        prev: &[Cost; ROW],
537    ) -> ([Cost; ROW], Cost) {
538        let row = child_row(
539            self.q,
540            self.cm,
541            self.w,
542            self.budget,
543            depth,
544            self.trie.symbol(v),
545            self.trie.symbol(c),
546            cur,
547            prev,
548        );
549        let lb = lower_bound(
550            self.trie,
551            c,
552            depth + 1,
553            &row,
554            cur,
555            self.q.len(),
556            self.w,
557            self.qmask,
558            self.cm,
559            self.tsb,
560            self.prefix,
561        );
562        (row, lb)
563    }
564}
565
566/// A reusable search context. It keeps its priority queue and scratch buffers
567/// across queries; the returned [`Output`] is still allocated per query.
568#[derive(Default)]
569pub struct Searcher {
570    heap: BinaryHeap<Entry>,
571    qmask: Vec<u64>,
572    /// The query as symbols (see `text::decode`).
573    qsym: Vec<u32>,
574    /// The normalised query of `search_text`.
575    qtext: String,
576    seq: u32,
577    /// Buffers of the highlighting traceback.
578    pub(crate) hl: highlight::Scratch,
579}
580
581impl Searcher {
582    /// Creates an empty context.
583    pub fn new() -> Self {
584        Self::default()
585    }
586
587    // Seven arguments since terminal entries carry their exact cost; a
588    // parameter struct would add more code than it removes, so the lint is
589    // allowed here.
590    #[allow(clippy::too_many_arguments)]
591    fn push(
592        &mut self,
593        key: u64,
594        node: u32,
595        term: u32,
596        cost: Cost,
597        depth: u16,
598        cur: [Cost; ROW],
599        prev: [Cost; ROW],
600    ) {
601        self.seq = self.seq.wrapping_add(1);
602        self.heap.push(Entry {
603            key,
604            seq: self.seq,
605            node,
606            term,
607            cost,
608            settled: false,
609            depth,
610            cur,
611            prev,
612        });
613    }
614
615    fn push_settled(&mut self, key: u64, node: u32, term: u32, cost: Cost, depth: u16) {
616        self.seq = self.seq.wrapping_add(1);
617        self.heap.push(Entry {
618            key,
619            seq: self.seq,
620            node,
621            term,
622            cost,
623            settled: true,
624            depth,
625            cur: [INF; ROW],
626            prev: [INF; ROW],
627        });
628    }
629
630    /// Validates the query length (in symbols) and the budget; returns the
631    /// band half-width.
632    fn check(len: usize, cm: &CostModel, cfg: &SearchConfig) -> Result<usize, SearchError> {
633        if len > MAX_QUERY_LEN {
634            return Err(SearchError::QueryTooLong {
635                len,
636                max: MAX_QUERY_LEN,
637            });
638        }
639        let max_budget = MAX_W as Cost * cm.c_indel_min();
640        if cfg.budget > max_budget {
641            return Err(SearchError::BudgetTooLarge {
642                budget: cfg.budget,
643                max: max_budget,
644            });
645        }
646        Ok(usize::from(cfg.budget / cm.c_indel_min()))
647    }
648
649    /// Returns the `cfg.k` best terms within `cfg.budget`, best first, in the
650    /// order set by `cfg.ranking`.
651    ///
652    /// `q` is UTF-8 and is compared per code point with the terms, as it is:
653    /// it must already be in the form the terms are stored in (lowercase for
654    /// a dictionary of lowercase terms). [`Searcher::search_text`] normalises
655    /// it for a trie built with [`Trie::build_normalized`]. A byte that is not
656    /// part of valid UTF-8 is a symbol of its own that matches no term.
657    pub fn search(
658        &mut self,
659        trie: &Trie,
660        cm: &CostModel,
661        q: &[u8],
662        cfg: &SearchConfig,
663    ) -> Result<Output, SearchError> {
664        let len = text::decode(q, &mut self.qsym, MAX_QUERY_LEN);
665        self.run(trie, cm, len, cfg)
666    }
667
668    /// Fills the per-query masks from `q` and resets the queue.
669    fn prepare(&mut self, q: &[u32]) {
670        let m = q.len();
671        self.qmask.clear();
672        self.qmask.resize(m + 1, 0);
673        for i in (0..m).rev() {
674            self.qmask[i] = self.qmask[i + 1] | symbol_class(q[i]);
675        }
676        self.heap.clear();
677        self.seq = 0;
678    }
679
680    /// Normalises `q` with the trie's normaliser, if it has one, and decodes
681    /// it into `self.qsym`; returns its length in symbols.
682    fn load_text(&mut self, trie: &Trie, q: &str) -> usize {
683        match trie.normalizer() {
684            Some(n) => {
685                let mut t = core::mem::take(&mut self.qtext);
686                n.normalize_into(q, &mut t);
687                let len = text::decode(t.as_bytes(), &mut self.qsym, MAX_QUERY_LEN);
688                self.qtext = t;
689                len
690            }
691            None => text::decode(q.as_bytes(), &mut self.qsym, MAX_QUERY_LEN),
692        }
693    }
694
695    /// [`Searcher::search`] for text: `q` is normalised with the normaliser
696    /// of `trie` ([`Trie::build_normalized`]) first, so terms and queries are
697    /// folded identically. For a trie built with [`Trie::build`] this is
698    /// `search(trie, cm, q.as_bytes(), cfg)`. [`MAX_QUERY_LEN`] applies to the
699    /// normalised query.
700    ///
701    /// ```
702    /// use keyhammer::cost::CostModel;
703    /// use keyhammer::search::{SearchConfig, Searcher};
704    /// use keyhammer::text::Normalizer;
705    /// use keyhammer::trie::Trie;
706    ///
707    /// let items = [("São Paulo", 10), ("Straße", 5), ("Crème brûlée", 7)];
708    /// let trie = Trie::build_normalized(&items, &Normalizer::new()).unwrap();
709    /// let mut s = Searcher::new();
710    /// let cm = CostModel::qwerty();
711    /// let cfg = SearchConfig::default();
712    /// let first = |s: &mut Searcher, q: &str| {
713    ///     let out = s.search_text(&trie, &cm, q, &cfg).unwrap();
714    ///     (trie.input_index(out.hits[0].id), out.hits[0].cost)
715    /// };
716    /// assert_eq!(first(&mut s, "SAO PAULO"), (0, 0));
717    /// assert_eq!(first(&mut s, "strasse"), (1, 0));
718    /// assert_eq!(first(&mut s, "creme brulee"), (2, 0));
719    /// assert_eq!(first(&mut s, "sao paolo"), (0, 16)); // one typo
720    /// ```
721    pub fn search_text(
722        &mut self,
723        trie: &Trie,
724        cm: &CostModel,
725        q: &str,
726        cfg: &SearchConfig,
727    ) -> Result<Output, SearchError> {
728        let len = self.load_text(trie, q);
729        self.run(trie, cm, len, cfg)
730    }
731
732    /// [`Searcher::search_prefix`] for text, with the query normalised as by
733    /// [`Searcher::search_text`].
734    ///
735    /// ```
736    /// use keyhammer::cost::CostModel;
737    /// use keyhammer::search::{SearchConfig, Searcher};
738    /// use keyhammer::text::Normalizer;
739    /// use keyhammer::trie::Trie;
740    ///
741    /// let items = [("Ação", 10), ("Acapulco", 5)];
742    /// let trie = Trie::build_normalized(&items, &Normalizer::new()).unwrap();
743    /// let out = Searcher::new()
744    ///     .search_prefix_text(&trie, &CostModel::qwerty(), "AÇÃ", &SearchConfig::default())
745    ///     .unwrap();
746    /// assert_eq!(trie.term(out.hits[0].id), "acao");
747    /// assert_eq!(out.hits[0].cost, 0);
748    /// ```
749    pub fn search_prefix_text(
750        &mut self,
751        trie: &Trie,
752        cm: &CostModel,
753        q: &str,
754        cfg: &SearchConfig,
755    ) -> Result<Output, SearchError> {
756        let len = self.load_text(trie, q);
757        self.run_prefix(trie, cm, len, cfg)
758    }
759
760    /// The characters of a hit that the query matched, as ranges of `source`
761    /// in code points, UTF-8 bytes and UTF-16 units (see
762    /// [`crate::highlight`] and `docs/design/highlighting.md`).
763    ///
764    /// `q` is the query exactly as given to [`Searcher::search`]
765    /// ([`HighlightMode::Whole`]) or [`Searcher::search_prefix`]
766    /// ([`HighlightMode::Prefix`]), and `hit` one of the hits that search
767    /// returned. `source` is the string the caller inserted for the term: for
768    /// a trie from [`Trie::build_normalized`], the entry
769    /// `items[trie.input_index(hit.id)]`; for a trie from [`Trie::build`],
770    /// the term itself ([`Trie::term`]`(hit.id)`). It must normalise, with
771    /// the trie's normaliser if it has one, to the hit's term.
772    ///
773    /// The alignment is recomputed on the full matrix and walked back; its
774    /// cost always equals `hit.cost` ([`Highlight::cost`]). The search itself
775    /// does no highlighting work: call this only for the hits you show. The
776    /// work per hit is at most `(m + 1) * (m + 9)` matrix cells for a query
777    /// of `m` symbols ([`Highlight::cells`]), plus a pass over `source`.
778    ///
779    /// # Errors
780    ///
781    /// [`HighlightError::QueryTooLong`] as for the search,
782    /// [`HighlightError::UnknownTerm`] if `hit.id` is not a term of `trie`,
783    /// [`HighlightError::SourceMismatch`] if `source` does not normalise to
784    /// the term, [`HighlightError::CostMismatch`] if the alignment does not
785    /// cost `hit.cost` (a hit of another query, cost model or mode).
786    ///
787    /// # Panics
788    ///
789    /// Never, for any input.
790    ///
791    /// # Examples
792    ///
793    /// ```
794    /// use keyhammer::cost::CostModel;
795    /// use keyhammer::highlight::HighlightMode;
796    /// use keyhammer::search::{SearchConfig, Searcher};
797    /// use keyhammer::trie::Trie;
798    ///
799    /// let trie = Trie::build(&[("javascript", 10), ("java", 30)]).unwrap();
800    /// let cm = CostModel::qwerty();
801    /// let mut s = Searcher::new();
802    /// let cfg = SearchConfig::default();
803    ///
804    /// // "javasript" skips the "c": everything else is highlighted.
805    /// let out = s.search(&trie, &cm, b"javasript", &cfg).unwrap();
806    /// let hit = &out.hits[0];
807    /// let term = trie.term(hit.id);
808    /// let h = s
809    ///     .highlight(&trie, &cm, b"javasript", hit, term, HighlightMode::Whole)
810    ///     .unwrap();
811    /// let spans: Vec<&str> = h.ranges.iter().map(|r| &term[r.utf8.clone()]).collect();
812    /// assert_eq!(spans, ["javas", "ript"]);
813    /// assert_eq!(h.cost, hit.cost);
814    ///
815    /// // Prefix mode: only the typed prefix is highlighted.
816    /// let out = s.search_prefix(&trie, &cm, b"jav", &cfg).unwrap();
817    /// for hit in &out.hits {
818    ///     let term = trie.term(hit.id);
819    ///     let h = s
820    ///         .highlight(&trie, &cm, b"jav", hit, term, HighlightMode::Prefix)
821    ///         .unwrap();
822    ///     assert_eq!(h.ranges[0].chars, 0..3);
823    ///     assert_eq!(h.aligned.chars, 0..3);
824    /// }
825    /// ```
826    pub fn highlight(
827        &mut self,
828        trie: &Trie,
829        cm: &CostModel,
830        q: &[u8],
831        hit: &Hit,
832        source: &str,
833        mode: HighlightMode,
834    ) -> Result<Highlight, HighlightError> {
835        let len = text::decode(q, &mut self.qsym, MAX_QUERY_LEN);
836        self.run_highlight(trie, cm, len, hit, source, mode)
837    }
838
839    /// [`Searcher::highlight`] for a hit of [`Searcher::search_text`]
840    /// ([`HighlightMode::Whole`]) or [`Searcher::search_prefix_text`]
841    /// ([`HighlightMode::Prefix`]): `q` is normalised with the trie's
842    /// normaliser first, as those searches do.
843    ///
844    /// # Errors
845    ///
846    /// As for [`Searcher::highlight`].
847    ///
848    /// # Panics
849    ///
850    /// Never, for any input.
851    ///
852    /// # Examples
853    ///
854    /// ```
855    /// use keyhammer::cost::CostModel;
856    /// use keyhammer::highlight::HighlightMode;
857    /// use keyhammer::search::{SearchConfig, Searcher};
858    /// use keyhammer::text::Normalizer;
859    /// use keyhammer::trie::Trie;
860    ///
861    /// let items = [("Straße", 1)];
862    /// let trie = Trie::build_normalized(&items, &Normalizer::new()).unwrap();
863    /// let cm = CostModel::qwerty();
864    /// let mut s = Searcher::new();
865    /// let out = s.search_text(&trie, &cm, "STRASE", &SearchConfig::default()).unwrap();
866    /// let hit = &out.hits[0];
867    /// let h = s
868    ///     .highlight_text(&trie, &cm, "STRASE", hit, items[0].0, HighlightMode::Whole)
869    ///     .unwrap();
870    /// // One of the two "s" of "ß" was typed: "ß" counts as typed.
871    /// assert_eq!(h.ranges.len(), 1);
872    /// assert_eq!(h.ranges[0].chars, 0..6);
873    /// assert_eq!(h.ranges[0].utf8, 0..7);
874    /// ```
875    pub fn highlight_text(
876        &mut self,
877        trie: &Trie,
878        cm: &CostModel,
879        q: &str,
880        hit: &Hit,
881        source: &str,
882        mode: HighlightMode,
883    ) -> Result<Highlight, HighlightError> {
884        let len = self.load_text(trie, q);
885        self.run_highlight(trie, cm, len, hit, source, mode)
886    }
887
888    /// [`Searcher::highlight`] on the `len` symbols in `self.qsym`.
889    fn run_highlight(
890        &mut self,
891        trie: &Trie,
892        cm: &CostModel,
893        len: usize,
894        hit: &Hit,
895        source: &str,
896        mode: HighlightMode,
897    ) -> Result<Highlight, HighlightError> {
898        if len > MAX_QUERY_LEN {
899            return Err(HighlightError::QueryTooLong {
900                len,
901                max: MAX_QUERY_LEN,
902            });
903        }
904        highlight::run(&mut self.hl, trie, cm, &self.qsym, hit, source, mode)
905    }
906
907    /// [`Searcher::search`] on the `len` symbols in `self.qsym`.
908    fn run(
909        &mut self,
910        trie: &Trie,
911        cm: &CostModel,
912        len: usize,
913        cfg: &SearchConfig,
914    ) -> Result<Output, SearchError> {
915        let w = Self::check(len, cm, cfg)?;
916        let mut out = Output {
917            hits: Vec::new(),
918            stats: Stats::default(),
919        };
920        if cfg.k == 0 {
921            return Ok(out);
922        }
923        // Moved out for the duration of the search so that the expander can
924        // borrow them while entries are pushed; put back below.
925        let qsym = core::mem::take(&mut self.qsym);
926        let q = qsym.as_slice();
927        self.prepare(q);
928        let qmask = core::mem::take(&mut self.qmask);
929        let ex = Expander {
930            trie,
931            cm,
932            q,
933            qmask: &qmask,
934            w,
935            budget: cfg.budget,
936            tsb: cfg.tsb,
937            prefix: false,
938        };
939        let rank = |c: Cost| cfg.ranking.rank(c);
940        let (root_cur, lb) = ex.root();
941        out.stats.rows_computed = 1;
942        if lb <= cfg.budget {
943            self.push(
944                pack(rank(lb), trie.max_weight(0), 0),
945                0,
946                NO_TERM,
947                0,
948                0,
949                root_cur,
950                [INF; ROW],
951            );
952        }
953
954        while let Some(e) = self.heap.pop() {
955            if e.term != NO_TERM {
956                out.hits.push(Hit {
957                    id: e.term,
958                    cost: e.cost,
959                    weight: trie.weight(e.term),
960                });
961                if out.hits.len() == cfg.k {
962                    break;
963                }
964                continue;
965            }
966            if out.stats.nodes_expanded >= cfg.max_nodes {
967                out.stats.truncated = true;
968                break;
969            }
970            out.stats.nodes_expanded += 1;
971            let v = e.node as usize;
972            let depth = usize::from(e.depth);
973
974            if let Some((tid, cost)) = ex.terminal(v, depth, &e.cur) {
975                let key = pack(rank(cost), trie.weight(tid), tid);
976                self.push(key, e.node, tid, cost, e.depth, [INF; ROW], [INF; ROW]);
977            }
978
979            for c in trie.children(v) {
980                let (row, lb) = ex.child(v, depth, c, &e.cur, &e.prev);
981                out.stats.rows_computed += 1;
982                if lb <= cfg.budget {
983                    self.push(
984                        pack(rank(lb), trie.max_weight(c), 0),
985                        c as u32,
986                        NO_TERM,
987                        0,
988                        e.depth + 1,
989                        row,
990                        e.cur,
991                    );
992                }
993            }
994        }
995        self.qmask = qmask;
996        self.qsym = qsym;
997        out.stats.nodes_pushed = self.seq as usize;
998        Ok(out)
999    }
1000
1001    /// Prefix (autocomplete) search: returns the `cfg.k` best terms that
1002    /// *start with* something within `cfg.budget` of the query, best first.
1003    ///
1004    /// The cost of a term is the smallest weighted edit cost between the whole
1005    /// query and any prefix of the term (the empty prefix included), so the
1006    /// typed text may have typos while the rest of the term is free. The order
1007    /// is the one of [`Searcher::search`]: [`Ranking`] cost, then higher
1008    /// weight, then lower term id, and [`Hit::cost`] is that exact prefix cost.
1009    /// A hit is the full term; the length of the matched prefix is not
1010    /// reported. An empty query matches every term at cost 0, so the result is
1011    /// the `k` heaviest terms.
1012    ///
1013    /// Configuration fields keep their meaning: `budget` bounds the prefix
1014    /// cost, `tsb` selects the subtree bound (results never change with it),
1015    /// `max_nodes` limits expanded trie nodes, including nodes expanded
1016    /// without a row (see `docs/design/prefix-mode.md`); `Stats::truncated`
1017    /// tells if it stopped the search, and the hits returned are then still
1018    /// the first ones of the exact order.
1019    ///
1020    /// `q` is read as for [`Searcher::search`].
1021    ///
1022    /// ```
1023    /// use keyhammer::cost::CostModel;
1024    /// use keyhammer::search::{SearchConfig, Searcher};
1025    /// use keyhammer::trie::Trie;
1026    ///
1027    /// let trie = Trie::build(&[("javascript", 10), ("java", 30), ("python", 50)]).unwrap();
1028    /// let mut s = Searcher::new();
1029    /// // "javs" types "s" for "a" (neighbouring keys); "java" starts both terms.
1030    /// let out = s
1031    ///     .search_prefix(&trie, &CostModel::qwerty(), b"javs", &SearchConfig::default())
1032    ///     .unwrap();
1033    /// let terms: Vec<&str> = out.hits.iter().map(|h| trie.term(h.id)).collect();
1034    /// assert_eq!(terms, ["java", "javascript"]);
1035    /// ```
1036    pub fn search_prefix(
1037        &mut self,
1038        trie: &Trie,
1039        cm: &CostModel,
1040        q: &[u8],
1041        cfg: &SearchConfig,
1042    ) -> Result<Output, SearchError> {
1043        let len = text::decode(q, &mut self.qsym, MAX_QUERY_LEN);
1044        self.run_prefix(trie, cm, len, cfg)
1045    }
1046
1047    /// [`Searcher::search_prefix`] on the `len` symbols in `self.qsym`.
1048    fn run_prefix(
1049        &mut self,
1050        trie: &Trie,
1051        cm: &CostModel,
1052        len: usize,
1053        cfg: &SearchConfig,
1054    ) -> Result<Output, SearchError> {
1055        let w = Self::check(len, cm, cfg)?;
1056        let mut out = Output {
1057            hits: Vec::new(),
1058            stats: Stats::default(),
1059        };
1060        if cfg.k == 0 {
1061            return Ok(out);
1062        }
1063        let qsym = core::mem::take(&mut self.qsym);
1064        let q = qsym.as_slice();
1065        self.prepare(q);
1066        let qmask = core::mem::take(&mut self.qmask);
1067        let ex = Expander {
1068            trie,
1069            cm,
1070            q,
1071            qmask: &qmask,
1072            w,
1073            budget: cfg.budget,
1074            tsb: cfg.tsb,
1075            prefix: true,
1076        };
1077        let rank = |c: Cost| cfg.ranking.rank(c);
1078        // `acc` is the best cost of a prefix ending at or above a node, `lbd`
1079        // the bound on the prefixes strictly below it; every term below costs
1080        // at least `min(acc, lbd)`, and exactly `acc` when `lbd >= acc`.
1081        let (root_cur, lbd) = ex.root();
1082        out.stats.rows_computed = 1;
1083        let acc = ex.prefix_cell(0, &root_cur);
1084        let lb = acc.min(lbd);
1085        if lb <= cfg.budget {
1086            if acc < INF && lbd >= acc {
1087                self.push_settled(pack(rank(acc), trie.max_weight(0), 0), 0, NO_TERM, acc, 0);
1088            } else {
1089                self.push(
1090                    pack(rank(lb), trie.max_weight(0), 0),
1091                    0,
1092                    NO_TERM,
1093                    acc,
1094                    0,
1095                    root_cur,
1096                    [INF; ROW],
1097                );
1098            }
1099        }
1100
1101        while let Some(e) = self.heap.pop() {
1102            if e.term != NO_TERM {
1103                out.hits.push(Hit {
1104                    id: e.term,
1105                    cost: e.cost,
1106                    weight: trie.weight(e.term),
1107                });
1108                if out.hits.len() == cfg.k {
1109                    break;
1110                }
1111                continue;
1112            }
1113            if out.stats.nodes_expanded >= cfg.max_nodes {
1114                out.stats.truncated = true;
1115                break;
1116            }
1117            out.stats.nodes_expanded += 1;
1118            let v = e.node as usize;
1119            let depth = usize::from(e.depth);
1120            let acc = e.cost;
1121
1122            // A term ending here has had all its prefixes looked at.
1123            let tid = trie.term_id(v);
1124            if tid != NO_TERM && acc <= cfg.budget {
1125                let key = pack(rank(acc), trie.weight(tid), tid);
1126                self.push_settled(key, e.node, tid, acc, e.depth);
1127            }
1128
1129            for c in trie.children(v) {
1130                if e.settled {
1131                    self.push_settled(
1132                        pack(rank(acc), trie.max_weight(c), 0),
1133                        c as u32,
1134                        NO_TERM,
1135                        acc,
1136                        e.depth + 1,
1137                    );
1138                    continue;
1139                }
1140                let (row, lbd) = ex.child(v, depth, c, &e.cur, &e.prev);
1141                out.stats.rows_computed += 1;
1142                let acc_c = acc.min(ex.prefix_cell(depth + 1, &row));
1143                let lb = acc_c.min(lbd);
1144                if lb > cfg.budget {
1145                    continue;
1146                }
1147                if acc_c < INF && lbd >= acc_c {
1148                    self.push_settled(
1149                        pack(rank(acc_c), trie.max_weight(c), 0),
1150                        c as u32,
1151                        NO_TERM,
1152                        acc_c,
1153                        e.depth + 1,
1154                    );
1155                } else {
1156                    self.push(
1157                        pack(rank(lb), trie.max_weight(c), 0),
1158                        c as u32,
1159                        NO_TERM,
1160                        acc_c,
1161                        e.depth + 1,
1162                        row,
1163                        e.cur,
1164                    );
1165                }
1166            }
1167        }
1168        self.qmask = qmask;
1169        self.qsym = qsym;
1170        out.stats.nodes_pushed = self.seq as usize;
1171        Ok(out)
1172    }
1173}
1174
1175/// Direct checks of [`lower_bound`] against a brute-force oracle.
1176///
1177/// For every node of random small tries (not only the nodes the search pops),
1178/// the bound computed through [`Expander`] must not exceed the exact cost of
1179/// any term at or below the node whose cost is within the budget, the node's
1180/// queue key must not exceed the key of any such term under either
1181/// [`Ranking`], and the cost read at a terminal must be the exact cost.
1182#[cfg(test)]
1183mod tests {
1184    use super::*;
1185    use crate::cost::Layout;
1186    use alloc::string::String;
1187    use alloc::vec;
1188    use alloc::vec::Vec;
1189
1190    /// Small deterministic xorshift generator.
1191    struct Rng(u64);
1192
1193    impl Rng {
1194        fn new(seed: u64) -> Self {
1195            Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
1196        }
1197        fn below(&mut self, n: usize) -> usize {
1198            self.0 ^= self.0 << 13;
1199            self.0 ^= self.0 >> 7;
1200            self.0 ^= self.0 << 17;
1201            (self.0 % n as u64) as usize
1202        }
1203    }
1204
1205    /// The symbols of a term.
1206    fn syms(s: &str) -> Vec<u32> {
1207        s.chars().map(u32::from).collect()
1208    }
1209
1210    /// Naive full-matrix weighted OSA cost, independent of the banded rows.
1211    /// `d[j][i]` aligns the term prefix `t[..j]` with the query prefix `q[..i]`.
1212    fn oracle(cm: &CostModel, q: &[u32], t: &[u32]) -> u32 {
1213        oracle_matrix(cm, q, t)[t.len()][q.len()]
1214    }
1215
1216    fn oracle_matrix(cm: &CostModel, q: &[u32], t: &[u32]) -> Vec<Vec<u32>> {
1217        const BIG: u32 = 1_000_000;
1218        let (m, n) = (q.len(), t.len());
1219        let mut d = vec![vec![BIG; m + 1]; n + 1];
1220        d[0][0] = 0;
1221        for j in 0..=n {
1222            for i in 0..=m {
1223                let mut best = d[j][i];
1224                if i >= 1 && j >= 1 {
1225                    best = best
1226                        .min(d[j - 1][i - 1] + u32::from(cm.sub_cost(q[i - 1], t[j - 1], i - 1)));
1227                }
1228                if j >= 1 {
1229                    let prev_t = if j >= 2 { Some(t[j - 2]) } else { None };
1230                    best = best.min(d[j - 1][i] + u32::from(cm.ins_cost(t[j - 1], prev_t, i)));
1231                }
1232                if i >= 1 {
1233                    best = best.min(d[j][i - 1] + u32::from(cm.del_cost(q, i - 1)));
1234                }
1235                if i >= 2
1236                    && j >= 2
1237                    && q[i - 1] == t[j - 2]
1238                    && q[i - 2] == t[j - 1]
1239                    && q[i - 1] != q[i - 2]
1240                {
1241                    best = best.min(d[j - 2][i - 2] + u32::from(cm.transpose_cost(i - 2)));
1242                }
1243                d[j][i] = best;
1244            }
1245        }
1246        d
1247    }
1248
1249    fn random_word(rng: &mut Rng, alpha: &[u32], max_len: usize) -> Vec<u32> {
1250        let len = 1 + rng.below(max_len);
1251        (0..len).map(|_| alpha[rng.below(alpha.len())]).collect()
1252    }
1253
1254    /// Up to `ops` random substitutions, insertions, deletions and swaps.
1255    fn mutate(rng: &mut Rng, word: &[u32], alpha: &[u32], ops: usize) -> Vec<u32> {
1256        let mut w = word.to_vec();
1257        for _ in 0..ops {
1258            let letter = alpha[rng.below(alpha.len())];
1259            match rng.below(4) {
1260                0 if !w.is_empty() => {
1261                    let i = rng.below(w.len());
1262                    w[i] = letter;
1263                }
1264                1 => {
1265                    let i = rng.below(w.len() + 1);
1266                    w.insert(i, letter);
1267                }
1268                2 if !w.is_empty() => {
1269                    let i = rng.below(w.len());
1270                    w.remove(i);
1271                }
1272                3 if w.len() > 1 => {
1273                    let i = rng.below(w.len() - 1);
1274                    w.swap(i, i + 1);
1275                }
1276                _ => {}
1277            }
1278        }
1279        w
1280    }
1281
1282    /// A dictionary of 1 to 24 entries of length 1 to 9 with duplicates,
1283    /// prefix chains and extensions of earlier entries.
1284    fn random_dictionary(rng: &mut Rng, alpha: &[u32]) -> Vec<(String, u16)> {
1285        let size = 1 + rng.below(24);
1286        let mut words: Vec<Vec<u32>> = Vec::new();
1287        while words.len() < size {
1288            let w = match (rng.below(6), words.len()) {
1289                (0, n) if n > 0 => words[rng.below(n)].clone(),
1290                (1, n) if n > 0 => {
1291                    let base = &words[rng.below(n)];
1292                    base[..1 + rng.below(base.len())].to_vec()
1293                }
1294                (2, n) if n > 0 => {
1295                    let mut w = words[rng.below(n)].clone();
1296                    for _ in 0..1 + rng.below(3) {
1297                        if w.len() < 9 {
1298                            w.push(alpha[rng.below(alpha.len())]);
1299                        }
1300                    }
1301                    w
1302                }
1303                _ => random_word(rng, alpha, 9),
1304            };
1305            words.push(w);
1306        }
1307        words
1308            .into_iter()
1309            .map(|w| {
1310                let weight = if rng.below(3) == 0 {
1311                    100
1312                } else {
1313                    rng.below(65_536) as u16
1314                };
1315                let s = w.iter().filter_map(|&c| char::from_u32(c)).collect();
1316                (s, weight)
1317            })
1318            .collect()
1319    }
1320
1321    #[derive(Default)]
1322    struct Coverage {
1323        instances: usize,
1324        nodes: usize,
1325        considered: usize,
1326        bounded: usize,
1327        terminals: usize,
1328        terminals_within: usize,
1329        settled: usize,
1330    }
1331
1332    /// Walks every node of `trie` for one query and checks the bound, the
1333    /// keys and the terminal costs.
1334    fn check_instance(
1335        trie: &Trie,
1336        cm: &CostModel,
1337        q: &[u32],
1338        budget: Cost,
1339        tsb: bool,
1340        cov: &mut Coverage,
1341    ) {
1342        let w = usize::from(budget / cm.c_indel_min());
1343        let m = q.len();
1344        let mut qmask = vec![0u64; m + 1];
1345        for i in (0..m).rev() {
1346            qmask[i] = qmask[i + 1] | symbol_class(q[i]);
1347        }
1348        let ex = Expander {
1349            trie,
1350            cm,
1351            q,
1352            qmask: &qmask,
1353            w,
1354            budget,
1355            tsb,
1356            prefix: false,
1357        };
1358        let rankings = [Ranking::Coarse, Ranking::Exact];
1359
1360        // Exact cost of every term, and per node the smallest within-budget
1361        // cost and the smallest term key at or below it. Children always have
1362        // larger indices than their parent.
1363        let costs: Vec<u32> = (0..trie.len() as u32)
1364            .map(|id| oracle(cm, q, &syms(trie.term(id))))
1365            .collect();
1366        let n = trie.node_count();
1367        let mut best = vec![u32::MAX; n];
1368        let mut best_key = [vec![u64::MAX; n], vec![u64::MAX; n]];
1369        for v in (0..n).rev() {
1370            let tid = trie.term_id(v);
1371            if tid != NO_TERM && costs[tid as usize] <= u32::from(budget) {
1372                let c = costs[tid as usize] as Cost;
1373                best[v] = u32::from(c);
1374                for (r, keys) in rankings.iter().zip(best_key.iter_mut()) {
1375                    keys[v] = pack(r.rank(c), trie.weight(tid), tid);
1376                }
1377            }
1378            for c in trie.children(v) {
1379                best[v] = best[v].min(best[c]);
1380                for keys in &mut best_key {
1381                    keys[v] = keys[v].min(keys[c]);
1382                }
1383            }
1384        }
1385
1386        let check_node = |v: usize, lb: Cost| {
1387            if best[v] != u32::MAX {
1388                assert!(
1389                    u32::from(lb) <= best[v],
1390                    "bound {lb} above the cost {} of a term below node {v} \
1391                     (query {q:?}, budget {budget}, tsb {tsb})",
1392                    best[v],
1393                );
1394                for (r, keys) in rankings.iter().zip(best_key.iter()) {
1395                    assert!(pack(r.rank(lb), trie.max_weight(v), 0) <= keys[v]);
1396                }
1397            }
1398        };
1399
1400        cov.instances += 1;
1401        let (root, lb) = ex.root();
1402        check_node(0, lb);
1403        cov.nodes += 1;
1404        cov.considered += 1;
1405        cov.bounded += usize::from(best[0] != u32::MAX);
1406        // (node, depth, row, parent row, whether the search would push it)
1407        let mut stack = vec![(0usize, 0usize, root, [INF; ROW], lb <= budget)];
1408        while let Some((v, depth, cur, prev, pushed)) = stack.pop() {
1409            let tid = trie.term_id(v);
1410            if tid != NO_TERM {
1411                let c = costs[tid as usize];
1412                cov.terminals += 1;
1413                if c <= u32::from(budget) {
1414                    cov.terminals_within += 1;
1415                    let want = Some((tid, c as Cost));
1416                    assert_eq!(ex.terminal(v, depth, &cur), want, "query {q:?}");
1417                } else {
1418                    assert_eq!(ex.terminal(v, depth, &cur), None, "query {q:?}");
1419                }
1420            }
1421            for c in trie.children(v) {
1422                let (row, lb) = ex.child(v, depth, c, &cur, &prev);
1423                check_node(c, lb);
1424                cov.nodes += 1;
1425                cov.considered += usize::from(pushed);
1426                cov.bounded += usize::from(best[c] != u32::MAX);
1427                stack.push((c, depth + 1, row, cur, pushed && lb <= budget));
1428            }
1429        }
1430    }
1431
1432    /// The alphabets of the property tests: ASCII ones (in the last, '!' and
1433    /// '"' share the classes of 'a' and 'b').
1434    const ASCII_ALPHABETS: [&str; 4] = ["aqw", "asdfqwer", "abcdefghijklmnopqrstuvwxyz", "ab!\""];
1435
1436    /// Non-ASCII alphabets: Latin-1 letters (narrow labels), `à` and `Ć`
1437    /// (U+00E0 and U+0106, whose classes collide), letters of other scripts
1438    /// and an emoji (wide labels), and ç with its ABNT2 neighbours.
1439    const OTHER_ALPHABETS: [&str; 4] = ["aeéèçß", "aàĆc", "жзaЖ😀", "çlp.;"];
1440
1441    fn check_mode(tsb: bool) -> Coverage {
1442        check_mode_on(&ASCII_ALPHABETS, tsb)
1443    }
1444
1445    fn check_mode_on(alphabets: &[&str], tsb: bool) -> Coverage {
1446        let mut cov = Coverage::default();
1447        for (a, alpha) in alphabets.iter().enumerate() {
1448            let alpha = &syms(alpha);
1449            for d in 0..150 {
1450                // Every layout in turn: the bound uses the model's minimum
1451                // costs, which must hold for any of them.
1452                let layouts = Layout::ALL;
1453                let cm = CostModel::for_layout(layouts[d as usize % layouts.len()]);
1454                let mut rng = Rng::new((a as u64 + 1) * 1000 + d);
1455                let dict = random_dictionary(&mut rng, alpha);
1456                let items: Vec<(&str, u16)> = dict.iter().map(|(s, w)| (s.as_str(), *w)).collect();
1457                let trie = Trie::build(&items).unwrap_or_else(|e| panic!("{e}"));
1458                for _ in 0..8 {
1459                    let q = if rng.below(4) == 0 {
1460                        let len = rng.below(11);
1461                        (0..len).map(|_| alpha[rng.below(alpha.len())]).collect()
1462                    } else {
1463                        let base = &syms(&dict[rng.below(dict.len())].0);
1464                        let ops = rng.below(4);
1465                        mutate(&mut rng, base, alpha, ops)
1466                    };
1467                    // Band half-widths W = 0 to 6 and 8, with budgets that are not
1468                    // multiples of 16.
1469                    for budget in [7, 15, 16, 24, 31, 32, 40, 48, 64] {
1470                        check_instance(&trie, &cm, &q, budget, tsb, &mut cov);
1471                    }
1472                }
1473            }
1474        }
1475        std::eprintln!(
1476            "tsb {tsb}: {} instances, {} nodes checked ({} considered by the search, \
1477             {} with a within-budget term below), {} terminals ({} within budget)",
1478            cov.instances,
1479            cov.nodes,
1480            cov.considered,
1481            cov.bounded,
1482            cov.terminals,
1483            cov.terminals_within,
1484        );
1485        cov
1486    }
1487
1488    #[test]
1489    fn lower_bound_never_exceeds_the_oracle_without_tsb() {
1490        let cov = check_mode(false);
1491        assert!(cov.bounded > 10_000 && cov.terminals_within > 1_000);
1492    }
1493
1494    #[test]
1495    fn lower_bound_never_exceeds_the_oracle_with_tsb() {
1496        let cov = check_mode(true);
1497        assert!(cov.bounded > 10_000 && cov.terminals_within > 1_000);
1498    }
1499
1500    #[test]
1501    fn lower_bound_never_exceeds_the_oracle_on_non_ascii_alphabets() {
1502        for tsb in [false, true] {
1503            let cov = check_mode_on(&OTHER_ALPHABETS, tsb);
1504            assert!(cov.bounded > 10_000 && cov.terminals_within > 1_000);
1505        }
1506    }
1507
1508    /// `D[m][j]` for every term prefix length `j` (the last column).
1509    fn oracle_col(cm: &CostModel, q: &[u32], t: &[u32]) -> Vec<u32> {
1510        oracle_matrix(cm, q, t).iter().map(|r| r[q.len()]).collect()
1511    }
1512
1513    /// Prefix-mode counterpart of `check_instance`. For every node it walks
1514    /// the same way `search_prefix` does (carrying `acc`) and checks
1515    ///
1516    /// - `lbd` (the bound on prefixes strictly longer than the node's) is at
1517    ///   most `D[m][j']` for every term below and every `j' > depth`, when
1518    ///   that is within the budget;
1519    /// - `min(acc, lbd)` and its queue key are at most the prefix cost and key
1520    ///   of every within-budget term below;
1521    /// - a settled node (`acc < INF && lbd >= acc`) has every term below at
1522    ///   exactly cost `acc`;
1523    /// - at a terminal the cost `acc` equals the oracle prefix cost when that
1524    ///   is within budget, and no cost is reported otherwise.
1525    fn check_prefix_instance(
1526        trie: &Trie,
1527        cm: &CostModel,
1528        q: &[u32],
1529        budget: Cost,
1530        tsb: bool,
1531        cov: &mut Coverage,
1532    ) {
1533        let w = usize::from(budget / cm.c_indel_min());
1534        let m = q.len();
1535        let mut qmask = vec![0u64; m + 1];
1536        for i in (0..m).rev() {
1537            qmask[i] = qmask[i + 1] | symbol_class(q[i]);
1538        }
1539        let ex = Expander {
1540            trie,
1541            cm,
1542            q,
1543            qmask: &qmask,
1544            w,
1545            budget,
1546            tsb,
1547            prefix: true,
1548        };
1549        let cols: Vec<Vec<u32>> = (0..trie.len() as u32)
1550            .map(|id| oracle_col(cm, q, &syms(trie.term(id))))
1551            .collect();
1552        let pcost: Vec<u32> = cols
1553            .iter()
1554            .map(|c| c.iter().copied().min().unwrap_or(u32::MAX))
1555            .collect();
1556        let n = trie.node_count();
1557        // Terms at or below each node.
1558        let mut below: Vec<Vec<u32>> = vec![Vec::new(); n];
1559        for v in (0..n).rev() {
1560            let tid = trie.term_id(v);
1561            if tid != NO_TERM {
1562                below[v].push(tid);
1563            }
1564            for c in trie.children(v) {
1565                let kids = below[c].clone();
1566                below[v].extend(kids);
1567            }
1568        }
1569        let rankings = [Ranking::Coarse, Ranking::Exact];
1570        let b32 = u32::from(budget);
1571
1572        let mut check_node = |v: usize, depth: usize, acc: Cost, lbd: Cost| {
1573            let mut best = u32::MAX;
1574            let mut deeper = u32::MAX;
1575            for &t in &below[v] {
1576                let col = &cols[t as usize];
1577                let tail = &col[(depth + 1).min(col.len())..];
1578                deeper = deeper.min(tail.iter().copied().min().unwrap_or(u32::MAX));
1579                best = best.min(pcost[t as usize]);
1580                if pcost[t as usize] <= b32 {
1581                    let c = pcost[t as usize] as Cost;
1582                    for r in rankings {
1583                        let tk = pack(r.rank(c), trie.weight(t), t);
1584                        let nk = pack(r.rank(acc.min(lbd)), trie.max_weight(v), 0);
1585                        assert!(nk <= tk, "key above a term below node {v}, q {q:?}");
1586                    }
1587                }
1588            }
1589            if deeper <= b32 {
1590                assert!(
1591                    u32::from(lbd) <= deeper,
1592                    "deeper bound {lbd} above {deeper} below node {v} at depth {depth} \
1593                     (query {q:?}, budget {budget}, tsb {tsb})",
1594                );
1595            }
1596            if best <= b32 {
1597                assert!(
1598                    u32::from(acc.min(lbd)) <= best,
1599                    "min(acc, lbd) above {best}"
1600                );
1601            }
1602            if acc < INF && lbd >= acc {
1603                cov.settled += 1;
1604                for &t in &below[v] {
1605                    assert_eq!(
1606                        pcost[t as usize],
1607                        u32::from(acc),
1608                        "settled node {v} has a term of another cost (query {q:?}, budget {budget})"
1609                    );
1610                }
1611            }
1612            best <= b32
1613        };
1614
1615        cov.instances += 1;
1616        let (root, lbd) = ex.root();
1617        let acc = ex.prefix_cell(0, &root);
1618        let b = check_node(0, 0, acc, lbd);
1619        cov.nodes += 1;
1620        cov.bounded += usize::from(b);
1621        let mut stack = vec![(0usize, 0usize, root, [INF; ROW], acc)];
1622        while let Some((v, depth, cur, prev, acc)) = stack.pop() {
1623            let tid = trie.term_id(v);
1624            if tid != NO_TERM {
1625                cov.terminals += 1;
1626                let c = pcost[tid as usize];
1627                if c <= b32 {
1628                    cov.terminals_within += 1;
1629                    assert_eq!(u32::from(acc), c, "terminal cost, query {q:?}");
1630                } else {
1631                    assert!(acc > budget, "terminal reported beyond the budget");
1632                }
1633            }
1634            for c in trie.children(v) {
1635                let (row, lbd) = ex.child(v, depth, c, &cur, &prev);
1636                let acc_c = acc.min(ex.prefix_cell(depth + 1, &row));
1637                let b = check_node(c, depth + 1, acc_c, lbd);
1638                cov.nodes += 1;
1639                cov.bounded += usize::from(b);
1640                stack.push((c, depth + 1, row, cur, acc_c));
1641            }
1642        }
1643    }
1644
1645    fn check_prefix_mode(tsb: bool) -> Coverage {
1646        check_prefix_mode_on(&ASCII_ALPHABETS, tsb, false)
1647    }
1648
1649    /// With `all_layouts`, dictionary `d` uses layout `d % 6` (the ASCII run
1650    /// keeps QWERTY, so that the counts quoted in `prefix-mode.md` hold).
1651    fn check_prefix_mode_on(alphabets: &[&str], tsb: bool, all_layouts: bool) -> Coverage {
1652        let mut cov = Coverage::default();
1653        for (a, alpha) in alphabets.iter().enumerate() {
1654            let alpha = &syms(alpha);
1655            for d in 0..150 {
1656                let cm = if all_layouts {
1657                    CostModel::for_layout(Layout::ALL[d as usize % Layout::ALL.len()])
1658                } else {
1659                    CostModel::qwerty()
1660                };
1661                let mut rng = Rng::new((a as u64 + 1) * 1000 + d);
1662                let dict = random_dictionary(&mut rng, alpha);
1663                let items: Vec<(&str, u16)> = dict.iter().map(|(s, w)| (s.as_str(), *w)).collect();
1664                let trie = Trie::build(&items).unwrap_or_else(|e| panic!("{e}"));
1665                for _ in 0..8 {
1666                    let q = if rng.below(4) == 0 {
1667                        let len = rng.below(11);
1668                        (0..len).map(|_| alpha[rng.below(alpha.len())]).collect()
1669                    } else {
1670                        // A truncated dictionary entry with 0 to 3 edits.
1671                        let base = &syms(&dict[rng.below(dict.len())].0);
1672                        let cut = 1 + rng.below(base.len());
1673                        let ops = rng.below(4);
1674                        mutate(&mut rng, &base[..cut], alpha, ops)
1675                    };
1676                    for budget in [7, 15, 16, 24, 31, 32, 40, 48, 64] {
1677                        check_prefix_instance(&trie, &cm, &q, budget, tsb, &mut cov);
1678                    }
1679                }
1680            }
1681        }
1682        std::eprintln!(
1683            "prefix, tsb {tsb}: {} instances, {} nodes checked ({} with a within-budget \
1684             term below, {} settled), {} terminals ({} within budget)",
1685            cov.instances,
1686            cov.nodes,
1687            cov.bounded,
1688            cov.settled,
1689            cov.terminals,
1690            cov.terminals_within,
1691        );
1692        cov
1693    }
1694
1695    #[test]
1696    fn prefix_bound_never_exceeds_the_oracle_without_tsb() {
1697        let cov = check_prefix_mode(false);
1698        assert!(cov.bounded > 10_000 && cov.terminals_within > 1_000 && cov.settled > 1_000);
1699    }
1700
1701    #[test]
1702    fn prefix_bound_never_exceeds_the_oracle_with_tsb() {
1703        let cov = check_prefix_mode(true);
1704        assert!(cov.bounded > 10_000 && cov.terminals_within > 1_000 && cov.settled > 1_000);
1705    }
1706
1707    #[test]
1708    fn prefix_bound_never_exceeds_the_oracle_on_non_ascii_alphabets() {
1709        for tsb in [false, true] {
1710            let cov = check_prefix_mode_on(&OTHER_ALPHABETS, tsb, true);
1711            assert!(cov.bounded > 10_000 && cov.terminals_within > 1_000 && cov.settled > 1_000);
1712        }
1713    }
1714}