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}