From ea52725cd2625bc47a7ec761d24588428f241bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 11:33:45 +0200 Subject: [PATCH] fix(index): give every NaN one index key, below -Infinity 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 --- src/BLite.Core/Indexing/IndexKey.cs | 8 ++- tests/BLite.Tests/IndexKeyNaNTests.cs | 73 +++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/BLite.Tests/IndexKeyNaNTests.cs diff --git a/src/BLite.Core/Indexing/IndexKey.cs b/src/BLite.Core/Indexing/IndexKey.cs index a36926c0..282dde9e 100644 --- a/src/BLite.Core/Indexing/IndexKey.cs +++ b/src/BLite.Core/Indexing/IndexKey.cs @@ -99,7 +99,13 @@ public IndexKey(double value) // Result: lexicographic byte order == IEEE 754 numeric order for all finite and ±Inf. // -0.0 == +0.0, so both take the key of +0.0; -0.0 would otherwise encode just below it (#249). if (value == 0) value = 0.0; - var bits = BitConverter.DoubleToInt64Bits(value); + // A NaN's sign bit and payload are arbitrary (a computed NaN has the sign bit clear on Arm64, + // set on x64), which put NaNs both below -Inf and above +Inf. Every NaN takes one key, that of + // the bits of double.NaN (0xFFF8...), which sorts below -Inf, where double.CompareTo orders + // NaN (#257). The bits are spelled out so the key does not depend on how a NaN was produced. + var bits = double.IsNaN(value) + ? unchecked((long)0xFFF8_0000_0000_0000UL) + : BitConverter.DoubleToInt64Bits(value); ulong u = bits < 0 ? ~(ulong)bits : (ulong)bits | 0x8000_0000_0000_0000UL; _data = [KeyPrefix, (byte)(u >> 56), (byte)(u >> 48), (byte)(u >> 40), (byte)(u >> 32), (byte)(u >> 24), (byte)(u >> 16), (byte)(u >> 8), (byte)u]; diff --git a/tests/BLite.Tests/IndexKeyNaNTests.cs b/tests/BLite.Tests/IndexKeyNaNTests.cs new file mode 100644 index 00000000..300500df --- /dev/null +++ b/tests/BLite.Tests/IndexKeyNaNTests.cs @@ -0,0 +1,73 @@ +using BLite.Core.Indexing; +using BLite.Core.Storage; + +namespace BLite.Tests; + +/// +/// Every NaN gets one index key, below -Infinity (#257). The sort-preserving double encoding keyed a +/// NaN by its bits, so a NaN with the sign bit clear (what Arm64 computes for 0.0 / 0.0) sorted above +/// +Infinity and one with the sign bit set (, what x64 computes) below +/// -Infinity: the order of an index-served query depended on the CPU that wrote the value. +/// +public sealed class IndexKeyNaNTests : IDisposable +{ + private static readonly double PositiveNaN = BitConverter.Int64BitsToDouble(0x7FF8_0000_0000_0000L); + private static readonly double PayloadNaN = BitConverter.Int64BitsToDouble(0x7FF0_0000_0000_0001L); + private static readonly double NegativePayloadNaN = BitConverter.Int64BitsToDouble(unchecked((long)0xFFF0_0000_0000_0001UL)); + + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"blite_nan_{Guid.NewGuid():N}.db"); + + public void Dispose() + { + foreach (var p in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".wal") }) + try { if (File.Exists(p)) File.Delete(p); } catch { } + } + + public static IEnumerable NaNs() => new[] + { + new object[] { PositiveNaN }, + new object[] { PayloadNaN }, + new object[] { NegativePayloadNaN }, + new object[] { double.NaN }, + }; + + [Theory] + [MemberData(nameof(NaNs))] + public void Every_NaN_Has_The_Key_Of_Double_NaN_Below_Negative_Infinity(double nan) + { + Assert.True(double.IsNaN(nan)); + var key = IndexKey.Create(nan); + + Assert.Equal(IndexKey.Create(double.NaN), key); + Assert.True(key < IndexKey.Create(double.NegativeInfinity)); + Assert.True(key < IndexKey.Create(double.MinValue)); + Assert.True(double.IsNaN(key.As())); + } + + [Fact] + public void Index_Orders_NaNs_First_And_Excludes_Them_From_A_Lower_Bounded_Range() + { + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + var index = new BTreeIndex(storage, IndexOptions.CreateBTree("ratio")); + var txn = storage.BeginTransaction().TransactionId; + + index.Insert(IndexKey.Create(PositiveNaN), new DocumentLocation(1, 0), txn); // d1 + index.Insert(IndexKey.Create(1.0), new DocumentLocation(2, 0), txn); // d2 + index.Insert(IndexKey.Create(double.NaN), new DocumentLocation(3, 0), txn); // d3 + index.Insert(IndexKey.Create(double.PositiveInfinity), new DocumentLocation(4, 0), txn); + + var ascending = index.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txn) + .Select(e => e.Location.PageId).ToList(); + // The two NaNs share a key and keep their insertion order between themselves. + Assert.Equal(new uint[] { 1, 3 }, ascending.Take(2).OrderBy(p => p)); + Assert.Equal(new uint[] { 2, 4 }, ascending.Skip(2)); + + var aboveHalf = index.Range(IndexKey.Create(0.5), IndexKey.MaxKey, IndexDirection.Forward, txn) + .Select(e => e.Location.PageId).ToList(); + Assert.Equal(new uint[] { 2, 4 }, aboveHalf); + + var atLeastMax = index.Range(IndexKey.Create(double.MaxValue), IndexKey.MaxKey, IndexDirection.Forward, txn) + .Select(e => e.Location.PageId).ToList(); + Assert.Equal(new uint[] { 4 }, atLeastMax); + } +}