From 7e4cb3c316a78aee6423654e33e1b0b6f5bf0caf Mon Sep 17 00:00:00 2001 From: corgab Date: Wed, 9 Sep 2026 06:04:33 +0000 Subject: [PATCH 1/2] refactor(circuit): derive Gate factories and fromArray from a generic make() constructor Gate::make(GateType, qubits, angles) lays parameters out from GateType::shape(), the same metadata fromArray() already dispatched on; every named factory is now a one-line wrapper around it, and fromArray() builds through it too. CircuitBuilder::gate() exposes the same generic entry point as fluent sugar for building a gate from data. A new InvalidCircuitException::gateArity() reports a wrong qubit/angle count. --- README.md | 4 +- src/Circuit/CircuitBuilder.php | 17 +++ src/Circuit/Gate.php | 162 ++++++++++----------- src/Exceptions/InvalidCircuitException.php | 11 ++ tests/Unit/Circuit/CircuitBuilderTest.php | 23 ++- tests/Unit/Circuit/GateTest.php | 36 +++++ 6 files changed, 167 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index a59f1ac..bc15ef8 100644 --- a/README.md +++ b/README.md @@ -328,12 +328,12 @@ Appending a fragment that requires more qubits than the circuit has throws an `I 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: 1. A `GateType` case (and, for a new parameter shape, a `GateShape` case) -2. A static factory on `Gate` +2. A static factory on `Gate` (a one-liner delegating to `Gate::make()`, which lays the arguments out from the shape) 3. A fluent method on `CircuitBuilder` (a one-liner delegating to `push()`) 4. A `GATE_PARAMS` row in `bin/python/common.py` 5. A row in the gate table above -Everything else is derived from the metadata. The test suite enforces completeness: a `GateType` case without a factory, fluent method, or wire-contract dataset entry fails the Unit suite, and a PHP/Python mismatch fails `tests/Feature/GateParityTest.php`, which compares the two tables through the real Python bridge. +Everything else is derived from the metadata: `Gate::make(GateType $type, array $qubits, array $angles = [])` and `CircuitBuilder::gate()` build any gate from positional arguments, so the named factories and fluent methods are typed sugar over one generic constructor. Use `->gate()` when the gate type is data rather than code, e.g. when replaying a stored circuit description. The test suite enforces completeness: a `GateType` case without a factory, fluent method, or wire-contract dataset entry fails the Unit suite, and a PHP/Python mismatch fails `tests/Feature/GateParityTest.php`, which compares the two tables through the real Python bridge. ## Events diff --git a/src/Circuit/CircuitBuilder.php b/src/Circuit/CircuitBuilder.php index 0d7f565..473b081 100644 --- a/src/Circuit/CircuitBuilder.php +++ b/src/Circuit/CircuitBuilder.php @@ -93,6 +93,23 @@ public function shots(int $shots): static return $this; } + /** + * Add a gate of any type from positional qubit indices and angles. + * + * The generic entry point behind the named fluent methods, which remain + * as typed sugar: use this when the gate type is data rather than code, + * e.g. when building a circuit from a stored description. + * + * @param int[] $qubits Qubit indices in the gate's wire order. + * @param array $angles Angles in the gate's wire order. + * + * @throws InvalidCircuitException + */ + public function gate(GateType $type, array $qubits, array $angles = []): static + { + return $this->push(Gate::make($type, $qubits, $angles)); + } + /** * Add a Hadamard gate on the given qubit. * diff --git a/src/Circuit/Gate.php b/src/Circuit/Gate.php index d4004b1..80d35d8 100644 --- a/src/Circuit/Gate.php +++ b/src/Circuit/Gate.php @@ -19,12 +19,56 @@ private function __construct( public array $params = [], ) {} + /** + * Build a gate of any type from positional qubit indices and angles. + * + * The parameter layout comes from GateType::shape(): qubit indices are + * matched to the shape's qubit keys and angles to its angle keys, in wire + * order, so this is the one place that knows how a gate's arguments map + * onto its wire representation. Every named factory below is a typed + * one-line wrapper around it, and fromArray() rebuilds gates through it. + * A Measure type delegates to measure(): its qubits are the targets, and + * none means "measure all". + * + * @param int[] $qubits Qubit indices in the shape's wire order. + * @param array $angles Angles in the shape's wire order. + * + * @throws InvalidCircuitException When the argument counts do not match the shape. + */ + public static function make(GateType $type, array $qubits, array $angles = []): self + { + if ($type === GateType::Measure) { + if ($angles !== []) { + throw InvalidCircuitException::gateArity($type->value, 'angle', 0, count($angles)); + } + + return self::measure($qubits === [] ? null : array_values($qubits)); + } + + $shape = $type->shape(); + $qubitKeys = $shape->qubitKeys(); + $angleKeys = $shape->angleKeys(); + + if (count($qubits) !== count($qubitKeys)) { + throw InvalidCircuitException::gateArity($type->value, 'qubit', count($qubitKeys), count($qubits)); + } + + if (count($angles) !== count($angleKeys)) { + throw InvalidCircuitException::gateArity($type->value, 'angle', count($angleKeys), count($angles)); + } + + $params = array_combine($qubitKeys, array_values($qubits)) + + array_combine($angleKeys, array_map(self::radians(...), array_values($angles))); + + return new self($type->value, $params); + } + /** * Create a Hadamard gate on the given qubit. */ public static function h(int $target): self { - return new self('h', ['target' => $target]); + return self::make(GateType::H, [$target]); } /** @@ -32,7 +76,7 @@ public static function h(int $target): self */ public static function x(int $target): self { - return new self('x', ['target' => $target]); + return self::make(GateType::X, [$target]); } /** @@ -40,7 +84,7 @@ public static function x(int $target): self */ public static function y(int $target): self { - return new self('y', ['target' => $target]); + return self::make(GateType::Y, [$target]); } /** @@ -48,7 +92,7 @@ public static function y(int $target): self */ public static function z(int $target): self { - return new self('z', ['target' => $target]); + return self::make(GateType::Z, [$target]); } /** @@ -56,7 +100,7 @@ public static function z(int $target): self */ public static function i(int $target): self { - return new self('i', ['target' => $target]); + return self::make(GateType::I, [$target]); } /** @@ -64,7 +108,7 @@ public static function i(int $target): self */ public static function s(int $target): self { - return new self('s', ['target' => $target]); + return self::make(GateType::S, [$target]); } /** @@ -72,7 +116,7 @@ public static function s(int $target): self */ public static function si(int $target): self { - return new self('si', ['target' => $target]); + return self::make(GateType::SI, [$target]); } /** @@ -80,7 +124,7 @@ public static function si(int $target): self */ public static function t(int $target): self { - return new self('t', ['target' => $target]); + return self::make(GateType::T, [$target]); } /** @@ -88,7 +132,7 @@ public static function t(int $target): self */ public static function ti(int $target): self { - return new self('ti', ['target' => $target]); + return self::make(GateType::TI, [$target]); } /** @@ -96,10 +140,7 @@ public static function ti(int $target): self */ public static function rx(int $target, float|Angle $angle): self { - return new self('rx', [ - 'target' => $target, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::RX, [$target], [$angle]); } /** @@ -107,10 +148,7 @@ public static function rx(int $target, float|Angle $angle): self */ public static function ry(int $target, float|Angle $angle): self { - return new self('ry', [ - 'target' => $target, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::RY, [$target], [$angle]); } /** @@ -118,10 +156,7 @@ public static function ry(int $target, float|Angle $angle): self */ public static function rz(int $target, float|Angle $angle): self { - return new self('rz', [ - 'target' => $target, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::RZ, [$target], [$angle]); } /** @@ -129,7 +164,7 @@ public static function rz(int $target, float|Angle $angle): self */ public static function cnot(int $control, int $target): self { - return new self('cnot', ['control' => $control, 'target' => $target]); + return self::make(GateType::CNOT, [$control, $target]); } /** @@ -137,7 +172,7 @@ public static function cnot(int $control, int $target): self */ public static function cz(int $control, int $target): self { - return new self('cz', ['control' => $control, 'target' => $target]); + return self::make(GateType::CZ, [$control, $target]); } /** @@ -145,7 +180,7 @@ public static function cz(int $control, int $target): self */ public static function cy(int $control, int $target): self { - return new self('cy', ['control' => $control, 'target' => $target]); + return self::make(GateType::CY, [$control, $target]); } /** @@ -153,7 +188,7 @@ public static function cy(int $control, int $target): self */ public static function swap(int $qubit0, int $qubit1): self { - return new self('swap', ['target0' => $qubit0, 'target1' => $qubit1]); + return self::make(GateType::Swap, [$qubit0, $qubit1]); } /** @@ -161,7 +196,7 @@ public static function swap(int $qubit0, int $qubit1): self */ public static function ccnot(int $control0, int $control1, int $target): self { - return new self('ccnot', ['control0' => $control0, 'control1' => $control1, 'target' => $target]); + return self::make(GateType::CCNOT, [$control0, $control1, $target]); } /** @@ -169,11 +204,7 @@ public static function ccnot(int $control0, int $control1, int $target): self */ public static function crx(int $control, int $target, float|Angle $angle): self { - return new self('crx', [ - 'control' => $control, - 'target' => $target, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::CRX, [$control, $target], [$angle]); } /** @@ -181,11 +212,7 @@ public static function crx(int $control, int $target, float|Angle $angle): self */ public static function cry(int $control, int $target, float|Angle $angle): self { - return new self('cry', [ - 'control' => $control, - 'target' => $target, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::CRY, [$control, $target], [$angle]); } /** @@ -193,11 +220,7 @@ public static function cry(int $control, int $target, float|Angle $angle): self */ public static function crz(int $control, int $target, float|Angle $angle): self { - return new self('crz', [ - 'control' => $control, - 'target' => $target, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::CRZ, [$control, $target], [$angle]); } /** @@ -205,11 +228,7 @@ public static function crz(int $control, int $target, float|Angle $angle): self */ public static function cphaseshift(int $control, int $target, float|Angle $angle): self { - return new self('cphaseshift', [ - 'control' => $control, - 'target' => $target, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::CPhaseShift, [$control, $target], [$angle]); } /** @@ -217,10 +236,7 @@ public static function cphaseshift(int $control, int $target, float|Angle $angle */ public static function phaseshift(int $target, float|Angle $angle): self { - return new self('phaseshift', [ - 'target' => $target, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::PhaseShift, [$target], [$angle]); } /** @@ -228,12 +244,7 @@ public static function phaseshift(int $target, float|Angle $angle): self */ public static function u(int $target, float|Angle $theta, float|Angle $phi, float|Angle $lambda): self { - return new self('u', [ - 'target' => $target, - 'theta' => self::radians($theta), - 'phi' => self::radians($phi), - 'lambda' => self::radians($lambda), - ]); + return self::make(GateType::U, [$target], [$theta, $phi, $lambda]); } /** @@ -241,11 +252,7 @@ public static function u(int $target, float|Angle $theta, float|Angle $phi, floa */ public static function cswap(int $control, int $qubit0, int $qubit1): self { - return new self('cswap', [ - 'control' => $control, - 'target0' => $qubit0, - 'target1' => $qubit1, - ]); + return self::make(GateType::CSwap, [$control, $qubit0, $qubit1]); } /** @@ -253,7 +260,7 @@ public static function cswap(int $control, int $qubit0, int $qubit1): self */ public static function iswap(int $qubit0, int $qubit1): self { - return new self('iswap', ['target0' => $qubit0, 'target1' => $qubit1]); + return self::make(GateType::ISwap, [$qubit0, $qubit1]); } /** @@ -261,11 +268,7 @@ public static function iswap(int $qubit0, int $qubit1): self */ public static function xx(int $qubit0, int $qubit1, float|Angle $angle): self { - return new self('xx', [ - 'target0' => $qubit0, - 'target1' => $qubit1, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::XX, [$qubit0, $qubit1], [$angle]); } /** @@ -273,11 +276,7 @@ public static function xx(int $qubit0, int $qubit1, float|Angle $angle): self */ public static function yy(int $qubit0, int $qubit1, float|Angle $angle): self { - return new self('yy', [ - 'target0' => $qubit0, - 'target1' => $qubit1, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::YY, [$qubit0, $qubit1], [$angle]); } /** @@ -285,11 +284,7 @@ public static function yy(int $qubit0, int $qubit1, float|Angle $angle): self */ public static function zz(int $qubit0, int $qubit1, float|Angle $angle): self { - return new self('zz', [ - 'target0' => $qubit0, - 'target1' => $qubit1, - 'angle' => self::radians($angle), - ]); + return self::make(GateType::ZZ, [$qubit0, $qubit1], [$angle]); } /** @@ -327,8 +322,8 @@ public static function measure(int|array|null $targets = null): self * 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 are cast to int and angle keys to + * float, in wire order, then make() lays them out and normalises angles. * * @param array $definition * @@ -353,14 +348,15 @@ public static function fromArray(array $definition): self } $shape = $gateType->shape(); - $params = []; + $qubits = []; + $angles = []; foreach ($shape->qubitKeys() as $key) { if (! array_key_exists($key, $definition)) { throw InvalidCircuitException::missingGateParameter($type, $key); } - $params[$key] = (int) $definition[$key]; + $qubits[] = (int) $definition[$key]; } foreach ($shape->angleKeys() as $key) { @@ -368,10 +364,10 @@ public static function fromArray(array $definition): self throw InvalidCircuitException::missingGateParameter($type, $key); } - $params[$key] = self::radians((float) $definition[$key]); + $angles[] = (float) $definition[$key]; } - return new self($type, $params); + return self::make($gateType, $qubits, $angles); } /** diff --git a/src/Exceptions/InvalidCircuitException.php b/src/Exceptions/InvalidCircuitException.php index ed110ef..7481742 100644 --- a/src/Exceptions/InvalidCircuitException.php +++ b/src/Exceptions/InvalidCircuitException.php @@ -117,6 +117,17 @@ public static function missingGateParameter(string $type, string $key): self ); } + /** + * Create an exception for a gate built with the wrong number of qubit + * indices or angles for its shape. + */ + public static function gateArity(string $type, string $kind, int $expected, int $given): self + { + return new self( + "Gate [{$type}] takes {$expected} {$kind} argument(s), {$given} given." + ); + } + /** * Create an exception for a circuit requesting more qubits than the * driver's configured `max_qubits` ceiling allows. diff --git a/tests/Unit/Circuit/CircuitBuilderTest.php b/tests/Unit/Circuit/CircuitBuilderTest.php index 6ac6459..a739b99 100644 --- a/tests/Unit/Circuit/CircuitBuilderTest.php +++ b/tests/Unit/Circuit/CircuitBuilderTest.php @@ -910,6 +910,25 @@ expect($builder->depth())->toBe(1); }); +it('appends the same gate through the generic gate() as through the named method', function (GateType $type): void { + $device = $this->createMock(QuantumDevice::class); + $shape = $type->shape(); + $qubits = range(0, count($shape->qubitKeys()) - 1); + $angles = array_map(static fn (int $i): float => 0.1 * ($i + 1), array_keys($shape->angleKeys())); + + $generic = (new CircuitBuilder($device))->qubits(3)->gate($type, $qubits, $angles); + $named = (new CircuitBuilder($device))->qubits(3)->{$type->value}(...$qubits, ...$angles); + + expect($generic->toArray()['gates'])->toBe($named->toArray()['gates']); +})->with(array_filter(GateType::cases(), fn (GateType $type): bool => $type !== GateType::Measure)); + +it('validates the qubit indices of a generically added gate like any other', function (): void { + $device = $this->createMock(QuantumDevice::class); + + expect(fn () => (new CircuitBuilder($device))->qubits(1)->gate(GateType::CNOT, [0, 1])) + ->toThrow(InvalidCircuitException::class); +}); + it('every Gate self-returning static factory has a matching GateType case', function (): void { $reflection = new ReflectionClass(Gate::class); @@ -918,7 +937,9 @@ array_filter( $reflection->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC), static function (ReflectionMethod $method): bool { - if ($method->getName() === 'fromArray') { + // fromArray() and make() are the generic constructors every + // named factory delegates to, not gates themselves. + if (in_array($method->getName(), ['fromArray', 'make'], true)) { return false; } diff --git a/tests/Unit/Circuit/GateTest.php b/tests/Unit/Circuit/GateTest.php index 8478c8c..75859d7 100644 --- a/tests/Unit/Circuit/GateTest.php +++ b/tests/Unit/Circuit/GateTest.php @@ -282,6 +282,42 @@ // fromArray() — metadata-driven round trip for every gate type // ------------------------------------------------------------------------- +it('builds every gate type through make() exactly as its named factory does', function (GateType $type): void { + $shape = $type->shape(); + $qubits = range(0, count($shape->qubitKeys()) - 1); + $angles = array_map(static fn (int $i): float => 0.1 * ($i + 1), array_keys($shape->angleKeys())); + + $generic = Gate::make($type, $qubits, $angles); + $named = Gate::{$type->value}(...$qubits, ...$angles); + + expect($generic->toArray())->toBe($named->toArray()) + ->and(array_keys($generic->params))->toBe([...$shape->qubitKeys(), ...$shape->angleKeys()]); +})->with(array_filter(GateType::cases(), fn (GateType $type): bool => $type !== GateType::Measure)); + +it('make() accepts Angle instances and normalises them like the named factories', function (): void { + expect(Gate::make(GateType::RX, [0], [Angle::degrees(180)])->params['angle'])->toBe(M_PI); +}); + +it('make() rejects the wrong number of qubit indices', function (): void { + expect(fn () => Gate::make(GateType::CNOT, [0])) + ->toThrow(InvalidCircuitException::class, 'Gate [cnot] takes 2 qubit argument(s), 1 given.'); +}); + +it('make() rejects the wrong number of angles', function (): void { + expect(fn () => Gate::make(GateType::U, [0], [1.0])) + ->toThrow(InvalidCircuitException::class, 'Gate [u] takes 3 angle argument(s), 1 given.'); +}); + +it('make() builds a measurement from its qubits, or a measure-all when none are given', function (): void { + expect(Gate::make(GateType::Measure, [0, 2])->toArray())->toBe(Gate::measure([0, 2])->toArray()) + ->and(Gate::make(GateType::Measure, [])->toArray())->toBe(Gate::measure()->toArray()); +}); + +it('make() rejects angles on a measurement', function (): void { + expect(fn () => Gate::make(GateType::Measure, [0], [1.0])) + ->toThrow(InvalidCircuitException::class, 'Gate [measure] takes 0 angle argument(s), 1 given.'); +}); + it('round trips every gate type through fromArray/toArray', function (GateType $type): void { $shape = $type->shape(); $definition = ['type' => $type->value]; From a668e98d3150caa705fd1926bc1f00e5013ce90a Mon Sep 17 00:00:00 2001 From: corgab Date: Wed, 9 Sep 2026 06:06:23 +0000 Subject: [PATCH 2/2] fix(tests): use Angle::deg(), not the nonexistent Angle::degrees() Angle's degree factory is deg(), not degrees(); the new make() test called a method that doesn't exist, failing CI with a fatal error rather than a test assertion. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01E8zT5sUCTC8TgcpsME4WP5 --- tests/Unit/Circuit/GateTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Circuit/GateTest.php b/tests/Unit/Circuit/GateTest.php index 75859d7..b1abd74 100644 --- a/tests/Unit/Circuit/GateTest.php +++ b/tests/Unit/Circuit/GateTest.php @@ -295,7 +295,7 @@ })->with(array_filter(GateType::cases(), fn (GateType $type): bool => $type !== GateType::Measure)); it('make() accepts Angle instances and normalises them like the named factories', function (): void { - expect(Gate::make(GateType::RX, [0], [Angle::degrees(180)])->params['angle'])->toBe(M_PI); + expect(Gate::make(GateType::RX, [0], [Angle::deg(180)])->params['angle'])->toBe(M_PI); }); it('make() rejects the wrong number of qubit indices', function (): void {