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
162 changes: 104 additions & 58 deletions src/Drivers/AbstractQuantumDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $data
* @return array<mixed>
*/
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<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 @@ -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<mixed> $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<mixed> $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
Expand Down Expand Up @@ -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<mixed> $counts */
$counts = $this->expectKey($response, $script, 'counts', is_array(...), 'an array');

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

/**
Expand All @@ -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;
}
Expand All @@ -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
Expand All @@ -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));

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,
);
}
}
58 changes: 57 additions & 1 deletion tests/Unit/Drivers/AbstractQuantumDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -323,14 +323,70 @@ 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']]]);

$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]);
});