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