diff --git a/README.md b/README.md index a59f1ac..2c5e76b 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ $hex = $entropy->hex(128); // 32-char hex string $roll = $entropy->integer(1, 6); // unbiased die roll (rejection sampling) ``` +`integer($min, $max)` accepts any bounds whose span fits in a signed 64-bit integer, `integer(0, PHP_INT_MAX)` included; a span wider than that, such as `integer(PHP_INT_MIN, PHP_INT_MAX)`, throws an `InvalidArgumentException`. + ### Batch Execution Run several circuits in a single Python process instead of paying the interpreter start-up cost once per circuit. The results come back as a `BatchResult`, ordered like the input, which is arrayable, jsonable, countable and iterable over the individual `CircuitResult` objects. diff --git a/src/Entropy/EntropyGenerator.php b/src/Entropy/EntropyGenerator.php index 5d19e47..d28345e 100644 --- a/src/Entropy/EntropyGenerator.php +++ b/src/Entropy/EntropyGenerator.php @@ -51,6 +51,10 @@ public function hex(int $bits): string /** * Generate an unbiased random integer in [$min, $max] using rejection sampling. + * + * Any bounds are accepted as long as $max - $min fits in a signed 64-bit + * integer, so integer(0, PHP_INT_MAX) works while + * integer(PHP_INT_MIN, PHP_INT_MAX) is rejected. */ public function integer(int $min, int $max): int { @@ -60,6 +64,16 @@ public function integer(int $min, int $max): int ); } + // A span wider than PHP_INT_MAX would overflow the subtraction and + // need a 64-bit chunk, which bindec() can only return as a float. + // With a non-negative $min the span cannot overflow; otherwise + // PHP_INT_MAX + $min is the largest $max that still fits. + if ($min < 0 && $max > PHP_INT_MAX + $min) { + throw new \InvalidArgumentException( + "The span between {$min} and {$max} exceeds PHP_INT_MAX; request a range that fits in a signed 64-bit integer." + ); + } + $range = $max - $min; // Edge case: single possible value. @@ -67,8 +81,9 @@ public function integer(int $min, int $max): int return $min; } - $bitsNeeded = (int) ceil(log($range + 1, 2)); - $mask = (1 << $bitsNeeded) - 1; + // decbin() gives the exact bit length; ceil(log(range + 1, 2)) loses + // precision above 2^53 and under-counts for ranges such as 2^62. + $bitsNeeded = strlen(decbin($range)); // A correct entropy source accepts on the first batch with overwhelming // probability; the cap is a safety net against a degenerate source that @@ -82,7 +97,8 @@ public function integer(int $min, int $max): int $chunk = substr($bitstring, $offset, $bitsNeeded); $offset += $bitsNeeded; - $value = (int) bindec($chunk) & $mask; + // The chunk is exactly $bitsNeeded digits, so no mask is needed. + $value = (int) bindec($chunk); if ($value <= $range) { return $min + $value; diff --git a/tests/Unit/Entropy/EntropyGeneratorTest.php b/tests/Unit/Entropy/EntropyGeneratorTest.php index 9c84622..77c2cbe 100644 --- a/tests/Unit/Entropy/EntropyGeneratorTest.php +++ b/tests/Unit/Entropy/EntropyGeneratorTest.php @@ -161,6 +161,32 @@ $generator->integer(0, 2); })->throws(QuantumExecutionException::class, 'entropy'); +// ------------------------------------------------------------------------- +// Wide ranges +// ------------------------------------------------------------------------- + +it('integer covers the full positive 64-bit range', function () use (&$device, &$generator): void { + // The first 63-bit chunk of all-ones is PHP_INT_MAX itself, so it is accepted as-is. + $device->method('generateEntropy')->with(256)->willReturn(str_repeat("\xff", 32)); + + expect($generator->integer(0, PHP_INT_MAX))->toBe(PHP_INT_MAX); +}); + +it('integer can return the top of a power-of-two range', function () use (&$device, &$generator): void { + // 2^62 needs 63 bits; the float-based bit count rounded down to 62 and + // could never produce it. First chunk: 1 followed by 62 zeros. + $device->method('generateEntropy')->with(256)->willReturn("\x80".str_repeat("\x00", 31)); + + expect($generator->integer(0, 2 ** 62))->toBe(2 ** 62); +}); + +it('integer rejects a span wider than a signed 64-bit integer', function () use (&$device, &$generator): void { + $device->expects($this->never())->method('generateEntropy'); + + expect(fn () => $generator->integer(PHP_INT_MIN, PHP_INT_MAX)) + ->toThrow(InvalidArgumentException::class, 'exceeds PHP_INT_MAX'); +}); + // ------------------------------------------------------------------------- // Validation: min > max // -------------------------------------------------------------------------