Skip to main content

ferray_ma/
put.rs

1// ferray-ma: in-place flat assignment ops — `put` / `putmask` (REQ-36, REQ-37)
2//
3// These two operations mutate a `MaskedArray` in place by writing data and
4// updating the mask at selected positions, honouring the array's hard-mask
5// state. They mirror `numpy.ma.MaskedArray.put` (`numpy/ma/core.py:4837`) and
6// `numpy.ma.putmask` (`numpy/ma/core.py:7537`) exactly.
7//
8// Both build on the existing in-place mutation surface in `masked_array.rs`
9// (`data_mut`, `set_mask_flat`, `set_mask`, `ensure_materialized_mut`) — no
10// new struct field is required because the hard-mask flag and its assignment
11// effect already exist.
12//
13// ## REQ status
14//
15// Both SHIPPED — audited, green. The library setters are exposed through
16// `ferray-python` and registered on the `ferray.ma` module.
17//
18// | REQ | Status | Evidence |
19// |-----|--------|----------|
20// | REQ-36 (put) | SHIPPED | `MaskedArray::put` (this file) composes `data_mut` + `set_mask_flat` honouring the hard-mask drop (`numpy/ma/core.py:4899`), values-repeat, masked-`values` propagation, and `PutMode` raise/wrap/clip (`numpy/ma/core.py:4837`). Non-test production consumer: `PyMaskedArray::put` + module-level `put` `#[pyfunction]` in `ferray-python/src/ma.rs`, registered via `register_ma_module` in `ferray-python/src/lib.rs`. |
21// | REQ-37 (putmask) | SHIPPED | `MaskedArray::putmask` (this file) mirrors `numpy/ma/core.py:7537` — data `copyto(where=mask)` always (even hard-masked positions), mask branch on `hard_mask` (`:7581`), scalar/same-length `values` broadcast. Non-test production consumer: `PyMaskedArray::putmask` + module-level `putmask` `#[pyfunction]` in `ferray-python/src/ma.rs`, registered via `register_ma_module` in `ferray-python/src/lib.rs`. |
22//
23// `PutMode` (this file) is the raise/wrap/clip enum mirroring numpy's `mode`
24// keyword (`numpy/ma/core.py:4852`); re-exported from `ferray-ma/src/lib.rs`.
25
26use ferray_core::dimension::Dimension;
27use ferray_core::dtype::Element;
28use ferray_core::error::{FerrayError, FerrayResult};
29
30use crate::MaskedArray;
31
32/// Out-of-bounds index behaviour for [`MaskedArray::put`], mirroring numpy's
33/// `mode` keyword (`{'raise', 'wrap', 'clip'}`, `numpy/ma/core.py:4852`).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PutMode {
36    /// `'raise'` — an out-of-range index returns `IndexOutOfBounds`.
37    Raise,
38    /// `'wrap'` — indices wrap around modulo the length (Python modulo, so a
39    /// negative index wraps from the end).
40    Wrap,
41    /// `'clip'` — indices are clamped into `[0, len)`; negatives clip to `0`.
42    Clip,
43}
44
45impl PutMode {
46    /// Parse the numpy `mode` string. An unrecognised mode mirrors numpy's
47    /// `ValueError` (raised by `np.put` for a bad mode).
48    ///
49    /// # Errors
50    /// Returns `FerrayError::InvalidValue` for any string other than
51    /// `"raise"`, `"wrap"`, or `"clip"`.
52    pub fn parse(s: &str) -> FerrayResult<Self> {
53        match s {
54            "raise" => Ok(Self::Raise),
55            "wrap" => Ok(Self::Wrap),
56            "clip" => Ok(Self::Clip),
57            other => Err(FerrayError::invalid_value(format!(
58                "put: clipmode '{other}' not understood (expected 'raise', 'wrap', or 'clip')"
59            ))),
60        }
61    }
62}
63
64/// Resolve a possibly-negative index against `len` under `mode`, returning the
65/// concrete flat position in `[0, len)`.
66///
67/// Mirrors numpy's `PyArray_PutTo` index resolution: `'raise'` accepts the
68/// Python range `[-len, len)` and errors otherwise; `'wrap'` applies Python
69/// modulo so any integer maps in-range; `'clip'` clamps, with negatives
70/// (and any index into a zero-length array) clipping to `0`.
71fn resolve_index(idx: isize, len: usize, mode: PutMode) -> FerrayResult<usize> {
72    let len_i = len as isize;
73    match mode {
74        PutMode::Raise => {
75            let resolved = if idx < 0 { idx + len_i } else { idx };
76            if resolved < 0 || resolved >= len_i {
77                return Err(FerrayError::index_out_of_bounds(idx, 0, len));
78            }
79            Ok(resolved as usize)
80        }
81        PutMode::Wrap => {
82            if len == 0 {
83                return Err(FerrayError::index_out_of_bounds(idx, 0, len));
84            }
85            Ok(idx.rem_euclid(len_i) as usize)
86        }
87        PutMode::Clip => {
88            if len == 0 {
89                return Err(FerrayError::index_out_of_bounds(idx, 0, len));
90            }
91            if idx < 0 {
92                Ok(0)
93            } else if idx >= len_i {
94                Ok(len - 1)
95            } else {
96                Ok(idx as usize)
97            }
98        }
99    }
100}
101
102impl<T: Element + Copy, D: Dimension> MaskedArray<T, D> {
103    /// Set flat-indexed positions to corresponding values, mirroring
104    /// `numpy.ma.MaskedArray.put` (`numpy/ma/core.py:4837`).
105    ///
106    /// `self._data.flat[indices[n]] = values[n]` for each `n`. This is a
107    /// faithful translation of numpy's two-phase delegation
108    /// (`numpy/ma/core.py:4898-4921`): the **data** is written via
109    /// `self._data.put(indices, values, mode)` (numpy `ndarray.put`, which
110    /// *cycles* a short `values` and is a silent no-op for an empty `values`),
111    /// then the **mask** is updated by a *separate* `put` whose value array is
112    /// either a scalar `False` (unmasked `values`) or the `values` mask:
113    ///
114    /// - if `values_mask` is `None` (an unmasked `values`), every written
115    ///   position is **unmasked** (`numpy/ma/core.py:4916`,
116    ///   `m.put(indices, False, mode)`);
117    /// - if `values_mask` is `Some`, the written positions take the
118    ///   corresponding `values` mask bit, cycled like numpy's
119    ///   `m.put(indices, values._mask, mode)` (`:4918`).
120    ///
121    /// Because the data and mask are two independent `ndarray.put` calls, an
122    /// **empty `values` with non-empty `indices` writes no data** (the data
123    /// `put` has nothing to iterate) but **still updates the mask** at every
124    /// resolved position (the mask `put` uses the scalar `False` / values-mask,
125    /// not the empty `values`). This matches the live oracle
126    /// `a.put([0], [])` → data unchanged, `a[0]` unmasked.
127    ///
128    /// When the array is hard-masked **and carries a real mask**, numpy takes
129    /// the hard-mask branch (`numpy/ma/core.py:4899-4905`): `values` is
130    /// `resize`d to `indices.shape` — numpy's `ndarray.resize` **zero-pads**
131    /// when growing (it does *not* cycle) — then every pair whose target is
132    /// masked in the *original* mask is dropped (`indices = indices[~mask]`).
133    /// The surviving `(index, zero-padded value)` pairs are then written 1:1.
134    /// So a masked target is neither overwritten nor unmasked, and a short
135    /// `values` zero-pads the tail rather than cycling.
136    ///
137    /// `mode` controls out-of-bounds index behaviour; see [`PutMode`].
138    ///
139    /// # Errors
140    /// Returns `FerrayError::IndexOutOfBounds` for an out-of-range index under
141    /// [`PutMode::Raise`], or any error from the underlying mask
142    /// materialisation. An empty `indices` is a no-op.
143    pub fn put(
144        &mut self,
145        indices: &[isize],
146        values: &[T],
147        values_mask: Option<&[bool]>,
148        mode: PutMode,
149    ) -> FerrayResult<()> {
150        if indices.is_empty() {
151            // Nothing to do; numpy's empty put is a no-op.
152            return Ok(());
153        }
154        let len = self.size();
155
156        // Resolve every index up-front so a `Raise` failure aborts before any
157        // mutation (matching numpy, which validates the whole index array).
158        let resolved: Vec<usize> = indices
159            .iter()
160            .map(|&i| resolve_index(i, len, mode))
161            .collect::<FerrayResult<_>>()?;
162
163        let hard = self.is_hard_mask() && self.has_real_mask();
164
165        // Cycled values-mask bit at index `n` (numpy's `m.put(indices,
166        // values._mask)` cycles a short mask). An unmasked or empty
167        // values-mask yields `False`.
168        let vmask_bit = |n: usize| -> bool {
169            match values_mask {
170                Some(vm) if !vm.is_empty() => vm[n % vm.len()],
171                _ => false,
172            }
173        };
174
175        if hard {
176            // ---- Hard-mask branch (numpy/ma/core.py:4899-4905) ----
177            // `values.resize(indices.shape)` zero-pads a short `values`; the
178            // surviving pairs are those whose target is unmasked in the
179            // *original* mask. Snapshot that mask once before any mutation.
180            let original_mask: Vec<bool> = self.mask().iter().copied().collect();
181            for (n, &flat) in resolved.iter().enumerate() {
182                // Drop pairs whose target is masked in the original mask.
183                if original_mask[flat] {
184                    continue;
185                }
186                // Zero-pad: positions past the end of `values` write the
187                // type's zero (numpy `ndarray.resize` pads with zeros).
188                let value = if n < values.len() {
189                    values[n]
190                } else {
191                    T::zero()
192                };
193                if let Some(slice) = self.data_mut() {
194                    slice[flat] = value;
195                } else {
196                    return Err(FerrayError::invalid_value(
197                        "put: underlying data is not contiguous; cannot place values",
198                    ));
199                }
200                // Mask update for this surviving position. The hard mask cannot
201                // be cleared, so an unmasked `values` (False) leaves a masked
202                // target alone — but masked targets are already dropped above,
203                // so only currently-unmasked positions reach here.
204                self.set_mask_flat(flat, vmask_bit(n))?;
205            }
206            return Ok(());
207        }
208
209        // ---- Non-hard branch (numpy/ma/core.py:4907-4920) ----
210        // Phase 1: `self._data.put(indices, values, mode)`. numpy's
211        // `ndarray.put` cycles a short `values` and is a silent no-op for an
212        // empty `values`, so skip the data write entirely when `values` is
213        // empty (the mask phase still runs).
214        if !values.is_empty() {
215            for (n, &flat) in resolved.iter().enumerate() {
216                let value = values[n % values.len()];
217                if let Some(slice) = self.data_mut() {
218                    slice[flat] = value;
219                } else {
220                    return Err(FerrayError::invalid_value(
221                        "put: underlying data is not contiguous; cannot place values",
222                    ));
223                }
224            }
225        }
226
227        // Phase 2: mask update (`numpy/ma/core.py:4910-4920`). A separate
228        // `ndarray.put` whose value array is the scalar `False` (unmasked
229        // `values`) or the cycled `values` mask — independent of whether the
230        // data `values` was empty.
231        for (n, &flat) in resolved.iter().enumerate() {
232            self.set_mask_flat(flat, vmask_bit(n))?;
233        }
234        Ok(())
235    }
236
237    /// Set positions where `mask` is `true` from `values`, mirroring
238    /// `numpy.ma.putmask` (`numpy/ma/core.py:7537`).
239    ///
240    /// The data is written unconditionally at every `mask`-true position
241    /// (numpy's final `np.copyto(a._data, valdata, where=mask)`, `:7590`) —
242    /// even hard-masked positions get their **data** overwritten (only the
243    /// mask is protected). Both `mask` (the `where=` argument) and `values`
244    /// are **broadcast** against `self` exactly as numpy's `copyto` does: a
245    /// length-1 `mask`/`values` broadcasts across every position, otherwise it
246    /// must match `self`'s length (numpy raises `ValueError` on any other
247    /// length, since `copyto` broadcasts rather than cycles).
248    ///
249    /// Mask update branches on hard-mask (`numpy/ma/core.py:7581`):
250    ///
251    /// - **soft + unmasked `values`**: written positions are unmasked (numpy
252    ///   copies `getmaskarray(values)`, all-`False`, where `mask`);
253    /// - **soft + masked `values`**: written positions take the `values` mask;
254    /// - **hard + masked `values`**: the `values` mask is **unioned** into the
255    ///   existing mask where `mask` is true — never cleared (`:7585`);
256    /// - **hard + unmasked `values`**: the mask is left unchanged (`:7582`
257    ///   `if valmask is not nomask`).
258    ///
259    /// # Errors
260    /// Returns `FerrayError::ShapeMismatch` if `mask.len()` is neither 1 nor
261    /// `self.size()` (numpy's `where=` broadcast failure), or
262    /// `FerrayError::InvalidValue` if `values` is neither length 1 nor length
263    /// `self.size()` (numpy's `values` broadcast failure).
264    pub fn putmask(
265        &mut self,
266        mask: &[bool],
267        values: &[T],
268        values_mask: Option<&[bool]>,
269    ) -> FerrayResult<()> {
270        let len = self.size();
271        // numpy's copyto broadcasts the `where=` mask against `self`: a
272        // length-1 mask broadcasts across every position; otherwise it must
273        // match `self` exactly (any other length is a broadcast error).
274        if mask.len() != 1 && mask.len() != len {
275            return Err(FerrayError::shape_mismatch(format!(
276                "putmask: boolean mask length {} does not broadcast to array size {len}",
277                mask.len()
278            )));
279        }
280        if values.is_empty() {
281            return Err(FerrayError::invalid_value(
282                "putmask: `values` must not be empty",
283            ));
284        }
285        // numpy's copyto broadcasts `values` against `self`: a length-1
286        // `values` is a scalar fill; otherwise it must match `self` exactly.
287        if values.len() != 1 && values.len() != len {
288            return Err(FerrayError::invalid_value(format!(
289                "putmask: could not broadcast values of length {} into array of size {len}",
290                values.len()
291            )));
292        }
293        // Broadcast the `where=` mask: a length-1 mask applies to all `len`
294        // positions; otherwise it is read 1:1.
295        let mask_bit = |i: usize| -> bool { if mask.len() == 1 { mask[0] } else { mask[i] } };
296        let broadcast = |i: usize| -> T {
297            if values.len() == 1 {
298                values[0]
299            } else {
300                values[i]
301            }
302        };
303        let vmask_bit = |i: usize| -> bool {
304            match values_mask {
305                None => false,
306                Some(vm) => {
307                    if vm.len() == 1 {
308                        vm[0]
309                    } else {
310                        vm[i]
311                    }
312                }
313            }
314        };
315
316        let hard = self.is_hard_mask() && self.has_real_mask();
317
318        // ---- Mask update (numpy/ma/core.py:7576-7589) ----
319        // Done before the data write; the two touch disjoint buffers so order
320        // is immaterial.
321        match (hard, values_mask) {
322            // soft path: copy the values mask (or all-False) where `mask`.
323            (false, _) => {
324                for i in 0..len {
325                    if mask_bit(i) {
326                        self.set_mask_flat(i, vmask_bit(i))?;
327                    }
328                }
329            }
330            // hard + masked values: union the values mask in where `mask`,
331            // never clearing (set_mask_flat already refuses to clear on a hard
332            // mask, so a `true` bit is added and a `false` bit is a no-op).
333            (true, Some(_)) => {
334                for i in 0..len {
335                    if mask_bit(i) && vmask_bit(i) {
336                        self.set_mask_flat(i, true)?;
337                    }
338                }
339            }
340            // hard + unmasked values: mask unchanged (numpy's guard skips).
341            (true, None) => {}
342        }
343
344        // ---- Data write (numpy/ma/core.py:7590) ----
345        // Unconditional copyto where `mask`: even hard-masked positions get
346        // their data overwritten (only the mask is protected on a hard mask).
347        if let Some(slice) = self.data_mut() {
348            for (i, cell) in slice.iter_mut().enumerate() {
349                if mask_bit(i) {
350                    *cell = broadcast(i);
351                }
352            }
353        } else {
354            return Err(FerrayError::invalid_value(
355                "putmask: underlying data is not contiguous; cannot place values",
356            ));
357        }
358        Ok(())
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use ferray_core::Array;
366    use ferray_core::dimension::Ix1;
367
368    fn arr_f64(data: Vec<f64>) -> Array<f64, Ix1> {
369        let n = data.len();
370        Array::<f64, Ix1>::from_vec(Ix1::new([n]), data).unwrap()
371    }
372
373    fn arr_bool(data: Vec<bool>) -> Array<bool, Ix1> {
374        let n = data.len();
375        Array::<bool, Ix1>::from_vec(Ix1::new([n]), data).unwrap()
376    }
377
378    fn ma(data: Vec<f64>, mask: Vec<bool>) -> MaskedArray<f64, Ix1> {
379        MaskedArray::new(arr_f64(data), arr_bool(mask)).unwrap()
380    }
381
382    fn data_of(m: &MaskedArray<f64, Ix1>) -> Vec<f64> {
383        m.data().iter().copied().collect()
384    }
385    fn mask_of(m: &MaskedArray<f64, Ix1>) -> Vec<bool> {
386        m.mask().iter().copied().collect()
387    }
388
389    // ---- put: basic write unmasks written positions ----
390    // Oracle: np.ma.array([1,2,3,4], mask=[F,T,F,F]); a.put([1,3],[10,30])
391    //   -> data [1,10,3,30], mask all False.
392    #[test]
393    fn put_basic_unmasks_written() {
394        let mut a = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false, true, false, false]);
395        a.put(&[1, 3], &[10.0, 30.0], None, PutMode::Raise).unwrap();
396        assert_eq!(data_of(&a), vec![1.0, 10.0, 3.0, 30.0]);
397        assert_eq!(mask_of(&a), vec![false, false, false, false]);
398    }
399
400    // ---- put: masked values propagate to the target mask ----
401    // Oracle: a=[1,2,3,4] nomask; vals=ma([10,20], mask=[T,F]); a.put([0,1],vals)
402    //   -> data [10,20,3,4], mask [T,F,F,F].
403    #[test]
404    fn put_masked_values_propagate() {
405        let mut a = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
406        a.put(&[0, 1], &[10.0, 20.0], Some(&[true, false]), PutMode::Raise)
407            .unwrap();
408        assert_eq!(data_of(&a), vec![10.0, 20.0, 3.0, 4.0]);
409        assert_eq!(mask_of(&a), vec![true, false, false, false]);
410    }
411
412    // ---- put: values repeat (cycle) when shorter than indices ----
413    // Oracle: a=[1..5] nomask; a.put([0,1,2,3],[9]) -> data [9,9,9,9,5].
414    #[test]
415    fn put_values_repeat() {
416        let mut a = ma(vec![1.0, 2.0, 3.0, 4.0, 5.0], vec![false; 5]);
417        a.put(&[0, 1, 2, 3], &[9.0], None, PutMode::Raise).unwrap();
418        assert_eq!(data_of(&a), vec![9.0, 9.0, 9.0, 9.0, 5.0]);
419    }
420
421    // ---- put: mode wrap ----
422    // Oracle: a=[1,2,3] nomask; a.put([5],[99],mode='wrap') (5%3=2) -> [1,2,99].
423    #[test]
424    fn put_mode_wrap() {
425        let mut a = ma(vec![1.0, 2.0, 3.0], vec![false; 3]);
426        a.put(&[5], &[99.0], None, PutMode::Wrap).unwrap();
427        assert_eq!(data_of(&a), vec![1.0, 2.0, 99.0]);
428    }
429
430    // ---- put: mode clip ----
431    // Oracle: a=[1,2,3] nomask; a.put([5],[99],mode='clip') (->2) -> [1,2,99].
432    #[test]
433    fn put_mode_clip() {
434        let mut a = ma(vec![1.0, 2.0, 3.0], vec![false; 3]);
435        a.put(&[5], &[99.0], None, PutMode::Clip).unwrap();
436        assert_eq!(data_of(&a), vec![1.0, 2.0, 99.0]);
437    }
438
439    // ---- put: mode clip negative -> 0 ----
440    // Oracle: a=[1,2,3] nomask; a.put([-5],[9],mode='clip') -> [9,2,3].
441    #[test]
442    fn put_mode_clip_negative() {
443        let mut a = ma(vec![1.0, 2.0, 3.0], vec![false; 3]);
444        a.put(&[-5], &[9.0], None, PutMode::Clip).unwrap();
445        assert_eq!(data_of(&a), vec![9.0, 2.0, 3.0]);
446    }
447
448    // ---- put: mode wrap negative (Python modulo) ----
449    // Oracle: a=[1,2,3] nomask; a.put([-5],[9],mode='wrap') (-5 %3 ==1) -> [1,9,3].
450    #[test]
451    fn put_mode_wrap_negative() {
452        let mut a = ma(vec![1.0, 2.0, 3.0], vec![false; 3]);
453        a.put(&[-5], &[9.0], None, PutMode::Wrap).unwrap();
454        assert_eq!(data_of(&a), vec![1.0, 9.0, 3.0]);
455    }
456
457    // ---- put: negative index under raise resolves from the end ----
458    // Oracle: a=[1,2,3,4] nomask; a.put([-1,-2],[10,20]) -> [1,2,20,10].
459    #[test]
460    fn put_negative_index_raise() {
461        let mut a = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
462        a.put(&[-1, -2], &[10.0, 20.0], None, PutMode::Raise)
463            .unwrap();
464        assert_eq!(data_of(&a), vec![1.0, 2.0, 20.0, 10.0]);
465    }
466
467    // ---- put: mode raise out of bounds errors ----
468    // Oracle: a=[1,2,3] nomask; a.put([5],[99],mode='raise') -> IndexError.
469    #[test]
470    fn put_mode_raise_out_of_bounds() {
471        let mut a = ma(vec![1.0, 2.0, 3.0], vec![false; 3]);
472        assert!(a.put(&[5], &[99.0], None, PutMode::Raise).is_err());
473    }
474
475    #[test]
476    fn put_mode_raise_negative_out_of_bounds() {
477        let mut a = ma(vec![1.0, 2.0, 3.0], vec![false; 3]);
478        assert!(a.put(&[-5], &[9.0], None, PutMode::Raise).is_err());
479    }
480
481    // ---- put: hard-mask suppression drops masked targets ----
482    // Oracle: a=[1,2,3,4] mask=[F,T,F,F]; a.harden_mask(); a.put([1,3],[10,30])
483    //   -> data [1,2,3,30], mask [F,T,F,F] (a[1] never written nor unmasked).
484    #[test]
485    fn put_hard_mask_suppresses_masked_target() {
486        let mut a = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false, true, false, false]);
487        a.harden_mask().unwrap();
488        a.put(&[1, 3], &[10.0, 30.0], None, PutMode::Raise).unwrap();
489        assert_eq!(data_of(&a), vec![1.0, 2.0, 3.0, 30.0]);
490        assert_eq!(mask_of(&a), vec![false, true, false, false]);
491    }
492
493    // ---- put: empty indices is a no-op ----
494    #[test]
495    fn put_empty_indices_noop() {
496        let mut a = ma(vec![1.0, 2.0, 3.0], vec![false; 3]);
497        a.put(&[], &[99.0], None, PutMode::Raise).unwrap();
498        assert_eq!(data_of(&a), vec![1.0, 2.0, 3.0]);
499    }
500
501    // ---- put: empty values leaves DATA unchanged but unmasks targets (D3) ----
502    // numpy delegates the data write to `ndarray.put(indices, values)`, a
503    // silent no-op for an empty `values`; the mask is updated by a SEPARATE
504    // `m.put(indices, False)` so the targeted positions are unmasked.
505    // Oracle: a=np.ma.array([1.,2,3], mask=[1,0,0]); a.put([0], [])
506    //   -> data [1,2,3] (unchanged), mask [F,F,F] (a[0] unmasked).
507    #[test]
508    fn put_empty_values_data_noop_unmasks_target() {
509        let mut a = ma(vec![1.0, 2.0, 3.0], vec![true, false, false]);
510        a.put(&[0], &[], None, PutMode::Raise).unwrap();
511        assert_eq!(data_of(&a), vec![1.0, 2.0, 3.0]);
512        assert_eq!(mask_of(&a), vec![false, false, false]);
513    }
514
515    // ---- put: hard-mask short values zero-pad, not cycle (D1) ----
516    // numpy hard-mask branch: values.resize(indices.shape) ZERO-PADS, then
517    // filters indices/values by the original ~mask.
518    // Oracle: a=np.ma.array([1.,2,3,4,5], mask=[0,1,0,0,0]); a.harden_mask();
519    //   a.put([0,1,2],[10,20]):
520    //     resize([10,20]->(3,)) = [10,20,0]; ~mask over idx[0,1,2]=[F,T,F]
521    //     -> keep idx[0,2], values[10,0] -> data [10,2,0,4,5], mask unchanged.
522    #[test]
523    fn put_hard_mask_short_values_zeropad() {
524        let mut a = ma(
525            vec![1.0, 2.0, 3.0, 4.0, 5.0],
526            vec![false, true, false, false, false],
527        );
528        a.harden_mask().unwrap();
529        a.put(&[0, 1, 2], &[10.0, 20.0], None, PutMode::Raise)
530            .unwrap();
531        assert_eq!(data_of(&a), vec![10.0, 2.0, 0.0, 4.0, 5.0]);
532        assert_eq!(mask_of(&a), vec![false, true, false, false, false]);
533    }
534
535    // ---- put: hard-mask, no masked target among indices, still zero-pads ----
536    // Oracle: a=np.ma.array([1.,2,3,4,5], mask=[0,1,0,0,0]); a.harden_mask();
537    //   a.put([0,2,4],[10,20]) -> resize->[10,20,0]; all ~mask kept
538    //   -> data [10,2,20,4,0].
539    #[test]
540    fn put_hard_mask_zeropad_all_kept() {
541        let mut a = ma(
542            vec![1.0, 2.0, 3.0, 4.0, 5.0],
543            vec![false, true, false, false, false],
544        );
545        a.harden_mask().unwrap();
546        a.put(&[0, 2, 4], &[10.0, 20.0], None, PutMode::Raise)
547            .unwrap();
548        assert_eq!(data_of(&a), vec![10.0, 2.0, 20.0, 4.0, 0.0]);
549    }
550
551    // ---- put: hard-mask, values longer than indices (resize truncates) ----
552    // Oracle: a=np.ma.array([1.,2,3,4,5], mask=[0,1,0,0,0]); a.harden_mask();
553    //   a.put([0,1,2],[10,20,30,40,50]) -> resize to (3,)=[10,20,30];
554    //   keep idx[0,2] vals[10,30] -> data [10,2,30,4,5].
555    #[test]
556    fn put_hard_mask_long_values_truncate() {
557        let mut a = ma(
558            vec![1.0, 2.0, 3.0, 4.0, 5.0],
559            vec![false, true, false, false, false],
560        );
561        a.harden_mask().unwrap();
562        a.put(
563            &[0, 1, 2],
564            &[10.0, 20.0, 30.0, 40.0, 50.0],
565            None,
566            PutMode::Raise,
567        )
568        .unwrap();
569        assert_eq!(data_of(&a), vec![10.0, 2.0, 30.0, 4.0, 5.0]);
570    }
571
572    // ---- putmask: basic, clears mask where written ----
573    // Oracle: x=ma([1,2,3,4], mask=[T,F,F,F]);
574    //   np.ma.putmask(x,[T,F,T,F],[10,20,30,40])
575    //   -> data [10,2,30,4], mask all False.
576    #[test]
577    fn putmask_basic_clears_mask() {
578        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![true, false, false, false]);
579        x.putmask(&[true, false, true, false], &[10.0, 20.0, 30.0, 40.0], None)
580            .unwrap();
581        assert_eq!(data_of(&x), vec![10.0, 2.0, 30.0, 4.0]);
582        assert_eq!(mask_of(&x), vec![false, false, false, false]);
583    }
584
585    // ---- putmask: scalar values broadcast ----
586    // Oracle: x=ma([1,2,3,4], nomask); np.ma.putmask(x,[T,F,T,F],99.)
587    //   -> data [99,2,99,4].
588    #[test]
589    fn putmask_scalar_broadcast() {
590        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
591        x.putmask(&[true, false, true, false], &[99.0], None)
592            .unwrap();
593        assert_eq!(data_of(&x), vec![99.0, 2.0, 99.0, 4.0]);
594        assert_eq!(mask_of(&x), vec![false, false, false, false]);
595    }
596
597    // ---- putmask: mismatched non-scalar values raises (copyto broadcast) ----
598    // Oracle: np.ma.putmask(x, [T,T,T,T], [9,8]) -> ValueError (broadcast).
599    #[test]
600    fn putmask_mismatched_values_errors() {
601        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
602        assert!(
603            x.putmask(&[true, true, true, true], &[9.0, 8.0], None)
604                .is_err()
605        );
606    }
607
608    // ---- putmask: mask length mismatch errors ----
609    #[test]
610    fn putmask_mask_length_mismatch_errors() {
611        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
612        assert!(x.putmask(&[true, false], &[9.0], None).is_err());
613    }
614
615    // ---- putmask: length-1 mask broadcasts across all positions (D2) ----
616    // numpy's `np.copyto(_data, valdata, where=mask)` broadcasts the `where=`
617    // mask, so a length-1 True mask writes every position.
618    // Oracle: a=np.ma.array([1.,2,3,4]); np.ma.putmask(a,[True],[9])
619    //   -> [9,9,9,9].
620    #[test]
621    fn putmask_length1_mask_broadcasts() {
622        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
623        x.putmask(&[true], &[9.0], None).unwrap();
624        assert_eq!(data_of(&x), vec![9.0, 9.0, 9.0, 9.0]);
625    }
626
627    // ---- putmask: length-1 False mask writes nothing ----
628    // Oracle: a=np.ma.array([1.,2,3,4]); np.ma.putmask(a,[False],[9])
629    //   -> [1,2,3,4].
630    #[test]
631    fn putmask_length1_false_mask_noop() {
632        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
633        x.putmask(&[false], &[9.0], None).unwrap();
634        assert_eq!(data_of(&x), vec![1.0, 2.0, 3.0, 4.0]);
635    }
636
637    // ---- putmask: length-1 masked values broadcast their mask too (D2) ----
638    // Oracle: a=np.ma.array([1.,2,3,4]); v=ma.array([9],mask=[1]);
639    //   np.ma.putmask(a,[True],v) -> data [9,9,9,9], mask [T,T,T,T].
640    #[test]
641    fn putmask_length1_mask_broadcasts_values_mask() {
642        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
643        x.putmask(&[true], &[9.0], Some(&[true])).unwrap();
644        assert_eq!(data_of(&x), vec![9.0, 9.0, 9.0, 9.0]);
645        assert_eq!(mask_of(&x), vec![true, true, true, true]);
646    }
647
648    // ---- putmask: hard mask keeps existing mask; data still overwritten ----
649    // Oracle: x=ma([1,2,3,4], mask=[T,F,F,F]); x.harden_mask();
650    //   np.ma.putmask(x,[T,T,F,F],[10,20,30,40])
651    //   -> data [10,20,3,4], mask [T,F,F,F] (a[0] data overwritten, mask kept).
652    #[test]
653    fn putmask_hard_keeps_mask_overwrites_data() {
654        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![true, false, false, false]);
655        x.harden_mask().unwrap();
656        x.putmask(&[true, true, false, false], &[10.0, 20.0, 30.0, 40.0], None)
657            .unwrap();
658        assert_eq!(data_of(&x), vec![10.0, 20.0, 3.0, 4.0]);
659        assert_eq!(mask_of(&x), vec![true, false, false, false]);
660    }
661
662    // ---- putmask: soft + masked values propagate (set/clear) ----
663    // Oracle: x=ma([1,2,3,4], mask=[F,F,F,T]);
664    //   vals=ma([10,20,30,40], mask=[T,F,F,F]);
665    //   np.ma.putmask(x,[T,T,F,F],vals)
666    //   -> data [10,20,3,4], mask [T,F,F,T].
667    #[test]
668    fn putmask_soft_masked_values_propagate() {
669        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false, false, false, true]);
670        x.putmask(
671            &[true, true, false, false],
672            &[10.0, 20.0, 30.0, 40.0],
673            Some(&[true, false, false, false]),
674        )
675        .unwrap();
676        assert_eq!(data_of(&x), vec![10.0, 20.0, 3.0, 4.0]);
677        assert_eq!(mask_of(&x), vec![true, false, false, true]);
678    }
679
680    // ---- putmask: hard + masked values union (never clears) ----
681    // Oracle: x=ma([1,2,3,4], nomask); x.harden_mask();
682    //   vals=ma([10,20,30,40], mask=[T,F,F,F]);
683    //   np.ma.putmask(x,[T,T,F,F],vals)
684    //   -> data [10,20,3,4], mask [T,F,F,F].
685    #[test]
686    fn putmask_hard_masked_values_union() {
687        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![false; 4]);
688        x.harden_mask().unwrap();
689        x.putmask(
690            &[true, true, false, false],
691            &[10.0, 20.0, 30.0, 40.0],
692            Some(&[true, false, false, false]),
693        )
694        .unwrap();
695        assert_eq!(data_of(&x), vec![10.0, 20.0, 3.0, 4.0]);
696        assert_eq!(mask_of(&x), vec![true, false, false, false]);
697    }
698
699    // ---- putmask: hard pos already masked -> data still overwritten ----
700    // Oracle: x=ma([1,2,3,4], mask=[T,F,F,F]); x.harden_mask();
701    //   np.ma.putmask(x,[T,F,F,F],[10,20,30,40])
702    //   -> data [10,2,3,4], mask [T,F,F,F].
703    #[test]
704    fn putmask_hard_masked_target_data_overwritten() {
705        let mut x = ma(vec![1.0, 2.0, 3.0, 4.0], vec![true, false, false, false]);
706        x.harden_mask().unwrap();
707        x.putmask(&[true, false, false, false], &[10.0], None)
708            .unwrap();
709        assert_eq!(data_of(&x), vec![10.0, 2.0, 3.0, 4.0]);
710        assert_eq!(mask_of(&x), vec![true, false, false, false]);
711    }
712
713    // ---- PutMode::from_str ----
714    #[test]
715    fn put_mode_parse() {
716        assert_eq!(PutMode::parse("raise").unwrap(), PutMode::Raise);
717        assert_eq!(PutMode::parse("wrap").unwrap(), PutMode::Wrap);
718        assert_eq!(PutMode::parse("clip").unwrap(), PutMode::Clip);
719        assert!(PutMode::parse("nonsense").is_err());
720    }
721}