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