Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 19 additions & 3 deletions src/Entropy/EntropyGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -60,15 +64,26 @@ 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.
if ($range === 0) {
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
Expand All @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions tests/Unit/Entropy/EntropyGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down