From 27b739c15c1020c7f7038551f459e943e3d8a4dc Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 19:03:28 +0000 Subject: [PATCH 1/4] refactor(drivers): funnel response-shape checks through one expectKey() helper Five call sites in AbstractQuantumDriver repeated the same check on decoded Python output: key present, value of the right type, else throw QuantumExecutionException::malformedResponse() with a hand-written message. expectKey() now performs the check and words the message once ("expected to have a \"\" key holding "), so each call site states only the script, the key, the predicate and the expected shape. The count and length checks that are not shape checks stay inline. Closes #53 --- src/Drivers/AbstractQuantumDriver.php | 119 +++++++++++------- .../Drivers/AbstractQuantumDriverTest.php | 28 ++++- 2 files changed, 98 insertions(+), 49 deletions(-) diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..58c3484 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -156,6 +156,39 @@ private function assertWithinQubitCeiling(CircuitBuilder $circuit): void } } + /** + * Return $response[$key] once it is present and $isValid accepts it, or + * throw the malformed-response exception every script shares. + * + * The single funnel for "key present and of the right shape" checks on + * decoded Python output, so each call site states only what differs: the + * script, the key, the predicate and how to describe the expected value. + * $subject names what the key belongs to when it is not the response + * itself (e.g. "each result" for the items of a batch). + * + * @param array $response + * @param \Closure(mixed): bool $isValid + * + * @throws QuantumExecutionException + */ + private function expectKey( + array $response, + string $script, + string $key, + \Closure $isValid, + string $expected, + string $subject = 'the response' + ): mixed { + if (! array_key_exists($key, $response) || ! $isValid($response[$key])) { + throw QuantumExecutionException::malformedResponse( + $script, + "expected {$subject} to have a \"{$key}\" key holding {$expected}." + ); + } + + return $response[$key]; + } + /** * Wrap script input in the envelope every bin/python script expects: the * data itself plus the driver name and config the provider layer reads. @@ -189,30 +222,29 @@ public function executeBatch(array $circuits): BatchResult $response = $this->bridge->execute('batch.py', $payload, $this->config); - if (! array_key_exists('results', $response) || ! is_array($response['results'])) { - throw QuantumExecutionException::malformedResponse( - 'batch.py', - 'expected the "results" key to be present and hold an array.' - ); - } + /** @var array $results */ + $results = $this->expectKey($response, 'batch.py', 'results', is_array(...), 'an array'); - if (count($response['results']) !== count($circuits)) { + if (count($results) !== count($circuits)) { throw QuantumExecutionException::malformedResponse( 'batch.py', - 'expected exactly '.count($circuits).' results, got '.count($response['results']).'.' + 'expected exactly '.count($circuits).' results, got '.count($results).'.' ); } $circuitResults = []; - foreach ($response['results'] as $result) { - if (! is_array($result) || ! array_key_exists('counts', $result) || ! is_array($result['counts'])) { - throw QuantumExecutionException::malformedResponse( - 'batch.py', - 'expected each result to have a "counts" array.' - ); - } + foreach ($results as $result) { + /** @var array $counts */ + $counts = $this->expectKey( + is_array($result) ? $result : [], + 'batch.py', + 'counts', + is_array(...), + 'an array', + 'each result' + ); - $circuitResults[] = new CircuitResult($result['counts']); + $circuitResults[] = new CircuitResult($counts); } // Announced only once the whole response has been validated, so a @@ -275,14 +307,10 @@ private function runDefinition(array $definition): CircuitResult { $response = $this->bridge->execute('circuit.py', $this->payload($definition), $this->config); - if (! array_key_exists('counts', $response) || ! is_array($response['counts'])) { - throw QuantumExecutionException::malformedResponse( - 'circuit.py', - 'expected the "counts" key to be present and hold an array.' - ); - } + /** @var array $counts */ + $counts = $this->expectKey($response, 'circuit.py', 'counts', is_array(...), 'an array'); - return new CircuitResult($response['counts']); + return new CircuitResult($counts); } /** @@ -305,14 +333,14 @@ protected function submitTask(CircuitBuilder $circuit): string $response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray()), $this->config); - $taskArn = $response['task_arn'] ?? null; - - if (! is_string($taskArn) || trim($taskArn) === '') { - throw QuantumExecutionException::malformedResponse( - 'submit.py', - 'expected the "task_arn" key to be present and hold a non-empty string.' - ); - } + /** @var string $taskArn */ + $taskArn = $this->expectKey( + $response, + 'submit.py', + 'task_arn', + static fn (mixed $value): bool => is_string($value) && trim($value) !== '', + 'a non-empty string' + ); return $taskArn; } @@ -332,14 +360,13 @@ protected function pollTask(string $taskArn): TaskSnapshot $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn]), $this->config); - $status = $response['status'] ?? null; - - if (! is_string($status) || TaskStatus::tryFrom($status) === null) { - throw QuantumExecutionException::malformedResponse( - 'check.py', - 'expected the "status" key to be present and hold a valid task status value.' - ); - } + $this->expectKey( + $response, + 'check.py', + 'status', + static fn (mixed $value): bool => is_string($value) && TaskStatus::tryFrom($value) !== null, + 'a valid task status value' + ); return TaskSnapshot::fromResponse($response); } @@ -366,21 +393,17 @@ public function generateEntropy(int $bits): string $response = $this->bridge->execute('entropy.py', $payload, $this->config); - if (! array_key_exists('bits', $response) || ! is_string($response['bits'])) { - throw QuantumExecutionException::malformedResponse( - 'entropy.py', - 'expected the "bits" key to be present and hold a string.' - ); - } + /** @var string $bitstring */ + $bitstring = $this->expectKey($response, 'entropy.py', 'bits', is_string(...), 'a string'); - if (strlen($response['bits']) < $bits) { + if (strlen($bitstring) < $bits) { throw QuantumExecutionException::malformedResponse( 'entropy.py', - "expected at least {$bits} bits in the response, got ".strlen($response['bits']).'.' + "expected at least {$bits} bits in the response, got ".strlen($bitstring).'.' ); } - $bitstring = substr($response['bits'], 0, $bits); + $bitstring = substr($bitstring, 0, $bits); $this->dispatchEvent(new EntropyGenerated($this->driverName(), $bits)); diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1c79b88..3fdf948 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -323,6 +323,32 @@ protected function driverName(): string $this->driver->executeBatch([$circuit]); })->throws(QuantumExecutionException::class, '"results" key'); +it('names the script, the key and the expected shape in a malformed-response message', function () { + $this->bridge->method('execute')->willReturn(['counts' => 'not-an-array']); + + $circuit = $this->createMock(CircuitBuilder::class); + $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1]); + $circuit->method('qubitCount')->willReturn(1); + + expect(fn () => $this->driver->executeCircuit($circuit))->toThrow( + QuantumExecutionException::class, + 'Python script [circuit.py] returned a malformed response: expected the response to have a "counts" key holding an array.' + ); +}); + +it('reports a missing key the same way as a key of the wrong type', function (array $response, string $key) { + $this->bridge->method('execute')->willReturn($response); + + expect(fn () => $this->driver->generateEntropy(8))->toThrow( + QuantumExecutionException::class, + "expected the response to have a \"{$key}\" key holding a string." + ); +})->with([ + 'absent' => [[], 'bits'], + 'null' => [['bits' => null], 'bits'], + 'integer' => [['bits' => 12345], 'bits'], +]); + it('throws when a batch.py result lacks a counts array', function () { $this->bridge->method('execute')->willReturn(['results' => [['status' => 'ok']]]); @@ -330,7 +356,7 @@ protected function driverName(): string $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1000]); $this->driver->executeBatch([$circuit]); -})->throws(QuantumExecutionException::class, '"counts" array'); +})->throws(QuantumExecutionException::class, 'expected each result to have a "counts" key holding an array'); it('throws when batch.py results count does not match circuits count', function () { $this->bridge->method('execute')->willReturn(['results' => [['counts' => ['0' => 500]]]]); From 8ef0cd830e38040e3e7682f97c3b0e3448b52f89 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 19:09:50 +0000 Subject: [PATCH 2/4] refactor(drivers): keep the validated status, name the offending batch item, and stop over-claiming counts types pollTask() now hands the status expectKey() validated to TaskSnapshot::fromResponse() instead of letting the snapshot re-parse the key. A batch item that is not an object gets its own message with its index, and the per-item counts check names the item too. The @var annotations on counts say array, which is what is_array() proves; CircuitResult owns the value shape. --- src/Drivers/AbstractQuantumDriver.php | 27 ++++++++++--------- src/Tasks/TaskSnapshot.php | 16 ++++++++--- .../Drivers/AbstractQuantumDriverTest.php | 15 ++++++++++- tests/Unit/Tasks/TaskSnapshotTest.php | 7 +++++ 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index 58c3484..10b8fc3 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -233,16 +233,16 @@ public function executeBatch(array $circuits): BatchResult } $circuitResults = []; - foreach ($results as $result) { - /** @var array $counts */ - $counts = $this->expectKey( - is_array($result) ? $result : [], - 'batch.py', - 'counts', - is_array(...), - 'an array', - 'each result' - ); + foreach (array_values($results) as $index => $result) { + if (! is_array($result)) { + throw QuantumExecutionException::malformedResponse( + 'batch.py', + "expected result #{$index} to be an object, got ".get_debug_type($result).'.' + ); + } + + /** @var array $counts */ + $counts = $this->expectKey($result, 'batch.py', 'counts', is_array(...), 'an array', "result #{$index}"); $circuitResults[] = new CircuitResult($counts); } @@ -307,7 +307,7 @@ private function runDefinition(array $definition): CircuitResult { $response = $this->bridge->execute('circuit.py', $this->payload($definition), $this->config); - /** @var array $counts */ + /** @var array $counts */ $counts = $this->expectKey($response, 'circuit.py', 'counts', is_array(...), 'an array'); return new CircuitResult($counts); @@ -360,7 +360,8 @@ protected function pollTask(string $taskArn): TaskSnapshot $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn]), $this->config); - $this->expectKey( + /** @var string $status */ + $status = $this->expectKey( $response, 'check.py', 'status', @@ -368,7 +369,7 @@ protected function pollTask(string $taskArn): TaskSnapshot 'a valid task status value' ); - return TaskSnapshot::fromResponse($response); + return TaskSnapshot::fromResponse($response, TaskStatus::from($status)); } public function generateEntropy(int $bits): string diff --git a/src/Tasks/TaskSnapshot.php b/src/Tasks/TaskSnapshot.php index 422feb1..ef7322e 100644 --- a/src/Tasks/TaskSnapshot.php +++ b/src/Tasks/TaskSnapshot.php @@ -20,13 +20,23 @@ public function __construct( /** * Build a snapshot from a decoded check-script response. * + * Pass $status when the caller has already validated the response's + * `status` key (AbstractQuantumDriver::pollTask() does), so the value the + * guard checked is the one the snapshot carries; otherwise the key is + * parsed here. + * * @param array $response */ - public static function fromResponse(array $response): self + public static function fromResponse(array $response, ?TaskStatus $status = null): self { + $counts = $response['counts'] ?? null; + + /** @var array|null $counts */ + $counts = is_array($counts) ? $counts : null; + return new self( - TaskStatus::from((string) ($response['status'] ?? '')), - isset($response['counts']) && is_array($response['counts']) ? $response['counts'] : null, + $status ?? TaskStatus::from((string) ($response['status'] ?? '')), + $counts, ); } } diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 3fdf948..1076ce5 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -349,6 +349,19 @@ protected function driverName(): string 'integer' => [['bits' => 12345], 'bits'], ]); +it('names the offending batch item when it is not an object', function () { + $this->bridge->method('execute')->willReturn(['results' => [['counts' => ['0' => 1]], 'ok']]); + + $circuit = $this->createMock(CircuitBuilder::class); + $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1]); + $circuit->method('qubitCount')->willReturn(1); + + expect(fn () => $this->driver->executeBatch([$circuit, $circuit]))->toThrow( + QuantumExecutionException::class, + 'expected result #1 to be an object, got string.' + ); +}); + it('throws when a batch.py result lacks a counts array', function () { $this->bridge->method('execute')->willReturn(['results' => [['status' => 'ok']]]); @@ -356,7 +369,7 @@ protected function driverName(): string $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1000]); $this->driver->executeBatch([$circuit]); -})->throws(QuantumExecutionException::class, 'expected each result to have a "counts" key holding an array'); +})->throws(QuantumExecutionException::class, 'expected result #0 to have a "counts" key holding an array'); it('throws when batch.py results count does not match circuits count', function () { $this->bridge->method('execute')->willReturn(['results' => [['counts' => ['0' => 500]]]]); diff --git a/tests/Unit/Tasks/TaskSnapshotTest.php b/tests/Unit/Tasks/TaskSnapshotTest.php index bf6b7aa..bd45a33 100644 --- a/tests/Unit/Tasks/TaskSnapshotTest.php +++ b/tests/Unit/Tasks/TaskSnapshotTest.php @@ -31,3 +31,10 @@ expect($snapshot->status)->toBe(TaskStatus::Queued) ->and($snapshot->counts)->toBeNull(); }); + +it('carries a pre-validated status instead of re-parsing the response', function () { + $snapshot = TaskSnapshot::fromResponse(['status' => 'bogus', 'counts' => ['0' => 10]], TaskStatus::Completed); + + expect($snapshot->status)->toBe(TaskStatus::Completed) + ->and($snapshot->counts)->toBe(['0' => 10]); +}); From 58a37955cf4386b750eacb2feb83e92e846b3be3 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 19:10:10 +0000 Subject: [PATCH 3/4] refactor(drivers): name each Python script once per method Every script name appeared two to four times in AbstractQuantumDriver: at the bridge call and again in each malformed-response message that could follow it. Each method now holds the name in one local, hands it to the new callScript() helper (execute() plus the payload envelope) and reuses the same variable for expectKey() and malformedResponse(), so a message can never name a script other than the one that ran. Closes #52 --- src/Drivers/AbstractQuantumDriver.php | 54 ++++++++++++++++++--------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index 10b8fc3..313cbc2 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -156,6 +156,23 @@ private function assertWithinQubitCeiling(CircuitBuilder $circuit): void } } + /** + * Run one bin/python script with $data wrapped in the standard envelope + * and return its decoded response. + * + * The single place a script name meets the bridge: callers hold the name + * in a local once, pass it here, and reuse the same variable for the + * expectKey() / malformedResponse() calls that follow, so the name in an + * error message can never drift from the script that actually ran. + * + * @param array $data + * @return array + */ + private function callScript(string $script, array $data): array + { + return $this->bridge->execute($script, $this->payload($data), $this->config); + } + /** * Return $response[$key] once it is present and $isValid accepts it, or * throw the malformed-response exception every script shares. @@ -216,18 +233,17 @@ public function executeBatch(array $circuits): BatchResult $this->preflight(); $this->validateCircuits(array_values($circuits)); - $payload = $this->payload([ + $script = 'batch.py'; + $response = $this->callScript($script, [ 'circuits' => array_map(static fn (CircuitBuilder $c): array => $c->toArray(), $circuits), ]); - $response = $this->bridge->execute('batch.py', $payload, $this->config); - /** @var array $results */ - $results = $this->expectKey($response, 'batch.py', 'results', is_array(...), 'an array'); + $results = $this->expectKey($response, $script, 'results', is_array(...), 'an array'); if (count($results) !== count($circuits)) { throw QuantumExecutionException::malformedResponse( - 'batch.py', + $script, 'expected exactly '.count($circuits).' results, got '.count($results).'.' ); } @@ -236,13 +252,13 @@ public function executeBatch(array $circuits): BatchResult foreach (array_values($results) as $index => $result) { if (! is_array($result)) { throw QuantumExecutionException::malformedResponse( - 'batch.py', + $script, "expected result #{$index} to be an object, got ".get_debug_type($result).'.' ); } /** @var array $counts */ - $counts = $this->expectKey($result, 'batch.py', 'counts', is_array(...), 'an array', "result #{$index}"); + $counts = $this->expectKey($result, $script, 'counts', is_array(...), 'an array', "result #{$index}"); $circuitResults[] = new CircuitResult($counts); } @@ -305,10 +321,11 @@ protected function runCircuit(CircuitBuilder $circuit): CircuitResult */ private function runDefinition(array $definition): CircuitResult { - $response = $this->bridge->execute('circuit.py', $this->payload($definition), $this->config); + $script = 'circuit.py'; + $response = $this->callScript($script, $definition); /** @var array $counts */ - $counts = $this->expectKey($response, 'circuit.py', 'counts', is_array(...), 'an array'); + $counts = $this->expectKey($response, $script, 'counts', is_array(...), 'an array'); return new CircuitResult($counts); } @@ -331,12 +348,13 @@ protected function submitTask(CircuitBuilder $circuit): string $this->assertConfigured(); $this->validateCircuits([$circuit]); - $response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray()), $this->config); + $script = 'submit.py'; + $response = $this->callScript($script, $circuit->toArray()); /** @var string $taskArn */ $taskArn = $this->expectKey( $response, - 'submit.py', + $script, 'task_arn', static fn (mixed $value): bool => is_string($value) && trim($value) !== '', 'a non-empty string' @@ -358,12 +376,13 @@ protected function pollTask(string $taskArn): TaskSnapshot { $this->assertConfigured(); - $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn]), $this->config); + $script = 'check.py'; + $response = $this->callScript($script, ['task_arn' => $taskArn]); /** @var string $status */ $status = $this->expectKey( $response, - 'check.py', + $script, 'status', static fn (mixed $value): bool => is_string($value) && TaskStatus::tryFrom($value) !== null, 'a valid task status value' @@ -387,19 +406,18 @@ public function generateEntropy(int $bits): string $shots = (int) ceil($bits / $qubits); - $payload = $this->payload([ + $script = 'entropy.py'; + $response = $this->callScript($script, [ 'qubits' => $qubits, 'shots' => $shots, ]); - $response = $this->bridge->execute('entropy.py', $payload, $this->config); - /** @var string $bitstring */ - $bitstring = $this->expectKey($response, 'entropy.py', 'bits', is_string(...), 'a string'); + $bitstring = $this->expectKey($response, $script, 'bits', is_string(...), 'a string'); if (strlen($bitstring) < $bits) { throw QuantumExecutionException::malformedResponse( - 'entropy.py', + $script, "expected at least {$bits} bits in the response, got ".strlen($bitstring).'.' ); } From a4ebf7fff963f8b39a121cbdbf020600ebb1d02f Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 19:14:45 +0000 Subject: [PATCH 4/4] fix(drivers): send batch circuits as a list, expose callScript() to subclasses, fix a docblock example executeBatch() now array_values() the circuit payload, matching the two other places in the method that already normalise keys, so a driver called with an associative array no longer hands batch.py a JSON object. callScript() is protected so a custom driver's own script gets the same envelope, and expectKey()'s subject example matches the real call site. --- src/Drivers/AbstractQuantumDriver.php | 10 +++++++--- .../Unit/Drivers/AbstractQuantumDriverTest.php | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index 313cbc2..7bd96a3 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -164,11 +164,13 @@ private function assertWithinQubitCeiling(CircuitBuilder $circuit): void * in a local once, pass it here, and reuse the same variable for the * expectKey() / malformedResponse() calls that follow, so the name in an * error message can never drift from the script that actually ran. + * Protected so a custom driver with a script of its own gets the same + * envelope and config passing without re-implementing this line. * * @param array $data * @return array */ - private function callScript(string $script, array $data): array + protected function callScript(string $script, array $data): array { return $this->bridge->execute($script, $this->payload($data), $this->config); } @@ -181,7 +183,7 @@ private function callScript(string $script, array $data): array * decoded Python output, so each call site states only what differs: the * script, the key, the predicate and how to describe the expected value. * $subject names what the key belongs to when it is not the response - * itself (e.g. "each result" for the items of a batch). + * itself (e.g. "result #3" for an item of a batch). * * @param array $response * @param \Closure(mixed): bool $isValid @@ -235,7 +237,9 @@ public function executeBatch(array $circuits): BatchResult $script = 'batch.py'; $response = $this->callScript($script, [ - 'circuits' => array_map(static fn (CircuitBuilder $c): array => $c->toArray(), $circuits), + // A list, whatever keys the caller used: an associative array would + // JSON-encode as an object that batch.py cannot iterate. + 'circuits' => array_values(array_map(static fn (CircuitBuilder $c): array => $c->toArray(), $circuits)), ]); /** @var array $results */ diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1076ce5..88c39e2 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -349,6 +349,23 @@ protected function driverName(): string 'integer' => [['bits' => 12345], 'bits'], ]); +it('sends the batch circuits to Python as a list whatever keys the caller used', function () { + $this->bridge->expects($this->once()) + ->method('execute') + ->with( + 'batch.py', + $this->callback(fn (array $p) => array_is_list($p['circuits']) && count($p['circuits']) === 2), + ['key' => 'value'] + ) + ->willReturn(['results' => [['counts' => ['0' => 1]], ['counts' => ['0' => 1]]]]); + + $circuit = $this->createMock(CircuitBuilder::class); + $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1]); + $circuit->method('qubitCount')->willReturn(1); + + $this->driver->executeBatch(['first' => $circuit, 'second' => $circuit]); +}); + it('names the offending batch item when it is not an object', function () { $this->bridge->method('execute')->willReturn(['results' => [['counts' => ['0' => 1]], 'ok']]);