Skip to content

NextDoubleExclusive() can return exactly 1.0, so Normal/LogNormal Sample returns +Infinity #60

Description

@matt-edmondson

What's wrong

IRandomProvider.NextDoubleExclusive() is defined in Essentials/IRandomProvider.cs, around line 173:

public double NextDoubleExclusive() => ((NextUInt64() >> 11) + 0.5) * RandomHelpers.UnitScale; // UnitScale = 2^-53

When the 53-bit draw is at its maximum, 2^53 - 1, the sum (2^53 - 1) + 0.5 cannot be represented as a double. Doubles near 2^53 are spaced 1 apart, so this is an exact tie between 2^53 - 1 and 2^53. Round-half-to-even picks 2^53, and the result is 2^53 * 2^-53 = 1.0.

The method's own remarks say the half-step offset exists so that inverse-transform sampling never sees 0 or 1, because the quantile of an unbounded distribution is infinite there. The upper end is not excluded.

Consequence

The default IDistribution.Sample computes Quantile(NextDoubleExclusive()). When the draw hits this value:

  • Normal(0, 1).Sample(random) returns +Infinity.
  • LogNormal behaves the same way.
  • Every other distribution that uses the default Sample receives u = 1, which the method's contract excludes.

Reproduction

A stub IRandomProvider whose NextBytes fills the buffer with 0xFF was run against the net10.0 build:

NextDoubleExclusive = 1 (== 1.0? True)
Normal(0,1).Sample: Infinity

The chance per draw is 2^-53. However, a seeded Xoshiro or Pcg state that produces this word will produce it every time it is replayed. The existing test at RandomProviderTests.cs:152 only checks random samples, so it can never hit this boundary.

Suggested fix / acceptance criteria

  • Use 52 bits so that the sum is always exact:
    ((NextUInt64() >> 12) + 0.5) * (1.0 / 4503599627370496.0) // 2^-52
    The largest result is then 1 - 2^-53 and the smallest is 2^-53, both strictly inside (0, 1).
  • Add a boundary test with a stub provider that returns ulong.MaxValue and one that returns 0. Assert 0 < u < 1, and assert that Normal(0,1).Sample is finite for both.

Activity

  1. matt-edmondson commented on Sep 28, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    • Category: Bug
    • Priority: Medium. The chance per draw is only 2^-53, but a seeded generator that produces the maximal word does so every time it is replayed. When it does, Normal/LogNormal Sample returns +Infinity, and every other default-Sample distribution gets a u = 1 that its contract excludes. The fix is a one-line change.
    • Area / suggested owner: Essentials random, IRandomProvider.NextDoubleExclusive() (RandomHelpers.UnitScale)
    • Duplicates / in progress: Not a duplicate, and no open PR covers it. It is closely related to NextDouble(min, max) can return maxExclusive, and returns Infinity for wide finite bounds (Uniform distribution too) #61 (NextDouble(min, max) can return maxExclusive or overflow). Both are unit-interval boundary bugs in IRandomProvider, so fix them together and share the ulong.MaxValue/0 stub providers in the tests.

    Next step: Switch to a 52-bit draw (((NextUInt64() >> 12) + 0.5) * 2^-52) so the result stays strictly inside (0, 1). Add boundary tests that assert 0 < u < 1 and that Normal(0,1).Sample is finite for both extreme stubs.


    Generated by Claude Code

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

Metadata

Metadata

Labels

bugSomething isn't workingreadyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions