Skip to main content

vortex_array/aggregate_fn/fns/bounded_min/
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;
7
8use vortex_buffer::BufferString;
9use vortex_buffer::ByteBuffer;
10use vortex_error::VortexExpect;
11use vortex_error::VortexResult;
12use vortex_error::vortex_ensure;
13use vortex_error::vortex_ensure_eq;
14use vortex_session::VortexSession;
15use vortex_session::registry::CachedId;
16
17use crate::ArrayRef;
18use crate::Columnar;
19use crate::ExecutionCtx;
20use crate::IntoArray;
21use crate::aggregate_fn::AggregateArgs;
22use crate::aggregate_fn::AggregateFnId;
23use crate::aggregate_fn::AggregateFnRef;
24use crate::aggregate_fn::AggregateFnSatisfaction;
25use crate::aggregate_fn::AggregateFnVTable;
26use crate::aggregate_fn::NumericalAggregateOpts;
27use crate::aggregate_fn::fns::min::Min;
28use crate::aggregate_fn::fns::min_max::MinMax;
29use crate::aggregate_fn::fns::min_max::min_max;
30use crate::dtype::DType;
31use crate::partial_ord::partial_min;
32use crate::scalar::Scalar;
33use crate::scalar::ScalarTruncation;
34use crate::scalar::lower_bound;
35
36/// Options for [`BoundedMin`].
37#[derive(Clone, Debug, PartialEq, Eq, Hash)]
38pub struct BoundedMinOptions {
39    /// Maximum byte length for UTF8/Binary bounds.
40    pub max_bytes: NonZeroUsize,
41}
42
43impl Display for BoundedMinOptions {
44    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
45        write!(f, "{}", self.max_bytes.get())
46    }
47}
48
49/// Compute a byte-bounded lower bound for the minimum non-null value of a UTF8/Binary array.
50#[derive(Clone, Debug)]
51pub struct BoundedMin;
52
53enum BoundedMinState {
54    Empty,
55    Value(Scalar),
56}
57
58/// Partial accumulator state for the bounded minimum aggregate.
59pub struct BoundedMinPartial {
60    state: BoundedMinState,
61}
62
63impl BoundedMinPartial {
64    fn merge(&mut self, min: Scalar) {
65        if min.is_null() {
66            return;
67        }
68
69        self.state = match std::mem::replace(&mut self.state, BoundedMinState::Empty) {
70            BoundedMinState::Empty => BoundedMinState::Value(min),
71            BoundedMinState::Value(current) => BoundedMinState::Value(
72                partial_min(min, current).vortex_expect("incomparable bounded min scalars"),
73            ),
74        };
75    }
76}
77
78impl AggregateFnVTable for BoundedMin {
79    type Options = BoundedMinOptions;
80    type Partial = BoundedMinPartial;
81
82    fn id(&self) -> AggregateFnId {
83        static ID: CachedId = CachedId::new("vortex.bounded_min");
84        *ID
85    }
86
87    fn serialize(&self, options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
88        let max_bytes = u64::try_from(options.max_bytes.get())?;
89        Ok(Some(max_bytes.to_le_bytes().to_vec()))
90    }
91
92    fn deserialize(
93        &self,
94        metadata: &[u8],
95        _session: &VortexSession,
96    ) -> VortexResult<Self::Options> {
97        vortex_ensure_eq!(
98            metadata.len(),
99            size_of::<u64>(),
100            "BoundedMin options have the wrong byte length"
101        );
102        let mut bytes = [0u8; size_of::<u64>()];
103        bytes.copy_from_slice(metadata);
104        let max_bytes = usize::try_from(u64::from_le_bytes(bytes))?;
105        vortex_ensure!(max_bytes > 0, "BoundedMin requires max_bytes > 0");
106        Ok(BoundedMinOptions {
107            max_bytes: NonZeroUsize::new(max_bytes).vortex_expect("checked non-zero max_bytes"),
108        })
109    }
110
111    fn return_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
112        supported_dtype(options, input_dtype).map(DType::as_nullable)
113    }
114
115    fn can_satisfy(
116        &self,
117        options: &Self::Options,
118        requested: &AggregateFnRef,
119    ) -> AggregateFnSatisfaction {
120        if let Some(other) = requested.as_opt::<Self>() {
121            return if other == options {
122                AggregateFnSatisfaction::Exact
123            } else if options.max_bytes >= other.max_bytes {
124                AggregateFnSatisfaction::Approximate
125            } else {
126                AggregateFnSatisfaction::No
127            };
128        }
129
130        // The stored bound skips NaNs, so it cannot stand in for a NaN-including minimum.
131        if requested
132            .as_opt::<Min>()
133            .is_some_and(|options| options.skip_nans)
134        {
135            AggregateFnSatisfaction::Approximate
136        } else {
137            AggregateFnSatisfaction::No
138        }
139    }
140
141    fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
142        self.return_dtype(options, input_dtype)
143    }
144
145    fn empty_partial(
146        &self,
147        _args: AggregateArgs<'_, Self::Options>,
148    ) -> VortexResult<Self::Partial> {
149        Ok(BoundedMinPartial {
150            state: BoundedMinState::Empty,
151        })
152    }
153
154    fn partial_from_scalar(
155        &self,
156        _args: AggregateArgs<'_, Self::Options>,
157        scalar: &Scalar,
158    ) -> VortexResult<Self::Partial> {
159        // A null partial means the producing accumulator saw nothing valid.
160        let state = if scalar.is_null() {
161            BoundedMinState::Empty
162        } else {
163            BoundedMinState::Value(scalar.clone())
164        };
165        Ok(BoundedMinPartial { state })
166    }
167
168    fn merge_partials(
169        &self,
170        _args: AggregateArgs<'_, Self::Options>,
171        mut first: Self::Partial,
172        second: Self::Partial,
173    ) -> VortexResult<Self::Partial> {
174        if let BoundedMinState::Value(min) = second.state {
175            first.merge(min);
176        }
177        Ok(first)
178    }
179
180    fn to_scalar(
181        &self,
182        args: AggregateArgs<'_, Self::Options>,
183        partial: &Self::Partial,
184    ) -> VortexResult<Scalar> {
185        let dtype = args.dtype.as_nullable();
186        match &partial.state {
187            BoundedMinState::Empty => Ok(Scalar::null(dtype)),
188            BoundedMinState::Value(min) => min.cast(&dtype),
189        }
190    }
191
192    fn is_saturated(
193        &self,
194        _args: AggregateArgs<'_, Self::Options>,
195        _partial: &Self::Partial,
196    ) -> bool {
197        false
198    }
199
200    fn accumulate(
201        &self,
202        args: AggregateArgs<'_, Self::Options>,
203        partial: &mut Self::Partial,
204        batch: &Columnar,
205        ctx: &mut ExecutionCtx,
206    ) -> VortexResult<()> {
207        // Delegate to the existing min_max implementation for now. A dedicated bounded-min
208        // aggregate would avoid computing max when only min is needed.
209        let array = match batch {
210            Columnar::Canonical(canonical) => canonical.clone().into_array(),
211            Columnar::Constant(constant) => constant.clone().into_array(),
212        };
213        let Some(result) = min_max(&array, ctx, NumericalAggregateOpts::default())? else {
214            return Ok(());
215        };
216        if let Some(bound) = truncate_min(result.min, args.options.max_bytes.get())? {
217            partial.merge(bound);
218        }
219        Ok(())
220    }
221
222    fn finalize(
223        &self,
224        _args: AggregateArgs<'_, Self::Options>,
225        partials: ArrayRef,
226    ) -> VortexResult<ArrayRef> {
227        Ok(partials)
228    }
229
230    fn finalize_scalar(
231        &self,
232        args: AggregateArgs<'_, Self::Options>,
233        partial: &Self::Partial,
234    ) -> VortexResult<Scalar> {
235        self.to_scalar(args, partial)
236    }
237}
238
239fn supported_dtype<'a>(_options: &BoundedMinOptions, input_dtype: &'a DType) -> Option<&'a DType> {
240    MinMax
241        .return_dtype(&NumericalAggregateOpts::default(), input_dtype)
242        .map(|_| input_dtype)
243}
244
245fn truncate_min(value: Scalar, max_bytes: usize) -> VortexResult<Option<Scalar>> {
246    let nullability = value.dtype().nullability();
247    match value.dtype() {
248        DType::Utf8(_) => {
249            Ok(
250                lower_bound(BufferString::from_scalar(value)?, max_bytes, nullability)
251                    .map(|(bound, _)| bound),
252            )
253        }
254        DType::Binary(_) => {
255            Ok(
256                lower_bound(ByteBuffer::from_scalar(value)?, max_bytes, nullability)
257                    .map(|(bound, _)| bound),
258            )
259        }
260        _ => Ok(Some(value)),
261    }
262}
263#[cfg(test)]
264mod tests {
265    use std::num::NonZeroUsize;
266
267    use vortex_buffer::buffer;
268    use vortex_error::VortexExpect;
269    use vortex_error::VortexResult;
270    use vortex_session::VortexSession;
271
272    use crate::IntoArray;
273    use crate::VortexSessionExecute;
274    use crate::aggregate_fn::Accumulator;
275    use crate::aggregate_fn::AggregateFnSatisfaction;
276    use crate::aggregate_fn::AggregateFnVTable;
277    use crate::aggregate_fn::AggregateFnVTableExt;
278    use crate::aggregate_fn::DynAccumulator;
279    use crate::aggregate_fn::NumericalAggregateOpts;
280    use crate::aggregate_fn::fns::bounded_min::BoundedMin;
281    use crate::aggregate_fn::fns::bounded_min::BoundedMinOptions;
282    use crate::aggregate_fn::fns::max::Max;
283    use crate::aggregate_fn::fns::min::Min;
284    use crate::array_session;
285    use crate::arrays::PrimitiveArray;
286    use crate::arrays::VarBinViewArray;
287    use crate::dtype::Nullability;
288    use crate::scalar::Scalar;
289    use crate::validity::Validity;
290
291    fn max_bytes(value: usize) -> NonZeroUsize {
292        NonZeroUsize::new(value).vortex_expect("non-zero max_bytes")
293    }
294
295    fn fresh_session() -> VortexSession {
296        array_session()
297    }
298
299    #[test]
300    fn bounded_min_truncates_utf8_to_lower_bound() -> VortexResult<()> {
301        let mut ctx = array_session().create_execution_ctx();
302        let array =
303            VarBinViewArray::from_iter_str(["snowman⛄️snowman", "untruncated"]).into_array();
304        let mut acc = Accumulator::try_new(
305            BoundedMin,
306            BoundedMinOptions {
307                max_bytes: max_bytes(9),
308            },
309            array.dtype().clone(),
310        )?;
311
312        acc.accumulate(&array, &mut ctx)?;
313
314        assert_eq!(
315            acc.finish()?,
316            Scalar::utf8("snowman", Nullability::Nullable)
317        );
318        Ok(())
319    }
320
321    #[test]
322    fn bounded_min_keeps_fixed_width_values_exact() -> VortexResult<()> {
323        let mut ctx = array_session().create_execution_ctx();
324        let array = PrimitiveArray::new(buffer![10i32, 20, 5], Validity::NonNullable).into_array();
325        let mut acc = Accumulator::try_new(
326            BoundedMin,
327            BoundedMinOptions {
328                max_bytes: max_bytes(9),
329            },
330            array.dtype().clone(),
331        )?;
332
333        acc.accumulate(&array, &mut ctx)?;
334
335        assert_eq!(
336            acc.finish()?,
337            Scalar::primitive(5i32, Nullability::Nullable)
338        );
339        Ok(())
340    }
341
342    #[test]
343    fn bounded_min_null_partial_does_not_poison_existing_bound() -> VortexResult<()> {
344        let mut ctx = fresh_session().create_execution_ctx();
345        let values = VarBinViewArray::from_iter_bin([&[1u8][..]]).into_array();
346        let mut acc = Accumulator::try_new(
347            BoundedMin,
348            BoundedMinOptions {
349                max_bytes: max_bytes(2),
350            },
351            values.dtype().clone(),
352        )?;
353
354        acc.accumulate(&values, &mut ctx)?;
355        let empty = acc.empty_partial()?;
356        acc.fold_partial(empty)?;
357
358        assert_eq!(
359            acc.finish()?,
360            Scalar::binary(buffer![1u8], Nullability::Nullable)
361        );
362        Ok(())
363    }
364
365    #[test]
366    fn bounded_min_satisfies_min_bounds() {
367        let stored = BoundedMin.bind(BoundedMinOptions {
368            max_bytes: max_bytes(5),
369        });
370        let same = BoundedMin.bind(BoundedMinOptions {
371            max_bytes: max_bytes(5),
372        });
373        let looser_bounded = BoundedMin.bind(BoundedMinOptions {
374            max_bytes: max_bytes(4),
375        });
376        let tighter_bounded = BoundedMin.bind(BoundedMinOptions {
377            max_bytes: max_bytes(6),
378        });
379
380        assert_eq!(stored.can_satisfy(&same), AggregateFnSatisfaction::Exact);
381        assert_eq!(
382            stored.can_satisfy(&looser_bounded),
383            AggregateFnSatisfaction::Approximate
384        );
385        assert_eq!(
386            stored.can_satisfy(&tighter_bounded),
387            AggregateFnSatisfaction::No
388        );
389        assert_eq!(
390            stored.can_satisfy(&Min.bind(NumericalAggregateOpts::default())),
391            AggregateFnSatisfaction::Approximate
392        );
393        assert_eq!(
394            stored.can_satisfy(&Min.bind(NumericalAggregateOpts::include_nans())),
395            AggregateFnSatisfaction::No
396        );
397        assert_eq!(
398            Min.bind(NumericalAggregateOpts::include_nans())
399                .can_satisfy(&stored),
400            AggregateFnSatisfaction::No
401        );
402        assert_eq!(
403            Min.bind(NumericalAggregateOpts::default())
404                .can_satisfy(&stored),
405            AggregateFnSatisfaction::Approximate
406        );
407        assert_eq!(
408            stored.can_satisfy(&Max.bind(NumericalAggregateOpts::default())),
409            AggregateFnSatisfaction::No
410        );
411    }
412
413    #[test]
414    fn bounded_min_options_round_trip() -> VortexResult<()> {
415        let options = BoundedMinOptions {
416            max_bytes: max_bytes(64),
417        };
418        let metadata = BoundedMin
419            .serialize(&options)?
420            .expect("serializable options");
421        let roundtrip = BoundedMin.deserialize(&metadata, &VortexSession::empty())?;
422
423        assert_eq!(roundtrip, options);
424        Ok(())
425    }
426}