Skip to content

fix(index): give every NaN one index key, below -Infinity - #282

Open
CaffeinatedCoder wants to merge 1 commit into
fix/249-negative-zero-index-keyfrom
fix/index-key-nan-canonical
Open

CaffeinatedCoder wants to merge 1 commit into
fix/249-negative-zero-index-keyfrom
fix/index-key-nan-canonical

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #252 (base fix/249-negative-zero-index-key): it lands after #252. Rebased onto #252's head 8a6f320, so IndexKey(double) has both changes: a zero becomes +0.0 first, then every NaN takes the key of double.NaN. I will retarget it to main once #252 merges. The diff here is the one commit on top of #252.

What was wrong

IndexKey(double) gave a NaN with the sign bit clear a key above +Infinity, and a NaN with the sign bit set (the constant double.NaN) a key below -Infinity (#257). Every computed NaN on Arm64 (0.0 / 0.0, Math.Sqrt(-1), ∞ - ∞) has the sign bit clear; on x64 it has it set. So index-served orderings put those documents last on one CPU and first on the other, and index-served counts with a lower bound (Count(x => x.Ratio > 0.5) through CountRange) included them.

Mechanism

The sort-preserving encoding (IndexKey.cs:94-105) flips all bits of a value with the sign bit set and only the sign bit otherwise. A NaN's sign bit and payload are arbitrary, so the two NaN halves landed below -Inf and above +Inf, and every payload got a distinct key.

Fix

Every NaN now takes one key: that of the bits 0xFFF8000000000000 (double.NaN), spelled out as a constant so the key does not depend on how the NaN was produced. That key sorts below -Infinity, which is where double.CompareTo (and so LINQ-to-Objects and the unindexed path) puts NaN. A range with a finite lower bound no longer reaches it.

Range semantics: NaN is now the smallest double key, as -Infinity was before. < x served from the index includes NaN, the same way it includes -Infinity. Whether < x should exclude NaN (C# comparison operators say false) is the open question the issue names; the BSON post-filter decides for Where, not for Count. I left that as it is.

Tests

IndexKeyNaNTests:

  • Every_NaN_Has_The_Key_Of_Double_NaN_Below_Negative_Infinity, a theory over a positive NaN (0x7FF8…), a positive payload NaN, a negative payload NaN and double.NaN. Each must equal IndexKey.Create(double.NaN), sort below -Infinity and MinValue, and round-trip to a NaN.
  • Index_Orders_NaNs_First_And_Excludes_Them_From_A_Lower_Bounded_Range, on BTreeIndex: the issue's d1 (computed NaN), d2 (1.0) and d3 (double.NaN) plus +Inf. Ascending order is NaNs first, then 1.0 and +Inf; >= 0.5 returns 1.0 and +Inf only; >= double.MaxValue returns +Inf only.

Four of the five cases fail on upstream/main (checked by reverting src/ with git checkout HEAD~1 -- src); the double.NaN row passes there, since that NaN already had this key. All pass with the fix.

dotnet test tests/BLite.Tests: 3095 passed, 4 skipped. Two tests failed under load from parallel runs (SpatialVectorIndexMaintenanceTests.Vector_*, BTreeSplitTests.Dynamic_*). Both classes pass when rerun on their own (the spatial class 4 runs out of 4), and the spatial index does not use IndexKey.

Compatibility

Fixes #257

The B-tree model test PR (#259) lists this as a known candidate. Its registry entry can go once this lands.

🤖 Generated with Claude Code

IndexKey(double) flips all bits of a value with the sign bit set and only
the sign bit otherwise. A NaN's sign bit and payload are arbitrary, so a
NaN with the sign bit clear (what Arm64 computes for 0.0 / 0.0, Sqrt(-1)
or Inf - Inf) encoded above +Infinity while double.NaN (sign bit set, what
x64 computes) encoded below -Infinity, and every payload was a distinct
key. Index-served orderings then put documents holding a computed NaN
last on Arm64 and first on x64, and index-served counts with a lower bound
(Count(x => x.Ratio > 0.5) through CountRange) included them.

Every NaN now takes the key of the bits 0xFFF8000000000000 (double.NaN),
spelled out so it does not depend on how the NaN was produced. That key
sorts below -Infinity, where double.CompareTo puts NaN, and a range with a
finite lower bound no longer reaches it. Existing indexes holding a NaN
with the sign bit clear keep its old key until they are rebuilt.

Found-By: OT
Defect-Class: IDX
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder
CaffeinatedCoder force-pushed the fix/index-key-nan-canonical branch from f41339f to ea52725 Compare October 18, 2026 15:47
@CaffeinatedCoder CaffeinatedCoder added the stacked Base is another open PR; merge in order, CI does not run until it targets main label Oct 18, 2026
@CaffeinatedCoder
CaffeinatedCoder changed the base branch from main to fix/249-negative-zero-index-key October 18, 2026 15:48

@mrdevrobot mrdevrobot 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.

Changes requested. The fix is right, but positive-NaN keys already stored on Arm64 stay under the old key with no migration: lookups miss them and deleting the document leaves a stale index entry. Detect the old above-+Infinity NaN key in NeedsKeyFormatMigration, as #252 does for -0.0, so affected indexes rebuild on open.

@CaffeinatedCoder

Copy link
Copy Markdown
Collaborator Author

I can no longer push to this branch, so #327 carries this fix rebased onto main, plus the requested migration of old above-+Infinity NaN keys in NeedsKeyFormatMigration with a regression test. The branch fix/index-key-nan-canonical on EntglDb can be deleted once #327 is in.

This branch was successfully deployed

1 active deployment
Production — ea52725c Deployed Oct 18, 2026 by CaffeinatedCoder via build_and_publish #773
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/indexing B-Tree and secondary indexes, index lifecycle bug Something isn't working package/core Affects BLite.Core stacked Base is another open PR; merge in order, CI does not run until it targets main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A NaN with the sign bit clear sorts above +Infinity in an index on a double

2 participants