Skip to main content

vortex_array/aggregate_fn/fns/is_constant/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4mod bool;
5mod decimal;
6mod extension;
7mod fixed_size_list;
8mod list;
9mod map;
10pub mod primitive;
11mod struct_;
12mod varbin;
13
14use vortex_error::VortexExpect;
15use vortex_error::VortexResult;
16use vortex_error::vortex_bail;
17use vortex_mask::Mask;
18use vortex_session::registry::CachedId;
19
20use self::bool::check_bool_constant;
21use self::decimal::check_decimal_constant;
22use self::extension::check_extension_constant;
23use self::fixed_size_list::check_fixed_size_list_constant;
24use self::list::check_listview_constant;
25use self::map::check_map_constant;
26use self::primitive::check_primitive_constant;
27use self::struct_::check_struct_constant;
28use self::varbin::check_varbinview_constant;
29use crate::ArrayRef;
30use crate::Canonical;
31use crate::Columnar;
32use crate::ExecutionCtx;
33use crate::IntoArray;
34use crate::aggregate_fn::Accumulator;
35use crate::aggregate_fn::AggregateArgs;
36use crate::aggregate_fn::AggregateFnId;
37use crate::aggregate_fn::AggregateFnVTable;
38use crate::aggregate_fn::DynAccumulator;
39use crate::aggregate_fn::EmptyOptions;
40use crate::arrays::Constant;
41use crate::arrays::Null;
42use crate::builtins::ArrayBuiltins;
43use crate::dtype::DType;
44use crate::dtype::FieldNames;
45use crate::dtype::Nullability;
46use crate::dtype::StructFields;
47use crate::expr::stats::Precision;
48use crate::expr::stats::Stat;
49use crate::expr::stats::StatsProvider;
50use crate::expr::stats::StatsProviderExt;
51use crate::scalar::Scalar;
52use crate::scalar_fn::fns::operators::Operator;
53
54/// Check if two arrays of the same length have equal values at every position (null-safe).
55///
56/// Two positions are considered equal if they are both null, or both non-null with the same value.
57// TODO(ngates): move this function out when we have any/all aggregate functions.
58fn arrays_value_equal(a: &ArrayRef, b: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<bool> {
59    debug_assert_eq!(a.len(), b.len());
60    if a.is_empty() {
61        return Ok(true);
62    }
63
64    // Check validity masks match (null positions must be identical).
65    let a_mask = a.validity()?.execute_mask(a.len(), ctx)?;
66    let b_mask = b.validity()?.execute_mask(b.len(), ctx)?;
67    if a_mask != b_mask {
68        return Ok(false);
69    }
70
71    let valid_count = a_mask.true_count();
72    if valid_count == 0 {
73        // Both all-null → equal.
74        return Ok(true);
75    }
76
77    // Compare values element-wise. Result is null where both inputs are null,
78    // true/false where both are valid.
79    let eq_result = a.binary(b.clone(), Operator::Eq)?;
80    let eq_result = eq_result.fill_null(false)?.execute::<Mask>(ctx)?;
81
82    Ok(eq_result.true_count() == valid_count)
83}
84
85/// Compute whether an array has constant values.
86///
87/// An array is constant IFF at least one of the following conditions apply:
88/// 1. It has at least one element (**Note** - an empty array isn't constant).
89/// 2. It's encoded as a [`ConstantArray`](crate::arrays::ConstantArray) or [`NullArray`](crate::arrays::NullArray)
90/// 3. Has an exact statistic attached to it, saying its constant.
91/// 4. Is all invalid.
92/// 5. Is all valid AND has minimum and maximum statistics that are equal.
93pub fn is_constant(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<bool> {
94    // Short-circuit using cached array statistics.
95    if let Precision::Exact(value) = array.statistics().get_as::<bool>(Stat::IsConstant) {
96        return Ok(value);
97    }
98
99    // Empty arrays are not constant.
100    if array.is_empty() {
101        return Ok(false);
102    }
103
104    // Array of length 1 is always constant.
105    if array.len() == 1 {
106        array
107            .statistics()
108            .set(Stat::IsConstant, Precision::Exact(true.into()));
109        return Ok(true);
110    }
111
112    // Constant and null arrays are always constant.
113    if array.is::<Constant>() || array.is::<Null>() {
114        array
115            .statistics()
116            .set(Stat::IsConstant, Precision::Exact(true.into()));
117        return Ok(true);
118    }
119
120    let all_invalid = array.all_invalid(ctx)?;
121    if all_invalid {
122        array
123            .statistics()
124            .set(Stat::IsConstant, Precision::Exact(true.into()));
125        return Ok(true);
126    }
127
128    let all_valid = array.all_valid(ctx)?;
129
130    // If we have some nulls but not all nulls, array can't be constant.
131    if !all_valid && !all_invalid {
132        array
133            .statistics()
134            .set(Stat::IsConstant, Precision::Exact(false.into()));
135        return Ok(false);
136    }
137
138    // We already know here that the array is all valid, so we check for min/max stats.
139    let min_stat = array.statistics().get(Stat::Min);
140    let max_stat = array.statistics().get(Stat::Max);
141
142    if let Precision::Exact(min) = min_stat.as_ref()
143        && let Precision::Exact(max) = max_stat.as_ref()
144        && min == max
145        && (Stat::NaNCount.dtype(array.dtype()).is_none()
146            || array.statistics().get_as::<u64>(Stat::NaNCount) == Precision::exact(0u64))
147    {
148        array
149            .statistics()
150            .set(Stat::IsConstant, Precision::Exact(true.into()));
151        return Ok(true);
152    }
153
154    // Short-circuit for unsupported dtypes.
155    if IsConstant
156        .return_dtype(&EmptyOptions, array.dtype())
157        .is_none()
158    {
159        // Null dtype - vacuously false for empty
160        return Ok(false);
161    }
162
163    // Compute using Accumulator<IsConstant>.
164    let mut acc = Accumulator::try_new(IsConstant, EmptyOptions, array.dtype().clone())?;
165    acc.accumulate(array, ctx)?;
166    let result_scalar = acc.finish()?;
167
168    let result = result_scalar.as_bool().value().unwrap_or(false);
169
170    // Cache the computed is_constant as a statistic.
171    array
172        .statistics()
173        .set(Stat::IsConstant, Precision::Exact(result.into()));
174
175    Ok(result)
176}
177
178/// Compute whether an array is constant.
179///
180/// Returns a `Bool(NonNullable)` scalar.
181/// The partial state is a nullable struct `{is_constant: Bool(NN), value: input_dtype?}`.
182/// A null struct means the accumulator has seen no data yet (empty).
183#[derive(Clone, Debug)]
184pub struct IsConstant;
185
186impl IsConstant {
187    /// Build a partial scalar from a kernel's `is_constant` result.
188    ///
189    /// Kernels that compute `is_constant` by delegating to child arrays can call this
190    /// to package the boolean result into the partial struct format expected by the
191    /// accumulator, avoiding duplicated boilerplate.
192    pub fn make_partial(
193        batch: &ArrayRef,
194        is_constant: bool,
195        ctx: &mut ExecutionCtx,
196    ) -> VortexResult<Scalar> {
197        let partial_dtype = make_is_constant_partial_dtype(batch.dtype());
198        if is_constant {
199            if batch.is_empty() {
200                return Ok(Scalar::null(partial_dtype));
201            }
202            let first_value = batch.execute_scalar(0, ctx)?.into_nullable();
203            Ok(Scalar::struct_(
204                partial_dtype,
205                vec![Scalar::bool(true, Nullability::NonNullable), first_value],
206            ))
207        } else {
208            Ok(Scalar::struct_(
209                partial_dtype,
210                vec![
211                    Scalar::bool(false, Nullability::NonNullable),
212                    Scalar::null(batch.dtype().as_nullable()),
213                ],
214            ))
215        }
216    }
217}
218
219/// Partial accumulator state for is_constant.
220pub struct IsConstantPartial {
221    is_constant: bool,
222    /// None = empty (no values seen), Some(null) = all nulls, Some(v) = first value seen.
223    first_value: Option<Scalar>,
224}
225
226impl IsConstantPartial {
227    /// The state of a group with no accumulated values.
228    fn empty() -> Self {
229        Self {
230            is_constant: true,
231            first_value: None,
232        }
233    }
234
235    fn check_value(&mut self, value: Scalar) {
236        if !self.is_constant {
237            return;
238        }
239        match &self.first_value {
240            None => {
241                self.first_value = Some(value);
242            }
243            Some(first) => {
244                if *first != value {
245                    self.is_constant = false;
246                }
247            }
248        }
249    }
250}
251
252static NAMES: std::sync::LazyLock<FieldNames> =
253    std::sync::LazyLock::new(|| FieldNames::from(["is_constant", "value"]));
254
255pub fn make_is_constant_partial_dtype(element_dtype: &DType) -> DType {
256    DType::Struct(
257        StructFields::new(
258            NAMES.clone(),
259            vec![
260                DType::Bool(Nullability::NonNullable),
261                element_dtype.as_nullable(),
262            ],
263        ),
264        Nullability::Nullable,
265    )
266}
267
268impl AggregateFnVTable for IsConstant {
269    type Options = EmptyOptions;
270    type Partial = IsConstantPartial;
271
272    fn id(&self) -> AggregateFnId {
273        static ID: CachedId = CachedId::new("vortex.is_constant");
274        *ID
275    }
276
277    fn serialize(&self, _options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
278        vortex_bail!("IsConstant is not yet serializable");
279    }
280
281    fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option<DType> {
282        match input_dtype {
283            DType::Null | DType::Variant(..) => None,
284            _ => Some(DType::Bool(Nullability::NonNullable)),
285        }
286    }
287
288    fn partial_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option<DType> {
289        match input_dtype {
290            DType::Null | DType::Variant(..) => None,
291            _ => Some(make_is_constant_partial_dtype(input_dtype)),
292        }
293    }
294
295    fn empty_partial(
296        &self,
297        _args: AggregateArgs<'_, Self::Options>,
298    ) -> VortexResult<Self::Partial> {
299        Ok(IsConstantPartial::empty())
300    }
301
302    fn partial_from_scalar(
303        &self,
304        _args: AggregateArgs<'_, Self::Options>,
305        scalar: &Scalar,
306    ) -> VortexResult<Self::Partial> {
307        // A null struct means the producing accumulator was empty.
308        if scalar.is_null() {
309            return Ok(IsConstantPartial::empty());
310        }
311
312        let is_constant = scalar
313            .as_struct()
314            .field_by_idx(0)
315            .map(|s| s.as_bool().value().unwrap_or(false))
316            .unwrap_or(false);
317
318        Ok(IsConstantPartial {
319            is_constant,
320            first_value: scalar.as_struct().field_by_idx(1),
321        })
322    }
323
324    fn merge_partials(
325        &self,
326        _args: AggregateArgs<'_, Self::Options>,
327        mut acc: Self::Partial,
328        partial: Self::Partial,
329    ) -> VortexResult<Self::Partial> {
330        if !partial.is_constant {
331            acc.is_constant = false;
332        } else if let Some(value) = partial.first_value {
333            acc.check_value(value);
334        }
335        Ok(acc)
336    }
337
338    fn to_scalar(
339        &self,
340        args: AggregateArgs<'_, Self::Options>,
341        partial: &Self::Partial,
342    ) -> VortexResult<Scalar> {
343        let dtype = args.partial_dtype.clone();
344        let element_dtype = args.dtype.as_nullable();
345        // Only a constant partial that saw no values is the empty (null) state: a non-constant
346        // verdict stands regardless of whether a value was observed.
347        let first_value = match &partial.first_value {
348            Some(first_value) => first_value.clone().cast(&element_dtype)?,
349            None if partial.is_constant => return Ok(Scalar::null(dtype)),
350            None => Scalar::null(element_dtype),
351        };
352        Ok(Scalar::struct_(
353            dtype,
354            vec![
355                Scalar::bool(partial.is_constant, Nullability::NonNullable),
356                first_value,
357            ],
358        ))
359    }
360
361    #[inline]
362    fn is_saturated(
363        &self,
364        _args: AggregateArgs<'_, Self::Options>,
365        partial: &Self::Partial,
366    ) -> bool {
367        !partial.is_constant
368    }
369
370    fn accumulate(
371        &self,
372        args: AggregateArgs<'_, Self::Options>,
373        partial: &mut Self::Partial,
374        batch: &Columnar,
375        ctx: &mut ExecutionCtx,
376    ) -> VortexResult<()> {
377        if !partial.is_constant {
378            return Ok(());
379        }
380
381        match batch {
382            Columnar::Constant(c) => {
383                if c.is_empty() {
384                    return Ok(());
385                }
386
387                partial.check_value(c.scalar().clone().into_nullable());
388                Ok(())
389            }
390            Columnar::Canonical(c) => {
391                if c.is_empty() {
392                    return Ok(());
393                }
394
395                // Convert to ArrayRef for DynArrayData methods.
396                let array_ref = c.clone().into_array();
397
398                let all_invalid = array_ref.all_invalid(ctx)?;
399                if all_invalid {
400                    partial.check_value(Scalar::null(args.dtype.as_nullable()));
401                    return Ok(());
402                }
403
404                let all_valid = array_ref.all_valid(ctx)?;
405                // Mixed nulls → not constant.
406                if !all_valid && !all_invalid {
407                    partial.is_constant = false;
408                    return Ok(());
409                }
410
411                // All valid from here. Check batch-level constancy.
412                if c.len() == 1 {
413                    partial.check_value(array_ref.execute_scalar(0, ctx)?.into_nullable());
414                    return Ok(());
415                }
416
417                let batch_is_constant = match c {
418                    Canonical::Primitive(p) => check_primitive_constant(p),
419                    Canonical::Bool(b) => check_bool_constant(b),
420                    Canonical::VarBinView(v) => check_varbinview_constant(v),
421                    Canonical::Decimal(d) => check_decimal_constant(d),
422                    Canonical::Struct(s) => check_struct_constant(s, ctx)?,
423                    Canonical::Extension(e) => check_extension_constant(e, ctx)?,
424                    Canonical::List(l) => check_listview_constant(l, ctx)?,
425                    Canonical::Map(m) => check_map_constant(m, ctx)?,
426                    Canonical::FixedSizeList(f) => check_fixed_size_list_constant(f, ctx)?,
427                    Canonical::Null(_) => true,
428                    Canonical::Union(_) => {
429                        vortex_bail!("TODO(connor)[Union]: implement IsConstant for Union arrays")
430                    }
431                    Canonical::Variant(_) => {
432                        vortex_bail!("Variant arrays don't support IsConstant")
433                    }
434                };
435
436                if !batch_is_constant {
437                    partial.is_constant = false;
438                    return Ok(());
439                }
440
441                partial.check_value(array_ref.execute_scalar(0, ctx)?.into_nullable());
442                Ok(())
443            }
444        }
445    }
446
447    fn finalize(
448        &self,
449        _args: AggregateArgs<'_, Self::Options>,
450        partials: ArrayRef,
451    ) -> VortexResult<ArrayRef> {
452        partials.get_item(NAMES.get(0).vortex_expect("out of bounds").clone())
453    }
454
455    fn finalize_scalar(
456        &self,
457        _args: AggregateArgs<'_, Self::Options>,
458        partial: &Self::Partial,
459    ) -> VortexResult<Scalar> {
460        if partial.first_value.is_none() {
461            // Empty accumulator → return false.
462            return Ok(Scalar::bool(false, Nullability::NonNullable));
463        }
464        Ok(Scalar::bool(partial.is_constant, Nullability::NonNullable))
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use rstest::rstest;
471    use vortex_buffer::Buffer;
472    use vortex_buffer::buffer;
473    use vortex_error::VortexResult;
474
475    use crate::IntoArray as _;
476    use crate::VortexSessionExecute;
477    use crate::aggregate_fn::Accumulator;
478    use crate::aggregate_fn::AggregateDTypes;
479    use crate::aggregate_fn::AggregateFnVTable;
480    use crate::aggregate_fn::DynAccumulator;
481    use crate::aggregate_fn::EmptyOptions;
482    use crate::aggregate_fn::fns::is_constant::IsConstant;
483    use crate::aggregate_fn::fns::is_constant::IsConstantPartial;
484    use crate::aggregate_fn::fns::is_constant::is_constant;
485    use crate::array_session;
486    use crate::arrays::BoolArray;
487    use crate::arrays::ChunkedArray;
488    use crate::arrays::ConstantArray;
489    use crate::arrays::DecimalArray;
490    use crate::arrays::ListArray;
491    use crate::arrays::PrimitiveArray;
492    use crate::arrays::StructArray;
493    use crate::builders::MapBuilder;
494    use crate::dtype::DType;
495    use crate::dtype::DecimalDType;
496    use crate::dtype::FieldNames;
497    use crate::dtype::MapDType;
498    use crate::dtype::Nullability;
499    use crate::dtype::PType;
500    use crate::expr::stats::Stat;
501    use crate::scalar::Scalar;
502    use crate::validity::Validity;
503
504    type MapEntryFixture<'a> = (i32, Option<&'a str>);
505    type MapRowFixture<'a> = Option<Vec<MapEntryFixture<'a>>>;
506
507    fn map_array_from_rows(rows: &[MapRowFixture<'_>]) -> VortexResult<crate::ArrayRef> {
508        let map_dtype = MapDType::try_new(
509            DType::Primitive(PType::I32, Nullability::NonNullable),
510            DType::Utf8(Nullability::Nullable),
511            false,
512        )?;
513        let dtype = DType::Map(map_dtype.clone(), Nullability::Nullable);
514        let mut builder = MapBuilder::<u64, u64>::with_capacity_in(
515            map_dtype,
516            Nullability::Nullable,
517            rows.len(),
518            vortex_buffer::BufferAllocatorRef::static_ref(),
519        );
520
521        for row in rows {
522            let scalar = match row {
523                Some(entries) => {
524                    let entries = entries
525                        .iter()
526                        .map(|(key, value)| {
527                            let key = Scalar::primitive(*key, Nullability::NonNullable);
528                            let value = value.map_or_else(
529                                || Scalar::null(DType::Utf8(Nullability::Nullable)),
530                                |value| Scalar::utf8(value, Nullability::Nullable),
531                            );
532                            (key, value)
533                        })
534                        .collect::<Vec<_>>();
535                    Scalar::try_map(dtype.clone(), entries)?
536                }
537                None => Scalar::null(dtype.clone()),
538            };
539            builder.append_value(scalar.as_map())?;
540        }
541
542        Ok(builder.finish_into_map().into_array())
543    }
544
545    // Tests migrated from compute/is_constant.rs
546    #[test]
547    fn is_constant_min_max_no_nan() -> VortexResult<()> {
548        let mut ctx = array_session().create_execution_ctx();
549
550        let arr = buffer![0, 1].into_array();
551        arr.statistics()
552            .compute_all(&[Stat::Min, Stat::Max], &mut ctx)?;
553        assert!(!is_constant(&arr, &mut ctx)?);
554
555        let arr = buffer![0, 0].into_array();
556        arr.statistics()
557            .compute_all(&[Stat::Min, Stat::Max], &mut ctx)?;
558        assert!(is_constant(&arr, &mut ctx)?);
559
560        let arr = PrimitiveArray::from_option_iter([Some(0), Some(0)]).into_array();
561        assert!(is_constant(&arr, &mut ctx)?);
562        Ok(())
563    }
564
565    #[test]
566    fn is_constant_min_max_with_nan() -> VortexResult<()> {
567        let mut ctx = array_session().create_execution_ctx();
568
569        let arr = PrimitiveArray::from_iter([0.0, 0.0, f32::NAN]).into_array();
570        arr.statistics()
571            .compute_all(&[Stat::Min, Stat::Max], &mut ctx)?;
572        assert!(!is_constant(&arr, &mut ctx)?);
573
574        let arr =
575            PrimitiveArray::from_option_iter([Some(f32::NEG_INFINITY), Some(f32::NEG_INFINITY)])
576                .into_array();
577        arr.statistics()
578            .compute_all(&[Stat::Min, Stat::Max], &mut ctx)?;
579        assert!(is_constant(&arr, &mut ctx)?);
580        Ok(())
581    }
582
583    // Tests migrated from arrays/bool/compute/is_constant.rs
584    #[rstest]
585    #[case(vec![true], true)]
586    #[case(vec![false; 65], true)]
587    #[case({
588        let mut v = vec![true; 64];
589        v.push(false);
590        v
591    }, false)]
592    fn test_bool_is_constant(#[case] input: Vec<bool>, #[case] expected: bool) -> VortexResult<()> {
593        let array = BoolArray::from_iter(input);
594        let mut ctx = array_session().create_execution_ctx();
595        assert_eq!(is_constant(&array.into_array(), &mut ctx)?, expected);
596        Ok(())
597    }
598
599    // Tests migrated from arrays/chunked/compute/is_constant.rs
600    #[test]
601    fn empty_chunk_is_constant() -> VortexResult<()> {
602        let chunked = ChunkedArray::try_new(
603            vec![
604                Buffer::<u8>::empty().into_array(),
605                Buffer::<u8>::empty().into_array(),
606                buffer![255u8, 255].into_array(),
607                Buffer::<u8>::empty().into_array(),
608                buffer![255u8, 255].into_array(),
609            ],
610            DType::Primitive(PType::U8, Nullability::NonNullable),
611        )?
612        .into_array();
613
614        let mut ctx = array_session().create_execution_ctx();
615        assert!(is_constant(&chunked, &mut ctx)?);
616        Ok(())
617    }
618
619    // Tests migrated from arrays/decimal/compute/is_constant.rs
620    #[test]
621    fn test_decimal_is_constant() -> VortexResult<()> {
622        let mut ctx = array_session().create_execution_ctx();
623
624        let array = DecimalArray::new(
625            buffer![0i128, 1i128, 2i128],
626            DecimalDType::new(19, 0),
627            Validity::NonNullable,
628        );
629        assert!(!is_constant(&array.into_array(), &mut ctx)?);
630
631        let array = DecimalArray::new(
632            buffer![100i128, 100i128, 100i128],
633            DecimalDType::new(19, 0),
634            Validity::NonNullable,
635        );
636        assert!(is_constant(&array.into_array(), &mut ctx)?);
637        Ok(())
638    }
639
640    // Tests migrated from arrays/list/compute/is_constant.rs
641    #[test]
642    fn test_is_constant_nested_list() -> VortexResult<()> {
643        let mut ctx = array_session().create_execution_ctx();
644
645        let xs = ListArray::try_new(
646            buffer![0i32, 1, 0, 1].into_array(),
647            buffer![0u32, 2, 4].into_array(),
648            Validity::NonNullable,
649        )?;
650
651        let struct_of_lists = StructArray::try_new(
652            FieldNames::from(["xs"]),
653            vec![xs.into_array()],
654            2,
655            Validity::NonNullable,
656        )?;
657        assert!(is_constant(
658            &struct_of_lists.clone().into_array(),
659            &mut ctx
660        )?);
661        assert!(is_constant(&struct_of_lists.into_array(), &mut ctx)?);
662        Ok(())
663    }
664
665    #[rstest]
666    #[case(
667        // [1,2], [1, 2], [1, 2]
668        vec![1i32, 2, 1, 2, 1, 2],
669        vec![0u32, 2, 4, 6],
670        true
671    )]
672    #[case(
673        // [1, 2], [3], [4, 5]
674        vec![1i32, 2, 3, 4, 5],
675        vec![0u32, 2, 3, 5],
676        false
677    )]
678    #[case(
679        // [1, 2], [3, 4]
680        vec![1i32, 2, 3, 4],
681        vec![0u32, 2, 4],
682        false
683    )]
684    #[case(
685        // [], [], []
686        vec![],
687        vec![0u32, 0, 0, 0],
688        true
689    )]
690    fn test_list_is_constant(
691        #[case] elements: Vec<i32>,
692        #[case] offsets: Vec<u32>,
693        #[case] expected: bool,
694    ) -> VortexResult<()> {
695        let list_array = ListArray::try_new(
696            PrimitiveArray::from_iter(elements).into_array(),
697            PrimitiveArray::from_iter(offsets).into_array(),
698            Validity::NonNullable,
699        )?;
700
701        let mut ctx = array_session().create_execution_ctx();
702        assert_eq!(is_constant(&list_array.into_array(), &mut ctx)?, expected);
703        Ok(())
704    }
705
706    #[test]
707    fn test_list_is_constant_nested_lists() -> VortexResult<()> {
708        let inner_elements = buffer![1i32, 2, 1, 2].into_array();
709        let inner_offsets = buffer![0u32, 1, 2, 3, 4].into_array();
710        let inner_lists = ListArray::try_new(inner_elements, inner_offsets, Validity::NonNullable)?;
711
712        let outer_offsets = buffer![0u32, 2, 4].into_array();
713        let outer_list = ListArray::try_new(
714            inner_lists.into_array(),
715            outer_offsets,
716            Validity::NonNullable,
717        )?;
718
719        let mut ctx = array_session().create_execution_ctx();
720        // Both outer lists contain [[1], [2]], so should be constant
721        assert!(is_constant(&outer_list.into_array(), &mut ctx)?);
722        Ok(())
723    }
724
725    #[rstest]
726    #[case(
727        // 100 identical [1, 2] lists
728        [1i32, 2].repeat(100),
729        (0..101).map(|i| (i * 2) as u32).collect(),
730        true
731    )]
732    #[case(
733        // Difference after threshold: 64 identical [1, 2] + one [3, 4]
734        {
735            let mut elements = [1i32, 2].repeat(64);
736            elements.extend_from_slice(&[3, 4]);
737            elements
738        },
739        (0..66).map(|i| (i * 2) as u32).collect(),
740        false
741    )]
742    #[case(
743        // Difference in first 64: first 63 identical [1, 2] + one [3, 4] + rest identical [1, 2]
744        {
745            let mut elements = [1i32, 2].repeat(63);
746            elements.extend_from_slice(&[3, 4]);
747            elements.extend([1i32, 2].repeat(37));
748            elements
749        },
750        (0..101).map(|i| (i * 2) as u32).collect(),
751        false
752    )]
753    fn test_list_is_constant_with_threshold(
754        #[case] elements: Vec<i32>,
755        #[case] offsets: Vec<u32>,
756        #[case] expected: bool,
757    ) -> VortexResult<()> {
758        let list_array = ListArray::try_new(
759            PrimitiveArray::from_iter(elements).into_array(),
760            PrimitiveArray::from_iter(offsets).into_array(),
761            Validity::NonNullable,
762        )?;
763
764        let mut ctx = array_session().create_execution_ctx();
765        assert_eq!(is_constant(&list_array.into_array(), &mut ctx)?, expected);
766        Ok(())
767    }
768
769    #[test]
770    fn test_map_is_constant() -> VortexResult<()> {
771        let mut ctx = array_session().create_execution_ctx();
772
773        let identical = map_array_from_rows(&[
774            Some(vec![(1, Some("one")), (2, None)]),
775            Some(vec![(1, Some("one")), (2, None)]),
776        ])?;
777        assert!(is_constant(&identical, &mut ctx)?);
778
779        let different = map_array_from_rows(&[
780            Some(vec![(1, Some("one")), (2, None)]),
781            Some(vec![(1, Some("one")), (3, None)]),
782        ])?;
783        assert!(!is_constant(&different, &mut ctx)?);
784
785        let all_null = map_array_from_rows(&[None, None])?;
786        assert!(is_constant(&all_null, &mut ctx)?);
787
788        Ok(())
789    }
790
791    #[test]
792    fn empty_constant_leaves_partial_empty() -> VortexResult<()> {
793        let mut ctx = array_session().create_execution_ctx();
794        let array = ConstantArray::new(Scalar::from(99i32), 0).into_array();
795        let mut acc = Accumulator::try_new(IsConstant, EmptyOptions, array.dtype().clone())?;
796
797        acc.accumulate(&array, &mut ctx)?;
798
799        assert!(acc.partial_scalar()?.is_null());
800        Ok(())
801    }
802
803    /// Merging a non-constant partial into a materialized empty one must keep the false verdict
804    /// in the partial scalar rather than collapsing it to the empty state.
805    #[test]
806    fn non_constant_merged_into_empty_keeps_verdict() -> VortexResult<()> {
807        let mut ctx = array_session().create_execution_ctx();
808        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
809        let mut empty = Accumulator::try_new(IsConstant, EmptyOptions, dtype.clone())?;
810        let mut varying = Accumulator::try_new(IsConstant, EmptyOptions, dtype)?;
811
812        // An empty batch materializes the empty partial in place.
813        empty.accumulate(
814            &PrimitiveArray::new(Buffer::<i32>::empty(), Validity::NonNullable).into_array(),
815            &mut ctx,
816        )?;
817        varying.accumulate(&buffer![1i32, 2].into_array(), &mut ctx)?;
818        empty.merge_from(&mut varying)?;
819
820        assert!(!empty.partial_scalar()?.is_null());
821        assert_eq!(
822            empty.finish()?,
823            Scalar::bool(false, Nullability::NonNullable)
824        );
825        Ok(())
826    }
827
828    /// A non-constant verdict without an observed value is not the empty state.
829    #[test]
830    fn non_constant_partial_without_value_is_not_empty() -> VortexResult<()> {
831        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
832        let owned = AggregateDTypes::try_new(&IsConstant, &EmptyOptions, dtype)?;
833        let args = owned.args(&EmptyOptions);
834        let partial = IsConstantPartial {
835            is_constant: false,
836            first_value: None,
837        };
838
839        let scalar = IsConstant.to_scalar(args, &partial)?;
840        assert!(!scalar.is_null());
841        let parsed = IsConstant.partial_from_scalar(args, &scalar)?;
842        assert_eq!(
843            IsConstant.finalize_scalar(args, &parsed)?,
844            Scalar::bool(false, Nullability::NonNullable)
845        );
846        Ok(())
847    }
848}