Skip to content

fix: incorrect results when using NOT physical expression in physical… - #20138

Closed
evangelisilva wants to merge 8 commits into
apache:mainfrom
evangelisilva:fix/19264-not-expr-analysis
Closed

evangelisilva wants to merge 8 commits into
apache:mainfrom
evangelisilva:fix/19264-not-expr-analysis

Conversation

@evangelisilva

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #19264.

Rationale for this change

The analyze function in physical-expr was incorrectly returning None (implying an empty set/infeasibility) when analyzing NOT(Eq) expressions (e.g., NOT(a = 0.0)).
This was caused by propagate_comparison in cp_solver.rs returning Ok(None) for Operator::Eq when the parent interval is FALSE. Ok(None) in cp_solver is interpreted as "Infeasible", but NOT(a=b) is a feasible condition (it effectively creates two disjoint intervals, which the current Interval implementation cannot represent as a single interval, but it is certainly not empty).

What changes are included in this PR?

  • Modified propagate_comparison in cp_solver.rs. When handling Operator::Eq with a FALSE parent (meaning we are looking for "Not Equal"), it now returns the original intervals (identity) instead of None. This treats the constraint as valid/feasible but properly reflects that we cannot refine the bounds further with single-interval arithmetic.
  • Added a new regression test analyze_not_eq_around in datafusion/physical-expr/src/analysis.rs to verify that NOT(a = 0.0) now returns a valid interval encompassing the domain instead of None.

Are these changes tested?

Yes, tested locally.

  • Run command: cargo test -p datafusion-physical-expr --lib analysis::tests
  • Verification: The new test analyze_not_eq_around passes, and existing tests in analysis.rs also pass.

Are there any user-facing changes?

No, this is a bug fix in the physical expression analysis logic.

@github-actions github-actions Bot added the physical-expr Changes to the physical-expr crates label Feb 3, 2026

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @evangelisilva

@pepijnve or @berkaysynnada -- I think you might be familiar with this code. Do you have time to help review this PR?

@pepijnve

pepijnve commented Feb 3, 2026

Copy link
Copy Markdown
Contributor

@alamb I'm not super familiar with this code, but looking at the review I think I can see a counter example that produces an incorrect response with this change. I will try to write that to confirm my hypothesis.

Operator::Eq => {
// TODO: Propagation is not possible until we support interval sets.
Ok(None)
Ok(Some((left_child.clone(), right_child.clone())))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is correct, but only if the left and right child intervals do not intersect. So maybe this should be

match left_child.intersect(right_child)? {
    None => Ok(Some((left_child.clone(), right_child.clone()))),
    // TODO: Propagation is not possible until we support interval sets.
    Some(_) => Ok(None)
}

instead?

In the test case

#[test]
fn test_propagate_eq_false() -> Result<()> {
    let left = Interval::make(Some(-10_i64), Some(10_i64))?;
    let right = Interval::make(Some(0_i64), Some(0_i64))?;
    let outcome = propagate_comparison(&Operator::Eq, &Interval::FALSE, &left, &right)?;
    assert_eq!(
        None,
        outcome
    );
    Ok(())
}

for instance, the result for the left child should be the intervals [-10, 0[ and ]0, 10], but as the TODO suggests that can't currently be represented. The proposed fix would return [-10, 10] for the left child, but that is not correct since a value of 0 for the left child would result in true, not false.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on @berkaysynnada's answer I see I misinterpreted the contract of propagate_comparison. I had assumed it needed to provide a correct stricter constraint if it could or None if it couldn't. If not restricting the child constraints is valid, which makes perfect sense, then indeed there's just the situation where we can know for sure that (left == right) == true, i.e. two equal point intervals, that we should return None. In all other cases we can simply retain the existing constraints.

@berkaysynnada

berkaysynnada commented Feb 3, 2026 •

Copy link
Copy Markdown
Contributor

The fix seems correct to me. To elaborate, assume we have 2 child intervals; A and B. Propagation of TRUE over Eq is done like that:

We prune A such: "choose a point in A. If that point cannot be satisfied by any point from B, then that point is pruned."
and for B, similarly: "choose a point in B. If that point cannot be satisfied by any point from A, then that point is pruned."

So, what I think now is, we don't actually require interval sets for this problem, only except this case:

A is a set which strictly contains B, and B is a single-point value. In that case, we need to split the interval A into 2 parts at the intersection point (or vice versa for B and A). However, leaving the fix as is is not a big problem. We will only not be capable of a tiny optimization.

But to not introduce another bug, the only case which should keep as infeasible is, having 2 same single-point values. In that case, having an equality as false is impossible.

cc @ozankabak if I missed anything

@evangelisilva

Copy link
Copy Markdown
Contributor Author

@pepijnve @berkaysynnada Thanks for the review! I agree, that makes sense.

I will update the PR to handle the specific case where both sides are single points (constants). In that case, if left == right, we know Not(Eq) is impossible, so we can correctly return Infeasible (None).

For other interval cases, I'll keep the safe over-approximation (returning the original intervals) until interval sets are supported.

@evangelisilva

Copy link
Copy Markdown
Contributor Author

@pepijnve @berkaysynnada I've updated the PR to handle the Not(Eq) logic more precisely.

Specifically:

  1. If left == right and both are valid singleton intervals (identical single points), it now returns Ok(None) (Infeasible).
  2. For all other cases (different or overlapping intervals), it falls back to the safe over-approximation of returning the original intervals, as discussed regarding the current limitations with interval sets.

@berkaysynnada

Copy link
Copy Markdown
Contributor

Thanks for the effort. Let's fix the fmt, remove the redundant diffs, and we'll merge this. It would be also better if you add a test for that edge case

@evangelisilva

Copy link
Copy Markdown
Contributor Author

@berkaysynnada I have updated the PR based on your feedback:

  • Fixed Formatting: Ran cargo fmt to ensure everything aligns with the project's style.
  • Cleaned Diffs: Removed redundant diffs and kept the changes focused on the fix.
  • Added Edge Case Test: I’ve added a new test case, analyze_not_eq_infeasible, in analysis.rs. This test specifically verifies that NOT (a = 0.0) for a column a already fixed at 0.0 correctly results in an Infeasible outcome (0.0 selectivity and None intervals).

Ready for another look. Thanks!

@github-actions

Copy link
Copy Markdown

Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days.

@github-actions github-actions Bot added the Stale PR has not had any activity for some time label Apr 25, 2026
@github-actions github-actions Bot closed this May 2, 2026
Smallfu666 added a commit to Smallfu666/datafusion that referenced this pull request Aug 11, 2026
…cp_solver

propagate_comparison for Operator::Eq with parent == Interval::FALSE
returned Ok(None), which ExprIntervalGraph::propagate_constraints
interprets as infeasible. The correct semantics is that a = b being
certainly false means a != b, which excludes at most a single point
from each operand. Interval arithmetic cannot represent a hole, so
the children cannot be refined further, but the expression is not
infeasible.

This caused analyze() to return None for the column interval when
the predicate was NOT (a = 0.0) and the input domain contained 0.0
but was not equal to it (e.g. a in [-1, 1]). The None is documented
to mean the predicate is always false, but NOT (a = 0.0) is true
for every a != 0, so the correct result is the full input domain.

The only case that should remain infeasible under Eq + FALSE is
when both children are the same single-point interval: then a = b
is certainly true, so NOT(a = b) is impossible. All other cases
return the children unchanged (Ok(Some((left, right)))) so
propagation completes with Success and the column interval is
preserved. This guard was agreed on during review of apache#20138 by
berkaysynnada and pepijnve.

Downstream, analyze() feeds FilterExec::statistics_helper(), which
uses an Infeasible result to set selectivity to 0.0 and clear all
column boundaries, producing wrong statistics estimation.

This revives the fix from apache#20138, which was closed as stale after
review, and adds direct regression coverage for the
propagate_comparison Eq + FALSE branch that the previous E2E
analysis tests did not exercise (the [0,0] infeasible case
short-circuits at update_ranges before reaching
propagate_comparison).

Tests:
- analysis.rs: boundary pair [-1,1] (satisfiable) and [0,0]
  (infeasible) E2E via analyze().
- cp_solver.rs: direct unit tests for propagate_comparison with
  Eq + FALSE covering identical singletons (infeasible), different
  singletons (feasible), overlapping intervals, and the [-1,1] vs
  [0,0] case from the issue.

Closes apache#19264.
Smallfu666 added a commit to Smallfu666/datafusion that referenced this pull request Aug 11, 2026
…cp_solver

propagate_comparison for Operator::Eq with parent == Interval::FALSE
returned Ok(None), which ExprIntervalGraph::propagate_constraints
interprets as infeasible. The correct semantics is that a = b being
certainly false means a != b, which excludes at most a single point
from each operand. Interval arithmetic cannot represent a hole, so
the children cannot be refined further, but the expression is not
infeasible.

This caused analyze() to return None for the column interval when
the predicate was NOT (a = 0.0) and the input domain contained 0.0
but was not equal to it (e.g. a in [-1, 1]). The None is documented
to mean the predicate is always false, but NOT (a = 0.0) is true
for every a != 0, so the correct result is the full input domain.

The only case that should remain infeasible under Eq + FALSE is
when both children are the same single-point interval: then a = b
is certainly true, so NOT(a = b) is impossible. All other cases
return the children unchanged (Ok(Some((left, right)))) so
propagation completes with Success and the column interval is
preserved. This guard was agreed on during review of apache#20138 by
berkaysynnada and pepijnve.

Downstream, analyze() feeds FilterExec::statistics_helper(), which
uses an Infeasible result to set selectivity to 0.0 and clear all
column boundaries, producing wrong statistics estimation.

This revives the fix from apache#20138, which was closed as stale after
review, and adds direct regression coverage for the
propagate_comparison Eq + FALSE branch that the previous E2E
analysis tests did not exercise (the [0,0] infeasible case
short-circuits at update_ranges before reaching
propagate_comparison).

Tests:
- analysis.rs: boundary pair [-1,1] (satisfiable) and [0,0]
  (infeasible) E2E via analyze().
- cp_solver.rs: direct unit tests for propagate_comparison with
  Eq + FALSE covering identical singletons (infeasible), different
  singletons (feasible), overlapping intervals, and the [-1,1] vs
  [0,0] case from the issue.

Closes apache#19264.
Smallfu666 added a commit to Smallfu666/datafusion that referenced this pull request Aug 11, 2026
…cp_solver

propagate_comparison for Operator::Eq with parent == Interval::FALSE
returned Ok(None), which ExprIntervalGraph::propagate_constraints
interprets as infeasible. The correct semantics is that a = b being
certainly false means a != b, which excludes at most a single point
from each operand. Interval arithmetic cannot represent a hole, so
the children cannot be refined further, but the expression is not
infeasible.

This caused analyze() to return None for the column interval when
the predicate was NOT (a = 0.0) and the input domain contained 0.0
but was not equal to it (e.g. a in [-1, 1]). The None is documented
in ExprBoundaries::interval to mean "evaluating the given column
results in an empty set", but NOT (a = 0.0) is true for every
a != 0, so the correct result is the full input domain. This is a
contract violation of the public analyze() API, as reported in apache#19264
by an external user calling analyze() to infer bounds for pushdown
into another library.

The only case that should remain infeasible under Eq + FALSE is
when both children are the same single-point interval: then a = b
is certainly true, so NOT(a = b) is impossible. All other cases
return the children unchanged (Ok(Some((left, right)))) so
propagation completes with Success and the column interval is
preserved. This guard was agreed on during review of apache#20138 by
berkaysynnada and pepijnve.

This revives the fix from apache#20138, which was closed as stale after
review, and adds direct regression coverage for the
propagate_comparison Eq + FALSE branch that the previous E2E
analysis tests did not exercise (the [0,0] infeasible case
short-circuits at update_ranges before reaching
propagate_comparison).

Tests:
- analysis.rs: boundary pair [-1,1] (satisfiable) and [0,0]
  (infeasible) E2E via analyze().
- cp_solver.rs: direct unit tests for propagate_comparison with
  Eq + FALSE covering identical singletons (infeasible), different
  singletons (feasible), overlapping intervals, and the [-1,1] vs
  [0,0] case from the issue.

Closes apache#19264.

Co-authored-by: Claude <[email protected]>
Smallfu666 added a commit to Smallfu666/datafusion that referenced this pull request Aug 12, 2026
…cp_solver

propagate_comparison for Operator::Eq with parent == Interval::FALSE
returned Ok(None), which ExprIntervalGraph::propagate_constraints
interprets as infeasible. The correct semantics is that a = b being
certainly false means a != b, which excludes at most a single point
from each operand. Interval arithmetic cannot represent a hole, so
the children cannot be refined further, but the expression is not
infeasible.

This caused analyze() to return None for the column interval when
the predicate was NOT (a = 0.0) and the input domain contained 0.0
but was not equal to it (e.g. a in [-1, 1]). The None is documented
in ExprBoundaries::interval to mean "evaluating the given column
results in an empty set", but NOT (a = 0.0) is true for every
a != 0, so the correct result is the full input domain. This is a
contract violation of the public analyze() API, as reported in apache#19264
by an external user calling analyze() to infer bounds for pushdown
into another library.

The only case that should remain infeasible under Eq + FALSE is
when both children are the same single-point interval: then a = b
is certainly true, so NOT(a = b) is impossible. All other cases
return the children unchanged (Ok(Some((left, right)))) so
propagation completes with Success and the column interval is
preserved. This guard was agreed on during review of apache#20138 by
berkaysynnada and pepijnve.

This revives the fix from apache#20138, which was closed as stale after
review, and adds direct unit coverage for that guard. No
analyze()-level test can reach it: when both children are the same
single point the bounds evaluate to FALSE, so update_ranges
short-circuits to Infeasible before propagate_constraints runs.
Only a direct propagate_comparison call exercises the guard.

Tests:
- analysis.rs: E2E boundary pair via analyze(). Input [-1,1] is the
  reported case and does reach the fixed branch; input [0,0] is the
  nearest-invalid counterpart and must stay infeasible.
- cp_solver.rs: direct propagate_comparison unit tests for Eq + FALSE
  covering identical singletons (infeasible), different singletons
  (feasible), overlapping intervals, and the [-1,1] vs [0,0] case
  from the issue.

Negative control on baseline a1f64e9 with the production arm
reverted to Ok(None): 3 of the 4 new tests fail, including the E2E
test_analyze_not_eq_around_zero. test_analyze_not_eq_clamped_at_zero
passes on baseline as well, so it is a boundary guard rather than
evidence of the fix.

Closes apache#19264.

Co-authored-by: evangelisilva <[email protected]>
Smallfu666 added a commit to Smallfu666/datafusion that referenced this pull request Aug 13, 2026
…cp_solver

propagate_comparison for Operator::Eq with parent == Interval::FALSE
returned Ok(None), which ExprIntervalGraph::propagate_constraints
interprets as infeasible. The correct semantics is that a = b being
certainly false means a != b, which excludes at most a single point
from each operand. Interval arithmetic cannot represent a hole, so
the children cannot be refined further, but the expression is not
infeasible.

This caused analyze() to return None for the column interval when
the predicate was NOT (a = 0.0) and the input domain contained 0.0
but was not equal to it (e.g. a in [-1, 1]). The None is documented
in ExprBoundaries::interval to mean "evaluating the given column
results in an empty set", but NOT (a = 0.0) is true for every
a != 0, so the correct result is the full input domain. This is a
contract violation of the public analyze() API, as reported in apache#19264
by an external user calling analyze() to infer bounds for pushdown
into another library.

The only case that should remain infeasible under Eq + FALSE is
when both children are the same single-point interval: then a = b
is certainly true, so NOT(a = b) is impossible. All other cases
return the children unchanged (Ok(Some((left, right)))) so
propagation completes with Success and the column interval is
preserved. This guard was agreed on during review of apache#20138 by
berkaysynnada and pepijnve.

This revives the fix from apache#20138, which was closed as stale after
review, and adds direct unit coverage for that guard. No
analyze()-level test can reach it: when both children are the same
single point the bounds evaluate to FALSE, so update_ranges
short-circuits to Infeasible before propagate_constraints runs.
Only a direct propagate_comparison call exercises the guard.

Tests:
- analysis.rs: E2E boundary pair via analyze(). Input [-1,1] is the
  reported case and does reach the fixed branch; input [0,0] is the
  nearest-invalid counterpart and must stay infeasible.
- cp_solver.rs: direct propagate_comparison unit tests for Eq + FALSE
  covering identical singletons (infeasible), distinct singletons
  (feasible), and overlapping intervals including the [-1,1] vs [0,0]
  case from the issue.

Negative control on baseline a1f64e9 with the production arm
reverted to Ok(None): 3 of the 5 new tests fail, including the E2E
test_analyze_not_eq_around_zero. Each test is scoped so its baseline
verdict is unambiguous. The two that pass on baseline
(test_propagate_eq_false_identical_singletons and
test_analyze_not_eq_clamped_at_zero) are boundary guards rather than
evidence of the fix: the baseline returns None for every Eq + FALSE
input, so it agrees with them by accident.

Closes apache#19264.

Co-authored-by: evangelisilva <[email protected]>
Smallfu666 added a commit to Smallfu666/datafusion that referenced this pull request Aug 14, 2026
…cp_solver

propagate_comparison for Operator::Eq with parent == Interval::FALSE
returned Ok(None), which ExprIntervalGraph::propagate_constraints
interprets as infeasible. The correct semantics is that a = b being
certainly false means a != b, which excludes at most a single point
from each operand. Interval arithmetic cannot represent a hole, so
the children cannot be refined further, but the expression is not
infeasible.

This caused analyze() to return None for the column interval when
the predicate was NOT (a = 0.0) and the input domain contained 0.0
but was not equal to it (e.g. a in [-1, 1]). The None is documented
in ExprBoundaries::interval to mean "evaluating the given column
results in an empty set", but NOT (a = 0.0) is true for every
a != 0, so the correct result is the full input domain. This is a
contract violation of the public analyze() API, as reported in apache#19264
by an external user calling analyze() to infer bounds for pushdown
into another library.

Eq + FALSE is infeasible when equality is provably true for the two
singleton operands under the comparison semantics. This must use
comparison-semantics equality, not structural ScalarValue::PartialEq:
the latter is bit-wise for floats (to_bits), under which -0.0 != +0.0
even though SQL/IEEE-754 comparison treats them as equal. The guard
uses singleton_values_equal, which normalizes signed zero via
normalize_float_zero_scalar (the same normalization DataFusion applies
in physical-expr-common/src/datum.rs for runtime comparison) before
structural equality. This matters in nested boolean contexts (e.g.
NOT(a = +0.0) AND b) where the bottom-up pass does not short-circuit
and top-down propagation forces Eq to FALSE, reaching this arm with
[-0.0,-0.0] vs [+0.0,+0.0].

This revives the fix from apache#20138, which was closed as stale after
review. The singleton guard was agreed on during apache#20138 review by
berkaysynnada and pepijnve; this version strengthens it to use
comparison-semantics equality and adds direct regression coverage
for the Eq + FALSE branch that no analyze()-level test can reach
(the [0,0] infeasible case short-circuits at update_ranges before
propagate_comparison runs).

Tests:
- analysis.rs: E2E tests via analyze(). Input [-1,1] is the reported
  case and does reach the fixed branch; input [0,0] is the
  nearest-invalid counterpart and must stay infeasible; the nested
  NOT(a = +0.0) AND b case proves the case-(1) short-circuit does
  not protect the production arm and exercises the signed-zero guard.
- cp_solver.rs: direct propagate_comparison unit tests for Eq + FALSE
  covering identical singletons (infeasible), signed-zero singletons
  (infeasible under comparison semantics), distinct singletons
  (feasible), and overlapping intervals including the [-1,1] vs [0,0]
  case from the issue.

Negative control on baseline a1f64e9 with the production arm
reverted to Ok(None): 4 of the 7 new tests fail. The 3 that pass on
baseline are boundary guards (baseline returns None for every
Eq + FALSE input, so it agrees by accident). Additionally, replacing
singleton_values_equal with structural == makes the signed-zero
tests fail, confirming they exercise the comparison-semantics guard.

Closes apache#19264.

Co-authored-by: evangelisilva <[email protected]>
Smallfu666 added a commit to Smallfu666/datafusion that referenced this pull request Aug 14, 2026
…cp_solver

propagate_comparison for Operator::Eq with parent == Interval::FALSE
returned Ok(None), which ExprIntervalGraph::propagate_constraints
interprets as infeasible. The correct semantics is that a = b being
certainly false means a != b, which excludes at most a single point
from each operand. Interval arithmetic cannot represent a hole, so
the children cannot be refined further, but the expression is not
infeasible.

This caused analyze() to return None for the column interval when
the predicate was NOT (a = 0.0) and the input domain contained 0.0
but was not equal to it (e.g. a in [-1, 1]). The None is documented
in ExprBoundaries::interval to mean "evaluating the given column
results in an empty set", but NOT (a = 0.0) is true for every
a != 0, so the correct result is the full input domain. This is a
contract violation of the public analyze() API, as reported in apache#19264
by an external user calling analyze() to infer bounds for pushdown
into another library.

Eq + FALSE is infeasible when equality is provably true for the two
singleton operands under the comparison semantics. This must use
comparison-semantics equality, not structural ScalarValue::PartialEq:
the latter is bit-wise for floats (to_bits), under which -0.0 != +0.0
even though SQL/IEEE-754 comparison treats them as equal. The guard
uses singleton_values_equal, which normalizes signed zero via
normalize_float_zero_scalar (the same normalization DataFusion applies
in physical-expr-common/src/datum.rs for runtime comparison) before
structural equality. This matters in nested boolean contexts (e.g.
NOT(a = +0.0) AND b) where the bottom-up pass does not short-circuit
and top-down propagation forces Eq to FALSE, reaching this arm with
[-0.0,-0.0] vs [+0.0,+0.0].

This revives the fix from apache#20138, which was closed as stale after
review. The singleton guard was agreed on during apache#20138 review by
berkaysynnada and pepijnve; this version strengthens it to use
comparison-semantics equality and adds direct regression coverage
for the Eq + FALSE branch that no analyze()-level test can reach
(the [0,0] infeasible case short-circuits at update_ranges before
propagate_comparison runs).

Tests:
- analysis.rs: E2E tests via analyze(). Input [-1,1] is the reported
  case and does reach the fixed branch; input [0,0] is the
  nearest-invalid counterpart and must stay infeasible; the nested
  NOT(a = +0.0) AND b case proves the case-(1) short-circuit does
  not protect the production arm and exercises the signed-zero guard.
- cp_solver.rs: direct propagate_comparison unit tests for Eq + FALSE
  covering identical singletons (infeasible), signed-zero singletons
  (infeasible under comparison semantics), distinct singletons
  (feasible), and overlapping intervals including the [-1,1] vs [0,0]
  case from the issue.

Negative control on baseline c085cdf with the production arm
reverted to Ok(None): 4 of the 7 new tests fail. The 3 that pass on
baseline are boundary guards (baseline returns None for every
Eq + FALSE input, so it agrees by accident). Additionally, replacing
singleton_values_equal with structural == makes the signed-zero
tests fail, confirming they exercise the comparison-semantics guard.

Closes apache#19264.

Co-authored-by: evangelisilva <[email protected]>
Smallfu666 added a commit to Smallfu666/datafusion that referenced this pull request Aug 18, 2026
…cp_solver

propagate_comparison for Operator::Eq with parent == Interval::FALSE
returned Ok(None), which ExprIntervalGraph::propagate_constraints
interprets as infeasible. The correct semantics is that a = b being
certainly false means a != b, which excludes at most a single point
from each operand. Interval arithmetic cannot represent a hole, so
the children cannot be refined further, but the expression is not
infeasible.

This caused analyze() to return None for the column interval when
the predicate was NOT (a = 0.0) and the input domain contained 0.0
but was not equal to it (e.g. a in [-1, 1]). The None is documented
in ExprBoundaries::interval to mean "evaluating the given column
results in an empty set", but NOT (a = 0.0) is true for every
a != 0, so the correct result is the full input domain. This is a
contract violation of the public analyze() API, as reported in apache#19264
by an external user calling analyze() to infer bounds for pushdown
into another library.

Eq + FALSE is infeasible when equality is provably true for the two
singleton operands under the comparison semantics. This must use
comparison-semantics equality, not structural ScalarValue::PartialEq:
the latter is bit-wise for floats (to_bits), under which -0.0 != +0.0
even though SQL/IEEE-754 comparison treats them as equal. The guard
uses singleton_values_equal, which normalizes signed zero via
normalize_float_zero_scalar (the same normalization DataFusion applies
in physical-expr-common/src/datum.rs for runtime comparison) before
structural equality. This matters in nested boolean contexts (e.g.
NOT(a = +0.0) AND b) where the bottom-up pass does not short-circuit
and top-down propagation forces Eq to FALSE, reaching this arm with
[-0.0,-0.0] vs [+0.0,+0.0].

This revives the fix from apache#20138, which was closed as stale after
review. The singleton guard was agreed on during apache#20138 review by
berkaysynnada and pepijnve; this version strengthens it to use
comparison-semantics equality. Direct unit tests isolate the ordinary
identical-singleton case that the [0,0] analyze() test cannot reach
because update_ranges short-circuits. The nested signed-zero E2E test
separately proves that analyze() can reach the production arm through
the pre-existing interval/runtime equality divergence.

Tests:
- analysis.rs: E2E tests via analyze(). Input [-1,1] is the reported
  case and does reach the fixed branch; input [0,0] is the
  nearest-invalid counterpart and must stay infeasible; the nested
  NOT(a = +0.0) AND b case proves the case-(1) short-circuit does
  not protect the production arm and exercises the signed-zero guard.
- cp_solver.rs: direct propagate_comparison unit tests for Eq + FALSE
  covering identical singletons (infeasible), signed-zero singletons
  (infeasible under comparison semantics), distinct singletons
  (feasible), and overlapping intervals including the [-1,1] vs [0,0]
  case from the issue.

Negative control on baseline 6eaca8b with the production arm
reverted to Ok(None): 3 of the 7 new tests fail. The 4 that pass on
baseline are boundary guards (baseline returns None for every
Eq + FALSE input, so it agrees by accident). A second, independent
control covers those guards: replacing singleton_values_equal with
structural == makes both signed-zero tests fail, confirming they
exercise the comparison-semantics guard rather than passing
vacuously.

Closes apache#19264.

Co-authored-by: evangelisilva <[email protected]>
haohuaijin pushed a commit to haohuaijin/arrow-datafusion that referenced this pull request Aug 28, 2026
…cp_solver (apache#24464)

## Which issue does this PR close?

- Closes apache#19264.

## Rationale for this change

`physical_expr::analyze` is a public API. For a satisfiable predicate
such as
`NOT (a = 0.0)` over `a ∈ [-1, 1]`, it currently returns `None`, which
`ExprBoundaries::interval` documents as an empty set. The safe result is
the
unchanged input interval because one interval cannot represent the hole
at zero.

The same infeasible-sentinel mistake exists in `propagate_comparison`'s
catch-all
uncertainty arm. That arm is shared by every supported comparison
operator that
reaches it with an uncertain Boolean parent. Returning the child
intervals unchanged
preserves their enclosure for all operand shapes, including shapes
beyond the three
issue test functions; it does not claim a refinement that interval
arithmetic cannot
prove.

This carries forward the original patch by @evangelisilva in apache#20138 and
preserves
its reviewed equal-singleton infeasibility guard. The original bug-fix
commit in this PR contains a valid `Co-authored-by` trailer for the
original author.

## What changes are included in this PR?

- For `Eq + FALSE`, preserve both child intervals unless both are
bounded,
  equal singletons. Only the latter case is provably infeasible.
- For an uncertain comparison parent, return both child intervals
unchanged
  instead of returning the infeasible sentinel.
- Add direct tests for bounded-singleton × unbounded and unbounded ×
unbounded,
  preventing `[NULL, NULL]` from being mistaken for a known singleton.
- Add end-to-end coverage for `NOT (a = 0.0)`, `not_between(...)`, and
explicit
  `between(...).not()` over the issue's input domains.

## Are these changes tested?

Yes. The three issue test functions execute independently:

- `test_analyze_not_eq_around_zero` passes with the repair.
- `test_analyze_not_between_clamped` is a baseline-passing negative
control: its
  infeasible result remains unchanged.
- `test_analyze_not_between_containing` passes with the repair for both
NOT spellings.

The complete `datafusion-physical-expr` library suite passes: 1607
passed,
0 failed, 2 ignored. Formatting and clippy with all targets and all
features
also pass.

## Are there any user-facing changes?

No API signature change. `analyze()` no longer reports a satisfiable
negated
comparison as infeasible when interval arithmetic cannot refine its
operands.

---------

Signed-off-by: Han-Yin Chang <[email protected]>
Co-authored-by: evangelisilva <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-expr Changes to the physical-expr crates Stale PR has not had any activity for some time

Projects

None yet

Development

Successfully merging this pull request may close these issues.

incorrect results when using NOT physical expression in physical_expr::analyze

4 participants