From a5d38015e4957c48410955562c5bffb46677fdf0 Mon Sep 17 00:00:00 2001 From: Vinny010 Date: Thu, 4 Jun 2026 02:43:48 +0400 Subject: [PATCH 1/3] Extract Pauli flow from XZ-corrections (closes #432) Implement `XZCorrections.to_pauli_flow` and the convenience method `Pattern.extract_pauli_flow`, reconstructing a Pauli flow directly from a pattern's XZ-corrections (Theorem 4 of Browne et al. 2007) rather than from the underlying open graph (whose Pauli flow is not unique and need not generate the pattern). The difficulty is the anachronical corrections: corrections targeting X/Y Pauli-measured nodes in the present or past of the corrected node. These are dropped by `PauliFlow.to_corrections` (the `& future` filter) and so never appear in the pattern, so they must be reconstructed. For each measured node this is cast as a GF(2) linear system: the future membership of the correction set is pinned by the observed X-corrections; the free variables are the anachronical (non-future, X/Y-measured) candidates and, where allowed, the node itself; and the equations encode the odd-neighbourhood constraints (Z-corrections on future nodes, P2 on past non-(Y/Z) nodes, the P3 coupling on past Y nodes, and the local proposition P4-P9 on the node). The system is solved over GF(2) with `_solve_gf2`. Tests verify, on the three worked examples of the issue, on a Pauli-measured open graph, and on a randomized family of open graphs that admit a Pauli flow, that the reconstructed flow is well formed and that `to_corrections()` reproduces the pattern's corrections exactly (the decisive round-trip criterion). Passes ruff, mypy --strict, pyright, and pytest locally. Co-Authored-By: Claude Opus 4.8 --- graphix/flow/core.py | 206 ++++++++++++++++++++++++++++ graphix/pattern.py | 29 ++++ tests/test_pauli_flow_extraction.py | 176 ++++++++++++++++++++++++ 3 files changed, 411 insertions(+) create mode 100644 tests/test_pauli_flow_extraction.py diff --git a/graphix/flow/core.py b/graphix/flow/core.py index fa674c3fd..3aa4befcc 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -266,6 +266,51 @@ def to_gflow(self: XZCorrections[_PM_co]) -> GFlow[_PM_co]: gf.check_well_formed() # Raises a `FlowError` if the partial order and the correction function are not compatible. return gf + def to_pauli_flow(self) -> PauliFlow[_AM_co]: + r"""Extract a Pauli flow from XZ-corrections. + + This method does not invoke the flow-extraction routine on the underlying open graph. + Instead, it reconstructs, for every measured node, a correction set whose *future* + part matches the observed XZ-corrections and which satisfies the Pauli-flow + propositions (P1-P9, see :meth:`PauliFlow.check_well_formed`). + + The difficulty, compared with :meth:`to_gflow`, is that the Pauli-flow correction + sets may contain *anachronical corrections*: corrections targeting nodes measured in + the X or Y Pauli bases that lie in the present or the past of the corrected node. + Such corrections do not appear in the pattern, because :meth:`PauliFlow.to_corrections` + only keeps the part of each correction set lying in the future (the ``& future`` filter). + They must therefore be reconstructed rather than read off the corrections. + + For each measured node ``i`` this is cast as a system of linear equations over GF(2): + the membership of the future nodes in the correction set is pinned by the X-corrections + of ``i``; the free variables are the anachronical (non-future, X/Y-measured) candidates + and, where the proposition allows it, ``i`` itself; and the equations encode the + odd-neighbourhood constraints, namely the Z-corrections on the future nodes (P-future), + the vanishing of the odd neighbourhood on past non-(Y/Z) nodes (P2) and the local + proposition on ``i`` (P4-P9). The system is solved with :func:`_solve_gf2`. + + Returns + ------- + PauliFlow[_AM_co] + + Raises + ------ + FlowError + If no Pauli flow is compatible with the XZ-corrections. + + Notes + ----- + See Theorem 4 in Ref. [1] and Definition 5 therein for the Pauli-flow propositions. + + References + ---------- + [1] Browne et al., 2007 New J. Phys. 9 250 (arXiv:quant-ph/0702212). + """ + correction_function = _reconstruct_pauli_correction_function(self) + pf: PauliFlow[_AM_co] = PauliFlow(self.og, correction_function, self.partial_order_layers) + pf.check_well_formed() # Raises a `FlowError` if the reconstructed flow is not well formed. + return pf + def to_bloch(self: XZCorrections[Measurement]) -> XZCorrections[BlochMeasurement]: """Return the XZ-corrections where all measurements in the open graph are converted to Bloch. @@ -1391,3 +1436,164 @@ def _check_flow_general_properties(flow: PauliFlow[_AM_co]) -> None: o_set = set(flow.og.output_nodes) if first_layer != o_set or not first_layer: raise PartialOrderLayerError(PartialOrderLayerErrorReason.FirstLayer, layer_index=0, layer=first_layer) + + +def _solve_gf2(matrix: list[list[int]], rhs: list[int], n_vars: int) -> list[int] | None: + """Return one solution of the GF(2) linear system ``matrix @ x = rhs``. + + Gaussian elimination over GF(2) is used; free variables are assigned 0. + + Parameters + ---------- + matrix : list[list[int]] + Coefficient rows, each of length ``n_vars`` with entries in ``{0, 1}``. + rhs : list[int] + Right-hand side column (entries in ``{0, 1}``), one entry per row. + n_vars : int + Number of variables (columns). + + Returns + ------- + list[int] | None + A particular solution, or ``None`` if the system is inconsistent. + """ + rows = [row[:] for row in matrix] + consts = list(rhs) + n_rows = len(rows) + pivot_cols: list[int] = [] + pivot_row = 0 + for col in range(n_vars): + sel = next((r for r in range(pivot_row, n_rows) if rows[r][col]), None) + if sel is None: + continue + rows[pivot_row], rows[sel] = rows[sel], rows[pivot_row] + consts[pivot_row], consts[sel] = consts[sel], consts[pivot_row] + for r in range(n_rows): + if r != pivot_row and rows[r][col]: + rows[r] = [a ^ b for a, b in zip(rows[r], rows[pivot_row], strict=True)] + consts[r] ^= consts[pivot_row] + pivot_cols.append(col) + pivot_row += 1 + if pivot_row == n_rows: + break + if any(consts[r] and not any(rows[r]) for r in range(n_rows)): + return None + solution = [0] * n_vars + for idx, col in enumerate(pivot_cols): + solution[col] = consts[idx] + return solution + + +def _solve_pauli_correction_set( + xz: XZCorrections[AbstractMeasurement], + node: int, + future: set[int], + adjacency: Mapping[int, set[int]], + labels: Mapping[int, Plane | Axis], +) -> set[int] | None: + """Reconstruct the Pauli-flow correction set of a single ``node``. + + See :meth:`XZCorrections.to_pauli_flow` for the description of the GF(2) system solved here. + """ + og = xz.og + nodes = set(og.graph.nodes) + non_inputs = nodes - set(og.input_nodes) + x_corr = set(xz.x_corrections.get(node, ())) + z_corr = set(xz.z_corrections.get(node, ())) + label = labels[node] + + # Self-membership in the correction set, dictated by the local proposition (P4-P9). + self_fixed_in = label in {Plane.XZ, Plane.YZ, Axis.Z} + self_is_var = label in {Axis.X, Axis.Y} and node in non_inputs + if self_fixed_in and node not in non_inputs: + return None # `node` must correct itself but is an input node. + + # Anachronical candidates: non-future, X/Y-measured, non-input nodes (other than `node`). + nonfuture_others = nodes - future - {node} + candidates = sorted(a for a in nonfuture_others if a in non_inputs and labels.get(a) in {Axis.X, Axis.Y}) + variables = [*candidates, node] if self_is_var else list(candidates) + var_index = {v: i for i, v in enumerate(variables)} + + fixed_in_p = set(x_corr) + if self_fixed_in: + fixed_in_p.add(node) + + def const_at(g: int) -> int: + return len(adjacency[g] & fixed_in_p) % 2 + + def row_at(g: int) -> list[int]: + return [1 if v in adjacency[g] else 0 for v in variables] + + matrix: list[list[int]] = [] + rhs: list[int] = [] + + # Odd-neighbourhood constraints on the future nodes (the Z-corrections of `node`). + for g in future: + matrix.append(row_at(g)) + rhs.append((1 if g in z_corr else 0) ^ const_at(g)) + + # P2: the odd neighbourhood vanishes on non-future, non-(Y/Z) nodes. + for g in nonfuture_others: + lab_g = labels.get(g) + if lab_g is not None and lab_g not in {Axis.Y, Axis.Z}: + matrix.append(row_at(g)) + rhs.append(const_at(g)) + + # P3: a non-future Y-measured node `g` must lie outside the closed odd neighbourhood of the + # correction set, i.e. its membership and odd-neighbourhood membership must coincide. + for g in nonfuture_others: + if labels.get(g) == Axis.Y: + row = row_at(g) + if g in var_index: + row[var_index[g]] ^= 1 + matrix.append(row) + rhs.append(const_at(g)) + + # Local proposition on `node` (P4-P9). + if label == Axis.Y: + # P9: exactly one of (node in p, node in Odd(p)). + row = row_at(node) + if node in var_index: + row[var_index[node]] ^= 1 + matrix.append(row) + rhs.append(1 ^ const_at(node)) + elif label != Axis.Z: + # XY, XZ, X -> node in Odd(p); YZ -> node not in Odd(p). + target = 0 if label == Plane.YZ else 1 + matrix.append(row_at(node)) + rhs.append(target ^ const_at(node)) + + solution = _solve_gf2(matrix, rhs, len(variables)) + if solution is None: + return None + + correction_set = set(fixed_in_p) + correction_set.update(v for v, bit in zip(variables, solution, strict=True) if bit) + return correction_set + + +def _reconstruct_pauli_correction_function(xz: XZCorrections[AbstractMeasurement]) -> dict[int, set[int]]: + """Reconstruct a Pauli-flow correction function from XZ-corrections. + + See :meth:`XZCorrections.to_pauli_flow`. + """ + og = xz.og + adjacency: dict[int, set[int]] = {n: set(og.graph.neighbors(n)) for n in og.graph.nodes} + labels: dict[int, Plane | Axis] = {n: meas.to_plane_or_axis() for n, meas in og.measurements.items()} + + # future_of[node]: nodes measured strictly after `node` (more future in the partial order). + future_of: dict[int, set[int]] = {} + accumulated: set[int] = set() + for layer in xz.partial_order_layers: + for node in layer: + future_of[node] = set(accumulated) + accumulated |= set(layer) + + correction_function: dict[int, set[int]] = {} + for node in og.measurements: + correction_set = _solve_pauli_correction_set(xz, node, future_of[node], adjacency, labels) + if correction_set is None: + # No correction set reconciles the XZ-corrections with the Pauli-flow propositions. + raise FlowError + correction_function[node] = correction_set + return correction_function diff --git a/graphix/pattern.py b/graphix/pattern.py index bcc18035b..ec20cd760 100644 --- a/graphix/pattern.py +++ b/graphix/pattern.py @@ -991,6 +991,35 @@ def extract_gflow(self) -> GFlow[BlochMeasurement]: """ return self.extract_xzcorrections().downcast_bloch().to_gflow() + def extract_pauli_flow(self) -> PauliFlow[Measurement]: + r"""Extract the Pauli flow structure from the current measurement pattern. + + This method does not call the flow-extraction routine on the underlying open graph, but + reconstructs the Pauli flow from the pattern corrections instead (see + :meth:`graphix.flow.core.XZCorrections.to_pauli_flow`). Contrary to + :meth:`extract_causal_flow` and :meth:`extract_gflow`, Pauli measurements are kept as + axes rather than downcast to planar Bloch measurements, so that the Pauli-basis + structure is preserved. + + Returns + ------- + PauliFlow[Measurement] + The Pauli flow associated with the current pattern. + + Raises + ------ + FlowError + If the pattern is empty or if no Pauli flow is compatible with the pattern corrections. + ValueError + If `N` commands in the pattern do not represent a :math:`|+\rangle` state or if the + pattern corrections form closed loops. + + Notes + ----- + The notes provided in :func:`self.extract_causal_flow` apply here as well. + """ + return self.extract_xzcorrections().to_pauli_flow() + def extract_xzcorrections(self) -> XZCorrections[Measurement]: """Extract the XZ-corrections from the current measurement pattern. diff --git a/tests/test_pauli_flow_extraction.py b/tests/test_pauli_flow_extraction.py new file mode 100644 index 000000000..466ea898b --- /dev/null +++ b/tests/test_pauli_flow_extraction.py @@ -0,0 +1,176 @@ +r"""Tests for Pauli-flow extraction from a pattern / XZ-corrections. + +Correctness criterion +--------------------- +A reconstructed Pauli flow ``pf`` generates the original pattern if and only if +``pf.check_well_formed()`` succeeds *and* ``pf.to_corrections()`` reproduces the pattern's +X- and Z-corrections exactly. The latter "round-trip" property is the decisive check: it +guarantees that the flow generates *this* pattern (and not merely some Pauli flow of the +underlying open graph, which need not be unique). The tests below verify this on the three +worked examples of the issue, on a Pauli-measured open graph, and on a randomized family of +open graphs that admit a Pauli flow. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import networkx as nx +import numpy as np +import pytest + +from graphix import Measurement, OpenGraph, Pattern +from graphix.command import E, M, N, X, Z +from graphix.flow.core import _solve_gf2 + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from collections.abc import Set as AbstractSet + + from numpy.random import Generator + + +def _norm(corrections: Mapping[int, AbstractSet[int]]) -> dict[int, frozenset[int]]: + """Drop empty correction sets to compare correction dictionaries up to empty entries.""" + return {k: frozenset(v) for k, v in corrections.items() if v} + + +def _assert_round_trip(pattern: Pattern) -> None: + xz = pattern.extract_xzcorrections() + pf = xz.to_pauli_flow() # `check_well_formed` runs inside `to_pauli_flow`. + assert pf.is_well_formed() + rt = pf.to_corrections() + assert _norm(rt.x_corrections) == _norm(xz.x_corrections) + assert _norm(rt.z_corrections) == _norm(xz.z_corrections) + + +def _correction_function(pattern: Pattern) -> dict[int, set[int]]: + pf = pattern.extract_pauli_flow() + return {k: set(v) for k, v in pf.correction_function.items()} + + +def _causal_pattern() -> Pattern: + return Pattern(input_nodes=[0], cmds=[N(1), E((0, 1)), M(0, Measurement.XY(0)), X(1, {0})], output_nodes=[1]) + + +def _gflow_pattern() -> Pattern: + return Pattern( + input_nodes=[0], + cmds=[ + N(1), N(2), N(3), E((0, 1)), E((0, 2)), E((1, 2)), E((1, 3)), + M(0, Measurement.XY(0.1)), X(2, {0}), X(3, {0}), + M(1, Measurement.XZ(0.2)), Z(2, {1}), Z(3, {1}), X(2, {1}), + ], + output_nodes=[2, 3], + ) # fmt: skip + + +def _pauli_pattern() -> Pattern: + return Pattern( + input_nodes=[0], + cmds=[ + N(1), N(2), N(3), E((0, 1)), E((1, 2)), E((2, 3)), + M(0, Measurement.X), X(3, {0}), + M(1, Measurement.X), Z(3, {1}), + M(2, Measurement.X), X(3, {2}), + ], + output_nodes=[3], + ) # fmt: skip + + +def test_extract_pauli_flow_causal_example() -> None: + pattern = _causal_pattern() + assert _correction_function(pattern) == {0: {1}} + _assert_round_trip(pattern) + + +def test_extract_pauli_flow_gflow_example() -> None: + pattern = _gflow_pattern() + assert _correction_function(pattern) == {0: {2, 3}, 1: {1, 2}} + _assert_round_trip(pattern) + + +def test_extract_pauli_flow_pauli_example() -> None: + # The flow must include the anachronical correction (node 1 in p(0)) that does not + # appear in the pattern, in order to satisfy the X-axis proposition (P7). + pattern = _pauli_pattern() + assert _correction_function(pattern) == {0: {1, 3}, 1: {2}, 2: {3}} + _assert_round_trip(pattern) + + +@pytest.mark.filterwarnings("ignore:Open graph with non-inferred Pauli measurements.") +def test_extract_pauli_flow_pauli_opengraph() -> None: + 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.XY(0.1), + 1: Measurement.XZ(0.1), + 2: Measurement.Y, + 3: Measurement.XY(0.1), + 4: Measurement.Z, + }, + ) + _assert_round_trip(og.to_pattern()) + + +_MEASUREMENTS: list[Callable[[Generator], Measurement]] = [ + lambda r: Measurement.XY(round(float(r.random()), 3)), + lambda r: Measurement.XZ(round(float(r.random()), 3)), + lambda r: Measurement.YZ(round(float(r.random()), 3)), + lambda _r: Measurement.X, + lambda _r: Measurement.Y, + lambda _r: Measurement.Z, +] + + +@pytest.mark.filterwarnings("ignore:Open graph with non-inferred Pauli measurements.") +def test_extract_pauli_flow_randomized_round_trip() -> None: + # Generate random open graphs; those that admit a Pauli flow (so that `to_pattern` + # succeeds) are converted to a pattern, and the reconstructed flow is checked to be + # well formed and to reproduce the pattern's corrections. + tested = 0 + for seed in range(400): + rng = np.random.default_rng(seed) + n = int(rng.integers(4, 10)) + graph = nx.gnp_random_graph(n, 0.45, seed=seed) + if graph.number_of_edges() == 0: + continue + nodes = list(graph.nodes()) + rng.shuffle(nodes) + n_out = int(rng.integers(1, max(2, n // 2))) + n_in = int(rng.integers(0, max(1, n // 2))) + outputs = nodes[:n_out] + inputs = nodes[n_out : n_out + n_in] + measurements = { + m: _MEASUREMENTS[int(rng.integers(0, len(_MEASUREMENTS)))](rng) for m in nodes if m not in outputs + } + try: + pattern = OpenGraph( + graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=measurements + ).to_pattern() + except Exception: # noqa: BLE001, S112 open graph without a flow -> not a valid test case + continue + _assert_round_trip(pattern) + tested += 1 + assert tested >= 30 # ensure the randomized sweep actually exercised the extraction + + +def test_solve_gf2_unique_solution() -> None: + # x0 + x1 = 1, x1 = 1 -> x0 = 0, x1 = 1 + assert _solve_gf2([[1, 1], [0, 1]], [1, 1], 2) == [0, 1] + + +def test_solve_gf2_free_variable_set_to_zero() -> None: + # x0 + x1 = 0 -> free x1 = 0, x0 = 0 + assert _solve_gf2([[1, 1]], [0], 2) == [0, 0] + + +def test_solve_gf2_inconsistent_returns_none() -> None: + # x0 = 0 and x0 = 1 simultaneously. + assert _solve_gf2([[1, 0], [1, 0]], [0, 1], 2) is None + + +def test_solve_gf2_no_equations() -> None: + assert _solve_gf2([], [], 3) == [0, 0, 0] From 926197a3a53e806b27c87c29eadaa68c78f8f97e Mon Sep 17 00:00:00 2001 From: Vinny010 Date: Thu, 4 Jun 2026 10:14:47 +0400 Subject: [PATCH 2/3] Add test covering the no-Pauli-flow error paths Cover the branches where no Pauli flow is compatible with the XZ-corrections: a measured input node that must correct itself, and an isolated XY-measured node whose proposition P4 cannot be satisfied (unsolvable GF(2) system). Addresses the patch-coverage gap reported on the PR. Co-Authored-By: Claude Opus 4.8 --- tests/test_pauli_flow_extraction.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_pauli_flow_extraction.py b/tests/test_pauli_flow_extraction.py index 466ea898b..b16029edc 100644 --- a/tests/test_pauli_flow_extraction.py +++ b/tests/test_pauli_flow_extraction.py @@ -21,7 +21,8 @@ from graphix import Measurement, OpenGraph, Pattern from graphix.command import E, M, N, X, Z -from graphix.flow.core import _solve_gf2 +from graphix.flow.core import XZCorrections, _solve_gf2 +from graphix.flow.exceptions import FlowError if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -174,3 +175,26 @@ def test_solve_gf2_inconsistent_returns_none() -> None: def test_solve_gf2_no_equations() -> None: assert _solve_gf2([], [], 3) == [0, 0, 0] + + +def test_to_pauli_flow_raises_when_no_flow_exists() -> None: + # A measured input node that must correct itself (Z axis) admits no Pauli flow, + # because the correction set's image cannot contain an input node. + og1 = OpenGraph(graph=nx.Graph([(0, 1)]), input_nodes=[0], output_nodes=[1], measurements={0: Measurement.Z}) + with pytest.raises(FlowError): + XZCorrections(og1, {}, {}, [{1}, {0}]).to_pauli_flow() + + # An isolated node measured in the XY plane cannot satisfy proposition P4 + # (it must lie in the odd neighbourhood of its correction set), so the GF(2) + # system has no solution and no Pauli flow exists. + graph: nx.Graph[int] = nx.Graph() + graph.add_node(0) + graph.add_edge(1, 2) + og2 = OpenGraph( + graph=graph, + input_nodes=[], + output_nodes=[2], + measurements={0: Measurement.XY(0.1), 1: Measurement.XY(0.1)}, + ) + with pytest.raises(FlowError): + XZCorrections(og2, {}, {}, [{2}, {1}, {0}]).to_pauli_flow() From 9a73dc732c6fd909076c69d403ea9579b6cfca46 Mon Sep 17 00:00:00 2001 From: Vinny010 Date: Fri, 5 Jun 2026 21:45:14 +0400 Subject: [PATCH 3/3] Address review on Pauli-flow extraction - Reuse `graphix._linalg.solve_f2_linear_system` rather than the ad-hoc `_solve_gf2` (which is removed). The per-node augmented matrix `[A | b]` is reduced to row echelon form with `MatGF2.gauss_elimination(ncols=n_vars)`, inconsistency is detected by scanning for `[0...0 | 1]` rows, and the reduced system is then handed to the existing solver. - Update the `XZCorrections.to_pauli_flow` docstring to point at the existing GF(2) solver and to spell out the propositions encoded by the GF(2) system (P1 by construction via the X/Y-axis candidate restriction; P2-P9 directly). - Add a comment on the `pf.check_well_formed()` call: it is a regression guard; the algorithm satisfies the propositions by construction, so a failure there would indicate a bug rather than malformed input. - In the randomized round-trip test, narrow `except Exception` to `OpenGraphError` (the only documented raise of `OpenGraph.to_pattern` when no flow exists) and drop the cosmetic `round` on the random measurement angles. Co-Authored-By: Claude Opus 4.8 --- graphix/flow/core.py | 87 +++++++++++------------------ tests/test_pauli_flow_extraction.py | 32 +++-------- 2 files changed, 41 insertions(+), 78 deletions(-) diff --git a/graphix/flow/core.py b/graphix/flow/core.py index 3aa4befcc..c39f23880 100644 --- a/graphix/flow/core.py +++ b/graphix/flow/core.py @@ -11,10 +11,12 @@ from typing import TYPE_CHECKING, Generic, TypeVar import networkx as nx +import numpy as np # `override` introduced in Python 3.12, `assert_never` introduced in Python 3.11 from typing_extensions import assert_never, override +from graphix._linalg import MatGF2, solve_f2_linear_system from graphix.circ_ext.extraction import ( CliffordMap, ExtractionResult, @@ -286,8 +288,10 @@ def to_pauli_flow(self) -> PauliFlow[_AM_co]: of ``i``; the free variables are the anachronical (non-future, X/Y-measured) candidates and, where the proposition allows it, ``i`` itself; and the equations encode the odd-neighbourhood constraints, namely the Z-corrections on the future nodes (P-future), - the vanishing of the odd neighbourhood on past non-(Y/Z) nodes (P2) and the local - proposition on ``i`` (P4-P9). The system is solved with :func:`_solve_gf2`. + the vanishing of the odd neighbourhood on past non-(Y/Z) nodes (P2), the coupling on + past Y-measured nodes (P3) and the local proposition on ``i`` (P4-P9). The system is + reduced with :meth:`graphix._linalg.MatGF2.gauss_elimination` and solved with + :func:`graphix._linalg.solve_f2_linear_system`. Returns ------- @@ -296,7 +300,9 @@ def to_pauli_flow(self) -> PauliFlow[_AM_co]: Raises ------ FlowError - If no Pauli flow is compatible with the XZ-corrections. + If no Pauli flow is compatible with the XZ-corrections (raised by + :func:`_reconstruct_pauli_correction_function` when the GF(2) system has no + solution for at least one measured node). Notes ----- @@ -308,7 +314,12 @@ def to_pauli_flow(self) -> PauliFlow[_AM_co]: """ correction_function = _reconstruct_pauli_correction_function(self) pf: PauliFlow[_AM_co] = PauliFlow(self.og, correction_function, self.partial_order_layers) - pf.check_well_formed() # Raises a `FlowError` if the reconstructed flow is not well formed. + # Defensive: by construction the GF(2) equations of `_reconstruct_pauli_correction_function` + # encode propositions P2-P9 exactly, and the anachronical candidates are restricted to X/Y + # axes which guarantees P1; the general flow properties are also satisfied by construction + # (the correction function is defined on the measured nodes and its image is included in + # the non-input nodes). This check is kept as a regression guard. + pf.check_well_formed() return pf def to_bloch(self: XZCorrections[Measurement]) -> XZCorrections[BlochMeasurement]: @@ -1438,52 +1449,6 @@ def _check_flow_general_properties(flow: PauliFlow[_AM_co]) -> None: raise PartialOrderLayerError(PartialOrderLayerErrorReason.FirstLayer, layer_index=0, layer=first_layer) -def _solve_gf2(matrix: list[list[int]], rhs: list[int], n_vars: int) -> list[int] | None: - """Return one solution of the GF(2) linear system ``matrix @ x = rhs``. - - Gaussian elimination over GF(2) is used; free variables are assigned 0. - - Parameters - ---------- - matrix : list[list[int]] - Coefficient rows, each of length ``n_vars`` with entries in ``{0, 1}``. - rhs : list[int] - Right-hand side column (entries in ``{0, 1}``), one entry per row. - n_vars : int - Number of variables (columns). - - Returns - ------- - list[int] | None - A particular solution, or ``None`` if the system is inconsistent. - """ - rows = [row[:] for row in matrix] - consts = list(rhs) - n_rows = len(rows) - pivot_cols: list[int] = [] - pivot_row = 0 - for col in range(n_vars): - sel = next((r for r in range(pivot_row, n_rows) if rows[r][col]), None) - if sel is None: - continue - rows[pivot_row], rows[sel] = rows[sel], rows[pivot_row] - consts[pivot_row], consts[sel] = consts[sel], consts[pivot_row] - for r in range(n_rows): - if r != pivot_row and rows[r][col]: - rows[r] = [a ^ b for a, b in zip(rows[r], rows[pivot_row], strict=True)] - consts[r] ^= consts[pivot_row] - pivot_cols.append(col) - pivot_row += 1 - if pivot_row == n_rows: - break - if any(consts[r] and not any(rows[r]) for r in range(n_rows)): - return None - solution = [0] * n_vars - for idx, col in enumerate(pivot_cols): - solution[col] = consts[idx] - return solution - - def _solve_pauli_correction_set( xz: XZCorrections[AbstractMeasurement], node: int, @@ -1563,12 +1528,26 @@ def row_at(g: int) -> list[int]: matrix.append(row_at(node)) rhs.append(target ^ const_at(node)) - solution = _solve_gf2(matrix, rhs, len(variables)) - if solution is None: - return None + n_vars = len(variables) + if not matrix: + # No constraints: free variables default to 0, so the correction set is exactly + # the part of ``p(node)`` pinned by the X-corrections and the local proposition. + return set(fixed_in_p) + + # Reduce the augmented matrix ``[A | b]`` to row echelon form together so that the + # row operations propagate to the right-hand side. Inconsistent systems leave a row + # ``[0...0 | 1]`` after reduction, which signals that no Pauli flow exists. + augmented = np.array([[*row, c] for row, c in zip(matrix, rhs, strict=True)], dtype=np.uint8).view(MatGF2) + augmented = augmented.gauss_elimination(ncols=n_vars) + lhs = MatGF2(augmented[:, :n_vars]) + b = augmented[:, n_vars] + for i in range(lhs.shape[0]): + if not lhs[i].any() and b[i] != 0: + return None + solution = solve_f2_linear_system(lhs, MatGF2(b)) correction_set = set(fixed_in_p) - correction_set.update(v for v, bit in zip(variables, solution, strict=True) if bit) + correction_set.update(v for v, bit in zip(variables, solution, strict=True) if int(bit)) return correction_set diff --git a/tests/test_pauli_flow_extraction.py b/tests/test_pauli_flow_extraction.py index b16029edc..aa91a99de 100644 --- a/tests/test_pauli_flow_extraction.py +++ b/tests/test_pauli_flow_extraction.py @@ -21,8 +21,9 @@ from graphix import Measurement, OpenGraph, Pattern from graphix.command import E, M, N, X, Z -from graphix.flow.core import XZCorrections, _solve_gf2 +from graphix.flow.core import XZCorrections from graphix.flow.exceptions import FlowError +from graphix.opengraph import OpenGraphError if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -117,9 +118,9 @@ def test_extract_pauli_flow_pauli_opengraph() -> None: _MEASUREMENTS: list[Callable[[Generator], Measurement]] = [ - lambda r: Measurement.XY(round(float(r.random()), 3)), - lambda r: Measurement.XZ(round(float(r.random()), 3)), - lambda r: Measurement.YZ(round(float(r.random()), 3)), + lambda r: Measurement.XY(float(r.random())), + lambda r: Measurement.XZ(float(r.random())), + lambda r: Measurement.YZ(float(r.random())), lambda _r: Measurement.X, lambda _r: Measurement.Y, lambda _r: Measurement.Z, @@ -151,32 +152,15 @@ def test_extract_pauli_flow_randomized_round_trip() -> None: pattern = OpenGraph( graph=graph, input_nodes=inputs, output_nodes=outputs, measurements=measurements ).to_pattern() - except Exception: # noqa: BLE001, S112 open graph without a flow -> not a valid test case + except OpenGraphError: + # The randomly drawn open graph does not admit a flow (the only documented + # raise condition of `OpenGraph.to_pattern`) -> not a valid test case. continue _assert_round_trip(pattern) tested += 1 assert tested >= 30 # ensure the randomized sweep actually exercised the extraction -def test_solve_gf2_unique_solution() -> None: - # x0 + x1 = 1, x1 = 1 -> x0 = 0, x1 = 1 - assert _solve_gf2([[1, 1], [0, 1]], [1, 1], 2) == [0, 1] - - -def test_solve_gf2_free_variable_set_to_zero() -> None: - # x0 + x1 = 0 -> free x1 = 0, x0 = 0 - assert _solve_gf2([[1, 1]], [0], 2) == [0, 0] - - -def test_solve_gf2_inconsistent_returns_none() -> None: - # x0 = 0 and x0 = 1 simultaneously. - assert _solve_gf2([[1, 0], [1, 0]], [0, 1], 2) is None - - -def test_solve_gf2_no_equations() -> None: - assert _solve_gf2([], [], 3) == [0, 0, 0] - - def test_to_pauli_flow_raises_when_no_flow_exists() -> None: # A measured input node that must correct itself (Z axis) admits no Pauli flow, # because the correction set's image cannot contain an input node.