Skip to main content

secs_engine/mimo/
design.rs

1//! Port of SECS 2 `src/secs/mimo.py` (GPL-3.0), support ranges and guards.
2//! Normal equations exploit the Toeplitz blocks without allocating A.
3
4use secs_dsp::{
5    fft,
6    fir::firwin,
7    iir::{BType, Sos, butter, sosfilt},
8    interp, pairwise_sum,
9    response::hp2,
10    smooth::{Fraction, smooth_power},
11};
12
13use crate::{
14    C, Design, EngineError, Note, Spectra,
15    decay::{self, onset},
16    grid::design_freqs,
17    measurement::delay_response,
18};
19
20const FS_LOW: f64 = 600.0;
21const EARLY: usize = 36;
22
23use super::solve::solve;
24use crate::{EngineConfig, MeasurementSet};
25#[cfg(test)]
26use secs_compute::MapError;
27use secs_compute::{Context, MemoryError};
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct MimoRequest {
31    pub fsiso: f64,
32    pub level_db: f64,
33    pub f_low: Option<f64>,
34    pub max_supports: Option<usize>,
35    pub exclude: Vec<String>,
36    pub groups: Option<Vec<String>>,
37    pub taps_low: usize,
38}
39
40impl Default for MimoRequest {
41    fn default() -> Self {
42        Self {
43            fsiso: 150.0,
44            level_db: -12.0,
45            f_low: None,
46            max_supports: None,
47            exclude: Vec::new(),
48            groups: None,
49            taps_low: 180,
50        }
51    }
52}
53
54pub type SupportRanges = Vec<(String, Option<(f64, f64)>)>;
55pub type InputSupports = Vec<(String, Vec<(String, Vec<f64>)>)>;
56
57#[derive(Debug, Clone, PartialEq)]
58pub struct MimoDesign {
59    pub fs_low: f64,
60    pub ranges: SupportRanges,
61    pub supports: InputSupports,
62    pub level_db: Vec<(String, f64)>,
63    pub notes: Vec<Note>,
64}
65
66impl Default for MimoDesign {
67    fn default() -> Self {
68        Self {
69            fs_low: FS_LOW,
70            ranges: Vec::new(),
71            supports: Vec::new(),
72            level_db: Vec::new(),
73            notes: Vec::new(),
74        }
75    }
76}
77
78pub fn lowpass_fir(fc: f64, taps: usize) -> Result<Vec<f64>, EngineError> {
79    Ok(firwin(taps, fc, FS_LOW)?)
80}
81
82fn geomspace(start: f64, end: f64) -> Vec<f64> {
83    let step = (end.log10() - start.log10()) / 199.0;
84    let mut values: Vec<_> = (0..200)
85        .map(|k| 10f64.powf(start.log10() + k as f64 * step))
86        .collect();
87    values[0] = start;
88    values[199] = end;
89    values
90}
91
92/// Position-mean one-third-octave support ranges, in main then sub order.
93/// Long spectra must already include the sub's fixed and bundle filters.
94pub fn support_ranges(
95    design: &Design,
96    spectra: &Spectra,
97    fsiso: f64,
98    positions: &[u32],
99) -> Result<SupportRanges, EngineError> {
100    if positions.is_empty() || !fsiso.is_finite() || fsiso <= 0.0 {
101        return Err(EngineError::Input("invalid MIMO range inputs".into()));
102    }
103    let f = design_freqs();
104    let mut out = Vec::new();
105    for (role, sub) in design
106        .mains
107        .iter()
108        .map(|m| (m.role.as_str(), None))
109        .chain(design.chain.subs.iter().map(|s| (s.role.as_str(), Some(s))))
110    {
111        let Some(paths): Option<Vec<_>> = positions.iter().map(|&p| spectra.get(p, role)).collect()
112        else {
113            continue;
114        };
115        let mut power = vec![0.0; f.len()];
116        for h in paths {
117            if h.len() != f.len() {
118                return Err(EngineError::Input("invalid MIMO spectrum length".into()));
119            }
120            for (sum, h) in power.iter_mut().zip(h.iter()) {
121                let a = h.re.hypot(h.im);
122                *sum += a * a;
123            }
124        }
125        for p in &mut power {
126            *p /= positions.len() as f64;
127        }
128        let db: Vec<_> = smooth_power(f, &power, Fraction::Scalar(3.0))?
129            .iter()
130            .map(|p| 10.0 * (p + 1e-30).log10())
131            .collect();
132        let (lo_ref, hi_ref) = if sub.is_some() {
133            (30.0, 80.0)
134        } else {
135            (200.0, 1000.0)
136        };
137        let mut reference = decay::median(
138            &f.iter()
139                .zip(&db)
140                .filter(|(f, _)| (lo_ref..=hi_ref).contains(*f))
141                .map(|(_, &v)| v)
142                .collect::<Vec<_>>(),
143        );
144        let mut start = if sub.is_some() {
145            50.0
146        } else {
147            fsiso.min(200.0)
148        };
149        if let Some(s) = sub.filter(|s| s.hp_hz != 0.0 || s.lp_hz != 0.0) {
150            let lo = s.hp_hz.max(15.0);
151            let hi = (if s.lp_hz != 0.0 {
152                s.lp_hz.min(80.0)
153            } else {
154                80.0
155            })
156            .max(lo * 1.5);
157            let indices: Vec<_> = f
158                .iter()
159                .enumerate()
160                .filter(|(_, f)| (lo..=hi).contains(f))
161                .map(|(k, _)| k)
162                .collect();
163            let values: Vec<_> = indices.iter().map(|&k| db[k]).collect();
164            let k = indices[decay::first_max(&values)];
165            reference = db[k];
166            start = f[k];
167        }
168        let low = geomspace(start, 15.0)
169            .into_iter()
170            .find(|&v| interp(v, f, &db) < reference - 10.0)
171            .unwrap_or(15.0)
172            .max(if sub.is_some() { 20.0 } else { 50.0 });
173        let high = geomspace(start, fsiso)
174            .into_iter()
175            .find(|&v| interp(v, f, &db) < reference - 10.0)
176            .unwrap_or(fsiso);
177        out.push((
178            role.to_owned(),
179            (low < high.min(fsiso) * 0.8).then(|| {
180                (
181                    (low * 10.0).round_ties_even() / 10.0,
182                    (high * 10.0).round_ties_even() / 10.0,
183                )
184            }),
185        ));
186    }
187    Ok(out)
188}
189
190/// Arrival-aligned acoustic path with the fourth-order lower-edge protection.
191pub fn support_path(
192    mimo: &MimoDesign,
193    role: &str,
194    spectra: &Spectra,
195    position: u32,
196    align_delay_ms: &[(String, f64)],
197) -> Result<Vec<C>, EngineError> {
198    let h = spectra
199        .get(position, role)
200        .ok_or_else(|| EngineError::Input(format!("missing MIMO path {position}/{role}")))?;
201    let delay = align_delay_ms
202        .iter()
203        .find(|(r, _)| r == role)
204        .map_or(0.0, |(_, ms)| *ms);
205    let low = mimo
206        .ranges
207        .iter()
208        .find(|(r, _)| r == role)
209        .and_then(|(_, r)| *r)
210        .map_or(50.0, |r| r.0);
211    Ok(h.iter()
212        .zip(delay_response(delay))
213        .zip(hp2(design_freqs(), low))
214        .map(|((&h, d), hp)| h * d * (hp * hp))
215        .collect())
216}
217
218pub(crate) fn convolve(a: &[f64], b: &[f64]) -> Vec<f64> {
219    if a.is_empty() || b.is_empty() {
220        return Vec::new();
221    }
222    // NumPy's convolve swaps the operands when the second is longer, then
223    // accumulates the first operand in increasing index order for each output.
224    let (a, b) = if a.len() < b.len() { (b, a) } else { (a, b) };
225    (0..a.len() + b.len() - 1)
226        .map(|t| {
227            let mut sum = 0.0;
228            for k in t.saturating_sub(b.len() - 1)..=(t.min(a.len() - 1)) {
229                sum += a[k] * b[t - k];
230            }
231            sum
232        })
233        .collect()
234}
235
236/// One block pair `(j, jj)` of the weighted normal equations, summed over
237/// positions in order, and the right-hand side of `j` when `j == jj`.
238/// The first row/column are direct time sums; the diagonal recurrence handles
239/// the truncated last sample and the weight step at `onset + 36`.
240fn block_pair(
241    xs: &[Vec<f64>],
242    paths: &[Vec<Vec<f64>>],
243    (j, jj): (usize, usize),
244    taps: usize,
245    early_weight: f64,
246) -> (Vec<f64>, Vec<f64>) {
247    let mut total = vec![0.0; taps * taps];
248    let mut rhs = vec![0.0; if j == jj { taps } else { 0 }];
249    for (x, gs) in xs.iter().zip(paths) {
250        let n = x.len();
251        let e = (onset(x) + EARLY).min(n);
252        let c = |t: usize| if t < e { early_weight } else { 1.0 };
253        let (g, h) = (&gs[j], &gs[jj]);
254        for (k, b) in rhs.iter_mut().enumerate() {
255            for t in e.max(k)..n {
256                *b -= x[t] * g[t - k];
257            }
258        }
259        let mut block = vec![0.0; taps * taps];
260        for k in 0..taps {
261            for t in k..n {
262                block[k * taps] += c(t) * g[t - k] * h[t];
263            }
264        }
265        for l in 1..taps {
266            for t in l..n {
267                block[l] += c(t) * g[t] * h[t - l];
268            }
269        }
270        for k in 0..taps - 1 {
271            for l in 0..taps - 1 {
272                let mut v = block[k * taps + l] - c(n - 1) * g[n - 1 - k] * h[n - 1 - l];
273                if e < n && e > k.max(l) {
274                    v += (1.0 - early_weight) * g[e - 1 - k] * h[e - 1 - l];
275                }
276                block[(k + 1) * taps + l + 1] = v;
277            }
278        }
279        for (sum, v) in total.iter_mut().zip(block) {
280            *sum += v;
281        }
282    }
283    (total, rhs)
284}
285
286/// A block pair's index and its `(block, rhs)`.
287type PairBlock = ((usize, usize), (Vec<f64>, Vec<f64>));
288
289fn block_pairs(candidates: usize) -> Vec<(usize, usize)> {
290    (0..candidates)
291        .flat_map(|j| (j..candidates).map(move |jj| (j, jj)))
292        .collect()
293}
294
295/// Place the upper-triangle blocks; AᵀA is symmetric, and the opposite
296/// triangle reuses the same value instead of a second accumulation.
297fn place(blocks: Vec<PairBlock>, ata: &mut [f64], atb: &mut [f64], taps: usize) {
298    let dim = atb.len();
299    for ((j, jj), (block, rhs)) in blocks {
300        if j == jj {
301            atb[j * taps..(j + 1) * taps].copy_from_slice(&rhs);
302        }
303        for k in 0..taps {
304            for l in 0..taps {
305                if j != jj || k <= l {
306                    let (row, col) = (j * taps + k, jj * taps + l);
307                    ata[row * dim + col] = block[k * taps + l];
308                    ata[col * dim + row] = block[k * taps + l];
309                }
310            }
311        }
312    }
313}
314
315/// Weighted normal equations, position-major paths, row-major output.
316/// `early_weight=10` is the design system, `0` is candidate ranking.
317pub(crate) fn normal_equations(
318    xs: &[Vec<f64>],
319    paths: &[Vec<Vec<f64>>],
320    taps: usize,
321    early_weight: f64,
322) -> (Vec<f64>, Vec<f64>) {
323    let candidates = paths[0].len();
324    let dim = candidates * taps;
325    let (mut ata, mut atb) = (vec![0.0; dim * dim], vec![0.0; dim]);
326    for pair in block_pairs(candidates) {
327        let block = block_pair(xs, paths, pair, taps, early_weight);
328        place(vec![(pair, block)], &mut ata, &mut atb, taps);
329    }
330    (ata, atb)
331}
332
333/// [`normal_equations`] with the block pairs as a map over `ctx`. Each block
334/// keeps the position order of its sums, and placement adds nothing, so the
335/// result has the same bits for any thread count.
336pub(crate) fn normal_equations_mapped(
337    xs: &[Vec<f64>],
338    paths: &[Vec<Vec<f64>>],
339    taps: usize,
340    early_weight: f64,
341    ctx: &Context,
342) -> Result<(Vec<f64>, Vec<f64>), EngineError> {
343    check(ctx)?;
344    let candidates = paths[0].len();
345    let dim = candidates * taps;
346    let (mut ata, mut atb) = (vec![0.0; dim * dim], vec![0.0; dim]);
347    // Retain at most eight completed blocks alongside AᵀA. Each block keeps
348    // its original position/time sums; placement only copies disjoint entries.
349    for pairs in block_pairs(candidates).chunks(8) {
350        let blocks = ctx
351            .map(pairs.to_vec(), |pair| {
352                (pair, block_pair(xs, paths, pair, taps, early_weight))
353            })
354            .map_err(|_| EngineError::Cancelled)?;
355        place(blocks, &mut ata, &mut atb, taps);
356    }
357    Ok((ata, atb))
358}
359
360// Retain the direct form as an independent guard oracle and diagnostic entry.
361#[cfg(any(test, feature = "test-support"))]
362pub(crate) fn worst_band_db(
363    xs: &[Vec<f64>],
364    paths: &[Vec<Vec<f64>>],
365    supports: &[Vec<f64>],
366    fsiso: f64,
367) -> f64 {
368    let n = xs[0].len();
369    let nf = 2 * n;
370    let bins = secs_dsp::grid::freqs(nf, FS_LOW).expect("MIMO grid");
371    let mut bands = third_octave_bands(fsiso);
372    let refs: Vec<_> = xs.iter().map(Vec::as_slice).collect();
373    bands.extend(
374        decay::late_peaks(&refs, 6)
375            .iter()
376            .map(|&f| (f - 1.5, f + 1.5)),
377    );
378    let mut before = vec![0.0; bands.len()];
379    let mut after = before.clone();
380    for (x, gs) in xs.iter().zip(paths) {
381        let mut add = vec![0.0; n];
382        for (g, s) in gs.iter().zip(supports) {
383            for (a, v) in add.iter_mut().zip(convolve(g, s)) {
384                *a += v;
385            }
386        }
387        let e = (onset(x) + EARLY).min(n);
388        for (acc, addition) in [(&mut before, false), (&mut after, true)] {
389            let mut y = vec![0.0; nf];
390            for t in e..n {
391                y[t] = x[t] + if addition { add[t] } else { 0.0 };
392            }
393            let spectrum: Vec<_> = fft::rfft(&y)
394                .iter()
395                .map(|h| {
396                    let a = h.re.hypot(h.im);
397                    a * a
398                })
399                .collect();
400            for (total, &(lo, hi)) in acc.iter_mut().zip(&bands) {
401                let begin = bins.partition_point(|&f| f < lo);
402                let end = bins.partition_point(|&f| f < hi);
403                *total += pairwise_sum(&spectrum[begin..end]);
404            }
405        }
406    }
407    before
408        .iter()
409        .zip(after)
410        .filter(|(a, _)| **a > 0.0)
411        .map(|(a, b)| 10.0 * ((b + 1e-30) / a).log10())
412        .reduce(f64::max)
413        .unwrap_or(-99.0)
414}
415
416/// The guards' third-octave bands from 20 Hz while the upper edge stays below
417/// `fsiso`, as `(lo, hi)` in Hz (TD-75: the CPU guard, its direct-form oracle
418/// and the IIR guard share them).
419pub(crate) fn third_octave_bands(fsiso: f64) -> Vec<(f64, f64)> {
420    let stop = (fsiso / 20.0).log2() + 1e-9;
421    let edges: Vec<_> = (0..)
422        .map(|k| k as f64 / 3.0)
423        .take_while(|&v| v < stop)
424        .map(|v| 20.0 * 2f64.powf(v))
425        .collect();
426    edges.windows(2).map(|v| (v[0], v[1])).collect()
427}
428
429#[derive(Debug)]
430pub(crate) struct BandGuard {
431    n: usize,
432    starts: Vec<usize>,
433    hz: Vec<(f64, f64)>,
434    bands: Vec<(usize, usize)>,
435    before: Vec<f64>,
436}
437
438impl BandGuard {
439    pub(crate) fn new(xs: &[Vec<f64>], fsiso: f64) -> Self {
440        let n = xs[0].len();
441        let bins = secs_dsp::grid::freqs(2 * n, FS_LOW).expect("MIMO grid");
442        let refs: Vec<_> = xs.iter().map(Vec::as_slice).collect();
443        let hz: Vec<_> = third_octave_bands(fsiso)
444            .into_iter()
445            .chain(
446                decay::late_peaks(&refs, 6)
447                    .into_iter()
448                    .map(|f| (f - 1.5, f + 1.5)),
449            )
450            .collect();
451        let bands = hz
452            .iter()
453            .map(|&(lo, hi)| {
454                (
455                    bins.partition_point(|&f| f < lo),
456                    bins.partition_point(|&f| f < hi),
457                )
458            })
459            .collect();
460        let mut guard = Self {
461            n,
462            starts: xs.iter().map(|x| (onset(x) + EARLY).min(n)).collect(),
463            hz,
464            bands,
465            before: Vec::new(),
466        };
467        guard.before = guard.energies(xs, true);
468        guard
469    }
470
471    fn energies(&self, responses: &[Vec<f64>], baseline: bool) -> Vec<f64> {
472        let mut totals = vec![0.0; self.bands.len()];
473        for (response, &start) in responses.iter().zip(&self.starts) {
474            let mut window = vec![0.0; 2 * self.n];
475            for t in start..self.n {
476                // Preserve the original baseline's x + 0.0, including -0.0.
477                window[t] = if baseline {
478                    response[t] + 0.0
479                } else {
480                    response[t]
481                };
482            }
483            let spectrum: Vec<_> = fft::rfft(&window)
484                .iter()
485                .map(|h| {
486                    let a = h.re.hypot(h.im);
487                    a * a
488                })
489                .collect();
490            for (total, &(begin, end)) in totals.iter_mut().zip(&self.bands) {
491                *total += pairwise_sum(&spectrum[begin..end]);
492            }
493        }
494        totals
495    }
496
497    pub(crate) fn evaluate(&self, ys: &[Vec<f64>]) -> f64 {
498        self.evaluate_band(ys).0
499    }
500
501    /// The worst late-energy change in dB and its band `(lo, hi)` in Hz.
502    pub(crate) fn evaluate_band(&self, ys: &[Vec<f64>]) -> (f64, Option<(f64, f64)>) {
503        let changes: Vec<_> = self
504            .before
505            .iter()
506            .zip(self.energies(ys, false))
507            .map(|(a, b)| (*a > 0.0).then(|| 10.0 * ((b + 1e-30) / a).log10()))
508            .collect();
509        let worst = changes
510            .iter()
511            .flatten()
512            .copied()
513            .reduce(f64::max)
514            .unwrap_or(-99.0);
515        let band = changes
516            .iter()
517            .position(|change| *change == Some(worst))
518            .map(|k| self.hz[k]);
519        (worst, band)
520    }
521}
522
523/// Absolute modal-tail change, in the upstream 0.15 seconds-per-dB scale.
524/// Despite its name this guard does not compare fitted T60 slopes.
525pub fn worst_t60_change(
526    xs: &[Vec<f64>],
527    ys: &[Vec<f64>],
528    count: usize,
529) -> Result<f64, EngineError> {
530    T60Guard::new(xs, count).evaluate(ys)
531}
532
533#[derive(Debug)]
534struct TailBaseline {
535    frequency: f64,
536    positions: usize,
537    sos: Sos,
538    energy: f64,
539}
540
541/// Fixed original modal peaks and mode-specific tails, shared across guard
542/// trials. Each trial still discovers its own new peaks before taking the
543/// union, so reducing the support cannot hide a newly exposed mode.
544#[derive(Debug)]
545pub(crate) struct T60Guard<'a> {
546    xs: &'a [Vec<f64>],
547    count: usize,
548    original_modes: Vec<f64>,
549    starts: Vec<usize>,
550    baselines: Vec<TailBaseline>,
551}
552
553impl<'a> T60Guard<'a> {
554    pub(crate) fn new(xs: &'a [Vec<f64>], count: usize) -> Self {
555        Self {
556            xs,
557            count,
558            original_modes: decay::late_peaks(
559                &xs.iter().map(Vec::as_slice).collect::<Vec<_>>(),
560                count,
561            ),
562            starts: xs.iter().map(|x| onset(x) + 180).collect(),
563            baselines: Vec::new(),
564        }
565    }
566
567    pub(crate) fn evaluate(&mut self, ys: &[Vec<f64>]) -> Result<f64, EngineError> {
568        Ok(self.evaluate_mode(ys)?.0)
569    }
570
571    /// The worst tail change in the 0.15 scale and the mode it is at, Hz.
572    pub(crate) fn evaluate_mode(
573        &mut self,
574        ys: &[Vec<f64>],
575    ) -> Result<(f64, Option<f64>), EngineError> {
576        let mut modes: Vec<_> = self
577            .original_modes
578            .iter()
579            .copied()
580            .chain(decay::late_peaks(
581                &ys.iter().map(Vec::as_slice).collect::<Vec<_>>(),
582                self.count,
583            ))
584            .map(|f| (f * 10.0).round_ties_even() / 10.0)
585            .collect();
586        modes.sort_by(f64::total_cmp);
587        modes.dedup();
588        let positions = self.xs.len().min(ys.len());
589        let mut tails = Vec::new();
590        for fm in modes {
591            let index = self
592                .baselines
593                .iter()
594                .position(|entry| entry.frequency == fm && entry.positions == positions);
595            let index = if let Some(index) = index {
596                index
597            } else {
598                let sos = butter(2, &[(fm - 1.5).max(1.0), fm + 1.5], BType::Bandpass, FS_LOW)?;
599                let mut energy = 0.0;
600                for (x, &t) in self.xs.iter().zip(&self.starts).take(positions) {
601                    let xf = sosfilt(&sos, x)?;
602                    energy += decay::energy(&xf[t.min(xf.len())..]);
603                }
604                self.baselines.push(TailBaseline {
605                    frequency: fm,
606                    positions,
607                    sos,
608                    energy,
609                });
610                self.baselines.len() - 1
611            };
612            let baseline = &self.baselines[index];
613            let mut after = 0.0;
614            for (y, &t) in ys.iter().zip(&self.starts).take(positions) {
615                let yf = sosfilt(&baseline.sos, y)?;
616                after += decay::energy(&yf[t.min(yf.len())..]);
617            }
618            tails.push((fm, baseline.energy, after));
619        }
620        let top = tails.iter().map(|&(_, a, _)| a).fold(0.0, f64::max);
621        let mut worst: f64 = -99.0;
622        let mut mode = None;
623        for (fm, a, b) in tails {
624            if a > 0.0 && a >= top * 10f64.powf(-20.0 / 10.0) {
625                let change = 10.0 * ((b + 1e-30) / a).log10();
626                if change > worst {
627                    mode = Some(fm);
628                }
629                worst = worst.max(change);
630            }
631        }
632        Ok((worst * 0.15, mode))
633    }
634}
635
636#[cfg(test)]
637pub(crate) fn worst_t60_change_before(
638    xs: &[Vec<f64>],
639    ys: &[Vec<f64>],
640    count: usize,
641) -> Result<f64, EngineError> {
642    let refs =
643        |v: &[Vec<f64>]| decay::late_peaks(&v.iter().map(Vec::as_slice).collect::<Vec<_>>(), count);
644    let mut modes: Vec<_> = refs(xs)
645        .into_iter()
646        .chain(refs(ys))
647        .map(|f| (f * 10.0).round_ties_even() / 10.0)
648        .collect();
649    modes.sort_by(f64::total_cmp);
650    modes.dedup();
651    let mut tails = Vec::new();
652    for fm in modes {
653        let sos = butter(2, &[(fm - 1.5).max(1.0), fm + 1.5], BType::Bandpass, FS_LOW)?;
654        let (mut a, mut b) = (0.0, 0.0);
655        for (x, y) in xs.iter().zip(ys) {
656            let t = onset(x) + 180;
657            let xf = sosfilt(&sos, x)?;
658            let yf = sosfilt(&sos, y)?;
659            a += decay::energy(&xf[t.min(xf.len())..]);
660            b += decay::energy(&yf[t.min(yf.len())..]);
661        }
662        tails.push((a, b));
663    }
664    let top = tails.iter().map(|&(a, _)| a).fold(0.0, f64::max);
665    let mut worst: f64 = -99.0;
666    for (a, b) in tails {
667        if a > 0.0 && a >= top * 10f64.powf(-20.0 / 10.0) {
668            worst = worst.max(10.0 * ((b + 1e-30) / a).log10());
669        }
670    }
671    Ok(worst * 0.15)
672}
673
674#[derive(Debug, Clone, PartialEq)]
675pub(crate) struct LambdaStep {
676    pub mid: f64,
677    pub ratio: f64,
678    pub accepted: bool,
679}
680#[derive(Debug, Clone, PartialEq)]
681pub(crate) struct GuardStep {
682    pub scale: f64,
683    pub band_db: f64,
684    pub t60_change: f64,
685    /// Where `band_db` and `t60_change` were found (`BandGuard::evaluate_band`,
686    /// `T60Guard::evaluate_mode`).
687    pub band_hz: Option<(f64, f64)>,
688    pub mode_hz: Option<f64>,
689}
690
691impl GuardStep {
692    fn passes(&self) -> bool {
693        self.band_db <= 1.0 && self.t60_change <= 0.15
694    }
695}
696
697/// The last rejected guard step as a note's cause; `None` when every step
698/// passed (no reduction).
699pub(crate) fn guard_cause(guards: &[GuardStep]) -> Option<crate::notes::GuardCause> {
700    let step = guards.iter().rev().find(|step| !step.passes())?;
701    Some(crate::notes::GuardCause {
702        rejected_db: 20.0 * step.scale.max(1e-9).log10(),
703        band: (step.band_db > 1.0)
704            .then_some(step.band_hz)
705            .flatten()
706            .map(|(lo, hi)| (lo, hi, step.band_db)),
707        tail: (step.t60_change > 0.15)
708            .then_some(step.mode_hz)
709            .flatten()
710            .map(|hz| (hz, step.t60_change / 0.15)),
711    })
712}
713#[derive(Debug, Clone, PartialEq)]
714pub(crate) struct InputTrace {
715    pub candidates: Vec<String>,
716    pub ranking: Vec<(String, f64)>,
717    pub trajectory: Vec<LambdaStep>,
718    pub log_lambda: f64,
719    pub ratio: f64,
720    pub guards: Vec<GuardStep>,
721    pub guard_scale: f64,
722}
723#[derive(Debug)]
724pub(crate) struct InputResult {
725    pub supports: Vec<(String, Vec<f64>)>,
726    pub level_db: f64,
727    pub trace: InputTrace,
728}
729
730fn check(ctx: &Context) -> Result<(), EngineError> {
731    ctx.cancel().check().map_err(|_| EngineError::Cancelled)
732}
733
734pub(crate) fn rank_supports(
735    candidates: &[String],
736    xs: &[Vec<f64>],
737    paths: &[Vec<Vec<f64>>],
738    taps: usize,
739    ctx: &Context,
740) -> Result<Vec<(String, f64)>, EngineError> {
741    let late_input: Vec<_> = xs
742        .iter()
743        .flat_map(|x| {
744            let e = onset(x) + EARLY;
745            x.iter()
746                .enumerate()
747                .map(move |(t, &x)| if t >= e { x } else { 0.0 })
748        })
749        .collect();
750    let input_energy = decay::energy(&late_input);
751    let work = xs.iter().map(Vec::len).sum::<usize>() + 3 * taps;
752    let mut scored = Vec::new();
753    for (j, role) in candidates.iter().enumerate() {
754        check(ctx)?;
755        let candidate_paths: Vec<_> = paths.iter().map(|g| vec![g[j].clone()]).collect();
756        let (ata, atb) = normal_equations(xs, &candidate_paths, taps, 0.0);
757        let diagonal: Vec<_> = (0..taps).map(|k| ata[k * taps + k]).collect();
758        let lambda = 1e-2 * pairwise_sum(&diagonal) / taps as f64;
759        let u = solve(&ata, &atb, lambda, false)?;
760        let path_energy = candidate_paths.iter().map(|gs| decay::energy(&gs[0])).sum();
761        let (score, uncertainty) =
762            rank_score(input_energy, path_energy, &ata, &atb, &u, lambda, work);
763        // Cancellation and nonfinite arithmetic must not make a poor path
764        // look perfect. Keep the original residual evaluation in that case.
765        let direct = !score.is_finite() || !uncertainty.is_finite() || score <= 8.0 * uncertainty;
766        scored.push((
767            role.clone(),
768            if direct {
769                rank_score_direct(xs, paths, j, &u)
770            } else {
771                score
772            },
773            if direct { 0.0 } else { uncertainty },
774            u,
775        ));
776    }
777    // A tiny scalar roundoff must not change the discrete support ordering.
778    // Recompute both members of every overlapping score interval, before
779    // the stable sort, in the original time-domain accumulation order.
780    let mut direct = vec![false; scored.len()];
781    for i in 0..scored.len() {
782        for j in i + 1..scored.len() {
783            if (scored[i].1 - scored[j].1).abs() <= scored[i].2 + scored[j].2 {
784                direct[i] = true;
785                direct[j] = true;
786            }
787        }
788    }
789    let mut scores = Vec::with_capacity(scored.len());
790    for (j, ((role, score, _, u), direct)) in scored.into_iter().zip(direct).enumerate() {
791        check(ctx)?;
792        scores.push((
793            role,
794            if direct {
795                rank_score_direct(xs, paths, j, &u)
796            } else {
797                score
798            },
799        ));
800    }
801    scores.sort_by(|a, b| a.1.total_cmp(&b.1));
802    Ok(scores)
803}
804
805fn rank_score(
806    input_energy: f64,
807    path_energy: f64,
808    ata: &[f64],
809    atb: &[f64],
810    u: &[f64],
811    lambda: f64,
812    work: usize,
813) -> (f64, f64) {
814    let products: Vec<_> = u.iter().zip(atb).map(|(u, b)| u * b).collect();
815    let ub = pairwise_sum(&products);
816    let regularization = lambda * decay::energy(u);
817    let u_l1 = u.iter().map(|v| v.abs()).sum::<f64>();
818    // Full path energy also bounds cancellation in the Gram's sliding
819    // recurrence when the retained late-window block itself is tiny.
820    let mut magnitude = input_energy
821        + 2.0 * products.iter().map(|v| v.abs()).sum::<f64>()
822        + regularization.abs()
823        + path_energy * u_l1 * u_l1;
824    let mut row_products = vec![0.0; u.len()];
825    let mut corrections = Vec::with_capacity(u.len());
826    for (i, row) in ata.chunks_exact(u.len()).enumerate() {
827        for ((v, a), u) in row_products.iter_mut().zip(row).zip(u) {
828            *v = a * u;
829        }
830        let residual = (pairwise_sum(&row_products) + lambda * u[i]) - atb[i];
831        corrections.push(u[i] * residual);
832        magnitude += u[i].abs() * row_products.iter().map(|v| v.abs()).sum::<f64>();
833    }
834    // b = -Aᵀx, M = AᵀA. For a finite-precision solve the exact identity is
835    // ||Au+x||² = xᵀx - uᵀb - λuᵀu + uᵀ((M+λI)u-b).
836    // The O(taps²) residual correction is cheaper than the old O(P*N*taps)
837    // convolutions and does not assume the solver residual is exactly zero.
838    let score = ((input_energy - ub) - regularization) + pairwise_sum(&corrections);
839    // This deliberately conservative reevaluation trigger scales with Gram
840    // recurrences and dot products. It is not a certified error bound or a
841    // relaxation of any result-validation tolerance.
842    let uncertainty = 64.0 * f64::EPSILON * work.max(1) as f64 * magnitude;
843    (score, uncertainty)
844}
845
846pub(crate) fn rank_score_direct(
847    xs: &[Vec<f64>],
848    paths: &[Vec<Vec<f64>>],
849    candidate: usize,
850    u: &[f64],
851) -> f64 {
852    let mut residual = Vec::new();
853    for (x, gs) in xs.iter().zip(paths) {
854        let y = convolve(&gs[candidate], u);
855        let e = onset(x) + EARLY;
856        residual.extend(
857            x.iter()
858                .enumerate()
859                .map(|(t, &x)| if t >= e { y[t] + x } else { 0.0 }),
860        );
861    }
862    decay::energy(&residual)
863}
864
865/// The low-rate stage boundary used by the oracle: paths have lower-edge
866/// protection and arrival alignment, but not the common low-pass FIR yet.
867pub(crate) fn design_input(
868    xs: &[Vec<f64>],
869    raw_paths: &[Vec<Vec<f64>>],
870    direct: &[f64],
871    candidates: &[String],
872    req: &MimoRequest,
873    ctx: &Context,
874) -> Result<InputResult, EngineError> {
875    design_input_observed(xs, raw_paths, direct, candidates, req, ctx, &|_, _| {})
876}
877
878pub(crate) fn design_input_observed(
879    xs: &[Vec<f64>],
880    raw_paths: &[Vec<Vec<f64>>],
881    direct: &[f64],
882    candidates: &[String],
883    req: &MimoRequest,
884    ctx: &Context,
885    observe: &dyn Fn(&str, std::time::Duration),
886) -> Result<InputResult, EngineError> {
887    design_input_accelerated(xs, raw_paths, direct, candidates, req, ctx, observe, None)
888}
889
890#[allow(clippy::too_many_arguments)]
891fn design_input_accelerated(
892    xs: &[Vec<f64>],
893    raw_paths: &[Vec<Vec<f64>>],
894    direct: &[f64],
895    candidates: &[String],
896    req: &MimoRequest,
897    ctx: &Context,
898    observe: &dyn Fn(&str, std::time::Duration),
899    acceleration: Option<&super::acceleration::RunAcceleration<'_>>,
900) -> Result<InputResult, EngineError> {
901    let started = std::time::Instant::now();
902    let taps = req.taps_low;
903    let lp = lowpass_fir(req.fsiso, 31)?;
904    let n = xs[0].len();
905    let all_paths: Vec<Vec<Vec<f64>>> = raw_paths
906        .iter()
907        .map(|gs| {
908            gs.iter()
909                .map(|g| {
910                    let mut h = convolve(g, &lp);
911                    h.truncate(n);
912                    h
913                })
914                .collect()
915        })
916        .collect();
917    observe("path_building", started.elapsed());
918    let started = std::time::Instant::now();
919    let ranking = if req
920        .max_supports
921        .is_some_and(|cap| cap > 0 && candidates.len() > cap)
922    {
923        rank_supports(candidates, xs, &all_paths, taps, ctx)?
924    } else {
925        Vec::new()
926    };
927    observe("ranking", started.elapsed());
928    let selected: Vec<_> = if ranking.is_empty() {
929        candidates.to_vec()
930    } else {
931        ranking
932            .iter()
933            .take(req.max_supports.unwrap())
934            .map(|(r, _)| r.clone())
935            .collect()
936    };
937    let indices: Vec<_> = selected
938        .iter()
939        .map(|r| candidates.iter().position(|v| v == r).unwrap())
940        .collect();
941    let select = |paths: &[Vec<Vec<f64>>]| {
942        paths
943            .iter()
944            .map(|gs| indices.iter().map(|&j| gs[j].clone()).collect())
945            .collect::<Vec<_>>()
946    };
947    design_selected(
948        xs,
949        &select(raw_paths),
950        direct,
951        &select(&all_paths),
952        &selected,
953        &ranking,
954        req,
955        ctx,
956        observe,
957        acceleration,
958    )
959}
960
961#[allow(clippy::too_many_arguments)]
962fn design_selected(
963    xs: &[Vec<f64>],
964    raw_paths: &[Vec<Vec<f64>>],
965    direct: &[f64],
966    paths: &[Vec<Vec<f64>>],
967    candidates: &[String],
968    ranking: &[(String, f64)],
969    req: &MimoRequest,
970    ctx: &Context,
971    observe: &dyn Fn(&str, std::time::Duration),
972    acceleration: Option<&super::acceleration::RunAcceleration<'_>>,
973) -> Result<InputResult, EngineError> {
974    let taps = req.taps_low;
975    let lp = lowpass_fir(req.fsiso, 31)?;
976    let n = xs[0].len();
977    let started = std::time::Instant::now();
978    let (ata, atb) = normal_equations_mapped(xs, paths, taps, 10.0, ctx)?;
979    observe("normal_equations", started.elapsed());
980    let started = std::time::Instant::now();
981    let dim = atb.len();
982    if dim == 0 {
983        return Err(EngineError::Input("no MIMO support candidates".into()));
984    }
985    let diagonal: Vec<_> = (0..dim).map(|k| ata[k * dim + k]).collect();
986    let normalization = pairwise_sum(&diagonal) / dim as f64;
987    let target = 10f64.powf(req.level_db / 10.0);
988    let direct_energy = decay::energy(direct).max(1e-30);
989    let result = super::acceleration::run_sweep(
990        super::acceleration::RidgeProblem {
991            ata: &ata,
992            atb: &atb,
993            lp: &lp,
994            taps,
995            normalization,
996            target,
997            direct_energy,
998        },
999        ctx.cancel(),
1000        acceleration,
1001    )?;
1002    let (u, ratio, hi, trajectory) = (result.u, result.ratio, result.log_lambda, result.trajectory);
1003    observe("sweep", started.elapsed());
1004    let started = std::time::Instant::now();
1005    let supports: Vec<_> = (0..candidates.len())
1006        .map(|k| convolve(&lp, &u[k * taps..(k + 1) * taps]))
1007        .collect();
1008    let mut scale = 1.0;
1009    let mut guards = Vec::new();
1010    let band_guard = BandGuard::new(xs, req.fsiso);
1011    let mut tail_guard = T60Guard::new(xs, 6);
1012    for step in 0..5 {
1013        check(ctx)?;
1014        let trial: Vec<Vec<_>> = supports
1015            .iter()
1016            .map(|s| s.iter().map(|v| v * scale).collect())
1017            .collect();
1018        let ys: Vec<_> = xs
1019            .iter()
1020            .zip(raw_paths)
1021            .map(|(x, gs)| {
1022                let mut add = vec![0.0; n];
1023                for (g, s) in gs.iter().zip(&trial) {
1024                    for (v, h) in add.iter_mut().zip(convolve(g, s)) {
1025                        *v += h;
1026                    }
1027                }
1028                x.iter().zip(add).map(|(x, a)| x + a).collect()
1029            })
1030            .collect();
1031        let (band_db, band_hz) = band_guard.evaluate_band(&ys);
1032        let (t60_change, mode_hz) = tail_guard.evaluate_mode(&ys)?;
1033        let guard = GuardStep {
1034            scale,
1035            band_db,
1036            t60_change,
1037            band_hz,
1038            mode_hz,
1039        };
1040        let passes = guard.passes();
1041        guards.push(guard);
1042        if passes {
1043            break;
1044        }
1045        scale = if step == 4 {
1046            0.0
1047        } else {
1048            scale * 10f64.powf(-3.0 / 20.0)
1049        };
1050    }
1051    observe("guard_steps", started.elapsed());
1052    Ok(InputResult {
1053        supports: if scale > 0.0 {
1054            candidates
1055                .iter()
1056                .cloned()
1057                .zip(
1058                    supports
1059                        .into_iter()
1060                        .map(|s| s.into_iter().map(|v| v * scale).collect()),
1061                )
1062                .collect()
1063        } else {
1064            Vec::new()
1065        },
1066        level_db: 10.0 * (ratio * scale * scale).max(1e-30).log10(),
1067        trace: InputTrace {
1068            candidates: candidates.to_vec(),
1069            ranking: ranking.to_vec(),
1070            trajectory,
1071            log_lambda: hi,
1072            ratio,
1073            guards,
1074            guard_scale: scale,
1075        },
1076    })
1077}
1078
1079pub fn design_mimo(
1080    design: &Design,
1081    measurements: &MeasurementSet,
1082    config: &EngineConfig,
1083    req: &MimoRequest,
1084    ctx: &Context,
1085) -> Result<MimoDesign, EngineError> {
1086    design_mimo_traced(design, measurements, config, req, ctx).map(|(out, _)| out)
1087}
1088
1089pub(crate) fn design_mimo_traced(
1090    design: &Design,
1091    measurements: &MeasurementSet,
1092    config: &EngineConfig,
1093    req: &MimoRequest,
1094    ctx: &Context,
1095) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1096    design_mimo_inputs(design, measurements, config, req, ctx, None)
1097}
1098
1099pub(crate) fn design_mimo_inputs(
1100    design: &Design,
1101    measurements: &MeasurementSet,
1102    config: &EngineConfig,
1103    req: &MimoRequest,
1104    ctx: &Context,
1105    input: Option<&str>,
1106) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1107    design_mimo_inputs_observed(design, measurements, config, req, ctx, input, &|_, _| {})
1108}
1109
1110#[allow(clippy::too_many_arguments)]
1111pub(crate) fn design_mimo_inputs_observed(
1112    design: &Design,
1113    measurements: &MeasurementSet,
1114    config: &EngineConfig,
1115    req: &MimoRequest,
1116    ctx: &Context,
1117    input: Option<&str>,
1118    observe: &(dyn Fn(&str, std::time::Duration) + Sync),
1119) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1120    check(ctx)?;
1121    let mut out = MimoDesign::default();
1122    let positions = &design.spectra.positions;
1123    if positions.len() < 3 {
1124        out.notes
1125            .push(Note::Mimo(crate::notes::MimoNote::InsufficientPositions {
1126                measured: positions.len(),
1127            }));
1128        return Ok((out, Vec::new()));
1129    }
1130    validate_request(req)?;
1131    let started = std::time::Instant::now();
1132    let prepared = super::prepared::Prepared::new(design, measurements, config, ctx)?;
1133    observe("preparation", started.elapsed());
1134    design_mimo_prepared(design, &prepared, req, ctx, input, observe)
1135}
1136
1137#[allow(clippy::too_many_arguments)]
1138pub(crate) fn design_mimo_prepared(
1139    design: &Design,
1140    prepared: &super::prepared::Prepared<'_>,
1141    req: &MimoRequest,
1142    ctx: &Context,
1143    input: Option<&str>,
1144    observe: &(dyn Fn(&str, std::time::Duration) + Sync),
1145) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1146    design_mimo_prepared_accelerated(design, prepared, req, ctx, input, observe, None)
1147}
1148
1149#[allow(clippy::too_many_arguments)]
1150pub(crate) fn design_mimo_prepared_accelerated(
1151    design: &Design,
1152    prepared: &super::prepared::Prepared<'_>,
1153    req: &MimoRequest,
1154    ctx: &Context,
1155    input: Option<&str>,
1156    observe: &(dyn Fn(&str, std::time::Duration) + Sync),
1157    acceleration: Option<&super::acceleration::RunAcceleration<'_>>,
1158) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1159    check(ctx)?;
1160    let mut out = MimoDesign::default();
1161    let positions = &design.spectra.positions;
1162    if positions.len() < 3 {
1163        out.notes
1164            .push(Note::Mimo(crate::notes::MimoNote::InsufficientPositions {
1165                measured: positions.len(),
1166            }));
1167        return Ok((out, Vec::new()));
1168    }
1169    validate_request(req)?;
1170    let spectra = &prepared.spectra;
1171    let started = std::time::Instant::now();
1172    out.ranges = support_ranges(design, spectra, req.fsiso, positions)?;
1173    if let Some(low) = req.f_low.filter(|v| *v != 0.0) {
1174        for (_, range) in &mut out.ranges {
1175            *range =
1176                range.and_then(|(lo, hi)| (lo.max(low) < hi * 0.8).then_some((lo.max(low), hi)));
1177        }
1178    }
1179    let usable: Vec<_> = out
1180        .ranges
1181        .iter()
1182        .filter(|(r, range)| {
1183            range.is_some()
1184                && !req.exclude.contains(r)
1185                && req.groups.as_ref().is_none_or(|groups| {
1186                    design
1187                        .groups
1188                        .iter()
1189                        .any(|(g, roles)| groups.contains(g) && roles.contains(r))
1190                })
1191        })
1192        .map(|(r, _)| r.clone())
1193        .collect();
1194    if !req.exclude.is_empty() {
1195        let mut excluded = req.exclude.clone();
1196        excluded.sort();
1197        out.notes
1198            .push(Note::Mimo(crate::notes::MimoNote::ExcludedSupports(
1199                excluded,
1200            )));
1201    }
1202    out.notes
1203        .push(Note::Mimo(crate::notes::MimoNote::SupportRanges(
1204            out.ranges.clone(),
1205        )));
1206    observe("ranges", started.elapsed());
1207    let started = std::time::Instant::now();
1208    let mains: Vec<_> = design
1209        .mains
1210        .iter()
1211        .filter(|m| input.is_none_or(|r| r == m.role))
1212        .collect();
1213    let roles: Vec<_> = usable
1214        .iter()
1215        .filter(|r| mains.iter().any(|m| m.role != **r))
1216        .collect();
1217    let pairs: Vec<_> = positions
1218        .iter()
1219        .flat_map(|&p| roles.iter().map(move |r| (p, *r)))
1220        .collect();
1221    let shared_bytes = (pairs.len()
1222        * (720 * 8 + std::mem::size_of::<Vec<f64>>() + 2 * std::mem::size_of::<(u32, &String)>()))
1223        as u64;
1224    let scratch_bytes = (32 * design_freqs().len() * 8) as u64;
1225    let largest_input = mains
1226        .iter()
1227        .map(|m| {
1228            input_bytes(
1229                positions.len(),
1230                usable.iter().filter(|r| **r != m.role).count(),
1231                req.taps_low,
1232            ) as u64
1233        })
1234        .max()
1235        .unwrap_or(0);
1236    // A retained reservation must not leave a worker waiting for bytes that
1237    // this same invocation cannot release until the worker has finished. The
1238    // preparation's spectra stay reserved underneath (TD-24).
1239    let retained = prepared.reserved.saturating_add(shared_bytes);
1240    let needed = retained.saturating_add(largest_input.max(scratch_bytes));
1241    if needed > ctx.budget().total() {
1242        return Err(EngineError::Input(
1243            MemoryError::TooLarge {
1244                requested: needed,
1245                total: ctx.budget().total(),
1246            }
1247            .to_string(),
1248        ));
1249    }
1250    let _shared_permit = ctx
1251        .budget()
1252        .acquire(shared_bytes, ctx.cancel())
1253        .map_err(memory_error)?;
1254    let low_paths = crate::memory::try_map(
1255        ctx,
1256        pairs.clone(),
1257        retained,
1258        |_| scratch_bytes,
1259        |(p, r)| {
1260            let h = support_path(&out, r, spectra, p, &prepared.align_delay_ms)?;
1261            Ok::<_, EngineError>(secs_dsp::lowrate::to_low(&h, design_freqs(), 720)?)
1262        },
1263    )?;
1264    observe("shared_paths", started.elapsed());
1265    let started = std::time::Instant::now();
1266    let results = crate::memory::try_map(
1267        ctx,
1268        mains,
1269        retained,
1270        |main| {
1271            input_bytes(
1272                positions.len(),
1273                usable.iter().filter(|r| **r != main.role).count(),
1274                req.taps_low,
1275            ) as u64
1276        },
1277        |main| {
1278            let candidates: Vec<_> = usable
1279                .iter()
1280                .filter(|r| **r != main.role)
1281                .cloned()
1282                .collect();
1283            let started = std::time::Instant::now();
1284            let mut xs = Vec::new();
1285            let mut paths = Vec::new();
1286            for &p in positions {
1287                check(ctx)?;
1288                let response =
1289                    crate::input_response(design, &main.role, spectra, p).ok_or_else(|| {
1290                        EngineError::Input(format!("missing MIMO input {p}/{}", main.role))
1291                    })?;
1292                xs.push(secs_dsp::lowrate::to_low(&response, design_freqs(), 720)?);
1293                paths.push(
1294                    candidates
1295                        .iter()
1296                        .map(|r| {
1297                            let index = pairs
1298                                .iter()
1299                                .position(|(pos, role)| *pos == p && *role == r)
1300                                .unwrap();
1301                            low_paths[index].clone()
1302                        })
1303                        .collect(),
1304                );
1305            }
1306            let direct = secs_dsp::lowrate::to_low(&main.response, design_freqs(), 720)?;
1307            observe("input_preparation", started.elapsed());
1308            let result = design_input_accelerated(
1309                &xs,
1310                &paths,
1311                &direct,
1312                &candidates,
1313                req,
1314                ctx,
1315                observe,
1316                acceleration,
1317            )?;
1318            Ok((main.role.clone(), result))
1319        },
1320    )?;
1321    observe("inputs_wall", started.elapsed());
1322    let mut traces = Vec::new();
1323    for (role, result) in results {
1324        let scale = result.trace.guard_scale;
1325        if let Some(cause) = guard_cause(&result.trace.guards) {
1326            out.notes.push(Note::Mimo(if scale > 0.0 {
1327                crate::notes::MimoNote::GuardReduced {
1328                    role: role.clone(),
1329                    gain_db: 20.0 * scale.max(1e-9).log10(),
1330                    cause,
1331                }
1332            } else {
1333                crate::notes::MimoNote::GuardDisabled {
1334                    role: role.clone(),
1335                    cause,
1336                }
1337            }));
1338        }
1339        out.supports.push((role.clone(), result.supports));
1340        out.level_db.push((role.clone(), result.level_db));
1341        traces.push((role, result.trace));
1342    }
1343    Ok((out, traces))
1344}
1345
1346fn validate_request(req: &MimoRequest) -> Result<(), EngineError> {
1347    if !(0.0..300.0).contains(&req.fsiso)
1348        || req.fsiso == 0.0
1349        || !req.level_db.is_finite()
1350        || req.taps_low == 0
1351        || req.taps_low > 720
1352        || req.f_low.is_some_and(|f| !f.is_finite())
1353    {
1354        return Err(EngineError::Input("invalid MIMO request".into()));
1355    }
1356    Ok(())
1357}
1358
1359fn memory_error(error: MemoryError) -> EngineError {
1360    match error {
1361        MemoryError::Cancelled => EngineError::Cancelled,
1362        other => EngineError::Input(other.to_string()),
1363    }
1364}
1365
1366fn input_bytes(positions: usize, candidates: usize, taps: usize) -> usize {
1367    let dim = candidates * taps;
1368    8 * (4 * dim * dim
1369        + taps * taps
1370        + positions * 720 * (4 * candidates + 8)
1371        + 32 * design_freqs().len()
1372        + 16 * dim
1373        + 16384)
1374}
1375
1376// Pre-P18c per-input preparation, retained only as an independent bitwise oracle.
1377#[cfg(test)]
1378pub(crate) fn design_mimo_before(
1379    design: &Design,
1380    measurements: &MeasurementSet,
1381    config: &EngineConfig,
1382    req: &MimoRequest,
1383    ctx: &Context,
1384    input: Option<&str>,
1385) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1386    check(ctx)?;
1387    let mut out = MimoDesign::default();
1388    let positions = &design.spectra.positions;
1389    if positions.len() < 3 {
1390        out.notes
1391            .push(Note::Mimo(crate::notes::MimoNote::InsufficientPositions {
1392                measured: positions.len(),
1393            }));
1394        return Ok((out, Vec::new()));
1395    }
1396    if !(0.0..300.0).contains(&req.fsiso)
1397        || req.fsiso == 0.0
1398        || !req.level_db.is_finite()
1399        || req.taps_low == 0
1400        || req.taps_low > 720
1401        || req.f_low.is_some_and(|f| !f.is_finite())
1402    {
1403        return Err(EngineError::Input("invalid MIMO request".into()));
1404    }
1405    let prepared =
1406        crate::measurement::prepare(measurements, config, &crate::setup::Setup::new(config))?;
1407    let spectra =
1408        crate::export_view::export_view_prepared(design, measurements, &prepared, &|| false)?
1409            .spectra;
1410    out.ranges = support_ranges(design, &spectra, req.fsiso, positions)?;
1411    if let Some(low) = req.f_low.filter(|v| *v != 0.0) {
1412        for (_, range) in &mut out.ranges {
1413            *range =
1414                range.and_then(|(lo, hi)| (lo.max(low) < hi * 0.8).then_some((lo.max(low), hi)));
1415        }
1416    }
1417    let usable: Vec<_> = out
1418        .ranges
1419        .iter()
1420        .filter(|(r, range)| {
1421            range.is_some()
1422                && !req.exclude.contains(r)
1423                && req.groups.as_ref().is_none_or(|groups| {
1424                    design
1425                        .groups
1426                        .iter()
1427                        .any(|(g, roles)| groups.contains(g) && roles.contains(r))
1428                })
1429        })
1430        .map(|(r, _)| r.clone())
1431        .collect();
1432    if !req.exclude.is_empty() {
1433        let mut excluded = req.exclude.clone();
1434        excluded.sort();
1435        out.notes
1436            .push(Note::Mimo(crate::notes::MimoNote::ExcludedSupports(
1437                excluded,
1438            )));
1439    }
1440    out.notes
1441        .push(Note::Mimo(crate::notes::MimoNote::SupportRanges(
1442            out.ranges.clone(),
1443        )));
1444    let results = ctx
1445        .try_map(
1446            design
1447                .mains
1448                .iter()
1449                .filter(|m| input.is_none_or(|r| r == m.role))
1450                .collect(),
1451            |main| {
1452                let candidates: Vec<_> = usable
1453                    .iter()
1454                    .filter(|r| **r != main.role)
1455                    .cloned()
1456                    .collect();
1457                let dim = candidates.len() * req.taps_low;
1458                // Normal equations, Householder reduction, possible fallback factor,
1459                // and scratch (also covers the mapped blocks during construction),
1460                // plus path/guard/design-grid buffers. Reserve before allocating.
1461                let bytes = 8
1462                    * (4 * dim * dim
1463                        + req.taps_low * req.taps_low
1464                        + positions.len() * 720 * (4 * candidates.len() + 8)
1465                        + 32 * design_freqs().len()
1466                        + 16 * dim
1467                        + 16384);
1468                let _permit =
1469                    ctx.budget()
1470                        .acquire(bytes as u64, ctx.cancel())
1471                        .map_err(|e| match e {
1472                            MemoryError::Cancelled => EngineError::Cancelled,
1473                            other => EngineError::Input(other.to_string()),
1474                        })?;
1475                let mut xs = Vec::new();
1476                let mut paths = Vec::new();
1477                for &p in positions {
1478                    check(ctx)?;
1479                    let response = crate::input_response(design, &main.role, &spectra, p)
1480                        .ok_or_else(|| {
1481                            EngineError::Input(format!("missing MIMO input {p}/{}", main.role))
1482                        })?;
1483                    xs.push(secs_dsp::lowrate::to_low(&response, design_freqs(), 720)?);
1484                    let gs: Result<Vec<_>, EngineError> = candidates
1485                        .iter()
1486                        .map(|r| {
1487                            let h = support_path(&out, r, &spectra, p, &prepared.align_delay_ms)?;
1488                            Ok(secs_dsp::lowrate::to_low(&h, design_freqs(), 720)?)
1489                        })
1490                        .collect();
1491                    paths.push(gs?);
1492                }
1493                let direct = secs_dsp::lowrate::to_low(&main.response, design_freqs(), 720)?;
1494                let result = design_input(&xs, &paths, &direct, &candidates, req, ctx)?;
1495                Ok((main.role.clone(), result))
1496            },
1497        )
1498        .map_err(|e| match e {
1499            MapError::Cancelled => EngineError::Cancelled,
1500            MapError::Item { error, .. } => error,
1501        })?;
1502    let mut traces = Vec::new();
1503    for (role, result) in results {
1504        let scale = result.trace.guard_scale;
1505        if let Some(cause) = guard_cause(&result.trace.guards) {
1506            out.notes.push(Note::Mimo(if scale > 0.0 {
1507                crate::notes::MimoNote::GuardReduced {
1508                    role: role.clone(),
1509                    gain_db: 20.0 * scale.max(1e-9).log10(),
1510                    cause,
1511                }
1512            } else {
1513                crate::notes::MimoNote::GuardDisabled {
1514                    role: role.clone(),
1515                    cause,
1516                }
1517            }));
1518        }
1519        out.supports.push((role.clone(), result.supports));
1520        out.level_db.push((role.clone(), result.level_db));
1521        traces.push((role, result.trace));
1522    }
1523    Ok((out, traces))
1524}