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}