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}