Skip to content
Closed
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
114 changes: 69 additions & 45 deletions src/Drivers/AbstractQuantumDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed> $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.
Expand Down Expand Up @@ -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<mixed> $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'])) {
foreach (array_values($results) as $index => $result) {
if (! is_array($result)) {
throw QuantumExecutionException::malformedResponse(
'batch.py',
'expected each result to have a "counts" array.'
"expected result #{$index} to be an object, got ".get_debug_type($result).'.'
);
}

$circuitResults[] = new CircuitResult($result['counts']);
/** @var array<mixed> $counts */
$counts = $this->expectKey($result, 'batch.py', 'counts', is_array(...), 'an array', "result #{$index}");

$circuitResults[] = new CircuitResult($counts);
}

// Announced only once the whole response has been validated, so a
Expand Down Expand Up @@ -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<mixed> $counts */
$counts = $this->expectKey($response, 'circuit.py', 'counts', is_array(...), 'an array');

return new CircuitResult($response['counts']);
return new CircuitResult($counts);
}

/**
Expand All @@ -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;
}
Expand All @@ -332,16 +360,16 @@ protected function pollTask(string $taskArn): TaskSnapshot

$response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn]), $this->config);

$status = $response['status'] ?? null;
/** @var string $status */
$status = $this->expectKey(
$response,
'check.py',
'status',
static fn (mixed $value): bool => is_string($value) && TaskStatus::tryFrom($value) !== null,
'a valid task status value'
);

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.'
);
}

return TaskSnapshot::fromResponse($response);
return TaskSnapshot::fromResponse($response, TaskStatus::from($status));
}

public function generateEntropy(int $bits): string
Expand All @@ -366,21 +394,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));

Expand Down
16 changes: 13 additions & 3 deletions src/Tasks/TaskSnapshot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed> $response
*/
public static function fromResponse(array $response): self
public static function fromResponse(array $response, ?TaskStatus $status = null): self
{
$counts = $response['counts'] ?? null;

/** @var array<string, int>|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,
);
}
}
41 changes: 40 additions & 1 deletion tests/Unit/Drivers/AbstractQuantumDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -323,14 +323,53 @@ 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('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']]]);

$circuit = $this->createMock(CircuitBuilder::class);
$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]]]]);
Expand Down
7 changes: 7 additions & 0 deletions tests/Unit/Tasks/TaskSnapshotTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});