diff --git a/README.md b/README.md index a59f1ac..7926cd6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/config/aether.php b/config/aether.php index 4fff7db..a767a06 100644 --- a/config/aether.php +++ b/config/aether.php @@ -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), ], @@ -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'), ], diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..948ba7f 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -73,11 +73,18 @@ protected function beforeExecution(): void {} * @param list $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); } } @@ -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 $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; } /** diff --git a/src/Drivers/AwsBraketDriver.php b/src/Drivers/AwsBraketDriver.php index ff6ce2e..9e417c6 100644 --- a/src/Drivers/AwsBraketDriver.php +++ b/src/Drivers/AwsBraketDriver.php @@ -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; } @@ -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); } } } diff --git a/src/Exceptions/InvalidDriverConfigException.php b/src/Exceptions/InvalidDriverConfigException.php index a8372fc..12fffea 100644 --- a/src/Exceptions/InvalidDriverConfigException.php +++ b/src/Exceptions/InvalidDriverConfigException.php @@ -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}." + ); + } } diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1c79b88..ee29a77 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -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 { diff --git a/tests/Unit/Drivers/AwsBraketDriverTest.php b/tests/Unit/Drivers/AwsBraketDriverTest.php index 548904b..d2fdd84 100644 --- a/tests/Unit/Drivers/AwsBraketDriverTest.php +++ b/tests/Unit/Drivers/AwsBraketDriverTest.php @@ -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);