@@ -237,6 +237,17 @@ impl ScalarUDFImpl for CeilFunc {
237237 }
238238 _ => None ,
239239 } ;
240+ // When BOTH bounds of the output are finite, the output interval
241+ // [N, M] admits an integer (and therefore a preimage) only when
242+ // `ceil(N) ≤ floor(M)`. With our transformed values that's
243+ // `lo + 1 ≤ hi`, i.e. `lo < hi`. If `lo ≥ hi`, the output contains
244+ // no integer (e.g. `ceil(x) ∈ [12.3, 12.7]` or `[13.1, 13.1]`), the
245+ // preimage is empty, and we can prune the branch.
246+ if let ( Some ( l) , Some ( h) ) = ( & lo, & hi)
247+ && l >= h
248+ {
249+ return Ok ( None ) ;
250+ }
240251 // If either side of the output is finite we can still narrow that side
241252 // of the input; the unknown side falls back to the input's own bound so
242253 // the intersect is a no-op on it.
@@ -257,6 +268,12 @@ impl ScalarUDFImpl for CeilFunc {
257268 }
258269}
259270
271+ /// IEEE-754 signed-zero is intentionally preserved: `(-0.001).ceil() == -0.0`
272+ /// (not `+0.0`), and `ScalarValue` compares bit-for-bit, so `Float64(-0.0)` is
273+ /// structurally distinct from `Float64(+0.0)` even though numerically equal.
274+ /// Do not "normalise" `-0.0 → +0.0` here without considering downstream
275+ /// hashing, structural equality, and Arrow consistency — see the
276+ /// `test_*_zero` and `test_*_singleton_negative_zero` regression tests.
260277fn ceil_scalar ( v : & ScalarValue ) -> Option < ScalarValue > {
261278 match v {
262279 ScalarValue :: Float64 ( Some ( f) ) if f. is_finite ( ) => {
@@ -464,4 +481,286 @@ mod tests {
464481 let result = ceil ( ) . propagate_constraints ( & output, & [ & input] ) . unwrap ( ) ;
465482 assert ! ( result. is_none( ) ) ;
466483 }
484+
485+ // --- Output contains no integer → preimage is empty, branch prunes ---
486+
487+ /// `ceil(x) ∈ [12.3, 12.7]` — both bounds non-integer in the same gap
488+ /// (12, 13). No integer satisfies `ceil(x) ∈ [12.3, 12.7]`, so the
489+ /// preimage is empty and the branch must be pruned (`Ok(None)`).
490+ #[ test]
491+ fn test_propagate_constraints_no_integer_in_output_positive_width ( ) {
492+ let output = f64_interval ( 12.3 , 12.7 ) ;
493+ let input = unbounded_f64 ( ) ;
494+ let result = ceil ( ) . propagate_constraints ( & output, & [ & input] ) . unwrap ( ) ;
495+ assert ! ( result. is_none( ) , "expected branch pruned, got {result:?}" ) ;
496+ }
497+
498+ /// `ceil(x) ∈ [13.1, 13.1]` — degenerate (singleton) non-integer interval.
499+ /// Same logic: `ceil` only produces integers, so no x maps into a
500+ /// non-integer singleton. Branch must be pruned.
501+ #[ test]
502+ fn test_propagate_constraints_no_integer_in_output_degenerate ( ) {
503+ let output = f64_interval ( 13.1 , 13.1 ) ;
504+ let input = unbounded_f64 ( ) ;
505+ let result = ceil ( ) . propagate_constraints ( & output, & [ & input] ) . unwrap ( ) ;
506+ assert ! ( result. is_none( ) , "expected branch pruned, got {result:?}" ) ;
507+ }
508+
509+ /// `ceil(x) ∈ [13.1, 13.9]` — wider non-integer interval that still
510+ /// contains no integer. Branch must be pruned.
511+ #[ test]
512+ fn test_propagate_constraints_no_integer_in_output_wider_gap ( ) {
513+ let output = f64_interval ( 13.1 , 13.9 ) ;
514+ let input = unbounded_f64 ( ) ;
515+ let result = ceil ( ) . propagate_constraints ( & output, & [ & input] ) . unwrap ( ) ;
516+ assert ! ( result. is_none( ) , "expected branch pruned, got {result:?}" ) ;
517+ }
518+
519+ /// Float32 variant of the impossibility detection — the same logic must
520+ /// apply regardless of float width.
521+ #[ test]
522+ fn test_propagate_constraints_no_integer_in_output_f32 ( ) {
523+ let output = f32_interval ( 7.2 , 7.8 ) ;
524+ let input = unbounded_f32 ( ) ;
525+ let result = ceil ( ) . propagate_constraints ( & output, & [ & input] ) . unwrap ( ) ;
526+ assert ! ( result. is_none( ) , "expected branch pruned, got {result:?}" ) ;
527+ }
528+
529+ // --- Cases that must NOT trigger the impossibility check ---
530+
531+ /// Integer singleton `ceil(x) ∈ [12.0, 12.0]` is feasible: `ceil(12) = 12`.
532+ /// The check `lo >= hi` becomes `11 >= 12` (false), so we DO NOT prune.
533+ #[ test]
534+ fn test_propagate_constraints_integer_singleton_is_feasible ( ) {
535+ let output = f64_interval ( 12.0 , 12.0 ) ;
536+ let input = unbounded_f64 ( ) ;
537+ let result = ceil ( )
538+ . propagate_constraints ( & output, & [ & input] )
539+ . unwrap ( )
540+ . unwrap ( ) ;
541+ assert_eq ! ( result[ 0 ] , f64_interval( 11.0 , 12.0 ) ) ;
542+ }
543+
544+ /// Boundary case `ceil(x) ∈ [12.0, 12.7]` — N integer, M non-integer in
545+ /// gap (12, 13). Feasible (x=12 → ceil=12 ∈ [12, 12.7]). Must NOT prune.
546+ /// Without the guard the check could over-fire if integer N is mishandled.
547+ #[ test]
548+ fn test_propagate_constraints_integer_lower_non_integer_upper_feasible ( ) {
549+ let output = f64_interval ( 12.0 , 12.7 ) ;
550+ let input = f64_interval ( 0.0 , 100.0 ) ;
551+ let result = ceil ( )
552+ . propagate_constraints ( & output, & [ & input] )
553+ . unwrap ( )
554+ . unwrap ( ) ;
555+ // lo = ceil(12)-1 = 11, hi = floor(12.7) = 12 → constraint [11, 12]
556+ // intersect with [0, 100] → [11, 12]
557+ assert_eq ! ( result[ 0 ] , f64_interval( 11.0 , 12.0 ) ) ;
558+ }
559+
560+ /// Boundary case `ceil(x) ∈ [12.3, 13.0]` — N non-integer, M integer.
561+ /// Feasible (x=13 → ceil=13 ∈ [12.3, 13]). Must NOT prune.
562+ #[ test]
563+ fn test_propagate_constraints_non_integer_lower_integer_upper_feasible ( ) {
564+ let output = f64_interval ( 12.3 , 13.0 ) ;
565+ let input = f64_interval ( 0.0 , 100.0 ) ;
566+ let result = ceil ( )
567+ . propagate_constraints ( & output, & [ & input] )
568+ . unwrap ( )
569+ . unwrap ( ) ;
570+ // lo = ceil(12.3)-1 = 12, hi = floor(13) = 13 → constraint [12, 13]
571+ // intersect with [0, 100] → [12, 13]
572+ assert_eq ! ( result[ 0 ] , f64_interval( 12.0 , 13.0 ) ) ;
573+ }
574+
575+ /// One-sided output `(-∞, 12.7]` — even though `floor(12.7) = 12` would
576+ /// look "narrow", the lower side is unbounded so the impossibility check
577+ /// must NOT fire (the guard is `(Some, Some)` only).
578+ #[ test]
579+ fn test_propagate_constraints_one_sided_does_not_prune ( ) {
580+ let output = Interval :: try_new (
581+ ScalarValue :: Float64 ( None ) ,
582+ ScalarValue :: Float64 ( Some ( 12.7 ) ) ,
583+ )
584+ . unwrap ( ) ;
585+ let input = f64_interval ( 0.0 , 100.0 ) ;
586+ let result = ceil ( )
587+ . propagate_constraints ( & output, & [ & input] )
588+ . unwrap ( )
589+ . unwrap ( ) ;
590+ // hi = floor(12.7) = 12; lower bound falls back to input lower (0.0)
591+ assert_eq ! ( result[ 0 ] , f64_interval( 0.0 , 12.0 ) ) ;
592+ }
593+
594+ // --- evaluate_bounds: intervals straddling integer boundaries (off-by-one
595+ // prone). ceil is monotonic, so ceil([a, b]) = [ceil(a), ceil(b)]. ---
596+
597+ /// `[1.999, 2.001]` — straddles integer 2. ceil(1.999)=2, ceil(2.001)=3.
598+ #[ test]
599+ fn test_evaluate_bounds_straddles_integer ( ) {
600+ let input = f64_interval ( 1.999 , 2.001 ) ;
601+ let result = ceil ( ) . evaluate_bounds ( & [ & input] ) . unwrap ( ) ;
602+ assert_eq ! ( result, f64_interval( 2.0 , 3.0 ) ) ;
603+ }
604+
605+ /// `[-2.001, -1.999]` — negative variant of the same boundary case.
606+ /// ceil(-2.001) = -2, ceil(-1.999) = -1.
607+ #[ test]
608+ fn test_evaluate_bounds_straddles_negative_integer ( ) {
609+ let input = f64_interval ( -2.001 , -1.999 ) ;
610+ let result = ceil ( ) . evaluate_bounds ( & [ & input] ) . unwrap ( ) ;
611+ assert_eq ! ( result, f64_interval( -2.0 , -1.0 ) ) ;
612+ }
613+
614+ /// `[-0.001, 0.001]` — straddles zero, the sign-flip boundary.
615+ /// Note: `f64::ceil(-0.001)` returns IEEE-754 `-0.0` (sign-preserving),
616+ /// not `+0.0`. Numerically equivalent but bit-level distinct, so the
617+ /// expected lower bound is `-0.0`.
618+ #[ test]
619+ fn test_evaluate_bounds_straddles_zero ( ) {
620+ let input = f64_interval ( -0.001 , 0.001 ) ;
621+ let result = ceil ( ) . evaluate_bounds ( & [ & input] ) . unwrap ( ) ;
622+ let expected = Interval :: try_new (
623+ ScalarValue :: Float64 ( Some ( -0.0 ) ) ,
624+ ScalarValue :: Float64 ( Some ( 1.0 ) ) ,
625+ )
626+ . unwrap ( ) ;
627+ assert_eq ! ( result, expected) ;
628+ }
629+
630+ // --- propagate_constraints: integer singletons at sign-sensitive points ---
631+
632+ /// `ceil(x) ∈ [0, 0]` — zero is the sign-flip boundary. Real preimage is
633+ /// `(-1, 0]`, conservatively `[-1, 0]`.
634+ #[ test]
635+ fn test_propagate_constraints_integer_singleton_zero ( ) {
636+ let output = f64_interval ( 0.0 , 0.0 ) ;
637+ let input = unbounded_f64 ( ) ;
638+ let result = ceil ( )
639+ . propagate_constraints ( & output, & [ & input] )
640+ . unwrap ( )
641+ . unwrap ( ) ;
642+ assert_eq ! ( result[ 0 ] , f64_interval( -1.0 , 0.0 ) ) ;
643+ }
644+
645+ /// `ceil(x) ∈ [-3, -3]` — negative integer singleton. Real preimage is
646+ /// `(-4, -3]`, conservatively `[-4, -3]`.
647+ #[ test]
648+ fn test_propagate_constraints_integer_singleton_negative ( ) {
649+ let output = f64_interval ( -3.0 , -3.0 ) ;
650+ let input = unbounded_f64 ( ) ;
651+ let result = ceil ( )
652+ . propagate_constraints ( & output, & [ & input] )
653+ . unwrap ( )
654+ . unwrap ( ) ;
655+ assert_eq ! ( result[ 0 ] , f64_interval( -4.0 , -3.0 ) ) ;
656+ }
657+
658+ // --- propagate_constraints: single-integer output where the integer is on
659+ // the LOWER bound (the mirror of `non_integer_lower_integer_upper`) ---
660+
661+ /// `ceil(x) ∈ [13.0, 13.7]` — exactly one integer (13) lies in the output.
662+ /// lo = ceil(13)-1 = 12, hi = floor(13.7) = 13 → constraint [12, 13].
663+ #[ test]
664+ fn test_propagate_constraints_integer_lower_with_room_above ( ) {
665+ let output = f64_interval ( 13.0 , 13.7 ) ;
666+ let input = unbounded_f64 ( ) ;
667+ let result = ceil ( )
668+ . propagate_constraints ( & output, & [ & input] )
669+ . unwrap ( )
670+ . unwrap ( ) ;
671+ assert_eq ! ( result[ 0 ] , f64_interval( 12.0 , 13.0 ) ) ;
672+ }
673+
674+ // --- propagate_constraints: multiple integers and broad negative ranges ---
675+
676+ /// `ceil(x) ∈ [12.3, 15.7]` — three integers (13, 14, 15) in the output.
677+ /// lo = ceil(12.3)-1 = 12, hi = floor(15.7) = 15 → [12, 15].
678+ #[ test]
679+ fn test_propagate_constraints_multiple_integers_in_output ( ) {
680+ let output = f64_interval ( 12.3 , 15.7 ) ;
681+ let input = unbounded_f64 ( ) ;
682+ let result = ceil ( )
683+ . propagate_constraints ( & output, & [ & input] )
684+ . unwrap ( )
685+ . unwrap ( ) ;
686+ assert_eq ! ( result[ 0 ] , f64_interval( 12.0 , 15.0 ) ) ;
687+ }
688+
689+ /// `ceil(x) ∈ [-5, -3]` — broader negative range. lo = ceil(-5)-1 = -6,
690+ /// hi = floor(-3) = -3 → [-6, -3]. Mirror of `[3, 5] → [2, 5]`.
691+ #[ test]
692+ fn test_propagate_constraints_negative_multi_integer_range ( ) {
693+ let output = f64_interval ( -5.0 , -3.0 ) ;
694+ let input = unbounded_f64 ( ) ;
695+ let result = ceil ( )
696+ . propagate_constraints ( & output, & [ & input] )
697+ . unwrap ( )
698+ . unwrap ( ) ;
699+ assert_eq ! ( result[ 0 ] , f64_interval( -6.0 , -3.0 ) ) ;
700+ }
701+
702+ // --- propagate_constraints: pruning short-circuits intersect with a
703+ // bounded input (no need to compute the intersection) ---
704+
705+ /// `ceil(x) ∈ [12.3, 12.7]` with a bounded input `[5, 7]`: the new
706+ /// impossibility check fires BEFORE the intersect step, so the result is
707+ /// `Ok(None)` regardless of the input. Distinct from
708+ /// `test_propagate_constraints_empty_intersection`, which exercises the
709+ /// intersect-returns-None path.
710+ #[ test]
711+ fn test_propagate_constraints_pruning_short_circuits_intersect ( ) {
712+ let output = f64_interval ( 12.3 , 12.7 ) ;
713+ let input = f64_interval ( 5.0 , 7.0 ) ;
714+ let result = ceil ( ) . propagate_constraints ( & output, & [ & input] ) . unwrap ( ) ;
715+ assert ! (
716+ result. is_none( ) ,
717+ "expected pruning to short-circuit intersect, got {result:?}"
718+ ) ;
719+ }
720+
721+ // --- Signed-zero edge cases: ScalarValue compares bit-for-bit, so the
722+ // output preserves the IEEE-754 sign of zero. These tests document
723+ // that behaviour so a future "normalise -0.0 → +0.0" refactor breaks
724+ // them visibly. ---
725+
726+ /// `ceil(x) ∈ [0.0, 0.0]` (positive zero singleton). Feasible: ceil(0)=0.
727+ /// lo = 0.0.ceil() - 1.0 = -1.0, hi = 0.0.floor() = +0.0 → `[-1.0, +0.0]`.
728+ #[ test]
729+ fn test_propagate_constraints_singleton_positive_zero ( ) {
730+ let output = f64_interval ( 0.0 , 0.0 ) ;
731+ let input = unbounded_f64 ( ) ;
732+ let result = ceil ( )
733+ . propagate_constraints ( & output, & [ & input] )
734+ . unwrap ( )
735+ . unwrap ( ) ;
736+ let expected = Interval :: try_new (
737+ ScalarValue :: Float64 ( Some ( -1.0 ) ) ,
738+ ScalarValue :: Float64 ( Some ( 0.0 ) ) ,
739+ )
740+ . unwrap ( ) ;
741+ assert_eq ! ( result[ 0 ] , expected) ;
742+ }
743+
744+ /// `ceil(x) ∈ [-0.0, -0.0]` (negative zero singleton). Same set
745+ /// numerically as `[+0.0, +0.0]`, but bit-distinct. ceil/floor preserve
746+ /// the negative sign, so the propagated upper bound is `-0.0`, not `+0.0`.
747+ #[ test]
748+ fn test_propagate_constraints_singleton_negative_zero ( ) {
749+ let output = Interval :: try_new (
750+ ScalarValue :: Float64 ( Some ( -0.0 ) ) ,
751+ ScalarValue :: Float64 ( Some ( -0.0 ) ) ,
752+ )
753+ . unwrap ( ) ;
754+ let input = unbounded_f64 ( ) ;
755+ let result = ceil ( )
756+ . propagate_constraints ( & output, & [ & input] )
757+ . unwrap ( )
758+ . unwrap ( ) ;
759+ let expected = Interval :: try_new (
760+ ScalarValue :: Float64 ( Some ( -1.0 ) ) ,
761+ ScalarValue :: Float64 ( Some ( -0.0 ) ) ,
762+ )
763+ . unwrap ( ) ;
764+ assert_eq ! ( result[ 0 ] , expected) ;
765+ }
467766}
0 commit comments