Skip to main content

kcode_speaker_v3_analysis/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::{collections::BTreeSet, error::Error, fmt};
3
4pub use kcode_speaker_v3_llm_protocol::{
5    GEMINI_FEATURE_PROMPT_ONE, GEMINI_FEATURE_PROMPT_ONE_REVISION, GEMINI_FEATURE_PROMPT_REVISIONS,
6    GEMINI_FEATURE_PROMPT_THREE, GEMINI_FEATURE_PROMPT_THREE_REVISION, GEMINI_FEATURE_PROMPT_TWO,
7    GEMINI_FEATURE_PROMPT_TWO_REVISION, GEMINI_TRANSCRIPT_PROMPT,
8    GEMINI_TRANSCRIPT_PROMPT_REVISION, GPT_STRUCTURING_PROMPT, GPT_STRUCTURING_PROMPT_REVISION,
9};
10pub use kcode_speaker_v3_schema::{
11    FEATURE_NAMES, FEATURE_SCHEMA_REVISION, FeatureVector24, LocalSpeakerLabel,
12    MAX_AUDIO_DURATION_MS, OGG_MEDIA_TYPE, OggAudioMetadata, StructuredAnalysis, StructuredSpeaker,
13    ValidationError, VocalGenderPresentation,
14};
15
16#[cfg(any(feature = "providers", test))]
17use futures::{
18    FutureExt,
19    future::{BoxFuture, join_all},
20};
21#[cfg(any(feature = "providers", test))]
22use kcode_speaker_v3_llm_protocol::{
23    FEATURE_PACKETS, FeaturePacket, GeminiRequestPart, SpeakerFeatureEvidence,
24    TERRA_SPEAKER_LABELS_PROMPT_REVISION, TerraFinalInput, TerraSpeakerLabelsInput, ToolDefinition,
25    decode_record_speaker_analysis_arguments, decode_record_speaker_labels_arguments,
26    extract_gemini_text, gemini_feature_cached_prefix, gemini_feature_suffix,
27    gemini_transcript_request, record_speaker_analysis_tool, record_speaker_labels_tool,
28};
29#[cfg(any(feature = "providers", test))]
30use serde_json::Value;
31
32const GEMINI_MODEL_ID: &str = "gemini-3.1-pro-preview";
33const TERRA_MODEL_ID: &str = "gpt-5.6-terra";
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct GeminiCohort {
37    pub model_id: String,
38    pub transcript_prompt_revision: String,
39    pub feature_prompt_revisions: [String; 3],
40    pub feature_schema_revision: String,
41}
42
43impl GeminiCohort {
44    pub fn new(model_id: impl Into<String>) -> Self {
45        Self {
46            model_id: model_id.into(),
47            transcript_prompt_revision: GEMINI_TRANSCRIPT_PROMPT_REVISION.into(),
48            feature_prompt_revisions: GEMINI_FEATURE_PROMPT_REVISIONS.map(str::to_owned),
49            feature_schema_revision: FEATURE_SCHEMA_REVISION.into(),
50        }
51    }
52
53    pub fn validate(&self) -> Result<(), ValidationError> {
54        validate_text(&self.model_id, "gemini_model_id")?;
55        validate_text(
56            &self.transcript_prompt_revision,
57            "transcript_prompt_revision",
58        )?;
59        for revision in &self.feature_prompt_revisions {
60            validate_text(revision, "feature_prompt_revision")?;
61        }
62        validate_text(&self.feature_schema_revision, "feature_schema_revision")
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct StructurerProvenance {
68    pub model_id: String,
69    pub prompt_revision: String,
70}
71
72impl StructurerProvenance {
73    pub fn new(model_id: impl Into<String>) -> Self {
74        Self {
75            model_id: model_id.into(),
76            prompt_revision: GPT_STRUCTURING_PROMPT_REVISION.into(),
77        }
78    }
79
80    pub fn validate(&self) -> Result<(), ValidationError> {
81        validate_text(&self.model_id, "structurer_model_id")?;
82        validate_text(&self.prompt_revision, "structurer_prompt_revision")
83    }
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct AnalysisEnvelope {
88    pub audio: OggAudioMetadata,
89    pub analysis: StructuredAnalysis,
90    pub gemini: GeminiCohort,
91    pub structurer: StructurerProvenance,
92}
93
94impl AnalysisEnvelope {
95    pub fn validate(&self) -> Result<(), ValidationError> {
96        self.audio.validate()?;
97        self.analysis.validate()?;
98        self.gemini.validate()?;
99        self.structurer.validate()
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum AnalysisError {
105    Input(String),
106    GeminiTranscript(String),
107    TerraLabels(String),
108    GeminiCache(String),
109    GeminiFeature {
110        speaker: LocalSpeakerLabel,
111        packet: u8,
112        message: String,
113    },
114    TerraStructuring(String),
115    TranscriptMismatch,
116    SpeakerSetMismatch,
117}
118
119impl fmt::Display for AnalysisError {
120    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::Input(message) => write!(formatter, "invalid input: {message}"),
123            Self::GeminiTranscript(message) => {
124                write!(formatter, "Gemini transcript failed: {message}")
125            }
126            Self::TerraLabels(message) => {
127                write!(
128                    formatter,
129                    "Terra speaker-label extraction failed: {message}"
130                )
131            }
132            Self::GeminiCache(message) => {
133                write!(formatter, "Gemini feature cache creation failed: {message}")
134            }
135            Self::GeminiFeature {
136                speaker,
137                packet,
138                message,
139            } => write!(
140                formatter,
141                "Gemini feature call failed for {speaker}, packet {packet}: {message}"
142            ),
143            Self::TerraStructuring(message) => {
144                write!(formatter, "Terra final structuring failed: {message}")
145            }
146            Self::TranscriptMismatch => {
147                formatter.write_str("Terra returned a different transcript")
148            }
149            Self::SpeakerSetMismatch => {
150                formatter.write_str("Terra returned a different speaker set")
151            }
152        }
153    }
154}
155
156impl Error for AnalysisError {}
157
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159pub struct ExecutedAnalysis {
160    pub envelope: AnalysisEnvelope,
161    pub label_extractor: StructurerProvenance,
162}
163
164#[cfg(feature = "providers")]
165pub struct Analyzer {
166    backend: ProviderBackend,
167}
168
169#[cfg(feature = "providers")]
170impl Analyzer {
171    pub fn new(
172        gemini: kcode_gemini_3_1_pro::Gemini31Pro,
173        terra: kcode_codex_terra::CodexTerra,
174    ) -> Self {
175        Self {
176            backend: ProviderBackend { gemini, terra },
177        }
178    }
179
180    pub async fn analyze_ogg(
181        &self,
182        bytes: &[u8],
183        duration_ms: u64,
184        filename: Option<String>,
185    ) -> Result<ExecutedAnalysis, AnalysisError> {
186        execute(&self.backend, bytes, duration_ms, filename).await
187    }
188}
189
190fn validate_text(value: &str, field: &'static str) -> Result<(), ValidationError> {
191    (!value.trim().is_empty())
192        .then_some(())
193        .ok_or(ValidationError::Blank(field))
194}
195
196#[cfg(any(feature = "providers", test))]
197trait AnalysisBackend: Sync {
198    type Cache: Send + Sync;
199
200    fn gemini_transcript<'a>(&'a self, audio: &'a [u8]) -> BoxFuture<'a, Result<Value, String>>;
201
202    fn terra_labels<'a>(
203        &'a self,
204        input: String,
205        tool: ToolDefinition,
206    ) -> BoxFuture<'a, Result<Value, String>>;
207
208    fn gemini_cache<'a>(
209        &'a self,
210        audio: &'a [u8],
211        transcript: &'a str,
212    ) -> BoxFuture<'a, Result<Self::Cache, String>>;
213
214    fn gemini_feature<'a>(
215        &'a self,
216        cache: &'a Self::Cache,
217        speaker: LocalSpeakerLabel,
218        packet: FeaturePacket,
219    ) -> BoxFuture<'a, Result<Value, String>>;
220
221    fn terra_final<'a>(
222        &'a self,
223        input: String,
224        tool: ToolDefinition,
225    ) -> BoxFuture<'a, Result<Value, String>>;
226}
227
228#[cfg(any(feature = "providers", test))]
229async fn execute<B: AnalysisBackend>(
230    backend: &B,
231    bytes: &[u8],
232    duration_ms: u64,
233    filename: Option<String>,
234) -> Result<ExecutedAnalysis, AnalysisError> {
235    let audio = OggAudioMetadata::from_bytes(bytes, duration_ms, filename)
236        .map_err(|error| AnalysisError::Input(error.to_string()))?;
237
238    let transcript_response = backend
239        .gemini_transcript(bytes)
240        .await
241        .map_err(AnalysisError::GeminiTranscript)?;
242    let transcript = extract_gemini_text(&transcript_response)
243        .map_err(|error| AnalysisError::GeminiTranscript(error.to_string()))?;
244
245    let labels_input = TerraSpeakerLabelsInput::new(transcript.clone())
246        .map_err(|error| AnalysisError::TerraLabels(error.to_string()))?;
247    let labels_arguments = backend
248        .terra_labels(labels_input.render(), record_speaker_labels_tool())
249        .await
250        .map_err(AnalysisError::TerraLabels)?;
251    let labels = decode_record_speaker_labels_arguments(&labels_arguments)
252        .map_err(|error| AnalysisError::TerraLabels(error.to_string()))?;
253
254    let evidence = collect_feature_evidence(backend, bytes, &transcript, &labels).await?;
255
256    let final_input = TerraFinalInput::new(transcript.clone(), evidence)
257        .map_err(|error| AnalysisError::TerraStructuring(error.to_string()))?;
258    let final_arguments = backend
259        .terra_final(final_input.render(), record_speaker_analysis_tool())
260        .await
261        .map_err(AnalysisError::TerraStructuring)?;
262    let analysis = decode_record_speaker_analysis_arguments(&final_arguments)
263        .map_err(|error| AnalysisError::TerraStructuring(error.to_string()))?;
264
265    if analysis.transcript != transcript {
266        return Err(AnalysisError::TranscriptMismatch);
267    }
268
269    let expected_speakers = labels.iter().copied().collect::<BTreeSet<_>>();
270    let returned_speakers = analysis
271        .speakers
272        .iter()
273        .map(|speaker| speaker.speaker)
274        .collect::<BTreeSet<_>>();
275    if expected_speakers != returned_speakers {
276        return Err(AnalysisError::SpeakerSetMismatch);
277    }
278
279    let envelope = AnalysisEnvelope {
280        audio,
281        analysis,
282        gemini: GeminiCohort::new(GEMINI_MODEL_ID),
283        structurer: StructurerProvenance::new(TERRA_MODEL_ID),
284    };
285    envelope
286        .validate()
287        .map_err(|error| AnalysisError::TerraStructuring(error.to_string()))?;
288
289    let label_extractor = StructurerProvenance {
290        model_id: TERRA_MODEL_ID.into(),
291        prompt_revision: TERRA_SPEAKER_LABELS_PROMPT_REVISION.into(),
292    };
293    label_extractor
294        .validate()
295        .map_err(|error| AnalysisError::TerraLabels(error.to_string()))?;
296
297    Ok(ExecutedAnalysis {
298        envelope,
299        label_extractor,
300    })
301}
302
303#[cfg(any(feature = "providers", test))]
304async fn collect_feature_evidence<B: AnalysisBackend>(
305    backend: &B,
306    bytes: &[u8],
307    transcript: &str,
308    labels: &[LocalSpeakerLabel],
309) -> Result<Vec<SpeakerFeatureEvidence>, AnalysisError> {
310    if labels.is_empty() {
311        return Ok(Vec::new());
312    }
313
314    let cache = backend
315        .gemini_cache(bytes, transcript)
316        .await
317        .map_err(AnalysisError::GeminiCache)?;
318    let jobs = labels
319        .iter()
320        .copied()
321        .flat_map(|speaker| {
322            FEATURE_PACKETS
323                .into_iter()
324                .map(move |packet| (speaker, packet))
325        })
326        .collect::<Vec<_>>();
327    let responses = join_all(
328        jobs.iter()
329            .map(|(speaker, packet)| backend.gemini_feature(&cache, *speaker, *packet)),
330    )
331    .await;
332
333    let mut texts = Vec::with_capacity(responses.len());
334    for ((speaker, packet), response) in jobs.into_iter().zip(responses) {
335        let response = response.map_err(|message| AnalysisError::GeminiFeature {
336            speaker,
337            packet: packet.index() as u8 + 1,
338            message,
339        })?;
340        let text =
341            extract_gemini_text(&response).map_err(|error| AnalysisError::GeminiFeature {
342                speaker,
343                packet: packet.index() as u8 + 1,
344                message: error.to_string(),
345            })?;
346        texts.push(text);
347    }
348
349    let mut evidence = Vec::with_capacity(labels.len());
350    for (speaker, packets) in labels.iter().copied().zip(texts.chunks_exact(3)) {
351        evidence.push(
352            SpeakerFeatureEvidence::new(
353                speaker,
354                packets[0].clone(),
355                packets[1].clone(),
356                packets[2].clone(),
357            )
358            .map_err(|error| AnalysisError::GeminiFeature {
359                speaker,
360                packet: 1,
361                message: error.to_string(),
362            })?,
363        );
364    }
365    Ok(evidence)
366}
367
368#[cfg(any(feature = "providers", test))]
369#[derive(Clone)]
370struct ProviderBackend {
371    gemini: kcode_gemini_3_1_pro::Gemini31Pro,
372    terra: kcode_codex_terra::CodexTerra,
373}
374
375#[cfg(any(feature = "providers", test))]
376impl AnalysisBackend for ProviderBackend {
377    type Cache = kcode_gemini_3_1_pro::CachedPrefix;
378
379    fn gemini_transcript<'a>(&'a self, audio: &'a [u8]) -> BoxFuture<'a, Result<Value, String>> {
380        async move {
381            let contents = vec![gemini_content(gemini_transcript_request(audio))];
382            self.gemini
383                .generate(contents, None, None)
384                .await
385                .map(|generation| generation.response)
386                .map_err(|error| error.to_string())
387        }
388        .boxed()
389    }
390
391    fn terra_labels<'a>(
392        &'a self,
393        input: String,
394        tool: ToolDefinition,
395    ) -> BoxFuture<'a, Result<Value, String>> {
396        async move { run_terra(&self.terra, input, tool).await }.boxed()
397    }
398
399    fn gemini_cache<'a>(
400        &'a self,
401        audio: &'a [u8],
402        transcript: &'a str,
403    ) -> BoxFuture<'a, Result<Self::Cache, String>> {
404        async move {
405            let contents = vec![gemini_content(gemini_feature_cached_prefix(
406                audio, transcript,
407            ))];
408            self.gemini
409                .create_cached_prefix(contents, None, std::time::Duration::from_secs(60 * 60))
410                .await
411                .map_err(|error| error.to_string())
412        }
413        .boxed()
414    }
415
416    fn gemini_feature<'a>(
417        &'a self,
418        cache: &'a Self::Cache,
419        speaker: LocalSpeakerLabel,
420        packet: FeaturePacket,
421    ) -> BoxFuture<'a, Result<Value, String>> {
422        async move {
423            let contents = vec![kcode_gemini_3_1_pro::Content {
424                parts: vec![kcode_gemini_3_1_pro::Part::Text(gemini_feature_suffix(
425                    packet, speaker,
426                ))],
427            }];
428            self.gemini
429                .generate(contents, Some(cache), None)
430                .await
431                .map(|generation| generation.response)
432                .map_err(|error| error.to_string())
433        }
434        .boxed()
435    }
436
437    fn terra_final<'a>(
438        &'a self,
439        input: String,
440        tool: ToolDefinition,
441    ) -> BoxFuture<'a, Result<Value, String>> {
442        async move { run_terra(&self.terra, input, tool).await }.boxed()
443    }
444}
445
446#[cfg(any(feature = "providers", test))]
447fn gemini_content<'a>(
448    parts: impl IntoIterator<Item = GeminiRequestPart<'a>>,
449) -> kcode_gemini_3_1_pro::Content {
450    kcode_gemini_3_1_pro::Content {
451        parts: parts
452            .into_iter()
453            .map(|part| match part {
454                GeminiRequestPart::Audio { media_type, bytes } => {
455                    kcode_gemini_3_1_pro::Part::InlineData {
456                        mime_type: media_type.into(),
457                        bytes: bytes.to_vec(),
458                    }
459                }
460                GeminiRequestPart::Text(text) => kcode_gemini_3_1_pro::Part::Text(text),
461            })
462            .collect(),
463    }
464}
465
466#[cfg(any(feature = "providers", test))]
467async fn run_terra(
468    terra: &kcode_codex_terra::CodexTerra,
469    input: String,
470    tool: ToolDefinition,
471) -> Result<Value, String> {
472    terra
473        .run(kcode_codex_terra::ToolRun {
474            input,
475            tool_name: tool.name.into(),
476            tool_description: tool.description.into(),
477            input_schema: tool.input_schema,
478        })
479        .await
480        .map(|result| result.arguments)
481        .map_err(|error| error.to_string())
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use futures::{executor::block_on, future::poll_fn, join};
488    use serde_json::{Map, json};
489    use std::{
490        sync::{
491            Arc, Mutex,
492            atomic::{AtomicBool, AtomicUsize, Ordering},
493        },
494        task::Poll,
495    };
496
497    #[derive(Clone)]
498    struct FakeBackend {
499        state: Arc<FakeState>,
500    }
501
502    struct FakeState {
503        transcript: Value,
504        labels: Value,
505        final_arguments: Value,
506        transcript_error: Option<String>,
507        labels_error: Option<String>,
508        cache_error: Option<String>,
509        feature_error: Option<(LocalSpeakerLabel, FeaturePacket)>,
510        final_error: Option<String>,
511        wait_for: Option<Arc<AtomicBool>>,
512        mark_complete: Option<Arc<AtomicBool>>,
513        transcript_calls: AtomicUsize,
514        labels_calls: AtomicUsize,
515        cache_calls: AtomicUsize,
516        feature_calls: AtomicUsize,
517        final_calls: AtomicUsize,
518        active_features: AtomicUsize,
519        maximum_active_features: AtomicUsize,
520        targets: Mutex<Vec<(LocalSpeakerLabel, FeaturePacket)>>,
521    }
522
523    impl FakeBackend {
524        fn successful(speaker_count: u32) -> Self {
525            let labels = (1..=speaker_count)
526                .map(|number| format!("Speaker {number}"))
527                .collect::<Vec<_>>();
528            Self {
529                state: Arc::new(FakeState {
530                    transcript: gemini_text("[high] Speaker 1: exact transcript"),
531                    labels: json!({ "speakers": labels }),
532                    final_arguments: final_arguments(
533                        "[high] Speaker 1: exact transcript",
534                        speaker_count,
535                    ),
536                    transcript_error: None,
537                    labels_error: None,
538                    cache_error: None,
539                    feature_error: None,
540                    final_error: None,
541                    wait_for: None,
542                    mark_complete: None,
543                    transcript_calls: AtomicUsize::new(0),
544                    labels_calls: AtomicUsize::new(0),
545                    cache_calls: AtomicUsize::new(0),
546                    feature_calls: AtomicUsize::new(0),
547                    final_calls: AtomicUsize::new(0),
548                    active_features: AtomicUsize::new(0),
549                    maximum_active_features: AtomicUsize::new(0),
550                    targets: Mutex::new(Vec::new()),
551                }),
552            }
553        }
554
555        fn with_state(&self, update: impl FnOnce(&FakeState) -> FakeState) -> Self {
556            Self {
557                state: Arc::new(update(&self.state)),
558            }
559        }
560    }
561
562    impl AnalysisBackend for FakeBackend {
563        type Cache = ();
564
565        fn gemini_transcript<'a>(
566            &'a self,
567            _audio: &'a [u8],
568        ) -> BoxFuture<'a, Result<Value, String>> {
569            async move {
570                self.state.transcript_calls.fetch_add(1, Ordering::SeqCst);
571                if let Some(wait_for) = &self.state.wait_for {
572                    poll_fn(|context| {
573                        if wait_for.load(Ordering::SeqCst) {
574                            Poll::Ready(())
575                        } else {
576                            context.waker().wake_by_ref();
577                            Poll::Pending
578                        }
579                    })
580                    .await;
581                }
582                match &self.state.transcript_error {
583                    Some(error) => Err(error.clone()),
584                    None => Ok(self.state.transcript.clone()),
585                }
586            }
587            .boxed()
588        }
589
590        fn terra_labels<'a>(
591            &'a self,
592            _input: String,
593            _tool: ToolDefinition,
594        ) -> BoxFuture<'a, Result<Value, String>> {
595            async move {
596                self.state.labels_calls.fetch_add(1, Ordering::SeqCst);
597                match &self.state.labels_error {
598                    Some(error) => Err(error.clone()),
599                    None => Ok(self.state.labels.clone()),
600                }
601            }
602            .boxed()
603        }
604
605        fn gemini_cache<'a>(
606            &'a self,
607            _audio: &'a [u8],
608            _transcript: &'a str,
609        ) -> BoxFuture<'a, Result<Self::Cache, String>> {
610            async move {
611                self.state.cache_calls.fetch_add(1, Ordering::SeqCst);
612                match &self.state.cache_error {
613                    Some(error) => Err(error.clone()),
614                    None => Ok(()),
615                }
616            }
617            .boxed()
618        }
619
620        fn gemini_feature<'a>(
621            &'a self,
622            _cache: &'a Self::Cache,
623            speaker: LocalSpeakerLabel,
624            packet: FeaturePacket,
625        ) -> BoxFuture<'a, Result<Value, String>> {
626            async move {
627                self.state.feature_calls.fetch_add(1, Ordering::SeqCst);
628                self.state.targets.lock().unwrap().push((speaker, packet));
629                let active = self.state.active_features.fetch_add(1, Ordering::SeqCst) + 1;
630                self.state
631                    .maximum_active_features
632                    .fetch_max(active, Ordering::SeqCst);
633                let mut yielded = false;
634                poll_fn(|context| {
635                    if yielded {
636                        Poll::Ready(())
637                    } else {
638                        yielded = true;
639                        context.waker().wake_by_ref();
640                        Poll::Pending
641                    }
642                })
643                .await;
644                self.state.active_features.fetch_sub(1, Ordering::SeqCst);
645                if self.state.feature_error == Some((speaker, packet)) {
646                    Err("feature failure".into())
647                } else {
648                    Ok(gemini_text(&format!(
649                        "{speaker} packet {}",
650                        packet.index() + 1
651                    )))
652                }
653            }
654            .boxed()
655        }
656
657        fn terra_final<'a>(
658            &'a self,
659            _input: String,
660            _tool: ToolDefinition,
661        ) -> BoxFuture<'a, Result<Value, String>> {
662            async move {
663                self.state.final_calls.fetch_add(1, Ordering::SeqCst);
664                match &self.state.final_error {
665                    Some(error) => Err(error.clone()),
666                    None => {
667                        if let Some(mark_complete) = &self.state.mark_complete {
668                            mark_complete.store(true, Ordering::SeqCst);
669                        }
670                        Ok(self.state.final_arguments.clone())
671                    }
672                }
673            }
674            .boxed()
675        }
676    }
677
678    fn copy_state(state: &FakeState) -> FakeState {
679        FakeState {
680            transcript: state.transcript.clone(),
681            labels: state.labels.clone(),
682            final_arguments: state.final_arguments.clone(),
683            transcript_error: state.transcript_error.clone(),
684            labels_error: state.labels_error.clone(),
685            cache_error: state.cache_error.clone(),
686            feature_error: state.feature_error,
687            final_error: state.final_error.clone(),
688            wait_for: state.wait_for.clone(),
689            mark_complete: state.mark_complete.clone(),
690            transcript_calls: AtomicUsize::new(0),
691            labels_calls: AtomicUsize::new(0),
692            cache_calls: AtomicUsize::new(0),
693            feature_calls: AtomicUsize::new(0),
694            final_calls: AtomicUsize::new(0),
695            active_features: AtomicUsize::new(0),
696            maximum_active_features: AtomicUsize::new(0),
697            targets: Mutex::new(Vec::new()),
698        }
699    }
700
701    fn gemini_text(text: &str) -> Value {
702        json!({
703            "candidates": [{
704                "content": {
705                    "parts": [{ "text": text }]
706                }
707            }]
708        })
709    }
710
711    fn null_features() -> Map<String, Value> {
712        FEATURE_NAMES
713            .into_iter()
714            .map(|name| (name.into(), Value::Null))
715            .collect()
716    }
717
718    fn final_arguments(transcript: &str, speaker_count: u32) -> Value {
719        let speakers = (1..=speaker_count)
720            .map(|number| {
721                json!({
722                    "speaker": format!("Speaker {number}"),
723                    "language": "English",
724                    "features": null_features(),
725                    "features_usable_for_training": false
726                })
727            })
728            .collect::<Vec<_>>();
729        json!({
730            "transcript": transcript,
731            "speakers": speakers
732        })
733    }
734
735    fn ogg() -> Vec<u8> {
736        let mut bytes = vec![0; 28];
737        bytes[..4].copy_from_slice(b"OggS");
738        bytes[4] = 0;
739        bytes[26] = 1;
740        bytes[27] = 0;
741        bytes
742    }
743
744    #[test]
745    fn zero_one_and_many_speaker_workflows_have_exact_calls() {
746        for speaker_count in [0, 1, 40] {
747            let backend = FakeBackend::successful(speaker_count);
748            let result = block_on(execute(&backend, &ogg(), 1, Some("voice.ogg".into()))).unwrap();
749            assert_eq!(
750                result.envelope.analysis.speakers.len(),
751                speaker_count as usize
752            );
753            assert_eq!(
754                result.label_extractor,
755                StructurerProvenance {
756                    model_id: TERRA_MODEL_ID.into(),
757                    prompt_revision: TERRA_SPEAKER_LABELS_PROMPT_REVISION.into(),
758                }
759            );
760            assert_eq!(backend.state.transcript_calls.load(Ordering::SeqCst), 1);
761            assert_eq!(backend.state.labels_calls.load(Ordering::SeqCst), 1);
762            assert_eq!(
763                backend.state.cache_calls.load(Ordering::SeqCst),
764                usize::from(speaker_count > 0)
765            );
766            assert_eq!(
767                backend.state.feature_calls.load(Ordering::SeqCst),
768                speaker_count as usize * 3
769            );
770            assert_eq!(backend.state.final_calls.load(Ordering::SeqCst), 1);
771        }
772    }
773
774    #[test]
775    fn terra_labels_control_targets_and_features_overlap() {
776        let backend = FakeBackend::successful(3);
777        block_on(execute(&backend, &ogg(), 1, None)).unwrap();
778        let expected = (1..=3)
779            .flat_map(|number| {
780                FEATURE_PACKETS
781                    .into_iter()
782                    .map(move |packet| (LocalSpeakerLabel::new(number).unwrap(), packet))
783            })
784            .collect::<Vec<_>>();
785        assert_eq!(*backend.state.targets.lock().unwrap(), expected);
786        assert!(backend.state.maximum_active_features.load(Ordering::SeqCst) > 1);
787    }
788
789    #[test]
790    fn input_and_each_provider_stage_fail_without_retry() {
791        let backend = FakeBackend::successful(1);
792        assert!(matches!(
793            block_on(execute(&backend, b"bad", 1, None)),
794            Err(AnalysisError::Input(_))
795        ));
796        assert_eq!(backend.state.transcript_calls.load(Ordering::SeqCst), 0);
797
798        let transcript_failure = backend.with_state(|state| {
799            let mut state = copy_state(state);
800            state.transcript_error = Some("transcript".into());
801            state
802        });
803        assert!(matches!(
804            block_on(execute(&transcript_failure, &ogg(), 1, None)),
805            Err(AnalysisError::GeminiTranscript(_))
806        ));
807        assert_eq!(
808            transcript_failure
809                .state
810                .transcript_calls
811                .load(Ordering::SeqCst),
812            1
813        );
814        assert_eq!(
815            transcript_failure.state.labels_calls.load(Ordering::SeqCst),
816            0
817        );
818
819        let labels_failure = backend.with_state(|state| {
820            let mut state = copy_state(state);
821            state.labels_error = Some("labels".into());
822            state
823        });
824        assert!(matches!(
825            block_on(execute(&labels_failure, &ogg(), 1, None)),
826            Err(AnalysisError::TerraLabels(_))
827        ));
828        assert_eq!(labels_failure.state.labels_calls.load(Ordering::SeqCst), 1);
829        assert_eq!(labels_failure.state.cache_calls.load(Ordering::SeqCst), 0);
830
831        let cache_failure = backend.with_state(|state| {
832            let mut state = copy_state(state);
833            state.cache_error = Some("cache".into());
834            state
835        });
836        assert!(matches!(
837            block_on(execute(&cache_failure, &ogg(), 1, None)),
838            Err(AnalysisError::GeminiCache(_))
839        ));
840        assert_eq!(cache_failure.state.cache_calls.load(Ordering::SeqCst), 1);
841        assert_eq!(cache_failure.state.feature_calls.load(Ordering::SeqCst), 0);
842
843        let feature_failure = backend.with_state(|state| {
844            let mut state = copy_state(state);
845            state.feature_error = Some((LocalSpeakerLabel::new(1).unwrap(), FeaturePacket::Two));
846            state
847        });
848        assert!(matches!(
849            block_on(execute(&feature_failure, &ogg(), 1, None)),
850            Err(AnalysisError::GeminiFeature { packet: 2, .. })
851        ));
852        assert_eq!(
853            feature_failure.state.feature_calls.load(Ordering::SeqCst),
854            3
855        );
856        assert_eq!(feature_failure.state.final_calls.load(Ordering::SeqCst), 0);
857
858        let final_failure = backend.with_state(|state| {
859            let mut state = copy_state(state);
860            state.final_error = Some("final".into());
861            state
862        });
863        assert!(matches!(
864            block_on(execute(&final_failure, &ogg(), 1, None)),
865            Err(AnalysisError::TerraStructuring(_))
866        ));
867        assert_eq!(final_failure.state.final_calls.load(Ordering::SeqCst), 1);
868    }
869
870    #[test]
871    fn protocol_failures_and_cross_stage_mismatches_are_rejected() {
872        let backend = FakeBackend::successful(1);
873
874        let invalid_transcript = backend.with_state(|state| {
875            let mut state = copy_state(state);
876            state.transcript = json!({});
877            state
878        });
879        assert!(matches!(
880            block_on(execute(&invalid_transcript, &ogg(), 1, None)),
881            Err(AnalysisError::GeminiTranscript(_))
882        ));
883
884        let invalid_labels = backend.with_state(|state| {
885            let mut state = copy_state(state);
886            state.labels = json!({ "speakers": ["Unknown"] });
887            state
888        });
889        assert!(matches!(
890            block_on(execute(&invalid_labels, &ogg(), 1, None)),
891            Err(AnalysisError::TerraLabels(_))
892        ));
893
894        let invalid_final = backend.with_state(|state| {
895            let mut state = copy_state(state);
896            state.final_arguments = json!({});
897            state
898        });
899        assert!(matches!(
900            block_on(execute(&invalid_final, &ogg(), 1, None)),
901            Err(AnalysisError::TerraStructuring(_))
902        ));
903
904        let transcript_mismatch = backend.with_state(|state| {
905            let mut state = copy_state(state);
906            state.final_arguments = final_arguments("different", 1);
907            state
908        });
909        assert_eq!(
910            block_on(execute(&transcript_mismatch, &ogg(), 1, None)),
911            Err(AnalysisError::TranscriptMismatch)
912        );
913
914        let speaker_mismatch = backend.with_state(|state| {
915            let mut state = copy_state(state);
916            state.final_arguments = final_arguments("[high] Speaker 1: exact transcript", 2);
917            state
918        });
919        assert_eq!(
920            block_on(execute(&speaker_mismatch, &ogg(), 1, None)),
921            Err(AnalysisError::SpeakerSetMismatch)
922        );
923    }
924
925    #[test]
926    fn a_blocked_analysis_does_not_block_an_unrelated_analysis() {
927        let completed = Arc::new(AtomicBool::new(false));
928        let fast = FakeBackend::successful(0).with_state(|state| {
929            let mut state = copy_state(state);
930            state.mark_complete = Some(completed.clone());
931            state
932        });
933        let slow = FakeBackend::successful(0).with_state(|state| {
934            let mut state = copy_state(state);
935            state.wait_for = Some(completed.clone());
936            state
937        });
938        let slow_audio = ogg();
939        let fast_audio = ogg();
940        let (slow_result, fast_result) = block_on(async {
941            join!(
942                execute(&slow, &slow_audio, 1, None),
943                execute(&fast, &fast_audio, 1, None)
944            )
945        });
946        slow_result.unwrap();
947        fast_result.unwrap();
948        assert!(completed.load(Ordering::SeqCst));
949    }
950
951    #[test]
952    fn provenance_preserves_the_previous_public_contract() {
953        let cohort = GeminiCohort::new("gemini-model");
954        assert_eq!(
955            cohort.feature_prompt_revisions,
956            GEMINI_FEATURE_PROMPT_REVISIONS.map(str::to_owned)
957        );
958        cohort.validate().unwrap();
959        StructurerProvenance::new("gpt-5.6").validate().unwrap();
960        assert_eq!(
961            GeminiCohort::new(" ").validate(),
962            Err(ValidationError::Blank("gemini_model_id"))
963        );
964    }
965
966    #[test]
967    fn concrete_provider_backend_compiles() {
968        fn require_backend<B: AnalysisBackend>() {}
969        require_backend::<ProviderBackend>();
970    }
971}