Skip to content

fix: make float filters treat -0.0 and 0.0 as the same value - #6236

Merged
Xuanwo merged 33 commits into
lance-format:mainfrom
LuciferYang:fix-5868
Sep 1, 2026
Merged

fix: make float filters treat -0.0 and 0.0 as the same value#6236
Xuanwo merged 33 commits into
lance-format:mainfrom
LuciferYang:fix-5868

Conversation

@LuciferYang

@LuciferYangLuciferYang commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Fixes#5868.

IEEE 754 and SQL treat -0.0 and 0.0 as one number. Arrow and DataFusion 54 order floats by total order, which ranks -0.0 strictly below 0.0, and test equality on to_bits(). A filter over a float column therefore answers by encoding rather than by value. With this data:

value: +0.0 -0.0 +inf -inf NaN 1.0 -1.0 MIN MAX NULL
id: 0 1 2 3 4 5 6 7 8 9

value < 0.0 returns ids 1, 3, 6, 7, and id 1 is -0.0, which is not less than zero. value = 0.0 returns id 0 and misses id 1.

Approach

Rewrite the literal at the end of Planner::optimize_expr, replacing a zero with whichever encoding answers the operator correctly:

predicaterewritten to
value < 0.0, value >= 0.0compare against -0.0
value <= 0.0, value > 0.0compare against +0.0
value = 0.0value IN (-0.0, 0.0)
value != 0.0value NOT IN (-0.0, 0.0)
value IN (0.0, 1.0)value IN (-0.0, 0.0, 1.0)
0.0 IN (a, b)a IN (-0.0, 0.0) OR b IN (-0.0, 0.0)
value IS NOT DISTINCT FROM 0.0(value IN (-0.0, 0.0)) IS TRUE
value IS DISTINCT FROM 0.0(value IN (-0.0, 0.0)) IS NOT TRUE

The ordered comparisons stay a single literal, so they cost the same as before. An equality against zero becomes a two-value lookup. The rewrite needs no recheck and changes no index code: the scalar indices and the in-place filter both keep their existing behaviour, and they now agree with each other.

IS [NOT] DISTINCT FROM is reachable from a hand-built Expr but not from SQL, which rejects it in planner.rs. The IS TRUE pairing is what keeps the null case decided while naming the operand once: NULL IN (..) is NULL, and NULL IS TRUE is false, which is what distinctness means for a null against a non-null literal. An earlier revision guarded that arm to a bare column so it could write IS NOT NULL AND IN (..), which names the operand twice; bailing out on anything else left filter_expr answering computed operands like value * 2.0 on Arrow's order, so the guard bought a rare double evaluation at the price of wrong rows.

The rewrite runs in two places: normalize_zero_comparisons before simplification and rewrite_signed_zero_comparisons after it. Coercion has to precede both, or value = 0 keeps an integer literal and never reaches the float arm.

normalize_zero_comparisons exists because a rewrite that only runs on the finished expression cannot reach a comparison whose zero does not exist yet. ExprSimplifier::simplify folds an operand and everything above it in one pass, so -1.0 * 0.0 < (1.0 - 1.0) went straight to a boolean decided by Arrow's total order, answering true where IEEE says false. It walks bottom-up and folds only the operands of the node in hand, so by the time any container is folded, every comparison inside it already carries the corrected literal.

That traversal deliberately does not enumerate which containers may sit above a comparison. An earlier revision listed AND, OR and NOT, which left IS TRUE, IS FALSE, = TRUE, CAST(.. AS BOOLEAN) and IN (TRUE) folding the inner comparison under the old semantics, and any such list would keep missing the next spelling.

An operand that is already a literal skips the simplifier, because a literal cannot fold further and an IN list can hold hundreds of them. Without that, planning a large list roughly doubled in cost. Paired measurement, same binary with only the short-circuit toggled, 20 optimizations each: a 2048-element integer list went from 18.62ms to 6.89ms, a 256-element one from 2.10ms to 0.90ms, and a 257-element float list, which still has to be widened, from 2.34ms to 1.03ms.

The pass after simplify catches what only becomes visible later: simplify is what expands BETWEEN into two comparisons and folds the casts coercion inserts. folded_constant_comparisons_use_ieee_semantics covers 25 shapes across both, and the between case of the fixed-point test covers the last.

One consequence worth flagging for review: BETWEEN needed its own arm in the rewrite. It normally arrives already expanded into >= and <=, which is why it had none, but a fully constant BETWEEN never arrives expanded because simplify expands and folds it in the same pass. The arm gives low the -0.0 encoding and high the +0.0 one, matching what the expanded operators would take.

The hook is optimize_expr, not create_filter_plan, because the latter is not the only entry point. projection.rs and the memtable scanner's plan_full_scan family call optimize_expr and then build a physical expression directly, so hooking the filter plan would let one predicate get two different answers inside a single result set.

The output has to be a fixed point of optimize_expr, not of the rewrite alone. DataFusion's ShortenInListSimplifier expands an IN list of at most three elements over a bare column back into an OR chain, and the scan path optimizes twice, so the second pass can reprocess the rewrite's own output. That showed up as value = 0.0 planning to value IN ([-0,0]) OR value IN ([-0,0]) and searching the index twice. rewrite_node therefore dedupes equivalent OR and AND operands, and optimizing_twice_changes_nothing drives a real Planner rather than the rewrite by itself. That property is now load-bearing twice over: with two passes inside optimize_expr and the scan path calling it twice, a zero predicate goes through the rewrite four times.

Behaviour change

value = 0 now matches rows holding -0.0, in Rust and through the Python and Java bindings.

The test that pinned the bug

test_query_float_special_values checked value > 0.0, value < 0.0 and value = 0.0 with test_filter, which compares Lance against the same DataFusion release. That makes the reference implementation the source of the bug, so the test passed on the wrong answer. Those three cases now assert row ids, and the case list now also covers both spellings of the literal, a literal on the left, BETWEEN, IN, and composition with IS NULL. assert_filter_ids runs every predicate twice, once through the dataset's index and once with scalar indices turned off, because DatasetTestCases never generates the no-index variant on its own.

Out of scope

None of these turn on a zero literal, so the rewrite leaves them where they are:

  • NaN ordering. Arrow sorts NaN above every value, so it survives > and >=. The new assertions pin this rather than change it.
  • Column against column comparisons, GROUP BY, DISTINCT, ordering, and the array_has family. There is no literal to rewrite.
  • merge_insert join keys. DataFusion 54 hashes join keys on raw bits, so the two zeros land in different buckets. test_merge_insert_on_float_zero_key now runs with and without an index to pin current behaviour.
  • Float16 columns. safe_coerce_scalar has no Float16 arm, so no numeric literal resolves against a Float16 column at all. Tracked in bug: filtering a Float16 column with a numeric literal always fails #8846 and fixed by fix(datafusion): coerce numeric literals to and from Float16 #8847, which makes the Float16 branch of this rewrite reachable; it is unreachable dead code until that lands.
  • KNN distance_range. Its bounds do not pass through Planner.

Test plan

  • cargo test -p lance-datafusion (142 passed), including 69 tests in the new signed_zero module
  • cargo test -p lance-index (1173 passed)
  • cargo test -p lance --lib -- --test-threads=1 (3179 passed)
  • cargo test -p lance --test integration_tests --features slow_tests -- --test-threads=1 (51 passed)
  • cargo clippy --all --tests --benches -- -D warnings clean, cargo fmt --all applied
  • All three lockfiles refreshed for the new half dependency in lance-datafusion

@github-actions

Copy link
Copy Markdown
Contributor

ACTION NEEDED
Lance follows the Conventional Commits specification for release automation.

The PR title and description are used as the merge commit message. Please update your PR title and description to match the specification.

For details on the error please inspect the "PR Title Check" action.

@github-actionsgithub-actionsBot added the bug Something isn't working label Mar 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Review

Clean, well-structured fix for the IEEE 754 -0.0 vs +0.0 inconsistency. The multi-layer approach (ArrowScalar row encoding, OrderableScalarValue Ord, query expression literals, coercion) is the right strategy — normalizing at each boundary rather than trying a single choke-point.

Minor observations (not blocking)

Unnecessary allocations for +0.0: The v == 0.0 check matches both -0.0 and +0.0, so normalize_float_neg_zero allocates a new length-1 array even when the value is already +0.0. A tighter check like v.to_bits() == f32::NEG_ZERO.to_bits() (or v.is_sign_negative() && v == 0.0) would skip the allocation for the common +0.0 case. Same applies to normalize_float_scalar and the Ord impl. The cost is negligible for scalars, but it would express the intent more precisely.

DRY opportunity: normalize_float_scalar in lance-index/src/scalar.rs and the inline normalization in btree.rsOrd impl do the same conceptual operation on different representations. Not a problem today, but if more float-handling sites appear, a shared normalization trait or utility could reduce surface area.

Overall: good fix, good tests, good PR description. LGTM.

@LuciferYangLuciferYang changed the title fix: normalize IEEE 754 -0.0 to +0.0 for consistent float comparison fix: normalize IEEE 754 -0.0 to +0.0 for consistent float comparisonMar 20, 2026
@codecov

codecovBot commented Mar 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.69231% with 16 lines in your changes missing coverage. Please review.

Files with missing linesPatch %Lines
rust/lance-datafusion/src/expr.rs40.00%5 Missing and 1 partial ⚠️
rust/lance-index/src/scalar.rs75.00%6 Missing ⚠️
rust/arrow-scalar/src/lib.rs96.55%1 Missing and 1 partial ⚠️
rust/lance-index/src/scalar/btree.rs94.73%2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@LuciferYang

Copy link
Copy Markdown
ContributorAuthor

There are test failures, and I need to investigate them.

@LuciferYang

Copy link
Copy Markdown
ContributorAuthor

The test failure is due to inconsistent behaviors between Rust Arrow/DataFusion and PyArrow.

Rust Arrow 57 (arrow-ord)

Arrow's comparison kernels (lt, eq, gt, etc.) use total_cmp as defined in IEEE 754-2008 totalOrder predicate. From the Arrow docs:

For floating values like f32 and f64, this comparison produces an ordering in accordance to the totalOrder predicate as defined in the IEEE 754 (2008 revision) floating point standard. Note that totalOrder treats positive and negative zeros as different. If it is necessary to treat them as equal, please normalize zeros before calling this kernel.

Under total_cmp: -0.0 < 0.0 is true, and -0.0 == 0.0 is false.

DataFusion 52.3.0

DataFusion delegates comparison to Arrow's kernels via apply_cmp -> arrow::compute::kernels::cmp::lt. Verified with DataFusion Python:

DataFusion SQL: value < 0.0
ids: [1, 3, 6, 7] ← includes -0.0 (id=1)
DataFusion SQL: value = 0.0
ids: [0] ← excludes -0.0

PyArrow 23 (C++ Arrow)

PyArrow follows IEEE 754 comparison semantics:

less(-0.0, 0.0) = False
equal(-0.0, 0.0) = True

Summary Table

OperationIEEE 754total_cmp (Rust Arrow 57 / DataFusion 52)PyArrow (C++ Arrow)
-0.0 < 0.0falsetruefalse
-0.0 == 0.0truefalsetrue
-0.0 <= 0.0truetruetrue

Conflict

This pr normalizes -0.0 to +0.0 in multiple locations to implement IEEE 754 semantics:

  • ArrowScalar row encoding (Eq/Ord/Hash consistency)
  • OrderableScalarValue::Ord (btree index comparisons)
  • SargableQuery::to_expr / BloomFilterQuery::to_expr (query expression literals)
  • safe_coerce_scalar (float coercion)
  • Bitmap index training (HashMap key normalization)

This makes Lance's index results inconsistent with DataFusion/Arrow scan results:

  • Lance bitmap index (value < 0.0): excludes -0.0 (IEEE 754)
  • DataFusion scan (value < 0.0): includes -0.0 (total_cmp)

The integration test test_query_float_special_values catches this inconsistency because it validates Lance scanner output against DataFusion SQL output.

Recommended Approach: Option B + C

After analysis, neither Option B (index/query normalization) nor Option C (storage normalization) alone is sufficient. The recommended approach combines both:

Option B: Index/query layer normalization (current commit)

Normalize -0.0 to +0.0 in index building, query expression conversion, and scalar comparisons. This is the current implementation on branch fix-5868.

Handles: Historical data that already has -0.0 stored on disk. When these values flow through indexes and queries, they are normalized at the comparison/expression boundary.

Option C: Storage/ingestion layer normalization (additional work needed)

Normalize -0.0 to +0.0 when writing data to Lance files at the writer/encoding layer.

Handles: New data. Eliminates -0.0 at the source so all downstream code (indexes, scans, DataFusion filters) sees only +0.0. This removes the total_cmp vs IEEE 754 divergence entirely for new writes, since +0.0 < 0.0 is false under both semantics.

Why both are needed

ScenarioOption B aloneOption C aloneB + C
New data, index queryCorrectCorrect (no -0.0 to compare)Correct
New data, DataFusion scan filterDiverges (index: IEEE 754, scan: total_cmp)Correct (no -0.0 to compare)Correct
Historical data, index queryCorrectWrong (-0.0 still on disk)Correct
Historical data, DataFusion scan filterDivergesWrongDiverges (unavoidable without rewrite)
  • Option B alone fails for new data: Lance index results (IEEE 754) will differ from DataFusion scan results (total_cmp) when the data contains -0.0.
  • Option C alone fails for historical data: existing Lance files still contain -0.0 on disk. Indexes built from historical data will encounter -0.0 values.
  • B + C together are fully correct for new data. For historical data, index queries are correct (Option B normalizes at comparison time), but a raw DataFusion scan filter on un-rewritten data may still include -0.0 in < 0.0 results due to total_cmp. This edge case is unavoidable without rewriting the data files, and is acceptable since:
    • It only affects legacy data written before the fix
    • Users can resolve it by rewriting/compacting their datasets
    • The index path (which is the primary query path) gives correct results

Integration test update

The integration test test_query_float_special_values compares Lance scanner output against DataFusion SQL on the original in-memory batch. With Option C, the stored data no longer contains -0.0, so the test baseline must be constructed from data read back from the Lance dataset (post-normalization) rather than the pre-write in-memory batch. This is a test-only change.

Other Options (not recommended)

Option A: Match DataFusion/Arrow (total_cmp semantics)

Revert -0.0 normalization from OrderableScalarValue::Ord, bitmap training, and SargableQuery::to_expr. Keep only the ArrowScalar Eq/Hash normalization (which has its own contract independent of DataFusion).

Pros:

  • Lance index results match full-scan results (both use total_cmp)
  • Consistent with the Rust Arrow/DataFusion ecosystem
  • Integration tests pass as-is

Cons:

Dear project maintainers, what are your thoughts on this?

@XuanwoXuanwo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

  1. Full-scan and flat BTree paths still evaluate comparisons against original float values, while only the literal side is normalized. Signed-zero queries can still return different results across scan and index paths.
  2. Bitmap indexes can keep the two signed zeros as separate training keys but load them through a comparator that treats them as equal. Exact bitmap queries can lose one side of the zero rows.
  3. Bloom pruning still hashes raw float bytes before any normalized expression can recheck results. A zone containing one signed-zero encoding can be pruned for a query using the other encoding.

Conflicts were all additive: arrow-scalar import list (upstream added AsArray/FloatXXType for ArrowScalar::is_nan), SargableQuery::to_expr (upstream added the LikePrefix arm next to Equals), and adjacent test blocks in btree.rs.
Measured on a 4-value dataset [-1.0, -0.0, 0.0, 1.0], all four hunks either
did nothing for the reported bug or made results worse:
- safe_coerce_scalar normalizes the SQL literal, but the reported filter
`x < 0.0` already carries +0.0, so the -0.0 row is still returned. It also
turns `x < -0.0` and `x >= -0.0` from IEEE-correct into wrong, because the
data side keeps its sign while the literal loses it.
- OrderableScalarValue::cmp made -0.0 == +0.0 while ScalarValue's PartialEq
and Hash stayed bitwise, so bitmap training still emits two zero keys and
the load path collapses them at bitmap.rs:430, orphaning one bitmap. Rows
under the dropped key become unreachable, and the loss is re-serialized on
remap/merge/update. It also breaks the Ord/Eq contract.
- SargableQuery::to_expr builds the recheck expression; semantics there belong
to the expression layer, not to the index.
- ArrowScalar::compute_row contradicts the type's documented total ordering
and arrow-stats' find_extrema_float, which selects extrema with total_cmp
precisely to distinguish the two zeros.
Scalar indices select candidates in arrow's total order, where -0.0 sorts
strictly below +0.0, and bloom filters hash the raw float bytes. Expression
evaluation follows IEEE 754, where the two compare equal. A query on one
encoding therefore prunes the rows stored under the other before any recheck
can look at them: a btree page or zone whose extremum is -0.0 is skipped for
`= +0.0`, a bitmap key lookup finds only one of the two keys, and a bloom
probe misses the other block.
Nothing is lost today because DataFusion 54 evaluates float comparisons in
total order too, so index and scan agree on the same wrong answer. It starts
losing rows the moment expression evaluation becomes IEEE-correct, which is
what the DataFusion 55 signed-zero normalization (apache/datafusion#22835)
does.
So widen the query, not the comparators: a query on a float zero offers both
encodings as candidates and reports AtMost, and the parser forces the matching
recheck so expression evaluation still decides which rows match. Candidate
sets only grow, never shrink, which keeps results identical on DataFusion 54
and makes them IEEE-correct on 55 without rebuilding any index.
Range bounds only need widening where an inclusive bound leaves the other
encoding out (`>= +0.0`, `<= -0.0`); exclusive bounds already admit every row
IEEE 754 matches, so `> 0.0` and `< 0.0` stay exact.
# Conflicts:
#	rust/arrow-scalar/src/lib.rs
#	rust/lance-index/src/scalar.rs
#	rust/lance-index/src/scalar/btree.rs
@github-actionsgithub-actionsBot added the A-index Vector index, linalg, tokenizer label Aug 26, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 26, 2026
Review of the previous commit turned up two ways the unconditional widening was
wrong.
Callers that use an index result as the answer cannot take a wider candidate
set. merge_insert's key lookup builds its own query with `needs_recheck: false`
and then panics on a non-exact result; the label-list sub-index turns one into
an internal error. So make the caller's intent explicit: `SearchOptions` grows
an `is_rechecked` flag, `ScalarIndexExpr::evaluate_with_options` sets it from
the plan's `needs_recheck`, and btree and bitmap widen only when it is on. Zone
map and bloom keep widening unconditionally, since a zone result is always
`AtMost` and every caller already narrows it down.
The recheck also has to cover more than the queries that get rewritten. A bound
like `> -0.0` needs no rewrite, yet total order still admits `+0.0`, which IEEE
754 excludes. Deciding exactness from "did the query get rewritten" left those
shapes claiming `Exact`, and the V2 read path keys off exactly that claim rather
than the plan flag. Both now come from one boolean.
# Conflicts:
#	rust/arrow-scalar/src/lib.rs
#	rust/lance-index/src/scalar.rs
#	rust/lance-index/src/scalar/btree.rs
@lance-gatekeeperlance-gatekeeperBot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 26, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 26, 2026
Replaces the index-side candidate widening with a rewrite of the filter
expression, so scan and index paths both answer zero comparisons the way
IEEE 754 and SQL define, with no recheck.
Filter planning was not the only place Lance compiles an expression, so a
projected copy of the same predicate, and a memtable scan, still answered by
Arrow's total order. Also covers a zero literal on the probe side of IN,
IS [NOT] DISTINCT FROM, and restores the IS NOT NULL elision for NOT IN.
IS [NOT] DISTINCT FROM expanded into a pair of probes that matched again on
the next optimize pass, so an expression re-optimized N times grew 2^N. The
memtable BTree fast path is chosen from the un-optimized filter, so a float
zero got a bit-exact lookup while the scan beside it answered per IEEE 754.
@lance-gatekeeperlance-gatekeeperBot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 29, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 29, 2026
An IN list can hold hundreds of literals and each one was going through the
simplifier for nothing. Paired measurement on a 2048-element int list: 18.6ms
to 6.9ms for 20 optimizations.
@lance-gatekeeperlance-gatekeeperBot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Aug 30, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 30, 2026
@lance-gatekeeperlance-gatekeeperBot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 31, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 31, 2026
@LuciferYang

Copy link
Copy Markdown
ContributorAuthor

The test failures seems unrelated to this PR.

@lance-gatekeeperlance-gatekeeperBot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 1, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 1, 2026
@lance-gatekeeperlance-gatekeeperBot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 1, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 1, 2026
@lance-gatekeeperlance-gatekeeperBot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 1, 2026

@lance-gatekeeperlance-gatekeeperBot 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.

Gate recommendation: approve.

The effective PR patch remains unchanged after merging main. The compact fragment-reuse remapper sits below scalar-index predicate evaluation, and the signed-zero scalar-index pushdown regression still passes, so the prior acceptance basis holds.

@lance-gatekeeperlance-gatekeeperBot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 1, 2026
@Xuanwo
Xuanwo merged commit f03a278 into lance-format:mainSep 1, 2026
38 checks passed
@LuciferYang

Copy link
Copy Markdown
ContributorAuthor

Thank you @Xuanwo

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-depsDependency updatesA-indexVector index, linalg, tokenizerA-javaJava bindings + JNIA-pythonPython bindingsbugSomething isn't workingK-approvedLatest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

incorrect handling of -0.0 in comparison

2 participants

@LuciferYang@Xuanwo