#[cfg(feature = "vbx")]
pub mod plda;
#[cfg(feature = "vbx")]
pub mod vbx;
pub mod asnorm;
pub mod assign;
pub mod domain;
pub mod short_filter;
pub use asnorm::{
AsNormClusterer, AsNormCohort, AsNormConfig, AsNormError, CohortSource, DEFAULT_AS_NORM_TOP_N,
DEFAULT_ASNORM_COHORT_MODEL_ID,
};
pub use assign::{
LocalGlobalDuration, build_cooccurrence, hungarian_local_to_global, majority_local_to_global,
};
pub use domain::{
AMI, CALLHOME, DEFAULT_DOMAIN_PROFILE, DOMAIN_PROFILES, DomainProfile, VOXCONVERSE,
domain_profile,
};
pub use short_filter::{
partition_by_min_duration, reassign_short_by_cosine, reassign_short_by_features,
};
const AHC_FALLBACK_N: usize = 8;
pub trait Clusterer: Send + Sync {
fn cluster(&self, embeddings: &[Vec<f32>]) -> Result<Vec<usize>, ClustererError>;
fn cluster_with_durations(
&self,
embeddings: &[Vec<f32>],
durations_secs: &[f64],
) -> Result<Vec<usize>, ClustererError> {
let _ = durations_secs;
self.cluster(embeddings)
}
fn max_clusters(&self) -> usize;
fn wants_raw_embeddings(&self) -> bool {
false
}
}
#[derive(Debug, thiserror::Error)]
pub enum ClustererError {
#[error("too few embeddings: got {actual}, need at least {min}")]
TooFewEmbeddings { actual: usize, min: usize },
#[error("embedding dimension mismatch: expected {expected}, got {actual} at index {index}")]
DimMismatch {
expected: usize,
actual: usize,
index: usize,
},
#[error("clustering failed: {detail}")]
AlgorithmFailed { detail: String },
#[cfg(feature = "vbx")]
#[error("PLDA model error: {0}")]
Plda(#[from] crate::clusterer::plda::PldaError),
}
fn uniform_dim(embeddings: &[Vec<f32>]) -> Result<(), ClustererError> {
let expected = embeddings[0].len();
for (index, emb) in embeddings.iter().enumerate().skip(1) {
let actual = emb.len();
if actual != expected {
return Err(ClustererError::DimMismatch {
expected,
actual,
index,
});
}
}
Ok(())
}
pub struct AhcClusterer {
max_clusters: usize,
threshold: Option<f32>,
}
impl AhcClusterer {
pub fn new(max_clusters: usize) -> Self {
Self {
max_clusters: max_clusters.max(1),
threshold: None,
}
}
pub fn with_threshold(max_clusters: usize, threshold: f32) -> Self {
Self {
max_clusters,
threshold: Some(threshold),
}
}
}
impl Default for AhcClusterer {
fn default() -> Self {
Self::new(64)
}
}
impl Clusterer for AhcClusterer {
fn cluster(&self, embeddings: &[Vec<f32>]) -> Result<Vec<usize>, ClustererError> {
if embeddings.is_empty() {
return Err(ClustererError::TooFewEmbeddings { actual: 0, min: 1 });
}
if embeddings.len() == 1 {
return Ok(vec![0]);
}
uniform_dim(embeddings)?;
let labels = match self.threshold {
Some(t) => {
crate::ahc::agglomerative_cluster_max_clusters(embeddings, t, self.max_clusters)
}
None => {
crate::ahc::agglomerative_cluster_auto_max_clusters(embeddings, self.max_clusters).0
}
};
Ok(labels)
}
fn max_clusters(&self) -> usize {
self.max_clusters
}
}
pub struct MinClusterSizeClusterer {
inner: Box<dyn Clusterer>,
min_size: usize,
}
impl MinClusterSizeClusterer {
pub fn new(inner: Box<dyn Clusterer>, min_size: usize) -> Self {
Self { inner, min_size }
}
}
impl Clusterer for MinClusterSizeClusterer {
fn cluster(&self, embeddings: &[Vec<f32>]) -> Result<Vec<usize>, ClustererError> {
let labels = self.inner.cluster(embeddings)?;
Ok(crate::ahc::prune_small_clusters(
embeddings,
labels,
self.min_size,
))
}
fn cluster_with_durations(
&self,
embeddings: &[Vec<f32>],
durations_secs: &[f64],
) -> Result<Vec<usize>, ClustererError> {
let labels = self
.inner
.cluster_with_durations(embeddings, durations_secs)?;
Ok(crate::ahc::prune_small_clusters(
embeddings,
labels,
self.min_size,
))
}
fn max_clusters(&self) -> usize {
self.inner.max_clusters()
}
fn wants_raw_embeddings(&self) -> bool {
self.inner.wants_raw_embeddings()
}
}
pub struct KmeansClusterer {
max_clusters: usize,
max_iter: usize,
trials: usize,
fast_mode: bool,
}
impl KmeansClusterer {
pub fn new(max_clusters: usize) -> Self {
Self {
max_clusters: max_clusters.max(2),
max_iter: 50,
trials: 3,
fast_mode: false,
}
}
pub fn fast_mode(mut self) -> Self {
self.fast_mode = true;
self
}
pub fn with_max_iter(mut self, max_iter: usize) -> Self {
self.max_iter = max_iter;
self
}
pub fn with_trials(mut self, trials: usize) -> Self {
self.trials = trials.max(1);
self
}
}
impl Default for KmeansClusterer {
fn default() -> Self {
Self::new(64)
}
}
impl Clusterer for KmeansClusterer {
fn cluster(&self, embeddings: &[Vec<f32>]) -> Result<Vec<usize>, ClustererError> {
if embeddings.is_empty() {
return Err(ClustererError::TooFewEmbeddings { actual: 0, min: 1 });
}
if embeddings.len() == 1 {
return Ok(vec![0]);
}
uniform_dim(embeddings)?;
if embeddings.len() < AHC_FALLBACK_N {
return AhcClusterer::new(self.max_clusters).cluster(embeddings);
}
let n = embeddings.len();
let (k_max, max_iter, trials) = if self.fast_mode {
let adaptive_k = (n / 20).clamp(5, 12).min(self.max_clusters);
(adaptive_k, 20, 1)
} else {
(self.max_clusters.min(n), self.max_iter, self.trials)
};
let labels = crate::kmeans::kmeans_auto_k(embeddings, 2, k_max, max_iter, trials);
Ok(labels)
}
fn max_clusters(&self) -> usize {
self.max_clusters
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
#[path = "trait_tests.rs"]
mod trait_tests;
#[allow(clippy::unwrap_used)]
#[cfg(test)]
#[path = "ahc_tests.rs"]
mod ahc_tests;
#[allow(clippy::unwrap_used)]
#[cfg(test)]
#[path = "min_cluster_size_tests.rs"]
mod min_cluster_size_tests;
#[allow(clippy::unwrap_used)]
#[cfg(test)]
#[path = "kmeans_tests.rs"]
mod kmeans_tests;
#[cfg(feature = "spectral")]
pub struct NmeScClusterer {
max_clusters: usize,
}
#[cfg(feature = "spectral")]
impl NmeScClusterer {
pub fn new(max_clusters: usize) -> Self {
Self {
max_clusters: max_clusters.max(1),
}
}
}
#[cfg(feature = "spectral")]
impl Default for NmeScClusterer {
fn default() -> Self {
Self::new(64)
}
}
#[cfg(feature = "spectral")]
impl Clusterer for NmeScClusterer {
fn cluster(&self, embeddings: &[Vec<f32>]) -> Result<Vec<usize>, ClustererError> {
let n = embeddings.len();
if n == 0 {
return Err(ClustererError::TooFewEmbeddings { actual: 0, min: 1 });
}
if n == 1 {
return Ok(vec![0]);
}
uniform_dim(embeddings)?;
if n < AHC_FALLBACK_N {
return AhcClusterer::new(self.max_clusters).cluster(embeddings);
}
let Some(graph) = crate::spectral::SpectralGraph::from_embeddings(embeddings) else {
return Ok(vec![0; n]);
};
let max_k = self
.max_clusters
.min(graph.n())
.min(crate::spectral::MAX_EIGENGAP_CANDIDATES);
let k = crate::spectral::select_k_by_normalized_eigengap(&graph.eig_asc(), max_k).max(1);
let spectral = graph.embedding_f32(k);
let labels = crate::kmeans::kmeans_pp(&spectral, k, 50);
Ok(labels)
}
fn max_clusters(&self) -> usize {
self.max_clusters
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
#[cfg(feature = "spectral")]
#[path = "nme_sc_tests.rs"]
mod nme_sc_tests;
#[allow(clippy::unwrap_used)]
#[cfg(test)]
#[path = "dim_uniformity_tests.rs"]
mod dim_uniformity_tests;