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