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, and one-to-one identity checks all 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 && names.insert(candidate.full_name.as_str())
422        })
423    })
424}
425
426pub(crate) fn build_packet(
427    context: &ClassificationContext,
428    chunks: Vec<CorrectionChunk>,
429) -> anyhow::Result<CorrectionPacket> {
430    ensure!(!chunks.is_empty(), "correction packet has no chunks");
431    let chunk_count = chunks.len();
432    ensure!(
433        chunks.iter().enumerate().all(|(index, chunk)| {
434            chunk.chunk_index == index
435                && chunk.chunk_count == chunk_count
436                && chunk.audio_end_ms > chunk.audio_start_ms
437                && chunk.observations.len() == chunk.parsed.speakers.len()
438        }),
439        "correction packet chunks are not one complete chronological plan"
440    );
441    let clean = chunks.iter().all(|chunk| chunk.clean);
442    Ok(CorrectionPacket {
443        recording_id: context.recording_id,
444        user_id: context.user_id.clone(),
445        sha256: context.sha256.clone(),
446        original_filename: context.original_filename.clone(),
447        size_bytes: context.size_bytes,
448        recorded_at: context.recorded_at,
449        clean,
450        chunk_count,
451        chunks,
452        confirmation_state: ConfirmationState::Unconfirmed,
453    })
454}
455
456pub(crate) fn train_clean_packet(
457    classifier: &SpeechClassifier,
458    packet: &mut CorrectionPacket,
459) -> anyhow::Result<()> {
460    if !packet.clean {
461        ensure!(
462            packet.confirmation_state == ConfirmationState::Unconfirmed,
463            "unclean packet unexpectedly claims retained training"
464        );
465        return Ok(());
466    }
467
468    let mut added = Vec::new();
469    for chunk in &packet.chunks {
470        for observation in &chunk.observations {
471            let speaker = speaker_for_observation(chunk, observation)?;
472            let full_name = observation
473                .identified_full_name
474                .as_deref()
475                .context("clean observation omitted its identified full name")?;
476            match classifier.train(
477                observation.observation_key.clone(),
478                cohort(&speaker.primary_language),
479                speaker.feature_row.clone(),
480                full_name.to_owned(),
481            ) {
482                Ok(TrainOutcome::Added) => added.push(observation.observation_key.clone()),
483                Ok(TrainOutcome::Unchanged | TrainOutcome::Corrected) => {}
484                Err(error) => {
485                    let rollback_errors = rollback_added(classifier, &added);
486                    if rollback_errors.is_empty() {
487                        anyhow::bail!("automatic identity training failed: {error}");
488                    }
489                    anyhow::bail!(
490                        "automatic identity training failed: {error}; rollback also failed: {}",
491                        rollback_errors.join("; ")
492                    );
493                }
494            }
495        }
496    }
497    packet.confirmation_state = ConfirmationState::AutomaticallyTrained;
498    Ok(())
499}
500
501pub(crate) fn validate_confirmation_coverage(
502    packet: &CorrectionPacket,
503    confirmation: &RecordingConfirmation,
504) -> Result<(), String> {
505    if confirmation.recording_id != packet.recording_id {
506        return Err("Confirmation recording ID does not match the packet.".into());
507    }
508
509    let known = packet
510        .chunks
511        .iter()
512        .flat_map(|chunk| &chunk.observations)
513        .map(|observation| key_tuple(&observation.observation_key))
514        .collect::<BTreeSet<_>>();
515    if known.is_empty() {
516        return Err("The correction packet contains no speaker observations.".into());
517    }
518
519    let mut supplied = BTreeSet::new();
520    for observation in &confirmation.observations {
521        if observation.confirmed_full_name.trim().is_empty()
522            || observation.confirmed_full_name.chars().count() > 512
523        {
524            return Err("Confirmed full names must contain between 1 and 512 characters.".into());
525        }
526        if !supplied.insert(key_tuple(&observation.observation_key)) {
527            return Err("Confirmation contains a duplicate observation key.".into());
528        }
529    }
530    if supplied != known {
531        return Err(
532            "Confirmation must cover every known observation exactly once, with no extras.".into(),
533        );
534    }
535    Ok(())
536}
537
538pub(crate) fn apply_confirmations(
539    classifier: &SpeechClassifier,
540    packet: &mut CorrectionPacket,
541    confirmation: &RecordingConfirmation,
542) -> anyhow::Result<()> {
543    validate_confirmation_coverage(packet, confirmation).map_err(anyhow::Error::msg)?;
544    let assignments = confirmation
545        .observations
546        .iter()
547        .map(|entry| {
548            (
549                key_tuple(&entry.observation_key),
550                entry.confirmed_full_name.trim().to_owned(),
551            )
552        })
553        .collect::<HashMap<_, _>>();
554
555    #[derive(Clone)]
556    struct Target {
557        chunk_position: usize,
558        observation_position: usize,
559        key: ObservationKey,
560        cohort: Cohort,
561        row: FeatureRow,
562        new_name: String,
563        old_name: Option<String>,
564    }
565
566    let mut targets = Vec::new();
567    for (chunk_position, chunk) in packet.chunks.iter().enumerate() {
568        for (observation_position, observation) in chunk.observations.iter().enumerate() {
569            let speaker = speaker_for_observation(chunk, observation)?;
570            targets.push(Target {
571                chunk_position,
572                observation_position,
573                key: observation.observation_key.clone(),
574                cohort: cohort(&speaker.primary_language),
575                row: speaker.feature_row.clone(),
576                new_name: assignments
577                    .get(&key_tuple(&observation.observation_key))
578                    .context("validated confirmation assignment disappeared")?
579                    .clone(),
580                old_name: retained_name(packet.confirmation_state, observation),
581            });
582        }
583    }
584
585    let mut applied = Vec::<(Target, TrainOutcome)>::new();
586    for target in targets {
587        match classifier.train(
588            target.key.clone(),
589            target.cohort.clone(),
590            target.row.clone(),
591            target.new_name.clone(),
592        ) {
593            Ok(outcome) => applied.push((target, outcome)),
594            Err(error) => {
595                let mut rollback_errors = Vec::new();
596                for (previous, outcome) in applied.iter().rev() {
597                    let rollback = if let Some(old_name) = &previous.old_name {
598                        classifier
599                            .train(
600                                previous.key.clone(),
601                                previous.cohort.clone(),
602                                previous.row.clone(),
603                                old_name.clone(),
604                            )
605                            .map(|_| ())
606                    } else if *outcome == TrainOutcome::Added {
607                        classifier.delete(previous.key.clone()).map(|_| ())
608                    } else {
609                        Ok(())
610                    };
611                    if let Err(rollback_error) = rollback {
612                        rollback_errors.push(rollback_error.to_string());
613                    }
614                }
615                if rollback_errors.is_empty() {
616                    anyhow::bail!("applying identity confirmations failed: {error}");
617                }
618                anyhow::bail!(
619                    "applying identity confirmations failed: {error}; rollback also failed: {}",
620                    rollback_errors.join("; ")
621                );
622            }
623        }
624    }
625
626    for (target, _) in applied {
627        packet.chunks[target.chunk_position].observations[target.observation_position]
628            .confirmed_full_name = Some(target.new_name);
629    }
630    packet.confirmation_state = ConfirmationState::Confirmed;
631    Ok(())
632}
633
634pub(crate) fn restore_packet_training(
635    classifier: &SpeechClassifier,
636    packet: &CorrectionPacket,
637) -> Vec<String> {
638    let mut errors = Vec::new();
639    for chunk in packet.chunks.iter().rev() {
640        for observation in chunk.observations.iter().rev() {
641            let result = match retained_name(packet.confirmation_state, observation) {
642                Some(name) => speaker_for_observation(chunk, observation).and_then(|speaker| {
643                    classifier
644                        .train(
645                            observation.observation_key.clone(),
646                            cohort(&speaker.primary_language),
647                            speaker.feature_row.clone(),
648                            name,
649                        )
650                        .map(|_| ())
651                        .map_err(anyhow::Error::from)
652                }),
653                None => classifier
654                    .delete(observation.observation_key.clone())
655                    .map(|_| ())
656                    .map_err(anyhow::Error::from),
657            };
658            if let Err(error) = result {
659                errors.push(error.to_string());
660            }
661        }
662    }
663    errors
664}
665
666pub(crate) fn training_object_id(recording_id: Uuid, chunk_index: usize) -> String {
667    format!("kcode-audio-ingress/recording/{recording_id}/chunk/{chunk_index}")
668}
669
670fn probe_object_id(recording_id: Uuid, chunk_index: usize) -> String {
671    format!("kcode-audio-ingress/probe/{recording_id}/chunk/{chunk_index}")
672}
673
674fn cohort(primary_language: &str) -> Cohort {
675    Cohort {
676        provider: CLASSIFIER_PROVIDER.into(),
677        model: CLASSIFIER_MODEL.into(),
678        prompt_version: CLASSIFIER_PROMPT_VERSION.into(),
679        schema_version: CLASSIFIER_SCHEMA_VERSION.into(),
680        primary_language: primary_language.into(),
681    }
682}
683
684fn candidate_mapping(evidence: &IdentifyEvidence) -> CandidateMapping {
685    CandidateMapping {
686        full_name: evidence.best.speaker_id.clone(),
687        cost: evidence.best.cost,
688        confidence: evidence.confidence_score,
689        runner_up_full_name: evidence
690            .runner_up
691            .as_ref()
692            .map(|candidate| candidate.speaker_id.clone()),
693        runner_up_cost: evidence.runner_up.as_ref().map(|candidate| candidate.cost),
694        background_population_cost: evidence.background_population_cost,
695    }
696}
697
698fn speaker_for_observation<'a>(
699    chunk: &'a CorrectionChunk,
700    observation: &CorrectionObservation,
701) -> anyhow::Result<&'a ParsedSpeaker> {
702    let speaker = chunk
703        .parsed
704        .speakers
705        .get(observation.speaker_ordinal as usize)
706        .context("observation ordinal is outside the parsed speaker rows")?;
707    ensure!(
708        speaker.local_label == observation.local_label,
709        "observation label does not match its parsed speaker row"
710    );
711    Ok(speaker)
712}
713
714fn retained_name(state: ConfirmationState, observation: &CorrectionObservation) -> Option<String> {
715    match state {
716        ConfirmationState::Unconfirmed => None,
717        ConfirmationState::AutomaticallyTrained => observation.identified_full_name.clone(),
718        ConfirmationState::Confirmed => observation.confirmed_full_name.clone(),
719    }
720}
721
722fn rollback_added(classifier: &SpeechClassifier, keys: &[ObservationKey]) -> Vec<String> {
723    let mut errors = Vec::new();
724    for key in keys.iter().rev() {
725        if let Err(error) = classifier.delete(key.clone()) {
726            errors.push(error.to_string());
727        }
728    }
729    errors
730}
731
732fn key_tuple(key: &ObservationKey) -> (String, u32) {
733    (key.object_id.clone(), key.piece_index)
734}
735
736fn validate_feature_row_shapes(value: &Value) -> anyhow::Result<()> {
737    let speakers = value
738        .get("speakers")
739        .and_then(Value::as_array)
740        .context("GPT parser JSON omitted the speakers array")?;
741    let expected = FEATURE_FIELDS.iter().copied().collect::<BTreeSet<_>>();
742    for (index, speaker) in speakers.iter().enumerate() {
743        let row = speaker
744            .get("feature_row")
745            .and_then(Value::as_object)
746            .with_context(|| format!("speaker row {index} omitted feature_row"))?;
747        let actual = row.keys().map(String::as_str).collect::<BTreeSet<_>>();
748        ensure!(
749            actual == expected,
750            "speaker row {index} must contain exactly the 24 feature fields"
751        );
752    }
753    Ok(())
754}
755
756fn validate_iso_639_3(value: &str) -> anyhow::Result<()> {
757    ensure!(
758        value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_lowercase()),
759        "must be a lowercase ISO 639-3 code"
760    );
761    ensure!(
762        !matches!(value, "mis" | "mul" | "und" | "zxx"),
763        "must identify one primary spoken language"
764    );
765    Ok(())
766}
767
768fn validate_feature_row(row: &FeatureRow) -> anyhow::Result<()> {
769    validate_nonempty("accent_variety", &row.accent_variety)?;
770    validate_positive("perceived_age", row.perceived_age)?;
771    validate_range(
772        "vocal_gender_presentation",
773        row.vocal_gender_presentation,
774        0.0,
775        100.0,
776    )?;
777    validate_positive("median_f0_hz", row.median_f0_hz)?;
778    validate_positive("formant_dispersion_hz", row.formant_dispersion_hz)?;
779    validate_positive("vai", row.vai)?;
780    validate_range("hypernasality", row.hypernasality, 0.0, 4.0)?;
781    validate_range(
782        "creaky_phonation_percent",
783        row.creaky_phonation_percent,
784        0.0,
785        100.0,
786    )?;
787    validate_nonempty("rhotic_realization", &row.rhotic_realization)?;
788    validate_positive(
789        "word_initial_stressed_prevocalic_t_vot_ms",
790        row.word_initial_stressed_prevocalic_t_vot_ms,
791    )?;
792    validate_range("breathiness", row.breathiness, 0.0, 100.0)?;
793    validate_range("roughness", row.roughness, 0.0, 100.0)?;
794    validate_positive("f0_pitch_span_semitones", row.f0_pitch_span_semitones)?;
795    validate_positive(
796        "articulation_rate_syllables_per_second",
797        row.articulation_rate_syllables_per_second,
798    )?;
799    validate_nonnegative("npvi_v", row.npvi_v)?;
800    validate_range("foreign_accentedness", row.foreign_accentedness, 1.0, 9.0)?;
801    validate_range(
802        "unstressed_vowel_reduction_percent",
803        row.unstressed_vowel_reduction_percent,
804        0.0,
805        100.0,
806    )?;
807    validate_nonempty("lateral_realization", &row.lateral_realization)?;
808    validate_nonnegative(
809        "filled_pauses_per_100_words",
810        row.filled_pauses_per_100_words,
811    )?;
812    validate_nonempty("s_realization", &row.s_realization)?;
813    validate_range(
814        "lexical_stress_accuracy_percent",
815        row.lexical_stress_accuracy_percent,
816        0.0,
817        100.0,
818    )?;
819    validate_range(
820        "monophthongization_percent",
821        row.monophthongization_percent,
822        0.0,
823        100.0,
824    )?;
825    validate_range(
826        "consonant_cluster_reduction_percent",
827        row.consonant_cluster_reduction_percent,
828        0.0,
829        100.0,
830    )
831}
832
833fn validate_nonempty(field: &str, value: &str) -> anyhow::Result<()> {
834    ensure!(!value.trim().is_empty(), "{field} must not be empty");
835    Ok(())
836}
837
838fn validate_finite(field: &str, value: f64) -> anyhow::Result<()> {
839    ensure!(value.is_finite(), "{field} must be finite");
840    Ok(())
841}
842
843fn validate_positive(field: &str, value: f64) -> anyhow::Result<()> {
844    validate_finite(field, value)?;
845    ensure!(value > 0.0, "{field} must be positive");
846    Ok(())
847}
848
849fn validate_nonnegative(field: &str, value: f64) -> anyhow::Result<()> {
850    validate_finite(field, value)?;
851    ensure!(value >= 0.0, "{field} must be nonnegative");
852    Ok(())
853}
854
855fn validate_range(field: &str, value: f64, minimum: f64, maximum: f64) -> anyhow::Result<()> {
856    validate_finite(field, value)?;
857    ensure!(
858        (minimum..=maximum).contains(&value),
859        "{field} must be between {minimum} and {maximum} inclusive"
860    );
861    Ok(())
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use kcode_speech_classification::DeleteOutcome;
868    use std::{
869        fs,
870        path::{Path, PathBuf},
871        sync::atomic::{AtomicU64, Ordering},
872    };
873
874    static NEXT_PATH: AtomicU64 = AtomicU64::new(0);
875
876    fn database_path(label: &str) -> PathBuf {
877        std::env::temp_dir().join(format!(
878            "kcode-audio-ingress-identity-{}-{label}-{}.sqlite3",
879            std::process::id(),
880            NEXT_PATH.fetch_add(1, Ordering::Relaxed)
881        ))
882    }
883
884    fn remove_database(path: &Path) {
885        for suffix in ["", "-wal", "-shm"] {
886            let mut value = path.as_os_str().to_os_string();
887            value.push(suffix);
888            let _ = fs::remove_file(PathBuf::from(value));
889        }
890    }
891
892    fn row() -> FeatureRow {
893        FeatureRow {
894            accent_variety: "stan1293 Standard American English".into(),
895            perceived_age: 36.0,
896            vocal_gender_presentation: 55.0,
897            median_f0_hz: 145.0,
898            formant_dispersion_hz: 1050.0,
899            vai: 1.1,
900            hypernasality: 0.0,
901            creaky_phonation_percent: 5.0,
902            rhotic_realization: "[ɹ] alveolar approximant".into(),
903            word_initial_stressed_prevocalic_t_vot_ms: 58.0,
904            breathiness: 8.0,
905            roughness: 4.0,
906            f0_pitch_span_semitones: 10.0,
907            articulation_rate_syllables_per_second: 4.1,
908            npvi_v: 48.0,
909            cefr: Cefr::C2,
910            foreign_accentedness: 1.0,
911            unstressed_vowel_reduction_percent: 75.0,
912            lateral_realization: "mixed".into(),
913            filled_pauses_per_100_words: 1.0,
914            s_realization: "laminal [s̻]".into(),
915            lexical_stress_accuracy_percent: 99.0,
916            monophthongization_percent: 2.0,
917            consonant_cluster_reduction_percent: 1.0,
918        }
919    }
920
921    fn parsed() -> ParsedChunk {
922        ParsedChunk {
923            utterances: vec![ParsedUtterance {
924                speaker: "Speaker A".into(),
925                language: "eng".into(),
926                original_text: "Hello.".into(),
927                english_translation: String::new(),
928                corrected_natural_text: None,
929                coaching: Vec::new(),
930                annotations: Vec::new(),
931            }],
932            notes: vec!["Clear recording.".into()],
933            clip_valid: true,
934            clip_validity_reason: None,
935            speakers: vec![ParsedSpeaker {
936                local_label: "Speaker A".into(),
937                primary_language: "eng".into(),
938                feature_row: row(),
939            }],
940        }
941    }
942
943    fn observation(name: &str, confidence: f64, ordinal: u32) -> CorrectionObservation {
944        CorrectionObservation {
945            local_label: format!("Speaker {}", char::from(b'A' + ordinal as u8)),
946            speaker_ordinal: ordinal,
947            observation_key: ObservationKey {
948                object_id: "recording".into(),
949                piece_index: ordinal,
950            },
951            candidate: Some(CandidateMapping {
952                full_name: name.into(),
953                cost: 1.0,
954                confidence,
955                runner_up_full_name: None,
956                runner_up_cost: None,
957                background_population_cost: 3.0,
958            }),
959            identified_full_name: Some(name.into()),
960            confirmed_full_name: None,
961        }
962    }
963
964    fn packet(clean: bool) -> CorrectionPacket {
965        CorrectionPacket {
966            recording_id: Uuid::nil(),
967            user_id: "user".into(),
968            sha256: "0".repeat(64),
969            original_filename: "audio.wav".into(),
970            size_bytes: 44,
971            recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
972                .unwrap()
973                .with_timezone(&Utc),
974            clean,
975            chunk_count: 1,
976            chunks: vec![CorrectionChunk {
977                chunk_index: 0,
978                chunk_count: 1,
979                audio_start_ms: 0,
980                audio_end_ms: 1_000,
981                raw_gemini_response: "raw".into(),
982                parsed: parsed(),
983                observations: vec![observation("David Example", 2.0, 0)],
984                clean,
985            }],
986            confirmation_state: ConfirmationState::Unconfirmed,
987        }
988    }
989
990    #[test]
991    fn parser_requires_exact_rows_and_known_utterance_speakers() {
992        let valid = serde_json::to_string(&parsed()).unwrap();
993        let restored = parse_and_validate_chunk(&valid, 2.0).unwrap();
994        assert_eq!(restored, parsed());
995
996        let mut missing: Value = serde_json::from_str(&valid).unwrap();
997        missing["speakers"][0]["feature_row"]
998            .as_object_mut()
999            .unwrap()
1000            .remove("median_f0_hz");
1001        assert!(parse_and_validate_chunk(&missing.to_string(), 2.0).is_err());
1002
1003        let mut unknown: Value = serde_json::from_str(&valid).unwrap();
1004        unknown["utterances"][0]["speaker"] = Value::String("Speaker Z".into());
1005        assert!(parse_and_validate_chunk(&unknown.to_string(), 2.0).is_err());
1006
1007        let mut invalid_language: Value = serde_json::from_str(&valid).unwrap();
1008        invalid_language["speakers"][0]["primary_language"] = Value::String("EN".into());
1009        assert!(parse_and_validate_chunk(&invalid_language.to_string(), 2.0).is_err());
1010
1011        let mut duplicate = parsed();
1012        duplicate.speakers.push(duplicate.speakers[0].clone());
1013        assert!(
1014            parse_and_validate_chunk(&serde_json::to_string(&duplicate).unwrap(), 2.0).is_err()
1015        );
1016    }
1017
1018    #[test]
1019    fn deterministic_keys_are_unique_for_multi_speaker_chunks() {
1020        let recording = Uuid::new_v4();
1021        let first = training_object_id(recording, 0);
1022        let second = training_object_id(recording, 1);
1023        assert_ne!(first, second);
1024        let keys = [
1025            ObservationKey {
1026                object_id: first.clone(),
1027                piece_index: 0,
1028            },
1029            ObservationKey {
1030                object_id: first,
1031                piece_index: 1,
1032            },
1033            ObservationKey {
1034                object_id: second,
1035                piece_index: 0,
1036            },
1037        ];
1038        assert_eq!(
1039            keys.iter().map(key_tuple).collect::<BTreeSet<_>>().len(),
1040            keys.len()
1041        );
1042    }
1043
1044    #[test]
1045    fn clean_gate_requires_positive_unique_candidates() {
1046        assert!(chunk_is_clean(
1047            true,
1048            &[observation("David Example", 1.0, 0)]
1049        ));
1050        assert!(!chunk_is_clean(
1051            true,
1052            &[
1053                observation("David Example", 1.0, 0),
1054                observation("David Example", 2.0, 1),
1055            ]
1056        ));
1057        assert!(!chunk_is_clean(
1058            true,
1059            &[observation("David Example", 0.0, 0)]
1060        ));
1061        assert!(!chunk_is_clean(
1062            false,
1063            &[observation("David Example", 2.0, 0)]
1064        ));
1065    }
1066
1067    #[test]
1068    fn recording_training_gate_trains_only_clean_packets() {
1069        let path = database_path("training-gate");
1070        let classifier = SpeechClassifier::open(&path).unwrap();
1071        let mut unclean = packet(false);
1072        train_clean_packet(&classifier, &mut unclean).unwrap();
1073        assert_eq!(
1074            classifier
1075                .delete(unclean.chunks[0].observations[0].observation_key.clone())
1076                .unwrap(),
1077            DeleteOutcome::NotFound
1078        );
1079
1080        let mut clean = packet(true);
1081        train_clean_packet(&classifier, &mut clean).unwrap();
1082        assert_eq!(
1083            clean.confirmation_state,
1084            ConfirmationState::AutomaticallyTrained
1085        );
1086        assert_eq!(
1087            classifier
1088                .delete(clean.chunks[0].observations[0].observation_key.clone())
1089                .unwrap(),
1090            DeleteOutcome::Deleted
1091        );
1092        drop(classifier);
1093        remove_database(&path);
1094    }
1095
1096    #[test]
1097    fn confirmation_requires_exact_coverage() {
1098        let packet = packet(false);
1099        let key = packet.chunks[0].observations[0].observation_key.clone();
1100        let exact = RecordingConfirmation {
1101            recording_id: packet.recording_id,
1102            observations: vec![ObservationConfirmation {
1103                observation_key: key.clone(),
1104                confirmed_full_name: "David Example".into(),
1105            }],
1106        };
1107        assert!(validate_confirmation_coverage(&packet, &exact).is_ok());
1108
1109        let duplicate = RecordingConfirmation {
1110            recording_id: packet.recording_id,
1111            observations: vec![
1112                ObservationConfirmation {
1113                    observation_key: key.clone(),
1114                    confirmed_full_name: "David Example".into(),
1115                },
1116                ObservationConfirmation {
1117                    observation_key: key,
1118                    confirmed_full_name: "David Example".into(),
1119                },
1120            ],
1121        };
1122        assert!(validate_confirmation_coverage(&packet, &duplicate).is_err());
1123
1124        let empty = RecordingConfirmation {
1125            recording_id: packet.recording_id,
1126            observations: Vec::new(),
1127        };
1128        assert!(validate_confirmation_coverage(&packet, &empty).is_err());
1129    }
1130}