Skip to main content

vortex_array/aggregate_fn/fns/bounded_max/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::fmt::Display;
5use std::fmt::Formatter;
6use std::num::NonZeroUsize;
7use std::sync::LazyLock;
8
9use vortex_buffer::BufferString;
10use vortex_buffer::ByteBuffer;
11use vortex_error::VortexExpect;
12use vortex_error::VortexResult;
13use vortex_error::vortex_bail;
14use vortex_error::vortex_ensure;
15use vortex_error::vortex_ensure_eq;
16use vortex_session::VortexSession;
17use vortex_session::registry::CachedId;
18
19use crate::ArrayRef;
20use crate::Columnar;
21use crate::ExecutionCtx;
22use crate::IntoArray;
23use crate::aggregate_fn::AggregateArgs;
24use crate::aggregate_fn::AggregateFnId;
25use crate::aggregate_fn::AggregateFnRef;
26use crate::aggregate_fn::AggregateFnSatisfaction;
27use crate::aggregate_fn::AggregateFnVTable;
28use crate::aggregate_fn::NumericalAggregateOpts;
29use crate::aggregate_fn::fns::max::Max;
30use crate::aggregate_fn::fns::min_max::MinMax;
31use crate::aggregate_fn::fns::min_max::min_max;
32use crate::builtins::ArrayBuiltins;
33use crate::dtype::DType;
34use crate::dtype::FieldNames;
35use crate::dtype::Nullability;
36use crate::dtype::StructFields;
37use crate::partial_ord::partial_max;
38use crate::scalar::Scalar;
39use crate::scalar::ScalarTruncation;
40use crate::scalar::upper_bound;
41
42/// Field name for the bounded maximum upper-bound value in the partial state.
43pub const BOUNDED_MAX_BOUND: &str = "bound";
44/// Field name for whether the partial state represents an unknown upper bound.
45pub const BOUNDED_MAX_UNKNOWN: &str = "unknown";
46
47static NAMES: LazyLock<FieldNames> =
48    LazyLock::new(|| FieldNames::from([BOUNDED_MAX_BOUND, BOUNDED_MAX_UNKNOWN]));
49
50/// Options for [`BoundedMax`].
51#[derive(Clone, Debug, PartialEq, Eq, Hash)]
52pub struct BoundedMaxOptions {
53    /// Maximum byte length for UTF8/Binary bounds.
54    pub max_bytes: NonZeroUsize,
55}
56
57impl Display for BoundedMaxOptions {
58    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
59        write!(f, "{}", self.max_bytes.get())
60    }
61}
62
63/// Compute a byte-bounded upper bound for the maximum non-null value of a UTF8/Binary array.
64#[derive(Clone, Debug)]
65pub struct BoundedMax;
66
67enum BoundedMaxState {
68    Empty,
69    Value(Scalar),
70    Unknown,
71}
72
73/// Partial accumulator state for the bounded maximum aggregate.
74pub struct BoundedMaxPartial {
75    state: BoundedMaxState,
76}
77
78impl BoundedMaxPartial {
79    fn merge_bound(&mut self, max: Scalar) {
80        if max.is_null() {
81            return;
82        }
83
84        self.state = match std::mem::replace(&mut self.state, BoundedMaxState::Empty) {
85            BoundedMaxState::Empty => BoundedMaxState::Value(max),
86            BoundedMaxState::Value(current) => BoundedMaxState::Value(
87                partial_max(max, current).vortex_expect("incomparable bounded max scalars"),
88            ),
89            BoundedMaxState::Unknown => BoundedMaxState::Unknown,
90        };
91    }
92
93    fn unknown(&mut self) {
94        self.state = BoundedMaxState::Unknown;
95    }
96
97    fn final_scalar(&self, args: AggregateArgs<'_, BoundedMaxOptions>) -> VortexResult<Scalar> {
98        let dtype = args.return_dtype.clone();
99        match &self.state {
100            BoundedMaxState::Value(max) => max.cast(&dtype),
101            BoundedMaxState::Empty | BoundedMaxState::Unknown => Ok(Scalar::null(dtype)),
102        }
103    }
104}
105
106/// Return the serialized partial-state dtype for [`BoundedMax`].
107///
108/// A null struct means the partial is empty. A non-null struct with a null `bound` and
109/// `unknown = true` means the input has a non-null maximum but no finite upper bound could be
110/// represented within the configured byte limit.
111pub fn make_bounded_max_partial_dtype(element_dtype: &DType) -> DType {
112    DType::Struct(
113        StructFields::new(
114            NAMES.clone(),
115            vec![
116                element_dtype.as_nullable(),
117                DType::Bool(Nullability::NonNullable),
118            ],
119        ),
120        Nullability::Nullable,
121    )
122}
123
124impl AggregateFnVTable for BoundedMax {
125    type Options = BoundedMaxOptions;
126    type Partial = BoundedMaxPartial;
127
128    fn id(&self) -> AggregateFnId {
129        static ID: CachedId = CachedId::new("vortex.bounded_max");
130        *ID
131    }
132
133    fn serialize(&self, options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
134        let max_bytes = u64::try_from(options.max_bytes.get())?;
135        Ok(Some(max_bytes.to_le_bytes().to_vec()))
136    }
137
138    fn deserialize(
139        &self,
140        metadata: &[u8],
141        _session: &VortexSession,
142    ) -> VortexResult<Self::Options> {
143        vortex_ensure_eq!(
144            metadata.len(),
145            size_of::<u64>(),
146            "BoundedMax options have the wrong byte length"
147        );
148        let mut bytes = [0u8; size_of::<u64>()];
149        bytes.copy_from_slice(metadata);
150        let max_bytes = usize::try_from(u64::from_le_bytes(bytes))?;
151        vortex_ensure!(max_bytes > 0, "BoundedMax requires max_bytes > 0");
152        Ok(BoundedMaxOptions {
153            max_bytes: NonZeroUsize::new(max_bytes).vortex_expect("checked non-zero max_bytes"),
154        })
155    }
156
157    fn return_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
158        supported_dtype(options, input_dtype).map(DType::as_nullable)
159    }
160
161    fn can_satisfy(
162        &self,
163        options: &Self::Options,
164        requested: &AggregateFnRef,
165    ) -> AggregateFnSatisfaction {
166        if let Some(other) = requested.as_opt::<Self>() {
167            return if other == options {
168                AggregateFnSatisfaction::Exact
169            } else if options.max_bytes >= other.max_bytes {
170                AggregateFnSatisfaction::Approximate
171            } else {
172                AggregateFnSatisfaction::No
173            };
174        }
175
176        // The stored bound skips NaNs, so it cannot stand in for a NaN-including maximum.
177        if requested
178            .as_opt::<Max>()
179            .is_some_and(|options| options.skip_nans)
180        {
181            AggregateFnSatisfaction::Approximate
182        } else {
183            AggregateFnSatisfaction::No
184        }
185    }
186
187    fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
188        supported_dtype(options, input_dtype).map(make_bounded_max_partial_dtype)
189    }
190
191    fn empty_partial(
192        &self,
193        _args: AggregateArgs<'_, Self::Options>,
194    ) -> VortexResult<Self::Partial> {
195        Ok(BoundedMaxPartial {
196            state: BoundedMaxState::Empty,
197        })
198    }
199
200    fn partial_from_scalar(
201        &self,
202        _args: AggregateArgs<'_, Self::Options>,
203        scalar: &Scalar,
204    ) -> VortexResult<Self::Partial> {
205        // A null partial means the producing accumulator saw nothing valid.
206        let state = if scalar.is_null() {
207            BoundedMaxState::Empty
208        } else {
209            let Some(fields) = scalar.as_struct_opt() else {
210                vortex_bail!(
211                    "BoundedMax partial must be a struct, got {}",
212                    scalar.dtype()
213                );
214            };
215            let Some(bound) = fields.field_by_idx(0) else {
216                vortex_bail!("BoundedMax partial is missing its bound field");
217            };
218            let Some(unknown) = fields
219                .field_by_idx(1)
220                .and_then(|unknown| unknown.as_bool().value())
221            else {
222                vortex_bail!("BoundedMax partial is missing its non-null unknown field");
223            };
224
225            if unknown {
226                BoundedMaxState::Unknown
227            } else if bound.is_null() {
228                BoundedMaxState::Empty
229            } else {
230                BoundedMaxState::Value(bound)
231            }
232        };
233        Ok(BoundedMaxPartial { state })
234    }
235
236    fn merge_partials(
237        &self,
238        _args: AggregateArgs<'_, Self::Options>,
239        mut first: Self::Partial,
240        second: Self::Partial,
241    ) -> VortexResult<Self::Partial> {
242        match second.state {
243            BoundedMaxState::Empty => {}
244            BoundedMaxState::Value(max) => first.merge_bound(max),
245            BoundedMaxState::Unknown => first.unknown(),
246        }
247        Ok(first)
248    }
249
250    fn to_scalar(
251        &self,
252        args: AggregateArgs<'_, Self::Options>,
253        partial: &Self::Partial,
254    ) -> VortexResult<Scalar> {
255        let dtype = args.partial_dtype.clone();
256        let bound_dtype = args.dtype.as_nullable();
257        match &partial.state {
258            BoundedMaxState::Empty => Ok(Scalar::null(dtype)),
259            BoundedMaxState::Value(max) => Ok(Scalar::struct_(
260                dtype,
261                vec![
262                    max.cast(&bound_dtype)?,
263                    Scalar::bool(false, Nullability::NonNullable),
264                ],
265            )),
266            BoundedMaxState::Unknown => Ok(Scalar::struct_(
267                dtype,
268                vec![
269                    Scalar::null(bound_dtype),
270                    Scalar::bool(true, Nullability::NonNullable),
271                ],
272            )),
273        }
274    }
275
276    fn is_saturated(
277        &self,
278        _args: AggregateArgs<'_, Self::Options>,
279        partial: &Self::Partial,
280    ) -> bool {
281        matches!(partial.state, BoundedMaxState::Unknown)
282    }
283
284    fn accumulate(
285        &self,
286        args: AggregateArgs<'_, Self::Options>,
287        partial: &mut Self::Partial,
288        batch: &Columnar,
289        ctx: &mut ExecutionCtx,
290    ) -> VortexResult<()> {
291        // Delegate to the existing min_max implementation for now. A dedicated bounded-max
292        // aggregate would avoid computing min when only max is needed.
293        let array = match batch {
294            Columnar::Canonical(canonical) => canonical.clone().into_array(),
295            Columnar::Constant(constant) => constant.clone().into_array(),
296        };
297        let Some(result) = min_max(&array, ctx, NumericalAggregateOpts::default())? else {
298            return Ok(());
299        };
300        match truncate_max(result.max, args.options.max_bytes.get())? {
301            Some(bound) => partial.merge_bound(bound),
302            None => partial.unknown(),
303        }
304        Ok(())
305    }
306
307    fn finalize(
308        &self,
309        _args: AggregateArgs<'_, Self::Options>,
310        partials: ArrayRef,
311    ) -> VortexResult<ArrayRef> {
312        partials.get_item(BOUNDED_MAX_BOUND)
313    }
314
315    fn finalize_scalar(
316        &self,
317        args: AggregateArgs<'_, Self::Options>,
318        partial: &Self::Partial,
319    ) -> VortexResult<Scalar> {
320        partial.final_scalar(args)
321    }
322}
323
324fn supported_dtype<'a>(_options: &BoundedMaxOptions, input_dtype: &'a DType) -> Option<&'a DType> {
325    MinMax
326        .return_dtype(&NumericalAggregateOpts::default(), input_dtype)
327        .map(|_| input_dtype)
328}
329
330fn truncate_max(value: Scalar, max_bytes: usize) -> VortexResult<Option<Scalar>> {
331    let nullability = value.dtype().nullability();
332    match value.dtype() {
333        DType::Utf8(_) => {
334            Ok(
335                upper_bound(BufferString::from_scalar(value)?, max_bytes, nullability)
336                    .map(|(bound, _)| bound),
337            )
338        }
339        DType::Binary(_) => {
340            Ok(
341                upper_bound(ByteBuffer::from_scalar(value)?, max_bytes, nullability)
342                    .map(|(bound, _)| bound),
343            )
344        }
345        _ => Ok(Some(value)),
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use std::num::NonZeroUsize;
352
353    use vortex_buffer::buffer;
354    use vortex_error::VortexExpect;
355    use vortex_error::VortexResult;
356    use vortex_session::VortexSession;
357
358    use crate::IntoArray;
359    use crate::VortexSessionExecute;
360    use crate::aggregate_fn::Accumulator;
361    use crate::aggregate_fn::AggregateFnSatisfaction;
362    use crate::aggregate_fn::AggregateFnVTable;
363    use crate::aggregate_fn::AggregateFnVTableExt;
364    use crate::aggregate_fn::DynAccumulator;
365    use crate::aggregate_fn::NumericalAggregateOpts;
366    use crate::aggregate_fn::fns::bounded_max::BoundedMax;
367    use crate::aggregate_fn::fns::bounded_max::BoundedMaxOptions;
368    use crate::aggregate_fn::fns::bounded_max::BoundedMaxPartial;
369    use crate::aggregate_fn::fns::bounded_max::BoundedMaxState;
370    use crate::aggregate_fn::fns::max::Max;
371    use crate::aggregate_fn::fns::min::Min;
372    use crate::array_session;
373    use crate::arrays::PrimitiveArray;
374    use crate::arrays::VarBinViewArray;
375    use crate::dtype::Nullability;
376    use crate::scalar::Scalar;
377    use crate::validity::Validity;
378
379    fn max_bytes(value: usize) -> NonZeroUsize {
380        NonZeroUsize::new(value).vortex_expect("non-zero max_bytes")
381    }
382
383    fn fresh_session() -> VortexSession {
384        array_session()
385    }
386
387    #[test]
388    fn bounded_max_truncates_utf8_to_upper_bound() -> VortexResult<()> {
389        let mut ctx = array_session().create_execution_ctx();
390        let array = VarBinViewArray::from_iter_str(["aardvark", "char🪩"]).into_array();
391        let mut acc = Accumulator::try_new(
392            BoundedMax,
393            BoundedMaxOptions {
394                max_bytes: max_bytes(5),
395            },
396            array.dtype().clone(),
397        )?;
398
399        acc.accumulate(&array, &mut ctx)?;
400
401        assert_eq!(acc.finish()?, Scalar::utf8("chas", Nullability::Nullable));
402        Ok(())
403    }
404
405    #[test]
406    fn bounded_max_unknown_upper_bound_returns_null() -> VortexResult<()> {
407        let mut ctx = array_session().create_execution_ctx();
408        let array = VarBinViewArray::from_iter_bin([&[255u8, 255, 255][..]]).into_array();
409        let mut acc = Accumulator::try_new(
410            BoundedMax,
411            BoundedMaxOptions {
412                max_bytes: max_bytes(2),
413            },
414            array.dtype().clone(),
415        )?;
416
417        acc.accumulate(&array, &mut ctx)?;
418
419        assert_eq!(acc.finish()?, Scalar::null(array.dtype().as_nullable()));
420        Ok(())
421    }
422
423    #[test]
424    fn bounded_max_empty_does_not_poison_later_values() -> VortexResult<()> {
425        let mut ctx = array_session().create_execution_ctx();
426        let empty = VarBinViewArray::from_iter_bin(Vec::<&[u8]>::new()).into_array();
427        let values = VarBinViewArray::from_iter_bin([&[1u8][..]]).into_array();
428        let mut acc = Accumulator::try_new(
429            BoundedMax,
430            BoundedMaxOptions {
431                max_bytes: max_bytes(2),
432            },
433            empty.dtype().clone(),
434        )?;
435
436        acc.accumulate(&empty, &mut ctx)?;
437        acc.accumulate(&values, &mut ctx)?;
438
439        assert_eq!(
440            acc.finish()?,
441            Scalar::binary(buffer![1u8], Nullability::Nullable)
442        );
443        Ok(())
444    }
445
446    #[test]
447    fn bounded_max_unknown_poisons_later_values() -> VortexResult<()> {
448        let mut ctx = array_session().create_execution_ctx();
449        let unknown = VarBinViewArray::from_iter_bin([&[255u8, 255, 255][..]]).into_array();
450        let values = VarBinViewArray::from_iter_bin([&[1u8][..]]).into_array();
451        let mut acc = Accumulator::try_new(
452            BoundedMax,
453            BoundedMaxOptions {
454                max_bytes: max_bytes(2),
455            },
456            unknown.dtype().clone(),
457        )?;
458
459        acc.accumulate(&unknown, &mut ctx)?;
460        acc.accumulate(&values, &mut ctx)?;
461
462        assert_eq!(acc.finish()?, Scalar::null(unknown.dtype().as_nullable()));
463        Ok(())
464    }
465
466    #[test]
467    fn bounded_max_empty_partial_does_not_poison_existing_bound() -> VortexResult<()> {
468        let mut ctx = fresh_session().create_execution_ctx();
469        let values = VarBinViewArray::from_iter_bin([&[1u8][..]]).into_array();
470        let mut acc = Accumulator::try_new(
471            BoundedMax,
472            BoundedMaxOptions {
473                max_bytes: max_bytes(2),
474            },
475            values.dtype().clone(),
476        )?;
477
478        acc.accumulate(&values, &mut ctx)?;
479        let empty = acc.empty_partial()?;
480        acc.fold_partial(empty)?;
481
482        assert_eq!(
483            acc.finish()?,
484            Scalar::binary(buffer![1u8], Nullability::Nullable)
485        );
486        Ok(())
487    }
488
489    #[test]
490    fn bounded_max_unknown_partial_poisons_existing_bound() -> VortexResult<()> {
491        let mut ctx = fresh_session().create_execution_ctx();
492        let values = VarBinViewArray::from_iter_bin([&[1u8][..]]).into_array();
493        let mut acc = Accumulator::try_new(
494            BoundedMax,
495            BoundedMaxOptions {
496                max_bytes: max_bytes(2),
497            },
498            values.dtype().clone(),
499        )?;
500
501        acc.accumulate(&values, &mut ctx)?;
502        acc.fold_partial(BoundedMaxPartial {
503            state: BoundedMaxState::Unknown,
504        })?;
505
506        assert_eq!(acc.finish()?, Scalar::null(values.dtype().as_nullable()));
507        Ok(())
508    }
509
510    #[test]
511    fn bounded_max_keeps_fixed_width_values_exact() -> VortexResult<()> {
512        let mut ctx = array_session().create_execution_ctx();
513        let array = PrimitiveArray::new(buffer![10i32, 20, 5], Validity::NonNullable).into_array();
514        let mut acc = Accumulator::try_new(
515            BoundedMax,
516            BoundedMaxOptions {
517                max_bytes: max_bytes(9),
518            },
519            array.dtype().clone(),
520        )?;
521
522        acc.accumulate(&array, &mut ctx)?;
523
524        assert_eq!(
525            acc.finish()?,
526            Scalar::primitive(20i32, Nullability::Nullable)
527        );
528        Ok(())
529    }
530
531    #[test]
532    fn bounded_max_satisfies_max_bounds() {
533        let stored = BoundedMax.bind(BoundedMaxOptions {
534            max_bytes: max_bytes(5),
535        });
536        let same = BoundedMax.bind(BoundedMaxOptions {
537            max_bytes: max_bytes(5),
538        });
539        let looser_bounded = BoundedMax.bind(BoundedMaxOptions {
540            max_bytes: max_bytes(4),
541        });
542        let tighter_bounded = BoundedMax.bind(BoundedMaxOptions {
543            max_bytes: max_bytes(6),
544        });
545
546        assert_eq!(stored.can_satisfy(&same), AggregateFnSatisfaction::Exact);
547        assert_eq!(
548            stored.can_satisfy(&looser_bounded),
549            AggregateFnSatisfaction::Approximate
550        );
551        assert_eq!(
552            stored.can_satisfy(&tighter_bounded),
553            AggregateFnSatisfaction::No
554        );
555        assert_eq!(
556            stored.can_satisfy(&Max.bind(NumericalAggregateOpts::default())),
557            AggregateFnSatisfaction::Approximate
558        );
559        assert_eq!(
560            stored.can_satisfy(&Max.bind(NumericalAggregateOpts::include_nans())),
561            AggregateFnSatisfaction::No
562        );
563        assert_eq!(
564            Max.bind(NumericalAggregateOpts::include_nans())
565                .can_satisfy(&stored),
566            AggregateFnSatisfaction::No
567        );
568        assert_eq!(
569            Max.bind(NumericalAggregateOpts::default())
570                .can_satisfy(&stored),
571            AggregateFnSatisfaction::Approximate
572        );
573        assert_eq!(
574            stored.can_satisfy(&Min.bind(NumericalAggregateOpts::default())),
575            AggregateFnSatisfaction::No
576        );
577    }
578
579    #[test]
580    fn bounded_max_options_round_trip() -> VortexResult<()> {
581        let options = BoundedMaxOptions {
582            max_bytes: max_bytes(64),
583        };
584        let metadata = BoundedMax
585            .serialize(&options)?
586            .expect("serializable options");
587        let roundtrip = BoundedMax.deserialize(&metadata, &VortexSession::empty())?;
588
589        assert_eq!(roundtrip, options);
590        Ok(())
591    }
592}