#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Trigram(u64);
impl Trigram {
#[must_use]
fn new(a: char, b: char, c: char) -> Self {
Self((a as u64) << 42 | (b as u64) << 21 | c as u64)
}
}
#[must_use]
pub fn trigrams(text: &str) -> Vec<Trigram> {
let mut out: Vec<Trigram> = Vec::with_capacity(text.len() + 2);
for word in text.split(|c: char| !c.is_alphanumeric()) {
if word.is_empty() {
continue;
}
let (mut a, mut b) = (' ', ' ');
for c in word.chars().chain(std::iter::once(' ')) {
out.push(Trigram::new(a, b, c));
a = b;
b = c;
}
}
out.sort_unstable();
out.dedup();
out
}
fn intersection_count(a: &[Trigram], b: &[Trigram]) -> usize {
let (mut i, mut j, mut count) = (0, 0, 0);
while i < a.len() && j < b.len() {
match a[i].cmp(&b[j]) {
std::cmp::Ordering::Less => i += 1,
std::cmp::Ordering::Greater => j += 1,
std::cmp::Ordering::Equal => {
count += 1;
i += 1;
j += 1;
}
}
}
count
}
#[must_use]
pub fn similarity(query: &[Trigram], target: &[Trigram]) -> i64 {
if query.is_empty() || target.is_empty() {
return 0;
}
let inter = intersection_count(query, target);
let union = query.len() + target.len() - inter;
((inter as f64 / union as f64) * 100.0) as i64
}
#[must_use]
pub fn containment(query: &[Trigram], target: &[Trigram]) -> i64 {
if query.is_empty() {
return 0;
}
let inter = intersection_count(query, target);
((inter as f64 / query.len() as f64) * 100.0) as i64
}
#[cfg(test)]
mod tests {
use super::*;
fn tg(s: &str) -> Vec<Trigram> {
trigrams(s)
}
#[test]
fn cat_trigram_set_matches_pg_trgm() {
assert_eq!(
trigrams("cat"),
vec![
Trigram::new(' ', ' ', 'c'),
Trigram::new(' ', 'c', 'a'),
Trigram::new('a', 't', ' '),
Trigram::new('c', 'a', 't'),
]
);
}
#[test]
fn split_on_non_alphanumeric_words() {
assert_eq!(trigrams("foo_bar"), trigrams("foo bar"));
assert_eq!(trigrams("a@b.c"), trigrams("a b c"));
}
#[test]
fn identical_strings_score_100() {
let t = tg("postgres");
assert_eq!(similarity(&t, &t), 100);
assert_eq!(containment(&t, &t), 100);
}
#[test]
fn disjoint_strings_score_0() {
assert_eq!(similarity(&tg("abc"), &tg("xyz")), 0);
assert_eq!(containment(&tg("abc"), &tg("xyz")), 0);
}
#[test]
fn transposition_keeps_high_similarity() {
assert!(similarity(&tg("teh"), &tg("the")) > 0);
}
#[test]
fn containment_beats_similarity_for_short_query_in_long_title() {
let q = tg("iphone");
let title = tg("apple iphone fifteen pro max");
assert!(containment(&q, &title) > similarity(&q, &title));
assert!(containment(&q, &title) >= 100); }
#[test]
fn word_order_does_not_change_jaccard() {
assert_eq!(similarity(&tg("alice johnson"), &tg("johnson alice")), 100);
}
}