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}