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_session::VortexSession;
16use vortex_session::registry::CachedId;
17
18use crate::ArrayRef;
19use crate::Columnar;
20use crate::ExecutionCtx;
21use crate::IntoArray;
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::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!(
143 metadata.len() == size_of::<u64>(),
144 "BoundedMax options expected {} bytes, got {}",
145 size_of::<u64>(),
146 metadata.len()
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 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 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 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}