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