Skip to content

Repository files navigation

interval64

Rigorous binary64 interval arithmetic with proof-directed, correctly rounded bounds.

import { Interval } from 'interval64'
const step = 0.1
let naive = 0
let rigorous = Interval.of(0)
for (let i = 0; i < 10; i++) {
    naive += step
    rigorous = rigorous.add(Interval.of(step))
}
// naive = 0.9999999999999999; rigorous = [0.9999999999999998, 1.0000000000000007]

The ordinary result silently picks one rounded path. The interval records every real result consistent with the binary64 inputs and operations. Each primitive endpoint is the correctly directed rounding of its exact endpoint expression; normal interval dependency can still widen a longer computation.

Zero runtime dependencies. ESM and TypeScript declarations are included.

Why this exists

JavaScript has no directed rounding mode. The incumbent interval-arithmetic works around that by moving every result one representable number outward. That encloses the answer, but loses a ULP even when the operation was exact.

interval64 asks an error-free transform whether rounding crossed the true value and nudges only when the proof says it did. On 50,000 seeded interval pairs drawn by raw binary64 bit pattern across the full exponent range:

operation strictly tighter than interval-arithmetic@1.1.3 rate invalid incumbent overflow results
add 29,922 / 50,000 59.84% 0
sub 29,813 / 50,000 59.63% 0
mul 31,988 / 49,853 64.16% 147
div 16,983 / 49,862 34.06% 138
sqrt 21,941 / 37,563 58.41% 0

“Invalid incumbent overflow” means its result collapsed a non-singleton real range to [-Infinity, -Infinity] or [Infinity, Infinity]; those cases are reported, but excluded from the subset denominator because the incumbent did not return an enclosure. For every remaining defined result, interval64's answer was a subset of the incumbent's.

A small worked receipt makes the cost of unconditional nudging concrete:

let x = Interval.of(0)
for (let i = 0; i < 1_000; i++) x = x.add(Interval.of(1))
x // [1000, 1000]

// interval-arithmetic@1.1.3: [999.9999999999252, 1000.0000000000749]

Every addition there is exact. interval64 proves that and keeps all the digits; the incumbent discards roughly eleven decimal digits of interval width.

What “correct” means

An Interval(lo, hi) denotes the closed set of real values between its bounds, with infinite endpoints used for unbounded sets. Every operation is the natural set extension of the corresponding real operation:

  • Containment: if x ∈ X and y ∈ Y, then every defined x ∘ y is in X.op(Y).
  • Correctly rounded primitive bounds: for addition, subtraction, multiplication, division, and square root, each endpoint is the closest binary64 value on the required side of the exact endpoint — across the full finite domain, including the overflow-adjacent windows near ±MAX_VALUE (an adversarial-review find; see DESIGN.md).
  • No false uncertainty: exact operations such as 1 + 2 stay [3, 3].
  • Explicit absence: EMPTY is an empty set, not a NaN-bearing interval. NaN inputs throw.

The proof, overflow cases, and independent oracle architecture are in DESIGN.md.

API

Intervals are immutable. Operations return new intervals (or the shared empty value where specified).

const x = Interval.of(2)          // [2, 2]
const y = Interval.of(-1, 4)      // [-1, 4]

Interval.EMPTY                    // [+Infinity, -Infinity]
Interval.ENTIRE                   // [-Infinity, +Infinity]

x.add(y)
x.sub(y)
x.mul(y)
x.div(y)                          // hull if y contains zero
x.divSplit(y)                     // preserves the 0–2 connected components
x.neg()
x.abs()
x.sqrt()                          // intersects the input with [0, +Infinity]
x.powInt(7)                       // repeated squaring, parity-aware
x.min(y)
x.max(y)
x.intersect(y)
x.hull(y)

x.contains(2)                     // number or Interval
x.strictlyContains(2)
x.width()
x.rad()                           // outward upper bound on the radius
x.mid()                           // approximate convenience, not a bound
x.isEmpty()
x.isEntire()
x.eq(y)

x / [0, 0] is EMPTY: there is no real quotient. A divisor that merely contains zero excludes the zero point and retains the quotients for every nonzero divisor value. divSplit returns the two disconnected rays when they remain disconnected; div returns their hull. Thus [0, 0] / [-1, 1] is [0, 0], while [1, 2] / [-1, 1] splits into two rays and has ENTIRE as its hull. For an unbounded divisor, -0 and +0 bounds retain which side approaches the excluded zero limit, allowing divSplit to keep the two components apart.

powInt(0) is [1, 1] for every nonempty interval; empty propagation takes priority, so EMPTY.powInt(0) is EMPTY. Negative exponents use set-based division. A non-safe-integer exponent throws.

width() and rad() return outward-rounded upper bounds and 0 for EMPTY; rad() halves before subtracting so wide finite intervals do not overflow prematurely. mid() is deliberately only a non-rigorous sampling convenience: it uses an overflow-safe floating midpoint, returns 0 for ENTIRE, the corresponding infinity for one-sided unbounded intervals, and NaN for EMPTY.

The low-level exports are:

nextUp(x)
nextDown(x)
twoSum(a, b)                       // [rounded sum, exact residual]
twoProduct(a, b)                   // [product, residual/proxy, residualExact, signReliable]

The last two flags expose the one remaining inexact case: a near-underflow residual may be too small to represent but still carries a proven sign. Both overflow windows resolve to exact residuals via power-of-two rescaling, so only non-finite inputs or products clear both flags.

Performance receipts

Measured on Node 24.13.1, Apple M5 Max, 2026-07-11. The machine was under heavy background load (load averages 24.12 / 24.21 / 26.84), so treat these as reproducible order-of-magnitude receipts, not clean-room records. cyclebench interleaves candidates to reduce drift. Each call processes arrays of 1,000 random intervals; the table divides the call time by 1,000.

operation interval64 ns/op interval-arithmetic ns/op interval64 / incumbent
add 101 289 0.35×
mul 207 206 1.00×
div 506 192 2.64×

Addition benefits from an inlined bit-twiddling path; multiplication tied in this run. Division is the tradeoff: proving the quotient side requires a product transform and exact residual comparison, and cost 2.64× the incumbent. The package targets tighter proof-backed bounds, not universal speed. Run npm run bench for both the speed and tightness tables.

Limits

Dekker splitting multiplies each operand by 2^27 + 1, which overflows for magnitudes above MAX_VALUE / (2^27 + 1) ≈ 2^997; and even with finite splits, a partial product can overflow when |a·b| sits within ~2^-26 relative of 2^1024. Both windows are resolved exactly by rescaling one operand by 2^-64 (a power of two neither rounds nor loses the residual), so correctly rounded bounds hold across the entire finite domain. The second window previously broke containment and is now regression-pinned. Near underflow, the residual is recovered as a same-sign proxy after 2^54 scaling: bounds there are correctly rounded, but a subnormal true residual has no binary64 representation, which is the one place the machinery leans on a sign rather than an exact value.

As with every ordinary interval package, repeated variables introduce the dependency problem: x.sub(x) generally encloses more than zero. This package tightens floating-point rounding; it does not perform symbolic correlation.

Family

exact-sum is the sibling package for correctly rounded reductions. It shares the same Knuth/Dekker/Shewchuk error-free-transform mathematics. interval64 is implemented standalone and retains zero runtime dependencies.

Verification

The seeded suite performs two million containment checks with numbers generated from raw binary64 bit patterns—not scaled Math.random()—covering subnormals, signed zeros, the full exponent range, and near-overflow values. Independent BigInt dyadic-rational oracles require bit-identical directed bounds for addition, subtraction, and every product in the exact Dekker region. Division and square root are checked by independently comparing exact BigInt products.

Dedicated batteries cover EMPTY/ENTIRE, zero-straddling div and divSplit, negative and zero integer powers, signed zero, infinities, NaN rejection, and explicit products near 2^1000. The incumbent cross-check runs on the same full-exponent generator and prints strict-tightness counts.

CI runs test and build on Node 18, 22, and 24.

Install

npm install interval64

License

MIT © Xyra Sinclair

About

Rigorous f64 interval arithmetic with correctly-rounded bounds — error-free transforms decide every ULP nudge; verified against a BigInt-rational oracle.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages