1#![forbid(unsafe_code)]
2
3use kcode_diag_gmm::{
4 DiagGmm, FitConfig as GmmFitConfig, fit as fit_gmm, log_likelihood, means, responsibilities,
5 with_means,
6};
7use kcode_speaker_types::{
8 FEATURE_COUNT, FeatureMask, FeatureVector, Key, LabeledSample, RecordingKind,
9};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::fmt;
13
14const SNAPSHOT_VERSION: u8 = 2;
15const WIRE_FEATURE_COUNT: u8 = FEATURE_COUNT as u8;
16const MAX_ITERATIONS: u16 = 200;
17const RELATIVE_TOLERANCE: f64 = 1e-8;
18
19pub const ALL_24: FeatureMask = match FeatureMask::from_bits((1_u64 << FEATURE_COUNT) - 1) {
20 Ok(mask) => mask,
21 Err(_) => panic!("FEATURE_COUNT must define a valid nonempty feature mask"),
22};
23
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct ModelConfig {
27 pub mask: FeatureMask,
28 pub components: u8,
29 pub relevance: f64,
30 pub variance_floor: f64,
31 pub absolute_threshold: f64,
32 pub margin_threshold: f64,
33}
34
35pub struct FitInput<'a> {
36 pub cohort_id: &'a Key,
37 pub samples: &'a [LabeledSample],
38 pub config: ModelConfig,
39}
40
41#[derive(Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ModelSnapshot {
44 version: u8,
45 feature_count: u8,
46 cohort_id: Key,
47 config: ModelConfig,
48 selected_indices: Vec<u8>,
49 normalizer_mean: Vec<f64>,
50 normalizer_std: Vec<f64>,
51 ubm: DiagGmm,
52 speakers: Vec<SpeakerModel>,
53 training_sample_count: usize,
54 manifest_sha256: [u8; 32],
55}
56
57#[derive(Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct SpeakerModel {
60 speaker_id: Key,
61 sample_count: usize,
62 occupancy: Vec<f64>,
63 first_moments: Vec<Vec<f64>>,
64 adapted_means: Vec<Vec<f64>>,
65}
66
67#[derive(Clone, Debug, PartialEq)]
68pub struct CandidateScore {
69 pub speaker_id: Key,
70 pub llr: f64,
71}
72
73#[derive(Clone, Debug, PartialEq)]
74pub enum Decision {
75 Known { speaker_id: Key },
76 Unknown,
77}
78
79#[derive(Clone, Debug, PartialEq)]
80pub struct Identification {
81 pub decision: Decision,
82 pub best: CandidateScore,
83 pub runner_up: Option<CandidateScore>,
84 pub absolute_pass: bool,
85 pub margin_pass: bool,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub enum ModelError {
90 InvalidConfig,
91 InvalidSamples,
92 CohortMismatch,
93 DuplicateSampleId,
94 ZeroVariance,
95 Nonconverged,
96 Numerical,
97 Gmm,
98 Serialization,
99 MalformedArtifact,
100}
101
102impl fmt::Display for ModelError {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 let text = match self {
105 Self::InvalidConfig => "invalid model configuration",
106 Self::InvalidSamples => "invalid training samples",
107 Self::CohortMismatch => "cohort does not match",
108 Self::DuplicateSampleId => "duplicate training sample ID",
109 Self::ZeroVariance => "selected training feature has zero variance",
110 Self::Nonconverged => "diagonal GMM did not converge",
111 Self::Numerical => "nonfinite model computation",
112 Self::Gmm => "diagonal GMM operation failed",
113 Self::Serialization => "snapshot serialization failed",
114 Self::MalformedArtifact => "malformed or incompatible snapshot artifact",
115 };
116 formatter.write_str(text)
117 }
118}
119
120impl std::error::Error for ModelError {}
121
122pub fn fit(input: FitInput<'_>) -> Result<ModelSnapshot, ModelError> {
123 if input.samples.is_empty() {
124 return Err(ModelError::InvalidSamples);
125 }
126 validate_config(&input.config, input.samples.len())?;
127
128 let mut samples = input.samples.iter().collect::<Vec<_>>();
129 samples.sort_by(|left, right| left.sample_id.cmp(&right.sample_id));
130
131 if samples
132 .iter()
133 .any(|sample| sample.cohort_id != *input.cohort_id)
134 {
135 return Err(ModelError::CohortMismatch);
136 }
137 if samples
138 .windows(2)
139 .any(|pair| pair[0].sample_id == pair[1].sample_id)
140 {
141 return Err(ModelError::DuplicateSampleId);
142 }
143
144 let selected = mask_indices(input.config.mask);
145 let raw_rows = samples
146 .iter()
147 .map(|sample| {
148 selected
149 .iter()
150 .map(|index| f64::from(sample.features.as_ref()[usize::from(*index)]))
151 .collect::<Vec<_>>()
152 })
153 .collect::<Vec<_>>();
154 let (normalizer_mean, normalizer_std) = fit_normalizer(&raw_rows)?;
155 let rows = raw_rows
156 .iter()
157 .map(|row| normalize(row, &normalizer_mean, &normalizer_std))
158 .collect::<Result<Vec<_>, _>>()?;
159
160 let ubm = fit_ubm_with_limits(
161 &rows,
162 input.config.components,
163 input.config.variance_floor,
164 MAX_ITERATIONS,
165 RELATIVE_TOLERANCE,
166 )?;
167 let ubm_means = means(&ubm);
168
169 let mut speaker_ids = samples
170 .iter()
171 .map(|sample| sample.speaker_id.clone())
172 .collect::<Vec<_>>();
173 speaker_ids.sort();
174 speaker_ids.dedup();
175 if speaker_ids.is_empty() {
176 return Err(ModelError::InvalidSamples);
177 }
178
179 let components = usize::from(input.config.components);
180 let dimension = selected.len();
181 let mut speakers = speaker_ids
182 .into_iter()
183 .map(|speaker_id| SpeakerModel {
184 speaker_id,
185 sample_count: 0,
186 occupancy: vec![0.0; components],
187 first_moments: vec![vec![0.0; dimension]; components],
188 adapted_means: Vec::new(),
189 })
190 .collect::<Vec<_>>();
191
192 for (sample, row) in samples.iter().zip(&rows) {
193 let speaker_index = speakers
194 .binary_search_by(|speaker| speaker.speaker_id.cmp(&sample.speaker_id))
195 .map_err(|_| ModelError::InvalidSamples)?;
196 let gamma = responsibilities(&ubm, row).map_err(|_| ModelError::Gmm)?;
197 if gamma.len() != components {
198 return Err(ModelError::Numerical);
199 }
200
201 let speaker = &mut speakers[speaker_index];
202 speaker.sample_count += 1;
203 for (component, responsibility) in gamma.iter().copied().enumerate() {
204 if !responsibility.is_finite() || responsibility < 0.0 {
205 return Err(ModelError::Numerical);
206 }
207
208 speaker.occupancy[component] += responsibility;
209 if !speaker.occupancy[component].is_finite() {
210 return Err(ModelError::Numerical);
211 }
212 for (sum, value) in speaker.first_moments[component].iter_mut().zip(row) {
213 *sum += responsibility * value;
214 if !sum.is_finite() {
215 return Err(ModelError::Numerical);
216 }
217 }
218 }
219 }
220
221 for speaker in &mut speakers {
222 speaker.adapted_means = map_means(
223 ubm_means,
224 &speaker.occupancy,
225 &speaker.first_moments,
226 input.config.relevance,
227 )?;
228 }
229
230 let manifest_sha256 = manifest(&samples);
231 let model = ModelSnapshot {
232 version: SNAPSHOT_VERSION,
233 feature_count: WIRE_FEATURE_COUNT,
234 cohort_id: input.cohort_id.clone(),
235 config: input.config,
236 selected_indices: selected,
237 normalizer_mean,
238 normalizer_std,
239 ubm,
240 speakers,
241 training_sample_count: samples.len(),
242 manifest_sha256,
243 };
244 validate_snapshot(&model)?;
245 Ok(model)
246}
247
248pub fn identify(
249 model: &ModelSnapshot,
250 cohort_id: &Key,
251 features: &FeatureVector,
252) -> Result<Identification, ModelError> {
253 validate_snapshot(model)?;
254 if cohort_id != &model.cohort_id {
255 return Err(ModelError::CohortMismatch);
256 }
257
258 let raw = model
259 .selected_indices
260 .iter()
261 .map(|index| f64::from(features.as_ref()[usize::from(*index)]))
262 .collect::<Vec<_>>();
263 let row = normalize(&raw, &model.normalizer_mean, &model.normalizer_std)?;
264 let ubm_score = log_likelihood(&model.ubm, &row).map_err(|_| ModelError::Gmm)?;
265 if !ubm_score.is_finite() {
266 return Err(ModelError::Numerical);
267 }
268
269 let mut scores = Vec::with_capacity(model.speakers.len());
270 for speaker in &model.speakers {
271 let adapted =
272 with_means(&model.ubm, speaker.adapted_means.clone()).map_err(|_| ModelError::Gmm)?;
273 let score = log_likelihood(&adapted, &row).map_err(|_| ModelError::Gmm)? - ubm_score;
274 if !score.is_finite() {
275 return Err(ModelError::Numerical);
276 }
277 scores.push(CandidateScore {
278 speaker_id: speaker.speaker_id.clone(),
279 llr: score,
280 });
281 }
282 scores.sort_by(|left, right| {
283 right
284 .llr
285 .total_cmp(&left.llr)
286 .then_with(|| left.speaker_id.cmp(&right.speaker_id))
287 });
288
289 let mut ranked = scores.into_iter();
290 let best = ranked.next().ok_or(ModelError::MalformedArtifact)?;
291 let runner_up = ranked.next();
292 let absolute_pass = best.llr >= model.config.absolute_threshold;
293 let margin_pass = runner_up
294 .as_ref()
295 .is_none_or(|runner| best.llr - runner.llr >= model.config.margin_threshold);
296 let decision = if absolute_pass && margin_pass {
297 Decision::Known {
298 speaker_id: best.speaker_id.clone(),
299 }
300 } else {
301 Decision::Unknown
302 };
303
304 Ok(Identification {
305 decision,
306 best,
307 runner_up,
308 absolute_pass,
309 margin_pass,
310 })
311}
312
313pub fn encode(model: &ModelSnapshot) -> Result<Vec<u8>, ModelError> {
314 validate_snapshot(model)?;
315 serde_json::to_vec(model).map_err(|_| ModelError::Serialization)
316}
317
318pub fn decode(bytes: &[u8]) -> Result<ModelSnapshot, ModelError> {
319 let model = serde_json::from_slice::<ModelSnapshot>(bytes)
320 .map_err(|_| ModelError::MalformedArtifact)?;
321 validate_snapshot(&model).map_err(|_| ModelError::MalformedArtifact)?;
322 Ok(model)
323}
324
325fn validate_config(config: &ModelConfig, sample_count: usize) -> Result<(), ModelError> {
326 if config.components == 0
327 || usize::from(config.components) > sample_count
328 || !config.relevance.is_finite()
329 || config.relevance <= 0.0
330 || !config.variance_floor.is_finite()
331 || config.variance_floor <= 0.0
332 || !config.absolute_threshold.is_finite()
333 || !config.margin_threshold.is_finite()
334 {
335 return Err(ModelError::InvalidConfig);
336 }
337 Ok(())
338}
339
340fn mask_indices(mask: FeatureMask) -> Vec<u8> {
341 let bits = u64::from(mask);
342 (0..FEATURE_COUNT)
343 .filter(|index| bits & (1_u64 << index) != 0)
344 .map(|index| index as u8)
345 .collect()
346}
347
348fn fit_normalizer(rows: &[Vec<f64>]) -> Result<(Vec<f64>, Vec<f64>), ModelError> {
349 let dimension = rows.first().map_or(0, Vec::len);
350 if rows.is_empty() || dimension == 0 || rows.iter().any(|row| row.len() != dimension) {
351 return Err(ModelError::InvalidSamples);
352 }
353
354 let count = rows.len() as f64;
355 let mut mean = vec![0.0; dimension];
356 for row in rows {
357 for (sum, value) in mean.iter_mut().zip(row) {
358 *sum += value;
359 }
360 }
361 for value in &mut mean {
362 *value /= count;
363 }
364 if mean.iter().any(|value| !value.is_finite()) {
365 return Err(ModelError::Numerical);
366 }
367
368 let mut standard_deviation = vec![0.0; dimension];
369 for row in rows {
370 for (sum, (value, center)) in standard_deviation.iter_mut().zip(row.iter().zip(&mean)) {
371 let delta = value - center;
372 *sum += delta * delta;
373 }
374 }
375 for value in &mut standard_deviation {
376 *value = (*value / count).sqrt();
377 if !value.is_finite() || *value <= 0.0 {
378 return Err(ModelError::ZeroVariance);
379 }
380 }
381
382 Ok((mean, standard_deviation))
383}
384
385fn normalize(
386 row: &[f64],
387 mean: &[f64],
388 standard_deviation: &[f64],
389) -> Result<Vec<f64>, ModelError> {
390 if row.len() != mean.len() || mean.len() != standard_deviation.len() {
391 return Err(ModelError::Numerical);
392 }
393
394 row.iter()
395 .zip(mean.iter().zip(standard_deviation))
396 .map(|(value, (center, scale))| {
397 let normalized = (value - center) / scale;
398 normalized
399 .is_finite()
400 .then_some(normalized)
401 .ok_or(ModelError::Numerical)
402 })
403 .collect()
404}
405
406fn fit_ubm_with_limits(
407 rows: &[Vec<f64>],
408 components: u8,
409 variance_floor: f64,
410 max_iterations: u16,
411 relative_tolerance: f64,
412) -> Result<DiagGmm, ModelError> {
413 let result = fit_gmm(
414 rows,
415 GmmFitConfig {
416 components,
417 max_iterations,
418 relative_tolerance,
419 variance_floor,
420 },
421 )
422 .map_err(|_| ModelError::Gmm)?;
423 if !result.converged {
424 return Err(ModelError::Nonconverged);
425 }
426 Ok(result.model)
427}
428
429fn map_means(
430 ubm_means: &[Vec<f64>],
431 occupancy: &[f64],
432 first_moments: &[Vec<f64>],
433 relevance: f64,
434) -> Result<Vec<Vec<f64>>, ModelError> {
435 if occupancy.len() != ubm_means.len()
436 || first_moments.len() != ubm_means.len()
437 || !relevance.is_finite()
438 || relevance <= 0.0
439 {
440 return Err(ModelError::Numerical);
441 }
442
443 let mut adapted = Vec::with_capacity(ubm_means.len());
444 for ((background, mass), first) in ubm_means
445 .iter()
446 .zip(occupancy.iter().copied())
447 .zip(first_moments)
448 {
449 if !mass.is_finite()
450 || mass < 0.0
451 || first.len() != background.len()
452 || first.iter().any(|value| !value.is_finite())
453 || mass == 0.0 && first.iter().any(|value| *value != 0.0)
454 {
455 return Err(ModelError::Numerical);
456 }
457
458 let mut component = background.clone();
459 if mass > 0.0 {
460 let alpha = mass / (mass + relevance);
461 if !alpha.is_finite() {
462 return Err(ModelError::Numerical);
463 }
464 for ((value, first_value), background_value) in
465 component.iter_mut().zip(first).zip(background)
466 {
467 let empirical = first_value / mass;
468 *value = alpha * empirical + (1.0 - alpha) * background_value;
469 if !value.is_finite() {
470 return Err(ModelError::Numerical);
471 }
472 }
473 }
474 adapted.push(component);
475 }
476 Ok(adapted)
477}
478
479fn manifest(samples: &[&LabeledSample]) -> [u8; 32] {
480 let mut ordered = samples.to_vec();
481 ordered.sort_by(|left, right| left.sample_id.cmp(&right.sample_id));
482
483 let mut digest = Sha256::new();
484 manifest_field(&mut digest, 0, b"kcode-speaker-model/training-manifest/2");
485 digest.update((ordered.len() as u64).to_be_bytes());
486
487 for sample in ordered {
488 digest.update([0xfe]);
489 manifest_field(&mut digest, 1, sample.sample_id.as_ref().as_bytes());
490 manifest_field(&mut digest, 2, sample.attempt_id.as_ref().as_bytes());
491 manifest_field(&mut digest, 3, sample.speaker_id.as_ref().as_bytes());
492 manifest_field(&mut digest, 4, sample.cohort_id.as_ref().as_bytes());
493 manifest_field(&mut digest, 5, sample.group_id.as_ref().as_bytes());
494 manifest_field(&mut digest, 6, sample.clip_object.as_ref().as_bytes());
495 manifest_field(&mut digest, 7, sample.primary_language.as_ref().as_bytes());
496 manifest_field(
497 &mut digest,
498 8,
499 &[recording_kind_byte(&sample.recording_kind)],
500 );
501 manifest_field(&mut digest, 9, &sample.usable_speech_ms.to_be_bytes());
502 manifest_field(&mut digest, 10, &[sample.recording_quality]);
503 manifest_field(&mut digest, 11, sample.features.as_ref());
504 digest.update([0xff]);
505 }
506
507 digest.finalize().into()
508}
509
510fn manifest_field(digest: &mut Sha256, tag: u8, bytes: &[u8]) {
511 digest.update([tag]);
512 digest.update((bytes.len() as u64).to_be_bytes());
513 digest.update(bytes);
514}
515
516fn recording_kind_byte(kind: &RecordingKind) -> u8 {
517 match kind {
518 RecordingKind::VoiceNote => 0,
519 RecordingKind::Meeting => 1,
520 RecordingKind::Call => 2,
521 RecordingKind::Other => 3,
522 }
523}
524
525fn validate_snapshot(model: &ModelSnapshot) -> Result<(), ModelError> {
526 if model.version != SNAPSHOT_VERSION
527 || model.feature_count != WIRE_FEATURE_COUNT
528 || model.training_sample_count == 0
529 {
530 return Err(ModelError::MalformedArtifact);
531 }
532 validate_config(&model.config, model.training_sample_count)
533 .map_err(|_| ModelError::MalformedArtifact)?;
534
535 let expected_indices = mask_indices(model.config.mask);
536 if model.selected_indices != expected_indices
537 || model.normalizer_mean.len() != expected_indices.len()
538 || model.normalizer_std.len() != expected_indices.len()
539 || model.normalizer_mean.iter().any(|value| !value.is_finite())
540 || model
541 .normalizer_std
542 .iter()
543 .any(|value| !value.is_finite() || *value <= 0.0)
544 {
545 return Err(ModelError::MalformedArtifact);
546 }
547
548 let dimension = expected_indices.len();
549 let components = usize::from(model.config.components);
550 let ubm_means = means(&model.ubm);
551 let zero_row = vec![0.0; dimension];
552 if ubm_means.len() != components
553 || ubm_means
554 .iter()
555 .any(|row| row.len() != dimension || row.iter().any(|value| !value.is_finite()))
556 || log_likelihood(&model.ubm, &zero_row).is_err()
557 || model.speakers.is_empty()
558 || model.speakers.len() > model.training_sample_count
559 {
560 return Err(ModelError::MalformedArtifact);
561 }
562
563 let mut counted_samples = 0_usize;
564 for (index, speaker) in model.speakers.iter().enumerate() {
565 let out_of_order = index > 0
566 && model.speakers[index - 1].speaker_id.as_ref() >= speaker.speaker_id.as_ref();
567 if speaker.sample_count == 0
568 || out_of_order
569 || speaker.occupancy.len() != components
570 || speaker.first_moments.len() != components
571 || speaker.adapted_means.len() != components
572 {
573 return Err(ModelError::MalformedArtifact);
574 }
575
576 counted_samples = counted_samples
577 .checked_add(speaker.sample_count)
578 .ok_or(ModelError::MalformedArtifact)?;
579
580 let occupancy_sum = speaker.occupancy.iter().sum::<f64>();
581 let sample_count = speaker.sample_count as f64;
582 let tolerance = 1e-8 * sample_count.max(1.0);
583 if !occupancy_sum.is_finite() || (occupancy_sum - sample_count).abs() > tolerance {
584 return Err(ModelError::MalformedArtifact);
585 }
586
587 let expected = map_means(
588 ubm_means,
589 &speaker.occupancy,
590 &speaker.first_moments,
591 model.config.relevance,
592 )
593 .map_err(|_| ModelError::MalformedArtifact)?;
594
595 for (((occupancy, first), actual), expected_component) in speaker
596 .occupancy
597 .iter()
598 .zip(&speaker.first_moments)
599 .zip(&speaker.adapted_means)
600 .zip(&expected)
601 {
602 if !occupancy.is_finite()
603 || *occupancy < 0.0
604 || *occupancy > sample_count + tolerance
605 || first.len() != dimension
606 || actual.len() != dimension
607 || expected_component.len() != dimension
608 || first.iter().any(|value| !value.is_finite())
609 || actual
610 .iter()
611 .zip(expected_component)
612 .any(|(value, expected_value)| {
613 !value.is_finite() || value.to_bits() != expected_value.to_bits()
614 })
615 {
616 return Err(ModelError::MalformedArtifact);
617 }
618 }
619
620 let adapted = with_means(&model.ubm, speaker.adapted_means.clone())
621 .map_err(|_| ModelError::MalformedArtifact)?;
622 if log_likelihood(&adapted, &zero_row).is_err() {
623 return Err(ModelError::MalformedArtifact);
624 }
625 }
626
627 if counted_samples != model.training_sample_count {
628 return Err(ModelError::MalformedArtifact);
629 }
630 Ok(())
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use kcode_speaker_types::ObjectId;
637 use serde_json::{Value, json};
638
639 fn key(value: &str) -> Key {
640 Key::parse(value).unwrap()
641 }
642
643 fn vector(seed: u8) -> FeatureVector {
644 let mut values = [0_u8; FEATURE_COUNT];
645 for (index, value) in values.iter_mut().enumerate() {
646 *value = u8::try_from((usize::from(seed) + index * 3) % 90).unwrap();
647 }
648 FeatureVector::new(values).unwrap()
649 }
650
651 fn sample(id: &str, cohort: &str, speaker: &str, seed: u8) -> LabeledSample {
652 LabeledSample {
653 sample_id: key(id),
654 attempt_id: key(&format!("attempt:{id}")),
655 speaker_id: key(speaker),
656 cohort_id: key(cohort),
657 group_id: key("group:fixture"),
658 clip_object: ObjectId::parse(&format!("C{seed:07}")).unwrap(),
659 primary_language: key("eng"),
660 recording_kind: RecordingKind::VoiceNote,
661 usable_speech_ms: 1_000 + u32::from(seed),
662 recording_quality: 80 + seed % 20,
663 features: vector(seed),
664 }
665 }
666
667 fn config(mask: FeatureMask) -> ModelConfig {
668 ModelConfig {
669 mask,
670 components: 1,
671 relevance: 2.0,
672 variance_floor: 0.01,
673 absolute_threshold: -1e9,
674 margin_threshold: -1e9,
675 }
676 }
677
678 fn training() -> Vec<LabeledSample> {
679 vec![
680 sample("sample:1", "cohort", "alice", 10),
681 sample("sample:2", "cohort", "alice", 14),
682 sample("sample:3", "cohort", "bob", 60),
683 sample("sample:4", "cohort", "bob", 66),
684 ]
685 }
686
687 fn assert_malformed(value: &Value) {
688 let bytes = serde_json::to_vec(value).unwrap();
689 assert_eq!(decode(&bytes).err(), Some(ModelError::MalformedArtifact));
690 }
691
692 #[test]
693 fn all_24_covers_exactly_the_shared_feature_contract() {
694 assert_eq!(FEATURE_COUNT, 24);
695 assert_eq!(u64::from(ALL_24), (1_u64 << FEATURE_COUNT) - 1);
696 }
697
698 #[test]
699 fn hand_computed_map_and_unoccupied_component() {
700 let ubm = vec![vec![1.0, -1.0], vec![7.0, 8.0]];
701 let occupancy = vec![2.0, 0.0];
702 let first_moments = vec![vec![6.0, 2.0], vec![0.0, 0.0]];
703 let adapted = map_means(&ubm, &occupancy, &first_moments, 2.0).unwrap();
704
705 assert_eq!(adapted[0], vec![2.0, 0.0]);
706 assert_eq!(adapted[1], ubm[1]);
707 }
708
709 #[test]
710 fn k1_llr_is_shared_covariance_mahalanobis_difference() {
711 let samples = training();
712 let cohort = key("cohort");
713 let mask = FeatureMask::from_bits(1).unwrap();
714 let model = fit(FitInput {
715 cohort_id: &cohort,
716 samples: &samples,
717 config: config(mask),
718 })
719 .unwrap();
720
721 let probe = vector(12);
722 let result = identify(&model, &cohort, &probe).unwrap();
723 let speaker = model
724 .speakers
725 .iter()
726 .find(|speaker| speaker.speaker_id == result.best.speaker_id)
727 .unwrap();
728 let x = (f64::from(probe.as_ref()[0]) - model.normalizer_mean[0]) / model.normalizer_std[0];
729 let ubm_mean = means(&model.ubm)[0][0];
730 let value = serde_json::to_value(&model.ubm).unwrap();
731 let variance = value["variances"][0][0].as_f64().unwrap();
732 let adapted_mean = speaker.adapted_means[0][0];
733 let expected =
734 -0.5 * ((x - adapted_mean).powi(2) / variance - (x - ubm_mean).powi(2) / variance);
735
736 assert!((result.best.llr - expected).abs() < 1e-12);
737 }
738
739 #[test]
740 fn threshold_equality_and_one_speaker_rules() {
741 let samples = training();
742 let cohort = key("cohort");
743 let mut model = fit(FitInput {
744 cohort_id: &cohort,
745 samples: &samples,
746 config: config(ALL_24),
747 })
748 .unwrap();
749 let probe = vector(12);
750 let initial = identify(&model, &cohort, &probe).unwrap();
751 let margin = initial.best.llr - initial.runner_up.as_ref().unwrap().llr;
752 model.config.absolute_threshold = initial.best.llr;
753 model.config.margin_threshold = margin;
754
755 let equality = identify(&model, &cohort, &probe).unwrap();
756 assert!(equality.absolute_pass);
757 assert!(equality.margin_pass);
758 assert!(matches!(equality.decision, Decision::Known { .. }));
759
760 let one = fit(FitInput {
761 cohort_id: &cohort,
762 samples: &samples[..2],
763 config: config(ALL_24),
764 })
765 .unwrap();
766 let result = identify(&one, &cohort, &probe).unwrap();
767 assert!(result.runner_up.is_none());
768 assert!(result.margin_pass);
769
770 let mut blocked = one;
771 blocked.config.absolute_threshold = result.best.llr + 1e-12;
772 assert!(matches!(
773 identify(&blocked, &cohort, &probe).unwrap().decision,
774 Decision::Unknown
775 ));
776 }
777
778 #[test]
779 fn cohort_mismatch_is_typed() {
780 let samples = training();
781 let cohort = key("cohort");
782 let model = fit(FitInput {
783 cohort_id: &cohort,
784 samples: &samples,
785 config: config(ALL_24),
786 })
787 .unwrap();
788
789 assert_eq!(
790 identify(&model, &key("different"), &vector(12)),
791 Err(ModelError::CohortMismatch)
792 );
793 }
794
795 #[test]
796 fn input_order_does_not_change_the_artifact() {
797 let samples = training();
798 let cohort = key("cohort");
799 let first = fit(FitInput {
800 cohort_id: &cohort,
801 samples: &samples,
802 config: config(ALL_24),
803 })
804 .unwrap();
805
806 let mut reversed = training();
807 reversed.reverse();
808 let second = fit(FitInput {
809 cohort_id: &cohort,
810 samples: &reversed,
811 config: config(ALL_24),
812 })
813 .unwrap();
814
815 assert_eq!(first.manifest_sha256, second.manifest_sha256);
816 assert_eq!(encode(&first).unwrap(), encode(&second).unwrap());
817 }
818
819 #[test]
820 fn same_sample_ids_with_changed_features_or_labels_change_identity() {
821 let samples = training();
822 let cohort = key("cohort");
823 let original = fit(FitInput {
824 cohort_id: &cohort,
825 samples: &samples,
826 config: config(ALL_24),
827 })
828 .unwrap();
829 let original_bytes = encode(&original).unwrap();
830
831 let mut feature_changed = training();
832 let mut values = *feature_changed[0].features.as_ref();
833 values[0] += 1;
834 feature_changed[0].features = FeatureVector::new(values).unwrap();
835 assert!(
836 samples
837 .iter()
838 .zip(&feature_changed)
839 .all(|(left, right)| left.sample_id == right.sample_id)
840 );
841 let feature_model = fit(FitInput {
842 cohort_id: &cohort,
843 samples: &feature_changed,
844 config: config(ALL_24),
845 })
846 .unwrap();
847 assert_ne!(original.manifest_sha256, feature_model.manifest_sha256);
848 assert_ne!(original_bytes, encode(&feature_model).unwrap());
849
850 let mut label_changed = training();
851 label_changed[0].speaker_id = key("carol");
852 assert!(
853 samples
854 .iter()
855 .zip(&label_changed)
856 .all(|(left, right)| left.sample_id == right.sample_id)
857 );
858 let label_model = fit(FitInput {
859 cohort_id: &cohort,
860 samples: &label_changed,
861 config: config(ALL_24),
862 })
863 .unwrap();
864 assert_ne!(original.manifest_sha256, label_model.manifest_sha256);
865 assert_ne!(original_bytes, encode(&label_model).unwrap());
866 }
867
868 #[test]
869 fn serialization_round_trip_is_deterministic_and_valid() {
870 let samples = training();
871 let cohort = key("cohort");
872 let model = fit(FitInput {
873 cohort_id: &cohort,
874 samples: &samples,
875 config: config(ALL_24),
876 })
877 .unwrap();
878
879 let first = encode(&model).unwrap();
880 let decoded = decode(&first).unwrap();
881 let second = encode(&decoded).unwrap();
882 let decoded_again = decode(&second).unwrap();
883 let third = encode(&decoded_again).unwrap();
884
885 assert_eq!(first, second);
886 assert_eq!(second, third);
887 assert_eq!(
888 identify(&model, &cohort, &vector(12)).unwrap(),
889 identify(&decoded_again, &cohort, &vector(12)).unwrap()
890 );
891 }
892
893 #[test]
894 fn decode_rejects_incompatible_and_corrupted_artifacts() {
895 let samples = training();
896 let cohort = key("cohort");
897 let model = fit(FitInput {
898 cohort_id: &cohort,
899 samples: &samples,
900 config: config(ALL_24),
901 })
902 .unwrap();
903 let bytes = encode(&model).unwrap();
904 let value = serde_json::from_slice::<Value>(&bytes).unwrap();
905
906 assert_eq!(decode(b"{}").err(), Some(ModelError::MalformedArtifact));
907 assert_eq!(decode(b"{").err(), Some(ModelError::MalformedArtifact));
908
909 let mut old_version = value.clone();
910 old_version["version"] = json!(1);
911 assert_malformed(&old_version);
912
913 let mut wrong_feature_count = value.clone();
914 wrong_feature_count["feature_count"] = json!(35);
915 assert_malformed(&wrong_feature_count);
916
917 let mut bad_scale = value.clone();
918 bad_scale["normalizer_std"][0] = json!(0.0);
919 assert_malformed(&bad_scale);
920
921 let mut bad_map_state = value;
922 let adapted = bad_map_state["speakers"][0]["adapted_means"][0][0]
923 .as_f64()
924 .unwrap();
925 bad_map_state["speakers"][0]["adapted_means"][0][0] = json!(adapted + 0.25);
926 assert_malformed(&bad_map_state);
927 }
928
929 #[test]
930 fn rejects_bad_samples_and_configuration() {
931 let cohort = key("cohort");
932 let empty = Vec::<LabeledSample>::new();
933 assert_eq!(
934 fit(FitInput {
935 cohort_id: &cohort,
936 samples: &empty,
937 config: config(ALL_24),
938 })
939 .err(),
940 Some(ModelError::InvalidSamples)
941 );
942
943 let mut mixed = training();
944 mixed[0].cohort_id = key("other");
945 assert_eq!(
946 fit(FitInput {
947 cohort_id: &cohort,
948 samples: &mixed,
949 config: config(ALL_24),
950 })
951 .err(),
952 Some(ModelError::CohortMismatch)
953 );
954
955 let mut duplicate = training();
956 duplicate[1].sample_id = duplicate[0].sample_id.clone();
957 assert_eq!(
958 fit(FitInput {
959 cohort_id: &cohort,
960 samples: &duplicate,
961 config: config(ALL_24),
962 })
963 .err(),
964 Some(ModelError::DuplicateSampleId)
965 );
966
967 let same = vec![
968 sample("same:1", "cohort", "alice", 10),
969 sample("same:2", "cohort", "alice", 10),
970 ];
971 assert_eq!(
972 fit(FitInput {
973 cohort_id: &cohort,
974 samples: &same,
975 config: config(ALL_24),
976 })
977 .err(),
978 Some(ModelError::ZeroVariance)
979 );
980
981 let samples = training();
982 for bad in [
983 ModelConfig {
984 components: 0,
985 ..config(ALL_24)
986 },
987 ModelConfig {
988 components: 5,
989 ..config(ALL_24)
990 },
991 ModelConfig {
992 relevance: 0.0,
993 ..config(ALL_24)
994 },
995 ModelConfig {
996 variance_floor: f64::NAN,
997 ..config(ALL_24)
998 },
999 ModelConfig {
1000 absolute_threshold: f64::INFINITY,
1001 ..config(ALL_24)
1002 },
1003 ModelConfig {
1004 margin_threshold: f64::NAN,
1005 ..config(ALL_24)
1006 },
1007 ] {
1008 assert_eq!(
1009 fit(FitInput {
1010 cohort_id: &cohort,
1011 samples: &samples,
1012 config: bad,
1013 })
1014 .err(),
1015 Some(ModelError::InvalidConfig)
1016 );
1017 }
1018 }
1019
1020 #[test]
1021 fn rejects_nonconverged_gmm() {
1022 let rows = vec![
1023 vec![-5.0],
1024 vec![-4.0],
1025 vec![-1.0],
1026 vec![2.0],
1027 vec![3.0],
1028 vec![10.0],
1029 ];
1030 assert_eq!(
1031 fit_ubm_with_limits(&rows, 2, 0.01, 1, f64::MIN_POSITIVE,).err(),
1032 Some(ModelError::Nonconverged)
1033 );
1034 }
1035}