use crate::rowid::RowId;
use std::collections::{HashMap, HashSet};
const NUM_PERM: usize = 128;
const NUM_BANDS: usize = 32;
pub fn minhash_token_hash(token: &str) -> u64 {
minhash_member_hash_v1(&serde_json::Value::String(token.into())).unwrap()
}
pub fn minhash_member_hash_v1(member: &serde_json::Value) -> Result<u64, &'static str> {
let mut canonical = Vec::new();
match member {
serde_json::Value::String(value) => {
canonical.push(0x01);
canonical.extend_from_slice(value.as_bytes());
}
serde_json::Value::Number(value) => {
canonical.push(0x02);
canonical.extend_from_slice(value.to_string().as_bytes());
}
serde_json::Value::Bool(value) => {
canonical.extend_from_slice(&[0x03, u8::from(*value)]);
}
_ => return Err("set member must be a string, number, or boolean"),
}
Ok(xxhash_rust::xxh3::xxh3_64_with_seed(&canonical, 0))
}
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 member in arr {
if let Ok(hash) = minhash_member_hash_v1(&member) {
set.insert(hash);
}
}
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 coefficient(i: usize) -> (u64, u64) {
let a = splitmix64(0xA5A5_0000 ^ (i as u64).wrapping_mul(2)) | 1;
let b = splitmix64(0x5A5A_0000 ^ ((i as u64).wrapping_mul(2) + 1));
(a, b)
}
fn signature(token_hashes: &[u64], permutations: usize) -> Option<Vec<u32>> {
if token_hashes.is_empty() {
return None;
}
let mut sig = vec![u32::MAX; permutations];
for &h in token_hashes {
for (i, slot) in sig.iter_mut().enumerate() {
let (a, b) = coefficient(i);
let p = a.wrapping_mul(h).wrapping_add(b);
let v = (p >> 32) as u32;
if v < *slot {
*slot = v;
}
}
}
Some(sig)
}
fn band_key(b: usize, sig: &[u32], rows_per_band: usize) -> 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()
}
pub struct MinHashIndex {
permutations: usize,
bands: usize,
sigs: Vec<(RowId, Vec<u32>)>,
buckets: HashMap<u64, Vec<u32>>,
}
impl MinHashIndex {
pub fn new() -> Self {
Self::with_options(NUM_PERM, NUM_BANDS)
}
pub fn with_options(permutations: usize, bands: usize) -> Self {
assert!(permutations > 0 && bands > 0 && permutations % bands == 0);
Self {
permutations,
bands,
sigs: Vec::new(),
buckets: HashMap::new(),
}
}
pub fn insert(&mut self, token_hashes: &[u64], row_id: RowId) {
let Some(sig) = signature(token_hashes, self.permutations) else {
return;
};
let idx = self.sigs.len() as u32;
for b in 0..self.bands {
self.buckets
.entry(band_key(b, &sig, self.permutations / self.bands))
.or_default()
.push(idx);
}
self.sigs.push((row_id, sig));
}
pub fn search(&self, query_token_hashes: &[u64], k: usize) -> Vec<(RowId, f32)> {
self.search_filtered(query_token_hashes, k, |_| true)
}
pub fn search_filtered(
&self,
query_token_hashes: &[u64],
k: usize,
allowed: impl Fn(RowId) -> bool,
) -> Vec<(RowId, f32)> {
let Some(qsig) = signature(query_token_hashes, self.permutations) else {
return Vec::new();
};
let mut candidates: HashSet<u32> = HashSet::new();
for b in 0..self.bands {
if let Some(v) = self
.buckets
.get(&band_key(b, &qsig, self.permutations / self.bands))
{
candidates.extend(v.iter().copied());
}
}
let mut scored: Vec<(RowId, f32)> = candidates
.into_iter()
.filter_map(|idx| {
let (rid, sig) = &self.sigs[idx as usize];
if !allowed(*rid) {
return None;
}
let matches = sig.iter().zip(&qsig).filter(|(a, b)| a == b).count();
Some((*rid, matches as f32 / self.permutations as f32))
})
.collect();
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
scored.truncate(k);
scored
}
pub fn candidate_row_ids(&self, query_token_hashes: &[u64]) -> Vec<RowId> {
let Some(signature) = signature(query_token_hashes, self.permutations) else {
return Vec::new();
};
let mut candidates = HashSet::new();
for band in 0..self.bands {
if let Some(indices) =
self.buckets
.get(&band_key(band, &signature, self.permutations / self.bands))
{
candidates.extend(indices.iter().map(|index| self.sigs[*index as usize].0));
}
}
candidates.into_iter().collect()
}
pub fn is_empty(&self) -> bool {
self.sigs.is_empty()
}
pub fn options(&self) -> (usize, usize) {
(self.permutations, self.bands)
}
pub fn entries(&self) -> Vec<(RowId, Vec<u32>)> {
self.sigs.clone()
}
pub fn from_entries(entries: Vec<(RowId, Vec<u32>)>) -> Self {
Self::from_entries_with_options(entries, NUM_PERM, NUM_BANDS)
}
pub fn from_entries_with_options(
entries: Vec<(RowId, Vec<u32>)>,
permutations: usize,
bands: usize,
) -> Self {
let mut idx = Self {
permutations,
bands,
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..bands {
idx.buckets
.entry(band_key(b, &sig, permutations / bands))
.or_default()
.push(i);
}
idx.sigs.push((rid, sig));
}
idx
}
pub fn snapshot(&self) -> MinHashSnapshot {
MinHashSnapshot {
permutations: self.permutations,
bands: self.bands,
entries: self.entries(),
}
}
pub fn from_snapshot(snapshot: MinHashSnapshot) -> Self {
Self::from_entries_with_options(snapshot.entries, snapshot.permutations, snapshot.bands)
}
}
impl Default for MinHashIndex {
fn default() -> Self {
Self::new()
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct MinHashSnapshot {
pub permutations: usize,
pub bands: usize,
pub entries: MinHashEntries,
}
pub type MinHashEntries = Vec<(RowId, Vec<u32>)>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stable_typed_hash_vectors() {
let fixtures: Vec<serde_json::Value> =
serde_json::from_str(include_str!("../../../../docs/ai/minhash-v1-golden.json"))
.unwrap();
for fixture in fixtures {
let expected = fixture["expected"]
.as_str()
.unwrap()
.parse::<u64>()
.unwrap();
assert_eq!(
minhash_member_hash_v1(&fixture["member"]).unwrap(),
expected
);
}
assert_ne!(
minhash_token_hash("1"),
minhash_member_hash_v1(&serde_json::json!(1)).unwrap()
);
assert_ne!(
minhash_token_hash("true"),
minhash_member_hash_v1(&serde_json::json!(true)).unwrap()
);
}
#[test]
fn custom_options_survive_snapshot() {
let mut index = MinHashIndex::with_options(64, 16);
let query = set(&["a", "b", "c", "d"]);
index.insert(&query, RowId(7));
let restored = MinHashIndex::from_snapshot(index.snapshot());
assert_eq!(restored.options(), (64, 16));
assert_eq!(restored.search(&query, 1)[0].0, RowId(7));
assert_eq!(restored.search(&query, 1)[0].1, 1.0);
}
#[test]
fn exact_verification_cannot_recover_an_lsh_miss() {
let base = set(&["a", "b", "c", "d"]);
let mut index = MinHashIndex::with_options(1, 1);
index.insert(&base, RowId(1));
let missed = (0..100)
.map(|candidate| set(&["a", "b", "c", &format!("x{candidate}")]))
.find(|query| index.search(query, 1).is_empty())
.expect("one-permutation LSH must miss a near set in this fixture");
assert_eq!(
base.iter().filter(|token| missed.contains(token)).count(),
3
);
assert!(index.search(&missed, 1).is_empty());
}
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);
}
}