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