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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
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)
```

`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.
Expand Down
16 changes: 14 additions & 2 deletions src/Bridge/PythonBridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,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) % 8 !== 0) {
throw new \InvalidArgumentException(
'Bit string must be a sequence of 0/1 digits whose length is a multiple of 8, 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).
// 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);
}

Expand Down
4 changes: 3 additions & 1 deletion src/Contracts/PythonExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 4 additions & 1 deletion src/Contracts/QuantumDevice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
22 changes: 18 additions & 4 deletions src/Drivers/AbstractQuantumDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,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();
Expand All @@ -357,7 +361,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 / 8) * 8;
$shots = (int) ceil($bitsToFetch / $qubits);

$payload = $this->payload([
'qubits' => $qubits,
Expand All @@ -373,14 +380,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));

Expand Down
4 changes: 4 additions & 0 deletions src/Entropy/EntropyGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
12 changes: 12 additions & 0 deletions tests/Unit/Bridge/PythonBridgeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
42 changes: 42 additions & 0 deletions tests/Unit/Drivers/AbstractQuantumDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down