use numpy::{IntoPyArray, PyArray2, PyReadonlyArray1, PyUntypedArrayMethods};
use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use crate::core::index::sparse_impact::{
CsrView, SparseBuildParams, SparseImpactIndex, SparseSearchParams,
};
fn value_err(e: impl std::fmt::Display) -> PyErr {
PyValueError::new_err(e.to_string())
}
fn thread_pool(threads: usize) -> PyResult<Option<rayon::ThreadPool>> {
match threads {
0 => Ok(None),
n => rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build()
.map(Some)
.map_err(|e| PyRuntimeError::new_err(format!("thread pool: {e}"))),
}
}
#[pyclass(name = "SparseIndex")]
struct PySparseIndex {
params: SparseBuildParams,
index: Option<SparseImpactIndex>,
search: SparseSearchParams,
pool: Option<rayon::ThreadPool>,
}
impl PySparseIndex {
fn run<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
match &self.pool {
Some(pool) => pool.install(f),
None => f(),
}
}
fn built(&self) -> PyResult<&SparseImpactIndex> {
self.index
.as_ref()
.ok_or_else(|| PyRuntimeError::new_err("index is not built"))
}
}
#[pymethods]
impl PySparseIndex {
#[new]
#[pyo3(signature = (postings_per_term=4000, block_fraction=0.1, summary_energy=0.4, threads=0, seed=0x5eed))]
fn new(
postings_per_term: usize,
block_fraction: f32,
summary_energy: f32,
threads: usize,
seed: u64,
) -> PyResult<Self> {
if !(block_fraction > 0.0 && block_fraction <= 1.0) {
return Err(value_err("block_fraction must be in (0, 1]"));
}
if !(summary_energy > 0.0 && summary_energy <= 1.0) {
return Err(value_err("summary_energy must be in (0, 1]"));
}
Ok(Self {
params: SparseBuildParams {
postings_per_term: if postings_per_term == 0 {
usize::MAX
} else {
postings_per_term
},
block_fraction,
summary_energy,
seed,
..SparseBuildParams::default()
},
index: None,
search: SparseSearchParams {
query_cut: 4,
heap_factor: 1.1,
},
pool: thread_pool(threads)?,
})
}
fn build_from_spmat(&mut self, py: Python<'_>, path: std::path::PathBuf) -> PyResult<()> {
let file = std::fs::File::open(&path)
.map_err(|e| value_err(format!("{}: {e}", path.display())))?;
let map = unsafe { memmap2::Mmap::map(&file) }
.map_err(|e| value_err(format!("{}: {e}", path.display())))?;
let view = CsrView::from_spmat(&map).map_err(value_err)?;
let params = self.params;
let built = py.detach(|| self.run(|| SparseImpactIndex::build(view, params)));
self.index = Some(built.map_err(value_err)?);
Ok(())
}
#[pyo3(signature = (query_cut=4, heap_factor=1.1))]
fn set_search(&mut self, query_cut: usize, heap_factor: f32) -> PyResult<()> {
if !(heap_factor > 0.0 && heap_factor.is_finite()) {
return Err(value_err("heap_factor must be positive"));
}
self.search = SparseSearchParams {
query_cut: if query_cut == 0 {
usize::MAX
} else {
query_cut
},
heap_factor,
};
Ok(())
}
fn search<'py>(
&self,
py: Python<'py>,
indptr: PyReadonlyArray1<'py, i64>,
indices: PyReadonlyArray1<'py, i32>,
data: PyReadonlyArray1<'py, f32>,
k: usize,
) -> PyResult<Bound<'py, PyArray2<i64>>> {
let index = self.built()?;
if k == 0 || k > 4096 {
return Err(value_err("k must be in 1..=4096"));
}
let view = CsrView {
indptr: indptr.as_slice()?,
indices: indices.as_slice()?,
data: data.as_slice()?,
n_dims: index.n_dims(),
};
let nq = indptr.len().saturating_sub(1);
let params = self.search;
let ids = py
.detach(|| self.run(|| index.search_batch(view, k, params)))
.map_err(value_err)?;
let out: Vec<i64> = ids
.into_iter()
.map(|d| if d == u32::MAX { -1 } else { i64::from(d) })
.collect();
numpy::ndarray::Array2::from_shape_vec((nq, k), out)
.map(|a| a.into_pyarray(py))
.map_err(|e| PyRuntimeError::new_err(e.to_string()))
}
fn stats(&self) -> PyResult<(usize, usize, usize)> {
let index = self.built()?;
let (blocks, postings) = index.block_stats();
Ok((index.memory_bytes(), blocks, postings))
}
}
pub(super) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PySparseIndex>()
}