use std::collections::HashMap;
use crate::Region;
use vicinity::hnsw::HNSWIndex;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("index must be built before search")]
NotBuilt,
#[error("vicinity: {0}")]
Vicinity(#[from] vicinity::RetrieveError),
}
pub struct RegionIndex<R: Region> {
center: HNSWIndex,
lift: HNSWIndex,
dim: usize,
regions: Vec<R>,
ids: Vec<u32>,
id_to_pos: HashMap<u32, usize>,
built: bool,
}
pub struct IndexParams {
pub m: usize,
pub m_max: usize,
pub ef_construction: usize,
}
impl Default for IndexParams {
fn default() -> Self {
Self {
m: 16,
m_max: 32,
ef_construction: 200,
}
}
}
pub struct SearchParams {
pub ef: usize,
pub overretrieve: usize,
}
impl Default for SearchParams {
fn default() -> Self {
Self {
ef: 200,
overretrieve: 10,
}
}
}
pub type SearchResult = (u32, f32);
impl<R: Region> RegionIndex<R> {
pub fn new(dim: usize, params: IndexParams) -> Result<Self, Error> {
let builder = |d: usize| {
HNSWIndex::builder(d)
.m(params.m)
.m_max(params.m_max)
.ef_construction(params.ef_construction)
.metric(vicinity::DistanceMetric::L2)
.build()
};
let center = builder(dim)?;
let lift = builder(dim + 2)?;
Ok(Self {
center,
lift,
dim,
regions: Vec::new(),
ids: Vec::new(),
id_to_pos: HashMap::new(),
built: false,
})
}
pub fn add(&mut self, id: u32, region: R) -> Result<(), Error> {
let pos = self.regions.len();
self.regions.push(region);
self.ids.push(id);
self.id_to_pos.insert(id, pos);
self.built = false;
Ok(())
}
pub fn build(&mut self) -> Result<(), Error> {
let lifted: Vec<Vec<f32>> = self
.regions
.iter()
.map(|r| {
let (c, radius) = r.bounding_ball();
lift_region(&c, radius)
})
.collect();
let m_sq = lifted
.iter()
.map(|u| u.iter().map(|x| x * x).sum::<f32>())
.fold(0.0f32, f32::max);
for (pos, region) in self.regions.iter().enumerate() {
self.center.add(self.ids[pos], region.center().to_vec())?;
let u = &lifted[pos];
let norm_sq: f32 = u.iter().map(|x| x * x).sum();
let aug = (m_sq - norm_sq).max(0.0).sqrt();
let mut v = u.clone();
v.push(aug);
self.lift.add(self.ids[pos], v)?;
}
self.center.build()?;
self.lift.build()?;
self.built = true;
Ok(())
}
fn lift_query(&self, point: &[f32]) -> Vec<f32> {
let mut q = Vec::with_capacity(self.dim + 2);
q.extend_from_slice(point);
q.push(1.0);
q.push(0.0);
q
}
#[must_use = "search results are not used"]
pub fn search(
&self,
query: &[f32],
k: usize,
params: SearchParams,
) -> Result<Vec<SearchResult>, Error> {
if !self.built {
return Err(Error::NotBuilt);
}
let fetch_k = k.saturating_mul(params.overretrieve).max(k);
let candidates = self.center.search(query, fetch_k, params.ef)?;
let mut reranked: Vec<SearchResult> = candidates
.into_iter()
.map(|(doc_id, _)| {
let region = &self.regions[self.id_to_pos[&doc_id]];
(doc_id, region.distance_to_point(query))
})
.collect();
reranked
.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
reranked.truncate(k);
Ok(reranked)
}
pub fn containing(&self, point: &[f32], params: SearchParams) -> Result<Vec<u32>, Error> {
if !self.built {
return Err(Error::NotBuilt);
}
let fetch_k = self
.regions
.len()
.min(params.ef.saturating_mul(params.overretrieve).max(1));
let lifted = self.lift_query(point);
let candidates = self.lift.search(&lifted, fetch_k, params.ef)?;
Ok(candidates
.into_iter()
.filter(|(doc_id, _)| self.regions[self.id_to_pos[doc_id]].contains(point))
.map(|(doc_id, _)| doc_id)
.collect())
}
pub fn subsumers(&self, query: &R, params: SearchParams) -> Result<Vec<u32>, Error> {
let candidates = self.containing(query.center(), params)?;
Ok(candidates
.into_iter()
.filter(|id| self.regions[self.id_to_pos[id]].contains_region(query))
.collect())
}
pub fn subsumers_soft(
&self,
query: &R,
min_prob: f32,
params: SearchParams,
) -> Result<Vec<SearchResult>, Error> {
let candidates = self.containing(query.center(), params)?;
let mut out: Vec<SearchResult> = candidates
.into_iter()
.filter_map(|id| {
let p = self.regions[self.id_to_pos[&id]].entailment_prob(query);
(p >= min_prob).then_some((id, p))
})
.collect();
out.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(out)
}
pub fn subsumees(&self, query: &R) -> Vec<u32> {
self.ids
.iter()
.enumerate()
.filter(|(pos, _)| query.contains_region(&self.regions[*pos]))
.map(|(_, &id)| id)
.collect()
}
pub fn overlapping(&self, query: &R, params: SearchParams) -> Result<Vec<u32>, Error> {
if !self.built {
return Err(Error::NotBuilt);
}
let SearchParams { ef, overretrieve } = params;
let fetch_k = self
.regions
.len()
.min(ef.saturating_mul(overretrieve).max(1));
let mut cand: std::collections::HashSet<u32> = self
.center
.search(query.center(), fetch_k, ef)?
.into_iter()
.map(|(id, _)| id)
.collect();
cand.extend(self.containing(query.center(), SearchParams { ef, overretrieve })?);
Ok(cand
.into_iter()
.filter(|id| self.regions[self.id_to_pos[id]].overlaps_region(query))
.collect())
}
pub fn overlapping_exhaustive(&self, query: &R) -> Vec<u32> {
self.ids
.iter()
.enumerate()
.filter(|(pos, _)| query.overlaps_region(&self.regions[*pos]))
.map(|(_, &id)| id)
.collect()
}
pub fn nearest_region(
&self,
query: &R,
k: usize,
params: SearchParams,
) -> Result<Vec<SearchResult>, Error> {
if !self.built {
return Err(Error::NotBuilt);
}
let fetch_k = k.saturating_mul(params.overretrieve).max(k);
let candidates = self.center.search(query.center(), fetch_k, params.ef)?;
let mut reranked: Vec<SearchResult> = candidates
.into_iter()
.map(|(id, _)| {
let r = &self.regions[self.id_to_pos[&id]];
(id, l2(query.center(), r.center()))
})
.collect();
reranked
.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
reranked.truncate(k);
Ok(reranked)
}
pub fn containing_exhaustive(&self, point: &[f32]) -> Vec<u32> {
self.id_to_pos
.iter()
.filter(|(_, &pos)| self.regions[pos].contains(point))
.map(|(&id, _)| id)
.collect()
}
pub fn subsumers_exhaustive(&self, query: &R) -> Vec<u32> {
self.ids
.iter()
.enumerate()
.filter(|(pos, _)| self.regions[*pos].contains_region(query))
.map(|(_, &id)| id)
.collect()
}
pub fn search_exhaustive(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
let mut results: Vec<SearchResult> = self
.id_to_pos
.iter()
.map(|(&id, &pos)| (id, self.regions[pos].distance_to_point(query)))
.collect();
results.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(k);
results
}
pub fn search_with_distance(
&self,
query: &[f32],
k: usize,
ef: usize,
dist_fn: &dyn Fn(&[f32], u32) -> f32,
) -> Result<Vec<SearchResult>, Error> {
if !self.built {
return Err(Error::NotBuilt);
}
Ok(self
.center
.search_with_distance(query, k, ef, &|q, id| dist_fn(q, id))?)
}
pub fn get(&self, id: u32) -> Option<&R> {
self.id_to_pos.get(&id).map(|&pos| &self.regions[pos])
}
#[must_use]
pub fn len(&self) -> usize {
self.regions.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.regions.is_empty()
}
}
fn l2(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y).powi(2))
.sum::<f32>()
.sqrt()
}
fn lift_region(center: &[f32], radius: f32) -> Vec<f32> {
let mut u = Vec::with_capacity(center.len() + 1);
let mut norm_c_sq = 0.0f32;
for &ci in center {
u.push(2.0 * ci);
norm_c_sq += ci * ci;
}
u.push(radius * radius - norm_c_sq);
u
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AxisBox, Ball};
fn build_test_index() -> RegionIndex<AxisBox> {
let mut idx = RegionIndex::new(3, Default::default()).unwrap();
for i in 0..20 {
let o = i as f32 * 2.0; idx.add(
i,
AxisBox::new(vec![o, o, o], vec![o + 1.0, o + 1.0, o + 1.0]),
)
.unwrap();
}
idx.build().unwrap();
idx
}
#[test]
fn search_finds_nearest_box() {
let idx = build_test_index();
let results = idx.search(&[0.5, 0.5, 0.5], 1, Default::default()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 0);
assert_eq!(results[0].1, 0.0);
}
#[test]
fn search_reranks_correctly() {
let idx = build_test_index();
let query = [10.5, 10.5, 10.5];
let results = idx
.search(
&query,
5,
SearchParams {
ef: 100,
overretrieve: 10,
},
)
.unwrap();
assert_eq!(results[0].0, 5);
assert_eq!(results[0].1, 0.0);
for w in results.windows(2) {
assert!(w[0].1 <= w[1].1, "results not sorted: {:?}", results);
}
}
#[test]
fn search_with_custom_distance() {
let idx = build_test_index();
let dist_fn = |q: &[f32], internal_id: u32| -> f32 {
let o = internal_id as f32 * 2.0;
let center = [o + 0.5, o + 0.5, o + 0.5];
center
.iter()
.zip(q)
.map(|(c, p)| (c - p).powi(2))
.sum::<f32>()
.sqrt()
};
let results = idx
.search_with_distance(&[6.5, 6.5, 6.5], 3, 200, &dist_fn)
.unwrap();
assert_eq!(results.len(), 3);
for w in results.windows(2) {
assert!(w[0].1 <= w[1].1, "results not sorted: {:?}", results);
}
assert!(
results[0].1 < 2.0,
"closest result too far: {}",
results[0].1
);
}
#[test]
fn containing_finds_far_centered_enclosing_box() {
let mut idx = RegionIndex::new(2, Default::default()).unwrap();
idx.add(0, AxisBox::new(vec![0.0, 0.0], vec![100.0, 1.0]))
.unwrap();
for i in 1..60u32 {
let x = i as f32 * 0.05;
idx.add(i, AxisBox::new(vec![x, 2.0], vec![x + 0.1, 2.1]))
.unwrap();
}
idx.build().unwrap();
let params = SearchParams {
ef: 64,
overretrieve: 4,
};
let got = idx.containing(&[1.0, 0.5], params).unwrap();
assert!(
got.contains(&0),
"lift must surface the far-centered enclosing box; got {got:?}"
);
assert!(idx.containing_exhaustive(&[1.0, 0.5]).contains(&0));
}
#[test]
fn subsumers_and_subsumees_nested_boxes() {
let mut idx = RegionIndex::new(2, Default::default()).unwrap();
idx.add(0, AxisBox::new(vec![0.0, 0.0], vec![10.0, 10.0]))
.unwrap();
idx.add(1, AxisBox::new(vec![2.0, 2.0], vec![8.0, 8.0]))
.unwrap();
idx.add(2, AxisBox::new(vec![4.0, 4.0], vec![6.0, 6.0]))
.unwrap();
idx.add(3, AxisBox::new(vec![20.0, 20.0], vec![21.0, 21.0]))
.unwrap();
for i in 4..20u32 {
let o = i as f32 * 3.0;
idx.add(i, AxisBox::new(vec![o, o], vec![o + 0.5, o + 0.5]))
.unwrap();
}
idx.build().unwrap();
let middle = AxisBox::new(vec![2.0, 2.0], vec![8.0, 8.0]);
let mut subs = idx
.subsumers(
&middle,
SearchParams {
ef: 64,
overretrieve: 8,
},
)
.unwrap();
subs.sort_unstable();
assert!(subs.contains(&0), "box 0 subsumes box 1; got {subs:?}");
assert!(!subs.contains(&2), "box 2 does not subsume box 1");
assert!(!subs.contains(&3), "box 3 is disjoint");
let mut exh = idx.subsumers_exhaustive(&middle);
exh.sort_unstable();
assert_eq!(subs, exh, "indexed subsumers must match exhaustive");
let mut down = idx.subsumees(&middle);
down.sort_unstable();
assert!(
down.contains(&2),
"box 2 is subsumed by box 1; got {down:?}"
);
assert!(!down.contains(&0), "box 0 is larger, not subsumed");
assert!(!down.contains(&3), "box 3 is disjoint");
}
#[test]
fn containment_recall_on_realistic_hierarchy() {
use rand::{rngs::StdRng, Rng, SeedableRng};
let dim = 16;
let mut rng = StdRng::seed_from_u64(7);
let mut idx = RegionIndex::new(dim, IndexParams::default()).unwrap();
let mut id = 0u32;
let mut bigs = Vec::new();
for _ in 0..10 {
let c: Vec<f32> = (0..dim).map(|_| rng.random_range(-5.0..5.0)).collect();
let hw: Vec<f32> = (0..dim).map(|_| rng.random_range(3.0..6.0)).collect();
idx.add(id, AxisBox::from_center_offset(c.clone(), hw))
.unwrap();
bigs.push(c);
id += 1;
}
let mut queries = Vec::new();
for _ in 0..300 {
let bc = &bigs[rng.random_range(0..bigs.len())];
let c: Vec<f32> = bc.iter().map(|x| x + rng.random_range(-1.5..1.5)).collect();
let hw: Vec<f32> = (0..dim).map(|_| rng.random_range(0.05..0.2)).collect();
idx.add(id, AxisBox::from_center_offset(c.clone(), hw))
.unwrap();
queries.push(c);
id += 1;
}
idx.build().unwrap();
let (mut hit, mut total) = (0usize, 0usize);
for q in &queries {
let truth: std::collections::HashSet<u32> =
idx.containing_exhaustive(q).into_iter().collect();
let got: std::collections::HashSet<u32> = idx
.containing(
q,
SearchParams {
ef: 100,
overretrieve: 10,
},
)
.unwrap()
.into_iter()
.collect();
for t in &truth {
hit += usize::from(got.contains(t));
total += 1;
}
}
let recall = hit as f64 / total as f64;
assert!(recall > 0.95, "containment recall {recall:.3} below 0.95");
}
#[test]
fn overlapping_and_nearest_region() {
let mut idx = RegionIndex::new(2, Default::default()).unwrap();
idx.add(0, AxisBox::new(vec![0.0, 0.0], vec![4.0, 4.0]))
.unwrap();
idx.add(1, AxisBox::new(vec![3.0, 3.0], vec![7.0, 7.0]))
.unwrap(); idx.add(2, AxisBox::new(vec![6.0, 6.0], vec![8.0, 8.0]))
.unwrap(); idx.add(3, AxisBox::new(vec![50.0, 50.0], vec![51.0, 51.0]))
.unwrap(); for i in 4..18u32 {
let o = 60.0 + i as f32 * 3.0;
idx.add(i, AxisBox::new(vec![o, o], vec![o + 0.5, o + 0.5]))
.unwrap();
}
idx.build().unwrap();
let probe = AxisBox::new(vec![2.0, 2.0], vec![3.5, 3.5]); let params = || SearchParams {
ef: 64,
overretrieve: 8,
};
let mut ov = idx.overlapping(&probe, params()).unwrap();
ov.sort_unstable();
assert!(
ov.contains(&0) && ov.contains(&1),
"probe overlaps 0 and 1; got {ov:?}"
);
assert!(!ov.contains(&3), "box 3 is far");
let mut exh = idx.overlapping_exhaustive(&probe);
exh.sort_unstable();
assert_eq!(ov, exh, "indexed overlap must match exhaustive");
let near = idx
.nearest_region(&AxisBox::new(vec![1.0, 1.0], vec![2.0, 2.0]), 3, params())
.unwrap();
let ids: std::collections::HashSet<u32> = near.iter().map(|(id, _)| *id).collect();
assert!(
ids.contains(&0),
"nearest region to origin cluster includes box 0"
);
assert!(!ids.contains(&3), "far box 3 is not among the 3 nearest");
for w in near.windows(2) {
assert!(w[0].1 <= w[1].1, "nearest_region not sorted");
}
}
#[test]
fn subsumers_soft_recovers_partial_overlap() {
let mut idx = RegionIndex::new(2, Default::default()).unwrap();
idx.add(0, AxisBox::new(vec![0.0, 0.0], vec![10.0, 10.0]))
.unwrap(); idx.add(1, AxisBox::new(vec![2.0, 2.0], vec![8.0, 8.0]))
.unwrap(); for i in 2..16u32 {
let o = i as f32 * 4.0;
idx.add(i, AxisBox::new(vec![o, o], vec![o + 0.5, o + 0.5]))
.unwrap();
}
idx.build().unwrap();
let query = AxisBox::new(vec![7.0, 7.0], vec![9.0, 9.0]); let params = || SearchParams {
ef: 64,
overretrieve: 8,
};
let strict = idx.subsumers(&query, params()).unwrap();
assert!(
strict.contains(&0) && !strict.contains(&1),
"strict: {strict:?}"
);
let soft_lo: Vec<u32> = idx
.subsumers_soft(&query, 0.2, params())
.unwrap()
.iter()
.map(|(id, _)| *id)
.collect();
assert!(
soft_lo.contains(&0) && soft_lo.contains(&1),
"soft@0.2: {soft_lo:?}"
);
let soft_hi: Vec<u32> = idx
.subsumers_soft(&query, 0.5, params())
.unwrap()
.iter()
.map(|(id, _)| *id)
.collect();
assert!(
soft_hi.contains(&0) && !soft_hi.contains(&1),
"soft@0.5: {soft_hi:?}"
);
let ranked = idx.subsumers_soft(&query, 0.0, params()).unwrap();
assert_eq!(ranked[0].0, 0);
}
#[test]
fn ball_subsumption() {
let outer = Ball::new(vec![0.0, 0.0], 5.0);
let inner = Ball::new(vec![1.0, 0.0], 1.0);
let disjoint = Ball::new(vec![20.0, 0.0], 1.0);
assert!(outer.contains_region(&inner));
assert!(!inner.contains_region(&outer));
assert!(!outer.contains_region(&disjoint));
}
#[test]
fn containing_exhaustive_finds_enclosing_boxes() {
let mut idx = RegionIndex::new(2, Default::default()).unwrap();
idx.add(0, AxisBox::new(vec![0.0, 0.0], vec![10.0, 10.0]))
.unwrap();
idx.add(1, AxisBox::new(vec![4.0, 4.0], vec![6.0, 6.0]))
.unwrap();
idx.add(2, AxisBox::new(vec![20.0, 20.0], vec![21.0, 21.0]))
.unwrap();
for i in 3..15 {
let o = (i as f32) * 3.0;
idx.add(i, AxisBox::new(vec![o, o], vec![o + 0.5, o + 0.5]))
.unwrap();
}
idx.build().unwrap();
let result = idx.containing_exhaustive(&[5.0, 5.0]);
assert!(result.contains(&0));
assert!(result.contains(&1));
assert!(!result.contains(&2));
}
#[test]
fn get_returns_region() {
let mut idx = RegionIndex::new(2, Default::default()).unwrap();
idx.add(42, AxisBox::new(vec![0.0, 0.0], vec![1.0, 1.0]))
.unwrap();
idx.build().unwrap();
assert!(idx.get(42).is_some());
assert!(idx.get(99).is_none());
}
#[test]
fn error_on_search_before_build() {
let idx: RegionIndex<AxisBox> = RegionIndex::new(2, Default::default()).unwrap();
assert!(idx.search(&[0.0, 0.0], 1, Default::default()).is_err());
}
}