Repository navigation
Float64Range/Float64Set and Float32Range/Float32Set over finite values only, mapped to custom float8range and float4range #153
Description
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(andFloat64Set), elementdouble, continuous likeDecimalRange. ARequireFinite(double)guard inCreateFinite,CreateUnboundedStart,CreateUnboundedEnd(andFloat64Set.From/NormalizeElement):ArgumentExceptionfor NaN,PositiveInfinity,NegativeInfinity. Every construction path funnels through them: the literal parser (RangeFormat.Parse), the JSON and TypeConverter readers,RangeProviderConversion.FromProvideron the EF read leg, and the engines.YearMonthRange.RequireIsois the precedent.TryParsereturns false for those literals.- The same guard on every scalar operand, because
[0,)underCompareTodoes not containNaNwhile PostgreSQL's@>does:Contains(T),RangeSet.Contains(T), value setContains/Add/Remove,IValueSet<T>.NormalizeElementand the EF definition'snormalizeValue(a NaN parameter fails at query time, loudly). An unsupported operand throws; it never answersfalse. - Continuous, never canonicalized: PostgreSQL treats
(1,1.0000000000000002)as non-empty although no double lies strictly between the endpoints. NoBitIncrementstep; exact comparisons, no epsilon. Lengthmay overflow to infinity ([-MaxValue, MaxValue]); it returns the IEEE result, documented. Infinity as an output is not a bound.- A stored
InfinityorNaNbound therefore throws on load, loudly; document it under "Where is the model stricter than the database?" next to thenumrangeNaN case. - EF: store type
float8range/float8multirange, declared by the consumer withmodelBuilder.HasPostgresRange("float8range", "double precision")plusEnableUnmappedTypes(), exactly astimerange, withoutsubtypeDiff:float8mioverflows (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].Float64Setisdouble 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_digits1, .NET Core 3.0+). - Docs: a "custom types" table in
docs/efcore.mdlistingtimerangeandfloat8rangewith their setup and caveats (that is the "supported, mind the quirks" tier, without a new name);docs/why.mdrewritten to the measured reason above; the README's "whydoubleis absent" line goes. - Tests: the discovered sweeps (
ShapeMatrixParityTests,LiteralCultureContractTests,TemporalLiteralStrictness…not applicable,JsonDefaultContractTests,CapturedCollectionProbeTests,HostCultureProbeTests) pick the type up by reflection;claims.jsongets the new sentences; a seed that drops the guard must fail the parity sweep (the[5,NaN)row is the obvious seed). Float32Range/Float32Setare in scope (float4range,real;real[]native). Same finite-only guard overfloat. Its literal parity is a separate measurement (6 to 9 significant digits; .NET's shortest round-tripfloattext vs PostgreSQL'sfloat4outwithextra_float_digits1): measure on 16 and 18 before writing the parser, and seed a defect that formatsfloatthroughdouble.
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, notDouble/Single. The integer families are named by width (Int16Set,Int32Range,Int64Range), and PostgreSQL's float types are too (float4,float8).SingleRangewould also read as "one range".- No
Float16. PostgreSQL has no two-byte float, so there is no domain to mirror: arealcolumn could hold values aHalfcannot, which would round or fail on load. It is the same reason there isInt16Set(smallint[]) but noInt16Range(noint2range). docs/why.mdand the README's "whydoubleis absent" text are rewritten to the measured reason.
Activity
CaffeinatedCoder commentedon Oct 18, 2026
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:
range.Contains(double.NaN)underCompareToanswers false for[0,)while PostgreSQL answers true (NaNsorts above everything). SoRequireFiniteapplies to everyToperand:Contains(T),RangeSet.Contains(T), value setContains/Add/Remove,IValueSet<T>.NormalizeElementand the EF definition'snormalizeValue(a NaN parameter then fails at query time, loudly, instead of binding). Throwing on an unsupported operand is the contract; a silentfalseis not.(1,1.0000000000000002)as non-empty although no double lies strictly between the endpoints. NoBitIncrementcanonical function; the type is continuous likeDecimalRange. Exact comparisons, no epsilon.Lengthcan 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 = float8mioverflows 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 documentedHasPostgresRangecall must therefore omitsubtypeDiffor name a saturating function, and the integration test must build a GiST index over[-MaxValue, MaxValue].Comparer<T>.Default, i.e. .NET order; it makes no PostgreSQL-parity promise. Worth one sentence indocs/why.md.CompareToas 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.[-]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[/+]CaffeinatedCoder commentedon Oct 18, 2026
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.
CaffeinatedCoder commentedon Oct 18, 2026
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/float4outare Ryu built withacceptBounds = false(STRICTLY_SHORTESTis undefined insrc/common/d2s.c/f2s.c), with fixed notation for decimal exponents −4…14 (float8) or −4…5 (float4) andd.ddde±xxotherwise. .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.double.ToString(),"R"andUtf8JsonWriterprint 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.floatformatted throughdoublediffers in text on 14,095 of 16,243 sampled floats but reads back as the samereal, so only a text sweep can see it; the seed for it is caught.float4invsfloat.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 with22003on 16 and 18; without it the index builds and answers.float4micannot be a diff function at all (it returnsreal).float8inacceptsstrtodhexadecimal (0x10) on these builds; the families refuse it.Follow-up from the work: #200 (the clone sweep stops reporting divergences two siblings share).