1use 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#[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}
27pub type Fitter<'f> = dyn FnMut(&FitRequest<'_>) -> Vec<Peq> + 'f;
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub enum PeqDerivatives {
34 #[default]
36 TwoPoint,
37 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
150pub(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
191struct 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
299pub fn fit_peq(req: &FitRequest<'_>) -> Vec<Peq> {
302 fit_peq_until(req, &|| false)
303}
304
305pub fn fit_peq_until(req: &FitRequest<'_>, cancelled: &dyn Fn() -> bool) -> Vec<Peq> {
311 fit_peq_with(req, cancelled, PeqDerivatives::TwoPoint)
312}
313
314pub 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}
408fn 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
432pub 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#[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}