From ba297480d6d92a5aece648c997c65d89beff9503 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 7 Oct 2025 17:10:19 +0200 Subject: [PATCH 01/56] wip --- graphix/flow/__init__.py | 0 graphix/flow/_find_cflow.py | 0 graphix/flow/_find_pflow.py | 612 ++++++++++++++++++++++++++++++++++++ graphix/flow/flow.py | 208 ++++++++++++ graphix/flow/utils.py | 40 +++ graphix/opengraph_.py | 209 ++++++++++++ stubs/networkx/__init__.pyi | 11 + 7 files changed, 1080 insertions(+) create mode 100644 graphix/flow/__init__.py create mode 100644 graphix/flow/_find_cflow.py create mode 100644 graphix/flow/_find_pflow.py create mode 100644 graphix/flow/flow.py create mode 100644 graphix/flow/utils.py create mode 100644 graphix/opengraph_.py create mode 100644 stubs/networkx/__init__.pyi diff --git a/graphix/flow/__init__.py b/graphix/flow/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py new file mode 100644 index 000000000..e69de29bb diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_pflow.py new file mode 100644 index 000000000..6a0b8e949 --- /dev/null +++ b/graphix/flow/_find_pflow.py @@ -0,0 +1,612 @@ +"""Pauli flow finding algorithm. + +This module implements the algorithm presented in [1]. For a given labelled open graph (G, I, O, meas_plane), this algorithm finds a maximally delayed Pauli flow [2] in polynomial time with the number of nodes, :math:`O(N^3)`. +If the input graph does not have Pauli measurements, the algorithm returns a general flow (gflow) if it exists by definition. + +References +---------- +[1] Mitosek and Backens, 2024 (arXiv:2410.23439). +[2] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212) +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import numpy as np + +from graphix._linalg import MatGF2, solve_f2_linear_system +from graphix.fundamentals import Axis, Plane +from graphix.measurements import PauliMeasurement +from graphix.sim.base_backend import NodeIndex + +if TYPE_CHECKING: + from collections.abc import Set as AbstractSet + + from graphix.opengraph import OpenGraph + + +class OpenGraphIndex: + """A class for managing the mapping between node numbers of a given open graph and matrix indices in the Pauli flow finding algorithm. + + It reuses the class `:class: graphix.sim.base_backend.NodeIndex` introduced for managing the mapping between node numbers and qubit indices in the internal state of the backend. + + Attributes + ---------- + og (OpenGraph) + non_inputs (NodeIndex) : Mapping between matrix indices and non-input nodes (labelled with integers). + non_outputs (NodeIndex) : Mapping between matrix indices and non-output nodes (labelled with integers). + non_outputs_optim (NodeIndex) : Mapping between matrix indices and a subset of non-output nodes (labelled with integers). + + Notes + ----- + At initialization, `non_outputs_optim` is a copy of `non_outputs`. The nodes corresponding to zero-rows of the order-demand matrix are removed for calculating the P matrix more efficiently in the `:func: _find_pflow_general` routine. + """ + + def __init__(self, og: OpenGraph) -> None: + self.og = og + nodes = set(og.inside.nodes) + + # Nodes don't need to be sorted. We do it for debugging purposes, so we can check the matrices in intermediate steps of the algorithm. + + nodes_non_input = sorted(nodes - set(og.inputs)) + nodes_non_output = sorted(nodes - set(og.outputs)) + + self.non_inputs = NodeIndex() + self.non_inputs.extend(nodes_non_input) + + self.non_outputs = NodeIndex() + self.non_outputs.extend(nodes_non_output) + + # Needs to be a deep copy because it may be modified during runtime. + self.non_outputs_optim = deepcopy(self.non_outputs) + + +def _compute_reduced_adj(ogi: OpenGraphIndex) -> MatGF2: + r"""Return the reduced adjacency matrix (RAdj) of the input open graph. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph whose RAdj is computed. + + Returns + ------- + adj_red : MatGF2 + Reduced adjacency matrix. + + Notes + ----- + The adjacency matrix of a graph :math:`Adj_G` is an :math:`n \times n` matrix. + + The RAdj matrix of an open graph OG is an :math:`(n - n_O) \times (n - n_I)` submatrix of :math:`Adj_G` constructed by removing the output rows and input columns of :math:`Adj_G`. + + See Definition 3.3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + graph = ogi.og.inside + row_tags = ogi.non_outputs + col_tags = ogi.non_inputs + + adj_red = np.zeros((len(row_tags), len(col_tags)), dtype=np.uint8).view(MatGF2) + + for n1, n2 in graph.edges: + for u, v in ((n1, n2), (n2, n1)): + if u in row_tags and v in col_tags: + i, j = row_tags.index(u), col_tags.index(v) + adj_red[i, j] = 1 + + return adj_red + + +def _compute_pflow_matrices(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2]: + r"""Construct flow-demand and order-demand matrices. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph whose flow-demand and order-demand matrices are computed. + + Returns + ------- + flow_demand_matrix : MatGF2 + order_demand_matrix : MatGF2 + + Notes + ----- + See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + flow_demand_matrix = _compute_reduced_adj(ogi) + order_demand_matrix = flow_demand_matrix.copy() + + inputs_set = set(ogi.og.inputs) + meas = ogi.og.measurements + + row_tags = ogi.non_outputs + col_tags = ogi.non_inputs + + # TODO: integrate pauli measurements in open graphs + meas_planes = {i: m.plane for i, m in meas.items()} + meas_angles = {i: m.angle for i, m in meas.items()} + meas_plane_axis = { + node: pm.axis if (pm := PauliMeasurement.try_from(plane, meas_angles[node])) else plane + for node, plane in meas_planes.items() + } + + for v in row_tags: # v is a node tag + i = row_tags.index(v) + plane_axis_v = meas_plane_axis[v] + + if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: + flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 + if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Y, Axis.Z} and v not in inputs_set: + j = col_tags.index(v) + flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 + if plane_axis_v in {Plane.XY, Axis.X, Axis.Y, Axis.Z}: + order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 + if plane_axis_v in {Plane.XY, Plane.XZ} and v not in inputs_set: + j = col_tags.index(v) + order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 + + return flow_demand_matrix, order_demand_matrix + + +def _find_pflow_simple(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: + r"""Construct the correction matrix :math:`C` and the ordering matrix, :math:`NC` for an open graph with equal number of inputs and outputs. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph for which :math:`C` and :math:`NC` are computed. + + Returns + ------- + correction_matrix : MatGF2 + Matrix encoding the correction function. + ordering_matrix : MatGF2 + Matrix encoding the partial ordering between nodes. + + or `None` + if the input open graph does not have Pauli flow. + + Notes + ----- + - The ordering matrix is defined as the product of the order-demand matrix :math:`N` and the correction matrix. + + - The function only returns `None` when the flow-demand matrix is not invertible (meaning that `ogi` does not have Pauli flow). The condition that the ordering matrix :math:`NC` must encode a directed acyclic graph (DAG) is verified in a subsequent step by `:func: _compute_topological_generations`. + + See Definitions 3.4, 3.5 and 3.6, Theorems 3.1 and 4.1, and Algorithm 2 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) + + correction_matrix = flow_demand_matrix.right_inverse() # C matrix + + if correction_matrix is None: + return None # The flow-demand matrix is not invertible, therefore there's no flow. + + ordering_matrix = order_demand_matrix.mat_mul(correction_matrix) # NC matrix + + return correction_matrix, ordering_matrix + + +def _compute_p_matrix(ogi: OpenGraphIndex, nb_matrix: MatGF2) -> MatGF2 | None: + r"""Perform the steps 8 - 12 of the general case (larger number of outputs than inputs) algorithm. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph for which the matrix :math:`P` is computed. + nb_matrix : MatGF2 + Matrix :math:`N_B` + + Returns + ------- + p_matrix : MatGF2 + Matrix encoding the correction function. + + or `None` + if the input open graph does not have Pauli flow. + + Notes + ----- + See Theorem 4.4, steps 8 - 12 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + n_no = len(ogi.non_outputs) # number of columns of P matrix. + n_oi_diff = len(ogi.og.outputs) - len(ogi.og.inputs) # number of rows of P matrix. + n_no_optim = len(ogi.non_outputs_optim) # number of rows and columns of the third block of the K_{LS} matrix. + + # Steps 8, 9 and 10 + kils_matrix = np.concatenate( + (nb_matrix[:, n_no:], nb_matrix[:, :n_no], np.eye(n_no_optim, dtype=np.uint8)), axis=1 + ).view(MatGF2) # N_R | N_L | 1 matrix. + kls_matrix = kils_matrix.gauss_elimination(ncols=n_oi_diff, copy=True) # RREF form is not needed, only REF. + + # Step 11 + p_matrix = np.zeros((n_oi_diff, n_no), dtype=np.uint8).view(MatGF2) + solved_nodes: set[int] = set() + non_outputs_set = set(ogi.non_outputs) + + # Step 12 + while solved_nodes != non_outputs_set: + solvable_nodes = _find_solvable_nodes(ogi, kls_matrix, non_outputs_set, solved_nodes, n_oi_diff) # Step 12.a + if not solvable_nodes: + return None + + _update_p_matrix(ogi, kls_matrix, p_matrix, solvable_nodes, n_oi_diff) # Steps 12.b, 12.c + _update_kls_matrix(ogi, kls_matrix, kils_matrix, solvable_nodes, n_oi_diff, n_no, n_no_optim) # Step 12.d + solved_nodes.update(solvable_nodes) + + return p_matrix + + +def _find_solvable_nodes( + ogi: OpenGraphIndex, + kls_matrix: MatGF2, + non_outputs_set: AbstractSet[int], + solved_nodes: AbstractSet[int], + n_oi_diff: int, +) -> set[int]: + """Return the set nodes whose associated linear system is solvable. + + A node is solvable if: + - It has not been solved yet. + - Its column in the second block of :math:`K_{LS}` (which determines the constants in each equation) has only zeros where it intersects rows for which all the coefficients in the first block are 0s. + + See Theorem 4.4, step 12.a in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + solvable_nodes: set[int] = set() + + row_idxs = np.flatnonzero( + ~kls_matrix[:, :n_oi_diff].any(axis=1) + ) # Row indices of the 0-rows in the first block of K_{LS}. + if row_idxs.size: + for v in non_outputs_set - solved_nodes: + j = n_oi_diff + ogi.non_outputs.index(v) # `n_oi_diff` is the column offset from the first block of K_{LS}. + if not kls_matrix[row_idxs, j].any(): + solvable_nodes.add(v) + else: + # If the first block of K_{LS} does not have 0-rows, all non-solved nodes are solvable. + solvable_nodes = set(non_outputs_set - solved_nodes) + + return solvable_nodes + + +def _update_p_matrix( + ogi: OpenGraphIndex, kls_matrix: MatGF2, p_matrix: MatGF2, solvable_nodes: AbstractSet[int], n_oi_diff: int +) -> None: + """Update `p_matrix`. + + The solution of the linear system associated with node :math:`v` in `solvable_nodes` corresponds to the column of `p_matrix` associated with node :math:`v`. + + See Theorem 4.4, steps 12.b and 12.c in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + for v in solvable_nodes: + j = ogi.non_outputs.index(v) + j_shift = n_oi_diff + j # `n_oi_diff` is the column offset from the first block of K_{LS}. + mat = MatGF2(kls_matrix[:, :n_oi_diff]) # First block of K_{LS}, in row echelon form. + b = MatGF2(kls_matrix[:, j_shift]) + x = solve_f2_linear_system(mat, b) + p_matrix[:, j] = x + + +def _update_kls_matrix( + ogi: OpenGraphIndex, + kls_matrix: MatGF2, + kils_matrix: MatGF2, + solvable_nodes: AbstractSet[int], + n_oi_diff: int, + n_no: int, + n_no_optim: int, +) -> None: + """Update `kls_matrix`. + + Bring the linear system encoded in :math:`K_{LS}` to the row-echelon form (REF) that would be achieved by Gaussian elimination if the row and column vectors corresponding to vertices in `solvable_nodes` where not included in the starting matrix. + + See Theorem 4.4, step 12.d in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + shift = n_oi_diff + n_no # `n_oi_diff` + `n_no` is the column offset from the first two blocks of K_{LS}. + row_permutation: list[int] + + def reorder(old_pos: int, new_pos: int) -> None: # Used in step 12.d.vi + """Reorder the elements of `row_permutation`. + + The element at `old_pos` is placed on the right of the element at `new_pos`. + Example: + ``` + row_permutation = [0, 1, 2, 3, 4] + reorder(1, 3) -> [0, 2, 3, 1, 4] + reorder(2, -1) -> [2, 0, 1, 3, 4] + ``` + """ + val = row_permutation.pop(old_pos) + row_permutation.insert(new_pos + (new_pos < old_pos), val) + + for v in solvable_nodes: + if ( + v in ogi.non_outputs_optim + ): # if `v` corresponded to a zero row in N_B, it was not present in `kls_matrix` because we removed it in the optimization process, so there's no need to do Gaussian elimination for that vertex. + # Step 12.d.ii + j = ogi.non_outputs_optim.index(v) + j_shift = shift + j + row_idxs = np.flatnonzero( + kls_matrix[:, j_shift] + ).tolist() # Row indices with 1s in column of node `v` in third block. + + # `row_idxs` can't be empty: + # The third block of K_{LS} is initially the identity matrix, so all columns have initially a 1. Row permutations and row additions in the Gaussian elimination routine can't remove all 1s from a given column. + k = row_idxs.pop() + + # Step 12.d.iii + kls_matrix[row_idxs] ^= kls_matrix[k] # Adding a row to previous rows preserves REF. + + # Step 12.d.iv + kls_matrix[k] ^= kils_matrix[j] # Row `k` may now break REF. + + # Step 12.d.v + pivots: list[np.int_] = [] # Store pivots for next step. + for i, row in enumerate(kls_matrix): + if i != k: + col_idxs = np.flatnonzero(row[:n_oi_diff]) # Column indices with 1s in first block. + if col_idxs.size == 0: + # Row `i` has all zeros in the first block. Only row `k` can break REF, so rows below have all zeros in the first block too. + break + pivots.append(p := col_idxs[0]) + if kls_matrix[k, p]: # Row `k` has a 1 in the column corresponding to the leading 1 of row `i`. + kls_matrix[k] ^= row + + row_permutation = list(range(n_no_optim)) # Row indices of `kls_matrix`. + n_pivots = len(pivots) + + col_idxs = np.flatnonzero(kls_matrix[k, :n_oi_diff]) + pk = col_idxs[0] if col_idxs.size else None # Pivot of row `k`. + + if pk and k >= n_pivots: # Row `k` is non-zero in the FB (first block) and it's among zero rows. + # Find row `new_pos` s.t. `pivots[new_pos] <= pk < pivots[new_pos+1]`. + new_pos = ( + int(np.argmax(np.array(pivots) > pk) - 1) if pivots else -1 + ) # `pivots` can be empty. If so, we bring row `k` to the top since it's non-zero. + elif pk: # Row `k` is non-zero in the FB and it's among non-zero rows. + # Find row `new_pos` s.t. `pivots[new_pos] <= pk < pivots[new_pos+1]` + new_pos = int(np.argmax(np.array(pivots) > pk) - 1) + # We skipped row `k` in loop of step 12.d.v, so `pivots[j]` can be the pivot of row `j` or `j+1`. + if new_pos >= k: + new_pos += 1 + elif k < n_pivots: # Row `k` is zero in the first block and it's among non-zero rows. + new_pos = ( + n_pivots # Move row `k` to the top of the zeros block (i.e., below the row of the last pivot). + ) + else: # Row `k` is zero in the first block and it's among zero rows. + new_pos = k # Do nothing. + + if new_pos != k: + reorder(k, new_pos) # Modify `row_permutation` in-place. + kls_matrix[:] = kls_matrix[ + row_permutation + ] # `[:]` is crucial to modify the data pointed by `kls_matrix`. + + +def _find_pflow_general(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: + r"""Construct the generalized correction matrix :math:`C'C^B` and the generalized ordering matrix, :math:`NC'C^B` for an open graph with larger number of outputs than inputs. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph for which :math:`C'C^B` and :math:`NC'C^B` are computed. + + Returns + ------- + correction_matrix : MatGF2 + Matrix encoding the correction function. + ordering_matrix : MatGF2 + Matrix encoding the partial ordering between nodes. + + or `None` + if the input open graph does not have Pauli flow. + + Notes + ----- + - The function returns `None` if + a) The flow-demand matrix is not invertible, or + b) Not all linear systems of equations associated to the non-output nodes are solvable, + meaning that `ogi` does not have Pauli flow. + Condition (b) is satisfied when the flow-demand matrix :math:`M` does not have a right inverse :math:`C` such that :math:`NC` represents a directed acyclical graph (DAG). + + See Theorem 4.4 and Algorithm 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + n_no = len(ogi.non_outputs) + n_oi_diff = len(ogi.og.outputs) - len(ogi.og.inputs) + + # Steps 1 and 2 + flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) + + # Steps 3 and 4 + correction_matrix_0 = flow_demand_matrix.right_inverse() # C0 matrix. + if correction_matrix_0 is None: + return None # The flow-demand matrix is not invertible, therefore there's no flow. + + # Steps 5, 6 and 7 + ker_flow_demand_matrix = flow_demand_matrix.null_space().transpose() # F matrix. + c_prime_matrix = np.concatenate((correction_matrix_0, ker_flow_demand_matrix), axis=1).view(MatGF2) + + row_idxs = np.flatnonzero(order_demand_matrix.any(axis=1)) # Row indices of the non-zero rows. + + if row_idxs.size: + # The p-matrix finding algorithm runs on the `order_demand_matrix` without the zero rows. + # This optimization is allowed because: + # - The zero rows remain zero after the change of basis (multiplication by `c_prime_matrix`). + # - The zero rows remain zero after gaussian elimination. + # - Removing the zero rows does not change the solvability condition of the open graph nodes. + nb_matrix_optim = ( + order_demand_matrix[row_idxs].view(MatGF2).mat_mul(c_prime_matrix) + ) # `view` is used to keep mypy happy without copying data. + for i in set(range(order_demand_matrix.shape[0])).difference(row_idxs): + ogi.non_outputs_optim.remove(ogi.non_outputs[i]) # Update the node-index mapping. + + # Steps 8 - 12 + if (p_matrix := _compute_p_matrix(ogi, nb_matrix_optim)) is None: + return None + else: + # If all rows of `order_demand_matrix` are zero, any matrix will solve the associated linear system of equations. + p_matrix = np.zeros((n_oi_diff, n_no), dtype=np.uint8).view(MatGF2) + + # Step 13 + cb_matrix = np.concatenate((np.eye(n_no, dtype=np.uint8), p_matrix), axis=0).view(MatGF2) + + correction_matrix = c_prime_matrix.mat_mul(cb_matrix) + ordering_matrix = order_demand_matrix.mat_mul(correction_matrix) + + return correction_matrix, ordering_matrix + + +def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] | None: + """Stratify the directed acyclic graph (DAG) represented by the ordering matrix into generations. + + Parameters + ---------- + ordering_matrix : MatGF2 + Matrix encoding the partial ordering between nodes interpreted as the adjacency matrix of a directed graph. + + Returns + ------- + list[list[int]] + Topological generations. Integers represent the indices of the matrix `ordering_matrix`, not the labelling of the nodes. + + or `None` + if `ordering_matrix` is not a DAG. + + Notes + ----- + This function is adapted from `:func: networkx.algorithms.dag.topological_generations` so that it works directly on the adjacency matrix (which is the output of the Pauli-flow finding algorithm) instead of a `:class: nx.DiGraph` object. This avoids calling the function `nx.from_numpy_array` which can be expensive for certain graph instances. + + Here we use the convention that the element `ordering_matrix[i,j]` represents a link `j -> i`. NetworkX uses the opposite convention. + """ + adj_mat = ordering_matrix + + indegree_map: dict[int, int] = {} + zero_indegree: list[int] = [] + neighbors = {node: set(np.flatnonzero(row).astype(int)) for node, row in enumerate(adj_mat.T)} + for node, col in enumerate(adj_mat): + parents = np.flatnonzero(col) + if parents.size: + indegree_map[node] = parents.size + else: + zero_indegree.append(node) + + generations: list[list[int]] = [] + + while zero_indegree: + this_generation = zero_indegree + zero_indegree = [] + for node in this_generation: + for child in neighbors[node]: + indegree_map[child] -= 1 + if indegree_map[child] == 0: + zero_indegree.append(child) + del indegree_map[child] + generations.append(this_generation) + + if indegree_map: + return None + return generations + + +def _cnc_matrices2pflow( + ogi: OpenGraphIndex, + correction_matrix: MatGF2, + ordering_matrix: MatGF2, +) -> tuple[dict[int, set[int]], dict[int, int]] | None: + r"""Transform the correction and ordering matrices into a Pauli flow in its standard form (correction function and partial order). + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph whose Pauli flow is calculated. + correction_matrix : MatGF2 + Matrix encoding the correction function. + ordering_matrix : MatGF2 + Matrix encoding the partial ordering between nodes (DAG). + + Returns + ------- + pf : dict[int, set[int]] + Pauli flow correction function. pf[i] is the set of qubits to be corrected for the measurement of qubit i. + l_k : dict[int, int] + Partial order between corrected qubits, such that the pair (`key`, `value`) corresponds to (node, depth). + + or `None` + if the ordering matrix is not a DAG, in which case the input open graph does not have Pauli flow. + + Notes + ----- + - The correction matrix :math:`C` is an :math:`(n - n_I) \times (n - n_O)` matrix related to the correction function :math:`c(v) = \{u \in I^c|C_{u,v} = 1\}`, where :math:`I^c` are the non-input nodes of `ogi`. In other words, the column :math:`v` of :math:`C` encodes the correction set of :math:`v`, :math:`c(v)`. + + - The Pauli flow's ordering :math:`<_c` is the transitive closure of :math:`\lhd_c`, where the latter is related to the ordering matrix :math:`NC` as :math:`v \lhd_c w \Leftrightarrow (NC)_{w,v} = 1`, for :math:`v, w, \in O^c` two non-output nodes of `ogi`. + + See Definition 3.6, Lemma 3.12, and Theorem 3.1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + row_tags = ogi.non_inputs + col_tags = ogi.non_outputs + + # Calculation of the partial ordering + + if (topo_gen := _compute_topological_generations(ordering_matrix)) is None: + return None # The NC matrix is not a DAG, therefore there's no flow. + + l_k = dict.fromkeys(ogi.og.outputs, 0) # Output nodes are always in layer 0. + + # If m >_c n, with >_c the flow order for two nodes m, n, then layer(n) > layer(m). + # Therefore, we iterate the topological sort of the graph in _reverse_ order to obtain the order of measurements. + for layer, idx in enumerate(reversed(topo_gen), start=1): + l_k.update({col_tags[i]: layer for i in idx}) + + # Calculation of the correction function + + pf: dict[int, set[int]] = {} + for node in col_tags: + i = col_tags.index(node) + correction_set = {row_tags[j] for j in np.flatnonzero(correction_matrix[:, i])} + pf[node] = correction_set + + return pf, l_k + + +def find_pflow(og: OpenGraph) -> tuple[dict[int, set[int]], dict[int, int]] | None: + """Return a Pauli flow of the input open graph if it exists. + + Parameters + ---------- + og : OpenGraph + Open graph whose Pauli flow is calculated. + + Returns + ------- + pf : dict[int, set[int]] + Pauli flow correction function. `pf[i]` is the set of qubits to be corrected for the measurement of qubit `i`. + l_k : dict[int, int] + Partial order between corrected qubits, such that the pair (`key`, `value`) corresponds to (node, depth). + + or `None` + if the input open graph does not have Pauli flow. + + Notes + ----- + See Theorems 3.1, 4.2 and 4.4, and Algorithms 2 and 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + ni = len(og.inputs) + no = len(og.outputs) + + if ni > no: + return None + + ogi = OpenGraphIndex(og) + + cnc_matrices = _find_pflow_simple(ogi) if ni == no else _find_pflow_general(ogi) + if cnc_matrices is None: + return None + pflow = _cnc_matrices2pflow(ogi, *cnc_matrices) + if pflow is None: + return None + + pf, l_k = pflow + + return pf, l_k diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py new file mode 100644 index 000000000..2914669c1 --- /dev/null +++ b/graphix/flow/flow.py @@ -0,0 +1,208 @@ +"""Module for flow classes.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import cached_property +from typing import TYPE_CHECKING, Generic + +import networkx as nx +import numpy as np +import numpy.typing as npt + +from graphix.opengraph_ import OpenGraph, _MeasurementLabel_T +from graphix.flow.utils import find_odd_neighbor + +if TYPE_CHECKING: + from collections.abc import Mapping, Collection + + +@dataclass(frozen=True) +class PauliFlow(Generic[_MeasurementLabel_T]): + og: OpenGraph[_MeasurementLabel_T] + correction_function: Mapping[int, set[int]] + partial_order: PartialOrder + + # we might want to pass some order ? + def compute_corrections(self) -> Corrections[_MeasurementLabel_T]: + corrections = Corrections(self.og) + + # TODO: implement Browne et al. Theorems. (Need to have defiend Partial Order) + # I think we only need to override in CausalFlow. + + #for layer in self.partial_order.layers: #TODO Define partial order iter + + + + return corrections + + +@dataclass(frozen=True) +class GFlow(PauliFlow[_MeasurementLabel_T]): + pass + + +@dataclass(frozen=True) +class CausalFlow(GFlow[_MeasurementLabel_T]): + pass + + +@dataclass(frozen=True) +class PartialOrder: + dag: nx.DiGraph[int] + layers: dict[int, set[int]] = field(init=False) + + def __post_init__(self) -> None: + try: + self.layers = {layer: set(generation) for layer, generation in enumerate(reversed(nx.topological_generations(self.dag)))} + except nx.NetworkXUnfeasible: + raise ValueError("Partial order contains loops.") + + + @staticmethod + def from_adj_matrix(adj_mat: npt.NDArray[np.uint8], nodelist: Collection[int] | None = None) -> PartialOrder: + return PartialOrder(nx.from_numpy_array(adj_mat, create_using=nx.DiGraph, nodelist=nodelist)) + + @staticmethod + def from_relations(relations: Collection[tuple[int, int]]) -> PartialOrder: + return PartialOrder(nx.DiGraph(relations)) + + @staticmethod + def from_layers(layers: Mapping[int, set[int]]) -> PartialOrder: + pass + + @property + def nodes(self) -> set[int]: + """Return nodes in the partial order.""" + return set(self.dag.nodes) + + @property + def node_layer_mapping(self) -> dict[int, int]: + """Return layers in the form `{node: layer}`.""" + mapping: dict[int, int] = {} + for layer, nodes in self.layers: + mapping.update({node: layer for node in nodes}) + + @cached_property + def transitive_closure(self) -> set[tuple[int, int]]: + """Return the transitive closure of the Directed Acyclic Graph (DAG) encoding the partial order. + + Returns + ------- + set[tuple[int, int]] + A tuple `(i, j)` belongs to the transitive closure of the DAG if `i > j` according to the partial order. + """ + return set(nx.transitive_closure_dag(self.dag)) + + + def greater(self, a: int, b: int) -> bool: + """Verify order between two nodes. + + Parameters + ---------- + a : int + b : int + + Returns + ------- + bool + `True` if `a > b` in the partial order, `False` otherwise. + + Raises + ----- + ValueError + If either node `a` or `b` is not included in the definition of the partial order. + """ + if a not in self.nodes: + raise ValueError(f"Node a = {a} is not included in the partial order.") + if b not in self.nodes: + raise ValueError(f"Node b = {b} is not included in the partial order.") + return (a, b) in self.transitive_closure + + def compute_future(self, node: int) -> set[int]: + """Compute the future of `node`. + + Parameters + ---------- + node : int + Node for which the future is computed. + + Returns + ------- + set[int] + Set of nodes `i` such that `i > node` in the partial order. + """ + + if node not in self.nodes: + raise ValueError(f"Node {node} is not included in the partial order.") + + return {i for i, j in self.transitive_closure if j == node} + + + + def is_compatible(self, other: PartialOrder) -> bool: + r"""Verify compatibility between two partial orders. + + Parameters + ---------- + other : PartialOrder + + Returns + ------- + bool + `True` if partial order `self` is compatible with partial order `other`, `False` otherwise. + + Notes + ----- + We define partial-order compatibility as follows: + A partial order :math:`<_P` on a set :math:`U` is compatible with a partial order :math:`<_Q` on a set :math:`V` iff :math:`a <_P b \rightarrow a <_Q b \forall a, b \in U`. + This definition of compatibility requires that :math:`U \subseteq V`. + Further, it is not symmetric. + """ + + return self.transitive_closure.issubset(other.transitive_closure) + + + +@dataclass +class Corrections(Generic[_MeasurementLabel_T]): + og: OpenGraph[_MeasurementLabel_T] + _x_corrections: dict[int, set[int]] = field(default_factory=dict) # {node: domain} + _z_corrections: dict[int, set[int]] = field(default_factory=dict) # {node: domain} + + @property + def x_corrections(self) -> dict[int, set[int]]: + return self._x_corrections + + @property + def z_corrections(self) -> dict[int, set[int]]: + return self._z_corrections + + def add_x_correction(self, node: int, domain: set[int]) -> None: + if node not in self.og.graph.nodes: + raise ValueError(f"Cannot apply X correction. Corrected node {node} does not belong to the open graph.") + + if not domain.issubset(self.og.measurements): + raise ValueError(f"Cannot apply X correction. Domain nodes {domain} are not measured.") + + if node in self._x_corrections: + self._x_corrections[node] |= domain + else: + self._x_corrections.update({node: domain}) + + def add_z_correction(self, node: int, domain: set[int]) -> None: + if node not in self.og.graph.nodes: + raise ValueError(f"Cannot apply Z correction. Corrected node {node} does not belong to the open graph.") + + if not domain.issubset(self.og.measurements): + raise ValueError(f"Cannot apply Z correction. Domain nodes {domain} are not measured.") + + if node in self._z_corrections: + self._z_corrections[node] |= domain + else: + self._z_corrections.update({node: domain}) + + # TODO: There's a bit a of duplicity between X and Z, can we do better? + + + diff --git a/graphix/flow/utils.py b/graphix/flow/utils.py new file mode 100644 index 000000000..e4eb0ed4b --- /dev/null +++ b/graphix/flow/utils.py @@ -0,0 +1,40 @@ +"""Module for flow utils.""" + +from __future__ import annotations + + +from typing import TYPE_CHECKING + +import networkx as nx + + + +if TYPE_CHECKING: + from collections.abc import Mapping + from collections.abc import Set as AbstractSet + + import networkx as nx + + + + +def find_odd_neighbor(graph: nx.Graph[int], vertices: AbstractSet[int]) -> set[int]: + """Return the odd neighborhood of a set of nodes. + + Parameters + ---------- + graph : networkx.Graph + Underlying graph. + vertices : set + Set of nodes of which to find the odd neighborhood. + + Returns + ------- + odd_neighbors : set + Set of indices for odd neighbor of set `vertices`. + """ + odd_neighbors: set[int] = set() + for vertex in vertices: + neighbors = set(graph.neighbors(vertex)) + odd_neighbors ^= neighbors + return odd_neighbors \ No newline at end of file diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py new file mode 100644 index 000000000..dbc6b3df7 --- /dev/null +++ b/graphix/opengraph_.py @@ -0,0 +1,209 @@ +"""Provides a class for open graphs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Generic, TypeVar + +import networkx as nx + +# import graphix.generator +from graphix.fundamentals import Axis, Plane +from graphix.measurements import Measurement, PauliMeasurement + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from graphix.pattern import Pattern + +# TODO +# I think we should treat Plane and Axes on the same footing (are likewise for Measurement and PauliMeasurement) +# Otherwise, shall we define Plane.XY-only open graphs. +# Maybe move these definitions to graphix.fundamentals and graphix.measurements ? +PlaneOrAxis = Plane | Axis +MeasurementOrPauliMeasurement = Measurement | PauliMeasurement + +_MeasurementLabel_T = TypeVar("_MeasurementLabel_T", PlaneOrAxis, MeasurementOrPauliMeasurement) + + +@dataclass(frozen=True) +class OpenGraph(Generic[_MeasurementLabel_T]): + """Open graph contains the graph, measurement, and input and output nodes. + + This is the graph we wish to implement deterministically. + + :param graph: the underlying :class:`networkx.Graph` state + :param measurements: a dictionary whose key is the ID of a node and the + value is the measurement at that node + :param input_nodes: an ordered list of node IDs that are inputs to the graph + :param output_nodes: an ordered list of node IDs that are outputs of the graph + + Example + ------- + >>> import networkx as nx + >>> from graphix.fundamentals import Plane + >>> from graphix.opengraph import OpenGraph, Measurement + >>> + >>> graph = nx.Graph([(0, 1), (1, 2), (2, 0)]) + >>> + >>> measurements = {i: Measurement(0.5 * i, Plane.XY) for i in range(2)} + >>> input_nodes = [0] + >>> output_nodes = [2] + >>> og = OpenGraph(graph, measurements, input_nodes, output_nodes) + """ + + graph: nx.Graph[int] + measurements: Mapping[int, _MeasurementLabel_T] # TODO: Rename `measurement_labels` ? + input_nodes: list[int] # Inputs are ordered + output_nodes: list[int] # Outputs are ordered + + # TODO + # Should we transform measurements with angle n pi/2 into PauliMeasurements with PauliMeasurement.try_from? + def __post_init__(self) -> None: + """Validate the open graph.""" + if not set(self.measurements).issubset(self.graph.nodes): + raise ValueError("All measured nodes must be part of the graph's nodes.") + if not set(self.input_nodes).issubset(self.graph.nodes): + raise ValueError("All input nodes must be part of the graph's nodes.") + if not set(self.output_nodes).issubset(self.graph.nodes): + raise ValueError("All output nodes must be part of the graph's nodes.") + if set(self.output_nodes) & self.measurements.keys(): + raise ValueError("Output node cannot be measured.") + if len(set(self.input_nodes)) != len(self.input_nodes): + raise ValueError("Input nodes contain duplicates.") + if len(set(self.output_nodes)) != len(self.output_nodes): + raise ValueError("Output nodes contain duplicates.") + + # def isclose(self, other: OpenGraph, rel_MeasurementLabel_Tol: float = 1e-09, abs_MeasurementLabel_Tol: float = 0.0) -> bool: + # """Return `True` if two open graphs implement approximately the same unitary operator. + + # Ensures the structure of the graphs are the same and all + # measurement angles are sufficiently close. + + # This doesn't check they are equal up to an isomorphism. + + # """ + # if not nx.utils.graphs_equal(self.graph, other.graph): + # return False + + # if self.input_nodes != other.input_nodes or self.output_nodes != other.output_nodes: + # return False + + # if set(self.measurements.keys()) != set(other.measurements.keys()): + # return False + + # return all( + # m.isclose(other.measurements[node], rel_MeasurementLabel_Tol=rel_MeasurementLabel_Tol, abs_MeasurementLabel_Tol=abs_MeasurementLabel_Tol) + # for node, m in self.measurements.items() + # ) + + @staticmethod + def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: + """Initialise an `OpenGraph` object based on the resource-state graph associated with the measurement pattern.""" + graph = pattern.extract_graph() + + input_nodes = pattern.input_nodes + output_nodes = pattern.output_nodes + + meas_planes = pattern.get_meas_plane() + meas_angles = pattern.get_angles() + measurements: Mapping[int, Measurement] = {node: Measurement(meas_angles[node], meas_planes[node]) for node in meas_angles} + + return OpenGraph(graph, measurements, input_nodes, output_nodes) + + # def to_pattern(self) -> Pattern: + # """Convert the `OpenGraph` into a `Pattern`. + + # Will raise an exception if the open graph does not have flow, gflow, or + # Pauli flow. + # The pattern will be generated using maximally-delayed flow. + # """ + # g = self.graph.copy() + # input_nodes = self.input_nodes + # output_nodes = self.output_nodes + # meas = self.measurements + + # angles = {node: m.angle for node, m in meas.items()} + # planes = {node: m.plane for node, m in meas.items()} + + # return graphix.generator.generate_from_graph(g, angles, input_nodes, output_nodes, planes) + + def compose(self, other: OpenGraph[_MeasurementLabel_T], mapping: Mapping[int, int]) -> tuple[OpenGraph[_MeasurementLabel_T], dict[int, int]]: + r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. + + Parameters + ---------- + other : OpenGraph + Open graph to be composed with `self`. + mapping: dict[int, int] + Partial relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node labels, respectively. + + Returns + ------- + og: OpenGraph + composed open graph + mapping_complete: dict[int, int] + Complete relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node label, respectively. + + Notes + ----- + Let's denote :math:`\{G(V_1, E_1), I_1, O_1\}` the open graph `self`, :math:`\{G(V_2, E_2), I_2, O_2\}` the open graph `other`, :math:`\{G(V, E), I, O\}` the resulting open graph `og` and `{v:u}` an element of `mapping`. + + We define :math:`V, U` the set of nodes in `mapping.keys()` and `mapping.values()`, and :math:`M = U \cap V_1` the set of merged nodes. + + The open graph composition requires that + - :math:`V \subseteq V_2`. + - If both `v` and `u` are measured, the corresponding measurements must have the same plane and angle. + The returned open graph follows this convention: + - :math:`I = (I_1 \cup I_2) \setminus M \cup (I_1 \cap I_2 \cap M)`, + - :math:`O = (O_1 \cup O_2) \setminus M \cup (O_1 \cap O_2 \cap M)`, + - If only one node of the pair `{v:u}` is measured, this measure is assigned to :math:`u \in V` in the resulting open graph. + - Input (and, respectively, output) nodes in the returned open graph have the order of the open graph `self` followed by those of the open graph `other`. Merged nodes are removed, except when they are input (or output) nodes in both open graphs, in which case, they appear in the order they originally had in the graph `self`. + """ + if not (mapping.keys() <= other.graph.nodes): + raise ValueError("Keys of mapping must be correspond to nodes of other.") + if len(mapping) != len(set(mapping.values())): + raise ValueError("Values in mapping contain duplicates.") + for v, u in mapping.items(): + if ( + (vm := other.measurements.get(v)) is not None + and (um := self.measurements.get(u)) is not None + and not vm.isclose(um) # TODO: How do we ensure that planes, axis, etc. are the same ? + ): + raise ValueError(f"Attempted to merge nodes {v}:{u} but have different measurements") + + shift = max(*self.graph.nodes, *mapping.values()) + 1 + + mapping_sequential = { + node: i for i, node in enumerate(sorted(other.graph.nodes - mapping.keys()), start=shift) + } # assigns new labels to nodes in other not specified in mapping + + mapping_complete = {**mapping, **mapping_sequential} + + g2_shifted = nx.relabel_nodes(other.graph, mapping_complete) + g = nx.compose(self.graph, g2_shifted) + + merged = set(mapping_complete.values()) & self.graph.nodes + + def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: + p2_mapped = [mapping_complete[node] for node in p2] + p2_set = set(p2_mapped) + part1 = [node for node in p1 if node not in merged or node in p2_set] + part2 = [node for node in p2_mapped if node not in merged] + return part1 + part2 + + input_nodes = merge_ports(self.input_nodes, other.input_nodes) + output_nodes = merge_ports(self.output_nodes, other.output_nodes) + + measurements_shifted = {mapping_complete[i]: meas for i, meas in other.measurements.items()} + measurements = {**self.measurements, **measurements_shifted} + + return OpenGraph(g, measurements, input_nodes, output_nodes), mapping_complete + + # def compute_flow(self) -> PauliFlow | None: + # """Compute flow.""" + + # try: + # if all(isinstance(meas.plane, Plane.XY) for meas in self.measurements.values()): + # find_cflow(self) + # except diff --git a/stubs/networkx/__init__.pyi b/stubs/networkx/__init__.pyi new file mode 100644 index 000000000..69d0f4ea9 --- /dev/null +++ b/stubs/networkx/__init__.pyi @@ -0,0 +1,11 @@ +from collections.abc import Collection, Hashable +from typing import Any, TypeVar + +import numpy.typing as npt +from networkx.classes.graph import Graph + +_G = TypeVar("_G", bound=Graph[Hashable]) + +# parameter `nodelist` is not included in networkx-types +# https://github.com/python/typeshed/blob/main/stubs/networkx/networkx/convert_matrix.pyi +def from_numpy_array(adj_mat: npt.NDArray[Any], create_using: type[_G], *, nodelist: Collection[int]) -> _G: ... From 55ce45a7f220265d2e8817168ce04a88c6b5e0e4 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 7 Oct 2025 20:11:19 +0200 Subject: [PATCH 02/56] wip --- graphix/flow/flow.py | 56 ++++++++++++++++--------------------- graphix/flow/utils.py | 8 +----- graphix/opengraph_.py | 10 +++++-- stubs/networkx/__init__.pyi | 4 +-- 4 files changed, 34 insertions(+), 44 deletions(-) diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index 2914669c1..d0f23461e 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -11,10 +11,9 @@ import numpy.typing as npt from graphix.opengraph_ import OpenGraph, _MeasurementLabel_T -from graphix.flow.utils import find_odd_neighbor if TYPE_CHECKING: - from collections.abc import Mapping, Collection + from collections.abc import Collection, Mapping @dataclass(frozen=True) @@ -30,9 +29,7 @@ def compute_corrections(self) -> Corrections[_MeasurementLabel_T]: # TODO: implement Browne et al. Theorems. (Need to have defiend Partial Order) # I think we only need to override in CausalFlow. - #for layer in self.partial_order.layers: #TODO Define partial order iter - - + # for layer in self.partial_order.layers: #TODO Define partial order iter return corrections @@ -54,19 +51,21 @@ class PartialOrder: def __post_init__(self) -> None: try: - self.layers = {layer: set(generation) for layer, generation in enumerate(reversed(nx.topological_generations(self.dag)))} + self.layers = { + layer: set(generation) + for layer, generation in enumerate(reversed(nx.topological_generations(self.dag))) + } except nx.NetworkXUnfeasible: raise ValueError("Partial order contains loops.") - @staticmethod def from_adj_matrix(adj_mat: npt.NDArray[np.uint8], nodelist: Collection[int] | None = None) -> PartialOrder: return PartialOrder(nx.from_numpy_array(adj_mat, create_using=nx.DiGraph, nodelist=nodelist)) - + @staticmethod def from_relations(relations: Collection[tuple[int, int]]) -> PartialOrder: return PartialOrder(nx.DiGraph(relations)) - + @staticmethod def from_layers(layers: Mapping[int, set[int]]) -> PartialOrder: pass @@ -75,41 +74,42 @@ def from_layers(layers: Mapping[int, set[int]]) -> PartialOrder: def nodes(self) -> set[int]: """Return nodes in the partial order.""" return set(self.dag.nodes) - + @property def node_layer_mapping(self) -> dict[int, int]: """Return layers in the form `{node: layer}`.""" mapping: dict[int, int] = {} - for layer, nodes in self.layers: - mapping.update({node: layer for node in nodes}) + for layer, nodes in self.layers.items(): + mapping.update(dict.fromkeys(nodes, layer)) + + return mapping @cached_property def transitive_closure(self) -> set[tuple[int, int]]: """Return the transitive closure of the Directed Acyclic Graph (DAG) encoding the partial order. - + Returns ------- set[tuple[int, int]] A tuple `(i, j)` belongs to the transitive closure of the DAG if `i > j` according to the partial order. """ - return set(nx.transitive_closure_dag(self.dag)) - + return set(nx.transitive_closure_dag(self.dag).edges()) def greater(self, a: int, b: int) -> bool: """Verify order between two nodes. - + Parameters ---------- a : int b : int - + Returns ------- bool `True` if `a > b` in the partial order, `False` otherwise. - + Raises - ----- + ------ ValueError If either node `a` or `b` is not included in the definition of the partial order. """ @@ -126,32 +126,29 @@ def compute_future(self, node: int) -> set[int]: ---------- node : int Node for which the future is computed. - + Returns ------- set[int] Set of nodes `i` such that `i > node` in the partial order. """ - if node not in self.nodes: raise ValueError(f"Node {node} is not included in the partial order.") - + return {i for i, j in self.transitive_closure if j == node} - - def is_compatible(self, other: PartialOrder) -> bool: r"""Verify compatibility between two partial orders. - + Parameters ---------- other : PartialOrder - + Returns ------- bool `True` if partial order `self` is compatible with partial order `other`, `False` otherwise. - + Notes ----- We define partial-order compatibility as follows: @@ -159,11 +156,9 @@ def is_compatible(self, other: PartialOrder) -> bool: This definition of compatibility requires that :math:`U \subseteq V`. Further, it is not symmetric. """ - return self.transitive_closure.issubset(other.transitive_closure) - @dataclass class Corrections(Generic[_MeasurementLabel_T]): og: OpenGraph[_MeasurementLabel_T] @@ -203,6 +198,3 @@ def add_z_correction(self, node: int, domain: set[int]) -> None: self._z_corrections.update({node: domain}) # TODO: There's a bit a of duplicity between X and Z, can we do better? - - - diff --git a/graphix/flow/utils.py b/graphix/flow/utils.py index e4eb0ed4b..f99f8370f 100644 --- a/graphix/flow/utils.py +++ b/graphix/flow/utils.py @@ -2,22 +2,16 @@ from __future__ import annotations - from typing import TYPE_CHECKING import networkx as nx - - if TYPE_CHECKING: - from collections.abc import Mapping from collections.abc import Set as AbstractSet import networkx as nx - - def find_odd_neighbor(graph: nx.Graph[int], vertices: AbstractSet[int]) -> set[int]: """Return the odd neighborhood of a set of nodes. @@ -37,4 +31,4 @@ def find_odd_neighbor(graph: nx.Graph[int], vertices: AbstractSet[int]) -> set[i for vertex in vertices: neighbors = set(graph.neighbors(vertex)) odd_neighbors ^= neighbors - return odd_neighbors \ No newline at end of file + return odd_neighbors diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index dbc6b3df7..e24ad29ad 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -98,7 +98,7 @@ def __post_init__(self) -> None: # ) @staticmethod - def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: + def from_pattern(pattern: Pattern) -> OpenGraph[MeasurementOrPauliMeasurement]: """Initialise an `OpenGraph` object based on the resource-state graph associated with the measurement pattern.""" graph = pattern.extract_graph() @@ -107,7 +107,9 @@ def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: meas_planes = pattern.get_meas_plane() meas_angles = pattern.get_angles() - measurements: Mapping[int, Measurement] = {node: Measurement(meas_angles[node], meas_planes[node]) for node in meas_angles} + measurements: Mapping[int, Measurement] = { + node: Measurement(meas_angles[node], meas_planes[node]) for node in meas_angles + } return OpenGraph(graph, measurements, input_nodes, output_nodes) @@ -128,7 +130,9 @@ def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: # return graphix.generator.generate_from_graph(g, angles, input_nodes, output_nodes, planes) - def compose(self, other: OpenGraph[_MeasurementLabel_T], mapping: Mapping[int, int]) -> tuple[OpenGraph[_MeasurementLabel_T], dict[int, int]]: + def compose( + self, other: OpenGraph[_MeasurementLabel_T], mapping: Mapping[int, int] + ) -> tuple[OpenGraph[_MeasurementLabel_T], dict[int, int]]: r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. Parameters diff --git a/stubs/networkx/__init__.pyi b/stubs/networkx/__init__.pyi index 69d0f4ea9..68c6c4ffc 100644 --- a/stubs/networkx/__init__.pyi +++ b/stubs/networkx/__init__.pyi @@ -1,10 +1,10 @@ -from collections.abc import Collection, Hashable +from collections.abc import Collection from typing import Any, TypeVar import numpy.typing as npt from networkx.classes.graph import Graph -_G = TypeVar("_G", bound=Graph[Hashable]) +_G = TypeVar("_G", bound=Graph) # parameter `nodelist` is not included in networkx-types # https://github.com/python/typeshed/blob/main/stubs/networkx/networkx/convert_matrix.pyi From 8b50154f87ddbb1dcb325c4453e8c8b101a474d4 Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 8 Oct 2025 15:56:49 +0200 Subject: [PATCH 03/56] wip --- graphix/flow/flow.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index d0f23461e..44d47932e 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -44,6 +44,13 @@ class CausalFlow(GFlow[_MeasurementLabel_T]): pass +### +# _compute_layers_from_dag -> checks dag is ok +# _compute_dag_from_layers +# use classmethod instead of static method +# from_corrections + + @dataclass(frozen=True) class PartialOrder: dag: nx.DiGraph[int] From e4a21bbcdc12053ed6c97a4c88357a01ca001576 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 9 Oct 2025 10:46:48 +0200 Subject: [PATCH 04/56] Up PartialOrder constructors --- graphix/flow/flow.py | 131 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 28 deletions(-) diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index 44d47932e..f3282d762 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from functools import cached_property +from itertools import pairwise, product from typing import TYPE_CHECKING, Generic import networkx as nx @@ -14,6 +15,7 @@ if TYPE_CHECKING: from collections.abc import Collection, Mapping + from collections.abc import Set as AbstractSet @dataclass(frozen=True) @@ -26,10 +28,10 @@ class PauliFlow(Generic[_MeasurementLabel_T]): def compute_corrections(self) -> Corrections[_MeasurementLabel_T]: corrections = Corrections(self.og) - # TODO: implement Browne et al. Theorems. (Need to have defiend Partial Order) - # I think we only need to override in CausalFlow. + # TODO: implement Browne et al. Theorems. - # for layer in self.partial_order.layers: #TODO Define partial order iter + # Causal Flow and GFlow -> only need correction function + # Pauli Flow -> need correction function and partial order return corrections @@ -44,38 +46,85 @@ class CausalFlow(GFlow[_MeasurementLabel_T]): pass -### -# _compute_layers_from_dag -> checks dag is ok -# _compute_dag_from_layers -# use classmethod instead of static method -# from_corrections - - @dataclass(frozen=True) class PartialOrder: + """Class for storing and manipulating the partial order in a flow. + + Attributes + ---------- dag: nx.DiGraph[int] - layers: dict[int, set[int]] = field(init=False) + Directed Acyclical Graph (DAG) representing the partial order. The transitive closure of `dag` yields all the relations in the partial order. + + layers: Mapping[int, AbstractSet[int]] + Mapping storing the partial order in a layer structure. + The pair `(key, value)` corresponds to the layer and the set of nodes in that layer. + Layer 0 corresponds to the largest nodes in the partial order. In general, if `i > j`, then nodes in `layers[j]` are in the future of nodes in `layers[i]`. + + """ - def __post_init__(self) -> None: - try: - self.layers = { - layer: set(generation) - for layer, generation in enumerate(reversed(nx.topological_generations(self.dag))) - } - except nx.NetworkXUnfeasible: - raise ValueError("Partial order contains loops.") + dag: nx.DiGraph[int] + layers: Mapping[int, AbstractSet[int]] - @staticmethod - def from_adj_matrix(adj_mat: npt.NDArray[np.uint8], nodelist: Collection[int] | None = None) -> PartialOrder: - return PartialOrder(nx.from_numpy_array(adj_mat, create_using=nx.DiGraph, nodelist=nodelist)) + @classmethod + def from_adj_matrix(cls, adj_mat: npt.NDArray[np.uint8], nodelist: Collection[int] | None = None) -> PartialOrder: + """Construct a partial order from an adjacency matrix representing a DAG. - @staticmethod - def from_relations(relations: Collection[tuple[int, int]]) -> PartialOrder: - return PartialOrder(nx.DiGraph(relations)) + Parameters + ---------- + adj_mat: npt.NDArray[np.uint8] + Adjacency matrix of the DAG. A nonzero element `adj_mat[i,j]` represents a link `i -> j`. + node_list: Collection[int] | None + Mapping between matrix indices and node labels. Optional, defaults to `None`. - @staticmethod - def from_layers(layers: Mapping[int, set[int]]) -> PartialOrder: - pass + Returns + ------- + PartialOrder + + Notes + ----- + The `layers` attribute of the `PartialOrder` attribute is obtained by performing a topological sort on the DAG. This routine verifies that the input directed graph is indeed acyclical. See :func:`_compute_layers_from_dag` for more details. + """ + dag = nx.from_numpy_array(adj_mat, create_using=nx.DiGraph, nodelist=nodelist) + layers = _compute_layers_from_dag(dag) + return cls(dag=dag, layers=layers) + + @classmethod + def from_relations(cls, relations: Collection[tuple[int, int]]) -> PartialOrder: + """Construct a partial order from the order relations. + + Parameters + ---------- + relations: Collection[tuple[int, int]] + Collection of relations in the partial order. A tuple `(a, b)` represents `a > b` in the partial order. + + Returns + ------- + PartialOrder + + Notes + ----- + The `layers` attribute of the `PartialOrder` attribute is obtained by performing a topological sort on the DAG. This routine verifies that the input directed graph is indeed acyclical. See :func:`_compute_layers_from_dag` for more details. + """ + dag = nx.DiGraph(relations) + layers = _compute_layers_from_dag(dag) + return cls(dag=dag, layers=layers) + + @classmethod + def from_layers(cls, layers: Mapping[int, AbstractSet[int]]) -> PartialOrder: + dag = _compute_dag_from_layers(layers) + return cls(dag=dag, layers=layers) + + @classmethod + def from_corrections(cls, corrections: Corrections) -> PartialOrder: + relations: set[tuple[int, int]] = set() + + for node, domain in corrections.x_corrections.items(): + relations.update(product([node], domain)) + + for node, domain in corrections.z_corrections.items(): + relations.update(product([node], domain)) + + return cls.from_relations(relations) @property def nodes(self) -> set[int]: @@ -205,3 +254,29 @@ def add_z_correction(self, node: int, domain: set[int]) -> None: self._z_corrections.update({node: domain}) # TODO: There's a bit a of duplicity between X and Z, can we do better? + + +def _compute_layers_from_dag(dag: nx.DiGraph[int]) -> dict[int, set[int]]: + try: + generations = reversed(list(nx.topological_generations(dag))) + return {layer: set(generation) for layer, generation in enumerate(generations)} + except nx.NetworkXUnfeasible as exc: + raise ValueError("Partial order contains loops.") from exc + + +def _compute_dag_from_layers(layers: Mapping[int, AbstractSet[int]]) -> nx.DiGraph[int]: + max_layer = max(layers) + relations: list[tuple[int, int]] = [] + visited_nodes: set[int] = set() + + for i, j in pairwise(reversed(range(max_layer + 1))): + layer_curr, layer_next = layers[i], layers[j] + if layer_curr & visited_nodes: + raise ValueError(f"Layer {i} contains nodes in previous layers.") + visited_nodes |= layer_curr + relations.extend(product(layer_curr, layer_next)) + + if layers[0] & visited_nodes: + raise ValueError(f"Layer {i} contains nodes in previous layers.") + + return nx.DiGraph(relations) From 0c36fbc9976eba4e99008ab6b4d3fd9798d89abc Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 10 Oct 2025 10:40:08 +0200 Subject: [PATCH 05/56] wip --- graphix/opengraph_.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index e24ad29ad..c4a1729e2 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -25,6 +25,10 @@ _MeasurementLabel_T = TypeVar("_MeasurementLabel_T", PlaneOrAxis, MeasurementOrPauliMeasurement) +# Add methods ? +# neighbors(node) -> calls self.graph.neighbors(node) +# odd_neighbors -> custom method + @dataclass(frozen=True) class OpenGraph(Generic[_MeasurementLabel_T]): From 16d137608d68f8e0443e9698b346030a1931d775 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 14 Oct 2025 09:25:51 +0200 Subject: [PATCH 06/56] wip --- graphix/flow/flow.py | 155 +++++++++++++++++++++++++++++------------- graphix/opengraph_.py | 38 ++++++++++- 2 files changed, 146 insertions(+), 47 deletions(-) diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index f3282d762..4c20af148 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -18,22 +18,116 @@ from collections.abc import Set as AbstractSet +@dataclass +class Corrections_(Generic[_MeasurementLabel_T]): + og: OpenGraph[_MeasurementLabel_T] + _x_corrections: dict[int, set[int]] = field(default_factory=dict) # {node: domain} + _z_corrections: dict[int, set[int]] = field(default_factory=dict) # {node: domain} + + @property + def x_corrections(self) -> dict[int, set[int]]: + return self._x_corrections + + @property + def z_corrections(self) -> dict[int, set[int]]: + return self._z_corrections + + # TODO: This may be a cached_property. In this case we would have to clear the cache after adding an X or a Z correction. + # TODO: Strictly speaking, this function returns a directed graph (i.e., we don't check that it's acyclical at this level). This is done by the function `is_wellformed` + @property + def dag(self) -> nx.DiGraph[int]: + + relations: set[tuple[int, int]] = set() + + for node, domain in self.x_corrections.items(): + relations.update(product([node], domain)) + + for node, domain in self.z_corrections.items(): + relations.update(product([node], domain)) + + return nx.DiGraph(relations) + + # TODO: There's a bit a of duplicity between X and Z, can we do better? + + def add_x_correction(self, node: int, domain: set[int]) -> None: + if node not in self.og.graph.nodes: + raise ValueError(f"Cannot apply X correction. Corrected node {node} does not belong to the open graph.") + + if not domain.issubset(self.og.measurements): + raise ValueError(f"Cannot apply X correction. Domain nodes {domain} are not measured.") + + if node in self._x_corrections: + self._x_corrections[node] |= domain + else: + self._x_corrections.update({node: domain}) + + def add_z_correction(self, node: int, domain: set[int]) -> None: + if node not in self.og.graph.nodes: + raise ValueError(f"Cannot apply Z correction. Corrected node {node} does not belong to the open graph.") + + if not domain.issubset(self.og.measurements): + raise ValueError(f"Cannot apply Z correction. Domain nodes {domain} are not measured.") + + if node in self._z_corrections: + self._z_corrections[node] |= domain + else: + self._z_corrections.update({node: domain}) + + def is_wellformed(self) -> bool: + return nx.is_directed_acyclic_graph(self.dag) + + +@dataclass(frozen=True) +class Corrections(Generic[_MeasurementLabel_T]): + og: OpenGraph[_MeasurementLabel_T] + x_corrections: dict[int, set[int]] # {node: domain} + z_corrections: dict[int, set[int]] # {node: domain} + + def __post_init__(self): + for corr_type in ['X', 'Z']: + corrections = self.__getattribute__(f"{corr_type.lower()}_corrections") + for node, domain in corrections.items(): + if node not in self.og.graph.nodes: + raise ValueError(f"Cannot apply {corr_type} correction. Corrected node {node} does not belong to the open graph.") + if not domain.issubset(self.og.measurements): + raise ValueError(f"Cannot apply {corr_type} correction. Domain nodes {domain} are not measured.") + if nx.is_directed_acyclic_graph(self.dag): + raise ValueError("Corrections are not runnable since the induced directed graph contains cycles.") + + @property + def dag(self) -> nx.DiGraph[int]: + + relations: set[tuple[int, int]] = set() + + for node, domain in self.x_corrections.items(): + relations.update(product([node], domain)) + + for node, domain in self.z_corrections.items(): + relations.update(product([node], domain)) + + return nx.DiGraph(relations) + + @dataclass(frozen=True) -class PauliFlow(Generic[_MeasurementLabel_T]): +class PauliFlow(Corrections[_MeasurementLabel_T]): og: OpenGraph[_MeasurementLabel_T] correction_function: Mapping[int, set[int]] - partial_order: PartialOrder - # we might want to pass some order ? - def compute_corrections(self) -> Corrections[_MeasurementLabel_T]: - corrections = Corrections(self.og) + # TODO: Not needed atm + # @classmethod + # def from_correction_function(cls, og, pf) -> Self: + # x_corrections: dict[int, set[int]] = {} + # z_corrections: dict[int, set[int]] = {} - # TODO: implement Browne et al. Theorems. + # return cls(og, x_corrections, z_corrections, pf) - # Causal Flow and GFlow -> only need correction function - # Pauli Flow -> need correction function and partial order + @classmethod + def from_c_matrix(cls, aog, c_matrix) -> Self: + x_corrections: dict[int, set[int]] = {} + z_corrections: dict[int, set[int]] = {} + pf: dict[int, set[int]] = {} - return corrections + return cls(aog, x_corrections, z_corrections, pf) @dataclass(frozen=True) @@ -215,46 +309,15 @@ def is_compatible(self, other: PartialOrder) -> bool: return self.transitive_closure.issubset(other.transitive_closure) -@dataclass -class Corrections(Generic[_MeasurementLabel_T]): - og: OpenGraph[_MeasurementLabel_T] - _x_corrections: dict[int, set[int]] = field(default_factory=dict) # {node: domain} - _z_corrections: dict[int, set[int]] = field(default_factory=dict) # {node: domain} +def _compute_corrections(og: OpenGraph, corr_func: Mapping[int, set[int]]) -> Corrections: - @property - def x_corrections(self) -> dict[int, set[int]]: - return self._x_corrections - - @property - def z_corrections(self) -> dict[int, set[int]]: - return self._z_corrections + for node, corr_set in corr_func.items(): + domain_x = corr_set - {node} + domain_z = og.odd_neighbors(corr_set) - def add_x_correction(self, node: int, domain: set[int]) -> None: - if node not in self.og.graph.nodes: - raise ValueError(f"Cannot apply X correction. Corrected node {node} does not belong to the open graph.") - - if not domain.issubset(self.og.measurements): - raise ValueError(f"Cannot apply X correction. Domain nodes {domain} are not measured.") - - if node in self._x_corrections: - self._x_corrections[node] |= domain - else: - self._x_corrections.update({node: domain}) - - def add_z_correction(self, node: int, domain: set[int]) -> None: - if node not in self.og.graph.nodes: - raise ValueError(f"Cannot apply Z correction. Corrected node {node} does not belong to the open graph.") - - if not domain.issubset(self.og.measurements): - raise ValueError(f"Cannot apply Z correction. Domain nodes {domain} are not measured.") - - if node in self._z_corrections: - self._z_corrections[node] |= domain - else: - self._z_corrections.update({node: domain}) - - # TODO: There's a bit a of duplicity between X and Z, can we do better? +########### +# OLD functions def _compute_layers_from_dag(dag: nx.DiGraph[int]) -> dict[int, set[int]]: try: diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index c4a1729e2..6ddebcefd 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -12,7 +12,7 @@ from graphix.measurements import Measurement, PauliMeasurement if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Collection, Iterable, Mapping from graphix.pattern import Pattern @@ -208,6 +208,42 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: return OpenGraph(g, measurements, input_nodes, output_nodes), mapping_complete + def neighbors(self, nodes: Collection[int]) -> set[int]: + """Return the set containing the neighborhood of a set of nodes. + + Parameters + ---------- + nodes : Collection[int] + Set of nodes whose neighborhood is to be found + + Returns + ------- + neighbors_set : set[int] + Neighborhood of set `nodes`. + """ + neighbors_set: set[int] = set() + for node in nodes: + neighbors_set |= set(self.graph.neighbors(node)) + return neighbors_set + + def odd_neighbors(self, nodes: Collection[int]) -> set[int]: + """Return the set containing the odd neighborhood of a set of nodes. + + Parameters + ---------- + nodes : Collection[int] + Set of nodes whose odd neighborhood is to be found + + Returns + ------- + odd_neighbors_set : set[int] + Odd neighborhood of set `nodes`. + """ + odd_neighbors_set: set[int] = set() + for node in nodes: + odd_neighbors_set ^= self.neighbors([node]) + return odd_neighbors_set + # def compute_flow(self) -> PauliFlow | None: # """Compute flow.""" From 5b7219122051deb4ca41a8bf295c77b45cd8f3e5 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 14 Oct 2025 17:42:09 +0200 Subject: [PATCH 07/56] wip --- graphix/flow/_find_pflow.py | 392 +++++++++++++++++------------------- graphix/flow/flow.py | 232 +++++++++++++-------- graphix/opengraph_.py | 35 ++-- stubs/networkx/__init__.pyi | 11 - 4 files changed, 353 insertions(+), 317 deletions(-) delete mode 100644 stubs/networkx/__init__.pyi diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_pflow.py index 6a0b8e949..451a36b3d 100644 --- a/graphix/flow/_find_pflow.py +++ b/graphix/flow/_find_pflow.py @@ -12,6 +12,7 @@ from __future__ import annotations from copy import deepcopy +from functools import cached_property from typing import TYPE_CHECKING import numpy as np @@ -27,8 +28,8 @@ from graphix.opengraph import OpenGraph -class OpenGraphIndex: - """A class for managing the mapping between node numbers of a given open graph and matrix indices in the Pauli flow finding algorithm. +class AlgebraicOpenGraph: + """A class for providing an algebraic representation of open graphs as introduced in [1]. In particular, it allows managing the mapping between node labels of the graph and the relevant matrix indices. The flow demand and order demand matrices appear as cached properties. It reuses the class `:class: graphix.sim.base_backend.NodeIndex` introduced for managing the mapping between node numbers and qubit indices in the internal state of the backend. @@ -41,7 +42,11 @@ class OpenGraphIndex: Notes ----- - At initialization, `non_outputs_optim` is a copy of `non_outputs`. The nodes corresponding to zero-rows of the order-demand matrix are removed for calculating the P matrix more efficiently in the `:func: _find_pflow_general` routine. + At initialization, `non_outputs_optim` is a copy of `non_outputs`. The nodes corresponding to zero-rows of the order-demand matrix are removed for calculating the P matrix more efficiently in the `:func: _compute_correction_matrix_general` routine. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). """ def __init__(self, og: OpenGraph) -> None: @@ -62,139 +67,108 @@ def __init__(self, og: OpenGraph) -> None: # Needs to be a deep copy because it may be modified during runtime. self.non_outputs_optim = deepcopy(self.non_outputs) + @property + def flow_demand_matrix(self) -> MatGF2: + return self._compute_pflow_matrices[0] -def _compute_reduced_adj(ogi: OpenGraphIndex) -> MatGF2: - r"""Return the reduced adjacency matrix (RAdj) of the input open graph. + @property + def order_demand_matrix(self) -> MatGF2: + return self._compute_pflow_matrices[1] - Parameters - ---------- - ogi : OpenGraphIndex - Open graph whose RAdj is computed. - - Returns - ------- - adj_red : MatGF2 - Reduced adjacency matrix. - - Notes - ----- - The adjacency matrix of a graph :math:`Adj_G` is an :math:`n \times n` matrix. - - The RAdj matrix of an open graph OG is an :math:`(n - n_O) \times (n - n_I)` submatrix of :math:`Adj_G` constructed by removing the output rows and input columns of :math:`Adj_G`. - - See Definition 3.3 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - graph = ogi.og.inside - row_tags = ogi.non_outputs - col_tags = ogi.non_inputs + def _compute_reduced_adj(self) -> MatGF2: + r"""Return the reduced adjacency matrix (RAdj) of the input open graph. - adj_red = np.zeros((len(row_tags), len(col_tags)), dtype=np.uint8).view(MatGF2) + Parameters + ---------- + aog : AlgebraicOpenGraph + Open graph whose RAdj is computed. - for n1, n2 in graph.edges: - for u, v in ((n1, n2), (n2, n1)): - if u in row_tags and v in col_tags: - i, j = row_tags.index(u), col_tags.index(v) - adj_red[i, j] = 1 + Returns + ------- + adj_red : MatGF2 + Reduced adjacency matrix. - return adj_red - - -def _compute_pflow_matrices(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2]: - r"""Construct flow-demand and order-demand matrices. - - Parameters - ---------- - ogi : OpenGraphIndex - Open graph whose flow-demand and order-demand matrices are computed. - - Returns - ------- - flow_demand_matrix : MatGF2 - order_demand_matrix : MatGF2 - - Notes - ----- - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - flow_demand_matrix = _compute_reduced_adj(ogi) - order_demand_matrix = flow_demand_matrix.copy() + Notes + ----- + The adjacency matrix of a graph :math:`Adj_G` is an :math:`n \times n` matrix. - inputs_set = set(ogi.og.inputs) - meas = ogi.og.measurements + The RAdj matrix of an open graph OG is an :math:`(n - n_O) \times (n - n_I)` submatrix of :math:`Adj_G` constructed by removing the output rows and input columns of :math:`Adj_G`. - row_tags = ogi.non_outputs - col_tags = ogi.non_inputs - - # TODO: integrate pauli measurements in open graphs - meas_planes = {i: m.plane for i, m in meas.items()} - meas_angles = {i: m.angle for i, m in meas.items()} - meas_plane_axis = { - node: pm.axis if (pm := PauliMeasurement.try_from(plane, meas_angles[node])) else plane - for node, plane in meas_planes.items() - } - - for v in row_tags: # v is a node tag - i = row_tags.index(v) - plane_axis_v = meas_plane_axis[v] - - if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: - flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Y, Axis.Z} and v not in inputs_set: - j = col_tags.index(v) - flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 - if plane_axis_v in {Plane.XY, Axis.X, Axis.Y, Axis.Z}: - order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if plane_axis_v in {Plane.XY, Plane.XZ} and v not in inputs_set: - j = col_tags.index(v) - order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 - - return flow_demand_matrix, order_demand_matrix - - -def _find_pflow_simple(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: - r"""Construct the correction matrix :math:`C` and the ordering matrix, :math:`NC` for an open graph with equal number of inputs and outputs. - - Parameters - ---------- - ogi : OpenGraphIndex - Open graph for which :math:`C` and :math:`NC` are computed. - - Returns - ------- - correction_matrix : MatGF2 - Matrix encoding the correction function. - ordering_matrix : MatGF2 - Matrix encoding the partial ordering between nodes. - - or `None` - if the input open graph does not have Pauli flow. + See Definition 3.3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + graph = self.og.inside + row_tags = self.non_outputs + col_tags = self.non_inputs - Notes - ----- - - The ordering matrix is defined as the product of the order-demand matrix :math:`N` and the correction matrix. + adj_red = np.zeros((len(row_tags), len(col_tags)), dtype=np.uint8).view(MatGF2) - - The function only returns `None` when the flow-demand matrix is not invertible (meaning that `ogi` does not have Pauli flow). The condition that the ordering matrix :math:`NC` must encode a directed acyclic graph (DAG) is verified in a subsequent step by `:func: _compute_topological_generations`. + for n1, n2 in graph.edges: + for u, v in ((n1, n2), (n2, n1)): + if u in row_tags and v in col_tags: + i, j = row_tags.index(u), col_tags.index(v) + adj_red[i, j] = 1 - See Definitions 3.4, 3.5 and 3.6, Theorems 3.1 and 4.1, and Algorithm 2 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) + return adj_red - correction_matrix = flow_demand_matrix.right_inverse() # C matrix + @cached_property + def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: + r"""Construct flow-demand and order-demand matrices. - if correction_matrix is None: - return None # The flow-demand matrix is not invertible, therefore there's no flow. + Parameters + ---------- + aog : AlgebraicOpenGraph + Open graph whose flow-demand and order-demand matrices are computed. - ordering_matrix = order_demand_matrix.mat_mul(correction_matrix) # NC matrix + Returns + ------- + flow_demand_matrix : MatGF2 + order_demand_matrix : MatGF2 - return correction_matrix, ordering_matrix - - -def _compute_p_matrix(ogi: OpenGraphIndex, nb_matrix: MatGF2) -> MatGF2 | None: + Notes + ----- + See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + flow_demand_matrix = self._compute_reduced_adj() + order_demand_matrix = flow_demand_matrix.copy() + + inputs_set = set(self.og.inputs) + meas = self.og.measurements + + row_tags = self.non_outputs + col_tags = self.non_inputs + + # TODO: integrate pauli measurements in open graphs + meas_planes = {i: m.plane for i, m in meas.items()} + meas_angles = {i: m.angle for i, m in meas.items()} + meas_plane_axis = { + node: pm.axis if (pm := PauliMeasurement.try_from(plane, meas_angles[node])) else plane + for node, plane in meas_planes.items() + } + + for v in row_tags: # v is a node tag + i = row_tags.index(v) + plane_axis_v = meas_plane_axis[v] + + if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: + flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 + if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Y, Axis.Z} and v not in inputs_set: + j = col_tags.index(v) + flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 + if plane_axis_v in {Plane.XY, Axis.X, Axis.Y, Axis.Z}: + order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 + if plane_axis_v in {Plane.XY, Plane.XZ} and v not in inputs_set: + j = col_tags.index(v) + order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 + + return flow_demand_matrix, order_demand_matrix + + +def _compute_p_matrix(aog: AlgebraicOpenGraph, nb_matrix: MatGF2) -> MatGF2 | None: r"""Perform the steps 8 - 12 of the general case (larger number of outputs than inputs) algorithm. Parameters ---------- - ogi : OpenGraphIndex + aog : AlgebraicOpenGraph Open graph for which the matrix :math:`P` is computed. nb_matrix : MatGF2 Matrix :math:`N_B` @@ -211,9 +185,9 @@ def _compute_p_matrix(ogi: OpenGraphIndex, nb_matrix: MatGF2) -> MatGF2 | None: ----- See Theorem 4.4, steps 8 - 12 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - n_no = len(ogi.non_outputs) # number of columns of P matrix. - n_oi_diff = len(ogi.og.outputs) - len(ogi.og.inputs) # number of rows of P matrix. - n_no_optim = len(ogi.non_outputs_optim) # number of rows and columns of the third block of the K_{LS} matrix. + n_no = len(aog.non_outputs) # number of columns of P matrix. + n_oi_diff = len(aog.og.outputs) - len(aog.og.inputs) # number of rows of P matrix. + n_no_optim = len(aog.non_outputs_optim) # number of rows and columns of the third block of the K_{LS} matrix. # Steps 8, 9 and 10 kils_matrix = np.concatenate( @@ -224,23 +198,23 @@ def _compute_p_matrix(ogi: OpenGraphIndex, nb_matrix: MatGF2) -> MatGF2 | None: # Step 11 p_matrix = np.zeros((n_oi_diff, n_no), dtype=np.uint8).view(MatGF2) solved_nodes: set[int] = set() - non_outputs_set = set(ogi.non_outputs) + non_outputs_set = set(aog.non_outputs) # Step 12 while solved_nodes != non_outputs_set: - solvable_nodes = _find_solvable_nodes(ogi, kls_matrix, non_outputs_set, solved_nodes, n_oi_diff) # Step 12.a + solvable_nodes = _find_solvable_nodes(aog, kls_matrix, non_outputs_set, solved_nodes, n_oi_diff) # Step 12.a if not solvable_nodes: return None - _update_p_matrix(ogi, kls_matrix, p_matrix, solvable_nodes, n_oi_diff) # Steps 12.b, 12.c - _update_kls_matrix(ogi, kls_matrix, kils_matrix, solvable_nodes, n_oi_diff, n_no, n_no_optim) # Step 12.d + _update_p_matrix(aog, kls_matrix, p_matrix, solvable_nodes, n_oi_diff) # Steps 12.b, 12.c + _update_kls_matrix(aog, kls_matrix, kils_matrix, solvable_nodes, n_oi_diff, n_no, n_no_optim) # Step 12.d solved_nodes.update(solvable_nodes) return p_matrix def _find_solvable_nodes( - ogi: OpenGraphIndex, + aog: AlgebraicOpenGraph, kls_matrix: MatGF2, non_outputs_set: AbstractSet[int], solved_nodes: AbstractSet[int], @@ -261,7 +235,7 @@ def _find_solvable_nodes( ) # Row indices of the 0-rows in the first block of K_{LS}. if row_idxs.size: for v in non_outputs_set - solved_nodes: - j = n_oi_diff + ogi.non_outputs.index(v) # `n_oi_diff` is the column offset from the first block of K_{LS}. + j = n_oi_diff + aog.non_outputs.index(v) # `n_oi_diff` is the column offset from the first block of K_{LS}. if not kls_matrix[row_idxs, j].any(): solvable_nodes.add(v) else: @@ -272,7 +246,7 @@ def _find_solvable_nodes( def _update_p_matrix( - ogi: OpenGraphIndex, kls_matrix: MatGF2, p_matrix: MatGF2, solvable_nodes: AbstractSet[int], n_oi_diff: int + aog: AlgebraicOpenGraph, kls_matrix: MatGF2, p_matrix: MatGF2, solvable_nodes: AbstractSet[int], n_oi_diff: int ) -> None: """Update `p_matrix`. @@ -281,7 +255,7 @@ def _update_p_matrix( See Theorem 4.4, steps 12.b and 12.c in Mitosek and Backens, 2024 (arXiv:2410.23439). """ for v in solvable_nodes: - j = ogi.non_outputs.index(v) + j = aog.non_outputs.index(v) j_shift = n_oi_diff + j # `n_oi_diff` is the column offset from the first block of K_{LS}. mat = MatGF2(kls_matrix[:, :n_oi_diff]) # First block of K_{LS}, in row echelon form. b = MatGF2(kls_matrix[:, j_shift]) @@ -290,7 +264,7 @@ def _update_p_matrix( def _update_kls_matrix( - ogi: OpenGraphIndex, + aog: AlgebraicOpenGraph, kls_matrix: MatGF2, kils_matrix: MatGF2, solvable_nodes: AbstractSet[int], @@ -323,10 +297,10 @@ def reorder(old_pos: int, new_pos: int) -> None: # Used in step 12.d.vi for v in solvable_nodes: if ( - v in ogi.non_outputs_optim + v in aog.non_outputs_optim ): # if `v` corresponded to a zero row in N_B, it was not present in `kls_matrix` because we removed it in the optimization process, so there's no need to do Gaussian elimination for that vertex. # Step 12.d.ii - j = ogi.non_outputs_optim.index(v) + j = aog.non_outputs_optim.index(v) j_shift = shift + j row_idxs = np.flatnonzero( kls_matrix[:, j_shift] @@ -385,20 +359,22 @@ def reorder(old_pos: int, new_pos: int) -> None: # Used in step 12.d.vi ] # `[:]` is crucial to modify the data pointed by `kls_matrix`. -def _find_pflow_general(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: - r"""Construct the generalized correction matrix :math:`C'C^B` and the generalized ordering matrix, :math:`NC'C^B` for an open graph with larger number of outputs than inputs. +def _compute_correction_matrix_general_case(aog: AlgebraicOpenGraph, flow_demand_matrix: MatGF2, order_demand_matrix: MatGF2) -> MatGF2 | None: + r"""Construct the generalized correction matrix :math:`C'C^B` for an open graph with larger number of outputs than inputs. Parameters ---------- - ogi : OpenGraphIndex + aog : AlgebraicOpenGraph Open graph for which :math:`C'C^B` and :math:`NC'C^B` are computed. + flow_demand_matrix: MatGF2 + Flow demand matrix :math:`M` (a property of the open graph). + order_demand_matrix: MatGF2 + Order demand matrix :math:`N` (a property of the open graph). Returns ------- correction_matrix : MatGF2 Matrix encoding the correction function. - ordering_matrix : MatGF2 - Matrix encoding the partial ordering between nodes. or `None` if the input open graph does not have Pauli flow. @@ -408,16 +384,13 @@ def _find_pflow_general(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: - The function returns `None` if a) The flow-demand matrix is not invertible, or b) Not all linear systems of equations associated to the non-output nodes are solvable, - meaning that `ogi` does not have Pauli flow. + meaning that `aog` does not have Pauli flow. Condition (b) is satisfied when the flow-demand matrix :math:`M` does not have a right inverse :math:`C` such that :math:`NC` represents a directed acyclical graph (DAG). See Theorem 4.4 and Algorithm 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - n_no = len(ogi.non_outputs) - n_oi_diff = len(ogi.og.outputs) - len(ogi.og.inputs) - - # Steps 1 and 2 - flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) + n_no = len(aog.non_outputs) + n_oi_diff = len(aog.og.outputs) - len(aog.og.inputs) # Steps 3 and 4 correction_matrix_0 = flow_demand_matrix.right_inverse() # C0 matrix. @@ -440,10 +413,10 @@ def _find_pflow_general(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: order_demand_matrix[row_idxs].view(MatGF2).mat_mul(c_prime_matrix) ) # `view` is used to keep mypy happy without copying data. for i in set(range(order_demand_matrix.shape[0])).difference(row_idxs): - ogi.non_outputs_optim.remove(ogi.non_outputs[i]) # Update the node-index mapping. + aog.non_outputs_optim.remove(aog.non_outputs[i]) # Update the node-index mapping. # Steps 8 - 12 - if (p_matrix := _compute_p_matrix(ogi, nb_matrix_optim)) is None: + if (p_matrix := _compute_p_matrix(aog, nb_matrix_optim)) is None: return None else: # If all rows of `order_demand_matrix` are zero, any matrix will solve the associated linear system of equations. @@ -452,10 +425,7 @@ def _find_pflow_general(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: # Step 13 cb_matrix = np.concatenate((np.eye(n_no, dtype=np.uint8), p_matrix), axis=0).view(MatGF2) - correction_matrix = c_prime_matrix.mat_mul(cb_matrix) - ordering_matrix = order_demand_matrix.mat_mul(correction_matrix) - - return correction_matrix, ordering_matrix + return c_prime_matrix.mat_mul(cb_matrix) def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] | None: @@ -469,7 +439,7 @@ def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] Returns ------- list[list[int]] - Topological generations. Integers represent the indices of the matrix `ordering_matrix`, not the labelling of the nodes. + topological generations. Integers represent the indices of the matrix `ordering_matrix`, not the labelling of the nodes. or `None` if `ordering_matrix` is not a DAG. @@ -510,87 +480,102 @@ def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] return generations -def _cnc_matrices2pflow( - ogi: OpenGraphIndex, - correction_matrix: MatGF2, - ordering_matrix: MatGF2, -) -> tuple[dict[int, set[int]], dict[int, int]] | None: - r"""Transform the correction and ordering matrices into a Pauli flow in its standard form (correction function and partial order). +def compute_correction_function(aog: AlgebraicOpenGraph, correction_matrix: MatGF2) -> dict[int, set[int]]: + r"""Transform the correction matrix into a correction function. Parameters ---------- - ogi : OpenGraphIndex + aog : AlgebraicOpenGraph + Open graph whose Pauli flow is calculated. + correction_matrix : MatGF2 + Matrix encoding the correction function. + + Returns + ------- + correction_function : dict[int, set[int]] + Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits to be corrected for the measurement of qubit i. + + + Notes + ----- + - The correction matrix :math:`C` is an :math:`(n - n_I) \times (n - n_O)` matrix related to the correction function :math:`c(v) = \{u \in I^c|C_{u,v} = 1\}`, where :math:`I^c` are the non-input nodes of `aog`. In other words, the column :math:`v` of :math:`C` encodes the correction set of :math:`v`, :math:`c(v)`. + + See Definition 3.6 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + row_tags = aog.non_inputs + col_tags = aog.non_outputs + correction_function: dict[int, set[int]] = {} + for node in col_tags: + i = col_tags.index(node) + correction_set = {row_tags[j] for j in np.flatnonzero(correction_matrix[:, i])} + correction_function[node] = correction_set + return correction_function + + +def compute_partial_order_layers(aog: AlgebraicOpenGraph, correction_matrix: MatGF2) -> list[set[int]] | None: + r"""Compute the partial order compatible with the correction matrix if it exists. + + Parameters + ---------- + aog : AlgebraicOpenGraph Open graph whose Pauli flow is calculated. correction_matrix : MatGF2 Matrix encoding the correction function. - ordering_matrix : MatGF2 - Matrix encoding the partial ordering between nodes (DAG). Returns ------- - pf : dict[int, set[int]] - Pauli flow correction function. pf[i] is the set of qubits to be corrected for the measurement of qubit i. - l_k : dict[int, int] - Partial order between corrected qubits, such that the pair (`key`, `value`) corresponds to (node, depth). + layers : list[set[int]] + Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. or `None` + If the correction matrix is not compatible with a partial order on the the open graph, + if the ordering matrix is not a DAG, in which case the input open graph does not have Pauli flow. Notes ----- - - The correction matrix :math:`C` is an :math:`(n - n_I) \times (n - n_O)` matrix related to the correction function :math:`c(v) = \{u \in I^c|C_{u,v} = 1\}`, where :math:`I^c` are the non-input nodes of `ogi`. In other words, the column :math:`v` of :math:`C` encodes the correction set of :math:`v`, :math:`c(v)`. - - - The Pauli flow's ordering :math:`<_c` is the transitive closure of :math:`\lhd_c`, where the latter is related to the ordering matrix :math:`NC` as :math:`v \lhd_c w \Leftrightarrow (NC)_{w,v} = 1`, for :math:`v, w, \in O^c` two non-output nodes of `ogi`. + - The partial order of the Pauli (or generalised) flow :math:`<_c` is the transitive closure of :math:`\lhd_c`, where the latter is related to the ordering matrix :math:`NC` as :math:`v \lhd_c w \Leftrightarrow (NC)_{w,v} = 1`, for :math:`v, w, \in O^c` two non-output nodes of `aog`. The ordering matrix is the product of the order-demand and the correction matrices and it is the adjacency matrix of the directed acyclical graph encoding the partial order. - See Definition 3.6, Lemma 3.12, and Theorem 3.1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + See Lemma 3.12, and Theorem 3.1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - row_tags = ogi.non_inputs - col_tags = ogi.non_outputs - - # Calculation of the partial ordering + ordering_matrix = aog.order_demand_matrix.mat_mul(correction_matrix) if (topo_gen := _compute_topological_generations(ordering_matrix)) is None: return None # The NC matrix is not a DAG, therefore there's no flow. - l_k = dict.fromkeys(ogi.og.outputs, 0) # Output nodes are always in layer 0. + layers = [set(aog.og.outputs)] # Output nodes are always in layer 0. - # If m >_c n, with >_c the flow order for two nodes m, n, then layer(n) > layer(m). + # If m >_c n, with >_c the flow partial order for two nodes m, n, then layer(n) > layer(m). # Therefore, we iterate the topological sort of the graph in _reverse_ order to obtain the order of measurements. - for layer, idx in enumerate(reversed(topo_gen), start=1): - l_k.update({col_tags[i]: layer for i in idx}) - - # Calculation of the correction function - - pf: dict[int, set[int]] = {} - for node in col_tags: - i = col_tags.index(node) - correction_set = {row_tags[j] for j in np.flatnonzero(correction_matrix[:, i])} - pf[node] = correction_set + col_tags = aog.non_outputs + layers.extend({col_tags[i] for i in idx_layer} for idx_layer in reversed(topo_gen)) - return pf, l_k + return layers -def find_pflow(og: OpenGraph) -> tuple[dict[int, set[int]], dict[int, int]] | None: - """Return a Pauli flow of the input open graph if it exists. +def compute_correction_matrix(og: OpenGraph) -> tuple[AlgebraicOpenGraph, MatGF2] | None: + """Return the correction matrix of the input open graph if it exists. Parameters ---------- og : OpenGraph - Open graph whose Pauli flow is calculated. + Open graph whose correction matrix is calculated. Returns ------- - pf : dict[int, set[int]] - Pauli flow correction function. `pf[i]` is the set of qubits to be corrected for the measurement of qubit `i`. - l_k : dict[int, int] - Partial order between corrected qubits, such that the pair (`key`, `value`) corresponds to (node, depth). + aog : AlgebraicOpenGraph + Algebraic representation of the open graph. This object encodes the mapping between the node labels in the input open graph and the row and column indices of the returned correction matrix + correction_matrix : MatGF2 + Matrix encoding the correction function. or `None` - if the input open graph does not have Pauli flow. + if the input open graph does not have Pauli (or generalised) flow. Notes ----- - See Theorems 3.1, 4.2 and 4.4, and Algorithms 2 and 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + - In the case of open graphs with equal number of inputs and outputs, the function only returns `None` when the flow-demand matrix is not invertible (meaning that `aog` does not have Pauli flow). The additional condition for the existence of Pauli flow that the ordering matrix :math:`NC` must encode a directed acyclic graph (DAG) is verified by :func:`compute_partial_order`, which is called from the `graphix.flow.flow.PauliFlow` constructor. + + See Definitions 3.4, 3.5 and 3.6, Theorems 3.1, 4.2 and 4.4, and Algorithms 2 and 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ ni = len(og.inputs) no = len(og.outputs) @@ -598,15 +583,18 @@ def find_pflow(og: OpenGraph) -> tuple[dict[int, set[int]], dict[int, int]] | No if ni > no: return None - ogi = OpenGraphIndex(og) + aog = AlgebraicOpenGraph(og) - cnc_matrices = _find_pflow_simple(ogi) if ni == no else _find_pflow_general(ogi) - if cnc_matrices is None: - return None - pflow = _cnc_matrices2pflow(ogi, *cnc_matrices) - if pflow is None: - return None + # Steps 1 and 2 + # Flow-demand and order-demand matrices are cached properties of `aog`. + flow_demand_matrix, order_demand_matrix = aog._compute_pflow_matrices + + if ni == no: + correction_matrix = flow_demand_matrix.right_inverse() + else: + correction_matrix = _compute_correction_matrix_general_case(aog, flow_demand_matrix, order_demand_matrix) - pf, l_k = pflow + if correction_matrix is None: + return None - return pf, l_k + return aog, correction_matrix diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index 4c20af148..48e17ee6e 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -2,41 +2,49 @@ from __future__ import annotations -from dataclasses import dataclass, field +from collections import defaultdict +from dataclasses import dataclass from functools import cached_property from itertools import pairwise, product from typing import TYPE_CHECKING, Generic import networkx as nx -import numpy as np -import numpy.typing as npt -from graphix.opengraph_ import OpenGraph, _MeasurementLabel_T +from graphix.flow._find_pflow import compute_correction_function, compute_partial_order_layers +from graphix.fundamentals import Plane +from graphix.opengraph_ import _M, OpenGraph if TYPE_CHECKING: from collections.abc import Collection, Mapping from collections.abc import Set as AbstractSet + from typing import Self, override + import numpy as np + import numpy.typing as npt -@dataclass -class Corrections_(Generic[_MeasurementLabel_T]): - og: OpenGraph[_MeasurementLabel_T] - _x_corrections: dict[int, set[int]] = field(default_factory=dict) # {node: domain} - _z_corrections: dict[int, set[int]] = field(default_factory=dict) # {node: domain} + from graphix._linalg import MatGF2 + from graphix.flow._find_pflow import AlgebraicOpenGraph - @property - def x_corrections(self) -> dict[int, set[int]]: - return self._x_corrections - @property - def z_corrections(self) -> dict[int, set[int]]: - return self._z_corrections +@dataclass(frozen=True) +class Corrections(Generic[_M]): + og: OpenGraph[_M] + x_corrections: dict[int, set[int]] # {node: domain} + z_corrections: dict[int, set[int]] # {node: domain} - # TODO: This may be a cached_property. In this case we would have to clear the cache after adding an X or a Z correction. - # TODO: Strictly speaking, this function returns a directed graph (i.e., we don't check that it's acyclical at this level). This is done by the function `is_wellformed` - @property - def dag(self) -> nx.DiGraph[int]: + def extract_dag(self) -> nx.DiGraph[int]: + """Extract directed graph induced by the corrections. + + Returns + ------- + nx.DiGraph[int] + Directed graph in which an edge `i -> j` represents a correction applied to qubit `i`, conditioned on the measurement outcome of qubit `j`. + Notes + ----- + - Not all nodes of the underlying open graph are nodes of the returned directed graph, but only those involved in a correction, either as corrected qubits or belonging to a correction domain. + - Despite the name, the output of this method is not guranteed to be a directed acyclical graph (i.e., a directed graph without any loops). This is only the case if the `Corrections` object is well formed, which is verified by the method :func:`Corrections.is_wellformed`. + """ relations: set[tuple[int, int]] = set() for node, domain in self.x_corrections.items(): @@ -47,97 +55,147 @@ def dag(self) -> nx.DiGraph[int]: return nx.DiGraph(relations) - # TODO: There's a bit a of duplicity between X and Z, can we do better? + def is_wellformed(self, verbose: bool = True) -> bool: + """Verify if `Corrections` object is well formed. - def add_x_correction(self, node: int, domain: set[int]) -> None: - if node not in self.og.graph.nodes: - raise ValueError(f"Cannot apply X correction. Corrected node {node} does not belong to the open graph.") + Parameters + ---------- + verbose : bool + Optional flag that indicates the source of the issue when `self` is malformed. Defaults to `True`. - if not domain.issubset(self.og.measurements): - raise ValueError(f"Cannot apply X correction. Domain nodes {domain} are not measured.") + Returns + ------- + bool + `True` if `self` is well formed, `False` otherwise. - if node in self._x_corrections: - self._x_corrections[node] |= domain - else: - self._x_corrections.update({node: domain}) + Notes + ----- + This method verifies that: + - Corrected nodes belong to the underlying open graph. + - Nodes in domain set are measured. + - Corrections are runnable. This amounts to verifying that the corrections-induced directed graph does not have loops. + """ + for corr_type in ['X', 'Z']: + corrections = getattr(self, f"{corr_type.lower()}_corrections") + for node, domain in corrections.items(): + if node not in self.og.graph.nodes: + if verbose: + print(f"Cannot apply {corr_type} correction. Corrected node {node} does not belong to the open graph.") + return False + if not domain.issubset(self.og.measurements): + if verbose: + print(f"Cannot apply {corr_type} correction. Domain nodes {domain} are not measured.") + return False + if nx.is_directed_acyclic_graph(self.extract_dag()): + if verbose: + print("Corrections are not runnable since the induced directed graph contains cycles.") + return False - def add_z_correction(self, node: int, domain: set[int]) -> None: - if node not in self.og.graph.nodes: - raise ValueError(f"Cannot apply Z correction. Corrected node {node} does not belong to the open graph.") + return True - if not domain.issubset(self.og.measurements): - raise ValueError(f"Cannot apply Z correction. Domain nodes {domain} are not measured.") + # def to_pattern(self, total_order, angles) -> Pattern: ... - if node in self._z_corrections: - self._z_corrections[node] |= domain - else: - self._z_corrections.update({node: domain}) - def is_wellformed(self) -> bool: - return nx.is_directed_acyclic_graph(self.dag) +@dataclass(frozen=True) +class PauliFlow(Generic[_M]): + og: OpenGraph[_M] + correction_function: Mapping[int, set[int]] + partial_order_layers: list[set[int]] + # TODO: Add parametric dependence of AlgebraicOpenGraph + @classmethod + def from_correction_matrix(cls, aog: AlgebraicOpenGraph, correction_matrix: MatGF2) -> Self | None: + correction_function = compute_correction_function(aog, correction_matrix) + partial_order_layers = compute_partial_order_layers(aog, correction_matrix) + if partial_order_layers is None: + return None -@dataclass(frozen=True) -class Corrections(Generic[_MeasurementLabel_T]): - og: OpenGraph[_MeasurementLabel_T] - x_corrections: dict[int, set[int]] # {node: domain} - z_corrections: dict[int, set[int]] # {node: domain} + return cls(aog.og, correction_function, partial_order_layers) - def __post_init__(self): - for corr_type in ['X', 'Z']: - corrections = self.__getattribute__(f"{corr_type.lower()}_corrections") - for node, domain in corrections.items(): - if node not in self.og.graph.nodes: - raise ValueError(f"Cannot apply {corr_type} correction. Corrected node {node} does not belong to the open graph.") - if not domain.issubset(self.og.measurements): - raise ValueError(f"Cannot apply {corr_type} correction. Domain nodes {domain} are not measured.") - if nx.is_directed_acyclic_graph(self.dag): - raise ValueError("Corrections are not runnable since the induced directed graph contains cycles.") + def to_corrections(self) -> Corrections[_M]: + """Compute the X and Z corrections induced by the Pauli flow encoded in `self`. - @property - def dag(self) -> nx.DiGraph[int]: + Returns + ------- + Corrections[_M] - relations: set[tuple[int, int]] = set() + Notes + ----- + This function partially implements Theorem 4 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. + """ + future: set[int] = self.partial_order_layers[0] + x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} + z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} - for node, domain in self.x_corrections.items(): - relations.update(product([node], domain)) + for layer in self.partial_order_layers[1:]: + for node in layer: + corr_set = self.correction_function[node] + x_corrections[node].update(corr_set & future) + z_corrections[node].update(self.og.odd_neighbors(corr_set) & future) - for node, domain in self.z_corrections.items(): - relations.update(product([node], domain)) + future |= layer - return nx.DiGraph(relations) + return Corrections(self.og, x_corrections, z_corrections) + + # TODO: for compatibility with previous encoding of layers. + # def node_layer_mapping(self) -> dict[int, int]: + # """Return layers in the form `{node: layer}`.""" + # mapping: dict[int, int] = {} + # for layer, nodes in self.layers.items(): + # mapping.update(dict.fromkeys(nodes, layer)) + + # return mapping @dataclass(frozen=True) -class PauliFlow(Corrections[_MeasurementLabel_T]): - og: OpenGraph[_MeasurementLabel_T] - correction_function: Mapping[int, set[int]] +class GFlow(PauliFlow[Plane]): - # TODO: Not needed atm - # @classmethod - # def from_correction_function(cls, og, pf) -> Self: - # x_corrections: dict[int, set[int]] = {} - # z_corrections: dict[int, set[int]] = {} + @override + def to_corrections(self) -> Corrections[Plane]: + r"""Compute the X and Z corrections induced by the generalised flow encoded in `self`. - # return cls(og, x_corrections, z_corrections, pf) + Returns + ------- + Corrections[Plane] - @classmethod - def from_c_matrix(cls, aog, c_matrix) -> Self: - x_corrections: dict[int, set[int]] = {} - z_corrections: dict[int, set[int]] = {} - pf: dict[int, set[int]] = {} + Notes + ----- + - This function partially implements Theorem 2 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. - return cls(aog, x_corrections, z_corrections, pf) + - Contrary to the overridden method in the parent class, here we do not need any information on the partial order to build the corrections since a valid correction function :math:`g` guarantees that both :math:`g(i)\setminus \{i\}` and :math:`Odd(g(i))` are in the future of :math:`i`. + """ + x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} + z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} + for node, corr_set in self.correction_function.items(): + x_corrections[node].update(corr_set - {node}) + z_corrections[node].update(self.og.odd_neighbors(corr_set)) -@dataclass(frozen=True) -class GFlow(PauliFlow[_MeasurementLabel_T]): - pass + return Corrections(self.og, x_corrections, z_corrections) @dataclass(frozen=True) -class CausalFlow(GFlow[_MeasurementLabel_T]): - pass +class CausalFlow(GFlow): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane + @override + def to_corrections(self) -> Corrections[Plane]: + r"""Compute the X and Z corrections induced by the causal flow encoded in `self`. + + Returns + ------- + Corrections[Plane] + + Notes + ----- + This function partially implements Theorem 1 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. + """ + x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} + z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} + + for node, corr_set in self.correction_function.items(): + x_corrections[node].update(corr_set) + z_corrections[node].update(self.og.neighbors(corr_set) - {node}) + + return Corrections(self.og, x_corrections, z_corrections) @dataclass(frozen=True) @@ -309,11 +367,11 @@ def is_compatible(self, other: PartialOrder) -> bool: return self.transitive_closure.issubset(other.transitive_closure) -def _compute_corrections(og: OpenGraph, corr_func: Mapping[int, set[int]]) -> Corrections: +# def _compute_corrections(og: OpenGraph, corr_func: Mapping[int, set[int]]) -> Corrections: - for node, corr_set in corr_func.items(): - domain_x = corr_set - {node} - domain_z = og.odd_neighbors(corr_set) +# for node, corr_set in corr_func.items(): +# domain_x = corr_set - {node} +# domain_z = og.odd_neighbors(corr_set) ########### diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index 6ddebcefd..7914fb767 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -7,9 +7,10 @@ import networkx as nx -# import graphix.generator +from graphix.flow._find_pflow import compute_correction_matrix +from graphix.flow.flow import CausalFlow, PauliFlow from graphix.fundamentals import Axis, Plane -from graphix.measurements import Measurement, PauliMeasurement +from graphix.measurements import Measurement if TYPE_CHECKING: from collections.abc import Collection, Iterable, Mapping @@ -20,10 +21,8 @@ # I think we should treat Plane and Axes on the same footing (are likewise for Measurement and PauliMeasurement) # Otherwise, shall we define Plane.XY-only open graphs. # Maybe move these definitions to graphix.fundamentals and graphix.measurements ? -PlaneOrAxis = Plane | Axis -MeasurementOrPauliMeasurement = Measurement | PauliMeasurement -_MeasurementLabel_T = TypeVar("_MeasurementLabel_T", PlaneOrAxis, MeasurementOrPauliMeasurement) +_M = TypeVar("_M", bound=Plane | Axis) # Add methods ? # neighbors(node) -> calls self.graph.neighbors(node) @@ -31,7 +30,7 @@ @dataclass(frozen=True) -class OpenGraph(Generic[_MeasurementLabel_T]): +class OpenGraph(Generic[_M]): """Open graph contains the graph, measurement, and input and output nodes. This is the graph we wish to implement deterministically. @@ -57,7 +56,7 @@ class OpenGraph(Generic[_MeasurementLabel_T]): """ graph: nx.Graph[int] - measurements: Mapping[int, _MeasurementLabel_T] # TODO: Rename `measurement_labels` ? + measurements: Mapping[int, _M] # TODO: Rename `measurement_labels` ? input_nodes: list[int] # Inputs are ordered output_nodes: list[int] # Outputs are ordered @@ -78,7 +77,7 @@ def __post_init__(self) -> None: if len(set(self.output_nodes)) != len(self.output_nodes): raise ValueError("Output nodes contain duplicates.") - # def isclose(self, other: OpenGraph, rel_MeasurementLabel_Tol: float = 1e-09, abs_MeasurementLabel_Tol: float = 0.0) -> bool: + # def isclose(self, other: OpenGraph, rel_Mol: float = 1e-09, abs_Mol: float = 0.0) -> bool: # """Return `True` if two open graphs implement approximately the same unitary operator. # Ensures the structure of the graphs are the same and all @@ -97,7 +96,7 @@ def __post_init__(self) -> None: # return False # return all( - # m.isclose(other.measurements[node], rel_MeasurementLabel_Tol=rel_MeasurementLabel_Tol, abs_MeasurementLabel_Tol=abs_MeasurementLabel_Tol) + # m.isclose(other.measurements[node], rel_Mol=rel_Mol, abs_Mol=abs_Mol) # for node, m in self.measurements.items() # ) @@ -135,8 +134,8 @@ def from_pattern(pattern: Pattern) -> OpenGraph[MeasurementOrPauliMeasurement]: # return graphix.generator.generate_from_graph(g, angles, input_nodes, output_nodes, planes) def compose( - self, other: OpenGraph[_MeasurementLabel_T], mapping: Mapping[int, int] - ) -> tuple[OpenGraph[_MeasurementLabel_T], dict[int, int]]: + self, other: OpenGraph[_M], mapping: Mapping[int, int] + ) -> tuple[OpenGraph[_M], dict[int, int]]: r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. Parameters @@ -244,10 +243,12 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: odd_neighbors_set ^= self.neighbors([node]) return odd_neighbors_set - # def compute_flow(self) -> PauliFlow | None: - # """Compute flow.""" + def compute_casual_flow(self) -> CausalFlow | None: + return None - # try: - # if all(isinstance(meas.plane, Plane.XY) for meas in self.measurements.values()): - # find_cflow(self) - # except + def compute_pauli_flow(self) -> PauliFlow | None: + + aog_correction_matrix = compute_correction_matrix(self) + if aog_correction_matrix is None: + return None + return PauliFlow.from_correction_matrix(*aog_correction_matrix) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. diff --git a/stubs/networkx/__init__.pyi b/stubs/networkx/__init__.pyi deleted file mode 100644 index 68c6c4ffc..000000000 --- a/stubs/networkx/__init__.pyi +++ /dev/null @@ -1,11 +0,0 @@ -from collections.abc import Collection -from typing import Any, TypeVar - -import numpy.typing as npt -from networkx.classes.graph import Graph - -_G = TypeVar("_G", bound=Graph) - -# parameter `nodelist` is not included in networkx-types -# https://github.com/python/typeshed/blob/main/stubs/networkx/networkx/convert_matrix.pyi -def from_numpy_array(adj_mat: npt.NDArray[Any], create_using: type[_G], *, nodelist: Collection[int]) -> _G: ... From 5dcc95d82b0ae37d6065b5c507ae2732527c73ee Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 14 Oct 2025 17:44:19 +0200 Subject: [PATCH 08/56] wip --- graphix/flow/flow.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index 48e17ee6e..b6e4e0cc2 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -137,6 +137,9 @@ def to_corrections(self) -> Corrections[_M]: return Corrections(self.og, x_corrections, z_corrections) + # TODO + # def is_well_formed(self) -> bool: + # TODO: for compatibility with previous encoding of layers. # def node_layer_mapping(self) -> dict[int, int]: # """Return layers in the form `{node: layer}`.""" @@ -367,13 +370,6 @@ def is_compatible(self, other: PartialOrder) -> bool: return self.transitive_closure.issubset(other.transitive_closure) -# def _compute_corrections(og: OpenGraph, corr_func: Mapping[int, set[int]]) -> Corrections: - -# for node, corr_set in corr_func.items(): -# domain_x = corr_set - {node} -# domain_z = og.odd_neighbors(corr_set) - - ########### # OLD functions From 65f081737f421bd72d64f253271af8395573992f Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 15 Oct 2025 17:26:23 +0200 Subject: [PATCH 09/56] wip --- graphix/flow/_find_cflow.py | 104 ++++++++++++++++++++++++++++++++++++ graphix/flow/_find_pflow.py | 2 +- graphix/flow/flow.py | 86 +++++++++++++++++++++++++++-- graphix/flow/utils.py | 34 ------------ graphix/opengraph_.py | 20 +++---- 5 files changed, 197 insertions(+), 49 deletions(-) delete mode 100644 graphix/flow/utils.py diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index e69de29bb..1f8ced9bc 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from graphix.fundamentals import Plane + +if TYPE_CHECKING: + from collections.abc import Set as AbstractSet + + from graphix.opengraph_ import OpenGraph + + +def find_cflow(og: OpenGraph[Plane]) -> tuple[dict[int, set[int]], list[set[int]]] | None: + """Return the causal flow of the input open graph if it exists. + + Parameters + ---------- + og : OpenGraph[Plane] + Open graph whose causal flow is calculated. + + Returns + ------- + cf : dict[int, set[int]] + Causal flow correction function. `cf[i]` is the one-qubit set correcting the measurement of qubit `i`. + layers : list[set[int]] + Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. + + or `None` + if the input open graph does not have a causal flow. + + Notes + ----- + See Definition 2, Theorem 1 and Algorithm 1 in Mhalla and Perdrix, Finding Optimal Flows Efficiently, 2008 (arXiv:0709.2670). + """ + if {Plane.XZ, Plane.YZ}.intersection(og.measurements.values()): + return None + + corrected_nodes = set(og.output_nodes) + corrector_candidates = corrected_nodes - set(og.input_nodes) + + cf: dict[int, set[int]] = {} + layers: list[set[int]] = [corrected_nodes] + + non_input_nodes = og.graph.nodes - set(og.input_nodes) + + return _flow_aux(og, non_input_nodes, corrected_nodes, corrector_candidates, cf, layers) + + +def _flow_aux(og: OpenGraph[Plane], non_input_nodes: AbstractSet[int], corrected_nodes: AbstractSet[int], corrector_candidates: AbstractSet[int], cf: dict[int, set[int]], layers: list[set[int]]) -> tuple[dict[int, set[int]], list[set[int]]] | None: + """Find one layer of the causal flow. + + Parameters + ---------- + og : OpenGraph[Plane] + Open graph whose causal flow is calculated. + non_input_nodes : AbstractSet[int] + Non-input nodes of the input open graph. This parameter remains constant throughout the execution of the algorithm and can be derived from `og` at any time. It is passed as an argument to avoid unnecessary recalulations. + corrected_nodes : AbstractSet[int] + Nodes which have already been corrected. + corrector_candidates : AbstractSet[int] + Nodes which could correct a node at the time of calling the function. This set can never contain input nodes, uncorrected nodes or nodes which already correct another node. + cf : dict[int, set[int]] + Causal flow correction function. `cf[i]` is the one-qubit set correcting the measurement of qubit `i`. + layers : list[set[int]] + Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. + + + Returns + ------- + cf : dict[int, set[int]] + Causal flow correction function. `cf[i]` is the one-qubit set correcting the measurement of qubit `i`. + layers : list[set[int]] + Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. + + or `None` + if the input open graph does not have a causal flow. + """ + corrected_nodes_new: set[int] = set() + corrector_nodes_new: set[int] = set() + curr_layer: set[int] = set() + + non_corrected_nodes = og.graph.nodes - corrected_nodes + + for p in corrector_candidates: + non_corrected_neighbors = og.neighbors({p}) & non_corrected_nodes + if len(non_corrected_neighbors) == 1: + q, = non_corrected_neighbors + cf[q] = {p} + curr_layer.add(p) + corrected_nodes_new |= {q} + corrector_nodes_new |= {p} + + layers.append(curr_layer) + + if len(corrected_nodes_new) == 0: + # TODO: This is the structure in the original graphix code. I think that we could check if non_corrected_nodes == empty before the loop and here just return None. + if corrected_nodes == og.graph.nodes: + return cf, layers + return None + + corrected_nodes |= corrected_nodes_new + corrector_candidates = (corrector_candidates - corrector_nodes_new) | (corrected_nodes_new & non_input_nodes) + + return _flow_aux(og, non_input_nodes, corrected_nodes, corrector_candidates, cf, layers) diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_pflow.py index 451a36b3d..c54eeb844 100644 --- a/graphix/flow/_find_pflow.py +++ b/graphix/flow/_find_pflow.py @@ -493,7 +493,7 @@ def compute_correction_function(aog: AlgebraicOpenGraph, correction_matrix: MatG Returns ------- correction_function : dict[int, set[int]] - Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits to be corrected for the measurement of qubit i. + Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. Notes diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index b6e4e0cc2..938228e7a 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -3,27 +3,35 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Sequence from dataclasses import dataclass from functools import cached_property from itertools import pairwise, product -from typing import TYPE_CHECKING, Generic +from typing import TYPE_CHECKING, Generic, Self, override import networkx as nx +from graphix.command import E, M, N, X, Z from graphix.flow._find_pflow import compute_correction_function, compute_partial_order_layers -from graphix.fundamentals import Plane +from graphix.fundamentals import Axis, Plane, Sign +from graphix.graphix._linalg import MatGF2 +from graphix.graphix.flow._find_pflow import AlgebraicOpenGraph from graphix.opengraph_ import _M, OpenGraph +from graphix.pattern import Pattern if TYPE_CHECKING: from collections.abc import Collection, Mapping from collections.abc import Set as AbstractSet - from typing import Self, override + from typing import Self import numpy as np import numpy.typing as npt from graphix._linalg import MatGF2 from graphix.flow._find_pflow import AlgebraicOpenGraph + from graphix.measurements import ExpressionOrFloat + +TotalOrder = Sequence[int] @dataclass(frozen=True) @@ -93,14 +101,76 @@ def is_wellformed(self, verbose: bool = True) -> bool: return True - # def to_pattern(self, total_order, angles) -> Pattern: ... + def is_compatible(self, total_order: TotalOrder) -> bool: + # Verify compatibility + # Verify nodes are in open graph + return True + + def to_pattern(self, angles: Mapping[int, ExpressionOrFloat | Sign], total_order: TotalOrder | None = None) -> Pattern: + + # TODO: Should we verify thar corrections are well formed ? If we did so, and the total order is inferred from the corrections, we are doing a topological sort twice + + # TODO: Do we want to raise an error or just a warning and assign 0 by default ? + if not angles.keys() == self.og.measurements.keys(): + raise ValueError("All measured nodes in the open graph must have an assigned angle label.") + + if total_order is None: + total_order = list(reversed(list(nx.topological_sort(self.extract_dag())))) + elif not self.is_compatible(total_order): + raise ValueError("The input total order is not compatible with the partial order induced by the correction sets.") + + pattern = Pattern(input_nodes=self.og.input_nodes) + non_input_nodes = set(self.og.graph.nodes) - set(self.og.input_nodes) + + for i in non_input_nodes: + pattern.add(N(node=i)) + for e in self.og.graph.edges: + pattern.add(E(nodes=e)) + + for node in total_order: + if node in self.og.output_nodes: + break + + # TODO: the following block is hideous. + # Refactor Plane and Axis ? + # Abstract class Plane, Plane.XY, .XZ, .YZ subclasses ? + # Axis X subclass of Plane.XY, Plane.XZ, etc. ? + # Method Axis, Sign -> Plane, angle + + meas_label = self.og.measurements[node] + angle_label = angles[node] + + if isinstance(meas_label, Plane): + assert not isinstance(angle_label, Sign) + pattern.add(M(node=node, plane=meas_label, angle=angle_label)) + else: + assert isinstance(angle_label, Sign) + if meas_label == Axis.X: + plane = Plane.XY + angle = 0 if angle_label is Sign.PLUS else 1 + elif meas_label == Axis.Y: + plane = Plane.XY + angle = 0.5 if angle_label is Sign.PLUS else 1.5 + elif meas_label == Axis.Z: + plane = Plane.XZ + angle = 0 if angle_label is Sign.PLUS else 1 + + pattern.add(M(node=node, plane=plane, angle=angle)) + + if node in self.z_corrections: + pattern.add(Z(node=node, domain=self.z_corrections[node])) + if node in self.x_corrections: + pattern.add(X(node=node, domain=self.x_corrections[node])) + + pattern.reorder_output_nodes(self.og.output_nodes) + return pattern @dataclass(frozen=True) class PauliFlow(Generic[_M]): og: OpenGraph[_M] correction_function: Mapping[int, set[int]] - partial_order_layers: list[set[int]] + partial_order_layers: Sequence[AbstractSet[int]] # TODO: Add parametric dependence of AlgebraicOpenGraph @classmethod @@ -179,6 +249,12 @@ def to_corrections(self) -> Corrections[Plane]: @dataclass(frozen=True) class CausalFlow(GFlow): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane + + @override + @staticmethod + def from_correction_matrix() -> None: + raise NotImplementedError("Initialization of a causal flow from a correction matrix is not supported.") + @override def to_corrections(self) -> Corrections[Plane]: r"""Compute the X and Z corrections induced by the causal flow encoded in `self`. diff --git a/graphix/flow/utils.py b/graphix/flow/utils.py deleted file mode 100644 index f99f8370f..000000000 --- a/graphix/flow/utils.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Module for flow utils.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import networkx as nx - -if TYPE_CHECKING: - from collections.abc import Set as AbstractSet - - import networkx as nx - - -def find_odd_neighbor(graph: nx.Graph[int], vertices: AbstractSet[int]) -> set[int]: - """Return the odd neighborhood of a set of nodes. - - Parameters - ---------- - graph : networkx.Graph - Underlying graph. - vertices : set - Set of nodes of which to find the odd neighborhood. - - Returns - ------- - odd_neighbors : set - Set of indices for odd neighbor of set `vertices`. - """ - odd_neighbors: set[int] = set() - for vertex in vertices: - neighbors = set(graph.neighbors(vertex)) - odd_neighbors ^= neighbors - return odd_neighbors diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index 7914fb767..88156fefb 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -7,6 +7,7 @@ import networkx as nx +from graphix.flow._find_cflow import find_cflow from graphix.flow._find_pflow import compute_correction_matrix from graphix.flow.flow import CausalFlow, PauliFlow from graphix.fundamentals import Axis, Plane @@ -24,10 +25,6 @@ _M = TypeVar("_M", bound=Plane | Axis) -# Add methods ? -# neighbors(node) -> calls self.graph.neighbors(node) -# odd_neighbors -> custom method - @dataclass(frozen=True) class OpenGraph(Generic[_M]): @@ -101,7 +98,7 @@ def __post_init__(self) -> None: # ) @staticmethod - def from_pattern(pattern: Pattern) -> OpenGraph[MeasurementOrPauliMeasurement]: + def from_pattern(pattern: Pattern) -> OpenGraph[_M]: """Initialise an `OpenGraph` object based on the resource-state graph associated with the measurement pattern.""" graph = pattern.extract_graph() @@ -243,12 +240,17 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: odd_neighbors_set ^= self.neighbors([node]) return odd_neighbors_set + # TODO: how to define this method just for OpenGraphs[Plane] ? def compute_casual_flow(self) -> CausalFlow | None: - return None + causal_flow = find_cflow(self) + if causal_flow is None: + return None + + return CausalFlow(self, *causal_flow) def compute_pauli_flow(self) -> PauliFlow | None: - aog_correction_matrix = compute_correction_matrix(self) - if aog_correction_matrix is None: + aog_and_correction_matrix = compute_correction_matrix(self) + if aog_and_correction_matrix is None: return None - return PauliFlow.from_correction_matrix(*aog_correction_matrix) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. + return PauliFlow.from_correction_matrix(*aog_and_correction_matrix) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. From f2653f1617d7a2b3adfca7dad82a38b4386d3898 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 16 Oct 2025 23:34:00 +0200 Subject: [PATCH 10/56] wip --- graphix/flow/_find_cflow.py | 18 ++++-- graphix/flow/_find_pflow.py | 117 ++++++++++++++++++++++++------------ graphix/flow/flow.py | 109 ++++++++++++++++++++++----------- graphix/opengraph_.py | 39 +++++++----- 4 files changed, 190 insertions(+), 93 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 1f8ced9bc..8d7f008a3 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from graphix.flow.flow import CausalFlow from graphix.fundamentals import Plane if TYPE_CHECKING: @@ -9,8 +10,10 @@ from graphix.opengraph_ import OpenGraph +# TODO: Up doc strings -def find_cflow(og: OpenGraph[Plane]) -> tuple[dict[int, set[int]], list[set[int]]] | None: + +def find_cflow(og: OpenGraph[Plane]) -> CausalFlow | None: """Return the causal flow of the input open graph if it exists. Parameters @@ -46,7 +49,14 @@ def find_cflow(og: OpenGraph[Plane]) -> tuple[dict[int, set[int]], list[set[int] return _flow_aux(og, non_input_nodes, corrected_nodes, corrector_candidates, cf, layers) -def _flow_aux(og: OpenGraph[Plane], non_input_nodes: AbstractSet[int], corrected_nodes: AbstractSet[int], corrector_candidates: AbstractSet[int], cf: dict[int, set[int]], layers: list[set[int]]) -> tuple[dict[int, set[int]], list[set[int]]] | None: +def _flow_aux( + og: OpenGraph[Plane], + non_input_nodes: AbstractSet[int], + corrected_nodes: AbstractSet[int], + corrector_candidates: AbstractSet[int], + cf: dict[int, set[int]], + layers: list[set[int]], +) -> CausalFlow | None: """Find one layer of the causal flow. Parameters @@ -84,7 +94,7 @@ def _flow_aux(og: OpenGraph[Plane], non_input_nodes: AbstractSet[int], corrected for p in corrector_candidates: non_corrected_neighbors = og.neighbors({p}) & non_corrected_nodes if len(non_corrected_neighbors) == 1: - q, = non_corrected_neighbors + (q,) = non_corrected_neighbors cf[q] = {p} curr_layer.add(p) corrected_nodes_new |= {q} @@ -95,7 +105,7 @@ def _flow_aux(og: OpenGraph[Plane], non_input_nodes: AbstractSet[int], corrected if len(corrected_nodes_new) == 0: # TODO: This is the structure in the original graphix code. I think that we could check if non_corrected_nodes == empty before the loop and here just return None. if corrected_nodes == og.graph.nodes: - return cf, layers + return CausalFlow(og, cf, layers) return None corrected_nodes |= corrected_nodes_new diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_pflow.py index c54eeb844..8a97cb27f 100644 --- a/graphix/flow/_find_pflow.py +++ b/graphix/flow/_find_pflow.py @@ -13,7 +13,7 @@ from copy import deepcopy from functools import cached_property -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple import numpy as np @@ -23,6 +23,7 @@ from graphix.sim.base_backend import NodeIndex if TYPE_CHECKING: + from collections.abc import Mapping from collections.abc import Set as AbstractSet from graphix.opengraph import OpenGraph @@ -163,6 +164,75 @@ def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: return flow_demand_matrix, order_demand_matrix +class CorrectionMatrix(NamedTuple): + r"""A dataclass to bundle the correction matrix and the open graph to which it refers. + + Attributes + ---------- + aog (AgebraicOpenGraph) : Open graph in an algebraic representation. + c_matrix (MatGF2) : Matrix encoding the correction function of a Pauli (or generalised) flow, :math:`C`. + + Notes + ----- + The correction matrix :math:`C` is an :math:`(n - n_I) \times (n - n_O)` matrix related to the correction function :math:`c(v) = \{u \in I^c|C_{u,v} = 1\}`, where :math:`I^c` are the non-input nodes of `aog`. In other words, the column :math:`v` of :math:`C` encodes the correction set of :math:`v`, :math:`c(v)`. + + See Definition 3.6 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + + aog: AlgebraicOpenGraph + c_matrix: MatGF2 + + @staticmethod + def from_correction_function(og: OpenGraph, correction_function: Mapping[int, set[int]]) -> CorrectionMatrix: + r"""Initialise a `CorrectionMatrix` object from a correction function. + + Parameters + ---------- + og : OpenGraph + The open graph relative to which the correction function is defined. + correction_function : dict[int, set[int]] + Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. + + Returns + ------- + c_matrix : MatGF2 + Matrix encoding the correction function. + + Notes + ----- + This function is not required to find a Pauli (or generalised) flow on an open graph but is a useful auxiliary method to verify the validity of a flow encoded in a correction function. + """ + aog = AlgebraicOpenGraph(og) + row_tags = aog.non_inputs + col_tags = aog.non_outputs + + c_matrix = MatGF2(np.zeros((len(row_tags), len(col_tags)), dtype=np.uint8)) + + for node, correction_set in correction_function.items(): + col = col_tags.index(node) + for corrector in correction_set: + row = row_tags.index(corrector) + c_matrix[row, col] = 1 + return CorrectionMatrix(aog, c_matrix) + + def to_correction_function(self) -> dict[int, set[int]]: + r"""Transform the correction matrix into a correction function. + + Returns + ------- + correction_function : dict[int, set[int]] + Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. + """ + row_tags = self.aog.non_inputs + col_tags = self.aog.non_outputs + correction_function: dict[int, set[int]] = {} + for node in col_tags: + i = col_tags.index(node) + correction_set = {row_tags[j] for j in np.flatnonzero(self.c_matrix[:, i])} + correction_function[node] = correction_set + return correction_function + + def _compute_p_matrix(aog: AlgebraicOpenGraph, nb_matrix: MatGF2) -> MatGF2 | None: r"""Perform the steps 8 - 12 of the general case (larger number of outputs than inputs) algorithm. @@ -359,7 +429,9 @@ def reorder(old_pos: int, new_pos: int) -> None: # Used in step 12.d.vi ] # `[:]` is crucial to modify the data pointed by `kls_matrix`. -def _compute_correction_matrix_general_case(aog: AlgebraicOpenGraph, flow_demand_matrix: MatGF2, order_demand_matrix: MatGF2) -> MatGF2 | None: +def _compute_correction_matrix_general_case( + aog: AlgebraicOpenGraph, flow_demand_matrix: MatGF2, order_demand_matrix: MatGF2 +) -> MatGF2 | None: r"""Construct the generalized correction matrix :math:`C'C^B` for an open graph with larger number of outputs than inputs. Parameters @@ -480,39 +552,7 @@ def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] return generations -def compute_correction_function(aog: AlgebraicOpenGraph, correction_matrix: MatGF2) -> dict[int, set[int]]: - r"""Transform the correction matrix into a correction function. - - Parameters - ---------- - aog : AlgebraicOpenGraph - Open graph whose Pauli flow is calculated. - correction_matrix : MatGF2 - Matrix encoding the correction function. - - Returns - ------- - correction_function : dict[int, set[int]] - Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. - - - Notes - ----- - - The correction matrix :math:`C` is an :math:`(n - n_I) \times (n - n_O)` matrix related to the correction function :math:`c(v) = \{u \in I^c|C_{u,v} = 1\}`, where :math:`I^c` are the non-input nodes of `aog`. In other words, the column :math:`v` of :math:`C` encodes the correction set of :math:`v`, :math:`c(v)`. - - See Definition 3.6 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - row_tags = aog.non_inputs - col_tags = aog.non_outputs - correction_function: dict[int, set[int]] = {} - for node in col_tags: - i = col_tags.index(node) - correction_set = {row_tags[j] for j in np.flatnonzero(correction_matrix[:, i])} - correction_function[node] = correction_set - return correction_function - - -def compute_partial_order_layers(aog: AlgebraicOpenGraph, correction_matrix: MatGF2) -> list[set[int]] | None: +def compute_partial_order_layers(correction_matrix: CorrectionMatrix) -> list[set[int]] | None: r"""Compute the partial order compatible with the correction matrix if it exists. Parameters @@ -538,7 +578,8 @@ def compute_partial_order_layers(aog: AlgebraicOpenGraph, correction_matrix: Mat See Lemma 3.12, and Theorem 3.1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - ordering_matrix = aog.order_demand_matrix.mat_mul(correction_matrix) + aog, c_matrix = correction_matrix + ordering_matrix = aog.order_demand_matrix.mat_mul(c_matrix) if (topo_gen := _compute_topological_generations(ordering_matrix)) is None: return None # The NC matrix is not a DAG, therefore there's no flow. @@ -553,7 +594,7 @@ def compute_partial_order_layers(aog: AlgebraicOpenGraph, correction_matrix: Mat return layers -def compute_correction_matrix(og: OpenGraph) -> tuple[AlgebraicOpenGraph, MatGF2] | None: +def compute_correction_matrix(og: OpenGraph) -> CorrectionMatrix | None: """Return the correction matrix of the input open graph if it exists. Parameters @@ -597,4 +638,4 @@ def compute_correction_matrix(og: OpenGraph) -> tuple[AlgebraicOpenGraph, MatGF2 if correction_matrix is None: return None - return aog, correction_matrix + return CorrectionMatrix(aog, correction_matrix) diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index 938228e7a..78a9eb022 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -10,12 +10,12 @@ from typing import TYPE_CHECKING, Generic, Self, override import networkx as nx +import numpy as np from graphix.command import E, M, N, X, Z -from graphix.flow._find_pflow import compute_correction_function, compute_partial_order_layers +from graphix.flow._find_pflow import CorrectionMatrix, compute_partial_order_layers from graphix.fundamentals import Axis, Plane, Sign from graphix.graphix._linalg import MatGF2 -from graphix.graphix.flow._find_pflow import AlgebraicOpenGraph from graphix.opengraph_ import _M, OpenGraph from graphix.pattern import Pattern @@ -24,11 +24,8 @@ from collections.abc import Set as AbstractSet from typing import Self - import numpy as np import numpy.typing as npt - from graphix._linalg import MatGF2 - from graphix.flow._find_pflow import AlgebraicOpenGraph from graphix.measurements import ExpressionOrFloat TotalOrder = Sequence[int] @@ -37,8 +34,8 @@ @dataclass(frozen=True) class Corrections(Generic[_M]): og: OpenGraph[_M] - x_corrections: dict[int, set[int]] # {node: domain} - z_corrections: dict[int, set[int]] # {node: domain} + x_corrections: dict[int, set[int]] # {node: domain} + z_corrections: dict[int, set[int]] # {node: domain} def extract_dag(self) -> nx.DiGraph[int]: """Extract directed graph induced by the corrections. @@ -63,14 +60,9 @@ def extract_dag(self) -> nx.DiGraph[int]: return nx.DiGraph(relations) - def is_wellformed(self, verbose: bool = True) -> bool: + def is_wellformed(self) -> bool: """Verify if `Corrections` object is well formed. - Parameters - ---------- - verbose : bool - Optional flag that indicates the source of the issue when `self` is malformed. Defaults to `True`. - Returns ------- bool @@ -83,20 +75,19 @@ def is_wellformed(self, verbose: bool = True) -> bool: - Nodes in domain set are measured. - Corrections are runnable. This amounts to verifying that the corrections-induced directed graph does not have loops. """ - for corr_type in ['X', 'Z']: + for corr_type in ["X", "Z"]: corrections = getattr(self, f"{corr_type.lower()}_corrections") for node, domain in corrections.items(): if node not in self.og.graph.nodes: - if verbose: - print(f"Cannot apply {corr_type} correction. Corrected node {node} does not belong to the open graph.") + print( + f"Cannot apply {corr_type} correction. Corrected node {node} does not belong to the open graph." + ) return False if not domain.issubset(self.og.measurements): - if verbose: - print(f"Cannot apply {corr_type} correction. Domain nodes {domain} are not measured.") + print(f"Cannot apply {corr_type} correction. Domain nodes {domain} are not measured.") return False if nx.is_directed_acyclic_graph(self.extract_dag()): - if verbose: - print("Corrections are not runnable since the induced directed graph contains cycles.") + print("Corrections are not runnable since the induced directed graph contains cycles.") return False return True @@ -106,8 +97,9 @@ def is_compatible(self, total_order: TotalOrder) -> bool: # Verify nodes are in open graph return True - def to_pattern(self, angles: Mapping[int, ExpressionOrFloat | Sign], total_order: TotalOrder | None = None) -> Pattern: - + def to_pattern( + self, angles: Mapping[int, ExpressionOrFloat | Sign], total_order: TotalOrder | None = None + ) -> Pattern: # TODO: Should we verify thar corrections are well formed ? If we did so, and the total order is inferred from the corrections, we are doing a topological sort twice # TODO: Do we want to raise an error or just a warning and assign 0 by default ? @@ -117,7 +109,9 @@ def to_pattern(self, angles: Mapping[int, ExpressionOrFloat | Sign], total_order if total_order is None: total_order = list(reversed(list(nx.topological_sort(self.extract_dag())))) elif not self.is_compatible(total_order): - raise ValueError("The input total order is not compatible with the partial order induced by the correction sets.") + raise ValueError( + "The input total order is not compatible with the partial order induced by the correction sets." + ) pattern = Pattern(input_nodes=self.og.input_nodes) non_input_nodes = set(self.og.graph.nodes) - set(self.og.input_nodes) @@ -174,13 +168,13 @@ class PauliFlow(Generic[_M]): # TODO: Add parametric dependence of AlgebraicOpenGraph @classmethod - def from_correction_matrix(cls, aog: AlgebraicOpenGraph, correction_matrix: MatGF2) -> Self | None: - correction_function = compute_correction_function(aog, correction_matrix) - partial_order_layers = compute_partial_order_layers(aog, correction_matrix) + def from_correction_matrix(cls, correction_matrix: CorrectionMatrix) -> Self | None: + correction_function = correction_matrix.to_correction_function() + partial_order_layers = compute_partial_order_layers(correction_matrix) if partial_order_layers is None: return None - return cls(aog.og, correction_function, partial_order_layers) + return cls(correction_matrix.aog.og, correction_function, partial_order_layers) def to_corrections(self) -> Corrections[_M]: """Compute the X and Z corrections induced by the Pauli flow encoded in `self`. @@ -193,7 +187,7 @@ def to_corrections(self) -> Corrections[_M]: ----- This function partially implements Theorem 4 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. """ - future: set[int] = self.partial_order_layers[0] + future = self.partial_order_layers[0] x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} @@ -207,8 +201,53 @@ def to_corrections(self) -> Corrections[_M]: return Corrections(self.og, x_corrections, z_corrections) - # TODO - # def is_well_formed(self) -> bool: + def is_well_formed(self) -> bool: + r"""Verify if flow object is well formed. + + Returns + ------- + bool + `True` if `self` is well formed, `False` otherwise. + + Notes + ----- + This method verifies that: + - The correction function's domain and codomain respectively are non-output and non-input nodes. + - The product of the flow-demand and the correction matrices is the identity matrix, :math:`MC = \mathbb{1}`. + - The product of the order-demand and the correction matrices is the adjacency matrix of a DAG compatible with `self.partial_order_layers`. + """ + domain = set(self.correction_function) + if not domain.intersection(self.og.output_nodes): + print("Invalid flow. Domain of the correction function includes output nodes.") + return False + + codomain = set().union(*self.correction_function.values()) + if not codomain.intersection(self.og.input_nodes): + print("Invalid flow. Codomain of the correction function includes input nodes.") + return False + + correction_matrix = CorrectionMatrix.from_correction_function(self.og, self.correction_function) + + aog, c_matrix = correction_matrix + + identity = MatGF2(np.eye(len(aog.non_outputs), dtype=np.uint8)) + mc_matrix = aog.flow_demand_matrix.mat_mul(c_matrix) + if not np.all(mc_matrix == identity): + print( + "Invalid flow. The product of the flow-demand and the correction matrices is not the identity matrix, MC ≠ 1" + ) + return False + + partial_order_layers = compute_partial_order_layers(correction_matrix) + if partial_order_layers is None: + print( + "Invalid flow. The correction function is not compatible with a partial order on the open graph. The product of the order-demand and the correction matrices NC does not form a DAG." + ) + return False + + # TODO: Verify that self.partial_order_layers is compatible with partial_order_layers + + return True # TODO: for compatibility with previous encoding of layers. # def node_layer_mapping(self) -> dict[int, int]: @@ -222,7 +261,6 @@ def to_corrections(self) -> Corrections[_M]: @dataclass(frozen=True) class GFlow(PauliFlow[Plane]): - @override def to_corrections(self) -> Corrections[Plane]: r"""Compute the X and Z corrections induced by the generalised flow encoded in `self`. @@ -249,7 +287,6 @@ def to_corrections(self) -> Corrections[Plane]: @dataclass(frozen=True) class CausalFlow(GFlow): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane - @override @staticmethod def from_correction_matrix() -> None: @@ -277,6 +314,11 @@ def to_corrections(self) -> Corrections[Plane]: return Corrections(self.og, x_corrections, z_corrections) +########### +# OLD functions +########### + + @dataclass(frozen=True) class PartialOrder: """Class for storing and manipulating the partial order in a flow. @@ -446,9 +488,6 @@ def is_compatible(self, other: PartialOrder) -> bool: return self.transitive_closure.issubset(other.transitive_closure) -########### -# OLD functions - def _compute_layers_from_dag(dag: nx.DiGraph[int]) -> dict[int, set[int]]: try: generations = reversed(list(nx.topological_generations(dag))) diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index 88156fefb..2caadd1bb 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -3,13 +3,14 @@ from __future__ import annotations from dataclasses import dataclass +from functools import singledispatchmethod from typing import TYPE_CHECKING, Generic, TypeVar import networkx as nx from graphix.flow._find_cflow import find_cflow from graphix.flow._find_pflow import compute_correction_matrix -from graphix.flow.flow import CausalFlow, PauliFlow +from graphix.flow.flow import CausalFlow, GFlow, PauliFlow from graphix.fundamentals import Axis, Plane from graphix.measurements import Measurement @@ -130,9 +131,7 @@ def from_pattern(pattern: Pattern) -> OpenGraph[_M]: # return graphix.generator.generate_from_graph(g, angles, input_nodes, output_nodes, planes) - def compose( - self, other: OpenGraph[_M], mapping: Mapping[int, int] - ) -> tuple[OpenGraph[_M], dict[int, int]]: + def compose(self, other: OpenGraph[_M], mapping: Mapping[int, int]) -> tuple[OpenGraph[_M], dict[int, int]]: r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. Parameters @@ -240,17 +239,25 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: odd_neighbors_set ^= self.neighbors([node]) return odd_neighbors_set - # TODO: how to define this method just for OpenGraphs[Plane] ? - def compute_casual_flow(self) -> CausalFlow | None: - causal_flow = find_cflow(self) - if causal_flow is None: - return None - - return CausalFlow(self, *causal_flow) + def compute_causal_flow(self: OpenGraph[Plane]) -> CausalFlow | None: + return find_cflow(self) - def compute_pauli_flow(self) -> PauliFlow | None: - - aog_and_correction_matrix = compute_correction_matrix(self) - if aog_and_correction_matrix is None: + @singledispatchmethod + def compute_maximally_delayed_flow(self) -> PauliFlow[_M] | None: + correction_matrix = compute_correction_matrix(self) + if correction_matrix is None: + return None + return PauliFlow.from_correction_matrix( + correction_matrix + ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. + + # TODO: this function could return a GFlow that is also a CausalFlow. Should we deal with this ? + # TODO: maybe @override for mypy + # Can we do singledispatch on self ? + # This doesn-t work because at run time we don-t now about the parametrized-type of OpenGraph + @compute_maximally_delayed_flow.register + def _(self: OpenGraph[Plane]) -> GFlow | None: + correction_matrix = compute_correction_matrix(self) + if correction_matrix is None: return None - return PauliFlow.from_correction_matrix(*aog_and_correction_matrix) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. + return GFlow.from_correction_matrix(correction_matrix) From b92f76b33f57d3cab5edf8b4a0bfde52edc36647 Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 17 Oct 2025 16:10:50 +0200 Subject: [PATCH 11/56] Introduce measurement abc --- graphix/flow/_find_cflow.py | 12 +-- graphix/flow/_find_pflow.py | 125 +++++++++++++++++++--------- graphix/flow/flow.py | 8 +- graphix/fundamentals.py | 19 ++++- graphix/measurements.py | 23 ++++- graphix/opengraph_.py | 162 +++++++++++++++++------------------- 6 files changed, 211 insertions(+), 138 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 8d7f008a3..6aa99d803 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -3,17 +3,18 @@ from typing import TYPE_CHECKING from graphix.flow.flow import CausalFlow -from graphix.fundamentals import Plane +from graphix.measurements import Plane if TYPE_CHECKING: from collections.abc import Set as AbstractSet + from graphix.measurements import AbstractPlanarMeasurement from graphix.opengraph_ import OpenGraph # TODO: Up doc strings -def find_cflow(og: OpenGraph[Plane]) -> CausalFlow | None: +def find_cflow(og: OpenGraph[AbstractPlanarMeasurement]) -> CausalFlow | None: """Return the causal flow of the input open graph if it exists. Parameters @@ -35,8 +36,9 @@ def find_cflow(og: OpenGraph[Plane]) -> CausalFlow | None: ----- See Definition 2, Theorem 1 and Algorithm 1 in Mhalla and Perdrix, Finding Optimal Flows Efficiently, 2008 (arXiv:0709.2670). """ - if {Plane.XZ, Plane.YZ}.intersection(og.measurements.values()): - return None + for measurement in og.measurements.values(): + if measurement.to_plane() in {Plane.XZ, Plane.YZ}: + return None corrected_nodes = set(og.output_nodes) corrector_candidates = corrected_nodes - set(og.input_nodes) @@ -50,7 +52,7 @@ def find_cflow(og: OpenGraph[Plane]) -> CausalFlow | None: def _flow_aux( - og: OpenGraph[Plane], + og: OpenGraph[AbstractPlanarMeasurement], non_input_nodes: AbstractSet[int], corrected_nodes: AbstractSet[int], corrector_candidates: AbstractSet[int], diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_pflow.py index 8a97cb27f..7c2f494c7 100644 --- a/graphix/flow/_find_pflow.py +++ b/graphix/flow/_find_pflow.py @@ -11,25 +11,27 @@ from __future__ import annotations +from abc import ABC, abstractmethod from copy import deepcopy from functools import cached_property -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, Generic, NamedTuple import numpy as np from graphix._linalg import MatGF2, solve_f2_linear_system from graphix.fundamentals import Axis, Plane -from graphix.measurements import PauliMeasurement +from graphix.measurements import AbstractPlanarMeasurement +from graphix.opengraph_ import _M from graphix.sim.base_backend import NodeIndex if TYPE_CHECKING: from collections.abc import Mapping from collections.abc import Set as AbstractSet - from graphix.opengraph import OpenGraph + from graphix.opengraph_ import OpenGraph -class AlgebraicOpenGraph: +class AlgebraicOpenGraph(ABC, Generic[_M]): """A class for providing an algebraic representation of open graphs as introduced in [1]. In particular, it allows managing the mapping between node labels of the graph and the relevant matrix indices. The flow demand and order demand matrices appear as cached properties. It reuses the class `:class: graphix.sim.base_backend.NodeIndex` introduced for managing the mapping between node numbers and qubit indices in the internal state of the backend. @@ -50,14 +52,14 @@ class AlgebraicOpenGraph: [1] Mitosek and Backens, 2024 (arXiv:2410.23439). """ - def __init__(self, og: OpenGraph) -> None: + def __init__(self, og: OpenGraph[_M]) -> None: self.og = og - nodes = set(og.inside.nodes) + nodes = set(og.graph.nodes) # Nodes don't need to be sorted. We do it for debugging purposes, so we can check the matrices in intermediate steps of the algorithm. - nodes_non_input = sorted(nodes - set(og.inputs)) - nodes_non_output = sorted(nodes - set(og.outputs)) + nodes_non_input = sorted(nodes - set(og.input_nodes)) + nodes_non_output = sorted(nodes - set(og.output_nodes)) self.non_inputs = NodeIndex() self.non_inputs.extend(nodes_non_input) @@ -97,7 +99,7 @@ def _compute_reduced_adj(self) -> MatGF2: See Definition 3.3 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - graph = self.og.inside + graph = self.og.graph row_tags = self.non_outputs col_tags = self.non_inputs @@ -112,13 +114,25 @@ def _compute_reduced_adj(self) -> MatGF2: return adj_red @cached_property + @abstractmethod def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: r"""Construct flow-demand and order-demand matrices. - Parameters - ---------- - aog : AlgebraicOpenGraph - Open graph whose flow-demand and order-demand matrices are computed. + Returns + ------- + flow_demand_matrix : MatGF2 + order_demand_matrix : MatGF2 + + Notes + ----- + See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + + +class AlgebraicOpenGraphGFlow(AlgebraicOpenGraph[AbstractPlanarMeasurement]): + @cached_property + def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: + r"""Construct flow-demand and order-demand matrices assuming that the underlying open graph has planar measurements only. Returns ------- @@ -132,23 +146,54 @@ def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: flow_demand_matrix = self._compute_reduced_adj() order_demand_matrix = flow_demand_matrix.copy() - inputs_set = set(self.og.inputs) - meas = self.og.measurements + inputs_set = set(self.og.input_nodes) row_tags = self.non_outputs col_tags = self.non_inputs - # TODO: integrate pauli measurements in open graphs - meas_planes = {i: m.plane for i, m in meas.items()} - meas_angles = {i: m.angle for i, m in meas.items()} - meas_plane_axis = { - node: pm.axis if (pm := PauliMeasurement.try_from(plane, meas_angles[node])) else plane - for node, plane in meas_planes.items() - } + for v in row_tags: # v is a node tag + i = row_tags.index(v) + plane_v = self.og.measurements[v].to_plane() + + if plane_v in {Plane.YZ, Plane.XZ}: + flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 + if v not in inputs_set: + j = col_tags.index(v) + flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 + if plane_v is Plane.XY: + order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 + if plane_v in {Plane.XY, Plane.XZ} and v not in inputs_set: + j = col_tags.index(v) + order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 + + return flow_demand_matrix, order_demand_matrix + + +class AlgebraicOpenGraphPauliFlow(AlgebraicOpenGraph[_M]): + @cached_property + def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: + r"""Construct flow-demand and order-demand matrices. + + Returns + ------- + flow_demand_matrix : MatGF2 + order_demand_matrix : MatGF2 + + Notes + ----- + See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + flow_demand_matrix = self._compute_reduced_adj() + order_demand_matrix = flow_demand_matrix.copy() + + inputs_set = set(self.og.input_nodes) + + row_tags = self.non_outputs + col_tags = self.non_inputs for v in row_tags: # v is a node tag i = row_tags.index(v) - plane_axis_v = meas_plane_axis[v] + plane_axis_v = self.og.measurements[v].to_plane_or_axis() if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 @@ -164,7 +209,7 @@ def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: return flow_demand_matrix, order_demand_matrix -class CorrectionMatrix(NamedTuple): +class CorrectionMatrix(NamedTuple, Generic[_M]): r"""A dataclass to bundle the correction matrix and the open graph to which it refers. Attributes @@ -179,11 +224,11 @@ class CorrectionMatrix(NamedTuple): See Definition 3.6 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - aog: AlgebraicOpenGraph + aog: AlgebraicOpenGraph[_M] c_matrix: MatGF2 @staticmethod - def from_correction_function(og: OpenGraph, correction_function: Mapping[int, set[int]]) -> CorrectionMatrix: + def from_correction_function(og: OpenGraph[_M], correction_function: Mapping[int, set[int]]) -> CorrectionMatrix[_M]: r"""Initialise a `CorrectionMatrix` object from a correction function. Parameters @@ -202,7 +247,7 @@ def from_correction_function(og: OpenGraph, correction_function: Mapping[int, se ----- This function is not required to find a Pauli (or generalised) flow on an open graph but is a useful auxiliary method to verify the validity of a flow encoded in a correction function. """ - aog = AlgebraicOpenGraph(og) + aog = AlgebraicOpenGraphPauliFlow(og) # TODO: Is it a problem that we instatiate an AOGPauliFlow, regardless of the type of og ? row_tags = aog.non_inputs col_tags = aog.non_outputs @@ -233,7 +278,7 @@ def to_correction_function(self) -> dict[int, set[int]]: return correction_function -def _compute_p_matrix(aog: AlgebraicOpenGraph, nb_matrix: MatGF2) -> MatGF2 | None: +def _compute_p_matrix(aog: AlgebraicOpenGraph[_M], nb_matrix: MatGF2) -> MatGF2 | None: r"""Perform the steps 8 - 12 of the general case (larger number of outputs than inputs) algorithm. Parameters @@ -256,7 +301,7 @@ def _compute_p_matrix(aog: AlgebraicOpenGraph, nb_matrix: MatGF2) -> MatGF2 | No See Theorem 4.4, steps 8 - 12 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ n_no = len(aog.non_outputs) # number of columns of P matrix. - n_oi_diff = len(aog.og.outputs) - len(aog.og.inputs) # number of rows of P matrix. + n_oi_diff = len(aog.og.output_nodes) - len(aog.og.input_nodes) # number of rows of P matrix. n_no_optim = len(aog.non_outputs_optim) # number of rows and columns of the third block of the K_{LS} matrix. # Steps 8, 9 and 10 @@ -284,7 +329,7 @@ def _compute_p_matrix(aog: AlgebraicOpenGraph, nb_matrix: MatGF2) -> MatGF2 | No def _find_solvable_nodes( - aog: AlgebraicOpenGraph, + aog: AlgebraicOpenGraph[_M], kls_matrix: MatGF2, non_outputs_set: AbstractSet[int], solved_nodes: AbstractSet[int], @@ -316,7 +361,7 @@ def _find_solvable_nodes( def _update_p_matrix( - aog: AlgebraicOpenGraph, kls_matrix: MatGF2, p_matrix: MatGF2, solvable_nodes: AbstractSet[int], n_oi_diff: int + aog: AlgebraicOpenGraph[_M], kls_matrix: MatGF2, p_matrix: MatGF2, solvable_nodes: AbstractSet[int], n_oi_diff: int ) -> None: """Update `p_matrix`. @@ -334,7 +379,7 @@ def _update_p_matrix( def _update_kls_matrix( - aog: AlgebraicOpenGraph, + aog: AlgebraicOpenGraph[_M], kls_matrix: MatGF2, kils_matrix: MatGF2, solvable_nodes: AbstractSet[int], @@ -430,7 +475,7 @@ def reorder(old_pos: int, new_pos: int) -> None: # Used in step 12.d.vi def _compute_correction_matrix_general_case( - aog: AlgebraicOpenGraph, flow_demand_matrix: MatGF2, order_demand_matrix: MatGF2 + aog: AlgebraicOpenGraph[_M], flow_demand_matrix: MatGF2, order_demand_matrix: MatGF2 ) -> MatGF2 | None: r"""Construct the generalized correction matrix :math:`C'C^B` for an open graph with larger number of outputs than inputs. @@ -462,7 +507,7 @@ def _compute_correction_matrix_general_case( See Theorem 4.4 and Algorithm 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ n_no = len(aog.non_outputs) - n_oi_diff = len(aog.og.outputs) - len(aog.og.inputs) + n_oi_diff = len(aog.og.output_nodes) - len(aog.og.input_nodes) # Steps 3 and 4 correction_matrix_0 = flow_demand_matrix.right_inverse() # C0 matrix. @@ -552,7 +597,7 @@ def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] return generations -def compute_partial_order_layers(correction_matrix: CorrectionMatrix) -> list[set[int]] | None: +def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M]) -> list[set[int]] | None: r"""Compute the partial order compatible with the correction matrix if it exists. Parameters @@ -584,7 +629,7 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix) -> list[se if (topo_gen := _compute_topological_generations(ordering_matrix)) is None: return None # The NC matrix is not a DAG, therefore there's no flow. - layers = [set(aog.og.outputs)] # Output nodes are always in layer 0. + layers = [set(aog.og.output_nodes)] # Output nodes are always in layer 0. # If m >_c n, with >_c the flow partial order for two nodes m, n, then layer(n) > layer(m). # Therefore, we iterate the topological sort of the graph in _reverse_ order to obtain the order of measurements. @@ -594,7 +639,7 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix) -> list[se return layers -def compute_correction_matrix(og: OpenGraph) -> CorrectionMatrix | None: +def compute_correction_matrix(aog: AlgebraicOpenGraph[_M]) -> CorrectionMatrix[_M] | None: """Return the correction matrix of the input open graph if it exists. Parameters @@ -618,14 +663,12 @@ def compute_correction_matrix(og: OpenGraph) -> CorrectionMatrix | None: See Definitions 3.4, 3.5 and 3.6, Theorems 3.1, 4.2 and 4.4, and Algorithms 2 and 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - ni = len(og.inputs) - no = len(og.outputs) + ni = len(aog.og.input_nodes) + no = len(aog.og.output_nodes) if ni > no: return None - aog = AlgebraicOpenGraph(og) - # Steps 1 and 2 # Flow-demand and order-demand matrices are cached properties of `aog`. flow_demand_matrix, order_demand_matrix = aog._compute_pflow_matrices diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index 78a9eb022..c65612c2d 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -26,7 +26,7 @@ import numpy.typing as npt - from graphix.measurements import ExpressionOrFloat + from graphix.measurements import AbstractPlanarMeasurement, ExpressionOrFloat, Measurement TotalOrder = Sequence[int] @@ -98,7 +98,7 @@ def is_compatible(self, total_order: TotalOrder) -> bool: return True def to_pattern( - self, angles: Mapping[int, ExpressionOrFloat | Sign], total_order: TotalOrder | None = None + self: Corrections[Measurement], angles: Mapping[int, ExpressionOrFloat | Sign], total_order: TotalOrder | None = None ) -> Pattern: # TODO: Should we verify thar corrections are well formed ? If we did so, and the total order is inferred from the corrections, we are doing a topological sort twice @@ -260,9 +260,9 @@ def is_well_formed(self) -> bool: @dataclass(frozen=True) -class GFlow(PauliFlow[Plane]): +class GFlow(PauliFlow[AbstractPlanarMeasurement]): @override - def to_corrections(self) -> Corrections[Plane]: + def to_corrections(self) -> Corrections[AbstractPlanarMeasurement]: r"""Compute the X and Z corrections induced by the generalised flow encoded in `self`. Returns diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index 3f5de0103..5317a2eb6 100644 --- a/graphix/fundamentals.py +++ b/graphix/fundamentals.py @@ -6,10 +6,11 @@ import sys import typing from enum import Enum -from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat, SupportsIndex, overload +from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat, SupportsIndex, overload, override import typing_extensions +from graphix.measurements import AbstractMeasurement, AbstractPlanarMeasurement from graphix.ops import Ops from graphix.parameter import cos_sin from graphix.repr_mixins import EnumReprMixin @@ -214,7 +215,8 @@ def matrix(self) -> npt.NDArray[np.complex128]: typing_extensions.assert_never(self) -class Axis(EnumReprMixin, Enum): +# TODO Conflicts with Enum +class Axis(EnumReprMixin, Enum, AbstractMeasurement): """Axis: *X*, *Y* or *Z*.""" X = enum.auto() @@ -232,8 +234,12 @@ def matrix(self) -> npt.NDArray[np.complex128]: return Ops.Z typing_extensions.assert_never(self) + @override + def to_plane_or_axis(self) -> Axis: + return self -class Plane(EnumReprMixin, Enum): + +class Plane(EnumReprMixin, Enum, AbstractPlanarMeasurement): # TODO: Refactor using match """Plane: *XY*, *YZ* or *XZ*.""" @@ -317,3 +323,10 @@ def from_axes(a: Axis, b: Axis) -> Plane: return Plane.XZ assert a == b raise ValueError(f"Cannot make a plane giving the same axis {a} twice.") + + @override + def to_plane_or_axis(self) -> Plane: + return self + + def to_plane(self) -> Plane: + return self diff --git a/graphix/measurements.py b/graphix/measurements.py index fa382705e..4c6a17f7e 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -4,6 +4,7 @@ import dataclasses import math +from abc import ABC, abstractmethod from typing import Literal, NamedTuple, SupportsInt from typing_extensions import TypeAlias # TypeAlias introduced in Python 3.10 @@ -35,7 +36,19 @@ class Domains: t_domain: set[int] -class Measurement(NamedTuple): +class AbstractMeasurement(ABC): + @abstractmethod + def to_plane_or_axis(self) -> Plane | Axis: ... + + +class AbstractPlanarMeasurement(AbstractMeasurement): + @abstractmethod + def to_plane(self) -> Plane: ... + + +# TODO: Multiple inheritance with NamedTuple error +# Replace by dataclass +class Measurement(NamedTuple, AbstractPlanarMeasurement): """An MBQC measurement. :param angle: the angle of the measurement. Should be between [0, 2) @@ -65,6 +78,14 @@ def isclose(self, other: Measurement, rel_tol: float = 1e-09, abs_tol: float = 0 else self.angle == other.angle ) and self.plane == other.plane + def to_plane_or_axis(self) -> Plane | Axis: + if pm := PauliMeasurement.try_from(self.plane, self.angle): + return pm.axis + return self.plane + + def to_plane(self) -> Plane: + return self.plane + class PauliMeasurement(NamedTuple): """Pauli measurement.""" diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index 2caadd1bb..fd66ae03c 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -3,19 +3,17 @@ from __future__ import annotations from dataclasses import dataclass -from functools import singledispatchmethod from typing import TYPE_CHECKING, Generic, TypeVar import networkx as nx from graphix.flow._find_cflow import find_cflow -from graphix.flow._find_pflow import compute_correction_matrix +from graphix.flow._find_pflow import AlgebraicOpenGraphGFlow, AlgebraicOpenGraphPauliFlow, compute_correction_matrix from graphix.flow.flow import CausalFlow, GFlow, PauliFlow -from graphix.fundamentals import Axis, Plane -from graphix.measurements import Measurement +from graphix.measurements import AbstractMeasurement, AbstractPlanarMeasurement, Measurement if TYPE_CHECKING: - from collections.abc import Collection, Iterable, Mapping + from collections.abc import Collection, Mapping from graphix.pattern import Pattern @@ -24,7 +22,7 @@ # Otherwise, shall we define Plane.XY-only open graphs. # Maybe move these definitions to graphix.fundamentals and graphix.measurements ? -_M = TypeVar("_M", bound=Plane | Axis) +_M = TypeVar("_M", bound=AbstractMeasurement) @dataclass(frozen=True) @@ -58,8 +56,6 @@ class OpenGraph(Generic[_M]): input_nodes: list[int] # Inputs are ordered output_nodes: list[int] # Outputs are ordered - # TODO - # Should we transform measurements with angle n pi/2 into PauliMeasurements with PauliMeasurement.try_from? def __post_init__(self) -> None: """Validate the open graph.""" if not set(self.measurements).issubset(self.graph.nodes): @@ -99,7 +95,7 @@ def __post_init__(self) -> None: # ) @staticmethod - def from_pattern(pattern: Pattern) -> OpenGraph[_M]: + def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: """Initialise an `OpenGraph` object based on the resource-state graph associated with the measurement pattern.""" graph = pattern.extract_graph() @@ -131,77 +127,77 @@ def from_pattern(pattern: Pattern) -> OpenGraph[_M]: # return graphix.generator.generate_from_graph(g, angles, input_nodes, output_nodes, planes) - def compose(self, other: OpenGraph[_M], mapping: Mapping[int, int]) -> tuple[OpenGraph[_M], dict[int, int]]: - r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. - - Parameters - ---------- - other : OpenGraph - Open graph to be composed with `self`. - mapping: dict[int, int] - Partial relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node labels, respectively. - - Returns - ------- - og: OpenGraph - composed open graph - mapping_complete: dict[int, int] - Complete relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node label, respectively. - - Notes - ----- - Let's denote :math:`\{G(V_1, E_1), I_1, O_1\}` the open graph `self`, :math:`\{G(V_2, E_2), I_2, O_2\}` the open graph `other`, :math:`\{G(V, E), I, O\}` the resulting open graph `og` and `{v:u}` an element of `mapping`. - - We define :math:`V, U` the set of nodes in `mapping.keys()` and `mapping.values()`, and :math:`M = U \cap V_1` the set of merged nodes. - - The open graph composition requires that - - :math:`V \subseteq V_2`. - - If both `v` and `u` are measured, the corresponding measurements must have the same plane and angle. - The returned open graph follows this convention: - - :math:`I = (I_1 \cup I_2) \setminus M \cup (I_1 \cap I_2 \cap M)`, - - :math:`O = (O_1 \cup O_2) \setminus M \cup (O_1 \cap O_2 \cap M)`, - - If only one node of the pair `{v:u}` is measured, this measure is assigned to :math:`u \in V` in the resulting open graph. - - Input (and, respectively, output) nodes in the returned open graph have the order of the open graph `self` followed by those of the open graph `other`. Merged nodes are removed, except when they are input (or output) nodes in both open graphs, in which case, they appear in the order they originally had in the graph `self`. - """ - if not (mapping.keys() <= other.graph.nodes): - raise ValueError("Keys of mapping must be correspond to nodes of other.") - if len(mapping) != len(set(mapping.values())): - raise ValueError("Values in mapping contain duplicates.") - for v, u in mapping.items(): - if ( - (vm := other.measurements.get(v)) is not None - and (um := self.measurements.get(u)) is not None - and not vm.isclose(um) # TODO: How do we ensure that planes, axis, etc. are the same ? - ): - raise ValueError(f"Attempted to merge nodes {v}:{u} but have different measurements") + # def compose(self, other: OpenGraph[_M], mapping: Mapping[int, int]) -> tuple[OpenGraph[_M], dict[int, int]]: + # r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. + + # Parameters + # ---------- + # other : OpenGraph + # Open graph to be composed with `self`. + # mapping: dict[int, int] + # Partial relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node labels, respectively. + + # Returns + # ------- + # og: OpenGraph + # composed open graph + # mapping_complete: dict[int, int] + # Complete relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node label, respectively. + + # Notes + # ----- + # Let's denote :math:`\{G(V_1, E_1), I_1, O_1\}` the open graph `self`, :math:`\{G(V_2, E_2), I_2, O_2\}` the open graph `other`, :math:`\{G(V, E), I, O\}` the resulting open graph `og` and `{v:u}` an element of `mapping`. + + # We define :math:`V, U` the set of nodes in `mapping.keys()` and `mapping.values()`, and :math:`M = U \cap V_1` the set of merged nodes. + + # The open graph composition requires that + # - :math:`V \subseteq V_2`. + # - If both `v` and `u` are measured, the corresponding measurements must have the same plane and angle. + # The returned open graph follows this convention: + # - :math:`I = (I_1 \cup I_2) \setminus M \cup (I_1 \cap I_2 \cap M)`, + # - :math:`O = (O_1 \cup O_2) \setminus M \cup (O_1 \cap O_2 \cap M)`, + # - If only one node of the pair `{v:u}` is measured, this measure is assigned to :math:`u \in V` in the resulting open graph. + # - Input (and, respectively, output) nodes in the returned open graph have the order of the open graph `self` followed by those of the open graph `other`. Merged nodes are removed, except when they are input (or output) nodes in both open graphs, in which case, they appear in the order they originally had in the graph `self`. + # """ + # if not (mapping.keys() <= other.graph.nodes): + # raise ValueError("Keys of mapping must be correspond to nodes of other.") + # if len(mapping) != len(set(mapping.values())): + # raise ValueError("Values in mapping contain duplicates.") + # for v, u in mapping.items(): + # if ( + # (vm := other.measurements.get(v)) is not None + # and (um := self.measurements.get(u)) is not None + # and not vm.isclose(um) # TODO: How do we ensure that planes, axis, etc. are the same ? + # ): + # raise ValueError(f"Attempted to merge nodes {v}:{u} but have different measurements") - shift = max(*self.graph.nodes, *mapping.values()) + 1 + # shift = max(*self.graph.nodes, *mapping.values()) + 1 - mapping_sequential = { - node: i for i, node in enumerate(sorted(other.graph.nodes - mapping.keys()), start=shift) - } # assigns new labels to nodes in other not specified in mapping + # mapping_sequential = { + # node: i for i, node in enumerate(sorted(other.graph.nodes - mapping.keys()), start=shift) + # } # assigns new labels to nodes in other not specified in mapping - mapping_complete = {**mapping, **mapping_sequential} + # mapping_complete = {**mapping, **mapping_sequential} - g2_shifted = nx.relabel_nodes(other.graph, mapping_complete) - g = nx.compose(self.graph, g2_shifted) + # g2_shifted = nx.relabel_nodes(other.graph, mapping_complete) + # g = nx.compose(self.graph, g2_shifted) - merged = set(mapping_complete.values()) & self.graph.nodes + # merged = set(mapping_complete.values()) & self.graph.nodes - def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: - p2_mapped = [mapping_complete[node] for node in p2] - p2_set = set(p2_mapped) - part1 = [node for node in p1 if node not in merged or node in p2_set] - part2 = [node for node in p2_mapped if node not in merged] - return part1 + part2 + # def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: + # p2_mapped = [mapping_complete[node] for node in p2] + # p2_set = set(p2_mapped) + # part1 = [node for node in p1 if node not in merged or node in p2_set] + # part2 = [node for node in p2_mapped if node not in merged] + # return part1 + part2 - input_nodes = merge_ports(self.input_nodes, other.input_nodes) - output_nodes = merge_ports(self.output_nodes, other.output_nodes) + # input_nodes = merge_ports(self.input_nodes, other.input_nodes) + # output_nodes = merge_ports(self.output_nodes, other.output_nodes) - measurements_shifted = {mapping_complete[i]: meas for i, meas in other.measurements.items()} - measurements = {**self.measurements, **measurements_shifted} + # measurements_shifted = {mapping_complete[i]: meas for i, meas in other.measurements.items()} + # measurements = {**self.measurements, **measurements_shifted} - return OpenGraph(g, measurements, input_nodes, output_nodes), mapping_complete + # return OpenGraph(g, measurements, input_nodes, output_nodes), mapping_complete def neighbors(self, nodes: Collection[int]) -> set[int]: """Return the set containing the neighborhood of a set of nodes. @@ -239,25 +235,23 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: odd_neighbors_set ^= self.neighbors([node]) return odd_neighbors_set - def compute_causal_flow(self: OpenGraph[Plane]) -> CausalFlow | None: + def find_causal_flow(self: OpenGraph[AbstractPlanarMeasurement]) -> CausalFlow | None: return find_cflow(self) - @singledispatchmethod - def compute_maximally_delayed_flow(self) -> PauliFlow[_M] | None: - correction_matrix = compute_correction_matrix(self) + def find_gflow(self: OpenGraph[AbstractPlanarMeasurement]) -> GFlow | None: + aog = AlgebraicOpenGraphGFlow(self) + correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: return None - return PauliFlow.from_correction_matrix( + return GFlow.from_correction_matrix( correction_matrix ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. - # TODO: this function could return a GFlow that is also a CausalFlow. Should we deal with this ? - # TODO: maybe @override for mypy - # Can we do singledispatch on self ? - # This doesn-t work because at run time we don-t now about the parametrized-type of OpenGraph - @compute_maximally_delayed_flow.register - def _(self: OpenGraph[Plane]) -> GFlow | None: - correction_matrix = compute_correction_matrix(self) + def find_pauli_flow(self: OpenGraph[AbstractMeasurement]) -> PauliFlow | None: + aog = AlgebraicOpenGraphPauliFlow(self) + correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: return None - return GFlow.from_correction_matrix(correction_matrix) + return PauliFlow.from_correction_matrix( + correction_matrix + ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. From 0cb2c2e62182e7fe0f818ae2d5d96b4261d72273 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 21 Oct 2025 16:21:19 +0200 Subject: [PATCH 12/56] wip --- graphix/flow/_find_cflow.py | 3 +- graphix/flow/_find_pflow.py | 72 +++++++++++++++++-------------------- graphix/flow/flow.py | 20 ++++++----- graphix/opengraph_.py | 6 ++-- 4 files changed, 48 insertions(+), 53 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 6aa99d803..76d38c5b5 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -3,12 +3,11 @@ from typing import TYPE_CHECKING from graphix.flow.flow import CausalFlow -from graphix.measurements import Plane +from graphix.measurements import AbstractPlanarMeasurement, Plane if TYPE_CHECKING: from collections.abc import Set as AbstractSet - from graphix.measurements import AbstractPlanarMeasurement from graphix.opengraph_ import OpenGraph # TODO: Up doc strings diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_pflow.py index 7c2f494c7..afacd24c3 100644 --- a/graphix/flow/_find_pflow.py +++ b/graphix/flow/_find_pflow.py @@ -11,17 +11,15 @@ from __future__ import annotations -from abc import ABC, abstractmethod from copy import deepcopy from functools import cached_property -from typing import TYPE_CHECKING, Generic, NamedTuple +from typing import TYPE_CHECKING, Generic, NamedTuple, TypeVar import numpy as np from graphix._linalg import MatGF2, solve_f2_linear_system from graphix.fundamentals import Axis, Plane -from graphix.measurements import AbstractPlanarMeasurement -from graphix.opengraph_ import _M +from graphix.measurements import AbstractMeasurement, AbstractPlanarMeasurement from graphix.sim.base_backend import NodeIndex if TYPE_CHECKING: @@ -31,7 +29,11 @@ from graphix.opengraph_ import OpenGraph -class AlgebraicOpenGraph(ABC, Generic[_M]): +_M = TypeVar("_M", bound=AbstractMeasurement) +_PM = TypeVar("_PM", bound=AbstractPlanarMeasurement) + + +class AlgebraicOpenGraph(Generic[_M]): """A class for providing an algebraic representation of open graphs as introduced in [1]. In particular, it allows managing the mapping between node labels of the graph and the relevant matrix indices. The flow demand and order demand matrices appear as cached properties. It reuses the class `:class: graphix.sim.base_backend.NodeIndex` introduced for managing the mapping between node numbers and qubit indices in the internal state of the backend. @@ -114,7 +116,6 @@ def _compute_reduced_adj(self) -> MatGF2: return adj_red @cached_property - @abstractmethod def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: r"""Construct flow-demand and order-demand matrices. @@ -127,22 +128,6 @@ def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: ----- See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - - -class AlgebraicOpenGraphGFlow(AlgebraicOpenGraph[AbstractPlanarMeasurement]): - @cached_property - def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: - r"""Construct flow-demand and order-demand matrices assuming that the underlying open graph has planar measurements only. - - Returns - ------- - flow_demand_matrix : MatGF2 - order_demand_matrix : MatGF2 - - Notes - ----- - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ flow_demand_matrix = self._compute_reduced_adj() order_demand_matrix = flow_demand_matrix.copy() @@ -153,26 +138,26 @@ def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: for v in row_tags: # v is a node tag i = row_tags.index(v) - plane_v = self.og.measurements[v].to_plane() + plane_axis_v = self.og.measurements[v].to_plane_or_axis() - if plane_v in {Plane.YZ, Plane.XZ}: + if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if v not in inputs_set: - j = col_tags.index(v) - flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 - if plane_v is Plane.XY: + if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Y, Axis.Z} and v not in inputs_set: + j = col_tags.index(v) + flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 + if plane_axis_v in {Plane.XY, Axis.X, Axis.Y, Axis.Z}: order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if plane_v in {Plane.XY, Plane.XZ} and v not in inputs_set: + if plane_axis_v in {Plane.XY, Plane.XZ} and v not in inputs_set: j = col_tags.index(v) order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 return flow_demand_matrix, order_demand_matrix -class AlgebraicOpenGraphPauliFlow(AlgebraicOpenGraph[_M]): +class PlanarAlgebraicOpenGraph(AlgebraicOpenGraph[_PM]): @cached_property def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: - r"""Construct flow-demand and order-demand matrices. + r"""Construct flow-demand and order-demand matrices assuming that the underlying open graph has planar measurements only. Returns ------- @@ -193,16 +178,16 @@ def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: for v in row_tags: # v is a node tag i = row_tags.index(v) - plane_axis_v = self.og.measurements[v].to_plane_or_axis() + plane_v = self.og.measurements[v].to_plane() - if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: + if plane_v in {Plane.YZ, Plane.XZ}: flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Y, Axis.Z} and v not in inputs_set: - j = col_tags.index(v) - flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 - if plane_axis_v in {Plane.XY, Axis.X, Axis.Y, Axis.Z}: + if v not in inputs_set: + j = col_tags.index(v) + flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 + if plane_v is Plane.XY: order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if plane_axis_v in {Plane.XY, Plane.XZ} and v not in inputs_set: + if plane_v in {Plane.XY, Plane.XZ} and v not in inputs_set: j = col_tags.index(v) order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 @@ -228,7 +213,9 @@ class CorrectionMatrix(NamedTuple, Generic[_M]): c_matrix: MatGF2 @staticmethod - def from_correction_function(og: OpenGraph[_M], correction_function: Mapping[int, set[int]]) -> CorrectionMatrix[_M]: + def from_correction_function( + og: OpenGraph[_M], correction_function: Mapping[int, set[int]] + ) -> CorrectionMatrix[_M]: r"""Initialise a `CorrectionMatrix` object from a correction function. Parameters @@ -247,7 +234,9 @@ def from_correction_function(og: OpenGraph[_M], correction_function: Mapping[int ----- This function is not required to find a Pauli (or generalised) flow on an open graph but is a useful auxiliary method to verify the validity of a flow encoded in a correction function. """ - aog = AlgebraicOpenGraphPauliFlow(og) # TODO: Is it a problem that we instatiate an AOGPauliFlow, regardless of the type of og ? + aog = AlgebraicOpenGraph( + og + ) # TODO: Is it a problem that we instatiate an AOGPauliFlow, regardless of the type of og ? row_tags = aog.non_inputs col_tags = aog.non_outputs @@ -682,3 +671,6 @@ def compute_correction_matrix(aog: AlgebraicOpenGraph[_M]) -> CorrectionMatrix[_ return None return CorrectionMatrix(aog, correction_matrix) + + +# TODO: When should inputs be parametrized with `_M` and when with `AbstractMeasurement` ? diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index c65612c2d..8ce9723f1 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -13,10 +13,9 @@ import numpy as np from graphix.command import E, M, N, X, Z -from graphix.flow._find_pflow import CorrectionMatrix, compute_partial_order_layers +from graphix.flow._find_pflow import _M, _PM, CorrectionMatrix, compute_partial_order_layers from graphix.fundamentals import Axis, Plane, Sign from graphix.graphix._linalg import MatGF2 -from graphix.opengraph_ import _M, OpenGraph from graphix.pattern import Pattern if TYPE_CHECKING: @@ -26,7 +25,8 @@ import numpy.typing as npt - from graphix.measurements import AbstractPlanarMeasurement, ExpressionOrFloat, Measurement + from graphix.measurements import ExpressionOrFloat, Measurement + from graphix.opengraph_ import OpenGraph TotalOrder = Sequence[int] @@ -98,7 +98,9 @@ def is_compatible(self, total_order: TotalOrder) -> bool: return True def to_pattern( - self: Corrections[Measurement], angles: Mapping[int, ExpressionOrFloat | Sign], total_order: TotalOrder | None = None + self: Corrections[Measurement], + angles: Mapping[int, ExpressionOrFloat | Sign], + total_order: TotalOrder | None = None, ) -> Pattern: # TODO: Should we verify thar corrections are well formed ? If we did so, and the total order is inferred from the corrections, we are doing a topological sort twice @@ -260,9 +262,9 @@ def is_well_formed(self) -> bool: @dataclass(frozen=True) -class GFlow(PauliFlow[AbstractPlanarMeasurement]): +class GFlow(PauliFlow[_PM]): @override - def to_corrections(self) -> Corrections[AbstractPlanarMeasurement]: + def to_corrections(self) -> Corrections[_PM]: r"""Compute the X and Z corrections induced by the generalised flow encoded in `self`. Returns @@ -286,14 +288,16 @@ def to_corrections(self) -> Corrections[AbstractPlanarMeasurement]: @dataclass(frozen=True) -class CausalFlow(GFlow): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane +class CausalFlow( + GFlow[_PM] +): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane @override @staticmethod def from_correction_matrix() -> None: raise NotImplementedError("Initialization of a causal flow from a correction matrix is not supported.") @override - def to_corrections(self) -> Corrections[Plane]: + def to_corrections(self) -> Corrections[_PM]: r"""Compute the X and Z corrections induced by the causal flow encoded in `self`. Returns diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index fd66ae03c..076b50323 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -8,7 +8,7 @@ import networkx as nx from graphix.flow._find_cflow import find_cflow -from graphix.flow._find_pflow import AlgebraicOpenGraphGFlow, AlgebraicOpenGraphPauliFlow, compute_correction_matrix +from graphix.flow._find_pflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix from graphix.flow.flow import CausalFlow, GFlow, PauliFlow from graphix.measurements import AbstractMeasurement, AbstractPlanarMeasurement, Measurement @@ -239,7 +239,7 @@ def find_causal_flow(self: OpenGraph[AbstractPlanarMeasurement]) -> CausalFlow | return find_cflow(self) def find_gflow(self: OpenGraph[AbstractPlanarMeasurement]) -> GFlow | None: - aog = AlgebraicOpenGraphGFlow(self) + aog = PlanarAlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: return None @@ -248,7 +248,7 @@ def find_gflow(self: OpenGraph[AbstractPlanarMeasurement]) -> GFlow | None: ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. def find_pauli_flow(self: OpenGraph[AbstractMeasurement]) -> PauliFlow | None: - aog = AlgebraicOpenGraphPauliFlow(self) + aog = AlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: return None From 69a129549d837c9f754a093bfce0376364ac4f7f Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 23 Oct 2025 11:24:12 +0200 Subject: [PATCH 13/56] wip --- graphix/__init__.py | 14 +- graphix/flow/_find_cflow.py | 8 +- graphix/flow/_find_pflow.py | 13 +- graphix/flow/flow.py | 419 ++++++++++++++++++------------------ graphix/fundamentals.py | 22 +- graphix/measurements.py | 22 +- graphix/opengraph_.py | 15 +- graphix/pattern.py | 6 +- tests/test_flow.py | 28 +++ tests/test_opengraph_.py | 26 +++ 10 files changed, 316 insertions(+), 257 deletions(-) create mode 100644 tests/test_flow.py create mode 100644 tests/test_opengraph_.py diff --git a/graphix/__init__.py b/graphix/__init__.py index 782a7ef7b..97c369473 100644 --- a/graphix/__init__.py +++ b/graphix/__init__.py @@ -1,11 +1,11 @@ """Optimize and simulate measurement-based quantum computation.""" -from __future__ import annotations +# from __future__ import annotations -from graphix.generator import generate_from_graph -from graphix.graphsim import GraphState -from graphix.pattern import Pattern -from graphix.sim.statevec import Statevec -from graphix.transpiler import Circuit +# # from graphix.generator import generate_from_graph +# from graphix.graphsim import GraphState +# from graphix.pattern import Pattern +# from graphix.sim.statevec import Statevec +# from graphix.transpiler import Circuit -__all__ = ["Circuit", "GraphState", "Pattern", "Statevec", "generate_from_graph"] +# __all__ = ["Circuit", "GraphState", "Pattern", "Statevec"] # , "generate_from_graph"] diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 76d38c5b5..77c77d6eb 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -3,17 +3,17 @@ from typing import TYPE_CHECKING from graphix.flow.flow import CausalFlow -from graphix.measurements import AbstractPlanarMeasurement, Plane +from graphix.fundamentals import Plane if TYPE_CHECKING: from collections.abc import Set as AbstractSet - from graphix.opengraph_ import OpenGraph + from graphix.opengraph_ import _PM, OpenGraph # TODO: Up doc strings -def find_cflow(og: OpenGraph[AbstractPlanarMeasurement]) -> CausalFlow | None: +def find_cflow(og: OpenGraph[_PM]) -> CausalFlow | None: """Return the causal flow of the input open graph if it exists. Parameters @@ -51,7 +51,7 @@ def find_cflow(og: OpenGraph[AbstractPlanarMeasurement]) -> CausalFlow | None: def _flow_aux( - og: OpenGraph[AbstractPlanarMeasurement], + og: OpenGraph[_PM], non_input_nodes: AbstractSet[int], corrected_nodes: AbstractSet[int], corrector_candidates: AbstractSet[int], diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_pflow.py index afacd24c3..6a0d585a5 100644 --- a/graphix/flow/_find_pflow.py +++ b/graphix/flow/_find_pflow.py @@ -18,8 +18,7 @@ import numpy as np from graphix._linalg import MatGF2, solve_f2_linear_system -from graphix.fundamentals import Axis, Plane -from graphix.measurements import AbstractMeasurement, AbstractPlanarMeasurement +from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.sim.base_backend import NodeIndex if TYPE_CHECKING: @@ -74,11 +73,11 @@ def __init__(self, og: OpenGraph[_M]) -> None: @property def flow_demand_matrix(self) -> MatGF2: - return self._compute_pflow_matrices[0] + return self._compute_og_matrices[0] @property def order_demand_matrix(self) -> MatGF2: - return self._compute_pflow_matrices[1] + return self._compute_og_matrices[1] def _compute_reduced_adj(self) -> MatGF2: r"""Return the reduced adjacency matrix (RAdj) of the input open graph. @@ -116,7 +115,7 @@ def _compute_reduced_adj(self) -> MatGF2: return adj_red @cached_property - def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: + def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: r"""Construct flow-demand and order-demand matrices. Returns @@ -156,7 +155,7 @@ def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: class PlanarAlgebraicOpenGraph(AlgebraicOpenGraph[_PM]): @cached_property - def _compute_pflow_matrices(self) -> tuple[MatGF2, MatGF2]: + def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: r"""Construct flow-demand and order-demand matrices assuming that the underlying open graph has planar measurements only. Returns @@ -660,7 +659,7 @@ def compute_correction_matrix(aog: AlgebraicOpenGraph[_M]) -> CorrectionMatrix[_ # Steps 1 and 2 # Flow-demand and order-demand matrices are cached properties of `aog`. - flow_demand_matrix, order_demand_matrix = aog._compute_pflow_matrices + flow_demand_matrix, order_demand_matrix = aog._compute_og_matrices if ni == no: correction_matrix = flow_demand_matrix.right_inverse() diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index 8ce9723f1..4ba070793 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -5,26 +5,23 @@ from collections import defaultdict from collections.abc import Sequence from dataclasses import dataclass -from functools import cached_property -from itertools import pairwise, product +from itertools import product from typing import TYPE_CHECKING, Generic, Self, override import networkx as nx import numpy as np +from graphix._linalg import MatGF2 from graphix.command import E, M, N, X, Z from graphix.flow._find_pflow import _M, _PM, CorrectionMatrix, compute_partial_order_layers from graphix.fundamentals import Axis, Plane, Sign -from graphix.graphix._linalg import MatGF2 from graphix.pattern import Pattern if TYPE_CHECKING: - from collections.abc import Collection, Mapping + from collections.abc import Mapping from collections.abc import Set as AbstractSet from typing import Self - import numpy.typing as npt - from graphix.measurements import ExpressionOrFloat, Measurement from graphix.opengraph_ import OpenGraph @@ -32,7 +29,7 @@ @dataclass(frozen=True) -class Corrections(Generic[_M]): +class XZCorrections(Generic[_M]): og: OpenGraph[_M] x_corrections: dict[int, set[int]] # {node: domain} z_corrections: dict[int, set[int]] # {node: domain} @@ -98,7 +95,7 @@ def is_compatible(self, total_order: TotalOrder) -> bool: return True def to_pattern( - self: Corrections[Measurement], + self: XZCorrections[Measurement], angles: Mapping[int, ExpressionOrFloat | Sign], total_order: TotalOrder | None = None, ) -> Pattern: @@ -178,7 +175,7 @@ def from_correction_matrix(cls, correction_matrix: CorrectionMatrix) -> Self | N return cls(correction_matrix.aog.og, correction_function, partial_order_layers) - def to_corrections(self) -> Corrections[_M]: + def to_corrections(self) -> XZCorrections[_M]: """Compute the X and Z corrections induced by the Pauli flow encoded in `self`. Returns @@ -201,7 +198,7 @@ def to_corrections(self) -> Corrections[_M]: future |= layer - return Corrections(self.og, x_corrections, z_corrections) + return XZCorrections(self.og, x_corrections, z_corrections) def is_well_formed(self) -> bool: r"""Verify if flow object is well formed. @@ -264,7 +261,7 @@ def is_well_formed(self) -> bool: @dataclass(frozen=True) class GFlow(PauliFlow[_PM]): @override - def to_corrections(self) -> Corrections[_PM]: + def to_corrections(self) -> XZCorrections[_PM]: r"""Compute the X and Z corrections induced by the generalised flow encoded in `self`. Returns @@ -280,11 +277,13 @@ def to_corrections(self) -> Corrections[_PM]: x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} - for node, corr_set in self.correction_function.items(): - x_corrections[node].update(corr_set - {node}) - z_corrections[node].update(self.og.odd_neighbors(corr_set)) + for corr_node, corr_set in self.correction_function.items(): + for node in self.og.odd_neighbors(corr_set): + z_corrections[node].add(corr_node) + for node in corr_set - {corr_node}: + x_corrections[node].add(corr_node) - return Corrections(self.og, x_corrections, z_corrections) + return XZCorrections(self.og, x_corrections, z_corrections) @dataclass(frozen=True) @@ -297,7 +296,7 @@ def from_correction_matrix() -> None: raise NotImplementedError("Initialization of a causal flow from a correction matrix is not supported.") @override - def to_corrections(self) -> Corrections[_PM]: + def to_corrections(self) -> XZCorrections[_PM]: r"""Compute the X and Z corrections induced by the causal flow encoded in `self`. Returns @@ -315,7 +314,7 @@ def to_corrections(self) -> Corrections[_PM]: x_corrections[node].update(corr_set) z_corrections[node].update(self.og.neighbors(corr_set) - {node}) - return Corrections(self.og, x_corrections, z_corrections) + return XZCorrections(self.og, x_corrections, z_corrections) ########### @@ -323,196 +322,196 @@ def to_corrections(self) -> Corrections[_PM]: ########### -@dataclass(frozen=True) -class PartialOrder: - """Class for storing and manipulating the partial order in a flow. - - Attributes - ---------- - dag: nx.DiGraph[int] - Directed Acyclical Graph (DAG) representing the partial order. The transitive closure of `dag` yields all the relations in the partial order. - - layers: Mapping[int, AbstractSet[int]] - Mapping storing the partial order in a layer structure. - The pair `(key, value)` corresponds to the layer and the set of nodes in that layer. - Layer 0 corresponds to the largest nodes in the partial order. In general, if `i > j`, then nodes in `layers[j]` are in the future of nodes in `layers[i]`. - - """ - - dag: nx.DiGraph[int] - layers: Mapping[int, AbstractSet[int]] - - @classmethod - def from_adj_matrix(cls, adj_mat: npt.NDArray[np.uint8], nodelist: Collection[int] | None = None) -> PartialOrder: - """Construct a partial order from an adjacency matrix representing a DAG. - - Parameters - ---------- - adj_mat: npt.NDArray[np.uint8] - Adjacency matrix of the DAG. A nonzero element `adj_mat[i,j]` represents a link `i -> j`. - node_list: Collection[int] | None - Mapping between matrix indices and node labels. Optional, defaults to `None`. - - Returns - ------- - PartialOrder - - Notes - ----- - The `layers` attribute of the `PartialOrder` attribute is obtained by performing a topological sort on the DAG. This routine verifies that the input directed graph is indeed acyclical. See :func:`_compute_layers_from_dag` for more details. - """ - dag = nx.from_numpy_array(adj_mat, create_using=nx.DiGraph, nodelist=nodelist) - layers = _compute_layers_from_dag(dag) - return cls(dag=dag, layers=layers) - - @classmethod - def from_relations(cls, relations: Collection[tuple[int, int]]) -> PartialOrder: - """Construct a partial order from the order relations. - - Parameters - ---------- - relations: Collection[tuple[int, int]] - Collection of relations in the partial order. A tuple `(a, b)` represents `a > b` in the partial order. - - Returns - ------- - PartialOrder - - Notes - ----- - The `layers` attribute of the `PartialOrder` attribute is obtained by performing a topological sort on the DAG. This routine verifies that the input directed graph is indeed acyclical. See :func:`_compute_layers_from_dag` for more details. - """ - dag = nx.DiGraph(relations) - layers = _compute_layers_from_dag(dag) - return cls(dag=dag, layers=layers) - - @classmethod - def from_layers(cls, layers: Mapping[int, AbstractSet[int]]) -> PartialOrder: - dag = _compute_dag_from_layers(layers) - return cls(dag=dag, layers=layers) - - @classmethod - def from_corrections(cls, corrections: Corrections) -> PartialOrder: - relations: set[tuple[int, int]] = set() - - for node, domain in corrections.x_corrections.items(): - relations.update(product([node], domain)) - - for node, domain in corrections.z_corrections.items(): - relations.update(product([node], domain)) - - return cls.from_relations(relations) - - @property - def nodes(self) -> set[int]: - """Return nodes in the partial order.""" - return set(self.dag.nodes) - - @property - def node_layer_mapping(self) -> dict[int, int]: - """Return layers in the form `{node: layer}`.""" - mapping: dict[int, int] = {} - for layer, nodes in self.layers.items(): - mapping.update(dict.fromkeys(nodes, layer)) - - return mapping - - @cached_property - def transitive_closure(self) -> set[tuple[int, int]]: - """Return the transitive closure of the Directed Acyclic Graph (DAG) encoding the partial order. - - Returns - ------- - set[tuple[int, int]] - A tuple `(i, j)` belongs to the transitive closure of the DAG if `i > j` according to the partial order. - """ - return set(nx.transitive_closure_dag(self.dag).edges()) - - def greater(self, a: int, b: int) -> bool: - """Verify order between two nodes. - - Parameters - ---------- - a : int - b : int - - Returns - ------- - bool - `True` if `a > b` in the partial order, `False` otherwise. - - Raises - ------ - ValueError - If either node `a` or `b` is not included in the definition of the partial order. - """ - if a not in self.nodes: - raise ValueError(f"Node a = {a} is not included in the partial order.") - if b not in self.nodes: - raise ValueError(f"Node b = {b} is not included in the partial order.") - return (a, b) in self.transitive_closure - - def compute_future(self, node: int) -> set[int]: - """Compute the future of `node`. - - Parameters - ---------- - node : int - Node for which the future is computed. - - Returns - ------- - set[int] - Set of nodes `i` such that `i > node` in the partial order. - """ - if node not in self.nodes: - raise ValueError(f"Node {node} is not included in the partial order.") - - return {i for i, j in self.transitive_closure if j == node} - - def is_compatible(self, other: PartialOrder) -> bool: - r"""Verify compatibility between two partial orders. - - Parameters - ---------- - other : PartialOrder - - Returns - ------- - bool - `True` if partial order `self` is compatible with partial order `other`, `False` otherwise. - - Notes - ----- - We define partial-order compatibility as follows: - A partial order :math:`<_P` on a set :math:`U` is compatible with a partial order :math:`<_Q` on a set :math:`V` iff :math:`a <_P b \rightarrow a <_Q b \forall a, b \in U`. - This definition of compatibility requires that :math:`U \subseteq V`. - Further, it is not symmetric. - """ - return self.transitive_closure.issubset(other.transitive_closure) - - -def _compute_layers_from_dag(dag: nx.DiGraph[int]) -> dict[int, set[int]]: - try: - generations = reversed(list(nx.topological_generations(dag))) - return {layer: set(generation) for layer, generation in enumerate(generations)} - except nx.NetworkXUnfeasible as exc: - raise ValueError("Partial order contains loops.") from exc - - -def _compute_dag_from_layers(layers: Mapping[int, AbstractSet[int]]) -> nx.DiGraph[int]: - max_layer = max(layers) - relations: list[tuple[int, int]] = [] - visited_nodes: set[int] = set() - - for i, j in pairwise(reversed(range(max_layer + 1))): - layer_curr, layer_next = layers[i], layers[j] - if layer_curr & visited_nodes: - raise ValueError(f"Layer {i} contains nodes in previous layers.") - visited_nodes |= layer_curr - relations.extend(product(layer_curr, layer_next)) - - if layers[0] & visited_nodes: - raise ValueError(f"Layer {i} contains nodes in previous layers.") - - return nx.DiGraph(relations) +# @dataclass(frozen=True) +# class PartialOrder: +# """Class for storing and manipulating the partial order in a flow. + +# Attributes +# ---------- +# dag: nx.DiGraph[int] +# Directed Acyclical Graph (DAG) representing the partial order. The transitive closure of `dag` yields all the relations in the partial order. + +# layers: Mapping[int, AbstractSet[int]] +# Mapping storing the partial order in a layer structure. +# The pair `(key, value)` corresponds to the layer and the set of nodes in that layer. +# Layer 0 corresponds to the largest nodes in the partial order. In general, if `i > j`, then nodes in `layers[j]` are in the future of nodes in `layers[i]`. + +# """ + +# dag: nx.DiGraph[int] +# layers: Mapping[int, AbstractSet[int]] + +# @classmethod +# def from_adj_matrix(cls, adj_mat: npt.NDArray[np.uint8], nodelist: Collection[int] | None = None) -> PartialOrder: +# """Construct a partial order from an adjacency matrix representing a DAG. + +# Parameters +# ---------- +# adj_mat: npt.NDArray[np.uint8] +# Adjacency matrix of the DAG. A nonzero element `adj_mat[i,j]` represents a link `i -> j`. +# node_list: Collection[int] | None +# Mapping between matrix indices and node labels. Optional, defaults to `None`. + +# Returns +# ------- +# PartialOrder + +# Notes +# ----- +# The `layers` attribute of the `PartialOrder` attribute is obtained by performing a topological sort on the DAG. This routine verifies that the input directed graph is indeed acyclical. See :func:`_compute_layers_from_dag` for more details. +# """ +# dag = nx.from_numpy_array(adj_mat, create_using=nx.DiGraph, nodelist=nodelist) +# layers = _compute_layers_from_dag(dag) +# return cls(dag=dag, layers=layers) + +# @classmethod +# def from_relations(cls, relations: Collection[tuple[int, int]]) -> PartialOrder: +# """Construct a partial order from the order relations. + +# Parameters +# ---------- +# relations: Collection[tuple[int, int]] +# Collection of relations in the partial order. A tuple `(a, b)` represents `a > b` in the partial order. + +# Returns +# ------- +# PartialOrder + +# Notes +# ----- +# The `layers` attribute of the `PartialOrder` attribute is obtained by performing a topological sort on the DAG. This routine verifies that the input directed graph is indeed acyclical. See :func:`_compute_layers_from_dag` for more details. +# """ +# dag = nx.DiGraph(relations) +# layers = _compute_layers_from_dag(dag) +# return cls(dag=dag, layers=layers) + +# @classmethod +# def from_layers(cls, layers: Mapping[int, AbstractSet[int]]) -> PartialOrder: +# dag = _compute_dag_from_layers(layers) +# return cls(dag=dag, layers=layers) + +# @classmethod +# def from_corrections(cls, corrections: XZCorrections) -> PartialOrder: +# relations: set[tuple[int, int]] = set() + +# for node, domain in corrections.x_corrections.items(): +# relations.update(product([node], domain)) + +# for node, domain in corrections.z_corrections.items(): +# relations.update(product([node], domain)) + +# return cls.from_relations(relations) + +# @property +# def nodes(self) -> set[int]: +# """Return nodes in the partial order.""" +# return set(self.dag.nodes) + +# @property +# def node_layer_mapping(self) -> dict[int, int]: +# """Return layers in the form `{node: layer}`.""" +# mapping: dict[int, int] = {} +# for layer, nodes in self.layers.items(): +# mapping.update(dict.fromkeys(nodes, layer)) + +# return mapping + +# @cached_property +# def transitive_closure(self) -> set[tuple[int, int]]: +# """Return the transitive closure of the Directed Acyclic Graph (DAG) encoding the partial order. + +# Returns +# ------- +# set[tuple[int, int]] +# A tuple `(i, j)` belongs to the transitive closure of the DAG if `i > j` according to the partial order. +# """ +# return set(nx.transitive_closure_dag(self.dag).edges()) + +# def greater(self, a: int, b: int) -> bool: +# """Verify order between two nodes. + +# Parameters +# ---------- +# a : int +# b : int + +# Returns +# ------- +# bool +# `True` if `a > b` in the partial order, `False` otherwise. + +# Raises +# ------ +# ValueError +# If either node `a` or `b` is not included in the definition of the partial order. +# """ +# if a not in self.nodes: +# raise ValueError(f"Node a = {a} is not included in the partial order.") +# if b not in self.nodes: +# raise ValueError(f"Node b = {b} is not included in the partial order.") +# return (a, b) in self.transitive_closure + +# def compute_future(self, node: int) -> set[int]: +# """Compute the future of `node`. + +# Parameters +# ---------- +# node : int +# Node for which the future is computed. + +# Returns +# ------- +# set[int] +# Set of nodes `i` such that `i > node` in the partial order. +# """ +# if node not in self.nodes: +# raise ValueError(f"Node {node} is not included in the partial order.") + +# return {i for i, j in self.transitive_closure if j == node} + +# def is_compatible(self, other: PartialOrder) -> bool: +# r"""Verify compatibility between two partial orders. + +# Parameters +# ---------- +# other : PartialOrder + +# Returns +# ------- +# bool +# `True` if partial order `self` is compatible with partial order `other`, `False` otherwise. + +# Notes +# ----- +# We define partial-order compatibility as follows: +# A partial order :math:`<_P` on a set :math:`U` is compatible with a partial order :math:`<_Q` on a set :math:`V` iff :math:`a <_P b \rightarrow a <_Q b \forall a, b \in U`. +# This definition of compatibility requires that :math:`U \subseteq V`. +# Further, it is not symmetric. +# """ +# return self.transitive_closure.issubset(other.transitive_closure) + + +# def _compute_layers_from_dag(dag: nx.DiGraph[int]) -> dict[int, set[int]]: +# try: +# generations = reversed(list(nx.topological_generations(dag))) +# return {layer: set(generation) for layer, generation in enumerate(generations)} +# except nx.NetworkXUnfeasible as exc: +# raise ValueError("Partial order contains loops.") from exc + + +# def _compute_dag_from_layers(layers: Mapping[int, AbstractSet[int]]) -> nx.DiGraph[int]: +# max_layer = max(layers) +# relations: list[tuple[int, int]] = [] +# visited_nodes: set[int] = set() + +# for i, j in pairwise(reversed(range(max_layer + 1))): +# layer_curr, layer_next = layers[i], layers[j] +# if layer_curr & visited_nodes: +# raise ValueError(f"Layer {i} contains nodes in previous layers.") +# visited_nodes |= layer_curr +# relations.extend(product(layer_curr, layer_next)) + +# if layers[0] & visited_nodes: +# raise ValueError(f"Layer {i} contains nodes in previous layers.") + +# return nx.DiGraph(relations) diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index 5317a2eb6..c599b09d4 100644 --- a/graphix/fundamentals.py +++ b/graphix/fundamentals.py @@ -5,12 +5,12 @@ import enum import sys import typing -from enum import Enum +from abc import ABC, ABCMeta, abstractmethod +from enum import Enum, EnumMeta from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat, SupportsIndex, overload, override import typing_extensions -from graphix.measurements import AbstractMeasurement, AbstractPlanarMeasurement from graphix.ops import Ops from graphix.parameter import cos_sin from graphix.repr_mixins import EnumReprMixin @@ -215,8 +215,22 @@ def matrix(self) -> npt.NDArray[np.complex128]: typing_extensions.assert_never(self) +class CustomMeta(ABCMeta, EnumMeta): + """Custom metaclass to allow multiple inheritance from `Enum` and `ABC`.""" + + +class AbstractMeasurement(ABC): + @abstractmethod + def to_plane_or_axis(self) -> Plane | Axis: ... + + +class AbstractPlanarMeasurement(AbstractMeasurement): + @abstractmethod + def to_plane(self) -> Plane: ... + + # TODO Conflicts with Enum -class Axis(EnumReprMixin, Enum, AbstractMeasurement): +class Axis(AbstractMeasurement, EnumReprMixin, Enum, metaclass=CustomMeta): """Axis: *X*, *Y* or *Z*.""" X = enum.auto() @@ -239,7 +253,7 @@ def to_plane_or_axis(self) -> Axis: return self -class Plane(EnumReprMixin, Enum, AbstractPlanarMeasurement): +class Plane(AbstractPlanarMeasurement, EnumReprMixin, Enum, metaclass=CustomMeta): # TODO: Refactor using match """Plane: *XY*, *YZ* or *XZ*.""" diff --git a/graphix/measurements.py b/graphix/measurements.py index 4c6a17f7e..3f0db4ca5 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -2,15 +2,14 @@ from __future__ import annotations -import dataclasses import math -from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import Literal, NamedTuple, SupportsInt from typing_extensions import TypeAlias # TypeAlias introduced in Python 3.10 from graphix import utils -from graphix.fundamentals import Axis, Plane, Sign +from graphix.fundamentals import AbstractPlanarMeasurement, Axis, Plane, Sign # Ruff suggests to move this import to a type-checking block, but dataclass requires it here from graphix.parameter import ExpressionOrFloat # noqa: TC001 @@ -28,7 +27,7 @@ def toggle_outcome(outcome: Outcome) -> Outcome: return 1 if outcome == 0 else 0 -@dataclasses.dataclass +@dataclass class Domains: """Represent `X^sZ^t` where s and t are XOR of results from given sets of indices.""" @@ -36,19 +35,8 @@ class Domains: t_domain: set[int] -class AbstractMeasurement(ABC): - @abstractmethod - def to_plane_or_axis(self) -> Plane | Axis: ... - - -class AbstractPlanarMeasurement(AbstractMeasurement): - @abstractmethod - def to_plane(self) -> Plane: ... - - -# TODO: Multiple inheritance with NamedTuple error -# Replace by dataclass -class Measurement(NamedTuple, AbstractPlanarMeasurement): +@dataclass +class Measurement(AbstractPlanarMeasurement): """An MBQC measurement. :param angle: the angle of the measurement. Should be between [0, 2) diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index 076b50323..6cbeb9c39 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -5,16 +5,17 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Generic, TypeVar -import networkx as nx - from graphix.flow._find_cflow import find_cflow from graphix.flow._find_pflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix from graphix.flow.flow import CausalFlow, GFlow, PauliFlow -from graphix.measurements import AbstractMeasurement, AbstractPlanarMeasurement, Measurement +from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement +from graphix.measurements import Measurement if TYPE_CHECKING: from collections.abc import Collection, Mapping + import networkx as nx + from graphix.pattern import Pattern # TODO @@ -23,6 +24,7 @@ # Maybe move these definitions to graphix.fundamentals and graphix.measurements ? _M = TypeVar("_M", bound=AbstractMeasurement) +_PM = TypeVar("_PM", bound=AbstractPlanarMeasurement) @dataclass(frozen=True) @@ -199,6 +201,7 @@ def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: # return OpenGraph(g, measurements, input_nodes, output_nodes), mapping_complete + # TODO: check if nodes in input belong to open graph ? def neighbors(self, nodes: Collection[int]) -> set[int]: """Return the set containing the neighborhood of a set of nodes. @@ -235,10 +238,10 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: odd_neighbors_set ^= self.neighbors([node]) return odd_neighbors_set - def find_causal_flow(self: OpenGraph[AbstractPlanarMeasurement]) -> CausalFlow | None: + def find_causal_flow(self: OpenGraph[_PM]) -> CausalFlow | None: return find_cflow(self) - def find_gflow(self: OpenGraph[AbstractPlanarMeasurement]) -> GFlow | None: + def find_gflow(self: OpenGraph[_PM]) -> GFlow | None: aog = PlanarAlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: @@ -247,7 +250,7 @@ def find_gflow(self: OpenGraph[AbstractPlanarMeasurement]) -> GFlow | None: correction_matrix ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. - def find_pauli_flow(self: OpenGraph[AbstractMeasurement]) -> PauliFlow | None: + def find_pauli_flow(self: OpenGraph[_M]) -> PauliFlow | None: aog = AlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: diff --git a/graphix/pattern.py b/graphix/pattern.py index 2c96d77b2..ce94bf7ea 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -21,13 +21,15 @@ from graphix.clifford import Clifford from graphix.command import Command, CommandKind from graphix.fundamentals import Axis, Plane, Sign -from graphix.gflow import find_flow, find_gflow, get_layers + +# from graphix.gflow import find_flow, find_gflow, get_layers from graphix.graphsim import GraphState from graphix.measurements import Outcome, PauliMeasurement, toggle_outcome from graphix.pretty_print import OutputFormat, pattern_to_str from graphix.simulator import PatternSimulator from graphix.states import BasicStates -from graphix.visualization import GraphVisualizer + +# from graphix.visualization import GraphVisualizer if TYPE_CHECKING: from collections.abc import Container, Iterator, Mapping diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 000000000..40d6210c0 --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import networkx as nx + +from graphix.fundamentals import Plane +from graphix.opengraph_ import OpenGraph + + +class TestFlow: + + # Simple flow tests + def test_causal_flow_0(self) -> None: + og = get_linear_og() + flow = og.find_causal_flow() + + assert flow is not None + assert flow.correction_function == {0: {1}, 1: {2}, 2: {3}} + assert flow.partial_order_layers == [{3}, {2}, {1}, {0}] + + +def get_linear_og() -> OpenGraph[Plane]: + """Return linear open graph with causal flow.""" + graph: nx.Graph[int] = nx.Graph([(0, 1), (1, 2), (2, 3)]) + input_nodes = [0] + output_nodes = [3] + measurements = dict.fromkeys(range(3), Plane.XY) + + return OpenGraph(graph, measurements, input_nodes, output_nodes) diff --git a/tests/test_opengraph_.py b/tests/test_opengraph_.py new file mode 100644 index 000000000..0f91b4221 --- /dev/null +++ b/tests/test_opengraph_.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import networkx as nx + +from graphix.fundamentals import Plane +from graphix.opengraph_ import OpenGraph + + +class TestOpenGraph: + def test_odd_neighbors(self) -> None: + graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 2), (1, 3), (1, 2), (2, 3), (1, 4)]) + og = OpenGraph(graph=graph, input_nodes=[0], output_nodes=[1, 2, 3, 4], measurements={0: Plane.XY}) + + assert og.odd_neighbors([0]) == {1, 2} + assert og.odd_neighbors([0, 1]) == {0, 1, 3, 4} + assert og.odd_neighbors([1, 2, 3]) == {4} + assert og.odd_neighbors([]) == set() + + def test_neighbors(self) -> None: + graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 2), (1, 3), (1, 2), (2, 3), (1, 4)]) + og = OpenGraph(graph=graph, input_nodes=[0], output_nodes=[1, 2, 3, 4], measurements={0: Plane.XY}) + + assert og.neighbors([4]) == {1} + assert og.neighbors([0, 1]) == {0, 1, 2, 3, 4} + assert og.neighbors([1, 2, 3]) == {0, 1, 2, 3, 4} + assert og.neighbors([]) == set() From bf8607a3a0ffeea53e0d3e982546821404923fb3 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 23 Oct 2025 14:47:55 +0200 Subject: [PATCH 14/56] wip --- graphix/flow/_find_cflow.py | 14 ++-- graphix/flow/flow.py | 12 ++-- tests/test_flow.py | 125 +++++++++++++++++++++++++++++++----- 3 files changed, 126 insertions(+), 25 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 77c77d6eb..c079e1eab 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import copy from typing import TYPE_CHECKING from graphix.flow.flow import CausalFlow @@ -43,7 +44,9 @@ def find_cflow(og: OpenGraph[_PM]) -> CausalFlow | None: corrector_candidates = corrected_nodes - set(og.input_nodes) cf: dict[int, set[int]] = {} - layers: list[set[int]] = [corrected_nodes] + layers: list[set[int]] = [ + copy(corrected_nodes) + ] # A copy is necessary because `corrected_nodes` is mutable and changes during the algorithm. non_input_nodes = og.graph.nodes - set(og.input_nodes) @@ -92,12 +95,15 @@ def _flow_aux( non_corrected_nodes = og.graph.nodes - corrected_nodes + if corrected_nodes == set(og.graph.nodes): + return CausalFlow(og, cf, layers) + for p in corrector_candidates: non_corrected_neighbors = og.neighbors({p}) & non_corrected_nodes if len(non_corrected_neighbors) == 1: (q,) = non_corrected_neighbors cf[q] = {p} - curr_layer.add(p) + curr_layer.add(q) corrected_nodes_new |= {q} corrector_nodes_new |= {p} @@ -105,8 +111,8 @@ def _flow_aux( if len(corrected_nodes_new) == 0: # TODO: This is the structure in the original graphix code. I think that we could check if non_corrected_nodes == empty before the loop and here just return None. - if corrected_nodes == og.graph.nodes: - return CausalFlow(og, cf, layers) + # if corrected_nodes == set(og.graph.nodes): + # return CausalFlow(og, cf, layers) return None corrected_nodes |= corrected_nodes_new diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index 4ba070793..ceb0b83a7 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -291,8 +291,8 @@ class CausalFlow( GFlow[_PM] ): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane @override - @staticmethod - def from_correction_matrix() -> None: + @classmethod + def from_correction_matrix(cls, correction_matrix: CorrectionMatrix) -> None: raise NotImplementedError("Initialization of a causal flow from a correction matrix is not supported.") @override @@ -310,9 +310,11 @@ def to_corrections(self) -> XZCorrections[_PM]: x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} - for node, corr_set in self.correction_function.items(): - x_corrections[node].update(corr_set) - z_corrections[node].update(self.og.neighbors(corr_set) - {node}) + for corrected_node, correcting_set in self.correction_function.items(): + (correcting_node,) = correcting_set # Correcting set of a causal flow has one element only. + x_corrections[correcting_node].add(corrected_node) + for node in self.og.neighbors(correcting_set) - {corrected_node}: + z_corrections[node].add(corrected_node) return XZCorrections(self.og, x_corrections, z_corrections) diff --git a/tests/test_flow.py b/tests/test_flow.py index 40d6210c0..0591b01ab 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -1,28 +1,121 @@ from __future__ import annotations +from typing import TYPE_CHECKING, NamedTuple + import networkx as nx +import pytest -from graphix.fundamentals import Plane +from graphix.fundamentals import AbstractPlanarMeasurement, Plane +from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph +from graphix.parameter import Placeholder +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence -class TestFlow: - # Simple flow tests - def test_causal_flow_0(self) -> None: - og = get_linear_og() - flow = og.find_causal_flow() +class FlowTestCase(NamedTuple): + og: OpenGraph[AbstractPlanarMeasurement] + has_causal_flow: bool + has_gflow: bool + cf: Mapping[int, set[int]] | None = None + c_layers: Sequence[set[int]] | None = None + c_x_corr: Mapping[int, set[int]] | None = None + c_z_corr: Mapping[int, set[int]] | None = None + gf: Mapping[int, set[int]] | None = None + g_layers: Sequence[set[int]] | None = None + g_x_corr: Mapping[int, set[int]] | None = None + g_z_corr: Mapping[int, set[int]] | None = None + + +def prepare_test_flow() -> list[FlowTestCase]: + test_cases: list[FlowTestCase] = [] - assert flow is not None - assert flow.correction_function == {0: {1}, 1: {2}, 2: {3}} - assert flow.partial_order_layers == [{3}, {2}, {1}, {0}] + test_cases.extend( + ( + # Linear open graph with causal flow. + FlowTestCase( + og=OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Plane.XY), # Plane labels + ), + has_causal_flow=True, + has_gflow=True, + cf={0: {1}, 1: {2}, 2: {3}}, + c_layers=[{3}, {2}, {1}, {0}], + c_x_corr={1: {0}, 2: {1}, 3: {2}}, + c_z_corr={2: {0}, 3: {1}}, + gf={0: {1}, 1: {2}, 2: {3}}, + g_layers=[{3}, {2}, {1}, {0}], + g_x_corr={1: {0}, 2: {1}, 3: {2}}, + g_z_corr={2: {0}, 3: {1}}, + ), + # H-shaped open graph with causal flow. + FlowTestCase( + og=OpenGraph( + graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), + input_nodes=[0, 1], + output_nodes=[4, 5], + measurements=dict.fromkeys(range(3), Measurement(angle=0, plane=Plane.XY)), # Measurement labels + ), + has_causal_flow=True, + cf={0: {2}, 1: {3}, 2: {4}, 3: {5}}, + c_layers=[{4, 5}, {2, 3}, {0, 1}], + c_x_corr={2: {0}, 3: {1}, 4: {2}, 5: {3}}, + c_z_corr={3: {0}, 4: {0}, 2: {1}, 5: {1}}, + ), + # Open graph without causal flow but gflow. + FlowTestCase( + og=OpenGraph( + graph=nx.Graph([(0, 3), (0, 4), (1, 4), (2, 4)]), + input_nodes=[0], + output_nodes=[3, 4], + measurements={0: Plane.XY, 1: Plane.YZ, 2: Plane.XZ} + ), + has_causal_flow=False, + ), + # Open graph without causal flow but gflow. + FlowTestCase( + og=OpenGraph( + graph=nx.Graph([(0, 3), (0, 4), (1, 3), (1, 4), (1, 5), (2, 4), (2, 5)]), + input_nodes=[0, 1, 2], + output_nodes=[3, 4, 5], + measurements=dict.fromkeys(range(3), Plane.XY) + ), + has_causal_flow=False, + ), + # Open graph without causal flow or gflow. + FlowTestCase( + og=OpenGraph( + graph=nx.Graph([(0, 2), (1, 2), (2, 3), (2, 4)]), + input_nodes=[0, 1], + output_nodes=[3, 4], + measurements=dict.fromkeys(range(3), Measurement(angle=Placeholder('Angle'), plane=Plane.XY)), + ), + has_causal_flow=False, + ), + ) + ) + return test_cases + + +class TestFlow: + + @pytest.mark.parametrize("test_case", prepare_test_flow()) + def test_causal_flow(self, test_case: FlowTestCase) -> None: + og = test_case.og + flow = og.find_causal_flow() -def get_linear_og() -> OpenGraph[Plane]: - """Return linear open graph with causal flow.""" - graph: nx.Graph[int] = nx.Graph([(0, 1), (1, 2), (2, 3)]) - input_nodes = [0] - output_nodes = [3] - measurements = dict.fromkeys(range(3), Plane.XY) + if test_case.has_causal_flow: + assert flow is not None + assert flow.correction_function == test_case.cf + assert flow.partial_order_layers == test_case.c_layers - return OpenGraph(graph, measurements, input_nodes, output_nodes) + corrections = flow.to_corrections() + assert corrections.z_corrections == test_case.c_z_corr + assert corrections.x_corrections == test_case.c_x_corr + else: + assert flow is None From ac0826a376ae73a0ee4317b2949bc45e6a76d9cc Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 23 Oct 2025 18:00:23 +0200 Subject: [PATCH 15/56] wip --- graphix/flow/flow.py | 14 +-- tests/test_flow.py | 291 ++++++++++++++++++++++++++++++------------- 2 files changed, 211 insertions(+), 94 deletions(-) diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index ceb0b83a7..fa2ff64bf 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -259,7 +259,7 @@ def is_well_formed(self) -> bool: @dataclass(frozen=True) -class GFlow(PauliFlow[_PM]): +class GFlow(PauliFlow[_PM], Generic[_PM]): @override def to_corrections(self) -> XZCorrections[_PM]: r"""Compute the X and Z corrections induced by the generalised flow encoded in `self`. @@ -277,18 +277,18 @@ def to_corrections(self) -> XZCorrections[_PM]: x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} - for corr_node, corr_set in self.correction_function.items(): - for node in self.og.odd_neighbors(corr_set): - z_corrections[node].add(corr_node) - for node in corr_set - {corr_node}: - x_corrections[node].add(corr_node) + for corrected_node, correcting_set in self.correction_function.items(): + for correcting_node in self.og.odd_neighbors(correcting_set) - {corrected_node}: + z_corrections[correcting_node].add(corrected_node) + for correcting_node in correcting_set - {corrected_node}: + x_corrections[correcting_node].add(corrected_node) return XZCorrections(self.og, x_corrections, z_corrections) @dataclass(frozen=True) class CausalFlow( - GFlow[_PM] + GFlow[_PM], Generic[_PM] ): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane @override @classmethod diff --git a/tests/test_flow.py b/tests/test_flow.py index 0591b01ab..a3c64fc08 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -5,96 +5,223 @@ import networkx as nx import pytest +from graphix.flow.flow import CausalFlow, GFlow from graphix.fundamentals import AbstractPlanarMeasurement, Plane from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph -from graphix.parameter import Placeholder if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Mapping -class FlowTestCase(NamedTuple): - og: OpenGraph[AbstractPlanarMeasurement] - has_causal_flow: bool - has_gflow: bool - cf: Mapping[int, set[int]] | None = None - c_layers: Sequence[set[int]] | None = None - c_x_corr: Mapping[int, set[int]] | None = None - c_z_corr: Mapping[int, set[int]] | None = None - gf: Mapping[int, set[int]] | None = None - g_layers: Sequence[set[int]] | None = None - g_x_corr: Mapping[int, set[int]] | None = None - g_z_corr: Mapping[int, set[int]] | None = None +def generate_causal_flow_0() -> CausalFlow[Plane]: + """Generate causal flow on linear open graph. + Open graph structure: -def prepare_test_flow() -> list[FlowTestCase]: - test_cases: list[FlowTestCase] = [] + [0]-1-2-(3) - test_cases.extend( - ( - # Linear open graph with causal flow. - FlowTestCase( - og=OpenGraph( - graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), - input_nodes=[0], - output_nodes=[3], - measurements=dict.fromkeys(range(3), Plane.XY), # Plane labels - ), - has_causal_flow=True, - has_gflow=True, - cf={0: {1}, 1: {2}, 2: {3}}, - c_layers=[{3}, {2}, {1}, {0}], - c_x_corr={1: {0}, 2: {1}, 3: {2}}, - c_z_corr={2: {0}, 3: {1}}, - gf={0: {1}, 1: {2}, 2: {3}}, - g_layers=[{3}, {2}, {1}, {0}], - g_x_corr={1: {0}, 2: {1}, 3: {2}}, - g_z_corr={2: {0}, 3: {1}}, - ), - # H-shaped open graph with causal flow. - FlowTestCase( - og=OpenGraph( + Causal flow: + c(0) = 1, c(1) = 2, c(2) = 3 + {3} > {2} > {1} > {0} + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Plane.XY), + ) + return CausalFlow( + og=og, + correction_function={0: {1}, 1: {2}, 2: {3}}, + partial_order_layers=[{3}, {2}, {1}, {0}], + ) + + +def generate_causal_flow_1() -> CausalFlow[Measurement]: + """Generate causal flow on H-shaped open graph. + + Open graph structure: + + [0]-2-(4) + | + [1]-3-(5) + + Causal flow: + c(0) = 2, c(1) = 3, c(2) = 4, c(3) = 5 + {4, 5} > {2, 3} > {0, 1} + """ + og = OpenGraph( graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), input_nodes=[0, 1], output_nodes=[4, 5], - measurements=dict.fromkeys(range(3), Measurement(angle=0, plane=Plane.XY)), # Measurement labels - ), - has_causal_flow=True, - cf={0: {2}, 1: {3}, 2: {4}, 3: {5}}, - c_layers=[{4, 5}, {2, 3}, {0, 1}], - c_x_corr={2: {0}, 3: {1}, 4: {2}, 5: {3}}, - c_z_corr={3: {0}, 4: {0}, 2: {1}, 5: {1}}, - ), - # Open graph without causal flow but gflow. - FlowTestCase( - og=OpenGraph( + measurements=dict.fromkeys(range(4), Measurement(angle=0, plane=Plane.XY))) + return CausalFlow( + og=og, + correction_function={0: {2}, 1: {3}, 2: {4}, 3: {5}}, + partial_order_layers=[{4, 5}, {2, 3}, {0, 1}], + ) + + +def generate_gflow_0() -> GFlow[Measurement]: + """Generate gflow on H-shaped open graph. + + Open graph structure: + + [0]-2-(4) + | + [1]-3-(5) + + GFlow: + g(0) = {2, 5}, g(1) = {3, 4}, g(2) = {4}, g(3) = {5} + {4, 5} > {0, 1, 2, 3} + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), + input_nodes=[0, 1], + output_nodes=[4, 5], + measurements=dict.fromkeys(range(4), Measurement(angle=0, plane=Plane.XY))) + return GFlow( + og=og, + correction_function={0: {2, 5}, 1: {3, 4}, 2: {4}, 3: {5}}, + partial_order_layers=[{4, 5}, {0, 1, 2, 3}], + ) + + +def generate_gflow_1() -> GFlow[Plane]: + r"""Generate gflow on open graph without causal flow. + + Open graph structure: + + 1 + \ + (4)-[0]-(3) + / + 2 + + GFlow: + g(0) = {3}, g(1) = {1}, g(2) = {2, 3, 4} + {3, 4} > {1} > {0, 2} + """ + og = OpenGraph( graph=nx.Graph([(0, 3), (0, 4), (1, 4), (2, 4)]), input_nodes=[0], output_nodes=[3, 4], - measurements={0: Plane.XY, 1: Plane.YZ, 2: Plane.XZ} - ), - has_causal_flow=False, - ), - # Open graph without causal flow but gflow. - FlowTestCase( - og=OpenGraph( + measurements={0: Plane.XY, 1: Plane.YZ, 2: Plane.XZ}, + ) + return GFlow( + og=og, + correction_function={0: {3}, 1: {1}, 2: {2, 3, 4}}, + partial_order_layers=[{3, 4}, {1}, {0, 2}], + ) + + +def generate_gflow_2() -> GFlow[Plane]: + r"""Generate gflow on open graph without causal flow. + + Open graph structure: + + [0]-(3) + X + [1]-(4) + X + [2]-(5) + + GFlow: + g(0) = {4, 5}, g(1) = {3, 4, 5}, g(2) = {3, 4} + {3, 4, 5} > {0, 1, 2} + """ + og = OpenGraph( graph=nx.Graph([(0, 3), (0, 4), (1, 3), (1, 4), (1, 5), (2, 4), (2, 5)]), input_nodes=[0, 1, 2], output_nodes=[3, 4, 5], - measurements=dict.fromkeys(range(3), Plane.XY) - ), - has_causal_flow=False, + measurements=dict.fromkeys(range(3), Plane.XY), + ) + return GFlow( + og=og, + correction_function={0: {4, 5}, 1: {3, 4, 5}, 2: {3, 4}}, + partial_order_layers=[{3, 4}, {1}, {0, 2}], + ) + + +def prepare_test_causal_flow() -> list[CausalFlow[AbstractPlanarMeasurement]]: + return [generate_causal_flow_0(), generate_causal_flow_1()] + + +def prepare_test_gflow() -> list[GFlow[AbstractPlanarMeasurement]]: + return [generate_gflow_0(), generate_gflow_1(), generate_gflow_2()] + + # # Open graph without causal flow or gflow. + # FlowTestCase( + # og=OpenGraph( + # graph=nx.Graph([(0, 2), (1, 2), (2, 3), (2, 4)]), + # input_nodes=[0, 1], + # output_nodes=[3, 4], + # measurements=dict.fromkeys(range(3), Measurement(angle=Placeholder("Angle"), plane=Plane.XY)), + # ), + # has_causal_flow=False, + # has_gflow=False, + # ), + # ) + # ) + + +class TestFlow: + @pytest.mark.parametrize("test_case", prepare_test_causal_flow()) + def test_causal_flow(self, test_case: CausalFlow[AbstractPlanarMeasurement]) -> None: + flow = test_case.og.find_causal_flow() + + assert flow is not None + assert flow.correction_function == test_case.correction_function + assert flow.partial_order_layers == test_case.partial_order_layers + + # @pytest.mark.parametrize("test_case", prepare_test_gflow()) + # def test_gflow(self, test_case: GFlow[AbstractPlanarMeasurement]) -> None: + # flow = test_case.og.find_gflow() + + # assert flow is not None + # assert flow.correction_function == test_case.correction_function + # assert flow.partial_order_layers == test_case.partial_order_layers + + +class XZCorrectionsTestCase(NamedTuple): + flow: CausalFlow[AbstractPlanarMeasurement] | GFlow[AbstractPlanarMeasurement] + x_corr: Mapping[int, set[int]] + z_corr: Mapping[int, set[int]] + +# TODO: add pattern, add dag + + +def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: + test_cases: list[XZCorrectionsTestCase] = [] + + test_cases.extend( + ( + XZCorrectionsTestCase( + flow=generate_causal_flow_0(), + x_corr={1: {0}, 2: {1}, 3: {2}}, + z_corr={2: {0}, 3: {1}}, ), - # Open graph without causal flow or gflow. - FlowTestCase( - og=OpenGraph( - graph=nx.Graph([(0, 2), (1, 2), (2, 3), (2, 4)]), - input_nodes=[0, 1], - output_nodes=[3, 4], - measurements=dict.fromkeys(range(3), Measurement(angle=Placeholder('Angle'), plane=Plane.XY)), - ), - has_causal_flow=False, + XZCorrectionsTestCase( + flow=generate_causal_flow_1(), + x_corr={2: {0}, 3: {1}, 4: {2}, 5: {3}}, + z_corr={3: {0}, 4: {0}, 2: {1}, 5: {1}}, + ), + # Same open graph as before but now we consider a gflow which has lower depth than the causal flow. + XZCorrectionsTestCase( + flow=generate_gflow_0(), + x_corr={2: {0}, 5: {0, 3}, 3: {1}, 4: {1, 2}}, + z_corr={4: {0}, 5: {1}}, + ), + XZCorrectionsTestCase( + flow=generate_gflow_1(), + x_corr={3: {0, 2}, 4: {2}}, + z_corr={1: {2}, 4: {1, 2}}, + ), + XZCorrectionsTestCase( + flow=generate_gflow_2(), + x_corr={3: {1, 2}, 4: {0, 1, 2}, 5: {0, 1}}, + z_corr={}, ), ) ) @@ -102,20 +229,10 @@ def prepare_test_flow() -> list[FlowTestCase]: return test_cases -class TestFlow: - - @pytest.mark.parametrize("test_case", prepare_test_flow()) - def test_causal_flow(self, test_case: FlowTestCase) -> None: - og = test_case.og - flow = og.find_causal_flow() - - if test_case.has_causal_flow: - assert flow is not None - assert flow.correction_function == test_case.cf - assert flow.partial_order_layers == test_case.c_layers - - corrections = flow.to_corrections() - assert corrections.z_corrections == test_case.c_z_corr - assert corrections.x_corrections == test_case.c_x_corr - else: - assert flow is None +class TestXZCorrections: + @pytest.mark.parametrize("test_case", prepare_test_xzcorrections()) + def test_causal_flow(self, test_case: XZCorrectionsTestCase) -> None: + flow = test_case.flow + corrections = flow.to_corrections() + assert corrections.z_corrections == test_case.z_corr + assert corrections.x_corrections == test_case.x_corr From 6a7e84a85d0bbb677ba0ed9d9092ccef1f241ac6 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 27 Oct 2025 10:49:39 +0100 Subject: [PATCH 16/56] to corrections working --- graphix/__init__.py | 14 +-- graphix/flow/_find_pflow.py | 38 +++---- graphix/flow/flow.py | 37 +++---- graphix/fundamentals.py | 1 - graphix/opengraph_.py | 14 +-- graphix/pattern.py | 6 +- tests/test_flow.py | 192 +++++++++++++++++++++--------------- 7 files changed, 169 insertions(+), 133 deletions(-) diff --git a/graphix/__init__.py b/graphix/__init__.py index 97c369473..782a7ef7b 100644 --- a/graphix/__init__.py +++ b/graphix/__init__.py @@ -1,11 +1,11 @@ """Optimize and simulate measurement-based quantum computation.""" -# from __future__ import annotations +from __future__ import annotations -# # from graphix.generator import generate_from_graph -# from graphix.graphsim import GraphState -# from graphix.pattern import Pattern -# from graphix.sim.statevec import Statevec -# from graphix.transpiler import Circuit +from graphix.generator import generate_from_graph +from graphix.graphsim import GraphState +from graphix.pattern import Pattern +from graphix.sim.statevec import Statevec +from graphix.transpiler import Circuit -# __all__ = ["Circuit", "GraphState", "Pattern", "Statevec"] # , "generate_from_graph"] +__all__ = ["Circuit", "GraphState", "Pattern", "Statevec", "generate_from_graph"] diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_pflow.py index 6a0d585a5..00cedfc11 100644 --- a/graphix/flow/_find_pflow.py +++ b/graphix/flow/_find_pflow.py @@ -28,11 +28,11 @@ from graphix.opengraph_ import OpenGraph -_M = TypeVar("_M", bound=AbstractMeasurement) -_PM = TypeVar("_PM", bound=AbstractPlanarMeasurement) +_M_co = TypeVar("_M_co", bound=AbstractMeasurement, covariant=True) +_PM_co = TypeVar("_PM_co", bound=AbstractPlanarMeasurement, covariant=True) -class AlgebraicOpenGraph(Generic[_M]): +class AlgebraicOpenGraph(Generic[_M_co]): """A class for providing an algebraic representation of open graphs as introduced in [1]. In particular, it allows managing the mapping between node labels of the graph and the relevant matrix indices. The flow demand and order demand matrices appear as cached properties. It reuses the class `:class: graphix.sim.base_backend.NodeIndex` introduced for managing the mapping between node numbers and qubit indices in the internal state of the backend. @@ -53,7 +53,7 @@ class AlgebraicOpenGraph(Generic[_M]): [1] Mitosek and Backens, 2024 (arXiv:2410.23439). """ - def __init__(self, og: OpenGraph[_M]) -> None: + def __init__(self, og: OpenGraph[_M_co]) -> None: self.og = og nodes = set(og.graph.nodes) @@ -153,7 +153,7 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: return flow_demand_matrix, order_demand_matrix -class PlanarAlgebraicOpenGraph(AlgebraicOpenGraph[_PM]): +class PlanarAlgebraicOpenGraph(AlgebraicOpenGraph[_PM_co]): @cached_property def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: r"""Construct flow-demand and order-demand matrices assuming that the underlying open graph has planar measurements only. @@ -193,7 +193,7 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: return flow_demand_matrix, order_demand_matrix -class CorrectionMatrix(NamedTuple, Generic[_M]): +class CorrectionMatrix(NamedTuple, Generic[_M_co]): r"""A dataclass to bundle the correction matrix and the open graph to which it refers. Attributes @@ -208,13 +208,13 @@ class CorrectionMatrix(NamedTuple, Generic[_M]): See Definition 3.6 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - aog: AlgebraicOpenGraph[_M] + aog: AlgebraicOpenGraph[_M_co] c_matrix: MatGF2 @staticmethod def from_correction_function( - og: OpenGraph[_M], correction_function: Mapping[int, set[int]] - ) -> CorrectionMatrix[_M]: + og: OpenGraph[_M_co], correction_function: Mapping[int, set[int]] + ) -> CorrectionMatrix[_M_co]: r"""Initialise a `CorrectionMatrix` object from a correction function. Parameters @@ -266,7 +266,7 @@ def to_correction_function(self) -> dict[int, set[int]]: return correction_function -def _compute_p_matrix(aog: AlgebraicOpenGraph[_M], nb_matrix: MatGF2) -> MatGF2 | None: +def _compute_p_matrix(aog: AlgebraicOpenGraph[_M_co], nb_matrix: MatGF2) -> MatGF2 | None: r"""Perform the steps 8 - 12 of the general case (larger number of outputs than inputs) algorithm. Parameters @@ -317,7 +317,7 @@ def _compute_p_matrix(aog: AlgebraicOpenGraph[_M], nb_matrix: MatGF2) -> MatGF2 def _find_solvable_nodes( - aog: AlgebraicOpenGraph[_M], + aog: AlgebraicOpenGraph[_M_co], kls_matrix: MatGF2, non_outputs_set: AbstractSet[int], solved_nodes: AbstractSet[int], @@ -349,7 +349,11 @@ def _find_solvable_nodes( def _update_p_matrix( - aog: AlgebraicOpenGraph[_M], kls_matrix: MatGF2, p_matrix: MatGF2, solvable_nodes: AbstractSet[int], n_oi_diff: int + aog: AlgebraicOpenGraph[_M_co], + kls_matrix: MatGF2, + p_matrix: MatGF2, + solvable_nodes: AbstractSet[int], + n_oi_diff: int, ) -> None: """Update `p_matrix`. @@ -367,7 +371,7 @@ def _update_p_matrix( def _update_kls_matrix( - aog: AlgebraicOpenGraph[_M], + aog: AlgebraicOpenGraph[_M_co], kls_matrix: MatGF2, kils_matrix: MatGF2, solvable_nodes: AbstractSet[int], @@ -463,7 +467,7 @@ def reorder(old_pos: int, new_pos: int) -> None: # Used in step 12.d.vi def _compute_correction_matrix_general_case( - aog: AlgebraicOpenGraph[_M], flow_demand_matrix: MatGF2, order_demand_matrix: MatGF2 + aog: AlgebraicOpenGraph[_M_co], flow_demand_matrix: MatGF2, order_demand_matrix: MatGF2 ) -> MatGF2 | None: r"""Construct the generalized correction matrix :math:`C'C^B` for an open graph with larger number of outputs than inputs. @@ -585,7 +589,7 @@ def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] return generations -def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M]) -> list[set[int]] | None: +def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> list[set[int]] | None: r"""Compute the partial order compatible with the correction matrix if it exists. Parameters @@ -627,7 +631,7 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M]) -> lis return layers -def compute_correction_matrix(aog: AlgebraicOpenGraph[_M]) -> CorrectionMatrix[_M] | None: +def compute_correction_matrix(aog: AlgebraicOpenGraph[_M_co]) -> CorrectionMatrix[_M_co] | None: """Return the correction matrix of the input open graph if it exists. Parameters @@ -672,4 +676,4 @@ def compute_correction_matrix(aog: AlgebraicOpenGraph[_M]) -> CorrectionMatrix[_ return CorrectionMatrix(aog, correction_matrix) -# TODO: When should inputs be parametrized with `_M` and when with `AbstractMeasurement` ? +# TODO: When should inputs be parametrized with `_M_co` and when with `AbstractMeasurement` ? diff --git a/graphix/flow/flow.py b/graphix/flow/flow.py index fa2ff64bf..bfc6c5e9d 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/flow.py @@ -13,7 +13,7 @@ from graphix._linalg import MatGF2 from graphix.command import E, M, N, X, Z -from graphix.flow._find_pflow import _M, _PM, CorrectionMatrix, compute_partial_order_layers +from graphix.flow._find_pflow import CorrectionMatrix, _M_co, _PM_co, compute_partial_order_layers from graphix.fundamentals import Axis, Plane, Sign from graphix.pattern import Pattern @@ -29,8 +29,8 @@ @dataclass(frozen=True) -class XZCorrections(Generic[_M]): - og: OpenGraph[_M] +class XZCorrections(Generic[_M_co]): + og: OpenGraph[_M_co] x_corrections: dict[int, set[int]] # {node: domain} z_corrections: dict[int, set[int]] # {node: domain} @@ -160,8 +160,8 @@ def to_pattern( @dataclass(frozen=True) -class PauliFlow(Generic[_M]): - og: OpenGraph[_M] +class PauliFlow(Generic[_M_co]): + og: OpenGraph[_M_co] correction_function: Mapping[int, set[int]] partial_order_layers: Sequence[AbstractSet[int]] @@ -175,12 +175,12 @@ def from_correction_matrix(cls, correction_matrix: CorrectionMatrix) -> Self | N return cls(correction_matrix.aog.og, correction_function, partial_order_layers) - def to_corrections(self) -> XZCorrections[_M]: + def to_corrections(self) -> XZCorrections[_M_co]: """Compute the X and Z corrections induced by the Pauli flow encoded in `self`. Returns ------- - Corrections[_M] + Corrections[_M_co] Notes ----- @@ -191,11 +191,12 @@ def to_corrections(self) -> XZCorrections[_M]: z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} for layer in self.partial_order_layers[1:]: - for node in layer: - corr_set = self.correction_function[node] - x_corrections[node].update(corr_set & future) - z_corrections[node].update(self.og.odd_neighbors(corr_set) & future) - + for corrected_node in layer: + correcting_set = self.correction_function[corrected_node] + for correcting_node in correcting_set & future: + x_corrections[correcting_node].add(corrected_node) + for correcting_node in self.og.odd_neighbors(correcting_set) & future: + z_corrections[correcting_node].add(corrected_node) future |= layer return XZCorrections(self.og, x_corrections, z_corrections) @@ -259,9 +260,9 @@ def is_well_formed(self) -> bool: @dataclass(frozen=True) -class GFlow(PauliFlow[_PM], Generic[_PM]): +class GFlow(PauliFlow[_PM_co], Generic[_PM_co]): @override - def to_corrections(self) -> XZCorrections[_PM]: + def to_corrections(self) -> XZCorrections[_PM_co]: r"""Compute the X and Z corrections induced by the generalised flow encoded in `self`. Returns @@ -278,17 +279,17 @@ def to_corrections(self) -> XZCorrections[_PM]: z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} for corrected_node, correcting_set in self.correction_function.items(): - for correcting_node in self.og.odd_neighbors(correcting_set) - {corrected_node}: - z_corrections[correcting_node].add(corrected_node) for correcting_node in correcting_set - {corrected_node}: x_corrections[correcting_node].add(corrected_node) + for correcting_node in self.og.odd_neighbors(correcting_set) - {corrected_node}: + z_corrections[correcting_node].add(corrected_node) return XZCorrections(self.og, x_corrections, z_corrections) @dataclass(frozen=True) class CausalFlow( - GFlow[_PM], Generic[_PM] + GFlow[_PM_co], Generic[_PM_co] ): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane @override @classmethod @@ -296,7 +297,7 @@ def from_correction_matrix(cls, correction_matrix: CorrectionMatrix) -> None: raise NotImplementedError("Initialization of a causal flow from a correction matrix is not supported.") @override - def to_corrections(self) -> XZCorrections[_PM]: + def to_corrections(self) -> XZCorrections[_PM_co]: r"""Compute the X and Z corrections induced by the causal flow encoded in `self`. Returns diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index c599b09d4..ed0e24fd6 100644 --- a/graphix/fundamentals.py +++ b/graphix/fundamentals.py @@ -229,7 +229,6 @@ class AbstractPlanarMeasurement(AbstractMeasurement): def to_plane(self) -> Plane: ... -# TODO Conflicts with Enum class Axis(AbstractMeasurement, EnumReprMixin, Enum, metaclass=CustomMeta): """Axis: *X*, *Y* or *Z*.""" diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index 6cbeb9c39..fa64e32e6 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -23,12 +23,12 @@ # Otherwise, shall we define Plane.XY-only open graphs. # Maybe move these definitions to graphix.fundamentals and graphix.measurements ? -_M = TypeVar("_M", bound=AbstractMeasurement) -_PM = TypeVar("_PM", bound=AbstractPlanarMeasurement) +_M_co = TypeVar("_M_co", bound=AbstractMeasurement, covariant=True) +_PM_co = TypeVar("_PM_co", bound=AbstractPlanarMeasurement, covariant=True) @dataclass(frozen=True) -class OpenGraph(Generic[_M]): +class OpenGraph(Generic[_M_co]): """Open graph contains the graph, measurement, and input and output nodes. This is the graph we wish to implement deterministically. @@ -54,7 +54,7 @@ class OpenGraph(Generic[_M]): """ graph: nx.Graph[int] - measurements: Mapping[int, _M] # TODO: Rename `measurement_labels` ? + measurements: Mapping[int, _M_co] # TODO: Rename `measurement_labels` ? input_nodes: list[int] # Inputs are ordered output_nodes: list[int] # Outputs are ordered @@ -238,10 +238,10 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: odd_neighbors_set ^= self.neighbors([node]) return odd_neighbors_set - def find_causal_flow(self: OpenGraph[_PM]) -> CausalFlow | None: + def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow | None: return find_cflow(self) - def find_gflow(self: OpenGraph[_PM]) -> GFlow | None: + def find_gflow(self: OpenGraph[_PM_co]) -> GFlow | None: aog = PlanarAlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: @@ -250,7 +250,7 @@ def find_gflow(self: OpenGraph[_PM]) -> GFlow | None: correction_matrix ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. - def find_pauli_flow(self: OpenGraph[_M]) -> PauliFlow | None: + def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow | None: aog = AlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: diff --git a/graphix/pattern.py b/graphix/pattern.py index ce94bf7ea..2c96d77b2 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -21,15 +21,13 @@ from graphix.clifford import Clifford from graphix.command import Command, CommandKind from graphix.fundamentals import Axis, Plane, Sign - -# from graphix.gflow import find_flow, find_gflow, get_layers +from graphix.gflow import find_flow, find_gflow, get_layers from graphix.graphsim import GraphState from graphix.measurements import Outcome, PauliMeasurement, toggle_outcome from graphix.pretty_print import OutputFormat, pattern_to_str from graphix.simulator import PatternSimulator from graphix.states import BasicStates - -# from graphix.visualization import GraphVisualizer +from graphix.visualization import GraphVisualizer if TYPE_CHECKING: from collections.abc import Container, Iterator, Mapping diff --git a/tests/test_flow.py b/tests/test_flow.py index a3c64fc08..cb1433c4b 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -5,8 +5,8 @@ import networkx as nx import pytest -from graphix.flow.flow import CausalFlow, GFlow -from graphix.fundamentals import AbstractPlanarMeasurement, Plane +from graphix.flow.flow import CausalFlow, GFlow, PauliFlow +from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph @@ -26,16 +26,16 @@ def generate_causal_flow_0() -> CausalFlow[Plane]: {3} > {2} > {1} > {0} """ og = OpenGraph( - graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), - input_nodes=[0], - output_nodes=[3], - measurements=dict.fromkeys(range(3), Plane.XY), - ) + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Plane.XY), + ) return CausalFlow( - og=og, - correction_function={0: {1}, 1: {2}, 2: {3}}, - partial_order_layers=[{3}, {2}, {1}, {0}], - ) + og=og, + correction_function={0: {1}, 1: {2}, 2: {3}}, + partial_order_layers=[{3}, {2}, {1}, {0}], + ) def generate_causal_flow_1() -> CausalFlow[Measurement]: @@ -52,15 +52,16 @@ def generate_causal_flow_1() -> CausalFlow[Measurement]: {4, 5} > {2, 3} > {0, 1} """ og = OpenGraph( - graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), - input_nodes=[0, 1], - output_nodes=[4, 5], - measurements=dict.fromkeys(range(4), Measurement(angle=0, plane=Plane.XY))) + graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), + input_nodes=[0, 1], + output_nodes=[4, 5], + measurements=dict.fromkeys(range(4), Measurement(angle=0, plane=Plane.XY)), + ) return CausalFlow( - og=og, - correction_function={0: {2}, 1: {3}, 2: {4}, 3: {5}}, - partial_order_layers=[{4, 5}, {2, 3}, {0, 1}], - ) + og=og, + correction_function={0: {2}, 1: {3}, 2: {4}, 3: {5}}, + partial_order_layers=[{4, 5}, {2, 3}, {0, 1}], + ) def generate_gflow_0() -> GFlow[Measurement]: @@ -75,17 +76,22 @@ def generate_gflow_0() -> GFlow[Measurement]: GFlow: g(0) = {2, 5}, g(1) = {3, 4}, g(2) = {4}, g(3) = {5} {4, 5} > {0, 1, 2, 3} + + Notes + ----- + This is the same open graph as in `:func: generate_causal_flow_1` but now we consider a gflow which has lower depth than the causal flow. """ og = OpenGraph( - graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), - input_nodes=[0, 1], - output_nodes=[4, 5], - measurements=dict.fromkeys(range(4), Measurement(angle=0, plane=Plane.XY))) + graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), + input_nodes=[0, 1], + output_nodes=[4, 5], + measurements=dict.fromkeys(range(4), Measurement(angle=0, plane=Plane.XY)), + ) return GFlow( - og=og, - correction_function={0: {2, 5}, 1: {3, 4}, 2: {4}, 3: {5}}, - partial_order_layers=[{4, 5}, {0, 1, 2, 3}], - ) + og=og, + correction_function={0: {2, 5}, 1: {3, 4}, 2: {4}, 3: {5}}, + partial_order_layers=[{4, 5}, {0, 1, 2, 3}], + ) def generate_gflow_1() -> GFlow[Plane]: @@ -104,16 +110,16 @@ def generate_gflow_1() -> GFlow[Plane]: {3, 4} > {1} > {0, 2} """ og = OpenGraph( - graph=nx.Graph([(0, 3), (0, 4), (1, 4), (2, 4)]), - input_nodes=[0], - output_nodes=[3, 4], - measurements={0: Plane.XY, 1: Plane.YZ, 2: Plane.XZ}, - ) + graph=nx.Graph([(0, 3), (0, 4), (1, 4), (2, 4)]), + input_nodes=[0], + output_nodes=[3, 4], + measurements={0: Plane.XY, 1: Plane.YZ, 2: Plane.XZ}, + ) return GFlow( - og=og, - correction_function={0: {3}, 1: {1}, 2: {2, 3, 4}}, - partial_order_layers=[{3, 4}, {1}, {0, 2}], - ) + og=og, + correction_function={0: {3}, 1: {1}, 2: {2, 3, 4}}, + partial_order_layers=[{3, 4}, {1}, {0, 2}], + ) def generate_gflow_2() -> GFlow[Plane]: @@ -132,65 +138,81 @@ def generate_gflow_2() -> GFlow[Plane]: {3, 4, 5} > {0, 1, 2} """ og = OpenGraph( - graph=nx.Graph([(0, 3), (0, 4), (1, 3), (1, 4), (1, 5), (2, 4), (2, 5)]), - input_nodes=[0, 1, 2], - output_nodes=[3, 4, 5], - measurements=dict.fromkeys(range(3), Plane.XY), + graph=nx.Graph([(0, 3), (0, 4), (1, 3), (1, 4), (1, 5), (2, 4), (2, 5)]), + input_nodes=[0, 1, 2], + output_nodes=[3, 4, 5], + measurements=dict.fromkeys(range(3), Plane.XY), ) return GFlow( - og=og, - correction_function={0: {4, 5}, 1: {3, 4, 5}, 2: {3, 4}}, - partial_order_layers=[{3, 4}, {1}, {0, 2}], - ) + og=og, + correction_function={0: {4, 5}, 1: {3, 4, 5}, 2: {3, 4}}, + partial_order_layers=[{3, 4}, {1}, {0, 2}], + ) -def prepare_test_causal_flow() -> list[CausalFlow[AbstractPlanarMeasurement]]: - return [generate_causal_flow_0(), generate_causal_flow_1()] +def generate_pauli_flow_0() -> PauliFlow[Axis]: + """Generate Pauli flow on linear open graph. + Open graph structure: -def prepare_test_gflow() -> list[GFlow[AbstractPlanarMeasurement]]: - return [generate_gflow_0(), generate_gflow_1(), generate_gflow_2()] + [0]-1-2-(3) - # # Open graph without causal flow or gflow. - # FlowTestCase( - # og=OpenGraph( - # graph=nx.Graph([(0, 2), (1, 2), (2, 3), (2, 4)]), - # input_nodes=[0, 1], - # output_nodes=[3, 4], - # measurements=dict.fromkeys(range(3), Measurement(angle=Placeholder("Angle"), plane=Plane.XY)), - # ), - # has_causal_flow=False, - # has_gflow=False, - # ), - # ) - # ) + Pauli flow: + p(0) = {1, 3}, p(1) = {2}, p(2) = {3} + {3} > {0, 1, 2} + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Axis.X), + ) + return PauliFlow( + og=og, + correction_function={0: {1, 3}, 1: {2}, 2: {3}}, + partial_order_layers=[{3}, {0, 1, 2}], + ) -class TestFlow: - @pytest.mark.parametrize("test_case", prepare_test_causal_flow()) - def test_causal_flow(self, test_case: CausalFlow[AbstractPlanarMeasurement]) -> None: - flow = test_case.og.find_causal_flow() +def generate_pauli_flow_1() -> PauliFlow[Measurement]: + """Generate Pauli flow on double-H-shaped open graph. - assert flow is not None - assert flow.correction_function == test_case.correction_function - assert flow.partial_order_layers == test_case.partial_order_layers + Open graph structure: - # @pytest.mark.parametrize("test_case", prepare_test_gflow()) - # def test_gflow(self, test_case: GFlow[AbstractPlanarMeasurement]) -> None: - # flow = test_case.og.find_gflow() + [0]-2-4-(6) + | | + [1]-3-5-(7) - # assert flow is not None - # assert flow.correction_function == test_case.correction_function - # assert flow.partial_order_layers == test_case.partial_order_layers + Pauli flow: + p(0) = {2, 5, 7}, p(1) = {3, 4}, p(2) = {4, 7}, p(3) = {5, 6, 7}, + p(4) = {6}, p(5) = 7 + {6, 7} > {3} > {0, 1, 2, 4, 5} + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]), + input_nodes=[0, 1], + output_nodes=[6, 7], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XY), # XY + 2: Measurement(0.0, Plane.XY), # X + 3: Measurement(0.1, Plane.XY), # XY + 4: Measurement(0.0, Plane.XY), # X + 5: Measurement(0.5, Plane.XY), # Y + }, + ) + return PauliFlow( + og=og, + correction_function={0: {2, 5, 7}, 1: {3, 4}, 2: {4, 7}, 3: {5, 6, 7}, 4: {6}, 5: {7}}, + partial_order_layers=[{6, 7}, {3}, {0, 1, 2, 4, 5}], + ) class XZCorrectionsTestCase(NamedTuple): - flow: CausalFlow[AbstractPlanarMeasurement] | GFlow[AbstractPlanarMeasurement] + flow: CausalFlow[AbstractPlanarMeasurement] | GFlow[AbstractPlanarMeasurement] | PauliFlow[AbstractMeasurement] x_corr: Mapping[int, set[int]] z_corr: Mapping[int, set[int]] -# TODO: add pattern, add dag - def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: test_cases: list[XZCorrectionsTestCase] = [] @@ -207,7 +229,6 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: x_corr={2: {0}, 3: {1}, 4: {2}, 5: {3}}, z_corr={3: {0}, 4: {0}, 2: {1}, 5: {1}}, ), - # Same open graph as before but now we consider a gflow which has lower depth than the causal flow. XZCorrectionsTestCase( flow=generate_gflow_0(), x_corr={2: {0}, 5: {0, 3}, 3: {1}, 4: {1, 2}}, @@ -223,6 +244,16 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: x_corr={3: {1, 2}, 4: {0, 1, 2}, 5: {0, 1}}, z_corr={}, ), + XZCorrectionsTestCase( + flow=generate_pauli_flow_0(), + x_corr={3: {0, 2}}, + z_corr={3: {1}}, + ), + XZCorrectionsTestCase( + flow=generate_pauli_flow_1(), + x_corr={3: {1}, 6: {3, 4}, 7: {0, 2, 3, 5}}, + z_corr={6: {1, 2}, 7: {0, 3}}, + ), ) ) @@ -231,8 +262,11 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: class TestXZCorrections: @pytest.mark.parametrize("test_case", prepare_test_xzcorrections()) - def test_causal_flow(self, test_case: XZCorrectionsTestCase) -> None: + def test_flow_to_corrections(self, test_case: XZCorrectionsTestCase) -> None: flow = test_case.flow corrections = flow.to_corrections() assert corrections.z_corrections == test_case.z_corr assert corrections.x_corrections == test_case.x_corr + + +# TODO: add pattern, add dag, order From 5deab85fd6c29bd99f292620ab68e97d66412b79 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 27 Oct 2025 12:02:59 +0100 Subject: [PATCH 17/56] Adapt algebraic open graph tests --- graphix/flow/_find_cflow.py | 8 +- .../flow/{_find_pflow.py => _find_gpflow.py} | 0 graphix/flow/{flow.py => core.py} | 2 +- graphix/opengraph_.py | 4 +- tests/{test_flow.py => test_flow_core.py} | 2 +- tests/test_flow_find_gpflow.py | 281 ++++++++++++++++++ 6 files changed, 289 insertions(+), 8 deletions(-) rename graphix/flow/{_find_pflow.py => _find_gpflow.py} (100%) rename graphix/flow/{flow.py => core.py} (99%) rename tests/{test_flow.py => test_flow_core.py} (99%) create mode 100644 tests/test_flow_find_gpflow.py diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index c079e1eab..599712432 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -3,18 +3,18 @@ from copy import copy from typing import TYPE_CHECKING -from graphix.flow.flow import CausalFlow +from graphix.flow.core import CausalFlow from graphix.fundamentals import Plane if TYPE_CHECKING: from collections.abc import Set as AbstractSet - from graphix.opengraph_ import _PM, OpenGraph + from graphix.opengraph_ import OpenGraph, _PM_co # TODO: Up doc strings -def find_cflow(og: OpenGraph[_PM]) -> CausalFlow | None: +def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow | None: """Return the causal flow of the input open graph if it exists. Parameters @@ -54,7 +54,7 @@ def find_cflow(og: OpenGraph[_PM]) -> CausalFlow | None: def _flow_aux( - og: OpenGraph[_PM], + og: OpenGraph[_PM_co], non_input_nodes: AbstractSet[int], corrected_nodes: AbstractSet[int], corrector_candidates: AbstractSet[int], diff --git a/graphix/flow/_find_pflow.py b/graphix/flow/_find_gpflow.py similarity index 100% rename from graphix/flow/_find_pflow.py rename to graphix/flow/_find_gpflow.py diff --git a/graphix/flow/flow.py b/graphix/flow/core.py similarity index 99% rename from graphix/flow/flow.py rename to graphix/flow/core.py index bfc6c5e9d..ed217e819 100644 --- a/graphix/flow/flow.py +++ b/graphix/flow/core.py @@ -13,7 +13,7 @@ from graphix._linalg import MatGF2 from graphix.command import E, M, N, X, Z -from graphix.flow._find_pflow import CorrectionMatrix, _M_co, _PM_co, compute_partial_order_layers +from graphix.flow._find_gpflow import CorrectionMatrix, _M_co, _PM_co, compute_partial_order_layers from graphix.fundamentals import Axis, Plane, Sign from graphix.pattern import Pattern diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index fa64e32e6..4162235ea 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, Generic, TypeVar from graphix.flow._find_cflow import find_cflow -from graphix.flow._find_pflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix -from graphix.flow.flow import CausalFlow, GFlow, PauliFlow +from graphix.flow._find_gpflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix +from graphix.flow.core import CausalFlow, GFlow, PauliFlow from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement from graphix.measurements import Measurement diff --git a/tests/test_flow.py b/tests/test_flow_core.py similarity index 99% rename from tests/test_flow.py rename to tests/test_flow_core.py index cb1433c4b..a94b90de4 100644 --- a/tests/test_flow.py +++ b/tests/test_flow_core.py @@ -5,7 +5,7 @@ import networkx as nx import pytest -from graphix.flow.flow import CausalFlow, GFlow, PauliFlow +from graphix.flow.core import CausalFlow, GFlow, PauliFlow from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph diff --git a/tests/test_flow_find_gpflow.py b/tests/test_flow_find_gpflow.py new file mode 100644 index 000000000..d8c3779c3 --- /dev/null +++ b/tests/test_flow_find_gpflow.py @@ -0,0 +1,281 @@ +"""Unit tests for the algebraic flow finding algorithm (for generalised or Pauli flow). + +This module tests the following: + - Computation of the reduced adjacency matrix. + - Computation of the flow-demand and order-demand matrices. We check this routine for open graphs with Pauli-angle measurements, intepreted as planes or as axes. + - Computation of the correction matrix. + - Computation of topological generations on small DAGs. + +The second part of the flow-finding algorithm (namely, verifying if the correction matrix is compatible with a DAG) is not done in this test module. For a complete test on the flow-finding algorithms see `tests.test_opengraph.py`. +""" + + +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple + +import networkx as nx +import numpy as np +import pytest + +from graphix._linalg import MatGF2 +from graphix.flow._find_gpflow import ( + AlgebraicOpenGraph, + PlanarAlgebraicOpenGraph, + _compute_topological_generations, + compute_correction_matrix, +) +from graphix.fundamentals import Axis, Plane +from graphix.measurements import Measurement +from graphix.opengraph_ import OpenGraph + +if TYPE_CHECKING: + from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement + + +class AlgebraicOpenGraphTestCase(NamedTuple): + aog: AlgebraicOpenGraph[AbstractMeasurement] | PlanarAlgebraicOpenGraph[AbstractPlanarMeasurement] + radj: MatGF2 + flow_demand_mat: MatGF2 + order_demand_mat: MatGF2 + has_corr_mat: bool + + +class DAGTestCase(NamedTuple): + adj_mat: MatGF2 + generations: list[list[int]] | None + + +def prepare_test_og() -> list[AlgebraicOpenGraphTestCase]: + test_cases: list[AlgebraicOpenGraphTestCase] = [] + + # Trivial open graph with pflow and nI = nO + def get_og_0() -> OpenGraph[Plane | Axis]: + """Return an open graph with Pauli flow and equal number of outputs and inputs. + + The returned graph has the following structure: + + [0]-1-(2) + """ + return OpenGraph( + graph=nx.Graph([(0, 1), (1, 2)]), input_nodes=[0], output_nodes=[2], measurements={0: Plane.XY, 1: Axis.Y} + ) + + test_cases.append( + AlgebraicOpenGraphTestCase( + aog=AlgebraicOpenGraph(get_og_0()), + radj=MatGF2([[1, 0], [0, 1]]), + flow_demand_mat=MatGF2([[1, 0], [1, 1]]), + order_demand_mat=MatGF2([[0, 0], [0, 0]]), + has_corr_mat=True, + ) + ) + + # Non-trivial open graph with pflow and nI = nO + def get_og_1() -> OpenGraph[Measurement]: + """Return an open graph with Pauli flow and equal number of outputs and inputs. + + The returned graph has the following structure: + + [0]-2-4-(6) + | | + [1]-3-5-(7) + """ + graph: nx.Graph[int] = nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]) + inputs = [0, 1] + outputs = [6, 7] + meas = { + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XY), # XY + 2: Measurement(0.0, Plane.XY), # X + 3: Measurement(0.1, Plane.XY), # XY + 4: Measurement(0.0, Plane.XY), # X + 5: Measurement(0.5, Plane.XY), # Y + } + return OpenGraph(graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=meas) + + test_cases.extend( + ( + AlgebraicOpenGraphTestCase( + aog=AlgebraicOpenGraph(get_og_1()), + radj=MatGF2( + [ + [1, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0], + [0, 1, 1, 0, 0, 0], + [1, 0, 0, 1, 0, 0], + [1, 0, 0, 1, 1, 0], + [0, 1, 1, 0, 0, 1], + ] + ), + flow_demand_mat=MatGF2( + [ + [1, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0], + [0, 1, 1, 0, 0, 0], + [1, 0, 0, 1, 0, 0], + [1, 0, 0, 1, 1, 0], + [0, 1, 1, 1, 0, 1], + ] + ), + order_demand_mat=MatGF2( + [ + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + ] + ), + has_corr_mat=True, + ), + # Same open graph but we interpret the measurements on Pauli axes as planar measurements, therefore, there flow-demand and order demand matrices are different. + AlgebraicOpenGraphTestCase( + aog=PlanarAlgebraicOpenGraph(get_og_1()), + radj=MatGF2( + [ + [1, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0], + [0, 1, 1, 0, 0, 0], + [1, 0, 0, 1, 0, 0], + [1, 0, 0, 1, 1, 0], + [0, 1, 1, 0, 0, 1], + ] + ), + flow_demand_mat=MatGF2( + [ + [1, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0], + [0, 1, 1, 0, 0, 0], + [1, 0, 0, 1, 0, 0], + [1, 0, 0, 1, 1, 0], + [0, 1, 1, 0, 0, 1], + ] + ), + order_demand_mat=MatGF2( + [ + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0], + [0, 0, 0, 1, 0, 0], + ] + ), + has_corr_mat=True, + ), + ) + ) + + # Non-trivial open graph with pflow and nI != nO + def get_og_2() -> OpenGraph[Measurement]: + """Return an open graph with Pauli flow and unequal number of outputs and inputs. + + Example from Fig. 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + graph: nx.Graph[int] = nx.Graph( + [(0, 2), (2, 4), (3, 4), (4, 6), (1, 4), (1, 6), (2, 3), (3, 5), (2, 6), (3, 6)] + ) + inputs = [0] + outputs = [5, 6] + meas = { + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XZ), # XZ + 2: Measurement(0.5, Plane.YZ), # Y + 3: Measurement(0.1, Plane.XY), # XY + 4: Measurement(0, Plane.XZ), # Z + } + + return OpenGraph(graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=meas) + + test_cases.extend( + ( + AlgebraicOpenGraphTestCase( + aog=AlgebraicOpenGraph(get_og_2()), + radj=MatGF2( + [[0, 1, 0, 0, 0, 0], [0, 0, 0, 1, 0, 1], [0, 0, 1, 1, 0, 1], [0, 1, 0, 1, 1, 1], [1, 1, 1, 0, 0, 1]] + ), + flow_demand_mat=MatGF2( + [[0, 1, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0], [0, 1, 1, 1, 0, 1], [0, 1, 0, 1, 1, 1], [0, 0, 0, 1, 0, 0]] + ), + order_demand_mat=MatGF2( + [[0, 0, 0, 0, 0, 0], [1, 0, 0, 1, 0, 1], [0, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0]] + ), + has_corr_mat=True, + ), + # Same open graph but we interpret the measurements on Pauli axes as planar measurements, therefore, there flow-demand and order demand matrices are different. + # The new flow-demand matrix is not invertible, therefore the open graph does not have gflow. + AlgebraicOpenGraphTestCase( + aog=PlanarAlgebraicOpenGraph(get_og_2()), + radj=MatGF2( + [[0, 1, 0, 0, 0, 0], [0, 0, 0, 1, 0, 1], [0, 0, 1, 1, 0, 1], [0, 1, 0, 1, 1, 1], [1, 1, 1, 0, 0, 1]] + ), + flow_demand_mat=MatGF2( + [[0, 1, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0], [0, 1, 0, 1, 1, 1], [0, 0, 0, 1, 0, 0]] + ), + order_demand_mat=MatGF2( + [[0, 0, 0, 0, 0, 0], [1, 0, 0, 1, 0, 1], [0, 0, 1, 1, 0, 1], [0, 0, 1, 0, 0, 0], [1, 1, 1, 1, 0, 1]] + ), + has_corr_mat=False, + ), + ) + ) + return test_cases + + +def prepare_test_dag() -> list[DAGTestCase]: + test_cases: list[DAGTestCase] = [] + + # Simple DAG + test_cases.extend( + ( # Simple DAG + DAGTestCase( + adj_mat=MatGF2([[0, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 1, 0]]), generations=[[0], [1, 2], [3]] + ), + # Graph with loop + DAGTestCase(adj_mat=MatGF2([[0, 0, 0, 0], [1, 0, 0, 1], [1, 0, 0, 0], [0, 1, 1, 0]]), generations=None), + # Disconnected graph + DAGTestCase( + adj_mat=MatGF2([[0, 0, 0, 0, 0], [1, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 1, 0, 0], [0, 0, 1, 0, 0]]), + generations=[[0, 2], [1, 3, 4]], + ), + ) + ) + + return test_cases + + +class TestAlgebraicFlow: + @pytest.mark.parametrize("test_case", prepare_test_og()) + def test_compute_reduced_adj(self, test_case: AlgebraicOpenGraphTestCase) -> None: + aog = test_case.aog + radj = aog._compute_reduced_adj() + assert np.all(radj == test_case.radj) + + @pytest.mark.parametrize("test_case", prepare_test_og()) + def test_og_matrices(self, test_case: AlgebraicOpenGraphTestCase) -> None: + aog = test_case.aog + assert np.all(aog.flow_demand_matrix == test_case.flow_demand_mat) + assert np.all(aog.order_demand_matrix == test_case.order_demand_mat) + + @pytest.mark.parametrize("test_case", prepare_test_og()) + def test_correction_matrix(self, test_case: AlgebraicOpenGraphTestCase) -> None: + aog = test_case.aog + corr_matrix = compute_correction_matrix(aog) + + ident = MatGF2(np.eye(len(aog.non_outputs), dtype=np.uint8)) + if test_case.has_corr_mat: + assert corr_matrix is not None + assert np.all( + (test_case.flow_demand_mat @ corr_matrix.c_matrix) % 2 == ident + ) # Test with numpy matrix product. + else: + assert corr_matrix is None + + @pytest.mark.parametrize("test_case", prepare_test_dag()) + def test_compute_topological_generations(self, test_case: DAGTestCase) -> None: + adj_mat = test_case.adj_mat + generations_ref = test_case.generations + + assert generations_ref == _compute_topological_generations(adj_mat) From 7f98a86c78162026e126ecf8691a1c92c4ddba97 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 27 Oct 2025 15:35:23 +0100 Subject: [PATCH 18/56] Change convention in XZcorrections to domain:nodes --- graphix/flow/core.py | 55 ++++++++++++++++++++++------------------- tests/test_flow_core.py | 26 +++++++++---------- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index ed217e819..eb9d5c2b7 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections import defaultdict from collections.abc import Sequence from dataclasses import dataclass from itertools import product @@ -31,8 +30,8 @@ @dataclass(frozen=True) class XZCorrections(Generic[_M_co]): og: OpenGraph[_M_co] - x_corrections: dict[int, set[int]] # {node: domain} - z_corrections: dict[int, set[int]] # {node: domain} + x_corrections: dict[int, set[int]] # {domain: nodes} + z_corrections: dict[int, set[int]] # {domain: nodes} def extract_dag(self) -> nx.DiGraph[int]: """Extract directed graph induced by the corrections. @@ -187,16 +186,18 @@ def to_corrections(self) -> XZCorrections[_M_co]: This function partially implements Theorem 4 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. """ future = self.partial_order_layers[0] - x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} - z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} + x_corrections: dict[int, set[int]] = {} # {domain: nodes} + z_corrections: dict[int, set[int]] = {} # {domain: nodes} for layer in self.partial_order_layers[1:]: - for corrected_node in layer: - correcting_set = self.correction_function[corrected_node] - for correcting_node in correcting_set & future: - x_corrections[correcting_node].add(corrected_node) - for correcting_node in self.og.odd_neighbors(correcting_set) & future: - z_corrections[correcting_node].add(corrected_node) + for measured_node in layer: + correcting_set = self.correction_function[measured_node] + # Conditionals avoid storing empty correction sets + if x_corrected_nodes := correcting_set & future: + x_corrections[measured_node] = x_corrected_nodes + if z_corrected_nodes := self.og.odd_neighbors(correcting_set) & future: + z_corrections[measured_node] = z_corrected_nodes + future |= layer return XZCorrections(self.og, x_corrections, z_corrections) @@ -275,14 +276,15 @@ def to_corrections(self) -> XZCorrections[_PM_co]: - Contrary to the overridden method in the parent class, here we do not need any information on the partial order to build the corrections since a valid correction function :math:`g` guarantees that both :math:`g(i)\setminus \{i\}` and :math:`Odd(g(i))` are in the future of :math:`i`. """ - x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} - z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} + x_corrections: dict[int, set[int]] = {} # {domain: nodes} + z_corrections: dict[int, set[int]] = {} # {domain: nodes} - for corrected_node, correcting_set in self.correction_function.items(): - for correcting_node in correcting_set - {corrected_node}: - x_corrections[correcting_node].add(corrected_node) - for correcting_node in self.og.odd_neighbors(correcting_set) - {corrected_node}: - z_corrections[correcting_node].add(corrected_node) + for measured_node, correcting_set in self.correction_function.items(): + # Conditionals avoid storing empty correction sets + if x_corrected_nodes := correcting_set - {measured_node}: + x_corrections[measured_node] = x_corrected_nodes + if z_corrected_nodes := self.og.odd_neighbors(correcting_set) - {measured_node}: + z_corrections[measured_node] = z_corrected_nodes return XZCorrections(self.og, x_corrections, z_corrections) @@ -308,14 +310,15 @@ def to_corrections(self) -> XZCorrections[_PM_co]: ----- This function partially implements Theorem 1 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. """ - x_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} - z_corrections: dict[int, set[int]] = defaultdict(set) # {node: domain} - - for corrected_node, correcting_set in self.correction_function.items(): - (correcting_node,) = correcting_set # Correcting set of a causal flow has one element only. - x_corrections[correcting_node].add(corrected_node) - for node in self.og.neighbors(correcting_set) - {corrected_node}: - z_corrections[node].add(corrected_node) + x_corrections: dict[int, set[int]] = {} # {domain: nodes} + z_corrections: dict[int, set[int]] = {} # {domain: nodes} + + for measured_node, correcting_set in self.correction_function.items(): + # Conditionals avoid storing empty correction sets + if x_corrected_nodes := correcting_set: + x_corrections[measured_node] = x_corrected_nodes + if z_corrected_nodes := self.og.neighbors(correcting_set) - {measured_node}: + z_corrections[measured_node] = z_corrected_nodes return XZCorrections(self.og, x_corrections, z_corrections) diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index a94b90de4..b156f3e6e 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -221,38 +221,38 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: ( XZCorrectionsTestCase( flow=generate_causal_flow_0(), - x_corr={1: {0}, 2: {1}, 3: {2}}, - z_corr={2: {0}, 3: {1}}, + x_corr={0: {1}, 1: {2}, 2: {3}}, + z_corr={0: {2}, 1: {3}}, ), XZCorrectionsTestCase( flow=generate_causal_flow_1(), - x_corr={2: {0}, 3: {1}, 4: {2}, 5: {3}}, - z_corr={3: {0}, 4: {0}, 2: {1}, 5: {1}}, + x_corr={0: {2}, 1: {3}, 2: {4}, 3: {5}}, + z_corr={0: {3, 4}, 1: {2, 5}}, ), XZCorrectionsTestCase( flow=generate_gflow_0(), - x_corr={2: {0}, 5: {0, 3}, 3: {1}, 4: {1, 2}}, - z_corr={4: {0}, 5: {1}}, + x_corr={0: {2, 5}, 1: {3, 4}, 2: {4}, 3: {5}}, + z_corr={0: {4}, 1: {5}}, ), XZCorrectionsTestCase( flow=generate_gflow_1(), - x_corr={3: {0, 2}, 4: {2}}, - z_corr={1: {2}, 4: {1, 2}}, + x_corr={0: {3}, 2: {3, 4}}, + z_corr={1: {4}, 2: {1, 4}}, ), XZCorrectionsTestCase( flow=generate_gflow_2(), - x_corr={3: {1, 2}, 4: {0, 1, 2}, 5: {0, 1}}, + x_corr={0: {4, 5}, 1: {3, 4, 5}, 2: {3, 4}}, z_corr={}, ), XZCorrectionsTestCase( flow=generate_pauli_flow_0(), - x_corr={3: {0, 2}}, - z_corr={3: {1}}, + x_corr={0: {3}, 2: {3}}, + z_corr={1: {3}}, ), XZCorrectionsTestCase( flow=generate_pauli_flow_1(), - x_corr={3: {1}, 6: {3, 4}, 7: {0, 2, 3, 5}}, - z_corr={6: {1, 2}, 7: {0, 3}}, + x_corr={0: {7}, 1: {3}, 2: {7}, 3: {6, 7}, 4: {6}, 5: {7}}, + z_corr={0: {7}, 1: {6}, 2: {6}, 3: {7}}, ), ) ) From af7bd17bd0f67fb090eeeb37c20f1cbab62f643a Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 27 Oct 2025 18:01:22 +0100 Subject: [PATCH 19/56] wip corrections --- graphix/flow/core.py | 138 ++++++++++++++++++++----------------------- 1 file changed, 65 insertions(+), 73 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index eb9d5c2b7..03b53b59e 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -13,7 +13,6 @@ from graphix._linalg import MatGF2 from graphix.command import E, M, N, X, Z from graphix.flow._find_gpflow import CorrectionMatrix, _M_co, _PM_co, compute_partial_order_layers -from graphix.fundamentals import Axis, Plane, Sign from graphix.pattern import Pattern if TYPE_CHECKING: @@ -21,17 +20,74 @@ from collections.abc import Set as AbstractSet from typing import Self - from graphix.measurements import ExpressionOrFloat, Measurement + from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph TotalOrder = Sequence[int] -@dataclass(frozen=True) +@dataclass class XZCorrections(Generic[_M_co]): og: OpenGraph[_M_co] x_corrections: dict[int, set[int]] # {domain: nodes} z_corrections: dict[int, set[int]] # {domain: nodes} + _partial_order_layers: Sequence[AbstractSet[int]] | None = None + # Often xz-corrections are extracted from a flow whose partial order can be used to construct a pattern from the corrections. We store it to avoid recalculating it twice. + + def to_pattern( + self: XZCorrections[Measurement], + total_measurement_order: TotalOrder | None = None, + ) -> Pattern: + # TODO: Should we verify thar corrections are well formed ? If we did so, and the total order is inferred from the corrections, we are doing a topological sort twice + + if total_measurement_order is None: + total_measurement_order = [] + # TODO: Compute total measurement order + # total_order = list(reversed(list(nx.topological_sort(self.extract_dag())))) + elif not self.is_compatible(total_measurement_order): + raise ValueError( + "The input total order is not compatible with the partial order induced by the correction sets." + ) + + pattern = Pattern(input_nodes=self.og.input_nodes) + non_input_nodes = set(self.og.graph.nodes) - set(self.og.input_nodes) + + for i in non_input_nodes: + pattern.add(N(node=i)) + for e in self.og.graph.edges: + pattern.add(E(nodes=e)) + + for measured_node in total_measurement_order: + + measurement = self.og.measurements[measured_node] + pattern.add(M(node=measured_node, plane=measurement.plane, angle=measurement.angle)) + + for corrected_node in self.z_corrections.get(measured_node, []): + pattern.add(Z(node=corrected_node, domain={measured_node})) + + for corrected_node in self.x_corrections.get(measured_node, []): + pattern.add(X(node=corrected_node, domain={measured_node})) + + pattern.reorder_output_nodes(self.og.output_nodes) + return pattern + + def generate_total_order(self) -> TotalOrder: + + if self._partial_order_layers is None: + self._partial_order_layers = self.compute_partial_order_layers() + + return [node for layer in reversed(self._partial_order_layers[1:]) for node in layer] + + def compute_partial_order_layers(self) -> list[set[int]]: + + layers = [set(self.og.output_nodes)] + dag = self.extract_dag() + try: + layers.extend(set(layer) for layer in nx.topological_generations(dag)) + except nx.NetworkXUnfeasible: + raise ValueError("XZ-corrections are not runnable since the induced directed graph contains closed loops.") from nx.NetworkXUnfeasible + + return layers def extract_dag(self) -> nx.DiGraph[int]: """Extract directed graph induced by the corrections. @@ -39,20 +95,20 @@ def extract_dag(self) -> nx.DiGraph[int]: Returns ------- nx.DiGraph[int] - Directed graph in which an edge `i -> j` represents a correction applied to qubit `i`, conditioned on the measurement outcome of qubit `j`. + Directed graph in which an edge `i -> j` represents a correction applied to qubit `j`, conditioned on the measurement outcome of qubit `i`. Notes ----- - Not all nodes of the underlying open graph are nodes of the returned directed graph, but only those involved in a correction, either as corrected qubits or belonging to a correction domain. - - Despite the name, the output of this method is not guranteed to be a directed acyclical graph (i.e., a directed graph without any loops). This is only the case if the `Corrections` object is well formed, which is verified by the method :func:`Corrections.is_wellformed`. + - Despite the name, the output of this method is not guranteed to be a directed acyclical graph (i.e., a directed graph without any loops). This is only the case if the `XZCorrections` object is well formed, which is verified by the method :func:`XZCorrections.is_wellformed`. """ relations: set[tuple[int, int]] = set() - for node, domain in self.x_corrections.items(): - relations.update(product([node], domain)) + for measured_node, corrected_nodes in self.x_corrections.items(): + relations.update(product([measured_node], corrected_nodes)) - for node, domain in self.z_corrections.items(): - relations.update(product([node], domain)) + for measured_node, corrected_nodes in self.z_corrections.items(): + relations.update(product([measured_node], corrected_nodes)) return nx.DiGraph(relations) @@ -93,70 +149,6 @@ def is_compatible(self, total_order: TotalOrder) -> bool: # Verify nodes are in open graph return True - def to_pattern( - self: XZCorrections[Measurement], - angles: Mapping[int, ExpressionOrFloat | Sign], - total_order: TotalOrder | None = None, - ) -> Pattern: - # TODO: Should we verify thar corrections are well formed ? If we did so, and the total order is inferred from the corrections, we are doing a topological sort twice - - # TODO: Do we want to raise an error or just a warning and assign 0 by default ? - if not angles.keys() == self.og.measurements.keys(): - raise ValueError("All measured nodes in the open graph must have an assigned angle label.") - - if total_order is None: - total_order = list(reversed(list(nx.topological_sort(self.extract_dag())))) - elif not self.is_compatible(total_order): - raise ValueError( - "The input total order is not compatible with the partial order induced by the correction sets." - ) - - pattern = Pattern(input_nodes=self.og.input_nodes) - non_input_nodes = set(self.og.graph.nodes) - set(self.og.input_nodes) - - for i in non_input_nodes: - pattern.add(N(node=i)) - for e in self.og.graph.edges: - pattern.add(E(nodes=e)) - - for node in total_order: - if node in self.og.output_nodes: - break - - # TODO: the following block is hideous. - # Refactor Plane and Axis ? - # Abstract class Plane, Plane.XY, .XZ, .YZ subclasses ? - # Axis X subclass of Plane.XY, Plane.XZ, etc. ? - # Method Axis, Sign -> Plane, angle - - meas_label = self.og.measurements[node] - angle_label = angles[node] - - if isinstance(meas_label, Plane): - assert not isinstance(angle_label, Sign) - pattern.add(M(node=node, plane=meas_label, angle=angle_label)) - else: - assert isinstance(angle_label, Sign) - if meas_label == Axis.X: - plane = Plane.XY - angle = 0 if angle_label is Sign.PLUS else 1 - elif meas_label == Axis.Y: - plane = Plane.XY - angle = 0.5 if angle_label is Sign.PLUS else 1.5 - elif meas_label == Axis.Z: - plane = Plane.XZ - angle = 0 if angle_label is Sign.PLUS else 1 - - pattern.add(M(node=node, plane=plane, angle=angle)) - - if node in self.z_corrections: - pattern.add(Z(node=node, domain=self.z_corrections[node])) - if node in self.x_corrections: - pattern.add(X(node=node, domain=self.x_corrections[node])) - - pattern.reorder_output_nodes(self.og.output_nodes) - return pattern - @dataclass(frozen=True) class PauliFlow(Generic[_M_co]): From fea19a47881e7fda6e7f93d6fd7dcf6a8ed845b2 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 28 Oct 2025 11:36:07 +0100 Subject: [PATCH 20/56] Add partial order in XZCorrections and methods --- graphix/flow/core.py | 235 ++++++++++++++++++++++----------- tests/test_flow_core.py | 66 ++++++++- tests/test_flow_find_gpflow.py | 1 - 3 files changed, 223 insertions(+), 79 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 03b53b59e..a27977e19 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -26,27 +26,79 @@ TotalOrder = Sequence[int] -@dataclass +@dataclass(frozen=True) class XZCorrections(Generic[_M_co]): og: OpenGraph[_M_co] - x_corrections: dict[int, set[int]] # {domain: nodes} - z_corrections: dict[int, set[int]] # {domain: nodes} - _partial_order_layers: Sequence[AbstractSet[int]] | None = None - # Often xz-corrections are extracted from a flow whose partial order can be used to construct a pattern from the corrections. We store it to avoid recalculating it twice. + x_corrections: Mapping[int, AbstractSet[int]] # {domain: nodes} + z_corrections: Mapping[int, AbstractSet[int]] # {domain: nodes} + partial_order_layers: Sequence[AbstractSet[int]] + # Often XZ-corrections are extracted from a flow whose partial order can be used to construct a pattern from the corrections. We store it to avoid recalculating it twice. + + @staticmethod + def from_measured_nodes_mapping( + og: OpenGraph[_M_co], + x_corrections: Mapping[int, AbstractSet[int]] | None = None, + z_corrections: Mapping[int, AbstractSet[int]] | None = None, + ) -> XZCorrections[_M_co]: + """Create an `XZCorrections` instance from the XZ-corrections mappings. + + Parameters + ---------- + og : OpenGraph[_M_co] + Open graph with respect to which the corrections are defined. + x_corrections : Mapping[int, AbstractSet[int]] | None + Mapping of X-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an X-correction must be applied depending on the measurement result of `key`. + z_corrections : Mapping[int, AbstractSet[int]] | None + Mapping of X-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an X-correction must be applied depending on the measurement result of `key`. + + Returns + ------- + XZCorrections[_M_co] + + Notes + ----- + This method computes the partial order induced by the XZ-corrections. + """ + x_corrections = x_corrections or {} + z_corrections = z_corrections or {} + + nodes_set = set(og.graph.nodes) + outputs_set = set(og.output_nodes) + non_outputs_set = nodes_set - outputs_set + + if not set(x_corrections).issubset(non_outputs_set): + raise ValueError("Keys of input X-corrections contain non-measured nodes.") + if not set(z_corrections).issubset(non_outputs_set): + raise ValueError("Keys of input Z-corrections contain non-measured nodes.") + + dag = _corrections_to_dag(x_corrections, z_corrections) + partial_order_layers = _dag_to_partial_order_layers(dag) + + # The first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain a subset of the output nodes. + # The first element in `XZCorrections.partial_order_layers` should contain all output nodes. + + shift = 1 if partial_order_layers[0].issubset(outputs_set) else 0 + partial_order_layers = [outputs_set, *partial_order_layers[shift:]] + + ordered_nodes = {node for layer in partial_order_layers for node in layer} + if not ordered_nodes.issubset(nodes_set): + raise ValueError("Values of input mapping contain labels which are not nodes of the input open graph.") + + # We append to the last layer (first measured nodes) all the non-output nodes not involved in the corrections + if unordered_nodes := nodes_set - ordered_nodes: + partial_order_layers.append(unordered_nodes) + + return XZCorrections(og, x_corrections, z_corrections, partial_order_layers) def to_pattern( self: XZCorrections[Measurement], total_measurement_order: TotalOrder | None = None, ) -> Pattern: - # TODO: Should we verify thar corrections are well formed ? If we did so, and the total order is inferred from the corrections, we are doing a topological sort twice - if total_measurement_order is None: - total_measurement_order = [] - # TODO: Compute total measurement order - # total_order = list(reversed(list(nx.topological_sort(self.extract_dag())))) + total_measurement_order = self.generate_total_measurement_order() elif not self.is_compatible(total_measurement_order): raise ValueError( - "The input total order is not compatible with the partial order induced by the correction sets." + "The input total measurement order is not compatible with the partial order induced by the correction sets." ) pattern = Pattern(input_nodes=self.og.input_nodes) @@ -58,7 +110,6 @@ def to_pattern( pattern.add(E(nodes=e)) for measured_node in total_measurement_order: - measurement = self.og.measurements[measured_node] pattern.add(M(node=measured_node, plane=measurement.plane, angle=measurement.angle)) @@ -71,26 +122,20 @@ def to_pattern( pattern.reorder_output_nodes(self.og.output_nodes) return pattern - def generate_total_order(self) -> TotalOrder: - - if self._partial_order_layers is None: - self._partial_order_layers = self.compute_partial_order_layers() + def generate_total_measurement_order(self) -> TotalOrder: + """Generate a sequence of all the non-output nodes in the open graph in an arbitrary order compatible with the intrinsic partial order of the XZ-corrections. - return [node for layer in reversed(self._partial_order_layers[1:]) for node in layer] - - def compute_partial_order_layers(self) -> list[set[int]]: - - layers = [set(self.og.output_nodes)] - dag = self.extract_dag() - try: - layers.extend(set(layer) for layer in nx.topological_generations(dag)) - except nx.NetworkXUnfeasible: - raise ValueError("XZ-corrections are not runnable since the induced directed graph contains closed loops.") from nx.NetworkXUnfeasible + Returns + ------- + TotalOrder + """ + total_order = [node for layer in reversed(self.partial_order_layers[1:]) for node in layer] - return layers + assert set(total_order) == set(self.og.graph.nodes) - set(self.og.output_nodes) + return total_order def extract_dag(self) -> nx.DiGraph[int]: - """Extract directed graph induced by the corrections. + """Extract the directed graph induced by the corrections. Returns ------- @@ -102,53 +147,75 @@ def extract_dag(self) -> nx.DiGraph[int]: - Not all nodes of the underlying open graph are nodes of the returned directed graph, but only those involved in a correction, either as corrected qubits or belonging to a correction domain. - Despite the name, the output of this method is not guranteed to be a directed acyclical graph (i.e., a directed graph without any loops). This is only the case if the `XZCorrections` object is well formed, which is verified by the method :func:`XZCorrections.is_wellformed`. """ - relations: set[tuple[int, int]] = set() - - for measured_node, corrected_nodes in self.x_corrections.items(): - relations.update(product([measured_node], corrected_nodes)) + return _corrections_to_dag(self.x_corrections, self.z_corrections) - for measured_node, corrected_nodes in self.z_corrections.items(): - relations.update(product([measured_node], corrected_nodes)) + def is_compatible(self, total_measurement_order: TotalOrder) -> bool: + """Verify if a given total measurement order is compatible with the intrisic partial order of the XZ-corrections. - return nx.DiGraph(relations) - - def is_wellformed(self) -> bool: - """Verify if `Corrections` object is well formed. + Parameters + ---------- + total_measurement_order: TotalOrder + An ordered sequence of all the non-output nodes in the open graph. Returns ------- bool - `True` if `self` is well formed, `False` otherwise. - - Notes - ----- - This method verifies that: - - Corrected nodes belong to the underlying open graph. - - Nodes in domain set are measured. - - Corrections are runnable. This amounts to verifying that the corrections-induced directed graph does not have loops. + `True` if `total_measurement_order` is compatible with `self.partial_order_layers`, `False` otherwise. """ - for corr_type in ["X", "Z"]: - corrections = getattr(self, f"{corr_type.lower()}_corrections") - for node, domain in corrections.items(): - if node not in self.og.graph.nodes: - print( - f"Cannot apply {corr_type} correction. Corrected node {node} does not belong to the open graph." - ) - return False - if not domain.issubset(self.og.measurements): - print(f"Cannot apply {corr_type} correction. Domain nodes {domain} are not measured.") - return False - if nx.is_directed_acyclic_graph(self.extract_dag()): - print("Corrections are not runnable since the induced directed graph contains cycles.") + non_outputs_set = set(self.og.graph.nodes) - set(self.og.output_nodes) + + if set(total_measurement_order) != non_outputs_set: + print("The input total measurement order does not contain all non-output nodes.") return False - return True + if len(total_measurement_order) != len(non_outputs_set): + print("The input total measurement order contains duplicates.") + return False + + layer = len(self.partial_order_layers) - 1 # First layer to be measured. + + for node in total_measurement_order: + while True: + if node in self.partial_order_layers[layer]: + break + layer -= 1 + if layer == 0: # Layer 0 only contains output nodes. + return False - def is_compatible(self, total_order: TotalOrder) -> bool: - # Verify compatibility - # Verify nodes are in open graph return True + # def is_wellformed(self) -> bool: + # """Verify if `Corrections` object is well formed. + + # Returns + # ------- + # bool + # `True` if `self` is well formed, `False` otherwise. + + # Notes + # ----- + # This method verifies that: + # - Corrected nodes belong to the underlying open graph. + # - Nodes in domain set are measured. + # - Corrections are runnable. This amounts to verifying that the corrections-induced directed graph does not have loops. + # """ + # for corr_type in ["X", "Z"]: + # corrections = getattr(self, f"{corr_type.lower()}_corrections") + # for node, domain in corrections.items(): + # if node not in self.og.graph.nodes: + # print( + # f"Cannot apply {corr_type} correction. Corrected node {node} does not belong to the open graph." + # ) + # return False + # if not domain.issubset(self.og.measurements): + # print(f"Cannot apply {corr_type} correction. Domain nodes {domain} are not measured.") + # return False + # if nx.is_directed_acyclic_graph(self.extract_dag()): + # print("Corrections are not runnable since the induced directed graph contains cycles.") + # return False + + # return True + @dataclass(frozen=True) class PauliFlow(Generic[_M_co]): @@ -156,7 +223,6 @@ class PauliFlow(Generic[_M_co]): correction_function: Mapping[int, set[int]] partial_order_layers: Sequence[AbstractSet[int]] - # TODO: Add parametric dependence of AlgebraicOpenGraph @classmethod def from_correction_matrix(cls, correction_matrix: CorrectionMatrix) -> Self | None: correction_function = correction_matrix.to_correction_function() @@ -192,7 +258,7 @@ def to_corrections(self) -> XZCorrections[_M_co]: future |= layer - return XZCorrections(self.og, x_corrections, z_corrections) + return XZCorrections(self.og, x_corrections, z_corrections, self.partial_order_layers) def is_well_formed(self) -> bool: r"""Verify if flow object is well formed. @@ -242,15 +308,6 @@ def is_well_formed(self) -> bool: return True - # TODO: for compatibility with previous encoding of layers. - # def node_layer_mapping(self) -> dict[int, int]: - # """Return layers in the form `{node: layer}`.""" - # mapping: dict[int, int] = {} - # for layer, nodes in self.layers.items(): - # mapping.update(dict.fromkeys(nodes, layer)) - - # return mapping - @dataclass(frozen=True) class GFlow(PauliFlow[_PM_co], Generic[_PM_co]): @@ -278,7 +335,7 @@ def to_corrections(self) -> XZCorrections[_PM_co]: if z_corrected_nodes := self.og.odd_neighbors(correcting_set) - {measured_node}: z_corrections[measured_node] = z_corrected_nodes - return XZCorrections(self.og, x_corrections, z_corrections) + return XZCorrections(self.og, x_corrections, z_corrections, self.partial_order_layers) @dataclass(frozen=True) @@ -312,7 +369,33 @@ def to_corrections(self) -> XZCorrections[_PM_co]: if z_corrected_nodes := self.og.neighbors(correcting_set) - {measured_node}: z_corrections[measured_node] = z_corrected_nodes - return XZCorrections(self.og, x_corrections, z_corrections) + return XZCorrections(self.og, x_corrections, z_corrections, self.partial_order_layers) + + +def _corrections_to_dag( + x_corrections: Mapping[int, AbstractSet[int]], z_corrections: Mapping[int, AbstractSet[int]] +) -> nx.DiGraph[int]: + relations: set[tuple[int, int]] = set() + + for measured_node, corrected_nodes in x_corrections.items(): + relations.update(product([measured_node], corrected_nodes)) + + for measured_node, corrected_nodes in z_corrections.items(): + relations.update(product([measured_node], corrected_nodes)) + + return nx.DiGraph(relations) + + +def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[set[int]]: + + try: + topo_gen = reversed(list(nx.topological_generations(dag))) + except nx.NetworkXUnfeasible: + raise ValueError( + "XZ-corrections are not runnable since the induced directed graph contains closed loops." + ) from nx.NetworkXUnfeasible + + return [set(layer) for layer in topo_gen] ########### diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index b156f3e6e..7d8510f96 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -5,7 +5,7 @@ import networkx as nx import pytest -from graphix.flow.core import CausalFlow, GFlow, PauliFlow +from graphix.flow.core import CausalFlow, GFlow, PauliFlow, XZCorrections from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph @@ -268,5 +268,67 @@ def test_flow_to_corrections(self, test_case: XZCorrectionsTestCase) -> None: assert corrections.z_corrections == test_case.z_corr assert corrections.x_corrections == test_case.x_corr + def test_order_0(self) -> None: + corrections = generate_causal_flow_0().to_corrections() -# TODO: add pattern, add dag, order + assert corrections.generate_total_measurement_order() == [0, 1, 2] + assert corrections.is_compatible([0, 1, 2]) # Correct order + assert not corrections.is_compatible([1, 0, 2]) # Wrong order + assert not corrections.is_compatible([1, 2]) # Incomplete order + assert not corrections.is_compatible([0, 1, 2, 3]) # Contains outputs + + assert nx.utils.graphs_equal(corrections.extract_dag(), nx.DiGraph([(0, 1), (0, 2), (1, 2), (2, 3), (1, 3)])) + + def test_order_1(self) -> None: + # See `:func: generate_causal_flow_1` + + og = OpenGraph( + graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), + input_nodes=[0, 1], + output_nodes=[4, 5], + measurements=dict.fromkeys(range(4), Measurement(angle=0, plane=Plane.XY)), + ) + + corrections = XZCorrections.from_measured_nodes_mapping( + og=og, x_corrections={0: {2}, 1: {3}, 2: {4}, 3: {5}}, z_corrections={0: {3, 4}, 1: {2, 5}} + ) + + assert corrections.is_compatible([0, 1, 2, 3]) + assert corrections.is_compatible([1, 0, 2, 3]) + assert corrections.is_compatible([1, 0, 3, 2]) + assert not corrections.is_compatible([0, 2, 1, 3]) # Wrong order + assert not corrections.is_compatible([1, 0, 3]) # Incomplete order + assert not corrections.is_compatible([0, 1, 1, 2, 3]) # Duplicates + assert not corrections.is_compatible([0, 1, 2, 3, 4, 5]) # Contains outputs + + assert nx.utils.graphs_equal( + corrections.extract_dag(), nx.DiGraph([(0, 2), (0, 3), (0, 4), (1, 2), (1, 3), (1, 5), (2, 4), (3, 5)]) + ) + + def test_order_2(self) -> None: + # Incomplete corrections + + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (1, 3)]), + input_nodes=[0], + output_nodes=[2, 3], + measurements=dict.fromkeys(range(2), Measurement(angle=0, plane=Plane.XY)), + ) + + corrections = XZCorrections.from_measured_nodes_mapping( + og=og, x_corrections={1: {0}} + ) + + assert corrections.partial_order_layers == [{2, 3}, {0}, {1}] + assert corrections.is_compatible([1, 0]) + assert not corrections.is_compatible([0, 1]) # Wrong order + assert not corrections.is_compatible([0]) # Incomplete order + assert not corrections.is_compatible([0, 0, 1]) # Duplicates + assert not corrections.is_compatible([1, 0, 2, 3]) # Contains outputs + + assert nx.utils.graphs_equal( + corrections.extract_dag(), nx.DiGraph([(1, 0)]) + ) + + +# TODO: add pattern diff --git a/tests/test_flow_find_gpflow.py b/tests/test_flow_find_gpflow.py index d8c3779c3..3d8bb01e4 100644 --- a/tests/test_flow_find_gpflow.py +++ b/tests/test_flow_find_gpflow.py @@ -9,7 +9,6 @@ The second part of the flow-finding algorithm (namely, verifying if the correction matrix is compatible with a DAG) is not done in this test module. For a complete test on the flow-finding algorithms see `tests.test_opengraph.py`. """ - from __future__ import annotations from typing import TYPE_CHECKING, NamedTuple From a8837915f579b7b541782b5a546500e5b1032619 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 28 Oct 2025 13:18:03 +0100 Subject: [PATCH 21/56] corrections to pattern working --- graphix/flow/core.py | 18 +++++++++++++++++- tests/test_flow_core.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index a27977e19..82cbf9494 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -94,11 +94,27 @@ def to_pattern( self: XZCorrections[Measurement], total_measurement_order: TotalOrder | None = None, ) -> Pattern: + """Generate a unique pattern from an instance of `XZCorrections[Measurement]`. + + Parameters + ---------- + total_measurement_order : TotalOrder | None + Ordered sequence of all the non-output nodes in the open graph indicating the measurement order. This parameter must be compatible with the partial order induced by the XZ-corrections. + Optional, defaults to `None`. If `None` an arbitrary total order compatible with `self.partial_order_layers` is generated. + + Returns + ------- + Pattern + + Notes + ----- + The resulting pattern is guaranteed to be runnable if the `XZCorrections` object is well formed, but does not need to be deterministic. It will be deterministic if the XZ-corrections were inferred from a flow. In this case, this routine follows the recipe in Theorems 1, 2 and 4 of Browne et al., NJP 9, 250 (2007). + """ if total_measurement_order is None: total_measurement_order = self.generate_total_measurement_order() elif not self.is_compatible(total_measurement_order): raise ValueError( - "The input total measurement order is not compatible with the partial order induced by the correction sets." + "The input total measurement order is not compatible with the partial order induced by the XZ-corrections." ) pattern = Pattern(input_nodes=self.og.input_nodes) diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 7d8510f96..07b3ee109 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -3,16 +3,22 @@ from typing import TYPE_CHECKING, NamedTuple import networkx as nx +import numpy as np import pytest +from graphix.command import E, M, N, X, Z from graphix.flow.core import CausalFlow, GFlow, PauliFlow, XZCorrections from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph +from graphix.pattern import Pattern +from graphix.states import PlanarState if TYPE_CHECKING: from collections.abc import Mapping + from numpy.random import Generator + def generate_causal_flow_0() -> CausalFlow[Plane]: """Generate causal flow on linear open graph. @@ -212,6 +218,8 @@ class XZCorrectionsTestCase(NamedTuple): flow: CausalFlow[AbstractPlanarMeasurement] | GFlow[AbstractPlanarMeasurement] | PauliFlow[AbstractMeasurement] x_corr: Mapping[int, set[int]] z_corr: Mapping[int, set[int]] + pattern: Pattern | None + # Patterns can only be extracted from `Measurement`-type objects. If `flow` is of parametric type, we set `pattern = None`. def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: @@ -223,36 +231,43 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: flow=generate_causal_flow_0(), x_corr={0: {1}, 1: {2}, 2: {3}}, z_corr={0: {2}, 1: {3}}, + pattern=None ), XZCorrectionsTestCase( flow=generate_causal_flow_1(), x_corr={0: {2}, 1: {3}, 2: {4}, 3: {5}}, z_corr={0: {3, 4}, 1: {2, 5}}, + pattern=Pattern(input_nodes=[0, 1], cmds=[N(2), N(3), N(4), N(5), E((0, 2)), E((2, 3)), E((2, 4)), E((3, 1)), E((3, 5)), M(0), Z(3, {0}), Z(4, {0}), X(2, {0}), M(1), Z(2, {1}), Z(5, {1}), X(3, {1}), M(2), X(4, {2}), M(3), X(5, {3})], output_nodes=[4, 5]) ), XZCorrectionsTestCase( flow=generate_gflow_0(), x_corr={0: {2, 5}, 1: {3, 4}, 2: {4}, 3: {5}}, z_corr={0: {4}, 1: {5}}, + pattern=Pattern(input_nodes=[0, 1], cmds=[N(2), N(3), N(4), N(5), E((0, 2)), E((2, 3)), E((2, 4)), E((3, 1)), E((3, 5)), M(0), Z(3, {0}), Z(4, {0}), X(2, {0}), M(1), Z(2, {1}), Z(5, {1}), X(3, {1}), M(2), X(4, {2}), M(3), X(5, {3})], output_nodes=[4, 5]), ), XZCorrectionsTestCase( flow=generate_gflow_1(), x_corr={0: {3}, 2: {3, 4}}, z_corr={1: {4}, 2: {1, 4}}, + pattern=None, ), XZCorrectionsTestCase( flow=generate_gflow_2(), x_corr={0: {4, 5}, 1: {3, 4, 5}, 2: {3, 4}}, z_corr={}, + pattern=None ), XZCorrectionsTestCase( flow=generate_pauli_flow_0(), x_corr={0: {3}, 2: {3}}, z_corr={1: {3}}, + pattern=None, ), XZCorrectionsTestCase( flow=generate_pauli_flow_1(), x_corr={0: {7}, 1: {3}, 2: {7}, 3: {6, 7}, 4: {6}, 5: {7}}, z_corr={0: {7}, 1: {6}, 2: {6}, 3: {7}}, + pattern=Pattern(input_nodes=[0, 1], cmds=[N(2), N(3), N(4), N(5), N(6), N(7), E((0, 2)), E((2, 3)), E((2, 4)), E((1, 3)), E((3, 5)), E((4, 5)), E((4, 6)), E((5, 7)), M(0, angle=0.1), Z(3, {0}), Z(4, {0}), X(2, {0}), M(1, angle=0.1), Z(2, {1}), Z(5, {1}), X(3, {1}), M(2), Z(5, {2}), Z(6, {2}), X(4, {2}), M(3, angle=0.1), Z(4, {3}), Z(7, {3}), X(5, {3}), M(4), X(6, {4}), M(5, angle=0.5), X(7, {5})], output_nodes=[6, 7]) ), ) ) @@ -268,6 +283,25 @@ def test_flow_to_corrections(self, test_case: XZCorrectionsTestCase) -> None: assert corrections.z_corrections == test_case.z_corr assert corrections.x_corrections == test_case.x_corr + @pytest.mark.parametrize("test_case", prepare_test_xzcorrections()) + def test_corrections_to_pattern(self, test_case: XZCorrectionsTestCase, fx_rng: Generator) -> None: + if test_case.pattern is not None: + pattern = test_case.flow.to_corrections().to_pattern() # type: ignore[misc] + n_shots = 2 + results = [] + + for plane in {Plane.XY, Plane.XZ, Plane.YZ}: + alpha = 2 * np.pi * fx_rng.random() + state_ref = test_case.pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) + + for _ in range(n_shots): + state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) + results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) + + avg = sum(results) / (n_shots * 3) + + assert avg == pytest.approx(1) + def test_order_0(self) -> None: corrections = generate_causal_flow_0().to_corrections() From e25ba9b0294039ac35b06430d43544860695955b Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 28 Oct 2025 13:25:59 +0100 Subject: [PATCH 22/56] fix mypy --- graphix/flow/_find_cflow.py | 4 +- graphix/flow/core.py | 5 +- tests/test_flow_core.py | 118 ++++++++++++++++++++++++++++++------ 3 files changed, 102 insertions(+), 25 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 599712432..72386ffb4 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -14,7 +14,7 @@ # TODO: Up doc strings -def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow | None: +def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: """Return the causal flow of the input open graph if it exists. Parameters @@ -60,7 +60,7 @@ def _flow_aux( corrector_candidates: AbstractSet[int], cf: dict[int, set[int]], layers: list[set[int]], -) -> CausalFlow | None: +) -> CausalFlow[_PM_co] | None: """Find one layer of the causal flow. Parameters diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 82cbf9494..69a77f78a 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -240,7 +240,7 @@ class PauliFlow(Generic[_M_co]): partial_order_layers: Sequence[AbstractSet[int]] @classmethod - def from_correction_matrix(cls, correction_matrix: CorrectionMatrix) -> Self | None: + def from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_M_co]) -> Self | None: correction_function = correction_matrix.to_correction_function() partial_order_layers = compute_partial_order_layers(correction_matrix) if partial_order_layers is None: @@ -360,7 +360,7 @@ class CausalFlow( ): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane @override @classmethod - def from_correction_matrix(cls, correction_matrix: CorrectionMatrix) -> None: + def from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_PM_co]) -> None: raise NotImplementedError("Initialization of a causal flow from a correction matrix is not supported.") @override @@ -403,7 +403,6 @@ def _corrections_to_dag( def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[set[int]]: - try: topo_gen = reversed(list(nx.topological_generations(dag))) except nx.NetworkXUnfeasible: diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 07b3ee109..f4909c424 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -228,22 +228,71 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: test_cases.extend( ( XZCorrectionsTestCase( - flow=generate_causal_flow_0(), - x_corr={0: {1}, 1: {2}, 2: {3}}, - z_corr={0: {2}, 1: {3}}, - pattern=None + flow=generate_causal_flow_0(), x_corr={0: {1}, 1: {2}, 2: {3}}, z_corr={0: {2}, 1: {3}}, pattern=None ), XZCorrectionsTestCase( flow=generate_causal_flow_1(), x_corr={0: {2}, 1: {3}, 2: {4}, 3: {5}}, z_corr={0: {3, 4}, 1: {2, 5}}, - pattern=Pattern(input_nodes=[0, 1], cmds=[N(2), N(3), N(4), N(5), E((0, 2)), E((2, 3)), E((2, 4)), E((3, 1)), E((3, 5)), M(0), Z(3, {0}), Z(4, {0}), X(2, {0}), M(1), Z(2, {1}), Z(5, {1}), X(3, {1}), M(2), X(4, {2}), M(3), X(5, {3})], output_nodes=[4, 5]) + pattern=Pattern( + input_nodes=[0, 1], + cmds=[ + N(2), + N(3), + N(4), + N(5), + E((0, 2)), + E((2, 3)), + E((2, 4)), + E((3, 1)), + E((3, 5)), + M(0), + Z(3, {0}), + Z(4, {0}), + X(2, {0}), + M(1), + Z(2, {1}), + Z(5, {1}), + X(3, {1}), + M(2), + X(4, {2}), + M(3), + X(5, {3}), + ], + output_nodes=[4, 5], + ), ), XZCorrectionsTestCase( flow=generate_gflow_0(), x_corr={0: {2, 5}, 1: {3, 4}, 2: {4}, 3: {5}}, z_corr={0: {4}, 1: {5}}, - pattern=Pattern(input_nodes=[0, 1], cmds=[N(2), N(3), N(4), N(5), E((0, 2)), E((2, 3)), E((2, 4)), E((3, 1)), E((3, 5)), M(0), Z(3, {0}), Z(4, {0}), X(2, {0}), M(1), Z(2, {1}), Z(5, {1}), X(3, {1}), M(2), X(4, {2}), M(3), X(5, {3})], output_nodes=[4, 5]), + pattern=Pattern( + input_nodes=[0, 1], + cmds=[ + N(2), + N(3), + N(4), + N(5), + E((0, 2)), + E((2, 3)), + E((2, 4)), + E((3, 1)), + E((3, 5)), + M(0), + Z(3, {0}), + Z(4, {0}), + X(2, {0}), + M(1), + Z(2, {1}), + Z(5, {1}), + X(3, {1}), + M(2), + X(4, {2}), + M(3), + X(5, {3}), + ], + output_nodes=[4, 5], + ), ), XZCorrectionsTestCase( flow=generate_gflow_1(), @@ -252,10 +301,7 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: pattern=None, ), XZCorrectionsTestCase( - flow=generate_gflow_2(), - x_corr={0: {4, 5}, 1: {3, 4, 5}, 2: {3, 4}}, - z_corr={}, - pattern=None + flow=generate_gflow_2(), x_corr={0: {4, 5}, 1: {3, 4, 5}, 2: {3, 4}}, z_corr={}, pattern=None ), XZCorrectionsTestCase( flow=generate_pauli_flow_0(), @@ -267,7 +313,46 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: flow=generate_pauli_flow_1(), x_corr={0: {7}, 1: {3}, 2: {7}, 3: {6, 7}, 4: {6}, 5: {7}}, z_corr={0: {7}, 1: {6}, 2: {6}, 3: {7}}, - pattern=Pattern(input_nodes=[0, 1], cmds=[N(2), N(3), N(4), N(5), N(6), N(7), E((0, 2)), E((2, 3)), E((2, 4)), E((1, 3)), E((3, 5)), E((4, 5)), E((4, 6)), E((5, 7)), M(0, angle=0.1), Z(3, {0}), Z(4, {0}), X(2, {0}), M(1, angle=0.1), Z(2, {1}), Z(5, {1}), X(3, {1}), M(2), Z(5, {2}), Z(6, {2}), X(4, {2}), M(3, angle=0.1), Z(4, {3}), Z(7, {3}), X(5, {3}), M(4), X(6, {4}), M(5, angle=0.5), X(7, {5})], output_nodes=[6, 7]) + pattern=Pattern( + input_nodes=[0, 1], + cmds=[ + N(2), + N(3), + N(4), + N(5), + N(6), + N(7), + E((0, 2)), + E((2, 3)), + E((2, 4)), + E((1, 3)), + E((3, 5)), + E((4, 5)), + E((4, 6)), + E((5, 7)), + M(0, angle=0.1), + Z(3, {0}), + Z(4, {0}), + X(2, {0}), + M(1, angle=0.1), + Z(2, {1}), + Z(5, {1}), + X(3, {1}), + M(2), + Z(5, {2}), + Z(6, {2}), + X(4, {2}), + M(3, angle=0.1), + Z(4, {3}), + Z(7, {3}), + X(5, {3}), + M(4), + X(6, {4}), + M(5, angle=0.5), + X(7, {5}), + ], + output_nodes=[6, 7], + ), ), ) ) @@ -349,9 +434,7 @@ def test_order_2(self) -> None: measurements=dict.fromkeys(range(2), Measurement(angle=0, plane=Plane.XY)), ) - corrections = XZCorrections.from_measured_nodes_mapping( - og=og, x_corrections={1: {0}} - ) + corrections = XZCorrections.from_measured_nodes_mapping(og=og, x_corrections={1: {0}}) assert corrections.partial_order_layers == [{2, 3}, {0}, {1}] assert corrections.is_compatible([1, 0]) @@ -360,9 +443,4 @@ def test_order_2(self) -> None: assert not corrections.is_compatible([0, 0, 1]) # Duplicates assert not corrections.is_compatible([1, 0, 2, 3]) # Contains outputs - assert nx.utils.graphs_equal( - corrections.extract_dag(), nx.DiGraph([(1, 0)]) - ) - - -# TODO: add pattern + assert nx.utils.graphs_equal(corrections.extract_dag(), nx.DiGraph([(1, 0)])) From 65f11432ff502b1d5838c5fb86152c8092b40c45 Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 29 Oct 2025 15:34:42 +0100 Subject: [PATCH 23/56] Add test suite OG --- tests/test_opengraph_.py | 569 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 569 insertions(+) diff --git a/tests/test_opengraph_.py b/tests/test_opengraph_.py index 0f91b4221..a3a8cc5ec 100644 --- a/tests/test_opengraph_.py +++ b/tests/test_opengraph_.py @@ -1,9 +1,539 @@ +"""Unit tests for the class `:class: graphix.opengraph_.OpenGraph`. + +This module tests the full conversion Open Graph -> Flow -> XZ-corrections -> Pattern for all three classes of flow. +Output correctness is verified by checking if the resulting pattern is deterministic (when the flow exists). +""" + + from __future__ import annotations +from typing import TYPE_CHECKING, NamedTuple + import networkx as nx +import numpy as np +import pytest from graphix.fundamentals import Plane +from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph +from graphix.states import PlanarState + +if TYPE_CHECKING: + from numpy.random import Generator + + from graphix.pattern import Pattern + + +class OpenGraphFlowTestCase(NamedTuple): + og: OpenGraph[Measurement] + has_cflow: bool + has_gflow: bool + has_pflow: bool + + +def _og_0() -> OpenGraphFlowTestCase: + """Generate open graph. + + Structure: + + [(0)]-[(1)] + """ + meas: dict[int, Measurement] = {} + og = OpenGraph( + graph=nx.Graph([(0, 1)]), + input_nodes=[0, 1], + output_nodes=[0, 1], + measurements=meas, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_1() -> OpenGraphFlowTestCase: + """Generate open graph. + + Structure: + + [0]-1-20-30-4-(5) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 20), (20, 30), (30, 4), (4, 5)]), + input_nodes=[0], + output_nodes=[5], + measurements={ + 0: Measurement(0.1, Plane.XY), + 1: Measurement(0.2, Plane.XY), + 20: Measurement(0.3, Plane.XY), + 30: Measurement(0.4, Plane.XY), + 4: Measurement(0.5, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_2() -> OpenGraphFlowTestCase: + """Generate open graph. + + Structure: + + [1]-3-(5) + | + [2]-4-(6) + """ + og = OpenGraph( + graph=nx.Graph([(1, 3), (2, 4), (3, 4), (3, 5), (4, 6)]), + input_nodes=[1, 2], + output_nodes=[5, 6], + measurements={ + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.2, Plane.XY), + 3: Measurement(0.3, Plane.XY), + 4: Measurement(0.4, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_3() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [1]-(4) + \ / + X____ + / | + [2]-(5) | + \ / | + X | + / \ / + [3]-(6) + """ + og = OpenGraph( + graph=nx.Graph([(1, 4), (1, 6), (2, 4), (2, 5), (2, 6), (3, 5), (3, 6)]), + input_nodes=[1, 2, 3], + output_nodes=[4, 5, 6], + measurements={ + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.2, Plane.XY), + 3: Measurement(0.3, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) + + +def _og_4() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-[1] + /| | + (4)| | + \| | + 2--(5)-3 + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 2), (0, 4), (1, 5), (2, 4), (2, 5), (3, 5)]), + input_nodes=[0, 1], + output_nodes=[4, 5], + measurements={ + 0: Measurement(0.1, Plane.XY), + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.2, Plane.XZ), + 3: Measurement(0.3, Plane.YZ), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) + + +def _og_5() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [1]-(3) + \ / + X + / \ + [2]-(4) + """ + og = OpenGraph( + graph=nx.Graph([(1, 3), (1, 4), (2, 3), (2, 4)]), + input_nodes=[1, 2], + output_nodes=[3, 4], + measurements={ + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.1, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_6() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0] + | + 1-2-3 + | + (4) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (1, 4), (2, 3)]), + input_nodes=[0], + output_nodes=[4], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0, Plane.XY), # X + 2: Measurement(0.1, Plane.XY), # XY + 3: Measurement(0, Plane.XY), # X + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def _og_7() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-1-(2) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2)]), + input_nodes=[0], + output_nodes=[2], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.5, Plane.YZ), # Y + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def _og_8() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2-4-(6) + | | + [1]-3-5-(7) + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]), + input_nodes=[1, 0], + output_nodes=[6, 7], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XZ), # XZ + 2: Measurement(0.5, Plane.XZ), # X + 3: Measurement(0.5, Plane.YZ), # Y + 4: Measurement(0.5, Plane.YZ), # Y + 5: Measurement(0.1, Plane.YZ), # YZ + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_9() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2-4-(6) + | | + [1]-3-5-(7) + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]), + input_nodes=[0, 1], + output_nodes=[6, 7], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XY), # XY + 2: Measurement(0.0, Plane.XY), # X + 3: Measurement(0.1, Plane.XY), # XY + 4: Measurement(0.0, Plane.XY), # X + 5: Measurement(0.5, Plane.XY), # Y + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_10() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 3-(5) + /| \ + [0]-2-4-(6) + | | /| + | 1 | + |____| + + Notes + ----- + Example from Fig. 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (2, 4), (3, 4), (4, 6), (1, 4), (1, 6), (2, 3), (3, 5), (2, 6), (3, 6)]), + input_nodes=[0], + output_nodes=[5, 6], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XZ), # XZ + 2: Measurement(0.5, Plane.YZ), # Y + 3: Measurement(0.1, Plane.XY), # XY + 4: Measurement(0, Plane.XZ), # Z + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def _og_11() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2--3--4-7-(8) + | | | + (6)[1](5) + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), + input_nodes=[0, 1], + output_nodes=[5, 6, 8], + measurements={ + 0: Measurement(0.1, Plane.XY), + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.0, Plane.XY), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.5, Plane.XY), + 7: Measurement(0, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_12() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2-(3)-(4) + | + [(1)] + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 2), (2, 3), (3, 4)]), + input_nodes=[0, 1], + output_nodes=[1, 3, 4], + measurements={0: Measurement(0.1, Plane.XY), 2: Measurement(0.5, Plane.YZ)}, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def _og_13() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 0-[1] + | | + 5-(2)-3--4-(7) + | + (6) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7)]), + input_nodes=[1], + output_nodes=[6, 2, 7], + measurements={ + 0: Measurement(0.1, Plane.XZ), + 1: Measurement(0.1, Plane.XY), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.1, Plane.XY), + 5: Measurement(0.1, Plane.YZ), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) + + +def _og_14() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 0-(1) (4)-6 + | | + 2-(3) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (4, 6)]), + input_nodes=[], + output_nodes=[1, 3, 4], + measurements={0: Measurement(0.5, Plane.XZ), 2: Measurement(0, Plane.YZ), 6: Measurement(0.2, Plane.XY)}, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) + + +def _og_15() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [1]--0 + | | + 4---3-(2) + | | | + (7)-(6)-5 + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7), (5, 6), (6, 7)]), + input_nodes=[1], + output_nodes=[6, 2, 7], + measurements={ + 0: Measurement(0.1, Plane.XZ), + 1: Measurement(0.1, Plane.XY), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.1, Plane.XY), + 5: Measurement(0.1, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_16() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-(1) (4)-6 + | | + 2--(3) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (4, 6)]), + input_nodes=[0], + output_nodes=[1, 3, 4], + measurements={0: Measurement(0.1, Plane.XZ), 2: Measurement(0, Plane.YZ), 6: Measurement(0.2, Plane.XY)}, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_17() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 0-[1] [8] + | | + 5-(2)-3--4-(7) + | + (6) + + Notes + ----- + Graph is constructed by adding a disconnected input to OG 13. + """ + graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7)]) + graph.add_node(8) + og = OpenGraph( + graph=graph, + input_nodes=[1, 8], + output_nodes=[6, 2, 7], + measurements={ + 0: Measurement(0.1, Plane.XZ), + 1: Measurement(0.1, Plane.XY), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.1, Plane.XY), + 5: Measurement(0.1, Plane.YZ), + 8: Measurement(0.1, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_18() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2--3--4-7-(8) + | | | + (6)[1](5) + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), + input_nodes=[0, 1], + output_nodes=[5, 6, 8], + measurements={ + 0: Measurement(0, Plane.XY), + 1: Measurement(0, Plane.XY), + 2: Measurement(0, Plane.XZ), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.5, Plane.XY), + 7: Measurement(0, Plane.YZ), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_19() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 0-2--3--4-7-(8) + | | | + (6) 1 (5) + + Notes + ----- + Even though any node is measured in the XY plane, OG has Pauli flow because none of them are inputs. + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), + input_nodes=[], + output_nodes=[5, 6, 8], + measurements={ + 0: Measurement(0, Plane.XZ), + 1: Measurement(0, Plane.XZ), + 2: Measurement(0, Plane.XZ), + 3: Measurement(0, Plane.XZ), + 4: Measurement(0, Plane.XZ), + 7: Measurement(0, Plane.XZ), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def prepare_test_og_flow() -> list[OpenGraphFlowTestCase]: + n_og_samples = 20 + test_cases: list[OpenGraphFlowTestCase] = [globals()[f"_og_{i}"]() for i in range(n_og_samples)] + + return test_cases + + +def check_determinism(pattern: Pattern, fx_rng: Generator, n_shots: int = 3) -> bool: + """Verify if the input pattern is deterministic.""" + results = [] + + for plane in {Plane.XY, Plane.XZ, Plane.YZ}: + alpha = 2 * np.pi * fx_rng.random() + state_ref = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) + + for _ in range(n_shots): + state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) + results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) + + avg = sum(results) / (n_shots * 3) + + return avg == pytest.approx(1) class TestOpenGraph: @@ -24,3 +554,42 @@ def test_neighbors(self) -> None: assert og.neighbors([0, 1]) == {0, 1, 2, 3, 4} assert og.neighbors([1, 2, 3]) == {0, 1, 2, 3, 4} assert og.neighbors([]) == set() + + @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + def test_cflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: + og = test_case.og + + cflow = og.find_causal_flow() + + if test_case.has_cflow: + assert cflow is not None + pattern = cflow.to_corrections().to_pattern() + assert check_determinism(pattern, fx_rng) + else: + assert cflow is None + + @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + def test_gflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: + og = test_case.og + + gflow = og.find_gflow() + + if test_case.has_gflow: + assert gflow is not None + pattern = gflow.to_corrections().to_pattern() + assert check_determinism(pattern, fx_rng) + else: + assert gflow is None + + @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: + og = test_case.og + + pflow = og.find_pauli_flow() + + if test_case.has_pflow: + assert pflow is not None + pattern = pflow.to_corrections().to_pattern() + assert check_determinism(pattern, fx_rng) + else: + assert pflow is None From a727f04bfe45940a68271b091b2af1ae5fb3ab8d Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 29 Oct 2025 16:52:43 +0100 Subject: [PATCH 24/56] Add docs og --- graphix/flow/_find_cflow.py | 6 +- graphix/opengraph_.py | 278 +++++++++++++++++------------------- 2 files changed, 135 insertions(+), 149 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 72386ffb4..1567681bc 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -34,7 +34,11 @@ def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: Notes ----- - See Definition 2, Theorem 1 and Algorithm 1 in Mhalla and Perdrix, Finding Optimal Flows Efficiently, 2008 (arXiv:0709.2670). + See Definition 2, Theorem 1 and Algorithm 1 in Ref. [1]. + + References + ---------- + [1] Mhalla and Perdrix, (2008), Finding Optimal Flows Efficiently, doi.org/10.1007/978-3-540-70575-8_70 """ for measurement in og.measurements.values(): if measurement.to_plane() in {Plane.XZ, Plane.YZ}: diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index 4162235ea..cd2fd7f30 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -1,4 +1,4 @@ -"""Provides a class for open graphs.""" +"""Class for open graph states.""" from __future__ import annotations @@ -12,32 +12,35 @@ from graphix.measurements import Measurement if TYPE_CHECKING: - from collections.abc import Collection, Mapping + from collections.abc import Collection, Mapping, Sequence import networkx as nx from graphix.pattern import Pattern -# TODO -# I think we should treat Plane and Axes on the same footing (are likewise for Measurement and PauliMeasurement) -# Otherwise, shall we define Plane.XY-only open graphs. -# Maybe move these definitions to graphix.fundamentals and graphix.measurements ? - +# TODO: Maybe move these definitions to graphix.fundamentals and graphix.measurements ? _M_co = TypeVar("_M_co", bound=AbstractMeasurement, covariant=True) _PM_co = TypeVar("_PM_co", bound=AbstractPlanarMeasurement, covariant=True) @dataclass(frozen=True) class OpenGraph(Generic[_M_co]): - """Open graph contains the graph, measurement, and input and output nodes. - - This is the graph we wish to implement deterministically. - - :param graph: the underlying :class:`networkx.Graph` state - :param measurements: a dictionary whose key is the ID of a node and the - value is the measurement at that node - :param input_nodes: an ordered list of node IDs that are inputs to the graph - :param output_nodes: an ordered list of node IDs that are outputs of the graph + """An unmutable dataclass providing a representation of open graph states. + + Attributes + ---------- + graph : networkx.Graph[int] + The underlying resource-state graph. Nodes represent qubits and edges represent the application of :math:`CZ` gate on the linked nodes. + input_nodes : Sequence[int] + An ordered sequence of node labels corresponding to the open graph inputs. + output_nodes : Sequence[int] + An ordered sequence of node labels corresponding to the open graph outputs. + measurements : Mapping[int, _M_co] + A mapping between the non-output nodes of the open graph (`key`) and their corresponding measurement label (`value`). Measurement labels can be specified as `Measurement` or `Plane|Axis` instances. + + Notes + ----- + The inputs and outputs of `OpenGraph` instances in Graphix are defined as ordered sequences of node labels. This contrasts the usual definition of open graphs in the literature, where inputs and outputs are unordered sets of nodes labels. This restriction facilitates the interplay with `Pattern` objects, where the order of input and output nodes represents a choice of Hilbert space basis. Example ------- @@ -45,60 +48,52 @@ class OpenGraph(Generic[_M_co]): >>> from graphix.fundamentals import Plane >>> from graphix.opengraph import OpenGraph, Measurement >>> - >>> graph = nx.Graph([(0, 1), (1, 2), (2, 0)]) - >>> + >>> graph = nx.Graph([(0, 1), (1, 2)]) >>> measurements = {i: Measurement(0.5 * i, Plane.XY) for i in range(2)} >>> input_nodes = [0] >>> output_nodes = [2] - >>> og = OpenGraph(graph, measurements, input_nodes, output_nodes) + >>> og = OpenGraph(graph, input_nodes, output_nodes, measurements) """ graph: nx.Graph[int] - measurements: Mapping[int, _M_co] # TODO: Rename `measurement_labels` ? - input_nodes: list[int] # Inputs are ordered - output_nodes: list[int] # Outputs are ordered + input_nodes: Sequence[int] + output_nodes: Sequence[int] + measurements: Mapping[int, _M_co] def __post_init__(self) -> None: - """Validate the open graph.""" - if not set(self.measurements).issubset(self.graph.nodes): + """Validate the correctness of the open graph.""" + all_nodes = set(self.graph.nodes) + inputs = set(self.input_nodes) + outputs = set(self.output_nodes) + + if not set(self.measurements).issubset(all_nodes): raise ValueError("All measured nodes must be part of the graph's nodes.") - if not set(self.input_nodes).issubset(self.graph.nodes): + if not inputs.issubset(all_nodes): raise ValueError("All input nodes must be part of the graph's nodes.") - if not set(self.output_nodes).issubset(self.graph.nodes): + if not outputs.issubset(all_nodes): raise ValueError("All output nodes must be part of the graph's nodes.") - if set(self.output_nodes) & self.measurements.keys(): - raise ValueError("Output node cannot be measured.") - if len(set(self.input_nodes)) != len(self.input_nodes): + if outputs & self.measurements.keys(): + raise ValueError("Output nodes cannot be measured.") + if all_nodes - outputs != self.measurements.keys(): + raise ValueError("All non-ouptut nodes must be measured.") + if len(inputs) != len(self.input_nodes): raise ValueError("Input nodes contain duplicates.") - if len(set(self.output_nodes)) != len(self.output_nodes): + if len(outputs) != len(self.output_nodes): raise ValueError("Output nodes contain duplicates.") - # def isclose(self, other: OpenGraph, rel_Mol: float = 1e-09, abs_Mol: float = 0.0) -> bool: - # """Return `True` if two open graphs implement approximately the same unitary operator. - - # Ensures the structure of the graphs are the same and all - # measurement angles are sufficiently close. - - # This doesn't check they are equal up to an isomorphism. - - # """ - # if not nx.utils.graphs_equal(self.graph, other.graph): - # return False - - # if self.input_nodes != other.input_nodes or self.output_nodes != other.output_nodes: - # return False - - # if set(self.measurements.keys()) != set(other.measurements.keys()): - # return False - - # return all( - # m.isclose(other.measurements[node], rel_Mol=rel_Mol, abs_Mol=abs_Mol) - # for node, m in self.measurements.items() - # ) - @staticmethod def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: - """Initialise an `OpenGraph` object based on the resource-state graph associated with the measurement pattern.""" + """Initialise an `OpenGraph[Measurement]` object from the underlying resource-state graph of the input measurement pattern. + + Parameters + ---------- + pattern : Pattern + The input pattern. + + Returns + ------- + OpenGraph[Measurement] + """ graph = pattern.extract_graph() input_nodes = pattern.input_nodes @@ -110,100 +105,39 @@ def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: node: Measurement(meas_angles[node], meas_planes[node]) for node in meas_angles } - return OpenGraph(graph, measurements, input_nodes, output_nodes) - - # def to_pattern(self) -> Pattern: - # """Convert the `OpenGraph` into a `Pattern`. - - # Will raise an exception if the open graph does not have flow, gflow, or - # Pauli flow. - # The pattern will be generated using maximally-delayed flow. - # """ - # g = self.graph.copy() - # input_nodes = self.input_nodes - # output_nodes = self.output_nodes - # meas = self.measurements - - # angles = {node: m.angle for node, m in meas.items()} - # planes = {node: m.plane for node, m in meas.items()} - - # return graphix.generator.generate_from_graph(g, angles, input_nodes, output_nodes, planes) - - # def compose(self, other: OpenGraph[_M], mapping: Mapping[int, int]) -> tuple[OpenGraph[_M], dict[int, int]]: - # r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. - - # Parameters - # ---------- - # other : OpenGraph - # Open graph to be composed with `self`. - # mapping: dict[int, int] - # Partial relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node labels, respectively. - - # Returns - # ------- - # og: OpenGraph - # composed open graph - # mapping_complete: dict[int, int] - # Complete relabelling of the nodes in `other`, with `keys` and `values` denoting the old and new node label, respectively. - - # Notes - # ----- - # Let's denote :math:`\{G(V_1, E_1), I_1, O_1\}` the open graph `self`, :math:`\{G(V_2, E_2), I_2, O_2\}` the open graph `other`, :math:`\{G(V, E), I, O\}` the resulting open graph `og` and `{v:u}` an element of `mapping`. - - # We define :math:`V, U` the set of nodes in `mapping.keys()` and `mapping.values()`, and :math:`M = U \cap V_1` the set of merged nodes. - - # The open graph composition requires that - # - :math:`V \subseteq V_2`. - # - If both `v` and `u` are measured, the corresponding measurements must have the same plane and angle. - # The returned open graph follows this convention: - # - :math:`I = (I_1 \cup I_2) \setminus M \cup (I_1 \cap I_2 \cap M)`, - # - :math:`O = (O_1 \cup O_2) \setminus M \cup (O_1 \cap O_2 \cap M)`, - # - If only one node of the pair `{v:u}` is measured, this measure is assigned to :math:`u \in V` in the resulting open graph. - # - Input (and, respectively, output) nodes in the returned open graph have the order of the open graph `self` followed by those of the open graph `other`. Merged nodes are removed, except when they are input (or output) nodes in both open graphs, in which case, they appear in the order they originally had in the graph `self`. - # """ - # if not (mapping.keys() <= other.graph.nodes): - # raise ValueError("Keys of mapping must be correspond to nodes of other.") - # if len(mapping) != len(set(mapping.values())): - # raise ValueError("Values in mapping contain duplicates.") - # for v, u in mapping.items(): - # if ( - # (vm := other.measurements.get(v)) is not None - # and (um := self.measurements.get(u)) is not None - # and not vm.isclose(um) # TODO: How do we ensure that planes, axis, etc. are the same ? - # ): - # raise ValueError(f"Attempted to merge nodes {v}:{u} but have different measurements") - - # shift = max(*self.graph.nodes, *mapping.values()) + 1 - - # mapping_sequential = { - # node: i for i, node in enumerate(sorted(other.graph.nodes - mapping.keys()), start=shift) - # } # assigns new labels to nodes in other not specified in mapping - - # mapping_complete = {**mapping, **mapping_sequential} - - # g2_shifted = nx.relabel_nodes(other.graph, mapping_complete) - # g = nx.compose(self.graph, g2_shifted) - - # merged = set(mapping_complete.values()) & self.graph.nodes - - # def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: - # p2_mapped = [mapping_complete[node] for node in p2] - # p2_set = set(p2_mapped) - # part1 = [node for node in p1 if node not in merged or node in p2_set] - # part2 = [node for node in p2_mapped if node not in merged] - # return part1 + part2 - - # input_nodes = merge_ports(self.input_nodes, other.input_nodes) - # output_nodes = merge_ports(self.output_nodes, other.output_nodes) - - # measurements_shifted = {mapping_complete[i]: meas for i, meas in other.measurements.items()} - # measurements = {**self.measurements, **measurements_shifted} - - # return OpenGraph(g, measurements, input_nodes, output_nodes), mapping_complete - - # TODO: check if nodes in input belong to open graph ? + return OpenGraph(graph, input_nodes, output_nodes, measurements) + + def to_pattern(self: OpenGraph[Measurement]) -> Pattern | None: + """Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists. + + Returns + ------- + Pattern | None + A deterministic pattern on the open graph. If it does not exist, it returns `None`. + + Notes + ----- + - The open graph instance must be of parametric type `Measurement` to allow for a pattern extraction, otherwise is does not contain information about the measurement angles. + + - This method proceeds by searching a flow on the open graph and converting it into a pattern as prescripted in Ref. [1]. + It first attempts to find a causal flow because the corresponding flow-finding algorithm has lower complexity. If it fails, it attemps to find a Pauli flow because this property is more general than a generalised flow, and the corresponding flow-finding algorithms have the same complexity in the current implementation. + + References + ---------- + [1] Browne et al., NJP 9, 250 (2007) + """ + cflow = self.find_causal_flow() + if cflow is not None: + return cflow.to_corrections().to_pattern() + + pflow = self.find_pauli_flow() + if pflow is not None: + return pflow.to_corrections().to_pattern() + + return None + def neighbors(self, nodes: Collection[int]) -> set[int]: - """Return the set containing the neighborhood of a set of nodes. + """Return the set containing the neighborhood of a set of nodes in the open graph. Parameters ---------- @@ -221,7 +155,7 @@ def neighbors(self, nodes: Collection[int]) -> set[int]: return neighbors_set def odd_neighbors(self, nodes: Collection[int]) -> set[int]: - """Return the set containing the odd neighborhood of a set of nodes. + """Return the set containing the odd neighborhood of a set of nodes in the open graph. Parameters ---------- @@ -239,9 +173,41 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: return odd_neighbors_set def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow | None: + """Attempt to find a causal flow on the open graph. + + Returns + ------- + CausalFlow | None + A causal flow object if the open graph has causal flow, `None` otherwise. + + Notes + ----- + - The open graph instance must be of parametric type `Measurement` or `Plane` since the causal flow is only defined on open graphs with :math:`XY` measurements. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^2)`. + + References + ---------- + [1] Mhalla and Perdrix, (2008), Finding Optimal Flows Efficiently, doi.org/10.1007/978-3-540-70575-8_70 + """ return find_cflow(self) def find_gflow(self: OpenGraph[_PM_co]) -> GFlow | None: + r"""Attempt to find a generalised flow (gflow) on the open graph. + + Returns + ------- + GFlow | None + A gflow object if the open graph has gflow, `None` otherwise. + + Notes + ----- + - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`OpenGraph.find_pauli_flow`. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ aog = PlanarAlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: @@ -251,6 +217,22 @@ def find_gflow(self: OpenGraph[_PM_co]) -> GFlow | None: ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow | None: + r"""Attempt to find a generalised flow (gflow) on the open graph. + + Returns + ------- + PauliFlow | None + A Pauli flow object if the open graph has Pauli flow, `None` otherwise. + + Notes + ----- + - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`OpenGraph.find_gflow`. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ aog = AlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: From 50687b42a85a559345d3f71b10887dc3ae8e5ec7 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 30 Oct 2025 09:11:17 +0100 Subject: [PATCH 25/56] Up docs _find_cflow --- graphix/flow/_find_cflow.py | 40 +++++++++++++++++-------------------- graphix/flow/core.py | 5 ++--- tests/test_opengraph_.py | 3 +-- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 1567681bc..e5d166c67 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -1,3 +1,13 @@ +"""Causal flow finding algorithm. + +This module implements Algorithm 1 from Ref. [1]. For a given labelled open graph (G, I, O, meas_plane), this algorithm finds a causal flow [2] in polynomial time with the number of nodes, :math:`O(N^2)`. + +References +---------- +[1] Mhalla and Perdrix, (2008), Finding Optimal Flows Efficiently, doi.org/10.1007/978-3-540-70575-8_70 +[2] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212) +""" + from __future__ import annotations from copy import copy @@ -11,30 +21,24 @@ from graphix.opengraph_ import OpenGraph, _PM_co -# TODO: Up doc strings - def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: """Return the causal flow of the input open graph if it exists. Parameters ---------- - og : OpenGraph[Plane] + og : OpenGraph[_PM_co] Open graph whose causal flow is calculated. Returns ------- - cf : dict[int, set[int]] - Causal flow correction function. `cf[i]` is the one-qubit set correcting the measurement of qubit `i`. - layers : list[set[int]] - Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. - - or `None` - if the input open graph does not have a causal flow. + CausalFlow | None + A causal flow object if the open graph has causal flow, `None` otherwise. Notes ----- - See Definition 2, Theorem 1 and Algorithm 1 in Ref. [1]. + - See Definition 2, Theorem 1 and Algorithm 1 in Ref. [1]. + - The open graph instance must be of parametric type `Measurement` or `Plane` since the causal flow is only defined on open graphs with :math:`XY` measurements. References ---------- @@ -72,7 +76,7 @@ def _flow_aux( og : OpenGraph[Plane] Open graph whose causal flow is calculated. non_input_nodes : AbstractSet[int] - Non-input nodes of the input open graph. This parameter remains constant throughout the execution of the algorithm and can be derived from `og` at any time. It is passed as an argument to avoid unnecessary recalulations. + Non-input nodes of the input open graph. This parameter remains constant throughout the execution of the algorithm and can be derived from `og` at any time. It is passed as an argument to avoid unnecessary recalculations. corrected_nodes : AbstractSet[int] Nodes which have already been corrected. corrector_candidates : AbstractSet[int] @@ -85,13 +89,8 @@ def _flow_aux( Returns ------- - cf : dict[int, set[int]] - Causal flow correction function. `cf[i]` is the one-qubit set correcting the measurement of qubit `i`. - layers : list[set[int]] - Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. - - or `None` - if the input open graph does not have a causal flow. + CausalFlow | None + A causal flow object if the open graph has causal flow, `None` otherwise. """ corrected_nodes_new: set[int] = set() corrector_nodes_new: set[int] = set() @@ -114,9 +113,6 @@ def _flow_aux( layers.append(curr_layer) if len(corrected_nodes_new) == 0: - # TODO: This is the structure in the original graphix code. I think that we could check if non_corrected_nodes == empty before the loop and here just return None. - # if corrected_nodes == set(og.graph.nodes): - # return CausalFlow(og, cf, layers) return None corrected_nodes |= corrected_nodes_new diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 69a77f78a..7c774ef2a 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -74,8 +74,7 @@ def from_measured_nodes_mapping( dag = _corrections_to_dag(x_corrections, z_corrections) partial_order_layers = _dag_to_partial_order_layers(dag) - # The first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain a subset of the output nodes. - # The first element in `XZCorrections.partial_order_layers` should contain all output nodes. + # The first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain a subset of the output nodes, but the first element in `XZCorrections.partial_order_layers` should contain all output nodes. shift = 1 if partial_order_layers[0].issubset(outputs_set) else 0 partial_order_layers = [outputs_set, *partial_order_layers[shift:]] @@ -84,7 +83,7 @@ def from_measured_nodes_mapping( if not ordered_nodes.issubset(nodes_set): raise ValueError("Values of input mapping contain labels which are not nodes of the input open graph.") - # We append to the last layer (first measured nodes) all the non-output nodes not involved in the corrections + # We append to the last layer (first measured nodes) all the non-output nodes not involved in the . if unordered_nodes := nodes_set - ordered_nodes: partial_order_layers.append(unordered_nodes) diff --git a/tests/test_opengraph_.py b/tests/test_opengraph_.py index a3a8cc5ec..04e4586c1 100644 --- a/tests/test_opengraph_.py +++ b/tests/test_opengraph_.py @@ -4,7 +4,6 @@ Output correctness is verified by checking if the resulting pattern is deterministic (when the flow exists). """ - from __future__ import annotations from typing import TYPE_CHECKING, NamedTuple @@ -288,7 +287,7 @@ def _og_10() -> OpenGraphFlowTestCase: 1: Measurement(0.1, Plane.XZ), # XZ 2: Measurement(0.5, Plane.YZ), # Y 3: Measurement(0.1, Plane.XY), # XY - 4: Measurement(0, Plane.XZ), # Z + 4: Measurement(0, Plane.XZ), # Z }, ) return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) From 943782ab8ab3e3faec19aa96618ddae447547b2d Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 30 Oct 2025 09:51:33 +0100 Subject: [PATCH 26/56] Up docs _find_gpflow --- graphix/flow/_find_gpflow.py | 96 ++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 36 deletions(-) diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index 00cedfc11..403b26b46 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -1,7 +1,7 @@ """Pauli flow finding algorithm. This module implements the algorithm presented in [1]. For a given labelled open graph (G, I, O, meas_plane), this algorithm finds a maximally delayed Pauli flow [2] in polynomial time with the number of nodes, :math:`O(N^3)`. -If the input graph does not have Pauli measurements, the algorithm returns a general flow (gflow) if it exists by definition. +If the input graph does not have Pauli measurements, the algorithm returns a generalised flow (gflow) if it exists by definition. References ---------- @@ -33,7 +33,7 @@ class AlgebraicOpenGraph(Generic[_M_co]): - """A class for providing an algebraic representation of open graphs as introduced in [1]. In particular, it allows managing the mapping between node labels of the graph and the relevant matrix indices. The flow demand and order demand matrices appear as cached properties. + """A class for providing an algebraic representation of open graphs as introduced in [1]. In particular, it allows managing the mapping between node labels of the graph and the relevant matrix indices. The flow-demand and order-demand matrices are cached properties. It reuses the class `:class: graphix.sim.base_backend.NodeIndex` introduced for managing the mapping between node numbers and qubit indices in the internal state of the backend. @@ -46,7 +46,7 @@ class AlgebraicOpenGraph(Generic[_M_co]): Notes ----- - At initialization, `non_outputs_optim` is a copy of `non_outputs`. The nodes corresponding to zero-rows of the order-demand matrix are removed for calculating the P matrix more efficiently in the `:func: _compute_correction_matrix_general` routine. + At initialization, `non_outputs_optim` is a copy of `non_outputs`. The nodes corresponding to zero-rows of the order-demand matrix are removed for calculating the :math:`P` matrix more efficiently in the `:func: _compute_correction_matrix_general` routine. References ---------- @@ -54,6 +54,13 @@ class AlgebraicOpenGraph(Generic[_M_co]): """ def __init__(self, og: OpenGraph[_M_co]) -> None: + """Initialize AlgebraicOpenGraph objects. + + Parameters + ---------- + og : OpenGraph[_M_co] + The open graph in its standard representation. + """ self.og = og nodes = set(og.graph.nodes) @@ -73,19 +80,36 @@ def __init__(self, og: OpenGraph[_M_co]) -> None: @property def flow_demand_matrix(self) -> MatGF2: + """Return the flow-demand matrix. + + Returns + ------- + MatGF2 + Flow-demand matrix + + Notes + ----- + See Definition 3.4 and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ return self._compute_og_matrices[0] @property def order_demand_matrix(self) -> MatGF2: + """Return the flow-demand matrix. + + Returns + ------- + MatGF2 + Order-demand matrix + + Notes + ----- + See Definition 3.5 and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ return self._compute_og_matrices[1] def _compute_reduced_adj(self) -> MatGF2: - r"""Return the reduced adjacency matrix (RAdj) of the input open graph. - - Parameters - ---------- - aog : AlgebraicOpenGraph - Open graph whose RAdj is computed. + r"""Return the reduced adjacency matrix (RAdj) of the open graph. Returns ------- @@ -116,7 +140,7 @@ def _compute_reduced_adj(self) -> MatGF2: @cached_property def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: - r"""Construct flow-demand and order-demand matrices. + r"""Construct the flow-demand and order-demand matrices. Returns ------- @@ -125,7 +149,8 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: Notes ----- - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + - Measurements with a Pauli angle are intepreted as `Axis` instances. + - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ flow_demand_matrix = self._compute_reduced_adj() order_demand_matrix = flow_demand_matrix.copy() @@ -154,6 +179,12 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: class PlanarAlgebraicOpenGraph(AlgebraicOpenGraph[_PM_co]): + """A subclass of `AlgebraicOpenGraph`. + + This class differs from its parent class only in that Pauli measurements are interpreted as `Plane` instances (instead of `Axis`) when constructing the flow-demand and order-demand matrices. This allows to verify if open graphs with measurements along Pauli angles interpreted as planes have generalised flow. + + """ + @cached_property def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: r"""Construct flow-demand and order-demand matrices assuming that the underlying open graph has planar measurements only. @@ -165,7 +196,8 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: Notes ----- - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + - Measurements with a Pauli angle are intepreted as `Plane` instances. + - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ flow_demand_matrix = self._compute_reduced_adj() order_demand_matrix = flow_demand_matrix.copy() @@ -194,7 +226,7 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: class CorrectionMatrix(NamedTuple, Generic[_M_co]): - r"""A dataclass to bundle the correction matrix and the open graph to which it refers. + r"""A dataclass to bundle the correction matrix and its associated open graph. Attributes ---------- @@ -211,6 +243,7 @@ class CorrectionMatrix(NamedTuple, Generic[_M_co]): aog: AlgebraicOpenGraph[_M_co] c_matrix: MatGF2 + # TODO this method is not tested yet @staticmethod def from_correction_function( og: OpenGraph[_M_co], correction_function: Mapping[int, set[int]] @@ -219,23 +252,23 @@ def from_correction_function( Parameters ---------- - og : OpenGraph + og : OpenGraph[_M_co] The open graph relative to which the correction function is defined. correction_function : dict[int, set[int]] Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. Returns ------- - c_matrix : MatGF2 - Matrix encoding the correction function. + CorrectionMatrix[_M_co] + Algebraic representation of the correction function. Notes ----- - This function is not required to find a Pauli (or generalised) flow on an open graph but is a useful auxiliary method to verify the validity of a flow encoded in a correction function. + This function is not required to find a Pauli (or generalised) flow on an open graph but is an auxiliary method to initialize a flow from a correction function. """ aog = AlgebraicOpenGraph( og - ) # TODO: Is it a problem that we instatiate an AOGPauliFlow, regardless of the type of og ? + ) # TODO: Is it a problem that we instatiate an AlgebraicOpenGraph (instead of Planar...), regardless of the type of og ? row_tags = aog.non_inputs col_tags = aog.non_outputs @@ -594,10 +627,8 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> Parameters ---------- - aog : AlgebraicOpenGraph - Open graph whose Pauli flow is calculated. - correction_matrix : MatGF2 - Matrix encoding the correction function. + correction_matrix : CorrectionMatrix[_M_co] + Algebraic representation of the correction function. Returns ------- @@ -605,9 +636,7 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. or `None` - If the correction matrix is not compatible with a partial order on the the open graph, - - if the ordering matrix is not a DAG, in which case the input open graph does not have Pauli flow. + If the correction matrix is not compatible with a partial order on the the open graph, in which case the associated ordering matrix is not a DAG. In the context of the flow-finding algorithm, this means that the input open graph does not have Pauli (or generalised) flow. Notes ----- @@ -636,24 +665,22 @@ def compute_correction_matrix(aog: AlgebraicOpenGraph[_M_co]) -> CorrectionMatri Parameters ---------- - og : OpenGraph - Open graph whose correction matrix is calculated. + aog : AlgebraicOpenGraph[_M_co] + Algberaic representation of the open graph whose correction matrix is calculated. Returns ------- - aog : AlgebraicOpenGraph - Algebraic representation of the open graph. This object encodes the mapping between the node labels in the input open graph and the row and column indices of the returned correction matrix - correction_matrix : MatGF2 - Matrix encoding the correction function. + correction_matrix : CorrectionMatrix[_M_co] + Algebraic representation of the correction function. or `None` if the input open graph does not have Pauli (or generalised) flow. Notes ----- - - In the case of open graphs with equal number of inputs and outputs, the function only returns `None` when the flow-demand matrix is not invertible (meaning that `aog` does not have Pauli flow). The additional condition for the existence of Pauli flow that the ordering matrix :math:`NC` must encode a directed acyclic graph (DAG) is verified by :func:`compute_partial_order`, which is called from the `graphix.flow.flow.PauliFlow` constructor. + - In the case of open graphs with equal number of inputs and outputs, the function only returns `None` when the flow-demand matrix is not invertible (meaning that `aog` does not have Pauli flow). The additional condition for the existence of Pauli flow that the ordering matrix :math:`NC` must encode a directed acyclic graph (DAG) is verified by :func:`compute_partial_order`, which is called from the `graphix.flow.core.PauliFlow` constructor. - See Definitions 3.4, 3.5 and 3.6, Theorems 3.1, 4.2 and 4.4, and Algorithms 2 and 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + - See Definitions 3.4, 3.5 and 3.6, Theorems 3.1, 4.2 and 4.4, and Algorithms 2 and 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ ni = len(aog.og.input_nodes) no = len(aog.og.output_nodes) @@ -674,6 +701,3 @@ def compute_correction_matrix(aog: AlgebraicOpenGraph[_M_co]) -> CorrectionMatri return None return CorrectionMatrix(aog, correction_matrix) - - -# TODO: When should inputs be parametrized with `_M_co` and when with `AbstractMeasurement` ? From 4c90bbe003fbdbd13c7898e44ded672760ddce05 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 30 Oct 2025 11:46:25 +0100 Subject: [PATCH 27/56] Add docs flow.core --- graphix/flow/_find_cflow.py | 2 +- graphix/flow/_find_gpflow.py | 41 +-- graphix/flow/core.py | 469 ++++++++++++----------------------- graphix/opengraph_.py | 25 +- 4 files changed, 177 insertions(+), 360 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index e5d166c67..79d0c6890 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -84,7 +84,7 @@ def _flow_aux( cf : dict[int, set[int]] Causal flow correction function. `cf[i]` is the one-qubit set correcting the measurement of qubit `i`. layers : list[set[int]] - Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. + Partial order between corrected qubits in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. Returns diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index 403b26b46..d9b9c1646 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -22,7 +22,6 @@ from graphix.sim.base_backend import NodeIndex if TYPE_CHECKING: - from collections.abc import Mapping from collections.abc import Set as AbstractSet from graphix.opengraph_ import OpenGraph @@ -243,44 +242,6 @@ class CorrectionMatrix(NamedTuple, Generic[_M_co]): aog: AlgebraicOpenGraph[_M_co] c_matrix: MatGF2 - # TODO this method is not tested yet - @staticmethod - def from_correction_function( - og: OpenGraph[_M_co], correction_function: Mapping[int, set[int]] - ) -> CorrectionMatrix[_M_co]: - r"""Initialise a `CorrectionMatrix` object from a correction function. - - Parameters - ---------- - og : OpenGraph[_M_co] - The open graph relative to which the correction function is defined. - correction_function : dict[int, set[int]] - Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. - - Returns - ------- - CorrectionMatrix[_M_co] - Algebraic representation of the correction function. - - Notes - ----- - This function is not required to find a Pauli (or generalised) flow on an open graph but is an auxiliary method to initialize a flow from a correction function. - """ - aog = AlgebraicOpenGraph( - og - ) # TODO: Is it a problem that we instatiate an AlgebraicOpenGraph (instead of Planar...), regardless of the type of og ? - row_tags = aog.non_inputs - col_tags = aog.non_outputs - - c_matrix = MatGF2(np.zeros((len(row_tags), len(col_tags)), dtype=np.uint8)) - - for node, correction_set in correction_function.items(): - col = col_tags.index(node) - for corrector in correction_set: - row = row_tags.index(corrector) - c_matrix[row, col] = 1 - return CorrectionMatrix(aog, c_matrix) - def to_correction_function(self) -> dict[int, set[int]]: r"""Transform the correction matrix into a correction function. @@ -633,7 +594,7 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> Returns ------- layers : list[set[int]] - Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer 0 are the "largest" nodes in the partial order. + Partial order between corrected qubits in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. or `None` If the correction matrix is not compatible with a partial order on the the open graph, in which case the associated ordering matrix is not a DAG. In the context of the flow-finding algorithm, this means that the input open graph does not have Pauli (or generalised) flow. diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 7c774ef2a..6a62499ec 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -1,16 +1,14 @@ -"""Module for flow classes.""" +"""Class for flow objects and XZ-corrections.""" from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass from itertools import product -from typing import TYPE_CHECKING, Generic, Self, override +from typing import TYPE_CHECKING, Generic, override import networkx as nx -import numpy as np -from graphix._linalg import MatGF2 from graphix.command import E, M, N, X, Z from graphix.flow._find_gpflow import CorrectionMatrix, _M_co, _PM_co, compute_partial_order_layers from graphix.pattern import Pattern @@ -28,11 +26,29 @@ @dataclass(frozen=True) class XZCorrections(Generic[_M_co]): + """An unmutable dataclass providing a representation of XZ-corrections. + + Attributes + ---------- + og : OpenGraph[_M_co] + The open graph with respect to which the XZ-corrections are defined. + x_corrections : Mapping[int, AbstractSet[int]] + Mapping of X-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an X-correction must be applied depending on the measurement result of `key`. + z_corrections : Mapping[int, AbstractSet[int]] + Mapping of Z-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an Z-correction must be applied depending on the measurement result of `key`. + partial_order_layers : Sequence[AbstractSet[int]] + Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + + Notes + ----- + The XZ-corrections mappings define a partial order, therefore, only `og`, `x_corrections` and `z_corrections` are necessary to initialize an `XZCorrections` instance (see :func:`XZCorrections.from_measured_nodes_mapping`). However, XZ-corrections are often extracted from a flow whose partial order is known and can be used to construct a pattern, so it can also be passed as an argument to the `dataclass` constructor. The correctness of the input parameters is not verified automatically. + + """ + og: OpenGraph[_M_co] x_corrections: Mapping[int, AbstractSet[int]] # {domain: nodes} z_corrections: Mapping[int, AbstractSet[int]] # {domain: nodes} partial_order_layers: Sequence[AbstractSet[int]] - # Often XZ-corrections are extracted from a flow whose partial order can be used to construct a pattern from the corrections. We store it to avoid recalculating it twice. @staticmethod def from_measured_nodes_mapping( @@ -74,6 +90,11 @@ def from_measured_nodes_mapping( dag = _corrections_to_dag(x_corrections, z_corrections) partial_order_layers = _dag_to_partial_order_layers(dag) + if partial_order_layers is None: + raise ValueError( + "Input XZ-corrections are not runnable since the induced directed graph contains closed loops." + ) + # The first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain a subset of the output nodes, but the first element in `XZCorrections.partial_order_layers` should contain all output nodes. shift = 1 if partial_order_layers[0].issubset(outputs_set) else 0 @@ -107,7 +128,13 @@ def to_pattern( Notes ----- - The resulting pattern is guaranteed to be runnable if the `XZCorrections` object is well formed, but does not need to be deterministic. It will be deterministic if the XZ-corrections were inferred from a flow. In this case, this routine follows the recipe in Theorems 1, 2 and 4 of Browne et al., NJP 9, 250 (2007). + - The `XZCorrections` instance must be of parametric type `Measurement` to allow for a pattern extraction, otherwise the underlying open graph does not contain information about the measurement angles. + + - The resulting pattern is guaranteed to be runnable if the `XZCorrections` object is well formed, but does not need to be deterministic. It will be deterministic if the XZ-corrections were inferred from a flow. In this case, this routine follows the recipe in Theorems 1, 2 and 4 in Ref. [1]. + + References + ---------- + [1] Browne et al., NJP 9, 250 (2007). """ if total_measurement_order is None: total_measurement_order = self.generate_total_measurement_order() @@ -150,7 +177,7 @@ def generate_total_measurement_order(self) -> TotalOrder: return total_order def extract_dag(self) -> nx.DiGraph[int]: - """Extract the directed graph induced by the corrections. + """Extract the directed graph induced by the XZ-corrections. Returns ------- @@ -199,47 +226,59 @@ def is_compatible(self, total_measurement_order: TotalOrder) -> bool: return True - # def is_wellformed(self) -> bool: - # """Verify if `Corrections` object is well formed. - - # Returns - # ------- - # bool - # `True` if `self` is well formed, `False` otherwise. - - # Notes - # ----- - # This method verifies that: - # - Corrected nodes belong to the underlying open graph. - # - Nodes in domain set are measured. - # - Corrections are runnable. This amounts to verifying that the corrections-induced directed graph does not have loops. - # """ - # for corr_type in ["X", "Z"]: - # corrections = getattr(self, f"{corr_type.lower()}_corrections") - # for node, domain in corrections.items(): - # if node not in self.og.graph.nodes: - # print( - # f"Cannot apply {corr_type} correction. Corrected node {node} does not belong to the open graph." - # ) - # return False - # if not domain.issubset(self.og.measurements): - # print(f"Cannot apply {corr_type} correction. Domain nodes {domain} are not measured.") - # return False - # if nx.is_directed_acyclic_graph(self.extract_dag()): - # print("Corrections are not runnable since the induced directed graph contains cycles.") - # return False - - # return True - @dataclass(frozen=True) class PauliFlow(Generic[_M_co]): + """An unmutable dataclass providing a representation of a Pauli flow. + + Attributes + ---------- + og : OpenGraph[_M_co] + The open graph with respect to which the Pauli flow is defined. + correction_function : Mapping[int, AbstractSet[int] + Pauli flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. + partial_order_layers : Sequence[AbstractSet[int]] + Partial order between corrected qubits in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + + Notes + ----- + - See Definition 5 in Ref. [1] for a definition of Pauli flow. + + - The flow's correction function defines a partial order (see Def. 2.8 and 2.9, Lemma 2.11 and Theorem 2.12 in Ref. [2]), therefore, only `og` and `correction_function` are necessary to initialize an `PauliFlow` instance (see :func:`PauliFlow.from_correction_matrix`). However, flow-finding algorithms generate a partial order in a layer form, which is necessary to extract the flow's XZ-corrections, so it is stored as an attribute. + + References + ---------- + [1] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212). + [2] Mitosek and Backens, 2024 (arXiv:2410.23439). + + """ + og: OpenGraph[_M_co] - correction_function: Mapping[int, set[int]] + correction_function: Mapping[int, AbstractSet[int]] partial_order_layers: Sequence[AbstractSet[int]] @classmethod def from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_M_co]) -> Self | None: + """Initialize a Pauli flow object from a matrix encoding a correction function. + + Attributes + ---------- + correction_matrix : CorrectionMatrix[_M_co] + Algebraic representation of the correction function. + + Returns + ------- + Self | None + A Pauli flow if it exists, `None` otherwise. + + Notes + ----- + This method verifies if there exists a partial measurement order on the input open graph compatible with the input correction matrix. See Lemma 3.12, and Theorem 3.1 in Ref. [1]. Failure to find a partial order implies the non-existence of a Pauli flow if the correction matrix was calculated by means of Algorithms 2 and 3 in [1]. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ correction_function = correction_matrix.to_correction_function() partial_order_layers = compute_partial_order_layers(correction_matrix) if partial_order_layers is None: @@ -252,15 +291,19 @@ def to_corrections(self) -> XZCorrections[_M_co]: Returns ------- - Corrections[_M_co] + XZCorrections[_M_co] Notes ----- - This function partially implements Theorem 4 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. + This method partially implements Theorem 4 in [1]. The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. + + References + ---------- + [1] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212). """ future = self.partial_order_layers[0] - x_corrections: dict[int, set[int]] = {} # {domain: nodes} - z_corrections: dict[int, set[int]] = {} # {domain: nodes} + x_corrections: dict[int, AbstractSet[int]] = {} # {domain: nodes} + z_corrections: dict[int, AbstractSet[int]] = {} # {domain: nodes} for layer in self.partial_order_layers[1:]: for measured_node in layer: @@ -275,73 +318,42 @@ def to_corrections(self) -> XZCorrections[_M_co]: return XZCorrections(self.og, x_corrections, z_corrections, self.partial_order_layers) - def is_well_formed(self) -> bool: - r"""Verify if flow object is well formed. - - Returns - ------- - bool - `True` if `self` is well formed, `False` otherwise. - Notes - ----- - This method verifies that: - - The correction function's domain and codomain respectively are non-output and non-input nodes. - - The product of the flow-demand and the correction matrices is the identity matrix, :math:`MC = \mathbb{1}`. - - The product of the order-demand and the correction matrices is the adjacency matrix of a DAG compatible with `self.partial_order_layers`. - """ - domain = set(self.correction_function) - if not domain.intersection(self.og.output_nodes): - print("Invalid flow. Domain of the correction function includes output nodes.") - return False - - codomain = set().union(*self.correction_function.values()) - if not codomain.intersection(self.og.input_nodes): - print("Invalid flow. Codomain of the correction function includes input nodes.") - return False - - correction_matrix = CorrectionMatrix.from_correction_function(self.og, self.correction_function) - - aog, c_matrix = correction_matrix - - identity = MatGF2(np.eye(len(aog.non_outputs), dtype=np.uint8)) - mc_matrix = aog.flow_demand_matrix.mat_mul(c_matrix) - if not np.all(mc_matrix == identity): - print( - "Invalid flow. The product of the flow-demand and the correction matrices is not the identity matrix, MC ≠ 1" - ) - return False +@dataclass(frozen=True) +class GFlow(PauliFlow[_PM_co], Generic[_PM_co]): + """An unmutable subclass of `PauliFlow` providing a representation of a generalised flow (gflow). - partial_order_layers = compute_partial_order_layers(correction_matrix) - if partial_order_layers is None: - print( - "Invalid flow. The correction function is not compatible with a partial order on the open graph. The product of the order-demand and the correction matrices NC does not form a DAG." - ) - return False + This class differs from its parent class in the following: + - It cannot be constructed from `OpenGraph[Axis]` instances, since the gflow is only defined for planar measurements. + - The extraction of XZ-corrections from the gflow does not require knowledge on the partial order. + - The method :func:`GFlow.is_well_formed` verifies the definition of gflow (Definition 2.36 in Ref. [1]). - # TODO: Verify that self.partial_order_layers is compatible with partial_order_layers + References + ---------- + [1] Backens et al., Quantum 5, 421 (2021), doi.org/10.22331/q-2021-03-25-421 - return True + """ - -@dataclass(frozen=True) -class GFlow(PauliFlow[_PM_co], Generic[_PM_co]): @override def to_corrections(self) -> XZCorrections[_PM_co]: - r"""Compute the X and Z corrections induced by the generalised flow encoded in `self`. + r"""Compute the XZ-corrections induced by the generalised flow encoded in `self`. Returns ------- - Corrections[Plane] + XZCorrections[_PM_co] Notes ----- - - This function partially implements Theorem 2 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. + - This function partially implements Theorem 2 in Ref. [1]. The generated XZ-corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. - Contrary to the overridden method in the parent class, here we do not need any information on the partial order to build the corrections since a valid correction function :math:`g` guarantees that both :math:`g(i)\setminus \{i\}` and :math:`Odd(g(i))` are in the future of :math:`i`. + + References + ---------- + [1] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212). """ - x_corrections: dict[int, set[int]] = {} # {domain: nodes} - z_corrections: dict[int, set[int]] = {} # {domain: nodes} + x_corrections: dict[int, AbstractSet[int]] = {} # {domain: nodes} + z_corrections: dict[int, AbstractSet[int]] = {} # {domain: nodes} for measured_node, correcting_set in self.correction_function.items(): # Conditionals avoid storing empty correction sets @@ -354,9 +366,19 @@ def to_corrections(self) -> XZCorrections[_PM_co]: @dataclass(frozen=True) -class CausalFlow( - GFlow[_PM_co], Generic[_PM_co] -): # TODO: change parametric type to Plane.XY. Requires defining Plane.XY as subclasses of Plane +class CausalFlow(GFlow[_PM_co], Generic[_PM_co]): + """An unmutable subclass of `GFlow` providing a representation of a causal flow. + + This class differs from its parent class in the following: + - The extraction of XZ-corrections from the causal flow does assumes that correction sets have one element only. + - The method :func:`CausalFlow.is_well_formed` verifies the definition of causal flow (Definition 2 in Ref. [1]). + + References + ---------- + [1] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212). + + """ + @override @classmethod def from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_PM_co]) -> None: @@ -364,18 +386,22 @@ def from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_PM_co]) -> @override def to_corrections(self) -> XZCorrections[_PM_co]: - r"""Compute the X and Z corrections induced by the causal flow encoded in `self`. + r"""Compute the XZ-corrections induced by the causal flow encoded in `self`. Returns ------- - Corrections[Plane] + XZCorrections[_PM_co] Notes ----- - This function partially implements Theorem 1 of Browne et al., NJP 9, 250 (2007). The generated X and Z corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. + This function partially implements Theorem 1 in Ref. [1]. The generated XZ-corrections can be used to obtain a robustly deterministic pattern on the underlying open graph. + + References + ---------- + [1] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212). """ - x_corrections: dict[int, set[int]] = {} # {domain: nodes} - z_corrections: dict[int, set[int]] = {} # {domain: nodes} + x_corrections: dict[int, AbstractSet[int]] = {} # {domain: nodes} + z_corrections: dict[int, AbstractSet[int]] = {} # {domain: nodes} for measured_node, correcting_set in self.correction_function.items(): # Conditionals avoid storing empty correction sets @@ -390,6 +416,24 @@ def to_corrections(self) -> XZCorrections[_PM_co]: def _corrections_to_dag( x_corrections: Mapping[int, AbstractSet[int]], z_corrections: Mapping[int, AbstractSet[int]] ) -> nx.DiGraph[int]: + """Convert an XZ-corrections mapping into a directed graph. + + Parameters + ---------- + x_corrections : Mapping[int, AbstractSet[int]] + Mapping of X-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an X-correction must be applied depending on the measurement result of `key`. + z_corrections : Mapping[int, AbstractSet[int]] + Mapping of Z-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an Z-correction must be applied depending on the measurement result of `key`. + + Returns + ------- + nx.DiGraph[int] + Directed graph in which an edge `i -> j` represents a correction applied to qubit `j`, conditioned on the measurement outcome of qubit `i`. + + Notes + ----- + See :func:`XZCorrections.extract_dag`. + """ relations: set[tuple[int, int]] = set() for measured_node, corrected_nodes in x_corrections.items(): @@ -401,212 +445,23 @@ def _corrections_to_dag( return nx.DiGraph(relations) -def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[set[int]]: +def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[set[int]] | None: + """Return the partial order encoded in a directed graph in a layer form if it exists. + + Parameters + ---------- + dag : nx.DiGraph[int] + A directed graph. + + Returns + ------- + list[set[int]] | None + Partial order between corrected qubits in a layer form or `None` if the input directed graph is not acyclical. + The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + """ try: topo_gen = reversed(list(nx.topological_generations(dag))) except nx.NetworkXUnfeasible: - raise ValueError( - "XZ-corrections are not runnable since the induced directed graph contains closed loops." - ) from nx.NetworkXUnfeasible + return None return [set(layer) for layer in topo_gen] - - -########### -# OLD functions -########### - - -# @dataclass(frozen=True) -# class PartialOrder: -# """Class for storing and manipulating the partial order in a flow. - -# Attributes -# ---------- -# dag: nx.DiGraph[int] -# Directed Acyclical Graph (DAG) representing the partial order. The transitive closure of `dag` yields all the relations in the partial order. - -# layers: Mapping[int, AbstractSet[int]] -# Mapping storing the partial order in a layer structure. -# The pair `(key, value)` corresponds to the layer and the set of nodes in that layer. -# Layer 0 corresponds to the largest nodes in the partial order. In general, if `i > j`, then nodes in `layers[j]` are in the future of nodes in `layers[i]`. - -# """ - -# dag: nx.DiGraph[int] -# layers: Mapping[int, AbstractSet[int]] - -# @classmethod -# def from_adj_matrix(cls, adj_mat: npt.NDArray[np.uint8], nodelist: Collection[int] | None = None) -> PartialOrder: -# """Construct a partial order from an adjacency matrix representing a DAG. - -# Parameters -# ---------- -# adj_mat: npt.NDArray[np.uint8] -# Adjacency matrix of the DAG. A nonzero element `adj_mat[i,j]` represents a link `i -> j`. -# node_list: Collection[int] | None -# Mapping between matrix indices and node labels. Optional, defaults to `None`. - -# Returns -# ------- -# PartialOrder - -# Notes -# ----- -# The `layers` attribute of the `PartialOrder` attribute is obtained by performing a topological sort on the DAG. This routine verifies that the input directed graph is indeed acyclical. See :func:`_compute_layers_from_dag` for more details. -# """ -# dag = nx.from_numpy_array(adj_mat, create_using=nx.DiGraph, nodelist=nodelist) -# layers = _compute_layers_from_dag(dag) -# return cls(dag=dag, layers=layers) - -# @classmethod -# def from_relations(cls, relations: Collection[tuple[int, int]]) -> PartialOrder: -# """Construct a partial order from the order relations. - -# Parameters -# ---------- -# relations: Collection[tuple[int, int]] -# Collection of relations in the partial order. A tuple `(a, b)` represents `a > b` in the partial order. - -# Returns -# ------- -# PartialOrder - -# Notes -# ----- -# The `layers` attribute of the `PartialOrder` attribute is obtained by performing a topological sort on the DAG. This routine verifies that the input directed graph is indeed acyclical. See :func:`_compute_layers_from_dag` for more details. -# """ -# dag = nx.DiGraph(relations) -# layers = _compute_layers_from_dag(dag) -# return cls(dag=dag, layers=layers) - -# @classmethod -# def from_layers(cls, layers: Mapping[int, AbstractSet[int]]) -> PartialOrder: -# dag = _compute_dag_from_layers(layers) -# return cls(dag=dag, layers=layers) - -# @classmethod -# def from_corrections(cls, corrections: XZCorrections) -> PartialOrder: -# relations: set[tuple[int, int]] = set() - -# for node, domain in corrections.x_corrections.items(): -# relations.update(product([node], domain)) - -# for node, domain in corrections.z_corrections.items(): -# relations.update(product([node], domain)) - -# return cls.from_relations(relations) - -# @property -# def nodes(self) -> set[int]: -# """Return nodes in the partial order.""" -# return set(self.dag.nodes) - -# @property -# def node_layer_mapping(self) -> dict[int, int]: -# """Return layers in the form `{node: layer}`.""" -# mapping: dict[int, int] = {} -# for layer, nodes in self.layers.items(): -# mapping.update(dict.fromkeys(nodes, layer)) - -# return mapping - -# @cached_property -# def transitive_closure(self) -> set[tuple[int, int]]: -# """Return the transitive closure of the Directed Acyclic Graph (DAG) encoding the partial order. - -# Returns -# ------- -# set[tuple[int, int]] -# A tuple `(i, j)` belongs to the transitive closure of the DAG if `i > j` according to the partial order. -# """ -# return set(nx.transitive_closure_dag(self.dag).edges()) - -# def greater(self, a: int, b: int) -> bool: -# """Verify order between two nodes. - -# Parameters -# ---------- -# a : int -# b : int - -# Returns -# ------- -# bool -# `True` if `a > b` in the partial order, `False` otherwise. - -# Raises -# ------ -# ValueError -# If either node `a` or `b` is not included in the definition of the partial order. -# """ -# if a not in self.nodes: -# raise ValueError(f"Node a = {a} is not included in the partial order.") -# if b not in self.nodes: -# raise ValueError(f"Node b = {b} is not included in the partial order.") -# return (a, b) in self.transitive_closure - -# def compute_future(self, node: int) -> set[int]: -# """Compute the future of `node`. - -# Parameters -# ---------- -# node : int -# Node for which the future is computed. - -# Returns -# ------- -# set[int] -# Set of nodes `i` such that `i > node` in the partial order. -# """ -# if node not in self.nodes: -# raise ValueError(f"Node {node} is not included in the partial order.") - -# return {i for i, j in self.transitive_closure if j == node} - -# def is_compatible(self, other: PartialOrder) -> bool: -# r"""Verify compatibility between two partial orders. - -# Parameters -# ---------- -# other : PartialOrder - -# Returns -# ------- -# bool -# `True` if partial order `self` is compatible with partial order `other`, `False` otherwise. - -# Notes -# ----- -# We define partial-order compatibility as follows: -# A partial order :math:`<_P` on a set :math:`U` is compatible with a partial order :math:`<_Q` on a set :math:`V` iff :math:`a <_P b \rightarrow a <_Q b \forall a, b \in U`. -# This definition of compatibility requires that :math:`U \subseteq V`. -# Further, it is not symmetric. -# """ -# return self.transitive_closure.issubset(other.transitive_closure) - - -# def _compute_layers_from_dag(dag: nx.DiGraph[int]) -> dict[int, set[int]]: -# try: -# generations = reversed(list(nx.topological_generations(dag))) -# return {layer: set(generation) for layer, generation in enumerate(generations)} -# except nx.NetworkXUnfeasible as exc: -# raise ValueError("Partial order contains loops.") from exc - - -# def _compute_dag_from_layers(layers: Mapping[int, AbstractSet[int]]) -> nx.DiGraph[int]: -# max_layer = max(layers) -# relations: list[tuple[int, int]] = [] -# visited_nodes: set[int] = set() - -# for i, j in pairwise(reversed(range(max_layer + 1))): -# layer_curr, layer_next = layers[i], layers[j] -# if layer_curr & visited_nodes: -# raise ValueError(f"Layer {i} contains nodes in previous layers.") -# visited_nodes |= layer_curr -# relations.extend(product(layer_curr, layer_next)) - -# if layers[0] & visited_nodes: -# raise ValueError(f"Layer {i} contains nodes in previous layers.") - -# return nx.DiGraph(relations) diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index cd2fd7f30..270ffc251 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -19,6 +19,7 @@ from graphix.pattern import Pattern # TODO: Maybe move these definitions to graphix.fundamentals and graphix.measurements ? +# They are redefined in graphix.flow._find_gpflow, not very elegant. _M_co = TypeVar("_M_co", bound=AbstractMeasurement, covariant=True) _PM_co = TypeVar("_PM_co", bound=AbstractPlanarMeasurement, covariant=True) @@ -29,14 +30,14 @@ class OpenGraph(Generic[_M_co]): Attributes ---------- - graph : networkx.Graph[int] - The underlying resource-state graph. Nodes represent qubits and edges represent the application of :math:`CZ` gate on the linked nodes. - input_nodes : Sequence[int] - An ordered sequence of node labels corresponding to the open graph inputs. - output_nodes : Sequence[int] - An ordered sequence of node labels corresponding to the open graph outputs. - measurements : Mapping[int, _M_co] - A mapping between the non-output nodes of the open graph (`key`) and their corresponding measurement label (`value`). Measurement labels can be specified as `Measurement` or `Plane|Axis` instances. + graph : networkx.Graph[int] + The underlying resource-state graph. Nodes represent qubits and edges represent the application of :math:`CZ` gate on the linked nodes. + input_nodes : Sequence[int] + An ordered sequence of node labels corresponding to the open graph inputs. + output_nodes : Sequence[int] + An ordered sequence of node labels corresponding to the open graph outputs. + measurements : Mapping[int, _M_co] + A mapping between the non-output nodes of the open graph (`key`) and their corresponding measurement label (`value`). Measurement labels can be specified as `Measurement` or `Plane|Axis` instances. Notes ----- @@ -117,7 +118,7 @@ def to_pattern(self: OpenGraph[Measurement]) -> Pattern | None: Notes ----- - - The open graph instance must be of parametric type `Measurement` to allow for a pattern extraction, otherwise is does not contain information about the measurement angles. + - The open graph instance must be of parametric type `Measurement` to allow for a pattern extraction, otherwise it does not contain information about the measurement angles. - This method proceeds by searching a flow on the open graph and converting it into a pattern as prescripted in Ref. [1]. It first attempts to find a causal flow because the corresponding flow-finding algorithm has lower complexity. If it fails, it attemps to find a Pauli flow because this property is more general than a generalised flow, and the corresponding flow-finding algorithms have the same complexity in the current implementation. @@ -173,7 +174,7 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: return odd_neighbors_set def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow | None: - """Attempt to find a causal flow on the open graph. + """Return a causal flow on the open graph if it exists. Returns ------- @@ -192,7 +193,7 @@ def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow | None: return find_cflow(self) def find_gflow(self: OpenGraph[_PM_co]) -> GFlow | None: - r"""Attempt to find a generalised flow (gflow) on the open graph. + r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. Returns ------- @@ -217,7 +218,7 @@ def find_gflow(self: OpenGraph[_PM_co]) -> GFlow | None: ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow | None: - r"""Attempt to find a generalised flow (gflow) on the open graph. + r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. Returns ------- From 62f19725e8d7afa0932303f9b0bd5402c70227c2 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 30 Oct 2025 11:58:33 +0100 Subject: [PATCH 28/56] Add docs measurements --- graphix/fundamentals.py | 45 +++++++++++++++++++++++++++++++++++++++-- graphix/measurements.py | 28 +++++++++++++++++++++---- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index ed0e24fd6..3dce5449d 100644 --- a/graphix/fundamentals.py +++ b/graphix/fundamentals.py @@ -220,13 +220,42 @@ class CustomMeta(ABCMeta, EnumMeta): class AbstractMeasurement(ABC): + """Abstract base class for measurement objects. + + Measurement objects are: + - :class:`graphix.measurements.Measurement`. + - :class:`graphix.fundamentals.Plane`. + - :class:`graphix.fundamentals.Axis`. + + """ + @abstractmethod - def to_plane_or_axis(self) -> Plane | Axis: ... + def to_plane_or_axis(self) -> Plane | Axis: + """Return the plane or axis of a measurement object. + + Returns + ------- + Plane | Axis + """ class AbstractPlanarMeasurement(AbstractMeasurement): + """Abstract base class for planar measurement objects. + + Planar measurement objects are: + - :class:`graphix.measurements.Measurement`. + - :class:`graphix.fundamentals.Plane`. + + """ + @abstractmethod - def to_plane(self) -> Plane: ... + def to_plane(self) -> Plane: + """Return the plane of a measurement object. + + Returns + ------- + Plane + """ class Axis(AbstractMeasurement, EnumReprMixin, Enum, metaclass=CustomMeta): @@ -339,7 +368,19 @@ def from_axes(a: Axis, b: Axis) -> Plane: @override def to_plane_or_axis(self) -> Plane: + """Return the plane. + + Returns + ------- + Plane + """ return self def to_plane(self) -> Plane: + """Return the plane. + + Returns + ------- + Plane + """ return self diff --git a/graphix/measurements.py b/graphix/measurements.py index 3f0db4ca5..a3af77609 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -37,10 +37,14 @@ class Domains: @dataclass class Measurement(AbstractPlanarMeasurement): - """An MBQC measurement. - - :param angle: the angle of the measurement. Should be between [0, 2) - :param plane: the measurement plane + r"""An MBQC measurement. + + Attributes + ---------- + angle : Expressionor Float + The angle of the measurement in units of :math:`\pi`. Should be between [0, 2). + plane : graphix.fundamentals.Plane + The measurement plane. """ angle: ExpressionOrFloat @@ -67,11 +71,27 @@ def isclose(self, other: Measurement, rel_tol: float = 1e-09, abs_tol: float = 0 ) and self.plane == other.plane def to_plane_or_axis(self) -> Plane | Axis: + """Return the measurements's plane or axis. + + Returns + ------- + Plane | Axis + + Notes + ----- + Measurements with Pauli angles (i.e., `self.angle == n/2` with `n` an integer) are interpreted as `Axis` instances. + """ if pm := PauliMeasurement.try_from(self.plane, self.angle): return pm.axis return self.plane def to_plane(self) -> Plane: + """Return the measurement's plane. + + Returns + ------- + Plane + """ return self.plane From 8dc221bd1cba08445ea525f4d6da11bb15dcaea0 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 30 Oct 2025 16:54:38 +0100 Subject: [PATCH 29/56] Add tests --- graphix/flow/__init__.py | 1 + graphix/flow/core.py | 4 +-- graphix/opengraph_.py | 3 +- tests/test_flow_core.py | 63 ++++++++++++++++++++++++++++++++++++---- tests/test_opengraph_.py | 32 ++++++++++++++++++-- 5 files changed, 92 insertions(+), 11 deletions(-) diff --git a/graphix/flow/__init__.py b/graphix/flow/__init__.py index e69de29bb..cf9a35291 100644 --- a/graphix/flow/__init__.py +++ b/graphix/flow/__init__.py @@ -0,0 +1 @@ +"""Flow classes and flow-finding algorithms.""" diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 6a62499ec..8895e0c3b 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -37,7 +37,7 @@ class XZCorrections(Generic[_M_co]): z_corrections : Mapping[int, AbstractSet[int]] Mapping of Z-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an Z-correction must be applied depending on the measurement result of `key`. partial_order_layers : Sequence[AbstractSet[int]] - Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. Layer 0 always contains all the output nodes (an empty set if the open graph does not have any outputs). Notes ----- @@ -457,7 +457,7 @@ def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[set[int]] | None: ------- list[set[int]] | None Partial order between corrected qubits in a layer form or `None` if the input directed graph is not acyclical. - The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. Layer 0 always contains all the output nodes (an empty set if the open graph does not have any outputs). """ try: topo_gen = reversed(list(nx.topological_generations(dag))) diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index 270ffc251..ab2d57314 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -18,8 +18,7 @@ from graphix.pattern import Pattern -# TODO: Maybe move these definitions to graphix.fundamentals and graphix.measurements ? -# They are redefined in graphix.flow._find_gpflow, not very elegant. +# TODO: Maybe move these definitions to graphix.fundamentals and graphix.measurements ? Now they are redefined in graphix.flow._find_gpflow, not very elegant. _M_co = TypeVar("_M_co", bound=AbstractMeasurement, covariant=True) _PM_co = TypeVar("_PM_co", bound=AbstractPlanarMeasurement, covariant=True) diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index f4909c424..1a9f4f22a 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -360,7 +360,12 @@ def prepare_test_xzcorrections() -> list[XZCorrectionsTestCase]: return test_cases -class TestXZCorrections: +class TestFlowPatternConversion: + """Bundle for unit tests of the flow to XZ-corrections to pattern methods. + + The module `tests.test_opengraph.py` provides an additional (more comprehensive) suite of unit tests on this transformation. + """ + @pytest.mark.parametrize("test_case", prepare_test_xzcorrections()) def test_flow_to_corrections(self, test_case: XZCorrectionsTestCase) -> None: flow = test_case.flow @@ -387,6 +392,11 @@ def test_corrections_to_pattern(self, test_case: XZCorrectionsTestCase, fx_rng: assert avg == pytest.approx(1) + +class TestXZCorrections: + """Bundle for unit tests of :class:`XZCorrections`.""" + + # See `:func: generate_causal_flow_0` def test_order_0(self) -> None: corrections = generate_causal_flow_0().to_corrections() @@ -398,9 +408,8 @@ def test_order_0(self) -> None: assert nx.utils.graphs_equal(corrections.extract_dag(), nx.DiGraph([(0, 1), (0, 2), (1, 2), (2, 3), (1, 3)])) + # See `:func: generate_causal_flow_1` def test_order_1(self) -> None: - # See `:func: generate_causal_flow_1` - og = OpenGraph( graph=nx.Graph([(0, 2), (2, 3), (1, 3), (2, 4), (3, 5)]), input_nodes=[0, 1], @@ -424,9 +433,8 @@ def test_order_1(self) -> None: corrections.extract_dag(), nx.DiGraph([(0, 2), (0, 3), (0, 4), (1, 2), (1, 3), (1, 5), (2, 4), (3, 5)]) ) + # Incomplete corrections def test_order_2(self) -> None: - # Incomplete corrections - og = OpenGraph( graph=nx.Graph([(0, 1), (1, 2), (1, 3)]), input_nodes=[0], @@ -444,3 +452,48 @@ def test_order_2(self) -> None: assert not corrections.is_compatible([1, 0, 2, 3]) # Contains outputs assert nx.utils.graphs_equal(corrections.extract_dag(), nx.DiGraph([(1, 0)])) + + # OG without outputs + def test_order_3(self) -> None: + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2)]), + input_nodes=[0], + output_nodes=[], + measurements=dict.fromkeys(range(3), Measurement(angle=0, plane=Plane.XY)), + ) + + corrections = XZCorrections.from_measured_nodes_mapping( + og=og, x_corrections={0: {1, 2}}, z_corrections={0: {1}} + ) + + assert corrections.partial_order_layers == [set(), {1, 2}, {0}] # Layer 0 always contains output nodes + assert corrections.is_compatible([0, 1, 2]) + assert not corrections.is_compatible([2, 0, 1]) # Wrong order + assert not corrections.is_compatible([0, 1]) # Incomplete order + + assert nx.utils.graphs_equal(corrections.extract_dag(), nx.DiGraph([(0, 1), (0, 2)])) + + # Test exceptions + def test_from_measured_nodes_mapping_exceptions(self) -> None: + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (2, 3)]), + input_nodes=[0], + output_nodes=[3], + measurements=dict.fromkeys(range(3), Measurement(angle=0, plane=Plane.XY)), + ) + with pytest.raises(ValueError, match=r"Keys of input X-corrections contain non-measured nodes."): + XZCorrections.from_measured_nodes_mapping(og=og, x_corrections={3: {1, 2}}) + + with pytest.raises(ValueError, match=r"Keys of input Z-corrections contain non-measured nodes."): + XZCorrections.from_measured_nodes_mapping(og=og, z_corrections={3: {1, 2}}) + + with pytest.raises( + ValueError, + match=r"Input XZ-corrections are not runnable since the induced directed graph contains closed loops.", + ): + XZCorrections.from_measured_nodes_mapping(og=og, x_corrections={0: {1}, 1: {2}}, z_corrections={2: {0}}) + + with pytest.raises( + ValueError, match=r"Values of input mapping contain labels which are not nodes of the input open graph." + ): + XZCorrections.from_measured_nodes_mapping(og=og, x_corrections={0: {4}}) diff --git a/tests/test_opengraph_.py b/tests/test_opengraph_.py index 04e4586c1..b8a4c7306 100644 --- a/tests/test_opengraph_.py +++ b/tests/test_opengraph_.py @@ -12,16 +12,17 @@ import numpy as np import pytest +from graphix.command import E from graphix.fundamentals import Plane from graphix.measurements import Measurement from graphix.opengraph_ import OpenGraph +from graphix.pattern import Pattern +from graphix.random_objects import rand_circuit from graphix.states import PlanarState if TYPE_CHECKING: from numpy.random import Generator - from graphix.pattern import Pattern - class OpenGraphFlowTestCase(NamedTuple): og: OpenGraph[Measurement] @@ -592,3 +593,30 @@ def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> Non assert check_determinism(pattern, fx_rng) else: assert pflow is None + + def test_double_entanglement(self) -> None: + pattern = Pattern(input_nodes=[0, 1], cmds=[E((0, 1)), E((0, 1))]) + pattern2 = OpenGraph.from_pattern(pattern).to_pattern() + state = pattern.simulate_pattern() + assert pattern2 is not None + state2 = pattern2.simulate_pattern() + assert np.abs(np.dot(state.flatten().conjugate(), state2.flatten())) == pytest.approx(1) + + def test_from_to_pattern(self, fx_rng: Generator) -> None: + n_qubits = 2 + depth = 2 + circuit = rand_circuit(n_qubits, depth, fx_rng) + pattern_ref = circuit.transpile().pattern + pattern = OpenGraph.from_pattern(pattern_ref).to_pattern() + assert pattern is not None + + results = [] + + for plane in {Plane.XY, Plane.XZ, Plane.YZ}: + alpha = 2 * np.pi * fx_rng.random() + state_ref = pattern_ref.simulate_pattern(input_state=PlanarState(plane, alpha)) + state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) + results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) + + avg = sum(results) / 3 + assert avg == pytest.approx(1) From a0fd3d3a957cb5a29741fff9b0a4d4a7ceac9454 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 30 Oct 2025 17:33:26 +0100 Subject: [PATCH 30/56] PR part 1 --- graphix/opengraph_.py | 6 +++--- tests/test_opengraph_.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py index ab2d57314..3bf9c47d9 100644 --- a/graphix/opengraph_.py +++ b/graphix/opengraph_.py @@ -172,7 +172,7 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: odd_neighbors_set ^= self.neighbors([node]) return odd_neighbors_set - def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow | None: + def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: """Return a causal flow on the open graph if it exists. Returns @@ -191,7 +191,7 @@ def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow | None: """ return find_cflow(self) - def find_gflow(self: OpenGraph[_PM_co]) -> GFlow | None: + def find_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. Returns @@ -216,7 +216,7 @@ def find_gflow(self: OpenGraph[_PM_co]) -> GFlow | None: correction_matrix ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. - def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow | None: + def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. Returns diff --git a/tests/test_opengraph_.py b/tests/test_opengraph_.py index b8a4c7306..3d788cd96 100644 --- a/tests/test_opengraph_.py +++ b/tests/test_opengraph_.py @@ -533,7 +533,7 @@ def check_determinism(pattern: Pattern, fx_rng: Generator, n_shots: int = 3) -> avg = sum(results) / (n_shots * 3) - return avg == pytest.approx(1) + return bool(avg == pytest.approx(1)) class TestOpenGraph: From 5263da02d00a0727dc634eee2708427ea7ddebcc Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 30 Oct 2025 20:57:50 +0100 Subject: [PATCH 31/56] Mod layer in XZCorrections --- graphix/flow/_find_cflow.py | 4 ++-- graphix/flow/_find_gpflow.py | 4 +++- graphix/flow/core.py | 35 ++++++++++++++++++++--------------- tests/test_flow_core.py | 4 ++-- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index 79d0c6890..a3f560abd 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -50,14 +50,14 @@ def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: corrected_nodes = set(og.output_nodes) corrector_candidates = corrected_nodes - set(og.input_nodes) + non_input_nodes = og.graph.nodes - set(og.input_nodes) cf: dict[int, set[int]] = {} + # Output nodes are always in layer 0. If the open graph has flow, it must have outputs, so we never end up with an empty set at `layers[0]`. layers: list[set[int]] = [ copy(corrected_nodes) ] # A copy is necessary because `corrected_nodes` is mutable and changes during the algorithm. - non_input_nodes = og.graph.nodes - set(og.input_nodes) - return _flow_aux(og, non_input_nodes, corrected_nodes, corrector_candidates, cf, layers) diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index d9b9c1646..acaaa2dca 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -611,7 +611,9 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> if (topo_gen := _compute_topological_generations(ordering_matrix)) is None: return None # The NC matrix is not a DAG, therefore there's no flow. - layers = [set(aog.og.output_nodes)] # Output nodes are always in layer 0. + layers = [ + set(aog.og.output_nodes) + ] # Output nodes are always in layer 0. If the open graph has flow, it must have outputs, so we never end up with an empty set at `layers[0]`. # If m >_c n, with >_c the flow partial order for two nodes m, n, then layer(n) > layer(m). # Therefore, we iterate the topological sort of the graph in _reverse_ order to obtain the order of measurements. diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 8895e0c3b..617e4751b 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -37,7 +37,7 @@ class XZCorrections(Generic[_M_co]): z_corrections : Mapping[int, AbstractSet[int]] Mapping of Z-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an Z-correction must be applied depending on the measurement result of `key`. partial_order_layers : Sequence[AbstractSet[int]] - Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. Layer 0 always contains all the output nodes (an empty set if the open graph does not have any outputs). + Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. Notes ----- @@ -95,16 +95,16 @@ def from_measured_nodes_mapping( "Input XZ-corrections are not runnable since the induced directed graph contains closed loops." ) - # The first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain a subset of the output nodes, but the first element in `XZCorrections.partial_order_layers` should contain all output nodes. - - shift = 1 if partial_order_layers[0].issubset(outputs_set) else 0 - partial_order_layers = [outputs_set, *partial_order_layers[shift:]] + # If the open graph has outputs, the first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain a subset of the output nodes. + if outputs_set: + shift = 1 if partial_order_layers[0].issubset(outputs_set) else 0 + partial_order_layers = [outputs_set, *partial_order_layers[shift:]] ordered_nodes = {node for layer in partial_order_layers for node in layer} if not ordered_nodes.issubset(nodes_set): raise ValueError("Values of input mapping contain labels which are not nodes of the input open graph.") - # We append to the last layer (first measured nodes) all the non-output nodes not involved in the . + # We append to the last layer (first measured nodes) all the non-output nodes not involved in the corrections. if unordered_nodes := nodes_set - ordered_nodes: partial_order_layers.append(unordered_nodes) @@ -171,7 +171,8 @@ def generate_total_measurement_order(self) -> TotalOrder: ------- TotalOrder """ - total_order = [node for layer in reversed(self.partial_order_layers[1:]) for node in layer] + shift = 1 if self.og.output_nodes else 0 + total_order = [node for layer in reversed(self.partial_order_layers[shift:]) for node in layer] assert set(total_order) == set(self.og.graph.nodes) - set(self.og.output_nodes) return total_order @@ -187,7 +188,7 @@ def extract_dag(self) -> nx.DiGraph[int]: Notes ----- - Not all nodes of the underlying open graph are nodes of the returned directed graph, but only those involved in a correction, either as corrected qubits or belonging to a correction domain. - - Despite the name, the output of this method is not guranteed to be a directed acyclical graph (i.e., a directed graph without any loops). This is only the case if the `XZCorrections` object is well formed, which is verified by the method :func:`XZCorrections.is_wellformed`. + - The output of this method is not guaranteed to be a directed acyclical graph (i.e., a directed graph without any loops). This is only the case if the `XZCorrections` object is well formed, which is verified by the method :func:`XZCorrections.is_wellformed`. """ return _corrections_to_dag(self.x_corrections, self.z_corrections) @@ -214,15 +215,19 @@ def is_compatible(self, total_measurement_order: TotalOrder) -> bool: print("The input total measurement order contains duplicates.") return False - layer = len(self.partial_order_layers) - 1 # First layer to be measured. + shift = 1 if self.og.output_nodes else 0 + measured_layers = list(reversed(self.partial_order_layers[shift:])) + + i = 0 + n_measured_layers = len(measured_layers) + layer = measured_layers[0] for node in total_measurement_order: - while True: - if node in self.partial_order_layers[layer]: - break - layer -= 1 - if layer == 0: # Layer 0 only contains output nodes. + while node not in layer: + i += 1 + if i == n_measured_layers: return False + layer = measured_layers[i] return True @@ -457,7 +462,7 @@ def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[set[int]] | None: ------- list[set[int]] | None Partial order between corrected qubits in a layer form or `None` if the input directed graph is not acyclical. - The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. Layer 0 always contains all the output nodes (an empty set if the open graph does not have any outputs). + The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. """ try: topo_gen = reversed(list(nx.topological_generations(dag))) diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 1a9f4f22a..e03f71dec 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -466,11 +466,11 @@ def test_order_3(self) -> None: og=og, x_corrections={0: {1, 2}}, z_corrections={0: {1}} ) - assert corrections.partial_order_layers == [set(), {1, 2}, {0}] # Layer 0 always contains output nodes + assert corrections.partial_order_layers == [{1, 2}, {0}] assert corrections.is_compatible([0, 1, 2]) assert not corrections.is_compatible([2, 0, 1]) # Wrong order assert not corrections.is_compatible([0, 1]) # Incomplete order - + assert corrections.generate_total_measurement_order() in ([0, 1, 2], [0, 2, 1]) assert nx.utils.graphs_equal(corrections.extract_dag(), nx.DiGraph([(0, 1), (0, 2)])) # Test exceptions From 7ae925da07760f9cd3df1d7cb764fe99777bcacc Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 3 Nov 2025 15:34:05 +0100 Subject: [PATCH 32/56] Remove unnecessary files --- graphix/__init__.py | 3 +- graphix/find_pflow.py | 612 ----------------------------- graphix/flow/_find_cflow.py | 2 +- graphix/flow/_find_gpflow.py | 2 +- graphix/flow/core.py | 2 +- graphix/generator.py | 189 --------- graphix/opengraph.py | 278 ++++++++++--- graphix/opengraph_.py | 242 ------------ graphix/pyzx.py | 23 +- tests/test_find_pflow.py | 682 -------------------------------- tests/test_flow_core.py | 2 +- tests/test_flow_find_gpflow.py | 2 +- tests/test_generator.py | 158 -------- tests/test_opengraph.py | 697 ++++++++++++++++++++++++++++++--- tests/test_opengraph_.py | 622 ----------------------------- tests/test_pyzx.py | 3 + 16 files changed, 872 insertions(+), 2647 deletions(-) delete mode 100644 graphix/find_pflow.py delete mode 100644 graphix/generator.py delete mode 100644 graphix/opengraph_.py delete mode 100644 tests/test_find_pflow.py delete mode 100644 tests/test_generator.py delete mode 100644 tests/test_opengraph_.py diff --git a/graphix/__init__.py b/graphix/__init__.py index 782a7ef7b..cb8aedb61 100644 --- a/graphix/__init__.py +++ b/graphix/__init__.py @@ -2,10 +2,9 @@ from __future__ import annotations -from graphix.generator import generate_from_graph from graphix.graphsim import GraphState from graphix.pattern import Pattern from graphix.sim.statevec import Statevec from graphix.transpiler import Circuit -__all__ = ["Circuit", "GraphState", "Pattern", "Statevec", "generate_from_graph"] +__all__ = ["Circuit", "GraphState", "Pattern", "Statevec"] diff --git a/graphix/find_pflow.py b/graphix/find_pflow.py deleted file mode 100644 index 6a0b8e949..000000000 --- a/graphix/find_pflow.py +++ /dev/null @@ -1,612 +0,0 @@ -"""Pauli flow finding algorithm. - -This module implements the algorithm presented in [1]. For a given labelled open graph (G, I, O, meas_plane), this algorithm finds a maximally delayed Pauli flow [2] in polynomial time with the number of nodes, :math:`O(N^3)`. -If the input graph does not have Pauli measurements, the algorithm returns a general flow (gflow) if it exists by definition. - -References ----------- -[1] Mitosek and Backens, 2024 (arXiv:2410.23439). -[2] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212) -""" - -from __future__ import annotations - -from copy import deepcopy -from typing import TYPE_CHECKING - -import numpy as np - -from graphix._linalg import MatGF2, solve_f2_linear_system -from graphix.fundamentals import Axis, Plane -from graphix.measurements import PauliMeasurement -from graphix.sim.base_backend import NodeIndex - -if TYPE_CHECKING: - from collections.abc import Set as AbstractSet - - from graphix.opengraph import OpenGraph - - -class OpenGraphIndex: - """A class for managing the mapping between node numbers of a given open graph and matrix indices in the Pauli flow finding algorithm. - - It reuses the class `:class: graphix.sim.base_backend.NodeIndex` introduced for managing the mapping between node numbers and qubit indices in the internal state of the backend. - - Attributes - ---------- - og (OpenGraph) - non_inputs (NodeIndex) : Mapping between matrix indices and non-input nodes (labelled with integers). - non_outputs (NodeIndex) : Mapping between matrix indices and non-output nodes (labelled with integers). - non_outputs_optim (NodeIndex) : Mapping between matrix indices and a subset of non-output nodes (labelled with integers). - - Notes - ----- - At initialization, `non_outputs_optim` is a copy of `non_outputs`. The nodes corresponding to zero-rows of the order-demand matrix are removed for calculating the P matrix more efficiently in the `:func: _find_pflow_general` routine. - """ - - def __init__(self, og: OpenGraph) -> None: - self.og = og - nodes = set(og.inside.nodes) - - # Nodes don't need to be sorted. We do it for debugging purposes, so we can check the matrices in intermediate steps of the algorithm. - - nodes_non_input = sorted(nodes - set(og.inputs)) - nodes_non_output = sorted(nodes - set(og.outputs)) - - self.non_inputs = NodeIndex() - self.non_inputs.extend(nodes_non_input) - - self.non_outputs = NodeIndex() - self.non_outputs.extend(nodes_non_output) - - # Needs to be a deep copy because it may be modified during runtime. - self.non_outputs_optim = deepcopy(self.non_outputs) - - -def _compute_reduced_adj(ogi: OpenGraphIndex) -> MatGF2: - r"""Return the reduced adjacency matrix (RAdj) of the input open graph. - - Parameters - ---------- - ogi : OpenGraphIndex - Open graph whose RAdj is computed. - - Returns - ------- - adj_red : MatGF2 - Reduced adjacency matrix. - - Notes - ----- - The adjacency matrix of a graph :math:`Adj_G` is an :math:`n \times n` matrix. - - The RAdj matrix of an open graph OG is an :math:`(n - n_O) \times (n - n_I)` submatrix of :math:`Adj_G` constructed by removing the output rows and input columns of :math:`Adj_G`. - - See Definition 3.3 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - graph = ogi.og.inside - row_tags = ogi.non_outputs - col_tags = ogi.non_inputs - - adj_red = np.zeros((len(row_tags), len(col_tags)), dtype=np.uint8).view(MatGF2) - - for n1, n2 in graph.edges: - for u, v in ((n1, n2), (n2, n1)): - if u in row_tags and v in col_tags: - i, j = row_tags.index(u), col_tags.index(v) - adj_red[i, j] = 1 - - return adj_red - - -def _compute_pflow_matrices(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2]: - r"""Construct flow-demand and order-demand matrices. - - Parameters - ---------- - ogi : OpenGraphIndex - Open graph whose flow-demand and order-demand matrices are computed. - - Returns - ------- - flow_demand_matrix : MatGF2 - order_demand_matrix : MatGF2 - - Notes - ----- - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - flow_demand_matrix = _compute_reduced_adj(ogi) - order_demand_matrix = flow_demand_matrix.copy() - - inputs_set = set(ogi.og.inputs) - meas = ogi.og.measurements - - row_tags = ogi.non_outputs - col_tags = ogi.non_inputs - - # TODO: integrate pauli measurements in open graphs - meas_planes = {i: m.plane for i, m in meas.items()} - meas_angles = {i: m.angle for i, m in meas.items()} - meas_plane_axis = { - node: pm.axis if (pm := PauliMeasurement.try_from(plane, meas_angles[node])) else plane - for node, plane in meas_planes.items() - } - - for v in row_tags: # v is a node tag - i = row_tags.index(v) - plane_axis_v = meas_plane_axis[v] - - if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: - flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Y, Axis.Z} and v not in inputs_set: - j = col_tags.index(v) - flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 - if plane_axis_v in {Plane.XY, Axis.X, Axis.Y, Axis.Z}: - order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if plane_axis_v in {Plane.XY, Plane.XZ} and v not in inputs_set: - j = col_tags.index(v) - order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 - - return flow_demand_matrix, order_demand_matrix - - -def _find_pflow_simple(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: - r"""Construct the correction matrix :math:`C` and the ordering matrix, :math:`NC` for an open graph with equal number of inputs and outputs. - - Parameters - ---------- - ogi : OpenGraphIndex - Open graph for which :math:`C` and :math:`NC` are computed. - - Returns - ------- - correction_matrix : MatGF2 - Matrix encoding the correction function. - ordering_matrix : MatGF2 - Matrix encoding the partial ordering between nodes. - - or `None` - if the input open graph does not have Pauli flow. - - Notes - ----- - - The ordering matrix is defined as the product of the order-demand matrix :math:`N` and the correction matrix. - - - The function only returns `None` when the flow-demand matrix is not invertible (meaning that `ogi` does not have Pauli flow). The condition that the ordering matrix :math:`NC` must encode a directed acyclic graph (DAG) is verified in a subsequent step by `:func: _compute_topological_generations`. - - See Definitions 3.4, 3.5 and 3.6, Theorems 3.1 and 4.1, and Algorithm 2 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) - - correction_matrix = flow_demand_matrix.right_inverse() # C matrix - - if correction_matrix is None: - return None # The flow-demand matrix is not invertible, therefore there's no flow. - - ordering_matrix = order_demand_matrix.mat_mul(correction_matrix) # NC matrix - - return correction_matrix, ordering_matrix - - -def _compute_p_matrix(ogi: OpenGraphIndex, nb_matrix: MatGF2) -> MatGF2 | None: - r"""Perform the steps 8 - 12 of the general case (larger number of outputs than inputs) algorithm. - - Parameters - ---------- - ogi : OpenGraphIndex - Open graph for which the matrix :math:`P` is computed. - nb_matrix : MatGF2 - Matrix :math:`N_B` - - Returns - ------- - p_matrix : MatGF2 - Matrix encoding the correction function. - - or `None` - if the input open graph does not have Pauli flow. - - Notes - ----- - See Theorem 4.4, steps 8 - 12 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - n_no = len(ogi.non_outputs) # number of columns of P matrix. - n_oi_diff = len(ogi.og.outputs) - len(ogi.og.inputs) # number of rows of P matrix. - n_no_optim = len(ogi.non_outputs_optim) # number of rows and columns of the third block of the K_{LS} matrix. - - # Steps 8, 9 and 10 - kils_matrix = np.concatenate( - (nb_matrix[:, n_no:], nb_matrix[:, :n_no], np.eye(n_no_optim, dtype=np.uint8)), axis=1 - ).view(MatGF2) # N_R | N_L | 1 matrix. - kls_matrix = kils_matrix.gauss_elimination(ncols=n_oi_diff, copy=True) # RREF form is not needed, only REF. - - # Step 11 - p_matrix = np.zeros((n_oi_diff, n_no), dtype=np.uint8).view(MatGF2) - solved_nodes: set[int] = set() - non_outputs_set = set(ogi.non_outputs) - - # Step 12 - while solved_nodes != non_outputs_set: - solvable_nodes = _find_solvable_nodes(ogi, kls_matrix, non_outputs_set, solved_nodes, n_oi_diff) # Step 12.a - if not solvable_nodes: - return None - - _update_p_matrix(ogi, kls_matrix, p_matrix, solvable_nodes, n_oi_diff) # Steps 12.b, 12.c - _update_kls_matrix(ogi, kls_matrix, kils_matrix, solvable_nodes, n_oi_diff, n_no, n_no_optim) # Step 12.d - solved_nodes.update(solvable_nodes) - - return p_matrix - - -def _find_solvable_nodes( - ogi: OpenGraphIndex, - kls_matrix: MatGF2, - non_outputs_set: AbstractSet[int], - solved_nodes: AbstractSet[int], - n_oi_diff: int, -) -> set[int]: - """Return the set nodes whose associated linear system is solvable. - - A node is solvable if: - - It has not been solved yet. - - Its column in the second block of :math:`K_{LS}` (which determines the constants in each equation) has only zeros where it intersects rows for which all the coefficients in the first block are 0s. - - See Theorem 4.4, step 12.a in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - solvable_nodes: set[int] = set() - - row_idxs = np.flatnonzero( - ~kls_matrix[:, :n_oi_diff].any(axis=1) - ) # Row indices of the 0-rows in the first block of K_{LS}. - if row_idxs.size: - for v in non_outputs_set - solved_nodes: - j = n_oi_diff + ogi.non_outputs.index(v) # `n_oi_diff` is the column offset from the first block of K_{LS}. - if not kls_matrix[row_idxs, j].any(): - solvable_nodes.add(v) - else: - # If the first block of K_{LS} does not have 0-rows, all non-solved nodes are solvable. - solvable_nodes = set(non_outputs_set - solved_nodes) - - return solvable_nodes - - -def _update_p_matrix( - ogi: OpenGraphIndex, kls_matrix: MatGF2, p_matrix: MatGF2, solvable_nodes: AbstractSet[int], n_oi_diff: int -) -> None: - """Update `p_matrix`. - - The solution of the linear system associated with node :math:`v` in `solvable_nodes` corresponds to the column of `p_matrix` associated with node :math:`v`. - - See Theorem 4.4, steps 12.b and 12.c in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - for v in solvable_nodes: - j = ogi.non_outputs.index(v) - j_shift = n_oi_diff + j # `n_oi_diff` is the column offset from the first block of K_{LS}. - mat = MatGF2(kls_matrix[:, :n_oi_diff]) # First block of K_{LS}, in row echelon form. - b = MatGF2(kls_matrix[:, j_shift]) - x = solve_f2_linear_system(mat, b) - p_matrix[:, j] = x - - -def _update_kls_matrix( - ogi: OpenGraphIndex, - kls_matrix: MatGF2, - kils_matrix: MatGF2, - solvable_nodes: AbstractSet[int], - n_oi_diff: int, - n_no: int, - n_no_optim: int, -) -> None: - """Update `kls_matrix`. - - Bring the linear system encoded in :math:`K_{LS}` to the row-echelon form (REF) that would be achieved by Gaussian elimination if the row and column vectors corresponding to vertices in `solvable_nodes` where not included in the starting matrix. - - See Theorem 4.4, step 12.d in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - shift = n_oi_diff + n_no # `n_oi_diff` + `n_no` is the column offset from the first two blocks of K_{LS}. - row_permutation: list[int] - - def reorder(old_pos: int, new_pos: int) -> None: # Used in step 12.d.vi - """Reorder the elements of `row_permutation`. - - The element at `old_pos` is placed on the right of the element at `new_pos`. - Example: - ``` - row_permutation = [0, 1, 2, 3, 4] - reorder(1, 3) -> [0, 2, 3, 1, 4] - reorder(2, -1) -> [2, 0, 1, 3, 4] - ``` - """ - val = row_permutation.pop(old_pos) - row_permutation.insert(new_pos + (new_pos < old_pos), val) - - for v in solvable_nodes: - if ( - v in ogi.non_outputs_optim - ): # if `v` corresponded to a zero row in N_B, it was not present in `kls_matrix` because we removed it in the optimization process, so there's no need to do Gaussian elimination for that vertex. - # Step 12.d.ii - j = ogi.non_outputs_optim.index(v) - j_shift = shift + j - row_idxs = np.flatnonzero( - kls_matrix[:, j_shift] - ).tolist() # Row indices with 1s in column of node `v` in third block. - - # `row_idxs` can't be empty: - # The third block of K_{LS} is initially the identity matrix, so all columns have initially a 1. Row permutations and row additions in the Gaussian elimination routine can't remove all 1s from a given column. - k = row_idxs.pop() - - # Step 12.d.iii - kls_matrix[row_idxs] ^= kls_matrix[k] # Adding a row to previous rows preserves REF. - - # Step 12.d.iv - kls_matrix[k] ^= kils_matrix[j] # Row `k` may now break REF. - - # Step 12.d.v - pivots: list[np.int_] = [] # Store pivots for next step. - for i, row in enumerate(kls_matrix): - if i != k: - col_idxs = np.flatnonzero(row[:n_oi_diff]) # Column indices with 1s in first block. - if col_idxs.size == 0: - # Row `i` has all zeros in the first block. Only row `k` can break REF, so rows below have all zeros in the first block too. - break - pivots.append(p := col_idxs[0]) - if kls_matrix[k, p]: # Row `k` has a 1 in the column corresponding to the leading 1 of row `i`. - kls_matrix[k] ^= row - - row_permutation = list(range(n_no_optim)) # Row indices of `kls_matrix`. - n_pivots = len(pivots) - - col_idxs = np.flatnonzero(kls_matrix[k, :n_oi_diff]) - pk = col_idxs[0] if col_idxs.size else None # Pivot of row `k`. - - if pk and k >= n_pivots: # Row `k` is non-zero in the FB (first block) and it's among zero rows. - # Find row `new_pos` s.t. `pivots[new_pos] <= pk < pivots[new_pos+1]`. - new_pos = ( - int(np.argmax(np.array(pivots) > pk) - 1) if pivots else -1 - ) # `pivots` can be empty. If so, we bring row `k` to the top since it's non-zero. - elif pk: # Row `k` is non-zero in the FB and it's among non-zero rows. - # Find row `new_pos` s.t. `pivots[new_pos] <= pk < pivots[new_pos+1]` - new_pos = int(np.argmax(np.array(pivots) > pk) - 1) - # We skipped row `k` in loop of step 12.d.v, so `pivots[j]` can be the pivot of row `j` or `j+1`. - if new_pos >= k: - new_pos += 1 - elif k < n_pivots: # Row `k` is zero in the first block and it's among non-zero rows. - new_pos = ( - n_pivots # Move row `k` to the top of the zeros block (i.e., below the row of the last pivot). - ) - else: # Row `k` is zero in the first block and it's among zero rows. - new_pos = k # Do nothing. - - if new_pos != k: - reorder(k, new_pos) # Modify `row_permutation` in-place. - kls_matrix[:] = kls_matrix[ - row_permutation - ] # `[:]` is crucial to modify the data pointed by `kls_matrix`. - - -def _find_pflow_general(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: - r"""Construct the generalized correction matrix :math:`C'C^B` and the generalized ordering matrix, :math:`NC'C^B` for an open graph with larger number of outputs than inputs. - - Parameters - ---------- - ogi : OpenGraphIndex - Open graph for which :math:`C'C^B` and :math:`NC'C^B` are computed. - - Returns - ------- - correction_matrix : MatGF2 - Matrix encoding the correction function. - ordering_matrix : MatGF2 - Matrix encoding the partial ordering between nodes. - - or `None` - if the input open graph does not have Pauli flow. - - Notes - ----- - - The function returns `None` if - a) The flow-demand matrix is not invertible, or - b) Not all linear systems of equations associated to the non-output nodes are solvable, - meaning that `ogi` does not have Pauli flow. - Condition (b) is satisfied when the flow-demand matrix :math:`M` does not have a right inverse :math:`C` such that :math:`NC` represents a directed acyclical graph (DAG). - - See Theorem 4.4 and Algorithm 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - n_no = len(ogi.non_outputs) - n_oi_diff = len(ogi.og.outputs) - len(ogi.og.inputs) - - # Steps 1 and 2 - flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) - - # Steps 3 and 4 - correction_matrix_0 = flow_demand_matrix.right_inverse() # C0 matrix. - if correction_matrix_0 is None: - return None # The flow-demand matrix is not invertible, therefore there's no flow. - - # Steps 5, 6 and 7 - ker_flow_demand_matrix = flow_demand_matrix.null_space().transpose() # F matrix. - c_prime_matrix = np.concatenate((correction_matrix_0, ker_flow_demand_matrix), axis=1).view(MatGF2) - - row_idxs = np.flatnonzero(order_demand_matrix.any(axis=1)) # Row indices of the non-zero rows. - - if row_idxs.size: - # The p-matrix finding algorithm runs on the `order_demand_matrix` without the zero rows. - # This optimization is allowed because: - # - The zero rows remain zero after the change of basis (multiplication by `c_prime_matrix`). - # - The zero rows remain zero after gaussian elimination. - # - Removing the zero rows does not change the solvability condition of the open graph nodes. - nb_matrix_optim = ( - order_demand_matrix[row_idxs].view(MatGF2).mat_mul(c_prime_matrix) - ) # `view` is used to keep mypy happy without copying data. - for i in set(range(order_demand_matrix.shape[0])).difference(row_idxs): - ogi.non_outputs_optim.remove(ogi.non_outputs[i]) # Update the node-index mapping. - - # Steps 8 - 12 - if (p_matrix := _compute_p_matrix(ogi, nb_matrix_optim)) is None: - return None - else: - # If all rows of `order_demand_matrix` are zero, any matrix will solve the associated linear system of equations. - p_matrix = np.zeros((n_oi_diff, n_no), dtype=np.uint8).view(MatGF2) - - # Step 13 - cb_matrix = np.concatenate((np.eye(n_no, dtype=np.uint8), p_matrix), axis=0).view(MatGF2) - - correction_matrix = c_prime_matrix.mat_mul(cb_matrix) - ordering_matrix = order_demand_matrix.mat_mul(correction_matrix) - - return correction_matrix, ordering_matrix - - -def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] | None: - """Stratify the directed acyclic graph (DAG) represented by the ordering matrix into generations. - - Parameters - ---------- - ordering_matrix : MatGF2 - Matrix encoding the partial ordering between nodes interpreted as the adjacency matrix of a directed graph. - - Returns - ------- - list[list[int]] - Topological generations. Integers represent the indices of the matrix `ordering_matrix`, not the labelling of the nodes. - - or `None` - if `ordering_matrix` is not a DAG. - - Notes - ----- - This function is adapted from `:func: networkx.algorithms.dag.topological_generations` so that it works directly on the adjacency matrix (which is the output of the Pauli-flow finding algorithm) instead of a `:class: nx.DiGraph` object. This avoids calling the function `nx.from_numpy_array` which can be expensive for certain graph instances. - - Here we use the convention that the element `ordering_matrix[i,j]` represents a link `j -> i`. NetworkX uses the opposite convention. - """ - adj_mat = ordering_matrix - - indegree_map: dict[int, int] = {} - zero_indegree: list[int] = [] - neighbors = {node: set(np.flatnonzero(row).astype(int)) for node, row in enumerate(adj_mat.T)} - for node, col in enumerate(adj_mat): - parents = np.flatnonzero(col) - if parents.size: - indegree_map[node] = parents.size - else: - zero_indegree.append(node) - - generations: list[list[int]] = [] - - while zero_indegree: - this_generation = zero_indegree - zero_indegree = [] - for node in this_generation: - for child in neighbors[node]: - indegree_map[child] -= 1 - if indegree_map[child] == 0: - zero_indegree.append(child) - del indegree_map[child] - generations.append(this_generation) - - if indegree_map: - return None - return generations - - -def _cnc_matrices2pflow( - ogi: OpenGraphIndex, - correction_matrix: MatGF2, - ordering_matrix: MatGF2, -) -> tuple[dict[int, set[int]], dict[int, int]] | None: - r"""Transform the correction and ordering matrices into a Pauli flow in its standard form (correction function and partial order). - - Parameters - ---------- - ogi : OpenGraphIndex - Open graph whose Pauli flow is calculated. - correction_matrix : MatGF2 - Matrix encoding the correction function. - ordering_matrix : MatGF2 - Matrix encoding the partial ordering between nodes (DAG). - - Returns - ------- - pf : dict[int, set[int]] - Pauli flow correction function. pf[i] is the set of qubits to be corrected for the measurement of qubit i. - l_k : dict[int, int] - Partial order between corrected qubits, such that the pair (`key`, `value`) corresponds to (node, depth). - - or `None` - if the ordering matrix is not a DAG, in which case the input open graph does not have Pauli flow. - - Notes - ----- - - The correction matrix :math:`C` is an :math:`(n - n_I) \times (n - n_O)` matrix related to the correction function :math:`c(v) = \{u \in I^c|C_{u,v} = 1\}`, where :math:`I^c` are the non-input nodes of `ogi`. In other words, the column :math:`v` of :math:`C` encodes the correction set of :math:`v`, :math:`c(v)`. - - - The Pauli flow's ordering :math:`<_c` is the transitive closure of :math:`\lhd_c`, where the latter is related to the ordering matrix :math:`NC` as :math:`v \lhd_c w \Leftrightarrow (NC)_{w,v} = 1`, for :math:`v, w, \in O^c` two non-output nodes of `ogi`. - - See Definition 3.6, Lemma 3.12, and Theorem 3.1 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - row_tags = ogi.non_inputs - col_tags = ogi.non_outputs - - # Calculation of the partial ordering - - if (topo_gen := _compute_topological_generations(ordering_matrix)) is None: - return None # The NC matrix is not a DAG, therefore there's no flow. - - l_k = dict.fromkeys(ogi.og.outputs, 0) # Output nodes are always in layer 0. - - # If m >_c n, with >_c the flow order for two nodes m, n, then layer(n) > layer(m). - # Therefore, we iterate the topological sort of the graph in _reverse_ order to obtain the order of measurements. - for layer, idx in enumerate(reversed(topo_gen), start=1): - l_k.update({col_tags[i]: layer for i in idx}) - - # Calculation of the correction function - - pf: dict[int, set[int]] = {} - for node in col_tags: - i = col_tags.index(node) - correction_set = {row_tags[j] for j in np.flatnonzero(correction_matrix[:, i])} - pf[node] = correction_set - - return pf, l_k - - -def find_pflow(og: OpenGraph) -> tuple[dict[int, set[int]], dict[int, int]] | None: - """Return a Pauli flow of the input open graph if it exists. - - Parameters - ---------- - og : OpenGraph - Open graph whose Pauli flow is calculated. - - Returns - ------- - pf : dict[int, set[int]] - Pauli flow correction function. `pf[i]` is the set of qubits to be corrected for the measurement of qubit `i`. - l_k : dict[int, int] - Partial order between corrected qubits, such that the pair (`key`, `value`) corresponds to (node, depth). - - or `None` - if the input open graph does not have Pauli flow. - - Notes - ----- - See Theorems 3.1, 4.2 and 4.4, and Algorithms 2 and 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - ni = len(og.inputs) - no = len(og.outputs) - - if ni > no: - return None - - ogi = OpenGraphIndex(og) - - cnc_matrices = _find_pflow_simple(ogi) if ni == no else _find_pflow_general(ogi) - if cnc_matrices is None: - return None - pflow = _cnc_matrices2pflow(ogi, *cnc_matrices) - if pflow is None: - return None - - pf, l_k = pflow - - return pf, l_k diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index a3f560abd..cffb88c95 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from collections.abc import Set as AbstractSet - from graphix.opengraph_ import OpenGraph, _PM_co + from graphix.opengraph import OpenGraph, _PM_co def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index acaaa2dca..f84f9567c 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from collections.abc import Set as AbstractSet - from graphix.opengraph_ import OpenGraph + from graphix.opengraph import OpenGraph _M_co = TypeVar("_M_co", bound=AbstractMeasurement, covariant=True) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 617e4751b..c58e54b43 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -19,7 +19,7 @@ from typing import Self from graphix.measurements import Measurement - from graphix.opengraph_ import OpenGraph + from graphix.opengraph import OpenGraph TotalOrder = Sequence[int] diff --git a/graphix/generator.py b/graphix/generator.py deleted file mode 100644 index 815488c2f..000000000 --- a/graphix/generator.py +++ /dev/null @@ -1,189 +0,0 @@ -"""MBQC pattern generator.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import graphix.gflow -from graphix.command import E, M, N, X, Z -from graphix.fundamentals import Plane -from graphix.pattern import Pattern - -if TYPE_CHECKING: - from collections.abc import Iterable, Mapping - from collections.abc import Set as AbstractSet - - import networkx as nx - - from graphix.parameter import ExpressionOrFloat - - -def generate_from_graph( - graph: nx.Graph[int], - angles: Mapping[int, ExpressionOrFloat], - inputs: Iterable[int], - outputs: Iterable[int], - meas_planes: Mapping[int, Plane] | None = None, -) -> Pattern: - r"""Generate the measurement pattern from open graph and measurement angles. - - This function takes an open graph ``G = (nodes, edges, input, outputs)``, - specified by :class:`networkx.Graph` and two lists specifying input and output nodes. - Currently we support XY-plane measurements. - - Searches for the flow in the open graph using :func:`graphix.gflow.find_flow` and if found, - construct the measurement pattern according to the theorem 1 of [NJP 9, 250 (2007)]. - - Then, if no flow was found, searches for gflow using :func:`graphix.gflow.find_gflow`, - from which measurement pattern can be constructed from theorem 2 of [NJP 9, 250 (2007)]. - - Then, if no gflow was found, searches for Pauli flow using :func:`graphix.gflow.find_pauliflow`, - from which measurement pattern can be constructed from theorem 4 of [NJP 9, 250 (2007)]. - - The constructed measurement pattern deterministically realize the unitary embedding - - .. math:: - - U = \left( \prod_i \langle +_{\alpha_i} |_i \right) E_G N_{I^C}, - - where the measurements (bras) with always :math:`\langle+|` bases determined by the measurement - angles :math:`\alpha_i` are applied to the measuring nodes, - i.e. the randomness of the measurement is eliminated by the added byproduct commands. - - .. seealso:: :func:`graphix.gflow.find_flow` :func:`graphix.gflow.find_gflow` :func:`graphix.gflow.find_pauliflow` :class:`graphix.pattern.Pattern` - - Parameters - ---------- - graph : :class:`networkx.Graph` - Graph on which MBQC should be performed - angles : dict - measurement angles for each nodes on the graph (unit of pi), except output nodes - inputs : list - list of node indices for input nodes - outputs : list - list of node indices for output nodes - meas_planes : dict - optional: measurement planes for each nodes on the graph, except output nodes - - Returns - ------- - pattern : graphix.pattern.Pattern - constructed pattern. - """ - inputs_set = set(inputs) - outputs_set = set(outputs) - - measuring_nodes = set(graph.nodes) - outputs_set - - meas_planes = dict.fromkeys(measuring_nodes, Plane.XY) if not meas_planes else dict(meas_planes) - - # search for flow first - f, l_k = graphix.gflow.find_flow(graph, inputs_set, outputs_set, meas_planes=meas_planes) - if f is not None: - # flow found - pattern = _flow2pattern(graph, angles, inputs, f, l_k) - pattern.reorder_output_nodes(outputs) - return pattern - - # no flow found - we try gflow - g, l_k = graphix.gflow.find_gflow(graph, inputs_set, outputs_set, meas_planes=meas_planes) - if g is not None and l_k is not None: - # gflow found - pattern = _gflow2pattern(graph, angles, inputs, meas_planes, g, l_k) - pattern.reorder_output_nodes(outputs) - return pattern - - # no flow or gflow found - we try pflow - p, l_k = graphix.gflow.find_pauliflow(graph, inputs_set, outputs_set, meas_planes=meas_planes, meas_angles=angles) - if p is not None and l_k is not None: - # pflow found - pattern = _pflow2pattern(graph, angles, inputs, meas_planes, p, l_k) - pattern.reorder_output_nodes(outputs) - return pattern - - raise ValueError("no flow or gflow or pflow found") - - -def _flow2pattern( - graph: nx.Graph[int], - angles: Mapping[int, ExpressionOrFloat], - inputs: Iterable[int], - f: Mapping[int, AbstractSet[int]], - l_k: Mapping[int, int], -) -> Pattern: - """Construct a measurement pattern from a causal flow according to the theorem 1 of [NJP 9, 250 (2007)].""" - depth, layers = graphix.gflow.get_layers(l_k) - pattern = Pattern(input_nodes=inputs) - for i in set(graph.nodes) - set(inputs): - pattern.add(N(node=i)) - for e in graph.edges: - pattern.add(E(nodes=e)) - measured: list[int] = [] - for i in range(depth, 0, -1): # i from depth, depth-1, ... 1 - for j in layers[i]: - measured.append(j) - pattern.add(M(node=j, angle=angles[j])) - neighbors: set[int] = set() - for k in f[j]: - neighbors |= set(graph.neighbors(k)) - for k in neighbors - {j}: - # if k not in measured: - pattern.add(Z(node=k, domain={j})) - (fj,) = f[j] - pattern.add(X(node=fj, domain={j})) - return pattern - - -def _gflow2pattern( - graph: nx.Graph[int], - angles: Mapping[int, ExpressionOrFloat], - inputs: Iterable[int], - meas_planes: Mapping[int, Plane], - g: Mapping[int, AbstractSet[int]], - l_k: Mapping[int, int], -) -> Pattern: - """Construct a measurement pattern from a generalized flow according to the theorem 2 of [NJP 9, 250 (2007)].""" - depth, layers = graphix.gflow.get_layers(l_k) - pattern = Pattern(input_nodes=inputs) - for i in set(graph.nodes) - set(inputs): - pattern.add(N(node=i)) - for e in graph.edges: - pattern.add(E(nodes=e)) - for i in range(depth, 0, -1): # i from depth, depth-1, ... 1 - for j in layers[i]: - pattern.add(M(node=j, plane=meas_planes[j], angle=angles[j])) - odd_neighbors = graphix.gflow.find_odd_neighbor(graph, g[j]) - for k in odd_neighbors - {j}: - pattern.add(Z(node=k, domain={j})) - for k in g[j] - {j}: - pattern.add(X(node=k, domain={j})) - return pattern - - -def _pflow2pattern( - graph: nx.Graph[int], - angles: Mapping[int, ExpressionOrFloat], - inputs: Iterable[int], - meas_planes: Mapping[int, Plane], - p: Mapping[int, AbstractSet[int]], - l_k: Mapping[int, int], -) -> Pattern: - """Construct a measurement pattern from a Pauli flow according to the theorem 4 of [NJP 9, 250 (2007)].""" - depth, layers = graphix.gflow.get_layers(l_k) - pattern = Pattern(input_nodes=inputs) - for i in set(graph.nodes) - set(inputs): - pattern.add(N(node=i)) - for e in graph.edges: - pattern.add(E(nodes=e)) - for i in range(depth, 0, -1): # i from depth, depth-1, ... 1 - for j in layers[i]: - pattern.add(M(node=j, plane=meas_planes[j], angle=angles[j])) - odd_neighbors = graphix.gflow.find_odd_neighbor(graph, p[j]) - future_nodes: set[int] = set.union( - *(nodes for (layer, nodes) in layers.items() if layer < i) - ) # {k | k > j}, with "j" last corrected node and ">" the Pauli flow ordering - for k in odd_neighbors & future_nodes: - pattern.add(Z(node=k, domain={j})) - for k in p[j] & future_nodes: - pattern.add(X(node=k, domain={j})) - return pattern diff --git a/graphix/opengraph.py b/graphix/opengraph.py index e6bbadd41..663f5a771 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -1,32 +1,46 @@ -"""Provides a class for open graphs.""" +"""Class for open graph states.""" from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generic, TypeVar import networkx as nx -import graphix.generator +from graphix.flow._find_cflow import find_cflow +from graphix.flow._find_gpflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix +from graphix.flow.core import CausalFlow, GFlow, PauliFlow +from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement from graphix.measurements import Measurement if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Collection, Iterable, Mapping, Sequence from graphix.pattern import Pattern +# TODO: Maybe move these definitions to graphix.fundamentals and graphix.measurements ? Now they are redefined in graphix.flow._find_gpflow, not very elegant. +_M_co = TypeVar("_M_co", bound=AbstractMeasurement, covariant=True) +_PM_co = TypeVar("_PM_co", bound=AbstractPlanarMeasurement, covariant=True) -@dataclass(frozen=True) -class OpenGraph: - """Open graph contains the graph, measurement, and input and output nodes. - - This is the graph we wish to implement deterministically. - :param inside: the underlying :class:`networkx.Graph` state - :param measurements: a dictionary whose key is the ID of a node and the - value is the measurement at that node - :param inputs: an ordered list of node IDs that are inputs to the graph - :param outputs: an ordered list of node IDs that are outputs of the graph +@dataclass(frozen=True) +class OpenGraph(Generic[_M_co]): + """An unmutable dataclass providing a representation of open graph states. + + Attributes + ---------- + graph : networkx.Graph[int] + The underlying resource-state graph. Nodes represent qubits and edges represent the application of :math:`CZ` gate on the linked nodes. + input_nodes : Sequence[int] + An ordered sequence of node labels corresponding to the open graph inputs. + output_nodes : Sequence[int] + An ordered sequence of node labels corresponding to the open graph outputs. + measurements : Mapping[int, _M_co] + A mapping between the non-output nodes of the open graph (`key`) and their corresponding measurement label (`value`). Measurement labels can be specified as `Measurement` or `Plane|Axis` instances. + + Notes + ----- + The inputs and outputs of `OpenGraph` instances in Graphix are defined as ordered sequences of node labels. This contrasts the usual definition of open graphs in the literature, where inputs and outputs are unordered sets of nodes labels. This restriction facilitates the interplay with `Pattern` objects, where the order of input and output nodes represents a choice of Hilbert space basis. Example ------- @@ -34,35 +48,43 @@ class OpenGraph: >>> from graphix.fundamentals import Plane >>> from graphix.opengraph import OpenGraph, Measurement >>> - >>> inside_graph = nx.Graph([(0, 1), (1, 2), (2, 0)]) - >>> + >>> graph = nx.Graph([(0, 1), (1, 2)]) >>> measurements = {i: Measurement(0.5 * i, Plane.XY) for i in range(2)} - >>> inputs = [0] - >>> outputs = [2] - >>> og = OpenGraph(inside_graph, measurements, inputs, outputs) + >>> input_nodes = [0] + >>> output_nodes = [2] + >>> og = OpenGraph(graph, input_nodes, output_nodes, measurements) """ - inside: nx.Graph[int] - measurements: dict[int, Measurement] - inputs: list[int] # Inputs are ordered - outputs: list[int] # Outputs are ordered + graph: nx.Graph[int] + input_nodes: Sequence[int] + output_nodes: Sequence[int] + measurements: Mapping[int, _M_co] def __post_init__(self) -> None: - """Validate the open graph.""" - if not all(node in self.inside.nodes for node in self.measurements): + """Validate the correctness of the open graph.""" + all_nodes = set(self.graph.nodes) + inputs = set(self.input_nodes) + outputs = set(self.output_nodes) + + if not set(self.measurements).issubset(all_nodes): raise ValueError("All measured nodes must be part of the graph's nodes.") - if not all(node in self.inside.nodes for node in self.inputs): + if not inputs.issubset(all_nodes): raise ValueError("All input nodes must be part of the graph's nodes.") - if not all(node in self.inside.nodes for node in self.outputs): + if not outputs.issubset(all_nodes): raise ValueError("All output nodes must be part of the graph's nodes.") - if any(node in self.outputs for node in self.measurements): - raise ValueError("Output node cannot be measured.") - if len(set(self.inputs)) != len(self.inputs): + if outputs & self.measurements.keys(): + raise ValueError("Output nodes cannot be measured.") + if all_nodes - outputs != self.measurements.keys(): + raise ValueError("All non-ouptut nodes must be measured.") + if len(inputs) != len(self.input_nodes): raise ValueError("Input nodes contain duplicates.") - if len(set(self.outputs)) != len(self.outputs): + if len(outputs) != len(self.output_nodes): raise ValueError("Output nodes contain duplicates.") - def isclose(self, other: OpenGraph, rel_tol: float = 1e-09, abs_tol: float = 0.0) -> bool: + # TODO: Up docstrings and generalise to any type + def isclose( + self: OpenGraph[Measurement], other: OpenGraph[Measurement], rel_tol: float = 1e-09, abs_tol: float = 0.0 + ) -> bool: """Return `True` if two open graphs implement approximately the same unitary operator. Ensures the structure of the graphs are the same and all @@ -71,10 +93,10 @@ def isclose(self, other: OpenGraph, rel_tol: float = 1e-09, abs_tol: float = 0.0 This doesn't check they are equal up to an isomorphism. """ - if not nx.utils.graphs_equal(self.inside, other.inside): + if not nx.utils.graphs_equal(self.graph, other.graph): return False - if self.inputs != other.inputs or self.outputs != other.outputs: + if self.input_nodes != other.input_nodes or self.output_nodes != other.output_nodes: return False if set(self.measurements.keys()) != set(other.measurements.keys()): @@ -86,37 +108,169 @@ def isclose(self, other: OpenGraph, rel_tol: float = 1e-09, abs_tol: float = 0.0 ) @staticmethod - def from_pattern(pattern: Pattern) -> OpenGraph: - """Initialise an `OpenGraph` object based on the resource-state graph associated with the measurement pattern.""" + def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: + """Initialise an `OpenGraph[Measurement]` object from the underlying resource-state graph of the input measurement pattern. + + Parameters + ---------- + pattern : Pattern + The input pattern. + + Returns + ------- + OpenGraph[Measurement] + """ graph = pattern.extract_graph() - inputs = pattern.input_nodes - outputs = pattern.output_nodes + input_nodes = pattern.input_nodes + output_nodes = pattern.output_nodes meas_planes = pattern.get_meas_plane() meas_angles = pattern.get_angles() - meas = {node: Measurement(meas_angles[node], meas_planes[node]) for node in meas_angles} + measurements: Mapping[int, Measurement] = { + node: Measurement(meas_angles[node], meas_planes[node]) for node in meas_angles + } + + return OpenGraph(graph, input_nodes, output_nodes, measurements) - return OpenGraph(graph, meas, inputs, outputs) + def to_pattern(self: OpenGraph[Measurement]) -> Pattern | None: + """Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists. - def to_pattern(self) -> Pattern: - """Convert the `OpenGraph` into a `Pattern`. + Returns + ------- + Pattern | None + A deterministic pattern on the open graph. If it does not exist, it returns `None`. - Will raise an exception if the open graph does not have flow, gflow, or - Pauli flow. - The pattern will be generated using maximally-delayed flow. + Notes + ----- + - The open graph instance must be of parametric type `Measurement` to allow for a pattern extraction, otherwise it does not contain information about the measurement angles. + + - This method proceeds by searching a flow on the open graph and converting it into a pattern as prescripted in Ref. [1]. + It first attempts to find a causal flow because the corresponding flow-finding algorithm has lower complexity. If it fails, it attemps to find a Pauli flow because this property is more general than a generalised flow, and the corresponding flow-finding algorithms have the same complexity in the current implementation. + + References + ---------- + [1] Browne et al., NJP 9, 250 (2007) + """ + cflow = self.find_causal_flow() + if cflow is not None: + return cflow.to_corrections().to_pattern() + + pflow = self.find_pauli_flow() + if pflow is not None: + return pflow.to_corrections().to_pattern() + + return None + + def neighbors(self, nodes: Collection[int]) -> set[int]: + """Return the set containing the neighborhood of a set of nodes in the open graph. + + Parameters + ---------- + nodes : Collection[int] + Set of nodes whose neighborhood is to be found + + Returns + ------- + neighbors_set : set[int] + Neighborhood of set `nodes`. """ - g = self.inside.copy() - inputs = self.inputs - outputs = self.outputs - meas = self.measurements + neighbors_set: set[int] = set() + for node in nodes: + neighbors_set |= set(self.graph.neighbors(node)) + return neighbors_set - angles = {node: m.angle for node, m in meas.items()} - planes = {node: m.plane for node, m in meas.items()} + def odd_neighbors(self, nodes: Collection[int]) -> set[int]: + """Return the set containing the odd neighborhood of a set of nodes in the open graph. - return graphix.generator.generate_from_graph(g, angles, inputs, outputs, planes) + Parameters + ---------- + nodes : Collection[int] + Set of nodes whose odd neighborhood is to be found - def compose(self, other: OpenGraph, mapping: Mapping[int, int]) -> tuple[OpenGraph, dict[int, int]]: + Returns + ------- + odd_neighbors_set : set[int] + Odd neighborhood of set `nodes`. + """ + odd_neighbors_set: set[int] = set() + for node in nodes: + odd_neighbors_set ^= self.neighbors([node]) + return odd_neighbors_set + + def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: + """Return a causal flow on the open graph if it exists. + + Returns + ------- + CausalFlow | None + A causal flow object if the open graph has causal flow, `None` otherwise. + + Notes + ----- + - The open graph instance must be of parametric type `Measurement` or `Plane` since the causal flow is only defined on open graphs with :math:`XY` measurements. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^2)`. + + References + ---------- + [1] Mhalla and Perdrix, (2008), Finding Optimal Flows Efficiently, doi.org/10.1007/978-3-540-70575-8_70 + """ + return find_cflow(self) + + def find_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: + r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. + + Returns + ------- + GFlow | None + A gflow object if the open graph has gflow, `None` otherwise. + + Notes + ----- + - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`OpenGraph.find_pauli_flow`. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + aog = PlanarAlgebraicOpenGraph(self) + correction_matrix = compute_correction_matrix(aog) + if correction_matrix is None: + return None + return GFlow.from_correction_matrix( + correction_matrix + ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. + + def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: + r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. + + Returns + ------- + PauliFlow | None + A Pauli flow object if the open graph has Pauli flow, `None` otherwise. + + Notes + ----- + - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`OpenGraph.find_gflow`. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + aog = AlgebraicOpenGraph(self) + correction_matrix = compute_correction_matrix(aog) + if correction_matrix is None: + return None + return PauliFlow.from_correction_matrix( + correction_matrix + ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. + + # TODO: Generalise `compose` to any type of OpenGraph + def compose( + self: OpenGraph[Measurement], other: OpenGraph[Measurement], mapping: Mapping[int, int] + ) -> tuple[OpenGraph[Measurement], dict[int, int]]: r"""Compose two open graphs by merging subsets of nodes from `self` and `other`, and relabeling the nodes of `other` that were not merged. Parameters @@ -148,7 +302,7 @@ def compose(self, other: OpenGraph, mapping: Mapping[int, int]) -> tuple[OpenGra - If only one node of the pair `{v:u}` is measured, this measure is assigned to :math:`u \in V` in the resulting open graph. - Input (and, respectively, output) nodes in the returned open graph have the order of the open graph `self` followed by those of the open graph `other`. Merged nodes are removed, except when they are input (or output) nodes in both open graphs, in which case, they appear in the order they originally had in the graph `self`. """ - if not (mapping.keys() <= other.inside.nodes): + if not (mapping.keys() <= other.graph.nodes): raise ValueError("Keys of mapping must be correspond to nodes of other.") if len(mapping) != len(set(mapping.values())): raise ValueError("Values in mapping contain duplicates.") @@ -160,18 +314,18 @@ def compose(self, other: OpenGraph, mapping: Mapping[int, int]) -> tuple[OpenGra ): raise ValueError(f"Attempted to merge nodes {v}:{u} but have different measurements") - shift = max(*self.inside.nodes, *mapping.values()) + 1 + shift = max(*self.graph.nodes, *mapping.values()) + 1 mapping_sequential = { - node: i for i, node in enumerate(sorted(other.inside.nodes - mapping.keys()), start=shift) + node: i for i, node in enumerate(sorted(other.graph.nodes - mapping.keys()), start=shift) } # assigns new labels to nodes in other not specified in mapping mapping_complete = {**mapping, **mapping_sequential} - g2_shifted = nx.relabel_nodes(other.inside, mapping_complete) - g = nx.compose(self.inside, g2_shifted) + g2_shifted = nx.relabel_nodes(other.graph, mapping_complete) + g = nx.compose(self.graph, g2_shifted) - merged = set(mapping_complete.values()) & self.inside.nodes + merged = set(mapping_complete.values()) & self.graph.nodes def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: p2_mapped = [mapping_complete[node] for node in p2] @@ -180,10 +334,10 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: part2 = [node for node in p2_mapped if node not in merged] return part1 + part2 - inputs = merge_ports(self.inputs, other.inputs) - outputs = merge_ports(self.outputs, other.outputs) + inputs = merge_ports(self.input_nodes, other.input_nodes) + outputs = merge_ports(self.output_nodes, other.output_nodes) measurements_shifted = {mapping_complete[i]: meas for i, meas in other.measurements.items()} measurements = {**self.measurements, **measurements_shifted} - return OpenGraph(g, measurements, inputs, outputs), mapping_complete + return OpenGraph(g, inputs, outputs, measurements), mapping_complete diff --git a/graphix/opengraph_.py b/graphix/opengraph_.py deleted file mode 100644 index 3bf9c47d9..000000000 --- a/graphix/opengraph_.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Class for open graph states.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic, TypeVar - -from graphix.flow._find_cflow import find_cflow -from graphix.flow._find_gpflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix -from graphix.flow.core import CausalFlow, GFlow, PauliFlow -from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement -from graphix.measurements import Measurement - -if TYPE_CHECKING: - from collections.abc import Collection, Mapping, Sequence - - import networkx as nx - - from graphix.pattern import Pattern - -# TODO: Maybe move these definitions to graphix.fundamentals and graphix.measurements ? Now they are redefined in graphix.flow._find_gpflow, not very elegant. -_M_co = TypeVar("_M_co", bound=AbstractMeasurement, covariant=True) -_PM_co = TypeVar("_PM_co", bound=AbstractPlanarMeasurement, covariant=True) - - -@dataclass(frozen=True) -class OpenGraph(Generic[_M_co]): - """An unmutable dataclass providing a representation of open graph states. - - Attributes - ---------- - graph : networkx.Graph[int] - The underlying resource-state graph. Nodes represent qubits and edges represent the application of :math:`CZ` gate on the linked nodes. - input_nodes : Sequence[int] - An ordered sequence of node labels corresponding to the open graph inputs. - output_nodes : Sequence[int] - An ordered sequence of node labels corresponding to the open graph outputs. - measurements : Mapping[int, _M_co] - A mapping between the non-output nodes of the open graph (`key`) and their corresponding measurement label (`value`). Measurement labels can be specified as `Measurement` or `Plane|Axis` instances. - - Notes - ----- - The inputs and outputs of `OpenGraph` instances in Graphix are defined as ordered sequences of node labels. This contrasts the usual definition of open graphs in the literature, where inputs and outputs are unordered sets of nodes labels. This restriction facilitates the interplay with `Pattern` objects, where the order of input and output nodes represents a choice of Hilbert space basis. - - Example - ------- - >>> import networkx as nx - >>> from graphix.fundamentals import Plane - >>> from graphix.opengraph import OpenGraph, Measurement - >>> - >>> graph = nx.Graph([(0, 1), (1, 2)]) - >>> measurements = {i: Measurement(0.5 * i, Plane.XY) for i in range(2)} - >>> input_nodes = [0] - >>> output_nodes = [2] - >>> og = OpenGraph(graph, input_nodes, output_nodes, measurements) - """ - - graph: nx.Graph[int] - input_nodes: Sequence[int] - output_nodes: Sequence[int] - measurements: Mapping[int, _M_co] - - def __post_init__(self) -> None: - """Validate the correctness of the open graph.""" - all_nodes = set(self.graph.nodes) - inputs = set(self.input_nodes) - outputs = set(self.output_nodes) - - if not set(self.measurements).issubset(all_nodes): - raise ValueError("All measured nodes must be part of the graph's nodes.") - if not inputs.issubset(all_nodes): - raise ValueError("All input nodes must be part of the graph's nodes.") - if not outputs.issubset(all_nodes): - raise ValueError("All output nodes must be part of the graph's nodes.") - if outputs & self.measurements.keys(): - raise ValueError("Output nodes cannot be measured.") - if all_nodes - outputs != self.measurements.keys(): - raise ValueError("All non-ouptut nodes must be measured.") - if len(inputs) != len(self.input_nodes): - raise ValueError("Input nodes contain duplicates.") - if len(outputs) != len(self.output_nodes): - raise ValueError("Output nodes contain duplicates.") - - @staticmethod - def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: - """Initialise an `OpenGraph[Measurement]` object from the underlying resource-state graph of the input measurement pattern. - - Parameters - ---------- - pattern : Pattern - The input pattern. - - Returns - ------- - OpenGraph[Measurement] - """ - graph = pattern.extract_graph() - - input_nodes = pattern.input_nodes - output_nodes = pattern.output_nodes - - meas_planes = pattern.get_meas_plane() - meas_angles = pattern.get_angles() - measurements: Mapping[int, Measurement] = { - node: Measurement(meas_angles[node], meas_planes[node]) for node in meas_angles - } - - return OpenGraph(graph, input_nodes, output_nodes, measurements) - - def to_pattern(self: OpenGraph[Measurement]) -> Pattern | None: - """Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists. - - Returns - ------- - Pattern | None - A deterministic pattern on the open graph. If it does not exist, it returns `None`. - - Notes - ----- - - The open graph instance must be of parametric type `Measurement` to allow for a pattern extraction, otherwise it does not contain information about the measurement angles. - - - This method proceeds by searching a flow on the open graph and converting it into a pattern as prescripted in Ref. [1]. - It first attempts to find a causal flow because the corresponding flow-finding algorithm has lower complexity. If it fails, it attemps to find a Pauli flow because this property is more general than a generalised flow, and the corresponding flow-finding algorithms have the same complexity in the current implementation. - - References - ---------- - [1] Browne et al., NJP 9, 250 (2007) - """ - cflow = self.find_causal_flow() - if cflow is not None: - return cflow.to_corrections().to_pattern() - - pflow = self.find_pauli_flow() - if pflow is not None: - return pflow.to_corrections().to_pattern() - - return None - - def neighbors(self, nodes: Collection[int]) -> set[int]: - """Return the set containing the neighborhood of a set of nodes in the open graph. - - Parameters - ---------- - nodes : Collection[int] - Set of nodes whose neighborhood is to be found - - Returns - ------- - neighbors_set : set[int] - Neighborhood of set `nodes`. - """ - neighbors_set: set[int] = set() - for node in nodes: - neighbors_set |= set(self.graph.neighbors(node)) - return neighbors_set - - def odd_neighbors(self, nodes: Collection[int]) -> set[int]: - """Return the set containing the odd neighborhood of a set of nodes in the open graph. - - Parameters - ---------- - nodes : Collection[int] - Set of nodes whose odd neighborhood is to be found - - Returns - ------- - odd_neighbors_set : set[int] - Odd neighborhood of set `nodes`. - """ - odd_neighbors_set: set[int] = set() - for node in nodes: - odd_neighbors_set ^= self.neighbors([node]) - return odd_neighbors_set - - def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: - """Return a causal flow on the open graph if it exists. - - Returns - ------- - CausalFlow | None - A causal flow object if the open graph has causal flow, `None` otherwise. - - Notes - ----- - - The open graph instance must be of parametric type `Measurement` or `Plane` since the causal flow is only defined on open graphs with :math:`XY` measurements. - - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^2)`. - - References - ---------- - [1] Mhalla and Perdrix, (2008), Finding Optimal Flows Efficiently, doi.org/10.1007/978-3-540-70575-8_70 - """ - return find_cflow(self) - - def find_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: - r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. - - Returns - ------- - GFlow | None - A gflow object if the open graph has gflow, `None` otherwise. - - Notes - ----- - - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`OpenGraph.find_pauli_flow`. - - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. - - References - ---------- - [1] Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - aog = PlanarAlgebraicOpenGraph(self) - correction_matrix = compute_correction_matrix(aog) - if correction_matrix is None: - return None - return GFlow.from_correction_matrix( - correction_matrix - ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. - - def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: - r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. - - Returns - ------- - PauliFlow | None - A Pauli flow object if the open graph has Pauli flow, `None` otherwise. - - Notes - ----- - - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`OpenGraph.find_gflow`. - - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. - - References - ---------- - [1] Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - aog = AlgebraicOpenGraph(self) - correction_matrix = compute_correction_matrix(aog) - if correction_matrix is None: - return None - return PauliFlow.from_correction_matrix( - correction_matrix - ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. diff --git a/graphix/pyzx.py b/graphix/pyzx.py index e3d355483..d38dc900b 100644 --- a/graphix/pyzx.py +++ b/graphix/pyzx.py @@ -31,7 +31,8 @@ def _fraction_of_angle(angle: ExpressionOrFloat) -> Fraction: return Fraction(angle) -def to_pyzx_graph(og: OpenGraph) -> BaseGraph[int, tuple[int, int]]: +# TODO: Adapt to new OpenGrpah APi +def to_pyzx_graph(og: OpenGraph[Measurement]) -> BaseGraph[int, tuple[int, int]]: """Return a :mod:`pyzx` graph corresponding to the open graph. Example @@ -42,7 +43,7 @@ def to_pyzx_graph(og: OpenGraph) -> BaseGraph[int, tuple[int, int]]: >>> inputs = [0] >>> outputs = [2] >>> measurements = {0: Measurement(0, Plane.XY), 1: Measurement(1, Plane.YZ)} - >>> og = OpenGraph(g, measurements, inputs, outputs) + >>> og = OpenGraph(g, inputs, outputs, measurements) >>> reconstructed_pyzx_graph = to_pyzx_graph(og) """ if zx.__version__ != "0.9.0": @@ -61,11 +62,11 @@ def add_vertices(n: int, ty: VertexType) -> list[VertexType]: return verts # Add input boundary nodes - in_verts = add_vertices(len(og.inputs), VertexType.BOUNDARY) + in_verts = add_vertices(len(og.input_nodes), VertexType.BOUNDARY) g.set_inputs(tuple(in_verts)) # Add nodes for internal Z spiders - not including the phase gadgets - body_verts = add_vertices(len(og.inside), VertexType.Z) + body_verts = add_vertices(len(og.graph), VertexType.Z) # Add nodes for the phase gadgets. In OpenGraph we don't store the # effect as a separate node, it is instead just stored in the @@ -73,23 +74,23 @@ def add_vertices(n: int, ty: VertexType) -> list[VertexType]: x_meas = [i for i, m in og.measurements.items() if m.plane == Plane.YZ] x_meas_verts = add_vertices(len(x_meas), VertexType.Z) - out_verts = add_vertices(len(og.outputs), VertexType.BOUNDARY) + out_verts = add_vertices(len(og.output_nodes), VertexType.BOUNDARY) g.set_outputs(tuple(out_verts)) # Maps a node's ID in the Open Graph to it's corresponding node ID in # the PyZX graph and vice versa. - map_to_og = dict(zip(body_verts, og.inside.nodes())) + map_to_og = dict(zip(body_verts, og.graph.nodes())) map_to_pyzx = {v: i for i, v in map_to_og.items()} # Open Graph's don't have boundary nodes, so we need to connect the # input and output Z spiders to their corresponding boundary nodes in # pyzx. - for pyzx_index, og_index in zip(in_verts, og.inputs): + for pyzx_index, og_index in zip(in_verts, og.input_nodes): g.add_edge((pyzx_index, map_to_pyzx[og_index])) - for pyzx_index, og_index in zip(out_verts, og.outputs): + for pyzx_index, og_index in zip(out_verts, og.output_nodes): g.add_edge((pyzx_index, map_to_pyzx[og_index])) - og_edges = og.inside.edges() + og_edges = og.graph.edges() pyzx_edges = ((map_to_pyzx[a], map_to_pyzx[b]) for a, b in og_edges) g.add_edges(pyzx_edges, EdgeType.HADAMARD) @@ -114,7 +115,7 @@ def _checked_float(x: FractionLike) -> float: return float(x) -def from_pyzx_graph(g: BaseGraph[int, tuple[int, int]]) -> OpenGraph: +def from_pyzx_graph(g: BaseGraph[int, tuple[int, int]]) -> OpenGraph[Measurement]: """Construct an :class:`OpenGraph` from a :mod:`pyzx` graph. This method may add additional nodes to the graph so that it adheres @@ -193,4 +194,4 @@ def from_pyzx_graph(g: BaseGraph[int, tuple[int, int]]) -> OpenGraph: # expects a float measurements[v] = Measurement(-_checked_float(g.phase(v)), Plane.XY) - return OpenGraph(g_nx, measurements, inputs, outputs) + return OpenGraph(g_nx, inputs, outputs, measurements) diff --git a/tests/test_find_pflow.py b/tests/test_find_pflow.py deleted file mode 100644 index 185418139..000000000 --- a/tests/test_find_pflow.py +++ /dev/null @@ -1,682 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, NamedTuple - -import networkx as nx -import numpy as np -import pytest - -from graphix._linalg import MatGF2 -from graphix.find_pflow import ( - OpenGraphIndex, - _compute_pflow_matrices, - _compute_reduced_adj, - _compute_topological_generations, - _find_pflow_simple, - find_pflow, -) -from graphix.fundamentals import Plane -from graphix.generator import _pflow2pattern -from graphix.measurements import Measurement -from graphix.opengraph import OpenGraph -from graphix.parameter import Placeholder -from graphix.random_objects import rand_circuit -from graphix.states import PlanarState -from tests.conftest import fx_rng - -if TYPE_CHECKING: - from numpy.random import Generator - from pytest_benchmark import BenchmarkFixture - - -class OpenGraphTestCase(NamedTuple): - ogi: OpenGraphIndex - radj: MatGF2 | None - flow_demand_mat: MatGF2 | None - order_demand_mat: MatGF2 | None - has_pflow: bool - - -class DAGTestCase(NamedTuple): - adj_mat: MatGF2 - generations: list[list[int]] | None - - -def get_og_rndcircuit(depth: int, n_qubits: int, n_inputs: int | None = None) -> OpenGraph: - """Return an open graph from a random circuit. - - Parameters - ---------- - depth : int - Circuit depth of the random circuits for generating open graphs. - n_qubits : int - Number of qubits in the random circuits for generating open graphs. It controls the number of outputs. - n_inputs : int | None - Optional (default to `None`). Maximum number of inputs in the returned open graph. The returned open graph is the open graph generated from the random circuit where `n_qubits - n_inputs` nodes have been removed from the input-nodes set. This operation does not change the flow properties of the graph. - - Returns - ------- - OpenGraph - Open graph with causal flow. - """ - circuit = rand_circuit(n_qubits, depth, fx_rng._fixture_function()) - pattern = circuit.transpile().pattern - graph = pattern.extract_graph() - - angles = pattern.get_angles() - planes = pattern.get_meas_plane() - meas = {node: Measurement(angle, planes[node]) for node, angle in angles.items()} - - og = OpenGraph( - inside=graph, - inputs=pattern.input_nodes, - outputs=pattern.output_nodes, - measurements=meas, - ) - - if n_inputs is not None: - ni_remove = max(0, n_qubits - n_inputs) - for i in range(ni_remove): - og.inputs.remove(i) - - return og - - -def get_og_dense(ni: int, no: int, m: int) -> OpenGraph: - """Return a dense open graph with causal, gflow and pflow. - - Parameters - ---------- - ni : int - Number of input nodes (must be equal or smaller than `no` ). - no : int - Number of output nodes (must be larger than 1). - m : int - Number of total nodes (it must satisfy `m - 2*no > 0`). - - Returns - ------- - OpenGraph - Open graph with causal and gflow. - - Notes - ----- - Adapted from Fig. 1 in Houshmand et al., Phys. Rev. A, 98 (2018) (arXiv:1705.01535) - """ - if no <= 1: - raise ValueError("Number of outputs must be larger than 1 (no > 1).") - if m - 2 * no <= 0: - raise ValueError("Total number of nodes must be larger than twice the number of outputs (m - 2no > 0).") - - inputs = list(range(no)) # we remove inputs afterwards - outputs = list(range(no, 2 * no)) - edges = [(i, o) for i, o in zip(inputs[:-2], outputs[:-2])] - edges.extend((node, node + 1) for node in range(2 * no - 1, m - 1)) - edges.append((inputs[-2], m - 1)) - - graph: nx.Graph[int] = nx.Graph() - graph.add_nodes_from(range(m)) - graph.add_edges_from(edges) - graph_c = nx.complement(graph) - - meas = {node: Measurement(Placeholder("Angle"), Plane.XY) for node in range(m) if node not in set(outputs)} - - og = OpenGraph( - inside=graph_c, - inputs=inputs, - outputs=outputs, - measurements=meas, - ) # This open graph corresponds to the example in the reference. Now we remove nodes from the set of inputs, since this operation preserves the flow properties. - - ni_remove = max(0, len(og.inputs) - ni) - for i in og.inputs[ni_remove:]: - og.inputs.remove(i) - return og - - -def prepare_test_og() -> list[OpenGraphTestCase]: - test_cases: list[OpenGraphTestCase] = [] - - # Trivial open graph with pflow and nI = nO - def get_og_0() -> OpenGraph: - """Return an open graph with Pauli flow and equal number of outputs and inputs. - - The returned graph has the following structure: - - [0]-1-(2) - """ - graph: nx.Graph[int] = nx.Graph([(0, 1), (1, 2)]) - inputs = [0] - outputs = [2] - meas = { - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.5, Plane.YZ), # Y - } - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_0()), - radj=MatGF2([[1, 0], [0, 1]]), - flow_demand_mat=MatGF2([[1, 0], [1, 1]]), - order_demand_mat=MatGF2([[0, 0], [0, 0]]), - has_pflow=True, - ) - ) - - # Non-trivial open graph without pflow and nI = nO - def get_og_1() -> OpenGraph: - """Return an open graph without Pauli flow and equal number of outputs and inputs. - - The returned graph has the following structure: - - [0]-2-4-(6) - | | - [1]-3-5-(7) - """ - graph: nx.Graph[int] = nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]) - inputs = [1, 0] - outputs = [6, 7] - meas = { - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.1, Plane.XZ), # XZ - 2: Measurement(0.5, Plane.XZ), # X - 3: Measurement(0.5, Plane.YZ), # Y - 4: Measurement(0.5, Plane.YZ), # Y - 5: Measurement(0.1, Plane.YZ), # YZ - } - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_1()), - radj=MatGF2( - [ - [1, 0, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0], - [0, 1, 1, 0, 0, 0], - [1, 0, 0, 1, 0, 0], - [1, 0, 0, 1, 1, 0], - [0, 1, 1, 0, 0, 1], - ] - ), - flow_demand_mat=MatGF2( - [ - [1, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], - [0, 1, 1, 0, 0, 0], - [1, 1, 0, 1, 0, 0], - [1, 0, 1, 1, 1, 0], - [0, 0, 0, 1, 0, 0], - ] - ), - order_demand_mat=MatGF2( - [ - [0, 0, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], - [0, 1, 1, 0, 0, 1], - ] - ), - has_pflow=False, - ) - ) - - # Non-trivial open graph with pflow and nI = nO - def get_og_2() -> OpenGraph: - """Return an open graph with Pauli flow and equal number of outputs and inputs. - - The returned graph has the following structure: - - [0]-2-4-(6) - | | - [1]-3-5-(7) - """ - graph: nx.Graph[int] = nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]) - inputs = [0, 1] - outputs = [6, 7] - meas = { - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.1, Plane.XY), # XY - 2: Measurement(0.0, Plane.XY), # X - 3: Measurement(0.1, Plane.XY), # XY - 4: Measurement(0.0, Plane.XY), # X - 5: Measurement(0.5, Plane.XY), # Y - } - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_2()), - radj=MatGF2( - [ - [1, 0, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0], - [0, 1, 1, 0, 0, 0], - [1, 0, 0, 1, 0, 0], - [1, 0, 0, 1, 1, 0], - [0, 1, 1, 0, 0, 1], - ] - ), - flow_demand_mat=MatGF2( - [ - [1, 0, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0], - [0, 1, 1, 0, 0, 0], - [1, 0, 0, 1, 0, 0], - [1, 0, 0, 1, 1, 0], - [0, 1, 1, 1, 0, 1], - ] - ), - order_demand_mat=MatGF2( - [ - [0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], - ] - ), - has_pflow=True, - ) - ) - - # Non-trivial open graph with pflow and nI != nO - def get_og_3() -> OpenGraph: - """Return an open graph with Pauli flow and unequal number of outputs and inputs. - - Example from Fig. 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - graph: nx.Graph[int] = nx.Graph( - [(0, 2), (2, 4), (3, 4), (4, 6), (1, 4), (1, 6), (2, 3), (3, 5), (2, 6), (3, 6)] - ) - inputs = [0] - outputs = [5, 6] - meas = { - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.1, Plane.XZ), # XZ - 2: Measurement(0.5, Plane.YZ), # Y - 3: Measurement(0.1, Plane.XY), # XY - 4: Measurement(0, Plane.XZ), # Z - } - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_3()), - radj=MatGF2( - [[0, 1, 0, 0, 0, 0], [0, 0, 0, 1, 0, 1], [0, 0, 1, 1, 0, 1], [0, 1, 0, 1, 1, 1], [1, 1, 1, 0, 0, 1]] - ), - flow_demand_mat=MatGF2( - [[0, 1, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0], [0, 1, 1, 1, 0, 1], [0, 1, 0, 1, 1, 1], [0, 0, 0, 1, 0, 0]] - ), - order_demand_mat=MatGF2( - [[0, 0, 0, 0, 0, 0], [1, 0, 0, 1, 0, 1], [0, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0]] - ), - has_pflow=True, - ) - ) - - # The following tests check the final result only, not the intermediate steps. - - # Non-trivial open graph with pflow and nI != nO - def get_og_4() -> OpenGraph: - """Return an open graph with Pauli flow and unequal number of outputs and inputs.""" - graph: nx.Graph[int] = nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]) - inputs = [0, 1] - outputs = [5, 6, 8] - meas = { - 0: Measurement(0.1, Plane.XY), - 1: Measurement(0.1, Plane.XY), - 2: Measurement(0.0, Plane.XY), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.5, Plane.XY), - 7: Measurement(0, Plane.XY), - } - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_4()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=True, - ) - ) - - # Non-trivial open graph with pflow and nI != nO - def get_og_5() -> OpenGraph: - """Return an open graph with Pauli flow and unequal number of outputs and inputs.""" - graph: nx.Graph[int] = nx.Graph([(0, 2), (1, 2), (2, 3), (3, 4)]) - inputs = [0, 1] - outputs = [1, 3, 4] - meas = {0: Measurement(0.1, Plane.XY), 2: Measurement(0.5, Plane.YZ)} - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_5()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=True, - ) - ) - - # Non-trivial open graph with pflow and nI != nO - def get_og_6() -> OpenGraph: - """Return an open graph with Pauli flow and unequal number of outputs and inputs.""" - graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7)]) - inputs = [1] - outputs = [6, 2, 7] - meas = { - 0: Measurement(0.1, Plane.XZ), - 1: Measurement(0.1, Plane.XY), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.1, Plane.XY), - 5: Measurement(0.1, Plane.YZ), - } - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_6()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=True, - ) - ) - - # Disconnected open graph with pflow and nI != nO - def get_og_7() -> OpenGraph: - """Return an open graph with Pauli flow and unequal number of outputs and inputs.""" - graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (4, 6)]) - inputs: list[int] = [] - outputs = [1, 3, 4] - meas = {0: Measurement(0.5, Plane.XZ), 2: Measurement(0, Plane.YZ), 6: Measurement(0.2, Plane.XY)} - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_7()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=True, - ) - ) - - # Non-trivial open graph without pflow and nI != nO - def get_og_8() -> OpenGraph: - """Return an open graph without Pauli flow and unequal number of outputs and inputs.""" - graph: nx.Graph[int] = nx.Graph( - [(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7), (5, 6), (6, 7)] - ) - inputs = [1] - outputs = [6, 2, 7] - meas = { - 0: Measurement(0.1, Plane.XZ), - 1: Measurement(0.1, Plane.XY), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.1, Plane.XY), - 5: Measurement(0.1, Plane.XY), - } - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_8()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=False, - ) - ) - - # Disconnected open graph without pflow and nI != nO - def get_og_9() -> OpenGraph: - """Return an open graph without Pauli flow and unequal number of outputs and inputs.""" - graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (4, 6)]) - inputs = [0] - outputs = [1, 3, 4] - meas = {0: Measurement(0.1, Plane.XZ), 2: Measurement(0, Plane.YZ), 6: Measurement(0.2, Plane.XY)} - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_9()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=False, - ) - ) - - # Non-trivial open graph without pflow and nI != nO - def get_og_10() -> OpenGraph: - """Return a graph constructed by adding a disconnected input to graph_6. The resulting graph does not have pflow.""" - graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7)]) - graph.add_node(8) - inputs = [1, 8] - outputs = [6, 2, 7] - meas = { - 0: Measurement(0.1, Plane.XZ), - 1: Measurement(0.1, Plane.XY), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.1, Plane.XY), - 5: Measurement(0.1, Plane.YZ), - 8: Measurement(0.1, Plane.XY), - } - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_10()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=False, - ) - ) - - # Open graph with only Pauli measurements, without pflow and nI != nO - def get_og_11() -> OpenGraph: - """Return an open graph without Pauli flow and unequal number of outputs and inputs.""" - graph: nx.Graph[int] = nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]) - inputs = [0, 1] - outputs = [5, 6, 8] - meas = { - 0: Measurement(0, Plane.XY), - 1: Measurement(0, Plane.XY), - 2: Measurement(0, Plane.XZ), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.5, Plane.XY), - 7: Measurement(0, Plane.YZ), - } - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_11()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=False, - ) - ) - - # Open graph with only Pauli measurements, with pflow and nI != nO - def get_og_12() -> OpenGraph: - """Return an open graph with Pauli flow and unequal number of outputs and inputs. Even though all nodes are Pauli-measured, open graph has flow because none of them are inputs.""" - graph: nx.Graph[int] = nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]) - outputs = [5, 6, 8] - meas = { - 0: Measurement(0, Plane.XZ), - 1: Measurement(0, Plane.XZ), - 2: Measurement(0, Plane.XZ), - 3: Measurement(0, Plane.XZ), - 4: Measurement(0, Plane.XZ), - 7: Measurement(0, Plane.XZ), - } - - return OpenGraph(inside=graph, inputs=[], outputs=outputs, measurements=meas) - - test_cases.append( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_12()), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=True, - ) - ) - - return test_cases - - -def prepare_benchmark_og() -> list[OpenGraphTestCase]: - test_cases: list[OpenGraphTestCase] = [] - - # Open graph from random circuit - test_cases.extend( - ( - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_rndcircuit(depth=20, n_qubits=7, n_inputs=1)), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=True, - ), - OpenGraphTestCase( - ogi=OpenGraphIndex(get_og_dense(ni=3, no=6, m=400)), - radj=None, - flow_demand_mat=None, - order_demand_mat=None, - has_pflow=True, - ), - ) - ) - return test_cases - - -def prepare_test_dag() -> list[DAGTestCase]: - test_cases: list[DAGTestCase] = [] - - # Simple DAG - test_cases.extend( - ( # Simple DAG - DAGTestCase( - adj_mat=MatGF2([[0, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 1, 0]]), generations=[[0], [1, 2], [3]] - ), - # Graph with loop - DAGTestCase(adj_mat=MatGF2([[0, 0, 0, 0], [1, 0, 0, 1], [1, 0, 0, 0], [0, 1, 1, 0]]), generations=None), - # Disconnected graph - DAGTestCase( - adj_mat=MatGF2([[0, 0, 0, 0, 0], [1, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 1, 0, 0], [0, 0, 1, 0, 0]]), - generations=[[0, 2], [1, 3, 4]], - ), - ) - ) - - return test_cases - - -class TestPflow: - @pytest.mark.parametrize("test_case", prepare_test_og()) - def test_compute_reduced_adj(self, test_case: OpenGraphTestCase) -> None: - if test_case.radj is not None: - ogi = test_case.ogi - radj = _compute_reduced_adj(ogi) - assert np.all(radj == test_case.radj) - - @pytest.mark.parametrize("test_case", prepare_test_og()) - def test_compute_pflow_matrices(self, test_case: OpenGraphTestCase) -> None: - if test_case.flow_demand_mat is not None and test_case.order_demand_mat is not None: - ogi = test_case.ogi - flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) - - assert np.all(flow_demand_matrix == test_case.flow_demand_mat) - assert np.all(order_demand_matrix == test_case.order_demand_mat) - - @pytest.mark.parametrize("test_case", prepare_test_og()) - def test_find_pflow_simple(self, test_case: OpenGraphTestCase) -> None: - if test_case.flow_demand_mat is not None: - ogi = test_case.ogi - if len(ogi.og.outputs) == len(ogi.og.inputs): - pflow_algebraic = _find_pflow_simple(ogi) - - if not test_case.has_pflow: - assert pflow_algebraic is None - else: - assert pflow_algebraic is not None - correction_matrix, _ = pflow_algebraic - ident = MatGF2(np.eye(len(ogi.non_outputs), dtype=np.uint8)) - assert np.all( - (test_case.flow_demand_mat @ correction_matrix) % 2 == ident - ) # Test with numpy matrix product. - - @pytest.mark.parametrize("test_case", prepare_test_og()) - def test_find_pflow_determinism(self, test_case: OpenGraphTestCase, fx_rng: Generator) -> None: - og = test_case.ogi.og - - pflow = find_pflow(og) - - if not test_case.has_pflow: - assert pflow is None - else: - assert pflow is not None - - pattern = _pflow2pattern( - graph=og.inside, - inputs=set(og.inputs), - meas_planes={i: m.plane for i, m in og.measurements.items()}, - angles={i: m.angle for i, m in og.measurements.items()}, - p=pflow[0], - l_k=pflow[1], - ) - pattern.reorder_output_nodes(og.outputs) - - alpha = 2 * np.pi * fx_rng.random() - state_ref = pattern.simulate_pattern(input_state=PlanarState(Plane.XY, alpha)) - - n_shots = 5 - results = [] - for _ in range(n_shots): - state = pattern.simulate_pattern(input_state=PlanarState(Plane.XY, alpha)) - results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) - - avg = sum(results) / n_shots - - assert avg == pytest.approx(1) - - @pytest.mark.parametrize("test_case", prepare_benchmark_og()) - def test_benchmark_pflow(self, benchmark: BenchmarkFixture, test_case: OpenGraphTestCase) -> None: - og = test_case.ogi.og - - pflow = benchmark(find_pflow, og) - - if not test_case.has_pflow: - assert pflow is None - else: - assert pflow is not None - - @pytest.mark.parametrize("test_case", prepare_test_dag()) - def test_compute_topological_generations(self, test_case: DAGTestCase) -> None: - adj_mat = test_case.adj_mat - generations_ref = test_case.generations - - assert generations_ref == _compute_topological_generations(adj_mat) diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index e03f71dec..3d36d327c 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -10,7 +10,7 @@ from graphix.flow.core import CausalFlow, GFlow, PauliFlow, XZCorrections from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.measurements import Measurement -from graphix.opengraph_ import OpenGraph +from graphix.opengraph import OpenGraph from graphix.pattern import Pattern from graphix.states import PlanarState diff --git a/tests/test_flow_find_gpflow.py b/tests/test_flow_find_gpflow.py index 3d8bb01e4..704008db2 100644 --- a/tests/test_flow_find_gpflow.py +++ b/tests/test_flow_find_gpflow.py @@ -26,7 +26,7 @@ ) from graphix.fundamentals import Axis, Plane from graphix.measurements import Measurement -from graphix.opengraph_ import OpenGraph +from graphix.opengraph import OpenGraph if TYPE_CHECKING: from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement diff --git a/tests/test_generator.py b/tests/test_generator.py deleted file mode 100644 index b2ba81567..000000000 --- a/tests/test_generator.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import networkx as nx -import numpy as np -import pytest - -from graphix.fundamentals import Plane -from graphix.generator import generate_from_graph -from graphix.gflow import find_gflow, find_pauliflow, pauliflow_from_pattern -from graphix.measurements import Measurement -from graphix.opengraph import OpenGraph -from graphix.random_objects import rand_gate - -if TYPE_CHECKING: - from numpy.random import Generator - - -class TestGenerator: - def get_graph_pflow(self, fx_rng: Generator) -> OpenGraph: - """Create a graph which has pflow but no gflow. - - Parameters - ---------- - fx_rng : :class:`numpy.random.Generator` - See graphix.tests.conftest.py - - Returns - ------- - OpenGraph: :class:`graphix.opengraph.OpenGraph` - """ - graph: nx.Graph[int] = nx.Graph( - [(0, 2), (1, 4), (2, 3), (3, 4), (2, 5), (3, 6), (4, 7), (5, 6), (6, 7), (5, 8), (7, 9)] - ) - inputs = [1, 0] - outputs = [9, 8] - - # Heuristic mixture of Pauli and non-Pauli angles ensuring there's no gflow but there's pflow. - meas_angles: dict[int, float] = { - **dict.fromkeys(range(4), 0), - **dict(zip(range(4, 8), (2 * fx_rng.random(4)).tolist())), - } - meas_planes = dict.fromkeys(range(8), Plane.XY) - meas = {i: Measurement(angle, plane) for (i, angle), plane in zip(meas_angles.items(), meas_planes.values())} - - gf, _ = find_gflow(graph=graph, iset=set(inputs), oset=set(outputs), meas_planes=meas_planes) - pf, _ = find_pauliflow( - graph=graph, iset=set(inputs), oset=set(outputs), meas_planes=meas_planes, meas_angles=meas_angles - ) - - assert gf is None # example graph doesn't have gflow - assert pf is not None # example graph has Pauli flow - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - def test_pattern_generation_determinism_flow(self, fx_rng: Generator) -> None: - graph: nx.Graph[int] = nx.Graph([(0, 3), (1, 4), (2, 5), (1, 3), (2, 4), (3, 6), (4, 7), (5, 8)]) - inputs = [1, 0, 2] # non-trivial order to check order is conserved. - outputs = [7, 6, 8] - angles = dict(zip(range(6), (2 * fx_rng.random(6)).tolist())) - meas_planes = dict.fromkeys(range(6), Plane.XY) - - pattern = generate_from_graph(graph, angles, inputs, outputs, meas_planes=meas_planes) - pattern.standardize() - pattern.minimize_space() - - repeats = 3 # for testing the determinism of a pattern - results = [pattern.simulate_pattern(rng=fx_rng) for _ in range(repeats)] - - for i in range(1, 3): - inner_product = np.dot(results[0].flatten(), results[i].flatten().conjugate()) - assert abs(inner_product) == pytest.approx(1) - - assert pattern.input_nodes == inputs - assert pattern.output_nodes == outputs - - def test_pattern_generation_determinism_gflow(self, fx_rng: Generator) -> None: - graph: nx.Graph[int] = nx.Graph([(1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (3, 6), (1, 6)]) - inputs = [3, 1, 5] - outputs = [4, 2, 6] - angles = dict(zip([1, 3, 5], (2 * fx_rng.random(3)).tolist())) - meas_planes = dict.fromkeys([1, 3, 5], Plane.XY) - - pattern = generate_from_graph(graph, angles, inputs, outputs, meas_planes=meas_planes) - pattern.standardize() - pattern.minimize_space() - - repeats = 3 # for testing the determinism of a pattern - results = [pattern.simulate_pattern(rng=fx_rng) for _ in range(repeats)] - - for i in range(1, 3): - inner_product = np.dot(results[0].flatten(), results[i].flatten().conjugate()) - assert abs(inner_product) == pytest.approx(1) - - assert pattern.input_nodes == inputs - assert pattern.output_nodes == outputs - - def test_pattern_generation_determinism_pflow(self, fx_rng: Generator) -> None: - og = self.get_graph_pflow(fx_rng) - pattern = og.to_pattern() - pattern.standardize() - pattern.minimize_space() - - repeats = 3 # for testing the determinism of a pattern - results = [pattern.simulate_pattern(rng=fx_rng) for _ in range(repeats)] - - for i in range(1, 3): - inner_product = np.dot(results[0].flatten(), results[i].flatten().conjugate()) - assert abs(inner_product) == pytest.approx(1) - - assert og.inputs == pattern.input_nodes - assert og.outputs == pattern.output_nodes - - def test_pattern_generation_flow(self, fx_rng: Generator) -> None: - nqubits = 3 - depth = 2 - pairs = [(0, 1), (1, 2)] - circuit = rand_gate(nqubits, depth, pairs, fx_rng) - # transpile into graph - pattern = circuit.transpile().pattern - pattern.standardize() - pattern.shift_signals() - # get the graph and generate pattern again with flow algorithm - graph = pattern.extract_graph() - input_list = [0, 1, 2] - angles: dict[int, float] = {} - for cmd in pattern.extract_measurement_commands(): - assert isinstance(cmd.angle, float) - angles[cmd.node] = float(cmd.angle) - meas_planes = pattern.get_meas_plane() - pattern2 = generate_from_graph(graph, angles, input_list, pattern.output_nodes, meas_planes) - # check that the new one runs and returns correct result - pattern2.standardize() - pattern2.shift_signals() - pattern2.minimize_space() - state = circuit.simulate_statevector().statevec - state_mbqc = pattern2.simulate_pattern(rng=fx_rng) - assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) - - def test_pattern_generation_no_internal_nodes(self) -> None: - g: nx.Graph[int] = nx.Graph() - g.add_edges_from([(0, 1), (1, 2)]) - pattern = generate_from_graph(g, {}, {0, 1, 2}, {0, 1, 2}, {}) - graph = pattern.extract_graph() - graph_ref = nx.Graph(((0, 1), (1, 2))) - assert nx.utils.graphs_equal(graph, graph_ref) - - def test_pattern_generation_pflow(self, fx_rng: Generator) -> None: - og = self.get_graph_pflow(fx_rng) - pattern = og.to_pattern() - - graph_generated_pattern = pattern.extract_graph() - assert nx.is_isomorphic(og.inside, graph_generated_pattern) - - pattern.standardize() - pf_generated_pattern, _ = pauliflow_from_pattern(pattern) - assert pf_generated_pattern is not None diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index 58844fd55..ba5230b17 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -1,50 +1,628 @@ +"""Unit tests for the class `:class: graphix.opengraph.OpenGraph`. + +This module tests the full conversion Open Graph -> Flow -> XZ-corrections -> Pattern for all three classes of flow. +Output correctness is verified by checking if the resulting pattern is deterministic (when the flow exists). +""" + from __future__ import annotations +from typing import TYPE_CHECKING, NamedTuple + import networkx as nx import numpy as np import pytest -from graphix import Pattern, command +from graphix.command import E from graphix.fundamentals import Plane from graphix.measurements import Measurement from graphix.opengraph import OpenGraph +from graphix.pattern import Pattern +from graphix.random_objects import rand_circuit +from graphix.states import PlanarState +if TYPE_CHECKING: + from numpy.random import Generator -# Tests whether an open graph can be converted to and from a pattern and be -# successfully reconstructed. -def test_open_graph_to_pattern() -> None: - g: nx.Graph[int] - g = nx.Graph([(0, 1), (1, 2)]) - inputs = [0] - outputs = [2] - meas = {0: Measurement(0, Plane.XY), 1: Measurement(0, Plane.XY)} - og = OpenGraph(g, meas, inputs, outputs) - pattern = og.to_pattern() - og_reconstructed = OpenGraph.from_pattern(pattern) +class OpenGraphFlowTestCase(NamedTuple): + og: OpenGraph[Measurement] + has_cflow: bool + has_gflow: bool + has_pflow: bool - assert og.isclose(og_reconstructed) - # 0 -- 1 -- 2 - # | - # 3 -- 4 -- 5 - g = nx.Graph([(0, 1), (1, 2), (1, 4), (3, 4), (4, 5)]) - inputs = [0, 3] - outputs = [2, 5] - meas = { - 0: Measurement(0, Plane.XY), - 1: Measurement(1.0, Plane.XY), - 3: Measurement(0.5, Plane.YZ), - 4: Measurement(1.0, Plane.XY), - } +def _og_0() -> OpenGraphFlowTestCase: + """Generate open graph. + + Structure: + + [(0)]-[(1)] + """ + meas: dict[int, Measurement] = {} + og = OpenGraph( + graph=nx.Graph([(0, 1)]), + input_nodes=[0, 1], + output_nodes=[0, 1], + measurements=meas, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_1() -> OpenGraphFlowTestCase: + """Generate open graph. + + Structure: + + [0]-1-20-30-4-(5) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 20), (20, 30), (30, 4), (4, 5)]), + input_nodes=[0], + output_nodes=[5], + measurements={ + 0: Measurement(0.1, Plane.XY), + 1: Measurement(0.2, Plane.XY), + 20: Measurement(0.3, Plane.XY), + 30: Measurement(0.4, Plane.XY), + 4: Measurement(0.5, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_2() -> OpenGraphFlowTestCase: + """Generate open graph. + + Structure: + + [1]-3-(5) + | + [2]-4-(6) + """ + og = OpenGraph( + graph=nx.Graph([(1, 3), (2, 4), (3, 4), (3, 5), (4, 6)]), + input_nodes=[1, 2], + output_nodes=[5, 6], + measurements={ + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.2, Plane.XY), + 3: Measurement(0.3, Plane.XY), + 4: Measurement(0.4, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_3() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [1]-(4) + \ / + X____ + / | + [2]-(5) | + \ / | + X | + / \ / + [3]-(6) + """ + og = OpenGraph( + graph=nx.Graph([(1, 4), (1, 6), (2, 4), (2, 5), (2, 6), (3, 5), (3, 6)]), + input_nodes=[1, 2, 3], + output_nodes=[4, 5, 6], + measurements={ + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.2, Plane.XY), + 3: Measurement(0.3, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) + + +def _og_4() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-[1] + /| | + (4)| | + \| | + 2--(5)-3 + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 2), (0, 4), (1, 5), (2, 4), (2, 5), (3, 5)]), + input_nodes=[0, 1], + output_nodes=[4, 5], + measurements={ + 0: Measurement(0.1, Plane.XY), + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.2, Plane.XZ), + 3: Measurement(0.3, Plane.YZ), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) + + +def _og_5() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [1]-(3) + \ / + X + / \ + [2]-(4) + """ + og = OpenGraph( + graph=nx.Graph([(1, 3), (1, 4), (2, 3), (2, 4)]), + input_nodes=[1, 2], + output_nodes=[3, 4], + measurements={ + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.1, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_6() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0] + | + 1-2-3 + | + (4) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2), (1, 4), (2, 3)]), + input_nodes=[0], + output_nodes=[4], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0, Plane.XY), # X + 2: Measurement(0.1, Plane.XY), # XY + 3: Measurement(0, Plane.XY), # X + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def _og_7() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-1-(2) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (1, 2)]), + input_nodes=[0], + output_nodes=[2], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.5, Plane.YZ), # Y + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def _og_8() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2-4-(6) + | | + [1]-3-5-(7) + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]), + input_nodes=[1, 0], + output_nodes=[6, 7], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XZ), # XZ + 2: Measurement(0.5, Plane.XZ), # X + 3: Measurement(0.5, Plane.YZ), # Y + 4: Measurement(0.5, Plane.YZ), # Y + 5: Measurement(0.1, Plane.YZ), # YZ + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_9() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2-4-(6) + | | + [1]-3-5-(7) + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]), + input_nodes=[0, 1], + output_nodes=[6, 7], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XY), # XY + 2: Measurement(0.0, Plane.XY), # X + 3: Measurement(0.1, Plane.XY), # XY + 4: Measurement(0.0, Plane.XY), # X + 5: Measurement(0.5, Plane.XY), # Y + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_10() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 3-(5) + /| \ + [0]-2-4-(6) + | | /| + | 1 | + |____| + + Notes + ----- + Example from Fig. 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (2, 4), (3, 4), (4, 6), (1, 4), (1, 6), (2, 3), (3, 5), (2, 6), (3, 6)]), + input_nodes=[0], + output_nodes=[5, 6], + measurements={ + 0: Measurement(0.1, Plane.XY), # XY + 1: Measurement(0.1, Plane.XZ), # XZ + 2: Measurement(0.5, Plane.YZ), # Y + 3: Measurement(0.1, Plane.XY), # XY + 4: Measurement(0, Plane.XZ), # Z + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def _og_11() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2--3--4-7-(8) + | | | + (6)[1](5) + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), + input_nodes=[0, 1], + output_nodes=[5, 6, 8], + measurements={ + 0: Measurement(0.1, Plane.XY), + 1: Measurement(0.1, Plane.XY), + 2: Measurement(0.0, Plane.XY), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.5, Plane.XY), + 7: Measurement(0, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) + + +def _og_12() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2-(3)-(4) + | + [(1)] + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 2), (2, 3), (3, 4)]), + input_nodes=[0, 1], + output_nodes=[1, 3, 4], + measurements={0: Measurement(0.1, Plane.XY), 2: Measurement(0.5, Plane.YZ)}, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def _og_13() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 0-[1] + | | + 5-(2)-3--4-(7) + | + (6) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7)]), + input_nodes=[1], + output_nodes=[6, 2, 7], + measurements={ + 0: Measurement(0.1, Plane.XZ), + 1: Measurement(0.1, Plane.XY), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.1, Plane.XY), + 5: Measurement(0.1, Plane.YZ), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) - og = OpenGraph(g, meas, inputs, outputs) - pattern = og.to_pattern() - og_reconstructed = OpenGraph.from_pattern(pattern) +def _og_14() -> OpenGraphFlowTestCase: + r"""Generate open graph. - assert og.isclose(og_reconstructed) + Structure: + 0-(1) (4)-6 + | | + 2-(3) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (4, 6)]), + input_nodes=[], + output_nodes=[1, 3, 4], + measurements={0: Measurement(0.5, Plane.XZ), 2: Measurement(0, Plane.YZ), 6: Measurement(0.2, Plane.XY)}, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) + + +def _og_15() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [1]--0 + | | + 4---3-(2) + | | | + (7)-(6)-5 + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7), (5, 6), (6, 7)]), + input_nodes=[1], + output_nodes=[6, 2, 7], + measurements={ + 0: Measurement(0.1, Plane.XZ), + 1: Measurement(0.1, Plane.XY), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.1, Plane.XY), + 5: Measurement(0.1, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_16() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-(1) (4)-6 + | | + 2--(3) + """ + og = OpenGraph( + graph=nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (4, 6)]), + input_nodes=[0], + output_nodes=[1, 3, 4], + measurements={0: Measurement(0.1, Plane.XZ), 2: Measurement(0, Plane.YZ), 6: Measurement(0.2, Plane.XY)}, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_17() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 0-[1] [8] + | | + 5-(2)-3--4-(7) + | + (6) + + Notes + ----- + Graph is constructed by adding a disconnected input to OG 13. + """ + graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7)]) + graph.add_node(8) + og = OpenGraph( + graph=graph, + input_nodes=[1, 8], + output_nodes=[6, 2, 7], + measurements={ + 0: Measurement(0.1, Plane.XZ), + 1: Measurement(0.1, Plane.XY), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.1, Plane.XY), + 5: Measurement(0.1, Plane.YZ), + 8: Measurement(0.1, Plane.XY), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_18() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + [0]-2--3--4-7-(8) + | | | + (6)[1](5) + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), + input_nodes=[0, 1], + output_nodes=[5, 6, 8], + measurements={ + 0: Measurement(0, Plane.XY), + 1: Measurement(0, Plane.XY), + 2: Measurement(0, Plane.XZ), + 3: Measurement(0, Plane.XY), + 4: Measurement(0.5, Plane.XY), + 7: Measurement(0, Plane.YZ), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) + + +def _og_19() -> OpenGraphFlowTestCase: + r"""Generate open graph. + + Structure: + + 0-2--3--4-7-(8) + | | | + (6) 1 (5) + + Notes + ----- + Even though any node is measured in the XY plane, OG has Pauli flow because none of them are inputs. + """ + og = OpenGraph( + graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), + input_nodes=[], + output_nodes=[5, 6, 8], + measurements={ + 0: Measurement(0, Plane.XZ), + 1: Measurement(0, Plane.XZ), + 2: Measurement(0, Plane.XZ), + 3: Measurement(0, Plane.XZ), + 4: Measurement(0, Plane.XZ), + 7: Measurement(0, Plane.XZ), + }, + ) + return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) + + +def prepare_test_og_flow() -> list[OpenGraphFlowTestCase]: + n_og_samples = 20 + test_cases: list[OpenGraphFlowTestCase] = [globals()[f"_og_{i}"]() for i in range(n_og_samples)] + + return test_cases + + +def check_determinism(pattern: Pattern, fx_rng: Generator, n_shots: int = 3) -> bool: + """Verify if the input pattern is deterministic.""" + results = [] + + for plane in {Plane.XY, Plane.XZ, Plane.YZ}: + alpha = 2 * np.pi * fx_rng.random() + state_ref = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) + + for _ in range(n_shots): + state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) + results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) + + avg = sum(results) / (n_shots * 3) + + return bool(avg == pytest.approx(1)) + + +class TestOpenGraph: + def test_odd_neighbors(self) -> None: + graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 2), (1, 3), (1, 2), (2, 3), (1, 4)]) + og = OpenGraph(graph=graph, input_nodes=[0], output_nodes=[1, 2, 3, 4], measurements={0: Plane.XY}) + + assert og.odd_neighbors([0]) == {1, 2} + assert og.odd_neighbors([0, 1]) == {0, 1, 3, 4} + assert og.odd_neighbors([1, 2, 3]) == {4} + assert og.odd_neighbors([]) == set() + + def test_neighbors(self) -> None: + graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 2), (1, 3), (1, 2), (2, 3), (1, 4)]) + og = OpenGraph(graph=graph, input_nodes=[0], output_nodes=[1, 2, 3, 4], measurements={0: Plane.XY}) + + assert og.neighbors([4]) == {1} + assert og.neighbors([0, 1]) == {0, 1, 2, 3, 4} + assert og.neighbors([1, 2, 3]) == {0, 1, 2, 3, 4} + assert og.neighbors([]) == set() + + @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + def test_cflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: + og = test_case.og + + cflow = og.find_causal_flow() + + if test_case.has_cflow: + assert cflow is not None + pattern = cflow.to_corrections().to_pattern() + assert check_determinism(pattern, fx_rng) + else: + assert cflow is None + + @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + def test_gflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: + og = test_case.og + + gflow = og.find_gflow() + + if test_case.has_gflow: + assert gflow is not None + pattern = gflow.to_corrections().to_pattern() + assert check_determinism(pattern, fx_rng) + else: + assert gflow is None + + @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: + og = test_case.og + + pflow = og.find_pauli_flow() + + if test_case.has_pflow: + assert pflow is not None + pattern = pflow.to_corrections().to_pattern() + assert check_determinism(pattern, fx_rng) + else: + assert pflow is None + + def test_double_entanglement(self) -> None: + pattern = Pattern(input_nodes=[0, 1], cmds=[E((0, 1)), E((0, 1))]) + pattern2 = OpenGraph.from_pattern(pattern).to_pattern() + state = pattern.simulate_pattern() + assert pattern2 is not None + state2 = pattern2.simulate_pattern() + assert np.abs(np.dot(state.flatten().conjugate(), state2.flatten())) == pytest.approx(1) + + def test_from_to_pattern(self, fx_rng: Generator) -> None: + n_qubits = 2 + depth = 2 + circuit = rand_circuit(n_qubits, depth, fx_rng) + pattern_ref = circuit.transpile().pattern + pattern = OpenGraph.from_pattern(pattern_ref).to_pattern() + assert pattern is not None + + results = [] + + for plane in {Plane.XY, Plane.XZ, Plane.YZ}: + alpha = 2 * np.pi * fx_rng.random() + state_ref = pattern_ref.simulate_pattern(input_state=PlanarState(plane, alpha)) + state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) + results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) + + avg = sum(results) / 3 + assert avg == pytest.approx(1) + +# TODO: Add test `OpenGraph.is_close` +# TODO: rewrite as parametric tests # Tests composition of two graphs @@ -68,7 +646,7 @@ def test_compose_1() -> None: inputs = [1] outputs = [2] meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, meas, inputs, outputs) + og_1 = OpenGraph(g, inputs, outputs, meas) mapping = {1: 100, 2: 200} @@ -76,11 +654,11 @@ def test_compose_1() -> None: expected_graph: nx.Graph[int] expected_graph = nx.Graph([(1, 2), (100, 200)]) - assert nx.is_isomorphic(og.inside, expected_graph) - assert og.inputs == [1, 100] - assert og.outputs == [2, 200] + assert nx.is_isomorphic(og.graph, expected_graph) + assert og.input_nodes == [1, 100] + assert og.output_nodes == [2, 200] - outputs_c = {i for i in og.inside.nodes if i not in og.outputs} + outputs_c = {i for i in og.graph.nodes if i not in og.output_nodes} assert og.measurements.keys() == outputs_c assert mapping.keys() <= mapping_complete.keys() assert set(mapping.values()) <= set(mapping_complete.values()) @@ -110,13 +688,13 @@ def test_compose_2() -> None: inputs = [0, 3] outputs = [13, 23] meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, meas, inputs, outputs) + og_1 = OpenGraph(g, inputs, outputs, meas) g = nx.Graph([(6, 7), (6, 17), (17, 1), (7, 4), (17, 4), (4, 2)]) inputs = [6, 7] outputs = [1, 2] meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_2 = OpenGraph(g, meas, inputs, outputs) + og_2 = OpenGraph(g, inputs, outputs, meas) mapping = {6: 23, 7: 13, 1: 100, 2: 200} @@ -126,11 +704,11 @@ def test_compose_2() -> None: expected_graph = nx.Graph( [(0, 17), (17, 23), (17, 4), (3, 4), (4, 13), (23, 13), (23, 1), (13, 2), (1, 2), (1, 100), (2, 200)] ) - assert nx.is_isomorphic(og.inside, expected_graph) - assert og.inputs == [0, 3] - assert og.outputs == [100, 200] + assert nx.is_isomorphic(og.graph, expected_graph) + assert og.input_nodes == [0, 3] + assert og.output_nodes == [100, 200] - outputs_c = {i for i in og.inside.nodes if i not in og.outputs} + outputs_c = {i for i in og.graph.nodes if i not in og.output_nodes} assert og.measurements.keys() == outputs_c assert mapping.keys() <= mapping_complete.keys() assert set(mapping.values()) <= set(mapping_complete.values()) @@ -154,7 +732,7 @@ def test_compose_3() -> None: inputs = [0, 3] outputs = [13, 23] meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, meas, inputs, outputs) + og_1 = OpenGraph(g, inputs, outputs, meas) mapping = {i: i for i in g.nodes} @@ -187,13 +765,13 @@ def test_compose_4() -> None: inputs = [17, 18] outputs = [3, 17] meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, meas, inputs, outputs) + og_1 = OpenGraph(g, inputs, outputs, meas) g = nx.Graph([(1, 2), (2, 3)]) inputs = [1] outputs = [3] meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_2 = OpenGraph(g, meas, inputs, outputs) + og_2 = OpenGraph(g, inputs, outputs, meas) mapping = {1: 17, 3: 300} @@ -201,11 +779,14 @@ def test_compose_4() -> None: expected_graph: nx.Graph[int] expected_graph = nx.Graph([(18, 17), (17, 3), (17, 2), (2, 300)]) - assert nx.is_isomorphic(og.inside, expected_graph) - assert og.inputs == [17, 18] # the input character of node 17 is kept because node 1 (in G2) is an input - assert og.outputs == [3, 300] # the output character of node 17 is lost because node 1 (in G2) is not an output - - outputs_c = {i for i in og.inside.nodes if i not in og.outputs} + assert nx.is_isomorphic(og.graph, expected_graph) + assert og.input_nodes == [17, 18] # the input character of node 17 is kept because node 1 (in G2) is an input + assert og.output_nodes == [ + 3, + 300, + ] # the output character of node 17 is lost because node 1 (in G2) is not an output + + outputs_c = {i for i in og.graph.nodes if i not in og.output_nodes} assert og.measurements.keys() == outputs_c assert mapping.keys() <= mapping_complete.keys() assert set(mapping.values()) <= set(mapping_complete.values()) @@ -233,13 +814,13 @@ def test_compose_5() -> None: inputs = [1, 3] outputs = [2] meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_1 = OpenGraph(g, meas, inputs, outputs) + og_1 = OpenGraph(g, inputs, outputs, meas) g = nx.Graph([(3, 4)]) inputs = [3] outputs = [4] meas = {i: Measurement(0, Plane.XY) for i in g.nodes - set(outputs)} - og_2 = OpenGraph(g, meas, inputs, outputs) + og_2 = OpenGraph(g, inputs, outputs, meas) mapping = {4: 1, 3: 300} @@ -247,19 +828,11 @@ def test_compose_5() -> None: expected_graph: nx.Graph[int] expected_graph = nx.Graph([(1, 2), (1, 3), (1, 300)]) - assert nx.is_isomorphic(og.inside, expected_graph) - assert og.inputs == [3, 300] - assert og.outputs == [2] + assert nx.is_isomorphic(og.graph, expected_graph) + assert og.input_nodes == [3, 300] + assert og.output_nodes == [2] - outputs_c = {i for i in og.inside.nodes if i not in og.outputs} + outputs_c = {i for i in og.graph.nodes if i not in og.output_nodes} assert og.measurements.keys() == outputs_c assert mapping.keys() <= mapping_complete.keys() assert set(mapping.values()) <= set(mapping_complete.values()) - - -def test_double_entanglement() -> None: - pattern = Pattern(input_nodes=[0, 1], cmds=[command.E((0, 1)), command.E((0, 1))]) - pattern2 = OpenGraph.from_pattern(pattern).to_pattern() - state = pattern.simulate_pattern() - state2 = pattern2.simulate_pattern() - assert np.abs(np.dot(state.flatten().conjugate(), state2.flatten())) == pytest.approx(1) diff --git a/tests/test_opengraph_.py b/tests/test_opengraph_.py deleted file mode 100644 index 3d788cd96..000000000 --- a/tests/test_opengraph_.py +++ /dev/null @@ -1,622 +0,0 @@ -"""Unit tests for the class `:class: graphix.opengraph_.OpenGraph`. - -This module tests the full conversion Open Graph -> Flow -> XZ-corrections -> Pattern for all three classes of flow. -Output correctness is verified by checking if the resulting pattern is deterministic (when the flow exists). -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, NamedTuple - -import networkx as nx -import numpy as np -import pytest - -from graphix.command import E -from graphix.fundamentals import Plane -from graphix.measurements import Measurement -from graphix.opengraph_ import OpenGraph -from graphix.pattern import Pattern -from graphix.random_objects import rand_circuit -from graphix.states import PlanarState - -if TYPE_CHECKING: - from numpy.random import Generator - - -class OpenGraphFlowTestCase(NamedTuple): - og: OpenGraph[Measurement] - has_cflow: bool - has_gflow: bool - has_pflow: bool - - -def _og_0() -> OpenGraphFlowTestCase: - """Generate open graph. - - Structure: - - [(0)]-[(1)] - """ - meas: dict[int, Measurement] = {} - og = OpenGraph( - graph=nx.Graph([(0, 1)]), - input_nodes=[0, 1], - output_nodes=[0, 1], - measurements=meas, - ) - return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) - - -def _og_1() -> OpenGraphFlowTestCase: - """Generate open graph. - - Structure: - - [0]-1-20-30-4-(5) - """ - og = OpenGraph( - graph=nx.Graph([(0, 1), (1, 20), (20, 30), (30, 4), (4, 5)]), - input_nodes=[0], - output_nodes=[5], - measurements={ - 0: Measurement(0.1, Plane.XY), - 1: Measurement(0.2, Plane.XY), - 20: Measurement(0.3, Plane.XY), - 30: Measurement(0.4, Plane.XY), - 4: Measurement(0.5, Plane.XY), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) - - -def _og_2() -> OpenGraphFlowTestCase: - """Generate open graph. - - Structure: - - [1]-3-(5) - | - [2]-4-(6) - """ - og = OpenGraph( - graph=nx.Graph([(1, 3), (2, 4), (3, 4), (3, 5), (4, 6)]), - input_nodes=[1, 2], - output_nodes=[5, 6], - measurements={ - 1: Measurement(0.1, Plane.XY), - 2: Measurement(0.2, Plane.XY), - 3: Measurement(0.3, Plane.XY), - 4: Measurement(0.4, Plane.XY), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) - - -def _og_3() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [1]-(4) - \ / - X____ - / | - [2]-(5) | - \ / | - X | - / \ / - [3]-(6) - """ - og = OpenGraph( - graph=nx.Graph([(1, 4), (1, 6), (2, 4), (2, 5), (2, 6), (3, 5), (3, 6)]), - input_nodes=[1, 2, 3], - output_nodes=[4, 5, 6], - measurements={ - 1: Measurement(0.1, Plane.XY), - 2: Measurement(0.2, Plane.XY), - 3: Measurement(0.3, Plane.XY), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) - - -def _og_4() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0]-[1] - /| | - (4)| | - \| | - 2--(5)-3 - """ - og = OpenGraph( - graph=nx.Graph([(0, 1), (0, 2), (0, 4), (1, 5), (2, 4), (2, 5), (3, 5)]), - input_nodes=[0, 1], - output_nodes=[4, 5], - measurements={ - 0: Measurement(0.1, Plane.XY), - 1: Measurement(0.1, Plane.XY), - 2: Measurement(0.2, Plane.XZ), - 3: Measurement(0.3, Plane.YZ), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) - - -def _og_5() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [1]-(3) - \ / - X - / \ - [2]-(4) - """ - og = OpenGraph( - graph=nx.Graph([(1, 3), (1, 4), (2, 3), (2, 4)]), - input_nodes=[1, 2], - output_nodes=[3, 4], - measurements={ - 1: Measurement(0.1, Plane.XY), - 2: Measurement(0.1, Plane.XY), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) - - -def _og_6() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0] - | - 1-2-3 - | - (4) - """ - og = OpenGraph( - graph=nx.Graph([(0, 1), (1, 2), (1, 4), (2, 3)]), - input_nodes=[0], - output_nodes=[4], - measurements={ - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0, Plane.XY), # X - 2: Measurement(0.1, Plane.XY), # XY - 3: Measurement(0, Plane.XY), # X - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) - - -def _og_7() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0]-1-(2) - """ - og = OpenGraph( - graph=nx.Graph([(0, 1), (1, 2)]), - input_nodes=[0], - output_nodes=[2], - measurements={ - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.5, Plane.YZ), # Y - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) - - -def _og_8() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0]-2-4-(6) - | | - [1]-3-5-(7) - """ - og = OpenGraph( - graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]), - input_nodes=[1, 0], - output_nodes=[6, 7], - measurements={ - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.1, Plane.XZ), # XZ - 2: Measurement(0.5, Plane.XZ), # X - 3: Measurement(0.5, Plane.YZ), # Y - 4: Measurement(0.5, Plane.YZ), # Y - 5: Measurement(0.1, Plane.YZ), # YZ - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) - - -def _og_9() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0]-2-4-(6) - | | - [1]-3-5-(7) - """ - og = OpenGraph( - graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5), (4, 6), (5, 7)]), - input_nodes=[0, 1], - output_nodes=[6, 7], - measurements={ - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.1, Plane.XY), # XY - 2: Measurement(0.0, Plane.XY), # X - 3: Measurement(0.1, Plane.XY), # XY - 4: Measurement(0.0, Plane.XY), # X - 5: Measurement(0.5, Plane.XY), # Y - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) - - -def _og_10() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - 3-(5) - /| \ - [0]-2-4-(6) - | | /| - | 1 | - |____| - - Notes - ----- - Example from Fig. 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - og = OpenGraph( - graph=nx.Graph([(0, 2), (2, 4), (3, 4), (4, 6), (1, 4), (1, 6), (2, 3), (3, 5), (2, 6), (3, 6)]), - input_nodes=[0], - output_nodes=[5, 6], - measurements={ - 0: Measurement(0.1, Plane.XY), # XY - 1: Measurement(0.1, Plane.XZ), # XZ - 2: Measurement(0.5, Plane.YZ), # Y - 3: Measurement(0.1, Plane.XY), # XY - 4: Measurement(0, Plane.XZ), # Z - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) - - -def _og_11() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0]-2--3--4-7-(8) - | | | - (6)[1](5) - """ - og = OpenGraph( - graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), - input_nodes=[0, 1], - output_nodes=[5, 6, 8], - measurements={ - 0: Measurement(0.1, Plane.XY), - 1: Measurement(0.1, Plane.XY), - 2: Measurement(0.0, Plane.XY), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.5, Plane.XY), - 7: Measurement(0, Plane.XY), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) - - -def _og_12() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0]-2-(3)-(4) - | - [(1)] - """ - og = OpenGraph( - graph=nx.Graph([(0, 2), (1, 2), (2, 3), (3, 4)]), - input_nodes=[0, 1], - output_nodes=[1, 3, 4], - measurements={0: Measurement(0.1, Plane.XY), 2: Measurement(0.5, Plane.YZ)}, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) - - -def _og_13() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - 0-[1] - | | - 5-(2)-3--4-(7) - | - (6) - """ - og = OpenGraph( - graph=nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7)]), - input_nodes=[1], - output_nodes=[6, 2, 7], - measurements={ - 0: Measurement(0.1, Plane.XZ), - 1: Measurement(0.1, Plane.XY), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.1, Plane.XY), - 5: Measurement(0.1, Plane.YZ), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) - - -def _og_14() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - 0-(1) (4)-6 - | | - 2-(3) - """ - og = OpenGraph( - graph=nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (4, 6)]), - input_nodes=[], - output_nodes=[1, 3, 4], - measurements={0: Measurement(0.5, Plane.XZ), 2: Measurement(0, Plane.YZ), 6: Measurement(0.2, Plane.XY)}, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) - - -def _og_15() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [1]--0 - | | - 4---3-(2) - | | | - (7)-(6)-5 - """ - og = OpenGraph( - graph=nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7), (5, 6), (6, 7)]), - input_nodes=[1], - output_nodes=[6, 2, 7], - measurements={ - 0: Measurement(0.1, Plane.XZ), - 1: Measurement(0.1, Plane.XY), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.1, Plane.XY), - 5: Measurement(0.1, Plane.XY), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) - - -def _og_16() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0]-(1) (4)-6 - | | - 2--(3) - """ - og = OpenGraph( - graph=nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (4, 6)]), - input_nodes=[0], - output_nodes=[1, 3, 4], - measurements={0: Measurement(0.1, Plane.XZ), 2: Measurement(0, Plane.YZ), 6: Measurement(0.2, Plane.XY)}, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) - - -def _og_17() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - 0-[1] [8] - | | - 5-(2)-3--4-(7) - | - (6) - - Notes - ----- - Graph is constructed by adding a disconnected input to OG 13. - """ - graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 3), (1, 4), (3, 4), (2, 3), (2, 5), (3, 6), (4, 7)]) - graph.add_node(8) - og = OpenGraph( - graph=graph, - input_nodes=[1, 8], - output_nodes=[6, 2, 7], - measurements={ - 0: Measurement(0.1, Plane.XZ), - 1: Measurement(0.1, Plane.XY), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.1, Plane.XY), - 5: Measurement(0.1, Plane.YZ), - 8: Measurement(0.1, Plane.XY), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) - - -def _og_18() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - [0]-2--3--4-7-(8) - | | | - (6)[1](5) - """ - og = OpenGraph( - graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), - input_nodes=[0, 1], - output_nodes=[5, 6, 8], - measurements={ - 0: Measurement(0, Plane.XY), - 1: Measurement(0, Plane.XY), - 2: Measurement(0, Plane.XZ), - 3: Measurement(0, Plane.XY), - 4: Measurement(0.5, Plane.XY), - 7: Measurement(0, Plane.YZ), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) - - -def _og_19() -> OpenGraphFlowTestCase: - r"""Generate open graph. - - Structure: - - 0-2--3--4-7-(8) - | | | - (6) 1 (5) - - Notes - ----- - Even though any node is measured in the XY plane, OG has Pauli flow because none of them are inputs. - """ - og = OpenGraph( - graph=nx.Graph([(0, 2), (1, 3), (2, 3), (2, 6), (3, 4), (4, 7), (4, 5), (7, 8)]), - input_nodes=[], - output_nodes=[5, 6, 8], - measurements={ - 0: Measurement(0, Plane.XZ), - 1: Measurement(0, Plane.XZ), - 2: Measurement(0, Plane.XZ), - 3: Measurement(0, Plane.XZ), - 4: Measurement(0, Plane.XZ), - 7: Measurement(0, Plane.XZ), - }, - ) - return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) - - -def prepare_test_og_flow() -> list[OpenGraphFlowTestCase]: - n_og_samples = 20 - test_cases: list[OpenGraphFlowTestCase] = [globals()[f"_og_{i}"]() for i in range(n_og_samples)] - - return test_cases - - -def check_determinism(pattern: Pattern, fx_rng: Generator, n_shots: int = 3) -> bool: - """Verify if the input pattern is deterministic.""" - results = [] - - for plane in {Plane.XY, Plane.XZ, Plane.YZ}: - alpha = 2 * np.pi * fx_rng.random() - state_ref = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) - - for _ in range(n_shots): - state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) - results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) - - avg = sum(results) / (n_shots * 3) - - return bool(avg == pytest.approx(1)) - - -class TestOpenGraph: - def test_odd_neighbors(self) -> None: - graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 2), (1, 3), (1, 2), (2, 3), (1, 4)]) - og = OpenGraph(graph=graph, input_nodes=[0], output_nodes=[1, 2, 3, 4], measurements={0: Plane.XY}) - - assert og.odd_neighbors([0]) == {1, 2} - assert og.odd_neighbors([0, 1]) == {0, 1, 3, 4} - assert og.odd_neighbors([1, 2, 3]) == {4} - assert og.odd_neighbors([]) == set() - - def test_neighbors(self) -> None: - graph: nx.Graph[int] = nx.Graph([(0, 1), (0, 2), (1, 3), (1, 2), (2, 3), (1, 4)]) - og = OpenGraph(graph=graph, input_nodes=[0], output_nodes=[1, 2, 3, 4], measurements={0: Plane.XY}) - - assert og.neighbors([4]) == {1} - assert og.neighbors([0, 1]) == {0, 1, 2, 3, 4} - assert og.neighbors([1, 2, 3]) == {0, 1, 2, 3, 4} - assert og.neighbors([]) == set() - - @pytest.mark.parametrize("test_case", prepare_test_og_flow()) - def test_cflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: - og = test_case.og - - cflow = og.find_causal_flow() - - if test_case.has_cflow: - assert cflow is not None - pattern = cflow.to_corrections().to_pattern() - assert check_determinism(pattern, fx_rng) - else: - assert cflow is None - - @pytest.mark.parametrize("test_case", prepare_test_og_flow()) - def test_gflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: - og = test_case.og - - gflow = og.find_gflow() - - if test_case.has_gflow: - assert gflow is not None - pattern = gflow.to_corrections().to_pattern() - assert check_determinism(pattern, fx_rng) - else: - assert gflow is None - - @pytest.mark.parametrize("test_case", prepare_test_og_flow()) - def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: - og = test_case.og - - pflow = og.find_pauli_flow() - - if test_case.has_pflow: - assert pflow is not None - pattern = pflow.to_corrections().to_pattern() - assert check_determinism(pattern, fx_rng) - else: - assert pflow is None - - def test_double_entanglement(self) -> None: - pattern = Pattern(input_nodes=[0, 1], cmds=[E((0, 1)), E((0, 1))]) - pattern2 = OpenGraph.from_pattern(pattern).to_pattern() - state = pattern.simulate_pattern() - assert pattern2 is not None - state2 = pattern2.simulate_pattern() - assert np.abs(np.dot(state.flatten().conjugate(), state2.flatten())) == pytest.approx(1) - - def test_from_to_pattern(self, fx_rng: Generator) -> None: - n_qubits = 2 - depth = 2 - circuit = rand_circuit(n_qubits, depth, fx_rng) - pattern_ref = circuit.transpile().pattern - pattern = OpenGraph.from_pattern(pattern_ref).to_pattern() - assert pattern is not None - - results = [] - - for plane in {Plane.XY, Plane.XZ, Plane.YZ}: - alpha = 2 * np.pi * fx_rng.random() - state_ref = pattern_ref.simulate_pattern(input_state=PlanarState(plane, alpha)) - state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) - results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) - - avg = sum(results) / 3 - assert avg == pytest.approx(1) diff --git a/tests/test_pyzx.py b/tests/test_pyzx.py index 53e8644c6..58a383d1b 100644 --- a/tests/test_pyzx.py +++ b/tests/test_pyzx.py @@ -88,6 +88,7 @@ def test_random_circuit(fx_bg: PCG64, jumps: int) -> None: pattern.perform_pauli_measurements() pattern.minimize_space() state = pattern.simulate_pattern() + assert pattern2 is not None pattern2.perform_pauli_measurements() pattern2.minimize_space() state2 = pattern2.simulate_pattern() @@ -103,6 +104,7 @@ def test_rz() -> None: g = circ.to_graph() og = from_pyzx_graph(g) pattern_zx = og.to_pattern() + assert pattern_zx is not None state = pattern.simulate_pattern() state_zx = pattern_zx.simulate_pattern() assert np.abs(np.dot(state_zx.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -124,6 +126,7 @@ def test_full_reduce_toffoli() -> None: assert zx.compare_tensors(t, t2) og2 = from_pyzx_graph(pyg) p2 = og2.to_pattern() + assert p2 is not None s = p.simulate_pattern() s2 = p2.simulate_pattern() print(np.abs(np.dot(s.flatten().conj(), s2.flatten()))) From ddf1b35dfd52748148377ff26846efefb76f8d49 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 3 Nov 2025 16:05:00 +0100 Subject: [PATCH 33/56] Fix circular imports --- graphix/find_pflow.py | 613 ++++++++++++++++++++++++++++++++++++++++ graphix/flow/core.py | 5 +- graphix/gflow.py | 18 +- graphix/measurements.py | 2 +- 4 files changed, 626 insertions(+), 12 deletions(-) create mode 100644 graphix/find_pflow.py diff --git a/graphix/find_pflow.py b/graphix/find_pflow.py new file mode 100644 index 000000000..609c00cd8 --- /dev/null +++ b/graphix/find_pflow.py @@ -0,0 +1,613 @@ +"""Pauli flow finding algorithm. + +This module implements the algorithm presented in [1]. For a given labelled open graph (G, I, O, meas_plane), this algorithm finds a maximally delayed Pauli flow [2] in polynomial time with the number of nodes, :math:`O(N^3)`. +If the input graph does not have Pauli measurements, the algorithm returns a general flow (gflow) if it exists by definition. + +References +---------- +[1] Mitosek and Backens, 2024 (arXiv:2410.23439). +[2] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212) +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import numpy as np + +from graphix._linalg import MatGF2, solve_f2_linear_system +from graphix.fundamentals import Axis, Plane +from graphix.measurements import PauliMeasurement +from graphix.sim.base_backend import NodeIndex + +if TYPE_CHECKING: + from collections.abc import Set as AbstractSet + + from graphix.measurements import Measurement + from graphix.opengraph import OpenGraph + + +class OpenGraphIndex: + """A class for managing the mapping between node numbers of a given open graph and matrix indices in the Pauli flow finding algorithm. + + It reuses the class `:class: graphix.sim.base_backend.NodeIndex` introduced for managing the mapping between node numbers and qubit indices in the internal state of the backend. + + Attributes + ---------- + og (OpenGraph) + non_inputs (NodeIndex) : Mapping between matrix indices and non-input nodes (labelled with integers). + non_outputs (NodeIndex) : Mapping between matrix indices and non-output nodes (labelled with integers). + non_outputs_optim (NodeIndex) : Mapping between matrix indices and a subset of non-output nodes (labelled with integers). + + Notes + ----- + At initialization, `non_outputs_optim` is a copy of `non_outputs`. The nodes corresponding to zero-rows of the order-demand matrix are removed for calculating the P matrix more efficiently in the `:func: _find_pflow_general` routine. + """ + + def __init__(self, og: OpenGraph[Measurement]) -> None: + self.og = og + nodes = set(og.graph.nodes) + + # Nodes don't need to be sorted. We do it for debugging purposes, so we can check the matrices in intermediate steps of the algorithm. + + nodes_non_input = sorted(nodes - set(og.input_nodes)) + nodes_non_output = sorted(nodes - set(og.output_nodes)) + + self.non_inputs = NodeIndex() + self.non_inputs.extend(nodes_non_input) + + self.non_outputs = NodeIndex() + self.non_outputs.extend(nodes_non_output) + + # Needs to be a deep copy because it may be modified during runtime. + self.non_outputs_optim = deepcopy(self.non_outputs) + + +def _compute_reduced_adj(ogi: OpenGraphIndex) -> MatGF2: + r"""Return the reduced adjacency matrix (RAdj) of the input open graph. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph whose RAdj is computed. + + Returns + ------- + adj_red : MatGF2 + Reduced adjacency matrix. + + Notes + ----- + The adjacency matrix of a graph :math:`Adj_G` is an :math:`n \times n` matrix. + + The RAdj matrix of an open graph OG is an :math:`(n - n_O) \times (n - n_I)` submatrix of :math:`Adj_G` constructed by removing the output rows and input columns of :math:`Adj_G`. + + See Definition 3.3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + graph = ogi.og.graph + row_tags = ogi.non_outputs + col_tags = ogi.non_inputs + + adj_red = np.zeros((len(row_tags), len(col_tags)), dtype=np.uint8).view(MatGF2) + + for n1, n2 in graph.edges: + for u, v in ((n1, n2), (n2, n1)): + if u in row_tags and v in col_tags: + i, j = row_tags.index(u), col_tags.index(v) + adj_red[i, j] = 1 + + return adj_red + + +def _compute_pflow_matrices(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2]: + r"""Construct flow-demand and order-demand matrices. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph whose flow-demand and order-demand matrices are computed. + + Returns + ------- + flow_demand_matrix : MatGF2 + order_demand_matrix : MatGF2 + + Notes + ----- + See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + flow_demand_matrix = _compute_reduced_adj(ogi) + order_demand_matrix = flow_demand_matrix.copy() + + inputs_set = set(ogi.og.input_nodes) + meas = ogi.og.measurements + + row_tags = ogi.non_outputs + col_tags = ogi.non_inputs + + # TODO: integrate pauli measurements in open graphs + meas_planes = {i: m.plane for i, m in meas.items()} + meas_angles = {i: m.angle for i, m in meas.items()} + meas_plane_axis = { + node: pm.axis if (pm := PauliMeasurement.try_from(plane, meas_angles[node])) else plane + for node, plane in meas_planes.items() + } + + for v in row_tags: # v is a node tag + i = row_tags.index(v) + plane_axis_v = meas_plane_axis[v] + + if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: + flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 + if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Y, Axis.Z} and v not in inputs_set: + j = col_tags.index(v) + flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 + if plane_axis_v in {Plane.XY, Axis.X, Axis.Y, Axis.Z}: + order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 + if plane_axis_v in {Plane.XY, Plane.XZ} and v not in inputs_set: + j = col_tags.index(v) + order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 + + return flow_demand_matrix, order_demand_matrix + + +def _find_pflow_simple(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: + r"""Construct the correction matrix :math:`C` and the ordering matrix, :math:`NC` for an open graph with equal number of inputs and outputs. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph for which :math:`C` and :math:`NC` are computed. + + Returns + ------- + correction_matrix : MatGF2 + Matrix encoding the correction function. + ordering_matrix : MatGF2 + Matrix encoding the partial ordering between nodes. + + or `None` + if the input open graph does not have Pauli flow. + + Notes + ----- + - The ordering matrix is defined as the product of the order-demand matrix :math:`N` and the correction matrix. + + - The function only returns `None` when the flow-demand matrix is not invertible (meaning that `ogi` does not have Pauli flow). The condition that the ordering matrix :math:`NC` must encode a directed acyclic graph (DAG) is verified in a subsequent step by `:func: _compute_topological_generations`. + + See Definitions 3.4, 3.5 and 3.6, Theorems 3.1 and 4.1, and Algorithm 2 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) + + correction_matrix = flow_demand_matrix.right_inverse() # C matrix + + if correction_matrix is None: + return None # The flow-demand matrix is not invertible, therefore there's no flow. + + ordering_matrix = order_demand_matrix.mat_mul(correction_matrix) # NC matrix + + return correction_matrix, ordering_matrix + + +def _compute_p_matrix(ogi: OpenGraphIndex, nb_matrix: MatGF2) -> MatGF2 | None: + r"""Perform the steps 8 - 12 of the general case (larger number of outputs than inputs) algorithm. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph for which the matrix :math:`P` is computed. + nb_matrix : MatGF2 + Matrix :math:`N_B` + + Returns + ------- + p_matrix : MatGF2 + Matrix encoding the correction function. + + or `None` + if the input open graph does not have Pauli flow. + + Notes + ----- + See Theorem 4.4, steps 8 - 12 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + n_no = len(ogi.non_outputs) # number of columns of P matrix. + n_oi_diff = len(ogi.og.output_nodes) - len(ogi.og.input_nodes) # number of rows of P matrix. + n_no_optim = len(ogi.non_outputs_optim) # number of rows and columns of the third block of the K_{LS} matrix. + + # Steps 8, 9 and 10 + kils_matrix = np.concatenate( + (nb_matrix[:, n_no:], nb_matrix[:, :n_no], np.eye(n_no_optim, dtype=np.uint8)), axis=1 + ).view(MatGF2) # N_R | N_L | 1 matrix. + kls_matrix = kils_matrix.gauss_elimination(ncols=n_oi_diff, copy=True) # RREF form is not needed, only REF. + + # Step 11 + p_matrix = np.zeros((n_oi_diff, n_no), dtype=np.uint8).view(MatGF2) + solved_nodes: set[int] = set() + non_outputs_set = set(ogi.non_outputs) + + # Step 12 + while solved_nodes != non_outputs_set: + solvable_nodes = _find_solvable_nodes(ogi, kls_matrix, non_outputs_set, solved_nodes, n_oi_diff) # Step 12.a + if not solvable_nodes: + return None + + _update_p_matrix(ogi, kls_matrix, p_matrix, solvable_nodes, n_oi_diff) # Steps 12.b, 12.c + _update_kls_matrix(ogi, kls_matrix, kils_matrix, solvable_nodes, n_oi_diff, n_no, n_no_optim) # Step 12.d + solved_nodes.update(solvable_nodes) + + return p_matrix + + +def _find_solvable_nodes( + ogi: OpenGraphIndex, + kls_matrix: MatGF2, + non_outputs_set: AbstractSet[int], + solved_nodes: AbstractSet[int], + n_oi_diff: int, +) -> set[int]: + """Return the set nodes whose associated linear system is solvable. + + A node is solvable if: + - It has not been solved yet. + - Its column in the second block of :math:`K_{LS}` (which determines the constants in each equation) has only zeros where it intersects rows for which all the coefficients in the first block are 0s. + + See Theorem 4.4, step 12.a in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + solvable_nodes: set[int] = set() + + row_idxs = np.flatnonzero( + ~kls_matrix[:, :n_oi_diff].any(axis=1) + ) # Row indices of the 0-rows in the first block of K_{LS}. + if row_idxs.size: + for v in non_outputs_set - solved_nodes: + j = n_oi_diff + ogi.non_outputs.index(v) # `n_oi_diff` is the column offset from the first block of K_{LS}. + if not kls_matrix[row_idxs, j].any(): + solvable_nodes.add(v) + else: + # If the first block of K_{LS} does not have 0-rows, all non-solved nodes are solvable. + solvable_nodes = set(non_outputs_set - solved_nodes) + + return solvable_nodes + + +def _update_p_matrix( + ogi: OpenGraphIndex, kls_matrix: MatGF2, p_matrix: MatGF2, solvable_nodes: AbstractSet[int], n_oi_diff: int +) -> None: + """Update `p_matrix`. + + The solution of the linear system associated with node :math:`v` in `solvable_nodes` corresponds to the column of `p_matrix` associated with node :math:`v`. + + See Theorem 4.4, steps 12.b and 12.c in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + for v in solvable_nodes: + j = ogi.non_outputs.index(v) + j_shift = n_oi_diff + j # `n_oi_diff` is the column offset from the first block of K_{LS}. + mat = MatGF2(kls_matrix[:, :n_oi_diff]) # First block of K_{LS}, in row echelon form. + b = MatGF2(kls_matrix[:, j_shift]) + x = solve_f2_linear_system(mat, b) + p_matrix[:, j] = x + + +def _update_kls_matrix( + ogi: OpenGraphIndex, + kls_matrix: MatGF2, + kils_matrix: MatGF2, + solvable_nodes: AbstractSet[int], + n_oi_diff: int, + n_no: int, + n_no_optim: int, +) -> None: + """Update `kls_matrix`. + + Bring the linear system encoded in :math:`K_{LS}` to the row-echelon form (REF) that would be achieved by Gaussian elimination if the row and column vectors corresponding to vertices in `solvable_nodes` where not included in the starting matrix. + + See Theorem 4.4, step 12.d in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + shift = n_oi_diff + n_no # `n_oi_diff` + `n_no` is the column offset from the first two blocks of K_{LS}. + row_permutation: list[int] + + def reorder(old_pos: int, new_pos: int) -> None: # Used in step 12.d.vi + """Reorder the elements of `row_permutation`. + + The element at `old_pos` is placed on the right of the element at `new_pos`. + Example: + ``` + row_permutation = [0, 1, 2, 3, 4] + reorder(1, 3) -> [0, 2, 3, 1, 4] + reorder(2, -1) -> [2, 0, 1, 3, 4] + ``` + """ + val = row_permutation.pop(old_pos) + row_permutation.insert(new_pos + (new_pos < old_pos), val) + + for v in solvable_nodes: + if ( + v in ogi.non_outputs_optim + ): # if `v` corresponded to a zero row in N_B, it was not present in `kls_matrix` because we removed it in the optimization process, so there's no need to do Gaussian elimination for that vertex. + # Step 12.d.ii + j = ogi.non_outputs_optim.index(v) + j_shift = shift + j + row_idxs = np.flatnonzero( + kls_matrix[:, j_shift] + ).tolist() # Row indices with 1s in column of node `v` in third block. + + # `row_idxs` can't be empty: + # The third block of K_{LS} is initially the identity matrix, so all columns have initially a 1. Row permutations and row additions in the Gaussian elimination routine can't remove all 1s from a given column. + k = row_idxs.pop() + + # Step 12.d.iii + kls_matrix[row_idxs] ^= kls_matrix[k] # Adding a row to previous rows preserves REF. + + # Step 12.d.iv + kls_matrix[k] ^= kils_matrix[j] # Row `k` may now break REF. + + # Step 12.d.v + pivots: list[np.int_] = [] # Store pivots for next step. + for i, row in enumerate(kls_matrix): + if i != k: + col_idxs = np.flatnonzero(row[:n_oi_diff]) # Column indices with 1s in first block. + if col_idxs.size == 0: + # Row `i` has all zeros in the first block. Only row `k` can break REF, so rows below have all zeros in the first block too. + break + pivots.append(p := col_idxs[0]) + if kls_matrix[k, p]: # Row `k` has a 1 in the column corresponding to the leading 1 of row `i`. + kls_matrix[k] ^= row + + row_permutation = list(range(n_no_optim)) # Row indices of `kls_matrix`. + n_pivots = len(pivots) + + col_idxs = np.flatnonzero(kls_matrix[k, :n_oi_diff]) + pk = col_idxs[0] if col_idxs.size else None # Pivot of row `k`. + + if pk and k >= n_pivots: # Row `k` is non-zero in the FB (first block) and it's among zero rows. + # Find row `new_pos` s.t. `pivots[new_pos] <= pk < pivots[new_pos+1]`. + new_pos = ( + int(np.argmax(np.array(pivots) > pk) - 1) if pivots else -1 + ) # `pivots` can be empty. If so, we bring row `k` to the top since it's non-zero. + elif pk: # Row `k` is non-zero in the FB and it's among non-zero rows. + # Find row `new_pos` s.t. `pivots[new_pos] <= pk < pivots[new_pos+1]` + new_pos = int(np.argmax(np.array(pivots) > pk) - 1) + # We skipped row `k` in loop of step 12.d.v, so `pivots[j]` can be the pivot of row `j` or `j+1`. + if new_pos >= k: + new_pos += 1 + elif k < n_pivots: # Row `k` is zero in the first block and it's among non-zero rows. + new_pos = ( + n_pivots # Move row `k` to the top of the zeros block (i.e., below the row of the last pivot). + ) + else: # Row `k` is zero in the first block and it's among zero rows. + new_pos = k # Do nothing. + + if new_pos != k: + reorder(k, new_pos) # Modify `row_permutation` in-place. + kls_matrix[:] = kls_matrix[ + row_permutation + ] # `[:]` is crucial to modify the data pointed by `kls_matrix`. + + +def _find_pflow_general(ogi: OpenGraphIndex) -> tuple[MatGF2, MatGF2] | None: + r"""Construct the generalized correction matrix :math:`C'C^B` and the generalized ordering matrix, :math:`NC'C^B` for an open graph with larger number of outputs than inputs. + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph for which :math:`C'C^B` and :math:`NC'C^B` are computed. + + Returns + ------- + correction_matrix : MatGF2 + Matrix encoding the correction function. + ordering_matrix : MatGF2 + Matrix encoding the partial ordering between nodes. + + or `None` + if the input open graph does not have Pauli flow. + + Notes + ----- + - The function returns `None` if + a) The flow-demand matrix is not invertible, or + b) Not all linear systems of equations associated to the non-output nodes are solvable, + meaning that `ogi` does not have Pauli flow. + Condition (b) is satisfied when the flow-demand matrix :math:`M` does not have a right inverse :math:`C` such that :math:`NC` represents a directed acyclical graph (DAG). + + See Theorem 4.4 and Algorithm 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + n_no = len(ogi.non_outputs) + n_oi_diff = len(ogi.og.output_nodes) - len(ogi.og.input_nodes) + + # Steps 1 and 2 + flow_demand_matrix, order_demand_matrix = _compute_pflow_matrices(ogi) + + # Steps 3 and 4 + correction_matrix_0 = flow_demand_matrix.right_inverse() # C0 matrix. + if correction_matrix_0 is None: + return None # The flow-demand matrix is not invertible, therefore there's no flow. + + # Steps 5, 6 and 7 + ker_flow_demand_matrix = flow_demand_matrix.null_space().transpose() # F matrix. + c_prime_matrix = np.concatenate((correction_matrix_0, ker_flow_demand_matrix), axis=1).view(MatGF2) + + row_idxs = np.flatnonzero(order_demand_matrix.any(axis=1)) # Row indices of the non-zero rows. + + if row_idxs.size: + # The p-matrix finding algorithm runs on the `order_demand_matrix` without the zero rows. + # This optimization is allowed because: + # - The zero rows remain zero after the change of basis (multiplication by `c_prime_matrix`). + # - The zero rows remain zero after gaussian elimination. + # - Removing the zero rows does not change the solvability condition of the open graph nodes. + nb_matrix_optim = ( + order_demand_matrix[row_idxs].view(MatGF2).mat_mul(c_prime_matrix) + ) # `view` is used to keep mypy happy without copying data. + for i in set(range(order_demand_matrix.shape[0])).difference(row_idxs): + ogi.non_outputs_optim.remove(ogi.non_outputs[i]) # Update the node-index mapping. + + # Steps 8 - 12 + if (p_matrix := _compute_p_matrix(ogi, nb_matrix_optim)) is None: + return None + else: + # If all rows of `order_demand_matrix` are zero, any matrix will solve the associated linear system of equations. + p_matrix = np.zeros((n_oi_diff, n_no), dtype=np.uint8).view(MatGF2) + + # Step 13 + cb_matrix = np.concatenate((np.eye(n_no, dtype=np.uint8), p_matrix), axis=0).view(MatGF2) + + correction_matrix = c_prime_matrix.mat_mul(cb_matrix) + ordering_matrix = order_demand_matrix.mat_mul(correction_matrix) + + return correction_matrix, ordering_matrix + + +def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] | None: + """Stratify the directed acyclic graph (DAG) represented by the ordering matrix into generations. + + Parameters + ---------- + ordering_matrix : MatGF2 + Matrix encoding the partial ordering between nodes interpreted as the adjacency matrix of a directed graph. + + Returns + ------- + list[list[int]] + Topological generations. Integers represent the indices of the matrix `ordering_matrix`, not the labelling of the nodes. + + or `None` + if `ordering_matrix` is not a DAG. + + Notes + ----- + This function is adapted from `:func: networkx.algorithms.dag.topological_generations` so that it works directly on the adjacency matrix (which is the output of the Pauli-flow finding algorithm) instead of a `:class: nx.DiGraph` object. This avoids calling the function `nx.from_numpy_array` which can be expensive for certain graph instances. + + Here we use the convention that the element `ordering_matrix[i,j]` represents a link `j -> i`. NetworkX uses the opposite convention. + """ + adj_mat = ordering_matrix + + indegree_map: dict[int, int] = {} + zero_indegree: list[int] = [] + neighbors = {node: set(np.flatnonzero(row).astype(int)) for node, row in enumerate(adj_mat.T)} + for node, col in enumerate(adj_mat): + parents = np.flatnonzero(col) + if parents.size: + indegree_map[node] = parents.size + else: + zero_indegree.append(node) + + generations: list[list[int]] = [] + + while zero_indegree: + this_generation = zero_indegree + zero_indegree = [] + for node in this_generation: + for child in neighbors[node]: + indegree_map[child] -= 1 + if indegree_map[child] == 0: + zero_indegree.append(child) + del indegree_map[child] + generations.append(this_generation) + + if indegree_map: + return None + return generations + + +def _cnc_matrices2pflow( + ogi: OpenGraphIndex, + correction_matrix: MatGF2, + ordering_matrix: MatGF2, +) -> tuple[dict[int, set[int]], dict[int, int]] | None: + r"""Transform the correction and ordering matrices into a Pauli flow in its standard form (correction function and partial order). + + Parameters + ---------- + ogi : OpenGraphIndex + Open graph whose Pauli flow is calculated. + correction_matrix : MatGF2 + Matrix encoding the correction function. + ordering_matrix : MatGF2 + Matrix encoding the partial ordering between nodes (DAG). + + Returns + ------- + pf : dict[int, set[int]] + Pauli flow correction function. pf[i] is the set of qubits to be corrected for the measurement of qubit i. + l_k : dict[int, int] + Partial order between corrected qubits, such that the pair (`key`, `value`) corresponds to (node, depth). + + or `None` + if the ordering matrix is not a DAG, in which case the input open graph does not have Pauli flow. + + Notes + ----- + - The correction matrix :math:`C` is an :math:`(n - n_I) \times (n - n_O)` matrix related to the correction function :math:`c(v) = \{u \in I^c|C_{u,v} = 1\}`, where :math:`I^c` are the non-input nodes of `ogi`. In other words, the column :math:`v` of :math:`C` encodes the correction set of :math:`v`, :math:`c(v)`. + + - The Pauli flow's ordering :math:`<_c` is the transitive closure of :math:`\lhd_c`, where the latter is related to the ordering matrix :math:`NC` as :math:`v \lhd_c w \Leftrightarrow (NC)_{w,v} = 1`, for :math:`v, w, \in O^c` two non-output nodes of `ogi`. + + See Definition 3.6, Lemma 3.12, and Theorem 3.1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + row_tags = ogi.non_inputs + col_tags = ogi.non_outputs + + # Calculation of the partial ordering + + if (topo_gen := _compute_topological_generations(ordering_matrix)) is None: + return None # The NC matrix is not a DAG, therefore there's no flow. + + l_k = dict.fromkeys(ogi.og.output_nodes, 0) # Output nodes are always in layer 0. + + # If m >_c n, with >_c the flow order for two nodes m, n, then layer(n) > layer(m). + # Therefore, we iterate the topological sort of the graph in _reverse_ order to obtain the order of measurements. + for layer, idx in enumerate(reversed(topo_gen), start=1): + l_k.update({col_tags[i]: layer for i in idx}) + + # Calculation of the correction function + + pf: dict[int, set[int]] = {} + for node in col_tags: + i = col_tags.index(node) + correction_set = {row_tags[j] for j in np.flatnonzero(correction_matrix[:, i])} + pf[node] = correction_set + + return pf, l_k + + +def find_pflow(og: OpenGraph[Measurement]) -> tuple[dict[int, set[int]], dict[int, int]] | None: + """Return a Pauli flow of the input open graph if it exists. + + Parameters + ---------- + og : OpenGraph + Open graph whose Pauli flow is calculated. + + Returns + ------- + pf : dict[int, set[int]] + Pauli flow correction function. `pf[i]` is the set of qubits to be corrected for the measurement of qubit `i`. + l_k : dict[int, int] + Partial order between corrected qubits, such that the pair (`key`, `value`) corresponds to (node, depth). + + or `None` + if the input open graph does not have Pauli flow. + + Notes + ----- + See Theorems 3.1, 4.2 and 4.4, and Algorithms 2 and 3 in Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + ni = len(og.input_nodes) + no = len(og.output_nodes) + + if ni > no: + return None + + ogi = OpenGraphIndex(og) + + cnc_matrices = _find_pflow_simple(ogi) if ni == no else _find_pflow_general(ogi) + if cnc_matrices is None: + return None + pflow = _cnc_matrices2pflow(ogi, *cnc_matrices) + if pflow is None: + return None + + pf, l_k = pflow + + return pf, l_k diff --git a/graphix/flow/core.py b/graphix/flow/core.py index c58e54b43..773641d80 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -9,9 +9,9 @@ import networkx as nx +import graphix.pattern from graphix.command import E, M, N, X, Z from graphix.flow._find_gpflow import CorrectionMatrix, _M_co, _PM_co, compute_partial_order_layers -from graphix.pattern import Pattern if TYPE_CHECKING: from collections.abc import Mapping @@ -20,6 +20,7 @@ from graphix.measurements import Measurement from graphix.opengraph import OpenGraph + from graphix.pattern import Pattern TotalOrder = Sequence[int] @@ -143,7 +144,7 @@ def to_pattern( "The input total measurement order is not compatible with the partial order induced by the XZ-corrections." ) - pattern = Pattern(input_nodes=self.og.input_nodes) + pattern = graphix.pattern.Pattern(input_nodes=self.og.input_nodes) non_input_nodes = set(self.og.graph.nodes) - set(self.og.input_nodes) for i in non_input_nodes: diff --git a/graphix/gflow.py b/graphix/gflow.py index 5faae477c..b251045d7 100644 --- a/graphix/gflow.py +++ b/graphix/gflow.py @@ -16,9 +16,9 @@ from typing_extensions import assert_never +import graphix.find_pflow import graphix.opengraph from graphix.command import CommandKind -from graphix.find_pflow import find_pflow as _find_pflow from graphix.fundamentals import Axis, Plane from graphix.measurements import Measurement, PauliMeasurement from graphix.parameter import Placeholder @@ -86,12 +86,12 @@ def find_gflow( """ meas = {node: Measurement(Placeholder("Angle"), plane) for node, plane in meas_planes.items()} og = graphix.opengraph.OpenGraph( - inside=graph, - inputs=list(iset), - outputs=list(oset), + graph=graph, + input_nodes=list(iset), + output_nodes=list(oset), measurements=meas, ) - gf = _find_pflow(og) + gf = graphix.find_pflow.find_pflow(og) if gf is None: return None, None # This is to comply with old API. It will be change in the future to `None`` return gf[0], gf[1] @@ -271,12 +271,12 @@ def find_pauliflow( """ meas = {node: Measurement(angle, meas_planes[node]) for node, angle in meas_angles.items()} og = graphix.opengraph.OpenGraph( - inside=graph, - inputs=list(iset), - outputs=list(oset), + graph=graph, + input_nodes=list(iset), + output_nodes=list(oset), measurements=meas, ) - pf = _find_pflow(og) + pf = graphix.find_pflow.find_pflow(og) if pf is None: return None, None # This is to comply with old API. It will be change in the future to `None`` return pf[0], pf[1] diff --git a/graphix/measurements.py b/graphix/measurements.py index a3af77609..32bf344aa 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -55,7 +55,7 @@ def isclose(self, other: Measurement, rel_tol: float = 1e-09, abs_tol: float = 0 Example ------- - >>> from graphix.opengraph import Measurement + >>> from graphix.measurements import Measurement >>> from graphix.fundamentals import Plane >>> Measurement(0.0, Plane.XY).isclose(Measurement(0.0, Plane.XY)) True From 98757b22719858bb8c3e6bbe8ebb4874c5d23c50 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 3 Nov 2025 16:21:41 +0100 Subject: [PATCH 34/56] Fix compatibility with Python <= 3.11 --- graphix/flow/core.py | 5 ++++- graphix/fundamentals.py | 5 ++++- tests/test_opengraph.py | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 773641d80..6a4452467 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -5,10 +5,13 @@ from collections.abc import Sequence from dataclasses import dataclass from itertools import product -from typing import TYPE_CHECKING, Generic, override +from typing import TYPE_CHECKING, Generic import networkx as nx +# override introduced in Python 3.12 +from typing_extensions import override + import graphix.pattern from graphix.command import E, M, N, X, Z from graphix.flow._find_gpflow import CorrectionMatrix, _M_co, _PM_co, compute_partial_order_layers diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index 3dce5449d..da94cb33a 100644 --- a/graphix/fundamentals.py +++ b/graphix/fundamentals.py @@ -7,10 +7,13 @@ import typing from abc import ABC, ABCMeta, abstractmethod from enum import Enum, EnumMeta -from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat, SupportsIndex, overload, override +from typing import TYPE_CHECKING, SupportsComplex, SupportsFloat, SupportsIndex, overload import typing_extensions +# override introduced in Python 3.12 +from typing_extensions import override + from graphix.ops import Ops from graphix.parameter import cos_sin from graphix.repr_mixins import EnumReprMixin diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index ba5230b17..9f47aa562 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -621,6 +621,7 @@ def test_from_to_pattern(self, fx_rng: Generator) -> None: avg = sum(results) / 3 assert avg == pytest.approx(1) + # TODO: Add test `OpenGraph.is_close` # TODO: rewrite as parametric tests From d85cb3b4919288a8f4931613a27bbdc5af006948 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 3 Nov 2025 16:36:06 +0100 Subject: [PATCH 35/56] Replace NamedTuple by dataclass in CorrectionMatrix --- graphix/flow/_find_gpflow.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index f84f9567c..9819f8eeb 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -12,8 +12,9 @@ from __future__ import annotations from copy import deepcopy +from dataclasses import dataclass from functools import cached_property -from typing import TYPE_CHECKING, Generic, NamedTuple, TypeVar +from typing import TYPE_CHECKING, Generic, TypeVar import numpy as np @@ -224,7 +225,8 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: return flow_demand_matrix, order_demand_matrix -class CorrectionMatrix(NamedTuple, Generic[_M_co]): +@dataclass(frozen=True) # `NamedTuple` does not support multiple inheritance in Python 3.9 and 3.10 +class CorrectionMatrix(Generic[_M_co]): r"""A dataclass to bundle the correction matrix and its associated open graph. Attributes @@ -605,7 +607,7 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> See Lemma 3.12, and Theorem 3.1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - aog, c_matrix = correction_matrix + aog, c_matrix = correction_matrix.aog, correction_matrix.c_matrix ordering_matrix = aog.order_demand_matrix.mat_mul(c_matrix) if (topo_gen := _compute_topological_generations(ordering_matrix)) is None: From 8aae0fe628fdbd411520d29d589d6f3a557bcf2e Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 4 Nov 2025 16:08:48 +0100 Subject: [PATCH 36/56] Remove from docs --- docs/source/generator.rst | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/source/generator.rst b/docs/source/generator.rst index 083e4d8ec..1a9915952 100644 --- a/docs/source/generator.rst +++ b/docs/source/generator.rst @@ -41,12 +41,3 @@ Pattern Generation .. autoclass:: TranspileResult .. autoclass:: SimulateResult - -:mod:`graphix.generator` module -+++++++++++++++++++++++++++++++ - -.. automodule:: graphix.generator - -.. currentmodule:: graphix.generator - -.. autofunction:: graphix.generator.generate_from_graph From dd2de794b5a97a2bac114cc1055b019f8f747472 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 4 Nov 2025 16:24:38 +0100 Subject: [PATCH 37/56] Fix bugs doc --- graphix/opengraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 663f5a771..5a20e3b33 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -36,7 +36,7 @@ class OpenGraph(Generic[_M_co]): output_nodes : Sequence[int] An ordered sequence of node labels corresponding to the open graph outputs. measurements : Mapping[int, _M_co] - A mapping between the non-output nodes of the open graph (`key`) and their corresponding measurement label (`value`). Measurement labels can be specified as `Measurement` or `Plane|Axis` instances. + A mapping between the non-output nodes of the open graph (``key``) and their corresponding measurement label (``value``). Measurement labels can be specified as `Measurement`, `Plane` or `Axis` instances. Notes ----- From 264a0af8164be90008c9b4919ca76f517b4b38ce Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 5 Nov 2025 14:50:21 +0100 Subject: [PATCH 38/56] Replace set by frozenset in flow attributes --- graphix/flow/_find_cflow.py | 21 ++++++++++----------- graphix/flow/_find_gpflow.py | 20 ++++++++++---------- graphix/flow/core.py | 13 +++++++------ tests/test_flow_core.py | 4 ++-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index cffb88c95..ee994b528 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -10,7 +10,6 @@ from __future__ import annotations -from copy import copy from typing import TYPE_CHECKING from graphix.flow.core import CausalFlow @@ -52,10 +51,10 @@ def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: corrector_candidates = corrected_nodes - set(og.input_nodes) non_input_nodes = og.graph.nodes - set(og.input_nodes) - cf: dict[int, set[int]] = {} + cf: dict[int, frozenset[int]] = {} # Output nodes are always in layer 0. If the open graph has flow, it must have outputs, so we never end up with an empty set at `layers[0]`. - layers: list[set[int]] = [ - copy(corrected_nodes) + layers: list[frozenset[int]] = [ + frozenset(corrected_nodes) ] # A copy is necessary because `corrected_nodes` is mutable and changes during the algorithm. return _flow_aux(og, non_input_nodes, corrected_nodes, corrector_candidates, cf, layers) @@ -66,8 +65,8 @@ def _flow_aux( non_input_nodes: AbstractSet[int], corrected_nodes: AbstractSet[int], corrector_candidates: AbstractSet[int], - cf: dict[int, set[int]], - layers: list[set[int]], + cf: dict[int, frozenset[int]], + layers: list[frozenset[int]], ) -> CausalFlow[_PM_co] | None: """Find one layer of the causal flow. @@ -81,9 +80,9 @@ def _flow_aux( Nodes which have already been corrected. corrector_candidates : AbstractSet[int] Nodes which could correct a node at the time of calling the function. This set can never contain input nodes, uncorrected nodes or nodes which already correct another node. - cf : dict[int, set[int]] + cf : dict[int, frozenset[int]] Causal flow correction function. `cf[i]` is the one-qubit set correcting the measurement of qubit `i`. - layers : list[set[int]] + layers : list[frozenset[int]] Partial order between corrected qubits in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. @@ -99,18 +98,18 @@ def _flow_aux( non_corrected_nodes = og.graph.nodes - corrected_nodes if corrected_nodes == set(og.graph.nodes): - return CausalFlow(og, cf, layers) + return CausalFlow(og, cf, tuple(layers)) for p in corrector_candidates: non_corrected_neighbors = og.neighbors({p}) & non_corrected_nodes if len(non_corrected_neighbors) == 1: (q,) = non_corrected_neighbors - cf[q] = {p} + cf[q] = frozenset({p}) curr_layer.add(q) corrected_nodes_new |= {q} corrector_nodes_new |= {p} - layers.append(curr_layer) + layers.append(frozenset(curr_layer)) if len(corrected_nodes_new) == 0: return None diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index 9819f8eeb..063e7c375 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -244,21 +244,21 @@ class CorrectionMatrix(Generic[_M_co]): aog: AlgebraicOpenGraph[_M_co] c_matrix: MatGF2 - def to_correction_function(self) -> dict[int, set[int]]: + def to_correction_function(self) -> dict[int, frozenset[int]]: r"""Transform the correction matrix into a correction function. Returns ------- - correction_function : dict[int, set[int]] + correction_function : dict[int, frozenset[int]] Pauli (or generalised) flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. """ row_tags = self.aog.non_inputs col_tags = self.aog.non_outputs - correction_function: dict[int, set[int]] = {} + correction_function: dict[int, frozenset[int]] = {} for node in col_tags: i = col_tags.index(node) correction_set = {row_tags[j] for j in np.flatnonzero(self.c_matrix[:, i])} - correction_function[node] = correction_set + correction_function[node] = frozenset(correction_set) return correction_function @@ -585,7 +585,7 @@ def _compute_topological_generations(ordering_matrix: MatGF2) -> list[list[int]] return generations -def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> list[set[int]] | None: +def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> tuple[frozenset[int], ...] | None: r"""Compute the partial order compatible with the correction matrix if it exists. Parameters @@ -595,8 +595,8 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> Returns ------- - layers : list[set[int]] - Partial order between corrected qubits in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + layers : tuple[frozenset[int], ...] + Partial order between corrected qubits in a layer form. The frozenset `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. or `None` If the correction matrix is not compatible with a partial order on the the open graph, in which case the associated ordering matrix is not a DAG. In the context of the flow-finding algorithm, this means that the input open graph does not have Pauli (or generalised) flow. @@ -614,15 +614,15 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> return None # The NC matrix is not a DAG, therefore there's no flow. layers = [ - set(aog.og.output_nodes) + frozenset(aog.og.output_nodes) ] # Output nodes are always in layer 0. If the open graph has flow, it must have outputs, so we never end up with an empty set at `layers[0]`. # If m >_c n, with >_c the flow partial order for two nodes m, n, then layer(n) > layer(m). # Therefore, we iterate the topological sort of the graph in _reverse_ order to obtain the order of measurements. col_tags = aog.non_outputs - layers.extend({col_tags[i] for i in idx_layer} for idx_layer in reversed(topo_gen)) + layers.extend(frozenset({col_tags[i] for i in idx_layer}) for idx_layer in reversed(topo_gen)) - return layers + return tuple(layers) def compute_correction_matrix(aog: AlgebraicOpenGraph[_M_co]) -> CorrectionMatrix[_M_co] | None: diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 6a4452467..f9fc79955 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence +from copy import copy from dataclasses import dataclass from itertools import product from typing import TYPE_CHECKING, Generic @@ -83,7 +84,7 @@ def from_measured_nodes_mapping( z_corrections = z_corrections or {} nodes_set = set(og.graph.nodes) - outputs_set = set(og.output_nodes) + outputs_set = frozenset(og.output_nodes) non_outputs_set = nodes_set - outputs_set if not set(x_corrections).issubset(non_outputs_set): @@ -109,10 +110,10 @@ def from_measured_nodes_mapping( raise ValueError("Values of input mapping contain labels which are not nodes of the input open graph.") # We append to the last layer (first measured nodes) all the non-output nodes not involved in the corrections. - if unordered_nodes := nodes_set - ordered_nodes: + if unordered_nodes := frozenset(nodes_set - ordered_nodes): partial_order_layers.append(unordered_nodes) - return XZCorrections(og, x_corrections, z_corrections, partial_order_layers) + return XZCorrections(og, x_corrections, z_corrections, tuple(partial_order_layers)) def to_pattern( self: XZCorrections[Measurement], @@ -310,7 +311,7 @@ def to_corrections(self) -> XZCorrections[_M_co]: ---------- [1] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212). """ - future = self.partial_order_layers[0] + future = copy(self.partial_order_layers[0]) # Sets are mutable x_corrections: dict[int, AbstractSet[int]] = {} # {domain: nodes} z_corrections: dict[int, AbstractSet[int]] = {} # {domain: nodes} @@ -454,7 +455,7 @@ def _corrections_to_dag( return nx.DiGraph(relations) -def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[set[int]] | None: +def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[frozenset[int]] | None: """Return the partial order encoded in a directed graph in a layer form if it exists. Parameters @@ -473,4 +474,4 @@ def _dag_to_partial_order_layers(dag: nx.DiGraph[int]) -> list[set[int]] | None: except nx.NetworkXUnfeasible: return None - return [set(layer) for layer in topo_gen] + return [frozenset(layer) for layer in topo_gen] diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 3d36d327c..3ed7d86ed 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -444,7 +444,7 @@ def test_order_2(self) -> None: corrections = XZCorrections.from_measured_nodes_mapping(og=og, x_corrections={1: {0}}) - assert corrections.partial_order_layers == [{2, 3}, {0}, {1}] + assert corrections.partial_order_layers == (frozenset({2, 3}), frozenset({0}), frozenset({1})) assert corrections.is_compatible([1, 0]) assert not corrections.is_compatible([0, 1]) # Wrong order assert not corrections.is_compatible([0]) # Incomplete order @@ -466,7 +466,7 @@ def test_order_3(self) -> None: og=og, x_corrections={0: {1, 2}}, z_corrections={0: {1}} ) - assert corrections.partial_order_layers == [{1, 2}, {0}] + assert corrections.partial_order_layers == (frozenset({1, 2}), frozenset({0})) assert corrections.is_compatible([0, 1, 2]) assert not corrections.is_compatible([2, 0, 1]) # Wrong order assert not corrections.is_compatible([0, 1]) # Incomplete order From dab0ee80ad01d741d2e5217ac0dbd0a95e6ffbc9 Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 12 Nov 2025 13:36:57 +0100 Subject: [PATCH 39/56] Apply suggestions from Thierry's review Co-authored-by: thierry-martinez --- graphix/flow/_find_cflow.py | 6 +++--- graphix/flow/core.py | 15 ++++++++------- graphix/opengraph.py | 2 +- graphix/pyzx.py | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/graphix/flow/_find_cflow.py b/graphix/flow/_find_cflow.py index ee994b528..56cb375c0 100644 --- a/graphix/flow/_find_cflow.py +++ b/graphix/flow/_find_cflow.py @@ -31,7 +31,7 @@ def find_cflow(og: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: Returns ------- - CausalFlow | None + CausalFlow[_PM_co] | None A causal flow object if the open graph has causal flow, `None` otherwise. Notes @@ -72,7 +72,7 @@ def _flow_aux( Parameters ---------- - og : OpenGraph[Plane] + og : OpenGraph[_PM_co] Open graph whose causal flow is calculated. non_input_nodes : AbstractSet[int] Non-input nodes of the input open graph. This parameter remains constant throughout the execution of the algorithm and can be derived from `og` at any time. It is passed as an argument to avoid unnecessary recalculations. @@ -88,7 +88,7 @@ def _flow_aux( Returns ------- - CausalFlow | None + CausalFlow[_PM_co] | None A causal flow object if the open graph has causal flow, `None` otherwise. """ corrected_nodes_new: set[int] = set() diff --git a/graphix/flow/core.py b/graphix/flow/core.py index f9fc79955..f1e290660 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -87,7 +87,7 @@ def from_measured_nodes_mapping( outputs_set = frozenset(og.output_nodes) non_outputs_set = nodes_set - outputs_set - if not set(x_corrections).issubset(non_outputs_set): + if not non_outputs_set.issuperset(x_corrections): raise ValueError("Keys of input X-corrections contain non-measured nodes.") if not set(z_corrections).issubset(non_outputs_set): raise ValueError("Keys of input Z-corrections contain non-measured nodes.") @@ -105,7 +105,7 @@ def from_measured_nodes_mapping( shift = 1 if partial_order_layers[0].issubset(outputs_set) else 0 partial_order_layers = [outputs_set, *partial_order_layers[shift:]] - ordered_nodes = {node for layer in partial_order_layers for node in layer} + ordered_nodes = set.union(*partial_order_layers) if not ordered_nodes.issubset(nodes_set): raise ValueError("Values of input mapping contain labels which are not nodes of the input open graph.") @@ -446,11 +446,12 @@ def _corrections_to_dag( """ relations: set[tuple[int, int]] = set() - for measured_node, corrected_nodes in x_corrections.items(): - relations.update(product([measured_node], corrected_nodes)) - - for measured_node, corrected_nodes in z_corrections.items(): - relations.update(product([measured_node], corrected_nodes)) + relations = ( + (measured_node, corrected_node) + for corrections in (x_corrections, z_corrections) + for measured_node, corrected_nodes in corrections.items() + for corrected_node in corrected_nodes + ) return nx.DiGraph(relations) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 5a20e3b33..bdc33a375 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -75,7 +75,7 @@ def __post_init__(self) -> None: if outputs & self.measurements.keys(): raise ValueError("Output nodes cannot be measured.") if all_nodes - outputs != self.measurements.keys(): - raise ValueError("All non-ouptut nodes must be measured.") + raise ValueError("All non-output nodes must be measured.") if len(inputs) != len(self.input_nodes): raise ValueError("Input nodes contain duplicates.") if len(outputs) != len(self.output_nodes): diff --git a/graphix/pyzx.py b/graphix/pyzx.py index d38dc900b..33cf68dae 100644 --- a/graphix/pyzx.py +++ b/graphix/pyzx.py @@ -31,7 +31,7 @@ def _fraction_of_angle(angle: ExpressionOrFloat) -> Fraction: return Fraction(angle) -# TODO: Adapt to new OpenGrpah APi +# TODO: Adapt to new OpenGraph API def to_pyzx_graph(og: OpenGraph[Measurement]) -> BaseGraph[int, tuple[int, int]]: """Return a :mod:`pyzx` graph corresponding to the open graph. From 6e7d36bbc946eb0def5d40e10bacf7a1b6e3fd03 Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 12 Nov 2025 14:04:43 +0100 Subject: [PATCH 40/56] Add Thierry suggestions --- graphix/flow/_find_gpflow.py | 74 ++++++++++++++++++------------------ graphix/flow/core.py | 5 +-- 2 files changed, 38 insertions(+), 41 deletions(-) diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index 063e7c375..1f26ff997 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Generic, TypeVar import numpy as np +from typing_extensions import override from graphix._linalg import MatGF2, solve_f2_linear_system from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane @@ -91,7 +92,7 @@ def flow_demand_matrix(self) -> MatGF2: ----- See Definition 3.4 and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - return self._compute_og_matrices[0] + return self._og_matrices[0] @property def order_demand_matrix(self) -> MatGF2: @@ -106,7 +107,7 @@ def order_demand_matrix(self) -> MatGF2: ----- See Definition 3.5 and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ - return self._compute_og_matrices[1] + return self._og_matrices[1] def _compute_reduced_adj(self) -> MatGF2: r"""Return the reduced adjacency matrix (RAdj) of the open graph. @@ -139,7 +140,7 @@ def _compute_reduced_adj(self) -> MatGF2: return adj_red @cached_property - def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: + def _og_matrices(self) -> tuple[MatGF2, MatGF2]: r"""Construct the flow-demand and order-demand matrices. Returns @@ -149,7 +150,6 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: Notes ----- - - Measurements with a Pauli angle are intepreted as `Axis` instances. - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ flow_demand_matrix = self._compute_reduced_adj() @@ -162,7 +162,7 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: for v in row_tags: # v is a node tag i = row_tags.index(v) - plane_axis_v = self.og.measurements[v].to_plane_or_axis() + plane_axis_v = self._get_measurement_label(v) if plane_axis_v in {Plane.YZ, Plane.XZ, Axis.Z}: flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 @@ -177,6 +177,25 @@ def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: return flow_demand_matrix, order_demand_matrix + def _get_measurement_label(self, node: int) -> Plane | Axis: + """Return the measurement label (plane or axis) of a node in the open graph. + + Parameters + ---------- + node : int + Measured node. + + Returns + ------- + Plane | Axis + Measurement label. + + Notes + ----- + Measurements with a Pauli angle are intepreted as `Axis` instances. + """ + return self.og.measurements[node].to_plane_or_axis() + class PlanarAlgebraicOpenGraph(AlgebraicOpenGraph[_PM_co]): """A subclass of `AlgebraicOpenGraph`. @@ -185,44 +204,25 @@ class PlanarAlgebraicOpenGraph(AlgebraicOpenGraph[_PM_co]): """ - @cached_property - def _compute_og_matrices(self) -> tuple[MatGF2, MatGF2]: - r"""Construct flow-demand and order-demand matrices assuming that the underlying open graph has planar measurements only. + @override + def _get_measurement_label(self, node: int) -> Plane: + """Return the measurement label (plane) of a node in the open graph. + + Parameters + ---------- + node : int + Measured node. Returns ------- - flow_demand_matrix : MatGF2 - order_demand_matrix : MatGF2 + Plane + Measurement label. Notes ----- - - Measurements with a Pauli angle are intepreted as `Plane` instances. - - See Definitions 3.4 and 3.5, and Algorithm 1 in Mitosek and Backens, 2024 (arXiv:2410.23439). + Measurements with a Pauli angle are intepreted as `Plane` instances. """ - flow_demand_matrix = self._compute_reduced_adj() - order_demand_matrix = flow_demand_matrix.copy() - - inputs_set = set(self.og.input_nodes) - - row_tags = self.non_outputs - col_tags = self.non_inputs - - for v in row_tags: # v is a node tag - i = row_tags.index(v) - plane_v = self.og.measurements[v].to_plane() - - if plane_v in {Plane.YZ, Plane.XZ}: - flow_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if v not in inputs_set: - j = col_tags.index(v) - flow_demand_matrix[i, j] = 1 # Set element (v, v) = 0 - if plane_v is Plane.XY: - order_demand_matrix[i, :] = 0 # Set row corresponding to node v to 0 - if plane_v in {Plane.XY, Plane.XZ} and v not in inputs_set: - j = col_tags.index(v) - order_demand_matrix[i, j] = 1 # Set element (v, v) = 1 - - return flow_demand_matrix, order_demand_matrix + return self.og.measurements[node].to_plane() @dataclass(frozen=True) # `NamedTuple` does not support multiple inheritance in Python 3.9 and 3.10 @@ -655,7 +655,7 @@ def compute_correction_matrix(aog: AlgebraicOpenGraph[_M_co]) -> CorrectionMatri # Steps 1 and 2 # Flow-demand and order-demand matrices are cached properties of `aog`. - flow_demand_matrix, order_demand_matrix = aog._compute_og_matrices + flow_demand_matrix, order_demand_matrix = aog._og_matrices if ni == no: correction_matrix = flow_demand_matrix.right_inverse() diff --git a/graphix/flow/core.py b/graphix/flow/core.py index f1e290660..440f3dd25 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -5,7 +5,6 @@ from collections.abc import Sequence from copy import copy from dataclasses import dataclass -from itertools import product from typing import TYPE_CHECKING, Generic import networkx as nx @@ -105,7 +104,7 @@ def from_measured_nodes_mapping( shift = 1 if partial_order_layers[0].issubset(outputs_set) else 0 partial_order_layers = [outputs_set, *partial_order_layers[shift:]] - ordered_nodes = set.union(*partial_order_layers) + ordered_nodes = {node for layer in partial_order_layers for node in layer} if not ordered_nodes.issubset(nodes_set): raise ValueError("Values of input mapping contain labels which are not nodes of the input open graph.") @@ -444,8 +443,6 @@ def _corrections_to_dag( ----- See :func:`XZCorrections.extract_dag`. """ - relations: set[tuple[int, int]] = set() - relations = ( (measured_node, corrected_node) for corrections in (x_corrections, z_corrections) From 3f394a5287e9608e7a32c344315cceda86e28aaf Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 12 Nov 2025 15:48:42 +0100 Subject: [PATCH 41/56] Fix bugs in XZCorrections.partial_order_layers --- graphix/flow/_find_gpflow.py | 4 +++- graphix/flow/core.py | 22 ++++++++++++++---- tests/test_flow_core.py | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index 1f26ff997..65e3ac917 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -596,7 +596,7 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> Returns ------- layers : tuple[frozenset[int], ...] - Partial order between corrected qubits in a layer form. The frozenset `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + Partial order between corrected qubits in a layer form. The frozenset `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. Output nodes are always in layer 0. or `None` If the correction matrix is not compatible with a partial order on the the open graph, in which case the associated ordering matrix is not a DAG. In the context of the flow-finding algorithm, this means that the input open graph does not have Pauli (or generalised) flow. @@ -605,6 +605,8 @@ def compute_partial_order_layers(correction_matrix: CorrectionMatrix[_M_co]) -> ----- - The partial order of the Pauli (or generalised) flow :math:`<_c` is the transitive closure of :math:`\lhd_c`, where the latter is related to the ordering matrix :math:`NC` as :math:`v \lhd_c w \Leftrightarrow (NC)_{w,v} = 1`, for :math:`v, w, \in O^c` two non-output nodes of `aog`. The ordering matrix is the product of the order-demand and the correction matrices and it is the adjacency matrix of the directed acyclical graph encoding the partial order. + - If the open graph has flow, it must have outputs, so `layers[0]` always contains a finite set of nodes. + See Lemma 3.12, and Theorem 3.1 in Mitosek and Backens, 2024 (arXiv:2410.23439). """ aog, c_matrix = correction_matrix.aog, correction_matrix.c_matrix diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 440f3dd25..5e93933ac 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -41,7 +41,7 @@ class XZCorrections(Generic[_M_co]): z_corrections : Mapping[int, AbstractSet[int]] Mapping of Z-corrections: in each (`key`, `value`) pair, `key` is a measured node, and `value` is the set of nodes on which an Z-correction must be applied depending on the measurement result of `key`. partial_order_layers : Sequence[AbstractSet[int]] - Partial order between corrected qubits in a layer form. In particular, the set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + Partial order between the open graph's nodes in a layer form determined by the corrections. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. If the open graph has output nodes, they are always in layer 0. Non-corrected, measured nodes are always in the last layer. Notes ----- @@ -99,10 +99,20 @@ def from_measured_nodes_mapping( "Input XZ-corrections are not runnable since the induced directed graph contains closed loops." ) - # If the open graph has outputs, the first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain a subset of the output nodes. + # If there're no corrections, the partial order has 2 layers only: outputs and measured nodes. + if len(partial_order_layers) == 0: + partial_order_layers = [outputs_set] if outputs_set else [] + if non_outputs_set: + partial_order_layers.append(frozenset(non_outputs_set)) + return XZCorrections(og, x_corrections, z_corrections, tuple(partial_order_layers)) + + # If the open graph has outputs, the first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain output nodes. if outputs_set: - shift = 1 if partial_order_layers[0].issubset(outputs_set) else 0 - partial_order_layers = [outputs_set, *partial_order_layers[shift:]] + partial_order_layers = [ + outputs_set, + frozenset(partial_order_layers[0] - outputs_set), + *partial_order_layers[1:], + ] ordered_nodes = {node for layer in partial_order_layers for node in layer} if not ordered_nodes.issubset(nodes_set): @@ -247,7 +257,7 @@ class PauliFlow(Generic[_M_co]): correction_function : Mapping[int, AbstractSet[int] Pauli flow correction function. `correction_function[i]` is the set of qubits correcting the measurement of qubit `i`. partial_order_layers : Sequence[AbstractSet[int]] - Partial order between corrected qubits in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. + Partial order between the open graph's nodes in a layer form. The set `layers[i]` comprises the nodes in layer `i`. Nodes in layer `i` are "larger" in the partial order than nodes in layer `i+1`. Output nodes are always in layer 0. Notes ----- @@ -255,6 +265,8 @@ class PauliFlow(Generic[_M_co]): - The flow's correction function defines a partial order (see Def. 2.8 and 2.9, Lemma 2.11 and Theorem 2.12 in Ref. [2]), therefore, only `og` and `correction_function` are necessary to initialize an `PauliFlow` instance (see :func:`PauliFlow.from_correction_matrix`). However, flow-finding algorithms generate a partial order in a layer form, which is necessary to extract the flow's XZ-corrections, so it is stored as an attribute. + - A correct flow can only exist on an open graph with output nodes, so `layers[0]` always contains a finite set of nodes. + References ---------- [1] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212). diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index 3ed7d86ed..d5dbe2b6c 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -473,6 +473,51 @@ def test_order_3(self) -> None: assert corrections.generate_total_measurement_order() in ([0, 1, 2], [0, 2, 1]) assert nx.utils.graphs_equal(corrections.extract_dag(), nx.DiGraph([(0, 1), (0, 2)])) + # Graph state + def test_from_measured_nodes_mapping_0(self) -> None: + og: OpenGraph[Plane] = OpenGraph( + graph=nx.Graph([(0, 1)]), + input_nodes=[], + output_nodes=[0, 1], + measurements={}, + ) + + corrections = XZCorrections.from_measured_nodes_mapping(og=og) + assert corrections.x_corrections == {} + assert corrections.z_corrections == {} + assert len(corrections.partial_order_layers) == 1 + assert corrections.partial_order_layers[0] == frozenset(og.output_nodes) + + def test_from_measured_nodes_mapping_1(self) -> None: + og: OpenGraph[Plane] = OpenGraph( + graph=nx.Graph([(0, 1)]), + input_nodes=[], + output_nodes=[1], + measurements={0: Plane.XY}, + ) + + corrections = XZCorrections.from_measured_nodes_mapping(og=og) + assert corrections.x_corrections == {} + assert corrections.z_corrections == {} + assert len(corrections.partial_order_layers) == 2 + assert corrections.partial_order_layers[0] == frozenset(og.output_nodes) + assert corrections.partial_order_layers[1] == frozenset(og.measurements) + + def test_partial_order_layers_partition(self) -> None: + og = OpenGraph( + graph=nx.Graph([(0, 1), (2, 3)]), + input_nodes=[], + output_nodes=[3], + measurements=dict.fromkeys([0, 1, 2], Measurement(angle=0, plane=Plane.XY)), + ) + x_corrections = {0: {1}, 2: {3}} + + corrections = XZCorrections.from_measured_nodes_mapping(og=og, x_corrections=x_corrections) + + assert all( + sum(1 for layer in corrections.partial_order_layers if node in layer) == 1 for node in og.graph.nodes + ) + # Test exceptions def test_from_measured_nodes_mapping_exceptions(self) -> None: og = OpenGraph( From b3a82c2a044f0ac21efdb10766032fa8271f5497 Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 12 Nov 2025 15:56:03 +0100 Subject: [PATCH 42/56] Replace set comprehension by frozenset.union + unpacking --- graphix/flow/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 5e93933ac..e5dba75ec 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -114,7 +114,8 @@ def from_measured_nodes_mapping( *partial_order_layers[1:], ] - ordered_nodes = {node for layer in partial_order_layers for node in layer} + ordered_nodes = frozenset.union(*partial_order_layers) + if not ordered_nodes.issubset(nodes_set): raise ValueError("Values of input mapping contain labels which are not nodes of the input open graph.") From 0fe31139d83e10e2cf6fc9738ddffacb6b0d3791 Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 13 Nov 2025 12:09:06 +0100 Subject: [PATCH 43/56] Fix bug in XZCorrections partial order --- graphix/flow/core.py | 24 ++++++++++++------ tests/test_flow_core.py | 55 +++++++++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index e5dba75ec..e0203a5c5 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -106,22 +106,30 @@ def from_measured_nodes_mapping( partial_order_layers.append(frozenset(non_outputs_set)) return XZCorrections(og, x_corrections, z_corrections, tuple(partial_order_layers)) - # If the open graph has outputs, the first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain output nodes. + # If the open graph has outputs, the first element in the output of `_dag_to_partial_order_layers(dag)` may or may not contain all or some output nodes. if outputs_set: - partial_order_layers = [ - outputs_set, - frozenset(partial_order_layers[0] - outputs_set), - *partial_order_layers[1:], - ] + if measured_layer_0 := partial_order_layers[0] - outputs_set: + # `partial_order_layers[0]` contains (some or all) outputs and measured nodes + partial_order_layers = [ + outputs_set, + frozenset(measured_layer_0), + *partial_order_layers[1:], + ] + else: + # `partial_order_layers[0]` contains only (some or all) outputs + partial_order_layers = [ + outputs_set, + *partial_order_layers[1:], + ] ordered_nodes = frozenset.union(*partial_order_layers) if not ordered_nodes.issubset(nodes_set): raise ValueError("Values of input mapping contain labels which are not nodes of the input open graph.") - # We append to the last layer (first measured nodes) all the non-output nodes not involved in the corrections. + # We include all the non-output nodes not involved in the corrections in the last layer (first measured nodes). if unordered_nodes := frozenset(nodes_set - ordered_nodes): - partial_order_layers.append(unordered_nodes) + partial_order_layers[-1] |= unordered_nodes return XZCorrections(og, x_corrections, z_corrections, tuple(partial_order_layers)) diff --git a/tests/test_flow_core.py b/tests/test_flow_core.py index d5dbe2b6c..9b15a9a72 100644 --- a/tests/test_flow_core.py +++ b/tests/test_flow_core.py @@ -473,7 +473,7 @@ def test_order_3(self) -> None: assert corrections.generate_total_measurement_order() in ([0, 1, 2], [0, 2, 1]) assert nx.utils.graphs_equal(corrections.extract_dag(), nx.DiGraph([(0, 1), (0, 2)])) - # Graph state + # Only output nodes def test_from_measured_nodes_mapping_0(self) -> None: og: OpenGraph[Plane] = OpenGraph( graph=nx.Graph([(0, 1)]), @@ -485,9 +485,9 @@ def test_from_measured_nodes_mapping_0(self) -> None: corrections = XZCorrections.from_measured_nodes_mapping(og=og) assert corrections.x_corrections == {} assert corrections.z_corrections == {} - assert len(corrections.partial_order_layers) == 1 - assert corrections.partial_order_layers[0] == frozenset(og.output_nodes) + assert corrections.partial_order_layers == (frozenset({0, 1}),) + # Empty corrections def test_from_measured_nodes_mapping_1(self) -> None: og: OpenGraph[Plane] = OpenGraph( graph=nx.Graph([(0, 1)]), @@ -499,11 +499,9 @@ def test_from_measured_nodes_mapping_1(self) -> None: corrections = XZCorrections.from_measured_nodes_mapping(og=og) assert corrections.x_corrections == {} assert corrections.z_corrections == {} - assert len(corrections.partial_order_layers) == 2 - assert corrections.partial_order_layers[0] == frozenset(og.output_nodes) - assert corrections.partial_order_layers[1] == frozenset(og.measurements) + assert corrections.partial_order_layers == (frozenset({1}), frozenset({0})) - def test_partial_order_layers_partition(self) -> None: + def test_from_measured_nodes_mapping_2(self) -> None: og = OpenGraph( graph=nx.Graph([(0, 1), (2, 3)]), input_nodes=[], @@ -514,10 +512,53 @@ def test_partial_order_layers_partition(self) -> None: corrections = XZCorrections.from_measured_nodes_mapping(og=og, x_corrections=x_corrections) + assert all(corrections.partial_order_layers) # No empty sets assert all( sum(1 for layer in corrections.partial_order_layers if node in layer) == 1 for node in og.graph.nodes ) + # All output nodes in corrections + def test_from_measured_nodes_mapping_3(self) -> None: + og = OpenGraph( + graph=nx.Graph([(0, 1), (2, 3)]), + input_nodes=[], + output_nodes=[1, 3], + measurements=dict.fromkeys([0, 2], Measurement(angle=0, plane=Plane.XY)), + ) + x_corrections = {0: {1}, 2: {3}} + + corrections = XZCorrections.from_measured_nodes_mapping(og=og, x_corrections=x_corrections) + + assert corrections.partial_order_layers == (frozenset({1, 3}), frozenset({0, 2})) + + # Some output nodes in corrections + def test_from_measured_nodes_mapping_4(self) -> None: + og = OpenGraph( + graph=nx.Graph([(0, 1), (2, 3)]), + input_nodes=[], + output_nodes=[1, 3], + measurements=dict.fromkeys([0, 2], Measurement(angle=0, plane=Plane.XY)), + ) + x_corrections = {2: {3}} + + corrections = XZCorrections.from_measured_nodes_mapping(og=og, x_corrections=x_corrections) + + assert corrections.partial_order_layers == (frozenset({1, 3}), frozenset({0, 2})) + + # No output nodes in corrections + def test_from_measured_nodes_mapping_5(self) -> None: + og = OpenGraph( + graph=nx.Graph([(0, 1), (2, 3)]), + input_nodes=[0], + output_nodes=[1, 3], + measurements=dict.fromkeys([0, 2], Measurement(angle=0, plane=Plane.XY)), + ) + x_corrections = {0: {2}} + + corrections = XZCorrections.from_measured_nodes_mapping(og=og, x_corrections=x_corrections) + + assert corrections.partial_order_layers == (frozenset({1, 3}), frozenset({2}), frozenset({0})) + # Test exceptions def test_from_measured_nodes_mapping_exceptions(self) -> None: og = OpenGraph( From a7c677867ed82288022405d82cdd264d10e67c4e Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 13 Nov 2025 20:53:14 +0100 Subject: [PATCH 44/56] Change OpenGraph.from_pattern by Pattern.extract_opengraph --- graphix/gflow.py | 8 +-- graphix/opengraph.py | 28 +---------- graphix/pattern.py | 99 ++++++++++++++++++++----------------- tests/test_gflow.py | 4 +- tests/test_opengraph.py | 4 +- tests/test_pattern.py | 4 +- tests/test_pyzx.py | 5 +- tests/test_visualization.py | 4 +- 8 files changed, 70 insertions(+), 86 deletions(-) diff --git a/graphix/gflow.py b/graphix/gflow.py index b251045d7..849b33fd2 100644 --- a/graphix/gflow.py +++ b/graphix/gflow.py @@ -299,7 +299,7 @@ def flow_from_pattern(pattern: Pattern) -> tuple[dict[int, set[int]], dict[int, """ if not pattern.is_standard(strict=True): raise ValueError("The pattern should be standardized first.") - meas_planes = pattern.get_meas_plane() + meas_planes = pattern.extract_planes() for plane in meas_planes.values(): if plane != Plane.XY: return None, None @@ -352,7 +352,7 @@ def gflow_from_pattern(pattern: Pattern) -> tuple[dict[int, set[int]], dict[int, graph = pattern.extract_graph() input_nodes = set(pattern.input_nodes) if pattern.input_nodes else set() output_nodes = set(pattern.output_nodes) - meas_planes = pattern.get_meas_plane() + meas_planes = pattern.extract_planes() layers = pattern.get_layers() l_k = {} @@ -414,8 +414,8 @@ def pauliflow_from_pattern( graph = pattern.extract_graph() input_nodes = set(pattern.input_nodes) if pattern.input_nodes else set() output_nodes = set(pattern.output_nodes) if pattern.output_nodes else set() - meas_planes = pattern.get_meas_plane() - meas_angles = pattern.get_angles() + meas_planes = pattern.extract_planes() + meas_angles = pattern.extract_angles() return find_pauliflow(graph, input_nodes, output_nodes, meas_planes, meas_angles) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index bdc33a375..238cea304 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -11,11 +11,11 @@ from graphix.flow._find_gpflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix from graphix.flow.core import CausalFlow, GFlow, PauliFlow from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement -from graphix.measurements import Measurement if TYPE_CHECKING: from collections.abc import Collection, Iterable, Mapping, Sequence + from graphix.measurements import Measurement from graphix.pattern import Pattern # TODO: Maybe move these definitions to graphix.fundamentals and graphix.measurements ? Now they are redefined in graphix.flow._find_gpflow, not very elegant. @@ -107,32 +107,6 @@ def isclose( for node, m in self.measurements.items() ) - @staticmethod - def from_pattern(pattern: Pattern) -> OpenGraph[Measurement]: - """Initialise an `OpenGraph[Measurement]` object from the underlying resource-state graph of the input measurement pattern. - - Parameters - ---------- - pattern : Pattern - The input pattern. - - Returns - ------- - OpenGraph[Measurement] - """ - graph = pattern.extract_graph() - - input_nodes = pattern.input_nodes - output_nodes = pattern.output_nodes - - meas_planes = pattern.get_meas_plane() - meas_angles = pattern.get_angles() - measurements: Mapping[int, Measurement] = { - node: Measurement(meas_angles[node], meas_planes[node]) for node in meas_angles - } - - return OpenGraph(graph, input_nodes, output_nodes, measurements) - def to_pattern(self: OpenGraph[Measurement]) -> Pattern | None: """Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists. diff --git a/graphix/pattern.py b/graphix/pattern.py index 2c96d77b2..c67e073c1 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -23,7 +23,8 @@ from graphix.fundamentals import Axis, Plane, Sign from graphix.gflow import find_flow, find_gflow, get_layers from graphix.graphsim import GraphState -from graphix.measurements import Outcome, PauliMeasurement, toggle_outcome +from graphix.measurements import Measurement, Outcome, PauliMeasurement, toggle_outcome +from graphix.opengraph import OpenGraph from graphix.pretty_print import OutputFormat, pattern_to_str from graphix.simulator import PatternSimulator from graphix.states import BasicStates @@ -996,7 +997,7 @@ def get_measurement_order_from_flow(self) -> list[int] | None: graph = self.extract_graph() vin = set(self.input_nodes) if self.input_nodes is not None else set() vout = set(self.output_nodes) - meas_planes = self.get_meas_plane() + meas_planes = self.extract_planes() f, l_k = find_flow(graph, vin, vout, meas_planes=meas_planes) if f is None: return None @@ -1022,7 +1023,7 @@ def get_measurement_order_from_gflow(self) -> list[int]: raise ValueError("The input graph must be connected") vin = set(self.input_nodes) if self.input_nodes is not None else set() vout = set(self.output_nodes) - meas_planes = self.get_meas_plane() + meas_planes = self.extract_planes() flow, l_k = find_gflow(graph, vin, vout, meas_planes=meas_planes) if flow is None or l_k is None: # We check both to avoid typing issues with `get_layers`. raise ValueError("No gflow found") @@ -1067,33 +1068,54 @@ def extract_measurement_commands(self) -> Iterator[command.M]: """ yield from (cmd for cmd in self if cmd.kind == CommandKind.M) - def get_meas_plane(self) -> dict[int, Plane]: - """Get measurement plane from the pattern. + def extract_opengraph(self) -> OpenGraph[Measurement]: + """Extract the underlying resource-state open graph from the pattern. Returns ------- - meas_plane: dict of graphix.pauli.Plane - list of planes representing measurement plane for each node. + OpenGraph[Measurement] + + Notes + ----- + This operation loses all the information on the Clifford commands. """ - meas_plane = {} + graph: nx.Graph[int] = nx.Graph() + measurements: dict[int, Measurement] = {} + graph.add_nodes_from(self.input_nodes) for cmd in self.__seq: - if cmd.kind == CommandKind.M: - meas_plane[cmd.node] = cmd.plane - return meas_plane + if cmd.kind == CommandKind.N: + graph.add_node(cmd.node) + elif cmd.kind == CommandKind.E: + u, v = cmd.nodes + if graph.has_edge(u, v): + graph.remove_edge(u, v) + else: + graph.add_edge(u, v) + elif cmd.kind == CommandKind.M: + measurements[cmd.node] = Measurement(cmd.angle, cmd.plane) + return OpenGraph(graph, self.input_nodes, self.output_nodes, measurements) - def get_angles(self) -> dict[int, ExpressionOrFloat]: - """Get measurement angles of the pattern. + def extract_planes(self) -> dict[int, Plane]: + """Return the measurement planes of the pattern. Returns ------- - angles : dict - measurement angles of the each node. + dict[int, Plane] + measurement planes for each node. """ - angles = {} - for cmd in self.__seq: - if cmd.kind == CommandKind.M: - angles[cmd.node] = cmd.angle - return angles + og = self.extract_opengraph() + return {node: m.plane for node, m in og.measurements.items()} + + def extract_angles(self) -> dict[int, ExpressionOrFloat]: + """Return the measurement angles of the pattern. + + Returns + ------- + dict[int, ExpressionOrFloat] + measurement angles of each node. + """ + og = self.extract_opengraph() + return {node: m.angle for node, m in og.measurements.items()} def compute_max_degree(self) -> int: """Get max degree of a pattern. @@ -1113,36 +1135,25 @@ def extract_graph(self) -> nx.Graph[int]: Returns ------- - graph_state: nx.Graph[int] + nx.Graph[int] """ - graph: nx.Graph[int] = nx.Graph() - graph.add_nodes_from(self.input_nodes) - for cmd in self.__seq: - if cmd.kind == CommandKind.N: - graph.add_node(cmd.node) - elif cmd.kind == CommandKind.E: - u, v = cmd.nodes - if graph.has_edge(u, v): - graph.remove_edge(u, v) - else: - graph.add_edge(u, v) - return graph + return self.extract_opengraph().graph def extract_nodes(self) -> set[int]: - """Return the set of nodes of the pattern.""" - nodes = set(self.input_nodes) - for cmd in self.__seq: - if cmd.kind == CommandKind.N: - nodes.add(cmd.node) - return nodes + """Return the nodes of the pattern. + + Returns + ------- + set[int] + """ + return set(self.extract_graph().nodes) def extract_isolated_nodes(self) -> set[int]: - """Get isolated nodes. + """Return the isolated nodes in the pattern. Returns ------- - isolated_nodes : set[int] - set of the isolated nodes + set[int] """ graph = self.extract_graph() return {node for node, d in graph.degree if d == 0} @@ -1420,8 +1431,8 @@ def draw_graph( graph = self.extract_graph() vin = self.input_nodes if self.input_nodes is not None else [] vout = self.output_nodes - meas_planes = self.get_meas_plane() - meas_angles = self.get_angles() + meas_planes = self.extract_planes() + meas_angles = self.extract_angles() local_clifford = self.get_vops() vis = GraphVisualizer(graph, vin, vout, meas_planes, meas_angles, local_clifford) diff --git a/tests/test_gflow.py b/tests/test_gflow.py index 87fbae81a..3aab091ac 100644 --- a/tests/test_gflow.py +++ b/tests/test_gflow.py @@ -523,7 +523,7 @@ def test_with_rand_circ(self, fx_rng: Generator) -> None: graph = pattern.extract_graph() input_ = set(pattern.input_nodes) output = set(pattern.output_nodes) - meas_planes = pattern.get_meas_plane() + meas_planes = pattern.extract_planes() f, _ = find_flow(graph, input_, output, meas_planes) valid = verify_flow(graph, input_, output, f, meas_planes) @@ -542,7 +542,7 @@ def test_rand_circ_gflow(self, fx_rng: Generator) -> None: graph = pattern.extract_graph() input_ = set() output = set(pattern.output_nodes) - meas_planes = pattern.get_meas_plane() + meas_planes = pattern.extract_planes() g, _ = find_gflow(graph, input_, output, meas_planes) valid = verify_gflow(graph, input_, output, g, meas_planes) diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index 9f47aa562..ce2f4b442 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -596,7 +596,7 @@ def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> Non def test_double_entanglement(self) -> None: pattern = Pattern(input_nodes=[0, 1], cmds=[E((0, 1)), E((0, 1))]) - pattern2 = OpenGraph.from_pattern(pattern).to_pattern() + pattern2 = pattern.extract_opengraph().to_pattern() state = pattern.simulate_pattern() assert pattern2 is not None state2 = pattern2.simulate_pattern() @@ -607,7 +607,7 @@ def test_from_to_pattern(self, fx_rng: Generator) -> None: depth = 2 circuit = rand_circuit(n_qubits, depth, fx_rng) pattern_ref = circuit.transpile().pattern - pattern = OpenGraph.from_pattern(pattern_ref).to_pattern() + pattern = pattern_ref.extract_opengraph().to_pattern() assert pattern is not None results = [] diff --git a/tests/test_pattern.py b/tests/test_pattern.py index eef7cfb66..8fccf2300 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -287,7 +287,7 @@ def test_pauli_measurement_leave_input(self) -> None: assert isolated_nodes == isolated_nodes_ref - def test_get_meas_plane(self) -> None: + def test_extract_planes(self) -> None: preset_meas_plane = [ Plane.XY, Plane.XY, @@ -314,7 +314,7 @@ def test_get_meas_plane(self) -> None: 7: Plane.YZ, 8: Plane.XZ, } - meas_plane = pattern.get_meas_plane() + meas_plane = pattern.extract_planes() assert meas_plane == ref_meas_plane @pytest.mark.parametrize("plane", Plane) diff --git a/tests/test_pyzx.py b/tests/test_pyzx.py index 58a383d1b..720efc13f 100644 --- a/tests/test_pyzx.py +++ b/tests/test_pyzx.py @@ -8,7 +8,6 @@ import pytest from numpy.random import PCG64, Generator -from graphix.opengraph import OpenGraph from graphix.random_objects import rand_circuit from graphix.transpiler import Circuit @@ -81,7 +80,7 @@ def test_random_circuit(fx_bg: PCG64, jumps: int) -> None: depth = 5 circuit = rand_circuit(nqubits, depth, rng) pattern = circuit.transpile().pattern - opengraph = OpenGraph.from_pattern(pattern) + opengraph = pattern.extract_opengraph() zx_graph = to_pyzx_graph(opengraph) opengraph2 = from_pyzx_graph(zx_graph) pattern2 = opengraph2.to_pattern() @@ -115,7 +114,7 @@ def test_full_reduce_toffoli() -> None: c = Circuit(3) c.ccx(0, 1, 2) p = c.transpile().pattern - og = OpenGraph.from_pattern(p) + og = p.extract_opengraph() pyg = to_pyzx_graph(og) pyg.normalize() pyg_copy = deepcopy(pyg) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index ddd52cf6b..ec8847f80 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -10,8 +10,8 @@ def test_get_pos_from_flow(): graph = pattern.extract_graph() vin = pattern.input_nodes if pattern.input_nodes is not None else [] vout = pattern.output_nodes - meas_planes = pattern.get_meas_plane() - meas_angles = pattern.get_angles() + meas_planes = pattern.extract_planes() + meas_angles = pattern.extract_angles() local_clifford = pattern.get_vops() vis = visualization.GraphVisualizer(graph, vin, vout, meas_planes, meas_angles, local_clifford) f, l_k = gflow.find_flow(graph, set(vin), set(vout), meas_planes) From 97d54f75811231cb3276bdbfce5cef3bfa0f8a56 Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 14 Nov 2025 09:32:15 +0100 Subject: [PATCH 45/56] Fix CI --- docs/source/modifier.rst | 2 +- graphix/opengraph.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/modifier.rst b/docs/source/modifier.rst index ff82ba2d1..5d8473f49 100644 --- a/docs/source/modifier.rst +++ b/docs/source/modifier.rst @@ -26,7 +26,7 @@ Pattern Manipulation .. automethod:: compute_max_degree - .. automethod:: get_angles + .. automethod:: extract_angles .. automethod:: get_vops diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 238cea304..74b4b262e 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -46,7 +46,8 @@ class OpenGraph(Generic[_M_co]): ------- >>> import networkx as nx >>> from graphix.fundamentals import Plane - >>> from graphix.opengraph import OpenGraph, Measurement + >>> from graphix.opengraph import OpenGraph + >>> from graphix.measurements import Measurement >>> >>> graph = nx.Graph([(0, 1), (1, 2)]) >>> measurements = {i: Measurement(0.5 * i, Plane.XY) for i in range(2)} From 3b4a2b8e4687f9b8100d1ab38890533a4f30c2b2 Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 14 Nov 2025 10:05:22 +0100 Subject: [PATCH 46/56] Fix docs and add OpenGraphException --- docs/source/data.rst | 12 ++++++++++++ docs/source/open_graph.rst | 2 -- graphix/measurements.py | 2 +- graphix/opengraph.py | 17 +++++++++++++---- tests/test_opengraph.py | 2 -- tests/test_pyzx.py | 3 --- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index c8545d86a..0e84328d1 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -62,6 +62,18 @@ This module defines standard data structure for Pauli operators. .. autoclass:: Pauli +:mod:`graphix.measurements` module +++++++++++++++++++++++++++++++++++ + +This module defines data structures for single-qubit measurements in MBQC. + +.. automodule:: graphix.measurements + +.. currentmodule:: graphix.measurements + +.. autoclass:: Measurement + :members: + :mod:`graphix.instruction` module +++++++++++++++++++++++++++++++++ diff --git a/docs/source/open_graph.rst b/docs/source/open_graph.rst index 41f02da6e..ab1dbdbf4 100644 --- a/docs/source/open_graph.rst +++ b/docs/source/open_graph.rst @@ -9,5 +9,3 @@ This module defines classes for defining MBQC patterns as Open Graphs. .. currentmodule:: graphix.opengraph .. autoclass:: OpenGraph - -.. autoclass:: Measurement diff --git a/graphix/measurements.py b/graphix/measurements.py index 32bf344aa..179c26f49 100644 --- a/graphix/measurements.py +++ b/graphix/measurements.py @@ -79,7 +79,7 @@ def to_plane_or_axis(self) -> Plane | Axis: Notes ----- - Measurements with Pauli angles (i.e., `self.angle == n/2` with `n` an integer) are interpreted as `Axis` instances. + Measurements with Pauli angles (i.e., ``self.angle == n/2`` with ``n`` an integer) are interpreted as `Axis` instances. """ if pm := PauliMeasurement.try_from(self.plane, self.angle): return pm.axis diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 74b4b262e..f2cb05998 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -108,13 +108,18 @@ def isclose( for node, m in self.measurements.items() ) - def to_pattern(self: OpenGraph[Measurement]) -> Pattern | None: + def to_pattern(self: OpenGraph[Measurement]) -> Pattern: """Extract a deterministic pattern from an `OpenGraph[Measurement]` if it exists. Returns ------- - Pattern | None - A deterministic pattern on the open graph. If it does not exist, it returns `None`. + Pattern + A deterministic pattern on the open graph. + + Raises + ------ + OpenGraphError + If the open graph does not have flow. Notes ----- @@ -135,7 +140,7 @@ def to_pattern(self: OpenGraph[Measurement]) -> Pattern | None: if pflow is not None: return pflow.to_corrections().to_pattern() - return None + raise OpenGraphError("The open graph does not have flow. It does not support a deterministic pattern.") def neighbors(self, nodes: Collection[int]) -> set[int]: """Return the set containing the neighborhood of a set of nodes in the open graph. @@ -316,3 +321,7 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: measurements = {**self.measurements, **measurements_shifted} return OpenGraph(g, inputs, outputs, measurements), mapping_complete + + +class OpenGraphError(Exception): + """Exception subclass to handle incorrect open graphs.""" diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index ce2f4b442..37a9c30e9 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -598,7 +598,6 @@ def test_double_entanglement(self) -> None: pattern = Pattern(input_nodes=[0, 1], cmds=[E((0, 1)), E((0, 1))]) pattern2 = pattern.extract_opengraph().to_pattern() state = pattern.simulate_pattern() - assert pattern2 is not None state2 = pattern2.simulate_pattern() assert np.abs(np.dot(state.flatten().conjugate(), state2.flatten())) == pytest.approx(1) @@ -608,7 +607,6 @@ def test_from_to_pattern(self, fx_rng: Generator) -> None: circuit = rand_circuit(n_qubits, depth, fx_rng) pattern_ref = circuit.transpile().pattern pattern = pattern_ref.extract_opengraph().to_pattern() - assert pattern is not None results = [] diff --git a/tests/test_pyzx.py b/tests/test_pyzx.py index 720efc13f..a468cca0f 100644 --- a/tests/test_pyzx.py +++ b/tests/test_pyzx.py @@ -87,7 +87,6 @@ def test_random_circuit(fx_bg: PCG64, jumps: int) -> None: pattern.perform_pauli_measurements() pattern.minimize_space() state = pattern.simulate_pattern() - assert pattern2 is not None pattern2.perform_pauli_measurements() pattern2.minimize_space() state2 = pattern2.simulate_pattern() @@ -103,7 +102,6 @@ def test_rz() -> None: g = circ.to_graph() og = from_pyzx_graph(g) pattern_zx = og.to_pattern() - assert pattern_zx is not None state = pattern.simulate_pattern() state_zx = pattern_zx.simulate_pattern() assert np.abs(np.dot(state_zx.flatten().conjugate(), state.flatten())) == pytest.approx(1) @@ -125,7 +123,6 @@ def test_full_reduce_toffoli() -> None: assert zx.compare_tensors(t, t2) og2 = from_pyzx_graph(pyg) p2 = og2.to_pattern() - assert p2 is not None s = p.simulate_pattern() s2 = p2.simulate_pattern() print(np.abs(np.dot(s.flatten().conj(), s2.flatten()))) From 235ab71ce17ed9171d9b8ee278717816def6d099 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 17 Nov 2025 17:06:52 +0100 Subject: [PATCH 47/56] Revert graph extraction. Keep :func:`Pattern.extract_opengraph` only. --- docs/source/modifier.rst | 2 +- graphix/gflow.py | 8 +-- graphix/pattern.py | 121 ++++++++++++++++++++---------------- tests/test_gflow.py | 4 +- tests/test_pattern.py | 4 +- tests/test_visualization.py | 4 +- 6 files changed, 80 insertions(+), 63 deletions(-) diff --git a/docs/source/modifier.rst b/docs/source/modifier.rst index 5d8473f49..ff82ba2d1 100644 --- a/docs/source/modifier.rst +++ b/docs/source/modifier.rst @@ -26,7 +26,7 @@ Pattern Manipulation .. automethod:: compute_max_degree - .. automethod:: extract_angles + .. automethod:: get_angles .. automethod:: get_vops diff --git a/graphix/gflow.py b/graphix/gflow.py index 849b33fd2..b251045d7 100644 --- a/graphix/gflow.py +++ b/graphix/gflow.py @@ -299,7 +299,7 @@ def flow_from_pattern(pattern: Pattern) -> tuple[dict[int, set[int]], dict[int, """ if not pattern.is_standard(strict=True): raise ValueError("The pattern should be standardized first.") - meas_planes = pattern.extract_planes() + meas_planes = pattern.get_meas_plane() for plane in meas_planes.values(): if plane != Plane.XY: return None, None @@ -352,7 +352,7 @@ def gflow_from_pattern(pattern: Pattern) -> tuple[dict[int, set[int]], dict[int, graph = pattern.extract_graph() input_nodes = set(pattern.input_nodes) if pattern.input_nodes else set() output_nodes = set(pattern.output_nodes) - meas_planes = pattern.extract_planes() + meas_planes = pattern.get_meas_plane() layers = pattern.get_layers() l_k = {} @@ -414,8 +414,8 @@ def pauliflow_from_pattern( graph = pattern.extract_graph() input_nodes = set(pattern.input_nodes) if pattern.input_nodes else set() output_nodes = set(pattern.output_nodes) if pattern.output_nodes else set() - meas_planes = pattern.extract_planes() - meas_angles = pattern.extract_angles() + meas_planes = pattern.get_meas_plane() + meas_angles = pattern.get_angles() return find_pauliflow(graph, input_nodes, output_nodes, meas_planes, meas_angles) diff --git a/graphix/pattern.py b/graphix/pattern.py index c67e073c1..a54ae662f 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -997,7 +997,7 @@ def get_measurement_order_from_flow(self) -> list[int] | None: graph = self.extract_graph() vin = set(self.input_nodes) if self.input_nodes is not None else set() vout = set(self.output_nodes) - meas_planes = self.extract_planes() + meas_planes = self.get_meas_plane() f, l_k = find_flow(graph, vin, vout, meas_planes=meas_planes) if f is None: return None @@ -1023,7 +1023,7 @@ def get_measurement_order_from_gflow(self) -> list[int]: raise ValueError("The input graph must be connected") vin = set(self.input_nodes) if self.input_nodes is not None else set() vout = set(self.output_nodes) - meas_planes = self.extract_planes() + meas_planes = self.get_meas_plane() flow, l_k = find_gflow(graph, vin, vout, meas_planes=meas_planes) if flow is None or l_k is None: # We check both to avoid typing issues with `get_layers`. raise ValueError("No gflow found") @@ -1068,54 +1068,33 @@ def extract_measurement_commands(self) -> Iterator[command.M]: """ yield from (cmd for cmd in self if cmd.kind == CommandKind.M) - def extract_opengraph(self) -> OpenGraph[Measurement]: - """Extract the underlying resource-state open graph from the pattern. + def get_meas_plane(self) -> dict[int, Plane]: + """Get measurement plane from the pattern. Returns ------- - OpenGraph[Measurement] - - Notes - ----- - This operation loses all the information on the Clifford commands. + meas_plane: dict of graphix.pauli.Plane + list of planes representing measurement plane for each node. """ - graph: nx.Graph[int] = nx.Graph() - measurements: dict[int, Measurement] = {} - graph.add_nodes_from(self.input_nodes) + meas_plane = {} for cmd in self.__seq: - if cmd.kind == CommandKind.N: - graph.add_node(cmd.node) - elif cmd.kind == CommandKind.E: - u, v = cmd.nodes - if graph.has_edge(u, v): - graph.remove_edge(u, v) - else: - graph.add_edge(u, v) - elif cmd.kind == CommandKind.M: - measurements[cmd.node] = Measurement(cmd.angle, cmd.plane) - return OpenGraph(graph, self.input_nodes, self.output_nodes, measurements) - - def extract_planes(self) -> dict[int, Plane]: - """Return the measurement planes of the pattern. - - Returns - ------- - dict[int, Plane] - measurement planes for each node. - """ - og = self.extract_opengraph() - return {node: m.plane for node, m in og.measurements.items()} + if cmd.kind == CommandKind.M: + meas_plane[cmd.node] = cmd.plane + return meas_plane - def extract_angles(self) -> dict[int, ExpressionOrFloat]: - """Return the measurement angles of the pattern. + def get_angles(self) -> dict[int, ExpressionOrFloat]: + """Get measurement angles of the pattern. Returns ------- - dict[int, ExpressionOrFloat] - measurement angles of each node. + angles : dict + measurement angles of the each node. """ - og = self.extract_opengraph() - return {node: m.angle for node, m in og.measurements.items()} + angles = {} + for cmd in self.__seq: + if cmd.kind == CommandKind.M: + angles[cmd.node] = cmd.angle + return angles def compute_max_degree(self) -> int: """Get max degree of a pattern. @@ -1135,28 +1114,66 @@ def extract_graph(self) -> nx.Graph[int]: Returns ------- - nx.Graph[int] + graph_state: nx.Graph[int] """ - return self.extract_opengraph().graph + graph: nx.Graph[int] = nx.Graph() + graph.add_nodes_from(self.input_nodes) + for cmd in self.__seq: + if cmd.kind == CommandKind.N: + graph.add_node(cmd.node) + elif cmd.kind == CommandKind.E: + u, v = cmd.nodes + if graph.has_edge(u, v): + graph.remove_edge(u, v) + else: + graph.add_edge(u, v) + return graph def extract_nodes(self) -> set[int]: - """Return the nodes of the pattern. + """Return the set of nodes of the pattern.""" + nodes = set(self.input_nodes) + for cmd in self.__seq: + if cmd.kind == CommandKind.N: + nodes.add(cmd.node) + return nodes + + def extract_isolated_nodes(self) -> set[int]: + """Get isolated nodes. Returns ------- - set[int] + isolated_nodes : set[int] + set of the isolated nodes """ - return set(self.extract_graph().nodes) + graph = self.extract_graph() + return {node for node, d in graph.degree if d == 0} - def extract_isolated_nodes(self) -> set[int]: - """Return the isolated nodes in the pattern. + def extract_opengraph(self) -> OpenGraph[Measurement]: + """Extract the underlying resource-state open graph from the pattern. Returns ------- - set[int] + OpenGraph[Measurement] + + Notes + ----- + This operation loses all the information on the Clifford commands. """ - graph = self.extract_graph() - return {node for node, d in graph.degree if d == 0} + graph: nx.Graph[int] = nx.Graph() + measurements: dict[int, Measurement] = {} + graph.add_nodes_from(self.input_nodes) + for cmd in self.__seq: + if cmd.kind == CommandKind.N: + graph.add_node(cmd.node) + elif cmd.kind == CommandKind.E: + u, v = cmd.nodes + if graph.has_edge(u, v): + graph.remove_edge(u, v) + else: + graph.add_edge(u, v) + elif cmd.kind == CommandKind.M: + measurements[cmd.node] = Measurement(cmd.angle, cmd.plane) + return OpenGraph(graph, self.input_nodes, self.output_nodes, measurements) def get_vops(self, conj: bool = False, include_identity: bool = False) -> dict[int, Clifford]: """Get local-Clifford decorations from measurement or Clifford commands. @@ -1431,8 +1448,8 @@ def draw_graph( graph = self.extract_graph() vin = self.input_nodes if self.input_nodes is not None else [] vout = self.output_nodes - meas_planes = self.extract_planes() - meas_angles = self.extract_angles() + meas_planes = self.get_meas_plane() + meas_angles = self.get_angles() local_clifford = self.get_vops() vis = GraphVisualizer(graph, vin, vout, meas_planes, meas_angles, local_clifford) diff --git a/tests/test_gflow.py b/tests/test_gflow.py index 3aab091ac..87fbae81a 100644 --- a/tests/test_gflow.py +++ b/tests/test_gflow.py @@ -523,7 +523,7 @@ def test_with_rand_circ(self, fx_rng: Generator) -> None: graph = pattern.extract_graph() input_ = set(pattern.input_nodes) output = set(pattern.output_nodes) - meas_planes = pattern.extract_planes() + meas_planes = pattern.get_meas_plane() f, _ = find_flow(graph, input_, output, meas_planes) valid = verify_flow(graph, input_, output, f, meas_planes) @@ -542,7 +542,7 @@ def test_rand_circ_gflow(self, fx_rng: Generator) -> None: graph = pattern.extract_graph() input_ = set() output = set(pattern.output_nodes) - meas_planes = pattern.extract_planes() + meas_planes = pattern.get_meas_plane() g, _ = find_gflow(graph, input_, output, meas_planes) valid = verify_gflow(graph, input_, output, g, meas_planes) diff --git a/tests/test_pattern.py b/tests/test_pattern.py index 8fccf2300..eef7cfb66 100644 --- a/tests/test_pattern.py +++ b/tests/test_pattern.py @@ -287,7 +287,7 @@ def test_pauli_measurement_leave_input(self) -> None: assert isolated_nodes == isolated_nodes_ref - def test_extract_planes(self) -> None: + def test_get_meas_plane(self) -> None: preset_meas_plane = [ Plane.XY, Plane.XY, @@ -314,7 +314,7 @@ def test_extract_planes(self) -> None: 7: Plane.YZ, 8: Plane.XZ, } - meas_plane = pattern.extract_planes() + meas_plane = pattern.get_meas_plane() assert meas_plane == ref_meas_plane @pytest.mark.parametrize("plane", Plane) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index ec8847f80..ddd52cf6b 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -10,8 +10,8 @@ def test_get_pos_from_flow(): graph = pattern.extract_graph() vin = pattern.input_nodes if pattern.input_nodes is not None else [] vout = pattern.output_nodes - meas_planes = pattern.extract_planes() - meas_angles = pattern.extract_angles() + meas_planes = pattern.get_meas_plane() + meas_angles = pattern.get_angles() local_clifford = pattern.get_vops() vis = visualization.GraphVisualizer(graph, vin, vout, meas_planes, meas_angles, local_clifford) f, l_k = gflow.find_flow(graph, set(vin), set(vout), meas_planes) From e4dd8561099a1009945e86aa3d329bf98472bcc1 Mon Sep 17 00:00:00 2001 From: matulni Date: Mon, 17 Nov 2025 17:24:03 +0100 Subject: [PATCH 48/56] Improve opengraph extraction from pattern --- graphix/pattern.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/graphix/pattern.py b/graphix/pattern.py index a54ae662f..64499882d 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -1159,20 +1159,24 @@ def extract_opengraph(self) -> OpenGraph[Measurement]: ----- This operation loses all the information on the Clifford commands. """ - graph: nx.Graph[int] = nx.Graph() + nodes = set(self.input_nodes) + edges: set[tuple[int, int]] = set() measurements: dict[int, Measurement] = {} - graph.add_nodes_from(self.input_nodes) + for cmd in self.__seq: if cmd.kind == CommandKind.N: - graph.add_node(cmd.node) + nodes.add(cmd.node) elif cmd.kind == CommandKind.E: u, v = cmd.nodes - if graph.has_edge(u, v): - graph.remove_edge(u, v) - else: - graph.add_edge(u, v) + if u > v: + u, v = v, u + edges.symmetric_difference_update({(u, v)}) elif cmd.kind == CommandKind.M: measurements[cmd.node] = Measurement(cmd.angle, cmd.plane) + + graph = nx.Graph(edges) + graph.add_nodes_from(nodes) + return OpenGraph(graph, self.input_nodes, self.output_nodes, measurements) def get_vops(self, conj: bool = False, include_identity: bool = False) -> dict[int, Clifford]: From 05bf591fba34e1354014a4489cd4168e3c2e90c2 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 18 Nov 2025 09:44:54 +0100 Subject: [PATCH 49/56] Remove test_visualization.py dependence on test_generator and remove generator files --- graphix/generator.py | 189 ------------------------------------ tests/test_generator.py | 156 ----------------------------- tests/test_visualization.py | 72 +++++++++++++- 3 files changed, 71 insertions(+), 346 deletions(-) delete mode 100644 graphix/generator.py delete mode 100644 tests/test_generator.py diff --git a/graphix/generator.py b/graphix/generator.py deleted file mode 100644 index 39cf4b508..000000000 --- a/graphix/generator.py +++ /dev/null @@ -1,189 +0,0 @@ -"""MBQC pattern generator.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import graphix.gflow -from graphix.command import E, M, N, X, Z -from graphix.fundamentals import Plane -from graphix.pattern import Pattern - -if TYPE_CHECKING: - from collections.abc import Iterable, Mapping - from collections.abc import Set as AbstractSet - - import networkx as nx - - from graphix.parameter import ExpressionOrFloat - - -def generate_from_graph( - graph: nx.Graph[int], - angles: Mapping[int, ExpressionOrFloat], - inputs: Iterable[int], - outputs: Iterable[int], - meas_planes: Mapping[int, Plane] | None = None, -) -> Pattern: - r"""Generate the measurement pattern from open graph and measurement angles. - - This function takes an open graph ``G = (nodes, edges, input, outputs)``, - specified by :class:`networkx.Graph` and two lists specifying input and output nodes. - Currently we support XY-plane measurements. - - Searches for the flow in the open graph using :func:`graphix.gflow.find_flow` and if found, - construct the measurement pattern according to the theorem 1 of [NJP 9, 250 (2007)]. - - Then, if no flow was found, searches for gflow using :func:`graphix.gflow.find_gflow`, - from which measurement pattern can be constructed from theorem 2 of [NJP 9, 250 (2007)]. - - Then, if no gflow was found, searches for Pauli flow using :func:`graphix.gflow.find_pauliflow`, - from which measurement pattern can be constructed from theorem 4 of [NJP 9, 250 (2007)]. - - The constructed measurement pattern deterministically realize the unitary embedding - - .. math:: - - U = \left( \prod_i \langle +_{\alpha_i} |_i \right) E_G N_{I^C}, - - where the measurements (bras) with always :math:`\langle+|` bases determined by the measurement - angles :math:`\alpha_i` are applied to the measuring nodes, - i.e. the randomness of the measurement is eliminated by the added byproduct commands. - - .. seealso:: :func:`graphix.gflow.find_flow` :func:`graphix.gflow.find_gflow` :func:`graphix.gflow.find_pauliflow` :class:`graphix.pattern.Pattern` - - Parameters - ---------- - graph : :class:`networkx.Graph` - Graph on which MBQC should be performed - angles : dict - measurement angles for each nodes on the graph (unit of pi), except output nodes - inputs : list - list of node indices for input nodes - outputs : list - list of node indices for output nodes - meas_planes : dict - optional: measurement planes for each nodes on the graph, except output nodes - - Returns - ------- - pattern : graphix.pattern.Pattern - constructed pattern. - """ - inputs_set = set(inputs) - outputs_set = set(outputs) - - measuring_nodes = set(graph.nodes) - outputs_set - - meas_planes = dict.fromkeys(measuring_nodes, Plane.XY) if not meas_planes else dict(meas_planes) - - # search for flow first - f, l_k = graphix.gflow.find_flow(graph, inputs_set, outputs_set, meas_planes=meas_planes) - if f is not None and l_k is not None: - # flow found - pattern = _flow2pattern(graph, angles, inputs, f, l_k) - pattern.reorder_output_nodes(outputs) - return pattern - - # no flow found - we try gflow - g, l_k = graphix.gflow.find_gflow(graph, inputs_set, outputs_set, meas_planes=meas_planes) - if g is not None and l_k is not None: - # gflow found - pattern = _gflow2pattern(graph, angles, inputs, meas_planes, g, l_k) - pattern.reorder_output_nodes(outputs) - return pattern - - # no flow or gflow found - we try pflow - p, l_k = graphix.gflow.find_pauliflow(graph, inputs_set, outputs_set, meas_planes=meas_planes, meas_angles=angles) - if p is not None and l_k is not None: - # pflow found - pattern = _pflow2pattern(graph, angles, inputs, meas_planes, p, l_k) - pattern.reorder_output_nodes(outputs) - return pattern - - raise ValueError("no flow or gflow or pflow found") - - -def _flow2pattern( - graph: nx.Graph[int], - angles: Mapping[int, ExpressionOrFloat], - inputs: Iterable[int], - f: Mapping[int, AbstractSet[int]], - l_k: Mapping[int, int], -) -> Pattern: - """Construct a measurement pattern from a causal flow according to the theorem 1 of [NJP 9, 250 (2007)].""" - depth, layers = graphix.gflow.get_layers(l_k) - pattern = Pattern(input_nodes=inputs) - for i in set(graph.nodes) - set(inputs): - pattern.add(N(node=i)) - for e in graph.edges: - pattern.add(E(nodes=e)) - measured: list[int] = [] - for i in range(depth, 0, -1): # i from depth, depth-1, ... 1 - for j in layers[i]: - measured.append(j) - pattern.add(M(node=j, angle=angles[j])) - neighbors: set[int] = set() - for k in f[j]: - neighbors |= set(graph.neighbors(k)) - for k in neighbors - {j}: - # if k not in measured: - pattern.add(Z(node=k, domain={j})) - (fj,) = f[j] - pattern.add(X(node=fj, domain={j})) - return pattern - - -def _gflow2pattern( - graph: nx.Graph[int], - angles: Mapping[int, ExpressionOrFloat], - inputs: Iterable[int], - meas_planes: Mapping[int, Plane], - g: Mapping[int, AbstractSet[int]], - l_k: Mapping[int, int], -) -> Pattern: - """Construct a measurement pattern from a generalized flow according to the theorem 2 of [NJP 9, 250 (2007)].""" - depth, layers = graphix.gflow.get_layers(l_k) - pattern = Pattern(input_nodes=inputs) - for i in set(graph.nodes) - set(inputs): - pattern.add(N(node=i)) - for e in graph.edges: - pattern.add(E(nodes=e)) - for i in range(depth, 0, -1): # i from depth, depth-1, ... 1 - for j in layers[i]: - pattern.add(M(node=j, plane=meas_planes[j], angle=angles[j])) - odd_neighbors = graphix.gflow.find_odd_neighbor(graph, g[j]) - for k in odd_neighbors - {j}: - pattern.add(Z(node=k, domain={j})) - for k in g[j] - {j}: - pattern.add(X(node=k, domain={j})) - return pattern - - -def _pflow2pattern( - graph: nx.Graph[int], - angles: Mapping[int, ExpressionOrFloat], - inputs: Iterable[int], - meas_planes: Mapping[int, Plane], - p: Mapping[int, AbstractSet[int]], - l_k: Mapping[int, int], -) -> Pattern: - """Construct a measurement pattern from a Pauli flow according to the theorem 4 of [NJP 9, 250 (2007)].""" - depth, layers = graphix.gflow.get_layers(l_k) - pattern = Pattern(input_nodes=inputs) - for i in set(graph.nodes) - set(inputs): - pattern.add(N(node=i)) - for e in graph.edges: - pattern.add(E(nodes=e)) - for i in range(depth, 0, -1): # i from depth, depth-1, ... 1 - for j in layers[i]: - pattern.add(M(node=j, plane=meas_planes[j], angle=angles[j])) - odd_neighbors = graphix.gflow.find_odd_neighbor(graph, p[j]) - future_nodes: set[int] = set.union( - *(nodes for (layer, nodes) in layers.items() if layer < i) - ) # {k | k > j}, with "j" last corrected node and ">" the Pauli flow ordering - for k in odd_neighbors & future_nodes: - pattern.add(Z(node=k, domain={j})) - for k in p[j] & future_nodes: - pattern.add(X(node=k, domain={j})) - return pattern diff --git a/tests/test_generator.py b/tests/test_generator.py deleted file mode 100644 index c9f8989d3..000000000 --- a/tests/test_generator.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import networkx as nx -import numpy as np -import pytest - -from graphix.fundamentals import Plane -from graphix.generator import generate_from_graph -from graphix.gflow import find_gflow, find_pauliflow, pauliflow_from_pattern -from graphix.measurements import Measurement -from graphix.opengraph import OpenGraph -from graphix.random_objects import rand_gate - -if TYPE_CHECKING: - from collections.abc import Callable - - from numpy.random import Generator - - from graphix import Pattern - - -def example_flow(rng: Generator) -> Pattern: - graph: nx.Graph[int] = nx.Graph([(0, 3), (1, 4), (2, 5), (1, 3), (2, 4), (3, 6), (4, 7), (5, 8)]) - inputs = [1, 0, 2] # non-trivial order to check order is conserved. - outputs = [7, 6, 8] - angles = dict(zip(range(6), (2 * rng.random(6)).tolist())) - meas_planes = dict.fromkeys(range(6), Plane.XY) - - pattern = generate_from_graph(graph, angles, inputs, outputs, meas_planes=meas_planes) - pattern.standardize() - - assert pattern.input_nodes == inputs - assert pattern.output_nodes == outputs - return pattern - - -def example_gflow(rng: Generator) -> Pattern: - graph: nx.Graph[int] = nx.Graph([(1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (3, 6), (1, 6)]) - inputs = [3, 1, 5] - outputs = [4, 2, 6] - angles = dict(zip([1, 3, 5], (2 * rng.random(3)).tolist())) - meas_planes = dict.fromkeys([1, 3, 5], Plane.XY) - - pattern = generate_from_graph(graph, angles, inputs, outputs, meas_planes=meas_planes) - pattern.standardize() - - assert pattern.input_nodes == inputs - assert pattern.output_nodes == outputs - return pattern - - -def example_graph_pflow(rng: Generator) -> OpenGraph: - """Create a graph which has pflow but no gflow. - - Parameters - ---------- - rng : :class:`numpy.random.Generator` - See graphix.tests.conftest.py - - Returns - ------- - OpenGraph: :class:`graphix.opengraph.OpenGraph` - """ - graph: nx.Graph[int] = nx.Graph( - [(0, 2), (1, 4), (2, 3), (3, 4), (2, 5), (3, 6), (4, 7), (5, 6), (6, 7), (5, 8), (7, 9)] - ) - inputs = [1, 0] - outputs = [9, 8] - - # Heuristic mixture of Pauli and non-Pauli angles ensuring there's no gflow but there's pflow. - meas_angles: dict[int, float] = { - **dict.fromkeys(range(4), 0), - **dict(zip(range(4, 8), (2 * rng.random(4)).tolist())), - } - meas_planes = dict.fromkeys(range(8), Plane.XY) - meas = {i: Measurement(angle, plane) for (i, angle), plane in zip(meas_angles.items(), meas_planes.values())} - - gf, _ = find_gflow(graph=graph, iset=set(inputs), oset=set(outputs), meas_planes=meas_planes) - pf, _ = find_pauliflow( - graph=graph, iset=set(inputs), oset=set(outputs), meas_planes=meas_planes, meas_angles=meas_angles - ) - - assert gf is None # example graph doesn't have gflow - assert pf is not None # example graph has Pauli flow - - return OpenGraph(inside=graph, inputs=inputs, outputs=outputs, measurements=meas) - - -def example_pflow(rng: Generator) -> Pattern: - og = example_graph_pflow(rng) - pattern = og.to_pattern() - pattern.standardize() - assert og.inputs == pattern.input_nodes - assert og.outputs == pattern.output_nodes - return pattern - - -class TestGenerator: - @pytest.mark.parametrize("example", [example_flow, example_gflow, example_pflow]) - def test_pattern_generation_determinism(self, example: Callable[[Generator], Pattern], fx_rng: Generator) -> None: - pattern = example(fx_rng) - pattern.minimize_space() - - repeats = 3 # for testing the determinism of a pattern - results = [pattern.simulate_pattern(rng=fx_rng) for _ in range(repeats)] - - for i in range(1, 3): - inner_product = np.dot(results[0].flatten(), results[i].flatten().conjugate()) - assert abs(inner_product) == pytest.approx(1) - - def test_pattern_generation_flow(self, fx_rng: Generator) -> None: - nqubits = 3 - depth = 2 - pairs = [(0, 1), (1, 2)] - circuit = rand_gate(nqubits, depth, pairs, fx_rng) - # transpile into graph - pattern = circuit.transpile().pattern - pattern.standardize() - pattern.shift_signals() - # get the graph and generate pattern again with flow algorithm - graph = pattern.extract_graph() - input_list = [0, 1, 2] - angles: dict[int, float] = {} - for cmd in pattern.extract_measurement_commands(): - assert isinstance(cmd.angle, float) - angles[cmd.node] = float(cmd.angle) - meas_planes = pattern.get_meas_plane() - pattern2 = generate_from_graph(graph, angles, input_list, pattern.output_nodes, meas_planes) - # check that the new one runs and returns correct result - pattern2.standardize() - pattern2.shift_signals() - pattern2.minimize_space() - state = circuit.simulate_statevector().statevec - state_mbqc = pattern2.simulate_pattern(rng=fx_rng) - assert np.abs(np.dot(state_mbqc.flatten().conjugate(), state.flatten())) == pytest.approx(1) - - def test_pattern_generation_no_internal_nodes(self) -> None: - g: nx.Graph[int] = nx.Graph() - g.add_edges_from([(0, 1), (1, 2)]) - pattern = generate_from_graph(g, {}, {0, 1, 2}, {0, 1, 2}, {}) - graph = pattern.extract_graph() - graph_ref = nx.Graph(((0, 1), (1, 2))) - assert nx.utils.graphs_equal(graph, graph_ref) - - def test_pattern_generation_pflow(self, fx_rng: Generator) -> None: - og = example_graph_pflow(fx_rng) - pattern = og.to_pattern() - - graph_generated_pattern = pattern.extract_graph() - assert nx.is_isomorphic(og.inside, graph_generated_pattern) - - pattern.standardize() - pf_generated_pattern, _ = pauliflow_from_pattern(pattern) - assert pf_generated_pattern is not None diff --git a/tests/test_visualization.py b/tests/test_visualization.py index a56045653..e6e87e7cc 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -6,11 +6,14 @@ from typing import TYPE_CHECKING import matplotlib.pyplot as plt +import networkx as nx import pytest from graphix import Circuit, Pattern, command, gflow, visualization +from graphix.fundamentals import Plane +from graphix.measurements import Measurement +from graphix.opengraph import OpenGraph from graphix.visualization import GraphVisualizer -from tests.test_generator import example_flow, example_gflow, example_pflow if TYPE_CHECKING: from collections.abc import Callable @@ -19,6 +22,73 @@ from numpy.random import Generator +def example_flow(rng: Generator) -> Pattern: + graph: nx.Graph[int] = nx.Graph([(0, 3), (1, 4), (2, 5), (1, 3), (2, 4), (3, 6), (4, 7), (5, 8)]) + inputs = [1, 0, 2] # non-trivial order to check order is conserved. + outputs = [7, 6, 8] + angles = (2 * rng.random(6)).tolist() + measurements = {node: Measurement(angle, Plane.XY) for node, angle in enumerate(angles)} + + pattern = OpenGraph(graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=measurements).to_pattern() + pattern.standardize() + + assert pattern.input_nodes == inputs + assert pattern.output_nodes == outputs + return pattern + + +def example_gflow(rng: Generator) -> Pattern: + graph: nx.Graph[int] = nx.Graph([(1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (3, 6), (1, 6)]) + inputs = [3, 1, 5] + outputs = [4, 2, 6] + angles = dict(zip([1, 3, 5], (2 * rng.random(3)).tolist())) + measurements = {node: Measurement(angle, Plane.XY) for node, angle in angles.items()} + + pattern = OpenGraph(graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=measurements).to_pattern() + pattern.standardize() + + assert pattern.input_nodes == inputs + assert pattern.output_nodes == outputs + return pattern + + +def example_pflow(rng: Generator) -> Pattern: + """Create a graph which has pflow but no gflow. + + Parameters + ---------- + rng : :class:`numpy.random.Generator` + See graphix.tests.conftest.py + + Returns + ------- + Pattern: :class:`graphix.pattern.Pattern` + """ + graph: nx.Graph[int] = nx.Graph( + [(0, 2), (1, 4), (2, 3), (3, 4), (2, 5), (3, 6), (4, 7), (5, 6), (6, 7), (5, 8), (7, 9)] + ) + inputs = [1, 0] + outputs = [9, 8] + + # Heuristic mixture of Pauli and non-Pauli angles ensuring there's no gflow but there's pflow. + meas_angles: dict[int, float] = { + **dict.fromkeys(range(4), 0), + **dict(zip(range(4, 8), (2 * rng.random(4)).tolist())), + } + measurements = {i: Measurement(angle, Plane.XY) for i, angle in meas_angles.items()} + + og = OpenGraph(graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=measurements) + + assert og.find_gflow() is None # example graph doesn't have gflow + assert og.find_pauli_flow() is not None # example graph has Pauli flow + + pattern = og.to_pattern() + pattern.standardize() + assert og.input_nodes == pattern.input_nodes + assert og.output_nodes == pattern.output_nodes + return pattern + + def test_get_pos_from_flow() -> None: circuit = Circuit(1) circuit.h(0) From 06146333075568cba90ddf3fcc1d2b653f4ffc70 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 18 Nov 2025 11:44:57 +0100 Subject: [PATCH 50/56] Replace by exceptions in finding flow methods --- graphix/opengraph.py | 74 ++++++++++++++++++++++++------------- tests/test_opengraph.py | 26 +++++-------- tests/test_visualization.py | 8 ++-- 3 files changed, 63 insertions(+), 45 deletions(-) diff --git a/graphix/opengraph.py b/graphix/opengraph.py index f2cb05998..b497e5434 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -132,13 +132,11 @@ def to_pattern(self: OpenGraph[Measurement]) -> Pattern: ---------- [1] Browne et al., NJP 9, 250 (2007) """ - cflow = self.find_causal_flow() - if cflow is not None: - return cflow.to_corrections().to_pattern() - - pflow = self.find_pauli_flow() - if pflow is not None: - return pflow.to_corrections().to_pattern() + for extractor in (self.extract_causal_flow, self.extract_pauli_flow): + try: + return extractor().to_corrections().to_pattern() + except OpenGraphError: # noqa: PERF203 + continue raise OpenGraphError("The open graph does not have flow. It does not support a deterministic pattern.") @@ -178,13 +176,18 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: odd_neighbors_set ^= self.neighbors([node]) return odd_neighbors_set - def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: + def extract_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co]: """Return a causal flow on the open graph if it exists. Returns ------- - CausalFlow | None - A causal flow object if the open graph has causal flow, `None` otherwise. + CausalFlow[_PM_co] + A causal flow object if the open graph has causal flow. + + Raises + ------ + OpenGraphError + If the open graph does not have a causal flow. Notes ----- @@ -195,19 +198,27 @@ def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: ---------- [1] Mhalla and Perdrix, (2008), Finding Optimal Flows Efficiently, doi.org/10.1007/978-3-540-70575-8_70 """ - return find_cflow(self) + cf = find_cflow(self) + if cf is None: + raise OpenGraphError("The open graph does not have a causal flow.") + return cf - def find_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: + def extract_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co]: r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. Returns ------- - GFlow | None - A gflow object if the open graph has gflow, `None` otherwise. + GFlow[_PM_co] + A gflow object if the open graph has gflow. + + Raises + ------ + OpenGraphError + If the open graph does not have a gflow. Notes ----- - - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`OpenGraph.find_pauli_flow`. + - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`OpenGraph.extract_pauli_flow`. - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. References @@ -217,22 +228,30 @@ def find_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: aog = PlanarAlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: - return None - return GFlow.from_correction_matrix( + raise OpenGraphError("The open graph does not have a gflow.") + gf = GFlow.from_correction_matrix( correction_matrix - ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. + ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. + if gf is None: + raise OpenGraphError("The open graph does not have a gflow.") + return gf - def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: + def extract_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co]: r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. Returns ------- - PauliFlow | None - A Pauli flow object if the open graph has Pauli flow, `None` otherwise. + PauliFlow[_M_co] + A Pauli flow object if the open graph has Pauli flow. + + Raises + ------ + OpenGraphError + If the open graph does not have a Pauli flow. Notes ----- - - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`OpenGraph.find_gflow`. + - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`OpenGraph.extract_gflow`. - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. References @@ -242,10 +261,13 @@ def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: aog = AlgebraicOpenGraph(self) correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: - return None - return PauliFlow.from_correction_matrix( + raise OpenGraphError("The open graph does not have a Pauli flow.") + pf = PauliFlow.from_correction_matrix( correction_matrix - ) # The constructor can return `None` if the correction matrix is not compatible with any partial order on the open graph. + ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. + if pf is None: + raise OpenGraphError("The open graph does not have a Pauli flow.") + return pf # TODO: Generalise `compose` to any type of OpenGraph def compose( @@ -324,4 +346,4 @@ def merge_ports(p1: Iterable[int], p2: Iterable[int]) -> list[int]: class OpenGraphError(Exception): - """Exception subclass to handle incorrect open graphs.""" + """Exception subclass to handle open graphs errors.""" diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index 37a9c30e9..82419df1d 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -15,7 +15,7 @@ from graphix.command import E from graphix.fundamentals import Plane from graphix.measurements import Measurement -from graphix.opengraph import OpenGraph +from graphix.opengraph import OpenGraph, OpenGraphError from graphix.pattern import Pattern from graphix.random_objects import rand_circuit from graphix.states import PlanarState @@ -559,40 +559,34 @@ def test_neighbors(self) -> None: def test_cflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: og = test_case.og - cflow = og.find_causal_flow() - if test_case.has_cflow: - assert cflow is not None - pattern = cflow.to_corrections().to_pattern() + pattern = og.extract_causal_flow().to_corrections().to_pattern() assert check_determinism(pattern, fx_rng) else: - assert cflow is None + with pytest.raises(OpenGraphError, match=r"The open graph does not have a causal flow."): + og.extract_causal_flow() @pytest.mark.parametrize("test_case", prepare_test_og_flow()) def test_gflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: og = test_case.og - gflow = og.find_gflow() - if test_case.has_gflow: - assert gflow is not None - pattern = gflow.to_corrections().to_pattern() + pattern = og.extract_gflow().to_corrections().to_pattern() assert check_determinism(pattern, fx_rng) else: - assert gflow is None + with pytest.raises(OpenGraphError, match=r"The open graph does not have a gflow."): + og.extract_gflow() @pytest.mark.parametrize("test_case", prepare_test_og_flow()) def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: og = test_case.og - pflow = og.find_pauli_flow() - if test_case.has_pflow: - assert pflow is not None - pattern = pflow.to_corrections().to_pattern() + pattern = og.extract_pauli_flow().to_corrections().to_pattern() assert check_determinism(pattern, fx_rng) else: - assert pflow is None + with pytest.raises(OpenGraphError, match=r"The open graph does not have a Pauli flow."): + og.extract_pauli_flow() def test_double_entanglement(self) -> None: pattern = Pattern(input_nodes=[0, 1], cmds=[E((0, 1)), E((0, 1))]) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index e6e87e7cc..d74ed359e 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -12,7 +12,7 @@ from graphix import Circuit, Pattern, command, gflow, visualization from graphix.fundamentals import Plane from graphix.measurements import Measurement -from graphix.opengraph import OpenGraph +from graphix.opengraph import OpenGraph, OpenGraphError from graphix.visualization import GraphVisualizer if TYPE_CHECKING: @@ -79,8 +79,10 @@ def example_pflow(rng: Generator) -> Pattern: og = OpenGraph(graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=measurements) - assert og.find_gflow() is None # example graph doesn't have gflow - assert og.find_pauli_flow() is not None # example graph has Pauli flow + try: + og.extract_gflow() # example graph doesn't have gflow + except OpenGraphError: + og.extract_pauli_flow() # example graph has Pauli flow pattern = og.to_pattern() pattern.standardize() From c4111fb7b35e5742be71525986d150fa477d1a66 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 18 Nov 2025 17:28:58 +0100 Subject: [PATCH 51/56] Add Thierry's comments --- graphix/fundamentals.py | 1 + tests/test_opengraph.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/graphix/fundamentals.py b/graphix/fundamentals.py index da94cb33a..2b0733d1d 100644 --- a/graphix/fundamentals.py +++ b/graphix/fundamentals.py @@ -379,6 +379,7 @@ def to_plane_or_axis(self) -> Plane: """ return self + @override def to_plane(self) -> Plane: """Return the plane. diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index 82419df1d..dda1aec6f 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -521,19 +521,19 @@ def prepare_test_og_flow() -> list[OpenGraphFlowTestCase]: def check_determinism(pattern: Pattern, fx_rng: Generator, n_shots: int = 3) -> bool: """Verify if the input pattern is deterministic.""" - results = [] - for plane in {Plane.XY, Plane.XZ, Plane.YZ}: alpha = 2 * np.pi * fx_rng.random() state_ref = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) for _ in range(n_shots): state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) - results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) + result = np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten())) - avg = sum(results) / (n_shots * 3) + if result: + continue + return False - return bool(avg == pytest.approx(1)) + return True class TestOpenGraph: @@ -602,16 +602,11 @@ def test_from_to_pattern(self, fx_rng: Generator) -> None: pattern_ref = circuit.transpile().pattern pattern = pattern_ref.extract_opengraph().to_pattern() - results = [] - for plane in {Plane.XY, Plane.XZ, Plane.YZ}: alpha = 2 * np.pi * fx_rng.random() state_ref = pattern_ref.simulate_pattern(input_state=PlanarState(plane, alpha)) state = pattern.simulate_pattern(input_state=PlanarState(plane, alpha)) - results.append(np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten()))) - - avg = sum(results) / 3 - assert avg == pytest.approx(1) + assert np.abs(np.dot(state.flatten().conjugate(), state_ref.flatten())) == pytest.approx(1) # TODO: Add test `OpenGraph.is_close` From 0c2125652c2a78edca1afda16aaba032ffa634b0 Mon Sep 17 00:00:00 2001 From: matulni Date: Tue, 18 Nov 2025 18:06:56 +0100 Subject: [PATCH 52/56] Add find_flow functions which return None --- graphix/flow/_find_gpflow.py | 64 ++++++++++++++++++++++++++++++++++++ graphix/opengraph.py | 31 +++++------------ 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index 65e3ac917..ec6ac4c82 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -19,6 +19,7 @@ import numpy as np from typing_extensions import override +import graphix.flow.core as flw # To avoid circular imports from graphix._linalg import MatGF2, solve_f2_linear_system from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.sim.base_backend import NodeIndex @@ -26,6 +27,7 @@ if TYPE_CHECKING: from collections.abc import Set as AbstractSet + from graphix.flow.core import GFlow, PauliFlow from graphix.opengraph import OpenGraph @@ -668,3 +670,65 @@ def compute_correction_matrix(aog: AlgebraicOpenGraph[_M_co]) -> CorrectionMatri return None return CorrectionMatrix(aog, correction_matrix) + + +def find_gflow(og: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: + r"""Return a maximally delayed generalised flow (gflow) on an open graph if it exists. + + Parameters + ---------- + og : OpenGraph[_PM_co] + The input open graph. + + Returns + ------- + GFlow[_PM_co] | None + A gflow object if the open graph has gflow or ``None`` otherwise. + + Notes + ----- + - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`graphix.flow._find_gpflow.find_pflow`. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + aog = PlanarAlgebraicOpenGraph(og) + correction_matrix = compute_correction_matrix(aog) + if correction_matrix is None: + return None + return flw.GFlow.from_correction_matrix( + correction_matrix + ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. + + +def find_pflow(og: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: + r"""Return a maximally delayed Pauli flow on the open graph if it exists. + + Parameters + ---------- + og : OpenGraph[_M_co] + The input open graph. + + Returns + ------- + PauliFlow[_M_co] | None + A Pauli flow object if the open graph has Pauli flow or ``None`` otherwise. + + Notes + ----- + - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`graphix.flow._find_gpflow.find_gflow`. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + aog = AlgebraicOpenGraph(og) + correction_matrix = compute_correction_matrix(aog) + if correction_matrix is None: + return None + return flw.PauliFlow.from_correction_matrix( + correction_matrix + ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. diff --git a/graphix/opengraph.py b/graphix/opengraph.py index b497e5434..412f4ab67 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -8,13 +8,13 @@ import networkx as nx from graphix.flow._find_cflow import find_cflow -from graphix.flow._find_gpflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix -from graphix.flow.core import CausalFlow, GFlow, PauliFlow +from graphix.flow._find_gpflow import find_gflow, find_pflow from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement if TYPE_CHECKING: from collections.abc import Collection, Iterable, Mapping, Sequence + from graphix.flow.core import CausalFlow, GFlow, PauliFlow from graphix.measurements import Measurement from graphix.pattern import Pattern @@ -132,11 +132,10 @@ def to_pattern(self: OpenGraph[Measurement]) -> Pattern: ---------- [1] Browne et al., NJP 9, 250 (2007) """ - for extractor in (self.extract_causal_flow, self.extract_pauli_flow): - try: - return extractor().to_corrections().to_pattern() - except OpenGraphError: # noqa: PERF203 - continue + for extractor in (find_cflow, find_pflow): + flow = extractor(self) + if flow is not None: + return flow.to_corrections().to_pattern() raise OpenGraphError("The open graph does not have flow. It does not support a deterministic pattern.") @@ -225,19 +224,13 @@ def extract_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co]: ---------- [1] Mitosek and Backens, 2024 (arXiv:2410.23439). """ - aog = PlanarAlgebraicOpenGraph(self) - correction_matrix = compute_correction_matrix(aog) - if correction_matrix is None: - raise OpenGraphError("The open graph does not have a gflow.") - gf = GFlow.from_correction_matrix( - correction_matrix - ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. + gf = find_gflow(self) if gf is None: raise OpenGraphError("The open graph does not have a gflow.") return gf def extract_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co]: - r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. + r"""Return a maximally delayed Pauli on the open graph if it exists. Returns ------- @@ -258,13 +251,7 @@ def extract_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co]: ---------- [1] Mitosek and Backens, 2024 (arXiv:2410.23439). """ - aog = AlgebraicOpenGraph(self) - correction_matrix = compute_correction_matrix(aog) - if correction_matrix is None: - raise OpenGraphError("The open graph does not have a Pauli flow.") - pf = PauliFlow.from_correction_matrix( - correction_matrix - ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. + pf = find_pflow(self) if pf is None: raise OpenGraphError("The open graph does not have a Pauli flow.") return pf From 339ba1c0888d5864cd4eb3f1498f8ce72eda9435 Mon Sep 17 00:00:00 2001 From: Thierry Martinez Date: Tue, 18 Nov 2025 14:55:52 +0100 Subject: [PATCH 53/56] Registry pattern for flow test cases --- tests/test_opengraph.py | 45 ++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/test_opengraph.py b/tests/test_opengraph.py index dda1aec6f..114f1a3ac 100644 --- a/tests/test_opengraph.py +++ b/tests/test_opengraph.py @@ -21,6 +21,8 @@ from graphix.states import PlanarState if TYPE_CHECKING: + from typing import Callable + from numpy.random import Generator @@ -31,6 +33,17 @@ class OpenGraphFlowTestCase(NamedTuple): has_pflow: bool +OPEN_GRAPH_FLOW_TEST_CASES: list[OpenGraphFlowTestCase] = [] + + +def register_open_graph_flow_test_case( + test_case: Callable[[], OpenGraphFlowTestCase], +) -> Callable[[], OpenGraphFlowTestCase]: + OPEN_GRAPH_FLOW_TEST_CASES.append(test_case()) + return test_case + + +@register_open_graph_flow_test_case def _og_0() -> OpenGraphFlowTestCase: """Generate open graph. @@ -48,6 +61,7 @@ def _og_0() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_1() -> OpenGraphFlowTestCase: """Generate open graph. @@ -70,6 +84,7 @@ def _og_1() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_2() -> OpenGraphFlowTestCase: """Generate open graph. @@ -93,6 +108,7 @@ def _og_2() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_3() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -121,6 +137,7 @@ def _og_3() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_4() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -146,6 +163,7 @@ def _og_4() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_5() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -169,6 +187,7 @@ def _og_5() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) +@register_open_graph_flow_test_case def _og_6() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -194,6 +213,7 @@ def _og_6() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) +@register_open_graph_flow_test_case def _og_7() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -213,6 +233,7 @@ def _og_7() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) +@register_open_graph_flow_test_case def _og_8() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -238,6 +259,7 @@ def _og_8() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) +@register_open_graph_flow_test_case def _og_9() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -263,6 +285,7 @@ def _og_9() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_10() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -294,6 +317,7 @@ def _og_10() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) +@register_open_graph_flow_test_case def _og_11() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -319,6 +343,7 @@ def _og_11() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=True, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_12() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -337,6 +362,7 @@ def _og_12() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) +@register_open_graph_flow_test_case def _og_13() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -363,6 +389,7 @@ def _og_13() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_14() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -381,6 +408,7 @@ def _og_14() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=True, has_pflow=True) +@register_open_graph_flow_test_case def _og_15() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -407,6 +435,7 @@ def _og_15() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) +@register_open_graph_flow_test_case def _og_16() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -425,6 +454,7 @@ def _og_16() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) +@register_open_graph_flow_test_case def _og_17() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -458,6 +488,7 @@ def _og_17() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) +@register_open_graph_flow_test_case def _og_18() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -483,6 +514,7 @@ def _og_18() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=False) +@register_open_graph_flow_test_case def _og_19() -> OpenGraphFlowTestCase: r"""Generate open graph. @@ -512,13 +544,6 @@ def _og_19() -> OpenGraphFlowTestCase: return OpenGraphFlowTestCase(og, has_cflow=False, has_gflow=False, has_pflow=True) -def prepare_test_og_flow() -> list[OpenGraphFlowTestCase]: - n_og_samples = 20 - test_cases: list[OpenGraphFlowTestCase] = [globals()[f"_og_{i}"]() for i in range(n_og_samples)] - - return test_cases - - def check_determinism(pattern: Pattern, fx_rng: Generator, n_shots: int = 3) -> bool: """Verify if the input pattern is deterministic.""" for plane in {Plane.XY, Plane.XZ, Plane.YZ}: @@ -555,7 +580,7 @@ def test_neighbors(self) -> None: assert og.neighbors([1, 2, 3]) == {0, 1, 2, 3, 4} assert og.neighbors([]) == set() - @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + @pytest.mark.parametrize("test_case", OPEN_GRAPH_FLOW_TEST_CASES) def test_cflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: og = test_case.og @@ -566,7 +591,7 @@ def test_cflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> Non with pytest.raises(OpenGraphError, match=r"The open graph does not have a causal flow."): og.extract_causal_flow() - @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + @pytest.mark.parametrize("test_case", OPEN_GRAPH_FLOW_TEST_CASES) def test_gflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: og = test_case.og @@ -577,7 +602,7 @@ def test_gflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> Non with pytest.raises(OpenGraphError, match=r"The open graph does not have a gflow."): og.extract_gflow() - @pytest.mark.parametrize("test_case", prepare_test_og_flow()) + @pytest.mark.parametrize("test_case", OPEN_GRAPH_FLOW_TEST_CASES) def test_pflow(self, test_case: OpenGraphFlowTestCase, fx_rng: Generator) -> None: og = test_case.og From 04d97f813ce72f9efd3d421ddd7d32cfc570937b Mon Sep 17 00:00:00 2001 From: matulni Date: Wed, 19 Nov 2025 00:23:21 +0100 Subject: [PATCH 54/56] Add find_flow methods to OpenGraph --- graphix/flow/_find_gpflow.py | 64 ----------------- graphix/opengraph.py | 133 +++++++++++++++++++++++++++-------- 2 files changed, 103 insertions(+), 94 deletions(-) diff --git a/graphix/flow/_find_gpflow.py b/graphix/flow/_find_gpflow.py index ec6ac4c82..65e3ac917 100644 --- a/graphix/flow/_find_gpflow.py +++ b/graphix/flow/_find_gpflow.py @@ -19,7 +19,6 @@ import numpy as np from typing_extensions import override -import graphix.flow.core as flw # To avoid circular imports from graphix._linalg import MatGF2, solve_f2_linear_system from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement, Axis, Plane from graphix.sim.base_backend import NodeIndex @@ -27,7 +26,6 @@ if TYPE_CHECKING: from collections.abc import Set as AbstractSet - from graphix.flow.core import GFlow, PauliFlow from graphix.opengraph import OpenGraph @@ -670,65 +668,3 @@ def compute_correction_matrix(aog: AlgebraicOpenGraph[_M_co]) -> CorrectionMatri return None return CorrectionMatrix(aog, correction_matrix) - - -def find_gflow(og: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: - r"""Return a maximally delayed generalised flow (gflow) on an open graph if it exists. - - Parameters - ---------- - og : OpenGraph[_PM_co] - The input open graph. - - Returns - ------- - GFlow[_PM_co] | None - A gflow object if the open graph has gflow or ``None`` otherwise. - - Notes - ----- - - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`graphix.flow._find_gpflow.find_pflow`. - - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. - - References - ---------- - [1] Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - aog = PlanarAlgebraicOpenGraph(og) - correction_matrix = compute_correction_matrix(aog) - if correction_matrix is None: - return None - return flw.GFlow.from_correction_matrix( - correction_matrix - ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. - - -def find_pflow(og: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: - r"""Return a maximally delayed Pauli flow on the open graph if it exists. - - Parameters - ---------- - og : OpenGraph[_M_co] - The input open graph. - - Returns - ------- - PauliFlow[_M_co] | None - A Pauli flow object if the open graph has Pauli flow or ``None`` otherwise. - - Notes - ----- - - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`graphix.flow._find_gpflow.find_gflow`. - - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. - - References - ---------- - [1] Mitosek and Backens, 2024 (arXiv:2410.23439). - """ - aog = AlgebraicOpenGraph(og) - correction_matrix = compute_correction_matrix(aog) - if correction_matrix is None: - return None - return flw.PauliFlow.from_correction_matrix( - correction_matrix - ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. diff --git a/graphix/opengraph.py b/graphix/opengraph.py index 412f4ab67..ed5cdc6b7 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -8,13 +8,14 @@ import networkx as nx from graphix.flow._find_cflow import find_cflow -from graphix.flow._find_gpflow import find_gflow, find_pflow +from graphix.flow._find_gpflow import AlgebraicOpenGraph, PlanarAlgebraicOpenGraph, compute_correction_matrix +from graphix.flow.core import GFlow, PauliFlow from graphix.fundamentals import AbstractMeasurement, AbstractPlanarMeasurement if TYPE_CHECKING: from collections.abc import Collection, Iterable, Mapping, Sequence - from graphix.flow.core import CausalFlow, GFlow, PauliFlow + from graphix.flow.core import CausalFlow from graphix.measurements import Measurement from graphix.pattern import Pattern @@ -132,8 +133,8 @@ def to_pattern(self: OpenGraph[Measurement]) -> Pattern: ---------- [1] Browne et al., NJP 9, 250 (2007) """ - for extractor in (find_cflow, find_pflow): - flow = extractor(self) + for extractor in (self.find_causal_flow, self.find_pauli_flow): + flow = extractor() if flow is not None: return flow.to_corrections().to_pattern() @@ -176,7 +177,9 @@ def odd_neighbors(self, nodes: Collection[int]) -> set[int]: return odd_neighbors_set def extract_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co]: - """Return a causal flow on the open graph if it exists. + """Try to extract a causal flow on the open graph. + + This method is a wrapper over :func:`OpenGraph.find_causal_flow` with a single return type. Returns ------- @@ -188,22 +191,19 @@ def extract_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co]: OpenGraphError If the open graph does not have a causal flow. - Notes - ----- - - The open graph instance must be of parametric type `Measurement` or `Plane` since the causal flow is only defined on open graphs with :math:`XY` measurements. - - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^2)`. - - References - ---------- - [1] Mhalla and Perdrix, (2008), Finding Optimal Flows Efficiently, doi.org/10.1007/978-3-540-70575-8_70 + See Also + -------- + :func:`OpenGraph.find_causal_flow` """ - cf = find_cflow(self) + cf = self.find_causal_flow() if cf is None: raise OpenGraphError("The open graph does not have a causal flow.") return cf def extract_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co]: - r"""Return a maximally delayed generalised flow (gflow) on the open graph if it exists. + r"""Try to extract a maximally delayed generalised flow (gflow) on the open graph. + + This method is a wrapper over :func:`OpenGraph.find_gflow` with a single return type. Returns ------- @@ -215,22 +215,19 @@ def extract_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co]: OpenGraphError If the open graph does not have a gflow. - Notes - ----- - - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`OpenGraph.extract_pauli_flow`. - - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. - - References - ---------- - [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + See Also + -------- + :func:`OpenGraph.find_gflow` """ - gf = find_gflow(self) + gf = self.find_gflow() if gf is None: raise OpenGraphError("The open graph does not have a gflow.") return gf def extract_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co]: - r"""Return a maximally delayed Pauli on the open graph if it exists. + r"""Try to extract a maximally delayed Pauli on the open graph. + + This method is a wrapper over :func:`OpenGraph.find_pauli_flow` with a single return type. Returns ------- @@ -242,19 +239,95 @@ def extract_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co]: OpenGraphError If the open graph does not have a Pauli flow. + See Also + -------- + :func:`OpenGraph.find_pauli_flow` + """ + pf = self.find_pauli_flow() + if pf is None: + raise OpenGraphError("The open graph does not have a Pauli flow.") + return pf + + def find_causal_flow(self: OpenGraph[_PM_co]) -> CausalFlow[_PM_co] | None: + """Return a causal flow on the open graph if it exists. + + Returns + ------- + CausalFlow[_PM_co] | None + A causal flow object if the open graph has causal flow or ``None`` otherwise. + + See Also + -------- + :func:`OpenGraph.extract_causal_flow` + + Notes + ----- + - The open graph instance must be of parametric type `Measurement` or `Plane` since the causal flow is only defined on open graphs with :math:`XY` measurements. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^2)`. + + References + ---------- + [1] Mhalla and Perdrix, (2008), Finding Optimal Flows Efficiently, doi.org/10.1007/978-3-540-70575-8_70 + """ + return find_cflow(self) + + def find_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: + r"""Return a maximally delayed Pauli on the open graph if it exists. + + Returns + ------- + GFlow[_PM_co] | None + A gflow object if the open graph has gflow or ``None`` otherwise. + + See Also + -------- + :func:`OpenGraph.extract_gflow` + Notes ----- - - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`OpenGraph.extract_gflow`. + - The open graph instance must be of parametric type `Measurement` or `Plane` since the gflow is only defined on open graphs with planar measurements. Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Plane` instances, in contrast with :func:`OpenGraph.find_pauli_flow`. - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. References ---------- [1] Mitosek and Backens, 2024 (arXiv:2410.23439). """ - pf = find_pflow(self) - if pf is None: - raise OpenGraphError("The open graph does not have a Pauli flow.") - return pf + aog = PlanarAlgebraicOpenGraph(self) + correction_matrix = compute_correction_matrix(aog) + if correction_matrix is None: + return None + return GFlow.from_correction_matrix( + correction_matrix + ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. + + def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: + r"""Return a maximally delayed Pauli on the open graph if it exists. + + Returns + ------- + PauliFlow[_M_co] | None + A Pauli flow object if the open graph has Pauli flow or ``None`` otherwise. + + See Also + -------- + :func:`OpenGraph.extract_pauli_flow` + + Notes + ----- + - Measurement instances with a Pauli angle (integer multiple of :math:`\pi/2`) are interpreted as `Axis` instances, in contrast with :func:`OpenGraph.find_gflow`. + - This function implements the algorithm presented in Ref. [1] with polynomial complexity on the number of nodes, :math:`O(N^3)`. + + References + ---------- + [1] Mitosek and Backens, 2024 (arXiv:2410.23439). + """ + aog = AlgebraicOpenGraph(self) + correction_matrix = compute_correction_matrix(aog) + if correction_matrix is None: + return None + return PauliFlow.from_correction_matrix( + correction_matrix + ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. # TODO: Generalise `compose` to any type of OpenGraph def compose( From e6a69e5d221564cdbf1212575bef7d974526150f Mon Sep 17 00:00:00 2001 From: matulni Date: Thu, 20 Nov 2025 13:56:16 +0100 Subject: [PATCH 55/56] Rename from_correction_matrix as try_from_correction_matrix since it can return None --- graphix/flow/core.py | 4 ++-- graphix/opengraph.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index e0203a5c5..94c88008c 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -288,7 +288,7 @@ class PauliFlow(Generic[_M_co]): partial_order_layers: Sequence[AbstractSet[int]] @classmethod - def from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_M_co]) -> Self | None: + def try_from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_M_co]) -> Self | None: """Initialize a Pauli flow object from a matrix encoding a correction function. Attributes @@ -411,7 +411,7 @@ class CausalFlow(GFlow[_PM_co], Generic[_PM_co]): @override @classmethod - def from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_PM_co]) -> None: + def try_from_correction_matrix(cls, correction_matrix: CorrectionMatrix[_PM_co]) -> None: raise NotImplementedError("Initialization of a causal flow from a correction matrix is not supported.") @override diff --git a/graphix/opengraph.py b/graphix/opengraph.py index ed5cdc6b7..540aab3d2 100644 --- a/graphix/opengraph.py +++ b/graphix/opengraph.py @@ -296,7 +296,7 @@ def find_gflow(self: OpenGraph[_PM_co]) -> GFlow[_PM_co] | None: correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: return None - return GFlow.from_correction_matrix( + return GFlow.try_from_correction_matrix( correction_matrix ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. @@ -325,7 +325,7 @@ def find_pauli_flow(self: OpenGraph[_M_co]) -> PauliFlow[_M_co] | None: correction_matrix = compute_correction_matrix(aog) if correction_matrix is None: return None - return PauliFlow.from_correction_matrix( + return PauliFlow.try_from_correction_matrix( correction_matrix ) # The constructor returns `None` if the correction matrix is not compatible with any partial order on the open graph. From 9adb729ecbcf8eb1d879426b4a6c1a65015a7c7f Mon Sep 17 00:00:00 2001 From: matulni Date: Fri, 21 Nov 2025 17:24:15 +0100 Subject: [PATCH 56/56] Up CHANGELOG.md --- CHANGELOG.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe318797e..1de6d275f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added - +- #358: Refactor of flow tools - Part I + - New module `graphix.flow.core` which introduces classes `PauliFlow`, `GFlow`, `CausalFlow` and `XZCorrections` allowing a finer analysis of MBQC flows. This module subsumes `graphix.generator` which has been removed and part of `graphix.gflow` which will be removed in the future. + - New module `graphix.flow._find_cflow` with the existing causal-flow finding algorithm. + - New module `graphix.flow._find_gpflow` with the existing g- and Pauli-flow finding algorithm introduced in #337. + - New abstract types `graphix.fundamentals.AbstractMeasurement` and `graphix.fundamentals.AbstractPlanarMeasurement` which serve as an umbrella of the existing types `graphix.measurements.Measurement`, `graphix.fundamentals.Plane` and `graphix.fundamentals.Axis`. + - New method `graphix.pattern.Pattern.extract_opengraph` which subsumes the static method `graphix.opengraph.OpenGraph.from_pattern`. + - New methods of `graphix.opengraph.OpenGraph` which allow to extract a causal, g- or Pauli flow. ### Fixed ### Changed + - #358: Refactor of flow tools - Part I + - API for the `graphix.opengraph.OpenGraph` class: + - `OpenGraphs` are parametrically typed so that they can be defined on planes and axes mappings in addition to measurements mappings. + - Attribute names are now `graph`, `input_nodes`, `output_nodes` and `measurements`. + ## [0.3.3] - 2025-10-23