Skip to content

Float64Range/Float64Set and Float32Range/Float32Set over finite values only, mapped to custom float8range and float4range #153

Closed
1 of 1 issue completed

Description

@CaffeinatedCoder

Decided 2026-10-18 after research: a float range is well-defined once NaN and the two infinities are refused at construction, the way DecimalRange already cannot hold numrange's NaN. Rescheduled 2026-10-18 (maintainer): ships in 10.0.0 (epic #58), merged forward to the 11 line, as Float64Range/Float64Set and Float32Range/Float32Set (see Naming and scope below).

Why the current exclusion was imprecise. docs/why.md says IEEE operators and CompareTo disagree. The engines use CompareTo consistently, so that is not where it breaks. The break is that .NET sorts NaN first and PostgreSQL sorts it last. Measured on PostgreSQL 17.10 and 18.1 with CREATE TYPE float8range AS RANGE (subtype = float8, subtype_diff = float8mi):

Literal PostgreSQL .NET under CompareTo
[5,NaN) valid, upper_inf false, contains 1e308 and Infinity inverted, so Empty
[NaN,5) error, lower bound above upper valid
[0.0,-0.0) empty, -0 equals 0 same
[1,Infinity) vs [1,) distinct, upper_inf false vs true same distinction
[1e-300,1.7976931348623157e308], [-1.7976931348623157e308,5e-324) stored and printed exactly same

So NaN is the only value that disagrees, and infinity-as-a-value only duplicates what CreateUnboundedEnd and Infinite already say. Refuse all three and the remaining doubles are a total order on which CompareTo and the IEEE operators agree.

Design

  • Float64Range (and Float64Set), element double, continuous like DecimalRange. A RequireFinite(double) guard in CreateFinite, CreateUnboundedStart, CreateUnboundedEnd (and Float64Set.From/NormalizeElement): ArgumentException for NaN, PositiveInfinity, NegativeInfinity. Every construction path funnels through them: the literal parser (RangeFormat.Parse), the JSON and TypeConverter readers, RangeProviderConversion.FromProvider on the EF read leg, and the engines. YearMonthRange.RequireIso is the precedent. TryParse returns false for those literals.
  • The same guard on every scalar operand, because [0,) under CompareTo does not contain NaN while PostgreSQL's @> does: Contains(T), RangeSet.Contains(T), value set Contains/Add/Remove, IValueSet<T>.NormalizeElement and the EF definition's normalizeValue (a NaN parameter fails at query time, loudly). An unsupported operand throws; it never answers false.
  • Continuous, never canonicalized: PostgreSQL treats (1,1.0000000000000002) as non-empty although no double lies strictly between the endpoints. No BitIncrement step; exact comparisons, no epsilon.
  • Length may overflow to infinity ([-MaxValue, MaxValue]); it returns the IEEE result, documented. Infinity as an output is not a bound.
  • A stored Infinity or NaN bound therefore throws on load, loudly; document it under "Where is the model stricter than the database?" next to the numrange NaN case.
  • EF: store type float8range / float8multirange, declared by the consumer with modelBuilder.HasPostgresRange("float8range", "double precision") plus EnableUnmappedTypes(), exactly as timerange, without subtypeDiff: float8mi overflows (value out of range: overflow) when PostgreSQL builds a GiST index over extreme finite values, and the index builds and answers correctly without it. The integration test builds a GiST index over [-MaxValue, MaxValue]. Float64Set is double precision[], native. Constant element operands typed (::double precision) per the polymorphic-operator rule. Literal text is the shortest round-trip form on both sides (PostgreSQL 12+ extra_float_digits 1, .NET Core 3.0+).
  • Docs: a "custom types" table in docs/efcore.md listing timerange and float8range with their setup and caveats (that is the "supported, mind the quirks" tier, without a new name); docs/why.md rewritten to the measured reason above; the README's "why double is absent" line goes.
  • Tests: the discovered sweeps (ShapeMatrixParityTests, LiteralCultureContractTests, TemporalLiteralStrictness… not applicable, JsonDefaultContractTests, CapturedCollectionProbeTests, HostCultureProbeTests) pick the type up by reflection; claims.json gets the new sentences; a seed that drops the guard must fail the parity sweep (the [5,NaN) row is the obvious seed).
  • Float32Range / Float32Set are in scope (float4range, real; real[] native). Same finite-only guard over float. Its literal parity is a separate measurement (6 to 9 significant digits; .NET's shortest round-trip float text vs PostgreSQL's float4out with extra_float_digits 1): measure on 16 and 18 before writing the parser, and seed a defect that formats float through double.

Demand, for the record. No issue asks for a float range on Npgsql, efcore.pg, Hypersistence, Django, Rails or pgx; Django removed FloatRangeField because it was numrange in disguise; one real user found (opendatacube, float8range). Npgsql's own docs example is HasPostgresRange("floatrange", "real"). The AI/ML hypothesis (score thresholds as range columns) found no evidence. Intervals.NET is a direct .NET competitor that accepts doubles under Comparer<T>.Default, i.e. .NET order, with no PostgreSQL-parity promise; one sentence in docs/why.md. An independent review (2026-10-18) measured 4,096 sampled singleton ranges, including extremes, subnormals, infinities and NaN, round-tripping exactly through Npgsql's binary path and PostgreSQL's text path; the alternative it preferred, a PostgreSQL-ordering comparer with NaN greatest, was rejected in favour of finite-only because it would have to govern every comparison path in the engines.

Naming and scope (decided 2026-10-18)

  • Float64 / Float32, not Double / Single. The integer families are named by width (Int16Set, Int32Range, Int64Range), and PostgreSQL's float types are too (float4, float8). SingleRange would also read as "one range".
  • No Float16. PostgreSQL has no two-byte float, so there is no domain to mirror: a real column could hold values a Half cannot, which would round or fail on load. It is the same reason there is Int16Set (smallint[]) but no Int16Range (no int2range).
  • docs/why.md and the README's "why double is absent" text are rewritten to the measured reason.

Activity

  1. added
    enhancementNew feature or request
    area/rangesRange unions, variants and the shape engines
    area/value-setsValue set families and wrapper arities
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    area/jsonSystem.Text.Json and TypeConverter wire forms
    area/literalsParse, TryParse and formatting
    package/coreAffects CodoMetis.ValueRanges
    on Oct 18, 2026
  2. CaffeinatedCoder commented on Oct 18, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Additions from an independent review (GPT 6 Astra, 2026-10-18), each a measured fact or a design gap the finite-only design must cover:

    • The guard must also cover scalar operands, not only bounds. range.Contains(double.NaN) under CompareTo answers false for [0,) while PostgreSQL answers true (NaN sorts above everything). So RequireFinite applies to every T operand: Contains(T), RangeSet.Contains(T), value set Contains/Add/Remove, IValueSet<T>.NormalizeElement and the EF definition's normalizeValue (a NaN parameter then fails at query time, loudly, instead of binding). Throwing on an unsupported operand is the contract; a silent false is not.
    • Continuous, never canonicalized. PostgreSQL treats (1,1.0000000000000002) as non-empty although no double lies strictly between the endpoints. No BitIncrement canonical function; the type is continuous like DecimalRange. Exact comparisons, no epsilon.
    • Length can overflow to infinity ([-MaxValue, MaxValue]). Decide and document: return the IEEE result (infinity as an output is not a bound) or throw. Prefer the IEEE result, stated in the doc.
    • subtype_diff = float8mi overflows when PostgreSQL builds a GiST index over extreme finite values (value out of range: overflow); the index builds and answers correctly without the diff function. The documented HasPostgresRange call must therefore omit subtypeDiff or name a saturating function, and the integration test must build a GiST index over [-MaxValue, MaxValue].
    • Round-trip measured at scale: 4,096 sampled singleton ranges including extremes, subnormals, infinities and NaN round-tripped exactly through Npgsql's binary path and PostgreSQL's text path.
    • A direct .NET competitor exists: Intervals.NET advertises double ranges and compares through Comparer<T>.Default, i.e. .NET order; it makes no PostgreSQL-parity promise. Worth one sentence in docs/why.md.
    • The reviewer's preferred alternative is a PostgreSQL-ordering contract including NaN (NaN greatest, NaN equal to NaN) governing every comparison path. Rejected here on 2026-10-18 by the maintainer in favour of finite-only, which keeps CompareTo as the single order; the reviewer calls that defensible provided the guard covers construction, parsing, JSON, materialization, parameters and operands, which the list above now does.
  3. added this to the 10.0.0 milestone on Oct 18, 2026
  4. changed the title [-]DoubleRange and DoubleSet over finite doubles only, mapped to a custom float8range[/-] [+]Float64Range/Float64Set and Float32Range/Float32Set over finite values only, mapped to custom float8range and float4range[/+] on Oct 18, 2026
  5. CaffeinatedCoder commented on Oct 18, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Depends on #174 (PR #176): the family is added by following docs/architecture.md#adding-a-range-or-set-type from that PR, so work starts once it has merged.

  6. CaffeinatedCoder commented on Oct 18, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Implemented in two PRs: #198 (Float64Range/Float64Set, closes the sub-issue #196) and #199 (Float32Range/Float32Set, stacked on #198, closes this issue).

    Measured before writing the parsers (PostgreSQL 16.15 and 18.1; the integration suite holds it on 17):

    • float8out/float4out are Ryu built with acceptBounds = false (STRICTLY_SHORTEST is undefined in src/common/d2s.c/f2s.c), with fixed notation for decimal exponents −4…14 (float8) or −4…5 (float4) and d.ddde±xx otherwise. .NET's shortest form differs in digits for 128 of 40,016 doubles and 233 of 39,940 floats, and in layout for most exponent forms. A PG-exact formatter (Internals/FloatLiteral.cs) matches on 649,929 doubles and 649,250 floats, 0 mismatches.
    • .NET 10 defect: double.ToString(), "R" and Utf8JsonWriter print 2^-25 and 2^-958 as text that reads back one ulp lower — exactly those two doubles. The families never use .NET's shortest form; the JSON writer and the wrapper arity (G17) route around it. Not reported upstream yet.
    • A float formatted through double differs in text on 14,095 of 16,243 sampled floats but reads back as the same real, so only a text sweep can see it; the seed for it is caught.
    • float4in vs float.Parse: 0 mismatches over 199,914 texts, half of them exact float midpoints and their double neighbours.
    • subtype_diff = float8mi: GiST insert/build over extreme values fails with 22003 on 16 and 18; without it the index builds and answers. float4mi cannot be a diff function at all (it returns real).
    • float8in accepts strtod hexadecimal (0x10) on these builds; the families refuse it.

    Follow-up from the work: #200 (the clone sweep stops reporting divergences two siblings share).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/jsonSystem.Text.Json and TypeConverter wire formsarea/literalsParse, TryParse and formattingarea/rangesRange unions, variants and the shape enginesarea/value-setsValue set families and wrapper aritiesenhancementNew feature or requestpackage/coreAffects CodoMetis.ValueRangespackage/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions