use faer::Side;
use faer::prelude::*;
#[cfg_attr(not(feature = "clusterer"), allow(dead_code))]
pub(crate) const MAX_EIGENGAP_CANDIDATES: usize = 20;
#[cfg_attr(not(feature = "clusterer"), allow(dead_code))]
pub(crate) fn select_k_by_normalized_eigengap(eig_asc: &[f64], max_k: usize) -> usize {
let max_k = max_k.min(eig_asc.len()).min(MAX_EIGENGAP_CANDIDATES);
let mut best_k = 1usize;
let mut best_gap = 0.0f64;
for k in 1..max_k {
let lam_k = eig_asc[k - 1];
let lam_k1 = eig_asc[k];
let gap = if lam_k1.abs() > 1e-10 {
(lam_k1 - lam_k) / lam_k1.abs()
} else {
0.0
};
if gap > best_gap {
best_gap = gap;
best_k = k;
}
}
best_k
}
#[cfg_attr(not(feature = "clusterer"), allow(dead_code))]
pub(crate) struct SpectralGraph {
n: usize,
eig_pairs: Vec<(f64, usize)>,
u: Mat<f64>,
}
#[cfg_attr(not(feature = "clusterer"), allow(dead_code))]
impl SpectralGraph {
pub(crate) fn from_embeddings(embeddings: &[Vec<f32>]) -> Option<Self> {
let n = embeddings.len();
if n == 0 {
return None;
}
if n == 1 {
let mut u = Mat::zeros(1, 1);
u[(0, 0)] = 1.0;
return Some(Self {
n: 1,
eig_pairs: vec![(0.0, 0)],
u,
});
}
let k_nn = (n / 10).clamp(2, 10);
let sims = crate::utils::pairwise_cosine_similarity_matrix(embeddings);
let mut aff = vec![0.0f64; n * n];
for i in 0..n {
aff[i * n + i] = 1.0;
let mut neighbors: Vec<(f64, usize)> = Vec::with_capacity(n);
for j in 0..n {
if i != j {
let sim = sims[i * n + j] as f64;
neighbors.push((sim, j));
}
}
neighbors.sort_by(|a, b| b.0.total_cmp(&a.0));
for &(sim, j) in neighbors.iter().take(k_nn) {
if sim > 0.0 {
aff[i * n + j] = sim;
aff[j * n + i] = sim;
}
}
}
let deg: Vec<f64> = (0..n).map(|i| aff[i * n..i * n + n].iter().sum()).collect();
let mut lap = Mat::zeros(n, n);
for i in 0..n {
for j in 0..n {
let val = if i == j {
1.0 - aff[i * n + j] / deg[i].max(1e-10)
} else {
-aff[i * n + j] / (deg[i].sqrt() * deg[j].sqrt()).max(1e-10)
};
lap[(i, j)] = val;
}
}
let eig = lap.self_adjoint_eigen(Side::Lower).ok()?;
let s = eig.S();
let u = eig.U().cloned();
let mut eig_pairs: Vec<(f64, usize)> = (0..n).map(|i| (s[i], i)).collect();
eig_pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
Some(Self { n, eig_pairs, u })
}
pub(crate) fn n(&self) -> usize {
self.n
}
pub(crate) fn eig_asc(&self) -> Vec<f64> {
self.eig_pairs.iter().map(|p| p.0).collect()
}
pub(crate) fn embedding_f64(&self, k: usize) -> Vec<Vec<f64>> {
let k = k.min(self.n).max(1);
let mut features = vec![vec![0.0f64; k]; self.n];
for (i, feat) in features.iter_mut().enumerate() {
for (col, &(_, idx)) in self.eig_pairs.iter().take(k).enumerate() {
feat[col] = self.u[(i, idx)];
}
let norm: f64 = feat.iter().map(|v| v * v).sum::<f64>().sqrt();
if norm > 1e-10 {
for v in feat.iter_mut() {
*v /= norm;
}
}
}
features
}
pub(crate) fn embedding_f32(&self, k: usize) -> Vec<Vec<f32>> {
self.embedding_f64(k)
.into_iter()
.map(|row| row.into_iter().map(|v| v as f32).collect())
.collect()
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn eigengap_selects_k_on_known_sequence() {
assert_eq!(
select_k_by_normalized_eigengap(&[0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 6),
3
);
assert_eq!(select_k_by_normalized_eigengap(&[0.0, 1.0, 1.0, 1.0], 4), 1);
assert_eq!(select_k_by_normalized_eigengap(&[0.0, 0.0, 1.0, 1.0], 4), 2);
}
}