Skip to main content

kcode_speaker_eval/
lib.rs

1#![forbid(unsafe_code)]
2
3use kcode_speaker_dataset::{Dataset, OpenSetFold, active_rows};
4use kcode_speaker_model::{Decision, FitInput, ModelConfig, ModelError, fit, identify};
5use kcode_speaker_types::LabeledSample;
6use std::cmp::Ordering;
7use std::collections::{BTreeMap, BTreeSet};
8use std::error::Error;
9use std::fmt;
10
11pub struct EvaluationPlan<'a> {
12    pub dataset: &'a Dataset,
13    pub tuning_folds: &'a [OpenSetFold],
14    pub held_out_fold: &'a OpenSetFold,
15    pub candidates: &'a [ModelConfig],
16}
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub struct AggregateMetrics {
20    pub known_total: usize,
21    pub known_correct: usize,
22    pub known_misidentified: usize,
23    pub known_rejected: usize,
24    pub unknown_total: usize,
25    pub unknown_rejected: usize,
26    pub unknown_accepted: usize,
27}
28
29impl AggregateMetrics {
30    pub fn known_identification_rate(&self) -> f64 {
31        rate(self.known_correct, self.known_total)
32    }
33
34    pub fn known_misidentification_rate(&self) -> f64 {
35        rate(self.known_misidentified, self.known_total)
36    }
37
38    pub fn known_rejection_rate(&self) -> f64 {
39        rate(self.known_rejected, self.known_total)
40    }
41
42    pub fn unknown_rejection_rate(&self) -> f64 {
43        rate(self.unknown_rejected, self.unknown_total)
44    }
45
46    pub fn unknown_acceptance_rate(&self) -> f64 {
47        rate(self.unknown_accepted, self.unknown_total)
48    }
49
50    pub fn balanced_open_set_rate(&self) -> f64 {
51        (self.known_identification_rate() + self.unknown_rejection_rate()) / 2.0
52    }
53
54    fn merge(&mut self, other: Self) -> Result<(), EvalError> {
55        checked_add(&mut self.known_total, other.known_total)?;
56        checked_add(&mut self.known_correct, other.known_correct)?;
57        checked_add(&mut self.known_misidentified, other.known_misidentified)?;
58        checked_add(&mut self.known_rejected, other.known_rejected)?;
59        checked_add(&mut self.unknown_total, other.unknown_total)?;
60        checked_add(&mut self.unknown_rejected, other.unknown_rejected)?;
61        checked_add(&mut self.unknown_accepted, other.unknown_accepted)?;
62        Ok(())
63    }
64}
65
66#[derive(Clone, Debug, PartialEq)]
67pub struct CandidateEvaluation {
68    pub config: ModelConfig,
69    pub tuning: AggregateMetrics,
70}
71
72#[derive(Clone, Debug, PartialEq)]
73pub struct EvaluationResult {
74    pub selected_config: ModelConfig,
75    pub candidate_evaluations: Vec<CandidateEvaluation>,
76    pub held_out: AggregateMetrics,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum EvalError {
81    EmptyCandidates,
82    EmptyTuningFolds,
83    DuplicateCandidate,
84    DuplicateTuningFold,
85    InvalidCandidate,
86    InvalidFold,
87    FoldOutsideOuterTraining,
88    GroupLeakage,
89    OpenSetViolation,
90    CountOverflow,
91    Model(ModelError),
92}
93
94impl fmt::Display for EvalError {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        let text = match self {
97            Self::EmptyCandidates => "evaluation has no candidate configurations",
98            Self::EmptyTuningFolds => "evaluation has no tuning folds",
99            Self::DuplicateCandidate => "evaluation contains a duplicate candidate configuration",
100            Self::DuplicateTuningFold => "evaluation contains a duplicate tuning fold",
101            Self::InvalidCandidate => "candidate configuration is invalid",
102            Self::InvalidFold => "evaluation fold is malformed",
103            Self::FoldOutsideOuterTraining => {
104                "tuning fold contains a row outside held-out training"
105            }
106            Self::GroupLeakage => "a leakage group crosses training and test",
107            Self::OpenSetViolation => "fold violates known-speaker or pseudo-unknown boundaries",
108            Self::CountOverflow => "aggregate evaluation count overflowed",
109            Self::Model(error) => return error.fmt(formatter),
110        };
111        formatter.write_str(text)
112    }
113}
114
115impl Error for EvalError {
116    fn source(&self) -> Option<&(dyn Error + 'static)> {
117        match self {
118            Self::Model(error) => Some(error),
119            _ => None,
120        }
121    }
122}
123
124impl From<ModelError> for EvalError {
125    fn from(error: ModelError) -> Self {
126        Self::Model(error)
127    }
128}
129
130pub fn evaluate(plan: EvaluationPlan<'_>) -> Result<EvaluationResult, EvalError> {
131    if plan.candidates.is_empty() {
132        return Err(EvalError::EmptyCandidates);
133    }
134    if plan.tuning_folds.is_empty() {
135        return Err(EvalError::EmptyTuningFolds);
136    }
137
138    let mut candidates = plan.candidates.to_vec();
139    for candidate in &candidates {
140        validate_candidate_shape(candidate)?;
141    }
142    candidates.sort_by(compare_configs);
143    if candidates
144        .windows(2)
145        .any(|pair| compare_configs(&pair[0], &pair[1]).is_eq())
146    {
147        return Err(EvalError::DuplicateCandidate);
148    }
149
150    let rows = active_rows(plan.dataset);
151    let held_out = canonical_fold(plan.held_out_fold);
152    let complete_universe = vec![true; rows.len()];
153    validate_fold(rows, &held_out, &complete_universe, false)?;
154
155    let mut outer_training = vec![false; rows.len()];
156    for &index in &held_out.train_indices {
157        outer_training[index] = true;
158    }
159
160    let mut tuning_folds = plan
161        .tuning_folds
162        .iter()
163        .map(canonical_fold)
164        .collect::<Vec<_>>();
165    tuning_folds.sort_by(compare_folds);
166    if tuning_folds.windows(2).any(|pair| pair[0] == pair[1]) {
167        return Err(EvalError::DuplicateTuningFold);
168    }
169    for fold in &tuning_folds {
170        validate_fold(rows, fold, &outer_training, true)?;
171    }
172
173    let minimum_training_count = tuning_folds
174        .iter()
175        .map(|fold| fold.train_indices.len())
176        .chain(std::iter::once(held_out.train_indices.len()))
177        .min()
178        .ok_or(EvalError::EmptyTuningFolds)?;
179    if candidates
180        .iter()
181        .any(|candidate| usize::from(candidate.components) > minimum_training_count)
182    {
183        return Err(EvalError::InvalidCandidate);
184    }
185
186    let mut candidate_evaluations = Vec::with_capacity(candidates.len());
187    for config in candidates {
188        let mut tuning = AggregateMetrics::default();
189        for fold in &tuning_folds {
190            tuning.merge(score_fold(rows, fold, &config)?)?;
191        }
192        candidate_evaluations.push(CandidateEvaluation { config, tuning });
193    }
194
195    let mut selected_index = 0;
196    for index in 1..candidate_evaluations.len() {
197        if compare_evaluations(
198            &candidate_evaluations[index],
199            &candidate_evaluations[selected_index],
200        )
201        .is_lt()
202        {
203            selected_index = index;
204        }
205    }
206
207    let selected_config = candidate_evaluations[selected_index].config.clone();
208    let held_out_metrics = score_fold(rows, &held_out, &selected_config)?;
209
210    Ok(EvaluationResult {
211        selected_config,
212        candidate_evaluations,
213        held_out: held_out_metrics,
214    })
215}
216
217fn rate(numerator: usize, denominator: usize) -> f64 {
218    if denominator == 0 {
219        0.0
220    } else {
221        numerator as f64 / denominator as f64
222    }
223}
224
225fn checked_add(value: &mut usize, addition: usize) -> Result<(), EvalError> {
226    *value = value
227        .checked_add(addition)
228        .ok_or(EvalError::CountOverflow)?;
229    Ok(())
230}
231
232fn validate_candidate_shape(config: &ModelConfig) -> Result<(), EvalError> {
233    if config.components == 0
234        || !config.relevance.is_finite()
235        || config.relevance <= 0.0
236        || !config.variance_floor.is_finite()
237        || config.variance_floor <= 0.0
238        || !config.absolute_threshold.is_finite()
239        || !config.margin_threshold.is_finite()
240    {
241        return Err(EvalError::InvalidCandidate);
242    }
243    Ok(())
244}
245
246fn compare_configs(left: &ModelConfig, right: &ModelConfig) -> Ordering {
247    u64::from(left.mask)
248        .cmp(&u64::from(right.mask))
249        .then_with(|| left.components.cmp(&right.components))
250        .then_with(|| left.relevance.total_cmp(&right.relevance))
251        .then_with(|| left.variance_floor.total_cmp(&right.variance_floor))
252        .then_with(|| left.absolute_threshold.total_cmp(&right.absolute_threshold))
253        .then_with(|| left.margin_threshold.total_cmp(&right.margin_threshold))
254}
255
256fn canonical_fold(fold: &OpenSetFold) -> OpenSetFold {
257    let mut canonical = fold.clone();
258    canonical.train_indices.sort_unstable();
259    canonical.known_test_indices.sort_unstable();
260    canonical.unknown_test_indices.sort_unstable();
261    canonical
262}
263
264fn compare_folds(left: &OpenSetFold, right: &OpenSetFold) -> Ordering {
265    left.pseudo_unknown_speaker
266        .cmp(&right.pseudo_unknown_speaker)
267        .then_with(|| left.train_indices.cmp(&right.train_indices))
268        .then_with(|| left.known_test_indices.cmp(&right.known_test_indices))
269        .then_with(|| left.unknown_test_indices.cmp(&right.unknown_test_indices))
270}
271
272fn validate_fold(
273    rows: &[LabeledSample],
274    fold: &OpenSetFold,
275    universe: &[bool],
276    nested: bool,
277) -> Result<(), EvalError> {
278    if universe.len() != rows.len()
279        || fold.train_indices.is_empty()
280        || fold.known_test_indices.is_empty()
281        || fold.unknown_test_indices.is_empty()
282    {
283        return Err(EvalError::InvalidFold);
284    }
285
286    let mut placement = vec![0_u8; rows.len()];
287    for (side, indices) in [
288        (1_u8, fold.train_indices.as_slice()),
289        (2_u8, fold.known_test_indices.as_slice()),
290        (3_u8, fold.unknown_test_indices.as_slice()),
291    ] {
292        for &index in indices {
293            if index >= rows.len() {
294                return Err(EvalError::InvalidFold);
295            }
296            if !universe[index] {
297                return Err(if nested {
298                    EvalError::FoldOutsideOuterTraining
299                } else {
300                    EvalError::InvalidFold
301                });
302            }
303            if placement[index] != 0 {
304                return Err(EvalError::InvalidFold);
305            }
306            placement[index] = side;
307        }
308    }
309
310    if universe
311        .iter()
312        .zip(&placement)
313        .any(|(included, side)| *included && *side == 0)
314    {
315        return Err(EvalError::InvalidFold);
316    }
317
318    let mut group_sides = BTreeMap::<&str, u8>::new();
319    for (index, row) in rows.iter().enumerate() {
320        if !universe[index] {
321            continue;
322        }
323        let side = if placement[index] == 1 { 1 } else { 2 };
324        match group_sides.get(row.group_id.as_ref()) {
325            Some(previous) if *previous != side => {
326                return Err(EvalError::GroupLeakage);
327            }
328            Some(_) => {}
329            None => {
330                group_sides.insert(row.group_id.as_ref(), side);
331            }
332        }
333    }
334
335    let pseudo_unknown = &fold.pseudo_unknown_speaker;
336    if fold
337        .train_indices
338        .iter()
339        .any(|&index| rows[index].speaker_id == *pseudo_unknown)
340        || fold
341            .unknown_test_indices
342            .iter()
343            .any(|&index| rows[index].speaker_id != *pseudo_unknown)
344        || rows.iter().enumerate().any(|(index, row)| {
345            universe[index] && row.speaker_id == *pseudo_unknown && placement[index] != 3
346        })
347    {
348        return Err(EvalError::OpenSetViolation);
349    }
350
351    let enrolled = fold
352        .train_indices
353        .iter()
354        .map(|&index| rows[index].speaker_id.as_ref())
355        .collect::<BTreeSet<_>>();
356    if fold
357        .known_test_indices
358        .iter()
359        .any(|&index| !enrolled.contains(rows[index].speaker_id.as_ref()))
360    {
361        return Err(EvalError::OpenSetViolation);
362    }
363
364    Ok(())
365}
366
367fn score_fold(
368    rows: &[LabeledSample],
369    fold: &OpenSetFold,
370    config: &ModelConfig,
371) -> Result<AggregateMetrics, EvalError> {
372    let training = fold
373        .train_indices
374        .iter()
375        .map(|&index| rows[index].clone())
376        .collect::<Vec<_>>();
377    let cohort_id = training
378        .first()
379        .ok_or(EvalError::InvalidFold)?
380        .cohort_id
381        .clone();
382    let model = fit(FitInput {
383        cohort_id: &cohort_id,
384        samples: &training,
385        config: config.clone(),
386    })?;
387
388    let mut metrics = AggregateMetrics::default();
389    for &index in &fold.known_test_indices {
390        let row = &rows[index];
391        let result = identify(&model, &cohort_id, &row.features)?;
392        checked_add(&mut metrics.known_total, 1)?;
393        match result.decision {
394            Decision::Known { speaker_id } if speaker_id == row.speaker_id => {
395                checked_add(&mut metrics.known_correct, 1)?;
396            }
397            Decision::Known { .. } => {
398                checked_add(&mut metrics.known_misidentified, 1)?;
399            }
400            Decision::Unknown => {
401                checked_add(&mut metrics.known_rejected, 1)?;
402            }
403        }
404    }
405
406    for &index in &fold.unknown_test_indices {
407        let row = &rows[index];
408        let result = identify(&model, &cohort_id, &row.features)?;
409        checked_add(&mut metrics.unknown_total, 1)?;
410        match result.decision {
411            Decision::Unknown => {
412                checked_add(&mut metrics.unknown_rejected, 1)?;
413            }
414            Decision::Known { .. } => {
415                checked_add(&mut metrics.unknown_accepted, 1)?;
416            }
417        }
418    }
419
420    Ok(metrics)
421}
422
423fn compare_evaluations(left: &CandidateEvaluation, right: &CandidateEvaluation) -> Ordering {
424    right
425        .tuning
426        .balanced_open_set_rate()
427        .total_cmp(&left.tuning.balanced_open_set_rate())
428        .then_with(|| {
429            right
430                .tuning
431                .known_identification_rate()
432                .total_cmp(&left.tuning.known_identification_rate())
433        })
434        .then_with(|| {
435            right
436                .tuning
437                .unknown_rejection_rate()
438                .total_cmp(&left.tuning.unknown_rejection_rate())
439        })
440        .then_with(|| {
441            left.tuning
442                .known_misidentified
443                .cmp(&right.tuning.known_misidentified)
444        })
445        .then_with(|| compare_configs(&left.config, &right.config))
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use kcode_speaker_dataset::build;
452    use kcode_speaker_model::ALL_24;
453    use kcode_speaker_types::{
454        FEATURE_COUNT, FeatureMask, FeatureVector, Key, ObjectId, RecordingKind,
455    };
456
457    fn key(value: &str) -> Key {
458        Key::parse(value).unwrap()
459    }
460
461    fn sample(
462        id: &str,
463        speaker: &str,
464        clip: &str,
465        group: &str,
466        first_feature: u8,
467    ) -> LabeledSample {
468        let mut values = [50_u8; FEATURE_COUNT];
469        values[0] = first_feature;
470        LabeledSample {
471            sample_id: key(id),
472            attempt_id: key(&format!("attempt:{id}")),
473            speaker_id: key(speaker),
474            cohort_id: key("cohort"),
475            group_id: key(group),
476            clip_object: ObjectId::parse(clip).unwrap(),
477            primary_language: key("en"),
478            recording_kind: RecordingKind::Meeting,
479            usable_speech_ms: 1_000,
480            recording_quality: 90,
481            features: FeatureVector::new(values).unwrap(),
482        }
483    }
484
485    fn row_index(dataset: &Dataset, id: &str) -> usize {
486        active_rows(dataset)
487            .iter()
488            .position(|row| row.sample_id.as_ref() == id)
489            .unwrap()
490    }
491
492    fn fixture() -> (Dataset, Vec<OpenSetFold>, OpenSetFold) {
493        let dataset = build(
494            vec![
495                sample("d1", "d", "CLIP0007", "g-d-block", 50),
496                sample("c2", "c", "CLIP0005", "g-c-only", 48),
497                sample("b2", "b", "CLIP0004", "g-c-block", 82),
498                sample("a3", "a", "CLIP0007", "g-d-block", 11),
499                sample("c1", "c", "CLIP0004", "g-c-block", 45),
500                sample("a2", "a", "CLIP0002", "g-a-test", 12),
501                sample("b1", "b", "CLIP0003", "g-b-train", 80),
502                sample("a1", "a", "CLIP0001", "g-a-train", 10),
503            ],
504            vec![],
505        )
506        .unwrap();
507
508        let a1 = row_index(&dataset, "a1");
509        let a2 = row_index(&dataset, "a2");
510        let a3 = row_index(&dataset, "a3");
511        let b1 = row_index(&dataset, "b1");
512        let b2 = row_index(&dataset, "b2");
513        let c1 = row_index(&dataset, "c1");
514        let c2 = row_index(&dataset, "c2");
515        let d1 = row_index(&dataset, "d1");
516
517        let held_out = OpenSetFold {
518            pseudo_unknown_speaker: key("d"),
519            train_indices: vec![c2, a1, b2, c1, a2, b1],
520            known_test_indices: vec![a3],
521            unknown_test_indices: vec![d1],
522        };
523        let tune_c = OpenSetFold {
524            pseudo_unknown_speaker: key("c"),
525            train_indices: vec![b1, a1],
526            known_test_indices: vec![b2, a2],
527            unknown_test_indices: vec![c2, c1],
528        };
529        let tune_b = OpenSetFold {
530            pseudo_unknown_speaker: key("b"),
531            train_indices: vec![c2, a1],
532            known_test_indices: vec![c1, a2],
533            unknown_test_indices: vec![b2, b1],
534        };
535
536        (dataset, vec![tune_c, tune_b], held_out)
537    }
538
539    fn config(absolute_threshold: f64, margin_threshold: f64) -> ModelConfig {
540        ModelConfig {
541            mask: FeatureMask::from_bits(1).unwrap(),
542            components: 1,
543            relevance: 2.0,
544            variance_floor: 0.01,
545            absolute_threshold,
546            margin_threshold,
547        }
548    }
549
550    fn good_config() -> ModelConfig {
551        config(0.0, 0.2)
552    }
553
554    #[test]
555    fn deterministic_selection_is_independent_of_input_order() {
556        let (dataset, mut tuning, held_out) = fixture();
557        let always_known = config(-1_000.0, -1_000.0);
558        let good = good_config();
559        let always_unknown = config(1_000.0, -1_000.0);
560        let mut candidates = vec![always_unknown.clone(), good.clone(), always_known.clone()];
561
562        let first = evaluate(EvaluationPlan {
563            dataset: &dataset,
564            tuning_folds: &tuning,
565            held_out_fold: &held_out,
566            candidates: &candidates,
567        })
568        .unwrap();
569
570        tuning.reverse();
571        candidates.reverse();
572        let second = evaluate(EvaluationPlan {
573            dataset: &dataset,
574            tuning_folds: &tuning,
575            held_out_fold: &held_out,
576            candidates: &candidates,
577        })
578        .unwrap();
579
580        assert_eq!(first, second);
581        assert_eq!(first.selected_config, good);
582        assert_eq!(
583            first
584                .candidate_evaluations
585                .iter()
586                .map(|evaluation| evaluation.config.clone())
587                .collect::<Vec<_>>(),
588            vec![always_known, good.clone(), always_unknown]
589        );
590
591        let selected = first
592            .candidate_evaluations
593            .iter()
594            .find(|evaluation| evaluation.config == good)
595            .unwrap();
596        assert_eq!(selected.tuning.known_total, 4);
597        assert_eq!(selected.tuning.known_correct, 4);
598        assert_eq!(selected.tuning.unknown_total, 4);
599        assert_eq!(selected.tuning.unknown_rejected, 2);
600        assert_eq!(first.held_out.known_total, 1);
601        assert_eq!(first.held_out.unknown_total, 1);
602    }
603
604    #[test]
605    fn exact_metric_ties_use_canonical_configuration_order() {
606        let (dataset, tuning, held_out) = fixture();
607        let canonical = config(-1_000.0, -1_000.0);
608        let later = config(-999.0, -1_000.0);
609        let candidates = [later, canonical.clone()];
610
611        let result = evaluate(EvaluationPlan {
612            dataset: &dataset,
613            tuning_folds: &tuning,
614            held_out_fold: &held_out,
615            candidates: &candidates,
616        })
617        .unwrap();
618
619        assert_eq!(result.selected_config, canonical);
620        assert_eq!(
621            result.candidate_evaluations[0].tuning,
622            result.candidate_evaluations[1].tuning
623        );
624    }
625
626    #[test]
627    fn unknown_rows_are_never_enrolled_and_are_counted_separately() {
628        let (dataset, tuning, held_out) = fixture();
629        let candidates = [config(1_000.0, -1_000.0)];
630
631        let result = evaluate(EvaluationPlan {
632            dataset: &dataset,
633            tuning_folds: &tuning,
634            held_out_fold: &held_out,
635            candidates: &candidates,
636        })
637        .unwrap();
638
639        let metrics = result.candidate_evaluations[0].tuning;
640        assert_eq!(metrics.known_total, 4);
641        assert_eq!(metrics.known_rejected, 4);
642        assert_eq!(metrics.unknown_total, 4);
643        assert_eq!(metrics.unknown_rejected, 4);
644        assert_eq!(metrics.unknown_accepted, 0);
645        assert_eq!(result.held_out.known_rejected, 1);
646        assert_eq!(result.held_out.unknown_rejected, 1);
647    }
648
649    fn assert_group_safe(dataset: &Dataset, fold: &OpenSetFold) {
650        let train_groups = fold
651            .train_indices
652            .iter()
653            .map(|&index| active_rows(dataset)[index].group_id.as_ref())
654            .collect::<BTreeSet<_>>();
655        let test_groups = fold
656            .known_test_indices
657            .iter()
658            .chain(&fold.unknown_test_indices)
659            .map(|&index| active_rows(dataset)[index].group_id.as_ref())
660            .collect::<BTreeSet<_>>();
661        assert!(train_groups.is_disjoint(&test_groups));
662    }
663
664    #[test]
665    fn leakage_groups_never_cross_train_and_test() {
666        let (dataset, tuning, held_out) = fixture();
667        assert_group_safe(&dataset, &held_out);
668        for fold in &tuning {
669            assert_group_safe(&dataset, fold);
670        }
671
672        let mut leaking = tuning
673            .iter()
674            .find(|fold| fold.pseudo_unknown_speaker.as_ref() == "c")
675            .unwrap()
676            .clone();
677        let c1 = row_index(&dataset, "c1");
678        leaking.unknown_test_indices.retain(|index| *index != c1);
679        leaking.train_indices.push(c1);
680        let candidates = [good_config()];
681        let folds = [leaking];
682
683        assert_eq!(
684            evaluate(EvaluationPlan {
685                dataset: &dataset,
686                tuning_folds: &folds,
687                held_out_fold: &held_out,
688                candidates: &candidates,
689            })
690            .unwrap_err(),
691            EvalError::GroupLeakage
692        );
693    }
694
695    #[test]
696    fn malformed_open_set_and_nested_boundaries_fail_closed() {
697        let (dataset, tuning, held_out) = fixture();
698        let tune_c = tuning
699            .iter()
700            .find(|fold| fold.pseudo_unknown_speaker.as_ref() == "c")
701            .unwrap();
702        let candidates = [good_config()];
703
704        let mut enrolled_unknown = tune_c.clone();
705        let c2 = row_index(&dataset, "c2");
706        let a1 = row_index(&dataset, "a1");
707        enrolled_unknown
708            .unknown_test_indices
709            .retain(|index| *index != c2);
710        enrolled_unknown.train_indices.push(c2);
711        enrolled_unknown.train_indices.retain(|index| *index != a1);
712        enrolled_unknown.unknown_test_indices.push(a1);
713        let folds = [enrolled_unknown];
714        assert_eq!(
715            evaluate(EvaluationPlan {
716                dataset: &dataset,
717                tuning_folds: &folds,
718                held_out_fold: &held_out,
719                candidates: &candidates,
720            })
721            .unwrap_err(),
722            EvalError::OpenSetViolation
723        );
724
725        let mut outside = tune_c.clone();
726        let a3 = row_index(&dataset, "a3");
727        outside.train_indices.retain(|index| *index != a1);
728        outside.train_indices.push(a3);
729        let folds = [outside];
730        assert_eq!(
731            evaluate(EvaluationPlan {
732                dataset: &dataset,
733                tuning_folds: &folds,
734                held_out_fold: &held_out,
735                candidates: &candidates,
736            })
737            .unwrap_err(),
738            EvalError::FoldOutsideOuterTraining
739        );
740    }
741
742    #[test]
743    fn malformed_plans_and_configurations_are_typed() {
744        let (dataset, tuning, held_out) = fixture();
745        let valid = good_config();
746
747        assert_eq!(
748            evaluate(EvaluationPlan {
749                dataset: &dataset,
750                tuning_folds: &tuning,
751                held_out_fold: &held_out,
752                candidates: &[],
753            })
754            .unwrap_err(),
755            EvalError::EmptyCandidates
756        );
757        assert_eq!(
758            evaluate(EvaluationPlan {
759                dataset: &dataset,
760                tuning_folds: &[],
761                held_out_fold: &held_out,
762                candidates: std::slice::from_ref(&valid),
763            })
764            .unwrap_err(),
765            EvalError::EmptyTuningFolds
766        );
767
768        let duplicates = [valid.clone(), valid.clone()];
769        assert_eq!(
770            evaluate(EvaluationPlan {
771                dataset: &dataset,
772                tuning_folds: &tuning,
773                held_out_fold: &held_out,
774                candidates: &duplicates,
775            })
776            .unwrap_err(),
777            EvalError::DuplicateCandidate
778        );
779
780        let duplicate_folds = [tuning[0].clone(), tuning[0].clone()];
781        assert_eq!(
782            evaluate(EvaluationPlan {
783                dataset: &dataset,
784                tuning_folds: &duplicate_folds,
785                held_out_fold: &held_out,
786                candidates: std::slice::from_ref(&valid),
787            })
788            .unwrap_err(),
789            EvalError::DuplicateTuningFold
790        );
791
792        let mut nonfinite = valid.clone();
793        nonfinite.absolute_threshold = f64::NAN;
794        assert_eq!(
795            evaluate(EvaluationPlan {
796                dataset: &dataset,
797                tuning_folds: &tuning,
798                held_out_fold: &held_out,
799                candidates: &[nonfinite],
800            })
801            .unwrap_err(),
802            EvalError::InvalidCandidate
803        );
804
805        let mut too_many_components = valid.clone();
806        too_many_components.components = 3;
807        assert_eq!(
808            evaluate(EvaluationPlan {
809                dataset: &dataset,
810                tuning_folds: &tuning,
811                held_out_fold: &held_out,
812                candidates: &[too_many_components],
813            })
814            .unwrap_err(),
815            EvalError::InvalidCandidate
816        );
817
818        let mut incomplete = held_out.clone();
819        incomplete.train_indices.pop();
820        assert_eq!(
821            evaluate(EvaluationPlan {
822                dataset: &dataset,
823                tuning_folds: &tuning,
824                held_out_fold: &incomplete,
825                candidates: std::slice::from_ref(&valid),
826            })
827            .unwrap_err(),
828            EvalError::InvalidFold
829        );
830    }
831
832    #[test]
833    fn dependency_contract_is_frozen_at_twenty_four_features() {
834        assert_eq!(FEATURE_COUNT, 24);
835        assert_eq!(u64::from(ALL_24), (1_u64 << FEATURE_COUNT) - 1);
836        assert!(FeatureVector::new([100; FEATURE_COUNT]).is_ok());
837    }
838}