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