Skip to main content

kcode_speaker_model/
lib.rs

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}