diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..7bd96a3 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -156,6 +156,58 @@ 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. + * 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 + */ + protected 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. + * + * 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. "result #3" for an item 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. @@ -183,36 +235,36 @@ public function executeBatch(array $circuits): BatchResult $this->preflight(); $this->validateCircuits(array_values($circuits)); - $payload = $this->payload([ - 'circuits' => array_map(static fn (CircuitBuilder $c): array => $c->toArray(), $circuits), + $script = 'batch.py'; + $response = $this->callScript($script, [ + // 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)), ]); - $response = $this->bridge->execute('batch.py', $payload, $this->config); + /** @var array $results */ + $results = $this->expectKey($response, $script, 'results', is_array(...), 'an array'); - if (! array_key_exists('results', $response) || ! is_array($response['results'])) { + if (count($results) !== count($circuits)) { throw QuantumExecutionException::malformedResponse( - 'batch.py', - 'expected the "results" key to be present and hold an array.' - ); - } - - if (count($response['results']) !== count($circuits)) { - throw QuantumExecutionException::malformedResponse( - 'batch.py', - 'expected exactly '.count($circuits).' results, got '.count($response['results']).'.' + $script, + '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'])) { + foreach (array_values($results) as $index => $result) { + if (! is_array($result)) { throw QuantumExecutionException::malformedResponse( - 'batch.py', - 'expected each result to have a "counts" array.' + $script, + "expected result #{$index} to be an object, got ".get_debug_type($result).'.' ); } - $circuitResults[] = new CircuitResult($result['counts']); + /** @var array $counts */ + $counts = $this->expectKey($result, $script, 'counts', is_array(...), 'an array', "result #{$index}"); + + $circuitResults[] = new CircuitResult($counts); } // Announced only once the whole response has been validated, so a @@ -273,16 +325,13 @@ 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); - 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, $script, 'counts', is_array(...), 'an array'); - return new CircuitResult($response['counts']); + return new CircuitResult($counts); } /** @@ -303,16 +352,17 @@ 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()); - $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, + $script, + 'task_arn', + static fn (mixed $value): bool => is_string($value) && trim($value) !== '', + 'a non-empty string' + ); return $taskArn; } @@ -330,18 +380,19 @@ protected function pollTask(string $taskArn): TaskSnapshot { $this->assertConfigured(); - $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn]), $this->config); - - $status = $response['status'] ?? null; + $script = 'check.py'; + $response = $this->callScript($script, ['task_arn' => $taskArn]); - 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.' - ); - } + /** @var string $status */ + $status = $this->expectKey( + $response, + $script, + 'status', + static fn (mixed $value): bool => is_string($value) && TaskStatus::tryFrom($value) !== null, + 'a valid task status value' + ); - return TaskSnapshot::fromResponse($response); + return TaskSnapshot::fromResponse($response, TaskStatus::from($status)); } public function generateEntropy(int $bits): string @@ -359,28 +410,23 @@ 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); - - 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, $script, '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']).'.' + $script, + "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/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 1c79b88..88c39e2 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -323,6 +323,62 @@ 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('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']]); + + $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']]]); @@ -330,7 +386,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 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]); +});