Skip to content

fix(datafusion): coerce numeric literals to and from Float16 - #8847

Open
LuciferYang wants to merge 9 commits into
lance-format:mainfrom
LuciferYang:fix/float16-coerce
Open

fix(datafusion): coerce numeric literals to and from Float16#8847
LuciferYang wants to merge 9 commits into
lance-format:mainfrom
LuciferYang:fix/float16-coerce

Conversation

@LuciferYang

@LuciferYangLuciferYang commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Problem

Fixes#8846.

A Float16 scalar column can be written and scanned, but no numeric literal ever reaches it, so it cannot be filtered at all:

Invalid user input: Error resolving filter expression value < 0.0: Invalid user input: Received literal Float64(0) and could not convert to literal of type 'Float16'

safe_coerce_scalar had no Float16 arm in either direction, and the second direction fails silently. Adding the DataType::Float16 targets alone makes filters work, but maybe_scalar then calls safe_coerce_scalar on a literal the planner has already coerced to Float16, so without a ScalarValue::Float16 source arm the predicate drops to a refine filter. Rows come back correct, so only the plan shows it. On a Float16 column with a BTree index, at 6 rows and at 5000:

before: LanceRead: ... full_filter=value = Float16(1), refine_filter=value = Float16(1)
after: LanceRead: ... full_filter=value = Float16(1), refine_filter=--
ScalarIndexQuery: query=[value = 1]@value_idx(BTree)

A Float32 column of the same shape and query already planned the second form, which is the control that says this is a Float16 gap and not a row-count heuristic.

What this changes

DataType::Float16 is now a target in all ten numeric source arms, and ScalarValue::Float16 is a new source arm reaching Float16, Float32 and Float64.

Out-of-range literals are rejected, not saturated. This differs from the Float64 to Float32 arm next to it. f16 overflows at 65520, which an ordinary literal passes, and safe_coerce_scalar cannot see the operator, so it cannot saturate only where saturation is harmless: collapsing 1e-30 to zero would make value = 1e-30 match real zeros. The cost is that value < 100000 now errors where saturating would have returned every finite row. I took the error over the wrong row set. The Float32 arm is left alone because reaching its boundary takes a literal above 1e38.

Rounding is done by neighbour comparison, because half rounds incorrectly in both of its paths: the software one truncates the low 32 bits of the f64 mantissa before rounding (half-rs#151), and the x86 hardware one rounds through f32 first (half-rs#116). nearest_finite_f16 starts from the software answer, which at least does not vary by target, then picks the true nearest among it and its two bit-neighbours, tie to even. Widening f16 back to f64 is exact, so those three comparisons decide it. = 2049.001 now coerces to 2050 where half gives 2048.

Overflow is decided by value.abs() >= 65520.0 rather than by half. 65520 is the exact IEEE tie above the largest finite f16 and is exactly representable in f64, so the check does not inherit the platform-dependent boundary.

The f16 grid itself stays inexact: past 2048 it is coarser than the integers, so = 2049 matches rows holding 2048, the same way the Float32 and Float64 arms are inexact on finer grids.

Out of scope

IndexType::BloomFilter rejects Float16 outright, so the Float16 cases of test_query_float and test_query_float_special_values run BTree, Bitmap and ZoneMap only. test_bloom_filter_rejects_float16 pins that refusal so the skip cannot outlive it.

Test plan

  • cargo test -p lance-datafusion (176 and 5 passed). 24 new cases over safe_coerce_scalar: every numeric literal type reaching Float16, the range and tie edges pinned as raw bits, non-finite passthrough, both zeros keeping their sign. test_f16_rounds_to_nearest_even_across_the_whole_range is the one to read: for each of the 31743 adjacent finite f16 pairs it asserts the midpoint and the two f64 values either side, with expectations stated rather than recomputed by the code under test. Stubbing the correction back out to from_f64_const makes it fail.
  • cargo test -p lance-index (1187 and 9 passed). test_float16_column_reaches_its_index drives Planner::parse_filter and create_filter_plan so it runs the production coercion order; deleting the ScalarValue::Float16 source arm turns all four cases red. Built on check_with_schema it would not have, because that helper skips type coercion and so exercises the target arm instead.
  • cargo test -p lance --lib (3300 passed)
  • cargo test -p lance --test integration_tests --features slow_tests -- --test-threads=1 (54 passed). --features slow_tests is required: mod query is behind that gate, so without it the new tests match nothing and cargo still exits 0. The Float16 case of test_query_float_special_values is new; fix: make float filters treat -0.0 and 0.0 as the same value #6236 already carried the Float16 branch of its signed-zero rewrite, and this is the first coverage that reaches it. Reverting expr.rs to main makes that case fail with the original coercion error while Float32 and Float64 stay green.
  • cargo clippy --all --tests --benches --features slow_tests -- -D warnings clean, cargo fmt --all -- --check clean

@github-actionsgithub-actionsBot added A-python Python bindings A-index Vector index, linalg, tokenizer A-java Java bindings + JNI bug Something isn't working A-deps Dependency updates labels Aug 28, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Aug 28, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Aug 28, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeperlance-gatekeeperBot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 28, 2026
@LuciferYang
LuciferYang marked this pull request as draft August 30, 2026 08:01
@lance-gatekeeperlance-gatekeeperBot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 30, 2026
Xuanwo added a commit that referenced this pull request Sep 1, 2026
## 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:
| predicate | rewritten to |
|---|---|
| `value < 0.0`, `value >= 0.0` | compare against `-0.0` |
| `value <= 0.0`, `value > 0.0` | compare against `+0.0` |
| `value = 0.0` | `value IN (-0.0, 0.0)` |
| `value != 0.0` | `value 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
#8846 and fixed by #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`
---------
Co-authored-by: Xuanwo <github@xuanwo.io>
safe_coerce_scalar had no Float16 arm in either direction, so a Float16
column could not be filtered at all, and the index layer silently dropped
the predicate to a refine filter.
Drive the index test through the production coercion order, pin the f16
boundary in bits, use the target-independent conversion, and stop the doc
from claiming rounding is harmless.
The previous wording said the truncation was unreachable from a written
literal. It is reachable: = 2049.001 matches rows holding 2048.
Four review rounds found a wrong clause in this one comment, each time in
the sentence explaining the mechanism rather than the one stating it. The
prose now stops at what it can defend and points at the cases.
from_f64 is correctly rounded on aarch64 with fp16, so the claim is only
true of the const form.
Neither half entry point is correctly rounded: the software path truncates
the low 32 mantissa bits before rounding (half-rs#151) and the x86 path goes
through f32 (half-rs#116). Pick the true nearest among half's answer and its
two neighbours, and take the overflow decision off half entirely.
The Float16 case was left out of test_query_float_special_values because
literal coercion could not reach the type. This PR supplies that arm, so
the case runs now.
@LuciferYang
LuciferYang marked this pull request as ready for review September 1, 2026 08:51
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
@LuciferYang

Copy link
Copy Markdown
ContributorAuthor

cc @Xuanwo FYI

@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
@LuciferYang

Copy link
Copy Markdown
ContributorAuthor

The red windows-build is not this diff. It panics on debug_assert_eq!(acc as u64, total) in PrefixSums::from_deltas during an IVF_HNSW_SQ index build, which this change cannot reach: it touches Float16 literal coercion in lance-datafusion/src/expr.rs, the scalar-index expression path, and one query test. Filed as #8947.

@lance-gatekeeperlance-gatekeeperBot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 3, 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 main-branch merge leaves the reviewed Float16 patch unchanged. Deterministic nearest-even coercion, scalar-index reachability, and indexed/unindexed signed-zero coverage remain intact on the updated base.

@lance-gatekeeperlance-gatekeeperBot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 3, 2026
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.

bug: filtering a Float16 column with a numeric literal always fails

2 participants

@LuciferYang@Xuanwo