Interactive request/response
One input, an answer now. A search box, an API endpoint, an editor plugin.
Priorities: latency, ergonomics, predictability. Non-priority: allocation count. You are about to do a syscall.
The shape
use verbora_normalizers::remove_diacritics;
use verbora_tokenizers::{AggressiveTokenizer, Tokenize};
/// Prepare a user's query for lookup: fold accents, split, lowercase.
fn prepare_query(raw: &str) -> Vec<String> {
let folded = remove_diacritics(raw); // Cow: free if unaccented
let tokenizer = AggressiveTokenizer::new(); // zero-sized
tokenizer
.tokens(&folded)
.map(str::to_lowercase)
.collect()
}
assert_eq!(prepare_query("Café RESTAURANT"), ["cafe", "restaurant"]);Nothing clever. tokenize() would have been just as good; tokens().map().collect() is here because the map needs to happen anyway, so there is no reason to materialise twice.
Why not the performance APIs
A request handler runs once per request. tokenize_into would need a buffer that outlives the request — which means either a thread_local!, a pool, or passing it down your call stack. All three are real complexity, and the thing you save is one allocation against a request that already cost a TCP round trip.
OrthographyTokenizer, a SentenceTokenizer::with_abbreviations list, and above all a Trie — build those once at startup, share them, and keep them out of the hot path. Startup vs per-request
use verbora_trie::Trie;
/// Built once. `Trie` is `Send + Sync`, so an `Arc<Trie>` serves every request.
fn build_index(words: &[&str]) -> Trie {
let mut trie = Trie::new();
trie.reserve(words.len()); // one growth instead of several
trie.add_strings(words.iter().copied());
trie
}
let index = build_index(&["rust", "rustic", "rusty", "ruse"]);
// Per request: a query against a shared, immutable structure.
assert_eq!(index.keys_with_prefix("rust"), ["rust", "rustic", "rusty"]);| Build once, at startup | Build per request |
|---|---|
Trie | tokenizer values (they are zero-sized) |
OrthographyTokenizer::new(lang) | SoundEx, Metaphone, DoubleMetaphone, SoundExDM |
RegexpTokenizer / Pattern (compiled regex) | levenshtein::Options (a Copy struct) |
SentenceTokenizer::with_abbreviations(…) | NounInflector::new() if you add no rules |
StopWords::english() |
Bounding the work, not the allocations
The latency risk in an interactive path is almost never allocation. It is doing O(n) work against a corpus of size n:
use verbora_distance::{levenshtein, levenshtein::Options};
/// Rank candidates by edit distance — but only after something else has
/// reduced `candidates` to a sane size.
fn best_match<'a>(query: &str, candidates: &[&'a str]) -> Option<&'a str> {
let opts = Options::default(); // hoisted out of the loop
candidates
.iter()
.map(|c| (*c, levenshtein(query, c, &opts)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(c, _)| c)
}
assert_eq!(best_match("kitten", &["sitting", "mitten", "bitten"]), Some("mitten"));If candidates is your whole dictionary, this is the wrong shape no matter how fast levenshtein is. Narrow it first — see Fuzzy name matching.
Handling the fallible APIs
Interactive input is arbitrary, so the Result-returning APIs matter here more than anywhere else:
use verbora_phonetics::{PhoneticError, SoundEx};
let soundex = SoundEx::new();
// A token beginning with a regex metacharacter (`(`, `)`, `*`, `+`, `?`, `[`,
// `\`) makes `try_process` return an error instead of a code. It hands you
// the condition instead of panicking, so you can decide what an unusable
// query token means for your endpoint.
assert_eq!(
soundex.try_process("(hello", None),
Err(PhoneticError::InvalidInitialPattern('(')),
);
// The infallible entry point cannot fail, and is what you want for clean input.
assert_eq!(soundex.process("hello"), "H400");Same for inflectors, where the empty token is an Err(EmptyToken) rather than a panic:
use verbora_inflectors::NounInflector;
let inflector = NounInflector::new();
assert_eq!(inflector.pluralize("octopus").unwrap(), "octopi");
assert!(inflector.pluralize("").is_err());Predictability
Two things can make a latency profile spiky:
Input size you do not control. levenshtein is O(nm). A 100 kB paste into a search box is 10¹⁰ cells — even at 64 cells per bitwise word, that is hundreds of millions of operations. Cap the input length at the boundary.
Non-ASCII promotion. ASCII operands are compared as borrowed &[u8]; non-ASCII ones are promoted to Vec<u16> first. At 256 units, levenshtein/cyrillic measures 3.97 µs against ASCII's 2.13 µs — both fast, but the difference is real and data-dependent.
Checklist
- [ ] Expensive structures built at startup, shared behind
Arc - [ ]
OptionsandStopWordshoisted out of loops - [ ] Input length capped before it reaches an
O(nm)metric - [ ] Candidate sets narrowed before ranking
- [ ]
try_*/Resultpaths handled, notunwrap()ed on user input - [ ] No
thread_local!buffer pools until a profiler asks for one