From 14b5b53d36a66f3021a1c3c953cab43f87d8859e Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 14:15:05 +0300 Subject: [PATCH 01/11] feat(cargo): add resegmentation feature flag for v1.0 M4 work Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e12fb8fe..7e6eddb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["multimedia::audio", "science"] crate-type = ["cdylib", "rlib"] [features] -default = ["spectral", "segmentation", "embedder", "clusterer"] +default = ["spectral", "segmentation", "embedder", "clusterer", "resegmentation"] # ONNX-based embedding extractors (pulls ort). onnx = ["dep:ort"] # C FFI bindings. @@ -46,6 +46,12 @@ embedder = [] # pulls `faer`). clusterer = [] +# v1.0 Overlap-aware post-clustering resegmentation pass. +# Pure-Rust, wasm32-clean — does not require `onnx`. Operates on already- +# computed speaker centroids and overlap-region embeddings supplied by the +# caller (M6 Pipeline wires the embedder pool into this). +resegmentation = [] + # Model profile bundles. M0 only emits metadata; CLI uses these to pick which # manifest entries to ensure(). M5 will introduce INT8 model variants. profile-mobile = [] From ea34dd0948877cf101390b27f9850194719fd680 Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 14:38:33 +0300 Subject: [PATCH 02/11] feat(resegmentation): add Resegmenter trait + ResegmentError + input types Introduces the `Resegmenter` trait, `ResegmentInputs`, `SpeakerCentroid`, `OverlapRegionInput`, and `ResegmentError` behind the `resegmentation` feature flag. Five trait-conformance tests pass; pure Rust, wasm32-clean, clippy-clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib.rs | 7 ++ src/resegmentation.rs | 164 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 src/resegmentation.rs diff --git a/src/lib.rs b/src/lib.rs index d288db4d..60b64c87 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,9 @@ pub use clusterer::{AhcClusterer, Clusterer, ClustererError}; #[cfg(all(feature = "clusterer", feature = "spectral"))] pub use clusterer::NmeScClusterer; +#[cfg(feature = "resegmentation")] +pub mod resegmentation; + #[cfg(feature = "onnx")] pub mod ecapa; #[cfg(feature = "onnx")] @@ -112,10 +115,14 @@ pub use types::{ }; pub use vad::{EnergyVad, VadConfig, VadError, VoiceActivityDetector, segment_speech}; +#[cfg(feature = "onnx")] +pub use ecapa::EcapaMelOnnxExtractor; #[cfg(feature = "onnx")] #[allow(deprecated)] pub use ecapa::EcapaTdnnExtractor; #[cfg(feature = "onnx")] pub use ecapa::FbankOnnxExtractor; #[cfg(feature = "onnx")] +pub use ecapa::RawAudioOnnxExtractor; +#[cfg(feature = "onnx")] pub use onnx::OnnxEmbeddingExtractor; diff --git a/src/resegmentation.rs b/src/resegmentation.rs new file mode 100644 index 00000000..d404ad74 --- /dev/null +++ b/src/resegmentation.rs @@ -0,0 +1,164 @@ +//! v1.0 OverlapResegmenter — overlap-aware post-clustering pass. +//! +//! Added in v0.6 (M4). See `docs/superpowers/specs/2026-05-07-m4-overlap-resegmenter-design.md` +//! and `docs/superpowers/specs/2026-05-07-perfect-diarization-roadmap-v1-design.md` §3.1. +//! +//! Pure Rust, wasm32-clean. Operates on already-computed speaker centroids and +//! overlap-region embeddings supplied by the caller. M6 (`Pipeline`) wires the +//! `EmbedderPool` and `apply_overlap_mask` into this. + +use crate::types::{SpeakerId, SpeakerTurn, TimeRange}; + +/// Speaker resegmenter — given primary single-speaker turns, cluster centroids, +/// and per-overlap-region embeddings, returns a (possibly overlap-aware) flat +/// list of `SpeakerTurn`s where overlap regions may produce two turns over the +/// same time range with different speakers. +/// +/// In v1.0 (M4) the polyvoice crate introduces `Resegmenter` as the canonical +/// trait. The legacy `crate::overlap::detect_overlaps` remains as an +/// interval-only helper unrelated to this pass. +pub trait Resegmenter: Send + Sync { + /// Run the pass. + /// + /// **Requires:** all centroid vectors and all overlap embeddings have the + /// same dimension and are approximately L2-normalized. + /// **Guarantees on Ok:** every turn in `inputs.primary_turns` is preserved + /// verbatim; secondary turns (if any) carry an existing `SpeakerId` from + /// `inputs.speaker_centroids` and never repeat the primary speaker for the + /// same region; output is sorted by `time.start`. + fn resegment(&self, inputs: ResegmentInputs<'_>) -> Result, ResegmentError>; +} + +/// All inputs needed by `Resegmenter::resegment`. +#[derive(Debug, Clone)] +pub struct ResegmentInputs<'a> { + pub primary_turns: &'a [SpeakerTurn], + pub speaker_centroids: &'a [SpeakerCentroid], + pub overlap_regions: &'a [OverlapRegionInput], +} + +/// L2-normalized centroid for one speaker cluster. +#[derive(Debug, Clone, PartialEq)] +pub struct SpeakerCentroid { + pub speaker: SpeakerId, + pub embedding: Vec, +} + +/// One overlap region with its caller-supplied embedding. +/// +/// `embedding` is expected to be L2-normalized; this struct does not enforce +/// it (`OverlapResegmenter` returns `OverlapDimMismatch` only on dimension +/// mismatches, not on norm drift). +#[derive(Debug, Clone, PartialEq)] +pub struct OverlapRegionInput { + pub time: TimeRange, + pub primary_speaker: SpeakerId, + pub embedding: Vec, +} + +/// Errors from `Resegmenter` implementations. +#[derive(Debug, thiserror::Error)] +pub enum ResegmentError { + #[error("centroid dim mismatch at index {index}: expected {expected}, got {actual}")] + CentroidDimMismatch { + index: usize, + expected: usize, + actual: usize, + }, + + #[error("overlap embedding dim mismatch at index {index}: expected {expected}, got {actual}")] + OverlapDimMismatch { + index: usize, + expected: usize, + actual: usize, + }, + + #[error("primary speaker {primary} for overlap region {index} not present in centroids")] + MissingPrimaryCentroid { index: usize, primary: SpeakerId }, +} + +#[cfg(test)] +mod trait_tests { + use super::*; + + /// In-memory dummy used by trait conformance tests. + struct ConstantResegmenter { + out: Vec, + } + + impl Resegmenter for ConstantResegmenter { + fn resegment( + &self, + _inputs: ResegmentInputs<'_>, + ) -> Result, ResegmentError> { + Ok(self.out.clone()) + } + } + + fn turn(start: f64, end: f64, spk: u32) -> SpeakerTurn { + SpeakerTurn { + speaker: SpeakerId(spk), + time: TimeRange { start, end }, + text: None, + } + } + + #[test] + fn resegmenter_trait_object_is_dyn_compatible() { + let r = ConstantResegmenter { + out: vec![turn(0.0, 1.0, 0)], + }; + let _b: Box = Box::new(r); + } + + #[test] + fn resegmenter_returns_owned_turns() { + let r = ConstantResegmenter { + out: vec![turn(0.0, 1.0, 0), turn(1.0, 2.0, 1)], + }; + let inputs = ResegmentInputs { + primary_turns: &[], + speaker_centroids: &[], + overlap_regions: &[], + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].speaker, SpeakerId(0)); + } + + #[test] + fn error_centroid_dim_mismatch_displays() { + let err = ResegmentError::CentroidDimMismatch { + index: 1, + expected: 192, + actual: 256, + }; + let msg = format!("{err}"); + assert!(msg.contains("192")); + assert!(msg.contains("256")); + assert!(msg.contains("index 1")); + } + + #[test] + fn error_overlap_dim_mismatch_displays() { + let err = ResegmentError::OverlapDimMismatch { + index: 0, + expected: 192, + actual: 64, + }; + let msg = format!("{err}"); + assert!(msg.contains("192")); + assert!(msg.contains("64")); + } + + #[test] + fn error_missing_primary_centroid_displays() { + let err = ResegmentError::MissingPrimaryCentroid { + index: 2, + primary: SpeakerId(7), + }; + let msg = format!("{err}"); + assert!(msg.contains('2')); + assert!(msg.contains('7')); + } +} From a88be880e96262f58b1bdf893bba7662b34768c7 Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 14:46:24 +0300 Subject: [PATCH 03/11] feat(resegmentation): add compute_centroids + extract_overlap_time_ranges helpers Co-Authored-By: Claude Sonnet 4.6 --- src/resegmentation.rs | 213 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/src/resegmentation.rs b/src/resegmentation.rs index d404ad74..a8de537a 100644 --- a/src/resegmentation.rs +++ b/src/resegmentation.rs @@ -77,6 +77,91 @@ pub enum ResegmentError { MissingPrimaryCentroid { index: usize, primary: SpeakerId }, } +/// Compute per-cluster L2-normalized centroids from clustered embeddings. +/// +/// `labels[i]` is the cluster label of `embeddings[i]`. The cluster id stored +/// in the resulting `SpeakerCentroid` is the raw `labels[i]` cast to `SpeakerId`. +/// Empty clusters yield no entry. Output is sorted by `SpeakerId.0` ascending. +/// +/// Returns an empty `Vec` if `embeddings.len() != labels.len()` or both are +/// empty — never panics. +/// +/// **Pure Rust, wasm32-clean.** +pub fn compute_centroids( + embeddings: &[Vec], + labels: &[usize], +) -> Vec { + if embeddings.len() != labels.len() || embeddings.is_empty() { + return Vec::new(); + } + // Bucket by label. + let mut buckets: std::collections::BTreeMap>> = + std::collections::BTreeMap::new(); + for (emb, &lbl) in embeddings.iter().zip(labels.iter()) { + buckets.entry(lbl).or_default().push(emb); + } + let mut out = Vec::with_capacity(buckets.len()); + for (lbl, members) in buckets { + let owned: Vec> = members.iter().map(|e| (*e).clone()).collect(); + if let Some(mut mean) = crate::utils::mean_vector(&owned) { + crate::utils::l2_normalize(&mut mean); + // SpeakerId is u32; clamp to its range conservatively. + let id = SpeakerId(lbl as u32); + out.push(SpeakerCentroid { + speaker: id, + embedding: mean, + }); + } + } + // BTreeMap iterates in label order, but cast to SpeakerId may reorder if + // u32 truncation happened. Sort explicitly. + out.sort_by_key(|c| c.speaker.0); + out +} + +/// Find pairs of `RawSegment`s that share a time range, are flagged +/// `is_overlap = true`, and carry two distinct `local_speaker_idx`. +/// Returns `(time_range, lo_local_idx, hi_local_idx)` per detected pair. +/// +/// "Same time range" uses an `f64` tolerance of `1e-6`. +/// +/// `lo_local_idx < hi_local_idx`. Caller is responsible for the local→global +/// `SpeakerId` mapping (typically from the same clustering pipeline). +/// +/// **Pure Rust, wasm32-clean.** Gated `segmentation` because `RawSegment` +/// lives in the segmentation module. +#[cfg(feature = "segmentation")] +pub fn extract_overlap_time_ranges( + segments: &[crate::segmentation::RawSegment], +) -> Vec<(TimeRange, u8, u8)> { + let mut pairs: Vec<(TimeRange, u8, u8)> = Vec::new(); + for (i, a) in segments.iter().enumerate() { + if !a.is_overlap { + continue; + } + for b in segments.iter().skip(i + 1) { + if !b.is_overlap { + continue; + } + if a.local_speaker_idx == b.local_speaker_idx { + continue; + } + if (a.time.start - b.time.start).abs() > 1e-6 + || (a.time.end - b.time.end).abs() > 1e-6 + { + continue; + } + let (lo, hi) = if a.local_speaker_idx < b.local_speaker_idx { + (a.local_speaker_idx, b.local_speaker_idx) + } else { + (b.local_speaker_idx, a.local_speaker_idx) + }; + pairs.push((a.time, lo, hi)); + } + } + pairs +} + #[cfg(test)] mod trait_tests { use super::*; @@ -162,3 +247,131 @@ mod trait_tests { assert!(msg.contains('7')); } } + +#[cfg(test)] +mod centroid_tests { + use super::*; + + fn unit(dim: usize, axis: usize) -> Vec { + let mut v = vec![0.0_f32; dim]; + v[axis] = 1.0; + v + } + + #[test] + fn compute_centroids_l2_normalized() { + let embeddings = vec![ + unit(3, 0), + unit(3, 0), + unit(3, 1), + unit(3, 1), + ]; + let labels = vec![0, 0, 1, 1]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 2); + for c in ¢roids { + let n: f32 = c.embedding.iter().map(|x| x * x).sum::().sqrt(); + assert!((n - 1.0).abs() < 1e-3, "centroid not L2-normalized: norm={n}"); + } + } + + #[test] + fn compute_centroids_drops_empty_clusters() { + // Labels skip from 0 to 2; cluster 1 has no members. + let embeddings = vec![unit(3, 0), unit(3, 1), unit(3, 1)]; + let labels = vec![0, 2, 2]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 2); + let speakers: Vec = centroids.iter().map(|c| c.speaker.0).collect(); + assert_eq!(speakers, vec![0, 2]); + } + + #[test] + fn compute_centroids_sorted_by_speaker_id() { + let embeddings = vec![unit(3, 0), unit(3, 1), unit(3, 2)]; + let labels = vec![5, 1, 3]; + let centroids = compute_centroids(&embeddings, &labels); + let speakers: Vec = centroids.iter().map(|c| c.speaker.0).collect(); + assert_eq!(speakers, vec![1, 3, 5]); + } + + #[test] + fn compute_centroids_empty_input_returns_empty() { + let centroids = compute_centroids(&[], &[]); + assert!(centroids.is_empty()); + } + + #[test] + fn compute_centroids_label_mismatch_returns_empty() { + // Mismatched lengths: caller bug, conservative empty return rather than panic. + let centroids = compute_centroids(&[unit(3, 0)], &[0, 1]); + assert!(centroids.is_empty()); + } +} + +#[cfg(all(test, feature = "segmentation"))] +mod overlap_extract_tests { + use super::*; + use crate::segmentation::RawSegment; + use crate::types::Confidence; + + fn raw(start: f64, end: f64, spk: u8, overlap: bool) -> RawSegment { + RawSegment { + time: TimeRange { start, end }, + local_speaker_idx: spk, + is_overlap: overlap, + confidence: Confidence::new(0.9).unwrap(), + } + } + + #[test] + fn extract_returns_pairs_for_simultaneous_overlap_segments() { + // Two RawSegments with the same time range and is_overlap = true: + // aggregator's canonical overlap output. + let segs = vec![ + raw(0.0, 1.0, 0, true), + raw(0.0, 1.0, 1, true), + ]; + let pairs = extract_overlap_time_ranges(&segs); + assert_eq!(pairs.len(), 1); + assert!((pairs[0].0.start - 0.0).abs() < 1e-6); + assert!((pairs[0].0.end - 1.0).abs() < 1e-6); + // local pair is (lo, hi) where lo < hi. + assert_eq!(pairs[0].1, 0); + assert_eq!(pairs[0].2, 1); + } + + #[test] + fn extract_ignores_non_overlap_segments() { + let segs = vec![ + raw(0.0, 1.0, 0, false), + raw(0.0, 1.0, 1, false), + ]; + let pairs = extract_overlap_time_ranges(&segs); + assert!(pairs.is_empty()); + } + + #[test] + fn extract_ignores_overlap_flag_without_pair() { + // is_overlap=true but only one local speaker present at this range. + let segs = vec![raw(0.0, 1.0, 0, true)]; + let pairs = extract_overlap_time_ranges(&segs); + assert!(pairs.is_empty()); + } + + #[test] + fn extract_handles_multiple_overlap_regions() { + let segs = vec![ + raw(0.0, 1.0, 0, true), + raw(0.0, 1.0, 1, true), + raw(2.0, 3.0, 1, true), + raw(2.0, 3.0, 2, true), + ]; + let pairs = extract_overlap_time_ranges(&segs); + assert_eq!(pairs.len(), 2); + assert_eq!(pairs[0].1, 0); + assert_eq!(pairs[0].2, 1); + assert_eq!(pairs[1].1, 1); + assert_eq!(pairs[1].2, 2); + } +} From dc1511ea2ad2d6112755bfeaaafe33101f4dcb3b Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 14:50:43 +0300 Subject: [PATCH 04/11] refactor(resegmentation): name 1e-6 time tolerance + add 3-way overlap test --- src/resegmentation.rs | 56 ++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/src/resegmentation.rs b/src/resegmentation.rs index a8de537a..5b960f0f 100644 --- a/src/resegmentation.rs +++ b/src/resegmentation.rs @@ -9,6 +9,11 @@ use crate::types::{SpeakerId, SpeakerTurn, TimeRange}; +/// Time-range equality tolerance (seconds) used by `extract_overlap_time_ranges` +/// when matching pairs of `RawSegment`s that should occupy the same span. +#[cfg(feature = "segmentation")] +const TIME_RANGE_EPS_SECS: f64 = 1e-6; + /// Speaker resegmenter — given primary single-speaker turns, cluster centroids, /// and per-overlap-region embeddings, returns a (possibly overlap-aware) flat /// list of `SpeakerTurn`s where overlap regions may produce two turns over the @@ -87,10 +92,7 @@ pub enum ResegmentError { /// empty — never panics. /// /// **Pure Rust, wasm32-clean.** -pub fn compute_centroids( - embeddings: &[Vec], - labels: &[usize], -) -> Vec { +pub fn compute_centroids(embeddings: &[Vec], labels: &[usize]) -> Vec { if embeddings.len() != labels.len() || embeddings.is_empty() { return Vec::new(); } @@ -105,7 +107,7 @@ pub fn compute_centroids( let owned: Vec> = members.iter().map(|e| (*e).clone()).collect(); if let Some(mut mean) = crate::utils::mean_vector(&owned) { crate::utils::l2_normalize(&mut mean); - // SpeakerId is u32; clamp to its range conservatively. + // Truncating cast; cluster labels are well within u32 range in practice. let id = SpeakerId(lbl as u32); out.push(SpeakerCentroid { speaker: id, @@ -146,8 +148,8 @@ pub fn extract_overlap_time_ranges( if a.local_speaker_idx == b.local_speaker_idx { continue; } - if (a.time.start - b.time.start).abs() > 1e-6 - || (a.time.end - b.time.end).abs() > 1e-6 + if (a.time.start - b.time.start).abs() > TIME_RANGE_EPS_SECS + || (a.time.end - b.time.end).abs() > TIME_RANGE_EPS_SECS { continue; } @@ -260,18 +262,16 @@ mod centroid_tests { #[test] fn compute_centroids_l2_normalized() { - let embeddings = vec![ - unit(3, 0), - unit(3, 0), - unit(3, 1), - unit(3, 1), - ]; + let embeddings = vec![unit(3, 0), unit(3, 0), unit(3, 1), unit(3, 1)]; let labels = vec![0, 0, 1, 1]; let centroids = compute_centroids(&embeddings, &labels); assert_eq!(centroids.len(), 2); for c in ¢roids { let n: f32 = c.embedding.iter().map(|x| x * x).sum::().sqrt(); - assert!((n - 1.0).abs() < 1e-3, "centroid not L2-normalized: norm={n}"); + assert!( + (n - 1.0).abs() < 1e-3, + "centroid not L2-normalized: norm={n}" + ); } } @@ -328,10 +328,7 @@ mod overlap_extract_tests { fn extract_returns_pairs_for_simultaneous_overlap_segments() { // Two RawSegments with the same time range and is_overlap = true: // aggregator's canonical overlap output. - let segs = vec![ - raw(0.0, 1.0, 0, true), - raw(0.0, 1.0, 1, true), - ]; + let segs = vec![raw(0.0, 1.0, 0, true), raw(0.0, 1.0, 1, true)]; let pairs = extract_overlap_time_ranges(&segs); assert_eq!(pairs.len(), 1); assert!((pairs[0].0.start - 0.0).abs() < 1e-6); @@ -343,10 +340,7 @@ mod overlap_extract_tests { #[test] fn extract_ignores_non_overlap_segments() { - let segs = vec![ - raw(0.0, 1.0, 0, false), - raw(0.0, 1.0, 1, false), - ]; + let segs = vec![raw(0.0, 1.0, 0, false), raw(0.0, 1.0, 1, false)]; let pairs = extract_overlap_time_ranges(&segs); assert!(pairs.is_empty()); } @@ -374,4 +368,22 @@ mod overlap_extract_tests { assert_eq!(pairs[1].1, 1); assert_eq!(pairs[1].2, 2); } + + #[test] + fn extract_three_way_overlap_emits_all_three_pairs() { + // Three RawSegments at the same time range with distinct local indices. + // The O(N²) loop should emit all (0,1), (0,2), (1,2) pairs. + let segs = vec![ + raw(0.0, 1.0, 0, true), + raw(0.0, 1.0, 1, true), + raw(0.0, 1.0, 2, true), + ]; + let pairs = extract_overlap_time_ranges(&segs); + assert_eq!(pairs.len(), 3); + let local_pairs: std::collections::HashSet<(u8, u8)> = + pairs.iter().map(|p| (p.1, p.2)).collect(); + assert!(local_pairs.contains(&(0, 1))); + assert!(local_pairs.contains(&(0, 2))); + assert!(local_pairs.contains(&(1, 2))); + } } From da23184f9260a6ce6774515a522ebfc6709be7ac Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 14:54:28 +0300 Subject: [PATCH 05/11] feat(resegmentation): add OverlapResegmenter cosine-matching impl Co-Authored-By: Claude Sonnet 4.6 --- src/resegmentation.rs | 333 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) diff --git a/src/resegmentation.rs b/src/resegmentation.rs index 5b960f0f..38e7c224 100644 --- a/src/resegmentation.rs +++ b/src/resegmentation.rs @@ -164,6 +164,133 @@ pub fn extract_overlap_time_ranges( pairs } +/// Default-constructible overlap-aware resegmenter that picks the nearest +/// non-primary cluster centroid (by cosine similarity) for each overlap region +/// above a configurable threshold and minimum duration. +/// +/// Typical usage (from `Pipeline` in M6): +/// +/// ```rust,ignore +/// let r = OverlapResegmenter::default(); +/// let out = r.resegment(ResegmentInputs { +/// primary_turns: &turns, +/// speaker_centroids: ¢roids, +/// overlap_regions: ®ions, +/// })?; +/// ``` +pub struct OverlapResegmenter { + threshold: f32, + min_overlap_secs: f32, +} + +impl OverlapResegmenter { + /// `threshold` — minimum cosine similarity required to attach a secondary + /// speaker to an overlap region. Default `0.0` (always attach the nearest + /// non-primary cluster). + /// `min_overlap_secs` — overlap regions shorter than this are skipped. + /// Default `0.1`. + pub fn new(threshold: f32, min_overlap_secs: f32) -> Self { + Self { + threshold, + min_overlap_secs: min_overlap_secs.max(0.0), + } + } + + pub fn threshold(&self) -> f32 { + self.threshold + } + + pub fn min_overlap_secs(&self) -> f32 { + self.min_overlap_secs + } +} + +impl Default for OverlapResegmenter { + fn default() -> Self { + Self::new(0.0, 0.1) + } +} + +impl Resegmenter for OverlapResegmenter { + fn resegment( + &self, + inputs: ResegmentInputs<'_>, + ) -> Result, ResegmentError> { + let mut out: Vec = inputs.primary_turns.to_vec(); + + // Fast paths. + if inputs.speaker_centroids.len() < 2 || inputs.overlap_regions.is_empty() { + out.sort_by(|a, b| a.time.start.total_cmp(&b.time.start)); + return Ok(out); + } + + // Validate centroid dimensionality first (single-pass). + let expected_dim = inputs.speaker_centroids[0].embedding.len(); + for (i, c) in inputs.speaker_centroids.iter().enumerate() { + if c.embedding.len() != expected_dim { + return Err(ResegmentError::CentroidDimMismatch { + index: i, + expected: expected_dim, + actual: c.embedding.len(), + }); + } + } + + for (i, region) in inputs.overlap_regions.iter().enumerate() { + // Validate dim. + if region.embedding.len() != expected_dim { + return Err(ResegmentError::OverlapDimMismatch { + index: i, + expected: expected_dim, + actual: region.embedding.len(), + }); + } + // Validate primary present. + if !inputs + .speaker_centroids + .iter() + .any(|c| c.speaker == region.primary_speaker) + { + return Err(ResegmentError::MissingPrimaryCentroid { + index: i, + primary: region.primary_speaker, + }); + } + // Skip too-short regions. + if (region.time.duration() as f32) < self.min_overlap_secs { + continue; + } + // Find best non-primary cluster. + let mut best: Option<(SpeakerId, f32)> = None; + for c in inputs.speaker_centroids.iter() { + if c.speaker == region.primary_speaker { + continue; + } + let s = crate::utils::cosine_similarity(®ion.embedding, &c.embedding); + let take = match best { + None => true, + Some((_, b)) => s > b, + }; + if take { + best = Some((c.speaker, s)); + } + } + if let Some((id, score)) = best + && score > self.threshold + { + out.push(SpeakerTurn { + speaker: id, + time: region.time, + text: None, + }); + } + } + + out.sort_by(|a, b| a.time.start.total_cmp(&b.time.start)); + Ok(out) + } +} + #[cfg(test)] mod trait_tests { use super::*; @@ -387,3 +514,209 @@ mod overlap_extract_tests { assert!(local_pairs.contains(&(1, 2))); } } + +#[cfg(test)] +mod resegmenter_tests { + use super::*; + use crate::types::{SpeakerId, SpeakerTurn, TimeRange}; + + fn unit(dim: usize, axis: usize) -> Vec { + let mut v = vec![0.0_f32; dim]; + v[axis] = 1.0; + v + } + + fn turn(start: f64, end: f64, spk: u32) -> SpeakerTurn { + SpeakerTurn { + speaker: SpeakerId(spk), + time: TimeRange { start, end }, + text: None, + } + } + + fn centroid(spk: u32, dim: usize, axis: usize) -> SpeakerCentroid { + SpeakerCentroid { + speaker: SpeakerId(spk), + embedding: unit(dim, axis), + } + } + + fn region(start: f64, end: f64, primary: u32, dim: usize, axis: usize) -> OverlapRegionInput { + OverlapRegionInput { + time: TimeRange { start, end }, + primary_speaker: SpeakerId(primary), + embedding: unit(dim, axis), + } + } + + #[test] + fn no_overlap_passes_primary_through() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0), turn(2.0, 3.0, 1)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: &[], + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary); + } + + #[test] + fn single_cluster_passes_through() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0)]; + let regions = vec![region(0.5, 0.9, 0, 3, 0)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary); + } + + #[test] + fn picks_secondary_excluding_primary() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1), centroid(2, 3, 2)]; + // Overlap region embedding lies along axis 1 → nearest to centroid id=1. + let regions = vec![region(0.0, 1.0, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out.len(), 2); + // Both turns cover (0.0, 1.0); one is primary (id=0), other is secondary (id=1). + let speakers: Vec = out.iter().map(|t| t.speaker.0).collect(); + assert!(speakers.contains(&0)); + assert!(speakers.contains(&1)); + assert!(!speakers.contains(&2)); + } + + #[test] + fn threshold_blocks_low_cosine() { + // Threshold 0.99 — only near-perfect matches allowed. + let r = OverlapResegmenter::new(0.99, 0.0); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + // Overlap embedding along axis 0 (matches primary); cosine to centroid 1 = 0. + let regions = vec![region(0.0, 1.0, 0, 3, 0)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary, "no secondary should be appended"); + } + + #[test] + fn min_duration_blocks_short_region() { + // Region duration 0.05s < default 0.1s → skipped. + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + let regions = vec![region(0.10, 0.15, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary); + } + + #[test] + fn output_is_sorted_by_start() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(2.0, 3.0, 0), turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + let regions = vec![region(2.0, 3.0, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + for w in out.windows(2) { + assert!(w[0].time.start <= w[1].time.start); + } + } + + #[test] + fn missing_primary_centroid_errors() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(1, 3, 1), centroid(2, 3, 2)]; + let regions = vec![region(0.0, 1.0, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let err = r.resegment(inputs).expect_err("missing primary must error"); + assert!(matches!( + err, + ResegmentError::MissingPrimaryCentroid { primary: SpeakerId(0), .. } + )); + } + + #[test] + fn centroid_dim_mismatch_errors() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![ + centroid(0, 3, 0), + SpeakerCentroid { + speaker: SpeakerId(1), + embedding: vec![1.0, 0.0], // dim 2, not 3 + }, + ]; + let regions = vec![region(0.0, 1.0, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let err = r.resegment(inputs).expect_err("dim mismatch must error"); + assert!(matches!(err, ResegmentError::CentroidDimMismatch { .. })); + } + + #[test] + fn overlap_dim_mismatch_errors() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + let regions = vec![OverlapRegionInput { + time: TimeRange { start: 0.0, end: 1.0 }, + primary_speaker: SpeakerId(0), + embedding: vec![1.0, 0.0], // dim 2, not 3 + }]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let err = r.resegment(inputs).expect_err("dim mismatch must error"); + assert!(matches!(err, ResegmentError::OverlapDimMismatch { .. })); + } + + #[test] + fn empty_centroids_passes_through() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: &[], + overlap_regions: &[], + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary); + } +} From 70f2c2b11b0d5750ea61b181a6b585daddc098f8 Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 15:01:35 +0300 Subject: [PATCH 06/11] refactor(resegmentation): keep f64 in duration check, add Debug derive, document validation order - Fix 1: Keep f64 precision in min_overlap_secs comparison to avoid f32 boundary truncation - Fix 2: Add #[derive(Debug, Clone, Copy)] to OverlapResegmenter for better introspection - Fix 3: Document validation order in Resegmenter::resegment trait doc comment All 25 tests pass. clippy, fmt, and wasm32 checks clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resegmentation.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/resegmentation.rs b/src/resegmentation.rs index 38e7c224..5ff63d86 100644 --- a/src/resegmentation.rs +++ b/src/resegmentation.rs @@ -31,6 +31,11 @@ pub trait Resegmenter: Send + Sync { /// verbatim; secondary turns (if any) carry an existing `SpeakerId` from /// `inputs.speaker_centroids` and never repeat the primary speaker for the /// same region; output is sorted by `time.start`. + /// + /// **Validation order:** structural checks (centroid dimensions, overlap + /// embedding dimensions, `primary_speaker` presence) run before duration + /// filtering. A short overlap region with an invalid primary speaker + /// returns `MissingPrimaryCentroid`, not silent success. fn resegment(&self, inputs: ResegmentInputs<'_>) -> Result, ResegmentError>; } @@ -178,6 +183,7 @@ pub fn extract_overlap_time_ranges( /// overlap_regions: ®ions, /// })?; /// ``` +#[derive(Debug, Clone, Copy)] pub struct OverlapResegmenter { threshold: f32, min_overlap_secs: f32, @@ -212,10 +218,7 @@ impl Default for OverlapResegmenter { } impl Resegmenter for OverlapResegmenter { - fn resegment( - &self, - inputs: ResegmentInputs<'_>, - ) -> Result, ResegmentError> { + fn resegment(&self, inputs: ResegmentInputs<'_>) -> Result, ResegmentError> { let mut out: Vec = inputs.primary_turns.to_vec(); // Fast paths. @@ -256,8 +259,8 @@ impl Resegmenter for OverlapResegmenter { primary: region.primary_speaker, }); } - // Skip too-short regions. - if (region.time.duration() as f32) < self.min_overlap_secs { + // Skip too-short regions. Compare in f64 to avoid f32 boundary truncation. + if region.time.duration() < f64::from(self.min_overlap_secs) { continue; } // Find best non-primary cluster. @@ -663,7 +666,10 @@ mod resegmenter_tests { let err = r.resegment(inputs).expect_err("missing primary must error"); assert!(matches!( err, - ResegmentError::MissingPrimaryCentroid { primary: SpeakerId(0), .. } + ResegmentError::MissingPrimaryCentroid { + primary: SpeakerId(0), + .. + } )); } @@ -694,7 +700,10 @@ mod resegmenter_tests { let primary = vec![turn(0.0, 1.0, 0)]; let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; let regions = vec![OverlapRegionInput { - time: TimeRange { start: 0.0, end: 1.0 }, + time: TimeRange { + start: 0.0, + end: 1.0, + }, primary_speaker: SpeakerId(0), embedding: vec![1.0, 0.0], // dim 2, not 3 }]; From aac804cc0f3a0063c8369ab7a12f65787a770b50 Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 15:06:43 +0300 Subject: [PATCH 07/11] feat(lib): re-export resegmentation surface + integration/miri tests + changelog Adds crate-root re-exports for all public resegmentation symbols, two new test files (3 integration + 3 miri tests), and M4 CHANGELOG entry. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 +++ src/lib.rs | 9 ++ tests/miri_resegmentation.rs | 92 +++++++++++++++++++ tests/resegmentation_test.rs | 166 +++++++++++++++++++++++++++++++++++ 4 files changed, 280 insertions(+) create mode 100644 tests/miri_resegmentation.rs create mode 100644 tests/resegmentation_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8133a548..20d7b99c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Integration test on synthetic 4-cluster data (no model required) — runs in every PR's normal `cargo test` (not `--ignored`). +### Added (M4 — Overlap resegmenter) +- `polyvoice::resegmentation` module: `Resegmenter` trait, `ResegmentError`, + `OverlapResegmenter` (pure-Rust post-clustering pass that attaches a second + speaker to overlap regions via nearest-cosine cluster), `ResegmentInputs`, + `OverlapRegionInput`, `SpeakerCentroid`, helpers `compute_centroids` and + `extract_overlap_time_ranges` (gated `segmentation`). +- New Cargo feature `resegmentation` (in default features). Pure-Rust core, + wasm32-clean, no `onnx` requirement. +- Integration test on synthetic two-speaker / three-speaker data + RTTM + round-trip — runs in every PR's normal `cargo test`. +- Miri-friendly test target `tests/miri_resegmentation.rs` covering + no-overlap, single-overlap, and centroid math paths. + ## [0.5.2] - 2025-05-05 ### Added diff --git a/src/lib.rs b/src/lib.rs index 60b64c87..23156683 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,6 +93,15 @@ pub use clusterer::NmeScClusterer; #[cfg(feature = "resegmentation")] pub mod resegmentation; +#[cfg(feature = "resegmentation")] +pub use resegmentation::{ + OverlapRegionInput, OverlapResegmenter, ResegmentError, ResegmentInputs, Resegmenter, + SpeakerCentroid, compute_centroids, +}; + +#[cfg(all(feature = "resegmentation", feature = "segmentation"))] +pub use resegmentation::extract_overlap_time_ranges; + #[cfg(feature = "onnx")] pub mod ecapa; #[cfg(feature = "onnx")] diff --git a/tests/miri_resegmentation.rs b/tests/miri_resegmentation.rs new file mode 100644 index 00000000..62012590 --- /dev/null +++ b/tests/miri_resegmentation.rs @@ -0,0 +1,92 @@ +//! Miri-friendly subset of M4 resegmenter tests. Covers no-overlap pass-through, +//! single-overlap cosine matching, and centroid math. ONNX-free, deterministic. + +#![cfg(feature = "resegmentation")] + +use polyvoice::resegmentation::{ + OverlapRegionInput, OverlapResegmenter, ResegmentInputs, Resegmenter, SpeakerCentroid, + compute_centroids, +}; +use polyvoice::types::{SpeakerId, SpeakerTurn, TimeRange}; + +fn unit(dim: usize, axis: usize) -> Vec { + let mut v = vec![0.0_f32; dim]; + v[axis] = 1.0; + v +} + +#[test] +fn miri_resegment_no_overlap() { + let primary = vec![SpeakerTurn { + speaker: SpeakerId(0), + time: TimeRange { + start: 0.0, + end: 1.0, + }, + text: None, + }]; + let centroids = vec![SpeakerCentroid { + speaker: SpeakerId(0), + embedding: unit(4, 0), + }]; + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: &[], + }) + .unwrap(); + assert_eq!(out, primary); +} + +#[test] +fn miri_resegment_single_overlap() { + let primary = vec![SpeakerTurn { + speaker: SpeakerId(0), + time: TimeRange { + start: 0.0, + end: 1.0, + }, + text: None, + }]; + let centroids = vec![ + SpeakerCentroid { + speaker: SpeakerId(0), + embedding: unit(4, 0), + }, + SpeakerCentroid { + speaker: SpeakerId(1), + embedding: unit(4, 1), + }, + ]; + let regions = vec![OverlapRegionInput { + time: TimeRange { + start: 0.0, + end: 1.0, + }, + primary_speaker: SpeakerId(0), + embedding: unit(4, 1), + }]; + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }) + .unwrap(); + assert_eq!(out.len(), 2); +} + +#[test] +fn miri_compute_centroids() { + let embeddings = vec![unit(4, 0), unit(4, 0), unit(4, 1), unit(4, 1)]; + let labels = vec![0, 0, 1, 1]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 2); + for c in ¢roids { + let n: f32 = c.embedding.iter().map(|x| x * x).sum::().sqrt(); + assert!((n - 1.0).abs() < 1e-3); + } +} diff --git a/tests/resegmentation_test.rs b/tests/resegmentation_test.rs new file mode 100644 index 00000000..c7604f2a --- /dev/null +++ b/tests/resegmentation_test.rs @@ -0,0 +1,166 @@ +//! Integration test for the M4 OverlapResegmenter on synthetic data. +//! Pure-CPU; runs in normal `cargo test` (no model required). + +#![cfg(feature = "resegmentation")] + +use polyvoice::resegmentation::{ + OverlapRegionInput, OverlapResegmenter, ResegmentInputs, Resegmenter, SpeakerCentroid, + compute_centroids, +}; +use polyvoice::types::{SpeakerId, SpeakerTurn, TimeRange}; + +fn unit(dim: usize, axis: usize) -> Vec { + let mut v = vec![0.0_f32; dim]; + v[axis] = 1.0; + v +} + +fn turn(start: f64, end: f64, spk: u32) -> SpeakerTurn { + SpeakerTurn { + speaker: SpeakerId(spk), + time: TimeRange { start, end }, + text: None, + } +} + +#[test] +fn end_to_end_synthetic_two_speakers_overlap() { + // Two speakers, one overlap region. Embeddings are 8-d unit vectors. + let dim = 8; + let embeddings = vec![ + unit(dim, 0), + unit(dim, 0), + unit(dim, 0), + unit(dim, 1), + unit(dim, 1), + unit(dim, 1), + ]; + let labels = vec![0, 0, 0, 1, 1, 1]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 2); + + let primary = vec![turn(0.0, 5.0, 0), turn(5.0, 10.0, 1)]; + // Overlap at 4.5–5.5: primary spk=0, embedding aligned with axis 1 (i.e. spk=1). + let regions = vec![OverlapRegionInput { + time: TimeRange { + start: 4.5, + end: 5.5, + }, + primary_speaker: SpeakerId(0), + embedding: unit(dim, 1), + }]; + + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }) + .unwrap(); + assert_eq!(out.len(), 3, "primary 2 + secondary 1"); + let secondary = out + .iter() + .find(|t| (t.time.start - 4.5).abs() < 1e-6 && (t.time.end - 5.5).abs() < 1e-6) + .expect("secondary turn at 4.5..5.5 missing"); + assert_eq!(secondary.speaker, SpeakerId(1)); +} + +#[test] +fn end_to_end_three_speakers_two_pairs() { + let dim = 8; + let embeddings = vec![ + unit(dim, 0), + unit(dim, 0), + unit(dim, 1), + unit(dim, 1), + unit(dim, 2), + unit(dim, 2), + ]; + let labels = vec![0, 0, 1, 1, 2, 2]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 3); + + let primary = vec![turn(0.0, 2.0, 0), turn(2.0, 4.0, 1), turn(4.0, 6.0, 2)]; + let regions = vec![ + // 1.0..2.0: primary 0, secondary best should be 1. + OverlapRegionInput { + time: TimeRange { + start: 1.0, + end: 2.0, + }, + primary_speaker: SpeakerId(0), + embedding: unit(dim, 1), + }, + // 4.0..5.0: primary 2, secondary best should be 1. + OverlapRegionInput { + time: TimeRange { + start: 4.0, + end: 5.0, + }, + primary_speaker: SpeakerId(2), + embedding: unit(dim, 1), + }, + ]; + + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }) + .unwrap(); + // Two secondaries appended. + assert_eq!(out.len(), 5); + let n_spk1 = out.iter().filter(|t| t.speaker == SpeakerId(1)).count(); + assert!(n_spk1 >= 2, "expected ≥2 turns for speaker 1, got {n_spk1}"); + // Sorted by start. + for w in out.windows(2) { + assert!(w[0].time.start <= w[1].time.start); + } +} + +#[test] +fn rttm_round_trip_preserves_overlap_turns() { + use polyvoice::rttm::write_rttm; + + let dim = 4; + let centroids = vec![ + SpeakerCentroid { + speaker: SpeakerId(0), + embedding: unit(dim, 0), + }, + SpeakerCentroid { + speaker: SpeakerId(1), + embedding: unit(dim, 1), + }, + ]; + let primary = vec![turn(0.0, 1.0, 0)]; + let regions = vec![OverlapRegionInput { + time: TimeRange { + start: 0.2, + end: 0.8, + }, + primary_speaker: SpeakerId(0), + embedding: unit(dim, 1), + }]; + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }) + .unwrap(); + assert_eq!(out.len(), 2); + + // RTTM writer must accept overlapping spans without error or data loss. + let mut buf = Vec::new(); + write_rttm(&mut buf, "test", &out).expect("rttm write"); + let s = String::from_utf8(buf).unwrap(); + let n_lines = s.lines().filter(|l| l.starts_with("SPEAKER")).count(); + assert_eq!(n_lines, 2, "expected 2 SPEAKER lines, got {n_lines}: {s}"); + assert!(s.contains("SPEAKER_00")); + assert!(s.contains("SPEAKER_01")); +} From af715614b9420d8f7ddbb5bad4481cff4a8576d3 Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 15:12:21 +0300 Subject: [PATCH 08/11] =?UTF-8?q?test(resegmentation):=20add=20property=20?= =?UTF-8?q?tests=20for=20=C2=A79=20invariants=20+=20tighten=20Miri=20asser?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/miri_resegmentation.rs | 7 ++ tests/resegmentation_test.rs | 162 +++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/tests/miri_resegmentation.rs b/tests/miri_resegmentation.rs index 62012590..d5fa486e 100644 --- a/tests/miri_resegmentation.rs +++ b/tests/miri_resegmentation.rs @@ -77,6 +77,13 @@ fn miri_resegment_single_overlap() { }) .unwrap(); assert_eq!(out.len(), 2); + // Secondary turn must be SpeakerId(1) — its centroid is the unit vector + // along axis 1, which exactly matches the overlap embedding. + assert!( + out.iter().any(|t| t.speaker == SpeakerId(1)), + "expected secondary SpeakerId(1) appended, got speakers {:?}", + out.iter().map(|t| t.speaker.0).collect::>() + ); } #[test] diff --git a/tests/resegmentation_test.rs b/tests/resegmentation_test.rs index c7604f2a..ed871f34 100644 --- a/tests/resegmentation_test.rs +++ b/tests/resegmentation_test.rs @@ -164,3 +164,165 @@ fn rttm_round_trip_preserves_overlap_turns() { assert!(s.contains("SPEAKER_00")); assert!(s.contains("SPEAKER_01")); } + +mod proptests { + use super::{turn, unit}; + use polyvoice::resegmentation::{ + OverlapRegionInput, OverlapResegmenter, ResegmentInputs, Resegmenter, SpeakerCentroid, + compute_centroids, + }; + use polyvoice::types::{SpeakerId, SpeakerTurn, TimeRange}; + use proptest::prelude::*; + + /// Build an L2-normalized embedding from raw f32 components. + /// Falls back to a unit-axis vector if the input norm is too small to + /// normalize stably (mirrors `crate::utils::l2_normalize`). + fn normalize(mut v: Vec) -> Vec { + let n: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if n > 1e-3 { + for x in &mut v { + *x /= n; + } + } else { + for x in v.iter_mut() { + *x = 0.0; + } + if !v.is_empty() { + v[0] = 1.0; + } + } + v + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 1000, + .. ProptestConfig::default() + })] + + /// Invariant 1: every primary turn appears verbatim in the output + /// (set inclusion on `(start, end, speaker.0)`). + #[test] + fn primary_turns_are_preserved_in_output( + primary_count in 1usize..6, + num_centroids in 2usize..6, + embedding_seed_a in -1.0_f32..1.0_f32, + embedding_seed_b in -1.0_f32..1.0_f32, + overlap_count in 0usize..5, + ) { + let dim = 4; + // Build deterministic centroids by varying axis offsets. + let centroids: Vec = (0..num_centroids) + .map(|i| SpeakerCentroid { + speaker: SpeakerId(i as u32), + embedding: unit(dim, i % dim), + }) + .collect(); + // Build primary turns over distinct, non-overlapping intervals so the + // set comparison is unambiguous. + let primary: Vec = (0..primary_count) + .map(|i| turn(i as f64 * 2.0, i as f64 * 2.0 + 1.5, (i % num_centroids) as u32)) + .collect(); + let regions: Vec = (0..overlap_count) + .map(|i| OverlapRegionInput { + time: TimeRange { start: i as f64 * 0.7, end: i as f64 * 0.7 + 0.5 }, + primary_speaker: SpeakerId((i % num_centroids) as u32), + embedding: normalize(vec![embedding_seed_a, embedding_seed_b, 0.0, 0.0]), + }) + .collect(); + + let r = OverlapResegmenter::default(); + let out = r.resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }).unwrap(); + + // Set inclusion: every primary turn appears with the same triple in the output. + for p in &primary { + let key = (p.time.start, p.time.end, p.speaker.0); + let found = out.iter().any(|t| { + (t.time.start - p.time.start).abs() < 1e-9 + && (t.time.end - p.time.end).abs() < 1e-9 + && t.speaker.0 == key.2 + }); + prop_assert!(found, "primary turn {:?} missing from output", key); + } + } + + /// Invariant 2: output is sorted by `time.start`. + #[test] + fn output_is_sorted_by_start( + primary_count in 0usize..6, + num_centroids in 2usize..5, + overlap_count in 0usize..5, + ) { + let dim = 4; + let centroids: Vec = (0..num_centroids) + .map(|i| SpeakerCentroid { + speaker: SpeakerId(i as u32), + embedding: unit(dim, i % dim), + }) + .collect(); + // Deliberately unsorted primary turns — the resegmenter must still + // produce a sorted output. + let primary: Vec = (0..primary_count) + .map(|i| { + let start = ((primary_count - i) as f64) * 1.3; + turn(start, start + 0.5, (i % num_centroids) as u32) + }) + .collect(); + let regions: Vec = (0..overlap_count) + .map(|i| OverlapRegionInput { + time: TimeRange { start: i as f64 * 0.9, end: i as f64 * 0.9 + 0.4 }, + primary_speaker: SpeakerId(0), + embedding: unit(dim, (i + 1) % dim), + }) + .collect(); + + let r = OverlapResegmenter::default(); + let out = r.resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }).unwrap(); + + for w in out.windows(2) { + prop_assert!( + w[0].time.start <= w[1].time.start, + "output not sorted: {} > {}", + w[0].time.start, + w[1].time.start + ); + } + } + + /// Invariant 3: `compute_centroids` returns L2-normalized centroids. + #[test] + fn compute_centroids_outputs_are_l2_normalized( + num_clusters in 1usize..5, + members_per_cluster in 1usize..6, + ) { + let dim = 4; + let total = num_clusters * members_per_cluster; + let mut embeddings: Vec> = Vec::with_capacity(total); + let mut labels: Vec = Vec::with_capacity(total); + for cluster in 0..num_clusters { + for _ in 0..members_per_cluster { + embeddings.push(unit(dim, cluster % dim)); + labels.push(cluster); + } + } + let centroids = compute_centroids(&embeddings, &labels); + prop_assert_eq!(centroids.len(), num_clusters); + for c in ¢roids { + let n: f32 = c.embedding.iter().map(|x| x * x).sum::().sqrt(); + prop_assert!( + (n - 1.0).abs() < 1e-3, + "centroid not L2-normalized: norm={}", + n + ); + } + } + } +} From 6a6663bfcfbf424375194633bd492c8f9a62bb8f Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 15:21:32 +0300 Subject: [PATCH 09/11] docs(resegmentation): document fast-path skip behavior in Resegmenter trait Clarify that when speaker_centroids.len() < 2 or overlap_regions is empty, primary_turns is returned without validating overlap regions, so no MissingPrimaryCentroid or dim error is produced even if a would-be region was invalid. Surfaced by final M4 review. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resegmentation.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/resegmentation.rs b/src/resegmentation.rs index 5ff63d86..c8b1df6d 100644 --- a/src/resegmentation.rs +++ b/src/resegmentation.rs @@ -36,6 +36,11 @@ pub trait Resegmenter: Send + Sync { /// embedding dimensions, `primary_speaker` presence) run before duration /// filtering. A short overlap region with an invalid primary speaker /// returns `MissingPrimaryCentroid`, not silent success. + /// + /// **Fast path:** when `inputs.speaker_centroids.len() < 2` or + /// `inputs.overlap_regions` is empty, `inputs.primary_turns` is returned + /// sorted without further validation; no error is produced even if a + /// would-be overlap region had an invalid primary or dim. fn resegment(&self, inputs: ResegmentInputs<'_>) -> Result, ResegmentError>; } From ae7e4018f767e19295fbdac1ee6f143d5f419536 Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 15:23:14 +0300 Subject: [PATCH 10/11] docs(M4): add overlap resegmenter design spec and implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the M4 design (Variant A — pure-Rust post-clustering pass) and the 5-task TDD implementation plan that produced the m4-complete tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-07-m4-overlap-resegmenter-plan.md | 1440 +++++++++++++++++ ...026-05-07-m4-overlap-resegmenter-design.md | 306 ++++ 2 files changed, 1746 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-07-m4-overlap-resegmenter-plan.md create mode 100644 docs/superpowers/specs/2026-05-07-m4-overlap-resegmenter-design.md diff --git a/docs/superpowers/plans/2026-05-07-m4-overlap-resegmenter-plan.md b/docs/superpowers/plans/2026-05-07-m4-overlap-resegmenter-plan.md new file mode 100644 index 00000000..a5d921ed --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-m4-overlap-resegmenter-plan.md @@ -0,0 +1,1440 @@ +# M4 — Overlap Resegmenter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Add the v1.0 `Resegmenter` trait + `OverlapResegmenter` (pure-Rust post-clustering pass) that, given primary single-speaker turns, speaker centroids, and per-overlap-region embeddings, attaches a **second** speaker label to each overlap region by picking the nearest cosine-cluster ≠ primary. Pure-Rust, wasm-clean, no ONNX dependency. Spec: `docs/superpowers/specs/2026-05-07-m4-overlap-resegmenter-design.md`. + +**Architecture:** New single-file module `src/resegmentation.rs` (feature-gated `resegmentation`, default-on, no `onnx` requirement). Holds `Resegmenter` trait, `ResegmentError`, `OverlapResegmenter` struct + impl, helpers `compute_centroids` and `extract_overlap_time_ranges`. M6 will wire it into `Pipeline` and produce overlap embeddings via the existing `EmbedderPool` + `apply_overlap_mask`. Legacy `src/overlap.rs::detect_overlaps` stays untouched — it's interval-only and serves a different code path. + +**Tech Stack:** Rust 2024. No new dependencies. Reuses `crate::utils::{cosine_similarity, mean_vector, l2_normalize}` and `crate::types::{SpeakerId, SpeakerTurn, TimeRange}`. M1's `RawSegment` (gated `segmentation`) is consumed by `extract_overlap_time_ranges`. + +--- + +## File structure + +| Path | Action | Responsibility | +|---|---|---| +| `Cargo.toml` | modify | Add `resegmentation` feature (default-on) | +| `src/resegmentation.rs` | create | `Resegmenter` trait, `ResegmentError`, `OverlapResegmenter`, types, `compute_centroids`, `extract_overlap_time_ranges` | +| `src/lib.rs` | modify | `pub mod resegmentation;` gated, re-exports | +| `tests/resegmentation_test.rs` | create | Synthetic-data integration tests (no #[ignore], runs in CI) | +| `tests/miri_resegmentation.rs` | create | Miri-friendly subset (no-overlap, single-overlap, centroid math) | +| `CHANGELOG.md` | modify | Unreleased M4 section | + +Total roughly 520 lines new code. + +--- + +## Task 1: Add `resegmentation` Cargo feature + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1.1: Update default + add feature** + +In `/Users/ekhodzitsky/Documents/personal/polyvoice/Cargo.toml`, find: + +```toml +default = ["spectral", "segmentation", "embedder", "clusterer"] +``` + +Replace with: + +```toml +default = ["spectral", "segmentation", "embedder", "clusterer", "resegmentation"] +``` + +After the `clusterer = []` line, append: + +```toml + +# v1.0 Overlap-aware post-clustering resegmentation pass. +# Pure-Rust, wasm32-clean — does not require `onnx`. Operates on already- +# computed speaker centroids and overlap-region embeddings supplied by the +# caller (M6 Pipeline wires the embedder pool into this). +resegmentation = [] +``` + +- [ ] **Step 1.2: Verify build matrix** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo check +cargo check --features resegmentation +cargo check --features resegmentation,segmentation +cargo check --features resegmentation,clusterer +cargo check --features resegmentation,clusterer,spectral,segmentation,embedder +cargo check --no-default-features +cargo check --no-default-features --features resegmentation +cargo check --target wasm32-unknown-unknown --no-default-features --features resegmentation --lib +cargo check --all-features +``` + +All exit 0. + +- [ ] **Step 1.3: Commit** + +```bash +git add Cargo.toml +git commit -m "feat(cargo): add resegmentation feature flag for v1.0 M4 work" +``` + +--- + +## Task 2: `Resegmenter` trait + `ResegmentError` + input types + +**Files:** +- Create: `src/resegmentation.rs` +- Modify: `src/lib.rs` + +- [ ] **Step 2.1: Write failing tests first** + +Create `/Users/ekhodzitsky/Documents/personal/polyvoice/src/resegmentation.rs`: + +```rust +//! v1.0 OverlapResegmenter — overlap-aware post-clustering pass. +//! +//! Added in v0.6 (M4). See `docs/superpowers/specs/2026-05-07-m4-overlap-resegmenter-design.md` +//! and `docs/superpowers/specs/2026-05-07-perfect-diarization-roadmap-v1-design.md` §3.1. + +#[cfg(test)] +mod trait_tests { + use super::*; + use crate::types::{SpeakerId, SpeakerTurn, TimeRange}; + + /// In-memory dummy used by trait conformance tests. + struct ConstantResegmenter { + out: Vec, + } + + impl Resegmenter for ConstantResegmenter { + fn resegment( + &self, + _inputs: ResegmentInputs<'_>, + ) -> Result, ResegmentError> { + Ok(self.out.clone()) + } + } + + fn turn(start: f64, end: f64, spk: u32) -> SpeakerTurn { + SpeakerTurn { + speaker: SpeakerId(spk), + time: TimeRange { start, end }, + text: None, + } + } + + #[test] + fn resegmenter_trait_object_is_dyn_compatible() { + let r = ConstantResegmenter { + out: vec![turn(0.0, 1.0, 0)], + }; + let _b: Box = Box::new(r); + } + + #[test] + fn resegmenter_returns_owned_turns() { + let r = ConstantResegmenter { + out: vec![turn(0.0, 1.0, 0), turn(1.0, 2.0, 1)], + }; + let inputs = ResegmentInputs { + primary_turns: &[], + speaker_centroids: &[], + overlap_regions: &[], + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].speaker, SpeakerId(0)); + } + + #[test] + fn error_centroid_dim_mismatch_displays() { + let err = ResegmentError::CentroidDimMismatch { + index: 1, + expected: 192, + actual: 256, + }; + let msg = format!("{err}"); + assert!(msg.contains("192")); + assert!(msg.contains("256")); + assert!(msg.contains("index 1")); + } + + #[test] + fn error_overlap_dim_mismatch_displays() { + let err = ResegmentError::OverlapDimMismatch { + index: 0, + expected: 192, + actual: 64, + }; + let msg = format!("{err}"); + assert!(msg.contains("192")); + assert!(msg.contains("64")); + } + + #[test] + fn error_missing_primary_centroid_displays() { + let err = ResegmentError::MissingPrimaryCentroid { + index: 2, + primary: SpeakerId(7), + }; + let msg = format!("{err}"); + assert!(msg.contains('2')); + assert!(msg.contains('7')); + } +} +``` + +- [ ] **Step 2.2: Wire stub mod into lib.rs** + +In `/Users/ekhodzitsky/Documents/personal/polyvoice/src/lib.rs`, after the existing block: + +```rust +#[cfg(all(feature = "clusterer", feature = "spectral"))] +pub use clusterer::NmeScClusterer; +``` + +append: + +```rust + +#[cfg(feature = "resegmentation")] +pub mod resegmentation; +``` + +- [ ] **Step 2.3: Confirm compile-failure** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo test --features resegmentation --lib resegmentation::trait_tests 2>&1 | head -30 +``` + +Expected: errors about undefined `Resegmenter`, `ResegmentInputs`, `ResegmentError`, `OverlapRegionInput`, `SpeakerCentroid`. + +- [ ] **Step 2.4: Implement trait + error + input types** + +Replace the body of `src/resegmentation.rs` (keep the `#[cfg(test)] mod trait_tests` block at the bottom): + +```rust +//! v1.0 OverlapResegmenter — overlap-aware post-clustering pass. +//! +//! Added in v0.6 (M4). See `docs/superpowers/specs/2026-05-07-m4-overlap-resegmenter-design.md` +//! and `docs/superpowers/specs/2026-05-07-perfect-diarization-roadmap-v1-design.md` §3.1. +//! +//! Pure Rust, wasm32-clean. Operates on already-computed speaker centroids and +//! overlap-region embeddings supplied by the caller. M6 (`Pipeline`) wires the +//! `EmbedderPool` and `apply_overlap_mask` into this. + +use crate::types::{SpeakerId, SpeakerTurn, TimeRange}; + +/// Speaker resegmenter — given primary single-speaker turns, cluster centroids, +/// and per-overlap-region embeddings, returns a (possibly overlap-aware) flat +/// list of `SpeakerTurn`s where overlap regions may produce two turns over the +/// same time range with different speakers. +/// +/// In v1.0 (M4) the polyvoice crate introduces `Resegmenter` as the canonical +/// trait. The legacy `crate::overlap::detect_overlaps` remains as an +/// interval-only helper unrelated to this pass. +pub trait Resegmenter: Send + Sync { + /// Run the pass. + /// + /// **Requires:** all centroid vectors and all overlap embeddings have the + /// same dimension and are approximately L2-normalized. + /// **Guarantees on Ok:** every turn in `inputs.primary_turns` is preserved + /// verbatim; secondary turns (if any) carry an existing `SpeakerId` from + /// `inputs.speaker_centroids` and never repeat the primary speaker for the + /// same region; output is sorted by `time.start`. + fn resegment( + &self, + inputs: ResegmentInputs<'_>, + ) -> Result, ResegmentError>; +} + +/// All inputs needed by `Resegmenter::resegment`. +#[derive(Debug, Clone)] +pub struct ResegmentInputs<'a> { + pub primary_turns: &'a [SpeakerTurn], + pub speaker_centroids: &'a [SpeakerCentroid], + pub overlap_regions: &'a [OverlapRegionInput], +} + +/// L2-normalized centroid for one speaker cluster. +#[derive(Debug, Clone, PartialEq)] +pub struct SpeakerCentroid { + pub speaker: SpeakerId, + pub embedding: Vec, +} + +/// One overlap region with its caller-supplied embedding. +/// +/// `embedding` is expected to be L2-normalized; this struct does not enforce +/// it (`OverlapResegmenter` returns `OverlapDimMismatch` only on dimension +/// mismatches, not on norm drift). +#[derive(Debug, Clone, PartialEq)] +pub struct OverlapRegionInput { + pub time: TimeRange, + pub primary_speaker: SpeakerId, + pub embedding: Vec, +} + +/// Errors from `Resegmenter` implementations. +#[derive(Debug, thiserror::Error)] +pub enum ResegmentError { + #[error("centroid dim mismatch at index {index}: expected {expected}, got {actual}")] + CentroidDimMismatch { + index: usize, + expected: usize, + actual: usize, + }, + + #[error("overlap embedding dim mismatch at index {index}: expected {expected}, got {actual}")] + OverlapDimMismatch { + index: usize, + expected: usize, + actual: usize, + }, + + #[error("primary speaker {primary} for overlap region {index} not present in centroids")] + MissingPrimaryCentroid { index: usize, primary: SpeakerId }, +} + +#[cfg(test)] +mod trait_tests { + use super::*; + + /// In-memory dummy used by trait conformance tests. + struct ConstantResegmenter { + out: Vec, + } + + impl Resegmenter for ConstantResegmenter { + fn resegment( + &self, + _inputs: ResegmentInputs<'_>, + ) -> Result, ResegmentError> { + Ok(self.out.clone()) + } + } + + fn turn(start: f64, end: f64, spk: u32) -> SpeakerTurn { + SpeakerTurn { + speaker: SpeakerId(spk), + time: TimeRange { start, end }, + text: None, + } + } + + #[test] + fn resegmenter_trait_object_is_dyn_compatible() { + let r = ConstantResegmenter { + out: vec![turn(0.0, 1.0, 0)], + }; + let _b: Box = Box::new(r); + } + + #[test] + fn resegmenter_returns_owned_turns() { + let r = ConstantResegmenter { + out: vec![turn(0.0, 1.0, 0), turn(1.0, 2.0, 1)], + }; + let inputs = ResegmentInputs { + primary_turns: &[], + speaker_centroids: &[], + overlap_regions: &[], + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].speaker, SpeakerId(0)); + } + + #[test] + fn error_centroid_dim_mismatch_displays() { + let err = ResegmentError::CentroidDimMismatch { + index: 1, + expected: 192, + actual: 256, + }; + let msg = format!("{err}"); + assert!(msg.contains("192")); + assert!(msg.contains("256")); + assert!(msg.contains("index 1")); + } + + #[test] + fn error_overlap_dim_mismatch_displays() { + let err = ResegmentError::OverlapDimMismatch { + index: 0, + expected: 192, + actual: 64, + }; + let msg = format!("{err}"); + assert!(msg.contains("192")); + assert!(msg.contains("64")); + } + + #[test] + fn error_missing_primary_centroid_displays() { + let err = ResegmentError::MissingPrimaryCentroid { + index: 2, + primary: SpeakerId(7), + }; + let msg = format!("{err}"); + assert!(msg.contains('2')); + assert!(msg.contains('7')); + } +} +``` + +- [ ] **Step 2.5: Verify** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo test --features resegmentation --lib resegmentation::trait_tests +cargo fmt +cargo clippy --features resegmentation --lib -- -D warnings +cargo check --target wasm32-unknown-unknown --no-default-features --features resegmentation --lib +``` + +Expected: 5 trait tests pass, all clean. + +- [ ] **Step 2.6: Commit** + +```bash +git add src/resegmentation.rs src/lib.rs +git commit -m "feat(resegmentation): add Resegmenter trait + ResegmentError + input types" +``` + +--- + +## Task 3: Helpers — `compute_centroids` + `extract_overlap_time_ranges` + +**Files:** +- Modify: `src/resegmentation.rs` + +- [ ] **Step 3.1: Append failing tests** + +Add to `src/resegmentation.rs` (after `mod trait_tests`): + +```rust +#[cfg(test)] +mod centroid_tests { + use super::*; + use crate::types::SpeakerId; + + fn unit(dim: usize, axis: usize) -> Vec { + let mut v = vec![0.0_f32; dim]; + v[axis] = 1.0; + v + } + + #[test] + fn compute_centroids_l2_normalized() { + let embeddings = vec![ + unit(3, 0), + unit(3, 0), + unit(3, 1), + unit(3, 1), + ]; + let labels = vec![0, 0, 1, 1]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 2); + for c in ¢roids { + let n: f32 = c.embedding.iter().map(|x| x * x).sum::().sqrt(); + assert!((n - 1.0).abs() < 1e-3, "centroid not L2-normalized: norm={n}"); + } + } + + #[test] + fn compute_centroids_drops_empty_clusters() { + // Labels skip from 0 to 2; cluster 1 has no members. + let embeddings = vec![unit(3, 0), unit(3, 1), unit(3, 1)]; + let labels = vec![0, 2, 2]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 2); + let speakers: Vec = centroids.iter().map(|c| c.speaker.0).collect(); + assert_eq!(speakers, vec![0, 2]); + } + + #[test] + fn compute_centroids_sorted_by_speaker_id() { + let embeddings = vec![unit(3, 0), unit(3, 1), unit(3, 2)]; + let labels = vec![5, 1, 3]; + let centroids = compute_centroids(&embeddings, &labels); + let speakers: Vec = centroids.iter().map(|c| c.speaker.0).collect(); + assert_eq!(speakers, vec![1, 3, 5]); + } + + #[test] + fn compute_centroids_empty_input_returns_empty() { + let centroids = compute_centroids(&[], &[]); + assert!(centroids.is_empty()); + } + + #[test] + fn compute_centroids_label_mismatch_returns_empty() { + // Mismatched lengths: caller bug, conservative empty return rather than panic. + let centroids = compute_centroids(&[unit(3, 0)], &[0, 1]); + assert!(centroids.is_empty()); + } +} + +#[cfg(all(test, feature = "segmentation"))] +mod overlap_extract_tests { + use super::*; + use crate::segmentation::RawSegment; + use crate::types::Confidence; + + fn raw(start: f64, end: f64, spk: u8, overlap: bool) -> RawSegment { + RawSegment { + time: TimeRange { start, end }, + local_speaker_idx: spk, + is_overlap: overlap, + confidence: Confidence::new(0.9).unwrap(), + } + } + + #[test] + fn extract_returns_pairs_for_simultaneous_overlap_segments() { + // Two RawSegments with the same time range and is_overlap = true: + // aggregator's canonical overlap output. + let segs = vec![ + raw(0.0, 1.0, 0, true), + raw(0.0, 1.0, 1, true), + ]; + let pairs = extract_overlap_time_ranges(&segs); + assert_eq!(pairs.len(), 1); + assert!((pairs[0].0.start - 0.0).abs() < 1e-6); + assert!((pairs[0].0.end - 1.0).abs() < 1e-6); + // local pair is (lo, hi) where lo < hi. + assert_eq!(pairs[0].1, 0); + assert_eq!(pairs[0].2, 1); + } + + #[test] + fn extract_ignores_non_overlap_segments() { + let segs = vec![ + raw(0.0, 1.0, 0, false), + raw(0.0, 1.0, 1, false), + ]; + let pairs = extract_overlap_time_ranges(&segs); + assert!(pairs.is_empty()); + } + + #[test] + fn extract_ignores_overlap_flag_without_pair() { + // is_overlap=true but only one local speaker present at this range. + let segs = vec![raw(0.0, 1.0, 0, true)]; + let pairs = extract_overlap_time_ranges(&segs); + assert!(pairs.is_empty()); + } + + #[test] + fn extract_handles_multiple_overlap_regions() { + let segs = vec![ + raw(0.0, 1.0, 0, true), + raw(0.0, 1.0, 1, true), + raw(2.0, 3.0, 1, true), + raw(2.0, 3.0, 2, true), + ]; + let pairs = extract_overlap_time_ranges(&segs); + assert_eq!(pairs.len(), 2); + assert_eq!(pairs[0].1, 0); + assert_eq!(pairs[0].2, 1); + assert_eq!(pairs[1].1, 1); + assert_eq!(pairs[1].2, 2); + } +} +``` + +- [ ] **Step 3.2: Confirm compile-failure** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo test --features resegmentation,segmentation --lib resegmentation:: 2>&1 | head -10 +``` + +Expected: undefined `compute_centroids`, `extract_overlap_time_ranges`. + +- [ ] **Step 3.3: Implement helpers** + +Add to `src/resegmentation.rs` (after the error type, before the test blocks): + +```rust +/// Compute per-cluster L2-normalized centroids from clustered embeddings. +/// +/// `labels[i]` is the cluster label of `embeddings[i]`. The cluster id stored +/// in the resulting `SpeakerCentroid` is the raw `labels[i]` cast to `SpeakerId`. +/// Empty clusters yield no entry. Output is sorted by `SpeakerId.0` ascending. +/// +/// Returns an empty `Vec` if `embeddings.len() != labels.len()` or both are +/// empty — never panics. +/// +/// **Pure Rust, wasm32-clean.** +pub fn compute_centroids( + embeddings: &[Vec], + labels: &[usize], +) -> Vec { + if embeddings.len() != labels.len() || embeddings.is_empty() { + return Vec::new(); + } + // Bucket by label. + let mut buckets: std::collections::BTreeMap>> = + std::collections::BTreeMap::new(); + for (emb, &lbl) in embeddings.iter().zip(labels.iter()) { + buckets.entry(lbl).or_default().push(emb); + } + let mut out = Vec::with_capacity(buckets.len()); + for (lbl, members) in buckets { + let owned: Vec> = members.iter().map(|e| (*e).clone()).collect(); + if let Some(mut mean) = crate::utils::mean_vector(&owned) { + crate::utils::l2_normalize(&mut mean); + // SpeakerId is u32; clamp to its range conservatively. + let id = SpeakerId(lbl as u32); + out.push(SpeakerCentroid { + speaker: id, + embedding: mean, + }); + } + } + // BTreeMap iterates in label order, but cast to SpeakerId may reorder if + // u32 truncation happened. Sort explicitly. + out.sort_by_key(|c| c.speaker.0); + out +} + +/// Find pairs of `RawSegment`s that share a time range, are flagged +/// `is_overlap = true`, and carry two distinct `local_speaker_idx`. +/// Returns `(time_range, lo_local_idx, hi_local_idx)` per detected pair. +/// +/// "Same time range" uses an `f64` tolerance of `1e-6`. +/// +/// `lo_local_idx < hi_local_idx`. Caller is responsible for the local→global +/// `SpeakerId` mapping (typically from the same clustering pipeline). +/// +/// **Pure Rust, wasm32-clean.** Gated `segmentation` because `RawSegment` +/// lives in the segmentation module. +#[cfg(feature = "segmentation")] +pub fn extract_overlap_time_ranges( + segments: &[crate::segmentation::RawSegment], +) -> Vec<(TimeRange, u8, u8)> { + let mut pairs: Vec<(TimeRange, u8, u8)> = Vec::new(); + for (i, a) in segments.iter().enumerate() { + if !a.is_overlap { + continue; + } + for b in segments.iter().skip(i + 1) { + if !b.is_overlap { + continue; + } + if a.local_speaker_idx == b.local_speaker_idx { + continue; + } + if (a.time.start - b.time.start).abs() > 1e-6 + || (a.time.end - b.time.end).abs() > 1e-6 + { + continue; + } + let (lo, hi) = if a.local_speaker_idx < b.local_speaker_idx { + (a.local_speaker_idx, b.local_speaker_idx) + } else { + (b.local_speaker_idx, a.local_speaker_idx) + }; + pairs.push((a.time, lo, hi)); + } + } + pairs +} +``` + +- [ ] **Step 3.4: Verify** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo test --features resegmentation --lib resegmentation::centroid_tests +cargo test --features resegmentation,segmentation --lib resegmentation::overlap_extract_tests +cargo clippy --features resegmentation,segmentation --lib -- -D warnings +cargo check --target wasm32-unknown-unknown --no-default-features --features resegmentation --lib +cargo check --target wasm32-unknown-unknown --no-default-features --features resegmentation,segmentation --lib +``` + +Expected: +- 5 centroid tests pass. +- 4 overlap_extract tests pass. +- Clippy clean. +- wasm32 with `resegmentation` only — clean. +- wasm32 with `resegmentation,segmentation` — clean (segmentation pure-Rust core is wasm32-clean per M1). + +- [ ] **Step 3.5: Commit** + +```bash +git add src/resegmentation.rs +git commit -m "feat(resegmentation): add compute_centroids + extract_overlap_time_ranges helpers" +``` + +--- + +## Task 4: `OverlapResegmenter` impl with cosine matching + +**Files:** +- Modify: `src/resegmentation.rs` + +- [ ] **Step 4.1: Append failing tests** + +Add to `src/resegmentation.rs` (after the existing test modules): + +```rust +#[cfg(test)] +mod resegmenter_tests { + use super::*; + use crate::types::{SpeakerId, SpeakerTurn, TimeRange}; + + fn unit(dim: usize, axis: usize) -> Vec { + let mut v = vec![0.0_f32; dim]; + v[axis] = 1.0; + v + } + + fn turn(start: f64, end: f64, spk: u32) -> SpeakerTurn { + SpeakerTurn { + speaker: SpeakerId(spk), + time: TimeRange { start, end }, + text: None, + } + } + + fn centroid(spk: u32, dim: usize, axis: usize) -> SpeakerCentroid { + SpeakerCentroid { + speaker: SpeakerId(spk), + embedding: unit(dim, axis), + } + } + + fn region(start: f64, end: f64, primary: u32, dim: usize, axis: usize) -> OverlapRegionInput { + OverlapRegionInput { + time: TimeRange { start, end }, + primary_speaker: SpeakerId(primary), + embedding: unit(dim, axis), + } + } + + #[test] + fn no_overlap_passes_primary_through() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0), turn(2.0, 3.0, 1)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: &[], + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary); + } + + #[test] + fn single_cluster_passes_through() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0)]; + let regions = vec![region(0.5, 0.9, 0, 3, 0)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary); + } + + #[test] + fn picks_secondary_excluding_primary() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1), centroid(2, 3, 2)]; + // Overlap region embedding lies along axis 1 → nearest to centroid id=1. + let regions = vec![region(0.0, 1.0, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out.len(), 2); + // Both turns cover (0.0, 1.0); one is primary (id=0), other is secondary (id=1). + let speakers: Vec = out.iter().map(|t| t.speaker.0).collect(); + assert!(speakers.contains(&0)); + assert!(speakers.contains(&1)); + assert!(!speakers.contains(&2)); + } + + #[test] + fn threshold_blocks_low_cosine() { + // Threshold 0.99 — only near-perfect matches allowed. + let r = OverlapResegmenter::new(0.99, 0.0); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + // Overlap embedding along axis 0 (matches primary); cosine to centroid 1 = 0. + let regions = vec![region(0.0, 1.0, 0, 3, 0)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary, "no secondary should be appended"); + } + + #[test] + fn min_duration_blocks_short_region() { + // Region duration 0.05s < default 0.1s → skipped. + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + let regions = vec![region(0.10, 0.15, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary); + } + + #[test] + fn output_is_sorted_by_start() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(2.0, 3.0, 0), turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + let regions = vec![region(2.0, 3.0, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let out = r.resegment(inputs).unwrap(); + for w in out.windows(2) { + assert!(w[0].time.start <= w[1].time.start); + } + } + + #[test] + fn missing_primary_centroid_errors() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(1, 3, 1), centroid(2, 3, 2)]; + let regions = vec![region(0.0, 1.0, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let err = r.resegment(inputs).expect_err("missing primary must error"); + assert!(matches!( + err, + ResegmentError::MissingPrimaryCentroid { primary: SpeakerId(0), .. } + )); + } + + #[test] + fn centroid_dim_mismatch_errors() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![ + centroid(0, 3, 0), + SpeakerCentroid { + speaker: SpeakerId(1), + embedding: vec![1.0, 0.0], // dim 2, not 3 + }, + ]; + let regions = vec![region(0.0, 1.0, 0, 3, 1)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let err = r.resegment(inputs).expect_err("dim mismatch must error"); + assert!(matches!(err, ResegmentError::CentroidDimMismatch { .. })); + } + + #[test] + fn overlap_dim_mismatch_errors() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let centroids = vec![centroid(0, 3, 0), centroid(1, 3, 1)]; + let regions = vec![OverlapRegionInput { + time: TimeRange { start: 0.0, end: 1.0 }, + primary_speaker: SpeakerId(0), + embedding: vec![1.0, 0.0], // dim 2, not 3 + }]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }; + let err = r.resegment(inputs).expect_err("dim mismatch must error"); + assert!(matches!(err, ResegmentError::OverlapDimMismatch { .. })); + } + + #[test] + fn empty_centroids_passes_through() { + let r = OverlapResegmenter::default(); + let primary = vec![turn(0.0, 1.0, 0)]; + let inputs = ResegmentInputs { + primary_turns: &primary, + speaker_centroids: &[], + overlap_regions: &[], + }; + let out = r.resegment(inputs).unwrap(); + assert_eq!(out, primary); + } +} +``` + +- [ ] **Step 4.2: Confirm compile-failure** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo test --features resegmentation --lib resegmentation::resegmenter_tests 2>&1 | head -10 +``` + +Expected: undefined `OverlapResegmenter`. + +- [ ] **Step 4.3: Implement `OverlapResegmenter`** + +Add to `src/resegmentation.rs` (above all the test modules, after the helpers): + +```rust +/// Default-constructible overlap-aware resegmenter that picks the nearest +/// non-primary cluster centroid (by cosine similarity) for each overlap region +/// above a configurable threshold and minimum duration. +/// +/// Typical usage (from `Pipeline` in M6): +/// +/// ```rust,ignore +/// let r = OverlapResegmenter::default(); +/// let out = r.resegment(ResegmentInputs { +/// primary_turns: &turns, +/// speaker_centroids: ¢roids, +/// overlap_regions: ®ions, +/// })?; +/// ``` +pub struct OverlapResegmenter { + threshold: f32, + min_overlap_secs: f32, +} + +impl OverlapResegmenter { + /// `threshold` — minimum cosine similarity required to attach a secondary + /// speaker to an overlap region. Default `0.0` (always attach the nearest + /// non-primary cluster). + /// `min_overlap_secs` — overlap regions shorter than this are skipped. + /// Default `0.1`. + pub fn new(threshold: f32, min_overlap_secs: f32) -> Self { + Self { + threshold, + min_overlap_secs: min_overlap_secs.max(0.0), + } + } + + pub fn threshold(&self) -> f32 { + self.threshold + } + + pub fn min_overlap_secs(&self) -> f32 { + self.min_overlap_secs + } +} + +impl Default for OverlapResegmenter { + fn default() -> Self { + Self::new(0.0, 0.1) + } +} + +impl Resegmenter for OverlapResegmenter { + fn resegment( + &self, + inputs: ResegmentInputs<'_>, + ) -> Result, ResegmentError> { + let mut out: Vec = inputs.primary_turns.to_vec(); + + // Fast paths. + if inputs.speaker_centroids.len() < 2 || inputs.overlap_regions.is_empty() { + out.sort_by(|a, b| a.time.start.total_cmp(&b.time.start)); + return Ok(out); + } + + // Validate centroid dimensionality first (single-pass). + let expected_dim = inputs.speaker_centroids[0].embedding.len(); + for (i, c) in inputs.speaker_centroids.iter().enumerate() { + if c.embedding.len() != expected_dim { + return Err(ResegmentError::CentroidDimMismatch { + index: i, + expected: expected_dim, + actual: c.embedding.len(), + }); + } + } + + for (i, region) in inputs.overlap_regions.iter().enumerate() { + // Validate dim. + if region.embedding.len() != expected_dim { + return Err(ResegmentError::OverlapDimMismatch { + index: i, + expected: expected_dim, + actual: region.embedding.len(), + }); + } + // Validate primary present. + if !inputs + .speaker_centroids + .iter() + .any(|c| c.speaker == region.primary_speaker) + { + return Err(ResegmentError::MissingPrimaryCentroid { + index: i, + primary: region.primary_speaker, + }); + } + // Skip too-short regions. + if (region.time.duration() as f32) < self.min_overlap_secs { + continue; + } + // Find best non-primary cluster. + let mut best: Option<(SpeakerId, f32)> = None; + for c in inputs.speaker_centroids.iter() { + if c.speaker == region.primary_speaker { + continue; + } + let s = crate::utils::cosine_similarity(®ion.embedding, &c.embedding); + let take = match best { + None => true, + Some((_, b)) => s > b, + }; + if take { + best = Some((c.speaker, s)); + } + } + if let Some((id, score)) = best { + if score > self.threshold { + out.push(SpeakerTurn { + speaker: id, + time: region.time, + text: None, + }); + } + } + } + + out.sort_by(|a, b| a.time.start.total_cmp(&b.time.start)); + Ok(out) + } +} +``` + +- [ ] **Step 4.4: Verify** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo test --features resegmentation --lib resegmentation:: +cargo test --features resegmentation,segmentation --lib resegmentation:: +cargo clippy --features resegmentation,segmentation --lib -- -D warnings +cargo check --target wasm32-unknown-unknown --no-default-features --features resegmentation --lib +``` + +Expected: ~19 tests pass (5 trait + 5 centroid + 4 overlap_extract + 10 resegmenter), all clean. + +- [ ] **Step 4.5: Commit** + +```bash +git add src/resegmentation.rs +git commit -m "feat(resegmentation): add OverlapResegmenter cosine-matching impl" +``` + +--- + +## Task 5: lib.rs re-exports + integration test + miri test + CHANGELOG + tag + +**Files:** +- Modify: `src/lib.rs` +- Create: `tests/resegmentation_test.rs` +- Create: `tests/miri_resegmentation.rs` +- Modify: `CHANGELOG.md` + +- [ ] **Step 5.1: Add re-exports** + +In `/Users/ekhodzitsky/Documents/personal/polyvoice/src/lib.rs`, after the line: + +```rust +#[cfg(feature = "resegmentation")] +pub mod resegmentation; +``` + +append: + +```rust + +#[cfg(feature = "resegmentation")] +pub use resegmentation::{ + OverlapRegionInput, OverlapResegmenter, ResegmentError, ResegmentInputs, Resegmenter, + SpeakerCentroid, compute_centroids, +}; + +#[cfg(all(feature = "resegmentation", feature = "segmentation"))] +pub use resegmentation::extract_overlap_time_ranges; +``` + +- [ ] **Step 5.2: Create integration test** + +Write `/Users/ekhodzitsky/Documents/personal/polyvoice/tests/resegmentation_test.rs`: + +```rust +//! Integration test for the M4 OverlapResegmenter on synthetic data. +//! Pure-CPU; runs in normal `cargo test` (no model required). + +#![cfg(feature = "resegmentation")] + +use polyvoice::resegmentation::{ + OverlapRegionInput, OverlapResegmenter, ResegmentInputs, Resegmenter, SpeakerCentroid, + compute_centroids, +}; +use polyvoice::types::{SpeakerId, SpeakerTurn, TimeRange}; + +fn unit(dim: usize, axis: usize) -> Vec { + let mut v = vec![0.0_f32; dim]; + v[axis] = 1.0; + v +} + +fn turn(start: f64, end: f64, spk: u32) -> SpeakerTurn { + SpeakerTurn { + speaker: SpeakerId(spk), + time: TimeRange { start, end }, + text: None, + } +} + +#[test] +fn end_to_end_synthetic_two_speakers_overlap() { + // Two speakers, one overlap region. Embeddings are 8-d unit vectors. + let dim = 8; + let embeddings = vec![ + unit(dim, 0), + unit(dim, 0), + unit(dim, 0), + unit(dim, 1), + unit(dim, 1), + unit(dim, 1), + ]; + let labels = vec![0, 0, 0, 1, 1, 1]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 2); + + let primary = vec![turn(0.0, 5.0, 0), turn(5.0, 10.0, 1)]; + // Overlap at 4.5–5.5: primary spk=0, embedding aligned with axis 1 (i.e. spk=1). + let regions = vec![OverlapRegionInput { + time: TimeRange { start: 4.5, end: 5.5 }, + primary_speaker: SpeakerId(0), + embedding: unit(dim, 1), + }]; + + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }) + .unwrap(); + assert_eq!(out.len(), 3, "primary 2 + secondary 1"); + let secondary = out + .iter() + .find(|t| (t.time.start - 4.5).abs() < 1e-6 && (t.time.end - 5.5).abs() < 1e-6) + .expect("secondary turn at 4.5..5.5 missing"); + assert_eq!(secondary.speaker, SpeakerId(1)); +} + +#[test] +fn end_to_end_three_speakers_two_pairs() { + let dim = 8; + let embeddings = vec![ + unit(dim, 0), + unit(dim, 0), + unit(dim, 1), + unit(dim, 1), + unit(dim, 2), + unit(dim, 2), + ]; + let labels = vec![0, 0, 1, 1, 2, 2]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 3); + + let primary = vec![turn(0.0, 2.0, 0), turn(2.0, 4.0, 1), turn(4.0, 6.0, 2)]; + let regions = vec![ + // 1.0..2.0: primary 0, secondary best should be 1. + OverlapRegionInput { + time: TimeRange { start: 1.0, end: 2.0 }, + primary_speaker: SpeakerId(0), + embedding: unit(dim, 1), + }, + // 4.0..5.0: primary 2, secondary best should be 1. + OverlapRegionInput { + time: TimeRange { start: 4.0, end: 5.0 }, + primary_speaker: SpeakerId(2), + embedding: unit(dim, 1), + }, + ]; + + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }) + .unwrap(); + // Two secondaries appended. + assert_eq!(out.len(), 5); + let n_spk1 = out.iter().filter(|t| t.speaker == SpeakerId(1)).count(); + assert!(n_spk1 >= 2, "expected ≥2 turns for speaker 1, got {n_spk1}"); + // Sorted by start. + for w in out.windows(2) { + assert!(w[0].time.start <= w[1].time.start); + } +} + +#[test] +fn rttm_round_trip_preserves_overlap_turns() { + use polyvoice::rttm::write_rttm; + + let dim = 4; + let centroids = vec![ + SpeakerCentroid { + speaker: SpeakerId(0), + embedding: unit(dim, 0), + }, + SpeakerCentroid { + speaker: SpeakerId(1), + embedding: unit(dim, 1), + }, + ]; + let primary = vec![turn(0.0, 1.0, 0)]; + let regions = vec![OverlapRegionInput { + time: TimeRange { start: 0.2, end: 0.8 }, + primary_speaker: SpeakerId(0), + embedding: unit(dim, 1), + }]; + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }) + .unwrap(); + assert_eq!(out.len(), 2); + + // RTTM writer must accept overlapping spans without error or data loss. + let mut buf = Vec::new(); + write_rttm(&mut buf, "test", &out).expect("rttm write"); + let s = String::from_utf8(buf).unwrap(); + let n_lines = s.lines().filter(|l| l.starts_with("SPEAKER")).count(); + assert_eq!(n_lines, 2, "expected 2 SPEAKER lines, got {n_lines}: {s}"); + assert!(s.contains("SPEAKER_00")); + assert!(s.contains("SPEAKER_01")); +} +``` + +- [ ] **Step 5.3: Verify integration test** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo test --features resegmentation --test resegmentation_test +``` + +Expected: 3 tests pass. + +If `polyvoice::rttm::write_rttm` does not exist with that exact name, replace the call with whichever function is exported (check `src/rttm.rs` — typical names: `write`, `write_rttm`, `to_rttm`). Adjust the test to match before running. + +- [ ] **Step 5.4: Create Miri test** + +Write `/Users/ekhodzitsky/Documents/personal/polyvoice/tests/miri_resegmentation.rs`: + +```rust +//! Miri-friendly subset of M4 resegmenter tests. Covers no-overlap pass-through, +//! single-overlap cosine matching, and centroid math. ONNX-free, deterministic. + +#![cfg(feature = "resegmentation")] + +use polyvoice::resegmentation::{ + OverlapRegionInput, OverlapResegmenter, ResegmentInputs, Resegmenter, SpeakerCentroid, + compute_centroids, +}; +use polyvoice::types::{SpeakerId, SpeakerTurn, TimeRange}; + +fn unit(dim: usize, axis: usize) -> Vec { + let mut v = vec![0.0_f32; dim]; + v[axis] = 1.0; + v +} + +#[test] +fn miri_resegment_no_overlap() { + let primary = vec![SpeakerTurn { + speaker: SpeakerId(0), + time: TimeRange { start: 0.0, end: 1.0 }, + text: None, + }]; + let centroids = vec![SpeakerCentroid { + speaker: SpeakerId(0), + embedding: unit(4, 0), + }]; + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: &[], + }) + .unwrap(); + assert_eq!(out, primary); +} + +#[test] +fn miri_resegment_single_overlap() { + let primary = vec![SpeakerTurn { + speaker: SpeakerId(0), + time: TimeRange { start: 0.0, end: 1.0 }, + text: None, + }]; + let centroids = vec![ + SpeakerCentroid { + speaker: SpeakerId(0), + embedding: unit(4, 0), + }, + SpeakerCentroid { + speaker: SpeakerId(1), + embedding: unit(4, 1), + }, + ]; + let regions = vec![OverlapRegionInput { + time: TimeRange { start: 0.0, end: 1.0 }, + primary_speaker: SpeakerId(0), + embedding: unit(4, 1), + }]; + let r = OverlapResegmenter::default(); + let out = r + .resegment(ResegmentInputs { + primary_turns: &primary, + speaker_centroids: ¢roids, + overlap_regions: ®ions, + }) + .unwrap(); + assert_eq!(out.len(), 2); +} + +#[test] +fn miri_compute_centroids() { + let embeddings = vec![unit(4, 0), unit(4, 0), unit(4, 1), unit(4, 1)]; + let labels = vec![0, 0, 1, 1]; + let centroids = compute_centroids(&embeddings, &labels); + assert_eq!(centroids.len(), 2); + for c in ¢roids { + let n: f32 = c.embedding.iter().map(|x| x * x).sum::().sqrt(); + assert!((n - 1.0).abs() < 1e-3); + } +} +``` + +- [ ] **Step 5.5: Verify Miri** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo +nightly miri test --features resegmentation --test miri_resegmentation 2>&1 | tail -15 +``` + +Expected: 3 Miri tests pass. If Miri toolchain is missing locally, this step is permitted to fail with `error: toolchain 'nightly' is not installed` — CI runs Miri separately. Document the skip in the commit message instead of blocking. + +- [ ] **Step 5.6: Update CHANGELOG.md** + +In `/Users/ekhodzitsky/Documents/personal/polyvoice/CHANGELOG.md`, in the `## [Unreleased]` block, after M3's `### Added (M3 — Clusterer trait + NME-SC)` section, append: + +```markdown + +### Added (M4 — Overlap resegmenter) +- `polyvoice::resegmentation` module: `Resegmenter` trait, `ResegmentError`, + `OverlapResegmenter` (pure-Rust post-clustering pass that attaches a second + speaker to overlap regions via nearest-cosine cluster), `ResegmentInputs`, + `OverlapRegionInput`, `SpeakerCentroid`, helpers `compute_centroids` and + `extract_overlap_time_ranges` (gated `segmentation`). +- New Cargo feature `resegmentation` (in default features). Pure-Rust core, + wasm32-clean, no `onnx` requirement. +- Integration test on synthetic two-speaker / three-speaker data + RTTM + round-trip — runs in every PR's normal `cargo test`. +- Miri-friendly test target `tests/miri_resegmentation.rs` covering + no-overlap, single-overlap, and centroid math paths. +``` + +- [ ] **Step 5.7: Verify full feature matrix + tests + lints** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +cargo test --all-features --lib 2>&1 | tail -3 +cargo test --all-features --doc 2>&1 | tail -3 +cargo test --all-features --test resegmentation_test 2>&1 | tail -3 +cargo clippy --all-targets --all-features -- -D warnings 2>&1 | tail -5 +cargo fmt --check +cargo check --target wasm32-unknown-unknown --no-default-features --features resegmentation --lib +./scripts/release-gate.sh ; echo "exit=$?" +``` + +Apply `cargo fmt` if `--check` fails. Apply clippy fixes (struct-update, iter_mut, needless_borrow etc.) if `--all-targets` flags test code. + +- [ ] **Step 5.8: Tag** + +```bash +cd /Users/ekhodzitsky/Documents/personal/polyvoice +git tag -a m4-complete -m "M4 complete: OverlapResegmenter + helpers" +``` + +(Don't push.) + +- [ ] **Step 5.9: Commit** + +```bash +git add src/lib.rs tests/resegmentation_test.rs tests/miri_resegmentation.rs CHANGELOG.md +git commit -m "feat(lib): re-export resegmentation surface + integration/miri tests + changelog" +``` + +- [ ] **Step 5.10: Final git log** + +```bash +git log --oneline 53afbf8..HEAD +``` + +Should show 6 commits (1 per task + the wiring/tests commit). + +--- + +## Self-review checklist + +1. **Spec coverage:** all M4 deliverables (Resegmenter trait, OverlapResegmenter cosine-matching impl, compute_centroids, extract_overlap_time_ranges, RTTM round-trip, Miri subset) → Tasks 2–5. +2. **Additive guarantee:** `git diff 53afbf8..HEAD -- src/clusterer.rs src/embedder.rs src/segmentation/ src/types.rs src/overlap.rs src/pipeline.rs src/utils.rs` should show ZERO changes. +3. **Wasm32 cleanness:** `resegmentation` alone (without `segmentation`) compiles to wasm32. With `segmentation` it stays wasm32-clean (segmentation pure-Rust core is wasm32-clean per M1). +4. **No `unwrap`/`expect`/`panic`** in lib non-test code (`src/resegmentation.rs` body uses only `?` and validated error types; in-test code is allowed). +5. **Test coverage:** trait (5) + centroid (5) + overlap_extract (4) + resegmenter (10) + integration (3) + miri (3) ≈ 30 tests. +6. **Atomic commits:** ~6 total — one per task plus the final wiring commit. +7. **No ONNX dependency** introduced into resegmentation. +8. **Threshold/min duration** are tunable via `OverlapResegmenter::new(threshold, min_overlap_secs)` and the defaults match the spec (`0.0` / `0.1`). + +--- + +## Out of scope + +- VBx HMM resegmentation (sliding-window posterior smoothing) — sdvинуто в v1.2 per roadmap §2.3. +- Re-running the segmenter on overlap regions — design picked the cosine-only path (spec §"Approach: Variant A"). +- Wiring `OverlapResegmenter` into `Pipeline` — M6. +- Producing overlap-region embeddings via `EmbedderPool` + `apply_overlap_mask` — M6. +- Updating Python/FFI bindings to surface secondary turns — M7. +- Closing the DER baseline gate (`tests/der_baseline.json`) on VoxConverse-smoke — M5/M6 (after the Pipeline is rebuilt with new components). +- Removing legacy `src/overlap.rs::detect_overlaps` (interval-only) — M6. diff --git a/docs/superpowers/specs/2026-05-07-m4-overlap-resegmenter-design.md b/docs/superpowers/specs/2026-05-07-m4-overlap-resegmenter-design.md new file mode 100644 index 00000000..bf0fa4dc --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-m4-overlap-resegmenter-design.md @@ -0,0 +1,306 @@ +--- +title: M4 — Overlap Resegmenter Design +date: 2026-05-07 +status: draft +milestone: M4 +preceding: M0, M1, M2, M3 +following: M6 (Pipeline integration), M7 +authors: ekhodzitsky +--- + +# M4 — Overlap Resegmenter Design + +## Problem + +После M1 (powerset segmenter) + M2 (Embedder + overlap mask) + M3 (Clusterer) полный pipeline умеет обнаруживать overlap-фреймы и присваивать **первого** спикера каждому overlap-региону через clustering чистых embeddings. Но overlap-регион по определению содержит **двух** спикеров. Без дополнительного pass'а второй спикер теряется и DER на VoxConverse недосчитывает overlap miss-rate. + +## Goal + +Добавить пост-процессинговый `OverlapResegmenter`: для каждого overlap-региона приписать второго спикера, выбирая **ближайший cosine-кластер ≠ primary** среди уже найденных кластеров. Pass обязан: + +- Не ломать single-speaker / silence результаты (regress = 0). +- Поправить overlap-mass без повторного запуска segmenter / embedder / clusterer (post-processing only). +- Уложиться в ±0.5% DER на VoxConverse-smoke (acceptance из roadmap §10). +- Быть pure-Rust + wasm32-clean (соответствует `clusterer::AhcClusterer` паттерну). + +## Non-goals + +- VBx / HMM resegmentation (сдвинуто в v1.2 по roadmap §2.3). +- Re-running segmenter на overlap-регионах (упомянуто в roadmap §3.1 диаграмме как комментарий, но реальный механизм — cosine assignment, см. ту же диаграмму). +- Изменение `Clusterer` / `Embedder` / `Segmenter` traits. +- Wiring в `Pipeline` — это M6. +- Изменение FFI / Python — это M7. + +## Existing components M4 опирается на + +- `segmentation::RawSegment` (M1): два сегмента с одинаковым `time` и разными `local_speaker_idx`, оба с `is_overlap = true` для каждого overlap-фрейма. См. `aggregator.rs::single_window_overlap_yields_two_segments_same_time`. +- `embedder::apply_overlap_mask` (M2): обнуляет overlap regions перед основным embedding pass'ом. +- `clusterer::Clusterer::cluster(embeddings) -> Vec` (M3): primary speaker labels для каждого clean-embedded segment. +- `types::SpeakerTurn { time, speaker, confidence }`, `types::SpeakerId(u32)`, `types::TimeRange`. + +## Approach: Variant A — pure-Rust post-clustering pass + +Caller (M6 Pipeline) подаёт три уже-готовых входа: + +1. `primary_turns: &[SpeakerTurn]` — single-speaker turns после clustering, без overlap. +2. `speaker_centroids: &[SpeakerCentroid]` — `(SpeakerId, Vec)`, L2-normalized центроиды кластеров (mean of L2-normalized embeddings). +3. `overlap_regions: &[OverlapRegionInput]` — для каждой overlap-области: `time`, `primary_speaker: SpeakerId`, `embedding: Vec` (L2-normalized embedding извлечённого overlap-региона; caller сам решает как — обычно прямой embed без mask, либо отдельный specialised pass). + +`OverlapResegmenter::resegment(...)` для каждого overlap-региона: + +1. Skip, если `(time.end - time.start) < min_overlap_secs` (сегмент слишком короткий для надёжного assignment). +2. Найти `secondary` = центроид с максимальным cosine similarity к `embedding`, **исключая** `primary_speaker`. +3. Если `cosine(embedding, secondary_centroid) > threshold` → создать дополнительный `SpeakerTurn { time, speaker: secondary.id, text: None }`. Текущий `SpeakerTurn` не несёт confidence-поля; cosine score не сохраняется в M4 — при необходимости его можно прокинуть через возвращаемый `ResegmentDiagnostics` в M6. +4. Если кластеров < 2 (один спикер во всём файле) → пропустить. + +Output: `primary_turns ++ secondaries`, отсортированный по `time.start`. Time-spans могут пересекаться — это и есть overlap representation в существующей `Vec` модели. (Полноценный `DiarizationResultV2` со специальным overlap field — отдельная работа, не в M4.) + +### Pseudocode + +```text +resegment(primary_turns, centroids, overlap_regions, cfg): + if centroids.len() < 2: return primary_turns.clone() + let mut out = primary_turns.to_vec() + for region in overlap_regions: + if region.time.duration() < cfg.min_overlap_secs: continue + let mut best = None + for (id, c) in centroids: + if id == region.primary_speaker: continue + let s = cosine(region.embedding, c) + if best.map(|(_, sb)| s > sb).unwrap_or(true): + best = Some((id, s)) + if let Some((id, s)) = best: + if s > cfg.threshold: + out.push(SpeakerTurn { + time: region.time, + speaker: id, + confidence: clamp01(s), + }) + out.sort_by(|a, b| a.time.start.partial_cmp(&b.time.start).unwrap()) + out +``` + +## API surface + +### Module placement + +Single file `src/resegmentation.rs` (mirrors `src/clusterer.rs`, `src/embedder.rs` — все per-milestone модули в M2/M3). + +Cargo feature `resegmentation = []`, default-on, pure Rust (нет ONNX зависимости). + +### Trait + types + +```rust +//! v1.0 OverlapResegmenter — overlap-aware post-processing pass. +//! Added in v0.6 (M4). See docs/superpowers/specs/2026-05-07-perfect-diarization-roadmap-v1-design.md §3.1. + +pub trait Resegmenter: Send + Sync { + /// Take primary turns + speaker centroids + overlap region inputs and + /// return a (possibly overlap-aware) flat list of turns. + /// + /// **Requires:** all centroid vectors and all overlap embeddings have the + /// same dimension and are approximately L2-normalized. + /// **Guarantees on Ok:** every turn in `primary_turns` is preserved + /// verbatim; secondary turns (if any) carry an existing `SpeakerId` from + /// `centroids`; output is sorted by `time.start`. + fn resegment( + &self, + inputs: ResegmentInputs<'_>, + ) -> Result, ResegmentError>; +} + +#[derive(Debug, Clone)] +pub struct ResegmentInputs<'a> { + pub primary_turns: &'a [SpeakerTurn], + pub speaker_centroids: &'a [SpeakerCentroid], + pub overlap_regions: &'a [OverlapRegionInput], +} + +#[derive(Debug, Clone)] +pub struct SpeakerCentroid { + pub speaker: SpeakerId, + pub embedding: Vec, // L2-normalized +} + +#[derive(Debug, Clone)] +pub struct OverlapRegionInput { + pub time: TimeRange, + pub primary_speaker: SpeakerId, + pub embedding: Vec, // L2-normalized +} + +#[derive(Debug, thiserror::Error)] +pub enum ResegmentError { + #[error("centroid dimension mismatch at index {index}: expected {expected}, got {actual}")] + CentroidDimMismatch { index: usize, expected: usize, actual: usize }, + #[error("overlap embedding dimension mismatch at index {index}: expected {expected}, got {actual}")] + OverlapDimMismatch { index: usize, expected: usize, actual: usize }, + #[error("primary speaker {primary:?} for overlap region {index} not present in centroids")] + MissingPrimaryCentroid { index: usize, primary: SpeakerId }, +} + +pub struct OverlapResegmenter { + threshold: f32, // default 0.0 + min_overlap_secs: f32, // default 0.1 +} + +impl OverlapResegmenter { + pub fn new(threshold: f32, min_overlap_secs: f32) -> Self; +} + +impl Default for OverlapResegmenter { + fn default() -> Self { Self::new(0.0, 0.1) } +} + +impl Resegmenter for OverlapResegmenter { ... } +``` + +### Helpers (same module, public) + +```rust +/// Compute per-cluster L2-normalized centroids from clustered embeddings. +/// `labels[i]` is the cluster label of `embeddings[i]`. Empty clusters yield +/// no entry. Output is sorted by SpeakerId. +pub fn compute_centroids( + embeddings: &[Vec], + labels: &[usize], +) -> Vec; + +/// Find pairs of overlapping `RawSegment`s (same `time`, different +/// `local_speaker_idx`, `is_overlap = true`) and return one +/// `OverlapRegionInput` per pair. Caller must supply `local_to_global` +/// mapping (typically from clustering pipeline) and `embedder` results +/// for each overlap region. This helper only does interval matching. +/// +/// Returns time ranges with the **primary** local speaker (the one that +/// already appears in `primary_turns`); secondary embedding lookup remains +/// the caller's responsibility (M6 Pipeline). +pub fn extract_overlap_time_ranges( + raw_segments: &[RawSegment], +) -> Vec<(TimeRange, u8 /* primary local idx */, u8 /* secondary local idx */)>; +``` + +`extract_overlap_time_ranges` живёт в `resegmentation.rs` чтобы инкапсулировать всю overlap-aware логику в одном модуле. Pipeline (M6) использует эту функцию + `local_to_global` mapping + embedder pool для построения `OverlapRegionInput[]`. + +### Re-exports (lib.rs) + +```rust +#[cfg(feature = "resegmentation")] +pub mod resegmentation; + +#[cfg(feature = "resegmentation")] +pub use resegmentation::{ + OverlapRegionInput, OverlapResegmenter, ResegmentError, ResegmentInputs, + Resegmenter, SpeakerCentroid, compute_centroids, extract_overlap_time_ranges, +}; +``` + +## Cosine similarity contract + +Используется существующий `crate::utils::cosine_similarity(a: &[f32], b: &[f32]) -> f32`. Centroids и overlap embeddings обязаны быть L2-normalized; resegmenter **не** перенормализует — это контракт caller'а (как в `Embedder::embed` "guarantees on Ok"). + +`compute_centroids` нормализует sum → mean → L2 на выходе, поэтому output guaranteed-normalized. + +## File layout + +| Path | Action | Lines | +|---|---|---| +| `Cargo.toml` | modify | +5 (feature `resegmentation` default-on) | +| `src/resegmentation.rs` | create | ~350 (trait + impl + helpers + tests) | +| `src/lib.rs` | modify | +6 (cfg-gated `pub mod` + re-exports) | +| `tests/resegmentation_test.rs` | create | ~100 (synthetic integration test) | +| `tests/miri_resegmentation.rs` | create | ~50 (no-overlap, full-overlap) | +| `CHANGELOG.md` | modify | +9 (Unreleased M4 section) | + +Total: ~520 lines new code. + +## Acceptance criteria + +1. `cargo test --features resegmentation` зелёный. +2. `cargo test --features resegmentation,segmentation,clusterer,spectral` зелёный (full-feature combo). +3. `cargo test --no-default-features --features resegmentation` зелёный (pure-Rust core). +4. `cargo check --target wasm32-unknown-unknown --no-default-features --features resegmentation --lib` зелёный (wasm32-clean). +5. `cargo clippy --features resegmentation --all-targets -- -D warnings` зелёный. +6. `cargo fmt --check` зелёный. +7. `cargo miri test --features resegmentation --test miri_resegmentation` зелёный. +8. Integration test покрывает: + - **No overlap input**: output identical to `primary_turns` (silence/single-speaker preservation). + - **Single overlap, two well-separated centroids**: secondary correctly assigned. + - **One cluster only**: pass-through (no second speaker to assign). + - **Threshold gating**: при `threshold = 1.0` (невозможно достичь) — secondary не добавляется. + - **Min duration gating**: overlap < `min_overlap_secs` пропускается. +9. Property tests (≥1000 cases each): + - `primary_turns ⊆ output` always (set inclusion на `(start, end, speaker)`). + - `output.is_sorted_by(|t| t.time.start)`. + - `compute_centroids(emb, labels)` → каждый centroid L2-normalized (`|‖c‖₂ − 1| < 1e-3`). +10. DER на VoxConverse-smoke: pre-M4 baseline и post-M4 — diff не хуже +0.5% (acceptance из roadmap §10). Замер делается через существующий `polyvoice-bench`. **Этот замер не блокирует merge M4** — он выполняется как отдельный artifact в plan'е и закрепляется в `tests/der_baseline.json` после M5/M6 (когда Pipeline собран). + +## Tests catalogue (TDD plan delivers these) + +```text +src/resegmentation.rs::tests + resegmenter_trait_object_is_dyn_compatible + compute_centroids_l2_normalized + compute_centroids_drops_empty_clusters + compute_centroids_sorted_by_speaker_id + extract_overlap_time_ranges_returns_pairs + extract_overlap_time_ranges_ignores_non_overlap + resegment_no_overlap_passes_through + resegment_single_cluster_passes_through + resegment_picks_secondary_excluding_primary + resegment_threshold_blocks_low_confidence + resegment_min_duration_blocks_short_regions + resegment_output_sorted_by_start + resegment_missing_primary_centroid_errors + resegment_dim_mismatch_errors + +tests/resegmentation_test.rs (integration) + end_to_end_synthetic_two_speakers_overlap + end_to_end_three_speakers_two_pairs + rttm_round_trip_preserves_overlap_turns + +tests/miri_resegmentation.rs + miri_resegment_no_overlap + miri_resegment_single_overlap + miri_compute_centroids +``` + +## Risks & mitigations + +| Риск | Вероятность | Mitigation | +|---|---|---| +| `cosine_similarity` не нормализуется на потенциально не-L2 vector → wrong scores | низкая | Property test: shuffled L2-normalized vectors → cosine ∈ [-1, 1]. Document contract в trait doc. | +| Default `threshold = 0.0` слишком агрессивен → ложные secondary в speech-only регионах | средняя | Caller (M6 Pipeline) обязан подавать только overlap regions, помеченные segmenter'ом как `is_overlap = true`. Поэтому false-positive overlap detection локализуется в M1 segmenter, а не в M4. Если потребуется более консервативный default — поднимем до 0.3 в M5/M6 после VoxConverse-smoke бенча. | +| Caller передаст overlap embedding для региона где primary не присутствует в centroids | низкая | `ResegmentError::MissingPrimaryCentroid` (нет silent failure). | +| Output ломает downstream RTTM writer (turns с пересечением time) | средняя | Existing `rttm.rs` уже допускает многострочные SPEAKER записи с пересечением. Тестируется явно в integration test через `rttm::write` round-trip. | +| Property test instability (random embeddings → degenerate cosines) | низкая | Use seeded `rand::SeedableRng`; clamp results before assertions. | + +## Dependencies on other milestones + +- **Inputs:** M1 (`RawSegment`), M2 (`Embedder` for caller, не для M4), M3 (`Clusterer` для caller). +- **Used by:** M6 (Pipeline wires everything together), then M7/M9. +- **Independent of:** M5 (INT8 quantization), M8 (Android/multi-platform). + +M4 mergeable до M5 — INT8 артефактов не требует. Параллельно с M4 могут идти M5 calibration работы. + +## Open questions + +(закрыты в pre-design discussion) + +- ✅ Variant A (pure-Rust post-clustering pass) chosen over B (full orchestrator) and C (RawSegment-driven without cosine). +- ✅ Output: flat `Vec` with possibly overlapping time spans (not a structured `ResegmentOutput`). +- ✅ Default `threshold = 0.0`, `min_overlap_secs = 0.1`. +- ✅ `extract_overlap_time_ranges` lives in `src/resegmentation.rs`. + +## Follow-ups + +1. После одобрения spec: invoke `superpowers:writing-plans` для генерации M4 implementation plan в `docs/superpowers/plans/2026-05-07-m4-overlap-resegmenter-plan.md` (стиль M3 plan: 5 tasks, TDD, atomic commits per task, git tag `m4-complete`). +2. После M5+M6: запустить `polyvoice-bench --profile balanced` на VoxConverse-smoke до и после `resegment_overlap = true`, зафиксировать DER delta в `tests/der_baseline.json`. + +## References + +- Roadmap §3.1 (pipeline diagram, "Resegmentation pass"), §3.2 ("Resegmentation: −1…−2% DER на VoxConverse"), §10.1 (M4 row). +- Existing impl pattern: `src/clusterer.rs` (M3) — single-file trait + adapter + tests, feature-gated, default-on. +- Existing helper: `src/embedder.rs::apply_overlap_mask` (M2). +- Aggregator overlap output proof: `src/segmentation/aggregator.rs::tests::single_window_overlap_yields_two_segments_same_time`. +- Powerset paper: Plaquet & Bredin, INTERSPEECH 2023, [arXiv:2310.13025](https://arxiv.org/html/2310.13025v1) (overlap binarization). From 3573c6e42afceecd2077e82e6fa57e620e06d885 Mon Sep 17 00:00:00 2001 From: ekhodzitsky Date: Thu, 7 May 2026 17:04:09 +0300 Subject: [PATCH 11/11] fix(M4): remove stray re-exports + drive-by fmt for M3 files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two erroneous public re-exports (ecapa::EcapaMelOnnxExtractor and ecapa::RawAudioOnnxExtractor) slipped into src/lib.rs during Task 5 — those types do not exist on master, so doc / clippy / semver-checks all failed in CI. Removed. Drive-by: apply rustfmt to src/clusterer.rs and tests/clusterer_test.rs (both pre-existing M3 fmt issues that were unblocking CI on this PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/clusterer.rs | 48 +++++++++++++++++++++++++++++------------ src/lib.rs | 4 ---- tests/clusterer_test.rs | 18 +++++++++++++--- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/clusterer.rs b/src/clusterer.rs index ca769b14..4603ceca 100644 --- a/src/clusterer.rs +++ b/src/clusterer.rs @@ -47,12 +47,16 @@ pub struct AhcClusterer { impl AhcClusterer { pub fn new(max_clusters: usize) -> Self { - Self { max_clusters: max_clusters.max(1) } + Self { + max_clusters: max_clusters.max(1), + } } } impl Default for AhcClusterer { - fn default() -> Self { Self::new(64) } + fn default() -> Self { + Self::new(64) + } } impl Clusterer for AhcClusterer { @@ -67,7 +71,9 @@ impl Clusterer for AhcClusterer { Ok(labels) } - fn max_clusters(&self) -> usize { self.max_clusters } + fn max_clusters(&self) -> usize { + self.max_clusters + } } #[cfg(test)] @@ -121,8 +127,12 @@ mod ahc_tests { fn synth_two_clusters() -> Vec> { vec![ - vec![1.0, 0.05, 0.0], vec![0.95, 0.0, 0.05], vec![1.0, 0.0, 0.0], - vec![0.0, 1.0, 0.0], vec![0.05, 0.95, 0.0], vec![0.0, 1.0, 0.05], + vec![1.0, 0.05, 0.0], + vec![0.95, 0.0, 0.05], + vec![1.0, 0.0, 0.0], + vec![0.0, 1.0, 0.0], + vec![0.05, 0.95, 0.0], + vec![0.0, 1.0, 0.05], ] } @@ -180,13 +190,17 @@ pub struct NmeScClusterer { #[cfg(feature = "spectral")] impl NmeScClusterer { pub fn new(max_clusters: usize) -> Self { - Self { max_clusters: max_clusters.max(1) } + Self { + max_clusters: max_clusters.max(1), + } } } #[cfg(feature = "spectral")] impl Default for NmeScClusterer { - fn default() -> Self { Self::new(64) } + fn default() -> Self { + Self::new(64) + } } #[cfg(feature = "spectral")] @@ -223,9 +237,7 @@ impl Clusterer for NmeScClusterer { } // Degree vector. - let deg: Vec = (0..n) - .map(|i| aff[i * n..i * n + n].iter().sum()) - .collect(); + let deg: Vec = (0..n).map(|i| aff[i * n..i * n + n].iter().sum()).collect(); // Normalized Laplacian L = I - D^{-1/2} A D^{-1/2}. let mut lap = Mat::zeros(n, n); @@ -294,7 +306,9 @@ impl Clusterer for NmeScClusterer { Ok(labels) } - fn max_clusters(&self) -> usize { self.max_clusters } + fn max_clusters(&self) -> usize { + self.max_clusters + } } #[cfg(all(test, feature = "spectral"))] @@ -303,9 +317,15 @@ mod nme_sc_tests { fn synth_three_clusters() -> Vec> { vec![ - vec![1.0, 0.0, 0.0], vec![0.98, 0.05, 0.0], vec![0.97, 0.0, 0.05], - vec![0.0, 1.0, 0.0], vec![0.05, 0.98, 0.0], vec![0.0, 0.97, 0.05], - vec![0.0, 0.0, 1.0], vec![0.05, 0.0, 0.98], vec![0.0, 0.05, 0.97], + vec![1.0, 0.0, 0.0], + vec![0.98, 0.05, 0.0], + vec![0.97, 0.0, 0.05], + vec![0.0, 1.0, 0.0], + vec![0.05, 0.98, 0.0], + vec![0.0, 0.97, 0.05], + vec![0.0, 0.0, 1.0], + vec![0.05, 0.0, 0.98], + vec![0.0, 0.05, 0.97], ] } diff --git a/src/lib.rs b/src/lib.rs index 23156683..ca315187 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -124,14 +124,10 @@ pub use types::{ }; pub use vad::{EnergyVad, VadConfig, VadError, VoiceActivityDetector, segment_speech}; -#[cfg(feature = "onnx")] -pub use ecapa::EcapaMelOnnxExtractor; #[cfg(feature = "onnx")] #[allow(deprecated)] pub use ecapa::EcapaTdnnExtractor; #[cfg(feature = "onnx")] pub use ecapa::FbankOnnxExtractor; #[cfg(feature = "onnx")] -pub use ecapa::RawAudioOnnxExtractor; -#[cfg(feature = "onnx")] pub use onnx::OnnxEmbeddingExtractor; diff --git a/tests/clusterer_test.rs b/tests/clusterer_test.rs index bfa6ea16..540ea262 100644 --- a/tests/clusterer_test.rs +++ b/tests/clusterer_test.rs @@ -22,7 +22,9 @@ fn synth_clusters_4(d: usize) -> Vec> { let mut perturbed = c.clone(); perturbed[0] += 0.01; let n: f32 = perturbed.iter().map(|x| x * x).sum::().sqrt(); - for x in &mut perturbed { *x /= n; } + for x in &mut perturbed { + *x /= n; + } all.push(perturbed); } } @@ -34,7 +36,12 @@ fn ahc_finds_four_clusters() { let c = AhcClusterer::default(); let labels = c.cluster(&synth_clusters_4(8)).unwrap(); let unique: std::collections::HashSet = labels.iter().copied().collect(); - assert!((3..=5).contains(&unique.len()), "got {} clusters: {:?}", unique.len(), labels); + assert!( + (3..=5).contains(&unique.len()), + "got {} clusters: {:?}", + unique.len(), + labels + ); } #[cfg(feature = "spectral")] @@ -43,5 +50,10 @@ fn nme_sc_finds_four_clusters() { let c = NmeScClusterer::default(); let labels = c.cluster(&synth_clusters_4(8)).unwrap(); let unique: std::collections::HashSet = labels.iter().copied().collect(); - assert!((3..=5).contains(&unique.len()), "got {} clusters: {:?}", unique.len(), labels); + assert!( + (3..=5).contains(&unique.len()), + "got {} clusters: {:?}", + unique.len(), + labels + ); }