Skip to main content

kcode_speech_classification/
model.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
5pub struct ObservationKey {
6    pub object_id: String,
7    pub piece_index: u32,
8}
9
10impl ObservationKey {
11    pub(crate) fn validate(&self) -> Result<(), Error> {
12        validate_nonempty("key.object_id", &self.object_id)
13    }
14}
15
16#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
17pub struct Cohort {
18    pub provider: String,
19    pub model: String,
20    pub prompt_version: String,
21    pub schema_version: String,
22    pub primary_language: String,
23}
24
25impl Cohort {
26    pub(crate) fn validate(&self) -> Result<(), Error> {
27        validate_nonempty("cohort.provider", &self.provider)?;
28        validate_nonempty("cohort.model", &self.model)?;
29        validate_nonempty("cohort.prompt_version", &self.prompt_version)?;
30        validate_nonempty("cohort.schema_version", &self.schema_version)?;
31        if self.primary_language.len() != 3
32            || !self
33                .primary_language
34                .bytes()
35                .all(|byte| byte.is_ascii_lowercase())
36        {
37            return Err(Error::validation(
38                "cohort.primary_language",
39                "must be a lowercase ISO 639-3 code",
40            ));
41        }
42        Ok(())
43    }
44}
45
46#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
47pub enum Cefr {
48    #[serde(rename = "A1")]
49    A1,
50    #[serde(rename = "A2")]
51    A2,
52    #[serde(rename = "B1")]
53    B1,
54    #[serde(rename = "B2")]
55    B2,
56    #[serde(rename = "C1")]
57    C1,
58    #[serde(rename = "C2")]
59    C2,
60}
61
62impl Cefr {
63    pub(crate) const fn numerical_value(self) -> f64 {
64        match self {
65            Self::A1 => 1.0,
66            Self::A2 => 2.0,
67            Self::B1 => 3.0,
68            Self::B2 => 4.0,
69            Self::C1 => 5.0,
70            Self::C2 => 6.0,
71        }
72    }
73}
74
75#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
76pub struct FeatureRow {
77    pub accent_variety: String,
78    pub perceived_age: f64,
79    pub vocal_gender_presentation: f64,
80    pub median_f0_hz: f64,
81    pub formant_dispersion_hz: f64,
82    pub vai: f64,
83    pub hypernasality: f64,
84    pub creaky_phonation_percent: f64,
85    pub rhotic_realization: String,
86    pub word_initial_stressed_prevocalic_t_vot_ms: f64,
87    pub breathiness: f64,
88    pub roughness: f64,
89    pub f0_pitch_span_semitones: f64,
90    pub articulation_rate_syllables_per_second: f64,
91    pub npvi_v: f64,
92    pub cefr: Cefr,
93    pub foreign_accentedness: f64,
94    pub unstressed_vowel_reduction_percent: f64,
95    pub lateral_realization: String,
96    pub filled_pauses_per_100_words: f64,
97    pub s_realization: String,
98    pub lexical_stress_accuracy_percent: f64,
99    pub monophthongization_percent: f64,
100    pub consonant_cluster_reduction_percent: f64,
101}
102
103impl FeatureRow {
104    pub(crate) fn validate(&self) -> Result<(), Error> {
105        validate_nonempty("row.accent_variety", &self.accent_variety)?;
106        validate_positive("row.perceived_age", self.perceived_age)?;
107        validate_range(
108            "row.vocal_gender_presentation",
109            self.vocal_gender_presentation,
110            0.0,
111            100.0,
112        )?;
113        validate_positive("row.median_f0_hz", self.median_f0_hz)?;
114        validate_positive("row.formant_dispersion_hz", self.formant_dispersion_hz)?;
115        validate_positive("row.vai", self.vai)?;
116        validate_range("row.hypernasality", self.hypernasality, 0.0, 4.0)?;
117        validate_range(
118            "row.creaky_phonation_percent",
119            self.creaky_phonation_percent,
120            0.0,
121            100.0,
122        )?;
123        validate_nonempty("row.rhotic_realization", &self.rhotic_realization)?;
124        validate_positive(
125            "row.word_initial_stressed_prevocalic_t_vot_ms",
126            self.word_initial_stressed_prevocalic_t_vot_ms,
127        )?;
128        validate_range("row.breathiness", self.breathiness, 0.0, 100.0)?;
129        validate_range("row.roughness", self.roughness, 0.0, 100.0)?;
130        validate_positive("row.f0_pitch_span_semitones", self.f0_pitch_span_semitones)?;
131        validate_positive(
132            "row.articulation_rate_syllables_per_second",
133            self.articulation_rate_syllables_per_second,
134        )?;
135        validate_nonnegative("row.npvi_v", self.npvi_v)?;
136        validate_range(
137            "row.foreign_accentedness",
138            self.foreign_accentedness,
139            1.0,
140            9.0,
141        )?;
142        validate_range(
143            "row.unstressed_vowel_reduction_percent",
144            self.unstressed_vowel_reduction_percent,
145            0.0,
146            100.0,
147        )?;
148        validate_nonempty("row.lateral_realization", &self.lateral_realization)?;
149        validate_nonnegative(
150            "row.filled_pauses_per_100_words",
151            self.filled_pauses_per_100_words,
152        )?;
153        validate_nonempty("row.s_realization", &self.s_realization)?;
154        validate_range(
155            "row.lexical_stress_accuracy_percent",
156            self.lexical_stress_accuracy_percent,
157            0.0,
158            100.0,
159        )?;
160        validate_range(
161            "row.monophthongization_percent",
162            self.monophthongization_percent,
163            0.0,
164            100.0,
165        )?;
166        validate_range(
167            "row.consonant_cluster_reduction_percent",
168            self.consonant_cluster_reduction_percent,
169            0.0,
170            100.0,
171        )
172    }
173
174    pub(crate) fn numerical_values(&self) -> [f64; 20] {
175        [
176            self.perceived_age,
177            self.vocal_gender_presentation,
178            self.median_f0_hz,
179            self.formant_dispersion_hz,
180            self.vai,
181            self.hypernasality,
182            self.creaky_phonation_percent,
183            self.word_initial_stressed_prevocalic_t_vot_ms,
184            self.breathiness,
185            self.roughness,
186            self.f0_pitch_span_semitones,
187            self.articulation_rate_syllables_per_second,
188            self.npvi_v,
189            self.cefr.numerical_value(),
190            self.foreign_accentedness,
191            self.unstressed_vowel_reduction_percent,
192            self.filled_pauses_per_100_words,
193            self.lexical_stress_accuracy_percent,
194            self.monophthongization_percent,
195            self.consonant_cluster_reduction_percent,
196        ]
197    }
198
199    pub(crate) fn categorical_values(&self) -> [&str; 4] {
200        [
201            &self.accent_variety,
202            &self.rhotic_realization,
203            &self.lateral_realization,
204            &self.s_realization,
205        ]
206    }
207}
208
209#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
210pub struct CandidateEvidence {
211    pub speaker_id: String,
212    pub cost: f64,
213}
214
215#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
216pub struct IdentifyEvidence {
217    pub best: CandidateEvidence,
218    pub runner_up: Option<CandidateEvidence>,
219    pub background_population_cost: f64,
220    pub absolute_gap: f64,
221    pub runner_up_gap: Option<f64>,
222    pub confidence_score: f64,
223}
224
225#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
226pub struct IdentifyOutcome {
227    pub speaker_id: Option<String>,
228    pub evidence: Option<IdentifyEvidence>,
229}
230
231#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
232#[serde(rename_all = "snake_case")]
233pub enum TrainOutcome {
234    Added,
235    Unchanged,
236    Corrected,
237}
238
239#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
240#[serde(rename_all = "snake_case")]
241pub enum DeleteOutcome {
242    Deleted,
243    NotFound,
244}
245
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub enum Error {
248    Validation {
249        field: String,
250        message: String,
251    },
252    Conflict {
253        key: ObservationKey,
254        message: String,
255    },
256    UnsupportedSchema {
257        found: u32,
258    },
259    Storage(String),
260    CorruptStorage(String),
261}
262
263impl Error {
264    pub(crate) fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
265        Self::Validation {
266            field: field.into(),
267            message: message.into(),
268        }
269    }
270
271    pub(crate) fn conflict(key: &ObservationKey) -> Self {
272        Self::Conflict {
273            key: key.clone(),
274            message:
275                "observation key already has conflicting data or assignment; use train or delete"
276                    .to_owned(),
277        }
278    }
279
280    pub(crate) fn corrupt(message: impl Into<String>) -> Self {
281        Self::CorruptStorage(message.into())
282    }
283
284    pub fn code(&self) -> &'static str {
285        match self {
286            Self::Validation { .. } => "validation",
287            Self::Conflict { .. } => "conflict",
288            Self::UnsupportedSchema { .. } => "unsupported_schema",
289            Self::Storage(_) => "storage",
290            Self::CorruptStorage(_) => "corrupt_storage",
291        }
292    }
293}
294
295impl fmt::Display for Error {
296    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297        match self {
298            Self::Validation { field, message } => write!(formatter, "{field}: {message}"),
299            Self::Conflict { key, message } => write!(
300                formatter,
301                "{}:{}: {message}",
302                key.object_id, key.piece_index
303            ),
304            Self::UnsupportedSchema { found } => {
305                write!(formatter, "unsupported database schema version {found}")
306            }
307            Self::Storage(message) => write!(formatter, "storage error: {message}"),
308            Self::CorruptStorage(message) => write!(formatter, "corrupt storage: {message}"),
309        }
310    }
311}
312
313impl std::error::Error for Error {}
314
315impl From<rusqlite::Error> for Error {
316    fn from(error: rusqlite::Error) -> Self {
317        Self::Storage(error.to_string())
318    }
319}
320
321fn validate_nonempty(field: &str, value: &str) -> Result<(), Error> {
322    if value.trim().is_empty() {
323        Err(Error::validation(field, "must not be empty"))
324    } else {
325        Ok(())
326    }
327}
328
329fn validate_finite(field: &str, value: f64) -> Result<(), Error> {
330    if value.is_finite() {
331        Ok(())
332    } else {
333        Err(Error::validation(field, "must be finite"))
334    }
335}
336
337fn validate_positive(field: &str, value: f64) -> Result<(), Error> {
338    validate_finite(field, value)?;
339    if value > 0.0 {
340        Ok(())
341    } else {
342        Err(Error::validation(field, "must be positive"))
343    }
344}
345
346fn validate_nonnegative(field: &str, value: f64) -> Result<(), Error> {
347    validate_finite(field, value)?;
348    if value >= 0.0 {
349        Ok(())
350    } else {
351        Err(Error::validation(field, "must be nonnegative"))
352    }
353}
354
355fn validate_range(field: &str, value: f64, minimum: f64, maximum: f64) -> Result<(), Error> {
356    validate_finite(field, value)?;
357    if (minimum..=maximum).contains(&value) {
358        Ok(())
359    } else {
360        Err(Error::validation(
361            field,
362            format!("must be between {minimum} and {maximum} inclusive"),
363        ))
364    }
365}
366
367#[cfg(test)]
368pub(crate) mod tests {
369    use super::*;
370
371    pub(crate) fn sample_row() -> FeatureRow {
372        FeatureRow {
373            accent_variety: "General American".to_owned(),
374            perceived_age: 36.0,
375            vocal_gender_presentation: 45.0,
376            median_f0_hz: 155.0,
377            formant_dispersion_hz: 1100.0,
378            vai: 1.1,
379            hypernasality: 0.5,
380            creaky_phonation_percent: 8.0,
381            rhotic_realization: "rhotic".to_owned(),
382            word_initial_stressed_prevocalic_t_vot_ms: 62.0,
383            breathiness: 20.0,
384            roughness: 10.0,
385            f0_pitch_span_semitones: 9.0,
386            articulation_rate_syllables_per_second: 4.2,
387            npvi_v: 48.0,
388            cefr: Cefr::C1,
389            foreign_accentedness: 2.0,
390            unstressed_vowel_reduction_percent: 72.0,
391            lateral_realization: "alveolar".to_owned(),
392            filled_pauses_per_100_words: 2.5,
393            s_realization: "alveolar".to_owned(),
394            lexical_stress_accuracy_percent: 92.0,
395            monophthongization_percent: 4.0,
396            consonant_cluster_reduction_percent: 3.0,
397        }
398    }
399
400    pub(crate) fn sample_cohort() -> Cohort {
401        Cohort {
402            provider: "google".to_owned(),
403            model: "gemini-example".to_owned(),
404            prompt_version: "p1".to_owned(),
405            schema_version: "s1".to_owned(),
406            primary_language: "eng".to_owned(),
407        }
408    }
409
410    #[test]
411    fn validates_every_feature_slot() {
412        let valid = sample_row();
413        valid.validate().unwrap();
414
415        for index in 0..24 {
416            let mut row = valid.clone();
417            match index {
418                0 => row.accent_variety.clear(),
419                1 => row.perceived_age = f64::NAN,
420                2 => row.vocal_gender_presentation = f64::NAN,
421                3 => row.median_f0_hz = f64::NAN,
422                4 => row.formant_dispersion_hz = f64::NAN,
423                5 => row.vai = f64::NAN,
424                6 => row.hypernasality = f64::NAN,
425                7 => row.creaky_phonation_percent = f64::NAN,
426                8 => row.rhotic_realization.clear(),
427                9 => row.word_initial_stressed_prevocalic_t_vot_ms = f64::NAN,
428                10 => row.breathiness = f64::NAN,
429                11 => row.roughness = f64::NAN,
430                12 => row.f0_pitch_span_semitones = f64::NAN,
431                13 => row.articulation_rate_syllables_per_second = f64::NAN,
432                14 => row.npvi_v = f64::NAN,
433                15 => {
434                    let json = serde_json::to_string(&row).unwrap();
435                    let invalid = json.replace("\"C1\"", "\"D1\"");
436                    assert!(serde_json::from_str::<FeatureRow>(&invalid).is_err());
437                    continue;
438                }
439                16 => row.foreign_accentedness = f64::NAN,
440                17 => row.unstressed_vowel_reduction_percent = f64::NAN,
441                18 => row.lateral_realization.clear(),
442                19 => row.filled_pauses_per_100_words = f64::NAN,
443                20 => row.s_realization.clear(),
444                21 => row.lexical_stress_accuracy_percent = f64::NAN,
445                22 => row.monophthongization_percent = f64::NAN,
446                23 => row.consonant_cluster_reduction_percent = f64::NAN,
447                _ => unreachable!(),
448            }
449            assert!(row.validate().is_err(), "feature index {index}");
450        }
451    }
452
453    #[test]
454    fn enforces_scientific_ranges_and_identity_shape() {
455        let mut row = sample_row();
456        row.hypernasality = 4.1;
457        assert!(row.validate().is_err());
458
459        let mut row = sample_row();
460        row.foreign_accentedness = 0.9;
461        assert!(row.validate().is_err());
462
463        let mut row = sample_row();
464        row.creaky_phonation_percent = 100.1;
465        assert!(row.validate().is_err());
466
467        let mut row = sample_row();
468        row.breathiness = 0.0;
469        row.roughness = 100.0;
470        row.validate().unwrap();
471
472        let mut row = sample_row();
473        row.breathiness = -0.1;
474        assert!(row.validate().is_err());
475
476        let mut row = sample_row();
477        row.breathiness = 100.1;
478        assert!(row.validate().is_err());
479
480        let mut row = sample_row();
481        row.roughness = -0.1;
482        assert!(row.validate().is_err());
483
484        let mut row = sample_row();
485        row.roughness = 100.1;
486        assert!(row.validate().is_err());
487
488        let mut cohort = sample_cohort();
489        cohort.primary_language = "EN".to_owned();
490        assert!(cohort.validate().is_err());
491
492        let key = ObservationKey {
493            object_id: " ".to_owned(),
494            piece_index: 0,
495        };
496        assert!(key.validate().is_err());
497    }
498
499    #[test]
500    fn cefr_strings_and_numerical_order_are_canonical() {
501        let levels = [
502            (Cefr::A1, "A1", 1.0),
503            (Cefr::A2, "A2", 2.0),
504            (Cefr::B1, "B1", 3.0),
505            (Cefr::B2, "B2", 4.0),
506            (Cefr::C1, "C1", 5.0),
507            (Cefr::C2, "C2", 6.0),
508        ];
509
510        for (level, serialized, numerical) in levels {
511            assert_eq!(
512                serde_json::to_string(&level).unwrap(),
513                format!("\"{serialized}\"")
514            );
515            assert_eq!(level.numerical_value(), numerical);
516        }
517
518        let mut row = sample_row();
519        row.cefr = Cefr::B2;
520        assert_eq!(row.numerical_values()[13], 4.0);
521    }
522
523    #[test]
524    fn feature_json_keeps_declared_order_and_round_trips() {
525        let row = sample_row();
526        let json = serde_json::to_string(&row).unwrap();
527        assert!(json.find("accent_variety").unwrap() < json.find("perceived_age").unwrap());
528        assert!(
529            json.find("monophthongization_percent").unwrap()
530                < json.find("consonant_cluster_reduction_percent").unwrap()
531        );
532        assert_eq!(serde_json::from_str::<FeatureRow>(&json).unwrap(), row);
533    }
534}