use crate::rowid::RowId;
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
const NUM_PERM: usize = 128;
const NUM_BANDS: usize = 32;
const ROWS_PER_BAND: usize = NUM_PERM / NUM_BANDS;
pub fn minhash_token_hash(token: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
token.hash(&mut h);
h.finish()
}
pub fn token_hashes_from_bytes(bytes: &[u8]) -> Vec<u64> {
let arr = match serde_json::from_slice::<serde_json::Value>(bytes) {
Ok(serde_json::Value::Array(a)) => a,
Ok(serde_json::Value::String(s)) => match serde_json::from_str::<serde_json::Value>(&s) {
Ok(serde_json::Value::Array(a)) => a,
_ => return Vec::new(),
},
_ => return Vec::new(),
};
let mut set = HashSet::new();
for v in arr {
let token = match v {
serde_json::Value::String(s) => s,
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => continue,
};
set.insert(minhash_token_hash(&token));
}
set.into_iter().collect()
}
fn splitmix64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn coeffs() -> &'static [(u64, u64); NUM_PERM] {
static COEFFS: OnceLock<[(u64, u64); NUM_PERM]> = OnceLock::new();
COEFFS.get_or_init(|| {
let mut c = [(0u64, 0u64); NUM_PERM];
for (i, slot) in c.iter_mut().enumerate() {
let a = splitmix64(0xA5A5_0000 ^ (i as u64).wrapping_mul(2)) | 1;
let b = splitmix64(0x5A5A_0000 ^ ((i as u64).wrapping_mul(2) + 1));
*slot = (a, b);
}
c
})
}
fn signature(token_hashes: &[u64]) -> Option<Vec<u32>> {
if token_hashes.is_empty() {
return None;
}
let coeffs = coeffs();
let mut sig = vec![u32::MAX; NUM_PERM];
for &h in token_hashes {
for (i, &(a, b)) in coeffs.iter().enumerate() {
let p = a.wrapping_mul(h).wrapping_add(b);
let v = (p >> 32) as u32;
if v < sig[i] {
sig[i] = v;
}
}
}
Some(sig)
}
fn band_key(b: usize, sig: &[u32]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(b as u64).hash(&mut h);
let lo = b * ROWS_PER_BAND;
sig[lo..lo + ROWS_PER_BAND].hash(&mut h);
h.finish()
}
#[derive(Default)]
pub struct MinHashIndex {
sigs: Vec<(RowId, Vec<u32>)>,
buckets: HashMap<u64, Vec<u32>>,
}
impl MinHashIndex {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, token_hashes: &[u64], row_id: RowId) {
let Some(sig) = signature(token_hashes) else {
return;
};
let idx = self.sigs.len() as u32;
for b in 0..NUM_BANDS {
self.buckets.entry(band_key(b, &sig)).or_default().push(idx);
}
self.sigs.push((row_id, sig));
}
pub fn search(&self, query_token_hashes: &[u64], k: usize) -> Vec<(RowId, f32)> {
let Some(qsig) = signature(query_token_hashes) else {
return Vec::new();
};
let mut candidates: HashSet<u32> = HashSet::new();
for b in 0..NUM_BANDS {
if let Some(v) = self.buckets.get(&band_key(b, &qsig)) {
candidates.extend(v.iter().copied());
}
}
let mut scored: Vec<(RowId, f32)> = candidates
.into_iter()
.map(|idx| {
let (rid, sig) = &self.sigs[idx as usize];
let matches = sig.iter().zip(&qsig).filter(|(a, b)| a == b).count();
(*rid, matches as f32 / NUM_PERM as f32)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
scored
}
pub fn is_empty(&self) -> bool {
self.sigs.is_empty()
}
pub fn entries(&self) -> Vec<(RowId, Vec<u32>)> {
self.sigs.clone()
}
pub fn from_entries(entries: Vec<(RowId, Vec<u32>)>) -> Self {
let mut idx = Self {
sigs: Vec::with_capacity(entries.len()),
buckets: HashMap::new(),
};
for (rid, sig) in entries {
let i = idx.sigs.len() as u32;
for b in 0..NUM_BANDS {
idx.buckets.entry(band_key(b, &sig)).or_default().push(i);
}
idx.sigs.push((rid, sig));
}
idx
}
}
pub type MinHashEntries = Vec<(RowId, Vec<u32>)>;
#[cfg(test)]
mod tests {
use super::*;
fn set(tokens: &[&str]) -> Vec<u64> {
tokens.iter().map(|t| minhash_token_hash(t)).collect()
}
#[test]
fn similar_sets_are_candidates_and_rank_by_jaccard() {
let mut idx = MinHashIndex::new();
idx.insert(&set(&["a", "b", "c", "d"]), RowId(1)); idx.insert(&set(&["a", "b", "c", "e"]), RowId(2)); idx.insert(&set(&["x", "y", "z", "w"]), RowId(3)); let hits = idx.search(&set(&["a", "b", "c", "d"]), 10);
let ids: Vec<u64> = hits.iter().map(|(r, _)| r.0).collect();
assert!(ids.contains(&1), "identical set must be a candidate");
assert_eq!(hits[0].0, RowId(1));
assert!(hits[0].1 > 0.95);
assert!(!ids.contains(&3) || hits.last().unwrap().0 == RowId(3));
}
#[test]
fn checkpoint_roundtrip_preserves_search() {
let mut idx = MinHashIndex::new();
idx.insert(&set(&["a", "b", "c", "d"]), RowId(1));
idx.insert(&set(&["a", "b", "c", "e"]), RowId(2));
let restored = MinHashIndex::from_entries(idx.entries());
let a = idx.search(&set(&["a", "b", "c", "d"]), 5);
let b = restored.search(&set(&["a", "b", "c", "d"]), 5);
assert_eq!(a, b);
}
#[test]
fn tokenizes_json_array_bytes() {
let direct = token_hashes_from_bytes(br#"["a","b","c"]"#);
assert_eq!(direct.len(), 3);
let quoted = token_hashes_from_bytes(br#""[\"a\",\"b\",\"c\"]""#);
assert_eq!(quoted.len(), 3);
let mut a = direct.clone();
let mut b = token_hashes_from_bytes(br#"["c","b","a"]"#);
a.sort_unstable();
b.sort_unstable();
assert_eq!(a, b);
}
}