Skip to main content

secs_engine/mimo/
iir.rs

1//! Port of SECS 2 `src/secs/mimo_iir.py` (GPL-3.0).
2//! Default joint TRF accumulates position blocks; the opt-in solver applies
3//! Jv/Jᵀv directly. Neither stacks a dense residual Jacobian (ADR 0007).
4
5use std::{cell::RefCell, f64::consts::PI};
6
7use faer::{Accum, Mat, Par, linalg::matmul::matmul};
8use secs_compute::Context;
9use secs_dsp::{
10    DspError, fft, optimize,
11    optimize::{JacobianOperator, NormalEquations, NormalOptions, OperatorOptions, XScale},
12    pairwise_sum, response,
13};
14
15use super::{MimoDesign, MimoRequest, design::third_octave_bands, support_path};
16use crate::{
17    C, Design, EngineConfig, EngineError, MeasurementSet, Note, Tuning, decay,
18    fit::{FitRequest, PeqDerivatives, fit_peq_with},
19    grid::design_freqs,
20};
21
22const FS: f64 = 600.0;
23/// Residuals of one support's seed fit: 160 frequencies, real and imaginary.
24const SEED_RESIDUALS: usize = 320;
25const N: usize = 720;
26const MU: f64 = 10.0;
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct IirSupport {
30    pub gain_db: f64,
31    pub delay_ms: f64,
32    pub polarity: i8,
33    pub peq: Vec<(f64, f64, f64)>,
34    pub allpass: Vec<(f64, f64)>,
35}
36
37impl IirSupport {
38    pub fn response(&self, f: &[f64]) -> Vec<C> {
39        self.response_z(f, &frequency_z(f))
40    }
41
42    fn response_z(&self, f: &[f64], z: &[C]) -> Vec<C> {
43        let gain = 10f64.powf(self.gain_db / 20.0) * f64::from(self.polarity);
44        let mut out: Vec<_> = f
45            .iter()
46            .map(|&f| C::from_polar(1.0, -2.0 * PI * f * self.delay_ms / 1000.0) * gain)
47            .collect();
48        for &(f0, q, g) in &self.peq {
49            for (h, p) in out
50                .iter_mut()
51                .zip(response::peaking_response_z(z, f0, q, g, 48000.0))
52            {
53                *h *= p;
54            }
55        }
56        for &(f0, q) in &self.allpass {
57            for (h, p) in out.iter_mut().zip(response::allpass2(f, f0, q)) {
58                *h *= p;
59            }
60        }
61        out
62    }
63}
64
65fn frequency_z(f: &[f64]) -> Vec<C> {
66    f.iter()
67        .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
68        .collect()
69}
70
71#[derive(Debug, Clone, PartialEq, Default)]
72pub struct MimoIir {
73    pub supports: Vec<(String, Vec<(String, IirSupport)>)>,
74    pub removed: Vec<(String, Vec<String>)>,
75    pub notes: Vec<Note>,
76}
77
78/// Joint IIR optimizer. The matrix-free path is explicit because an iterative
79/// trust-region subproblem follows a different numerical trajectory.
80#[derive(Debug, Clone, Copy, Default, PartialEq)]
81pub enum IirSolver {
82    #[default]
83    Dense,
84    MatrixFree {
85        max_cg_iterations: usize,
86        cg_rtol: f64,
87    },
88}
89
90/// How MIMO IIR fits differentiate the complex seed refinement and joint fit.
91/// Gain and delay columns are exact either way; the initial PEQ fit is selected
92/// independently by [`IirSolveOptions::seed_peq_derivatives`].
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
94pub enum IirDerivatives {
95    /// Upstream central differences (step 1e-4). Reproduces SECS 2.
96    #[default]
97    CentralDifference,
98    /// Closed-form logarithmic derivatives (experimental; results leave SECS 2).
99    Analytic,
100}
101
102#[derive(Debug, Clone, Copy)]
103pub struct IirSolveOptions {
104    pub solver: IirSolver,
105    /// Retry the original dense Jac/Unit solvers after an operator error. The
106    /// memory permit also includes dense workspaces when this is enabled.
107    pub dense_fallback: bool,
108    pub derivatives: IirDerivatives,
109    pub seed_peq_derivatives: PeqDerivatives,
110}
111
112impl Default for IirSolveOptions {
113    fn default() -> Self {
114        Self {
115            solver: IirSolver::Dense,
116            dense_fallback: true,
117            derivatives: IirDerivatives::CentralDifference,
118            seed_peq_derivatives: PeqDerivatives::TwoPoint,
119        }
120    }
121}
122
123impl IirSolveOptions {
124    fn validate(&self) -> Result<(), EngineError> {
125        if let IirSolver::MatrixFree {
126            max_cg_iterations,
127            cg_rtol,
128        } = self.solver
129            && (max_cg_iterations == 0 || !cg_rtol.is_finite() || cg_rtol <= 0.0 || cg_rtol >= 1.0)
130        {
131            return Err(EngineError::Input("invalid matrix-free IIR options".into()));
132        }
133        Ok(())
134    }
135}
136
137fn check(ctx: &Context) -> Result<(), EngineError> {
138    ctx.cancel().check().map_err(|_| EngineError::Cancelled)
139}
140
141fn per(t: &Tuning) -> usize {
142    2 + 3 * t.mimo_k_bands + 2 * t.mimo_a_bands
143}
144
145pub(crate) fn band_of(lo: f64, hi: f64, fsiso: f64, t: &Tuning) -> (f64, f64) {
146    let low = 15f64.max(lo * 0.9);
147    let high = 300f64.min(hi.min(fsiso) * 1.1);
148    if t.mimo_in_band && high > low * 1.5 {
149        (low, high)
150    } else {
151        (15.0, 300.0)
152    }
153}
154
155pub(crate) fn bounds(band: (f64, f64), t: &Tuning) -> (Vec<f64>, Vec<f64>) {
156    let mut lb = vec![-80.0, 0.0];
157    let mut ub = vec![t.mimo_gain_max_db, 250.0];
158    for _ in 0..t.mimo_k_bands {
159        lb.extend([band.0.ln(), 0.5f64.ln(), -t.mimo_band_db]);
160        ub.extend([band.1.ln(), t.mimo_q_max.ln(), t.mimo_band_db]);
161    }
162    for _ in 0..t.mimo_a_bands {
163        lb.extend([band.0.ln(), 0.3f64.ln()]);
164        ub.extend([band.1.ln(), 20f64.ln()]);
165    }
166    (lb, ub)
167}
168
169pub(crate) fn unpack(x: &[f64], pols: &[i8], t: &Tuning) -> Vec<IirSupport> {
170    x.chunks_exact(per(t))
171        .zip(pols)
172        .map(|(v, &polarity)| IirSupport {
173            gain_db: v[0],
174            delay_ms: v[1],
175            polarity,
176            peq: (0..t.mimo_k_bands)
177                .map(|k| (v[2 + 3 * k].exp(), v[3 + 3 * k].exp(), v[4 + 3 * k]))
178                .collect(),
179            allpass: (0..t.mimo_a_bands)
180                .map(|a| {
181                    let b = 2 + 3 * t.mimo_k_bands + 2 * a;
182                    (v[b].exp(), v[b + 1].exp())
183                })
184                .collect(),
185        })
186        .collect()
187}
188
189pub(crate) fn late_modes(xs: &[Vec<f64>], count: usize, lo: f64, hi: f64) -> Vec<f64> {
190    let mut spec = vec![0.0; 2049];
191    for x in xs {
192        let mut late = vec![0.0; 4096];
193        let begin = (decay::onset(x) + 90).min(x.len().min(4096));
194        let end = x.len().min(4096);
195        late[begin..end].copy_from_slice(&x[begin..end]);
196        for (s, h) in spec.iter_mut().zip(fft::rfft(&late)) {
197            let a = response::magnitude(h);
198            *s += a * a;
199        }
200    }
201    let f = secs_dsp::grid::freqs(4096, FS).expect("MIMO grid");
202    let masked: Vec<_> = spec
203        .iter()
204        .zip(&f)
205        .map(|(&s, &f)| if (lo..=hi).contains(&f) { s } else { 0.0 })
206        .collect();
207    let mut indices = secs_dsp::peaks::find_peaks(&masked, None);
208    indices.sort_by(|&a, &b| spec[b].total_cmp(&spec[a]));
209    indices.into_iter().take(count).map(|i| f[i]).collect()
210}
211
212pub(crate) struct SeedResult {
213    pub x: Vec<f64>,
214    pub polarity: i8,
215    pub cost: f64,
216    pub nfev: usize,
217    pub status: i32,
218}
219
220#[allow(clippy::too_many_arguments)]
221pub(crate) fn fit_one(
222    s: &[f64],
223    lo: f64,
224    hi: f64,
225    modes: &[f64],
226    band: (f64, f64),
227    t: &Tuning,
228    derivatives: IirDerivatives,
229    seed_peq_derivatives: PeqDerivatives,
230    ctx: &Context,
231) -> Result<SeedResult, EngineError> {
232    check(ctx)?;
233    let lo = lo.max(15.0);
234    let step = (hi.log10() - lo.log10()) / 159.0;
235    let mut f: Vec<_> = (0..160)
236        .map(|k| 10f64.powf(lo.log10() + k as f64 * step))
237        .collect();
238    f[0] = lo;
239    f[159] = hi;
240    let target: Vec<C> = f
241        .iter()
242        .map(|&f| {
243            s.iter()
244                .enumerate()
245                .map(|(k, &s)| C::from_polar(1.0, -2.0 * PI * f * k as f64 / FS) * s)
246                .sum()
247        })
248        .collect();
249    let k = decay::first_max(&s.iter().map(|v| v.abs()).collect::<Vec<_>>());
250    let polarity = if s[k] >= 0.0 { 1 } else { -1 };
251    let db: Vec<_> = target
252        .iter()
253        .map(|&h| 20.0 * (response::magnitude(h) + 1e-12).log10())
254        .collect();
255    let base = decay::median(&db);
256    let corr: Vec<_> = db.iter().map(|v| v - base).collect();
257    let mut peq = fit_peq_with(
258        &FitRequest {
259            g: &f,
260            corr_db: &corr,
261            n: t.mimo_k_bands,
262            lo,
263            hi,
264            boost: t.mimo_band_db,
265            ring_ms: None,
266            f_max: None,
267            bands: None,
268        },
269        &|| ctx.cancel().is_cancelled(),
270        seed_peq_derivatives,
271    );
272    check(ctx)?;
273    let middle = (lo * hi).sqrt();
274    peq.resize(t.mimo_k_bands, (middle, 1.0, 0.0));
275    let mut x0 = vec![base, 1000.0 * k as f64 / FS];
276    for (f, q, g) in peq {
277        x0.extend([f.ln(), q.ln(), g]);
278    }
279    for a in 0..t.mimo_a_bands {
280        x0.extend([
281            modes
282                .get(a)
283                .copied()
284                .unwrap_or(middle)
285                .clamp(band.0 * 1.03, band.1 * 0.97)
286                .ln(),
287            2f64.ln(),
288        ]);
289    }
290    let (lb, ub) = bounds(band, t);
291    for (k, v) in x0.iter_mut().enumerate() {
292        *v = v.clamp(lb[k] + 1e-6, ub[k] - 1e-6);
293    }
294    let norm = pairwise_sum(
295        &target
296            .iter()
297            .map(|&h| response::magnitude(h))
298            .collect::<Vec<_>>(),
299    ) / 160.0
300        + 1e-12;
301    let z = frequency_z(&f);
302    let cache = RefCell::new(Parts::default());
303    let residual = |x: &[f64]| {
304        if ctx.cancel().is_cancelled() {
305            return vec![0.0; SEED_RESIDUALS];
306        }
307        let mut cache = cache.borrow_mut();
308        cache.response(x, polarity, &f, &z, t);
309        let r: Vec<_> = cache
310            .s
311            .iter()
312            .zip(&target)
313            .map(|(&h, &v)| (h - v) / norm)
314            .collect();
315        r.iter()
316            .map(|v| v.re)
317            .chain(r.iter().map(|v| v.im))
318            .collect::<Vec<_>>()
319    };
320    let result = match derivatives {
321        IirDerivatives::CentralDifference => optimize::least_squares(residual, &x0, &lb, &ub, 300),
322        IirDerivatives::Analytic => {
323            let jacobian = |x: &[f64]| {
324                if ctx.cancel().is_cancelled() {
325                    return Mat::zeros(SEED_RESIDUALS, x.len());
326                }
327                let mut cache = cache.borrow_mut();
328                cache.derivatives(x, polarity, &f, &z, t, IirDerivatives::Analytic);
329                let half = SEED_RESIDUALS / 2;
330                Mat::from_fn(SEED_RESIDUALS, x.len(), |i, j| {
331                    if i < half {
332                        cache.d[j][i].re / norm
333                    } else {
334                        cache.d[j][i - half].im / norm
335                    }
336                })
337            };
338            optimize::least_squares_analytic(
339                residual,
340                jacobian,
341                &x0,
342                &lb,
343                &ub,
344                optimize::Options {
345                    max_nfev: 300,
346                    ..optimize::Options::default()
347                },
348                None,
349            )
350        }
351    };
352    check(ctx)?;
353    Ok(match result {
354        Ok(sol) => SeedResult {
355            x: sol.x,
356            polarity,
357            cost: sol.cost,
358            nfev: sol.nfev,
359            status: sol.status,
360        },
361        Err(_) => SeedResult {
362            cost: decay::energy(&residual(&x0)) * 0.5,
363            x: x0,
364            polarity,
365            nfev: 0,
366            status: -1,
367        },
368    })
369}
370
371#[derive(Clone)]
372pub(crate) struct Problem {
373    pub roles: Vec<String>,
374    pub f: Vec<f64>,
375    pub lp: Vec<C>,
376    pub n: usize,
377    pub nf: usize,
378    pub xs: Vec<Vec<f64>>,
379    pub wl: Vec<Vec<f64>>,
380    pub we: Vec<Vec<f64>>,
381    pub g: Vec<Vec<Vec<C>>>,
382    pub fir: Vec<Vec<f64>>,
383    pub ranges: Vec<(f64, f64)>,
384    pub modes: Vec<f64>,
385    pub fsiso: f64,
386    pub direct: f64,
387    pub level_db: f64,
388    pub seat_guard: bool,
389}
390
391#[derive(Default)]
392struct Parts {
393    key: Vec<u64>,
394    s: Vec<C>,
395    d: Vec<Vec<C>>,
396}
397
398impl Parts {
399    fn response(&mut self, v: &[f64], pol: i8, f: &[f64], z: &[C], t: &Tuning) {
400        if self.key.len() == v.len() && self.key.iter().zip(v).all(|(&a, &b)| a == b.to_bits()) {
401            return;
402        }
403        self.s = unpack(v, &[pol], t).remove(0).response_z(f, z);
404        self.key.clear();
405        self.key.extend(v.iter().map(|v| v.to_bits()));
406        self.d.clear();
407    }
408
409    fn derivatives(
410        &mut self,
411        v: &[f64],
412        pol: i8,
413        f: &[f64],
414        z: &[C],
415        t: &Tuning,
416        derivatives: IirDerivatives,
417    ) {
418        self.response(v, pol, f, z, t);
419        if !self.d.is_empty() {
420            return;
421        }
422        if derivatives == IirDerivatives::CentralDifference {
423            self.d = finite_difference_derivatives(v, &self.s, f, t);
424            return;
425        }
426        self.d = vec![vec![C::new(0.0, 0.0); f.len()]; per(t)];
427        for (k, &f) in f.iter().enumerate() {
428            self.d[0][k] = self.s[k] * 10f64.ln() / 20.0;
429            self.d[1][k] = self.s[k] * C::new(0.0, -2.0 * PI * f / 1000.0);
430        }
431        for k in 0..t.mimo_k_bands {
432            let base = 2 + 3 * k;
433            let derivatives = response::peaking_log_derivatives_z(
434                z,
435                v[base].exp(),
436                v[base + 1].exp(),
437                v[base + 2],
438                48000.0,
439            );
440            for (c, derivative) in derivatives.into_iter().enumerate() {
441                for (i, value) in derivative.into_iter().enumerate() {
442                    self.d[base + c][i] = self.s[i] * value;
443                }
444            }
445        }
446        for a in 0..t.mimo_a_bands {
447            let base = 2 + 3 * t.mimo_k_bands + 2 * a;
448            let derivatives =
449                response::allpass_log_derivatives(f, v[base].exp(), v[base + 1].exp());
450            for (c, derivative) in derivatives.into_iter().enumerate() {
451                for (i, value) in derivative.into_iter().enumerate() {
452                    self.d[base + c][i] = self.s[i] * value;
453                }
454            }
455        }
456    }
457}
458
459/// Upstream's central differences (step 1e-4) for the support factors. The
460/// gain and delay columns are exact. Analytic factor derivatives moved the
461/// joint fit outside SECS 2's spread (ADR 0012), so the port keeps these.
462fn finite_difference_derivatives(v: &[f64], s: &[C], f: &[f64], t: &Tuning) -> Vec<Vec<C>> {
463    let mut d = vec![vec![C::new(0.0, 0.0); f.len()]; per(t)];
464    for (k, &f) in f.iter().enumerate() {
465        d[0][k] = s[k] * 10f64.ln() / 20.0;
466        d[1][k] = s[k] * C::new(0.0, -2.0 * PI * f / 1000.0);
467    }
468    let h = 1e-4;
469    for k in 0..t.mimo_k_bands {
470        let base = 2 + 3 * k;
471        let factor =
472            response::peaking_response(f, v[base].exp(), v[base + 1].exp(), v[base + 2], 48000.0);
473        for c in 0..3 {
474            let mut up = [v[base], v[base + 1], v[base + 2]];
475            let mut dn = up;
476            up[c] += h;
477            dn[c] -= h;
478            let u = response::peaking_response(f, up[0].exp(), up[1].exp(), up[2], 48000.0);
479            let l = response::peaking_response(f, dn[0].exp(), dn[1].exp(), dn[2], 48000.0);
480            for i in 0..f.len() {
481                d[base + c][i] = secs_dsp::complex_div(s[i] * (u[i] - l[i]) / (2.0 * h), factor[i]);
482            }
483        }
484    }
485    for a in 0..t.mimo_a_bands {
486        let base = 2 + 3 * t.mimo_k_bands + 2 * a;
487        let factor = response::allpass2(f, v[base].exp(), v[base + 1].exp());
488        for c in 0..2 {
489            let mut up = [v[base], v[base + 1]];
490            let mut dn = up;
491            up[c] += h;
492            dn[c] -= h;
493            let u = response::allpass2(f, up[0].exp(), up[1].exp());
494            let l = response::allpass2(f, dn[0].exp(), dn[1].exp());
495            for i in 0..f.len() {
496                d[base + c][i] = secs_dsp::complex_div(s[i] * (u[i] - l[i]) / (2.0 * h), factor[i]);
497            }
498        }
499    }
500    d
501}
502
503fn time_product(a: &[C], b: &[C], nf: usize, n: usize) -> Vec<f64> {
504    let mut out = fft::irfft(&a.iter().zip(b).map(|(a, b)| a * b).collect::<Vec<_>>(), nf);
505    out.truncate(n);
506    out
507}
508
509pub(crate) struct Objective<'a> {
510    pb: &'a Problem,
511    t: &'a Tuning,
512    ctx: &'a Context,
513    pols: &'a [i8],
514    weff: Vec<Vec<f64>>,
515    scale: f64,
516    z: Vec<C>,
517    cache: Vec<Parts>,
518    derivatives: IirDerivatives,
519    operator: Option<OperatorState>,
520}
521
522struct OperatorState {
523    /// Per support, parameter and frequency. Retained independently of trial
524    /// response caches, so a rejected residual cannot change Jv or Jᵀv.
525    derivatives: Vec<Vec<Vec<C>>>,
526    level: Vec<f64>,
527}
528
529impl<'a> Objective<'a> {
530    pub(crate) fn new(pb: &'a Problem, t: &'a Tuning, ctx: &'a Context, pols: &'a [i8]) -> Self {
531        let band: Vec<_> =
532            pb.f.iter()
533                .map(|&f| f >= 15.0 && f <= pb.fsiso * 1.2)
534                .collect();
535        let count = band.iter().filter(|&&b| b).count().max(1);
536        let energy = decay::energy(&pb.xs[0]);
537        let weff = (0..pb.roles.len())
538            .map(|r| {
539                let mut gbar = vec![0.0; pb.f.len()];
540                for g in &pb.g {
541                    for (sum, &h) in gbar.iter_mut().zip(&g[r]) {
542                        *sum += response::magnitude(h);
543                    }
544                }
545                for v in &mut gbar {
546                    *v /= pb.xs.len() as f64;
547                }
548                let reference = decay::median(
549                    &gbar
550                        .iter()
551                        .zip(&band)
552                        .filter(|(_, b)| **b)
553                        .map(|(&v, _)| v)
554                        .collect::<Vec<_>>(),
555                );
556                let factor = t.mimo_effort * (energy / count as f64).sqrt();
557                gbar.iter()
558                    .zip(&band)
559                    .map(|(&g, &b)| {
560                        factor
561                            * if b {
562                                reference / (g + 0.05 * reference)
563                            } else {
564                                0.0
565                            }
566                    })
567                    .collect()
568            })
569            .collect();
570        Self {
571            pb,
572            t,
573            ctx,
574            pols,
575            weff,
576            scale: energy.sqrt(),
577            z: frequency_z(&pb.f),
578            cache: (0..pb.roles.len()).map(|_| Parts::default()).collect(),
579            derivatives: IirDerivatives::CentralDifference,
580            operator: None,
581        }
582    }
583
584    pub(crate) fn set_derivatives(&mut self, derivatives: IirDerivatives) {
585        self.derivatives = derivatives;
586        for part in &mut self.cache {
587            part.d.clear();
588        }
589        self.operator = None;
590    }
591
592    fn evaluate(&mut self, x: &[f64]) {
593        for ((part, v), &pol) in self
594            .cache
595            .iter_mut()
596            .zip(x.chunks_exact(per(self.t)))
597            .zip(self.pols)
598        {
599            part.response(v, pol, &self.pb.f, &self.z, self.t);
600        }
601    }
602
603    fn derivatives(&mut self, x: &[f64]) {
604        for ((part, v), &pol) in self
605            .cache
606            .iter_mut()
607            .zip(x.chunks_exact(per(self.t)))
608            .zip(self.pols)
609        {
610            part.derivatives(v, pol, &self.pb.f, &self.z, self.t, self.derivatives);
611        }
612    }
613
614    fn level(&self) -> (Vec<Vec<f64>>, f64, f64) {
615        let st: Vec<_> = self
616            .cache
617            .iter()
618            .map(|p| time_product(&p.s, &self.pb.lp, self.pb.nf, self.pb.n))
619            .collect();
620        let lvl = st.iter().map(|s| decay::energy(s)).sum::<f64>() / self.pb.direct.max(1e-30);
621        let over = 10.0 * (lvl + 1e-30).log10() - self.pb.level_db;
622        (st, lvl, over)
623    }
624
625    fn rows(&self) -> usize {
626        2 * self.pb.xs.len() * self.pb.n
627            + 1
628            + 2 * self.pb.roles.len() * self.pb.f.len()
629            + if self.t.mimo_band_reg > 0.0 {
630                self.pb.roles.len() * self.t.mimo_k_bands
631            } else {
632                0
633            }
634    }
635}
636
637pub(crate) fn solve_joint(
638    pb: &Problem,
639    t: &Tuning,
640    ctx: &Context,
641    x0: &[f64],
642    pols: &[i8],
643) -> Result<optimize::LeastSquaresResult, EngineError> {
644    solve_joint_with_options(pb, t, ctx, x0, pols, &IirSolveOptions::default())
645}
646
647pub(crate) fn solve_joint_with_options(
648    pb: &Problem,
649    t: &Tuning,
650    ctx: &Context,
651    x0: &[f64],
652    pols: &[i8],
653    options: &IirSolveOptions,
654) -> Result<optimize::LeastSquaresResult, EngineError> {
655    check(ctx)?;
656    let mut lb = Vec::new();
657    let mut ub = Vec::new();
658    for &(lo, hi) in &pb.ranges {
659        let (l, u) = bounds(band_of(lo, hi, pb.fsiso, t), t);
660        lb.extend(l);
661        ub.extend(u);
662    }
663    let x0: Vec<_> = x0
664        .iter()
665        .enumerate()
666        .map(|(k, &v)| v.clamp(lb[k] + 1e-6, ub[k] - 1e-6))
667        .collect();
668    let mut objective = Objective::new(pb, t, ctx, pols);
669    objective.derivatives = options.derivatives;
670    if let IirSolver::MatrixFree {
671        max_cg_iterations,
672        cg_rtol,
673    } = options.solver
674    {
675        let result = optimize::least_squares_operator(
676            &mut objective,
677            &x0,
678            &lb,
679            &ub,
680            &OperatorOptions {
681                max_nfev: t.mimo_max_nfev,
682                max_cg_iterations,
683                cg_rtol,
684                ..OperatorOptions::default()
685            },
686        );
687        check(ctx)?;
688        match result {
689            Ok(solution) => return Ok(solution),
690            Err(error) if !options.dense_fallback => return Err(error.into()),
691            Err(_) => objective.operator = None,
692        }
693    }
694    for x_scale in [XScale::Jac, XScale::Unit] {
695        let result = optimize::least_squares_normal(
696            &mut objective,
697            &x0,
698            &lb,
699            &ub,
700            &NormalOptions {
701                max_nfev: t.mimo_max_nfev,
702                x_scale,
703                ..NormalOptions::default()
704            },
705        );
706        check(ctx)?;
707        if let Ok(sol) = result {
708            return Ok(sol);
709        }
710    }
711    let cost = 0.5 * decay::energy(&NormalEquations::residual(&mut objective, &x0));
712    Ok(optimize::LeastSquaresResult {
713        x: x0,
714        cost,
715        nfev: 0,
716        njev: 0,
717        status: -1,
718        optimality: f64::NAN,
719    })
720}
721
722pub(crate) fn solve_problem(
723    pb: &Problem,
724    t: &Tuning,
725    ctx: &Context,
726) -> Result<(Vec<f64>, Vec<i8>), EngineError> {
727    solve_problem_with_options(pb, t, ctx, &IirSolveOptions::default())
728}
729
730fn solve_problem_with_options(
731    pb: &Problem,
732    t: &Tuning,
733    ctx: &Context,
734    options: &IirSolveOptions,
735) -> Result<(Vec<f64>, Vec<i8>), EngineError> {
736    let mut starts = Vec::new();
737    let mut pols = Vec::new();
738    for (r, &(lo, hi)) in pb.ranges.iter().enumerate() {
739        let modes: Vec<_> = pb
740            .modes
741            .iter()
742            .copied()
743            .filter(|m| (lo..=hi).contains(m))
744            .collect();
745        let modes = if modes.is_empty() { &pb.modes } else { &modes };
746        let seed = fit_one(
747            &pb.fir[r],
748            lo,
749            hi.min(pb.fsiso),
750            modes,
751            band_of(lo, hi, pb.fsiso, t),
752            t,
753            options.derivatives,
754            options.seed_peq_derivatives,
755            ctx,
756        )?;
757        starts.extend(seed.x);
758        pols.push(seed.polarity);
759    }
760    Ok((
761        solve_joint_with_options(pb, t, ctx, &starts, &pols, options)?.x,
762        pols,
763    ))
764}
765
766pub(crate) fn applied(pb: &Problem, supports: &[(usize, IirSupport)]) -> Vec<Vec<f64>> {
767    let ss: Vec<_> = supports.iter().map(|(_, s)| s.response(&pb.f)).collect();
768    pb.xs
769        .iter()
770        .enumerate()
771        .map(|(p, x)| {
772            let mut sum = vec![C::new(0.0, 0.0); pb.f.len()];
773            for ((r, _), s) in supports.iter().zip(&ss) {
774                for (k, v) in sum.iter_mut().enumerate() {
775                    *v += pb.g[p][*r][k] * s[k];
776                }
777            }
778            let add = fft::irfft(&sum, pb.nf);
779            x.iter().zip(add).map(|(x, a)| x + a).collect()
780        })
781        .collect()
782}
783
784struct BandGuard {
785    bounds: Vec<(usize, usize)>,
786    windows: Vec<Vec<f64>>,
787    before: Vec<f64>,
788    before_by_position: Option<Vec<Vec<f64>>>,
789}
790
791impl BandGuard {
792    fn new(pb: &Problem) -> Self {
793        let mut bands = third_octave_bands(pb.fsiso);
794        bands.extend(
795            late_modes(&pb.xs, 6, 20.0, 160.0)
796                .iter()
797                .map(|f| (f - 1.5, f + 1.5)),
798        );
799        let bounds = bands
800            .into_iter()
801            .map(|(lo, hi)| {
802                (
803                    pb.f.partition_point(|&f| f < lo),
804                    pb.f.partition_point(|&f| f < hi),
805                )
806            })
807            .collect::<Vec<_>>();
808        // Guard late energy remains the established 60 ms definition even
809        // when the optimizer starts supports sooner (P177).
810        let windows: Vec<Vec<f64>> = pb
811            .xs
812            .iter()
813            .map(|x| {
814                let start = (decay::onset(x) + super::design::GUARD_EARLY).min(pb.n);
815                (0..pb.n)
816                    .map(|i| if i >= start { 1.0 } else { 0.0 })
817                    .collect()
818            })
819            .collect();
820        let mut guard = Self {
821            bounds,
822            windows,
823            before: Vec::new(),
824            before_by_position: None,
825        };
826        if pb.seat_guard {
827            guard.before_by_position = Some(guard.power_by_position(pb, &pb.xs));
828        } else {
829            guard.before = guard.power(pb, &pb.xs);
830        }
831        guard
832    }
833
834    fn power(&self, pb: &Problem, ys: &[Vec<f64>]) -> Vec<f64> {
835        let mut total = vec![0.0; self.bounds.len()];
836        for (y, window) in ys.iter().zip(&self.windows) {
837            let mut windowed: Vec<_> = y.iter().zip(window).map(|(y, w)| y * w).collect();
838            windowed.resize(pb.nf, 0.0);
839            let power: Vec<_> = fft::rfft(&windowed)
840                .iter()
841                .map(|&h| {
842                    let a = response::magnitude(h);
843                    a * a
844                })
845                .collect();
846            for (total, &(a, b)) in total.iter_mut().zip(&self.bounds) {
847                *total += pairwise_sum(&power[a..b]);
848            }
849        }
850        total
851    }
852
853    fn power_by_position(&self, pb: &Problem, ys: &[Vec<f64>]) -> Vec<Vec<f64>> {
854        ys.iter()
855            .zip(&self.windows)
856            .map(|(y, window)| {
857                let mut windowed: Vec<_> = y.iter().zip(window).map(|(y, w)| y * w).collect();
858                windowed.resize(pb.nf, 0.0);
859                let power: Vec<_> = fft::rfft(&windowed)
860                    .iter()
861                    .map(|&h| {
862                        let a = response::magnitude(h);
863                        a * a
864                    })
865                    .collect();
866                self.bounds
867                    .iter()
868                    .map(|&(a, b)| pairwise_sum(&power[a..b]))
869                    .collect()
870            })
871            .collect()
872    }
873
874    fn evaluate(&self, pb: &Problem, ys: &[Vec<f64>]) -> f64 {
875        if let Some(before) = &self.before_by_position {
876            return before
877                .iter()
878                .zip(self.power_by_position(pb, ys))
879                .flat_map(|(before, after)| before.iter().copied().zip(after))
880                .filter_map(|(b, a)| super::design::seat_change(b, a))
881                .reduce(f64::max)
882                .unwrap_or(-99.0);
883        }
884        self.before
885            .iter()
886            .zip(self.power(pb, ys))
887            .filter(|(b, _)| **b > 0.0)
888            .map(|(b, a)| 10.0 * ((a + 1e-30) / b).log10())
889            .reduce(f64::max)
890            .unwrap_or(-99.0)
891    }
892}
893
894pub(crate) fn worst_band_db(pb: &Problem, supports: &[(usize, IirSupport)]) -> f64 {
895    BandGuard::new(pb).evaluate(pb, &applied(pb, supports))
896}
897
898struct OutcomeGuard<'a> {
899    pb: &'a Problem,
900    band: BandGuard,
901    tail: super::design::T60Guard<'a>,
902}
903
904impl<'a> OutcomeGuard<'a> {
905    fn new(pb: &'a Problem) -> Self {
906        Self {
907            pb,
908            band: BandGuard::new(pb),
909            tail: super::design::T60Guard::new(&pb.xs, 6, pb.seat_guard),
910        }
911    }
912
913    fn evaluate(&mut self, supports: &[(usize, IirSupport)]) -> Result<(f64, f64), EngineError> {
914        // Both guards consume the same candidate waveform, while baseline
915        // windows, bands, modal filters and original tails survive all trials.
916        let ys = applied(self.pb, supports);
917        Ok((
918            if supports.is_empty() {
919                -99.0
920            } else {
921                self.band.evaluate(self.pb, &ys)
922            },
923            self.tail.evaluate(&ys)?,
924        ))
925    }
926}
927
928pub(crate) fn outcome(
929    pb: &Problem,
930    supports: &[(usize, IirSupport)],
931) -> Result<(f64, f64), EngineError> {
932    OutcomeGuard::new(pb).evaluate(supports)
933}
934
935#[derive(Debug)]
936pub(crate) struct PostResult {
937    pub supports: Vec<(usize, IirSupport)>,
938    pub removed: Vec<String>,
939    pub level_cut: f64,
940    pub violations: Vec<f64>,
941    pub gain_steps: usize,
942}
943
944pub(crate) fn postprocess(
945    pb: &Problem,
946    x: &[f64],
947    pols: &[i8],
948    t: &Tuning,
949    ctx: &Context,
950) -> Result<PostResult, EngineError> {
951    let mut sups = unpack(x, pols, t);
952    let lvl = sups
953        .iter()
954        .map(|s| decay::energy(&time_product(&s.response(&pb.f), &pb.lp, pb.nf, pb.n)))
955        .sum::<f64>()
956        / pb.direct.max(1e-30);
957    let target = 10f64.powf(pb.level_db / 10.0);
958    let level_cut = if lvl > target {
959        10.0 * (lvl / target).log10()
960    } else {
961        0.0
962    };
963    for s in &mut sups {
964        s.gain_db -= level_cut;
965    }
966    let mut cur: Vec<_> = sups.into_iter().enumerate().collect();
967    let mut violations = Vec::new();
968    let mut guard = OutcomeGuard::new(pb);
969    let mut violation = |s: &[(usize, IirSupport)]| -> Result<f64, EngineError> {
970        check(ctx)?;
971        let (band, tail) = guard.evaluate(s)?;
972        let v = (band - t.mimo_worse_db).max(tail - 0.15 * t.mimo_worse_db);
973        violations.push(v);
974        Ok(v)
975    };
976    let mut v = violation(&cur)?;
977    let mut removed = Vec::new();
978    while v > 0.0 && removed.len() < (pb.roles.len() / 2).max(1) && cur.len() > 1 {
979        let mut best = (0, f64::INFINITY);
980        for index in 0..cur.len() {
981            let trial: Vec<_> = cur
982                .iter()
983                .enumerate()
984                .filter(|(k, _)| *k != index)
985                .map(|(_, s)| s.clone())
986                .collect();
987            let value = violation(&trial)?;
988            if value < best.1 {
989                best = (index, value);
990            }
991        }
992        if best.1 >= v {
993            break;
994        }
995        removed.push(pb.roles[cur.remove(best.0).0].clone());
996        v = best.1;
997    }
998    let mut gain_steps = 0;
999    for step in 0..5 {
1000        if v <= 0.0 {
1001            break;
1002        }
1003        if step == 4 {
1004            cur.clear();
1005            break;
1006        }
1007        for (_, s) in &mut cur {
1008            s.gain_db -= 3.0;
1009        }
1010        gain_steps += 1;
1011        v = violation(&cur)?;
1012    }
1013    Ok(PostResult {
1014        supports: cur,
1015        removed,
1016        level_cut,
1017        violations,
1018        gain_steps,
1019    })
1020}
1021
1022#[allow(clippy::too_many_arguments)]
1023pub(crate) fn build_problem(
1024    design: &Design,
1025    spectra: &crate::Spectra,
1026    align_delay_ms: &[(String, f64)],
1027    ideal: &MimoDesign,
1028    input: &str,
1029    supports: &[(String, Vec<f64>)],
1030    req: &MimoRequest,
1031    t: &Tuning,
1032    ctx: &Context,
1033) -> Result<Problem, EngineError> {
1034    let f = secs_dsp::grid::freqs(2 * N, FS)?;
1035    let lp = response::lr4(&f, req.fsiso, "LP");
1036    let roles: Vec<_> = supports.iter().map(|(r, _)| r.clone()).collect();
1037    let mut pb = Problem {
1038        roles,
1039        f,
1040        lp,
1041        n: N,
1042        nf: 2 * N,
1043        xs: Vec::new(),
1044        wl: Vec::new(),
1045        we: Vec::new(),
1046        g: Vec::new(),
1047        fir: supports.iter().map(|(_, s)| s.clone()).collect(),
1048        ranges: Vec::new(),
1049        modes: Vec::new(),
1050        fsiso: req.fsiso,
1051        direct: 0.0,
1052        level_db: req.level_db,
1053        seat_guard: req.seat_guard,
1054    };
1055    for role in &pb.roles {
1056        pb.ranges.push(
1057            ideal
1058                .ranges
1059                .iter()
1060                .find(|(r, _)| r == role)
1061                .and_then(|(_, v)| *v)
1062                .ok_or_else(|| EngineError::Input(format!("missing MIMO range {role}")))?,
1063        );
1064    }
1065    for &p in &design.spectra.positions {
1066        check(ctx)?;
1067        let h = crate::input_response(design, input, spectra, p)
1068            .ok_or_else(|| EngineError::Input(format!("missing MIMO input {p}/{input}")))?;
1069        let x = secs_dsp::lowrate::to_low(&h, design_freqs(), N)?;
1070        let onset = decay::onset(&x) + super::design::early_samples(req.mimo_early_ms);
1071        pb.wl
1072            .push((0..N).map(|i| if i >= onset { 1.0 } else { 0.0 }).collect());
1073        pb.we
1074            .push(pb.wl.last().unwrap().iter().map(|w| 1.0 - w).collect());
1075        pb.xs.push(x);
1076        let mut paths = Vec::new();
1077        for role in &pb.roles {
1078            let h = support_path(ideal, role, spectra, p, align_delay_ms)?;
1079            let mut low = secs_dsp::lowrate::to_low(&h, design_freqs(), N)?;
1080            low.resize(2 * N, 0.0);
1081            paths.push(
1082                fft::rfft(&low)
1083                    .iter()
1084                    .zip(&pb.lp)
1085                    .map(|(h, l)| h * l)
1086                    .collect(),
1087            );
1088        }
1089        pb.g.push(paths);
1090    }
1091    let main = design
1092        .mains
1093        .iter()
1094        .find(|m| m.role == input)
1095        .ok_or_else(|| EngineError::Input(format!("missing MIMO main {input}")))?;
1096    pb.direct = decay::energy(&secs_dsp::lowrate::to_low(
1097        &main.response,
1098        design_freqs(),
1099        N,
1100    )?);
1101    pb.modes = late_modes(&pb.xs, t.mimo_a_bands, 20.0, req.fsiso);
1102    Ok(pb)
1103}
1104
1105fn validate_request(req: &MimoRequest, t: &Tuning) -> Result<(), EngineError> {
1106    if !(0.0..300.0).contains(&req.fsiso)
1107        || req.fsiso == 0.0
1108        || !req.level_db.is_finite()
1109        || !req.mimo_early_ms.is_finite()
1110        || !(5.0..=60.0).contains(&req.mimo_early_ms)
1111        || !t.mimo_band_db.is_finite()
1112        || t.mimo_band_db <= 0.0
1113        || !t.mimo_q_max.is_finite()
1114        || t.mimo_q_max <= 0.5
1115        || !t.mimo_gain_max_db.is_finite()
1116        || t.mimo_gain_max_db <= -80.0
1117        || t.mimo_max_nfev == 0
1118        || [t.mimo_band_reg, t.mimo_effort, t.mimo_worse_db]
1119            .iter()
1120            .any(|v| !v.is_finite() || *v < 0.0)
1121    {
1122        return Err(EngineError::Input("invalid MIMO IIR tuning".into()));
1123    }
1124    // Each support's seed fit solves `per(t)` = 2 + 3·K + 2·A parameters from
1125    // its SEED_RESIDUALS residuals; more unknowns than that is not a fit the
1126    // design supports, and the count must not overflow (TD-104).
1127    let parameters = t
1128        .mimo_k_bands
1129        .checked_mul(3)
1130        .zip(t.mimo_a_bands.checked_mul(2))
1131        .and_then(|(k, a)| k.checked_add(a)?.checked_add(2));
1132    if parameters.is_none_or(|n| n > SEED_RESIDUALS) {
1133        return Err(EngineError::Input(format!(
1134            "invalid MIMO IIR tuning: {} peaking and {} all-pass bands exceed {SEED_RESIDUALS} parameters per support",
1135            t.mimo_k_bands, t.mimo_a_bands
1136        )));
1137    }
1138    // The seed and joint fits clamp their start into [lb + 1e-6, ub - 1e-6],
1139    // which needs every interval wider than 2e-6. The tuning sets the gain, Q
1140    // and band-gain intervals; the frequency ones are at least ln 1.5 wide
1141    // for any band `band_of` returns, so one representative band suffices.
1142    // Every bound is finite after the checks above.
1143    let (lb, ub) = bounds((15.0, 300.0), t);
1144    if lb
1145        .iter()
1146        .zip(&ub)
1147        .any(|(&l, &u)| u - l <= 2e-6 || l + 1e-6 > u - 1e-6)
1148    {
1149        return Err(EngineError::Input(
1150            "invalid MIMO IIR tuning: a parameter range is not wider than 2e-6".into(),
1151        ));
1152    }
1153    Ok(())
1154}
1155
1156#[allow(clippy::too_many_arguments)]
1157pub fn design_mimo_iir(
1158    design: &Design,
1159    measurements: &MeasurementSet,
1160    config: &EngineConfig,
1161    ideal: &MimoDesign,
1162    req: &MimoRequest,
1163    t: &Tuning,
1164    ctx: &Context,
1165) -> Result<MimoIir, EngineError> {
1166    design_mimo_iir_with_options(
1167        design,
1168        measurements,
1169        config,
1170        ideal,
1171        req,
1172        t,
1173        &IirSolveOptions::default(),
1174        ctx,
1175    )
1176}
1177
1178/// Designs IIR supports with an explicit joint-solver choice. Existing callers
1179/// use `design_mimo_iir`, which retains the dense solver. Setting
1180/// `IirSolver::MatrixFree { max_cg_iterations: 64, cg_rtol: 1e-3 }` enables the
1181/// f64 operator solver without changing project files or IPC tuning values.
1182#[allow(clippy::too_many_arguments)]
1183pub fn design_mimo_iir_with_options(
1184    design: &Design,
1185    measurements: &MeasurementSet,
1186    config: &EngineConfig,
1187    ideal: &MimoDesign,
1188    req: &MimoRequest,
1189    t: &Tuning,
1190    options: &IirSolveOptions,
1191    ctx: &Context,
1192) -> Result<MimoIir, EngineError> {
1193    check(ctx)?;
1194    validate_request(req, t)?;
1195    options.validate()?;
1196    if ideal.supports.iter().all(|(_, s)| s.is_empty()) {
1197        let _progress = ctx.stage("engine.mimo_iir", 0);
1198        return Ok(MimoIir::default());
1199    }
1200    let prepared = super::prepared::Prepared::new(design, measurements, config, ctx)?;
1201    design_mimo_iir_prepared_with_options(design, &prepared, ideal, req, t, options, ctx)
1202}
1203
1204pub(crate) fn design_mimo_iir_prepared(
1205    design: &Design,
1206    prepared: &super::prepared::Prepared<'_>,
1207    ideal: &MimoDesign,
1208    req: &MimoRequest,
1209    t: &Tuning,
1210    ctx: &Context,
1211) -> Result<MimoIir, EngineError> {
1212    design_mimo_iir_prepared_with_options(
1213        design,
1214        prepared,
1215        ideal,
1216        req,
1217        t,
1218        &IirSolveOptions::default(),
1219        ctx,
1220    )
1221}
1222
1223#[allow(clippy::too_many_arguments)]
1224pub(crate) fn design_mimo_iir_prepared_with_options(
1225    design: &Design,
1226    prepared: &super::prepared::Prepared<'_>,
1227    ideal: &MimoDesign,
1228    req: &MimoRequest,
1229    t: &Tuning,
1230    options: &IirSolveOptions,
1231    ctx: &Context,
1232) -> Result<MimoIir, EngineError> {
1233    check(ctx)?;
1234    validate_request(req, t)?;
1235    options.validate()?;
1236    let inputs: Vec<_> = ideal
1237        .supports
1238        .iter()
1239        .filter(|(_, s)| !s.is_empty())
1240        .collect();
1241    let progress = ctx.stage("engine.mimo_iir", inputs.len() as u64);
1242    if inputs.is_empty() {
1243        return Ok(MimoIir::default());
1244    }
1245    let spectra = &prepared.spectra;
1246    let positions = &design.spectra.positions;
1247    let results = crate::memory::try_map(
1248        ctx,
1249        inputs,
1250        prepared.reserved,
1251        |(_, supports)| {
1252            let r = supports.len();
1253            let dim = r * per(t);
1254            let rows = 2 * positions.len() * N + 1 + 2 * r * (N + 1) + r * t.mimo_k_bands;
1255            let solver_bytes = if options.solver == IirSolver::Dense || options.dense_fallback {
1256                4 * N * dim + 12 * dim * dim
1257            } else {
1258                8 * rows + 24 * dim + 8 * r * (N + 1)
1259            };
1260            // Problem X/WL/WE/G, derivative cache, one position's time columns,
1261            // weighted columns, normal/eigen/TRF workspaces, seed Jacobian and
1262            // design-grid response temporaries. Reserve before per-input allocation.
1263            let bytes = 8
1264                * (positions.len() * N * (3 + 4 * r)
1265                    + 4 * dim * (N + 1)
1266                    + solver_bytes
1267                    + 320 * per(t) * 4
1268                    + 32 * design_freqs().len()
1269                    + 32768);
1270            bytes as u64
1271        },
1272        |(input, supports)| {
1273            let pb = build_problem(
1274                design,
1275                spectra,
1276                &prepared.align_delay_ms,
1277                ideal,
1278                input,
1279                supports,
1280                req,
1281                t,
1282                ctx,
1283            )?;
1284            let (x, pols) = solve_problem_with_options(&pb, t, ctx, options)?;
1285            let post = postprocess(&pb, &x, &pols, t, ctx)?;
1286            let paths = post
1287                .supports
1288                .into_iter()
1289                .map(|(r, s)| (pb.roles[r].clone(), s))
1290                .collect();
1291            progress.advance(1);
1292            Ok((input.clone(), paths, post.removed))
1293        },
1294    )?;
1295    let mut out = MimoIir::default();
1296    for (input, paths, removed) in results {
1297        out.supports.push((input.clone(), paths));
1298        out.removed.push((input, removed));
1299    }
1300    Ok(out)
1301}
1302
1303impl NormalEquations for Objective<'_> {
1304    fn residual(&mut self, x: &[f64]) -> Vec<f64> {
1305        if self.ctx.cancel().is_cancelled() {
1306            return vec![0.0; self.rows()];
1307        }
1308        self.evaluate(x);
1309        let pb = self.pb;
1310        let mut out = Vec::with_capacity(self.rows());
1311        for p in 0..pb.xs.len() {
1312            let mut sum = vec![C::new(0.0, 0.0); pb.f.len()];
1313            for (r, part) in self.cache.iter().enumerate() {
1314                for (k, v) in sum.iter_mut().enumerate() {
1315                    *v += pb.g[p][r][k] * part.s[k];
1316                }
1317            }
1318            let add = fft::irfft(&sum, pb.nf);
1319            out.extend((0..pb.n).map(|i| pb.wl[p][i] * (pb.xs[p][i] + add[i])));
1320            out.extend((0..pb.n).map(|i| MU.sqrt() * pb.we[p][i] * add[i]));
1321        }
1322        out.push(self.level().2.max(0.0) * self.scale);
1323        for (r, part) in self.cache.iter().enumerate() {
1324            out.extend(part.s.iter().zip(&self.weff[r]).map(|(h, w)| w * h.re));
1325            out.extend(part.s.iter().zip(&self.weff[r]).map(|(h, w)| w * h.im));
1326        }
1327        if self.t.mimo_band_reg > 0.0 {
1328            for r in 0..pb.roles.len() {
1329                for k in 0..self.t.mimo_k_bands {
1330                    out.push(
1331                        self.t.mimo_band_reg * self.scale * x[r * per(self.t) + 4 + 3 * k] / 30.0,
1332                    );
1333                }
1334            }
1335        }
1336        out
1337    }
1338
1339    fn normal(&mut self, x: &[f64], f: &[f64]) -> Result<(Mat<f64>, Vec<f64>), DspError> {
1340        let dim = x.len();
1341        if self.ctx.cancel().is_cancelled() {
1342            return Ok((Mat::zeros(dim, dim), vec![0.0; dim]));
1343        }
1344        self.derivatives(x);
1345        let pb = self.pb;
1346        let width = per(self.t);
1347        let mut h = Mat::zeros(dim, dim);
1348        let mut grad = vec![0.0; dim];
1349        // One position's unweighted time columns, not the stacked residual Jacobian.
1350        // Each position is accumulated in input order; faer never spawns threads.
1351        for p in 0..pb.xs.len() {
1352            let mut c = Mat::zeros(pb.n, dim);
1353            for (r, part) in self.cache.iter().enumerate() {
1354                for (a, d) in part.d.iter().enumerate() {
1355                    let column = time_product(&pb.g[p][r], d, pb.nf, pb.n);
1356                    for (i, &v) in column.iter().enumerate() {
1357                        c[(i, r * width + a)] = v;
1358                    }
1359                }
1360            }
1361            let weighted = Mat::from_fn(pb.n, dim, |i, j| {
1362                c[(i, j)] * (pb.wl[p][i] * pb.wl[p][i] + MU * pb.we[p][i] * pb.we[p][i])
1363            });
1364            matmul(
1365                h.as_mut(),
1366                Accum::Add,
1367                c.transpose(),
1368                &weighted,
1369                1.0,
1370                Par::Seq,
1371            );
1372            for j in 0..dim {
1373                for i in 0..pb.n {
1374                    grad[j] += c[(i, j)]
1375                        * (pb.wl[p][i] * f[2 * p * pb.n + i]
1376                            + MU.sqrt() * pb.we[p][i] * f[(2 * p + 1) * pb.n + i]);
1377                }
1378            }
1379        }
1380        let (st, lvl, over) = self.level();
1381        let mut level = vec![0.0; dim];
1382        for (r, part) in self.cache.iter().enumerate() {
1383            let effort = Mat::from_fn(2 * pb.f.len(), width, |i, j| {
1384                let k = i % pb.f.len();
1385                self.weff[r][k]
1386                    * if i < pb.f.len() {
1387                        part.d[j][k].re
1388                    } else {
1389                        part.d[j][k].im
1390                    }
1391            });
1392            let e0 = 2 * pb.xs.len() * pb.n + 1 + 2 * r * pb.f.len();
1393            let mut block = Mat::zeros(width, width);
1394            matmul(
1395                &mut block,
1396                Accum::Replace,
1397                effort.transpose(),
1398                &effort,
1399                1.0,
1400                Par::Seq,
1401            );
1402            for a in 0..width {
1403                for b in 0..width {
1404                    h[(r * width + a, r * width + b)] += block[(a, b)];
1405                }
1406                for i in 0..2 * pb.f.len() {
1407                    grad[r * width + a] += effort[(i, a)] * f[e0 + i];
1408                }
1409                if over > 0.0 {
1410                    let ct = time_product(&part.d[a], &pb.lp, pb.nf, pb.n);
1411                    let dot = ct.iter().zip(&st[r]).map(|(a, b)| a * b).sum::<f64>();
1412                    level[r * width + a] = self.scale * 10.0 / (10f64.ln() * (lvl + 1e-30))
1413                        * (2.0 * dot / pb.direct.max(1e-30));
1414                }
1415            }
1416        }
1417        if over > 0.0 {
1418            for a in 0..dim {
1419                grad[a] += level[a] * f[2 * pb.xs.len() * pb.n];
1420                for b in 0..dim {
1421                    h[(a, b)] += level[a] * level[b];
1422                }
1423            }
1424        }
1425        if self.t.mimo_band_reg > 0.0 {
1426            let factor = self.t.mimo_band_reg * self.scale / 30.0;
1427            let row = 2 * pb.xs.len() * pb.n + 1 + 2 * pb.roles.len() * pb.f.len();
1428            for r in 0..pb.roles.len() {
1429                for k in 0..self.t.mimo_k_bands {
1430                    let a = r * width + 4 + 3 * k;
1431                    h[(a, a)] += factor * factor;
1432                    grad[a] += factor * f[row + r * self.t.mimo_k_bands + k];
1433                }
1434            }
1435        }
1436        Ok((h, grad))
1437    }
1438}
1439
1440/// Adjoint of the normalized real inverse transform after truncation to n.
1441/// Interior bins carry both conjugate frequencies (2/nf); DC and an even
1442/// Nyquist bin carry one (1/nf), with imaginary endpoint derivatives ignored.
1443fn inverse_adjoint(time: &[f64], nf: usize) -> Vec<C> {
1444    let mut padded = time.to_vec();
1445    padded.resize(nf, 0.0);
1446    let mut spectrum = fft::rfft(&padded);
1447    for (k, h) in spectrum.iter_mut().enumerate() {
1448        let endpoint = k == 0 || (nf.is_multiple_of(2) && k == nf / 2);
1449        *h *= if endpoint {
1450            1.0 / nf as f64
1451        } else {
1452            2.0 / nf as f64
1453        };
1454        if endpoint {
1455            h.im = 0.0;
1456        }
1457    }
1458    spectrum
1459}
1460
1461impl JacobianOperator for Objective<'_> {
1462    fn residual(&mut self, x: &[f64]) -> Vec<f64> {
1463        NormalEquations::residual(self, x)
1464    }
1465
1466    fn linearize(&mut self, x: &[f64]) -> Result<Vec<f64>, DspError> {
1467        let width = per(self.t);
1468        let dim = x.len();
1469        if self.ctx.cancel().is_cancelled() {
1470            self.operator = Some(OperatorState {
1471                derivatives: Vec::new(),
1472                level: vec![0.0; dim],
1473            });
1474            return Ok(vec![0.0; dim]);
1475        }
1476        self.derivatives(x);
1477        let pb = self.pb;
1478        let mut diagonal = vec![0.0; dim];
1479        // Exact column norms need one time column at a time, never an n×dim
1480        // block and never a Gram matrix. They supply scaling/preconditioning.
1481        for p in 0..pb.xs.len() {
1482            for (r, part) in self.cache.iter().enumerate() {
1483                for (a, d) in part.d.iter().enumerate() {
1484                    let column = time_product(&pb.g[p][r], d, pb.nf, pb.n);
1485                    for (i, value) in column.into_iter().enumerate() {
1486                        diagonal[r * width + a] += value
1487                            * value
1488                            * (pb.wl[p][i] * pb.wl[p][i] + MU * pb.we[p][i] * pb.we[p][i]);
1489                    }
1490                }
1491            }
1492        }
1493        let (st, lvl, over) = self.level();
1494        let mut level = vec![0.0; dim];
1495        for (r, part) in self.cache.iter().enumerate() {
1496            for (a, derivative) in part.d.iter().enumerate() {
1497                let j = r * width + a;
1498                for component in [false, true] {
1499                    for (k, d) in derivative.iter().enumerate() {
1500                        let v = self.weff[r][k] * if component { d.im } else { d.re };
1501                        diagonal[j] += v * v;
1502                    }
1503                }
1504                if over > 0.0 {
1505                    let ct = time_product(derivative, &pb.lp, pb.nf, pb.n);
1506                    let dot = ct.iter().zip(&st[r]).map(|(a, b)| a * b).sum::<f64>();
1507                    level[j] = self.scale * 10.0 / (10f64.ln() * (lvl + 1e-30))
1508                        * (2.0 * dot / pb.direct.max(1e-30));
1509                    diagonal[j] += level[j] * level[j];
1510                }
1511            }
1512        }
1513        if self.t.mimo_band_reg > 0.0 {
1514            let factor = self.t.mimo_band_reg * self.scale / 30.0;
1515            for r in 0..pb.roles.len() {
1516                for k in 0..self.t.mimo_k_bands {
1517                    diagonal[r * width + 4 + 3 * k] += factor * factor;
1518                }
1519            }
1520        }
1521        // Moving derivatives into the retained base point avoids a second full
1522        // cache and lets every rejected trial remain response-only.
1523        self.operator = Some(OperatorState {
1524            derivatives: self
1525                .cache
1526                .iter_mut()
1527                .map(|part| std::mem::take(&mut part.d))
1528                .collect(),
1529            level,
1530        });
1531        Ok(diagonal)
1532    }
1533
1534    fn jvp(&self, direction: &[f64], out: &mut [f64]) -> Result<(), DspError> {
1535        let pb = self.pb;
1536        let width = per(self.t);
1537        if direction.len() != width * pb.roles.len() || out.len() != self.rows() {
1538            return Err(DspError::InvalidArgument(
1539                "invalid IIR Jacobian product shape".into(),
1540            ));
1541        }
1542        out.fill(0.0);
1543        if self.ctx.cancel().is_cancelled() {
1544            return Ok(());
1545        }
1546        let state = self
1547            .operator
1548            .as_ref()
1549            .ok_or_else(|| DspError::InvalidArgument("IIR operator has no linearization".into()))?;
1550        let mut responses = vec![vec![C::new(0.0, 0.0); pb.f.len()]; pb.roles.len()];
1551        for (r, derivatives) in state.derivatives.iter().enumerate() {
1552            for (a, derivative) in derivatives.iter().enumerate() {
1553                for (k, d) in derivative.iter().enumerate() {
1554                    responses[r][k] += d * direction[r * width + a];
1555                }
1556            }
1557        }
1558        for p in 0..pb.xs.len() {
1559            let mut sum = vec![C::new(0.0, 0.0); pb.f.len()];
1560            for (r, response) in responses.iter().enumerate() {
1561                for (k, value) in sum.iter_mut().enumerate() {
1562                    *value += pb.g[p][r][k] * response[k];
1563                }
1564            }
1565            let time = fft::irfft(&sum, pb.nf);
1566            for i in 0..pb.n {
1567                out[2 * p * pb.n + i] = pb.wl[p][i] * time[i];
1568                out[(2 * p + 1) * pb.n + i] = MU.sqrt() * pb.we[p][i] * time[i];
1569            }
1570        }
1571        let level_row = 2 * pb.xs.len() * pb.n;
1572        out[level_row] = state.level.iter().zip(direction).map(|(g, v)| g * v).sum();
1573        for (r, response) in responses.iter().enumerate() {
1574            let base = level_row + 1 + 2 * r * pb.f.len();
1575            for (k, h) in response.iter().enumerate() {
1576                out[base + k] = self.weff[r][k] * h.re;
1577                out[base + pb.f.len() + k] = self.weff[r][k] * h.im;
1578            }
1579        }
1580        if self.t.mimo_band_reg > 0.0 {
1581            let base = level_row + 1 + 2 * pb.roles.len() * pb.f.len();
1582            let factor = self.t.mimo_band_reg * self.scale / 30.0;
1583            for r in 0..pb.roles.len() {
1584                for k in 0..self.t.mimo_k_bands {
1585                    out[base + r * self.t.mimo_k_bands + k] =
1586                        factor * direction[r * width + 4 + 3 * k];
1587                }
1588            }
1589        }
1590        Ok(())
1591    }
1592
1593    fn vjp(&self, residual_direction: &[f64], out: &mut [f64]) -> Result<(), DspError> {
1594        let pb = self.pb;
1595        let width = per(self.t);
1596        if out.len() != width * pb.roles.len() || residual_direction.len() != self.rows() {
1597            return Err(DspError::InvalidArgument(
1598                "invalid IIR transpose product shape".into(),
1599            ));
1600        }
1601        out.fill(0.0);
1602        if self.ctx.cancel().is_cancelled() {
1603            return Ok(());
1604        }
1605        let state = self
1606            .operator
1607            .as_ref()
1608            .ok_or_else(|| DspError::InvalidArgument("IIR operator has no linearization".into()))?;
1609        let mut adjoints = vec![vec![C::new(0.0, 0.0); pb.f.len()]; pb.roles.len()];
1610        for p in 0..pb.xs.len() {
1611            let time: Vec<_> = (0..pb.n)
1612                .map(|i| {
1613                    pb.wl[p][i] * residual_direction[2 * p * pb.n + i]
1614                        + MU.sqrt() * pb.we[p][i] * residual_direction[(2 * p + 1) * pb.n + i]
1615                })
1616                .collect();
1617            let spectrum = inverse_adjoint(&time, pb.nf);
1618            for (r, adjoint) in adjoints.iter_mut().enumerate() {
1619                for (k, value) in adjoint.iter_mut().enumerate() {
1620                    *value += pb.g[p][r][k].conj() * spectrum[k];
1621                }
1622            }
1623        }
1624        let level_row = 2 * pb.xs.len() * pb.n;
1625        for (r, adjoint) in adjoints.iter_mut().enumerate() {
1626            let base = level_row + 1 + 2 * r * pb.f.len();
1627            for (k, value) in adjoint.iter_mut().enumerate() {
1628                *value += C::new(
1629                    self.weff[r][k] * residual_direction[base + k],
1630                    self.weff[r][k] * residual_direction[base + pb.f.len() + k],
1631                );
1632            }
1633            for (a, derivative) in state.derivatives[r].iter().enumerate() {
1634                let j = r * width + a;
1635                out[j] = derivative
1636                    .iter()
1637                    .zip(&*adjoint)
1638                    .map(|(d, a)| d.re * a.re + d.im * a.im)
1639                    .sum::<f64>()
1640                    + state.level[j] * residual_direction[level_row];
1641            }
1642        }
1643        if self.t.mimo_band_reg > 0.0 {
1644            let base = level_row + 1 + 2 * pb.roles.len() * pb.f.len();
1645            let factor = self.t.mimo_band_reg * self.scale / 30.0;
1646            for r in 0..pb.roles.len() {
1647                for k in 0..self.t.mimo_k_bands {
1648                    out[r * width + 4 + 3 * k] +=
1649                        factor * residual_direction[base + r * self.t.mimo_k_bands + k];
1650                }
1651            }
1652        }
1653        Ok(())
1654    }
1655}
1656
1657#[cfg(test)]
1658mod operator_tests {
1659    use super::*;
1660
1661    fn fixture(level_db: f64) -> (Problem, Tuning, Vec<f64>, Vec<i8>) {
1662        let (n, nf) = (64, 128);
1663        let f = secs_dsp::grid::freqs(nf, FS).unwrap();
1664        let t = Tuning {
1665            mimo_k_bands: 2,
1666            mimo_a_bands: 1,
1667            mimo_max_nfev: 25,
1668            ..Tuning::default()
1669        };
1670        let lp = response::lr4(&f, 100.0, "LP");
1671        let xs: Vec<Vec<f64>> = (0..2)
1672            .map(|p| {
1673                (0..n)
1674                    .map(|i| {
1675                        let time = i as f64;
1676                        (time * 0.6 + p as f64 * 0.1).cos() * (-time / 40.0).exp()
1677                    })
1678                    .collect()
1679            })
1680            .collect();
1681        let wl: Vec<Vec<f64>> = (0..2)
1682            .map(|p| (0..n).map(|i| if i >= 8 + p { 1.0 } else { 0.0 }).collect())
1683            .collect();
1684        let we = wl
1685            .iter()
1686            .map(|w| w.iter().map(|w| 1.0 - w).collect())
1687            .collect();
1688        let g = (0..2)
1689            .map(|p| {
1690                (0..2)
1691                    .map(|r| {
1692                        let mut ir = vec![0.0; nf];
1693                        for (i, value) in ir.iter_mut().enumerate().take(n).skip(2 + p + r) {
1694                            *value = (-((i - 2 - p - r) as f64) / 12.0).exp()
1695                                * (i as f64 * (0.4 + r as f64 * 0.1)).cos();
1696                        }
1697                        fft::rfft(&ir).iter().zip(&lp).map(|(a, b)| a * b).collect()
1698                    })
1699                    .collect()
1700            })
1701            .collect();
1702        let x = (0..2)
1703            .flat_map(|r| {
1704                [
1705                    -6.0 - r as f64,
1706                    12.0 + r as f64,
1707                    50.0_f64.ln(),
1708                    2.0_f64.ln(),
1709                    3.0,
1710                    110.0_f64.ln(),
1711                    1.3_f64.ln(),
1712                    -4.0,
1713                    70.0_f64.ln(),
1714                    1.5_f64.ln(),
1715                ]
1716            })
1717            .collect();
1718        (
1719            Problem {
1720                roles: vec!["SW1".into(), "SW2".into()],
1721                f,
1722                lp,
1723                n,
1724                nf,
1725                xs,
1726                wl,
1727                we,
1728                g,
1729                fir: vec![vec![0.0; n]; 2],
1730                ranges: vec![(20.0, 150.0); 2],
1731                modes: vec![50.0, 80.0],
1732                fsiso: 100.0,
1733                direct: 1.0,
1734                level_db,
1735                seat_guard: false,
1736            },
1737            t,
1738            x,
1739            vec![1, -1],
1740        )
1741    }
1742
1743    fn dot(a: &[f64], b: &[f64]) -> f64 {
1744        a.iter().zip(b).map(|(a, b)| a * b).sum()
1745    }
1746
1747    #[test]
1748    fn real_inverse_adjoint_handles_dc_nyquist_and_truncation() {
1749        for nf in [63, 64] {
1750            let spectrum: Vec<_> = (0..nf / 2 + 1)
1751                .map(|k| C::new((k as f64 * 0.3).sin(), (k as f64 * 0.7).cos()))
1752                .collect();
1753            let time: Vec<_> = (0..17).map(|i| (i as f64 * 0.5).cos()).collect();
1754            let transformed = fft::irfft(&spectrum, nf);
1755            let lhs = dot(&transformed[..time.len()], &time);
1756            let adjoint = inverse_adjoint(&time, nf);
1757            let rhs: f64 = spectrum
1758                .iter()
1759                .zip(&adjoint)
1760                .map(|(a, b)| a.re * b.re + a.im * b.im)
1761                .sum();
1762            assert!((lhs - rhs).abs() < 1e-12 * lhs.abs().max(rhs.abs()).max(1.0));
1763        }
1764    }
1765
1766    #[test]
1767    fn iir_operator_matches_derivatives_normal_equations_and_adjoint() {
1768        for level_db in [-80.0, 80.0] {
1769            let (pb, tuning, x, pols) = fixture(level_db);
1770            let ctx = Context::serial();
1771            let mut objective = Objective::new(&pb, &tuning, &ctx, &pols);
1772            let f = NormalEquations::residual(&mut objective, &x);
1773            assert!(
1774                objective.cache.iter().all(|part| part.d.is_empty()),
1775                "residual calculated unused derivatives"
1776            );
1777            let (normal, gradient) = objective.normal(&x, &f).unwrap();
1778            let diagonal = objective.linearize(&x).unwrap();
1779            let direction: Vec<_> = (0..x.len()).map(|i| (i as f64 * 0.4).sin()).collect();
1780            let row_direction: Vec<_> = (0..f.len()).map(|i| (i as f64 * 0.13).cos()).collect();
1781            let mut jv = vec![0.0; f.len()];
1782            objective.jvp(&direction, &mut jv).unwrap();
1783            let mut jtu = vec![0.0; x.len()];
1784            objective.vjp(&row_direction, &mut jtu).unwrap();
1785            let lhs = dot(&jv, &row_direction);
1786            let rhs = dot(&direction, &jtu);
1787            assert!((lhs - rhs).abs() < 1e-10 * lhs.abs().max(rhs.abs()).max(1.0));
1788            let mut jt_jv = vec![0.0; x.len()];
1789            objective.vjp(&jv, &mut jt_jv).unwrap();
1790            let mut jt_f = vec![0.0; x.len()];
1791            objective.vjp(&f, &mut jt_f).unwrap();
1792            for j in 0..x.len() {
1793                let expected: f64 = (0..x.len()).map(|k| normal[(j, k)] * direction[k]).sum();
1794                assert!((jt_jv[j] - expected).abs() < 2e-10 * expected.abs().max(1.0));
1795                assert!((jt_f[j] - gradient[j]).abs() < 2e-10 * gradient[j].abs().max(1.0));
1796                assert!(
1797                    (diagonal[j] - normal[(j, j)]).abs() < 2e-10 * normal[(j, j)].abs().max(1.0)
1798                );
1799            }
1800            let up: Vec<_> = x
1801                .iter()
1802                .zip(&direction)
1803                .map(|(x, v)| x + 1e-5 * v)
1804                .collect();
1805            let down: Vec<_> = x
1806                .iter()
1807                .zip(&direction)
1808                .map(|(x, v)| x - 1e-5 * v)
1809                .collect();
1810            let up = NormalEquations::residual(&mut objective, &up);
1811            let down = NormalEquations::residual(&mut objective, &down);
1812            let error = jv
1813                .iter()
1814                .zip(up.iter().zip(down))
1815                .map(|(d, (u, l))| (d - (u - l) / 2e-5).abs())
1816                .fold(0.0_f64, f64::max);
1817            let scale = jv.iter().map(|v| v.abs()).fold(1.0_f64, f64::max);
1818            assert!(
1819                error < 5e-6 * scale,
1820                "analytic directional derivative {error}/{scale}"
1821            );
1822            let mut unchanged = vec![0.0; f.len()];
1823            objective.jvp(&direction, &mut unchanged).unwrap();
1824            assert!(
1825                unchanged
1826                    .iter()
1827                    .zip(&jv)
1828                    .all(|(a, b)| a.to_bits() == b.to_bits()),
1829                "trial residual invalidated the retained operator"
1830            );
1831        }
1832    }
1833
1834    #[test]
1835    fn iir_matrix_free_solve_is_reachable_and_reduces_the_same_objective() {
1836        let (pb, tuning, x, pols) = fixture(80.0);
1837        let ctx = Context::serial();
1838        let mut objective = Objective::new(&pb, &tuning, &ctx, &pols);
1839        let initial = 0.5 * decay::energy(&NormalEquations::residual(&mut objective, &x));
1840        let options = IirSolveOptions {
1841            solver: IirSolver::MatrixFree {
1842                max_cg_iterations: 20,
1843                cg_rtol: 1e-4,
1844            },
1845            dense_fallback: false,
1846            ..IirSolveOptions::default()
1847        };
1848        let result = solve_joint_with_options(&pb, &tuning, &ctx, &x, &pols, &options).unwrap();
1849        assert!(result.cost < initial * 0.95, "{initial} -> {}", result.cost);
1850        assert!(result.nfev > 1 && result.nfev <= tuning.mimo_max_nfev);
1851        for (r, &(lo, hi)) in pb.ranges.iter().enumerate() {
1852            let (lb, ub) = bounds(band_of(lo, hi, pb.fsiso, &tuning), &tuning);
1853            assert!(
1854                result.x[r * per(&tuning)..(r + 1) * per(&tuning)]
1855                    .iter()
1856                    .zip(lb)
1857                    .zip(ub)
1858                    .all(|((&x, l), u)| x >= l && x <= u)
1859            );
1860        }
1861    }
1862
1863    #[test]
1864    fn iir_level_derivative_respects_the_additive_energy_floor() {
1865        let (mut pb, tuning, x, pols) = fixture(-400.0);
1866        for h in &mut pb.lp {
1867            *h *= 1e-15;
1868        }
1869        let ctx = Context::serial();
1870        let mut objective = Objective::new(&pb, &tuning, &ctx, &pols);
1871        let _ = NormalEquations::residual(&mut objective, &x);
1872        objective.linearize(&x).unwrap();
1873        let mut direction = vec![0.0; x.len()];
1874        direction[0] = 1.0;
1875        let mut jv = vec![0.0; objective.rows()];
1876        objective.jvp(&direction, &mut jv).unwrap();
1877        let (mut up, mut down) = (x.clone(), x);
1878        up[0] += 1e-3;
1879        down[0] -= 1e-3;
1880        let up = NormalEquations::residual(&mut objective, &up);
1881        let down = NormalEquations::residual(&mut objective, &down);
1882        let row = 2 * pb.xs.len() * pb.n;
1883        let central = (up[row] - down[row]) / 2e-3;
1884        assert!((jv[row] - central).abs() < 1e-7 * central.abs().max(1.0));
1885    }
1886
1887    #[test]
1888    fn iir_operator_error_preserves_dense_fallback() {
1889        let (pb, tuning, x, pols) = fixture(80.0);
1890        let ctx = Context::serial();
1891        // Public API validation rejects this option before any design work.
1892        // Passing it internally injects an operator error to exercise recovery.
1893        let options = IirSolveOptions {
1894            solver: IirSolver::MatrixFree {
1895                max_cg_iterations: 0,
1896                cg_rtol: 1e-3,
1897            },
1898            dense_fallback: true,
1899            ..IirSolveOptions::default()
1900        };
1901        assert!(options.validate().is_err());
1902        let recovered = solve_joint_with_options(&pb, &tuning, &ctx, &x, &pols, &options).unwrap();
1903        let dense = solve_joint(&pb, &tuning, &ctx, &x, &pols).unwrap();
1904        assert_eq!(recovered.cost.to_bits(), dense.cost.to_bits());
1905        assert_eq!(recovered.nfev, dense.nfev);
1906        assert!(
1907            recovered
1908                .x
1909                .iter()
1910                .zip(dense.x)
1911                .all(|(a, b)| a.to_bits() == b.to_bits())
1912        );
1913        assert!(
1914            solve_joint_with_options(
1915                &pb,
1916                &tuning,
1917                &ctx,
1918                &x,
1919                &pols,
1920                &IirSolveOptions {
1921                    dense_fallback: false,
1922                    ..options
1923                }
1924            )
1925            .is_err()
1926        );
1927    }
1928}
1929
1930#[cfg(test)]
1931mod tuning_tests {
1932    use super::*;
1933
1934    #[test]
1935    fn iir_tuning_ranges_are_checked_at_entry() {
1936        // TD-104: band counts beyond the seed fit's residuals, and bounds too
1937        // narrow for the 1e-6 start margins (f64::clamp panics on min > max).
1938        let req = MimoRequest::default();
1939        let ok = |t: Tuning| validate_request(&req, &t).is_ok();
1940        assert!(ok(Tuning::default()));
1941        // 2 + 3·106 = 320 parameters per support: as many as residuals.
1942        assert!(ok(Tuning {
1943            mimo_k_bands: 106,
1944            mimo_a_bands: 0,
1945            ..Tuning::default()
1946        }));
1947        for (k, a) in [(106, 1), (0, 160), (usize::MAX, 0), (0, usize::MAX)] {
1948            let t = Tuning {
1949                mimo_k_bands: k,
1950                mimo_a_bands: a,
1951                ..Tuning::default()
1952            };
1953            assert!(
1954                matches!(validate_request(&req, &t), Err(EngineError::Input(_))),
1955                "{k} peaking and {a} all-pass bands"
1956            );
1957        }
1958        assert!(ok(Tuning {
1959            mimo_band_db: 2e-6,
1960            ..Tuning::default()
1961        }));
1962        for t in [
1963            Tuning {
1964                mimo_band_db: 1e-6,
1965                ..Tuning::default()
1966            },
1967            Tuning {
1968                mimo_q_max: 0.5 * 1e-6f64.exp(),
1969                ..Tuning::default()
1970            },
1971            Tuning {
1972                mimo_gain_max_db: -80.0 + 1e-6,
1973                ..Tuning::default()
1974            },
1975        ] {
1976            assert!(
1977                matches!(validate_request(&req, &t), Err(EngineError::Input(_))),
1978                "{t:?}"
1979            );
1980        }
1981    }
1982}