Skip to main content

kcode_speaker_dataset/
lib.rs

1use kcode_speaker_types::{Key, LabeledSample, ObjectId};
2use std::collections::{BTreeMap, BTreeSet};
3use std::error::Error;
4use std::fmt;
5
6/// A validated, canonically ordered pair of dataset views.
7#[derive(Clone, Debug)]
8pub struct Dataset {
9    active: Vec<LabeledSample>,
10    repeatability: Vec<LabeledSample>,
11}
12
13/// Number of deterministic known-speaker folds per pseudo-unknown speaker.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct FoldConfig {
16    pub known_folds: u8,
17}
18
19/// One open-set fixture. Every index addresses [`active_rows`].
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct OpenSetFold {
22    pub pseudo_unknown_speaker: Key,
23    pub train_indices: Vec<usize>,
24    pub known_test_indices: Vec<usize>,
25    pub unknown_test_indices: Vec<usize>,
26}
27
28/// Distinct provider attempts for one confirmed speaker and canonical clip.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct RepeatabilityGroup {
31    pub speaker_id: Key,
32    pub clip_object: ObjectId,
33    pub sample_indices: Vec<usize>,
34}
35
36/// Validation and fold-construction failures.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum DatasetError {
39    EmptyActive,
40    MixedActiveCohorts,
41    DuplicateActiveSampleId,
42    DuplicateRepeatabilitySampleId,
43    ConflictingSharedSampleId,
44    DuplicateActiveObservation,
45    InconsistentClipGroup,
46    InvalidRecordingQuality,
47    NoUsableSpeech,
48    InvalidKnownFolds,
49    ImpossibleFold,
50}
51
52impl fmt::Display for DatasetError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        let message = match self {
55            Self::EmptyActive => "active data is empty",
56            Self::MixedActiveCohorts => "active rows contain multiple cohorts",
57            Self::DuplicateActiveSampleId => "active rows contain a duplicate sample ID",
58            Self::DuplicateRepeatabilitySampleId => {
59                "repeatability rows contain a duplicate sample ID"
60            }
61            Self::ConflictingSharedSampleId => {
62                "a sample ID shared by both views has different rows"
63            }
64            Self::DuplicateActiveObservation => {
65                "active rows contain a duplicate clip and speaker observation"
66            }
67            Self::InconsistentClipGroup => "one active clip appears in multiple leakage groups",
68            Self::InvalidRecordingQuality => "recording quality exceeds 100",
69            Self::NoUsableSpeech => "a row has no usable speech",
70            Self::InvalidKnownFolds => "known_folds must be at least two",
71            Self::ImpossibleFold => "the requested leakage-safe folds cannot be formed",
72        };
73        f.write_str(message)
74    }
75}
76
77impl Error for DatasetError {}
78
79/// Validates and canonically orders both dataset views.
80pub fn build(
81    mut active_rows: Vec<LabeledSample>,
82    mut repeatability_rows: Vec<LabeledSample>,
83) -> Result<Dataset, DatasetError> {
84    if active_rows.is_empty() {
85        return Err(DatasetError::EmptyActive);
86    }
87
88    active_rows.sort_by(|a, b| a.sample_id.as_ref().cmp(b.sample_id.as_ref()));
89    repeatability_rows.sort_by(|a, b| a.sample_id.as_ref().cmp(b.sample_id.as_ref()));
90
91    validate_rows(&active_rows)?;
92    validate_rows(&repeatability_rows)?;
93
94    if active_rows
95        .windows(2)
96        .any(|rows| rows[0].sample_id == rows[1].sample_id)
97    {
98        return Err(DatasetError::DuplicateActiveSampleId);
99    }
100    if repeatability_rows
101        .windows(2)
102        .any(|rows| rows[0].sample_id == rows[1].sample_id)
103    {
104        return Err(DatasetError::DuplicateRepeatabilitySampleId);
105    }
106
107    let cohort = active_rows[0].cohort_id.as_ref();
108    if active_rows
109        .iter()
110        .any(|row| row.cohort_id.as_ref() != cohort)
111    {
112        return Err(DatasetError::MixedActiveCohorts);
113    }
114
115    let mut observations = BTreeSet::new();
116    let mut clip_groups: BTreeMap<&str, &str> = BTreeMap::new();
117    for row in &active_rows {
118        if !observations.insert((row.clip_object.as_ref(), row.speaker_id.as_ref())) {
119            return Err(DatasetError::DuplicateActiveObservation);
120        }
121
122        match clip_groups.get(row.clip_object.as_ref()) {
123            Some(group) if *group != row.group_id.as_ref() => {
124                return Err(DatasetError::InconsistentClipGroup);
125            }
126            Some(_) => {}
127            None => {
128                clip_groups.insert(row.clip_object.as_ref(), row.group_id.as_ref());
129            }
130        }
131    }
132
133    let active_by_id: BTreeMap<&str, &LabeledSample> = active_rows
134        .iter()
135        .map(|row| (row.sample_id.as_ref(), row))
136        .collect();
137    for row in &repeatability_rows {
138        if let Some(active) = active_by_id.get(row.sample_id.as_ref())
139            && *active != row
140        {
141            return Err(DatasetError::ConflictingSharedSampleId);
142        }
143    }
144
145    Ok(Dataset {
146        active: active_rows,
147        repeatability: repeatability_rows,
148    })
149}
150
151fn validate_rows(rows: &[LabeledSample]) -> Result<(), DatasetError> {
152    for row in rows {
153        if row.recording_quality > 100 {
154            return Err(DatasetError::InvalidRecordingQuality);
155        }
156        if row.usable_speech_ms == 0 {
157            return Err(DatasetError::NoUsableSpeech);
158        }
159    }
160    Ok(())
161}
162
163/// Returns the canonical active row view used by all fold indices.
164pub fn active_rows(dataset: &Dataset) -> &[LabeledSample] {
165    &dataset.active
166}
167
168/// Returns the canonical repeatability view used by repeatability indices.
169pub fn repeatability_rows(dataset: &Dataset) -> &[LabeledSample] {
170    &dataset.repeatability
171}
172
173#[derive(Debug)]
174struct Group {
175    id: String,
176    indices: Vec<usize>,
177    speaker_counts: BTreeMap<String, usize>,
178}
179
180fn active_groups(dataset: &Dataset) -> Vec<Group> {
181    let mut indices_by_group: BTreeMap<String, Vec<usize>> = BTreeMap::new();
182    for (index, row) in dataset.active.iter().enumerate() {
183        indices_by_group
184            .entry(row.group_id.as_ref().to_owned())
185            .or_default()
186            .push(index);
187    }
188
189    indices_by_group
190        .into_iter()
191        .map(|(id, indices)| {
192            let mut speaker_counts = BTreeMap::new();
193            for &index in &indices {
194                *speaker_counts
195                    .entry(dataset.active[index].speaker_id.as_ref().to_owned())
196                    .or_default() += 1;
197            }
198            Group {
199                id,
200                indices,
201                speaker_counts,
202            }
203        })
204        .collect()
205}
206
207/// Builds deterministic leakage-safe open-set fixtures.
208pub fn open_set_folds(
209    dataset: &Dataset,
210    config: FoldConfig,
211) -> Result<Vec<OpenSetFold>, DatasetError> {
212    let fold_count = usize::from(config.known_folds);
213    if fold_count < 2 {
214        return Err(DatasetError::InvalidKnownFolds);
215    }
216
217    let groups = active_groups(dataset);
218    let speakers: BTreeMap<String, Key> = dataset
219        .active
220        .iter()
221        .map(|row| (row.speaker_id.as_ref().to_owned(), row.speaker_id.clone()))
222        .collect();
223    let mut output = Vec::with_capacity(speakers.len() * fold_count);
224
225    for (pseudo_name, pseudo_key) in speakers {
226        let (blocked, mut eligible): (Vec<&Group>, Vec<&Group>) = groups
227            .iter()
228            .partition(|group| group.speaker_counts.contains_key(&pseudo_name));
229
230        if blocked.is_empty() || eligible.len() < fold_count {
231            return Err(DatasetError::ImpossibleFold);
232        }
233
234        eligible.sort_by(|a, b| {
235            b.indices
236                .len()
237                .cmp(&a.indices.len())
238                .then_with(|| b.speaker_counts.len().cmp(&a.speaker_counts.len()))
239                .then_with(|| a.id.cmp(&b.id))
240        });
241
242        let mut test_counts = vec![BTreeMap::<String, usize>::new(); fold_count];
243        let mut row_counts = vec![0_usize; fold_count];
244        let mut group_counts = vec![0_usize; fold_count];
245        let mut assignment = BTreeMap::<&str, usize>::new();
246
247        for group in &eligible {
248            let chosen = (0..fold_count)
249                .min_by_key(|&fold| {
250                    let speaker_cost: usize = group
251                        .speaker_counts
252                        .iter()
253                        .map(|(speaker, addition)| {
254                            let current =
255                                test_counts[fold].get(speaker).copied().unwrap_or_default();
256                            2 * current * addition + addition * addition
257                        })
258                        .sum();
259                    (speaker_cost, row_counts[fold], group_counts[fold], fold)
260                })
261                .expect("fold count is nonzero");
262
263            assignment.insert(group.id.as_str(), chosen);
264            for (speaker, count) in &group.speaker_counts {
265                *test_counts[chosen].entry(speaker.clone()).or_default() += count;
266            }
267            row_counts[chosen] += group.indices.len();
268            group_counts[chosen] += 1;
269        }
270
271        if group_counts.contains(&0) {
272            return Err(DatasetError::ImpossibleFold);
273        }
274
275        for fold in 0..fold_count {
276            let mut train_indices: Vec<usize> = Vec::new();
277            let mut known_test_indices: Vec<usize> = Vec::new();
278            let mut unknown_test_indices: Vec<usize> = Vec::new();
279
280            for group in &blocked {
281                for &index in &group.indices {
282                    if dataset.active[index].speaker_id.as_ref() == pseudo_name {
283                        unknown_test_indices.push(index);
284                    } else {
285                        known_test_indices.push(index);
286                    }
287                }
288            }
289
290            for group in &eligible {
291                if assignment[group.id.as_str()] == fold {
292                    known_test_indices.extend(&group.indices);
293                } else {
294                    train_indices.extend(&group.indices);
295                }
296            }
297
298            train_indices.sort_unstable();
299            known_test_indices.sort_unstable();
300            unknown_test_indices.sort_unstable();
301
302            if train_indices.is_empty()
303                || known_test_indices.is_empty()
304                || unknown_test_indices.is_empty()
305            {
306                return Err(DatasetError::ImpossibleFold);
307            }
308
309            let trained_speakers: BTreeSet<&str> = train_indices
310                .iter()
311                .map(|index: &usize| dataset.active[*index].speaker_id.as_ref())
312                .collect();
313            if known_test_indices.iter().any(|index: &usize| {
314                !trained_speakers.contains(dataset.active[*index].speaker_id.as_ref())
315            }) {
316                return Err(DatasetError::ImpossibleFold);
317            }
318
319            output.push(OpenSetFold {
320                pseudo_unknown_speaker: pseudo_key.clone(),
321                train_indices,
322                known_test_indices,
323                unknown_test_indices,
324            });
325        }
326    }
327
328    Ok(output)
329}
330
331/// Returns canonical clip/speaker groups having at least two distinct attempts.
332pub fn repeatability_groups(dataset: &Dataset) -> Vec<RepeatabilityGroup> {
333    let mut grouped: BTreeMap<(String, String), Vec<usize>> = BTreeMap::new();
334    for (index, row) in dataset.repeatability.iter().enumerate() {
335        grouped
336            .entry((
337                row.speaker_id.as_ref().to_owned(),
338                row.clip_object.as_ref().to_owned(),
339            ))
340            .or_default()
341            .push(index);
342    }
343
344    grouped
345        .into_values()
346        .filter(|indices| {
347            indices
348                .iter()
349                .map(|&index| dataset.repeatability[index].attempt_id.as_ref())
350                .collect::<BTreeSet<_>>()
351                .len()
352                >= 2
353        })
354        .map(|sample_indices| {
355            let row = &dataset.repeatability[sample_indices[0]];
356            RepeatabilityGroup {
357                speaker_id: row.speaker_id.clone(),
358                clip_object: row.clip_object.clone(),
359                sample_indices,
360            }
361        })
362        .collect()
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use kcode_speaker_types::{FEATURE_COUNT, FeatureVector, RecordingKind};
369
370    fn key(value: &str) -> Key {
371        Key::parse(value).unwrap()
372    }
373
374    fn object(value: &str) -> ObjectId {
375        ObjectId::parse(value).unwrap()
376    }
377
378    fn sample_with_attempt(
379        id: &str,
380        attempt: &str,
381        speaker: &str,
382        clip: &str,
383        group: &str,
384    ) -> LabeledSample {
385        LabeledSample {
386            sample_id: key(id),
387            attempt_id: key(attempt),
388            speaker_id: key(speaker),
389            cohort_id: key("cohort"),
390            group_id: key(group),
391            clip_object: object(clip),
392            recording_kind: RecordingKind::Meeting,
393            recording_quality: 100,
394            usable_speech_ms: 900,
395            primary_language: key("en"),
396            features: FeatureVector::new([50; FEATURE_COUNT]).unwrap(),
397        }
398    }
399
400    fn sample(id: &str, speaker: &str, clip: &str, group: &str) -> LabeledSample {
401        sample_with_attempt(id, id, speaker, clip, group)
402    }
403
404    fn fixture_rows() -> Vec<LabeledSample> {
405        vec![
406            sample("a1", "a", "CLIP0001", "a-source"),
407            sample("a2", "a", "CLIP0002", "a-source"),
408            sample("a3", "a", "CLIP0003", "a-three"),
409            sample("a4", "a", "CLIP0004", "meeting"),
410            sample("b1", "b", "CLIP0011", "b-one"),
411            sample("b2", "b", "CLIP0012", "b-two"),
412            sample("b3", "b", "CLIP0013", "b-three"),
413            sample("b4", "b", "CLIP0004", "meeting"),
414            sample("c1", "c", "CLIP0021", "c-source"),
415            sample("c2", "c", "CLIP0022", "c-source"),
416            sample("c3", "c", "CLIP0023", "c-three"),
417        ]
418    }
419
420    fn repeat_rows() -> Vec<LabeledSample> {
421        vec![
422            sample_with_attempt("r3", "try-3", "a", "CLIP0031", "repeat"),
423            sample_with_attempt("r1", "try-1", "a", "CLIP0030", "repeat"),
424            sample_with_attempt("r2", "try-2", "a", "CLIP0030", "repeat"),
425            sample_with_attempt("r4", "try-4", "b", "CLIP0032", "repeat"),
426            sample_with_attempt("r5", "same-try", "c", "CLIP0033", "repeat"),
427            sample_with_attempt("r6", "same-try", "c", "CLIP0033", "repeat"),
428        ]
429    }
430
431    fn side(fold: &OpenSetFold, index: usize) -> u8 {
432        if fold.train_indices.contains(&index) {
433            1
434        } else if fold.known_test_indices.contains(&index)
435            || fold.unknown_test_indices.contains(&index)
436        {
437            2
438        } else {
439            0
440        }
441    }
442
443    #[test]
444    fn groups_never_cross_and_co_speakers_are_test_only() {
445        let dataset = build(fixture_rows(), vec![]).unwrap();
446        let folds = open_set_folds(&dataset, FoldConfig { known_folds: 2 }).unwrap();
447
448        for fold in &folds {
449            let mut group_sides: BTreeMap<&str, u8> = BTreeMap::new();
450            for (index, row) in active_rows(&dataset).iter().enumerate() {
451                let current = side(fold, index);
452                assert_ne!(current, 0);
453                let previous = group_sides.entry(row.group_id.as_ref()).or_insert(current);
454                assert_eq!(*previous, current);
455            }
456        }
457
458        let meeting_b = active_rows(&dataset)
459            .iter()
460            .position(|row| row.sample_id.as_ref() == "b4")
461            .unwrap();
462        for fold in folds
463            .iter()
464            .filter(|fold| fold.pseudo_unknown_speaker.as_ref() == "a")
465        {
466            assert!(fold.known_test_indices.contains(&meeting_b));
467            assert!(!fold.train_indices.contains(&meeting_b));
468        }
469    }
470
471    #[test]
472    fn sibling_duplicate_excerpt_and_overlap_group_stays_whole() {
473        let dataset = build(fixture_rows(), vec![]).unwrap();
474        let folds = open_set_folds(&dataset, FoldConfig { known_folds: 2 }).unwrap();
475        let related: Vec<_> = active_rows(&dataset)
476            .iter()
477            .enumerate()
478            .filter(|(_, row)| row.group_id.as_ref() == "a-source")
479            .map(|(index, _)| index)
480            .collect();
481
482        assert_eq!(related.len(), 2);
483        for fold in folds {
484            assert_eq!(side(&fold, related[0]), side(&fold, related[1]));
485        }
486    }
487
488    #[test]
489    fn permutation_balancing_and_index_views_are_stable() {
490        let active = fixture_rows();
491        let repeatability = repeat_rows();
492        let mut reversed_active = active.clone();
493        let mut reversed_repeatability = repeatability.clone();
494        reversed_active.reverse();
495        reversed_repeatability.reverse();
496
497        let first = build(active, repeatability).unwrap();
498        let second = build(reversed_active, reversed_repeatability).unwrap();
499        assert_eq!(
500            active_rows(&first)
501                .iter()
502                .map(|row| row.sample_id.as_ref())
503                .collect::<Vec<_>>(),
504            active_rows(&second)
505                .iter()
506                .map(|row| row.sample_id.as_ref())
507                .collect::<Vec<_>>()
508        );
509
510        let first_folds = open_set_folds(&first, FoldConfig { known_folds: 2 }).unwrap();
511        assert_eq!(
512            first_folds,
513            open_set_folds(&second, FoldConfig { known_folds: 2 }).unwrap()
514        );
515        assert_eq!(repeatability_groups(&first), repeatability_groups(&second));
516        assert_eq!(first_folds.len(), 6);
517
518        let b_folds: Vec<_> = first_folds
519            .iter()
520            .filter(|fold| fold.pseudo_unknown_speaker.as_ref() == "b")
521            .collect();
522        let known_ids = |fold: &OpenSetFold| {
523            fold.known_test_indices
524                .iter()
525                .map(|&index| active_rows(&first)[index].sample_id.as_ref())
526                .collect::<BTreeSet<_>>()
527        };
528        assert_eq!(
529            known_ids(b_folds[0]),
530            BTreeSet::from(["a1", "a2", "a4", "c3"])
531        );
532        assert_eq!(
533            known_ids(b_folds[1]),
534            BTreeSet::from(["a3", "a4", "c1", "c2"])
535        );
536
537        for fold in first_folds {
538            for index in fold
539                .train_indices
540                .iter()
541                .chain(&fold.known_test_indices)
542                .chain(&fold.unknown_test_indices)
543            {
544                assert!(active_rows(&first).get(*index).is_some());
545            }
546        }
547        for group in repeatability_groups(&first) {
548            for index in group.sample_indices {
549                assert!(repeatability_rows(&first).get(index).is_some());
550            }
551        }
552    }
553
554    #[test]
555    fn exact_repeatability_grouping_requires_distinct_attempts() {
556        let dataset = build(fixture_rows(), repeat_rows()).unwrap();
557        let groups = repeatability_groups(&dataset);
558
559        assert_eq!(groups.len(), 1);
560        assert_eq!(groups[0].speaker_id.as_ref(), "a");
561        assert_eq!(groups[0].clip_object.as_ref(), "CLIP0030");
562        let ids: Vec<_> = groups[0]
563            .sample_indices
564            .iter()
565            .map(|&index| repeatability_rows(&dataset)[index].sample_id.as_ref())
566            .collect();
567        assert_eq!(ids, ["r1", "r2"]);
568    }
569
570    #[test]
571    fn duplicate_and_cross_view_rules_are_exact() {
572        let row = sample("same", "a", "CLIP0040", "g");
573        assert_eq!(
574            build(vec![row.clone(), row.clone()], vec![]).unwrap_err(),
575            DatasetError::DuplicateActiveSampleId
576        );
577        assert_eq!(
578            build(fixture_rows(), vec![row.clone(), row.clone()]).unwrap_err(),
579            DatasetError::DuplicateRepeatabilitySampleId
580        );
581
582        let dataset = build(vec![row.clone()], vec![row.clone()]).unwrap();
583        assert_eq!(active_rows(&dataset)[0], repeatability_rows(&dataset)[0]);
584
585        let mut changed = row.clone();
586        changed.usable_speech_ms = 1;
587        assert_eq!(
588            build(vec![row], vec![changed]).unwrap_err(),
589            DatasetError::ConflictingSharedSampleId
590        );
591    }
592
593    #[test]
594    fn sparse_and_impossible_configs_fail_closed() {
595        let dataset = build(fixture_rows(), vec![]).unwrap();
596        assert_eq!(
597            open_set_folds(&dataset, FoldConfig { known_folds: 1 }).unwrap_err(),
598            DatasetError::InvalidKnownFolds
599        );
600        assert_eq!(
601            open_set_folds(&dataset, FoldConfig { known_folds: 20 }).unwrap_err(),
602            DatasetError::ImpossibleFold
603        );
604
605        let rows = vec![
606            sample("a1", "a", "CLIP0050", "ab"),
607            sample("b1", "b", "CLIP0050", "ab"),
608            sample("b2", "b", "CLIP0051", "b-only"),
609            sample("c1", "c", "CLIP0052", "c-one"),
610            sample("c2", "c", "CLIP0053", "c-two"),
611        ];
612        let sparse = build(rows, vec![]).unwrap();
613        assert_eq!(
614            open_set_folds(&sparse, FoldConfig { known_folds: 2 }).unwrap_err(),
615            DatasetError::ImpossibleFold
616        );
617    }
618
619    #[test]
620    fn active_observation_and_supplied_clip_group_are_validated() {
621        let first = sample("x1", "a", "CLIP0060", "one");
622        let duplicate = sample("x2", "a", "CLIP0060", "one");
623        assert_eq!(
624            build(vec![first.clone(), duplicate], vec![]).unwrap_err(),
625            DatasetError::DuplicateActiveObservation
626        );
627
628        let other_speaker = sample("x3", "b", "CLIP0060", "two");
629        assert_eq!(
630            build(vec![first, other_speaker], vec![]).unwrap_err(),
631            DatasetError::InconsistentClipGroup
632        );
633    }
634
635    #[test]
636    fn cohort_quality_and_usable_speech_are_validated() {
637        let mut mixed = sample("x2", "b", "CLIP0062", "two");
638        mixed.cohort_id = key("other");
639        assert_eq!(
640            build(vec![sample("x1", "a", "CLIP0061", "one"), mixed], vec![]).unwrap_err(),
641            DatasetError::MixedActiveCohorts
642        );
643
644        let mut poor = sample("x3", "a", "CLIP0063", "three");
645        poor.recording_quality = 101;
646        assert_eq!(
647            build(vec![poor], vec![]).unwrap_err(),
648            DatasetError::InvalidRecordingQuality
649        );
650
651        let mut silent = sample("x4", "a", "CLIP0064", "four");
652        silent.usable_speech_ms = 0;
653        assert_eq!(
654            build(vec![silent], vec![]).unwrap_err(),
655            DatasetError::NoUsableSpeech
656        );
657    }
658}