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