Skip to main content

qsm_core/unwrap/
prelude.rs

1//! PRELUDE phase unwrapping — best-pair-first region merging.
2//!
3//! After M. Jenkinson, "A Fast, Automated, N-Dimensional Phase Unwrapping
4//! Algorithm", FMRIB Technical Report TR01MJ1 (2001),
5//! <https://www.fmrib.ox.ac.uk/datasets/techrep/tr01mj1/tr01mj1.pdf>, condensed as
6//! Jenkinson M., *Magn Reson Med* 2003;49(1):193–197,
7//! <https://doi.org/10.1002/mrm.10354>.
8//!
9//! PRELUDE is the reference unwrapper that the QSM and field-mapping literature
10//! benchmarks against, which is the reason it is here: it makes this crate's
11//! unwrapper comparisons readable against the published ones.
12//!
13//! The literature generally reports it as slower and less accurate than
14//! [`unwrap_romeo`](super::unwrap_romeo) — Dymerska et al., *Magn Reson Med*
15//! 2021;85(4):2294–2308, <https://doi.org/10.1002/mrm.28563>. That comparison ran FSL's
16//! implementation. The accuracy half carries over, since this module gives FSL's output (below);
17//! the speed half does not. FSL's `prelude` at its defaults took 31 min for the KKI travelling
18//! head's five echoes (256×256×128, 1.52M in-mask voxels); this module takes 4.6 s for the same
19//! output (release build, `parallel`, 8 cores; 0.8–1.1 s per echo). On the `bids/` phantom
20//! (164×205×205, 1.31M in-mask voxels) it takes 0.40 s for the first echo and 1.45 s for the
21//! last at the defaults. With full-3D labelling at 6 partitions it took, per echo:
22//!
23//! | | first echo | last echo |
24//! |---|---|---|
25//! | PRELUDE (full 3D, 6 partitions) | 0.19 s | 0.34 s |
26//! | ROMEO | 0.34 s | 0.29 s |
27//! | best path | 0.32 s | 0.33 s |
28//! | Laplacian | 0.46 s | 0.55 s |
29//!
30//! and the local-field correlations after an identical V-SHARP were 0.615 (PRELUDE),
31//! 0.608 (Laplacian), 0.602 (best path), 0.592 (ROMEO) — a spread narrower than any
32//! of them is from ground truth, which is the agreement the comparison is for rather
33//! than a ranking.
34//!
35//! # Reference pipeline
36//!
37//! The original is FSL's `prelude` (Jenkinson's own implementation, fsl-fugue), and the defaults
38//! ([`PreludeParams::default`]) are its defaults: **8 phase partitions** (its `-n`; the report's
39//! worked example uses 6) and **initial regions labelled slice by slice**, then merged in 3D,
40//! on any volume over 64 voxels along either in-plane axis, and over the whole volume on
41//! smaller ones ([`PreludeLabelling::Auto`]). `--labelslices` labels slice by slice whatever the
42//! size ([`PreludeLabelling::Slices`]), and `-f` over the whole volume
43//! ([`PreludeLabelling::Volume`]). On the in-vivo and simulated volumes measured its output has
44//! a median in-mask wrap count of 0, which [`PreludeParams::correct_global`] (on by default)
45//! reproduces (see "The global constant" below). FSL's own
46//! masking from the magnitude (when no mask is given) is not reproduced; a mask is required here.
47//! Nothing in the algorithm depends on voxel size, field strength or orientation. The QSM
48//! pipeline offers it as [`UnwrappingAlgorithm::Prelude`](crate::pipeline::UnwrappingAlgorithm::Prelude)
49//! (each echo on its own, then whole turns aligned between echoes); it is also
50//! [`UnwrapMethod::Prelude`](super::UnwrapMethod::Prelude) for the slice-wise unwrapper and
51//! MCPC-3D-S's HIP unwrapping.
52//!
53//! **Why slice labelling is the default** (QSM.rs#397, Ashley's decision 2026-11-02): it is the
54//! original's default on every in-vivo volume (FSL's switch to full 3D below 65 voxels in-plane
55//! is kept too, as [`PreludeLabelling::Auto`], so that small volumes also give FSL's output), and
56//! on the KKI travelling head's late echoes full-3D labelling lets
57//! regions straddle wraps that slice labelling does not. Measured as the share of in-mask voxels
58//! whose echo-to-echo step `u_e − u_{e−1}` is not `wrap(φ_e − φ_{e−1})` plus the most common
59//! 2π multiple (a temporal-consistency check that needs no ground truth; lower is better), KKI
60//! echoes 2→5:
61//!
62//! | labelling, partitions | e2 | e3 | e4 | e5 |
63//! |---|---|---|---|---|
64//! | full 3D, 6 (`prelude -f -n 6`) | 3.21 % | 3.48 % | 12.36 % | 6.87 % |
65//! | full 3D, 8 (`prelude -f`) | 3.21 % | 3.47 % | 4.66 % | 11.93 % |
66//! | slices, 6 (`prelude -n 6`) | 3.21 % | 3.47 % | 3.45 % | 3.45 % |
67//! | **slices, 8 (defaults)** | 3.21 % | 3.47 % | 3.45 % | 3.46 % |
68//!
69//! On the simulation (4 echoes at 7 T) the labelling makes no difference to it (5.5–5.7 % for
70//! all; its 8 ms echo spacing sets that floor). Full-3D labelling is kept for parity with
71//! `prelude -f`.
72//!
73//! # Against FSL's `prelude`
74//!
75//! FSL's binary was run as a black box (QSM-CI's `algorithms/_fsl-prelude`: fsl-fugue 2603.0 from
76//! FSL's conda channel; its source was not read) and compared with this module inside the mask,
77//! each echo on its own, on QSM-CI's simulation (164×205×205, 4 echoes, 1.31M in-mask voxels),
78//! the KKI travelling head (256×256×128, 5 echoes, 1.52M) and the Prisma travelling head's first
79//! echo (184×256×128, 1.70M) (QSMxT/QSM-CI#302, QSM.rs#397; measured 2026-11-02). Counted is
80//! every in-mask voxel whose wrap count differs from FSL's, beyond one offset per connected
81//! component of the mask:
82//!
83//! | here | FSL | sim | KKI | Prisma e1 |
84//! |---|---|---|---|---|
85//! | **defaults** (auto = slices here, 8) | `prelude` | 0 on all 4 echoes | 0 on all 5 echoes | 0 |
86//! | slices, 6 | `prelude -n 6` | 0 on all 4 echoes | 0 on echoes 1–4; 7 voxels (0.0005 %) on echo 5 | — |
87//! | full 3D, 8 | `prelude -f` | 0 on all 4 echoes | 0 on all 5 echoes | 0 |
88//! | full 3D, 6 | `prelude -f -n 6` | 0 on all 4 echoes | 0 on all 5 echoes | — |
89//!
90//! At the defaults the output is FSL's exactly, global constant included (largest difference 0
91//! after FSL's single-precision output), and the region counts agree: FSL's `-v` reports 13111,
92//! 23565, 40070, 59635 and 82032 initial regions on KKI's echoes at its defaults (4739 to 40265
93//! with `-f`), which is what 4-connected labelling of the third axis's slices gives, and not the
94//! other two axes'. FSL writes 0 outside the mask. FSL's default run took 31 min on KKI and,
95//! with the simulation's four echoes in one 4-D run, did not finish; run one echo at a time it
96//! took 72 s, 492 s and 1964 s on echoes 2 to 4 (31618 to 118208 regions); this module takes
97//! 0.4 to 1.5 s per echo there.
98//!
99//! **When FSL's default labels slices** ([`PreludeLabelling::Auto`]): only when the volume is over
100//! 64 voxels along its first or second axis. 64×64×5, 64×64×64, 64×64×128, 40×36×200 and
101//! 20×20×300 volumes were labelled in full 3D (`-v` region count equal to `-f`'s), and 65×10×20,
102//! 10×65×20, 65×65×20, 128×32×20 and 256×256×8 slice by slice (equal to `--labelslices`'),
103//! whatever the voxel size (0.4, 1, 3 mm) or the mask's extent (a 128-wide volume masked to its
104//! first 38 columns is still labelled by slices).
105//!
106//! **The global constant.** Each connected set of regions comes out of the merge with an arbitrary
107//! whole number of turns. FSL's output on the three volumes above has a median in-mask wrap count
108//! of 0, which [`PreludeParams::correct_global`] gives; on the small noisy phantoms of
109//! `tests/prelude_fsl_parity.rs` FSL's constant is up to two turns off that median (and is not the
110//! mean, the mode, the first voxel's, the centre voxel's or the largest region's either). No rule
111//! was found for it, so the fixture compares wrap counts up to that one offset: every voxel of all
112//! eight runs (defaults, `--labelslices`, `-f`, `-n 6`, on a 72- and a 64-wide phantom) matches.
113//!
114//! `prelude -s` (everything slice by slice, no 3D merge) differs from
115//! [`unwrap_slicewise`](super::unwrap_slicewise) with [`UnwrapMethod::Prelude`](super::UnwrapMethod::Prelude):
116//! FSL writes 0 in small in-slice components (14k and 23k in-mask voxels on the simulation and
117//! KKI) and, beyond a per-component offset, 0.1–0.6 % of the remaining voxels take another wrap
118//! count. Slice-wise unwrapping is this crate's own, so that is information only.
119//!
120//! # Provenance
121//!
122//! This is a **clean-room implementation written from the technical report alone**.
123//! FSL's `prelude.cc` was not read, fetched, or consulted. That is a licensing
124//! requirement, not a stylistic one: FSL is licensed for non-commercial use only and
125//! the licence reaches "the Software or any derivative of it", so transliterating it
126//! would encumber this MIT crate. The report specifies the whole algorithm, down to
127//! the data-structure choices, so nothing had to be reverse-engineered. Equation
128//! numbers in the comments below refer to it. The slice labelling is the planar restriction
129//! of the report's §4.1; that it is FSL's default, with 8 partitions, and
130//! that it labels slices of the third axis, was established by running FSL's binary as a black
131//! box (its `-v` output and region counts; see above), not from its code.
132//!
133//! # The algorithm
134//!
135//! The unwrapped phase is the wrapped phase plus a whole number of turns,
136//! `θ_j = φ_j + 2π·m_j` (eq 1). Solving for a per-voxel `m_j` is hopeless, so the
137//! volume is first cut into regions that are each assumed wrap-free, and every region
138//! gets a single offset.
139//!
140//! 1. **Initial regions** (§2.4, §4.1). Split `[-π, π)` into `num_phase_partitions` equal
141//!    bins. For each bin, mask the in-mask voxels whose wrapped phase falls in it and
142//!    label the connected components: within each slice of the third axis, 4-connected
143//!    ([`PreludeLabelling::Slices`]; what the default [`PreludeLabelling::Auto`] does on
144//!    in-vivo volumes), or over the volume, 6-connected ([`PreludeLabelling::Volume`]). Every component across every bin (and slice) becomes a
145//!    region with its own id. Two voxels in one region therefore differ in wrapped
146//!    phase by less than one bin width, so a region straddling a wrap is unlikely —
147//!    and the narrower the bin, the less likely still. Limiting a region to one slice also
148//!    cuts the chance of two distant areas at the same phase being labelled one region
149//!    (§4.1), which is how a full-3D region comes to straddle a wrap. The merge below is 3D
150//!    either way.
151//!
152//! 2. **Cost** (eq 2–4). Across the interface between regions A and B the cost is
153//!    `Σ (φ_Aj − φ_Bk + 2π·M_AB)²`, summed over interfacing voxel pairs, with
154//!    `M_AB = M_A − M_B`. Totalled over all interfaces that is the function to
155//!    minimise. Differentiating (eq 5–6) gives the continuous minimum
156//!    `M_AB = −P_AB / (2π·N_AB)` for `N_AB` interfacing pairs and `P_AB` their summed
157//!    phase difference. But `M_AB` has to be an integer, which makes the exact problem
158//!    integer programming with `2^N` neighbouring solutions — hence the greedy scheme
159//!    below.
160//!
161//! 3. **Best-pair-first merging** (§2.3). Treat each interface as the constraint
162//!    `M_AB = round(K_AB)`, `K_AB = −P_AB/(2π·N_AB)` (eq 7). Getting one of these
163//!    wrong by ±1 costs `ΔC_AB = 8π²·N_AB·(½ − |K_AB − L_AB|)` (eq 8–9). Merge the
164//!    pair that maximises it (eq 10) — in the report's words, *"choose the pair of
165//!    regions where getting the offset 'wrong' is the most disastrous"*. A wide
166//!    interface whose mean phase step sits close to a whole number of turns is both
167//!    the most confident call available and the most expensive one to get wrong, so
168//!    it is settled first and the ambiguous interfaces inherit what it decided.
169//!    Merging sums the statistics over the merged region's remaining neighbours
170//!    (eq 11–12). Repeat until no interfaces are left.
171//!
172//! # Deviations from the report
173//!
174//! - **The merge statistics are kept in the current frame.** Equations 11–12 read
175//!   `N_QW = N_AW + N_BW`, `P_QW = P_AW + P_BW`. The counts add directly; the summed
176//!   differences do not, until both sides are expressed in one frame. Merging at
177//!   `L_AB` only fixes the two sets *relative* to each other, so one of them has to
178//!   move — and its `P` to every other region shifts by `±2π·L_AB·N` before the sum.
179//!   Which side moves is not free to choose here: the union-find keeps the larger
180//!   set's representative, so the absorbed set is the one that moves, and the sign
181//!   follows from that rather than always being B's. The report says to merge "using
182//!   the 'optimal' offset `L_AB`" and leaves the bookkeeping implicit; `merge_pair`
183//!   writes it out. Getting the direction wrong is silent — it biases every later
184//!   merge by one turn, and only shows up on volumes where the two sets differ in
185//!   size enough for the union-find to pick the other root.
186//! - **A lazily-invalidated binary heap replaces the O(C) scan.** The report searches
187//!   its `map` of interfaces linearly for the maximum `ΔC`, at most `R` times, which
188//!   it costs as a small penalty next to the cheap insert and delete. That holds at
189//!   its worked example's scale (1920 regions); it does not at the hundreds of
190//!   thousands a high-resolution 3D volume can produce, where `O(R·C)` dominates
191//!   everything else. The merge *order* is identical — the heap returns the same
192//!   maximum — only the way it is found differs.
193//! - **Masked voxels keep their original wrapped phase** on output, matching
194//!   [`unwrap_bestpath`](super::unwrap_bestpath) and [`unwrap_romeo`](super::unwrap_romeo).
195//!
196//! Each connected set of regions is unwrapped independently and carries its own
197//! arbitrary multiple of 2π — nothing links regions across a gap in the mask. Set
198//! [`PreludeParams::correct_global`] for a defined choice of that constant.
199
200use std::cmp::Ordering;
201use std::collections::{BTreeMap, BinaryHeap, HashMap};
202
203use crate::grid::Grid;
204use crate::utils::connected::{label_components, Connectivity};
205use crate::utils::union_find::UnionFind;
206
207use super::bestpath::correct_global_offset;
208
209const PI: f64 = std::f64::consts::PI;
210const TWO_PI: f64 = 2.0 * PI;
211
212/// Sentinel in the region map for a voxel that belongs to no region — outside the
213/// mask, or carrying a non-finite phase.
214const NO_REGION: u32 = u32::MAX;
215
216/// How the initial regions are labelled (§2.4, §4.1): which voxels of one phase partition count
217/// as connected.
218#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
219#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
220pub enum PreludeLabelling {
221    /// FSL `prelude`'s default: [`Slices`](Self::Slices) when the volume is more than
222    /// [`AUTO_SLICES_ABOVE`] voxels along either in-plane axis (the first two), otherwise
223    /// [`Volume`](Self::Volume). Every in-vivo acquisition is over that size, so in practice
224    /// this is slice labelling; small test volumes get FSL's full-3D labelling, as FSL gives
225    /// them. The rule was found by running FSL's binary on volumes of different shapes (module
226    /// docs).
227    #[default]
228    Auto,
229    /// Label each partition's connected components **within each slice** (4-connected in the
230    /// first two axes' plane, one slice of the third axis at a time), then merge the regions in
231    /// 3D. FSL's `--labelslices`, and its default on volumes over [`AUTO_SLICES_ABOVE`] voxels
232    /// in-plane: the planar restriction of the report's §4.1.
233    Slices,
234    /// Label each partition's 6-connected components over the whole volume, then merge them.
235    /// FSL `prelude -f`; the algorithm as the report's §2.4 states it.
236    Volume,
237}
238
239/// The in-plane size above which [`PreludeLabelling::Auto`] labels slice by slice: FSL `prelude`
240/// labels a 64×64×N volume in full 3D and a 65×N×M or N×65×M one slice by slice, whatever the
241/// slice count, voxel size or mask.
242pub const AUTO_SLICES_ABOVE: usize = 64;
243
244impl PreludeLabelling {
245    /// Stable id (`"auto"`, `"slices"`, `"volume"`).
246    pub fn id(self) -> &'static str {
247        match self {
248            Self::Auto => "auto",
249            Self::Slices => "slices",
250            Self::Volume => "volume",
251        }
252    }
253
254    /// The labelling used on a volume of `dims`: [`Auto`](Self::Auto) resolved, the others as
255    /// they are.
256    pub fn resolve(self, dims: (usize, usize, usize)) -> Self {
257        match self {
258            Self::Auto if dims.0 > AUTO_SLICES_ABOVE || dims.1 > AUTO_SLICES_ABOVE => Self::Slices,
259            Self::Auto => Self::Volume,
260            other => other,
261        }
262    }
263}
264
265/// Parameters for [`unwrap_prelude`].
266///
267/// The defaults are FSL `prelude`'s: 8 partitions, regions labelled slice by slice on volumes
268/// over 64 voxels in-plane. With them this module gives FSL's default output (see the module
269/// docs for the measured agreement).
270#[derive(Clone, Copy, Debug, PartialEq)]
271#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
272pub struct PreludeParams {
273    /// Number of equal bins the phase range `[-π, π)` is cut into when forming the
274    /// initial regions (default: 8, FSL `prelude`'s default, its `-n`; the report's
275    /// worked example uses 6).
276    ///
277    /// More partitions means narrower bins, so a connected component is less likely
278    /// to contain a wrap — at the price of more, smaller regions and a longer merge.
279    /// Fewer means larger regions that are more likely to straddle a wrap, which the
280    /// merge cannot undo: a region is committed to a single offset by construction.
281    /// Must be at least 1; at 1 every in-mask component is one region and nothing is
282    /// unwrapped within it.
283    pub num_phase_partitions: usize,
284    /// How the initial regions are labelled (default: [`PreludeLabelling::Auto`], FSL
285    /// `prelude`'s default, which is slice by slice on any in-vivo volume;
286    /// [`PreludeLabelling::Slices`] is `prelude --labelslices`, [`PreludeLabelling::Volume`] is
287    /// `prelude -f`).
288    pub labelling: PreludeLabelling,
289    /// Subtract the median wrap count from the result so the unwrapped phase stays
290    /// centred near the input range (default: true).
291    ///
292    /// Without it each connected set of regions carries an arbitrary integer multiple
293    /// of 2π. The default is on because FSL's `prelude` output has a median in-mask wrap count
294    /// of 0 on every volume measured (see the module docs), so this is what reproduces it; it
295    /// also gives a multi-echo caller echoes whose constants are already near each other
296    /// before whole turns are aligned between them. Same operation as
297    /// [`BestPathParams::correct_global`](super::BestPathParams::correct_global) and
298    /// [`RomeoParams::correct_global`](super::RomeoParams::correct_global), which default to
299    /// false because their originals do not do it.
300    pub correct_global: bool,
301}
302
303impl Default for PreludeParams {
304    fn default() -> Self {
305        Self {
306            num_phase_partitions: 8,
307            labelling: PreludeLabelling::Auto,
308            correct_global: true,
309        }
310    }
311}
312
313/// Running statistics for one interface between two regions.
314///
315/// `p` is directed: for the entry stored under `adjacency[a][b]` it is the sum taken
316/// as *a minus b*, so `adjacency[b][a]` holds its negation.
317#[derive(Clone, Copy, Debug, PartialEq)]
318struct Interface {
319    /// `N_AB`: the number of interfacing voxel pairs.
320    n: u32,
321    /// `P_AB = Σ (ψ_Aj − ψ_Bk)` over those pairs, where ψ is the phase in the
322    /// *current* working frame — the wrapped phase plus whatever turns previous
323    /// merges have already committed to.
324    p: f64,
325}
326
327impl Interface {
328    /// `K_AB = −P_AB / (2π·N_AB)`, the continuous minimum of eq 6.
329    #[inline]
330    fn k(self) -> f64 {
331        -self.p / (TWO_PI * self.n as f64)
332    }
333
334    /// `L_AB = round(K_AB)`: the integer offset the merge would commit to (eq 7).
335    #[inline]
336    fn offset(self) -> f64 {
337        self.k().round()
338    }
339
340    /// `ΔC_AB = 8π²·N_AB·(½ − |K_AB − L_AB|)`, the smallest cost penalty for getting
341    /// this interface's offset wrong by one turn (eq 9).
342    ///
343    /// Non-negative, since rounding bounds `|K − L|` by ½. It is largest for a wide
344    /// interface whose mean phase step lands near a whole number of turns, and falls
345    /// to zero when the step is exactly half a turn and the rounding is a coin toss.
346    #[inline]
347    fn cost(self) -> f64 {
348        let k = self.k();
349        8.0 * PI * PI * self.n as f64 * (0.5 - (k - k.round()).abs())
350    }
351}
352
353/// One entry in the merge queue: an interface, with the `ΔC` it had when queued.
354///
355/// The cost is carried along so a stale entry can be recognised on the way out and
356/// requeued at its true value, rather than the queue having to be rebuilt on each
357/// merge.
358#[derive(Clone, Copy, PartialEq)]
359struct Candidate {
360    cost: f64,
361    a: u32,
362    b: u32,
363}
364
365impl Eq for Candidate {}
366
367impl Ord for Candidate {
368    /// Orders by cost, then by region id so ties break the same way on every run.
369    ///
370    /// `BinaryHeap` is a max-heap, so the id comparisons are reversed: of two
371    /// interfaces with equal `ΔC`, the one with the lower ids is merged first.
372    fn cmp(&self, other: &Self) -> Ordering {
373        self.cost
374            .total_cmp(&other.cost)
375            .then_with(|| other.a.cmp(&self.a))
376            .then_with(|| other.b.cmp(&self.b))
377    }
378}
379
380impl PartialOrd for Candidate {
381    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
382        Some(self.cmp(other))
383    }
384}
385
386/// Unwrap a 3D phase volume with PRELUDE's region-merging algorithm.
387///
388/// # Arguments
389/// * `phase` — wrapped phase in radians, `nx * ny * nz`, expected in `[-π, π]`
390/// * `mask` — binary mask, non-zero to process
391/// * `grid` — volume dimensions (voxel sizes are unused: regions and interfaces are
392///   defined on the voxel lattice, not in physical space)
393/// * `params` — see [`PreludeParams`]
394///
395/// # Returns
396/// Unwrapped phase, same length as `phase`. Voxels outside the mask are returned
397/// unchanged, as are in-mask voxels whose phase is not finite.
398///
399/// Each connected set of regions is unwrapped independently and carries its own
400/// arbitrary multiple of 2π; there is no information linking them across a gap in
401/// the mask.
402///
403/// # Panics
404/// If `phase` or `mask` does not match the grid, if `params.num_phase_partitions` is
405/// zero, or if the volume has more than `u32::MAX` voxels.
406pub fn unwrap_prelude(
407    phase: &[f64],
408    mask: &[u8],
409    grid: &Grid,
410    params: &PreludeParams,
411) -> Vec<f64> {
412    let (nx, ny, nz) = grid.dims;
413    let n_total = nx * ny * nz;
414    assert_eq!(phase.len(), n_total, "phase length must match grid dimensions");
415    assert_eq!(mask.len(), n_total, "mask length must match grid dimensions");
416    assert!(
417        params.num_phase_partitions > 0,
418        "num_phase_partitions must be at least 1"
419    );
420    assert!(
421        n_total < u32::MAX as usize,
422        "PRELUDE indexes regions by u32 (volume has {n_total} voxels)"
423    );
424
425    let mut unwrapped = phase.to_vec();
426    if n_total == 0 {
427        return unwrapped;
428    }
429
430    let (region_of, n_regions) =
431        label_regions(phase, mask, grid.dims, params.num_phase_partitions, params.labelling);
432    if n_regions == 0 {
433        return unwrapped;
434    }
435
436    let mut adjacency = build_interfaces(phase, &region_of, n_regions, grid.dims);
437    let mut uf = UnionFind::new(n_regions);
438    merge_regions(&mut adjacency, &mut uf);
439
440    for i in 0..n_total {
441        if region_of[i] != NO_REGION {
442            let (_, turns) = uf.find(region_of[i] as usize);
443            unwrapped[i] = phase[i] + TWO_PI * turns as f64;
444        }
445    }
446
447    if params.correct_global {
448        correct_global_offset(&mut unwrapped, mask);
449    }
450
451    unwrapped
452}
453
454/// Which phase partition a voxel falls in, or `None` if it has no usable phase.
455///
456/// Clamped rather than asserted: the caller is documented to supply phase in
457/// `[-π, π]`, and exactly `+π` would otherwise fall off the end of the last bin.
458/// Anything that strayed further out joins the nearest bin, which keeps the voxel in
459/// play — any region it lands in still merges on its measured phase differences.
460#[inline]
461fn phase_partition(v: f64, width: f64, partitions: usize) -> Option<usize> {
462    if !v.is_finite() {
463        return None;
464    }
465    let bin = ((v + PI) / width).floor().max(0.0);
466    // `as usize` saturates, so a wildly out-of-range phase lands in the last bin.
467    Some((bin as usize).min(partitions - 1))
468}
469
470/// Partition the phase range, label the connected components of each partition, and
471/// hand every component across every partition a unique region id (§2.4).
472///
473/// With [`PreludeLabelling::Slices`] (or [`PreludeLabelling::Auto`] on a volume it resolves to
474/// slices for) a component is connected within one slice of the third axis only (4-connected in-plane), so no region spans two slices; the merge is 3D either way.
475/// Ids run partition by partition, and within a partition slice by slice.
476///
477/// Returns the per-voxel region map, [`NO_REGION`] where there is none, and the
478/// number of regions created.
479fn label_regions(
480    phase: &[f64],
481    mask: &[u8],
482    dims: (usize, usize, usize),
483    partitions: usize,
484    labelling: PreludeLabelling,
485) -> (Vec<u32>, usize) {
486    let n_total = phase.len();
487    let (nx, ny, nz) = dims;
488    let nxy = nx * ny;
489    let width = TWO_PI / partitions as f64;
490
491    let mut region_of = vec![NO_REGION; n_total];
492    let mut partition_mask = vec![0u8; n_total];
493    let mut next_id: u32 = 0;
494
495    // Label `partition_mask[range]` as a volume of `dims` and give its components the next ids.
496    let mut assign = |partition_mask: &[u8], offset: usize, dims: (usize, usize, usize)| {
497        let (labels, sizes) = label_components(partition_mask, dims, Connectivity::Face);
498        for (i, &l) in labels.iter().enumerate() {
499            if l != 0 {
500                region_of[offset + i] = next_id + l - 1;
501            }
502        }
503        // `sizes[0]` is the background slot, so the component count is one less.
504        next_id += (sizes.len() - 1) as u32;
505    };
506
507    for bin in 0..partitions {
508        for i in 0..n_total {
509            let inside = mask[i] != 0
510                && phase_partition(phase[i], width, partitions) == Some(bin);
511            partition_mask[i] = u8::from(inside);
512        }
513
514        match labelling.resolve(dims) {
515            PreludeLabelling::Volume | PreludeLabelling::Auto => assign(&partition_mask, 0, dims),
516            // A single-slice volume's face neighbours are its in-plane 4-neighbours.
517            PreludeLabelling::Slices => {
518                for z in 0..nz {
519                    let slice = &partition_mask[z * nxy..(z + 1) * nxy];
520                    if slice.iter().any(|&v| v != 0) {
521                        assign(slice, z * nxy, (nx, ny, 1));
522                    }
523                }
524            }
525        }
526    }
527
528    (region_of, next_id as usize)
529}
530
531/// Accumulate `N_AB` and `P_AB` for every interface, as a symmetric adjacency list.
532///
533/// Only the three forward neighbours are visited, so each interfacing voxel pair is
534/// counted exactly once — which is what `N_AB` in eq 6 is defined to be.
535fn build_interfaces(
536    phase: &[f64],
537    region_of: &[u32],
538    n_regions: usize,
539    dims: (usize, usize, usize),
540) -> Vec<BTreeMap<u32, Interface>> {
541    let (nx, ny, nz) = dims;
542    let nxy = nx * ny;
543
544    // Keyed on the ordered pair packed into a u64, so the sums below accumulate in
545    // raster order and the result does not depend on hash iteration order.
546    let mut pairs: HashMap<u64, Interface> = HashMap::new();
547
548    for z in 0..nz {
549        for y in 0..ny {
550            for x in 0..nx {
551                let i = z * nxy + y * nx + x;
552                let ra = region_of[i];
553                if ra == NO_REGION {
554                    continue;
555                }
556                let accumulate = |j: usize, pairs: &mut HashMap<u64, Interface>| {
557                    let rb = region_of[j];
558                    if rb == NO_REGION || rb == ra {
559                        return;
560                    }
561                    // Store the sum directed from the lower id to the higher.
562                    let (lo, hi, d) = if ra < rb {
563                        (ra, rb, phase[i] - phase[j])
564                    } else {
565                        (rb, ra, phase[j] - phase[i])
566                    };
567                    let entry = pairs
568                        .entry((lo as u64) << 32 | hi as u64)
569                        .or_insert(Interface { n: 0, p: 0.0 });
570                    entry.n += 1;
571                    entry.p += d;
572                };
573                if x + 1 < nx {
574                    accumulate(i + 1, &mut pairs);
575                }
576                if y + 1 < ny {
577                    accumulate(i + nx, &mut pairs);
578                }
579                if z + 1 < nz {
580                    accumulate(i + nxy, &mut pairs);
581                }
582            }
583        }
584    }
585
586    let mut adjacency = vec![BTreeMap::new(); n_regions];
587    for (key, iface) in pairs {
588        let lo = (key >> 32) as u32;
589        let hi = key as u32;
590        adjacency[lo as usize].insert(hi, iface);
591        adjacency[hi as usize].insert(
592            lo,
593            Interface {
594                n: iface.n,
595                p: -iface.p,
596            },
597        );
598    }
599    adjacency
600}
601
602/// Run the best-pair-first merge to exhaustion (§2.3).
603///
604/// On return the union-find holds, for every region, the number of turns separating
605/// it from its set's representative.
606fn merge_regions(adjacency: &mut [BTreeMap<u32, Interface>], uf: &mut UnionFind) {
607    let mut heap: BinaryHeap<Candidate> = BinaryHeap::new();
608    for (a, neighbours) in adjacency.iter().enumerate() {
609        for (&b, iface) in neighbours {
610            if (a as u32) < b {
611                heap.push(Candidate {
612                    cost: iface.cost(),
613                    a: a as u32,
614                    b,
615                });
616            }
617        }
618    }
619
620    while let Some(candidate) = heap.pop() {
621        let (a, b) = (candidate.a as usize, candidate.b as usize);
622        // An endpoint that is no longer its set's representative has been absorbed,
623        // and the merge that absorbed it queued a replacement for this interface.
624        if uf.find(a).0 != a || uf.find(b).0 != b {
625            continue;
626        }
627        let Some(&iface) = adjacency[a].get(&candidate.b) else {
628            continue;
629        };
630        let cost = iface.cost();
631        // An interface carrying no usable cost tells us nothing about the offset, and
632        // guessing one would be worse than leaving the two regions apart. `label_regions`
633        // keeps non-finite phases out of every region, so this does not arise from
634        // ordinary input; it is here so that a future change upstream degrades into
635        // "unmerged" rather than into something silently wrong.
636        if !cost.is_finite() {
637            continue;
638        }
639        // Both operands are finite here — the guard above saw to `cost`, and a queued
640        // NaN would have been dropped by that same guard when its entry was popped — so
641        // `==` is exact, and the loop terminates. Removing that guard reintroduces the
642        // NaN-never-equals-itself case and with it an infinite requeue, which is what
643        // `an_interface_with_no_usable_cost_is_left_unmerged` exists to pin.
644        if cost != candidate.cost {
645            // The interface changed after this entry was queued. Requeue it at its true
646            // cost; the entry that superseded this one is already in the heap. The
647            // recomputation is bit-identical to the one that queued it, so this settles
648            // in one round rather than oscillating.
649            heap.push(Candidate { cost, ..candidate });
650            continue;
651        }
652        merge_pair(adjacency, uf, candidate.a, candidate.b, iface, &mut heap);
653    }
654}
655
656/// Merge regions `a` and `b` at the offset their interface implies, fold their
657/// adjacency together, and queue the interfaces that changed.
658fn merge_pair(
659    adjacency: &mut [BTreeMap<u32, Interface>],
660    uf: &mut UnionFind,
661    a: u32,
662    b: u32,
663    iface: Interface,
664    heap: &mut BinaryHeap<Candidate>,
665) {
666    let turns = iface.offset();
667    debug_assert!(
668        turns.abs() < i32::MAX as f64,
669        "interface offset {turns} is out of range for the union-find's i32 potential"
670    );
671
672    // Committing to M_AB = L_AB means theta_A - theta_B = psi_A - psi_B + 2*pi*L_AB:
673    // the two sets end up L_AB turns apart, with B the lower.
674    let joined = uf.union_with_delta(a as usize, b as usize, -(turns as i32));
675    debug_assert!(joined, "merge_pair called on two regions already in one set");
676    let root = uf.find(a as usize).0 as u32;
677    let absorbed = if root == a { b } else { a };
678
679    // The interface just consumed is gone.
680    adjacency[a as usize].remove(&b);
681    adjacency[b as usize].remove(&a);
682
683    // That constraint fixes only the *difference* between the two sets, so one of them
684    // has to be re-expressed in the other's frame, and the union-find has already
685    // chosen which: it keeps the larger set's representative, and `find` then reports
686    // the absorbed set's potentials against it. So the absorbed set is exactly the one
687    // whose phases moved — by L_AB turns down if that was B, up if it was A — and its
688    // interfaces to everything else move with it. This is what equation 12 leaves
689    // implicit when it says to merge "using the 'optimal' offset"; getting the
690    // direction wrong here silently biases every later merge by 2*pi*L_AB.
691    let shift = if absorbed == b { -TWO_PI * turns } else { TWO_PI * turns };
692
693    // Fold the absorbed region's interfaces into the surviving one (eq 11-12).
694    let moved = std::mem::take(&mut adjacency[absorbed as usize]);
695    let mut touched: Vec<u32> = Vec::with_capacity(moved.len());
696    for (w, from_absorbed) in moved {
697        // Every merge drops the absorbed region's mirror entries, so adjacency only ever
698        // holds live representatives. That is what lets `merge_regions` treat a missing
699        // entry as "already merged" rather than as a lost interface, so it is worth
700        // pinning: skipping the removal below leaks dead ids back in, and nothing else
701        // would notice.
702        debug_assert_eq!(
703            uf.find(w as usize).0,
704            w as usize,
705            "adjacency holds region {w}, which is not its set's representative"
706        );
707        adjacency[w as usize].remove(&absorbed);
708        let merged = {
709            let entry = adjacency[root as usize]
710                .entry(w)
711                .or_insert(Interface { n: 0, p: 0.0 });
712            entry.n += from_absorbed.n;
713            entry.p += from_absorbed.p + shift * from_absorbed.n as f64;
714            *entry
715        };
716        adjacency[w as usize].insert(
717            root,
718            Interface {
719                n: merged.n,
720                p: -merged.p,
721            },
722        );
723        touched.push(w);
724    }
725
726    // Only the folded interfaces changed; the survivor's own are already in its frame,
727    // and their queued candidates stay valid.
728    for w in touched {
729        if let Some(entry) = adjacency[root as usize].get(&w) {
730            heap.push(Candidate {
731                cost: entry.cost(),
732                a: root,
733                b: w,
734            });
735        }
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
744        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
745    }
746
747    fn wrap_to_pi(v: f64) -> f64 {
748        v - TWO_PI * (v / TWO_PI).round()
749    }
750
751    /// Build a wrapped volume from a continuous field, plus the field itself.
752    fn wrap_field<F: Fn(f64, f64, f64) -> f64>(
753        nx: usize,
754        ny: usize,
755        nz: usize,
756        f: F,
757    ) -> (Vec<f64>, Vec<f64>) {
758        let mut truth = vec![0.0; nx * ny * nz];
759        let mut wrapped = vec![0.0; nx * ny * nz];
760        for k in 0..nz {
761            for j in 0..ny {
762                for i in 0..nx {
763                    let idx = k * nx * ny + j * nx + i;
764                    let v = f(i as f64, j as f64, k as f64);
765                    truth[idx] = v;
766                    wrapped[idx] = wrap_to_pi(v);
767                }
768            }
769        }
770        (truth, wrapped)
771    }
772
773    /// Assert that `got` equals `truth` up to one global 2π offset, over the mask.
774    ///
775    /// The offset is read off the *median* residual, not a single voxel, so the check
776    /// still works when the input carries noise.
777    fn assert_unwrapped_to_constant(got: &[f64], truth: &[f64], mask: &[u8], tol: f64) {
778        let mut residuals: Vec<f64> = (0..got.len())
779            .filter(|&i| mask[i] != 0)
780            .map(|i| got[i] - truth[i])
781            .collect();
782        assert!(!residuals.is_empty(), "empty mask");
783        residuals.sort_by(|a, b| a.partial_cmp(b).unwrap());
784        let median = residuals[residuals.len() / 2];
785        let offset = TWO_PI * (median / TWO_PI).round();
786        assert!(
787            (median - offset).abs() < tol,
788            "median residual {median} is not within {tol} of a multiple of 2pi"
789        );
790        let worst = residuals
791            .iter()
792            .map(|d| (d - offset).abs())
793            .fold(0.0f64, f64::max);
794        assert!(worst < tol, "max deviation {worst} exceeds {tol}");
795    }
796
797    /// A deterministic uniform draw in `[0, 1)`, so the noisy tests do not pull in a
798    /// dependency and do not change answer between runs.
799    fn lcg(state: &mut u64) -> f64 {
800        *state = state
801            .wrapping_mul(6364136223846793005)
802            .wrapping_add(1442695040888963407);
803        ((*state >> 11) as f64) / ((1u64 << 53) as f64)
804    }
805
806    // ---------------------------------------------------------------- recovery
807
808    #[test]
809    fn recovers_a_steep_linear_ramp() {
810        let (nx, ny, nz) = (16, 16, 8);
811        // ~0.9 rad per voxel in x: many wraps, still well under the pi/voxel limit.
812        let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.3 * j + 0.2 * k);
813        let mask = vec![1u8; nx * ny * nz];
814
815        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
816        assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
817    }
818
819    #[test]
820    fn recovers_a_quadratic_field() {
821        let (nx, ny, nz) = (20, 20, 12);
822        let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| {
823            let (x, y, z) = (i - 10.0, j - 10.0, k - 6.0);
824            0.05 * (x * x + y * y) + 0.1 * z * z
825        });
826        let mask = vec![1u8; nx * ny * nz];
827
828        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
829        assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
830    }
831
832    #[test]
833    fn a_single_wrap_between_two_regions_is_closed() {
834        // Two voxels 6 rad apart in wrapped phase: one turn, and the whole algorithm
835        // reduces to a single interface with N = 1 and L = 1.
836        let phase = vec![3.0, -3.0];
837        let mask = vec![1u8, 1];
838        let out = unwrap_prelude(&phase, &mask, &grid(2, 1, 1), &PreludeParams::default());
839
840        // Which of the two keeps its phase is the arbitrary constant; what the merge
841        // decides is the step between them.
842        let step = out[1] - out[0];
843        assert!(
844            (step - (TWO_PI - 6.0)).abs() < 1e-12,
845            "expected the 6 rad gap to close to {}, got {step}",
846            TWO_PI - 6.0
847        );
848        for i in 0..2 {
849            let turns = (out[i] - phase[i]) / TWO_PI;
850            assert!(
851                (turns - turns.round()).abs() < 1e-12,
852                "voxel {i} moved by {turns} turns, which is not a whole number"
853            );
854        }
855    }
856
857    #[test]
858    fn unwrapped_result_is_already_smooth() {
859        // The real property of interest: no 2pi jumps between neighbours.
860        let (nx, ny, nz) = (16, 16, 16);
861        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.8 * i - 0.5 * j + 0.4 * k);
862        let mask = vec![1u8; nx * ny * nz];
863
864        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
865
866        let nxy = nx * ny;
867        for k in 0..nz {
868            for j in 0..ny {
869                for i in 0..nx {
870                    let idx = k * nxy + j * nx + i;
871                    for (step, limit, axis) in [(1, i + 1 < nx, 'x'), (nx, j + 1 < ny, 'y'), (nxy, k + 1 < nz, 'z')] {
872                        if limit {
873                            let d = (out[idx + step] - out[idx]).abs();
874                            assert!(d < PI, "{axis} step of {d} at ({i},{j},{k}) exceeds pi");
875                        }
876                    }
877                }
878            }
879        }
880    }
881
882    #[test]
883    fn survives_noise_in_the_phase() {
884        let (nx, ny, nz) = (24, 24, 12);
885        let (truth, mut wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.5 * i + 0.2 * j + 0.15 * k);
886        let mut state = 0xC0FFEEu64;
887        let mut noisy_truth = truth.clone();
888        for idx in 0..wrapped.len() {
889            let noise = 0.3 * (lcg(&mut state) - 0.5);
890            noisy_truth[idx] += noise;
891            wrapped[idx] = wrap_to_pi(noisy_truth[idx]);
892        }
893        let mask = vec![1u8; nx * ny * nz];
894
895        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
896        // Every voxel must land on the right turn; the noise itself survives untouched.
897        assert_unwrapped_to_constant(&out, &noisy_truth, &mask, 1e-9);
898    }
899
900    #[test]
901    fn agrees_with_best_path_on_a_smooth_field() {
902        let (nx, ny, nz) = (20, 20, 10);
903        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| {
904            0.6 * i - 0.35 * j + 0.5 * k + 0.02 * i * j
905        });
906        let mask = vec![1u8; nx * ny * nz];
907        let g = grid(nx, ny, nz);
908
909        let a = unwrap_prelude(&wrapped, &mask, &g, &PreludeParams::default());
910        let b = super::super::unwrap_bestpath(&wrapped, &mask, &Default::default(), &g);
911
912        // Both fix the phase only up to one global turn over the (single) connected
913        // component, so compare after removing that constant.
914        let offset = TWO_PI * ((a[0] - b[0]) / TWO_PI).round();
915        let worst = (0..a.len())
916            .map(|i| (a[i] - b[i] - offset).abs())
917            .fold(0.0f64, f64::max);
918        assert!(worst < 1e-9, "PRELUDE and best path differ by up to {worst} rad");
919    }
920
921    // ------------------------------------------------------------ mask handling
922
923    #[test]
924    fn masked_voxels_are_left_untouched() {
925        let (nx, ny, nz) = (12, 12, 6);
926        let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.7 * i + 0.4 * j + 0.3 * k);
927        let nxy = nx * ny;
928        let mut mask = vec![0u8; nx * ny * nz];
929        for k in 1..nz - 1 {
930            for j in 2..ny - 2 {
931                for i in 2..nx - 2 {
932                    mask[k * nxy + j * nx + i] = 1;
933                }
934            }
935        }
936
937        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
938        for i in 0..out.len() {
939            if mask[i] == 0 {
940                assert_eq!(out[i], wrapped[i], "voxel {i} is outside the mask");
941            }
942        }
943        assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
944    }
945
946    #[test]
947    fn disconnected_components_are_each_internally_consistent() {
948        // Two slabs with a gap: nothing links them, so each picks its own turn, but
949        // each must be smooth on its own.
950        let (nx, ny, nz) = (10, 10, 10);
951        let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.5 * j + 0.6 * k);
952        let nxy = nx * ny;
953        let mut mask = vec![0u8; nx * ny * nz];
954        for k in 0..nz {
955            if k == 4 || k == 5 {
956                continue; // the gap
957            }
958            for idx in k * nxy..(k + 1) * nxy {
959                mask[idx] = 1;
960            }
961        }
962
963        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
964
965        for (lo, hi) in [(0, 4), (6, 10)] {
966            let mut part = vec![0u8; nx * ny * nz];
967            part[lo * nxy..hi * nxy].fill(1);
968            assert_unwrapped_to_constant(&out, &truth, &part, 1e-9);
969        }
970    }
971
972    #[test]
973    fn an_empty_mask_returns_the_input() {
974        let (nx, ny, nz) = (6, 6, 6);
975        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.8 * i + j + k);
976        let mask = vec![0u8; nx * ny * nz];
977        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
978        assert_eq!(out, wrapped);
979    }
980
981    #[test]
982    fn non_finite_phase_voxels_are_left_alone() {
983        let (nx, ny, nz) = (8, 8, 4);
984        let (truth, mut wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.4 * j + 0.3 * k);
985        let mut mask = vec![1u8; nx * ny * nz];
986        for &idx in &[5usize, 40, 100] {
987            wrapped[idx] = f64::NAN;
988        }
989
990        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
991        for &idx in &[5usize, 40, 100] {
992            assert!(out[idx].is_nan(), "voxel {idx} should have been left as NaN");
993            mask[idx] = 0;
994        }
995        // Everything else still unwraps, around the holes the NaNs punch in the mask.
996        assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
997    }
998
999    #[test]
1000    fn thin_volumes_do_not_panic() {
1001        for dims in [(1, 1, 1), (8, 1, 1), (1, 8, 1), (1, 1, 8), (4, 4, 1)] {
1002            let (nx, ny, nz) = dims;
1003            let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 1.4 * i + 1.1 * j + 0.9 * k);
1004            let mask = vec![1u8; nx * ny * nz];
1005            let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
1006            assert_eq!(out.len(), nx * ny * nz, "dims {dims:?}");
1007            assert!(out.iter().all(|v| v.is_finite()), "dims {dims:?}");
1008        }
1009    }
1010
1011    // ---------------------------------------------------------------- behaviour
1012
1013    #[test]
1014    fn is_deterministic_across_runs() {
1015        let (nx, ny, nz) = (18, 18, 9);
1016        let (_, mut wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.7 * i + 0.45 * j + 0.3 * k);
1017        let mut state = 0x5EEDu64;
1018        for v in wrapped.iter_mut() {
1019            *v = wrap_to_pi(*v + 0.6 * (lcg(&mut state) - 0.5));
1020        }
1021        let mask = vec![1u8; nx * ny * nz];
1022        let g = grid(nx, ny, nz);
1023
1024        let first = unwrap_prelude(&wrapped, &mask, &g, &PreludeParams::default());
1025        for _ in 0..4 {
1026            assert_eq!(
1027                unwrap_prelude(&wrapped, &mask, &g, &PreludeParams::default()),
1028                first,
1029                "repeat runs disagree"
1030            );
1031        }
1032    }
1033
1034    #[test]
1035    fn correct_global_centres_the_result() {
1036        // A ramp spanning tens of radians, so the unwrapped field runs several turns
1037        // clear of zero whichever region ends up carrying the arbitrary constant.
1038        let (nx, ny, nz) = (40, 8, 4);
1039        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 1.2 * i + 0.4 * j + 0.3 * k);
1040        let mask = vec![1u8; nx * ny * nz];
1041        let g = grid(nx, ny, nz);
1042
1043        let median_turns = |v: &[f64]| {
1044            let mut turns: Vec<f64> = v.iter().map(|x| (x / TWO_PI).round()).collect();
1045            turns.sort_by(|a, b| a.partial_cmp(b).unwrap());
1046            turns[turns.len() / 2]
1047        };
1048
1049        // Full-3D labelling at 6 partitions: a setting where the arbitrary constant this
1050        // volume ends up with is not already zero.
1051        let base = PreludeParams {
1052            num_phase_partitions: 6,
1053            labelling: PreludeLabelling::Volume,
1054            correct_global: false,
1055        };
1056        let plain = unwrap_prelude(&wrapped, &mask, &g, &base);
1057        assert_ne!(
1058            median_turns(&plain), 0.0,
1059            "the uncorrected result was already centred, so the test proves nothing"
1060        );
1061
1062        let centred = unwrap_prelude(
1063            &wrapped,
1064            &mask,
1065            &g,
1066            &PreludeParams { correct_global: true, ..base },
1067        );
1068        assert_eq!(median_turns(&centred), 0.0, "corrected result is not centred");
1069
1070        // The two differ by that constant and nothing else.
1071        let shift = plain[0] - centred[0];
1072        assert!(shift.abs() > 1e-9);
1073        for i in 0..plain.len() {
1074            assert!((plain[i] - centred[i] - shift).abs() < 1e-9);
1075        }
1076    }
1077
1078    #[test]
1079    fn one_partition_leaves_connected_phase_alone() {
1080        // The documented degenerate case: a single bin makes every connected piece of
1081        // the mask one region, there are no interfaces, and nothing is unwrapped.
1082        let (nx, ny, nz) = (10, 10, 4);
1083        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.5 * j + 0.4 * k);
1084        let mask = vec![1u8; nx * ny * nz];
1085        let out = unwrap_prelude(
1086            &wrapped,
1087            &mask,
1088            &grid(nx, ny, nz),
1089            &PreludeParams {
1090                num_phase_partitions: 1,
1091                labelling: PreludeLabelling::Volume,
1092                ..PreludeParams::default()
1093            },
1094        );
1095        assert_eq!(out, wrapped);
1096    }
1097
1098    #[test]
1099    #[should_panic(expected = "num_phase_partitions must be at least 1")]
1100    fn zero_partitions_is_rejected() {
1101        unwrap_prelude(
1102            &[0.0; 8],
1103            &[1u8; 8],
1104            &grid(2, 2, 2),
1105            &PreludeParams { num_phase_partitions: 0, ..PreludeParams::default() },
1106        );
1107    }
1108
1109    #[test]
1110    #[should_panic(expected = "phase length must match grid dimensions")]
1111    fn mismatched_phase_length_is_rejected() {
1112        unwrap_prelude(&[0.0; 7], &[1u8; 8], &grid(2, 2, 2), &PreludeParams::default());
1113    }
1114
1115    // --------------------------------------------------------- initial regions
1116
1117    #[test]
1118    fn every_region_stays_inside_one_phase_partition() {
1119        let (nx, ny, nz) = (16, 16, 8);
1120        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.7 * i + 0.3 * j + 0.9 * k);
1121        let mask = vec![1u8; nx * ny * nz];
1122
1123        for partitions in [2usize, 3, 6, 12] {
1124            let (region_of, n_regions) =
1125                label_regions(&wrapped, &mask, (nx, ny, nz), partitions, PreludeLabelling::Volume);
1126            assert!(n_regions > 0);
1127            let width = TWO_PI / partitions as f64;
1128            let mut lo = vec![f64::INFINITY; n_regions];
1129            let mut hi = vec![f64::NEG_INFINITY; n_regions];
1130            for i in 0..wrapped.len() {
1131                assert_ne!(region_of[i], NO_REGION, "voxel {i} was left unlabelled");
1132                let r = region_of[i] as usize;
1133                lo[r] = lo[r].min(wrapped[i]);
1134                hi[r] = hi[r].max(wrapped[i]);
1135            }
1136            for r in 0..n_regions {
1137                assert!(
1138                    hi[r] - lo[r] < width,
1139                    "region {r} spans {} rad, wider than the {width} rad partition",
1140                    hi[r] - lo[r]
1141                );
1142            }
1143        }
1144    }
1145
1146    #[test]
1147    fn narrower_partitions_make_more_regions() {
1148        let (nx, ny, nz) = (20, 20, 10);
1149        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.55 * i + 0.3 * j + 0.2 * k);
1150        let mask = vec![1u8; nx * ny * nz];
1151
1152        let counts: Vec<usize> = [2usize, 4, 6, 12, 24]
1153            .iter()
1154            .map(|&p| label_regions(&wrapped, &mask, (nx, ny, nz), p, PreludeLabelling::Volume).1)
1155            .collect();
1156        for w in counts.windows(2) {
1157            assert!(w[1] > w[0], "region counts are not increasing: {counts:?}");
1158        }
1159    }
1160
1161    #[test]
1162    fn slice_labelling_keeps_every_region_in_one_slice() {
1163        let (nx, ny, nz) = (16, 16, 8);
1164        let nxy = nx * ny;
1165        // Shallow along z, so the 3D components run through several slices.
1166        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.7 * i + 0.3 * j + 0.2 * k);
1167        let mask = vec![1u8; nx * ny * nz];
1168
1169        for partitions in [1usize, 3, 8] {
1170            let (slices, n_slices) =
1171                label_regions(&wrapped, &mask, (nx, ny, nz), partitions, PreludeLabelling::Slices);
1172            let (_, n_volume) =
1173                label_regions(&wrapped, &mask, (nx, ny, nz), partitions, PreludeLabelling::Volume);
1174            let mut slice_of = vec![usize::MAX; n_slices];
1175            for (i, &r) in slices.iter().enumerate() {
1176                assert_ne!(r, NO_REGION, "voxel {i} was left unlabelled");
1177                let z = i / nxy;
1178                let s = &mut slice_of[r as usize];
1179                assert!(*s == usize::MAX || *s == z, "region {r} spans slices {s} and {z}");
1180                *s = z;
1181            }
1182            // Each slice's partitions are labelled on their own, so a 3D component through
1183            // several slices becomes at least one region per slice.
1184            assert!(n_slices >= nz && n_slices > n_volume, "{n_slices} vs {n_volume}");
1185        }
1186    }
1187
1188    #[test]
1189    fn slice_labelling_still_merges_across_slices() {
1190        // One partition: each slice is a single region and the merge across z is all that
1191        // unwraps, so a field that wraps only along z checks the 3D merge.
1192        let (nx, ny, nz) = (6, 6, 12);
1193        let (truth, wrapped) = wrap_field(nx, ny, nz, |_, _, k| 1.3 * k);
1194        let mask = vec![1u8; nx * ny * nz];
1195        let params = PreludeParams {
1196            num_phase_partitions: 1,
1197            labelling: PreludeLabelling::Slices,
1198            ..PreludeParams::default()
1199        };
1200        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &params);
1201        assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
1202    }
1203
1204    #[test]
1205    fn the_defaults_are_fsls() {
1206        let p = PreludeParams::default();
1207        assert_eq!(p.num_phase_partitions, 8);
1208        assert_eq!(p.labelling, PreludeLabelling::Auto);
1209        assert!(p.correct_global);
1210        let auto = PreludeLabelling::Auto;
1211        assert_eq!(auto.resolve((64, 64, 200)), PreludeLabelling::Volume);
1212        assert_eq!(auto.resolve((65, 10, 20)), PreludeLabelling::Slices);
1213        assert_eq!(auto.resolve((10, 65, 20)), PreludeLabelling::Slices);
1214        assert_eq!(PreludeLabelling::Slices.resolve((8, 8, 8)), PreludeLabelling::Slices);
1215        assert_eq!(PreludeLabelling::Volume.resolve((256, 256, 8)), PreludeLabelling::Volume);
1216        let ids: Vec<_> = [auto, PreludeLabelling::Slices, PreludeLabelling::Volume].map(|l| l.id()).into();
1217        assert_eq!(ids, ["auto", "slices", "volume"]);
1218    }
1219
1220    #[test]
1221    fn the_phase_endpoints_land_in_the_end_partitions() {
1222        let width = TWO_PI / 6.0;
1223        assert_eq!(phase_partition(-PI, width, 6), Some(0));
1224        assert_eq!(phase_partition(-PI + 1e-12, width, 6), Some(0));
1225        assert_eq!(phase_partition(0.0, width, 6), Some(3));
1226        assert_eq!(phase_partition(PI - 1e-12, width, 6), Some(5));
1227        assert_eq!(phase_partition(PI, width, 6), Some(5), "+pi must not fall off the end");
1228        // Out of range is clamped rather than dropped, and NaN is dropped.
1229        assert_eq!(phase_partition(-10.0, width, 6), Some(0));
1230        assert_eq!(phase_partition(10.0, width, 6), Some(5));
1231        assert_eq!(phase_partition(f64::NAN, width, 6), None);
1232        assert_eq!(phase_partition(f64::INFINITY, width, 6), None);
1233    }
1234
1235    /// The report's §2.4 at the scale of its own worked example (§4.1): a
1236    /// 320x320 = 102400-voxel image with 31566 voxels in the mask gave 1920 regions
1237    /// from 6 partitions, 1102 of them single voxels and only 163 larger than 27
1238    /// voxels. Our data is synthetic, so the counts cannot match exactly; what the
1239    /// bounds here catch is a partitioning that is wrong by an order of magnitude.
1240    #[test]
1241    fn region_counts_are_the_order_the_report_reports() {
1242        let (nx, ny, nz) = (320, 320, 1);
1243        let (cx, cy) = (159.5f64, 159.5f64);
1244        // Radius chosen so the disc holds ~31566 voxels, as the report's mask does.
1245        let radius = (31566.0 / PI).sqrt();
1246
1247        let mut state = 0xBEEFu64;
1248        let mut wrapped = vec![0.0; nx * ny];
1249        let mut mask = vec![0u8; nx * ny];
1250        for j in 0..ny {
1251            for i in 0..nx {
1252                let idx = j * nx + i;
1253                let (x, y) = (i as f64 - cx, j as f64 - cy);
1254                if x * x + y * y > radius * radius {
1255                    continue;
1256                }
1257                mask[idx] = 1;
1258                // A field with several wraps across the mask, plus noise at the level
1259                // that produces the report's crop of single-voxel regions.
1260                let field = 0.09 * x + 0.004 * (x * x + y * y) / 10.0;
1261                wrapped[idx] = wrap_to_pi(field + 1.2 * (lcg(&mut state) - 0.5));
1262            }
1263        }
1264        let in_mask = mask.iter().filter(|&&m| m != 0).count();
1265        assert!(
1266            (30000..33000).contains(&in_mask),
1267            "test mask has {in_mask} voxels, not the report's ~31566"
1268        );
1269
1270        let (region_of, n_regions) = label_regions(&wrapped, &mask, (nx, ny, nz), 6, PreludeLabelling::Volume);
1271        let mut sizes = vec![0usize; n_regions];
1272        for &r in &region_of {
1273            if r != NO_REGION {
1274                sizes[r as usize] += 1;
1275            }
1276        }
1277        let singletons = sizes.iter().filter(|&&s| s == 1).count();
1278        let large = sizes.iter().filter(|&&s| s > 27).count();
1279        println!(
1280            "regions={n_regions} singletons={singletons} larger_than_27={large} \
1281             in_mask={in_mask}"
1282        );
1283
1284        assert!(
1285            (500..6000).contains(&n_regions),
1286            "{n_regions} regions is not the order of the report's 1920"
1287        );
1288        assert!(
1289            singletons * 4 > n_regions && singletons < n_regions,
1290            "{singletons} single-voxel regions out of {n_regions} does not look like the \
1291             report's 1102/1920"
1292        );
1293        assert!(
1294            large < n_regions / 4,
1295            "{large} regions larger than 27 voxels, out of {n_regions}; the report had 163/1920"
1296        );
1297
1298        // And the whole thing still unwraps at that scale.
1299        let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
1300        for j in 0..ny {
1301            for i in 0..nx - 1 {
1302                let idx = j * nx + i;
1303                if mask[idx] != 0 && mask[idx + 1] != 0 {
1304                    let step = (out[idx + 1] - out[idx]).abs();
1305                    assert!(step < TWO_PI, "x step of {step} rad at ({i},{j})");
1306                }
1307            }
1308        }
1309    }
1310
1311    #[test]
1312    fn an_interface_with_no_usable_cost_is_left_unmerged() {
1313        // `unwrap_prelude` cannot produce this — `label_regions` drops non-finite phases
1314        // before a region is formed — so drive `merge_regions` directly. What is being
1315        // pinned is that the loop *terminates*: with `==` in place of `total_cmp`, a NaN
1316        // cost compares unequal to itself and the candidate is requeued forever. A
1317        // regression here hangs rather than fails, so a timeout on this test is the
1318        // symptom to look for.
1319        let mut adjacency = vec![BTreeMap::new(); 3];
1320        for (a, b, iface) in [
1321            (0u32, 1u32, Interface { n: 4, p: f64::NAN }),
1322            (1, 2, Interface { n: 6, p: 0.0 }),
1323        ] {
1324            adjacency[a as usize].insert(b, iface);
1325            adjacency[b as usize].insert(a, Interface { n: iface.n, p: -iface.p });
1326        }
1327
1328        let mut uf = UnionFind::new(3);
1329        merge_regions(&mut adjacency, &mut uf);
1330
1331        // The usable interface merged; the one carrying NaN did not.
1332        assert_eq!(uf.find(1).0, uf.find(2).0, "regions 1 and 2 should have merged");
1333        assert_ne!(uf.find(0).0, uf.find(1).0, "region 0 had no usable interface");
1334        assert_eq!(uf.find(0).1, 0, "an unmerged region keeps its own frame");
1335    }
1336
1337    // ------------------------------------------------- the heap against a linear scan
1338
1339    /// Selection exactly as the report describes it: scan every interface for the
1340    /// largest `ΔC` (eq 10), merge that pair, repeat. `O(C)` per iteration.
1341    ///
1342    /// Shares [`merge_pair`] with the real implementation, so the only thing that
1343    /// differs is how the maximum is found — which is the one deviation the module
1344    /// docs claim is free.
1345    fn merge_regions_by_linear_scan(
1346        adjacency: &mut [BTreeMap<u32, Interface>],
1347        uf: &mut UnionFind,
1348    ) {
1349        let mut sink: BinaryHeap<Candidate> = BinaryHeap::new();
1350        loop {
1351            let mut best: Option<(u32, u32, Interface)> = None;
1352            for a in 0..adjacency.len() {
1353                if uf.find(a).0 != a {
1354                    continue;
1355                }
1356                for (&b, &iface) in &adjacency[a] {
1357                    if (a as u32) < b
1358                        && best.is_none_or(|(_, _, chosen)| iface.cost() > chosen.cost())
1359                    {
1360                        best = Some((a as u32, b, iface));
1361                    }
1362                }
1363            }
1364            let Some((a, b, iface)) = best else { break };
1365            merge_pair(adjacency, uf, a, b, iface, &mut sink);
1366            sink.clear();
1367        }
1368    }
1369
1370    /// Random region graphs with deliberately inconsistent interfaces.
1371    ///
1372    /// `p` ranges far wider than any real volume would produce, which is the point: it
1373    /// makes merges that *lower* a neighbouring interface's `ΔC`, so a candidate queued
1374    /// earlier at a higher cost surfaces before the one that replaced it. That is the
1375    /// only situation the heap's staleness check exists for, and on real data it does
1376    /// not arise — 2582 merges over a noisy 26×26×14 volume triggered it zero times, so
1377    /// a test built from volumes cannot reach it.
1378    fn random_region_graph(
1379        state: &mut u64,
1380        n_regions: usize,
1381        edge_chance: f64,
1382    ) -> Vec<BTreeMap<u32, Interface>> {
1383        let mut adjacency = vec![BTreeMap::new(); n_regions];
1384        for a in 0..n_regions {
1385            for b in a + 1..n_regions {
1386                if lcg(state) > edge_chance {
1387                    continue;
1388                }
1389                let iface = Interface {
1390                    n: 1 + (lcg(state) * 60.0) as u32,
1391                    p: (lcg(state) - 0.5) * 400.0,
1392                };
1393                adjacency[a].insert(b as u32, iface);
1394                adjacency[b].insert(
1395                    a as u32,
1396                    Interface { n: iface.n, p: -iface.p },
1397                );
1398            }
1399        }
1400        adjacency
1401    }
1402
1403    #[test]
1404    fn the_heap_picks_the_same_pair_as_a_linear_scan() {
1405        let mut state = 0x9A5Du64;
1406        let mut compared = 0;
1407        for n_regions in [2usize, 3, 5, 8, 13, 21, 34] {
1408            for &edge_chance in &[0.15, 0.4, 0.9] {
1409                for _ in 0..12 {
1410                    let graph = random_region_graph(&mut state, n_regions, edge_chance);
1411
1412                    let mut by_heap = graph.clone();
1413                    let mut uf_heap = UnionFind::new(n_regions);
1414                    merge_regions(&mut by_heap, &mut uf_heap);
1415
1416                    let mut by_scan = graph.clone();
1417                    let mut uf_scan = UnionFind::new(n_regions);
1418                    merge_regions_by_linear_scan(&mut by_scan, &mut uf_scan);
1419
1420                    // Both must have merged the same regions together. Which member
1421                    // ends up as the set's representative is arbitrary — union-by-size
1422                    // decides it, and a different merge order gives a different tree —
1423                    // so compare the partition itself, not the roots.
1424                    for r in 0..n_regions {
1425                        for q in 0..n_regions {
1426                            assert_eq!(
1427                                uf_heap.find(r).0 == uf_heap.find(q).0,
1428                                uf_scan.find(r).0 == uf_scan.find(q).0,
1429                                "regions {r} and {q} of {n_regions} were merged by one \
1430                                 method and not the other"
1431                            );
1432                        }
1433                    }
1434                    // And committed the same turns. Each set carries an arbitrary
1435                    // constant, so measure every region against the lowest-numbered
1436                    // member of its own set.
1437                    let anchor = |uf: &mut UnionFind, r: usize| {
1438                        let root = uf.find(r).0;
1439                        (0..n_regions).find(|&q| uf.find(q).0 == root).expect("r is its own")
1440                    };
1441                    for r in 0..n_regions {
1442                        let (a_h, a_s) = (anchor(&mut uf_heap, r), anchor(&mut uf_scan, r));
1443                        let rel_h = uf_heap.find(r).1 - uf_heap.find(a_h).1;
1444                        let rel_s = uf_scan.find(r).1 - uf_scan.find(a_s).1;
1445                        assert_eq!(
1446                            rel_h, rel_s,
1447                            "region {r} of {n_regions} sits {rel_h} turns above its set's \
1448                             first member under the heap, but {rel_s} under the scan"
1449                        );
1450                    }
1451                    compared += 1;
1452                }
1453            }
1454        }
1455        assert_eq!(compared, 7 * 3 * 12);
1456    }
1457
1458    // ------------------------------------------------ against a literal transcription
1459
1460    /// The report's §2.3 written out with no incremental bookkeeping at all: every
1461    /// interface statistic is recomputed from the voxels after each merge, in the
1462    /// phases as they currently stand.
1463    ///
1464    /// Slow — `O(R)` passes over the volume — but it is the equations and nothing
1465    /// else, which makes it the yardstick for [`merge_regions`]'s incremental
1466    /// updates. Returns the unwrapped phase.
1467    fn merge_from_scratch_each_step(
1468        phase: &[f64],
1469        mask: &[u8],
1470        dims: (usize, usize, usize),
1471        partitions: usize,
1472        labelling: PreludeLabelling,
1473    ) -> Vec<f64> {
1474        let (nx, ny, nz) = dims;
1475        let nxy = nx * ny;
1476        let (region_of, n_regions) = label_regions(phase, mask, dims, partitions, labelling);
1477
1478        // Turns committed so far per region, and which merged set it belongs to.
1479        let mut turns = vec![0i64; n_regions];
1480        let mut set_of: Vec<usize> = (0..n_regions).collect();
1481        let psi = |turns: &[i64], i: usize| {
1482            phase[i] + TWO_PI * turns[region_of[i] as usize] as f64
1483        };
1484
1485        loop {
1486            let mut stats: BTreeMap<(usize, usize), Interface> = BTreeMap::new();
1487            for z in 0..nz {
1488                for y in 0..ny {
1489                    for x in 0..nx {
1490                        let i = z * nxy + y * nx + x;
1491                        if region_of[i] == NO_REGION {
1492                            continue;
1493                        }
1494                        let sa = set_of[region_of[i] as usize];
1495                        for (j, inside) in
1496                            [(i + 1, x + 1 < nx), (i + nx, y + 1 < ny), (i + nxy, z + 1 < nz)]
1497                        {
1498                            if !inside || region_of[j] == NO_REGION {
1499                                continue;
1500                            }
1501                            let sb = set_of[region_of[j] as usize];
1502                            if sa == sb {
1503                                continue;
1504                            }
1505                            let (lo, hi, d) = if sa < sb {
1506                                (sa, sb, psi(&turns, i) - psi(&turns, j))
1507                            } else {
1508                                (sb, sa, psi(&turns, j) - psi(&turns, i))
1509                            };
1510                            let e = stats.entry((lo, hi)).or_insert(Interface { n: 0, p: 0.0 });
1511                            e.n += 1;
1512                            e.p += d;
1513                        }
1514                    }
1515                }
1516            }
1517
1518            // eq 10: the pair where getting the offset wrong is most disastrous.
1519            let Some((&(a, b), &iface)) = stats
1520                .iter()
1521                .max_by(|x, y| x.1.cost().total_cmp(&y.1.cost()))
1522            else {
1523                break;
1524            };
1525            let offset = iface.offset() as i64;
1526            for r in 0..n_regions {
1527                if set_of[r] == b {
1528                    turns[r] -= offset;
1529                    set_of[r] = a;
1530                }
1531            }
1532        }
1533
1534        (0..phase.len())
1535            .map(|i| {
1536                if region_of[i] == NO_REGION {
1537                    phase[i]
1538                } else {
1539                    psi(&turns, i)
1540                }
1541            })
1542            .collect()
1543    }
1544
1545    /// Assert two unwrapped fields are the same solution — equal up to the arbitrary
1546    /// constant each connected piece of the mask carries.
1547    ///
1548    /// Compares neighbour differences, which pin a component's field down completely
1549    /// and are the one thing the constant cannot hide.
1550    fn assert_same_solution(a: &[f64], b: &[f64], mask: &[u8], dims: (usize, usize, usize)) {
1551        let (nx, ny, nz) = dims;
1552        let nxy = nx * ny;
1553        let mut compared = 0usize;
1554        for z in 0..nz {
1555            for y in 0..ny {
1556                for x in 0..nx {
1557                    let i = z * nxy + y * nx + x;
1558                    if mask[i] == 0 {
1559                        continue;
1560                    }
1561                    for (j, inside) in
1562                        [(i + 1, x + 1 < nx), (i + nx, y + 1 < ny), (i + nxy, z + 1 < nz)]
1563                    {
1564                        if !inside || mask[j] == 0 {
1565                            continue;
1566                        }
1567                        compared += 1;
1568                        let d = (a[j] - a[i]) - (b[j] - b[i]);
1569                        assert!(
1570                            d.abs() < 1e-9,
1571                            "the two disagree by {d} rad across ({x},{y},{z})"
1572                        );
1573                    }
1574                }
1575            }
1576        }
1577        assert!(compared > 0, "nothing was compared");
1578    }
1579
1580    #[test]
1581    fn the_incremental_merge_matches_a_literal_transcription() {
1582        let (nx, ny, nz) = (12, 12, 10);
1583        let nxy = nx * ny;
1584        let dims = (nx, ny, nz);
1585        let g = grid(nx, ny, nz);
1586
1587        let (_, smooth) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.5 * j + 0.6 * k);
1588        let mut noisy = smooth.clone();
1589        let mut state = 0x15EEDu64;
1590        for v in noisy.iter_mut() {
1591            *v = wrap_to_pi(*v + 0.8 * (lcg(&mut state) - 0.5));
1592        }
1593
1594        let full = vec![1u8; nx * ny * nz];
1595        // A gap across the volume, so there are two independent sets of regions. It
1596        // was this case that first exposed the frame-shift direction in `merge_pair`.
1597        let mut split = vec![1u8; nx * ny * nz];
1598        split[4 * nxy..6 * nxy].fill(0);
1599        // An irregular blob, so regions have ragged interfaces of very uneven size.
1600        let mut blob = vec![0u8; nx * ny * nz];
1601        for k in 0..nz {
1602            for j in 0..ny {
1603                for i in 0..nx {
1604                    let (x, y, z) = (i as f64 - 5.5, j as f64 - 5.5, k as f64 - 4.5);
1605                    if x * x + y * y + 1.8 * z * z < 26.0 {
1606                        blob[k * nxy + j * nx + i] = 1;
1607                    }
1608                }
1609            }
1610        }
1611
1612        for (field_name, phase) in [("smooth", &smooth), ("noisy", &noisy)] {
1613            for (mask_name, mask) in [("full", &full), ("split", &split), ("blob", &blob)] {
1614                for partitions in [2usize, 4, 6, 9] {
1615                    for labelling in [PreludeLabelling::Volume, PreludeLabelling::Slices] {
1616                        let params = PreludeParams {
1617                            num_phase_partitions: partitions,
1618                            labelling,
1619                            correct_global: false,
1620                        };
1621                        let fast = unwrap_prelude(phase, mask, &g, &params);
1622                        let slow =
1623                            merge_from_scratch_each_step(phase, mask, dims, partitions, labelling);
1624                        println!("{field_name}/{mask_name}/{partitions} partitions, {labelling:?}");
1625                        assert_same_solution(&fast, &slow, mask, dims);
1626                    }
1627                }
1628            }
1629        }
1630    }
1631
1632    // ------------------------------------------------------- interfaces and cost
1633
1634    #[test]
1635    fn interface_sums_match_their_definition() {
1636        // 3x1x1, regions [0, 1, 0]: region 0 interfaces region 1 on two voxel pairs.
1637        let phase = vec![1.0, -0.5, 2.0];
1638        let region_of = vec![0u32, 1, 0];
1639        let adjacency = build_interfaces(&phase, &region_of, 2, (3, 1, 1));
1640
1641        // P_01 = (phase[0] - phase[1]) + (phase[2] - phase[1]) = 1.5 + 2.5 = 4.0
1642        assert_eq!(adjacency[0][&1], Interface { n: 2, p: 4.0 });
1643        assert_eq!(adjacency[1][&0], Interface { n: 2, p: -4.0 });
1644    }
1645
1646    #[test]
1647    fn voxels_in_the_same_region_do_not_interface() {
1648        let phase = vec![0.0, 0.1, 0.2, 0.3];
1649        let region_of = vec![0u32, 0, 0, 0];
1650        let adjacency = build_interfaces(&phase, &region_of, 1, (4, 1, 1));
1651        assert!(adjacency[0].is_empty());
1652    }
1653
1654    #[test]
1655    fn unlabelled_voxels_do_not_interface() {
1656        let phase = vec![0.0, 1.0, 2.0];
1657        let region_of = vec![0u32, NO_REGION, 1];
1658        let adjacency = build_interfaces(&phase, &region_of, 2, (3, 1, 1));
1659        assert!(adjacency[0].is_empty(), "a gap must not join the regions either side");
1660        assert!(adjacency[1].is_empty());
1661    }
1662
1663    #[test]
1664    fn each_interfacing_pair_is_counted_once() {
1665        // A 2x2x2 checkerboard of two regions: every one of the 12 lattice edges
1666        // joins different regions, and each must be counted exactly once.
1667        let region_of: Vec<u32> = (0..8u32).map(|i| i.count_ones() % 2).collect();
1668        let phase = vec![0.0; 8];
1669        let adjacency = build_interfaces(&phase, &region_of, 2, (2, 2, 2));
1670        assert_eq!(adjacency[0][&1].n, 12);
1671    }
1672
1673    #[test]
1674    fn cost_follows_equation_nine() {
1675        let eight_pi_sq = 8.0 * PI * PI;
1676        // K = 0 exactly: rounding is unambiguous, so getting it wrong is most costly.
1677        let certain = Interface { n: 4, p: 0.0 };
1678        assert_eq!(certain.offset(), 0.0);
1679        assert!((certain.cost() - eight_pi_sq * 4.0 * 0.5).abs() < 1e-9);
1680
1681        // K = 1/4 of a turn off: the penalty drops linearly.
1682        let quarter = Interface { n: 4, p: -TWO_PI * 4.0 * 0.25 };
1683        assert!((quarter.k() - 0.25).abs() < 1e-12);
1684        assert_eq!(quarter.offset(), 0.0);
1685        assert!((quarter.cost() - eight_pi_sq * 4.0 * 0.25).abs() < 1e-9);
1686
1687        // K = half a turn: a coin toss, and worth nothing to decide.
1688        let ambiguous = Interface { n: 4, p: -TWO_PI * 4.0 * 0.5 };
1689        assert!(ambiguous.cost().abs() < 1e-9);
1690
1691        // The count scales the penalty: a wider interface at the same K costs more.
1692        let wide = Interface { n: 40, p: 0.0 };
1693        assert!((wide.cost() - 10.0 * certain.cost()).abs() < 1e-9);
1694
1695        // And the offset rounds, including away from zero at the half-turn.
1696        assert_eq!(Interface { n: 1, p: -TWO_PI * 1.6 }.offset(), 2.0);
1697        assert_eq!(Interface { n: 1, p: TWO_PI * 2.4 }.offset(), -2.0);
1698    }
1699
1700    #[test]
1701    fn cost_is_never_negative() {
1702        let mut state = 0xDEADu64;
1703        for _ in 0..2000 {
1704            let iface = Interface {
1705                n: 1 + (lcg(&mut state) * 50.0) as u32,
1706                p: (lcg(&mut state) - 0.5) * 400.0,
1707            };
1708            assert!(iface.cost() >= 0.0, "{iface:?} gave a negative cost");
1709            assert!((iface.k() - iface.offset()).abs() <= 0.5 + 1e-12);
1710        }
1711    }
1712
1713    #[test]
1714    fn candidates_order_by_cost_then_by_id() {
1715        let mut heap: BinaryHeap<Candidate> = BinaryHeap::new();
1716        heap.push(Candidate { cost: 1.0, a: 0, b: 1 });
1717        heap.push(Candidate { cost: 5.0, a: 7, b: 9 });
1718        heap.push(Candidate { cost: 5.0, a: 2, b: 3 });
1719        heap.push(Candidate { cost: 5.0, a: 2, b: 1 });
1720        let order: Vec<(u32, u32)> =
1721            std::iter::from_fn(|| heap.pop()).map(|c| (c.a, c.b)).collect();
1722        assert_eq!(order, vec![(2, 1), (2, 3), (7, 9), (0, 1)]);
1723    }
1724}