diff --git a/README.md b/README.md index a59f1ac..4a64b4e 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,8 @@ $result = Quantum::circuit() Appending a fragment that requires more qubits than the circuit has throws an `InvalidCircuitException`. +Qubit indices must be integers: `measure()` accepts `null` (every qubit), an `int`, or a non-empty array of integers, and every gate method takes `int` indices. A string or a float inside a `measure()` array throws an `InvalidCircuitException` instead of a `TypeError`, and a queued definition carrying a non-integer index for any gate is rejected when it is rebuilt rather than silently cast to qubit 0. + ### Adding a Gate Gate knowledge lives in a single metadata layer on each side of the bridge: the `GateType` / `GateShape` enums in `src/Circuit/` (PHP) and the `GATE_PARAMS` table in `bin/python/common.py` (Python). Adding a gate touches exactly five places: diff --git a/src/Circuit/Gate.php b/src/Circuit/Gate.php index d4004b1..3b42555 100644 --- a/src/Circuit/Gate.php +++ b/src/Circuit/Gate.php @@ -317,18 +317,49 @@ public static function measure(int|array|null $targets = null): self $resolved = match (true) { $targets === null => null, is_int($targets) => [$targets], - default => $targets, + default => self::integerIndices('measure', $targets), }; return new self('measure', ['targets' => $resolved]); } + /** + * Require a qubit index to be an integer. + * + * PHP cannot type array elements or a serialized definition's values, so + * a stray string or float would otherwise reach the int-typed range check + * as a TypeError, or be cast to qubit 0 when a queued definition is rebuilt. + * + * @throws InvalidCircuitException + */ + private static function integerIndex(string $gate, mixed $value): int + { + if (! is_int($value)) { + throw InvalidCircuitException::invalidQubitIndex(strtoupper($gate), $value); + } + + return $value; + } + + /** + * Require every value to be an integer qubit index and return them as a list. + * + * @param array $values + * @return list + * + * @throws InvalidCircuitException + */ + private static function integerIndices(string $gate, array $values): array + { + return array_values(array_map(static fn (mixed $value): int => self::integerIndex($gate, $value), $values)); + } + /** * Rebuild a Gate from the flat array shape produced by toArray(). * * Dispatches generically on GateType/GateShape metadata instead of a - * per-type match arm: qubit-index keys are cast to int, angle keys are - * cast to float and normalised via radians(), in wire order. + * per-type match arm: qubit-index keys must already be integers, angle + * keys are cast to float and normalised via radians(), in wire order. * * @param array $definition * @@ -360,7 +391,7 @@ public static function fromArray(array $definition): self throw InvalidCircuitException::missingGateParameter($type, $key); } - $params[$key] = (int) $definition[$key]; + $params[$key] = self::integerIndex($type, $definition[$key]); } foreach ($shape->angleKeys() as $key) { @@ -424,11 +455,15 @@ private static function decodeMeasureTargets(array $definition): ?array { $targets = $definition['targets'] ?? null; - if (! is_array($targets)) { + if ($targets === null) { return null; } - return array_map(static fn (mixed $target): int => (int) $target, $targets); + if (! is_array($targets)) { + throw InvalidCircuitException::invalidQubitIndex('MEASURE', $targets); + } + + return self::integerIndices('measure', $targets); } /** diff --git a/src/Exceptions/InvalidCircuitException.php b/src/Exceptions/InvalidCircuitException.php index ed110ef..2a251bb 100644 --- a/src/Exceptions/InvalidCircuitException.php +++ b/src/Exceptions/InvalidCircuitException.php @@ -75,6 +75,18 @@ public static function appendedCircuitTooLarge(int $fragmentQubits, int $qubits) ); } + /** + * Create an exception for a gate parameter that is not an integer qubit index. + */ + public static function invalidQubitIndex(string $gate, mixed $value): self + { + $given = is_scalar($value) ? var_export($value, true) : get_debug_type($value); + + return new self( + "Gate {$gate} expects integer qubit indices, got {$given}." + ); + } + /** * Create an exception for a measurement operation with an empty target list. */ diff --git a/tests/Unit/Circuit/CircuitBuilderTest.php b/tests/Unit/Circuit/CircuitBuilderTest.php index 6ac6459..fe7dbb0 100644 --- a/tests/Unit/Circuit/CircuitBuilderTest.php +++ b/tests/Unit/Circuit/CircuitBuilderTest.php @@ -340,6 +340,11 @@ expect($builder->qubitCount())->toBe(2); }); +it('measure with a non-integer target throws InvalidCircuitException, not a TypeError', function () use (&$builder): void { + expect(fn () => $builder->qubits(2)->measure(['a'])) + ->toThrow(InvalidCircuitException::class, 'integer qubit indices'); +}); + it('measure with an empty array throws', function () use (&$builder): void { expect(fn () => $builder->qubits(2)->measure([])) ->toThrow(InvalidCircuitException::class); diff --git a/tests/Unit/Circuit/GateTest.php b/tests/Unit/Circuit/GateTest.php index 8478c8c..374b0a3 100644 --- a/tests/Unit/Circuit/GateTest.php +++ b/tests/Unit/Circuit/GateTest.php @@ -139,6 +139,21 @@ expect($gate->params)->toBe(['targets' => [2]]); }); +it('measure rejects targets that are not integer qubit indices', function (mixed $targets, string $given): void { + expect(fn () => Gate::measure($targets)) + ->toThrow(InvalidCircuitException::class, "got {$given}"); +})->with([ + 'string' => [['a'], "'a'"], + 'numeric string' => [['1'], "'1'"], + 'float' => [[1.5], '1.5'], + 'mixed with a valid index' => [[0, 'x'], "'x'"], + 'nested array' => [[[0]], 'array'], +]); + +it('measure reindexes explicit targets', function (): void { + expect(Gate::measure([2 => 1, 5 => 0])->qubitIndices())->toBe([1, 0]); +}); + it('measure with array keeps array', function (): void { $gate = Gate::measure([0, 1, 2]); @@ -298,6 +313,18 @@ expect(Gate::fromArray($definition)->toArray())->toBe($definition); })->with(array_filter(GateType::cases(), fn (GateType $type): bool => $type !== GateType::Measure)); +it('fromArray rejects qubit indices that are not integers instead of casting them', function (array $definition, string $given): void { + expect(fn () => Gate::fromArray($definition)) + ->toThrow(InvalidCircuitException::class, "got {$given}"); +})->with([ + 'measure target string' => [['type' => 'measure', 'targets' => ['a']], "'a'"], + 'measure targets scalar' => [['type' => 'measure', 'targets' => 'a'], "'a'"], + 'measure targets int' => [['type' => 'measure', 'targets' => 3], '3'], + 'single-qubit gate string' => [['type' => 'h', 'target' => 'a'], "'a'"], + 'single-qubit gate numeric string' => [['type' => 'h', 'target' => '1'], "'1'"], + 'two-qubit gate float control' => [['type' => 'cnot', 'control' => 1.9, 'target' => 0], '1.9'], +]); + it('round trips a measure gate with explicit targets through fromArray/toArray', function (): void { $definition = ['type' => 'measure', 'targets' => [0, 2]];