Skip to main content

kcode_speaker_extract/
lib.rs

1pub use kcode_speaker_types::{FeatureVector, Key};
2
3use kcode_speaker_types::FEATURE_COUNT;
4use serde::Deserialize;
5use std::fmt;
6use std::sync::OnceLock;
7
8const SINGLE_SEGMENT_LIMIT_MS: u64 = 240_000;
9const MAX_SEGMENT_MS: u128 = 239_999;
10const OVERLAP_MS: u128 = 5_000;
11const UNIQUE_CAPACITY_MS: u128 = MAX_SEGMENT_MS - OVERLAP_MS;
12
13pub struct SegmentPlan {
14    pub policy: Key,
15    pub source_duration_ms: u64,
16    pub segments: Vec<PlannedSegment>,
17}
18
19pub struct PlannedSegment {
20    pub ordinal: u16,
21    pub start_ms: u64,
22    pub end_ms: u64,
23}
24
25pub struct ExtractionContract {
26    pub schema_key: Key,
27    pub prompt: &'static str,
28    pub response_mime: &'static str,
29}
30
31pub enum ExtractionOutcome {
32    Scored(ScoredClip),
33    Unscorable { reason: String },
34}
35
36pub struct ScoredClip {
37    pub recording_quality: u8,
38    pub speakers: Vec<SpeakerSample>,
39}
40
41pub struct SpeakerSample {
42    pub speaker_ordinal: u16,
43    pub primary_language: Key,
44    pub closest_dialect: String,
45    pub usable_speech_ms: u32,
46    pub features: FeatureVector,
47}
48
49#[derive(Debug, PartialEq, Eq)]
50pub enum ExtractError {
51    ZeroDuration,
52    SegmentCountExceedsU16 { required: u128 },
53    InvalidJson(String),
54    InvalidResponse(&'static str),
55}
56
57impl fmt::Display for ExtractError {
58    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::ZeroDuration => formatter.write_str("source duration must be positive"),
61            Self::SegmentCountExceedsU16 { required } => {
62                write!(formatter, "required segment count {required} exceeds u16")
63            }
64            Self::InvalidJson(message) => write!(formatter, "invalid response JSON: {message}"),
65            Self::InvalidResponse(message) => write!(formatter, "invalid response: {message}"),
66        }
67    }
68}
69
70impl std::error::Error for ExtractError {}
71
72pub fn plan_segments(duration_ms: u64) -> Result<SegmentPlan, ExtractError> {
73    if duration_ms == 0 {
74        return Err(ExtractError::ZeroDuration);
75    }
76
77    if duration_ms < SINGLE_SEGMENT_LIMIT_MS {
78        return Ok(SegmentPlan {
79            policy: segment_policy(),
80            source_duration_ms: duration_ms,
81            segments: vec![PlannedSegment {
82                ordinal: 0,
83                start_ms: 0,
84                end_ms: duration_ms,
85            }],
86        });
87    }
88
89    let duration = u128::from(duration_ms);
90    let unique_duration = duration - OVERLAP_MS;
91    let required = unique_duration.div_ceil(UNIQUE_CAPACITY_MS);
92    if required > u128::from(u16::MAX) {
93        return Err(ExtractError::SegmentCountExceedsU16 { required });
94    }
95
96    let count = usize::try_from(required).expect("u16-bounded count fits usize");
97    let total_segment_duration = duration + (required - 1) * OVERLAP_MS;
98    let short_length = total_segment_duration / required;
99    let longer_count = total_segment_duration % required;
100    let mut segments = Vec::with_capacity(count);
101    let mut start = 0_u128;
102
103    for index in 0..count {
104        let index_u128 = u128::try_from(index).expect("u16-bounded index fits u128");
105        let length = short_length + if index_u128 < longer_count { 1 } else { 0 };
106        let end = start + length;
107        segments.push(PlannedSegment {
108            ordinal: u16::try_from(index).expect("count is bounded by u16::MAX"),
109            start_ms: u64::try_from(start).expect("segment start is within source duration"),
110            end_ms: u64::try_from(end).expect("segment end is within source duration"),
111        });
112        start = end - OVERLAP_MS;
113    }
114
115    debug_assert_eq!(
116        segments.last().map(|segment| segment.end_ms),
117        Some(duration_ms)
118    );
119    debug_assert!(
120        segments
121            .iter()
122            .all(|segment| u128::from(segment.end_ms - segment.start_ms) <= MAX_SEGMENT_MS)
123    );
124
125    Ok(SegmentPlan {
126        policy: segment_policy(),
127        source_duration_ms: duration_ms,
128        segments,
129    })
130}
131
132pub fn contract() -> &'static ExtractionContract {
133    static CONTRACT: OnceLock<ExtractionContract> = OnceLock::new();
134    CONTRACT.get_or_init(|| ExtractionContract {
135        schema_key: frozen_key("gemini-speaker-35/1"),
136        prompt: include_str!("assets/prompt-v1.txt"),
137        response_mime: "application/json",
138    })
139}
140
141pub fn parse(response: &str, clip_duration_ms: u64) -> Result<ExtractionOutcome, ExtractError> {
142    let response: WireOutcome = serde_json::from_str(response)
143        .map_err(|error| ExtractError::InvalidJson(error.to_string()))?;
144
145    match response {
146        WireOutcome::Unscorable { reason } => {
147            if reason.is_empty() || reason.len() > 240 {
148                return Err(ExtractError::InvalidResponse(
149                    "unscorable reason must contain 1..=240 bytes",
150                ));
151            }
152            Ok(ExtractionOutcome::Unscorable { reason })
153        }
154        WireOutcome::Scored {
155            recording_quality,
156            speakers,
157        } => {
158            if recording_quality > 100 {
159                return Err(ExtractError::InvalidResponse(
160                    "recording quality must be in 0..=100",
161                ));
162            }
163            if speakers.is_empty() {
164                return Err(ExtractError::InvalidResponse(
165                    "scored response must contain at least one speaker",
166                ));
167            }
168
169            let mut validated = Vec::with_capacity(speakers.len());
170            for (index, speaker) in speakers.into_iter().enumerate() {
171                if usize::from(speaker.speaker_ordinal) != index {
172                    return Err(ExtractError::InvalidResponse(
173                        "speaker ordinals must be unique and contiguous from zero",
174                    ));
175                }
176                if speaker.closest_dialect.is_empty() || speaker.closest_dialect.len() > 128 {
177                    return Err(ExtractError::InvalidResponse(
178                        "closest dialect must contain 1..=128 bytes",
179                    ));
180                }
181                if speaker.usable_speech_ms == 0
182                    || u64::from(speaker.usable_speech_ms) > clip_duration_ms
183                {
184                    return Err(ExtractError::InvalidResponse(
185                        "usable speech must be positive and no greater than clip duration",
186                    ));
187                }
188
189                let primary_language = Key::parse(&speaker.primary_language).map_err(|_| {
190                    ExtractError::InvalidResponse("primary language is not a valid shared key")
191                })?;
192                let feature_values: [u8; FEATURE_COUNT] =
193                    speaker.features.try_into().map_err(|_| {
194                        ExtractError::InvalidResponse("features must contain exactly 35 integers")
195                    })?;
196                let features = FeatureVector::new(feature_values).map_err(|_| {
197                    ExtractError::InvalidResponse("feature value is outside shared validation")
198                })?;
199
200                validated.push(SpeakerSample {
201                    speaker_ordinal: speaker.speaker_ordinal,
202                    primary_language,
203                    closest_dialect: speaker.closest_dialect,
204                    usable_speech_ms: speaker.usable_speech_ms,
205                    features,
206                });
207            }
208
209            Ok(ExtractionOutcome::Scored(ScoredClip {
210                recording_quality,
211                speakers: validated,
212            }))
213        }
214    }
215}
216
217fn segment_policy() -> Key {
218    frozen_key("speaker-segments/1")
219}
220
221fn frozen_key(value: &str) -> Key {
222    match Key::parse(value) {
223        Ok(key) => key,
224        Err(_) => panic!("invalid frozen key"),
225    }
226}
227
228#[derive(Deserialize)]
229#[serde(tag = "status", deny_unknown_fields)]
230enum WireOutcome {
231    #[serde(rename = "scored")]
232    Scored {
233        #[serde(rename = "recordingQuality")]
234        recording_quality: u8,
235        speakers: Vec<WireSpeaker>,
236    },
237    #[serde(rename = "unscorable")]
238    Unscorable { reason: String },
239}
240
241#[derive(Deserialize)]
242#[serde(deny_unknown_fields)]
243struct WireSpeaker {
244    #[serde(rename = "speakerOrdinal")]
245    speaker_ordinal: u16,
246    #[serde(rename = "primaryLanguage")]
247    primary_language: String,
248    #[serde(rename = "closestDialect")]
249    closest_dialect: String,
250    #[serde(rename = "usableSpeechMs")]
251    usable_speech_ms: u32,
252    features: Vec<u8>,
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use serde_json::{Value, json};
259
260    fn base_features() -> Vec<u8> {
261        let mut values = vec![50; FEATURE_COUNT];
262        values[11] = 60;
263        values
264    }
265
266    fn speaker(ordinal: u16, usable_speech_ms: u32) -> Value {
267        json!({
268            "speakerOrdinal": ordinal,
269            "primaryLanguage": "eng",
270            "closestDialect": "General American English",
271            "usableSpeechMs": usable_speech_ms,
272            "features": base_features(),
273        })
274    }
275
276    fn scored(speakers: Vec<Value>) -> Value {
277        json!({
278            "status": "scored",
279            "recordingQuality": 82,
280            "speakers": speakers,
281        })
282    }
283
284    fn assert_rejected(value: Value, clip_duration_ms: u64) {
285        assert!(parse(&value.to_string(), clip_duration_ms).is_err());
286    }
287
288    fn assert_plan_invariants(duration_ms: u64) -> SegmentPlan {
289        let plan = plan_segments(duration_ms).expect("plan should succeed");
290        assert_eq!(plan.source_duration_ms, duration_ms);
291        assert_eq!(plan.policy.as_ref(), "speaker-segments/1");
292        assert_eq!(plan.segments.first().expect("segment").start_ms, 0);
293        assert_eq!(plan.segments.last().expect("segment").end_ms, duration_ms);
294
295        let lengths: Vec<u64> = plan
296            .segments
297            .iter()
298            .enumerate()
299            .map(|(index, segment)| {
300                assert_eq!(usize::from(segment.ordinal), index);
301                assert!(segment.start_ms < segment.end_ms);
302                let length = segment.end_ms - segment.start_ms;
303                assert!(length <= u64::try_from(MAX_SEGMENT_MS).unwrap());
304                length
305            })
306            .collect();
307
308        for (index, pair) in plan.segments.windows(2).enumerate() {
309            assert_eq!(pair[0].end_ms - pair[1].start_ms, 5_000);
310            assert_eq!(usize::from(pair[1].ordinal), index + 1);
311        }
312
313        let minimum = *lengths.iter().min().expect("length");
314        let maximum = *lengths.iter().max().expect("length");
315        assert!(maximum - minimum <= 1);
316
317        if plan.segments.len() > 1 {
318            let fewer = u128::try_from(plan.segments.len() - 1).unwrap();
319            let fewer_capacity = fewer * UNIQUE_CAPACITY_MS + OVERLAP_MS;
320            assert!(u128::from(duration_ms) > fewer_capacity);
321        }
322
323        plan
324    }
325
326    #[test]
327    fn rejects_zero_duration() {
328        assert_eq!(plan_segments(0).err(), Some(ExtractError::ZeroDuration));
329    }
330
331    #[test]
332    fn plans_below_equal_and_above_four_minutes() {
333        let below = assert_plan_invariants(239_999);
334        assert_eq!(below.segments.len(), 1);
335        assert_eq!(below.segments[0].end_ms, 239_999);
336
337        let equal = assert_plan_invariants(240_000);
338        assert_eq!(equal.segments.len(), 2);
339
340        let above = assert_plan_invariants(240_001);
341        assert_eq!(above.segments.len(), 2);
342    }
343
344    #[test]
345    fn plans_long_and_remainder_sources() {
346        for duration in [474_998, 474_999, 475_000, 1_000_003, 9_876_543] {
347            assert_plan_invariants(duration);
348        }
349    }
350
351    #[test]
352    fn plans_exact_capacity_with_minimum_count() {
353        let duration = 7 * 234_999 + 5_000;
354        let plan = assert_plan_invariants(duration);
355        assert_eq!(plan.segments.len(), 7);
356        assert!(
357            plan.segments
358                .iter()
359                .all(|segment| segment.end_ms - segment.start_ms == 239_999)
360        );
361    }
362
363    #[test]
364    fn rejects_count_that_does_not_fit_u16() {
365        let duration = u64::from(u16::MAX) * 234_999 + 5_001;
366        assert!(matches!(
367            plan_segments(duration),
368            Err(ExtractError::SegmentCountExceedsU16 { required: 65_536 })
369        ));
370    }
371
372    #[test]
373    fn maximum_supported_count_is_valid() {
374        let duration = u64::from(u16::MAX) * 234_999 + 5_000;
375        let plan = assert_plan_invariants(duration);
376        assert_eq!(plan.segments.len(), usize::from(u16::MAX));
377    }
378
379    #[test]
380    fn planning_is_deterministic() {
381        let first = plan_segments(1_234_567).unwrap();
382        let second = plan_segments(1_234_567).unwrap();
383        assert_eq!(first.policy.as_ref(), second.policy.as_ref());
384        assert_eq!(first.source_duration_ms, second.source_duration_ms);
385        assert_eq!(first.segments.len(), second.segments.len());
386        for (left, right) in first.segments.iter().zip(&second.segments) {
387            assert_eq!(left.ordinal, right.ordinal);
388            assert_eq!(left.start_ms, right.start_ms);
389            assert_eq!(left.end_ms, right.end_ms);
390        }
391    }
392
393    #[test]
394    fn contract_has_frozen_identity_and_prompt() {
395        let value = contract();
396        assert_eq!(value.schema_key.as_ref(), "gemini-speaker-35/1");
397        assert_eq!(value.response_mime, "application/json");
398        assert!(
399            value
400                .prompt
401                .starts_with("Analyze the attached audio directly.")
402        );
403        assert!(value.prompt.contains("0 filler_preference"));
404        assert!(value.prompt.contains("11 perceived_age"));
405        assert!(value.prompt.contains("34 aspiration_intensity"));
406        assert!(
407            value
408                .prompt
409                .trim_end()
410                .ends_with("Return the JSON object only.")
411        );
412        assert!(std::ptr::eq(value, contract()));
413    }
414
415    #[test]
416    fn accepts_reordered_scored_fields() {
417        let response = format!(
418            r#"{{"speakers":[{}],"recordingQuality":0,"status":"scored"}}"#,
419            speaker(0, 1_000)
420        );
421        let outcome = parse(&response, 1_000).expect("reordered keys are valid");
422        let ExtractionOutcome::Scored(clip) = outcome else {
423            panic!("expected scored response");
424        };
425        assert_eq!(clip.recording_quality, 0);
426        assert_eq!(clip.speakers.len(), 1);
427    }
428
429    #[test]
430    fn accepts_multi_speaker_scored_with_overlapping_durations() {
431        let mut second = speaker(1, 1_000);
432        second["primaryLanguage"] = json!("spa");
433        second["closestDialect"] = json!("Mexican Spanish");
434        let response = scored(vec![speaker(0, 1_000), second]).to_string();
435        let outcome = parse(&response, 1_000).expect("overlap is valid");
436        let ExtractionOutcome::Scored(clip) = outcome else {
437            panic!("expected scored response");
438        };
439        assert_eq!(clip.recording_quality, 82);
440        assert_eq!(clip.speakers.len(), 2);
441        assert_eq!(clip.speakers[0].speaker_ordinal, 0);
442        assert_eq!(clip.speakers[1].speaker_ordinal, 1);
443        assert_eq!(clip.speakers[1].primary_language.as_ref(), "spa");
444    }
445
446    #[test]
447    fn accepts_whole_clip_unscorable() {
448        let outcome = parse(
449            r#"{"reason":"Insufficient substantive speech.","status":"unscorable"}"#,
450            0,
451        )
452        .expect("whole-clip abstention is valid");
453        let ExtractionOutcome::Unscorable { reason } = outcome else {
454            panic!("expected unscorable response");
455        };
456        assert_eq!(reason, "Insufficient substantive speech.");
457    }
458
459    #[test]
460    fn accepts_quality_and_feature_boundaries() {
461        for quality in [0, 100] {
462            for maximum in [false, true] {
463                let mut values = if maximum {
464                    vec![100; FEATURE_COUNT]
465                } else {
466                    vec![0; FEATURE_COUNT]
467                };
468                if maximum {
469                    values[11] = 120;
470                }
471                let mut value = scored(vec![speaker(0, 1)]);
472                value["recordingQuality"] = json!(quality);
473                value["speakers"][0]["features"] = json!(values);
474                let outcome = parse(&value.to_string(), 1).expect("boundary is valid");
475                let ExtractionOutcome::Scored(clip) = outcome else {
476                    panic!("expected scored response");
477                };
478                let actual: &[u8; FEATURE_COUNT] = clip.speakers[0].features.as_ref();
479                assert_eq!(actual[11], if maximum { 120 } else { 0 });
480            }
481        }
482    }
483
484    #[test]
485    fn rejects_each_feature_above_its_upper_boundary() {
486        for index in 0..FEATURE_COUNT {
487            let mut values = base_features();
488            values[index] = if index == 11 { 121 } else { 101 };
489            let mut value = scored(vec![speaker(0, 1)]);
490            value["speakers"][0]["features"] = json!(values);
491            assert_rejected(value, 1);
492        }
493    }
494
495    #[test]
496    fn rejects_wrong_feature_counts() {
497        for count in [0, 1, FEATURE_COUNT - 1, FEATURE_COUNT + 1] {
498            let mut value = scored(vec![speaker(0, 1)]);
499            value["speakers"][0]["features"] = json!(vec![50; count]);
500            assert_rejected(value, 1);
501        }
502    }
503
504    #[test]
505    fn rejects_quality_and_duration_ranges() {
506        let mut quality = scored(vec![speaker(0, 1)]);
507        quality["recordingQuality"] = json!(101);
508        assert_rejected(quality, 1);
509
510        let mut zero = scored(vec![speaker(0, 1)]);
511        zero["speakers"][0]["usableSpeechMs"] = json!(0);
512        assert_rejected(zero, 1);
513
514        assert_rejected(scored(vec![speaker(0, 2)]), 1);
515        assert_rejected(scored(vec![speaker(0, 1)]), 0);
516    }
517
518    #[test]
519    fn validates_dialect_byte_lengths() {
520        let mut accepted = scored(vec![speaker(0, 1)]);
521        accepted["speakers"][0]["closestDialect"] = json!("é".repeat(64));
522        assert!(parse(&accepted.to_string(), 1).is_ok());
523
524        for dialect in [
525            "".to_owned(),
526            "a".repeat(129),
527            format!("{}a", "é".repeat(64)),
528        ] {
529            let mut value = scored(vec![speaker(0, 1)]);
530            value["speakers"][0]["closestDialect"] = json!(dialect);
531            assert_rejected(value, 1);
532        }
533    }
534
535    #[test]
536    fn validates_reason_byte_lengths() {
537        for reason in ["a".to_owned(), "a".repeat(240), "é".repeat(120)] {
538            let value = json!({"status": "unscorable", "reason": reason});
539            assert!(parse(&value.to_string(), 0).is_ok());
540        }
541
542        for reason in [
543            "".to_owned(),
544            "a".repeat(241),
545            format!("{}a", "é".repeat(120)),
546        ] {
547            let value = json!({"status": "unscorable", "reason": reason});
548            assert_rejected(value, 0);
549        }
550    }
551
552    #[test]
553    fn rejects_invalid_primary_language_keys() {
554        let languages = ["".to_owned(), "en g".to_owned(), "a".repeat(129)];
555        for language in languages {
556            let mut value = scored(vec![speaker(0, 1)]);
557            value["speakers"][0]["primaryLanguage"] = json!(language);
558            assert_rejected(value, 1);
559        }
560    }
561
562    #[test]
563    fn rejects_empty_and_noncontiguous_speaker_sets() {
564        assert_rejected(scored(vec![]), 1);
565        assert_rejected(scored(vec![speaker(1, 1)]), 1);
566        assert_rejected(scored(vec![speaker(0, 1), speaker(0, 1)]), 1);
567        assert_rejected(scored(vec![speaker(0, 1), speaker(2, 1)]), 1);
568    }
569
570    #[test]
571    fn rejects_malformed_and_non_object_json() {
572        for response in [
573            "",
574            "{",
575            "null",
576            "[]",
577            "true",
578            "42",
579            r#""scored""#,
580            r#"{"status":"unknown"}"#,
581            r#"{"status":null}"#,
582            r#"{"status":"unscorable","reason":"x"} trailing"#,
583            r#"{"status":"scored","recordingQuality":NaN,"speakers":[]}"#,
584            r#"{"status":"scored","recordingQuality":Infinity,"speakers":[]}"#,
585        ] {
586            assert!(parse(response, 1).is_err(), "{response}");
587        }
588    }
589
590    #[test]
591    fn rejects_missing_unknown_extra_and_mixed_top_level_fields() {
592        for value in [
593            json!({}),
594            json!({"status": "scored"}),
595            json!({"status": "scored", "recordingQuality": 50}),
596            json!({"status": "scored", "speakers": [speaker(0, 1)]}),
597            json!({"status": "unscorable"}),
598            json!({"status": "unscorable", "reason": "x", "extra": 1}),
599            json!({"status": "scored", "recordingQuality": 50, "speakers": [speaker(0, 1)], "extra": 1}),
600            json!({"status": "scored", "recordingQuality": 50, "speakers": [speaker(0, 1)], "reason": "x"}),
601            json!({"status": "unscorable", "reason": "x", "recordingQuality": 50}),
602            json!({"status": "unscorable", "reason": "x", "speakers": []}),
603        ] {
604            assert_rejected(value, 1);
605        }
606    }
607
608    #[test]
609    fn rejects_duplicate_json_fields() {
610        for response in [
611            r#"{"status":"unscorable","reason":"a","reason":"b"}"#,
612            r#"{"status":"unscorable","status":"unscorable","reason":"a"}"#,
613            r#"{"status":"scored","recordingQuality":50,"recordingQuality":51,"speakers":[]}"#,
614            r#"{"status":"scored","recordingQuality":50,"speakers":[{"speakerOrdinal":0,"speakerOrdinal":0,"primaryLanguage":"eng","closestDialect":"x","usableSpeechMs":1,"features":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}"#,
615        ] {
616            assert!(parse(response, 1).is_err(), "{response}");
617        }
618    }
619
620    #[test]
621    fn rejects_missing_and_extra_speaker_fields() {
622        for field in [
623            "speakerOrdinal",
624            "primaryLanguage",
625            "closestDialect",
626            "usableSpeechMs",
627            "features",
628        ] {
629            let mut value = scored(vec![speaker(0, 1)]);
630            value["speakers"][0].as_object_mut().unwrap().remove(field);
631            assert_rejected(value, 1);
632        }
633
634        let mut extra = scored(vec![speaker(0, 1)]);
635        extra["speakers"][0]["confidence"] = json!(50);
636        assert_rejected(extra, 1);
637    }
638
639    #[test]
640    fn rejects_wrong_top_level_types() {
641        for quality in [json!(null), json!("50"), json!(50.5), json!(-1)] {
642            let mut value = scored(vec![speaker(0, 1)]);
643            value["recordingQuality"] = quality;
644            assert_rejected(value, 1);
645        }
646
647        for speakers in [json!(null), json!("speaker"), json!({}), json!(50)] {
648            let mut value = scored(vec![speaker(0, 1)]);
649            value["speakers"] = speakers;
650            assert_rejected(value, 1);
651        }
652
653        for reason in [json!(null), json!(50), json!([]), json!({})] {
654            assert_rejected(json!({"status": "unscorable", "reason": reason}), 1);
655        }
656    }
657
658    #[test]
659    fn rejects_wrong_speaker_field_types() {
660        let cases = [
661            ("speakerOrdinal", json!(null)),
662            ("speakerOrdinal", json!("0")),
663            ("speakerOrdinal", json!(0.5)),
664            ("speakerOrdinal", json!(-1)),
665            ("primaryLanguage", json!(null)),
666            ("primaryLanguage", json!(1)),
667            ("closestDialect", json!(null)),
668            ("closestDialect", json!(1)),
669            ("usableSpeechMs", json!(null)),
670            ("usableSpeechMs", json!("1")),
671            ("usableSpeechMs", json!(1.5)),
672            ("usableSpeechMs", json!(-1)),
673            ("features", json!(null)),
674            ("features", json!("values")),
675            ("features", json!({})),
676        ];
677
678        for (field, replacement) in cases {
679            let mut value = scored(vec![speaker(0, 1)]);
680            value["speakers"][0][field] = replacement;
681            assert_rejected(value, 1);
682        }
683    }
684
685    #[test]
686    fn rejects_non_integer_partial_and_abstaining_features() {
687        for replacement in [
688            json!(null),
689            json!("50"),
690            json!(50.5),
691            json!(-1),
692            json!({"value": 50, "confidence": 80}),
693            json!([50, 60]),
694        ] {
695            let mut value = scored(vec![speaker(0, 1)]);
696            value["speakers"][0]["features"][4] = replacement;
697            assert_rejected(value, 1);
698        }
699
700        let raw = format!(
701            r#"{{"status":"scored","recordingQuality":50,"speakers":[{{"speakerOrdinal":0,"primaryLanguage":"eng","closestDialect":"x","usableSpeechMs":1,"features":[NaN,{}]}}]}}"#,
702            vec!["0"; FEATURE_COUNT - 1].join(",")
703        );
704        assert!(parse(&raw, 1).is_err());
705    }
706
707    #[test]
708    fn rejects_out_of_representation_numeric_values() {
709        let responses = [
710            r#"{"status":"scored","recordingQuality":256,"speakers":[]}"#.to_owned(),
711            format!(
712                r#"{{"status":"scored","recordingQuality":50,"speakers":[{{"speakerOrdinal":65536,"primaryLanguage":"eng","closestDialect":"x","usableSpeechMs":1,"features":[{}]}}]}}"#,
713                vec!["0"; FEATURE_COUNT].join(",")
714            ),
715            format!(
716                r#"{{"status":"scored","recordingQuality":50,"speakers":[{{"speakerOrdinal":0,"primaryLanguage":"eng","closestDialect":"x","usableSpeechMs":4294967296,"features":[{}]}}]}}"#,
717                vec!["0"; FEATURE_COUNT].join(",")
718            ),
719            format!(
720                r#"{{"status":"scored","recordingQuality":50,"speakers":[{{"speakerOrdinal":0,"primaryLanguage":"eng","closestDialect":"x","usableSpeechMs":1,"features":[256,{}]}}]}}"#,
721                vec!["0"; FEATURE_COUNT - 1].join(",")
722            ),
723        ];
724
725        for response in responses {
726            assert!(parse(&response, u64::MAX).is_err(), "{response}");
727        }
728    }
729}