use crate::sparse_io_vector::SparseIoVec;
use legume_numeric::matrix::knn_match::ColumnDict;
use log::{info, warn};
use rayon::prelude::*;
type Mat = nalgebra::DMatrix<f32>;
pub struct RetrievalImputeConfig {
pub knn: usize,
pub temperature: f32,
pub chunk: usize,
}
struct QueryNeighbours {
ids: Vec<u32>,
weights: Vec<f32>,
}
pub fn retrieval_impute(
query_latent: &Mat,
ref_latent: &Mat,
ref_data: &SparseIoVec,
cfg: &RetrievalImputeConfig,
) -> anyhow::Result<Mat> {
let (n_query, k_query) = (query_latent.nrows(), query_latent.ncols());
let (n_ref, k_ref) = (ref_latent.nrows(), ref_latent.ncols());
anyhow::ensure!(
k_query == k_ref,
"latent dimension mismatch: query K={k_query} vs reference K={k_ref}"
);
anyhow::ensure!(n_ref > 0, "reference latent has no rows");
anyhow::ensure!(
ref_data.num_columns() == n_ref,
"reference data has {} cells but reference latent has {n_ref}; \
the data files don't match the latent",
ref_data.num_columns(),
);
let g_ref = ref_data.num_rows();
anyhow::ensure!(g_ref > 0, "reference data has no features");
let live: Vec<usize> = (0..n_ref)
.filter(|&i| ref_latent.row(i).iter().any(|&x| x != 0.0))
.collect();
anyhow::ensure!(!live.is_empty(), "every reference latent row is zero");
if live.len() < n_ref {
warn!(
"{} of {n_ref} reference rows have a zero latent and are excluded \
from the index",
n_ref - live.len()
);
}
info!(
"Building kNN index over {} reference rows (k={}, τ={})",
live.len(),
cfg.knn,
cfg.temperature
);
let ref_dict = ColumnDict::<u32>::from_dmatrix(
Mat::from_fn(k_ref, live.len(), |r, c| ref_latent[(live[c], r)]),
live.iter().map(|&i| i as u32).collect(),
);
let knn = cfg.knn.min(live.len());
let neighbours: Vec<QueryNeighbours> = (0..n_query)
.into_par_iter()
.map_init(
legume_numeric::matrix::knn_match::SearchScratch::default,
|scratch, i| {
let query: Vec<f32> = query_latent.row(i).iter().copied().collect();
if query.iter().all(|&x| x == 0.0) {
return Ok(QueryNeighbours {
ids: Vec::new(),
weights: Vec::new(),
});
}
let (ids, distances) = ref_dict
.search_by_query_data_reuse(&query, knn, scratch)
.map_err(|e| anyhow::anyhow!("kNN search for query row {i}: {e}"))?;
let weights = dist_to_softmax_weights(&distances, cfg.temperature);
Ok(QueryNeighbours { ids, weights })
},
)
.collect::<anyhow::Result<_>>()?;
let n_skipped = neighbours.iter().filter(|n| n.ids.is_empty()).count();
if n_skipped > 0 {
warn!(
"{n_skipped} of {n_query} query rows have a zero latent and were skipped; \
their imputed rows stay zero"
);
}
let bytes_est = (n_query * g_ref).saturating_mul(4);
if bytes_est > (1 << 30) {
warn!(
"imputed dense matrix will allocate ~{} MB ({n_query} × {g_ref} f32). \
Consider reducing the query size or the reference feature set if \
memory is tight.",
bytes_est >> 20,
);
}
let mut cell_to_consumers: Vec<Vec<(u32, f32)>> = vec![Vec::new(); n_ref];
for (query_id, nbr) in neighbours.iter().enumerate() {
for (&c, &w) in nbr.ids.iter().zip(nbr.weights.iter()) {
cell_to_consumers[c as usize].push((query_id as u32, w));
}
}
let mut imputed_t = Mat::zeros(g_ref, n_query);
let mut groups: Vec<Vec<(u32, f32)>> = vec![Vec::new(); n_query];
let chunk_size = cfg.chunk.max(64);
let mut col_lb = 0;
while col_lb < n_ref {
let col_ub = (col_lb + chunk_size).min(n_ref);
if cell_to_consumers[col_lb..col_ub]
.iter()
.all(std::vec::Vec::is_empty)
{
col_lb = col_ub;
continue;
}
let csc = ref_data.read_columns_csc(col_lb..col_ub)?;
for c_local in 0..(col_ub - col_lb) {
for &(query_id, w) in &cell_to_consumers[col_lb + c_local] {
groups[query_id as usize].push((c_local as u32, w));
}
}
imputed_t
.as_mut_slice()
.par_chunks_mut(g_ref)
.zip(groups.par_iter_mut())
.for_each(|(query_col, grp)| {
for &(c_local, w) in grp.iter() {
let col = csc.col(c_local as usize);
for (&row_id, &v) in col.row_indices().iter().zip(col.values().iter()) {
query_col[row_id] += w * v;
}
}
grp.clear();
});
col_lb = col_ub;
}
Ok(imputed_t.transpose())
}
fn dist_to_softmax_weights(distances: &[f32], temperature: f32) -> Vec<f32> {
if distances.is_empty() {
return Vec::new();
}
let tau = temperature.max(1e-6);
let scale = 1.0 / (2.0 * tau * tau);
let mut max = f32::NEG_INFINITY;
let mut out: Vec<f32> = distances
.iter()
.map(|d| {
let v = -d * d * scale;
if v > max {
max = v;
}
v
})
.collect();
let mut sum = 0.0f32;
for x in &mut out {
*x = (*x - max).exp();
sum += *x;
}
let inv = 1.0 / sum.max(1e-12);
for x in &mut out {
*x *= inv;
}
out
}
#[cfg(test)]
#[path = "retrieval_impute_tests.rs"]
mod tests;