From b04ecbbd9f25f9053cd828bc4cf55593f1a43d75 Mon Sep 17 00:00:00 2001 From: corgab Date: Sun, 6 Sep 2026 03:05:31 +0000 Subject: [PATCH 1/2] fix: reject non-numeric qubit and cost ceilings instead of treating them as zero max_qubits and max_cost_per_run come straight from env() as strings, and (int) "abc" is 0 in PHP, so a typo in .env silently became a ceiling of zero that rejected every circuit with a message blaming the circuit. The drivers now read both options through validating helpers: blank stays "no ceiling", a positive integer or a non-negative number is accepted (numeric strings included), and anything else throws InvalidDriverConfigException naming the key and the value it got. Closes #44 --- README.md | 4 +- config/aether.php | 2 + src/Drivers/AbstractQuantumDriver.php | 57 +++++++++++++++++-- src/Drivers/AwsBraketDriver.php | 8 +-- .../InvalidDriverConfigException.php | 12 ++++ .../Drivers/AbstractQuantumDriverTest.php | 42 ++++++++++++++ tests/Unit/Drivers/AwsBraketDriverTest.php | 30 ++++++++++ 7 files changed, 145 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a59f1ac..aa5ecdd 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 non-numeric value, such as a typo in `.env`, 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 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..ed2a0d1 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -143,19 +143,68 @@ protected function assertConfigured(): void */ private function assertWithinQubitCeiling(CircuitBuilder $circuit): void { - $ceiling = $this->config['max_qubits'] ?? null; + $ceiling = $this->positiveIntegerConfig('max_qubits'); - if (blank($ceiling)) { + if ($ceiling === null) { return; } $requested = $circuit->qubitCount(); - if ($requested > (int) $ceiling) { - throw InvalidCircuitException::qubitCeilingExceeded($requested, (int) $ceiling, $this->driverName()); + if ($requested > $ceiling) { + throw InvalidCircuitException::qubitCeilingExceeded($requested, $ceiling, $this->driverName()); } } + /** + * Read an optional positive-integer option, or null when it is unset or blank. + * + * 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. + * + * @throws InvalidDriverConfigException When the value is neither blank nor a positive integer. + */ + protected function positiveIntegerConfig(string $key): ?int + { + $value = $this->config[$key] ?? null; + + if (blank($value)) { + return null; + } + + $integer = is_scalar($value) && ! is_bool($value) + ? filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) + : false; + + if ($integer === false) { + throw InvalidDriverConfigException::invalidValue($this->driverName(), $key, $value, 'a positive integer or null'); + } + + return $integer; + } + + /** + * 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->config[$key] ?? null; + + if (blank($value)) { + return null; + } + + $number = is_scalar($value) && ! is_bool($value) ? filter_var($value, FILTER_VALIDATE_FLOAT) : false; + + if ($number === false || $number < 0) { + throw InvalidDriverConfigException::invalidValue($this->driverName(), $key, $value, 'a non-negative number or null'); + } + + return $number; + } + /** * Wrap script input in the envelope every bin/python script expects: the * data itself plus the driver name and config the provider layer reads. 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); From 9b88f8644021f819e4449f4717263a210929b3a5 Mon Sep 17 00:00:00 2001 From: corgab Date: Sun, 6 Sep 2026 03:10:01 +0000 Subject: [PATCH 2/2] refactor: read the ceiling options through one validating helper positiveIntegerConfig() and nonNegativeNumberConfig() now share a single filteredConfig() that handles the blank check, the boolean exclusion and the filter_var call, so the two readers differ only in filter and range. validateCircuits() resolves max_qubits once per call instead of once per circuit and documents the InvalidDriverConfigException it can raise; the README lists every rejected shape, not only non-numeric strings. --- README.md | 4 +- src/Drivers/AbstractQuantumDriver.php | 74 +++++++++++++-------------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index aa5ecdd..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. Any other non-numeric value, such as a typo in `.env`, 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. +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 non-numeric 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. +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/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index ed2a0d1..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,26 +136,12 @@ 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->positiveIntegerConfig('max_qubits'); - - if ($ceiling === null) { - return; - } - $requested = $circuit->qubitCount(); if ($requested > $ceiling) { @@ -159,28 +152,13 @@ private function assertWithinQubitCeiling(CircuitBuilder $circuit): void /** * Read an optional positive-integer option, or null when it is unset or blank. * - * 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. - * * @throws InvalidDriverConfigException When the value is neither blank nor a positive integer. */ protected function positiveIntegerConfig(string $key): ?int { - $value = $this->config[$key] ?? null; + $value = $this->filteredConfig($key, FILTER_VALIDATE_INT, ['min_range' => 1], 'a positive integer or null'); - if (blank($value)) { - return null; - } - - $integer = is_scalar($value) && ! is_bool($value) - ? filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) - : false; - - if ($integer === false) { - throw InvalidDriverConfigException::invalidValue($this->driverName(), $key, $value, 'a positive integer or null'); - } - - return $integer; + return $value === null ? null : (int) $value; } /** @@ -189,6 +167,24 @@ protected function positiveIntegerConfig(string $key): ?int * @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; @@ -196,13 +192,15 @@ protected function nonNegativeNumberConfig(string $key): ?float return null; } - $number = is_scalar($value) && ! is_bool($value) ? filter_var($value, FILTER_VALIDATE_FLOAT) : false; + $filtered = is_scalar($value) && ! is_bool($value) + ? filter_var($value, $filter, ['options' => $options]) + : false; - if ($number === false || $number < 0) { - throw InvalidDriverConfigException::invalidValue($this->driverName(), $key, $value, 'a non-negative number or null'); + if ($filtered === false) { + throw InvalidDriverConfigException::invalidValue($this->driverName(), $key, $value, $expected); } - return $number; + return $filtered; } /**