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}