Skip to main content

ferray_ma/
extras.rs

1// ferray-ma: numpy.ma extras
2//
3// Adds the high-value `numpy.ma` API surface that was missing:
4//   - extra reductions: prod, cumsum, cumprod, argmin, argmax, median, ptp,
5//     average, anom
6//   - constructors: masked_all, masked_values, nomask
7//   - manipulation: ma_concatenate, ma_clip, ma_diagonal, ma_repeat,
8//     ma_atleast_{1,2,3}d, ma_expand_dims, ma_swapaxes
9//   - mask manipulation: harden_mask, soften_mask, mask_or, make_mask,
10//     make_mask_none
11//   - linalg/set/corr: ma_dot, ma_inner, ma_outer, ma_trace,
12//     ma_unique, ma_in1d, ma_isin
13//   - functional: ma_apply_along_axis, ma_apply_over_axes, ma_vander
14//   - fill-value protocol: default_fill_value, maximum_fill_value,
15//     minimum_fill_value, common_fill_value
16//   - bitwise/comparison ufuncs: ma_equal, ma_not_equal, ma_less,
17//     ma_greater, ma_less_equal, ma_greater_equal, ma_logical_and,
18//     ma_logical_or, ma_logical_not, ma_logical_xor
19//   - class helpers: is_masked_array (alias isMA), getmaskarray, ids
20//   - set ops: ma_unique_masked, ma_intersect1d, ma_union1d, ma_setdiff1d,
21//     ma_setxor1d
22//   - row/col suppression: ma_compress_rowcols/_rows/_cols, ma_mask_rowcols
23//   - masked covariance/correlation: ma_cov, ma_corrcoef (y=None case)
24//   - contiguity/run helpers: clump_masked, clump_unmasked,
25//     flatnotmasked_edges/_contiguous, notmasked_edges(_axis2),
26//     notmasked_contiguous_axis
27//   - 2-D masked matmul: ma_dot_2d; multi-axis argsort: argsort_axis
28//   - axis median + returned-weights average: ma_median_axis,
29//     average_returned
30//
31// Items intentionally NOT covered (deferred — no REQ-N, genuine gaps):
32//   - mvoid (record-void): requires structured-record dtype work in core
33//   - mr_ / masked_singleton / masked_print_option: Python-class display
34//     state with no Rust analog
35//   - polyfit / convolve / correlate: would pull in ferray-polynomial /
36//     ferray-stats; users can call those directly on the unmasked subset
37//     via .compressed()
38//   - ma_cov/ma_corrcoef two-variable-set `y` argument: the binding raises a
39//     clear `ValueError` (REQ-29 covers only the `y=None` case)
40//
41// ## REQ status
42//
43// All extras REQs SHIPPED — audited, green. Each row's non-test production
44// consumer is the matching `#[pyfunction]`/method in `ferray-python/src/ma.rs`,
45// registered by `register_ma_module` in `ferray-python/src/lib.rs`. (REQ-18..22
46// — `ma_where`/`ma_choose`/`ma_diff`/`ma_ediff1d`/`ma_nonzero` — live in
47// `algorithms.rs`, not this file; their evidence is in `.design/ferray-ma.md`.)
48//
49// | REQ | Status | Evidence (impl in this file unless noted) |
50// |-----|--------|------|
51// | REQ-23 (`ma_vander`) | SHIPPED | `ma_vander`; consumer `ferray-python/src/ma.rs::vander`. |
52// | REQ-24 (`ma_isin`/`ma_in1d`) | SHIPPED | `ma_isin`/`ma_in1d`; consumers `ferray-python/src/ma.rs::isin`/`in1d`. |
53// | REQ-25 (set ops) | SHIPPED | `ma_intersect1d`/`ma_union1d`/`ma_setdiff1d`/`ma_setxor1d` (built on `ma_unique_masked`); consumers `ferray-python/src/ma.rs::intersect1d`/`union1d`/`setdiff1d`/`setxor1d` (`numpy/ma/extras.py:1317`/`:1463`/`:1485`/`:1350`). |
54// | REQ-26 (`unique` masked slot) | SHIPPED | `ma_unique_masked`; consumed by the REQ-25 set ops (`numpy/ma/extras.py:1267`). |
55// | REQ-27 (`compress_rowcols`) | SHIPPED | `ma_compress_rowcols`/`ma_compress_rows`/`ma_compress_cols`; consumers `ferray-python/src/ma.rs::compress_rowcols`/`compress_rows`/`compress_cols` (`numpy/ma/extras.py:920`/`:953`/`:991`). |
56// | REQ-28 (`mask_rowcols`) | SHIPPED | `ma_mask_rowcols`; consumer `ferray-python/src/ma.rs::mask_rowcols` (`numpy/ma/extras.py:830`). |
57// | REQ-29 (`cov`/`corrcoef`, y=None) | SHIPPED | `ma_cov`/`ma_corrcoef`; consumers `ferray-python/src/ma.rs::cov`/`corrcoef` (`numpy/ma/extras.py:1580`/`:1675`). The two-variable-set `y` arg remains a documented gap. |
58// | REQ-30 (`clump_masked`/`clump_unmasked`) | SHIPPED | `clump_masked`/`clump_unmasked` (on `ezclump_true`); consumers `ferray-python/src/ma.rs::clump_masked`/`clump_unmasked` (`numpy/ma/extras.py:2189`/`:2235`). |
59// | REQ-31 (`flatnotmasked_*`) | SHIPPED | `flatnotmasked_edges`/`flatnotmasked_contiguous`; consumers `ferray-python/src/ma.rs::flatnotmasked_edges`/`flatnotmasked_contiguous` (`numpy/ma/extras.py:1818`/`:1762`). |
60// | REQ-32 (`notmasked_*`) | SHIPPED | `notmasked_edges`/`notmasked_edges_axis2`/`notmasked_contiguous_axis`; consumers `ferray-python/src/ma.rs::notmasked_edges`/`notmasked_contiguous` (`numpy/ma/extras.py:1977`/`:1925`). |
61// | REQ-33 (2-D `ma.dot`) | SHIPPED | `MaskedArray<T, Ix2>::ma_dot_2d` (and the flat `ma_dot_flat`); consumer `ferray-python/src/ma.rs::dot` (`numpy/ma/core.py:8214`). |
62// | REQ-14 (argsort axis) | SHIPPED | `argsort_axis` (endwith=True lane fill); consumer `ferray-python/src/ma.rs::argsort` (explicit-axis branch). |
63// | REQ-38 (`median(axis=)`) | SHIPPED | `ma_median_axis`; consumer the explicit-axis branch of `ferray-python/src/ma.rs::median` (`numpy/ma/extras.py:678`). |
64// | REQ-39 (`average(returned=)`) | SHIPPED | `MaskedArray::average_returned` (the plain `average` delegates to it); consumer the `returned` branch of `ferray-python/src/ma.rs::average` (`numpy/ma/extras.py:510`/`:670`). |
65//
66// Also SHIPPED here (REQ-4 extension, full-array reductions beyond
67// `reductions.rs`): `prod`, `cumsum_flat`, `cumprod_flat`, `argmin`, `argmax`,
68// `ptp`, `median`, `average`, `anom` — consumed by the matching `PyMaskedArray`
69// methods in `ferray-python/src/ma.rs`. The fill-value protocol
70// (`default_fill_value_f64`/`_f32`, `maximum_fill_value`, `minimum_fill_value`,
71// `common_fill_value`) and the comparison/logical ufuncs (`ma_logical_and`/
72// `_or`/`_xor`/`_not`) are likewise re-exported via `ferray-ma/src/lib.rs` and
73// consumed by the `ferray.ma` module shims.
74
75use ferray_core::dimension::{Dimension, IxDyn};
76use ferray_core::dtype::Element;
77use ferray_core::error::{FerrayError, FerrayResult};
78use ferray_core::{Array, Ix1, Ix2};
79use num_traits::Float;
80
81use crate::MaskedArray;
82
83// ===========================================================================
84// Sentinel constants
85// ===========================================================================
86
87/// The "no mask" sentinel — equivalent to `numpy.ma.nomask`. Read this and
88/// pass it as a mask to indicate "no elements are masked."
89pub const NOMASK: bool = false;
90
91// ===========================================================================
92// Extra reductions on MaskedArray
93// ===========================================================================
94
95impl<T, D> MaskedArray<T, D>
96where
97    T: Element + Copy + std::ops::Mul<Output = T> + num_traits::One,
98    D: Dimension,
99{
100    /// Product of unmasked elements. Returns `T::one()` for the all-masked case.
101    ///
102    /// # Errors
103    /// Returns an error only for internal failures.
104    pub fn prod(&self) -> FerrayResult<T> {
105        let one = num_traits::one::<T>();
106        let p = self
107            .data()
108            .iter()
109            .zip(self.mask().iter())
110            .filter(|(_, m)| !**m)
111            .fold(one, |acc, (v, _)| acc * *v);
112        Ok(p)
113    }
114}
115
116impl<T, D> MaskedArray<T, D>
117where
118    T: Element + Copy + std::ops::Add<Output = T> + num_traits::Zero,
119    D: Dimension,
120{
121    /// Cumulative sum over unmasked elements (mask propagates: each output
122    /// position carries the same mask as the input). Masked positions
123    /// contribute zero to the running total.
124    ///
125    /// # Errors
126    /// Returns an error if internal array construction fails.
127    pub fn cumsum_flat(&self) -> FerrayResult<MaskedArray<T, Ix1>> {
128        let zero = num_traits::zero::<T>();
129        let n = self.size();
130        let data: Vec<T> = self
131            .data()
132            .iter()
133            .zip(self.mask().iter())
134            .scan(zero, |acc, (v, m)| {
135                if !*m {
136                    *acc = *acc + *v;
137                }
138                Some(*acc)
139            })
140            .collect();
141        let mask: Vec<bool> = self.mask().iter().copied().collect();
142        let data_arr = Array::from_vec(Ix1::new([n]), data)?;
143        let mask_arr = Array::from_vec(Ix1::new([n]), mask)?;
144        MaskedArray::new(data_arr, mask_arr)
145    }
146}
147
148impl<T, D> MaskedArray<T, D>
149where
150    T: Element + Copy + std::ops::Mul<Output = T> + num_traits::One,
151    D: Dimension,
152{
153    /// Cumulative product over unmasked elements (mask propagates).
154    /// Masked positions contribute one to the running product.
155    ///
156    /// # Errors
157    /// Returns an error if internal array construction fails.
158    pub fn cumprod_flat(&self) -> FerrayResult<MaskedArray<T, Ix1>> {
159        let one = num_traits::one::<T>();
160        let n = self.size();
161        let data: Vec<T> = self
162            .data()
163            .iter()
164            .zip(self.mask().iter())
165            .scan(one, |acc, (v, m)| {
166                if !*m {
167                    *acc = *acc * *v;
168                }
169                Some(*acc)
170            })
171            .collect();
172        let mask: Vec<bool> = self.mask().iter().copied().collect();
173        let data_arr = Array::from_vec(Ix1::new([n]), data)?;
174        let mask_arr = Array::from_vec(Ix1::new([n]), mask)?;
175        MaskedArray::new(data_arr, mask_arr)
176    }
177}
178
179impl<T, D> MaskedArray<T, D>
180where
181    T: Element + Copy + PartialOrd,
182    D: Dimension,
183{
184    /// Flat index of the minimum unmasked element.
185    ///
186    /// # Errors
187    /// Returns `FerrayError::InvalidValue` if every element is masked.
188    pub fn argmin(&self) -> FerrayResult<usize> {
189        self.data()
190            .iter()
191            .zip(self.mask().iter())
192            .enumerate()
193            .filter(|(_, (_, m))| !**m)
194            .min_by(|(_, (a, _)), (_, (b, _))| {
195                a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
196            })
197            .map(|(i, _)| i)
198            .ok_or_else(|| FerrayError::invalid_value("argmin: all elements are masked"))
199    }
200
201    /// Flat index of the maximum unmasked element.
202    ///
203    /// # Errors
204    /// Returns `FerrayError::InvalidValue` if every element is masked.
205    pub fn argmax(&self) -> FerrayResult<usize> {
206        self.data()
207            .iter()
208            .zip(self.mask().iter())
209            .enumerate()
210            .filter(|(_, (_, m))| !**m)
211            .max_by(|(_, (a, _)), (_, (b, _))| {
212                a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
213            })
214            .map(|(i, _)| i)
215            .ok_or_else(|| FerrayError::invalid_value("argmax: all elements are masked"))
216    }
217
218    /// Peak-to-peak range over unmasked elements (`max - min`).
219    ///
220    /// # Errors
221    /// Returns `FerrayError::InvalidValue` if every element is masked.
222    pub fn ptp(&self) -> FerrayResult<T>
223    where
224        T: std::ops::Sub<Output = T>,
225    {
226        let mut iter = self
227            .data()
228            .iter()
229            .zip(self.mask().iter())
230            .filter(|(_, m)| !**m)
231            .map(|(v, _)| *v);
232        let first = iter
233            .next()
234            .ok_or_else(|| FerrayError::invalid_value("ptp: all elements are masked"))?;
235        let mut lo = first;
236        let mut hi = first;
237        for v in iter {
238            if v < lo {
239                lo = v;
240            }
241            if v > hi {
242                hi = v;
243            }
244        }
245        Ok(hi - lo)
246    }
247}
248
249impl<T, D> MaskedArray<T, D>
250where
251    T: Element
252        + Copy
253        + PartialOrd
254        + num_traits::FromPrimitive
255        + std::ops::Add<Output = T>
256        + std::ops::Div<Output = T>,
257    D: Dimension,
258{
259    /// Median of unmasked elements (interpolated between the two middle
260    /// values for an even count).
261    ///
262    /// # Errors
263    /// Returns `FerrayError::InvalidValue` if every element is masked.
264    pub fn median(&self) -> FerrayResult<T> {
265        let mut vals: Vec<T> = self
266            .data()
267            .iter()
268            .zip(self.mask().iter())
269            .filter(|(_, m)| !**m)
270            .map(|(v, _)| *v)
271            .collect();
272        if vals.is_empty() {
273            return Err(FerrayError::invalid_value(
274                "median: all elements are masked",
275            ));
276        }
277        vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
278        let n = vals.len();
279        if n % 2 == 1 {
280            Ok(vals[n / 2])
281        } else {
282            let two = T::from_u8(2).unwrap();
283            Ok((vals[n / 2 - 1] + vals[n / 2]) / two)
284        }
285    }
286}
287
288/// `numpy.ma.median(a, axis=axis)` (`numpy/ma/extras.py:678`) — per-lane median
289/// of the unmasked elements along `axis`, returning an `(ndim - 1)`-D masked
290/// array. A lane whose elements are all masked yields a masked output slot
291/// (numpy's `median` returns the `masked` value for an all-masked lane).
292///
293/// Each lane's median is the middle unmasked value (odd count) or the average
294/// of the two middle unmasked values (even count), matching the flat
295/// [`MaskedArray::median`].
296///
297/// # Errors
298/// Returns `FerrayError::axis_out_of_bounds` if `axis >= a.ndim()`, or an
299/// internal construction error.
300pub fn ma_median_axis<T>(
301    a: &MaskedArray<T, IxDyn>,
302    axis: usize,
303) -> FerrayResult<MaskedArray<T, IxDyn>>
304where
305    T: Element
306        + Copy
307        + PartialOrd
308        + num_traits::Zero
309        + num_traits::FromPrimitive
310        + std::ops::Add<Output = T>
311        + std::ops::Div<Output = T>,
312{
313    let shape = a.shape().to_vec();
314    if axis >= shape.len() {
315        return Err(FerrayError::axis_out_of_bounds(axis, shape.len()));
316    }
317    let lane_len = shape[axis];
318    let out_shape: Vec<usize> = shape
319        .iter()
320        .enumerate()
321        .filter(|&(i, _)| i != axis)
322        .map(|(_, &s)| s)
323        .collect();
324    let out_size: usize = out_shape.iter().product::<usize>().max(1);
325
326    let ndim = shape.len();
327    let mut strides = vec![1usize; ndim];
328    for i in (0..ndim.saturating_sub(1)).rev() {
329        strides[i] = strides[i + 1] * shape[i + 1];
330    }
331
332    let data: Vec<T> = a.data().iter().copied().collect();
333    let mask: Vec<bool> = a.mask().iter().copied().collect();
334
335    let mut out_data = Vec::with_capacity(out_size);
336    let mut out_mask = Vec::with_capacity(out_size);
337
338    // Iterate over every multi-index of the OUTPUT shape (axes other than
339    // `axis`), reconstruct the base offset, then sweep the lane.
340    let out_axes: Vec<usize> = (0..ndim).filter(|&i| i != axis).collect();
341    let mut counter = vec![0usize; out_axes.len()];
342    let two = T::from_u8(2).ok_or_else(|| {
343        FerrayError::invalid_value("median: constant 2 not representable in element type")
344    })?;
345    for _ in 0..out_size {
346        let mut base = 0usize;
347        for (slot, &ax) in out_axes.iter().enumerate() {
348            base += counter[slot] * strides[ax];
349        }
350        let mut vals: Vec<T> = Vec::with_capacity(lane_len);
351        for k in 0..lane_len {
352            let off = base + k * strides[axis];
353            if !mask[off] {
354                vals.push(data[off]);
355            }
356        }
357        if vals.is_empty() {
358            out_data.push(<T as num_traits::Zero>::zero());
359            out_mask.push(true);
360        } else {
361            vals.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
362            let n = vals.len();
363            let med = if n % 2 == 1 {
364                vals[n / 2]
365            } else {
366                (vals[n / 2 - 1] + vals[n / 2]) / two
367            };
368            out_data.push(med);
369            out_mask.push(false);
370        }
371
372        // Increment the output multi-index (row-major over out_axes).
373        for slot in (0..out_axes.len()).rev() {
374            counter[slot] += 1;
375            if counter[slot] < out_shape[slot] {
376                break;
377            }
378            counter[slot] = 0;
379        }
380    }
381
382    let dim = IxDyn::new(&out_shape);
383    let data_arr = Array::<T, IxDyn>::from_vec(dim.clone(), out_data)?;
384    let mask_arr = Array::<bool, IxDyn>::from_vec(dim, out_mask)?;
385    MaskedArray::new(data_arr, mask_arr)
386}
387
388impl<T, D> MaskedArray<T, D>
389where
390    T: Element + Copy + Float,
391    D: Dimension,
392{
393    /// Weighted average of unmasked elements.
394    ///
395    /// `weights` must be `Some(Array<T, D>)` of the same shape as `self`,
396    /// or `None` (delegates to [`MaskedArray::mean`]).
397    ///
398    /// # Errors
399    /// Returns `FerrayError::ShapeMismatch` if weights shape differs, or
400    /// `FerrayError::InvalidValue` if the weight sum over unmasked
401    /// elements is zero (or every element is masked).
402    pub fn average(&self, weights: Option<&Array<T, D>>) -> FerrayResult<T> {
403        Ok(self.average_returned(weights)?.0)
404    }
405
406    /// Weighted average of unmasked elements together with the sum of the
407    /// weights actually applied (the unmasked-weight total).
408    ///
409    /// Mirrors `numpy.ma.average(..., returned=True)`
410    /// (`numpy/ma/extras.py:510`), which returns the tuple
411    /// `(average, sum_of_weights)`. For the unweighted case (`weights=None`)
412    /// numpy uses each element's implicit weight of one, so the second tuple
413    /// element is the count of unmasked elements
414    /// (`numpy/ma/extras.py:637` `scl = avg.dtype.type(a.count(axis))`).
415    ///
416    /// # Errors
417    /// Returns `FerrayError::ShapeMismatch` if weights shape differs, or
418    /// `FerrayError::InvalidValue` if the weight sum over unmasked
419    /// elements is zero (or every element is masked).
420    pub fn average_returned(&self, weights: Option<&Array<T, D>>) -> FerrayResult<(T, T)> {
421        let zero = <T as num_traits::Zero>::zero();
422        let Some(w) = weights else {
423            // Unweighted: avg = mean, sum_of_weights = count of unmasked.
424            let avg = self.mean()?;
425            let count = self.mask().iter().filter(|m| !**m).count();
426            let scl = <T as num_traits::NumCast>::from(count).ok_or_else(|| {
427                FerrayError::invalid_value("average: unmasked count not representable")
428            })?;
429            return Ok((avg, scl));
430        };
431        if w.shape() != self.shape() {
432            return Err(FerrayError::shape_mismatch(format!(
433                "average: weights shape {:?} differs from array shape {:?}",
434                w.shape(),
435                self.shape(),
436            )));
437        }
438        let mut wsum = zero;
439        let mut acc = zero;
440        for ((v, m), wi) in self.data().iter().zip(self.mask().iter()).zip(w.iter()) {
441            if !*m {
442                wsum = wsum + *wi;
443                acc = acc + *v * *wi;
444            }
445        }
446        if wsum == zero {
447            return Err(FerrayError::invalid_value(
448                "average: weight sum is zero (or all elements are masked)",
449            ));
450        }
451        Ok((acc / wsum, wsum))
452    }
453
454    /// Anomaly array: `self - mean(self)`. Returns a masked array of the
455    /// same shape with the same mask.
456    ///
457    /// Equivalent to `numpy.ma.anom`.
458    ///
459    /// # Errors
460    /// Returns `FerrayError::InvalidValue` if every element is masked.
461    pub fn anom(&self) -> FerrayResult<MaskedArray<T, D>> {
462        let m = self.mean()?;
463        let data: Vec<T> = self.data().iter().map(|v| *v - m).collect();
464        let data_arr = Array::from_vec(self.data().dim().clone(), data)?;
465        let mask_arr: Array<bool, D> = Array::from_vec(
466            self.mask().dim().clone(),
467            self.mask().iter().copied().collect(),
468        )?;
469        MaskedArray::new(data_arr, mask_arr)
470    }
471}
472
473// ===========================================================================
474// Constructors
475// ===========================================================================
476
477/// Build a fully-masked array of the given shape, filled with `T::zero()`.
478///
479/// Equivalent to `numpy.ma.masked_all(shape)`.
480///
481/// # Errors
482/// Returns an error if internal array construction fails.
483pub fn masked_all<T, D>(shape: D) -> FerrayResult<MaskedArray<T, D>>
484where
485    T: Element + Copy,
486    D: Dimension,
487{
488    let data = Array::<T, D>::from_elem(shape.clone(), T::zero())?;
489    let mask = Array::<bool, D>::from_elem(shape, true)?;
490    MaskedArray::new(data, mask)
491}
492
493/// Build a masked-all array with the same shape as the reference.
494///
495/// Equivalent to `numpy.ma.masked_all_like(other)`.
496///
497/// # Errors
498/// Returns an error if internal array construction fails.
499pub fn masked_all_like<T, U, D>(reference: &Array<U, D>) -> FerrayResult<MaskedArray<T, D>>
500where
501    T: Element + Copy,
502    U: Element,
503    D: Dimension,
504{
505    masked_all(reference.dim().clone())
506}
507
508/// Mask `arr` where each element approximately equals `value` within `rtol` /
509/// `atol` (relative + absolute tolerance, NumPy semantics:
510/// `|x - value| <= atol + rtol * |value|`).
511///
512/// Equivalent to `numpy.ma.masked_values(arr, value, rtol, atol)`.
513///
514/// # Errors
515/// Returns an error if internal array construction fails.
516pub fn masked_values<T, D>(
517    arr: &Array<T, D>,
518    value: T,
519    rtol: T,
520    atol: T,
521) -> FerrayResult<MaskedArray<T, D>>
522where
523    T: Element + Copy + Float,
524    D: Dimension,
525{
526    let threshold = atol + rtol * value.abs();
527    let mask: Vec<bool> = arr
528        .iter()
529        .map(|x| (*x - value).abs() <= threshold)
530        .collect();
531    let mask_arr = Array::from_vec(arr.dim().clone(), mask)?;
532    let data_arr = arr.clone();
533    MaskedArray::new(data_arr, mask_arr)
534}
535
536// ===========================================================================
537// Mask manipulation: harden / soften / mask_or / make_mask / make_mask_none
538// ===========================================================================
539
540// Note: `harden_mask` / `soften_mask` already live on `MaskedArray` via
541// `mask_ops.rs`. They're re-exported from this crate's root for parity
542// with `numpy.ma.MaskedArray.harden_mask` / `soften_mask`.
543
544/// Element-wise OR of two masks. Both must share the same shape.
545///
546/// Equivalent to `numpy.ma.mask_or(m1, m2)`.
547///
548/// # Errors
549/// Returns `FerrayError::ShapeMismatch` if shapes differ.
550pub fn mask_or<D: Dimension>(
551    a: &Array<bool, D>,
552    b: &Array<bool, D>,
553) -> FerrayResult<Array<bool, D>> {
554    if a.shape() != b.shape() {
555        return Err(FerrayError::shape_mismatch(format!(
556            "mask_or: shapes {:?} and {:?} differ",
557            a.shape(),
558            b.shape()
559        )));
560    }
561    let data: Vec<bool> = a.iter().zip(b.iter()).map(|(x, y)| *x || *y).collect();
562    Array::from_vec(a.dim().clone(), data)
563}
564
565/// Construct a boolean mask from a slice — convenience wrapper.
566///
567/// Equivalent to `numpy.ma.make_mask(values)` for the simple case of an
568/// existing bool array.
569///
570/// # Errors
571/// Returns an error if internal array construction fails.
572pub fn make_mask<D: Dimension>(values: &[bool], shape: D) -> FerrayResult<Array<bool, D>> {
573    Array::from_vec(shape, values.to_vec())
574}
575
576/// Build an all-`false` mask of the given shape.
577///
578/// Equivalent to `numpy.ma.make_mask_none(shape)`.
579///
580/// # Errors
581/// Returns an error if internal array construction fails.
582pub fn make_mask_none<D: Dimension>(shape: D) -> FerrayResult<Array<bool, D>> {
583    Array::from_elem(shape, false)
584}
585
586// ===========================================================================
587// Manipulation: concatenate / clip / repeat / atleast_*d / expand_dims /
588// swapaxes / diag / diagonal
589// ===========================================================================
590
591/// Concatenate two masked arrays along an axis. Both must have the same
592/// dimensionality. Mask is concatenated alongside data.
593///
594/// Equivalent to `numpy.ma.concatenate` for two-arg case.
595///
596/// # Errors
597/// Returns `FerrayError::ShapeMismatch` if shapes are incompatible.
598pub fn ma_concatenate<T>(
599    a: &MaskedArray<T, IxDyn>,
600    b: &MaskedArray<T, IxDyn>,
601    axis: usize,
602) -> FerrayResult<MaskedArray<T, IxDyn>>
603where
604    T: Element + Copy,
605{
606    let cat_data =
607        ferray_core::manipulation::concatenate(&[a.data().clone(), b.data().clone()], axis)?;
608    let cat_mask =
609        ferray_core::manipulation::concatenate(&[a.mask().clone(), b.mask().clone()], axis)?;
610    MaskedArray::new(cat_data, cat_mask)
611}
612
613impl<T, D> MaskedArray<T, D>
614where
615    T: Element + Copy + PartialOrd,
616    D: Dimension,
617{
618    /// Clip each unmasked element to `[a_min, a_max]`. Masked elements
619    /// pass through unchanged. Equivalent to `numpy.ma.clip`.
620    ///
621    /// # Errors
622    /// Returns an error if internal array construction fails.
623    pub fn clip(&self, a_min: T, a_max: T) -> FerrayResult<MaskedArray<T, D>> {
624        let data: Vec<T> = self
625            .data()
626            .iter()
627            .zip(self.mask().iter())
628            .map(|(v, m)| {
629                if *m {
630                    *v
631                } else if *v < a_min {
632                    a_min
633                } else if *v > a_max {
634                    a_max
635                } else {
636                    *v
637                }
638            })
639            .collect();
640        let data_arr = Array::from_vec(self.data().dim().clone(), data)?;
641        let mask_arr: Array<bool, D> = Array::from_vec(
642            self.mask().dim().clone(),
643            self.mask().iter().copied().collect(),
644        )?;
645        MaskedArray::new(data_arr, mask_arr)
646    }
647}
648
649impl<T, D> MaskedArray<T, D>
650where
651    T: Element + Copy,
652    D: Dimension,
653{
654    /// Repeat each unmasked element `repeats` times along the flat axis.
655    /// Always returns a 1-D masked array.
656    ///
657    /// Equivalent to `numpy.ma.repeat(arr, repeats)` with default flat axis.
658    ///
659    /// # Errors
660    /// Returns an error if internal array construction fails.
661    pub fn repeat(&self, repeats: usize) -> FerrayResult<MaskedArray<T, Ix1>> {
662        let n = self.size() * repeats;
663        let mut data = Vec::with_capacity(n);
664        let mut mask = Vec::with_capacity(n);
665        for (v, m) in self.data().iter().zip(self.mask().iter()) {
666            for _ in 0..repeats {
667                data.push(*v);
668                mask.push(*m);
669            }
670        }
671        let data_arr = Array::from_vec(Ix1::new([n]), data)?;
672        let mask_arr = Array::from_vec(Ix1::new([n]), mask)?;
673        MaskedArray::new(data_arr, mask_arr)
674    }
675
676    /// Promote to at least 1-D. Scalar (0-D) becomes shape `(1,)`.
677    ///
678    /// # Errors
679    /// Returns an error if internal array construction fails.
680    pub fn atleast_1d(&self) -> FerrayResult<MaskedArray<T, IxDyn>> {
681        let shape = self.shape();
682        let new_shape: Vec<usize> = if shape.is_empty() {
683            vec![1]
684        } else {
685            shape.to_vec()
686        };
687        let data: Vec<T> = self.data().iter().copied().collect();
688        let mask: Vec<bool> = self.mask().iter().copied().collect();
689        let data_arr = Array::from_vec(IxDyn::new(&new_shape), data)?;
690        let mask_arr = Array::from_vec(IxDyn::new(&new_shape), mask)?;
691        MaskedArray::new(data_arr, mask_arr)
692    }
693
694    /// Promote to at least 2-D. 0-D → (1,1); 1-D (N,) → (1, N).
695    ///
696    /// # Errors
697    /// Returns an error if internal array construction fails.
698    pub fn atleast_2d(&self) -> FerrayResult<MaskedArray<T, IxDyn>> {
699        let shape = self.shape();
700        let new_shape: Vec<usize> = match shape.len() {
701            0 => vec![1, 1],
702            1 => vec![1, shape[0]],
703            _ => shape.to_vec(),
704        };
705        let data: Vec<T> = self.data().iter().copied().collect();
706        let mask: Vec<bool> = self.mask().iter().copied().collect();
707        let data_arr = Array::from_vec(IxDyn::new(&new_shape), data)?;
708        let mask_arr = Array::from_vec(IxDyn::new(&new_shape), mask)?;
709        MaskedArray::new(data_arr, mask_arr)
710    }
711
712    /// Promote to at least 3-D. See [`atleast_3d`](crate::extras::MaskedArray::atleast_3d) for the rank ladder.
713    ///
714    /// # Errors
715    /// Returns an error if internal array construction fails.
716    pub fn atleast_3d(&self) -> FerrayResult<MaskedArray<T, IxDyn>> {
717        let shape = self.shape();
718        let new_shape: Vec<usize> = match shape.len() {
719            0 => vec![1, 1, 1],
720            1 => vec![1, shape[0], 1],
721            2 => vec![shape[0], shape[1], 1],
722            _ => shape.to_vec(),
723        };
724        let data: Vec<T> = self.data().iter().copied().collect();
725        let mask: Vec<bool> = self.mask().iter().copied().collect();
726        let data_arr = Array::from_vec(IxDyn::new(&new_shape), data)?;
727        let mask_arr = Array::from_vec(IxDyn::new(&new_shape), mask)?;
728        MaskedArray::new(data_arr, mask_arr)
729    }
730
731    /// Insert an axis of length 1 at the given position.
732    ///
733    /// # Errors
734    /// Returns `FerrayError::AxisOutOfBounds` if `axis > ndim`.
735    pub fn expand_dims(&self, axis: usize) -> FerrayResult<MaskedArray<T, IxDyn>> {
736        let data_exp = ferray_core::manipulation::expand_dims(self.data(), axis)?;
737        let mask_exp = ferray_core::manipulation::expand_dims(self.mask(), axis)?;
738        MaskedArray::new(data_exp, mask_exp)
739    }
740}
741
742impl<T> MaskedArray<T, Ix2>
743where
744    T: Element + Copy + num_traits::Zero,
745{
746    /// Extract the `k`-th diagonal of a 2-D masked array as a 1-D masked array.
747    ///
748    /// Equivalent to `numpy.ma.diagonal` for the 2-D case.
749    ///
750    /// # Errors
751    /// Returns an error if internal array construction fails.
752    pub fn diagonal(&self, k: isize) -> FerrayResult<MaskedArray<T, Ix1>> {
753        let shape = self.shape();
754        let (rows, cols) = (shape[0], shape[1]);
755        let (start_r, start_c) = if k >= 0 {
756            (0usize, k as usize)
757        } else {
758            (-k as usize, 0usize)
759        };
760        let mut data = Vec::new();
761        let mut mask = Vec::new();
762        let data_slice = self.data().as_slice();
763        let mask_slice = self.mask().as_slice();
764        let mut r = start_r;
765        let mut c = start_c;
766        while r < rows && c < cols {
767            let flat = r * cols + c;
768            // Use as_slice when available (C-contiguous), else fall back to iter().nth.
769            if let (Some(ds), Some(ms)) = (data_slice, mask_slice) {
770                data.push(ds[flat]);
771                mask.push(ms[flat]);
772            } else {
773                data.push(*self.data().iter().nth(flat).expect("flat index in bounds"));
774                mask.push(*self.mask().iter().nth(flat).expect("flat index in bounds"));
775            }
776            r += 1;
777            c += 1;
778        }
779        let n = data.len();
780        let data_arr = Array::from_vec(Ix1::new([n]), data)?;
781        let mask_arr = Array::from_vec(Ix1::new([n]), mask)?;
782        MaskedArray::new(data_arr, mask_arr)
783    }
784}
785
786// ===========================================================================
787// Linalg-lite (mask-aware via fill-zero)
788// ===========================================================================
789
790impl<T, D> MaskedArray<T, D>
791where
792    T: Element + Copy + std::ops::Add<Output = T> + std::ops::Mul<Output = T> + num_traits::Zero,
793    D: Dimension,
794{
795    /// Inner product of two masked arrays as flat 1-D vectors. Masked
796    /// positions on either side contribute zero.
797    ///
798    /// # Errors
799    /// Returns `FerrayError::ShapeMismatch` if total element counts differ.
800    pub fn ma_dot_flat(&self, other: &MaskedArray<T, D>) -> FerrayResult<T> {
801        if self.size() != other.size() {
802            return Err(FerrayError::shape_mismatch(format!(
803                "ma_dot_flat: lhs.size()={} != rhs.size()={}",
804                self.size(),
805                other.size(),
806            )));
807        }
808        let zero = <T as num_traits::Zero>::zero();
809        let s = self
810            .data()
811            .iter()
812            .zip(self.mask().iter())
813            .zip(other.data().iter().zip(other.mask().iter()))
814            .fold(
815                zero,
816                |acc, ((a, ma), (b, mb))| {
817                    if *ma || *mb { acc } else { acc + *a * *b }
818                },
819            );
820        Ok(s)
821    }
822}
823
824impl<T> MaskedArray<T, Ix2>
825where
826    T: Element + Copy + std::ops::Add<Output = T> + std::ops::Mul<Output = T> + num_traits::Zero,
827{
828    /// Trace (sum of diagonal) of a 2-D masked array. Masked diagonal
829    /// elements contribute zero.
830    pub fn trace(&self, k: isize) -> FerrayResult<T> {
831        let diag = self.diagonal(k)?;
832        let zero = <T as num_traits::Zero>::zero();
833        let s = diag
834            .data()
835            .iter()
836            .zip(diag.mask().iter())
837            .filter(|(_, m)| !**m)
838            .fold(zero, |acc, (v, _)| acc + *v);
839        Ok(s)
840    }
841}
842
843// ===========================================================================
844// Set ops on the unmasked subset
845// ===========================================================================
846
847/// Sorted unique unmasked values of `ma` as a plain `Array<T, Ix1>` (not a
848/// MaskedArray — the result has no masked entries by construction).
849///
850/// Equivalent to `numpy.ma.unique(ma)`.
851///
852/// # Errors
853/// Returns an error if internal array construction fails.
854pub fn ma_unique<T, D>(ma: &MaskedArray<T, D>) -> FerrayResult<Array<T, Ix1>>
855where
856    T: Element + Copy + PartialOrd,
857    D: Dimension,
858{
859    let mut vals: Vec<T> = ma
860        .data()
861        .iter()
862        .zip(ma.mask().iter())
863        .filter(|(_, m)| !**m)
864        .map(|(v, _)| *v)
865        .collect();
866    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
867    vals.dedup_by(|a, b| (*a).partial_cmp(&*b) == Some(std::cmp::Ordering::Equal));
868    let n = vals.len();
869    Array::from_vec(Ix1::new([n]), vals)
870}
871
872/// Per-element membership test against `test_values`, matching
873/// `numpy.ma.isin` / `numpy.ma.in1d` (`numpy/ma/extras.py:1434` / `:1387`).
874///
875/// numpy.ma.in1d routes through `ma.unique(ar1, return_inverse=True)`, which
876/// collapses every masked input to a single trailing **masked** unique
877/// element whose membership is `False`. The result therefore:
878/// - **carries the input mask** (masked input positions stay masked), and
879/// - at every masked position the underlying boolean is **`False`** (a masked
880///   value is never reported as a member).
881///
882/// Live oracle (numpy 2.4.5):
883/// `np.ma.isin(np.ma.array([1.,2,3,4],mask=[0,1,0,0]),[2,3])` → underlying data
884/// `[False, False, True, False]`, mask `[F, T, F, F]`. Position 1 (masked
885/// input value `2`, which *is* in the test set) still reports `False` because
886/// numpy treats the masked element as the masked-unique slot.
887///
888/// The prior ferray implementation reported the raw-value membership
889/// (`True`) at masked positions; this corrects the masked-position data to
890/// `False` to match numpy's `unique`-based path (R-HONEST-4).
891///
892/// # Errors
893/// Returns an error if internal array construction fails.
894pub fn ma_isin<T, D>(
895    ma: &MaskedArray<T, D>,
896    test_values: &[T],
897) -> FerrayResult<MaskedArray<bool, D>>
898where
899    T: Element + Copy + PartialEq,
900    D: Dimension,
901{
902    let mask: Vec<bool> = ma.mask().iter().copied().collect();
903    let data: Vec<bool> = ma
904        .data()
905        .iter()
906        .zip(mask.iter())
907        .map(|(v, &m)| {
908            // A masked input is the masked-unique element -> never a member.
909            !m && test_values.iter().any(|t| t == v)
910        })
911        .collect();
912    let data_arr = Array::from_vec(ma.data().dim().clone(), data)?;
913    let mask_arr: Array<bool, D> = Array::from_vec(ma.mask().dim().clone(), mask)?;
914    MaskedArray::new(data_arr, mask_arr)
915}
916
917/// 1-D variant of [`ma_isin`].
918///
919/// Equivalent to `numpy.ma.in1d`.
920///
921/// # Errors
922/// Returns an error if internal array construction fails.
923pub fn ma_in1d<T>(
924    ma: &MaskedArray<T, Ix1>,
925    test_values: &[T],
926) -> FerrayResult<MaskedArray<bool, Ix1>>
927where
928    T: Element + Copy + PartialEq,
929{
930    ma_isin(ma, test_values)
931}
932
933// ===========================================================================
934// Masked set operations (numpy.ma.unique / intersect1d / union1d /
935// setdiff1d / setxor1d). All operate on the *unmasked* unique values, with a
936// single trailing `masked` element iff any input element was masked — exactly
937// `numpy.ma.unique`'s observable contract (numpy/ma/extras.py:1267): masked
938// values "are considered the same element (masked)" and collapse to one slot.
939// ===========================================================================
940
941/// Sorted unique unmasked values of `ma` plus one trailing **masked** slot iff
942/// the input contained any masked element — i.e. `numpy.ma.unique(ma)`
943/// (numpy/ma/extras.py:1267).
944///
945/// The returned [`MaskedArray<T, Ix1>`] carries a mask whose only `true` entry
946/// (if present) is the final element. The data value behind that masked slot is
947/// not observable; the first masked input value is reused as a placeholder.
948///
949/// # Errors
950/// Returns an error if internal array construction fails.
951pub fn ma_unique_masked<T, D>(ma: &MaskedArray<T, D>) -> FerrayResult<MaskedArray<T, Ix1>>
952where
953    T: Element + Copy + PartialOrd,
954    D: Dimension,
955{
956    let mut vals: Vec<T> = ma
957        .data()
958        .iter()
959        .zip(ma.mask().iter())
960        .filter(|(_, m)| !**m)
961        .map(|(v, _)| *v)
962        .collect();
963    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
964    vals.dedup_by(|a, b| (*a).partial_cmp(&*b) == Some(std::cmp::Ordering::Equal));
965
966    let mut mask = vec![false; vals.len()];
967    // A single trailing masked slot iff any input was masked.
968    if let Some(pos) = ma.mask().iter().position(|&m| m) {
969        if let Some(placeholder) = ma.data().iter().nth(pos) {
970            vals.push(*placeholder);
971            mask.push(true);
972        }
973    }
974    let n = vals.len();
975    let data_arr = Array::<T, Ix1>::from_vec(Ix1::new([n]), vals)?;
976    let mask_arr = Array::<bool, Ix1>::from_vec(Ix1::new([n]), mask)?;
977    MaskedArray::new(data_arr, mask_arr)
978}
979
980/// Build a `MaskedArray<T, Ix1>` from explicit `(value, is_masked)` pairs.
981fn build_ma_ix1<T>(pairs: Vec<(T, bool)>) -> FerrayResult<MaskedArray<T, Ix1>>
982where
983    T: Element + Copy,
984{
985    let n = pairs.len();
986    let data: Vec<T> = pairs.iter().map(|(v, _)| *v).collect();
987    let mask: Vec<bool> = pairs.iter().map(|(_, m)| *m).collect();
988    let data_arr = Array::<T, Ix1>::from_vec(Ix1::new([n]), data)?;
989    let mask_arr = Array::<bool, Ix1>::from_vec(Ix1::new([n]), mask)?;
990    MaskedArray::new(data_arr, mask_arr)
991}
992
993/// Collect the unmasked unique values and the "any masked" flag of a
994/// `MaskedArray` (the two ingredients every masked set op consumes).
995fn unique_parts<T, D>(ma: &MaskedArray<T, D>) -> (Vec<T>, bool)
996where
997    T: Element + Copy + PartialOrd,
998    D: Dimension,
999{
1000    let mut vals: Vec<T> = ma
1001        .data()
1002        .iter()
1003        .zip(ma.mask().iter())
1004        .filter(|(_, m)| !**m)
1005        .map(|(v, _)| *v)
1006        .collect();
1007    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1008    vals.dedup_by(|a, b| (*a).partial_cmp(&*b) == Some(std::cmp::Ordering::Equal));
1009    let any_masked = ma.mask().iter().any(|&m| m);
1010    (vals, any_masked)
1011}
1012
1013/// `numpy.ma.intersect1d(ar1, ar2)` — sorted unique values common to both
1014/// (numpy/ma/extras.py:1317). Masked values are considered equal to each other,
1015/// so a masked slot appears in the result iff **both** inputs were masked.
1016///
1017/// numpy concatenates `unique(ar1)` and `unique(ar2)`, sorts, and keeps an
1018/// element iff it equals its successor — i.e. it appears in both. With each
1019/// input contributing at most one masked slot, the masked slot survives iff
1020/// both contributed one.
1021///
1022/// # Errors
1023/// Returns an error if internal array construction fails.
1024pub fn ma_intersect1d<T, D>(
1025    ar1: &MaskedArray<T, D>,
1026    ar2: &MaskedArray<T, D>,
1027) -> FerrayResult<MaskedArray<T, Ix1>>
1028where
1029    T: Element + Copy + PartialOrd,
1030    D: Dimension,
1031{
1032    let (v1, m1) = unique_parts(ar1);
1033    let (v2, m2) = unique_parts(ar2);
1034    // Intersection of two sorted unique unmasked sequences (merge walk).
1035    let mut out: Vec<(T, bool)> = Vec::new();
1036    let (mut i, mut j) = (0usize, 0usize);
1037    while i < v1.len() && j < v2.len() {
1038        match v1[i].partial_cmp(&v2[j]) {
1039            Some(std::cmp::Ordering::Equal) => {
1040                out.push((v1[i], false));
1041                i += 1;
1042                j += 1;
1043            }
1044            Some(std::cmp::Ordering::Less) => i += 1,
1045            Some(std::cmp::Ordering::Greater) => j += 1,
1046            None => {
1047                // NaN: skip the left element (NaN never equals anything).
1048                i += 1;
1049            }
1050        }
1051    }
1052    // Masked slot is common iff both inputs were masked.
1053    if m1 && m2 {
1054        let placeholder = first_masked_value(ar1).or_else(|| first_masked_value(ar2));
1055        if let Some(p) = placeholder {
1056            out.push((p, true));
1057        }
1058    }
1059    build_ma_ix1(out)
1060}
1061
1062/// `numpy.ma.union1d(ar1, ar2)` — sorted unique union
1063/// (numpy/ma/extras.py:1463): `unique(concatenate((ar1, ar2)))`. A trailing
1064/// masked slot appears iff **either** input was masked.
1065///
1066/// # Errors
1067/// Returns an error if internal array construction fails.
1068pub fn ma_union1d<T, D>(
1069    ar1: &MaskedArray<T, D>,
1070    ar2: &MaskedArray<T, D>,
1071) -> FerrayResult<MaskedArray<T, Ix1>>
1072where
1073    T: Element + Copy + PartialOrd,
1074    D: Dimension,
1075{
1076    let (v1, m1) = unique_parts(ar1);
1077    let (v2, m2) = unique_parts(ar2);
1078    let mut vals: Vec<T> = v1;
1079    vals.extend(v2);
1080    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1081    vals.dedup_by(|a, b| (*a).partial_cmp(&*b) == Some(std::cmp::Ordering::Equal));
1082    let mut out: Vec<(T, bool)> = vals.into_iter().map(|v| (v, false)).collect();
1083    if m1 || m2 {
1084        let placeholder = first_masked_value(ar1).or_else(|| first_masked_value(ar2));
1085        if let Some(p) = placeholder {
1086            out.push((p, true));
1087        }
1088    }
1089    build_ma_ix1(out)
1090}
1091
1092/// `numpy.ma.setdiff1d(ar1, ar2)` — sorted unique values in `ar1` not in `ar2`
1093/// (numpy/ma/extras.py:1485): `unique(ar1)[in1d(unique(ar1), unique(ar2),
1094/// invert=True)]`. The masked slot of `ar1` survives iff `ar2` was **not**
1095/// masked (a masked `ar2` element removes the masked `ar1` slot, since masked
1096/// values are equal).
1097///
1098/// # Errors
1099/// Returns an error if internal array construction fails.
1100pub fn ma_setdiff1d<T, D>(
1101    ar1: &MaskedArray<T, D>,
1102    ar2: &MaskedArray<T, D>,
1103) -> FerrayResult<MaskedArray<T, Ix1>>
1104where
1105    T: Element + Copy + PartialOrd,
1106    D: Dimension,
1107{
1108    let (v1, m1) = unique_parts(ar1);
1109    let (v2, _m2) = unique_parts(ar2);
1110    let m2_masked = ar2.mask().iter().any(|&m| m);
1111    let mut out: Vec<(T, bool)> = Vec::new();
1112    for v in v1 {
1113        let present = v2
1114            .iter()
1115            .any(|w| v.partial_cmp(w) == Some(std::cmp::Ordering::Equal));
1116        if !present {
1117            out.push((v, false));
1118        }
1119    }
1120    if m1 && !m2_masked {
1121        if let Some(p) = first_masked_value(ar1) {
1122            out.push((p, true));
1123        }
1124    }
1125    build_ma_ix1(out)
1126}
1127
1128/// `numpy.ma.setxor1d(ar1, ar2)` — symmetric difference of the unique values
1129/// (numpy/ma/extras.py:1350): elements present in exactly one input. The masked
1130/// slot survives iff exactly one input was masked.
1131///
1132/// # Errors
1133/// Returns an error if internal array construction fails.
1134pub fn ma_setxor1d<T, D>(
1135    ar1: &MaskedArray<T, D>,
1136    ar2: &MaskedArray<T, D>,
1137) -> FerrayResult<MaskedArray<T, Ix1>>
1138where
1139    T: Element + Copy + PartialOrd,
1140    D: Dimension,
1141{
1142    let (v1, m1) = unique_parts(ar1);
1143    let (v2, m2) = unique_parts(ar2);
1144    let mut out: Vec<(T, bool)> = Vec::new();
1145    // Elements in v1 not in v2.
1146    for v in &v1 {
1147        if !v2
1148            .iter()
1149            .any(|w| v.partial_cmp(w) == Some(std::cmp::Ordering::Equal))
1150        {
1151            out.push((*v, false));
1152        }
1153    }
1154    // Elements in v2 not in v1.
1155    for v in &v2 {
1156        if !v1
1157            .iter()
1158            .any(|w| v.partial_cmp(w) == Some(std::cmp::Ordering::Equal))
1159        {
1160            out.push((*v, false));
1161        }
1162    }
1163    out.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1164    // Masked slot present in exactly one input -> survives the xor.
1165    if m1 ^ m2 {
1166        let placeholder = first_masked_value(ar1).or_else(|| first_masked_value(ar2));
1167        if let Some(p) = placeholder {
1168            out.push((p, true));
1169        }
1170    }
1171    build_ma_ix1(out)
1172}
1173
1174/// The first underlying value at a masked position, if any.
1175fn first_masked_value<T, D>(ma: &MaskedArray<T, D>) -> Option<T>
1176where
1177    T: Element + Copy,
1178    D: Dimension,
1179{
1180    ma.mask()
1181        .iter()
1182        .position(|&m| m)
1183        .and_then(|pos| ma.data().iter().nth(pos).copied())
1184}
1185
1186// ===========================================================================
1187// Row/column suppression and masking (numpy.ma.compress_rowcols /
1188// compress_rows / compress_cols / mask_rowcols). 2-D only — numpy raises
1189// NotImplementedError otherwise (numpy/ma/extras.py:920 / :830).
1190// ===========================================================================
1191
1192/// `numpy.ma.compress_rowcols(x, axis)` — suppress whole rows and/or columns of
1193/// a 2-D masked array that contain any masked value
1194/// (numpy/ma/extras.py:920, via `compress_nd`).
1195///
1196/// - `axis = None` → drop both rows and columns containing a masked value.
1197/// - `axis = Some(0)` → drop only rows.
1198/// - `axis = Some(1)` → drop only columns.
1199///
1200/// Returns a plain (unmasked) [`Array<T, Ix2>`] of the surviving data.
1201///
1202/// # Errors
1203/// Returns `FerrayError::invalid_value` if the input is not 2-D (numpy raises
1204/// `NotImplementedError`); or if internal array construction fails.
1205pub fn ma_compress_rowcols<T>(
1206    ma: &MaskedArray<T, Ix2>,
1207    axis: Option<usize>,
1208) -> FerrayResult<Array<T, Ix2>>
1209where
1210    T: Element + Copy,
1211{
1212    let shape = ma.data().shape();
1213    let (nrows, ncols) = (shape[0], shape[1]);
1214    let data: Vec<T> = ma.data().iter().copied().collect();
1215    let mask: Vec<bool> = ma.mask().iter().copied().collect();
1216
1217    let drop_rows = matches!(axis, None | Some(0));
1218    let drop_cols = matches!(axis, None | Some(1));
1219
1220    // Rows/cols to keep.
1221    let mut keep_row = vec![true; nrows];
1222    let mut keep_col = vec![true; ncols];
1223    if drop_rows {
1224        for (r, kr) in keep_row.iter_mut().enumerate() {
1225            *kr = !(0..ncols).any(|c| mask[r * ncols + c]);
1226        }
1227    }
1228    if drop_cols {
1229        for (c, kc) in keep_col.iter_mut().enumerate() {
1230            *kc = !(0..nrows).any(|r| mask[r * ncols + c]);
1231        }
1232    }
1233
1234    let kept_rows: Vec<usize> = (0..nrows).filter(|&r| keep_row[r]).collect();
1235    let kept_cols: Vec<usize> = (0..ncols).filter(|&c| keep_col[c]).collect();
1236
1237    let mut out: Vec<T> = Vec::with_capacity(kept_rows.len() * kept_cols.len());
1238    for &r in &kept_rows {
1239        for &c in &kept_cols {
1240            out.push(data[r * ncols + c]);
1241        }
1242    }
1243    Array::<T, Ix2>::from_vec(Ix2::new([kept_rows.len(), kept_cols.len()]), out)
1244}
1245
1246/// `numpy.ma.compress_rows(a)` — suppress whole rows containing masked values;
1247/// equivalent to `compress_rowcols(a, 0)` (numpy/ma/extras.py:953).
1248///
1249/// # Errors
1250/// Propagates [`ma_compress_rowcols`] errors.
1251pub fn ma_compress_rows<T>(ma: &MaskedArray<T, Ix2>) -> FerrayResult<Array<T, Ix2>>
1252where
1253    T: Element + Copy,
1254{
1255    ma_compress_rowcols(ma, Some(0))
1256}
1257
1258/// `numpy.ma.compress_cols(a)` — suppress whole columns containing masked
1259/// values; equivalent to `compress_rowcols(a, 1)` (numpy/ma/extras.py:991).
1260///
1261/// # Errors
1262/// Propagates [`ma_compress_rowcols`] errors.
1263pub fn ma_compress_cols<T>(ma: &MaskedArray<T, Ix2>) -> FerrayResult<Array<T, Ix2>>
1264where
1265    T: Element + Copy,
1266{
1267    ma_compress_rowcols(ma, Some(1))
1268}
1269
1270/// `numpy.ma.mask_rowcols(a, axis)` — mask whole rows and/or columns of a 2-D
1271/// masked array that contain any masked value (numpy/ma/extras.py:830).
1272///
1273/// - `axis = None` → mask both rows and columns containing a masked value.
1274/// - `axis = Some(0)` → mask only rows.
1275/// - `axis = Some(1)` → mask only columns.
1276///
1277/// Returns a [`MaskedArray<T, Ix2>`] with the same data and an expanded mask.
1278///
1279/// # Errors
1280/// Returns an error if internal array construction fails.
1281pub fn ma_mask_rowcols<T>(
1282    ma: &MaskedArray<T, Ix2>,
1283    axis: Option<usize>,
1284) -> FerrayResult<MaskedArray<T, Ix2>>
1285where
1286    T: Element + Copy,
1287{
1288    let shape = ma.data().shape();
1289    let (nrows, ncols) = (shape[0], shape[1]);
1290    let data: Vec<T> = ma.data().iter().copied().collect();
1291    let mask: Vec<bool> = ma.mask().iter().copied().collect();
1292
1293    let mask_rows = matches!(axis, None | Some(0));
1294    let mask_cols = matches!(axis, None | Some(1));
1295
1296    let row_has_mask: Vec<bool> = (0..nrows)
1297        .map(|r| (0..ncols).any(|c| mask[r * ncols + c]))
1298        .collect();
1299    let col_has_mask: Vec<bool> = (0..ncols)
1300        .map(|c| (0..nrows).any(|r| mask[r * ncols + c]))
1301        .collect();
1302
1303    let mut new_mask = mask.clone();
1304    for r in 0..nrows {
1305        for c in 0..ncols {
1306            if (mask_rows && row_has_mask[r]) || (mask_cols && col_has_mask[c]) {
1307                new_mask[r * ncols + c] = true;
1308            }
1309        }
1310    }
1311
1312    let data_arr = Array::<T, Ix2>::from_vec(Ix2::new([nrows, ncols]), data)?;
1313    let mask_arr = Array::<bool, Ix2>::from_vec(Ix2::new([nrows, ncols]), new_mask)?;
1314    MaskedArray::new(data_arr, mask_arr)
1315}
1316
1317// ===========================================================================
1318// Masked covariance / correlation (numpy.ma.cov / corrcoef). Common case
1319// y=None, computed over the unmasked pairs (numpy/ma/extras.py:1519
1320// `_covhelper`). Each row is centered by its own masked mean; the pairwise
1321// normalisation `fact[i,j]` is the count of jointly-unmasked observations,
1322// and an entry is masked where `fact <= 0`.
1323// ===========================================================================
1324
1325/// `numpy.ma.cov(x, rowvar, bias, ddof)` for the `y=None` case
1326/// (numpy/ma/extras.py:1547). Returns the masked covariance matrix computed
1327/// over the unmasked observation pairs.
1328///
1329/// Each variable (row when `rowvar`, column otherwise) is centered by the mean
1330/// of its own unmasked observations. The `[i,j]` entry sums the product of the
1331/// centered values over the columns where **both** `i` and `j` are unmasked,
1332/// divided by `fact[i,j] = (#jointly-unmasked) - ddof`. When `ddof` is `None`,
1333/// it defaults to `0` if `bias`, else `1` (numpy/ma/extras.py:1648). An entry
1334/// is masked iff `fact[i,j] <= 0`.
1335///
1336/// # Errors
1337/// Returns `FerrayError::invalid_value` if the input is not 1-D or 2-D; or if
1338/// internal array construction fails.
1339pub fn ma_cov<D>(
1340    x: &MaskedArray<f64, D>,
1341    rowvar: bool,
1342    bias: bool,
1343    ddof: Option<usize>,
1344) -> FerrayResult<MaskedArray<f64, Ix2>>
1345where
1346    D: Dimension,
1347{
1348    let ddof_val = ddof.unwrap_or(if bias { 0 } else { 1 }) as f64;
1349
1350    // Build the (nvars x nobs) data + not-mask matrices (rows = variables).
1351    let ndim = x.ndim();
1352    if ndim > 2 {
1353        return Err(FerrayError::invalid_value(
1354            "ma.cov requires a 1-D or 2-D masked array",
1355        ));
1356    }
1357    let shape = x.data().shape();
1358    let flat_data: Vec<f64> = x.data().iter().copied().collect();
1359    let flat_mask: Vec<bool> = x.mask().iter().copied().collect();
1360
1361    // Build a row-major (nvars x nobs) data + mask matrix. numpy promotes 1-D
1362    // input to a single row (ndmin=2); for a single row, rowvar is forced True
1363    // (numpy/ma/extras.py: "if x.shape[0] == 1: rowvar = True").
1364    let (nvars, nobs, mat_data, mat_mask): (usize, usize, Vec<f64>, Vec<bool>) = if ndim <= 1 {
1365        let n = flat_data.len();
1366        (1, n, flat_data, flat_mask)
1367    } else {
1368        let (r, c) = (shape[0], shape[1]);
1369        let eff_rowvar = rowvar || r == 1;
1370        if eff_rowvar {
1371            (r, c, flat_data, flat_mask)
1372        } else {
1373            // Transpose so variables become rows.
1374            let mut td = vec![0.0f64; r * c];
1375            let mut tm = vec![false; r * c];
1376            for i in 0..c {
1377                for k in 0..r {
1378                    td[i * r + k] = flat_data[k * c + i];
1379                    tm[i * r + k] = flat_mask[k * c + i];
1380                }
1381            }
1382            (c, r, td, tm)
1383        }
1384    };
1385
1386    // Center each variable by the mean of its own unmasked observations.
1387    let mut centered = vec![0.0f64; nvars * nobs];
1388    let mut notmask = vec![0.0f64; nvars * nobs];
1389    for i in 0..nvars {
1390        let mut sum = 0.0;
1391        let mut cnt = 0.0;
1392        for k in 0..nobs {
1393            let idx = i * nobs + k;
1394            if !mat_mask[idx] {
1395                sum += mat_data[idx];
1396                cnt += 1.0;
1397                notmask[idx] = 1.0;
1398            }
1399        }
1400        let mean = if cnt > 0.0 { sum / cnt } else { 0.0 };
1401        for k in 0..nobs {
1402            let idx = i * nobs + k;
1403            // filled(x, 0): masked positions contribute 0 to the dot product.
1404            centered[idx] = if mat_mask[idx] {
1405                0.0
1406            } else {
1407                mat_data[idx] - mean
1408            };
1409        }
1410    }
1411
1412    // cov[i,j] = (centered_i . centered_j) / (notmask_i . notmask_j - ddof),
1413    // masked where fact <= 0.
1414    let mut cov_data = vec![0.0f64; nvars * nvars];
1415    let mut cov_mask = vec![false; nvars * nvars];
1416    for i in 0..nvars {
1417        for j in i..nvars {
1418            let mut dot = 0.0;
1419            let mut fact = 0.0;
1420            for k in 0..nobs {
1421                dot += centered[i * nobs + k] * centered[j * nobs + k];
1422                fact += notmask[i * nobs + k] * notmask[j * nobs + k];
1423            }
1424            fact -= ddof_val;
1425            let (val, masked) = if fact <= 0.0 {
1426                (0.0, true)
1427            } else {
1428                (dot / fact, false)
1429            };
1430            cov_data[i * nvars + j] = val;
1431            cov_data[j * nvars + i] = val;
1432            cov_mask[i * nvars + j] = masked;
1433            cov_mask[j * nvars + i] = masked;
1434        }
1435    }
1436
1437    let data_arr = Array::<f64, Ix2>::from_vec(Ix2::new([nvars, nvars]), cov_data)?;
1438    let mask_arr = Array::<bool, Ix2>::from_vec(Ix2::new([nvars, nvars]), cov_mask)?;
1439    MaskedArray::new(data_arr, mask_arr)
1440}
1441
1442/// `numpy.ma.corrcoef(x, rowvar)` for the `y=None` case
1443/// (numpy/ma/extras.py:1672). The covariance matrix (computed with the default
1444/// `bias=False`, `ddof=None` normalisation) divided by the outer product of the
1445/// per-variable standard deviations `sqrt(diag(cov))`.
1446///
1447/// A `corr[i,j]` is masked iff `cov[i,j]` is masked or either `std[i]`/`std[j]`
1448/// is masked (numpy propagates the masked covariance through the division).
1449///
1450/// # Errors
1451/// Returns an error if [`ma_cov`] fails or internal array construction fails.
1452pub fn ma_corrcoef<D>(x: &MaskedArray<f64, D>, rowvar: bool) -> FerrayResult<MaskedArray<f64, Ix2>>
1453where
1454    D: Dimension,
1455{
1456    let cov = ma_cov(x, rowvar, false, None)?;
1457    let n = cov.data().shape()[0];
1458    let cov_data: Vec<f64> = cov.data().iter().copied().collect();
1459    let cov_mask: Vec<bool> = cov.mask().iter().copied().collect();
1460
1461    // Per-variable std = sqrt(diag(cov)); masked iff the diagonal is masked.
1462    let mut std = vec![0.0f64; n];
1463    let mut std_masked = vec![false; n];
1464    for i in 0..n {
1465        std_masked[i] = cov_mask[i * n + i];
1466        std[i] = cov_data[i * n + i].sqrt();
1467    }
1468
1469    let mut corr_data = vec![0.0f64; n * n];
1470    let mut corr_mask = vec![false; n * n];
1471    for i in 0..n {
1472        for j in 0..n {
1473            let d = std[i] * std[j];
1474            let masked = cov_mask[i * n + j] || std_masked[i] || std_masked[j] || d == 0.0;
1475            if masked {
1476                corr_mask[i * n + j] = true;
1477                corr_data[i * n + j] = 0.0;
1478            } else {
1479                // Clamp to [-1, 1] for numerical stability (matches the
1480                // ferray-stats corrcoef convention).
1481                let v = cov_data[i * n + j] / d;
1482                corr_data[i * n + j] = v.clamp(-1.0, 1.0);
1483            }
1484        }
1485    }
1486
1487    let data_arr = Array::<f64, Ix2>::from_vec(Ix2::new([n, n]), corr_data)?;
1488    let mask_arr = Array::<bool, Ix2>::from_vec(Ix2::new([n, n]), corr_mask)?;
1489    MaskedArray::new(data_arr, mask_arr)
1490}
1491
1492// ===========================================================================
1493// Functional helpers: apply_along_axis / apply_over_axes / vander
1494// ===========================================================================
1495
1496/// Apply a function `f` to every 1-D slice along `axis`, collecting the
1497/// scalar result of each into an `(ndim - 1)`-D masked array.
1498///
1499/// `f` receives a [`MaskedArray<T, Ix1>`] view (rebuilt each call) and
1500/// returns a tuple `(value, masked)` indicating the reduction's output and
1501/// whether to mark the output position as masked.
1502///
1503/// # Errors
1504/// Returns `FerrayError::AxisOutOfBounds` if `axis >= ndim`, or any error
1505/// from `f`.
1506pub fn ma_apply_along_axis<T, F>(
1507    ma: &MaskedArray<T, IxDyn>,
1508    axis: usize,
1509    mut f: F,
1510) -> FerrayResult<MaskedArray<T, IxDyn>>
1511where
1512    T: Element + Copy,
1513    F: FnMut(&MaskedArray<T, Ix1>) -> FerrayResult<(T, bool)>,
1514{
1515    let shape = ma.shape().to_vec();
1516    if axis >= shape.len() {
1517        return Err(FerrayError::axis_out_of_bounds(axis, shape.len()));
1518    }
1519    let lane_len = shape[axis];
1520    let out_shape: Vec<usize> = shape
1521        .iter()
1522        .enumerate()
1523        .filter(|&(i, _)| i != axis)
1524        .map(|(_, &s)| s)
1525        .collect();
1526    let out_size: usize = out_shape.iter().product::<usize>().max(1);
1527
1528    let ndim = shape.len();
1529    let mut strides = vec![1usize; ndim];
1530    for i in (0..ndim.saturating_sub(1)).rev() {
1531        strides[i] = strides[i + 1] * shape[i + 1];
1532    }
1533
1534    let data: Vec<T> = ma.data().iter().copied().collect();
1535    let mask: Vec<bool> = ma.mask().iter().copied().collect();
1536
1537    let mut out_data = Vec::with_capacity(out_size);
1538    let mut out_mask = Vec::with_capacity(out_size);
1539    let mut out_multi = vec![0usize; out_shape.len()];
1540
1541    for _ in 0..out_size {
1542        // Build the lane.
1543        let mut lane_data = Vec::with_capacity(lane_len);
1544        let mut lane_mask = Vec::with_capacity(lane_len);
1545        let mut full_idx = vec![0usize; ndim];
1546        // Map out_multi -> full_idx (skipping axis).
1547        let mut oi = 0;
1548        for (d, slot) in full_idx.iter_mut().enumerate() {
1549            if d == axis {
1550                continue;
1551            }
1552            *slot = out_multi[oi];
1553            oi += 1;
1554        }
1555        for j in 0..lane_len {
1556            full_idx[axis] = j;
1557            let flat: usize = full_idx
1558                .iter()
1559                .zip(strides.iter())
1560                .map(|(i, s)| i * s)
1561                .sum();
1562            lane_data.push(data[flat]);
1563            lane_mask.push(mask[flat]);
1564        }
1565        let lane_data_arr = Array::from_vec(Ix1::new([lane_len]), lane_data)?;
1566        let lane_mask_arr = Array::from_vec(Ix1::new([lane_len]), lane_mask)?;
1567        let lane_ma = MaskedArray::new(lane_data_arr, lane_mask_arr)?;
1568        let (val, masked) = f(&lane_ma)?;
1569        out_data.push(val);
1570        out_mask.push(masked);
1571
1572        // Advance multi-index over output dimensions.
1573        for d in (0..out_shape.len()).rev() {
1574            out_multi[d] += 1;
1575            if out_multi[d] < out_shape[d] {
1576                break;
1577            }
1578            out_multi[d] = 0;
1579        }
1580    }
1581
1582    let data_arr = Array::from_vec(IxDyn::new(&out_shape), out_data)?;
1583    let mask_arr = Array::from_vec(IxDyn::new(&out_shape), out_mask)?;
1584    MaskedArray::new(data_arr, mask_arr)
1585}
1586
1587/// Apply `f` repeatedly, reducing over the given axes in succession.
1588///
1589/// Each axis in `axes` is reduced via [`ma_apply_along_axis`] in order,
1590/// with subsequent axis indices adjusted for previous reductions.
1591///
1592/// # Errors
1593/// Returns errors from [`ma_apply_along_axis`].
1594pub fn ma_apply_over_axes<T, F>(
1595    ma: &MaskedArray<T, IxDyn>,
1596    axes: &[usize],
1597    mut f: F,
1598) -> FerrayResult<MaskedArray<T, IxDyn>>
1599where
1600    T: Element + Copy,
1601    F: FnMut(&MaskedArray<T, Ix1>) -> FerrayResult<(T, bool)>,
1602{
1603    let mut result = ma.clone();
1604    let mut sorted: Vec<usize> = axes.to_vec();
1605    sorted.sort_unstable();
1606    for (offset, &ax) in sorted.iter().enumerate() {
1607        // Each previous reduction collapsed an earlier axis, so shift
1608        // subsequent axis indices left by the number already consumed.
1609        let adjusted = ax.saturating_sub(offset);
1610        result = ma_apply_along_axis(&result, adjusted, &mut f)?;
1611    }
1612    Ok(result)
1613}
1614
1615/// Vandermonde matrix from a 1-D masked input, matching `numpy.ma.vander`.
1616///
1617/// numpy.ma.vander (`numpy/ma/extras.py:2216`):
1618/// ```text
1619/// _vander = np.vander(x, n)
1620/// m = getmask(x)
1621/// if m is not nomask:
1622///     _vander[m] = 0
1623/// return _vander
1624/// ```
1625/// The plain (unmasked) Vandermonde of the raw data is built first, then
1626/// **every masked row is set to all-zeros**, and the result carries **no
1627/// mask** (it is a plain ndarray returned through the `ma` namespace). Live
1628/// oracle (numpy 2.4.5): `np.ma.vander(np.ma.array([1.,2,3],mask=[0,1,0]),3)` →
1629/// `[[1,1,1],[0,0,0],[9,3,1]]`, `getmaskarray(...) == all False`.
1630///
1631/// # Errors
1632/// Returns `FerrayError::InvalidValue` if `x` is empty.
1633pub fn ma_vander<T>(x: &MaskedArray<T, Ix1>, n: Option<usize>) -> FerrayResult<MaskedArray<T, Ix2>>
1634where
1635    T: Element + Copy + std::ops::Mul<Output = T> + num_traits::One + num_traits::Zero,
1636{
1637    let m = x.size();
1638    if m == 0 {
1639        return Err(FerrayError::invalid_value(
1640            "ma_vander: input must not be empty",
1641        ));
1642    }
1643    let cols = n.unwrap_or(m);
1644    let xs: Vec<T> = x.data().iter().copied().collect();
1645    let xmask: Vec<bool> = x.mask().iter().copied().collect();
1646    let zero = num_traits::zero::<T>();
1647    let one = num_traits::one::<T>();
1648    let mut data = vec![one; m * cols];
1649    for (i, &xi) in xs.iter().enumerate() {
1650        // Build the plain Vandermonde row from the RAW data (numpy builds
1651        // `np.vander(x)` before masking, so unmasked rows use the true value).
1652        let mut acc = one;
1653        let mut powers = Vec::with_capacity(cols);
1654        for _ in 0..cols {
1655            powers.push(acc);
1656            acc = acc * xi;
1657        }
1658        for (j, p) in powers.iter().enumerate() {
1659            // NumPy's vander defaults to decreasing powers (left-to-right).
1660            data[i * cols + (cols - 1 - j)] = *p;
1661        }
1662        // numpy then zeros the ENTIRE row of any masked input (`_vander[m]=0`).
1663        if xmask[i] {
1664            for slot in data[i * cols..(i + 1) * cols].iter_mut() {
1665                *slot = zero;
1666            }
1667        }
1668    }
1669    let data_arr = Array::from_vec(Ix2::new([m, cols]), data)?;
1670    // nomask result — masked-array constructed from data only.
1671    MaskedArray::from_data(data_arr)
1672}
1673
1674// ===========================================================================
1675// Fill-value protocol
1676// ===========================================================================
1677
1678/// Default fill value for a dtype, mirroring NumPy:
1679/// - bool: `false`
1680/// - integer: `999_999` (capped at the type max for parity with modern numpy)
1681/// - float: `1e20` (numpy uses `1e20` for f64, `1e20` cast to f32)
1682/// - complex: `1e20 + 0j`
1683///
1684/// This function is type-erased at the boundary; the type-specific
1685/// equivalents are `T::fill_default_value`. ferray represents this via a
1686/// trait helper rather than a single function.
1687#[must_use]
1688pub fn default_fill_value_f64() -> f64 {
1689    1e20
1690}
1691
1692/// Default fill value for f32.
1693#[must_use]
1694pub fn default_fill_value_f32() -> f32 {
1695    1e20_f32
1696}
1697
1698/// Default fill value for bool.
1699#[must_use]
1700pub const fn default_fill_value_bool() -> bool {
1701    false
1702}
1703
1704/// Default fill value for `i64`.
1705#[must_use]
1706pub const fn default_fill_value_i64() -> i64 {
1707    999_999
1708}
1709
1710/// Maximum fill value for a Float type (used when filling masked values
1711/// for max-reductions so they don't influence the result).
1712#[must_use]
1713pub fn maximum_fill_value<T: Float>() -> T {
1714    T::infinity()
1715}
1716
1717/// Minimum fill value for a Float type (used when filling masked values
1718/// for min-reductions).
1719#[must_use]
1720pub fn minimum_fill_value<T: Float>() -> T {
1721    T::neg_infinity()
1722}
1723
1724/// Common fill value for two masked arrays — returns `a.fill_value()` if
1725/// both share the same fill value, else `T::zero()` (NumPy's fallback).
1726pub fn common_fill_value<T, D>(a: &MaskedArray<T, D>, b: &MaskedArray<T, D>) -> T
1727where
1728    T: Element + Copy + PartialEq,
1729    D: Dimension,
1730{
1731    if a.fill_value() == b.fill_value() {
1732        a.fill_value()
1733    } else {
1734        T::zero()
1735    }
1736}
1737
1738// ===========================================================================
1739// Comparison and logical ufuncs (mask-aware)
1740// ===========================================================================
1741
1742macro_rules! ma_cmp {
1743    ($name:ident, $op:tt, $bound:path) => {
1744        /// Element-wise comparison preserving mask union.
1745        ///
1746        /// # Errors
1747        /// Returns `FerrayError::ShapeMismatch` if shapes differ.
1748        pub fn $name<T, D>(
1749            a: &MaskedArray<T, D>,
1750            b: &MaskedArray<T, D>,
1751        ) -> FerrayResult<MaskedArray<bool, D>>
1752        where
1753            T: Element + Copy + $bound,
1754            D: Dimension,
1755        {
1756            if a.shape() != b.shape() {
1757                return Err(FerrayError::shape_mismatch(format!(
1758                    "{}: shapes {:?} and {:?} differ",
1759                    stringify!($name),
1760                    a.shape(),
1761                    b.shape(),
1762                )));
1763            }
1764            let data: Vec<bool> = a
1765                .data()
1766                .iter()
1767                .zip(b.data().iter())
1768                .map(|(x, y)| x $op y)
1769                .collect();
1770            let mask: Vec<bool> = a
1771                .mask()
1772                .iter()
1773                .zip(b.mask().iter())
1774                .map(|(x, y)| *x || *y)
1775                .collect();
1776            let data_arr = Array::from_vec(a.data().dim().clone(), data)?;
1777            let mask_arr: Array<bool, D> = Array::from_vec(a.mask().dim().clone(), mask)?;
1778            MaskedArray::new(data_arr, mask_arr)
1779        }
1780    };
1781}
1782
1783ma_cmp!(ma_equal, ==, PartialEq);
1784ma_cmp!(ma_not_equal, !=, PartialEq);
1785ma_cmp!(ma_less, <, PartialOrd);
1786ma_cmp!(ma_greater, >, PartialOrd);
1787ma_cmp!(ma_less_equal, <=, PartialOrd);
1788ma_cmp!(ma_greater_equal, >=, PartialOrd);
1789
1790/// Element-wise logical AND on bool MaskedArrays. Mask is unioned.
1791///
1792/// # Errors
1793/// Returns `FerrayError::ShapeMismatch` if shapes differ.
1794pub fn ma_logical_and<D: Dimension>(
1795    a: &MaskedArray<bool, D>,
1796    b: &MaskedArray<bool, D>,
1797) -> FerrayResult<MaskedArray<bool, D>> {
1798    binary_bool(a, b, |x, y| x && y, "ma_logical_and")
1799}
1800
1801/// Element-wise logical OR.
1802///
1803/// # Errors
1804/// Returns `FerrayError::ShapeMismatch` if shapes differ.
1805pub fn ma_logical_or<D: Dimension>(
1806    a: &MaskedArray<bool, D>,
1807    b: &MaskedArray<bool, D>,
1808) -> FerrayResult<MaskedArray<bool, D>> {
1809    binary_bool(a, b, |x, y| x || y, "ma_logical_or")
1810}
1811
1812/// Element-wise logical XOR.
1813///
1814/// # Errors
1815/// Returns `FerrayError::ShapeMismatch` if shapes differ.
1816pub fn ma_logical_xor<D: Dimension>(
1817    a: &MaskedArray<bool, D>,
1818    b: &MaskedArray<bool, D>,
1819) -> FerrayResult<MaskedArray<bool, D>> {
1820    binary_bool(a, b, |x, y| x ^ y, "ma_logical_xor")
1821}
1822
1823/// Element-wise logical NOT. Mask is preserved.
1824///
1825/// # Errors
1826/// Returns an error if internal array construction fails.
1827pub fn ma_logical_not<D: Dimension>(
1828    a: &MaskedArray<bool, D>,
1829) -> FerrayResult<MaskedArray<bool, D>> {
1830    let data: Vec<bool> = a.data().iter().map(|x| !*x).collect();
1831    let data_arr = Array::from_vec(a.data().dim().clone(), data)?;
1832    let mask_arr: Array<bool, D> =
1833        Array::from_vec(a.mask().dim().clone(), a.mask().iter().copied().collect())?;
1834    MaskedArray::new(data_arr, mask_arr)
1835}
1836
1837fn binary_bool<D: Dimension>(
1838    a: &MaskedArray<bool, D>,
1839    b: &MaskedArray<bool, D>,
1840    op: impl Fn(bool, bool) -> bool,
1841    name: &str,
1842) -> FerrayResult<MaskedArray<bool, D>> {
1843    if a.shape() != b.shape() {
1844        return Err(FerrayError::shape_mismatch(format!(
1845            "{name}: shapes {:?} and {:?} differ",
1846            a.shape(),
1847            b.shape()
1848        )));
1849    }
1850    let data: Vec<bool> = a
1851        .data()
1852        .iter()
1853        .zip(b.data().iter())
1854        .map(|(x, y)| op(*x, *y))
1855        .collect();
1856    let mask: Vec<bool> = a
1857        .mask()
1858        .iter()
1859        .zip(b.mask().iter())
1860        .map(|(x, y)| *x || *y)
1861        .collect();
1862    let data_arr = Array::from_vec(a.data().dim().clone(), data)?;
1863    let mask_arr: Array<bool, D> = Array::from_vec(a.mask().dim().clone(), mask)?;
1864    MaskedArray::new(data_arr, mask_arr)
1865}
1866
1867// ===========================================================================
1868// Class helpers
1869// ===========================================================================
1870
1871/// Whether `ma` is a [`MaskedArray`]. In Rust this is statically true, so
1872/// this always returns `true` — preserved for API parity with
1873/// `numpy.ma.isMaskedArray`.
1874#[must_use]
1875pub const fn is_masked_array<T, D>(_ma: &MaskedArray<T, D>) -> bool
1876where
1877    T: Element,
1878    D: Dimension,
1879{
1880    true
1881}
1882
1883/// NumPy-spelling alias for [`is_masked_array`].
1884#[must_use]
1885pub const fn is_ma<T, D>(ma: &MaskedArray<T, D>) -> bool
1886where
1887    T: Element,
1888    D: Dimension,
1889{
1890    is_masked_array(ma)
1891}
1892
1893/// Return the mask array, materializing the all-false sentinel form into a
1894/// real bool array. Equivalent to `numpy.ma.getmaskarray`.
1895///
1896/// Always returns an `Array<bool, D>`, never `nomask`.
1897///
1898/// # Errors
1899/// Returns an error if internal array construction fails.
1900pub fn getmaskarray<T, D>(ma: &MaskedArray<T, D>) -> FerrayResult<Array<bool, D>>
1901where
1902    T: Element,
1903    D: Dimension,
1904{
1905    Array::from_vec(ma.mask().dim().clone(), ma.mask().iter().copied().collect())
1906}
1907
1908/// Return a stable identity-pair `(data_ptr, mask_ptr)` for two
1909/// MaskedArrays, useful for cheap-equality checks.
1910///
1911/// Equivalent to `numpy.ma.MaskedArray.ids` — note that the mask sentinel
1912/// state still produces a valid pointer (the mask is materialized lazily).
1913#[must_use]
1914pub fn ids<T, D>(ma: &MaskedArray<T, D>) -> (*const u8, *const u8)
1915where
1916    T: Element,
1917    D: Dimension,
1918{
1919    let data_ptr: *const u8 = ma.data() as *const _ as *const u8;
1920    let mask_ptr: *const u8 = ma.mask() as *const _ as *const u8;
1921    (data_ptr, mask_ptr)
1922}
1923
1924// ===========================================================================
1925// Mask-structure analysis: clump / notmasked run-length helpers
1926// (numpy/ma/extras.py). Slices are returned as `(start, stop)` half-open
1927// index pairs; the binding converts them to Python `slice` objects.
1928// ===========================================================================
1929
1930/// Find the runs (clumps) of `true` values in a 1-D bool slice, returning each
1931/// as a half-open `(start, stop)` index pair.
1932///
1933/// This is the structural core shared by `clump_masked` / `clump_unmasked` /
1934/// `flatnotmasked_contiguous`, mirroring `numpy.ma.extras._ezclump`
1935/// (`numpy/ma/extras.py:2105`): it walks the boolean transitions and emits one
1936/// pair per contiguous run of `true`.
1937fn ezclump_true(mask: &[bool]) -> Vec<(usize, usize)> {
1938    let n = mask.len();
1939    let mut runs: Vec<(usize, usize)> = Vec::new();
1940    let mut i = 0usize;
1941    while i < n {
1942        if mask[i] {
1943            let start = i;
1944            while i < n && mask[i] {
1945                i += 1;
1946            }
1947            runs.push((start, i));
1948        } else {
1949            i += 1;
1950        }
1951    }
1952    runs
1953}
1954
1955/// Flatten a masked array's mask into a row-major `Vec<bool>`.
1956fn flat_mask<T, D>(a: &MaskedArray<T, D>) -> Vec<bool>
1957where
1958    T: Element,
1959    D: Dimension,
1960{
1961    a.mask().iter().copied().collect()
1962}
1963
1964/// `numpy.ma.clump_masked(a)` (`numpy/ma/extras.py:2189`) — the list of
1965/// half-open `(start, stop)` slices, one per contiguous run of **masked**
1966/// elements in a 1-D masked array. Returns an empty list when nothing is
1967/// masked (numpy returns `[]` for the `nomask` case).
1968#[must_use]
1969pub fn clump_masked<T, D>(a: &MaskedArray<T, D>) -> Vec<(usize, usize)>
1970where
1971    T: Element,
1972    D: Dimension,
1973{
1974    ezclump_true(&flat_mask(a))
1975}
1976
1977/// `numpy.ma.clump_unmasked(a)` (`numpy/ma/extras.py:2235`) — the list of
1978/// half-open `(start, stop)` slices, one per contiguous run of **unmasked**
1979/// elements (numpy computes `_ezclump(~mask)`). With no mask, numpy yields the
1980/// single slice `[0, size)`.
1981#[must_use]
1982pub fn clump_unmasked<T, D>(a: &MaskedArray<T, D>) -> Vec<(usize, usize)>
1983where
1984    T: Element,
1985    D: Dimension,
1986{
1987    let inv: Vec<bool> = flat_mask(a).iter().map(|&m| !m).collect();
1988    ezclump_true(&inv)
1989}
1990
1991/// `numpy.ma.flatnotmasked_edges(a)` (`numpy/ma/extras.py:1762`) — the
1992/// `[first, last]` indices of the unmasked values of a flattened masked array.
1993///
1994/// Returns `Some([0, size-1])` when nothing is masked, `Some([first, last])`
1995/// for the partially-masked case, and `None` when every element is masked
1996/// (matching numpy's `None` return).
1997#[must_use]
1998pub fn flatnotmasked_edges<T, D>(a: &MaskedArray<T, D>) -> Option<[usize; 2]>
1999where
2000    T: Element,
2001    D: Dimension,
2002{
2003    let m = flat_mask(a);
2004    let size = m.len();
2005    if size == 0 {
2006        return None;
2007    }
2008    if !m.iter().any(|&b| b) {
2009        return Some([0, size - 1]);
2010    }
2011    let first = m.iter().position(|&b| !b)?;
2012    let last = m.iter().rposition(|&b| !b)?;
2013    Some([first, last])
2014}
2015
2016/// `numpy.ma.flatnotmasked_contiguous(a)` (`numpy/ma/extras.py:1818`) — the
2017/// list of half-open `(start, stop)` slices of contiguous **unmasked** regions
2018/// of a flattened masked array. With no mask, numpy returns the single slice
2019/// `[0, size)`; the all-masked case yields `[]`.
2020#[must_use]
2021pub fn flatnotmasked_contiguous<T, D>(a: &MaskedArray<T, D>) -> Vec<(usize, usize)>
2022where
2023    T: Element,
2024    D: Dimension,
2025{
2026    let inv: Vec<bool> = flat_mask(a).iter().map(|&m| !m).collect();
2027    ezclump_true(&inv)
2028}
2029
2030/// `numpy.ma.notmasked_edges(a, axis=None)` (`numpy/ma/extras.py:1878`).
2031///
2032/// For `axis = None` (or a 1-D array) this is exactly
2033/// [`flatnotmasked_edges`]. The numpy multi-axis form returns per-axis
2034/// coordinate tuples; that form is a deferred extension (#835 follow-up) — this
2035/// entry point covers the flattened contract used by ferray's binding.
2036#[must_use]
2037pub fn notmasked_edges<T, D>(a: &MaskedArray<T, D>) -> Option<[usize; 2]>
2038where
2039    T: Element,
2040    D: Dimension,
2041{
2042    flatnotmasked_edges(a)
2043}
2044
2045/// First/last unmasked coordinate tuples of a 2-D masked array along `axis`
2046/// (`numpy/ma/extras.py:1925` `notmasked_edges`, the explicit-`axis` branch).
2047///
2048/// numpy computes, per lane along `axis`, the min (first) and max (last)
2049/// coordinate of the unmasked positions, then compresses out fully-masked
2050/// lanes. The result is two `(rows, cols)` index tuples — the coordinate
2051/// arrays are always emitted in axis order (axis-0 indices, then axis-1
2052/// indices), independent of which `axis` selects the first/last extent.
2053///
2054/// Returns `(first, last)` where each is `(row_indices, col_indices)`. The
2055/// `row_indices` / `col_indices` vectors have one entry per lane (along the
2056/// axis orthogonal to `axis`) that contains at least one unmasked element.
2057///
2058/// # Errors
2059/// Returns `FerrayError::axis_out_of_bounds` if `axis >= 2`.
2060#[allow(
2061    clippy::type_complexity,
2062    reason = "mirrors numpy's two (rows, cols) tuples"
2063)]
2064pub fn notmasked_edges_axis2<T>(
2065    a: &MaskedArray<T, Ix2>,
2066    axis: usize,
2067) -> FerrayResult<((Vec<i64>, Vec<i64>), (Vec<i64>, Vec<i64>))>
2068where
2069    T: Element,
2070{
2071    if axis >= 2 {
2072        return Err(FerrayError::axis_out_of_bounds(axis, 2));
2073    }
2074    let shape = a.shape();
2075    let (rows, cols) = (shape[0], shape[1]);
2076    let mask: Vec<bool> = a.mask().iter().copied().collect();
2077
2078    // We iterate over lanes along `axis` for each fixed index on the orthogonal
2079    // axis; the first/last unmasked POSITION along `axis` defines the edge.
2080    let (mut first_rows, mut first_cols) = (Vec::new(), Vec::new());
2081    let (mut last_rows, mut last_cols) = (Vec::new(), Vec::new());
2082    let other_len = if axis == 0 { cols } else { rows };
2083    let axis_len = if axis == 0 { rows } else { cols };
2084    for o in 0..other_len {
2085        let mut first: Option<usize> = None;
2086        let mut last: Option<usize> = None;
2087        for k in 0..axis_len {
2088            let (r, c) = if axis == 0 { (k, o) } else { (o, k) };
2089            if !mask[r * cols + c] {
2090                if first.is_none() {
2091                    first = Some(k);
2092                }
2093                last = Some(k);
2094            }
2095        }
2096        if let (Some(f), Some(l)) = (first, last) {
2097            // Reconstruct the (row, col) coordinate of the first/last edge.
2098            let (fr_r, fr_c) = if axis == 0 { (f, o) } else { (o, f) };
2099            let (lr_r, lr_c) = if axis == 0 { (l, o) } else { (o, l) };
2100            first_rows.push(fr_r as i64);
2101            first_cols.push(fr_c as i64);
2102            last_rows.push(lr_r as i64);
2103            last_cols.push(lr_c as i64);
2104        }
2105    }
2106    Ok(((first_rows, first_cols), (last_rows, last_cols)))
2107}
2108
2109/// `numpy.ma.notmasked_contiguous(a, axis=None)` (`numpy/ma/extras.py:1936`).
2110///
2111/// `axis = None` matches [`flatnotmasked_contiguous`] (numpy delegates to it
2112/// directly). For a 2-D array with an explicit `axis`, returns one slice list
2113/// per lane along the orthogonal axis (a list of lists), mirroring numpy's
2114/// per-lane `flatnotmasked_contiguous` sweep.
2115///
2116/// # Errors
2117/// Returns `FerrayError::axis_out_of_bounds` if `axis >= 2`, and a
2118/// shape-mismatch error if the array is not 2-D when an axis is given.
2119pub fn notmasked_contiguous_axis<T>(
2120    a: &MaskedArray<T, Ix2>,
2121    axis: usize,
2122) -> FerrayResult<Vec<Vec<(usize, usize)>>>
2123where
2124    T: Element,
2125{
2126    if axis >= 2 {
2127        return Err(FerrayError::axis_out_of_bounds(axis, 2));
2128    }
2129    let shape = a.shape();
2130    let (rows, cols) = (shape[0], shape[1]);
2131    let mask: Vec<bool> = a.mask().iter().copied().collect();
2132    // `other` is the axis we iterate over; for each fixed index on `other`
2133    // we sweep the full lane along `axis` and collect unmasked runs.
2134    let other = (axis + 1) % 2;
2135    let other_len = if other == 0 { rows } else { cols };
2136    let axis_len = if axis == 0 { rows } else { cols };
2137    let mut result: Vec<Vec<(usize, usize)>> = Vec::with_capacity(other_len);
2138    for o in 0..other_len {
2139        let mut lane: Vec<bool> = Vec::with_capacity(axis_len);
2140        for k in 0..axis_len {
2141            let (r, c) = if axis == 0 { (k, o) } else { (o, k) };
2142            lane.push(mask[r * cols + c]);
2143        }
2144        let inv: Vec<bool> = lane.iter().map(|&m| !m).collect();
2145        result.push(ezclump_true(&inv));
2146    }
2147    Ok(result)
2148}
2149
2150// ===========================================================================
2151// 2-D masked matrix product (numpy/ma/core.py:8214, non-strict default)
2152// ===========================================================================
2153
2154impl<T> MaskedArray<T, Ix2>
2155where
2156    T: Element + Copy + std::ops::Add<Output = T> + std::ops::Mul<Output = T> + num_traits::Zero,
2157{
2158    /// `numpy.ma.dot(a, b)` for 2-D operands, non-strict (the default)
2159    /// (`numpy/ma/core.py:8214`).
2160    ///
2161    /// numpy computes `d = dot(filled(a, 0), filled(b, 0))` (masked entries
2162    /// contribute zero) and masks the result where **no** valid pair
2163    /// contributed: `m = ~dot(~mask_a, ~mask_b)`. Concretely `out[i, j]` is
2164    /// masked iff for every `k`, `a[i, k]` is masked OR `b[k, j]` is masked.
2165    ///
2166    /// # Errors
2167    /// Returns `FerrayError::shape_mismatch` if `self.cols != other.rows`.
2168    pub fn ma_dot_2d(&self, other: &MaskedArray<T, Ix2>) -> FerrayResult<MaskedArray<T, Ix2>> {
2169        let a_shape = self.shape();
2170        let b_shape = other.shape();
2171        let (m, k1) = (a_shape[0], a_shape[1]);
2172        let (k2, n) = (b_shape[0], b_shape[1]);
2173        if k1 != k2 {
2174            return Err(FerrayError::shape_mismatch(format!(
2175                "ma_dot_2d: lhs.cols={k1} != rhs.rows={k2}"
2176            )));
2177        }
2178        let a_data: Vec<T> = self.data().iter().copied().collect();
2179        let a_mask: Vec<bool> = self.mask().iter().copied().collect();
2180        let b_data: Vec<T> = other.data().iter().copied().collect();
2181        let b_mask: Vec<bool> = other.mask().iter().copied().collect();
2182        let zero = <T as num_traits::Zero>::zero();
2183
2184        let mut out_data = vec![zero; m * n];
2185        let mut out_mask = vec![false; m * n];
2186        for i in 0..m {
2187            for j in 0..n {
2188                let mut acc = zero;
2189                // `any_valid` tracks whether at least one k-pair was unmasked
2190                // on BOTH sides — mirrors `dot(~am, ~bm) != 0` (the result mask).
2191                let mut any_valid = false;
2192                for k in 0..k1 {
2193                    let am = a_mask[i * k1 + k];
2194                    let bm = b_mask[k * n + j];
2195                    // numpy's data is `dot(filled(a,0), filled(b,0))`: masked
2196                    // operands contribute a zero factor, but the sum still runs
2197                    // over every k.
2198                    let av = if am { zero } else { a_data[i * k1 + k] };
2199                    let bv = if bm { zero } else { b_data[k * n + j] };
2200                    acc = acc + av * bv;
2201                    if !am && !bm {
2202                        any_valid = true;
2203                    }
2204                }
2205                out_data[i * n + j] = acc;
2206                out_mask[i * n + j] = !any_valid;
2207            }
2208        }
2209        let data_arr = Array::<T, Ix2>::from_vec(Ix2::new([m, n]), out_data)?;
2210        let mask_arr = Array::<bool, Ix2>::from_vec(Ix2::new([m, n]), out_mask)?;
2211        MaskedArray::new(data_arr, mask_arr)
2212    }
2213}
2214
2215// ===========================================================================
2216// Multi-axis masked argsort (numpy.ma.argsort, numpy/ma/core.py)
2217// ===========================================================================
2218
2219impl<T, D> MaskedArray<T, D>
2220where
2221    T: Element + PartialOrd + Copy,
2222    D: Dimension,
2223{
2224    /// `numpy.ma.argsort(a, axis)` along an explicit `axis`
2225    /// (`numpy/ma/core.py` `MaskedArray.argsort`).
2226    ///
2227    /// numpy fills masked positions with the per-dtype maximum fill value
2228    /// (`endwith=True`, the default) so they sort to the **end** of each lane,
2229    /// then returns the ordinary argsort of the filled data. Within a lane,
2230    /// indices are returned relative to that lane (0-based), exactly like
2231    /// `numpy.argsort(..., axis=axis)`. The result is a plain index array
2232    /// (no mask) of the input shape.
2233    ///
2234    /// # Errors
2235    /// Returns `FerrayError::axis_out_of_bounds` if `axis >= self.ndim()`.
2236    pub fn argsort_axis(&self, axis: usize) -> FerrayResult<Array<u64, IxDyn>> {
2237        let ndim = self.ndim();
2238        if axis >= ndim {
2239            return Err(FerrayError::axis_out_of_bounds(axis, ndim));
2240        }
2241        let shape = self.shape().to_vec();
2242        let axis_len = shape[axis];
2243        let total: usize = shape.iter().product();
2244        let src_data: Vec<T> = self.data().iter().copied().collect();
2245        let src_mask: Vec<bool> = self.mask().iter().copied().collect();
2246
2247        let mut strides = vec![1usize; ndim];
2248        for i in (0..ndim.saturating_sub(1)).rev() {
2249            strides[i] = strides[i + 1] * shape[i + 1];
2250        }
2251
2252        let mut out = vec![0u64; total];
2253
2254        let outer_shape: Vec<usize> = shape
2255            .iter()
2256            .enumerate()
2257            .filter_map(|(i, &s)| if i == axis { None } else { Some(s) })
2258            .collect();
2259        let outer_size: usize = if outer_shape.is_empty() {
2260            1
2261        } else {
2262            outer_shape.iter().product()
2263        };
2264
2265        let mut outer_multi = vec![0usize; outer_shape.len()];
2266        for _ in 0..outer_size {
2267            // Resolve the flat index of lane position `k` for the current
2268            // outer multi-index.
2269            let flat_of = |k: usize| -> usize {
2270                let mut flat = 0usize;
2271                let mut o = 0usize;
2272                for (i, &stride) in strides.iter().enumerate() {
2273                    if i == axis {
2274                        flat += stride * k;
2275                    } else {
2276                        flat += stride * outer_multi[o];
2277                        o += 1;
2278                    }
2279                }
2280                flat
2281            };
2282
2283            // Stable argsort over lane positions: unmasked compare by value;
2284            // any masked entry sorts after every unmasked one (the `endwith`
2285            // fill-to-max behaviour), with masked entries keeping input order.
2286            let mut order: Vec<usize> = (0..axis_len).collect();
2287            order.sort_by(|&x, &y| {
2288                let fx = flat_of(x);
2289                let fy = flat_of(y);
2290                match (src_mask[fx], src_mask[fy]) {
2291                    (false, false) => src_data[fx]
2292                        .partial_cmp(&src_data[fy])
2293                        .unwrap_or(std::cmp::Ordering::Equal),
2294                    (false, true) => std::cmp::Ordering::Less,
2295                    (true, false) => std::cmp::Ordering::Greater,
2296                    (true, true) => std::cmp::Ordering::Equal,
2297                }
2298            });
2299
2300            for (k, &pos) in order.iter().enumerate() {
2301                out[flat_of(k)] = pos as u64;
2302            }
2303
2304            for i in (0..outer_shape.len()).rev() {
2305                outer_multi[i] += 1;
2306                if outer_multi[i] < outer_shape[i] {
2307                    break;
2308                }
2309                outer_multi[i] = 0;
2310            }
2311        }
2312
2313        Array::<u64, IxDyn>::from_vec(IxDyn::new(&shape), out)
2314    }
2315}
2316
2317// ===========================================================================
2318// Tests
2319// ===========================================================================
2320
2321#[cfg(test)]
2322mod tests {
2323    use super::*;
2324    use ferray_core::Ix1;
2325
2326    fn arr1f(v: Vec<f64>) -> Array<f64, Ix1> {
2327        let n = v.len();
2328        Array::from_vec(Ix1::new([n]), v).unwrap()
2329    }
2330
2331    fn mask1(v: Vec<bool>) -> Array<bool, Ix1> {
2332        let n = v.len();
2333        Array::from_vec(Ix1::new([n]), v).unwrap()
2334    }
2335
2336    fn ma_f1(d: Vec<f64>, m: Vec<bool>) -> MaskedArray<f64, Ix1> {
2337        MaskedArray::new(arr1f(d), mask1(m)).unwrap()
2338    }
2339
2340    #[test]
2341    fn prod_skips_masked() {
2342        let ma = ma_f1(vec![2.0, 3.0, 5.0, 7.0], vec![false, true, false, false]);
2343        let p = ma.prod().unwrap();
2344        assert!((p - 70.0).abs() < 1e-10); // 2 * 5 * 7
2345    }
2346
2347    #[test]
2348    fn cumsum_propagates_mask() {
2349        let ma = ma_f1(vec![1.0, 2.0, 3.0, 4.0], vec![false, true, false, false]);
2350        let r = ma.cumsum_flat().unwrap();
2351        let mask: Vec<bool> = r.mask().iter().copied().collect();
2352        let data: Vec<f64> = r.data().iter().copied().collect();
2353        assert_eq!(mask, vec![false, true, false, false]);
2354        // Running sum: 1, (skipped→still 1, marked masked), 1+3=4, 4+4=8
2355        assert!((data[0] - 1.0).abs() < 1e-10);
2356        assert!((data[2] - 4.0).abs() < 1e-10);
2357        assert!((data[3] - 8.0).abs() < 1e-10);
2358    }
2359
2360    #[test]
2361    fn argmin_argmax_skip_masked() {
2362        let ma = ma_f1(vec![5.0, 1.0, 9.0, 3.0], vec![false, true, false, false]);
2363        // unmasked: 5, 9, 3 at positions 0, 2, 3 → min at 3, max at 2
2364        assert_eq!(ma.argmin().unwrap(), 3);
2365        assert_eq!(ma.argmax().unwrap(), 2);
2366    }
2367
2368    #[test]
2369    fn ptp_basic() {
2370        let ma = ma_f1(vec![5.0, 1.0, 9.0, 3.0], vec![false, true, false, false]);
2371        // unmasked: 5, 9, 3 → ptp = 9 - 3 = 6
2372        assert!((ma.ptp().unwrap() - 6.0).abs() < 1e-10);
2373    }
2374
2375    #[test]
2376    fn median_odd_and_even() {
2377        let odd = ma_f1(vec![3.0, 1.0, 4.0, 1.0, 5.0], vec![false; 5]);
2378        assert!((odd.median().unwrap() - 3.0).abs() < 1e-10);
2379        let even = ma_f1(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
2380        assert!((even.median().unwrap() - 2.5).abs() < 1e-10);
2381    }
2382
2383    #[test]
2384    fn average_unweighted_matches_mean() {
2385        let ma = ma_f1(vec![2.0, 4.0, 6.0], vec![false; 3]);
2386        assert!((ma.average(None).unwrap() - 4.0).abs() < 1e-10);
2387    }
2388
2389    #[test]
2390    fn average_weighted_skips_masked() {
2391        let ma = ma_f1(vec![1.0, 100.0, 3.0], vec![false, true, false]);
2392        let w = arr1f(vec![1.0, 1.0, 3.0]);
2393        // unmasked weighted: (1*1 + 3*3) / (1 + 3) = 10/4 = 2.5
2394        assert!((ma.average(Some(&w)).unwrap() - 2.5).abs() < 1e-10);
2395    }
2396
2397    #[test]
2398    fn anom_centers_at_zero() {
2399        let ma = ma_f1(vec![1.0, 2.0, 3.0], vec![false; 3]);
2400        let a = ma.anom().unwrap();
2401        let data: Vec<f64> = a.data().iter().copied().collect();
2402        assert!((data[0] - (-1.0)).abs() < 1e-10);
2403        assert!((data[1] - 0.0).abs() < 1e-10);
2404        assert!((data[2] - 1.0).abs() < 1e-10);
2405    }
2406
2407    #[test]
2408    fn masked_all_is_fully_masked() {
2409        let ma: MaskedArray<f64, Ix1> = masked_all(Ix1::new([5])).unwrap();
2410        let mask: Vec<bool> = ma.mask().iter().copied().collect();
2411        assert_eq!(mask, vec![true; 5]);
2412    }
2413
2414    #[test]
2415    fn masked_values_within_tol() {
2416        let arr = arr1f(vec![1.0, 1.0001, 2.0]);
2417        let r = masked_values(&arr, 1.0, 1e-3, 0.0).unwrap();
2418        let mask: Vec<bool> = r.mask().iter().copied().collect();
2419        assert_eq!(mask, vec![true, true, false]);
2420    }
2421
2422    #[test]
2423    fn harden_mask_blocks_clearing() {
2424        let mut ma = ma_f1(vec![1.0, 2.0], vec![false, true]);
2425        ma.harden_mask().unwrap();
2426        assert!(ma.is_hard_mask());
2427        // Try to clear the mask bit at index 1.
2428        ma.set_mask_flat(1, false).unwrap();
2429        let mask: Vec<bool> = ma.mask().iter().copied().collect();
2430        // Hardened: clear is a no-op.
2431        assert_eq!(mask, vec![false, true]);
2432        ma.soften_mask().unwrap();
2433        ma.set_mask_flat(1, false).unwrap();
2434        let mask2: Vec<bool> = ma.mask().iter().copied().collect();
2435        assert_eq!(mask2, vec![false, false]);
2436    }
2437
2438    #[test]
2439    fn mask_or_unions() {
2440        let m1 = mask1(vec![true, false, false]);
2441        let m2 = mask1(vec![false, true, false]);
2442        let r = mask_or(&m1, &m2).unwrap();
2443        let v: Vec<bool> = r.iter().copied().collect();
2444        assert_eq!(v, vec![true, true, false]);
2445    }
2446
2447    #[test]
2448    fn make_mask_none_is_all_false() {
2449        let m: Array<bool, Ix1> = make_mask_none(Ix1::new([3])).unwrap();
2450        let v: Vec<bool> = m.iter().copied().collect();
2451        assert_eq!(v, vec![false; 3]);
2452    }
2453
2454    #[test]
2455    fn clip_unmasked_only() {
2456        let ma = ma_f1(vec![-5.0, 0.0, 5.0, 10.0], vec![false, false, false, true]);
2457        let r = ma.clip(-1.0, 3.0).unwrap();
2458        let data: Vec<f64> = r.data().iter().copied().collect();
2459        // Masked element passes through (10.0).
2460        assert_eq!(data, vec![-1.0, 0.0, 3.0, 10.0]);
2461    }
2462
2463    #[test]
2464    fn repeat_propagates_mask() {
2465        let ma = ma_f1(vec![1.0, 2.0], vec![false, true]);
2466        let r = ma.repeat(3).unwrap();
2467        let data: Vec<f64> = r.data().iter().copied().collect();
2468        let mask: Vec<bool> = r.mask().iter().copied().collect();
2469        assert_eq!(data, vec![1.0, 1.0, 1.0, 2.0, 2.0, 2.0]);
2470        assert_eq!(mask, vec![false, false, false, true, true, true]);
2471    }
2472
2473    #[test]
2474    fn ma_unique_dedups_unmasked() {
2475        let ma = ma_f1(
2476            vec![3.0, 1.0, 2.0, 1.0, 3.0, 9.0],
2477            vec![false, false, false, false, false, true],
2478        );
2479        let v = ma_unique(&ma).unwrap();
2480        let data: Vec<f64> = v.iter().copied().collect();
2481        assert_eq!(data, vec![1.0, 2.0, 3.0]);
2482    }
2483
2484    #[test]
2485    fn ma_isin_basic() {
2486        let ma = ma_f1(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
2487        let r = ma_isin(&ma, &[2.0, 4.0]).unwrap();
2488        let data: Vec<bool> = r.data().iter().copied().collect();
2489        assert_eq!(data, vec![false, true, false, true]);
2490    }
2491
2492    #[test]
2493    fn ma_dot_flat_skips_masked_pairs() {
2494        let a = ma_f1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
2495        let b = ma_f1(vec![4.0, 5.0, 6.0], vec![false, false, false]);
2496        // Pair (1,4): both ok → 4. Pair (2,5): a masked → skip. Pair (3,6): both ok → 18.
2497        // Total = 22.
2498        assert!((a.ma_dot_flat(&b).unwrap() - 22.0).abs() < 1e-10);
2499    }
2500
2501    #[test]
2502    fn fill_value_protocol_constants() {
2503        assert_eq!(default_fill_value_f64(), 1e20);
2504        assert!(!default_fill_value_bool());
2505        assert!(maximum_fill_value::<f64>().is_infinite());
2506        assert!(
2507            minimum_fill_value::<f64>().is_infinite()
2508                && minimum_fill_value::<f64>().is_sign_negative()
2509        );
2510    }
2511
2512    #[test]
2513    fn common_fill_value_returns_shared_or_zero() {
2514        let a = ma_f1(vec![1.0, 2.0], vec![false, false]).with_fill_value(99.0);
2515        let b = ma_f1(vec![3.0, 4.0], vec![false, false]).with_fill_value(99.0);
2516        assert_eq!(common_fill_value(&a, &b), 99.0);
2517        let c = ma_f1(vec![5.0, 6.0], vec![false, false]).with_fill_value(0.5);
2518        assert_eq!(common_fill_value(&a, &c), 0.0); // fall back to zero
2519    }
2520
2521    #[test]
2522    fn ma_equal_and_friends_union_mask() {
2523        let a = ma_f1(vec![1.0, 2.0, 3.0], vec![false, false, true]);
2524        let b = ma_f1(vec![1.0, 9.0, 3.0], vec![false, true, false]);
2525        let r = ma_equal(&a, &b).unwrap();
2526        let data: Vec<bool> = r.data().iter().copied().collect();
2527        let mask: Vec<bool> = r.mask().iter().copied().collect();
2528        assert_eq!(data, vec![true, false, true]);
2529        assert_eq!(mask, vec![false, true, true]);
2530    }
2531
2532    #[test]
2533    fn ma_logical_and_basic() {
2534        let a = MaskedArray::new(
2535            Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![true, true, false]).unwrap(),
2536            Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![false; 3]).unwrap(),
2537        )
2538        .unwrap();
2539        let b = MaskedArray::new(
2540            Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![true, false, true]).unwrap(),
2541            Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![false; 3]).unwrap(),
2542        )
2543        .unwrap();
2544        let r = ma_logical_and(&a, &b).unwrap();
2545        let data: Vec<bool> = r.data().iter().copied().collect();
2546        assert_eq!(data, vec![true, false, false]);
2547    }
2548
2549    #[test]
2550    fn is_masked_array_always_true() {
2551        let ma = ma_f1(vec![1.0], vec![false]);
2552        assert!(is_masked_array(&ma));
2553        assert!(is_ma(&ma));
2554    }
2555
2556    #[test]
2557    fn getmaskarray_materializes() {
2558        let ma = MaskedArray::<f64, Ix1>::from_data(arr1f(vec![1.0, 2.0])).unwrap();
2559        let m = getmaskarray(&ma).unwrap();
2560        let v: Vec<bool> = m.iter().copied().collect();
2561        assert_eq!(v, vec![false; 2]);
2562    }
2563
2564    #[test]
2565    fn diagonal_main_and_offset() {
2566        use ferray_core::Ix2;
2567        let data = Array::<f64, Ix2>::from_vec(
2568            Ix2::new([3, 3]),
2569            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
2570        )
2571        .unwrap();
2572        let mask = Array::<bool, Ix2>::from_vec(Ix2::new([3, 3]), vec![false; 9]).unwrap();
2573        let ma = MaskedArray::new(data, mask).unwrap();
2574        let main = ma.diagonal(0).unwrap();
2575        let main_data: Vec<f64> = main.data().iter().copied().collect();
2576        assert_eq!(main_data, vec![1.0, 5.0, 9.0]);
2577        let upper = ma.diagonal(1).unwrap();
2578        let upper_data: Vec<f64> = upper.data().iter().copied().collect();
2579        assert_eq!(upper_data, vec![2.0, 6.0]);
2580    }
2581
2582    #[test]
2583    fn trace_sums_diagonal() {
2584        use ferray_core::Ix2;
2585        let data = Array::<f64, Ix2>::from_vec(
2586            Ix2::new([3, 3]),
2587            vec![1.0, 0.0, 0.0, 0.0, 5.0, 0.0, 0.0, 0.0, 9.0],
2588        )
2589        .unwrap();
2590        let mask = Array::<bool, Ix2>::from_vec(Ix2::new([3, 3]), vec![false; 9]).unwrap();
2591        let ma = MaskedArray::new(data, mask).unwrap();
2592        assert!((ma.trace(0).unwrap() - 15.0).abs() < 1e-10);
2593    }
2594
2595    #[test]
2596    fn ma_apply_along_axis_sum_lane() {
2597        use ferray_core::IxDyn;
2598        let data =
2599            Array::<f64, IxDyn>::from_vec(IxDyn::new(&[2, 3]), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
2600                .unwrap();
2601        let mask = Array::<bool, IxDyn>::from_vec(IxDyn::new(&[2, 3]), vec![false; 6]).unwrap();
2602        let ma = MaskedArray::new(data, mask).unwrap();
2603        let result = ma_apply_along_axis(&ma, 1, |lane| {
2604            let s: f64 = lane.data().iter().copied().sum();
2605            Ok((s, false))
2606        })
2607        .unwrap();
2608        let v: Vec<f64> = result.data().iter().copied().collect();
2609        assert_eq!(v, vec![6.0, 15.0]);
2610    }
2611
2612    // -----------------------------------------------------------------------
2613    // Masked set ops (numpy.ma.intersect1d/union1d/setdiff1d/setxor1d).
2614    // Expected values from the numpy 2.4.5 oracle (R-CHAR-3):
2615    //   a = ma([1,2,3,4], mask=[0,1,0,0]); b = ma([3,4,5], mask=[0,0,1])
2616    //   intersect -> data[3,4,--] mask[F,F,T]
2617    //   union     -> data[1,3,4,--] mask[F,F,F,T]
2618    //   setdiff   -> data[1] mask[F]
2619    //   setxor    -> data[1] mask[F]
2620    //   unique(a) -> data[1,3,4,--] mask[F,F,F,T]
2621    // -----------------------------------------------------------------------
2622    fn ab() -> (MaskedArray<f64, Ix1>, MaskedArray<f64, Ix1>) {
2623        (
2624            ma_f1(vec![1.0, 2.0, 3.0, 4.0], vec![false, true, false, false]),
2625            ma_f1(vec![3.0, 4.0, 5.0], vec![false, false, true]),
2626        )
2627    }
2628
2629    fn data_mask(m: &MaskedArray<f64, Ix1>) -> (Vec<f64>, Vec<bool>) {
2630        (
2631            m.data().iter().copied().collect(),
2632            m.mask().iter().copied().collect(),
2633        )
2634    }
2635
2636    #[test]
2637    fn ma_unique_masked_trails_one_masked_slot() -> FerrayResult<()> {
2638        let (a, _) = ab();
2639        let (data, mask) = data_mask(&ma_unique_masked(&a)?);
2640        // numpy: unmasked uniques [1,3,4] then one trailing masked slot.
2641        assert_eq!(&data[..3], &[1.0, 3.0, 4.0]);
2642        assert_eq!(mask, vec![false, false, false, true]);
2643        Ok(())
2644    }
2645
2646    #[test]
2647    fn ma_intersect1d_common_with_both_masked() -> FerrayResult<()> {
2648        let (a, b) = ab();
2649        let (data, mask) = data_mask(&ma_intersect1d(&a, &b)?);
2650        assert_eq!(&data[..2], &[3.0, 4.0]);
2651        assert_eq!(mask, vec![false, false, true]);
2652        Ok(())
2653    }
2654
2655    #[test]
2656    fn ma_union1d_all_uniques_plus_masked() -> FerrayResult<()> {
2657        let (a, b) = ab();
2658        let (data, mask) = data_mask(&ma_union1d(&a, &b)?);
2659        assert_eq!(&data[..3], &[1.0, 3.0, 4.0]);
2660        assert_eq!(mask, vec![false, false, false, true]);
2661        Ok(())
2662    }
2663
2664    #[test]
2665    fn ma_setdiff1d_drops_masked_when_rhs_masked() -> FerrayResult<()> {
2666        let (a, b) = ab();
2667        let (data, mask) = data_mask(&ma_setdiff1d(&a, &b)?);
2668        // a's masked slot is removed because b is also masked.
2669        assert_eq!(data, vec![1.0]);
2670        assert_eq!(mask, vec![false]);
2671        Ok(())
2672    }
2673
2674    #[test]
2675    fn ma_setxor1d_symmetric_difference() -> FerrayResult<()> {
2676        let (a, b) = ab();
2677        let (data, mask) = data_mask(&ma_setxor1d(&a, &b)?);
2678        // both masked -> masked slot cancels; only unmasked-unique-once is 1.
2679        assert_eq!(data, vec![1.0]);
2680        assert_eq!(mask, vec![false]);
2681        Ok(())
2682    }
2683
2684    // -----------------------------------------------------------------------
2685    // compress_rowcols / mask_rowcols (numpy.ma.extras).
2686    // Oracle: m = ma([[1,2,3],[4,5,6]], mask=[[0,0,1],[0,0,0]])
2687    //   compress_rowcols(None) -> [[4,5]]
2688    //   compress_rows          -> [[4,5,6]]
2689    //   compress_cols          -> [[1,2],[4,5]]
2690    //   mask_rowcols(None)     -> mask[[T,T,T],[F,F,T]]
2691    // -----------------------------------------------------------------------
2692    fn m23() -> FerrayResult<MaskedArray<f64, Ix2>> {
2693        let data =
2694            Array::<f64, Ix2>::from_vec(Ix2::new([2, 3]), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])?;
2695        let mask = Array::<bool, Ix2>::from_vec(
2696            Ix2::new([2, 3]),
2697            vec![false, false, true, false, false, false],
2698        )?;
2699        MaskedArray::new(data, mask)
2700    }
2701
2702    #[test]
2703    fn ma_compress_rowcols_both() -> FerrayResult<()> {
2704        let r = ma_compress_rowcols(&m23()?, None)?;
2705        assert_eq!(r.shape(), &[1, 2]);
2706        let v: Vec<f64> = r.iter().copied().collect();
2707        assert_eq!(v, vec![4.0, 5.0]);
2708        Ok(())
2709    }
2710
2711    #[test]
2712    fn ma_compress_rows_and_cols() -> FerrayResult<()> {
2713        let rows = ma_compress_rows(&m23()?)?;
2714        assert_eq!(rows.shape(), &[1, 3]);
2715        assert_eq!(
2716            rows.iter().copied().collect::<Vec<f64>>(),
2717            vec![4.0, 5.0, 6.0]
2718        );
2719        let cols = ma_compress_cols(&m23()?)?;
2720        assert_eq!(cols.shape(), &[2, 2]);
2721        assert_eq!(
2722            cols.iter().copied().collect::<Vec<f64>>(),
2723            vec![1.0, 2.0, 4.0, 5.0]
2724        );
2725        Ok(())
2726    }
2727
2728    #[test]
2729    fn ma_mask_rowcols_masks_whole_row_and_col() -> FerrayResult<()> {
2730        let r = ma_mask_rowcols(&m23()?, None)?;
2731        let mask: Vec<bool> = r.mask().iter().copied().collect();
2732        // row 0 and col 2 both masked: [[T,T,T],[F,F,T]]
2733        assert_eq!(mask, vec![true, true, true, false, false, true]);
2734        let data: Vec<f64> = r.data().iter().copied().collect();
2735        assert_eq!(data, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
2736        Ok(())
2737    }
2738
2739    // -----------------------------------------------------------------------
2740    // Masked cov / corrcoef (numpy.ma.cov / corrcoef).
2741    // Oracle: m = ma([[1,2,3],[4,5,6]], mask=[[0,0,1],[0,0,0]])
2742    //   cov      -> [[0.5,0.5],[0.5,1.0]]
2743    //   corrcoef -> [[1.0, 0.70710678...],[0.70710678..., 1.0]]
2744    // -----------------------------------------------------------------------
2745    #[test]
2746    fn ma_cov_unmasked_pairs() -> FerrayResult<()> {
2747        let c = ma_cov(&m23()?, true, false, None)?;
2748        let data: Vec<f64> = c.data().iter().copied().collect();
2749        let mask: Vec<bool> = c.mask().iter().copied().collect();
2750        let expect = [0.5, 0.5, 0.5, 1.0];
2751        for (g, e) in data.iter().zip(expect.iter()) {
2752            assert!((g - e).abs() < 1e-12, "cov {g} != {e}");
2753        }
2754        assert_eq!(mask, vec![false; 4]);
2755        Ok(())
2756    }
2757
2758    #[test]
2759    fn ma_corrcoef_normalized() -> FerrayResult<()> {
2760        let c = ma_corrcoef(&m23()?, true)?;
2761        let data: Vec<f64> = c.data().iter().copied().collect();
2762        let expect = [1.0, 0.7071067811865475, 0.7071067811865475, 1.0];
2763        for (g, e) in data.iter().zip(expect.iter()) {
2764            assert!((g - e).abs() < 1e-12, "corr {g} != {e}");
2765        }
2766        Ok(())
2767    }
2768
2769    // -----------------------------------------------------------------------
2770    // Mask-structure analysis: clump / notmasked run-length helpers.
2771    //
2772    // Oracle (numpy 2.4.5) for `m = ma.array([1,2,3,4,5,6], mask=[0,0,1,1,0,0])`:
2773    //   clump_masked          -> [slice(2, 4)]
2774    //   clump_unmasked        -> [slice(0, 2), slice(4, 6)]
2775    //   notmasked_contiguous  -> [slice(0, 2), slice(4, 6)]
2776    //   notmasked_edges       -> [0, 5]
2777    //   flatnotmasked_edges   -> [0, 5]
2778    // -----------------------------------------------------------------------
2779    #[test]
2780    fn clump_run_length_matches_numpy() {
2781        let m = ma_f1(
2782            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
2783            vec![false, false, true, true, false, false],
2784        );
2785        assert_eq!(clump_masked(&m), vec![(2, 4)]);
2786        assert_eq!(clump_unmasked(&m), vec![(0, 2), (4, 6)]);
2787        assert_eq!(flatnotmasked_contiguous(&m), vec![(0, 2), (4, 6)]);
2788        assert_eq!(flatnotmasked_edges(&m), Some([0, 5]));
2789        assert_eq!(notmasked_edges(&m), Some([0, 5]));
2790    }
2791
2792    #[test]
2793    fn clump_nomask_and_allmask_edges() {
2794        // numpy: clump_unmasked(no mask) -> [slice(0,n)], clump_masked -> [];
2795        // flatnotmasked_edges(no mask) -> [0, n-1].
2796        let none = ma_f1(vec![1.0, 2.0, 3.0], vec![false, false, false]);
2797        assert_eq!(clump_masked(&none), vec![]);
2798        assert_eq!(clump_unmasked(&none), vec![(0, 3)]);
2799        assert_eq!(flatnotmasked_edges(&none), Some([0, 2]));
2800        // numpy: all masked -> clump_unmasked [], clump_masked [slice(0,n)],
2801        // flatnotmasked_contiguous [], flatnotmasked_edges None.
2802        let all = ma_f1(vec![1.0, 2.0, 3.0], vec![true, true, true]);
2803        assert_eq!(clump_unmasked(&all), vec![]);
2804        assert_eq!(clump_masked(&all), vec![(0, 3)]);
2805        assert_eq!(flatnotmasked_contiguous(&all), vec![]);
2806        assert_eq!(flatnotmasked_edges(&all), None);
2807    }
2808
2809    #[test]
2810    fn clump_leading_and_trailing_masked() {
2811        // numpy for ma.masked_array(arange(10)); a[[0,1,2,6,8,9]] = masked:
2812        //   clump_masked   -> [slice(0,3), slice(6,7), slice(8,10)]
2813        //   clump_unmasked -> [slice(3,6), slice(7,8)]
2814        let mut mask = vec![false; 10];
2815        for &i in &[0usize, 1, 2, 6, 8, 9] {
2816            mask[i] = true;
2817        }
2818        let data: Vec<f64> = (0..10).map(|x| x as f64).collect();
2819        let m = ma_f1(data, mask);
2820        assert_eq!(clump_masked(&m), vec![(0, 3), (6, 7), (8, 10)]);
2821        assert_eq!(clump_unmasked(&m), vec![(3, 6), (7, 8)]);
2822    }
2823
2824    #[test]
2825    fn notmasked_contiguous_axis_2d_matches_numpy() -> FerrayResult<()> {
2826        use ferray_core::Ix2;
2827        // numpy:
2828        //   a = arange(12).reshape(3,4); mask[1:,:-1]=1; mask[0,1]=1; mask[-1,0]=0
2829        //   notmasked_contiguous(ma, axis=0) ->
2830        //     [[slice(0,1), slice(2,3)], [], [slice(0,1)], [slice(0,3)]]
2831        //   notmasked_contiguous(ma, axis=1) ->
2832        //     [[slice(0,1), slice(2,4)], [slice(3,4)], [slice(0,1), slice(3,4)]]
2833        // mask matrix (row-major):
2834        //   [[0,1,0,0],[1,1,1,0],[0,1,1,0]]
2835        let mask = vec![
2836            false, true, false, false, true, true, true, false, false, true, true, false,
2837        ];
2838        let data: Vec<f64> = (0..12).map(|x| x as f64).collect();
2839        let d = Array::<f64, Ix2>::from_vec(Ix2::new([3, 4]), data)?;
2840        let mk = Array::<bool, Ix2>::from_vec(Ix2::new([3, 4]), mask)?;
2841        let m = MaskedArray::new(d, mk)?;
2842        let by0 = notmasked_contiguous_axis(&m, 0)?;
2843        assert_eq!(
2844            by0,
2845            vec![vec![(0, 1), (2, 3)], vec![], vec![(0, 1)], vec![(0, 3)]]
2846        );
2847        let by1 = notmasked_contiguous_axis(&m, 1)?;
2848        assert_eq!(
2849            by1,
2850            vec![vec![(0, 1), (2, 4)], vec![(3, 4)], vec![(0, 1), (3, 4)]]
2851        );
2852        Ok(())
2853    }
2854
2855    // -----------------------------------------------------------------------
2856    // 2-D masked dot. Oracle (numpy 2.4.5):
2857    //   a = ma.array([[1.,2],[3,4]], mask=[[0,1],[0,0]])
2858    //   ma.dot(a, a).data -> [[1, 0], [15, 16]]  (masked entries -> 0 in product)
2859    //   ma.dot(a, a).mask -> [[False, True], [False, False]]
2860    // -----------------------------------------------------------------------
2861    #[test]
2862    fn ma_dot_2d_matches_numpy() -> FerrayResult<()> {
2863        use ferray_core::Ix2;
2864        let d = Array::<f64, Ix2>::from_vec(Ix2::new([2, 2]), vec![1.0, 2.0, 3.0, 4.0])?;
2865        let mk = Array::<bool, Ix2>::from_vec(Ix2::new([2, 2]), vec![false, true, false, false])?;
2866        let a = MaskedArray::new(d, mk)?;
2867        let out = a.ma_dot_2d(&a)?;
2868        let data: Vec<f64> = out.data().iter().copied().collect();
2869        let mask: Vec<bool> = out.mask().iter().copied().collect();
2870        // out[0,0]=1*1=1 (a[0,1] masked -> 0 factor); out[0,1] masked (every
2871        // contributing path traverses the masked a[0,1], data sums to 0).
2872        assert_eq!(data, vec![1.0, 0.0, 15.0, 16.0]);
2873        assert_eq!(mask, vec![false, true, false, false]);
2874        Ok(())
2875    }
2876
2877    // -----------------------------------------------------------------------
2878    // Multi-axis argsort. Oracle (numpy 2.4.5):
2879    //   b = ma.array([[3,1,2],[6,5,4]], mask=[[0,0,1],[0,0,0]])
2880    //   ma.argsort(b, axis=1) -> [[1,0,2],[2,1,0]]
2881    //   ma.argsort(b, axis=0) -> [[0,0,1],[1,1,0]]
2882    // -----------------------------------------------------------------------
2883    #[test]
2884    fn argsort_axis_matches_numpy() -> FerrayResult<()> {
2885        use ferray_core::Ix2;
2886        let d = Array::<f64, Ix2>::from_vec(Ix2::new([2, 3]), vec![3.0, 1.0, 2.0, 6.0, 5.0, 4.0])?;
2887        let mk = Array::<bool, Ix2>::from_vec(
2888            Ix2::new([2, 3]),
2889            vec![false, false, true, false, false, false],
2890        )?;
2891        let b = MaskedArray::new(d, mk)?;
2892        let by1 = b.argsort_axis(1)?;
2893        let v1: Vec<u64> = by1.iter().copied().collect();
2894        assert_eq!(v1, vec![1, 0, 2, 2, 1, 0]);
2895        let by0 = b.argsort_axis(0)?;
2896        let v0: Vec<u64> = by0.iter().copied().collect();
2897        assert_eq!(v0, vec![0, 0, 1, 1, 1, 0]);
2898        Ok(())
2899    }
2900}