From 4c0e0015bee6635f186a30b6e52c9b54e028b930 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 18 Jul 2026 12:05:07 +0200 Subject: [PATCH 01/22] Add in-place variants for `infer_pauli_measurements`, `to_bloch`,... Method `Pattern.infer_pauli_measurements` now operates in place by default, with an optional `copy` parameter. If `copy=True`, a modified copy is returned instead. 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`, - `substitute`, an in-place variant of `subs`, - `replace_parameters`, an in-place variant of `xreplace`. This is a step toward the cleaner simulation API proposed in #493. It also makes the examples in the Graphix paper cleaner. --- CHANGELOG.md | 8 + examples/deutsch_jozsa.py | 2 +- examples/mbqc_vqe.py | 2 +- examples/qaoa.py | 2 +- examples/qft_with_tn.py | 2 +- examples/tn_simulation.py | 4 +- examples/visualization.py | 2 +- graphix/pattern.py | 186 +++++++++++++++++++++--- tests/test_optimization.py | 6 +- tests/test_parameter.py | 2 +- tests/test_pattern.py | 40 ++--- tests/test_qasm3_exporter.py | 2 +- tests/test_qasm3_exporter_to_qiskit.py | 2 +- tests/test_remove_pauli_measurements.py | 2 +- tests/test_tnsim.py | 6 +- tests/test_visualization.py | 6 +- 16 files changed, 215 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f40251f..41b9edd21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #545: Added an amplitude damping noise model. Introduces `amplitude_damping_channel` / `two_qubit_amplitude_damping_channel`, the `AmplitudeDampingNoise` / `TwoQubitAmplitudeDampingNoise` noise elements, and `AmplitudeDampingNoiseModel`. +- #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`, + - `substitute`, an in-place variant of `subs`, + - `replace_parameters`, an-in place variant of `xreplace`. + ### Fixed - #454, #481: Ensure `Pattern.minimize_space` only reduces max-space and does not increase it. @@ -100,6 +106,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #512: Method `Circuit.simulate_statevector` accepts a `backend: DenseStateBackend[_DenseStateT] | Literal["statevector", "densitymatrix"]` parameter. +- #568: Method `Pattern.infer_pauli_measurements` now operates in place by default, with an optional `copy` parameter. If `copy=True`, a modified copy is returned instead. + ## [0.3.5] - 2026-03-26 ### Added diff --git a/examples/deutsch_jozsa.py b/examples/deutsch_jozsa.py index 5f4d6dee4..c9f621812 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 da993fe37..51e11f46e 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 5cb991c2a..2a4e90be7 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 84a5e8b19..6e13d40ab 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_pattern(backend="tensornetwork", graph_prep="parallel") exp_val: float = 0 diff --git a/examples/visualization.py b/examples/visualization.py index d6e0e68de..dd2a663ef 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/pattern.py b/graphix/pattern.py index ddfebcf3d..156e05908 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1622,12 +1622,88 @@ def is_parameterized(self) -> bool: ) 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)) + """Return a copy of the pattern where all occurrences of the given variable in measurement angles are substituted by the given value. + + Equivalent to ``self.substitute(variable, substitute, copy=True)`` + + 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`. + + Returns + ------- + Pattern + The pattern with the updated measurement parameters. + """ + return self.substitute(variable, substitute, copy=True) + + def substitute(self, variable: Parameter, substitute: ExpressionOrSupportsFloat, *, copy: bool = False) -> Pattern: + """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 pattern remains unchanged and a + new pattern is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Pattern + The pattern with the updated measurement parameters. + Equal to ``self`` if ``copy`` is ``False``. + """ + return self.apply(lambda m: m.subs(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)) + """Return a copy of the pattern where all occurrences of the given keys in measurement angles are substituted by the given values in parallel. + + Equivalent to ``self.replace_parameters(assignment, copy=True)``. + + 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 pattern remains unchanged and a + new pattern is returned. The default is ``False``, meaning + that changes are performed in place. + + Returns + ------- + Pattern + The pattern with the updated measurement parameters. + """ + return self.replace_parameters(assignment, copy=True) + + def replace_parameters( + self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat], *, copy: bool = False + ) -> Pattern: + """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. + + Returns + ------- + Pattern + The pattern with the updated measurement parameters. + Equal to ``self`` if ``copy`` is ``False``. + """ + return self.apply(lambda m: m.xreplace(assignment), copy=copy) def copy(self) -> Pattern: """Return a copy of the pattern.""" @@ -1700,9 +1776,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] @@ -1720,20 +1835,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 ---------- @@ -1743,11 +1851,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 ------- @@ -1757,11 +1871,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 @@ -1769,15 +1913,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/tests/test_optimization.py b/tests/test_optimization.py index 5c386d8d3..e67325250 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 85d0e7ecf..f5083951c 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -170,7 +170,7 @@ 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)} diff --git a/tests/test_pattern.py b/tests/test_pattern.py index 6e16278d1..5b187ce65 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_statevector().statevec @@ -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_statevector().statevec @@ -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_statevector().statevec @@ -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.extract_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_pattern(rng=rng) state1 = pattern1.simulate_pattern(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_extract_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.extract_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.extract_partial_order_layers() == (frozenset({1}), frozenset({2})) @@ -936,10 +936,10 @@ def test_extract_causal_flow_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().extract_causal_flow().to_corrections().to_pattern().infer_pauli_measurements() + p_ref.infer_pauli_measurements() + p_test = p_ref.to_bloch().extract_causal_flow().to_corrections().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_extract_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().extract_gflow().to_corrections().to_pattern().infer_pauli_measurements() + p_ref.infer_pauli_measurements() + p_test = p_ref.to_bloch().extract_gflow().to_corrections().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_extract_pauli_flow_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().extract_pauli_flow().to_corrections().to_pattern().infer_pauli_measurements() + p_ref.infer_pauli_measurements() + p_test = p_ref.to_bloch().extract_pauli_flow().to_corrections().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_pattern(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_qiskit.py b/tests/test_qasm3_exporter_to_qiskit.py index 77c5bab76..343c73683 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 10870ab36..0c7820797 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 2bb76a21b..f80d88b2b 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_statevector().statevec tn_mbqc = pattern.simulate_pattern(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_statevector().statevec tn_mbqc = pattern.simulate_pattern(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_statevector().statevec tn_mbqc = pattern.simulate_pattern(backend="tensornetwork", rng=fx_rng) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 598082bc8..a67e11c9f 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 @@ -256,9 +256,9 @@ def test_draw_graph_reference(flow_and_not_pauli_presimulate: bool) -> Figure: # Pauli flow extraction from pattern is not implemented yet; # therefore, the pattern should not contain Pauli measurements # to have causal flow. - pattern = pattern.to_bloch() + pattern.blochify() else: - pattern = pattern.infer_pauli_measurements() + pattern.infer_pauli_measurements() pattern.remove_pauli_measurements() pattern.standardize() pattern.draw( From ef37b2e72e509dc80e81e993cba08b72856af022 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 18 Jul 2026 22:07:52 +0200 Subject: [PATCH 02/22] Fix docstrings of `xreplace` and `replace_parameters` --- graphix/pattern.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index 156e05908..5ca4ead3e 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1675,11 +1675,6 @@ def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> 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 pattern remains unchanged and a - new pattern is returned. The default is ``False``, meaning - that changes are performed in place. - Returns ------- Pattern @@ -1697,6 +1692,11 @@ def replace_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 pattern remains unchanged and a + new pattern is returned. The default is ``False``, meaning + that changes are performed in place. + Returns ------- Pattern From 86f69ce6fa195b3b9ea0e501d2fd911314b5e1c1 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Sat, 18 Jul 2026 22:30:21 +0200 Subject: [PATCH 03/22] Fix docstrings for `subs` --- graphix/opengraph.py | 2 +- graphix/pattern.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 3f1cba960..ca5a1b61e 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -758,7 +758,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 ------- diff --git a/graphix/pattern.py b/graphix/pattern.py index 5ca4ead3e..6365d1b9a 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1632,7 +1632,7 @@ def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Pa 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 ------- @@ -1650,7 +1650,7 @@ def substitute(self, variable: Parameter, substitute: ExpressionOrSupportsFloat, 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``. copy : bool, optional If ``True``, the current pattern remains unchanged and a From 7432c1cc7abc63f93507db93aea9424f678dd7a9 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 14:34:55 +0200 Subject: [PATCH 04/22] Uniformize `replace_parameter(s)` and `with_parameter(s)` --- CHANGELOG.md | 15 +- graphix/flow/core.py | 27 ++- graphix/instruction.py | 181 ++++++++++------- graphix/measurements.py | 26 +-- graphix/opengraph.py | 26 ++- graphix/parameter.py | 190 +++++++++++++++--- graphix/pattern.py | 95 ++------- graphix/sim/density_matrix.py | 52 +++-- graphix/sim/statevec.py | 47 +++-- graphix/transpiler.py | 98 +++++++-- tests/test_circ_extraction.py | 5 +- tests/test_flow_core.py | 4 +- tests/test_instruction.py | 95 +++++++++ tests/test_parameter.py | 39 ++-- .../test_qasm3_exporter_to_graphix_parser.py | 31 +-- tests/test_transpiler.py | 11 +- 16 files changed, 612 insertions(+), 330 deletions(-) create mode 100644 tests/test_instruction.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b9edd21..efff56324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,11 +45,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #545: Added an amplitude damping noise model. Introduces `amplitude_damping_channel` / `two_qubit_amplitude_damping_channel`, the `AmplitudeDampingNoise` / `TwoQubitAmplitudeDampingNoise` noise elements, and `AmplitudeDampingNoiseModel`. -- #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`, - - `substitute`, an in-place variant of `subs`, - - `replace_parameters`, an-in place variant of `xreplace`. +- #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`, `Statevector`, `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 @@ -106,7 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #512: Method `Circuit.simulate_statevector` accepts a `backend: DenseStateBackend[_DenseStateT] | Literal["statevector", "densitymatrix"]` parameter. -- #568: Method `Pattern.infer_pauli_measurements` now operates in place by default, with an optional `copy` parameter. If `copy=True`, a modified copy is returned instead. +- #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/graphix/flow/core.py b/graphix/flow/core.py index 6d066214d..fdc1a3c69 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 @@ -537,10 +541,11 @@ def subs(self: XZCorrections[_M], variable: Parameter, substitute: ExpressionOrS ----- See notes and examples in :func:`OpenGraph.subs`. """ - 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. @@ -559,12 +564,12 @@ def xreplace( ----- See notes and examples in :func:`OpenGraph.xreplace`. """ - 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. @@ -879,10 +885,11 @@ def subs( # noqa: PYI019 Annotating with ``Self`` is not possible since ``self` ----- See notes and examples in :func:`OpenGraph.subs`. """ - 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. @@ -901,7 +908,7 @@ def xreplace( # noqa: PYI019 ----- See notes and examples in :func:`OpenGraph.xreplace`. """ - 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 55307b9d4..30929af96 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 try_to_pauli(self, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> PauliMea >>> alpha = Placeholder("alpha") >>> Measurement.XY(alpha).try_to_pauli() is None True - >>> Measurement.XY(alpha).subs(alpha, 0.5).try_to_pauli() + >>> Measurement.XY(alpha).with_parameter(alpha, 0.5).try_to_pauli() 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 ca5a1b61e..421c33aa5 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 @@ -748,7 +752,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. @@ -786,12 +793,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. @@ -824,9 +834,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..f5f7ea938 100644 --- a/graphix/parameter.py +++ b/graphix/parameter.py @@ -12,7 +12,10 @@ 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, Self, SupportsComplex, SupportsFloat, TypeAlias, TypeVar, overload + +# override introduced in Python 3.12 +from typing_extensions import override if TYPE_CHECKING: from collections.abc import Mapping @@ -92,19 +95,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 +177,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 +205,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 +221,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 +258,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, value: ExpressionOrSupportsFloat + ) -> AffineExpression | ExpressionOrComplex: """Look to the documentation in the parent class.""" if variable == self.x: return self.evaluate(value) 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 +310,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,21 +370,23 @@ 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 + `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 @@ -375,7 +399,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,23 +408,25 @@ 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 + `value.with_parameters(assignment)` (coerced into a complex if the result is a number). If `value` does not implement `xreplace`, `value` is returned @@ -413,7 +439,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 +468,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 6365d1b9a..17210bc4d 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, Statevec @@ -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. @@ -1621,89 +1624,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. - - Equivalent to ``self.substitute(variable, substitute, copy=True)`` - - 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``. - - Returns - ------- - Pattern - The pattern with the updated measurement parameters. - """ - return self.substitute(variable, substitute, copy=True) - - def substitute(self, variable: Parameter, substitute: ExpressionOrSupportsFloat, *, copy: bool = False) -> Pattern: - """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 pattern remains unchanged and a - new pattern is returned. The default is ``False``, meaning - that changes are performed in place. - - Returns - ------- - Pattern - The pattern with the updated measurement parameters. - Equal to ``self`` if ``copy`` is ``False``. - """ - return self.apply(lambda m: m.subs(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. - - Equivalent to ``self.replace_parameters(assignment, copy=True)``. - - 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. - - Returns - ------- - Pattern - The pattern with the updated measurement parameters. - """ - return self.replace_parameters(assignment, copy=True) + @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) + @override def replace_parameters( self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat], *, copy: bool = False ) -> Pattern: - """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 pattern remains unchanged and a - new pattern is returned. The default is ``False``, meaning - that changes are performed in place. - - Returns - ------- - Pattern - The pattern with the updated measurement parameters. - Equal to ``self`` if ``copy`` is ``False``. - """ - return self.apply(lambda m: m.xreplace(assignment), copy=copy) + return self.apply(lambda m: m.with_parameters(assignment), copy=copy) def copy(self) -> Pattern: """Return a copy of the pattern.""" diff --git a/graphix/sim/density_matrix.py b/graphix/sim/density_matrix.py index 6aff2c05a..39df71c65 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,9 +16,15 @@ from typing_extensions import override from graphix import linalg_validations as lv -from graphix import parameter from graphix.channels import KrausChannel -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 CNOT_TENSOR, CZ_TENSOR, SWAP_TENSOR, Statevec, _check_permutation @@ -33,7 +39,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, loc: 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_kraus_channel() 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 cb45e1eb5..6e79c250c 100644 --- a/graphix/sim/statevec.py +++ b/graphix/sim/statevec.py @@ -14,8 +14,15 @@ import numpy.typing as npt from typing_extensions import override -from graphix import parameter, states -from graphix.parameter import Expression, ExpressionOrSupportsComplex, check_expression_or_float +from graphix import states +from graphix.parameter import ( + Expression, + ExpressionOrSupportsComplex, + InplaceParameterizable, + check_expression_or_float, + with_parameter, + with_parameters, +) from graphix.pretty_print import OutputFormat, statevec_to_str from graphix.sim.base_backend import DenseState, DenseStateBackend, Matrix, kron, tensordot from graphix.states import BasicStates @@ -45,7 +52,7 @@ ) -class Statevec(DenseState): +class Statevec(DenseState, InplaceParameterizable): """Statevector object.""" psi: Matrix @@ -601,17 +608,29 @@ def _to_dict_map( return {_format_encoding(self.nqubit, i, encoding): amp for i, amp in zip(i_vals, amp_vals, strict=True)} - def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> Statevec: - """Return a copy of the state vector where all occurrences of the given variable in measurement angles are substituted by the given value.""" - result = Statevec() - result.psi = np.vectorize(lambda value: parameter.subs(value, variable, substitute))(self.psi) - return result - - def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> Statevec: - """Return a copy of the state vector where all occurrences of the given keys in measurement angles are substituted by the given values in parallel.""" - result = Statevec() - result.psi = np.vectorize(lambda value: parameter.xreplace(value, assignment))(self.psi) - return result + @override + def replace_parameter( + self, variable: Parameter, substitute: ExpressionOrSupportsFloat, *, copy: bool = False + ) -> Statevec: + psi = np.vectorize(lambda value: with_parameter(value, variable, substitute))(self.psi) + if copy: + result = Statevec() + result.psi = np.vectorize(lambda value: with_parameter(value, variable, substitute))(self.psi) + return result + self.psi = psi + return self + + @override + def replace_parameters( + self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat], *, copy: bool = False + ) -> Statevec: + psi = np.vectorize(lambda value: with_parameters(value, assignment))(self.psi) + if copy: + result = Statevec() + result.psi = np.vectorize(lambda value: with_parameters(value, assignment))(self.psi) + return result + self.psi = psi + return self @dataclass(frozen=True) diff --git a/graphix/transpiler.py b/graphix/transpiler.py index aa49d76bb..0afb6c2e8 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 @@ -110,7 +111,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. @@ -518,7 +519,7 @@ def transpile(self, *, transpile_swaps: bool = True) -> TranspiledPattern: """ if not transpile_swaps: return self.transpile_to_causal_flow().to_pattern() - swap = _transpile_swaps(self) + swap = _transpile_swaps(self, copy=True) result = swap.circuit.transpile_to_causal_flow().to_pattern() result.pattern.reorder_output_nodes(swap.swap_output_nodes(result.pattern.output_nodes)) classical_outputs = swap.swap_classical_outputs(result.classical_outputs) @@ -648,16 +649,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: """ @@ -676,13 +713,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.""" @@ -1029,7 +1070,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 @@ -1037,11 +1078,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 @@ -1059,13 +1107,21 @@ def transpile_swaps(circuit: Circuit) -> TranspileSwapsResult: visitor.visit_qubit(v) visitor.outputs[u], visitor.outputs[v] = visitor.outputs[v], visitor.outputs[u] else: - new_circuit.add(instr.visit(visitor)) if instr.kind == InstructionKind.M: - visitor.outputs[instr.target] = OutputIndex(OutputKind.Bit, measurement_index) + old_target = instr.target + new_instr = instr.visit(visitor, copy=copy) + new_circuit.add(new_instr) + if instr.kind == InstructionKind.M: + visitor.outputs[old_target] = OutputIndex(OutputKind.Bit, measurement_index) measurement_index += 1 + 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/tests/test_circ_extraction.py b/tests/test_circ_extraction.py index cdd6e1549..e5f8b217e 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.extract_circuit() - s1 = qc1.subs(alpha, alpha_val).simulate_statevector(rng=fx_rng).statevec + qc1.replace_parameter(alpha, alpha_val) + s1 = qc1.simulate_statevector(rng=fx_rng).statevec # 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().extract_circuit() + qc2 = og.with_parameter(alpha, alpha_val).infer_pauli_measurements().extract_circuit() s2 = qc2.simulate_statevector(rng=fx_rng).statevec assert s1.isclose(s2) diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 4e52a9943..9d3fccea0 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -437,7 +437,7 @@ def test_subs(self) -> None: ) flow_ref = og_ref.extract_pauli_flow() - 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) @@ -728,7 +728,7 @@ def test_subs(self) -> None: ) xzcorr_ref = og_ref.extract_causal_flow().to_corrections() - 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_parameter.py b/tests/test_parameter.py index f5083951c..d4d4a4ba6 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -44,7 +44,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: @@ -54,7 +54,7 @@ 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))] @@ -66,10 +66,10 @@ def test_instantiated_pattern_simulation(fx_rng: Generator) -> None: 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_pattern(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()) == [ @@ -87,9 +87,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) == [ @@ -135,32 +136,32 @@ def test_parallel_substitution_with_zero() -> None: def test_statevec_subs() -> None: alpha = Placeholder("alpha") statevec = Statevec([alpha]) - assert np.allclose(statevec.subs(alpha, 1).psi, np.array([1])) + assert np.allclose(statevec.with_parameter(alpha, 1).psi, np.array([1])) def test_statevec_xreplace() -> None: alpha = Placeholder("alpha") beta = Placeholder("beta") statevec = Statevec([alpha, beta]) - assert np.allclose(statevec.xreplace({alpha: 1, beta: 2}).psi, np.array([1, 2])) + assert np.allclose(statevec.with_parameters({alpha: 1, beta: 2}).psi, np.array([1, 2])) def test_density_matrix_subs() -> 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])) def test_density_matrix_xreplace() -> 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]])) @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 @@ -174,12 +175,16 @@ def test_random_circuit_with_parameters(fx_bg: PCG64, jumps: int, use_xreplace: 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_statevector().statevec - state_mbqc = pattern.xreplace(assignment).simulate_pattern(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_statevector().statevec - state_mbqc = pattern.subs(alpha, assignment[alpha]).subs(beta, assignment[beta]).simulate_pattern(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_statevector().statevec + state_mbqc = pattern.simulate_pattern(rng=rng) assert state_mbqc.isclose(state) 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_transpiler.py b/tests/test_transpiler.py index f3ae5f181..1b80adbe9 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 @@ -185,10 +186,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_statevector(rng=rng, input_state=input_state, branch_selector=branch_selector).statevec @@ -352,7 +357,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_statevector(rng=rng).statevec @@ -372,7 +377,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 From 0a4de18c6e8721e008d757f332e4b1e44d3a8df4 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 16:08:30 +0200 Subject: [PATCH 05/22] Add `AbstractStatevector` for pretty-printing symbolic statevectors --- graphix/pretty_print.py | 11 +- graphix/sim/statevec.py | 442 +++++++++++++++++++++------------------- 2 files changed, 239 insertions(+), 214 deletions(-) diff --git a/graphix/pretty_print.py b/graphix/pretty_print.py index 5623602d7..1b4c065e7 100644 --- a/graphix/pretty_print.py +++ b/graphix/pretty_print.py @@ -22,13 +22,18 @@ if TYPE_CHECKING: from collections.abc import Container, Iterable, Mapping, Sequence from collections.abc import Set as AbstractSet + from typing import Any, TypeVar + + import numpy as np from graphix.command import Node from graphix.flow.core import PauliFlow, XZCorrections from graphix.fundamentals import Angle from graphix.pattern import Pattern from graphix.sim.density_matrix import DensityMatrix - from graphix.sim.statevec import _ENCODING, Statevector + from graphix.sim.statevec import _ENCODING, AbstractStatevector + + _ScalarT = TypeVar("_ScalarT", bound=np.generic[Any]) class OutputFormat(Enum): @@ -870,7 +875,7 @@ def _factor_uniform_magnitude( def statevec_to_str( - statevec: Statevector, + statevec: AbstractStatevector[_ScalarT], output: OutputFormat | None = None, *, encoding: _ENCODING = "MSB", @@ -886,7 +891,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/statevec.py b/graphix/sim/statevec.py index 6cfc40c53..0eeafb657 100644 --- a/graphix/sim/statevec.py +++ b/graphix/sim/statevec.py @@ -8,9 +8,10 @@ import dataclasses import functools import math +from abc import abstractmethod from collections.abc import Iterable from dataclasses import dataclass -from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat +from typing import TYPE_CHECKING, Any, Generic, SupportsComplex, SupportsFloat, TypeVar import numba as nb import numpy as np @@ -30,7 +31,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 @@ -40,6 +41,9 @@ _ENCODING = Literal["LSB", "MSB"] _ScalarT = TypeVar("_ScalarT", bound=np.generic[Any]) + _ScalarT2 = TypeVar("_ScalarT2", bound=np.generic[Any]) +else: + _ScalarT = TypeVar("_ScalarT", bound=np.generic) NUM_QUBIT_PARALLEL = 15 @@ -49,7 +53,230 @@ This number was determined empirically and may be platform dependent.""" -class Statevector(DenseState): +class AbstractStatevector(DenseState, Generic[_ScalarT]): + """Base class for statevectors. + + This base class exposes methods that can be shared with other implementations. + """ + + @property + @abstractmethod + def psi(self) -> npt.NDArray[_ScalarT]: + """Return the statevector as a numpy array.""" + ... + + @override + def flatten(self) -> Matrix: + """Return flattened state vector. + + A view of only the first ``2**self.nqubit`` elements of ``self._psi`` is returned. + """ + if self.psi.dtype == np.object_: + return self.psi.astype(np.object_, copy=False) + return self.psi.astype(np.complex128, copy=False) + + def fidelity(self, other: Statevector) -> float: + r"""Calculate the fidelity against another statevector. + + The fidelity is defined as :math:`|\langle\psi_1|\psi_2\rangle|^2`. + + Parameters + ---------- + other : :class:`graphix.sim.statevec.Statevector` + Statevector to compare with. + + Returns + ------- + float + Fidelity between the two statevectors. + """ + inner = np.dot(self.flatten().conjugate(), other.flatten()) + return float(np.abs(inner) ** 2) + + def isclose(self, other: Statevector, *, rtol: float = 1e-09, atol: float = 0.0) -> bool: + """Check if two quantum states are equal up to global phase. + + Two states are considered close if their fidelity is close to 1. + + Parameters + ---------- + other : :class:`graphix.sim.statevec.Statevector` + Statevector to compare with. + rtol : float + Relative tolerance for :func:`math.isclose`. + atol : float + Absolute tolerance for :func:`math.isclose`. + + Returns + ------- + bool + ``True`` if the states are equal up to global phase. + """ + return math.isclose(self.fidelity(other), 1, rel_tol=rtol, abs_tol=atol) + + def to_dict( + self, + encoding: _ENCODING = "MSB", + *, + rtol: float = 0.0, + atol: float = 1e-8, + ) -> dict[str, _ScalarT]: + r"""Convert the statevector to dictionary form. + + This dictionary representation uses a ket-like notation where the dictionary ``keys`` are qubit strings for the basis vectors and ``values`` are the corresponding complex amplitudes. Amplitudes below a certain threshold are filtered out. + + Parameters + ---------- + encoding : Literal["LSB", "MSB"], default="MSB" + Encoding for the basis kets. See notes for additional information. + + rtol : float, default=0.0 + Relative tolerance used when deciding whether a coefficient should be + treated as zero. Values whose magnitude is within this relative tolerance + of zero are omitted from the resulting dictionary. + + atol : float, default=1e-8 + Absolute tolerance used when deciding whether a coefficient should be + treated as zero. Values whose magnitude is within this relative tolerance + of zero are omitted from the resulting dictionary. + + Returns + ------- + dict[str, complex] + The statevector in dictionary form. + + Notes + ----- + The encoding determines the bit ordering convention used when mapping basis states to dictionary + keys. Consider a tensor product of three qubits: + + .. math:: + + \lvert\psi\rangle = q_0 \otimes q_1 \otimes q_2. + + If ``encoding == "MSB"`` the first qubit is represented in the Most Significant Bit -> ``q0q1q2``. This is the default representation in Graphix. + If ``encoding == "LSB"`` the first qubit is represented in the Least Significant Bit -> ``q2q1q0``. This is the default representation in other software packages such as Qiskit. + + Example + ------- + >>> from graphix.states import BasicStates + >>> from graphix.sim.statevec import Statevector + >>> sv = Statevector(data=[BasicStates.ZERO, BasicStates.ONE]) + >>> sv.to_dict() + {'01': np.complex128(1+0j)} + >>> sv.to_dict(encoding="LSB") + {'10': np.complex128(1+0j)} + """ + return self._to_dict_map(lambda x: x, encoding, rtol=rtol, atol=atol) + + def to_prob_dict( + self, encoding: _ENCODING = "MSB", *, rtol: float = 0.0, atol: float = 1e-8 + ) -> dict[str, np.object_ | np.float64]: + r"""Convert the statevector to a probability distirbution in a dictionary form. + + This dictionary representation uses a ket-like notation where the dictionary ``keys`` are qubit strings for the basis vectors and ``values`` are the corresponding probabilities. + Basis vector whose amplitude is below a certain threshold are filtered out. + + Parameters + ---------- + encoding: Literal["LSB", "MSB"], default="MSB" + Encoding for the basis kets. See :meth:`to_dict` for additional information. + + rtol : float, default=0.0 + Relative tolerance used when deciding whether a coefficient should be + treated as zero. Values whose magnitude is within this relative tolerance + of zero are omitted from the resulting dictionary. + + atol : float, default=1e-8 + Absolute tolerance used when deciding whether a coefficient should be + treated as zero. Values whose magnitude is within this relative tolerance + of zero are omitted from the resulting dictionary. + + Returns + ------- + dict[str, float] + The probability distribution associated to the statevector in dictionary form. + + See Also + -------- + .. :meth:`to_dict` + """ + return self._to_dict_map(lambda x: np.abs(x) ** 2, encoding, rtol=rtol, atol=atol) + + def draw( + self, + output: OutputFormat | None = None, + *, + encoding: _ENCODING = "MSB", + max_denominator: int = 1000, + atol: float = 1e-9, + rtol: float = 0.0, + precision: int = 4, + ) -> str: + r"""Return a pretty-printed ket-notation representation of the statevector. + + Amplitudes are rendered with :func:`graphix.pretty_print.complex_to_str`, + so common values appear as exact expressions (e.g. ``√2/2``) rather than + floating-point numbers. + + Parameters + ---------- + output : OutputFormat, optional + Desired formatting style. Defaults to :attr:`OutputFormat.Unicode`. + encoding : {"LSB", "MSB"}, optional + Bit-ordering convention for the basis kets (default: ``"MSB"``). + See :meth:`to_dict`. + max_denominator : int, optional + Maximum denominator used by the amplitude recognition (default: ``1000``). + atol : float, optional + Absolute tolerance for dropping near-zero amplitudes and for the + recognition heuristics (default: ``1e-9``). + rtol : float, optional + Relative tolerance for dropping near-zero amplitudes (default: ``0.0``). + precision : int, optional + Number of significant digits to use for amplitudes that fall back to + a decimal representation (default: ``4``). + + Returns + ------- + str + The formatted statevector. + + Examples + -------- + >>> from graphix.transpiler import Circuit + >>> circuit = Circuit(2) + >>> circuit.h(0) + >>> circuit.cz(0, 1) + >>> print(circuit.simulate_statevector().statevec.draw()) + sqrt(2)/2(|00> + |01>) + """ + return statevec_to_str( + self, + output, + encoding=encoding, + max_denominator=max_denominator, + atol=atol, + rtol=rtol, + precision=precision, + ) + + def _to_dict_map( + self, + f: Callable[[npt.NDArray[_ScalarT]], npt.NDArray[_ScalarT2]], + encoding: _ENCODING = "MSB", + *, + rtol: float = 0.0, + atol: float = 1e-8, + ) -> dict[str, _ScalarT2]: + mask = np.logical_not(np.isclose(np.abs(self.psi), 0, rtol=rtol, atol=atol)) + i_vals = np.arange(1 << self.nqubit)[mask] + amp_vals = f(self.flatten()[mask]) + + return {_format_encoding(self.nqubit, i, encoding): amp for i, amp in zip(i_vals, amp_vals, strict=True)} + + +class Statevector(AbstractStatevector[np.complex128]): """Statevector object. Attributes @@ -220,6 +447,7 @@ def __str__(self) -> str: return self.draw() @property + @override def psi(self) -> npt.NDArray[np.complex128]: r"""View of the meaningful elements in ``self._psi``. @@ -258,14 +486,6 @@ def ensure_capacity(self, required_qubits: int) -> None: self._psi = new_psi self._max_qubits = required_qubits - @override - def flatten(self) -> Matrix: - """Return flattened state vector. - - A view of only the first ``2**self.nqubit`` elements of ``self._psi`` is returned. - """ - return self.psi - @override def add_nodes(self, nqubit: int, data: Data) -> None: r"""Add nodes (qubits) to the state vector and initialize them in a specified state. @@ -549,206 +769,6 @@ def permute(self, permutation: Sequence[int]) -> None: len_psi = len(self.psi) self._psi[:len_psi] = psi_tensor_perm.reshape(len_psi) - def fidelity(self, other: Statevector) -> float: - r"""Calculate the fidelity against another statevector. - - The fidelity is defined as :math:`|\langle\psi_1|\psi_2\rangle|^2`. - - Parameters - ---------- - other : :class:`graphix.sim.statevec.Statevector` - Statevector to compare with. - - Returns - ------- - float - Fidelity between the two statevectors. - """ - inner = np.dot(self.flatten().conjugate(), other.flatten()) - return float(np.abs(inner) ** 2) - - def isclose(self, other: Statevector, *, rtol: float = 1e-09, atol: float = 0.0) -> bool: - """Check if two quantum states are equal up to global phase. - - Two states are considered close if their fidelity is close to 1. - - Parameters - ---------- - other : :class:`graphix.sim.statevec.Statevector` - Statevector to compare with. - rtol : float - Relative tolerance for :func:`math.isclose`. - atol : float - Absolute tolerance for :func:`math.isclose`. - - Returns - ------- - bool - ``True`` if the states are equal up to global phase. - """ - return math.isclose(self.fidelity(other), 1, rel_tol=rtol, abs_tol=atol) - - def to_dict( - self, - encoding: _ENCODING = "MSB", - *, - rtol: float = 0.0, - atol: float = 1e-8, - ) -> dict[str, np.object_ | np.complex128]: - r"""Convert the statevector to dictionary form. - - This dictionary representation uses a ket-like notation where the dictionary ``keys`` are qubit strings for the basis vectors and ``values`` are the corresponding complex amplitudes. Amplitudes below a certain threshold are filtered out. - - Parameters - ---------- - encoding : Literal["LSB", "MSB"], default="MSB" - Encoding for the basis kets. See notes for additional information. - - rtol : float, default=0.0 - Relative tolerance used when deciding whether a coefficient should be - treated as zero. Values whose magnitude is within this relative tolerance - of zero are omitted from the resulting dictionary. - - atol : float, default=1e-8 - Absolute tolerance used when deciding whether a coefficient should be - treated as zero. Values whose magnitude is within this relative tolerance - of zero are omitted from the resulting dictionary. - - Returns - ------- - dict[str, complex] - The statevector in dictionary form. - - Notes - ----- - The encoding determines the bit ordering convention used when mapping basis states to dictionary - keys. Consider a tensor product of three qubits: - - .. math:: - - \lvert\psi\rangle = q_0 \otimes q_1 \otimes q_2. - - If ``encoding == "MSB"`` the first qubit is represented in the Most Significant Bit -> ``q0q1q2``. This is the default representation in Graphix. - If ``encoding == "LSB"`` the first qubit is represented in the Least Significant Bit -> ``q2q1q0``. This is the default representation in other software packages such as Qiskit. - - Example - ------- - >>> from graphix.states import BasicStates - >>> from graphix.sim.statevec import Statevector - >>> sv = Statevector(data=[BasicStates.ZERO, BasicStates.ONE]) - >>> sv.to_dict() - {'01': np.complex128(1+0j)} - >>> sv.to_dict(encoding="LSB") - {'10': np.complex128(1+0j)} - """ - return self._to_dict_map(lambda x: x, encoding, rtol=rtol, atol=atol) - - def to_prob_dict( - self, encoding: _ENCODING = "MSB", *, rtol: float = 0.0, atol: float = 1e-8 - ) -> dict[str, np.object_ | np.float64]: - r"""Convert the statevector to a probability distirbution in a dictionary form. - - This dictionary representation uses a ket-like notation where the dictionary ``keys`` are qubit strings for the basis vectors and ``values`` are the corresponding probabilities. - Basis vector whose amplitude is below a certain threshold are filtered out. - - Parameters - ---------- - encoding: Literal["LSB", "MSB"], default="MSB" - Encoding for the basis kets. See :meth:`to_dict` for additional information. - - rtol : float, default=0.0 - Relative tolerance used when deciding whether a coefficient should be - treated as zero. Values whose magnitude is within this relative tolerance - of zero are omitted from the resulting dictionary. - - atol : float, default=1e-8 - Absolute tolerance used when deciding whether a coefficient should be - treated as zero. Values whose magnitude is within this relative tolerance - of zero are omitted from the resulting dictionary. - - Returns - ------- - dict[str, float] - The probability distribution associated to the statevector in dictionary form. - - See Also - -------- - .. :meth:`to_dict` - """ - return self._to_dict_map(lambda x: np.abs(x) ** 2, encoding, rtol=rtol, atol=atol) - - def draw( - self, - output: OutputFormat | None = None, - *, - encoding: _ENCODING = "MSB", - max_denominator: int = 1000, - atol: float = 1e-9, - rtol: float = 0.0, - precision: int = 4, - ) -> str: - r"""Return a pretty-printed ket-notation representation of the statevector. - - Amplitudes are rendered with :func:`graphix.pretty_print.complex_to_str`, - so common values appear as exact expressions (e.g. ``√2/2``) rather than - floating-point numbers. - - Parameters - ---------- - output : OutputFormat, optional - Desired formatting style. Defaults to :attr:`OutputFormat.Unicode`. - encoding : {"LSB", "MSB"}, optional - Bit-ordering convention for the basis kets (default: ``"MSB"``). - See :meth:`to_dict`. - max_denominator : int, optional - Maximum denominator used by the amplitude recognition (default: ``1000``). - atol : float, optional - Absolute tolerance for dropping near-zero amplitudes and for the - recognition heuristics (default: ``1e-9``). - rtol : float, optional - Relative tolerance for dropping near-zero amplitudes (default: ``0.0``). - precision : int, optional - Number of significant digits to use for amplitudes that fall back to - a decimal representation (default: ``4``). - - Returns - ------- - str - The formatted statevector. - - Examples - -------- - >>> from graphix.transpiler import Circuit - >>> circuit = Circuit(2) - >>> circuit.h(0) - >>> circuit.cz(0, 1) - >>> print(circuit.simulate_statevector().statevec.draw()) - sqrt(2)/2(|00> + |01>) - """ - return statevec_to_str( - self, - output, - encoding=encoding, - max_denominator=max_denominator, - atol=atol, - rtol=rtol, - precision=precision, - ) - - def _to_dict_map( - self, - f: Callable[[npt.NDArray[np.object_ | np.complex128]], npt.NDArray[_ScalarT]], - encoding: _ENCODING = "MSB", - *, - rtol: float = 0.0, - atol: float = 1e-8, - ) -> dict[str, _ScalarT]: - mask = np.logical_not(np.isclose(np.abs(self.flatten()), 0, rtol=rtol, atol=atol)) - i_vals = np.arange(1 << self.nqubit)[mask] - amp_vals = f(self.flatten()[mask]) - - return {_format_encoding(self.nqubit, i, encoding): amp for i, amp in zip(i_vals, amp_vals, strict=True)} - def _format_encoding(nqubit: int, i: int, encoding: _ENCODING) -> str: """Format the i-th basis vector as a ket. From 294ff58662b56cb9440c7b1d75dfb98ce730277f Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 16:27:59 +0200 Subject: [PATCH 06/22] Fixes for pyright --- graphix/parameter.py | 4 ++-- graphix/sim/statevec.py | 2 +- graphix/transpiler.py | 13 ++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/graphix/parameter.py b/graphix/parameter.py index f5f7ea938..fa4479cbf 100644 --- a/graphix/parameter.py +++ b/graphix/parameter.py @@ -260,11 +260,11 @@ def evaluate(self, value: ExpressionOrSupportsFloat) -> ExpressionOrFloat: @override def with_parameter( - self, variable: Parameter, value: ExpressionOrSupportsFloat + 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 @override diff --git a/graphix/sim/statevec.py b/graphix/sim/statevec.py index 0eeafb657..f7094743b 100644 --- a/graphix/sim/statevec.py +++ b/graphix/sim/statevec.py @@ -271,7 +271,7 @@ def _to_dict_map( ) -> dict[str, _ScalarT2]: mask = np.logical_not(np.isclose(np.abs(self.psi), 0, rtol=rtol, atol=atol)) i_vals = np.arange(1 << self.nqubit)[mask] - amp_vals = f(self.flatten()[mask]) + amp_vals = f(self.psi[mask]) return {_format_encoding(self.nqubit, i, encoding): amp for i, amp in zip(i_vals, amp_vals, strict=True)} diff --git a/graphix/transpiler.py b/graphix/transpiler.py index 2056c24b5..0ba0c28ce 100644 --- a/graphix/transpiler.py +++ b/graphix/transpiler.py @@ -1106,14 +1106,13 @@ def transpile_swaps(circuit: Circuit, *, copy: bool = False) -> TranspileSwapsRe 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: - if instr.kind == InstructionKind.M: - old_target = instr.target - new_instr = instr.visit(visitor, copy=copy) - new_circuit.add(new_instr) - if instr.kind == InstructionKind.M: - visitor.outputs[old_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 From e1d579097d0a31d5bda3dac14295609aaf40026b Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 16:54:37 +0200 Subject: [PATCH 07/22] More general flatten --- graphix/sim/statevec.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/graphix/sim/statevec.py b/graphix/sim/statevec.py index f7094743b..bcab1b528 100644 --- a/graphix/sim/statevec.py +++ b/graphix/sim/statevec.py @@ -71,9 +71,10 @@ def flatten(self) -> Matrix: A view of only the first ``2**self.nqubit`` elements of ``self._psi`` is returned. """ - if self.psi.dtype == np.object_: - return self.psi.astype(np.object_, copy=False) - return self.psi.astype(np.complex128, copy=False) + flat_psi = self.psi.flatten() + if flat_psi.dtype == np.object_: + return flat_psi.astype(np.object_, copy=False) + return flat_psi.astype(np.complex128, copy=False) def fidelity(self, other: Statevector) -> float: r"""Calculate the fidelity against another statevector. From 36800f02ea999e33433751f04eec9e409ffb5bea Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 16:58:44 +0200 Subject: [PATCH 08/22] Move `Self` in `TYPE_CHECKING` --- graphix/parameter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphix/parameter.py b/graphix/parameter.py index fa4479cbf..cc83f502e 100644 --- a/graphix/parameter.py +++ b/graphix/parameter.py @@ -12,13 +12,14 @@ import math from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Self, SupportsComplex, SupportsFloat, TypeAlias, 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): From be2b0ce8e4bb26bd51c356bcffdd4080cbcf84c1 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 16:58:59 +0200 Subject: [PATCH 09/22] Change repo for graphix-symbolic --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 38cdb8039..dea4e60e2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -95,7 +95,7 @@ class ReverseDependency: @nox.parametrize( "package", [ - ReverseDependency("https://github.com/matulni/graphix-symbolic", branch="backend"), + ReverseDependency("https://github.com/thierry-martinez/graphix-symbolic", branch="in-place_methods"), ReverseDependency("https://github.com/matulni/graphix-stim-backend", branch="rename_methods"), ReverseDependency("https://github.com/TeamGraphix/graphix-qasm-parser"), ReverseDependency("https://github.com/matulni/graphix-ibmq", doctest_modules=False, branch="rename_methods"), From 9c39dec3c1cf3da31c412f4af2015adf034be90b Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 17:35:57 +0200 Subject: [PATCH 10/22] Fix coverage --- tests/test_parameter.py | 100 ++++++++++++++++++++++++++++++++++++++- tests/test_transpiler.py | 11 +++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 2f96f4f64..a36efb403 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -3,20 +3,31 @@ 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 +from typing_extensions import override + import graphix.command +from graphix import OpenGraph from graphix.measurements import Measurement from graphix.parameter import Placeholder, PlaceholderOperationError from graphix.pattern import DrawPatternAnnotations, Pattern from graphix.random_objects import rand_circuit from graphix.sim.density_matrix import DensityMatrix +from graphix.sim.statevec import AbstractStatevector if TYPE_CHECKING: + from collections.abc import Sequence + + import numpy.typing as npt from numpy.random import PCG64, Generator from graphix.parameter import Parameter + from graphix.sim.base_backend import Matrix + from graphix.sim.data import Data def test_pattern_affine_operations() -> None: @@ -60,6 +71,32 @@ def test_pattern_substitution() -> None: assert list(pattern0.infer_pauli_measurements()) == [graphix.command.M(0), graphix.command.M(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)) @@ -132,17 +169,76 @@ def test_parallel_substitution_with_zero() -> None: assert not pattern23.is_parameterized() -def test_density_matrix_subs() -> None: +def test_statevector_flatten() -> None: + alpha = Placeholder("alpha") + + class PlaceholderStatevector(AbstractStatevector[np.object_]): + @property + @override + def psi(self) -> npt.NDArray[np.object_]: + return np.array([alpha]) + + @override + def add_nodes(self, nqubit: int, data: Data) -> None: + raise NotImplementedError + + @override + def entangle(self, qubits: tuple[int, int]) -> None: + raise NotImplementedError + + @override + def evolve(self, op: Matrix, qubits: Sequence[int]) -> None: + raise NotImplementedError + + @override + def evolve_single(self, op: Matrix, qubit: int) -> None: + raise NotImplementedError + + @override + def expectation_single(self, op: Matrix, qubit: int) -> complex: + raise NotImplementedError + + @override + def remove_qubit(self, qubit: int) -> None: + raise NotImplementedError + + @override + def swap(self, qubits: tuple[int, int]) -> None: + raise NotImplementedError + + @override + def permute(self, permutation: Sequence[int]) -> None: + raise NotImplementedError + + # Note that `@property` must appear before `@override` for pyright + @property + @override + def nqubit(self) -> int: + return 1 + + statevec = PlaceholderStatevector() + assert statevec.flatten()[0] == alpha + + +def test_density_matrix_with_parameter() -> None: alpha = Placeholder("alpha") dm = DensityMatrix([[alpha]]) 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.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)) diff --git a/tests/test_transpiler.py b/tests/test_transpiler.py index 80a57f248..574dbb738 100644 --- a/tests/test_transpiler.py +++ b/tests/test_transpiler.py @@ -18,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 @@ -419,3 +420,13 @@ def test_backend_branch_selector() -> None: circ = Circuit(1) with pytest.raises(ValueError, match="already instantiated"): circ.simulate_statevector(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 From ac190c075249e9f062b700973ff6145b85be9911 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 17:45:18 +0200 Subject: [PATCH 11/22] Fix coverage again --- tests/test_parameter.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_parameter.py b/tests/test_parameter.py index a36efb403..1efc584a4 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -71,6 +71,18 @@ def test_pattern_substitution() -> None: 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)}) From 461eed7aca429cee131f03bfe57230962e67612e Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Wed, 29 Jul 2026 20:22:36 +0200 Subject: [PATCH 12/22] Fix typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9d99cbe..4dedc0109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). + - `replace_parameters`, an in-place variant of `with_parameters` (or `xreplace`). ### Fixed From ccb18583ebae2c2b3297f4293eef8b66b3070427 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Thu, 30 Jul 2026 13:06:36 +0200 Subject: [PATCH 13/22] Fix docstring --- graphix/pattern.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index 54da18948..e95e83c06 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1714,7 +1714,7 @@ def check_measured(cmd: CommandType, node: int) -> None: check_active(cmd, cmd.node) def apply(self, f: Callable[[Measurement], Measurement], *, copy: bool = False) -> Pattern: - """Apply the function ``f` to each measurement. + """Apply the function ``f`` to each measurement. Parameters ---------- From b261344679957c4db6bc13cf3492584ebff86298 Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Fri, 31 Jul 2026 12:55:03 +0200 Subject: [PATCH 14/22] Update graphix/parameter.py Co-authored-by: matulni --- graphix/parameter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/parameter.py b/graphix/parameter.py index cc83f502e..69c6d1756 100644 --- a/graphix/parameter.py +++ b/graphix/parameter.py @@ -390,7 +390,7 @@ def with_parameter(value: _T, variable: Parameter, substitute: ExpressionOrSuppo `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 From fdc287ceb58eabda9bb778cd73cd33e2e24459eb Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Fri, 31 Jul 2026 12:55:16 +0200 Subject: [PATCH 15/22] Update graphix/flow/core.py Co-authored-by: matulni --- graphix/flow/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 1229d8ecd..c4bb2ab11 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -539,7 +539,7 @@ def with_parameter( Notes ----- - See notes and examples in :func:`OpenGraph.subs`. + See notes and examples in :meth:`OpenGraph.with_parameter`. """ new_og = self.og.with_parameter(variable, substitute) return dataclasses.replace(self, og=new_og) From e342755aaba90a50ab098fd69e80a4d8ce584e86 Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Fri, 31 Jul 2026 12:55:31 +0200 Subject: [PATCH 16/22] Update graphix/flow/core.py Co-authored-by: matulni --- graphix/flow/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index c4bb2ab11..eb29f1326 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -883,7 +883,7 @@ def with_parameter( # noqa: PYI019 Annotating with ``Self`` is not possible sin Notes ----- - See notes and examples in :func:`OpenGraph.subs`. + See notes and examples in :meth:`OpenGraph.with_parameter`. """ new_og = self.og.with_parameter(variable, substitute) return dataclasses.replace(self, og=new_og) From 0ac1052b0bf1a132fdcc3e6d5946b33a5e04acfc Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Fri, 31 Jul 2026 12:55:44 +0200 Subject: [PATCH 17/22] Update graphix/flow/core.py Co-authored-by: matulni --- graphix/flow/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index eb29f1326..43729d6aa 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -906,7 +906,7 @@ def with_parameters( # noqa: PYI019 Notes ----- - See notes and examples in :func:`OpenGraph.xreplace`. + See notes and examples in :meth:`OpenGraph.with_parameters`. """ new_og = self.og.with_parameters(assignment) return dataclasses.replace(self, og=new_og) From 02615cc10f59a9b2f6bac8f47974923c25ed5b07 Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Fri, 31 Jul 2026 12:56:06 +0200 Subject: [PATCH 18/22] Update graphix/flow/core.py Co-authored-by: matulni --- graphix/flow/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 43729d6aa..1cea8c47b 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -562,7 +562,7 @@ def with_parameters( Notes ----- - See notes and examples in :func:`OpenGraph.xreplace`. + See notes and examples in :meth:`OpenGraph.with_parameters`. """ new_og = self.og.with_parameters(assignment) return dataclasses.replace(self, og=new_og) From f73b57a8b9ba9b84bb4c4b61745ac9e9b9a198f6 Mon Sep 17 00:00:00 2001 From: thierry-martinez Date: Fri, 31 Jul 2026 12:56:17 +0200 Subject: [PATCH 19/22] Update graphix/parameter.py Co-authored-by: matulni --- graphix/parameter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/parameter.py b/graphix/parameter.py index 69c6d1756..c80709802 100644 --- a/graphix/parameter.py +++ b/graphix/parameter.py @@ -430,7 +430,7 @@ def with_parameters(value: _T, assignment: Mapping[Parameter, ExpressionOrSuppor `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 From 4fc5df885333b3627dfc6dce6c686122be4a1431 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 31 Jul 2026 15:36:36 +0200 Subject: [PATCH 20/22] Remove `AbstractStatevector` --- graphix/pretty_print.py | 9 +- graphix/sim/statevec.py | 441 +++++++++++++++++++--------------------- tests/test_parameter.py | 58 ------ 3 files changed, 212 insertions(+), 296 deletions(-) diff --git a/graphix/pretty_print.py b/graphix/pretty_print.py index 1b4c065e7..8054fbae0 100644 --- a/graphix/pretty_print.py +++ b/graphix/pretty_print.py @@ -22,18 +22,13 @@ if TYPE_CHECKING: from collections.abc import Container, Iterable, Mapping, Sequence from collections.abc import Set as AbstractSet - from typing import Any, TypeVar - - import numpy as np from graphix.command import Node from graphix.flow.core import PauliFlow, XZCorrections from graphix.fundamentals import Angle from graphix.pattern import Pattern from graphix.sim.density_matrix import DensityMatrix - from graphix.sim.statevec import _ENCODING, AbstractStatevector - - _ScalarT = TypeVar("_ScalarT", bound=np.generic[Any]) + from graphix.sim.statevec import _ENCODING, Statevector class OutputFormat(Enum): @@ -875,7 +870,7 @@ def _factor_uniform_magnitude( def statevec_to_str( - statevec: AbstractStatevector[_ScalarT], + statevec: Statevector, output: OutputFormat | None = None, *, encoding: _ENCODING = "MSB", diff --git a/graphix/sim/statevec.py b/graphix/sim/statevec.py index e6546ce1f..0cb4eac88 100644 --- a/graphix/sim/statevec.py +++ b/graphix/sim/statevec.py @@ -8,10 +8,9 @@ import dataclasses import functools import math -from abc import abstractmethod from collections.abc import Iterable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generic, SupportsComplex, SupportsFloat, TypeVar +from typing import TYPE_CHECKING, Any, SupportsComplex, SupportsFloat, TypeVar import numba as nb import numpy as np @@ -41,9 +40,6 @@ _ENCODING = Literal["LSB", "MSB"] _ScalarT = TypeVar("_ScalarT", bound=np.generic[Any]) - _ScalarT2 = TypeVar("_ScalarT2", bound=np.generic[Any]) -else: - _ScalarT = TypeVar("_ScalarT", bound=np.generic) NUM_QUBIT_PARALLEL = 15 @@ -53,231 +49,7 @@ This number was determined empirically and may be platform dependent.""" -class AbstractStatevector(DenseState, Generic[_ScalarT]): - """Base class for statevectors. - - This base class exposes methods that can be shared with other implementations. - """ - - @property - @abstractmethod - def psi(self) -> npt.NDArray[_ScalarT]: - """Return the statevector as a numpy array.""" - ... - - @override - def flatten(self) -> Matrix: - """Return flattened state vector. - - A view of only the first ``2**self.nqubit`` elements of ``self._psi`` is returned. - """ - flat_psi = self.psi.flatten() - if flat_psi.dtype == np.object_: - return flat_psi.astype(np.object_, copy=False) - return flat_psi.astype(np.complex128, copy=False) - - def fidelity(self, other: Statevector) -> float: - r"""Calculate the fidelity against another statevector. - - The fidelity is defined as :math:`|\langle\psi_1|\psi_2\rangle|^2`. - - Parameters - ---------- - other : :class:`graphix.sim.statevec.Statevector` - Statevector to compare with. - - Returns - ------- - float - Fidelity between the two statevectors. - """ - inner = np.dot(self.flatten().conjugate(), other.flatten()) - return float(np.abs(inner) ** 2) - - def isclose(self, other: Statevector, *, rtol: float = 1e-09, atol: float = 0.0) -> bool: - """Check if two quantum states are equal up to global phase. - - Two states are considered close if their fidelity is close to 1. - - Parameters - ---------- - other : :class:`graphix.sim.statevec.Statevector` - Statevector to compare with. - rtol : float - Relative tolerance for :func:`math.isclose`. - atol : float - Absolute tolerance for :func:`math.isclose`. - - Returns - ------- - bool - ``True`` if the states are equal up to global phase. - """ - return math.isclose(self.fidelity(other), 1, rel_tol=rtol, abs_tol=atol) - - def to_dict( - self, - encoding: _ENCODING = "MSB", - *, - rtol: float = 0.0, - atol: float = 1e-8, - ) -> dict[str, _ScalarT]: - r"""Convert the statevector to dictionary form. - - This dictionary representation uses a ket-like notation where the dictionary ``keys`` are qubit strings for the basis vectors and ``values`` are the corresponding complex amplitudes. Amplitudes below a certain threshold are filtered out. - - Parameters - ---------- - encoding : Literal["LSB", "MSB"], default="MSB" - Encoding for the basis kets. See notes for additional information. - - rtol : float, default=0.0 - Relative tolerance used when deciding whether a coefficient should be - treated as zero. Values whose magnitude is within this relative tolerance - of zero are omitted from the resulting dictionary. - - atol : float, default=1e-8 - Absolute tolerance used when deciding whether a coefficient should be - treated as zero. Values whose magnitude is within this relative tolerance - of zero are omitted from the resulting dictionary. - - Returns - ------- - dict[str, complex] - The statevector in dictionary form. - - Notes - ----- - The encoding determines the bit ordering convention used when mapping basis states to dictionary - keys. Consider a tensor product of three qubits: - - .. math:: - - \lvert\psi\rangle = q_0 \otimes q_1 \otimes q_2. - - If ``encoding == "MSB"`` the first qubit is represented in the Most Significant Bit -> ``q0q1q2``. This is the default representation in Graphix. - If ``encoding == "LSB"`` the first qubit is represented in the Least Significant Bit -> ``q2q1q0``. This is the default representation in other software packages such as Qiskit. - - Example - ------- - >>> from graphix.states import BasicStates - >>> from graphix.sim.statevec import Statevector - >>> sv = Statevector(data=[BasicStates.ZERO, BasicStates.ONE]) - >>> sv.to_dict() - {'01': np.complex128(1+0j)} - >>> sv.to_dict(encoding="LSB") - {'10': np.complex128(1+0j)} - """ - return self._to_dict_map(lambda x: x, encoding, rtol=rtol, atol=atol) - - def to_prob_dict( - self, encoding: _ENCODING = "MSB", *, rtol: float = 0.0, atol: float = 1e-8 - ) -> dict[str, np.object_ | np.float64]: - r"""Convert the statevector to a probability distirbution in a dictionary form. - - This dictionary representation uses a ket-like notation where the dictionary ``keys`` are qubit strings for the basis vectors and ``values`` are the corresponding probabilities. - Basis vector whose amplitude is below a certain threshold are filtered out. - - Parameters - ---------- - encoding: Literal["LSB", "MSB"], default="MSB" - Encoding for the basis kets. See :meth:`to_dict` for additional information. - - rtol : float, default=0.0 - Relative tolerance used when deciding whether a coefficient should be - treated as zero. Values whose magnitude is within this relative tolerance - of zero are omitted from the resulting dictionary. - - atol : float, default=1e-8 - Absolute tolerance used when deciding whether a coefficient should be - treated as zero. Values whose magnitude is within this relative tolerance - of zero are omitted from the resulting dictionary. - - Returns - ------- - dict[str, float] - The probability distribution associated to the statevector in dictionary form. - - See Also - -------- - .. :meth:`to_dict` - """ - return self._to_dict_map(lambda x: np.abs(x) ** 2, encoding, rtol=rtol, atol=atol) - - def draw( - self, - output: OutputFormat | None = None, - *, - encoding: _ENCODING = "MSB", - max_denominator: int = 1000, - atol: float = 1e-9, - rtol: float = 0.0, - precision: int = 4, - ) -> str: - r"""Return a pretty-printed ket-notation representation of the statevector. - - Amplitudes are rendered with :func:`graphix.pretty_print.complex_to_str`, - so common values appear as exact expressions (e.g. ``√2/2``) rather than - floating-point numbers. - - Parameters - ---------- - output : OutputFormat, optional - Desired formatting style. Defaults to :attr:`OutputFormat.Unicode`. - encoding : {"LSB", "MSB"}, optional - Bit-ordering convention for the basis kets (default: ``"MSB"``). - See :meth:`to_dict`. - max_denominator : int, optional - Maximum denominator used by the amplitude recognition (default: ``1000``). - atol : float, optional - Absolute tolerance for dropping near-zero amplitudes and for the - recognition heuristics (default: ``1e-9``). - rtol : float, optional - Relative tolerance for dropping near-zero amplitudes (default: ``0.0``). - precision : int, optional - Number of significant digits to use for amplitudes that fall back to - a decimal representation (default: ``4``). - - Returns - ------- - str - The formatted statevector. - - Examples - -------- - >>> from graphix.transpiler import Circuit - >>> circuit = Circuit(2) - >>> circuit.h(0) - >>> circuit.cz(0, 1) - >>> print(circuit.simulate().state.draw()) - sqrt(2)/2(|00> + |01>) - """ - return statevec_to_str( - self, - output, - encoding=encoding, - max_denominator=max_denominator, - atol=atol, - rtol=rtol, - precision=precision, - ) - - def _to_dict_map( - self, - f: Callable[[npt.NDArray[_ScalarT]], npt.NDArray[_ScalarT2]], - encoding: _ENCODING = "MSB", - *, - rtol: float = 0.0, - atol: float = 1e-8, - ) -> dict[str, _ScalarT2]: - mask = np.logical_not(np.isclose(np.abs(self.psi), 0, rtol=rtol, atol=atol)) - i_vals = np.arange(1 << self.nqubit)[mask] - amp_vals = f(self.psi[mask]) - - return {_format_encoding(self.nqubit, i, encoding): amp for i, amp in zip(i_vals, amp_vals, strict=True)} - - -class Statevector(AbstractStatevector[np.complex128]): +class Statevector(DenseState): """Statevector object. Attributes @@ -448,7 +220,6 @@ def __str__(self) -> str: return self.draw() @property - @override def psi(self) -> npt.NDArray[np.complex128]: r"""View of the meaningful elements in ``self._psi``. @@ -487,6 +258,14 @@ def ensure_capacity(self, required_qubits: int) -> None: self._psi = new_psi self._max_qubits = required_qubits + @override + def flatten(self) -> Matrix: + """Return flattened state vector. + + A view of only the first ``2**self.nqubit`` elements of ``self._psi`` is returned. + """ + return self.psi + @override def add_nodes(self, nqubit: int, data: Data) -> None: r"""Add nodes (qubits) to the state vector and initialize them in a specified state. @@ -770,6 +549,206 @@ def permute(self, permutation: Sequence[int]) -> None: len_psi = len(self.psi) self._psi[:len_psi] = psi_tensor_perm.reshape(len_psi) + def fidelity(self, other: Statevector) -> float: + r"""Calculate the fidelity against another statevector. + + The fidelity is defined as :math:`|\langle\psi_1|\psi_2\rangle|^2`. + + Parameters + ---------- + other : :class:`graphix.sim.statevec.Statevector` + Statevector to compare with. + + Returns + ------- + float + Fidelity between the two statevectors. + """ + inner = np.dot(self.flatten().conjugate(), other.flatten()) + return float(np.abs(inner) ** 2) + + def isclose(self, other: Statevector, *, rtol: float = 1e-09, atol: float = 0.0) -> bool: + """Check if two quantum states are equal up to global phase. + + Two states are considered close if their fidelity is close to 1. + + Parameters + ---------- + other : :class:`graphix.sim.statevec.Statevector` + Statevector to compare with. + rtol : float + Relative tolerance for :func:`math.isclose`. + atol : float + Absolute tolerance for :func:`math.isclose`. + + Returns + ------- + bool + ``True`` if the states are equal up to global phase. + """ + return math.isclose(self.fidelity(other), 1, rel_tol=rtol, abs_tol=atol) + + def to_dict( + self, + encoding: _ENCODING = "MSB", + *, + rtol: float = 0.0, + atol: float = 1e-8, + ) -> dict[str, np.object_ | np.complex128]: + r"""Convert the statevector to dictionary form. + + This dictionary representation uses a ket-like notation where the dictionary ``keys`` are qubit strings for the basis vectors and ``values`` are the corresponding complex amplitudes. Amplitudes below a certain threshold are filtered out. + + Parameters + ---------- + encoding : Literal["LSB", "MSB"], default="MSB" + Encoding for the basis kets. See notes for additional information. + + rtol : float, default=0.0 + Relative tolerance used when deciding whether a coefficient should be + treated as zero. Values whose magnitude is within this relative tolerance + of zero are omitted from the resulting dictionary. + + atol : float, default=1e-8 + Absolute tolerance used when deciding whether a coefficient should be + treated as zero. Values whose magnitude is within this relative tolerance + of zero are omitted from the resulting dictionary. + + Returns + ------- + dict[str, complex] + The statevector in dictionary form. + + Notes + ----- + The encoding determines the bit ordering convention used when mapping basis states to dictionary + keys. Consider a tensor product of three qubits: + + .. math:: + + \lvert\psi\rangle = q_0 \otimes q_1 \otimes q_2. + + If ``encoding == "MSB"`` the first qubit is represented in the Most Significant Bit -> ``q0q1q2``. This is the default representation in Graphix. + If ``encoding == "LSB"`` the first qubit is represented in the Least Significant Bit -> ``q2q1q0``. This is the default representation in other software packages such as Qiskit. + + Example + ------- + >>> from graphix.states import BasicStates + >>> from graphix.sim.statevec import Statevector + >>> sv = Statevector(data=[BasicStates.ZERO, BasicStates.ONE]) + >>> sv.to_dict() + {'01': np.complex128(1+0j)} + >>> sv.to_dict(encoding="LSB") + {'10': np.complex128(1+0j)} + """ + return self._to_dict_map(lambda x: x, encoding, rtol=rtol, atol=atol) + + def to_prob_dict( + self, encoding: _ENCODING = "MSB", *, rtol: float = 0.0, atol: float = 1e-8 + ) -> dict[str, np.object_ | np.float64]: + r"""Convert the statevector to a probability distirbution in a dictionary form. + + This dictionary representation uses a ket-like notation where the dictionary ``keys`` are qubit strings for the basis vectors and ``values`` are the corresponding probabilities. + Basis vector whose amplitude is below a certain threshold are filtered out. + + Parameters + ---------- + encoding: Literal["LSB", "MSB"], default="MSB" + Encoding for the basis kets. See :meth:`to_dict` for additional information. + + rtol : float, default=0.0 + Relative tolerance used when deciding whether a coefficient should be + treated as zero. Values whose magnitude is within this relative tolerance + of zero are omitted from the resulting dictionary. + + atol : float, default=1e-8 + Absolute tolerance used when deciding whether a coefficient should be + treated as zero. Values whose magnitude is within this relative tolerance + of zero are omitted from the resulting dictionary. + + Returns + ------- + dict[str, float] + The probability distribution associated to the statevector in dictionary form. + + See Also + -------- + .. :meth:`to_dict` + """ + return self._to_dict_map(lambda x: np.abs(x) ** 2, encoding, rtol=rtol, atol=atol) + + def draw( + self, + output: OutputFormat | None = None, + *, + encoding: _ENCODING = "MSB", + max_denominator: int = 1000, + atol: float = 1e-9, + rtol: float = 0.0, + precision: int = 4, + ) -> str: + r"""Return a pretty-printed ket-notation representation of the statevector. + + Amplitudes are rendered with :func:`graphix.pretty_print.complex_to_str`, + so common values appear as exact expressions (e.g. ``√2/2``) rather than + floating-point numbers. + + Parameters + ---------- + output : OutputFormat, optional + Desired formatting style. Defaults to :attr:`OutputFormat.Unicode`. + encoding : {"LSB", "MSB"}, optional + Bit-ordering convention for the basis kets (default: ``"MSB"``). + See :meth:`to_dict`. + max_denominator : int, optional + Maximum denominator used by the amplitude recognition (default: ``1000``). + atol : float, optional + Absolute tolerance for dropping near-zero amplitudes and for the + recognition heuristics (default: ``1e-9``). + rtol : float, optional + Relative tolerance for dropping near-zero amplitudes (default: ``0.0``). + precision : int, optional + Number of significant digits to use for amplitudes that fall back to + a decimal representation (default: ``4``). + + Returns + ------- + str + The formatted statevector. + + Examples + -------- + >>> from graphix.transpiler import Circuit + >>> circuit = Circuit(2) + >>> circuit.h(0) + >>> circuit.cz(0, 1) + >>> print(circuit.simulate().state.draw()) + sqrt(2)/2(|00> + |01>) + """ + return statevec_to_str( + self, + output, + encoding=encoding, + max_denominator=max_denominator, + atol=atol, + rtol=rtol, + precision=precision, + ) + + def _to_dict_map( + self, + f: Callable[[npt.NDArray[np.object_ | np.complex128]], npt.NDArray[_ScalarT]], + encoding: _ENCODING = "MSB", + *, + rtol: float = 0.0, + atol: float = 1e-8, + ) -> dict[str, _ScalarT]: + mask = np.logical_not(np.isclose(np.abs(self.flatten()), 0, rtol=rtol, atol=atol)) + i_vals = np.arange(1 << self.nqubit)[mask] + amp_vals = f(self.flatten()[mask]) + + return {_format_encoding(self.nqubit, i, encoding): amp for i, amp in zip(i_vals, amp_vals, strict=True)} + def _format_encoding(nqubit: int, i: int, encoding: _ENCODING) -> str: """Format the i-th basis vector as a ket. diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 543cdb2d5..6e049df94 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -8,8 +8,6 @@ import pytest # override introduced in Python 3.12 -from typing_extensions import override - import graphix.command from graphix import OpenGraph from graphix.measurements import Measurement @@ -17,17 +15,12 @@ from graphix.pattern import DrawPatternAnnotations, Pattern from graphix.random_objects import rand_circuit from graphix.sim.density_matrix import DensityMatrix -from graphix.sim.statevec import AbstractStatevector if TYPE_CHECKING: - from collections.abc import Sequence - import numpy.typing as npt from numpy.random import PCG64, Generator from graphix.parameter import Parameter - from graphix.sim.base_backend import Matrix - from graphix.sim.data import Data def test_pattern_affine_operations() -> None: @@ -181,57 +174,6 @@ def test_parallel_substitution_with_zero() -> None: assert not pattern23.is_parameterized() -def test_statevector_flatten() -> None: - alpha = Placeholder("alpha") - - class PlaceholderStatevector(AbstractStatevector[np.object_]): - @property - @override - def psi(self) -> npt.NDArray[np.object_]: - return np.array([alpha]) - - @override - def add_nodes(self, nqubit: int, data: Data) -> None: - raise NotImplementedError - - @override - def entangle(self, qubits: tuple[int, int]) -> None: - raise NotImplementedError - - @override - def evolve(self, op: Matrix, qubits: Sequence[int]) -> None: - raise NotImplementedError - - @override - def evolve_single(self, op: Matrix, qubit: int) -> None: - raise NotImplementedError - - @override - def expectation_single(self, op: Matrix, qubit: int) -> complex: - raise NotImplementedError - - @override - def remove_qubit(self, qubit: int) -> None: - raise NotImplementedError - - @override - def swap(self, qubits: tuple[int, int]) -> None: - raise NotImplementedError - - @override - def permute(self, permutation: Sequence[int]) -> None: - raise NotImplementedError - - # Note that `@property` must appear before `@override` for pyright - @property - @override - def nqubit(self) -> int: - return 1 - - statevec = PlaceholderStatevector() - assert statevec.flatten()[0] == alpha - - def test_density_matrix_with_parameter() -> None: alpha = Placeholder("alpha") dm = DensityMatrix([[alpha]]) From 560c339e627b8350fb581dff8ce83ba361db3621 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 31 Jul 2026 15:38:49 +0200 Subject: [PATCH 21/22] fix `ruff` --- tests/test_parameter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 6e049df94..5000a3428 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -17,7 +17,6 @@ from graphix.sim.density_matrix import DensityMatrix if TYPE_CHECKING: - from numpy.random import PCG64, Generator from graphix.parameter import Parameter From 151c9e7e2c1e4eca4b525b66df9228d29df2670b Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Fri, 31 Jul 2026 15:45:35 +0200 Subject: [PATCH 22/22] Fix `graphix-stim-backend` reverse dependency --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 29e3cf63d..072b0ba7d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -96,7 +96,7 @@ class ReverseDependency: "package", [ ReverseDependency("https://github.com/thierry-martinez/graphix-symbolic", branch="in-place_methods"), - ReverseDependency("https://github.com/matulni/graphix-stim-backend", branch="rename_methods"), + ReverseDependency("https://github.com/thierry-martinez/graphix-stim-backend", 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"