Skip to content

Commit 40cddef

Browse files
committed
prune empty preimages in ceil propagate_constraints
1 parent 3cf6a22 commit 40cddef

1 file changed

Lines changed: 299 additions & 0 deletions

File tree

  • datafusion/functions/src/math

datafusion/functions/src/math/ceil.rs

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
260277
fn 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

Comments
 (0)