Skip to main content

ferray_ma/
interop.rs

1// ferray-ma: Interop with ferray-core's Array<T, D>
2//
3// In NumPy, MaskedArray subclasses ndarray, so anywhere ndarray is accepted
4// MaskedArray works too — with mask propagation handled automatically by
5// __array_ufunc__. Rust has no inheritance, so we provide the interop in
6// three layers:
7//
8//   1. `AsRef<Array<T, D>>` for low-level access — pass `&masked.as_ref()`
9//      where `&Array<T, D>` is expected, accepting that the mask is dropped.
10//      Useful when you need the data quickly and the mask is irrelevant.
11//
12//   2. `From<MaskedArray<T, D>>` for `Array<T, D>` — owned data extraction
13//      that consumes the mask. Use when you want to convert and forget.
14//
15//   3. `apply_unary` / `apply_binary` — mask-aware adapters that take any
16//      Array→Array function (e.g. `ferray_ufunc::sin`) and propagate the
17//      mask through to the result. The function operates on every element
18//      of the underlying data array (including masked positions, which
19//      hold whatever value is there); the mask is then re-attached to the
20//      result so that downstream consumers see the correct invalidity.
21//
22// See: https://github.com/dollspace-gay/ferray/issues/505
23
24use ferray_core::Array;
25use ferray_core::dimension::Dimension;
26use ferray_core::dtype::Element;
27use ferray_core::error::{FerrayError, FerrayResult};
28
29use crate::MaskedArray;
30
31// ---------------------------------------------------------------------------
32// AsRef / From — passive interop
33// ---------------------------------------------------------------------------
34
35impl<T: Element, D: Dimension> AsRef<Array<T, D>> for MaskedArray<T, D> {
36    /// Borrow the underlying data array, dropping the mask.
37    ///
38    /// This lets you pass a `&MaskedArray<T, D>` to any function that takes
39    /// `&Array<T, D>`, but the mask is **not** consulted — masked positions
40    /// will be processed like normal data. Use [`MaskedArray::apply_unary`]
41    /// or [`MaskedArray::apply_binary`] when you want mask propagation.
42    ///
43    /// # Example
44    /// ```
45    /// # use ferray_core::{Array, dimension::Ix1};
46    /// # use ferray_ma::MaskedArray;
47    /// # let data = Array::<f64, Ix1>::from_vec(Ix1::new([3]), vec![1.0, 2.0, 3.0]).unwrap();
48    /// # let mask = Array::<bool, Ix1>::from_vec(Ix1::new([3]), vec![false, true, false]).unwrap();
49    /// let ma = MaskedArray::new(data, mask).unwrap();
50    /// // Pass through a function that operates on `&Array<T, D>`:
51    /// let arr_ref: &Array<f64, Ix1> = ma.as_ref();
52    /// assert_eq!(arr_ref.shape(), &[3]);
53    /// ```
54    fn as_ref(&self) -> &Array<T, D> {
55        self.data()
56    }
57}
58
59impl<T: Element + Copy, D: Dimension> From<MaskedArray<T, D>> for Array<T, D> {
60    /// Consume a `MaskedArray` and return its underlying data array,
61    /// **discarding** the mask. Equivalent to calling `ma.into_data()`.
62    ///
63    /// Requires `T: Copy` because the underlying data buffer is cloned;
64    /// use [`MaskedArray::filled`] / [`MaskedArray::filled_default`] for a
65    /// mask-aware materialization with custom fill semantics.
66    fn from(ma: MaskedArray<T, D>) -> Self {
67        ma.into_data()
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Active interop — apply functions with mask propagation
73// ---------------------------------------------------------------------------
74
75impl<T, D> MaskedArray<T, D>
76where
77    T: Element + Copy,
78    D: Dimension,
79{
80    /// Consume the masked array and return its underlying data array,
81    /// dropping the mask.
82    ///
83    /// Use [`MaskedArray::filled_default`] (or [`MaskedArray::filled`]) if
84    /// you want masked positions replaced by a sentinel value before
85    /// dropping the mask.
86    pub fn into_data(self) -> Array<T, D> {
87        // We can't move out of a struct with non-Copy fields directly
88        // because of `data_mut()` borrowing semantics, so destructure
89        // the unsafe-but-safe internal `data` field via the public getter.
90        // The clone here is unavoidable without a fully private accessor.
91        self.data().clone()
92    }
93
94    /// Apply a unary function to the underlying data and re-attach the
95    /// mask, propagating it to the result.
96    ///
97    /// The function `f` is called on the **entire** data array — masked
98    /// positions are processed alongside unmasked ones, but their values
99    /// in the result are immediately overwritten with the masked array's
100    /// `fill_value`. This matches NumPy's `__array_ufunc__` semantics where
101    /// ufuncs run over the raw data and the mask is propagated separately.
102    ///
103    /// # Example
104    /// ```ignore
105    /// // Apply ferray-ufunc::sin to a masked f64 array:
106    /// let result = ma.apply_unary(|arr| ferray_ufunc::sin(arr))?;
107    /// ```
108    ///
109    /// # Errors
110    /// Forwards any error from `f`.
111    pub fn apply_unary<F>(&self, f: F) -> FerrayResult<MaskedArray<T, D>>
112    where
113        F: FnOnce(&Array<T, D>) -> FerrayResult<Array<T, D>>,
114    {
115        let data_out = f(self.data())?;
116        if data_out.shape() != self.shape() {
117            return Err(FerrayError::shape_mismatch(format!(
118                "apply_unary: function changed shape from {:?} to {:?}",
119                self.shape(),
120                data_out.shape()
121            )));
122        }
123        let fill = self.fill_value();
124        // Replace masked positions in the result with fill_value to keep
125        // operations like log/sqrt from leaving misleading data behind.
126        let masked_data: Vec<T> = data_out
127            .iter()
128            .zip(self.mask().iter())
129            .map(|(v, m)| if *m { fill } else { *v })
130            .collect();
131        let final_data = Array::from_vec(self.dim().clone(), masked_data)?;
132        let mut result = MaskedArray::new(final_data, self.mask().clone())?;
133        result.set_fill_value(fill);
134        Ok(result)
135    }
136
137    /// Apply a unary function that maps `T -> U`, propagating the mask.
138    ///
139    /// This is the type-changing variant of [`MaskedArray::apply_unary`],
140    /// useful for predicates like `isnan` that return `Array<bool, D>` from
141    /// `Array<T, D>`. Masked positions in the result hold the explicitly
142    /// supplied `default_for_masked` value.
143    ///
144    /// # Errors
145    /// Forwards any error from `f`. Returns `FerrayError::ShapeMismatch` if
146    /// `f` produces an array with a different shape.
147    pub fn apply_unary_to<U, F>(
148        &self,
149        f: F,
150        default_for_masked: U,
151    ) -> FerrayResult<MaskedArray<U, D>>
152    where
153        U: Element + Copy,
154        F: FnOnce(&Array<T, D>) -> FerrayResult<Array<U, D>>,
155    {
156        let data_out = f(self.data())?;
157        if data_out.shape() != self.shape() {
158            return Err(FerrayError::shape_mismatch(format!(
159                "apply_unary_to: function changed shape from {:?} to {:?}",
160                self.shape(),
161                data_out.shape()
162            )));
163        }
164        let masked_data: Vec<U> = data_out
165            .iter()
166            .zip(self.mask().iter())
167            .map(|(v, m)| if *m { default_for_masked } else { *v })
168            .collect();
169        let final_data = Array::from_vec(self.dim().clone(), masked_data)?;
170        let mut result = MaskedArray::new(final_data, self.mask().clone())?;
171        result.set_fill_value(default_for_masked);
172        Ok(result)
173    }
174
175    /// Apply a binary function to two masked arrays, propagating the mask
176    /// union. Both inputs must have the same shape.
177    ///
178    /// The function `f` is called on the underlying data of both inputs
179    /// (no broadcasting — use [`crate::masked_add`]-style functions for
180    /// that). The result mask is the OR of the two input masks; masked
181    /// positions in the data are overwritten with the receiver's `fill_value`.
182    ///
183    /// # Example
184    /// ```ignore
185    /// let result = a.apply_binary(&b, |x, y| ferray_ufunc::power(x, y))?;
186    /// ```
187    ///
188    /// # Errors
189    /// Returns `FerrayError::ShapeMismatch` if shapes differ. Forwards any
190    /// error from `f`.
191    pub fn apply_binary<F>(
192        &self,
193        other: &MaskedArray<T, D>,
194        f: F,
195    ) -> FerrayResult<MaskedArray<T, D>>
196    where
197        F: FnOnce(&Array<T, D>, &Array<T, D>) -> FerrayResult<Array<T, D>>,
198    {
199        if self.shape() != other.shape() {
200            return Err(FerrayError::shape_mismatch(format!(
201                "apply_binary: shapes {:?} and {:?} differ",
202                self.shape(),
203                other.shape()
204            )));
205        }
206        let data_out = f(self.data(), other.data())?;
207        if data_out.shape() != self.shape() {
208            return Err(FerrayError::shape_mismatch(format!(
209                "apply_binary: function changed shape from {:?} to {:?}",
210                self.shape(),
211                data_out.shape()
212            )));
213        }
214
215        // Mask union.
216        let union_data: Vec<bool> = self
217            .mask()
218            .iter()
219            .zip(other.mask().iter())
220            .map(|(a, b)| *a || *b)
221            .collect();
222        let union_mask = Array::from_vec(self.dim().clone(), union_data)?;
223
224        let fill = self.fill_value();
225        let masked_data: Vec<T> = data_out
226            .iter()
227            .zip(union_mask.iter())
228            .map(|(v, m)| if *m { fill } else { *v })
229            .collect();
230        let final_data = Array::from_vec(self.dim().clone(), masked_data)?;
231        let mut result = MaskedArray::new(final_data, union_mask)?;
232        result.set_fill_value(fill);
233        Ok(result)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use ferray_core::dimension::Ix1;
241
242    fn ma1(data: Vec<f64>, mask: Vec<bool>) -> MaskedArray<f64, Ix1> {
243        let n = data.len();
244        let d = Array::<f64, Ix1>::from_vec(Ix1::new([n]), data).unwrap();
245        let m = Array::<bool, Ix1>::from_vec(Ix1::new([n]), mask).unwrap();
246        MaskedArray::new(d, m).unwrap()
247    }
248
249    #[test]
250    fn as_ref_returns_underlying_data() {
251        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
252        let arr_ref: &Array<f64, Ix1> = ma.as_ref();
253        assert_eq!(arr_ref.shape(), &[3]);
254        // The data is the unmasked-equivalent, mask is dropped.
255        let v: Vec<f64> = arr_ref.iter().copied().collect();
256        assert_eq!(v, vec![1.0, 2.0, 3.0]);
257    }
258
259    #[test]
260    fn from_masked_to_array_drops_mask() {
261        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
262        let arr: Array<f64, Ix1> = ma.into();
263        assert_eq!(arr.shape(), &[3]);
264    }
265
266    #[test]
267    fn into_data_method() {
268        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
269        let arr = ma.into_data();
270        assert_eq!(arr.shape(), &[3]);
271    }
272
273    #[test]
274    fn apply_unary_propagates_mask() {
275        let ma =
276            ma1(vec![1.0, 4.0, 9.0, 16.0], vec![false, false, true, false]).with_fill_value(-1.0);
277        let result = ma
278            .apply_unary(|arr| {
279                // Squaring closure as a stand-in for any ferray-ufunc function
280                let data: Vec<f64> = arr.iter().map(|&x| x.sqrt()).collect();
281                Array::<f64, Ix1>::from_vec(Ix1::new([arr.size()]), data)
282            })
283            .unwrap();
284
285        // Masked position (index 2) holds fill_value; others hold sqrt.
286        let d: Vec<f64> = result.data().iter().copied().collect();
287        assert_eq!(d, vec![1.0, 2.0, -1.0, 4.0]);
288        // Mask is preserved.
289        let m: Vec<bool> = result.mask().iter().copied().collect();
290        assert_eq!(m, vec![false, false, true, false]);
291        // Fill value is preserved.
292        assert_eq!(result.fill_value(), -1.0);
293    }
294
295    #[test]
296    fn apply_unary_forwards_error() {
297        let ma = ma1(vec![1.0, 2.0], vec![false, false]);
298        let result: FerrayResult<MaskedArray<f64, Ix1>> =
299            ma.apply_unary(|_| Err(FerrayError::invalid_value("simulated failure")));
300        assert!(result.is_err());
301    }
302
303    #[test]
304    fn apply_unary_rejects_shape_change() {
305        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, false, false]);
306        let result = ma.apply_unary(|_| Array::<f64, Ix1>::from_vec(Ix1::new([2]), vec![1.0, 2.0]));
307        assert!(result.is_err());
308    }
309
310    #[test]
311    fn apply_unary_to_changes_type_with_mask_default() {
312        // Apply a "is positive" predicate that returns bool.
313        let ma = ma1(vec![1.0, -2.0, 3.0, -4.0], vec![false, false, true, false]);
314        let result = ma
315            .apply_unary_to(
316                |arr| {
317                    let data: Vec<bool> = arr.iter().map(|&x| x > 0.0).collect();
318                    Array::<bool, Ix1>::from_vec(Ix1::new([arr.size()]), data)
319                },
320                false, // default for masked positions
321            )
322            .unwrap();
323
324        let d: Vec<bool> = result.data().iter().copied().collect();
325        // Index 2 is masked → false (the default); others reflect the predicate.
326        assert_eq!(d, vec![true, false, false, false]);
327        let m: Vec<bool> = result.mask().iter().copied().collect();
328        assert_eq!(m, vec![false, false, true, false]);
329    }
330
331    #[test]
332    fn apply_binary_unions_masks() {
333        let a = ma1(vec![10.0, 20.0, 30.0], vec![false, true, false]).with_fill_value(-1.0);
334        let b = ma1(vec![1.0, 2.0, 3.0], vec![false, false, true]);
335        let result = a
336            .apply_binary(&b, |x, y| {
337                let data: Vec<f64> = x.iter().zip(y.iter()).map(|(&a, &b)| a + b).collect();
338                Array::<f64, Ix1>::from_vec(Ix1::new([x.size()]), data)
339            })
340            .unwrap();
341
342        let d: Vec<f64> = result.data().iter().copied().collect();
343        // Indices 1 and 2 are masked (union of the two input masks); index 0
344        // gets the actual sum.
345        assert_eq!(d, vec![11.0, -1.0, -1.0]);
346        let m: Vec<bool> = result.mask().iter().copied().collect();
347        assert_eq!(m, vec![false, true, true]);
348        assert_eq!(result.fill_value(), -1.0);
349    }
350
351    #[test]
352    fn apply_binary_rejects_shape_mismatch() {
353        let a = ma1(vec![1.0, 2.0, 3.0], vec![false; 3]);
354        let b = ma1(vec![1.0, 2.0], vec![false; 2]);
355        let result = a.apply_binary(&b, |x, _y| Ok(x.clone()));
356        assert!(result.is_err());
357    }
358
359    /// Demonstrates the canonical interop pattern with a real ferray-stats
360    /// call: pass `&MaskedArray` through `as_ref()` to a function that
361    /// expects `&Array`. This loses the mask (per the AsRef contract) but
362    /// is the cheapest way to bridge.
363    #[test]
364    fn as_ref_works_with_array_consuming_function() {
365        // A simple Array -> Array function (any closure works as a stand-in).
366        fn double(arr: &Array<f64, Ix1>) -> FerrayResult<Array<f64, Ix1>> {
367            let data: Vec<f64> = arr.iter().map(|&x| x * 2.0).collect();
368            Array::<f64, Ix1>::from_vec(Ix1::new([arr.size()]), data)
369        }
370
371        let ma = ma1(vec![1.0, 2.0, 3.0], vec![false, true, false]);
372        // Direct call via AsRef — the mask is dropped:
373        let result = double(ma.as_ref()).unwrap();
374        let v: Vec<f64> = result.iter().copied().collect();
375        assert_eq!(v, vec![2.0, 4.0, 6.0]);
376
377        // For mask-preserving usage, route through apply_unary instead:
378        let masked_result = ma.apply_unary(double).unwrap();
379        let m: Vec<bool> = masked_result.mask().iter().copied().collect();
380        assert_eq!(m, vec![false, true, false]);
381    }
382}
383
384// ---------------------------------------------------------------------------
385// MaskAware trait: common interface for Array and MaskedArray (#505)
386//
387// NumPy's MaskedArray subclasses ndarray so any ndarray-accepting function
388// automatically accepts MaskedArray (with __array_ufunc__ handling mask
389// propagation). Rust doesn't have inheritance, so the ferray equivalent is a
390// trait both types implement: mask-aware functions take
391// `&impl MaskAware<T, D>` and dispatch via the trait methods.
392//
393// Array<T, D> is treated as "always fully unmasked" — `mask_opt()` returns
394// None and `fill_value()` falls back to `T::zero()`. MaskedArray<T, D>
395// delegates to its actual accessors. This lets callers write one function
396// that works on both and still propagates masks correctly.
397// ---------------------------------------------------------------------------
398
399/// Shared view contract for functions that want to accept either an
400/// `Array` or a `MaskedArray` (#505).
401///
402/// Implementations:
403/// - `Array<T, D>`: `data()` returns `self`, `mask_opt()` returns `None`
404///   (no mask), `fill_value()` returns `T::zero()`. The array is treated
405///   as fully unmasked.
406/// - `MaskedArray<T, D>`: delegates to the existing accessors.
407///
408/// Downstream code that wants to write "one function, works on both"
409/// should take `&impl MaskAware<T, D>` and consult `mask_opt()` to
410/// decide whether to do mask propagation.
411pub trait MaskAware<T: Element, D: Dimension> {
412    /// Return a reference to the underlying data array.
413    fn data(&self) -> &Array<T, D>;
414
415    /// Return the mask array if one is explicitly present, or `None`
416    /// when the input carries no mask (treated as fully unmasked).
417    ///
418    /// For `Array<T, D>` this always returns `None`. For
419    /// `MaskedArray<T, D>` it returns `Some` when a real mask has
420    /// been explicitly set and `None` when the array is in the
421    /// nomask-sentinel state (#506).
422    fn mask_opt(&self) -> Option<&Array<bool, D>>;
423
424    /// Return the fill value to use for masked positions in
425    /// derived results.
426    fn fill_value(&self) -> T
427    where
428        T: Copy;
429
430    /// Return the shape of the underlying data.
431    fn shape(&self) -> &[usize] {
432        self.data().shape()
433    }
434}
435
436impl<T: Element, D: Dimension> MaskAware<T, D> for Array<T, D> {
437    #[inline]
438    fn data(&self) -> &Array<T, D> {
439        self
440    }
441
442    /// A plain `Array<T, D>` has no mask — always returns `None`.
443    #[inline]
444    fn mask_opt(&self) -> Option<&Array<bool, D>> {
445        None
446    }
447
448    /// A plain `Array<T, D>` has no fill value; returns `T::zero()`.
449    #[inline]
450    fn fill_value(&self) -> T
451    where
452        T: Copy,
453    {
454        T::zero()
455    }
456}
457
458impl<T: Element, D: Dimension> MaskAware<T, D> for MaskedArray<T, D> {
459    #[inline]
460    fn data(&self) -> &Array<T, D> {
461        MaskedArray::data(self)
462    }
463
464    #[inline]
465    fn mask_opt(&self) -> Option<&Array<bool, D>> {
466        MaskedArray::mask_opt(self)
467    }
468
469    #[inline]
470    fn fill_value(&self) -> T
471    where
472        T: Copy,
473    {
474        MaskedArray::fill_value(self)
475    }
476}
477
478/// Apply a unary function to any `MaskAware` input, propagating the
479/// mask if one is present.
480///
481/// When the input is a plain `Array<T, D>` (or a nomask-sentinel
482/// `MaskedArray`), the function is applied directly and the result
483/// is returned as a nomask `MaskedArray`. When the input has a real
484/// mask, this delegates to the existing [`MaskedArray::apply_unary`]
485/// path so masked positions are overwritten with the fill value.
486///
487/// Use this to write "one function, works on both" adapters:
488///
489/// ```ignore
490/// fn my_op<X: MaskAware<f64, Ix1>>(x: &X) -> FerrayResult<MaskedArray<f64, Ix1>> {
491///     ma_apply_unary(x, |a| ferray_ufunc::sin(a))
492/// }
493/// ```
494///
495/// # Errors
496/// Forwards any error from `f`, plus shape-mismatch errors if `f`
497/// returns a differently-shaped array.
498pub fn ma_apply_unary<T, D, X, F>(input: &X, f: F) -> FerrayResult<MaskedArray<T, D>>
499where
500    T: Element + Copy,
501    D: Dimension,
502    X: MaskAware<T, D>,
503    F: FnOnce(&Array<T, D>) -> FerrayResult<Array<T, D>>,
504{
505    let data_out = f(input.data())?;
506    if data_out.shape() != input.shape() {
507        return Err(FerrayError::shape_mismatch(format!(
508            "ma_apply_unary: function changed shape from {:?} to {:?}",
509            input.shape(),
510            data_out.shape()
511        )));
512    }
513
514    match input.mask_opt() {
515        None => {
516            // No mask — wrap the result in a nomask-sentinel
517            // MaskedArray so the caller gets a uniform return type.
518            let mut out = MaskedArray::from_data(data_out)?;
519            out.set_fill_value(input.fill_value());
520            Ok(out)
521        }
522        Some(mask) => {
523            // Overwrite masked positions with fill_value so downstream
524            // operations can't see stale data at masked slots.
525            let fill = input.fill_value();
526            let masked_data: Vec<T> = data_out
527                .iter()
528                .zip(mask.iter())
529                .map(|(v, m)| if *m { fill } else { *v })
530                .collect();
531            let final_data = Array::from_vec(input.data().dim().clone(), masked_data)?;
532            let mut result = MaskedArray::new(final_data, mask.clone())?;
533            result.set_fill_value(fill);
534            Ok(result)
535        }
536    }
537}
538
539#[cfg(test)]
540mod mask_aware_tests {
541    use super::*;
542    use ferray_core::dimension::Ix1;
543
544    fn arr_f64(data: Vec<f64>) -> Array<f64, Ix1> {
545        let n = data.len();
546        Array::<f64, Ix1>::from_vec(Ix1::new([n]), data).unwrap()
547    }
548
549    fn ma_f64(data: Vec<f64>, mask: Vec<bool>) -> MaskedArray<f64, Ix1> {
550        let d = arr_f64(data);
551        let n = d.size();
552        let m = Array::<bool, Ix1>::from_vec(Ix1::new([n]), mask).unwrap();
553        MaskedArray::new(d, m).unwrap()
554    }
555
556    // ---- MaskAware trait impls (#505) ----
557
558    #[test]
559    fn array_implements_mask_aware_with_none_mask() {
560        let a = arr_f64(vec![1.0, 2.0, 3.0]);
561        // Plain Array carries no mask.
562        assert!(<Array<f64, Ix1> as MaskAware<f64, Ix1>>::mask_opt(&a).is_none());
563        assert_eq!(
564            <Array<f64, Ix1> as MaskAware<f64, Ix1>>::fill_value(&a),
565            0.0
566        );
567        assert_eq!(<Array<f64, Ix1> as MaskAware<f64, Ix1>>::shape(&a), &[3]);
568    }
569
570    #[test]
571    fn masked_array_implements_mask_aware_with_real_mask() {
572        let ma = ma_f64(vec![1.0, 2.0, 3.0], vec![false, true, false]);
573        let via_trait = <MaskedArray<f64, Ix1> as MaskAware<f64, Ix1>>::mask_opt(&ma);
574        assert!(via_trait.is_some());
575        assert_eq!(
576            via_trait.unwrap().iter().copied().collect::<Vec<_>>(),
577            vec![false, true, false]
578        );
579    }
580
581    #[test]
582    fn nomask_sentinel_masked_array_reports_none_via_trait() {
583        // A from_data-constructed MaskedArray (nomask sentinel) should
584        // report None through the MaskAware trait, matching the
585        // behavior of a plain Array.
586        let ma = MaskedArray::from_data(arr_f64(vec![1.0, 2.0, 3.0])).unwrap();
587        let via_trait = <MaskedArray<f64, Ix1> as MaskAware<f64, Ix1>>::mask_opt(&ma);
588        assert!(via_trait.is_none());
589    }
590
591    #[test]
592    fn ma_apply_unary_on_plain_array_returns_nomask_result() {
593        let a = arr_f64(vec![1.0, 2.0, 3.0]);
594        let result = ma_apply_unary(&a, |x| {
595            let data: Vec<f64> = x.iter().map(|v| v * 2.0).collect();
596            Ok(Array::from_vec(x.dim().clone(), data)?)
597        })
598        .unwrap();
599        assert_eq!(
600            result.data().iter().copied().collect::<Vec<_>>(),
601            vec![2.0, 4.0, 6.0]
602        );
603        // Plain-Array input → nomask-sentinel result.
604        assert!(!result.has_real_mask());
605    }
606
607    #[test]
608    fn ma_apply_unary_on_masked_array_propagates_mask() {
609        let ma = ma_f64(vec![1.0, 2.0, 3.0], vec![false, true, false]);
610        let result = ma_apply_unary(&ma, |x| {
611            let data: Vec<f64> = x.iter().map(|v| v * 2.0).collect();
612            Ok(Array::from_vec(x.dim().clone(), data)?)
613        })
614        .unwrap();
615        // Mask survives the operation.
616        assert!(result.has_real_mask());
617        assert_eq!(
618            result.mask().iter().copied().collect::<Vec<_>>(),
619            vec![false, true, false]
620        );
621        // Masked position was overwritten with fill_value (0.0 default).
622        let d: Vec<f64> = result.data().iter().copied().collect();
623        assert_eq!(d[0], 2.0);
624        assert_eq!(d[1], 0.0); // masked → fill value
625        assert_eq!(d[2], 6.0);
626    }
627
628    #[test]
629    fn ma_apply_unary_generic_over_both_types() {
630        // Write a helper that works on both Array and MaskedArray.
631        fn double_it<T: Element, D: Dimension, X: MaskAware<T, D>>(
632            x: &X,
633        ) -> FerrayResult<MaskedArray<T, D>>
634        where
635            T: Copy + std::ops::Mul<Output = T> + num_traits::FromPrimitive,
636        {
637            let two = T::from_f64(2.0).unwrap();
638            ma_apply_unary(x, move |a| {
639                let data: Vec<T> = a.iter().map(|v| *v * two).collect();
640                Ok(Array::from_vec(a.dim().clone(), data)?)
641            })
642        }
643
644        let plain = arr_f64(vec![1.0, 2.0, 3.0]);
645        let masked = ma_f64(vec![1.0, 2.0, 3.0], vec![false, true, false]);
646
647        // Both inputs go through the same helper.
648        let r_plain = double_it(&plain).unwrap();
649        let r_masked = double_it(&masked).unwrap();
650
651        // Plain result: no mask, all values doubled.
652        assert!(!r_plain.has_real_mask());
653        assert_eq!(
654            r_plain.data().iter().copied().collect::<Vec<_>>(),
655            vec![2.0, 4.0, 6.0]
656        );
657
658        // Masked result: mask preserved, masked position holds fill.
659        assert!(r_masked.has_real_mask());
660        assert_eq!(
661            r_masked.mask().iter().copied().collect::<Vec<_>>(),
662            vec![false, true, false]
663        );
664    }
665
666    #[test]
667    fn ma_apply_unary_rejects_shape_changing_function() {
668        let a = arr_f64(vec![1.0, 2.0, 3.0]);
669        let result = ma_apply_unary(&a, |_| {
670            // Return a wrong-shape result deliberately.
671            Ok(arr_f64(vec![1.0, 2.0]))
672        });
673        assert!(result.is_err());
674    }
675}