Skip to main content

volas_core/column/
ops.rs

1//! `Column` arithmetic, comparison, logical, and reduction operations.
2
3use super::*;
4
5impl Column {
6    /// Combined validity of `self` and `other` (present only where both are) —
7    /// the missing-value rule for a dtype-preserving binary op.
8    fn combined_nulls(&self, other: &Column) -> Validity {
9        let a = self.validity().cloned().unwrap_or_default();
10        let b = other.validity().cloned().unwrap_or_default();
11        a.and(&b)
12    }
13
14    /// Error if this column is not numeric (`float` / `int` / `bool` — bool acts as
15    /// 0/1). The guard for the numeric-only operations (arithmetic, reductions): a
16    /// `Str` / `Datetime` column would funnel through `to_f64_vec` to `NaN` (str) or
17    /// a quantized epoch (datetime), which the API contract (C4) forbids as a silent
18    /// lossy conversion.
19    pub fn require_numeric(&self) -> Result<()> {
20        if self.dtype().is_numeric() || matches!(self, Column::Bool(..)) {
21            Ok(())
22        } else {
23            Err(VolasError::DType(format!(
24                "a numeric operation is not supported on a {} column",
25                self.dtype()
26            )))
27        }
28    }
29
30    /// Binary `+ - *` against `other`, dtype-preserving via [`binary_supertype`]
31    /// (`int ∘ int → i64`, else f64). `bool ∘ bool` is logical, matching pandas:
32    /// `+` is OR, `*` is AND, `-` is an error (numpy disallows bool subtraction);
33    /// `bool ∘ number` promotes (bool acts as 0/1). Wrapping int ops match pandas
34    /// overflow. Equal lengths assumed.
35    pub fn binary(&self, other: &Column, op: BinOp) -> Result<Column> {
36        self.require_numeric()?;
37        other.require_numeric()?;
38        if let (Column::Bool(a, av), Column::Bool(b, bv)) = (self, other) {
39            let nulls = av.and(bv);
40            return match op {
41                BinOp::Add => Ok(Column::bool_with(
42                    a.iter().zip(b.iter()).map(|(&x, &y)| x || y).collect(),
43                    nulls,
44                )),
45                BinOp::Mul => Ok(Column::bool_with(
46                    a.iter().zip(b.iter()).map(|(&x, &y)| x && y).collect(),
47                    nulls,
48                )),
49                BinOp::Sub => Err(VolasError::DType(
50                    "the `-` operator is not supported for bool columns (use `^`)".into(),
51                )),
52            };
53        }
54        match binary_supertype(self.dtype(), other.dtype()) {
55            DType::I64 => Ok(Column::i64_with(
56                binary_kernel(&self.as_i64_vec()?, &other.as_i64_vec()?, op),
57                self.combined_nulls(other),
58            )),
59            DType::I32 => Ok(Column::i32_with(
60                binary_kernel(&self.as_i32_vec()?, &other.as_i32_vec()?, op),
61                self.combined_nulls(other),
62            )),
63            DType::F32 => Ok(Column::f32(binary_kernel(
64                &self.to_f32_vec(),
65                &other.to_f32_vec(),
66                op,
67            ))),
68            _ => Ok(Column::f64(binary_kernel(
69                &self.to_f64_vec(),
70                &other.to_f64_vec(),
71                op,
72            ))),
73        }
74    }
75
76    /// True division `self / other` (pandas `/`): always float. `bool / bool` is
77    /// an error, matching pandas (division is not defined on bool).
78    pub fn div(&self, other: &Column) -> Result<Column> {
79        self.require_numeric()?;
80        other.require_numeric()?;
81        if matches!((self, other), (Column::Bool(_, _), Column::Bool(_, _))) {
82            return Err(VolasError::DType(
83                "division is not supported between two bool columns".into(),
84            ));
85        }
86        let (a, b) = (self.to_f64_vec(), other.to_f64_vec());
87        Ok(Column::f64(
88            a.iter().zip(&b).map(|(&x, &y)| x / y).collect(),
89        ))
90    }
91
92    /// Floor division `self // other` (pandas `//`), dtype-preserving like pandas:
93    /// `int // int` stays integer (the supertype, exact even past 2^53) **unless** a
94    /// present divisor is `0`, in which case the whole result is float64 (`inf` /
95    /// `-inf` / `nan`) — exactly how pandas computes it; anything involving a float
96    /// is float64. NA in either operand propagates. `bool // bool` is an error, like
97    /// true division.
98    pub fn floordiv(&self, other: &Column) -> Result<Column> {
99        self.require_numeric()?;
100        other.require_numeric()?;
101        if matches!((self, other), (Column::Bool(_, _), Column::Bool(_, _))) {
102            return Err(VolasError::DType(
103                "floor division is not supported between two bool columns".into(),
104            ));
105        }
106        let n = self.len();
107        let st = binary_supertype(self.dtype(), other.dtype());
108        // A present divisor of 0 forces pandas' float path (`inf` / `-inf` / `nan`).
109        let int_path = matches!(st, DType::I64 | DType::I32)
110            && !(0..n).any(|i| other.is_valid(i) && other.get_f64(i) == 0.0);
111        if int_path {
112            let (a, b) = (self.as_i64_vec()?, other.as_i64_vec()?);
113            let present = |i: usize| self.is_valid(i) && other.is_valid(i);
114            // exact i64 floor division (the divisor is non-zero where present);
115            // NA positions hold a 0 placeholder, masked by the result validity.
116            let out: Vec<i64> = (0..n)
117                .map(|i| if present(i) { ifloordiv(a[i], b[i]) } else { 0 })
118                .collect();
119            let val = Validity::from_valid_iter(n, (0..n).map(present));
120            // i32 // i32 stays i32 (the result fits); any i64 operand widens to i64.
121            Ok(if st == DType::I32 {
122                Column::i32_with(out.iter().map(|&x| x as i32).collect(), val)
123            } else {
124                Column::i64_with(out, val)
125            })
126        } else {
127            let (a, b) = (self.to_f64_vec(), other.to_f64_vec());
128            Ok(Column::f64(
129                a.iter().zip(&b).map(|(&x, &y)| (x / y).floor()).collect(),
130            ))
131        }
132    }
133
134    /// Element-wise comparison (pandas `== != < <= > >=`) producing a **non-nullable
135    /// bool** mask (decision ②): `str` / `datetime` / `bool` compare by their native
136    /// value (NOT through the `to_f64_vec` funnel, which maps `str` to `NaN` and
137    /// loses `datetime` precision), numeric kinds compare as `f64`. A missing slot
138    /// (either side NA) follows IEEE — `!=` is `true`, every other op `false`,
139    /// matching the existing numeric comparison. Comparing two incompatible kinds
140    /// (e.g. `str` vs a number) is a [`VolasError::DType`]. Equal lengths assumed.
141    pub fn compare(&self, other: &Column, op: CmpOp) -> Result<Column> {
142        let n = self.len();
143        let numeric_like = |c: &Column| c.dtype().is_numeric() || matches!(c, Column::Bool(..));
144        let out = match (self, other) {
145            (Column::Str(a, av), Column::Str(b, bv)) => cmp_typed(n, op, |i| {
146                // SAFETY: `i < n` and both columns have length `n` (equal lengths assumed).
147                (av.is_valid(i) && bv.is_valid(i))
148                    .then(|| unsafe { a.get_unchecked(i).cmp(b.get_unchecked(i)) })
149            }),
150            (Column::Datetime(a), Column::Datetime(b)) => cmp_typed(n, op, |i| {
151                (a[i] != i64::MIN && b[i] != i64::MIN).then(|| a[i].cmp(&b[i]))
152            }),
153            (Column::Bool(a, av), Column::Bool(b, bv)) => cmp_typed(n, op, |i| {
154                (av.is_valid(i) && bv.is_valid(i)).then(|| a[i].cmp(&b[i]))
155            }),
156            // numeric (and bool-vs-numeric) compare as f64 — NaN follows IEEE here
157            // (`==` false, `!=` true), the established mask policy.
158            (a, b) if numeric_like(a) && numeric_like(b) => {
159                let (x, y) = (a.to_f64_vec(), b.to_f64_vec());
160                (0..n).map(|i| op.matches_f64(x[i], y[i])).collect()
161            }
162            _ => {
163                return Err(VolasError::DType(format!(
164                    "cannot compare a {} column with a {} column",
165                    self.dtype(),
166                    other.dtype()
167                )))
168            }
169        };
170        Ok(Column::bool(out))
171    }
172
173    /// Three-valued logical `and` / `or` / `xor` (pandas `&` / `|` / `^`), Kleene
174    /// semantics: a present `false` makes `and` false and a present `true` makes
175    /// `or` true even when the other side is missing; otherwise a missing operand
176    /// yields NA. A non-bool operand is read as `x != 0` (present). Equal lengths.
177    pub fn logical(&self, other: &Column, op: BoolOp) -> Column {
178        let n = self.len();
179        from_option_bools(
180            n,
181            (0..n).map(|i| {
182                let (a, ap) = self.bool_at(i);
183                let (b, bp) = other.bool_at(i);
184                match op {
185                    BoolOp::And => {
186                        if (ap && !a) || (bp && !b) {
187                            Some(false)
188                        } else if ap && bp {
189                            Some(true)
190                        } else {
191                            None
192                        }
193                    }
194                    BoolOp::Or => {
195                        if (ap && a) || (bp && b) {
196                            Some(true)
197                        } else if ap && bp {
198                            Some(false)
199                        } else {
200                            None
201                        }
202                    }
203                    BoolOp::Xor => (ap && bp).then_some(a ^ b),
204                }
205            }),
206        )
207    }
208
209    /// Logical NOT (pandas `~`), propagating missing (NA in -> NA out). A non-bool
210    /// column is read as `x != 0` first.
211    pub fn not(&self) -> Column {
212        let n = self.len();
213        from_option_bools(
214            n,
215            (0..n).map(|i| {
216                let (a, ap) = self.bool_at(i);
217                ap.then_some(!a)
218            }),
219        )
220    }
221
222    /// The value (`x != 0` for a non-bool) and presence of element `i`, for the
223    /// logical ops. A non-bool is always present (its missing-as-bool question is
224    /// the comparison policy: a missing value compares/reads `false`-y).
225    fn bool_at(&self, i: usize) -> (bool, bool) {
226        match self {
227            Column::Bool(v, val) => (v[i], val.is_valid(i)),
228            _ => (self.get_f64(i) != 0.0, true),
229        }
230    }
231
232    /// Sum, dtype-preserving (pandas): a float column -> float, int/bool -> i64
233    /// (bool counts trues). Computed natively (i64 in i64, exact past 2^53).
234    pub fn sum(&self) -> Scalar {
235        match self {
236            Column::F64(v) => Scalar::F64(stats::sum(v.as_slice())),
237            Column::F32(v) => Scalar::F32(stats::sum(v.as_slice())),
238            Column::I64(v, val) => Scalar::I64(sum_valid(v, val)),
239            // int32 / bool sum promotes to int64 (pandas / numpy accumulator)
240            Column::I32(v, val) => Scalar::I64(sum_valid(&widen_i64(v), val)),
241            Column::Bool(v, val) => Scalar::I64(sum_valid(&widen_i64(v), val)),
242            other => Scalar::F64(stats::sum(&other.to_f64_vec())),
243        }
244    }
245
246    /// Product, dtype-preserving (float -> float, int/bool -> i64).
247    pub fn prod(&self) -> Scalar {
248        match self {
249            Column::F64(v) => Scalar::F64(stats::prod(v.as_slice())),
250            Column::F32(v) => Scalar::F32(stats::prod(v.as_slice())),
251            Column::I64(v, val) => Scalar::I64(prod_valid(v, val)),
252            Column::I32(v, val) => Scalar::I64(prod_valid(&widen_i64(v), val)),
253            Column::Bool(v, val) => Scalar::I64(prod_valid(&widen_i64(v), val)),
254            other => Scalar::F64(stats::prod(&other.to_f64_vec())),
255        }
256    }
257
258    /// Minimum (`want_max = false`) / maximum, dtype-preserving (float -> float,
259    /// int -> i64, bool -> bool). Empty / all-missing -> `F64(NaN)`.
260    pub fn extreme(&self, want_max: bool) -> Scalar {
261        match self {
262            Column::I64(v, val) => match extreme_valid(v, val, want_max) {
263                Some(x) => Scalar::I64(x),
264                None => Scalar::F64(f64::NAN),
265            },
266            Column::I32(v, val) => match extreme_valid(v, val, want_max) {
267                Some(x) => Scalar::I32(x),
268                None => Scalar::F64(f64::NAN),
269            },
270            Column::F32(v) => {
271                Scalar::F32(stats::extreme(v.as_slice(), want_max).unwrap_or(f32::NAN))
272            }
273            Column::Bool(v, val) => {
274                // min = all (AND) / max = any (OR), over present values; none -> NaN
275                let present = |i: usize| val.is_valid(i);
276                if !(0..v.len()).any(present) {
277                    Scalar::F64(f64::NAN)
278                } else if want_max {
279                    Scalar::Bool((0..v.len()).any(|i| present(i) && v[i]))
280                } else {
281                    Scalar::Bool((0..v.len()).all(|i| !present(i) || v[i]))
282                }
283            }
284            Column::F64(v) => {
285                Scalar::F64(stats::extreme(v.as_slice(), want_max).unwrap_or(f64::NAN))
286            }
287            other => Scalar::F64(stats::extreme(&other.to_f64_vec(), want_max).unwrap_or(f64::NAN)),
288        }
289    }
290}