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        && let Some(placeholder) = ma.data().iter().nth(pos)
970    {
971        vals.push(*placeholder);
972        mask.push(true);
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
1121        && !m2_masked
1122        && let Some(p) = first_masked_value(ar1)
1123    {
1124        out.push((p, true));
1125    }
1126    build_ma_ix1(out)
1127}
1128
1129/// `numpy.ma.setxor1d(ar1, ar2)` — symmetric difference of the unique values
1130/// (numpy/ma/extras.py:1350): elements present in exactly one input. The masked
1131/// slot survives iff exactly one input was masked.
1132///
1133/// # Errors
1134/// Returns an error if internal array construction fails.
1135pub fn ma_setxor1d<T, D>(
1136    ar1: &MaskedArray<T, D>,
1137    ar2: &MaskedArray<T, D>,
1138) -> FerrayResult<MaskedArray<T, Ix1>>
1139where
1140    T: Element + Copy + PartialOrd,
1141    D: Dimension,
1142{
1143    let (v1, m1) = unique_parts(ar1);
1144    let (v2, m2) = unique_parts(ar2);
1145    let mut out: Vec<(T, bool)> = Vec::new();
1146    // Elements in v1 not in v2.
1147    for v in &v1 {
1148        if !v2
1149            .iter()
1150            .any(|w| v.partial_cmp(w) == Some(std::cmp::Ordering::Equal))
1151        {
1152            out.push((*v, false));
1153        }
1154    }
1155    // Elements in v2 not in v1.
1156    for v in &v2 {
1157        if !v1
1158            .iter()
1159            .any(|w| v.partial_cmp(w) == Some(std::cmp::Ordering::Equal))
1160        {
1161            out.push((*v, false));
1162        }
1163    }
1164    out.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1165    // Masked slot present in exactly one input -> survives the xor.
1166    if m1 ^ m2 {
1167        let placeholder = first_masked_value(ar1).or_else(|| first_masked_value(ar2));
1168        if let Some(p) = placeholder {
1169            out.push((p, true));
1170        }
1171    }
1172    build_ma_ix1(out)
1173}
1174
1175/// The first underlying value at a masked position, if any.
1176fn first_masked_value<T, D>(ma: &MaskedArray<T, D>) -> Option<T>
1177where
1178    T: Element + Copy,
1179    D: Dimension,
1180{
1181    ma.mask()
1182        .iter()
1183        .position(|&m| m)
1184        .and_then(|pos| ma.data().iter().nth(pos).copied())
1185}
1186
1187// ===========================================================================
1188// Row/column suppression and masking (numpy.ma.compress_rowcols /
1189// compress_rows / compress_cols / mask_rowcols). 2-D only — numpy raises
1190// NotImplementedError otherwise (numpy/ma/extras.py:920 / :830).
1191// ===========================================================================
1192
1193/// `numpy.ma.compress_rowcols(x, axis)` — suppress whole rows and/or columns of
1194/// a 2-D masked array that contain any masked value
1195/// (numpy/ma/extras.py:920, via `compress_nd`).
1196///
1197/// - `axis = None` → drop both rows and columns containing a masked value.
1198/// - `axis = Some(0)` → drop only rows.
1199/// - `axis = Some(1)` → drop only columns.
1200///
1201/// Returns a plain (unmasked) [`Array<T, Ix2>`] of the surviving data.
1202///
1203/// # Errors
1204/// Returns `FerrayError::invalid_value` if the input is not 2-D (numpy raises
1205/// `NotImplementedError`); or if internal array construction fails.
1206pub fn ma_compress_rowcols<T>(
1207    ma: &MaskedArray<T, Ix2>,
1208    axis: Option<usize>,
1209) -> FerrayResult<Array<T, Ix2>>
1210where
1211    T: Element + Copy,
1212{
1213    let shape = ma.data().shape();
1214    let (nrows, ncols) = (shape[0], shape[1]);
1215    let data: Vec<T> = ma.data().iter().copied().collect();
1216    let mask: Vec<bool> = ma.mask().iter().copied().collect();
1217
1218    let drop_rows = matches!(axis, None | Some(0));
1219    let drop_cols = matches!(axis, None | Some(1));
1220
1221    // Rows/cols to keep.
1222    let mut keep_row = vec![true; nrows];
1223    let mut keep_col = vec![true; ncols];
1224    if drop_rows {
1225        for (r, kr) in keep_row.iter_mut().enumerate() {
1226            *kr = !(0..ncols).any(|c| mask[r * ncols + c]);
1227        }
1228    }
1229    if drop_cols {
1230        for (c, kc) in keep_col.iter_mut().enumerate() {
1231            *kc = !(0..nrows).any(|r| mask[r * ncols + c]);
1232        }
1233    }
1234
1235    let kept_rows: Vec<usize> = (0..nrows).filter(|&r| keep_row[r]).collect();
1236    let kept_cols: Vec<usize> = (0..ncols).filter(|&c| keep_col[c]).collect();
1237
1238    let mut out: Vec<T> = Vec::with_capacity(kept_rows.len() * kept_cols.len());
1239    for &r in &kept_rows {
1240        for &c in &kept_cols {
1241            out.push(data[r * ncols + c]);
1242        }
1243    }
1244    Array::<T, Ix2>::from_vec(Ix2::new([kept_rows.len(), kept_cols.len()]), out)
1245}
1246
1247/// `numpy.ma.compress_rows(a)` — suppress whole rows containing masked values;
1248/// equivalent to `compress_rowcols(a, 0)` (numpy/ma/extras.py:953).
1249///
1250/// # Errors
1251/// Propagates [`ma_compress_rowcols`] errors.
1252pub fn ma_compress_rows<T>(ma: &MaskedArray<T, Ix2>) -> FerrayResult<Array<T, Ix2>>
1253where
1254    T: Element + Copy,
1255{
1256    ma_compress_rowcols(ma, Some(0))
1257}
1258
1259/// `numpy.ma.compress_cols(a)` — suppress whole columns containing masked
1260/// values; equivalent to `compress_rowcols(a, 1)` (numpy/ma/extras.py:991).
1261///
1262/// # Errors
1263/// Propagates [`ma_compress_rowcols`] errors.
1264pub fn ma_compress_cols<T>(ma: &MaskedArray<T, Ix2>) -> FerrayResult<Array<T, Ix2>>
1265where
1266    T: Element + Copy,
1267{
1268    ma_compress_rowcols(ma, Some(1))
1269}
1270
1271/// `numpy.ma.mask_rowcols(a, axis)` — mask whole rows and/or columns of a 2-D
1272/// masked array that contain any masked value (numpy/ma/extras.py:830).
1273///
1274/// - `axis = None` → mask both rows and columns containing a masked value.
1275/// - `axis = Some(0)` → mask only rows.
1276/// - `axis = Some(1)` → mask only columns.
1277///
1278/// Returns a [`MaskedArray<T, Ix2>`] with the same data and an expanded mask.
1279///
1280/// # Errors
1281/// Returns an error if internal array construction fails.
1282pub fn ma_mask_rowcols<T>(
1283    ma: &MaskedArray<T, Ix2>,
1284    axis: Option<usize>,
1285) -> FerrayResult<MaskedArray<T, Ix2>>
1286where
1287    T: Element + Copy,
1288{
1289    let shape = ma.data().shape();
1290    let (nrows, ncols) = (shape[0], shape[1]);
1291    let data: Vec<T> = ma.data().iter().copied().collect();
1292    let mask: Vec<bool> = ma.mask().iter().copied().collect();
1293
1294    let mask_rows = matches!(axis, None | Some(0));
1295    let mask_cols = matches!(axis, None | Some(1));
1296
1297    let row_has_mask: Vec<bool> = (0..nrows)
1298        .map(|r| (0..ncols).any(|c| mask[r * ncols + c]))
1299        .collect();
1300    let col_has_mask: Vec<bool> = (0..ncols)
1301        .map(|c| (0..nrows).any(|r| mask[r * ncols + c]))
1302        .collect();
1303
1304    let mut new_mask = mask.clone();
1305    for r in 0..nrows {
1306        for c in 0..ncols {
1307            if (mask_rows && row_has_mask[r]) || (mask_cols && col_has_mask[c]) {
1308                new_mask[r * ncols + c] = true;
1309            }
1310        }
1311    }
1312
1313    let data_arr = Array::<T, Ix2>::from_vec(Ix2::new([nrows, ncols]), data)?;
1314    let mask_arr = Array::<bool, Ix2>::from_vec(Ix2::new([nrows, ncols]), new_mask)?;
1315    MaskedArray::new(data_arr, mask_arr)
1316}
1317
1318// ===========================================================================
1319// Masked covariance / correlation (numpy.ma.cov / corrcoef). Common case
1320// y=None, computed over the unmasked pairs (numpy/ma/extras.py:1519
1321// `_covhelper`). Each row is centered by its own masked mean; the pairwise
1322// normalisation `fact[i,j]` is the count of jointly-unmasked observations,
1323// and an entry is masked where `fact <= 0`.
1324// ===========================================================================
1325
1326/// `numpy.ma.cov(x, rowvar, bias, ddof)` for the `y=None` case
1327/// (numpy/ma/extras.py:1547). Returns the masked covariance matrix computed
1328/// over the unmasked observation pairs.
1329///
1330/// Each variable (row when `rowvar`, column otherwise) is centered by the mean
1331/// of its own unmasked observations. The `[i,j]` entry sums the product of the
1332/// centered values over the columns where **both** `i` and `j` are unmasked,
1333/// divided by `fact[i,j] = (#jointly-unmasked) - ddof`. When `ddof` is `None`,
1334/// it defaults to `0` if `bias`, else `1` (numpy/ma/extras.py:1648). An entry
1335/// is masked iff `fact[i,j] <= 0`.
1336///
1337/// # Errors
1338/// Returns `FerrayError::invalid_value` if the input is not 1-D or 2-D; or if
1339/// internal array construction fails.
1340pub fn ma_cov<D>(
1341    x: &MaskedArray<f64, D>,
1342    rowvar: bool,
1343    bias: bool,
1344    ddof: Option<usize>,
1345) -> FerrayResult<MaskedArray<f64, Ix2>>
1346where
1347    D: Dimension,
1348{
1349    let ddof_val = ddof.unwrap_or(if bias { 0 } else { 1 }) as f64;
1350
1351    // Build the (nvars x nobs) data + not-mask matrices (rows = variables).
1352    let ndim = x.ndim();
1353    if ndim > 2 {
1354        return Err(FerrayError::invalid_value(
1355            "ma.cov requires a 1-D or 2-D masked array",
1356        ));
1357    }
1358    let shape = x.data().shape();
1359    let flat_data: Vec<f64> = x.data().iter().copied().collect();
1360    let flat_mask: Vec<bool> = x.mask().iter().copied().collect();
1361
1362    // Build a row-major (nvars x nobs) data + mask matrix. numpy promotes 1-D
1363    // input to a single row (ndmin=2); for a single row, rowvar is forced True
1364    // (numpy/ma/extras.py: "if x.shape[0] == 1: rowvar = True").
1365    let (nvars, nobs, mat_data, mat_mask): (usize, usize, Vec<f64>, Vec<bool>) = if ndim <= 1 {
1366        let n = flat_data.len();
1367        (1, n, flat_data, flat_mask)
1368    } else {
1369        let (r, c) = (shape[0], shape[1]);
1370        let eff_rowvar = rowvar || r == 1;
1371        if eff_rowvar {
1372            (r, c, flat_data, flat_mask)
1373        } else {
1374            // Transpose so variables become rows.
1375            let mut td = vec![0.0f64; r * c];
1376            let mut tm = vec![false; r * c];
1377            for i in 0..c {
1378                for k in 0..r {
1379                    td[i * r + k] = flat_data[k * c + i];
1380                    tm[i * r + k] = flat_mask[k * c + i];
1381                }
1382            }
1383            (c, r, td, tm)
1384        }
1385    };
1386
1387    // Center each variable by the mean of its own unmasked observations.
1388    let mut centered = vec![0.0f64; nvars * nobs];
1389    let mut notmask = vec![0.0f64; nvars * nobs];
1390    for i in 0..nvars {
1391        let mut sum = 0.0;
1392        let mut cnt = 0.0;
1393        for k in 0..nobs {
1394            let idx = i * nobs + k;
1395            if !mat_mask[idx] {
1396                sum += mat_data[idx];
1397                cnt += 1.0;
1398                notmask[idx] = 1.0;
1399            }
1400        }
1401        let mean = if cnt > 0.0 { sum / cnt } else { 0.0 };
1402        for k in 0..nobs {
1403            let idx = i * nobs + k;
1404            // filled(x, 0): masked positions contribute 0 to the dot product.
1405            centered[idx] = if mat_mask[idx] {
1406                0.0
1407            } else {
1408                mat_data[idx] - mean
1409            };
1410        }
1411    }
1412
1413    // cov[i,j] = (centered_i . centered_j) / (notmask_i . notmask_j - ddof),
1414    // masked where fact <= 0.
1415    let mut cov_data = vec![0.0f64; nvars * nvars];
1416    let mut cov_mask = vec![false; nvars * nvars];
1417    for i in 0..nvars {
1418        for j in i..nvars {
1419            let mut dot = 0.0;
1420            let mut fact = 0.0;
1421            for k in 0..nobs {
1422                dot += centered[i * nobs + k] * centered[j * nobs + k];
1423                fact += notmask[i * nobs + k] * notmask[j * nobs + k];
1424            }
1425            fact -= ddof_val;
1426            let (val, masked) = if fact <= 0.0 {
1427                (0.0, true)
1428            } else {
1429                (dot / fact, false)
1430            };
1431            cov_data[i * nvars + j] = val;
1432            cov_data[j * nvars + i] = val;
1433            cov_mask[i * nvars + j] = masked;
1434            cov_mask[j * nvars + i] = masked;
1435        }
1436    }
1437
1438    let data_arr = Array::<f64, Ix2>::from_vec(Ix2::new([nvars, nvars]), cov_data)?;
1439    let mask_arr = Array::<bool, Ix2>::from_vec(Ix2::new([nvars, nvars]), cov_mask)?;
1440    MaskedArray::new(data_arr, mask_arr)
1441}
1442
1443/// `numpy.ma.corrcoef(x, rowvar)` for the `y=None` case
1444/// (numpy/ma/extras.py:1672). The covariance matrix (computed with the default
1445/// `bias=False`, `ddof=None` normalisation) divided by the outer product of the
1446/// per-variable standard deviations `sqrt(diag(cov))`.
1447///
1448/// A `corr[i,j]` is masked iff `cov[i,j]` is masked or either `std[i]`/`std[j]`
1449/// is masked (numpy propagates the masked covariance through the division).
1450///
1451/// # Errors
1452/// Returns an error if [`ma_cov`] fails or internal array construction fails.
1453pub fn ma_corrcoef<D>(x: &MaskedArray<f64, D>, rowvar: bool) -> FerrayResult<MaskedArray<f64, Ix2>>
1454where
1455    D: Dimension,
1456{
1457    let cov = ma_cov(x, rowvar, false, None)?;
1458    let n = cov.data().shape()[0];
1459    let cov_data: Vec<f64> = cov.data().iter().copied().collect();
1460    let cov_mask: Vec<bool> = cov.mask().iter().copied().collect();
1461
1462    // Per-variable std = sqrt(diag(cov)); masked iff the diagonal is masked.
1463    let mut std = vec![0.0f64; n];
1464    let mut std_masked = vec![false; n];
1465    for i in 0..n {
1466        std_masked[i] = cov_mask[i * n + i];
1467        std[i] = cov_data[i * n + i].sqrt();
1468    }
1469
1470    let mut corr_data = vec![0.0f64; n * n];
1471    let mut corr_mask = vec![false; n * n];
1472    for i in 0..n {
1473        for j in 0..n {
1474            let d = std[i] * std[j];
1475            let masked = cov_mask[i * n + j] || std_masked[i] || std_masked[j] || d == 0.0;
1476            if masked {
1477                corr_mask[i * n + j] = true;
1478                corr_data[i * n + j] = 0.0;
1479            } else {
1480                // Clamp to [-1, 1] for numerical stability (matches the
1481                // ferray-stats corrcoef convention).
1482                let v = cov_data[i * n + j] / d;
1483                corr_data[i * n + j] = v.clamp(-1.0, 1.0);
1484            }
1485        }
1486    }
1487
1488    let data_arr = Array::<f64, Ix2>::from_vec(Ix2::new([n, n]), corr_data)?;
1489    let mask_arr = Array::<bool, Ix2>::from_vec(Ix2::new([n, n]), corr_mask)?;
1490    MaskedArray::new(data_arr, mask_arr)
1491}
1492
1493// ===========================================================================
1494// Functional helpers: apply_along_axis / apply_over_axes / vander
1495// ===========================================================================
1496
1497/// Apply a function `f` to every 1-D slice along `axis`, collecting the
1498/// scalar result of each into an `(ndim - 1)`-D masked array.
1499///
1500/// `f` receives a [`MaskedArray<T, Ix1>`] view (rebuilt each call) and
1501/// returns a tuple `(value, masked)` indicating the reduction's output and
1502/// whether to mark the output position as masked.
1503///
1504/// # Errors
1505/// Returns `FerrayError::AxisOutOfBounds` if `axis >= ndim`, or any error
1506/// from `f`.
1507pub fn ma_apply_along_axis<T, F>(
1508    ma: &MaskedArray<T, IxDyn>,
1509    axis: usize,
1510    mut f: F,
1511) -> FerrayResult<MaskedArray<T, IxDyn>>
1512where
1513    T: Element + Copy,
1514    F: FnMut(&MaskedArray<T, Ix1>) -> FerrayResult<(T, bool)>,
1515{
1516    let shape = ma.shape().to_vec();
1517    if axis >= shape.len() {
1518        return Err(FerrayError::axis_out_of_bounds(axis, shape.len()));
1519    }
1520    let lane_len = shape[axis];
1521    let out_shape: Vec<usize> = shape
1522        .iter()
1523        .enumerate()
1524        .filter(|&(i, _)| i != axis)
1525        .map(|(_, &s)| s)
1526        .collect();
1527    let out_size: usize = out_shape.iter().product::<usize>().max(1);
1528
1529    let ndim = shape.len();
1530    let mut strides = vec![1usize; ndim];
1531    for i in (0..ndim.saturating_sub(1)).rev() {
1532        strides[i] = strides[i + 1] * shape[i + 1];
1533    }
1534
1535    let data: Vec<T> = ma.data().iter().copied().collect();
1536    let mask: Vec<bool> = ma.mask().iter().copied().collect();
1537
1538    let mut out_data = Vec::with_capacity(out_size);
1539    let mut out_mask = Vec::with_capacity(out_size);
1540    let mut out_multi = vec![0usize; out_shape.len()];
1541
1542    for _ in 0..out_size {
1543        // Build the lane.
1544        let mut lane_data = Vec::with_capacity(lane_len);
1545        let mut lane_mask = Vec::with_capacity(lane_len);
1546        let mut full_idx = vec![0usize; ndim];
1547        // Map out_multi -> full_idx (skipping axis).
1548        let mut oi = 0;
1549        for (d, slot) in full_idx.iter_mut().enumerate() {
1550            if d == axis {
1551                continue;
1552            }
1553            *slot = out_multi[oi];
1554            oi += 1;
1555        }
1556        for j in 0..lane_len {
1557            full_idx[axis] = j;
1558            let flat: usize = full_idx
1559                .iter()
1560                .zip(strides.iter())
1561                .map(|(i, s)| i * s)
1562                .sum();
1563            lane_data.push(data[flat]);
1564            lane_mask.push(mask[flat]);
1565        }
1566        let lane_data_arr = Array::from_vec(Ix1::new([lane_len]), lane_data)?;
1567        let lane_mask_arr = Array::from_vec(Ix1::new([lane_len]), lane_mask)?;
1568        let lane_ma = MaskedArray::new(lane_data_arr, lane_mask_arr)?;
1569        let (val, masked) = f(&lane_ma)?;
1570        out_data.push(val);
1571        out_mask.push(masked);
1572
1573        // Advance multi-index over output dimensions.
1574        for d in (0..out_shape.len()).rev() {
1575            out_multi[d] += 1;
1576            if out_multi[d] < out_shape[d] {
1577                break;
1578            }
1579            out_multi[d] = 0;
1580        }
1581    }
1582
1583    let data_arr = Array::from_vec(IxDyn::new(&out_shape), out_data)?;
1584    let mask_arr = Array::from_vec(IxDyn::new(&out_shape), out_mask)?;
1585    MaskedArray::new(data_arr, mask_arr)
1586}
1587
1588/// Apply `f` repeatedly, reducing over the given axes in succession.
1589///
1590/// Each axis in `axes` is reduced via [`ma_apply_along_axis`] in order,
1591/// with subsequent axis indices adjusted for previous reductions.
1592///
1593/// # Errors
1594/// Returns errors from [`ma_apply_along_axis`].
1595pub fn ma_apply_over_axes<T, F>(
1596    ma: &MaskedArray<T, IxDyn>,
1597    axes: &[usize],
1598    mut f: F,
1599) -> FerrayResult<MaskedArray<T, IxDyn>>
1600where
1601    T: Element + Copy,
1602    F: FnMut(&MaskedArray<T, Ix1>) -> FerrayResult<(T, bool)>,
1603{
1604    let mut result = ma.clone();
1605    let mut sorted: Vec<usize> = axes.to_vec();
1606    sorted.sort_unstable();
1607    for (offset, &ax) in sorted.iter().enumerate() {
1608        // Each previous reduction collapsed an earlier axis, so shift
1609        // subsequent axis indices left by the number already consumed.
1610        let adjusted = ax.saturating_sub(offset);
1611        result = ma_apply_along_axis(&result, adjusted, &mut f)?;
1612    }
1613    Ok(result)
1614}
1615
1616/// Vandermonde matrix from a 1-D masked input, matching `numpy.ma.vander`.
1617///
1618/// numpy.ma.vander (`numpy/ma/extras.py:2216`):
1619/// ```text
1620/// _vander = np.vander(x, n)
1621/// m = getmask(x)
1622/// if m is not nomask:
1623///     _vander[m] = 0
1624/// return _vander
1625/// ```
1626/// The plain (unmasked) Vandermonde of the raw data is built first, then
1627/// **every masked row is set to all-zeros**, and the result carries **no
1628/// mask** (it is a plain ndarray returned through the `ma` namespace). Live
1629/// oracle (numpy 2.4.5): `np.ma.vander(np.ma.array([1.,2,3],mask=[0,1,0]),3)` →
1630/// `[[1,1,1],[0,0,0],[9,3,1]]`, `getmaskarray(...) == all False`.
1631///
1632/// # Errors
1633/// Returns `FerrayError::InvalidValue` if `x` is empty.
1634pub fn ma_vander<T>(x: &MaskedArray<T, Ix1>, n: Option<usize>) -> FerrayResult<MaskedArray<T, Ix2>>
1635where
1636    T: Element + Copy + std::ops::Mul<Output = T> + num_traits::One + num_traits::Zero,
1637{
1638    let m = x.size();
1639    if m == 0 {
1640        return Err(FerrayError::invalid_value(
1641            "ma_vander: input must not be empty",
1642        ));
1643    }
1644    let cols = n.unwrap_or(m);
1645    let xs: Vec<T> = x.data().iter().copied().collect();
1646    let xmask: Vec<bool> = x.mask().iter().copied().collect();
1647    let zero = num_traits::zero::<T>();
1648    let one = num_traits::one::<T>();
1649    let mut data = vec![one; m * cols];
1650    for (i, &xi) in xs.iter().enumerate() {
1651        // Build the plain Vandermonde row from the RAW data (numpy builds
1652        // `np.vander(x)` before masking, so unmasked rows use the true value).
1653        let mut acc = one;
1654        let mut powers = Vec::with_capacity(cols);
1655        for _ in 0..cols {
1656            powers.push(acc);
1657            acc = acc * xi;
1658        }
1659        for (j, p) in powers.iter().enumerate() {
1660            // NumPy's vander defaults to decreasing powers (left-to-right).
1661            data[i * cols + (cols - 1 - j)] = *p;
1662        }
1663        // numpy then zeros the ENTIRE row of any masked input (`_vander[m]=0`).
1664        if xmask[i] {
1665            for slot in data[i * cols..(i + 1) * cols].iter_mut() {
1666                *slot = zero;
1667            }
1668        }
1669    }
1670    let data_arr = Array::from_vec(Ix2::new([m, cols]), data)?;
1671    // nomask result — masked-array constructed from data only.
1672    MaskedArray::from_data(data_arr)
1673}
1674
1675// ===========================================================================
1676// Fill-value protocol
1677// ===========================================================================
1678
1679/// Default fill value for a dtype, mirroring NumPy:
1680/// - bool: `false`
1681/// - integer: `999_999` (capped at the type max for parity with modern numpy)
1682/// - float: `1e20` (numpy uses `1e20` for f64, `1e20` cast to f32)
1683/// - complex: `1e20 + 0j`
1684///
1685/// This function is type-erased at the boundary; the type-specific
1686/// equivalents are `T::fill_default_value`. ferray represents this via a
1687/// trait helper rather than a single function.
1688#[must_use]
1689pub fn default_fill_value_f64() -> f64 {
1690    1e20
1691}
1692
1693/// Default fill value for f32.
1694#[must_use]
1695pub fn default_fill_value_f32() -> f32 {
1696    1e20_f32
1697}
1698
1699/// Default fill value for bool.
1700#[must_use]
1701pub const fn default_fill_value_bool() -> bool {
1702    false
1703}
1704
1705/// Default fill value for `i64`.
1706#[must_use]
1707pub const fn default_fill_value_i64() -> i64 {
1708    999_999
1709}
1710
1711/// Maximum fill value for a Float type (used when filling masked values
1712/// for max-reductions so they don't influence the result).
1713#[must_use]
1714pub fn maximum_fill_value<T: Float>() -> T {
1715    T::infinity()
1716}
1717
1718/// Minimum fill value for a Float type (used when filling masked values
1719/// for min-reductions).
1720#[must_use]
1721pub fn minimum_fill_value<T: Float>() -> T {
1722    T::neg_infinity()
1723}
1724
1725/// Common fill value for two masked arrays — returns `a.fill_value()` if
1726/// both share the same fill value, else `T::zero()` (NumPy's fallback).
1727pub fn common_fill_value<T, D>(a: &MaskedArray<T, D>, b: &MaskedArray<T, D>) -> T
1728where
1729    T: Element + Copy + PartialEq,
1730    D: Dimension,
1731{
1732    if a.fill_value() == b.fill_value() {
1733        a.fill_value()
1734    } else {
1735        T::zero()
1736    }
1737}
1738
1739// ===========================================================================
1740// Comparison and logical ufuncs (mask-aware)
1741// ===========================================================================
1742
1743macro_rules! ma_cmp {
1744    ($name:ident, $op:tt, $bound:path) => {
1745        /// Element-wise comparison preserving mask union.
1746        ///
1747        /// # Errors
1748        /// Returns `FerrayError::ShapeMismatch` if shapes differ.
1749        pub fn $name<T, D>(
1750            a: &MaskedArray<T, D>,
1751            b: &MaskedArray<T, D>,
1752        ) -> FerrayResult<MaskedArray<bool, D>>
1753        where
1754            T: Element + Copy + $bound,
1755            D: Dimension,
1756        {
1757            if a.shape() != b.shape() {
1758                return Err(FerrayError::shape_mismatch(format!(
1759                    "{}: shapes {:?} and {:?} differ",
1760                    stringify!($name),
1761                    a.shape(),
1762                    b.shape(),
1763                )));
1764            }
1765            let data: Vec<bool> = a
1766                .data()
1767                .iter()
1768                .zip(b.data().iter())
1769                .map(|(x, y)| x $op y)
1770                .collect();
1771            let mask: Vec<bool> = a
1772                .mask()
1773                .iter()
1774                .zip(b.mask().iter())
1775                .map(|(x, y)| *x || *y)
1776                .collect();
1777            let data_arr = Array::from_vec(a.data().dim().clone(), data)?;
1778            let mask_arr: Array<bool, D> = Array::from_vec(a.mask().dim().clone(), mask)?;
1779            MaskedArray::new(data_arr, mask_arr)
1780        }
1781    };
1782}
1783
1784ma_cmp!(ma_equal, ==, PartialEq);
1785ma_cmp!(ma_not_equal, !=, PartialEq);
1786ma_cmp!(ma_less, <, PartialOrd);
1787ma_cmp!(ma_greater, >, PartialOrd);
1788ma_cmp!(ma_less_equal, <=, PartialOrd);
1789ma_cmp!(ma_greater_equal, >=, PartialOrd);
1790
1791/// Element-wise logical AND on bool MaskedArrays. Mask is unioned.
1792///
1793/// # Errors
1794/// Returns `FerrayError::ShapeMismatch` if shapes differ.
1795pub fn ma_logical_and<D: Dimension>(
1796    a: &MaskedArray<bool, D>,
1797    b: &MaskedArray<bool, D>,
1798) -> FerrayResult<MaskedArray<bool, D>> {
1799    binary_bool(a, b, |x, y| x && y, "ma_logical_and")
1800}
1801
1802/// Element-wise logical OR.
1803///
1804/// # Errors
1805/// Returns `FerrayError::ShapeMismatch` if shapes differ.
1806pub fn ma_logical_or<D: Dimension>(
1807    a: &MaskedArray<bool, D>,
1808    b: &MaskedArray<bool, D>,
1809) -> FerrayResult<MaskedArray<bool, D>> {
1810    binary_bool(a, b, |x, y| x || y, "ma_logical_or")
1811}
1812
1813/// Element-wise logical XOR.
1814///
1815/// # Errors
1816/// Returns `FerrayError::ShapeMismatch` if shapes differ.
1817pub fn ma_logical_xor<D: Dimension>(
1818    a: &MaskedArray<bool, D>,
1819    b: &MaskedArray<bool, D>,
1820) -> FerrayResult<MaskedArray<bool, D>> {
1821    binary_bool(a, b, |x, y| x ^ y, "ma_logical_xor")
1822}
1823
1824/// Element-wise logical NOT. Mask is preserved.
1825///
1826/// # Errors
1827/// Returns an error if internal array construction fails.
1828pub fn ma_logical_not<D: Dimension>(
1829    a: &MaskedArray<bool, D>,
1830) -> FerrayResult<MaskedArray<bool, D>> {
1831    let data: Vec<bool> = a.data().iter().map(|x| !*x).collect();
1832    let data_arr = Array::from_vec(a.data().dim().clone(), data)?;
1833    let mask_arr: Array<bool, D> =
1834        Array::from_vec(a.mask().dim().clone(), a.mask().iter().copied().collect())?;
1835    MaskedArray::new(data_arr, mask_arr)
1836}
1837
1838fn binary_bool<D: Dimension>(
1839    a: &MaskedArray<bool, D>,
1840    b: &MaskedArray<bool, D>,
1841    op: impl Fn(bool, bool) -> bool,
1842    name: &str,
1843) -> FerrayResult<MaskedArray<bool, D>> {
1844    if a.shape() != b.shape() {
1845        return Err(FerrayError::shape_mismatch(format!(
1846            "{name}: shapes {:?} and {:?} differ",
1847            a.shape(),
1848            b.shape()
1849        )));
1850    }
1851    let data: Vec<bool> = a
1852        .data()
1853        .iter()
1854        .zip(b.data().iter())
1855        .map(|(x, y)| op(*x, *y))
1856        .collect();
1857    let mask: Vec<bool> = a
1858        .mask()
1859        .iter()
1860        .zip(b.mask().iter())
1861        .map(|(x, y)| *x || *y)
1862        .collect();
1863    let data_arr = Array::from_vec(a.data().dim().clone(), data)?;
1864    let mask_arr: Array<bool, D> = Array::from_vec(a.mask().dim().clone(), mask)?;
1865    MaskedArray::new(data_arr, mask_arr)
1866}
1867
1868// ===========================================================================
1869// Class helpers
1870// ===========================================================================
1871
1872/// Whether `ma` is a [`MaskedArray`]. In Rust this is statically true, so
1873/// this always returns `true` — preserved for API parity with
1874/// `numpy.ma.isMaskedArray`.
1875#[must_use]
1876pub const fn is_masked_array<T, D>(_ma: &MaskedArray<T, D>) -> bool
1877where
1878    T: Element,
1879    D: Dimension,
1880{
1881    true
1882}
1883
1884/// NumPy-spelling alias for [`is_masked_array`].
1885#[must_use]
1886pub const fn is_ma<T, D>(ma: &MaskedArray<T, D>) -> bool
1887where
1888    T: Element,
1889    D: Dimension,
1890{
1891    is_masked_array(ma)
1892}
1893
1894/// Return the mask array, materializing the all-false sentinel form into a
1895/// real bool array. Equivalent to `numpy.ma.getmaskarray`.
1896///
1897/// Always returns an `Array<bool, D>`, never `nomask`.
1898///
1899/// # Errors
1900/// Returns an error if internal array construction fails.
1901pub fn getmaskarray<T, D>(ma: &MaskedArray<T, D>) -> FerrayResult<Array<bool, D>>
1902where
1903    T: Element,
1904    D: Dimension,
1905{
1906    Array::from_vec(ma.mask().dim().clone(), ma.mask().iter().copied().collect())
1907}
1908
1909/// Return a stable identity-pair `(data_ptr, mask_ptr)` for two
1910/// MaskedArrays, useful for cheap-equality checks.
1911///
1912/// Equivalent to `numpy.ma.MaskedArray.ids` — note that the mask sentinel
1913/// state still produces a valid pointer (the mask is materialized lazily).
1914#[must_use]
1915pub fn ids<T, D>(ma: &MaskedArray<T, D>) -> (*const u8, *const u8)
1916where
1917    T: Element,
1918    D: Dimension,
1919{
1920    let data_ptr: *const u8 = ma.data() as *const _ as *const u8;
1921    let mask_ptr: *const u8 = ma.mask() as *const _ as *const u8;
1922    (data_ptr, mask_ptr)
1923}
1924
1925// ===========================================================================
1926// Mask-structure analysis: clump / notmasked run-length helpers
1927// (numpy/ma/extras.py). Slices are returned as `(start, stop)` half-open
1928// index pairs; the binding converts them to Python `slice` objects.
1929// ===========================================================================
1930
1931/// Find the runs (clumps) of `true` values in a 1-D bool slice, returning each
1932/// as a half-open `(start, stop)` index pair.
1933///
1934/// This is the structural core shared by `clump_masked` / `clump_unmasked` /
1935/// `flatnotmasked_contiguous`, mirroring `numpy.ma.extras._ezclump`
1936/// (`numpy/ma/extras.py:2105`): it walks the boolean transitions and emits one
1937/// pair per contiguous run of `true`.
1938fn ezclump_true(mask: &[bool]) -> Vec<(usize, usize)> {
1939    let n = mask.len();
1940    let mut runs: Vec<(usize, usize)> = Vec::new();
1941    let mut i = 0usize;
1942    while i < n {
1943        if mask[i] {
1944            let start = i;
1945            while i < n && mask[i] {
1946                i += 1;
1947            }
1948            runs.push((start, i));
1949        } else {
1950            i += 1;
1951        }
1952    }
1953    runs
1954}
1955
1956/// Flatten a masked array's mask into a row-major `Vec<bool>`.
1957fn flat_mask<T, D>(a: &MaskedArray<T, D>) -> Vec<bool>
1958where
1959    T: Element,
1960    D: Dimension,
1961{
1962    a.mask().iter().copied().collect()
1963}
1964
1965/// `numpy.ma.clump_masked(a)` (`numpy/ma/extras.py:2189`) — the list of
1966/// half-open `(start, stop)` slices, one per contiguous run of **masked**
1967/// elements in a 1-D masked array. Returns an empty list when nothing is
1968/// masked (numpy returns `[]` for the `nomask` case).
1969#[must_use]
1970pub fn clump_masked<T, D>(a: &MaskedArray<T, D>) -> Vec<(usize, usize)>
1971where
1972    T: Element,
1973    D: Dimension,
1974{
1975    ezclump_true(&flat_mask(a))
1976}
1977
1978/// `numpy.ma.clump_unmasked(a)` (`numpy/ma/extras.py:2235`) — the list of
1979/// half-open `(start, stop)` slices, one per contiguous run of **unmasked**
1980/// elements (numpy computes `_ezclump(~mask)`). With no mask, numpy yields the
1981/// single slice `[0, size)`.
1982#[must_use]
1983pub fn clump_unmasked<T, D>(a: &MaskedArray<T, D>) -> Vec<(usize, usize)>
1984where
1985    T: Element,
1986    D: Dimension,
1987{
1988    let inv: Vec<bool> = flat_mask(a).iter().map(|&m| !m).collect();
1989    ezclump_true(&inv)
1990}
1991
1992/// `numpy.ma.flatnotmasked_edges(a)` (`numpy/ma/extras.py:1762`) — the
1993/// `[first, last]` indices of the unmasked values of a flattened masked array.
1994///
1995/// Returns `Some([0, size-1])` when nothing is masked, `Some([first, last])`
1996/// for the partially-masked case, and `None` when every element is masked
1997/// (matching numpy's `None` return).
1998#[must_use]
1999pub fn flatnotmasked_edges<T, D>(a: &MaskedArray<T, D>) -> Option<[usize; 2]>
2000where
2001    T: Element,
2002    D: Dimension,
2003{
2004    let m = flat_mask(a);
2005    let size = m.len();
2006    if size == 0 {
2007        return None;
2008    }
2009    if !m.iter().any(|&b| b) {
2010        return Some([0, size - 1]);
2011    }
2012    let first = m.iter().position(|&b| !b)?;
2013    let last = m.iter().rposition(|&b| !b)?;
2014    Some([first, last])
2015}
2016
2017/// `numpy.ma.flatnotmasked_contiguous(a)` (`numpy/ma/extras.py:1818`) — the
2018/// list of half-open `(start, stop)` slices of contiguous **unmasked** regions
2019/// of a flattened masked array. With no mask, numpy returns the single slice
2020/// `[0, size)`; the all-masked case yields `[]`.
2021#[must_use]
2022pub fn flatnotmasked_contiguous<T, D>(a: &MaskedArray<T, D>) -> Vec<(usize, usize)>
2023where
2024    T: Element,
2025    D: Dimension,
2026{
2027    let inv: Vec<bool> = flat_mask(a).iter().map(|&m| !m).collect();
2028    ezclump_true(&inv)
2029}
2030
2031/// `numpy.ma.notmasked_edges(a, axis=None)` (`numpy/ma/extras.py:1878`).
2032///
2033/// For `axis = None` (or a 1-D array) this is exactly
2034/// [`flatnotmasked_edges`]. The numpy multi-axis form returns per-axis
2035/// coordinate tuples; that form is a deferred extension (#835 follow-up) — this
2036/// entry point covers the flattened contract used by ferray's binding.
2037#[must_use]
2038pub fn notmasked_edges<T, D>(a: &MaskedArray<T, D>) -> Option<[usize; 2]>
2039where
2040    T: Element,
2041    D: Dimension,
2042{
2043    flatnotmasked_edges(a)
2044}
2045
2046/// First/last unmasked coordinate tuples of a 2-D masked array along `axis`
2047/// (`numpy/ma/extras.py:1925` `notmasked_edges`, the explicit-`axis` branch).
2048///
2049/// numpy computes, per lane along `axis`, the min (first) and max (last)
2050/// coordinate of the unmasked positions, then compresses out fully-masked
2051/// lanes. The result is two `(rows, cols)` index tuples — the coordinate
2052/// arrays are always emitted in axis order (axis-0 indices, then axis-1
2053/// indices), independent of which `axis` selects the first/last extent.
2054///
2055/// Returns `(first, last)` where each is `(row_indices, col_indices)`. The
2056/// `row_indices` / `col_indices` vectors have one entry per lane (along the
2057/// axis orthogonal to `axis`) that contains at least one unmasked element.
2058///
2059/// # Errors
2060/// Returns `FerrayError::axis_out_of_bounds` if `axis >= 2`.
2061#[allow(
2062    clippy::type_complexity,
2063    reason = "mirrors numpy's two (rows, cols) tuples"
2064)]
2065pub fn notmasked_edges_axis2<T>(
2066    a: &MaskedArray<T, Ix2>,
2067    axis: usize,
2068) -> FerrayResult<((Vec<i64>, Vec<i64>), (Vec<i64>, Vec<i64>))>
2069where
2070    T: Element,
2071{
2072    if axis >= 2 {
2073        return Err(FerrayError::axis_out_of_bounds(axis, 2));
2074    }
2075    let shape = a.shape();
2076    let (rows, cols) = (shape[0], shape[1]);
2077    let mask: Vec<bool> = a.mask().iter().copied().collect();
2078
2079    // We iterate over lanes along `axis` for each fixed index on the orthogonal
2080    // axis; the first/last unmasked POSITION along `axis` defines the edge.
2081    let (mut first_rows, mut first_cols) = (Vec::new(), Vec::new());
2082    let (mut last_rows, mut last_cols) = (Vec::new(), Vec::new());
2083    let other_len = if axis == 0 { cols } else { rows };
2084    let axis_len = if axis == 0 { rows } else { cols };
2085    for o in 0..other_len {
2086        let mut first: Option<usize> = None;
2087        let mut last: Option<usize> = None;
2088        for k in 0..axis_len {
2089            let (r, c) = if axis == 0 { (k, o) } else { (o, k) };
2090            if !mask[r * cols + c] {
2091                if first.is_none() {
2092                    first = Some(k);
2093                }
2094                last = Some(k);
2095            }
2096        }
2097        if let (Some(f), Some(l)) = (first, last) {
2098            // Reconstruct the (row, col) coordinate of the first/last edge.
2099            let (fr_r, fr_c) = if axis == 0 { (f, o) } else { (o, f) };
2100            let (lr_r, lr_c) = if axis == 0 { (l, o) } else { (o, l) };
2101            first_rows.push(fr_r as i64);
2102            first_cols.push(fr_c as i64);
2103            last_rows.push(lr_r as i64);
2104            last_cols.push(lr_c as i64);
2105        }
2106    }
2107    Ok(((first_rows, first_cols), (last_rows, last_cols)))
2108}
2109
2110/// `numpy.ma.notmasked_contiguous(a, axis=None)` (`numpy/ma/extras.py:1936`).
2111///
2112/// `axis = None` matches [`flatnotmasked_contiguous`] (numpy delegates to it
2113/// directly). For a 2-D array with an explicit `axis`, returns one slice list
2114/// per lane along the orthogonal axis (a list of lists), mirroring numpy's
2115/// per-lane `flatnotmasked_contiguous` sweep.
2116///
2117/// # Errors
2118/// Returns `FerrayError::axis_out_of_bounds` if `axis >= 2`, and a
2119/// shape-mismatch error if the array is not 2-D when an axis is given.
2120pub fn notmasked_contiguous_axis<T>(
2121    a: &MaskedArray<T, Ix2>,
2122    axis: usize,
2123) -> FerrayResult<Vec<Vec<(usize, usize)>>>
2124where
2125    T: Element,
2126{
2127    if axis >= 2 {
2128        return Err(FerrayError::axis_out_of_bounds(axis, 2));
2129    }
2130    let shape = a.shape();
2131    let (rows, cols) = (shape[0], shape[1]);
2132    let mask: Vec<bool> = a.mask().iter().copied().collect();
2133    // `other` is the axis we iterate over; for each fixed index on `other`
2134    // we sweep the full lane along `axis` and collect unmasked runs.
2135    let other = (axis + 1) % 2;
2136    let other_len = if other == 0 { rows } else { cols };
2137    let axis_len = if axis == 0 { rows } else { cols };
2138    let mut result: Vec<Vec<(usize, usize)>> = Vec::with_capacity(other_len);
2139    for o in 0..other_len {
2140        let mut lane: Vec<bool> = Vec::with_capacity(axis_len);
2141        for k in 0..axis_len {
2142            let (r, c) = if axis == 0 { (k, o) } else { (o, k) };
2143            lane.push(mask[r * cols + c]);
2144        }
2145        let inv: Vec<bool> = lane.iter().map(|&m| !m).collect();
2146        result.push(ezclump_true(&inv));
2147    }
2148    Ok(result)
2149}
2150
2151// ===========================================================================
2152// 2-D masked matrix product (numpy/ma/core.py:8214, non-strict default)
2153// ===========================================================================
2154
2155impl<T> MaskedArray<T, Ix2>
2156where
2157    T: Element + Copy + std::ops::Add<Output = T> + std::ops::Mul<Output = T> + num_traits::Zero,
2158{
2159    /// `numpy.ma.dot(a, b)` for 2-D operands, non-strict (the default)
2160    /// (`numpy/ma/core.py:8214`).
2161    ///
2162    /// numpy computes `d = dot(filled(a, 0), filled(b, 0))` (masked entries
2163    /// contribute zero) and masks the result where **no** valid pair
2164    /// contributed: `m = ~dot(~mask_a, ~mask_b)`. Concretely `out[i, j]` is
2165    /// masked iff for every `k`, `a[i, k]` is masked OR `b[k, j]` is masked.
2166    ///
2167    /// # Errors
2168    /// Returns `FerrayError::shape_mismatch` if `self.cols != other.rows`.
2169    pub fn ma_dot_2d(&self, other: &MaskedArray<T, Ix2>) -> FerrayResult<MaskedArray<T, Ix2>> {
2170        let a_shape = self.shape();
2171        let b_shape = other.shape();
2172        let (m, k1) = (a_shape[0], a_shape[1]);
2173        let (k2, n) = (b_shape[0], b_shape[1]);
2174        if k1 != k2 {
2175            return Err(FerrayError::shape_mismatch(format!(
2176                "ma_dot_2d: lhs.cols={k1} != rhs.rows={k2}"
2177            )));
2178        }
2179        let a_data: Vec<T> = self.data().iter().copied().collect();
2180        let a_mask: Vec<bool> = self.mask().iter().copied().collect();
2181        let b_data: Vec<T> = other.data().iter().copied().collect();
2182        let b_mask: Vec<bool> = other.mask().iter().copied().collect();
2183        let zero = <T as num_traits::Zero>::zero();
2184
2185        let mut out_data = vec![zero; m * n];
2186        let mut out_mask = vec![false; m * n];
2187        for i in 0..m {
2188            for j in 0..n {
2189                let mut acc = zero;
2190                // `any_valid` tracks whether at least one k-pair was unmasked
2191                // on BOTH sides — mirrors `dot(~am, ~bm) != 0` (the result mask).
2192                let mut any_valid = false;
2193                for k in 0..k1 {
2194                    let am = a_mask[i * k1 + k];
2195                    let bm = b_mask[k * n + j];
2196                    // numpy's data is `dot(filled(a,0), filled(b,0))`: masked
2197                    // operands contribute a zero factor, but the sum still runs
2198                    // over every k.
2199                    let av = if am { zero } else { a_data[i * k1 + k] };
2200                    let bv = if bm { zero } else { b_data[k * n + j] };
2201                    acc = acc + av * bv;
2202                    if !am && !bm {
2203                        any_valid = true;
2204                    }
2205                }
2206                out_data[i * n + j] = acc;
2207                out_mask[i * n + j] = !any_valid;
2208            }
2209        }
2210        let data_arr = Array::<T, Ix2>::from_vec(Ix2::new([m, n]), out_data)?;
2211        let mask_arr = Array::<bool, Ix2>::from_vec(Ix2::new([m, n]), out_mask)?;
2212        MaskedArray::new(data_arr, mask_arr)
2213    }
2214}
2215
2216// ===========================================================================
2217// Multi-axis masked argsort (numpy.ma.argsort, numpy/ma/core.py)
2218// ===========================================================================
2219
2220impl<T, D> MaskedArray<T, D>
2221where
2222    T: Element + PartialOrd + Copy,
2223    D: Dimension,
2224{
2225    /// `numpy.ma.argsort(a, axis)` along an explicit `axis`
2226    /// (`numpy/ma/core.py` `MaskedArray.argsort`).
2227    ///
2228    /// numpy fills masked positions with the per-dtype maximum fill value
2229    /// (`endwith=True`, the default) so they sort to the **end** of each lane,
2230    /// then returns the ordinary argsort of the filled data. Within a lane,
2231    /// indices are returned relative to that lane (0-based), exactly like
2232    /// `numpy.argsort(..., axis=axis)`. The result is a plain index array
2233    /// (no mask) of the input shape.
2234    ///
2235    /// # Errors
2236    /// Returns `FerrayError::axis_out_of_bounds` if `axis >= self.ndim()`.
2237    pub fn argsort_axis(&self, axis: usize) -> FerrayResult<Array<u64, IxDyn>> {
2238        let ndim = self.ndim();
2239        if axis >= ndim {
2240            return Err(FerrayError::axis_out_of_bounds(axis, ndim));
2241        }
2242        let shape = self.shape().to_vec();
2243        let axis_len = shape[axis];
2244        let total: usize = shape.iter().product();
2245        let src_data: Vec<T> = self.data().iter().copied().collect();
2246        let src_mask: Vec<bool> = self.mask().iter().copied().collect();
2247
2248        let mut strides = vec![1usize; ndim];
2249        for i in (0..ndim.saturating_sub(1)).rev() {
2250            strides[i] = strides[i + 1] * shape[i + 1];
2251        }
2252
2253        let mut out = vec![0u64; total];
2254
2255        let outer_shape: Vec<usize> = shape
2256            .iter()
2257            .enumerate()
2258            .filter_map(|(i, &s)| if i == axis { None } else { Some(s) })
2259            .collect();
2260        let outer_size: usize = if outer_shape.is_empty() {
2261            1
2262        } else {
2263            outer_shape.iter().product()
2264        };
2265
2266        let mut outer_multi = vec![0usize; outer_shape.len()];
2267        for _ in 0..outer_size {
2268            // Resolve the flat index of lane position `k` for the current
2269            // outer multi-index.
2270            let flat_of = |k: usize| -> usize {
2271                let mut flat = 0usize;
2272                let mut o = 0usize;
2273                for (i, &stride) in strides.iter().enumerate() {
2274                    if i == axis {
2275                        flat += stride * k;
2276                    } else {
2277                        flat += stride * outer_multi[o];
2278                        o += 1;
2279                    }
2280                }
2281                flat
2282            };
2283
2284            // Stable argsort over lane positions: unmasked compare by value;
2285            // any masked entry sorts after every unmasked one (the `endwith`
2286            // fill-to-max behaviour), with masked entries keeping input order.
2287            let mut order: Vec<usize> = (0..axis_len).collect();
2288            order.sort_by(|&x, &y| {
2289                let fx = flat_of(x);
2290                let fy = flat_of(y);
2291                match (src_mask[fx], src_mask[fy]) {
2292                    (false, false) => src_data[fx]
2293                        .partial_cmp(&src_data[fy])
2294                        .unwrap_or(std::cmp::Ordering::Equal),
2295                    (false, true) => std::cmp::Ordering::Less,
2296                    (true, false) => std::cmp::Ordering::Greater,
2297                    (true, true) => std::cmp::Ordering::Equal,
2298                }
2299            });
2300
2301            for (k, &pos) in order.iter().enumerate() {
2302                out[flat_of(k)] = pos as u64;
2303            }
2304
2305            for i in (0..outer_shape.len()).rev() {
2306                outer_multi[i] += 1;
2307                if outer_multi[i] < outer_shape[i] {
2308                    break;
2309                }
2310                outer_multi[i] = 0;
2311            }
2312        }
2313
2314        Array::<u64, IxDyn>::from_vec(IxDyn::new(&shape), out)
2315    }
2316}
2317
2318// ===========================================================================
2319// Tests
2320// ===========================================================================
2321
2322#[cfg(test)]
2323mod tests {
2324    use super::*;
2325    use ferray_core::Ix1;
2326
2327    fn arr1f(v: Vec<f64>) -> Array<f64, Ix1> {
2328        let n = v.len();
2329        Array::from_vec(Ix1::new([n]), v).unwrap()
2330    }
2331
2332    fn mask1(v: Vec<bool>) -> Array<bool, Ix1> {
2333        let n = v.len();
2334        Array::from_vec(Ix1::new([n]), v).unwrap()
2335    }
2336
2337    fn ma_f1(d: Vec<f64>, m: Vec<bool>) -> MaskedArray<f64, Ix1> {
2338        MaskedArray::new(arr1f(d), mask1(m)).unwrap()
2339    }
2340
2341    #[test]
2342    fn prod_skips_masked() {
2343        let ma = ma_f1(vec![2.0, 3.0, 5.0, 7.0], vec![false, true, false, false]);
2344        let p = ma.prod().unwrap();
2345        assert!((p - 70.0).abs() < 1e-10); // 2 * 5 * 7
2346    }
2347
2348    #[test]
2349    fn cumsum_propagates_mask() {
2350        let ma = ma_f1(vec![1.0, 2.0, 3.0, 4.0], vec![false, true, false, false]);
2351        let r = ma.cumsum_flat().unwrap();
2352        let mask: Vec<bool> = r.mask().iter().copied().collect();
2353        let data: Vec<f64> = r.data().iter().copied().collect();
2354        assert_eq!(mask, vec![false, true, false, false]);
2355        // Running sum: 1, (skipped→still 1, marked masked), 1+3=4, 4+4=8
2356        assert!((data[0] - 1.0).abs() < 1e-10);
2357        assert!((data[2] - 4.0).abs() < 1e-10);
2358        assert!((data[3] - 8.0).abs() < 1e-10);
2359    }
2360
2361    #[test]
2362    fn argmin_argmax_skip_masked() {
2363        let ma = ma_f1(vec![5.0, 1.0, 9.0, 3.0], vec![false, true, false, false]);
2364        // unmasked: 5, 9, 3 at positions 0, 2, 3 → min at 3, max at 2
2365        assert_eq!(ma.argmin().unwrap(), 3);
2366        assert_eq!(ma.argmax().unwrap(), 2);
2367    }
2368
2369    #[test]
2370    fn ptp_basic() {
2371        let ma = ma_f1(vec![5.0, 1.0, 9.0, 3.0], vec![false, true, false, false]);
2372        // unmasked: 5, 9, 3 → ptp = 9 - 3 = 6
2373        assert!((ma.ptp().unwrap() - 6.0).abs() < 1e-10);
2374    }
2375
2376    #[test]
2377    fn median_odd_and_even() {
2378        let odd = ma_f1(vec![3.0, 1.0, 4.0, 1.0, 5.0], vec![false; 5]);
2379        assert!((odd.median().unwrap() - 3.0).abs() < 1e-10);
2380        let even = ma_f1(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
2381        assert!((even.median().unwrap() - 2.5).abs() < 1e-10);
2382    }
2383
2384    #[test]
2385    fn average_unweighted_matches_mean() {
2386        let ma = ma_f1(vec![2.0, 4.0, 6.0], vec![false; 3]);
2387        assert!((ma.average(None).unwrap() - 4.0).abs() < 1e-10);
2388    }
2389
2390    #[test]
2391    fn average_weighted_skips_masked() {
2392        let ma = ma_f1(vec![1.0, 100.0, 3.0], vec![false, true, false]);
2393        let w = arr1f(vec![1.0, 1.0, 3.0]);
2394        // unmasked weighted: (1*1 + 3*3) / (1 + 3) = 10/4 = 2.5
2395        assert!((ma.average(Some(&w)).unwrap() - 2.5).abs() < 1e-10);
2396    }
2397
2398    #[test]
2399    fn anom_centers_at_zero() {
2400        let ma = ma_f1(vec![1.0, 2.0, 3.0], vec![false; 3]);
2401        let a = ma.anom().unwrap();
2402        let data: Vec<f64> = a.data().iter().copied().collect();
2403        assert!((data[0] - (-1.0)).abs() < 1e-10);
2404        assert!((data[1] - 0.0).abs() < 1e-10);
2405        assert!((data[2] - 1.0).abs() < 1e-10);
2406    }
2407
2408    #[test]
2409    fn masked_all_is_fully_masked() {
2410        let ma: MaskedArray<f64, Ix1> = masked_all(Ix1::new([5])).unwrap();
2411        let mask: Vec<bool> = ma.mask().iter().copied().collect();
2412        assert_eq!(mask, vec![true; 5]);
2413    }
2414
2415    #[test]
2416    fn masked_values_within_tol() {
2417        let arr = arr1f(vec![1.0, 1.0001, 2.0]);
2418        let r = masked_values(&arr, 1.0, 1e-3, 0.0).unwrap();
2419        let mask: Vec<bool> = r.mask().iter().copied().collect();
2420        assert_eq!(mask, vec![true, true, false]);
2421    }
2422
2423    #[test]
2424    fn harden_mask_blocks_clearing() {
2425        let mut ma = ma_f1(vec![1.0, 2.0], vec![false, true]);
2426        ma.harden_mask().unwrap();
2427        assert!(ma.is_hard_mask());
2428        // Try to clear the mask bit at index 1.
2429        ma.set_mask_flat(1, false).unwrap();
2430        let mask: Vec<bool> = ma.mask().iter().copied().collect();
2431        // Hardened: clear is a no-op.
2432        assert_eq!(mask, vec![false, true]);
2433        ma.soften_mask().unwrap();
2434        ma.set_mask_flat(1, false).unwrap();
2435        let mask2: Vec<bool> = ma.mask().iter().copied().collect();
2436        assert_eq!(mask2, vec![false, false]);
2437    }
2438
2439    #[test]
2440    fn mask_or_unions() {
2441        let m1 = mask1(vec![true, false, false]);
2442        let m2 = mask1(vec![false, true, false]);
2443        let r = mask_or(&m1, &m2).unwrap();
2444        let v: Vec<bool> = r.iter().copied().collect();
2445        assert_eq!(v, vec![true, true, false]);
2446    }
2447
2448    #[test]
2449    fn make_mask_none_is_all_false() {
2450        let m: Array<bool, Ix1> = make_mask_none(Ix1::new([3])).unwrap();
2451        let v: Vec<bool> = m.iter().copied().collect();
2452        assert_eq!(v, vec![false; 3]);
2453    }
2454
2455    #[test]
2456    fn clip_unmasked_only() {
2457        let ma = ma_f1(vec![-5.0, 0.0, 5.0, 10.0], vec![false, false, false, true]);
2458        let r = ma.clip(-1.0, 3.0).unwrap();
2459        let data: Vec<f64> = r.data().iter().copied().collect();
2460        // Masked element passes through (10.0).
2461        assert_eq!(data, vec![-1.0, 0.0, 3.0, 10.0]);
2462    }
2463
2464    #[test]
2465    fn repeat_propagates_mask() {
2466        let ma = ma_f1(vec![1.0, 2.0], vec![false, true]);
2467        let r = ma.repeat(3).unwrap();
2468        let data: Vec<f64> = r.data().iter().copied().collect();
2469        let mask: Vec<bool> = r.mask().iter().copied().collect();
2470        assert_eq!(data, vec![1.0, 1.0, 1.0, 2.0, 2.0, 2.0]);
2471        assert_eq!(mask, vec![false, false, false, true, true, true]);
2472    }
2473
2474    #[test]
2475    fn ma_unique_dedups_unmasked() {
2476        let ma = ma_f1(
2477            vec![3.0, 1.0, 2.0, 1.0, 3.0, 9.0],
2478            vec![false, false, false, false, false, true],
2479        );
2480        let v = ma_unique(&ma).unwrap();
2481        let data: Vec<f64> = v.iter().copied().collect();
2482        assert_eq!(data, vec![1.0, 2.0, 3.0]);
2483    }
2484
2485    #[test]
2486    fn ma_isin_basic() {
2487        let ma = ma_f1(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
2488        let r = ma_isin(&ma, &[2.0, 4.0]).unwrap();
2489        let data: Vec<bool> = r.data().iter().copied().collect();
2490        assert_eq!(data, vec![false, true, false, true]);
2491    }
2492
2493    #[test]
2494    fn ma_dot_flat_skips_masked_pairs() {
2495        let a = ma_f1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
2496        let b = ma_f1(vec![4.0, 5.0, 6.0], vec![false, false, false]);
2497        // Pair (1,4): both ok → 4. Pair (2,5): a masked → skip. Pair (3,6): both ok → 18.
2498        // Total = 22.
2499        assert!((a.ma_dot_flat(&b).unwrap() - 22.0).abs() < 1e-10);
2500    }
2501
2502    #[test]
2503    fn fill_value_protocol_constants() {
2504        assert_eq!(default_fill_value_f64(), 1e20);
2505        assert!(!default_fill_value_bool());
2506        assert!(maximum_fill_value::<f64>().is_infinite());
2507        assert!(
2508            minimum_fill_value::<f64>().is_infinite()
2509                && minimum_fill_value::<f64>().is_sign_negative()
2510        );
2511    }
2512
2513    #[test]
2514    fn common_fill_value_returns_shared_or_zero() {
2515        let a = ma_f1(vec![1.0, 2.0], vec![false, false]).with_fill_value(99.0);
2516        let b = ma_f1(vec![3.0, 4.0], vec![false, false]).with_fill_value(99.0);
2517        assert_eq!(common_fill_value(&a, &b), 99.0);
2518        let c = ma_f1(vec![5.0, 6.0], vec![false, false]).with_fill_value(0.5);
2519        assert_eq!(common_fill_value(&a, &c), 0.0); // fall back to zero
2520    }
2521
2522    #[test]
2523    fn ma_equal_and_friends_union_mask() {
2524        let a = ma_f1(vec![1.0, 2.0, 3.0], vec![false, false, true]);
2525        let b = ma_f1(vec![1.0, 9.0, 3.0], vec![false, true, false]);
2526        let r = ma_equal(&a, &b).unwrap();
2527        let data: Vec<bool> = r.data().iter().copied().collect();
2528        let mask: Vec<bool> = r.mask().iter().copied().collect();
2529        assert_eq!(data, vec![true, false, true]);
2530        assert_eq!(mask, vec![false, true, true]);
2531    }
2532
2533    #[test]
2534    fn ma_logical_and_basic() {
2535        let a = MaskedArray::new(
2536            Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![true, true, false]).unwrap(),
2537            Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![false; 3]).unwrap(),
2538        )
2539        .unwrap();
2540        let b = MaskedArray::new(
2541            Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![true, false, true]).unwrap(),
2542            Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![false; 3]).unwrap(),
2543        )
2544        .unwrap();
2545        let r = ma_logical_and(&a, &b).unwrap();
2546        let data: Vec<bool> = r.data().iter().copied().collect();
2547        assert_eq!(data, vec![true, false, false]);
2548    }
2549
2550    #[test]
2551    fn is_masked_array_always_true() {
2552        let ma = ma_f1(vec![1.0], vec![false]);
2553        assert!(is_masked_array(&ma));
2554        assert!(is_ma(&ma));
2555    }
2556
2557    #[test]
2558    fn getmaskarray_materializes() {
2559        let ma = MaskedArray::<f64, Ix1>::from_data(arr1f(vec![1.0, 2.0])).unwrap();
2560        let m = getmaskarray(&ma).unwrap();
2561        let v: Vec<bool> = m.iter().copied().collect();
2562        assert_eq!(v, vec![false; 2]);
2563    }
2564
2565    #[test]
2566    fn diagonal_main_and_offset() {
2567        use ferray_core::Ix2;
2568        let data = Array::<f64, Ix2>::from_vec(
2569            Ix2::new([3, 3]),
2570            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
2571        )
2572        .unwrap();
2573        let mask = Array::<bool, Ix2>::from_vec(Ix2::new([3, 3]), vec![false; 9]).unwrap();
2574        let ma = MaskedArray::new(data, mask).unwrap();
2575        let main = ma.diagonal(0).unwrap();
2576        let main_data: Vec<f64> = main.data().iter().copied().collect();
2577        assert_eq!(main_data, vec![1.0, 5.0, 9.0]);
2578        let upper = ma.diagonal(1).unwrap();
2579        let upper_data: Vec<f64> = upper.data().iter().copied().collect();
2580        assert_eq!(upper_data, vec![2.0, 6.0]);
2581    }
2582
2583    #[test]
2584    fn trace_sums_diagonal() {
2585        use ferray_core::Ix2;
2586        let data = Array::<f64, Ix2>::from_vec(
2587            Ix2::new([3, 3]),
2588            vec![1.0, 0.0, 0.0, 0.0, 5.0, 0.0, 0.0, 0.0, 9.0],
2589        )
2590        .unwrap();
2591        let mask = Array::<bool, Ix2>::from_vec(Ix2::new([3, 3]), vec![false; 9]).unwrap();
2592        let ma = MaskedArray::new(data, mask).unwrap();
2593        assert!((ma.trace(0).unwrap() - 15.0).abs() < 1e-10);
2594    }
2595
2596    #[test]
2597    fn ma_apply_along_axis_sum_lane() {
2598        use ferray_core::IxDyn;
2599        let data =
2600            Array::<f64, IxDyn>::from_vec(IxDyn::new(&[2, 3]), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
2601                .unwrap();
2602        let mask = Array::<bool, IxDyn>::from_vec(IxDyn::new(&[2, 3]), vec![false; 6]).unwrap();
2603        let ma = MaskedArray::new(data, mask).unwrap();
2604        let result = ma_apply_along_axis(&ma, 1, |lane| {
2605            let s: f64 = lane.data().iter().copied().sum();
2606            Ok((s, false))
2607        })
2608        .unwrap();
2609        let v: Vec<f64> = result.data().iter().copied().collect();
2610        assert_eq!(v, vec![6.0, 15.0]);
2611    }
2612
2613    // -----------------------------------------------------------------------
2614    // Masked set ops (numpy.ma.intersect1d/union1d/setdiff1d/setxor1d).
2615    // Expected values from the numpy 2.4.5 oracle (R-CHAR-3):
2616    //   a = ma([1,2,3,4], mask=[0,1,0,0]); b = ma([3,4,5], mask=[0,0,1])
2617    //   intersect -> data[3,4,--] mask[F,F,T]
2618    //   union     -> data[1,3,4,--] mask[F,F,F,T]
2619    //   setdiff   -> data[1] mask[F]
2620    //   setxor    -> data[1] mask[F]
2621    //   unique(a) -> data[1,3,4,--] mask[F,F,F,T]
2622    // -----------------------------------------------------------------------
2623    fn ab() -> (MaskedArray<f64, Ix1>, MaskedArray<f64, Ix1>) {
2624        (
2625            ma_f1(vec![1.0, 2.0, 3.0, 4.0], vec![false, true, false, false]),
2626            ma_f1(vec![3.0, 4.0, 5.0], vec![false, false, true]),
2627        )
2628    }
2629
2630    fn data_mask(m: &MaskedArray<f64, Ix1>) -> (Vec<f64>, Vec<bool>) {
2631        (
2632            m.data().iter().copied().collect(),
2633            m.mask().iter().copied().collect(),
2634        )
2635    }
2636
2637    #[test]
2638    fn ma_unique_masked_trails_one_masked_slot() -> FerrayResult<()> {
2639        let (a, _) = ab();
2640        let (data, mask) = data_mask(&ma_unique_masked(&a)?);
2641        // numpy: unmasked uniques [1,3,4] then one trailing masked slot.
2642        assert_eq!(&data[..3], &[1.0, 3.0, 4.0]);
2643        assert_eq!(mask, vec![false, false, false, true]);
2644        Ok(())
2645    }
2646
2647    #[test]
2648    fn ma_intersect1d_common_with_both_masked() -> FerrayResult<()> {
2649        let (a, b) = ab();
2650        let (data, mask) = data_mask(&ma_intersect1d(&a, &b)?);
2651        assert_eq!(&data[..2], &[3.0, 4.0]);
2652        assert_eq!(mask, vec![false, false, true]);
2653        Ok(())
2654    }
2655
2656    #[test]
2657    fn ma_union1d_all_uniques_plus_masked() -> FerrayResult<()> {
2658        let (a, b) = ab();
2659        let (data, mask) = data_mask(&ma_union1d(&a, &b)?);
2660        assert_eq!(&data[..3], &[1.0, 3.0, 4.0]);
2661        assert_eq!(mask, vec![false, false, false, true]);
2662        Ok(())
2663    }
2664
2665    #[test]
2666    fn ma_setdiff1d_drops_masked_when_rhs_masked() -> FerrayResult<()> {
2667        let (a, b) = ab();
2668        let (data, mask) = data_mask(&ma_setdiff1d(&a, &b)?);
2669        // a's masked slot is removed because b is also masked.
2670        assert_eq!(data, vec![1.0]);
2671        assert_eq!(mask, vec![false]);
2672        Ok(())
2673    }
2674
2675    #[test]
2676    fn ma_setxor1d_symmetric_difference() -> FerrayResult<()> {
2677        let (a, b) = ab();
2678        let (data, mask) = data_mask(&ma_setxor1d(&a, &b)?);
2679        // both masked -> masked slot cancels; only unmasked-unique-once is 1.
2680        assert_eq!(data, vec![1.0]);
2681        assert_eq!(mask, vec![false]);
2682        Ok(())
2683    }
2684
2685    // -----------------------------------------------------------------------
2686    // compress_rowcols / mask_rowcols (numpy.ma.extras).
2687    // Oracle: m = ma([[1,2,3],[4,5,6]], mask=[[0,0,1],[0,0,0]])
2688    //   compress_rowcols(None) -> [[4,5]]
2689    //   compress_rows          -> [[4,5,6]]
2690    //   compress_cols          -> [[1,2],[4,5]]
2691    //   mask_rowcols(None)     -> mask[[T,T,T],[F,F,T]]
2692    // -----------------------------------------------------------------------
2693    fn m23() -> FerrayResult<MaskedArray<f64, Ix2>> {
2694        let data =
2695            Array::<f64, Ix2>::from_vec(Ix2::new([2, 3]), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])?;
2696        let mask = Array::<bool, Ix2>::from_vec(
2697            Ix2::new([2, 3]),
2698            vec![false, false, true, false, false, false],
2699        )?;
2700        MaskedArray::new(data, mask)
2701    }
2702
2703    #[test]
2704    fn ma_compress_rowcols_both() -> FerrayResult<()> {
2705        let r = ma_compress_rowcols(&m23()?, None)?;
2706        assert_eq!(r.shape(), &[1, 2]);
2707        let v: Vec<f64> = r.iter().copied().collect();
2708        assert_eq!(v, vec![4.0, 5.0]);
2709        Ok(())
2710    }
2711
2712    #[test]
2713    fn ma_compress_rows_and_cols() -> FerrayResult<()> {
2714        let rows = ma_compress_rows(&m23()?)?;
2715        assert_eq!(rows.shape(), &[1, 3]);
2716        assert_eq!(
2717            rows.iter().copied().collect::<Vec<f64>>(),
2718            vec![4.0, 5.0, 6.0]
2719        );
2720        let cols = ma_compress_cols(&m23()?)?;
2721        assert_eq!(cols.shape(), &[2, 2]);
2722        assert_eq!(
2723            cols.iter().copied().collect::<Vec<f64>>(),
2724            vec![1.0, 2.0, 4.0, 5.0]
2725        );
2726        Ok(())
2727    }
2728
2729    #[test]
2730    fn ma_mask_rowcols_masks_whole_row_and_col() -> FerrayResult<()> {
2731        let r = ma_mask_rowcols(&m23()?, None)?;
2732        let mask: Vec<bool> = r.mask().iter().copied().collect();
2733        // row 0 and col 2 both masked: [[T,T,T],[F,F,T]]
2734        assert_eq!(mask, vec![true, true, true, false, false, true]);
2735        let data: Vec<f64> = r.data().iter().copied().collect();
2736        assert_eq!(data, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
2737        Ok(())
2738    }
2739
2740    // -----------------------------------------------------------------------
2741    // Masked cov / corrcoef (numpy.ma.cov / corrcoef).
2742    // Oracle: m = ma([[1,2,3],[4,5,6]], mask=[[0,0,1],[0,0,0]])
2743    //   cov      -> [[0.5,0.5],[0.5,1.0]]
2744    //   corrcoef -> [[1.0, 0.70710678...],[0.70710678..., 1.0]]
2745    // -----------------------------------------------------------------------
2746    #[test]
2747    fn ma_cov_unmasked_pairs() -> FerrayResult<()> {
2748        let c = ma_cov(&m23()?, true, false, None)?;
2749        let data: Vec<f64> = c.data().iter().copied().collect();
2750        let mask: Vec<bool> = c.mask().iter().copied().collect();
2751        let expect = [0.5, 0.5, 0.5, 1.0];
2752        for (g, e) in data.iter().zip(expect.iter()) {
2753            assert!((g - e).abs() < 1e-12, "cov {g} != {e}");
2754        }
2755        assert_eq!(mask, vec![false; 4]);
2756        Ok(())
2757    }
2758
2759    #[test]
2760    fn ma_corrcoef_normalized() -> FerrayResult<()> {
2761        let c = ma_corrcoef(&m23()?, true)?;
2762        let data: Vec<f64> = c.data().iter().copied().collect();
2763        let expect = [1.0, 0.7071067811865475, 0.7071067811865475, 1.0];
2764        for (g, e) in data.iter().zip(expect.iter()) {
2765            assert!((g - e).abs() < 1e-12, "corr {g} != {e}");
2766        }
2767        Ok(())
2768    }
2769
2770    // -----------------------------------------------------------------------
2771    // Mask-structure analysis: clump / notmasked run-length helpers.
2772    //
2773    // Oracle (numpy 2.4.5) for `m = ma.array([1,2,3,4,5,6], mask=[0,0,1,1,0,0])`:
2774    //   clump_masked          -> [slice(2, 4)]
2775    //   clump_unmasked        -> [slice(0, 2), slice(4, 6)]
2776    //   notmasked_contiguous  -> [slice(0, 2), slice(4, 6)]
2777    //   notmasked_edges       -> [0, 5]
2778    //   flatnotmasked_edges   -> [0, 5]
2779    // -----------------------------------------------------------------------
2780    #[test]
2781    fn clump_run_length_matches_numpy() {
2782        let m = ma_f1(
2783            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
2784            vec![false, false, true, true, false, false],
2785        );
2786        assert_eq!(clump_masked(&m), vec![(2, 4)]);
2787        assert_eq!(clump_unmasked(&m), vec![(0, 2), (4, 6)]);
2788        assert_eq!(flatnotmasked_contiguous(&m), vec![(0, 2), (4, 6)]);
2789        assert_eq!(flatnotmasked_edges(&m), Some([0, 5]));
2790        assert_eq!(notmasked_edges(&m), Some([0, 5]));
2791    }
2792
2793    #[test]
2794    fn clump_nomask_and_allmask_edges() {
2795        // numpy: clump_unmasked(no mask) -> [slice(0,n)], clump_masked -> [];
2796        // flatnotmasked_edges(no mask) -> [0, n-1].
2797        let none = ma_f1(vec![1.0, 2.0, 3.0], vec![false, false, false]);
2798        assert_eq!(clump_masked(&none), vec![]);
2799        assert_eq!(clump_unmasked(&none), vec![(0, 3)]);
2800        assert_eq!(flatnotmasked_edges(&none), Some([0, 2]));
2801        // numpy: all masked -> clump_unmasked [], clump_masked [slice(0,n)],
2802        // flatnotmasked_contiguous [], flatnotmasked_edges None.
2803        let all = ma_f1(vec![1.0, 2.0, 3.0], vec![true, true, true]);
2804        assert_eq!(clump_unmasked(&all), vec![]);
2805        assert_eq!(clump_masked(&all), vec![(0, 3)]);
2806        assert_eq!(flatnotmasked_contiguous(&all), vec![]);
2807        assert_eq!(flatnotmasked_edges(&all), None);
2808    }
2809
2810    #[test]
2811    fn clump_leading_and_trailing_masked() {
2812        // numpy for ma.masked_array(arange(10)); a[[0,1,2,6,8,9]] = masked:
2813        //   clump_masked   -> [slice(0,3), slice(6,7), slice(8,10)]
2814        //   clump_unmasked -> [slice(3,6), slice(7,8)]
2815        let mut mask = vec![false; 10];
2816        for &i in &[0usize, 1, 2, 6, 8, 9] {
2817            mask[i] = true;
2818        }
2819        let data: Vec<f64> = (0..10).map(|x| x as f64).collect();
2820        let m = ma_f1(data, mask);
2821        assert_eq!(clump_masked(&m), vec![(0, 3), (6, 7), (8, 10)]);
2822        assert_eq!(clump_unmasked(&m), vec![(3, 6), (7, 8)]);
2823    }
2824
2825    #[test]
2826    fn notmasked_contiguous_axis_2d_matches_numpy() -> FerrayResult<()> {
2827        use ferray_core::Ix2;
2828        // numpy:
2829        //   a = arange(12).reshape(3,4); mask[1:,:-1]=1; mask[0,1]=1; mask[-1,0]=0
2830        //   notmasked_contiguous(ma, axis=0) ->
2831        //     [[slice(0,1), slice(2,3)], [], [slice(0,1)], [slice(0,3)]]
2832        //   notmasked_contiguous(ma, axis=1) ->
2833        //     [[slice(0,1), slice(2,4)], [slice(3,4)], [slice(0,1), slice(3,4)]]
2834        // mask matrix (row-major):
2835        //   [[0,1,0,0],[1,1,1,0],[0,1,1,0]]
2836        let mask = vec![
2837            false, true, false, false, true, true, true, false, false, true, true, false,
2838        ];
2839        let data: Vec<f64> = (0..12).map(|x| x as f64).collect();
2840        let d = Array::<f64, Ix2>::from_vec(Ix2::new([3, 4]), data)?;
2841        let mk = Array::<bool, Ix2>::from_vec(Ix2::new([3, 4]), mask)?;
2842        let m = MaskedArray::new(d, mk)?;
2843        let by0 = notmasked_contiguous_axis(&m, 0)?;
2844        assert_eq!(
2845            by0,
2846            vec![vec![(0, 1), (2, 3)], vec![], vec![(0, 1)], vec![(0, 3)]]
2847        );
2848        let by1 = notmasked_contiguous_axis(&m, 1)?;
2849        assert_eq!(
2850            by1,
2851            vec![vec![(0, 1), (2, 4)], vec![(3, 4)], vec![(0, 1), (3, 4)]]
2852        );
2853        Ok(())
2854    }
2855
2856    // -----------------------------------------------------------------------
2857    // 2-D masked dot. Oracle (numpy 2.4.5):
2858    //   a = ma.array([[1.,2],[3,4]], mask=[[0,1],[0,0]])
2859    //   ma.dot(a, a).data -> [[1, 0], [15, 16]]  (masked entries -> 0 in product)
2860    //   ma.dot(a, a).mask -> [[False, True], [False, False]]
2861    // -----------------------------------------------------------------------
2862    #[test]
2863    fn ma_dot_2d_matches_numpy() -> FerrayResult<()> {
2864        use ferray_core::Ix2;
2865        let d = Array::<f64, Ix2>::from_vec(Ix2::new([2, 2]), vec![1.0, 2.0, 3.0, 4.0])?;
2866        let mk = Array::<bool, Ix2>::from_vec(Ix2::new([2, 2]), vec![false, true, false, false])?;
2867        let a = MaskedArray::new(d, mk)?;
2868        let out = a.ma_dot_2d(&a)?;
2869        let data: Vec<f64> = out.data().iter().copied().collect();
2870        let mask: Vec<bool> = out.mask().iter().copied().collect();
2871        // out[0,0]=1*1=1 (a[0,1] masked -> 0 factor); out[0,1] masked (every
2872        // contributing path traverses the masked a[0,1], data sums to 0).
2873        assert_eq!(data, vec![1.0, 0.0, 15.0, 16.0]);
2874        assert_eq!(mask, vec![false, true, false, false]);
2875        Ok(())
2876    }
2877
2878    // -----------------------------------------------------------------------
2879    // Multi-axis argsort. Oracle (numpy 2.4.5):
2880    //   b = ma.array([[3,1,2],[6,5,4]], mask=[[0,0,1],[0,0,0]])
2881    //   ma.argsort(b, axis=1) -> [[1,0,2],[2,1,0]]
2882    //   ma.argsort(b, axis=0) -> [[0,0,1],[1,1,0]]
2883    // -----------------------------------------------------------------------
2884    #[test]
2885    fn argsort_axis_matches_numpy() -> FerrayResult<()> {
2886        use ferray_core::Ix2;
2887        let d = Array::<f64, Ix2>::from_vec(Ix2::new([2, 3]), vec![3.0, 1.0, 2.0, 6.0, 5.0, 4.0])?;
2888        let mk = Array::<bool, Ix2>::from_vec(
2889            Ix2::new([2, 3]),
2890            vec![false, false, true, false, false, false],
2891        )?;
2892        let b = MaskedArray::new(d, mk)?;
2893        let by1 = b.argsort_axis(1)?;
2894        let v1: Vec<u64> = by1.iter().copied().collect();
2895        assert_eq!(v1, vec![1, 0, 2, 2, 1, 0]);
2896        let by0 = b.argsort_axis(0)?;
2897        let v0: Vec<u64> = by0.iter().copied().collect();
2898        assert_eq!(v0, vec![0, 0, 1, 1, 1, 0]);
2899        Ok(())
2900    }
2901}