use crate::types::SpeakerId;
pub fn prefer_current_speaker(
current: Option<SpeakerId>,
candidates: &[(SpeakerId, f32)],
margin: f32,
) -> Option<SpeakerId> {
if candidates.is_empty() {
return None;
}
let (best_id, best_score) = candidates
.iter()
.copied()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?;
let Some(cur) = current else {
return Some(best_id);
};
if cur == best_id {
return Some(cur);
}
let cur_score = candidates
.iter()
.find(|(id, _)| *id == cur)
.map(|(_, s)| *s);
match cur_score {
Some(cs) if best_score - cs <= margin => Some(cur),
_ => Some(best_id),
}
}
pub fn label_flip_rate(first_emitted: &[SpeakerId], final_labels: &[SpeakerId]) -> f32 {
let n = first_emitted.len().min(final_labels.len());
if n == 0 {
return 0.0;
}
let flips = first_emitted
.iter()
.zip(final_labels.iter())
.take(n)
.filter(|(a, b)| a != b)
.count();
flips as f32 / n as f32
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn hysteresis_keeps_current_within_margin() {
let cur = SpeakerId(0);
let cands = [(SpeakerId(0), 0.70), (SpeakerId(1), 0.74)];
let got = prefer_current_speaker(Some(cur), &cands, 0.05);
assert_eq!(got, Some(SpeakerId(0)));
}
#[test]
fn hysteresis_switches_when_margin_exceeded() {
let cur = SpeakerId(0);
let cands = [(SpeakerId(0), 0.60), (SpeakerId(1), 0.80)];
let got = prefer_current_speaker(Some(cur), &cands, 0.05);
assert_eq!(got, Some(SpeakerId(1)));
}
#[test]
fn hysteresis_no_current_picks_best() {
let cands = [(SpeakerId(2), 0.5), (SpeakerId(3), 0.9)];
assert_eq!(
prefer_current_speaker(None, &cands, 0.1),
Some(SpeakerId(3))
);
}
#[test]
fn hysteresis_suppresses_single_flicker() {
let margin = 0.08;
let mut current = Some(SpeakerId(0));
let frames = [
vec![(SpeakerId(0), 0.85), (SpeakerId(1), 0.40)],
vec![(SpeakerId(0), 0.72), (SpeakerId(1), 0.78)], vec![(SpeakerId(0), 0.88), (SpeakerId(1), 0.35)],
];
let mut labels = Vec::new();
for f in &frames {
current = prefer_current_speaker(current, f, margin);
labels.push(current.unwrap());
}
assert_eq!(
labels,
vec![SpeakerId(0), SpeakerId(0), SpeakerId(0)],
"single near-tie flicker must not flip the label"
);
}
#[test]
fn flip_rate_zero_when_identical() {
let a = [SpeakerId(0), SpeakerId(1), SpeakerId(1)];
assert_eq!(label_flip_rate(&a, &a), 0.0);
}
#[test]
fn flip_rate_counts_disagreements() {
let first = [SpeakerId(0), SpeakerId(0), SpeakerId(1), SpeakerId(2)];
let final_ = [SpeakerId(0), SpeakerId(1), SpeakerId(1), SpeakerId(1)];
assert!((label_flip_rate(&first, &final_) - 0.5).abs() < 1e-6);
}
#[test]
fn flip_rate_empty_is_zero() {
assert_eq!(label_flip_rate(&[], &[]), 0.0);
}
}