Skip to main content

chronix_query/
aggregate.rs

1//! Aggregation functions for query results.
2//!
3//! Implements core aggregation operations on Arrow arrays:
4//! `count`, `sum`, `min`, `max`, `avg`, `first`, `last`.
5//!
6//! Supports group-by aggregation partitioned by tag columns.
7//!
8//! # Memory model
9//!
10//! [`StreamingAggregator`] is the form to reach for over a lazy scan: batches
11//! are pushed in and dropped, and the state held is one accumulator set per
12//! group. Peak memory is therefore `O(groups × fields)` and independent of
13//! how many rows were aggregated — a `SELECT avg(x)` over a week of 1 s data
14//! produces its single row without ever holding the week.
15//!
16//! The whole-slice entry points ([`aggregate_batch`], [`aggregate_grouped`],
17//! [`aggregate_grouped_streaming`]) are conveniences for callers that already
18//! hold their input.
19//!
20//! The *output* is still a single `RecordBatch`, so a group-by over a very
21//! high-cardinality column is bounded by the group count rather than the row
22//! count. Pass a [`MemoryTracker`](crate::memory::MemoryTracker) to fail early
23//! rather than OOM in that case.
24
25use std::sync::Arc;
26
27use arrow::array::{
28    Array, ArrayRef, Decimal128Array, Float64Array, Int64Array, StringArray, UInt64Array,
29};
30use arrow::compute::kernels::aggregate as arrow_agg;
31use arrow::datatypes::{DataType, Field, Schema};
32use arrow::record_batch::RecordBatch;
33
34use crate::error::{QueryError, Result};
35
36/// Aggregation function type.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum AggFn {
39    /// Count of non-null values.
40    Count,
41    /// Sum of values.
42    Sum,
43    /// Minimum value.
44    Min,
45    /// Maximum value.
46    Max,
47    /// Average (mean) value.
48    Avg,
49    /// First value — the value at the minimum timestamp.
50    ///
51    /// When a timestamp column is available (the normal case), this scans
52    /// for the row with the smallest timestamp. The data need not be sorted.
53    /// Only falls back to array position if no timestamp column is present.
54    ///
55    /// # Edge cases
56    ///
57    /// - **Empty bucket / all nulls**: returns `None` (NULL in SQL).
58    /// - **Ties** (multiple rows with the same minimum timestamp): the
59    ///   first row encountered during the linear scan wins — i.e.
60    ///   the result is deterministic for a given input order but
61    ///   **not** stable across different physical orderings.
62    /// - **Null timestamp**: the row is skipped; only non-null
63    ///   timestamps participate in the min/max search.
64    First,
65    /// Last value — the value at the maximum timestamp.
66    ///
67    /// When a timestamp column is available (the normal case), this scans
68    /// for the row with the largest timestamp. The data need not be sorted.
69    /// Only falls back to array position if no timestamp column is present.
70    ///
71    /// # Edge cases
72    ///
73    /// - **Empty bucket / all nulls**: returns `None` (NULL in SQL).
74    /// - **Ties** (multiple rows with the same maximum timestamp): the
75    ///   last row encountered during the linear scan wins — i.e.
76    ///   the result is deterministic for a given input order but
77    ///   **not** stable across different physical orderings.
78    /// - **Null timestamp**: the row is skipped; only non-null
79    ///   timestamps participate in the min/max search.
80    Last,
81}
82
83/// Extra fractional digits an average of a decimal column is computed to.
84///
85/// A mean is a division, and a division of exact decimals does not
86/// generally terminate — `1/3` has no finite decimal form at any scale. So
87/// `avg` is the one aggregate here that rounds, and the rule is stated
88/// rather than left to a `double`: the result is carried to the column's
89/// scale plus six digits and rounded half away from zero, the way
90/// PostgreSQL's `NUMERIC` division extends its scale. Six digits is a
91/// millionth of the column's last digit; `sum` and `count` are both exact,
92/// so a caller who needs a different rounding can divide them itself.
93pub const AVG_EXTRA_SCALE: u8 = 6;
94
95/// One aggregate's result, in the type that keeps it exact.
96///
97/// Every column type but decimal reduces to an `f64`, which is what it
98/// already was. A decimal column stays a decimal all the way to the output
99/// array: `sum`, `min`, `max`, `first` and `last` over exact values are
100/// themselves exact, and turning them into a `double` on the way out would
101/// undo the whole point of the column.
102// No longer `Copy`: a merged histogram owns its buckets. Every call site
103// that relied on the implicit copy now clones explicitly, which is the
104// honest cost of a variant that carries a distribution.
105#[derive(Debug, Clone, PartialEq)]
106pub enum AggResult {
107    /// SQL `NULL` — an empty bucket, or a result that overflowed.
108    Null,
109    /// A floating-point result.
110    F64(f64),
111    /// An exact decimal result: `mantissa × 10⁻ˢᶜᵃˡᵉ`.
112    Decimal {
113        /// The unscaled result.
114        mantissa: i128,
115        /// Digits after the decimal point.
116        scale: u8,
117    },
118    /// A merged native histogram.
119    ///
120    /// The aggregate of a distribution is a distribution, not a number —
121    /// which is why this is a variant rather than something `as_f64` can
122    /// answer. `sum` merges, `avg` merges and scales; `min` and `max` are
123    /// undefined over distributions and are refused rather than guessed.
124    Histogram(Box<chronix_core::histogram::Histogram>),
125}
126
127impl AggResult {
128    /// The result as an `f64`, for the callers that only deal in floats.
129    ///
130    /// Lossy for a decimal, by definition — see
131    /// [`extract_f64`](crate::extract_f64) for where that is and is not the
132    /// right thing to do.
133    #[must_use]
134    #[allow(clippy::cast_precision_loss)]
135    pub fn as_f64(self) -> Option<f64> {
136        match self {
137            Self::Null => None,
138            Self::F64(v) => Some(v),
139            Self::Decimal { mantissa, scale } => {
140                Some(mantissa as f64 / chronix_core::pow10(u32::from(scale)).unwrap_or(1) as f64)
141            }
142            // A distribution has no `f64` it *is*. Collapsing it to its sum
143            // or its mean here would hand every caller that forgot to ask a
144            // number that looks like an answer.
145            Self::Histogram(_) => None,
146        }
147    }
148}
149
150/// Build one output column from a column of results.
151///
152/// Public because the rollup engine builds the same columns from the same
153/// accumulator, and two functions that must agree on an output type are one
154/// function.
155///
156/// The Arrow type follows the results: a decimal aggregate produces a
157/// `Decimal128` column, everything else a `Float64` one. A run of results
158/// is homogeneous by construction — they come from one accumulator over one
159/// input column — so the first non-null one decides.
160pub fn agg_result_column(name: &str, results: &[AggResult]) -> Result<(Field, ArrayRef)> {
161    // A histogram aggregate produces a histogram column, encoded the way the
162    // storage layer encodes one — so a rollup tier's target holds the same
163    // thing its source did, rather than a number derived from it.
164    if results.iter().any(|r| matches!(r, AggResult::Histogram(_))) {
165        let blobs: Vec<Option<Vec<u8>>> = results
166            .iter()
167            .map(|r| match r {
168                AggResult::Histogram(h) => postcard::to_allocvec(h.as_ref()).ok(),
169                _ => None,
170            })
171            .collect();
172        let refs: Vec<Option<&[u8]>> = blobs.iter().map(|b| b.as_deref()).collect();
173        return Ok((
174            Field::new(name, DataType::Binary, true),
175            Arc::new(arrow::array::BinaryArray::from(refs)),
176        ));
177    }
178    let scale = results.iter().find_map(|r| match r {
179        AggResult::Decimal { scale, .. } => Some(*scale),
180        _ => None,
181    });
182    match scale {
183        Some(scale) => {
184            let scale_i8 = i8::try_from(scale).map_err(|_| {
185                QueryError::Validation(format!("decimal scale {scale} is out of range"))
186            })?;
187            let mantissas: Vec<Option<i128>> = results
188                .iter()
189                .map(|r| match r {
190                    AggResult::Decimal { mantissa, .. } => Some(*mantissa),
191                    _ => None,
192                })
193                .collect();
194            let array = Decimal128Array::from(mantissas)
195                .with_precision_and_scale(chronix_core::DECIMAL_PRECISION, scale_i8)?;
196            let field = Field::new(
197                name,
198                DataType::Decimal128(chronix_core::DECIMAL_PRECISION, scale_i8),
199                true,
200            );
201            Ok((field, Arc::new(array)))
202        }
203        None => {
204            let values: Vec<Option<f64>> = results.iter().map(|r| r.clone().as_f64()).collect();
205            Ok((
206                Field::new(name, DataType::Float64, true),
207                Arc::new(Float64Array::from(values)),
208            ))
209        }
210    }
211}
212
213/// A value pulled out of a column, in the type that keeps it exact.
214#[derive(Clone, Copy, Debug)]
215pub enum Num {
216    /// Anything that was already a float, or was widened to one.
217    F64(f64),
218    /// A decimal mantissa together with its scale.
219    Dec(i128, u8),
220}
221
222/// The exact half of an accumulator, allocated only for decimal columns.
223///
224/// Boxed behind an `Option` so a float column pays eight bytes for it and
225/// nothing else — a group-by over a high-cardinality tag holds one
226/// accumulator per (group, field), and making every one of them 128 bytes
227/// wider to support a column type it does not have is a cost the gateway
228/// would feel.
229#[derive(Debug, Clone)]
230pub struct DecimalTrack {
231    /// The scale everything tracked here is expressed at.
232    pub scale: u8,
233    /// Running total.
234    pub sum: i128,
235    /// Smallest mantissa seen.
236    pub min: i128,
237    /// Largest mantissa seen.
238    pub max: i128,
239    /// Mantissa at the earliest timestamp.
240    pub first: i128,
241    /// Mantissa at the latest timestamp.
242    pub last: i128,
243    /// A sum or a rescale needed more than 38 significant digits. The
244    /// affected results are `NULL`: a number that is not the answer is
245    /// worse than no number.
246    pub overflow: bool,
247}
248
249impl DecimalTrack {
250    /// An empty track at `scale`.
251    #[must_use]
252    pub fn new(scale: u8) -> Self {
253        Self {
254            scale,
255            sum: 0,
256            min: i128::MAX,
257            max: i128::MIN,
258            first: 0,
259            last: 0,
260            overflow: false,
261        }
262    }
263
264    /// Re-express everything tracked so far at a wider scale.
265    pub fn widen_to(&mut self, scale: u8) {
266        let Some(factor) = scale
267            .checked_sub(self.scale)
268            .and_then(|d| chronix_core::pow10(u32::from(d)))
269        else {
270            self.overflow = true;
271            return;
272        };
273        let mut scaled = |v: i128| match v.checked_mul(factor) {
274            Some(x) => x,
275            None => {
276                self.overflow = true;
277                v
278            }
279        };
280        self.sum = scaled(self.sum);
281        // The sentinels stay sentinels: scaling `i128::MAX` overflows, and
282        // it does not stand for a value anyway.
283        if self.min != i128::MAX {
284            self.min = scaled(self.min);
285        }
286        if self.max != i128::MIN {
287            self.max = scaled(self.max);
288        }
289        self.first = scaled(self.first);
290        self.last = scaled(self.last);
291        self.scale = scale;
292    }
293
294    /// Bring an incoming mantissa to the track's scale, widening the track
295    /// if the incoming value is finer. Both directions are exact.
296    ///
297    /// Returns `None` when the alignment would overflow 38 digits; the
298    /// caller sets [`overflow`](Self::overflow).
299    pub fn align(&mut self, mantissa: i128, scale: u8) -> Option<i128> {
300        match scale.cmp(&self.scale) {
301            std::cmp::Ordering::Equal => Some(mantissa),
302            std::cmp::Ordering::Greater => {
303                self.widen_to(scale);
304                Some(mantissa)
305            }
306            std::cmp::Ordering::Less => chronix_core::pow10(u32::from(self.scale - scale))
307                .and_then(|f| mantissa.checked_mul(f)),
308        }
309    }
310
311    /// The sum, or `Null` if it overflowed 38 digits.
312    #[must_use]
313    pub fn sum_result(&self) -> AggResult {
314        if self.overflow || chronix_core::Decimal::new(self.sum, self.scale).is_err() {
315            return AggResult::Null;
316        }
317        AggResult::Decimal {
318            mantissa: self.sum,
319            scale: self.scale,
320        }
321    }
322
323    /// The mean, carried to [`AVG_EXTRA_SCALE`] extra digits and rounded
324    /// half away from zero. See that constant for why this is the one
325    /// aggregate that rounds.
326    #[must_use]
327    pub fn avg_result(&self, count: u64) -> AggResult {
328        if self.overflow || count == 0 {
329            return AggResult::Null;
330        }
331        let target = self
332            .scale
333            .saturating_add(AVG_EXTRA_SCALE)
334            .min(chronix_core::MAX_DECIMAL_SCALE);
335        let extra = target - self.scale;
336        let Some(numerator) =
337            chronix_core::pow10(u32::from(extra)).and_then(|factor| self.sum.checked_mul(factor))
338        else {
339            return AggResult::Null;
340        };
341        let divisor = i128::from(count);
342        let quotient = numerator / divisor;
343        let remainder = numerator % divisor;
344        // Half away from zero: `|r| * 2 >= divisor` rounds up in magnitude.
345        let rounded = match remainder.unsigned_abs().checked_mul(2) {
346            Some(twice) if twice >= divisor.unsigned_abs() => {
347                quotient + if numerator < 0 { -1 } else { 1 }
348            }
349            _ => quotient,
350        };
351        if chronix_core::Decimal::new(rounded, target).is_err() {
352            return AggResult::Null;
353        }
354        AggResult::Decimal {
355            mantissa: rounded,
356            scale: target,
357        }
358    }
359
360    /// One tracked mantissa as a result.
361    #[must_use]
362    pub fn at(&self, mantissa: i128) -> AggResult {
363        AggResult::Decimal {
364            mantissa,
365            scale: self.scale,
366        }
367    }
368}
369
370/// Generate a typed aggregation function that applies `AggFn` to an Arrow
371/// primitive array and returns the result as `Option<f64>`.
372///
373/// For `Float64Array` values are used directly; for integer types they are
374/// cast to `f64` via the `$conv` expression.
375macro_rules! impl_aggregate {
376    ($name:ident, $arr_ty:ty, $conv:expr) => {
377        #[allow(clippy::cast_precision_loss)]
378        fn $name(values: &$arr_ty, function: AggFn) -> Option<f64> {
379            // COUNT on empty input returns 0 (SQL semantics), not NULL.
380            if values.is_empty() {
381                return if function == AggFn::Count {
382                    Some(0.0)
383                } else {
384                    None
385                };
386            }
387            match function {
388                // O(1) non-null count via Arrow metadata instead
389                // of iterating all values.
390                AggFn::Count => Some((values.len() - values.null_count()) as f64),
391                AggFn::Sum => arrow_agg::sum(values).map($conv),
392                AggFn::Min => arrow_agg::min(values).map($conv),
393                AggFn::Max => arrow_agg::max(values).map($conv),
394                AggFn::Avg => {
395                    let sum = arrow_agg::sum(values).map($conv)?;
396                    let count = values.len() - values.null_count();
397                    if count == 0 {
398                        None
399                    } else {
400                        Some(sum / count as f64)
401                    }
402                }
403                AggFn::First => values.iter().flatten().next().map($conv),
404                AggFn::Last => values.iter().flatten().last().map($conv),
405            }
406        }
407    };
408}
409
410impl_aggregate!(aggregate_f64, Float64Array, |v| v);
411impl_aggregate!(aggregate_i64, Int64Array, |v| v as f64);
412impl_aggregate!(aggregate_u64, UInt64Array, |v| v as f64);
413
414/// Apply aggregation to a `RecordBatch` without grouping.
415///
416/// Returns a single-row `RecordBatch` with one column per field per function.
417///
418/// # Errors
419///
420/// Returns an error if the batch is empty or columns have unexpected types.
421pub fn aggregate_batch(
422    batch: &RecordBatch,
423    functions: &[AggFn],
424    field_columns: &[&str],
425    memory_tracker: Option<&crate::memory::MemoryTracker>,
426) -> Result<RecordBatch> {
427    let mut result_fields = Vec::new();
428    let mut result_columns: Vec<ArrayRef> = Vec::new();
429
430    // Extract timestamp column for timestamp-aware First/Last.
431    let ts_col = batch
432        .column_by_name(chronix_core::TIME_COLUMN)
433        .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
434
435    for &field_name in field_columns {
436        let col = batch
437            .column_by_name(field_name)
438            .ok_or_else(|| QueryError::Validation(format!("column '{field_name}' not found")))?;
439
440        for func in functions {
441            let func_name = format!("{field_name}_{}", agg_fn_name(*func));
442            let value = aggregate_column_ts(col, *func, ts_col);
443            let (field, array) = agg_result_column(&func_name, &[value])?;
444            result_fields.push(field);
445            result_columns.push(array);
446        }
447    }
448
449    if result_fields.is_empty() {
450        return Err(QueryError::Validation("no columns to aggregate".into()));
451    }
452
453    let schema = Arc::new(Schema::new(result_fields));
454    let batch = RecordBatch::try_new(schema, result_columns)?;
455    if let Some(tracker) = memory_tracker {
456        tracker.try_allocate(batch.get_array_memory_size())?;
457    }
458    Ok(batch)
459}
460
461/// Apply aggregation with group-by support.
462///
463/// Groups rows by the specified tag columns, then applies each aggregation
464/// function to each field column within each group.
465///
466/// # Vectorized design
467///
468/// Uses a single-pass incremental accumulator approach instead of
469/// per-group `compute::take()` + scalar aggregation. Each row is
470/// processed exactly once with zero intermediate array allocations:
471///
472/// 1. Hash group keys (FNV-1a, zero-copy)
473/// 2. Look up or create `IncrementalAccumulator` per (group, field)
474/// 3. Update accumulator with the row's value and timestamp
475/// 4. Finalize all accumulators into result arrays
476///
477/// This eliminates O(N_groups × N_fields) `compute::take()` allocations
478/// and replaces them with O(1) accumulator updates per row. One
479/// accumulator per (group, field) supports all `AggFn` variants
480/// simultaneously.
481///
482/// # Errors
483///
484/// Returns an error if columns are not found.
485pub fn aggregate_grouped(
486    batch: &RecordBatch,
487    functions: &[AggFn],
488    field_columns: &[&str],
489    group_by: &[&str],
490    memory_tracker: Option<&crate::memory::MemoryTracker>,
491) -> Result<RecordBatch> {
492    if group_by.is_empty() {
493        return aggregate_batch(batch, functions, field_columns, memory_tracker);
494    }
495
496    // Build group keys by combining group-by column values
497    let group_cols: Vec<&StringArray> = group_by
498        .iter()
499        .map(|name| {
500            batch
501                .column_by_name(name)
502                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
503                .ok_or_else(|| {
504                    QueryError::Validation(format!(
505                        "group-by column '{name}' not found or not string"
506                    ))
507                })
508        })
509        .collect::<Result<Vec<_>>>()?;
510
511    // Pre-resolve field columns and extract typed arrays ONCE.
512    let field_cols: Vec<&dyn Array> = field_columns
513        .iter()
514        .map(|name| {
515            batch
516                .column_by_name(name)
517                .map(std::convert::AsRef::as_ref)
518                .ok_or_else(|| QueryError::Validation(format!("column '{name}' not found")))
519        })
520        .collect::<Result<Vec<_>>>()?;
521
522    // Pre-downcast to eliminate per-row dynamic dispatch.
523    let typed_cols: Vec<TypedColumn<'_>> = field_cols
524        .iter()
525        .map(|c| TypedColumn::from_array(*c))
526        .collect();
527
528    let ts_col = batch
529        .column_by_name(chronix_core::TIME_COLUMN)
530        .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
531
532    // One accumulator per (group, field_column) — a single
533    // IncrementalAccumulator tracks count/sum/min/max/first/last
534    // simultaneously, so we never need separate accumulators per function.
535    let num_fields = field_columns.len();
536
537    // Per-group state: key + one accumulator per field column.
538    let mut groups: Vec<(Vec<Option<&str>>, Vec<IncrementalAccumulator>)> = Vec::new();
539    let mut group_index: std::collections::HashMap<u64, Vec<usize>> =
540        std::collections::HashMap::new();
541
542    for row in 0..batch.num_rows() {
543        let ts: Option<i64> = ts_col.and_then(|t| {
544            if t.is_null(row) {
545                None
546            } else {
547                Some(t.value(row))
548            }
549        });
550
551        // Hash the group key without allocating Strings
552        let mut hasher = fnv::FnvHasher::default();
553        for col in &group_cols {
554            if col.is_null(row) {
555                std::hash::Hash::hash(&0u8, &mut hasher);
556            } else {
557                std::hash::Hash::hash(&1u8, &mut hasher);
558                std::hash::Hash::hash(col.value(row), &mut hasher);
559            }
560        }
561        let hash = std::hash::Hasher::finish(&hasher);
562
563        // Build a zero-copy key for collision checking
564        let key: Vec<Option<&str>> = group_cols
565            .iter()
566            .map(|col| {
567                if col.is_null(row) {
568                    None
569                } else {
570                    Some(col.value(row))
571                }
572            })
573            .collect();
574
575        // Find or create the group
576        let bucket = group_index.entry(hash).or_default();
577        // Cap chain length to prevent hash-collision DoS.
578        if bucket.len() >= 1024 {
579            return Err(QueryError::Validation(
580                "group-by hash chain length exceeded limit (possible collision attack)".into(),
581            ));
582        }
583        let gidx = {
584            let mut found = None;
585            for &idx in bucket.iter() {
586                if groups[idx].0 == key {
587                    found = Some(idx);
588                    break;
589                }
590            }
591            match found {
592                Some(idx) => idx,
593                None => {
594                    // Check memory budget before adding a new group.
595                    if let Some(tracker) = memory_tracker {
596                        let estimated_group_bytes = 24 + group_by.len() * 16  // key Vec header + elements
597                            + num_fields * std::mem::size_of::<IncrementalAccumulator>()
598                            + 64; // HashMap entry overhead
599                        tracker.try_allocate(estimated_group_bytes)?;
600                    }
601                    let idx = groups.len();
602                    let accs = (0..num_fields)
603                        .map(|_| IncrementalAccumulator::new())
604                        .collect();
605                    groups.push((key, accs));
606                    bucket.push(idx);
607                    idx
608                }
609            }
610        };
611
612        // Update one accumulator per field — single pass, no
613        // intermediate array allocations. TypedColumn pre-downcast eliminates
614        // per-row dynamic dispatch.
615        for (fi, typed) in typed_cols.iter().enumerate() {
616            if let Some(v) = typed.value_num(row) {
617                groups[gidx].1[fi].update(v, ts);
618            }
619        }
620    }
621
622    // Build result schema
623    let mut result_fields: Vec<Field> = group_by
624        .iter()
625        .map(|name| Field::new(*name, DataType::Utf8, true))
626        .collect();
627
628    // Build result arrays
629    let num_groups = groups.len();
630    let mut result_columns: Vec<ArrayRef> = Vec::new();
631
632    // Group-by value columns.
633    for col_idx in 0..group_by.len() {
634        let vals: Vec<Option<&str>> = groups.iter().map(|(key, _)| key[col_idx]).collect();
635        let arr: StringArray = vals.into_iter().collect();
636        result_columns.push(Arc::new(arr));
637    }
638
639    // Finalize accumulators — each IncrementalAccumulator
640    // produces results for any requested AggFn in O(1). The output column's
641    // type follows the results, so an aggregate over a decimal column stays
642    // a decimal.
643    for (fi, &field_name) in field_columns.iter().enumerate() {
644        for func in functions {
645            let func_name = format!("{field_name}_{}", agg_fn_name(*func));
646            let vals: Vec<AggResult> = (0..num_groups)
647                .map(|gi| groups[gi].1[fi].finalize(*func))
648                .collect();
649            let (field, array) = agg_result_column(&func_name, &vals)?;
650            result_fields.push(field);
651            result_columns.push(array);
652        }
653    }
654    debug_assert_eq!(num_fields, field_columns.len());
655
656    let schema = Arc::new(Schema::new(result_fields));
657    let batch = RecordBatch::try_new(schema, result_columns)?;
658    if let Some(tracker) = memory_tracker {
659        tracker.try_allocate(batch.get_array_memory_size())?;
660    }
661    Ok(batch)
662}
663
664/// Aggregate a single column using the given function.
665///
666/// Timestamp-aware column aggregation.
667#[allow(clippy::cast_precision_loss)]
668fn aggregate_column_ts(col: &dyn Array, func: AggFn, timestamps: Option<&Int64Array>) -> AggResult {
669    // For First/Last with a timestamp column, find the index of the
670    // min/max timestamp and return the value at that index.
671    if let Some(ts) = timestamps
672        && matches!(func, AggFn::First | AggFn::Last)
673    {
674        return first_last_by_ts(col, ts, func);
675    }
676
677    // A decimal column is folded exactly, through the same accumulator the
678    // streaming path uses, rather than through the `f64` kernels.
679    if let TypedColumn::Decimal(a, scale) = TypedColumn::from_array(col) {
680        // With no timestamp column, First and Last mean array position —
681        // the same fallback the `f64` path takes. The accumulator cannot
682        // answer them here because it is fed no timestamps, and asking it
683        // anyway returned its zero-initialised slot rather than a value.
684        if matches!(func, AggFn::First | AggFn::Last) {
685            let mut present = (0..a.len()).filter(|&i| !a.is_null(i));
686            let idx = if func == AggFn::First {
687                present.next()
688            } else {
689                present.next_back()
690            };
691            return idx.map_or(AggResult::Null, |i| AggResult::Decimal {
692                mantissa: a.value(i),
693                scale,
694            });
695        }
696        let mut acc = IncrementalAccumulator::new();
697        for i in 0..a.len() {
698            if !a.is_null(i) {
699                acc.update(Num::Dec(a.value(i), scale), None);
700            }
701        }
702        return acc.finalize(func);
703    }
704
705    let value = if let Some(f64_col) = col.as_any().downcast_ref::<Float64Array>() {
706        aggregate_f64(f64_col, func)
707    } else if let Some(i64_col) = col.as_any().downcast_ref::<Int64Array>() {
708        aggregate_i64(i64_col, func)
709    } else if let Some(u64_col) = col.as_any().downcast_ref::<UInt64Array>() {
710        aggregate_u64(u64_col, func)
711    } else {
712        match func {
713            AggFn::Count => Some((col.len() - col.null_count()) as f64),
714            _ => None,
715        }
716    };
717    value.map_or(AggResult::Null, AggResult::F64)
718}
719
720/// Return the value at the row with the minimum (First) or maximum (Last)
721/// timestamp.  Works for any numeric column type.
722#[allow(clippy::cast_precision_loss)]
723fn first_last_by_ts(col: &dyn Array, ts: &Int64Array, func: AggFn) -> AggResult {
724    let find_idx = |cmp: fn(i64, i64) -> bool| -> Option<usize> {
725        let mut best_idx: Option<usize> = None;
726        let mut best_ts: i64 = 0;
727        for i in 0..ts.len() {
728            if ts.is_null(i) || col.is_null(i) {
729                continue;
730            }
731            let t = ts.value(i);
732            if best_idx.is_none() || cmp(t, best_ts) {
733                best_idx = Some(i);
734                best_ts = t;
735            }
736        }
737        best_idx
738    };
739
740    let idx = match func {
741        AggFn::First => find_idx(|t, best| t < best),
742        // Use `>=` so that on ties, the last row encountered wins.
743        // This makes Last deterministic: for a given input order, the
744        // latest-inserted row at the max timestamp is always selected.
745        AggFn::Last => find_idx(|t, best| t >= best),
746        _ => return AggResult::Null,
747    };
748    let Some(idx) = idx else {
749        return AggResult::Null;
750    };
751
752    // Extract the value at the selected index.
753    match TypedColumn::from_array(col).value_num(idx) {
754        Some(Num::F64(v)) => AggResult::F64(v),
755        Some(Num::Dec(mantissa, scale)) => AggResult::Decimal { mantissa, scale },
756        None => AggResult::Null,
757    }
758}
759
760// take_indices() removed — grouped aggregation now uses
761// IncrementalAccumulator per (group, field) in a single pass,
762// eliminating per-group compute::take() allocations.
763
764/// Human-readable function name for column naming.
765#[must_use]
766pub fn agg_fn_name(func: AggFn) -> &'static str {
767    match func {
768        AggFn::Count => "count",
769        AggFn::Sum => "sum",
770        AggFn::Min => "min",
771        AggFn::Max => "max",
772        AggFn::Avg => "avg",
773        AggFn::First => "first",
774        AggFn::Last => "last",
775    }
776}
777
778// ── Streaming (incremental) aggregation ─────────────────────────
779
780/// Pre-downcasted column reference — eliminates per-row `as_any().downcast_ref()`
781/// calls in the inner loop. The downcast is performed once per column.
782#[derive(Clone, Copy)]
783enum TypedColumn<'a> {
784    Float64(&'a Float64Array),
785    Int64(&'a Int64Array),
786    UInt64(&'a UInt64Array),
787    /// An exact decimal column, with the scale off its Arrow type.
788    Decimal(&'a Decimal128Array, u8),
789    Other,
790}
791
792impl<'a> TypedColumn<'a> {
793    fn from_array(arr: &'a dyn Array) -> Self {
794        if let Some(a) = arr.as_any().downcast_ref::<Float64Array>() {
795            Self::Float64(a)
796        } else if let Some(a) = arr.as_any().downcast_ref::<Int64Array>() {
797            Self::Int64(a)
798        } else if let Some(a) = arr.as_any().downcast_ref::<UInt64Array>() {
799            Self::UInt64(a)
800        } else if let Some(a) = arr.as_any().downcast_ref::<Decimal128Array>() {
801            // A negative scale is legal Arrow and meaningless here; it reads
802            // as 0 rather than aborting the whole aggregate.
803            Self::Decimal(a, u8::try_from(a.scale()).unwrap_or(0))
804        } else {
805            Self::Other
806        }
807    }
808
809    /// The value at `row`, in the type that keeps it exact.
810    #[allow(clippy::cast_precision_loss)]
811    fn value_num(&self, row: usize) -> Option<Num> {
812        match self {
813            Self::Decimal(a, scale) => {
814                if a.is_null(row) {
815                    None
816                } else {
817                    Some(Num::Dec(a.value(row), *scale))
818                }
819            }
820            _ => self.value_f64(row).map(Num::F64),
821        }
822    }
823
824    #[allow(clippy::cast_precision_loss)]
825    fn value_f64(&self, row: usize) -> Option<f64> {
826        match self {
827            Self::Float64(a) => {
828                if a.is_null(row) {
829                    None
830                } else {
831                    Some(a.value(row))
832                }
833            }
834            Self::Int64(a) => {
835                if a.is_null(row) {
836                    None
837                } else {
838                    Some(a.value(row) as f64)
839                }
840            }
841            Self::UInt64(a) => {
842                if a.is_null(row) {
843                    None
844                } else {
845                    Some(a.value(row) as f64)
846                }
847            }
848            // A decimal reaches an `f64` only through `value_num`, which
849            // does not lose anything; this is the fallback for the callers
850            // that genuinely want a float.
851            Self::Decimal(a, scale) => {
852                if a.is_null(row) {
853                    None
854                } else {
855                    Some(
856                        a.value(row) as f64
857                            / chronix_core::pow10(u32::from(*scale)).unwrap_or(1) as f64,
858                    )
859                }
860            }
861            Self::Other => None,
862        }
863    }
864}
865
866/// Per-field incremental accumulator that supports all `AggFn` operations
867/// without materializing the input.
868#[derive(Debug, Clone)]
869struct IncrementalAccumulator {
870    count: u64,
871    sum: f64,
872    min: f64,
873    max: f64,
874    /// Value at the minimum timestamp (First).
875    first_ts: i64,
876    first_val: f64,
877    first_set: bool,
878    /// Value at the maximum timestamp (Last).
879    last_ts: i64,
880    last_val: f64,
881    last_set: bool,
882    /// The exact track, present only once a decimal value has been seen.
883    decimal: Option<Box<DecimalTrack>>,
884}
885
886impl IncrementalAccumulator {
887    fn new() -> Self {
888        Self {
889            count: 0,
890            sum: 0.0,
891            min: f64::INFINITY,
892            max: f64::NEG_INFINITY,
893            first_ts: i64::MAX,
894            first_val: 0.0,
895            first_set: false,
896            last_ts: i64::MIN,
897            last_val: 0.0,
898            last_set: false,
899            decimal: None,
900        }
901    }
902
903    #[allow(clippy::cast_precision_loss)]
904    fn update(&mut self, value: Num, timestamp: Option<i64>) {
905        self.count += 1;
906        match value {
907            Num::F64(v) => {
908                self.sum += v;
909                if v < self.min {
910                    self.min = v;
911                }
912                if v > self.max {
913                    self.max = v;
914                }
915            }
916            Num::Dec(mantissa, scale) => {
917                let track = self
918                    .decimal
919                    .get_or_insert_with(|| Box::new(DecimalTrack::new(scale)));
920                match track.align(mantissa, scale) {
921                    Some(m) => {
922                        match track.sum.checked_add(m) {
923                            Some(sum) => track.sum = sum,
924                            None => track.overflow = true,
925                        }
926                        if m < track.min {
927                            track.min = m;
928                        }
929                        if m > track.max {
930                            track.max = m;
931                        }
932                    }
933                    None => track.overflow = true,
934                }
935            }
936        }
937        // Skip First/Last tracking when timestamp is null (consistent
938        // with the non-grouped `first_last_by_ts` path which also skips
939        // null-timestamp rows).
940        if let Some(ts) = timestamp {
941            let first = ts < self.first_ts || !self.first_set;
942            // Use `>=` so that on ties, the last row encountered wins
943            // (consistent with the non-grouped `first_last_by_ts` path).
944            let last = ts >= self.last_ts || !self.last_set;
945            if first {
946                self.first_ts = ts;
947                self.first_set = true;
948            }
949            if last {
950                self.last_ts = ts;
951                self.last_set = true;
952            }
953            match value {
954                Num::F64(v) => {
955                    if first {
956                        self.first_val = v;
957                    }
958                    if last {
959                        self.last_val = v;
960                    }
961                }
962                Num::Dec(mantissa, scale) => {
963                    if let Some(track) = self.decimal.as_deref_mut()
964                        && let Some(m) = track.align(mantissa, scale)
965                    {
966                        if first {
967                            track.first = m;
968                        }
969                        if last {
970                            track.last = m;
971                        }
972                    }
973                }
974            }
975        }
976    }
977
978    #[allow(clippy::cast_precision_loss)]
979    fn finalize(&self, func: AggFn) -> AggResult {
980        if self.count == 0 {
981            return if func == AggFn::Count {
982                AggResult::F64(0.0)
983            } else {
984                AggResult::Null
985            };
986        }
987        // A count is a count whatever the column holds, so it stays an
988        // `f64` for every type — it is the one aggregate whose result is
989        // not of the column's own kind.
990        if func == AggFn::Count {
991            return AggResult::F64(self.count as f64);
992        }
993        if let Some(track) = self.decimal.as_deref() {
994            return match func {
995                AggFn::Sum => track.sum_result(),
996                AggFn::Avg => track.avg_result(self.count),
997                AggFn::Min => track.at(track.min),
998                AggFn::Max => track.at(track.max),
999                AggFn::First => track.at(track.first),
1000                AggFn::Last => track.at(track.last),
1001                AggFn::Count => unreachable!("handled above"),
1002            };
1003        }
1004        match func {
1005            AggFn::Sum => AggResult::F64(self.sum),
1006            AggFn::Min => AggResult::F64(self.min),
1007            AggFn::Max => AggResult::F64(self.max),
1008            AggFn::Avg => AggResult::F64(self.sum / self.count as f64),
1009            AggFn::First => AggResult::F64(self.first_val),
1010            AggFn::Last => AggResult::F64(self.last_val),
1011            AggFn::Count => unreachable!("handled above"),
1012        }
1013    }
1014}
1015
1016/// Aggregation strategy chosen based on estimated group-key cardinality.
1017///
1018/// When cardinality metadata is available from segment column
1019/// statistics, the engine selects the strategy that minimises overhead:
1020///
1021/// - **Hash**: O(N) single-pass with FNV-1a hash table. Best for high
1022///   cardinality (many distinct groups) where sort cost dominates.
1023/// - **Sort**: Sort on group-by columns via Arrow `sort_to_indices`, then
1024///   linear scan over contiguous runs. Best for low cardinality where the
1025///   sort is cheap and avoids hash-table memory overhead. Produces sorted
1026///   output — advantageous for downstream ORDER BY.
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028pub enum AggregationStrategy {
1029    /// FNV-1a hash-table grouping (default).
1030    Hash,
1031    /// Sort group-by columns first, then linear-scan contiguous runs.
1032    Sort,
1033}
1034
1035/// Cardinality threshold below which sort-based aggregation is preferred.
1036/// Empirically, sort wins when group count is ≤ ~1024 because the O(N log N)
1037/// sort cost is offset by better cache locality, no hash collisions, and
1038/// sorted output for free.
1039const SORT_CARDINALITY_THRESHOLD: usize = 1024;
1040
1041/// Choose an aggregation strategy based on estimated cardinality.
1042///
1043/// - `None` → Hash (conservative default, universal).
1044/// - `Some(c)` where `c ≤ SORT_CARDINALITY_THRESHOLD` → Sort.
1045/// - `Some(c)` where `c > SORT_CARDINALITY_THRESHOLD` → Hash.
1046#[must_use]
1047pub fn choose_strategy(estimated_cardinality: Option<usize>) -> AggregationStrategy {
1048    match estimated_cardinality {
1049        Some(c) if c <= SORT_CARDINALITY_THRESHOLD => AggregationStrategy::Sort,
1050        _ => AggregationStrategy::Hash,
1051    }
1052}
1053
1054/// Sort-based group-by aggregation.
1055///
1056/// Sorts the batch on group-by columns, then iterates over contiguous runs
1057/// of equal keys, accumulating into `IncrementalAccumulator` per field.
1058/// Produces output in sorted group-key order.
1059///
1060/// Preferred for low-cardinality group-by where sort cost is small and the
1061/// sorted output is often useful for downstream processing.
1062///
1063/// # Errors
1064///
1065/// Returns an error if required columns are missing.
1066pub fn aggregate_sorted(
1067    batch: &RecordBatch,
1068    functions: &[AggFn],
1069    field_columns: &[&str],
1070    group_by: &[&str],
1071    memory_tracker: Option<&crate::memory::MemoryTracker>,
1072) -> Result<RecordBatch> {
1073    use arrow::row::{RowConverter, SortField};
1074
1075    if group_by.is_empty() {
1076        return aggregate_batch(batch, functions, field_columns, memory_tracker);
1077    }
1078
1079    // Build Arrow SortFields for group-by columns.
1080    let group_arrays: Vec<ArrayRef> = group_by
1081        .iter()
1082        .map(|name| {
1083            batch.column_by_name(name).cloned().ok_or_else(|| {
1084                QueryError::Validation(format!("group-by column '{name}' not found"))
1085            })
1086        })
1087        .collect::<Result<Vec<_>>>()?;
1088
1089    let sort_fields: Vec<SortField> = group_arrays
1090        .iter()
1091        .map(|a| SortField::new(a.data_type().clone()))
1092        .collect();
1093
1094    let converter = RowConverter::new(sort_fields)?;
1095    let rows = converter.convert_columns(&group_arrays)?;
1096
1097    // Build sort indices from row-encoded keys.
1098    let mut indices: Vec<u32> = (0..batch.num_rows() as u32).collect();
1099    indices.sort_by(|&a, &b| rows.row(a as usize).cmp(&rows.row(b as usize)));
1100
1101    // Pre-resolve field columns.
1102    let field_cols: Vec<&dyn Array> = field_columns
1103        .iter()
1104        .map(|name| {
1105            batch
1106                .column_by_name(name)
1107                .map(std::convert::AsRef::as_ref)
1108                .ok_or_else(|| QueryError::Validation(format!("column '{name}' not found")))
1109        })
1110        .collect::<Result<Vec<_>>>()?;
1111    let typed_cols: Vec<TypedColumn<'_>> = field_cols
1112        .iter()
1113        .map(|c| TypedColumn::from_array(*c))
1114        .collect();
1115
1116    let ts_col = batch
1117        .column_by_name(chronix_core::TIME_COLUMN)
1118        .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
1119
1120    let group_cols: Vec<&StringArray> = group_by
1121        .iter()
1122        .map(|name| {
1123            batch
1124                .column_by_name(name)
1125                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1126                .ok_or_else(|| {
1127                    QueryError::Validation(format!(
1128                        "group-by column '{name}' not found or not string"
1129                    ))
1130                })
1131        })
1132        .collect::<Result<Vec<_>>>()?;
1133
1134    let num_fields = field_columns.len();
1135    let mut groups: Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)> = Vec::new();
1136
1137    // Linear scan over sorted indices — contiguous equal keys form groups.
1138    let mut run_start = 0usize;
1139    while run_start < indices.len() {
1140        let anchor_row = rows.row(indices[run_start] as usize);
1141        let mut run_end = run_start + 1;
1142        while run_end < indices.len() && rows.row(indices[run_end] as usize) == anchor_row {
1143            run_end += 1;
1144        }
1145
1146        // Extract group key from the first row in the run.
1147        let first_idx = indices[run_start] as usize;
1148        let key: Vec<Option<String>> = group_cols
1149            .iter()
1150            .map(|col| {
1151                if col.is_null(first_idx) {
1152                    None
1153                } else {
1154                    Some(col.value(first_idx).to_owned())
1155                }
1156            })
1157            .collect();
1158
1159        if let Some(tracker) = memory_tracker {
1160            let est = 24
1161                + group_by.len() * 48
1162                + num_fields * std::mem::size_of::<IncrementalAccumulator>()
1163                + 64;
1164            tracker.try_allocate(est)?;
1165        }
1166
1167        let mut accs: Vec<IncrementalAccumulator> = (0..num_fields)
1168            .map(|_| IncrementalAccumulator::new())
1169            .collect();
1170
1171        for &idx in &indices[run_start..run_end] {
1172            let row = idx as usize;
1173            let ts: Option<i64> = ts_col.and_then(|t| {
1174                if t.is_null(row) {
1175                    None
1176                } else {
1177                    Some(t.value(row))
1178                }
1179            });
1180            for (fi, typed) in typed_cols.iter().enumerate() {
1181                if let Some(v) = typed.value_num(row) {
1182                    accs[fi].update(v, ts);
1183                }
1184            }
1185        }
1186
1187        groups.push((key, accs));
1188        run_start = run_end;
1189    }
1190
1191    // Build result schema and arrays.
1192    let mut result_fields: Vec<Field> = group_by
1193        .iter()
1194        .map(|name| Field::new(*name, DataType::Utf8, true))
1195        .collect();
1196    let num_groups = groups.len();
1197    let mut result_columns: Vec<ArrayRef> = Vec::new();
1198
1199    for col_idx in 0..group_by.len() {
1200        let vals: Vec<Option<&str>> = groups
1201            .iter()
1202            .map(|(key, _)| key[col_idx].as_deref())
1203            .collect();
1204        let arr: StringArray = vals.into_iter().collect();
1205        result_columns.push(Arc::new(arr));
1206    }
1207
1208    for (fi, &field_name) in field_columns.iter().enumerate() {
1209        for func in functions {
1210            let func_name = format!("{field_name}_{}", agg_fn_name(*func));
1211            let vals: Vec<AggResult> = (0..num_groups)
1212                .map(|gi| groups[gi].1[fi].finalize(*func))
1213                .collect();
1214            let (field, array) = agg_result_column(&func_name, &vals)?;
1215            result_fields.push(field);
1216            result_columns.push(array);
1217        }
1218    }
1219    debug_assert_eq!(num_fields, field_columns.len());
1220
1221    let schema = Arc::new(Schema::new(result_fields));
1222    let batch = RecordBatch::try_new(schema, result_columns)?;
1223    if let Some(tracker) = memory_tracker {
1224        tracker.try_allocate(batch.get_array_memory_size())?;
1225    }
1226    Ok(batch)
1227}
1228
1229/// Streaming group-by aggregation over multiple `RecordBatch`es.
1230///
1231/// Convenience wrapper over [`StreamingAggregator`] for callers that already
1232/// hold every batch. Prefer pushing batches into the aggregator directly when
1233/// they arrive from a lazy source — that is what keeps peak memory
1234/// proportional to the number of *groups* rather than the number of rows.
1235///
1236/// # Errors
1237///
1238/// Returns an error if required columns are missing or `batches` is empty.
1239pub fn aggregate_grouped_streaming(
1240    batches: &[RecordBatch],
1241    functions: &[AggFn],
1242    field_columns: &[&str],
1243    group_by: &[&str],
1244    memory_tracker: Option<&crate::memory::MemoryTracker>,
1245) -> Result<RecordBatch> {
1246    if batches.is_empty() {
1247        return Err(QueryError::Validation("no batches to aggregate".into()));
1248    }
1249    let mut agg = StreamingAggregator::new(functions, field_columns, group_by);
1250    for batch in batches {
1251        agg.push(batch, memory_tracker)?;
1252    }
1253    agg.finish(memory_tracker)
1254}
1255
1256/// Constant-memory aggregation state that batches are pushed into.
1257///
1258/// Aggregation does not need to see its input all at once: every supported
1259/// function has an incremental form, so the state is `O(groups × fields)` and
1260/// each batch can be dropped as soon as it has been folded in. Holding the
1261/// input instead would make a `SELECT avg(x) FROM m` over a week of 1 s data
1262/// cost gigabytes for a single output row.
1263///
1264/// Rows must carry the group-by and field columns by name; a batch missing a
1265/// column simply contributes nothing for it, which is how schema evolution
1266/// across segments is tolerated.
1267pub struct StreamingAggregator {
1268    functions: Vec<AggFn>,
1269    field_columns: Vec<String>,
1270    group_by: Vec<String>,
1271    state: AggState,
1272}
1273
1274enum AggState {
1275    /// One accumulator set per distinct group key.
1276    Grouped {
1277        groups: Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)>,
1278        /// Group-key hash → candidate indices into `groups` (collision chain).
1279        index: std::collections::HashMap<u64, Vec<usize>>,
1280    },
1281    /// Whole dataset is one group — folded with Arrow kernels per batch.
1282    Ungrouped(UngroupedState),
1283}
1284
1285/// Per-field partial state for the ungrouped fold.
1286struct UngroupedState {
1287    counts: Vec<u64>,
1288    sums: Vec<f64>,
1289    mins: Vec<f64>,
1290    maxs: Vec<f64>,
1291    first_ts: Vec<i64>,
1292    first_val: Vec<f64>,
1293    first_set: Vec<bool>,
1294    last_ts: Vec<i64>,
1295    last_val: Vec<f64>,
1296    last_set: Vec<bool>,
1297    needs_first_last: bool,
1298    /// The exact track per field, allocated only for decimal columns.
1299    decimals: Vec<Option<Box<DecimalTrack>>>,
1300}
1301
1302impl StreamingAggregator {
1303    /// Create an aggregator for `functions` over `field_columns`, grouped by
1304    /// `group_by` (empty for a whole-dataset aggregate).
1305    #[must_use]
1306    pub fn new(functions: &[AggFn], field_columns: &[&str], group_by: &[&str]) -> Self {
1307        let num_fields = field_columns.len();
1308        let state = if group_by.is_empty() {
1309            AggState::Ungrouped(UngroupedState {
1310                counts: vec![0; num_fields],
1311                sums: vec![0.0; num_fields],
1312                mins: vec![f64::INFINITY; num_fields],
1313                maxs: vec![f64::NEG_INFINITY; num_fields],
1314                first_ts: vec![i64::MAX; num_fields],
1315                first_val: vec![0.0; num_fields],
1316                first_set: vec![false; num_fields],
1317                last_ts: vec![i64::MIN; num_fields],
1318                last_val: vec![0.0; num_fields],
1319                last_set: vec![false; num_fields],
1320                needs_first_last: functions
1321                    .iter()
1322                    .any(|f| matches!(f, AggFn::First | AggFn::Last)),
1323                decimals: (0..num_fields).map(|_| None).collect(),
1324            })
1325        } else {
1326            AggState::Grouped {
1327                groups: Vec::new(),
1328                index: std::collections::HashMap::new(),
1329            }
1330        };
1331        Self {
1332            functions: functions.to_vec(),
1333            field_columns: field_columns.iter().map(|s| (*s).to_string()).collect(),
1334            group_by: group_by.iter().map(|s| (*s).to_string()).collect(),
1335            state,
1336        }
1337    }
1338
1339    /// Fold one batch into the running state. The batch may be dropped after.
1340    ///
1341    /// # Errors
1342    ///
1343    /// Returns an error if the optional `memory_tracker` budget is exceeded by
1344    /// newly discovered groups.
1345    pub fn push(
1346        &mut self,
1347        batch: &RecordBatch,
1348        memory_tracker: Option<&crate::memory::MemoryTracker>,
1349    ) -> Result<()> {
1350        let field_columns: Vec<&str> = self.field_columns.iter().map(String::as_str).collect();
1351        match &mut self.state {
1352            AggState::Ungrouped(st) => {
1353                st.push(batch, &field_columns);
1354                Ok(())
1355            }
1356            AggState::Grouped { groups, index } => {
1357                let group_by: Vec<&str> = self.group_by.iter().map(String::as_str).collect();
1358                push_grouped(
1359                    batch,
1360                    &field_columns,
1361                    &group_by,
1362                    groups,
1363                    index,
1364                    memory_tracker,
1365                )
1366            }
1367        }
1368    }
1369
1370    /// Finalise the accumulated state into a result batch.
1371    ///
1372    /// # Errors
1373    ///
1374    /// Returns an error if the result batch cannot be built or the optional
1375    /// `memory_tracker` budget is exceeded.
1376    pub fn finish(
1377        self,
1378        memory_tracker: Option<&crate::memory::MemoryTracker>,
1379    ) -> Result<RecordBatch> {
1380        let field_columns: Vec<&str> = self.field_columns.iter().map(String::as_str).collect();
1381        let batch = match self.state {
1382            AggState::Ungrouped(st) => st.finish(&self.functions, &field_columns)?,
1383            AggState::Grouped { groups, .. } => {
1384                let group_by: Vec<&str> = self.group_by.iter().map(String::as_str).collect();
1385                finish_grouped(&groups, &self.functions, &field_columns, &group_by)?
1386            }
1387        };
1388        if let Some(tracker) = memory_tracker {
1389            tracker.try_allocate(batch.get_array_memory_size())?;
1390        }
1391        Ok(batch)
1392    }
1393}
1394
1395/// Fold one batch into the grouped accumulator state.
1396fn push_grouped(
1397    batch: &RecordBatch,
1398    field_columns: &[&str],
1399    group_by: &[&str],
1400    groups: &mut Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)>,
1401    group_index: &mut std::collections::HashMap<u64, Vec<usize>>,
1402    memory_tracker: Option<&crate::memory::MemoryTracker>,
1403) -> Result<()> {
1404    let num_fields = field_columns.len();
1405
1406    let group_cols: Vec<Option<&StringArray>> = group_by
1407        .iter()
1408        .map(|name| {
1409            batch
1410                .column_by_name(name)
1411                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1412        })
1413        .collect();
1414
1415    let ts_col = batch
1416        .column_by_name(chronix_core::TIME_COLUMN)
1417        .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
1418
1419    // Resolve field columns for this batch — pre-downcast once.
1420    let typed_cols: Vec<Option<TypedColumn<'_>>> = field_columns
1421        .iter()
1422        .map(|name| {
1423            batch
1424                .column_by_name(name)
1425                .map(|c| TypedColumn::from_array(c.as_ref()))
1426        })
1427        .collect();
1428
1429    for row in 0..batch.num_rows() {
1430        let ts: Option<i64> = ts_col.and_then(|t| {
1431            if t.is_null(row) {
1432                None
1433            } else {
1434                Some(t.value(row))
1435            }
1436        });
1437
1438        // Hash the group key.
1439        let mut hasher = fnv::FnvHasher::default();
1440        for col in &group_cols {
1441            match col {
1442                Some(c) if !c.is_null(row) => {
1443                    std::hash::Hash::hash(&1u8, &mut hasher);
1444                    std::hash::Hash::hash(c.value(row), &mut hasher);
1445                }
1446                _ => std::hash::Hash::hash(&0u8, &mut hasher),
1447            }
1448        }
1449        let hash = std::hash::Hasher::finish(&hasher);
1450
1451        let key: Vec<Option<&str>> = group_cols
1452            .iter()
1453            .map(|col| match col {
1454                Some(c) if !c.is_null(row) => Some(c.value(row)),
1455                _ => None,
1456            })
1457            .collect();
1458
1459        // Find or create the group. The hash is only a bucket selector — the
1460        // full key is compared, so a collision cannot merge two series.
1461        let bucket = group_index.entry(hash).or_default();
1462        let gidx = {
1463            let mut found = None;
1464            for &idx in bucket.iter() {
1465                let existing: Vec<Option<&str>> =
1466                    groups[idx].0.iter().map(|o| o.as_deref()).collect();
1467                if existing == key {
1468                    found = Some(idx);
1469                    break;
1470                }
1471            }
1472            match found {
1473                Some(idx) => idx,
1474                None => {
1475                    // Check memory budget before adding a new group.
1476                    if let Some(tracker) = memory_tracker {
1477                        let estimated_bytes = group_by.len() * 48  // owned Strings
1478                            + num_fields * std::mem::size_of::<IncrementalAccumulator>()
1479                            + 48; // HashMap entry + Vec overhead
1480                        tracker.try_allocate(estimated_bytes)?;
1481                    }
1482                    let idx = groups.len();
1483                    let owned_key: Vec<Option<String>> =
1484                        key.iter().map(|o| o.map(String::from)).collect();
1485                    let accs = (0..num_fields)
1486                        .map(|_| IncrementalAccumulator::new())
1487                        .collect();
1488                    groups.push((owned_key, accs));
1489                    bucket.push(idx);
1490                    idx
1491                }
1492            }
1493        };
1494
1495        // Update one accumulator per field — single pass, no
1496        // redundant accumulator updates across function variants.
1497        for (fi, typed) in typed_cols.iter().enumerate() {
1498            if let Some(v) = typed.and_then(|tc| tc.value_num(row)) {
1499                groups[gidx].1[fi].update(v, ts);
1500            }
1501        }
1502    }
1503
1504    Ok(())
1505}
1506
1507/// Build the result batch from grouped accumulator state.
1508fn finish_grouped(
1509    groups: &[(Vec<Option<String>>, Vec<IncrementalAccumulator>)],
1510    functions: &[AggFn],
1511    field_columns: &[&str],
1512    group_by: &[&str],
1513) -> Result<RecordBatch> {
1514    let num_groups = groups.len();
1515    let mut result_fields: Vec<Field> = group_by
1516        .iter()
1517        .map(|name| Field::new(*name, DataType::Utf8, true))
1518        .collect();
1519    let mut result_columns: Vec<ArrayRef> = Vec::new();
1520
1521    // Group-by value columns.
1522    for col_idx in 0..group_by.len() {
1523        let vals: Vec<Option<&str>> = groups
1524            .iter()
1525            .map(|(key, _)| key[col_idx].as_deref())
1526            .collect();
1527        let arr: StringArray = vals.into_iter().collect();
1528        result_columns.push(Arc::new(arr));
1529    }
1530
1531    // Aggregation result columns — one accumulator per field.
1532    for (fi, &field_name) in field_columns.iter().enumerate() {
1533        for func in functions {
1534            let name = format!("{field_name}_{}", agg_fn_name(*func));
1535            let vals: Vec<AggResult> = (0..num_groups)
1536                .map(|gi| groups[gi].1[fi].finalize(*func))
1537                .collect();
1538            let (field, array) = agg_result_column(&name, &vals)?;
1539            result_fields.push(field);
1540            result_columns.push(array);
1541        }
1542    }
1543
1544    let schema = Arc::new(Schema::new(result_fields));
1545    RecordBatch::try_new(schema, result_columns).map_err(Into::into)
1546}
1547
1548impl UngroupedState {
1549    /// Fold one batch, using Arrow SIMD kernels for count/sum/min/max.
1550    /// First/Last still require a linear scan to find the row at the
1551    /// min/max timestamp.
1552    #[allow(clippy::cast_precision_loss)]
1553    fn push(&mut self, batch: &RecordBatch, field_columns: &[&str]) {
1554        let ts_col = batch
1555            .column_by_name(chronix_core::TIME_COLUMN)
1556            .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
1557
1558        for (fi, &field_name) in field_columns.iter().enumerate() {
1559            let Some(col) = batch.column_by_name(field_name) else {
1560                continue;
1561            };
1562
1563            // Vectorized count via O(1) Arrow metadata.
1564            self.counts[fi] += (col.len() - col.null_count()) as u64;
1565
1566            // Vectorized Sum/Min/Max using Arrow SIMD kernels.
1567            let typed = TypedColumn::from_array(col.as_ref());
1568            match typed {
1569                TypedColumn::Float64(a) => {
1570                    if let Some(s) = arrow_agg::sum(a) {
1571                        self.sums[fi] += s;
1572                    }
1573                    if let Some(m) = arrow_agg::min(a)
1574                        && m < self.mins[fi]
1575                    {
1576                        self.mins[fi] = m;
1577                    }
1578                    if let Some(m) = arrow_agg::max(a)
1579                        && m > self.maxs[fi]
1580                    {
1581                        self.maxs[fi] = m;
1582                    }
1583                }
1584                TypedColumn::Int64(a) => {
1585                    if let Some(s) = arrow_agg::sum(a) {
1586                        self.sums[fi] += s as f64;
1587                    }
1588                    if let Some(m) = arrow_agg::min(a)
1589                        && (m as f64) < self.mins[fi]
1590                    {
1591                        self.mins[fi] = m as f64;
1592                    }
1593                    if let Some(m) = arrow_agg::max(a)
1594                        && (m as f64) > self.maxs[fi]
1595                    {
1596                        self.maxs[fi] = m as f64;
1597                    }
1598                }
1599                TypedColumn::UInt64(a) => {
1600                    if let Some(s) = arrow_agg::sum(a) {
1601                        self.sums[fi] += s as f64;
1602                    }
1603                    if let Some(m) = arrow_agg::min(a)
1604                        && (m as f64) < self.mins[fi]
1605                    {
1606                        self.mins[fi] = m as f64;
1607                    }
1608                    if let Some(m) = arrow_agg::max(a)
1609                        && (m as f64) > self.maxs[fi]
1610                    {
1611                        self.maxs[fi] = m as f64;
1612                    }
1613                }
1614                TypedColumn::Decimal(a, scale) => {
1615                    // No Arrow kernel here: `sum` over `Decimal128` wraps on
1616                    // overflow, and a wrapped total is a wrong answer rather
1617                    // than a slow one. One checked pass instead.
1618                    let track =
1619                        self.decimals[fi].get_or_insert_with(|| Box::new(DecimalTrack::new(scale)));
1620                    for i in 0..a.len() {
1621                        if a.is_null(i) {
1622                            continue;
1623                        }
1624                        match track.align(a.value(i), scale) {
1625                            Some(m) => {
1626                                match track.sum.checked_add(m) {
1627                                    Some(sum) => track.sum = sum,
1628                                    None => track.overflow = true,
1629                                }
1630                                if m < track.min {
1631                                    track.min = m;
1632                                }
1633                                if m > track.max {
1634                                    track.max = m;
1635                                }
1636                            }
1637                            None => track.overflow = true,
1638                        }
1639                    }
1640                }
1641                TypedColumn::Other => {}
1642            }
1643
1644            // First/Last: find value at the row with min/max timestamp.
1645            // This is inherently a scatter operation (argmin/argmax + value
1646            // lookup), so a single linear scan per batch is optimal.
1647            if self.needs_first_last
1648                && let Some(ts) = ts_col
1649            {
1650                let n = ts.len().min(col.len());
1651                for i in 0..n {
1652                    if ts.is_null(i) || col.is_null(i) {
1653                        continue;
1654                    }
1655                    let t = ts.value(i);
1656                    if let Some(v) = typed.value_num(i) {
1657                        let first = t < self.first_ts[fi] || !self.first_set[fi];
1658                        // `>=` so last-encountered wins on ties.
1659                        let last = t >= self.last_ts[fi] || !self.last_set[fi];
1660                        if first {
1661                            self.first_ts[fi] = t;
1662                            self.first_set[fi] = true;
1663                        }
1664                        if last {
1665                            self.last_ts[fi] = t;
1666                            self.last_set[fi] = true;
1667                        }
1668                        match v {
1669                            Num::F64(x) => {
1670                                if first {
1671                                    self.first_val[fi] = x;
1672                                }
1673                                if last {
1674                                    self.last_val[fi] = x;
1675                                }
1676                            }
1677                            Num::Dec(mantissa, scale) => {
1678                                let track = self.decimals[fi]
1679                                    .get_or_insert_with(|| Box::new(DecimalTrack::new(scale)));
1680                                if let Some(m) = track.align(mantissa, scale) {
1681                                    if first {
1682                                        track.first = m;
1683                                    }
1684                                    if last {
1685                                        track.last = m;
1686                                    }
1687                                }
1688                            }
1689                        }
1690                    }
1691                }
1692            }
1693        }
1694    }
1695
1696    /// One field's result for one function, exact where the column is.
1697    #[allow(clippy::cast_precision_loss)]
1698    fn finalize(&self, fi: usize, func: AggFn) -> AggResult {
1699        if self.counts[fi] == 0 {
1700            return if func == AggFn::Count {
1701                AggResult::F64(0.0)
1702            } else {
1703                AggResult::Null
1704            };
1705        }
1706        // A count is a count whatever the column holds.
1707        if func == AggFn::Count {
1708            return AggResult::F64(self.counts[fi] as f64);
1709        }
1710        if let Some(track) = self.decimals[fi].as_deref() {
1711            return match func {
1712                AggFn::Sum => track.sum_result(),
1713                AggFn::Avg => track.avg_result(self.counts[fi]),
1714                AggFn::Min => track.at(track.min),
1715                AggFn::Max => track.at(track.max),
1716                AggFn::First => {
1717                    if self.first_set[fi] {
1718                        track.at(track.first)
1719                    } else {
1720                        AggResult::Null
1721                    }
1722                }
1723                AggFn::Last => {
1724                    if self.last_set[fi] {
1725                        track.at(track.last)
1726                    } else {
1727                        AggResult::Null
1728                    }
1729                }
1730                AggFn::Count => unreachable!("handled above"),
1731            };
1732        }
1733        match func {
1734            AggFn::Sum => AggResult::F64(self.sums[fi]),
1735            AggFn::Min => AggResult::F64(self.mins[fi]),
1736            AggFn::Max => AggResult::F64(self.maxs[fi]),
1737            AggFn::Avg => AggResult::F64(self.sums[fi] / self.counts[fi] as f64),
1738            AggFn::First => {
1739                if self.first_set[fi] {
1740                    AggResult::F64(self.first_val[fi])
1741                } else {
1742                    AggResult::Null
1743                }
1744            }
1745            AggFn::Last => {
1746                if self.last_set[fi] {
1747                    AggResult::F64(self.last_val[fi])
1748                } else {
1749                    AggResult::Null
1750                }
1751            }
1752            AggFn::Count => unreachable!("handled above"),
1753        }
1754    }
1755
1756    #[allow(clippy::cast_precision_loss)]
1757    fn finish(&self, functions: &[AggFn], field_columns: &[&str]) -> Result<RecordBatch> {
1758        let mut result_fields = Vec::new();
1759        let mut result_columns: Vec<ArrayRef> = Vec::new();
1760        for (fi, &field_name) in field_columns.iter().enumerate() {
1761            for func in functions {
1762                let name = format!("{field_name}_{}", agg_fn_name(*func));
1763                let value = self.finalize(fi, *func);
1764                let (field, array) = agg_result_column(&name, &[value])?;
1765                result_fields.push(field);
1766                result_columns.push(array);
1767            }
1768        }
1769
1770        let schema = Arc::new(Schema::new(result_fields));
1771        RecordBatch::try_new(schema, result_columns).map_err(Into::into)
1772    }
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777    use super::*;
1778
1779    // ── Exact decimals ─────────────────────────────────────────────
1780
1781    #[test]
1782    fn first_and_last_over_a_decimal_column_without_timestamps() {
1783        // No timestamp column, so First and Last mean array position. The
1784        // accumulator is fed no timestamps on this path, and asking it for
1785        // First returned its zero-initialised slot — a `0.00` where the
1786        // first value was `1.11`.
1787        let col: ArrayRef = Arc::new(
1788            Decimal128Array::from(vec![Some(111_i128), None, Some(333)])
1789                .with_precision_and_scale(38, 2)
1790                .unwrap(),
1791        );
1792        assert_eq!(
1793            aggregate_column_ts(col.as_ref(), AggFn::First, None),
1794            AggResult::Decimal {
1795                mantissa: 111,
1796                scale: 2
1797            }
1798        );
1799        assert_eq!(
1800            aggregate_column_ts(col.as_ref(), AggFn::Last, None),
1801            AggResult::Decimal {
1802                mantissa: 333,
1803                scale: 2
1804            }
1805        );
1806        // And an all-null column has no first value at all.
1807        let empty: ArrayRef = Arc::new(
1808            Decimal128Array::from(vec![None::<i128>, None])
1809                .with_precision_and_scale(38, 2)
1810                .unwrap(),
1811        );
1812        assert_eq!(
1813            aggregate_column_ts(empty.as_ref(), AggFn::First, None),
1814            AggResult::Null
1815        );
1816    }
1817
1818    #[test]
1819    fn a_decimal_sum_that_overflows_38_digits_is_null_not_wrapped() {
1820        // A wrapped total is a wrong answer that looks like an answer.
1821        let widest = 99_999_999_999_999_999_999_999_999_999_999_999_999_i128;
1822        let col: ArrayRef = Arc::new(
1823            Decimal128Array::from(vec![Some(widest), Some(widest)])
1824                .with_precision_and_scale(38, 0)
1825                .unwrap(),
1826        );
1827        assert_eq!(
1828            aggregate_column_ts(col.as_ref(), AggFn::Sum, None),
1829            AggResult::Null
1830        );
1831        // Min and max are untouched by the sum's overflow.
1832        assert_eq!(
1833            aggregate_column_ts(col.as_ref(), AggFn::Max, None),
1834            AggResult::Decimal {
1835                mantissa: widest,
1836                scale: 0
1837            }
1838        );
1839    }
1840
1841    #[test]
1842    fn a_decimal_column_at_two_scales_folds_at_the_wider_one() {
1843        // The write path normalises, so this only arises for batches that
1844        // never went through it — but folding them at different scales would
1845        // add a hundredth to a ten-thousandth.
1846        let mut acc = IncrementalAccumulator::new();
1847        acc.update(Num::Dec(150, 2), Some(1)); // 1.50
1848        acc.update(Num::Dec(15_000, 4), Some(2)); // 1.5000
1849        assert_eq!(
1850            acc.finalize(AggFn::Sum),
1851            AggResult::Decimal {
1852                mantissa: 30_000,
1853                scale: 4
1854            }
1855        );
1856        // …and in the other order, which widens the track after the fact.
1857        let mut acc = IncrementalAccumulator::new();
1858        acc.update(Num::Dec(15_000, 4), Some(1));
1859        acc.update(Num::Dec(150, 2), Some(2));
1860        assert_eq!(
1861            acc.finalize(AggFn::Sum),
1862            AggResult::Decimal {
1863                mantissa: 30_000,
1864                scale: 4
1865            }
1866        );
1867    }
1868
1869    #[test]
1870    fn the_average_of_decimals_rounds_half_away_from_zero() {
1871        let mut acc = IncrementalAccumulator::new();
1872        for m in [100_i128, 100, 200] {
1873            acc.update(Num::Dec(m, 2), Some(1));
1874        }
1875        // (1 + 1 + 2) / 3 = 1.333333…, at scale 2 + AVG_EXTRA_SCALE.
1876        assert_eq!(
1877            acc.finalize(AggFn::Avg),
1878            AggResult::Decimal {
1879                mantissa: 133_333_333,
1880                scale: 8
1881            }
1882        );
1883        // A negative mean rounds away from zero too, not toward it.
1884        let mut acc = IncrementalAccumulator::new();
1885        for m in [-100_i128, -100, -200] {
1886            acc.update(Num::Dec(m, 2), Some(1));
1887        }
1888        assert_eq!(
1889            acc.finalize(AggFn::Avg),
1890            AggResult::Decimal {
1891                mantissa: -133_333_333,
1892                scale: 8
1893            }
1894        );
1895    }
1896
1897    #[test]
1898    fn a_count_of_decimals_is_a_float_column_not_a_decimal_one() {
1899        let results = [AggResult::F64(3.0)];
1900        let (field, _) = agg_result_column("v_count", &results).unwrap();
1901        assert_eq!(field.data_type(), &DataType::Float64);
1902        // And a decimal result produces a decimal column at its own scale.
1903        let results = [AggResult::Decimal {
1904            mantissa: 30,
1905            scale: 2,
1906        }];
1907        let (field, array) = agg_result_column("v_sum", &results).unwrap();
1908        assert_eq!(field.data_type(), &DataType::Decimal128(38, 2));
1909        assert_eq!(array.len(), 1);
1910    }
1911
1912    fn test_batch() -> RecordBatch {
1913        let schema = Arc::new(Schema::new(vec![
1914            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1915            Field::new("host", DataType::Utf8, false),
1916            Field::new("value", DataType::Float64, false),
1917        ]));
1918
1919        let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
1920        let hosts = Arc::new(StringArray::from(vec!["a", "a", "b", "b", "a"]));
1921        let values = Arc::new(Float64Array::from(vec![10.0, 20.0, 30.0, 40.0, 50.0]));
1922
1923        RecordBatch::try_new(schema, vec![timestamps, hosts, values]).unwrap()
1924    }
1925
1926    #[test]
1927    fn aggregate_count() {
1928        let batch = test_batch();
1929        let result = aggregate_batch(&batch, &[AggFn::Count], &["value"], None).unwrap();
1930        assert_eq!(result.num_rows(), 1);
1931        let col = result
1932            .column(0)
1933            .as_any()
1934            .downcast_ref::<Float64Array>()
1935            .unwrap();
1936        assert!((col.value(0) - 5.0).abs() < f64::EPSILON);
1937    }
1938
1939    #[test]
1940    fn aggregate_sum() {
1941        let batch = test_batch();
1942        let result = aggregate_batch(&batch, &[AggFn::Sum], &["value"], None).unwrap();
1943        let col = result
1944            .column(0)
1945            .as_any()
1946            .downcast_ref::<Float64Array>()
1947            .unwrap();
1948        assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
1949    }
1950
1951    #[test]
1952    fn aggregate_min_max() {
1953        let batch = test_batch();
1954        let result = aggregate_batch(&batch, &[AggFn::Min, AggFn::Max], &["value"], None).unwrap();
1955        let min_col = result
1956            .column(0)
1957            .as_any()
1958            .downcast_ref::<Float64Array>()
1959            .unwrap();
1960        let max_col = result
1961            .column(1)
1962            .as_any()
1963            .downcast_ref::<Float64Array>()
1964            .unwrap();
1965        assert!((min_col.value(0) - 10.0).abs() < f64::EPSILON);
1966        assert!((max_col.value(0) - 50.0).abs() < f64::EPSILON);
1967    }
1968
1969    #[test]
1970    fn aggregate_avg() {
1971        let batch = test_batch();
1972        let result = aggregate_batch(&batch, &[AggFn::Avg], &["value"], None).unwrap();
1973        let col = result
1974            .column(0)
1975            .as_any()
1976            .downcast_ref::<Float64Array>()
1977            .unwrap();
1978        assert!((col.value(0) - 30.0).abs() < f64::EPSILON);
1979    }
1980
1981    #[test]
1982    fn aggregate_first_last() {
1983        let batch = test_batch();
1984        let result =
1985            aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
1986        let first = result
1987            .column(0)
1988            .as_any()
1989            .downcast_ref::<Float64Array>()
1990            .unwrap();
1991        let last = result
1992            .column(1)
1993            .as_any()
1994            .downcast_ref::<Float64Array>()
1995            .unwrap();
1996        assert!((first.value(0) - 10.0).abs() < f64::EPSILON);
1997        assert!((last.value(0) - 50.0).abs() < f64::EPSILON);
1998    }
1999
2000    #[test]
2001    fn aggregate_grouped_by_host() {
2002        let batch = test_batch();
2003        let result = aggregate_grouped(
2004            &batch,
2005            &[AggFn::Sum, AggFn::Count],
2006            &["value"],
2007            &["host"],
2008            None,
2009        )
2010        .unwrap();
2011
2012        assert_eq!(result.num_rows(), 2); // two groups: a, b
2013
2014        let hosts = result
2015            .column(0)
2016            .as_any()
2017            .downcast_ref::<StringArray>()
2018            .unwrap();
2019        let sums = result
2020            .column(1)
2021            .as_any()
2022            .downcast_ref::<Float64Array>()
2023            .unwrap();
2024        let counts = result
2025            .column(2)
2026            .as_any()
2027            .downcast_ref::<Float64Array>()
2028            .unwrap();
2029
2030        // Groups maintain insertion order (first-seen order)
2031        assert_eq!(hosts.value(0), "a");
2032        assert!((sums.value(0) - 80.0).abs() < f64::EPSILON); // 10+20+50
2033        assert!((counts.value(0) - 3.0).abs() < f64::EPSILON);
2034
2035        assert_eq!(hosts.value(1), "b");
2036        assert!((sums.value(1) - 70.0).abs() < f64::EPSILON); // 30+40
2037        assert!((counts.value(1) - 2.0).abs() < f64::EPSILON);
2038    }
2039
2040    #[test]
2041    fn all_functions_on_known_data() {
2042        let batch = test_batch();
2043        let result = aggregate_batch(
2044            &batch,
2045            &[
2046                AggFn::Count,
2047                AggFn::Sum,
2048                AggFn::Min,
2049                AggFn::Max,
2050                AggFn::Avg,
2051                AggFn::First,
2052                AggFn::Last,
2053            ],
2054            &["value"],
2055            None,
2056        )
2057        .unwrap();
2058
2059        assert_eq!(result.num_columns(), 7);
2060        let cols: Vec<f64> = (0..7)
2061            .map(|i| {
2062                result
2063                    .column(i)
2064                    .as_any()
2065                    .downcast_ref::<Float64Array>()
2066                    .unwrap()
2067                    .value(0)
2068            })
2069            .collect();
2070
2071        assert!((cols[0] - 5.0).abs() < f64::EPSILON); // count
2072        assert!((cols[1] - 150.0).abs() < f64::EPSILON); // sum
2073        assert!((cols[2] - 10.0).abs() < f64::EPSILON); // min
2074        assert!((cols[3] - 50.0).abs() < f64::EPSILON); // max
2075        assert!((cols[4] - 30.0).abs() < f64::EPSILON); // avg
2076        assert!((cols[5] - 10.0).abs() < f64::EPSILON); // first
2077        assert!((cols[6] - 50.0).abs() < f64::EPSILON); // last
2078    }
2079
2080    // ── U64 aggregation tests ─────────────────────────────────────
2081
2082    fn u64_batch() -> RecordBatch {
2083        let schema = Arc::new(Schema::new(vec![
2084            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2085            Field::new("count", DataType::UInt64, false),
2086        ]));
2087        let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
2088        let counts = Arc::new(UInt64Array::from(vec![10u64, 20, 30, 40, 50]));
2089        RecordBatch::try_new(schema, vec![timestamps, counts]).unwrap()
2090    }
2091
2092    #[test]
2093    fn aggregate_u64_sum() {
2094        let batch = u64_batch();
2095        let result = aggregate_batch(&batch, &[AggFn::Sum], &["count"], None).unwrap();
2096        let col = result
2097            .column(0)
2098            .as_any()
2099            .downcast_ref::<Float64Array>()
2100            .unwrap();
2101        assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
2102    }
2103
2104    #[test]
2105    fn aggregate_u64_min_max() {
2106        let batch = u64_batch();
2107        let result = aggregate_batch(&batch, &[AggFn::Min, AggFn::Max], &["count"], None).unwrap();
2108        let min_col = result
2109            .column(0)
2110            .as_any()
2111            .downcast_ref::<Float64Array>()
2112            .unwrap();
2113        let max_col = result
2114            .column(1)
2115            .as_any()
2116            .downcast_ref::<Float64Array>()
2117            .unwrap();
2118        assert!((min_col.value(0) - 10.0).abs() < f64::EPSILON);
2119        assert!((max_col.value(0) - 50.0).abs() < f64::EPSILON);
2120    }
2121
2122    #[test]
2123    fn aggregate_u64_avg() {
2124        let batch = u64_batch();
2125        let result = aggregate_batch(&batch, &[AggFn::Avg], &["count"], None).unwrap();
2126        let col = result
2127            .column(0)
2128            .as_any()
2129            .downcast_ref::<Float64Array>()
2130            .unwrap();
2131        assert!((col.value(0) - 30.0).abs() < f64::EPSILON);
2132    }
2133
2134    #[test]
2135    fn aggregate_u64_first_last() {
2136        let batch = u64_batch();
2137        let result =
2138            aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["count"], None).unwrap();
2139        let first = result
2140            .column(0)
2141            .as_any()
2142            .downcast_ref::<Float64Array>()
2143            .unwrap();
2144        let last = result
2145            .column(1)
2146            .as_any()
2147            .downcast_ref::<Float64Array>()
2148            .unwrap();
2149        assert!((first.value(0) - 10.0).abs() < f64::EPSILON);
2150        assert!((last.value(0) - 50.0).abs() < f64::EPSILON);
2151    }
2152
2153    #[test]
2154    fn aggregate_u64_count() {
2155        let batch = u64_batch();
2156        let result = aggregate_batch(&batch, &[AggFn::Count], &["count"], None).unwrap();
2157        let col = result
2158            .column(0)
2159            .as_any()
2160            .downcast_ref::<Float64Array>()
2161            .unwrap();
2162        assert!((col.value(0) - 5.0).abs() < f64::EPSILON);
2163    }
2164
2165    #[test]
2166    fn aggregate_respects_memory_tracker() {
2167        let batch = test_batch();
2168        let tracker = crate::memory::MemoryTracker::new(1_000_000);
2169        let result = aggregate_batch(&batch, &[AggFn::Sum], &["value"], Some(&tracker)).unwrap();
2170        assert!(tracker.allocated() > 0);
2171        let col = result
2172            .column(0)
2173            .as_any()
2174            .downcast_ref::<Float64Array>()
2175            .unwrap();
2176        assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
2177    }
2178
2179    #[test]
2180    fn aggregate_exceeds_memory_budget() {
2181        let batch = test_batch();
2182        let tracker = crate::memory::MemoryTracker::new(1); // 1 byte budget
2183        let err = aggregate_batch(&batch, &[AggFn::Sum], &["value"], Some(&tracker)).unwrap_err();
2184        assert!(matches!(
2185            err,
2186            crate::error::QueryError::QueryMemoryExceeded { .. }
2187        ));
2188    }
2189
2190    #[test]
2191    fn grouped_aggregate_respects_memory_tracker() {
2192        let batch = test_batch();
2193        let tracker = crate::memory::MemoryTracker::new(1_000_000);
2194        let result =
2195            aggregate_grouped(&batch, &[AggFn::Sum], &["value"], &["host"], Some(&tracker))
2196                .unwrap();
2197        assert!(tracker.allocated() > 0);
2198        assert_eq!(result.num_rows(), 2);
2199    }
2200
2201    /// Out-of-order timestamps: First/Last should use timestamp ordering,
2202    /// not positional ordering.
2203    #[test]
2204    fn first_last_out_of_order_timestamps() {
2205        let schema = Arc::new(Schema::new(vec![
2206            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2207            Field::new("host", DataType::Utf8, false),
2208            Field::new("value", DataType::Float64, false),
2209        ]));
2210        let ts = Int64Array::from(vec![300, 100, 500, 200, 400]);
2211        let host = StringArray::from(vec!["a", "a", "a", "a", "a"]);
2212        let value = Float64Array::from(vec![3.0, 1.0, 5.0, 2.0, 4.0]);
2213        let batch =
2214            RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2215                .unwrap();
2216
2217        let result =
2218            aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
2219        let first = result
2220            .column(0)
2221            .as_any()
2222            .downcast_ref::<Float64Array>()
2223            .unwrap();
2224        let last = result
2225            .column(1)
2226            .as_any()
2227            .downcast_ref::<Float64Array>()
2228            .unwrap();
2229        // First = value at min timestamp (100) = 1.0
2230        assert!(
2231            (first.value(0) - 1.0).abs() < f64::EPSILON,
2232            "First should be 1.0, got {}",
2233            first.value(0)
2234        );
2235        // Last = value at max timestamp (500) = 5.0
2236        assert!(
2237            (last.value(0) - 5.0).abs() < f64::EPSILON,
2238            "Last should be 5.0, got {}",
2239            last.value(0)
2240        );
2241    }
2242
2243    /// Timestamp ties: Last should keep the last-encountered row.
2244    #[test]
2245    fn first_last_timestamp_ties() {
2246        let schema = Arc::new(Schema::new(vec![
2247            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2248            Field::new("host", DataType::Utf8, false),
2249            Field::new("value", DataType::Float64, false),
2250        ]));
2251        // Two rows at ts=100 (first wins = 1.0), two rows at ts=200 (last-encountered wins = 4.0).
2252        let ts = Int64Array::from(vec![100, 200, 100, 200]);
2253        let host = StringArray::from(vec!["a", "a", "a", "a"]);
2254        let value = Float64Array::from(vec![1.0, 2.0, 3.0, 4.0]);
2255        let batch =
2256            RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2257                .unwrap();
2258
2259        let result =
2260            aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
2261        let first = result
2262            .column(0)
2263            .as_any()
2264            .downcast_ref::<Float64Array>()
2265            .unwrap();
2266        let last = result
2267            .column(1)
2268            .as_any()
2269            .downcast_ref::<Float64Array>()
2270            .unwrap();
2271        // First: first-encountered at min-ts (100) = 1.0
2272        assert!((first.value(0) - 1.0).abs() < f64::EPSILON);
2273        // Last: last-encountered at max-ts (200) = 4.0
2274        assert!((last.value(0) - 4.0).abs() < f64::EPSILON);
2275    }
2276
2277    /// Grouped First/Last should also use timestamps correctly.
2278    #[test]
2279    fn grouped_first_last_by_timestamp() {
2280        let schema = Arc::new(Schema::new(vec![
2281            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2282            Field::new("host", DataType::Utf8, false),
2283            Field::new("value", DataType::Float64, false),
2284        ]));
2285        // host=a: ts=[200,100] vals=[20,10] → first=10, last=20
2286        // host=b: ts=[300,100,200] vals=[30,10,20] → first=10, last=30
2287        let ts = Int64Array::from(vec![200, 100, 300, 100, 200]);
2288        let host = StringArray::from(vec!["a", "a", "b", "b", "b"]);
2289        let value = Float64Array::from(vec![20.0, 10.0, 30.0, 10.0, 20.0]);
2290        let batch =
2291            RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2292                .unwrap();
2293
2294        let result = aggregate_grouped(
2295            &batch,
2296            &[AggFn::First, AggFn::Last],
2297            &["value"],
2298            &["host"],
2299            None,
2300        )
2301        .unwrap();
2302
2303        // Find host=a and host=b rows in result
2304        let host_col = result.column_by_name("host").unwrap();
2305        let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
2306        let first_col = result.column_by_name("value_first").unwrap();
2307        let first_arr = first_col.as_any().downcast_ref::<Float64Array>().unwrap();
2308        let last_col = result.column_by_name("value_last").unwrap();
2309        let last_arr = last_col.as_any().downcast_ref::<Float64Array>().unwrap();
2310
2311        for i in 0..result.num_rows() {
2312            match host_arr.value(i) {
2313                "a" => {
2314                    assert!(
2315                        (first_arr.value(i) - 10.0).abs() < f64::EPSILON,
2316                        "host=a first"
2317                    );
2318                    assert!(
2319                        (last_arr.value(i) - 20.0).abs() < f64::EPSILON,
2320                        "host=a last"
2321                    );
2322                }
2323                "b" => {
2324                    assert!(
2325                        (first_arr.value(i) - 10.0).abs() < f64::EPSILON,
2326                        "host=b first"
2327                    );
2328                    assert!(
2329                        (last_arr.value(i) - 30.0).abs() < f64::EPSILON,
2330                        "host=b last"
2331                    );
2332                }
2333                other => panic!("unexpected host: {other}"),
2334            }
2335        }
2336    }
2337
2338    /// Grouped Last with timestamp ties should use last-encountered.
2339    #[test]
2340    fn grouped_last_timestamp_tie_breaking() {
2341        let schema = Arc::new(Schema::new(vec![
2342            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2343            Field::new("host", DataType::Utf8, false),
2344            Field::new("value", DataType::Float64, false),
2345        ]));
2346        // host=a: two rows at ts=100, values [1.0, 2.0] → last-encountered wins = 2.0
2347        let ts = Int64Array::from(vec![100, 100]);
2348        let host = StringArray::from(vec!["a", "a"]);
2349        let value = Float64Array::from(vec![1.0, 2.0]);
2350        let batch =
2351            RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2352                .unwrap();
2353
2354        let result =
2355            aggregate_grouped(&batch, &[AggFn::Last], &["value"], &["host"], None).unwrap();
2356
2357        let last_col = result.column_by_name("value_last").unwrap();
2358        let last_arr = last_col.as_any().downcast_ref::<Float64Array>().unwrap();
2359        assert!(
2360            (last_arr.value(0) - 2.0).abs() < f64::EPSILON,
2361            "Last with tie should keep last-encountered (2.0), got {}",
2362            last_arr.value(0)
2363        );
2364    }
2365
2366    /// Single-row batch: First and Last should return the same value.
2367    #[test]
2368    fn first_last_single_row() {
2369        let schema = Arc::new(Schema::new(vec![
2370            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2371            Field::new("host", DataType::Utf8, false),
2372            Field::new("value", DataType::Float64, false),
2373        ]));
2374        let ts = Int64Array::from(vec![100]);
2375        let host = StringArray::from(vec!["a"]);
2376        let value = Float64Array::from(vec![42.0]);
2377        let batch =
2378            RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
2379                .unwrap();
2380
2381        let result =
2382            aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
2383        let first = result
2384            .column(0)
2385            .as_any()
2386            .downcast_ref::<Float64Array>()
2387            .unwrap();
2388        let last = result
2389            .column(1)
2390            .as_any()
2391            .downcast_ref::<Float64Array>()
2392            .unwrap();
2393        assert!((first.value(0) - 42.0).abs() < f64::EPSILON);
2394        assert!((last.value(0) - 42.0).abs() < f64::EPSILON);
2395    }
2396
2397    // ── Sort-based aggregation tests ─────────────────
2398
2399    #[test]
2400    fn sort_aggregate_matches_hash() {
2401        let batch = test_batch();
2402        let hash_result = aggregate_grouped(
2403            &batch,
2404            &[AggFn::Sum, AggFn::Count, AggFn::Min, AggFn::Max, AggFn::Avg],
2405            &["value"],
2406            &["host"],
2407            None,
2408        )
2409        .unwrap();
2410        let sort_result = aggregate_sorted(
2411            &batch,
2412            &[AggFn::Sum, AggFn::Count, AggFn::Min, AggFn::Max, AggFn::Avg],
2413            &["value"],
2414            &["host"],
2415            None,
2416        )
2417        .unwrap();
2418
2419        // Same number of groups.
2420        assert_eq!(hash_result.num_rows(), sort_result.num_rows());
2421
2422        // Collect results by host for order-independent comparison.
2423        let hash_hosts = hash_result
2424            .column_by_name("host")
2425            .unwrap()
2426            .as_any()
2427            .downcast_ref::<StringArray>()
2428            .unwrap();
2429        let sort_hosts = sort_result
2430            .column_by_name("host")
2431            .unwrap()
2432            .as_any()
2433            .downcast_ref::<StringArray>()
2434            .unwrap();
2435
2436        for host in &["a", "b"] {
2437            let hi = (0..hash_result.num_rows())
2438                .find(|&i| hash_hosts.value(i) == *host)
2439                .unwrap();
2440            let si = (0..sort_result.num_rows())
2441                .find(|&i| sort_hosts.value(i) == *host)
2442                .unwrap();
2443
2444            for col_name in &[
2445                "value_sum",
2446                "value_count",
2447                "value_min",
2448                "value_max",
2449                "value_avg",
2450            ] {
2451                let hv = hash_result
2452                    .column_by_name(col_name)
2453                    .unwrap()
2454                    .as_any()
2455                    .downcast_ref::<Float64Array>()
2456                    .unwrap()
2457                    .value(hi);
2458                let sv = sort_result
2459                    .column_by_name(col_name)
2460                    .unwrap()
2461                    .as_any()
2462                    .downcast_ref::<Float64Array>()
2463                    .unwrap()
2464                    .value(si);
2465                assert!(
2466                    (hv - sv).abs() < f64::EPSILON,
2467                    "mismatch for host={host} col={col_name}: hash={hv}, sort={sv}"
2468                );
2469            }
2470        }
2471    }
2472
2473    #[test]
2474    fn sort_aggregate_first_last() {
2475        let batch = test_batch();
2476        let result = aggregate_sorted(
2477            &batch,
2478            &[AggFn::First, AggFn::Last],
2479            &["value"],
2480            &["host"],
2481            None,
2482        )
2483        .unwrap();
2484
2485        let host_col = result
2486            .column_by_name("host")
2487            .unwrap()
2488            .as_any()
2489            .downcast_ref::<StringArray>()
2490            .unwrap();
2491        let first_col = result
2492            .column_by_name("value_first")
2493            .unwrap()
2494            .as_any()
2495            .downcast_ref::<Float64Array>()
2496            .unwrap();
2497        let last_col = result
2498            .column_by_name("value_last")
2499            .unwrap()
2500            .as_any()
2501            .downcast_ref::<Float64Array>()
2502            .unwrap();
2503
2504        for i in 0..result.num_rows() {
2505            match host_col.value(i) {
2506                "a" => {
2507                    // ts=[100,200,500] vals=[10,20,50] → first=10, last=50
2508                    assert!((first_col.value(i) - 10.0).abs() < f64::EPSILON);
2509                    assert!((last_col.value(i) - 50.0).abs() < f64::EPSILON);
2510                }
2511                "b" => {
2512                    // ts=[300,400] vals=[30,40] → first=30, last=40
2513                    assert!((first_col.value(i) - 30.0).abs() < f64::EPSILON);
2514                    assert!((last_col.value(i) - 40.0).abs() < f64::EPSILON);
2515                }
2516                other => panic!("unexpected host: {other}"),
2517            }
2518        }
2519    }
2520
2521    #[test]
2522    fn sort_aggregate_produces_sorted_output() {
2523        let schema = Arc::new(Schema::new(vec![
2524            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
2525            Field::new("region", DataType::Utf8, false),
2526            Field::new("value", DataType::Float64, false),
2527        ]));
2528        let ts = Int64Array::from(vec![100, 200, 300]);
2529        let regions = StringArray::from(vec!["eu", "us", "ap"]);
2530        let values = Float64Array::from(vec![1.0, 2.0, 3.0]);
2531        let batch = RecordBatch::try_new(
2532            schema,
2533            vec![Arc::new(ts), Arc::new(regions), Arc::new(values)],
2534        )
2535        .unwrap();
2536
2537        let result =
2538            aggregate_sorted(&batch, &[AggFn::Sum], &["value"], &["region"], None).unwrap();
2539
2540        let region_col = result
2541            .column_by_name("region")
2542            .unwrap()
2543            .as_any()
2544            .downcast_ref::<StringArray>()
2545            .unwrap();
2546        // Sort-based aggregation should produce sorted group keys.
2547        let regions: Vec<&str> = (0..result.num_rows())
2548            .map(|i| region_col.value(i))
2549            .collect();
2550        assert_eq!(
2551            regions,
2552            vec!["ap", "eu", "us"],
2553            "output should be sorted by group key"
2554        );
2555    }
2556
2557    #[test]
2558    fn choose_strategy_defaults_to_hash() {
2559        assert_eq!(choose_strategy(None), AggregationStrategy::Hash);
2560        assert_eq!(choose_strategy(Some(2000)), AggregationStrategy::Hash);
2561    }
2562
2563    #[test]
2564    fn choose_strategy_low_cardinality_uses_sort() {
2565        assert_eq!(choose_strategy(Some(5)), AggregationStrategy::Sort);
2566        assert_eq!(choose_strategy(Some(1024)), AggregationStrategy::Sort);
2567    }
2568
2569    #[test]
2570    fn sort_aggregate_no_group_by_delegates() {
2571        let batch = test_batch();
2572        let result = aggregate_sorted(&batch, &[AggFn::Sum], &["value"], &[], None).unwrap();
2573        let col = result
2574            .column(0)
2575            .as_any()
2576            .downcast_ref::<Float64Array>()
2577            .unwrap();
2578        assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
2579    }
2580}