diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..1513fec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +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.) +- **Driver config is typed:** `AbstractQuantumDriver` builds a `Config\DriverConfig` (`AwsDriverConfig` for aws) once in its constructor; invalid values throw `InvalidDriverConfigException` there, blank means default. Read options via `$this->config->maxQubits` / `->get('key')`, never `$this->config['key']`. The raw array still goes to Python as `driver_config`. - **PythonBridge** only passes non-null env vars to preserve boto3 credential chain (IAM Roles). - **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. diff --git a/README.md b/README.md index a59f1ac..b4f5b98 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. The value must be a positive integer: anything else (`AETHER_MAX_QUBITS=abc`) throws an `InvalidDriverConfigException` as soon as the driver is resolved, rather than 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,9 @@ 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 ceiling configured without `pricing` rates throws an `InvalidDriverConfigException` instead of silently never tripping, and so does a ceiling or rate that is not a non-negative number. + +Every option the PHP layer reads (`max_qubits`, `entropy_qubits`, `synchronous_safe`, and for `aws` the `pricing` rates and `max_cost_per_run`) is validated once, when the driver is resolved, through a typed `Aether\Config\DriverConfig` value object (`AwsDriverConfig` for the `aws` driver). Custom drivers extending `AbstractQuantumDriver` read the shared options from `$this->config->maxQubits` and friends, and any key of their own through `$this->config->get('key')`; the raw array still reaches the Python provider untouched as `driver_config`. The object is built inside the base constructor, so `driverName()` must not depend on state your own constructor sets after calling `parent::__construct()`. ## License diff --git a/config/aether.php b/config/aether.php index 4fff7db..4b9a38f 100644 --- a/config/aether.php +++ b/config/aether.php @@ -116,14 +116,16 @@ 'local' => [ 'synchronous_safe' => true, - 'entropy_qubits' => (int) env('AETHER_ENTROPY_QUBITS', 16), + 'entropy_qubits' => env('AETHER_ENTROPY_QUBITS', 16), // The local simulator keeps a full statevector in memory: a dense // vector of 2^n complex128 amplitudes, 16 bytes each, so memory // use doubles with every additional qubit. The default of 25 // 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. + // null to remove the ceiling entirely. A positive integer, or + // null/blank for no ceiling; anything else throws + // InvalidDriverConfigException when the driver is resolved. 'max_qubits' => env('AETHER_MAX_QUBITS', 25), ], @@ -132,7 +134,7 @@ 'bucket' => env('AETHER_S3_BUCKET'), 'device_arn' => env('AETHER_DEVICE_ARN', 'arn:aws:braket:::device/quantum-simulator/amazon/sv1'), 'synchronous_safe' => true, - 'entropy_qubits' => (int) env('AETHER_ENTROPY_QUBITS', 16), + 'entropy_qubits' => env('AETHER_ENTROPY_QUBITS', 16), // No ceiling here: Braket enforces its own per-device qubit // limits, so this package does not duplicate or guess at those. @@ -144,10 +146,12 @@ // (e.g. SV1) bill per-minute instead, but the task+shot model // is what estimateCost() covers; treat simulator estimates as // a rough proxy, not an exact figure. Override via env/config - // without a package release. + // without a package release. The rates are strictly validated (not silently cast to 0) + // when the driver is resolved: a non-numeric or negative value + // throws InvalidDriverConfigException instead of pricing at 0. 'pricing' => [ - 'per_task' => (float) env('AETHER_AWS_PRICE_PER_TASK', 0.30), - 'per_shot' => (float) env('AETHER_AWS_PRICE_PER_SHOT', 0.00035), + 'per_task' => env('AETHER_AWS_PRICE_PER_TASK', 0.30), + 'per_shot' => env('AETHER_AWS_PRICE_PER_SHOT', 0.00035), 'currency' => env('AETHER_AWS_PRICE_CURRENCY', 'USD'), ], @@ -156,6 +160,9 @@ // 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 when the driver is + // resolved. 'max_cost_per_run' => env('AETHER_AWS_MAX_COST'), ], diff --git a/src/Config/AwsDriverConfig.php b/src/Config/AwsDriverConfig.php new file mode 100644 index 0000000..63fa01d --- /dev/null +++ b/src/Config/AwsDriverConfig.php @@ -0,0 +1,106 @@ +run(), ->dispatch() or batch, or null for none. + */ + public ?float $maxCostPerRun; + + /** + * Price per task (`pricing.per_task`), or null when not configured. + */ + public ?float $perTaskRate; + + /** + * Price per shot (`pricing.per_shot`), or null when not configured. + */ + public ?float $perShotRate; + + /** + * Currency code for estimates (`pricing.currency`). + */ + public string $currency; + + /** + * @param array $values + * + * @throws InvalidDriverConfigException When an option has a value of the wrong shape. + */ + public function __construct(string $driver, array $values) + { + parent::__construct($driver, $values); + + $this->region = $this->string('region', $this->get('region')); + $this->bucket = $this->string('bucket', $this->get('bucket')); + $this->deviceArn = $this->string('device_arn', $this->get('device_arn')); + + $this->maxCostPerRun = $this->nonNegativeNumber('max_cost_per_run', $this->get('max_cost_per_run')); + + $pricing = $this->get('pricing') ?? []; + + if (! is_array($pricing)) { + throw InvalidDriverConfigException::invalidValue($driver, 'pricing', $pricing, 'an array of rates or null'); + } + + $this->perTaskRate = $this->nonNegativeNumber('pricing.per_task', $pricing['per_task'] ?? null); + $this->perShotRate = $this->nonNegativeNumber('pricing.per_shot', $pricing['per_shot'] ?? null); + $this->currency = $this->string('pricing.currency', $pricing['currency'] ?? null) ?? self::DEFAULT_CURRENCY; + } + + /** + * The `pricing.*` keys that are blank, in the order the guard reports them. + * + * @return list + */ + public function missingRates(): array + { + $missing = []; + + if ($this->perTaskRate === null) { + $missing[] = 'pricing.per_task'; + } + + if ($this->perShotRate === null) { + $missing[] = 'pricing.per_shot'; + } + + return $missing; + } +} diff --git a/src/Config/DriverConfig.php b/src/Config/DriverConfig.php new file mode 100644 index 0000000..f5f2a2e --- /dev/null +++ b/src/Config/DriverConfig.php @@ -0,0 +1,224 @@ +run() is allowed on this driver. + */ + public bool $synchronousSafe; + + /** + * @param string $driver Driver identifier, used in exception messages. + * @param array $values The raw `aether.drivers.` array. + * + * @throws InvalidDriverConfigException When an option has a value of the wrong shape. + */ + public function __construct( + public string $driver, + private array $values, + ) { + $this->maxQubits = $this->positiveInteger('max_qubits', $this->get('max_qubits')); + + // A non-positive count would make generateEntropy() divide by zero. + // The setting is non-critical, so it falls back to the default rather + // than failing the whole request; a non-numeric value still throws. + $entropyQubits = $this->integer('entropy_qubits', $this->get('entropy_qubits')); + $this->entropyQubits = $entropyQubits === null || $entropyQubits <= 0 + ? self::DEFAULT_ENTROPY_QUBITS + : $entropyQubits; + + $this->synchronousSafe = $this->boolean('synchronous_safe', $this->get('synchronous_safe')) ?? true; + } + + /** + * Read a raw option, for keys this class does not type. + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + /** + * Whether the option is absent, null or an empty string. + */ + public function isBlank(string $key): bool + { + return self::blank($this->get($key)); + } + + /** + * Return the subset of $keys whose value is blank, preserving order. + * + * @param list $keys + * @return list + */ + public function blankKeys(array $keys): array + { + return array_values(array_filter($keys, fn (string $key): bool => $this->isBlank($key))); + } + + /** + * The raw array exactly as configured, for the `driver_config` payload key. + * + * @return array + * + * @deprecated Passing the raw array is a transitional mechanism for the Python bridge. + */ + public function toArray(): array + { + return $this->values; + } + + /** + * Cast an optional positive integer (>= 1), or null when blank. + * + * @throws InvalidDriverConfigException + */ + protected function positiveInteger(string $key, mixed $value): ?int + { + $filtered = $this->filtered($key, $value, FILTER_VALIDATE_INT, ['min_range' => 1], 'a positive integer or null'); + + return $filtered === null ? null : (int) $filtered; + } + + /** + * Cast an optional integer of any sign, or null when blank. + * + * @throws InvalidDriverConfigException + */ + protected function integer(string $key, mixed $value): ?int + { + $filtered = $this->filtered($key, $value, FILTER_VALIDATE_INT, [], 'an integer or null'); + + return $filtered === null ? null : (int) $filtered; + } + + /** + * Cast an optional non-negative number (>= 0), or null when blank. + * + * @throws InvalidDriverConfigException + */ + protected function nonNegativeNumber(string $key, mixed $value): ?float + { + $filtered = $this->filtered($key, $value, FILTER_VALIDATE_FLOAT, ['min_range' => 0], 'a non-negative number or null'); + + return $filtered === null ? null : (float) $filtered; + } + + /** + * Cast an optional boolean, or null when blank. + * + * Accepts real booleans and the string/integer spellings env() produces + * ("true", "false", "1", "0", "on", "off", "yes", "no"). + * + * @throws InvalidDriverConfigException + */ + protected function boolean(string $key, mixed $value): ?bool + { + if (self::blank($value)) { + return null; + } + + $filtered = is_scalar($value) + ? filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) + : null; + + if ($filtered === null) { + throw InvalidDriverConfigException::invalidValue($this->driver, $key, $value, 'a boolean or null'); + } + + return $filtered; + } + + /** + * Cast an optional string, or null when blank. + * + * @throws InvalidDriverConfigException + */ + protected function string(string $key, mixed $value): ?string + { + if (self::blank($value)) { + return null; + } + + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { + throw InvalidDriverConfigException::invalidValue($this->driver, $key, $value, 'a string or null'); + } + + return (string) $value; + } + + /** + * Run a numeric option through filter_var, treating blank as unset. + * + * Booleans are refused explicitly because filter_var accepts true as 1, + * and non-scalars (arrays, objects) never pass. + * + * @param array $options + * + * @throws InvalidDriverConfigException When the value is neither blank nor accepted by the filter. + */ + private function filtered(string $key, mixed $value, int $filter, array $options, string $expected): int|float|null + { + if (self::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->driver, $key, $value, $expected); + } + + return $filtered; + } + + /** + * Whether a raw value counts as "not configured". + */ + private static function blank(mixed $value): bool + { + return $value === null || (is_string($value) && trim($value) === ''); + } +} diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..c8eaa72 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -6,6 +6,7 @@ use Aether\Circuit\CircuitBuilder; use Aether\Concerns\DispatchesLifecycleEvents; +use Aether\Config\DriverConfig; use Aether\Contracts\BatchableDevice; use Aether\Contracts\PythonExecutor; use Aether\Contracts\QuantumDevice; @@ -21,24 +22,65 @@ /** * Base driver with shared circuit execution and entropy generation logic. + * + * @template TConfig of DriverConfig */ abstract class AbstractQuantumDriver implements BatchableDevice, QuantumDevice { use DispatchesLifecycleEvents; /** - * @param array $config + * Typed driver options, built once from the raw array by makeConfig(). + * + * @var TConfig + */ + protected readonly DriverConfig $config; + + /** + * @param array $config The raw `aether.drivers.` array. + * + * @throws InvalidDriverConfigException When an option has a value of the wrong shape. */ public function __construct( protected readonly PythonExecutor $bridge, - protected readonly array $config, - ) {} + array $config, + ) { + $this->config = $this->makeConfig($config); + } /** * Return the driver identifier passed to Python scripts. + * + * Called from the base constructor (through makeConfig()) before the + * subclass constructor body runs, so it must not depend on state a + * subclass sets after parent::__construct(): return a literal or a + * promoted constructor parameter. */ abstract protected function driverName(): string; + /** + * Build the typed config object for this driver. + * + * Override in drivers with options of their own (see AwsBraketDriver) to + * return a DriverConfig subclass; the base class types the shared options + * (`max_qubits`, `entropy_qubits`, `synchronous_safe`) and keeps every + * other key reachable through DriverConfig::get() and the JSON payload. + * + * Runs inside the base constructor, before the subclass constructor body, + * so it (and the driverName() it calls) can only rely on promoted + * constructor parameters, not on properties assigned afterwards. + * + * @param array $values + * @return TConfig + * + * @throws InvalidDriverConfigException + */ + protected function makeConfig(array $values): DriverConfig + { + /** @var TConfig */ + return new DriverConfig($this->driverName(), $values); + } + /** * Config keys that must be present and non-empty before the driver runs. * @@ -76,8 +118,14 @@ protected function beforeExecution(): void {} */ protected function validateCircuits(array $circuits): void { + $ceiling = $this->config->maxQubits; + + if ($ceiling === null) { + return; + } + foreach ($circuits as $circuit) { - $this->assertWithinQubitCeiling($circuit); + $this->assertWithinQubitCeiling($circuit, $ceiling); } } @@ -113,15 +161,7 @@ private function preflight(): void */ protected function assertConfigured(): void { - $missing = []; - - foreach ($this->requiredConfig() as $key) { - $value = $this->config[$key] ?? null; - - if ($value === null || (is_string($value) && trim($value) === '')) { - $missing[] = $key; - } - } + $missing = $this->config->blankKeys($this->requiredConfig()); if ($missing !== []) { throw InvalidDriverConfigException::missingKeys($this->driverName(), $missing); @@ -134,25 +174,18 @@ protected function assertConfigured(): void * * 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. + * reach a remote device's own limits. A blank `max_qubits` means + * unlimited (DriverConfig::$maxQubits is null and validateCircuits() + * never gets here), the default for every driver. * * @throws InvalidCircuitException */ - private function assertWithinQubitCeiling(CircuitBuilder $circuit): void + private function assertWithinQubitCeiling(CircuitBuilder $circuit, int $ceiling): void { - $ceiling = $this->config['max_qubits'] ?? null; - - if (blank($ceiling)) { - 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()); } } @@ -167,7 +200,7 @@ protected function payload(array $data): array { return array_merge($data, [ 'driver' => $this->driverName(), - 'driver_config' => $this->config, + 'driver_config' => $this->config->toArray(), ]); } @@ -187,7 +220,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, $this->config->toArray()); if (! array_key_exists('results', $response) || ! is_array($response['results'])) { throw QuantumExecutionException::malformedResponse( @@ -273,7 +306,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), $this->config->toArray()); if (! array_key_exists('counts', $response) || ! is_array($response['counts'])) { throw QuantumExecutionException::malformedResponse( @@ -303,7 +336,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()), $this->config->toArray()); $taskArn = $response['task_arn'] ?? null; @@ -330,7 +363,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]), $this->config->toArray()); $status = $response['status'] ?? null; @@ -348,15 +381,7 @@ public function generateEntropy(int $bits): string { $this->preflight(); - $qubits = (int) ($this->config['entropy_qubits'] ?? 16); - - // A non-positive qubit count would otherwise cause a DivisionByZeroError - // below. Config-level misconfiguration here is non-critical, so we fall - // back to the safe default instead of failing the whole request. - if ($qubits <= 0) { - $qubits = 16; - } - + $qubits = $this->config->entropyQubits; $shots = (int) ceil($bits / $qubits); $payload = $this->payload([ @@ -364,7 +389,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, $this->config->toArray()); if (! array_key_exists('bits', $response) || ! is_string($response['bits'])) { throw QuantumExecutionException::malformedResponse( diff --git a/src/Drivers/AwsBraketDriver.php b/src/Drivers/AwsBraketDriver.php index ff6ce2e..ebf819a 100644 --- a/src/Drivers/AwsBraketDriver.php +++ b/src/Drivers/AwsBraketDriver.php @@ -5,6 +5,7 @@ namespace Aether\Drivers; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AwsDriverConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\EstimatesCost; use Aether\Exceptions\InvalidCircuitException; @@ -15,6 +16,8 @@ /** * Quantum driver for AWS Braket QPU and managed simulators. + * + * @extends AbstractQuantumDriver */ class AwsBraketDriver extends AbstractQuantumDriver implements AsynchronousDevice, EstimatesCost { @@ -23,6 +26,14 @@ protected function driverName(): string return 'aws'; } + /** + * @param array $values + */ + protected function makeConfig(array $values): AwsDriverConfig + { + return new AwsDriverConfig($this->driverName(), $values); + } + /** * @return list */ @@ -33,7 +44,7 @@ protected function requiredConfig(): array protected function beforeExecution(): void { - if (($this->config['synchronous_safe'] ?? true) === false) { + if (! $this->config->synchronousSafe) { throw QuantumExecutionException::synchronousUnsafe('aws'); } } @@ -73,18 +84,12 @@ public function checkTask(string $taskArn): TaskSnapshot */ public function estimateCost(int $shots, int $tasks = 1): CostEstimate { - $pricing = $this->config['pricing'] ?? []; - - $perTaskRate = (float) ($pricing['per_task'] ?? 0.0); - $perShotRate = (float) ($pricing['per_shot'] ?? 0.0); - $currency = (string) ($pricing['currency'] ?? 'USD'); - - $taskCost = $perTaskRate * $tasks; - $shotCost = $perShotRate * $shots; + $taskCost = ($this->config->perTaskRate ?? 0.0) * $tasks; + $shotCost = ($this->config->perShotRate ?? 0.0) * $shots; return new CostEstimate( amount: $taskCost + $shotCost, - currency: $currency, + currency: $this->config->currency, shots: $shots, breakdown: [ 'per_task' => $taskCost, @@ -97,13 +102,13 @@ public function estimateCost(int $shots, int $tasks = 1): CostEstimate * Guard against a run — one circuit, or a whole batch — whose estimated * cost exceeds the driver's configured `max_cost_per_run` ceiling. * - * A blank `max_cost_per_run` (absent, null, or an empty string — what - * env() yields for `AETHER_AWS_MAX_COST=`) means unlimited — the default, - * so existing configs keep working unchanged. A configured ceiling with - * no `pricing` rates would silently never trip (every estimate would be - * 0.00), so that combination fails fast as a misconfiguration instead. - * Shots are only summed across $circuits once a ceiling is actually - * configured, mirroring the qubit-ceiling guard's lazy evaluation. + * A blank `max_cost_per_run` means unlimited (AwsDriverConfig leaves + * $maxCostPerRun null) — the default, so existing configs keep working + * unchanged. A configured ceiling with no `pricing` rates would silently + * never trip (every estimate would be 0.00), so that combination fails + * fast as a misconfiguration instead. Shots are only summed across + * $circuits once a ceiling is actually configured, mirroring the + * qubit-ceiling guard's lazy evaluation. * * @param list $circuits * @@ -112,20 +117,16 @@ 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->config->maxCostPerRun; - if (blank($ceiling)) { + if ($ceiling === null) { return; } - $pricing = $this->config['pricing'] ?? []; - $missing = array_filter( - ['pricing.per_task', 'pricing.per_shot'], - static fn (string $key): bool => blank($pricing[substr($key, strlen('pricing.'))] ?? null), - ); + $missing = $this->config->missingRates(); if ($missing !== []) { - throw InvalidDriverConfigException::missingKeys($this->driverName(), array_values($missing)); + throw InvalidDriverConfigException::missingKeys($this->driverName(), $missing); } $shots = array_sum(array_map( @@ -135,8 +136,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/Drivers/LocalSimulatorDriver.php b/src/Drivers/LocalSimulatorDriver.php index 010cf85..babb863 100644 --- a/src/Drivers/LocalSimulatorDriver.php +++ b/src/Drivers/LocalSimulatorDriver.php @@ -5,6 +5,7 @@ namespace Aether\Drivers; use Aether\Circuit\CircuitBuilder; +use Aether\Config\DriverConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Exceptions\QuantumExecutionException; use Aether\Tasks\TaskSnapshot; @@ -30,6 +31,8 @@ * * No process ever actually queues or polls anything; check.py explicitly * refuses to run for the "local" driver (see bin/python/check.py). + * + * @extends AbstractQuantumDriver */ class LocalSimulatorDriver extends AbstractQuantumDriver implements AsynchronousDevice { 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/Config/AwsDriverConfigTest.php b/tests/Unit/Config/AwsDriverConfigTest.php new file mode 100644 index 0000000..bbd284d --- /dev/null +++ b/tests/Unit/Config/AwsDriverConfigTest.php @@ -0,0 +1,85 @@ + '12', 'synchronous_safe' => 'false']); + + expect($config)->toBeInstanceOf(DriverConfig::class) + ->and($config->maxQubits)->toBe(12) + ->and($config->synchronousSafe)->toBeFalse(); +}); + +it('applies the documented defaults when pricing and the ceiling are absent', function () { + $config = new AwsDriverConfig('aws', []); + + expect($config->maxCostPerRun)->toBeNull() + ->and($config->perTaskRate)->toBeNull() + ->and($config->perShotRate)->toBeNull() + ->and($config->currency)->toBe(AwsDriverConfig::DEFAULT_CURRENCY) + ->and($config->missingRates())->toBe(['pricing.per_task', 'pricing.per_shot']); +}); + +it('casts the pricing rates and currency', function () { + $config = new AwsDriverConfig('aws', [ + 'pricing' => ['per_task' => '0.30', 'per_shot' => 0.00035, 'currency' => 'EUR'], + ]); + + expect($config->perTaskRate)->toBe(0.30) + ->and($config->perShotRate)->toBe(0.00035) + ->and($config->currency)->toBe('EUR') + ->and($config->missingRates())->toBe([]); +}); + +it('accepts a zero rate as configured, not missing', function () { + $config = new AwsDriverConfig('aws', ['pricing' => ['per_task' => 0, 'per_shot' => '0']]); + + expect($config->perTaskRate)->toBe(0.0) + ->and($config->perShotRate)->toBe(0.0) + ->and($config->missingRates())->toBe([]); +}); + +it('reports only the blank rates as missing', function () { + $config = new AwsDriverConfig('aws', ['pricing' => ['per_task' => 0.30, 'per_shot' => '']]); + + expect($config->missingRates())->toBe(['pricing.per_shot']); +}); + +it('rejects a pricing entry that is not an array', function () { + expect(fn () => new AwsDriverConfig('aws', ['pricing' => 'cheap'])) + ->toThrow(InvalidDriverConfigException::class, 'invalid value for [pricing]: expected an array of rates or null'); +}); + +it('rejects a rate that is not a non-negative number', function (string $rate, mixed $raw) { + expect(fn () => new AwsDriverConfig('aws', ['pricing' => [$rate => $raw]])) + ->toThrow(InvalidDriverConfigException::class, "invalid value for [pricing.{$rate}]: expected a non-negative number or null"); +})->with([ + 'negative per_task' => ['per_task', -0.1], + 'word per_shot' => ['per_shot', 'free'], + 'boolean per_task' => ['per_task', true], +]); + +it('rejects a currency that is not a string', function () { + expect(fn () => new AwsDriverConfig('aws', ['pricing' => ['currency' => ['USD']]])) + ->toThrow(InvalidDriverConfigException::class, 'invalid value for [pricing.currency]: expected a string or null'); +}); + +it('casts max_cost_per_run from the number or numeric string env() yields', function (mixed $raw, ?float $expected) { + expect((new AwsDriverConfig('aws', ['max_cost_per_run' => $raw]))->maxCostPerRun)->toBe($expected); +})->with([ + 'float' => [1.5, 1.5], + 'int' => [2, 2.0], + 'numeric string' => ['0.65', 0.65], + 'zero' => [0, 0.0], + 'blank string' => ['', null], + 'null' => [null, null], +]); + +it('rejects a max_cost_per_run that is not a non-negative number', function (mixed $raw) { + expect(fn () => new AwsDriverConfig('aws', ['max_cost_per_run' => $raw])) + ->toThrow(InvalidDriverConfigException::class, 'invalid value for [max_cost_per_run]: expected a non-negative number or null'); +})->with(['negative' => [-1], 'word' => ['unlimited'], 'boolean' => [true]]); diff --git a/tests/Unit/Config/DriverConfigTest.php b/tests/Unit/Config/DriverConfigTest.php new file mode 100644 index 0000000..9b0c485 --- /dev/null +++ b/tests/Unit/Config/DriverConfigTest.php @@ -0,0 +1,114 @@ +driver)->toBe('local') + ->and($config->maxQubits)->toBeNull() + ->and($config->entropyQubits)->toBe(DriverConfig::DEFAULT_ENTROPY_QUBITS) + ->and($config->synchronousSafe)->toBeTrue() + ->and($config->toArray())->toBe([]); +}); + +it('treats null and blank strings as unset', function (mixed $blank) { + $config = new DriverConfig('local', [ + 'max_qubits' => $blank, + 'entropy_qubits' => $blank, + 'synchronous_safe' => $blank, + ]); + + expect($config->maxQubits)->toBeNull() + ->and($config->entropyQubits)->toBe(16) + ->and($config->synchronousSafe)->toBeTrue(); +})->with(['null' => [null], 'empty string' => [''], 'whitespace' => [' ']]); + +// ------------------------------------------------------------------------- +// Typed options +// ------------------------------------------------------------------------- + +it('casts max_qubits from the int or numeric string env() yields', function (mixed $raw, int $expected) { + expect((new DriverConfig('local', ['max_qubits' => $raw]))->maxQubits)->toBe($expected); +})->with(['int' => [25, 25], 'numeric string' => ['25', 25], 'padded string' => [' 8 ', 8]]); + +it('rejects a max_qubits that is not a positive integer', function (mixed $raw) { + expect(fn () => new DriverConfig('local', ['max_qubits' => $raw])) + ->toThrow(InvalidDriverConfigException::class, 'invalid value for [max_qubits]: expected a positive integer or null'); +})->with([ + 'zero' => [0], + 'negative' => [-1], + 'float' => [2.5], + 'word' => ['abc'], + 'boolean true' => [true], + 'array' => [[25]], +]); + +it('names the driver, the key and the offending value in the message', function () { + expect(fn () => new DriverConfig('local', ['max_qubits' => 'abc'])) + ->toThrow(InvalidDriverConfigException::class, "Driver [local] has an invalid value for [max_qubits]: expected a positive integer or null, got 'abc'. Set it in config/aether.php under drivers.local."); +}); + +it('casts entropy_qubits and falls back to the default for a non-positive count', function (mixed $raw, int $expected) { + expect((new DriverConfig('local', ['entropy_qubits' => $raw]))->entropyQubits)->toBe($expected); +})->with([ + 'positive int' => [8, 8], + 'numeric string' => ['12', 12], + 'zero' => [0, 16], + 'negative' => [-4, 16], + 'negative string' => ['-4', 16], +]); + +it('rejects a non-numeric entropy_qubits', function () { + expect(fn () => new DriverConfig('local', ['entropy_qubits' => 'many'])) + ->toThrow(InvalidDriverConfigException::class, 'invalid value for [entropy_qubits]: expected an integer or null'); +}); + +it('casts synchronous_safe from booleans and their env() spellings', function (mixed $raw, bool $expected) { + expect((new DriverConfig('aws', ['synchronous_safe' => $raw]))->synchronousSafe)->toBe($expected); +})->with([ + 'true' => [true, true], + 'false' => [false, false], + '"false"' => ['false', false], + '"0"' => ['0', false], + '"off"' => ['off', false], + '"true"' => ['true', true], + '"1"' => ['1', true], + 'int 0' => [0, false], +]); + +it('rejects a synchronous_safe that is not boolean-like', function (mixed $raw) { + expect(fn () => new DriverConfig('aws', ['synchronous_safe' => $raw])) + ->toThrow(InvalidDriverConfigException::class, 'invalid value for [synchronous_safe]: expected a boolean or null'); +})->with(['word' => ['maybe'], 'array' => [[true]]]); + +// ------------------------------------------------------------------------- +// Raw access +// ------------------------------------------------------------------------- + +it('keeps untyped keys reachable through get() and toArray()', function () { + $raw = ['python_provider' => 'providers.custom', 'max_qubits' => '10', 'nested' => ['a' => 1]]; + $config = new DriverConfig('custom', $raw); + + expect($config->get('python_provider'))->toBe('providers.custom') + ->and($config->get('missing'))->toBeNull() + ->and($config->get('missing', 'fallback'))->toBe('fallback') + ->and($config->toArray())->toBe($raw); +}); + +it('reports blank keys in the order asked', function () { + $config = new DriverConfig('aws', ['region' => 'us-east-1', 'bucket' => '', 'device_arn' => null]); + + expect($config->isBlank('region'))->toBeFalse() + ->and($config->isBlank('bucket'))->toBeTrue() + ->and($config->isBlank('device_arn'))->toBeTrue() + ->and($config->isBlank('absent'))->toBeTrue() + ->and($config->blankKeys(['device_arn', 'region', 'bucket']))->toBe(['device_arn', 'bucket']); +}); diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1c79b88..4ae6d8d 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -342,6 +342,45 @@ protected function driverName(): string $this->driver->executeBatch([$circuit, $circuit]); })->throws(QuantumExecutionException::class, 'exactly 2 results, got 1'); +// ------------------------------------------------------------------------- +// Typed config +// ------------------------------------------------------------------------- + +it('rejects a non-integer max_qubits when the driver is constructed', function () { + expect(fn () => new class($this->bridge, ['max_qubits' => 'abc']) extends AbstractQuantumDriver + { + protected function driverName(): string + { + return 'test'; + } + })->toThrow(InvalidDriverConfigException::class, 'Driver [test] has an invalid value for [max_qubits]'); +}); + +it('still sends the raw config array to Python, untyped keys included', function () { + $driver = new class($this->bridge, ['max_qubits' => '10', 'python_provider' => 'providers.custom']) extends AbstractQuantumDriver + { + protected function driverName(): string + { + return 'test'; + } + }; + + $this->bridge->expects($this->once()) + ->method('execute') + ->with( + 'circuit.py', + $this->callback(fn (array $p) => $p['driver_config'] === ['max_qubits' => '10', 'python_provider' => 'providers.custom']), + ['max_qubits' => '10', 'python_provider' => 'providers.custom'] + ) + ->willReturn(['counts' => ['0' => 1000]]); + + $circuit = $this->createMock(CircuitBuilder::class); + $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1000]); + $circuit->method('qubitCount')->willReturn(1); + + $driver->executeCircuit($circuit); +}); + // ------------------------------------------------------------------------- // max_qubits ceiling // ------------------------------------------------------------------------- diff --git a/tests/Unit/Drivers/AwsBraketDriverTest.php b/tests/Unit/Drivers/AwsBraketDriverTest.php index 548904b..f9c0628 100644 --- a/tests/Unit/Drivers/AwsBraketDriverTest.php +++ b/tests/Unit/Drivers/AwsBraketDriverTest.php @@ -535,6 +535,34 @@ expect($estimate->currency)->toBe('USD'); }); +// ------------------------------------------------------------------------- +// Typed config +// ------------------------------------------------------------------------- + +it('rejects a non-numeric max_cost_per_run when the driver is constructed', function () { + expect(fn () => new AwsBraketDriver($this->bridge, array_merge($this->config, ['max_cost_per_run' => 'abc']))) + ->toThrow(InvalidDriverConfigException::class, 'Driver [aws] has an invalid value for [max_cost_per_run]'); +}); + +it('rejects a negative pricing rate when the driver is constructed', function () { + $config = array_merge($this->config, ['pricing' => ['per_task' => -0.30, 'per_shot' => 0.00035]]); + + expect(fn () => new AwsBraketDriver($this->bridge, $config)) + ->toThrow(InvalidDriverConfigException::class, 'invalid value for [pricing.per_task]'); +}); + +it('reads the rates env() hands over as strings', function () { + $config = array_merge($this->config, [ + 'pricing' => ['per_task' => '0.30', 'per_shot' => '0.00035', 'currency' => 'EUR'], + ]); + $driver = new AwsBraketDriver($this->bridge, $config); + + $estimate = $driver->estimateCost(1000); + + expect($estimate->amount)->toEqualWithDelta(0.65, 1e-9) + ->and($estimate->currency)->toBe('EUR'); +}); + // ------------------------------------------------------------------------- // max_cost_per_run guard // -------------------------------------------------------------------------