Skip to content

#5305: reject numbers outside long range in number.as-i64 instead of saturating#5315

Merged
yegor256 merged 1 commit into
objectionary:masterfrom
morphqdd:5305-number-as-i64-range-guard
Jul 10, 2026
Merged

#5305: reject numbers outside long range in number.as-i64 instead of saturating#5315
yegor256 merged 1 commit into
objectionary:masterfrom
morphqdd:5305-number-as-i64-range-guard

Conversation

@morphqdd

@morphqdd morphqdd commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Closes #5305.

number.as-i64 (EOnumber$EOas_i64.java) converted a generic number to i64 via
new Expect.Number(Expect.at(this, Phi.RHO)).it().longValue(). Expect.Number.it() only
validates the value is a number at all — it doesn't check the value is a whole number, nor that
it fits in long range. Double.longValue() doesn't throw for a value outside long range — it
saturates, returning Long.MAX_VALUE/Long.MIN_VALUE. So 1e30.as-i64 — vastly larger than any
i64 can represent — silently produced Long.MAX_VALUE instead of raising a domain error. This
is the same class of defect already reported for Expect.Natural/Expect.Int in #5295, here via
the separate Expect.Number wrapper that backs this very commonly used generic-to-i64
conversion.

Rather than modifying Expect.Number itself (which is used broadly wherever code just needs "is
this a number", with no integer/range assumptions — changing it would affect every caller),
inlined the same Expect.at(...) pipeline directly in EOnumber$EOas_i64 with an added range
check before narrowing, mirroring the style already used in Expect.Int/Expect.Natural
(#5295/#5307):

- new BytesOf(
-     new Expect.Number(Expect.at(this, Phi.RHO)).it().longValue()
- ).take()
+ final long value = Expect.at(this, Phi.RHO)
+     .that(phi -> new Dataized(phi).asNumber())
+     .otherwise("must be a number")
+     .must(number -> number >= Long.MIN_VALUE && number <= Long.MAX_VALUE)
+     .otherwise("must fit into long range")
+     .that(Double::longValue)
+     .it();

Added throws-on-converting-huge-number-to-i64 to number.eo (1.0e30.as-i64), the exact
reproduction value from the issue.

Ran the full EO-generated number test suite locally (EOnumberEOAtomTest) — all 202 tests pass.
The verbose log confirms the exact fix in action:

"the 'ρ' attribute (1.0E30) must fit into long range"

replacing what was previously a silent saturation to Long.MAX_VALUE.

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

🚀 Performance Analysis

All benchmarks are within the acceptable range. No critical degradation detected (threshold is 100%). Please refer to the detailed report for more information.

Click to see the detailed report
Test Base Score PR Score Change % Change Unit Mode
benchmarks.XmirBench.xmirToEO 13276.616 13846.456 569.841 4.29% ms/op Average Time

⚠️ Performance loss: benchmarks.XmirBench.xmirToEO is slower by 569.841 ms/op (4.29%)

@morphqdd

morphqdd commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

The mvn failures here are real, not the shared qulice flake (#5308) — this PR's fix exposes a pre-existing, undocumented reliance in ms/random.eo's LCG step on number.as-i64 silently saturating an out-of-range intermediate product. Filed as #5318, since fixing random.eo here would mix two unrelated issues into one PR. This PR should stay open until #5318 is resolved (or until we agree on a way to land both together).

@yegor256

yegor256 commented Jul 8, 2026

Copy link
Copy Markdown
Member

@morphqdd build is broken here

@morphqdd
morphqdd force-pushed the 5305-number-as-i64-range-guard branch from 7edbb7f to af8957b Compare July 8, 2026 09:14
@morphqdd

morphqdd commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@yegor256 the failing build here isn't a defect in this change itself — it's a blocking design dependency I documented in #5318.

This PR makes number.as-i64 raise a positioned error for values outside the int64 range instead of silently saturating (Double.longValue() clamping to Long.MAX_VALUE/Long.MIN_VALUE). The problem is that ms/random.eo's LCG step relies on that saturation as its overflow/wrap behaviour, so once as-i64 stops saturating, EOrandomTest fails.

So this can't go green until we decide how to handle #5318:

  • fix ms/random.eo to not depend on as-i64 saturation (e.g. an explicit modulo/wrap in the LCG step), then this PR passes; or
  • decide that saturation in as-i64 is intended, in which case this PR should be closed.

I kept this PR open rather than force it through, since the right fix depends on that decision. Happy to implement whichever direction you prefer.

@gemshrine gemshrine left a comment

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.

One boundary case slips through the range check as written.

number <= Long.MAX_VALUE compares against (double) Long.MAX_VALUE, which is exactly 9.223372036854776E18 == 2^63Long.MAX_VALUE has no exact double representation, so the promotion overshoots by 1. That means 9.223372036854776E18.as-i64 still slips through: the range check passes and Double::longValue then saturates to Long.MAX_VALUE — the exact behavior the PR is aiming to reject. Reproduced locally:

d = 9.223372036854776E18
passes range check: true
Double.valueOf(d).longValue() = 9223372036854775807  // == Long.MAX_VALUE

Swapping the upper bound to a strict < against 0x1p63 (the smallest double that's already above Long.MAX_VALUE) closes this without perturbing the low end, which is exact:

.must(number -> number >= Long.MIN_VALUE && number < 0x1p63)

A 9.223372036854776e18.as-i64 test alongside the 1.0e30 one would lock this in.

The sibling Expect.Int fix in #5307 uses <= Integer.MAX_VALUE — but (double)Integer.MAX_VALUE is exact, so that check is unaffected by the same class of bug.

@morphqdd
morphqdd force-pushed the 5305-number-as-i64-range-guard branch 2 times, most recently from 31edaed to 84e707a Compare July 8, 2026 15:14
@morphqdd

morphqdd commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@gemshrine excellent analysis, thank you — you're exactly right. Long.MAX_VALUE (2^63−1) has no exact double, so (double) Long.MAX_VALUE rounds up to 0x1p63 (2^63), which meant 9.223372036854776e18 slipped through number <= Long.MAX_VALUE and then Double::longValue saturated it to Long.MAX_VALUE — the exact case the PR meant to reject.

Switched the upper bound to a strict < 0x1p63:

.must(number -> number >= Long.MIN_VALUE && number < 0x1p63)

and added a throws-on-converting-long-max-boundary-to-i64 test on 9.223372036854776e18.as-i64 alongside the 1.0e30 one. Verified locally: EOnumberTest is green and the new boundary case now raises the 'ρ' attribute (9.223372036854776E18) must fit into long range as expected. (Good point on Expect.Int in #5307 too — (double) Integer.MAX_VALUE is exact, so that check is unaffected.)

The remaining red on this PR is only EOms.EOrandomTest, which is the separate blocker tracked in #5318 (the LCG relies on as-i64 saturation) — unrelated to this boundary fix.

@yegor256

Copy link
Copy Markdown
Member

@morphqdd the master is clean now, please update your branch

@morphqdd
morphqdd force-pushed the 5305-number-as-i64-range-guard branch from 84e707a to aadd28b Compare July 10, 2026 11:36
@morphqdd
morphqdd force-pushed the 5305-number-as-i64-range-guard branch from aadd28b to 63d4a8a Compare July 10, 2026 11:41
@sonarqubecloud

Copy link
Copy Markdown

@morphqdd

Copy link
Copy Markdown
Contributor Author

@yegor256, the blocker (#5318) is merged and this branch is rebased on top — CI is green now.

@yegor256
yegor256 merged commit b10fbfc into objectionary:master Jul 10, 2026
25 checks passed
@0crat

0crat commented Jul 10, 2026

Copy link
Copy Markdown

@morphqdd Thanks for the contribution! You've earned +12 points for this: +16 as a basis; -4 for too few (19) hits-of-code. Please, keep them coming. Your running score is +744; don't forget to check your Zerocracy account too).

@0crat

0crat commented Jul 14, 2026

Copy link
Copy Markdown

@gemshrine Thanks for the review! You've earned +4 points for this: +12 as a basis; -8 for absolutely no comments posted; -4 for too few (19) hits-of-code; +4 to give you at least something. Your running score is +68; don't forget to check your Zerocracy account too).

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.

number.as-i64 doesn't raise a domain error for out-of-range numbers, instead saturates to Long.MAX_VALUE/Long.MIN_VALUE

4 participants