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 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}