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/// The complete model input needed for one labelled feature row.
42#[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
50/// A minimal fitting request for callers that do not own media provenance.
51pub 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
171/// Fits the same deterministic model from only IDs and feature vectors.
172pub 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}