1#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9#[derive(Debug, Clone, PartialEq)]
11pub struct EncodeCandidate {
12 pub name: String,
14 pub crf: u8,
16 pub preset: String,
18 pub estimated_kbps: u32,
20}
21
22impl EncodeCandidate {
23 #[must_use]
25 pub fn new(
26 name: impl Into<String>,
27 crf: u8,
28 preset: impl Into<String>,
29 estimated_kbps: u32,
30 ) -> Self {
31 Self {
32 name: name.into(),
33 crf,
34 preset: preset.into(),
35 estimated_kbps,
36 }
37 }
38
39 #[must_use]
41 pub fn is_lossless(&self) -> bool {
42 self.crf == 0
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct QualityComparison {
49 pub candidate_a: EncodeCandidate,
51 pub candidate_b: EncodeCandidate,
53 pub psnr_diff: f32,
55 pub ssim_diff: f32,
57 pub bitrate_diff_pct: f32,
59}
60
61impl QualityComparison {
62 #[must_use]
64 pub fn new(
65 candidate_a: EncodeCandidate,
66 candidate_b: EncodeCandidate,
67 psnr_diff: f32,
68 ssim_diff: f32,
69 bitrate_diff_pct: f32,
70 ) -> Self {
71 Self {
72 candidate_a,
73 candidate_b,
74 psnr_diff,
75 ssim_diff,
76 bitrate_diff_pct,
77 }
78 }
79
80 #[must_use]
84 pub fn winner_by_psnr(&self) -> &str {
85 if self.psnr_diff.abs() < 0.01 {
86 "tie"
87 } else if self.psnr_diff > 0.0 {
88 &self.candidate_a.name
89 } else {
90 &self.candidate_b.name
91 }
92 }
93
94 #[must_use]
101 pub fn winner_by_efficiency(&self) -> &str {
102 if self.bitrate_diff_pct.abs() < 0.1 {
104 return self.winner_by_psnr();
105 }
106 let score = if self.bitrate_diff_pct > 0.0 {
109 self.psnr_diff / self.bitrate_diff_pct
111 } else {
112 -self.psnr_diff / self.bitrate_diff_pct
114 };
115
116 if score >= 0.0 {
117 &self.candidate_a.name
118 } else {
119 &self.candidate_b.name
120 }
121 }
122}
123
124#[derive(Debug, Clone, Default)]
126pub struct AbTestSuite {
127 pub comparisons: Vec<QualityComparison>,
129}
130
131impl AbTestSuite {
132 #[must_use]
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 pub fn add(&mut self, comparison: QualityComparison) {
140 self.comparisons.push(comparison);
141 }
142
143 #[must_use]
145 pub fn comparison_count(&self) -> usize {
146 self.comparisons.len()
147 }
148
149 #[must_use]
153 pub fn best_candidate_by_psnr(&self) -> Option<String> {
154 if self.comparisons.is_empty() {
155 return None;
156 }
157 let mut scores: std::collections::HashMap<&str, i32> = std::collections::HashMap::new();
158 for cmp in &self.comparisons {
159 let winner = cmp.winner_by_psnr();
160 if winner != "tie" {
161 *scores.entry(winner).or_insert(0) += 1;
162 }
163 }
164 scores
165 .into_iter()
166 .max_by_key(|&(_, count)| count)
167 .map(|(name, _)| name.to_string())
168 }
169
170 #[must_use]
174 pub fn best_candidate_by_efficiency(&self) -> Option<String> {
175 if self.comparisons.is_empty() {
176 return None;
177 }
178 let mut scores: std::collections::HashMap<&str, i32> = std::collections::HashMap::new();
179 for cmp in &self.comparisons {
180 let winner = cmp.winner_by_efficiency();
181 if winner != "tie" {
182 *scores.entry(winner).or_insert(0) += 1;
183 }
184 }
185 scores
186 .into_iter()
187 .max_by_key(|&(_, count)| count)
188 .map(|(name, _)| name.to_string())
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn make_candidate(name: &str, crf: u8, kbps: u32) -> EncodeCandidate {
197 EncodeCandidate::new(name, crf, "medium", kbps)
198 }
199
200 #[test]
203 fn test_is_lossless_true() {
204 let c = make_candidate("lossless", 0, 50000);
205 assert!(c.is_lossless());
206 }
207
208 #[test]
209 fn test_is_lossless_false() {
210 let c = make_candidate("lossy", 23, 3000);
211 assert!(!c.is_lossless());
212 }
213
214 #[test]
215 fn test_candidate_name() {
216 let c = make_candidate("my-encoder", 18, 5000);
217 assert_eq!(c.name, "my-encoder");
218 }
219
220 #[test]
223 fn test_winner_by_psnr_a_wins() {
224 let cmp = QualityComparison::new(
225 make_candidate("A", 18, 4000),
226 make_candidate("B", 23, 3000),
227 2.0,
228 0.01,
229 33.0,
230 );
231 assert_eq!(cmp.winner_by_psnr(), "A");
232 }
233
234 #[test]
235 fn test_winner_by_psnr_b_wins() {
236 let cmp = QualityComparison::new(
237 make_candidate("A", 23, 3000),
238 make_candidate("B", 18, 4000),
239 -2.0,
240 -0.01,
241 -25.0,
242 );
243 assert_eq!(cmp.winner_by_psnr(), "B");
244 }
245
246 #[test]
247 fn test_winner_by_psnr_tie() {
248 let cmp = QualityComparison::new(
249 make_candidate("A", 18, 4000),
250 make_candidate("B", 18, 4000),
251 0.005,
252 0.0,
253 0.0,
254 );
255 assert_eq!(cmp.winner_by_psnr(), "tie");
256 }
257
258 #[test]
259 fn test_winner_by_efficiency_same_bitrate_falls_back_to_psnr() {
260 let cmp = QualityComparison::new(
261 make_candidate("A", 18, 4000),
262 make_candidate("B", 23, 4000),
263 3.0,
264 0.02,
265 0.05, );
267 assert_eq!(cmp.winner_by_efficiency(), "A");
268 }
269
270 #[test]
271 fn test_winner_by_efficiency_a_cheaper_and_better() {
272 let cmp = QualityComparison::new(
274 make_candidate("A", 20, 3000),
275 make_candidate("B", 18, 4000),
276 1.0, 0.01,
278 -25.0, );
280 assert_eq!(cmp.winner_by_efficiency(), "A");
281 }
282
283 #[test]
286 fn test_suite_empty() {
287 let suite = AbTestSuite::new();
288 assert_eq!(suite.comparison_count(), 0);
289 assert!(suite.best_candidate_by_psnr().is_none());
290 assert!(suite.best_candidate_by_efficiency().is_none());
291 }
292
293 #[test]
294 fn test_suite_add_increments_count() {
295 let mut suite = AbTestSuite::new();
296 let cmp = QualityComparison::new(
297 make_candidate("A", 18, 4000),
298 make_candidate("B", 23, 3000),
299 2.0,
300 0.01,
301 33.0,
302 );
303 suite.add(cmp);
304 assert_eq!(suite.comparison_count(), 1);
305 }
306
307 #[test]
308 fn test_suite_best_by_psnr() {
309 let mut suite = AbTestSuite::new();
310 for _ in 0..2 {
312 suite.add(QualityComparison::new(
313 make_candidate("A", 18, 4000),
314 make_candidate("B", 23, 3000),
315 2.0,
316 0.01,
317 33.0,
318 ));
319 }
320 assert_eq!(suite.best_candidate_by_psnr(), Some("A".to_string()));
321 }
322
323 #[test]
324 fn test_suite_best_by_efficiency() {
325 let mut suite = AbTestSuite::new();
326 for _ in 0..2 {
328 suite.add(QualityComparison::new(
329 make_candidate("A", 18, 5000),
330 make_candidate("B", 20, 3500),
331 -0.5, -0.002,
333 43.0, ));
335 }
336 assert_eq!(suite.best_candidate_by_efficiency(), Some("B".to_string()));
337 }
338}