1use 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
41pub const BOUNDED_MAX_BOUND: &str = "bound";
43pub const BOUNDED_MAX_UNKNOWN: &str = "unknown";
45
46static NAMES: LazyLock<FieldNames> =
47 LazyLock::new(|| FieldNames::from([BOUNDED_MAX_BOUND, BOUNDED_MAX_UNKNOWN]));
48
49#[derive(Clone, Debug, PartialEq, Eq, Hash)]
51pub struct BoundedMaxOptions {
52 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#[derive(Clone, Debug)]
64pub struct BoundedMax;
65
66enum BoundedMaxState {
67 Empty,
68 Value(Scalar),
69 Unknown,
70}
71
72pub 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
105pub 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 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 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 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}