use rustc_hash::{FxHashMap, FxHashSet};
#[derive(Debug, Clone)]
pub struct NgramIndex {
n: usize,
index: FxHashMap<Vec<u8>, FxHashSet<usize>>,
terms: Vec<String>,
term_to_id: FxHashMap<String, usize>,
}
impl NgramIndex {
pub fn new(n: usize) -> Self {
assert!(n > 0, "N-gram size must be at least 1");
Self {
n,
index: FxHashMap::default(),
terms: Vec::new(),
term_to_id: FxHashMap::default(),
}
}
pub fn from_iter<I>(n: usize, terms: I) -> Self
where
I: IntoIterator<Item = String>,
{
let mut index = Self::new(n);
for term in terms {
index.insert(&term);
}
index
}
#[inline]
pub fn n(&self) -> usize {
self.n
}
#[inline]
pub fn len(&self) -> usize {
self.terms.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.terms.is_empty()
}
#[inline]
pub fn ngram_count(&self) -> usize {
self.index.len()
}
pub fn insert(&mut self, term: &str) -> usize {
if let Some(&id) = self.term_to_id.get(term) {
return id;
}
let id = self.terms.len();
self.terms.push(term.to_string());
self.term_to_id.insert(term.to_string(), id);
for ngram in self.compute_ngrams(term.as_bytes()) {
self.index
.entry(ngram)
.or_insert_with(FxHashSet::default)
.insert(id);
}
id
}
pub fn remove(&mut self, term: &str) -> bool {
if let Some(&id) = self.term_to_id.get(term) {
for ngram in self.compute_ngrams(term.as_bytes()) {
if let Some(ids) = self.index.get_mut(&ngram) {
ids.remove(&id);
}
}
self.term_to_id.remove(term);
self.terms[id] = String::new();
true
} else {
false
}
}
fn compute_ngrams(&self, bytes: &[u8]) -> Vec<Vec<u8>> {
if bytes.len() < self.n {
return vec![bytes.to_vec()];
}
bytes.windows(self.n).map(|w| w.to_vec()).collect()
}
pub fn find_candidates(&self, query: &str, max_distance: usize) -> Vec<&str> {
let query_ngrams: FxHashSet<Vec<u8>> =
self.compute_ngrams(query.as_bytes()).into_iter().collect();
let min_overlap = query_ngrams.len().saturating_sub(max_distance * self.n);
let mut term_counts: FxHashMap<usize, usize> = FxHashMap::default();
for qgram in &query_ngrams {
if let Some(term_ids) = self.index.get(qgram) {
for &id in term_ids {
*term_counts.entry(id).or_insert(0) += 1;
}
}
}
term_counts
.into_iter()
.filter(|&(id, count)| count >= min_overlap && !self.terms[id].is_empty())
.map(|(id, _)| self.terms[id].as_str())
.collect()
}
pub fn find_candidates_with_counts(
&self,
query: &str,
max_distance: usize,
) -> Vec<(&str, usize)> {
let query_ngrams: FxHashSet<Vec<u8>> =
self.compute_ngrams(query.as_bytes()).into_iter().collect();
let min_overlap = query_ngrams.len().saturating_sub(max_distance * self.n);
let mut term_counts: FxHashMap<usize, usize> = FxHashMap::default();
for qgram in &query_ngrams {
if let Some(term_ids) = self.index.get(qgram) {
for &id in term_ids {
*term_counts.entry(id).or_insert(0) += 1;
}
}
}
let mut results: Vec<_> = term_counts
.into_iter()
.filter(|&(id, count)| count >= min_overlap && !self.terms[id].is_empty())
.map(|(id, count)| (self.terms[id].as_str(), count))
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results
}
pub fn get_ngrams(&self, term: &str) -> Vec<String> {
self.compute_ngrams(term.as_bytes())
.into_iter()
.map(|ng| String::from_utf8_lossy(&ng).to_string())
.collect()
}
pub fn clear(&mut self) {
self.index.clear();
self.terms.clear();
self.term_to_id.clear();
}
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.terms
.iter()
.filter(|t| !t.is_empty())
.map(String::as_str)
}
}
impl Default for NgramIndex {
fn default() -> Self {
Self::new(2)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new() {
let index = NgramIndex::new(2);
assert_eq!(index.n(), 2);
assert!(index.is_empty());
assert_eq!(index.len(), 0);
}
#[test]
fn test_default() {
let index = NgramIndex::default();
assert_eq!(index.n(), 2);
}
#[test]
#[should_panic(expected = "N-gram size must be at least 1")]
fn test_zero_n_panics() {
NgramIndex::new(0);
}
#[test]
fn test_insert() {
let mut index = NgramIndex::new(2);
let id1 = index.insert("hello");
let id2 = index.insert("world");
let id3 = index.insert("hello");
assert_eq!(id1, 0);
assert_eq!(id2, 1);
assert_eq!(id3, 0); assert_eq!(index.len(), 2);
}
#[test]
fn test_compute_ngrams() {
let index = NgramIndex::new(2);
let ngrams = index.get_ngrams("hello");
assert_eq!(ngrams, vec!["he", "el", "ll", "lo"]);
let ngrams = index.get_ngrams("a");
assert_eq!(ngrams, vec!["a"]);
}
#[test]
fn test_trigrams() {
let index = NgramIndex::new(3);
let ngrams = index.get_ngrams("hello");
assert_eq!(ngrams, vec!["hel", "ell", "llo"]);
}
#[test]
fn test_find_candidates() {
let mut index = NgramIndex::new(2);
index.insert("hello");
index.insert("help");
index.insert("world");
index.insert("helm");
let candidates = index.find_candidates("helo", 1);
assert!(candidates.contains(&"hello"));
assert!(candidates.contains(&"help"));
}
#[test]
fn test_find_candidates_with_counts() {
let mut index = NgramIndex::new(2);
index.insert("hello");
index.insert("help");
let candidates = index.find_candidates_with_counts("hello", 0);
let hello_result = candidates.iter().find(|(t, _)| *t == "hello");
assert!(hello_result.is_some());
let (_, count) = hello_result.expect("expected Some hello_result in test");
assert_eq!(*count, 4); }
#[test]
fn test_remove() {
let mut index = NgramIndex::new(2);
index.insert("hello");
index.insert("world");
assert_eq!(index.len(), 2);
assert!(index.remove("hello"));
assert!(!index.remove("hello"));
let candidates = index.find_candidates("hello", 0);
assert!(!candidates.contains(&"hello"));
}
#[test]
fn test_from_iter() {
let terms = vec!["apple", "banana", "cherry"];
let index = NgramIndex::from_iter(2, terms.into_iter().map(String::from));
assert_eq!(index.len(), 3);
assert!(!index.is_empty());
}
#[test]
fn test_clear() {
let mut index = NgramIndex::new(2);
index.insert("hello");
index.insert("world");
assert_eq!(index.len(), 2);
index.clear();
assert_eq!(index.len(), 0);
assert!(index.is_empty());
}
#[test]
fn test_iter() {
let mut index = NgramIndex::new(2);
index.insert("hello");
index.insert("world");
let terms: Vec<_> = index.iter().collect();
assert_eq!(terms.len(), 2);
assert!(terms.contains(&"hello"));
assert!(terms.contains(&"world"));
}
#[test]
fn test_empty_query() {
let mut index = NgramIndex::new(2);
index.insert("hello");
let candidates = index.find_candidates("", 1);
assert!(candidates.is_empty() || candidates.len() <= 1);
}
#[test]
fn test_unicode() {
let mut index = NgramIndex::new(2);
index.insert("café");
index.insert("cafe");
let candidates = index.find_candidates("cafe", 1);
assert!(candidates.contains(&"cafe"));
}
#[test]
fn test_ngram_count() {
let mut index = NgramIndex::new(2);
index.insert("hello");
index.insert("help");
assert_eq!(index.ngram_count(), 5);
}
}