Skip to main content

codec_eval/stats/
rd_knee.rs

1//! Fixed-frame R-D curve parameterization with corner-based angles.
2//!
3//! Every encode lives in a triangle: the worst corner (max bpp, zero quality)
4//! is the origin, and the angle from that corner describes position on the
5//! rate-distortion tradeoff. This is comparable across codecs, corpora, and
6//! resolutions because the frame is fixed by the metric scales and a practical
7//! bpp ceiling.
8//!
9//! ## Fixed frame (web targeting)
10//!
11//! | Axis | Min | Max | Notes |
12//! |------|-----|-----|-------|
13//! | bpp  | 0   | 4.0 | Practical web ceiling |
14//! | s2   | 0   | 100 | SSIMULACRA2 scale |
15//! | ba   | 0   | 15  | Butteraugli practical floor (inverted) |
16//!
17//! ## Corner angle
18//!
19//! `θ = atan2(quality_norm * aspect, 1.0 - bpp_norm)`
20//!
21//! The aspect ratio is calibrated from the reference codec knee
22//! (mozjpeg 4:2:0 on CID22) so that the knee lands at exactly 45°.
23//!
24//! - θ < 0°  → worse than the worst corner (negative quality)
25//! - θ = 0°  → worst corner (max bpp, zero quality)
26//! - θ < 45° → compression-efficient (below the knee)
27//! - θ = 45° → reference knee (balanced tradeoff)
28//! - θ ≈ 52° → ideal diagonal (zero bpp, perfect quality)
29//! - θ > 52° → quality-dominated (spending bits for diminishing returns)
30//! - θ = 90° → no compression (max bpp, max quality)
31//! - θ > 90° → over-budget (bpp exceeds frame ceiling)
32//!
33//! The **knee** (45° tangent on the corpus-aggregate curve) is a landmark
34//! within this system, not the origin. Its angle tells you where the
35//! "balanced tradeoff" falls for a given codec.
36//!
37//! ## Dual-metric angles
38//!
39//! SSIMULACRA2 and Butteraugli produce different angles for the same encode.
40//! Comparing `theta_s2` and `theta_ba` reveals what kind of artifacts the
41//! codec configuration produces at that operating point.
42
43use serde::{Deserialize, Serialize};
44use std::collections::BTreeMap;
45use std::fmt::Write as _;
46
47// ---------------------------------------------------------------------------
48// Fixed frame
49// ---------------------------------------------------------------------------
50
51/// Fixed normalization frame for web-targeted R-D analysis.
52///
53/// Uses metric-native scales and an aspect ratio calibrated so the
54/// reference knee (mozjpeg 4:2:0 on CID22) lands at exactly 45 degrees.
55/// Angles are comparable across codecs and corpora.
56#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
57pub struct FixedFrame {
58    /// Maximum bpp (practical ceiling). Default: 4.0 for web.
59    pub bpp_max: f64,
60    /// SSIMULACRA2 scale maximum. Always 100.
61    pub s2_max: f64,
62    /// Butteraugli practical worst-case. Default: 15.0.
63    pub ba_max: f64,
64    /// Quality-axis stretch factor. Calibrated from reference knee so
65    /// that `atan2(q_norm * aspect, 1 - bpp_norm) = 45 deg` at the knee.
66    pub aspect: f64,
67}
68
69impl FixedFrame {
70    /// Standard web-targeting frame.
71    ///
72    /// Aspect ratio calibrated from CID22-training mozjpeg 4:2:0 s2 knee
73    /// at (0.7274 bpp, s2=65.10):
74    /// `aspect = (1 - 0.7274/4.0) / (65.10/100.0) = 1.2568`
75    pub const WEB: Self = Self {
76        bpp_max: 4.0,
77        s2_max: 100.0,
78        ba_max: 15.0,
79        aspect: (1.0 - 0.7274 / 4.0) / (65.10 / 100.0),
80    };
81
82    /// Compute the corner angle for an SSIMULACRA2 measurement.
83    ///
84    /// Origin is the worst corner: (bpp_max, s2=0).
85    /// The aspect ratio stretches the quality axis so the reference
86    /// knee is at 45 degrees. Angles can exceed 90 degrees or go
87    /// below 0 degrees for extreme encodes.
88    #[must_use]
89    pub fn s2_angle(&self, bpp: f64, s2: f64) -> f64 {
90        let bpp_norm = bpp / self.bpp_max;
91        let s2_norm = s2 / self.s2_max;
92        (s2_norm * self.aspect).atan2(1.0 - bpp_norm).to_degrees()
93    }
94
95    /// Compute the corner angle for a Butteraugli measurement.
96    ///
97    /// Butteraugli is inverted: lower = better. We normalize so that
98    /// ba=0 means quality_norm=1.0, ba=ba_max means quality_norm=0.0.
99    /// Same aspect ratio as s2 for comparable dual-angle analysis.
100    #[must_use]
101    pub fn ba_angle(&self, bpp: f64, ba: f64) -> f64 {
102        let bpp_norm = bpp / self.bpp_max;
103        let ba_norm = 1.0 - ba / self.ba_max;
104        (ba_norm * self.aspect).atan2(1.0 - bpp_norm).to_degrees()
105    }
106
107    /// Compute dual-angle position for an encode.
108    #[must_use]
109    pub fn position(&self, bpp: f64, s2: f64, ba: f64) -> RDPosition {
110        RDPosition {
111            theta_s2: self.s2_angle(bpp, s2),
112            theta_ba: self.ba_angle(bpp, ba),
113            bpp,
114            ssimulacra2: s2,
115            butteraugli: ba,
116        }
117    }
118}
119
120impl Default for FixedFrame {
121    fn default() -> Self {
122        Self::WEB
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Normalization (retained for knee detection on corpus-aggregate data)
128// ---------------------------------------------------------------------------
129
130/// Range for normalizing an axis to [0, 1].
131#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
132pub struct AxisRange {
133    pub min: f64,
134    pub max: f64,
135}
136
137impl AxisRange {
138    #[must_use]
139    pub fn new(min: f64, max: f64) -> Self {
140        debug_assert!(max > min, "AxisRange max must exceed min");
141        Self { min, max }
142    }
143
144    #[must_use]
145    pub fn normalize(&self, value: f64) -> f64 {
146        (value - self.min) / (self.max - self.min)
147    }
148
149    #[must_use]
150    pub fn denormalize(&self, norm: f64) -> f64 {
151        norm * (self.max - self.min) + self.min
152    }
153
154    #[must_use]
155    pub fn span(&self) -> f64 {
156        self.max - self.min
157    }
158}
159
160/// Direction of a quality metric.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162pub enum QualityDirection {
163    HigherIsBetter,
164    LowerIsBetter,
165}
166
167/// Normalization context for knee detection (uses per-curve observed ranges).
168#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
169pub struct NormalizationContext {
170    pub bpp_range: AxisRange,
171    pub quality_range: AxisRange,
172    pub direction: QualityDirection,
173}
174
175impl NormalizationContext {
176    #[must_use]
177    pub fn normalize_bpp(&self, bpp: f64) -> f64 {
178        self.bpp_range.normalize(bpp)
179    }
180
181    #[must_use]
182    pub fn normalize_quality(&self, raw_quality: f64) -> f64 {
183        match self.direction {
184            QualityDirection::HigherIsBetter => self.quality_range.normalize(raw_quality),
185            QualityDirection::LowerIsBetter => 1.0 - self.quality_range.normalize(raw_quality),
186        }
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Knee point (landmark within the fixed frame)
192// ---------------------------------------------------------------------------
193
194/// The 45° tangent point on a corpus-aggregate R-D curve.
195///
196/// Computed using per-curve normalization (where the slope = 1 in normalized
197/// space), then placed in the fixed frame as a landmark angle.
198#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
199pub struct RDKnee {
200    /// Bits per pixel at the knee (raw).
201    pub bpp: f64,
202
203    /// Quality metric value at the knee (raw units).
204    pub quality: f64,
205
206    /// Angle of this knee in the fixed-frame corner system (degrees).
207    /// Computed from `FixedFrame::s2_angle` or `FixedFrame::ba_angle`.
208    pub fixed_angle: f64,
209
210    /// The per-curve normalization context used for knee detection.
211    pub norm: NormalizationContext,
212}
213
214// ---------------------------------------------------------------------------
215// Calibration
216// ---------------------------------------------------------------------------
217
218/// Dual-metric calibration with knee landmarks in the fixed frame.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct RDCalibration {
221    /// The fixed frame used for angle computation.
222    pub frame: FixedFrame,
223
224    /// Knee in SSIMULACRA2 space.
225    pub ssimulacra2: RDKnee,
226
227    /// Knee in Butteraugli space.
228    pub butteraugli: RDKnee,
229
230    /// Which corpus was used.
231    pub corpus: String,
232
233    /// Codec this calibration applies to.
234    pub codec: String,
235
236    /// Number of images averaged.
237    pub image_count: usize,
238
239    /// ISO 8601 timestamp.
240    pub computed_at: String,
241}
242
243impl RDCalibration {
244    /// The bpp range where the two knees disagree.
245    #[must_use]
246    pub fn disagreement_range(&self) -> (f64, f64) {
247        let a = self.ssimulacra2.bpp;
248        let b = self.butteraugli.bpp;
249        (a.min(b), a.max(b))
250    }
251
252    /// Compute dual-angle position using the fixed frame.
253    #[must_use]
254    pub fn position(&self, bpp: f64, s2: f64, ba: f64) -> RDPosition {
255        self.frame.position(bpp, s2, ba)
256    }
257}
258
259// ---------------------------------------------------------------------------
260// Position in fixed-frame corner space
261// ---------------------------------------------------------------------------
262
263/// An encode's position in the fixed-frame corner coordinate system.
264///
265/// Both angles are measured from the worst corner (max bpp, zero quality).
266/// Higher angle = better (closer to the ideal of zero-cost perfect quality).
267///
268/// Comparing the two angles reveals artifact character:
269/// - `theta_s2 ≈ theta_ba` → uniform quality tradeoff
270/// - `theta_s2 > theta_ba` → better structural fidelity than local contrast
271/// - `theta_s2 < theta_ba` → better local contrast than structural fidelity
272#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
273pub struct RDPosition {
274    /// Corner angle in SSIMULACRA2 space (degrees, 0–90).
275    pub theta_s2: f64,
276
277    /// Corner angle in Butteraugli space (degrees, 0–90).
278    pub theta_ba: f64,
279
280    /// Raw bits per pixel.
281    pub bpp: f64,
282
283    /// Raw SSIMULACRA2 score (0–100, higher is better).
284    pub ssimulacra2: f64,
285
286    /// Raw Butteraugli distance (0+, lower is better).
287    pub butteraugli: f64,
288}
289
290impl RDPosition {
291    /// In the disagreement zone between the two knees.
292    #[must_use]
293    pub fn in_disagreement_zone(&self, cal: &RDCalibration) -> bool {
294        let (lo, hi) = cal.disagreement_range();
295        self.bpp >= lo && self.bpp <= hi
296    }
297
298    /// Which angular bin (by s2 angle).
299    #[must_use]
300    pub fn bin(&self, scheme: &BinScheme) -> AngleBin {
301        scheme.bin_for(self.theta_s2)
302    }
303
304    /// Dual-metric bin.
305    #[must_use]
306    pub fn dual_bin(&self, scheme: &BinScheme) -> DualAngleBin {
307        DualAngleBin {
308            s2: scheme.bin_for(self.theta_s2),
309            ba: scheme.bin_for(self.theta_ba),
310        }
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Angular binning
316// ---------------------------------------------------------------------------
317
318/// Defines how the [0°, 90°] range is divided into bins.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct BinScheme {
321    /// Center of the first bin (degrees).
322    pub start: f64,
323    /// Width of each bin (degrees).
324    pub width: f64,
325    /// Number of bins.
326    pub count: usize,
327}
328
329impl BinScheme {
330    /// Cover [lo, hi] with `count` equal-width bins.
331    #[must_use]
332    pub fn range(lo: f64, hi: f64, count: usize) -> Self {
333        let width = (hi - lo) / count as f64;
334        Self {
335            start: lo + width / 2.0,
336            width,
337            count,
338        }
339    }
340
341    /// Default: 18 bins of 5° each covering [0°, 90°].
342    #[must_use]
343    pub fn default_18() -> Self {
344        Self::range(0.0, 90.0, 18)
345    }
346
347    /// Fine: 36 bins of 2.5° each covering [0°, 90°].
348    #[must_use]
349    pub fn fine_36() -> Self {
350        Self::range(0.0, 90.0, 36)
351    }
352
353    /// Determine which bin an angle falls into.
354    #[must_use]
355    pub fn bin_for(&self, angle_deg: f64) -> AngleBin {
356        let first_edge = self.start - self.width / 2.0;
357        let offset = angle_deg - first_edge;
358        let idx = (offset / self.width).floor();
359        let idx = (idx.clamp(0.0, (self.count - 1) as f64)) as usize;
360        let center = self.start + idx as f64 * self.width;
361        AngleBin {
362            index: idx,
363            center,
364            width: self.width,
365        }
366    }
367
368    /// Iterate over all bins.
369    pub fn bins(&self) -> impl Iterator<Item = AngleBin> + '_ {
370        (0..self.count).map(move |i| {
371            let center = self.start + i as f64 * self.width;
372            AngleBin {
373                index: i,
374                center,
375                width: self.width,
376            }
377        })
378    }
379}
380
381/// A single angular bin.
382#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
383pub struct AngleBin {
384    pub index: usize,
385    pub center: f64,
386    pub width: f64,
387}
388
389impl AngleBin {
390    #[must_use]
391    pub fn lo(&self) -> f64 {
392        self.center - self.width / 2.0
393    }
394
395    #[must_use]
396    pub fn hi(&self) -> f64 {
397        self.center + self.width / 2.0
398    }
399
400    #[must_use]
401    pub fn contains(&self, angle_deg: f64) -> bool {
402        angle_deg >= self.lo() && angle_deg < self.hi()
403    }
404}
405
406/// Dual-metric bin.
407#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
408pub struct DualAngleBin {
409    pub s2: AngleBin,
410    pub ba: AngleBin,
411}
412
413// ---------------------------------------------------------------------------
414// Codec configuration tracking
415// ---------------------------------------------------------------------------
416
417/// A single tuning parameter value.
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
419#[serde(untagged)]
420pub enum ParamValue {
421    Int(i64),
422    Float(f64),
423    Bool(bool),
424    Text(String),
425}
426
427impl std::fmt::Display for ParamValue {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        match self {
430            Self::Int(v) => write!(f, "{v}"),
431            Self::Float(v) => write!(f, "{v}"),
432            Self::Bool(v) => write!(f, "{v}"),
433            Self::Text(v) => write!(f, "{v}"),
434        }
435    }
436}
437
438/// The full set of tuning knobs that produced a particular encode.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct CodecConfig {
441    pub codec: String,
442    pub version: String,
443    pub params: BTreeMap<String, ParamValue>,
444}
445
446impl CodecConfig {
447    #[must_use]
448    pub fn new(codec: impl Into<String>, version: impl Into<String>) -> Self {
449        Self {
450            codec: codec.into(),
451            version: version.into(),
452            params: BTreeMap::new(),
453        }
454    }
455
456    #[must_use]
457    pub fn with_param(mut self, key: impl Into<String>, value: ParamValue) -> Self {
458        self.params.insert(key.into(), value);
459        self
460    }
461
462    #[must_use]
463    pub fn fingerprint(&self) -> String {
464        let params: Vec<String> = self
465            .params
466            .iter()
467            .map(|(k, v)| format!("{k}={v}"))
468            .collect();
469        format!("{}@{} [{}]", self.codec, self.version, params.join(", "))
470    }
471}
472
473// ---------------------------------------------------------------------------
474// Pareto frontier
475// ---------------------------------------------------------------------------
476
477/// A point on the configuration-aware Pareto frontier.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct ConfiguredRDPoint {
480    pub position: RDPosition,
481    pub config: CodecConfig,
482    pub image: Option<String>,
483    pub encode_time_ms: Option<f64>,
484    pub decode_time_ms: Option<f64>,
485}
486
487/// Pareto frontier with angular binning.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct ConfiguredParetoFront {
490    pub calibration: RDCalibration,
491    pub scheme: BinScheme,
492    pub points: Vec<ConfiguredRDPoint>,
493}
494
495impl ConfiguredParetoFront {
496    /// Compute non-dominated front (bpp vs s2).
497    #[must_use]
498    pub fn compute(
499        points: Vec<ConfiguredRDPoint>,
500        calibration: RDCalibration,
501        scheme: BinScheme,
502    ) -> Self {
503        let mut front: Vec<ConfiguredRDPoint> = Vec::new();
504
505        for point in &points {
506            let dominated = front.iter().any(|p| {
507                p.position.bpp <= point.position.bpp
508                    && p.position.ssimulacra2 >= point.position.ssimulacra2
509                    && (p.position.bpp < point.position.bpp
510                        || p.position.ssimulacra2 > point.position.ssimulacra2)
511            });
512
513            if !dominated {
514                front.retain(|p| {
515                    !(point.position.bpp <= p.position.bpp
516                        && point.position.ssimulacra2 >= p.position.ssimulacra2
517                        && (point.position.bpp < p.position.bpp
518                            || point.position.ssimulacra2 > p.position.ssimulacra2))
519                });
520                front.push(point.clone());
521            }
522        }
523
524        front.sort_by(|a, b| {
525            a.position
526                .bpp
527                .partial_cmp(&b.position.bpp)
528                .unwrap_or(std::cmp::Ordering::Equal)
529        });
530
531        Self {
532            calibration,
533            scheme,
534            points: front,
535        }
536    }
537
538    #[must_use]
539    pub fn best_config_for_s2(&self, min_s2: f64) -> Option<&ConfiguredRDPoint> {
540        self.points
541            .iter()
542            .filter(|p| p.position.ssimulacra2 >= min_s2)
543            .min_by(|a, b| {
544                a.position
545                    .bpp
546                    .partial_cmp(&b.position.bpp)
547                    .unwrap_or(std::cmp::Ordering::Equal)
548            })
549    }
550
551    #[must_use]
552    pub fn best_config_for_ba(&self, max_ba: f64) -> Option<&ConfiguredRDPoint> {
553        self.points
554            .iter()
555            .filter(|p| p.position.butteraugli <= max_ba)
556            .min_by(|a, b| {
557                a.position
558                    .bpp
559                    .partial_cmp(&b.position.bpp)
560                    .unwrap_or(std::cmp::Ordering::Equal)
561            })
562    }
563
564    #[must_use]
565    pub fn best_config_for_bpp(&self, max_bpp: f64) -> Option<&ConfiguredRDPoint> {
566        self.points
567            .iter()
568            .filter(|p| p.position.bpp <= max_bpp)
569            .max_by(|a, b| {
570                a.position
571                    .ssimulacra2
572                    .partial_cmp(&b.position.ssimulacra2)
573                    .unwrap_or(std::cmp::Ordering::Equal)
574            })
575    }
576
577    #[must_use]
578    pub fn in_bin(&self, bin: &AngleBin) -> Vec<&ConfiguredRDPoint> {
579        self.points
580            .iter()
581            .filter(|p| bin.contains(p.position.theta_s2))
582            .collect()
583    }
584
585    #[must_use]
586    pub fn coverage(&self) -> Vec<(AngleBin, usize)> {
587        self.scheme
588            .bins()
589            .map(|bin| {
590                let count = self
591                    .points
592                    .iter()
593                    .filter(|p| bin.contains(p.position.theta_s2))
594                    .count();
595                (bin, count)
596            })
597            .collect()
598    }
599
600    #[must_use]
601    pub fn empty_bins(&self) -> Vec<AngleBin> {
602        self.coverage()
603            .into_iter()
604            .filter(|(_, count)| *count == 0)
605            .map(|(bin, _)| bin)
606            .collect()
607    }
608}
609
610// ---------------------------------------------------------------------------
611// Corpus aggregate and knee computation
612// ---------------------------------------------------------------------------
613
614/// A single encode result from one image at one quality level.
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct EncodeResult {
617    pub bpp: f64,
618    pub ssimulacra2: f64,
619    pub butteraugli: f64,
620    pub image: String,
621    pub config: CodecConfig,
622}
623
624/// Aggregated R-D data from a corpus.
625#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct CorpusAggregate {
627    pub corpus: String,
628    pub codec: String,
629    /// Averaged R-D points sorted by bpp: (bpp, mean_s2, mean_butteraugli).
630    pub curve: Vec<(f64, f64, f64)>,
631    pub image_count: usize,
632}
633
634impl CorpusAggregate {
635    /// Find the SSIMULACRA2 knee and express it in the fixed frame.
636    #[must_use]
637    pub fn ssimulacra2_knee(&self, frame: &FixedFrame) -> Option<RDKnee> {
638        self.find_knee_for(
639            QualityDirection::HigherIsBetter,
640            |(_b, s, _ba)| *s,
641            |bpp, quality| frame.s2_angle(bpp, quality),
642        )
643    }
644
645    /// Find the Butteraugli knee and express it in the fixed frame.
646    #[must_use]
647    pub fn butteraugli_knee(&self, frame: &FixedFrame) -> Option<RDKnee> {
648        self.find_knee_for(
649            QualityDirection::LowerIsBetter,
650            |(_b, _s, ba)| *ba,
651            |bpp, quality| frame.ba_angle(bpp, quality),
652        )
653    }
654
655    /// Compute the full dual-metric calibration.
656    #[must_use]
657    pub fn calibrate(&self, frame: &FixedFrame) -> Option<RDCalibration> {
658        let s2_knee = self.ssimulacra2_knee(frame)?;
659        let ba_knee = self.butteraugli_knee(frame)?;
660
661        Some(RDCalibration {
662            frame: *frame,
663            ssimulacra2: s2_knee,
664            butteraugli: ba_knee,
665            corpus: self.corpus.clone(),
666            codec: self.codec.clone(),
667            image_count: self.image_count,
668            computed_at: String::new(),
669        })
670    }
671
672    fn find_knee_for(
673        &self,
674        direction: QualityDirection,
675        extract: impl Fn(&(f64, f64, f64)) -> f64,
676        compute_fixed_angle: impl Fn(f64, f64) -> f64,
677    ) -> Option<RDKnee> {
678        if self.curve.len() < 3 {
679            return None;
680        }
681
682        let bpp_vals: Vec<f64> = self.curve.iter().map(|(b, _, _)| *b).collect();
683        let q_vals: Vec<f64> = self.curve.iter().map(&extract).collect();
684
685        let bpp_range = AxisRange::new(
686            *bpp_vals.iter().min_by(|a, b| a.partial_cmp(b).unwrap())?,
687            *bpp_vals.iter().max_by(|a, b| a.partial_cmp(b).unwrap())?,
688        );
689        let quality_range = AxisRange::new(
690            *q_vals.iter().min_by(|a, b| a.partial_cmp(b).unwrap())?,
691            *q_vals.iter().max_by(|a, b| a.partial_cmp(b).unwrap())?,
692        );
693
694        let norm = NormalizationContext {
695            bpp_range,
696            quality_range,
697            direction,
698        };
699
700        find_knee(&self.curve, &norm, &extract, &compute_fixed_angle)
701    }
702}
703
704/// Find the knee (45° tangent) on a per-curve normalized R-D curve,
705/// then express it in the fixed frame.
706fn find_knee(
707    curve: &[(f64, f64, f64)],
708    norm: &NormalizationContext,
709    extract_quality: &impl Fn(&(f64, f64, f64)) -> f64,
710    compute_fixed_angle: &impl Fn(f64, f64) -> f64,
711) -> Option<RDKnee> {
712    if curve.len() < 2 {
713        return None;
714    }
715
716    let mut slopes: Vec<(usize, f64)> = Vec::new();
717    for i in 0..curve.len() - 1 {
718        let bpp0 = norm.normalize_bpp(curve[i].0);
719        let bpp1 = norm.normalize_bpp(curve[i + 1].0);
720        let q0 = norm.normalize_quality(extract_quality(&curve[i]));
721        let q1 = norm.normalize_quality(extract_quality(&curve[i + 1]));
722
723        let d_bpp = bpp1 - bpp0;
724        if d_bpp.abs() < 1e-12 {
725            continue;
726        }
727
728        slopes.push((i, (q1 - q0) / d_bpp));
729    }
730
731    if slopes.is_empty() {
732        return None;
733    }
734
735    let crossing_idx = slopes
736        .iter()
737        .position(|(_, slope)| *slope <= 1.0)
738        .unwrap_or(slopes.len() / 2);
739
740    let (seg_idx, _) = slopes[crossing_idx];
741    let bpp = (curve[seg_idx].0 + curve[seg_idx + 1].0) / 2.0;
742    let quality = (extract_quality(&curve[seg_idx]) + extract_quality(&curve[seg_idx + 1])) / 2.0;
743
744    Some(RDKnee {
745        bpp,
746        quality,
747        fixed_angle: compute_fixed_angle(bpp, quality),
748        norm: *norm,
749    })
750}
751
752// ---------------------------------------------------------------------------
753// SVG plotting
754// ---------------------------------------------------------------------------
755
756/// Generate an SVG plot of the R-D curve with corner angle grid and knee markers.
757///
758/// Plots (bpp, s2) with angle reference lines radiating from the worst corner,
759/// and marks the knee positions.
760#[must_use]
761pub fn plot_rd_svg(curve: &[(f64, f64, f64)], calibration: &RDCalibration, title: &str) -> String {
762    let frame = &calibration.frame;
763    let margin = 60.0_f64;
764    let plot_w = 600.0_f64;
765    let plot_h = 400.0_f64;
766    let total_w = plot_w + margin * 2.0;
767    let total_h = plot_h + margin * 2.0;
768
769    // Coordinate transforms: data → SVG pixel
770    // bpp axis: 0 → margin, bpp_max → margin + plot_w
771    // s2 axis:  0 → margin + plot_h, s2_max → margin (SVG y is inverted)
772    let x_of = |bpp: f64| -> f64 { margin + (bpp / frame.bpp_max) * plot_w };
773    let y_of = |s2: f64| -> f64 { margin + plot_h - (s2.max(0.0) / frame.s2_max) * plot_h };
774
775    // Corner origin in SVG space: (bpp_max, s2=0) → bottom-right
776    let cx = x_of(frame.bpp_max);
777    let cy = y_of(0.0);
778
779    let mut svg = String::with_capacity(8192);
780
781    // Header
782    let _ = write!(
783        svg,
784        r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {total_w} {total_h}" font-family="monospace" font-size="11">"##
785    );
786
787    // Background
788    let _ = write!(
789        svg,
790        r##"<rect width="{total_w}" height="{total_h}" fill="#1a1a2e"/>"##
791    );
792
793    // Plot area background
794    let _ = write!(
795        svg,
796        r##"<rect x="{margin}" y="{margin}" width="{plot_w}" height="{plot_h}" fill="#16213e" stroke="#333" stroke-width="1"/>"##
797    );
798
799    // Angle reference lines from the corner
800    for deg in (0..=90).step_by(15) {
801        let rad = (deg as f64).to_radians();
802        let q_norm = rad.sin();
803        let r_norm = rad.cos(); // 1.0 - bpp_norm → bpp_norm = 1.0 - r_norm
804
805        // Line from corner to the edge of the plot
806        // Extend to hit plot boundary
807        let scale = if r_norm.abs() > 1e-6 {
808            (1.0 / r_norm).min(if q_norm.abs() > 1e-6 {
809                1.0 / q_norm
810            } else {
811                f64::MAX
812            })
813        } else if q_norm.abs() > 1e-6 {
814            1.0 / q_norm
815        } else {
816            1.0
817        };
818
819        let bpp_far = frame.bpp_max * (1.0 - r_norm * scale).clamp(0.0, 1.0);
820        let s2_far = (frame.s2_max * q_norm * scale).clamp(0.0, frame.s2_max);
821
822        let opacity = if deg == 45 { "0.4" } else { "0.15" };
823        let color = if deg == 45 { "#ffd700" } else { "#888" };
824
825        let _ = write!(
826            svg,
827            r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{color}" stroke-width="1" stroke-dasharray="4,4" opacity="{opacity}"/>"##,
828            cx,
829            cy,
830            x_of(bpp_far),
831            y_of(s2_far)
832        );
833
834        // Angle label near the corner
835        let label_dist = 35.0;
836        let lx = cx - label_dist * r_norm;
837        let ly = cy - label_dist * q_norm;
838        let _ = write!(
839            svg,
840            r##"<text x="{lx:.0}" y="{ly:.0}" fill="#666" text-anchor="middle" font-size="9">{deg}°</text>"##
841        );
842    }
843
844    // Grid lines
845    for bpp_tick in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5] {
846        let x = x_of(bpp_tick);
847        let _ = write!(
848            svg,
849            r##"<line x1="{x}" y1="{margin}" x2="{x}" y2="{}" stroke="#333" stroke-width="0.5"/>"##,
850            margin + plot_h
851        );
852        let _ = write!(
853            svg,
854            r##"<text x="{x}" y="{}" fill="#888" text-anchor="middle">{bpp_tick}</text>"##,
855            margin + plot_h + 16.0
856        );
857    }
858    for s2_tick in [0.0, 20.0, 40.0, 60.0, 80.0, 100.0] {
859        let y = y_of(s2_tick);
860        let _ = write!(
861            svg,
862            r##"<line x1="{margin}" y1="{y}" x2="{}" y2="{y}" stroke="#333" stroke-width="0.5"/>"##,
863            margin + plot_w
864        );
865        let _ = write!(
866            svg,
867            r##"<text x="{}" y="{}" fill="#888" text-anchor="end">{s2_tick:.0}</text>"##,
868            margin - 6.0,
869            y + 4.0
870        );
871    }
872
873    // R-D curve (s2)
874    if curve.len() >= 2 {
875        let mut path = String::from("M");
876        for (i, (bpp, s2, _ba)) in curve.iter().enumerate() {
877            let sep = if i == 0 { "" } else { " L" };
878            let _ = write!(path, "{sep}{:.1},{:.1}", x_of(*bpp), y_of(*s2));
879        }
880        let _ = write!(
881            svg,
882            r##"<path d="{path}" fill="none" stroke="#e74c3c" stroke-width="2.5" stroke-linejoin="round"/>"##
883        );
884
885        // Data points
886        for (bpp, s2, _ba) in curve {
887            let _ = write!(
888                svg,
889                r##"<circle cx="{:.1}" cy="{:.1}" r="3" fill="#e74c3c" opacity="0.8"/>"##,
890                x_of(*bpp),
891                y_of(*s2)
892            );
893        }
894    }
895
896    // Knee markers
897    let s2_knee = &calibration.ssimulacra2;
898    let kx = x_of(s2_knee.bpp);
899    let ky = y_of(s2_knee.quality);
900    let _ = write!(
901        svg,
902        r##"<circle cx="{kx:.1}" cy="{ky:.1}" r="7" fill="none" stroke="#ffd700" stroke-width="2.5"/>"##
903    );
904    let _ = write!(
905        svg,
906        r##"<text x="{:.0}" y="{:.0}" fill="#ffd700" font-size="10">s2 knee {:.1}° ({:.2} bpp, s2={:.1})</text>"##,
907        kx + 12.0,
908        ky - 4.0,
909        s2_knee.fixed_angle,
910        s2_knee.bpp,
911        s2_knee.quality
912    );
913
914    let ba_knee = &calibration.butteraugli;
915    // Find s2 value at the ba knee bpp (interpolate on the curve)
916    let s2_at_ba_knee = interpolate_curve_s2(curve, ba_knee.bpp).unwrap_or(50.0);
917    let bkx = x_of(ba_knee.bpp);
918    let bky = y_of(s2_at_ba_knee);
919    let _ = write!(
920        svg,
921        r##"<circle cx="{bkx:.1}" cy="{bky:.1}" r="7" fill="none" stroke="#3498db" stroke-width="2.5"/>"##
922    );
923    let _ = write!(
924        svg,
925        r##"<text x="{:.0}" y="{:.0}" fill="#3498db" font-size="10">ba knee {:.1}° ({:.2} bpp, ba={:.2})</text>"##,
926        bkx + 12.0,
927        bky + 14.0,
928        ba_knee.fixed_angle,
929        ba_knee.bpp,
930        ba_knee.quality
931    );
932
933    // Disagreement range shading
934    let (dis_lo, dis_hi) = calibration.disagreement_range();
935    let _ = write!(
936        svg,
937        r##"<rect x="{:.1}" y="{margin}" width="{:.1}" height="{plot_h}" fill="#ffd700" opacity="0.06"/>"##,
938        x_of(dis_lo),
939        x_of(dis_hi) - x_of(dis_lo)
940    );
941
942    // Axis labels
943    let _ = write!(
944        svg,
945        r##"<text x="{:.0}" y="{}" fill="#ccc" text-anchor="middle" font-size="12">bpp</text>"##,
946        margin + plot_w / 2.0,
947        margin + plot_h + 35.0
948    );
949    let _ = write!(
950        svg,
951        r##"<text x="{}" y="{:.0}" fill="#ccc" text-anchor="middle" font-size="12" transform="rotate(-90,{},{:.0})">SSIMULACRA2</text>"##,
952        margin - 40.0,
953        margin + plot_h / 2.0,
954        margin - 40.0,
955        margin + plot_h / 2.0
956    );
957
958    // Title
959    let _ = write!(
960        svg,
961        r##"<text x="{:.0}" y="{}" fill="#eee" text-anchor="middle" font-size="14" font-weight="bold">{title}</text>"##,
962        margin + plot_w / 2.0,
963        margin - 15.0
964    );
965
966    // Corner marker
967    let _ = write!(
968        svg,
969        r##"<circle cx="{cx:.0}" cy="{cy:.0}" r="4" fill="#ff6b6b"/>"##
970    );
971    let _ = write!(
972        svg,
973        r##"<text x="{:.0}" y="{:.0}" fill="#ff6b6b" font-size="9" text-anchor="end">origin</text>"##,
974        cx - 8.0,
975        cy + 4.0
976    );
977
978    svg.push_str("</svg>");
979    svg
980}
981
982/// Linearly interpolate s2 at a given bpp on the aggregate curve.
983fn interpolate_curve_s2(curve: &[(f64, f64, f64)], target_bpp: f64) -> Option<f64> {
984    if curve.len() < 2 {
985        return None;
986    }
987    for w in curve.windows(2) {
988        let (b0, s0, _) = w[0];
989        let (b1, s1, _) = w[1];
990        if target_bpp >= b0 && target_bpp <= b1 && (b1 - b0).abs() > 1e-12 {
991            let t = (target_bpp - b0) / (b1 - b0);
992            return Some(s0 + t * (s1 - s0));
993        }
994    }
995    None
996}
997
998// ---------------------------------------------------------------------------
999// Defaults
1000// ---------------------------------------------------------------------------
1001
1002/// Measured defaults from corpus calibration runs (2026-02-03).
1003///
1004/// Codec: mozjpeg 4:2:0 progressive with optimized scans.
1005/// Quality sweep: 10–98 step 4 (23 levels per image).
1006/// Fixed frame: bpp_max=4.0, s2_max=100, ba_max=15.
1007pub mod defaults {
1008    use super::{
1009        AxisRange, FixedFrame, NormalizationContext, QualityDirection, RDCalibration, RDKnee,
1010    };
1011
1012    /// MozJPEG 4:2:0 progressive on CID22-training (209 images, 512x512).
1013    ///
1014    /// s2 knee at 0.73 bpp (s2=65.10) -> fixed-frame angle 38.5 deg.
1015    /// ba knee at 0.70 bpp (ba=4.38) -> fixed-frame angle 40.7 deg.
1016    /// Disagreement range: 0.70-0.73 bpp (metrics nearly agree).
1017    #[must_use]
1018    pub fn mozjpeg_cid22() -> RDCalibration {
1019        let frame = FixedFrame::WEB;
1020        RDCalibration {
1021            frame,
1022            ssimulacra2: RDKnee {
1023                bpp: 0.7274,
1024                quality: 65.10,
1025                fixed_angle: frame.s2_angle(0.7274, 65.10),
1026                norm: NormalizationContext {
1027                    bpp_range: AxisRange::new(0.1760, 3.6274),
1028                    quality_range: AxisRange::new(-8.48, 87.99),
1029                    direction: QualityDirection::HigherIsBetter,
1030                },
1031            },
1032            butteraugli: RDKnee {
1033                bpp: 0.7048,
1034                quality: 4.378,
1035                fixed_angle: frame.ba_angle(0.7048, 4.378),
1036                norm: NormalizationContext {
1037                    bpp_range: AxisRange::new(0.1760, 3.6274),
1038                    quality_range: AxisRange::new(1.854, 11.663),
1039                    direction: QualityDirection::LowerIsBetter,
1040                },
1041            },
1042            corpus: "CID22-training".into(),
1043            codec: "mozjpeg-420-prog".into(),
1044            image_count: 209,
1045            computed_at: "2026-02-03T22:56:01Z".into(),
1046        }
1047    }
1048
1049    /// MozJPEG 4:2:0 progressive on CLIC2025-training (32 images, ~2048px).
1050    ///
1051    /// s2 knee at 0.46 bpp (s2=58.95) -> fixed-frame angle 33.7 deg.
1052    /// ba knee at 0.39 bpp (ba=5.19) -> fixed-frame angle 36.0 deg.
1053    /// Disagreement range: 0.39-0.46 bpp.
1054    #[must_use]
1055    pub fn mozjpeg_clic2025() -> RDCalibration {
1056        let frame = FixedFrame::WEB;
1057        RDCalibration {
1058            frame,
1059            ssimulacra2: RDKnee {
1060                bpp: 0.4623,
1061                quality: 58.95,
1062                fixed_angle: frame.s2_angle(0.4623, 58.95),
1063                norm: NormalizationContext {
1064                    bpp_range: AxisRange::new(0.1194, 3.0694),
1065                    quality_range: AxisRange::new(-16.94, 87.63),
1066                    direction: QualityDirection::HigherIsBetter,
1067                },
1068            },
1069            butteraugli: RDKnee {
1070                bpp: 0.3948,
1071                quality: 5.192,
1072                fixed_angle: frame.ba_angle(0.3948, 5.192),
1073                norm: NormalizationContext {
1074                    bpp_range: AxisRange::new(0.1194, 3.0694),
1075                    quality_range: AxisRange::new(1.895, 13.264),
1076                    direction: QualityDirection::LowerIsBetter,
1077                },
1078            },
1079            corpus: "CLIC2025-training".into(),
1080            codec: "mozjpeg-420-prog".into(),
1081            image_count: 32,
1082            computed_at: "2026-02-03T23:09:01Z".into(),
1083        }
1084    }
1085}
1086
1087// ---------------------------------------------------------------------------
1088// Tests
1089// ---------------------------------------------------------------------------
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::{
1094        AngleBin, AxisRange, BinScheme, CodecConfig, ConfiguredParetoFront, ConfiguredRDPoint,
1095        CorpusAggregate, FixedFrame, NormalizationContext, ParamValue, QualityDirection, defaults,
1096    };
1097
1098    fn make_test_curve() -> Vec<(f64, f64, f64)> {
1099        vec![
1100            (0.10, 25.0, 8.0),
1101            (0.20, 40.0, 5.5),
1102            (0.30, 52.0, 3.8),
1103            (0.50, 62.0, 2.5),
1104            (0.70, 70.0, 1.8),
1105            (1.00, 78.0, 1.2),
1106            (1.50, 84.0, 0.8),
1107            (2.00, 88.0, 0.6),
1108            (3.00, 92.0, 0.4),
1109        ]
1110    }
1111
1112    #[test]
1113    fn test_fixed_frame_s2_corner() {
1114        let f = FixedFrame::WEB;
1115        // Worst corner: (bpp_max, 0) → atan2(0, 0) ≈ 0°
1116        assert!(f.s2_angle(4.0, 0.0).abs() < 0.01);
1117        // Ideal diagonal: (0, s2_max) → atan2(1*aspect, 1) ≈ 51.5°
1118        let ideal = f.s2_angle(0.0, 100.0);
1119        assert!(ideal > 50.0 && ideal < 53.0, "ideal angle: {ideal}");
1120        // Reference knee: (0.7274, 65.10) → exactly 45°
1121        assert!((f.s2_angle(0.7274, 65.10) - 45.0).abs() < 0.1);
1122        // No compression: (bpp_max, s2_max) → atan2(aspect, 0) = 90°
1123        assert!((f.s2_angle(4.0, 100.0) - 90.0).abs() < 0.01);
1124        // Negative s2 → negative angle (allowed)
1125        assert!(f.s2_angle(2.0, -10.0) < 0.0);
1126        // Over-budget bpp → angle > 90° (allowed)
1127        assert!(f.s2_angle(5.0, 50.0) > 90.0);
1128    }
1129
1130    #[test]
1131    fn test_fixed_frame_ba_corner() {
1132        let f = FixedFrame::WEB;
1133        // Worst corner: (bpp_max, ba_max) → ba_norm=0, atan2(0, 0) = 0°
1134        assert!(f.ba_angle(4.0, 15.0).abs() < 0.01);
1135        // Ideal diagonal: (0, ba=0) → ba_norm=1, atan2(aspect, 1) ≈ 51.5°
1136        let ideal = f.ba_angle(0.0, 0.0);
1137        assert!(ideal > 50.0 && ideal < 53.0, "ba ideal angle: {ideal}");
1138    }
1139
1140    #[test]
1141    fn test_fixed_frame_comparable() {
1142        let f = FixedFrame::WEB;
1143        // Two encodes with same q_norm*aspect/(1-bpp_norm) ratio → same angle
1144        // At the reference knee: ratio = 1.0 → 45°
1145        let a = f.s2_angle(0.7274, 65.10); // the reference knee
1146        assert!((a - 45.0).abs() < 0.1);
1147        // Same proportional tradeoff at a different scale
1148        // s2_norm * aspect / (1 - bpp_norm) should be the same ratio
1149        // At knee: 0.651 * 1.257 / 0.818 = 1.0
1150        // At (2.0, 50.0): 0.50 * 1.257 / 0.50 = 1.257 → angle > 45°
1151        let b = f.s2_angle(2.0, 50.0);
1152        assert!(b > 45.0, "should be above knee: {b}");
1153    }
1154
1155    #[test]
1156    fn test_axis_range_normalize() {
1157        let r = AxisRange::new(0.0, 10.0);
1158        assert!((r.normalize(5.0) - 0.5).abs() < 1e-10);
1159    }
1160
1161    #[test]
1162    fn test_axis_range_roundtrip() {
1163        let r = AxisRange::new(2.0, 8.0);
1164        let val = 5.5;
1165        assert!((r.denormalize(r.normalize(val)) - val).abs() < 1e-10);
1166    }
1167
1168    #[test]
1169    fn test_quality_direction_higher_is_better() {
1170        let ctx = NormalizationContext {
1171            bpp_range: AxisRange::new(0.0, 3.0),
1172            quality_range: AxisRange::new(20.0, 100.0),
1173            direction: QualityDirection::HigherIsBetter,
1174        };
1175        assert!((ctx.normalize_quality(100.0) - 1.0).abs() < 1e-10);
1176        assert!(ctx.normalize_quality(20.0).abs() < 1e-10);
1177    }
1178
1179    #[test]
1180    fn test_quality_direction_lower_is_better() {
1181        let ctx = NormalizationContext {
1182            bpp_range: AxisRange::new(0.0, 3.0),
1183            quality_range: AxisRange::new(0.5, 12.0),
1184            direction: QualityDirection::LowerIsBetter,
1185        };
1186        assert!((ctx.normalize_quality(0.5) - 1.0).abs() < 1e-10);
1187        assert!(ctx.normalize_quality(12.0).abs() < 1e-10);
1188    }
1189
1190    #[test]
1191    fn test_knee_detection_s2() {
1192        let curve = make_test_curve();
1193        let agg = CorpusAggregate {
1194            corpus: "test".into(),
1195            codec: "test-codec".into(),
1196            curve,
1197            image_count: 1,
1198        };
1199
1200        let knee = agg
1201            .ssimulacra2_knee(&FixedFrame::WEB)
1202            .expect("should find knee");
1203        assert!(knee.bpp > 0.2, "knee bpp too low: {}", knee.bpp);
1204        assert!(knee.bpp < 2.0, "knee bpp too high: {}", knee.bpp);
1205        assert!(knee.quality > 40.0, "knee s2 too low: {}", knee.quality);
1206        assert!(knee.quality < 90.0, "knee s2 too high: {}", knee.quality);
1207        // Fixed-frame angle should be in a reasonable range
1208        assert!(
1209            knee.fixed_angle > 20.0,
1210            "angle too low: {}",
1211            knee.fixed_angle
1212        );
1213        assert!(
1214            knee.fixed_angle < 70.0,
1215            "angle too high: {}",
1216            knee.fixed_angle
1217        );
1218    }
1219
1220    #[test]
1221    fn test_knee_detection_ba() {
1222        let curve = make_test_curve();
1223        let agg = CorpusAggregate {
1224            corpus: "test".into(),
1225            codec: "test-codec".into(),
1226            curve,
1227            image_count: 1,
1228        };
1229
1230        let knee = agg
1231            .butteraugli_knee(&FixedFrame::WEB)
1232            .expect("should find knee");
1233        assert!(knee.bpp > 0.2);
1234        assert!(knee.bpp < 2.0);
1235        assert!(knee.fixed_angle > 20.0);
1236        assert!(knee.fixed_angle < 70.0);
1237    }
1238
1239    #[test]
1240    fn test_calibration_disagreement_range() {
1241        let curve = make_test_curve();
1242        let agg = CorpusAggregate {
1243            corpus: "test".into(),
1244            codec: "test-codec".into(),
1245            curve,
1246            image_count: 1,
1247        };
1248
1249        let cal = agg.calibrate(&FixedFrame::WEB).expect("should calibrate");
1250        let (lo, hi) = cal.disagreement_range();
1251        assert!(lo <= hi);
1252        assert!(lo > 0.0);
1253    }
1254
1255    #[test]
1256    fn test_defaults_knee_angles() {
1257        let cal = defaults::mozjpeg_cid22();
1258        // s2 knee should be at exactly 45° (this is the reference knee)
1259        assert!(
1260            (cal.ssimulacra2.fixed_angle - 45.0).abs() < 0.5,
1261            "s2 knee angle {:.1}° should be ~45°",
1262            cal.ssimulacra2.fixed_angle
1263        );
1264        // ba knee should be near 45° but not necessarily exact
1265        assert!(
1266            cal.butteraugli.fixed_angle > 40.0 && cal.butteraugli.fixed_angle < 55.0,
1267            "ba knee angle {:.1}° outside expected 40-55° range",
1268            cal.butteraugli.fixed_angle
1269        );
1270        // Both knees should be within 10° of each other for mozjpeg
1271        let diff = (cal.ssimulacra2.fixed_angle - cal.butteraugli.fixed_angle).abs();
1272        assert!(
1273            diff < 10.0,
1274            "knee angle difference {:.1}° too large (s2={:.1}°, ba={:.1}°)",
1275            diff,
1276            cal.ssimulacra2.fixed_angle,
1277            cal.butteraugli.fixed_angle
1278        );
1279    }
1280
1281    #[test]
1282    fn test_bin_scheme_range() {
1283        let scheme = BinScheme::default_18();
1284        assert_eq!(scheme.count, 18);
1285        assert!((scheme.width - 5.0).abs() < 1e-10);
1286
1287        let bins: Vec<AngleBin> = scheme.bins().collect();
1288        assert_eq!(bins.len(), 18);
1289        assert!((bins[0].center - 2.5).abs() < 1e-10);
1290        assert!((bins[17].center - 87.5).abs() < 1e-10);
1291    }
1292
1293    #[test]
1294    fn test_bin_assignment() {
1295        let scheme = BinScheme::default_18();
1296        let bin = scheme.bin_for(45.0);
1297        assert!(bin.contains(45.0));
1298    }
1299
1300    #[test]
1301    fn test_codec_config_fingerprint() {
1302        let config = CodecConfig::new("mozjpeg-rs", "0.5.0")
1303            .with_param("quality", ParamValue::Int(75))
1304            .with_param("trellis", ParamValue::Bool(true));
1305        let fp = config.fingerprint();
1306        assert!(fp.contains("mozjpeg-rs"));
1307        assert!(fp.contains("quality=75"));
1308    }
1309
1310    #[test]
1311    fn test_configured_pareto_front() {
1312        let cal = defaults::mozjpeg_cid22();
1313
1314        let points: Vec<ConfiguredRDPoint> = vec![
1315            ConfiguredRDPoint {
1316                position: cal.position(0.3, 50.0, 4.0),
1317                config: CodecConfig::new("test", "1.0").with_param("q", ParamValue::Int(30)),
1318                image: None,
1319                encode_time_ms: None,
1320                decode_time_ms: None,
1321            },
1322            ConfiguredRDPoint {
1323                position: cal.position(0.5, 65.0, 2.5),
1324                config: CodecConfig::new("test", "1.0").with_param("q", ParamValue::Int(50)),
1325                image: None,
1326                encode_time_ms: None,
1327                decode_time_ms: None,
1328            },
1329            ConfiguredRDPoint {
1330                position: cal.position(1.0, 80.0, 1.0),
1331                config: CodecConfig::new("test", "1.0").with_param("q", ParamValue::Int(80)),
1332                image: None,
1333                encode_time_ms: None,
1334                decode_time_ms: None,
1335            },
1336            ConfiguredRDPoint {
1337                position: cal.position(0.6, 60.0, 3.0),
1338                config: CodecConfig::new("test", "1.0").with_param("q", ParamValue::Int(45)),
1339                image: None,
1340                encode_time_ms: None,
1341                decode_time_ms: None,
1342            },
1343        ];
1344
1345        let scheme = BinScheme::default_18();
1346        let front = ConfiguredParetoFront::compute(points, cal, scheme);
1347
1348        // Dominated point should be removed
1349        assert_eq!(front.points.len(), 3);
1350
1351        // All angles should be positive for these well-behaved test points
1352        for p in &front.points {
1353            assert!(
1354                p.position.theta_s2 > 0.0,
1355                "s2 angle: {}",
1356                p.position.theta_s2
1357            );
1358            assert!(
1359                p.position.theta_ba > 0.0,
1360                "ba angle: {}",
1361                p.position.theta_ba
1362            );
1363        }
1364
1365        let best = front.best_config_for_s2(70.0).unwrap();
1366        assert_eq!(best.config.params.get("q"), Some(&ParamValue::Int(80)));
1367
1368        let best = front.best_config_for_bpp(0.5).unwrap();
1369        assert_eq!(best.config.params.get("q"), Some(&ParamValue::Int(50)));
1370    }
1371}