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