Skip to main content

kcode_speaker_system/
lib.rs

1#![forbid(unsafe_code)]
2
3mod ktool;
4
5pub use kcode_speaker_types::{FEATURE_COUNT, FeatureVector};
6pub use ktool::{
7    DELETE_TOOL, IDENTIFY_TOOL, KTOOLS, KtoolCall, KtoolError, TRAIN_TOOL, decode_ktool,
8};
9
10use kcode_speaker_model::{
11    FitRowsInput, ModelConfig, ModelError, ModelSnapshot, TrainingRow, fit_rows,
12    identify as identify_model,
13};
14use kcode_speaker_types::{FeatureMask, Key};
15use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::collections::{BTreeMap, HashMap};
19use std::fmt;
20use std::path::Path;
21use std::sync::{Mutex, MutexGuard};
22use std::time::Duration;
23
24/// The frozen 24-value feature row used by the classifier.
25pub type FeatureRow = FeatureVector;
26
27/// One caller-owned observation identity.
28#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
29pub struct ObservationKey {
30    pub object_id: String,
31    pub piece_index: u32,
32}
33
34impl ObservationKey {
35    fn validate(&self) -> Result<(), Error> {
36        validate_nonempty("key.object_id", &self.object_id)
37    }
38}
39
40/// The provider and feature-contract boundary within which rows may be compared.
41#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
42pub struct Cohort {
43    pub provider: String,
44    pub model: String,
45    pub prompt_version: String,
46    pub schema_version: String,
47    pub primary_language: String,
48}
49
50impl Cohort {
51    fn validate(&self) -> Result<(), Error> {
52        validate_nonempty("cohort.provider", &self.provider)?;
53        validate_nonempty("cohort.model", &self.model)?;
54        validate_nonempty("cohort.prompt_version", &self.prompt_version)?;
55        validate_nonempty("cohort.schema_version", &self.schema_version)?;
56        if self.primary_language.len() != 3
57            || !self
58                .primary_language
59                .bytes()
60                .all(|byte| byte.is_ascii_lowercase())
61        {
62            return Err(Error::validation(
63                "cohort.primary_language",
64                "must be a lowercase ISO 639-3 code",
65            ));
66        }
67        Ok(())
68    }
69}
70
71/// One ranked classifier candidate. Lower cost is better.
72#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
73pub struct CandidateEvidence {
74    pub speaker_id: String,
75    pub cost: f64,
76}
77
78/// Evidence supporting an identify result.
79#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
80pub struct IdentifyEvidence {
81    pub best: CandidateEvidence,
82    pub runner_up: Option<CandidateEvidence>,
83    pub background_population_cost: f64,
84    pub absolute_gap: f64,
85    pub runner_up_gap: Option<f64>,
86    pub confidence_score: f64,
87}
88
89/// The accepted speaker, if any, and the available ranking evidence.
90#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
91pub struct IdentifyOutcome {
92    pub speaker_id: Option<String>,
93    pub evidence: Option<IdentifyEvidence>,
94}
95
96/// Result of adding or correcting labelled training data.
97#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
98#[serde(rename_all = "snake_case")]
99pub enum TrainOutcome {
100    Added,
101    Unchanged,
102    Corrected,
103}
104
105/// Result of idempotently deleting an observation.
106#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
107#[serde(rename_all = "snake_case")]
108pub enum DeleteOutcome {
109    Deleted,
110    NotFound,
111}
112
113/// A classifier operation failure.
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub enum Error {
116    Validation {
117        field: String,
118        message: String,
119    },
120    Conflict {
121        key: ObservationKey,
122        message: String,
123    },
124    Storage(String),
125    CorruptStorage(String),
126    Model(String),
127}
128
129impl Error {
130    fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
131        Self::Validation {
132            field: field.into(),
133            message: message.into(),
134        }
135    }
136
137    fn conflict(key: &ObservationKey) -> Self {
138        Self::Conflict {
139            key: key.clone(),
140            message:
141                "observation key already has conflicting data or assignment; use train or delete"
142                    .to_owned(),
143        }
144    }
145
146    fn corrupt(message: impl Into<String>) -> Self {
147        Self::CorruptStorage(message.into())
148    }
149
150    /// Stable error category for callers that need machine-readable handling.
151    pub const fn code(&self) -> &'static str {
152        match self {
153            Self::Validation { .. } => "validation",
154            Self::Conflict { .. } => "conflict",
155            Self::Storage(_) => "storage",
156            Self::CorruptStorage(_) => "corrupt_storage",
157            Self::Model(_) => "model",
158        }
159    }
160}
161
162impl fmt::Display for Error {
163    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164        match self {
165            Self::Validation { field, message } => write!(formatter, "{field}: {message}"),
166            Self::Conflict { key, message } => {
167                write!(
168                    formatter,
169                    "{}:{}: {message}",
170                    key.object_id, key.piece_index
171                )
172            }
173            Self::Storage(message) => write!(formatter, "storage error: {message}"),
174            Self::CorruptStorage(message) => write!(formatter, "corrupt storage: {message}"),
175            Self::Model(message) => write!(formatter, "speaker model error: {message}"),
176        }
177    }
178}
179
180impl std::error::Error for Error {}
181
182impl From<rusqlite::Error> for Error {
183    fn from(error: rusqlite::Error) -> Self {
184        Self::Storage(error.to_string())
185    }
186}
187
188impl From<ModelError> for Error {
189    fn from(error: ModelError) -> Self {
190        Self::Model(error.to_string())
191    }
192}
193
194/// A thread-safe classifier backed by one SQLite database.
195pub struct SpeechClassifier {
196    connection: Mutex<Connection>,
197    models: Mutex<HashMap<String, CachedCohort>>,
198}
199
200/// Descriptive alias for the classifier owned by this package.
201pub type SpeakerSystem = SpeechClassifier;
202
203impl SpeechClassifier {
204    /// Opens or creates the classifier's current-row store.
205    pub fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
206        let connection = Connection::open(path)?;
207        connection.busy_timeout(Duration::from_secs(30))?;
208        connection.execute_batch(
209            "PRAGMA journal_mode = WAL;
210             PRAGMA synchronous = FULL;
211             CREATE TABLE IF NOT EXISTS speaker_observations_v2 (
212                 object_id TEXT NOT NULL,
213                 piece_index INTEGER NOT NULL,
214                 cohort_json TEXT NOT NULL,
215                 features BLOB NOT NULL,
216                 speaker_id TEXT NOT NULL,
217                 PRIMARY KEY (object_id, piece_index)
218             );
219             CREATE INDEX IF NOT EXISTS speaker_observations_v2_cohort
220                 ON speaker_observations_v2 (cohort_json);",
221        )?;
222        Ok(Self {
223            connection: Mutex::new(connection),
224            models: Mutex::new(HashMap::new()),
225        })
226    }
227
228    /// Scores an observation and atomically retains it when the threshold is met.
229    pub fn identify(
230        &self,
231        key: ObservationKey,
232        cohort: Cohort,
233        row: FeatureRow,
234        threshold: f64,
235    ) -> Result<IdentifyOutcome, Error> {
236        key.validate()?;
237        cohort.validate()?;
238        if !threshold.is_finite() {
239            return Err(Error::validation("threshold", "must be finite"));
240        }
241
242        let cohort_json = encode_cohort(&cohort)?;
243        let mut connection = self.lock()?;
244        let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
245
246        if let Some(existing) = load_observation(&transaction, &key)? {
247            if existing.cohort_json != cohort_json || existing.features != row {
248                return Err(Error::conflict(&key));
249            }
250            transaction.commit()?;
251            return Ok(IdentifyOutcome {
252                speaker_id: Some(existing.speaker_id),
253                evidence: None,
254            });
255        }
256
257        let mut models = self
258            .models
259            .lock()
260            .map_err(|_| Error::Model("classifier model cache lock was poisoned".to_owned()))?;
261        if !models.contains_key(&cohort_json) {
262            let training = load_cohort(&transaction, &cohort_json)?;
263            models.insert(cohort_json.clone(), fit_cohort(&training, &cohort_json)?);
264        }
265        let outcome = classify(
266            models
267                .get(&cohort_json)
268                .ok_or_else(|| Error::Model("fitted cohort was not cached".to_owned()))?,
269            &row,
270            threshold,
271        )?;
272        if let Some(speaker_id) = &outcome.speaker_id {
273            insert_observation(&transaction, &key, &cohort_json, &row, speaker_id)?;
274            models.clear();
275        }
276        transaction.commit()?;
277        Ok(outcome)
278    }
279
280    /// Adds known training data or atomically corrects the current assignment.
281    pub fn train(
282        &self,
283        key: ObservationKey,
284        cohort: Cohort,
285        row: FeatureRow,
286        speaker_id: String,
287    ) -> Result<TrainOutcome, Error> {
288        key.validate()?;
289        cohort.validate()?;
290        validate_nonempty("speaker_id", &speaker_id)?;
291
292        let cohort_json = encode_cohort(&cohort)?;
293        let mut connection = self.lock()?;
294        let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
295        let existing = load_observation(&transaction, &key)?;
296        let outcome = match existing {
297            None => TrainOutcome::Added,
298            Some(existing)
299                if existing.cohort_json == cohort_json
300                    && existing.features == row
301                    && existing.speaker_id == speaker_id =>
302            {
303                TrainOutcome::Unchanged
304            }
305            Some(_) => TrainOutcome::Corrected,
306        };
307        if outcome != TrainOutcome::Unchanged {
308            transaction.execute(
309                "INSERT INTO speaker_observations_v2
310                     (object_id, piece_index, cohort_json, features, speaker_id)
311                 VALUES (?1, ?2, ?3, ?4, ?5)
312                 ON CONFLICT(object_id, piece_index) DO UPDATE SET
313                     cohort_json = excluded.cohort_json,
314                     features = excluded.features,
315                     speaker_id = excluded.speaker_id",
316                params![
317                    key.object_id,
318                    i64::from(key.piece_index),
319                    cohort_json,
320                    row.as_ref().as_slice(),
321                    speaker_id,
322                ],
323            )?;
324        }
325        transaction.commit()?;
326        if outcome != TrainOutcome::Unchanged {
327            self.clear_models()?;
328        }
329        Ok(outcome)
330    }
331
332    /// Idempotently deletes the current observation for a key.
333    pub fn delete(&self, key: ObservationKey) -> Result<DeleteOutcome, Error> {
334        key.validate()?;
335        let connection = self.lock()?;
336        let changed = connection.execute(
337            "DELETE FROM speaker_observations_v2
338             WHERE object_id = ?1 AND piece_index = ?2",
339            params![key.object_id, i64::from(key.piece_index)],
340        )?;
341        if changed != 0 {
342            self.clear_models()?;
343        }
344        Ok(if changed == 0 {
345            DeleteOutcome::NotFound
346        } else {
347            DeleteOutcome::Deleted
348        })
349    }
350
351    /// Returns the sorted distinct speaker names currently retained by the store.
352    pub fn known_speakers(&self) -> Result<Vec<String>, Error> {
353        let connection = self.lock()?;
354        let mut statement = connection.prepare(
355            "SELECT DISTINCT speaker_id
356             FROM speaker_observations_v2
357             ORDER BY speaker_id",
358        )?;
359        statement
360            .query_map([], |record| record.get::<_, String>(0))?
361            .collect::<Result<Vec<_>, _>>()
362            .map_err(Error::from)
363    }
364
365    fn lock(&self) -> Result<MutexGuard<'_, Connection>, Error> {
366        self.connection
367            .lock()
368            .map_err(|_| Error::Storage("classifier database lock was poisoned".to_owned()))
369    }
370
371    fn clear_models(&self) -> Result<(), Error> {
372        self.models
373            .lock()
374            .map_err(|_| Error::Model("classifier model cache lock was poisoned".to_owned()))?
375            .clear();
376        Ok(())
377    }
378}
379
380#[derive(Debug)]
381struct StoredObservation {
382    key: ObservationKey,
383    cohort_json: String,
384    features: FeatureRow,
385    speaker_id: String,
386}
387
388struct CachedCohort {
389    cohort_id: Key,
390    names: BTreeMap<Key, String>,
391    model: Option<ModelSnapshot>,
392}
393
394fn load_observation(
395    connection: &Connection,
396    key: &ObservationKey,
397) -> Result<Option<StoredObservation>, Error> {
398    connection
399        .query_row(
400            "SELECT cohort_json, features, speaker_id
401             FROM speaker_observations_v2
402             WHERE object_id = ?1 AND piece_index = ?2",
403            params![key.object_id, i64::from(key.piece_index)],
404            |record| {
405                Ok((
406                    record.get::<_, String>(0)?,
407                    record.get::<_, Vec<u8>>(1)?,
408                    record.get::<_, String>(2)?,
409                ))
410            },
411        )
412        .optional()?
413        .map(|(cohort_json, features, speaker_id)| {
414            Ok(StoredObservation {
415                key: key.clone(),
416                cohort_json,
417                features: decode_features(&features)?,
418                speaker_id,
419            })
420        })
421        .transpose()
422}
423
424fn load_cohort(
425    connection: &Connection,
426    cohort_json: &str,
427) -> Result<Vec<StoredObservation>, Error> {
428    let mut statement = connection.prepare(
429        "SELECT object_id, piece_index, features, speaker_id
430         FROM speaker_observations_v2
431         WHERE cohort_json = ?1
432         ORDER BY object_id, piece_index",
433    )?;
434    let records = statement.query_map([cohort_json], |record| {
435        Ok((
436            record.get::<_, String>(0)?,
437            record.get::<_, u32>(1)?,
438            record.get::<_, Vec<u8>>(2)?,
439            record.get::<_, String>(3)?,
440        ))
441    })?;
442
443    let mut observations = Vec::new();
444    for record in records {
445        let (object_id, piece_index, features, speaker_id) = record?;
446        observations.push(StoredObservation {
447            key: ObservationKey {
448                object_id,
449                piece_index,
450            },
451            cohort_json: cohort_json.to_owned(),
452            features: decode_features(&features)?,
453            speaker_id,
454        });
455    }
456    Ok(observations)
457}
458
459fn insert_observation(
460    connection: &Connection,
461    key: &ObservationKey,
462    cohort_json: &str,
463    features: &FeatureRow,
464    speaker_id: &str,
465) -> Result<(), Error> {
466    connection.execute(
467        "INSERT INTO speaker_observations_v2
468             (object_id, piece_index, cohort_json, features, speaker_id)
469         VALUES (?1, ?2, ?3, ?4, ?5)",
470        params![
471            key.object_id,
472            i64::from(key.piece_index),
473            cohort_json,
474            features.as_ref().as_slice(),
475            speaker_id,
476        ],
477    )?;
478    Ok(())
479}
480
481fn fit_cohort(
482    observations: &[StoredObservation],
483    cohort_json: &str,
484) -> Result<CachedCohort, Error> {
485    let cohort_id = digest_key("cohort", &[cohort_json.as_bytes()])?;
486    let mut names = BTreeMap::new();
487    let mut rows = Vec::with_capacity(observations.len());
488    for observation in observations {
489        let speaker_key = digest_key("speaker", &[observation.speaker_id.as_bytes()])?;
490        names.insert(speaker_key.clone(), observation.speaker_id.clone());
491        rows.push(TrainingRow {
492            sample_id: digest_key(
493                "sample",
494                &[
495                    observation.key.object_id.as_bytes(),
496                    &observation.key.piece_index.to_be_bytes(),
497                ],
498            )?,
499            speaker_id: speaker_key,
500            cohort_id: cohort_id.clone(),
501            features: observation.features,
502        });
503    }
504
505    let model = varying_features(observations)
506        .map(|mask| {
507            fit_rows(FitRowsInput {
508                cohort_id: &cohort_id,
509                rows: &rows,
510                config: ModelConfig {
511                    mask,
512                    components: 1,
513                    relevance: 2.0,
514                    variance_floor: 0.01,
515                    absolute_threshold: 0.0,
516                    margin_threshold: 0.0,
517                },
518            })
519        })
520        .transpose()?;
521    Ok(CachedCohort {
522        cohort_id,
523        names,
524        model,
525    })
526}
527
528fn classify(
529    cached: &CachedCohort,
530    probe: &FeatureRow,
531    threshold: f64,
532) -> Result<IdentifyOutcome, Error> {
533    let Some(model) = cached.model.as_ref() else {
534        return Ok(IdentifyOutcome {
535            speaker_id: None,
536            evidence: None,
537        });
538    };
539    let identification = identify_model(model, &cached.cohort_id, probe)?;
540    let accepted = identification.best.llr >= threshold
541        && identification
542            .runner_up
543            .as_ref()
544            .is_none_or(|candidate| identification.best.llr - candidate.llr >= threshold);
545    let speaker_id = if accepted {
546        Some(
547            cached
548                .names
549                .get(&identification.best.speaker_id)
550                .cloned()
551                .ok_or_else(|| Error::corrupt("model returned an unknown speaker key"))?,
552        )
553    } else {
554        None
555    };
556    let best_name = cached
557        .names
558        .get(&identification.best.speaker_id)
559        .cloned()
560        .ok_or_else(|| Error::corrupt("model returned an unknown best speaker key"))?;
561    let runner_up = identification
562        .runner_up
563        .as_ref()
564        .map(|candidate| {
565            let speaker_id = cached
566                .names
567                .get(&candidate.speaker_id)
568                .cloned()
569                .ok_or_else(|| Error::corrupt("model returned an unknown runner-up speaker key"))?;
570            Ok::<_, Error>(CandidateEvidence {
571                speaker_id,
572                cost: -candidate.llr,
573            })
574        })
575        .transpose()?;
576    let absolute_gap = identification.best.llr;
577    let runner_up_gap = identification
578        .runner_up
579        .as_ref()
580        .map(|candidate| identification.best.llr - candidate.llr);
581    let confidence_score = runner_up_gap.map_or(absolute_gap, |gap| absolute_gap.min(gap));
582
583    Ok(IdentifyOutcome {
584        speaker_id,
585        evidence: Some(IdentifyEvidence {
586            best: CandidateEvidence {
587                speaker_id: best_name,
588                cost: -identification.best.llr,
589            },
590            runner_up,
591            background_population_cost: 0.0,
592            absolute_gap,
593            runner_up_gap,
594            confidence_score,
595        }),
596    })
597}
598
599fn varying_features(observations: &[StoredObservation]) -> Option<FeatureMask> {
600    let first = observations.first()?.features.as_ref();
601    let mut bits = 0_u64;
602    for (index, first_value) in first.iter().enumerate() {
603        if observations
604            .iter()
605            .skip(1)
606            .any(|observation| observation.features.as_ref()[index] != *first_value)
607        {
608            bits |= 1_u64 << index;
609        }
610    }
611    FeatureMask::from_bits(bits).ok()
612}
613
614fn digest_key(prefix: &str, fields: &[&[u8]]) -> Result<Key, Error> {
615    let mut digest = Sha256::new();
616    for field in fields {
617        digest.update((field.len() as u64).to_be_bytes());
618        digest.update(field);
619    }
620    let hex = format!("{prefix}:{:x}", digest.finalize());
621    Key::parse(&hex).map_err(|error| Error::Model(error.to_string()))
622}
623
624fn encode_cohort(cohort: &Cohort) -> Result<String, Error> {
625    serde_json::to_string(cohort).map_err(|error| Error::Storage(error.to_string()))
626}
627
628fn decode_features(bytes: &[u8]) -> Result<FeatureRow, Error> {
629    let values: [u8; FEATURE_COUNT] = bytes
630        .try_into()
631        .map_err(|_| Error::corrupt("stored feature row does not contain exactly 24 values"))?;
632    FeatureRow::new(values).map_err(|error| Error::corrupt(error.to_string()))
633}
634
635fn validate_nonempty(field: &str, value: &str) -> Result<(), Error> {
636    if value.trim().is_empty() {
637        Err(Error::validation(field, "must not be empty"))
638    } else {
639        Ok(())
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use std::fs;
647    use std::path::{Path, PathBuf};
648    use std::sync::atomic::{AtomicU64, Ordering};
649
650    static NEXT_PATH: AtomicU64 = AtomicU64::new(0);
651
652    fn database_path(label: &str) -> PathBuf {
653        std::env::temp_dir().join(format!(
654            "kcode-speaker-system-{label}-{}-{}.sqlite3",
655            std::process::id(),
656            NEXT_PATH.fetch_add(1, Ordering::Relaxed)
657        ))
658    }
659
660    fn cleanup(path: &Path) {
661        for suffix in ["", "-wal", "-shm"] {
662            let _ = fs::remove_file(format!("{}{}", path.display(), suffix));
663        }
664    }
665
666    fn key(value: &str) -> ObservationKey {
667        ObservationKey {
668            object_id: value.to_owned(),
669            piece_index: 0,
670        }
671    }
672
673    fn cohort() -> Cohort {
674        Cohort {
675            provider: "google".to_owned(),
676            model: "gemini-example".to_owned(),
677            prompt_version: "speaker-24-1".to_owned(),
678            schema_version: "speaker-24-1".to_owned(),
679            primary_language: "eng".to_owned(),
680        }
681    }
682
683    fn row(seed: u8) -> FeatureRow {
684        let mut values = [0_u8; FEATURE_COUNT];
685        for (index, value) in values.iter_mut().enumerate() {
686            *value = (seed + u8::try_from(index).unwrap()) % 100;
687        }
688        FeatureRow::new(values).unwrap()
689    }
690
691    #[test]
692    fn train_correct_delete_and_reopen_use_only_current_rows() {
693        let path = database_path("mutations");
694        let classifier = SpeechClassifier::open(&path).unwrap();
695        assert_eq!(
696            classifier
697                .train(key("one"), cohort(), row(10), "Alice Example".to_owned())
698                .unwrap(),
699            TrainOutcome::Added
700        );
701        assert_eq!(
702            classifier
703                .train(key("one"), cohort(), row(10), "Alice Example".to_owned())
704                .unwrap(),
705            TrainOutcome::Unchanged
706        );
707        assert_eq!(
708            classifier
709                .train(key("one"), cohort(), row(11), "Alice Corrected".to_owned())
710                .unwrap(),
711            TrainOutcome::Corrected
712        );
713        drop(classifier);
714
715        let classifier = SpeechClassifier::open(&path).unwrap();
716        assert_eq!(
717            classifier.delete(key("one")).unwrap(),
718            DeleteOutcome::Deleted
719        );
720        assert_eq!(
721            classifier.delete(key("one")).unwrap(),
722            DeleteOutcome::NotFound
723        );
724        drop(classifier);
725        cleanup(&path);
726    }
727
728    #[test]
729    fn known_speakers_are_distinct_sorted_and_follow_current_rows() {
730        let path = database_path("known-speakers");
731        let classifier = SpeechClassifier::open(&path).unwrap();
732        for (id, speaker) in [
733            ("bob", "Bob Example"),
734            ("alice-1", "Alice Example"),
735            ("alice-2", "Alice Example"),
736        ] {
737            classifier
738                .train(key(id), cohort(), row(10), speaker.to_owned())
739                .unwrap();
740        }
741        assert_eq!(
742            classifier.known_speakers().unwrap(),
743            vec!["Alice Example", "Bob Example"]
744        );
745        classifier.delete(key("bob")).unwrap();
746        assert_eq!(classifier.known_speakers().unwrap(), vec!["Alice Example"]);
747        drop(classifier);
748        cleanup(&path);
749    }
750
751    #[test]
752    fn identify_is_cohort_scoped_thresholded_and_retained() {
753        let path = database_path("identify");
754        let classifier = SpeechClassifier::open(&path).unwrap();
755        for (id, seed, speaker) in [
756            ("alice-1", 10, "Alice Example"),
757            ("alice-2", 12, "Alice Example"),
758            ("bob-1", 70, "Bob Example"),
759            ("bob-2", 72, "Bob Example"),
760        ] {
761            classifier
762                .train(key(id), cohort(), row(seed), speaker.to_owned())
763                .unwrap();
764        }
765
766        let rejected = classifier
767            .identify(key("rejected"), cohort(), row(11), f64::MAX)
768            .unwrap();
769        assert_eq!(rejected.speaker_id, None);
770        assert!(rejected.evidence.is_some());
771        assert_eq!(classifier.models.lock().unwrap().len(), 1);
772
773        let accepted = classifier
774            .identify(key("accepted"), cohort(), row(11), -1_000_000.0)
775            .unwrap();
776        assert_eq!(accepted.speaker_id.as_deref(), Some("Alice Example"));
777        assert!(classifier.models.lock().unwrap().is_empty());
778        assert_eq!(
779            classifier
780                .identify(key("accepted"), cohort(), row(11), f64::MAX)
781                .unwrap()
782                .speaker_id
783                .as_deref(),
784            Some("Alice Example")
785        );
786
787        let mut other = cohort();
788        other.prompt_version = "other".to_owned();
789        assert_eq!(
790            classifier
791                .identify(key("isolated"), other, row(11), -1_000_000.0)
792                .unwrap(),
793            IdentifyOutcome {
794                speaker_id: None,
795                evidence: None,
796            }
797        );
798        drop(classifier);
799        cleanup(&path);
800    }
801
802    #[test]
803    fn public_boundary_rejects_bad_keys_labels_and_thresholds() {
804        let path = database_path("validation");
805        let classifier = SpeechClassifier::open(&path).unwrap();
806        assert!(matches!(
807            classifier.delete(key(" ")),
808            Err(Error::Validation { .. })
809        ));
810        assert!(matches!(
811            classifier.train(key("one"), cohort(), row(1), " ".to_owned()),
812            Err(Error::Validation { .. })
813        ));
814        assert!(matches!(
815            classifier.identify(key("one"), cohort(), row(1), f64::NAN),
816            Err(Error::Validation { .. })
817        ));
818        drop(classifier);
819        cleanup(&path);
820    }
821}