Skip to main content

qsm_core/inversion/
amp_pe.rs

1//! AMP-PE: Approximate Message Passing with built-in Parameter Estimation for QSM.
2//!
3//! Nonlinear dipole inversion solved with Generalized Approximate Message Passing
4//! (GAMP) over a linearized wrapped-phase (complex-exponential) forward model,
5//! using a Laplace sparse-wavelet prior and a Gaussian-mixture noise model that
6//! absorbs phase outliers.
7//!
8//! Ported from the reference MATLAB implementation
9//! (<https://github.com/EmoryCN2L/QSM_AMP_PE>, Huang et al., *Magn. Reson. Med.*
10//! 2023) as packaged for the QSM-CI `dipole` stage (`recon.m` +
11//! `amp_pe_mri_qsm_awgn.m` / `amp_pe_mri_qsm_awgn_mix.m`). This is the
12//! dipole-inversion stage only: the local (tissue) field is provided, turned into
13//! a simulated single-echo phase, and fed to the two-step AMP-PE solve
14//! (single-Gaussian warm-up → Gaussian-mixture final).
15//!
16//! # Design notes / fidelity
17//! * The forward dipole operator, wavelet transform (periodic db1/db2), and
18//!   `erfcx` all match the MATLAB reference (see `utils::wavelet`,
19//!   `utils::special`).
20//! * The reference estimates operator Frobenius norms with 2 random probes. Here
21//!   they are computed **exactly** (the wavelet transform is orthonormal, so its
22//!   norm is `sqrt(coef_len)`; the weighted-dipole norm has a closed form). This
23//!   is deterministic and a strict improvement; the verification harness injects
24//!   the same exact values into MATLAB so the two agree to numerical precision.
25//! * Input local field is **ppm-scale** (crate convention); `mut_cst` converts
26//!   ppm to radians at the simulated echo time.
27//! * The L2 (chiL2) seed is computed on the field **masked to the ROI** (as
28//!   recon.m does) — background outside the mask must not enter the seed.
29//! * Verified against the MATLAB reference to ~2e-10 on the real 164x205x205
30//!   phantom when both use a double-precision seed. The reference's
31//!   `dipole_kernel_angulated` casts the seed kernel to `single`; in
32//!   phase-wrapping regions (|phase|>pi) the nonlinear solve is multistable, so
33//!   that ~1e-7 seed perturbation can select a different local solution there.
34//!   This crate uses full double precision (a strict improvement).
35
36use crate::fft::Fft3dWorkspace;
37use crate::inversion::admm::prepare_fansi_spectral;
38use crate::utils::special::{erfc, erfcx};
39use crate::utils::wavelet::WaveletPlan;
40use crate::Grid;
41use num_complex::Complex64;
42#[cfg(feature = "parallel")]
43use rayon::prelude::*;
44
45const EPS: f64 = 2.220446049250313e-16;
46
47/// Chunk length for the deterministic parallel reductions in the GAMP parameter
48/// estimation. Fixed size ⇒ the result is independent of the thread count.
49const PARAM_CHUNK: usize = 1 << 16;
50
51/// Parameters for the AMP-PE inversion.
52#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
53#[derive(Clone, Debug)]
54pub struct AmpPeParams {
55    /// Daubechies wavelet order: 1 (db1, best for straight B0) or 2 (db2).
56    pub wave_order: usize,
57    /// Wavelet decomposition levels.
58    pub nlevel: usize,
59    /// Morphology-mask retention fraction (cumulative-energy threshold).
60    pub wave_pec: f64,
61    /// Simulated echo time (s) used to turn the field into phase.
62    pub simulated_te: f64,
63    /// Linearization iterations for each of the two stages.
64    pub max_linearization_ite: usize,
65    /// Field strength (Tesla).
66    pub b0: f64,
67    /// Gyromagnetic ratio (MHz/T); matches the reference's `42.58`.
68    pub gyro_ratio: f64,
69    /// Damping rate for the GAMP signal updates (`damp_rate`).
70    pub damp_rate_sig: f64,
71    /// Learning rate for parameter estimation (`kappa`).
72    pub damp_rate_par: f64,
73    /// Inner sparse-reconstruction iterations per GAMP call.
74    pub max_pe_spar_ite: usize,
75    /// Inner parameter-estimation iterations per GAMP call.
76    pub max_pe_est_ite: usize,
77    /// Convergence threshold for the GAMP inner loop.
78    pub cvg_thd: f64,
79    /// Tikhonov regularization weight for the L2 seed (`chiL2`).
80    pub tikhonov_beta: f64,
81}
82
83impl Default for AmpPeParams {
84    fn default() -> Self {
85        Self {
86            wave_order: 1,
87            nlevel: 3,
88            wave_pec: 0.85,
89            simulated_te: 8e-3,
90            max_linearization_ite: 25,
91            b0: 3.0,
92            gyro_ratio: 42.58,
93            damp_rate_sig: 0.01,
94            damp_rate_par: 0.1,
95            max_pe_spar_ite: 5,
96            max_pe_est_ite: 5,
97            cvg_thd: 1e-6,
98            tikhonov_beta: 2e-2,
99        }
100    }
101}
102
103/// Sample variance (MATLAB `var`, normalized by `n-1`).
104fn var(v: &[f64]) -> f64 {
105    let n = v.len();
106    if n < 2 {
107        return 0.0;
108    }
109    let mean = v.iter().sum::<f64>() / n as f64;
110    v.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0)
111}
112
113/// Sample variance of a complex vector (MATLAB `var` on complex data:
114/// `mean(|x - mean|^2) * n/(n-1)`).
115fn var_complex(v: &[Complex64]) -> f64 {
116    let n = v.len();
117    if n < 2 {
118        return 0.0;
119    }
120    let mean = v.iter().sum::<Complex64>() / n as f64;
121    v.iter().map(|&x| (x - mean).norm_sqr()).sum::<f64>() / (n as f64 - 1.0)
122}
123
124/// Dipole forward/adjoint operator over a padded grid, with mask gather/scatter.
125struct DipoleOp {
126    fft_ws: Fft3dWorkspace,
127    kernel: Vec<f64>,
128    n: usize,
129    mask_idx: Vec<usize>,
130    buf: Vec<Complex64>,
131    full: Vec<f64>,
132    /// Reused scatter buffer for the adjoint. Non-mask positions are only ever
133    /// written to zero (at construction) and never touched again, so the mask
134    /// scatter needs no per-call clear.
135    scatter: Vec<f64>,
136}
137
138impl DipoleOp {
139    fn new(grid: &Grid, bdir: (f64, f64, f64), mask_idx: Vec<usize>) -> Self {
140        let (fft_ws, kernel, _ee2) = prepare_fansi_spectral(grid, bdir);
141        let n = grid.n_total();
142        Self {
143            fft_ws,
144            kernel,
145            n,
146            mask_idx,
147            buf: vec![Complex64::new(0.0, 0.0); n],
148            full: vec![0.0; n],
149            scatter: vec![0.0; n],
150        }
151    }
152
153    /// `real(ifftn(D .* fftn(x)))` for a full-volume real input.
154    fn apply(&mut self, x: &[f64], out: &mut [f64]) {
155        for (b, &xi) in self.buf.iter_mut().zip(x) {
156            *b = Complex64::new(xi, 0.0);
157        }
158        self.fft_ws.fft3d(&mut self.buf);
159        for (b, &k) in self.buf.iter_mut().zip(&self.kernel) {
160            *b *= k;
161        }
162        self.fft_ws.ifft3d(&mut self.buf);
163        for (o, b) in out.iter_mut().zip(&self.buf) {
164            *o = b.re;
165        }
166    }
167
168    /// Masked dipole field: `A.times(x)` — gather the dipole of `x_full` at the mask.
169    fn field_masked(&mut self, x_full: &[f64]) -> Vec<f64> {
170        let mut full = std::mem::take(&mut self.full);
171        self.apply(x_full, &mut full);
172        let out = self.mask_idx.iter().map(|&j| full[j]).collect();
173        self.full = full;
174        out
175    }
176
177    /// Adjoint: scatter masked real vector to full volume, apply dipole (self-adjoint).
178    fn adjoint_full(&mut self, y_masked: &[f64]) -> Vec<f64> {
179        let mut scattered = std::mem::take(&mut self.scatter);
180        for (k, &j) in self.mask_idx.iter().enumerate() {
181            scattered[j] = y_masked[k];
182        }
183        let mut out = vec![0.0; self.n];
184        self.apply(&scattered, &mut out);
185        self.scatter = scattered;
186        out
187    }
188}
189
190/// AMP-PE nonlinear dipole inversion.
191///
192/// * `local_field` — local (tissue) field, ppm-scale, full volume (`nx*ny*nz`).
193/// * `mask` — binary ROI mask (non-zero = inside).
194/// * `magnitude` — optional data-fidelity weight (single combined volume, e.g.
195///   RSS over echoes). When `None`, uniform weights and no morphology mask.
196/// * `grid` — volume grid (dims + voxel size).
197/// * `bdir` — B0 direction.
198/// * `params` — see [`AmpPeParams`].
199/// * `progress` — callback `(stage_iter, total)` where total counts both stages.
200///
201/// Returns the susceptibility map (ppm-scale), masked to the ROI, on the input grid.
202pub fn amp_pe(
203    local_field: &[f64],
204    mask: &[u8],
205    magnitude: Option<&[f64]>,
206    grid: &Grid,
207    bdir: (f64, f64, f64),
208    params: &AmpPeParams,
209    mut progress: impl FnMut(usize, usize),
210) -> Vec<f64> {
211    let orig = (grid.nx(), grid.ny(), grid.nz());
212    let pad_mult = 1usize << params.nlevel;
213    let pad = (
214        orig.0.div_ceil(pad_mult) * pad_mult,
215        orig.1.div_ceil(pad_mult) * pad_mult,
216        orig.2.div_ceil(pad_mult) * pad_mult,
217    );
218    let pgrid = Grid::new(
219        pad.0,
220        pad.1,
221        pad.2,
222        grid.voxel_size.0,
223        grid.voxel_size.1,
224        grid.voxel_size.2,
225    );
226
227    // Pad field / mask / magnitude to the wavelet-friendly grid.
228    let field_p = pad_volume(local_field, orig, pad);
229    let mask_f: Vec<f64> = mask.iter().map(|&m| if m != 0 { 1.0 } else { 0.0 }).collect();
230    let mask_p = pad_volume(&mask_f, orig, pad);
231    let imag_p = match magnitude {
232        Some(mag) => pad_volume(&mag.iter().map(|&v| v.abs()).collect::<Vec<_>>(), orig, pad),
233        None => mask_p.clone(),
234    };
235    let have_mag = magnitude.is_some();
236
237    let n = pad.0 * pad.1 * pad.2;
238    let mask_idx: Vec<usize> = (0..n).filter(|&i| mask_p[i] > 0.5).collect();
239    let m = mask_idx.len();
240
241    let te = params.simulated_te;
242    let mut_cst = params.gyro_ratio * params.b0 * 2.0 * std::f64::consts::PI * te;
243
244    // Measurement: simulated single-echo tissue phase (radians) at the mask.
245    let phase_image: Vec<f64> = mask_idx.iter().map(|&j| field_p[j] * mut_cst).collect();
246
247    // Data-fidelity weights, normalized to unit mean within the mask.
248    let weight_vect: Vec<f64> = if have_mag {
249        let wv: Vec<f64> = mask_idx.iter().map(|&j| imag_p[j]).collect();
250        let mean = (wv.iter().sum::<f64>() / m as f64).max(EPS);
251        wv.iter().map(|&v| v / mean).collect()
252    } else {
253        vec![1.0; m]
254    };
255
256    let mut dip = DipoleOp::new(&pgrid, bdir, mask_idx.clone());
257    // Kernel energy for the exact A_qsm Frobenius norm: ||g||^2 = mean(D^2).
258    let kernel_energy = dip.kernel.iter().map(|&d| d * d).sum::<f64>() / n as f64;
259
260    // Wavelet plan (orthonormal; coef_len == n).
261    let plan = WaveletPlan::new(params.wave_order, pad, params.nlevel);
262    debug_assert_eq!(plan.coef_len(), n);
263
264    // --- L2 (Tikhonov) seed for distribution initialization only ---
265    // Seed on the field masked to the ROI (recon.m builds `phs_tissue` from the
266    // masked field): background outside the mask must not enter the seed.
267    let field_masked: Vec<f64> = (0..n).map(|i| if mask_p[i] > 0.5 { field_p[i] } else { 0.0 }).collect();
268    let chi_l2 = chi_l2_seed(&field_masked, &mask_p, &dip.kernel, params.tikhonov_beta, pad);
269    let x_init_par: Vec<f64> = (0..n).map(|i| if mask_p[i] > 0.5 { chi_l2[i] } else { 0.0 }).collect();
270    let x_init_par_psi = plan.forward(&x_init_par);
271
272    // Wavelet morphology mask: pass the largest magnitude wavelet coefficients through.
273    let wave_mask = build_wave_mask(&plan, &imag_p, &mask_p, have_mag, params.wave_pec, n);
274
275    // --- GAMP shared state ---
276    let mut st = GampState {
277        x_hat_meas: vec![0.0; n],
278        tau_x_meas: var(&x_init_par),
279        s_hat_meas: vec![Complex64::new(0.0, 0.0); m],
280        x_hat_psi: vec![0.0; n],
281        p_hat_psi: vec![0.0; n],
282        tau_x_hat_psi: var(&x_init_par_psi),
283        tau_p_psi: var(&x_init_par_psi), // A_wav.multSq(tau_x_hat_psi) = identity
284        damp: params.damp_rate_sig,
285        damp_ceiling: params.damp_rate_sig,
286    };
287    let abs_psi: Vec<f64> = x_init_par_psi.iter().map(|v| v.abs()).collect();
288    let mut lambda = 1.0 / (var(&abs_psi) / 2.0).sqrt();
289
290    let cfg = GampCfg {
291        m,
292        n,
293        mut_cst,
294        kernel_energy,
295        max_pe_spar_ite: params.max_pe_spar_ite,
296        max_pe_est_ite: params.max_pe_est_ite,
297        cvg_thd: params.cvg_thd,
298        kappa: params.damp_rate_par,
299        // NOTE: `damp_rate_sig` is not carried here — the damping is adaptive and lives in
300        // GampState (`damp` / `damp_ceiling`), seeded from this same parameter.
301    };
302
303    let total = 2 * params.max_linearization_ite;
304    let mut x_curr = vec![0.0; n];
305    let mut tau_w_1 = 1e-12;
306
307    // ===== Step 1: single-Gaussian noise (warm-up) =====
308    for it in 0..params.max_linearization_ite {
309        progress(it + 1, total);
310        let (a_x, der_1st, y_upd) = linearize(&mut dip, &x_curr, &phase_image, &weight_vect, mut_cst);
311        tau_w_1 = gamp_awgn(&mut dip, &plan, &cfg, &mut st, &mut lambda, tau_w_1, &der_1st, &weight_vect, &y_upd, &wave_mask);
312        x_curr = st.x_hat_meas.clone();
313        let _ = a_x;
314    }
315
316    // ===== estimate outlier mixture from the step-1 residual =====
317    let a_x_init = dip.field_masked(&x_curr);
318    let resid: Vec<Complex64> = (0..m)
319        .map(|j| {
320            let axm = a_x_init[j] * mut_cst;
321            weight_vect[j] * (Complex64::new(0.0, axm).exp() - Complex64::new(0.0, phase_image[j]).exp())
322        })
323        .collect();
324    let resid_abs: Vec<f64> = resid.iter().map(|c| c.norm()).collect();
325    let resid_std = (resid_abs.iter().map(|v| v * v).sum::<f64>() / m as f64).sqrt();
326    let outliers: Vec<Complex64> = resid
327        .iter()
328        .zip(&resid_abs)
329        .filter(|(_, &a)| a > 3.0 * resid_std)
330        .map(|(&c, _)| c)
331        .collect();
332    let gamma_est = outliers.len() as f64 / m as f64;
333    let psi_est = var_complex(&outliers);
334
335    // ===== Step 2: Gaussian-mixture noise (final) =====
336    let mut mix = MixState {
337        theta: 0.0,
338        phi: tau_w_1,
339        omega: 1.0,
340        gamma: gamma_est,
341        psi: psi_est,
342    };
343    for it in 0..params.max_linearization_ite {
344        progress(params.max_linearization_ite + it + 1, total);
345        let (a_x, der_1st, y_upd) = linearize(&mut dip, &x_curr, &phase_image, &weight_vect, mut_cst);
346        gamp_awgn_mix(&mut dip, &plan, &cfg, &mut st, &mut mix, &mut lambda, &der_1st, &weight_vect, &y_upd, &wave_mask);
347        x_curr = st.x_hat_meas.clone();
348        let _ = a_x;
349    }
350
351    // Mask and crop back to the input grid.
352    for i in 0..n {
353        if mask_p[i] <= 0.5 {
354            x_curr[i] = 0.0;
355        }
356    }
357    crop_volume(&x_curr, pad, orig)
358}
359
360/// Persistent GAMP state carried across linearization iterations.
361struct GampState {
362    x_hat_meas: Vec<f64>,       // susceptibility image (length n)
363    tau_x_meas: f64,
364    s_hat_meas: Vec<Complex64>, // length m
365    x_hat_psi: Vec<f64>,        // wavelet coefficients (length n)
366    p_hat_psi: Vec<f64>,        // image (length n)
367    tau_x_hat_psi: f64,
368    tau_p_psi: f64,
369    /// CURRENT damping factor for the signal updates. Starts at the configured
370    /// `damp_rate_sig` and is only ever reduced when a sweep is found to have made the
371    /// data fit worse (see the adaptive-damping note on `gamp_awgn`), then relaxed back
372    /// towards the configured value. On data where nothing diverges it never leaves the
373    /// configured value, so the iteration is bit-for-bit what it was before.
374    damp: f64,
375    /// Ceiling that `damp` may relax back up to. RATCHETS DOWN on every rejected sweep: once a
376    /// run has shown it diverges at a given damping, letting the damping climb back to that value
377    /// simply re-diverges. Without this the guard catches each blow-up but the iteration keeps
378    /// re-entering the divergent regime and the result is still unusable.
379    damp_ceiling: f64,
380}
381
382/// A restore point for backing out of a divergent GAMP sweep.
383struct GampSnapshot {
384    x_hat_meas: Vec<f64>,
385    tau_x_meas: f64,
386    s_hat_meas: Vec<Complex64>,
387    x_hat_psi: Vec<f64>,
388    p_hat_psi: Vec<f64>,
389    tau_x_hat_psi: f64,
390    tau_p_psi: f64,
391    lambda: f64,
392    mix: Option<MixState>,
393}
394
395impl GampState {
396    fn snapshot(&self, lambda: f64, mix: Option<&MixState>) -> GampSnapshot {
397        GampSnapshot {
398            x_hat_meas: self.x_hat_meas.clone(),
399            tau_x_meas: self.tau_x_meas,
400            s_hat_meas: self.s_hat_meas.clone(),
401            x_hat_psi: self.x_hat_psi.clone(),
402            p_hat_psi: self.p_hat_psi.clone(),
403            tau_x_hat_psi: self.tau_x_hat_psi,
404            tau_p_psi: self.tau_p_psi,
405            lambda,
406            mix: mix.map(|m| MixState { ..*m }),
407        }
408    }
409
410    fn restore(&mut self, snap: &GampSnapshot, lambda: &mut f64, mix: Option<&mut MixState>) {
411        self.x_hat_meas.copy_from_slice(&snap.x_hat_meas);
412        self.tau_x_meas = snap.tau_x_meas;
413        self.s_hat_meas.copy_from_slice(&snap.s_hat_meas);
414        self.x_hat_psi.copy_from_slice(&snap.x_hat_psi);
415        self.p_hat_psi.copy_from_slice(&snap.p_hat_psi);
416        self.tau_x_hat_psi = snap.tau_x_hat_psi;
417        self.tau_p_psi = snap.tau_p_psi;
418        *lambda = snap.lambda;
419        if let (Some(dst), Some(src)) = (mix, snap.mix.as_ref()) {
420            *dst = MixState { ..*src };
421        }
422    }
423}
424
425// --- Adaptive damping ---------------------------------------------------------------------
426// GAMP is only guaranteed to converge for measurement operators close to i.i.d. Gaussian. The
427// dipole operator, weighted by the magnitude and composed with the wavelet morphology prior, is
428// not, and on some in-vivo data the iteration diverges outright: the estimate grows without
429// bound until χ reaches ~1e34. The published remedy is adaptive damping (Vila, Schniter, Rangan
430// et al., "Adaptive damping and mean removal for the generalized approximate message passing
431// algorithm", ICASSP 2015): watch a cost, and when a step makes it worse, undo the step and take
432// a smaller one.
433//
434// The cost is the plain data fit ||A x_hat - y||^2/m, which `a_qsm_mult` already computes each
435// sweep. NOTE it must NOT be the `mse` used for noise-variance estimation: that one includes the
436// Onsager correction term and is an estimate of the noise level, not a descent quantity, so it
437// fluctuates on perfectly healthy runs.
438//
439// The trigger is deliberately blunt — an order of magnitude worse than the best fit seen so far,
440// or non-finite. Divergence here spans many orders of magnitude, so a loose threshold still
441// catches it, while normal GAMP non-monotonicity never comes close. That keeps this a strict
442// no-op on data that does not diverge, which is what preserves agreement with the reference
443// implementation.
444/// Cost blow-up factor (relative to the best fit so far) treated as divergence.
445const DAMP_DIVERGE_FACTOR: f64 = 2.0;
446/// Factor applied to the damping when a sweep is rejected.
447const DAMP_SHRINK: f64 = 0.5;
448/// Factor by which damping relaxes back towards the configured value after an accepted sweep.
449const DAMP_GROW: f64 = 1.1;
450/// Damping floor; below this the iteration is making no progress and we stop with the last good state.
451const DAMP_MIN: f64 = 1e-4;
452/// Cap on rejected sweeps per GAMP call, so a pathological case cannot spin.
453const MAX_BACKTRACKS: usize = 20;
454
455#[cfg(test)]
456thread_local! {
457    /// Sweeps rejected by the divergence guard, counted per thread (each test gets its own).
458    /// The guard runs in the sequential part of the GAMP loop, so this never races with rayon.
459    static GUARD_FIRINGS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
460}
461
462/// Record that the divergence guard rejected a sweep. Compiled away outside tests.
463#[inline]
464fn note_guard_firing() {
465    #[cfg(test)]
466    GUARD_FIRINGS.with(|c| c.set(c.get() + 1));
467}
468
469/// Static GAMP configuration.
470struct GampCfg {
471    m: usize,
472    n: usize,
473    mut_cst: f64,
474    kernel_energy: f64,
475    max_pe_spar_ite: usize,
476    max_pe_est_ite: usize,
477    cvg_thd: f64,
478    kappa: f64,
479}
480
481/// Gaussian-mixture noise parameters (single component + outlier component).
482struct MixState {
483    theta: f64,
484    phi: f64,
485    omega: f64,
486    gamma: f64,
487    psi: f64,
488}
489
490/// Build the linearized measurement for the current estimate.
491///
492/// Returns `(A*x (masked, radians), der_1st = i*exp(i*Ax), y_upd)`.
493fn linearize(
494    dip: &mut DipoleOp,
495    x_curr: &[f64],
496    phase_image: &[f64],
497    weight_vect: &[f64],
498    mut_cst: f64,
499) -> (Vec<f64>, Vec<Complex64>, Vec<Complex64>) {
500    let field = dip.field_masked(x_curr);
501    let m = field.len();
502    let a_x: Vec<f64> = (0..m).map(|j| field[j] * mut_cst).collect();
503    let der_1st: Vec<Complex64> = a_x.iter().map(|&v| Complex64::new(0.0, 1.0) * Complex64::new(0.0, v).exp()).collect();
504    let y_upd: Vec<Complex64> = (0..m)
505        .map(|j| {
506            let e_phase = Complex64::new(0.0, phase_image[j]).exp();
507            let e_ax = Complex64::new(0.0, a_x[j]).exp();
508            weight_vect[j] * (der_1st[j] * a_x[j] + e_phase - e_ax)
509        })
510        .collect();
511    (a_x, der_1st, y_upd)
512}
513
514/// Frobenius norm squared of the weighted-dipole operator `A_qsm`.
515///
516/// `||A_qsm||_F^2 = mut_cst^2 * ||g||^2 * sum_j |der_j * w_j|^2`, where
517/// `||g||^2 = mean(D^2)` (kernel_energy).
518fn frob_qsm_sq(cfg: &GampCfg, der_1st: &[Complex64], weight_vect: &[f64]) -> f64 {
519    let sw: f64 = der_1st
520        .iter()
521        .zip(weight_vect)
522        .map(|(&d, &w)| (d * w).norm_sqr())
523        .sum();
524    cfg.mut_cst * cfg.mut_cst * cfg.kernel_energy * sw
525}
526
527/// A_qsm forward: image -> masked complex measurement.
528fn a_qsm_mult(dip: &mut DipoleOp, x_img: &[f64], der_1st: &[Complex64], weight_vect: &[f64], mut_cst: f64) -> Vec<Complex64> {
529    let field = dip.field_masked(x_img);
530    maybe_par_iter!(field)
531        .zip(maybe_par_iter!(der_1st))
532        .zip(maybe_par_iter!(weight_vect))
533        .map(|((&fj, &dj), &wj)| dj * wj * (fj * mut_cst))
534        .collect()
535}
536
537/// real(A_qsm^H * s): masked complex -> full-volume real.
538fn a_qsm_multtr_real(dip: &mut DipoleOp, s: &[Complex64], der_1st: &[Complex64], weight_vect: &[f64], mut_cst: f64) -> Vec<f64> {
539    // x_tmp = s .* conj(der*w); take real part; adjoint dipole; scale by mut_cst.
540    let re: Vec<f64> = maybe_par_iter!(s)
541        .zip(maybe_par_iter!(der_1st))
542        .zip(maybe_par_iter!(weight_vect))
543        .map(|((&sj, &dj), &wj)| (sj * (dj * wj).conj()).re)
544        .collect();
545    let mut adj = dip.adjoint_full(&re);
546    maybe_par_iter_mut!(adj).for_each(|v| *v *= mut_cst);
547    adj
548}
549
550/// One GAMP call with single-Gaussian (AWGN) noise. Returns updated `tau_w_1`.
551#[allow(clippy::too_many_arguments)]
552fn gamp_awgn(
553    dip: &mut DipoleOp,
554    plan: &WaveletPlan,
555    cfg: &GampCfg,
556    st: &mut GampState,
557    lambda: &mut f64,
558    tau_w_1_in: f64,
559    der_1st: &[Complex64],
560    weight_vect: &[f64],
561    y: &[Complex64],
562    wave_mask: &[bool],
563) -> f64 {
564    let frob_sq = frob_qsm_sq(cfg, der_1st, weight_vect);
565    let mut tau_w_1 = tau_w_1_in;
566    let (m, n) = (cfg.m, cfg.n);
567
568    let mut best_cost = f64::INFINITY;
569    let mut good: Option<GampSnapshot> = None;
570    let mut good_tau_w = tau_w_1;
571    let mut accepted = 0usize;
572    let mut backtracks = 0usize;
573
574    while accepted < cfg.max_pe_spar_ite && backtracks <= MAX_BACKTRACKS {
575        // tau_p_meas_1 = frob^2/M * tau_x_meas
576        let tau_p_meas_1 = frob_sq / m as f64 * st.tau_x_meas;
577        // p_hat_meas_1 = A_qsm*x_hat_meas - tau_p*s
578        let axm = a_qsm_mult(dip, &st.x_hat_meas, der_1st, weight_vect, cfg.mut_cst);
579        let p_hat_meas_1: Vec<Complex64> = (0..m).map(|j| axm[j] - tau_p_meas_1 * st.s_hat_meas[j]).collect();
580
581        // parameter estimation for tau_w_1. `mse` is invariant across the inner
582        // sweep (p_hat_meas_1 and y are fixed), so compute it once.
583        let mse: f64 = maybe_par_chunks!(p_hat_meas_1.as_slice(), PARAM_CHUNK)
584            .zip(maybe_par_chunks!(y, PARAM_CHUNK))
585            .map(|(pc, yc)| pc.iter().zip(yc).map(|(p, yy)| (p - yy).norm_sqr()).sum::<f64>())
586            .collect::<Vec<f64>>()
587            .iter()
588            .sum::<f64>()
589            / m as f64;
590
591        // Data fit of the state the PREVIOUS sweep produced. If it has blown up, that sweep
592        // diverged: undo it and take smaller steps from here.
593        let fit = data_fit(&axm, y, m);
594        if !fit.is_finite() || fit > best_cost * DAMP_DIVERGE_FACTOR {
595            note_guard_firing();
596            backtracks += 1;
597            if let Some(g) = good.as_ref() {
598                st.restore(g, lambda, None);
599                tau_w_1 = good_tau_w;
600            }
601            st.damp = (st.damp * DAMP_SHRINK).max(DAMP_MIN);
602            st.damp_ceiling = st.damp;
603            if st.damp <= DAMP_MIN {
604                break; // cannot damp further; keep the last good state
605            }
606            continue;
607        }
608        if fit < best_cost {
609            best_cost = fit;
610            good = Some(st.snapshot(*lambda, None));
611            good_tau_w = tau_w_1;
612        }
613        st.damp = (st.damp * DAMP_GROW).min(st.damp_ceiling);
614
615        let tau_w_new = mse + tau_p_meas_1;
616        for _ in 0..cfg.max_pe_est_ite {
617            tau_w_1 += cfg.kappa * (tau_w_new - tau_w_1);
618        }
619
620        let tau_s_meas_1 = 1.0 / (tau_w_1 + tau_p_meas_1);
621        for j in 0..m {
622            st.s_hat_meas[j] = (y[j] - p_hat_meas_1[j]) * tau_s_meas_1;
623        }
624
625        // tau_r_meas_1 = 1 / (frob^2/N * tau_s_meas_1)
626        let tau_r_meas_1 = 1.0 / (frob_sq / n as f64 * tau_s_meas_1);
627        let mtr = a_qsm_multtr_real(dip, &st.s_hat_meas, der_1st, weight_vect, cfg.mut_cst);
628        let r_hat_meas_1: Vec<f64> = maybe_par_iter!(st.x_hat_meas)
629            .zip(maybe_par_iter!(mtr))
630            .map(|(&x, &mv)| x + tau_r_meas_1 * mv)
631            .collect();
632
633        let cvg = wavelet_block(plan, cfg, st, lambda, tau_r_meas_1, &r_hat_meas_1, wave_mask);
634        accepted += 1;
635        if cvg < cfg.cvg_thd {
636            break;
637        }
638    }
639    tau_w_1
640}
641
642/// Plain data fit `||A x - y||^2 / m`, the quantity the divergence guard watches.
643fn data_fit(axm: &[Complex64], y: &[Complex64], m: usize) -> f64 {
644    maybe_par_chunks!(axm, PARAM_CHUNK)
645        .zip(maybe_par_chunks!(y, PARAM_CHUNK))
646        .map(|(ac, yc)| ac.iter().zip(yc).map(|(a, yy)| (a - yy).norm_sqr()).sum::<f64>())
647        .collect::<Vec<f64>>()
648        .iter()
649        .sum::<f64>()
650        / m as f64
651}
652
653/// One GAMP call with Gaussian-mixture noise (final stage).
654#[allow(clippy::too_many_arguments)]
655fn gamp_awgn_mix(
656    dip: &mut DipoleOp,
657    plan: &WaveletPlan,
658    cfg: &GampCfg,
659    st: &mut GampState,
660    mix: &mut MixState,
661    lambda: &mut f64,
662    der_1st: &[Complex64],
663    weight_vect: &[f64],
664    y: &[Complex64],
665    wave_mask: &[bool],
666) {
667    let frob_sq = frob_qsm_sq(cfg, der_1st, weight_vect);
668    let (m, n) = (cfg.m, cfg.n);
669
670    let mut best_cost = f64::INFINITY;
671    let mut good: Option<GampSnapshot> = None;
672    let mut accepted = 0usize;
673    let mut backtracks = 0usize;
674
675    while accepted < cfg.max_pe_spar_ite && backtracks <= MAX_BACKTRACKS {
676        let tau_p_meas_1 = frob_sq / m as f64 * st.tau_x_meas;
677        let axm = a_qsm_mult(dip, &st.x_hat_meas, der_1st, weight_vect, cfg.mut_cst);
678        let p_hat_meas_1: Vec<Complex64> = (0..m).map(|j| axm[j] - tau_p_meas_1 * st.s_hat_meas[j]).collect();
679        let r_noise: Vec<Complex64> = (0..m).map(|j| y[j] - p_hat_meas_1[j]).collect();
680
681        // Same divergence guard as the AWGN stage.
682        let fit = data_fit(&axm, y, m);
683        if !fit.is_finite() || fit > best_cost * DAMP_DIVERGE_FACTOR {
684            note_guard_firing();
685            backtracks += 1;
686            if let Some(g) = good.as_ref() {
687                st.restore(g, lambda, Some(mix));
688            }
689            st.damp = (st.damp * DAMP_SHRINK).max(DAMP_MIN);
690            st.damp_ceiling = st.damp;
691            if st.damp <= DAMP_MIN {
692                break;
693            }
694            continue;
695        }
696        if fit < best_cost {
697            best_cost = fit;
698            good = Some(st.snapshot(*lambda, Some(mix)));
699        }
700        st.damp = (st.damp * DAMP_GROW).min(st.damp_ceiling);
701
702        for _ in 0..cfg.max_pe_est_ite {
703            mix_output_parameter_est(&r_noise, tau_p_meas_1, mix, cfg.kappa);
704        }
705        let (noise_update, tau_z) = mix_output_function(&r_noise, tau_p_meas_1, mix);
706        // z_hat = y - noise_update
707        let tau_s_meas_1 = 1.0 / tau_p_meas_1 * (1.0 - tau_z / tau_p_meas_1);
708        for j in 0..m {
709            let z_hat = y[j] - noise_update[j];
710            st.s_hat_meas[j] = 1.0 / tau_p_meas_1 * (z_hat - p_hat_meas_1[j]);
711        }
712
713        let tau_r_meas_1 = 1.0 / (frob_sq / n as f64 * tau_s_meas_1);
714        let mtr = a_qsm_multtr_real(dip, &st.s_hat_meas, der_1st, weight_vect, cfg.mut_cst);
715        let r_hat_meas_1: Vec<f64> = maybe_par_iter!(st.x_hat_meas)
716            .zip(maybe_par_iter!(mtr))
717            .map(|(&x, &mv)| x + tau_r_meas_1 * mv)
718            .collect();
719
720        let cvg = wavelet_block(plan, cfg, st, lambda, tau_r_meas_1, &r_hat_meas_1, wave_mask);
721        accepted += 1;
722        if cvg < cfg.cvg_thd {
723            break;
724        }
725    }
726}
727
728/// Shared wavelet-domain GAMP block (identical between AWGN and mixture stages).
729///
730/// Returns the relative change in `x_hat_meas` (`||Δx|| / ||x||`) used for the
731/// GAMP inner-loop convergence test.
732fn wavelet_block(
733    plan: &WaveletPlan,
734    cfg: &GampCfg,
735    st: &mut GampState,
736    lambda: &mut f64,
737    tau_r_meas_1: f64,
738    r_hat_meas_1: &[f64],
739    wave_mask: &[bool],
740) -> f64 {
741    // Current adaptive damping (see the adaptive-damping note above); equals the configured
742    // `damp_rate_sig` unless a divergent sweep has forced it down.
743    let damp = st.damp;
744    let tau_s_psi = 1.0 / (tau_r_meas_1 + st.tau_p_psi);
745    let s_hat_psi: Vec<f64> = maybe_par_iter!(r_hat_meas_1)
746        .zip(maybe_par_iter!(st.p_hat_psi))
747        .map(|(&r, &p)| (r - p) * tau_s_psi)
748        .collect();
749
750    // A_wav.multSqTr is identity -> tau_r_psi = 1/tau_s_psi
751    let tau_r_psi = 1.0 / tau_s_psi;
752    let analysis = plan.forward(&s_hat_psi); // A_wav.multTr
753    let r_hat_psi: Vec<f64> = maybe_par_iter!(st.x_hat_psi)
754        .zip(maybe_par_iter!(analysis))
755        .map(|(&x, &a)| x + tau_r_psi * a)
756        .collect();
757
758    let abs_r: Vec<f64> = maybe_par_iter!(r_hat_psi).map(|v| v.abs()).collect();
759    for _ in 0..cfg.max_pe_est_ite {
760        *lambda = input_parameter_est(&abs_r, tau_r_psi, *lambda, cfg.kappa);
761    }
762
763    let (x_hat_psi_new, tau_x_hat_psi) = input_function(&r_hat_psi, tau_r_psi, *lambda, wave_mask);
764    st.x_hat_psi = x_hat_psi_new;
765    st.tau_x_hat_psi = tau_x_hat_psi;
766
767    st.tau_p_psi = tau_x_hat_psi; // A_wav.multSq identity
768    let synth = plan.inverse(&st.x_hat_psi); // A_wav.mult
769    let tau_p_psi = st.tau_p_psi;
770    maybe_par_iter_mut!(st.p_hat_psi)
771        .zip(maybe_par_iter!(synth))
772        .zip(maybe_par_iter!(s_hat_psi))
773        .for_each(|((p, &sy), &sh)| *p = sy - tau_p_psi * sh);
774
775    let tau_x_meas_pre = st.tau_x_meas;
776    let tau_new = (st.tau_p_psi * tau_r_meas_1) / (st.tau_p_psi + tau_r_meas_1);
777    st.tau_x_meas = tau_x_meas_pre + damp * (tau_new - tau_x_meas_pre);
778
779    let denom = st.tau_p_psi + tau_r_meas_1;
780    let mut change_sq = 0.0;
781    let mut norm_sq = 0.0;
782    for ((xh, &r), &p) in st.x_hat_meas.iter_mut().zip(r_hat_meas_1).zip(&st.p_hat_psi) {
783        let x_new = (st.tau_p_psi * r + tau_r_meas_1 * p) / denom;
784        let updated = *xh + damp * (x_new - *xh);
785        let d = updated - *xh;
786        change_sq += d * d;
787        norm_sq += updated * updated;
788        *xh = updated;
789    }
790    change_sq.sqrt() / norm_sq.sqrt().max(EPS)
791}
792
793/// Laplace input function: soft-threshold with the morphology mask passing large
794/// coefficients through unshrunk. Returns `(x_hat_psi, tau_x)`.
795fn input_function(r_hat: &[f64], tau_r: f64, lambda: f64, wave_mask: &[bool]) -> (Vec<f64>, f64) {
796    let thresh = lambda * tau_r;
797    let mut x0 = vec![0.0; r_hat.len()];
798    let mut nnz = 0usize;
799    for i in 0..r_hat.len() {
800        let v = if wave_mask[i] {
801            r_hat[i]
802        } else {
803            let a = r_hat[i].abs() - thresh;
804            if a > 0.0 {
805                a * r_hat[i].signum()
806            } else {
807                0.0
808            }
809        };
810        if v != 0.0 {
811            nnz += 1;
812        }
813        x0[i] = v;
814    }
815    let tau_x = tau_r * nnz as f64 / r_hat.len() as f64;
816    (x0, tau_x)
817}
818
819/// EM update of the Laplace scale parameter `lambda` (single cluster).
820fn input_parameter_est(r_hat: &[f64], tau_r: f64, lambda: f64, kappa: f64) -> f64 {
821    let s = (0.5 / tau_r).sqrt();
822    // Deterministic parallel reduction: sequential sum within each fixed-size
823    // chunk, chunks combined in index order (thread-count independent).
824    let partials: Vec<(f64, f64)> = maybe_par_chunks!(r_hat, PARAM_CHUNK)
825        .map(|chunk| {
826            let mut s1 = 0.0;
827            let mut s2 = 0.0;
828            for &r in chunk {
829                let arg = tau_r * lambda - r;
830                // block0 = lambda/2 * exp(0) * erfc(s*arg)   (block - block_min == 0)
831                let b0 = lambda / 2.0 * erfc(s * arg);
832                let der = (2.0 * tau_r / std::f64::consts::PI).sqrt() / erfcx(s * arg) + r - tau_r * lambda;
833                let fst = 1.0 / lambda - der;
834                let scd = -1.0 / (lambda * lambda) + (tau_r + (r - tau_r * lambda) * der) - der * der;
835                let w = b0 / (b0 + EPS);
836                s1 += w * fst;
837                s2 += w * scd;
838            }
839            (s1, s2)
840        })
841        .collect();
842    let (sum1, sum2) = partials.iter().fold((0.0, 0.0), |a, p| (a.0 + p.0, a.1 + p.1));
843    let lambda_new = if sum2 < 0.0 {
844        lambda - sum1 / sum2
845    } else if sum1 > 0.0 {
846        lambda * 1.1
847    } else {
848        lambda * 0.9
849    };
850    let lambda_new = lambda_new.max(1e-12);
851    lambda + kappa * (lambda_new - lambda)
852}
853
854/// Gaussian-mixture output function (single Gaussian + zero-mean outlier Gaussian).
855/// Returns `(x_hat, tau_x)` where `x_hat` is the posterior-mean noise estimate.
856fn mix_output_function(r_hat: &[Complex64], tau_r: f64, mix: &MixState) -> (Vec<Complex64>, f64) {
857    let (omega, theta, phi, gamma, psi) = (mix.omega, mix.theta, mix.phi, mix.gamma, mix.psi);
858    let mut x_hat = vec![Complex64::new(0.0, 0.0); r_hat.len()];
859    // Per-chunk: write the x_hat slice and accumulate a partial tau sum.
860    let partials: Vec<f64> = maybe_par_chunks!(r_hat, PARAM_CHUNK)
861        .zip(maybe_par_chunks_mut!(x_hat, PARAM_CHUNK))
862        .map(|(rc, xc)| {
863            let mut tau_sum = 0.0;
864            for (&r, xh_out) in rc.iter().zip(xc.iter_mut()) {
865                // component 1
866                let diff = theta - r.norm(); // |theta - r_hat| with theta real; matches abs for theta=0
867                let e1 = (-(diff / (phi + tau_r).sqrt()).powi(2)).exp();
868                let block1 = (1.0 - gamma) * omega * (tau_r / (phi + tau_r)) * e1;
869                let mean1 = (theta * tau_r + r * phi) / (phi + tau_r);
870                let block_nmr1 = block1 * mean1;
871                // outlier component (zero mean)
872                let e2 = (-(r.norm() / (psi + tau_r).sqrt()).powi(2)).exp();
873                let block2 = gamma * (tau_r / (psi + tau_r)) * e2;
874                let mean2 = r * psi / (psi + tau_r);
875                let block_nmr2 = block2 * mean2;
876
877                let nmr = block_nmr1 + block_nmr2;
878                let dnm = block1 + block2;
879                let xh = if dnm == 0.0 { r } else { nmr / dnm };
880                *xh_out = xh;
881
882                let nmr_sq1 = block1 * (phi * tau_r / (phi + tau_r) + mean1.norm_sqr());
883                let nmr_sq2 = block2 * (psi * tau_r / (psi + tau_r) + mean2.norm_sqr());
884                let nmr_sq = nmr_sq1 + nmr_sq2;
885                let tau_seq = if dnm == 0.0 { 0.0 } else { nmr_sq / dnm - xh.norm_sqr() };
886                tau_sum += tau_seq;
887            }
888            tau_sum
889        })
890        .collect();
891    let tau_sum: f64 = partials.iter().sum();
892    let tau_x = (tau_sum / r_hat.len() as f64).max(1e-12);
893    (x_hat, tau_x)
894}
895
896/// EM update of the mixture parameters `omega`, `phi`, `psi` (theta, gamma fixed).
897fn mix_output_parameter_est(r_hat: &[Complex64], tau_r: f64, mix: &mut MixState, kappa: f64) {
898    let (omega, theta, phi, gamma, psi) = (mix.omega, mix.theta, mix.phi, mix.gamma, mix.psi);
899    // sums: (block_1, block_2, block_1*|r-theta|^2, block_2*|r|^2)
900    let partials: Vec<(f64, f64, f64, f64)> = maybe_par_chunks!(r_hat, PARAM_CHUNK)
901        .map(|chunk| {
902            let (mut sum_b1, mut sum_b2, mut sum_phi_num, mut sum_psi_num) = (0.0, 0.0, 0.0, 0.0);
903            for &r in chunk {
904                let d1 = r.norm() - theta; // |r - theta| for real theta and using magnitude; theta=0
905                let t1 = (1.0 - gamma) * omega / (tau_r + phi) * (-(d1 / (tau_r + phi).sqrt()).powi(2)).exp();
906                let t2 = gamma / (tau_r + psi) * (-(r.norm() / (tau_r + psi).sqrt()).powi(2)).exp();
907                let sum = t1 + t2;
908                let (b1, b2) = if sum == 0.0 { (0.0, 1.0) } else { (t1 / sum, t2 / sum) };
909                sum_b1 += b1;
910                sum_b2 += b2;
911                sum_phi_num += b1 * d1 * d1;
912                sum_psi_num += b2 * r.norm_sqr();
913            }
914            (sum_b1, sum_b2, sum_phi_num, sum_psi_num)
915        })
916        .collect();
917    let (sum_b1, sum_b2, sum_phi_num, sum_psi_num) = partials
918        .iter()
919        .fold((0.0, 0.0, 0.0, 0.0), |a, p| (a.0 + p.0, a.1 + p.1, a.2 + p.2, a.3 + p.3));
920    // omega: single cluster normalizes to 1.
921    let omega_new = 1.0;
922    mix.omega = omega + kappa * (omega_new - omega);
923    mix.theta = 0.0;
924    // phi
925    let mut phi_new = if sum_b1 != 0.0 { sum_phi_num / sum_b1 - tau_r } else { phi };
926    if !phi_new.is_finite() || phi_new < 0.0 {
927        phi_new = phi;
928    }
929    mix.phi = phi + kappa * (phi_new - phi);
930    // psi
931    let mut psi_new = if sum_b2 != 0.0 { sum_psi_num / sum_b2 - tau_r } else { psi };
932    if psi_new < 0.0 {
933        psi_new = psi;
934    }
935    mix.psi = psi + kappa * (psi_new - psi);
936}
937
938/// Direct L2 (Tikhonov, gradient) QSM solver used only to seed distributions.
939/// Matches `chiL2.m`: `real(ifftn(conj(K) fftn(phase) / (|K|^2 + beta*E2 + eps))) * mask`.
940fn chi_l2_seed(phase: &[f64], mask: &[f64], kernel: &[f64], beta: f64, dims: (usize, usize, usize)) -> Vec<f64> {
941    let (nx, ny, nz) = dims;
942    let n = nx * ny * nz;
943    let tau = std::f64::consts::TAU;
944    // E2(k) = |1-exp(2πi kx/N)|^2 + ... (real, DC=0)
945    let mut e2 = vec![0.0f64; n];
946    for k in 0..nz {
947        for j in 0..ny {
948            for i in 0..nx {
949                let idx = i + j * nx + k * nx * ny;
950                let ex = Complex64::new(1.0, 0.0) - Complex64::new(0.0, tau * i as f64 / nx as f64).exp();
951                let ey = Complex64::new(1.0, 0.0) - Complex64::new(0.0, tau * j as f64 / ny as f64).exp();
952                let ez = Complex64::new(1.0, 0.0) - Complex64::new(0.0, tau * k as f64 / nz as f64).exp();
953                e2[idx] = ex.norm_sqr() + ey.norm_sqr() + ez.norm_sqr();
954            }
955        }
956    }
957    let mut ws = Fft3dWorkspace::new(nx, ny, nz);
958    let mut buf: Vec<Complex64> = phase.iter().map(|&p| Complex64::new(p, 0.0)).collect();
959    ws.fft3d(&mut buf);
960    for i in 0..n {
961        let k2 = kernel[i] * kernel[i];
962        buf[i] = kernel[i] * buf[i] / (k2 + beta * e2[i] + EPS);
963    }
964    ws.ifft3d(&mut buf);
965    (0..n).map(|i| buf[i].re * mask[i]).collect()
966}
967
968/// Build the morphology mask: coefficients whose magnitude exceeds a
969/// cumulative-energy threshold of the magnitude image's wavelet coefficients.
970fn build_wave_mask(plan: &WaveletPlan, imag: &[f64], mask: &[f64], have_mag: bool, wave_pec: f64, n: usize) -> Vec<bool> {
971    if !have_mag {
972        return vec![true; n];
973    }
974    let imag_masked: Vec<f64> = (0..n).map(|i| if mask[i] > 0.5 { imag[i] } else { 0.0 }).collect();
975    let magwav = plan.forward(&imag_masked);
976    let mut abs_sorted: Vec<f64> = magwav.iter().map(|v| v.abs()).collect();
977    abs_sorted.sort_by(|a, b| b.partial_cmp(a).unwrap()); // descending
978    let total: f64 = abs_sorted.iter().sum();
979    // cumulative sum; count elements while cumsum/total <= wave_pec
980    let mut cum = 0.0;
981    let mut count = 0usize;
982    for &v in &abs_sorted {
983        cum += v;
984        if cum / total <= wave_pec {
985            count += 1;
986        } else {
987            break;
988        }
989    }
990    let thd = abs_sorted[count.max(1) - 1];
991    magwav.iter().map(|v| v.abs() > thd).collect()
992}
993
994fn pad_volume(v: &[f64], orig: (usize, usize, usize), pad: (usize, usize, usize)) -> Vec<f64> {
995    if orig == pad {
996        return v.to_vec();
997    }
998    let (ox, oy, oz) = orig;
999    let (px, py, pz) = pad;
1000    let mut out = vec![0.0; px * py * pz];
1001    for k in 0..oz {
1002        for j in 0..oy {
1003            for i in 0..ox {
1004                out[i + j * px + k * px * py] = v[i + j * ox + k * ox * oy];
1005            }
1006        }
1007    }
1008    out
1009}
1010
1011fn crop_volume(v: &[f64], pad: (usize, usize, usize), orig: (usize, usize, usize)) -> Vec<f64> {
1012    if orig == pad {
1013        return v.to_vec();
1014    }
1015    let (ox, oy, oz) = orig;
1016    let (px, py, _pz) = pad;
1017    let mut out = vec![0.0; ox * oy * oz];
1018    for k in 0..oz {
1019        for j in 0..oy {
1020            for i in 0..ox {
1021                out[i + j * ox + k * ox * oy] = v[i + j * px + k * px * py];
1022            }
1023        }
1024    }
1025    out
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031
1032    #[test]
1033    fn amp_pe_masks_output() {
1034        // Non-degenerate structured field; check output is finite and zero outside the mask.
1035        let n = 16;
1036        let field: Vec<f64> = (0..n * n * n)
1037            .map(|i| {
1038                let (x, y, z) = (i % n, (i / n) % n, i / (n * n));
1039                0.02 * (((x + 2 * y + 3 * z) as f64) * 0.3).sin()
1040            })
1041            .collect();
1042        let mut mask = vec![0u8; n * n * n];
1043        // central cube mask
1044        for z in 4..12 {
1045            for y in 4..12 {
1046                for x in 4..12 {
1047                    mask[x + y * n + z * n * n] = 1;
1048                }
1049            }
1050        }
1051        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1052        let params = AmpPeParams { max_linearization_ite: 3, ..Default::default() };
1053        let chi = amp_pe(&field, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
1054        assert_eq!(chi.len(), n * n * n);
1055        for (i, &v) in chi.iter().enumerate() {
1056            assert!(v.is_finite(), "chi[{i}] not finite: {v}");
1057            if mask[i] == 0 {
1058                assert_eq!(v, 0.0, "chi must be zero outside mask at {i}");
1059            }
1060        }
1061    }
1062
1063    #[test]
1064    fn amp_pe_ignores_out_of_mask_field() {
1065        // The result must depend only on the field inside the mask (the chiL2 seed
1066        // and the data term are masked). Adding arbitrary background outside the
1067        // mask must not change the output.
1068        let n = 16;
1069        let base: Vec<f64> = (0..n * n * n)
1070            .map(|i| {
1071                let (x, y, z) = (i % n, (i / n) % n, i / (n * n));
1072                0.02 * (((x + 2 * y + 3 * z) as f64) * 0.3).sin()
1073            })
1074            .collect();
1075        let mut mask = vec![0u8; n * n * n];
1076        for z in 4..12 {
1077            for y in 4..12 {
1078                for x in 4..12 {
1079                    mask[x + y * n + z * n * n] = 1;
1080                }
1081            }
1082        }
1083        // Field with background outside the mask.
1084        let mut with_bg = base.clone();
1085        for i in 0..with_bg.len() {
1086            if mask[i] == 0 {
1087                with_bg[i] += 0.5 * ((i % 11) as f64 - 5.0);
1088            }
1089        }
1090        // Field zeroed outside the mask.
1091        let masked: Vec<f64> = (0..base.len()).map(|i| if mask[i] != 0 { base[i] } else { 0.0 }).collect();
1092
1093        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1094        let params = AmpPeParams { max_linearization_ite: 4, ..Default::default() };
1095        let a = amp_pe(&with_bg, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
1096        let b = amp_pe(&masked, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
1097        let maxerr = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max);
1098        assert!(maxerr < 1e-9, "out-of-mask field leaked into result: maxerr={maxerr}");
1099    }
1100
1101    /// The adaptive-damping guard must NEVER fire on well-conditioned data. That is the whole
1102    /// basis for claiming this iteration still matches the MATLAB reference: the guard only
1103    /// engages when the solve is diverging, so on ordinary data the arithmetic is untouched.
1104    ///
1105    /// This is not covered by the other tests in this module. An earlier revision of the guard
1106    /// used the GAMP noise-variance estimate as its cost — a quantity that fluctuates on healthy
1107    /// runs rather than descending — so it fired constantly and silently changed the answer. Every
1108    /// other test here still passed while the output was ~9x too smooth.
1109    #[test]
1110    fn damping_guard_is_inert_on_well_conditioned_data() {
1111        let n = 16;
1112        let field: Vec<f64> = (0..n * n * n)
1113            .map(|i| {
1114                let (x, y, z) = (i % n, (i / n) % n, i / (n * n));
1115                0.02 * (((x + 2 * y + 3 * z) as f64) * 0.3).sin()
1116            })
1117            .collect();
1118        // Structured magnitude, so the wavelet morphology prior is exercised — that prior is
1119        // what destabilises the solve on real data, so it must be part of the inertness check.
1120        let mag: Vec<f64> = (0..n * n * n)
1121            .map(|i| {
1122                let (x, y) = ((i % n) as f64, ((i / n) % n) as f64);
1123                100.0 + 30.0 * (x * 0.4).cos() + 20.0 * (y * 0.25).sin()
1124            })
1125            .collect();
1126        let mut mask = vec![0u8; n * n * n];
1127        for z in 4..12 {
1128            for y in 4..12 {
1129                for x in 4..12 {
1130                    mask[x + y * n + z * n * n] = 1;
1131                }
1132            }
1133        }
1134        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1135        let params = AmpPeParams { max_linearization_ite: 4, ..Default::default() };
1136
1137        GUARD_FIRINGS.with(|c| c.set(0));
1138        let chi = amp_pe(&field, &mask, Some(&mag), &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
1139        let fired = GUARD_FIRINGS.with(|c| c.get());
1140
1141        assert_eq!(
1142            fired, 0,
1143            "divergence guard fired {fired}x on well-conditioned data — the trigger has been \
1144             loosened, and every AMP-PE result will have silently changed"
1145        );
1146        assert!(chi.iter().all(|v| v.is_finite()));
1147    }
1148
1149    #[test]
1150    fn amp_pe_finite_on_ramp() {
1151        let n = 16;
1152        let field: Vec<f64> = (0..n * n * n).map(|i| ((i % 7) as f64 - 3.0) * 0.01).collect();
1153        let mask = vec![1u8; n * n * n];
1154        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1155        let params = AmpPeParams { max_linearization_ite: 3, ..Default::default() };
1156        let chi = amp_pe(&field, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
1157        for &v in &chi {
1158            assert!(v.is_finite());
1159        }
1160    }
1161}