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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Quantum (Facade)
- **Tests use Pest PHP**, not raw PHPUnit classes. Use `it()` / `test()` with `expect()`.
- **Python scripts** live in `bin/python/`, not `resources/`. Each script is self-contained (reads JSON stdin, writes JSON stdout).
- **Exceptions** all extend `AetherException` with static factory methods (`::fromPythonError()`, `::forDriver()`, etc.)
- **PythonBridge** only passes non-null env vars to preserve boto3 credential chain (IAM Roles).
- **PythonBridge** adds no env vars of its own: the child gets Symfony Process's default inherited environment (boto3 credential chain, IAM Roles included, works as-is). Driver settings reach the scripts only through `driver_config` in the JSON payload.
- **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.

Expand Down
41 changes: 8 additions & 33 deletions src/Bridge/PythonBridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,23 @@ public function __construct(
/**
* Execute a Python script with the given payload.
*
* The bridge adds no environment variables of its own: the child receives
* Symfony Process's default inherited environment, so boto3's credential
* chain (IAM roles, profiles, AWS_* variables) behaves exactly as it does
* for the PHP process. Driver settings travel inside the JSON payload under
* `driver_config`, which is the only channel the bin/python scripts read.
*
* @param array<mixed> $payload
* @param array<string, mixed> $driverConfig
* @return array<mixed>
*
* @throws PythonEnvironmentException
* @throws QuantumExecutionException
*/
public function execute(string $script, array $payload, array $driverConfig = []): array
public function execute(string $script, array $payload): array
{
$scriptPath = $this->scriptsPath.DIRECTORY_SEPARATOR.$script;

$process = new Process(
command: [$this->pythonPath, $scriptPath],
env: $this->buildEnvironment($driverConfig),
);
$process = new Process([$this->pythonPath, $scriptPath]);

$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
$process->setTimeout($this->timeout);
Expand Down Expand Up @@ -148,31 +150,4 @@ public function bitstringToBytes(string $bitstring): string

return $bytes;
}

/**
* Build the environment variable array for the child process.
*
* Only non-null values are included to preserve boto3's credential chain.
*
* @param array<string, mixed> $driverConfig
* @return array<string, string>
*/
public function buildEnvironment(array $driverConfig): array
{
$map = [
'region' => 'AWS_DEFAULT_REGION',
'bucket' => 'AETHER_S3_BUCKET',
'device_arn' => 'AETHER_DEVICE_ARN',
];

$env = [];

foreach ($map as $configKey => $envKey) {
if (isset($driverConfig[$configKey])) {
$env[$envKey] = (string) $driverConfig[$configKey];
}
}

return $env;
}
}
6 changes: 4 additions & 2 deletions src/Contracts/PythonExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ interface PythonExecutor
/**
* Execute a Python script with the given payload.
*
* Driver settings are part of the payload (`driver_config`); implementations
* must not need a second channel for them.
*
* @param array<mixed> $payload
* @param array<string, mixed> $driverConfig
* @return array<mixed>
*/
public function execute(string $script, array $payload, array $driverConfig = []): array;
public function execute(string $script, array $payload): array;

/**
* Convert a binary digit string into raw bytes.
Expand Down
10 changes: 5 additions & 5 deletions src/Drivers/AbstractQuantumDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ public function executeBatch(array $circuits): BatchResult
'circuits' => array_map(static fn (CircuitBuilder $c): array => $c->toArray(), $circuits),
]);

$response = $this->bridge->execute('batch.py', $payload, $this->config);
$response = $this->bridge->execute('batch.py', $payload);

if (! array_key_exists('results', $response) || ! is_array($response['results'])) {
throw QuantumExecutionException::malformedResponse(
Expand Down Expand Up @@ -273,7 +273,7 @@ protected function runCircuit(CircuitBuilder $circuit): CircuitResult
*/
private function runDefinition(array $definition): CircuitResult
{
$response = $this->bridge->execute('circuit.py', $this->payload($definition), $this->config);
$response = $this->bridge->execute('circuit.py', $this->payload($definition));

if (! array_key_exists('counts', $response) || ! is_array($response['counts'])) {
throw QuantumExecutionException::malformedResponse(
Expand Down Expand Up @@ -303,7 +303,7 @@ protected function submitTask(CircuitBuilder $circuit): string
$this->assertConfigured();
$this->validateCircuits([$circuit]);

$response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray()), $this->config);
$response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray()));

$taskArn = $response['task_arn'] ?? null;

Expand All @@ -330,7 +330,7 @@ protected function pollTask(string $taskArn): TaskSnapshot
{
$this->assertConfigured();

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

$status = $response['status'] ?? null;

Expand Down Expand Up @@ -364,7 +364,7 @@ public function generateEntropy(int $bits): string
'shots' => $shots,
]);

$response = $this->bridge->execute('entropy.py', $payload, $this->config);
$response = $this->bridge->execute('entropy.py', $payload);

if (! array_key_exists('bits', $response) || ! is_string($response['bits'])) {
throw QuantumExecutionException::malformedResponse(
Expand Down
74 changes: 14 additions & 60 deletions tests/Unit/Bridge/PythonBridgeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,71 +188,25 @@ function fakePython(string $shBody): string
});

// -------------------------------------------------------------------------
// buildEnvironment()
// execute() — environment
// -------------------------------------------------------------------------

it('includes only non-null values in environment', function () {
$bridge = new PythonBridge('python3');

$env = $bridge->buildEnvironment([
'region' => 'us-east-1',
'bucket' => null,
'device_arn' => 'arn:aws:braket:::device/quantum-simulator/amazon/sv1',
]);

expect($env)->toHaveKey('AWS_DEFAULT_REGION');
expect($env['AWS_DEFAULT_REGION'])->toBe('us-east-1');
expect($env)->not->toHaveKey('AETHER_S3_BUCKET');
expect($env)->toHaveKey('AETHER_DEVICE_ARN');
expect($env['AETHER_DEVICE_ARN'])->toBe('arn:aws:braket:::device/quantum-simulator/amazon/sv1');
});

it('does not include AWS credentials in environment', function () {
$bridge = new PythonBridge('python3');

$env = $bridge->buildEnvironment([
'region' => 'eu-west-1',
'AWS_ACCESS_KEY_ID' => 'AKIAIOSFODNN7EXAMPLE',
'AWS_SECRET_ACCESS_KEY' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
]);

expect($env)->not->toHaveKey('AWS_ACCESS_KEY_ID');
expect($env)->not->toHaveKey('AWS_SECRET_ACCESS_KEY');
});

it('returns empty array when config is empty', function () {
$bridge = new PythonBridge('python3');
it('lets the child process see the parent environment', function () {
// Pins the guarantee execute()'s docblock makes: a variable set in the PHP
// process (AWS_PROFILE, AWS_*, IAM metadata hints boto3 relies on) reaches
// the interpreter. It asserts the observable behaviour, not how the
// Process is built.
$_ENV['AETHER_TEST_MARKER'] = 'inherited';

$env = $bridge->buildEnvironment([]);

expect($env)->toBe([]);
});

it('maps all supported keys in environment', function () {
$bridge = new PythonBridge('python3');

$env = $bridge->buildEnvironment([
'region' => 'ap-southeast-2',
'bucket' => 'my-braket-bucket',
'device_arn' => 'arn:aws:braket:::device/qpu/ionq/ionQdevice',
]);

expect($env)->toBe([
'AWS_DEFAULT_REGION' => 'ap-southeast-2',
'AETHER_S3_BUCKET' => 'my-braket-bucket',
'AETHER_DEVICE_ARN' => 'arn:aws:braket:::device/qpu/ionq/ionQdevice',
]);
});

it('omits unset keys from environment', function () {
$bridge = new PythonBridge('python3');
try {
$python = fakePython('printf \'{"marker":"%s"}\' "$AETHER_TEST_MARKER"');

// Only region provided — bucket and device_arn not present at all.
$env = $bridge->buildEnvironment(['region' => 'us-west-2']);
$result = (new PythonBridge($python))->execute('circuit.py', ['qubits' => 1]);
} finally {
unset($_ENV['AETHER_TEST_MARKER']);
}

expect($env)->toBe(['AWS_DEFAULT_REGION' => 'us-west-2']);
expect($env)->not->toHaveKey('AETHER_S3_BUCKET');
expect($env)->not->toHaveKey('AETHER_DEVICE_ARN');
expect($result)->toBe(['marker' => 'inherited']);
});

// -------------------------------------------------------------------------
Expand Down
21 changes: 7 additions & 14 deletions tests/Unit/Drivers/AbstractQuantumDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ protected function driverName(): string
->method('execute')
->with(
'circuit.py',
$this->callback(fn (array $p) => $p['driver'] === 'test' && $p['driver_config'] === ['key' => 'value']),
['key' => 'value']
$this->callback(fn (array $p) => $p['driver'] === 'test' && $p['driver_config'] === ['key' => 'value'])
)
->willReturn(['counts' => ['0' => 500, '1' => 500]]);

Expand Down Expand Up @@ -74,8 +73,7 @@ protected function driverName(): string
&& $p['qubits'] === 16
&& $p['shots'] === 1
&& ! array_key_exists('bits', $p);
}),
['key' => 'value', 'entropy_qubits' => 16]
})
)
->willReturn(['bits' => str_repeat('1', 16)]);

Expand All @@ -96,8 +94,7 @@ protected function driverName(): string
->method('execute')
->with(
'entropy.py',
$this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 16),
['key' => 'value', 'entropy_qubits' => 16]
$this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 16)
)
->willReturn(['bits' => str_repeat('10', 128)]);

Expand All @@ -110,8 +107,7 @@ protected function driverName(): string
->method('execute')
->with(
'entropy.py',
$this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1),
['key' => 'value']
$this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1)
)
->willReturn(['bits' => str_repeat('1', 16)]);

Expand Down Expand Up @@ -250,8 +246,7 @@ protected function driverName(): string
->method('execute')
->with(
'entropy.py',
$this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1),
['entropy_qubits' => 0]
$this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1)
)
->willReturn(['bits' => str_repeat('1', 16)]);

Expand All @@ -272,8 +267,7 @@ protected function driverName(): string
->method('execute')
->with(
'entropy.py',
$this->callback(fn (array $p) => $p['qubits'] === 16),
['entropy_qubits' => -4]
$this->callback(fn (array $p) => $p['qubits'] === 16)
)
->willReturn(['bits' => str_repeat('1', 16)]);

Expand All @@ -294,8 +288,7 @@ protected function driverName(): string
&& $p['circuits'] === [
['qubits' => 1, 'gates' => [], 'shots' => 1000],
['qubits' => 2, 'gates' => [], 'shots' => 10],
]),
['key' => 'value']
])
)
->willReturn(['results' => [
['counts' => ['0' => 500, '1' => 500]],
Expand Down
12 changes: 5 additions & 7 deletions tests/Unit/Drivers/AwsBraketDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@

$this->bridge->expects($this->once())
->method('execute')
->with('circuit.py', $expectedPayload, $this->config)
->with('circuit.py', $expectedPayload)
->willReturn(['counts' => ['00' => 512, '11' => 512]]);

$result = $driver->executeCircuit($circuit);
Expand Down Expand Up @@ -132,8 +132,7 @@
->method('execute')
->with(
'entropy.py',
['qubits' => 16, 'shots' => 1, 'driver' => 'aws', 'driver_config' => $this->config],
$this->config
['qubits' => 16, 'shots' => 1, 'driver' => 'aws', 'driver_config' => $this->config]
)
->willReturn(['bits' => '1011001110100101']);

Expand Down Expand Up @@ -265,7 +264,7 @@

$this->bridge->expects($this->once())
->method('execute')
->with('submit.py', $expectedPayload, $this->config)
->with('submit.py', $expectedPayload)
->willReturn(['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc']);

$taskArn = $driver->submitCircuit($circuit);
Expand All @@ -282,7 +281,7 @@

$this->bridge->expects($this->once())
->method('execute')
->with('submit.py', $this->anything(), $config)
->with('submit.py', $this->anything())
->willReturn(['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/async']);

$taskArn = $driver->submitCircuit($circuit);
Expand Down Expand Up @@ -356,8 +355,7 @@
->method('execute')
->with(
'check.py',
['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc', 'driver' => 'aws', 'driver_config' => $this->config],
$this->config
['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc', 'driver' => 'aws', 'driver_config' => $this->config]
)
->willReturn(['status' => 'RUNNING']);

Expand Down
7 changes: 3 additions & 4 deletions tests/Unit/Drivers/LocalSimulatorDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@

$this->bridge->expects($this->once())
->method('execute')
->with('circuit.py', $expectedPayload, $this->config)
->with('circuit.py', $expectedPayload)
->willReturn(['counts' => ['00' => 512, '11' => 512]]);

$result = $this->driver->executeCircuit($circuit);
Expand Down Expand Up @@ -114,8 +114,7 @@
->method('execute')
->with(
'entropy.py',
['qubits' => 16, 'shots' => 1, 'driver' => 'local', 'driver_config' => $this->config],
$this->config
['qubits' => 16, 'shots' => 1, 'driver' => 'local', 'driver_config' => $this->config]
)
->willReturn(['bits' => '1011001110100101']);

Expand Down Expand Up @@ -187,7 +186,7 @@

$this->bridge->expects($this->once())
->method('execute')
->with('circuit.py', $this->anything(), $this->config)
->with('circuit.py', $this->anything())
->willReturn(['counts' => ['0' => 100]]);

$this->driver->submitCircuit($circuit);
Expand Down