Skip to main content

kcode_audio_ingress/
identity.rs

1//! Typed speaker-analysis validation and classifier orchestration.
2
3use 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
16/// Exact classifier provider cohort component.
17pub const CLASSIFIER_PROVIDER: &str = "google";
18/// Exact classifier model cohort component.
19pub const CLASSIFIER_MODEL: &str = "gemini-3.1-pro-preview";
20/// Exact classifier prompt-version cohort component.
21pub const CLASSIFIER_PROMPT_VERSION: &str = "gemini-speaker-prompt-v0.1";
22/// Exact classifier feature-schema cohort component.
23pub 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/// One validated chunk-local utterance parsed from the raw Gemini response.
54#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
55#[serde(deny_unknown_fields)]
56pub struct ParsedUtterance {
57    /// Exact chunk-local speaker label.
58    pub speaker: String,
59    /// Lowercase ISO 639-3 language code for this utterance.
60    pub language: String,
61    /// Complete utterance in the language spoken.
62    pub original_text: String,
63    /// Complete English translation, or an empty string for English.
64    pub english_translation: String,
65    /// Corrected natural version for audibly non-native speech, when applicable.
66    pub corrected_natural_text: Option<String>,
67    /// Concise grammar, vocabulary, pronunciation, stress, and rhythm coaching.
68    pub coaching: Vec<String>,
69    /// Concise audible annotations that belong to this utterance.
70    pub annotations: Vec<String>,
71}
72
73/// One typed speaker row parsed from a raw Gemini response.
74#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
75#[serde(deny_unknown_fields)]
76pub struct ParsedSpeaker {
77    /// Exact chunk-local label used by the utterances.
78    pub local_label: String,
79    /// Lowercase ISO 639-3 code for the speaker's primary spoken language.
80    pub primary_language: String,
81    /// Exactly one validated 24-feature classifier row.
82    pub feature_row: FeatureRow,
83}
84
85/// Complete validated structure parsed from one raw Gemini response.
86#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
87#[serde(deny_unknown_fields)]
88pub struct ParsedChunk {
89    /// Complete ordered utterances for the chunk.
90    pub utterances: Vec<ParsedUtterance>,
91    /// Useful whole-chunk notes.
92    pub notes: Vec<String>,
93    /// Whether Gemini's whole-clip assessment was valid.
94    pub clip_valid: bool,
95    /// Brief invalidity reason, present exactly when `clip_valid` is false.
96    pub clip_validity_reason: Option<String>,
97    /// Exactly one typed row for each chunk-local speaker.
98    pub speakers: Vec<ParsedSpeaker>,
99}
100
101/// Classifier evidence retained for one chunk-local speaker.
102#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
103pub struct CandidateMapping {
104    /// Best candidate's caller-owned full name.
105    pub full_name: String,
106    /// Best candidate's raw classifier cost.
107    pub cost: f64,
108    /// Raw classifier confidence evidence, not a probability.
109    pub confidence: f64,
110    /// Optional runner-up full name.
111    pub runner_up_full_name: Option<String>,
112    /// Optional runner-up raw cost.
113    pub runner_up_cost: Option<f64>,
114    /// Raw background-population cost.
115    pub background_population_cost: f64,
116}
117
118/// One deterministic classifier observation in a correction packet.
119#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
120pub struct CorrectionObservation {
121    /// Chunk-local speaker label.
122    pub local_label: String,
123    /// Stable zero-based ordinal after labels are sorted.
124    pub speaker_ordinal: u32,
125    /// Deterministic persisted training and correction key.
126    pub observation_key: ObservationKey,
127    /// Best available read-only classifier evidence.
128    pub candidate: Option<CandidateMapping>,
129    /// Best candidate's full name, even when identity quality is insufficient.
130    pub identified_full_name: Option<String>,
131    /// Human-confirmed full name, when the confirmation API has been applied.
132    pub confirmed_full_name: Option<String>,
133}
134
135/// One complete chunk in a recording-level correction packet.
136#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
137pub struct CorrectionChunk {
138    /// Zero-based chronological chunk index.
139    pub chunk_index: usize,
140    /// Total recording chunk count.
141    pub chunk_count: usize,
142    /// Source-audio start in milliseconds.
143    pub audio_start_ms: u64,
144    /// Source-audio end in milliseconds.
145    pub audio_end_ms: u64,
146    /// Complete raw Gemini response without normalization.
147    pub raw_gemini_response: String,
148    /// Validated GPT-parsed structure.
149    pub parsed: ParsedChunk,
150    /// Read-only classifier mappings for every parsed speaker.
151    pub observations: Vec<CorrectionObservation>,
152    /// Whether validity, confidence, background-bracketing, and one-to-one checks passed.
153    pub clean: bool,
154}
155
156/// Durable identity-confirmation lifecycle for a correction packet.
157#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ConfirmationState {
160    /// No classifier observations from this packet were intentionally retained.
161    Unconfirmed,
162    /// The recording was clean and all observations were automatically trained.
163    AutomaticallyTrained,
164    /// Exact observation-level human confirmations were applied.
165    Confirmed,
166}
167
168/// Complete transport-neutral correction packet for one recording.
169#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
170pub struct CorrectionPacket {
171    /// Stable recording UUID.
172    pub recording_id: Uuid,
173    /// Stable application user identifier associated with provider usage.
174    pub user_id: String,
175    /// Lowercase SHA-256 identity of the retained original bytes.
176    pub sha256: String,
177    /// Sanitized original filename.
178    pub original_filename: String,
179    /// Original retained file size in bytes.
180    pub size_bytes: u64,
181    /// Instant at which the recording began.
182    pub recorded_at: DateTime<Utc>,
183    /// Whether every chunk passed the recording-level clean gate.
184    pub clean: bool,
185    /// Total chronological chunk count.
186    pub chunk_count: usize,
187    /// Every raw response, parsed structure, row, mapping, key, and interval.
188    pub chunks: Vec<CorrectionChunk>,
189    /// Current durable confirmation and training state.
190    pub confirmation_state: ConfirmationState,
191}
192
193/// One observation-level full-name confirmation.
194#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
195pub struct ObservationConfirmation {
196    /// Exact deterministic observation key from the correction packet.
197    pub observation_key: ObservationKey,
198    /// Caller-confirmed full name.
199    pub confirmed_full_name: String,
200}
201
202/// Exact confirmations for one completed recording.
203#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
204pub struct RecordingConfirmation {
205    /// Completed recording receiving the confirmations.
206    pub recording_id: Uuid,
207    /// One confirmation for every known observation, with no extras.
208    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}