diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..469d2ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ Quantum (Facade) - **PythonBridge** only passes non-null env vars to preserve boto3 credential chain (IAM Roles). - **QPU safety:** Drivers with `synchronous_safe: false` throw on `->run()` to prevent HTTP timeouts. - **EntropyGenerator::integer()** uses rejection sampling on a 256-bit batch buffer — never modulo. +- **Entropy is fetched in whole bytes:** `generateEntropy($bits)` rounds the request up to a multiple of 8 before computing shots, and `PythonBridge::bitstringToBytes()` rejects a bit string that is not a multiple of 8, so no byte is ever zero-padded. ## Config diff --git a/README.md b/README.md index a59f1ac..d31377c 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) ``` +`generate($bits)` returns `ceil($bits / 8)` bytes. A bit count that is not a multiple of 8 is rounded up before the device is asked, so the last byte is measured in full rather than zero-padded: `generate(12)` fetches at least 16 bits (exactly 16 with the default `entropy_qubits` of 16) and returns 2 fully random bytes. + ### 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/Bridge/PythonBridge.php b/src/Bridge/PythonBridge.php index a7a0a0d..dec8d88 100644 --- a/src/Bridge/PythonBridge.php +++ b/src/Bridge/PythonBridge.php @@ -16,6 +16,8 @@ */ class PythonBridge implements PythonExecutor { + private const BITS_PER_BYTE = 8; + private readonly string $scriptsPath; public function __construct( @@ -135,14 +137,26 @@ public function scriptsPath(): string /** * Convert a binary digit string (e.g. "10110011") into raw bytes. + * + * The string must hold a whole number of bytes: bindec() would silently + * left-pad a shorter final chunk with zeros, producing a byte whose high + * bits are deterministic rather than measured. + * + * @throws \InvalidArgumentException When the string is not a multiple of 8 binary digits. */ public function bitstringToBytes(string $bitstring): string { + if (preg_match('/^[01]+$/D', $bitstring) !== 1 || strlen($bitstring) % self::BITS_PER_BYTE !== 0) { + throw new \InvalidArgumentException( + 'Bit string must be a sequence of 0/1 digits whose length is a multiple of ' . self::BITS_PER_BYTE . ', got '.strlen($bitstring).' character(s): '.var_export($bitstring, true) + ); + } + $bytes = ''; - foreach (str_split($bitstring, 8) as $chunk) { - // & 0xFF keeps the value in chr()'s 0-255 range (each chunk is at - // most 8 bits, so this is a no-op for valid input). + foreach (str_split($bitstring, self::BITS_PER_BYTE) as $chunk) { + // The guard above makes every chunk exactly 8 binary digits; the + // mask only narrows the type to chr()'s 0-255 range. $bytes .= chr(((int) bindec($chunk)) & 0xFF); } diff --git a/src/Contracts/PythonExecutor.php b/src/Contracts/PythonExecutor.php index 22ff647..3bee581 100644 --- a/src/Contracts/PythonExecutor.php +++ b/src/Contracts/PythonExecutor.php @@ -19,7 +19,9 @@ interface PythonExecutor public function execute(string $script, array $payload, array $driverConfig = []): array; /** - * Convert a binary digit string into raw bytes. + * Convert a binary digit string holding a whole number of bytes into raw bytes. + * + * @throws \InvalidArgumentException When the string is not a multiple of 8 binary digits. */ public function bitstringToBytes(string $bitstring): string; diff --git a/src/Contracts/QuantumDevice.php b/src/Contracts/QuantumDevice.php index a95f2c6..03acaf4 100644 --- a/src/Contracts/QuantumDevice.php +++ b/src/Contracts/QuantumDevice.php @@ -18,7 +18,10 @@ interface QuantumDevice public function executeCircuit(CircuitBuilder $circuit): CircuitResult; /** - * Generate a cryptographically strong random bit-string of the requested length. + * Generate cryptographically strong random bytes covering the requested bit count. + * + * Returns ceil($bits / 8) raw bytes; a bit count that is not a multiple of + * 8 is rounded up so every byte is fully random rather than zero-padded. */ public function generateEntropy(int $bits): string; } diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..d27eed2 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -24,6 +24,8 @@ */ abstract class AbstractQuantumDriver implements BatchableDevice, QuantumDevice { + private const BITS_PER_BYTE = 8; + use DispatchesLifecycleEvents; /** @@ -344,6 +346,10 @@ protected function pollTask(string $taskArn): TaskSnapshot return TaskSnapshot::fromResponse($response); } + /** + * Returns ceil($bits / 8) bytes, every bit of which was measured: the + * request is rounded up to whole bytes before it reaches the device. + */ public function generateEntropy(int $bits): string { $this->preflight(); @@ -357,7 +363,10 @@ public function generateEntropy(int $bits): string $qubits = 16; } - $shots = (int) ceil($bits / $qubits); + // Fetch whole bytes: a final chunk shorter than 8 bits would be + // zero-padded into a byte whose high bits are never random. + $bitsToFetch = (int) ceil($bits / self::BITS_PER_BYTE) * self::BITS_PER_BYTE; + $shots = (int) ceil($bitsToFetch / $qubits); $payload = $this->payload([ 'qubits' => $qubits, @@ -373,14 +382,21 @@ public function generateEntropy(int $bits): string ); } - if (strlen($response['bits']) < $bits) { + if (preg_match('/^[01]*$/D', $response['bits']) !== 1) { + throw QuantumExecutionException::malformedResponse( + 'entropy.py', + 'expected the "bits" value to contain only 0 and 1 digits.' + ); + } + + if (strlen($response['bits']) < $bitsToFetch) { throw QuantumExecutionException::malformedResponse( 'entropy.py', - "expected at least {$bits} bits in the response, got ".strlen($response['bits']).'.' + "expected at least {$bitsToFetch} bits in the response, got ".strlen($response['bits']).'.' ); } - $bitstring = substr($response['bits'], 0, $bits); + $bitstring = substr($response['bits'], 0, $bitsToFetch); $this->dispatchEvent(new EntropyGenerated($this->driverName(), $bits)); diff --git a/src/Entropy/EntropyGenerator.php b/src/Entropy/EntropyGenerator.php index 5d19e47..7b86bfa 100644 --- a/src/Entropy/EntropyGenerator.php +++ b/src/Entropy/EntropyGenerator.php @@ -29,6 +29,10 @@ public function __construct(private readonly QuantumDevice $device) {} /** * Generate raw entropy bytes. + * + * Returns ceil($bits / 8) bytes. A bit count that is not a multiple of 8 + * is rounded up before it reaches the device, so every returned byte is + * fully measured rather than zero-padded. */ public function generate(int $bits): string { diff --git a/tests/Unit/Bridge/PythonBridgeTest.php b/tests/Unit/Bridge/PythonBridgeTest.php index bcdb1b4..0fd9a52 100644 --- a/tests/Unit/Bridge/PythonBridgeTest.php +++ b/tests/Unit/Bridge/PythonBridgeTest.php @@ -180,6 +180,18 @@ function fakePython(string $shBody): string // bitstringToBytes() // ------------------------------------------------------------------------- +it('rejects a bit string that does not hold whole bytes', function (string $bitstring) { + $bridge = new PythonBridge('python3'); + + expect(fn () => $bridge->bitstringToBytes($bitstring)) + ->toThrow(InvalidArgumentException::class, 'multiple of 8'); +})->with([ + 'short final chunk' => ['110011001010'], + 'empty' => [''], + 'non-binary digit' => ['0000000a'], + 'trailing newline' => ["1111111\n"], +]); + it('converts a binary digit string into raw bytes', function () { $bridge = new PythonBridge('python3'); diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1c79b88..d07a6a1 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -233,6 +233,48 @@ protected function beforeExecution(): void $this->driver->generateEntropy(16); })->throws(QuantumExecutionException::class); +it('rounds a bit count up to whole bytes before asking the device', function (int $bits, int $qubits, int $shots, int $fetched) { + $bridge = $this->createMock(PythonExecutor::class); + $bridge->expects($this->once()) + ->method('execute') + ->with('entropy.py', $this->callback(fn (array $p): bool => $p['qubits'] === $qubits && $p['shots'] === $shots), $this->anything()) + ->willReturn(['bits' => str_repeat('1', $shots * $qubits)]); + $bridge->expects($this->once()) + ->method('bitstringToBytes') + ->with(str_repeat('1', $fetched)) + ->willReturn(str_repeat("\xff", intdiv($fetched, 8))); + + $driver = new class($bridge, ['entropy_qubits' => $qubits]) extends AbstractQuantumDriver + { + protected function driverName(): string + { + return 'test'; + } + }; + + expect($driver->generateEntropy($bits))->toBe(str_repeat("\xff", intdiv($fetched, 8))); +})->with([ + '12 bits on 16 qubits' => [12, 16, 1, 16], + '9 bits on 4 qubits' => [9, 4, 4, 16], + '17 bits on 16 qubits' => [17, 16, 2, 24], + '16 bits on 16 qubits' => [16, 16, 1, 16], +]); + +it('rejects entropy.py output that is not made of binary digits', function () { + $this->bridge->method('execute')->willReturn(['bits' => 'abcdefgh']); + + expect(fn () => $this->driver->generateEntropy(8)) + ->toThrow(QuantumExecutionException::class, 'only 0 and 1 digits'); +}); + +it('requires the device to return the rounded-up bit count', function () { + // 12 requested bits need 16 measured bits; 12 is no longer enough. + $this->bridge->method('execute')->willReturn(['bits' => str_repeat('1', 12)]); + + expect(fn () => $this->driver->generateEntropy(12)) + ->toThrow(QuantumExecutionException::class, 'expected at least 16 bits'); +}); + // ------------------------------------------------------------------------- // entropy_qubits clamping // -------------------------------------------------------------------------