diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c547ade..d87975e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #556, #563: Added a context variable `default_output_format` to unify the default output format for displaying `PauliFlow`, `XZCorrections`, `Pattern`, and `StateVec`. +- #568: + - Added the following methods to `Pattern`. By default, they modify the pattern in place, with an optional `copy` parameter. If `copy=True`, a modified copy is returned instead. + - `apply`, an in-place variant of `map`, + - `blochify`, an in-place variant of `to_bloch`. + - All classes that have `subs` and `xreplace` methods (`Pattern`, `Circuit`, `OpenGraph`, `Measurement`, `PauliFlow`, `XZCorrections`, `DensityMatrix`) now derive from the `Parameterizable` class, which exposes the method `with_parameter` and `with_parameters`, with `subs` and `xreplace` as their respective aliases. + - `Pattern` and `Circuit` now derive from the `InplaceParameterizable` subclass, which additionally provides the following methods: + - `replace_parameter`, an in-place variant of `with_parameter` (or `subs`), + - `replace_parameters`, an in-place variant of `with_parameters` (or `xreplace`). + ### Fixed - #454, #481: Ensure `Pattern.minimize_space` only reduces max-space and does not increase it. @@ -128,6 +137,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dropped support for symbolic simulation and removed `Statevector.xreplace` and `Statevector.subs` methods. - Added method `DenseState.project_qubit` which defaults to calling `evolve_single` and `remove_qubit`. Added specialized code for this method in `Statevector` class. - Unified `test_statevec.py` and `test_statevec_backend.py` into a single file. + +- #568: Method `Pattern.infer_pauli_measurements` and function `transpile_swaps` now operate in place by default, with an optional `copy` parameter. If `copy=True`, a modified copy is returned instead. ## [0.3.5] - 2026-03-26 diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 9c666516d..3f113389c 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -53,9 +53,4 @@ Graphix does not guarantee backwards compatibility between 0.x version releases. | `MatGF2.compute_rank` | `MatGF2.rank` | | `Statevec` | `Statevector` | - - - - - - +- #568: Method `Pattern.infer_pauli_measurements` and function `transpile_swaps` now operate in place by default, with an optional `copy` parameter. If `copy=True`, a modified copy is returned instead. diff --git a/examples/deutsch_jozsa.py b/examples/deutsch_jozsa.py index 65bf38ddd..10a0bd329 100644 --- a/examples/deutsch_jozsa.py +++ b/examples/deutsch_jozsa.py @@ -73,7 +73,7 @@ # %% # Now we preprocess all Pauli measurements, which requires that we move inputs to N commands -pattern = pattern.infer_pauli_measurements() +pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() print( pattern.to_ascii( diff --git a/examples/mbqc_vqe.py b/examples/mbqc_vqe.py index 6f991bb20..e3e44e5c7 100644 --- a/examples/mbqc_vqe.py +++ b/examples/mbqc_vqe.py @@ -92,7 +92,7 @@ def build_mbqc_pattern(self, params: Iterable[ParameterizedAngle]) -> Pattern: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.minimize_space() return pattern diff --git a/examples/qaoa.py b/examples/qaoa.py index f328f6f6b..7bf0ba516 100644 --- a/examples/qaoa.py +++ b/examples/qaoa.py @@ -40,7 +40,7 @@ # %% # perform Pauli measurements and plot the new (minimal) graph to perform the same quantum computation -pattern = pattern.infer_pauli_measurements() +pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.draw(flow_from_pattern=False) diff --git a/examples/qft_with_tn.py b/examples/qft_with_tn.py index a66383f71..e63459e14 100644 --- a/examples/qft_with_tn.py +++ b/examples/qft_with_tn.py @@ -67,7 +67,7 @@ def qft(circuit: Circuit, n: int) -> None: # Using graph rewriting rules, we can classically preprocess Pauli measurements. # We are currently improving the speed of this process by using rust-based graph manipulation backend. pattern.remove_input_nodes() -pattern = pattern.infer_pauli_measurements() +pattern.infer_pauli_measurements() pattern.remove_pauli_measurements(standardize=True) diff --git a/examples/tn_simulation.py b/examples/tn_simulation.py index b11457009..86ee1e95a 100644 --- a/examples/tn_simulation.py +++ b/examples/tn_simulation.py @@ -85,7 +85,7 @@ def ansatz( # %% # Optimizing by removing Pauli measurements in the pattern. pattern.remove_input_nodes() -pattern = pattern.infer_pauli_measurements() +pattern.infer_pauli_measurements() pattern.remove_pauli_measurements(standardize=True) # %% @@ -204,7 +204,7 @@ def cost( pattern.standardize() pattern.shift_signals() pattern.remove_input_nodes() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements(standardize=True) mbqc_tn = pattern.simulate(backend="tensornetwork", graph_prep="parallel") exp_val: float = 0 diff --git a/examples/visualization.py b/examples/visualization.py index 205b12040..67a97ac2a 100644 --- a/examples/visualization.py +++ b/examples/visualization.py @@ -37,7 +37,7 @@ # %% # next, show the gflow: -pattern = pattern.infer_pauli_measurements() +pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.draw(flow_from_pattern=False, measurement_labels=True) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 65ee725a0..1cea8c47b 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -47,6 +47,7 @@ ) from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.measurements import Measurement +from graphix.parameter import Parameterizable from graphix.pretty_print import OutputFormat, flow_to_str, xzcorr_to_str if TYPE_CHECKING: @@ -74,7 +75,7 @@ @dataclass(frozen=True) -class XZCorrections(Generic[_AM_co]): +class XZCorrections(Parameterizable, Generic[_AM_co]): """An unmutable dataclass providing a representation of XZ-corrections. Attributes @@ -518,7 +519,10 @@ def draw(self, **options: Unpack[DrawKwargs]) -> None: gv = GraphVisualizer.from_xzcorrections(xz_corr=self, **options) gv.visualize() - def subs(self: XZCorrections[_M], variable: Parameter, substitute: ExpressionOrSupportsFloat) -> XZCorrections[_M]: + @override + def with_parameter( + self: XZCorrections[_M], variable: Parameter, substitute: ExpressionOrSupportsFloat + ) -> XZCorrections[_M]: """Substitute a parameter with a value or expression in all measurement angles of the open graph. Parameters @@ -535,12 +539,13 @@ def subs(self: XZCorrections[_M], variable: Parameter, substitute: ExpressionOrS Notes ----- - See notes and examples in :func:`OpenGraph.subs`. + See notes and examples in :meth:`OpenGraph.with_parameter`. """ - new_og = self.og.subs(variable, substitute) + new_og = self.og.with_parameter(variable, substitute) return dataclasses.replace(self, og=new_og) - def xreplace( + @override + def with_parameters( self: XZCorrections[_M], assignment: Mapping[Parameter, ExpressionOrSupportsFloat] ) -> XZCorrections[_M]: """Perform parallel substitution of multiple parameters in measurement angles of the open graph. @@ -557,14 +562,14 @@ def xreplace( Notes ----- - See notes and examples in :func:`OpenGraph.xreplace`. + See notes and examples in :meth:`OpenGraph.with_parameters`. """ - new_og = self.og.xreplace(assignment) + new_og = self.og.with_parameters(assignment) return dataclasses.replace(self, og=new_og) @dataclass(frozen=True) -class PauliFlow(Generic[_AM_co]): +class PauliFlow(Parameterizable, Generic[_AM_co]): """An unmutable dataclass providing a representation of a Pauli flow. Attributes @@ -858,7 +863,8 @@ def draw(self, **options: Unpack[DrawKwargs]) -> None: gv = GraphVisualizer.from_flow(flow=self, **options) gv.visualize() - def subs( # noqa: PYI019 Annotating with ``Self`` is not possible since ``self`` must be of parametric type ``Measurement``. + @override + def with_parameter( # noqa: PYI019 Annotating with ``Self`` is not possible since ``self`` must be of parametric type ``Measurement``. self: _T_PauliFlowMeasurement, variable: Parameter, substitute: ExpressionOrSupportsFloat ) -> _T_PauliFlowMeasurement: """Substitute a parameter with a value or expression in all measurement angles of the open graph. @@ -877,12 +883,13 @@ def subs( # noqa: PYI019 Annotating with ``Self`` is not possible since ``self` Notes ----- - See notes and examples in :func:`OpenGraph.subs`. + See notes and examples in :meth:`OpenGraph.with_parameter`. """ - new_og = self.og.subs(variable, substitute) + new_og = self.og.with_parameter(variable, substitute) return dataclasses.replace(self, og=new_og) - def xreplace( # noqa: PYI019 + @override + def with_parameters( # noqa: PYI019 self: _T_PauliFlowMeasurement, assignment: Mapping[Parameter, ExpressionOrSupportsFloat] ) -> _T_PauliFlowMeasurement: """Perform parallel substitution of multiple parameters in measurement angles of the open graph. @@ -899,9 +906,9 @@ def xreplace( # noqa: PYI019 Notes ----- - See notes and examples in :func:`OpenGraph.xreplace`. + See notes and examples in :meth:`OpenGraph.with_parameters`. """ - new_og = self.og.xreplace(assignment) + new_og = self.og.with_parameters(assignment) return dataclasses.replace(self, og=new_og) def is_focused(self) -> bool: diff --git a/graphix/instruction.py b/graphix/instruction.py index 86be7e347..c70d58968 100644 --- a/graphix/instruction.py +++ b/graphix/instruction.py @@ -93,11 +93,27 @@ def visit_axis(self, axis: Axis) -> Axis: class BaseInstruction(ABC, DataclassReprMixin): - """Base class for circuit instruction.""" + """Base class for circuit instructions.""" @abstractmethod - def visit(self, visitor: InstructionVisitor) -> Self: - """Rewrite the instruction according to the given visitor.""" + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> Self: + """Rewrite the instruction according to the given visitor. + + Parameters + ---------- + visitor : InstructionVisitor + The visitor specifying the rewriting. + + copy : bool, optional + If ``True``, the current instruction remains unchanged, and a + new instruction is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Self + The rewritten instruction. Equal to ``self`` if ``copy`` is ``False``. + """ @dataclass(repr=False) @@ -109,9 +125,15 @@ class CCX(_KindChecker, BaseInstruction): kind: ClassVar[Literal[InstructionKind.CCX]] = field(default=InstructionKind.CCX, init=False) @override - def visit(self, visitor: InstructionVisitor) -> CCX: + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> CCX: u, v = self.controls - return CCX(visitor.visit_qubit(self.target), (visitor.visit_qubit(u), visitor.visit_qubit(v))) + target = visitor.visit_qubit(self.target) + controls = (visitor.visit_qubit(u), visitor.visit_qubit(v)) + if copy: + return CCX(target, controls) + self.target = target + self.controls = controls + return self @dataclass(repr=False) @@ -124,8 +146,16 @@ class RZZ(_KindChecker, BaseInstruction): kind: ClassVar[Literal[InstructionKind.RZZ]] = field(default=InstructionKind.RZZ, init=False) @override - def visit(self, visitor: InstructionVisitor) -> RZZ: - return RZZ(visitor.visit_qubit(self.target), visitor.visit_qubit(self.control), visitor.visit_angle(self.angle)) + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> RZZ: + target = visitor.visit_qubit(self.target) + control = visitor.visit_qubit(self.control) + angle = visitor.visit_angle(self.angle) + if copy: + return RZZ(target, control, angle) + self.target = target + self.control = control + self.angle = angle + return self @dataclass(repr=False) @@ -137,8 +167,14 @@ class CNOT(_KindChecker, BaseInstruction): kind: ClassVar[Literal[InstructionKind.CNOT]] = field(default=InstructionKind.CNOT, init=False) @override - def visit(self, visitor: InstructionVisitor) -> CNOT: - return CNOT(visitor.visit_qubit(self.target), visitor.visit_qubit(self.control)) + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> CNOT: + target = visitor.visit_qubit(self.target) + control = visitor.visit_qubit(self.control) + if copy: + return CNOT(target, control) + self.target = target + self.control = control + return self @dataclass(repr=False) @@ -149,9 +185,13 @@ class CZ(_KindChecker, BaseInstruction): kind: ClassVar[Literal[InstructionKind.CZ]] = field(default=InstructionKind.CZ, init=False) @override - def visit(self, visitor: InstructionVisitor) -> CZ: + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> CZ: u, v = self.targets - return CZ((visitor.visit_qubit(u), visitor.visit_qubit(v))) + targets = (visitor.visit_qubit(u), visitor.visit_qubit(v)) + if copy: + return CZ(targets) + self.targets = targets + return self @dataclass(repr=False) @@ -162,82 +202,71 @@ class SWAP(_KindChecker, BaseInstruction): kind: ClassVar[Literal[InstructionKind.SWAP]] = field(default=InstructionKind.SWAP, init=False) @override - def visit(self, visitor: InstructionVisitor) -> SWAP: + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> SWAP: u, v = self.targets - return SWAP((visitor.visit_qubit(u), visitor.visit_qubit(v))) + targets = (visitor.visit_qubit(u), visitor.visit_qubit(v)) + if copy: + return SWAP(targets) + self.targets = targets + return self @dataclass(repr=False) -class H(_KindChecker, BaseInstruction): - """H circuit instruction.""" +class SingleTargetInstruction(BaseInstruction): + """Base class for single-target circuit instructions.""" target: int - kind: ClassVar[Literal[InstructionKind.H]] = field(default=InstructionKind.H, init=False) @override - def visit(self, visitor: InstructionVisitor) -> H: - return H(visitor.visit_qubit(self.target)) + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> Self: + target = visitor.visit_qubit(self.target) + if copy: + return type(self)(target) + self.target = target + return self + + +@dataclass(repr=False) +class H(_KindChecker, SingleTargetInstruction): + """H circuit instruction.""" + + kind: ClassVar[Literal[InstructionKind.H]] = field(default=InstructionKind.H, init=False) @dataclass(repr=False) -class S(_KindChecker, BaseInstruction): +class S(_KindChecker, SingleTargetInstruction): """S circuit instruction.""" - target: int kind: ClassVar[Literal[InstructionKind.S]] = field(default=InstructionKind.S, init=False) - @override - def visit(self, visitor: InstructionVisitor) -> S: - return S(visitor.visit_qubit(self.target)) - @dataclass(repr=False) -class X(_KindChecker, BaseInstruction): +class X(_KindChecker, SingleTargetInstruction): """X circuit instruction.""" - target: int kind: ClassVar[Literal[InstructionKind.X]] = field(default=InstructionKind.X, init=False) - @override - def visit(self, visitor: InstructionVisitor) -> X: - return X(visitor.visit_qubit(self.target)) - @dataclass(repr=False) -class Y(_KindChecker, BaseInstruction): +class Y(_KindChecker, SingleTargetInstruction): """Y circuit instruction.""" - target: int kind: ClassVar[Literal[InstructionKind.Y]] = field(default=InstructionKind.Y, init=False) - @override - def visit(self, visitor: InstructionVisitor) -> Y: - return Y(visitor.visit_qubit(self.target)) - @dataclass(repr=False) -class Z(_KindChecker, BaseInstruction): +class Z(_KindChecker, SingleTargetInstruction): """Z circuit instruction.""" - target: int kind: ClassVar[Literal[InstructionKind.Z]] = field(default=InstructionKind.Z, init=False) - @override - def visit(self, visitor: InstructionVisitor) -> Z: - return Z(visitor.visit_qubit(self.target)) - @dataclass(repr=False) -class I(_KindChecker, BaseInstruction): +class I(_KindChecker, SingleTargetInstruction): """I circuit instruction.""" - target: int kind: ClassVar[Literal[InstructionKind.I]] = field(default=InstructionKind.I, init=False) - @override - def visit(self, visitor: InstructionVisitor) -> I: - return I(visitor.visit_qubit(self.target)) - @dataclass(repr=False) class M(_KindChecker, BaseInstruction): @@ -248,61 +277,61 @@ class M(_KindChecker, BaseInstruction): kind: ClassVar[Literal[InstructionKind.M]] = field(default=InstructionKind.M, init=False) @override - def visit(self, visitor: InstructionVisitor) -> M: - return M(visitor.visit_qubit(self.target), visitor.visit_axis(self.axis)) + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> M: + target = visitor.visit_qubit(self.target) + axis = visitor.visit_axis(self.axis) + if copy: + return M(target, axis) + self.target = target + self.axis = axis + return self @dataclass(repr=False) -class RX(_KindChecker, BaseInstruction): - """X rotation circuit instruction.""" +class RotationInstruction(BaseInstruction): + """Base class for rotation instructions.""" target: int angle: ParameterizedAngle = field(metadata={"repr": repr_angle}) - kind: ClassVar[Literal[InstructionKind.RX]] = field(default=InstructionKind.RX, init=False) @override - def visit(self, visitor: InstructionVisitor) -> RX: - return RX(visitor.visit_qubit(self.target), visitor.visit_angle(self.angle)) + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> Self: + target = visitor.visit_qubit(self.target) + angle = visitor.visit_angle(self.angle) + if copy: + return type(self)(target, angle) + self.target = target + self.angle = angle + return self + + +@dataclass(repr=False) +class RX(_KindChecker, RotationInstruction): + """X rotation circuit instruction.""" + + kind: ClassVar[Literal[InstructionKind.RX]] = field(default=InstructionKind.RX, init=False) @dataclass(repr=False) -class RY(_KindChecker, BaseInstruction): +class RY(_KindChecker, RotationInstruction): """Y rotation circuit instruction.""" - target: int - angle: ParameterizedAngle = field(metadata={"repr": repr_angle}) kind: ClassVar[Literal[InstructionKind.RY]] = field(default=InstructionKind.RY, init=False) - @override - def visit(self, visitor: InstructionVisitor) -> RY: - return RY(visitor.visit_qubit(self.target), visitor.visit_angle(self.angle)) - @dataclass(repr=False) -class RZ(_KindChecker, BaseInstruction): +class RZ(_KindChecker, RotationInstruction): """Z rotation circuit instruction.""" - target: int - angle: ParameterizedAngle = field(metadata={"repr": repr_angle}) kind: ClassVar[Literal[InstructionKind.RZ]] = field(default=InstructionKind.RZ, init=False) - @override - def visit(self, visitor: InstructionVisitor) -> RZ: - return RZ(visitor.visit_qubit(self.target), visitor.visit_angle(self.angle)) - @dataclass(repr=False) -class J(_KindChecker, BaseInstruction): +class J(_KindChecker, RotationInstruction): """J circuit instruction.""" - target: int - angle: ParameterizedAngle = field(metadata={"repr": repr_angle}) kind: ClassVar[Literal[InstructionKind.J]] = field(default=InstructionKind.J, init=False) - @override - def visit(self, visitor: InstructionVisitor) -> J: - return J(visitor.visit_qubit(self.target), visitor.visit_angle(self.angle)) - class InstructionWithoutRZZ: """Grouping of all instructions except RZZ for namespace exposure. diff --git a/graphix/measurements.py b/graphix/measurements.py index 63b297724..161555904 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -14,7 +14,6 @@ # override introduced in Python 3.12 from typing_extensions import override -from graphix import parameter from graphix.fundamentals import ( ANGLE_PI, AbstractMeasurement, @@ -25,6 +24,7 @@ Plane, Sign, ) +from graphix.parameter import Parameterizable, with_parameter, with_parameters from graphix.pauli import Pauli if TYPE_CHECKING: @@ -48,7 +48,7 @@ def toggle_outcome(outcome: Outcome) -> Outcome: @dataclass(frozen=True) -class Measurement(AbstractMeasurement): +class Measurement(AbstractMeasurement, Parameterizable): r"""An MBQC measurement. Base class for :class:`BlochMeasurement` and :class:`PauliMeasurement`. @@ -168,7 +168,7 @@ def to_pauli_or_none(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> Paul >>> alpha = Placeholder("alpha") >>> Measurement.XY(alpha).to_pauli_or_none() is None True - >>> Measurement.XY(alpha).subs(alpha, 0.5).to_pauli_or_none() + >>> Measurement.XY(alpha).with_parameter(alpha, 0.5).to_pauli_or_none() Measurement.Y """ @@ -203,14 +203,6 @@ def to_pauli_or_bloch(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> Pau Measurement.XY(0.25) """ - @abstractmethod - def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Self: - """Substitute a parameter with a value or expression in measurement angles.""" - - @abstractmethod - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Self: - """Perform parallel substitution of multiple parameters in measurement angles.""" - @dataclass(frozen=True) class BlochMeasurement(AbstractPlanarMeasurement, Measurement): @@ -337,12 +329,12 @@ def clifford(self, clifford_gate: Clifford) -> BlochMeasurement: return BlochMeasurement(angle, new_plane) @override - def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> BlochMeasurement: - return BlochMeasurement(parameter.subs(self.angle, variable, substitute), self.plane) + def with_parameter(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> BlochMeasurement: + return BlochMeasurement(with_parameter(self.angle, variable, substitute), self.plane) @override - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> BlochMeasurement: - return BlochMeasurement(parameter.xreplace(self.angle, assignment), self.plane) + def with_parameters(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> BlochMeasurement: + return BlochMeasurement(with_parameters(self.angle, assignment), self.plane) class PauliMeasurementMeta(ABCMeta): @@ -477,11 +469,11 @@ def clifford(self, clifford_gate: Clifford) -> PauliMeasurement: return PauliMeasurement(pauli.axis, pauli.unit.sign) @override - def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Self: + def with_parameter(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Self: return self @override - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Self: + def with_parameters(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Self: return self diff --git a/graphix/opengraph.py b/graphix/opengraph.py index b0be7e5c5..a896a9234 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -9,12 +9,16 @@ import networkx as nx +# override introduced in Python 3.12 +from typing_extensions import override + from graphix.clifford import Clifford from graphix.flow._find_cflow import find_cflow from graphix.flow._find_gpflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix from graphix.flow.core import GFlow, PauliFlow from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement from graphix.measurements import BlochMeasurement, Measurement +from graphix.parameter import Parameterizable if TYPE_CHECKING: from collections.abc import Callable, Collection, Iterable, Mapping, Sequence @@ -37,7 +41,7 @@ @dataclass(frozen=True) -class OpenGraph(Generic[_AM_co]): +class OpenGraph(Parameterizable, Generic[_AM_co]): """An unmutable dataclass providing a representation of open graph states. Attributes @@ -744,7 +748,10 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: return OpenGraph(g, inputs, outputs, measurements, output_cliffords), mapping_complete - def subs(self: OpenGraph[_M], variable: Parameter, substitute: ExpressionOrSupportsFloat) -> OpenGraph[_M]: + @override + def with_parameter( + self: OpenGraph[_M], variable: Parameter, substitute: ExpressionOrSupportsFloat + ) -> OpenGraph[_M]: """Substitute a parameter with a value or expression in all measurement angles. Creates a new open graph where every measurement angle containing the specified variable is updated using the provided substitution. The original open graph instance remains unmodified. @@ -754,7 +761,7 @@ def subs(self: OpenGraph[_M], variable: Parameter, substitute: ExpressionOrSuppo variable : Parameter The symbolic expression to be replaced within the measurement angles. substitute : ExpressionOrSupportsFloat - The value or symbolic expression to substitute in place of `variable`. + The value or symbolic expression to substitute in place of ``variable``. Returns ------- @@ -782,12 +789,15 @@ def subs(self: OpenGraph[_M], variable: Parameter, substitute: ExpressionOrSuppo ... measurements=measurements, ... ) >>> # To perform substitution, use the actual object in memory - >>> new_og = og.subs(parametric_angles[0], 0.3) - >>> # Note: og.subs(Placeholder("alpha0"), 0.3) would not trigger any substitution. + >>> new_og = og.with_parameter(parametric_angles[0], 0.3) + >>> # Note: og.with_parameter(Placeholder("alpha0"), 0.3) would not trigger any substitution. """ - return self.map(lambda meas: meas.subs(variable, substitute)) + return self.map(lambda meas: meas.with_parameter(variable, substitute)) - def xreplace(self: OpenGraph[_M], assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> OpenGraph[_M]: + @override + def with_parameters( + self: OpenGraph[_M], assignment: Mapping[Parameter, ExpressionOrSupportsFloat] + ) -> OpenGraph[_M]: """Perform parallel substitution of multiple parameters in measurement angles. Creates a new open graph where occurrences of parameters defined in the assignment mapping are replaced by their corresponding values. The original open graph instance remains unmodified. @@ -820,9 +830,9 @@ def xreplace(self: OpenGraph[_M], assignment: Mapping[Parameter, ExpressionOrSup >>> og = OpenGraph(nx.Graph([(0, 1)]), [0], [], measurements) >>> # Substitute multiple parameters at once >>> subs_map = {alpha: 0.5, beta: 1.2} - >>> new_og = og.xreplace(subs_map) + >>> new_og = og.with_parameters(subs_map) """ - return self.map(lambda meas: meas.xreplace(assignment)) + return self.map(lambda meas: meas.with_parameters(assignment)) def _warn_non_inferred_pauli_measurements(self, stacklevel: int) -> None: for m in self.measurements.values(): diff --git a/graphix/parameter.py b/graphix/parameter.py index 0261b19c5..891986f38 100644 --- a/graphix/parameter.py +++ b/graphix/parameter.py @@ -12,10 +12,14 @@ import math from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat, TypeVar, overload +from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat, TypeAlias, TypeVar, overload + +# override introduced in Python 3.12 +from typing_extensions import override if TYPE_CHECKING: from collections.abc import Mapping + from typing import Self class Expression(ABC): @@ -92,19 +96,20 @@ def __truediv__(self, other: object) -> ExpressionOrFloat: """ @abstractmethod - def subs(self, variable: Parameter, value: ExpressionOrSupportsFloat) -> ExpressionOrComplex: - """Return the expression where every occurrence of `variable` is replaced with `value`.""" + def with_parameter(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> ExpressionOrComplex: + """Substitute a parameter with a value or expression.""" @abstractmethod - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> ExpressionOrComplex: - """ - Return the expression where every occurrence of any keys from `assignment` is replaced with the corresponding value. + def with_parameters(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> ExpressionOrComplex: + """Perform parallel substitution of multiple parameters.""" - The substitutions are performed in parallel, i.e., once an - occurrence has been replaced by a value, this value is not - subject to any further replacement, even if another occurrence - of a key appears in this value. - """ + def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> ExpressionOrComplex: + """Alias for :meth:`with_parameter`, following the ``sympy`` convention.""" + return self.with_parameter(variable, substitute) + + def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> ExpressionOrComplex: + """Alias for :meth:`with_parameters`, following the ``sympy`` convention.""" + return self.with_parameters(assignment) class ExpressionWithTrigonometry(Expression, ABC): @@ -173,18 +178,21 @@ def scale(self, k: float) -> ExpressionOrFloat: return 0 return self.scale_non_null(k) + @override def __mul__(self, other: object) -> ExpressionOrFloat: """Look to the documentation in the parent class.""" if isinstance(other, SupportsFloat): return self.scale(float(other)) return NotImplemented + @override def __rmul__(self, other: object) -> ExpressionOrFloat: """Look to the documentation in the parent class.""" if isinstance(other, SupportsFloat): return self.scale(float(other)) return NotImplemented + @override def __add__(self, other: object) -> ExpressionOrFloat: """Look to the documentation in the parent class.""" if isinstance(other, SupportsFloat): @@ -198,12 +206,14 @@ def __add__(self, other: object) -> ExpressionOrFloat: return AffineExpression(a=a, x=self.x, b=self.b + other.b) return NotImplemented + @override def __radd__(self, other: object) -> ExpressionOrFloat: """Look to the documentation in the parent class.""" if isinstance(other, SupportsFloat): return self.offset(float(other)) return NotImplemented + @override def __sub__(self, other: object) -> ExpressionOrFloat: """Look to the documentation in the parent class.""" if isinstance(other, AffineExpression): @@ -212,26 +222,31 @@ def __sub__(self, other: object) -> ExpressionOrFloat: return self + -float(other) return NotImplemented + @override def __rsub__(self, other: object) -> ExpressionOrFloat: """Look to the documentation in the parent class.""" if isinstance(other, SupportsFloat): return self.scale_non_null(-1).offset(float(other)) return NotImplemented + @override def __neg__(self) -> ExpressionOrFloat: """Look to the documentation in the parent class.""" return self.scale_non_null(-1) + @override def __truediv__(self, other: object) -> ExpressionOrFloat: """Look to the documentation in the parent class.""" if isinstance(other, SupportsFloat): return self.scale(1 / float(other)) return NotImplemented + @override def __str__(self) -> str: """Return a textual representation of the expression.""" return f"{self.a} * {self.x} + {self.b}" + @override def __eq__(self, other: object) -> bool: """Check if two expressions are equal.""" if isinstance(other, AffineExpression): @@ -244,13 +259,17 @@ def evaluate(self, value: ExpressionOrSupportsFloat) -> ExpressionOrFloat: return self.a * float(value) + self.b return self.a * value + self.b - def subs(self, variable: Parameter, value: ExpressionOrSupportsFloat) -> ExpressionOrComplex: + @override + def with_parameter( + self, variable: Parameter, substitute: ExpressionOrSupportsFloat + ) -> AffineExpression | ExpressionOrComplex: """Look to the documentation in the parent class.""" if variable == self.x: - return self.evaluate(value) + return self.evaluate(substitute) return self - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> ExpressionOrComplex: + @override + def with_parameters(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> ExpressionOrComplex: """Look to the documentation in the parent class.""" value = assignment.get(self.x) # `value` can be 0, so checking with `is not None` is mandatory here. @@ -292,39 +311,43 @@ def name(self) -> str: """Return the name of the placeholder.""" return self.__name + @override def __repr__(self) -> str: """Return a representation of the placeholder.""" return f"Placeholder({self.__name!r})" + @override def __str__(self) -> str: """Return the name of the placeholder.""" return self.__name + @override def __eq__(self, other: object) -> bool: """Check if two placeholders are identical.""" if isinstance(other, Parameter): return self is other return super().__eq__(other) + @override def __hash__(self) -> int: """Return an hash value for the placeholder.""" return id(self) -ExpressionOrFloat = Expression | float +ExpressionOrFloat: TypeAlias = Expression | float -ExpressionOrComplex = Expression | complex +ExpressionOrComplex: TypeAlias = Expression | complex -ExpressionOrSupportsFloat = Expression | SupportsFloat +ExpressionOrSupportsFloat: TypeAlias = Expression | SupportsFloat # `ExpressionOrSupportsComplex` is based on # `ExpressionOrSupportsFloat` rather than `Expression`, because `int` # and `float` implement `SupportsFloat` but not `SupportsComplex`, # even though `complex(int)` and `complex(float)` are valid. -ExpressionOrSupportsComplex = ExpressionOrSupportsFloat | SupportsComplex +ExpressionOrSupportsComplex: TypeAlias = ExpressionOrSupportsFloat | SupportsComplex -T = TypeVar("T") +_T = TypeVar("_T") def check_expression_or_complex(value: object) -> ExpressionOrComplex: @@ -348,24 +371,26 @@ def check_expression_or_float(value: object) -> ExpressionOrFloat: @overload -def subs(value: ExpressionOrFloat, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> ExpressionOrFloat: ... +def with_parameter( + value: ExpressionOrFloat, variable: Parameter, substitute: ExpressionOrSupportsFloat +) -> ExpressionOrFloat: ... @overload -def subs(value: T, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> T | complex: ... +def with_parameter(value: _T, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> _T | complex: ... -# The return type could be `T | complex` since `subs` returns `Expression` only -# if `T == Expression`, but `mypy` does not handle this yet: https://github.com/python/mypy/issues/12989 -def subs(value: T, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> T | Expression | complex: +# The return type could be `_T | complex` since `with_parameter` returns `Expression` only +# if `_T == Expression`, but `mypy` does not handle this yet: https://github.com/python/mypy/issues/12989 +def with_parameter(value: _T, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> _T | Expression | complex: """ Substitute in `value`. - If `value` is in instance of :class:`Expression`, then return - `value.subs(variable, substitute)` (coerced into a complex or a + If `value` is an instance of :class:`Expression`, then return + `value.with_parameter(variable, substitute)` (coerced into a complex or a float if the result is a number). - If `value` does not implement `subs`, `value` is returned + If ``value`` does not implement ``with_parameter``, ``value`` is returned unchanged. This function is used to apply substitution to collections where @@ -375,7 +400,7 @@ def subs(value: T, variable: Parameter, substitute: ExpressionOrSupportsFloat) - """ if not isinstance(value, Expression): return value - new_value = value.subs(variable, substitute) + new_value = value.with_parameter(variable, substitute) if isinstance(new_value, complex) and math.isclose(new_value.imag, 0.0): # Conversion to float, to enable the simulator to call # real trigonometric functions to the result. @@ -384,26 +409,28 @@ def subs(value: T, variable: Parameter, substitute: ExpressionOrSupportsFloat) - @overload -def xreplace( +def with_parameters( value: ExpressionOrFloat, assignment: Mapping[Parameter, ExpressionOrSupportsFloat] ) -> ExpressionOrFloat: ... @overload -def xreplace(value: T, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> T | Expression | complex: ... +def with_parameters( + value: _T, assignment: Mapping[Parameter, ExpressionOrSupportsFloat] +) -> _T | Expression | complex: ... -# The return type could be `T | Expression | complex` since `subs` returns `Expression` only -# if `T == Expression`, but `mypy` does not handle this yet: https://github.com/python/mypy/issues/12989 -def xreplace(value: T, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> T | Expression | complex: +# The return type could be `_T | Expression | complex` since `subs` returns `Expression` only +# if `_T == Expression`, but `mypy` does not handle this yet: https://github.com/python/mypy/issues/12989 +def with_parameters(value: _T, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> _T | Expression | complex: """ Substitute in parallel in `value`. - If `value` is an an instance of :class:`Expression`, then return - `value.xreplace(assignment)` (coerced into a complex if the result + If `value` is an instance of :class:`Expression`, then return + `value.with_parameters(assignment)` (coerced into a complex if the result is a number). - If `value` does not implement `xreplace`, `value` is returned + If ``value``` does not implement ``with_parameters``, ``value`` is returned unchanged. This function is used to apply parallel substitutions to @@ -413,7 +440,7 @@ def xreplace(value: T, assignment: Mapping[Parameter, ExpressionOrSupportsFloat] """ if not isinstance(value, Expression): return value - new_value = value.xreplace(assignment) + new_value = value.with_parameters(assignment) if isinstance(new_value, complex) and math.isclose(new_value.imag, 0.0): # Conversion to float, to enable the simulator to call # real trigonometric functions to the result. @@ -442,3 +469,103 @@ def exp(z: ExpressionOrComplex) -> ExpressionOrComplex: return z.exp() raise PlaceholderOperationError return cmath.exp(z) + + +class Parameterizable(ABC): + """Abstract base class for objects that hold parameters. + + This class defines the abstract methods :meth:`with_parameter` for + single-parameter substitution and :meth:`with_parameters` for parallel + substitution of multiple parameters. + + It also exposes :meth:`subs` and :meth:`xreplace` as aliases for + :meth:`with_parameter` and :meth:`with_parameters`, respectively, + following the ``sympy`` convention. + """ + + @abstractmethod + def with_parameter(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Self: + """Substitute a parameter with a value or expression.""" + + @abstractmethod + def with_parameters(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Self: + """Perform parallel substitution of multiple parameters.""" + + def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Self: + """Alias for :meth:`with_parameter`, following the ``sympy`` convention.""" + return self.with_parameter(variable, substitute) + + def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Self: + """Alias for :meth:`with_parameters`, following the ``sympy`` convention.""" + return self.with_parameters(assignment) + + +class InplaceParameterizable(Parameterizable, ABC): + """Abstract base class for objects that hold parameters and can be modified in place. + + This class defines the abstract methods :meth:`replace_parameter` + for single-parameter substitution and :meth:`replace_parameters` + for parallel substitution of multiple parameters. By default, these + methods modify the object in place, with an optional ``copy`` + parameter. If ``copy=True``, a modified copy is returned instead. + + The methods :meth:`with_parameter` and :meth:`with_parameters` are + equivalent to calling :meth:`replace_parameter` and + :meth:`replace_parameters`, respectively, with ``copy=True``. + """ + + @abstractmethod + def replace_parameter( + self, variable: Parameter, substitute: ExpressionOrSupportsFloat, *, copy: bool = False + ) -> Self: + """Substitute all occurrences of the given variable in measurement angles by the given value. + + Parameters + ---------- + variable : Parameter + The symbolic expression to be replaced within the measurement angles. + substitute : ExpressionOrSupportsFloat + The value or symbolic expression to substitute in place of ``variable``. + + copy : bool, optional + If ``True``, the current object remains unchanged and a + new object is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Self + The object with the updated measurement parameters. + Equal to ``self`` if ``copy`` is ``False``. + """ + + @abstractmethod + def replace_parameters( + self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat], *, copy: bool = False + ) -> Self: + """Substitute all occurrences of the given keys in measurement angles by the given values in parallel. + + Parameters + ---------- + assignment : Mapping[Parameter, ExpressionOrSupportsFloat] + A dictionary-like mapping where keys are the `Parameter` objects to be replaced and values are the new expressions or numerical values. + + copy : bool, optional + If ``True``, the current object remains unchanged and a + new object is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Self + The object with the updated measurement parameters. + Equal to ``self`` if ``copy`` is ``False``. + """ + + @override + def with_parameter(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Self: + return self.replace_parameter(variable, substitute, copy=True) + + @override + def with_parameters(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Self: + return self.replace_parameters(assignment, copy=True) diff --git a/graphix/pattern.py b/graphix/pattern.py index 0194af3a9..e95e83c06 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -19,13 +19,16 @@ from warnings import warn import networkx as nx -from typing_extensions import assert_never + +# override introduced in Python 3.12 +from typing_extensions import assert_never, override from graphix import command, optimization from graphix.command import CommandKind, Node from graphix.flow.exceptions import FlowError from graphix.fundamentals import Plane from graphix.measurements import BlochMeasurement, Measurement, Outcome, toggle_outcome +from graphix.parameter import InplaceParameterizable from graphix.pretty_print import OutputFormat, pattern_to_str from graphix.qasm3_exporter import pattern_to_qasm3_lines from graphix.sim import DensityMatrix, MBQCTensorNet, Statevector @@ -69,7 +72,7 @@ class DrawPatternAnnotations(Enum): XZCorrections = enum.auto() -class Pattern: +class Pattern(InplaceParameterizable): """ MBQC pattern class. @@ -257,7 +260,7 @@ def reindex( default mapping is computed by using :meth:`node_mapping`. copy : bool, optional - If ``True``, the current pattern remains unchanged and a + If ``True``, the current pattern remains unchanged, and a new pattern is returned. The default is ``False``, meaning that changes are performed in place. @@ -1627,13 +1630,17 @@ def is_parameterized(self) -> bool: if cmd.kind == command.CommandKind.M and isinstance(cmd.measurement, BlochMeasurement) ) - def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Pattern: - """Return a copy of the pattern where all occurrences of the given variable in measurement angles are substituted by the given value.""" - return self.map(lambda m: m.subs(variable, substitute)) + @override + def replace_parameter( + self, variable: Parameter, substitute: ExpressionOrSupportsFloat, *, copy: bool = False + ) -> Pattern: + return self.apply(lambda m: m.with_parameter(variable, substitute), copy=copy) - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Pattern: - """Return a copy of the pattern where all occurrences of the given keys in measurement angles are substituted by the given values in parallel.""" - return self.map(lambda m: m.xreplace(assignment)) + @override + def replace_parameters( + self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat], *, copy: bool = False + ) -> Pattern: + return self.apply(lambda m: m.with_parameters(assignment), copy=copy) def copy(self) -> Pattern: """Return a copy of the pattern.""" @@ -1706,9 +1713,48 @@ def check_measured(cmd: CommandType, node: int) -> None: case CommandKind.C: check_active(cmd, cmd.node) + def apply(self, f: Callable[[Measurement], Measurement], *, copy: bool = False) -> Pattern: + """Apply the function ``f`` to each measurement. + + Parameters + ---------- + f: Callable[[Measurement], Measurement] + Function applied to each measurement. + copy : bool, optional + If ``True``, the current pattern remains unchanged and a + new pattern is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Pattern + The resulting pattern. + If ``copy`` is ``False``, the result is ``self``. + + Example + ------- + >>> from graphix import Pattern, command + >>> from graphix.measurements import BlochMeasurement, Measurement + >>> pattern = Pattern(input_nodes=[0], cmds=[command.M(0, Measurement.XZ(0.25))]) + >>> pattern.apply(lambda m: BlochMeasurement(m.angle + 1, m.plane)) + Pattern(input_nodes=[0], cmds=[M(0, Measurement.XZ(1.25))]) + >>> pattern + Pattern(input_nodes=[0], cmds=[M(0, Measurement.XZ(1.25))]) + """ + new_seq = [cmd.map(f) if cmd.kind == CommandKind.M else cmd for cmd in self] + + if copy: + new_pattern = Pattern(input_nodes=self.input_nodes, cmds=new_seq) + new_pattern.reorder_output_nodes(self.output_nodes) + return new_pattern + self.__seq = new_seq + return self + def map(self, f: Callable[[Measurement], Measurement]) -> Pattern: """Return a pattern where the function ``f`` has been applied to each measurement. + Equivalent to ``self.apply(f, copy=True)``. + Parameters ---------- f: Callable[[Measurement], Measurement] @@ -1726,20 +1772,13 @@ def map(self, f: Callable[[Measurement], Measurement]) -> Pattern: >>> pattern = Pattern(input_nodes=[0], cmds=[command.M(0, Measurement.XZ(0.25))]) >>> pattern.map(lambda m: BlochMeasurement(m.angle + 1, m.plane)) Pattern(input_nodes=[0], cmds=[M(0, Measurement.XZ(1.25))]) + >>> pattern + Pattern(input_nodes=[0], cmds=[M(0, Measurement.XZ(0.25))]) """ - new_pattern = Pattern(input_nodes=self.input_nodes) + return self.apply(f, copy=True) - for cmd in self: - if cmd.kind == CommandKind.M: - new_pattern.add(cmd.map(f)) - else: - new_pattern.add(cmd) - - new_pattern.reorder_output_nodes(self.output_nodes) - return new_pattern - - def infer_pauli_measurements(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> Pattern: - """Return an equivalent pattern in which Bloch measurements close to a Pauli measurement are replaced by Pauli measurements. + def infer_pauli_measurements(self, *, rel_tol: float = 1e-09, abs_tol: float = 0.0, copy: bool = False) -> Pattern: + """Replace Bloch measurements close to a Pauli measurement by Pauli measurements. Parameters ---------- @@ -1749,11 +1788,17 @@ def infer_pauli_measurements(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) abs_tol : float, optional Absolute tolerance for comparing angles, passed to :func:`math.isclose`. Default is ``0.0``. + copy : bool, optional + If ``True``, the current pattern remains unchanged and a + new pattern is returned. The default is ``False``, meaning + that changes are performed in place. Returns ------- Pattern - An equivalent pattern in which Bloch measurements close to a Pauli measurement are replaced by Pauli measurements. + A pattern in which Bloch measurements close to a Pauli + measurement are replaced by Pauli measurements. If + ``copy`` is ``False``, the result is ``self``. Example ------- @@ -1763,11 +1808,41 @@ def infer_pauli_measurements(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) >>> pattern.infer_pauli_measurements() Pattern(input_nodes=[0], cmds=[M(0, Measurement.Y)]) """ - return self.map(lambda m: m.to_pauli_or_bloch(rel_tol, abs_tol)) + return self.apply(lambda m: m.to_pauli_or_bloch(rel_tol, abs_tol), copy=copy) + + def blochify(self, *, copy: bool = False) -> Pattern: + """Represent all measurements as Bloch measurements. + + Parameters + ---------- + copy : bool, optional + If ``True``, the current pattern remains unchanged and a + new pattern is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Pattern + A pattern in which all measurements are Bloch measurements. + If ``copy`` is ``False``, the result is ``self``. + + Example + ------- + >>> from graphix import Pattern, command + >>> from graphix.measurements import BlochMeasurement, Measurement + >>> pattern = Pattern(input_nodes=[0], cmds=[command.M(0, Measurement.Y)]) + >>> pattern.blochify() + Pattern(input_nodes=[0], cmds=[M(0, Measurement.XY(0.5))]) + >>> pattern + Pattern(input_nodes=[0], cmds=[M(0, Measurement.XY(0.5))]) + """ + return self.apply(lambda m: m.to_bloch(), copy=copy) def to_bloch(self) -> Pattern: """Return an equivalent pattern in which all measurements are represented as Bloch measurements. + Equivalent to ``self.blochify(copy=True)``. + Example ------- >>> from graphix import Pattern, command @@ -1775,15 +1850,17 @@ def to_bloch(self) -> Pattern: >>> pattern = Pattern(input_nodes=[0], cmds=[command.M(0, Measurement.Y)]) >>> pattern.to_bloch() Pattern(input_nodes=[0], cmds=[M(0, Measurement.XY(0.5))]) + >>> pattern + Pattern(input_nodes=[0], cmds=[M(0, Measurement.Y)]) """ - return self.map(lambda m: m.to_bloch()) + return self.blochify(copy=True) def perform_pauli_pushing( self, + *, leave_nodes: AbstractSet[Node] | None = None, copy: bool = False, standardize: bool = False, - *, stacklevel: int = 1, ) -> Pattern: """Move Pauli measurements before the other measurements. diff --git a/graphix/pretty_print.py b/graphix/pretty_print.py index 5623602d7..8054fbae0 100644 --- a/graphix/pretty_print.py +++ b/graphix/pretty_print.py @@ -886,7 +886,7 @@ def statevec_to_str( Parameters ---------- - statevec : Statevector + statevec : AbstractStatevector The statevector to format. output : OutputFormat Desired formatting style (``ASCII``, ``LaTeX`` or ``Unicode``). diff --git a/graphix/sim/density_matrix.py b/graphix/sim/density_matrix.py index 90dd26e78..ad42a0ef6 100644 --- a/graphix/sim/density_matrix.py +++ b/graphix/sim/density_matrix.py @@ -5,7 +5,7 @@ from __future__ import annotations -import copy +import copy as _copy import dataclasses import math from collections.abc import Collection, Iterable @@ -16,10 +16,16 @@ from typing_extensions import override from graphix import linalg_validations as lv -from graphix import parameter from graphix.channels import KrausChannel from graphix.ops import Ops -from graphix.parameter import Expression, ExpressionOrFloat, ExpressionOrSupportsComplex +from graphix.parameter import ( + Expression, + ExpressionOrFloat, + ExpressionOrSupportsComplex, + InplaceParameterizable, + with_parameter, + with_parameters, +) from graphix.pretty_print import OutputFormat, density_matrix_to_str from graphix.sim.base_backend import DenseState, DenseStateBackend, Matrix, kron, matmul, outer, tensordot, vdot from graphix.sim.statevec import Statevector, _check_permutation @@ -34,7 +40,7 @@ from graphix.sim.data import Data -class DensityMatrix(DenseState): +class DensityMatrix(DenseState, InplaceParameterizable): """DensityMatrix object.""" rho: Matrix @@ -272,7 +278,7 @@ def expectation_single(self, op: Matrix, qubit: int) -> complex: if op.shape != (2, 2): raise ValueError("op must be 2x2 matrix.") - st1 = copy.copy(self) + st1 = _copy.copy(self) st1.normalize() nqubit = self.nqubit @@ -434,7 +440,7 @@ def apply_channel(self, channel: KrausChannel, qargs: Sequence[int]) -> None: raise TypeError("Can't apply a channel that is not a Channel object.") for k_op in channel: - dm = copy.copy(self) + dm = _copy.copy(self) dm.evolve(k_op.operator, qargs) result_array += k_op.coef * np.conj(k_op.coef) * dm.rho # reinitialize to input density matrix @@ -458,17 +464,29 @@ def apply_noise(self, qubits: Sequence[int], noise: Noise) -> None: channel = noise.to_krauschannel() self.apply_channel(channel, qubits) - def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> DensityMatrix: - """Return a copy of the density matrix where all occurrences of the given variable in measurement angles are substituted by the given value.""" - result = copy.copy(self) - result.rho = np.vectorize(lambda value: parameter.subs(value, variable, substitute))(self.rho) - return result - - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> DensityMatrix: - """Return a copy of the density matrix where all occurrences of the given keys in measurement angles are substituted by the given values in parallel.""" - result = copy.copy(self) - result.rho = np.vectorize(lambda value: parameter.xreplace(value, assignment))(self.rho) - return result + @override + def replace_parameter( + self, variable: Parameter, substitute: ExpressionOrSupportsFloat, *, copy: bool = False + ) -> DensityMatrix: + rho = np.vectorize(lambda value: with_parameter(value, variable, substitute))(self.rho) + if copy: + result = _copy.copy(self) + result.rho = rho + return result + self.rho = rho + return self + + @override + def replace_parameters( + self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat], *, copy: bool = False + ) -> DensityMatrix: + rho = np.vectorize(lambda value: with_parameters(value, assignment))(self.rho) + if copy: + result = _copy.copy(self) + result.rho = rho + return result + self.rho = rho + return self @dataclass(frozen=True) diff --git a/graphix/sim/statevec.py b/graphix/sim/statevec.py index 3b80121b6..0cb4eac88 100644 --- a/graphix/sim/statevec.py +++ b/graphix/sim/statevec.py @@ -10,7 +10,7 @@ import math from collections.abc import Iterable from dataclasses import dataclass -from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat +from typing import TYPE_CHECKING, Any, SupportsComplex, SupportsFloat, TypeVar import numba as nb import numpy as np @@ -18,6 +18,7 @@ from typing_extensions import override from graphix import states +from graphix.parameter import ExpressionOrSupportsComplex from graphix.pretty_print import OutputFormat, statevec_to_str from graphix.sim.base_backend import ( DenseState, @@ -29,7 +30,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence - from typing import Any, Literal, Self, TypeVar + from typing import Literal, Self # Unpack introduced in Python 3.12 from typing_extensions import Unpack diff --git a/graphix/transpiler.py b/graphix/transpiler.py index 92102948f..046500b98 100644 --- a/graphix/transpiler.py +++ b/graphix/transpiler.py @@ -26,6 +26,7 @@ from graphix.opengraph import OpenGraph from graphix.ops import Ops from graphix.optimization import StandardizedPattern +from graphix.parameter import InplaceParameterizable from graphix.pattern import Pattern from graphix.sim.base_backend import DenseStateBackend from graphix.sim.density_matrix import DensityMatrixBackend @@ -40,7 +41,7 @@ from graphix.command import Node from graphix.fundamentals import ParameterizedAngle from graphix.instruction import InstructionType - from graphix.parameter import ExpressionOrFloat, Parameter + from graphix.parameter import ExpressionOrSupportsFloat, Parameter from graphix.pattern import Pattern from graphix.sim import Data from graphix.sim.base_backend import DenseState, Matrix @@ -102,7 +103,7 @@ def visit_angle(self, angle: ParameterizedAngle) -> ParameterizedAngle: return self.f(angle) -class Circuit: +class Circuit(InplaceParameterizable): """Gate-to-MBQC transpiler. Holds gate operations and translates into MBQC measurement patterns. @@ -510,7 +511,7 @@ def transpile(self, *, transpile_swaps: bool = True) -> TranspiledPattern: """ if not transpile_swaps: return self.transpile_to_causalflow().to_pattern() - swap = _transpile_swaps(self) + swap = _transpile_swaps(self, copy=True) result = swap.circuit.transpile_to_causalflow().to_pattern() result.pattern.reorder_output_nodes(swap.swap_output_nodes(result.pattern.output_nodes)) classical_outputs = swap.swap_classical_outputs(result.classical_outputs) @@ -640,16 +641,52 @@ def evolve(op: Matrix, qargs: Iterable[int]) -> None: raise ValueError(f"Unknown instruction: {instr}") return SimulateResult(_backend.state, tuple(classical_measures)) - def visit(self, visitor: InstructionVisitor) -> Circuit: - """Apply ``visitor`` to all instructions in the circuit.""" - result = Circuit(self.width) + def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> Circuit: + """Apply ``visitor`` to all instructions in the circuit. + + Parameters + ---------- + visitor : InstructionVisitor + The visitor specifying the rewriting. + + copy : bool, optional + If ``True``, the current circuit remains unchanged, and a + new circuit is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Self + The rewritten circuit. Equal to ``self`` if ``copy`` is ``False``. + """ + if copy: + result = Circuit(self.width) + for instr in self.instruction: + result.instruction.append(instr.visit(visitor, copy=True)) + return result for instr in self.instruction: - result.instruction.append(instr.visit(visitor)) - return result + instr.visit(visitor, copy=False) + return self + + def apply_angle(self, f: Callable[[ParameterizedAngle], ParameterizedAngle], *, copy: bool = False) -> Circuit: + """Apply ``f`` to all angles that occur in the circuit. - def map_angle(self, f: Callable[[ParameterizedAngle], ParameterizedAngle]) -> Circuit: - """Apply ``f`` to all angles that occur in the circuit.""" - return self.visit(_MapAngleVisitor(f)) + Parameters + ---------- + f : Callable[[ParameterizedAngle], ParameterizedAngle] + The function to apply to every angle. + + copy : bool, optional + If ``True``, the current circuit remains unchanged, and a + new circuit is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Self + The rewritten circuit. Equal to ``self`` if ``copy`` is ``False``. + """ + return self.visit(_MapAngleVisitor(f), copy=copy) def is_parameterized(self) -> bool: """ @@ -668,13 +705,17 @@ def is_parameterized(self) -> bool: return True return False - def subs(self, variable: Parameter, substitute: ExpressionOrFloat) -> Circuit: - """Return a copy of the circuit where all occurrences of the given variable in measurement angles are substituted by the given value.""" - return self.map_angle(lambda angle: parameter.subs(angle, variable, substitute)) + @override + def replace_parameter( + self, variable: Parameter, substitute: ExpressionOrSupportsFloat, *, copy: bool = False + ) -> Circuit: + return self.apply_angle(lambda angle: parameter.with_parameter(angle, variable, substitute), copy=copy) - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrFloat]) -> Circuit: - """Return a copy of the circuit where all occurrences of the given keys in measurement angles are substituted by the given values in parallel.""" - return self.map_angle(lambda angle: parameter.xreplace(angle, assignment)) + @override + def replace_parameters( + self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat], *, copy: bool = False + ) -> Circuit: + return self.apply_angle(lambda angle: parameter.with_parameters(angle, assignment), copy=copy) def transpile_measurements_to_z_axis(self) -> Circuit: """Return an equivalent circuit where all measurements are on Z axis.""" @@ -1021,7 +1062,7 @@ def visit_qubit(self, qubit: int) -> int: return target.index -def transpile_swaps(circuit: Circuit) -> TranspileSwapsResult: +def transpile_swaps(circuit: Circuit, *, copy: bool = False) -> TranspileSwapsResult: """Return a new circuit equivalent to the original one but without SWAP gates. Parameters @@ -1029,11 +1070,18 @@ def transpile_swaps(circuit: Circuit) -> TranspileSwapsResult: circuit : Circuit The original circuit + copy : bool, optional + If ``True``, the current pattern remains unchanged, and a + new pattern is returned. The default is ``False``, meaning + that changes are performed in place. + Returns ------- TranspileSwapsResult The field ``circuit`` contains an equivalent circuit without - SWAP gates. The field ``outputs`` contains a tuple which has + SWAP gates. Equal to ``self`` if ``copy`` is ``False``. + + The field ``outputs`` contains a tuple which has the same width as the circuit. For every qubit of the original circuit, either the qubit is not measured, and ``outputs`` provides the index of the corresponding qubit in the output of @@ -1050,14 +1098,21 @@ def transpile_swaps(circuit: Circuit) -> TranspileSwapsResult: visitor.visit_qubit(u) visitor.visit_qubit(v) visitor.outputs[u], visitor.outputs[v] = visitor.outputs[v], visitor.outputs[u] + elif instr.kind == InstructionKind.M: + old_target = instr.target + new_circuit.add(instr.visit(visitor, copy=copy)) + visitor.outputs[old_target] = OutputIndex(OutputKind.Bit, measurement_index) + measurement_index += 1 else: - new_circuit.add(instr.visit(visitor)) - if instr.kind == InstructionKind.M: - visitor.outputs[instr.target] = OutputIndex(OutputKind.Bit, measurement_index) - measurement_index += 1 + new_circuit.add(instr.visit(visitor, copy=copy)) + if not copy: + circuit.instruction = new_circuit.instruction + new_circuit = circuit return TranspileSwapsResult(new_circuit, tuple(visitor.outputs)) +# Alias `_transpile_swaps` to call the function in `Circuit.transpile` +# method where `transpile_swaps` is shadowed by the keyword parameter. _transpile_swaps = transpile_swaps diff --git a/noxfile.py b/noxfile.py index 8523d0344..072b0ba7d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -95,8 +95,8 @@ class ReverseDependency: @nox.parametrize( "package", [ + ReverseDependency("https://github.com/thierry-martinez/graphix-symbolic", branch="in-place_methods"), ReverseDependency("https://github.com/thierry-martinez/graphix-stim-backend", branch="rename-simulate"), - ReverseDependency("https://github.com/thierry-martinez/graphix-symbolic", branch="rename-simulate"), ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser"), ReverseDependency( "https://github.com/thierry-martinez/graphix-ibmq", doctest_modules=False, branch="rename-simulate" diff --git a/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index c8483f30b..31144a0b3 100644 --- a/tests/test_circ_extraction.py +++ b/tests/test_circ_extraction.py @@ -592,12 +592,13 @@ def test_parametric_angles(self, test_case: float, fx_rng: Generator) -> None: # Substitute parameter at the level of the extracted circuit qc1 = og.to_circuit() - s1 = qc1.subs(alpha, alpha_val).simulate(rng=fx_rng).state + qc1.replace_parameter(alpha, alpha_val) + s1 = qc1.simulate(rng=fx_rng).state # Substitute parameter at the level of the open graph object # Calling `infer_pauli_measurements` is not necessary for the test to pass # (and it should not be), but it suppresses the warnings. - qc2 = og.subs(alpha, alpha_val).infer_pauli_measurements().to_circuit() + qc2 = og.with_parameter(alpha, alpha_val).infer_pauli_measurements().to_circuit() s2 = qc2.simulate(rng=fx_rng).state assert s1.isclose(s2) diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 7ea6ca334..2fd22fe3b 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -438,7 +438,7 @@ def test_subs(self) -> None: ) flow_ref = og_ref.to_pauliflow() - flow_test = flow.subs(alpha, value) + flow_test = flow.with_parameter(alpha, value) assert not flow.og.isclose(flow_test.og) assert flow_ref.og.isclose(flow_test.og) @@ -729,7 +729,7 @@ def test_subs(self) -> None: ) xzcorr_ref = og_ref.to_causalflow().to_xzcorrections() - xzcorr_test = xzcorr.subs(alpha, value) + xzcorr_test = xzcorr.with_parameter(alpha, value) assert not xzcorr.og.isclose(xzcorr_test.og) assert xzcorr_ref.og.isclose(xzcorr_test.og) diff --git a/tests/test_instruction.py b/tests/test_instruction.py new file mode 100644 index 000000000..4228ece67 --- /dev/null +++ b/tests/test_instruction.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from copy import copy +from typing import TYPE_CHECKING + +import pytest + +# override introduced in Python 3.12 +from typing_extensions import override + +from graphix import ANGLE_PI, Axis, Clifford +from graphix.instruction import Instruction, InstructionVisitor + +if TYPE_CHECKING: + from graphix.fundamentals import ParameterizedAngle + from graphix.instruction import InstructionType + +ALL_INSTRUCTIONS = [ + Instruction.CCX(target=0, controls=(1, 2)), + Instruction.RZZ(target=0, control=1, angle=ANGLE_PI / 4), + Instruction.CNOT(target=0, control=1), + Instruction.SWAP(targets=(0, 1)), + Instruction.CZ(targets=(0, 1)), + Instruction.H(target=0), + Instruction.S(target=0), + Instruction.X(target=0), + Instruction.Y(target=0), + Instruction.Z(target=0), + Instruction.I(target=0), + Instruction.RX(target=0, angle=ANGLE_PI / 4), + Instruction.RY(target=0, angle=ANGLE_PI / 4), + Instruction.RZ(target=0, angle=ANGLE_PI / 4), + Instruction.J(target=0, angle=ANGLE_PI / 4), + Instruction.M(target=0, axis=Axis.X), +] + + +class VisitQubit(InstructionVisitor): + @override + def visit_qubit(self, qubit: int) -> int: + return qubit + 1 + + +class VisitAngle(InstructionVisitor): + @override + def visit_angle(self, angle: ParameterizedAngle) -> ParameterizedAngle: + return -angle + + +class VisitAxis(InstructionVisitor): + @override + def visit_axis(self, axis: Axis) -> Axis: + return axis.clifford(Clifford.H) + + +@pytest.mark.parametrize("instruction", ALL_INSTRUCTIONS) +def test_visit_qubit(instruction: InstructionType) -> None: + # Copy the instruction to keep ALL_INSTRUCTIONS unmodified + instr_copy = copy(instruction) + visitor = VisitQubit() + instr_visited = instr_copy.visit(visitor, copy=True) + assert instr_copy == instruction + assert instr_visited != instruction + instr_copy.visit(visitor, copy=False) + assert instr_copy != instruction + assert instr_visited == instr_copy + + +@pytest.mark.parametrize("instruction", ALL_INSTRUCTIONS) +def test_visit_angle(instruction: InstructionType) -> None: + if not hasattr(instruction, "angle"): + pytest.skip() + # Copy the instruction to keep ALL_INSTRUCTIONS unmodified + instr_copy = copy(instruction) + visitor = VisitAngle() + instr_visited = instr_copy.visit(visitor, copy=True) + assert instr_copy == instruction + assert instr_visited != instruction + instr_copy.visit(visitor, copy=False) + assert instr_copy != instruction + assert instr_visited == instr_copy + + +@pytest.mark.parametrize("instruction", ALL_INSTRUCTIONS) +def test_visit_axis(instruction: InstructionType) -> None: + if not hasattr(instruction, "axis"): + pytest.skip() + instr_copy = copy(instruction) + visitor = VisitAxis() + instr_visited = instr_copy.visit(visitor, copy=True) + assert instr_copy == instruction + assert instr_visited != instruction + instr_copy.visit(visitor, copy=False) + assert instr_copy != instruction + assert instr_visited == instr_copy diff --git a/tests/test_optimization.py b/tests/test_optimization.py index 315d29f7f..780ec1568 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -66,7 +66,7 @@ def test_flow_after_pauli_preprocessing(fx_bg: PCG64, jumps: int) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() # We should convert to Bloch measurement the remaining Pauli # measurements on input nodes. @@ -83,7 +83,7 @@ def test_remove_useless_domains(fx_bg: PCG64, jumps: int) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern2 = remove_useless_domains(pattern) pattern2 = StandardizedPattern.from_pattern(pattern2).to_space_optimal_pattern() @@ -143,7 +143,7 @@ def test_remove_local_clifford_commands(fx_bg: PCG64, jumps: int) -> None: depth = 4 circuit = rand_circuit(nqubits, depth, rng) pattern = circuit.transpile().pattern - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() assert any(cmd.kind == CommandKind.C for cmd in pattern) new_pattern = pattern.remove_local_clifford_commands(copy=True) diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 3d274af74..5000a3428 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -3,10 +3,13 @@ from typing import TYPE_CHECKING import matplotlib as mpl +import networkx as nx import numpy as np import pytest +# override introduced in Python 3.12 import graphix.command +from graphix import OpenGraph from graphix.measurements import Measurement from graphix.parameter import Placeholder, PlaceholderOperationError from graphix.pattern import DrawPatternAnnotations, Pattern @@ -43,7 +46,7 @@ def test_pattern_without_parameter_subs_is_identity() -> None: pattern.add(graphix.command.M(node=0)) alpha = Placeholder("alpha") # Substitution in a pattern without parameterized angle is the identity. - assert list(pattern) == list(pattern.subs(alpha, 0)) + assert list(pattern) == list(pattern.with_parameter(alpha, 0)) def test_pattern_substitution() -> None: @@ -53,22 +56,60 @@ def test_pattern_substitution() -> None: pattern.add(graphix.command.M(1, Measurement.XY(alpha))) assert pattern.is_parameterized() # Parameterized patterns can be substituted, even if some angles are not parameterized. - pattern0 = pattern.subs(alpha, 0) + pattern0 = pattern.with_parameter(alpha, 0) # If all parameterized angles have been instantiated, the pattern is no longer parameterized. assert not pattern0.is_parameterized() assert list(pattern0) == [graphix.command.M(0), graphix.command.M(1, Measurement.XY(0))] assert list(pattern0.infer_pauli_measurements()) == [graphix.command.M(0), graphix.command.M(1)] +def test_placeholder_with_parameter_subs() -> None: + alpha = Placeholder("alpha") + assert alpha.with_parameter(alpha, 1) == 1 + assert alpha.subs(alpha, 1) == 1 + + +def test_placeholder_with_parameters_xreplace() -> None: + alpha = Placeholder("alpha") + assert alpha.with_parameters({alpha: 1}) == 1 + assert alpha.xreplace({alpha: 1}) == 1 + + +def test_opengraph_with_parameter_subs() -> None: + alpha = Placeholder("alpha") + og = OpenGraph(graph=nx.Graph([(0, 1)]), input_nodes=[0], output_nodes=[1], measurements={0: Measurement.XY(alpha)}) + og1 = og.with_parameter(alpha, 0.25) + og2 = og.subs(alpha, 0.25) + assert og.measurements == {0: Measurement.XY(alpha)} + assert og1.measurements == {0: Measurement.XY(0.25)} + assert og2.measurements == {0: Measurement.XY(0.25)} + + +def test_opengraph_with_parameters_xreplace() -> None: + alpha = Placeholder("alpha") + beta = Placeholder("beta") + og = OpenGraph( + graph=nx.path_graph(3), + input_nodes=[0], + output_nodes=[2], + measurements={0: Measurement.XY(alpha), 1: Measurement.XY(beta)}, + ) + og1 = og.with_parameters({alpha: beta, beta: alpha}) + og2 = og.xreplace({alpha: beta, beta: alpha}) + assert og.measurements == {0: Measurement.XY(alpha), 1: Measurement.XY(beta)} + assert og1.measurements == {0: Measurement.XY(beta), 1: Measurement.XY(alpha)} + assert og2.measurements == {0: Measurement.XY(beta), 1: Measurement.XY(alpha)} + + def test_instantiated_pattern_simulation(fx_rng: Generator) -> None: pattern = Pattern(input_nodes=[0, 1]) pattern.add(graphix.command.M(node=0)) alpha = Placeholder("alpha") pattern.add(graphix.command.M(1, Measurement.XY(alpha))) - pattern0 = pattern.subs(alpha, 0) + pattern0 = pattern.with_parameter(alpha, 0) # Instantied patterns can be simulated. pattern0.simulate(rng=fx_rng) - pattern1 = pattern.subs(alpha, 1) + pattern1 = pattern.with_parameter(alpha, 1) assert not pattern1.is_parameterized() assert list(pattern1) == [graphix.command.M(node=0), graphix.command.M(1, Measurement.XY(1))] assert list(pattern1.infer_pauli_measurements()) == [ @@ -86,9 +127,10 @@ def test_multiple_parameters(fx_rng: Generator) -> None: beta = Placeholder("beta") pattern.add(graphix.command.N(node=2)) pattern.add(graphix.command.M(2, Measurement.XY(beta))) + pattern.replace_parameter(alpha, 2) # A partially instantiated pattern is still parameterized. - assert pattern.subs(alpha, 2).is_parameterized() - pattern23 = pattern.subs(alpha, 2).subs(beta, 3) + assert pattern.is_parameterized() + pattern23 = pattern.subs(beta, 3) # A full instantiated pattern is no longer parameterized. assert not pattern23.is_parameterized() assert list(pattern23) == [ @@ -131,22 +173,30 @@ def test_parallel_substitution_with_zero() -> None: assert not pattern23.is_parameterized() -def test_density_matrix_subs() -> None: +def test_density_matrix_with_parameter() -> None: alpha = Placeholder("alpha") dm = DensityMatrix([[alpha]]) - assert np.allclose(dm.subs(alpha, 1).rho, np.array([1])) + assert np.allclose(dm.with_parameter(alpha, 1).rho, np.array([1])) + with pytest.raises(TypeError): + np.allclose(dm.rho, np.array([1])) + dm.replace_parameter(alpha, 1) + assert np.allclose(dm.rho, np.array([1])) -def test_density_matrix_xreplace() -> None: +def test_density_matrix_with_parameters() -> None: alpha = Placeholder("alpha") beta = Placeholder("beta") dm = DensityMatrix([[alpha, beta], [alpha, beta]]) - assert np.allclose(dm.xreplace({alpha: 1, beta: 2}).rho, np.array([[1, 2], [1, 2]])) + assert np.allclose(dm.with_parameters({alpha: 1, beta: 2}).rho, np.array([[1, 2], [1, 2]])) + with pytest.raises(TypeError): + np.allclose(dm.rho, np.array([[1, 2], [1, 2]])) + dm.replace_parameters({alpha: 1, beta: 2}) + assert np.allclose(dm.rho, np.array([[1, 2], [1, 2]])) @pytest.mark.parametrize("jumps", range(1, 11)) -@pytest.mark.parametrize("use_xreplace", [False, True]) -def test_random_circuit_with_parameters(fx_bg: PCG64, jumps: int, use_xreplace: bool) -> None: +@pytest.mark.parametrize("use_parallel", [False, True]) +def test_random_circuit_with_parameters(fx_bg: PCG64, jumps: int, use_parallel: bool) -> None: rng = np.random.Generator(fx_bg.jumped(jumps)) nqubits = 5 depth = 5 @@ -156,16 +206,20 @@ def test_random_circuit_with_parameters(fx_bg: PCG64, jumps: int, use_xreplace: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.minimize_space() assignment: dict[Parameter, float] = {alpha: rng.uniform(high=2), beta: rng.uniform(high=2)} - if use_xreplace: - state = circuit.xreplace(assignment).simulate().state - state_mbqc = pattern.xreplace(assignment).simulate(rng=rng) + if use_parallel: + circuit.replace_parameters(assignment) + pattern.replace_parameters(assignment) else: - state = circuit.subs(alpha, assignment[alpha]).subs(beta, assignment[beta]).simulate().state - state_mbqc = pattern.subs(alpha, assignment[alpha]).subs(beta, assignment[beta]).simulate(rng=rng) + circuit.replace_parameter(alpha, assignment[alpha]) + circuit.replace_parameter(beta, assignment[beta]) + pattern.replace_parameter(alpha, assignment[alpha]) + pattern.replace_parameter(beta, assignment[beta]) + state = circuit.simulate().state + state_mbqc = pattern.simulate(rng=rng) assert state_mbqc.isclose(state) diff --git a/tests/test_pattern.py b/tests/test_pattern.py index d41b55168..fe2a48ead 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -71,7 +71,7 @@ def test_standardize(self, fx_rng: Generator) -> None: depth = 1 circuit = rand_circuit(nqubits, depth, fx_rng) pattern = circuit.transpile().pattern - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.standardize() assert pattern.is_standard() state = circuit.simulate().state @@ -155,7 +155,7 @@ def test_minimize_space_with_gflow(self, fx_bg: PCG64, jumps: int) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals(method="mc") - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.minimize_space() state = circuit.simulate().state @@ -235,7 +235,7 @@ def test_pauli_measurement_random_circuit( pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals(method="mc") - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.minimize_space() state = circuit.simulate().state @@ -251,7 +251,7 @@ def test_pauli_measurement_random_circuit_all_paulis(self, fx_bg: PCG64, jumps: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals(method="mc") - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() input_node_set = set(pattern.input_nodes) assert not any( @@ -290,7 +290,7 @@ def test_pauli_measurement(self) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals(method="mc") - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern_opt = pattern.remove_pauli_measurements(copy=True) isolated_nodes = pattern_opt.isolated_nodes() assert isolated_nodes == set() @@ -309,7 +309,7 @@ def test_pauli_measured_against_nonmeasured(self, fx_bg: PCG64, jumps: int) -> N pattern = circuit.transpile().pattern pattern.minimize_space() pattern1 = copy.deepcopy(pattern) - pattern1 = pattern1.infer_pauli_measurements() + pattern1.infer_pauli_measurements() pattern1.remove_pauli_measurements() state = pattern.simulate(rng=rng) state1 = pattern1.simulate(rng=rng) @@ -415,7 +415,7 @@ def test_pauli_measurement_then_standardize(self, fx_bg: PCG64, jumps: int) -> N depth = 3 circuit = rand_circuit(nqubits, depth, rng) pattern = circuit.transpile().pattern - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.standardize() pattern.minimize_space() @@ -683,7 +683,7 @@ def test_compose_7(self, fx_rng: Generator) -> None: circuit_1.rz(0, alpha) p1 = circuit_1.transpile().pattern p1.remove_input_nodes() - p1 = p1.infer_pauli_measurements() + p1.infer_pauli_measurements() p1.remove_pauli_measurements() circuit_2 = Circuit(1) @@ -804,12 +804,12 @@ def test_partial_order_layers_results(self) -> None: c = Circuit(1) c.rz(0, 0.2) p = c.transpile().pattern - p = p.infer_pauli_measurements() + p.infer_pauli_measurements() p.remove_pauli_measurements() assert p.partial_order_layers() == (frozenset({1}), frozenset({0})) p = Pattern(cmds=[N(0), N(1), N(2), M(0), E((1, 2)), X(1, {0}), M(2, Measurement.XY(0.3))]) - p = p.infer_pauli_measurements() + p.infer_pauli_measurements() p.remove_pauli_measurements() assert p.partial_order_layers() == (frozenset({1}), frozenset({2})) @@ -936,10 +936,10 @@ def test_to_causalflow_rnd_circuit(self, fx_bg: PCG64, jumps: int) -> None: depth = 2 circuit_1 = rand_circuit(nqubits, depth, rng, use_ccx=False) p_ref = circuit_1.transpile().pattern - p_test = p_ref.to_bloch().to_causalflow().to_xzcorrections().to_pattern().infer_pauli_measurements() + p_ref.infer_pauli_measurements() + p_test = p_ref.to_bloch().to_causalflow().to_xzcorrections().to_pattern() + p_test.infer_pauli_measurements() - p_ref = p_ref.infer_pauli_measurements() - p_test = p_test.infer_pauli_measurements() p_ref.remove_pauli_measurements() p_test.remove_pauli_measurements() @@ -956,10 +956,10 @@ def test_to_gflow_rnd_circuit(self, fx_bg: PCG64, jumps: int) -> None: depth = 2 circuit_1 = rand_circuit(nqubits, depth, rng, use_ccx=False) p_ref = circuit_1.transpile().pattern - p_test = p_ref.to_bloch().to_gflow().to_xzcorrections().to_pattern().infer_pauli_measurements() + p_ref.infer_pauli_measurements() + p_test = p_ref.to_bloch().to_gflow().to_xzcorrections().to_pattern() + p_test.infer_pauli_measurements() - p_ref = p_ref.infer_pauli_measurements() - p_test = p_test.infer_pauli_measurements() p_ref.remove_pauli_measurements() p_test.remove_pauli_measurements() @@ -976,7 +976,9 @@ def test_to_pauliflow_rnd_circuit(self, fx_bg: PCG64, jumps: int) -> None: depth = 2 circuit_1 = rand_circuit(nqubits, depth, rng, use_ccx=False) p_ref = circuit_1.transpile().pattern - p_test = p_ref.to_bloch().to_pauliflow().to_xzcorrections().to_pattern().infer_pauli_measurements() + p_ref.infer_pauli_measurements() + p_test = p_ref.to_bloch().to_pauliflow().to_xzcorrections().to_pattern() + p_test.infer_pauli_measurements() p_ref.remove_pauli_measurements() p_test.remove_pauli_measurements() @@ -1089,9 +1091,9 @@ def test_extract_xzc_rnd_circuit(self, fx_bg: PCG64, jumps: int) -> None: xzc.check_well_formed() p_test = xzc.to_pattern() - p_ref = p_ref.infer_pauli_measurements() + p_ref.infer_pauli_measurements() p_ref.remove_pauli_measurements() - p_test = p_test.infer_pauli_measurements() + p_test.infer_pauli_measurements() p_test.remove_pauli_measurements() s_ref = p_ref.simulate(rng=rng) diff --git a/tests/test_qasm3_exporter.py b/tests/test_qasm3_exporter.py index 651335e9c..723e167c1 100644 --- a/tests/test_qasm3_exporter.py +++ b/tests/test_qasm3_exporter.py @@ -55,7 +55,7 @@ def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: depth = 5 circuit = rand_circuit(nqubits, depth, rng=rng) pattern = circuit.transpile().pattern - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.minimize_space() _qasm3 = pattern_to_qasm3(pattern) diff --git a/tests/test_qasm3_exporter_to_graphix_parser.py b/tests/test_qasm3_exporter_to_graphix_parser.py index 9e31e9eb9..87dcd7d16 100644 --- a/tests/test_qasm3_exporter_to_graphix_parser.py +++ b/tests/test_qasm3_exporter_to_graphix_parser.py @@ -7,10 +7,12 @@ import pytest from numpy.random import PCG64, Generator -from graphix import Circuit, instruction +from graphix import Circuit, Instruction from graphix.fundamentals import ANGLE_PI +from graphix.instruction import InstructionKind from graphix.qasm3_exporter import circuit_to_qasm3 from graphix.random_objects import rand_circuit +from tests.test_instruction import ALL_INSTRUCTIONS if TYPE_CHECKING: from graphix.instruction import InstructionType @@ -46,32 +48,15 @@ def test_circuit_to_qasm3(fx_bg: PCG64, jumps: int) -> None: check_round_trip(rand_circuit(nqubits, depth, rng, use_j=True, use_cz=True)) -@pytest.mark.parametrize( - "instruction", - [ - instruction.CCX(target=0, controls=(1, 2)), - instruction.RZZ(target=0, control=1, angle=ANGLE_PI / 4), - instruction.CNOT(target=0, control=1), - instruction.SWAP(targets=(0, 1)), - instruction.CZ(targets=(0, 1)), - instruction.H(target=0), - instruction.S(target=0), - instruction.X(target=0), - instruction.Y(target=0), - instruction.Z(target=0), - instruction.I(target=0), - instruction.RX(target=0, angle=ANGLE_PI / 4), - instruction.RY(target=0, angle=ANGLE_PI / 4), - instruction.RZ(target=0, angle=ANGLE_PI / 4), - instruction.J(target=0, angle=ANGLE_PI / 4), - ], -) +@pytest.mark.parametrize("instruction", ALL_INSTRUCTIONS) def test_instruction_to_qasm3(instruction: InstructionType) -> None: + if instruction.kind == InstructionKind.M: + pytest.skip() check_round_trip(Circuit(3, instr=[instruction])) def test_j_to_qasm3() -> None: - circuit = Circuit(3, instr=[instruction.J(target=0, angle=ANGLE_PI / 4)]) + circuit = Circuit(3, instr=[Instruction.J(target=0, angle=ANGLE_PI / 4)]) qasm = circuit_to_qasm3(circuit) parser = OpenQASMParser() parsed_circuit = parser.parse_str(qasm) @@ -79,6 +64,6 @@ def test_j_to_qasm3() -> None: def test_j_to_qasm3_failure() -> None: - circuit = Circuit(3, instr=[instruction.J(target=0, angle=ANGLE_PI / 4)]) + circuit = Circuit(3, instr=[Instruction.J(target=0, angle=ANGLE_PI / 4)]) with pytest.raises(ValueError): circuit_to_qasm3(circuit, transpile=False) diff --git a/tests/test_qasm3_exporter_to_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py index 31fdfb5f5..9b0ff7028 100644 --- a/tests/test_qasm3_exporter_to_qiskit.py +++ b/tests/test_qasm3_exporter_to_qiskit.py @@ -119,7 +119,7 @@ def test_to_qasm3_random_circuit(fx_bg: PCG64, jumps: int) -> None: depth = 5 circuit = rand_circuit(nqubits, depth, rng=rng, use_j=True) pattern = circuit.transpile().pattern - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.minimize_space() diff --git a/tests/test_remove_pauli_measurements.py b/tests/test_remove_pauli_measurements.py index e5353d871..e41eeb1d4 100644 --- a/tests/test_remove_pauli_measurements.py +++ b/tests/test_remove_pauli_measurements.py @@ -257,7 +257,7 @@ def test_pattern_remove_pauli_measurements() -> None: circuit = Circuit(2) circuit.cnot(0, 1) pattern = circuit.transpile().pattern - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern2 = pattern.remove_pauli_measurements(copy=True) assert all_bloch_measurement_or_input_node( pattern2.input_nodes, (cmd for cmd in pattern2 if isinstance(cmd, Command.M)) diff --git a/tests/test_tnsim.py b/tests/test_tnsim.py index 8546cd7fe..cfd488791 100644 --- a/tests/test_tnsim.py +++ b/tests/test_tnsim.py @@ -330,7 +330,7 @@ def test_with_graphtrans(self, fx_bg: PCG64, jumps: int, fx_rng: Generator) -> N pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() state = circuit.simulate().state tn_mbqc = pattern.simulate(backend="tensornetwork", rng=fx_rng) @@ -349,7 +349,7 @@ def test_with_graphtrans_sequential(self, fx_bg: PCG64, jumps: int, fx_rng: Gene pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() state = circuit.simulate().state tn_mbqc = pattern.simulate(backend="tensornetwork", graph_prep="sequential", rng=fx_rng) @@ -398,7 +398,7 @@ def test_evolve(self, fx_bg: PCG64, jumps: int, fx_rng: Generator) -> None: pattern = circuit.transpile().pattern pattern.standardize() pattern.shift_signals() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() state = circuit.simulate().state tn_mbqc = pattern.simulate(backend="tensornetwork", rng=fx_rng) diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py index 83da99a10..b14484b75 100644 --- a/tests/test_transpiler.py +++ b/tests/test_transpiler.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy from typing import TYPE_CHECKING import numpy as np @@ -17,6 +18,7 @@ from graphix.states import BasicStates from graphix.transpiler import Circuit, OutputIndex, OutputKind, decompose_ccx, transpile_swaps from tests.test_branch_selector import CheckedBranchSelector +from tests.test_instruction import VisitAngle if TYPE_CHECKING: from collections.abc import Callable @@ -183,10 +185,14 @@ def test_transpile_swaps_with_measurements(self, fx_bg: PCG64, jumps: int, axis: circuit.cnot(1, 2) circuit.m(1, axis) circuit.i(0) - transpiled_swaps = transpile_swaps(circuit) + transpiled_swaps = transpile_swaps(circuit, copy=True) circuit2 = transpiled_swaps.circuit assert not any(instr.kind == InstructionKind.SWAP for instr in circuit2.instruction) assert I(2) in circuit2.instruction + assert any(instr.kind == InstructionKind.SWAP for instr in circuit.instruction) + circuit_copy = deepcopy(circuit) + transpile_swaps(circuit_copy) + assert circuit_copy.instruction == circuit2.instruction input_state = rand_state_vector(3, rng=rng) branch_selector = ConstBranchSelector(outcome) state = circuit.simulate(rng=rng, input_state=input_state, branch_selector=branch_selector).state @@ -348,7 +354,7 @@ def test_transpile_swaps(fx_bg: PCG64, jumps: int) -> None: depth = 6 circuit = rand_circuit(nqubits, depth, rng, use_ccx=True, use_rzz=True) assert any(instr.kind == InstructionKind.SWAP for instr in circuit.instruction) - transpiled_swaps = transpile_swaps(circuit) + transpiled_swaps = transpile_swaps(circuit, copy=True) circuit2 = transpiled_swaps.circuit assert not any(instr.kind == InstructionKind.SWAP for instr in circuit2.instruction) state = circuit.simulate(rng=rng).state @@ -368,7 +374,7 @@ def test_transpile_swaps_with_measurements(fx_bg: PCG64, jumps: int, axis: Axis, circuit.cnot(1, 2) circuit.m(1, axis) circuit.i(0) - transpiled_swaps = transpile_swaps(circuit) + transpiled_swaps = transpile_swaps(circuit, copy=True) circuit2 = transpiled_swaps.circuit assert not any(instr.kind == InstructionKind.SWAP for instr in circuit2.instruction) assert I(2) in circuit2.instruction @@ -410,3 +416,13 @@ def test_backend_branch_selector() -> None: circ = Circuit(1) with pytest.raises(ValueError, match="already instantiated"): circ.simulate(backend=StatevectorBackend(), branch_selector=ConstBranchSelector(0)) + + +def test_visit() -> None: + circ = Circuit(1) + circ.rx(0, 0.5) + visitor = VisitAngle() + circ2 = circ.visit(visitor, copy=True) + assert circ.instruction != circ2.instruction + assert circ.visit(visitor) is circ + assert circ.instruction == circ2.instruction diff --git a/tests/test_visualization.py b/tests/test_visualization.py index d9e0b1e92..6b02cd9d6 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -158,7 +158,7 @@ def example_hadamard() -> Pattern: def example_local_clifford() -> Pattern: pattern = example_hadamard() - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() return pattern @@ -272,9 +272,9 @@ def test_draw_graph_reference(flow_from_pattern_and_to_bloch: bool) -> Figure: circuit.cnot(2, 1) pattern = circuit.transpile().pattern if flow_from_pattern_and_to_bloch: - pattern = pattern.to_bloch() + pattern.blochify() else: - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.standardize() pattern.draw(