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    fn lock(&self) -> Result<MutexGuard<'_, Connection>, Error> {
352        self.connection
353            .lock()
354            .map_err(|_| Error::Storage("classifier database lock was poisoned".to_owned()))
355    }
356
357    fn clear_models(&self) -> Result<(), Error> {
358        self.models
359            .lock()
360            .map_err(|_| Error::Model("classifier model cache lock was poisoned".to_owned()))?
361            .clear();
362        Ok(())
363    }
364}
365
366#[derive(Debug)]
367struct StoredObservation {
368    key: ObservationKey,
369    cohort_json: String,
370    features: FeatureRow,
371    speaker_id: String,
372}
373
374struct CachedCohort {
375    cohort_id: Key,
376    names: BTreeMap<Key, String>,
377    model: Option<ModelSnapshot>,
378}
379
380fn load_observation(
381    connection: &Connection,
382    key: &ObservationKey,
383) -> Result<Option<StoredObservation>, Error> {
384    connection
385        .query_row(
386            "SELECT cohort_json, features, speaker_id
387             FROM speaker_observations_v2
388             WHERE object_id = ?1 AND piece_index = ?2",
389            params![key.object_id, i64::from(key.piece_index)],
390            |record| {
391                Ok((
392                    record.get::<_, String>(0)?,
393                    record.get::<_, Vec<u8>>(1)?,
394                    record.get::<_, String>(2)?,
395                ))
396            },
397        )
398        .optional()?
399        .map(|(cohort_json, features, speaker_id)| {
400            Ok(StoredObservation {
401                key: key.clone(),
402                cohort_json,
403                features: decode_features(&features)?,
404                speaker_id,
405            })
406        })
407        .transpose()
408}
409
410fn load_cohort(
411    connection: &Connection,
412    cohort_json: &str,
413) -> Result<Vec<StoredObservation>, Error> {
414    let mut statement = connection.prepare(
415        "SELECT object_id, piece_index, features, speaker_id
416         FROM speaker_observations_v2
417         WHERE cohort_json = ?1
418         ORDER BY object_id, piece_index",
419    )?;
420    let records = statement.query_map([cohort_json], |record| {
421        Ok((
422            record.get::<_, String>(0)?,
423            record.get::<_, u32>(1)?,
424            record.get::<_, Vec<u8>>(2)?,
425            record.get::<_, String>(3)?,
426        ))
427    })?;
428
429    let mut observations = Vec::new();
430    for record in records {
431        let (object_id, piece_index, features, speaker_id) = record?;
432        observations.push(StoredObservation {
433            key: ObservationKey {
434                object_id,
435                piece_index,
436            },
437            cohort_json: cohort_json.to_owned(),
438            features: decode_features(&features)?,
439            speaker_id,
440        });
441    }
442    Ok(observations)
443}
444
445fn insert_observation(
446    connection: &Connection,
447    key: &ObservationKey,
448    cohort_json: &str,
449    features: &FeatureRow,
450    speaker_id: &str,
451) -> Result<(), Error> {
452    connection.execute(
453        "INSERT INTO speaker_observations_v2
454             (object_id, piece_index, cohort_json, features, speaker_id)
455         VALUES (?1, ?2, ?3, ?4, ?5)",
456        params![
457            key.object_id,
458            i64::from(key.piece_index),
459            cohort_json,
460            features.as_ref().as_slice(),
461            speaker_id,
462        ],
463    )?;
464    Ok(())
465}
466
467fn fit_cohort(
468    observations: &[StoredObservation],
469    cohort_json: &str,
470) -> Result<CachedCohort, Error> {
471    let cohort_id = digest_key("cohort", &[cohort_json.as_bytes()])?;
472    let mut names = BTreeMap::new();
473    let mut rows = Vec::with_capacity(observations.len());
474    for observation in observations {
475        let speaker_key = digest_key("speaker", &[observation.speaker_id.as_bytes()])?;
476        names.insert(speaker_key.clone(), observation.speaker_id.clone());
477        rows.push(TrainingRow {
478            sample_id: digest_key(
479                "sample",
480                &[
481                    observation.key.object_id.as_bytes(),
482                    &observation.key.piece_index.to_be_bytes(),
483                ],
484            )?,
485            speaker_id: speaker_key,
486            cohort_id: cohort_id.clone(),
487            features: observation.features,
488        });
489    }
490
491    let model = varying_features(observations)
492        .map(|mask| {
493            fit_rows(FitRowsInput {
494                cohort_id: &cohort_id,
495                rows: &rows,
496                config: ModelConfig {
497                    mask,
498                    components: 1,
499                    relevance: 2.0,
500                    variance_floor: 0.01,
501                    absolute_threshold: 0.0,
502                    margin_threshold: 0.0,
503                },
504            })
505        })
506        .transpose()?;
507    Ok(CachedCohort {
508        cohort_id,
509        names,
510        model,
511    })
512}
513
514fn classify(
515    cached: &CachedCohort,
516    probe: &FeatureRow,
517    threshold: f64,
518) -> Result<IdentifyOutcome, Error> {
519    let Some(model) = cached.model.as_ref() else {
520        return Ok(IdentifyOutcome {
521            speaker_id: None,
522            evidence: None,
523        });
524    };
525    let identification = identify_model(model, &cached.cohort_id, probe)?;
526    let accepted = identification.best.llr >= threshold
527        && identification
528            .runner_up
529            .as_ref()
530            .is_none_or(|candidate| identification.best.llr - candidate.llr >= threshold);
531    let speaker_id = if accepted {
532        Some(
533            cached
534                .names
535                .get(&identification.best.speaker_id)
536                .cloned()
537                .ok_or_else(|| Error::corrupt("model returned an unknown speaker key"))?,
538        )
539    } else {
540        None
541    };
542    let best_name = cached
543        .names
544        .get(&identification.best.speaker_id)
545        .cloned()
546        .ok_or_else(|| Error::corrupt("model returned an unknown best speaker key"))?;
547    let runner_up = identification
548        .runner_up
549        .as_ref()
550        .map(|candidate| {
551            let speaker_id = cached
552                .names
553                .get(&candidate.speaker_id)
554                .cloned()
555                .ok_or_else(|| Error::corrupt("model returned an unknown runner-up speaker key"))?;
556            Ok::<_, Error>(CandidateEvidence {
557                speaker_id,
558                cost: -candidate.llr,
559            })
560        })
561        .transpose()?;
562    let absolute_gap = identification.best.llr;
563    let runner_up_gap = identification
564        .runner_up
565        .as_ref()
566        .map(|candidate| identification.best.llr - candidate.llr);
567    let confidence_score = runner_up_gap.map_or(absolute_gap, |gap| absolute_gap.min(gap));
568
569    Ok(IdentifyOutcome {
570        speaker_id,
571        evidence: Some(IdentifyEvidence {
572            best: CandidateEvidence {
573                speaker_id: best_name,
574                cost: -identification.best.llr,
575            },
576            runner_up,
577            background_population_cost: 0.0,
578            absolute_gap,
579            runner_up_gap,
580            confidence_score,
581        }),
582    })
583}
584
585fn varying_features(observations: &[StoredObservation]) -> Option<FeatureMask> {
586    let first = observations.first()?.features.as_ref();
587    let mut bits = 0_u64;
588    for (index, first_value) in first.iter().enumerate() {
589        if observations
590            .iter()
591            .skip(1)
592            .any(|observation| observation.features.as_ref()[index] != *first_value)
593        {
594            bits |= 1_u64 << index;
595        }
596    }
597    FeatureMask::from_bits(bits).ok()
598}
599
600fn digest_key(prefix: &str, fields: &[&[u8]]) -> Result<Key, Error> {
601    let mut digest = Sha256::new();
602    for field in fields {
603        digest.update((field.len() as u64).to_be_bytes());
604        digest.update(field);
605    }
606    let hex = format!("{prefix}:{:x}", digest.finalize());
607    Key::parse(&hex).map_err(|error| Error::Model(error.to_string()))
608}
609
610fn encode_cohort(cohort: &Cohort) -> Result<String, Error> {
611    serde_json::to_string(cohort).map_err(|error| Error::Storage(error.to_string()))
612}
613
614fn decode_features(bytes: &[u8]) -> Result<FeatureRow, Error> {
615    let values: [u8; FEATURE_COUNT] = bytes
616        .try_into()
617        .map_err(|_| Error::corrupt("stored feature row does not contain exactly 24 values"))?;
618    FeatureRow::new(values).map_err(|error| Error::corrupt(error.to_string()))
619}
620
621fn validate_nonempty(field: &str, value: &str) -> Result<(), Error> {
622    if value.trim().is_empty() {
623        Err(Error::validation(field, "must not be empty"))
624    } else {
625        Ok(())
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use std::fs;
633    use std::path::{Path, PathBuf};
634    use std::sync::atomic::{AtomicU64, Ordering};
635
636    static NEXT_PATH: AtomicU64 = AtomicU64::new(0);
637
638    fn database_path(label: &str) -> PathBuf {
639        std::env::temp_dir().join(format!(
640            "kcode-speaker-system-{label}-{}-{}.sqlite3",
641            std::process::id(),
642            NEXT_PATH.fetch_add(1, Ordering::Relaxed)
643        ))
644    }
645
646    fn cleanup(path: &Path) {
647        for suffix in ["", "-wal", "-shm"] {
648            let _ = fs::remove_file(format!("{}{}", path.display(), suffix));
649        }
650    }
651
652    fn key(value: &str) -> ObservationKey {
653        ObservationKey {
654            object_id: value.to_owned(),
655            piece_index: 0,
656        }
657    }
658
659    fn cohort() -> Cohort {
660        Cohort {
661            provider: "google".to_owned(),
662            model: "gemini-example".to_owned(),
663            prompt_version: "speaker-24-1".to_owned(),
664            schema_version: "speaker-24-1".to_owned(),
665            primary_language: "eng".to_owned(),
666        }
667    }
668
669    fn row(seed: u8) -> FeatureRow {
670        let mut values = [0_u8; FEATURE_COUNT];
671        for (index, value) in values.iter_mut().enumerate() {
672            *value = (seed + u8::try_from(index).unwrap()) % 100;
673        }
674        FeatureRow::new(values).unwrap()
675    }
676
677    #[test]
678    fn train_correct_delete_and_reopen_use_only_current_rows() {
679        let path = database_path("mutations");
680        let classifier = SpeechClassifier::open(&path).unwrap();
681        assert_eq!(
682            classifier
683                .train(key("one"), cohort(), row(10), "Alice Example".to_owned())
684                .unwrap(),
685            TrainOutcome::Added
686        );
687        assert_eq!(
688            classifier
689                .train(key("one"), cohort(), row(10), "Alice Example".to_owned())
690                .unwrap(),
691            TrainOutcome::Unchanged
692        );
693        assert_eq!(
694            classifier
695                .train(key("one"), cohort(), row(11), "Alice Corrected".to_owned())
696                .unwrap(),
697            TrainOutcome::Corrected
698        );
699        drop(classifier);
700
701        let classifier = SpeechClassifier::open(&path).unwrap();
702        assert_eq!(
703            classifier.delete(key("one")).unwrap(),
704            DeleteOutcome::Deleted
705        );
706        assert_eq!(
707            classifier.delete(key("one")).unwrap(),
708            DeleteOutcome::NotFound
709        );
710        drop(classifier);
711        cleanup(&path);
712    }
713
714    #[test]
715    fn identify_is_cohort_scoped_thresholded_and_retained() {
716        let path = database_path("identify");
717        let classifier = SpeechClassifier::open(&path).unwrap();
718        for (id, seed, speaker) in [
719            ("alice-1", 10, "Alice Example"),
720            ("alice-2", 12, "Alice Example"),
721            ("bob-1", 70, "Bob Example"),
722            ("bob-2", 72, "Bob Example"),
723        ] {
724            classifier
725                .train(key(id), cohort(), row(seed), speaker.to_owned())
726                .unwrap();
727        }
728
729        let rejected = classifier
730            .identify(key("rejected"), cohort(), row(11), f64::MAX)
731            .unwrap();
732        assert_eq!(rejected.speaker_id, None);
733        assert!(rejected.evidence.is_some());
734        assert_eq!(classifier.models.lock().unwrap().len(), 1);
735
736        let accepted = classifier
737            .identify(key("accepted"), cohort(), row(11), -1_000_000.0)
738            .unwrap();
739        assert_eq!(accepted.speaker_id.as_deref(), Some("Alice Example"));
740        assert!(classifier.models.lock().unwrap().is_empty());
741        assert_eq!(
742            classifier
743                .identify(key("accepted"), cohort(), row(11), f64::MAX)
744                .unwrap()
745                .speaker_id
746                .as_deref(),
747            Some("Alice Example")
748        );
749
750        let mut other = cohort();
751        other.prompt_version = "other".to_owned();
752        assert_eq!(
753            classifier
754                .identify(key("isolated"), other, row(11), -1_000_000.0)
755                .unwrap(),
756            IdentifyOutcome {
757                speaker_id: None,
758                evidence: None,
759            }
760        );
761        drop(classifier);
762        cleanup(&path);
763    }
764
765    #[test]
766    fn public_boundary_rejects_bad_keys_labels_and_thresholds() {
767        let path = database_path("validation");
768        let classifier = SpeechClassifier::open(&path).unwrap();
769        assert!(matches!(
770            classifier.delete(key(" ")),
771            Err(Error::Validation { .. })
772        ));
773        assert!(matches!(
774            classifier.train(key("one"), cohort(), row(1), " ".to_owned()),
775            Err(Error::Validation { .. })
776        ));
777        assert!(matches!(
778            classifier.identify(key("one"), cohort(), row(1), f64::NAN),
779            Err(Error::Validation { .. })
780        ));
781        drop(classifier);
782        cleanup(&path);
783    }
784}