1use std::{
4 collections::{BTreeSet, HashMap, HashSet},
5 sync::Arc,
6};
7
8use anyhow::{Context, ensure};
9use chrono::{DateTime, Utc};
10pub use kcode_speech_classification::{Cefr, FeatureRow, ObservationKey};
11use kcode_speech_classification::{Cohort, IdentifyEvidence, SpeechClassifier, TrainOutcome};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use uuid::Uuid;
15
16pub const CLASSIFIER_PROVIDER: &str = "google";
18pub const CLASSIFIER_MODEL: &str = "gemini-3.1-pro-preview";
20pub const CLASSIFIER_PROMPT_VERSION: &str = "gemini-speaker-prompt-v0.1";
22pub const CLASSIFIER_SCHEMA_VERSION: &str = "gemini-speaker-features-v0.1";
24
25const READ_ONLY_IDENTIFY_THRESHOLD: f64 = 1e308;
26const FEATURE_FIELDS: [&str; 24] = [
27 "accent_variety",
28 "perceived_age",
29 "vocal_gender_presentation",
30 "median_f0_hz",
31 "formant_dispersion_hz",
32 "vai",
33 "hypernasality",
34 "creaky_phonation_percent",
35 "rhotic_realization",
36 "word_initial_stressed_prevocalic_t_vot_ms",
37 "breathiness",
38 "roughness",
39 "f0_pitch_span_semitones",
40 "articulation_rate_syllables_per_second",
41 "npvi_v",
42 "cefr",
43 "foreign_accentedness",
44 "unstressed_vowel_reduction_percent",
45 "lateral_realization",
46 "filled_pauses_per_100_words",
47 "s_realization",
48 "lexical_stress_accuracy_percent",
49 "monophthongization_percent",
50 "consonant_cluster_reduction_percent",
51];
52
53#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
55#[serde(deny_unknown_fields)]
56pub struct ParsedUtterance {
57 pub speaker: String,
59 pub language: String,
61 pub original_text: String,
63 pub english_translation: String,
65 pub corrected_natural_text: Option<String>,
67 pub coaching: Vec<String>,
69 pub annotations: Vec<String>,
71}
72
73#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
75#[serde(deny_unknown_fields)]
76pub struct ParsedSpeaker {
77 pub local_label: String,
79 pub primary_language: String,
81 pub feature_row: FeatureRow,
83}
84
85#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
87#[serde(deny_unknown_fields)]
88pub struct ParsedChunk {
89 pub utterances: Vec<ParsedUtterance>,
91 pub notes: Vec<String>,
93 pub clip_valid: bool,
95 pub clip_validity_reason: Option<String>,
97 pub speakers: Vec<ParsedSpeaker>,
99}
100
101#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
103pub struct CandidateMapping {
104 pub full_name: String,
106 pub cost: f64,
108 pub confidence: f64,
110 pub runner_up_full_name: Option<String>,
112 pub runner_up_cost: Option<f64>,
114 pub background_population_cost: f64,
116}
117
118#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
120pub struct CorrectionObservation {
121 pub local_label: String,
123 pub speaker_ordinal: u32,
125 pub observation_key: ObservationKey,
127 pub candidate: Option<CandidateMapping>,
129 pub identified_full_name: Option<String>,
131 pub confirmed_full_name: Option<String>,
133}
134
135#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
137pub struct CorrectionChunk {
138 pub chunk_index: usize,
140 pub chunk_count: usize,
142 pub audio_start_ms: u64,
144 pub audio_end_ms: u64,
146 pub raw_gemini_response: String,
148 pub parsed: ParsedChunk,
150 pub observations: Vec<CorrectionObservation>,
152 pub clean: bool,
154}
155
156#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ConfirmationState {
160 Unconfirmed,
162 AutomaticallyTrained,
164 Confirmed,
166}
167
168#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
170pub struct CorrectionPacket {
171 pub recording_id: Uuid,
173 pub user_id: String,
175 pub sha256: String,
177 pub original_filename: String,
179 pub size_bytes: u64,
181 pub recorded_at: DateTime<Utc>,
183 pub clean: bool,
185 pub chunk_count: usize,
187 pub chunks: Vec<CorrectionChunk>,
189 pub confirmation_state: ConfirmationState,
191}
192
193#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
195pub struct ObservationConfirmation {
196 pub observation_key: ObservationKey,
198 pub confirmed_full_name: String,
200}
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
204pub struct RecordingConfirmation {
205 pub recording_id: Uuid,
207 pub observations: Vec<ObservationConfirmation>,
209}
210
211#[derive(Clone)]
212pub(crate) struct ClassificationContext {
213 pub(crate) recording_id: Uuid,
214 pub(crate) user_id: String,
215 pub(crate) sha256: String,
216 pub(crate) original_filename: String,
217 pub(crate) size_bytes: u64,
218 pub(crate) recorded_at: DateTime<Utc>,
219 pub(crate) classifier: Arc<SpeechClassifier>,
220}
221
222pub(crate) fn parse_and_validate_chunk(
223 response: &str,
224 chunk_duration_seconds: f64,
225) -> anyhow::Result<ParsedChunk> {
226 ensure!(
227 !response.trim().is_empty(),
228 "GPT parser returned an empty response"
229 );
230 let value: Value =
231 serde_json::from_str(response).context("GPT parser response is not one JSON value")?;
232 validate_feature_row_shapes(&value)?;
233 let mut parsed: ParsedChunk =
234 serde_json::from_value(value).context("GPT parser JSON has an invalid typed shape")?;
235 validate_parsed_chunk(&mut parsed, chunk_duration_seconds)?;
236 Ok(parsed)
237}
238
239pub(crate) fn validate_parsed_chunk(
240 parsed: &mut ParsedChunk,
241 chunk_duration_seconds: f64,
242) -> anyhow::Result<()> {
243 ensure!(
244 chunk_duration_seconds.is_finite() && chunk_duration_seconds > 0.0,
245 "chunk duration must be finite and positive"
246 );
247 ensure!(
248 parsed.notes.iter().all(|note| !note.trim().is_empty()),
249 "chunk notes must not contain empty entries"
250 );
251 match (parsed.clip_valid, parsed.clip_validity_reason.as_deref()) {
252 (true, None) => {}
253 (false, Some(reason)) if !reason.trim().is_empty() => {}
254 (true, Some(_)) => anyhow::bail!("a valid clip must not carry an invalidity reason"),
255 (false, _) => anyhow::bail!("an invalid clip requires a brief reason"),
256 }
257
258 parsed
259 .speakers
260 .sort_by(|left, right| left.local_label.cmp(&right.local_label));
261 let mut labels = HashSet::new();
262 for speaker in &parsed.speakers {
263 ensure!(
264 !speaker.local_label.trim().is_empty(),
265 "speaker labels must not be empty"
266 );
267 ensure!(
268 labels.insert(speaker.local_label.clone()),
269 "duplicate speaker label {:?}",
270 speaker.local_label
271 );
272 validate_iso_639_3(&speaker.primary_language)
273 .with_context(|| format!("speaker {} primary language", speaker.local_label))?;
274 validate_feature_row(&speaker.feature_row)
275 .with_context(|| format!("speaker {} feature row", speaker.local_label))?;
276 }
277
278 let mut referenced = HashSet::new();
279 for (index, utterance) in parsed.utterances.iter().enumerate() {
280 ensure!(
281 labels.contains(&utterance.speaker),
282 "utterance {index} references unknown speaker {:?}",
283 utterance.speaker
284 );
285 referenced.insert(utterance.speaker.clone());
286 validate_iso_639_3(&utterance.language)
287 .with_context(|| format!("utterance {index} language"))?;
288 ensure!(
289 !utterance.original_text.trim().is_empty(),
290 "utterance {index} original text must not be empty"
291 );
292 if utterance.language == "eng" {
293 ensure!(
294 utterance.english_translation.is_empty(),
295 "English utterance {index} must use an empty translation"
296 );
297 } else {
298 ensure!(
299 !utterance.english_translation.trim().is_empty(),
300 "non-English utterance {index} requires a complete English translation"
301 );
302 }
303 if let Some(corrected) = utterance.corrected_natural_text.as_deref() {
304 ensure!(
305 !corrected.trim().is_empty(),
306 "utterance {index} corrected text must not be empty"
307 );
308 }
309 ensure!(
310 utterance
311 .coaching
312 .iter()
313 .chain(&utterance.annotations)
314 .all(|entry| !entry.trim().is_empty()),
315 "utterance {index} notes must not contain empty entries"
316 );
317 }
318
319 ensure!(
320 parsed
321 .speakers
322 .iter()
323 .all(|speaker| referenced.contains(&speaker.local_label)),
324 "every speaker row must be referenced by at least one utterance"
325 );
326 if parsed.clip_valid {
327 ensure!(
328 !parsed.speakers.is_empty(),
329 "a valid clip must contain at least one speaker row"
330 );
331 }
332 Ok(())
333}
334
335pub(crate) fn classify_speakers(
336 context: &ClassificationContext,
337 chunk_index: usize,
338 parsed: &ParsedChunk,
339) -> anyhow::Result<(Vec<CorrectionObservation>, bool)> {
340 let mut observations = Vec::with_capacity(parsed.speakers.len());
341 for (ordinal, speaker) in parsed.speakers.iter().enumerate() {
342 let speaker_ordinal = u32::try_from(ordinal)
343 .context("chunk has more speakers than the key schema supports")?;
344 let cohort = cohort(&speaker.primary_language);
345 let probe_key = ObservationKey {
346 object_id: probe_object_id(context.recording_id, chunk_index),
347 piece_index: speaker_ordinal,
348 };
349 let outcome = context
350 .classifier
351 .identify(
352 probe_key.clone(),
353 cohort,
354 speaker.feature_row.clone(),
355 READ_ONLY_IDENTIFY_THRESHOLD,
356 )
357 .with_context(|| {
358 format!(
359 "read-only identity scoring failed for chunk {chunk_index} speaker {}",
360 speaker.local_label
361 )
362 })?;
363 if outcome.speaker_id.is_some() {
364 context
365 .classifier
366 .delete(probe_key)
367 .context("removing an unexpectedly accepted read-only probe")?;
368 }
369 let candidate = outcome.evidence.as_ref().map(candidate_mapping);
370 let identified_full_name = candidate.as_ref().map(|value| value.full_name.clone());
371 observations.push(CorrectionObservation {
372 local_label: speaker.local_label.clone(),
373 speaker_ordinal,
374 observation_key: ObservationKey {
375 object_id: training_object_id(context.recording_id, chunk_index),
376 piece_index: speaker_ordinal,
377 },
378 candidate,
379 identified_full_name,
380 confirmed_full_name: None,
381 });
382 }
383 let clean = chunk_is_clean(parsed.clip_valid, &observations);
384 Ok((observations, clean))
385}
386
387pub(crate) fn unclassified_observations(
388 recording_id: Uuid,
389 chunk_index: usize,
390 parsed: &ParsedChunk,
391) -> anyhow::Result<Vec<CorrectionObservation>> {
392 parsed
393 .speakers
394 .iter()
395 .enumerate()
396 .map(|(ordinal, speaker)| {
397 let speaker_ordinal = u32::try_from(ordinal)
398 .context("chunk has more speakers than the key schema supports")?;
399 Ok(CorrectionObservation {
400 local_label: speaker.local_label.clone(),
401 speaker_ordinal,
402 observation_key: ObservationKey {
403 object_id: training_object_id(recording_id, chunk_index),
404 piece_index: speaker_ordinal,
405 },
406 candidate: None,
407 identified_full_name: None,
408 confirmed_full_name: None,
409 })
410 })
411 .collect()
412}
413
414pub(crate) fn chunk_is_clean(clip_valid: bool, observations: &[CorrectionObservation]) -> bool {
415 if !clip_valid || observations.is_empty() {
416 return false;
417 }
418 let mut names = HashSet::new();
419 observations.iter().all(|observation| {
420 observation.candidate.as_ref().is_some_and(|candidate| {
421 candidate.confidence > 0.0
422 && candidate.cost < candidate.background_population_cost
423 && candidate
424 .runner_up_cost
425 .is_some_and(|cost| cost > candidate.background_population_cost)
426 && names.insert(candidate.full_name.as_str())
427 })
428 })
429}
430
431pub(crate) fn build_packet(
432 context: &ClassificationContext,
433 chunks: Vec<CorrectionChunk>,
434) -> anyhow::Result<CorrectionPacket> {
435 ensure!(!chunks.is_empty(), "correction packet has no chunks");
436 let chunk_count = chunks.len();
437 ensure!(
438 chunks.iter().enumerate().all(|(index, chunk)| {
439 chunk.chunk_index == index
440 && chunk.chunk_count == chunk_count
441 && chunk.audio_end_ms > chunk.audio_start_ms
442 && chunk.observations.len() == chunk.parsed.speakers.len()
443 }),
444 "correction packet chunks are not one complete chronological plan"
445 );
446 let clean = chunks.iter().all(|chunk| chunk.clean);
447 Ok(CorrectionPacket {
448 recording_id: context.recording_id,
449 user_id: context.user_id.clone(),
450 sha256: context.sha256.clone(),
451 original_filename: context.original_filename.clone(),
452 size_bytes: context.size_bytes,
453 recorded_at: context.recorded_at,
454 clean,
455 chunk_count,
456 chunks,
457 confirmation_state: ConfirmationState::Unconfirmed,
458 })
459}
460
461pub(crate) fn train_clean_packet(
462 classifier: &SpeechClassifier,
463 packet: &mut CorrectionPacket,
464) -> anyhow::Result<()> {
465 if !packet.clean {
466 ensure!(
467 packet.confirmation_state == ConfirmationState::Unconfirmed,
468 "unclean packet unexpectedly claims retained training"
469 );
470 return Ok(());
471 }
472
473 let mut added = Vec::new();
474 for chunk in &packet.chunks {
475 for observation in &chunk.observations {
476 let speaker = speaker_for_observation(chunk, observation)?;
477 let full_name = observation
478 .identified_full_name
479 .as_deref()
480 .context("clean observation omitted its identified full name")?;
481 match classifier.train(
482 observation.observation_key.clone(),
483 cohort(&speaker.primary_language),
484 speaker.feature_row.clone(),
485 full_name.to_owned(),
486 ) {
487 Ok(TrainOutcome::Added) => added.push(observation.observation_key.clone()),
488 Ok(TrainOutcome::Unchanged | TrainOutcome::Corrected) => {}
489 Err(error) => {
490 let rollback_errors = rollback_added(classifier, &added);
491 if rollback_errors.is_empty() {
492 anyhow::bail!("automatic identity training failed: {error}");
493 }
494 anyhow::bail!(
495 "automatic identity training failed: {error}; rollback also failed: {}",
496 rollback_errors.join("; ")
497 );
498 }
499 }
500 }
501 }
502 packet.confirmation_state = ConfirmationState::AutomaticallyTrained;
503 Ok(())
504}
505
506pub(crate) fn validate_confirmation_coverage(
507 packet: &CorrectionPacket,
508 confirmation: &RecordingConfirmation,
509) -> Result<(), String> {
510 if confirmation.recording_id != packet.recording_id {
511 return Err("Confirmation recording ID does not match the packet.".into());
512 }
513
514 let known = packet
515 .chunks
516 .iter()
517 .flat_map(|chunk| &chunk.observations)
518 .map(|observation| key_tuple(&observation.observation_key))
519 .collect::<BTreeSet<_>>();
520 if known.is_empty() {
521 return Err("The correction packet contains no speaker observations.".into());
522 }
523
524 let mut supplied = BTreeSet::new();
525 for observation in &confirmation.observations {
526 if observation.confirmed_full_name.trim().is_empty()
527 || observation.confirmed_full_name.chars().count() > 512
528 {
529 return Err("Confirmed full names must contain between 1 and 512 characters.".into());
530 }
531 if !supplied.insert(key_tuple(&observation.observation_key)) {
532 return Err("Confirmation contains a duplicate observation key.".into());
533 }
534 }
535 if supplied != known {
536 return Err(
537 "Confirmation must cover every known observation exactly once, with no extras.".into(),
538 );
539 }
540 Ok(())
541}
542
543pub(crate) fn apply_confirmations(
544 classifier: &SpeechClassifier,
545 packet: &mut CorrectionPacket,
546 confirmation: &RecordingConfirmation,
547) -> anyhow::Result<()> {
548 validate_confirmation_coverage(packet, confirmation).map_err(anyhow::Error::msg)?;
549 let assignments = confirmation
550 .observations
551 .iter()
552 .map(|entry| {
553 (
554 key_tuple(&entry.observation_key),
555 entry.confirmed_full_name.trim().to_owned(),
556 )
557 })
558 .collect::<HashMap<_, _>>();
559
560 #[derive(Clone)]
561 struct Target {
562 chunk_position: usize,
563 observation_position: usize,
564 key: ObservationKey,
565 cohort: Cohort,
566 row: FeatureRow,
567 new_name: String,
568 old_name: Option<String>,
569 }
570
571 let mut targets = Vec::new();
572 for (chunk_position, chunk) in packet.chunks.iter().enumerate() {
573 for (observation_position, observation) in chunk.observations.iter().enumerate() {
574 let speaker = speaker_for_observation(chunk, observation)?;
575 targets.push(Target {
576 chunk_position,
577 observation_position,
578 key: observation.observation_key.clone(),
579 cohort: cohort(&speaker.primary_language),
580 row: speaker.feature_row.clone(),
581 new_name: assignments
582 .get(&key_tuple(&observation.observation_key))
583 .context("validated confirmation assignment disappeared")?
584 .clone(),
585 old_name: retained_name(packet.confirmation_state, observation),
586 });
587 }
588 }
589
590 let mut applied = Vec::<(Target, TrainOutcome)>::new();
591 for target in targets {
592 match classifier.train(
593 target.key.clone(),
594 target.cohort.clone(),
595 target.row.clone(),
596 target.new_name.clone(),
597 ) {
598 Ok(outcome) => applied.push((target, outcome)),
599 Err(error) => {
600 let mut rollback_errors = Vec::new();
601 for (previous, outcome) in applied.iter().rev() {
602 let rollback = if let Some(old_name) = &previous.old_name {
603 classifier
604 .train(
605 previous.key.clone(),
606 previous.cohort.clone(),
607 previous.row.clone(),
608 old_name.clone(),
609 )
610 .map(|_| ())
611 } else if *outcome == TrainOutcome::Added {
612 classifier.delete(previous.key.clone()).map(|_| ())
613 } else {
614 Ok(())
615 };
616 if let Err(rollback_error) = rollback {
617 rollback_errors.push(rollback_error.to_string());
618 }
619 }
620 if rollback_errors.is_empty() {
621 anyhow::bail!("applying identity confirmations failed: {error}");
622 }
623 anyhow::bail!(
624 "applying identity confirmations failed: {error}; rollback also failed: {}",
625 rollback_errors.join("; ")
626 );
627 }
628 }
629 }
630
631 for (target, _) in applied {
632 packet.chunks[target.chunk_position].observations[target.observation_position]
633 .confirmed_full_name = Some(target.new_name);
634 }
635 packet.confirmation_state = ConfirmationState::Confirmed;
636 Ok(())
637}
638
639pub(crate) fn restore_packet_training(
640 classifier: &SpeechClassifier,
641 packet: &CorrectionPacket,
642) -> Vec<String> {
643 let mut errors = Vec::new();
644 for chunk in packet.chunks.iter().rev() {
645 for observation in chunk.observations.iter().rev() {
646 let result = match retained_name(packet.confirmation_state, observation) {
647 Some(name) => speaker_for_observation(chunk, observation).and_then(|speaker| {
648 classifier
649 .train(
650 observation.observation_key.clone(),
651 cohort(&speaker.primary_language),
652 speaker.feature_row.clone(),
653 name,
654 )
655 .map(|_| ())
656 .map_err(anyhow::Error::from)
657 }),
658 None => classifier
659 .delete(observation.observation_key.clone())
660 .map(|_| ())
661 .map_err(anyhow::Error::from),
662 };
663 if let Err(error) = result {
664 errors.push(error.to_string());
665 }
666 }
667 }
668 errors
669}
670
671pub(crate) fn training_object_id(recording_id: Uuid, chunk_index: usize) -> String {
672 format!("kcode-audio-ingress/recording/{recording_id}/chunk/{chunk_index}")
673}
674
675fn probe_object_id(recording_id: Uuid, chunk_index: usize) -> String {
676 format!("kcode-audio-ingress/probe/{recording_id}/chunk/{chunk_index}")
677}
678
679fn cohort(primary_language: &str) -> Cohort {
680 Cohort {
681 provider: CLASSIFIER_PROVIDER.into(),
682 model: CLASSIFIER_MODEL.into(),
683 prompt_version: CLASSIFIER_PROMPT_VERSION.into(),
684 schema_version: CLASSIFIER_SCHEMA_VERSION.into(),
685 primary_language: primary_language.into(),
686 }
687}
688
689fn candidate_mapping(evidence: &IdentifyEvidence) -> CandidateMapping {
690 CandidateMapping {
691 full_name: evidence.best.speaker_id.clone(),
692 cost: evidence.best.cost,
693 confidence: evidence.confidence_score,
694 runner_up_full_name: evidence
695 .runner_up
696 .as_ref()
697 .map(|candidate| candidate.speaker_id.clone()),
698 runner_up_cost: evidence.runner_up.as_ref().map(|candidate| candidate.cost),
699 background_population_cost: evidence.background_population_cost,
700 }
701}
702
703fn speaker_for_observation<'a>(
704 chunk: &'a CorrectionChunk,
705 observation: &CorrectionObservation,
706) -> anyhow::Result<&'a ParsedSpeaker> {
707 let speaker = chunk
708 .parsed
709 .speakers
710 .get(observation.speaker_ordinal as usize)
711 .context("observation ordinal is outside the parsed speaker rows")?;
712 ensure!(
713 speaker.local_label == observation.local_label,
714 "observation label does not match its parsed speaker row"
715 );
716 Ok(speaker)
717}
718
719fn retained_name(state: ConfirmationState, observation: &CorrectionObservation) -> Option<String> {
720 match state {
721 ConfirmationState::Unconfirmed => None,
722 ConfirmationState::AutomaticallyTrained => observation.identified_full_name.clone(),
723 ConfirmationState::Confirmed => observation.confirmed_full_name.clone(),
724 }
725}
726
727fn rollback_added(classifier: &SpeechClassifier, keys: &[ObservationKey]) -> Vec<String> {
728 let mut errors = Vec::new();
729 for key in keys.iter().rev() {
730 if let Err(error) = classifier.delete(key.clone()) {
731 errors.push(error.to_string());
732 }
733 }
734 errors
735}
736
737fn key_tuple(key: &ObservationKey) -> (String, u32) {
738 (key.object_id.clone(), key.piece_index)
739}
740
741fn validate_feature_row_shapes(value: &Value) -> anyhow::Result<()> {
742 let speakers = value
743 .get("speakers")
744 .and_then(Value::as_array)
745 .context("GPT parser JSON omitted the speakers array")?;
746 let expected = FEATURE_FIELDS.iter().copied().collect::<BTreeSet<_>>();
747 for (index, speaker) in speakers.iter().enumerate() {
748 let row = speaker
749 .get("feature_row")
750 .and_then(Value::as_object)
751 .with_context(|| format!("speaker row {index} omitted feature_row"))?;
752 let actual = row.keys().map(String::as_str).collect::<BTreeSet<_>>();
753 ensure!(
754 actual == expected,
755 "speaker row {index} must contain exactly the 24 feature fields"
756 );
757 }
758 Ok(())
759}
760
761fn validate_iso_639_3(value: &str) -> anyhow::Result<()> {
762 ensure!(
763 value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_lowercase()),
764 "must be a lowercase ISO 639-3 code"
765 );
766 ensure!(
767 !matches!(value, "mis" | "mul" | "und" | "zxx"),
768 "must identify one primary spoken language"
769 );
770 Ok(())
771}
772
773fn validate_feature_row(row: &FeatureRow) -> anyhow::Result<()> {
774 validate_nonempty("accent_variety", &row.accent_variety)?;
775 validate_positive("perceived_age", row.perceived_age)?;
776 validate_range(
777 "vocal_gender_presentation",
778 row.vocal_gender_presentation,
779 0.0,
780 100.0,
781 )?;
782 validate_positive("median_f0_hz", row.median_f0_hz)?;
783 validate_positive("formant_dispersion_hz", row.formant_dispersion_hz)?;
784 validate_positive("vai", row.vai)?;
785 validate_range("hypernasality", row.hypernasality, 0.0, 4.0)?;
786 validate_range(
787 "creaky_phonation_percent",
788 row.creaky_phonation_percent,
789 0.0,
790 100.0,
791 )?;
792 validate_nonempty("rhotic_realization", &row.rhotic_realization)?;
793 validate_positive(
794 "word_initial_stressed_prevocalic_t_vot_ms",
795 row.word_initial_stressed_prevocalic_t_vot_ms,
796 )?;
797 validate_range("breathiness", row.breathiness, 0.0, 100.0)?;
798 validate_range("roughness", row.roughness, 0.0, 100.0)?;
799 validate_positive("f0_pitch_span_semitones", row.f0_pitch_span_semitones)?;
800 validate_positive(
801 "articulation_rate_syllables_per_second",
802 row.articulation_rate_syllables_per_second,
803 )?;
804 validate_nonnegative("npvi_v", row.npvi_v)?;
805 validate_range("foreign_accentedness", row.foreign_accentedness, 1.0, 9.0)?;
806 validate_range(
807 "unstressed_vowel_reduction_percent",
808 row.unstressed_vowel_reduction_percent,
809 0.0,
810 100.0,
811 )?;
812 validate_nonempty("lateral_realization", &row.lateral_realization)?;
813 validate_nonnegative(
814 "filled_pauses_per_100_words",
815 row.filled_pauses_per_100_words,
816 )?;
817 validate_nonempty("s_realization", &row.s_realization)?;
818 validate_range(
819 "lexical_stress_accuracy_percent",
820 row.lexical_stress_accuracy_percent,
821 0.0,
822 100.0,
823 )?;
824 validate_range(
825 "monophthongization_percent",
826 row.monophthongization_percent,
827 0.0,
828 100.0,
829 )?;
830 validate_range(
831 "consonant_cluster_reduction_percent",
832 row.consonant_cluster_reduction_percent,
833 0.0,
834 100.0,
835 )
836}
837
838fn validate_nonempty(field: &str, value: &str) -> anyhow::Result<()> {
839 ensure!(!value.trim().is_empty(), "{field} must not be empty");
840 Ok(())
841}
842
843fn validate_finite(field: &str, value: f64) -> anyhow::Result<()> {
844 ensure!(value.is_finite(), "{field} must be finite");
845 Ok(())
846}
847
848fn validate_positive(field: &str, value: f64) -> anyhow::Result<()> {
849 validate_finite(field, value)?;
850 ensure!(value > 0.0, "{field} must be positive");
851 Ok(())
852}
853
854fn validate_nonnegative(field: &str, value: f64) -> anyhow::Result<()> {
855 validate_finite(field, value)?;
856 ensure!(value >= 0.0, "{field} must be nonnegative");
857 Ok(())
858}
859
860fn validate_range(field: &str, value: f64, minimum: f64, maximum: f64) -> anyhow::Result<()> {
861 validate_finite(field, value)?;
862 ensure!(
863 (minimum..=maximum).contains(&value),
864 "{field} must be between {minimum} and {maximum} inclusive"
865 );
866 Ok(())
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use kcode_speech_classification::DeleteOutcome;
873 use std::{
874 fs,
875 path::{Path, PathBuf},
876 sync::atomic::{AtomicU64, Ordering},
877 };
878
879 static NEXT_PATH: AtomicU64 = AtomicU64::new(0);
880
881 fn database_path(label: &str) -> PathBuf {
882 std::env::temp_dir().join(format!(
883 "kcode-audio-ingress-identity-{}-{label}-{}.sqlite3",
884 std::process::id(),
885 NEXT_PATH.fetch_add(1, Ordering::Relaxed)
886 ))
887 }
888
889 fn remove_database(path: &Path) {
890 for suffix in ["", "-wal", "-shm"] {
891 let mut value = path.as_os_str().to_os_string();
892 value.push(suffix);
893 let _ = fs::remove_file(PathBuf::from(value));
894 }
895 }
896
897 fn row() -> FeatureRow {
898 FeatureRow {
899 accent_variety: "stan1293 Standard American English".into(),
900 perceived_age: 36.0,
901 vocal_gender_presentation: 55.0,
902 median_f0_hz: 145.0,
903 formant_dispersion_hz: 1050.0,
904 vai: 1.1,
905 hypernasality: 0.0,
906 creaky_phonation_percent: 5.0,
907 rhotic_realization: "[ɹ] alveolar approximant".into(),
908 word_initial_stressed_prevocalic_t_vot_ms: 58.0,
909 breathiness: 8.0,
910 roughness: 4.0,
911 f0_pitch_span_semitones: 10.0,
912 articulation_rate_syllables_per_second: 4.1,
913 npvi_v: 48.0,
914 cefr: Cefr::C2,
915 foreign_accentedness: 1.0,
916 unstressed_vowel_reduction_percent: 75.0,
917 lateral_realization: "mixed".into(),
918 filled_pauses_per_100_words: 1.0,
919 s_realization: "laminal [s̻]".into(),
920 lexical_stress_accuracy_percent: 99.0,
921 monophthongization_percent: 2.0,
922 consonant_cluster_reduction_percent: 1.0,
923 }
924 }
925
926 fn parsed() -> ParsedChunk {
927 ParsedChunk {
928 utterances: vec![ParsedUtterance {
929 speaker: "Speaker A".into(),
930 language: "eng".into(),
931 original_text: "Hello.".into(),
932 english_translation: String::new(),
933 corrected_natural_text: None,
934 coaching: Vec::new(),
935 annotations: Vec::new(),
936 }],
937 notes: vec!["Clear recording.".into()],
938 clip_valid: true,
939 clip_validity_reason: None,
940 speakers: vec![ParsedSpeaker {
941 local_label: "Speaker A".into(),
942 primary_language: "eng".into(),
943 feature_row: row(),
944 }],
945 }
946 }
947
948 fn observation(name: &str, confidence: f64, ordinal: u32) -> CorrectionObservation {
949 CorrectionObservation {
950 local_label: format!("Speaker {}", char::from(b'A' + ordinal as u8)),
951 speaker_ordinal: ordinal,
952 observation_key: ObservationKey {
953 object_id: "recording".into(),
954 piece_index: ordinal,
955 },
956 candidate: Some(CandidateMapping {
957 full_name: name.into(),
958 cost: 1.0,
959 confidence,
960 runner_up_full_name: Some("Runner Up".into()),
961 runner_up_cost: Some(4.0),
962 background_population_cost: 3.0,
963 }),
964 identified_full_name: Some(name.into()),
965 confirmed_full_name: None,
966 }
967 }
968
969 fn packet(clean: bool) -> CorrectionPacket {
970 CorrectionPacket {
971 recording_id: Uuid::nil(),
972 user_id: "user".into(),
973 sha256: "0".repeat(64),
974 original_filename: "audio.wav".into(),
975 size_bytes: 44,
976 recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
977 .unwrap()
978 .with_timezone(&Utc),
979 clean,
980 chunk_count: 1,
981 chunks: vec![CorrectionChunk {
982 chunk_index: 0,
983 chunk_count: 1,
984 audio_start_ms: 0,
985 audio_end_ms: 1_000,
986 raw_gemini_response: "raw".into(),
987 parsed: parsed(),
988 observations: vec![observation("David Example", 2.0, 0)],
989 clean,
990 }],
991 confirmation_state: ConfirmationState::Unconfirmed,
992 }
993 }
994
995 #[test]
996 fn parser_requires_exact_rows_and_known_utterance_speakers() {
997 let valid = serde_json::to_string(&parsed()).unwrap();
998 let restored = parse_and_validate_chunk(&valid, 2.0).unwrap();
999 assert_eq!(restored, parsed());
1000
1001 let mut missing: Value = serde_json::from_str(&valid).unwrap();
1002 missing["speakers"][0]["feature_row"]
1003 .as_object_mut()
1004 .unwrap()
1005 .remove("median_f0_hz");
1006 assert!(parse_and_validate_chunk(&missing.to_string(), 2.0).is_err());
1007
1008 let mut unknown: Value = serde_json::from_str(&valid).unwrap();
1009 unknown["utterances"][0]["speaker"] = Value::String("Speaker Z".into());
1010 assert!(parse_and_validate_chunk(&unknown.to_string(), 2.0).is_err());
1011
1012 let mut invalid_language: Value = serde_json::from_str(&valid).unwrap();
1013 invalid_language["speakers"][0]["primary_language"] = Value::String("EN".into());
1014 assert!(parse_and_validate_chunk(&invalid_language.to_string(), 2.0).is_err());
1015
1016 let mut duplicate = parsed();
1017 duplicate.speakers.push(duplicate.speakers[0].clone());
1018 assert!(
1019 parse_and_validate_chunk(&serde_json::to_string(&duplicate).unwrap(), 2.0).is_err()
1020 );
1021 }
1022
1023 #[test]
1024 fn deterministic_keys_are_unique_for_multi_speaker_chunks() {
1025 let recording = Uuid::new_v4();
1026 let first = training_object_id(recording, 0);
1027 let second = training_object_id(recording, 1);
1028 assert_ne!(first, second);
1029 let keys = [
1030 ObservationKey {
1031 object_id: first.clone(),
1032 piece_index: 0,
1033 },
1034 ObservationKey {
1035 object_id: first,
1036 piece_index: 1,
1037 },
1038 ObservationKey {
1039 object_id: second,
1040 piece_index: 0,
1041 },
1042 ];
1043 assert_eq!(
1044 keys.iter().map(key_tuple).collect::<BTreeSet<_>>().len(),
1045 keys.len()
1046 );
1047 }
1048
1049 #[test]
1050 fn clean_gate_requires_background_bracketing_and_unique_candidates() {
1051 let bracketed = observation("David Example", 1.0, 0);
1052 assert!(chunk_is_clean(true, std::slice::from_ref(&bracketed)));
1053 assert!(!chunk_is_clean(true, &[]));
1054
1055 let mut missing_candidate = bracketed.clone();
1056 missing_candidate.candidate = None;
1057 assert!(!chunk_is_clean(true, &[missing_candidate]));
1058
1059 let zero_confidence = observation("David Example", 0.0, 0);
1060 assert!(!chunk_is_clean(true, &[zero_confidence]));
1061 let negative_confidence = observation("David Example", -1.0, 0);
1062 assert!(!chunk_is_clean(true, &[negative_confidence]));
1063
1064 let mut best_equal = bracketed.clone();
1065 best_equal.candidate.as_mut().unwrap().cost = 3.0;
1066 assert!(!chunk_is_clean(true, &[best_equal]));
1067 let mut best_greater = bracketed.clone();
1068 best_greater.candidate.as_mut().unwrap().cost = 4.0;
1069 assert!(!chunk_is_clean(true, &[best_greater]));
1070
1071 let mut runner_up_absent = bracketed.clone();
1072 let candidate = runner_up_absent.candidate.as_mut().unwrap();
1073 candidate.runner_up_full_name = None;
1074 candidate.runner_up_cost = None;
1075 assert!(!chunk_is_clean(true, &[runner_up_absent]));
1076
1077 let mut runner_up_equal = bracketed.clone();
1078 runner_up_equal.candidate.as_mut().unwrap().runner_up_cost = Some(3.0);
1079 assert!(!chunk_is_clean(true, &[runner_up_equal]));
1080 let mut runner_up_below = bracketed.clone();
1081 runner_up_below.candidate.as_mut().unwrap().runner_up_cost = Some(2.0);
1082 assert!(!chunk_is_clean(true, &[runner_up_below]));
1083
1084 assert!(!chunk_is_clean(
1085 true,
1086 &[bracketed.clone(), observation("David Example", 2.0, 1),]
1087 ));
1088 assert!(!chunk_is_clean(false, &[bracketed]));
1089 }
1090
1091 #[test]
1092 fn recording_training_gate_trains_only_clean_packets() {
1093 let path = database_path("training-gate");
1094 let classifier = SpeechClassifier::open(&path).unwrap();
1095 let mut unclean = packet(false);
1096 train_clean_packet(&classifier, &mut unclean).unwrap();
1097 assert_eq!(
1098 classifier
1099 .delete(unclean.chunks[0].observations[0].observation_key.clone())
1100 .unwrap(),
1101 DeleteOutcome::NotFound
1102 );
1103
1104 let mut clean = packet(true);
1105 train_clean_packet(&classifier, &mut clean).unwrap();
1106 assert_eq!(
1107 clean.confirmation_state,
1108 ConfirmationState::AutomaticallyTrained
1109 );
1110 assert_eq!(
1111 classifier
1112 .delete(clean.chunks[0].observations[0].observation_key.clone())
1113 .unwrap(),
1114 DeleteOutcome::Deleted
1115 );
1116 drop(classifier);
1117 remove_database(&path);
1118 }
1119
1120 #[test]
1121 fn confirmation_requires_exact_coverage() {
1122 let packet = packet(false);
1123 let key = packet.chunks[0].observations[0].observation_key.clone();
1124 let exact = RecordingConfirmation {
1125 recording_id: packet.recording_id,
1126 observations: vec![ObservationConfirmation {
1127 observation_key: key.clone(),
1128 confirmed_full_name: "David Example".into(),
1129 }],
1130 };
1131 assert!(validate_confirmation_coverage(&packet, &exact).is_ok());
1132
1133 let duplicate = RecordingConfirmation {
1134 recording_id: packet.recording_id,
1135 observations: vec![
1136 ObservationConfirmation {
1137 observation_key: key.clone(),
1138 confirmed_full_name: "David Example".into(),
1139 },
1140 ObservationConfirmation {
1141 observation_key: key,
1142 confirmed_full_name: "David Example".into(),
1143 },
1144 ],
1145 };
1146 assert!(validate_confirmation_coverage(&packet, &duplicate).is_err());
1147
1148 let empty = RecordingConfirmation {
1149 recording_id: packet.recording_id,
1150 observations: Vec::new(),
1151 };
1152 assert!(validate_confirmation_coverage(&packet, &empty).is_err());
1153 }
1154}