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