Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 41 additions & 6 deletions src/Circuit/Gate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed> $values
* @return list<int>
*
* @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<string, mixed> $definition
*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/Exceptions/InvalidCircuitException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions tests/Unit/Circuit/CircuitBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions tests/Unit/Circuit/GateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

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

Expand Down