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
24pub type FeatureRow = FeatureVector;
26
27#[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#[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#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
73pub struct CandidateEvidence {
74 pub speaker_id: String,
75 pub cost: f64,
76}
77
78#[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#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
91pub struct IdentifyOutcome {
92 pub speaker_id: Option<String>,
93 pub evidence: Option<IdentifyEvidence>,
94}
95
96#[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
107#[serde(rename_all = "snake_case")]
108pub enum DeleteOutcome {
109 Deleted,
110 NotFound,
111}
112
113#[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 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
194pub struct SpeechClassifier {
196 connection: Mutex<Connection>,
197 models: Mutex<HashMap<String, CachedCohort>>,
198}
199
200pub type SpeakerSystem = SpeechClassifier;
202
203impl SpeechClassifier {
204 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 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 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 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 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}