use nabled_core::scalar::NabledReal;
use nabled_linalg::{matrix, vector};
use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
use crate::error::EmbeddingError;
use crate::similarity::Metric;
use crate::topk::{Neighbor, top_k};
#[derive(Debug, Clone)]
enum Prepared<T> {
Cosine { corpus_norms: Array1<T> },
Plain,
}
#[derive(Debug, Clone)]
pub struct CorpusWorkspace<T> {
metric: Metric,
corpus: Array2<T>,
prepared: Prepared<T>,
}
impl<T: NabledReal> CorpusWorkspace<T> {
pub fn build(corpus: &ArrayView2<'_, T>, metric: Metric) -> Result<Self, EmbeddingError> {
if corpus.is_empty() {
return Err(EmbeddingError::EmptyInput);
}
let prepared = match metric {
Metric::Cosine => {
let corpus_norms = vector::batched_l2_norm_view(corpus)?;
if corpus_norms.iter().any(|&n| n <= T::epsilon()) {
return Err(EmbeddingError::ZeroNorm);
}
Prepared::Cosine { corpus_norms }
}
Metric::Dot | Metric::L2 => Prepared::Plain,
};
Ok(Self { metric, corpus: corpus.to_owned(), prepared })
}
#[must_use]
pub const fn metric(&self) -> Metric { self.metric }
#[must_use]
pub fn len(&self) -> usize { self.corpus.nrows() }
#[must_use]
pub fn is_empty(&self) -> bool { self.corpus.nrows() == 0 }
#[must_use]
pub fn dim(&self) -> usize { self.corpus.ncols() }
pub fn query_corpus_scores(
&self,
queries: &ArrayView2<'_, T>,
) -> Result<Array2<T>, EmbeddingError> {
let mut out = Array2::<T>::zeros((queries.nrows(), self.corpus.nrows()));
self.query_corpus_scores_into(queries, &mut out)?;
Ok(out)
}
pub fn query_corpus_scores_into(
&self,
queries: &ArrayView2<'_, T>,
out: &mut Array2<T>,
) -> Result<(), EmbeddingError> {
if queries.is_empty() {
return Err(EmbeddingError::EmptyInput);
}
if queries.ncols() != self.corpus.ncols() {
return Err(EmbeddingError::DimensionMismatch);
}
if out.dim() != (queries.nrows(), self.corpus.nrows()) {
return Err(EmbeddingError::DimensionMismatch);
}
match &self.prepared {
Prepared::Cosine { corpus_norms } => {
let query_norms = vector::batched_l2_norm_view(queries)?;
if query_norms.iter().any(|&n| n <= T::epsilon()) {
return Err(EmbeddingError::ZeroNorm);
}
matrix::matmat_view_into(queries, &self.corpus.t(), out.view_mut())?;
out.outer_iter_mut().zip(query_norms.iter()).for_each(|(mut row, &qn)| {
row.iter_mut().zip(corpus_norms.iter()).for_each(|(value, &cn)| {
*value /= qn * cn;
});
});
}
Prepared::Plain if self.metric == Metric::Dot => {
matrix::matmat_view_into(queries, &self.corpus.t(), out.view_mut())?;
}
Prepared::Plain => {
vector::pairwise_l2_distance_view_into(queries, &self.corpus.view(), out)?;
}
}
Ok(())
}
pub fn rerank_with(
&self,
query: &ArrayView1<'_, T>,
k: usize,
) -> Result<Vec<Neighbor<T>>, EmbeddingError> {
let query_rows = query.view().insert_axis(Axis(0));
let scores = self.query_corpus_scores(&query_rows)?;
Ok(top_k(scores.row(0), k, self.metric.higher_is_better()))
}
pub fn knn_with(
&self,
queries: &ArrayView2<'_, T>,
k: usize,
) -> Result<Vec<Vec<Neighbor<T>>>, EmbeddingError> {
let scores = self.query_corpus_scores(queries)?;
let higher_is_better = self.metric.higher_is_better();
Ok(scores.outer_iter().map(|row| top_k(row, k, higher_is_better)).collect())
}
}
#[cfg(test)]
mod tests {
use ndarray::{arr1, arr2};
use super::*;
use crate::knn::brute_force_knn;
use crate::rerank::rerank;
use crate::similarity::query_corpus_scores_view;
fn corpus_f64() -> Array2<f64> {
arr2(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0], [0.2, 0.3, 0.9]])
}
fn queries_f64() -> Array2<f64> { arr2(&[[1.0, 0.0, 0.0], [0.1, 0.2, 0.9]]) }
#[test]
fn workspace_scores_match_stateless_for_all_metrics() {
let corpus = corpus_f64();
let queries = queries_f64();
for metric in [Metric::Cosine, Metric::Dot, Metric::L2] {
let ws = CorpusWorkspace::build(&corpus.view(), metric).unwrap();
let cached = ws.query_corpus_scores(&queries.view()).unwrap();
let stateless =
query_corpus_scores_view(&queries.view(), &corpus.view(), metric).unwrap();
for (lhs, rhs) in cached.iter().zip(stateless.iter()) {
assert!((lhs - rhs).abs() < 1e-12, "{metric:?}: {lhs} vs {rhs}");
}
}
}
#[test]
fn workspace_scores_match_stateless_f32() {
let corpus = arr2(&[[1.0_f32, 0.0], [0.0, 1.0], [0.7, 0.7]]);
let queries = arr2(&[[1.0_f32, 0.0], [0.3, 0.4]]);
for metric in [Metric::Cosine, Metric::Dot, Metric::L2] {
let ws = CorpusWorkspace::build(&corpus.view(), metric).unwrap();
let cached = ws.query_corpus_scores(&queries.view()).unwrap();
let stateless =
query_corpus_scores_view(&queries.view(), &corpus.view(), metric).unwrap();
for (lhs, rhs) in cached.iter().zip(stateless.iter()) {
assert!((lhs - rhs).abs() < 1e-5, "{metric:?}: {lhs} vs {rhs}");
}
}
}
#[test]
fn workspace_reuse_across_queries_is_stable() {
let corpus = corpus_f64();
let ws = CorpusWorkspace::build(&corpus.view(), Metric::Cosine).unwrap();
let q1 = arr1(&[1.0_f64, 0.0, 0.0]);
let q2 = arr1(&[0.0_f64, 1.0, 0.0]);
let r1 = ws.rerank_with(&q1.view(), 2).unwrap();
let r2 = ws.rerank_with(&q2.view(), 2).unwrap();
let e1 = rerank(&q1.view(), &corpus.view(), 2, Metric::Cosine).unwrap();
let e2 = rerank(&q2.view(), &corpus.view(), 2, Metric::Cosine).unwrap();
assert_eq!(r1[0].index, e1[0].index);
assert_eq!(r2[0].index, e2[0].index);
let r1_again = ws.rerank_with(&q1.view(), 2).unwrap();
assert_eq!(r1[0].index, r1_again[0].index);
}
#[test]
fn workspace_knn_matches_brute_force() {
let corpus = corpus_f64();
let queries = queries_f64();
let ws = CorpusWorkspace::build(&corpus.view(), Metric::L2).unwrap();
let cached = ws.knn_with(&queries.view(), 3).unwrap();
let expected = brute_force_knn(&queries.view(), &corpus.view(), 3, Metric::L2).unwrap();
for (clist, elist) in cached.iter().zip(expected.iter()) {
for (cn, en) in clist.iter().zip(elist.iter()) {
assert_eq!(cn.index, en.index);
assert!((cn.score - en.score).abs() < 1e-12);
}
}
}
#[test]
fn workspace_metadata_is_exposed() {
let corpus = corpus_f64();
let ws = CorpusWorkspace::build(&corpus.view(), Metric::Dot).unwrap();
assert_eq!(ws.metric(), Metric::Dot);
assert_eq!(ws.len(), 4);
assert_eq!(ws.dim(), 3);
assert!(!ws.is_empty());
}
#[test]
fn build_rejects_empty_corpus() {
let corpus = Array2::<f64>::zeros((0, 3));
assert_eq!(
CorpusWorkspace::build(&corpus.view(), Metric::Cosine).err(),
Some(EmbeddingError::EmptyInput)
);
}
#[test]
fn build_rejects_cosine_zero_norm_row() {
let corpus = arr2(&[[1.0_f64, 0.0], [0.0, 0.0]]);
assert_eq!(
CorpusWorkspace::build(&corpus.view(), Metric::Cosine).err(),
Some(EmbeddingError::ZeroNorm)
);
}
#[test]
fn query_rejects_dimension_mismatch() {
let corpus = corpus_f64();
let ws = CorpusWorkspace::build(&corpus.view(), Metric::Dot).unwrap();
let queries = arr2(&[[1.0_f64, 0.0]]);
assert_eq!(
ws.query_corpus_scores(&queries.view()).err(),
Some(EmbeddingError::DimensionMismatch)
);
}
#[test]
fn query_rejects_cosine_zero_norm_query() {
let corpus = corpus_f64();
let ws = CorpusWorkspace::build(&corpus.view(), Metric::Cosine).unwrap();
let queries = arr2(&[[0.0_f64, 0.0, 0.0]]);
assert_eq!(ws.query_corpus_scores(&queries.view()).err(), Some(EmbeddingError::ZeroNorm));
}
#[test]
fn query_into_rejects_wrong_output_shape() {
let corpus = corpus_f64();
let ws = CorpusWorkspace::build(&corpus.view(), Metric::Dot).unwrap();
let queries = queries_f64();
let mut out = Array2::<f64>::zeros((1, 1));
assert_eq!(
ws.query_corpus_scores_into(&queries.view(), &mut out).err(),
Some(EmbeddingError::DimensionMismatch)
);
}
}