From 9845e15da4c66bd4958797a12426f48280826e42 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 17:12:48 +0000 Subject: [PATCH 1/4] fix: reject gates and repeated measurements on measured qubits while building Braket refuses a measure() that lists the same qubit twice and any instruction applied to a qubit that was already measured, but the builder only checked that indices were in range, so both mistakes surfaced from the Python subprocess as a generic execution error. CircuitBuilder now tracks the measured qubits (every qubit for a measure-all) and throws InvalidCircuitException at build time, including for appended fragments and definitions rebuilt with fromArray(), with messages naming the gate and the qubit. Closes #34 --- README.md | 2 + src/Circuit/CircuitBuilder.php | 53 +++++++++++++++++++ src/Exceptions/InvalidCircuitException.php | 20 ++++++++ tests/Unit/Circuit/CircuitBuilderTest.php | 60 ++++++++++++++++++++++ 4 files changed, 135 insertions(+) diff --git a/README.md b/README.md index a59f1ac..799939b 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`. +Measurement is final, as on Braket: listing a qubit twice in one `measure()` call, measuring a qubit a second time, or applying any gate to a qubit after it was measured throws an `InvalidCircuitException` while the circuit is being built, before any Python process is spawned. Put `measure()` last, or measure only the qubits you are done with. + ### 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/CircuitBuilder.php b/src/Circuit/CircuitBuilder.php index 0d7f565..75ee0c0 100644 --- a/src/Circuit/CircuitBuilder.php +++ b/src/Circuit/CircuitBuilder.php @@ -40,6 +40,9 @@ class CircuitBuilder private bool $hasMeasurement = false; + /** @var array Qubits already measured; Braket rejects any later instruction on them. */ + private array $measuredQubits = []; + private int $shots = 1000; final public function __construct( @@ -407,6 +410,7 @@ public function append(self|callable $fragment): static // can be shared directly without a toArray()/Gate::fromArray() round // trip that would re-serialize and re-validate every gate. foreach ($fragment->gates as $gate) { + $this->assertMeasurementOrder($gate); if (! $gate->isMeasurement()) { $this->gates[] = $gate; } @@ -636,6 +640,7 @@ public function validate(): static private function push(Gate $gate): static { $this->validateTargets(strtoupper($gate->type), ...$gate->qubitIndices()); + $this->assertMeasurementOrder($gate); $this->gates[] = $gate; @@ -659,4 +664,52 @@ private function validateTargets(string $gate, int ...$qubits): void } } } + + /** + * Enforce Braket's measurement ordering before the circuit reaches Python: + * a qubit can be measured once, and nothing may act on it afterwards. + * + * Records the qubits a measurement covers (every qubit for a measure-all) + * so later gates and measurements can be checked against them. + * + * @throws InvalidCircuitException + */ + private function assertMeasurementOrder(Gate $gate): void + { + $name = strtoupper($gate->type); + + if (! $gate->isMeasurement()) { + foreach ($gate->qubitIndices() as $qubit) { + if (isset($this->measuredQubits[$qubit])) { + throw InvalidCircuitException::qubitAlreadyMeasured($name, $qubit); + } + } + + return; + } + + $targets = $gate->qubitIndices(); + + if ($targets === [] && $this->qubitCount > 0) { + $targets = range(0, $this->qubitCount - 1); + } + + $seen = []; + + foreach ($targets as $qubit) { + if (isset($seen[$qubit])) { + throw InvalidCircuitException::repeatedMeasurementTarget($qubit); + } + + if (isset($this->measuredQubits[$qubit])) { + throw InvalidCircuitException::qubitAlreadyMeasured($name, $qubit); + } + + $seen[$qubit] = true; + } + + foreach ($targets as $qubit) { + $this->measuredQubits[$qubit] = true; + } + } } diff --git a/src/Exceptions/InvalidCircuitException.php b/src/Exceptions/InvalidCircuitException.php index ed110ef..8157a11 100644 --- a/src/Exceptions/InvalidCircuitException.php +++ b/src/Exceptions/InvalidCircuitException.php @@ -75,6 +75,26 @@ public static function appendedCircuitTooLarge(int $fragmentQubits, int $qubits) ); } + /** + * Create an exception for a measure() call that lists the same qubit twice. + */ + public static function repeatedMeasurementTarget(int $qubit): self + { + return new self( + "Qubit {$qubit} is listed more than once in the same measure() call; each qubit can be measured once." + ); + } + + /** + * Create an exception for an instruction applied to a qubit that was already measured. + */ + public static function qubitAlreadyMeasured(string $gate, int $qubit): self + { + return new self( + "Cannot apply {$gate} to qubit {$qubit}: it has already been measured. Braket rejects any gate or measurement on a measured qubit, so move the measurement to the end of the circuit." + ); + } + /** * 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..9bc94d8 100644 --- a/tests/Unit/Circuit/CircuitBuilderTest.php +++ b/tests/Unit/Circuit/CircuitBuilderTest.php @@ -340,6 +340,66 @@ expect($builder->qubitCount())->toBe(2); }); +it('rejects a measure() call that lists the same qubit twice', function () use (&$builder): void { + expect(fn () => $builder->qubits(2)->h(0)->measure([0, 0])) + ->toThrow(InvalidCircuitException::class, 'listed more than once'); +}); + +it('rejects measuring a qubit a second time', function (callable $second) use (&$builder): void { + $builder->qubits(2)->h(0)->measure(0); + + expect(fn () => $second($builder)) + ->toThrow(InvalidCircuitException::class, 'already been measured'); +})->with([ + 'explicit target again' => [fn (CircuitBuilder $b) => $b->measure(0)], + 'inside a wider measurement' => [fn (CircuitBuilder $b) => $b->measure([1, 0])], + 'measure all afterwards' => [fn (CircuitBuilder $b) => $b->measure()], +]); + +it('rejects a gate applied to a qubit after it was measured', function (callable $gate, string $name) use (&$builder): void { + $builder->qubits(2)->h(0)->measure(0); + + expect(fn () => $gate($builder)) + ->toThrow(InvalidCircuitException::class, "Cannot apply {$name} to qubit 0"); +})->with([ + 'single-qubit gate' => [fn (CircuitBuilder $b) => $b->h(0), 'H'], + 'two-qubit gate through its control' => [fn (CircuitBuilder $b) => $b->cnot(0, 1), 'CNOT'], + 'two-qubit gate through its target' => [fn (CircuitBuilder $b) => $b->cnot(1, 0), 'CNOT'], +]); + +it('rejects any gate after a measure-all', function () use (&$builder): void { + $builder->qubits(2)->h(0)->measure(); + + expect(fn () => $builder->x(1))->toThrow(InvalidCircuitException::class, 'already been measured'); +}); + +it('still allows gates on qubits that were not measured', function () use (&$builder): void { + $builder->qubits(3)->h(0)->measure(0)->h(1)->cnot(1, 2)->measure([1, 2]); + + expect($builder->toArray()['gates'])->toHaveCount(5); +}); + +it('rejects an appended fragment that touches a measured qubit', function () use (&$builder, &$device): void { + $builder->qubits(2)->h(0)->measure(0); + $fragment = (new CircuitBuilder($device))->qubits(2)->x(0); + + expect(fn () => $builder->append($fragment))->toThrow(InvalidCircuitException::class, 'already been measured'); +}); + +it('fromArray rejects a definition that acts on a measured qubit', function () use (&$device): void { + $definition = [ + 'qubits' => 1, + 'gates' => [ + ['type' => 'measure', 'targets' => [0]], + ['type' => 'h', 'target' => 0], + ], + 'shots' => 10, + ]; + + expect(fn () => CircuitBuilder::fromArray($definition, $device)) + ->toThrow(InvalidCircuitException::class, 'already been measured'); +}); + it('measure with an empty array throws', function () use (&$builder): void { expect(fn () => $builder->qubits(2)->measure([])) ->toThrow(InvalidCircuitException::class); From 7a280f12a933ce1d2f7b4ba89a6602a9dd91397d Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 17:17:39 +0000 Subject: [PATCH 2/4] fix: keep appended fragments' measurements out of the measured-qubit guard append() drops a fragment's measurements by contract, so they must not mark the parent's qubits as measured; only the fragment's gates are checked against the parent's own measurements. A measure-all now sets a flag that covers qubits added by a later qubits() call, matching how the Python side expands it against the final qubit count, and push() resolves the gate name and indices once for both checks. --- README.md | 2 +- src/Circuit/CircuitBuilder.php | 54 +++++++++++++++-------- tests/Unit/Circuit/CircuitBuilderTest.php | 20 +++++++++ 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 799939b..c433c20 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,7 @@ $result = Quantum::circuit() Appending a fragment that requires more qubits than the circuit has throws an `InvalidCircuitException`. -Measurement is final, as on Braket: listing a qubit twice in one `measure()` call, measuring a qubit a second time, or applying any gate to a qubit after it was measured throws an `InvalidCircuitException` while the circuit is being built, before any Python process is spawned. Put `measure()` last, or measure only the qubits you are done with. +Measurement is final, as on Braket: listing a qubit twice in one `measure()` call, measuring a qubit a second time, or applying any gate to a qubit after it was measured throws an `InvalidCircuitException` while the circuit is being built, before any Python process is spawned. Put `measure()` last, or measure only the qubits you are done with. A fragment's own measurements do not count, since `append()` drops them: only the parent's measurements constrain what follows. ### Adding a Gate diff --git a/src/Circuit/CircuitBuilder.php b/src/Circuit/CircuitBuilder.php index 75ee0c0..c6d4c02 100644 --- a/src/Circuit/CircuitBuilder.php +++ b/src/Circuit/CircuitBuilder.php @@ -43,6 +43,9 @@ class CircuitBuilder /** @var array Qubits already measured; Braket rejects any later instruction on them. */ private array $measuredQubits = []; + /** A measure-all was pushed: every qubit, including ones added later, counts as measured. */ + private bool $measuredAll = false; + private int $shots = 1000; final public function __construct( @@ -410,7 +413,11 @@ public function append(self|callable $fragment): static // can be shared directly without a toArray()/Gate::fromArray() round // trip that would re-serialize and re-validate every gate. foreach ($fragment->gates as $gate) { - $this->assertMeasurementOrder($gate); + // The fragment's measurements are dropped below, so only its gates + // are checked against what the parent has already measured. + if (! $gate->isMeasurement()) { + $this->assertMeasurementOrder($gate, strtoupper($gate->type), $gate->qubitIndices()); + } if (! $gate->isMeasurement()) { $this->gates[] = $gate; } @@ -639,8 +646,11 @@ public function validate(): static */ private function push(Gate $gate): static { - $this->validateTargets(strtoupper($gate->type), ...$gate->qubitIndices()); - $this->assertMeasurementOrder($gate); + $name = strtoupper($gate->type); + $indices = $gate->qubitIndices(); + + $this->validateTargets($name, ...$indices); + $this->assertMeasurementOrder($gate, $name, $indices); $this->gates[] = $gate; @@ -669,18 +679,19 @@ private function validateTargets(string $gate, int ...$qubits): void * Enforce Braket's measurement ordering before the circuit reaches Python: * a qubit can be measured once, and nothing may act on it afterwards. * - * Records the qubits a measurement covers (every qubit for a measure-all) - * so later gates and measurements can be checked against them. + * Explicit measurements record their targets; a measure-all marks the + * whole circuit, including qubits added by a later qubits() call, since + * the Python side expands it against the final qubit count. + * + * @param list $indices The gate's qubit indices, already range-checked. * * @throws InvalidCircuitException */ - private function assertMeasurementOrder(Gate $gate): void + private function assertMeasurementOrder(Gate $gate, string $name, array $indices): void { - $name = strtoupper($gate->type); - if (! $gate->isMeasurement()) { - foreach ($gate->qubitIndices() as $qubit) { - if (isset($this->measuredQubits[$qubit])) { + foreach ($indices as $qubit) { + if ($this->isMeasured($qubit)) { throw InvalidCircuitException::qubitAlreadyMeasured($name, $qubit); } } @@ -688,28 +699,35 @@ private function assertMeasurementOrder(Gate $gate): void return; } - $targets = $gate->qubitIndices(); + if ($indices === []) { + if ($this->measuredAll || $this->measuredQubits !== []) { + throw InvalidCircuitException::qubitAlreadyMeasured($name, array_key_first($this->measuredQubits) ?? 0); + } + + $this->measuredAll = true; - if ($targets === [] && $this->qubitCount > 0) { - $targets = range(0, $this->qubitCount - 1); + return; } $seen = []; - foreach ($targets as $qubit) { + foreach ($indices as $qubit) { if (isset($seen[$qubit])) { throw InvalidCircuitException::repeatedMeasurementTarget($qubit); } - if (isset($this->measuredQubits[$qubit])) { + if ($this->isMeasured($qubit)) { throw InvalidCircuitException::qubitAlreadyMeasured($name, $qubit); } $seen[$qubit] = true; } - foreach ($targets as $qubit) { - $this->measuredQubits[$qubit] = true; - } + $this->measuredQubits += $seen; + } + + private function isMeasured(int $qubit): bool + { + return $this->measuredAll || isset($this->measuredQubits[$qubit]); } } diff --git a/tests/Unit/Circuit/CircuitBuilderTest.php b/tests/Unit/Circuit/CircuitBuilderTest.php index 9bc94d8..58bacd2 100644 --- a/tests/Unit/Circuit/CircuitBuilderTest.php +++ b/tests/Unit/Circuit/CircuitBuilderTest.php @@ -386,6 +386,26 @@ expect(fn () => $builder->append($fragment))->toThrow(InvalidCircuitException::class, 'already been measured'); }); +it('ignores the measurements of an appended fragment, which append() drops', function () use (&$builder, &$device): void { + $bell = (new CircuitBuilder($device))->qubits(2)->h(0)->cnot(0, 1)->measure(); + + $builder->qubits(2)->append($bell)->x(0)->measure(); + + expect($builder->toArray()['gates'])->toHaveCount(4); +}); + +it('treats qubits added after a measure-all as measured too', function () use (&$builder): void { + $builder->qubits(2)->h(0)->measure()->qubits(3); + + expect(fn () => $builder->h(2))->toThrow(InvalidCircuitException::class, 'already been measured'); +}); + +it('rejects a second measurement after a measure-all', function () use (&$builder): void { + $builder->qubits(2)->measure(); + + expect(fn () => $builder->measure(1))->toThrow(InvalidCircuitException::class, 'already been measured'); +}); + it('fromArray rejects a definition that acts on a measured qubit', function () use (&$device): void { $definition = [ 'qubits' => 1, From 341dff921b88560234b62a7ec8d8e6f2e3346e2b Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 17:18:56 +0000 Subject: [PATCH 3/4] fix: type the measured-qubit guard's indices like Gate::qubitIndices() PHPStan reads Gate::qubitIndices() as array, so the guard's parameter uses the same shape instead of list. --- src/Circuit/CircuitBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Circuit/CircuitBuilder.php b/src/Circuit/CircuitBuilder.php index c6d4c02..d69bfea 100644 --- a/src/Circuit/CircuitBuilder.php +++ b/src/Circuit/CircuitBuilder.php @@ -683,7 +683,7 @@ private function validateTargets(string $gate, int ...$qubits): void * whole circuit, including qubits added by a later qubits() call, since * the Python side expands it against the final qubit count. * - * @param list $indices The gate's qubit indices, already range-checked. + * @param array $indices The gate's qubit indices, already range-checked. * * @throws InvalidCircuitException */ From 85123522c947894bfbb4f87fc31ead032c6820c8 Mon Sep 17 00:00:00 2001 From: corgab Date: Thu, 10 Sep 2026 13:07:35 +0200 Subject: [PATCH 4/4] Apply review feedback: remove python/braket references and combine if blocks --- src/Circuit/CircuitBuilder.php | 6 ++---- src/Exceptions/InvalidCircuitException.php | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Circuit/CircuitBuilder.php b/src/Circuit/CircuitBuilder.php index d69bfea..7860a3a 100644 --- a/src/Circuit/CircuitBuilder.php +++ b/src/Circuit/CircuitBuilder.php @@ -417,8 +417,6 @@ public function append(self|callable $fragment): static // are checked against what the parent has already measured. if (! $gate->isMeasurement()) { $this->assertMeasurementOrder($gate, strtoupper($gate->type), $gate->qubitIndices()); - } - if (! $gate->isMeasurement()) { $this->gates[] = $gate; } } @@ -676,12 +674,12 @@ private function validateTargets(string $gate, int ...$qubits): void } /** - * Enforce Braket's measurement ordering before the circuit reaches Python: + * Enforce strict measurement ordering during circuit construction: * a qubit can be measured once, and nothing may act on it afterwards. * * Explicit measurements record their targets; a measure-all marks the * whole circuit, including qubits added by a later qubits() call, since - * the Python side expands it against the final qubit count. + * the driver expands it against the final qubit count. * * @param array $indices The gate's qubit indices, already range-checked. * diff --git a/src/Exceptions/InvalidCircuitException.php b/src/Exceptions/InvalidCircuitException.php index 8157a11..077d63f 100644 --- a/src/Exceptions/InvalidCircuitException.php +++ b/src/Exceptions/InvalidCircuitException.php @@ -91,7 +91,7 @@ public static function repeatedMeasurementTarget(int $qubit): self public static function qubitAlreadyMeasured(string $gate, int $qubit): self { return new self( - "Cannot apply {$gate} to qubit {$qubit}: it has already been measured. Braket rejects any gate or measurement on a measured qubit, so move the measurement to the end of the circuit." + "Cannot apply {$gate} to qubit {$qubit}: it has already been measured. Quantum circuits cannot apply gates or measurements to a measured qubit, so move the measurement to the end of the circuit." ); }