use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct ObservationKey {
pub object_id: String,
pub piece_index: u32,
}
impl ObservationKey {
pub(crate) fn validate(&self) -> Result<(), Error> {
validate_nonempty("key.object_id", &self.object_id)
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct Cohort {
pub provider: String,
pub model: String,
pub prompt_version: String,
pub schema_version: String,
pub primary_language: String,
}
impl Cohort {
pub(crate) fn validate(&self) -> Result<(), Error> {
validate_nonempty("cohort.provider", &self.provider)?;
validate_nonempty("cohort.model", &self.model)?;
validate_nonempty("cohort.prompt_version", &self.prompt_version)?;
validate_nonempty("cohort.schema_version", &self.schema_version)?;
if self.primary_language.len() != 3
|| !self
.primary_language
.bytes()
.all(|byte| byte.is_ascii_lowercase())
{
return Err(Error::validation(
"cohort.primary_language",
"must be a lowercase ISO 639-3 code",
));
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub enum Cefr {
#[serde(rename = "A1")]
A1,
#[serde(rename = "A2")]
A2,
#[serde(rename = "B1")]
B1,
#[serde(rename = "B2")]
B2,
#[serde(rename = "C1")]
C1,
#[serde(rename = "C2")]
C2,
}
impl Cefr {
pub(crate) const fn numerical_value(self) -> f64 {
match self {
Self::A1 => 1.0,
Self::A2 => 2.0,
Self::B1 => 3.0,
Self::B2 => 4.0,
Self::C1 => 5.0,
Self::C2 => 6.0,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct FeatureRow {
pub accent_variety: String,
pub perceived_age: f64,
pub vocal_gender_presentation: f64,
pub median_f0_hz: f64,
pub formant_dispersion_hz: f64,
pub vai: f64,
pub hypernasality: f64,
pub creaky_phonation_percent: f64,
pub rhotic_realization: String,
pub word_initial_stressed_prevocalic_t_vot_ms: f64,
pub breathiness: f64,
pub roughness: f64,
pub f0_pitch_span_semitones: f64,
pub articulation_rate_syllables_per_second: f64,
pub npvi_v: f64,
pub cefr: Cefr,
pub foreign_accentedness: f64,
pub unstressed_vowel_reduction_percent: f64,
pub lateral_realization: String,
pub filled_pauses_per_100_words: f64,
pub s_realization: String,
pub lexical_stress_accuracy_percent: f64,
pub monophthongization_percent: f64,
pub consonant_cluster_reduction_percent: f64,
}
impl FeatureRow {
pub(crate) fn validate(&self) -> Result<(), Error> {
validate_nonempty("row.accent_variety", &self.accent_variety)?;
validate_positive("row.perceived_age", self.perceived_age)?;
validate_range(
"row.vocal_gender_presentation",
self.vocal_gender_presentation,
0.0,
100.0,
)?;
validate_positive("row.median_f0_hz", self.median_f0_hz)?;
validate_positive("row.formant_dispersion_hz", self.formant_dispersion_hz)?;
validate_positive("row.vai", self.vai)?;
validate_range("row.hypernasality", self.hypernasality, 0.0, 4.0)?;
validate_range(
"row.creaky_phonation_percent",
self.creaky_phonation_percent,
0.0,
100.0,
)?;
validate_nonempty("row.rhotic_realization", &self.rhotic_realization)?;
validate_positive(
"row.word_initial_stressed_prevocalic_t_vot_ms",
self.word_initial_stressed_prevocalic_t_vot_ms,
)?;
validate_range("row.breathiness", self.breathiness, 0.0, 100.0)?;
validate_range("row.roughness", self.roughness, 0.0, 100.0)?;
validate_positive("row.f0_pitch_span_semitones", self.f0_pitch_span_semitones)?;
validate_positive(
"row.articulation_rate_syllables_per_second",
self.articulation_rate_syllables_per_second,
)?;
validate_nonnegative("row.npvi_v", self.npvi_v)?;
validate_range(
"row.foreign_accentedness",
self.foreign_accentedness,
1.0,
9.0,
)?;
validate_range(
"row.unstressed_vowel_reduction_percent",
self.unstressed_vowel_reduction_percent,
0.0,
100.0,
)?;
validate_nonempty("row.lateral_realization", &self.lateral_realization)?;
validate_nonnegative(
"row.filled_pauses_per_100_words",
self.filled_pauses_per_100_words,
)?;
validate_nonempty("row.s_realization", &self.s_realization)?;
validate_range(
"row.lexical_stress_accuracy_percent",
self.lexical_stress_accuracy_percent,
0.0,
100.0,
)?;
validate_range(
"row.monophthongization_percent",
self.monophthongization_percent,
0.0,
100.0,
)?;
validate_range(
"row.consonant_cluster_reduction_percent",
self.consonant_cluster_reduction_percent,
0.0,
100.0,
)
}
pub(crate) fn numerical_values(&self) -> [f64; 20] {
[
self.perceived_age,
self.vocal_gender_presentation,
self.median_f0_hz,
self.formant_dispersion_hz,
self.vai,
self.hypernasality,
self.creaky_phonation_percent,
self.word_initial_stressed_prevocalic_t_vot_ms,
self.breathiness,
self.roughness,
self.f0_pitch_span_semitones,
self.articulation_rate_syllables_per_second,
self.npvi_v,
self.cefr.numerical_value(),
self.foreign_accentedness,
self.unstressed_vowel_reduction_percent,
self.filled_pauses_per_100_words,
self.lexical_stress_accuracy_percent,
self.monophthongization_percent,
self.consonant_cluster_reduction_percent,
]
}
pub(crate) fn categorical_values(&self) -> [&str; 4] {
[
&self.accent_variety,
&self.rhotic_realization,
&self.lateral_realization,
&self.s_realization,
]
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct CandidateEvidence {
pub speaker_id: String,
pub cost: f64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct IdentifyEvidence {
pub best: CandidateEvidence,
pub runner_up: Option<CandidateEvidence>,
pub background_population_cost: f64,
pub absolute_gap: f64,
pub runner_up_gap: Option<f64>,
pub confidence_score: f64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct IdentifyOutcome {
pub speaker_id: Option<String>,
pub evidence: Option<IdentifyEvidence>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TrainOutcome {
Added,
Unchanged,
Corrected,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DeleteOutcome {
Deleted,
NotFound,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Error {
Validation {
field: String,
message: String,
},
Conflict {
key: ObservationKey,
message: String,
},
UnsupportedSchema {
found: u32,
},
Storage(String),
CorruptStorage(String),
}
impl Error {
pub(crate) fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
Self::Validation {
field: field.into(),
message: message.into(),
}
}
pub(crate) fn conflict(key: &ObservationKey) -> Self {
Self::Conflict {
key: key.clone(),
message:
"observation key already has conflicting data or assignment; use train or delete"
.to_owned(),
}
}
pub(crate) fn corrupt(message: impl Into<String>) -> Self {
Self::CorruptStorage(message.into())
}
pub fn code(&self) -> &'static str {
match self {
Self::Validation { .. } => "validation",
Self::Conflict { .. } => "conflict",
Self::UnsupportedSchema { .. } => "unsupported_schema",
Self::Storage(_) => "storage",
Self::CorruptStorage(_) => "corrupt_storage",
}
}
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Validation { field, message } => write!(formatter, "{field}: {message}"),
Self::Conflict { key, message } => write!(
formatter,
"{}:{}: {message}",
key.object_id, key.piece_index
),
Self::UnsupportedSchema { found } => {
write!(formatter, "unsupported database schema version {found}")
}
Self::Storage(message) => write!(formatter, "storage error: {message}"),
Self::CorruptStorage(message) => write!(formatter, "corrupt storage: {message}"),
}
}
}
impl std::error::Error for Error {}
impl From<rusqlite::Error> for Error {
fn from(error: rusqlite::Error) -> Self {
Self::Storage(error.to_string())
}
}
fn validate_nonempty(field: &str, value: &str) -> Result<(), Error> {
if value.trim().is_empty() {
Err(Error::validation(field, "must not be empty"))
} else {
Ok(())
}
}
fn validate_finite(field: &str, value: f64) -> Result<(), Error> {
if value.is_finite() {
Ok(())
} else {
Err(Error::validation(field, "must be finite"))
}
}
fn validate_positive(field: &str, value: f64) -> Result<(), Error> {
validate_finite(field, value)?;
if value > 0.0 {
Ok(())
} else {
Err(Error::validation(field, "must be positive"))
}
}
fn validate_nonnegative(field: &str, value: f64) -> Result<(), Error> {
validate_finite(field, value)?;
if value >= 0.0 {
Ok(())
} else {
Err(Error::validation(field, "must be nonnegative"))
}
}
fn validate_range(field: &str, value: f64, minimum: f64, maximum: f64) -> Result<(), Error> {
validate_finite(field, value)?;
if (minimum..=maximum).contains(&value) {
Ok(())
} else {
Err(Error::validation(
field,
format!("must be between {minimum} and {maximum} inclusive"),
))
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn sample_row() -> FeatureRow {
FeatureRow {
accent_variety: "General American".to_owned(),
perceived_age: 36.0,
vocal_gender_presentation: 45.0,
median_f0_hz: 155.0,
formant_dispersion_hz: 1100.0,
vai: 1.1,
hypernasality: 0.5,
creaky_phonation_percent: 8.0,
rhotic_realization: "rhotic".to_owned(),
word_initial_stressed_prevocalic_t_vot_ms: 62.0,
breathiness: 20.0,
roughness: 10.0,
f0_pitch_span_semitones: 9.0,
articulation_rate_syllables_per_second: 4.2,
npvi_v: 48.0,
cefr: Cefr::C1,
foreign_accentedness: 2.0,
unstressed_vowel_reduction_percent: 72.0,
lateral_realization: "alveolar".to_owned(),
filled_pauses_per_100_words: 2.5,
s_realization: "alveolar".to_owned(),
lexical_stress_accuracy_percent: 92.0,
monophthongization_percent: 4.0,
consonant_cluster_reduction_percent: 3.0,
}
}
pub(crate) fn sample_cohort() -> Cohort {
Cohort {
provider: "google".to_owned(),
model: "gemini-example".to_owned(),
prompt_version: "p1".to_owned(),
schema_version: "s1".to_owned(),
primary_language: "eng".to_owned(),
}
}
#[test]
fn validates_every_feature_slot() {
let valid = sample_row();
valid.validate().unwrap();
for index in 0..24 {
let mut row = valid.clone();
match index {
0 => row.accent_variety.clear(),
1 => row.perceived_age = f64::NAN,
2 => row.vocal_gender_presentation = f64::NAN,
3 => row.median_f0_hz = f64::NAN,
4 => row.formant_dispersion_hz = f64::NAN,
5 => row.vai = f64::NAN,
6 => row.hypernasality = f64::NAN,
7 => row.creaky_phonation_percent = f64::NAN,
8 => row.rhotic_realization.clear(),
9 => row.word_initial_stressed_prevocalic_t_vot_ms = f64::NAN,
10 => row.breathiness = f64::NAN,
11 => row.roughness = f64::NAN,
12 => row.f0_pitch_span_semitones = f64::NAN,
13 => row.articulation_rate_syllables_per_second = f64::NAN,
14 => row.npvi_v = f64::NAN,
15 => {
let json = serde_json::to_string(&row).unwrap();
let invalid = json.replace("\"C1\"", "\"D1\"");
assert!(serde_json::from_str::<FeatureRow>(&invalid).is_err());
continue;
}
16 => row.foreign_accentedness = f64::NAN,
17 => row.unstressed_vowel_reduction_percent = f64::NAN,
18 => row.lateral_realization.clear(),
19 => row.filled_pauses_per_100_words = f64::NAN,
20 => row.s_realization.clear(),
21 => row.lexical_stress_accuracy_percent = f64::NAN,
22 => row.monophthongization_percent = f64::NAN,
23 => row.consonant_cluster_reduction_percent = f64::NAN,
_ => unreachable!(),
}
assert!(row.validate().is_err(), "feature index {index}");
}
}
#[test]
fn enforces_scientific_ranges_and_identity_shape() {
let mut row = sample_row();
row.hypernasality = 4.1;
assert!(row.validate().is_err());
let mut row = sample_row();
row.foreign_accentedness = 0.9;
assert!(row.validate().is_err());
let mut row = sample_row();
row.creaky_phonation_percent = 100.1;
assert!(row.validate().is_err());
let mut row = sample_row();
row.breathiness = 0.0;
row.roughness = 100.0;
row.validate().unwrap();
let mut row = sample_row();
row.breathiness = -0.1;
assert!(row.validate().is_err());
let mut row = sample_row();
row.breathiness = 100.1;
assert!(row.validate().is_err());
let mut row = sample_row();
row.roughness = -0.1;
assert!(row.validate().is_err());
let mut row = sample_row();
row.roughness = 100.1;
assert!(row.validate().is_err());
let mut cohort = sample_cohort();
cohort.primary_language = "EN".to_owned();
assert!(cohort.validate().is_err());
let key = ObservationKey {
object_id: " ".to_owned(),
piece_index: 0,
};
assert!(key.validate().is_err());
}
#[test]
fn cefr_strings_and_numerical_order_are_canonical() {
let levels = [
(Cefr::A1, "A1", 1.0),
(Cefr::A2, "A2", 2.0),
(Cefr::B1, "B1", 3.0),
(Cefr::B2, "B2", 4.0),
(Cefr::C1, "C1", 5.0),
(Cefr::C2, "C2", 6.0),
];
for (level, serialized, numerical) in levels {
assert_eq!(
serde_json::to_string(&level).unwrap(),
format!("\"{serialized}\"")
);
assert_eq!(level.numerical_value(), numerical);
}
let mut row = sample_row();
row.cefr = Cefr::B2;
assert_eq!(row.numerical_values()[13], 4.0);
}
#[test]
fn feature_json_keeps_declared_order_and_round_trips() {
let row = sample_row();
let json = serde_json::to_string(&row).unwrap();
assert!(json.find("accent_variety").unwrap() < json.find("perceived_age").unwrap());
assert!(
json.find("monophthongization_percent").unwrap()
< json.find("consonant_cluster_reduction_percent").unwrap()
);
assert_eq!(serde_json::from_str::<FeatureRow>(&json).unwrap(), row);
}
}