@chenglou/freerange 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ import type {SiteID} from '../ir/ids.ts'
2
+ export type AbstractNumber = {
3
+ kind: 'number'
4
+ // The bounds carry finiteness by construction: a value that can be ±Infinity has that
5
+ // infinity as a bound (every producer keeps the invariant, so a "finite" flag would only
6
+ // be a hand-maintained copy of Number.isFinite over the bounds — use isFiniteNumber).
7
+ lower: number
8
+ upper: number
9
+ integer: boolean
10
+ mayBeNaN: boolean
11
+ // One point cut out of an interval that otherwise contains it strictly inside — set by
12
+ // a `count !== 0` or `width !== 4` guard (or the matching === early exit), where no
13
+ // interval endpoint can express the cut. Division consumes the point-zero exclusion
14
+ // directly, and the arithmetic rules below FORWARD an exclusion into a zero exclusion
15
+ // through the same float-exact inversions requirement peeling trusts: width ≠ 4 makes
16
+ // width - 4 ≠ 0, so the guard a peeled requires line names actually discharges it.
17
+ // Absent means "no point excluded"; producers stay conservative by construction (x - x
18
+ // can be zero from nonzero operands) except for those exact rules, and joins keep a
19
+ // point only when both sides exclude it. Unlike the report sites below, this is semantics:
20
+ // sameNumbers compares it. One point, not a set — the deliberate cap.
21
+ excludesPoint?: number
22
+ // Annotation only, never semantics: where finiteness and NaN-freedom were first lost,
23
+ // kept separately so a later NaN-producing operation is not blamed on an earlier
24
+ // overflow. Deliberately excluded from sameNumbers and never branched on by the engine.
25
+ nonFiniteSite?: SiteID
26
+ nanSite?: SiteID
27
+ }
28
+
29
+ const float64Scratch = new Float64Array(1)
30
+ const bitsScratch = new BigInt64Array(float64Scratch.buffer)
31
+
32
+ // The adjacent representable double above the value — the exact refinement for a strict
33
+ // float comparison: runtime x > b implies x >= nextUp(b), and no double sits between them.
34
+ export function nextUp(value: number): number {
35
+ if (Number.isNaN(value) || value === Infinity) return value
36
+ if (value === 0) return Number.MIN_VALUE
37
+ float64Scratch[0] = value
38
+ bitsScratch[0] = bitsScratch[0]! + (value > 0 ? 1n : -1n)
39
+ return float64Scratch[0]
40
+ }
41
+
42
+ export function nextDown(value: number): number {
43
+ return -nextUp(-value)
44
+ }
45
+
46
+ export function isFiniteNumber(value: AbstractNumber): boolean {
47
+ return Number.isFinite(value.lower) && Number.isFinite(value.upper)
48
+ }
49
+
50
+ // The values that pass Number.isFinite. Null means the input has no finite value.
51
+ export function finiteNumberPart(value: AbstractNumber): AbstractNumber | null {
52
+ const lower = Math.max(value.lower, -Number.MAX_VALUE)
53
+ const upper = Math.min(value.upper, Number.MAX_VALUE)
54
+ return lower <= upper ? {...value, lower, upper, mayBeNaN: false} : null
55
+ }
56
+
57
+ export function finiteInputNumber(): AbstractNumber {
58
+ return {
59
+ kind: 'number',
60
+ lower: -Number.MAX_VALUE,
61
+ upper: Number.MAX_VALUE,
62
+ integer: false,
63
+ mayBeNaN: false,
64
+ }
65
+ }
66
+
67
+ export function constantNumber(value: number): AbstractNumber {
68
+ return {
69
+ kind: 'number',
70
+ lower: value,
71
+ upper: value,
72
+ integer: Number.isInteger(value),
73
+ mayBeNaN: Number.isNaN(value),
74
+ }
75
+ }
76
+
77
+ // Addition does not collapse on possibly-infinite operands the way multiplication and
78
+ // division must: the only NaN case is opposite-signed infinities meeting, so with NaN-free
79
+ // operands the bounds stay real. Infinity + finite is Infinity — `(a + b) + c` with finite
80
+ // inputs can overflow, never turn NaN. An endpoint sum that IS NaN (the interval corners
81
+ // mix -Infinity and +Infinity) saturates to that direction's extreme, which over-covers
82
+ // the corner soundly.
83
+ export function addNumbers(left: AbstractNumber, right: AbstractNumber): AbstractNumber {
84
+ const lower = left.lower + right.lower
85
+ const upper = left.upper + right.upper
86
+ const oppositeInfinities =
87
+ (left.upper === Number.POSITIVE_INFINITY && right.lower === Number.NEGATIVE_INFINITY)
88
+ || (left.lower === Number.NEGATIVE_INFINITY && right.upper === Number.POSITIVE_INFINITY)
89
+ const result: AbstractNumber = {
90
+ kind: 'number',
91
+ lower: Number.isNaN(lower) ? Number.NEGATIVE_INFINITY : lower,
92
+ upper: Number.isNaN(upper) ? Number.POSITIVE_INFINITY : upper,
93
+ integer: left.integer && right.integer,
94
+ mayBeNaN: left.mayBeNaN || right.mayBeNaN || oppositeInfinities,
95
+ }
96
+ // The forward direction of requirement peeling: an IEEE sum is zero only when the
97
+ // operands are exact negations, so x ≠ -c makes x + c ≠ 0 — the `width !== 4` guard a
98
+ // peeled requires line names flows through `width - 4` and discharges the division
99
+ // (subtraction arrives here with the right side negated).
100
+ const pointSide = right.lower === right.upper && !right.mayBeNaN ? right
101
+ : left.lower === left.upper && !left.mayBeNaN ? left : null
102
+ const otherSide = pointSide === right ? left : right
103
+ if (pointSide != null && pointExcluded(otherSide, -pointSide.lower)
104
+ && result.lower < 0 && result.upper > 0) {
105
+ result.excludesPoint = 0
106
+ }
107
+ return result
108
+ }
109
+
110
+ // a - b is a + (-b); negation is exact on every value including infinities.
111
+ export function subtractNumbers(left: AbstractNumber, right: AbstractNumber): AbstractNumber {
112
+ const negated: AbstractNumber = {
113
+ kind: 'number',
114
+ lower: -right.upper,
115
+ upper: -right.lower,
116
+ integer: right.integer,
117
+ mayBeNaN: right.mayBeNaN,
118
+ }
119
+ // Negation is exact, so an excluded point flips sign with the value.
120
+ if (right.excludesPoint != null) negated.excludesPoint = -right.excludesPoint
121
+ return addNumbers(left, negated)
122
+ }
123
+
124
+ export function multiplyNumbers(left: AbstractNumber, right: AbstractNumber): AbstractNumber {
125
+ if (!safeOperands(left, right)) return unknownNumber()
126
+ const products = [
127
+ left.lower * right.lower,
128
+ left.lower * right.upper,
129
+ left.upper * right.lower,
130
+ left.upper * right.upper,
131
+ ]
132
+ const result = boundedResult(Math.min(...products), Math.max(...products), left.integer && right.integer, left, right)
133
+ // A factor of magnitude at least 1 cannot underflow a nonzero product to zero (|c·x| >=
134
+ // |x|, and no double below the smallest subnormal exists to round to), so a zero
135
+ // exclusion survives: `scale !== 0` discharges a division by scale * 2. The same
136
+ // condition requirement peeling trusts, run forward.
137
+ const pointSide = right.lower === right.upper && !right.mayBeNaN ? right
138
+ : left.lower === left.upper && !left.mayBeNaN ? left : null
139
+ const otherSide = pointSide === right ? left : right
140
+ if (pointSide != null && Number.isFinite(pointSide.lower) && Math.abs(pointSide.lower) >= 1
141
+ && pointExcluded(otherSide, 0) && !result.mayBeNaN
142
+ && result.lower < 0 && result.upper > 0) {
143
+ result.excludesPoint = 0
144
+ }
145
+ return result
146
+ }
147
+
148
+ export function divideNumbers(left: AbstractNumber, right: AbstractNumber): AbstractNumber {
149
+ // A possibly-infinite dividend over a finite nonzero NaN-free divisor stays exact:
150
+ // the division's NaN corners are 0/0 and Infinity/Infinity, and this divisor rules both
151
+ // out, so e.g. a frame delta that can overflow divided by a step constant is possibly
152
+ // non-finite, never NaN. The quotient corners are monotone (Infinity / 4 is Infinity) —
153
+ // but ONLY over a one-signed divisor interval; a divisor straddling zero with zero
154
+ // excluded by a guard takes the zero-cut path instead, since its corner quotients would
155
+ // exclude the blow-up near zero.
156
+ if (!left.mayBeNaN && !right.mayBeNaN && isFiniteNumber(right)) {
157
+ if (right.lower > 0 || right.upper < 0) {
158
+ const quotients = [
159
+ left.lower / right.lower,
160
+ left.lower / right.upper,
161
+ left.upper / right.lower,
162
+ left.upper / right.upper,
163
+ ]
164
+ return {
165
+ kind: 'number',
166
+ lower: Math.min(...quotients),
167
+ upper: Math.max(...quotients),
168
+ integer: false,
169
+ mayBeNaN: false,
170
+ }
171
+ }
172
+ if (right.excludesPoint === 0) return divideAcrossZero(left, right)
173
+ }
174
+ if (!safeOperands(left, right) || (right.lower <= 0 && right.upper >= 0)) return unknownNumber()
175
+ const quotients = [
176
+ left.lower / right.lower,
177
+ left.lower / right.upper,
178
+ left.upper / right.lower,
179
+ left.upper / right.upper,
180
+ ]
181
+ return boundedResult(Math.min(...quotients), Math.max(...quotients), false, left, right)
182
+ }
183
+
184
+ // A divisor interval straddling zero with zero itself excluded — by a `!== 0` guard (the
185
+ // excluded-point cut) or a recorded nonzero requirement. An integer divisor then has
186
+ // magnitude at least 1, so the quotient is bounded by the dividend; a float divisor can
187
+ // sit arbitrarily close to zero, so the quotient can overflow — possibly non-finite, but
188
+ // never NaN (zero is cut, so 0/0 cannot happen).
189
+ function divideAcrossZero(left: AbstractNumber, right: AbstractNumber): AbstractNumber {
190
+ if (!right.integer) {
191
+ return {kind: 'number', lower: -Infinity, upper: Infinity, integer: false, mayBeNaN: false}
192
+ }
193
+ const negativePart: AbstractNumber = {...right, upper: Math.min(right.upper, -1)}
194
+ const positivePart: AbstractNumber = {...right, lower: Math.max(right.lower, 1)}
195
+ const parts = [negativePart, positivePart].filter(part => part.lower <= part.upper)
196
+ const quotients = parts.flatMap(part => [
197
+ left.lower / part.lower,
198
+ left.lower / part.upper,
199
+ left.upper / part.lower,
200
+ left.upper / part.upper,
201
+ ])
202
+ if (quotients.length === 0) return unknownNumber()
203
+ return boundedResult(Math.min(...quotients), Math.max(...quotients), false, left, right)
204
+ }
205
+
206
+ // floor, abs, min, and max are exact on infinities (no rounding, no overflow, no NaN
207
+ // creation), so unlike the arithmetic operators they keep their bounds instead of
208
+ // collapsing to unknown. This is what lets a clamp recover a finite range from a possibly
209
+ // overflowed input: Math.max(0, Math.min(x, 100)) is 0..100 even when x may be Infinity.
210
+ // NaN is never recovered — Math.min(NaN, 100) is NaN — so the flag just carries through.
211
+ export function floorNumber(value: AbstractNumber): AbstractNumber {
212
+ return {
213
+ kind: 'number',
214
+ lower: Math.floor(value.lower),
215
+ upper: Math.floor(value.upper),
216
+ integer: true,
217
+ mayBeNaN: value.mayBeNaN,
218
+ }
219
+ }
220
+
221
+ // Division once a nonzero requirement has been recorded for the divisor: the divisor's
222
+ // range with zero cut out. An integer divisor then has magnitude at least 1, so the
223
+ // quotient is bounded by the dividend's magnitude — genuinely finite. A non-integer
224
+ // divisor can still be arbitrarily close to zero, so the quotient can overflow; the
225
+ // result is possibly non-finite but never NaN (a finite dividend over a nonzero finite
226
+ // divisor has no NaN case).
227
+ export function divideNumbersNonzeroDivisor(left: AbstractNumber, right: AbstractNumber): AbstractNumber {
228
+ if (!safeOperands(left, right)) return unknownNumber()
229
+ if (!includesZero(right)) return divideNumbers(left, right)
230
+ return divideAcrossZero(left, right)
231
+ }
232
+
233
+ // ceil, round, and trunc are monotone and exact on infinities, like floor; all three
234
+ // produce integers and carry NaN through. (Math.round's half-up tie rule sits between
235
+ // floor and ceil, so the monotone endpoint images cover it.)
236
+ export function roundedNumber(operator: 'ceil' | 'round' | 'trunc', value: AbstractNumber): AbstractNumber {
237
+ const apply = operator === 'ceil' ? Math.ceil : operator === 'round' ? Math.round : Math.trunc
238
+ return {
239
+ kind: 'number',
240
+ lower: apply(value.lower),
241
+ upper: apply(value.upper),
242
+ integer: true,
243
+ mayBeNaN: value.mayBeNaN,
244
+ }
245
+ }
246
+
247
+ // Monotone over non-negative inputs; a negative operand yields NaN, so an interval
248
+ // reaching below zero clips to the non-negative part and turns the NaN flag on. sqrt
249
+ // never overflows and sqrt(Infinity) is Infinity, so the endpoint images are exact.
250
+ export function squareRootNumber(value: AbstractNumber): AbstractNumber {
251
+ const mayBeNegative = value.lower < 0
252
+ const clippedLower = Math.max(value.lower, 0)
253
+ if (value.upper < 0) {
254
+ // The result is always NaN, and the domain has no NaN-only value: bounds must be
255
+ // real numbers, or every consumer of Math.min/Math.max over them (joins, clamps,
256
+ // branch refinement) silently turns its own bounds into NaN — literal NaN bounds
257
+ // here used to print `from NaN through NaN` while the function returned 0. The
258
+ // honest cover is the claim-free full range with the NaN flag on.
259
+ return unknownNumber()
260
+ }
261
+ return {
262
+ kind: 'number',
263
+ lower: Math.sqrt(clippedLower),
264
+ upper: Math.sqrt(value.upper),
265
+ integer: false,
266
+ mayBeNaN: value.mayBeNaN || mayBeNegative,
267
+ }
268
+ }
269
+
270
+ // JS remainder: the result's sign follows the dividend, its magnitude stays below both
271
+ // |dividend| and |divisor|, and it is NaN exactly when the dividend is infinite or the
272
+ // divisor is zero (or either is NaN). With the divisor's nonzero requirement recorded and
273
+ // a finite dividend, the result is genuinely finite and NaN-free.
274
+ export function remainderNumbers(left: AbstractNumber, right: AbstractNumber, divisorNonzero: boolean): AbstractNumber {
275
+ if (left.mayBeNaN || right.mayBeNaN) return unknownNumber()
276
+ const divisorMayBeZero = !divisorNonzero && includesZero(right)
277
+ const dividendMayBeInfinite = !isFiniteNumber(left)
278
+ // |r| < |b| tightens to |b| - 1 for integer operands — but ONLY on the divisor side:
279
+ // against the dividend the exact bound is |r| <= |a| with no subtraction, because when
280
+ // |a| < |b| the remainder IS the dividend (2 % 3 is 2; a review round caught the -1
281
+ // applied to the wrong side publishing 'at most 1').
282
+ const dividendMagnitude = Math.max(Math.abs(left.lower), Math.abs(left.upper))
283
+ const divisorMagnitude = Math.max(Math.abs(right.lower), Math.abs(right.upper))
284
+ const integer = left.integer && right.integer
285
+ const divisorBound = integer && Number.isFinite(divisorMagnitude)
286
+ ? Math.max(divisorMagnitude - 1, 0)
287
+ : divisorMagnitude
288
+ const bound = Math.min(dividendMagnitude, divisorBound)
289
+ return {
290
+ kind: 'number',
291
+ lower: left.lower < 0 ? (Number.isFinite(bound) ? -bound : Number.NEGATIVE_INFINITY) : 0,
292
+ upper: left.upper > 0 ? (Number.isFinite(bound) ? bound : Number.POSITIVE_INFINITY) : 0,
293
+ integer,
294
+ mayBeNaN: divisorMayBeZero || dividendMayBeInfinite,
295
+ }
296
+ }
297
+
298
+ export function absoluteNumber(value: AbstractNumber): AbstractNumber {
299
+ const lower = value.lower >= 0 ? value.lower : value.upper <= 0 ? -value.upper : 0
300
+ return {
301
+ kind: 'number',
302
+ lower,
303
+ upper: Math.max(-value.lower, value.upper),
304
+ integer: value.integer,
305
+ mayBeNaN: value.mayBeNaN,
306
+ }
307
+ }
308
+
309
+ export function minimumNumbers(values: AbstractNumber[]): AbstractNumber {
310
+ if (values.length === 0) return unknownNumber()
311
+ return {
312
+ kind: 'number',
313
+ lower: Math.min(...values.map(value => value.lower)),
314
+ upper: Math.min(...values.map(value => value.upper)),
315
+ integer: values.every(value => value.integer),
316
+ mayBeNaN: values.some(value => value.mayBeNaN),
317
+ }
318
+ }
319
+
320
+ export function maximumNumbers(values: AbstractNumber[]): AbstractNumber {
321
+ if (values.length === 0) return unknownNumber()
322
+ return {
323
+ kind: 'number',
324
+ lower: Math.max(...values.map(value => value.lower)),
325
+ upper: Math.max(...values.map(value => value.upper)),
326
+ integer: values.every(value => value.integer),
327
+ mayBeNaN: values.some(value => value.mayBeNaN),
328
+ }
329
+ }
330
+
331
+ export function includesZero(value: AbstractNumber): boolean {
332
+ return value.lower <= 0 && value.upper >= 0 && value.excludesPoint !== 0
333
+ }
334
+
335
+ export function isDefinitelyZero(value: AbstractNumber): boolean {
336
+ return value.lower === 0
337
+ && value.upper === 0
338
+ && !value.mayBeNaN
339
+ && value.excludesPoint !== 0
340
+ }
341
+
342
+ // Whether the abstract value provably never holds the point — by its bounds, by the
343
+ // integer flag against a fractional point, or by the excluded-point cut.
344
+ export function pointExcluded(value: AbstractNumber, point: number): boolean {
345
+ if (point < value.lower || point > value.upper) return true
346
+ // `integer` describes every finite inhabitant; the interval may still include an
347
+ // infinity introduced by overflow. Only a finite fractional point is impossible.
348
+ if (value.integer && Number.isFinite(point) && !Number.isInteger(point)) return true
349
+ return value.excludesPoint === point
350
+ }
351
+
352
+ // A joined or widened interval may keep one hole only when both inputs exclude it. Zero
353
+ // is always considered because division cares about it even when neither input needed an
354
+ // explicit cut before their disjoint ranges were combined.
355
+ function sharedExcludedPoint(left: AbstractNumber, right: AbstractNumber, lower: number, upper: number): number | null {
356
+ for (const point of [left.excludesPoint, right.excludesPoint, 0]) {
357
+ if (point == null) continue
358
+ if (pointExcluded(left, point) && pointExcluded(right, point) && lower < point && point < upper) return point
359
+ }
360
+ return null
361
+ }
362
+
363
+ export function joinNumbers(left: AbstractNumber, right: AbstractNumber): AbstractNumber {
364
+ const joined: AbstractNumber = {
365
+ kind: 'number',
366
+ lower: Math.min(left.lower, right.lower),
367
+ upper: Math.max(left.upper, right.upper),
368
+ integer: left.integer && right.integer,
369
+ mayBeNaN: left.mayBeNaN || right.mayBeNaN,
370
+ }
371
+ // A point stays excluded when neither side can hold it — whether by cut or by bounds,
372
+ // which is what pointExcluded checks. This also captures a sign-split join: [-5, -2]
373
+ // joined with [2, 5] straddles zero yet never holds it (zero is tried even when neither
374
+ // side carries a cut, since it is the point division cares about).
375
+ const excludesPoint = sharedExcludedPoint(left, right, joined.lower, joined.upper)
376
+ if (excludesPoint != null) joined.excludesPoint = excludesPoint
377
+ const nonFiniteSite = (!isFiniteNumber(left) ? left.nonFiniteSite : undefined)
378
+ ?? (!isFiniteNumber(right) ? right.nonFiniteSite : undefined)
379
+ if (!isFiniteNumber(joined) && nonFiniteSite != null) joined.nonFiniteSite = nonFiniteSite
380
+ const nanSite = (left.mayBeNaN ? left.nanSite : undefined)
381
+ ?? (right.mayBeNaN ? right.nanSite : undefined)
382
+ if (joined.mayBeNaN && nanSite != null) joined.nanSite = nanSite
383
+ return joined
384
+ }
385
+
386
+ export function sameNumbers(left: AbstractNumber, right: AbstractNumber): boolean {
387
+ return left.lower === right.lower
388
+ && left.upper === right.upper
389
+ && left.integer === right.integer
390
+ && left.mayBeNaN === right.mayBeNaN
391
+ && left.excludesPoint === right.excludesPoint
392
+ }
393
+
394
+ export function widenNumber(previous: AbstractNumber, next: AbstractNumber): AbstractNumber {
395
+ const finite = isFiniteNumber(previous) && isFiniteNumber(next)
396
+ const widened: AbstractNumber = {
397
+ kind: 'number',
398
+ lower: next.lower < previous.lower
399
+ ? finite ? -Number.MAX_VALUE : Number.NEGATIVE_INFINITY
400
+ : next.lower,
401
+ upper: next.upper > previous.upper
402
+ ? finite ? Number.MAX_VALUE : Number.POSITIVE_INFINITY
403
+ : next.upper,
404
+ integer: next.integer,
405
+ mayBeNaN: next.mayBeNaN,
406
+ }
407
+ if (!isFiniteNumber(widened) && next.nonFiniteSite != null) widened.nonFiniteSite = next.nonFiniteSite
408
+ if (widened.mayBeNaN && next.nanSite != null) widened.nanSite = next.nanSite
409
+ // The widened interval is a fresh, wider cover — a point stays excluded only when both
410
+ // rounds excluded it, same rule as joins. The cut can disappear across rounds and never
411
+ // reappear, so the fixed point still converges.
412
+ const excludesPoint = sharedExcludedPoint(previous, next, widened.lower, widened.upper)
413
+ if (excludesPoint != null) widened.excludesPoint = excludesPoint
414
+ return widened
415
+ }
416
+
417
+ function boundedResult(
418
+ lower: number,
419
+ upper: number,
420
+ integer: boolean,
421
+ left: AbstractNumber,
422
+ right: AbstractNumber,
423
+ ): AbstractNumber {
424
+ // With a possibly non-finite or NaN operand, the bound arithmetic itself is meaningless
425
+ // (Infinity - Infinity is NaN), so the result collapses to unknown. With clean operands
426
+ // the bounds are trustworthy even when they overflow to ±Infinity — overflow produces an
427
+ // infinity at runtime, never a NaN, so the result stays NaN-free.
428
+ if (!safeOperands(left, right)) return unknownNumber()
429
+ return {kind: 'number', lower, upper, integer, mayBeNaN: false}
430
+ }
431
+
432
+ function safeOperands(left: AbstractNumber, right: AbstractNumber): boolean {
433
+ return isFiniteNumber(left) && isFiniteNumber(right) && !left.mayBeNaN && !right.mayBeNaN
434
+ }
435
+
436
+ export function unknownNumber(): AbstractNumber {
437
+ return {
438
+ kind: 'number',
439
+ lower: Number.NEGATIVE_INFINITY,
440
+ upper: Number.POSITIVE_INFINITY,
441
+ integer: false,
442
+ mayBeNaN: true,
443
+ }
444
+ }