Skip to content

Compare ints with a float bound the way PHP does in IntegerRangeType - #6542

Open
zonuexe wants to merge 4 commits into
phpstan:2.3.xfrom
zonuexe:int-range-float-2-pow-63
Open

zonuexe wants to merge 4 commits into
phpstan:2.3.xfrom
zonuexe:int-range-float-2-pow-63

Conversation

@zonuexe

@zonuexe zonuexe commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

IntegerRangeType::createAllSmallerThan(), createAllSmallerThanOrEqualTo(), createAllGreaterThan() and createAllGreaterThanOrEqualTo() can return ranges that disagree with PHP when the bound is a float of magnitude 2**53 or more, or NaN; for NaN they also emit a "not representable as an int" warning. These factories narrow $int < $float and the other comparisons with a float constant, so a branch can lose ints that do reach it.

Impact

On 64-bit builds, before (current 2.3.x) and after this PR:

comparison branch before after
$i >= 9.2233720368547758E18 truthy *NEVER* int<9223372036854775296, max>
$i < 9.2233720368547758E18 falsy *NEVER* int<9223372036854775296, max>
$i < 9007199254740996.0 truthy int<min, 9007199254740995> int<min, 9007199254740994>
$i <= -9.2233720368547758E18 truthy -9223372036854775808 int<min, -9223372036854775296>
$i <= 9007199254740992.0 truthy int<min, 9007199254740992> int<min, 9007199254740993>
$i < NAN falsy int<0, max> int

PHP_INT_MAX >= 9.2233720368547758E18 is true at runtime (https://3v4l.org/WlQ9F), so the first two rows sent PHP_INT_MAX to a branch typed *NEVER*. No comparison holds for NaN, so -1 takes the falsy branch of $i < NAN too.

Cause

PHP compares an int with a float by converting the int to float. On 64-bit builds that conversion rounds every int beyond 2**53 in magnitude to the nearest float, with ties to even, so 9007199254740993 converts to 9007199254740992.0, 9007199254740995 to 9007199254740996.0, and every int from 2**63 - 512 up to 2**63. The factories took the bound from ceil() or floor() of the float, which is the exact mathematical bound, not the one PHP's comparison uses. The two agree for floats below 2**53 in magnitude, and on 32-bit builds, where every int converts exactly; that is why FLOAT_ABOVE_INT_MAX was enough there. For NaN, the factories cast ceil(NAN) or floor(NAN) to int.

Fix

The factories pass a float bound to a new private fromFloatComparison(), which checks the comparison at PHP_INT_MIN and PHP_INT_MAX and, if the result differs, bisects the int range with the comparison itself. The result is the set of ints PHP's own comparison accepts, on either int width. For NaN the factories return *NEVER* because no comparison holds for it, so the falsy branch keeps every int. This replaces the second commit's FLOAT_ABOVE_INT_MAX, which the new code no longer needs.

A value that is neither int nor float now fails on fromFloatComparison()'s float parameter in all four factories. Before, createAllGreaterThanOrEqualTo('-1e30') returned int from a loose comparison with PHP_INT_MIN before reaching ceil().

turbo-ext/src/IntegerRangeType.cpp gets the same bisection, with the VM's int-with-float comparison (both sides as doubles), and declares fromFloatComparison() natively, since the native class already declares the twin's private constructor. turbo-ext/tests/type-family.php adds the 2**53 bounds and calls fromFloatComparison() through reflection, including an unknown operator.

Other sites

  • ConstantStringType passes (float) $this->value for a numeric string. PHP compares an int with an integer-numeric string as two ints, so $i < '9007199254740993' should keep 9007199254740992 in the truthy branch, but it lands in the falsy branch. This was the same before this PR, and it is not changed here.
  • ConstantNumericComparisonTypeTrait tests (bool) $this->value, which on PHP 8.5 warns "unexpected NAN value was coerced to bool" for a NaN constant. That is why the nsrt test has no NaN case; testFloatBounds covers NaN at the factory level.

Testing

  • IntegerRangeTypeTest::testCreateFromFloat pins the 64-bit ranges at 2**63, 2**63 - 1024, -2**63, the float below -2**63, 2**53 and 2**53 + 4, which covers rounding to even in both directions. It is skipped on 32-bit hosts.
  • IntegerRangeTypeTest::testFloatBounds covers NaN, INF, -INF, 2.5 and -2.0, which do not depend on the int width.
  • IntegerRangeTypeTest::testMatchesPhpComparison checks, for the bounds that depend on the int width ((float) PHP_INT_MAX, PHP_INT_MAX ± 0.5, (float) PHP_INT_MIN, PHP_INT_MIN ± 0.5, 2**31, ±(2**53 + 4)), that each returned range holds exactly the ints PHP's comparison accepts within 2048 of the float and of both ends of the int range.
  • The nsrt test covers <, <=, > and >= with 9.2233720368547758E18, and both branches of < and <= with 9007199254740996.0.
before (23e9544) after
IntegerRangeTypeTest (64-bit) 29 of 58 fail pass
IntegerRangeTypeTest (linux/386, PHP 8.5.10) — pass (6 skipped)
nsrt integer-range-float-two-to-the-63.php 8 assertions fail pass
turbo-ext/tests/smoke.php against the new PHP code fails with the old .cpp ALL OK

A wider check outside the repository ran the four factories at 803 floats (around ±2**52 … ±2**64, NaN, ±INF, random values) and compared each range with PHP's comparison over 25.9 million ints near the bounds: 414,280 mismatches and 4 warnings before on 64-bit, and no mismatches after on 64-bit (with and without the turbo extension) and on linux/386. The full test suite passes with and without the extension loaded.

The search costs up to 64 comparisons per call: in the PHP implementation a factory call with a float bound takes about 2 µs instead of about 0.2 µs. The factories run only when a comparison with a float bound is narrowed; with the turbo extension the search is native.

@zonuexe zonuexe changed the title Treat the float 2**63 as above every int in IntegerRangeType factories Treat the float 2**63 as above every int in IntegerRangeType factories Sep 22, 2026
@zonuexe
zonuexe force-pushed the int-range-float-2-pow-63 branch from 960a598 to 76aae52 Compare September 22, 2026 20:26
@ondrejmirtes
ondrejmirtes changed the base branch from 2.2.x to 2.3.x November 1, 2026 11:53
@ondrejmirtes
ondrejmirtes force-pushed the int-range-float-2-pow-63 branch from 06683c6 to f91b631 Compare November 1, 2026 12:02
zonuexe and others added 2 commits November 1, 2026 16:09
A float compared with PHP_INT_MAX compares against the float 2**63, so
`$value > PHP_INT_MAX` is false for exactly 2**63. createAllSmallerThan()
and createAllGreaterThanOrEqualTo() then cast 2**63 to int, which emits
"not representable as an int" and yields NeverType / int<min, max>
instead of int / NeverType. Use `>=` like the other two factories do.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit made createAllSmallerThan() and
createAllGreaterThanOrEqualTo() treat a float bound `>= PHP_INT_MAX` as
past the int range. That holds on 64-bit builds, where comparing with
PHP_INT_MAX converts it to float and rounds it up to 2**63, but on 32-bit
builds PHP_INT_MAX is exact as a float: createAllGreaterThanOrEqualTo(
2147483647.0) would return never although the int PHP_INT_MAX satisfies
it, and createAllSmallerThan(2147483647.0) would return int instead of
int<min, 2147483646>.

Decide on the ceil() that is about to be cast instead, compared against
PHP_INT_MAX + 1.0, the first float above PHP_INT_MAX, which is exact on
both widths (2**63 and 2**31). On 64-bit builds every float at or above
2**52 is integral, so the result there is the same as with `>=`. This is
the same code as proposed for 2.3.x in phpstan#6546.

testCreateFromFloat's literals are 64-bit int bounds, so it is skipped on
32-bit hosts; testFloatBounds covers both widths.
@staabm
staabm force-pushed the int-range-float-2-pow-63 branch from f91b631 to 23e9544 Compare November 1, 2026 15:09
function greaterOrEqual(int $i): void
{
if ($i >= 9.2233720368547758E18) {
assertType('*NEVER*', $i);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems wrong: https://3v4l.org/WlQ9F#v

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, thanks. PHP compares an int with a float by converting the int to float, and (float) PHP_INT_MAX is 2**63, so PHP_INT_MAX >= 9.2233720368547758E18 holds. Every int from 2**63 - 512 up converts to 2**63, so the truthy branch there is int<9223372036854775296, max>, not *NEVER*.

The same rounding can shift the bound for any float of magnitude 2**53 or more, not only 2**63. For example, $i < 9007199254740996.0 kept 9007199254740995 in the truthy branch, although that int rounds up to the float itself, and $i <= -9.2233720368547758E18 gave only PHP_INT_MIN, although the ints up to -2**63 + 512 satisfy it.

943c365 makes the four createAll*() factories search for the bound with PHP's own comparison instead of deriving it from ceil() or floor(). The nsrt test now expects int<9223372036854775296, max> for your case, and the new testMatchesPhpComparison checks the ranges against PHP's comparisons around each bound, on 64-bit and on 32-bit builds.

@zonuexe
zonuexe marked this pull request as draft November 1, 2026 20:15
PHP compares an int with a float by converting the int to float, which
rounds every int beyond 2**53 on 64-bit builds. PHP_INT_MAX >= 2.0 ** 63
holds, so `$i >= 9.2233720368547758E18` is true for the ints from
2**63 - 512 up, and the createAll*() factories, which derived the bound
from ceil() or floor() of the float, narrowed the other branch to ints
that never reach it: `$i >= 2**63` became never, `$i < 2**53 + 4` kept
2**53 + 3 (which rounds to 2**53 + 4), and `$i <= -2**63` kept only
PHP_INT_MIN although the ints up to -2**63 + 512 satisfy it. A NaN bound
emitted "not representable as an int" and gave ranges no int satisfies.

Bisect the bound with the comparison itself instead. That is exact on
any int width (on 32-bit builds every int converts exactly, which also
covers what FLOAT_ABOVE_INT_MAX was for), and NaN gives never, since no
comparison holds for it. A value that is neither int nor float now fails
on fromFloatComparison()'s float parameter in all four factories, where
it used to depend on loose comparisons with the int limits first.

testMatchesPhpComparison checks the returned ranges against PHP's own
comparisons around the bound on both int widths; testCreateFromFloat
pins the 64-bit bounds, including both directions of rounding to even.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@zonuexe zonuexe changed the title Treat the float 2**63 as above every int in IntegerRangeType factories Compare ints with a float bound the way PHP does in IntegerRangeType Nov 2, 2026
The PHP 7.4 and 8.0 builds downgrade src/ and tests/ with
simple-downgrade, which has no visitor for match expressions, so Lint
(7.4) failed with "unexpected '=>'" on both files. Pick the comparison
with an if/elseif chain in fromFloatComparison(), and pass the factory
and the comparison from dataMatchesPhpComparison() as closures.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@zonuexe
zonuexe marked this pull request as ready for review November 2, 2026 10:23
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants