fix(beam): give sized integers .NET fixed-width semantics#4769
Merged
Conversation
Erlang integers are arbitrary precision and never overflow, while .NET's int8..int64/uint8..uint64 wrap. The Beam backend emitted bare Erlang arithmetic for them, so any code relying on wraparound computed the wrong answer -- and hash/PRNG code that feeds a rejection-sampling loop (such as Hedgehog's splitmix64 seed) never terminated, because the drawn value grew without bound and never fell under the limit. Implement Strategy A from FABLE-BEAM.md: bit-syntax wrapping in a new fable_int runtime module. Codegen routes the operations that can leave a type's width through it (+, -, *, bsl, negation, bnot and narrowing conversions) and masks shift counts to the width the way .NET does. band/bor/bxor/bsr/rem cannot grow an in-range value, so they stay bare; bigint and the fixed-scale decimal are never wrapped. Unsigned types are now represented as their unsigned value rather than a signed bit pattern, so UInt64.MaxValue emits 18446744073709551615 instead of -1, and bsr is a logical shift. Also implements ~~~ on unsigned integers (LogicalNotDynamic), which was previously an unsupported-construct error and is one of the operations that needs wrapping. Re-enables three tests that were disabled for lack of fixed-width semantics: negation of MinValue, bitwise NOT on large unsigned, and UInt64 shift right. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the fixed-width integer work, from review of #4769: - `char x` now truncates like a uint16. `sizedIntInfo` already described Char as 16-bit unsigned, but no `ToChar` branch routed through the wrapper, so `char 70000` returned 70000 instead of 4464. - Wrap the other narrowing paths that were still identity: `op_Explicit` on the SRTP/Dynamic route, and the BigInteger `ToInt32`/`ToByte`/etc. conversions (a bigint is unbounded, so converting out of it narrows). - Make zero-fill right shift correct for signed types: Erlang's `bsr` propagates the sign, so shift the unsigned reinterpretation and wrap back. Unreachable from Replacements today, but no longer a trap. - Fold `+`, `-`, `*`, `bsl`, negation and `bnot` over integer literals at compile time, so constant expressions no longer emit a cross-module `fable_int:wrap_*` call. The fold uses unchecked int64 arithmetic, which is exact modulo 2^64 and avoids depending on BigInteger. - Correct the comment claiming string parsing raises on out-of-range input: `fable_convert:to_int` does not range-check. Now a TODO. Tests: per-width shift-count masking for 8/16-bit types (F# masks to the width, unlike C# and JavaScript), unsigned multiplication wrap, and narrowing into char. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Erlang integers are arbitrary precision and never overflow, while .NET's
int8..int64/uint8..uint64wrap. The Beam backend emitted bare Erlang arithmetic for them, so any F# code relying on integer wraparound computed the wrong answer on Beam.Worse, it could hang. Hash/PRNG/checksum code depends on multiplication silently overflowing; Hedgehog's splitmix64 seed grew roughly twice as wide per iteration, and because
Seed.nextUInt64draws with rejection sampling (redraw while the value exceeds a limit), an unbounded value never falls under the limit and the loop spins forever.A second-order bug:
UInt64.MaxValuewas lowered via its signed bit pattern and emitted as-1, which on Beam is just minus one — so the sampling limit itself was nonsense.Fix
Strategy A from
FABLE-BEAM.md(which specified this but was never implemented): bit-syntax wrapping, in a newfable_intruntime module. Pure Erlang, no NIF, exact, JIT-friendly. Each wrap has an in-range guard clause first, so the common case returns without building a binary.Codegen routes through it only where a value can actually leave its width:
+,-,*,bsl, negation andbnotare wrapped.band/bor/bxor/bsr/remcannot grow an in-range value, so they stay bare.bigintand the fixed-scaledecimalare never wrapped.1 <<< 32is1, not0).byte 300=44), skipped when the source range already fits the target.0..2^n-1) rather than a signed bit pattern, soUInt64.MaxValueemits18446744073709551615andbsris a logical shift.Also implements
~~~on unsigned integers (LogicalNotDynamic), previously an unsupported-construct error — it is one of the operations that needs wrapping.Verification
tests/Beam/ArithmeticTests.fs: wrapping on overflow/underflow for every width, shift-left wrapping, shift-count masking, narrowing conversions, signed↔unsigned reinterpretation,UInt64.MaxValue, and splitmix64 / FNV-1a round-trips asserted against .NET values.MinValue, bitwise NOT on large unsigned, andUInt64shift right.mix64 42L=7599677983577462802).Unblocks adding Beam to property-based testing with Hedgehog, which is impossible until the RNG works.
🤖 Generated with Claude Code