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