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