Skip to content

fix(beam): give sized integers .NET fixed-width semantics#4769

Merged
dbrattli merged 2 commits into
mainfrom
feat/beam-fixed-width-integers
Jul 13, 2026
Merged

fix(beam): give sized integers .NET fixed-width semantics#4769
dbrattli merged 2 commits into
mainfrom
feat/beam-fixed-width-integers

Conversation

@dbrattli

Copy link
Copy Markdown
Collaborator

Problem

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 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.nextUInt64 draws 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.MaxValue was 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 new fable_int runtime 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:

  • Arithmetic: +, -, *, bsl, negation and bnot are wrapped. band/bor/bxor/bsr/rem cannot grow an in-range value, so they stay bare. bigint and the fixed-scale decimal are never wrapped.
  • Shift counts are masked to the width, since .NET uses only the low bits (1 <<< 32 is 1, not 0).
  • Conversions now truncate on narrowing (byte 300 = 44), skipped when the source range already fits the target.
  • Unsigned representation: stored as the unsigned value (0..2^n-1) rather than a signed bit pattern, so UInt64.MaxValue emits 18446744073709551615 and bsr is a logical shift.

Also implements ~~~ on unsigned integers (LogicalNotDynamic), previously an unsupported-construct error — it is one of the operations that needs wrapping.

Verification

  • Full Beam suite green: 2553 Erlang tests, 0 failures (2546 on .NET, 0 failures).
  • New fixed-width section in 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.
  • 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.
  • The original symptom: a splitmix64 rejection sampler now terminates and produces draws bit-identical to .NET (mix64 42L = 7599677983577462802).

Unblocks adding Beam to property-based testing with Hedgehog, which is impossible until the RNG works.

🤖 Generated with Claude Code

dbrattli and others added 2 commits July 13, 2026 11:59
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>
@dbrattli dbrattli merged commit 2ed3963 into main Jul 13, 2026
65 of 67 checks passed
@dbrattli dbrattli deleted the feat/beam-fixed-width-integers branch July 13, 2026 10:28
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.

1 participant