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 =
743        (extract_quality(&curve[seg_idx]) + extract_quality(&curve[seg_idx + 1])) / 2.0;
744
745    Some(RDKnee {
746        bpp,
747        quality,
748        fixed_angle: compute_fixed_angle(bpp, quality),
749        norm: *norm,
750    })
751}
752
753// ---------------------------------------------------------------------------
754// SVG plotting
755// ---------------------------------------------------------------------------
756
757/// Generate an SVG plot of the R-D curve with corner angle grid and knee markers.
758///
759/// Plots (bpp, s2) with angle reference lines radiating from the worst corner,
760/// and marks the knee positions.
761#[must_use]
762pub fn plot_rd_svg(
763    curve: &[(f64, f64, f64)],
764    calibration: &RDCalibration,
765    title: &str,
766) -> String {
767    let frame = &calibration.frame;
768    let margin = 60.0_f64;
769    let plot_w = 600.0_f64;
770    let plot_h = 400.0_f64;
771    let total_w = plot_w + margin * 2.0;
772    let total_h = plot_h + margin * 2.0;
773
774    // Coordinate transforms: data → SVG pixel
775    // bpp axis: 0 → margin, bpp_max → margin + plot_w
776    // s2 axis:  0 → margin + plot_h, s2_max → margin (SVG y is inverted)
777    let x_of = |bpp: f64| -> f64 { margin + (bpp / frame.bpp_max) * plot_w };
778    let y_of = |s2: f64| -> f64 { margin + plot_h - (s2.max(0.0) / frame.s2_max) * plot_h };
779
780    // Corner origin in SVG space: (bpp_max, s2=0) → bottom-right
781    let cx = x_of(frame.bpp_max);
782    let cy = y_of(0.0);
783
784    let mut svg = String::with_capacity(8192);
785
786    // Header
787    let _ = write!(
788        svg,
789        r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {total_w} {total_h}" font-family="monospace" font-size="11">"##
790    );
791
792    // Background
793    let _ = write!(
794        svg,
795        r##"<rect width="{total_w}" height="{total_h}" fill="#1a1a2e"/>"##
796    );
797
798    // Plot area background
799    let _ = write!(
800        svg,
801        r##"<rect x="{margin}" y="{margin}" width="{plot_w}" height="{plot_h}" fill="#16213e" stroke="#333" stroke-width="1"/>"##
802    );
803
804    // Angle reference lines from the corner
805    for deg in (0..=90).step_by(15) {
806        let rad = (deg as f64).to_radians();
807        let q_norm = rad.sin();
808        let r_norm = rad.cos(); // 1.0 - bpp_norm → bpp_norm = 1.0 - r_norm
809
810        // Line from corner to the edge of the plot
811        // Extend to hit plot boundary
812        let scale = if r_norm.abs() > 1e-6 {
813            (1.0 / r_norm).min(if q_norm.abs() > 1e-6 {
814                1.0 / q_norm
815            } else {
816                f64::MAX
817            })
818        } else if q_norm.abs() > 1e-6 {
819            1.0 / q_norm
820        } else {
821            1.0
822        };
823
824        let bpp_far = frame.bpp_max * (1.0 - r_norm * scale).clamp(0.0, 1.0);
825        let s2_far = (frame.s2_max * q_norm * scale).clamp(0.0, frame.s2_max);
826
827        let opacity = if deg == 45 { "0.4" } else { "0.15" };
828        let color = if deg == 45 { "#ffd700" } else { "#888" };
829
830        let _ = write!(
831            svg,
832            r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{color}" stroke-width="1" stroke-dasharray="4,4" opacity="{opacity}"/>"##,
833            cx, cy,
834            x_of(bpp_far), y_of(s2_far)
835        );
836
837        // Angle label near the corner
838        let label_dist = 35.0;
839        let lx = cx - label_dist * r_norm;
840        let ly = cy - label_dist * q_norm;
841        let _ = write!(
842            svg,
843            r##"<text x="{lx:.0}" y="{ly:.0}" fill="#666" text-anchor="middle" font-size="9">{deg}°</text>"##
844        );
845    }
846
847    // Grid lines
848    for bpp_tick in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5] {
849        let x = x_of(bpp_tick);
850        let _ = write!(
851            svg,
852            r##"<line x1="{x}" y1="{margin}" x2="{x}" y2="{}" stroke="#333" stroke-width="0.5"/>"##,
853            margin + plot_h
854        );
855        let _ = write!(
856            svg,
857            r##"<text x="{x}" y="{}" fill="#888" text-anchor="middle">{bpp_tick}</text>"##,
858            margin + plot_h + 16.0
859        );
860    }
861    for s2_tick in [0.0, 20.0, 40.0, 60.0, 80.0, 100.0] {
862        let y = y_of(s2_tick);
863        let _ = write!(
864            svg,
865            r##"<line x1="{margin}" y1="{y}" x2="{}" y2="{y}" stroke="#333" stroke-width="0.5"/>"##,
866            margin + plot_w
867        );
868        let _ = write!(
869            svg,
870            r##"<text x="{}" y="{}" fill="#888" text-anchor="end">{s2_tick:.0}</text>"##,
871            margin - 6.0,
872            y + 4.0
873        );
874    }
875
876    // R-D curve (s2)
877    if curve.len() >= 2 {
878        let mut path = String::from("M");
879        for (i, (bpp, s2, _ba)) in curve.iter().enumerate() {
880            let sep = if i == 0 { "" } else { " L" };
881            let _ = write!(path, "{sep}{:.1},{:.1}", x_of(*bpp), y_of(*s2));
882        }
883        let _ = write!(
884            svg,
885            r##"<path d="{path}" fill="none" stroke="#e74c3c" stroke-width="2.5" stroke-linejoin="round"/>"##
886        );
887
888        // Data points
889        for (bpp, s2, _ba) in curve {
890            let _ = write!(
891                svg,
892                r##"<circle cx="{:.1}" cy="{:.1}" r="3" fill="#e74c3c" opacity="0.8"/>"##,
893                x_of(*bpp),
894                y_of(*s2)
895            );
896        }
897    }
898
899    // Knee markers
900    let s2_knee = &calibration.ssimulacra2;
901    let kx = x_of(s2_knee.bpp);
902    let ky = y_of(s2_knee.quality);
903    let _ = write!(
904        svg,
905        r##"<circle cx="{kx:.1}" cy="{ky:.1}" r="7" fill="none" stroke="#ffd700" stroke-width="2.5"/>"##
906    );
907    let _ = write!(
908        svg,
909        r##"<text x="{:.0}" y="{:.0}" fill="#ffd700" font-size="10">s2 knee {:.1}° ({:.2} bpp, s2={:.1})</text>"##,
910        kx + 12.0, ky - 4.0, s2_knee.fixed_angle, s2_knee.bpp, s2_knee.quality
911    );
912
913    let ba_knee = &calibration.butteraugli;
914    // Find s2 value at the ba knee bpp (interpolate on the curve)
915    let s2_at_ba_knee = interpolate_curve_s2(curve, ba_knee.bpp).unwrap_or(50.0);
916    let bkx = x_of(ba_knee.bpp);
917    let bky = y_of(s2_at_ba_knee);
918    let _ = write!(
919        svg,
920        r##"<circle cx="{bkx:.1}" cy="{bky:.1}" r="7" fill="none" stroke="#3498db" stroke-width="2.5"/>"##
921    );
922    let _ = write!(
923        svg,
924        r##"<text x="{:.0}" y="{:.0}" fill="#3498db" font-size="10">ba knee {:.1}° ({:.2} bpp, ba={:.2})</text>"##,
925        bkx + 12.0, bky + 14.0, ba_knee.fixed_angle, ba_knee.bpp, ba_knee.quality
926    );
927
928    // Disagreement range shading
929    let (dis_lo, dis_hi) = calibration.disagreement_range();
930    let _ = write!(
931        svg,
932        r##"<rect x="{:.1}" y="{margin}" width="{:.1}" height="{plot_h}" fill="#ffd700" opacity="0.06"/>"##,
933        x_of(dis_lo),
934        x_of(dis_hi) - x_of(dis_lo)
935    );
936
937    // Axis labels
938    let _ = write!(
939        svg,
940        r##"<text x="{:.0}" y="{}" fill="#ccc" text-anchor="middle" font-size="12">bpp</text>"##,
941        margin + plot_w / 2.0,
942        margin + plot_h + 35.0
943    );
944    let _ = write!(
945        svg,
946        r##"<text x="{}" y="{:.0}" fill="#ccc" text-anchor="middle" font-size="12" transform="rotate(-90,{},{:.0})">SSIMULACRA2</text>"##,
947        margin - 40.0,
948        margin + plot_h / 2.0,
949        margin - 40.0,
950        margin + plot_h / 2.0
951    );
952
953    // Title
954    let _ = write!(
955        svg,
956        r##"<text x="{:.0}" y="{}" fill="#eee" text-anchor="middle" font-size="14" font-weight="bold">{title}</text>"##,
957        margin + plot_w / 2.0,
958        margin - 15.0
959    );
960
961    // Corner marker
962    let _ = write!(
963        svg,
964        r##"<circle cx="{cx:.0}" cy="{cy:.0}" r="4" fill="#ff6b6b"/>"##
965    );
966    let _ = write!(
967        svg,
968        r##"<text x="{:.0}" y="{:.0}" fill="#ff6b6b" font-size="9" text-anchor="end">origin</text>"##,
969        cx - 8.0, cy + 4.0
970    );
971
972    svg.push_str("</svg>");
973    svg
974}
975
976/// Linearly interpolate s2 at a given bpp on the aggregate curve.
977fn interpolate_curve_s2(curve: &[(f64, f64, f64)], target_bpp: f64) -> Option<f64> {
978    if curve.len() < 2 {
979        return None;
980    }
981    for w in curve.windows(2) {
982        let (b0, s0, _) = w[0];
983        let (b1, s1, _) = w[1];
984        if target_bpp >= b0 && target_bpp <= b1 && (b1 - b0).abs() > 1e-12 {
985            let t = (target_bpp - b0) / (b1 - b0);
986            return Some(s0 + t * (s1 - s0));
987        }
988    }
989    None
990}
991
992// ---------------------------------------------------------------------------
993// Defaults
994// ---------------------------------------------------------------------------
995
996/// Measured defaults from corpus calibration runs (2026-02-03).
997///
998/// Codec: mozjpeg 4:2:0 progressive with optimized scans.
999/// Quality sweep: 10–98 step 4 (23 levels per image).
1000/// Fixed frame: bpp_max=4.0, s2_max=100, ba_max=15.
1001pub mod defaults {
1002    use super::{
1003        AxisRange, FixedFrame, NormalizationContext, QualityDirection, RDCalibration, RDKnee,
1004    };
1005
1006    /// MozJPEG 4:2:0 progressive on CID22-training (209 images, 512x512).
1007    ///
1008    /// s2 knee at 0.73 bpp (s2=65.10) -> fixed-frame angle 38.5 deg.
1009    /// ba knee at 0.70 bpp (ba=4.38) -> fixed-frame angle 40.7 deg.
1010    /// Disagreement range: 0.70-0.73 bpp (metrics nearly agree).
1011    #[must_use]
1012    pub fn mozjpeg_cid22() -> RDCalibration {
1013        let frame = FixedFrame::WEB;
1014        RDCalibration {
1015            frame,
1016            ssimulacra2: RDKnee {
1017                bpp: 0.7274,
1018                quality: 65.10,
1019                fixed_angle: frame.s2_angle(0.7274, 65.10),
1020                norm: NormalizationContext {
1021                    bpp_range: AxisRange::new(0.1760, 3.6274),
1022                    quality_range: AxisRange::new(-8.48, 87.99),
1023                    direction: QualityDirection::HigherIsBetter,
1024                },
1025            },
1026            butteraugli: RDKnee {
1027                bpp: 0.7048,
1028                quality: 4.378,
1029                fixed_angle: frame.ba_angle(0.7048, 4.378),
1030                norm: NormalizationContext {
1031                    bpp_range: AxisRange::new(0.1760, 3.6274),
1032                    quality_range: AxisRange::new(1.854, 11.663),
1033                    direction: QualityDirection::LowerIsBetter,
1034                },
1035            },
1036            corpus: "CID22-training".into(),
1037            codec: "mozjpeg-420-prog".into(),
1038            image_count: 209,
1039            computed_at: "2026-02-03T22:56:01Z".into(),
1040        }
1041    }
1042
1043    /// MozJPEG 4:2:0 progressive on CLIC2025-training (32 images, ~2048px).
1044    ///
1045    /// s2 knee at 0.46 bpp (s2=58.95) -> fixed-frame angle 33.7 deg.
1046    /// ba knee at 0.39 bpp (ba=5.19) -> fixed-frame angle 36.0 deg.
1047    /// Disagreement range: 0.39-0.46 bpp.
1048    #[must_use]
1049    pub fn mozjpeg_clic2025() -> RDCalibration {
1050        let frame = FixedFrame::WEB;
1051        RDCalibration {
1052            frame,
1053            ssimulacra2: RDKnee {
1054                bpp: 0.4623,
1055                quality: 58.95,
1056                fixed_angle: frame.s2_angle(0.4623, 58.95),
1057                norm: NormalizationContext {
1058                    bpp_range: AxisRange::new(0.1194, 3.0694),
1059                    quality_range: AxisRange::new(-16.94, 87.63),
1060                    direction: QualityDirection::HigherIsBetter,
1061                },
1062            },
1063            butteraugli: RDKnee {
1064                bpp: 0.3948,
1065                quality: 5.192,
1066                fixed_angle: frame.ba_angle(0.3948, 5.192),
1067                norm: NormalizationContext {
1068                    bpp_range: AxisRange::new(0.1194, 3.0694),
1069                    quality_range: AxisRange::new(1.895, 13.264),
1070                    direction: QualityDirection::LowerIsBetter,
1071                },
1072            },
1073            corpus: "CLIC2025-training".into(),
1074            codec: "mozjpeg-420-prog".into(),
1075            image_count: 32,
1076            computed_at: "2026-02-03T23:09:01Z".into(),
1077        }
1078    }
1079}
1080
1081// ---------------------------------------------------------------------------
1082// Tests
1083// ---------------------------------------------------------------------------
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::{
1088        defaults, AngleBin, AxisRange, BinScheme, CodecConfig, ConfiguredParetoFront,
1089        ConfiguredRDPoint, CorpusAggregate, FixedFrame, NormalizationContext, ParamValue,
1090        QualityDirection,
1091    };
1092
1093    fn make_test_curve() -> Vec<(f64, f64, f64)> {
1094        vec![
1095            (0.10, 25.0, 8.0),
1096            (0.20, 40.0, 5.5),
1097            (0.30, 52.0, 3.8),
1098            (0.50, 62.0, 2.5),
1099            (0.70, 70.0, 1.8),
1100            (1.00, 78.0, 1.2),
1101            (1.50, 84.0, 0.8),
1102            (2.00, 88.0, 0.6),
1103            (3.00, 92.0, 0.4),
1104        ]
1105    }
1106
1107    #[test]
1108    fn test_fixed_frame_s2_corner() {
1109        let f = FixedFrame::WEB;
1110        // Worst corner: (bpp_max, 0) → atan2(0, 0) ≈ 0°
1111        assert!(f.s2_angle(4.0, 0.0).abs() < 0.01);
1112        // Ideal diagonal: (0, s2_max) → atan2(1*aspect, 1) ≈ 51.5°
1113        let ideal = f.s2_angle(0.0, 100.0);
1114        assert!(ideal > 50.0 && ideal < 53.0, "ideal angle: {ideal}");
1115        // Reference knee: (0.7274, 65.10) → exactly 45°
1116        assert!((f.s2_angle(0.7274, 65.10) - 45.0).abs() < 0.1);
1117        // No compression: (bpp_max, s2_max) → atan2(aspect, 0) = 90°
1118        assert!((f.s2_angle(4.0, 100.0) - 90.0).abs() < 0.01);
1119        // Negative s2 → negative angle (allowed)
1120        assert!(f.s2_angle(2.0, -10.0) < 0.0);
1121        // Over-budget bpp → angle > 90° (allowed)
1122        assert!(f.s2_angle(5.0, 50.0) > 90.0);
1123    }
1124
1125    #[test]
1126    fn test_fixed_frame_ba_corner() {
1127        let f = FixedFrame::WEB;
1128        // Worst corner: (bpp_max, ba_max) → ba_norm=0, atan2(0, 0) = 0°
1129        assert!(f.ba_angle(4.0, 15.0).abs() < 0.01);
1130        // Ideal diagonal: (0, ba=0) → ba_norm=1, atan2(aspect, 1) ≈ 51.5°
1131        let ideal = f.ba_angle(0.0, 0.0);
1132        assert!(ideal > 50.0 && ideal < 53.0, "ba ideal angle: {ideal}");
1133    }
1134
1135    #[test]
1136    fn test_fixed_frame_comparable() {
1137        let f = FixedFrame::WEB;
1138        // Two encodes with same q_norm*aspect/(1-bpp_norm) ratio → same angle
1139        // At the reference knee: ratio = 1.0 → 45°
1140        let a = f.s2_angle(0.7274, 65.10); // the reference knee
1141        assert!((a - 45.0).abs() < 0.1);
1142        // Same proportional tradeoff at a different scale
1143        // s2_norm * aspect / (1 - bpp_norm) should be the same ratio
1144        // At knee: 0.651 * 1.257 / 0.818 = 1.0
1145        // At (2.0, 50.0): 0.50 * 1.257 / 0.50 = 1.257 → angle > 45°
1146        let b = f.s2_angle(2.0, 50.0);
1147        assert!(b > 45.0, "should be above knee: {b}");
1148    }
1149
1150    #[test]
1151    fn test_axis_range_normalize() {
1152        let r = AxisRange::new(0.0, 10.0);
1153        assert!((r.normalize(5.0) - 0.5).abs() < 1e-10);
1154    }
1155
1156    #[test]
1157    fn test_axis_range_roundtrip() {
1158        let r = AxisRange::new(2.0, 8.0);
1159        let val = 5.5;
1160        assert!((r.denormalize(r.normalize(val)) - val).abs() < 1e-10);
1161    }
1162
1163    #[test]
1164    fn test_quality_direction_higher_is_better() {
1165        let ctx = NormalizationContext {
1166            bpp_range: AxisRange::new(0.0, 3.0),
1167            quality_range: AxisRange::new(20.0, 100.0),
1168            direction: QualityDirection::HigherIsBetter,
1169        };
1170        assert!((ctx.normalize_quality(100.0) - 1.0).abs() < 1e-10);
1171        assert!(ctx.normalize_quality(20.0).abs() < 1e-10);
1172    }
1173
1174    #[test]
1175    fn test_quality_direction_lower_is_better() {
1176        let ctx = NormalizationContext {
1177            bpp_range: AxisRange::new(0.0, 3.0),
1178            quality_range: AxisRange::new(0.5, 12.0),
1179            direction: QualityDirection::LowerIsBetter,
1180        };
1181        assert!((ctx.normalize_quality(0.5) - 1.0).abs() < 1e-10);
1182        assert!(ctx.normalize_quality(12.0).abs() < 1e-10);
1183    }
1184
1185    #[test]
1186    fn test_knee_detection_s2() {
1187        let curve = make_test_curve();
1188        let agg = CorpusAggregate {
1189            corpus: "test".into(),
1190            codec: "test-codec".into(),
1191            curve,
1192            image_count: 1,
1193        };
1194
1195        let knee = agg.ssimulacra2_knee(&FixedFrame::WEB).expect("should find knee");
1196        assert!(knee.bpp > 0.2, "knee bpp too low: {}", knee.bpp);
1197        assert!(knee.bpp < 2.0, "knee bpp too high: {}", knee.bpp);
1198        assert!(knee.quality > 40.0, "knee s2 too low: {}", knee.quality);
1199        assert!(knee.quality < 90.0, "knee s2 too high: {}", knee.quality);
1200        // Fixed-frame angle should be in a reasonable range
1201        assert!(knee.fixed_angle > 20.0, "angle too low: {}", knee.fixed_angle);
1202        assert!(knee.fixed_angle < 70.0, "angle too high: {}", knee.fixed_angle);
1203    }
1204
1205    #[test]
1206    fn test_knee_detection_ba() {
1207        let curve = make_test_curve();
1208        let agg = CorpusAggregate {
1209            corpus: "test".into(),
1210            codec: "test-codec".into(),
1211            curve,
1212            image_count: 1,
1213        };
1214
1215        let knee = agg.butteraugli_knee(&FixedFrame::WEB).expect("should find knee");
1216        assert!(knee.bpp > 0.2);
1217        assert!(knee.bpp < 2.0);
1218        assert!(knee.fixed_angle > 20.0);
1219        assert!(knee.fixed_angle < 70.0);
1220    }
1221
1222    #[test]
1223    fn test_calibration_disagreement_range() {
1224        let curve = make_test_curve();
1225        let agg = CorpusAggregate {
1226            corpus: "test".into(),
1227            codec: "test-codec".into(),
1228            curve,
1229            image_count: 1,
1230        };
1231
1232        let cal = agg.calibrate(&FixedFrame::WEB).expect("should calibrate");
1233        let (lo, hi) = cal.disagreement_range();
1234        assert!(lo <= hi);
1235        assert!(lo > 0.0);
1236    }
1237
1238    #[test]
1239    fn test_defaults_knee_angles() {
1240        let cal = defaults::mozjpeg_cid22();
1241        // s2 knee should be at exactly 45° (this is the reference knee)
1242        assert!(
1243            (cal.ssimulacra2.fixed_angle - 45.0).abs() < 0.5,
1244            "s2 knee angle {:.1}° should be ~45°",
1245            cal.ssimulacra2.fixed_angle
1246        );
1247        // ba knee should be near 45° but not necessarily exact
1248        assert!(
1249            cal.butteraugli.fixed_angle > 40.0 && cal.butteraugli.fixed_angle < 55.0,
1250            "ba knee angle {:.1}° outside expected 40-55° range",
1251            cal.butteraugli.fixed_angle
1252        );
1253        // Both knees should be within 10° of each other for mozjpeg
1254        let diff = (cal.ssimulacra2.fixed_angle - cal.butteraugli.fixed_angle).abs();
1255        assert!(
1256            diff < 10.0,
1257            "knee angle difference {:.1}° too large (s2={:.1}°, ba={:.1}°)",
1258            diff, cal.ssimulacra2.fixed_angle, cal.butteraugli.fixed_angle
1259        );
1260    }
1261
1262    #[test]
1263    fn test_bin_scheme_range() {
1264        let scheme = BinScheme::default_18();
1265        assert_eq!(scheme.count, 18);
1266        assert!((scheme.width - 5.0).abs() < 1e-10);
1267
1268        let bins: Vec<AngleBin> = scheme.bins().collect();
1269        assert_eq!(bins.len(), 18);
1270        assert!((bins[0].center - 2.5).abs() < 1e-10);
1271        assert!((bins[17].center - 87.5).abs() < 1e-10);
1272    }
1273
1274    #[test]
1275    fn test_bin_assignment() {
1276        let scheme = BinScheme::default_18();
1277        let bin = scheme.bin_for(45.0);
1278        assert!(bin.contains(45.0));
1279    }
1280
1281    #[test]
1282    fn test_codec_config_fingerprint() {
1283        let config = CodecConfig::new("mozjpeg-rs", "0.5.0")
1284            .with_param("quality", ParamValue::Int(75))
1285            .with_param("trellis", ParamValue::Bool(true));
1286        let fp = config.fingerprint();
1287        assert!(fp.contains("mozjpeg-rs"));
1288        assert!(fp.contains("quality=75"));
1289    }
1290
1291    #[test]
1292    fn test_configured_pareto_front() {
1293        let cal = defaults::mozjpeg_cid22();
1294
1295        let points: Vec<ConfiguredRDPoint> = vec![
1296            ConfiguredRDPoint {
1297                position: cal.position(0.3, 50.0, 4.0),
1298                config: CodecConfig::new("test", "1.0")
1299                    .with_param("q", ParamValue::Int(30)),
1300                image: None,
1301                encode_time_ms: None,
1302                decode_time_ms: None,
1303            },
1304            ConfiguredRDPoint {
1305                position: cal.position(0.5, 65.0, 2.5),
1306                config: CodecConfig::new("test", "1.0")
1307                    .with_param("q", ParamValue::Int(50)),
1308                image: None,
1309                encode_time_ms: None,
1310                decode_time_ms: None,
1311            },
1312            ConfiguredRDPoint {
1313                position: cal.position(1.0, 80.0, 1.0),
1314                config: CodecConfig::new("test", "1.0")
1315                    .with_param("q", ParamValue::Int(80)),
1316                image: None,
1317                encode_time_ms: None,
1318                decode_time_ms: None,
1319            },
1320            ConfiguredRDPoint {
1321                position: cal.position(0.6, 60.0, 3.0),
1322                config: CodecConfig::new("test", "1.0")
1323                    .with_param("q", ParamValue::Int(45)),
1324                image: None,
1325                encode_time_ms: None,
1326                decode_time_ms: None,
1327            },
1328        ];
1329
1330        let scheme = BinScheme::default_18();
1331        let front = ConfiguredParetoFront::compute(points, cal, scheme);
1332
1333        // Dominated point should be removed
1334        assert_eq!(front.points.len(), 3);
1335
1336        // All angles should be positive for these well-behaved test points
1337        for p in &front.points {
1338            assert!(p.position.theta_s2 > 0.0, "s2 angle: {}", p.position.theta_s2);
1339            assert!(p.position.theta_ba > 0.0, "ba angle: {}", p.position.theta_ba);
1340        }
1341
1342        let best = front.best_config_for_s2(70.0).unwrap();
1343        assert_eq!(best.config.params.get("q"), Some(&ParamValue::Int(80)));
1344
1345        let best = front.best_config_for_bpp(0.5).unwrap();
1346        assert_eq!(best.config.params.get("q"), Some(&ParamValue::Int(50)));
1347    }
1348}