#[cfg(test)]
mod tests;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub enum ParityResult {
Pass {
wer: f64,
timestamp_tolerance_ms: Option<u32>,
},
Fail {
wer: f64,
expected: String,
actual: String,
differences: Vec<ParityDifference>,
},
}
impl ParityResult {
#[must_use]
pub const fn is_pass(&self) -> bool {
matches!(self, Self::Pass { .. })
}
#[must_use]
pub const fn is_fail(&self) -> bool {
matches!(self, Self::Fail { .. })
}
}
#[derive(Debug, Clone)]
pub struct ParityDifference {
pub kind: DifferenceKind,
pub location: usize,
pub expected: String,
pub actual: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DifferenceKind {
Text,
Timestamp,
MissingSegment,
ExtraSegment,
Word,
}
#[derive(Debug, Clone)]
pub struct ParityConfig {
pub max_wer: f64,
pub timestamp_tolerance_ms: u32,
pub normalize_whitespace: bool,
pub normalize_punctuation: bool,
pub case_insensitive: bool,
}
impl Default for ParityConfig {
fn default() -> Self {
Self {
max_wer: 0.01, timestamp_tolerance_ms: 50, normalize_whitespace: true,
normalize_punctuation: false,
case_insensitive: false,
}
}
}
#[derive(Debug)]
pub struct ParityTest {
pub input: PathBuf,
pub cpp_output: String,
pub apr_output: String,
pub hf_output: Option<String>,
pub config: ParityConfig,
}
impl ParityTest {
#[must_use]
pub fn new(input: PathBuf, cpp_output: String, apr_output: String) -> Self {
Self {
input,
cpp_output,
apr_output,
hf_output: None,
config: ParityConfig::default(),
}
}
#[must_use]
pub fn with_hf_output(mut self, hf_output: String) -> Self {
self.hf_output = Some(hf_output);
self
}
#[must_use]
pub fn with_config(mut self, config: ParityConfig) -> Self {
self.config = config;
self
}
#[must_use]
pub fn verify_text_parity(&self) -> ParityResult {
let cpp_normalized = self.normalize_text(&self.cpp_output);
let apr_normalized = self.normalize_text(&self.apr_output);
let wer = calculate_wer(&cpp_normalized, &apr_normalized);
if wer <= self.config.max_wer {
ParityResult::Pass {
wer,
timestamp_tolerance_ms: None,
}
} else {
let differences = find_text_differences(&cpp_normalized, &apr_normalized);
ParityResult::Fail {
wer,
expected: cpp_normalized,
actual: apr_normalized,
differences,
}
}
}
fn normalize_text(&self, text: &str) -> String {
let mut result = text.to_string();
if self.config.normalize_whitespace {
result = result.split_whitespace().collect::<Vec<_>>().join(" ");
}
if self.config.normalize_punctuation {
result = strip_punctuation(&result);
}
if self.config.case_insensitive {
result = result.to_lowercase();
}
result
}
}
fn strip_punctuation(text: &str) -> String {
text.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect()
}
#[must_use]
pub fn calculate_wer(reference: &str, hypothesis: &str) -> f64 {
let normalize = |s: &str| -> Vec<String> {
strip_punctuation(&s.to_lowercase())
.split_whitespace()
.map(|w| w.to_string())
.collect()
};
let [ref_words, hyp_words] = [reference, hypothesis].map(normalize);
if ref_words.is_empty() {
return if hyp_words.is_empty() { 0.0 } else { 1.0 };
}
let distance = levenshtein_distance(&ref_words, &hyp_words);
distance as f64 / ref_words.len() as f64
}
fn levenshtein_distance<T: PartialEq>(a: &[T], b: &[T]) -> usize {
let m = a.len();
let n = b.len();
if m == 0 {
return n;
}
if n == 0 {
return m;
}
let mut prev = (0..=n).collect::<Vec<_>>();
let mut curr = vec![0; n + 1];
for (i, a_item) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, b_item) in b.iter().enumerate() {
let cost = usize::from(a_item != b_item);
curr[j + 1] = (prev[j + 1] + 1) .min(curr[j] + 1) .min(prev[j] + cost); }
std::mem::swap(&mut prev, &mut curr);
}
prev[n]
}
fn find_text_differences(expected: &str, actual: &str) -> Vec<ParityDifference> {
let [exp_words, act_words]: [Vec<&str>; 2] =
[expected, actual].map(|s| s.split_whitespace().collect());
let mut differences = Vec::new();
let max_len = exp_words.len().max(act_words.len());
for i in 0..max_len {
let exp = exp_words.get(i).copied().unwrap_or("");
let act = act_words.get(i).copied().unwrap_or("");
if exp != act {
let kind = if exp.is_empty() {
DifferenceKind::ExtraSegment
} else if act.is_empty() {
DifferenceKind::MissingSegment
} else {
DifferenceKind::Word
};
differences.push(ParityDifference {
kind,
location: i,
expected: exp.to_string(),
actual: act.to_string(),
});
}
}
differences
}
#[derive(Debug, Clone)]
pub struct ParityBenchmark {
pub cpp_rtf: f64,
pub apr_rtf: f64,
pub ratio: f64,
pub parity: bool,
}
impl ParityBenchmark {
#[must_use]
pub fn new(cpp_rtf: f64, apr_rtf: f64) -> Self {
let ratio = apr_rtf / cpp_rtf;
Self {
cpp_rtf,
apr_rtf,
ratio,
parity: ratio <= 1.1,
}
}
pub fn verify(&self) -> Result<(), ParityError> {
if self.ratio > 1.1 {
return Err(ParityError::PerformanceRegression {
cpp: self.cpp_rtf,
apr: self.apr_rtf,
ratio: self.ratio,
});
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ParityError {
#[error("Performance regression: whisper-apr ({apr:.3}x) is {ratio:.1}x of whisper.cpp ({cpp:.3}x), exceeds 1.1x threshold")]
PerformanceRegression {
cpp: f64,
apr: f64,
ratio: f64,
},
#[error("Text parity failed: WER {wer:.3} exceeds threshold {threshold:.3}")]
TextParityFailed {
wer: f64,
threshold: f64,
},
#[error("Timestamp parity failed: {delta_ms}ms difference exceeds {tolerance_ms}ms tolerance")]
TimestampParityFailed {
delta_ms: u32,
tolerance_ms: u32,
},
#[error("whisper.cpp binary not found at: {path}")]
WhisperCppNotFound {
path: String,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}