diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a7ed26..91e7c626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Hence, occasionally changes will be backwards incompatible (although they will a ## [Unreleased] +### Added +- `pyzx.simplify.drop_orphan_reset_discards`: opt-in cleanup pass that removes the disconnected `boundary -- Z(0) -- X(_rN)` components left behind by `Circuit.to_graph(elide_initial_resets=False)` on circuits with leading resets, matching the elided graph with |0⟩ applied to those inputs (by @dlyongemallo). + +### Fixed +- Reset gate representation changed from ground-based to symbolic boolean paradigm, avoiding paradigm-mixing issues that destroyed measurement phases during simplification of circuits with mid-circuit resets. As a side effect, `graph_to_circuit` now recovers conditional X-type rotations (`NOT`, `XPhase`, `SX`), since X-type vertices on the qubit wire are unambiguous now that measurement outcomes are represented as leaves. `Circuit.to_graph` gains an opt-in `elide_initial_resets` flag (default `False`) that skips the discard chain for a `Reset` on an unmodified input wire, useful for circuits with OpenQASM-style implicit |0⟩ inputs. Each `_rN` reset variable is allocated to the lowest name not already in the graph's variable registry to avoid aliasing user-supplied phases, and `graph_to_circuit` excludes vertices on classical-bit wires (e.g. the `Z`/`X` pair from `DiscardBit`) so hybrid graphs round-trip without extra phantom qubits (by @dlyongemallo). + ## [0.10.2] - 2026-05-01 ## [0.10.1] - 2026-04-28 diff --git a/pyzx/circuit/__init__.py b/pyzx/circuit/__init__.py index c42a611f..3c4d2873 100644 --- a/pyzx/circuit/__init__.py +++ b/pyzx/circuit/__init__.py @@ -293,18 +293,31 @@ def to_graph( zh:bool=False, compress_rows:bool=True, backend:Optional[str]=None, + elide_initial_resets:bool=False, ) -> BaseGraph: """Turns the circuit into a ZX-Graph. If ``compress_rows`` is set, it tries to put single qubit gates on different qubits, - on the same row.""" + on the same row. + + ``elide_initial_resets`` (default False) skips the discard chain + for a ``Reset`` on an unmodified input wire. Defaults to False + because programmatically-constructed circuits may have + uninitialized inputs, where a leading ``Reset`` is not + redundant. Set to True only when those inputs are already + represented as explicit |0⟩ states (e.g. via + :meth:`initialize_qubits` or OpenQASM-style implicit |0⟩ + inputs); otherwise eliding turns the leading ``Reset`` into a + no-op instead of trace-out-and-reprepare. See + :func:`circuit_to_graph` for details.""" from .graphparser import circuit_to_graph return circuit_to_graph( self if zh else self.to_basic_gates(), - compress_rows, - backend, - initialize_qubits=self._initialize_qubits, - postselect_qubits=self._postselect_qubits + compress_rows, + backend, + initialize_qubits=self._initialize_qubits, + postselect_qubits=self._postselect_qubits, + elide_initial_resets=elide_initial_resets, ) def to_tensor(self, preserve_scalar:bool=True, strategy:str='naive') -> np.ndarray: diff --git a/pyzx/circuit/gates.py b/pyzx/circuit/gates.py index 17b41290..d1a678f3 100644 --- a/pyzx/circuit/gates.py +++ b/pyzx/circuit/gates.py @@ -1305,11 +1305,14 @@ class Reset(Gate): Corresponds to the OpenQASM ``reset`` instruction, which discards the current qubit state and unconditionally prepares ``|0⟩``. - In the ZX-diagram this is represented as a Z spider connected to - ground (tracing out / discarding the qubit) followed by a - disconnected X spider with phase 0 (state preparation ``|0⟩``). - This mirrors the ``DiscardBit`` pattern and models reset as a CPTP - map. + In the ZX-diagram this is represented as a Z(0) spider on the + qubit wire with an X(``_rN``) leaf hanging off it, followed by a + disconnected X(0) leaf for the fresh ``|0⟩`` preparation. The + boolean variable ``_rN`` appears nowhere else, so marginalising + over it gives the partial-trace semantics of the discard. The + two leaves are tagged in vertex data with ``outcome_type`` + set to ``'reset_discard'`` and ``'reset_state'`` respectively, + so the gate can be recognised on round-trip. """ name = 'Reset' @@ -1388,19 +1391,12 @@ class ConditionalGate(Gate): Limitations: * Only single-qubit Z and X rotations (ZPhase, Z, S, T, XPhase, NOT, - and their subclasses) are supported as inner gates. Other gates, + and their subclasses) are supported as inner gates. Other gates, including HAD (which is single-qubit but not a Z/X rotation), CNOT, and CZ, raise ``NotImplementedError`` in ``to_graph()``. Conditional HAD is a known gap for QEC Pauli-frame-correction use cases and requires a decomposition into Z/X rotations or a dedicated graph representation. - - * Conditional X rotations (XPhase, NOT) convert to the graph correctly - but cannot be recovered by ``graph_to_circuit()`` because X-type - vertices with boolean phases are indistinguishable from measurement - outcome vertices. They are emitted as raw ``XPhase`` gates with a - symbolic ``Poly`` phase instead. The QASM round-trip (Circuit → - QASM string → Circuit) is unaffected. """ name = 'ConditionalGate' @@ -1562,7 +1558,14 @@ def reposition(self, mask, bit_mask = None): return g def to_graph_symbolic_boolean(self, g, q_mapper): - """Represent the measurement as a node with symbolic boolean phases.""" + """Represent the measurement as a Z spider with a symbolic-phase leaf. + + Places a Z(0) spider on the qubit wire and attaches the classical + outcome as a degree-1 X leaf carrying the symbolic boolean phase, + tagged with ``outcome_type='measurement'`` in vertex data. The + leaf is off-wire, so the qubit wire continues through the Z(0) + spider unaffected by the outcome. + """ r = q_mapper.next_row(self.target) if self.result_symbol is not None: symbol_name = self.result_symbol @@ -1571,13 +1574,21 @@ def to_graph_symbolic_boolean(self, g, q_mapper): else: symbol_name = "m{}".format(self.target) phase = new_var(name=symbol_name, is_bool=True, registry=g.var_registry) - _ = self.graph_add_node(g, + # Z(0) on the qubit wire: the measurement itself. + meas_v = self.graph_add_node(g, q_mapper, - VertexType.X, + VertexType.Z, self.target, - r, - phase=phase) - q_mapper.set_next_row(self.target, r+1) + r) + # X(phase) as a leaf: the classical outcome, branching off. + outcome_v = g.add_vertex(VertexType.X, + q_mapper.to_qubit(self.target), r + 0.5, phase) + g.add_edge((meas_v, outcome_v), EdgeType.SIMPLE) + g.set_vdata(outcome_v, 'outcome_type', 'measurement') + # Store the classical destination explicitly so it survives + # ``g.substitute_variables(...)`` unwrapping the Poly phase. + g.set_vdata(outcome_v, 'result_symbol', symbol_name) + q_mapper.set_next_row(self.target, r + 1) def to_graph(self, g, q_mapper, _c_mapper): self.to_graph_symbolic_boolean(g, q_mapper) diff --git a/pyzx/circuit/graphparser.py b/pyzx/circuit/graphparser.py index d5427ac8..0a930c15 100644 --- a/pyzx/circuit/graphparser.py +++ b/pyzx/circuit/graphparser.py @@ -19,7 +19,7 @@ from . import Circuit from .gates import (Gate, InitAncilla, Measurement, Reset, TargetMapper, - ConditionalGate, ZPhase, XPhase, NOT, Z, S, T) + ConditionalGate, ZPhase, XPhase, NOT, Z, S, T, SX) from ..utils import EdgeType, VertexType, FloatInt, FractionLike, settings from ..graph import Graph from ..graph.base import BaseGraph, VT, ET @@ -31,7 +31,7 @@ def _poly_phase_to_conditional_gate( """Try to convert a symbolic Poly phase into a ConditionalGate. The polynomial must consist entirely of boolean variables from the - same classical register (with names like ``reg[i]``). The function + same classical register (with names like ``reg[i]``). The function evaluates the polynomial at every possible bit assignment to find the unique assignment that produces a non-zero result, which gives both the condition value and the inner gate phase. @@ -41,10 +41,8 @@ def _poly_phase_to_conditional_gate( variables, complex coefficients, or more than one non-zero assignment (which would mean the phase is not a simple conditional). - This function is only called for Z-type vertices. X-type vertices - are skipped because their boolean phases are ambiguous with - measurement outcome vertices (see the call site in - ``graph_to_circuit``). + Both Z- and X-type vertices are supported as the conditional + inner gate. """ from fractions import Fraction from ..symbolic import Var @@ -116,9 +114,16 @@ def _poly_phase_to_conditional_gate( inner_gate = T(qubit, adjoint=True) else: inner_gate = ZPhase(qubit, inner_phase) + elif vertex_type == VertexType.X: + if inner_phase == 1: + inner_gate = NOT(qubit) + elif inner_phase == Fraction(1, 2): + inner_gate = SX(qubit) + elif inner_phase == Fraction(-1, 2) or inner_phase == Fraction(3, 2): + inner_gate = SX(qubit, adjoint=True) + else: + inner_gate = XPhase(qubit, inner_phase) else: - # X-type vertices are skipped at the call site because their - # boolean phases are ambiguous with measurement outcomes. raise ValueError( "Unsupported vertex type {} for conditional gate " "extraction.".format(vertex_type)) @@ -139,9 +144,33 @@ def graph_to_circuit(g:BaseGraph[VT,ET], split_phases:bool=True) -> Circuit: (VertexType.X, 0): '0', (VertexType.X, 1): '1', } - input_qubits = set(qs[v] for v in inputs) - c = Circuit(len(inputs)) + # ``g.inputs()``/``g.outputs()`` may include classical-bit + # boundaries in addition to quantum boundaries (see + # ``circuit_to_graph``), so counting boundaries directly would + # inflate the extracted qubit count. Derive the qubit count from + # the maximum qubit index used by either non-boundary vertices or + # non-classical boundary vertices, so identity-only quantum wires + # (with no on-wire vertices) are still counted when other wires + # contain gates. Boundaries tagged with ``is_classical`` (set by + # ``circuit_to_graph``) are excluded; internal vertices on the + # same qubit indices as classical boundaries (e.g. the ``Z``/``X`` + # pair from ``DiscardBit``) are also excluded so hybrid graphs do + # not inflate the qubit count. + classical_qubits = { + qs[v] for v in list(inputs) + list(g.outputs()) + if g.vdata(v, 'is_classical', False) + } + quantum_vertex_qubits = [ + qs[v] for v in g.vertices() + if ty[v] != VertexType.BOUNDARY and qs[v] not in classical_qubits + ] + quantum_boundary_qubits = [ + qs[v] for v in list(inputs) + list(g.outputs()) + if qs[v] not in classical_qubits + ] + all_quantum_qubits = quantum_vertex_qubits + quantum_boundary_qubits + c = Circuit(int(max(all_quantum_qubits)) + 1 if all_quantum_qubits else 0) for v in g.vertices(): if v in inputs: continue @@ -151,18 +180,20 @@ def graph_to_circuit(g:BaseGraph[VT,ET], split_phases:bool=True) -> Circuit: for r in sorted(rows.keys()): for v in rows[r]: q = qs[v] + if q in classical_qubits: + # Vertices on classical-bit wires (e.g. the ``Z``/``X`` + # pair from ``DiscardBit``) are out of scope for the + # quantum-gate extractor; skip them to avoid emitting + # spurious gates on non-existent qubit indices. + continue phase = phases[v] t = ty[v] neigh = [w for w in g.neighbors(v) if rs[w] Circuit: c.add_gate("HAD", q) if t == VertexType.BOUNDARY: #vertex is an output continue - if g.is_ground(v): - # Ground vertex: discard/trace-out, part of a Reset pair. - # (The corresponding Reset gate is emitted when the state - # preparation vertex on this qubit is processed.) + # Outcome / discard leaves are tagged in vertex data; + # check the tag first so the leaf is recognised even after + # ``g.substitute_variables(...)`` has unwrapped its Poly + # phase to a concrete Fraction/int. + outcome_type = g.vdata(v, 'outcome_type') + if outcome_type == 'reset_discard': + continue + if outcome_type == 'measurement': + # Prefer the classical destination stored in vdata so + # the round-trip is correct after the leaf's Poly phase + # has been substituted to a concrete value (in which + # case ``str(phase)`` would be e.g. ``'1'``). + result_symbol = g.vdata(v, 'result_symbol') + if result_symbol is None: + result_symbol = str(phase) + c.add_gate(Measurement(int(q), result_symbol=result_symbol)) continue if isinstance(phase, Poly): - # Only extract Z-type vertices as conditional gates. - # X-type vertices with boolean phases are ambiguous: - # measurement outcomes (from Measurement.to_graph_symbolic_boolean) - # produce the same X spider structure as conditional NOT/XPhase - # gates, so we cannot distinguish them here. Conditional - # Z rotations are unambiguous because measurements never - # create Z spiders with boolean phases. - cgate = None - if t == VertexType.Z: - cgate = _poly_phase_to_conditional_gate(phase, t, int(q)) + # Untagged Poly phase on the wire: conditional gate. + cgate = _poly_phase_to_conditional_gate(phase, t, int(q)) if cgate is not None: c.add_gate(cgate) elif phase != 0: @@ -236,25 +271,58 @@ def graph_to_circuit(g:BaseGraph[VT,ET], split_phases:bool=True) -> Circuit: def circuit_to_graph( - c: Circuit, + c: Circuit, compress_rows:bool=True, backend:Optional[str]=None, initialize_qubits:Optional[List[bool]]=None, - postselect_qubits:Optional[List[int]]=None + postselect_qubits:Optional[List[int]]=None, + elide_initial_resets:bool=False, ) -> BaseGraph[VT, ET]: """Turns the circuit into a ZX-Graph. If ``compress_rows`` is set, it tries to put single qubit gates on different qubits, on the same row. - ``initialize_qubits`` denotes whether each input should be connected to |0\ranlge, - ``postselect_qubits`` denotes for each measurement whether it should be - postselected to |0\rangle (0) or |1\ranlge (1).""" + ``initialize_qubits`` denotes whether each input should be connected to |0⟩, + ``postselect_qubits`` denotes for each measurement whether it should be + postselected to |0⟩ (0) or |1⟩ (1). + + ``elide_initial_resets`` (default False) skips emitting the discard + chain for a ``Reset`` whose preceding wire is still an unmodified + input boundary. Defaults to False because programmatically + constructed circuits may have uninitialized inputs, where a + leading ``Reset`` is not redundant (it traces out an unknown input + and prepares |0⟩). Set to True only when those inputs are already + represented in the PyZX model as explicit |0⟩ states, e.g. via + ``Circuit.initialize_qubits(...)`` / ``Graph.apply_state(...)`` or + equivalent initialization. This matches OpenQASM-style implicit + |0⟩ inputs; otherwise eliding changes semantics by leaving an open + input boundary connected and turning the leading ``Reset`` into a + no-op instead of trace-out-and-reprepare. When the precondition + holds, eliding avoids creating an ``input -> Z(0) -> X(_rN)`` + measurement-and-discard fragment plus a separate ``X(0)`` |0⟩ prep + per qubit.""" g = Graph(backend) q_mapper: TargetMapper[VT] = TargetMapper() c_mapper: TargetMapper[VT] = TargetMapper() inputs = [] outputs = [] measure_targets = set() + reset_count = 0 + + def _next_reset_var_name() -> str: + # Allocate the lowest ``_rN`` not already in the graph's + # variable registry. Scanning rather than blindly using + # ``reset_count`` avoids aliasing a user-defined symbolic + # phase that happens to share the ``_rN`` name (which would + # also break ``drop_orphan_reset_discards``, which assumes the + # reset variable is unique to its orphan leaf). + nonlocal reset_count + existing = g.var_registry.vars() + while "_r{}".format(reset_count) in existing: + reset_count += 1 + name = "_r{}".format(reset_count) + reset_count += 1 + return name # Create input vertices for i in range(c.qubits): @@ -265,6 +333,10 @@ def circuit_to_graph( for i in range(c.bits): qubit = i+c.qubits v = g.add_vertex(VertexType.BOUNDARY, qubit, 0) + # Tag classical-bit boundaries so ``graph_to_circuit`` can + # distinguish them from quantum input boundaries when deriving + # the qubit count. + g.set_vdata(v, 'is_classical', True) inputs.append(v) c_mapper.add_label(i, 1) c_mapper.set_qubit(i, qubit) @@ -285,22 +357,44 @@ def circuit_to_graph( q_mapper.set_prev_vertex(l, v) q_mapper.advance_next_row(l) elif gate.name == 'Reset': - # Model reset as discard (ground) then preparation |0⟩, - # creating a wire break. The ground connection traces out - # the qubit, modelling reset as a CPTP map. + # Model reset as an implicit measurement with a discarded + # outcome, followed by fresh |0⟩ preparation. The Z(0) + # spider + X(_rN) leaf mirrors the measurement pattern. + # The unused boolean variable gives trace-out semantics: + # summing over its two values is equivalent to discarding. l = gate.label # type: ignore q = q_mapper.to_qubit(l) # The qubit is being reused, so clear any pending measurement - # marker. If the qubit is measured again later without a + # marker. If the qubit is measured again later without a # subsequent reset, it will be re-added. measure_targets.discard(q) r = q_mapper.next_row(l) u = q_mapper.prev_vertex(l) - # Effect vertex: discard the old state by tracing out. - effect_v = g.add_vertex(VertexType.Z, q, r, 0, ground=True) - g.add_edge((u, effect_v), EdgeType.SIMPLE) - # State vertex: prepare the new |0⟩ state. + if (elide_initial_resets + and g.type(u) == VertexType.BOUNDARY + and u in inputs + and g.vertex_degree(u) == 0): + # Reset on a fresh qreg input wire is redundant under + # OpenQASM's "qreg implicitly |0⟩" semantics: the wire + # has no prior state to trace out. Skip emitting the + # discard chain and leave the input boundary as the + # prev_vertex, so the next gate connects to it directly. + continue + # Implicit measurement (discarded outcome). + reset_var = new_var( + name=_next_reset_var_name(), + is_bool=True, registry=g.var_registry) + meas_v = g.add_vertex(VertexType.Z, q, r, 0) + g.add_edge((u, meas_v), EdgeType.SIMPLE) + outcome_v = g.add_vertex(VertexType.X, q, r + 0.5, reset_var) + g.add_edge((meas_v, outcome_v), EdgeType.SIMPLE) + g.set_vdata(outcome_v, 'outcome_type', 'reset_discard') + # Disconnected X(0) leaf: fresh |0⟩ prep for the new wire + # segment. Tagged so graph_to_circuit can recognise it without + # colliding with InitAncilla('0') or hand-built X(0) state + # vertices. state_v = g.add_vertex(VertexType.X, q, r + 1, 0) + g.set_vdata(state_v, 'outcome_type', 'reset_state') q_mapper.set_prev_vertex(l, state_v) q_mapper.set_next_row(l, r + 2) elif gate.name == 'PostSelect': @@ -332,22 +426,42 @@ def circuit_to_graph( r = max(q_mapper.max_row(), c_mapper.max_row()) measure_vertices = [] for mapper in (q_mapper, c_mapper): + is_classical = mapper is c_mapper for l in mapper.labels(): o = mapper.to_qubit(l) u = mapper.prev_vertex(l) if o not in measure_targets: v = g.add_vertex(VertexType.BOUNDARY, o, r) + if is_classical: + g.set_vdata(v, 'is_classical', True) outputs.append(v) g.add_edge((u,v)) else: - measure_vertices.append(u) + # The on-wire vertex ``u`` is the Z(0) measurement + # spider; the symbolic outcome lives on its tagged + # X-leaf, which is what postselection needs to target. + # Fall back to ``u`` itself for legacy representations + # without the tagged leaf. + leaf = next( + (n for n in g.neighbors(u) + if g.vdata(n, 'outcome_type') == 'measurement'), + u) + measure_vertices.append(leaf) g.set_inputs(tuple(inputs)) g.set_outputs(tuple(outputs)) if initialize_qubits: - assert len(inputs) == len(initialize_qubits), "Length of init list must be equal to number of inputs!" + # ``inputs`` contains the ``c.qubits`` quantum inputs first, + # followed by ``c.bits`` classical-bit boundaries. Only the + # quantum prefix should be initialised, so the state string is + # padded with '/' for classical inputs (which ``apply_state`` + # treats as "leave as-is"); otherwise ``apply_state`` would + # drop the trailing classical boundaries from ``g.inputs()``. + assert len(initialize_qubits) == c.qubits, \ + "Length of init list must be equal to number of qubits!" state = "".join("0" if i else "/" for i in initialize_qubits) + state += "/" * c.bits g.apply_state(state) if postselect_qubits: diff --git a/pyzx/simplify.py b/pyzx/simplify.py index 012d4e49..c0d7a972 100644 --- a/pyzx/simplify.py +++ b/pyzx/simplify.py @@ -32,19 +32,21 @@ 'pivot_gadget_simp', 'pivot_boundary_simp', 'gadget_simp', 'lcomp_simp', 'clifford_simp', 'tcount', 'to_gh', 'to_rg', 'full_reduce', 'teleport_reduce', 'reduce_scalar', 'supplementarity_simp', - 'to_clifford_normal_form_graph', 'to_graph_like', 'is_graph_like', 'copy_simp'] + 'to_clifford_normal_form_graph', 'to_graph_like', 'is_graph_like', 'copy_simp', + 'drop_orphan_reset_discards'] -from typing import cast, Tuple, Dict, Set, Callable, TypeVar, Optional, Union +from typing import cast, List, Tuple, Dict, Set, Callable, TypeVar, Optional, Union from typing_extensions import Literal import itertools from .circuit import Circuit from .rewrite_rules import * from .rewrite import * +from .symbolic import Poly from .tensor import compare_tensors -from .utils import EdgeType, VertexType, toggle_edge, vertex_is_zx, phase_is_clifford +from .utils import EdgeType, VertexType, get_z_box_label, toggle_edge, vertex_is_zx, phase_is_clifford from .graph.base import BaseGraph, VT, ET MatchObject = TypeVar('MatchObject') @@ -271,6 +273,130 @@ def sanity_check(g1: BaseGraph[VT,ET], message: str): break print("done") +def drop_orphan_reset_discards(g: BaseGraph[VT,ET]) -> int: + """Remove orphan reset-discard components. + + A leading ``reset`` on a fresh ``qreg`` input emitted with + ``elide_initial_resets=False`` produces a disconnected component + rooted at an input boundary and terminating in a degree-1 leaf + tagged ``outcome_type='reset_discard'`` carrying a fresh boolean + phase ``_rN``. Two shapes are common: + + * ``BOUNDARY --S-- Z(0) --S-- X(_rN)`` straight out of + :func:`pyzx.circuit.graphparser.circuit_to_graph`. + * ``BOUNDARY --H-- Z(_rN)`` after :func:`full_reduce` (which + converts the X-leaf to a Z-leaf via Hadamard and removes the + identity Z(0) middle spider). + + More generally, the component must be a chain whose intermediate + vertices are phase-0, degree-2, untagged spiders. ``_rN`` must + appear nowhere else in the graph (no other vertex phases, no + Z-box labels, no scalar). Under those conditions, marginalising + over ``_rN`` collapses the component to a partial trace of the + input boundary, equivalent to the elided graph with |0⟩ applied + to that input up to a global scalar factor of ``1/sqrt(2)`` per + orphan, which is folded into ``g.scalar``. + + :param g: The graph to clean up. Modified in place. + :return: The number of orphan components removed. + """ + inputs_set = set(g.inputs()) + + def trace_chain(leaf: VT) -> Optional[List[VT]]: + """Walk from ``leaf`` outward through phase-0 degree-2 spiders. + Returns the chain ``[boundary, ..., leaf]`` if it terminates at + a degree-1 input boundary, or ``None`` otherwise.""" + if g.vertex_degree(leaf) != 1: + return None + chain: List[VT] = [leaf] + visited: Set[VT] = {leaf} + current = next(iter(g.neighbors(leaf))) + while True: + if current in visited: + return None + visited.add(current) + chain.append(current) + if g.type(current) == VertexType.BOUNDARY: + if current in inputs_set and g.vertex_degree(current) == 1: + chain.reverse() + return chain + return None + # Intermediate spider must be a clean identity link: + # phase 0, degree 2, no vdata tags. + if (g.vertex_degree(current) != 2 or g.phase(current) != 0 + or g.vdata_dict(current)): + return None + next_vs = [n for n in g.neighbors(current) if n not in visited] + if len(next_vs) != 1: + return None + current = next_vs[0] + + # Step 1: find candidate (chain, var_name) pairs from each tagged + # discard leaf with a ``1*_rN`` boolean phase. + candidates: List[Tuple[List[VT], str]] = [] + for v in g.vertices(): + if g.vdata(v, 'outcome_type') != 'reset_discard': + continue + phase = g.phase(v) + if not isinstance(phase, Poly) or len(phase.terms) != 1: + continue + coeff, term = phase.terms[0] + if coeff != 1 or len(term.vars) != 1: + continue + var, exponent = term.vars[0] + if exponent != 1 or not var.is_bool: + continue + chain = trace_chain(v) + if chain is None: + continue + candidates.append((chain, var.name)) + + if not candidates: + return 0 + + # Step 2: variable freshness. Each ``_rN`` must appear nowhere + # else: not on another vertex's phase, not on a Z-box label, and + # not in the scalar. + var_locations: Dict[str, Set[VT]] = {} + for v in g.vertices(): + p = g.phase(v) + if isinstance(p, Poly): + for fv in p.free_vars(): + var_locations.setdefault(fv.name, set()).add(v) + if g.type(v) == VertexType.Z_BOX: + label = get_z_box_label(g, v) + if isinstance(label, Poly): + for fv in label.free_vars(): + var_locations.setdefault(fv.name, set()).add(v) + scalar_var_names = {fv.name for fv in g.scalar.free_vars()} + + # Step 3: collect every vertex/var to drop in one pass, then mutate + # the graph in a single ``remove_vertices`` batch. This keeps the + # accumulated vertex IDs valid for the duration of the pass: + # backends that reindex on deletion (e.g. igraph) only renumber + # once the batch is committed. + remove_set: Set[VT] = set() + removed = 0 + for chain, var_name in candidates: + leaf = chain[-1] + if (var_locations.get(var_name, set()) != {leaf} + or var_name in scalar_var_names): + continue + remove_set.update(chain) + if var_name in g.var_registry.types: + del g.var_registry.types[var_name] + g.scalar.add_power(-1) + removed += 1 + + if remove_set: + # Filter inputs *before* removing vertices, while the IDs in + # ``g.inputs()`` are still valid. + new_inputs = tuple(v for v in g.inputs() if v not in remove_set) + g.remove_vertices(remove_set) + g.set_inputs(new_inputs) + return removed + + def teleport_reduce(g: BaseGraph[VT,ET]) -> BaseGraph[VT,ET]: """This simplification procedure runs :func:`full_reduce` in a way that does not change the graph structure of the resulting diagram. diff --git a/tests/__init__.py b/tests/__init__.py index 4c57953f..ce715ad6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ -# PyZX - Python library for quantum circuit rewriting +# PyZX - Python library for quantum circuit rewriting # and optimization using the ZX-calculus # Copyright (C) 2018 - Aleks Kissinger and John van de Wetering @@ -15,6 +15,52 @@ # limitations under the License. +# Shared test fixtures and helpers. + +# Steane X-stabiliser measurement circuit: 3 stabiliser rounds with +# mid-circuit resets after the first two measurements. +STEANE_X_STABILISER_QASM = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[8]; +creg c[3]; +h q[0]; +cx q[0], q[1]; cx q[0], q[2]; cx q[0], q[3]; cx q[0], q[4]; +h q[0]; +measure q[0] -> c[0]; +reset q[0]; +h q[0]; +cx q[0], q[1]; cx q[0], q[2]; cx q[0], q[5]; cx q[0], q[6]; +h q[0]; +measure q[0] -> c[1]; +reset q[0]; +h q[0]; +cx q[0], q[1]; cx q[0], q[3]; cx q[0], q[5]; cx q[0], q[7]; +h q[0]; +measure q[0] -> c[2]; +""" + + +def outcome_leaves(g, kind): + """Return vertices tagged with ``vdata('outcome_type') == kind``. + + ``kind`` is one of ``'reset_discard'``, ``'reset_state'``, or + ``'measurement'``. + """ + return [v for v in g.vertices() if g.vdata(v, 'outcome_type') == kind] + + +def discard_leaves(g): + return outcome_leaves(g, 'reset_discard') + + +def prep_leaves(g): + return outcome_leaves(g, 'reset_state') + + +def measurement_leaves(g): + return outcome_leaves(g, 'measurement') + if __name__ == '__main__': import unittest diff --git a/tests/test_extract.py b/tests/test_extract.py index c7165027..cc09a502 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -117,12 +117,19 @@ def test_extract_measurement_graph_raises(self): def test_extract_ground_vertex_raises(self): """extract_circuit should raise ValueError on graphs with ground vertices, even when input and output counts match.""" - c = Circuit(1) - c.add_gate(Reset(0)) - c.add_gate("HAD", 0) - g = c.to_graph() + import pyzx as zx + from pyzx.utils import VertexType, EdgeType + g = zx.Graph() + inp = g.add_vertex(VertexType.BOUNDARY, 0, 0) + out = g.add_vertex(VertexType.BOUNDARY, 0, 3) + z = g.add_vertex(VertexType.Z, 0, 1) + gnd = g.add_vertex(VertexType.Z, 0, 2, ground=True) + g.add_edge((inp, z), EdgeType.SIMPLE) + g.add_edge((z, gnd), EdgeType.SIMPLE) + g.add_edge((z, out), EdgeType.SIMPLE) + g.set_inputs((inp,)) + g.set_outputs((out,)) self.assertTrue(g.is_hybrid()) - simplify.full_reduce(g, quiet=True) with self.assertRaises(ValueError) as ctx: extract_circuit(g) self.assertIn("ground", str(ctx.exception)) diff --git a/tests/test_init_postselect.py b/tests/test_init_postselect.py index 15be2599..52d156e6 100644 --- a/tests/test_init_postselect.py +++ b/tests/test_init_postselect.py @@ -24,7 +24,9 @@ from pyzx.circuit import Circuit from pyzx.circuit.gates import InitAncilla, Measurement, PostSelect, Reset +from pyzx.symbolic import Poly from pyzx.utils import VertexType +from tests import discard_leaves, prep_leaves STATE_EXPECTED = { @@ -125,21 +127,40 @@ def test_reposition(self): self.assertEqual(g2.label, 5) def test_to_graph(self): - """Test that Reset on an existing qubit produces ground + state vertices.""" + """Test that Reset produces Z(0) + X(_r) discard leaf + X(0) prep.""" c = Circuit(1) c.add_gate(Reset(0)) - g = c.to_graph() - - # Effect vertex: Z spider connected to ground (discard). - ground_verts = list(g.grounds()) - self.assertEqual(len(ground_verts), 1) - self.assertEqual(g.type(ground_verts[0]), VertexType.Z) - self.assertEqual(g.phase(ground_verts[0]), 0) - - # State vertex: X spider phase 0 (|0⟩ preparation). - x_verts = [v for v in g.vertices() if g.type(v) == VertexType.X] - self.assertEqual(len(x_verts), 1) - self.assertEqual(g.phase(x_verts[0]), 0) + # Opt out of initial-reset elision so the discard chain is emitted. + g = c.to_graph(elide_initial_resets=False) + + # No ground vertices. + self.assertEqual(len(list(g.grounds())), 0) + + # Implicit measurement: Z(0) spider on the wire. + z_spiders = [v for v in g.vertices() + if g.type(v) == VertexType.Z + and v not in g.inputs() and v not in g.outputs()] + self.assertEqual(len(z_spiders), 1) + self.assertEqual(g.phase(z_spiders[0]), 0) + + # Discard outcome: X leaf with symbolic phase, tagged. + discards = discard_leaves(g) + self.assertEqual(len(discards), 1) + self.assertEqual(g.vertex_degree(discards[0]), 1) + self.assertIsInstance(g.phase(discards[0]), Poly) + self.assertTrue(str(g.phase(discards[0])).startswith("_r")) + + # |0⟩ prep: X(0) leaf, tagged. + preps = prep_leaves(g) + self.assertEqual(len(preps), 1) + self.assertEqual(g.phase(preps[0]), 0) + self.assertEqual(g.vertex_degree(preps[0]), 1) + + # No inner BOUNDARY vertices: prep is an on-wire X(0) leaf. + inner_boundaries = [v for v in g.vertices() + if g.type(v) == VertexType.BOUNDARY + and v not in g.inputs() and v not in g.outputs()] + self.assertEqual(inner_boundaries, []) class TestMeasurementEquality(unittest.TestCase): @@ -191,6 +212,26 @@ def test_to_graph(self): g = c.to_graph() self.assertGreater(g.num_vertices(), 0) + def test_initialize_qubits_with_classical_bits(self): + """``Circuit.initialize_qubits`` must apply only to quantum + inputs even when the circuit also has classical-bit boundaries. + + Regression test: ``circuit_to_graph`` previously asserted + ``len(g.inputs()) == len(initialize_qubits)``, which fails when + classical bits are appended to ``g.inputs()``. + """ + c = Circuit(2, bit_amount=1) + c.add_gate("HAD", 0) + c.add_gate("CNOT", 0, 1) + c.initialize_qubits([True, True]) + g = c.to_graph() + + # Quantum input boundaries are consumed by apply_state; the + # classical-bit boundary remains as an input. + self.assertEqual(len(g.inputs()), 1) + remaining = g.inputs()[0] + self.assertTrue(g.vdata(remaining, 'is_classical', False)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_qasm.py b/tests/test_qasm.py index a6423f3e..1376cd6d 100644 --- a/tests/test_qasm.py +++ b/tests/test_qasm.py @@ -24,10 +24,18 @@ if __name__ == '__main__': sys.path.append('..') sys.path.append('.') -from pyzx.simplify import full_reduce +from pyzx.simplify import full_reduce, drop_orphan_reset_discards from pyzx.extract import extract_circuit from pyzx.circuit import Circuit +from pyzx.symbolic import Poly +from pyzx.utils import VertexType from fractions import Fraction +from tests import ( + STEANE_X_STABILISER_QASM, + discard_leaves, + prep_leaves, + measurement_leaves, +) np: Optional[ModuleType] try: @@ -411,25 +419,23 @@ def test_reset_openqasm3(self): self.assertEqual(c.gates[0].target, 1) def test_reset_to_graph(self): - """Test that reset creates appropriate vertices when converted to graph.""" - from pyzx.utils import VertexType + """Smoke test: the QASM ``reset`` parser path emits the same + discard+prep structure as the ``Reset`` gate API. + + Detailed structural assertions live in + ``test_init_postselect.TestReset.test_to_graph``; this test + only verifies the QASM entry point produces a discard leaf + and a prep leaf. + """ c = Circuit.from_qasm(""" OPENQASM 2.0; include "qelib1.inc"; qreg q[1]; reset q[0]; """) - g = c.to_graph() - - # Effect vertex: Z spider connected to ground (discard). - ground_verts = list(g.grounds()) - self.assertEqual(len(ground_verts), 1) - self.assertEqual(g.type(ground_verts[0]), VertexType.Z) - - # State vertex: X spider phase 0 (|0⟩ preparation). - x_vertices = [v for v in g.vertices() if g.type(v) == VertexType.X] - self.assertEqual(len(x_vertices), 1) - self.assertEqual(g.phase(x_vertices[0]), 0) + g = c.to_graph(elide_initial_resets=False) + self.assertEqual(len(discard_leaves(g)), 1) + self.assertEqual(len(prep_leaves(g)), 1) def test_reset_to_graph_has_output(self): """Test that a qubit has an output boundary after reset.""" @@ -444,6 +450,266 @@ def test_reset_to_graph_has_output(self): self.assertEqual(len(g.inputs()), 1) self.assertEqual(len(g.outputs()), 1) + def test_elide_initial_resets_toggle(self): + """``elide_initial_resets`` controls emission of the discard + chain for leading resets on unmodified input wires. + + With elision off, both reset-discard and reset-state leaves + are emitted (one of each per leading reset). With elision on, + neither appears, no ``_rN`` symbolic phases survive anywhere, + and inputs are preserved with no extra "inner" boundaries. + + This is a structural test only: it does not exercise the + OpenQASM-style implicit |0⟩ semantics that justify enabling + the flag (which would require ``Circuit.initialize_qubits`` / + ``Graph.apply_state`` to fix the input as |0⟩). + """ + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + reset q[0]; + reset q[1]; + h q[0]; + """) + g_keep = c.to_graph(elide_initial_resets=False) + g_elide = c.to_graph(elide_initial_resets=True) + + # Elision off: one discard + one prep leaf per leading reset. + self.assertEqual(len(discard_leaves(g_keep)), 2) + self.assertEqual(len(prep_leaves(g_keep)), 2) + + # Elision on: no chain leaves, no `_rN` phases, fewer vertices. + self.assertLess(g_elide.num_vertices(), g_keep.num_vertices()) + self.assertEqual(discard_leaves(g_elide), []) + self.assertEqual(prep_leaves(g_elide), []) + for v in g_elide.vertices(): + p = g_elide.phase(v) + if isinstance(p, Poly): + self.assertNotIn('_r', str(p)) + + # Inputs are preserved with no inner BOUNDARY vertices. + self.assertEqual(len(g_elide.inputs()), 2) + boundaries = [v for v in g_elide.vertices() + if g_elide.type(v) == VertexType.BOUNDARY] + self.assertEqual(len(boundaries), + len(g_elide.inputs()) + len(g_elide.outputs())) + + def test_initial_reset_elided_mid_circuit_kept(self): + """With ``elide_initial_resets=True`` the initial reset is elided, + but a subsequent mid-circuit reset (after intervening gates) + still emits its ``_rN`` discard leaf.""" + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + reset q[0]; + h q[0]; + reset q[0]; + """) + g = c.to_graph(elide_initial_resets=True) + + # Exactly one reset-discard leaf survives (the mid-circuit one). + self.assertEqual(len(discard_leaves(g)), 1) + + def test_drop_orphan_reset_discards_removes_leading_chains(self): + """``drop_orphan_reset_discards`` removes the + ``boundary -- Z(0) -- X(_rN)`` orphan components that + ``elide_initial_resets=False`` leaves behind.""" + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + reset q[0]; + reset q[1]; + reset q[2]; + h q[0]; + cx q[0], q[1]; + cx q[0], q[2]; + """) + g = c.to_graph(elide_initial_resets=False) + before_inputs = len(g.inputs()) + self.assertEqual(len(discard_leaves(g)), 3) + + removed = drop_orphan_reset_discards(g) + self.assertEqual(removed, 3) + self.assertEqual(len(g.inputs()), before_inputs - 3) + + # No reset-discard leaves left. + self.assertEqual(discard_leaves(g), []) + # No symbolic _rN phases left anywhere. + for v in g.vertices(): + p = g.phase(v) + if isinstance(p, Poly): + self.assertNotIn('_r', str(p)) + # The associated _rN names are also gone from the registry. + self.assertFalse( + any(name.startswith('_r') for name in g.var_registry.vars())) + + def test_drop_orphan_reset_discards_matches_apply_state(self): + """After cleanup, the non-elided graph's tensor matches the + elided graph with |0⟩ applied to each leading-reset input, + including the global scalar.""" + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + reset q[0]; + reset q[1]; + h q[0]; + cx q[0], q[1]; + """) + g_keep = c.to_graph(elide_initial_resets=False) + drop_orphan_reset_discards(g_keep) + + g_elide = c.to_graph(elide_initial_resets=True) + g_elide.apply_state('00') + + self.assertTrue(compare_tensors(g_keep, g_elide, preserve_scalar=True)) + + def test_drop_orphan_reset_discards_preserves_mid_circuit(self): + """Mid-circuit reset discards are not orphans and must be kept.""" + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + reset q[0]; + h q[0]; + reset q[0]; + h q[0]; + """) + g = c.to_graph(elide_initial_resets=False) + removed = drop_orphan_reset_discards(g) + # Only the leading reset is an orphan; the mid-circuit reset is + # connected to the live wire and must remain. + self.assertEqual(removed, 1) + self.assertEqual(len(discard_leaves(g)), 1) + + def test_drop_orphan_reset_discards_no_op_when_elided(self): + """Cleanup is a no-op when there are no orphan components.""" + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + reset q[0]; + reset q[1]; + h q[0]; + cx q[0], q[1]; + """) + g = c.to_graph(elide_initial_resets=True) + before = (g.num_vertices(), g.num_edges(), len(g.inputs())) + removed = drop_orphan_reset_discards(g) + after = (g.num_vertices(), g.num_edges(), len(g.inputs())) + self.assertEqual(removed, 0) + self.assertEqual(before, after) + + def test_drop_orphan_reset_discards_keeps_var_used_in_z_box_label(self): + """If a ``_rN`` is also referenced by a Z-box label, the orphan + must be preserved so the variable is not deleted from the + registry while still in use.""" + from pyzx.symbolic import Term + from pyzx.utils import set_z_box_label + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + reset q[0]; + h q[0]; + """) + g = c.to_graph(elide_initial_resets=False) + # Find the orphan's `_rN` Var and reuse it on a Z_BOX label. + discards = discard_leaves(g) + self.assertEqual(len(discards), 1) + phase = g.phase(discards[0]) + assert isinstance(phase, Poly) + orphan_var = next(iter(phase.free_vars())) + zbox = g.add_vertex(VertexType.Z_BOX, 0, 100) + set_z_box_label(g, zbox, Poly([(1, Term([(orphan_var, 1)]))])) + + before_vars = set(g.var_registry.vars()) + removed = drop_orphan_reset_discards(g) + self.assertEqual(removed, 0) + self.assertIn(orphan_var.name, g.var_registry.vars()) + self.assertEqual(set(g.var_registry.vars()), before_vars) + + def test_drop_orphan_reset_discards_after_full_reduce_steane(self): + """End-to-end: cleanup must work after ``full_reduce`` on the + Steane X-stabiliser circuit. + + ``full_reduce`` rewrites the leading + ``boundary -- Z(0) -- X(_rN)`` orphan chain into + ``boundary -[H]- Z(_rN)`` (X-leaf flipped to Z-leaf, Z(0) + identity removed). The matcher must recognise this collapsed + shape, otherwise the orphans remain after cleanup. Mid-circuit + reset discards (already absorbed by ``full_reduce`` into the + live graph) must not be touched. + + Equivalence is asserted by comparing the post-``full_reduce`` + cleanup against running the cleanup before ``full_reduce``, + with classical-outcome variables frozen to ``0`` so the result + is a numerical tensor. + """ + # Prepend leading resets on all 8 qubits to the shared Steane + # X-stabiliser fixture (which itself emits no leading resets). + steane_qasm = """ + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[8]; + creg c[3]; + reset q[0]; reset q[1]; reset q[2]; reset q[3]; + reset q[4]; reset q[5]; reset q[6]; reset q[7]; + """ + "\n".join( + line for line in STEANE_X_STABILISER_QASM.splitlines() + if line.strip() and not line.strip().startswith(( + 'OPENQASM', 'include', 'qreg', 'creg')) + ) + c = Circuit.from_qasm(steane_qasm) + + # Chain emitted, full_reduce, then cleanup. + g_after = c.to_graph(elide_initial_resets=False) + full_reduce(g_after) + # 8 leading reset_discard leaves survive full_reduce; the 3 + # mid-circuit ones are absorbed by full_reduce. + self.assertEqual(len(discard_leaves(g_after)), 8) + removed = drop_orphan_reset_discards(g_after) + self.assertEqual(removed, 8, + "cleanup must remove all 8 leading orphans after full_reduce") + self.assertEqual(discard_leaves(g_after), []) + # 8 leading qreg input boundaries gone; 3 classical-bit inputs remain. + self.assertEqual(len(g_after.inputs()), 3) + # All ``_rN`` variables associated with the leading resets must be + # gone from the registry; any ``_rN`` absorbed into nearby phases + # by full_reduce stays in the registry but never appears as a + # discard leaf. + leading_var_names = {f'_r{i}' for i in range(8)} + self.assertFalse( + leading_var_names.intersection(g_after.var_registry.vars())) + + # Cleanup before full_reduce: must produce the same tensor. + g_before = c.to_graph(elide_initial_resets=False) + drop_orphan_reset_discards(g_before) + full_reduce(g_before) + + # Freeze classical outcome variables so tensors are numerical. + def freeze_classical(g): + var_map = {} + for v in g.vertices(): + p = g.phase(v) + if isinstance(p, Poly): + for fv in p.free_vars(): + var_map[fv.name] = Fraction(0) + for fv in g.scalar.free_vars(): + var_map[fv.name] = Fraction(0) + if var_map: + g.substitute_variables(var_map, in_place=True) + + freeze_classical(g_after) + freeze_classical(g_before) + self.assertTrue( + compare_tensors(g_after, g_before, preserve_scalar=True), + "post-full_reduce cleanup must produce same tensor as " + "pre-full_reduce cleanup") + def test_measure_reset_has_output(self): """Test that a qubit has an output after measure followed by reset.""" c = Circuit.from_qasm(""" @@ -543,6 +809,241 @@ def test_graph_to_circuit_reset(self): self.assertEqual(len(reset_gates), 1) self.assertEqual(reset_gates[0].target, 0) + def test_reset_circuit_tensorize(self): + """Tensor extraction must produce ``H |0⟩⟨0| H`` for + ``HAD - Reset - HAD`` once the reset's ``_rN`` is fixed to 0. + + Substituting ``_r=0`` selects the |0⟩-outcome branch, so the + whole circuit collapses to a rank-1 outer product proportional + to |+⟩⟨+|; pyzx's tensor convention leaves the ``[[1,1],[1,1]]`` + scalar. + """ + from pyzx.circuit.gates import Reset, HAD + c = Circuit(1) + c.gates = [HAD(0), Reset(0), HAD(0)] + g = c.to_graph() + # tensorfy rejects symbolic phases, so substitute the reset's + # _rN boolean to a concrete value first. + for v in list(g.vertices()): + p = g.phase(v) + if hasattr(p, 'terms'): + g.set_phase(v, Fraction(0)) + t = g.to_tensor() + expected = np.array([[1, 1], [1, 1]], dtype=complex) + np.testing.assert_allclose(t, expected, atol=1e-9) + + + def test_measure_reset_graph_round_trip(self): + """Test that measure+reset survives a circuit-graph-circuit round-trip.""" + from pyzx.circuit.gates import Measurement, Reset + from pyzx.circuit.graphparser import graph_to_circuit + c1 = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[1]; + h q[0]; + cx q[0], q[1]; + measure q[0] -> c[0]; + reset q[0]; + h q[0]; + """) + g = c1.to_graph() + + # Verify vdata tags on the intermediate graph. + self.assertEqual(len(measurement_leaves(g)), 1) + self.assertEqual(len(discard_leaves(g)), 1) + + c2 = graph_to_circuit(g) + + # Classical-bit boundaries must not inflate the qubit count. + self.assertEqual(c2.qubits, c1.qubits) + + measurements = [gt for gt in c2.gates if isinstance(gt, Measurement)] + resets = [gt for gt in c2.gates if isinstance(gt, Reset)] + self.assertEqual(len(measurements), 1) + self.assertEqual(measurements[0].target, 0) + self.assertEqual(measurements[0].result_symbol, "c[0]") + self.assertEqual(len(resets), 1) + self.assertEqual(resets[0].target, 0) + + def test_empty_circuit_with_creg_qubit_count(self): + """An empty circuit with both ``qreg`` and ``creg`` round-trips + without inflating the extracted qubit count. + + With no non-boundary vertices, ``graph_to_circuit`` falls back + to counting input boundaries; classical-bit boundaries tagged + via ``vdata('is_classical')`` must be excluded. + """ + from pyzx.circuit.graphparser import graph_to_circuit + c1 = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[3]; + """) + g = c1.to_graph() + c2 = graph_to_circuit(g) + self.assertEqual(c2.qubits, c1.qubits) + + def test_identity_wire_qubit_count(self): + """A circuit with an identity-only quantum wire round-trips + without dropping the unused qubit. + + ``graph_to_circuit`` must consider non-classical boundary + vertices when deriving the qubit count; otherwise an untouched + highest-index qubit (e.g. ``q[1]`` in a 2-qubit circuit with a + single gate on ``q[0]``) would be missing from the extracted + circuit. + """ + from pyzx.circuit.graphparser import graph_to_circuit + c1 = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + rx(pi/2) q[0]; + """) + g = c1.to_graph() + c2 = graph_to_circuit(g) + self.assertEqual(c2.qubits, c1.qubits) + + def test_reset_var_avoids_existing_name(self): + """``circuit_to_graph`` must allocate ``_rN`` reset variable + names that do not collide with names already in the graph's + variable registry. + + If the circuit already contains a measurement or other phase + using ``_r0``, the reset variable counter must skip past it; + otherwise the reset outcome would alias the existing variable, + change semantics, and confuse + :func:`drop_orphan_reset_discards`, which assumes the reset + variable is unique to its orphan leaf. + """ + from pyzx.circuit.gates import Measurement, Reset + from pyzx.circuit.graphparser import circuit_to_graph + # A measurement registers its ``result_symbol`` as a Boolean + # variable in ``g.var_registry``. Pick the same name the reset + # counter would otherwise use first, to prove the counter + # skips past it. + c = Circuit(1, bit_amount=1) + c.gates = [ + Measurement(0, result_symbol='_r0'), + Reset(0), + ] + g = circuit_to_graph(c) + names = g.var_registry.vars() + self.assertIn('_r0', names) + self.assertIn('_r1', names) + # The reset-discard leaf phase must be ``_r1``, not ``_r0``. + from tests import discard_leaves + leaves = discard_leaves(g) + self.assertEqual(len(leaves), 1) + leaf_phase = g.phase(leaves[0]) + self.assertEqual(str(leaf_phase), '_r1', + "reset variable aliased the existing _r0 phase") + + def test_discardbit_does_not_inflate_qubit_count(self): + """A circuit with ``DiscardBit`` (which creates Z/X internal + vertices on the classical-bit wire) must round-trip without + inflating the extracted qubit count, and must not emit + spurious quantum gates on the classical-wire qubit indices. + """ + from pyzx.circuit.gates import DiscardBit, InitAncilla + from pyzx.circuit.graphparser import graph_to_circuit + c1 = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[1]; + h q[0]; + measure q[0] -> c[0]; + """) + c1.gates.append(DiscardBit(0)) + g = c1.to_graph() + c2 = graph_to_circuit(g) + self.assertEqual(c2.qubits, c1.qubits, + "DiscardBit's classical-wire vertices inflated qubit count") + # Should not emit InitAncilla on a classical-wire qubit index. + bogus = [gt for gt in c2.gates + if isinstance(gt, InitAncilla) and gt.label >= c1.qubits] + self.assertEqual(bogus, [], + "DiscardBit's classical-wire X(0) was mis-extracted as a " + "quantum InitAncilla") + + def test_postselect_qubits_targets_outcome_leaf(self): + """``postselect_qubits`` must fix the symbolic measurement + outcome by setting the phase on the X-leaf (where the outcome + symbol lives), not on the on-wire Z(0) measurement spider.""" + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + h q[0]; + measure q[0] -> c[0]; + """) + c.postselect_qubits([1]) + g = c.to_graph() + + # The on-wire Z(0) measurement spider must keep phase 0; + # the postselect value lives on the tagged X-leaf. + leaves = measurement_leaves(g) + self.assertEqual(len(leaves), 1) + leaf = leaves[0] + self.assertEqual(g.phase(leaf), 1, + "postselect_qubits did not fix the X-leaf outcome phase") + # The leaf's on-wire Z(0) parent should still be Z(0), not Z(1). + z_parents = [n for n in g.neighbors(leaf) + if g.type(n) == VertexType.Z] + self.assertEqual(len(z_parents), 1) + self.assertEqual(g.phase(z_parents[0]), 0, + "postselect_qubits incorrectly set the on-wire Z spider phase") + + def test_outcome_tags_respected_after_substitution(self): + """Tagged outcome leaves round-trip even after the Poly phase + on the leaf has been substituted to a concrete value. + + ``graph_to_circuit`` must consult the ``outcome_type`` vdata + before falling back to phase-shape extraction; otherwise a + substituted ``_rN=1`` reset-discard leaf would be mis-extracted + as a ``NOT`` gate, and a substituted measurement leaf would + stop round-tripping as ``Measurement``. + """ + from pyzx.circuit.gates import Measurement, Reset + from pyzx.circuit.graphparser import graph_to_circuit + c1 = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + h q[0]; + measure q[0] -> c[0]; + reset q[0]; + h q[0]; + """) + g = c1.to_graph() + + # Substitute every symbolic phase to 1, so reset_discard and + # measurement leaves both have phase 1 (which would otherwise + # match the X(1) -> NOT extraction rule). + for v in list(g.vertices()): + p = g.phase(v) + if hasattr(p, 'terms'): + g.set_phase(v, Fraction(1)) + + c2 = graph_to_circuit(g) + # No spurious NOT gate from the substituted leaves. + not_gates = [gt for gt in c2.gates if type(gt).__name__ == 'NOT'] + self.assertEqual(not_gates, [], + "tagged leaves were mis-extracted as NOT after substitution") + # Reset and Measurement still recovered with the correct + # classical destination (read from vdata, not from the + # substituted phase, which would otherwise yield "1"). + self.assertEqual( + len([gt for gt in c2.gates if isinstance(gt, Reset)]), 1) + measurements = [gt for gt in c2.gates if isinstance(gt, Measurement)] + self.assertEqual(len(measurements), 1) + self.assertEqual(measurements[0].result_symbol, "c[0]") def test_issue_345_circuit1_measure_reset(self): """End-to-end test for issue #345 circuit 1 (Steane code with reset). @@ -1013,6 +1514,30 @@ def test_conditional_gate_graph_extraction(self): self.assertEqual(cond_gates[0].condition_register, "c") self.assertEqual(cond_gates[0].condition_value, 1) + def test_conditional_gate_x_type_graph_extraction(self): + """Test that conditional X-type gates (NOT, XPhase) round-trip through the graph.""" + from pyzx.circuit.gates import ConditionalGate, NOT, XPhase + from pyzx.circuit.graphparser import graph_to_circuit + # Conditional NOT: phase 1 should recover as NOT. + c1 = Circuit(1) + c1.gates = [ConditionalGate("c", 1, NOT(0), 1)] + g = c1.to_graph() + c2 = graph_to_circuit(g) + cond_gates = [gt for gt in c2.gates if isinstance(gt, ConditionalGate)] + self.assertEqual(len(cond_gates), 1) + self.assertEqual(cond_gates[0].condition_register, "c") + self.assertEqual(cond_gates[0].condition_value, 1) + self.assertIsInstance(cond_gates[0].inner_gate, NOT) + # Conditional XPhase with a non-Clifford phase falls back to XPhase. + c3 = Circuit(1) + c3.gates = [ConditionalGate("c", 1, XPhase(0, Fraction(1, 4)), 1)] + g = c3.to_graph() + c4 = graph_to_circuit(g) + cond_gates = [gt for gt in c4.gates if isinstance(gt, ConditionalGate)] + self.assertEqual(len(cond_gates), 1) + self.assertIsInstance(cond_gates[0].inner_gate, XPhase) + self.assertEqual(cond_gates[0].inner_gate.phase, Fraction(1, 4)) + def test_qec_measure_conditional_correction(self): """End-to-end test: measure + conditional Pauli correction (QEC pattern).""" from pyzx.circuit.gates import ConditionalGate, Measurement diff --git a/tests/test_simplify.py b/tests/test_simplify.py index e7c13c11..b5d902e1 100644 --- a/tests/test_simplify.py +++ b/tests/test_simplify.py @@ -29,12 +29,14 @@ from pyzx.graph import Graph from pyzx.circuit import Circuit from pyzx.circuit.qasmparser import qasm +from pyzx.symbolic import Poly from fractions import Fraction from pyzx.generate import cliffordT from pyzx.simplify import * from pyzx.simplify import supplementarity_simp, to_clifford_normal_form_graph, copy_simp from pyzx import compare_tensors from pyzx.generate import cliffordT +from tests import STEANE_X_STABILISER_QASM np: Optional[ModuleType] try: @@ -248,6 +250,100 @@ def test_copy_simp_full_reduce(self): self.assertTrue(g.num_vertices() == g1.num_vertices()) self.assertTrue(compare_tensors(g1.to_tensor(),g.to_tensor())) + def test_measurement_outcomes_survive_reduction(self): + """Symbolic measurement outcomes must survive full_reduce. + + Regression test for tqec/tqec#528. In a circuit with mid-circuit + resets, the measurement outcome must be on a separate leaf off the + qubit wire so that the subsequent reset traces out only the + post-measurement quantum state, not the classical result. + """ + c = Circuit.from_qasm(STEANE_X_STABILISER_QASM) + g = c.to_graph() + full_reduce(g) + + # Collect measurement-outcome vertices by phase label, keeping + # all matches so duplicate labels can be detected. Filter to + # ``Poly`` phases since numeric phases (``int``/``Fraction``) + # introduced by ``full_reduce`` would otherwise be misclassified + # as outcomes; reset variables (``_rN``) are also excluded. + outcome_verts: dict = {} + for v in g.vertices(): + p = g.phase(v) + if not isinstance(p, Poly): + continue + ps = str(p) + if ps.startswith('_r'): + continue + outcome_verts.setdefault(ps, []).append(v) + + for label in ('c[0]', 'c[1]', 'c[2]'): + self.assertEqual(len(outcome_verts.get(label, [])), 1, + f"expected exactly one outcome vertex for {label}, " + f"got {len(outcome_verts.get(label, []))}") + self.assertEqual(sorted(outcome_verts.keys()), ['c[0]', 'c[1]', 'c[2]'], + "full_reduce destroyed measurement outcome phases") + + # Verify stabiliser connectivity: each outcome spider should + # be connected (via Z neighbours) to exactly the data qubits + # of the corresponding Steane X-stabiliser. + expected_data_qubits = { + 'c[0]': {1, 2, 3, 4}, + 'c[1]': {1, 2, 5, 6}, + 'c[2]': {1, 3, 5, 7}, + } + output_qubit = {v: int(g.qubit(v)) for v in g.outputs()} + for label, vs in outcome_verts.items(): + v = vs[0] + data_qubits = set() + for n in g.neighbors(v): + if g.type(n) == VertexType.BOUNDARY and n in g.inputs(): + continue # Ancilla input, not a data qubit. + # Follow through to the output boundary to find the qubit. + for nn in g.neighbors(n): + if nn in output_qubit: + data_qubits.add(output_qubit[nn]) + self.assertEqual(data_qubits, expected_data_qubits[label], + f"{label} stabiliser has wrong data-qubit connectivity") + + def test_measurement_outcomes_survive_minimal_ancilla(self): + """Minimal measure-reset-measure pattern keeps both outcomes. + + Two-qubit reduction of tqec/tqec#528: ``q[0]`` is reused as a + parity-check ancilla against the data qubit ``q[1]``, so both + outcomes are entangled with the data wire and must survive + ``full_reduce``. + """ + c = Circuit.from_qasm(""" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + h q[0]; + cx q[0], q[1]; + measure q[0] -> c[0]; + reset q[0]; + h q[0]; + cx q[0], q[1]; + measure q[0] -> c[1]; + """) + g = c.to_graph() + full_reduce(g) + + # Both c[0] and c[1] must appear in a remaining symbolic phase + # (possibly XOR-combined in a single Poly, e.g. ``c[0] + c[1]``, + # since the CNOT couples the outcomes). Filter to ``Poly`` + # phases to avoid misclassifying numeric ``Fraction`` phases as + # outcomes; reset variables (``_rN``) are also excluded. + joined = " | ".join( + str(g.phase(v)) for v in g.vertices() + if isinstance(g.phase(v), Poly) + and not str(g.phase(v)).startswith('_r') + ) + self.assertIn('c[0]', joined, + "full_reduce destroyed c[0] outcome phase") + self.assertIn('c[1]', joined, + "full_reduce destroyed c[1] outcome phase") qasm_1 = """OPENQASM 2.0;