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}