Skip to main content

volas_core/column/
order.rs

1//! `Column` structural & order-based operations — slice/take, unique,
2//! sort/rank, the typed extreme/running-extreme, append, scatter, and equality.
3
4use super::*;
5
6impl Column {
7    /// A contiguous `[start, end)` slice (a fresh buffer).
8    pub fn slice(&self, start: usize, end: usize) -> Column {
9        match self {
10            Column::F64(v) => Column::f64(v[start..end].to_vec()),
11            Column::F32(v) => Column::f32(v[start..end].to_vec()),
12            Column::Bool(v, val) => {
13                Column::bool_with(v[start..end].to_vec(), val.slice(start, end))
14            }
15            Column::I64(v, val) => Column::i64_with(v[start..end].to_vec(), val.slice(start, end)),
16            Column::I32(v, val) => Column::i32_with(v[start..end].to_vec(), val.slice(start, end)),
17            Column::Str(v, val) => Column::Str(v.slice(start, end), val.slice(start, end)),
18            Column::Datetime(v) => Column::datetime(v[start..end].to_vec()),
19        }
20    }
21
22    /// Gather the given positions into a new column (fancy indexing).
23    pub fn take(&self, idx: &[usize]) -> Column {
24        match self {
25            Column::F64(v) => Column::f64(idx.iter().map(|&i| v[i]).collect()),
26            Column::F32(v) => Column::f32(idx.iter().map(|&i| v[i]).collect()),
27            Column::Bool(v, val) => {
28                Column::bool_with(idx.iter().map(|&i| v[i]).collect(), val.take(idx))
29            }
30            Column::I64(v, val) => {
31                Column::i64_with(idx.iter().map(|&i| v[i]).collect(), val.take(idx))
32            }
33            Column::I32(v, val) => {
34                Column::i32_with(idx.iter().map(|&i| v[i]).collect(), val.take(idx))
35            }
36            // Gather straight into one contiguous `StrBuffer` (no intermediate
37            // `Vec<String>`, so no per-cell allocation).
38            Column::Str(v, val) => {
39                Column::Str(idx.iter().map(|&i| v.get(i)).collect(), val.take(idx))
40            }
41            Column::Datetime(v) => Column::datetime(idx.iter().map(|&i| v[i]).collect()),
42        }
43    }
44
45    /// Gather optional positions into a new column of the SAME dtype: `Some(i)`
46    /// reads row `i`, `None` is a missing cell (dtype-preserving NA — int/bool/str
47    /// grow a validity hole, float `NaN`, datetime `NaT`). Backs the window
48    /// `first` / `last` aggregations.
49    pub fn take_optional(&self, idx: &[Option<usize>]) -> Column {
50        let validity = || {
51            Validity::from_valid_iter(
52                idx.len(),
53                idx.iter().map(|p| p.is_some_and(|i| self.is_valid(i))),
54            )
55        };
56        match self {
57            Column::F64(v) => {
58                Column::f64(idx.iter().map(|p| p.map_or(f64::NAN, |i| v[i])).collect())
59            }
60            Column::F32(v) => {
61                Column::f32(idx.iter().map(|p| p.map_or(f32::NAN, |i| v[i])).collect())
62            }
63            Column::Bool(v, _) => Column::bool_with(
64                idx.iter().map(|p| p.is_some_and(|i| v[i])).collect(),
65                validity(),
66            ),
67            Column::I64(v, _) => Column::i64_with(
68                idx.iter().map(|p| p.map_or(0, |i| v[i])).collect(),
69                validity(),
70            ),
71            Column::I32(v, _) => Column::i32_with(
72                idx.iter().map(|p| p.map_or(0, |i| v[i])).collect(),
73                validity(),
74            ),
75            // `None` → empty placeholder (the validity marks it NA); gather builds the
76            // `StrBuffer` in one pass.
77            Column::Str(v, _) => Column::Str(
78                idx.iter().map(|p| p.map_or("", |i| v.get(i))).collect(),
79                validity(),
80            ),
81            Column::Datetime(v) => Column::datetime(
82                idx.iter().map(|p| p.map_or(i64::MIN, |i| v[i])).collect(),
83            ),
84        }
85    }
86
87    /// Number of present (non-missing) values (pandas `count`): `len - null_count`,
88    /// reading the validity for every dtype (a float `NaN`, an int/bool/str NA, a
89    /// datetime `NaT`).
90    pub fn count(&self) -> usize {
91        self.len() - self.null_count()
92    }
93
94    /// Number of distinct present values (pandas `nunique`, `dropna=True`).
95    pub fn nunique(&self) -> usize {
96        self.group_records().iter().filter(|(_, _, na)| !na).count()
97    }
98
99    /// First-appearance index of each distinct value (pandas `unique` order),
100    /// **including one missing slot** if the column has any NA — so `take`ing these
101    /// indices yields the distinct values with a single `NA` where present.
102    pub fn unique_indices(&self) -> Vec<usize> {
103        self.group_records()
104            .iter()
105            .map(|(first, _, _)| *first)
106            .collect()
107    }
108
109    /// Distinct-value group records `(first_index, count, is_na)` in order of first
110    /// appearance — the shared basis of `nunique` / `unique` / `value_counts`. Every
111    /// missing value (`NaN` / NA / `NaT`) collapses into one `is_na = true` group.
112    pub(crate) fn group_records(&self) -> Vec<(usize, usize, bool)> {
113        let len = self.len();
114        match self {
115            Column::F64(v) => group_by(len, |i| float_key(v[i])),
116            Column::F32(v) => group_by(len, |i| float_key(v[i] as f64)),
117            Column::I64(v, val) => group_by(len, |i| val.is_valid(i).then_some(v[i])),
118            Column::I32(v, val) => group_by(len, |i| val.is_valid(i).then_some(v[i] as i64)),
119            Column::Bool(v, val) => group_by(len, |i| val.is_valid(i).then_some(v[i] as i64)),
120            Column::Str(v, val) => group_by(len, |i| val.is_valid(i).then(|| v.get(i).to_string())),
121            Column::Datetime(v) => group_by(len, |i| (v[i] != i64::MIN).then_some(v[i])),
122        }
123    }
124
125    /// Indices that sort the column (pandas `sort_values`), **stable**, with every
126    /// missing value placed last regardless of `ascending` (pandas `na_position =
127    /// 'last'`). Present values compare per dtype (float by value, str lexically).
128    pub fn argsort(&self, ascending: bool) -> Vec<usize> {
129        let mut idx: Vec<usize> = (0..self.len()).collect();
130        idx.sort_by(|&a, &b| match (self.is_valid(a), self.is_valid(b)) {
131            (false, false) => Ordering::Equal,
132            (false, true) => Ordering::Greater, // NA sinks to the end
133            (true, false) => Ordering::Less,
134            (true, true) => {
135                let o = self.cmp_at(a, b);
136                if ascending {
137                    o
138                } else {
139                    o.reverse()
140                }
141            }
142        });
143        idx
144    }
145
146    /// Order two **present** values at `a` / `b` (helper for [`argsort`](Self::argsort);
147    /// floats are non-`NaN` here, so `partial_cmp` is total).
148    fn cmp_at(&self, a: usize, b: usize) -> Ordering {
149        match self {
150            Column::F64(v) => v[a].partial_cmp(&v[b]).unwrap_or(Ordering::Equal),
151            Column::F32(v) => v[a].partial_cmp(&v[b]).unwrap_or(Ordering::Equal),
152            Column::I64(v, _) => v[a].cmp(&v[b]),
153            Column::I32(v, _) => v[a].cmp(&v[b]),
154            Column::Bool(v, _) => v[a].cmp(&v[b]),
155            // SAFETY: `a`/`b` are present-row indices from `argsort` / `arg_extreme`,
156            // always `< len`; the unchecked accessor avoids redundant offset/data checks.
157            Column::Str(v, _) => unsafe { v.get_unchecked(a).cmp(v.get_unchecked(b)) },
158            Column::Datetime(v) => v[a].cmp(&v[b]),
159        }
160    }
161
162    /// Position of the maximum (`want_max`) or minimum **present** value,
163    /// dtype-aware via [`cmp_at`](Self::cmp_at) — numeric by value, `str`
164    /// lexically, `datetime` by raw `i64` (so sub-256ns ordering survives, unlike
165    /// the `to_f64_vec` funnel which collapses it past 2^53). Ties keep the FIRST
166    /// occurrence (pandas `idxmax`/`idxmin`). `None` if every value is missing.
167    /// The typed basis of `idxmax` / `idxmin` / `min` / `max`.
168    pub fn arg_extreme(&self, want_max: bool) -> Option<usize> {
169        let mut best: Option<usize> = None;
170        for i in 0..self.len() {
171            if !self.is_valid(i) {
172                continue;
173            }
174            let take = match best {
175                None => true,
176                Some(b) => {
177                    let o = self.cmp_at(i, b);
178                    (want_max && o == Ordering::Greater) || (!want_max && o == Ordering::Less)
179                }
180            };
181            if take {
182                best = Some(i);
183            }
184        }
185        best
186    }
187
188    /// Cumulative running extreme (pandas `cummax` if `want_max`, else `cummin`),
189    /// dtype-aware and dtype-preserving via [`cmp_at`](Self::cmp_at): numeric by
190    /// value, `str` lexically, `datetime` by instant. A present cell takes the
191    /// running extreme of the present values seen so far; a missing cell stays
192    /// missing (skipped, not filled — pandas `cummax([3, NA, 5]) == [3, NA, 5]`).
193    /// Built by gathering each output position from the cell that holds its
194    /// running extreme (or itself, when missing).
195    pub fn cum_extreme(&self, want_max: bool) -> Result<Column> {
196        let n = self.len();
197        let mut positions: Vec<usize> = (0..n).collect(); // missing cells point at self -> stay NA
198        let mut best: Option<usize> = None;
199        for (i, slot) in positions.iter_mut().enumerate() {
200            if self.is_valid(i) {
201                best = Some(match best {
202                    None => i,
203                    Some(b) => {
204                        let o = self.cmp_at(i, b);
205                        if (want_max && o == Ordering::Greater)
206                            || (!want_max && o == Ordering::Less)
207                        {
208                            i
209                        } else {
210                            b
211                        }
212                    }
213                });
214                *slot = best.expect("just set"); // present cell -> running extreme so far
215            }
216        }
217        Ok(self.take(&positions))
218    }
219
220    /// Order-based rank (pandas `rank`, 1-based, missing -> `NaN`), dtype-aware
221    /// via [`cmp_at`](Self::cmp_at): numeric by value, `str` lexically, `datetime`
222    /// by raw `i64`, `bool` by `false < true`. The result is always `f64` (ties
223    /// can average to `x.5`), so rank is order-based, not a numeric-arithmetic op.
224    pub fn rank(&self, method: stats::RankMethod, ascending: bool, pct: bool) -> Vec<f64> {
225        stats::rank_by(
226            self.len(),
227            |i| self.is_valid(i),
228            |a, b| self.cmp_at(a, b),
229            method,
230            ascending,
231            pct,
232        )
233    }
234
235    /// Append another column of the same dtype, copy-on-write (grows in place
236    /// when the buffer is uniquely owned).
237    pub fn append(&mut self, other: &Column) -> Result<()> {
238        match (self, other) {
239            (Column::F64(a), Column::F64(b)) => {
240                a.make_mut().extend_from_slice(b);
241                Ok(())
242            }
243            (Column::F32(a), Column::F32(b)) => {
244                a.make_mut().extend_from_slice(b);
245                Ok(())
246            }
247            (Column::Bool(a, av), Column::Bool(b, bv)) => {
248                append_validity(av, a.len(), bv, b.len());
249                a.make_mut().extend_from_slice(b);
250                Ok(())
251            }
252            (Column::I64(a, av), Column::I64(b, bv)) => {
253                append_validity(av, a.len(), bv, b.len());
254                a.make_mut().extend_from_slice(b);
255                Ok(())
256            }
257            (Column::I32(a, av), Column::I32(b, bv)) => {
258                append_validity(av, a.len(), bv, b.len());
259                a.make_mut().extend_from_slice(b);
260                Ok(())
261            }
262            (Column::Str(a, av), Column::Str(b, bv)) => {
263                append_validity(av, a.len(), bv, b.len());
264                a.extend(b.iter());
265                Ok(())
266            }
267            (Column::Datetime(a), Column::Datetime(b)) => {
268                a.make_mut().extend_from_slice(b);
269                Ok(())
270            }
271            (s, o) => Err(VolasError::DType(format!(
272                "cannot append a {} column onto a {} column",
273                o.dtype(),
274                s.dtype()
275            ))),
276        }
277    }
278
279    /// Extend a stale computed column with placeholder missing values, avoiding a
280    /// temporary one-row [`Column`] allocation on the live append path.
281    pub fn append_missing(&mut self, len: usize) -> Result<()> {
282        match self {
283            Column::F64(v) => {
284                v.make_mut().extend(std::iter::repeat_n(f64::NAN, len));
285                Ok(())
286            }
287            Column::F32(v) => {
288                v.make_mut().extend(std::iter::repeat_n(f32::NAN, len));
289                Ok(())
290            }
291            // The refresh path overwrites these placeholder rows on recompute, so a
292            // dense `false` keeps the validity simple (no lingering NA to clear).
293            Column::Bool(v, _) => {
294                v.make_mut().extend(std::iter::repeat_n(false, len));
295                Ok(())
296            }
297            other => Err(VolasError::DType(format!(
298                "column type {} has no missing-value placeholder",
299                other.dtype()
300            ))),
301        }
302    }
303
304    /// Extend a **plain** (non-computed) column with `len` genuine missing values,
305    /// dtype-preserving: float / datetime use their in-band sentinel (`NaN` /
306    /// `NaT`), while int / bool / str grow the validity bitmap with `len` invalid
307    /// bits. Used when a column is absent from an appended frame; a cached
308    /// directive instead uses the cheaper [`append_missing`] placeholder, which
309    /// `fulfill` overwrites.
310    pub fn append_na(&mut self, len: usize) {
311        let old = self.len();
312        let na_validity = |val: &Validity| {
313            Validity::from_valid_iter(
314                old + len,
315                (0..old + len).map(|i| i < old && val.is_valid(i)),
316            )
317        };
318        match self {
319            Column::F64(v) => v.make_mut().extend(std::iter::repeat_n(f64::NAN, len)),
320            Column::F32(v) => v.make_mut().extend(std::iter::repeat_n(f32::NAN, len)),
321            Column::Datetime(v) => v.make_mut().extend(std::iter::repeat_n(i64::MIN, len)),
322            Column::I64(v, val) => {
323                *val = na_validity(val);
324                v.make_mut().extend(std::iter::repeat_n(0, len));
325            }
326            Column::I32(v, val) => {
327                *val = na_validity(val);
328                v.make_mut().extend(std::iter::repeat_n(0, len));
329            }
330            Column::Bool(v, val) => {
331                *val = na_validity(val);
332                v.make_mut().extend(std::iter::repeat_n(false, len));
333            }
334            Column::Str(v, val) => {
335                *val = na_validity(val);
336                v.extend((0..len).map(|_| ""));
337            }
338        }
339    }
340
341    /// Scatter `values` into `self` at `positions` — **the single assignment
342    /// primitive** behind every surface (`df.loc / iloc / at / iat = `, Series
343    /// setitem, and boolean-mask assignment; a scalar write passes a length-1
344    /// `values`, which broadcasts). It **keeps `self`'s dtype** and updates its
345    /// validity: each written position takes the source's presence, every other
346    /// position keeps its own. The dtype rules are:
347    /// - a float target absorbs any numeric source (a missing source -> in-band `NaN`);
348    /// - an int target stays int — a present integral value is stored, a missing /
349    ///   `NaN` source marks the cell NA (no float widening, per the NA model), a
350    ///   present non-integral value is a lossy [`VolasError::DType`];
351    /// - a `Bool` / `Str` / `Datetime` target requires a matching-kind source (else
352    ///   a `DType` error); a missing source marks the cell NA (`NaT` for datetime).
353    ///
354    /// `positions` are assumed in bounds (callers validate the mask / index).
355    pub fn scatter(&self, positions: &[usize], values: &Column) -> Result<Column> {
356        let len = self.len();
357        let m = values.len();
358        let pick = |k: usize| if m == 1 { 0 } else { k };
359        // A numeric target accepts only a numeric / bool source. A `Str` source
360        // would funnel through `to_f64_vec` to `NaN` and a `Datetime` source to raw
361        // epoch nanos — both silent corruption — so reject them up front. (The
362        // `Bool` / `Str` / `Datetime` targets are already strict via their
363        // `as_*_vec` helpers, which error on a mismatched-kind source.)
364        if self.dtype().is_numeric() && matches!(values.dtype(), DType::Utf8 | DType::Datetime) {
365            return Err(VolasError::DType(format!(
366                "cannot assign {} values into a {} column",
367                values.dtype(),
368                self.dtype()
369            )));
370        }
371        // The validity-carrying targets (int / bool / str) share one rule: keep
372        // each row's own presence, then stamp every written position with the
373        // source's. (Float and datetime carry missing in-band, so they skip this.)
374        let scatter_validity = |val: &Validity| -> Validity {
375            let mut flags: Vec<bool> = (0..len).map(|i| val.is_valid(i)).collect();
376            for (k, &p) in positions.iter().enumerate() {
377                flags[p] = values.is_valid(pick(k));
378            }
379            Validity::from_valid_iter(len, flags)
380        };
381        match self {
382            Column::F64(v) => {
383                let src = values.to_f64_vec(); // validity-aware: missing -> NaN
384                let mut nv = v.to_vec();
385                for (k, &p) in positions.iter().enumerate() {
386                    nv[p] = src[pick(k)];
387                }
388                Ok(Column::f64(nv))
389            }
390            Column::F32(v) => {
391                let src = values.to_f32_vec();
392                let mut nv = v.to_vec();
393                for (k, &p) in positions.iter().enumerate() {
394                    nv[p] = src[pick(k)];
395                }
396                Ok(Column::f32(nv))
397            }
398            // Int targets keep their dtype: `as_i*_vec` yields a 0 placeholder for a
399            // missing/`NaN` source (the presence is restored by `scatter_validity`)
400            // and errors on a present non-integral value — exactly the Series rule.
401            Column::I64(v, val) => {
402                let src = values.as_i64_vec()?;
403                let mut nv = v.to_vec();
404                for (k, &p) in positions.iter().enumerate() {
405                    nv[p] = src[pick(k)];
406                }
407                Ok(Column::i64_with(nv, scatter_validity(val)))
408            }
409            Column::I32(v, val) => {
410                let src = values.as_i32_vec()?;
411                let mut nv = v.to_vec();
412                for (k, &p) in positions.iter().enumerate() {
413                    nv[p] = src[pick(k)];
414                }
415                Ok(Column::i32_with(nv, scatter_validity(val)))
416            }
417            Column::Bool(v, val) => {
418                let src = values.as_bool_vec()?;
419                let mut nv = v.to_vec();
420                for (k, &p) in positions.iter().enumerate() {
421                    nv[p] = src[pick(k)];
422                }
423                Ok(Column::bool_with(nv, scatter_validity(val)))
424            }
425            Column::Str(v, val) => {
426                let src = values.as_str_vec()?;
427                let mut nv = v.to_vec();
428                for (k, &p) in positions.iter().enumerate() {
429                    nv[p] = src[pick(k)].clone();
430                }
431                Ok(Column::str_with(nv, scatter_validity(val)))
432            }
433            Column::Datetime(v) => {
434                let src = values.as_datetime_vec()?; // `NaT` (i64::MIN) is in-band missing
435                let mut nv = v.to_vec();
436                for (k, &p) in positions.iter().enumerate() {
437                    nv[p] = src[pick(k)];
438                }
439                Ok(Column::datetime(nv))
440            }
441        }
442    }
443
444    // --- dtype-preserving numeric transforms (pandas 3.0) ---------------------
445    // Each dispatches the kernel over the column's element type so an int column
446    // stays int and computes natively (no f64 round-trip). A non-numeric column
447    // is a `DType` error.
448
449    /// Value equality where `NaN == NaN` (pandas `equals` semantics), unlike the
450    /// derived `PartialEq` (which uses IEEE `NaN != NaN`).
451    pub fn equals(&self, other: &Column) -> bool {
452        match (self, other) {
453            (Column::F64(a), Column::F64(b)) => {
454                a.len() == b.len()
455                    && a.iter()
456                        .zip(b.iter())
457                        .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
458            }
459            (Column::F32(a), Column::F32(b)) => {
460                a.len() == b.len()
461                    && a.iter()
462                        .zip(b.iter())
463                        .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
464            }
465            _ => self == other,
466        }
467    }
468}