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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 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. 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

Expand Down Expand Up @@ -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

Expand Down
19 changes: 13 additions & 6 deletions config/aether.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],

Expand All @@ -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.
Expand All @@ -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'),
],

Expand All @@ -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'),
],

Expand Down
106 changes: 106 additions & 0 deletions src/Config/AwsDriverConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace Aether\Config;

use Aether\Exceptions\InvalidDriverConfigException;

/**
* Typed view over the `aws` driver's config: the shared options plus the
* pricing rates and the per-run cost ceiling AwsBraketDriver enforces.
*
* Presence of `region`, `device_arn` and `bucket` is still checked lazily by
* the driver (see AbstractQuantumDriver::requiredConfig()), so a driver can be
* resolved for estimateCost() without a complete remote setup; this class only
* rejects values that are present but of the wrong shape.
*/
readonly class AwsDriverConfig extends DriverConfig
{
/**
* Default currency reported by estimateCost() when none is configured.
*/
public const DEFAULT_CURRENCY = 'USD';

/**
* AWS region (`region`), or null when not configured.
*/
public ?string $region;

/**
* S3 bucket results are written to (`bucket`), or null to let the Braket
* SDK fall back to its own default bucket.
*/
public ?string $bucket;

/**
* Braket device ARN (`device_arn`), or null when not configured.
*/
public ?string $deviceArn;

/**
* Estimated-cost ceiling for one ->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<string, mixed> $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<string>
*/
public function missingRates(): array
{
$missing = [];

if ($this->perTaskRate === null) {
$missing[] = 'pricing.per_task';
}

if ($this->perShotRate === null) {
$missing[] = 'pricing.per_shot';
}

return $missing;
}
}
Loading