Skip to main content

secs_engine/
fit.rs

1//! The shared greedy/TRF PEQ fitter, ringing limits, GD cuts and acoustic XO target.
2//! Upstream: `correction_engine.py` `fit_peq`, `_ring_q_cap`, `_ring_boxes`,
3//! `fit_gd_cuts`, `CorrectionEngine._xo_target_peq`.
4
5use crate::grid::{SR_F, design_freqs};
6use faer::Mat;
7use secs_dsp::{
8    Complex64 as C, optimize, response,
9    smooth::{self, Fraction},
10};
11use secs_types::{chain::Peq, target::TargetCurve};
12use std::{cell::RefCell, f64::consts::PI};
13
14/// One `fit_peq` call's arguments. The grid and correction must have equal,
15/// nonzero length; frequencies, bounds and residuals must be finite and valid.
16#[derive(Debug, Clone)]
17pub struct FitRequest<'a> {
18    pub g: &'a [f64],
19    pub corr_db: &'a [f64],
20    pub n: usize,
21    pub lo: f64,
22    pub hi: f64,
23    pub boost: f64,
24    pub ring_ms: Option<f64>,
25    pub f_max: Option<f64>,
26}
27/// Production uses `fit_peq`; stage goldens inject recorded decisions here.
28/// Callers may reuse the result of an identical request across candidates.
29pub type Fitter<'f> = dyn FnMut(&FitRequest<'_>) -> Vec<Peq> + 'f;
30
31/// How PEQ fits differentiate their bands.
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub enum PeqDerivatives {
34    /// SciPy's two-point differences, as upstream.
35    #[default]
36    TwoPoint,
37    /// Closed-form cascade Jacobian (experimental).
38    Analytic,
39}
40
41pub fn ring_q_cap(f0: f64, gain_db: f64, ring_ms: f64) -> f64 {
42    PI * f0 * ring_ms / 1000.0 / 10_f64.powf(gain_db / 40.0)
43}
44
45pub(crate) fn ring_boxes(bands: &[Peq], lb: &mut [f64], ub: &mut [f64], ring_ms: f64) {
46    for (i, &(f0, _, gain)) in bands.iter().enumerate() {
47        let k = 3 * i;
48        lb[k] = lb[k].max((f0 / 1.2).ln());
49        ub[k] = ub[k].min((f0 * 1.2).ln()).max(lb[k] + 1e-3);
50        if gain > 0.0 {
51            lb[k + 2] = 0.0;
52            ub[k + 2] = (gain + 3.0).min(ub[k + 2]);
53        } else {
54            ub[k + 2] = (gain / 2.0).min(-0.25);
55        }
56        ub[k + 1] = ub[k + 1]
57            .exp()
58            .min(ring_q_cap(f0 / 1.2, ub[k + 2], ring_ms))
59            .max(0.31)
60            .ln();
61    }
62}
63
64pub(crate) struct Seed {
65    pub bands: Vec<Peq>,
66    pub x0: Vec<f64>,
67    pub lb: Vec<f64>,
68    pub ub: Vec<f64>,
69}
70
71pub(crate) fn seed(req: &FitRequest<'_>) -> Seed {
72    assert_eq!(req.g.len(), req.corr_db.len());
73    let mut bands = Vec::new();
74    for _ in 0..req.n {
75        let current = response::peq_response(req.g, &bands);
76        let r: Vec<_> = req
77            .g
78            .iter()
79            .zip(req.corr_db)
80            .zip(current)
81            .map(|((&g, &c), h)| {
82                if g >= req.lo && g <= req.hi {
83                    c - 20.0 * (response::magnitude(h) + 1e-12).log10()
84                } else {
85                    0.0
86                }
87            })
88            .collect();
89        let k = argmax(&r.iter().map(|v| v.abs()).collect::<Vec<_>>());
90        if r[k].abs() < 0.5 {
91            break;
92        }
93        let gain = r[k].clamp(-25.0, req.boost);
94        let half = r[k].abs() / 2.0;
95        let same = |i: usize| r[i].abs() >= half && r[i].signum() == r[k].signum();
96        let mut i0 = k;
97        let mut i1 = k;
98        while i0 > 0 && same(i0 - 1) {
99            i0 -= 1;
100        }
101        while i1 + 1 < r.len() && same(i1 + 1) {
102            i1 += 1;
103        }
104        let f0 = req.g[k];
105        let mut q = (f0 / (req.g[i1] - req.g[i0]).max(f0 * 0.02)).clamp(0.5, 15.0);
106        if let Some(ms) = req.ring_ms {
107            q = q.min(ring_q_cap(f0, gain, ms)).max(0.31);
108        }
109        bands.push((f0, q, gain));
110    }
111    let fmax = req.f_max.unwrap_or(2000.0_f64.max(req.hi * 1.2));
112    let mut lb = [15_f64.ln(), 0.3_f64.ln(), -25.0].repeat(bands.len());
113    let mut ub = [fmax.ln(), 20_f64.ln(), req.boost].repeat(bands.len());
114    if let Some(ms) = req.ring_ms {
115        ring_boxes(&bands, &mut lb, &mut ub, ms);
116    }
117    let x0 = bands
118        .iter()
119        .flat_map(|&(f, q, g)| [f.ln(), q.ln(), g])
120        .enumerate()
121        .map(|(i, x)| x.max(lb[i] + 1e-6).min(ub[i] - 1e-6))
122        .collect();
123    Seed { bands, x0, lb, ub }
124}
125
126pub(crate) fn residual(req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
127    let bands: Vec<_> = x
128        .as_chunks::<3>()
129        .0
130        .iter()
131        .map(|b| (b[0].exp(), b[1].exp(), b[2]))
132        .collect();
133    weighted_residual(req, response::peq_response(req.g, &bands))
134}
135
136fn weighted_residual(req: &FitRequest<'_>, h: Vec<C>) -> Vec<f64> {
137    h.into_iter()
138        .enumerate()
139        .map(|(i, h)| {
140            let w = if req.g[i] >= req.lo && req.g[i] <= req.hi {
141                1.0
142            } else {
143                0.15
144            };
145            w * (20.0 * (response::magnitude(h) + 1e-12).log10() - req.corr_db[i])
146        })
147        .collect()
148}
149
150/// Two-point Jacobian probes change only one band. Reuse the other bands'
151/// responses, but always multiply the complete cascade in its original order.
152pub(crate) struct ResidualCache {
153    z: Vec<C>,
154    bands: Vec<Option<([u64; 3], Vec<C>)>>,
155}
156
157impl ResidualCache {
158    pub(crate) fn new(g: &[f64], bands: usize) -> Self {
159        Self {
160            z: g.iter()
161                .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
162                .collect(),
163            bands: vec![None; bands],
164        }
165    }
166
167    pub(crate) fn evaluate(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
168        let mut h = vec![C::new(1.0, 0.0); self.z.len()];
169        for (cached, b) in self.bands.iter_mut().zip(x.as_chunks::<3>().0) {
170            let key = b.map(f64::to_bits);
171            if cached.as_ref().is_none_or(|(old, _)| *old != key) {
172                *cached = Some((
173                    key,
174                    response::peaking_response_z(&self.z, b[0].exp(), b[1].exp(), b[2], 48000.0),
175                ));
176            }
177            for (h, p) in h.iter_mut().zip(&cached.as_ref().unwrap().1) {
178                *h *= p;
179            }
180        }
181        weighted_residual(req, h)
182    }
183}
184
185struct AnalyticBand {
186    key: [u64; 3],
187    response: Vec<C>,
188    logarithmic_derivatives: Option<[Vec<C>; 3]>,
189}
190
191/// Analytic response and derivative cache. The two-point path keeps using
192/// `ResidualCache`, so its arithmetic and cache behavior remain unchanged.
193struct AnalyticResidualCache {
194    z: Vec<C>,
195    bands: Vec<Option<AnalyticBand>>,
196    key: Vec<u64>,
197    cascade: Vec<C>,
198}
199
200impl AnalyticResidualCache {
201    fn new(g: &[f64], bands: usize) -> Self {
202        Self {
203            z: g.iter()
204                .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
205                .collect(),
206            bands: (0..bands).map(|_| None).collect(),
207            key: Vec::new(),
208            cascade: Vec::new(),
209        }
210    }
211
212    fn response(&mut self, x: &[f64]) {
213        if self.key.len() == x.len()
214            && self.key.iter().zip(x).all(|(&a, &b)| a == b.to_bits())
215            && !self.cascade.is_empty()
216        {
217            return;
218        }
219        self.cascade.clear();
220        self.cascade.resize(self.z.len(), C::new(1.0, 0.0));
221        for (cached, b) in self.bands.iter_mut().zip(x.as_chunks::<3>().0) {
222            let key = b.map(f64::to_bits);
223            if cached.as_ref().is_none_or(|old| old.key != key) {
224                *cached = Some(AnalyticBand {
225                    key,
226                    response: response::peaking_response_z(
227                        &self.z,
228                        b[0].exp(),
229                        b[1].exp(),
230                        b[2],
231                        48000.0,
232                    ),
233                    logarithmic_derivatives: None,
234                });
235            }
236            for (h, p) in self
237                .cascade
238                .iter_mut()
239                .zip(&cached.as_ref().unwrap().response)
240            {
241                *h *= p;
242            }
243        }
244        self.key.clear();
245        self.key.extend(x.iter().map(|v| v.to_bits()));
246    }
247
248    fn evaluate(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
249        self.response(x);
250        weighted_residual(req, self.cascade.clone())
251    }
252
253    fn jacobian(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Mat<f64> {
254        self.response(x);
255        let factors: Vec<_> = self
256            .cascade
257            .iter()
258            .enumerate()
259            .map(|(i, &h)| {
260                let weight = if req.g[i] >= req.lo && req.g[i] <= req.hi {
261                    1.0
262                } else {
263                    0.15
264                };
265                let magnitude = response::magnitude(h);
266                weight * (20.0 / 10_f64.ln()) * magnitude / (magnitude + 1e-12)
267            })
268            .collect();
269        let mut jac = Mat::zeros(req.g.len(), x.len());
270        for (k, (cached, b)) in self.bands.iter_mut().zip(x.as_chunks::<3>().0).enumerate() {
271            let cached = cached.as_mut().unwrap();
272            let derivatives = cached.logarithmic_derivatives.get_or_insert_with(|| {
273                response::peaking_log_derivatives_z(&self.z, b[0].exp(), b[1].exp(), b[2], 48000.0)
274            });
275            for c in 0..3 {
276                for i in 0..req.g.len() {
277                    jac[(i, 3 * k + c)] = factors[i] * derivatives[c][i].re;
278                }
279            }
280        }
281        jac
282    }
283}
284
285pub(crate) fn result_bands(req: &FitRequest<'_>, x: &[f64]) -> Vec<Peq> {
286    x.as_chunks::<3>()
287        .0
288        .iter()
289        .map(|b| {
290            let (f, mut q, gain) = (b[0].exp(), b[1].exp(), b[2]);
291            if let Some(ms) = req.ring_ms {
292                q = q.min(ring_q_cap(f, gain, ms));
293            }
294            (f, q, gain)
295        })
296        .collect()
297}
298
299/// Fits the requested region, retaining upstream's out-of-region weight 0.15,
300/// default TRF tolerances, and 400/150 residual-evaluation caps.
301pub fn fit_peq(req: &FitRequest<'_>) -> Vec<Peq> {
302    fit_peq_until(req, &|| false)
303}
304
305/// `fit_peq` that gives up once `cancelled()` turns true. From then on the
306/// residual reads zero, so the trust-region solver stops at its next gradient
307/// test instead of running to its evaluation cap; the bands it returns are
308/// meaningless and the caller discards them. Until then the arithmetic is
309/// exactly `fit_peq`'s.
310pub fn fit_peq_until(req: &FitRequest<'_>, cancelled: &dyn Fn() -> bool) -> Vec<Peq> {
311    fit_peq_with(req, cancelled, PeqDerivatives::TwoPoint)
312}
313
314/// `fit_peq_until` with a chosen derivative; `TwoPoint` is the same arithmetic.
315pub fn fit_peq_with(
316    req: &FitRequest<'_>,
317    cancelled: &dyn Fn() -> bool,
318    derivatives: PeqDerivatives,
319) -> Vec<Peq> {
320    let seed = seed(req);
321    if seed.bands.is_empty() {
322        return Vec::new();
323    }
324    let max_nfev = if req.ring_ms.is_some() { 150 } else { 400 };
325    let sol = match derivatives {
326        PeqDerivatives::TwoPoint => {
327            let mut cache = ResidualCache::new(req.g, seed.bands.len());
328            optimize::least_squares(
329                |x| {
330                    if cancelled() {
331                        vec![0.0; req.g.len()]
332                    } else {
333                        cache.evaluate(req, x)
334                    }
335                },
336                &seed.x0,
337                &seed.lb,
338                &seed.ub,
339                max_nfev,
340            )
341        }
342        PeqDerivatives::Analytic => {
343            let cache = RefCell::new(AnalyticResidualCache::new(req.g, seed.bands.len()));
344            optimize::least_squares_analytic(
345                |x| {
346                    if cancelled() {
347                        vec![0.0; req.g.len()]
348                    } else {
349                        cache.borrow_mut().evaluate(req, x)
350                    }
351                },
352                |x| {
353                    if cancelled() {
354                        Mat::zeros(req.g.len(), x.len())
355                    } else {
356                        cache.borrow_mut().jacobian(req, x)
357                    }
358                },
359                &seed.x0,
360                &seed.lb,
361                &seed.ub,
362                optimize::Options {
363                    max_nfev,
364                    ..optimize::Options::default()
365                },
366                None,
367            )
368        }
369    }
370    .expect("valid PEQ fit");
371    result_bands(req, &sol.x)
372}
373
374fn argmax(v: &[f64]) -> usize {
375    assert!(!v.is_empty());
376    (1..v.len()).fold(0, |k, i| if v[i] > v[k] { i } else { k })
377}
378fn geomspace(lo: f64, hi: f64, n: usize) -> Vec<f64> {
379    let start = lo.log10();
380    let step = (hi.log10() - start) / (n - 1) as f64;
381    let mut g: Vec<_> = (0..n)
382        .map(|i| 10_f64.powf(i as f64 * step + start))
383        .collect();
384    g[0] = lo;
385    g[n - 1] = hi;
386    g
387}
388fn interp(x: f64, f: &[f64], y: &[f64]) -> f64 {
389    let i = f.partition_point(|v| *v <= x).saturating_sub(1);
390    if x <= f[0] {
391        y[0]
392    } else if i + 1 == f.len() || x == f[i] {
393        y[i]
394    } else {
395        (y[i + 1] - y[i]) / (f[i + 1] - f[i]) * (x - f[i]) + y[i]
396    }
397}
398fn median(v: &[f64]) -> f64 {
399    let mut v = v.to_vec();
400    v.sort_by(f64::total_cmp);
401    let k = v.len() / 2;
402    if v.len().is_multiple_of(2) {
403        (v[k - 1] + v[k]) / 2.0
404    } else {
405        v[k]
406    }
407}
408// NumPy's pairwise contiguous sum, used by the GD improvement decision.
409fn sum(v: &[f64]) -> f64 {
410    if v.len() < 8 {
411        return v.iter().fold(-0.0, |s, x| s + x);
412    }
413    if v.len() > 128 {
414        let m = (v.len() / 2) / 8 * 8;
415        return sum(&v[..m]) + sum(&v[m..]);
416    }
417    let mut r: [f64; 8] = v[..8].try_into().unwrap();
418    let end = v.len() / 8 * 8;
419    for c in v[8..end].as_chunks::<8>().0 {
420        for (s, x) in r.iter_mut().zip(c) {
421            *s += x;
422        }
423    }
424    let s = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
425    v[end..].iter().fold(s, |s, x| s + x)
426}
427
428pub fn fit_gd_cuts(f: &[f64], h: &[C], lo: f64, hi: f64, n_max: usize, depth_db: f64) -> Vec<Peq> {
429    fit_gd_cuts_until(f, h, lo, hi, n_max, depth_db, &|| false)
430}
431
432/// `fit_gd_cuts` that gives up once `cancelled()` turns true (TD-49), as
433/// `fit_peq_until` does: no further cut starts, and the running fit's residual
434/// reads zero so the trust-region solver stops at its next gradient test. The
435/// cuts it returns are then meaningless and the caller discards them. Until
436/// then the arithmetic is exactly `fit_gd_cuts`'s.
437pub fn fit_gd_cuts_until(
438    f: &[f64],
439    h: &[C],
440    lo: f64,
441    hi: f64,
442    n_max: usize,
443    depth_db: f64,
444    cancelled: &dyn Fn() -> bool,
445) -> Vec<Peq> {
446    let g = geomspace(lo, hi, 1500);
447    let mut cur = h.to_vec();
448    let mut cuts = Vec::new();
449    for _ in 0..n_max {
450        if cancelled() {
451            break;
452        }
453        let gd = response::gd_curve(&cur, f, &g, Fraction::Scalar(24.0)).expect("valid GD grid");
454        let base: Vec<_> = (0..g.len())
455            .map(|i| median(&gd[i.saturating_sub(40)..(i + 40).min(g.len())]))
456            .collect();
457        let exc: Vec<_> = gd.iter().zip(&base).map(|(g, b)| g - b).collect();
458        let k = argmax(&exc);
459        if exc[k] < 10.0 {
460            break;
461        }
462        let f0 = g[k];
463        let width = 2_f64.powf(1.0 / 3.0);
464        let gw: Vec<_> = g
465            .iter()
466            .copied()
467            .filter(|&g| g >= f0 / width && g <= f0 * width)
468            .collect();
469        let magnitude: Vec<_> = cur.iter().map(|&h| response::magnitude(h)).collect();
470        let mag0: Vec<_> = gw
471            .iter()
472            .map(|&g| 20.0 * (interp(g, f, &magnitude).abs() + 1e-12).log10())
473            .collect();
474        let res = |x: &[f64]| {
475            let hp: Vec<_> = cur
476                .iter()
477                .zip(response::peaking_response(
478                    f,
479                    x[0].exp(),
480                    x[1].exp(),
481                    x[2],
482                    SR_F,
483                ))
484                .map(|(h, p)| h * p)
485                .collect();
486            let gd2 =
487                response::gd_curve(&hp, f, &gw, Fraction::Scalar(24.0)).expect("valid GD grid");
488            let mag: Vec<_> = hp.iter().map(|&h| response::magnitude(h)).collect();
489            gd2.iter()
490                .zip(&gw)
491                .map(|(gd, &w)| (gd - interp(w, &g, &base)) / 5.0)
492                .chain(
493                    gw.iter()
494                        .zip(&mag0)
495                        .map(|(&w, m)| 20.0 * (interp(w, f, &mag).abs() + 1e-12).log10() - m),
496                )
497                .collect::<Vec<_>>()
498        };
499        let x0 = [f0.ln(), 8_f64.ln(), -1.0];
500        let lb = [(f0 / 1.1).ln(), 2_f64.ln(), -depth_db];
501        let ub = [(f0 * 1.1).ln(), 40_f64.ln(), 0.0];
502        let sol = optimize::least_squares(
503            |x| {
504                if cancelled() {
505                    vec![0.0; 2 * gw.len()]
506                } else {
507                    res(x)
508                }
509            },
510            &x0,
511            &lb,
512            &ub,
513            60,
514        )
515        .expect("valid GD fit");
516        if cancelled() {
517            break;
518        }
519        let band = (sol.x[0].exp(), sol.x[1].exp(), sol.x[2]);
520        let norm = |x: &[f64]| sum(&res(x).iter().map(|v| v * v).collect::<Vec<_>>());
521        if norm(&sol.x) >= norm(&[f0.ln(), 8_f64.ln(), 0.0]) * 0.95 || band.2 > -0.1 {
522            break;
523        }
524        cuts.push(band);
525        for (h, p) in cur
526            .iter_mut()
527            .zip(response::peaking_response(f, band.0, band.1, band.2, SR_F))
528        {
529            *h *= p;
530        }
531    }
532    cuts
533}
534
535/// Acoustic XO target before the electrical crossover; position order is
536/// preserved during power accumulation. No ringing cap is applied here.
537#[allow(clippy::too_many_arguments)]
538pub fn xo_target_peq(
539    by_pos: &[(f64, &[C])],
540    target: &TargetCurve,
541    level_db: f64,
542    lo: f64,
543    hi: f64,
544    bands: usize,
545    boost: f64,
546    f_max: Option<f64>,
547    fitter: &mut Fitter<'_>,
548) -> Vec<Peq> {
549    let f = design_freqs();
550    let mut power = vec![0.0; f.len()];
551    let mut weight = 0.0;
552    for &(w, h) in by_pos {
553        assert_eq!(h.len(), f.len());
554        weight += w;
555        for (p, &h) in power.iter_mut().zip(h) {
556            let m = response::magnitude(h);
557            *p += w * (m * m);
558        }
559    }
560    for p in &mut power {
561        *p /= weight;
562    }
563    let frac = secs_dsp::grid::frac_for(f);
564    let sm = smooth::smooth_power(f, &power, Fraction::PerBin(&frac)).expect("design grid");
565    let sm3 = smooth::smooth_power(f, &power, Fraction::Scalar(3.0)).expect("design grid");
566    let tgt = target.sample(&f.iter().map(|v| v.max(1.0)).collect::<Vec<_>>());
567    let corr: Vec<_> = sm
568        .iter()
569        .zip(sm3)
570        .zip(tgt)
571        .map(|((&s, s3), t)| {
572            (t + level_db - (10.0 * (s + 1e-30).log10()).max(10.0 * (s3 + 1e-30).log10() - 3.0))
573                .clamp(-25.0, boost)
574        })
575        .collect();
576    let g = geomspace(15.0, 2000.0, 500);
577    let c: Vec<_> = g
578        .iter()
579        .map(|&g| {
580            let soft = ((g / (lo / 1.4)).log2() / 0.5).clamp(0.0, 1.0)
581                * (((hi * 1.4) / g).log2() / 0.5).clamp(0.0, 1.0);
582            interp(g, f, &corr) * soft
583        })
584        .collect();
585    fitter(&FitRequest {
586        g: &g,
587        corr_db: &c,
588        n: bands,
589        lo,
590        hi,
591        boost,
592        ring_ms: None,
593        f_max,
594    })
595}
596
597#[cfg(any(test, feature = "test-support"))]
598#[doc(hidden)]
599#[derive(Debug, Clone)]
600pub struct RecordedFit {
601    pub g: Vec<f64>,
602    pub corr_db: Vec<f64>,
603    pub n: usize,
604    pub lo: f64,
605    pub hi: f64,
606    pub boost: f64,
607    pub ring_ms: Option<f64>,
608    pub f_max: Option<f64>,
609    pub result: Vec<Peq>,
610}
611#[cfg(any(test, feature = "test-support"))]
612impl RecordedFit {
613    pub fn request(&self) -> FitRequest<'_> {
614        FitRequest {
615            g: &self.g,
616            corr_db: &self.corr_db,
617            n: self.n,
618            lo: self.lo,
619            hi: self.hi,
620            boost: self.boost,
621            ring_ms: self.ring_ms,
622            f_max: self.f_max,
623        }
624    }
625}
626#[cfg(any(test, feature = "test-support"))]
627#[doc(hidden)]
628pub fn replay_fitter(
629    calls: Vec<RecordedFit>,
630    rtol: f64,
631) -> impl FnMut(&FitRequest<'_>) -> Vec<Peq> {
632    let mut next = 0;
633    move |req| {
634        let index = next;
635        let call = calls
636            .get(index)
637            .unwrap_or_else(|| panic!("fit call {index}: unexpected extra call"));
638        assert_eq!(req.n, call.n, "fit call {index}: n");
639        let mut worst = (0.0_f64, String::new());
640        for (name, a, b) in [
641            ("g", req.g, call.g.as_slice()),
642            ("corr_db", req.corr_db, call.corr_db.as_slice()),
643            (
644                "scalars",
645                &[req.lo, req.hi, req.boost],
646                &[call.lo, call.hi, call.boost],
647            ),
648        ] {
649            assert_eq!(a.len(), b.len(), "fit call {index}: {name} length");
650            for (i, (&a, &b)) in a.iter().zip(b).enumerate() {
651                let err = (a - b).abs() / b.abs().max(1.0);
652                if !err.is_finite() || err > worst.0 {
653                    worst = (err, format!("{name}[{i}] got={a:e} expected={b:e}"));
654                }
655            }
656        }
657        for (name, a, b) in [
658            ("ring_ms", req.ring_ms, call.ring_ms),
659            ("f_max", req.f_max, call.f_max),
660        ] {
661            assert_eq!(
662                a.is_some(),
663                b.is_some(),
664                "fit call {index}: {name} presence"
665            );
666            if let (Some(a), Some(b)) = (a, b) {
667                let err = (a - b).abs() / b.abs().max(1.0);
668                if !err.is_finite() || err > worst.0 {
669                    worst = (err, format!("{name} got={a:e} expected={b:e}"));
670                }
671            }
672        }
673        assert!(
674            worst.0.is_finite() && worst.0 <= rtol,
675            "fit call {index}: worst mismatch {} relative={} tolerance={rtol}",
676            worst.1,
677            worst.0
678        );
679        next += 1;
680        call.result.clone()
681    }
682}
683
684#[cfg(test)]
685mod analytic_tests {
686    use super::*;
687
688    #[test]
689    fn analytic_peq_jacobian_differentiates_the_weighted_residual() {
690        let g = geomspace(15.0, 3000.0, 100);
691        let corr = vec![0.0; g.len()];
692        let req = FitRequest {
693            g: &g,
694            corr_db: &corr,
695            n: 3,
696            lo: 20.0,
697            hi: 250.0,
698            boost: 6.0,
699            ring_ms: None,
700            f_max: None,
701        };
702        let x = [
703            30.0_f64.ln(),
704            1.0_f64.ln(),
705            -10.0,
706            80.0_f64.ln(),
707            4.0_f64.ln(),
708            3.0,
709            200.0_f64.ln(),
710            2.0_f64.ln(),
711            -5.0,
712        ];
713        let mut cache = AnalyticResidualCache::new(&g, 3);
714        let jac = cache.jacobian(&req, &x);
715        for j in 0..x.len() {
716            let samples: Vec<_> = [-2.0, -1.0, 1.0, 2.0]
717                .iter()
718                .map(|multiple| {
719                    let mut probe = x;
720                    probe[j] += multiple * 1e-3;
721                    residual(&req, &probe)
722                })
723                .collect();
724            let error = (0..g.len())
725                .map(|i| {
726                    let central = (samples[0][i] - 8.0 * samples[1][i] + 8.0 * samples[2][i]
727                        - samples[3][i])
728                        / 0.012;
729                    (jac[(i, j)] - central).abs()
730                })
731                .fold(0.0_f64, f64::max);
732            let scale = (0..g.len())
733                .map(|i| jac[(i, j)].abs())
734                .fold(1.0_f64, f64::max);
735            assert!(error < 3e-6 * scale, "column {j}: {error}/{scale}");
736        }
737        let cached = cache.evaluate(&req, &x);
738        assert!(
739            cached
740                .iter()
741                .zip(residual(&req, &x))
742                .all(|(a, b)| a.to_bits() == b.to_bits())
743        );
744    }
745
746    #[test]
747    fn analytic_peq_jacobian_retains_the_additive_magnitude_floor() {
748        let g = [100.0];
749        let corr = [0.0];
750        let req = FitRequest {
751            g: &g,
752            corr_db: &corr,
753            n: 16,
754            lo: 20.0,
755            hi: 250.0,
756            boost: 6.0,
757            ring_ms: None,
758            f_max: None,
759        };
760        let x = [100.0_f64.ln(), 2.0_f64.ln(), -25.0].repeat(16);
761        let mut cache = AnalyticResidualCache::new(&g, 16);
762        let jac = cache.jacobian(&req, &x);
763        let magnitude = response::magnitude(cache.cascade[0]);
764        let expected = magnitude / (magnitude + 1e-12);
765        assert!(expected > 0.0 && expected < 1e-6);
766        assert!((jac[(0, 2)] / expected - 1.0).abs() < 1e-8);
767    }
768}