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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ The local simulator keeps a full statevector in memory, and that memory doubles
],
```

A circuit that requests more qubits than the ceiling throws an `InvalidCircuitException` before any Python subprocess is spawned. Raise `AETHER_MAX_QUBITS` if your host has memory to spare, or set it to `null` (or leave `AETHER_MAX_QUBITS=` empty) to remove the ceiling entirely. The `aws` driver has no ceiling by default, but a `max_qubits` you configure for it is enforced on `->run()`, `->dispatch()` and `Quantum::batch()` alike.
A circuit that requests more qubits than the ceiling throws an `InvalidCircuitException` before any Python subprocess is spawned. Raise `AETHER_MAX_QUBITS` if your host has memory to spare, or set it to `null` (or leave `AETHER_MAX_QUBITS=` empty) to remove the ceiling entirely. Any other value, whether a typo in `.env`, `0`, a negative number or a decimal, throws an `InvalidDriverConfigException` instead of silently becoming a ceiling of zero. The `aws` driver has no ceiling by default, but a `max_qubits` you configure for it is enforced on `->run()`, `->dispatch()` and `Quantum::batch()` alike.

## Cost Estimation

Expand Down Expand Up @@ -515,7 +515,7 @@ Set `AETHER_AWS_MAX_COST` (or `max_cost_per_run` in config) to reject a circuit
],
```

The guard runs on `->run()`, `->dispatch()`, and `Quantum::batch()` (against the batch's total estimated cost — it bounds what one call can spend). It throws an `InvalidCircuitException`. `null` (the default) or an empty `AETHER_AWS_MAX_COST=` means unlimited — existing configs keep working unchanged. A ceiling configured without `pricing` rates throws an `InvalidDriverConfigException` instead of silently never tripping.
The guard runs on `->run()`, `->dispatch()`, and `Quantum::batch()` (against the batch's total estimated cost — it bounds what one call can spend). It throws an `InvalidCircuitException`. `null` (the default) or an empty `AETHER_AWS_MAX_COST=` means unlimited — existing configs keep working unchanged; a non-numeric or negative value throws an `InvalidDriverConfigException` instead of silently becoming a ceiling of zero. A ceiling configured without `pricing` rates throws an `InvalidDriverConfigException` instead of silently never tripping.

## License

Expand Down
2 changes: 2 additions & 0 deletions config/aether.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
// caps that at 2^25 x 16 bytes ~= 512 MB. Raise it only once
// you've confirmed the host has memory to spare, or set it to
// null to remove the ceiling entirely.
// A positive integer, or null/blank for no ceiling; anything else throws InvalidDriverConfigException.
'max_qubits' => env('AETHER_MAX_QUBITS', 25),
],

Expand Down Expand Up @@ -156,6 +157,7 @@
// total of all its circuits — before any AWS call is made. null
// (default) means unlimited. Requires the pricing rates above:
// a ceiling with no rates fails fast instead of never tripping.
// A non-negative number, or null/blank for no ceiling; anything else throws InvalidDriverConfigException.
'max_cost_per_run' => env('AETHER_AWS_MAX_COST'),
],

Expand Down
81 changes: 64 additions & 17 deletions src/Drivers/AbstractQuantumDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,18 @@ protected function beforeExecution(): void {}
* @param list<CircuitBuilder> $circuits
*
* @throws InvalidCircuitException
* @throws InvalidDriverConfigException When a ceiling option has an invalid value.
*/
protected function validateCircuits(array $circuits): void
{
$ceiling = $this->positiveIntegerConfig('max_qubits');

if ($ceiling === null) {
return;
}

foreach ($circuits as $circuit) {
$this->assertWithinQubitCeiling($circuit);
$this->assertWithinQubitCeiling($circuit, $ceiling);
}
}

Expand Down Expand Up @@ -129,31 +136,71 @@ protected function assertConfigured(): void
}

/**
* Guard against a circuit that requests more qubits than the driver's
* configured `max_qubits` ceiling allows.
*
* Statevector simulation memory doubles with every additional qubit, so
* an unbounded circuit can exhaust host memory well before it would ever
* reach a remote device's own limits. A blank `max_qubits` (absent, null,
* or an empty string — what env() yields for `AETHER_MAX_QUBITS=`) means
* unlimited, the default for every driver, so existing configs keep
* working unchanged.
* Reject a circuit that asks for more qubits than the configured ceiling.
*
* @throws InvalidCircuitException
*/
private function assertWithinQubitCeiling(CircuitBuilder $circuit): void
private function assertWithinQubitCeiling(CircuitBuilder $circuit, int $ceiling): void
{
$ceiling = $this->config['max_qubits'] ?? null;
$requested = $circuit->qubitCount();

if (blank($ceiling)) {
return;
if ($requested > $ceiling) {
throw InvalidCircuitException::qubitCeilingExceeded($requested, $ceiling, $this->driverName());
}
}

$requested = $circuit->qubitCount();
/**
* Read an optional positive-integer option, or null when it is unset or blank.
*
* @throws InvalidDriverConfigException When the value is neither blank nor a positive integer.
*/
protected function positiveIntegerConfig(string $key): ?int
{
$value = $this->filteredConfig($key, FILTER_VALIDATE_INT, ['min_range' => 1], 'a positive integer or null');

if ($requested > (int) $ceiling) {
throw InvalidCircuitException::qubitCeilingExceeded($requested, (int) $ceiling, $this->driverName());
return $value === null ? null : (int) $value;
}

/**
* Read an optional non-negative number option, or null when it is unset or blank.
*
* @throws InvalidDriverConfigException When the value is neither blank nor a non-negative number.
*/
protected function nonNegativeNumberConfig(string $key): ?float
{
$value = $this->filteredConfig($key, FILTER_VALIDATE_FLOAT, ['min_range' => 0], 'a non-negative number or null');

return $value === null ? null : (float) $value;
}

/**
* Run a numeric config option through filter_var, treating blank as unset.
*
* env() hands config a raw string, and (int) "abc" is 0 in PHP, so a typo
* would otherwise become a ceiling of zero that rejects every circuit.
* Booleans are refused explicitly because filter_var accepts true as 1.
*
* @param array<string, int|float> $options
*
* @throws InvalidDriverConfigException When the value is neither blank nor accepted by the filter.
*/
private function filteredConfig(string $key, int $filter, array $options, string $expected): int|float|null
{
$value = $this->config[$key] ?? null;

if (blank($value)) {
return null;
}

$filtered = is_scalar($value) && ! is_bool($value)
? filter_var($value, $filter, ['options' => $options])
: false;

if ($filtered === false) {
throw InvalidDriverConfigException::invalidValue($this->driverName(), $key, $value, $expected);
}

return $filtered;
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/Drivers/AwsBraketDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,9 @@ public function estimateCost(int $shots, int $tasks = 1): CostEstimate
*/
private function assertWithinCostCeiling(array $circuits): void
{
$ceiling = $this->config['max_cost_per_run'] ?? null;
$ceiling = $this->nonNegativeNumberConfig('max_cost_per_run');

if (blank($ceiling)) {
if ($ceiling === null) {
return;
}

Expand All @@ -135,8 +135,8 @@ private function assertWithinCostCeiling(array $circuits): void

$estimate = $this->estimateCost($shots, count($circuits));

if ($estimate->amount > (float) $ceiling) {
throw InvalidCircuitException::costCeilingExceeded($estimate, (float) $ceiling);
if ($estimate->amount > $ceiling) {
throw InvalidCircuitException::costCeilingExceeded($estimate, $ceiling);
}
}
}
12 changes: 12 additions & 0 deletions src/Exceptions/InvalidDriverConfigException.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,16 @@ public static function missingKeys(string $driver, array $missingKeys): self
"Driver [{$driver}] is missing required configuration: {$keys}. Set these in config/aether.php under drivers.{$driver}."
);
}

/**
* Create an exception for a configuration value of the wrong shape.
*/
public static function invalidValue(string $driver, string $key, mixed $value, string $expected): self
{
$given = is_scalar($value) ? var_export($value, true) : get_debug_type($value);

return new self(
"Driver [{$driver}] has an invalid value for [{$key}]: expected {$expected}, got {$given}. Set it in config/aether.php under drivers.{$driver}."
);
}
}
42 changes: 42 additions & 0 deletions tests/Unit/Drivers/AbstractQuantumDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,48 @@ protected function driverName(): string
expect($result)->toBeInstanceOf(CircuitResult::class);
});

it('rejects a max_qubits value that is not a positive integer', function (mixed $value) {
$driver = new class($this->bridge, ['max_qubits' => $value]) extends AbstractQuantumDriver
{
protected function driverName(): string
{
return 'test';
}
};

$circuit = $this->createMock(CircuitBuilder::class);
$circuit->method('qubitCount')->willReturn(1);
$circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 10]);

$this->bridge->expects($this->never())->method('execute');

expect(fn () => $driver->executeCircuit($circuit))
->toThrow(InvalidDriverConfigException::class, '[max_qubits]');
})->with([
'typo' => ['abc'],
'decimal' => ['2.5'],
'zero' => ['0'],
'negative' => [-3],
'boolean' => [true],
]);

it('accepts a numeric string max_qubits as the ceiling', function () {
$driver = new class($this->bridge, ['max_qubits' => '4']) extends AbstractQuantumDriver
{
protected function driverName(): string
{
return 'test';
}
};

$circuit = $this->createMock(CircuitBuilder::class);
$circuit->method('qubitCount')->willReturn(5);
$circuit->method('toArray')->willReturn(['qubits' => 5, 'gates' => [], 'shots' => 10]);

expect(fn () => $driver->executeCircuit($circuit))
->toThrow(InvalidCircuitException::class, 'ceiling of 4');
});

it('throws InvalidCircuitException on executeBatch when any circuit exceeds max_qubits', function () {
$driver = new class($this->bridge, ['max_qubits' => 5]) extends AbstractQuantumDriver
{
Expand Down
30 changes: 30 additions & 0 deletions tests/Unit/Drivers/AwsBraketDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,36 @@
expect($result)->toBeInstanceOf(CircuitResult::class);
});

it('rejects a max_cost_per_run value that is not a non-negative number', function (mixed $value) {
$driver = new AwsBraketDriver($this->bridge, array_merge($this->config, ['max_cost_per_run' => $value]));

$circuit = $this->createMock(CircuitBuilder::class);
$circuit->method('shotCount')->willReturn(10);
$circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 10]);

$this->bridge->expects($this->never())->method('execute');

expect(fn () => $driver->executeCircuit($circuit))
->toThrow(InvalidDriverConfigException::class, '[max_cost_per_run]');
})->with([
'typo' => ['abc'],
'negative' => ['-1'],
'boolean' => [true],
]);

it('accepts a numeric string max_cost_per_run as the ceiling', function () {
$driver = new AwsBraketDriver($this->bridge, array_merge($this->config, [
'pricing' => ['per_task' => 1.0, 'per_shot' => 0.0],
'max_cost_per_run' => '0.50',
]));

$circuit = $this->createMock(CircuitBuilder::class);
$circuit->method('shotCount')->willReturn(10);
$circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 10]);

expect(fn () => $driver->executeCircuit($circuit))->toThrow(InvalidCircuitException::class);
});

it('throws InvalidCircuitException on executeCircuit when the estimated cost exceeds max_cost_per_run', function () {
$config = array_merge($this->config, ['max_cost_per_run' => 0.5]);
$driver = new AwsBraketDriver($this->bridge, $config);
Expand Down