From 5687ebea68211d428ecd514d12f3379b0e4e66cd Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 2 Sep 2026 17:01:05 -0400 Subject: [PATCH 001/227] [fix] Replace removed np.complex/np.float aliases in IO.load_np_array NumPy 1.24 removed np.complex and np.float, which is already the pinned version here, so IO.load_np_array raised AttributeError on any call. Use np.complex128/np.float64 instead. --- graphiq/io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphiq/io.py b/graphiq/io.py index c917ecef..82c56fc8 100644 --- a/graphiq/io.py +++ b/graphiq/io.py @@ -272,7 +272,7 @@ def load_np_array(self, filename, complex_vals=False): """ full_path = self.path.joinpath(filename) file = np.loadtxt( - str(full_path), dtype=np.complex if complex_vals else np.float + str(full_path), dtype=np.complex128 if complex_vals else np.float64 ) if self.verbose: print(f"{current_time()} | Loaded from {full_path} successfully.") From 3bb2bdae804fe5cbcef33b30b11e07251fdf85a7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 2 Sep 2026 17:24:33 -0400 Subject: [PATCH 002/227] [fix] Round-trip multi-character single-qubit gate names in wrapped gates OneQubitGateWrapper concatenates each gate's openqasm name with no separator (e.g. Hadamard+PhaseDagger+SigmaY -> "hsdgy"), but from_openqasm decoded that one character at a time, silently mis-parsing any multi-character name like PhaseDagger's "sdg". Decode via greedy longest-match instead. --- graphiq/circuit/circuit_dag.py | 23 +++++++------ graphiq/circuit/ops.py | 49 +++++++++++++++++++++++++++- tests/test_circuits/test_openqasm.py | 40 ++++++++++++++++++++++- 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index c02e84de..b8a0c5d8 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -666,19 +666,18 @@ def _parse_if(command): reg_type = command_breakdown[1][0] reg = int(command_breakdown[1][1:-3]) # we must parse out [0] so -3 gate_class = ops.name_to_class_map(name) - if gate_class is not None: - circuit.add(gate_class(register=reg, reg_type=reg_type)) - else: - circuit_list = [ops.name_to_class_map(letter) for letter in name] - assert None not in circuit_list, ( - f"Gate not recognized, parsing invalid/" - f"{name} parsed to {circuit_list}" - ) - circuit.add( - ops.OneQubitGateWrapper( - circuit_list, register=reg, reg_type=reg_type + if isinstance(gate_class, list): + if len(gate_class) == 1: + # single (possibly multi-character) gate, e.g. "sdg" + circuit.add(gate_class[0](register=reg, reg_type=reg_type)) + else: + circuit.add( + ops.OneQubitGateWrapper( + gate_class, register=reg, reg_type=reg_type + ) ) - ) + else: + circuit.add(gate_class(register=reg, reg_type=reg_type)) elif command.count("[0]") == 2: # two-qubit gate command_breakdown = command.split() name = command_breakdown[0] diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 08af9fab..396dc93e 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -1152,6 +1152,48 @@ def simplify_local_clifford(gate_list): return find_local_clifford_by_matrix(matrix) +# Openqasm gate names emitted for single-qubit gates by single_qubit_wrapper_info +# (OneQubitGateWrapper serialization concatenates these with no separator). Kept +# as an explicit companion so the name_to_class_map dictionary stays untouched. +# Multi-character tokens (e.g. "sdg") are decoded by greedy longest-match. +_ONE_QUBIT_WRAPPER_TOKENS = { + "h": Hadamard, + "x": SigmaX, + "y": SigmaY, + "z": SigmaZ, + "s": Phase, + "sdg": PhaseDagger, +} + + +def _decode_wrapped_gate_name(name): + """ + Decode a concatenated one-qubit-gate wrapper name (e.g. "hsdgy") into its + component operation classes, using greedy longest-match tokenisation over the + single-qubit openqasm gate names. This correctly handles multi-character gate + names such as "sdg" (PhaseDagger). + + :param name: concatenated openqasm gate name + :type name: str + :raises ValueError: if the name cannot be fully tokenised into valid gates + :return: list of operation classes + :rtype: list + """ + max_token_len = max(len(token) for token in _ONE_QUBIT_WRAPPER_TOKENS) + op_list = [] + i = 0 + while i < len(name): + for size in range(min(max_token_len, len(name) - i), 0, -1): + token = name[i : i + size] + if token in _ONE_QUBIT_WRAPPER_TOKENS: + op_list.append(_ONE_QUBIT_WRAPPER_TOKENS[token]) + i += size + break + else: + raise ValueError(f"{name} not existing in the mapping dictionary") + return op_list + + def name_to_class_map(name): """ Maps our openqasm naming scheme to operation classes. Does not handle multi-gate wrappers @@ -1178,7 +1220,12 @@ def name_to_class_map(name): } if name in mapping: return mapping[name] - return None + # Not a single known gate name: interpret it as a composite of one-qubit + # gates (e.g. "hsy" or "hsdgy"), as produced by OneQubitGateWrapper + # serialization which concatenates the gates' openqasm names with no + # separator. Decode via greedy longest-match so multi-character names + # (e.g. "sdg") are handled. Raises ValueError if not a valid composite. + return _decode_wrapped_gate_name(name) def class_to_name_mapping(class_op): diff --git a/tests/test_circuits/test_openqasm.py b/tests/test_circuits/test_openqasm.py index 3f092614..eb46daeb 100644 --- a/tests/test_circuits/test_openqasm.py +++ b/tests/test_circuits/test_openqasm.py @@ -226,10 +226,48 @@ def test_load_circuit_3(initialization_circuit): """ str = initialization_circuit.to_openqasm() str += "hshk e0[0];" - with pytest.raises(AssertionError): + with pytest.raises(ValueError): circuit = CircuitDAG.from_openqasm(str) +def test_load_circuit_multichar_wrapped_gate(): + """ + Regression test for round-tripping one-qubit gates whose openqasm name is + multi-character (e.g. PhaseDagger -> "sdg"), both wrapped inside a + OneQubitGateWrapper and standalone. Guards against the parser assuming every + single-qubit gate name is a single character. + """ + circuit1 = CircuitDAG(n_emitter=1, n_photon=1, n_classical=0) + circuit1.add( + ops.OneQubitGateWrapper( + [ops.Hadamard, ops.PhaseDagger, ops.SigmaY], register=0, reg_type="e" + ) + ) + circuit1.add(ops.PhaseDagger(register=0, reg_type="p")) + + circuit2 = CircuitDAG.from_openqasm(circuit1.to_openqasm()) + + def signatures(circuit): + sigs = [] + for op in circuit.sequence(): + if isinstance(op, (ops.Input, ops.Output)): + continue + if isinstance(op, ops.OneQubitGateWrapper): + sigs.append( + ( + "wrapper", + op.reg_type, + op.register, + tuple(o.__name__ for o in op.operations), + ) + ) + else: + sigs.append((type(op).__name__, op.reg_type, op.register)) + return sorted(sigs, key=str) + + assert signatures(circuit1) == signatures(circuit2) + + def test_load_circuit_4(initialization_circuit): """ Here, we check that we get the expected failure when a gate that we do not have defined it used From 46ed7f8775f77bb25e55c110c2ee99a09788819f Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 2 Sep 2026 17:27:36 -0400 Subject: [PATCH 003/227] [test] Force a non-interactive matplotlib backend for the test suite Tests exercising draw_circuit()/plt.show() popped up plot windows on every full test-suite run since draw_circuit defaults to show=True. Set the Agg backend in a top-level tests/conftest.py, loaded before any test module imports matplotlib, so plotting still runs and is still assertable but never opens a window. --- tests/conftest.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..77d19a2e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Force a non-interactive matplotlib backend for the whole test suite, so that +# tests exercising draw_circuit()/plt.show() etc. don't pop up plot windows. +# This must run before matplotlib.pyplot is imported anywhere else, which is +# why it lives in the top-level conftest.py: pytest loads it before collecting +# any test module (and before their own conftest.py files import graphiq). +import matplotlib + +matplotlib.use("Agg") From 73925825fd02961e1ac18584f86376a1c9cfbf87 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 2 Sep 2026 17:38:26 -0400 Subject: [PATCH 004/227] [fix] Fix broken absolute import in data_collection/user_interface.py `from correlation_module import *` only resolved when run from inside graphiq/data_collection/, so importing this module normally raised ModuleNotFoundError. Use the fully-qualified package path instead. --- graphiq/data_collection/user_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index d33fe0af..b92edd8e 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -20,7 +20,7 @@ import pandas as pd from scipy.stats import spearmanr -from correlation_module import * +from graphiq.data_collection.correlation_module import * from graphiq.data_collection.ui_functions import * from graphiq.metrics import graph_met_value From 63e39c0e36383d7e39d59916cdf77ee4c4d5356c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 2 Sep 2026 18:19:04 -0400 Subject: [PATCH 005/227] [fix] Don't crash in get_node_by_labels/get_node_exclude_labels on an unseen label Both methods indexed node_dict[label] directly, raising KeyError whenever no node in the circuit had ever carried that label (e.g. querying for CNOT nodes on a circuit with no CNOT gates) - a real crash hit by several metrics.py/correlation_module.py/ user_interface.py call sites. Use node_dict.get(label, []) so an absent label just contributes nothing, matching "no nodes have this label". --- graphiq/circuit/circuit_dag.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index b8a0c5d8..debebeae 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -333,7 +333,9 @@ def get_node_by_labels(self, labels): """ remaining_nodes = set(self.dag.nodes) for label in labels: - remaining_nodes = remaining_nodes.intersection(set(self.node_dict[label])) + remaining_nodes = remaining_nodes.intersection( + set(self.node_dict.get(label, [])) + ) return list(remaining_nodes) def get_node_exclude_labels(self, labels): @@ -348,7 +350,7 @@ def get_node_exclude_labels(self, labels): all_nodes = set(self.dag.nodes) exclusion_nodes = set() for label in labels: - exclusion_nodes = exclusion_nodes.union(set(self.node_dict[label])) + exclusion_nodes = exclusion_nodes.union(set(self.node_dict.get(label, []))) return list(all_nodes - exclusion_nodes) def remove_op(self, node): From d5dc56a79544b0a34a699fcedee2cdf25087fcf7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 2 Sep 2026 18:25:38 -0400 Subject: [PATCH 006/227] [fix] Fix non-trace-preserving multi-qubit amplitude damping noise AmplitudeDampingNoise.apply built Kraus operators with itertools.combinations instead of itertools.product. Since amplitude_damping_operators always returns exactly 2 single-qubit operators, combinations silently dropped terms: the channel was non-trace-preserving for 2 qubits and applied zero Kraus operators (no noise at all) for 3+ qubits. Use product, matching the pattern already used by DepolarizingNoise in the same file. --- graphiq/noise/noise_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index dcf0e7f7..1213f813 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -505,7 +505,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): if damping_prob == 0.0: return single_qubit_kraus = dmf.amplitude_damping_operators(damping_prob) - kraus_ops_iter = itertools.combinations(single_qubit_kraus, len(reg_list)) + kraus_ops_iter = itertools.product(single_qubit_kraus, repeat=len(reg_list)) kraus_ops = [] for kraus_op in kraus_ops_iter: kraus_ops.append( From b0b6b0902cafd0933350173fdbd39a57c7b6fafb Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 14:00:49 -0400 Subject: [PATCH 007/227] [fix] Fix two copy-paste bugs in metrics.py CircuitCnotCount.__init__ set self.n_emitter_penalty instead of self.n_cnot_penalty in the default-penalty branch, so evaluate() crashed with AttributeError on any default-constructed instance. CircuitUnitaryCount.evaluate's gate label list was ["SigmaX", "SigmaX", "SigmaX", ...] instead of ["SigmaX", "SigmaY", "SigmaZ", ...], so SigmaY gates were never counted and SigmaX gates were triple-counted. --- graphiq/metrics.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/graphiq/metrics.py b/graphiq/metrics.py index 18fd25ae..d47b82fc 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -433,9 +433,9 @@ def __init__(self, log_steps=1, n_cnot_penalty=None, *args, **kwargs): super().__init__(log_steps=log_steps, *args, **kwargs) self.differentiable = False if n_cnot_penalty is None: - self.n_emitter_penalty = ( + self.n_cnot_penalty = ( lambda x: x - ) # by default, the number emitters itself + ) # by default, the number of CNOTs itself else: self.n_cnot_penalty = n_cnot_penalty @@ -508,8 +508,8 @@ def evaluate(self, state, circuit): n_u = 0 for label in [ "SigmaX", - "SigmaX", - "SigmaX", + "SigmaY", + "SigmaZ", "Phase", "PhaseDagger", "Hadamard", From 76447e9a54c30d2657d6ec916fa7b0fc98991c53 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 14:10:45 -0400 Subject: [PATCH 008/227] [fix] Fix UnboundLocalError in Infidelity.evaluate for mixed stabilizer states The MixedStabilizer branch checked isinstance(state.rep_data, ...) instead of isinstance(rep_data, ...). rep_data holds the converted stabilizer representation (set a few lines above); state.rep_data is the original, pre-conversion representation. Whenever state was a mixed QuantumState not already in stabilizer form, conversion produces a MixedStabilizer, but the check inspected the untouched original, so neither branch matched, fid was never assigned, and evaluate() crashed with UnboundLocalError. --- graphiq/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphiq/metrics.py b/graphiq/metrics.py index d47b82fc..f87c9773 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -131,7 +131,7 @@ def evaluate(self, state, circuit): if isinstance(rep_data, Stabilizer): fid = sfm.fidelity(tableau, rep_data.data) - elif isinstance(state.rep_data, MixedStabilizer): + elif isinstance(rep_data, MixedStabilizer): fid = sum( [p_i * sfm.fidelity(tableau, t_i) for p_i, t_i in rep_data.mixture] ) From b571da014ff7b58d3f63d21d86b880be70af3048 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 14:59:53 -0400 Subject: [PATCH 009/227] [fix] Register all compatible metric classes in Metrics._all Metrics._all only listed Infidelity, TraceDistance, and CircuitDepth, so Metrics rejected every other metric class defined in this file - even when passed as an already-constructed instance. Register the ones that actually fit Metrics' evaluate(state, circuit) calling convention: CircuitEmitterCount, CircuitCnotCount, CircuitUnitaryCount, CircuitMaxEmitDepth, CircuitMaxEmitResetDepth, CircuitMaxEmitEffDepth, and CircuitMeasureCount. GraphMetric is deliberately left out: its evaluate(graph_metric: str) signature doesn't match that convention, so Metrics could never actually call it - registering it would just turn a clear "not a recognized metric" warning at construction time into a confusing TypeError at evaluation time. Move the Metrics class itself to after all the metric classes it references, so _all can be one plain dict literal again instead of needing a separate post-definition update(). --- graphiq/metrics.py | 220 +++++++++++++++++++++++---------------------- 1 file changed, 115 insertions(+), 105 deletions(-) diff --git a/graphiq/metrics.py b/graphiq/metrics.py index f87c9773..f88880ce 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -258,111 +258,6 @@ def evaluate(self, state, circuit): return val -class Metrics(MetricBase): - """ - Wraps around one or more metric functions, evaluating each and logging the values - """ - - _all = { # metrics that can be used, which can be specified by the dictionary keys or as a class instance - "Infidelity": Infidelity, - "TraceDistance": TraceDistance, - "CircuitDepth": CircuitDepth, - } - - def __init__( - self, metrics_list: list, metric_weight=None, log_steps=1, *args, **kwargs - ): - """ - Create a Metrics object which acts as a wrapper around Metric functions - - :param metrics_list: metrics to evaluate - :type metrics_list: list of strings (strings should be metric names) OR MetricBase objects - MetricBase objects may be preferable, since we cannot set initial parameters via the list of strings. - :param metric_weight: some representation of how to weigh the different metric results against one another - if None, all metrics provided are weighted equally (by 1) - if a list or ndarray, the metrics are a linear combination weighted by the list/ndarray values - Otherwise, metric_weight is a function, that can make any mathematical function of the individual - metric values. - :type metric_weight: None, numpy.ndarray, list, or Function - :param log_steps: the metric values are computed at every log_steps optimization step - :type log_steps: int - :return: function returns nothing - :rtype: None - """ - super().__init__(log_steps=log_steps, *args, **kwargs) - # pass in either list of either strings or of specific Metric instance (must be an accepted Metric) - _metrics = [] - for metric in metrics_list: - if metric.__class__ in self._all.values(): - _metrics.append(metric) - elif metric in self._all.keys(): - _metrics.append(self._all[metric]()) - else: - raise UserWarning( - f"{metric} is not a recognized metric - it will not be evaluated." - ) - self._metrics = _metrics - - if ( - metric_weight is None - or isinstance(metric_weight, list) - or isinstance(metric_weight, np.ndarray) - ): - if metric_weight is None: - metric_weight = np.ones(len(metrics_list)).flatten() - - elif isinstance(metric_weight, list): - metric_weight = np.array([metric_weight]).flatten() - - else: - metric_weight = metric_weight.flatten() - - def weighting_func(state, circuit): - return np.dot( - metric_weight, - np.array( - [met.evaluate(state, circuit) for met in self._metrics] - ).flatten(), - ) - - self.weighting_func = weighting_func - elif callable(metric_weight): - self.weighting_func = metric_weight - - def evaluate(self, state, circuit): - """ - Evaluate each metric function contained by the Metrics object - - :param state: the state on which to evaluate the metrics - :type state: QuantumState - :param circuit: the circuit on which to evaluate the metrics - :type circuit: CircuitBase (or a subclass of it) - :return: this function returns nothing - :rtype: None - """ - val = self.weighting_func(state, circuit) - self.increment() - - if self._inc % self.log_steps == 0: - self.log.append(val) - - return val - - @property - def per_metric_log(self): - """ - The joint log of all metric functions - - :return: the log itself - :rtype: dict (keys are metric class names, values are the logs) - """ - m = {} - for i, metric in enumerate(self._metrics): - m[metric.__class__.__name__] = metric.log - - return m - - # circuit metrics @@ -821,6 +716,121 @@ def evaluate(self, graph_metric: str): return val +class Metrics(MetricBase): + """ + Wraps around one or more metric functions, evaluating each and logging the values + """ + + _all = { # metrics that can be used, which can be specified by the dictionary keys or as a class instance + "Infidelity": Infidelity, + "TraceDistance": TraceDistance, + "CircuitDepth": CircuitDepth, + "CircuitEmitterCount": CircuitEmitterCount, + "CircuitCnotCount": CircuitCnotCount, + "CircuitUnitaryCount": CircuitUnitaryCount, + "CircuitMaxEmitDepth": CircuitMaxEmitDepth, + "CircuitMaxEmitResetDepth": CircuitMaxEmitResetDepth, + "CircuitMaxEmitEffDepth": CircuitMaxEmitEffDepth, + "CircuitMeasureCount": CircuitMeasureCount, + # GraphMetric is deliberately excluded: its evaluate(graph_metric: str) + # signature doesn't match the evaluate(state, circuit) contract every + # other metric here follows, so Metrics can't actually call it. + } + + def __init__( + self, metrics_list: list, metric_weight=None, log_steps=1, *args, **kwargs + ): + """ + Create a Metrics object which acts as a wrapper around Metric functions + + :param metrics_list: metrics to evaluate + :type metrics_list: list of strings (strings should be metric names) OR MetricBase objects + MetricBase objects may be preferable, since we cannot set initial parameters via the list of strings. + :param metric_weight: some representation of how to weigh the different metric results against one another + if None, all metrics provided are weighted equally (by 1) + if a list or ndarray, the metrics are a linear combination weighted by the list/ndarray values + Otherwise, metric_weight is a function, that can make any mathematical function of the individual + metric values. + :type metric_weight: None, numpy.ndarray, list, or Function + :param log_steps: the metric values are computed at every log_steps optimization step + :type log_steps: int + :return: function returns nothing + :rtype: None + """ + super().__init__(log_steps=log_steps, *args, **kwargs) + # pass in either list of either strings or of specific Metric instance (must be an accepted Metric) + _metrics = [] + for metric in metrics_list: + if metric.__class__ in self._all.values(): + _metrics.append(metric) + elif metric in self._all.keys(): + _metrics.append(self._all[metric]()) + else: + raise UserWarning( + f"{metric} is not a recognized metric - it will not be evaluated." + ) + self._metrics = _metrics + + if ( + metric_weight is None + or isinstance(metric_weight, list) + or isinstance(metric_weight, np.ndarray) + ): + if metric_weight is None: + metric_weight = np.ones(len(metrics_list)).flatten() + + elif isinstance(metric_weight, list): + metric_weight = np.array([metric_weight]).flatten() + + else: + metric_weight = metric_weight.flatten() + + def weighting_func(state, circuit): + return np.dot( + metric_weight, + np.array( + [met.evaluate(state, circuit) for met in self._metrics] + ).flatten(), + ) + + self.weighting_func = weighting_func + elif callable(metric_weight): + self.weighting_func = metric_weight + + def evaluate(self, state, circuit): + """ + Evaluate each metric function contained by the Metrics object + + :param state: the state on which to evaluate the metrics + :type state: QuantumState + :param circuit: the circuit on which to evaluate the metrics + :type circuit: CircuitBase (or a subclass of it) + :return: this function returns nothing + :rtype: None + """ + val = self.weighting_func(state, circuit) + self.increment() + + if self._inc % self.log_steps == 0: + self.log.append(val) + + return val + + @property + def per_metric_log(self): + """ + The joint log of all metric functions + + :return: the log itself + :rtype: dict (keys are metric class names, values are the logs) + """ + m = {} + for i, metric in enumerate(self._metrics): + m[metric.__class__.__name__] = metric.log + + return m + + def graph_met_value(graph_metric, g): """ Evaluates the graph metric for the given graph. From db658e1665da6a44fd9135ddb6cbd7e13926f866 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 16:29:25 -0400 Subject: [PATCH 010/227] [fix] Fix ValueError compiling ClassicalCNOT/ClassicalCZ on a MixedStabilizer classical_registers is a numpy array, but MixedStabilizer.apply_measurement returns a list of per-mixture-branch outcomes, not a scalar. ClassicalCNOT and ClassicalCZ both stored the raw list into classical_registers[op.c_register] via an unconditional/trailing assignment, raising "setting an array element with a sequence" whenever the stabilizer state was mixed. Store outcome[0] in the MixedStabilizer branch and outcome in the plain-Stabilizer branch, matching the already-correct MeasurementZ handling just below. Also drops a leftover debug print() in the ClassicalCNOT branch. Also documents, at each outcome[0]/outcomes[0] site, that this only records one mixture branch's measurement result even though branches can disagree - a pre-existing simplification (see the TODO on MixedStabilizer.apply_measurement), not something this fix resolves. --- graphiq/backends/stabilizer/compiler.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/graphiq/backends/stabilizer/compiler.py b/graphiq/backends/stabilizer/compiler.py index a177f3f5..5d42b2c8 100644 --- a/graphiq/backends/stabilizer/compiler.py +++ b/graphiq/backends/stabilizer/compiler.py @@ -123,10 +123,15 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): q_index(op.control, op.control_type), measurement_determinism=self.measurement_determinism, ) - print(outcome) state.apply_conditioned_gate( q_index(op.target, op.target_type), outcome, gate="x" ) + # outcome has one entry per mixture branch, and branches can + # disagree; classical_registers only has room for one value + # per bit, so we report branch 0's outcome. The gate itself + # is still applied correctly per-branch above. See the TODO + # on MixedStabilizer.apply_measurement. + classical_registers[op.c_register] = outcome[0] else: outcome = state.apply_measurement( @@ -135,8 +140,7 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): ) if outcome == 1: state.apply_sigmax(q_index(op.target, op.target_type)) - - classical_registers[op.c_register] = outcome + classical_registers[op.c_register] = outcome elif type(op) is ops.ClassicalCZ: # apply an Z gate on the target qubit conditioned on the measurement outcome = 1 @@ -148,6 +152,7 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): state.apply_conditioned_gate( q_index(op.target, op.target_type), outcome, gate="z" ) + # see the matching comment in the ClassicalCNOT branch above classical_registers[op.c_register] = outcome[0] else: @@ -157,8 +162,7 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): ) if outcome == 1: state.apply_sigmaz(q_index(op.target, op.target_type)) - - classical_registers[op.c_register] = outcome + classical_registers[op.c_register] = outcome elif type(op) is ops.MeasurementCNOTandReset: if isinstance(state, MixedStabilizer): @@ -191,6 +195,8 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): q_index(op.register, op.reg_type), measurement_determinism=self.measurement_determinism, ) + # see the ClassicalCNOT branch above for why only outcomes[0] + # is recorded here classical_registers[op.c_register] = outcomes[0] else: From 2e814f8785209d3b21dd9ece89c17b108e089691 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 17:02:33 -0400 Subject: [PATCH 011/227] [fix] Stop OneQubitGateReplacement silently doing nothing on Stabilizer CompilerBase.compile sends a gate with replacement noise to compile_one_noisy_gate, which applies only the noise's own gate list. OneQubitGateReplacement's Stabilizer branch built an empty gate_list, in both its Stabilizer and MixedStabilizer paths (the latter dead code, since MixedStabilizer isn't a Stabilizer subclass). So on the stabilizer backend HadamardPerturbedError/PhasePerturbedError/ SigmaXPerturbedError (all subclasses of OneQubitGateReplacement) applied neither the noise nor the gate they replace: the state's tableau was unchanged before and after. Since a general one-qubit unitary isn't generally Clifford and can't always be represented exactly in the stabilizer formalism, raise NotImplementedError instead, matching the pattern already used by LocalCliffordError and TwoQubitControlledGateReplacement in this same file. --- graphiq/noise/noise_models.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 1213f813..7193601a 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -209,14 +209,12 @@ def apply(self, state: QuantumState, n_quantum, reg_list): ) state_rep.apply_unitary(noisy_gate) elif isinstance(state_rep, Stabilizer): - gate_list = [] - if isinstance(state_rep, MixedStabilizer): - mixture = state_rep.mixture - for p_i, tableau_i in mixture: - tableau_i.apply_circuit(gate_list) - else: - state_rep.apply_circuit(gate_list) - + # TODO: Implement this for the Stabilizer backend. A general + # one-qubit unitary is not generally Clifford, so it can't always + # be represented exactly in the stabilizer formalism. + raise NotImplementedError( + "OneQubitGateReplacement has not been implemented for stabilizer backend" + ) elif isinstance(state_rep, Graph): # TODO: Implement this for Graph backend raise TypeError( From 09febbd66ee0f1765a7d026dd1cf55831ce5ec85 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 17:13:41 -0400 Subject: [PATCH 012/227] [fix] Fix AttributeError compiling a noisy MeasurementZ on DensityMatrixCompiler compile_one_noisy_gate referenced state.dm at four call sites, but QuantumState has no .dm attribute - compile_one_gate in the same file correctly unwraps via state = state.rep_data, but compile_one_noisy_gate never did that. compile() sends a gate to compile_one_noisy_gate only when it carries replacement noise, so attaching a replacement noise model to a MeasurementZ and compiling with noise_simulation enabled crashed with AttributeError. Use state.rep_data throughout, matching compile_one_gate's convention. (Three of the four occurrences, in the ClassicalControlledPairOperationBase/MeasurementCNOTandReset branches, are currently dead code per compile()'s dispatch, but fixed alongside the live one for consistency.) --- graphiq/backends/density_matrix/compiler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/graphiq/backends/density_matrix/compiler.py b/graphiq/backends/density_matrix/compiler.py index 70dd6d53..45317ace 100644 --- a/graphiq/backends/density_matrix/compiler.py +++ b/graphiq/backends/density_matrix/compiler.py @@ -226,7 +226,7 @@ def compile_one_noisy_gate( self.ops[op.__class__], ) - outcome = state.dm.apply_measurement_controlled_gate( + outcome = state.rep_data.apply_measurement_controlled_gate( projectors, unitary, measurement_determinism=self.measurement_determinism, @@ -249,7 +249,7 @@ def compile_one_noisy_gate( n_quantum, q_index(op.target, op.target_type), dm.sigmax() ) - outcome = state.dm.apply_measurement_controlled_gate( + outcome = state.rep_data.apply_measurement_controlled_gate( projectors, unitary, measurement_determinism=self.measurement_determinism, @@ -261,7 +261,7 @@ def compile_one_noisy_gate( ) classical_registers[op.c_register] = outcome - state.dm.apply_channel(reset_kraus_ops) + state.rep_data.apply_channel(reset_kraus_ops) elif type(op) is ops.MeasurementZ: # TODO: implement measurement-related error model @@ -269,7 +269,7 @@ def compile_one_noisy_gate( projectors = dm.projectors_zbasis( n_quantum, q_index(op.register, op.reg_type) ) - outcome = state.dm.apply_measurement( + outcome = state.rep_data.apply_measurement( projectors, measurement_determinism=self.measurement_determinism ) classical_registers[op.c_register] = outcome From d8aa9542713e9fbadfc68d3d22f18e98c1cf5f99 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 17:24:30 -0400 Subject: [PATCH 013/227] [fix] Fix AttributeError in RandomSearchSolver.update_hof for inf-scored candidates self.hof is initialized with placeholder entries (np.inf, None). A candidate whose score is np.isclose to an unfilled slot's placeholder took the tie-breaking branch, which dereferenced .dag on the placeholder's None circuit. Treat an unfilled slot as always losing the tie-break (any real circuit fills it) instead of crashing. --- graphiq/solvers/solver_base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index 29affdb4..9e8d2fed 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -240,7 +240,12 @@ def update_hof(self, population): for score, circuit in population: for i in range(self.setting.n_hof): if np.isclose(score, self.hof[i][0]): - if len(circuit.dag.nodes) < len(self.hof[i][1].dag.nodes): + # self.hof[i][1] is None for a still-unfilled placeholder + # slot (initialized to (np.inf, None)); any real circuit + # is preferred over an empty slot. + if self.hof[i][1] is None or len(circuit.dag.nodes) < len( + self.hof[i][1].dag.nodes + ): self.hof.insert(i, (score, circuit.copy())) self.hof.pop() break From 168375f8310adbf27e75fa0f51596da9f1beb3a0 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 21:23:13 -0400 Subject: [PATCH 014/227] [fix] Fix AttributeError in EvolutionarySolver.update_logs when n_pop < n_hof self.hof can contain unfilled (np.inf, None) placeholder entries - update_hof fills at most one slot per population member, so whenever n_pop < n_hof (an ordinary solver setting, not an exotic input) some slots are still unfilled after the first iteration. update_logs computed depth_hof over every hof entry unconditionally, calling .depth on the None placeholder circuit and crashing with AttributeError on the very first iteration. Filter out unfilled entries before computing depth_hof. Same root cause as the update_hof fix in solver_base.py, surfacing at a second call site. --- graphiq/solvers/evolutionary_solver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index 8abbbe66..f9bf9f9a 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -474,7 +474,9 @@ def update_logs(self, population: list, iteration: int): scores_hof = list(zip(*self.hof))[0] depth_pop = [circuit.depth for (_, circuit) in population] - depth_hof = [circuit.depth for (_, circuit) in self.hof] + # self.hof can still contain unfilled (np.inf, None) placeholder + # entries (e.g. whenever n_pop < n_hof), which have no circuit/depth + depth_hof = [circuit.depth for (_, circuit) in self.hof if circuit is not None] self.logs["population"].append( dict( From b825e7e19a7ef688084c256b25d4ef1a7377b14f Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 3 Sep 2026 21:43:42 -0400 Subject: [PATCH 015/227] [fix] Stop replace_photon_one_qubit_op labelling every new gate "Fixed" replace_photon_one_qubit_op called gate.add_labels("Fixed") on the gate it put in place of the old one, and remove_op never removes a "Fixed" node. Remove that line. On a HybridEvolutionarySolver circuit, whose photon gates carry no "Fixed" label, a replaced photon gate used to become permanently unremovable. It now stays removable, like the gates the other mutation operators add. In an EvolutionarySolver search that starts from initialization and uses the default transformations, every photon gate this operator replaces is one that initialization labelled "Fixed". There the line was what kept the label, so once replaced such a gate now becomes removable. This changes seeded EvolutionarySolver and HybridEvolutionarySolver searches. --- graphiq/solvers/evolutionary_solver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index f9bf9f9a..0c53664c 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -570,7 +570,6 @@ def replace_photon_one_qubit_op(self, circuit): op = self.one_qubit_ops[ind] noise = self._wrap_noise(op, self.noise_model_mapping["p"]) gate = ops.OneQubitGateWrapper(op, reg_type="p", register=reg, noise=noise) - gate.add_labels("Fixed") circuit.replace_op(node, gate) def replace_emitter_one_qubit_op(self, circuit): From d7ad36331a89457a0905fe119dd7b90cf2149fc2 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 00:55:19 -0400 Subject: [PATCH 016/227] [fix] Validate pure input in density_to_stabilizer For a pure input, density_to_stabilizer called the unvalidated _density_to_graph_pure helper, so a pure state that isn't a graph state was silently converted to the wrong stabilizer instead of raising, even though the docstring limits the function to graph states. Use density_to_graph (which validates by default) to match the mixed-state branch, which already does. As a result, QuantumState.convert_representation("s") on a pure density matrix that is not a graph state now raises instead of returning a wrong stabilizer. That includes the conversion Infidelity.evaluate makes when the target is a stabilizer and the state a density matrix. --- graphiq/backends/state_rep_conversion.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index e8a0e6c7..15aa80e2 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -312,7 +312,11 @@ def density_to_stabilizer(input_matrix): """ # first check if the state is pure if dmf.is_pure(input_matrix): - graph = _density_to_graph_pure(input_matrix) + # density_to_graph (rather than the unvalidated _density_to_graph_pure) + # so a state that isn't actually a graph state raises instead of + # silently producing the wrong stabilizer - see this function's + # docstring on the graph-state-only restriction + graph = density_to_graph(input_matrix) stabilizer = _graph_to_stabilizer_pure(graph) return [(1.0, stabilizer)] else: From bdcfb76e89fd29f06a7643a11b94e488b8d47170 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:05:05 -0400 Subject: [PATCH 017/227] [fix] Fix invalid/corrupting escape sequences in two docstrings - trace_distance (density_matrix/functions.py): the docstring was not raw, so \rho, \frac/\right and \text were silently turned into a carriage return, form feed and tab, and \s raised an invalid-escape warning. Add the r""" prefix the module's other LaTeX docstrings already use. - binary_symplectic_product (stabilizer/functions/utils.py): the LaTeX row break was written as three backslashes plus a space, an invalid escape; use four. The runtime docstring text is unchanged. Both are SyntaxWarnings on Python 3.12. Add a test that compiles every .py file in the repo and fails on any invalid escape sequence. --- graphiq/backends/density_matrix/functions.py | 2 +- .../backends/stabilizer/functions/utils.py | 2 +- tests/test_source_hygiene.py | 52 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/test_source_hygiene.py diff --git a/graphiq/backends/density_matrix/functions.py b/graphiq/backends/density_matrix/functions.py index 6024160c..58632237 100644 --- a/graphiq/backends/density_matrix/functions.py +++ b/graphiq/backends/density_matrix/functions.py @@ -659,7 +659,7 @@ def fidelity(rho, sigma): def trace_distance(rho, sigma): - """ + r""" Return the trace distance between two hermitian matrices, $\rho$ and $\sigma$ The trace distance is computed as: $$ diff --git a/graphiq/backends/stabilizer/functions/utils.py b/graphiq/backends/stabilizer/functions/utils.py index adbfb3c1..7a28218b 100644 --- a/graphiq/backends/stabilizer/functions/utils.py +++ b/graphiq/backends/stabilizer/functions/utils.py @@ -137,7 +137,7 @@ def binary_symplectic_product(matrix1, matrix2): Compute the binary symplectic product of two matrices matrix1 (:math:`M_1`) and matrix2 (:math:`M_2`) The symplectic inner product between :math:`M_1` and :math:`M_2` is :math:`M_1 P M_2^T`, - where :math:`P = \\begin{bmatrix} 0 & I \\\ I & 0 \\end{bmatrix}`. + where :math:`P = \\begin{bmatrix} 0 & I \\\\ I & 0 \\end{bmatrix}`. :param matrix1: a binary symplectic matrix :type matrix1: numpy.ndarray diff --git a/tests/test_source_hygiene.py b/tests/test_source_hygiene.py new file mode 100644 index 00000000..acd9d1ab --- /dev/null +++ b/tests/test_source_hygiene.py @@ -0,0 +1,52 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +r"""Guard against invalid escape sequences in the source tree. + +Every ``*.py`` file under the directories in ``SWEPT_DIRECTORIES`` is compiled +with all warnings enabled, and any compiler warning whose message contains +"invalid escape sequence" is collected. Python 3.12 reports an invalid escape +(e.g. ``"\s"``) as a ``SyntaxWarning``, older versions as a +``DeprecationWarning``; matching on the message covers both. +""" + +import warnings +from pathlib import Path + +SWEPT_DIRECTORIES = ("graphiq", "tests", "examples", "visualization") + + +def test_no_invalid_escape_sequences(): + root = Path(__file__).resolve().parents[1] + for directory in SWEPT_DIRECTORIES: + assert (root / directory).is_dir(), f"swept directory missing: {directory}" + + files = sorted( + path + for directory in SWEPT_DIRECTORIES + for path in (root / directory).rglob("*.py") + ) + assert files, "no .py files found under the swept directories" + + findings = [] + for path in files: + source = path.read_text(encoding="utf-8") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + compile(source, str(path), "exec") + for w in caught: + if "invalid escape sequence" in str(w.message): + findings.append((str(path.relative_to(root)), w.lineno, str(w.message))) + + assert not findings, "\n".join(str(entry) for entry in findings) From 3b407497432717ddd588cc03fa8bf048564602c4 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:06:25 -0400 Subject: [PATCH 018/227] [fix] Refuse unsupported noise on a MixedStabilizer with NotImplementedError MixedStabilizer is a sibling of Stabilizer, not a subclass, so the "not implemented for stabilizer" arms of OneQubitGateReplacement, TwoQubitControlledGateReplacement, LocalCliffordError, AmplitudeDampingNoise, MixedUnitaryError, CoherentUnitaryError, GeneralKrausError, MeasurementError and ResetError never matched a mixture - which is what a noisy StabilizerCompiler run produces. A mixture fell through to the generic "Backend type is not supported." TypeError instead. Match both classes so the refusal is the same for either representation. Also correct the Graph-arm messages of TwoQubitControlledGateReplacement and LocalCliffordError, which said "stabilizer" instead of "graph". --- graphiq/noise/noise_models.py | 22 ++++----- tests/test_noise/test_noise_models.py | 67 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 7193601a..1fb6cf86 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -208,7 +208,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): n_quantum, reg_list[0], self.noise_parameters["One-qubit unitary"] ) state_rep.apply_unitary(noisy_gate) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Implement this for the Stabilizer backend. A general # one-qubit unitary is not generally Clifford, so it can't always # be represented exactly in the stabilizer formalism. @@ -301,7 +301,7 @@ def apply(self, state: QuantumState, n_quantum, ctr_reg, target_reg): unitary = post_gate_noise @ cu_gate @ pre_gate_noise state_rep.apply_unitary(unitary) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Implement this for Stabilizer backend raise NotImplementedError( "TwoQubitControlledGateReplacement error not implemented for stabilizer representation" @@ -309,7 +309,7 @@ def apply(self, state: QuantumState, n_quantum, ctr_reg, target_reg): elif isinstance(state_rep, Graph): # TODO: Implement this for Graph backend raise NotImplementedError( - "TwoQubitControlledGateReplacement error not implemented for stabilizer representation" + "TwoQubitControlledGateReplacement error not implemented for graph representation" ) else: raise TypeError("Backend type is not supported.") @@ -454,7 +454,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): else: raise ValueError("Wrong description of a local Clifford gate.") state_rep.apply_unitary(unitary) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Implement this for the Stabilizer backend raise NotImplementedError( "Local Clifford error has not been implemented for stabilizer backend" @@ -462,7 +462,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): elif isinstance(state_rep, Graph): # TODO: Implement this for the Graph backend raise NotImplementedError( - "Local Clifford error has not been implemented for stabilizer backend" + "Local Clifford error has not been implemented for graph backend" ) else: raise TypeError("Backend type is not supported.") @@ -512,7 +512,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): state_rep.apply_channel(kraus_ops) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "AmplitudeDamping not implemented for stabilizer representation" @@ -824,7 +824,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): raise NotImplementedError( "MixedUnitary not implemented for DensityMatrix representation" ) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "MixedUnitary not implemented for Stabilizer representation" @@ -876,7 +876,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): raise NotImplementedError( "CoherentUnitaryError not implemented for density matrix representation" ) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "CoherentUnitaryError not implemented for stabilizer representation" @@ -929,7 +929,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): raise NotImplementedError( "MeasurementError not implemented for density matrix representation" ) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "MeasurementError not implemented for stabilizer representation" @@ -980,7 +980,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): if isinstance(state_rep, DensityMatrix): kraus_ops = self.get_backend_dependent_noise(state_rep, n_quantum, reg_list) state_rep.apply_channel(kraus_ops) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): raise NotImplementedError( "GeneralKrausError not implemented (and not compatible) for stabilizer representation" ) @@ -1021,7 +1021,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): raise NotImplementedError( "ResetError not implemented for density matrix representation" ) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "ResetError not implemented for stabilizer representation" diff --git a/tests/test_noise/test_noise_models.py b/tests/test_noise/test_noise_models.py index cbdeb130..acb201b5 100644 --- a/tests/test_noise/test_noise_models.py +++ b/tests/test_noise/test_noise_models.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import numpy as np +import pytest import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.stabilizer.functions.clifford as sfc @@ -35,6 +36,17 @@ def _state_initialization_stabilizer(n_quantum): ) +def _two_branch_mixture(n_quantum): + return QuantumState( + [ + (0.25, sfc.create_n_ket0_state(n_quantum)), + (0.75, sfc.create_n_ket0_state(n_quantum)), + ], + rep_type="s", + mixed=True, + ) + + def test_depolarizing_noise(): n_quantum = 3 qubit_position = 1 @@ -145,3 +157,58 @@ def test_photon_loss_dm(): noise.apply(state, n_quantum, 1) assert state.rep_data.trace == 0.9 assert not state.rep_data.normalized + + +@pytest.mark.parametrize( + "state_factory", + [_state_initialization_stabilizer, _two_branch_mixture], + ids=["pure", "mixture"], +) +def test_two_qubit_replacement_refuses_the_stabilizer_backend(state_factory): + # MixedStabilizer is a sibling of Stabilizer, not a subclass, so a mixture + # must be named explicitly to get the same NotImplementedError as a pure + # tableau instead of the generic "Backend type is not supported." TypeError. + n_quantum = 2 + state = state_factory(n_quantum) + noise = nm.TwoQubitControlledGateReplacement(np.eye(2)) + + with pytest.raises(NotImplementedError): + noise.apply(state, n_quantum, 0, 1) + + +@pytest.mark.parametrize( + "state_factory", + [_state_initialization_stabilizer, _two_branch_mixture], + ids=["pure", "mixture"], +) +@pytest.mark.parametrize( + "noise", + [ + nm.OneQubitGateReplacement(np.eye(2)), + nm.LocalCliffordError(["sigmax"]), + nm.AmplitudeDampingNoise(0.1), + nm.MixedUnitaryError([np.eye(2)], [1.0]), + nm.CoherentUnitaryError(np.eye(2)), + nm.GeneralKrausError([np.eye(2)]), + nm.MeasurementError(np.eye(2)), + nm.ResetError(), + ], + ids=[ + "one_qubit_replacement", + "local_clifford", + "amplitude_damping", + "mixed_unitary", + "coherent_unitary", + "general_kraus", + "measurement", + "reset", + ], +) +def test_one_register_noise_refuses_the_stabilizer_backend(noise, state_factory): + # None of these is representable on the stabilizer backend; each must + # refuse a mixture the same way it refuses a pure tableau. + n_quantum = 2 + state = state_factory(n_quantum) + + with pytest.raises(NotImplementedError): + noise.apply(state, n_quantum, [0]) From 1f634a4339ce50c75935ffe103ec1d0a47d696a6 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:07:03 -0400 Subject: [PATCH 019/227] [fix] Fix TypeError applying DepolarizingNoise to a pure Stabilizer DepolarizingNoise.apply converts a pure Stabilizer into a one-branch MixedStabilizer before branching, but it assigned the mixture to state.rep_data while state.mixed was still False, so the setter raised "Cannot initialize the stabilizer representation with datatype MixedStabilizer". Past that, the local state_rep still pointed at the discarded pure tableau while the rest of the method branches over the mixture. Rebind the local and set the mixed flag before assigning, as PhotonLoss already does. This path is reached when Monte Carlo noise simulation (which compiles to a pure Stabilizer) is combined with a noise map containing DepolarizingNoise. --- graphiq/noise/noise_models.py | 9 ++++++-- tests/test_noise/test_noise_models.py | 33 ++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 1fb6cf86..63d27e9d 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -590,8 +590,13 @@ def apply(self, state: QuantumState, n_quantum, reg_list): state_rep, MixedStabilizer ): if not isinstance(state_rep, MixedStabilizer): - rep_data = MixedStabilizer([(1.0, state_rep.data)]) - state.rep_data = rep_data + # Rebind the local as well as the state, since everything below + # branches over the mixture. The mixed flag has to be set + # before the assignment, which the state otherwise rejects as + # the wrong datatype. + state_rep = MixedStabilizer([(1.0, state_rep.data)]) + state.mixed = True + state.rep_data = state_rep original_prob = state_rep.probability mixture = [] diff --git a/tests/test_noise/test_noise_models.py b/tests/test_noise/test_noise_models.py index acb201b5..6a49e8c8 100644 --- a/tests/test_noise/test_noise_models.py +++ b/tests/test_noise/test_noise_models.py @@ -18,7 +18,7 @@ import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.stabilizer.functions.clifford as sfc import graphiq.noise.noise_models as nm -from graphiq.backends.stabilizer.state import MixedStabilizer +from graphiq.backends.stabilizer.state import MixedStabilizer, Stabilizer from graphiq.state import QuantumState @@ -212,3 +212,34 @@ def test_one_register_noise_refuses_the_stabilizer_backend(noise, state_factory) with pytest.raises(NotImplementedError): noise.apply(state, n_quantum, [0]) + + +@pytest.mark.parametrize("depolarizing_prob", [0.0, 0.2, 0.75]) +def test_depolarizing_noise_on_a_pure_stabilizer(depolarizing_prob): + # A pure Stabilizer (what Monte Carlo noise simulation compiles to) must be + # converted to a mixture before it can branch. The result should match the + # equivalent one-branch mixture, which reaches the same code with the + # conversion already done. + n_quantum = 2 + reg_list = [0] + pure = _state_initialization_stabilizer(n_quantum) + assert isinstance(pure.rep_data, Stabilizer) + one_branch = QuantumState( + [(1.0, sfc.create_n_ket0_state(n_quantum))], rep_type="s", mixed=True + ) + + noise = nm.DepolarizingNoise(depolarizing_prob) + noise.apply(pure, n_quantum, reg_list) + noise.apply(one_branch, n_quantum, reg_list) + + assert pure.mixed + assert isinstance(pure.rep_data, MixedStabilizer) + assert np.isclose(pure.rep_data.probability, 1.0) + got = pure.rep_data.mixture + expected = one_branch.rep_data.mixture + assert len(got) == len(expected) + for (p_got, t_got), (p_expected, t_expected) in zip(got, expected): + assert np.isclose(p_got, p_expected) + assert t_got == t_expected + if depolarizing_prob > 0: + assert len(got) > 1 From 28ca29019b984cd14ecec8735b85d0dc6990c51c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:07:39 -0400 Subject: [PATCH 020/227] [fix] Fix AttributeError applying GeneralKrausError on DensityMatrix GeneralKrausError.apply's DensityMatrix arm - the only backend the class claims to support - called self.get_backend_dependent_noise, which is not defined anywhere, so every application raised AttributeError. Embed the stored single-qubit Kraus operators on each register in reg_list (as the other per-register channels such as DepolarizingNoise do) and apply the resulting channel. Operators that are not 2x2 are refused with a ValueError naming the single-qubit restriction, instead of failing later with a dimension mismatch. --- graphiq/noise/noise_models.py | 17 ++++++++++-- tests/test_noise/test_noise_models.py | 37 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 63d27e9d..14d351ec 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -954,7 +954,8 @@ class GeneralKrausError(AdditionNoiseBase): This error may only work for the DensityMatrix backend. - # TODO: Implement this noise model by figuring out how to pass parameters + The stored operators are single-qubit; each is applied independently on + every register in ``reg_list``. """ def __init__(self, kraus_ops): @@ -983,7 +984,19 @@ def apply(self, state: QuantumState, n_quantum, reg_list): """ state_rep = state.rep_data if isinstance(state_rep, DensityMatrix): - kraus_ops = self.get_backend_dependent_noise(state_rep, n_quantum, reg_list) + single_qubit_kraus = self.noise_parameters["Kraus"] + for kraus_op in single_qubit_kraus: + if np.array(kraus_op).shape != (2, 2): + raise ValueError( + "GeneralKrausError only supports single-qubit Kraus " + "operators (shape (2, 2)), applied independently on " + "every register in reg_list." + ) + kraus_ops_iter = itertools.product(single_qubit_kraus, repeat=len(reg_list)) + kraus_ops = [ + dmf.get_multi_qubit_gate(n_quantum, reg_list, kraus_op) + for kraus_op in kraus_ops_iter + ] state_rep.apply_channel(kraus_ops) elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): raise NotImplementedError( diff --git a/tests/test_noise/test_noise_models.py b/tests/test_noise/test_noise_models.py index 6a49e8c8..f42290de 100644 --- a/tests/test_noise/test_noise_models.py +++ b/tests/test_noise/test_noise_models.py @@ -243,3 +243,40 @@ def test_depolarizing_noise_on_a_pure_stabilizer(depolarizing_prob): assert t_got == t_expected if depolarizing_prob > 0: assert len(got) > 1 + + +def test_general_kraus_error_single_qubit(): + # A bit flip with probability p on a one-qubit |0> state. + n_quantum = 1 + p = 0.3 + kraus_ops = [np.sqrt(1 - p) * np.eye(2), np.sqrt(p) * dmf.sigmax()] + state = _state_initialization_dm(n_quantum) + noise = nm.GeneralKrausError(kraus_ops) + + noise.apply(state, n_quantum, [0]) + + assert np.allclose(state.rep_data.data, np.diag([1 - p, p])) + + +def test_general_kraus_error_applies_independently_to_every_register(): + # The same single-qubit channel on both qubits of |00> factorizes. + n_quantum = 2 + p = 0.3 + kraus_ops = [np.sqrt(1 - p) * np.eye(2), np.sqrt(p) * dmf.sigmax()] + state = _state_initialization_dm(n_quantum) + noise = nm.GeneralKrausError(kraus_ops) + + noise.apply(state, n_quantum, [0, 1]) + + single_qubit_result = np.diag([1 - p, p]) + expected = np.kron(single_qubit_result, single_qubit_result) + assert np.allclose(state.rep_data.data, expected) + + +def test_general_kraus_error_refuses_operators_wider_than_one_qubit(): + n_quantum = 1 + state = _state_initialization_dm(n_quantum) + noise = nm.GeneralKrausError([np.eye(4)]) + + with pytest.raises(ValueError, match="single-qubit"): + noise.apply(state, n_quantum, [0]) From 2e8679233f4c11e282c356d7bde4a39d245d8d17 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:07:49 -0400 Subject: [PATCH 021/227] [fix] Stop default-constructed ResetErrors sharing one noise_parameters dict ResetError.__init__ defaulted noise_parameters to a literal {}, so every default-constructed instance held the same dict object. Default to None; NoiseBase.__init__ already turns None into a fresh empty dict, matching the other noise base classes. --- graphiq/noise/noise_models.py | 2 +- tests/test_noise/test_noise_models.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 14d351ec..2ece5689 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -1017,7 +1017,7 @@ class ResetError(NoiseBase): # TODO: implement this error model """ - def __init__(self, noise_parameters={}): + def __init__(self, noise_parameters=None): super().__init__(noise_parameters) def apply(self, state: QuantumState, n_quantum, reg_list): diff --git a/tests/test_noise/test_noise_models.py b/tests/test_noise/test_noise_models.py index f42290de..12b8f10b 100644 --- a/tests/test_noise/test_noise_models.py +++ b/tests/test_noise/test_noise_models.py @@ -280,3 +280,10 @@ def test_general_kraus_error_refuses_operators_wider_than_one_qubit(): with pytest.raises(ValueError, match="single-qubit"): noise.apply(state, n_quantum, [0]) + + +def test_reset_error_default_noise_parameters_are_not_shared_between_instances(): + a = nm.ResetError() + b = nm.ResetError() + + assert a.noise_parameters is not b.noise_parameters From c6c50559fb1e8814934f10efea2a7ccd284ba964 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:08:45 -0400 Subject: [PATCH 022/227] [fix] Return the density matrix from stabilizer_to_density for a mixture stabilizer_to_density's mixed-state branch (a list of (probability, StabilizerTableau) pairs) summed the branches and then fell off the end without returning, so it returned None - and converting a mixed stabilizer QuantumState to "dm" failed with "TypeError: Input must be a numpy.ndarray or an integer". The accumulator was also the integer 0, so a real-valued first branch followed by a Y-containing (complex) branch raised a numpy casting error on the in-place add. Start from a complex zero matrix and return the sum. --- graphiq/backends/state_rep_conversion.py | 6 +++++- .../test_state_rep_conversion.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index 15aa80e2..524a5286 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -264,10 +264,14 @@ def stabilizer_to_density(input_stabilizer): stab = StabilizerTableau([x_matrix, z_matrix]) return _stabilizer_to_density_pure(stab) elif isinstance(input_stabilizer[0], tuple): - rho = 0 + # complex accumulator: a branch containing Y is complex, and adding + # it in place to a real array (from an earlier real branch) raises + n_qubits = input_stabilizer[0][1].n_qubits + rho = np.zeros((2**n_qubits, 2**n_qubits), dtype=complex) for p_i, tableau_i in input_stabilizer: rho += p_i * _stabilizer_to_density_pure(tableau_i) + return rho else: raise ValueError( "Invalid stabilizer input: use either StabilizerTableau or a mixed state representation." diff --git a/tests/test_state_representation/test_state_rep_conversion.py b/tests/test_state_representation/test_state_rep_conversion.py index 854ff941..a917bad9 100644 --- a/tests/test_state_representation/test_state_rep_conversion.py +++ b/tests/test_state_representation/test_state_rep_conversion.py @@ -207,3 +207,16 @@ def test_graph_to_density_3_linear_cluster(): assert np.size(rho) == 8**2 # Test Hermiticity assert (np.conjugate(rho.transpose()) == rho).all() + + +def test_stabilizer_to_density_mixture(): + # A mixture of |0> (real density matrix) followed by |+i> (stabilized by Y, + # so complex) must return the weighted sum. + ket0 = StabilizerTableau([np.array([[0]]), np.array([[1]])]) + ket_plus_i = StabilizerTableau([np.array([[1]]), np.array([[1]])]) + rho = rc.stabilizer_to_density([(0.25, ket0), (0.75, ket_plus_i)]) + + rho_0 = np.array([[1, 0], [0, 0]]) + rho_plus_i = np.array([[1, -1j], [1j, 1]]) / 2 + assert rho is not None + assert np.allclose(rho, 0.25 * rho_0 + 0.75 * rho_plus_i) From a0098b3f6680145d8eb7ca789e5ac8aaa689a12c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:10:09 -0400 Subject: [PATCH 023/227] [fix] Fix eigenvector indexing in density_to_graph/density_to_stabilizer scipy's linalg.eigh returns eigenvectors as columns, but the mixed-state branches of both functions read eigenvectors[i] (a row) and computed eigenvectors[i] @ eigenvectors[i].conj().T, which collapses to a scalar instead of the rank-1 projector |v_i><+| + 0.3 |-><-|: each branch must be built from one eigenvector + # (a column of eigh's output) and carry its own eigenvalue as the weight. + plus = np.array([1, 1]) / np.sqrt(2) + minus = np.array([1, -1]) / np.sqrt(2) + rho = 0.7 * np.outer(plus, plus) + 0.3 * np.outer(minus, minus) + + graph_list = rc.density_to_graph(rho, validate=False) + + weights = sorted(float(p_i) for p_i, _ in graph_list) + assert np.allclose(weights, [0.3, 0.7]) + for _, adj in graph_list: + assert np.array_equal(adj, np.zeros((1, 1))) From 0f8c4be6affe5290084240bafb078a473a3c4e2e Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:10:59 -0400 Subject: [PATCH 024/227] [fix] Fall back to nx.random_labeled_tree where nx.random_tree is gone networkx 3.4 removed nx.random_tree (replaced by random_labeled_tree, added in 3.2), and pyproject.toml allows any networkx 3.x, so a fresh install hits AttributeError in data_collection/ui_functions.py's "tree" graph type and in test_random_state_converter. Use random_tree where it still exists, so older networkx versions keep generating the same graphs, and random_labeled_tree otherwise. --- graphiq/data_collection/ui_functions.py | 4 +++- tests/test_state_representation/test_graph_LC_equivalence.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/graphiq/data_collection/ui_functions.py b/graphiq/data_collection/ui_functions.py index f6a40333..7b1351a7 100644 --- a/graphiq/data_collection/ui_functions.py +++ b/graphiq/data_collection/ui_functions.py @@ -56,7 +56,9 @@ def t_graph(gtype, n, seed=None, show=False): if gtype == "draw": g = graph_gui() elif gtype == "tree": - g = nx.random_tree(n, seed=seed) + # nx.random_tree was removed in networkx 3.4 in favour of random_labeled_tree + random_tree = getattr(nx, "random_tree", None) or nx.random_labeled_tree + g = random_tree(n, seed=seed) elif gtype == "rnd": rng = np.random.default_rng(seed=seed) p = rng.integers(low=15, high=95, size=1)[0] / 100 diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index 061ebf65..94ab0fb1 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -136,7 +136,9 @@ def test_random_state_converter(seed): convert one to the other. """ - g = nx.random_tree(12, seed) + # nx.random_tree was removed in networkx 3.4 in favour of random_labeled_tree + random_tree = getattr(nx, "random_tree", None) or nx.random_labeled_tree + g = random_tree(12, seed=seed) # also use the parameter seed to determine which node to apply local complementation on gg = local_comp_graph(g, seed) tab1 = get_stabilizer_tableau_from_graph(g) From 9135b33a6400eb1df988d2ffa1bcb25deec1b85c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:11:52 -0400 Subject: [PATCH 025/227] [fix] Make trace_out_qubits discard the qubits it is given Stabilizer.trace_out_qubits and MixedStabilizer.trace_out_qubits passed the qubits to trace out as partial_trace's keep= argument, so they kept exactly the qubits they were asked to discard. Pass the complement instead. --- graphiq/backends/stabilizer/state.py | 13 ++++++-- .../stabilizer/test_stabilizer_state.py | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index 44bc066f..f4b1cab5 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -307,9 +307,11 @@ def trace_out_qubits( :return: nothing :rtype: None """ + # partial_trace takes the qubits to keep, not the ones to trace out + keep = [q for q in range(self.n_qubits) if q not in set(qubit_positions)] self._tableau = sfc.partial_trace( self._tableau, - keep=qubit_positions, + keep=keep, dims=self.n_qubits * [2], measurement_determinism=measurement_determinism, ) @@ -724,13 +726,18 @@ def trace_out_qubits( :return: nothing :rtype: None """ + # partial_trace takes the qubits to keep, not the ones to trace out. + # Both are computed before the mixture is rebuilt, since n_qubits is + # read from it. + n_qubits = self.n_qubits + keep = [q for q in range(n_qubits) if q not in set(qubit_positions)] self._mixture = [ ( p_i, sfc.partial_trace( t_i, - keep=qubit_positions, - dims=self.n_qubits * [2], + keep=keep, + dims=n_qubits * [2], measurement_determinism=measurement_determinism, ), ) diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_state.py b/tests/test_state_representation/stabilizer/test_stabilizer_state.py index fd0b6e21..4037e460 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_state.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_state.py @@ -28,3 +28,33 @@ def test_state1(): state.apply_hadamard(1) state.tableau.destabilizer_from_labels(["XYXI", "ZYZI", "YIII", "ZZZZ"]) print(state.tableau.destabilizer) + + +def _plus_on_qubit_1(n_qubits): + state = Stabilizer(n_qubits) + state.apply_hadamard(1) + return state + + +def test_trace_out_qubits_discards_the_given_qubits(): + # |0>|+>|0>: tracing out qubit 0 must leave |+>|0>, not keep qubit 0 alone + state = _plus_on_qubit_1(3) + state.trace_out_qubits([0], measurement_determinism=0) + + expected = Stabilizer(2) + expected.apply_hadamard(0) + assert state.n_qubits == 2 + assert state == expected + + +def test_mixed_trace_out_qubits_discards_the_given_qubits(): + state = MixedStabilizer( + [(0.5, _plus_on_qubit_1(3).tableau), (0.5, _plus_on_qubit_1(3).tableau)] + ) + state.trace_out_qubits([0], measurement_determinism=0) + + expected = Stabilizer(2) + expected.apply_hadamard(0) + assert state.n_qubits == 2 + for _, tableau in state.mixture: + assert Stabilizer(tableau) == expected From 54b5a5a3a84efa70eac1b46678f14a0b2f4f74d5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:14:50 -0400 Subject: [PATCH 026/227] [fix] Raise, rather than return, MixedStabilizer.tableau's TypeError MixedStabilizer.tableau returned a TypeError instance instead of raising it, so reading the property on a mixture silently yielded an exception object that failed later and far from the cause. Raise it. --- graphiq/backends/stabilizer/state.py | 8 +++++++- .../stabilizer/test_stabilizer_state.py | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index f4b1cab5..6e716c65 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -474,7 +474,13 @@ def probability(self): @property def tableau(self): - return TypeError( + """ + A mixture has one tableau per branch, not one overall; read + :attr:`mixture` instead. + + :raises TypeError: always + """ + raise TypeError( "Simulating using a mixed state representation, no tableau defined." ) diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_state.py b/tests/test_state_representation/stabilizer/test_stabilizer_state.py index 4037e460..c600641e 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_state.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_state.py @@ -12,6 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import pytest + from graphiq.backends.stabilizer.state import * @@ -58,3 +60,9 @@ def test_mixed_trace_out_qubits_discards_the_given_qubits(): assert state.n_qubits == 2 for _, tableau in state.mixture: assert Stabilizer(tableau) == expected + + +def test_mixed_stabilizer_tableau_raises(): + state = MixedStabilizer([(1.0, Stabilizer(2).tableau)]) + with pytest.raises(TypeError): + state.tableau From af808271bdd1c31653307cce8777ba46414c7aa7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:15:19 -0400 Subject: [PATCH 027/227] [fix] Fix side effect and AttributeErrors in the stabilizer __eq__ methods - MixedStabilizer.__eq__ called self.sort() and other.sort(), so comparing two mixtures reordered both of them in place. Compare sorted copies instead. - Stabilizer.__eq__ and MixedStabilizer.__eq__ reached straight for other.data / other.mixture, so comparing against anything else (an int, None, or a pure vs. mixed stabilizer) raised AttributeError. Return NotImplemented for a foreign operand so Python falls back to the reflected comparison / identity, which gives False. --- graphiq/backends/stabilizer/state.py | 27 ++++++++++++------- .../stabilizer/test_stabilizer_state.py | 24 +++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index 6e716c65..cd6d658a 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -344,9 +344,12 @@ def __eq__(self, other): :param other: the other Stabilizer to be compared :type other: Stabilizer - :return: True if the stabilizer tableaux of two Stabilizer objects are the same - :rtype: bool + :return: True if the stabilizer tableaux of two Stabilizer objects are + the same; NotImplemented if other is not a Stabilizer + :rtype: bool or NotImplementedType """ + if not isinstance(other, Stabilizer): + return NotImplemented tableau1 = canonical_form(self.data.to_stabilizer()) tableau2 = canonical_form(other.data.to_stabilizer()) return tableau1 == tableau2 @@ -798,21 +801,25 @@ def __eq__(self, other): :param other: the other MixedStabilizer to be compared :type other: MixedStabilizer - :return: True if the stabilizer tableaux of two MixedStabilizer objects are the same - :rtype: bool + :return: True if the stabilizer tableaux of two MixedStabilizer objects + are the same; NotImplemented if other is not a MixedStabilizer + :rtype: bool or NotImplementedType """ + if not isinstance(other, MixedStabilizer): + return NotImplemented # Treat two objects the same if and only if they have the same probability distribution # and same set of tableaux with the same probability if len(self._mixture) != len(other.mixture): return False - # sort first - self.sort() - other.sort() - for i in range(len(self._mixture)): - if not np.isclose(self._mixture[i][0], other.mixture[i][0]): + # Compare sorted copies so the comparison does not reorder either + # object's mixture as a side effect. + mixture1 = sorted(self._mixture, key=lambda item: item[0], reverse=True) + mixture2 = sorted(other.mixture, key=lambda item: item[0], reverse=True) + for i in range(len(mixture1)): + if not np.isclose(mixture1[i][0], mixture2[i][0]): return False else: - if self._mixture[i][1] != other.mixture[i][1]: + if mixture1[i][1] != mixture2[i][1]: return False return True diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_state.py b/tests/test_state_representation/stabilizer/test_stabilizer_state.py index c600641e..0dc99683 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_state.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_state.py @@ -66,3 +66,27 @@ def test_mixed_stabilizer_tableau_raises(): state = MixedStabilizer([(1.0, Stabilizer(2).tableau)]) with pytest.raises(TypeError): state.tableau + + +def test_mixed_stabilizer_eq_does_not_reorder_its_operands(): + t_low = Stabilizer(2).tableau + t_high = _plus_on_qubit_1(2).tableau + a = MixedStabilizer([(0.25, t_low), (0.75, t_high)]) + b = MixedStabilizer([(0.25, t_low.copy()), (0.75, t_high.copy())]) + + assert a == b + assert [p_i for p_i, _ in a.mixture] == [0.25, 0.75] + assert [p_i for p_i, _ in b.mixture] == [0.25, 0.75] + + +@pytest.mark.parametrize("other", [3, "state", None]) +def test_stabilizer_eq_against_a_non_stabilizer(other): + assert (Stabilizer(2) == other) is False + assert (MixedStabilizer([(1.0, Stabilizer(2).tableau)]) == other) is False + + +def test_pure_and_mixed_stabilizers_compare_unequal_without_raising(): + pure = Stabilizer(2) + mixed = MixedStabilizer([(1.0, Stabilizer(2).tableau)]) + assert (pure == mixed) is False + assert (mixed == pure) is False From ee3e1f9603e8d3c0458049048157f775fd49ebb0 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:15:51 -0400 Subject: [PATCH 028/227] [fix] Fix AttributeError and broadcast false-positive in DensityMatrix.__eq__ DensityMatrix.__eq__ was a bare np.allclose(self._data, other.data): comparing against a non-DensityMatrix (an int, None, ...) raised AttributeError, comparing matrices of different sizes raised a broadcasting ValueError, and a (1, 1) matrix could compare equal to a larger one by broadcasting. Return NotImplemented for a foreign operand and False when the shapes differ. --- graphiq/backends/density_matrix/state.py | 10 ++++++++-- .../density_matrix/test_density_matrix_state.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index d3e79d05..5e61100a 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -270,7 +270,13 @@ def __eq__(self, other): :param other: another DensityMatrix object :type other: DensityMatrix - :return: True if they are equal; False otherwise - :rtype: bool + :return: True if they are equal; False otherwise (including when their + sizes differ); NotImplemented if other is not a DensityMatrix + :rtype: bool or NotImplementedType """ + if not isinstance(other, DensityMatrix): + return NotImplemented + # np.allclose would broadcast e.g. a (1, 1) matrix against a (2, 2) one + if self._data.shape != other.data.shape: + return False return np.allclose(self._data, other.data) diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_state.py b/tests/test_state_representation/density_matrix/test_density_matrix_state.py index f8bf858a..64c104a9 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_state.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_state.py @@ -46,3 +46,15 @@ def test_density_matrix_get_single_qubit_gate(): # TODO: replace these prints by assert np.allclose( <>, expected) print(dmf.get_one_qubit_gate(3, 0, dmf.sigmaz())) print(dmf.get_two_qubit_controlled_gate(3, 0, 1, dmf.sigmax())) + + +def test_density_matrix_eq_against_a_non_density_matrix(): + dm = DensityMatrix(np.array([[1.0, 0.0], [0.0, 0.0]])) + assert (dm == 3) is False + assert (dm == None) is False + + +def test_density_matrix_eq_different_sizes(): + # a (1, 1) matrix must not broadcast into equality with a (2, 2) one + assert (DensityMatrix(np.ones((1, 1))) == DensityMatrix(np.ones((2, 2)))) is False + assert (DensityMatrix(np.eye(2) / 2) == DensityMatrix(np.eye(4) / 4)) is False From e6ef168920e7e5c18f69758674a5dac0c9b11e70 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:16:48 -0400 Subject: [PATCH 029/227] [fix] Fix the Pauli row scanners' loop bound and unknown-type fallback - one_pauli_type_finder and pauli_type_finder bounded their row loop by the number of columns (np.shape(x_matrix)[1]) instead of rows, so on a non-square pair they silently skipped the lower rows or indexed past the end. Every production caller passes a square matrix, so results there are unchanged. - one_pauli_type_finder mapped any unrecognised pauli_type to the identity pair (0, 0), silently returning the identity rows instead of failing. Raise ValueError for anything but "x", "y" or "z". --- .../stabilizer/functions/stabilizer.py | 24 +++++++---- .../stabilizer/test_stabilizer_functions.py | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/graphiq/backends/stabilizer/functions/stabilizer.py b/graphiq/backends/stabilizer/functions/stabilizer.py index 720569bc..152fdf0c 100644 --- a/graphiq/backends/stabilizer/functions/stabilizer.py +++ b/graphiq/backends/stabilizer/functions/stabilizer.py @@ -37,24 +37,30 @@ def one_pauli_type_finder(x_matrix, z_matrix, pivot, pauli_type): :type z_matrix: np.ndarray :param pivot: the location of the pivot element [i,j] on the i-th row and j-th column of the stabilizer tableau :type pivot: list - :param pauli_type: A string to specify the type of Pauli to be found + :param pauli_type: which Pauli to look for: "x", "y" or "z", case-insensitively :type pauli_type: str + :raises ValueError: if pauli_type is not one of "x", "y" or "z" :return: a list containing the positions (row indices) of the Pauli operators of type pauli_type in and below the row specified by pivot[0] in the column given by pivot[1] :rtype: list """ - if pauli_type.lower() == "x": + pauli = pauli_type.lower() + if pauli == "x": match = (1, 0) - elif pauli_type.lower() == "y": + elif pauli == "y": match = (1, 1) - elif pauli_type.lower() == "z": + elif pauli == "z": match = (0, 1) else: - match = (0, 0) - n_qubits = np.shape(x_matrix)[1] + # silently matching the identity rows here would, e.g., let + # canonical_form return a tableau that is not in canonical form + raise ValueError( + f"pauli_type must be one of 'x', 'y' or 'z' (case-insensitive), got {pauli_type!r}" + ) + n_row = np.shape(x_matrix)[0] pauli_list = [] - for row_i in range(pivot[0], n_qubits): + for row_i in range(pivot[0], n_row): if (x_matrix[row_i, pivot[1]], z_matrix[row_i, pivot[1]]) == match: pauli_list.append(row_i) @@ -77,7 +83,7 @@ def pauli_type_finder(x_matrix, z_matrix, pivot): it means there are Pauli X operators in rows 3 and 4 in the pivot column. :rtype: list, list, list """ - n_qubits = np.shape(x_matrix)[1] + n_row = np.shape(x_matrix)[0] # list of the rows (generators) with a pauli X operator in the pivot column pauli_x_list = [] # list of the rows (generators) with a pauli Y operator in the pivot column @@ -85,7 +91,7 @@ def pauli_type_finder(x_matrix, z_matrix, pivot): # list of the rows (generators) with a pauli Z operator in the pivot column pauli_z_list = [] - for row_i in range(pivot[0], n_qubits): + for row_i in range(pivot[0], n_row): if x_matrix[row_i, pivot[1]] == 1 and z_matrix[row_i, pivot[1]] == 0: pauli_x_list.append(row_i) elif x_matrix[row_i, pivot[1]] == 1 and z_matrix[row_i, pivot[1]] == 1: diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py index d03aafad..35c47e4a 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py @@ -19,6 +19,7 @@ import numpy as np import graphiq.backends.stabilizer.functions.clifford as sfc import graphiq.backends.stabilizer.functions.metric as sfm +import graphiq.backends.stabilizer.functions.stabilizer as sfs import graphiq.backends.stabilizer.functions.rep_conversion as conversion import graphiq.backends.stabilizer.functions.utils as sfu from graphiq.circuit import ops @@ -216,3 +217,45 @@ def test_clifford_from_stabilizer(): assert tableau1 == tableau2 print(tableau1) print(tableau2) + + +def _mixed_pauli_column(): + # first column holds X, Y, Z and I, one per row + x_matrix = np.array([[1, 0, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]) + z_matrix = np.array([[0, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0]]) + return x_matrix, z_matrix + + +@pytest.mark.parametrize( + "pauli_type, expected", + [("x", [0]), ("y", [1]), ("z", [2]), ("X", [0]), ("Y", [1]), ("Z", [2])], +) +def test_one_pauli_type_finder_selects_the_rows_of_that_type(pauli_type, expected): + x_matrix, z_matrix = _mixed_pauli_column() + + assert sfs.one_pauli_type_finder(x_matrix, z_matrix, [0, 0], pauli_type) == expected + + +@pytest.mark.parametrize("pauli_type", ["zz", "q", "i", "I", ""]) +def test_one_pauli_type_finder_rejects_a_non_pauli_type(pauli_type): + x_matrix, z_matrix = _mixed_pauli_column() + + with pytest.raises(ValueError, match=repr(pauli_type)): + sfs.one_pauli_type_finder(x_matrix, z_matrix, [0, 0], pauli_type) + + +def test_the_row_scanners_are_bounded_by_the_row_count(): + # Four rows, two columns: a column-count bound stops after two rows and + # never sees the Z in row 2. + tall_x = np.array([[1, 0], [1, 0], [0, 0], [0, 0]]) + tall_z = np.array([[0, 0], [1, 0], [1, 0], [0, 0]]) + + assert sfs.one_pauli_type_finder(tall_x, tall_z, [0, 0], "z") == [2] + assert sfs.pauli_type_finder(tall_x, tall_z, [0, 0]) == ([0], [1], [2]) + + # Two rows, four columns: a column-count bound indexes past the end. + wide_x = np.array([[1, 0, 0, 0], [0, 0, 0, 0]]) + wide_z = np.array([[0, 0, 0, 0], [1, 0, 0, 0]]) + + assert sfs.one_pauli_type_finder(wide_x, wide_z, [0, 0], "x") == [0] + assert sfs.pauli_type_finder(wide_x, wide_z, [0, 0]) == ([0], [], [1]) From d1c3378f0d807e3317fb818508c435ad7787157c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:17:31 -0400 Subject: [PATCH 030/227] [fix] Reject a StabilizerTableau built from a non-square X/Z pair StabilizerTableau's list branch checked that the X and Z matrices had matching shapes but not that they were square, and took n_qubits from the column axis, so e.g. a 4x2 pair built a self-contradictory tableau. This is reachable from stabilizer_to_density's list[str] input: stabilizer_to_density(["XZI", "ZXI"]) (two generators on three qubits) returned an 8x8 "density matrix" of trace 2. Raise ValueError for a non-square or non-2-D pair, and take n_qubits from the row axis like the other constructor branches. --- graphiq/backends/stabilizer/tableau.py | 9 +++++++- .../stabilizer/test_stabilizer_functions.py | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/stabilizer/tableau.py b/graphiq/backends/stabilizer/tableau.py index 77ecf108..c8920b3b 100644 --- a/graphiq/backends/stabilizer/tableau.py +++ b/graphiq/backends/stabilizer/tableau.py @@ -116,8 +116,15 @@ def __init__(self, data, phase=None): assert len(data) == 2 assert isinstance(data[0], np.ndarray) and isinstance(data[1], np.ndarray) assert data[0].shape == data[1].shape + # n generators on n qubits: a non-square pair is not a stabilizer + # state and would otherwise produce a wrongly sized tableau + if data[0].ndim != 2 or data[0].shape[0] != data[0].shape[1]: + raise ValueError( + "x_matrix and z_matrix must be square 2-D arrays, got shape " + f"{data[0].shape}" + ) self._table = np.hstack(data).astype(int) - self.n_qubits = data[0].shape[1] + self.n_qubits = data[0].shape[0] else: raise TypeError("Cannot support the input type") diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py index 35c47e4a..e1df2de0 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py @@ -32,6 +32,7 @@ from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.functions.stabilizer import rref from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.backends.state_rep_conversion import stabilizer_to_density from graphiq.metrics import Infidelity @@ -259,3 +260,23 @@ def test_the_row_scanners_are_bounded_by_the_row_count(): assert sfs.one_pauli_type_finder(wide_x, wide_z, [0, 0], "x") == [0] assert sfs.pauli_type_finder(wide_x, wide_z, [0, 0]) == ([0], [], [1]) + + +@pytest.mark.parametrize( + "shape", [(4, 2), (2, 4), (2,), (2, 2, 2)], ids=["tall", "wide", "1d", "3d"] +) +def test_stabilizer_tableau_rejects_a_non_square_pair(shape): + with pytest.raises(ValueError, match="square 2-D"): + StabilizerTableau([np.zeros(shape, dtype=int), np.zeros(shape, dtype=int)]) + + +def test_stabilizer_to_density_rejects_too_few_generators(): + # two generators on three qubits used to give an (8, 8) matrix of trace 2 + with pytest.raises(ValueError, match="square 2-D"): + stabilizer_to_density(["XZI", "ZXI"]) + + +def test_stabilizer_tableau_accepts_a_square_pair(): + tableau = StabilizerTableau([np.eye(3, dtype=int), np.zeros((3, 3), dtype=int)]) + assert tableau.n_qubits == 3 + assert tableau.table.shape == (3, 6) From 1b54bfced7cd342807e61e8ba626113dedd8e65a Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:18:07 -0400 Subject: [PATCH 031/227] [fix] Reject an unrecognised target basis in TimeReversedSolver._change_pauli_type _change_pauli_type returned an empty gate list for any `result` other than "x", "y" or "z", so a typo was indistinguishable from "already in that basis" and the caller carried on as if the rotation had happened. Raise ValueError instead. All in-tree callers pass "z", so solver output is unchanged. Also adds direct tests for every basis pair. --- graphiq/solvers/time_reversed_solver.py | 9 ++- .../test_solvers/test_deterministic_solver.py | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/graphiq/solvers/time_reversed_solver.py b/graphiq/solvers/time_reversed_solver.py index fa8f4bee..9cfb346b 100644 --- a/graphiq/solvers/time_reversed_solver.py +++ b/graphiq/solvers/time_reversed_solver.py @@ -329,11 +329,18 @@ def _change_pauli_type(self, tableau, row, column, result="z"): :type row: int :param column: the qubit index in the tableau :type column: int - :param result: the Pauli type to be transformed to + :param result: which Pauli to transform to: "x", "y" or "z", case-insensitively :type result: str + :raises ValueError: if result is not one of "x", "y" or "z" :return: a list of one-qubit gates to be added to the circuit :rtype: list[ops.OperationBase] """ + if result.lower() not in ("x", "y", "z"): + raise ValueError( + f"result must be one of 'x', 'y' or 'z' (case-insensitive), got {result!r}" + ) + # An identity entry (x=0, z=0) falls through every block below and + # returns no gates; callers rely on that for registers holding identity. gate_list = [] if tableau.x_matrix[row, column] == 1 and tableau.z_matrix[row, column] == 0: diff --git a/tests/test_solvers/test_deterministic_solver.py b/tests/test_solvers/test_deterministic_solver.py index 765bb6d6..0295e37d 100644 --- a/tests/test_solvers/test_deterministic_solver.py +++ b/tests/test_solvers/test_deterministic_solver.py @@ -20,6 +20,7 @@ from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.density_matrix.functions import fidelity from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.tableau import StabilizerTableau from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, ) @@ -28,6 +29,7 @@ from graphiq.solvers.time_reversed_solver import TimeReversedSolver from graphiq.state import QuantumState from graphiq.benchmarks.circuits import linear_cluster_4qubit_circuit +from graphiq.circuit import ops def test_linear4(): @@ -152,3 +154,61 @@ def test_random_graph_states(n_nodes): final_state.partial_trace([*range(n_nodes)], n_total * [2]) fid = fidelity(target_state, np.round(final_state.rep_data.data, 8)) assert fid > 0.99 + + +def _one_qubit_tableau(pauli): + x = np.array([[1 if pauli in "XY" else 0]]) + z = np.array([[1 if pauli in "YZ" else 0]]) + return StabilizerTableau([x, z], np.array([0])) + + +def _cell(tableau): + return {(1, 0): "X", (1, 1): "Y", (0, 1): "Z", (0, 0): "I"}[ + (tableau.x_matrix[0, 0], tableau.z_matrix[0, 0]) + ] + + +@pytest.mark.parametrize( + "current, result, expected_gates, expected_final", + [ + ("X", "x", [], "X"), + ("X", "y", [ops.SigmaZ, ops.Phase], "Y"), + ("X", "z", [ops.Hadamard], "Z"), + ("Y", "x", [ops.Phase], "X"), + ("Y", "y", [], "Y"), + ("Y", "z", [ops.Phase, ops.Hadamard], "Z"), + ("Z", "x", [ops.Hadamard], "X"), + ("Z", "y", [ops.Hadamard, ops.SigmaZ, ops.Phase], "Y"), + ("Z", "z", [], "Z"), + ("X", "Z", [ops.Hadamard], "Z"), + ("Z", "X", [ops.Hadamard], "X"), + ], +) +def test_change_pauli_type_rotates_between_the_three_bases( + current, result, expected_gates, expected_final +): + solver = TimeReversedSolver.__new__(TimeReversedSolver) + tableau = _one_qubit_tableau(current) + + gates = solver._change_pauli_type(tableau, 0, 0, result) + + assert gates == expected_gates + assert _cell(tableau) == expected_final + + +def test_change_pauli_type_leaves_an_identity_entry_alone(): + solver = TimeReversedSolver.__new__(TimeReversedSolver) + tableau = _one_qubit_tableau("I") + + assert solver._change_pauli_type(tableau, 0, 0, "z") == [] + assert _cell(tableau) == "I" + + +@pytest.mark.parametrize("pauli", ["X", "I"]) +@pytest.mark.parametrize("result", ["q", "", "zz", "i"]) +def test_change_pauli_type_rejects_an_unrecognised_basis(result, pauli): + solver = TimeReversedSolver.__new__(TimeReversedSolver) + tableau = _one_qubit_tableau(pauli) + + with pytest.raises(ValueError, match=repr(result)): + solver._change_pauli_type(tableau, 0, 0, result) From ef794107c7b51f93233e00f4a4f35c499eee2a15 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:18:53 -0400 Subject: [PATCH 032/227] [fix] Return the added register's index from add_*_register CircuitBase.add_emitter_register / add_photonic_register / add_classical_register document an int return (the new register's index), but CircuitBase._add_register and CircuitDAG._add_register dropped the value, so they always returned None. Return it. --- graphiq/circuit/circuit_base.py | 2 +- graphiq/circuit/circuit_dag.py | 10 ++--- .../test_add_register_return_value.py | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 tests/test_circuits/test_add_register_return_value.py diff --git a/graphiq/circuit/circuit_base.py b/graphiq/circuit/circuit_base.py index ca5d6764..3e9c1777 100644 --- a/graphiq/circuit/circuit_base.py +++ b/graphiq/circuit/circuit_base.py @@ -295,7 +295,7 @@ def _add_register(self, reg_type: str, size=1): :return: the index number of the added register :rtype: int """ - self._registers.add_register(reg_type, size) + return self._registers.add_register(reg_type, size) def expand_emitter_register(self, register, new_size): """ diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index debebeae..b386b629 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -716,16 +716,16 @@ def _add_register(self, reg_type: str, size=1): :param reg_type: 'e' to add an emitter qubit register, 'p' to add a photonic qubit register, 'c' to add a classical register :type reg_type: str - :return: function returns nothing - :rtype: None + :return: the index of the added register + :rtype: int """ if size != 1: raise ValueError( f"_add_register for this circuit class must only add registers of size 1" ) - self._add_reg_if_absent( - register=len(self._registers[reg_type]), reg_type=reg_type - ) + register = len(self._registers[reg_type]) + self._add_reg_if_absent(register=register, reg_type=reg_type) + return register def _add_reg_if_absent(self, register, reg_type): """ diff --git a/tests/test_circuits/test_add_register_return_value.py b/tests/test_circuits/test_add_register_return_value.py new file mode 100644 index 00000000..3319dd82 --- /dev/null +++ b/tests/test_circuits/test_add_register_return_value.py @@ -0,0 +1,41 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from graphiq.circuit.circuit_dag import CircuitDAG + + +def test_add_emitter_register_returns_index(): + dag = CircuitDAG(n_emitter=0, n_photon=0, n_classical=0) + + assert dag.add_emitter_register() == 0 + assert dag.add_emitter_register() == 1 + assert dag.add_emitter_register() == 2 + + +def test_add_photonic_and_classical_registers_number_independently(): + dag = CircuitDAG(n_emitter=0, n_photon=0, n_classical=0) + + assert dag.add_emitter_register() == 0 + assert dag.add_emitter_register() == 1 + + assert dag.add_photonic_register() == 0 + assert dag.add_photonic_register() == 1 + assert dag.add_photonic_register() == 2 + + assert dag.add_classical_register() == 0 + assert dag.add_classical_register() == 1 + + # Adding another emitter register continues the emitter numbering, + # unaffected by the photonic/classical registers added above. + assert dag.add_emitter_register() == 2 From 5d37d89e81c5a9b7aa8380cfb28fe4ef55ed8af1 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:19:16 -0400 Subject: [PATCH 033/227] [fix] Make OneQubitGateWrapper's nesting guard actually fire OneQubitGateWrapper checked `not isinstance(op_class, OneQubitGateWrapper)` on the classes it is given, which is always True for a class, so nesting a wrapper inside a wrapper got past the guard and failed later with an obscure TypeError from openqasm_info. Use issubclass. --- graphiq/circuit/ops.py | 2 +- tests/test_circuits/test_ops.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 396dc93e..7d1a212e 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -602,7 +602,7 @@ def __init__(self, operations: list, register=0, reg_type="e", noise=nm.NoNoise( for op_class in operations: assert issubclass(op_class, OneQubitOperationBase) # can only contain base classes - assert not isinstance(op_class, OneQubitGateWrapper) + assert not issubclass(op_class, OneQubitGateWrapper) self.operations = operations self._openqasm_info = oq_lib.single_qubit_wrapper_info(operations) diff --git a/tests/test_circuits/test_ops.py b/tests/test_circuits/test_ops.py index 83493c77..a1df02a4 100644 --- a/tests/test_circuits/test_ops.py +++ b/tests/test_circuits/test_ops.py @@ -164,3 +164,8 @@ def test_op(): u3 = dmf.hadamard() @ dmf.phase() @ dmf.hadamard() @ dmf.phase() u4 = dmf.phase() @ dmf.hadamard() @ dmf.sigmax() print(f"is u3 equivalent to u4 = {dmf.check_equivalent_unitaries(u3, u4)}") + + +def test_one_qubit_gate_wrapper_rejects_nested_wrapper(): + with pytest.raises(AssertionError): + ops.OneQubitGateWrapper([ops.OneQubitGateWrapper]) From 092892d128ad1d4bded824bc7222d9bacdcc0717 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:20:16 -0400 Subject: [PATCH 034/227] [fix] Fix CircuitDAG.to_json/from_json round trip - class_to_name_mapping serializes MeasurementCNOTandReset as "measurement-controlled x and reset", but name_to_class_map had no entry for that name, so from_json raised ValueError on any circuit containing one (e.g. the ghz3 / linear-cluster benchmark circuits). Add the missing entry. - After a real json.dumps/json.loads, the register fields come back as lists; from_json stored them as-is, so the rebuilt circuit's ops carried lists instead of tuples and compare_circuits reported a difference. Coerce them back to tuples. --- graphiq/circuit/circuit_dag.py | 7 +- graphiq/circuit/ops.py | 2 + tests/test_circuits/test_circuit_json.py | 83 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 tests/test_circuits/test_circuit_json.py diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index b386b629..e243c986 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -1086,9 +1086,10 @@ def from_json(cls, data_dict): else: gate = ops.name_to_class_map(op["type"]) gate = gate() - gate.q_registers = op["q_registers"] - gate.q_registers_type = op["q_registers_type"] - gate.c_registers = op["c_registers"] + # a real JSON dump/load turns these tuples into lists + gate.q_registers = tuple(op["q_registers"]) + gate.q_registers_type = tuple(op["q_registers_type"]) + gate.c_registers = tuple(op["c_registers"]) circuit.add(gate) diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 7d1a212e..3761da98 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -1217,6 +1217,8 @@ def name_to_class_map(name): "classical x": ClassicalCNOT, "classical z": ClassicalCZ, "classical reset x": MeasurementCNOTandReset, + # the name class_to_name_mapping gives it, so to_json output loads back + "measurement-controlled x and reset": MeasurementCNOTandReset, } if name in mapping: return mapping[name] diff --git a/tests/test_circuits/test_circuit_json.py b/tests/test_circuits/test_circuit_json.py new file mode 100644 index 00000000..52b3524c --- /dev/null +++ b/tests/test_circuits/test_circuit_json.py @@ -0,0 +1,83 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Round-trip coverage for CircuitDAG.to_json / from_json: the generic gate arms, and the +OneQubitGateWrapper arms that are only reached when a circuit actually carries a wrapped gate. +""" + +import json + +import pytest + +from graphiq.benchmarks.circuits import ( + ghz3_state_circuit, + linear_cluster_3qubit_circuit, +) +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.circuit.ops import Hadamard, OneQubitGateWrapper, SigmaX +from graphiq.utils.circuit_comparison import compare_circuits + + +@pytest.mark.parametrize( + "make_circuit", + [ghz3_state_circuit, linear_cluster_3qubit_circuit], + ids=["ghz3", "linear_cluster_3qubit"], +) +def test_to_json_from_json_round_trips_a_measurement_circuit(make_circuit): + # Both fixtures carry a MeasurementCNOTandReset, exercising the generic gate arms on + # both sides of the round trip. + circuit, _ = make_circuit() + + assert compare_circuits(circuit, CircuitDAG.from_json(circuit.to_json())) + + +@pytest.mark.parametrize( + "make_circuit", + [ghz3_state_circuit, linear_cluster_3qubit_circuit], + ids=["ghz3", "linear_cluster_3qubit"], +) +def test_to_json_from_json_round_trips_through_a_real_json_dump(make_circuit): + # to_json returns a dict, so the in-memory round trip above never serializes it. Pushing the + # dict through json.dumps/json.loads turns every tuple (q_registers, q_registers_type, + # c_registers) into a list, which must still come back as a tuple on the way in. + circuit, _ = make_circuit() + + serialized = json.loads(json.dumps(circuit.to_json())) + + assert compare_circuits(circuit, CircuitDAG.from_json(serialized)) + + +def test_from_json_gives_tuple_typed_registers_after_a_real_json_dump(): + # Confirms the register fields are coerced back to tuples, not merely that comparison happens + # to still succeed on lists. + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add(Hadamard(register=0, reg_type="e")) + + serialized = json.loads(json.dumps(circuit.to_json())) + rebuilt = CircuitDAG.from_json(serialized) + + gate = next(op for op in rebuilt.sequence() if type(op).__name__ == "Hadamard") + assert isinstance(gate.q_registers, tuple) + assert isinstance(gate.q_registers_type, tuple) + assert isinstance(gate.c_registers, tuple) + + +def test_to_json_from_json_round_trips_a_one_qubit_gate_wrapper(): + # OneQubitGateWrapper has its own branch in to_json/from_json; + # a plain gate circuit never reaches it. + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add(OneQubitGateWrapper([Hadamard, SigmaX], register=0, reg_type="e")) + + assert compare_circuits(circuit, CircuitDAG.from_json(circuit.to_json())) From 0a9f66332508bc6c2e237acfd24d760eceef54e5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:22:28 -0400 Subject: [PATCH 035/227] [fix] Stop assign_noise mutating the circuit it copies CircuitDAG._noisy_gates and MonteCarloNoise._noisy_gates set op.noise in place on the source circuit's own op objects, so CircuitDAG.assign_noise changed the circuit it was called on (and the returned circuit shared its ops), and every MonteCarloNoise trial left the last trial's noise on MonteCarloNoise.circuit. Work on a deep copy of the op sequence instead. Monte Carlo scores are unchanged: the ideal state is compiled once, before any trial, and every trial overwrites each op's noise. What changes is the circuit the caller keeps: the circuits AlternateTargetSolver's serial Monte Carlo path returns no longer carry the last trial's noise. --- graphiq/circuit/circuit_dag.py | 4 +- graphiq/noise/monte_carlo_noise.py | 4 +- tests/test_circuits/test_assign_noise.py | 124 +++++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/test_circuits/test_assign_noise.py diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index e243c986..d568e5ef 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -1172,7 +1172,9 @@ def assign_noise(self, noise_model_map): return empty_circ def _noisy_gates(self, noise_model_map): - seq = self._slim_seq() + # copy first: the noise is set on the ops in place, and these must not + # be this circuit's own ops + seq = self.copy()._slim_seq() noisy_ops = [] for op in seq: is_controlled = False diff --git a/graphiq/noise/monte_carlo_noise.py b/graphiq/noise/monte_carlo_noise.py index 24d25523..88bf70d9 100644 --- a/graphiq/noise/monte_carlo_noise.py +++ b/graphiq/noise/monte_carlo_noise.py @@ -282,7 +282,9 @@ def assign_noise(self): def _noisy_gates(self): noise_model_map = self.mc_noise_model.mapping circ = self.circuit - seq = circ._slim_seq() + # copy first: the noise is set on the ops in place, and these must not + # be the source circuit's own ops + seq = circ.copy()._slim_seq() noisy_ops = [] for op in seq: is_controlled = False diff --git a/tests/test_circuits/test_assign_noise.py b/tests/test_circuits/test_assign_noise.py new file mode 100644 index 00000000..da39cf9d --- /dev/null +++ b/tests/test_circuits/test_assign_noise.py @@ -0,0 +1,124 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import graphiq.benchmarks.circuits as circ +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.noise.monte_carlo_noise import McNoiseMap, MonteCarloNoise + + +def _assert_every_leg_is_no_noise(op): + if isinstance(op.noise, list): + assert all(isinstance(leg, nm.NoNoise) for leg in op.noise) + else: + assert isinstance(op.noise, nm.NoNoise) + + +def test_assign_noise_leaves_the_source_circuit_alone(): + hadamard_noise = nm.DepolarizingNoise(0.1) + wrapper_noise = nm.DepolarizingNoise(0.2) + pair_noise = nm.DepolarizingNoise(0.3) + + circuit = CircuitDAG(n_emitter=1, n_photon=2, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.OneQubitGateWrapper([ops.Phase, ops.Identity], register=0, reg_type="e") + ) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=1, target_type="p", c_register=0 + ) + ) + + mapping = { + "e": { + "Hadamard": hadamard_noise, + "Phase": wrapper_noise, + "Identity": wrapper_noise, + }, + "p": {}, + "ee": {}, + "ep": {"CNOT": pair_noise, "MeasurementCNOTandReset": pair_noise}, + } + + circuit.assign_noise(mapping) + + for op in circuit.sequence(): + if isinstance(op, (ops.Input, ops.Output)): + continue + _assert_every_leg_is_no_noise(op) + + +def test_assign_noise_shares_no_op_with_the_source(): + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + + mapping = { + "e": {"Hadamard": nm.DepolarizingNoise(0.1)}, + "p": {}, + "ee": {}, + "ep": {"CNOT": nm.DepolarizingNoise(0.2)}, + } + + noisy = circuit.assign_noise(mapping) + + source_ids = { + id(op) + for op in circuit.sequence() + if not isinstance(op, (ops.Input, ops.Output)) + } + result_ids = { + id(op) for op in noisy.sequence() if not isinstance(op, (ops.Input, ops.Output)) + } + assert source_ids.isdisjoint(result_ids) + + +def test_assign_noise_twice_with_different_mappings_is_independent(): + noise_a = nm.DepolarizingNoise(0.1) + noise_b = nm.DepolarizingNoise(0.9) + + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + + mapping_a = {"e": {"Hadamard": noise_a}, "p": {}, "ee": {}, "ep": {}} + mapping_b = {"e": {"Hadamard": noise_b}, "p": {}, "ee": {}, "ep": {}} + + result_a = circuit.assign_noise(mapping_a) + circuit.assign_noise(mapping_b) + + hadamards_a = [op for op in result_a.sequence() if isinstance(op, ops.Hadamard)] + assert len(hadamards_a) == 1 + assert hadamards_a[0].noise is noise_a + + +def test_monte_carlo_trials_leave_the_source_circuit_alone(): + circuit, _ = circ.ghz4_state_circuit() + + mc_noise_model = McNoiseMap() + mc_noise_model.add_gate_noise("e", "Hadamard", [(nm.PauliError("X"), 1.0)]) + + mc = MonteCarloNoise(circuit, mc_noise_model=mc_noise_model, seed=7) + mc.one_run() + mc.one_run() + + for op in circuit.sequence(): + if isinstance(op, (ops.Input, ops.Output)): + continue + if isinstance(op.noise, list): + assert all(isinstance(leg, nm.NoNoise) for leg in op.noise) + else: + assert isinstance(op.noise, nm.NoNoise) From f899dd6a27a29b2e610b440e45f2456c2fbb9f3b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:25:45 -0400 Subject: [PATCH 036/227] [fix] Build a fresh settings object per solver instead of sharing one default RandomSearchSolver, EvolutionarySolver and HybridEvolutionarySolver took `solver_setting=()` as a default argument, which Python evaluates once, so every solver constructed without an explicit setting shared (and could mutate) one settings object. Likewise AlternateTargetSolverSetting defaulted callback_func to a shared {}. Default to None and build the object per instance. Behaviour is unchanged unless a caller mutated a default-constructed setting. --- graphiq/solvers/alternate_target_solver.py | 5 +- graphiq/solvers/evolutionary_solver.py | 8 +- graphiq/solvers/hybrid_solvers.py | 6 +- graphiq/solvers/solver_base.py | 7 +- .../test_alternate_target_solver.py | 14 +++ .../test_solver_setting_defaults.py | 117 ++++++++++++++++++ 6 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 tests/test_solvers/test_solver_setting_defaults.py diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 66338178..9a0d39be 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -520,7 +520,7 @@ def __init__( lc_method="max edge", verbose=False, save_openqasm: str = "none", - callback_func: dict = {}, + callback_func: dict = None, ): self.allow_relabel = allow_relabel self.allow_lc = allow_lc @@ -535,7 +535,8 @@ def __init__( self.sort_emitter = sort_emit self.label_map = label_map self.iso_thresh = iso_thresh - self.callback_func = callback_func + # built per instance rather than shared through a mutable default + self.callback_func = {} if callback_func is None else callback_func self.lc_orbit_depth = lc_orbit_depth self.depolarizing_rate = depolarizing_rate self.monte_carlo = monte_carlo diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index 0c53664c..f74ec2d9 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -130,7 +130,7 @@ def __init__( io: IO = None, n_emitter=1, n_photon=1, - solver_setting=EvolutionarySolverSetting(), + solver_setting=None, noise_model_mapping=None, *args, **kwargs, @@ -153,9 +153,15 @@ def __init__( :type n_emitter: int :param n_photon: number of photon registers to maintain in the circuit :type n_photon: int + :param solver_setting: the solver's settings; a fresh default is built when None + :type solver_setting: EvolutionarySolverSetting or None :param noise_model_mapping: a dictionary that associates each operation type to a noise model :type noise_model_mapping: dict """ + # built per instance: a default instance in the signature would be + # shared (and mutated) by every solver constructed without one + if solver_setting is None: + solver_setting = EvolutionarySolverSetting() super().__init__( target=target, metric=metric, diff --git a/graphiq/solvers/hybrid_solvers.py b/graphiq/solvers/hybrid_solvers.py index b7754273..903848ff 100644 --- a/graphiq/solvers/hybrid_solvers.py +++ b/graphiq/solvers/hybrid_solvers.py @@ -44,7 +44,7 @@ def __init__( metric: MetricBase, compiler: CompilerBase, io: IO = None, - solver_setting=EvolutionarySolverSetting(), + solver_setting=None, noise_model_mapping=None, *args, **kwargs, @@ -68,9 +68,13 @@ def __init__( :type use_adapt_probability: bool :param save_openqasm: save population, hof, or both to openQASM strings (options: None, "hof", "pop", "both") :type save_openqasm: str, None + :param solver_setting: the solver's settings; a fresh default is built when None + :type solver_setting: EvolutionarySolverSetting or None :param noise_model_mapping: a dictionary that associates each operation type to a noise model :type noise_model_mapping: dict """ + if solver_setting is None: + solver_setting = EvolutionarySolverSetting() if target.rep_type != "s": tmp_target = target.copy() tmp_target.convert_representation("s") diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index 9e8d2fed..84e9739f 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -216,12 +216,17 @@ def __init__( compiler: CompilerBase, circuit: CircuitBase = None, io: IO = None, - solver_setting=RandomSearchSolverSetting(), + solver_setting=None, *args, **kwargs, ): super().__init__(target, metric, compiler, circuit, io) + # built per instance: a default instance in the signature would be + # shared (and mutated) by every solver constructed without one + if solver_setting is None: + solver_setting = RandomSearchSolverSetting() + # hof stores the best circuits and their scores in the form of: (scores, circuits) self.setting = solver_setting self.hof = [(np.inf, None) for _ in range(self.setting.n_hof)] diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index a4ecd45f..1ac25e61 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -101,3 +101,17 @@ def test_solver_density_noise(setting=my_setting(), target_graph=linear_cluster) solver.noise_simulation = True solver.seed = 1 solver.solve() + + +def test_setting_callback_func_is_per_instance(): + a = AlternateTargetSolverSetting() + b = AlternateTargetSolverSetting() + assert a.callback_func is not b.callback_func + assert a.callback_func == {} + + a.callback_func["x"] = 1 + assert b.callback_func == {} + + d = {} + setting = AlternateTargetSolverSetting(callback_func=d) + assert setting.callback_func is d diff --git a/tests/test_solvers/test_solver_setting_defaults.py b/tests/test_solvers/test_solver_setting_defaults.py new file mode 100644 index 00000000..0cc719db --- /dev/null +++ b/tests/test_solvers/test_solver_setting_defaults.py @@ -0,0 +1,117 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests that RandomSearchSolver, EvolutionarySolver and HybridEvolutionarySolver each +build their own default settings object instead of sharing one module-level instance. +""" + +import networkx as nx +import pytest + +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.benchmarks.circuits import ghz3_state_circuit +from graphiq.metrics import Infidelity +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) +from graphiq.solvers.hybrid_solvers import HybridEvolutionarySolver +from graphiq.solvers.solver_base import RandomSearchSolver, RandomSearchSolverSetting +from graphiq.state import QuantumState + +_NO_SETTING = object() + + +def _build_random_search_solver(solver_setting=_NO_SETTING): + _, target = ghz3_state_circuit() + metric = Infidelity(target) + compiler = DensityMatrixCompiler() + if solver_setting is _NO_SETTING: + return RandomSearchSolver(target, metric, compiler) + return RandomSearchSolver(target, metric, compiler, solver_setting=solver_setting) + + +def _build_evolutionary_solver(solver_setting=_NO_SETTING): + _, target = ghz3_state_circuit() + metric = Infidelity(target) + compiler = DensityMatrixCompiler() + if solver_setting is _NO_SETTING: + return EvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + n_emitter=1, + n_photon=3, + ) + return EvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + n_emitter=1, + n_photon=3, + solver_setting=solver_setting, + ) + + +def _build_hybrid_evolutionary_solver(solver_setting=_NO_SETTING): + tableau = get_clifford_tableau_from_graph(nx.path_graph(3)) + target = QuantumState(tableau, rep_type="s") + metric = Infidelity(target) + compiler = StabilizerCompiler() + if solver_setting is _NO_SETTING: + return HybridEvolutionarySolver(target=target, metric=metric, compiler=compiler) + return HybridEvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + solver_setting=solver_setting, + ) + + +_CASES = [ + pytest.param( + _build_random_search_solver, RandomSearchSolverSetting, id="RandomSearchSolver" + ), + pytest.param( + _build_evolutionary_solver, EvolutionarySolverSetting, id="EvolutionarySolver" + ), + pytest.param( + _build_hybrid_evolutionary_solver, + EvolutionarySolverSetting, + id="HybridEvolutionarySolver", + ), +] + + +@pytest.mark.parametrize("build_solver, setting_cls", _CASES) +def test_default_setting_is_per_instance(build_solver, setting_cls): + a = build_solver() + b = build_solver() + + assert a.setting is not b.setting + + a.setting.n_stop = 7 + assert b.setting.n_stop == 50 + + c = build_solver() + assert c.setting.n_stop == 50 + + s = setting_cls() + solver = build_solver(solver_setting=s) + assert solver.setting is s From f8ba13743c0b1e13885d15fad935e6f77e7199ce Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:26:31 -0400 Subject: [PATCH 037/227] [fix] Default AlternateTargetSolverSetting.lc_method to a dispatchable value AlternateTargetSolverSetting defaulted lc_method to "max edge", which none of solve()'s dispatch branches handle, so solving with a default-constructed setting (including AlternateTargetSolver's own default) raised "LC method is not valid". Default to None, the plain orbit search solve() already supports. "max edge" was a dispatch key of an earlier solver that has since been removed; this solver never handled it, so no result was ever produced with that default. --- graphiq/solvers/alternate_target_solver.py | 2 +- tests/test_solvers/test_alternate_target_solver.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 9a0d39be..4a3001a7 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -517,7 +517,7 @@ def __init__( monte_carlo=False, monte_carlo_params=None, graph_metric=pre.graph_metric_lists[0], - lc_method="max edge", + lc_method=None, verbose=False, save_openqasm: str = "none", callback_func: dict = None, diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index 1ac25e61..23ef40f0 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -115,3 +115,16 @@ def test_setting_callback_func_is_per_instance(): d = {} setting = AlternateTargetSolverSetting(callback_func=d) assert setting.callback_func is d + + +def test_default_setting_names_a_dispatchable_lc_method(): + # every other test sets lc_method by hand, so an undispatchable default + # went unnoticed + setting = AlternateTargetSolverSetting() + assert setting.lc_method is None + + solver = AlternateTargetSolver( + target=nx.path_graph(4), solver_setting=setting, seed=1 + ) + solver.solve() + assert solver.result is not None From 1fa6fddf18a3394421c8aca48dbc7e20b675d443 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:27:43 -0400 Subject: [PATCH 038/227] [fix] Report the seed a solver was given instead of always None SolverBase.seed() records the seed on the class (SolverBase.last_seed), but SolverBase.__init__ also set self.last_seed = None, an instance attribute that shadowed it, so solver_info["seed"] (Evolutionary and TimeReversed solvers) always reported None. Make last_seed a class attribute only. benchmark_utils.benchmark_data also read solver_info before seeding, and recorded "seed": i instead of the seed actually used, i + seed_offset. Seed first (solver_info does not touch the RNG, so search results are unchanged) and record the real seed. --- graphiq/benchmarks/benchmark_utils.py | 5 +- graphiq/solvers/solver_base.py | 5 +- .../test_solver_seed_recording.py | 66 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/test_solvers/test_solver_seed_recording.py diff --git a/graphiq/benchmarks/benchmark_utils.py b/graphiq/benchmarks/benchmark_utils.py index e8da3b04..64956410 100644 --- a/graphiq/benchmarks/benchmark_utils.py +++ b/graphiq/benchmarks/benchmark_utils.py @@ -142,12 +142,13 @@ def benchmark_data( n_emitter=target["n_emitters"], n_photon=target["n_photons"], ) + # seed before reading solver_info, whose "seed" field reports it + solver.seed(i + seed_offset) solver_data = solver.solver_info if save_directory and i == 0 and j == 0: io.save_json(solver_data, "solver_info.json") start_time = time.time() - solver.seed(i + seed_offset) solver.solve() total_time = time.time() - start_time df.loc["runtime (s)", (target["name"], i)] = total_time @@ -164,7 +165,7 @@ def benchmark_data( circuit_data = { "name": target["name"], - "seed": i, + "seed": i + seed_offset, "runtime (s)": total_time, "Measurement independent (T/F)": df.loc[ f"Measurement independent (T/F)", (target["name"], i) diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index 84e9739f..b8390176 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -47,6 +47,10 @@ class SolverBase(ABC): ops.CNOT, ] + # the last seed passed to seed(); a class attribute because seed() sets the + # process-global numpy/random seeds and records it on SolverBase itself + last_seed = None + def __init__( self, target, @@ -63,7 +67,6 @@ def __init__( self.circuit = circuit self.solver_setting = solver_setting - self.last_seed = None self.logs = {} self.result = None diff --git a/tests/test_solvers/test_solver_seed_recording.py b/tests/test_solvers/test_solver_seed_recording.py new file mode 100644 index 00000000..5ee888d1 --- /dev/null +++ b/tests/test_solvers/test_solver_seed_recording.py @@ -0,0 +1,66 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.benchmarks.circuits import ghz3_state_circuit +from graphiq.benchmarks.graph_states import linear_cluster_state +from graphiq.metrics import Infidelity +from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.solver_base import SolverBase +from graphiq.solvers.time_reversed_solver import TimeReversedSolver +from graphiq.state import QuantumState + + +@pytest.fixture(autouse=True) +def _restore_last_seed(): + saved = getattr(SolverBase, "last_seed", None) + yield + SolverBase.last_seed = saved + + +def _evolutionary_solver(): + _, ideal_state = ghz3_state_circuit() + return EvolutionarySolver( + target=ideal_state, + metric=Infidelity(target=ideal_state), + compiler=DensityMatrixCompiler(), + n_emitter=1, + n_photon=3, + ) + + +def _time_reversed_solver(): + tableau = get_clifford_tableau_from_graph(linear_cluster_state(2).data) + target = QuantumState(tableau, rep_type="stab") + return TimeReversedSolver( + target=target, metric=Infidelity(target=target), compiler=StabilizerCompiler() + ) + + +@pytest.mark.parametrize("build", [_evolutionary_solver, _time_reversed_solver]) +def test_solver_reports_its_seed(build): + solver = build() + solver.seed(4) + assert solver.solver_info["seed"] == 4 + + +def test_class_level_seed_is_visible_to_an_instance(): + EvolutionarySolver.seed(7) + assert _evolutionary_solver().solver_info["seed"] == 7 From 809a3ca3dcbd85ebdde962292b3168c55ca11c69 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:28:21 -0400 Subject: [PATCH 039/227] [fix] Return state_to_graph's tuple in the documented order for matrix input state_to_graph documents its return as (graph, tableau, gate_list) and every other input branch follows that, but the adjacency-matrix branch returned (graph, [], tableau), so lc_check on two adjacency matrices crashed with "TypeError: 'StabilizerTableau' object is not iterable". --- graphiq/backends/state_rep_conversion.py | 2 +- .../test_graph_LC_equivalence.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index 0409491d..acaec5d3 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -424,7 +424,7 @@ def state_to_graph(state): try: graph = nx.from_numpy_array(state) tab = get_stabilizer_tableau_from_graph(graph) - return graph, [], tab + return graph, tab, [] except: raise ValueError( "the input numpy array is not a valid adjacency matrix, try fixing it or using other valid input types" diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index 94ab0fb1..b142b0fe 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -25,6 +25,8 @@ get_stabilizer_tableau_from_graph, ) from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.backends.state_rep_conversion import state_to_graph def _tester(n): @@ -168,3 +170,20 @@ def test_random_state_converter(seed): c2 = state_converter_circuit(random_tab1, random_tab2, validate=True) # check if output is not None assert c1 and c2 + + +def test_state_to_graph_matrix_input_matches_graph_input(): + # lc_check must give the same answer whether the states are passed as + # graphs or as their adjacency matrices; both go through state_to_graph. + star4 = nx.star_graph(3) + k4 = nx.complete_graph(4) + + result_from_graphs = lc_check(star4, k4) + result_from_matrices = lc_check(nx.to_numpy_array(star4), nx.to_numpy_array(k4)) + + assert result_from_graphs[0] is True + assert result_from_matrices == result_from_graphs + + graph, tableau, gate_list = state_to_graph(nx.to_numpy_array(star4)) + assert isinstance(tableau, StabilizerTableau) + assert gate_list == [] From f8c2ee3d42e23a588833846b171215c97c90de65 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:28:46 -0400 Subject: [PATCH 040/227] [fix] Fix converting a stabilizer QuantumState to the graph representation QuantumState._stabilizer_to_graph was broken in both arms: - pure: it passed the Stabilizer's CliffordTableau to rc.stabilizer_to_graph, which only accepts a StabilizerTableau (or a list), so convert_representation("g") raised ValueError. Pass rep.data.to_stabilizer(). - mixed: it passed stabilizer_to_graph's (probability, nx.Graph) list straight to MixedGraph, whose constructor asserts graphiq Graph entries. Wrap each graph in Graph. --- graphiq/state.py | 7 +++-- .../test_quantum_state.py | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/graphiq/state.py b/graphiq/state.py index 223197bf..b1fe9fce 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -328,9 +328,12 @@ def _stabilizer_to_graph(self, rep): for p_i, t_i in rep.mixture: data_list.append((p_i, t_i.to_stabilizer())) graph_list = rc.stabilizer_to_graph(data_list) - return MixedGraph(graph_list) + # MixedGraph expects graphiq Graph objects, not bare nx.Graphs + return MixedGraph([(p_i, Graph(g_i)) for p_i, g_i in graph_list]) else: - graph_list = rc.stabilizer_to_graph(rep.data) + # stabilizer_to_graph takes a StabilizerTableau, not the + # CliffordTableau the Stabilizer holds + graph_list = rc.stabilizer_to_graph(rep.data.to_stabilizer()) return Graph(graph_list[0][1]) def _graph_to_density(self, rep): diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 3a5fea73..72675382 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -17,6 +17,8 @@ import pytest from graphiq.backends.density_matrix import numpy as dmnp +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.graph.state import MixedGraph from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau from graphiq.state import QuantumState @@ -54,3 +56,31 @@ def test_initializing_stabilizer_2(): n = 130 state = QuantumState(CliffordTableau(n), rep_type="s") assert state.rep_data.n_qubits == n + + +def test_convert_representation_graph_stabilizer_graph_round_trip_pure_arm(): + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g") + + state.convert_representation("s") + state.convert_representation("g") + + assert nx.is_isomorphic(state.rep_data.data, graph) + + +def test_convert_representation_stabilizer_to_graph_mixed_arm(): + path3 = nx.path_graph(3) + star3 = nx.star_graph(2) + mixture = [ + (0.7, CliffordTableau(rc.graph_to_stabilizer(path3)[0][1])), + (0.3, CliffordTableau(rc.graph_to_stabilizer(star3)[0][1])), + ] + state = QuantumState(mixture, rep_type="s", mixed=True) + + state.convert_representation("g") + + mixed_graph = state.rep_data + assert isinstance(mixed_graph, MixedGraph) + assert [p_i for p_i, _ in mixed_graph.mixture] == [0.7, 0.3] + assert nx.is_isomorphic(mixed_graph.mixture[0][1].data, path3) + assert nx.is_isomorphic(mixed_graph.mixture[1][1].data, star3) From 8c965e31b6741debfb0af5e1742357225c01f3b8 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:29:04 -0400 Subject: [PATCH 041/227] [fix] Fix AttributeError formatting a MixedGraph as a string StateRepresentationBase.__str__ read self._data, which MixedGraph never sets (it stores its mixture elsewhere and exposes it through the data property), so str()/print() of a MixedGraph - including inside error messages - raised AttributeError. Read self.data instead. --- graphiq/backends/state_base.py | 2 +- tests/test_state_representation/test_quantum_state.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/graphiq/backends/state_base.py b/graphiq/backends/state_base.py index e916d0c2..75de8170 100644 --- a/graphiq/backends/state_base.py +++ b/graphiq/backends/state_base.py @@ -48,7 +48,7 @@ def __str__(self): :return: string representation of the state representation base class :rtype: str """ - return f"{self.__class__.__name__}\n{self._data}" + return f"{self.__class__.__name__}\n{self.data}" @property def data(self): diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 72675382..5f2b6870 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -18,7 +18,7 @@ from graphiq.backends.density_matrix import numpy as dmnp import graphiq.backends.state_rep_conversion as rc -from graphiq.backends.graph.state import MixedGraph +from graphiq.backends.graph.state import Graph, MixedGraph from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau from graphiq.state import QuantumState @@ -84,3 +84,9 @@ def test_convert_representation_stabilizer_to_graph_mixed_arm(): assert [p_i for p_i, _ in mixed_graph.mixture] == [0.7, 0.3] assert nx.is_isomorphic(mixed_graph.mixture[0][1].data, path3) assert nx.is_isomorphic(mixed_graph.mixture[1][1].data, star3) + + +def test_str_of_a_mixed_graph(): + # MixedGraph never sets _data, so the base class __str__ must go through data + mixed_graph = MixedGraph([(1.0, Graph(nx.path_graph(2)))]) + assert str(mixed_graph).startswith("MixedGraph") From a20e5e56eab591c7ac7cf4ea69e734c3899b0de3 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:29:33 -0400 Subject: [PATCH 042/227] [fix] Date the fallback folder name when IO.new_directory gets an empty folder With an empty folder name, IO.new_directory warns that it will use a unique dated folder instead, but it set a misspelled `include_data` rather than include_date, so the date was never added (e.g. "_A213" instead of "2026-09-23_..._A213"). --- graphiq/io.py | 2 +- tests/test_utils/test_io.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 tests/test_utils/test_io.py diff --git a/graphiq/io.py b/graphiq/io.py index 82c56fc8..56399338 100644 --- a/graphiq/io.py +++ b/graphiq/io.py @@ -111,7 +111,7 @@ def new_directory( warnings.warn( "No folder entered. Saving to a folder with a unique identifier" ) - include_data, include_id, verbose = True, True, True + include_date, include_id, verbose = True, True, True # build the full folder name with date, time, and uuid, if selected _str = "" diff --git a/tests/test_utils/test_io.py b/tests/test_utils/test_io.py new file mode 100644 index 00000000..c74decb9 --- /dev/null +++ b/tests/test_utils/test_io.py @@ -0,0 +1,30 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import datetime + +import pytest + +from graphiq.io import IO + + +def test_new_directory_with_empty_folder_warns_and_dates_the_path(tmp_path): + # An empty folder name should fall back to a unique, dated folder name + # and warn the caller that it did so. new_directory only builds a path + # object (join, no filesystem write), so there is nothing to clean up. + with pytest.warns(UserWarning): + io = IO.new_directory(path=tmp_path, folder="") + + today = datetime.date.today().isoformat() + assert io.path.name.startswith(today) From 8ce498c2922e8b856c1818a23a57b69b0c436d3d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:29:53 -0400 Subject: [PATCH 043/227] [fix] Fix two latent bugs in SolverResult - __setitem__'s type guard tested isinstance(type, ...) (the builtin) instead of the value being assigned, so it never fired and a non-list value of the right length (e.g. a string) was accepted. - load_json read self.properties, which does not exist (the attribute is _properties), so every load_json call raised AttributeError after loading the data. --- graphiq/solvers/solver_result.py | 4 +-- tests/test_utils/test_solver_result.py | 37 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/graphiq/solvers/solver_result.py b/graphiq/solvers/solver_result.py index 0c32b230..8f5aaee9 100644 --- a/graphiq/solvers/solver_result.py +++ b/graphiq/solvers/solver_result.py @@ -86,7 +86,7 @@ def __setitem__(self, key, value): :return: nothing :rtype: None """ - if isinstance(type, (list, np.ndarray)): + if not isinstance(value, (list, np.ndarray)): raise TypeError(f"Data should be a list or numpy array") else: if len(value) != len(self._data["circuit"]): @@ -206,7 +206,7 @@ def load_json(self, path, filename): "The circuits in the loaded result object are now openqasm strings!" ) diff = ( - set(self.properties) - set(loaded_dict.keys()) - {"circuit", "circuit_id"} + set(self._properties) - set(loaded_dict.keys()) - {"circuit", "circuit_id"} ) if diff: warnings.warn("") diff --git a/tests/test_utils/test_solver_result.py b/tests/test_utils/test_solver_result.py index 13692a71..1fbca57c 100644 --- a/tests/test_utils/test_solver_result.py +++ b/tests/test_utils/test_solver_result.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import networkx as nx +import pytest + from graphiq.solvers.solver_result import * from graphiq.benchmarks.circuits import ( ghz3_state_circuit, @@ -85,3 +88,37 @@ def test_get_index_with_column_value(): assert result.get_index_with_column_value("test_property", 1) == [0, 2] assert result.get_index_with_column_value("test_property", 2) == [1] + + +def test_set_item_rejects_non_list_non_array_value(): + circuit_1, _ = ghz3_state_circuit() + circuit_2, _ = linear_cluster_3qubit_circuit() + result = SolverResult([circuit_1, circuit_2]) + + # a string of the right length (2) must still be rejected + with pytest.raises(TypeError): + result["x"] = "ab" + + +def test_load_json_round_trips_saved_columns(tmp_path): + circuit, _ = ghz3_state_circuit() + + saved = SolverResult([circuit], properties=["score"]) + saved["score"] = [0.5] + saved["g"] = [nx.path_graph(2)] + saved.save2json(tmp_path, "res") + + loaded = SolverResult([circuit]) + with pytest.warns(UserWarning, match="openqasm") as rec: + loaded.load_json(tmp_path, "res") + + assert len(rec) == 1 + assert loaded["score"] == [0.5] + assert nx.utils.graphs_equal(loaded["g"][0], nx.path_graph(2)) + + # a declared property missing from the file also warns + loaded_missing = SolverResult([circuit], properties=["missing"]) + with pytest.warns(UserWarning) as rec: + loaded_missing.load_json(tmp_path, "res") + + assert len(rec) == 2 From 29cc2177f2631c59f1b7aead63ff6e4bec660d6d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:30:32 -0400 Subject: [PATCH 044/227] [fix] Fix broken src.* import in graph_states.crazy(alternate_order=True) crazy() imported relabel from `src.utils.relabel_module`, a module path left over from before the package was renamed to graphiq, so calling it with alternate_order=True raised ModuleNotFoundError. Import from graphiq.utils.relabel_module. --- graphiq/benchmarks/graph_states.py | 2 +- tests/test_benchmarking/test_graph_states.py | 44 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/test_benchmarking/test_graph_states.py diff --git a/graphiq/benchmarks/graph_states.py b/graphiq/benchmarks/graph_states.py index e54f360e..0456eb7c 100644 --- a/graphiq/benchmarks/graph_states.py +++ b/graphiq/benchmarks/graph_states.py @@ -259,7 +259,7 @@ def crazy(n_list: list, pos=False, alternate_order=False): edges.extend([(x, y) for x in current_nodes for y in next_col_nodes]) g.add_edges_from(edges) if alternate_order: - from src.utils.relabel_module import relabel + from graphiq.utils.relabel_module import relabel nodes = [] for k in range(0, len(n_list), 2): diff --git a/tests/test_benchmarking/test_graph_states.py b/tests/test_benchmarking/test_graph_states.py new file mode 100644 index 00000000..47f56f04 --- /dev/null +++ b/tests/test_benchmarking/test_graph_states.py @@ -0,0 +1,44 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import networkx as nx +import numpy as np +import pytest + +from graphiq.benchmarks import graph_states as gs + + +@pytest.mark.parametrize("n_list", [[2, 3], [2, 3, 2], [3, 3, 3], [1, 2, 1]]) +def test_crazy(n_list): + graph = gs.crazy(n_list) + assert graph.number_of_nodes() == sum(n_list) + + # consecutive layers are complete bipartite and there are no intra-layer edges + layers = [] + start = 0 + for n in n_list: + layers.append(list(range(start, start + n))) + start += n + for layer in layers: + for u in layer: + for v in layer: + if u != v: + assert not graph.has_edge(u, v) + for layer_a, layer_b in zip(layers, layers[1:]): + for u in layer_a: + for v in layer_b: + assert graph.has_edge(u, v) + + alternate_graph = gs.crazy(n_list, alternate_order=True) + assert nx.is_isomorphic(graph, alternate_graph) From 9d0f3bbc75abe3358f6b0df7b34cd0835db5eda3 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:31:08 -0400 Subject: [PATCH 045/227] [fix] Fix _select_graphs evicting the best-scoring candidate _select_graphs (used by get_lc_graph_by_max_edge and get_lc_graph_by_max_neighbor_edge) appended new candidates unsorted while under capacity and only did a sorted insert-and-pop once full, so it could pop the best candidate seen so far: feeding 5.0, 3.0, 4.0 with limit 2 gave [4.0, 5.0], dropping 3.0. Keep the `limit` lowest entries with heapq.nsmallest, keyed on the metric so tied graphs are never compared, and document the helper. --- graphiq/utils/preprocessing.py | 44 ++++++++-------- tests/test_utils/test_preprocessing.py | 70 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 tests/test_utils/test_preprocessing.py diff --git a/graphiq/utils/preprocessing.py b/graphiq/utils/preprocessing.py index 24a9b603..14151148 100644 --- a/graphiq/utils/preprocessing.py +++ b/graphiq/utils/preprocessing.py @@ -12,6 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import heapq + import networkx as nx import numpy as np @@ -124,28 +126,28 @@ def graph_closeness_centrality(graph): def _select_graphs(candidate_graphs, new_graph, limit, metric_value): """ - Helper function to select graphs - - :param candidate_graphs: - :type candidate_graphs: - :param new_graph: - :type new_graph: - :param limit: - :type limit: - :param metric_value: - :type metric_value: - :return: - :rtype: + Helper function to select graphs: add new_graph to the candidates and keep + the `limit` entries with the lowest metric value + + :param candidate_graphs: the current candidates, as (metric value, graph) pairs + :type candidate_graphs: list[(float, networkx.Graph)] + :param new_graph: the graph to consider adding + :type new_graph: networkx.Graph + :param limit: the maximum number of candidates to keep + :type limit: int + :param metric_value: the metric value of new_graph + :type metric_value: float + :return: the updated candidates, sorted by ascending metric value + :rtype: list[(float, networkx.Graph)] """ - if len(candidate_graphs) < limit: - candidate_graphs.append((metric_value, new_graph)) - else: - for i in range(len(candidate_graphs)): - if metric_value < candidate_graphs[i][0]: - candidate_graphs.insert(i, (metric_value, new_graph)) - candidate_graphs.pop() - break - return candidate_graphs + # keep the `limit` lowest-metric entries; the list was previously only + # sorted once full, so the best candidate could be evicted. key= avoids + # comparing the graphs themselves on ties. + return heapq.nsmallest( + limit, + candidate_graphs + [(metric_value, new_graph)], + key=lambda pair: pair[0], + ) def get_lc_graph_by_max_edge(graph, n_graphs, graph_metric, n_trial=5): diff --git a/tests/test_utils/test_preprocessing.py b/tests/test_utils/test_preprocessing.py new file mode 100644 index 00000000..5586b89a --- /dev/null +++ b/tests/test_utils/test_preprocessing.py @@ -0,0 +1,70 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import networkx as nx + +from graphiq.utils.preprocessing import _select_graphs + + +def _make_graph(label): + graph = nx.Graph() + graph.add_node(label) + return graph + + +def test_select_graphs_keeps_the_true_lowest_entries(): + # With limit=2, feeding metric values 5.0, then 3.0, then 4.0 must keep the two lowest + # (3.0 and 4.0), not evict 3.0 -- the best score seen -- once the list is at capacity. + graph_5 = _make_graph("five") + graph_3 = _make_graph("three") + graph_4 = _make_graph("four") + + candidate_graphs = [] + candidate_graphs = _select_graphs(candidate_graphs, graph_5, 2, 5.0) + candidate_graphs = _select_graphs(candidate_graphs, graph_3, 2, 3.0) + candidate_graphs = _select_graphs(candidate_graphs, graph_4, 2, 4.0) + + assert candidate_graphs == [(3.0, graph_3), (4.0, graph_4)] + + +def test_select_graphs_tolerates_tied_metric_values(): + # Equal metric values are common (integer-valued edge/eccentricity counts); comparing the + # graphs themselves as a tiebreak must never happen, since nx.Graph has no ordering. + graph_a = _make_graph("a") + graph_b = _make_graph("b") + graph_c = _make_graph("c") + + candidate_graphs = [] + candidate_graphs = _select_graphs(candidate_graphs, graph_a, 2, 3.0) + candidate_graphs = _select_graphs(candidate_graphs, graph_b, 2, 3.0) + candidate_graphs = _select_graphs(candidate_graphs, graph_c, 2, 3.0) + + assert len(candidate_graphs) == 2 + assert all(value == 3.0 for value, _ in candidate_graphs) + + +def test_select_graphs_keeps_starting_graph_when_it_is_best(): + # _select_graphs is seeded with the starting graph; when its metric is the lowest seen, + # it must still be present in the result. + starting_graph = _make_graph("start") + other_graph_1 = _make_graph("other1") + other_graph_2 = _make_graph("other2") + + candidate_graphs = [(1.0, starting_graph)] + candidate_graphs = _select_graphs(candidate_graphs, other_graph_1, 2, 5.0) + candidate_graphs = _select_graphs(candidate_graphs, other_graph_2, 2, 4.0) + + assert candidate_graphs[0] == (1.0, starting_graph) + assert starting_graph in [graph for _, graph in candidate_graphs] From 0b58dc6855c124290ba545157e53104b119700a2 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:32:08 -0400 Subject: [PATCH 046/227] [fix] Make the LC-equivalence demos run and report distinct graphs Fixes in benchmarks/lc_equivalence.py's demo functions: - run_multiple_graphs_and_report searched the original graph for every entry instead of each locally complemented alt_graph, so every report after the first repeated the first. - heuristic_example1/2/3 called get_lc_graph_by_max_edge / get_lc_graph_by_max_neighbor_edge without their required graph_metric argument (TypeError), and treated the returned (metric, graph) list as a graph. Pass graph_metric_lists[0] and take the first candidate that differs from the input (the heuristics seed their list with the input, which ties on K(3, 3)). heuristic_example2 complemented the selected graph back at node 0, which just returns K(3, 3); use node 3. - biclique_example built K(n, n) from an edge list, inserting nodes as 0, n, n+1, ..., 2n-1, 1, ..., n-1, so the position-indexed local complementation hit the wrong nodes. Build it with nx.complete_bipartite_graph. - Drop lc_nodes whose complementation duplicates another entry's (square: LC(0) == LC(3); star: LC(leaf) is the identity; biclique: LC(0) == LC(1)). --- graphiq/benchmarks/lc_equivalence.py | 59 ++++++-- .../test_lc_equivalence_demos.py | 137 ++++++++++++++++++ 2 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 tests/test_benchmarking/test_lc_equivalence_demos.py diff --git a/graphiq/benchmarks/lc_equivalence.py b/graphiq/benchmarks/lc_equivalence.py index c6a1818f..11e698fb 100644 --- a/graphiq/benchmarks/lc_equivalence.py +++ b/graphiq/benchmarks/lc_equivalence.py @@ -21,6 +21,29 @@ from graphiq.utils.preprocessing import * +def _first_distinct_graph(candidates, graph): + """ + Return the first candidate graph whose edge set differs from ``graph``'s. + + The LC heuristics in ``graphiq.utils.preprocessing`` seed their candidate list + with the input graph, so when the input ties on the metric (e.g. K(3, 3)) + ``candidates[0][1]`` is the input itself. + + :param candidates: (metric value, graph) pairs, lowest metric value first + :type candidates: list + :param graph: the graph the candidates were generated from + :type graph: networkx.Graph + :raises ValueError: if no candidate's edge set differs from ``graph``'s + :return: the first candidate whose edge set differs from ``graph``'s + :rtype: networkx.Graph + """ + reference = set(map(frozenset, graph.edges())) + for _, candidate in candidates: + if set(map(frozenset, candidate.edges())) != reference: + return candidate + raise ValueError("no candidate graph differs from the input graph") + + def run_multiple_graphs_and_report(graph, lc_nodes): results = [exemplary_run(graph, None)] graphs = [graph] @@ -28,7 +51,7 @@ def run_multiple_graphs_and_report(graph, lc_nodes): alt_graph = local_comp_graph(graph, node) graphs.append(alt_graph) - results.append(exemplary_run(graph, None)) + results.append(exemplary_run(alt_graph, None)) for i in range(len(results)): ax = plt.axes() nx.draw(graphs[i], ax=ax, with_labels=True) @@ -42,7 +65,8 @@ def linear_4_example(): def square_4_example(): graph = nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (1, 2)]) - run_multiple_graphs_and_report(graph, [0, 1, 2, 3]) + # nodes 0 and 3 share the neighbourhood {1, 2}, so LC(0) == LC(3) + run_multiple_graphs_and_report(graph, [0, 1, 2]) def repeater_graph_example(n_inner_qubits): @@ -52,16 +76,17 @@ def repeater_graph_example(n_inner_qubits): def star_graph_example(n_points): graph = nx.star_graph(n_points) - run_multiple_graphs_and_report(graph, [0, 1]) + # LC at a degree-1 leaf is the identity; only the centre changes the graph + run_multiple_graphs_and_report(graph, [0]) def biclique_example(n_pairs): - edges = [] - for i in range(n_pairs): - for j in range(n_pairs): - edges.append((i, j + n_pairs)) - graph = nx.Graph(edges) - run_multiple_graphs_and_report(graph, [0, 1, n_pairs]) + # built by the generator so nodes are inserted in ascending order: the local + # complementation indexes nodes by adjacency-matrix position, and an edge + # list inserts them as 0, n_pairs, ... and complements the wrong ones. + # Nodes on one side share a neighbourhood, so LC(0) == LC(1). + graph = nx.complete_bipartite_graph(n_pairs, n_pairs) + run_multiple_graphs_and_report(graph, [0, n_pairs]) def imperfect_repeater_graph_example(): @@ -88,7 +113,9 @@ def heuristic_example1(): result = exemplary_run(graph, None) report_alternate_circuits(result) - graph2 = get_lc_graph_by_max_edge(graph, 10) + graph2 = _first_distinct_graph( + get_lc_graph_by_max_edge(graph, 10, graph_metric_lists[0]), graph + ) nx.draw(graph2, with_labels=True) result = exemplary_run(graph2, None) report_alternate_circuits(result) @@ -100,11 +127,15 @@ def heuristic_example2(): result = exemplary_run(graph, None) report_alternate_circuits(result) - graph2 = get_lc_graph_by_max_edge(graph, 3) + graph2 = _first_distinct_graph( + get_lc_graph_by_max_edge(graph, 3, graph_metric_lists[0]), graph + ) # nx.draw(graph2, with_labels=True) # result = exemplary_run(graph2, None) # report_alternate_circuits(result) - graph3 = local_comp_graph(graph2, 0) + # graph2 is LC(K(3, 3), 0); complementing at node 0, 1 or 2 again would + # return K(3, 3) itself, while node 3 gives a distinct graph + graph3 = local_comp_graph(graph2, 3) nx.draw(graph3, with_labels=True) result = exemplary_run(graph3, None) report_alternate_circuits(result) @@ -117,7 +148,9 @@ def heuristic_example3(): report_alternate_circuits(result) # graph2 = candidate_graphs(graph, 3) - graph2 = get_lc_graph_by_max_neighbor_edge(graph, 3) + graph2 = _first_distinct_graph( + get_lc_graph_by_max_neighbor_edge(graph, 3, graph_metric_lists[0]), graph + ) nx.draw(graph2, with_labels=True) result = exemplary_run(graph2, None) report_alternate_circuits(result) diff --git a/tests/test_benchmarking/test_lc_equivalence_demos.py b/tests/test_benchmarking/test_lc_equivalence_demos.py new file mode 100644 index 00000000..9b5a995c --- /dev/null +++ b/tests/test_benchmarking/test_lc_equivalence_demos.py @@ -0,0 +1,137 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Regression tests for the graph-picking behaviour of the three heuristic demos in +graphiq.benchmarks.lc_equivalence. Each demo is supposed to draw and search a graph, then +draw and search a second graph reached from it by local complementation; this module guards +that the second graph is actually distinct from the first, rather than the input repeated. +""" + +import networkx as nx +import pytest + +from graphiq.benchmarks import lc_equivalence +from graphiq.utils.preprocessing import get_lc_graph_by_max_edge, graph_metric_lists + + +def _spy_on_search_and_draw(monkeypatch): + calls = [] + + def fake_exemplary_run(graph, *args, **kwargs): + calls.append(graph) + return None + + monkeypatch.setattr(lc_equivalence, "exemplary_run", fake_exemplary_run) + monkeypatch.setattr( + lc_equivalence, "report_alternate_circuits", lambda result: None + ) + monkeypatch.setattr(nx, "draw", lambda *args, **kwargs: None) + return calls + + +def test_heuristic_example1_searches_a_distinct_second_graph(monkeypatch): + calls = _spy_on_search_and_draw(monkeypatch) + + lc_equivalence.heuristic_example1() + + assert len(calls) == 2 + first_edges = set(map(frozenset, calls[0].edges())) + second_edges = set(map(frozenset, calls[1].edges())) + assert first_edges != second_edges + + +def test_heuristic_example2_searches_a_distinct_second_graph(monkeypatch): + calls = _spy_on_search_and_draw(monkeypatch) + + lc_equivalence.heuristic_example2() + + assert len(calls) == 2 + first_edges = set(map(frozenset, calls[0].edges())) + second_edges = set(map(frozenset, calls[1].edges())) + assert first_edges != second_edges + # Edge count alone pins the class of the second searched graph, not just its distinctness + # from the first: the unfixed heuristic pick (index 0, i.e. K(3, 3) itself) complemented at + # node 3 has 12 edges; the fixed pick complemented at node 0 undoes the heuristic's + # complementation and returns K(3, 3) itself, 9 edges; only the fixed pick complemented at + # node 3 gives this 8-edge graph. + assert calls[1].number_of_edges() == 8 + + +def test_heuristic_example3_searches_a_distinct_second_graph(monkeypatch): + calls = _spy_on_search_and_draw(monkeypatch) + + lc_equivalence.heuristic_example3() + + assert len(calls) == 2 + first_edges = set(map(frozenset, calls[0].edges())) + second_edges = set(map(frozenset, calls[1].edges())) + assert first_edges != second_edges + + +def test_first_distinct_graph_raises_on_k1(): + graph = nx.complete_graph(1) + candidates = get_lc_graph_by_max_edge(graph, 3, graph_metric_lists[0]) + + with pytest.raises(ValueError): + lc_equivalence._first_distinct_graph(candidates, graph) + + +def test_first_distinct_graph_raises_on_k2(): + graph = nx.complete_graph(2) + candidates = get_lc_graph_by_max_edge(graph, 3, graph_metric_lists[0]) + + with pytest.raises(ValueError): + lc_equivalence._first_distinct_graph(candidates, graph) + + +def test_first_distinct_graph_does_not_raise_on_k3(): + graph = nx.complete_graph(3) + candidates = get_lc_graph_by_max_edge(graph, 3, graph_metric_lists[0]) + + result = lc_equivalence._first_distinct_graph(candidates, graph) + + assert isinstance(result, nx.Graph) + reference_edges = set(map(frozenset, graph.edges())) + result_edges = set(map(frozenset, result.edges())) + assert result_edges != reference_edges + + +@pytest.mark.parametrize( + ("demo", "args", "expected"), + [ + (lc_equivalence.linear_4_example, (), 3), + (lc_equivalence.square_4_example, (), 4), + (lc_equivalence.repeater_graph_example, (4,), 2), + (lc_equivalence.star_graph_example, (7,), 2), + (lc_equivalence.biclique_example, (3,), 3), + ], + ids=[ + "linear_4_example", + "square_4_example", + "repeater_graph_example", + "star_graph_example", + "biclique_example", + ], +) +def test_run_multiple_graphs_demos_search_distinct_graphs( + demo, args, expected, monkeypatch +): + calls = _spy_on_search_and_draw(monkeypatch) + + demo(*args) + + assert len(calls) == expected + edge_sets = {frozenset(map(frozenset, g.edges())) for g in calls} + assert len(edge_sets) == len(calls) From 3926e4c7f04dd1119b81c1e3a2523148ff76969c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:32:54 -0400 Subject: [PATCH 047/227] [fix] Make the solver benchmark and example_solve scripts use their settings - benchmarks/solvers.py indexed the target QuantumState with ["dm"] (QuantumState has no __getitem__, so the script crashed at import of its target list), passed n_stop/n_pop/n_hof/tournament_k as solver keyword arguments that the solver constructors silently discard, and called plot_solver_logs() without its required argument. Pass the state itself, wrap the settings in an EvolutionarySolverSetting, and drop the broken plot call. - examples/example_solve.py set n_stop/n_pop/n_hof/tournament_k as class attributes on EvolutionarySolver, which nothing reads, so the example ran on default settings. Pass an EvolutionarySolverSetting. --- examples/example_solve.py | 13 +++++++------ graphiq/benchmarks/solvers.py | 30 ++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/examples/example_solve.py b/examples/example_solve.py index 8f5dc861..f606f027 100644 --- a/examples/example_solve.py +++ b/examples/example_solve.py @@ -6,7 +6,10 @@ import time from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler -from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.metrics import Infidelity from graphiq.visualizers.density_matrix import density_matrix_bars @@ -15,11 +18,8 @@ if __name__ == "__main__": - # %% here we have access - EvolutionarySolver.n_stop = 40 - EvolutionarySolver.n_pop = 150 - EvolutionarySolver.n_hof = 10 - EvolutionarySolver.tournament_k = 10 + # %% solver settings + setting = EvolutionarySolverSetting(n_stop=40, n_pop=150, n_hof=10, tournament_k=10) # %% comment/uncomment for reproducibility EvolutionarySolver.seed(3) @@ -40,6 +40,7 @@ compiler=compiler, n_photon=n_photon, n_emitter=n_emitter, + solver_setting=setting, ) # %% call the solver.solve() function to implement the random search algorithm diff --git a/graphiq/benchmarks/solvers.py b/graphiq/benchmarks/solvers.py index 569e51bc..6cf086dd 100644 --- a/graphiq/benchmarks/solvers.py +++ b/graphiq/benchmarks/solvers.py @@ -17,10 +17,12 @@ """ from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler -from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.metrics import Infidelity from graphiq.io import IO -from graphiq.visualizers.solver_logs import plot_solver_logs from graphiq.benchmarks.circuits import ghz3_state_circuit, ghz4_state_circuit from graphiq.benchmarks.pipeline import benchmark, run_combinations @@ -28,14 +30,28 @@ if __name__ == "__main__": # %% provide all combinations of solvers, targets, compilers, and metrics to run as a list solvers = [ - (EvolutionarySolver, dict(n_stop=30, n_pop=80, n_hof=10, tournament_k=2)), - (EvolutionarySolver, dict(n_stop=30, n_pop=100, n_hof=10, tournament_k=2)), + ( + EvolutionarySolver, + dict( + solver_setting=EvolutionarySolverSetting( + n_stop=30, n_pop=80, n_hof=10, tournament_k=2 + ) + ), + ), + ( + EvolutionarySolver, + dict( + solver_setting=EvolutionarySolverSetting( + n_stop=30, n_pop=100, n_hof=10, tournament_k=2 + ) + ), + ), ] # provide a list of targets targets = [ - (ghz3_state_circuit()[1]["dm"], dict(name="ghz3", n_photon=3, n_emitter=1)), - (ghz4_state_circuit()[1]["dm"], dict(name="ghz4", n_photon=4, n_emitter=1)), + (ghz3_state_circuit()[1], dict(name="ghz3", n_photon=3, n_emitter=1)), + (ghz4_state_circuit()[1], dict(name="ghz4", n_photon=4, n_emitter=1)), ] compilers = [DensityMatrixCompiler] @@ -50,5 +66,3 @@ ) df = benchmark(runs=runs, io=io, remote=True) print(df) - - plot_solver_logs() From 1c60f09a7a5dc704c11655b372fcbc65f7e49609 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:33:54 -0400 Subject: [PATCH 048/227] [fix] Configure solvers through EvolutionarySolverSetting in tests and notebooks Several tests (test_add_remove_measurements, test_solver_logging, test_one_benchmarking_run) and the 6_solvers / simulate_circuit notebooks tuned a solver by assigning solver.n_stop / n_pop / tournament_k, attributes the solver never reads (it reads solver.setting), so they silently ran on the default settings. Pass an EvolutionarySolverSetting instead. --- examples/startup_guide/6_solvers.ipynb | 6 ++---- examples/startup_guide/simulate_circuit.ipynb | 4 ++-- tests/test_benchmarking/test_benchmarking.py | 14 ++++++++++---- .../test_evolutionary_random_solvers.py | 9 ++++++--- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/examples/startup_guide/6_solvers.ipynb b/examples/startup_guide/6_solvers.ipynb index 3c958bc9..d2c011ec 100644 --- a/examples/startup_guide/6_solvers.ipynb +++ b/examples/startup_guide/6_solvers.ipynb @@ -56,7 +56,7 @@ "from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler\n", "from graphiq.metrics import Infidelity\n", "from graphiq.state import QuantumState\n", - "from graphiq.solvers.evolutionary_solver import EvolutionarySolver\n", + "from graphiq.solvers.evolutionary_solver import EvolutionarySolver, EvolutionarySolverSetting\n", "\n", "\n", "ghz3_target_dm = dmf.ket2dm(\n", @@ -448,9 +448,7 @@ "source": [ "\"\"\" Example where we mess around with settings to 'tune' our solver\"\"\"\n", "\n", - "solver.n_stop = 20\n", - "solver.n_pop = 100\n", - "solver.tournament_k = 3\n", + "solver.setting = EvolutionarySolverSetting(n_stop=20, n_pop=100, tournament_k=3)\n", "\n", "print(f\"default transformation probs: {solver.trans_probs}\") # we will keep these as is\n", "\n", diff --git a/examples/startup_guide/simulate_circuit.ipynb b/examples/startup_guide/simulate_circuit.ipynb index a9f401de..ecb3ed1a 100644 --- a/examples/startup_guide/simulate_circuit.ipynb +++ b/examples/startup_guide/simulate_circuit.ipynb @@ -208,7 +208,7 @@ "outputs": [], "source": [ "from graphiq import metrics\n", - "from graphiq.solvers.evolutionary_solver import EvolutionarySolver" + "from graphiq.solvers.evolutionary_solver import EvolutionarySolver, EvolutionarySolverSetting" ] }, { @@ -316,8 +316,8 @@ " circuit=None,\n", " n_emitter=1,\n", " n_photon=4,\n", + " solver_setting=EvolutionarySolverSetting(tournament_k=0),\n", ")\n", - "solver.tournament_k = 0\n", "\n", "# call .solve to implement the solver algorithm\n", "solver.solve()\n", diff --git a/tests/test_benchmarking/test_benchmarking.py b/tests/test_benchmarking/test_benchmarking.py index a5db34a0..cdd23555 100644 --- a/tests/test_benchmarking/test_benchmarking.py +++ b/tests/test_benchmarking/test_benchmarking.py @@ -15,7 +15,10 @@ from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.io import IO from graphiq.metrics import Infidelity -from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.benchmarks.circuits import ghz3_state_circuit @@ -26,10 +29,13 @@ def test_one_benchmarking_run(): metric = Infidelity(target=target) solver1 = EvolutionarySolver( - target=target, metric=metric, compiler=compiler, n_photon=3, n_emitter=1 + target=target, + metric=metric, + compiler=compiler, + n_photon=3, + n_emitter=1, + solver_setting=EvolutionarySolverSetting(n_stop=10, n_pop=5), ) - solver1.n_stop = 10 - solver1.n_pop = 5 example_run1 = { "solver": solver1, diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index 9ccb646e..eaaf0120 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -21,7 +21,10 @@ from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.io import IO from graphiq.metrics import Infidelity -from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.visualizers.density_matrix import density_matrix_bars from tests.test_flags import visualization from graphiq.benchmarks.circuits import ( @@ -230,9 +233,9 @@ def test_add_remove_measurements(seed): compiler=compiler, n_emitter=n_emitter, n_photon=n_photon, + solver_setting=EvolutionarySolverSetting(n_stop=10), ) solver.seed(seed) - solver.n_stop = 10 original_trans_prob = solver.trans_probs solver.trans_probs = { @@ -267,9 +270,9 @@ def test_solver_logging(seed): n_emitter=n_emitter, n_photon=n_photon, io=io, + solver_setting=EvolutionarySolverSetting(n_stop=5), ) solver.seed(seed) - solver.n_stop = 5 solver.solve() solver.logs_to_df() From bdc8fd6703c666dc718c962758b0ff3ad40a2fe4 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:35:32 -0400 Subject: [PATCH 049/227] [fix] Point the example notebooks at the graphiq package and renamed solver Several notebooks still imported from the pre-rename `src.*` / `benchmarks.*` module paths and from the removed DeterministicSolver (now TimeReversedSolver), so those cells failed with ModuleNotFoundError: 6_solvers, 8_variational_circuits (which also imported a non-existent `adagrad` from the solver module; it is optax's), simulate_circuit and 9_html_visualization_tutorial. Update the imports, and the solver's name in the 6_solvers and 0_implementer_quickstart prose. --- .../0_implementer_quickstart.ipynb | 2 +- examples/startup_guide/6_solvers.ipynb | 18 +++++++++--------- .../startup_guide/8_variational_circuits.ipynb | 3 ++- .../9_html_visualization_tutorial.ipynb | 4 ++-- examples/startup_guide/simulate_circuit.ipynb | 2 +- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/examples/startup_guide/0_implementer_quickstart.ipynb b/examples/startup_guide/0_implementer_quickstart.ipynb index d41f9d78..1c776caa 100644 --- a/examples/startup_guide/0_implementer_quickstart.ipynb +++ b/examples/startup_guide/0_implementer_quickstart.ipynb @@ -42,7 +42,7 @@ "3. Running a circuit (CompilerBase, DensityMatrixCompiler, StabilizerCompiler)\n", "4. Noise models\n", "5. Metrics (MetricBase, Infidelity, TraceDistance, Metrics)\n", - "6. Solvers (SolverBase, EvolutionarySolver, DeterministicSolver)\n", + "6. Solvers (SolverBase, EvolutionarySolver, TimeReversedSolver)\n", "7. Benchmarking\n", "8. (upcoming) Differentiable circuits\n", "\n", diff --git a/examples/startup_guide/6_solvers.ipynb b/examples/startup_guide/6_solvers.ipynb index d2c011ec..01c30ca9 100644 --- a/examples/startup_guide/6_solvers.ipynb +++ b/examples/startup_guide/6_solvers.ipynb @@ -24,7 +24,7 @@ "\n", "The role of a solver is to generate one or more candidate circuits which produce a target quantum state.\n", "\n", - "The central mapping algorithm uses the protocol outlined by Li *et al.* and is implemented in the `DeterministicSolver` class. \n", + "The central mapping algorithm uses the protocol outlined by Li *et al.* and is implemented in the `TimeReversedSolver` class. \n", "The `EvolutionarySolver` class is a heuristic optimization algorithm which can be used to find a circuit which produces a target state, however, does not scale well to large numbers of qubits." ] }, @@ -525,9 +525,9 @@ } }, "source": [ - "## `DeterministicSolver` [Public API]\n", + "## `TimeReversedSolver` [Public API]\n", "\n", - "Deterministic solver which follows the paper by Li et al [1]. \n", + "Time-reversed solver which follows the paper by Li et al [1]. \n", "[1] Bikun Li, Sophia E. Economou and Edwin Barnes, npj. Quantum Information 8, 11 (2022) " ] }, @@ -595,17 +595,17 @@ ], "source": [ "\"\"\" Solver circuit initialization examples \"\"\"\n", - "from benchmarks.circuits import *\n", - "from src.backends.stabilizer.compiler import StabilizerCompiler\n", - "from src.backends.density_matrix.compiler import DensityMatrixCompiler\n", - "from src.backends.density_matrix.functions import fidelity\n", - "from src.solvers.deterministic_solver import DeterministicSolver\n", + "from graphiq.benchmarks.circuits import ghz3_state_circuit\n", + "from graphiq.backends.stabilizer.compiler import StabilizerCompiler\n", + "from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler\n", + "from graphiq.backends.density_matrix.functions import fidelity\n", + "from graphiq.solvers.time_reversed_solver import TimeReversedSolver\n", "\n", "compiler = StabilizerCompiler()\n", "target_circuit, target = ghz3_state_circuit()\n", "metric = Infidelity(target)\n", "\n", - "solver = DeterministicSolver(\n", + "solver = TimeReversedSolver(\n", " target=target,\n", " metric=metric,\n", " compiler=compiler,\n", diff --git a/examples/startup_guide/8_variational_circuits.ipynb b/examples/startup_guide/8_variational_circuits.ipynb index d1d74104..c1714b30 100644 --- a/examples/startup_guide/8_variational_circuits.ipynb +++ b/examples/startup_guide/8_variational_circuits.ipynb @@ -391,7 +391,8 @@ "execution_count": 10, "outputs": [], "source": [ - "from src.solvers.gradient_descent_solver import GradientDescentSolver, adagrad" + "from graphiq.solvers.gradient_descent_solver import GradientDescentSolver\n", + "from optax import adagrad" ], "metadata": { "collapsed": false, diff --git a/examples/startup_guide/9_html_visualization_tutorial.ipynb b/examples/startup_guide/9_html_visualization_tutorial.ipynb index d664e416..d60ecefd 100644 --- a/examples/startup_guide/9_html_visualization_tutorial.ipynb +++ b/examples/startup_guide/9_html_visualization_tutorial.ipynb @@ -404,7 +404,7 @@ "\n", "To be able to draw from openqasm string, the `Painter` has a function called `load_openqasm_str`. The function takes an `OpenQASM 2.0` string as an input, and will add all the drawing information that need to be draw on the web app. \n", "\n", - "Here an example, that we draw `ghz3` circuit from our `benchmarks.circuits`" + "Here an example, that we draw `ghz3` circuit from our `graphiq.benchmarks.circuits`" ] }, { @@ -437,7 +437,7 @@ } ], "source": [ - "from benchmarks.circuits import *\n", + "from graphiq.benchmarks.circuits import ghz3_state_circuit\n", "\n", "circuit, state = ghz3_state_circuit()\n", "openqasm = circuit.to_openqasm()\n", diff --git a/examples/startup_guide/simulate_circuit.ipynb b/examples/startup_guide/simulate_circuit.ipynb index ecb3ed1a..91ac73a3 100644 --- a/examples/startup_guide/simulate_circuit.ipynb +++ b/examples/startup_guide/simulate_circuit.ipynb @@ -188,7 +188,7 @@ ], "source": [ "# save the resulting quantum state\n", - "from src.io import IO\n", + "from graphiq.io import IO\n", "\n", "io = IO.new_directory(folder=\"example-folder\", include_date=True, include_id=True)\n", "io.save_np_array(state_a, \"partial_density_matrix.np\")" From 332717ff6e5b37b57e35df508c809ef7b960393c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:36:27 -0400 Subject: [PATCH 050/227] [fix] Make the HTML visualization tutorial's figures render again A project-wide rename (src -> graphiq) also rewrote every `" + "![Visualization system design: OpenQASMParser, Painter, Flask and d3.js web app, HTML pages](../img/html_visualization/visualization_system_design.png)" ] }, { @@ -35,14 +35,14 @@ "1. Install `Flask`: If you haven't install `Flask`, run:\n", "`pip install Flask` or `pip install -r requirements.txt` since the `Flask` package is included in the `requirements.txt` file\n", "\n", - "2. In the `html_visualization` directory, you can see a file called `app.py`. \n", + "2. In the `visualization` directory, you can see a file called `app.py`. \n", "Simply run `python app.py` or run the `app.py` file on PyCharm IDE.\n", "\n", "3. Open a new tab on Google Chrome, and type in `127.0.0.1:5000` and you will see the app open\n", "\n", "Here is how the app looks like at first. Don't worry if you see the page is empty, that's how the web app is initialized. We will get to the part where we send visualization info to the app and it will draw on the svg element.\n", "\n", - "\"Drawing\"/" + "![The Painter web app at start: an empty circuit canvas and an empty Gate Info panel](../img/html_visualization/app_init.png)" ] }, { @@ -72,13 +72,13 @@ "- The Painter class use the Columns class to keep track of the position of operations, registers, and the width of each column.\n", "- The Columns class is a 2d array with an array columns, and each column is an array has a size equal to the number of registers.\n", "- When an operation is added, the Columns class will fill with value 1 where the operation is on. \n", - "\"Drawing\"/\n", + "![Four columns of a circuit and the Columns array marking each operation's cell with 1](../img/html_visualization/columns_class_0.png)\n", "\n", "- For special case like barriers, every columns before the barrier is filled with -1. This will help to add next operation after the barrier when the Columns find the empty space to add new operation\n", - "\"Drawing\"/\n", + "![A barrier column marked with 1s, and -1 filling the empty qubit cells of the columns before it](../img/html_visualization/columns_class_1.png)\n", "\n", "- Columns width: Each column will have a width of the column. This is helpful because sometimes we will want to draw gates that has bigger width than normal gate. The Columns will update the width accordingly when encounter a new width.\n", - "\"Drawing\"/" + "![A wider gate widening its column: default width beside the new width](../img/html_visualization/columns_class_2.png)" ] }, { @@ -152,7 +152,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding the registers p[0] to p[3] and c[4]](../img/html_visualization/draw_reg.png)" ] }, { @@ -222,7 +222,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding the gates: H, RX, RZ and a CNOT from e[0]](../img/html_visualization/draw_gates.png)" ] }, { @@ -281,7 +281,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding Z measurements on p[0] to p[3] into c[4]](../img/html_visualization/draw_measure.png)" ] }, { @@ -334,7 +334,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding a barrier across the five qubit registers](../img/html_visualization/draw_barriers.png)" ] }, { @@ -388,7 +388,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding a reset on each of the five qubit registers](../img/html_visualization/draw_reset.png)" ] }, { @@ -456,7 +456,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter drawing of the ghz3 benchmark circuit](../img/html_visualization/ghz3.png)" ] } ], From 865ff885a495128876ba567a144aa6f6bd638ab0 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:37:07 -0400 Subject: [PATCH 051/227] [fix] Fix docstrings and a type annotation in monte_carlo_noise.py - McNoiseMap's reg_type docs listed 'e', 'p', 'ep', 'pp'; the map's register types are 'e', 'p', 'ee', 'ep'. - MonteCarloNoise's class docstring said it saves results, which it does not (there is a TODO for it); describe what it does. - MonteCarloNoise.__init__ annotated mc_noise_model as dict but asserts a McNoiseMap. - Invalid tuple type syntax, a duplicated :return:, a missing :rtype: on run(), and missing blank lines before the field lists. --- graphiq/noise/monte_carlo_noise.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/graphiq/noise/monte_carlo_noise.py b/graphiq/noise/monte_carlo_noise.py index 88bf70d9..0b4f9e12 100644 --- a/graphiq/noise/monte_carlo_noise.py +++ b/graphiq/noise/monte_carlo_noise.py @@ -66,12 +66,13 @@ def get_gate_noise(self, reg_type, gate_name): """ A list of tuples containing noise models and their corresponding probability of happening is returned for a certain type of gate in circuit. - :param reg_type: the register type of the gate chosen from 'e', 'p', 'ep', 'pp' + + :param reg_type: the register type of the gate, one of 'e', 'p', 'ee', 'ep' :type reg_type: str :param gate_name: the name of the gate :type gate_name: str :return: list of tuples (noise models, probabilities) - :rtype: list + :rtype: list[tuple[nm.NoiseBase, float]] """ noise_prob = self.total_noise_prob(reg_type, gate_name) if noise_prob != 0: @@ -83,8 +84,9 @@ def get_gate_noise(self, reg_type, gate_name): def add_gate_noise(self, reg_type, gate_name, noise_list): """ - adds (or replaces if existing before) a noise model to a certain type of gate in the circuit. - :param reg_type: the register type of the gate chosen from 'e', 'p', 'ep', 'pp' + Adds (or replaces if existing before) a noise model to a certain type of gate in the circuit. + + :param reg_type: the register type of the gate, one of 'e', 'p', 'ee', 'ep' :type reg_type: str :param gate_name: the name of the gate :type gate_name: str @@ -92,7 +94,6 @@ def add_gate_noise(self, reg_type, gate_name, noise_list): :type noise_list: list :return: nothing :rtype: None - :return: """ if gate_name in self.mapping[reg_type]: self._mapping[reg_type][gate_name] = [] @@ -101,13 +102,14 @@ def add_gate_noise(self, reg_type, gate_name, noise_list): def add_noise_tuple(self, reg_type, gate_name, noise_tuple): """ - adds a noise model to a certain type of gate in the circuit. - :param reg_type: the register type of the gate chosen from 'e', 'p', 'ep', 'pp' + Adds a noise model to a certain type of gate in the circuit. + + :param reg_type: the register type of the gate, one of 'e', 'p', 'ee', 'ep' :type reg_type: str :param gate_name: the name of the gate :type gate_name: str :param noise_tuple: a tuple with first element being a noise model object and the second element a float < 1. - :type noise_tuple: tuple(NoiseBase, float) + :type noise_tuple: tuple[nm.NoiseBase, float] :return: nothing :rtype: None """ @@ -135,7 +137,8 @@ def add_noise_tuple(self, reg_type, gate_name, noise_tuple): def total_noise_prob(self, reg_type, gate_name): """ Calculates the total probability of the gate not being perfect. - :param reg_type: the register type of the gate chosen from 'e', 'p', 'ep', 'pp' + + :param reg_type: the register type of the gate, one of 'e', 'p', 'ee', 'ep' :type reg_type: str :param gate_name: the name of the gate :type gate_name: str @@ -151,8 +154,8 @@ def total_noise_prob(self, reg_type, gate_name): class MonteCarloNoise: """ - A class to compile a given noise model, possibly tailored specifically for monte-carlo run, for an input circuit and - saving the results. + Compiles a circuit under Monte Carlo noise, drawn from a per-gate noise model, and scores + each noisy sample against the noiseless compilation. """ # TODO: implement save circuits option @@ -160,7 +163,7 @@ def __init__( self, circuit, n_sample: int = 1, - mc_noise_model: dict = None, + mc_noise_model: McNoiseMap = None, compiler=None, seed=None, ): @@ -238,7 +241,9 @@ def seed(self, new_seed): def run(self): """ Calculates the average infidelity for the noisy circuits when n_sample trials are performed. + :return: average score (infidelity) + :rtype: float """ self.all_scores = [] for i in range(self.n_sample): From 84cbb13ae0cd78bf5330ff706d4bb22225a71173 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:37:59 -0400 Subject: [PATCH 052/227] [docs] Fix wrong and missing docstrings in noise_models.py - AmplitudeDampingNoise was "described by a depolarizing probability"; it takes a damping probability. - PhasePerturbedError and SigmaXPerturbedError's constructors said "Construct a HadamardPerturbedError object" (copy-paste). - "numpy.nadrray" typo in CoherentUnitaryError; capitalize MeasurementError's class docstring. - OneQubitGateReplacement.apply had no summary line; add missing :return:/:rtype: fields, and document that NoiseBase accepts None for noise_parameters. --- graphiq/noise/noise_models.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 2ece5689..4966e54a 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -59,8 +59,9 @@ def __init__(self, noise_parameters=None): """ Initialize a base class for noise model - :param noise_parameters: a dictionary of parameters to describe the noise model - :type noise_parameters: dict + :param noise_parameters: a dictionary of parameters to describe the noise model, or + None for an empty dictionary + :type noise_parameters: dict or None :return: nothing :rtype: None """ @@ -155,6 +156,8 @@ def __init__(self): """ Construct a NoNoise object + :return: nothing + :rtype: None """ super().__init__() @@ -190,6 +193,7 @@ def __init__(self, one_qubit_unitary): def apply(self, state: QuantumState, n_quantum, reg_list): """ + Apply the noise to the state :param state: a quantum state to which the noise is applied :type state: QuantumState @@ -470,7 +474,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): class AmplitudeDampingNoise(AdditionNoiseBase): """ - Amplitude damping noise described by a depolarizing probability + Amplitude damping noise described by a damping probability """ @@ -480,6 +484,8 @@ def __init__(self, damping_probability): :param damping_probability: the dampening probability between 0 and 1 :type damping_probability: float + :return: nothing + :rtype: None """ noise_parameters = {"damping_probability": damping_probability} super().__init__(noise_parameters) @@ -538,6 +544,8 @@ def __init__(self, depolarizing_prob): :param depolarizing_prob: the depolarizing probability :type depolarizing_prob: float + :return: nothing + :rtype: None """ noise_parameters = {"Depolarizing probability": depolarizing_prob} super().__init__(noise_parameters) @@ -677,7 +685,7 @@ class PhasePerturbedError(OneQubitGateReplacement): def __init__(self, theta_pert, phi_pert, lam_pert): """ - Construct a HadamardPerturbedError object + Construct a PhasePerturbedError object :param theta_pert: the perturbation added to the theta angle :type theta_pert: float @@ -706,7 +714,7 @@ class SigmaXPerturbedError(OneQubitGateReplacement): def __init__(self, theta_pert, phi_pert, lam_pert): """ - Construct a HadamardPerturbedError object + Construct a SigmaXPerturbedError object :param theta_pert: the perturbation added to the theta angle :type theta_pert: float @@ -806,6 +814,8 @@ def __init__(self, unitaries_list, prob_list): :type unitaries_list: list[numpy.ndarray] :param prob_list: the corresponding probability distribution for the unitaries :type prob_list: list[float] + :return: nothing + :rtype: None """ noise_parameters = {"Unitaries": unitaries_list, "Probabilities": prob_list} super().__init__(noise_parameters) @@ -855,7 +865,7 @@ def __init__(self, unitary): Construct a coherent unitary error described by a single unitary :param unitary: a unitary that specified the error - :type unitary: numpy.nadrray or str + :type unitary: numpy.ndarray or str :return: nothing :rtype: None """ @@ -897,7 +907,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): class MeasurementError(NoiseBase): """ - a measurement error described by a conditional probability distribution + A measurement error described by a conditional probability distribution # TODO: implement this error model """ @@ -981,6 +991,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): :param reg_list: a list of registers where non-identity gates are applied :type reg_list: list[int] :return: nothing + :rtype: None """ state_rep = state.rep_data if isinstance(state_rep, DensityMatrix): From b9e000e3955d0f5c980a7d26a2ebefc01fd6454b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:38:29 -0400 Subject: [PATCH 053/227] [docs] Fix wrong and malformed docstrings in time_depend_noise.py and model_parameters.py Correct copy-pasted :return: descriptions (reg_as_control, emission_finder and section_finder all claimed to return "the nodes that have more than one input or output edges"), invalid type syntax (list[(, )], tuple(int, str), a bare ":type:" with no name), a wrong class name (list[NoiseEvents]), a malformed ":raises:" field, missing field docs, and typos ("complied", "ect.", "calculated", "consist"). --- graphiq/noise/model_parameters.py | 9 +-- graphiq/noise/time_depend_noise.py | 91 +++++++++++++++++------------- 2 files changed, 56 insertions(+), 44 deletions(-) diff --git a/graphiq/noise/model_parameters.py b/graphiq/noise/model_parameters.py index 0d47eaf5..e55ea85d 100644 --- a/graphiq/noise/model_parameters.py +++ b/graphiq/noise/model_parameters.py @@ -17,10 +17,11 @@ def equal_time_dict(ops_list, duration): """ - A helper function. Creates a dictionary of operation types and assigns them all an equal time duration. - :param ops_list: list of operations - :type ops_list: list - :param duration: a time duration + Build a gate-duration dict, assigning duration to every operation type in ops_list. + + :param ops_list: operation instances whose types become the dict's keys + :type ops_list: list[ops.OperationBase] + :param duration: the time duration assigned to each operation type :type duration: int or float :return: the gate duration dictionary for the given operations :rtype: dict diff --git a/graphiq/noise/time_depend_noise.py b/graphiq/noise/time_depend_noise.py index f9f767ac..05e73191 100644 --- a/graphiq/noise/time_depend_noise.py +++ b/graphiq/noise/time_depend_noise.py @@ -32,8 +32,8 @@ class NoisyEnsemble: """ - NoisyEnsemble class. For a quantum circuit, this class explores all different possibilities to make it noisy given - the noise parameters and list of imperfect registers. + Explores every way to make a quantum circuit noisy, given noise parameters and a list of + imperfect registers. """ def __init__( @@ -41,7 +41,7 @@ def __init__( ): """ Creates a NoisyEnsemble object that allows an ideal circuit to turn into a noisy one by inserting errors to the - set of noisy registers (noisy_regs). The noise/error model and its properties in specified by the + set of noisy registers (noisy_regs). The noise/error model and its properties are specified by the noise_parameters. :param circ: the ideal quantum circuit @@ -52,7 +52,7 @@ def __init__( circuit. :type noise_parameters: dict :param gate_duration_dict: the gate duration dictionary for the operations used in the circuit - :type: dict + :type gate_duration_dict: dict """ if noise_parameters is None: noise_parameters = mp.noise_parameters @@ -92,14 +92,16 @@ def to_event_tree( A helper function that translate the output of the prob_tree_finder function into a list of NoiseEvent objects. We call this list an event tree. - :param prob_tree_found: the list [(error happening at certain (sections indices, ), probability of this event)] - and the list of sections - :type prob_tree_found: list[((, ), float)], list + :param prob_tree_found: a 2-tuple: the list + [(error happening at certain (sections indices, ), probability of this event)], + and the list of sections + :type prob_tree_found: tuple[list[tuple[tuple[int, ...], float]], list] :param reg: the register where noise events are happening :type reg: int :param reg_type: type of the register :type reg_type: str - :param noise_type: str or list[str] + :param noise_type: the noise type to tag each event with + :type noise_type: str :return: list of events :rtype: list[NoiseEvent] """ @@ -135,13 +137,13 @@ def reg_history(circ, reg, reg_type): def all_branches(self): """ This method calculates all possible NoiseEvents that can be applied on the NoisyEnsemble object. It corresponds - to the event tree that is resulted from adding noise to the circuit. An event tree is a list of branches, where - each branch has a probability of happening and corresponds to a series of error gates happening at different - parts of the ideal circuit. The total probability of an event tree is between the cut-off probability, which - can be set in noise parameters, and unity. + to the event tree that is resulted from adding noise to the circuit. An event tree is a list of branches, where + each branch has a probability of happening and corresponds to a series of error gates happening at different + parts of the ideal circuit. The total probability of an event tree is between the cut-off probability, which + can be set in noise parameters, and unity. :return: An event tree, which is a list of NoiseEvents - :rtype: list[NoiseEvents] + :rtype: list[NoiseEvent] """ event_tree_list = [] for noisy_reg in self.noisy_regs: @@ -192,15 +194,15 @@ def circuit_tree(self): The list of all possible noisy circuits and their probabilities, resulted by applying noise to the ideal circuit :return: circuit tree; which is a list of circuits and their respective probability of happening in tuples - :rtype: list[(, )] + :rtype: list[tuple[CircuitDAG, float]] """ return insert_error(self.circ, self.all_branches()) def output_state(self, backend="stabilizer"): """ - Calculates the resulting output quantum state once the noisy circuit is complied. + Calculates the resulting output quantum state once the noisy circuit is compiled. - :param backend: the backend in used to compile the noisy circuit. Right now only stabilizer is allowed. + :param backend: the backend used to compile the noisy circuit. Right now only stabilizer is allowed. :type backend: str :return: the noisy quantum state produced by the quantum circuit :rtype: QuantumState @@ -228,11 +230,11 @@ def output_state(self, backend="stabilizer"): def noise_type_list(self, noisy_reg): """ Gets the noisy register as a tuple (reg, reg_type) and returns a list of noise types that must be applied on - that register.Used to find the register specific noise type from noise parameters' dictionary if there exists + that register. Used to find the register specific noise type from noise parameters' dictionary if there exists any. :param noisy_reg: the noisy register and its type (emitter: 'e' or photon: 'p') - :type noisy_reg: tuple(int, str) + :type noisy_reg: tuple[int, str] :return: list of noise types :rtype: list[str] """ @@ -267,7 +269,7 @@ def noise_type_list(self, noisy_reg): class NoiseEvent: """ - The class to represent noise events. Each noise event has its own probability of happening and correspond to + The class to represent noise events. Each noise event has its own probability of happening and corresponds to inserting a series of errors before certain gates in the quantum circuit to make it noisy compared to an ideal circuit. """ @@ -309,13 +311,17 @@ def nodes_and_regs(self, value): def nodes(self): """ - :return: the set of node that participate in this event + The set of nodes that participate in this event. + + :return: the set of nodes that participate in this event :rtype: set """ return set([nod[0] for nod in self.nodes_and_regs]) def noises(self): """ + The set of noise types used in this event. + :return: the set of noise types used in the event :rtype: set """ @@ -327,7 +333,10 @@ def merge_with(self, events): accordingly. :param events: a list of NoiseEvent objects or a single one - :return: None + :type events: list[NoiseEvent] or NoiseEvent + :raises ValueError: if events is not a NoiseEvent or a list of NoiseEvent objects + :return: nothing + :rtype: None """ # events = a list of noise event objects or a single event if isinstance(events, list): @@ -352,7 +361,7 @@ def nodes_of_type(circ, node_list, gate_types): :param node_list: a list of some operation nodes in the DAG :type node_list: list :param gate_types: a single operation type or a list of operation types - :type gate_types: any subclass of OperationBase or a list of them + :type gate_types: type[ops.OperationBase] or list[type[ops.OperationBase]] :return: list of the nodes that are of the given types :rtype: list """ @@ -370,7 +379,7 @@ def nodes_of_type(circ, node_list, gate_types): def multi_reg_nodes(circ, node_list): """ - Given a list of nodes in a circuit DAG, it returns the multi-register nodes. (n-qubit gates, measurements, ect.) + Given a list of nodes in a circuit DAG, it returns the multi-register nodes. (n-qubit gates, measurements, etc.) :param circ: a quantum circuit :type circ: CircuitDAG @@ -405,7 +414,7 @@ def reg_as_control( :type reg: int :param reg_type: type of the register :type reg_type: str - :return: list of the nodes that have more than one input or output edges in the DAG. + :return: the nodes in node_list where reg (of type reg_type) is the control register :rtype: list """ multi_nodes = multi_reg_nodes(circ, node_list) @@ -427,7 +436,8 @@ def emission_finder(circ, node_list): :type circ: CircuitDAG :param node_list: a list of some operation nodes in the DAG :type node_list: list - :return: list of the nodes that have more than one input or output edges in the DAG. + :return: the nodes in node_list that are emission events -- an emitter-to-photon CNOT that + is the first gate applied to that photon :rtype: list """ # checks whether the CNOT on an emitter and a photon is the first gate that has ever been applied to the photon. @@ -460,9 +470,9 @@ def section_finder(circ, ordered_path, criteria, reg=0, reg_type="e"): :type reg: int :param reg_type: type of the register :type reg_type: str - :raises: ValueError if the criteria is not valid - :return: list of the nodes that have more than one input or output edges in the DAG. - :rtype: list + :raises ValueError: if the criteria is not valid + :return: the list of sections; each section is itself a list of nodes + :rtype: list[list] """ # (] the last operation=slicer is included in each section: the slicer node itself is in the previous section # criteria='based_on_a list of types or super classes= string or list of types' @@ -524,7 +534,7 @@ def section_duration(circ, section_nodes, gate_duration_dict): def seq_time_tuples(circ, list_of_sections, gate_duration_dict): """ - Given a list of sections,this helper function calculates the duration of each section and returns a list of tuples, + Given a list of sections, this helper function calculates the duration of each section and returns a list of tuples, with each tuple including the index of the section in the list and its time duration. :param circ: a quantum circuit @@ -534,7 +544,7 @@ def seq_time_tuples(circ, list_of_sections, gate_duration_dict): :param gate_duration_dict: a dictionary of {gates: their respective duration} :type gate_duration_dict: dict :return: list of the tuples (section index, duration) - :rtype: list + :rtype: list[tuple[int, float]] """ time_list = [] section_index = 0 @@ -591,7 +601,7 @@ def prob_tree_finder( circ, list_of_sections, error_rate, cut_off_prob, gate_duration_dict ): """ - Given a list of sections, this function calculated all possible needed outcomes in a probability tree, where each + Given a list of sections, this function calculates all possible needed outcomes in a probability tree, where each branch represents having the error gates happening at a certain number of sections. It returns the probability tree and the list of sections used for that tree. The probability tree is a list of tuples where the first element is itself a tuple of section indices (in the list of sections) where error is supposed to happen and the second @@ -607,9 +617,10 @@ def prob_tree_finder( :type cut_off_prob: float :param gate_duration_dict: a dictionary of {gates: their respective duration} :type gate_duration_dict: dict - :return: a list of tuples [(error happening at certain (sections indices, ), probability of this event)] and the - list of sections - :rtype: list[((, ), float)], list + :return: a 2-tuple: a list of tuples + [(error happening at certain (sections indices, ), probability of this event)], + and the list of sections + :rtype: tuple[list[tuple[tuple[int, ...], float]], list] """ # remove sections with zero time duration from the input list of sections. section_times = seq_time_tuples( @@ -657,15 +668,15 @@ def prob_tree_finder( def insert_error(circ, all_branches): """ - Given a circuit and a list noise events, this function returns a list of modified circuits with respect to the list - of noise events. The probability of each modified circuit is also returned . We call this a circuit tree. + Given a circuit and a list of noise events, this function returns a list of modified circuits with respect to the list + of noise events. The probability of each modified circuit is also returned. We call this a circuit tree. :param circ: a quantum circuit :type circ: CircuitDAG :param all_branches: the list of all NoiseEvents that need to be applied on the circuit. :type all_branches: list[NoiseEvent] :return: circuit tree; which is a list of circuits and their respective probability of happening in tuples - :rtype: list[(, )] + :rtype: list[tuple[CircuitDAG, float]] """ # all_branches is the list of all NoiseEvents that need to be applied on circuit. For a single noise event in the # list, we have the node, register and its type, noise_type [(node, (reg, reg_type), noise_type)] and its @@ -693,17 +704,17 @@ def insert_error(circ, all_branches): def unwrapper(event_tree_list, events_to_merge=None, all_events=None, ii=0): """ - This recursive function combines different individual event trees into a single one. Each event tree consist of a + This recursive function combines different individual event trees into a single one. Each event tree consists of a number of branches (list of NoiseEvents) that sum up to probability one. :param event_tree_list: a list of event trees :type event_tree_list: list :param events_to_merge: an internal parameter which is a list to keep branches and need to be merged into a single one. The default value should not be changed when function is called. - :type events_to_merge: list + :type events_to_merge: list or None :param all_events: an internal parameter that corresponds to the final combined event tree as branches are added to it through the runtime. The default value should not be changed when function is called. - :type all_events: list + :type all_events: list or None :param ii: internal parameter used in the recursive algorithm. The default value should not be changed when function is called. :type ii: int From 4b2b4278e01cf864b769703d51631e7cbdd541f9 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:38:59 -0400 Subject: [PATCH 054/227] [fix] Use math.factorial instead of the deprecated np.math alias np.math is just an alias of the standard-library math module; numpy 1.25 deprecated it and numpy 2.0 removed it, so relabel_module, time_depend_noise.poisson_prob, correlation_module and a relabel test raise AttributeError on a current numpy. Call math.factorial directly (same values). --- graphiq/data_collection/correlation_module.py | 6 +++--- graphiq/noise/time_depend_noise.py | 3 ++- graphiq/utils/relabel_module.py | 7 ++++--- tests/test_utils/test_relabel_module.py | 4 +++- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 34a97826..159e21d2 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import random -from math import ceil +from math import ceil, factorial import graphiq.backends.state_rep_conversion as converter import networkx as nx @@ -216,7 +216,7 @@ def _graph_list_maker(g, count): """ adj = nx.to_numpy_array(g) nod = g.number_of_nodes() - iso_count = min(np.math.factorial(nod), count) + iso_count = min(factorial(nod), count) adj_arr = iso_finder(adj, iso_count, allow_exhaustive=True) graph_list = [g] for adj_i in adj_arr: @@ -1146,7 +1146,7 @@ def adj_emit_sorted_list(adj, count=1000): :rtype: list, list """ n = adj.shape[0] - iso_count = min(np.math.factorial(n), count) + iso_count = min(factorial(n), count) arr = iso_finder(adj, iso_count, allow_exhaustive=True) li11 = emitter_sorted(arr) emit_list = [x[1] for x in li11] diff --git a/graphiq/noise/time_depend_noise.py b/graphiq/noise/time_depend_noise.py index 05e73191..a8392a62 100644 --- a/graphiq/noise/time_depend_noise.py +++ b/graphiq/noise/time_depend_noise.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import copy +import math from itertools import combinations_with_replacement import numpy as np @@ -569,7 +570,7 @@ def poisson_prob(count, time, rate): :return: the probability of the specified number of events happening :rtype: float """ - return np.exp(-rate * time) * ((rate * time) ** count) / np.math.factorial(count) + return np.exp(-rate * time) * ((rate * time) ** count) / math.factorial(count) def cut_off_counter(cut_off_prob, time, rate): diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 121c77f1..7ada343f 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -16,6 +16,7 @@ Utility functionality for creating isomorphic graphs to explore alternative circuits. """ +import math import warnings from itertools import permutations @@ -70,7 +71,7 @@ def iso_finder( :rtype: numpy.ndarray or tuple (numpy.ndarray, list[dict]) """ n_node = adj_matrix.shape[0] - n_max = np.math.factorial(n_node) + n_max = math.factorial(n_node) n_label = n_iso labels_arr = _label_finder(n_label, n_node, seed=seed, thresh=thresh) adj_arr = automorph_check(adj_matrix, labels_arr) @@ -209,7 +210,7 @@ def _label_finder( :rtype: numpy.ndarray """ rng = np.random.default_rng(seed) - n_max = np.math.factorial(n_node) + n_max = math.factorial(n_node) if thresh is None: thresh = 5 * n_label elif thresh < n_label: @@ -258,7 +259,7 @@ def _add_labels(labels_arr, add_n, exhaustive=False, seed=None, thresh=None): """ n_node = labels_arr[0].shape[0] n_label = len(labels_arr) - n_max = np.math.factorial(n_node) + n_max = math.factorial(n_node) n_total = add_n + n_label if add_n + n_label < n_max else n_max label_set = set([tuple(labels) for labels in labels_arr]) return _label_finder( diff --git a/tests/test_utils/test_relabel_module.py b/tests/test_utils/test_relabel_module.py index dc4a949f..8087c14e 100644 --- a/tests/test_utils/test_relabel_module.py +++ b/tests/test_utils/test_relabel_module.py @@ -12,6 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import math + import networkx as nx import numpy as np import pytest @@ -31,7 +33,7 @@ def random_connected_graph(n, p, seed=None): @pytest.mark.parametrize("n_node", [5, 6, 7, 8]) def test_random_graph(n_node): - n_iso = np.math.factorial(n_node - 2) + n_iso = math.factorial(n_node - 2) for p in range(4, 7): found = [] for i in range(3): From b7385d11830520dd0ea5315822a00d8edb1ded99 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:40:12 -0400 Subject: [PATCH 055/227] [fix] Size binary_symplectic_product's form from the column count binary_symplectic_product built its symplectic form P from matrix1.shape[0] (the row count) and required both matrices to have the same number of rows. That is only right by coincidence for an n x 2n stabilizer table; is_symplectic on a 2n x 2n Clifford table crashed with a matmul dimension error, and a stabilizer could not be multiplied against a Clifford table. Size P from the shared column count (2n) and require matching column counts; is_symplectic_self_orthogonal compares against a rows x rows zero matrix accordingly. Results for the n x 2n inputs used in the tree are unchanged. Also correct the documented return type (a binary matrix, not an int). --- .../backends/stabilizer/functions/utils.py | 18 ++++++++++-------- .../stabilizer/test_stabilizer_functions.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/graphiq/backends/stabilizer/functions/utils.py b/graphiq/backends/stabilizer/functions/utils.py index 7a28218b..2c970af6 100644 --- a/graphiq/backends/stabilizer/functions/utils.py +++ b/graphiq/backends/stabilizer/functions/utils.py @@ -125,10 +125,9 @@ def is_symplectic_self_orthogonal(input_matrix): :return: True if the input matrix is self-orthogonal; False otherwise :rtype: bool """ - dim = int(input_matrix.shape[1] / 2) - return np.array_equal( - binary_symplectic_product(input_matrix, input_matrix), np.zeros((dim, dim)) + binary_symplectic_product(input_matrix, input_matrix), + np.zeros((input_matrix.shape[0],) * 2), ) @@ -137,17 +136,20 @@ def binary_symplectic_product(matrix1, matrix2): Compute the binary symplectic product of two matrices matrix1 (:math:`M_1`) and matrix2 (:math:`M_2`) The symplectic inner product between :math:`M_1` and :math:`M_2` is :math:`M_1 P M_2^T`, - where :math:`P = \\begin{bmatrix} 0 & I \\\\ I & 0 \\end{bmatrix}`. + where :math:`P = \\begin{bmatrix} 0 & I \\\\ I & 0 \\end{bmatrix}` is sized from the shared + number of columns (2n), not the row count -- the two matrices need not have the same number of + rows (e.g. a stabilizer's n rows against a Clifford tableau's 2n). :param matrix1: a binary symplectic matrix :type matrix1: numpy.ndarray :param matrix2: a binary symplectic matrix :type matrix2: numpy.ndarray - :return: the symplectic product of these two matrices - :rtype: int + :return: a (number of rows in matrix1, number of rows in matrix2) binary matrix, one + entry per row pair + :rtype: numpy.ndarray """ - assert matrix1.shape[0] == matrix2.shape[0] - dim = matrix1.shape[0] + assert matrix1.shape[1] == matrix2.shape[1] + dim = matrix1.shape[1] // 2 symplectic_p = np.block( [[np.zeros((dim, dim)), np.eye(dim)], [np.eye(dim), np.zeros((dim, dim))]] ).astype(int) diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py index e1df2de0..3365e544 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py @@ -280,3 +280,22 @@ def test_stabilizer_tableau_accepts_a_square_pair(): tableau = StabilizerTableau([np.eye(3, dtype=int), np.zeros((3, 3), dtype=int)]) assert tableau.n_qubits == 3 assert tableau.table.shape == (3, 6) + + +def test_is_symplectic_on_a_clifford_tableau(): + # a 2n x 2n Clifford table: P must be sized from the columns, not the rows + n = 3 + assert sfu.is_symplectic(np.eye(2 * n, dtype=int)) + table = np.eye(2 * n, dtype=int) + table[0, 1] = 1 # X_0 -> X_0 X_1, which anticommutes with Z_1: not symplectic + assert not sfu.is_symplectic(table) + + +def test_binary_symplectic_product_of_different_row_counts(): + stabilizer = np.hstack([np.zeros((2, 2), dtype=int), np.eye(2, dtype=int)]) # ZI, IZ + clifford = np.eye(4, dtype=int) # XI, IX, ZI, IZ + product = sfu.binary_symplectic_product(stabilizer, clifford) + assert product.shape == (2, 4) + # Z_i anticommutes with X_i only + assert np.array_equal(product, np.array([[1, 0, 0, 0], [0, 1, 0, 0]])) + assert sfu.is_symplectic_self_orthogonal(stabilizer) From 2de730ac51bbd7494e1b60596a6847fe39962168 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:40:43 -0400 Subject: [PATCH 056/227] [docs] Correct the g_function and row_sum docstrings in linalg.py - g_function said "each Y operator introduces -i phase factor", which describes an XZ reading of the (1, 1) bit pair. The code (and the Aaronson-Gottesman encoding it cites) treats (1, 1) as the Hermitian Y, e.g. X * Z = -iY gives k = -1. State the actual convention. - row_sum returns four arrays (x, z, r and i-phase), not three. --- graphiq/backends/stabilizer/functions/linalg.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/graphiq/backends/stabilizer/functions/linalg.py b/graphiq/backends/stabilizer/functions/linalg.py index 2e9db46c..19a8421f 100644 --- a/graphiq/backends/stabilizer/functions/linalg.py +++ b/graphiq/backends/stabilizer/functions/linalg.py @@ -227,7 +227,10 @@ def g_function(x1, z1, x2, z2): """ A helper function to use in rowsum function. Takes 4 bits (2 Pauli matrices in the binary representation) as input and returns the phase factor needed when the two Pauli matrices are multiplied: Pauli_1 * Pauli_2 - As a convention for the phase, each Y operator introduces -i phase factor. + The bit pair (x, z) = (1, 1) denotes the Hermitian Y, not XZ = -iY, matching the + encoding of arXiv:quant-ph/0406196v5 Sec. III ("11 means Y") in which a row carries + only a +1/-1 sign. The returned exponent k is fixed by that reading: for example + X * Z = -iY gives k = -1, whereas under an XZ reading the same product would give 0. Refer to section III of arXiv:quant-ph/0406196v5 @@ -271,8 +274,8 @@ def row_sum(x_matrix, z_matrix, r_vector, iphase_vector, row_to_add, target_row) :type row_to_add: int :param target_row: the stabilizer to be multiplied by the "to_add" stabilizer :type target_row: int - :return: updated stabilizer tableau - :rtype: numpy.ndarray, numpy.ndarray, numpy.ndarray + :return: the updated x_matrix, z_matrix, r_vector, and iphase_vector + :rtype: numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray """ n_qubits = np.shape(x_matrix)[1] # determining the phase factor From 2f8b9a0ac970723b5257f46876d2cff360727966 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:41:16 -0400 Subject: [PATCH 057/227] [docs] Fix copy-pasted comments and a wrong return doc in metrics.py The default-penalty comments of CircuitUnitaryCount ("the number emitters itself"), CircuitMeasureCount and GraphMetric ("the penalty for depth is the depth itself") were copied from other metrics. Metrics.evaluate was documented as returning nothing; it returns the weighted combination of the metric values. --- graphiq/metrics.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/graphiq/metrics.py b/graphiq/metrics.py index f88880ce..c5b094b0 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -381,7 +381,7 @@ def __init__(self, log_steps=1, n_unitary_penalty=None, *args, **kwargs): if n_unitary_penalty is None: self.n_unitary_penalty = ( lambda x: x - ) # by default, the number emitters itself + ) # by default, the number of unitaries itself else: self.n_unitary_penalty = n_unitary_penalty @@ -623,7 +623,7 @@ def __init__(self, log_steps=1, m_penalty=None, *args, **kwargs): if m_penalty is None: self.m_penalty = ( lambda x: x - ) # by default, the penalty for depth is the depth itself + ) # by default, the penalty for measurement count is the count itself else: self.m_penalty = m_penalty @@ -673,7 +673,7 @@ def __init__(self, graph: nx.Graph, log_steps=1, penalty=None, *args, **kwargs): if penalty is None: self.penalty = ( lambda x: x - ) # by default, the penalty for depth is the depth itself + ) # by default, the penalty for the graph metric is the metric value itself else: self.penalty = penalty @@ -805,8 +805,8 @@ def evaluate(self, state, circuit): :type state: QuantumState :param circuit: the circuit on which to evaluate the metrics :type circuit: CircuitBase (or a subclass of it) - :return: this function returns nothing - :rtype: None + :return: the weighted combination of the individual metric values + :rtype: float """ val = self.weighting_func(state, circuit) self.increment() From 4ffeb5b3c8430e4428473d11e12aa4902cef938c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:41:16 -0400 Subject: [PATCH 058/227] [docs] Correct the return docs of graph_to_stabilizer/_graph_to_stabilizer_pure Both claimed to return "two binary matrices representing the stabilizer generators": graph_to_stabilizer returns a list of (probability, StabilizerTableau) pairs and _graph_to_stabilizer_pure returns a StabilizerTableau. --- graphiq/backends/state_rep_conversion.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index acaec5d3..2e805686 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -178,7 +178,7 @@ def graph_to_stabilizer(input_graph): :type input_graph: networkX.Graph or numpy.ndarray :raise TypeError: if input_graph is not of the type of networkx.Graph or an adjacency matrix given by a numpy array or a list - :return: two binary matrices representing the stabilizer generators + :return: the stabilizer representation, as (probability, StabilizerTableau) pairs :rtype: list[(float, StabilizerTableau)] """ if isinstance(input_graph, list): @@ -199,8 +199,8 @@ def _graph_to_stabilizer_pure(input_graph): :param input_graph: the input graph to be converted to the stabilizer :type input_graph: networkX.Graph or numpy.ndarray :raise TypeError: if input_graph is not of the type of networkx.Graph or an adjacency matrix given by a numpy array - :return: two binary matrices representing the stabilizer generators - :rtype: numpy.ndarray, numpy.ndarray + :return: the stabilizer tableau + :rtype: StabilizerTableau """ if isinstance(input_graph, nx.Graph): adj_matrix = nx.to_numpy_array(input_graph) From 374887ca6b75c1c56ad3a8e2be6487c1f42a927b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:44:51 -0400 Subject: [PATCH 059/227] [fix] Fix controlled-gate noise being dropped when the legs disagree on position When the two legs of a controlled gate carried AdditionNoiseBase noise with different "After gate" flags, CompilerBase.compile applied one leg's noise and silently discarded the other's: both mixed-position branches rebound op.noise and then rebuilt the second half from op.noise[k] - by then the placeholder they had just written - instead of the saved original. It went unnoticed because the placeholder was the NoNoise class rather than an instance. Replace the four-way branch with a single pre-gate/post-gate split in which every leg lands in exactly one half (identical behaviour for the two agreeing-position cases), and restore op.noise in a finally block so a failed compile no longer leaves the caller's circuit holding a half-built noise list. Also reword the ValueError for a non-addition leg, and give the abstract _apply_additional_noise the `state` parameter both overrides already take. No noise model sets "After gate" to False, so results through the public API are unchanged. --- graphiq/backends/compiler_base.py | 86 ++++----- tests/test_noise/test_noise_placement.py | 224 +++++++++++++++++++++++ 2 files changed, 258 insertions(+), 52 deletions(-) create mode 100644 tests/test_noise/test_noise_placement.py diff --git a/graphiq/backends/compiler_base.py b/graphiq/backends/compiler_base.py index 48af6f03..911d0161 100644 --- a/graphiq/backends/compiler_base.py +++ b/graphiq/backends/compiler_base.py @@ -142,59 +142,41 @@ def compile(self, circuit: CircuitBase, initial_state=None): if isinstance(op.noise[0], nm.AdditionNoiseBase) and isinstance( op.noise[1], nm.AdditionNoiseBase ): - after_control = op.noise[0].noise_parameters["After gate"] - after_target = op.noise[1].noise_parameters["After gate"] - if after_control and after_target: - self.compile_one_gate( - state, - op, - circuit.n_quantum, - q_index, - classical_registers, - ) - self._apply_additional_noise( - state, op, circuit.n_quantum, q_index + # Each leg of a controlled pair carries its own "After + # gate" flag and the two may disagree, so split the + # noise list into a pre-gate and a post-gate half and + # apply each half on its own side. Every leg appears in + # exactly one half; the other half holds NoNoise for it. + # Reading the flag is safe only under the isinstance + # guard above, which is what guarantees both legs carry + # one. + noise_copy = op.noise + pre_noise = [ + ( + nm.NoNoise() + if leg.noise_parameters["After gate"] + else leg ) - elif (not after_control) and (not after_target): - self._apply_additional_noise( - state, op, circuit.n_quantum, q_index - ) - self.compile_one_gate( - state, - op, - circuit.n_quantum, - q_index, - classical_registers, + for leg in noise_copy + ] + post_noise = [ + ( + leg + if leg.noise_parameters["After gate"] + else nm.NoNoise() ) - elif after_control and (not after_target): - noise_copy = op.noise - tmp_noise = [nm.NoNoise, op.noise[1]] - op.noise = tmp_noise - self._apply_additional_noise( - state, op, circuit.n_quantum, q_index - ) - - self.compile_one_gate( - state, - op, - circuit.n_quantum, - q_index, - classical_registers, - ) - tmp_noise = [op.noise[0], nm.NoNoise] - - op.noise = tmp_noise + for leg in noise_copy + ] + # `op` belongs to the caller's circuit, so the rebinding + # below has to be undone on every exit path -- a + # half-built list left behind here fails the isinstance + # guard the next time this circuit is compiled. + try: + op.noise = pre_noise self._apply_additional_noise( state, op, circuit.n_quantum, q_index ) op.noise = noise_copy - else: - noise_copy = op.noise - tmp_noise = [op.noise[0], nm.NoNoise] - op.noise = tmp_noise - self._apply_additional_noise( - state, op, circuit.n_quantum, q_index - ) self.compile_one_gate( state, op, @@ -202,16 +184,16 @@ def compile(self, circuit: CircuitBase, initial_state=None): q_index, classical_registers, ) - - tmp_noise = [nm.NoNoise, op.noise[1]] - op.noise = tmp_noise + op.noise = post_noise self._apply_additional_noise( state, op, circuit.n_quantum, q_index ) + finally: op.noise = noise_copy else: raise ValueError( - "We currently do not support different noise positions for one controlled gate." + "Both legs of a controlled gate must carry an AdditionNoiseBase; " + f"got {type(op.noise[0]).__name__} and {type(op.noise[1]).__name__}." ) else: @@ -334,5 +316,5 @@ def compile_one_noisy_gate( raise NotImplementedError("Please select a valid compiler") @abstractmethod - def _apply_additional_noise(self, op, n_quantum, q_index): + def _apply_additional_noise(self, state, op, n_quantum, q_index): raise NotImplementedError("Please select a valid compiler") diff --git a/tests/test_noise/test_noise_placement.py b/tests/test_noise/test_noise_placement.py new file mode 100644 index 00000000..f9ed4eb7 --- /dev/null +++ b/tests/test_noise/test_noise_placement.py @@ -0,0 +1,224 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Placement of `AdditionNoiseBase` noise around a controlled gate. + +`CompilerBase.compile` decides, per leg of a controlled pair, whether that +leg's noise is applied before or after the gate, from its +`noise_parameters["After gate"]` flag. The two legs may disagree, and each +leg must land on its own side regardless of what the other one does. + +These tests drive `compile()` end to end and compare against the same +circuit written out with explicit Pauli gates in the expected positions. +The probe noise is `PauliError`, which is a deterministic Clifford on every +backend (a coherent gate error, not a stochastic channel), so each expected +state is exact rather than statistical. +""" + +import numpy as np +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.circuit.circuit_dag import CircuitDAG + + +def _before_gate(noise): + """Flip an AdditionNoiseBase to pre-gate placement. + + No noise model exposes this through its constructor -- each builds its own + `noise_parameters` dict and `AdditionNoiseBase.__init__` then stamps + `"After gate": True` onto it -- so a mixed-position circuit can only be + built by mutating the flag afterwards. + """ + noise.noise_parameters["After gate"] = False + return noise + + +def _noisy_cnot_circuit(control_noise, target_noise): + """H(0), then a CNOT(0, 1) carrying one noise model per leg. + + Two things about the probe are load-bearing, and both were arrived at by + watching a weaker one pass against the broken compiler: + + The Hadamard leaves qubit 0 in an X eigenstate, so the Pauli errors do not + act as a global phase on a Z eigenstate -- which a density-matrix comparison + could not see. + + The callers pair an X error on the *control* with a Z error on the + *target*, because those are the two that anticommute across a CNOT. An X on + the target, or a Z on the control, commutes with the gate, so moving that + leg from one side to the other would leave the final state unchanged and + the test could not tell a correct placement from a wrong one. + """ + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e", noise=nm.NoNoise())) + cnot = ops.CNOT( + control=0, + control_type="e", + target=1, + target_type="e", + noise=[control_noise, target_noise], + ) + # `ControlledPairOperationBase.__init__` turns a *scalar* noise argument + # into `2 * [noise]`, i.e. the same object in both slots -- mutating + # "After gate" on that would flip both legs at once and silently test a + # non-mixed case. Passing a list of two distinct instances avoids it. + assert cnot.noise[0] is not cnot.noise[1] + circuit.add(cnot) + return circuit, cnot + + +def _explicit_circuit(pre_gates, post_gates): + """The same H(0) and CNOT(0, 1), with explicit noiseless Pauli gates. + + `pre_gates`/`post_gates` are (operation class, register) pairs placed + before and after the CNOT. + """ + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e", noise=nm.NoNoise())) + for gate, register in pre_gates: + circuit.add(gate(register=register, reg_type="e", noise=nm.NoNoise())) + circuit.add( + ops.CNOT( + control=0, + control_type="e", + target=1, + target_type="e", + noise=[nm.NoNoise(), nm.NoNoise()], + ) + ) + for gate, register in post_gates: + circuit.add(gate(register=register, reg_type="e", noise=nm.NoNoise())) + return circuit + + +def _compile_dm(circuit): + compiler = DensityMatrixCompiler() + compiler.noise_simulation = True + return compiler.compile(circuit).rep_data.data + + + +# -------------------------------------------------------------------------- +# The two positions that agree. No coverage existed for either before this +# file, so these are written to pass against the unmodified compiler and +# then to pin that behaviour across any restructuring of the placement code. +# -------------------------------------------------------------------------- + + +def test_both_legs_after_gate(): + circuit, _ = _noisy_cnot_circuit(nm.PauliError("X"), nm.PauliError("Z")) + expected = _explicit_circuit([], [(ops.SigmaX, 0), (ops.SigmaZ, 1)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +def test_neither_leg_after_gate(): + circuit, _ = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), _before_gate(nm.PauliError("Z")) + ) + expected = _explicit_circuit([(ops.SigmaX, 0), (ops.SigmaZ, 1)], []) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +# -------------------------------------------------------------------------- +# The two positions that disagree. +# -------------------------------------------------------------------------- + + +def test_control_after_target_before(): + circuit, _ = _noisy_cnot_circuit( + nm.PauliError("X"), _before_gate(nm.PauliError("Z")) + ) + expected = _explicit_circuit([(ops.SigmaZ, 1)], [(ops.SigmaX, 0)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +def test_control_before_target_after(): + circuit, _ = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), nm.PauliError("Z") + ) + expected = _explicit_circuit([(ops.SigmaX, 0)], [(ops.SigmaZ, 1)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +def test_mixed_position_differs_from_both_after(): + """A mixed placement must not coincide with the all-after placement. + + Guards the two tests above against passing for the wrong reason: if the + chosen Paulis commuted through the CNOT, moving one leg across the gate + would be unobservable and a dropped leg could look correct. + """ + mixed, _ = _noisy_cnot_circuit(nm.PauliError("X"), _before_gate(nm.PauliError("Z"))) + both_after, _ = _noisy_cnot_circuit(nm.PauliError("X"), nm.PauliError("Z")) + assert not np.allclose(_compile_dm(mixed), _compile_dm(both_after)) + + + +# -------------------------------------------------------------------------- +# `compile()` rebinds `op.noise` while placing the legs. It must put the +# caller's own list back, on every exit path -- the circuit belongs to the +# caller and is routinely compiled more than once. +# -------------------------------------------------------------------------- + + +def test_op_noise_restored_after_compile(): + circuit, cnot = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), nm.PauliError("Z") + ) + original = cnot.noise + original_legs = list(cnot.noise) + _compile_dm(circuit) + assert cnot.noise is original + assert list(cnot.noise) == original_legs + + +def test_op_noise_restored_when_gate_compilation_raises(monkeypatch): + """A failed compile must not leave the circuit poisoned. + + Without an unwind, `op.noise` keeps a half-built list holding the + `nm.NoNoise` *class* in one slot. That slot then fails the + `isinstance(..., AdditionNoiseBase)` guard on the next compile, so a + circuit with nothing wrong with it reports a defect it does not have. + """ + circuit, cnot = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), nm.PauliError("Z") + ) + original = cnot.noise + original_legs = list(cnot.noise) + + def _boom(self, state, op, n_quantum, q_index, classical_registers): + raise RuntimeError("gate compilation failed") + + monkeypatch.setattr(DensityMatrixCompiler, "compile_one_gate", _boom) + with pytest.raises(RuntimeError, match="gate compilation failed"): + _compile_dm(circuit) + + assert cnot.noise is original + assert list(cnot.noise) == original_legs + + # The real symptom: the next compile of this circuit must still work. + monkeypatch.undo() + expected = _explicit_circuit([(ops.SigmaX, 0)], [(ops.SigmaZ, 1)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +def test_non_addition_noise_on_a_leg_is_rejected(): + """Both legs must be `AdditionNoiseBase`; differing *positions* are fine.""" + replacement = nm.OneQubitGateReplacement(np.eye(2)) + circuit, _ = _noisy_cnot_circuit(replacement, nm.PauliError("X")) + with pytest.raises(ValueError, match="AdditionNoiseBase"): + _compile_dm(circuit) From 234167433908152d9979be1ebf2be97acabb94e7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:45:37 -0400 Subject: [PATCH 060/227] [fix] Raise on an unrecognised measurement_determinism in z_measurement_gate For a random Z-measurement outcome, z_measurement_gate forced outcome 0 for any measurement_determinism other than "probabilistic" or 1, so a typo (e.g. "probabilstic") silently pinned every outcome to 0. Raise ValueError instead, matching DensityMatrix.apply_measurement and the compiler's measurement_determinism setter. --- graphiq/backends/stabilizer/functions/clifford.py | 7 ++++++- .../stabilizer/test_stabilizer_state.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index c0f161d9..8989be8c 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -45,6 +45,7 @@ def z_measurement_gate( :param measurement_determinism: if the outcome is probabilistic from the simulation, we have the option to select a specific outcome :type measurement_determinism: str or int + :raises ValueError: if the outcome is random and measurement_determinism is not "probabilistic", 0 or 1 :return: the resulting state after gate action, the measurement outcome, whether the measurement outcome is probabilistic (zero means deterministic) :rtype: CliffordTableau, int, int @@ -92,8 +93,12 @@ def z_measurement_gate( outcome = np.random.randint(0, 2) elif measurement_determinism == 1: outcome = 1 - else: + elif measurement_determinism == 0: outcome = 0 + else: + raise ValueError( + f'measurement_determinism parameter must be "probabilistic", 0, or 1' + ) tableau.phase[x_p] = outcome tableau.table = table diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_state.py b/tests/test_state_representation/stabilizer/test_stabilizer_state.py index 0dc99683..54224708 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_state.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_state.py @@ -90,3 +90,12 @@ def test_pure_and_mixed_stabilizers_compare_unequal_without_raising(): mixed = MixedStabilizer([(1.0, Stabilizer(2).tableau)]) assert (pure == mixed) is False assert (mixed == pure) is False + + +@pytest.mark.parametrize("measurement_determinism", ["probabilstic", 2, None]) +def test_measurement_rejects_an_unrecognised_determinism(measurement_determinism): + # |+> has a random Z outcome, so the determinism setting is consulted + state = Stabilizer(1) + state.apply_hadamard(0) + with pytest.raises(ValueError, match="measurement_determinism"): + state.apply_measurement(0, measurement_determinism=measurement_determinism) From baa7dfbb9caaadf69b97bc6458a4a7ae5755edf6 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:46:44 -0400 Subject: [PATCH 061/227] [fix] Apply PauliError to a MixedStabilizer instead of raising TypeError PauliError.apply's stabilizer arm matched only Stabilizer, and MixedStabilizer is a sibling class rather than a subclass, so on the mixed state that a noisy (non-Monte-Carlo) StabilizerCompiler run produces it fell through to "Backend type is not supported.". Apply the Pauli to every branch of the mixture. Previously working inputs are unaffected. --- graphiq/noise/noise_models.py | 16 ++++++++++++++++ tests/test_noise/test_noise_models.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 4966e54a..91b5b8f4 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -381,6 +381,22 @@ def apply(self, state, n_quantum, reg_list): raise ValueError("Wrong description of a Pauli matrix.") state_rep.apply_circuit(gate_list) + elif isinstance(state_rep, MixedStabilizer): + # MixedStabilizer is a sibling of Stabilizer, not a subclass, and it + # is what a noisy (non-Monte-Carlo) stabilizer compile produces. A + # Pauli is a unitary, so it is applied to every branch; the + # MixedStabilizer gate methods do exactly that. + if pauli_error == "X": + state_rep.apply_sigmax(reg_list[0]) + elif pauli_error == "Y": + state_rep.apply_sigmay(reg_list[0]) + elif pauli_error == "Z": + state_rep.apply_sigmaz(reg_list[0]) + elif pauli_error == "I": + pass + else: + raise ValueError("Wrong description of a Pauli matrix.") + elif isinstance(state_rep, Graph): # TODO: Implement this for Graph backend raise NotImplementedError( diff --git a/tests/test_noise/test_noise_models.py b/tests/test_noise/test_noise_models.py index 12b8f10b..6373f43f 100644 --- a/tests/test_noise/test_noise_models.py +++ b/tests/test_noise/test_noise_models.py @@ -287,3 +287,19 @@ def test_reset_error_default_noise_parameters_are_not_shared_between_instances() b = nm.ResetError() assert a.noise_parameters is not b.noise_parameters + + +@pytest.mark.parametrize("pauli", ["X", "Y", "Z", "I"]) +def test_pauli_error_on_a_mixed_stabilizer(pauli): + # A noisy stabilizer compile works on a MixedStabilizer; the Pauli must be + # applied to every branch, matching the pure-Stabilizer result per branch. + n_quantum = 2 + mixed = _two_branch_mixture(n_quantum) + pure = _state_initialization_stabilizer(n_quantum) + + nm.PauliError(pauli).apply(mixed, n_quantum, [0]) + nm.PauliError(pauli).apply(pure, n_quantum, [0]) + + assert [p_i for p_i, _ in mixed.rep_data.mixture] == [0.25, 0.75] + for _, tableau in mixed.rep_data.mixture: + assert tableau == pure.rep_data.tableau From 31a6e89e440e1e6eac4c7c3962ddcfeb4b39771a Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:47:25 -0400 Subject: [PATCH 062/227] [fix] Honour allow_relabel and allow_lc in AlternateTargetSolver AlternateTargetSolverSetting stored allow_relabel and allow_lc but AlternateTargetSolver.solve never read them, so isomorphic relabellings and local-complementation orbits were always searched. For example, the data collection UI's "no relabelling" choice (allow_relabel = False, n_iso_graphs = 0) still called iso_finder with n_iso = 0, which raised ValueError. When allow_relabel is False, search only the given labelling; when allow_lc is False, use each isomorphic graph as its own only candidate (lc_method is then not consulted). Both default to True, so default behaviour is unchanged. --- graphiq/solvers/alternate_target_solver.py | 29 +++++++----- .../test_alternate_target_solver.py | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 4a3001a7..957e1a2f 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -164,16 +164,20 @@ def solve(self): n_lc = setting.n_lc_graphs adj_matrix = nx.to_numpy_array(self.target_graph) - iso_adjs = iso_finder( - adj_matrix, - n_iso, - rel_inc_thresh=setting.rel_inc_thresh, - allow_exhaustive=setting.allow_exhaustive, - sort_emit=setting.sort_emitter, - label_map=setting.label_map, - thresh=setting.iso_thresh, - seed=self.seed, - ) + if setting.allow_relabel: + iso_adjs = iso_finder( + adj_matrix, + n_iso, + rel_inc_thresh=setting.rel_inc_thresh, + allow_exhaustive=setting.allow_exhaustive, + sort_emit=setting.sort_emitter, + label_map=setting.label_map, + thresh=setting.iso_thresh, + seed=self.seed, + ) + else: + # only the labelling given + iso_adjs = [adj_matrix] results_list = [] mc_list = [] # repeater graph state check @@ -184,7 +188,10 @@ def solve(self): # list of tuples [(circuit, dict={'g': graph used to find circuit, 'map': relabel map with target})] iso_graphs = [nx.from_numpy_array(adj) for adj in iso_adjs] for iso_graph in iso_graphs: - if setting.lc_method == "rgs": + if not setting.allow_lc: + # each isomorphic graph is its own only candidate + lc_graphs = [iso_graph] + elif setting.lc_method == "rgs": lc_graphs = rgs_orbit_finder(iso_graph)[:n_lc] elif setting.lc_method == "linear": lc_graphs = linear_partial_orbit(iso_graph)[:n_lc] diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index 23ef40f0..3cbf9d91 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -128,3 +128,47 @@ def test_default_setting_names_a_dispatchable_lc_method(): ) solver.solve() assert solver.result is not None + + +def test_allow_relabel_false_searches_only_the_given_labelling(): + setting = AlternateTargetSolverSetting(n_iso_graphs=3, n_lc_graphs=2) + setting.allow_relabel = False + setting.lc_method = None + solver = AlternateTargetSolver( + target=nx.path_graph(4), solver_setting=setting, seed=1 + ) + results = solver.solve() + n = solver.target_graph.number_of_nodes() + for _, properties in results: + # get_relabel_map also carries a -1 "self" key, so identity is over range(n) + assert {i: properties["map"][i] for i in range(n)} == {i: i for i in range(n)} + + +def test_allow_lc_false_keeps_the_isomorphic_graph_unchanged(): + setting = AlternateTargetSolverSetting(n_iso_graphs=1, n_lc_graphs=4) + setting.allow_relabel = False + setting.allow_lc = False + solver = AlternateTargetSolver( + target=nx.path_graph(4), solver_setting=setting, seed=1 + ) + results = solver.solve() + assert len(results) == 1 + # the candidate is rebuilt from an adjacency matrix, so it carries weight + # attributes the target does not; compare structure, not edge data + candidate = results[0][1]["g"] + assert sorted(candidate.nodes()) == sorted(solver.target_graph.nodes()) + assert sorted(map(sorted, candidate.edges())) == sorted( + map(sorted, solver.target_graph.edges()) + ) + + +def test_allow_lc_false_bypasses_lc_method_validation(): + setting = AlternateTargetSolverSetting(n_iso_graphs=1, n_lc_graphs=1) + setting.allow_relabel = False + setting.allow_lc = False + setting.lc_method = "no such search" + solver = AlternateTargetSolver( + target=nx.path_graph(4), solver_setting=setting, seed=1 + ) + solver.solve() + assert solver.result is not None From 4340ebd0b4f3d7fcde3a5d46a8cb70d1eb62e9eb Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:50:05 -0400 Subject: [PATCH 063/227] [fix] Avoid NaN in DensityMatrix forced outcome-0 measurement With measurement_determinism=0, apply_measurement chose outcome 0 whenever p(1) < 1 and then divided by p(0). When p(1) is 1 up to rounding and p(0) is 0 (or was clamped to 0 from a tiny negative value), this divided by zero and left the state full of NaN. Only pick outcome 0 when p(0) > 0, matching the documented behaviour ("default to 0 unless p(0) = 0"). --- graphiq/backends/density_matrix/state.py | 5 ++++- .../density_matrix/test_density_matrix_state.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index 5e61100a..9331372b 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -179,7 +179,10 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") outcome = 0 elif measurement_determinism == 0: - if probs[1] < 1: + # probs[0] is what outcome 0 divides by below; testing only + # probs[1] < 1 let a rounding-error probs[1] = 1 - 1e-16 with + # probs[0] = 0 through, producing a NaN matrix + if probs[1] < 1 and probs[0] > 0: outcome = 0 else: outcome = 1 diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_state.py b/tests/test_state_representation/density_matrix/test_density_matrix_state.py index 64c104a9..e1c96bc9 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_state.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_state.py @@ -58,3 +58,16 @@ def test_density_matrix_eq_different_sizes(): # a (1, 1) matrix must not broadcast into equality with a (2, 2) one assert (DensityMatrix(np.ones((1, 1))) == DensityMatrix(np.ones((2, 2)))) is False assert (DensityMatrix(np.eye(2) / 2) == DensityMatrix(np.eye(4) / 4)) is False + + +def test_forced_outcome_0_with_zero_probability_does_not_divide_by_zero(): + # p(1) is 1 up to rounding and p(0) is exactly 0, so outcome 0 is impossible + state = DensityMatrix( + np.diag([0.0, 1.0 - 1e-16]).astype(complex), normalized=False + ) + projectors = [np.diag([1.0, 0.0]), np.diag([0.0, 1.0])] + + outcome = state.apply_measurement(projectors, measurement_determinism=0) + + assert outcome == 1 + assert not np.any(np.isnan(state.data)) From 69a8d14ce346b031e503e86b920e8e8caa5e2f97 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:50:31 -0400 Subject: [PATCH 064/227] [test] Pin noise-model failure modes on unsupported backends Add a parametrized test over six noise models and three state representations. A Graph representation raises NotImplementedError in five models and TypeError in OneQubitGateReplacement; a MixedGraph and an opaque object both fall through to each model's final TypeError. --- .../test_noise_models_unsupported_backends.py | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/test_noise/test_noise_models_unsupported_backends.py diff --git a/tests/test_noise/test_noise_models_unsupported_backends.py b/tests/test_noise/test_noise_models_unsupported_backends.py new file mode 100644 index 00000000..c1069ce2 --- /dev/null +++ b/tests/test_noise/test_noise_models_unsupported_backends.py @@ -0,0 +1,146 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import networkx as nx +import numpy as np +import pytest + +import graphiq.noise.noise_models as nm +from graphiq.backends.graph.state import MixedGraph +from graphiq.state import QuantumState + + +def _state_initialization_graph(): + """ + A graph-backed QuantumState on a 3-node path graph. + + :return: the state + :rtype: QuantumState + """ + return QuantumState(nx.Graph([(1, 2), (2, 3)]), rep_type="g") + + +def _state_with_mixed_graph_rep(): + """ + A QuantumState whose backing representation is a MixedGraph. + + MixedGraph cannot reach QuantumState.rep_data through either public entry + point. The constructor's validate_data has no arm for it, and its rejection + path crashes with AttributeError while formatting the message. The + rep_data setter re-initialises the representation and Graph(data) raises + TypeError on a MixedGraph input. Assigning _rep_data directly is the only + way to construct this state. + + :return: the state + :rtype: QuantumState + """ + state = _state_initialization_graph() + state._rep_data = MixedGraph(state.rep_data) + return state + + +def _state_with_opaque_rep(): + """ + A QuantumState whose backing representation is a plain object(), which no + noise model's isinstance dispatch recognizes. QuantumState.validate_data + rejects object() the same way it rejects MixedGraph, so _rep_data is + assigned directly here too. + + :return: the state + :rtype: QuantumState + """ + state = _state_initialization_graph() + state._rep_data = object() + return state + + +# Every row shares the same simplest-valid-argument construction per model: +# AmplitudeDampingNoise and DepolarizingNoise take a probability, +# GeneralKrausError takes a list of Kraus operators, PauliError takes a Pauli +# label, ResetError takes no required argument, and OneQubitGateReplacement +# takes a 2x2 unitary. +# The OneQubitGateReplacement row carries a message match because its Graph +# arm raises the same TypeError as the fall-through else arm; without the +# match, deleting the Graph arm would leave the row green. +_GRAPH_ROWS = [ + (nm.AmplitudeDampingNoise(0.1), NotImplementedError, None), + (nm.ResetError(), NotImplementedError, None), + (nm.GeneralKrausError([np.eye(2)]), NotImplementedError, None), + (nm.PauliError("X"), NotImplementedError, None), + (nm.DepolarizingNoise(0.1), NotImplementedError, None), + ( + nm.OneQubitGateReplacement(np.eye(2)), + TypeError, + "does not support Graph backend", + ), +] + +# Every model falls through to the same generic "Backend type ... is not +# supported" TypeError once state.rep_data is neither a recognized backend +# class nor Graph, regardless of which of the six models is asked. +_FALLTHROUGH_MODELS = [ + nm.AmplitudeDampingNoise(0.1), + nm.ResetError(), + nm.GeneralKrausError([np.eye(2)]), + nm.PauliError("X"), + nm.DepolarizingNoise(0.1), + nm.OneQubitGateReplacement(np.eye(2)), +] + + +@pytest.mark.parametrize( + "noise_model, state, expected_exception, match", + [ + (noise_model, _state_initialization_graph(), expected_exception, match) + for noise_model, expected_exception, match in _GRAPH_ROWS + ] + + [ + (noise_model, _state_with_mixed_graph_rep(), TypeError, "not supported") + for noise_model in _FALLTHROUGH_MODELS + ] + + [ + (noise_model, _state_with_opaque_rep(), TypeError, "not supported") + for noise_model in _FALLTHROUGH_MODELS + ], +) +def test_apply_on_unsupported_backend_raises( + noise_model, state, expected_exception, match +): + """ + Every noise model's apply() dispatches on the type of state.rep_data + through an isinstance chain. A Graph representation is named explicitly in + each model but rejected as not yet implemented (NotImplementedError), + except OneQubitGateReplacement, whose Graph arm raises TypeError instead + (an arbitrary one-qubit unitary is not expressible in the Clifford + vocabulary the stabilizer backend understands, but the Graph branch + reports it as an unsupported backend rather than an unimplemented one). A + MixedGraph, or a plain object() that matches nothing, falls through every + model's isinstance chain to its final else branch, which always raises + TypeError. + + The NotImplementedError Graph rows assert no message: theirs read "not + implemented for graph representation", which does not contain "not + supported", and the exception type alone already separates them from the + fall-through. The OneQubitGateReplacement Graph row matches on its own + message because its type does not. The MixedGraph and object() rows assert on "not supported" + since every model's fall-through branch uses that phrase, one of them with + the backend's type interpolated into it. + + n_quantum is fixed at 3, matching the 3-node graph every row's state + starts from: no model reads n_quantum before raising, since the dispatch + is on the type of state.rep_data, and state.n_qubits is unavailable on + the object() rows below. + """ + with pytest.raises(expected_exception, match=match): + noise_model.apply(state, 3, [0]) From c2cb1a005575fdbc4e47f0a8c05be664b2957c66 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:52:09 -0400 Subject: [PATCH 065/227] [fix] Make StabilizerTableau.validate work instead of raising StabilizerTableau.validate called sfu.is_stabilizer, which did not exist, so it always raised AttributeError. Add is_stabilizer: a table is a valid set of stabilizer generators when its rows pairwise commute and are linearly independent over GF(2). The rank is computed over GF(2), since a real-valued rank accepts dependent sets such as ZZI, IZZ, ZIZ. --- .../backends/stabilizer/functions/utils.py | 34 ++++++++++++ graphiq/backends/stabilizer/tableau.py | 5 +- .../stabilizer/test_stabilizer_validation.py | 53 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_stabilizer_validation.py diff --git a/graphiq/backends/stabilizer/functions/utils.py b/graphiq/backends/stabilizer/functions/utils.py index 2c970af6..a5f47535 100644 --- a/graphiq/backends/stabilizer/functions/utils.py +++ b/graphiq/backends/stabilizer/functions/utils.py @@ -131,6 +131,40 @@ def is_symplectic_self_orthogonal(input_matrix): ) +def is_stabilizer(table): + """ + Check if a given n x 2n binary symplectic table is a valid set of stabilizer generators, + i.e. its rows pairwise commute and are linearly independent over GF(2). + + :param table: an n x 2n binary symplectic table + :type table: numpy.ndarray + :return: True if the rows pairwise commute and are linearly independent over GF(2); False + otherwise + :rtype: bool + """ + n_rows, n_columns = table.shape + assert n_columns == 2 * n_rows + if not is_symplectic_self_orthogonal(table): + return False + # GF(2) rank by Gaussian elimination; a real-valued rank would not do, e.g. + # ZZI, IZZ, ZIZ has GF(2) rank 2 but real rank 3 + reduced = np.array(table, dtype=int) % 2 + rank = 0 + for column in range(n_columns): + pivots = np.nonzero(reduced[rank:, column])[0] + if len(pivots) == 0: + continue + pivot = rank + pivots[0] + reduced[[rank, pivot]] = reduced[[pivot, rank]] + for row in range(n_rows): + if row != rank and reduced[row, column]: + reduced[row] = (reduced[row] + reduced[rank]) % 2 + rank += 1 + if rank == n_rows: + break + return rank == n_rows + + def binary_symplectic_product(matrix1, matrix2): """ Compute the binary symplectic product of two matrices matrix1 (:math:`M_1`) and matrix2 (:math:`M_2`) diff --git a/graphiq/backends/stabilizer/tableau.py b/graphiq/backends/stabilizer/tableau.py index c8920b3b..e84a0002 100644 --- a/graphiq/backends/stabilizer/tableau.py +++ b/graphiq/backends/stabilizer/tableau.py @@ -231,9 +231,10 @@ def from_labels(self, labels): def validate(self): """ - Validate that the tableau is a valid tableau + Validate that the tableau is a valid set of stabilizer generators, i.e. its rows + pairwise commute and are linearly independent over GF(2) - :return: True if the tableau is symplectic; False if it is not + :return: True if the tableau is valid; False if it is not :rtype: bool """ return sfu.is_stabilizer(self._table) diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_validation.py b/tests/test_state_representation/stabilizer/test_stabilizer_validation.py new file mode 100644 index 00000000..a2f84c56 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_stabilizer_validation.py @@ -0,0 +1,53 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests for sfu.is_stabilizer and StabilizerTableau.validate. +""" + +import numpy as np +import pytest + +import graphiq.backends.stabilizer.functions.utils as sfu +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +@pytest.mark.parametrize( + "labels,expected", + [ + (["ZII", "IZI", "IIZ"], True), + (["XXX", "ZZI", "IZZ"], True), # GHZ + (["ZII", "ZII", "IIZ"], False), # duplicate row -> not independent + (["XII", "ZII", "IIZ"], False), # X and Z on qubit 0 anticommute + (["ZZI", "IZZ", "ZIZ"], False), # commuting, GF(2) rank 2, real rank 3 + (["III", "IZI", "IIZ"], False), # zero row + ], +) +def test_is_stabilizer(labels, expected): + x_matrix, z_matrix = sfu.string_to_symplectic(labels) + table = np.block([x_matrix, z_matrix]).astype(int) + assert sfu.is_stabilizer(table) is expected + + +def test_stabilizer_tableau_validate(): + tableau = StabilizerTableau(3) + tableau.from_labels(["ZII", "IZI", "IIZ"]) + assert tableau.validate() is True + + +def test_stabilizer_tableau_validate_false_for_dependent_rows(): + tableau = StabilizerTableau(3) + tableau.from_labels(["ZII", "ZII", "IIZ"]) + assert tableau.validate() is False + From 9a351ced970ce7c74394612b66fb2c31395857c0 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:53:37 -0400 Subject: [PATCH 066/227] [docs] Correct the GraphMetric and TraceDistance docstrings - GraphMetric.__init__ documented an m_penalty parameter (copied from CircuitMeasureCount) that does not exist; document the actual penalty parameter, the undocumented graph parameter and the AssertionError raised for a non-nx.Graph input. - TraceDistance's target was described as the state "against which we compute fidelity"; it is the trace-distance reference and must be a density matrix. evaluate() now documents the ValueError it raises for any other target representation. - Fix a doubled backslash (\\frac) in the raw TraceDistance.evaluate docstring that rendered literally, and separate the formula from the field list. --- graphiq/metrics.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/graphiq/metrics.py b/graphiq/metrics.py index c5b094b0..32636072 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -160,7 +160,8 @@ def __init__(self, target, log_steps=1, *args, **kwargs): Creates a TraceDistance Metric object, which computes the trace distance between the current state and the target state. - :param target: the ideal state against which we compute fidelity + :param target: the ideal state against which we compute the trace distance; it must be in the + density matrix representation :type target: QuantumState :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int @@ -178,13 +179,15 @@ def evaluate(self, state, circuit): The trace distance is computed between two density matrices $\rho$ and $\sigma$ as: $$ T(\rho, \sigma) = \frac{1}{2} \text{Tr}\left( \sqrt{ (\rho - \sigma)^2 } \right) - = \\frac{1}{2} \sum_i | \lambda_i | + = \frac{1}{2} \sum_i | \lambda_i | $$ + :param state: the state to evaluate :type state: QuantumState :param circuit: circuit which generated state Not used for the trace distance evaluation, but argument is provided for API consistency :type circuit: CircuitBase (or subclass of it) + :raises ValueError: if the target state is not in the density matrix representation :return: the trace distance :rtype: float """ @@ -658,11 +661,16 @@ class GraphMetric(MetricBase): def __init__(self, graph: nx.Graph, log_steps=1, penalty=None, *args, **kwargs): """ + Create a GraphMetric object + :param graph: the graph on which the metric is calculated + :type graph: nx.Graph :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :param m_penalty: a function which calculates a "cost"/penalty as a function of number of measurement - :type m_penalty: function + :param penalty: a function which calculates a "cost"/penalty as a function of the graph metric value; + if None, the penalty is the metric value itself + :type penalty: function or None + :raises AssertionError: if graph is not an nx.Graph :return: the function returns nothing :rtype: None """ From 533bd71c974b664aa09767f0fd657a804a9e4b09 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:53:37 -0400 Subject: [PATCH 067/227] [docs] Correct stale representation docs in state.py - The module docstring still listed the graph representation as "intended in the near future"; it is supported (as a state representation, without a compiler). - QuantumState.__init__ said stabilizer representations take a StabilizerTableau; the class accepted is CliffordTableau. - rep_data's rtype omitted MixedStabilizer and MixedGraph, which it can hold. - Add the missing :return:/:rtype: to _initialize_dm, _initialize_graph, _initialize_stabilizer and _initialize_representation, and widen _initialize_representation's data type to the stabilizer inputs it forwards. --- graphiq/state.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/graphiq/state.py b/graphiq/state.py index b1fe9fce..fb79dc78 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -18,9 +18,7 @@ State representations that we support are: 1. Density matrix 2. Stabilizer - -State representations that we intend to support in the near future are: - 1. Graph representation (only state representation, but no compiler at the moment) + 3. Graph (state representation only; no compiler at the moment) """ @@ -54,7 +52,7 @@ def __init__(self, data, rep_type=None, mixed=False): :param data: valid data input for "rep_type". Density matrices representations support np.ndarray or int inputs - Stabilizer representations take int or StabilizerTableau + Stabilizer representations take int or CliffordTableau Graph representations take networkx.Graph :type data: list OR numpy.ndarray OR Graph OR nx.Graph or CliffordTableau :param rep_type: selected representation to initialize; @@ -107,7 +105,7 @@ def rep_data(self): Representation data :return: representation data - :rtype: DensityMatrix or Stabilizer or Graph + :rtype: DensityMatrix or Stabilizer or MixedStabilizer or Graph or MixedGraph """ return self._rep_data @@ -183,6 +181,8 @@ def _initialize_dm(self, data): :param data: either a graph or ndarray matrix :type data: Graph OR nx.Graph OR numpy.ndarray :raises AssertionError: if the density matrix being initialized does not have self.n_qubits + :return: the density matrix representation + :rtype: DensityMatrix """ if isinstance(data, Graph) or isinstance(data, nx.Graph): dm = DensityMatrix.from_graph(data) @@ -199,6 +199,8 @@ def _initialize_graph(self, data): :param data: data to construct the Graph representation :type data: networkX.Graph :raises AssertionError: if the graph being initialized does not have self.n_qubits + :return: the graph representation + :rtype: Graph """ graph = Graph(data) @@ -213,6 +215,9 @@ def _initialize_stabilizer(self, data): :param data: data to construct the stabilizer state representation :type data: Stabilizer or int or CliffordTableau or MixedStabilizer or list[(float, CliffordTableau)] + :raises AssertionError: if the stabilizer being initialized does not have self.n_qubits + :return: a MixedStabilizer if self.mixed is True, a Stabilizer otherwise + :rtype: Stabilizer or MixedStabilizer """ if not self.mixed: if isinstance(data, Stabilizer): @@ -238,8 +243,10 @@ def _initialize_representation(self, rep_type, data): :param rep_type: representation type to initialize :type rep_type: str :param data: data with which the rep_type should be initialized - :type data: int OR Graph OR nx.Graph OR numpy.ndarray + :type data: int OR Graph OR nx.Graph OR numpy.ndarray OR CliffordTableau OR Stabilizer OR MixedStabilizer :raises ValueError: if rep_type is invalid + :return: the initialized representation + :rtype: DensityMatrix or Graph or Stabilizer or MixedStabilizer """ if rep_type in ("dm", "density matrix"): if self.n_qubits > DENSITY_MATRIX_QUBIT_THRESH: From 5a0fd8c84d6b9be66b90c4d4948394aee59f5157 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:53:37 -0400 Subject: [PATCH 068/227] [docs] Fix wrong and missing parameter docs in the circuit classes - Register.add_register claimed to raise ValueError "if new_size is not greater than the current register size" (copied from expand_register); document the conditions it actually checks. expand_register's ValueError list now includes its reg_type and multi-qubit checks, and its new_size type field was mislabelled ":type register:". - Document Register.register as a shallow copy whose per-type lists are shared with the Register. - CircuitDAG.__init__ did not document openqasm_imports/openqasm_defs; CircuitDAG._add_register did not document size or its ValueError for size != 1; assign_noise had empty :param:/:type: fields for noise_model_map. - OperationBase's labels setter and add_labels did not document new_labels; ControlledPairOperationBase did not document noise (a single model or a list of two) or its AssertionError. - class_to_name_mapping had an empty :return:; it returns the json name or None for an unmapped class. --- graphiq/circuit/circuit_dag.py | 14 ++++++++++++-- graphiq/circuit/ops.py | 12 +++++++++++- graphiq/circuit/register.py | 17 ++++++++++++++--- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index d568e5ef..e61835f1 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -71,6 +71,10 @@ def __init__( :type n_photon: int :param n_classical: the number of classical bits in the system :type n_classical: int + :param openqasm_imports: None or an (ordered) dictionary, where the keys are import strings for openqasm + :type openqasm_imports: a dictionary (with str keys) or None + :param openqasm_defs: None or an (ordered) dictionary, where the keys are definitions strings for openqasm gates + :type openqasm_defs: a dictionary (with str keys) or None :return: nothing :rtype: None """ @@ -716,6 +720,9 @@ def _add_register(self, reg_type: str, size=1): :param reg_type: 'e' to add an emitter qubit register, 'p' to add a photonic qubit register, 'c' to add a classical register :type reg_type: str + :param size: the size of the register to add; this circuit class only supports registers of size 1 + :type size: int + :raises ValueError: if size is not 1 :return: the index of the added register :rtype: int """ @@ -1156,8 +1163,11 @@ def assign_noise(self, noise_model_map): """ Create a copy of the circuit where each gate is appended its noise model - :param noise_model_map: - :type noise_model_map: + :param noise_model_map: a nested dictionary whose outer keys are register-type sections ("e", "p", "ee", + "ep", ...) and whose inner dictionaries map an operation's class name to its noise model; for a + two-qubit operation, the section is its control type followed by its target type, and the noise + model may be a list of two (one per leg). Operations without an entry get NoNoise + :type noise_model_map: dict[str, dict] :return: a new circuit :rtype: CircuitDAG """ diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 3761da98..692d508a 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -220,6 +220,8 @@ def labels(self, new_labels): """ Sets the labels to be new_labels + :param new_labels: the labels to replace the existing ones with + :type new_labels: list[str] :return: nothing :rtype: None """ @@ -229,6 +231,8 @@ def add_labels(self, new_labels): """ Adds new labels to existing labels + :param new_labels: the label or labels to add to the existing ones + :type new_labels: list[str] or str :return: nothing :rtype: None """ @@ -463,6 +467,10 @@ def __init__(self, control, control_type, target, target_type, noise=nm.NoNoise( :type target: int OR tuple (of ints, length 2) :param target_type: 'p' if photonic qubit, 'e' if emitter qubit :type target_type: str + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises AssertionError: if noise is a list whose length is not 2 :return: function returns nothing :rtype: None """ @@ -1236,7 +1244,9 @@ def class_to_name_mapping(class_op): :param class_op: operation class :type class_op: operation - :return: + :return: the string name used for this operation class in json, or None if class_op has no entry + in the mapping + :rtype: str or None """ mapping = { CNOT: "CX", diff --git a/graphiq/circuit/register.py b/graphiq/circuit/register.py index d48f65e0..d5ec2792 100644 --- a/graphiq/circuit/register.py +++ b/graphiq/circuit/register.py @@ -50,6 +50,15 @@ def __init__(self, reg_dict, is_multi_qubit: bool = False): @property def register(self): + """ + The register dictionary, keyed by register type + + This is a shallow copy: adding or removing a register type on the result does not affect the Register, + but the per-type lists are shared, so mutating one of those does. + + :return: a copy of the register dictionary + :rtype: dict + """ return self._registers.copy() def __getitem__(self, key): @@ -83,7 +92,8 @@ def add_register(self, reg_type: str, size: int = 1): :type reg_type: str :param size: the new register size :type size: int - :raises ValueError: if new_size is not greater than the current register size + :raises ValueError: if reg_type is not a valid register type, if size is less than 1, or if size is + greater than 1 and multi-qubit registers are not supported :return: the index number of the added register :rtype: int """ @@ -107,11 +117,12 @@ def expand_register(self, reg_type: str, register: int, new_size: int = 1): :param register: the register index of the register to expand :type register: int :param new_size: the new register size - :type register: int + :type new_size: int :param reg_type: 'p' for a photonic quantum register, 'e' for an emitter quantum register, 'c' for a classical register :type reg_type: str - :raises ValueError: if new_size is not greater than the current register size + :raises ValueError: if reg_type is not a valid register type, if new_size is greater than 1 and multi-qubit + registers are not supported, or if new_size is not greater than the current register size :return: this function returns nothing :rtype: None """ From 9b3d0255607316b2ff7a859ee5ce51871d68b975 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:53:37 -0400 Subject: [PATCH 069/227] [docs] Fix empty and copy-pasted docstrings in the stabilizer backend - CliffordTableau.stabilizer_to_labels/destabilizer_to_labels had empty :return:/:rtype: fields; they return one Pauli string per generator, without the generator's sign. - StabilizerTableau.x_matrix's setter had an empty :return:; both matrix setters now document their shape AssertionError. - Stabilizer.partial_trace had empty keep/dims descriptions. - MixedStabilizer.mixture's setter documented the getter's return value; it returns nothing, and now documents the AssertionError/TypeError it raises. The data setter's value type omitted the mixture list it accepts. - MixedStabilizer.apply_conditioned_gate used malformed ":param x: type" fields and an empty :return:. --- .../backends/stabilizer/clifford_tableau.py | 13 +++++--- graphiq/backends/stabilizer/state.py | 32 ++++++++++++------- graphiq/backends/stabilizer/tableau.py | 5 ++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/graphiq/backends/stabilizer/clifford_tableau.py b/graphiq/backends/stabilizer/clifford_tableau.py index 43167698..bc5905cf 100644 --- a/graphiq/backends/stabilizer/clifford_tableau.py +++ b/graphiq/backends/stabilizer/clifford_tableau.py @@ -295,8 +295,11 @@ def stabilizer_to_labels(self): """ Convert the stabilizers to generator strings - :return: - :rtype: + Each string carries only the letters I, X, Y and Z: the phase vector is not consulted, + so the sign of each generator is not included. + + :return: one string per stabilizer generator + :rtype: list[str] """ return sfu.symplectic_to_string(self.stabilizer_x, self.stabilizer_z) @@ -304,8 +307,10 @@ def destabilizer_to_labels(self): """ Convert the destabilizers to the generator strings - :return: - :rtype: + As with stabilizer_to_labels, the sign of each generator is not included. + + :return: one string per destabilizer generator + :rtype: list[str] """ return sfu.symplectic_to_string(self.destabilizer_x, self.destabilizer_z) diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index cd6d658a..f3248e98 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -320,10 +320,10 @@ def partial_trace(self, keep, dims): """ Trace out qubits after disentangling them from the rest - :param keep: + :param keep: the qubit positions to be kept :type keep: list[int] or numpy.ndarray - :param dims: - :type dims: + :param dims: dimension of each subsystem + :type dims: list[int] or numpy.ndarray :return: nothing :rtype: None """ @@ -418,8 +418,11 @@ def mixture(self, value): :param value: a new mixture list, pure tableau, or a parameter to initialize a new tableau :type value: list or int or CliffordTableau - :return: the mixture as a list of (probability_i, tableau_i) - :rtype: list + :raises AssertionError: if a list entry is not a (float, CliffordTableau) pair, or if the tableaux + do not all have the same number of qubits + :raises TypeError: if value is not a list, a CliffordTableau or an int + :return: nothing + :rtype: None """ if isinstance(value, list): assert all( @@ -457,8 +460,8 @@ def data(self, value): """ Set the data that represents the state given by the MixedStabilizer representation - :param value: a new tableau or a parameter to initialize a new tableau - :type value: CliffordTableau or int + :param value: a new mixture list, pure tableau, or a parameter to initialize a new tableau + :type value: list or CliffordTableau or int :return: nothing :rtype: None """ @@ -518,10 +521,17 @@ def apply_conditioned_gate(self, qubit_position, outcomes, gate=None): """ Apply a single-qubit gate, conditioned on a classical measurement outcome. - :param qubit_position: int - :param outcomes: list of measurement outcomes for each tableau in the mixture - :param gate: str, one of 'x', 'y', 'z', or 'h' - :return: + :param qubit_position: the qubit position where the gate is applied + :type qubit_position: int + :param outcomes: list of measurement outcomes for each tableau in the mixture; the gate is applied to + the tableaux whose outcome is 1 + :type outcomes: list[int] + :param gate: one of 'x', 'y', 'z', or 'h' + :type gate: str + :raises AssertionError: if outcomes is not a list with one entry per tableau in the mixture + :raises NotImplementedError: if gate is not one of 'x', 'y', 'z', or 'h' + :return: nothing + :rtype: None """ assert isinstance(outcomes, list) assert len(outcomes) == len(self._mixture) diff --git a/graphiq/backends/stabilizer/tableau.py b/graphiq/backends/stabilizer/tableau.py index e84a0002..c0311937 100644 --- a/graphiq/backends/stabilizer/tableau.py +++ b/graphiq/backends/stabilizer/tableau.py @@ -155,7 +155,9 @@ def x_matrix(self, value): :param value: the X matrix part of the stabilizer tableau :type value: numpy.ndarray - :return: + :raises AssertionError: if value is not of shape (n_qubits, n_qubits) + :return: nothing + :rtype: None """ assert value.shape == (self.n_qubits, self.n_qubits) self._table[:, 0 : self.n_qubits] = value.astype(int) @@ -177,6 +179,7 @@ def z_matrix(self, value): :param value: the Z matrix part of the stabilizer tableau :type value: numpy.ndarray + :raises AssertionError: if value is not of shape (n_qubits, n_qubits) :return: nothing :rtype: None """ From 1e2394998259e27db3b09148e2265f9a50fd12cc Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:53:38 -0400 Subject: [PATCH 070/227] [docs] Fix wrong return docs in the density-matrix and graph states - Graph.update_lc was documented as "Find the local Clifford gates" with a return value copied from find_lc; it replaces a node's gates and returns nothing. Document None/empty lc_gate and the ValueError for an unknown node. - find_lc and _compile_lc_gates claimed to return a single OneQubitOperationBase; they return a list of gate classes. - MixedGraph.mixture's setter documented the getter's return value; it returns nothing, and accepts a list of (probability, Graph) pairs or a single Graph. - Fill in Graph.draw's empty with_labels fields, document local_complementation's copy parameter and NotImplementedError, and mark clifford_dict as optional. - DensityMatrix.partial_trace had empty :return:/:rtype: fields. --- graphiq/backends/density_matrix/state.py | 4 +-- graphiq/backends/graph/state.py | 42 +++++++++++++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index 9331372b..7bf5253f 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -238,8 +238,8 @@ def partial_trace(self, keep, dims): if the space is :math:`A \\times B \\times C \\times D`, dims = [dim_A, dim_B, dim_C, dim_D] :type dims: list OR numpy.ndarray - :return: - :rtype: + :return: nothing + :rtype: None """ self.data = dmf.partial_trace(self.data, keep, dims) diff --git a/graphiq/backends/graph/state.py b/graphiq/backends/graph/state.py index 8241dcd3..be7acc7b 100644 --- a/graphiq/backends/graph/state.py +++ b/graphiq/backends/graph/state.py @@ -33,8 +33,8 @@ def _compile_lc_gates(lc_gate): :param lc_gate: a list of local Clifford gates :type lc_gate: list[str] - :return: a one-qubit gate - :rtype: ops.OneQubitOperationBase + :return: a list of one-qubit gate classes representing the same local Clifford + :rtype: list """ op_list = [] for op in lc_gate: @@ -57,7 +57,7 @@ def __init__(self, data, clifford_dict=None, *args, **kwargs): :param data: data used to construct the representation :type data: networkX.Graph :param clifford_dict: a dictionary that stores local Clifford for each node - :type clifford_dict: dict + :type clifford_dict: dict or None :return: nothing :rtype: None """ @@ -104,8 +104,9 @@ def find_lc(self, node_id): :param node_id: the node index :type node_id: int - :return: local Clifford gates - :rtype: ops.OneQubitOperationBase + :raises ValueError: if no node with node_id exists + :return: local Clifford gates, as a list of one-qubit gate classes + :rtype: list """ if self._data.has_node(node_id): return self._data.nodes[node_id]["LC"] @@ -114,14 +115,15 @@ def find_lc(self, node_id): def update_lc(self, node_id, lc_gate): """ - Find the local Clifford gates corresponding to a node + Replace the local Clifford gates recorded on a node :param node_id: the node index :type node_id: int - :param lc_gate: a list of local Clifford gates - :type lc_gate: list(str) - :return: local Clifford gates - :rtype: ops.OneQubitOperationBase + :param lc_gate: a list of local Clifford gates; None or an empty list records the identity + :type lc_gate: list(str) or None + :raises ValueError: if no node with node_id exists + :return: nothing + :rtype: None """ if not self._data.has_node(node_id): raise ValueError(f"Node with node ID {node_id} does not exist.") @@ -238,8 +240,8 @@ def draw(self, show=True, ax=None, with_labels=True): :type show: bool :param ax: axis on which to draw the plot (optional) :type ax: matplotlib.Axis - :param with_labels: - :type with_labels: + :param with_labels: if True, node labels are drawn + :type with_labels: bool :return: nothing :rtype: None """ @@ -258,6 +260,11 @@ def local_complementation(self, node_id, copy=False): :param node_id: the ID of the node around which local complementation should take place :type node_id: int + :param copy: if True, the local complementation is applied to a copy and this Graph is left unchanged; + if False, this Graph is modified in place + :type copy: bool + :raises NotImplementedError: if this Graph is not a graph state (i.e. carries non-identity local + Clifford gates) :return: the graph after the local complementation :rtype: Graph """ @@ -361,10 +368,13 @@ def mixture(self, value): """ Sets the mixture of pure states, represented as a list of Graphs and associated probabilities. - :param value: a new mixture list, pure Graph - :type value: list[Graph] - :return: the mixture as a list of (probability_i, Graph_i) - :rtype: list + :param value: a new mixture list of (probability_i, Graph_i), or a pure Graph + :type value: list or Graph + :raises AssertionError: if a list entry is not a (float, Graph) pair, or if the graphs do not all have + the same number of qubits + :raises TypeError: if value is neither a list nor a Graph + :return: nothing + :rtype: None """ if isinstance(value, list): assert all( From 1c10ee1c13d291b4b2dab45e188cb4f69fc3fe7c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:53:38 -0400 Subject: [PATCH 071/227] [docs] Correct solver docstrings that disagree with the code - AlternateTargetSolver.solve claimed to return a dict keyed by circuit; it returns a list of (circuit, {'g', 'score', 'map'}) tuples (and stores the same data in self.result). - Fill in AlternateTargetSolver's empty noise_model_mapping and solver_setting fields (noise_model_mapping also accepts "depolarizing" or None), and the empty docstrings of noise_score and depol_noise_map. - graph_to_circ's noise_model_mapping had an empty description. - SolverResult.add_properties had an empty docstring. - TimeReversedSolver._add_emitter_photon_cnot and _add_measurement_cnot_and_reset did not document emitter_index and photon_index. --- graphiq/solvers/alternate_target_solver.py | 49 ++++++++++++---------- graphiq/solvers/solver_result.py | 10 +++-- graphiq/solvers/time_reversed_solver.py | 8 ++++ 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 957e1a2f..835fcdd7 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -78,10 +78,12 @@ def __init__( :type noise_compiler: CompilerBase :param io: input/output :type io: IO - :param noise_model_mapping: - :type noise_model_mapping: dict - :param solver_setting: - :type solver_setting: + :param noise_model_mapping: a noise model mapping keyed by register-type section ("e", "p", "ee", "ep"); + the string "depolarizing" uses depolarizing noise at the solver setting's depolarizing_rate, and None + turns noise simulation off + :type noise_model_mapping: dict or str or None + :param solver_setting: the solver setting; if None, a default setting is created + :type solver_setting: AlternateTargetSolverSetting or None :param seed: a random seed :type seed: int """ @@ -154,10 +156,11 @@ def solve(self): alternative isomorphic and LC-equivalent graphs. Notice that the returned circuits generate the target state up to relabeling if this feature is enabled in the setting. Otherwise, user's exact target graph is produced. - :return: a dictionary where keys are the circuits and values are themselves dictionaries containing the LC graph - used as the intermediate states and relabeling map between the actual target and the graph the circuit - generates. {circuit: {'g':graph, 'map': relabel_map}} - :rtype: dict + :return: a list of (circuit, properties) tuples, where properties is a dictionary containing the LC graph + used as the intermediate state, the circuit's score, and the relabeling map between the actual target and + the graph the circuit generates: [(circuit, {'g': graph, 'score': score, 'map': relabel_map}), ...]. + The same data is also stored in self.result as a SolverResult + :rtype: list[tuple] """ setting = self.solver_setting n_iso = setting.n_iso_graphs @@ -352,14 +355,16 @@ def solve(self): def noise_score(self, circuit, relabel_map): """ - - - :param circuit: - :type circuit: - :param relabel_map: - :type relabel_map: - :return: - :rtype: + Score a circuit on the noise compiler against the target graph relabeled by relabel_map. + Emitter qubits are traced out before scoring, and a score that is close to, but not exactly, 0 is + replaced by 1e-05. + + :param circuit: the circuit to score + :type circuit: CircuitDAG + :param relabel_map: the relabeling map from the target graph's node labels to the new labels + :type relabel_map: dict + :return: the infidelity of the compiled state with respect to the relabeled target + :rtype: float """ graph = self.target_graph old_adj = nx.to_numpy_array(graph) @@ -391,10 +396,12 @@ def noise_score(self, circuit, relabel_map): def depol_noise_map(self): """ + Returns a noise model mapping with depolarizing noise at the rate depolarizing_rate on the one-qubit + emitter gates and on the emitter-emitter and emitter-photon CNOT gates. One-qubit photonic gates are + noiseless. - - :return: - :rtype: + :return: the noise model mapping, keyed by register-type section + :rtype: dict """ rate = self.depolarizing_rate @@ -468,8 +475,8 @@ def graph_to_circ(graph, noise_model_mapping=None, show=False): :param graph: The graph to generate :type graph: networkx.Graph - :param noise_model_mapping: - :type noise_model_mapping: dict + :param noise_model_mapping: a noise model mapping passed to TimeReversedSolver, or None for no noise + :type noise_model_mapping: dict or None :param show: If true draws the corresponding circuit :type show: bool :return: A circuit corresponding to the input graph diff --git a/graphiq/solvers/solver_result.py b/graphiq/solvers/solver_result.py index 8f5aaee9..629f09aa 100644 --- a/graphiq/solvers/solver_result.py +++ b/graphiq/solvers/solver_result.py @@ -98,11 +98,13 @@ def __setitem__(self, key, value): def add_properties(self, new_property): """ + Add an empty property column (filled with None), if it is not already present - :param new_property: - :type new_property: - :return: - :rtype: + :param new_property: the name of the property to add + :type new_property: str + :raises AssertionError: if new_property is not a str + :return: nothing + :rtype: None """ assert isinstance(new_property, str) if new_property not in self._properties: diff --git a/graphiq/solvers/time_reversed_solver.py b/graphiq/solvers/time_reversed_solver.py index 9cfb346b..b4dec679 100644 --- a/graphiq/solvers/time_reversed_solver.py +++ b/graphiq/solvers/time_reversed_solver.py @@ -547,6 +547,10 @@ def _add_emitter_photon_cnot(self, circuit, emitter_index, photon_index): :param circuit: a quantum circuit :type circuit: CircuitDAG + :param emitter_index: the index of the emitter qubit + :type emitter_index: int + :param photon_index: the index of the photonic qubit + :type photon_index: int :return: nothing :rtype: None """ @@ -626,6 +630,10 @@ def _add_measurement_cnot_and_reset(self, circuit, emitter_index, photon_index): :param circuit: a quantum circuit :type circuit: CircuitDAG + :param emitter_index: the index of the emitter qubit + :type emitter_index: int + :param photon_index: the index of the photonic qubit + :type photon_index: int :return: nothing :rtype: None """ From d21eb671fbcdadc1d45549d10e855b3eaef2206c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:53:38 -0400 Subject: [PATCH 072/227] [docs] Fill in empty docstrings and fix field syntax in graphiq/utils - preprocessing: the graph metric functions and the two get_lc_graph_by_* searches had empty :param:/:return: fields. graph_max_eccentricity returns the negated maximum eccentricity, the metrics raise ValueError on an empty graph, and the searches return at most n_graphs (score, graph) pairs sorted by ascending score. - relabel_module: iso_finder's adj_matrix was described as accepting a graph, but only an adjacency matrix works; lc_orbit_finder documented ":type rand:" under rep_allowed and never described rand; _equal_graphs did not document g2. - openqasm_parser and draw: fix malformed ":type:" fields that did not name their parameter. --- graphiq/utils/draw.py | 2 +- graphiq/utils/openqasm_parser.py | 2 +- graphiq/utils/preprocessing.py | 106 ++++++++++++++++++------------- graphiq/utils/relabel_module.py | 9 ++- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/graphiq/utils/draw.py b/graphiq/utils/draw.py index 0657b734..b63f995f 100644 --- a/graphiq/utils/draw.py +++ b/graphiq/utils/draw.py @@ -74,7 +74,7 @@ def update_col_width(self, index, new_width): :param index: index of the column that need to update the width :type index: int :param new_width: new width of the column - :type: new_width: int + :type new_width: int :return: nothing :rtype: None """ diff --git a/graphiq/utils/openqasm_parser.py b/graphiq/utils/openqasm_parser.py index eb5607e7..45f9fe66 100644 --- a/graphiq/utils/openqasm_parser.py +++ b/graphiq/utils/openqasm_parser.py @@ -29,7 +29,7 @@ def __init__(self, openqasm_str: str): between strings and parse functions. :param openqasm_str: OpenQASM 2.0 script string - :type: str + :type openqasm_str: str """ self.openqasm = openqasm_str diff --git a/graphiq/utils/preprocessing.py b/graphiq/utils/preprocessing.py index 14151148..c10939a7 100644 --- a/graphiq/utils/preprocessing.py +++ b/graphiq/utils/preprocessing.py @@ -29,12 +29,13 @@ def graph_circuit_depth(graph): """ - Return max of depth of emitter registers. + Find a circuit that generates the graph state with TimeReversedSolver, and return the maximum depth of + its emitter registers. - :param graph: - :type graph: - :return: - :rtype: + :param graph: the graph representing the target graph state + :type graph: nx.Graph + :return: the maximum emitter register depth of the circuit + :rtype: int """ target_tableau = get_clifford_tableau_from_graph(graph) target_state = QuantumState(target_tableau, rep_type="s") @@ -51,12 +52,14 @@ def graph_circuit_depth(graph): def graph_max_eccentricity(graph): """ - Return the maximum value of node eccentricity - - :param graph: - :type graph: - :return: - :rtype: + Return the negated maximum value of node eccentricity, so that a lower value corresponds to a larger + eccentricity + + :param graph: the graph to score + :type graph: nx.Graph + :raises ValueError: if the graph has no nodes + :return: the negative of the maximum node eccentricity + :rtype: int """ node_eccentricity = nx.eccentricity(graph) @@ -73,10 +76,11 @@ def graph_max_centrality(graph): """ Return the maximum of node degree centrality - :param graph: - :type graph: - :return: - :rtype: + :param graph: the graph to score + :type graph: nx.Graph + :raises ValueError: if the graph has no nodes + :return: the maximum degree centrality over all nodes + :rtype: float """ degree_centrality = nx.degree_centrality(graph) degree_centrality_list = degree_centrality.values() @@ -90,11 +94,13 @@ def graph_max_centrality(graph): def graph_betweenness_centrality(graph): """ + Return the maximum of node betweenness centrality - :param graph: - :type graph: - :return: - :rtype: + :param graph: the graph to score + :type graph: nx.Graph + :raises ValueError: if the graph has no nodes + :return: the maximum betweenness centrality over all nodes + :rtype: float """ betweenness_centrality = nx.betweenness_centrality(graph) betweenness_centrality_list = betweenness_centrality.values() @@ -108,11 +114,13 @@ def graph_betweenness_centrality(graph): def graph_closeness_centrality(graph): """ + Return the maximum of node closeness centrality - :param graph: - :type graph: - :return: - :rtype: + :param graph: the graph to score + :type graph: nx.Graph + :raises ValueError: if the graph has no nodes + :return: the maximum closeness centrality over all nodes + :rtype: float """ closeness_centrality = nx.closeness_centrality(graph) closeness_centrality_list = closeness_centrality.values() @@ -152,17 +160,21 @@ def _select_graphs(candidate_graphs, new_graph, limit, metric_value): def get_lc_graph_by_max_edge(graph, n_graphs, graph_metric, n_trial=5): """ - - :param graph: - :type graph: - :param n_graphs: - :type n_graphs: - :param graph_metric: - :type graph_metric: - :param n_trial: - :type n_trial: - :return: - :rtype: + Search the local-complementation orbit of a graph: for n_trial rounds, locally complement each candidate + graph at every node with the maximum number of edges (degree), score each resulting graph with graph_metric, + and keep the n_graphs candidates with the lowest score. + + :param graph: the graph to search from + :type graph: nx.Graph or numpy.ndarray + :param n_graphs: the maximum number of candidate graphs to keep + :type n_graphs: int + :param graph_metric: a function scoring a graph, called as graph_metric(graph) + :type graph_metric: function + :param n_trial: the number of local-complementation rounds + :type n_trial: int + :raises TypeError: if graph is neither an nx.Graph nor a numpy.ndarray + :return: at most n_graphs (score, graph) tuples, sorted by ascending score + :rtype: list[(float, nx.Graph)] """ if type(graph) == np.ndarray: graph = nx.from_numpy_array(graph) @@ -211,17 +223,21 @@ def _count_n_neighbor(adj_matrix): def get_lc_graph_by_max_neighbor_edge(graph, n_graphs, graph_metric, n_trial=5): """ - - :param graph: - :type graph: - :param n_graphs: - :type n_graphs: - :param graph_metric: - :type graph_metric: - :param n_trial: - :type n_trial: - :return: - :rtype: + Search the local-complementation orbit of a graph: for n_trial rounds, locally complement each candidate + graph at every node whose neighbors have the maximum number of edges among themselves, score each resulting + graph with graph_metric, and keep the n_graphs candidates with the lowest score. + + :param graph: the graph to search from + :type graph: nx.Graph or numpy.ndarray + :param n_graphs: the maximum number of candidate graphs to keep + :type n_graphs: int + :param graph_metric: a function scoring a graph, called as graph_metric(graph) + :type graph_metric: function + :param n_trial: the number of local-complementation rounds + :type n_trial: int + :raises TypeError: if graph is neither an nx.Graph nor a numpy.ndarray + :return: at most n_graphs (score, graph) tuples, sorted by ascending score + :rtype: list[(float, nx.Graph)] """ if type(graph) == np.ndarray: diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 7ada343f..018e08d6 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -45,7 +45,7 @@ def iso_finder( The maximum number of possible distinct cases may be less than n_iso. The graph G with the nodes relabeled using consecutive integers. - :param adj_matrix: initial adjacency matrix or graph + :param adj_matrix: initial adjacency matrix :type adj_matrix: numpy.ndarray :param n_iso: number of the isomorphic graphs to look for (including the original graph) :type n_iso: int @@ -352,8 +352,11 @@ def lc_orbit_finder( :type orbit_size_thresh: int :param with_iso: if true, isomorph graphs will be kept in the orbit :type with_iso: bool - :param rep_allowed: if true the orbit finder does not check for iso or auto morphism; useful for large graphs. + :param rand: if true, the search starts from a graph obtained by applying random local complementations to + the original graph, instead of from the original graph itself, and the nodes are tried in a random order :type rand: bool + :param rep_allowed: if true the orbit finder does not check for iso or auto morphism; useful for large graphs. + :type rep_allowed: bool :return: list of distinct graphs in the orbit of original graph :rtype: list[nx.Graph] """ @@ -532,6 +535,8 @@ def _equal_graphs(g1, g2): """ :param g1: a graph :type g1: nx.Graph + :param g2: a graph + :type g2: nx.Graph :return: given two graphs this function return true if they have the same adjacency matrix. :rtype: bool """ From a79c0b5f40e83826b4244f89a0ee8de24ef96872 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:53:38 -0400 Subject: [PATCH 073/227] [docs] Correct return and parameter docs in benchmarks and visualizers - pipeline.run_combinations claimed to return a list; it returns a dict of runs keyed by run name. benchmark and benchmark_graph_search_solver described runs as a list (they iterate runs.items()) and used a malformed ":type:" field; benchmark_graph_search_solver did not document circuit_format. - benchmark_run_graph_search_solver documented a pandas.DataFrame return; it returns a dict. - benchmark_utils.benchmark_data did not document seed_offset. - graph_states._gnp_random_connected_graph did not document np_rng and had an empty :return:. - plot_solver_logs had an empty :return:; it returns (fig, axs). --- graphiq/benchmarks/benchmark_utils.py | 2 ++ graphiq/benchmarks/graph_states.py | 4 +++- graphiq/benchmarks/pipeline.py | 18 ++++++++++++------ graphiq/visualizers/solver_logs.py | 4 +++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/graphiq/benchmarks/benchmark_utils.py b/graphiq/benchmarks/benchmark_utils.py index 64956410..6347f0ae 100644 --- a/graphiq/benchmarks/benchmark_utils.py +++ b/graphiq/benchmarks/benchmark_utils.py @@ -90,6 +90,8 @@ def benchmark_data( :type target_type: str :param per_target_retries: the number of times we should reseed and run the same solver problem :type per_target_retries: int + :param seed_offset: added to each retry's index to form the seed passed to the solver + :type seed_offset: int :param save_directory: if None, return data without saving. Else, save data to specified directory :type save_directory: str :return: dataframe summarizing benchmark information + other info diff --git a/graphiq/benchmarks/graph_states.py b/graphiq/benchmarks/graph_states.py index 0456eb7c..c9a901ff 100644 --- a/graphiq/benchmarks/graph_states.py +++ b/graphiq/benchmarks/graph_states.py @@ -101,7 +101,9 @@ def _gnp_random_connected_graph(n, p, np_rng): :type n: int :param p: the probability of an edge being added :type p: float - :return: + :param np_rng: the random number generator used to choose the edges + :type np_rng: numpy.random.Generator + :return: a random graph on n nodes; p <= 0 gives the graph with no edges and p >= 1 the complete graph :rtype: networkx.Graph """ edges = itertools.combinations(range(n), 2) diff --git a/graphiq/benchmarks/pipeline.py b/graphiq/benchmarks/pipeline.py index 94a0ac7c..fecbc5ce 100644 --- a/graphiq/benchmarks/pipeline.py +++ b/graphiq/benchmarks/pipeline.py @@ -50,7 +50,9 @@ def run_combinations(solvers, targets, compilers, metrics): :type compilers: list :param metrics: list of metrics to use :type metrics: list - :return: a list of dictionaries, each defining a single benchmarking run + :return: a dictionary mapping each run name ("run0", "run1", ...) to a dictionary defining a single + benchmarking run + :rtype: dict """ runs = {} @@ -123,8 +125,9 @@ def benchmark(runs: dict, io: IO, remote=True): """ Runs a sequence of benchmark runs, either using parallel or serial processing. - :param runs: a list of runs, each being a dictionary containing the important class instances (solvers, etc.) - :type: list + :param runs: a dictionary mapping each run name to a run, each being a dictionary containing the important + class instances (solvers, etc.) + :type runs: dict :param io: a top-level IO object for saving benchmark data :type io: IO instance :param remote: True/False flag. If True, will run solvers in parallel using `ray` @@ -178,7 +181,7 @@ def benchmark_run_graph_search_solver(run, name, io, circuit_format="qasm"): :param circuit_format: a variable define what format the circuit will be saved to :type circuit_format: str :return: dictionary storing all the results/metadata for the current run (can be added to a DataFrame) - :rtype: pandas.DataFrame + :rtype: dict """ solver = run["solver"] t0 = time.time() @@ -222,10 +225,13 @@ def benchmark_graph_search_solver(runs, io, circuit_format="qasm"): """ Runs a sequence of benchmark runs, either using parallel or serial processing. - :param runs: a list of runs, each being a dictionary containing the important class instances (solvers, etc.) - :type: list + :param runs: a dictionary mapping each run name to a run, each being a dictionary containing the important + class instances (solvers, etc.) + :type runs: dict :param io: a top-level IO object for saving benchmark data :type io: IO instance + :param circuit_format: the format each run's circuits are saved in, either "qasm" or "json" + :type circuit_format: str :return: DataFrame summarizing the results of all benchmarking runs """ futures = [] diff --git a/graphiq/visualizers/solver_logs.py b/graphiq/visualizers/solver_logs.py index 061e9d04..9531a82d 100644 --- a/graphiq/visualizers/solver_logs.py +++ b/graphiq/visualizers/solver_logs.py @@ -21,7 +21,9 @@ def plot_solver_logs(logs: dict): solver iteration. :param logs: dictionary containing a log (pd.DataFrame) for population and hof - :return: + :type logs: dict + :return: fig (figure handle), axs (2x2 array of axes handles) + :rtype: matplotlib.Figure, numpy.ndarray """ fig, axs = plt.subplots(nrows=2, ncols=2, sharey="row", sharex="col") colors = ["teal", "orange"] From 04b2f0f14c283926d665a29b437361403270dbd5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 01:54:48 -0400 Subject: [PATCH 074/227] [fix] Record the metric's class name in benchmark_run's summary benchmark_run filled the summary's "metric" field from the compiler instead of the metric, so every run reported its compiler class as the metric. --- graphiq/benchmarks/pipeline.py | 2 +- tests/test_benchmarking/test_pipeline.py | 32 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/graphiq/benchmarks/pipeline.py b/graphiq/benchmarks/pipeline.py index fecbc5ce..856c0ce3 100644 --- a/graphiq/benchmarks/pipeline.py +++ b/graphiq/benchmarks/pipeline.py @@ -112,7 +112,7 @@ def benchmark_run(run: dict, name: str, io: IO): path=io.path.name, solver=run["solver"].__class__.__name__, compiler=run["compiler"].__class__.__name__, - metric=run["compiler"].__class__.__name__, + metric=run["metric"].__class__.__name__, # target=run["target"], # TODO: ideally the target is a QuantumState object, and we can use the __name__ here time=(t1 - t0), circuit_cost=solver.hof[0][0], diff --git a/tests/test_benchmarking/test_pipeline.py b/tests/test_benchmarking/test_pipeline.py index 12df04a5..1720e80c 100644 --- a/tests/test_benchmarking/test_pipeline.py +++ b/tests/test_benchmarking/test_pipeline.py @@ -79,3 +79,35 @@ def test_benchmark_run_graph_search_solver(): data = benchmark_run_graph_search_solver(example_run, "test_pipeline", io) assert type(data) == dict + + +def test_benchmark_run_records_metric_name(monkeypatch, tmp_path): + import graphiq.benchmarks.pipeline as pipeline + + class _Circuit: + depth = 1 + + class _Solver: + logs = {} + hof = [(0.0, _Circuit())] + + def solve(self): + pass + + class _IO: + path = tmp_path + + def save_figure(self, fig, filename): + pass + + monkeypatch.setattr(pipeline, "plot_solver_logs", lambda logs: (None, None)) + run = dict( + solver=_Solver(), + compiler=StabilizerCompiler(), + metric=Infidelity(target=QuantumState(1, rep_type="s")), + ) + + d = pipeline.benchmark_run(run, "run0", _IO()) + + assert d["compiler"] == "StabilizerCompiler" + assert d["metric"] == "Infidelity" From 39d18eb615e2f10eabe05400ca742759888ec00a Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 02:02:23 -0400 Subject: [PATCH 075/227] [test] Cover solver selection, probability adaptation and MC noise map Add direct tests for RandomSearchSolver.tournament_selection (k=0 no-op, deep copies, scores drawn from the population), for EvolutionarySolver.adapt_probabilities (direction of each change, normalisation, non-strict monotonicity up to the clamp fixed point) and get_emission_assignment (length, contiguity and emitter-reuse invariants over many seeded draws), and for the Monte Carlo depolarizing noise map AlternateTargetSolver builds via mc_depol. --- .../test_alternate_target_solver.py | 60 ++++++++++++ .../test_evolutionary_random_solvers.py | 92 +++++++++++++++++++ tests/test_solvers/test_solver_base.py | 46 ++++++++++ 3 files changed, 198 insertions(+) diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index 3cbf9d91..a475c61a 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -172,3 +172,63 @@ def test_allow_lc_false_bypasses_lc_method_validation(): ) solver.solve() assert solver.result is not None + + +def test_mc_depol_builds_the_expected_noise_map(): + # solver_setting.monte_carlo = True, set before construction, is what makes __init__ set + # depolarizing_rate and build the Monte Carlo noise map via mc_depol() (rather than the + # density-matrix noise_model_mapping the other tests here use). + setting = my_setting() + setting.monte_carlo = True + solver = AlternateTargetSolver( + target=linear_cluster, + solver_setting=setting, + noise_model_mapping="depolarizing", + ) + + mapping = solver.mc_params["map"].mapping + assert set(mapping.keys()) == {"e", "p", "ee", "ep"} + assert mapping["p"] == {} + assert set(mapping["e"].keys()) == { + "Hadamard", + "Phase", + "PhaseDagger", + "SigmaX", + "SigmaY", + "SigmaZ", + } + assert set(mapping["ee"].keys()) == {"CNOT"} + assert set(mapping["ep"].keys()) == {"CNOT"} + + rate = solver.depolarizing_rate + assert rate == setting.depolarizing_rate + per_gate_rate = rate / 3 + gate_entries = [("e", name) for name in mapping["e"]] + [ + ("ee", "CNOT"), + ("ep", "CNOT"), + ] + for reg_type, gate_name in gate_entries: + entries = mapping[reg_type][gate_name] + assert len(entries) == 3 + assert {noise.noise_parameters["Pauli error"] for noise, _ in entries} == { + "X", + "Y", + "Z", + } + for _, prob in entries: + assert prob == pytest.approx(per_gate_rate) + + tuples = solver.mc_params["map"].get_gate_noise("e", "Hadamard") + assert len(tuples) == 4 + pauli_tuples, (no_noise, no_noise_prob) = tuples[:3], tuples[3] + assert {t[0].noise_parameters["Pauli error"] for t in pauli_tuples} == { + "X", + "Y", + "Z", + } + for _, prob in pauli_tuples: + assert prob == pytest.approx(per_gate_rate) + assert isinstance(no_noise, nm.NoNoise) + # The residual is built as 1 - 3 * (rate / 3), which differs from 1 - rate by one ulp + # for some rates, so the comparison is approximate rather than bit-exact. + assert no_noise_prob == pytest.approx(1 - rate) diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index eaaf0120..c585ca0a 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -319,3 +319,95 @@ def test_stabilizer_linear3(): print(f"the target stabilizer in the canonical form is {target_s_tableau}") assert output_s_tableau == target_s_tableau assert state.rep_data == target_state.rep_data + + +# Tests for EvolutionarySolver.adapt_probabilities and get_emission_assignment, which have no +# oracle beyond the source and are exercised here directly rather than through a full search. + + +def test_adapt_probabilities_moves_weight_from_additive_to_removal_transforms(): + solver = EvolutionarySolver(None, None, None, n_emitter=1) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert ( + solver.trans_probs[solver.add_emitter_one_qubit_op] + < before[solver.add_emitter_one_qubit_op] + ) + assert ( + solver.trans_probs[solver.replace_photon_one_qubit_op] + < before[solver.replace_photon_one_qubit_op] + ) + assert solver.trans_probs[solver.remove_op] > before[solver.remove_op] + assert solver.add_emitter_cnot not in solver.trans_probs + assert np.isclose(sum(solver.trans_probs.values()), 1) + + +def test_adapt_probabilities_also_shrinks_the_cnot_key_with_multiple_emitters(): + solver = EvolutionarySolver(None, None, None, n_emitter=2) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert solver.trans_probs[solver.add_emitter_cnot] < before[solver.add_emitter_cnot] + + +def test_adapt_probabilities_stay_non_strict_monotone_over_many_calls(): + # The clamp to [0.01, 0.99] happens before normalisation, so it is not observable in the raw + # values -- normalised, every decreasing key plateaus at a fixed point (0.0099 for one + # emitter, with remove_op at 0.9802) rather than continuing to shrink, so strict + # monotonicity would eventually fail. Non-strict (<=, >=) holds throughout. + solver = EvolutionarySolver(None, None, None, n_emitter=1) + n_stop = solver.setting.n_stop + decreasing_keys = [ + solver.add_emitter_one_qubit_op, + solver.replace_photon_one_qubit_op, + ] + + previous = dict(solver.trans_probs) + for _ in range(3 * n_stop + 1): + solver.adapt_probabilities() + for key in decreasing_keys: + assert solver.trans_probs[key] <= previous[key] + assert solver.trans_probs[solver.remove_op] >= previous[solver.remove_op] + assert np.isclose(sum(solver.trans_probs.values()), 1) + previous = dict(solver.trans_probs) + + assert max(solver.trans_probs, key=solver.trans_probs.get) == solver.remove_op + + +@pytest.mark.parametrize("n_photon, n_emitter", [(4, 2), (6, 3), (3, 3)]) +def test_get_emission_assignment_invariants_hold_over_many_seeded_draws( + n_photon, n_emitter +): + # Keep (6, 3): it is the only case where the reuse assertion at the end is load-bearing. + # With the random-branch growth removed, (4, 2) still reuses emitter 1 by chance, while + # (6, 3) can only ever reach emitter 2 through the forced tail, exactly once. + # Do not add a case with n_photon < n_emitter here: every invariant below fails for it + # (measured for (2, 3) and (3, 5)), which is a separate question about input validation + # that this function does not perform. + np.random.seed(2026) + top_emitter_reused = False + for _ in range(2000): + assignment = EvolutionarySolver.get_emission_assignment(n_photon, n_emitter) + top_emitter_reused |= assignment.count(n_emitter - 1) > 1 + + assert len(assignment) == n_photon + assert assignment[0] == 0 + assert set(assignment) == set(range(n_emitter)) + + # Contiguous: a new emitter id, once it first appears, is always exactly one more than + # the largest id seen so far. This is also why an index into available_emitter can + # double as the emitter id it names: available_emitter is maintained as exactly + # list(range(len(available_emitter))) throughout the assignment loop. + largest_seen = 0 + for emitter_id in assignment: + assert emitter_id <= largest_seen + 1 + largest_seen = max(largest_seen, emitter_id) + + # The invariants above are all enforced by the forced tail branch alone. The random branch + # is what lets an emitter be reused after it first appears; without it the top emitter + # could only ever appear once, in the forced tail. (3, 3) has no slack, so skip it there. + if n_photon > n_emitter: + assert top_emitter_reused diff --git a/tests/test_solvers/test_solver_base.py b/tests/test_solvers/test_solver_base.py index ed5f84c7..1213cbc2 100644 --- a/tests/test_solvers/test_solver_base.py +++ b/tests/test_solvers/test_solver_base.py @@ -34,3 +34,49 @@ def test_random_search_solver_setting_getter_setter(): assert solver_setting.n_hof == 10 assert solver_setting.n_pop == 10 assert solver_setting.n_stop == 10 + + +# Test for RandomSearchSolver.tournament_selection. The payload is (score, [score]) rather than a +# plain (float, str) pair or a CircuitDAG: a (float, str) pair would round-trip through +# copy.deepcopy as the very same object (CPython treats it as atomic), defeating the identity +# check below, and CircuitDAG has no __eq__, so the k=1 equality check would fail regardless of +# whether the copy is otherwise correct. A one-element list is mutable enough to force a real +# deep copy while still comparing equal by value. + + +def test_tournament_selection_k_zero_is_a_no_op(): + solver = RandomSearchSolver(None, None, None) + population = [(0.5, [0.5]), (0.1, [0.1])] + + assert solver.tournament_selection(population, k=0) is population + + +def test_tournament_selection_samples_with_replacement_and_returns_deep_copies(): + # random.choices(population, k=k) samples with replacement, so k == len(population) does + # not guarantee the minimum-scoring entry is ever drawn; only the length, copy-ness, and + # score bound are guaranteed. + random.seed(0) + solver = RandomSearchSolver(None, None, None) + population = [(0.5, [0.5]), (0.1, [0.1]), (0.9, [0.9])] + input_scores = [score for score, _ in population] + + new_population = solver.tournament_selection(population, k=2) + + assert len(new_population) == solver.setting.n_pop + for score, payload in new_population: + assert score in input_scores + assert not any(entry is p for entry in new_population for p in population) + + +def test_tournament_selection_k_one_with_a_single_element_population_copies_that_element(): + random.seed(0) + solver = RandomSearchSolver(None, None, None) + element = (0.42, [0.42]) + population = [element] + + new_population = solver.tournament_selection(population, k=1) + + assert len(new_population) == solver.setting.n_pop + for entry in new_population: + assert entry == element + assert entry is not element From 2b0714e2a55c945bc1f439f9d2ce6b4713f7ba79 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 02:02:24 -0400 Subject: [PATCH 076/227] [test] Cover QuantumState graph conversions and density-matrix helpers Test the pure graph-to-density-matrix and graph-to-stabilizer arms of QuantumState.convert_representation, the mixed-graph arm of _graph_to_density, state_to_graph rejecting an input it cannot represent, and the swap_two_qubits and trace_out_qubit density-matrix functions. --- .../test_density_matrix_functions.py | 36 +++++++++++++++++++ .../test_quantum_state.py | 34 ++++++++++++++++++ .../test_state_rep_conversion.py | 8 +++++ 3 files changed, 78 insertions(+) diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py index 9b02b72a..435bcbcd 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py @@ -105,3 +105,39 @@ def test_fidelity_multiqubit(n_qubits): ) # works only for even number of qubits assert np.allclose(fidelity(rho, sigma), 0.5**n_qubits) + + +def test_swap_two_qubits_exchanges_a_computational_basis_state(): + rho = ket2dm(tensor([state_ketz0(), state_ketz1()])) + + swapped = swap_two_qubits(rho, 0, 1) + + assert np.allclose(swapped, ket2dm(tensor([state_ketz1(), state_ketz0()]))) + + +def test_swap_two_qubits_refuses_equal_control_and_target(): + # Delegates to get_two_qubit_controlled_gate, which refuses to build a controlled gate + # whose control and target are the same qubit. + rho = ket2dm(tensor([state_ketz0(), state_ketz1()])) + + with pytest.raises(ValueError): + swap_two_qubits(rho, 0, 0) + + +def test_trace_out_qubit_of_a_bell_pair_leaves_the_maximally_mixed_state(): + bell = ( + tensor([state_ketz0(), state_ketz0()]) + tensor([state_ketz1(), state_ketz1()]) + ) / np.sqrt(2) + rho = ket2dm(bell) + + traced = trace_out_qubit(rho, 1) + + assert np.allclose(traced, np.eye(2) / 2) + + +def test_trace_out_qubit_of_a_product_state_leaves_the_untraced_factor(): + rho = ket2dm(tensor([state_ketz0(), state_ketz1()])) + + traced = trace_out_qubit(rho, 1) + + assert np.allclose(traced, ket2dm(state_ketz0())) diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 5f2b6870..b552bbbd 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -90,3 +90,37 @@ def test_str_of_a_mixed_graph(): # MixedGraph never sets _data, so the base class __str__ must go through data mixed_graph = MixedGraph([(1.0, Graph(nx.path_graph(2)))]) assert str(mixed_graph).startswith("MixedGraph") + + +def test_graph_to_density_mixed_arm_reached_through_the_private_helper(): + # QuantumState.validate_data has no MixedGraph arm, so a MixedGraph cannot enter a + # QuantumState through the constructor at all -- the mixed-graph conversion path can only be + # reached by calling the private helper directly on an instance whose "mixed" flag is set. + qs = QuantumState(nx.path_graph(3), rep_type="g", mixed=True) + graph_a = nx.path_graph(3) + graph_b = nx.cycle_graph(3) + mixed_graph = MixedGraph([(0.7, Graph(graph_a)), (0.3, Graph(graph_b))]) + + density = qs._graph_to_density(mixed_graph) + + expected = 0.7 * rc.graph_to_density(graph_a) + 0.3 * rc.graph_to_density(graph_b) + assert np.allclose(density.data, expected) + + +def test_convert_representation_graph_to_density_matrix_pure_arm(): + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g") + + state.convert_representation("dm") + + assert np.allclose(state.rep_data.data, rc.graph_to_density(graph)) + + +def test_convert_representation_graph_to_stabilizer_pure_arm(): + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g") + + state.convert_representation("s") + + expected = rc.graph_to_stabilizer(graph)[0][1] + assert state.rep_data.data.to_stabilizer() == expected diff --git a/tests/test_state_representation/test_state_rep_conversion.py b/tests/test_state_representation/test_state_rep_conversion.py index 3406dc63..dfd9529a 100644 --- a/tests/test_state_representation/test_state_rep_conversion.py +++ b/tests/test_state_representation/test_state_rep_conversion.py @@ -16,6 +16,7 @@ import networkx as nx import numpy as np +import pytest import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.state_rep_conversion as rc @@ -235,3 +236,10 @@ def test_density_to_graph_mixed_state_decomposes_into_eigenstates(): assert np.allclose(weights, [0.3, 0.7]) for _, adj in graph_list: assert np.array_equal(adj, np.zeros((1, 1))) + + +def test_state_to_graph_rejects_an_unrepresentable_input(): + # Neither an adjacency matrix, a graph, nor a stabilizer/Clifford tableau: falls through + # every isinstance check to the final else arm. + with pytest.raises(ValueError, match="input data should either be"): + rc.state_to_graph("x") From d6de2c7a76a1558b1ad9610fbc8e793c9175b628 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 02:02:24 -0400 Subject: [PATCH 077/227] [test] Cover parameter seeding, Monte Carlo noise draws and noise helpers Test that CircuitBase.initialize_parameters is reproducible for a given seed and differs across seeds, the unitary and recorded parameters of HadamardPerturbedError, the order in which MonteCarloNoise draws noise for the sub-gates of a wrapped operation, the independent draws for the two legs of a CNOT, and the register-specific and default arms of NoisyEnsemble.noise_type_list. --- tests/test_circuits/test_dag_circuit.py | 27 +++++++ tests/test_noise/test_monte_carlo_noise.py | 92 ++++++++++++++++++++++ tests/test_noise/test_noise_models.py | 19 +++++ tests/test_noise/test_time_depend_noise.py | 32 ++++++++ 4 files changed, 170 insertions(+) create mode 100644 tests/test_noise/test_monte_carlo_noise.py diff --git a/tests/test_circuits/test_dag_circuit.py b/tests/test_circuits/test_dag_circuit.py index 0069f6eb..6839fba4 100644 --- a/tests/test_circuits/test_dag_circuit.py +++ b/tests/test_circuits/test_dag_circuit.py @@ -652,3 +652,30 @@ def test_circuit_comparison_8(): match = circuit1.compare(circuit2) assert ged assert match + + +def test_initialize_parameters_same_seed_gives_identical_parameter_values(): + # Keys are id(op)-derived, so a fresh circuit's map compares unequal to another fresh + # circuit's map even with the same seed; comparing the parameter *values* is what actually + # checks reproducibility. + from graphiq.benchmarks.circuits import variational_entangling_layer_2qubit + + circuit_a, _ = variational_entangling_layer_2qubit() + circuit_b, _ = variational_entangling_layer_2qubit() + + parameters_a = circuit_a.initialize_parameters(seed=42) + parameters_b = circuit_b.initialize_parameters(seed=42) + + assert list(parameters_a.values()) == list(parameters_b.values()) + + +def test_initialize_parameters_different_seed_gives_different_parameter_values(): + from graphiq.benchmarks.circuits import variational_entangling_layer_2qubit + + circuit_a, _ = variational_entangling_layer_2qubit() + circuit_b, _ = variational_entangling_layer_2qubit() + + parameters_a = circuit_a.initialize_parameters(seed=42) + parameters_b = circuit_b.initialize_parameters(seed=43) + + assert list(parameters_a.values()) != list(parameters_b.values()) diff --git a/tests/test_noise/test_monte_carlo_noise.py b/tests/test_noise/test_monte_carlo_noise.py new file mode 100644 index 00000000..f97a23cd --- /dev/null +++ b/tests/test_noise/test_monte_carlo_noise.py @@ -0,0 +1,92 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +How MonteCarloNoise draws the noise it attaches to each operation of a circuit: the order it +visits the sub-gates of a wrapped operation, and the independent draws for the two legs of a +controlled gate. +""" + +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.noise.monte_carlo_noise import McNoiseMap, MonteCarloNoise + + +def test_noisy_gates_maps_a_wrapped_ops_sub_gates_in_unwrap_order(): + # OneQubitGateWrapper.unwrap() returns its sub-gates in the reverse of the order given to + # the constructor, and that is the order _noisy_gates/_find_wrapped_noise draw noise in. + # Mapping the first two sub-gates at probability 1.0 avoids the residual NoNoise draw that + # would otherwise make the ordering assertion flaky; the third, unmapped sub-gate is what + # reaches the NoNoise arm for an op with no entry in the mapping. + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add( + ops.OneQubitGateWrapper( + [ops.Hadamard, ops.SigmaX, ops.SigmaZ], register=0, reg_type="e" + ) + ) + + mc = MonteCarloNoise(circuit) + mc.n_noisy_gates = 0 + mc_noise_model = McNoiseMap() + mc_noise_model.add_gate_noise("e", "Hadamard", [(nm.PauliError("X"), 1.0)]) + mc_noise_model.add_gate_noise("e", "SigmaX", [(nm.PauliError("Z"), 1.0)]) + mc.mc_noise_model = mc_noise_model + + noisy_ops = mc._noisy_gates() + + assert len(noisy_ops) == 1 + noise = noisy_ops[0].noise + # unwrap() order is [SigmaZ, SigmaX, Hadamard] for operations=[Hadamard, SigmaX, SigmaZ] + assert isinstance(noise[0], nm.NoNoise) + assert ( + isinstance(noise[1], nm.PauliError) + and noise[1].noise_parameters["Pauli error"] == "Z" + ) + assert ( + isinstance(noise[2], nm.PauliError) + and noise[2].noise_parameters["Pauli error"] == "X" + ) + assert mc.n_noisy_gates == 2 + + +def test_noisy_gates_controlled_gate_legs_can_differ_across_seeds(): + # The controlled-gate noise-LIST arm is unreachable through the public API: the + # McNoiseMap.mapping setter, which add_gate_noise reaches through add_noise_tuple, + # requires the first element of every noise tuple to be a NoiseBase, so a list-valued + # entry is rejected before any draw happens. Only the per-leg + # redraw in the scalar-noise else arm is coverable -- control and target are drawn + # independently, so they can land on different noise objects. + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + mc_noise_model = McNoiseMap() + mc_noise_model.add_gate_noise( + "ee", "CNOT", [(nm.PauliError("X"), 0.5), (nm.PauliError("Z"), 0.5)] + ) + + def pauli_name(noise): + return noise.noise_parameters["Pauli error"] + + for seed in range(20): + mc = MonteCarloNoise(circuit, seed=seed) + mc.n_noisy_gates = 0 + mc.mc_noise_model = mc_noise_model + + control_noise, target_noise = mc._noisy_gates()[0].noise + if pauli_name(control_noise) != pauli_name(target_noise): + return + + pytest.fail("the two legs of a CNOT never differed across 20 seeds") diff --git a/tests/test_noise/test_noise_models.py b/tests/test_noise/test_noise_models.py index 6373f43f..88610034 100644 --- a/tests/test_noise/test_noise_models.py +++ b/tests/test_noise/test_noise_models.py @@ -303,3 +303,22 @@ def test_pauli_error_on_a_mixed_stabilizer(pauli): assert [p_i for p_i, _ in mixed.rep_data.mixture] == [0.25, 0.75] for _, tableau in mixed.rep_data.mixture: assert tableau == pure.rep_data.tableau + + +def test_hadamard_perturbed_error_at_zero_perturbation_is_a_hadamard(): + noise = nm.HadamardPerturbedError(0, 0, 0) + + unitary = noise.noise_parameters["One-qubit unitary"] + + assert np.allclose(unitary, dmf.hadamard()) + assert noise.noise_parameters["Perturbation"] == (0, 0, 0) + assert noise.noise_parameters["Original parameters"] == (np.pi / 2, 0, np.pi) + + +def test_hadamard_perturbed_error_with_a_nonzero_perturbation_is_still_unitary(): + noise = nm.HadamardPerturbedError(0.1, 0.2, 0.3) + + unitary = noise.noise_parameters["One-qubit unitary"] + + assert np.allclose(unitary @ unitary.conj().T, np.eye(2)) + assert noise.noise_parameters["Perturbation"] == (0.1, 0.2, 0.3) diff --git a/tests/test_noise/test_time_depend_noise.py b/tests/test_noise/test_time_depend_noise.py index 43b52f4d..56602445 100644 --- a/tests/test_noise/test_time_depend_noise.py +++ b/tests/test_noise/test_time_depend_noise.py @@ -94,3 +94,35 @@ def test_parameters(error_rates, criteria): # the number of branches in each circuit tree should increase as cut off increases len_list = [len(x) for x in circ_tree_list] assert all(len_list[i] <= len_list[i + 1] for i in range(len(len_list) - 1)) + + +def test_noise_type_list_reg_specific_str_value(): + # a two-character string tells apart wrapping the whole string as one + # element ([noise_type]) from splitting it into characters (list(noise_type)) + noisy_ensemble = tdn.NoisyEnsemble( + None, + noisy_regs=[(0, "e")], + noise_parameters={"reg_specific_noise": {"e0": "xy"}}, + ) + assert noisy_ensemble.noise_type_list((0, "e")) == ["xy"] + + +def test_noise_type_list_reg_specific_list_value(): + noisy_ensemble = tdn.NoisyEnsemble( + None, + noisy_regs=[(0, "e")], + noise_parameters={"reg_specific_noise": {"e0": ["y", "z"]}}, + ) + assert noisy_ensemble.noise_type_list((0, "e")) == ["y", "z"] + + +def test_noise_type_list_falls_back_to_default_noise_type_str_value(): + # register "e1" has no entry in reg_specific_noise, so this reaches the + # fallback branch that reads noise_parameters["noise_type"] instead + noisy_ensemble = tdn.NoisyEnsemble( + None, + noisy_regs=[(1, "e")], + # two characters, so a wrapped string and a split string differ + noise_parameters={"noise_type": "xz"}, + ) + assert noisy_ensemble.noise_type_list((1, "e")) == ["xz"] From 69a9cd576841a87c21649eddf123adbe8803b4cb Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 02:02:24 -0400 Subject: [PATCH 078/227] [test] Cover graph state generators, the graph backend API and LC checks Structural tests for the generators in benchmarks/graph_states.py against independently built reference graphs; the node, edge and local-Clifford API of the graph backend and both MixedGraph setters, including their rejection arms; the height function on the 3-qubit linear cluster and both height_dict error arms; and is_lc_equivalent in deterministic and random mode, including a non-equivalent pair that reaches both searches, plus find_lc_operations checked by applying its output. --- tests/test_benchmarking/test_graph_states.py | 79 ++++++++++++ .../graph/test_graph_rep.py | 118 +++++++++++++++++- .../stabilizer/test_height_functions.py | 27 +++- .../test_graph_LC_equivalence.py | 66 ++++++++++ 4 files changed, 287 insertions(+), 3 deletions(-) diff --git a/tests/test_benchmarking/test_graph_states.py b/tests/test_benchmarking/test_graph_states.py index 47f56f04..a8c62c36 100644 --- a/tests/test_benchmarking/test_graph_states.py +++ b/tests/test_benchmarking/test_graph_states.py @@ -16,9 +16,38 @@ import numpy as np import pytest +from graphiq.backends.graph.state import Graph +from graphiq.backends.lc_equivalence_check import is_lc_equivalent from graphiq.benchmarks import graph_states as gs +def _build_reference_bi_repeater_graph(n): + """ + Hand-built reference for the biclique repeater graph state: a complete + bipartite graph (biclique) between n "left" inner qubits and n "right" + inner qubits, with exactly one pendant leaf qubit attached to each inner + qubit. Node labels are independent of the ones used by + gs.bi_repeater_graph_states; only the graph structure is compared. + """ + left = [f"left_{i}" for i in range(n)] + right = [f"right_{i}" for i in range(n)] + left_leaves = [f"left_leaf_{i}" for i in range(n)] + right_leaves = [f"right_leaf_{i}" for i in range(n)] + graph = nx.Graph() + graph.add_edges_from((left[i], left_leaves[i]) for i in range(n)) + graph.add_edges_from((right[i], right_leaves[i]) for i in range(n)) + graph.add_edges_from((left[i], right[j]) for i in range(n) for j in range(n)) + return graph + + +def test_bi_repeater_graph_states(): + n = 3 + graph = gs.bi_repeater_graph_states(n) + reference = _build_reference_bi_repeater_graph(n) + assert nx.is_connected(graph) + assert nx.is_isomorphic(graph, reference) + + @pytest.mark.parametrize("n_list", [[2, 3], [2, 3, 2], [3, 3, 3], [1, 2, 1]]) def test_crazy(n_list): graph = gs.crazy(n_list) @@ -42,3 +71,53 @@ def test_crazy(n_list): alternate_graph = gs.crazy(n_list, alternate_order=True) assert nx.is_isomorphic(graph, alternate_graph) + + +@pytest.mark.parametrize("row_col", [(2, 2), (2, 3), (3, 3), (3, 4)]) +def test_two_d_cluster(row_col): + graph = gs.two_d_cluster(row_col) + reference = nx.grid_2d_graph(*row_col) + assert nx.is_isomorphic(graph, reference) + + +@pytest.mark.parametrize("row_col_depth", [(2, 2, 2), (2, 3, 2), (3, 3, 3), (2, 3, 4)]) +def test_three_d_cluster(row_col_depth): + graph = gs.three_d_cluster(row_col_depth) + row, col, depth = row_col_depth + reference = nx.grid_graph([depth, col, row]) + assert nx.is_isomorphic(graph, reference) + + +@pytest.mark.parametrize("b_vector", [[2], [2, 3], [3, 2, 1]]) +def test_branching_tree(b_vector): + graph = gs.branching_tree(b_vector) + expected_nodes = 1 + running_product = 1 + for b in b_vector: + running_product *= b + expected_nodes += running_product + assert graph.number_of_nodes() == expected_nodes + assert nx.is_tree(graph) + + +def test_random_graph_state(): + state = gs.random_graph_state(6, 0.5, np_rng=np.random.default_rng(seed=42)) + assert isinstance(state, Graph) + graph = state.data + assert graph.number_of_nodes() == 6 + assert nx.is_connected(graph) + + state_again = gs.random_graph_state(6, 0.5, np_rng=np.random.default_rng(seed=42)) + assert nx.utils.graphs_equal(graph, state_again.data) + + +def test_random_lc_equivalent(): + start_graph = Graph(nx.path_graph(5)) + new_graphs, _ = gs.random_lc_equivalent( + start_graph, 4, 3, np_rng=np.random.default_rng(seed=7) + ) + start_adj = nx.to_numpy_array(start_graph.data).astype(int) + for new_graph in new_graphs: + new_adj = nx.to_numpy_array(new_graph.data).astype(int) + equivalent, _ = is_lc_equivalent(start_adj, new_adj, mode="deterministic") + assert equivalent diff --git a/tests/test_state_representation/graph/test_graph_rep.py b/tests/test_state_representation/graph/test_graph_rep.py index 47344eaa..d00ba370 100644 --- a/tests/test_state_representation/graph/test_graph_rep.py +++ b/tests/test_state_representation/graph/test_graph_rep.py @@ -12,8 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from graphiq.backends.graph.state import Graph, MixedGraph import networkx as nx +import pytest + +import graphiq.circuit.ops as ops +from graphiq.backends.graph.state import Graph, MixedGraph def test_graph_initialization(): @@ -42,4 +45,115 @@ def test_mixed_graph(): state3 = MixedGraph([(0.5, state1), (0.5, state2)]) print(state3.mixture) assert state3.n_qubits == 4 - assert state3.probability == 1.0 \ No newline at end of file + assert state3.probability == 1.0 + + +def test_find_lc_missing_node_raises(): + state = Graph(nx.Graph([(0, 1)])) + with pytest.raises(ValueError): + state.find_lc(5) + + +def test_update_lc_missing_node_raises(): + state = Graph(nx.Graph([(0, 1)])) + with pytest.raises(ValueError): + state.update_lc(5, ["h"]) + + +def test_update_lc_sets_gate_and_resets_to_identity(): + state = Graph(nx.Graph([(0, 1)])) + assert state.find_lc(0) == [ops.Identity] + state.update_lc(0, ["h"]) + assert state.find_lc(0) != [ops.Identity] + state.update_lc(0, None) + assert state.find_lc(0) == [ops.Identity] + + +def test_add_node_new_node_and_existing_node_is_noop(): + state = Graph(nx.Graph([(0, 1)])) + state.add_node(2) + assert 2 in state.get_nodes() + assert state.find_lc(2) == [ops.Identity] + + # a node already present is left untouched, including its local Clifford + state.update_lc(2, ["h"]) + state.add_node(2) + assert state.find_lc(2) != [ops.Identity] + + +def test_add_node_with_explicit_lc_gate_stores_it(): + state = Graph(nx.Graph([(0, 1)])) + state.add_node(3, ["h"]) + assert 3 in state.get_nodes() + assert state.find_lc(3) == ops.simplify_local_clifford([ops.name_to_class_map("h")]) + + +def test_add_edge_creates_missing_second_endpoint(): + state = Graph(nx.Graph([(0, 1)])) + state.add_edge(1, 5) + assert 5 in state.get_nodes() + assert {1, 5} in [set(e) for e in state.get_edges()] + + +def test_add_edge_creates_missing_first_endpoint(): + state = Graph(nx.Graph([(0, 1)])) + state.add_edge(5, 1) + assert 5 in state.get_nodes() + assert state.find_lc(5) == [ops.Identity] + assert {5, 1} in [set(e) for e in state.get_edges()] + + +def test_get_edges_and_get_nodes(): + state = Graph(nx.Graph([(0, 1), (1, 2)])) + assert set(state.get_nodes()) == {0, 1, 2} + assert {frozenset(e) for e in state.get_edges()} == { + frozenset((0, 1)), + frozenset((1, 2)), + } + + +def test_get_neighbors_missing_node_raises(): + state = Graph(nx.Graph([(0, 1)])) + with pytest.raises(ValueError): + state.get_neighbors(99) + + +def test_mixed_graph_constructor_rejects_bare_nx_graph(): + with pytest.raises(TypeError): + MixedGraph(nx.Graph([(0, 1)])) + + +def test_mixed_graph_mixture_setter_rejects_bare_nx_graph(): + graph1 = Graph(nx.Graph([(0, 1)])) + state = MixedGraph(graph1) + with pytest.raises(TypeError): + state.mixture = nx.Graph([(0, 1)]) + + +def test_mixed_graph_rejects_list_of_bare_nx_graph(): + with pytest.raises(AssertionError): + MixedGraph([(1.0, nx.Graph([(0, 1)]))]) + + +def test_mixed_graph_mixture_setter_list(): + graph1 = Graph(nx.Graph([(0, 1)])) + graph2 = Graph(nx.Graph([(0, 1)])) + state = MixedGraph(graph1) + state.mixture = [(0.5, graph1), (0.5, graph2)] + assert state.mixture == [(0.5, graph1), (0.5, graph2)] + + +def test_mixed_graph_mixture_setter_single_graph(): + graph1 = Graph(nx.Graph([(0, 1)])) + graph2 = Graph(nx.Graph([(0, 1), (1, 2)])) + state = MixedGraph(graph1) + state.mixture = graph2 + assert state.mixture == [(1.0, graph2)] + + +def test_mixed_graph_data_setter_delegates_to_mixture(): + graph1 = Graph(nx.Graph([(0, 1)])) + graph2 = Graph(nx.Graph([(0, 1)])) + state = MixedGraph(graph1) + state.data = [(0.5, graph1), (0.5, graph2)] + assert state.data == [(0.5, graph1), (0.5, graph2)] diff --git a/tests/test_state_representation/stabilizer/test_height_functions.py b/tests/test_state_representation/stabilizer/test_height_functions.py index c59191a6..db2f24b4 100644 --- a/tests/test_state_representation/stabilizer/test_height_functions.py +++ b/tests/test_state_representation/stabilizer/test_height_functions.py @@ -12,10 +12,15 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import networkx as nx import numpy as np import pytest -from graphiq.backends.stabilizer.functions.height import height_func_list +from graphiq.backends.stabilizer.functions.height import ( + height_dict, + height_func_list, + height_function, +) from graphiq.backends.stabilizer.functions.stabilizer import rref from graphiq.backends.stabilizer.tableau import StabilizerTableau @@ -171,3 +176,23 @@ def test_paper_example(): assert (height_list_2 == height_list_expected).all() assert (tableau.x_matrix == x_matrix_expected).all() assert (tableau.z_matrix == z_matrix_expected).all() + + +@pytest.mark.parametrize("qubit_position, expected", [(0, 1), (1, 1), (2, 0)]) +def test_height_function_linear_cluster_3qubit(qubit_position, expected): + # the 3-qubit linear cluster state: x is the identity, z is path_graph(3)'s adjacency matrix + x_matrix = np.eye(3) + z_matrix = nx.to_numpy_array(nx.path_graph(3)) + assert height_function(x_matrix, z_matrix, qubit_position) == expected + + +def test_height_dict_graph_arg_must_be_networkx_graph(): + # a truthy value that is not a networkx graph is rejected explicitly + with pytest.raises(ValueError, match="valid networkx graph"): + height_dict(graph="not a graph") + + +def test_height_dict_needs_graph_or_matrices(): + # an empty (falsy) graph argument falls through to the "neither" branch + with pytest.raises(ValueError, match="Either a graph or both"): + height_dict(graph=[]) diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index b142b0fe..119c9042 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -17,6 +17,7 @@ from graphiq.benchmarks import graph_states as gs from graphiq.backends.lc_equivalence_check import * +from graphiq.backends.lc_equivalence_check import _is_valid_clifford from graphiq.backends.stabilizer.functions.local_cliff_equi_check import ( lc_check, state_converter_circuit, @@ -187,3 +188,68 @@ def test_state_to_graph_matrix_input_matches_graph_input(): graph, tableau, gate_list = state_to_graph(nx.to_numpy_array(star4)) assert isinstance(tableau, StabilizerTableau) assert gate_list == [] + + +@pytest.mark.parametrize("mode", ["deterministic", "random"]) +def test_is_lc_equivalent_star4_and_k4(mode): + # a 4-node star and the complete graph on 4 nodes are LC equivalent + star4 = nx.to_numpy_array(nx.star_graph(3)).astype(int) + k4 = nx.to_numpy_array(nx.complete_graph(4)).astype(int) + + equivalent, solution = is_lc_equivalent(star4, k4, mode=mode, seed=0) + + assert equivalent + n_nodes = star4.shape[0] + assert _is_valid_clifford(solution.reshape(4 * n_nodes, 1)) + + +def test_is_lc_equivalent_p4_and_its_complement_are_not_equivalent(): + # P4 and its complement are ruled out by the rank check in is_lc_equivalent + # (the coefficient matrix has rank 4*n_nodes) before either search mode is + # read, so this pair does not exercise the "deterministic"/"random" dispatch. + p4 = nx.to_numpy_array(nx.path_graph(4)).astype(int) + p4_complement = nx.to_numpy_array(nx.complement(nx.path_graph(4))).astype(int) + + equivalent, solution = is_lc_equivalent( + p4, p4_complement, mode="deterministic", seed=0 + ) + + assert not equivalent + assert solution is None + + +@pytest.mark.parametrize("mode", ["deterministic", "random"]) +def test_is_lc_equivalent_star5_and_star5_plus_edge_are_not_equivalent(mode): + # star5 and star5 with one extra edge among its leaves pass the rank check + # (rank 15 < 4*n_nodes = 20) with a solution-basis dimension of 5, so the + # "deterministic"/"random" dispatch in is_lc_equivalent is actually reached, + # and both searches correctly reject. + star5 = nx.to_numpy_array(nx.star_graph(4)).astype(int) + star5_plus_edge_graph = nx.star_graph(4) + star5_plus_edge_graph.add_edge(1, 2) + star5_plus_edge = nx.to_numpy_array(star5_plus_edge_graph).astype(int) + + equivalent, solution = is_lc_equivalent(star5, star5_plus_edge, mode=mode, seed=0) + + assert not equivalent + assert solution is None + + +def test_find_lc_operations_recovers_first_graph_from_second(): + # These two 5-node graphs are LC equivalent via a multi-step local + # complementation sequence (not a single node, unlike star4/k4), which is + # needed to tell apart which of the two adjacency matrices the sequence + # is built from. + g1 = nx.Graph([(0, 1), (0, 2), (0, 3), (0, 4), (1, 2), (1, 3), (2, 4)]) + g2 = nx.Graph([(0, 1), (0, 2), (1, 3), (2, 4), (3, 4)]) + adj_matrix1 = nx.to_numpy_array(g1).astype(int) + adj_matrix2 = nx.to_numpy_array(g2).astype(int) + + op_list = find_lc_operations(adj_matrix1, adj_matrix2, mode="deterministic", seed=0) + + # applying the returned local complementations to the second graph should + # turn it into the first graph + g = nx.from_numpy_array(adj_matrix2) + for node in op_list: + g = local_comp_graph(g, node) + assert np.array_equal(nx.to_numpy_array(g).astype(int), adj_matrix1) From e268d5727db85dd4283c8aad22a64e8d256ec39b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 02:02:24 -0400 Subject: [PATCH 079/227] [test] Cover IO save/load round trips and SolverResult helpers Test IO path handling, the new_directory warning, save/load round trips for JSON, text, DataFrame and NumPy arrays, figure saving and the verbose prints, plus SolverResult.sort_by, add_properties input validation and __str__. --- tests/test_utils/test_io.py | 77 ++++++++++++++++++++++++++ tests/test_utils/test_solver_result.py | 36 ++++++++++++ 2 files changed, 113 insertions(+) diff --git a/tests/test_utils/test_io.py b/tests/test_utils/test_io.py index c74decb9..0b1933c1 100644 --- a/tests/test_utils/test_io.py +++ b/tests/test_utils/test_io.py @@ -14,11 +14,88 @@ # limitations under the License. import datetime +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd import pytest from graphiq.io import IO +def test_io_init_no_path_uses_default_path(): + io = IO(verbose=False) + assert io.path == IO.default_path + + +def test_io_init_str_path_is_converted_to_path(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + assert io.path == tmp_path + + +def test_new_directory_does_not_touch_filesystem(tmp_path): + io = IO.new_directory(path=tmp_path, folder="my_folder", verbose=False) + assert not io.path.exists() + + +def test_new_directory_warns_only_when_folder_is_falsy(tmp_path, recwarn): + IO.new_directory(path=tmp_path, folder="my_folder", verbose=False) + assert len(recwarn.list) == 0 + + IO.new_directory(path=tmp_path, folder="", verbose=False) + assert len(recwarn.list) == 1 + assert "No folder entered" in str(recwarn.list[0].message) + + +def test_save_and_load_json_round_trip(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + data = {"a": 1, "b": [1, 2, 3]} + io.save_json(data, "data.json") + assert io.load_json("data.json") == data + + +def test_save_and_load_txt_round_trip(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + io.save_txt("hello world", "data.txt") + assert io.load_txt("data.txt") == "hello world" + + +def test_save_and_load_dataframe_round_trip(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + df = pd.DataFrame({"x": [1, 2], "y": [3, 4]}) + io.save_dataframe(df, "data.csv") + loaded = io.load_dataframe("data.csv") + pd.testing.assert_frame_equal(loaded, df) + + +def test_save_and_load_np_array_round_trip(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + arr = np.array([1.0, 2.0, 3.0]) + io.save_np_array(arr, "data.txt") + loaded = io.load_np_array("data.txt") + assert np.allclose(loaded, arr) + + +def test_save_figure_writes_file(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + io.save_figure(fig, "figure.png") + assert (tmp_path / "figure.png").exists() + plt.close(fig) + + +def test_verbose_save_and_load_prints_to_terminal(tmp_path, capsys): + io = IO(path=str(tmp_path), verbose=True) + data = {"a": 1} + io.save_json(data, "data.json") + saved_output = capsys.readouterr().out + assert f"Saved to {tmp_path / 'data.json'}" in saved_output + + io.load_json("data.json") + loaded_output = capsys.readouterr().out + assert f"Loaded from {tmp_path / 'data.json'}" in loaded_output + + def test_new_directory_with_empty_folder_warns_and_dates_the_path(tmp_path): # An empty folder name should fall back to a unique, dated folder name # and warn the caller that it did so. new_directory only builds a path diff --git a/tests/test_utils/test_solver_result.py b/tests/test_utils/test_solver_result.py index 1fbca57c..ac2e0b93 100644 --- a/tests/test_utils/test_solver_result.py +++ b/tests/test_utils/test_solver_result.py @@ -122,3 +122,39 @@ def test_load_json_round_trips_saved_columns(tmp_path): loaded_missing.load_json(tmp_path, "res") assert len(rec) == 2 + + +def test_sort_by(): + circuit_1, _ = ghz3_state_circuit() + circuit_2, _ = linear_cluster_3qubit_circuit() + circuit_3, _ = ghz4_state_circuit() + circuit_list = [circuit_1, circuit_2, circuit_3] + + result = SolverResult(circuit_list) + result["score"] = [3, 1, 2] + result.sort_by("score") + + assert result["score"] == [1, 2, 3] + assert result["circuit"] == [circuit_2, circuit_3, circuit_1] + + +def test_add_properties_rejects_non_string(): + circuit_1, _ = ghz3_state_circuit() + result = SolverResult([circuit_1]) + + with pytest.raises(AssertionError): + result.add_properties(123) + + +def test_str_contains_circuit_id_and_property_value_but_no_column_names(): + circuit_1, _ = ghz3_state_circuit() + result = SolverResult([circuit_1]) + result.add_properties("my_test_property") + result["my_test_property"] = ["some_value_xyz"] + + printed = str(result) + + assert "c0" in printed + assert "some_value_xyz" in printed + assert "my_test_property" not in printed + assert "circuit_id" not in printed From 06ccf78a84e93ed49d14eebd8f7ee92f48552e99 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 02:02:24 -0400 Subject: [PATCH 080/227] [test] Cover the Clifford tableau's generator-string accessors Test how a fresh CliffordTableau renders as labels, the label round trip through stabilizer_from_labels/destabilizer_from_labels, that each setter only writes its own half and leaves the phase vectors untouched, and that __str__ renders both halves. --- .../test_clifford_tableau_labels.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py diff --git a/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py b/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py new file mode 100644 index 00000000..e20e98ca --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py @@ -0,0 +1,82 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +The generator-string view of a Clifford tableau. + +``stabilizer_to_labels`` and ``destabilizer_to_labels`` render half the +tableau as Pauli strings; ``stabilizer_from_labels`` and +``destabilizer_from_labels`` set that half from them. The strings carry no +sign -- the phase and i-phase vectors are separate and the setters leave them +alone -- so a label round trip preserves the symplectic half only, and a +caller restoring a signed state has to carry the phase itself. +""" + +import numpy as np + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau + + +def test_a_fresh_tableau_renders_as_the_all_zero_state(): + # |00> is stabilized by ZI and IZ, with XI and IX as the destabilizers + # pairing with them by row index. + tableau = CliffordTableau(2) + + assert tableau.destabilizer_to_labels() == ["XI", "IX"] + assert tableau.stabilizer_to_labels() == ["ZI", "IZ"] + + +def test_labels_round_trip_through_the_setters(): + tableau = CliffordTableau(2) + + tableau.stabilizer_from_labels(["XX", "ZZ"]) + tableau.destabilizer_from_labels(["ZI", "IX"]) + + assert tableau.stabilizer_to_labels() == ["XX", "ZZ"] + assert tableau.destabilizer_to_labels() == ["ZI", "IX"] + + +def test_the_setters_write_only_their_own_half(): + tableau = CliffordTableau(2) + destabilizers_before = tableau.destabilizer_to_labels() + + tableau.stabilizer_from_labels(["XX", "ZZ"]) + + assert tableau.destabilizer_to_labels() == destabilizers_before + + +def test_the_setters_leave_the_phase_vectors_alone(): + # The strings carry no sign, so the setters cannot restore one. A caller + # rebuilding a signed state has to set the phase itself; this pins that + # the setters do not quietly zero or invent one. + tableau = CliffordTableau(2) + tableau.phase[2] = 1 + tableau.iphase[0] = 1 + + tableau.stabilizer_from_labels(["XX", "ZZ"]) + tableau.destabilizer_from_labels(["ZI", "IX"]) + + assert np.array_equal(tableau.phase, [0, 0, 1, 0]) + assert np.array_equal(tableau.iphase, [1, 0, 0, 0]) + + +def test_a_tableau_renders_both_halves_and_both_phases(): + # __str__ is the only caller of the label getters in the library, so this + # is what keeps that path from breaking unnoticed. + rendered = str(CliffordTableau(1)) + + assert "Destabilizers" in rendered + assert "Stabilizer" in rendered + assert "['X']" in rendered + assert "['Z']" in rendered From 858237aea7d90a5698b906850f816eeb28c6f0d2 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 02:02:24 -0400 Subject: [PATCH 081/227] [test] Add tests for the metrics module Add tests/test_metrics covering the circuit-structure metrics (CircuitDepth, CircuitEmitterCount, CircuitCnotCount, CircuitUnitaryCount, CircuitMeasureCount and the CircuitMaxEmit*Depth family, including a circuit where the effective and reset depths diverge), GraphMetric and graph_met_value, the Metrics wrapper's weighting and logging, Infidelity on a converted mixed state, and TraceDistance. --- tests/test_metrics/__init__.py | 14 + tests/test_metrics/test_metrics.py | 431 +++++++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 tests/test_metrics/__init__.py create mode 100644 tests/test_metrics/test_metrics.py diff --git a/tests/test_metrics/__init__.py b/tests/test_metrics/__init__.py new file mode 100644 index 00000000..fd1ce610 --- /dev/null +++ b/tests/test_metrics/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py new file mode 100644 index 00000000..00ca6cde --- /dev/null +++ b/tests/test_metrics/test_metrics.py @@ -0,0 +1,431 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests for graphiq/metrics.py: the circuit-structure metrics, GraphMetric, the +Metrics wrapper, the Infidelity branch that converts a non-stabilizer state into +a (possibly mixed) stabilizer representation, and TraceDistance. +""" + +import networkx as nx +import numpy as np +import pytest + +import graphiq.backends.density_matrix.functions as dmf +import graphiq.circuit.ops as ops +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.state import QuantumState +from graphiq.metrics import ( + CircuitCnotCount, + CircuitDepth, + CircuitEmitterCount, + CircuitMaxEmitDepth, + CircuitMaxEmitEffDepth, + CircuitMaxEmitResetDepth, + CircuitMeasureCount, + CircuitUnitaryCount, + GraphMetric, + Infidelity, + Metrics, + TraceDistance, + graph_met_value, +) + + +def _emitter_circuit(n_emitter, n_photon=0, n_classical=0): + return CircuitDAG(n_emitter=n_emitter, n_photon=n_photon, n_classical=n_classical) + + +def test_circuit_depth_default_penalty_returns_depth(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + assert CircuitDepth().evaluate(None, circuit) == circuit.depth + + +def test_circuit_depth_custom_penalty_applied(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + metric = CircuitDepth(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, circuit) == circuit.depth + 100 + + +def test_circuit_emitter_count_default_penalty_returns_count(): + circuit = _emitter_circuit(3) + assert CircuitEmitterCount().evaluate(None, circuit) == 3 + + +def test_circuit_emitter_count_custom_penalty_applied(): + circuit = _emitter_circuit(3) + metric = CircuitEmitterCount(n_emitter_penalty=lambda n: 10 * n) + assert metric.evaluate(None, circuit) == 30 + + +# The following CircuitCnotCount tests are regression coverage for the +# CircuitCnotCount.__init__ bug, where the default-penalty branch set +# self.n_emitter_penalty instead of self.n_cnot_penalty, so evaluate() raised +# AttributeError whenever n_cnot_penalty was left at its default. + + +def test_circuit_cnot_count_no_cnots_default_penalty(): + circuit = _emitter_circuit(2) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + assert CircuitCnotCount().evaluate(None, circuit) == 0 + + +def test_circuit_cnot_count_counts_cnots_with_default_penalty(): + circuit = _emitter_circuit(2) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + circuit.add(ops.CNOT(control=1, control_type="e", target=0, target_type="e")) + assert CircuitCnotCount().evaluate(None, circuit) == 2 + + +def test_circuit_cnot_count_custom_penalty_applied(): + circuit = _emitter_circuit(2) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + metric = CircuitCnotCount(n_cnot_penalty=lambda n: 10 * n) + assert metric.evaluate(None, circuit) == 10 + + +# The following CircuitUnitaryCount tests are regression coverage for the +# CircuitUnitaryCount bug, where the gate label list checked "SigmaX" three +# times instead of "SigmaX", "SigmaY", "SigmaZ", so SigmaY/SigmaZ gates were +# never counted. + + +def test_circuit_unitary_count_counts_sigma_y_and_z(): + circuit = _emitter_circuit(1) + circuit.add(ops.SigmaY(register=0, reg_type="e")) + circuit.add(ops.SigmaZ(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Phase(register=0, reg_type="e")) + circuit.add(ops.PhaseDagger(register=0, reg_type="e")) + # SigmaY, SigmaZ, Hadamard, Phase, PhaseDagger: 5 unitaries, no SigmaX + # present at all. The pre-fix code only ever checked "SigmaX" (thrice) so + # it would report 3 (Hadamard + Phase + PhaseDagger) here. + assert CircuitUnitaryCount().evaluate(None, circuit) == 5 + + +def test_circuit_unitary_count_counts_all_gate_types_once_each(): + circuit = _emitter_circuit(2) + circuit.add(ops.SigmaX(register=0, reg_type="e")) + circuit.add(ops.SigmaY(register=0, reg_type="e")) + circuit.add(ops.SigmaZ(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Phase(register=0, reg_type="e")) + circuit.add(ops.PhaseDagger(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + assert CircuitUnitaryCount().evaluate(None, circuit) == 7 + + +def test_circuit_measure_count_counts_measurement_and_reset_ops(): + circuit = _emitter_circuit(1, n_photon=2, n_classical=1) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=1, target_type="p", c_register=0 + ) + ) + assert CircuitMeasureCount().evaluate(None, circuit) == 2 + + +def test_circuit_measure_count_custom_penalty_applied(): + circuit = _emitter_circuit(1, n_photon=1, n_classical=1) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + metric = CircuitMeasureCount(m_penalty=lambda n: n + 5) + assert metric.evaluate(None, circuit) == 6 + + +# CircuitMaxEmitDepth, CircuitMaxEmitResetDepth, and CircuitMaxEmitEffDepth +# rely on undocumented CircuitDAG internals (reg_gate_history, _max_depth). +# The expected values below were derived by constructing each circuit and printing +# reg_gate_history/_max_depth/the metric's own output directly, rather than +# by hand-tracing the DAG, to avoid the test itself encoding a wrong +# understanding of these internals. + + +def test_circuit_max_emit_depth_default_penalty_is_max_across_emitters(): + circuit = _emitter_circuit(2) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + # emitter 0 has 1 gate node, emitter 1 has 3 -> max is 3 + assert CircuitMaxEmitDepth().evaluate(None, circuit) == 3 + + +def test_circuit_max_emit_depth_no_gates_is_zero(): + circuit = _emitter_circuit(1) + assert CircuitMaxEmitDepth().evaluate(None, circuit) == 0 + + +def test_circuit_max_emit_depth_custom_penalty_applied(): + circuit = _emitter_circuit(2) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + metric = CircuitMaxEmitDepth(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, circuit) == 103 + + +def _reset_circuit(): + # A single emitter measured-and-reset twice, with photons/classical + # register interleaved: Input, H, H, M&R, H, M&R, Output. The longest + # gap between consecutive Input/M&R/Output markers is 3 nodes (Input to + # the first M&R). + circuit = _emitter_circuit(1, n_photon=2, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=1, target_type="p", c_register=0 + ) + ) + return circuit + + +def test_circuit_max_emit_reset_depth_default_penalty(): + circuit = _reset_circuit() + assert CircuitMaxEmitResetDepth().evaluate(None, circuit) == 3 + + +def test_circuit_max_emit_reset_depth_custom_penalty_applied(): + circuit = _reset_circuit() + metric = CircuitMaxEmitResetDepth(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, circuit) == 103 + + +def test_circuit_max_emit_eff_depth_matches_reset_depth_without_branching(): + # With no two-qubit gates, DAG depth along a single emitter's own + # chronological chain increases by exactly 1 per node, so + # CircuitMaxEmitEffDepth reduces to the same value as + # CircuitMaxEmitResetDepth on this circuit. + circuit = _reset_circuit() + assert CircuitMaxEmitEffDepth().evaluate(None, circuit) == 3 + + +def test_circuit_max_emit_eff_depth_custom_penalty_applied(): + circuit = _reset_circuit() + metric = CircuitMaxEmitEffDepth(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, circuit) == 103 + + +def test_circuit_max_emit_eff_depth_diverges_from_reset_depth_with_cross_register_depth(): + # CircuitMaxEmitResetDepth counts nodes along each emitter's own chain, + # blind to what CNOTs bring in from other registers; CircuitMaxEmitEffDepth + # uses the DAG's true topological depth (_max_depth), which does pick up + # that cross-register depth. Chain e2 (5 Hadamards) -> CNOT into e1 (short + # chain) -> CNOT into e0 (short chain, then 2 measure-and-resets): e2's own + # depth injects into e1 and then e0, inflating e0/e1's effective depth well + # past their own node counts, while e2's own long chain still dominates the + # node-count-based CircuitMaxEmitResetDepth. + circuit = _emitter_circuit(3, n_photon=2, n_classical=1) + for _ in range(5): + circuit.add(ops.Hadamard(register=2, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.CNOT(control=2, control_type="e", target=1, target_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=1, control_type="e", target=0, target_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=1, target_type="p", c_register=0 + ) + ) + + assert CircuitMaxEmitResetDepth().evaluate(None, circuit) == 7 + assert CircuitMaxEmitEffDepth().evaluate(None, circuit) == 8 + + +@pytest.fixture +def path_graph(): + # 0 - 1 - 2 - 3, a simple, hand-checkable graph + return nx.path_graph(4) + + +def test_graph_met_value_node_and_edge_counts(path_graph): + assert graph_met_value("node", path_graph) == 4 + assert graph_met_value("n_edges", path_graph) == 3 + + +def test_graph_met_value_max_degree(path_graph): + # both middle nodes (1 and 2) have degree 2 + assert graph_met_value("max_deg", path_graph) == 2 + + +def test_graph_met_value_radius_and_diameter(path_graph): + assert graph_met_value("radius", path_graph) == 2 + assert graph_met_value("diameter", path_graph) == 3 + + +def test_graph_met_value_pop_is_edge_density(path_graph): + # density of a path graph on 4 nodes: 3 edges / C(4, 2) = 3 / 6 = 0.5 + assert graph_met_value("pop", path_graph) == pytest.approx(0.5) + + +def test_graph_met_value_unknown_metric_raises(path_graph): + with pytest.raises(ValueError): + graph_met_value("not_a_real_metric", path_graph) + + +def test_graph_metric_evaluate_default_penalty(path_graph): + metric = GraphMetric(path_graph) + assert metric.evaluate("node") == 4 + + +def test_graph_metric_evaluate_custom_penalty(path_graph): + metric = GraphMetric(path_graph, penalty=lambda x: x * 2) + assert metric.evaluate("n_edges") == 6 + + +# The following Metrics-wrapper tests stick to CircuitDepth, with distinct +# penalty functions to get distinguishable per-metric values. + + +def test_metrics_default_equal_weighting(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + depth = circuit.depth + metrics = Metrics( + [ + CircuitDepth(depth_penalty=lambda d: d), + CircuitDepth(depth_penalty=lambda d: d + 1), + ] + ) + assert metrics.evaluate(None, circuit) == depth + (depth + 1) + + +def test_metrics_list_weighting(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + depth = circuit.depth + metrics = Metrics( + [ + CircuitDepth(depth_penalty=lambda d: d), + CircuitDepth(depth_penalty=lambda d: d + 1), + ], + metric_weight=[1, 10], + ) + assert metrics.evaluate(None, circuit) == 1 * depth + 10 * (depth + 1) + + +def test_metrics_callable_weighting(): + circuit = _emitter_circuit(1) + metrics = Metrics( + [CircuitDepth()], + metric_weight=lambda state, circuit: 99, + ) + assert metrics.evaluate(None, circuit) == 99 + + +def test_metrics_per_metric_log(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + metrics = Metrics([CircuitDepth()]) + metrics.evaluate(None, circuit) + log = metrics.per_metric_log + assert log["CircuitDepth"] == [circuit.depth] + + +def test_metrics_string_metric_name(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + metrics = Metrics(["CircuitDepth"]) + assert metrics.evaluate(None, circuit) == circuit.depth + + +def test_metrics_unrecognized_metric_raises(): + with pytest.raises(UserWarning): + Metrics(["NotARealMetric"]) + + +def test_infidelity_graph_state_marked_mixed_matches_stabilizer_target(): + """Regression coverage for the Infidelity.evaluate bug where the mixed- + stabilizer branch checked `isinstance(state.rep_data, MixedStabilizer)` + instead of `isinstance(rep_data, MixedStabilizer)`. When the target is + rep_type "s" but the evaluated state is not, rep_data comes from a + converted copy, and state.rep_data (the original, un-converted dm/graph + data) never matches -- so `fid` was left unassigned and evaluate() raised + UnboundLocalError. + + Builds the |+> state two ways: once as a graph representation (a single + isolated node, no edges) marked mixed=True -- forcing the "g"->"s" + conversion this Infidelity branch performs to wrap its result in a + MixedStabilizer (a single (1.0, tableau) entry) -- and once as a genuine + Stabilizer state via a Hadamard-only circuit, used as the target. The + graph->stabilizer conversion is an exact, unambiguous transform, unlike + the density-matrix route, which goes through an eigendecomposition. + """ + graph = nx.Graph() + graph.add_node(0) + state = QuantumState(data=graph, rep_type="g", mixed=True) + + target_circuit = _emitter_circuit(1) + target_circuit.add(ops.Hadamard(register=0, reg_type="e")) + target = StabilizerCompiler().compile(target_circuit) + + infidelity = Infidelity(target=target).evaluate(state, None) + + assert infidelity == pytest.approx(0.0, abs=1e-9) + + +def test_trace_distance_stabilizer_state_against_its_own_density_matrix_is_zero(): + # TraceDistance.evaluate only compares density matrices; a stabilizer-represented state is + # converted to "dm" on the fly (a copy, so the original state is left untouched). + state = QuantumState(1, rep_type="s") + target = QuantumState(1, rep_type="s") + target.convert_representation("dm") + + trace_distance = TraceDistance(target=target).evaluate(state, None) + + assert trace_distance == pytest.approx(0.0, abs=1e-9) + + +def test_trace_distance_stabilizer_ket0_against_density_matrix_ket1_is_one(): + state = QuantumState(1, rep_type="s") # |0> + target = QuantumState(dmf.ket2dm(dmf.state_ketz1()), rep_type="dm") # |1> + + trace_distance = TraceDistance(target=target).evaluate(state, None) + + assert trace_distance == pytest.approx(1.0, abs=1e-9) + + +def test_trace_distance_raises_when_target_is_not_a_density_matrix(): + # The check is keyed on the target's rep_type, not the evaluated state's. + state = QuantumState(1, rep_type="s") + target = QuantumState(1, rep_type="s") + + with pytest.raises(ValueError, match="Cannot compute the trace distance"): + TraceDistance(target=target).evaluate(state, None) From 3dd4771f54b9b2438fa9088ae1c232d5354bec73 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 09:55:59 -0400 Subject: [PATCH 082/227] [fix] Contract each traced qubit in density-matrix partial_trace dmf.partial_trace built its einsum subscript with distinct letters for the row and column index of every qubit, e.g. "abAB->aA". For a traced qubit that sums rows and columns independently instead of along the diagonal, so the result was a partial sum, not a partial trace: on a Bell pair it returned |+><+| instead of I/2. It was only correct when the traced qubit was in a computational-basis product state. The column subscript now reuses the row letter for traced qubits. This changes results wherever a density-matrix state is traced over qubits still entangled with the rest, including the solvers' scoring after emitters are traced out. --- graphiq/backends/density_matrix/functions.py | 12 ++++-- .../test_density_matrix_functions.py | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/graphiq/backends/density_matrix/functions.py b/graphiq/backends/density_matrix/functions.py index 58632237..e3c20807 100644 --- a/graphiq/backends/density_matrix/functions.py +++ b/graphiq/backends/density_matrix/functions.py @@ -521,9 +521,15 @@ def partial_trace(rho, keep, dims, optimize=False): ndim = dims.size nkeep = np.prod(dims[keep]) - # string for initial array dimensions of form "abc...ABC...", where upper/lowercase = local Hilbert space + # string for initial array dimensions: lowercase letters index the rows, and the + # columns reuse the row letter for a traced qubit and take its uppercase letter + # for a kept one. A shared letter makes einsum sum the diagonal of that qubit, + # which is the trace; distinct letters would sum rows and columns independently. ssleft = "".join([string.ascii_lowercase[i] for i in range(ndim)]) + "".join( - [string.ascii_uppercase[i] for i in range(ndim)] + [ + string.ascii_uppercase[i] if i in keep else string.ascii_lowercase[i] + for i in range(ndim) + ] ) # string for final array dimensions is the same as initial, with upper/lowercase of dimensions to trace over omitted @@ -531,7 +537,7 @@ def partial_trace(rho, keep, dims, optimize=False): [string.ascii_lowercase[i] for i in range(ndim) if i in keep] ) + "".join([string.ascii_uppercase[i] for i in range(ndim) if i in keep]) - # e.g., "abcABC -> abAB" is partial trace over third qubit in three-qubit state + # e.g., "abcABc -> abAB" is partial trace over third qubit in three-qubit state superscript = ssleft + "->" + ssright rho_a = rho.reshape(np.tile(dims, 2)) diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py index 435bcbcd..b36677d4 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py @@ -141,3 +141,42 @@ def test_trace_out_qubit_of_a_product_state_leaves_the_untraced_factor(): traced = trace_out_qubit(rho, 1) assert np.allclose(traced, ket2dm(state_ketz0())) + + +def test_partial_trace_of_a_bell_pair_is_maximally_mixed(): + # Tracing one half of |00> + |11> must remove the coherence between |0> and + # |1>. Summing the traced qubit's row and column indices separately instead of + # along its diagonal leaves |+><+| here, off-diagonals of 0.5 instead of 0. + bell = np.array([1, 0, 0, 1]) / np.sqrt(2) + rho = np.outer(bell, bell.conj()) + + for keep in ([0], [1]): + assert np.allclose(partial_trace(rho, keep, [2, 2]), np.eye(2) / 2) + + +@pytest.mark.parametrize("keep", [[0], [1], [2], [0, 1], [0, 2], [1, 2]]) +def test_partial_trace_matches_an_explicit_sum_over_traced_basis_states(keep): + # Reference: sum over the traced qubits' basis states of , built + # with explicit projectors, on a random entangled three-qubit state. + rng = np.random.default_rng(7) + psi = rng.normal(size=8) + 1j * rng.normal(size=8) + psi /= np.linalg.norm(psi) + rho = np.outer(psi, psi.conj()) + + traced = [q for q in range(3) if q not in keep] + expected = np.zeros((2 ** len(keep), 2 ** len(keep)), dtype=complex) + for bits in np.ndindex(*(2,) * len(traced)): + # a (8 x 2**len(keep)) isometry picking out the traced qubits' values + iso = np.zeros((8, 2 ** len(keep))) + for kept_bits in np.ndindex(*(2,) * len(keep)): + full = [0, 0, 0] + for q, b in zip(traced, bits): + full[q] = b + for q, b in zip(keep, kept_bits): + full[q] = b + row = full[0] * 4 + full[1] * 2 + full[2] + col = int("".join(map(str, kept_bits)), 2) if keep else 0 + iso[row, col] = 1 + expected += iso.T @ rho @ iso + + assert np.allclose(partial_trace(rho, keep, [2, 2, 2]), expected) From ff194c45ea5d037afd6ab3150e08f4ba43f0a918 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 09:57:55 -0400 Subject: [PATCH 083/227] [fix] Read the phase vector when converting a stabilizer to a density matrix _stabilizer_to_density_pure built its density matrix from to_labels(), which emits no sign, so the phase vector was never read and a negative generator was read as positive: |1> converted to |0>. It now builds prod (I + (-1)^r P) / 2 using each generator's phase r. Infidelity.evaluate converts stabilizer states to density matrices, so this changes fidelities computed for any stabilizer state with a negative generator. test_stabilizer_compiler now passes tableaux, so it goes through the signed path. --- graphiq/backends/state_rep_conversion.py | 9 ++- .../stabilizer/test_stabilizer_compiler.py | 4 +- .../test_stabilizer_to_density_sign.py | 65 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index 2e805686..c4a53437 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -297,10 +297,13 @@ def _stabilizer_to_density_pure(input_stabilizer): """ n_qubits = input_stabilizer.n_qubits generator_string = input_stabilizer.to_labels() - rho = np.eye(2**n_qubits) - for generator in generator_string: + # to_labels() carries no sign, so read it from the phase vector + phase = input_stabilizer.phase + identity = np.eye(2**n_qubits) + rho = identity + for i, generator in enumerate(generator_string): stabilizer_elem = sfu.get_stabilizer_element_by_string(generator) - rho = np.matmul(rho, (stabilizer_elem + np.eye(2**n_qubits)) / 2) + rho = np.matmul(rho, (identity + (-1) ** int(phase[i]) * stabilizer_elem) / 2) return rho diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py index 1f0e3147..6ce99fcf 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py @@ -42,7 +42,7 @@ def test_linear_cluster_state(expected): dims=n_photons * [2], ) tableau = output_stabilizer.tableau.to_stabilizer() - output_state = rc.stabilizer_to_density(tableau.to_labels()) + output_state = rc.stabilizer_to_density(tableau) assert np.allclose(output_state, target_state.rep_data.data) output_tableau = sfs.canonical_form(tableau) @@ -64,7 +64,7 @@ def test_ghz3(): dims=n_photons * [2], ) output_s_tableau = output_state.rep_data.tableau.to_stabilizer() - output_dm = rc.stabilizer_to_density(output_s_tableau.to_labels()) + output_dm = rc.stabilizer_to_density(output_s_tableau) # use representation conversion module to convert to density matrix assert np.allclose(output_dm, target_state.rep_data.data) diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py b/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py new file mode 100644 index 00000000..8d29cc26 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py @@ -0,0 +1,65 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``state_rep_conversion.stabilizer_to_density`` reads the phase vector. + +Every expected density matrix below is written out by hand from the state it +describes. +""" + +import networkx as nx +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +def test_stabilizer_to_density_converts_ket_one_correctly(): + # ['-Z'], phase [1]: the state |1>. Sign-blind, this reads as ['+Z'] = |0>. + tableau = StabilizerTableau([np.array([[0]]), np.array([[1]])], np.array([1])) + + rho = rc.stabilizer_to_density(tableau) + + expected = np.array([[0, 0], [0, 1]], dtype=complex) + assert np.allclose(rho, expected) + + +def test_stabilizer_to_density_handles_a_two_qubit_signed_generator_set(): + # ['ZZ', '-XX']: (|00> - |11>) / sqrt(2). Sign-blind, the second generator's + # sign is dropped and this reads as (|00> + |11>) / sqrt(2) instead. + x_matrix = np.array([[0, 0], [1, 1]]) + z_matrix = np.array([[1, 1], [0, 0]]) + phase = np.array([0, 1]) + tableau = StabilizerTableau([x_matrix, z_matrix], phase) + + rho = rc.stabilizer_to_density(tableau) + + expected = np.zeros((4, 4), dtype=complex) + expected[0, 0] = 0.5 + expected[3, 3] = 0.5 + expected[0, 3] = -0.5 + expected[3, 0] = -0.5 + assert np.allclose(rho, expected) + + +def test_stabilizer_to_density_leaves_a_graph_state_unchanged(): + # Graph states carry an all-zero phase vector, so this is the case the + # fix must not disturb. + graph = nx.Graph([(0, 1), (1, 2), (2, 0)]) + tableau = rc.graph_to_stabilizer(graph)[0][1] + + rho = rc.stabilizer_to_density(tableau) + + assert np.allclose(rho, rc.graph_to_density(graph)) From 3e75bd458d577dcfdf3c8099e4a9fc9498af7d18 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:00:09 -0400 Subject: [PATCH 084/227] [fix] Transfer the measurement sign when removing a stabilizer qubit remove_qubit measured the qubit being removed and then deleted its X and Z columns, without first making sure +Z_q was in the group. So the measurement outcome's sign never reached the surviving generators: a Bell pair with one qubit removed on a forced 1 gave |0> instead of |1>, and |110> with qubit 0 removed gave |00> instead of |10>. After z_measurement_gate every stabilizer row has no X on the measured qubit, so applying X to it on a 1 outcome only flips the sign of rows with Z there. That puts +Z_q in the group, and deleting the column then leaves the correct state on the remaining qubits. Stabilizer.partial_trace and MixedStabilizer.partial_trace now also accept and forward measurement_determinism, as trace_out_qubits already does, so a caller can force the outcome now that it affects the result. Results: no score measured on the solvers' own circuits changes. Measured at the branch tip, the fixed and pre-fix remove_qubit give identical Monte Carlo and mixed-stabilizer means on 27 target/rate cases for circuits TimeReversedSolver built, and instrumented runs on three of those targets and two EvolutionarySolver runs found no call where the two versions differ. The likely reason is that the emitters these circuits trace out are not left with a 1 outcome correlated with a kept qubit. A circuit that does trace out such a qubit now gets the correct state on the remaining qubits. --- .../backends/stabilizer/functions/clifford.py | 8 + graphiq/backends/stabilizer/state.py | 20 +- .../test_remove_qubit_sign_exactness.py | 211 ++++++++++++++++++ 3 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index 8989be8c..cf961c12 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -370,6 +370,14 @@ def remove_qubit(tableau, qubit_position, measurement_determinism="probabilistic tableau, outcome, probabilistic = z_measurement_gate( tableau, qubit_position, measurement_determinism ) + if outcome == 1: + # After z_measurement_gate every stabilizer row has x_q = 0, so +Z_q + # is already in the group but may carry the wrong sign. x_gate flips + # phase exactly where z_q = 1, which fixes that sign without touching + # the x/z bits -- so it is safe to apply before non_zero is computed + # below, and it is what makes deleting column q transfer the outcome's + # sign to the remaining generators in both branches. + tableau = x_gate(tableau, qubit_position) new_table = np.delete( tableau.table, [qubit_position, qubit_position + n_qubits], axis=1 ) diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index f3248e98..75f1b72f 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -316,7 +316,7 @@ def trace_out_qubits( measurement_determinism=measurement_determinism, ) - def partial_trace(self, keep, dims): + def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): """ Trace out qubits after disentangling them from the rest @@ -324,10 +324,19 @@ def partial_trace(self, keep, dims): :type keep: list[int] or numpy.ndarray :param dims: dimension of each subsystem :type dims: list[int] or numpy.ndarray + :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected + if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 + if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 + :type measurement_determinism: str/int :return: nothing :rtype: None """ - self._tableau = sfc.partial_trace(self._tableau, keep=keep, dims=dims) + self._tableau = sfc.partial_trace( + self._tableau, + keep=keep, + dims=dims, + measurement_determinism=measurement_determinism, + ) def __str__(self): """ @@ -763,7 +772,7 @@ def trace_out_qubits( for (p_i, t_i) in self._mixture ] - def partial_trace(self, keep, dims): + def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): """ Trace out qubits after disentangling them from the rest @@ -771,6 +780,10 @@ def partial_trace(self, keep, dims): :type keep: list[int] or numpy.ndarray :param dims: dimension of each subsystem :type dims: list[int] or numpy.ndarray + :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected + if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 + if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 + :type measurement_determinism: str/int :return: nothing :rtype: None """ @@ -781,6 +794,7 @@ def partial_trace(self, keep, dims): t_i, keep=keep, dims=dims, + measurement_determinism=measurement_determinism, ), ) for (p_i, t_i) in self._mixture diff --git a/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py b/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py new file mode 100644 index 00000000..3e457022 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py @@ -0,0 +1,211 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``remove_qubit`` measures the traced-out qubit to disentangle it and then +deletes its columns. Applying X to the measured qubit on a 1 outcome puts +Z_q +in the group, at which point deleting the column transfers the outcome's sign +to the surviving generators. These tests hold that guarantee in both the +probabilistic and the deterministic branch, on small hand-checkable states: a +Bell pair, |110>, a case with more than one destabilizer row carrying the +removed qubit's X, and GHZ states. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + partial_trace, + remove_qubit, + z_measurement_gate, +) +from graphiq.backends.stabilizer.functions.linalg import row_sum +from graphiq.backends.stabilizer.functions.transformation import ( + cnot_gate, + hadamard_gate, + x_gate, +) +from graphiq.backends.stabilizer.state import Stabilizer + + +def tableau_to_density(tableau): + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def _combine_stabilizer_rows(tableau, row_to_add, target_row): + """ + Overwrite one stabilizer generator with its product with another, + in-place. Multiplying two generators together never changes the group + they generate, only which basis of it is written down -- so this is a + change of generator basis, not a change of state. + + :param tableau: the tableau to rewrite + :type tableau: CliffordTableau + :param row_to_add: stabilizer row index (0-based, within the stabilizer + half) to multiply in + :type row_to_add: int + :param target_row: stabilizer row index (0-based, within the stabilizer + half) to overwrite with the product + :type target_row: int + :return: the same tableau, rewritten + :rtype: CliffordTableau + """ + n_qubits = tableau.n_qubits + ( + tableau.table_x, + tableau.table_z, + tableau.phase, + tableau.iphase, + ) = row_sum( + tableau.table_x, + tableau.table_z, + tableau.phase, + tableau.iphase, + n_qubits + row_to_add, + n_qubits + target_row, + ) + return tableau + + +def test_removing_a_qubit_from_a_bell_pair_forced_to_outcome_zero_leaves_ket_0(): + tableau = remove_qubit(_bell_state(), 0, measurement_determinism=0) + + assert np.allclose(tableau_to_density(tableau), np.diag([1.0, 0.0])) + + +def test_removing_a_qubit_from_a_bell_pair_forced_to_outcome_one_leaves_ket_1(): + # This is the branch the un-fixed code gets wrong: outcome 1 never + # transfers its sign to the surviving qubit, so the result reads back as + # |0> instead of |1>. + tableau = remove_qubit(_bell_state(), 0, measurement_determinism=1) + + assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 1.0])) + + +def test_removing_a_qubit_from_ket_110_leaves_ket_10(): + # |110> is a product state, so the measurement of qubit 0 is deterministic + # (x_p == 0), remove_qubit's *other* branch. Writing the state down in the + # diagonal basis ['-ZII', '-IZI', '+IIZ'] would pass even with the defect, + # because no surviving generator has support on qubit 0 to begin with, so + # a row product first rewrites it as ['-ZII', '+ZZI', '+IIZ']: ZZI is the + # surviving generator whose sign the un-fixed code drops. + tableau = create_n_ket0_state(3) + tableau = x_gate(tableau, 0) + tableau = x_gate(tableau, 1) + tableau = _combine_stabilizer_rows(tableau, row_to_add=0, target_row=1) + + tableau = remove_qubit(tableau, 0) + + expected = np.zeros((4, 4)) + expected[2, 2] = 1.0 # |10> + assert np.allclose(tableau_to_density(tableau), expected) + + +def _ket_11_via_cnot(): + # CNOT(0, 1) on |10> gives |11>, but -- unlike building |11> with two X + # gates -- it leaves *both* destabilizer rows carrying X on qubit 1: the + # AG rule for CNOT(control=0, target=1) sends destabilizer X_0 -> X_0 X_1 + # while destabilizer X_1 is untouched, so column 1 of the destabilizer + # block ends up nonzero on both rows. That is remove_qubit's + # ``len(non_zero) > 1`` sub-branch (confirmed below), reached here while + # the qubit being removed is deterministically |1>, so the sub-branch is + # actually exercised with a sign to get right. + tableau = create_n_ket0_state(2) + tableau = x_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def test_the_more_than_one_destabilizer_sub_branch_is_really_exercised(): + # A check on the fixture itself, not on remove_qubit: if this ever stopped + # being true, the test below would still pass, but for the wrong reason + # -- it would have quietly stopped covering the len(non_zero) > 1 branch. + tableau = _ket_11_via_cnot() + non_zero = [i for i in range(2) if tableau.destabilizer_x[i, 1] != 0] + _, outcome, x_p = z_measurement_gate(tableau.copy(), 1) + + assert len(non_zero) > 1 + assert x_p == 0 # deterministic branch + assert outcome == 1 # the branch that needs the sign fix + + +def test_removing_a_qubit_with_more_than_one_destabilizer_row_on_it(): + tableau = remove_qubit(_ket_11_via_cnot(), 1) + + assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 1.0])) + + +def _ghz3(): + tableau = create_n_ket0_state(3) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + tableau = cnot_gate(tableau, 0, 2) + return tableau + + +def test_removing_the_middle_qubit_of_a_ghz3_state_forced_to_zero_leaves_ket_00(): + # A single-qubit Z measurement of a GHZ state is probabilistic (x_p != 0) + # but fully correlated: whichever outcome it lands on forces the other two + # qubits to the same value, so the post-removal state is a product, not a + # Bell pair. + tableau = remove_qubit(_ghz3(), 1, measurement_determinism=0) + + assert np.allclose(tableau_to_density(tableau), np.diag([1.0, 0.0, 0.0, 0.0])) + + +def test_removing_the_middle_qubit_of_a_ghz3_state_forced_to_one_leaves_ket_11(): + # The un-fixed code gets this one wrong: it reads back as |00> too, + # because outcome 1's sign never reaches the surviving generators. + tableau = remove_qubit(_ghz3(), 1, measurement_determinism=1) + + assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 0.0, 0.0, 1.0])) + + +def _ghz4(): + tableau = create_n_ket0_state(4) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + tableau = cnot_gate(tableau, 0, 2) + tableau = cnot_gate(tableau, 0, 3) + return tableau + + +def test_partial_trace_of_multiple_qubits_forced_to_one_leaves_ket_1(): + # sfc.partial_trace removes three qubits from a GHZ4 state in one call + # (each a remove_qubit under the hood), which is the multi-qubit path + # ~14 production call sites actually use. Forcing all three removed + # measurements to outcome 1 must leave the surviving qubit at |1>. + tableau = partial_trace(_ghz4(), keep=[0], dims=4 * [2], measurement_determinism=1) + + assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 1.0])) + + +def test_stabilizer_partial_trace_accepts_measurement_determinism(): + # Stabilizer.partial_trace forwards measurement_determinism, so a caller + # can pin the traced-out qubit's measurement. That matters because the + # removal now transfers the outcome's sign to the surviving generators: + # the outcome is observable in the result, where before it could not be, + # so a caller with no way to force it would be left with a random state. + state = Stabilizer(_ghz4()) + state.partial_trace(keep=[0], dims=4 * [2], measurement_determinism=1) + + assert np.allclose(tableau_to_density(state.tableau), np.diag([0.0, 1.0])) From ff2b80ae4192877b67822d6193769908bdbfc507 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:01:03 -0400 Subject: [PATCH 085/227] [fix] Forward measurement_determinism through QuantumState.partial_trace Tracing out a stabilizer qubit measures it, and the outcome now shows up in the surviving state. Both stabilizer backends accept a measurement_determinism, but QuantumState.partial_trace took only keep and dims, so every trace through it fell back to a random draw. QuantumState.partial_trace now takes and forwards the argument. DensityMatrix.partial_trace accepts and ignores it, since an exact partial trace has no outcome to force. search_for_alternative_circuits forces the compiler to outcome 1 and then traced out the emitters at random; both of its traces now use the compiler's setting. circuit_measurement_independent compiles at determinism 0 and 1 and compares the traces; each trace now matches the compilation that produced it. --- graphiq/backends/density_matrix/state.py | 6 +++- graphiq/benchmarks/alternate_circuits.py | 6 +++- graphiq/benchmarks/benchmark_utils.py | 4 +++ graphiq/state.py | 10 +++++-- .../test_quantum_state.py | 29 +++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index 7bf5253f..e67962bb 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -227,7 +227,7 @@ def apply_measurement_controlled_gate( self.apply_unitary(target_gate) return outcome - def partial_trace(self, keep, dims): + def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): """ Take the partial trace of the state @@ -238,6 +238,10 @@ def partial_trace(self, keep, dims): if the space is :math:`A \\times B \\times C \\times D`, dims = [dim_A, dim_B, dim_C, dim_D] :type dims: list OR numpy.ndarray + :param measurement_determinism: accepted and ignored. This is a partial trace over a subsystem, not a + measure-and-delete, so there is no outcome to force. It is in the signature so that a caller working + across representations does not need a branch of its own. + :type measurement_determinism: str or int :return: nothing :rtype: None """ diff --git a/graphiq/benchmarks/alternate_circuits.py b/graphiq/benchmarks/alternate_circuits.py index 582c37e0..5002b2c2 100644 --- a/graphiq/benchmarks/alternate_circuits.py +++ b/graphiq/benchmarks/alternate_circuits.py @@ -70,10 +70,13 @@ def search_for_alternative_circuits( benchmark_score, benchmark_circuit = det_solver.result compiled_state = compiler.compile(benchmark_circuit) - # trace out emitter qubits + # trace out emitter qubits, resolving the traced-out measurements the same + # way the compilation did -- this function sets the compiler to a forced + # outcome above, and a probabilistic trace would discard that choice. compiled_state.partial_trace( keep=list(range(n_photon)), dims=(n_photon + n_emitter) * [2], + measurement_determinism=compiler.measurement_determinism, ) if isinstance(compiled_state.rep_data, MixedStabilizer): prob = compiled_state.rep_data.probability @@ -106,6 +109,7 @@ def search_for_alternative_circuits( compiled_state.partial_trace( keep=list(range(n_photon)), dims=(n_photon + n_emitter) * [2], + measurement_determinism=compiler.measurement_determinism, ) if isinstance(compiled_state.rep_data, MixedStabilizer): prob = compiled_state.rep_data.probability diff --git a/graphiq/benchmarks/benchmark_utils.py b/graphiq/benchmarks/benchmark_utils.py index 6347f0ae..4fb410d1 100644 --- a/graphiq/benchmarks/benchmark_utils.py +++ b/graphiq/benchmarks/benchmark_utils.py @@ -41,13 +41,17 @@ def circuit_measurement_independent(circuit, compiler): state2 = compiler.compile(circuit) compiler.measurement_determinism = original_measurement_determinism + # Each trace resolves its measurements the same way the compilation that + # produced it did, so the comparison below is not reading measurement noise. state1.partial_trace( keep=list(range(0, circuit.n_quantum - 1)), dims=circuit.n_quantum * [2], + measurement_determinism=0, ) state2.partial_trace( keep=list(range(0, circuit.n_quantum - 1)), dims=circuit.n_quantum * [2], + measurement_determinism=1, ) return np.allclose(state1.rep_data.data, state2.rep_data.data), state1, state2 diff --git a/graphiq/state.py b/graphiq/state.py index fb79dc78..107c53d8 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -127,7 +127,7 @@ def rep_type(self): def rep_type(self, new_rep_type): self._rep_type = self._get_rep_type_name(new_rep_type) - def partial_trace(self, keep, dims): + def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): r""" Calculates the partial trace on all state representations which are currently defined @@ -138,6 +138,10 @@ def partial_trace(self, keep, dims): if the space is $A \times B \times C \times D$, dims = [$dim_A$, $dim_B$, $dim_C$, $dim_D$] :type dims: list OR numpy.ndarray + :param measurement_determinism: how a stabilizer representation resolves the measurement of a traced-out + qubit whose outcome is not already fixed by the state: "probabilistic" to draw one, or 0 or 1 to force + it. A density-matrix trace has no outcome to resolve and ignores this. + :type measurement_determinism: str or int :return: nothing :rtype: None """ @@ -147,7 +151,9 @@ def partial_trace(self, keep, dims): "Partial trace not yet implemented on graph state" ) else: - self._rep_data.partial_trace(keep, dims) + self._rep_data.partial_trace( + keep, dims, measurement_determinism=measurement_determinism + ) def show(self, show=True, ax=None): """ diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index b552bbbd..59d1dc38 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -20,6 +20,11 @@ import graphiq.backends.state_rep_conversion as rc from graphiq.backends.graph.state import Graph, MixedGraph from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.clifford import create_n_ket0_state +from graphiq.backends.stabilizer.functions.transformation import ( + cnot_gate, + hadamard_gate, +) from graphiq.state import QuantumState @@ -124,3 +129,27 @@ def test_convert_representation_graph_to_stabilizer_pure_arm(): expected = rc.graph_to_stabilizer(graph)[0][1] assert state.rep_data.data.to_stabilizer() == expected + + +@pytest.mark.parametrize("outcome", [0, 1]) +def test_partial_trace_forwards_measurement_determinism_to_stabilizer(outcome): + # Tracing three qubits out of GHZ4 leaves |outcome> on the survivor, so the + # result shows whether the forced outcome reached the backend. + tableau = hadamard_gate(create_n_ket0_state(4), 0) + for target in (1, 2, 3): + tableau = cnot_gate(tableau, 0, target) + state = QuantumState(tableau, rep_type="s") + + state.partial_trace(keep=[0], dims=4 * [2], measurement_determinism=outcome) + + rho = rc.stabilizer_to_density(state.rep_data.tableau.to_stabilizer()) + assert np.allclose(rho, np.diag([1.0 - outcome, float(outcome)])) + + +def test_density_matrix_partial_trace_accepts_measurement_determinism(): + bell = np.array([1, 0, 0, 1]) / np.sqrt(2) + state = QuantumState(np.outer(bell, bell), rep_type="dm") + + state.partial_trace(keep=[0], dims=[2, 2], measurement_determinism=1) + + assert np.allclose(state.rep_data.data, np.eye(2) / 2) From 42d5a3ba82d938bd5a82aa2846c71477e20a2c97 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:04:29 -0400 Subject: [PATCH 086/227] [fix] Reset with X in reset_z instead of overwriting the measured row's sign reset_z's probabilistic branch overwrote the measured row's sign with the intended state. The surviving generators still reference the qubit, so this rewrote their state too: resetting a Bell-pair qubit to |0> gave |00> whichever outcome was forced, where a forced 1 must give |01>. Both branches now apply X exactly when the outcome differs from the intended state. reset_x and reset_y inherit the fix, and the stabilizer compiler reaches it through reset_qubit. --- .../backends/stabilizer/functions/clifford.py | 18 ++- .../stabilizer/test_reset_sign_exactness.py | 104 ++++++++++++++++++ 2 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_reset_sign_exactness.py diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index cf961c12..92140cd6 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -217,19 +217,17 @@ def reset_z( n_qubits = tableau.n_qubits # number of qubits assert qubit_position < n_qubits assert intended_state == 0 or intended_state == 1 - tableau, outcome, probabilistic = z_measurement_gate( + tableau, outcome, _ = z_measurement_gate( tableau, qubit_position, measurement_determinism ) - if probabilistic: - tableau.phase[probabilistic] = intended_state - tableau.iphase[probabilistic] = 0 + # The measurement above has projected the qubit onto its outcome, so the + # only unitary left that moves it to the intended state is X, applied + # exactly when the two disagree. This holds on both branches: the sign of + # the measured row cannot be overwritten instead, because the surviving + # qubits' generators still reference it. + if outcome == intended_state: return tableau - - else: - if outcome == intended_state: - return tableau - else: - return x_gate(tableau, qubit_position) + return x_gate(tableau, qubit_position) def reset_x( diff --git a/tests/test_state_representation/stabilizer/test_reset_sign_exactness.py b/tests/test_state_representation/stabilizer/test_reset_sign_exactness.py new file mode 100644 index 00000000..ea931ec9 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_reset_sign_exactness.py @@ -0,0 +1,104 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Resetting a qubit is a Z-basis measurement followed by an X on the target +when the outcome disagrees with the requested state. Nothing else in the +state may move: the measurement has already projected the state, so any +extra write to the measured row's own sign lands on the generator every +other qubit's row still shares. + +These tests build a Bell pair -- the measurement of one of its qubits is +probabilistic, which is the branch that must not special-case a sign +rewrite -- and check both intended states against both forced outcomes with +the density matrix of the tableau's signed generators. ``reset_x`` and ``reset_y`` are built +from ``reset_z`` plus a basis change, so they inherit whatever ``reset_z`` +guarantees; the tests below hold that guarantee for each in turn. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + reset_x, + reset_y, + reset_z, +) +from graphiq.backends.stabilizer.functions.transformation import ( + + cnot_gate, + hadamard_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def _expect(index): + expected = np.zeros((4, 4)) + expected[index, index] = 1.0 + return expected + + +def test_resetting_a_bell_pair_qubit_forced_to_zero_to_ket_0_leaves_ket_00(): + tableau = reset_z(_bell_state(), 0, intended_state=0, measurement_determinism=0) + assert np.allclose(tableau_to_density(tableau), _expect(0b00)) + + +def test_resetting_a_bell_pair_qubit_forced_to_zero_to_ket_1_leaves_ket_10(): + tableau = reset_z(_bell_state(), 0, intended_state=1, measurement_determinism=0) + assert np.allclose(tableau_to_density(tableau), _expect(0b10)) + + +def test_resetting_a_bell_pair_qubit_forced_to_one_to_ket_1_leaves_ket_11(): + tableau = reset_z(_bell_state(), 0, intended_state=1, measurement_determinism=1) + assert np.allclose(tableau_to_density(tableau), _expect(0b11)) + + +def test_resetting_a_bell_pair_qubit_forced_to_one_to_ket_0_leaves_ket_01(): + # The projection has already happened on the forced-1 branch; a reset to + # |0> here must apply X to the target qubit rather than rewriting the + # measured row's own sign, which would corrupt the surviving generator + # that the untouched qubit still shares. + tableau = reset_z(_bell_state(), 0, intended_state=0, measurement_determinism=1) + assert np.allclose(tableau_to_density(tableau), _expect(0b01)) + + +def test_resetting_a_bell_pair_qubit_in_x_basis_forced_to_one_to_plus_leaves_plus_1(): + # reset_x is reset_z followed by a Hadamard, so it inherits reset_z's + # sign-exactness on the probabilistic branch. + tableau = reset_x(_bell_state(), 0, intended_state=0, measurement_determinism=1) + plus = np.array([[0.5, 0.5], [0.5, 0.5]]) + one = _expect(1)[:2, :2] + assert np.allclose(tableau_to_density(tableau), np.kron(plus, one)) + + +def test_resetting_a_bell_pair_qubit_in_y_basis_forced_to_one_to_plus_i_leaves_plus_i_1(): + # reset_y is reset_z followed by H then S, so it inherits the same + # guarantee. Forcing the outcome to 1 while asking for 0 is what makes this + # discriminate: the two agree in the cell where the reset has nothing to + # do, and only disagree where the correcting X is required. + tableau = reset_y(_bell_state(), 0, intended_state=0, measurement_determinism=1) + plus_i = np.array([[0.5, -0.5j], [0.5j, 0.5]]) + one = _expect(1)[:2, :2] + assert np.allclose(tableau_to_density(tableau), np.kron(plus_i, one)) From a18d0201e3226bcd2ff24b6b5ec885926bc04c41 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:04:29 -0400 Subject: [PATCH 087/227] [fix] Leave the row signs in place in swap_gate swap_gate permuted the phase and i-phase entries along with the two columns. A sign belongs to its row, and a swap does not permute rows, so |10> swapped stayed |10> instead of becoming |01>. --- .../backends/stabilizer/functions/clifford.py | 13 ++-- .../test_swap_gate_sign_exactness.py | 75 +++++++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_swap_gate_sign_exactness.py diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index 92140cd6..1b9d5e00 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -448,16 +448,13 @@ def swap_gate(tableau, qubit1, qubit2): """ n_qubits = tableau.n_qubits # number of qubits assert qubit1 < n_qubits and qubit2 < n_qubits + # Conjugating by SWAP permutes qubit labels within each row, so only the + # two symplectic columns move. A sign belongs to the row it is on, and + # rows -- and the destabilizer/stabilizer pairing between them -- are + # untouched by a swap, so the phase and i-phase vectors stay exactly + # where they are. tableau.table = column_swap(tableau.table, qubit1, qubit2) tableau.table = column_swap(tableau.table, qubit1 + n_qubits, qubit2 + n_qubits) - tableau.phase[[qubit1, qubit2]] = tableau.phase[[qubit2, qubit1]] - tableau.phase[[qubit1 + n_qubits, qubit2 + n_qubits]] = tableau.phase[ - [qubit2 + n_qubits, qubit1 + n_qubits] - ] - tableau.iphase[[qubit1, qubit2]] = tableau.iphase[[qubit2, qubit1]] - tableau.iphase[[qubit1 + n_qubits, qubit2 + n_qubits]] = tableau.iphase[ - [qubit2 + n_qubits, qubit1 + n_qubits] - ] return tableau diff --git a/tests/test_state_representation/stabilizer/test_swap_gate_sign_exactness.py b/tests/test_state_representation/stabilizer/test_swap_gate_sign_exactness.py new file mode 100644 index 00000000..cef19313 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_swap_gate_sign_exactness.py @@ -0,0 +1,75 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Conjugating by SWAP permutes qubit labels within each generator, so the +symplectic columns move -- but a sign belongs to the row it is on, not to +either of the columns that row happens to touch, and a swap does not permute +rows. ``swap_gate`` therefore moves the two columns and leaves both phase +vectors exactly where they are. + +These tests build small hand-checkable states and check the swap against +the density matrix of the tableau's signed generators. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + swap_gate, +) +from graphiq.backends.stabilizer.functions.transformation import ( + cnot_gate, + hadamard_gate, + x_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _expect(index, n_qubits): + expected = np.zeros((2**n_qubits, 2**n_qubits)) + expected[index, index] = 1.0 + return expected + + +def test_swapping_ket_10_gives_ket_01(): + tableau = create_n_ket0_state(2) + tableau = x_gate(tableau, 0) + tableau = swap_gate(tableau, 0, 1) + assert np.allclose(tableau_to_density(tableau), _expect(0b01, 2)) + + +def test_swapping_two_qubits_of_an_entangled_three_qubit_state(): + # Swapping qubits 0 and 2 of a GHZ3 state carries a stabilizer + # generator, ZIZ, whose sign must land on the swapped row, not drift + # onto a different generator through a column-index-keyed permutation. + tableau = create_n_ket0_state(3) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + tableau = cnot_gate(tableau, 0, 2) + tableau = x_gate(tableau, 1) + tableau = swap_gate(tableau, 0, 2) + + # H(0), CNOT(0,1), CNOT(0,2), X(1) gives (|010> + |101>) / sqrt(2); swapping + # qubits 0 and 2 leaves it unchanged, since both terms are symmetric in them. + expected = np.zeros((8, 8)) + for i in (0b010, 0b101): + for j in (0b010, 0b101): + expected[i, j] = 0.5 + + assert np.allclose(tableau_to_density(tableau), expected) From b0347710fecd0cbe2ea7af27451d6676a14514f4 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:04:29 -0400 Subject: [PATCH 088/227] [fix] Compute insert_qubit's np.insert indices against the original array insert_qubit computed np.insert indices against the enlarged array, but np.insert reads them against the original, so the new row's phase landed one slot too far right; appending raised IndexError. --- .../backends/stabilizer/functions/clifford.py | 6 +- .../stabilizer/test_insert_qubit_indexing.py | 84 +++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_insert_qubit_indexing.py diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index 1b9d5e00..b3963aa1 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -332,10 +332,8 @@ def insert_qubit(tableau, new_position): tmp_sz = np.insert(tmp_sz, new_position, new_row, axis=0) # phase vector part - new_phase = np.insert(tableau.phase, [new_position, n_qubits + 1 + new_position], 0) - new_iphase = np.insert( - tableau.iphase, [new_position, n_qubits + 1 + new_position], 0 - ) + new_phase = np.insert(tableau.phase, [new_position, n_qubits + new_position], 0) + new_iphase = np.insert(tableau.iphase, [new_position, n_qubits + new_position], 0) new_table = np.block([[tmp_dex, tmp_dez], [tmp_sx, tmp_sz]]) tableau.expand(new_table, new_phase, new_iphase) diff --git a/tests/test_state_representation/stabilizer/test_insert_qubit_indexing.py b/tests/test_state_representation/stabilizer/test_insert_qubit_indexing.py new file mode 100644 index 00000000..f1ef434e --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_insert_qubit_indexing.py @@ -0,0 +1,84 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``insert_qubit`` builds the enlarged phase and i-phase vectors with +``np.insert``, whose index list is read against the *original* array, so the +new stabilizer row goes at ``n_qubits + new_position``. That holds at either +end as well as in the middle: appending, which is what ``add_qubit`` does, +indexes the last slot of the original vector rather than one past it. + +These tests build small states that carry a non-zero phase and check the +inserted qubit's position, and the untouched qubits' signs, against the density matrix of the tableau's signed generators. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + add_qubit, + create_n_ket0_state, + insert_qubit, +) +from graphiq.backends.stabilizer.functions.transformation import ( + + cnot_gate, + hadamard_gate, + x_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _ket_01(): + tableau = create_n_ket0_state(2) + tableau = x_gate(tableau, 1) + return tableau + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def _expect(index, n_qubits): + expected = np.zeros((2**n_qubits, 2**n_qubits)) + expected[index, index] = 1.0 + return expected + + +def test_appending_an_isolated_qubit_to_a_state_with_a_nonzero_phase(): + # add_qubit is insert_qubit at new_position == n_qubits, the append case: + # the index is read against the original vector, so the new stabilizer + # row's slot is its last, not one past its end. add_qubit rests entirely + # on this, which is why it gets its own test. + tableau = add_qubit(_ket_01()) + assert np.allclose(tableau_to_density(tableau), _expect(0b010, 3)) + + +def test_inserting_a_qubit_in_the_middle_of_a_state_with_a_nonzero_phase(): + # Inserting at position 1 into |01> shifts the old qubit 1 to position 2; + # the inserted row's sign lands with its own qubit, not one to the right. + tableau = insert_qubit(_ket_01(), 1) + assert np.allclose(tableau_to_density(tableau), _expect(0b001, 3)) + + +def test_inserting_a_qubit_at_the_front_of_an_entangled_state(): + tableau = insert_qubit(_bell_state(), 0) + expected = np.kron(_expect(0, 1), tableau_to_density(_bell_state())) + assert np.allclose(tableau_to_density(tableau), expected) From e4deaa9b1f2de7269e13f536c587fe38e360fe9b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:04:29 -0400 Subject: [PATCH 089/227] [fix] Rotate back to the original frame after measure_x and measure_y measure_x and measure_y rotated into the Z frame, measured, and never rotated back, leaving the caller's tableau in the rotated frame. --- .../backends/stabilizer/functions/clifford.py | 9 ++ .../test_measure_x_y_restore_frame.py | 96 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/test_state_representation/stabilizer/test_measure_x_y_restore_frame.py diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index b3963aa1..5410bfcd 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -146,6 +146,10 @@ def measure_x(tableau, qubit_position, measurement_determinism="probabilistic"): _, outcome, _ = z_measurement_gate( stabilizer_state_new, qubit_position, measurement_determinism ) + # Rotate back out of the Z frame the measurement was taken in. The gates + # mutate the caller's tableau in place, so this leaves the caller holding + # the X-basis state the qubit was measured into. H is its own inverse. + hadamard_gate(tableau, qubit_position) return outcome @@ -173,6 +177,11 @@ def measure_y(tableau, qubit_position, measurement_determinism="probabilistic"): _, outcome, _ = z_measurement_gate( new_tableau, qubit_position, measurement_determinism ) + # Rotate back out of the Z frame, leaving the caller holding the Y-basis + # state the qubit was measured into. The pre-rotation is P dagger then H, + # so its inverse is H then P: H first, then the phase gate. + hadamard_gate(tableau, qubit_position) + phase_gate(tableau, qubit_position) return outcome diff --git a/tests/test_state_representation/stabilizer/test_measure_x_y_restore_frame.py b/tests/test_state_representation/stabilizer/test_measure_x_y_restore_frame.py new file mode 100644 index 00000000..a4b5fcad --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_measure_x_y_restore_frame.py @@ -0,0 +1,96 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``measure_x`` and ``measure_y`` rotate a qubit into the Z frame, measure it +there, and must rotate it back before returning: the transformation gates +mutate the tableau in place, so a caller holding onto the tableau otherwise +keeps a state that is still expressed in the rotated frame -- which reads +back as a different state in the qubit's own basis. + +These tests build small states, some entangled, and check the caller's +tableau after the call against the density matrix of the tableau's signed generators. Both +outcomes are forced with ``measurement_determinism`` so nothing here depends +on the unseeded global RNG. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + measure_x, + measure_y, +) +from graphiq.backends.stabilizer.functions.transformation import ( + + cnot_gate, + hadamard_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +_PLUS = np.array([[0.5, 0.5], [0.5, 0.5]]) +_MINUS = np.array([[0.5, -0.5], [-0.5, 0.5]]) +_PLUS_Y = np.array([[0.5, -0.5j], [0.5j, 0.5]]) +_MINUS_Y = np.array([[0.5, 0.5j], [-0.5j, 0.5]]) + + +def test_measuring_ket_0_in_the_x_basis_forced_to_zero_leaves_ket_plus(): + tableau = create_n_ket0_state(1) + outcome = measure_x(tableau, 0, measurement_determinism=0) + assert outcome == 0 + assert np.allclose(tableau_to_density(tableau), _PLUS) + + +def test_measuring_ket_0_in_the_x_basis_forced_to_one_leaves_ket_minus(): + tableau = create_n_ket0_state(1) + outcome = measure_x(tableau, 0, measurement_determinism=1) + assert outcome == 1 + assert np.allclose(tableau_to_density(tableau), _MINUS) + + +def test_measuring_ket_0_in_the_y_basis_forced_to_zero_leaves_ket_plus_y(): + tableau = create_n_ket0_state(1) + outcome = measure_y(tableau, 0, measurement_determinism=0) + assert outcome == 0 + assert np.allclose(tableau_to_density(tableau), _PLUS_Y) + + +def test_measuring_ket_0_in_the_y_basis_forced_to_one_leaves_ket_minus_y(): + tableau = create_n_ket0_state(1) + outcome = measure_y(tableau, 0, measurement_determinism=1) + assert outcome == 1 + assert np.allclose(tableau_to_density(tableau), _MINUS_Y) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def test_measuring_one_qubit_of_a_bell_pair_in_the_x_basis_leaves_the_other_correlated(): + # Measuring qubit 0 of a Bell pair in the X basis is probabilistic; the + # untouched qubit collapses to the same X eigenstate. Getting this right + # depends on the measured qubit's row landing back in its own frame + # before the surviving correlation is read off it. + tableau = _bell_state() + outcome = measure_x(tableau, 0, measurement_determinism=1) + assert outcome == 1 + assert np.allclose(tableau_to_density(tableau), np.kron(_MINUS, _MINUS)) From 3b4f9a96f196b474201ec226f6ffb2d44882a5b8 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:04:29 -0400 Subject: [PATCH 090/227] [fix] Build a new tableau of the final size in tensor tensor set n_qubits before replacing the table blocks, so it always hit an assertion and left its first argument mutated. It now builds a new tableau of the final size and leaves its arguments unchanged. --- .../backends/stabilizer/functions/clifford.py | 57 +++++---- .../stabilizer/test_tensor_product.py | 110 ++++++++++++++++++ 2 files changed, 145 insertions(+), 22 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_tensor_product.py diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index 5410bfcd..bca3edaa 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -520,29 +520,42 @@ def tensor(list_of_tables): :return: The resulting tableau :rtype: CliffordTableau """ - tableau = list_of_tables[0] - list_of_tables = list_of_tables[1:] - for tab in list_of_tables: - tableau.n_qubits = tableau.n_qubits + tab.n_qubits - tableau.destabilizer_x = block_diag(tableau.destabilizer_x, tab.destabilizer_x) - tableau.destabilizer_z = block_diag(tableau.destabilizer_z, tab.destabilizer_z) - tableau.stabilizer_x = block_diag(tableau.stabilizer_x, tab.stabilizer_x) - tableau.stabilizer_z = block_diag(tableau.stabilizer_z, tab.stabilizer_z) - phase_list1 = np.split(tableau.phase, 2) - phase_list2 = np.split(tab.phase, 2) - phase_vector = np.hstack( - (phase_list1[0], phase_list2[0], phase_list1[1], phase_list2[1]) - ).astype(int) - - tableau.phase = phase_vector - - iphase_list1 = np.split(tableau.iphase, 2) - iphase_list2 = np.split(tab.iphase, 2) - iphase_vector = np.hstack( - (iphase_list1[0], iphase_list2[0], iphase_list1[1], iphase_list2[1]) - ).astype(int) + # The result is a fresh tableau, sized up front, filled once at the end. + # n_qubits and the table blocks live behind separate attributes with no + # shared resize path, so a tableau can only be built at its final size -- + # widening n_qubits on an existing one leaves the block getters slicing a + # table that has not grown. Assembling off to the side also keeps every + # argument read-only. + n_qubits = sum(tab.n_qubits for tab in list_of_tables) + tableau = CliffordTableau(n_qubits) - tableau.iphase = iphase_vector + destabilizer_x = list_of_tables[0].destabilizer_x + destabilizer_z = list_of_tables[0].destabilizer_z + stabilizer_x = list_of_tables[0].stabilizer_x + stabilizer_z = list_of_tables[0].stabilizer_z + destabilizer_phase, stabilizer_phase = np.split(list_of_tables[0].phase, 2) + destabilizer_iphase, stabilizer_iphase = np.split(list_of_tables[0].iphase, 2) + + for tab in list_of_tables[1:]: + destabilizer_x = block_diag(destabilizer_x, tab.destabilizer_x) + destabilizer_z = block_diag(destabilizer_z, tab.destabilizer_z) + stabilizer_x = block_diag(stabilizer_x, tab.stabilizer_x) + stabilizer_z = block_diag(stabilizer_z, tab.stabilizer_z) + + tab_destabilizer_phase, tab_stabilizer_phase = np.split(tab.phase, 2) + destabilizer_phase = np.hstack((destabilizer_phase, tab_destabilizer_phase)) + stabilizer_phase = np.hstack((stabilizer_phase, tab_stabilizer_phase)) + + tab_destabilizer_iphase, tab_stabilizer_iphase = np.split(tab.iphase, 2) + destabilizer_iphase = np.hstack((destabilizer_iphase, tab_destabilizer_iphase)) + stabilizer_iphase = np.hstack((stabilizer_iphase, tab_stabilizer_iphase)) + + tableau.destabilizer_x = destabilizer_x + tableau.destabilizer_z = destabilizer_z + tableau.stabilizer_x = stabilizer_x + tableau.stabilizer_z = stabilizer_z + tableau.phase = np.hstack((destabilizer_phase, stabilizer_phase)).astype(int) + tableau.iphase = np.hstack((destabilizer_iphase, stabilizer_iphase)).astype(int) return tableau diff --git a/tests/test_state_representation/stabilizer/test_tensor_product.py b/tests/test_state_representation/stabilizer/test_tensor_product.py new file mode 100644 index 00000000..caf468d8 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_tensor_product.py @@ -0,0 +1,110 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``tensor`` returns a fresh tableau holding the block-diagonal product of its +arguments, leaving every argument untouched. The destabilizer blocks, the +stabilizer blocks and both phase vectors are assembled in factor order, with +all the destabilizer phases ahead of all the stabilizer phases. + +These tests check the product against the density matrix of the tableau's signed +generators, +confirm the inputs are read-only, and measure the result so that the +destabilizer half -- which that density matrix does not read -- is covered too. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + create_n_ket1_state, + tensor, +) +from graphiq.backends.stabilizer.functions.transformation import ( + + z_gate, + cnot_gate, + hadamard_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def _expect(index, n_qubits): + expected = np.zeros((2**n_qubits, 2**n_qubits)) + expected[index, index] = 1.0 + return expected + + +def test_tensor_of_ket_0_and_ket_1_is_ket_01(): + tableau = tensor([create_n_ket0_state(1), create_n_ket1_state(1)]) + assert np.allclose(tableau_to_density(tableau), _expect(0b01, 2)) + + +def test_tensor_carries_the_sign_of_each_factor(): + # ket1 carries phase [0, 1] -- the sign the aliasing-free rebuild has to + # place on the right qubit's row, not the left one's. + tableau = tensor([create_n_ket1_state(1), create_n_ket0_state(1)]) + assert np.allclose(tableau_to_density(tableau), _expect(0b10, 2)) + + +def test_tensor_of_three_factors_including_an_entangled_one(): + tableau = tensor([create_n_ket1_state(1), _bell_state(), create_n_ket0_state(1)]) + expected = np.kron( + np.kron(_expect(1, 1), tableau_to_density(_bell_state())), + _expect(0, 1), + ) + assert np.allclose(tableau_to_density(tableau), expected) + + +def test_tensor_lays_out_the_destabilizer_half_and_the_phases(): + # That density matrix reads the stabilizer half and the stabilizer phases only, so + # every test above is blind to the destabilizer blocks and to the + # destabilizer phases. This pins both against a hand-worked expectation. + # + # Z|0> is one qubit whose destabilizer is -X and whose stabilizer is +Z, so + # it is the smaller factor AND it carries the only non-zero phase, in the + # destabilizer half. The Bell pair's generators are ZI, IX (destabilizers) + # and XX, ZZ (stabilizers), all with a + sign. Tensored in that order the + # three qubits therefore read -XII, IZI, IIX / +ZII, IXX, IZZ. + tableau = tensor([z_gate(create_n_ket0_state(1), 0), _bell_state()]) + + assert np.array_equal(tableau.destabilizer_x, [[1, 0, 0], [0, 0, 0], [0, 0, 1]]) + assert np.array_equal(tableau.destabilizer_z, [[0, 0, 0], [0, 1, 0], [0, 0, 0]]) + assert np.array_equal(tableau.stabilizer_x, [[0, 0, 0], [0, 1, 1], [0, 0, 0]]) + assert np.array_equal(tableau.stabilizer_z, [[1, 0, 0], [0, 0, 0], [0, 1, 1]]) + # Every destabilizer phase ahead of every stabilizer phase, factor by + # factor -- not the two halves of each factor kept together. + assert np.array_equal(tableau.phase, [1, 0, 0, 0, 0, 0]) + assert not np.any(tableau.iphase) + + +def test_tensor_does_not_mutate_its_arguments(): + first, second = create_n_ket0_state(1), create_n_ket1_state(1) + first_before, second_before = first.copy(), second.copy() + + tensor([first, second]) + + assert first == first_before + assert second == second_before From 167e7abf5c892bef124b075578578c1c48b49a7d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:04:58 -0400 Subject: [PATCH 091/227] [fix] Make Stabilizer.apply_x_measurement work instead of raising apply_x_measurement called sfc.x_measurement_gate, which does not exist, so it always raised AttributeError. It now delegates to sfc.measure_x, which rotates into the Z frame, measures, and rotates back, mutating the tableau in place. The docstring said the measurement was in the computational basis; it is in the X basis. --- graphiq/backends/stabilizer/state.py | 10 +- .../stabilizer/test_apply_x_measurement.py | 119 ++++++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_apply_x_measurement.py diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index 75f1b72f..46d403e3 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -146,7 +146,7 @@ def apply_x_measurement( self, qubit_position, measurement_determinism="probabilistic" ): """ - Apply the measurement in the computational basis to a given qubit + Apply the measurement in the X basis to a given qubit :param qubit_position: the qubit position where the measurement is applied :type qubit_position: int @@ -157,13 +157,7 @@ def apply_x_measurement( :return: the measurement outcome :rtype: int """ - ( - self._tableau, - outcome, - _, - ) = sfc.x_measurement_gate( - self._tableau, qubit_position, measurement_determinism - ) + outcome = sfc.measure_x(self._tableau, qubit_position, measurement_determinism) return outcome def apply_hadamard(self, qubit_position): diff --git a/tests/test_state_representation/stabilizer/test_apply_x_measurement.py b/tests/test_state_representation/stabilizer/test_apply_x_measurement.py new file mode 100644 index 00000000..78213921 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_apply_x_measurement.py @@ -0,0 +1,119 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``Stabilizer.apply_x_measurement`` measures a qubit in the X basis and must +leave the tableau in a valid post-measurement state expressed in the qubit's +own (unrotated) basis -- not in the Z frame the measurement is taken in. + +These tests build small states through the ``Stabilizer`` class's own public +gate methods and check the outcome and the density matrix of the resulting +tableau. Every case forces its outcome with ``measurement_determinism`` so +nothing here depends on the unseeded global RNG. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.state import Stabilizer + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +_PLUS = np.array([[0.5, 0.5], [0.5, 0.5]]) +_MINUS = np.array([[0.5, -0.5], [-0.5, 0.5]]) + + +def test_measuring_ket_plus_in_the_x_basis_returns_zero_and_leaves_it_unchanged(): + stab = Stabilizer(1) + stab.apply_hadamard(0) + + outcome = stab.apply_x_measurement(0, measurement_determinism=0) + + assert outcome == 0 + assert np.allclose(tableau_to_density(stab.tableau), _PLUS) + + +def test_measuring_ket_0_in_the_x_basis_forced_to_zero_leaves_ket_plus(): + stab = Stabilizer(1) + + outcome = stab.apply_x_measurement(0, measurement_determinism=0) + + assert outcome == 0 + assert np.allclose(tableau_to_density(stab.tableau), _PLUS) + + +def test_measuring_ket_0_in_the_x_basis_forced_to_one_leaves_ket_minus(): + stab = Stabilizer(1) + + outcome = stab.apply_x_measurement(0, measurement_determinism=1) + + assert outcome == 1 + assert np.allclose(tableau_to_density(stab.tableau), _MINUS) + + +def test_measuring_one_qubit_of_a_bell_pair_in_the_x_basis_leaves_the_other_correlated(): + # Measuring qubit 0 of a Bell pair in the X basis is probabilistic; the + # untouched qubit collapses to the same X eigenstate. Getting this right + # depends on the measured qubit's row landing back in its own frame, + # not the Z frame the measurement is taken in, before the surviving + # correlation is read off it. + stab = Stabilizer(2) + stab.apply_hadamard(0) + stab.apply_cnot(0, 1) + + outcome = stab.apply_x_measurement(0, measurement_determinism=1) + + assert outcome == 1 + assert np.allclose(tableau_to_density(stab.tableau), np.kron(_MINUS, _MINUS)) + + +def test_measuring_qubit_one_of_an_asymmetric_state_measures_that_qubit(): + # The Bell case above cannot see which qubit was measured: that state is + # symmetric under exchanging the two, so measuring either gives the same + # answer. H then CNOT then H on qubit 1 breaks the symmetry -- qubit 0 is + # so this pins that the position argument reaches the measurement rather + # than the method always measuring qubit 0. + # + # H, CNOT, H leaves (|0>|+> + |1>|->)/sqrt(2). Forcing qubit 1 onto the -1 + # eigenstate of X selects the second branch, so the state collapses to + # |1>|->. Measuring qubit 0 instead would leave both qubits in an X + # eigenstate and never produce a |1> on qubit 0. + stab = Stabilizer(2) + stab.apply_hadamard(0) + stab.apply_cnot(0, 1) + stab.apply_hadamard(1) + + outcome = stab.apply_x_measurement(1, measurement_determinism=1) + + ket_one = np.array([[0.0, 0.0], [0.0, 1.0]]) + assert outcome == 1 + assert np.allclose(tableau_to_density(stab.tableau), np.kron(ket_one, _MINUS)) + + +def test_forcing_an_outcome_cannot_override_a_determined_one(): + # measurement_determinism selects among genuinely possible outcomes; it + # does not override a determined one. |-> is the -1 eigenstate of X, so + # measuring it in the X basis yields 1 whatever is asked for. + for determinism in (0, 1, "probabilistic"): + stab = Stabilizer(1) + stab.apply_hadamard(0) + stab.apply_sigmaz(0) + + outcome = stab.apply_x_measurement(0, measurement_determinism=determinism) + + assert outcome == 1, f"determinism={determinism}" + assert np.allclose(tableau_to_density(stab.tableau), _MINUS) From f0f7d5fc6cbd2ad5a1b0c14501e9f261f942932d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:06:48 -0400 Subject: [PATCH 092/227] [fix] Copy the whole row, sign included, on a Z-basis measurement When a probabilistic Z measurement replaces a stabilizer generator, the displaced generator becomes the destabilizer partner for the measured qubit. z_measurement_gate copied only its x/z bits into the destabilizer row and left the phase and i-phase behind, so that row kept whatever sign the row sums above had left in it. The copy now carries phase and i-phase too, and the fresh +/-Z row's i-phase is reset to zero. --- .../backends/stabilizer/functions/clifford.py | 9 +- ...est_z_measurement_destabilizer_row_copy.py | 99 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/test_state_representation/stabilizer/test_z_measurement_destabilizer_row_copy.py diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index bca3edaa..49d33bb2 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -79,12 +79,19 @@ def z_measurement_gate( tableau.iphase = iphase_vector table = tableau.table - # set x_p - n row equal to x_p row + # set x_p - n row equal to x_p row, phase and i phase included: the + # displaced generator becomes the new destabilizer partner for this + # qubit, so its whole row -- not just its symplectic bits -- moves. table[x_p - n_qubits] = table[x_p] + tableau.phase[x_p - n_qubits] = tableau.phase[x_p] + tableau.iphase[x_p - n_qubits] = tableau.iphase[x_p] # set x_p row equal to 0 except for z element of measured qubit which is 1. table[x_p] = np.zeros(2 * n_qubits) table[x_p, qubit_position + n_qubits] = 1 + # this row is now a fresh +/-Z_a generator, which carries a bare sign + # and no factor of i. + tableau.iphase[x_p] = 0 # set that row of the phase vector to random measurement outcome 0 or 1 with an equal probability. # We have the option to remove randomness and pick a specific outcome diff --git a/tests/test_state_representation/stabilizer/test_z_measurement_destabilizer_row_copy.py b/tests/test_state_representation/stabilizer/test_z_measurement_destabilizer_row_copy.py new file mode 100644 index 00000000..6daec09f --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_z_measurement_destabilizer_row_copy.py @@ -0,0 +1,99 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +When a probabilistic Z-basis measurement replaces a stabilizer generator with +the fresh outcome, the generator it displaces has to move somewhere: it +becomes the new destabilizer partner for the measured qubit. Aaronson-Gottesman +say to set that whole destabilizer row equal to the displaced generator's row, +sign included, and the code does: the destabilizer row's phase and i-phase +always end up matching the displaced generator's own, never whatever those +entries happened to hold going into the measurement (typically leftover +arithmetic from combining that row with others). + +These tests build tableaux by hand so the displaced generator's phase and +i-phase are known quantities, and check that the destabilizer row ends up +carrying exactly them -- copying only the symplectic (x/z) bits and leaving +the sign behind would fail every one of them. +""" + +import numpy as np + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + z_measurement_gate, +) +from graphiq.backends.stabilizer.functions.transformation import ( + cnot_gate, + hadamard_gate, +) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def test_the_displaced_generators_sign_moves_to_the_destabilizer_row(): + # (|00> - |11>) / sqrt(2): stabilizers -XX, +ZZ. Measuring qubit 0 finds + # a single stabilizer row with x-support there (the XX row), so the + # row-sum loop above the copy never touches it -- its phase is exactly + # what it was before the measurement, a sign of 1. A destabilizer row + # that fails to inherit it stays at its initial 0. + tableau = _bell_state() + tableau.phase[2] = 1 # the XX stabilizer row becomes "-XX" + + measured, outcome, x_p = z_measurement_gate(tableau, 0, measurement_determinism=0) + + assert x_p == 2 + assert measured.phase[x_p - measured.n_qubits] == 1 + + +def test_the_displaced_generators_i_phase_moves_to_the_destabilizer_row(): + # A hand-built Bell state in a basis where the destabilizer partner of + # the measured stabilizer row shares its x-support: x-column [1,0,1,0] + # for qubit 0 puts both the destabilizer row and the stabilizer row (XX) + # in the row-sum loop, which combines them and leaves the destabilizer + # row's i-phase at whatever that combination produced -- 1 here -- unless + # the copy overwrites it with the displaced generator's own i-phase, 0. + table = np.array( + [ + [1, 1, 1, 0], # destabilizer 0: YX + [0, 1, 0, 0], # destabilizer 1: IX + [1, 1, 0, 0], # stabilizer 0: XX + [0, 0, 1, 1], # stabilizer 1: ZZ + ] + ) + tableau = CliffordTableau(table) + + measured, outcome, x_p = z_measurement_gate(tableau, 0, measurement_determinism=0) + + assert x_p == 2 + assert measured.iphase[x_p - measured.n_qubits] == 0 + + +def test_the_fresh_generator_row_carries_no_i_phase(): + # The measured row is overwritten with a fresh +/-Z_a generator, which has + # a bare +1/-1 sign and no factor of i. Its i-phase entry should read 0 + # regardless of what it held going into the measurement. + tableau = _bell_state() + tableau.iphase[2] = 1 # a value the row's own bookkeeping should not keep + + measured, outcome, x_p = z_measurement_gate(tableau, 0, measurement_determinism=0) + + assert x_p == 2 + assert measured.iphase[x_p] == 0 From 6d874cd230b6dfcce30e957883535211b39ec134 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:08:25 -0400 Subject: [PATCH 093/227] [fix] Fold every matching tableau in MixedStabilizer.reduce reduce popped from the list it was iterating with enumerate, so each merge skipped the element after it: four identical tableaux reduced to two entries at 0.75 and 0.25. It now collects the non-matching entries instead, compares tableaux with == rather than counting the nonzeros of a bool, and copies the mixture first, since the setter stores the caller's list by reference. --- graphiq/backends/stabilizer/state.py | 19 +++-- .../test_mixed_stabilizer_reduce.py | 84 +++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index 46d403e3..11a59069 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -495,22 +495,25 @@ def tableau(self): def reduce(self): """ - Reduce the number of tableaux store in the mixture by comparing the Hamming distance between them. - Probabilities are summed and one tableau removed if they are the same. + Fold tableaux that compare equal into a single entry, summing their probabilities. :return: nothing :rtype: None """ # TODO: explore other ways of reduction and further simplification using a standard form. - mixture_temp = self._mixture + # Copy: the mixture setter stores the caller's list by reference, and the + # pop below would otherwise take an element out from under them. + mixture_temp = list(self._mixture) mixture_reduce = [] while len(mixture_temp) != 0: - p0, t0 = mixture_temp[0] - mixture_temp.pop(0) - for i, (p_i, t_i) in enumerate(mixture_temp): - if np.count_nonzero(t0 != t_i) == 0: + p0, t0 = mixture_temp.pop(0) + remaining = [] + for p_i, t_i in mixture_temp: + if t0 == t_i: p0 += p_i - mixture_temp.pop(i) + else: + remaining.append((p_i, t_i)) + mixture_temp = remaining mixture_reduce.append((p0, t0)) self._mixture = mixture_reduce diff --git a/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py b/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py new file mode 100644 index 00000000..5ebcf933 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py @@ -0,0 +1,84 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``MixedStabilizer.reduce`` folds identical tableaux in the mixture together, +summing their probabilities. Every match has to be found: comparing a +matched entry and moving on, without also re-checking the entry that shifted +into its place, would leave later duplicates unmerged. + +These tests build mixtures out of tableaux that are ``==`` to each other -- +copies of the very same tableau -- so the only thing under test is whether +every match gets folded in, not whether the equality check itself is generous +enough to recognise two different routes to the same state. That is a +separate limitation of ``CliffordTableau.__eq__``, which compares destabilizer +rows and generator order without canonicalizing, and is not what this tests. +""" + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.state import MixedStabilizer + + +def test_four_identical_tableaux_reduce_to_one(): + tableau = CliffordTableau(2) + mixed = MixedStabilizer([(0.25, tableau.copy()) for _ in range(4)]) + + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, reduced = mixed.mixture[0] + assert probability == 1.0 + assert reduced == tableau + + +def test_reduce_still_keeps_distinct_tableaux_separate(): + zero_state = CliffordTableau(2) + one_state = CliffordTableau(2) + one_state.phase[one_state.n_qubits] = 1 # a different generator sign + + mixed = MixedStabilizer( + [ + (0.5, zero_state.copy()), + (0.5, one_state.copy()), + ] + ) + + mixed.reduce() + + assert len(mixed.mixture) == 2 + + +def test_reduce_folds_three_matching_entries_in_a_row(): + # A run of three matching entries is exactly where an off-by-one skip + # would drop the third instead of merging it: once the first match is + # removed, the next matching entry shifts into the position such a skip + # would pass over. + tableau = CliffordTableau(2) + other = CliffordTableau(2) + other.phase[other.n_qubits] = 1 + + mixed = MixedStabilizer( + [ + (0.25, tableau.copy()), + (0.25, tableau.copy()), + (0.25, tableau.copy()), + (0.25, other.copy()), + ] + ) + + mixed.reduce() + + assert len(mixed.mixture) == 2 + probabilities = sorted(p for p, _ in mixed.mixture) + assert probabilities == [0.25, 0.75] From d5d380eb36d3810874a364855d341f85a15f6dca Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:24:36 -0400 Subject: [PATCH 094/227] [fix] Reject a stabilizer tableau with an i phase when converting to a density matrix The stabilizer-to-density-matrix conversion reads each generator as a Pauli string with a +1/-1 sign, and CliffordTableau.to_stabilizer() drops the i-phase vector. A tableau carrying a factor of i would therefore convert to a plausible but wrong density matrix instead of failing. QuantumState now raises ValueError when converting such a tableau, pure or in a mixture. No correct gate or measurement produces one, now that the Z-basis measurement copies the displaced row's i phase along with it: 1500 random sequences of Clifford gates, measurements, resets, swaps and qubit removals all end with an all-zero i-phase vector. --- graphiq/state.py | 27 ++++++++++++++ .../test_stabilizer_to_density_sign.py | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/graphiq/state.py b/graphiq/state.py index 107c53d8..be250be0 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -40,6 +40,29 @@ STABILIZER_MIXED_REPRESENTATION = "branch" +def _reject_unrepresentable_i_phase(tableau): + """ + Raise if a Clifford tableau carries a factor of i, which a density matrix cannot be built from. + + The stabilizer-to-density-matrix conversion reads each generator as a Pauli string with a +1/-1 + sign, so a tableau carrying an i phase would silently convert to a plausible but wrong density + matrix. No correct gate or measurement produces one: a stabilizer group consists of commuting + Hermitian Paulis with +1/-1 signs, and the Z-basis measurement copies a stabilizer row's own + (zero) i phase into the destabilizer half. + + :param tableau: the tableau about to be converted + :type tableau: CliffordTableau + :raises ValueError: if any entry of the tableau's i-phase vector is non-zero + :return: nothing + :rtype: None + """ + if np.any(tableau.iphase): + raise ValueError( + f"Cannot convert to a density matrix: the tableau carries a non-zero i phase: " + f"{tableau.iphase}" + ) + + class QuantumState: """ The QuantumState class is the unified API for accessing state representation backends. @@ -313,6 +336,7 @@ def _stabilizer_to_density(self, rep): :param rep: initial representation :type rep: Stabilizer or MixedStabilizer + :raises ValueError: if a tableau carries a non-zero i phase :return: a density matrix :rtype: DensityMatrix """ @@ -320,9 +344,12 @@ def _stabilizer_to_density(self, rep): data_list = [] assert isinstance(rep, MixedStabilizer) for p_i, t_i in rep.mixture: + # checked here: to_stabilizer() drops the i-phase vector + _reject_unrepresentable_i_phase(t_i) data_list.append((p_i, t_i.to_stabilizer())) rho = rc.stabilizer_to_density(data_list) else: + _reject_unrepresentable_i_phase(rep.data) rho = rc.stabilizer_to_density(rep.data.to_stabilizer()) return DensityMatrix(rho) diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py b/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py index 8d29cc26..32fce660 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py @@ -21,9 +21,12 @@ import networkx as nx import numpy as np +import pytest import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.state import QuantumState def test_stabilizer_to_density_converts_ket_one_correctly(): @@ -63,3 +66,37 @@ def test_stabilizer_to_density_leaves_a_graph_state_unchanged(): rho = rc.stabilizer_to_density(tableau) assert np.allclose(rho, rc.graph_to_density(graph)) + + +def test_conversion_rejects_a_nonzero_i_phase(): + # StabilizerTableau has no iphase -- CliffordTableau.to_stabilizer() drops + # it -- so the check has to happen on the CliffordTableau. A generator + # carrying a factor of i cannot be read as a bare +1/-1 sign. + tableau = CliffordTableau(1) + tableau.iphase[tableau.n_qubits] = 1 + + state = QuantumState(tableau, rep_type="s") + + with pytest.raises(ValueError, match="i phase"): + state.convert_representation("dm") + + +def test_conversion_rejects_a_nonzero_destabilizer_i_phase(): + tableau = CliffordTableau(1) + tableau.iphase[0] = 1 + + state = QuantumState(tableau, rep_type="s") + + with pytest.raises(ValueError, match="i phase"): + state.convert_representation("dm") + + +def test_conversion_rejects_a_nonzero_i_phase_in_a_mixture(): + good = CliffordTableau(1) + bad = CliffordTableau(1) + bad.iphase[bad.n_qubits] = 1 + + state = QuantumState([(0.5, good), (0.5, bad)], rep_type="s", mixed=True) + + with pytest.raises(ValueError, match="i phase"): + state.convert_representation("dm") From 55ebf54a8991ac5d7760eee9aaf91e565460bc9b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:31:59 -0400 Subject: [PATCH 095/227] [fix] Order height_dict's graph nodes by label height_dict(graph=...) passed list(graph.nodes()).sort() as the node list, but list.sort() sorts in place and returns None, so the adjacency matrix was built in edge-insertion order rather than by label. It now uses sorted(graph.nodes()), and the docstring says so. Graphs whose nodes were inserted in label order, such as those built with nx.from_numpy_array, are unaffected. --- graphiq/backends/stabilizer/functions/height.py | 7 ++++++- .../stabilizer/test_height_functions.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/stabilizer/functions/height.py b/graphiq/backends/stabilizer/functions/height.py index ec6cfb2a..37f08bf4 100644 --- a/graphiq/backends/stabilizer/functions/height.py +++ b/graphiq/backends/stabilizer/functions/height.py @@ -101,6 +101,11 @@ def height_dict(x_matrix=None, z_matrix=None, graph=None): Generates the height_function dictionary for all qubits, given the x and z matrices or the graph the state corresponds to. + When called with ``graph=``, nodes are ordered by label (not by insertion order) to build + the adjacency matrix. The height function depends on this ordering, so a caller who wants + the minimum emitter count (rather than the value for this particular ordering) must minimize + over orderings itself. + :param x_matrix: x matrix in the symplectic representation of the stabilizers :type x_matrix: np.ndarray :param z_matrix: z matrix in the symplectic representation of the stabilizers @@ -113,7 +118,7 @@ def height_dict(x_matrix=None, z_matrix=None, graph=None): if x_matrix is None or z_matrix is None: if isinstance(graph, nx.classes.graph.Graph): n_qubits = len(graph) - node_list = list(graph.nodes()).sort() + node_list = sorted(graph.nodes()) # nodelist is an essential kwarg in converting graph to adjacency matrix. z_matrix = nx.to_numpy_array(graph, nodelist=node_list) x_matrix = np.eye(n_qubits) diff --git a/tests/test_state_representation/stabilizer/test_height_functions.py b/tests/test_state_representation/stabilizer/test_height_functions.py index db2f24b4..ff2f8997 100644 --- a/tests/test_state_representation/stabilizer/test_height_functions.py +++ b/tests/test_state_representation/stabilizer/test_height_functions.py @@ -196,3 +196,14 @@ def test_height_dict_needs_graph_or_matrices(): # an empty (falsy) graph argument falls through to the "neither" branch with pytest.raises(ValueError, match="Either a graph or both"): height_dict(graph=[]) + + +def test_height_dict_from_graph_orders_nodes_by_label(): + # Nodes are added in the order 0, 2, 1, 3; height_dict(graph=...) must key its result by + # node label, not by the order the edges were inserted in. + graph = nx.Graph() + graph.add_edges_from([(0, 2), (1, 3)]) + + h_dict = height_dict(graph=graph) + + assert [h_dict[0], h_dict[1], h_dict[2], h_dict[3]] == [1, 2, 1, 0] From 6dd7f7083e18833d9a32d5ee1030b026ea5cb9d2 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:39:14 -0400 Subject: [PATCH 096/227] [fix] Make the evolutionary solver's candidate order independent of PYTHONHASHSEED _select_possible_cnot_position and _select_possible_measurement_position built their candidate list by iterating a set of edge tuples. Set iteration order depends on PYTHONHASHSEED, which CPython randomises per process, so the same numpy seed picked a different edge in a different process, and a seeded search that reached either helper was not reproducible. The candidates are now filtered from the edge list in its own order. sorted() would not work: node ids mix str and int. Seeded searches that reach either helper can now follow a different path than before, and follow the same path in every process. With the default transformations that is every search with more than one emitter, and every HybridEvolutionarySolver search, whose defaults include add_measurement_cnot_and_reset at any emitter count; so search_for_alternative_circuits in graphiq/benchmarks/alternate_circuits.py is among them. EvolutionarySolver searches with one emitter and the default transformations reach neither helper and are unchanged. --- graphiq/solvers/evolutionary_solver.py | 14 +- ...volutionary_solver_seed_reproducibility.py | 264 ++++++++++++++++++ 2 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 tests/test_solvers/test_evolutionary_solver_seed_reproducibility.py diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index f74ec2d9..35c27618 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -778,10 +778,11 @@ def _select_possible_cnot_position(circuit): ] for edge in edges: - possible_edges = set(edges) - circuit.find_incompatible_edges(edge) + incompatible = circuit.find_incompatible_edges(edge) - for another_edge in possible_edges: - edge_pair.append((edge, another_edge)) + for another_edge in edges: + if another_edge not in incompatible: + edge_pair.append((edge, another_edge)) return edge_pair @@ -815,10 +816,11 @@ def _select_possible_measurement_position(circuit): ] for edge in e_edges: - possible_edges = set(p_edges) - circuit.find_incompatible_edges(edge) + incompatible = circuit.find_incompatible_edges(edge) - for another_edge in possible_edges: - edge_pair.append((edge, another_edge)) + for another_edge in p_edges: + if another_edge not in incompatible: + edge_pair.append((edge, another_edge)) return edge_pair diff --git a/tests/test_solvers/test_evolutionary_solver_seed_reproducibility.py b/tests/test_solvers/test_evolutionary_solver_seed_reproducibility.py new file mode 100644 index 00000000..f21337f9 --- /dev/null +++ b/tests/test_solvers/test_evolutionary_solver_seed_reproducibility.py @@ -0,0 +1,264 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +`SolverBase.seed` reseeds only `numpy.random` and the `random` module. +`_select_possible_cnot_position` and `_select_possible_measurement_position` used to build their +candidate list by iterating a Python `set` of edge tuples, whose iteration order depends on +`PYTHONHASHSEED`. Since CPython randomises that per process, the same numpy seed could select a +different edge in a different process, so a seeded search was not reproducible across processes. + +Two properties are pinned here. The candidate order must not depend on `PYTHONHASHSEED`, and the +helpers must not order-compare node ids at all: ids are a mix of `str` (register endpoints) and +`int` (interior ids minted by `CircuitDAG._unique_node_id`), so `sorted()` raises +`TypeError: '<' not supported between instances of 'str' and 'int'`. Any fixture used here must +therefore contain inserted gates -- an *empty* `CircuitDAG` has only string ids and is exactly the +case in which a `sorted()`-based fix appears to work. + +`PYTHONHASHSEED` is read once at interpreter startup, so this needs separate processes; it cannot +be exercised by forking or by reseeding in-process. Fingerprints use op class name, `register`, +`reg_type` and `q_registers` -- never `repr`/`str` of a circuit or op, which embeds a +`<... object at 0x...>` address that differs between processes regardless of any bug. +""" + +import os +import pathlib +import subprocess +import sys + +from graphiq.circuit import ops +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.solvers.evolutionary_solver import EvolutionarySolver + +# Several hash seeds, not two, because how many seeds it takes to expose the bug is +# fixture-dependent and not obvious from the fixture. Measured against the pre-fix helpers on +# the circuit `_CANDIDATE_ORDER_SCRIPT` builds, these five seeds produce five *distinct* +# candidate orders -- so any pair of them would do here. That is not something to rely on: on +# the smaller `_emitter_only_circuit` the same range collapses to two orders, and a two-seed +# comparison is then one coincidence away from passing against the live defect. +_HASH_SEEDS = (0, 1, 2, 3, 5) + +# Searches are the slow path (~10s per process), so they span three seeds rather than five. +_SEARCH_HASH_SEEDS = (0, 1, 2) + + +def _emitter_only_circuit(): + """ + Mixed `str`/`int` node ids on the emitter edges, which is what + `_select_possible_cnot_position` scans. Yields 4 candidate pairs. + """ + circuit = CircuitDAG(n_emitter=2, n_photon=3, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + return circuit + + +def _emitter_and_photon_circuit(): + """ + Mixed ids on the photon edges too, so `_select_possible_measurement_position` returns a + non-empty list. On `_emitter_only_circuit` that helper returns [] -- its inner loop never + runs, so a `sorted()` call there would not raise and a test built on it would pass vacuously. + """ + circuit = CircuitDAG(n_emitter=2, n_photon=3, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + circuit.add(ops.CNOT(control=1, control_type="e", target=1, target_type="p")) + circuit.add(ops.Hadamard(register=0, reg_type="p")) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + return circuit + + +def _run_with_hashseed(script, hashseed, timeout=180): + """ + Run `script` in a fresh subprocess under `PYTHONHASHSEED=hashseed`. + + A fork cannot express this: CPython reads `PYTHONHASHSEED` once at interpreter startup, so a + forked child keeps the parent's hash seed whatever the environment says afterwards. + """ + env = {**os.environ, "PYTHONHASHSEED": str(hashseed)} + # Run from the repo root so the child imports this checkout's graphiq even when it is not + # installed. Using os.getcwd() would make these tests pass only when pytest happens to be + # run from the root. + repo_root = pathlib.Path(__file__).resolve().parents[2] + result = subprocess.run( + [sys.executable, "-c", script], + env=env, + cwd=repo_root, + capture_output=True, + text=True, + timeout=timeout, + ) + assert ( + result.returncode == 0 + ), f"subprocess with PYTHONHASHSEED={hashseed} failed:\n{result.stderr}" + return result.stdout.strip() + + +def test_node_ids_are_mixed_str_and_int(): + """ + The premise the other tests rest on. If node ids ever became uniformly typed this would fail, + and the `sorted()` formulation these tests exclude would become viable -- so the exclusion + should be revisited rather than silently kept. + """ + circuit = _emitter_and_photon_circuit() + + id_types = { + type(node).__name__ + for edge in circuit.edge_dict["e"] + circuit.edge_dict["p"] + for node in edge[:2] + } + + assert id_types == {"str", "int"} + + +def test_cnot_position_candidates_do_not_order_compare_node_ids(): + """ + `sorted()` on the set difference raises `TypeError` on this fixture. Asserting the exact + length matters: an empty result would let this pass without the ordering code ever running. + + Note what this does NOT guard: it passes against the pre-fix helper too, which returns the + same four pairs in a hash-dependent order. It pins that no *future* fix reintroduces an + order comparison between mixed-type node ids -- the `sorted()` formulation this file's + module docstring rules out -- not the set-iteration bug itself. The hash-seed tests below + are the regression guards for that. + """ + edge_pair = EvolutionarySolver._select_possible_cnot_position( + _emitter_only_circuit() + ) + + assert len(edge_pair) == 4 + + +def test_measurement_position_candidates_do_not_order_compare_node_ids(): + edge_pair = EvolutionarySolver._select_possible_measurement_position( + _emitter_and_photon_circuit() + ) + + assert len(edge_pair) == 7 + + +_CANDIDATE_ORDER_SCRIPT = """ +from graphiq.circuit import ops +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.solvers.evolutionary_solver import EvolutionarySolver + +circuit = CircuitDAG(n_emitter=2, n_photon=3, n_classical=1) +circuit.add(ops.Hadamard(register=0, reg_type="e")) +circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) +circuit.add(ops.CNOT(control=1, control_type="e", target=1, target_type="p")) +circuit.add(ops.Hadamard(register=0, reg_type="p")) +circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + +print(repr(EvolutionarySolver._select_possible_cnot_position(circuit))) +print(repr(EvolutionarySolver._select_possible_measurement_position(circuit))) +""" + + +def test_candidate_order_is_hash_seed_independent(): + """ + The most direct pin of the defect: the helpers' own output on a fixed circuit, rather than a + downstream search symptom. Covers both helpers in one process per seed. + """ + orders = { + seed: _run_with_hashseed(_CANDIDATE_ORDER_SCRIPT, seed) for seed in _HASH_SEEDS + } + + assert len(set(orders.values())) == 1, ( + f"candidate order varies with PYTHONHASHSEED: {len(set(orders.values()))} " + f"distinct orders across seeds {list(orders)}" + ) + + +_SEARCH_SCRIPT = """ +import networkx as nx + +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.metrics import Infidelity +from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.state import QuantumState + + +def fingerprint(op): + return ( + type(op).__name__, + getattr(op, "register", None), + getattr(op, "reg_type", None), + op.q_registers, + ) + + +graph = nx.Graph({graph_edges}) +target = QuantumState(get_clifford_tableau_from_graph(graph), rep_type="s") +compiler = StabilizerCompiler() +compiler.measurement_determinism = 1 + +solver = EvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=compiler, + n_emitter={n_emitter}, + n_photon={n_photon}, +) +solver.seed({seed}) +solver.solve() + +score, circuit = solver.hof[0] +print(repr((score, [fingerprint(op) for op in circuit.sequence(unwrapped=True)]))) +""" + + +def test_two_emitter_search_reproduces_across_hash_seeds(): + """ + At `n_emitter > 1` `add_emitter_cnot` is in `trans_probs`, so the search reaches + `_select_possible_cnot_position`. Both the score and the winning circuit must match across + hash seeds. + """ + script = _SEARCH_SCRIPT.format( + graph_edges="[(1, 2), (2, 3), (2, 4), (4, 3), (1, 3)]", + n_emitter=2, + n_photon=4, + seed=3, + ) + + winners = {seed: _run_with_hashseed(script, seed) for seed in _SEARCH_HASH_SEEDS} + + assert len(set(winners.values())) == 1, ( + f"n_emitter=2 search gave {len(set(winners.values()))} distinct (score, circuit) " + f"results across hash seeds {list(winners)} at one numpy seed" + ) + + +def test_one_emitter_search_is_hash_seed_independent(): + """ + At `n_emitter = 1` neither helper is reached -- `add_emitter_cnot` is gated on + `n_emitter > 1` and `add_measurement_cnot_and_reset` is not in the default `trans_probs` -- + so this was already reproducible and stays so. + + It compares hash seeds, so it would have passed before the fix as well; it is a regression + guard on the unaffected path. + """ + script = _SEARCH_SCRIPT.format( + graph_edges="[(1, 2), (2, 3)]", n_emitter=1, n_photon=3, seed=5 + ) + + winners = {seed: _run_with_hashseed(script, seed) for seed in _SEARCH_HASH_SEEDS} + + assert len(set(winners.values())) == 1, ( + f"n_emitter=1 search gave {len(set(winners.values()))} distinct results across hash " + f"seeds {list(winners)}; this path reaches neither changed helper, so it should not move" + ) From 3c41561cea19b2558eaaa978d0c189448ac9a49f Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 10:40:06 -0400 Subject: [PATCH 097/227] [fix] Make _position_finder check every pivot of the X matrix _position_finder walked the row-reduced X matrix looking one row ahead and never read its own starting pivot, so a state whose first qubit needed a Hadamard was mishandled: a single Z-stabilized qubit crashed _graph_finder, and a multi-qubit Z-stabilized state such as |00> silently lost the first qubit's Hadamard and then failed the independence check. It now reads the row-echelon structure directly: each row's leftmost nonzero column is its pivot, and the qubits needing a Hadamard are the columns that are no row's pivot. --- graphiq/backends/state_rep_conversion.py | 23 ++++------- .../test_state_rep_conversion.py | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index c4a53437..b0fc2879 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -466,22 +466,13 @@ def _position_finder(x_matrix): :return: list of qubit positions to apply the Hadamard on :rtype: list """ - pivot = [0, 0] - n = x_matrix.shape[0] - pos_list = [] - while pivot[0] < n and pivot[1] < n: - try: - if x_matrix[pivot[0] + 1, pivot[1]] == 1: - pivot = [pivot[0] + 1, pivot[1]] - if x_matrix[pivot[0] + 1, pivot[1] + 1] == 1: - pivot = [pivot[0] + 1, pivot[1] + 1] - else: - pivot = [pivot[0], pivot[1] + 1] - pos_list.append(pivot[1]) - except: - break - - return pos_list + n_row, n_column = x_matrix.shape + pivot_columns = set() + for row in range(n_row): + nonzero_cols = np.nonzero(x_matrix[row])[0] + if len(nonzero_cols): + pivot_columns.add(int(nonzero_cols[0])) + return [col for col in range(n_column) if col not in pivot_columns] def _graph_finder(x_matrix, z_matrix, get_ops_data=False): diff --git a/tests/test_state_representation/test_state_rep_conversion.py b/tests/test_state_representation/test_state_rep_conversion.py index dfd9529a..a2240262 100644 --- a/tests/test_state_representation/test_state_rep_conversion.py +++ b/tests/test_state_representation/test_state_rep_conversion.py @@ -19,10 +19,48 @@ import pytest import graphiq.backends.density_matrix.functions as dmf +import graphiq.backends.stabilizer.functions.linalg as sla import graphiq.backends.state_rep_conversion as rc from graphiq.backends.stabilizer.tableau import StabilizerTableau +def test_position_finder_single_z_stabilized_qubit_needs_hadamard(): + # a Z-stabilized qubit (e.g. |0>) has an all-zero X column; _position_finder must + # report it as needing a Hadamard, or downstream _graph_finder can't invert X at all. + assert rc._position_finder(np.array([[0]])) == [0] + + +def test_position_finder_two_z_stabilized_qubits_both_need_hadamard(): + # both qubits of |00> are Z-stabilized; _position_finder must not silently drop the + # first pivot it starts on. + x, _z, _rank = sla.row_reduction( + np.array([[0, 0], [0, 0]]), np.array([[1, 0], [0, 1]]) + ) + assert rc._position_finder(x) == [0, 1] + + +def test_position_finder_only_the_pivotless_qubit_needs_hadamard(): + # qubit 0 is Z-stabilized (needs Hadamard); qubits 1, 2 are already X-stabilized. + x, _z, _rank = sla.row_reduction( + np.array([[0, 0, 0], [0, 1, 0], [0, 0, 1]]), + np.array([[1, 0, 0], [0, 0, 0], [0, 0, 0]]), + ) + assert rc._position_finder(x) == [0] + + +def test_state_to_graph_two_qubit_z_stabilized_tableau_no_longer_crashes(): + # |00>: both qubits Z-stabilized, x_matrix all-zero. Used to raise + # "AssertionError: Stabilizer generators are not independent." in _graph_finder. + x_matrix = np.zeros((2, 2)) + z_matrix = np.eye(2) + tableau = StabilizerTableau([x_matrix, z_matrix]) + + graph, tab, gate_list = rc.state_to_graph(tableau) + + assert graph.number_of_nodes() == 2 + assert gate_list == [("H", 0), ("H", 1)] + + def test_negativity(): n_qubits = 4 st1 = dmf.state_ketx0() From 42b9c870ca86f7087ee2240e43b6d23d36900661 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 11:07:41 -0400 Subject: [PATCH 098/227] [fix] Make inverse_circuit reduce every state; copy in clifford_from_stabilizer inverse_circuit could return a tableau that was not |0...0>, and a circuit that did not zero the state, with no error. Its Hadamard block only applied H to a Z-type column with support to the right of it, and never cleared the column below each pivot, so on some states the later blocks ran out of pivots and fell through silently. The rewritten block applies H to every column that has no X pivot and is not already a bare-Z qubit, then places a pivot on each diagonal entry with row operations, which change the generators written down but not the state. It raises ValueError if a column has no pivot, which cannot happen for a valid stabilizer state. clifford_from_stabilizer now reduces a copy: inverse_circuit works in place, so building a CliffordTableau from a StabilizerTableau used to reduce the caller's tableau to |0...0>. TimeReversedSolver uses inverse_circuit for the final emitter disentangling. Its circuits are unchanged for the linear, star, repeater, lattice, ring and complete benchmark graphs; for some random graphs it now emits a different, equally correct circuit. The stabilizer fidelity, which reads the generators inverse_circuit leaves, changes too: on a state where the old block ended at |0...0> written with Z generators other than the bare Z on each qubit, it could return 1 for two orthogonal states, and it now returns 0. --- .../stabilizer/functions/rep_conversion.py | 4 +- .../stabilizer/functions/stabilizer.py | 98 ++++++++++++++--- .../test_inverse_circuit_reduction.py | 104 ++++++++++++++++++ .../test_stabilizer_to_clifford_conversion.py | 74 +++++++++++++ 4 files changed, 262 insertions(+), 18 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py create mode 100644 tests/test_state_representation/stabilizer/test_stabilizer_to_clifford_conversion.py diff --git a/graphiq/backends/stabilizer/functions/rep_conversion.py b/graphiq/backends/stabilizer/functions/rep_conversion.py index 8c0dd927..619b7ee3 100644 --- a/graphiq/backends/stabilizer/functions/rep_conversion.py +++ b/graphiq/backends/stabilizer/functions/rep_conversion.py @@ -35,7 +35,9 @@ def clifford_from_stabilizer(stabilizer_tableau): :rtype: CliffordTableau """ n_qubits = stabilizer_tableau.n_qubits - _, circuit = inverse_circuit(stabilizer_tableau) + # The reduction runs in place, so it works on a copy: the caller's tableau is an + # input here and still holds its state afterwards. + _, circuit = inverse_circuit(stabilizer_tableau.copy()) clifford_tableau = sfc.create_n_ket0_state(n_qubits) return transform.run_circuit(clifford_tableau, circuit, reverse=True) diff --git a/graphiq/backends/stabilizer/functions/stabilizer.py b/graphiq/backends/stabilizer/functions/stabilizer.py index 152fdf0c..fc196e60 100644 --- a/graphiq/backends/stabilizer/functions/stabilizer.py +++ b/graphiq/backends/stabilizer/functions/stabilizer.py @@ -102,6 +102,29 @@ def pauli_type_finder(x_matrix, z_matrix, pivot): return pauli_x_list, pauli_y_list, pauli_z_list +def _bare_z_row(tableau, qubit_position): + """ + Find the generator that is a bare Pauli Z on the given qubit and the identity + everywhere else. Such a generator is unique when it exists: two of them would be + the same generator. + + :param tableau: the tableau to search + :type tableau: StabilizerTableau + :param qubit_position: the qubit the generator acts on + :type qubit_position: int + :return: the row index of that generator, or None if the tableau has none + :rtype: int or None + """ + for row_i in range(tableau.n_qubits): + if ( + not tableau.x_matrix[row_i].any() + and np.count_nonzero(tableau.z_matrix[row_i]) == 1 + and tableau.z_matrix[row_i, qubit_position] == 1 + ): + return row_i + return None + + def inverse_circuit(tableau): """ Find the inverse circuit that transforms the input stabilizer tableau back to the stabilizer tableau @@ -113,28 +136,69 @@ def inverse_circuit(tableau): :rtype: StabilizerTableau, list[tuple] """ circuit_list = [] - pivot = [0, 0] n_qubits = tableau.n_qubits tableau = canonical_form(tableau) # Hadamard block + # The blocks below clear the X matrix by column operations, keying off the diagonal + # entry of each column, and they leave a column alone when its qubit is already + # reduced. So this block has to put a generator on every diagonal entry: an X pivot + # on the qubits that still need work, the bare Z on the ones that do not. + # + # The canonical form leaves the X matrix in reduced row echelon form, with the + # generators that have no X support gathered in the rows underneath and their Z + # parts reduced in turn. The leading entry of each row therefore names the columns + # that carry an X pivot, and a generator that is a bare Z on a single qubit says + # that qubit is already reduced -- unentangled, and in one of the two computational + # basis states -- so no gate but the sign fix belongs on it. Every other + # column without an X pivot gets a Hadamard, which moves that column's Z entries + # into the X matrix. That makes the X matrix invertible on the columns that need a + # pivot: a generator with no X support is orthogonal to the X row space, because the + # group is abelian, and such an element supported only on the pivot columns is the + # identity -- in reduced row echelon form the product against pivot row i collapses + # to that element's entry at pivot column i, so commutation kills the pivot + # coordinates one at a time. Their Z entries on the other columns are therefore + # independent. + x_pivot_columns = set() + reduced_columns = set() + for row_i in range(n_qubits): + x_support = np.nonzero(tableau.x_matrix[row_i])[0] + if x_support.size: + x_pivot_columns.add(int(x_support[0])) + elif np.count_nonzero(tableau.z_matrix[row_i]) == 1: + reduced_columns.add(int(np.nonzero(tableau.z_matrix[row_i])[0][0])) + for j in range(n_qubits): - pivot[1] = j - x_list, y_list, z_list = pauli_type_finder( - tableau.x_matrix, tableau.z_matrix, pivot - ) - if x_list: - tableau = tab_row_swap(tableau, pivot[0], x_list[0]) - elif y_list: - tableau = tab_row_swap(tableau, pivot[0], y_list[0]) - elif z_list: - tableau = tab_row_swap(tableau, pivot[0], z_list[-1]) - if np.any(tableau.x_matrix[pivot[0], j + 1 : n_qubits]) or np.any( - tableau.z_matrix[pivot[0], j + 1 : n_qubits] - ): - circuit_list.append(("H", j)) - tableau = transform.hadamard_gate(tableau, j) - pivot[0] = pivot[0] + 1 + if j not in x_pivot_columns and j not in reduced_columns: + circuit_list.append(("H", j)) + tableau = transform.hadamard_gate(tableau, j) + + # Move the generator for each column onto the diagonal and clear the column below + # it. These are row operations: they change which generators are written down, not + # the state, so they cost no gates. An already-reduced qubit takes its own bare Z + # generator, which the eliminations cannot have touched -- they only add into rows + # that have X support in the column being cleared. + for j in range(n_qubits): + if j in reduced_columns: + pivot_row = _bare_z_row(tableau, j) + else: + rows_below = np.nonzero(tableau.x_matrix[j:n_qubits, j])[0] + pivot_row = j + int(rows_below[0]) if rows_below.size else None + if pivot_row is None or pivot_row < j: + # Unreachable on a valid stabilizer state. It must raise rather than + # assert: with assertions off, a pivot_row below j would swap a row + # upward over an already-placed column and return a wrong tableau with + # a circuit that does not zero the state -- silently, which is how this + # defect survived in the first place. + raise ValueError( + f"column {j} has no generator left to pivot on; the tableau is " + f"not a valid stabilizer state" + ) + tableau = tab_row_swap(tableau, j, pivot_row) + for row_i in range(j + 1, n_qubits): + if tableau.x_matrix[row_i, j] == 1: + tableau = tab_row_sum(tableau, j, row_i) + # CNOT block for j in range(n_qubits): for k in range(j + 1, n_qubits): diff --git a/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py b/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py new file mode 100644 index 00000000..a9b23d8e --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py @@ -0,0 +1,104 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``inverse_circuit`` reduces a stabilizer state to +:math:`|0\\rangle^{\\otimes n}` and hands back the gates that do it. Its +Hadamard block is what makes the reduction possible: the blocks after it clear +the x matrix by column operations, which needs a pivot on every diagonal entry +of that matrix, and only Hadamards can create one in a column whose generators +are all Z. + +The guarantee those blocks rely on, and which these tests pin down, is that the +Hadamard block leaves the x matrix with a pivot in every column. The state +below is a case where a per-column walk that parks one generator per column +without checking that the generator has support there runs out of generators +before the last column. + +The reduction is silent -- nothing in ``inverse_circuit`` raises when it fails +-- so these tests assert on the returned tableau directly rather than only +through a reference. A state that did not reduce still carries X or Y +generators, which needs no oracle to spot. +""" + +import numpy as np + +from graphiq.backends.stabilizer.functions.stabilizer import ( + canonical_form, + inverse_circuit, +) +from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +def _stabilizer_state(labels, phase): + tableau = StabilizerTableau(len(labels)) + tableau.from_labels(labels) + tableau.phase = np.array(phase) + return tableau + + +#: A five-qubit state written in canonical form, chosen so that qubit 1 is +#: stabilized on its own by ``IZIII`` while every other generator's support +#: sits in columns 0, 2, 3 and 4. A per-column walk hands column 1 the lone +#: ``IZIII``, which needs no Hadamard, and then has nothing left with support +#: in column 4 by the time it gets there. +_NO_PIVOT_IN_THE_LAST_COLUMN = ( + ["XIYZZ", "IIZYZ", "IIZZY", "ZIZII", "IZIII"], + [0, 1, 1, 0, 1], +) + + +def test_a_state_with_a_lone_z_generator_reduces_to_the_all_zero_state(): + state = _stabilizer_state(*_NO_PIVOT_IN_THE_LAST_COLUMN) + + reduced, _ = inverse_circuit(state.copy()) + + assert reduced == StabilizerTableau(5) + + +def test_the_circuit_for_a_state_with_a_lone_z_generator_zeroes_it(): + state = _stabilizer_state(*_NO_PIVOT_IN_THE_LAST_COLUMN) + + _, circuit_list = inverse_circuit(state.copy()) + + # The circuit is compared in canonical form because it is only required to + # land the state on |0...0>, not to reproduce any particular generators + # for it: the reduction interleaves row operations that never enter the + # circuit, so the two end up in different bases of the same group. + assert canonical_form(run_circuit(state.copy(), list(circuit_list))) == ( + StabilizerTableau(5) + ) + + +def test_reducing_the_all_zero_state_asks_for_no_gates(): + reduced, circuit_list = inverse_circuit(StabilizerTableau(4)) + + assert circuit_list == [] + assert reduced == StabilizerTableau(4) + + +def test_the_reducing_circuit_skips_the_qubits_that_are_already_reduced(): + # A Bell pair on qubits 0 and 2, with qubits 1 and 3 in |0> and |1>. A + # generator that is a bare Z on one qubit says that qubit is unentangled + # and in |0> or |1>, so no gate but the sign fix belongs on it. + state = _stabilizer_state( + ["XIXI", "ZIZI", "IZII", "IIIZ"], + [0, 0, 0, 1], + ) + + reduced, circuit_list = inverse_circuit(state.copy()) + + assert reduced == StabilizerTableau(4) + assert [gate for gate in circuit_list if set(gate[1:]) & {1, 3}] == [("X", 3)] diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_to_clifford_conversion.py b/tests/test_state_representation/stabilizer/test_stabilizer_to_clifford_conversion.py new file mode 100644 index 00000000..64a53c11 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_stabilizer_to_clifford_conversion.py @@ -0,0 +1,74 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Building a ``CliffordTableau`` from a ``StabilizerTableau`` finds the +destabilizers by reducing the state to :math:`|0\\rangle^{\\otimes n}` and +replaying the reducing circuit backwards. The reduction runs in place, so the +conversion works on a copy: the caller's tableau is an input, and it still +holds the state it held before. Solvers hand their working tableau to this +conversion, so a conversion that reduced the caller's own generators would +leave them holding the all-zero state instead. + +The conversion also has to preserve the state, not just leave the argument +alone -- the Clifford tableau's stabilizer half generates the same group the +stabilizer tableau did, signs included. +""" + +import numpy as np + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.rep_conversion import ( + clifford_from_stabilizer, + stabilizer_from_clifford, +) +from graphiq.backends.stabilizer.functions.stabilizer import canonical_form +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +def _signed_ghz3(): + """A GHZ3 state whose generators carry signs, so a lost phase shows up.""" + tableau = StabilizerTableau(3) + tableau.from_labels(["XXX", "ZZI", "IZZ"]) + tableau.phase = np.array([1, 0, 1]) + return tableau + + +def test_the_clifford_tableau_constructor_leaves_the_stabilizer_tableau_alone(): + state = _signed_ghz3() + before = state.copy() + + CliffordTableau(state) + + assert state == before + + +def test_clifford_from_stabilizer_leaves_its_argument_alone(): + state = _signed_ghz3() + before = state.copy() + + clifford_from_stabilizer(state) + + assert state == before + + +def test_the_clifford_tableau_holds_the_state_it_was_built_from(): + state = _signed_ghz3() + # Taken before the conversion so that this holds the conversion to the + # state, independently of whether it leaves the argument alone. + expected = canonical_form(state.copy()) + + recovered = stabilizer_from_clifford(CliffordTableau(state)) + + assert canonical_form(recovered) == expected From d27d95cf0843051db8106c420d8af04a8cc8e6f1 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 11:10:25 -0400 Subject: [PATCH 099/227] [fix] Check LC equivalence per connected component is_lc_equivalent searched the whole graph for a sum of at most two solution-basis vectors, but that bound only holds for a connected graph: on a graph with several components a solution can need basis vectors from each of them. So two isolated vertices vs themselves, or two disjoint triangles vs two disjoint 3-vertex stars, came out as not LC equivalent. Local complementation preserves connected components and the linear system decouples over them, so the check now runs once per component, and graphs whose components differ as vertex sets are not equivalent. The deterministic search also tried only pairwise sums of basis vectors and skipped the single basis vectors, which are among the allowed candidates. An unknown mode was only rejected after the early returns, so it usually returned a result instead of raising; it is now checked first. The boolean result on connected graphs is unchanged, but the returned Clifford operation can differ, since single basis vectors are now tried before pairwise sums. AlternateTargetSolver appends the gates converter_gate_list builds from that operation to every LC candidate that differs from its isomorph, so its candidate circuits can change when it is asked for more than one LC graph: with allow_relabel=False, n_lc_graphs=10 and seed 1, 10 of the 141 connected 3- to 6-node atlas graphs, all with 5 or 6 nodes, give different circuits. With its constructor's default setting (one isomorph, one LC graph) it is unchanged. --- graphiq/backends/graph/state.py | 9 +- graphiq/backends/lc_equivalence_check.py | 118 +++++-- .../test_graph_LC_equivalence.py | 325 ++++++++++++++++++ 3 files changed, 428 insertions(+), 24 deletions(-) diff --git a/graphiq/backends/graph/state.py b/graphiq/backends/graph/state.py index be7acc7b..1dea133e 100644 --- a/graphiq/backends/graph/state.py +++ b/graphiq/backends/graph/state.py @@ -196,10 +196,13 @@ def lc_equivalent(self, other_graph, mode="deterministic"): :param other_graph: the other graph against which to check LC equivalence :type other_graph: Graph :param mode: the chosen mode for finding solutions. It can be either 'deterministic' (default) or 'random'. + 'deterministic' is exact: a returned False is conclusive. 'random' is a bounded random search that can + return False even for graphs that are in fact LC equivalent. :type mode: str - :raises AssertionError: if the number of rows in the row reduced matrix is less than the rank of coefficient - matrix or if the number of linearly dependent columns is not equal to $4n - rank$ - (for $n$ being the number of nodes in the graph) + :raises AssertionError: if the two adjacency matrices differ in shape, if the number of rows in the row + reduced matrix is less than the rank of coefficient matrix, or if the number of linearly dependent + columns is not equal to $4n - rank$ (for $n$ being the number of nodes in the graph) + :raises ValueError: if ``mode`` is neither ``"deterministic"`` nor ``"random"`` :return: If a solution is found, returns True and an array of single-qubit Clifford $2 \times 2$ matrices in the symplectic formalism. If not, graphs are not LC equivalent and returns False, None. :rtype: bool, numpy.ndarray or None diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index 02d2d95e..b9cf894d 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -97,16 +97,17 @@ Remember that if the system of equations is overdetermined after removing linearly dependent constraints, then there exists no solution (other than :math:`Q=0` which is not a valid Clifford operation) -and graph states are not equivalent for sure. It is proved that a solution for a valid -$Q$ exists, if and only if the set +and graph states are not equivalent for sure. For a **connected** graph, Bouchet (4.3) and (4.4) +[Combinatorica 11(4):315, 1991] show that when the solution space has dimension greater than 4, a valid +$Q$ exists iff one of the sums of **at most two** basis vectors $$ - \left\{b+b^{\prime} \mid b, b^{\prime} \in B\right\} + \{b, b + b' \mid b, b' \in B,\ b \neq b'\} $$ -has a member that satisfies the constraint. Note that this is only true for the cases where $B$ is at least of size 4. -However, this is rarely the case and has not practical significance. For smaller $B$, one should test all possible -solutions (at most 16) to see if a valid $Q$ can be found. +satisfies the constraint (the basis vectors themselves and their pairwise sums); at dimension at most 4, all +(at most 16) elements of $B$'s span are tested instead; and the check is run per connected component, because +local complementation preserves components and the linear system decouples over them. In order to find a basis for set B, one needs to use the $n^2 \times 4n$ coefficient matrix of the system of linear equations, find the linearly dependent columns (this is done in the code by a row-reduction process), and choose any @@ -168,7 +169,7 @@ """ -from itertools import combinations, permutations +from itertools import chain, combinations, permutations import networkx as nx import numpy as np @@ -182,20 +183,94 @@ def is_lc_equivalent(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): It takes two adjacency matrices as input and returns a numpy.ndarray containing $n$ ($2 \times 2$ arrays = clifford operations on each qubit. + The check is run once per connected component of the two graphs: Bouchet (4.3) bounds the codimension of + the solution space's affine subspace of valid vectors by 2 only for a connected graph, so on a graph with + :math:`c` components the whole-graph candidate set of sums of at most two basis vectors is incomplete (up + to :math:`2c` basis vectors may be needed), and the linear system decouples over components. For adjacency + matrices of shape ``(0, 0)`` (no nodes), returns ``(True, numpy.ndarray of shape (0, 2, 2))`` without + further computation. + :param adj_matrix1: the adjacency matrix of the first graph. This is equal to the binary matrix for representing Pauli Z part of the symplectic binary representation of the stabilizer generators :type adj_matrix1: numpy.ndarray :param adj_matrix2: the adjacency matrix of the second graph :type adj_matrix2: numpy.ndarray :param mode: the chosen mode for finding solutions. It can be either 'deterministic' (default) or 'random'. + 'deterministic' is exact: a returned False is conclusive. 'random' is a bounded random search that can + return False even for graphs that are in fact LC equivalent. + :type mode: str + :param seed: an optional input to set the random seed for the random search approach + :type seed: int + :raises AssertionError: if the two adjacency matrices differ in shape, if the number of rows in the row + reduced matrix is less than the rank of coefficient matrix, or if the number of linearly dependent + columns is not equal to :math:`4n - rank` (for :math:`n` being the number of nodes in the graph) + :raises ValueError: if ``mode`` is neither ``"deterministic"`` nor ``"random"`` + :return: If a solution is found, returns True and an array of single-qubit Clifford :math:`2 \\times 2` + matrices in the symplectic formalism. If not, graphs are not LC equivalent and returns False, None. + :rtype: bool, numpy.ndarray or None + """ + if mode not in ("deterministic", "random"): + raise ValueError( + 'The mode should be either "random" or "deterministic" (default)' + ) + assert np.shape(adj_matrix1) == np.shape(adj_matrix2), "graphs must be of same size" + n_nodes = np.shape(adj_matrix1)[0] + if n_nodes == 0: + return True, np.zeros((0, 2, 2), dtype=int) + + # local complementation preserves connected components (Bouchet, Combinatorica 11(4):315, 1991, + # Section 6), so the two graphs can only be LC equivalent if they partition into the same + # components (as vertex sets). + comps1 = [ + sorted(c) for c in nx.connected_components(nx.from_numpy_array(adj_matrix1)) + ] + comps2 = [ + sorted(c) for c in nx.connected_components(nx.from_numpy_array(adj_matrix2)) + ] + if {frozenset(c) for c in comps1} != {frozenset(c) for c in comps2}: + return False, None + + solution = np.zeros((n_nodes, 2, 2), dtype=int) + for idx in comps1: + equivalent, sub_solution = _is_lc_equivalent_connected( + adj_matrix1[np.ix_(idx, idx)], adj_matrix2[np.ix_(idx, idx)], mode, seed + ) + if not equivalent: + return False, None + solution[idx] = sub_solution + return True, solution + + +def _is_lc_equivalent_connected(adj_matrix1, adj_matrix2, mode, seed): + r""" + Determines whether two adjacency matrices that are each a single connected graph are local-Clifford + equivalent, and if so, finds the Clifford operation needed to convert one to the other. + + Callers must pass connected graphs: :py:func:`is_lc_equivalent` enforces this by calling this function once + per connected component. When the solution space has dimension greater than 4, Bouchet (4.3) and (4.4) + [Combinatorica 11(4):315, 1991] show that a valid Clifford operation, when one exists, is a sum of at most + two vectors of any basis of that space, so only the basis vectors and their pairwise sums are tried; at + dimension at most 4, all (at most 16) elements of the solution space are tried instead. Section 6 (p. 324) + is why running this per connected component, rather than once on the whole graph, is not a restriction: local + complementation preserves connected components. In ``"random"`` mode, ``_random_checker`` reseeds the global + NumPy random stream with ``seed``; harmless on its own, but note that :py:func:`is_lc_equivalent` calls this + function once per component, so the stream is reseeded to the same value at the start of every component. + + :param adj_matrix1: the adjacency matrix of the first graph's component. This is equal to the binary matrix + for representing Pauli Z part of the symplectic binary representation of the stabilizer generators + :type adj_matrix1: numpy.ndarray + :param adj_matrix2: the adjacency matrix of the second graph's component + :type adj_matrix2: numpy.ndarray + :param mode: the chosen mode for finding solutions. It can be either 'deterministic' (default) or 'random'. + Assumed already validated by :py:func:`is_lc_equivalent`; this function does not check it again. :type mode: str :param seed: an optional input to set the random seed for the random search approach :type seed: int :raises AssertionError: if the number of rows in the row reduced matrix is less than the rank of coefficient matrix or if the number of linearly dependent columns is not equal to :math:`4n - rank` (for :math:`n` being the number of nodes in the graph) - :return: If a solution is found, returns an array of single-qubit Clifford :math:`2 \\times 2` matrices - in the symplectic formalism. If not, graphs are not LC equivalent and returns None. + :return: If a solution is found, returns True and an array of single-qubit Clifford :math:`2 \\times 2` + matrices in the symplectic formalism. If not, components are not LC equivalent and returns False, None. :rtype: bool, numpy.ndarray or None """ @@ -257,24 +332,25 @@ def is_lc_equivalent(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): else: return False, None - elif mode == "deterministic": - basis = _solution_basis_finder(reduced_coeff_matrix, col_list) - possible_combinations = combinations(basis, 2) - solution_set = [] - for basis_combination in possible_combinations: - possible_solution = basis_combination[0] + basis_combination[1] - solution_set.append(possible_solution % 2) - for solution in solution_set: + else: + # mode == "deterministic" (mode is validated by the wrapper, so this is the only + # remaining branch). Bouchet (4.4): a valid Q, when one exists, is a sum of at most two + # vectors of basis B, so the candidates are the k basis vectors followed by their + # k(k-1)/2 pairwise sums. The empty sum (the zero vector) is also "a sum of <= 2 vectors + # of B", but it fails the per-vertex constraint (4.2) at every vertex (a_i d_i + b_i c_i + # = 0 mod 2, never 1), so it is skipped rather than tried. + basis = solution_basis + candidates = chain( + (b % 2 for b in basis), + (((b1 + b2) % 2) for b1, b2 in combinations(basis, 2)), + ) + for solution in candidates: if _is_valid_clifford(solution): # convert the solution to an array of n * (2 X 2) matrices return True, solution.reshape(n_nodes, 2, 2) # states are NOT LC equivalent return False, None - else: - raise ValueError( - 'The mode should be either "random" or "deterministic" (default)' - ) def _solution_basis_finder(reduced_coeff_matrix, col_list): diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index 119c9042..c1aded4f 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -12,11 +12,16 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import time + import matplotlib.pyplot as plt import pytest from graphiq.benchmarks import graph_states as gs from graphiq.backends.lc_equivalence_check import * +import graphiq.backends.lc_equivalence_check as lcc +import graphiq.backends.stabilizer.functions.linalg as slinalg +from graphiq.backends.graph.state import Graph from graphiq.backends.lc_equivalence_check import _is_valid_clifford from graphiq.backends.stabilizer.functions.local_cliff_equi_check import ( lc_check, @@ -253,3 +258,323 @@ def test_find_lc_operations_recovers_first_graph_from_second(): for node in op_list: g = local_comp_graph(g, node) assert np.array_equal(nx.to_numpy_array(g).astype(int), adj_matrix1) + + +def _adjacency(graph): + """ + Adjacency matrix of a networkx graph with a fixed, sorted node order, so both sides of a + comparison index their nodes the same way. + """ + return nx.to_numpy_array(graph, nodelist=sorted(graph.nodes)).astype(int) + + +def _solution_basis(z1, z2): + """ + Mirrors the row-reduction/basis-finding steps of ``is_lc_equivalent`` on the whole (not + per-component) system, so a test can enumerate every candidate solution independently of the + algorithm under test. + """ + coeff_matrix = lcc._coeff_maker(z1, z2) + reduced_coeff_matrix, _, last_nonzero_row_index = slinalg.row_reduction( + coeff_matrix, coeff_matrix * 0 + ) + n_nodes = np.shape(z1)[0] + rank = last_nonzero_row_index + 1 + if rank >= 4 * n_nodes: + return None + reduced_coeff_matrix = np.array([row for row in reduced_coeff_matrix if row.any()]) + col_list = lcc._col_finder(reduced_coeff_matrix) + return lcc._solution_basis_finder(reduced_coeff_matrix, col_list) + + +def _flat(sol): + """ + Reshapes an (n, 2, 2) Clifford-operator array into the (4n, 1) vector ``_is_valid_clifford`` + expects. + """ + return sol.reshape(-1, 1) + + +def test_disconnected_equivalent_pairs(): + """ + Minimal failing cases: the pair-sum search used to run on the whole graph instead of per + connected component, so valid vectors that need contributions from more than one component's + basis were missed. + """ + # (a) two isolated vertices vs itself: two single-node components, each trivially equivalent + # to itself via the identity. + z_two_isolated = np.zeros((2, 2)) + equivalent, solution = is_lc_equivalent(z_two_isolated, z_two_isolated) + assert equivalent + identity = np.array([[1, 0], [0, 1]]) + assert np.array_equal(solution, np.stack([identity, identity])) + + # (b) two disjoint K3 vs two disjoint 3-stars: LC equivalent component-wise, but the pair-sum + # search over the combined (6-node) system missed it. + k3 = nx.complete_graph(3) + star3 = nx.star_graph(2) + two_k3 = nx.disjoint_union(k3, k3) + two_star3 = nx.disjoint_union(star3, star3) + z1, z2 = _adjacency(two_k3), _adjacency(two_star3) + equivalent, solution = is_lc_equivalent(z1, z2) + assert equivalent + g_list = lc_graph_operations(z1, solution) + g_new = two_k3 + for i in g_list: + g_new = local_comp_graph(g_new, i) + assert nx.is_isomorphic(g_new, two_star3), "found graphs are not LC equivalent" + assert np.array_equal( + nx.to_numpy_array(g_new), z2 + ), "found graphs are not LC equivalent" + + # (c) the same pair with an added isolated vertex on both sides (three components). + two_k3_iso = nx.disjoint_union(two_k3, nx.empty_graph(1)) + two_star3_iso = nx.disjoint_union(two_star3, nx.empty_graph(1)) + z1_iso, z2_iso = _adjacency(two_k3_iso), _adjacency(two_star3_iso) + equivalent, solution = is_lc_equivalent(z1_iso, z2_iso) + assert equivalent + g_list = lc_graph_operations(z1_iso, solution) + g_new = two_k3_iso + for i in g_list: + g_new = local_comp_graph(g_new, i) + assert nx.is_isomorphic(g_new, two_star3_iso), "found graphs are not LC equivalent" + assert np.array_equal( + nx.to_numpy_array(g_new), z2_iso + ), "found graphs are not LC equivalent" + + # (d) the empty graph (0 nodes) vs itself. Only written after the fix: on unfixed code, + # slinalg.row_reduction never terminates on a 0x0 matrix, and there is no timeout binary here + # to cut a hung test. + z_empty = np.zeros((0, 0), dtype=int) + equivalent, solution = is_lc_equivalent(z_empty, z_empty) + assert equivalent + assert solution.shape == (0, 2, 2) + + +def test_component_partition_mismatch_is_false(): + """ + Two graphs whose adjacency matrices are the same size but partition into different connected + components can never be LC equivalent (local complementation preserves connected components as + vertex sets, Bouchet Section 6), and the check must reject them before running the linear-algebra + search at all. + """ + path4 = nx.path_graph(4) + two_edges = nx.disjoint_union(nx.path_graph(2), nx.path_graph(2)) + equivalent, solution = is_lc_equivalent(_adjacency(path4), _adjacency(two_edges)) + assert not equivalent + assert solution is None + + k3_plus_isolated = nx.disjoint_union(nx.complete_graph(3), nx.empty_graph(1)) + equivalent, solution = is_lc_equivalent( + _adjacency(k3_plus_isolated), _adjacency(path4) + ) + assert not equivalent + assert solution is None + + +@pytest.mark.parametrize("seed", [0, 1, 2, 3]) +def test_deterministic_seeded_sweep_keeps_disconnected_pairs(seed): + """ + Sweeps G(n, 0.5) draws (edgeless graphs, the canonical failing shape, are not skipped) against + an LC-equivalent partner reached by local complementation, and checks that the returned + Clifford operator actually solves the coefficient system, without redrawing on failure (the + redraw in the pre-existing ``_tester`` helper would hide exactly this bug). + """ + rng = np.random.default_rng(seed) + disconnected_draws = 0 + for n in range(4, 9): + for _ in range(10): + g = nx.gnp_random_graph(n, 0.5, seed=rng) + if not nx.is_connected(g): + disconnected_draws += 1 + lc_graphs, _ = gs.random_lc_equivalent(Graph(g.copy()), 1, 3, np_rng=rng) + partner = lc_graphs[0].data + z1 = _adjacency(g) + z2 = _adjacency(partner) + equivalent, sol = is_lc_equivalent(z1, z2, mode="deterministic") + assert ( + equivalent + ), f"seed={seed}, n={n}: partner via local complementation not found" + assert lcc._is_valid_clifford(_flat(sol)) + assert np.all((lcc._coeff_maker(z1, z2) @ _flat(sol)) % 2 == 0) + # Measured (this implementation): seed 0 -> 12, seed 1 -> 9, seed 2 -> 8, seed 3 -> 9 (of 50 + # draws each). G(n, 0.5) at n = 4 is disconnected about 40% of the time, so the sweep should + # not go vacuous; a count this low would mean the draws stopped exercising the failing shape. + assert ( + disconnected_draws >= 5 + ), f"seed {seed}: only {disconnected_draws} disconnected draws, sweep may be vacuous" + + +@pytest.mark.parametrize("seed", [0, 1]) +def test_deterministic_matches_exhaustive_oracle(seed): + """ + Compares ``is_lc_equivalent`` against a brute-force oracle (every candidate in the whole, + not per-component, solution space) on a mix of LC-equivalent and independently-drawn pairs, so + a regression that only breaks some shapes of input cannot hide behind a hand-picked example. + """ + + def oracle_equivalent(basis): + if basis is None: + return False + k = len(basis) + for bits in range(2**k): + combo = np.zeros_like(basis[0]) + for j in range(k): + if (bits >> j) & 1: + combo = (combo + basis[j]) % 2 + if lcc._is_valid_clifford(combo): + return True + return False + + rng = np.random.default_rng(seed) + start = time.time() + saw_nonequivalent_with_dim = False + saw_disconnected_equivalent = False + for n in range(4, 7): + for i in range(20): + g1 = nx.gnp_random_graph(n, 0.5, seed=rng) + if i < 12: # 60% LC partners + lc_graphs, _ = gs.random_lc_equivalent( + Graph(g1.copy()), 1, 3, np_rng=rng + ) + g2 = lc_graphs[0].data + else: # 40% independent draws + g2 = nx.gnp_random_graph(n, 0.5, seed=rng) + z1, z2 = _adjacency(g1), _adjacency(g2) + basis = _solution_basis(z1, z2) + dim = 0 if basis is None else len(basis) + if dim > 12: # keeps 2**dim well under 10 s + continue + equivalent, _ = is_lc_equivalent(z1, z2, mode="deterministic") + assert equivalent == oracle_equivalent(basis), (seed, n, i, dim) + if not equivalent and dim >= 1: + saw_nonequivalent_with_dim = True + if equivalent and not (nx.is_connected(g1) and nx.is_connected(g2)): + saw_disconnected_equivalent = True + wall_clock = time.time() - start + # Measured (this implementation): seed 0 -> 0.08 s, seed 1 -> 0.07 s. + assert wall_clock < 10 + assert saw_nonequivalent_with_dim + assert saw_disconnected_equivalent + + +def test_candidates_include_single_basis_vectors(monkeypatch): + """ + ``combinations(basis, 2)`` alone omits the k single basis vectors that Bouchet (4.4)'s "sum of + at most two vectors of B" also includes; this is the only test that can catch a regression + that removes the singles again (a differential sweep of 3000 pairs found no seed where the + singles change the boolean answer). + """ + star8 = nx.star_graph(7) # 8 nodes; dim(solution basis) = 9, measured, connected. + z = _adjacency(star8) + basis = _solution_basis(z, z) + k = len(basis) + + recorded = [] + + def fake_is_valid_clifford(vector): + recorded.append(vector.copy()) + return False # never stop early, so every candidate is recorded + + monkeypatch.setattr(lcc, "_is_valid_clifford", fake_is_valid_clifford) + equivalent, solution = is_lc_equivalent(z, z, mode="deterministic") + assert not equivalent + assert solution is None + + assert len(recorded) == k + k * (k - 1) // 2 + singles = recorded[:k] + remaining_basis = list(basis) + for single in singles: + match = next( + (i for i, b in enumerate(remaining_basis) if np.array_equal(b, single)), + None, + ) + assert ( + match is not None + ), "recorded single basis-vector candidate not found in basis" + remaining_basis.pop(match) + assert remaining_basis == [] + + +def test_symmetric_connected_graphs(): + """ + Regression guard, already passing before this change: a handful of large, connected, + boundary-shaped comparisons (star, complete, complete-bipartite, path graphs) that should stay + fast and correct. + """ + start = time.time() + star30 = nx.star_graph(29) + k_15_15 = nx.complete_bipartite_graph(15, 15) + equivalent, _ = is_lc_equivalent(_adjacency(star30), _adjacency(star30)) + assert equivalent + equivalent, _ = is_lc_equivalent(_adjacency(k_15_15), _adjacency(k_15_15)) + assert equivalent + + star20 = nx.star_graph(19) + k20 = nx.complete_graph(20) + equivalent, _ = is_lc_equivalent(_adjacency(star20), _adjacency(k20)) + assert equivalent + + k_10_10 = nx.complete_bipartite_graph(10, 10) + equivalent, _ = is_lc_equivalent(_adjacency(star20), _adjacency(k_10_10)) + assert not equivalent + + star40 = nx.star_graph(39) + path40 = nx.path_graph(40) + equivalent, _ = is_lc_equivalent(_adjacency(star40), _adjacency(path40)) + assert not equivalent + + wall_clock = time.time() - start + # Measured (this implementation): 0.19 s. + assert wall_clock < 20 + + +def test_unknown_mode_raises_before_early_returns(): + """ + ``mode`` used to be validated only in the trailing ``else`` of the old single-function + implementation, after the early returns for full rank and for a small solution-space + dimension, so ``mode="bogus"`` silently returned a result on most inputs instead of raising. + """ + # (a) two seeded random 6-node graphs whose system is full rank (no solution space at all). + g1 = nx.gnp_random_graph(6, 0.5, seed=0) + g2 = nx.gnp_random_graph(6, 0.5, seed=1000) + z1, z2 = _adjacency(g1), _adjacency(g2) + assert _solution_basis(z1, z2) is None + with pytest.raises(ValueError): + is_lc_equivalent(z1, z2, mode="bogus") + + # (b) path_8 vs itself: dimension 4, hits the exhaustive (< 5) branch's early return. + path8 = nx.path_graph(8) + z_path8 = _adjacency(path8) + with pytest.raises(ValueError): + is_lc_equivalent(z_path8, z_path8, mode="bogus") + + # (c) star_8 vs itself: dimension 9, green today (guard). + star8 = nx.star_graph(7) + z_star8 = _adjacency(star8) + with pytest.raises(ValueError): + is_lc_equivalent(z_star8, z_star8, mode="bogus") + + # (d) two isolated vertices vs itself: dimension 6 as a whole graph before the split, now two + # one-node components of dimension 3 each; green today (guard). + z_two_isolated = np.zeros((2, 2)) + with pytest.raises(ValueError): + is_lc_equivalent(z_two_isolated, z_two_isolated, mode="bogus") + + +def test_state_converter_circuit_on_disconnected_pair(): + """ + Before this change, ``lc_check`` on a disconnected LC-equivalent pair returned ``(False, [])``: + ``converter_gate_list`` asserted ``is_lc_equivalent``'s (buggy) False result and the bare + ``except`` in ``lc_check`` swallowed the AssertionError. + """ + two_k3 = nx.disjoint_union(nx.complete_graph(3), nx.complete_graph(3)) + two_star3 = nx.disjoint_union(nx.star_graph(2), nx.star_graph(2)) + result, gate_list = lc_check(two_k3, two_star3) + assert result + circuit = state_converter_circuit(two_k3, two_star3, validate=True) + assert circuit is not None + + two_isolated = nx.empty_graph(2) + circuit2 = state_converter_circuit(two_isolated, two_isolated, validate=True) + assert circuit2 is not None From c731cfac5410401038d3c774cf4be5415c860541 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 11:18:57 -0400 Subject: [PATCH 100/227] [test] Restore REDUCE_STABILIZER_MIXTURE after the tests that turn it off test_mixed_state_measurement set the module-global folding flag to False and never set it back, and test_mixed_state_tableaux_reduction set it back to True by hand. Every later depolarizing noise application in the same run then skipped reduce(), so noisy mixtures grew without bound. Both tests now set the flag through monkeypatch, which restores it however the test ends, and an autouse fixture in tests/conftest.py fails any test that leaves it flipped. --- tests/conftest.py | 20 ++++++++++++++++++++ tests/test_noise/test_mixed_stabilizers.py | 12 +++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 77d19a2e..179722a4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,5 +19,25 @@ # why it lives in the top-level conftest.py: pytest loads it before collecting # any test module (and before their own conftest.py files import graphiq). import matplotlib +import pytest matplotlib.use("Agg") + + +@pytest.fixture(autouse=True) +def _mixture_fold_flag_is_restored(): + """ + Fail the test that leaves ``REDUCE_STABILIZER_MIXTURE`` flipped. + + The flag is a module global read on every depolarizing noise application; a test that + sets it False without restoring it turns folding off for every later test in the + process, and noisy solver runs then grow their mixtures without bound. Autouse so this + is set up before ``monkeypatch`` and torn down after it. + """ + yield + import graphiq.noise.noise_models as nm + + assert nm.REDUCE_STABILIZER_MIXTURE is True, ( + "this test left graphiq.noise.noise_models.REDUCE_STABILIZER_MIXTURE = " + f"{nm.REDUCE_STABILIZER_MIXTURE!r}; restore it with monkeypatch.setattr" + ) diff --git a/tests/test_noise/test_mixed_stabilizers.py b/tests/test_noise/test_mixed_stabilizers.py index 40caea5e..fcdf5205 100644 --- a/tests/test_noise/test_mixed_stabilizers.py +++ b/tests/test_noise/test_mixed_stabilizers.py @@ -88,7 +88,7 @@ def test_mixed_state_compare_with_dm(n_emitters): @pytest.mark.parametrize("lam", [0.0, 0.1, 0.5]) -def test_mixed_state_tableaux_reduction(lam): +def test_mixed_state_tableaux_reduction(lam, monkeypatch): compiler = StabilizerCompiler() compiler.measurement_determinism = 1 n_emitters = 2 @@ -97,12 +97,14 @@ def test_mixed_state_tableaux_reduction(lam): compiler.noise_simulation = True - nm.REDUCE_STABILIZER_MIXTURE = False + # monkeypatch restores the module-global flag however the test ends; a + # leaked False would stop every later test's mixtures from being folded + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) state = compiler.compile(circ) assert isinstance(state.rep_data, MixedStabilizer) no_len_reduction = len(state.rep_data.mixture) - nm.REDUCE_STABILIZER_MIXTURE = True + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", True) state = compiler.compile(circ) assert isinstance(state.rep_data, MixedStabilizer) len_reduction = len(state.rep_data.mixture) @@ -112,7 +114,7 @@ def test_mixed_state_tableaux_reduction(lam): assert no_len_reduction > len_reduction -def test_mixed_state_measurement(): +def test_mixed_state_measurement(monkeypatch): compiler = StabilizerCompiler() compiler.measurement_determinism = "probabilistic" compiler.noise_simulation = True @@ -121,5 +123,5 @@ def test_mixed_state_measurement(): circ, circ_ideal = create_circuits(n_emitters, n_photons, d_emitter, lam) circ.add(ops.MeasurementZ(register=0, reg_type="e")) - nm.REDUCE_STABILIZER_MIXTURE = False + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) state = compiler.compile(circ) From d2b9f6ecb301a0b1ac366a5306b1b8a0d28ae6dd Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 11:32:39 -0400 Subject: [PATCH 101/227] [fix] Reset the emitter in the density-matrix MeasurementCNOTandReset MeasurementCNOTandReset subclasses ClassicalControlledPairOperationBase, so DensityMatrixCompiler.compile_one_gate's generic branch for that base matched it first and its own branch, the one that resets the control qubit, never ran. After a measured outcome of 1 the emitter was left in |1> instead of being reset. The generic branch now excludes it. The stabilizer compiler never wrote MeasurementCNOTandReset's outcome to its classical register, so it always read 0. It now records the outcome, as ClassicalCNOT, ClassicalCZ and MeasurementZ do. This changes results for density-matrix circuits that use an emitter again after a MeasurementCNOTandReset with outcome 1, and for stabilizer circuits that read that measurement's classical register. --- graphiq/backends/density_matrix/compiler.py | 7 +- graphiq/backends/stabilizer/compiler.py | 4 + tests/test_backends/__init__.py | 14 + .../test_measurement_cnot_and_reset_parity.py | 241 ++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 tests/test_backends/__init__.py create mode 100644 tests/test_backends/test_measurement_cnot_and_reset_parity.py diff --git a/graphiq/backends/density_matrix/compiler.py b/graphiq/backends/density_matrix/compiler.py index 45317ace..2c2ccba4 100644 --- a/graphiq/backends/density_matrix/compiler.py +++ b/graphiq/backends/density_matrix/compiler.py @@ -110,7 +110,12 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): ) state.apply_unitary(unitary) - elif isinstance(op, ops.ClassicalControlledPairOperationBase): + elif isinstance( + op, ops.ClassicalControlledPairOperationBase + ) and not isinstance(op, ops.MeasurementCNOTandReset): + # MeasurementCNOTandReset is itself a ClassicalControlledPairOperationBase, + # so it is excluded here to reach its own branch below, which resets the + # control qubit projectors = dm.projectors_zbasis( n_quantum, q_index(op.control, op.control_type) ) diff --git a/graphiq/backends/stabilizer/compiler.py b/graphiq/backends/stabilizer/compiler.py index 5d42b2c8..683fa5e4 100644 --- a/graphiq/backends/stabilizer/compiler.py +++ b/graphiq/backends/stabilizer/compiler.py @@ -173,6 +173,9 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): state.apply_conditioned_gate( q_index(op.target, op.target_type), outcomes, gate="x" ) + # see the ClassicalCNOT branch above for why only outcomes[0] + # is recorded here + classical_registers[op.c_register] = outcomes[0] else: outcome = state.apply_measurement( @@ -182,6 +185,7 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): if outcome == 1: state.apply_sigmax(q_index(op.target, op.target_type)) + classical_registers[op.c_register] = outcome # reset the control qubit state.reset_qubit( diff --git a/tests/test_backends/__init__.py b/tests/test_backends/__init__.py new file mode 100644 index 00000000..fd1ce610 --- /dev/null +++ b/tests/test_backends/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/test_backends/test_measurement_cnot_and_reset_parity.py b/tests/test_backends/test_measurement_cnot_and_reset_parity.py new file mode 100644 index 00000000..37e41ccc --- /dev/null +++ b/tests/test_backends/test_measurement_cnot_and_reset_parity.py @@ -0,0 +1,241 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Parity between the density-matrix and stabilizer backends for +`MeasurementCNOTandReset`, and the related classically-controlled family +(`ClassicalCNOT`, `ClassicalCZ`) on `MixedStabilizer`. + +Every measurement-outcome assertion here pins `measurement_determinism = 1`. +Both defects this file targets are invisible at `determinism = 0`: the emitter +is projected to |0> anyway, and the stabilizer's never-written 0.0 is correct +by accident. The default is "probabilistic", so a test that does not pin +determinism would pass on the buggy code about half the time. +""" + +import numpy as np +import pytest + +import graphiq.backends.density_matrix.functions as dmf +import graphiq.backends.state_rep_conversion as rc +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.circuit.circuit_dag import CircuitDAG + +# n_photon=1, n_emitter=1: with ordering photons-then-emitters +# (CompilerBase.reg_to_index_func), the emitter sits at qubit index 1. +_EMITTER_INDEX = 1 + + +def _h_cnot_reset_circuit(n_classical=1): + """H(e0), CNOT(e0->p0), MeasurementCNOTandReset(e0->p0, c_register=0). + + The Hadamard leaves the control in an X eigenstate, so a Z-basis + measurement has p(1) != 0, and `measurement_determinism = 1`'s "default to + 1 unless p(1) is 0" rule forces the outcome to 1 -- the branch where a + missing reset is observable. + """ + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=n_classical) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + return circuit + + +def _record_classical_registers(compiler): + """ + Make ``compiler.classical_registers`` refer to the classical-register array + that ``compile`` allocates and passes to every ``compile_one_gate`` call. + """ + compile_one_gate = compiler.compile_one_gate + + def recording(state, op, n_quantum, q_index, classical_registers): + compiler.classical_registers = classical_registers + return compile_one_gate(state, op, n_quantum, q_index, classical_registers) + + compiler.compile_one_gate = recording + return compiler + + +def _compile_dm(circuit): + compiler = DensityMatrixCompiler() + compiler.measurement_determinism = 1 + state = _record_classical_registers(compiler).compile(circuit) + return state, compiler + + +def _compile_stabilizer(circuit): + compiler = StabilizerCompiler() + compiler.measurement_determinism = 1 + state = _record_classical_registers(compiler).compile(circuit) + return state, compiler + + +def _stabilizer_qubit_density(state, qubit, n_qubits): + """The reduced density matrix of one qubit of a (non-mixed) Stabilizer. + + Converts the whole tableau to a density matrix via + `state_rep_conversion.stabilizer_to_density` -- the same utility + `test_stabilizer_compiler.py` uses -- and traces the rest away with + `dmf.partial_trace`, so the index arithmetic is never hand-rolled. + """ + tableau = state.rep_data.tableau.to_stabilizer() + rho = rc.stabilizer_to_density(tableau) + return dmf.partial_trace(rho, keep=[qubit], dims=n_qubits * [2]) + + +# --------------------------------------------------------------------------- +# Mutant: the emitter is |0> after MeasurementCNOTandReset, on both backends, +# pinned at determinism = 1. +# --------------------------------------------------------------------------- + + +def test_dm_emitter_is_reset_after_measurement_cnot_and_reset(): + circuit = _h_cnot_reset_circuit() + state, _ = _compile_dm(circuit) + reduced = dmf.partial_trace(state.rep_data.data, keep=[_EMITTER_INDEX], dims=[2, 2]) + assert np.allclose(reduced, [[1.0, 0.0], [0.0, 0.0]]) + + +def test_stabilizer_emitter_is_reset_after_measurement_cnot_and_reset(): + circuit = _h_cnot_reset_circuit() + state, _ = _compile_stabilizer(circuit) + reduced = _stabilizer_qubit_density(state, _EMITTER_INDEX, n_qubits=2) + assert np.allclose(reduced, [[1.0, 0.0], [0.0, 0.0]]) + + +# --------------------------------------------------------------------------- +# Mutant: classical_registers[c_register] holds the outcome on both backends, +# pinned at determinism = 1 (correct value 1.0; the stabilizer currently +# gives 0.0). +# --------------------------------------------------------------------------- + + +def test_dm_creg_records_the_outcome(): + circuit = _h_cnot_reset_circuit() + _, compiler = _compile_dm(circuit) + assert compiler.classical_registers[0] == 1.0 + + +def test_stabilizer_creg_records_the_outcome(): + circuit = _h_cnot_reset_circuit() + _, compiler = _compile_stabilizer(circuit) + assert compiler.classical_registers[0] == 1.0 + + +# --------------------------------------------------------------------------- +# Mutant: a circuit that re-uses the emitter after the reset. Without this, +# the fix is pinned only at the op boundary -- this is the failure the issue +# exists for: feed-forward work that reads the emitter after a measurement. +# +# A second op (MeasurementZ, not another MeasurementCNOTandReset) reads the +# emitter directly, so this is decoupled from defect 2 (the stabilizer's own +# missing register write) and isolates defect 1 (the DM reset). +# --------------------------------------------------------------------------- + + +def _reuse_circuit(): + circuit = _h_cnot_reset_circuit(n_classical=2) + circuit.add(ops.MeasurementZ(register=0, reg_type="e", c_register=1)) + return circuit + + +def test_dm_emitter_reuse_after_reset(): + circuit = _reuse_circuit() + _, compiler = _compile_dm(circuit) + # First outcome: the H'd control gives p(1) != 0, forced to 1. + assert compiler.classical_registers[0] == 1.0 + # Second outcome: if the control was actually reset to |0>, p(1) == 0, so + # determinism = 1's "default to 1 unless p(1) is 0" rule forces 0. A + # not-reset emitter (still |1> from the first measurement) would force 1. + assert compiler.classical_registers[1] == 0.0 + + +def test_stabilizer_emitter_reuse_after_reset(): + circuit = _reuse_circuit() + _, compiler = _compile_stabilizer(circuit) + assert compiler.classical_registers[0] == 1.0 + assert compiler.classical_registers[1] == 0.0 + + +# --------------------------------------------------------------------------- +# Mutant: MixedStabilizer writes a scalar, not a list, for +# MeasurementCNOTandReset, ClassicalCNOT and ClassicalCZ. Red today for all +# three: ClassicalCNOT and ClassicalCZ raise ValueError (they try to assign +# the whole outcomes list into a scalar slot of the classical_registers +# ndarray), and MeasurementCNOTandReset writes nothing at all (defect 2). +# +# `noise_simulation = True` with every op carrying NoNoise still routes +# through compile_one_gate (CompilerBase.compile's no_noise branch), but now +# against a MixedStabilizer state -- the same construction +# test_noise_placement.py already relies on for this backend. +# --------------------------------------------------------------------------- + + +def _mixed_stabilizer_circuit(op_cls): + """SigmaX(e0) forces the control to |1> outcome deterministically, then + op_cls(e0->p0) under noise_simulation=True so the compiler builds a + MixedStabilizer. + """ + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=1) + circuit.add(ops.SigmaX(register=0, reg_type="e", noise=nm.NoNoise())) + circuit.add( + op_cls( + control=0, + control_type="e", + target=0, + target_type="p", + c_register=0, + noise=nm.NoNoise(), + ) + ) + return circuit + + +def _compile_mixed_stabilizer(circuit): + compiler = StabilizerCompiler() + compiler.noise_simulation = True + compiler.measurement_determinism = 1 + state = _record_classical_registers(compiler).compile(circuit) + return state, compiler + + +@pytest.mark.parametrize( + "op_cls", [ops.MeasurementCNOTandReset, ops.ClassicalCNOT, ops.ClassicalCZ] +) +def test_mixed_stabilizer_writes_a_scalar_not_a_list(op_cls): + circuit = _mixed_stabilizer_circuit(op_cls) + _, compiler = _compile_mixed_stabilizer(circuit) + + value = compiler.classical_registers[0] + assert not isinstance(value, (list, np.ndarray)) + assert value == 1.0 + + +# --------------------------------------------------------------------------- +# Mutant: no stray stdout from a MixedStabilizer ClassicalCNOT compile. +# --------------------------------------------------------------------------- + + +def test_mixed_stabilizer_classical_cnot_compiles_silently(capsys): + circuit = _mixed_stabilizer_circuit(ops.ClassicalCNOT) + _compile_mixed_stabilizer(circuit) + captured = capsys.readouterr() + assert captured.out == "" From d1aca43616cfa93d168d5728f1fd7638bc4df0ae Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 11:34:11 -0400 Subject: [PATCH 102/227] [fix] Apply additive noise on classically controlled operations CompilerBase.compile splits the legs of a ClassicalCNOT, ClassicalCZ or MeasurementCNOTandReset into a pre-gate and a post-gate half and hands each to _apply_additional_noise, but neither backend handled those classes there: the stabilizer compiler silently dropped the noise and the density-matrix compiler raised ValueError. Both now apply leg i to register i of q_registers, control first, as for a ControlledPairOperationBase. A post-gate control leg therefore lands after the reset. The stabilizer compiler's fallthrough now raises like the density-matrix one, instead of silently ignoring an unhandled op; additive noise on a MeasurementZ, which reached it, now raises ValueError on the stabilizer compiler instead of being dropped. This changes results for noisy runs with additive noise on these operations: the stabilizer compiler now applies noise it used to drop. --- graphiq/backends/density_matrix/compiler.py | 8 + graphiq/backends/stabilizer/compiler.py | 12 +- ...ive_noise_on_classically_controlled_ops.py | 170 ++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tests/test_noise/test_additive_noise_on_classically_controlled_ops.py diff --git a/graphiq/backends/density_matrix/compiler.py b/graphiq/backends/density_matrix/compiler.py index 2c2ccba4..ae06480c 100644 --- a/graphiq/backends/density_matrix/compiler.py +++ b/graphiq/backends/density_matrix/compiler.py @@ -310,6 +310,14 @@ def _apply_additional_noise(self, state, op, n_quantum, q_index): state, n_quantum, [q_index(op.control, op.control_type)] ) target_noise.apply(state, n_quantum, [q_index(op.target, op.target_type)]) + elif isinstance(op, ops.ClassicalControlledPairOperationBase): + # one leg per register, in q_registers order (control first, then + # target), each on its own qubit -- the same reading as a + # ControlledPairOperationBase's legs + for noise, register, reg_type in zip( + op.noise, op.q_registers, op.q_registers_type + ): + noise.apply(state, n_quantum, [q_index(register, reg_type)]) else: raise ValueError( f"Noise model not implemented for operation type {type(op)}" diff --git a/graphiq/backends/stabilizer/compiler.py b/graphiq/backends/stabilizer/compiler.py index 683fa5e4..89b26d3e 100644 --- a/graphiq/backends/stabilizer/compiler.py +++ b/graphiq/backends/stabilizer/compiler.py @@ -297,5 +297,15 @@ def _apply_additional_noise(self, state, op, n_quantum, q_index): state, n_quantum, [q_index(op.control, op.control_type)] ) target_noise.apply(state, n_quantum, [q_index(op.target, op.target_type)]) + elif isinstance(op, ops.ClassicalControlledPairOperationBase): + # one leg per register, in q_registers order (control first, then + # target), each on its own qubit -- the same reading as a + # ControlledPairOperationBase's legs + for noise, register, reg_type in zip( + op.noise, op.q_registers, op.q_registers_type + ): + noise.apply(state, n_quantum, [q_index(register, reg_type)]) else: - pass + raise ValueError( + f"Noise model not implemented for operation type {type(op)}" + ) diff --git a/tests/test_noise/test_additive_noise_on_classically_controlled_ops.py b/tests/test_noise/test_additive_noise_on_classically_controlled_ops.py new file mode 100644 index 00000000..1559e783 --- /dev/null +++ b/tests/test_noise/test_additive_noise_on_classically_controlled_ops.py @@ -0,0 +1,170 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Additive noise on a classically controlled op's legs, on both backends. + +`ClassicalCNOT`/`ClassicalCZ`/`MeasurementCNOTandReset` (a +`ClassicalControlledPairOperationBase`) each carry one noise model per +register. `CompilerBase.compile` already splits those legs into a +pre-gate and a post-gate half by each leg's `"After gate"` flag whenever every +leg is an `AdditionNoiseBase`, and calls `_apply_additional_noise` on each +half -- this file exercises the backend arm that has to honour that split for +this op family, on both the stabilizer and the density-matrix backend. + +For `MeasurementCNOTandReset`, "the gate" runs as: measure the control, +conditionally apply the correction to the target, then reset the control. A +pre-gate leg on the control therefore acts on the qubit *before* it is +measured (so it can change the outcome), and a post-gate leg on the control +acts *after* the reset -- the only reading consistent with where +`compile_one_gate` measures and resets it. Every circuit below starts from +the all-zero state and every measurement outcome is deterministic once the +noise has acted, so `measurement_determinism = 0` never has to break a tie. + +Each behavioural test compiles the noisy circuit and an equivalent +circuit written out with an explicit, noiseless Pauli gate in the position +the noise should land, and asserts the two compile to the same state -- +mirroring `test_noise_placement.py`'s comparison style. A stabilizer compile +is converted to a density matrix via `QuantumState.convert_representation` +before the comparison, since both backends are being driven through the same +assertion. +""" + +import numpy as np +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.circuit.circuit_dag import CircuitDAG + + +def _pauli(letter, after_gate): + """A `PauliError` leg with its `"After gate"` placement spelled out. + + `PauliError` takes no placement argument -- `AdditionNoiseBase.__init__` + always stamps `noise_parameters["After gate"] = True` on construction -- + so a pre-gate leg is only reachable by flipping the flag afterwards. + """ + noise = nm.PauliError(letter) + noise.noise_parameters["After gate"] = after_gate + return noise + + +def _cnot_and_reset_circuit(noise_legs): + """`MeasurementCNOTandReset(control=0/e, target=0/p)` on `|0>|0>`.""" + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=1) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, + control_type="e", + target=0, + target_type="p", + c_register=0, + noise=list(noise_legs), + ) + ) + return circuit + + +def _cnot_and_reset_explicit(pre_control=False, post_control=False): + """The same op, noiseless, with an explicit `SigmaX(control)` where a + noisy leg should land.""" + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=1) + if pre_control: + circuit.add(ops.SigmaX(register=0, reg_type="e", noise=nm.NoNoise())) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, + control_type="e", + target=0, + target_type="p", + c_register=0, + noise=[nm.NoNoise(), nm.NoNoise()], + ) + ) + if post_control: + circuit.add(ops.SigmaX(register=0, reg_type="e", noise=nm.NoNoise())) + return circuit + + + + + + +def _compile(compiler_class, circuit): + compiler = compiler_class() + compiler.noise_simulation = True + compiler.measurement_determinism = 0 + state = compiler.compile(circuit) + if compiler_class is StabilizerCompiler: + state.convert_representation("dm") + return state.rep_data.data + + +@pytest.mark.parametrize("compiler_class", [StabilizerCompiler, DensityMatrixCompiler]) +def test_pre_gate_noise_on_the_control_flips_the_branch(compiler_class): + noisy = _cnot_and_reset_circuit([_pauli("X", after_gate=False), nm.NoNoise()]) + expected = _cnot_and_reset_explicit(pre_control=True) + assert np.allclose( + _compile(compiler_class, noisy), _compile(compiler_class, expected) + ) + + # Without the noise, the same circuit measures 0 and touches nothing. + plain = _cnot_and_reset_explicit() + assert not np.allclose( + _compile(compiler_class, noisy), _compile(compiler_class, plain) + ) + + +@pytest.mark.parametrize("compiler_class", [StabilizerCompiler, DensityMatrixCompiler]) +def test_post_gate_noise_on_the_control_lands_after_the_reset(compiler_class): + noisy = _cnot_and_reset_circuit([_pauli("X", after_gate=True), nm.NoNoise()]) + expected = _cnot_and_reset_explicit(post_control=True) + assert np.allclose( + _compile(compiler_class, noisy), _compile(compiler_class, expected) + ) + + plain = _cnot_and_reset_explicit() + assert not np.allclose( + _compile(compiler_class, noisy), _compile(compiler_class, plain) + ) + + + + +def test_the_two_backends_agree(): + noisy_circuits = [ + _cnot_and_reset_circuit([_pauli("X", after_gate=False), nm.NoNoise()]), + _cnot_and_reset_circuit([_pauli("X", after_gate=True), nm.NoNoise()]), + ] + for circuit in noisy_circuits: + stabilizer_result = _compile(StabilizerCompiler, circuit) + dm_result = _compile(DensityMatrixCompiler, circuit) + assert np.allclose(stabilizer_result, dm_result) + + +def test_an_op_outside_every_arm_still_raises_on_the_stabilizer_backend(): + # The dispatcher never routes a MeasurementZ through + # `_apply_additional_noise` (it is not a controlled op), so this is only + # reachable by calling the method directly -- which is exactly what pins + # that the bare `pass` this arm used to be is gone. + op = ops.MeasurementZ( + register=0, reg_type="e", c_register=0, noise=nm.PauliError("X") + ) + with pytest.raises(ValueError): + StabilizerCompiler()._apply_additional_noise( + None, op, 1, lambda reg, reg_type: 0 + ) From 3a951d177a6536dee0458d08592db8585e4a903e Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 11:35:36 -0400 Subject: [PATCH 103/227] [fix] Honour per-leg noise keys in SolverBase._identify_noise _identify_noise turns an operation instance into its class and then tested that class with isinstance, which is always False for a class. Its two-legged branch, the one that reads the _control and _target keys, was therefore never taken, and noise_model_loss_and_depolarizing, which uses those keys for the emitter CNOTs, had that noise silently dropped. A bare key kept working only because the controlled op's constructor copies a single noise model to both legs. The check is now issubclass; a bare key still gives the same model on both legs. Two noisy solver fixtures spelled the key "MeasurementCNOTandRest", so their measure-and-reset ops never carried the noise the fixtures named. A test now checks that every fixture key names an operation class. This changes results for noisy runs that use per-leg keys, including noise_model_loss_and_depolarizing and so examples/example_results.py, and for the two fixtures. Since the loss-and-depolarizing noise now applies, test_alternate_circuits_w_noise2 compiles large mixed stabilizers; it is a smoke test, so it now searches 50 candidates instead of 2,500. --- graphiq/solvers/solver_base.py | 11 ++- tests/test_noise/test_solver_with_noise.py | 43 +++++++++- tests/test_solvers/test_hybrid_solvers.py | 9 +- tests/test_solvers/test_identify_noise.py | 98 ++++++++++++++++++++++ 4 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 tests/test_solvers/test_identify_noise.py diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index b8390176..8c948910 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -122,7 +122,10 @@ def _identify_noise(self, op, noise_model_mapping): :param op: an operation :type op: ops.OperationBase :param noise_model_mapping: a dictionary that stores the mapping between an operation - and its associated noise model + and its associated noise model. A two-legged op is looked up by its per-leg keys, + ``_control`` and ``_target``, first; the bare ```` key is only used as a + fallback when neither per-leg key is present, in which case the same model is + returned for both legs :type noise_model_mapping: dict :return: a noise model :rtype: nm.NoiseBase @@ -131,8 +134,10 @@ def _identify_noise(self, op, noise_model_mapping): if isinstance(op, ops.OperationBase): # if op is an instance, turn it into the class itself. op = type(op) - if isinstance(op, ops.ControlledPairOperationBase) or isinstance( - op, ops.ClassicalControlledPairOperationBase + # op is a class here, so this must be issubclass, not isinstance + if issubclass( + op, + (ops.ControlledPairOperationBase, ops.ClassicalControlledPairOperationBase), ): op_name = op.__name__ op_control = op.__name__ + "_control" diff --git a/tests/test_noise/test_solver_with_noise.py b/tests/test_noise/test_solver_with_noise.py index 13257bb5..82f86e00 100644 --- a/tests/test_noise/test_solver_with_noise.py +++ b/tests/test_noise/test_solver_with_noise.py @@ -21,6 +21,7 @@ linear_cluster_4qubit_circuit, ghz3_state_circuit, ) +import graphiq.circuit.ops as ops import graphiq.noise.noise_models as nm from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler import graphiq.backends.density_matrix.functions as dmf @@ -325,7 +326,7 @@ def linear3_noise_model_2(): noise_model_mapping["ee"] = {"CNOT": nm.DepolarizingNoise(dp)} noise_model_mapping["ep"] = { "CNOT": nm.DepolarizingNoise(dp), - "MeasurementCNOTandRest": nm.DepolarizingNoise(dp), + "MeasurementCNOTandReset": nm.DepolarizingNoise(dp), } noise_model_mapping["p"] = { "Identity": nm.DepolarizingNoise(dp), @@ -354,7 +355,7 @@ def linear4_noise_model(): noise_model_mapping["ee"] = {"CNOT": nm.DepolarizingNoise(dp)} noise_model_mapping["ep"] = { "CNOT": nm.DepolarizingNoise(dp), - "MeasurementCNOTandRest": nm.DepolarizingNoise(dp), + "MeasurementCNOTandReset": nm.DepolarizingNoise(dp), } noise_model_mapping["p"] = { "Identity": nm.DepolarizingNoise(dp), @@ -403,3 +404,41 @@ def ghz3_noise_model(): @visualization def test_solver_ghz3_visualized(ghz3_run_no_noise, ghz3_run_noise, ghz3_expected): check_run_noise_visual(ghz3_run_no_noise, ghz3_run_noise, ghz3_expected) + + +@pytest.mark.parametrize( + "noise_model_fixture_name", + [ + "linear3_noise_model", + "linear3_noise_model_2", + "linear4_noise_model", + "ghz3_noise_model", + ], +) +def test_noise_model_fixture_keys_name_real_operations( + noise_model_fixture_name, request +): + """Every key in a noise-model fixture (with an optional ``_control``/``_target`` + suffix stripped) must name a real class in ``graphiq.circuit.ops``, or be the + literal ``"OneQubitGateWrapper"`` that ``SolverBase._wrap_noise`` reads. A + misspelled key silently matches nothing and the operation it was meant to name + keeps its default of no noise. + """ + noise_model_mapping = request.getfixturevalue(noise_model_fixture_name) + op_names = { + name + for name, obj in vars(ops).items() + if isinstance(obj, type) and issubclass(obj, ops.OperationBase) + } + + for register_type, mapping in noise_model_mapping.items(): + for key in mapping: + base_key = key + for suffix in ("_control", "_target"): + if base_key.endswith(suffix): + base_key = base_key[: -len(suffix)] + break + assert base_key == "OneQubitGateWrapper" or base_key in op_names, ( + f"{noise_model_fixture_name}[{register_type!r}] has key {key!r}, " + f"which names no class in graphiq.circuit.ops" + ) diff --git a/tests/test_solvers/test_hybrid_solvers.py b/tests/test_solvers/test_hybrid_solvers.py index b1b385a0..b394c997 100644 --- a/tests/test_solvers/test_hybrid_solvers.py +++ b/tests/test_solvers/test_hybrid_solvers.py @@ -140,7 +140,14 @@ def test_alternate_circuits_w_noise2(): loss_rate = 0.01 graph = repeater_graph_states(3) noise_model = noise_model_loss_and_depolarizing(error_rate, loss_rate) - exemplary_test(graph, noise_model, None, 1) + # The depolarizing noise on every emitter CNOT leg folds each noisy compile into a + # large mixed stabilizer, so the default 50x50 search is very slow. This is a smoke + # test that the noisy hybrid search completes, not a search-quality test, so it runs + # 50 candidates instead of 2,500. + solver_setting = EvolutionarySolverSetting() + solver_setting.n_pop = 10 + solver_setting.n_stop = 5 + exemplary_test(graph, noise_model, solver_setting, 1) def test_alternate_circuits_w_noise3(): diff --git a/tests/test_solvers/test_identify_noise.py b/tests/test_solvers/test_identify_noise.py new file mode 100644 index 00000000..74a431e0 --- /dev/null +++ b/tests/test_solvers/test_identify_noise.py @@ -0,0 +1,98 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.benchmarks.alternate_circuits import noise_model_loss_and_depolarizing +from graphiq.solvers.solver_base import SolverBase + + +class _MinimalSolver(SolverBase): + """A concrete SolverBase subclass with no search behaviour, so that + ``_identify_noise`` -- which does not read any solver state -- can be called on + an instance without constructing a full solver.""" + + def solve(self, *args): + raise NotImplementedError + + +@pytest.fixture +def solver(): + return _MinimalSolver(target=None, metric=None, compiler=None) + + +def test_per_leg_keys_are_honoured(solver): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + mapping = {"CNOT_control": control_noise, "CNOT_target": target_noise} + + result = solver._identify_noise(ops.CNOT, mapping) + + assert result[0] is control_noise + assert result[1] is target_noise + + +def test_one_per_leg_key_leaves_the_other_leg_noiseless(solver): + control_noise = nm.DepolarizingNoise(0.1) + mapping = {"MeasurementCNOTandReset_control": control_noise} + + result = solver._identify_noise(ops.MeasurementCNOTandReset, mapping) + + assert result[0] is control_noise + assert isinstance(result[1], nm.NoNoise) + + +def test_a_bare_name_key_reaches_both_legs(solver): + noise = nm.DepolarizingNoise(0.1) + mapping = {"CNOT": noise} + + result = solver._identify_noise(ops.CNOT, mapping) + + assert len(result) == 2 + assert result[0] is noise + assert result[1] is noise + + +def test_an_instance_and_its_class_agree(solver): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + mapping = {"CNOT_control": control_noise, "CNOT_target": target_noise} + op_instance = ops.CNOT(control=0, target=1) + + result_from_instance = solver._identify_noise(op_instance, mapping) + result_from_class = solver._identify_noise(ops.CNOT, mapping) + + assert result_from_instance[0] is result_from_class[0] is control_noise + assert result_from_instance[1] is result_from_class[1] is target_noise + + +def test_one_qubit_ops_are_unchanged(solver): + noise = nm.DepolarizingNoise(0.1) + + assert solver._identify_noise(ops.Hadamard, {"Hadamard": noise}) is noise + assert isinstance(solver._identify_noise(ops.Hadamard, {}), nm.NoNoise) + + +def test_the_loss_and_depolarizing_benchmark_mapping_reaches_the_cnot_legs(solver): + noise_model_mapping = noise_model_loss_and_depolarizing(0.1, 0.1) + + both_legs = solver._identify_noise(ops.CNOT, noise_model_mapping["ee"]) + assert isinstance(both_legs[0], nm.DepolarizingNoise) + assert isinstance(both_legs[1], nm.DepolarizingNoise) + + one_leg = solver._identify_noise(ops.CNOT, noise_model_mapping["ep"]) + assert isinstance(one_leg[0], nm.DepolarizingNoise) + assert isinstance(one_leg[1], nm.NoNoise) From b7be7b027416cd2c35b602fc13c0151219236bb3 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 11:38:27 -0400 Subject: [PATCH 104/227] [fix] Refuse replacement noise on MeasurementZ instead of mishandling it A MeasurementZ carrying replacement noise reaches compile_one_noisy_gate, and neither backend implements a measurement error model there. The stabilizer compiler's arm was a bare pass, so the op compiled to nothing: no measurement, no classical-register write, no error. The density-matrix compiler measured but silently ignored the noise. Both now raise NotImplementedError. --- graphiq/backends/density_matrix/compiler.py | 14 ++++++-------- graphiq/backends/stabilizer/compiler.py | 7 ++++++- tests/test_noise/test_noise_placement.py | 21 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/graphiq/backends/density_matrix/compiler.py b/graphiq/backends/density_matrix/compiler.py index ae06480c..e3026289 100644 --- a/graphiq/backends/density_matrix/compiler.py +++ b/graphiq/backends/density_matrix/compiler.py @@ -269,15 +269,13 @@ def compile_one_noisy_gate( state.rep_data.apply_channel(reset_kraus_ops) elif type(op) is ops.MeasurementZ: - # TODO: implement measurement-related error model - - projectors = dm.projectors_zbasis( - n_quantum, q_index(op.register, op.reg_type) + # this arm is only reached when op.noise is a replacement noise + # model, and no measurement-related error model is implemented, + # so refuse rather than measure while silently ignoring the noise + raise NotImplementedError( + f"{type(op).__name__} carrying {type(op.noise).__name__} is not " + f"implemented for {self.__class__.__name__}." ) - outcome = state.rep_data.apply_measurement( - projectors, measurement_determinism=self.measurement_determinism - ) - classical_registers[op.c_register] = outcome else: raise ValueError( diff --git a/graphiq/backends/stabilizer/compiler.py b/graphiq/backends/stabilizer/compiler.py index 89b26d3e..0329d9d3 100644 --- a/graphiq/backends/stabilizer/compiler.py +++ b/graphiq/backends/stabilizer/compiler.py @@ -264,7 +264,12 @@ def compile_one_noisy_gate( pass elif type(op) is ops.MeasurementZ: - pass + # refuse rather than silently dropping the measurement, the + # classical-register write and the noise + raise NotImplementedError( + f"{type(op).__name__} carrying {type(op.noise).__name__} is not " + f"implemented for {self.__class__.__name__}." + ) else: raise ValueError( diff --git a/tests/test_noise/test_noise_placement.py b/tests/test_noise/test_noise_placement.py index f9ed4eb7..6a9b6b9a 100644 --- a/tests/test_noise/test_noise_placement.py +++ b/tests/test_noise/test_noise_placement.py @@ -33,6 +33,7 @@ import graphiq.circuit.ops as ops import graphiq.noise.noise_models as nm from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.circuit.circuit_dag import CircuitDAG @@ -222,3 +223,23 @@ def test_non_addition_noise_on_a_leg_is_rejected(): circuit, _ = _noisy_cnot_circuit(replacement, nm.PauliError("X")) with pytest.raises(ValueError, match="AdditionNoiseBase"): _compile_dm(circuit) + + +@pytest.mark.parametrize("compiler_class", [DensityMatrixCompiler, StabilizerCompiler]) +def test_replacement_noise_on_measurement_z_is_refused(compiler_class): + """No measurement error model is implemented for replacement noise, so the + compiler must refuse it rather than drop the noise (or the measurement).""" + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e", noise=nm.NoNoise())) + circuit.add( + ops.MeasurementZ( + register=0, + reg_type="e", + c_register=0, + noise=nm.OneQubitGateReplacement(np.eye(2)), + ) + ) + compiler = compiler_class() + compiler.noise_simulation = True + with pytest.raises(NotImplementedError, match="MeasurementZ"): + compiler.compile(circuit) From 8976fc19463c72273541eaace5343bb0305624e1 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 16:26:19 -0400 Subject: [PATCH 105/227] [docs] Fix the failing cells in the solvers startup notebook The circuit-initialization cell passed an empty dict as the noise model mapping to EvolutionarySolver.initialization, which only builds its default mapping when the argument is None, so it raised KeyError: 'ep' and every later cell that used its circuit failed with NameError. Drop the argument. The TimeReversedSolver cell took its GHZ3 target from ghz3_state_circuit(), a density matrix. The solver works on the stabilizer representation and GHZ3 is not a graph state, so the density matrix cannot be converted to it. Build the target from its stabilizer generators XXX, ZZI, IZZ instead, and drop the two imports the cell did not use. Re-run the notebook and refresh the saved outputs of the two changed cells and of the four cells that use their circuit; the other cells and the notebook metadata are unchanged. --- examples/startup_guide/6_solvers.ipynb | 67 +++++++++++++------------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/examples/startup_guide/6_solvers.ipynb b/examples/startup_guide/6_solvers.ipynb index 01c30ca9..dbc6cf2e 100644 --- a/examples/startup_guide/6_solvers.ipynb +++ b/examples/startup_guide/6_solvers.ipynb @@ -122,9 +122,9 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -133,7 +133,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 2, @@ -154,7 +154,7 @@ " n_photons, n_emitter\n", ")\n", "\n", - "circuit = solver.initialization(emission_assignment, measurement_assignment, {})\n", + "circuit = solver.initialization(emission_assignment, measurement_assignment)\n", "circuit.draw_circuit()" ], "metadata": { @@ -191,9 +191,9 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -202,7 +202,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 3, @@ -230,9 +230,9 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -241,7 +241,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 4, @@ -282,9 +282,9 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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iwGXY9IhAHCxpKQkjR49Wv369VPPnj3VqlUrxcbGKjs7W9u2bdPGjRv117/+VV9++WWZQWDevHkaMmSIBg8erJ07d4bk3wD3IwwAgAs1a9ZMTz75pO6++27Vr1//op83adLEueIfMWKEfv3rX2v9+vWaOnWq3n333VJBYOzYsSosLFRqaiphAOUiDLjM8Wxp9S5p497zr733pXRtJ6lFcihLhrNO5Upr/iGt23N+n7y9Xvp+J6lNk/DbTx6v9M1hadXX51+b/jfpmo5S7w5S3TqhLJ2dxowZo1deecU54Rtff/21/vznPzu1ALt27VJRUZEaN26sq666SgMGDNAtt9yiPn366J133tGSJUv04IMPatq0aeeCgKlZWLp0aaj/WXAxwoCLbNonzV8tFXtLv25OPOZx69XSoM6hKh2MnQeluaukwuLS+8OEN/O4vqs0rLsUFRUe+6uoWJr3hfT3/VLJIn93Snp3o/TRdun+66SUhiEspGWef/55TZ482VnesmWLJk2apI8//rjMdT///HPNmDFDl1xyiR566CE99thjuu2223TDDTcoISHhXBD4y1/+EuR/BcINHQhdYtd30rzVvqu08ry3SdpQ4moUwbU/U5qz0ncCLc/ybaWvsN3urfW+IGCU/OidXc46I/3nx1J2XihKZ59nn332XBB4+umnnav98oJASUePHtWUKVPUt29fZWVlOUHA6/Vq/PjxBIEgGzFihL766it98803zh0QDVPDs3DhQrmZNWHAVJF16tRJHTt21OzZs+U2f9vq+wauIAs43t9acWBwq5fvjtX8x3vozV921f+8PFqF+bkKNx9tk4o8lR+jD9Ivrjlwo6NZ0vpKwqX3/wKBaboKpcOZ+/TAf/Qq9drURfdo7Y7qVX0PfSxW437fQ//6Ylf9dt5oTZo5WKu3nb96XrZ+jp5beJeCadiwYfrVr37lLN9///1OfwFzZe8v00fA1CI0aNDACQJRUVG65557FB1tzdd8yLRr187pt2GOwe9//3unw6Zpwnn00Ued5pzjx487HT9NSHMrK5oJTPvahAkT9Omnn6phw4ZOj9yRI0eea4+rjPnDys0N3Mnr6Oko7T7i34fkRI60dW+eOjYP7tnG46lrvm6q/f74hGTd+ewWZ/lv/3mn0j+eqauHT6hmWYqVkxPcS9XTeVHaur+eSlemly23QNqw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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -299,9 +299,9 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -310,7 +310,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 5, @@ -346,9 +346,9 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -357,7 +357,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 6, @@ -540,14 +540,6 @@ } }, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "D:\\Jobs\\Ki3\\graph-compiler\\src\\state.py:238: UserWarning: Stabilizer representation being set is not compared to previously existing representations. Make sure the newrepresentation is consistent with other object representations\n", - " warnings.warn(\n" - ] - }, { "name": "stdout", "output_type": "stream", @@ -557,9 +549,9 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -574,9 +566,9 @@ }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAV0AAAC7CAYAAADG4k2cAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAAsTAAALEwEAmpwYAAAZD0lEQVR4nO3dfXRU9b3v8fd3HghPytOtPViI7SE+W0UaPXpRBHygXVJsbT31GFpRPFArC0c9mui9drlqPSVU63ArlUZtfeig4inqqQ9wsaBowWsVxYWIPVhr8KCtQkAIFZLM7/6xJxBCMiEw89uTmc9rLdas2bNn/76bPfnkl9/es3/mnENERPyIhF2AiEgpUeiKiHik0BUR8UihKyLikUJXRMQjha6IiEcKXRERjxS6IiIeKXRFRDxS6IqIeKTQFRHxSKErIuKRQldExCOFroiIRwpdERGPFLoiIh4pdEVEPFLoioh4pNAVEfFIoSsi4pFCV0TEI4WuiIhHCl0REY9iYRdQihIpDgUuAb4M9AU+BZYCTyeraA6zNhHJL3POhV1DyUik+AfgFuBS9vzCiwLNmed/A+YAtyeraAqjRhHJL4WuJ4kURwLLgMOAeJZVm4AXga8nq9jhozYR8Udjuh4kUhxGMHzwedoEbsRgSP/gsY04cCbwaCKl4yNSbArmh9rMxprZWjNbb2b3mlk07Jpy6HqCwN1rDH1QP7j5guCxnThwPjDBS3Ui4k1BhK6ZRYB7gW875yqAQ4HJ4VaVG4kUfYDpZB9S6EgamJn7ikQkTN5D18zuM7PHzGyFmdWb2TXAKcBG59zazGr3Ad/yXVueXEhwhUJ3RYEJiRTDc1yPiIQojEvGRgKrgO8AQ4F1wBXAhjbr1EP2sDGzOFCenxJz5/Lk+6f1HzKsxSwSjdjeQwmD+u792FZDI6Qd9pc3nhljk89/2U+1InKQ6p1zWa888hq6ZhYDRgBnO+fSwH+b2WdAb6DtZRTW0fvbKQfW577K3PrT/3uUkefNJBov2z2G296Mc/ddduuT8Mm2NKuX3PWb/FcpIjlSAbybbQXfPd3jgA3OuS0AZlYObCfo7U5ts95w4IMutlVPsIMFreKUb/8gEovPAHo1NAZh2mpQ3yBw71oCDe0uDmtoBLMIx4+detFfVj/7uteiReRA1Xe1gu/QPRkYamaHAo3A7cBs4FXgC2Z2XGZcdyqwMNuGMl34rL9RCkEixXzgWoC0g03b912nYUfHy4GtFad863fOuZ35rFFE/PF9Im0kwUmyZ4E1wBrn3N3OuRbgX4Hfmtl6gt7vQ55ry4tkFa8Bb7D38Mn+2AXcnaxCgStSRMLo6Sacc9XtX3DOLQWO9VyPLz8l+CWyP2PVrSLAL/NTjoiExXdP91hgbZdrFZ+HCa5Dbmm7sHWMt6Fxn/Ud8N1kFX/xUp2IeKN7L3iS+UrvTwnGd3cBvTpYbRdBb3hysooFHssTEU8Uup4lUpwI/IDgTmO927z0N+DnwL3JKj4KozYRyT+FbkgSKQ4BRhOcVDwPWKZ76YoUv4K490IpSlaxDfivzNM/K3BFSoNCV0TEI4WuiIhHCl0REY8UuiIiHil0RUQ8UuiKiHik0BUR8UihKyLikUJXRMQjha6IiEcKXRERjxS6IiIeKXRFRDxS6IqIeOR7jjQpAIkUMeDrwCigH7ANeAn4fbKKdJi1iRQ7hW4JSaToD1wHXAUMAZoIpgdywA+B9xMpksDcZBVNYdUpUsw0vFAiEikOA/4A/C/gcwTHvoxgrrayzGpHEMzj9kwiRb8w6hQpdgrdEpAJ0MUEszHHW5dHDIb0Dx7biAFnAQsSKaI+6xQpBQUTumY2x8w+MDNNW5N73weOp03gAgzqBzdfEDy2Ewe+CnzDR3EipaRgQhd4DKgMu4hik5n6/WraBe5+mpnjckRKnvfQNbP7zOwxM1thZvVmdg2Ac+4l55ymHs+9s4FhB/C+CDAmkeKoHNcjUtLCuHphJLAK+A4wFFhnZj93znVrWMHM4kB57svzZ/TFs8orJ1bz6lO15Ta5Ji9tfO+n68YM/Icjd5lFyiK291DCoL57P7bV0AgtaZf+8L9WjDc7oyUvxYkUn3rnXNYrf8w556sYzCwGfAJ80Tm3JbPsY6DCObc187zZOdflLwMzGwGsz2O5eTfgsH9kys/e5f5rR7D1b3/OSxunTLqJU7/xv4n16sOQ/sEY7v649Un425ZdLKm7jHdWzM9LbSJFqMI59262FXz3dI8DNrQJ3HJge2vgdlM9UJHD2rw7Yfy0cmDpCeOnjf/DIzX1+WjjmNGTJ0fjZTcBvRoagzBtNagvzDgX7loCDTv2fl9DI0SicXfCuGnT3lkxf1k+ahMpQl3+HPsO3ZOBoWZ2KNAI3A7MPpANZbrwWX+jFLpEKnisnFhd/9LD1XnZl0SKx4BbANIONm3fd52GHR0vN7PmYcee9bhzblM+ahMpRb5PpI0E7gOeBdYAa5xzdwOY2S/N7AMgmrl0bK7n2opSsoq1BF+K6O7Xe5uAh5NVKHBFciiMnm7COVfd/gXn3HTPtZSSO4HTu/meOHBXHmoRKWm+e7rHAms9tymwEKgD9vcKkTRQk6zij/krSaQ0eQ1d59znnXO7fLYpkKzCATOA1iGbJghOlt36ZPCY0XpsbuAAx9pFJDvdZaxEJKtoARKJFI8Q3GXsO2lHvM0JtL8DvwLuTlbxVjhVihQ/hW6JSVbxMvByIkUCGA8sAC4CFier2BZmbSKloJDuvSAeZa5KWJV5+roCV8QPha6IiEcKXRERjxS6IiIeKXRFRDxS6IqIeKTQFRHxSKErIuKRQldExCOFroiIRwpdERGPFLoiIh4pdEVEPNJdxqQoJVK8DwzN8WY/TFZxRI63mRe9ljybl/3fde7XesT+F/LxV+hKsRpKMOVQrrfZU2j/C3T/FbriXSKFEczZ9k9Af2A78AqwIjPLhUjRUuiKN4kUMWAqkACOAXYCDjCgDHgnkSIJ3Jus2u/53ER6FJ1IEy8SKfoBvyOYYfjozOIyoHfmEeAo4OfAM5n1RYqOQlfyLpEiSjAt0NkEf11Z62sRgyH9g8fM8hgwFviPTM9YpKgUROia2XAze87M1prZGjP7cdg1SU5dDEyggxMbg/rBzRcEj23EgXOBS7xUV4LSf/oTzbfcCoBraqJ55jW4bZqxyYeCCF2gGbjBOXccMAoYY2aTQq5Jcudquv9ZiwAz81ALAB+sfZ6XHqnZ/fzN389j7fL789VcwYkcdRQccgjpVa+TfvxJ7JyzsUMOCbssb8I8/t5D18zuM7PHzGyFmdWb2TXOuQ+dc6sAnHO7gNXQM66HlOwSKU4ETqHNkMJ+MuAriRQjc16UABC9/FLSv34At/JlIud/LexySkYYY2YjCWah/Q7BdW/rzOznzrlmADMbDFxA8Odop8wsDpTnt9T8Gn3xrPLKidW8+lRtuU2u6foNPbD9f/nxqvM+d8TIJjOLR2yfYQQG9d37sVVDI7SkXdMn9avPNTu523/3znwojVl3cz475xxmkRE53WiexBY/3eX+26BBEItho07GotEutxnsv/WI/Q/x+Nc755qyreA1dM0sBowAznbOpYH/NrPPgH7AVjMrAx4D7nDOvd3F5sqB9XktOM/WLK2jcmI1a5bWLS3W9t96/h7OvORnxHr13j1+25EZ5+79/NYn4a+bd8bXPH/vbGB2d9tNtzQRjfXKus47K+fz0fqXAWjcspFTJt3U1Tbj9JTPXHMzxLN/NyC9YiX2pS/h/vgq7huTuh5eaG7uMfsf4vGvAN7NtoLvnu5xwAbn3BYAMysHtjvntppZFEgBq5xzc/ZjW/UEO9hjnTB+Wjmw9ITx08b/4ZGa+mJs//izrrggGi+rBWINjUGYtjWobxC4dy2Bhh17ljc0QjRe1nz8WVNvfHPJ3Me7224kGn+bLr6RdPTpl3DGxbOAYExvP7bZBBzb3VpCEYtl3X/X1ET60ceI3vYj3Btvkv7NfKJXTu9qmz1m/0M8/l3+HPkO3ZOBoWZ2KNAI3M6eXkwd8Clww/5sKNOFz/obpdAlUsFj5cTq+pcerva+Lz7aT6R4nOA4k3awaXvH6zXs2Pc1M4sd9sWTn3DOdbu21n3LJTPjQGoJQ68lz2Z9Pf34E8HJs/79sTP+J82/ewq34QNs+LBO39OT9r+Qj7/vE2kjgfuAZ4E1wBrn3N1mNhq4nOCEy+tm9oaZXeW5NsmDZBXvAUuAlm6+tQV4LlnVM/6c7Wmi/3wR0a+fv/t5rPbfswau5E4YPd2Ec6667ULn3B/o/tlt6TnmEFx32x0R4P/koRYAhh03lmHHjd39/MSzv5+vpqQAhXn8ffd0jwXWem5TwvcscDfs9/0UmoFfAk/lrSKRkHgNXefc5zPX4UoJydw5bCbQerZid/i2nlxraNxreR0wQ3cck2JUKN9IkyKXrKKFIHjHE9z4Jg24tKN503ZIO9IEPduzCQK3u2PAIj2Cbigi3mR6rsuAZYkUhxOM8VcASeDMZBUrQixPxAv1dCUUySo2Jqt4mj3jtn8Nsx4RXxS6IiIeaXhBitWH5GFiwhxvL5+63n/niDjiaaM54oiljSay36+guPb/wLZ50BS6UpR6yqy9+bI/s/bW1NaNILiXwHnAUuDYWdXTesQ3zrpSyMdfwwsiIh4pdEVEPFLoioh4pNAVEfFIoSsi4pFCV0TEI4WuiIhHCl0REY8UuiIiHhXtN9ISKd4nD18DLORvusgeYR9/M8tL+865HvH5K/X9z6ZoQ5fggGefg/rAtik9Q9jHP+z2w1bq+98pDS+IiHik0BUR8UihKyLiUTGP6YoUrL59+zJq1CgGDBjAjh07WLduHR9+uPftWr/yla+wefNm3nvvvZCqlHxQTzdcvTKPYR2H1vajYTSeSBEHPhdG22E5+uijmTNnDhs3buTFF1/kiSeeYOnSpWzYsIGFCxdyzjnnAFBZWclzzz3HjBkzQq5Ycq1gQtfMxprZWjNbb2b3mlkoQeBDIkVZIsU84LXMolcSKb7vsf1YIsUdwKuZRa8mUtyQSJF12oActh9JpLgF+AR4rnVxIlU4n8d8mD59Om+99RZjxozh+uuvZ+DAgcTjccrKypg4cSKxWIzFixfzzDPPsGTJEp5++mluuOGGsMvOqWXLlgFQW1vL8uXLefDBB4nFgj+4hwwZwuGHHx5meV4UxIfczCLAvcC3nXMVwKHA5Hy2+cHa53npkZrdz9/8/TzWLr8/n0229Svge0CfzPOBwE8TKao8tf8zYDrQN/P8EOBmginSfbgZ+DeC49wvs+xy4BZP7Xs//tOnT2fu3LnMnDmTk08+mXvuuYetW7cCsGvXLhYtWsSkSZOYOnUqEyZM4NNPP2XKlCm0tBTfTPQnnngiQ4cOZcyYMaxbt44LL7wQgHg8Tk1NTRfv7vm8j+ma2VDgTuBLBD/0CWA7sNE5tzaz2n3AVcADWbYTB8o7e33mQ2ks+3xP3eacwywy4mC28eXx0wePm/KLb1ok0qfdS/1bmnf9xKzs5YPZfleGH392329W/98rOmo/nW7+YSze/6mW5p15a7/foMOjU+fU/5tFov3avdTXpVuuGfC5itSnn/yl+WDbKaTjf+SRRzJ37lyuvvpqfvGLX3S6XmVlJXfeeSeLFi1izJgxXHHFFcybN2+vdczsoD5/bZ311QvLTxs7gXfXrTl8xDEn8PLzi8utZnquNt+p0aNHs2jRIgAWLVrEd7/7XRYsWMBHH33E0KHZL8XN5f7nSb1zrinbCl5D18xiwEPATOfcWjP7R+Bx4EfAhjar1gPDu9hcOcH8Th1KtzQRjfXq7OUDkm5pimdrc398/P4b7Nyxhd79B3f08vCD3X5Xtm3ewM6/b6V3v0H7vJZubhoMLq/tp1uaaNq5g159Dtnntaadjf2bm/6+LlftFMrxv/LKK1m7di1z587tdJ3KysrdQwqXXnoptbW1XHXVVe1D96A/f22tfmU5p42dwCvLF/9mxDEnsPqV5Utzte1sBg4cSH19PQBbt25l8OA9Pwuvv/46o0aNYtWqVR29Naf7nycVQNZ55nz3dCcCJwLz2/RCtgMGuDbr7U8XpZ5gBzsUicbfpotvxLyzcj4frQ86lo1bNnLKpJuyNhiJxpuAY/ejtk4NGf7lwWX9Br7AnqGFtuqB8Qez/a70GzC0T1mfAa901H4kGt8Mdmo+249E49F4Wd9XCYY09hIv67c9Fu9TCRx0T7cAjv/bQLxv375cdtll3HjjjZ2u2D5wW1pamDdvHtdddx1nnnkmL774YuuqB/35a+ukU8eUA0tPHTNhMvCbk04dM/6FRQvrc7T5Tv//t2zZwoABAwAYMGAADQ0Nu1977bXXqKys7Cx0c7r/edLl/5/v0D0JuM05N6ftQjP7J/YeKhgOfJBtQ5kufKe/URKpros5+vRLOOPiWUAwptcVM8M5d7Czpb6bSPFb4EL2jKkCbI/GetXkYPtdSqSoA65gz3gqwPZINHZLc9NnPtqfDdS0b98i0Tu2fvzeOzlqo0v5PP6tnYqTTjqJgQMHsmDBgg7X6yhwAdavX8+qVasYN25c29DNxedvt5raOgBGHHPCRoDTxk6of/7Z3+Zk+9mGdlasWME111zD/PnzmTBhAitXrtz92rhx47jnnns6fa+Pn498830ibSMwITPMgJkdYWYDCc6if8HMjsusNxVY6Lk2n6YCvwYaCXr6nwDXJqt42FP71wF3Adsy7TcAP8ws8+E2YBawNdP+VuB2gmGmorJlyxYeeOABNm/evM9rgwYNYvHixfsEbqtHH32Ut956y1ep3qxevZqPP/6Y5cuXc/zxx7Nw4Z4f9YqKCt59t8fnala+e7oPAmcBb5tZI/AR8C3nXIuZ/Svw28wJsuUEY79FKVnFLmBGIsW1wABgU7KKtMf2W4CaRIqbCa6c2JxZ5qt9B/w4kWIWMAhoSFYd/JBCIXr77beZMmVKh681NDRw0UUX8cILL3R4lcLs2bPzXF14rr/++n2WVVRU8M47OflDp6CZc67rtXqgRIpd5P4uR03JKnJ7dkbyIuzjb2Z5ad85l7PPX01t3QiCE1PjgaVAxazqabkaXuhw/5ctW8a4ceM6fE+vXr0wM3bu7PTqmZzuf1j0NWAR8aazwIXgeuVSUBBfjhARKRUKXRERjxS6IiIeKXRFRDwq5hNpH5KHifFyvD3Jn7CP/361H4lG4+mWluZINBpLt7Rk/c5+N9sPW9j//wWraENXs/aWtrCP//7MWtvmkq3zCC7ZOjZXl2yFrRhm7c0XDS+IiHik0BUR8UihKyLikUJXRMQjha6IiEcKXRERjxS6IiIeKXRFRDxS6IqIeKTQFRHxSKErIuJRUYWumZ1gZo+a2S1h1yKSK2Y2zMweyvw7Mex65OAU1Q1vnHNrzKwamBJ2LSIdqamtOwX4SeZpn8zj7ZnHB2tq6x6aVT2t/XzwlxPM1vwRcAfwg7wXKnlTVKEr0gO8CnwKfLPNslGZx3LgkQ7e8wVgg3Ou2cx657k+ybOiGl4QKXSzqqc5YDrw1w5enjKretqWDpZvBIZlArfTqXKlZyio0DWzq81sfebfVQfw/mHAbcBEM7uoprbOamrrrquprTss99WKHJhZ1dM+Bqa2WzxnVvW033fyll8BPwJ+mfmXM83NTTQ3NVkutynZFczwgpkdSTBWNRIwYJWZPeOce29/t+Gc+wCoan1eU1v3dYLxss3Ar3NasMhBmFU97ema2ro6YBrwNnBjZ+s65zYA38tl+2b2tUg0erdhpNMtT406fSwnnXpmr1y2IR0LradrZueY2R/NbLWZvQJcAjzqnNvunNsG/Ad7j3t1S01tnQG3AO8CD+WiZpEcuw5YB0yeVT3t774aNbOzgMfSLS1HtLQ045zrs/qPL5GaNzvpq4ZSZs45/42a/Q/gTWC8c26dmR0CzAZWOefuyaxzJXCUc+6ajrZRU1sXJzjx0JnxQB1wA7Awl/WL5FB/YLvPBu+4ecaC5qamUe2Xm9lnp4396lfHTPjGBz7rKTL1s6qnZZ3rLqzhhdOBlc65dQDOuW1m1gK0/Q3Q1ThTOcH8Ul2ZnfknIkAsFqe5ad9c6FXWu/cXjhjxvPeCiksFwV/XnQpzTLd9F3sDe/dchwPZfuNuJOjNduR0ghNqtcDiAy1QpBil0+l7gBHtlzft2vlZw6aPvw/U+6+qaGzsaoWwQnclcLeZHeWc+1NmeOEJ4D/NbDZBL/fbwIQs2zicYAbVbKoz/0QkY+I/X8bvHv0VTbv2XH0WiUYZOuyLvStHj78/vMqKQmH2dJ1zn5jZpcDDZhYDPiOYhnoesDqzWtI59+csm6kn2MH2NJYrksWRx4/k0IGD/2Xzxx9d78BwLh6LxVdWnnH2tcC2sOvr4br8KyGUE2n5VFNbdzRwFXDtrOppzWHXI1KozCwOfAnY5JzbFHY9paLoQldEpJAV1DfSRESKnUJXpISY2Wgzu9HMZrRZNtjM7jezM8KsrVQodEVKy1jn3E+AAWY2xcxuc85tBu4Pua6SodAVKU0OOB/4z7ALKTUKXZHS8oKZ3UTw1eNfA5PMrA/BdfEXmVlZqNWVAF29ICLikXq6IiIeKXRFRDxS6IqIeKTQFRHxSKErIuKRQldExCOFroiIRwpdERGPFLoiIh4pdEVEPFLoioh4pNAVEfFIoSsi4pFCV0TEI4WuiIhH/x+ZeIDIEnNX/QAAAABJRU5ErkJggg==\n", + "image/png": "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/mlRw10GoLqPOS8f9x06dDi1DG5lvDHVPz/+8AfuLLdd1TLYqKp1H+wy4NwIAxextG91rwsAhJvXj3uzFG5V5qaf8+1656d57+nbCH3do3rQTQAAgOUIAwAAWI4wAACA5QgDAABYjjAAAIDlCAMAAFiOMAAAgOUIAwAAWI4wAACA5QgDAABYjjAAAIDlCAMAAFiOMAAAgOUIAwAAWI4wAACA5QgDlTBo0CA1aNBAQ4YMCd1vBEDEmTNnjtq3b6927drprbfeCndxgLMQBirhiSee0HvvvVeZtwCwXHFxsUaOHKkFCxZo9erVGjdunA4cOBDuYgHlEAYqISMjQ4mJiZV5CwDLZWZmKjU1VS1atFBCQoL69u2refPmhbtYQDmx5e8CgN18Pp9+2J6rkpLSU4+Vlvq3N2zeUW67TOuWzVS7Vtw597d7924nCJQx27t27Qrh/wCoPMIAAJwmKipKu37Zr6+/XXlWvUz+eN5Z221btVBKu1bUITyNbgIAOENG905KbnZJhfVSK66mhvS7WdFRUed9TfPmzcu1BJht8xjgJoQBADhDTHS07uvfUzVqXLjx9K5br1f9ugkXfE16errWr1/vhID8/HzNnTtXffr0oc7hKoSBSujdu7fuueceffHFF0pKStKyZctC95sBEFaNG9bTHT2vO+/zV13ZWp06tK1wP7GxsRo/frx69uypTp06adSoUWrUqFGQSwtcHMYMVML8+fPP+9yWHTsln9T2shZOnyMA7+vWKUWbtuY4AwpPl5gQr4G33RDw3/qAAQOcG+BWtAwEgRldPHv+Ur098wutWPdDMHYJwAXMyX5w35sVf8aVAveYx2rXktudPHlSG1Zn6v1JrziTHr014X9r2aIvdfDgwXAXDS4TkWFg//79TpOcme3r7rvvVkFBQUj/vezvt2vvgcPOYCLTdAggctRNiNegPjeeun/dNR10Retkud3evXud8QofTp6kH7ds0ubNm7U790d9/dlMXX755Vq6dGm4iwgXicgwMGbMGA0ePFhbtmxR69atQzr9p2kV+GZplrN9Y9ernEAAILKkXdla16S2VeMG9dQvo5u8MFfCnXfe6QxcLLt/OjOQ8fbbb1dubvnuD9grynfmUeJB77zzjiZOnOg06d1666367LPPtGLFCtWrV09r1qzRc889p6+++iqgfb381/dVeLIo4H/bTExSXFLibNesEct4ASBCmQ9K83F5ocsI3cK0BEz+27gLvsZ8Xvbo1U+33DG42sqF0IurWUPPP/6QfS0D2dnZmjBhghYvXqy1a9fq+eef15EjR5wgEOrZvswHQ8m/g0BMTDRBAIhgJgJ4IQgY2auWKTo6usLPr7Urvq22MsHdPH81wcKFC3Xfffepfv36zv2GDRte1P4qk6jWbtyqabMXOF0Do//jAboIALjCmkWfaG0Ajb5FhQX681OPVEuZ4G6eDwPnYloFyloHKjvbV6DdBCZVFxUXO9vm53+9PuWiygwAwZK755DTUllRL3DNuNp6YcK7VHwEsbaboFevXpoxY4Zz8jfMJTP9+/fX5MmTnfvvv/++M5Am2EpLfSr7OzOzlQGAW6R17n5qQaXzMWHh6vTrq61McLeIGEBorhYwAwjNTF+33XabnnnmGQ0ZMsRpFejYsaOmTZum2rVrB+3fM39kE9/5UHsPHNKtN3TRLddfG7R9A8DFMh/r3bt318qVK0+NazqdGU9Qp04dbdiwQcnJ7r9MEqEXEWGguq3dtE3TPvuGsQIAXGvfvn3OGgirV69WTEyMEwrKug5MF6pZI8EEBiBixwyEWubaTc5P5hUA4FZNmjTRd999p08//VRvv/22duzY4Qy0NgOuf/vb36pBgwbhLiJchJaBKigqKlbm2u/VOe0KriAAAHgeYQAAAMsxDB4AAMsRBgAAsBxhAAAixJw5c5ylis2KrRdaoM0sVHTLLbcoISFBTz/9dLWWEe7EmAEAiADFxcXq0KGDM0W7uXSwc+fOzjLFjRo1Ouu1hYWFzpUGZp6Bbdu26ZVXXglLmeEetAwAQATIzMxUamqqszib+cbft29fTZo0SSkpKcrLy9OJEyecSdjM4m5xcXG66aabgjoZG7yNeQYAIALs3r3bCQJlzLaZZOjxxx/XqFGjVLduXWeOgbS0tLCWE+5EGACACGbCwM0336xjx445XQPAudBNAAARwKzOatZjKVO2YuvRo0e1f/9+HT9+XAUFBWEtI9yLMAAAESA9PV3r1693QoC5WsCsPWDWJjBXC5jWgccee0zPPvtsuIsJl6KbAAAigFm1dfz48erZs6ezsqo58ZtFin744Qe98cYbzgJFN954oxYtWqSMjAznEkSzmFFRUZGmT5+u5cuXKykpKdz/DYQJlxYCAGA5ugkAALAcYQAAAMsRBgAAsBxhAAAAyxEGAACwHGEAAADLEQYAALAcYQAAAMsRBgAAsBxhAAAAyxEGAACwHGEAAADLEQYAALAcYQAAAMsRBgAAsBxhAAAAyxEGAACwHGEAAADLEQYAALAcYQAAANnt/wO4za7iqCSyngAAAABJRU5ErkJggg==", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -585,7 +577,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 8, @@ -596,13 +588,20 @@ "source": [ "\"\"\" Solver circuit initialization examples \"\"\"\n", "from graphiq.benchmarks.circuits import ghz3_state_circuit\n", + "from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau\n", "from graphiq.backends.stabilizer.compiler import StabilizerCompiler\n", - "from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler\n", - "from graphiq.backends.density_matrix.functions import fidelity\n", + "from graphiq.backends.stabilizer.tableau import StabilizerTableau\n", "from graphiq.solvers.time_reversed_solver import TimeReversedSolver\n", "\n", "compiler = StabilizerCompiler()\n", - "target_circuit, target = ghz3_state_circuit()\n", + "target_circuit, _ = ghz3_state_circuit()\n", + "\n", + "# TimeReversedSolver works on the stabilizer representation. GHZ3 is not a graph\n", + "# state, so its density matrix cannot be converted to one; give the target\n", + "# directly by its stabilizer generators instead.\n", + "ghz3_stabilizer = StabilizerTableau(3)\n", + "ghz3_stabilizer.from_labels([\"XXX\", \"ZZI\", \"IZZ\"])\n", + "target = QuantumState(CliffordTableau(ghz3_stabilizer), rep_type=\"s\")\n", "metric = Infidelity(target)\n", "\n", "solver = TimeReversedSolver(\n", From 8015cd4c06abbe883260a45615ea0bd337f752f7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Wed, 23 Sep 2026 20:57:55 -0400 Subject: [PATCH 106/227] [test] Check solver winners in both measurement branches and reseed three searches The solver tests compile each winner with measurement_determinism = 1 only, so a circuit that is correct in that branch and wrong in the other passes check_run. Add assert_robust_to_measurement_outcome, which recompiles the winner with every measurement forced to 0 and then to 1 and requires zero infidelity both times, and call it from every seeded solver test. A self-test shows a one-branch winner (linear4 stabilizer search, seed 48) that check_run accepts and the helper rejects. Two earlier fixes changed the seeded search paths, and three searches now end at infidelity 0.5. Removing the "Fixed" label that replace_photon_one_qubit_op put on its new gate moved the linear4 and ghz3 density-matrix searches; reading the phase vector when converting a stabilizer to a density matrix moved the linear4 stabilizer search. Reseed them: linear4 density matrix 1 -> 2, linear4 stabilizer 1 -> 2, ghz3 density matrix 25 -> 3. Each new seed is the lowest in 0-49 whose winner scores zero infidelity in both branches. --- .../test_evolutionary_random_solvers.py | 129 +++++++++++++++++- 1 file changed, 126 insertions(+), 3 deletions(-) diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index c585ca0a..b31a8854 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -19,6 +19,7 @@ import graphiq.backends.stabilizer.functions.stabilizer as sfs from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.circuit.ops import MeasurementCNOTandReset, MeasurementZ from graphiq.io import IO from graphiq.metrics import Infidelity from graphiq.solvers.evolutionary_solver import ( @@ -92,6 +93,66 @@ def check_run(run_info, expected_info): assert state.rep_data == target_state.rep_data +def assert_robust_to_measurement_outcome( + circuit, expected_info, compiler_class, n_photon, n_emitter +): + """ + Assert that a winning circuit scores 0 infidelity against the target state when every + measurement is forced to outcome 0 and when every measurement is forced to outcome 1, not + only under the outcome the solver searched with. For a circuit with a single measurement, + as every winner certified in this module has, that covers every measurement branch; with + more than one measurement the mixed outcome patterns are not checked. + + The solver fixtures pin the compiler at measurement_determinism = 1, so every mid-circuit + measurement in the search, and in check_run, takes that one outcome. A circuit that is 0.0 + there but far from the target in the other branch is effectively post-selecting on a + measurement outcome. Compiling the circuit under both settings catches it, so a change of + solver seed cannot silently accept a branch-dependent winner. + + Deliberately not checked under "probabilistic": that draws an outcome at random, so a + post-selected circuit passes or fails by luck. Forcing both branches is stronger and + deterministic. + + :param circuit: the circuit to check, e.g. the winner of an evolutionary search + :type circuit: CircuitDAG + :param expected_info: (target_state, ideal_circuit, metric) tuple, as produced by the + ``*_expected`` fixtures in this module + :type expected_info: tuple + :param compiler_class: the compiler class to instantiate fresh for each determinism setting + (e.g. StabilizerCompiler or DensityMatrixCompiler) + :type compiler_class: type + :param n_photon: number of photonic qubits in the circuit + :type n_photon: int + :param n_emitter: number of emitter qubits in the circuit, traced out before scoring + :type n_emitter: int + :return: nothing + :rtype: None + """ + target_state, _, metric = expected_info + + # Without a measurement the two compiles are identical and this function guards nothing + # while still passing. + assert any( + isinstance(op, (MeasurementCNOTandReset, MeasurementZ)) + for op in circuit.sequence() + ), "circuit contains no measurement, so both determinism settings are the same run" + + for determinism in (0, 1): + compiler = compiler_class() + compiler.measurement_determinism = determinism + state = compiler.compile(circuit) + state.partial_trace( + list(range(n_photon)), + (n_photon + n_emitter) * [2], + measurement_determinism=determinism, + ) + infidelity = metric.evaluate(state, circuit) + assert np.isclose(infidelity, 0.0), ( + f"circuit is not robust to measurement outcome: infidelity={infidelity} " + f"under measurement_determinism={determinism}" + ) + + def check_run_visual(run_info, expected_info): hof, state = run_info target_state, _, metric = expected_info @@ -142,12 +203,12 @@ def linear4_run(density_matrix_compiler, linear4_expected): Since we want to apply 2 separate tests on the same run (one visual, one non-visual), it makes sense to have a common fixture that only gets called once per module """ - return generate_run(4, 1, linear4_expected, density_matrix_compiler, 1) + return generate_run(4, 1, linear4_expected, density_matrix_compiler, 2) @pytest.fixture(scope="module") def linear4_run_stabilizer(stabilizer_compiler, linear4_expected): - return generate_run(4, 1, linear4_expected, stabilizer_compiler, 1) + return generate_run(4, 1, linear4_expected, stabilizer_compiler, 2) @pytest.fixture(scope="module") @@ -161,7 +222,7 @@ def linear4_expected(): @pytest.fixture(scope="module") def ghz3_run(density_matrix_compiler, ghz3_expected): - return generate_run(3, 1, ghz3_expected, density_matrix_compiler, 25) + return generate_run(3, 1, ghz3_expected, density_matrix_compiler, 3) @pytest.fixture(scope="module") @@ -180,10 +241,18 @@ def ghz3_expected(): def test_solver_linear3(linear3_run, linear3_expected): check_run(linear3_run, linear3_expected) + hof, _ = linear3_run + assert_robust_to_measurement_outcome( + hof[0][1], linear3_expected, DensityMatrixCompiler, n_photon=3, n_emitter=1 + ) def test_solver_linear3_stabilizer(linear3_run_stabilizer, linear3_expected): check_run(linear3_run_stabilizer, linear3_expected) + hof, _ = linear3_run_stabilizer + assert_robust_to_measurement_outcome( + hof[0][1], linear3_expected, StabilizerCompiler, n_photon=3, n_emitter=1 + ) @visualization @@ -193,10 +262,18 @@ def test_solver_linear3_visualized(linear3_run, linear3_expected): def test_solver_linear4(linear4_run, linear4_expected): check_run(linear4_run, linear4_expected) + hof, _ = linear4_run + assert_robust_to_measurement_outcome( + hof[0][1], linear4_expected, DensityMatrixCompiler, n_photon=4, n_emitter=1 + ) def test_solver_linear4_stabilizer(linear4_run_stabilizer, linear4_expected): check_run(linear4_run_stabilizer, linear4_expected) + hof, _ = linear4_run_stabilizer + assert_robust_to_measurement_outcome( + hof[0][1], linear4_expected, StabilizerCompiler, n_photon=4, n_emitter=1 + ) @visualization @@ -206,12 +283,20 @@ def test_solver_linear4_visualized(linear4_run, linear4_expected): def test_solver_ghz3(ghz3_run, ghz3_expected, density_matrix_compiler): check_run(ghz3_run, ghz3_expected) + hof, _ = ghz3_run + assert_robust_to_measurement_outcome( + hof[0][1], ghz3_expected, DensityMatrixCompiler, n_photon=3, n_emitter=1 + ) def test_solver_ghz3_stabilizer( ghz3_run_stabilizer, ghz3_expected, stabilizer_compiler ): check_run(ghz3_run_stabilizer, ghz3_expected) + hof, _ = ghz3_run_stabilizer + assert_robust_to_measurement_outcome( + hof[0][1], ghz3_expected, StabilizerCompiler, n_photon=3, n_emitter=1 + ) @visualization @@ -219,6 +304,36 @@ def test_solver_ghz3_visualized(ghz3_run, ghz3_expected): check_run_visual(ghz3_run, ghz3_expected) +def test_assert_robust_to_measurement_outcome_rejects_a_post_selected_winner( + stabilizer_compiler, linear4_expected +): + """ + Seed 48 of the linear4 stabilizer search ends on a winner that scores 0.0 in the branch the + compiler is pinned to (measurement_determinism = 1) but is wrong in the other one, so + check_run accepts it and assert_robust_to_measurement_outcome must not. + + The seed only selects such a winner under the current search path. If a solver change moves + that path, the precondition below fails; pick a new seed by searching for one whose winner + scores 0.0 with measurement_determinism = 1 and not with measurement_determinism = 0. + """ + hof, state = generate_run(4, 1, linear4_expected, stabilizer_compiler, 48) + circuit = hof[0][1] + _, _, metric = linear4_expected + branch0_compiler = StabilizerCompiler() + branch0_compiler.measurement_determinism = 0 + branch0_state = branch0_compiler.compile(circuit) + branch0_state.partial_trace(list(range(4)), 5 * [2], measurement_determinism=0) + assert np.isclose(hof[0][0], 0.0) and not np.isclose( + metric.evaluate(branch0_state, circuit), 0.0 + ), "seed 48 no longer gives a one-branch winner; pick a new seed (see docstring)" + + check_run((hof, state), linear4_expected) + with pytest.raises(AssertionError, match="not robust to measurement outcome"): + assert_robust_to_measurement_outcome( + hof[0][1], linear4_expected, StabilizerCompiler, n_photon=4, n_emitter=1 + ) + + @pytest.mark.parametrize("seed", [0, 3, 325, 2949]) def test_add_remove_measurements(seed): n_emitter = 1 @@ -320,6 +435,14 @@ def test_stabilizer_linear3(): assert output_s_tableau == target_s_tableau assert state.rep_data == target_state.rep_data + assert_robust_to_measurement_outcome( + circuit, + (target_state, circuit_ideal, metric), + StabilizerCompiler, + n_photon=n_photon, + n_emitter=n_emitter, + ) + # Tests for EvolutionarySolver.adapt_probabilities and get_emission_assignment, which have no # oracle beyond the source and are exercised here directly rather than through a full search. From 4630a86a7bf795e85e92f3b1510376a61ac8136b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 11:27:40 -0400 Subject: [PATCH 107/227] [fix] Reject malformed stabilizer labels instead of misreading them string_to_symplectic and get_stabilizer_element_by_string set a bit only for x, y and z and read every other character as the identity, so a typo, a sign or a stray space silently became an extra identity qubit: from_labels(["ZQ", "IZ"]) gave the tableau of ["ZI", "IZ"], and stabilizer_to_density(["ZQ", "IZ"]) returned a valid-looking 4x4 density matrix for the wrong state. string_to_symplectic also took its width from the first label alone, so a ragged list was silently truncated when the first label was the shortest and raised IndexError otherwise; a bare string was read as one single-qubit generator per character; and an empty list raised IndexError. All of these now raise ValueError; the character and length errors name the offending label. Lowercase labels are still accepted. --- .../backends/stabilizer/functions/utils.py | 53 +++++++ .../test_pauli_string_validation.py | 142 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 tests/test_state_representation/stabilizer/test_pauli_string_validation.py diff --git a/graphiq/backends/stabilizer/functions/utils.py b/graphiq/backends/stabilizer/functions/utils.py index a5f47535..a3cb8565 100644 --- a/graphiq/backends/stabilizer/functions/utils.py +++ b/graphiq/backends/stabilizer/functions/utils.py @@ -48,6 +48,27 @@ def symplectic_to_string(x_matrix, z_matrix): return generator_list +_PAULI_CHARACTERS = frozenset("ixyzIXYZ") + + +def _validate_pauli_label(label): + """ + Raise ValueError if label contains a character other than I, X, Y, or Z + (case-insensitive). + + :param label: a single stabilizer generator string + :type label: str + :return: nothing + :rtype: None + """ + invalid = set(label) - _PAULI_CHARACTERS + if invalid: + raise ValueError( + f"Invalid stabilizer generator {label!r}: unexpected character(s) " + f"{''.join(sorted(invalid))!r}; expected one of I, X, Y, Z" + ) + + def string_to_symplectic(generator_list): """ Convert a string list representation of stabilizer generators to a symplectic representation @@ -56,7 +77,36 @@ def string_to_symplectic(generator_list): :type generator_list: list[str] :return: two binary matrices, one for X part, the other for Z part :rtype: numpy.ndarray, numpy.ndarray + :raises ValueError: if generator_list is a bare string rather than a list of strings, + if it is empty, if any generator contains a character other than I, X, Y, Z + (case-insensitive), or if the generators are not all the same length """ + if isinstance(generator_list, str): + raise ValueError( + "string_to_symplectic expects a list of generator strings, not a single " + f"string ({generator_list!r}); wrap it in a list" + ) + + # Materialise first: the validation below iterates twice, and an exhausted + # iterator would report an empty, actively misleading length set. + generator_list = list(generator_list) + + if not generator_list: + raise ValueError( + "string_to_symplectic expects at least one generator; an empty list " + "gives no qubit count to build a tableau from" + ) + + for label in generator_list: + _validate_pauli_label(label) + + lengths = {len(label) for label in generator_list} + if len(lengths) != 1: + raise ValueError( + f"Stabilizer generators have inconsistent lengths {sorted(lengths)}: " + f"{generator_list!r}" + ) + n_row = len(generator_list) n_column = len(generator_list[0]) x_matrix = np.zeros((n_row, n_column)) @@ -82,7 +132,10 @@ def get_stabilizer_element_by_string(generator): :type generator: str :return: a matrix representation of the stabilizer element :rtype: numpy.ndarray + :raises ValueError: if generator contains a character other than I, X, Y, Z + (case-insensitive) """ + _validate_pauli_label(generator) stabilizer_elem = 1 for pauli in generator: if pauli.lower() == "x": diff --git a/tests/test_state_representation/stabilizer/test_pauli_string_validation.py b/tests/test_state_representation/stabilizer/test_pauli_string_validation.py new file mode 100644 index 00000000..8025275f --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_pauli_string_validation.py @@ -0,0 +1,142 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``string_to_symplectic`` and ``get_stabilizer_element_by_string`` used to accept any +character, mapping everything that was not x/y/z (case-insensitively) to the identity +instead of rejecting it. A sign character, a stray space, a trailing newline, or a typo +would silently read as an extra identity qubit rather than raising. A ragged list of +labels was read at the width of the first label, so it was silently truncated when the +first label was the shortest and raised IndexError otherwise. A bare string was read as +one single-qubit generator per character, and an empty list raised IndexError. All of +these now raise ValueError. +""" + +import numpy as np +import pytest + +import graphiq.backends.stabilizer.functions.utils as sfu +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +def test_string_to_symplectic_accepts_clean_input(): + x_matrix, z_matrix = sfu.string_to_symplectic(["ZI", "IZ"]) + + assert np.array_equal(x_matrix, np.zeros((2, 2))) + assert np.array_equal(z_matrix, np.array([[1, 0], [0, 1]])) + + +def test_string_to_symplectic_lowercase_matches_uppercase(): + lower_x, lower_z = sfu.string_to_symplectic(["zi", "iz"]) + upper_x, upper_z = sfu.string_to_symplectic(["ZI", "IZ"]) + + assert np.array_equal(lower_x, upper_x) + assert np.array_equal(lower_z, upper_z) + + +def test_string_to_symplectic_rejects_non_pauli_character(): + # The critical mutant: same width as a clean label, so only a character check (not a + # length check) can catch it. + with pytest.raises(ValueError): + sfu.string_to_symplectic(["ZQ", "IZ"]) + + +def test_string_to_symplectic_rejects_sign_character(): + with pytest.raises(ValueError): + sfu.string_to_symplectic(["-ZI", "-IZ"]) + + +def test_string_to_symplectic_rejects_trailing_newline(): + with pytest.raises(ValueError): + sfu.string_to_symplectic(["ZI\n", "IZ\n"]) + + +def test_string_to_symplectic_rejects_embedded_space(): + with pytest.raises(ValueError): + sfu.string_to_symplectic(["Z I", "I Z"]) + + +def test_string_to_symplectic_rejects_ragged_lengths(): + with pytest.raises(ValueError): + sfu.string_to_symplectic(["ZI", "IZZ"]) + + +def test_string_to_symplectic_ragged_and_bad_character_raises_value_error_with_message(): + # Both ragged and bad-charactered; must raise the new ValueError (naming the label + # and/or the offending character), not an IndexError from the old truncating parser. + with pytest.raises(ValueError) as exc_info: + sfu.string_to_symplectic(["-ZI", "IZ"]) + + message = str(exc_info.value) + assert "-ZI" in message + + +def test_string_to_symplectic_empty_list_is_rejected(): + # An empty list means "no generators over an unknown number of qubits", which is + # not something a tableau can be built from -- the width is exactly what is missing. + # Returning zero-by-zero matrices instead would assert "over zero qubits", which the + # function has no way to know, and would push the failure into a bare shape assert + # further downstream. + with pytest.raises(ValueError, match=r"at least one generator"): + sfu.string_to_symplectic([]) + + +def test_string_to_symplectic_rejects_bare_string(): + with pytest.raises(ValueError): + sfu.string_to_symplectic("ZI") + + +def test_string_to_symplectic_round_trips_with_symplectic_to_string(): + rng = np.random.default_rng(0) + for _ in range(20): + n_row, n_col = rng.integers(1, 5), rng.integers(1, 5) + x_matrix = rng.integers(0, 2, size=(n_row, n_col)) + z_matrix = rng.integers(0, 2, size=(n_row, n_col)) + labels = sfu.symplectic_to_string(x_matrix, z_matrix) + + round_trip_x, round_trip_z = sfu.string_to_symplectic(labels) + + assert np.array_equal(round_trip_x, x_matrix) + assert np.array_equal(round_trip_z, z_matrix) + + +def test_from_labels_rejects_non_pauli_character(): + tableau = StabilizerTableau(2) + + with pytest.raises(ValueError): + tableau.from_labels(["ZQ", "IZ"]) + + +def test_get_stabilizer_element_by_string_rejects_sign_character(): + with pytest.raises(ValueError): + sfu.get_stabilizer_element_by_string("-ZI") + + +def test_get_stabilizer_element_by_string_accepts_clean_input(): + element = sfu.get_stabilizer_element_by_string("ZI") + + assert element.shape == (4, 4) + + +def test_stabilizer_to_density_rejects_sign_character_labels(): + # A sign adds a column, which the square-table check would also reject; match the + # character check's own message so the test pins the new rejection. + with pytest.raises(ValueError, match="unexpected character"): + rc.stabilizer_to_density(["-ZI", "-IZ"]) + + +def test_stabilizer_to_density_rejects_stray_character_labels(): + with pytest.raises(ValueError): + rc.stabilizer_to_density(["ZQ", "IZ"]) From 2baadf1140a304a05044cde13fa3ba01af3b75ad Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 11:27:40 -0400 Subject: [PATCH 108/227] [fix] Refuse partial_trace on a graph state built without an explicit rep_type QuantumState.partial_trace refused a graph state by checking _rep_type == "g". When rep_type is omitted, __init__ detects the representation from the data but leaves _rep_type as None, so QuantumState(nx.path_graph(4)).partial_trace(...) skipped the refusal and raised AttributeError: 'Graph' object has no attribute 'partial_trace'. Check whether the representation object has a partial_trace method instead; that case now raises the same NotImplementedError as rep_type="g", whose message now names the representation class. --- graphiq/state.py | 17 +++++++++++------ .../test_quantum_state.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/graphiq/state.py b/graphiq/state.py index be250be0..46c733d0 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -167,16 +167,21 @@ def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): :type measurement_determinism: str or int :return: nothing :rtype: None + :raises NotImplementedError: if the representation object has no partial_trace """ - if self._rep_type == "g": + # Asked of the representation object, not of _rep_type: __init__ can + # autodetect a representation without writing the name back, leaving + # _rep_type None, so a check on the name misses a graph state built + # without an explicit rep_type. + if not hasattr(self._rep_data, "partial_trace"): raise NotImplementedError( - "Partial trace not yet implemented on graph state" - ) - else: - self._rep_data.partial_trace( - keep, dims, measurement_determinism=measurement_determinism + f"Partial trace not yet implemented on the " + f"{type(self._rep_data).__name__} representation" ) + self._rep_data.partial_trace( + keep, dims, measurement_determinism=measurement_determinism + ) def show(self, show=True, ax=None): """ diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 59d1dc38..e24bd0c4 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -153,3 +153,13 @@ def test_density_matrix_partial_trace_accepts_measurement_determinism(): state.partial_trace(keep=[0], dims=[2, 2], measurement_determinism=1) assert np.allclose(state.rep_data.data, np.eye(2) / 2) + + +@pytest.mark.parametrize("rep_type", ["g", None]) +def test_partial_trace_on_a_graph_state_says_it_is_not_implemented(rep_type): + # rep_type=None autodetects the graph representation without recording its + # name, which used to slip past the refusal into an AttributeError. + state = QuantumState(nx.path_graph(4), rep_type=rep_type) + + with pytest.raises(NotImplementedError, match="Graph representation"): + state.partial_trace(keep=[0], dims=4 * [2]) From 0cebd8efd12252155dbd0860ff6252c540196465 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 11:27:40 -0400 Subject: [PATCH 109/227] [fix] Reject a malformed phase vector instead of zeroing or broadcasting it The StabilizerTableau and CliffordTableau constructors kept a phase only if it was an ndarray whose first dimension matched, and otherwise substituted zeros. A phase of the wrong length, or a plain list, was silently replaced by zeros, so a requested -1 sign became +1. A column-shaped phase was accepted as is, and later gate updates broadcast it: after one hadamard_gate, CliffordTableau(2, np.zeros((4, 1))) has a (4, 4) phase. A 0-d phase raised a bare IndexError, and CliffordTableau(tableau, phase) silently dropped the phase. Now: - the constructors take any array-like phase and raise ValueError unless its shape is exactly (n_qubits,) or (2 * n_qubits,); None still means zeros; - passing a phase with a tableau to copy raises ValueError; - the phase and iphase setters and both _reset methods check the full shape and raise ValueError instead of asserting the length only, and CliffordTableau._reset now also checks new_iphase; - both __eq__ methods compare phases with np.array_equal. The old np.all(a == b) broadcast a column phase into a match, and for tableaux of different sizes it emitted numpy's "elementwise comparison failed" DeprecationWarning. test_echelon_form passed column-shaped phases, which the old constructor accepted; it now passes 1-D phases. CliffordTableau.__init__ keeps its *args and **kwargs, so the public signature is unchanged and a misspelled keyword is still ignored. --- .../backends/stabilizer/clifford_tableau.py | 48 +++- graphiq/backends/stabilizer/tableau.py | 36 ++- .../stabilizer/test_stabilizer_functions.py | 4 +- .../test_tableau_phase_shape_guards.py | 221 ++++++++++++++++++ 4 files changed, 287 insertions(+), 22 deletions(-) create mode 100644 tests/test_state_representation/stabilizer/test_tableau_phase_shape_guards.py diff --git a/graphiq/backends/stabilizer/clifford_tableau.py b/graphiq/backends/stabilizer/clifford_tableau.py index bc5905cf..76bb0ee2 100644 --- a/graphiq/backends/stabilizer/clifford_tableau.py +++ b/graphiq/backends/stabilizer/clifford_tableau.py @@ -28,8 +28,12 @@ def __init__(self, data, phase=None, *args, **kwargs): :param data: input data used to initialize the tableau :type data: numpy.ndarray or int or CliffordTableau - :param phase: a phase vector - :type phase: numpy.ndarray + :param phase: a phase vector, or None for an all-zero default; must + be omitted when data is an existing tableau, since the phase is + copied from it instead + :type phase: numpy.ndarray or list[int] or None + :raises ValueError: if phase is not None and its shape is wrong, or + if phase is passed alongside an existing tableau to copy """ if isinstance(data, int): self._table = np.eye(2 * data).astype(int) @@ -41,6 +45,11 @@ def __init__(self, data, phase=None, *args, **kwargs): self.n_qubits = int(data.shape[1] / 2) self._initialize_phase(phase) elif isinstance(data, CliffordTableau) or isinstance(data, StabilizerTableau): + if phase is not None: + raise ValueError( + "phase cannot be set when constructing from an existing " + "tableau; the phase is copied from data instead" + ) if isinstance(data, StabilizerTableau): data = stab.clifford_from_stabilizer(data) self._table = np.copy(data.table) @@ -56,15 +65,21 @@ def _initialize_phase(self, phase): """ Helper function to initialize the phase vectors - :param phase: - :type phase: numpy.ndarray + :param phase: the phase vector, or None for an all-zero default + :type phase: numpy.ndarray or list[int] or None :return: nothing :rtype: None + :raises ValueError: if phase is not None and its shape is not (2 * n_qubits,) """ - if isinstance(phase, np.ndarray) and phase.shape[0] == 2 * self.n_qubits: - self._phase = phase.astype(int) - else: + if phase is None: self._phase = np.zeros(2 * self.n_qubits).astype(int) + else: + phase = np.asarray(phase) + if phase.shape != (2 * self.n_qubits,): + raise ValueError( + f"phase must have shape ({2 * self.n_qubits},), got {phase.shape}" + ) + self._phase = phase.astype(int) self._iphase = np.zeros(2 * self.n_qubits).astype(int) def copy(self): @@ -281,8 +296,12 @@ def iphase(self, value): :type value: numpy.ndarray :return: nothing :rtype: None + :raises ValueError: if value's shape is not (2 * n_qubits,) """ - assert value.shape[0] == 2 * self.n_qubits + if value.shape != (2 * self.n_qubits,): + raise ValueError( + f"iphase must have shape ({2 * self.n_qubits},), got {value.shape}" + ) self._iphase = value.astype(int) def __str__(self): @@ -340,8 +359,8 @@ def __eq__(self, other): # check without converting to canonical form if isinstance(other, CliffordTableau): return ( - np.all(self.phase == other.phase) - and np.all(self.iphase == other.iphase) + np.array_equal(self.phase, other.phase) + and np.array_equal(self.iphase, other.iphase) and np.array_equal(self.table.astype(int), other.table.astype(int)) ) @@ -358,7 +377,14 @@ def to_stabilizer(self): def _reset(self, new_table, new_phase, new_iphase): new_n_qubits = int(new_table.shape[0] / 2) - assert len(new_phase) == 2 * new_n_qubits + if new_phase.shape != (2 * new_n_qubits,): + raise ValueError( + f"new_phase must have shape ({2 * new_n_qubits},), got {new_phase.shape}" + ) + if new_iphase.shape != (2 * new_n_qubits,): + raise ValueError( + f"new_iphase must have shape ({2 * new_n_qubits},), got {new_iphase.shape}" + ) self._table = new_table.astype(int) self._phase = new_phase.astype(int) self._iphase = new_iphase.astype(int) diff --git a/graphiq/backends/stabilizer/tableau.py b/graphiq/backends/stabilizer/tableau.py index c0311937..59159480 100644 --- a/graphiq/backends/stabilizer/tableau.py +++ b/graphiq/backends/stabilizer/tableau.py @@ -23,7 +23,6 @@ class TableauBase(ABC): """ The base class for Stabilizer and Clifford Tableau - """ def __init__(self, table, phase, n_qubits, shape): @@ -86,8 +85,17 @@ def phase(self, value): :type value: numpy.ndarray :return: nothing :rtype: None - """ - assert value.shape[0] == self._phase.shape[0] + :raises ValueError: if value's shape is not (self.shape[0],) + """ + # self.shape[0] is the canonical phase length for both subclasses: + # n for StabilizerTableau, whose shape is (n, 2n), and 2n for + # CliffordTableau, whose shape is (2n, 2n). Comparing against + # self._phase.shape instead would let a corrupted _phase relax this + # check to match the corruption rather than catch it. + if value.shape != (self.shape[0],): + raise ValueError( + f"phase must have shape ({self.shape[0]},), got {value.shape}" + ) self._phase = value.astype(int) @@ -102,8 +110,9 @@ def __init__(self, data, phase=None): :param data: the data used to initialize the stabilizer tableau :type data: int or numpy.ndarray or [numpy.ndarray, numpy.ndarray] - :param phase: the phase vector - :type phase: numpy.ndarray + :param phase: the phase vector, or None for an all-zero default + :type phase: numpy.ndarray or list[int] or None + :raises ValueError: if phase is not None and its shape is not (n_qubits,) """ if isinstance(data, int): self._table = np.hstack([np.zeros((data, data)), np.eye(data)]).astype(int) @@ -128,10 +137,15 @@ def __init__(self, data, phase=None): else: raise TypeError("Cannot support the input type") - if isinstance(phase, np.ndarray) and phase.shape[0] == self.n_qubits: - self._phase = np.copy(phase).astype(int) - else: + if phase is None: self._phase = np.zeros(self.n_qubits).astype(int) + else: + phase = np.asarray(phase) + if phase.shape != (self.n_qubits,): + raise ValueError( + f"phase must have shape ({self.n_qubits},), got {phase.shape}" + ) + self._phase = phase.astype(int) self.shape = (self.n_qubits, 2 * self.n_qubits) @@ -207,7 +221,7 @@ def __eq__(self, other): :rtype: bool """ if isinstance(other, StabilizerTableau): - return np.all(self.phase == other.phase) and np.array_equal( + return np.array_equal(self.phase, other.phase) and np.array_equal( self.table.astype(int), other.table.astype(int) ) return False @@ -244,6 +258,10 @@ def validate(self): def _reset(self, new_table, new_phase): new_n_qubits = int(new_table.shape[0]) + if new_phase.shape != (new_n_qubits,): + raise ValueError( + f"new_phase must have shape ({new_n_qubits},), got {new_phase.shape}" + ) self._table = new_table.astype(int) self._phase = new_phase.astype(int) self.n_qubits = new_n_qubits diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py index 3365e544..023279df 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py @@ -88,8 +88,8 @@ def test_symplectic_product(): def test_echelon_form(n_nodes): g1 = nx.complete_graph(n_nodes) g2 = nx.gnp_random_graph(n_nodes, 0.5) - r_vector1 = np.zeros([n_nodes, 1]) - r_vector2 = np.zeros([n_nodes, 1]) + r_vector1 = np.zeros(n_nodes) + r_vector2 = np.zeros(n_nodes) z1 = nx.to_numpy_array(g1).astype(int) z2 = nx.to_numpy_array(g2).astype(int) x1 = np.eye(n_nodes) diff --git a/tests/test_state_representation/stabilizer/test_tableau_phase_shape_guards.py b/tests/test_state_representation/stabilizer/test_tableau_phase_shape_guards.py new file mode 100644 index 00000000..f21ea0fa --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_tableau_phase_shape_guards.py @@ -0,0 +1,221 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Shape guards on the phase and i-phase vectors of both tableau classes. + +The constructors used to replace a phase of the wrong length, or a plain list, +with zeros, so a requested -1 sign silently became +1. They accepted a column-shaped +phase as is, which later gate updates broadcast into an (n, n) array. A 0-d phase +raised a bare IndexError, and CliffordTableau dropped a phase passed alongside a +tableau to copy. The phase setters and ``_reset`` asserted only the length, and +``__eq__`` compared phases by broadcasting. These tests pin the ``ValueError`` each +path now raises, and the shape-strict comparison. +""" + +import warnings + +import numpy as np +import pytest + +from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau + + +def test_stabilizer_tableau_rejects_column_phase(): + with pytest.raises(ValueError): + StabilizerTableau(3, np.ones((3, 1))) + + +def test_clifford_tableau_rejects_column_phase(): + with pytest.raises(ValueError): + CliffordTableau(2, np.ones((4, 1))) + + +def test_stabilizer_tableau_phase_setter_rejects_column_phase(): + tableau = StabilizerTableau(3) + with pytest.raises(ValueError): + tableau.phase = np.ones((3, 1)) + + +def test_clifford_tableau_phase_setter_rejects_column_phase(): + tableau = CliffordTableau(2) + with pytest.raises(ValueError): + tableau.phase = np.ones((4, 1)) + + +def test_stabilizer_tableau_rejects_wrong_length_phase(): + with pytest.raises(ValueError): + StabilizerTableau(3, np.array([1, 1])) + + +def test_clifford_tableau_rejects_wrong_length_phase(): + with pytest.raises(ValueError): + CliffordTableau(2, np.array([1, 1])) + + +def test_stabilizer_tableau_rejects_scalar_phase_with_a_named_error(): + # A 0-d phase used to reach shape[0] and raise a bare IndexError; this + # pins that it is now the same named ValueError as any other bad shape. + with pytest.raises(ValueError): + StabilizerTableau(3, np.array(1)) + + +def test_phase_none_still_yields_zeros(): + assert np.array_equal(StabilizerTableau(3).phase, np.zeros(3)) + assert np.array_equal(StabilizerTableau(3, phase=None).phase, np.zeros(3)) + assert np.array_equal(CliffordTableau(2).phase, np.zeros(4)) + assert np.array_equal(CliffordTableau(2, phase=None).phase, np.zeros(4)) + + +def test_a_python_list_phase_is_accepted(): + assert np.array_equal(StabilizerTableau(3, [1, 0, 1]).phase, [1, 0, 1]) + assert np.array_equal(CliffordTableau(2, [1, 0, 1, 0]).phase, [1, 0, 1, 0]) + + +def test_clifford_tableau_from_clifford_tableau_rejects_a_phase_argument(): + src = CliffordTableau(2) + src.phase = np.array([1, 0, 1, 0]) + passed_phase = np.array([0, 1, 0, 1]) + + with pytest.raises(ValueError): + CliffordTableau(src, passed_phase) + with pytest.raises(ValueError): + CliffordTableau(src, phase=passed_phase) + + +def test_clifford_tableau_from_stabilizer_tableau_rejects_a_phase_argument(): + src = StabilizerTableau(2) + passed_phase = np.array([0, 1, 0, 1]) + + with pytest.raises(ValueError): + CliffordTableau(src, passed_phase) + with pytest.raises(ValueError): + CliffordTableau(src, phase=passed_phase) + + +def test_iphase_setter_rejects_a_column_vector(): + tableau = CliffordTableau(2) + with pytest.raises(ValueError): + tableau.iphase = np.ones((4, 1)) + + +def test_stabilizer_tableau_expand_rejects_a_column_new_phase(): + tableau = StabilizerTableau(2) + new_table = np.hstack([np.zeros((3, 3)), np.eye(3)]) + with pytest.raises(ValueError): + tableau.expand(new_table, np.ones((3, 1))) + + +def test_stabilizer_tableau_shrink_rejects_a_column_new_phase(): + tableau = StabilizerTableau(3) + new_table = np.hstack([np.zeros((2, 2)), np.eye(2)]) + with pytest.raises(ValueError): + tableau.shrink(new_table, np.ones((2, 1))) + + +def test_clifford_tableau_expand_rejects_a_column_new_phase(): + tableau = CliffordTableau(2) + new_table = np.eye(6) + with pytest.raises(ValueError): + tableau.expand(new_table, np.ones((6, 1)), np.zeros(6)) + + +def test_clifford_tableau_shrink_rejects_a_column_new_phase(): + tableau = CliffordTableau(3) + new_table = np.eye(4) + with pytest.raises(ValueError): + tableau.shrink(new_table, np.ones((4, 1)), np.zeros(4)) + + +def test_clifford_tableau_expand_rejects_a_wrong_length_new_iphase(): + tableau = CliffordTableau(2) + new_table = np.eye(6) + with pytest.raises(ValueError): + tableau.expand(new_table, np.zeros(6), np.zeros(3)) + + +def test_stabilizer_tableau_eq_reports_equal_phases_as_equal(): + a = StabilizerTableau(3, np.array([1, 0, 1])) + b = StabilizerTableau(3, np.array([1, 0, 1])) + + assert a == b + + +def test_stabilizer_tableau_eq_reports_unequal_phases_as_unequal(): + a = StabilizerTableau(3, np.array([1, 0, 1])) + b = StabilizerTableau(3, np.array([0, 0, 0])) + + assert a != b + + +def test_stabilizer_tableau_eq_does_not_broadcast_a_column_phase_into_a_match(): + # The constructor and setter now reject a column phase outright, but the + # phase getter returns _phase by identity, so `t.phase.shape = (n, 1)` + # reshapes one in place past both guards. A same-valued column used to + # compare equal to a row by broadcasting the comparison into an (n, n) + # matrix; the shape-strict comparison must report them as unequal. + row = StabilizerTableau(3, np.ones(3)) + column = StabilizerTableau(3, np.ones(3)) + column.phase.shape = (3, 1) + + assert row != column + + +def test_clifford_tableau_eq_reports_equal_phase_and_iphase_as_equal(): + a = CliffordTableau(2) + a.phase = np.array([1, 0, 1, 0]) + a.iphase = np.array([0, 1, 0, 1]) + + b = CliffordTableau(2) + b.phase = np.array([1, 0, 1, 0]) + b.iphase = np.array([0, 1, 0, 1]) + + assert a == b + + +def test_clifford_tableau_eq_reports_unequal_iphase_as_unequal(): + a = CliffordTableau(2) + a.iphase = np.array([1, 0, 0, 0]) + + b = CliffordTableau(2) + + assert a != b + + +def test_clifford_tableau_eq_does_not_broadcast_a_column_iphase_into_a_match(): + # Same in-place reshape as above, on the i-phase vector. + a = CliffordTableau(2) + a.iphase = np.ones(4) + + b = CliffordTableau(2) + b.iphase = np.ones(4) + b.iphase.shape = (4, 1) + + assert a != b + + +def test_stabilizer_tableau_eq_of_different_sizes_is_false_without_a_warning(): + # Comparing phases of different lengths with == is an elementwise comparison + # numpy cannot do; it warns and falls back to a scalar. The shape-strict + # comparison just reports the tableaux as unequal. + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert StabilizerTableau(2) != StabilizerTableau(3) + + +def test_clifford_tableau_eq_of_different_sizes_is_false_without_a_warning(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert CliffordTableau(2) != CliffordTableau(3) From f1b9df33000ed13638a937104c2f5a7d3db5a325 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 12:10:54 -0400 Subject: [PATCH 110/227] [fix] Stop AlternateTargetSolver.solve crashing when label_map is set With solver_setting.label_map = True, solve() passed label_map through to iso_finder, which then returns (adjacency matrices, label maps) instead of the matrices alone - but only when it has to search past its first batch of labellings; its early returns give a bare array. solve() treated the result as a list of matrices, so nx.from_numpy_array got the 3-D matrix stack and raised NetworkXError: Input array must be 2D, not 3 (e.g. linear4 with n_iso_graphs = 12). Take the matrices from the tuple when iso_finder returns one. The label maps are not used: solve() computes each graph's relabel map itself with get_relabel_map. label_map still defaults to False, and iso_finder is unchanged, so default results are unchanged. --- graphiq/solvers/alternate_target_solver.py | 5 ++ .../test_alternate_target_solver.py | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 835fcdd7..b49e67b2 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -178,6 +178,11 @@ def solve(self): thresh=setting.iso_thresh, seed=self.seed, ) + # with label_map set, iso_finder can return (adjacency matrices, label maps) + # instead of the matrices alone; the maps are not needed here, since each + # graph's relabel map is computed below with get_relabel_map + if isinstance(iso_adjs, tuple): + iso_adjs = iso_adjs[0] else: # only the labelling given iso_adjs = [adj_matrix] diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index a475c61a..73bb8033 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -103,6 +103,59 @@ def test_solver_density_noise(setting=my_setting(), target_graph=linear_cluster) solver.solve() +def _solve_with_label_map(label_map, n_iso_graphs): + setting = my_setting() + setting.n_iso_graphs = n_iso_graphs + setting.label_map = label_map + solver = AlternateTargetSolver( + target=linear_cluster, + solver_setting=setting, + noise_model_mapping="depolarizing", + ) + solver.metric = Infidelity + solver.compiler = StabilizerCompiler() + solver.noise_compiler = DensityMatrixCompiler() + solver.io = None + solver.noise_simulation = False + solver.seed = 1 + return solver.solve() + + +@pytest.mark.parametrize( + "n_iso_graphs, iso_finder_returns_tuple", [(3, False), (12, True)] +) +def test_label_map_setting_gives_the_same_result_as_without_it( + n_iso_graphs, iso_finder_returns_tuple +): + # With label_map set, iso_finder returns (matrices, label maps) only when it has to + # search past its first batch of labellings, and a bare array otherwise. Pin which + # case each parameter reaches, so both shapes stay covered. + setting = my_setting() + found = iso_finder( + nx.to_numpy_array(linear_cluster), + n_iso_graphs, + rel_inc_thresh=setting.rel_inc_thresh, + allow_exhaustive=setting.allow_exhaustive, + sort_emit=setting.sort_emitter, + label_map=True, + thresh=setting.iso_thresh, + seed=1, + ) + assert isinstance(found, tuple) == iso_finder_returns_tuple + + with_map = _solve_with_label_map(True, n_iso_graphs) + without_map = _solve_with_label_map(False, n_iso_graphs) + + assert len(with_map) == len(without_map) == n_iso_graphs + for (circuit_a, props_a), (circuit_b, props_b) in zip(with_map, without_map): + assert nx.utils.graphs_equal(props_a["g"], props_b["g"]) + assert props_a["map"] == props_b["map"] + assert props_a["score"] == props_b["score"] + assert [type(op) for op in circuit_a.sequence()] == [ + type(op) for op in circuit_b.sequence() + ] + + def test_setting_callback_func_is_per_instance(): a = AlternateTargetSolverSetting() b = AlternateTargetSolverSetting() From 9956e6a97fb09559c3aa38348fe92fe7ac25193d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 13:51:41 -0400 Subject: [PATCH 111/227] [fix] Record the representation type when QuantumState detects it from the data When rep_type is omitted, QuantumState.__init__ picks a density matrix, stabilizer or graph representation from the data but left _rep_type as None. Everything that dispatches on the name then failed on such a state: show() raised ValueError, every convert_representation raised KeyError (None, ...), Infidelity and TraceDistance raised ValueError, and AlternateTargetSolver, TimeReversedSolver and HybridEvolutionarySolver failed on the target. Set _rep_type to "dm", "s" or "g" alongside the representation that is built, so an autodetected state behaves like one built with the matching explicit rep_type. States built with an explicit rep_type are unchanged. The partial_trace guard keeps asking the representation object rather than the name, since the rep_type setter renames without converting; its comment now says so. --- graphiq/state.py | 13 ++++--- .../test_quantum_state.py | 36 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/graphiq/state.py b/graphiq/state.py index 46c733d0..8b8777e2 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -97,15 +97,21 @@ def __init__(self, data, rep_type=None, mixed=False): f"Data's type is incorrect. Input data is {data}, which is of type {type(data)}" ) if rep_type is None: + # record the detected representation's name: show, + # convert_representation and the metrics and solvers read rep_type + # to dispatch if ( self.n_qubits < DENSITY_MATRIX_QUBIT_THRESH and DensityMatrix.valid_datatype(data) ): self._rep_data = self._initialize_dm(data) + self._rep_type = "dm" elif Stabilizer.valid_datatype(data): self._rep_data = self._initialize_stabilizer(data) + self._rep_type = "s" elif Graph.valid_datatype(data): self._rep_data = self._initialize_graph(data) + self._rep_type = "g" elif DensityMatrix.valid_datatype(data): raise ValueError( "Data's type is correct for density matrix representation, but state size exceeds the " @@ -170,10 +176,9 @@ def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): :raises NotImplementedError: if the representation object has no partial_trace """ - # Asked of the representation object, not of _rep_type: __init__ can - # autodetect a representation without writing the name back, leaving - # _rep_type None, so a check on the name misses a graph state built - # without an explicit rep_type. + # Asked of the representation object, not of _rep_type: the rep_type + # setter renames without converting, so the name need not match the + # object held. if not hasattr(self._rep_data, "partial_trace"): raise NotImplementedError( f"Partial trace not yet implemented on the " diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index e24bd0c4..db879099 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -25,7 +25,7 @@ cnot_gate, hadamard_gate, ) -from graphiq.state import QuantumState +from graphiq.state import DENSITY_MATRIX_QUBIT_THRESH, QuantumState @pytest.mark.parametrize("n", [1, 2, 3, 4, 5]) @@ -157,9 +157,39 @@ def test_density_matrix_partial_trace_accepts_measurement_determinism(): @pytest.mark.parametrize("rep_type", ["g", None]) def test_partial_trace_on_a_graph_state_says_it_is_not_implemented(rep_type): - # rep_type=None autodetects the graph representation without recording its - # name, which used to slip past the refusal into an AttributeError. + # rep_type=None autodetects the graph representation; it is refused like an + # explicit one rather than failing with an AttributeError. state = QuantumState(nx.path_graph(4), rep_type=rep_type) with pytest.raises(NotImplementedError, match="Graph representation"): state.partial_trace(keep=[0], dims=4 * [2]) + + +@pytest.mark.parametrize( + "data, expected_rep_type", + [ + (np.eye(4) / 4, "dm"), + (2, "dm"), + (create_n_ket0_state(3), "s"), + (DENSITY_MATRIX_QUBIT_THRESH, "s"), + (nx.path_graph(3), "g"), + ], +) +def test_autodetected_representation_records_its_rep_type(data, expected_rep_type): + # Without an explicit rep_type, __init__ picks the representation from the data; + # the name must be recorded too, since everything downstream dispatches on it. + state = QuantumState(data) + + assert state.rep_type == expected_rep_type + + +def test_autodetected_state_converts_like_an_explicit_one(): + graph = nx.path_graph(3) + autodetected = QuantumState(graph) + explicit = QuantumState(graph, rep_type="g") + + autodetected.convert_representation("dm") + explicit.convert_representation("dm") + + assert autodetected.rep_type == "dm" + assert np.allclose(autodetected.rep_data.data, explicit.rep_data.data) From 283e7af3f3b97c48d61d44c36580e8e069795e67 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 14:41:49 -0400 Subject: [PATCH 112/227] [fix] Return the figure and axes from Graph.draw so QuantumState.show works on a pure graph state QuantumState.show unpacks the (fig, ax) returned by the representation's draw method. For a pure graph state that is Graph.draw, which called draw_graph but discarded the figure and axes it returns, so show() raised "TypeError: cannot unpack non-iterable NoneType object". Return draw_graph's (fig, ax), as DensityMatrix.draw already does, and document it. Graph.draw() now returns a tuple instead of None, so a notebook cell ending in a bare .draw() on a Graph also displays the tuple's repr. --- graphiq/backends/graph/state.py | 6 +++--- .../test_quantum_state.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/graphiq/backends/graph/state.py b/graphiq/backends/graph/state.py index 1dea133e..681a0efd 100644 --- a/graphiq/backends/graph/state.py +++ b/graphiq/backends/graph/state.py @@ -245,10 +245,10 @@ def draw(self, show=True, ax=None, with_labels=True): :type ax: matplotlib.Axis :param with_labels: if True, node labels are drawn :type with_labels: bool - :return: nothing - :rtype: None + :return: fig, axes on which the state is drawn + :rtype: matplotlib.Figure, matplotlib.Axes """ - draw_graph(self, show=show, ax=ax, with_labels=with_labels) + return draw_graph(self, show=show, ax=ax, with_labels=with_labels) def local_complementation(self, node_id, copy=False): """ diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index db879099..8ddf2632 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import matplotlib.axes +import matplotlib.figure +import matplotlib.pyplot as plt import networkx as nx import numpy as np import pytest @@ -193,3 +196,19 @@ def test_autodetected_state_converts_like_an_explicit_one(): assert autodetected.rep_type == "dm" assert np.allclose(autodetected.rep_data.data, explicit.rep_data.data) + + +def test_show_on_a_graph_state_returns_the_figure_and_axes(): + # show() unpacks the (fig, ax) that Graph.draw returns. No ax is passed: + # draw_graph returns the current axes rather than a caller-supplied one. + state = QuantumState(nx.path_graph(3), rep_type="g") + fresh = plt.figure() + try: + fig, ax = state.show(show=False) + + assert isinstance(fig, matplotlib.figure.Figure) + assert isinstance(ax, matplotlib.axes.Axes) + assert fig is fresh + assert ax.collections # the nodes and edges nx.draw added + finally: + plt.close(fresh) From 4ea9577170c3a409681b0a622b13a12a23152c65 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 15:19:01 -0400 Subject: [PATCH 113/227] [fix] Return the axes draw_graph was given, and its figure, instead of the current ones draw_graph drew on the axes it was given but returned plt.gcf() and plt.gca(), pyplot's current figure and axes, so QuantumState.show(ax=...) on a graph state returned whichever figure and axes were created or selected last. With an axes given it now returns that axes and its root figure (the root rather than a SubFigure, matching the documented matplotlib.Figure); with none given the result is unchanged. --- graphiq/visualizers/graph.py | 7 ++-- .../test_quantum_state.py | 36 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/graphiq/visualizers/graph.py b/graphiq/visualizers/graph.py index 0c01f4c7..eba74ec4 100644 --- a/graphiq/visualizers/graph.py +++ b/graphiq/visualizers/graph.py @@ -44,6 +44,7 @@ def draw_graph(state_graph, show=False, ax=None, with_labels=True): ) if show: plt.show() - fig = plt.gcf() - ax = plt.gca() - return fig, ax + if ax is None: + return plt.gcf(), plt.gca() + # ax.figure is a SubFigure when ax sits in one; its .figure is the root Figure. + return ax.figure.figure, ax diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 8ddf2632..e34dc91d 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -199,8 +199,8 @@ def test_autodetected_state_converts_like_an_explicit_one(): def test_show_on_a_graph_state_returns_the_figure_and_axes(): - # show() unpacks the (fig, ax) that Graph.draw returns. No ax is passed: - # draw_graph returns the current axes rather than a caller-supplied one. + # show() unpacks the (fig, ax) that Graph.draw returns. No ax is passed, so + # this covers the ax=None path: the current figure and its current axes. state = QuantumState(nx.path_graph(3), rep_type="g") fresh = plt.figure() try: @@ -212,3 +212,35 @@ def test_show_on_a_graph_state_returns_the_figure_and_axes(): assert ax.collections # the nodes and edges nx.draw added finally: plt.close(fresh) + + +def test_show_on_a_graph_state_returns_the_axes_it_was_given(): + state = QuantumState(nx.path_graph(3), rep_type="g") + f1, (a1, a2) = plt.subplots(1, 2) + f2 = plt.figure() # the current figure and axes are now neither f1 nor a1 + try: + fig, ax = state.show(show=False, ax=a1) + + assert fig is f1 + assert ax is a1 + assert a1.collections + assert not a2.collections + finally: + plt.close(f1) + plt.close(f2) + + +def test_show_on_a_graph_state_in_a_subfigure_returns_the_root_figure(): + state = QuantumState(nx.path_graph(3), rep_type="g") + root = plt.figure() + sub_ax = root.subfigures(1, 2)[0].subplots() + other = plt.figure() + try: + fig, ax = state.show(show=False, ax=sub_ax) + + assert isinstance(fig, matplotlib.figure.Figure) + assert fig is root + assert ax is sub_ax + finally: + plt.close(root) + plt.close(other) From 8df0e0dee44535280e04538840b1c64b6a12a3a7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 15:46:04 -0400 Subject: [PATCH 114/227] [fix] Raise NotImplementedError from QuantumState.show on a mixed graph state show() passed every "g" state to its representation's draw(). A mixed graph state, which convert_representation("g") produces from a mixed stabilizer, holds a MixedGraph, which has no draw(), so show() raised AttributeError. Raise NotImplementedError instead, as show() already does for the stabilizer representation. --- graphiq/state.py | 4 ++++ .../test_quantum_state.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/graphiq/state.py b/graphiq/state.py index 8b8777e2..9a349b43 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -203,6 +203,10 @@ def show(self, show=True, ax=None): if self._rep_type == "dm": fig, ax = self._rep_data.draw(show=show, style="heat") elif self._rep_type == "g": + if isinstance(self._rep_data, MixedGraph): + raise NotImplementedError( + "No visualization tool is implemented for a mixed graph state." + ) fig, ax = self._rep_data.draw(show=show, ax=ax, with_labels=True) elif self._rep_type == "s": raise NotImplementedError( diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index e34dc91d..8a9814a4 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -244,3 +244,20 @@ def test_show_on_a_graph_state_in_a_subfigure_returns_the_root_figure(): finally: plt.close(root) plt.close(other) + + +def test_show_on_a_mixed_graph_state_raises_not_implemented(): + t1 = QuantumState(nx.path_graph(3), rep_type="g") + t1.convert_representation("s") + t2 = QuantumState(nx.star_graph(2), rep_type="g") + t2.convert_representation("s") + state = QuantumState( + [(0.5, t1.rep_data.tableau), (0.5, t2.rep_data.tableau)], + rep_type="s", + mixed=True, + ) + state.convert_representation("g") + assert isinstance(state.rep_data, MixedGraph) + + with pytest.raises(NotImplementedError, match="mixed graph state"): + state.show(show=False) From 9e8eb200bc7c05da73d4c3d40e082c46dfa2860c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 16:26:20 -0400 Subject: [PATCH 115/227] [docs] Say that assigning QuantumState.rep_type renames without converting --- graphiq/state.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/graphiq/state.py b/graphiq/state.py index 9a349b43..ab7df951 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -147,6 +147,11 @@ def rep_type(self): """ Representation type + Assigning to this only renames the representation; it does not convert the data. + Use `convert_representation` to change the representation, or assign `rep_data` + right after renaming, with input valid for the new type and the same number of + qubits. + :return: representation type :rtype: str """ From 3bf3cacf0086425007cdca8dc0ecf8f636583c8f Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 20:42:23 -0400 Subject: [PATCH 116/227] [fix] Apply sort_emit and label_map on every iso_finder return path iso_finder returned early in two places, when its first batch of labellings already held n_iso distinct graphs, and at the high-symmetry stop with allow_exhaustive=False. Both returns skipped sort_emit and label_map. On the remaining path, the label maps were built before truncating to n_iso, so there could be more maps than matrices. All three returns now go through one step that truncates, sorts, and then builds the maps. This changes seeded AlternateTargetSolver results. With sort_emit=True (the default) and n_iso_graphs > 1, a run whose isomorph search returns early can list its candidates in a different order. It can also keep a different circuit and relabel map for a repeated graph, and put a different candidate first. The set of candidate graphs found for a given seed is unchanged. Runs with n_iso_graphs=1 are unchanged. --- graphiq/solvers/alternate_target_solver.py | 6 +- graphiq/utils/relabel_module.py | 47 ++++++++--- .../test_alternate_target_solver.py | 23 +++--- tests/test_utils/test_relabel_module.py | 78 +++++++++++++++++++ 4 files changed, 128 insertions(+), 26 deletions(-) diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index b49e67b2..056fe0f3 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -178,9 +178,9 @@ def solve(self): thresh=setting.iso_thresh, seed=self.seed, ) - # with label_map set, iso_finder can return (adjacency matrices, label maps) - # instead of the matrices alone; the maps are not needed here, since each - # graph's relabel map is computed below with get_relabel_map + # with label_map set, iso_finder returns (adjacency matrices, label maps); the + # maps are not needed here, since each graph's relabel map is computed below + # with get_relabel_map if isinstance(iso_adjs, tuple): iso_adjs = iso_adjs[0] else: diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 018e08d6..9020925e 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -41,7 +41,8 @@ def iso_finder( ): """ The function permutes the labels of the vertices of a graph to get n_iso distinct isomorphic graphs. The original - graph will also be returned as the first element of the list and counted toward the number of found cases. + graph will also be returned as the first element of the list (unless sort_emit reorders the list) and counted + toward the number of found cases. The maximum number of possible distinct cases may be less than n_iso. The graph G with the nodes relabeled using consecutive integers. @@ -76,8 +77,7 @@ def iso_finder( labels_arr = _label_finder(n_label, n_node, seed=seed, thresh=thresh) adj_arr = automorph_check(adj_matrix, labels_arr) if len(adj_arr) >= n_iso: - adj_arr = adj_arr[:n_iso] - return adj_arr + return _finish_iso_output(adj_matrix, adj_arr, n_iso, sort_emit, label_map) else: rel_inc = 1 # relative increase ratio of the number of non-redundant cases as the number of labeling increase all_checked = False @@ -93,7 +93,9 @@ def iso_finder( f"Only {len(adj_arr)} isomorphic graphs were found due to high symmetry." f"This may not be the maximum value. Allow for exhaustive search to check" ) - return adj_arr + return _finish_iso_output( + adj_matrix, adj_arr, n_iso, sort_emit, label_map + ) # update the seed used in _add_labels to get new values when we repeat it in the loop seed = seed + 1 if (seed is not None) else None @@ -120,14 +122,35 @@ def iso_finder( ) else: pass - if sort_emit: - adj_arr = np.array([x[0] for x in emitter_sorted(adj_arr[:n_iso])]) - if label_map: - mapping = [] - for new_adj in adj_arr: - mapping.append(get_relabel_map(adj_matrix, new_adj)) - return adj_arr[:n_iso], mapping - return adj_arr[:n_iso] + return _finish_iso_output(adj_matrix, adj_arr, n_iso, sort_emit, label_map) + + +def _finish_iso_output(adj_matrix, adj_arr, n_iso, sort_emit, label_map): + """ + Truncates the adjacency matrices found by to n_iso, then applies its sort_emit and label_map + options, so that every return path of treats them the same way. + + :param adj_matrix: the initial adjacency matrix given to + :type adj_matrix: numpy.ndarray + :param adj_arr: the distinct relabeled adjacency matrices found + :type adj_arr: numpy.ndarray + :param n_iso: number of the isomorphic graphs to return at most + :type n_iso: int + :param sort_emit: if True the outcome is sorted by the number of emitters needed to generate each graph + :type sort_emit: bool + :param label_map: if True, a list of label maps between the initial graph and each returned one is returned too + :type label_map: bool + :return: the adjacency matrices, or a tuple of the adjacency matrices and the list of label maps if label_map + is True + :rtype: numpy.ndarray or tuple (numpy.ndarray, list[dict]) + """ + adj_arr = adj_arr[:n_iso] + if sort_emit: + adj_arr = np.array([x[0] for x in emitter_sorted(adj_arr)]) + if label_map: + mapping = [get_relabel_map(adj_matrix, new_adj) for new_adj in adj_arr] + return adj_arr, mapping + return adj_arr def emitter_sorted(adj_arr): diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index 73bb8033..56de7323 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -16,6 +16,7 @@ from graphiq.benchmarks.graph_states import linear_cluster_state from graphiq.solvers.alternate_target_solver import * +from graphiq.utils.relabel_module import _label_finder, automorph_check linear_cluster = nx.from_numpy_array(nx.to_numpy_array(linear_cluster_state(4).data)) @@ -121,18 +122,17 @@ def _solve_with_label_map(label_map, n_iso_graphs): return solver.solve() -@pytest.mark.parametrize( - "n_iso_graphs, iso_finder_returns_tuple", [(3, False), (12, True)] -) -def test_label_map_setting_gives_the_same_result_as_without_it( - n_iso_graphs, iso_finder_returns_tuple -): - # With label_map set, iso_finder returns (matrices, label maps) only when it has to - # search past its first batch of labellings, and a bare array otherwise. Pin which - # case each parameter reaches, so both shapes stay covered. +@pytest.mark.parametrize("n_iso_graphs", [3, 12]) +def test_label_map_setting_gives_the_same_result_as_without_it(n_iso_graphs): + # With label_map set, iso_finder returns (matrices, label maps). With seed 1, 3 + # graphs are found in its first batch of labellings and 12 need a longer search, + # so both of its return paths are covered. setting = my_setting() + adj = nx.to_numpy_array(linear_cluster) + first_batch = automorph_check(adj, _label_finder(n_iso_graphs, len(adj), seed=1)) + assert (len(first_batch) >= n_iso_graphs) == (n_iso_graphs == 3) found = iso_finder( - nx.to_numpy_array(linear_cluster), + adj, n_iso_graphs, rel_inc_thresh=setting.rel_inc_thresh, allow_exhaustive=setting.allow_exhaustive, @@ -141,7 +141,8 @@ def test_label_map_setting_gives_the_same_result_as_without_it( thresh=setting.iso_thresh, seed=1, ) - assert isinstance(found, tuple) == iso_finder_returns_tuple + assert isinstance(found, tuple) + assert len(found[1]) == len(found[0]) with_map = _solve_with_label_map(True, n_iso_graphs) without_map = _solve_with_label_map(False, n_iso_graphs) diff --git a/tests/test_utils/test_relabel_module.py b/tests/test_utils/test_relabel_module.py index 8087c14e..b654bc1f 100644 --- a/tests/test_utils/test_relabel_module.py +++ b/tests/test_utils/test_relabel_module.py @@ -18,7 +18,10 @@ import numpy as np import pytest +from graphiq.backends.stabilizer.functions.height import height_max +from graphiq.benchmarks.graph_states import linear_cluster_state from graphiq.utils.relabel_module import * +from graphiq.utils.relabel_module import _label_finder def random_connected_graph(n, p, seed=None): @@ -77,3 +80,78 @@ def test_emitter_sort(): for i in range(len(n_emit_list) - 1) ) assert isinstance(sorted_list[0][0], np.ndarray) + + +def _linear_cluster_adj(n): + return nx.to_numpy_array(linear_cluster_state(n).data) + + +def _returns_on_first_batch(adj, n_iso, seed): + # iso_finder returns straight after its first batch of labellings when that + # batch already holds n_iso distinct graphs + labels = _label_finder(n_iso, adj.shape[0], seed=seed) + return len(automorph_check(adj, labels)) >= n_iso + + +def _emitter_counts(adj_arr): + return [height_max(graph=nx.from_numpy_array(adj)) for adj in adj_arr] + + +def _assert_maps_pair_with_matrices(adj, found): + assert isinstance(found, tuple) + adj_arr, maps = found + assert len(maps) == len(adj_arr) + g = nx.from_numpy_array(adj) + for new_adj, mapping in zip(adj_arr, maps): + new_g = nx.from_numpy_array(new_adj) + mapped_edges = {frozenset((mapping[u], mapping[v])) for u, v in g.edges} + assert mapped_edges == {frozenset(e) for e in new_g.edges} + + +def test_sort_emit_and_label_map_apply_when_the_first_batch_is_enough(): + adj = _linear_cluster_adj(6) + kwargs = dict(rel_inc_thresh=0.1, allow_exhaustive=False, sort_emit=True, seed=1) + assert _returns_on_first_batch(adj, 10, seed=1) + + adj_arr = iso_finder(adj, 10, **kwargs) + n_emit = _emitter_counts(adj_arr) + assert n_emit == sorted(n_emit) + assert n_emit != _emitter_counts( + iso_finder(adj, 10, **{**kwargs, "sort_emit": False}) + ) + + # the maps must pair with the sorted matrices, not the order they were found in + _assert_maps_pair_with_matrices(adj, iso_finder(adj, 10, label_map=True, **kwargs)) + + +def test_label_map_applies_when_the_first_batch_is_enough(): + adj = _linear_cluster_adj(4) + assert _returns_on_first_batch(adj, 3, seed=1) + + found = iso_finder(adj, 3, label_map=True, seed=1) + + _assert_maps_pair_with_matrices(adj, found) + assert len(found[0]) == 3 + + +def test_label_map_has_one_map_per_returned_matrix_after_a_longer_search(): + adj = _linear_cluster_adj(4) + assert not _returns_on_first_batch(adj, 5, seed=5) + + found = iso_finder(adj, 5, sort_emit=False, label_map=True, seed=5) + + _assert_maps_pair_with_matrices(adj, found) + assert len(found[0]) == 5 + + +def test_sort_emit_and_label_map_apply_at_the_high_symmetry_stop(): + adj = nx.to_numpy_array(nx.cycle_graph(4)) + with pytest.warns(UserWarning, match="high symmetry"): + found = iso_finder( + adj, 5, allow_exhaustive=False, sort_emit=True, label_map=True, seed=1 + ) + + _assert_maps_pair_with_matrices(adj, found) + n_emit = _emitter_counts(found[0]) + assert n_emit == sorted(n_emit) + assert len(set(n_emit)) > 1 # mixed counts, so the sort is actually exercised From bd0ac5b039bc888c9f857bd9f66c763a0d94403f Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 23:03:51 -0400 Subject: [PATCH 117/227] [fix] Keep name_to_class_map returning None for names outside its mapping To parse one-qubit wrappers whose gate names are several characters long (e.g. "sdg"), name_to_class_map had been changed to decode any name outside its mapping as a wrapper. It then returned a list for a composite name and raised ValueError for an unknown one. Before, it returned None for both. Its other callers (from_json, the graph LC gate compiler, the classical-gate lookups and the two-qubit openqasm branch) expect a class or None. name_to_class_map returns None again for any name outside its mapping. The wrapper decoding moves into from_openqasm's one-qubit branch. It calls the decoder only when name_to_class_map returns None, and adds the bare gate when the name decodes to a single gate, so "sdg" still round-trips. The decoder returns None instead of raising, and an undecodable name fails the parser's assertion again, as before. The decoder's token table gains "p" for Phase, which the mapping already accepts. So a wrapper written as "hp" parses as Hadamard then Phase again. No default result changes. --- graphiq/circuit/circuit_dag.py | 19 ++++++++++++------- graphiq/circuit/ops.py | 20 ++++++++------------ tests/test_circuits/test_openqasm.py | 18 +++++++++++++++++- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index e61835f1..3c317451 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -672,18 +672,23 @@ def _parse_if(command): reg_type = command_breakdown[1][0] reg = int(command_breakdown[1][1:-3]) # we must parse out [0] so -3 gate_class = ops.name_to_class_map(name) - if isinstance(gate_class, list): - if len(gate_class) == 1: - # single (possibly multi-character) gate, e.g. "sdg" - circuit.add(gate_class[0](register=reg, reg_type=reg_type)) + if gate_class is not None: + circuit.add(gate_class(register=reg, reg_type=reg_type)) + else: + # a OneQubitGateWrapper writes its gates' names with no separator, and + # some names are several characters long (e.g. "sdg") + circuit_list = ops._decode_wrapped_gate_name(name) + assert ( + circuit_list is not None + ), f"Gate not recognized, parsing invalid/{name}" + if len(circuit_list) == 1: + circuit.add(circuit_list[0](register=reg, reg_type=reg_type)) else: circuit.add( ops.OneQubitGateWrapper( - gate_class, register=reg, reg_type=reg_type + circuit_list, register=reg, reg_type=reg_type ) ) - else: - circuit.add(gate_class(register=reg, reg_type=reg_type)) elif command.count("[0]") == 2: # two-qubit gate command_breakdown = command.split() name = command_breakdown[0] diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 692d508a..2a7017cc 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -1161,8 +1161,9 @@ def simplify_local_clifford(gate_list): # Openqasm gate names emitted for single-qubit gates by single_qubit_wrapper_info -# (OneQubitGateWrapper serialization concatenates these with no separator). Kept -# as an explicit companion so the name_to_class_map dictionary stays untouched. +# (OneQubitGateWrapper serialization concatenates these with no separator), plus +# "p", which name_to_class_map also accepts for Phase. Kept as an explicit +# companion so the name_to_class_map dictionary stays untouched. # Multi-character tokens (e.g. "sdg") are decoded by greedy longest-match. _ONE_QUBIT_WRAPPER_TOKENS = { "h": Hadamard, @@ -1170,6 +1171,7 @@ def simplify_local_clifford(gate_list): "y": SigmaY, "z": SigmaZ, "s": Phase, + "p": Phase, "sdg": PhaseDagger, } @@ -1183,9 +1185,8 @@ def _decode_wrapped_gate_name(name): :param name: concatenated openqasm gate name :type name: str - :raises ValueError: if the name cannot be fully tokenised into valid gates - :return: list of operation classes - :rtype: list + :return: list of operation classes, or None if the name cannot be fully tokenised + :rtype: list or None """ max_token_len = max(len(token) for token in _ONE_QUBIT_WRAPPER_TOKENS) op_list = [] @@ -1198,7 +1199,7 @@ def _decode_wrapped_gate_name(name): i += size break else: - raise ValueError(f"{name} not existing in the mapping dictionary") + return None return op_list @@ -1230,12 +1231,7 @@ def name_to_class_map(name): } if name in mapping: return mapping[name] - # Not a single known gate name: interpret it as a composite of one-qubit - # gates (e.g. "hsy" or "hsdgy"), as produced by OneQubitGateWrapper - # serialization which concatenates the gates' openqasm names with no - # separator. Decode via greedy longest-match so multi-character names - # (e.g. "sdg") are handled. Raises ValueError if not a valid composite. - return _decode_wrapped_gate_name(name) + return None def class_to_name_mapping(class_op): diff --git a/tests/test_circuits/test_openqasm.py b/tests/test_circuits/test_openqasm.py index eb46daeb..71792480 100644 --- a/tests/test_circuits/test_openqasm.py +++ b/tests/test_circuits/test_openqasm.py @@ -226,10 +226,26 @@ def test_load_circuit_3(initialization_circuit): """ str = initialization_circuit.to_openqasm() str += "hshk e0[0];" - with pytest.raises(ValueError): + with pytest.raises(AssertionError): circuit = CircuitDAG.from_openqasm(str) +@pytest.mark.parametrize("name", ["hs", "hsdgy", "hk"]) +def test_name_to_class_map_returns_none_outside_its_mapping(name): + # composite wrapper names are decoded by from_openqasm, not by name_to_class_map + assert ops.name_to_class_map(name) is None + + +def test_load_circuit_wrapped_gate_written_with_p_for_phase(): + circuit = CircuitDAG.from_openqasm( + CircuitDAG(n_emitter=1, n_photon=0, n_classical=0).to_openqasm() + "hp e0[0];" + ) + (wrapper,) = [ + op for op in circuit.sequence() if isinstance(op, ops.OneQubitGateWrapper) + ] + assert wrapper.operations == [ops.Hadamard, ops.Phase] + + def test_load_circuit_multichar_wrapped_gate(): """ Regression test for round-tripping one-qubit gates whose openqasm name is From f5399472a96971a7c524efdddb107ed9a185b784 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 23:04:04 -0400 Subject: [PATCH 118/227] [fix] Drop the measurement_determinism parameter added to the partial_trace methods QuantumState.partial_trace, DensityMatrix.partial_trace, Stabilizer.partial_trace and MixedStabilizer.partial_trace had gained a public measurement_determinism parameter, and the two benchmark helpers passed it. Neither helper needed it: - search_for_alternative_circuits uses the traced state only for its probability, which a stabilizer trace leaves unchanged whatever the outcome. - circuit_measurement_independent compares density matrices, where the argument was ignored. A caller that needs a forced outcome already has Stabilizer.trace_out_qubits and MixedStabilizer.trace_out_qubits, and the stabilizer-level partial_trace function. So the four methods and the two helpers go back to their earlier signatures and calls. The measurement-sign fix in remove_qubit stays, with its tests. So do the later guards, comments and docstring fields in these methods. assert_robust_to_measurement_outcome and its self-test in the evolutionary solver tests now force the emitter trace through trace_out_qubits on a stabilizer state. On the certified winners this gives the same infidelities as before, and the self-test's winner is still wrong in branch 0. The tests that only exercised the removed parameter are removed. No default result changes. --- graphiq/backends/density_matrix/state.py | 6 +--- graphiq/backends/stabilizer/state.py | 20 ++----------- graphiq/benchmarks/alternate_circuits.py | 6 +--- graphiq/benchmarks/benchmark_utils.py | 4 --- graphiq/state.py | 10 ++----- .../test_evolutionary_random_solvers.py | 17 +++++++---- .../test_remove_qubit_sign_exactness.py | 13 --------- .../test_quantum_state.py | 28 ------------------- 8 files changed, 18 insertions(+), 86 deletions(-) diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index e67962bb..7bf5253f 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -227,7 +227,7 @@ def apply_measurement_controlled_gate( self.apply_unitary(target_gate) return outcome - def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): + def partial_trace(self, keep, dims): """ Take the partial trace of the state @@ -238,10 +238,6 @@ def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): if the space is :math:`A \\times B \\times C \\times D`, dims = [dim_A, dim_B, dim_C, dim_D] :type dims: list OR numpy.ndarray - :param measurement_determinism: accepted and ignored. This is a partial trace over a subsystem, not a - measure-and-delete, so there is no outcome to force. It is in the signature so that a caller working - across representations does not need a branch of its own. - :type measurement_determinism: str or int :return: nothing :rtype: None """ diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index 11a59069..f42c59f8 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -310,7 +310,7 @@ def trace_out_qubits( measurement_determinism=measurement_determinism, ) - def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): + def partial_trace(self, keep, dims): """ Trace out qubits after disentangling them from the rest @@ -318,19 +318,10 @@ def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): :type keep: list[int] or numpy.ndarray :param dims: dimension of each subsystem :type dims: list[int] or numpy.ndarray - :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected - if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 - if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 - :type measurement_determinism: str/int :return: nothing :rtype: None """ - self._tableau = sfc.partial_trace( - self._tableau, - keep=keep, - dims=dims, - measurement_determinism=measurement_determinism, - ) + self._tableau = sfc.partial_trace(self._tableau, keep=keep, dims=dims) def __str__(self): """ @@ -769,7 +760,7 @@ def trace_out_qubits( for (p_i, t_i) in self._mixture ] - def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): + def partial_trace(self, keep, dims): """ Trace out qubits after disentangling them from the rest @@ -777,10 +768,6 @@ def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): :type keep: list[int] or numpy.ndarray :param dims: dimension of each subsystem :type dims: list[int] or numpy.ndarray - :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected - if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 - if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 - :type measurement_determinism: str/int :return: nothing :rtype: None """ @@ -791,7 +778,6 @@ def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): t_i, keep=keep, dims=dims, - measurement_determinism=measurement_determinism, ), ) for (p_i, t_i) in self._mixture diff --git a/graphiq/benchmarks/alternate_circuits.py b/graphiq/benchmarks/alternate_circuits.py index 5002b2c2..582c37e0 100644 --- a/graphiq/benchmarks/alternate_circuits.py +++ b/graphiq/benchmarks/alternate_circuits.py @@ -70,13 +70,10 @@ def search_for_alternative_circuits( benchmark_score, benchmark_circuit = det_solver.result compiled_state = compiler.compile(benchmark_circuit) - # trace out emitter qubits, resolving the traced-out measurements the same - # way the compilation did -- this function sets the compiler to a forced - # outcome above, and a probabilistic trace would discard that choice. + # trace out emitter qubits compiled_state.partial_trace( keep=list(range(n_photon)), dims=(n_photon + n_emitter) * [2], - measurement_determinism=compiler.measurement_determinism, ) if isinstance(compiled_state.rep_data, MixedStabilizer): prob = compiled_state.rep_data.probability @@ -109,7 +106,6 @@ def search_for_alternative_circuits( compiled_state.partial_trace( keep=list(range(n_photon)), dims=(n_photon + n_emitter) * [2], - measurement_determinism=compiler.measurement_determinism, ) if isinstance(compiled_state.rep_data, MixedStabilizer): prob = compiled_state.rep_data.probability diff --git a/graphiq/benchmarks/benchmark_utils.py b/graphiq/benchmarks/benchmark_utils.py index 4fb410d1..6347f0ae 100644 --- a/graphiq/benchmarks/benchmark_utils.py +++ b/graphiq/benchmarks/benchmark_utils.py @@ -41,17 +41,13 @@ def circuit_measurement_independent(circuit, compiler): state2 = compiler.compile(circuit) compiler.measurement_determinism = original_measurement_determinism - # Each trace resolves its measurements the same way the compilation that - # produced it did, so the comparison below is not reading measurement noise. state1.partial_trace( keep=list(range(0, circuit.n_quantum - 1)), dims=circuit.n_quantum * [2], - measurement_determinism=0, ) state2.partial_trace( keep=list(range(0, circuit.n_quantum - 1)), dims=circuit.n_quantum * [2], - measurement_determinism=1, ) return np.allclose(state1.rep_data.data, state2.rep_data.data), state1, state2 diff --git a/graphiq/state.py b/graphiq/state.py index ab7df951..97e3697b 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -161,7 +161,7 @@ def rep_type(self): def rep_type(self, new_rep_type): self._rep_type = self._get_rep_type_name(new_rep_type) - def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): + def partial_trace(self, keep, dims): r""" Calculates the partial trace on all state representations which are currently defined @@ -172,10 +172,6 @@ def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): if the space is $A \times B \times C \times D$, dims = [$dim_A$, $dim_B$, $dim_C$, $dim_D$] :type dims: list OR numpy.ndarray - :param measurement_determinism: how a stabilizer representation resolves the measurement of a traced-out - qubit whose outcome is not already fixed by the state: "probabilistic" to draw one, or 0 or 1 to force - it. A density-matrix trace has no outcome to resolve and ignores this. - :type measurement_determinism: str or int :return: nothing :rtype: None :raises NotImplementedError: if the representation object has no partial_trace @@ -189,9 +185,7 @@ def partial_trace(self, keep, dims, measurement_determinism="probabilistic"): f"Partial trace not yet implemented on the " f"{type(self._rep_data).__name__} representation" ) - self._rep_data.partial_trace( - keep, dims, measurement_determinism=measurement_determinism - ) + self._rep_data.partial_trace(keep, dims) def show(self, show=True, ax=None): """ diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index b31a8854..ff88c3ca 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -19,6 +19,7 @@ import graphiq.backends.stabilizer.functions.stabilizer as sfs from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.state import MixedStabilizer, Stabilizer from graphiq.circuit.ops import MeasurementCNOTandReset, MeasurementZ from graphiq.io import IO from graphiq.metrics import Infidelity @@ -141,11 +142,15 @@ def assert_robust_to_measurement_outcome( compiler = compiler_class() compiler.measurement_determinism = determinism state = compiler.compile(circuit) - state.partial_trace( - list(range(n_photon)), - (n_photon + n_emitter) * [2], - measurement_determinism=determinism, - ) + emitters = list(range(n_photon, n_photon + n_emitter)) + if isinstance(state.rep_data, (Stabilizer, MixedStabilizer)): + # a stabilizer trace measures the emitters; resolve it in the same branch + state.rep_data.trace_out_qubits( + emitters, measurement_determinism=determinism + ) + else: + # an exact density-matrix trace has no outcome to resolve + state.partial_trace(list(range(n_photon)), (n_photon + n_emitter) * [2]) infidelity = metric.evaluate(state, circuit) assert np.isclose(infidelity, 0.0), ( f"circuit is not robust to measurement outcome: infidelity={infidelity} " @@ -322,7 +327,7 @@ def test_assert_robust_to_measurement_outcome_rejects_a_post_selected_winner( branch0_compiler = StabilizerCompiler() branch0_compiler.measurement_determinism = 0 branch0_state = branch0_compiler.compile(circuit) - branch0_state.partial_trace(list(range(4)), 5 * [2], measurement_determinism=0) + branch0_state.rep_data.trace_out_qubits([4], measurement_determinism=0) assert np.isclose(hof[0][0], 0.0) and not np.isclose( metric.evaluate(branch0_state, circuit), 0.0 ), "seed 48 no longer gives a one-branch winner; pick a new seed (see docstring)" diff --git a/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py b/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py index 3e457022..5e5af642 100644 --- a/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py +++ b/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py @@ -37,7 +37,6 @@ hadamard_gate, x_gate, ) -from graphiq.backends.stabilizer.state import Stabilizer def tableau_to_density(tableau): @@ -197,15 +196,3 @@ def test_partial_trace_of_multiple_qubits_forced_to_one_leaves_ket_1(): tableau = partial_trace(_ghz4(), keep=[0], dims=4 * [2], measurement_determinism=1) assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 1.0])) - - -def test_stabilizer_partial_trace_accepts_measurement_determinism(): - # Stabilizer.partial_trace forwards measurement_determinism, so a caller - # can pin the traced-out qubit's measurement. That matters because the - # removal now transfers the outcome's sign to the surviving generators: - # the outcome is observable in the result, where before it could not be, - # so a caller with no way to force it would be left with a random state. - state = Stabilizer(_ghz4()) - state.partial_trace(keep=[0], dims=4 * [2], measurement_determinism=1) - - assert np.allclose(tableau_to_density(state.tableau), np.diag([0.0, 1.0])) diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 8a9814a4..f524967f 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -24,10 +24,6 @@ from graphiq.backends.graph.state import Graph, MixedGraph from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau from graphiq.backends.stabilizer.functions.clifford import create_n_ket0_state -from graphiq.backends.stabilizer.functions.transformation import ( - cnot_gate, - hadamard_gate, -) from graphiq.state import DENSITY_MATRIX_QUBIT_THRESH, QuantumState @@ -134,30 +130,6 @@ def test_convert_representation_graph_to_stabilizer_pure_arm(): assert state.rep_data.data.to_stabilizer() == expected -@pytest.mark.parametrize("outcome", [0, 1]) -def test_partial_trace_forwards_measurement_determinism_to_stabilizer(outcome): - # Tracing three qubits out of GHZ4 leaves |outcome> on the survivor, so the - # result shows whether the forced outcome reached the backend. - tableau = hadamard_gate(create_n_ket0_state(4), 0) - for target in (1, 2, 3): - tableau = cnot_gate(tableau, 0, target) - state = QuantumState(tableau, rep_type="s") - - state.partial_trace(keep=[0], dims=4 * [2], measurement_determinism=outcome) - - rho = rc.stabilizer_to_density(state.rep_data.tableau.to_stabilizer()) - assert np.allclose(rho, np.diag([1.0 - outcome, float(outcome)])) - - -def test_density_matrix_partial_trace_accepts_measurement_determinism(): - bell = np.array([1, 0, 0, 1]) / np.sqrt(2) - state = QuantumState(np.outer(bell, bell), rep_type="dm") - - state.partial_trace(keep=[0], dims=[2, 2], measurement_determinism=1) - - assert np.allclose(state.rep_data.data, np.eye(2) / 2) - - @pytest.mark.parametrize("rep_type", ["g", None]) def test_partial_trace_on_a_graph_state_says_it_is_not_implemented(rep_type): # rep_type=None autodetects the graph representation; it is refused like an From 32df54345933c8cecf90ab20fa7aad7127a02b49 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 23:04:10 -0400 Subject: [PATCH 119/227] [fix] Keep "Fixed" on a photon gate that replaces a fixed one EvolutionarySolver.initialization labels the one-qubit gate it places after each photon emission "Fixed", so remove_op never removes it. replace_photon_one_qubit_op can pick that gate and put a new one in its place. replace_op takes the new gate's labels, and the new gate had no "Fixed" label, so once replaced the structural gate became removable. replace_photon_one_qubit_op now labels the new gate "Fixed" when the gate it replaces was. A gate without "Fixed", such as one a mutation added or one taken from a TimeReversedSolver circuit, stays removable as before. This changes seeded EvolutionarySolver searches. In a search with no supplied circuit, its default mutations never add a photon one-qubit gate, so every such gate it replaces is one initialization labelled "Fixed". Its search path is therefore the same as when every replaced photon gate was labelled "Fixed". HybridEvolutionarySolver is unaffected: its photon gates come from TimeReversedSolver or from add_photon_one_qubit_op and carry no "Fixed". Reseeded to the lowest seed whose winner scores zero infidelity in both measurement branches: - linear4 density matrix: 2 -> 1 - linear4 stabilizer: 2 -> 1 - ghz3 density matrix: 3 -> 0 - ghz3 stabilizer: 10 -> 0 The self-test for assert_robust_to_measurement_outcome moves to the ghz3 density-matrix search at seed 25. Its winner scores 0.0 with measurement_determinism = 1 and 1.0 with measurement_determinism = 0, and no linear4 stabilizer seed in 0-59 now gives such a winner. --- graphiq/solvers/evolutionary_solver.py | 4 ++ .../test_evolutionary_random_solvers.py | 70 +++++++++++++++---- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index 35c27618..87162477 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -576,6 +576,10 @@ def replace_photon_one_qubit_op(self, circuit): op = self.one_qubit_ops[ind] noise = self._wrap_noise(op, self.noise_model_mapping["p"]) gate = ops.OneQubitGateWrapper(op, reg_type="p", register=reg, noise=noise) + # keep the protection of the gate being replaced: initialization's photon gates + # stay out of remove_op's reach, gates without "Fixed" stay removable + if "Fixed" in old_op.labels: + gate.add_labels("Fixed") circuit.replace_op(node, gate) def replace_emitter_one_qubit_op(self, circuit): diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index ff88c3ca..88b428ee 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -20,7 +20,13 @@ from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.state import MixedStabilizer, Stabilizer -from graphiq.circuit.ops import MeasurementCNOTandReset, MeasurementZ +from graphiq.circuit.ops import ( + Hadamard, + Identity, + MeasurementCNOTandReset, + MeasurementZ, + OneQubitGateWrapper, +) from graphiq.io import IO from graphiq.metrics import Infidelity from graphiq.solvers.evolutionary_solver import ( @@ -208,12 +214,12 @@ def linear4_run(density_matrix_compiler, linear4_expected): Since we want to apply 2 separate tests on the same run (one visual, one non-visual), it makes sense to have a common fixture that only gets called once per module """ - return generate_run(4, 1, linear4_expected, density_matrix_compiler, 2) + return generate_run(4, 1, linear4_expected, density_matrix_compiler, 1) @pytest.fixture(scope="module") def linear4_run_stabilizer(stabilizer_compiler, linear4_expected): - return generate_run(4, 1, linear4_expected, stabilizer_compiler, 2) + return generate_run(4, 1, linear4_expected, stabilizer_compiler, 1) @pytest.fixture(scope="module") @@ -227,12 +233,12 @@ def linear4_expected(): @pytest.fixture(scope="module") def ghz3_run(density_matrix_compiler, ghz3_expected): - return generate_run(3, 1, ghz3_expected, density_matrix_compiler, 3) + return generate_run(3, 1, ghz3_expected, density_matrix_compiler, 0) @pytest.fixture(scope="module") def ghz3_run_stabilizer(stabilizer_compiler, ghz3_expected): - return generate_run(3, 1, ghz3_expected, stabilizer_compiler, 10) + return generate_run(3, 1, ghz3_expected, stabilizer_compiler, 0) @pytest.fixture(scope="module") @@ -310,10 +316,10 @@ def test_solver_ghz3_visualized(ghz3_run, ghz3_expected): def test_assert_robust_to_measurement_outcome_rejects_a_post_selected_winner( - stabilizer_compiler, linear4_expected + density_matrix_compiler, ghz3_expected ): """ - Seed 48 of the linear4 stabilizer search ends on a winner that scores 0.0 in the branch the + Seed 25 of the ghz3 density-matrix search ends on a winner that scores 0.0 in the branch the compiler is pinned to (measurement_determinism = 1) but is wrong in the other one, so check_run accepts it and assert_robust_to_measurement_outcome must not. @@ -321,21 +327,21 @@ def test_assert_robust_to_measurement_outcome_rejects_a_post_selected_winner( that path, the precondition below fails; pick a new seed by searching for one whose winner scores 0.0 with measurement_determinism = 1 and not with measurement_determinism = 0. """ - hof, state = generate_run(4, 1, linear4_expected, stabilizer_compiler, 48) + hof, state = generate_run(3, 1, ghz3_expected, density_matrix_compiler, 25) circuit = hof[0][1] - _, _, metric = linear4_expected - branch0_compiler = StabilizerCompiler() + _, _, metric = ghz3_expected + branch0_compiler = DensityMatrixCompiler() branch0_compiler.measurement_determinism = 0 branch0_state = branch0_compiler.compile(circuit) - branch0_state.rep_data.trace_out_qubits([4], measurement_determinism=0) + branch0_state.partial_trace(list(range(3)), 4 * [2]) assert np.isclose(hof[0][0], 0.0) and not np.isclose( metric.evaluate(branch0_state, circuit), 0.0 - ), "seed 48 no longer gives a one-branch winner; pick a new seed (see docstring)" + ), "seed 25 no longer gives a one-branch winner; pick a new seed (see docstring)" - check_run((hof, state), linear4_expected) + check_run((hof, state), ghz3_expected) with pytest.raises(AssertionError, match="not robust to measurement outcome"): assert_robust_to_measurement_outcome( - hof[0][1], linear4_expected, StabilizerCompiler, n_photon=4, n_emitter=1 + hof[0][1], ghz3_expected, DensityMatrixCompiler, n_photon=3, n_emitter=1 ) @@ -539,3 +545,39 @@ def test_get_emission_assignment_invariants_hold_over_many_seeded_draws( # could only ever appear once, in the forced tail. (3, 3) has no slack, so skip it there. if n_photon > n_emitter: assert top_emitter_reused + + +# Tests for replace_photon_one_qubit_op on the photon gate that initialization places after each +# emission and labels "Fixed" so that remove_op leaves it in place. With one photon and one +# emitter it is the only photonic one-qubit gate, so the replace always picks it. + + +def _one_photon_circuit(): + solver = EvolutionarySolver(None, None, None, n_emitter=1, n_photon=1) + circuit = solver.initialization([0], [0]) + (node,) = circuit.get_node_by_labels(["OneQubitGateWrapper", "Photonic"]) + return solver, circuit, node + + +def test_replacing_the_fixed_photon_gate_keeps_it_fixed(): + solver, circuit, node = _one_photon_circuit() + + solver.replace_photon_one_qubit_op(circuit) + solver.remove_op(circuit, node) + + assert node in circuit.dag.nodes + assert "Fixed" in circuit.dag.nodes[node]["op"].labels + + +def test_replacing_an_unfixed_photon_gate_leaves_it_removable(): + solver, circuit, node = _one_photon_circuit() + # stands in for a gate a mutation added, which carries no "Fixed" label + circuit.replace_op( + node, OneQubitGateWrapper([Identity, Hadamard], register=0, reg_type="p") + ) + + solver.replace_photon_one_qubit_op(circuit) + + assert "Fixed" not in circuit.dag.nodes[node]["op"].labels + solver.remove_op(circuit, node) + assert node not in circuit.dag.nodes From bb00f28743f265437e255060aa331a07c1cbd066 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 23:04:17 -0400 Subject: [PATCH 120/227] [fix] Reduce with the original Hadamard block first in inverse_circuit The rewritten Hadamard block in inverse_circuit fixed the states on which the original block left the later blocks without a pivot. But it also ran on states the original block reduced correctly, and gave a different circuit there. TimeReversedSolver with StabilizerCompiler, on 53 connected gnp(n, 0.5) graphs (n = 3..8, seeds 0..11), emitted a different circuit, by to_openqasm(), for 7 of them. All 7 were still at fidelity 1. inverse_circuit now reduces with the original block first. It keeps that result only when the tableau comes out as exactly the standard all-zero tableau, bare Z on each qubit with + sign. Otherwise it restores the input in the same object and reduces again with the rewritten block. If that also misses the all-zero tableau, it raises ValueError. The check is exact equality, not just "X = 0, + signs, Z invertible". The stabilizer inner product behind the stabilizer fidelity reads the returned generators row by row, and needs each one to be the bare Z on its qubit. On a state where the original block ends at |0...0> written with other Z generators, it returned fidelity 1 for orthogonal states. The rewritten block gets this right, so such states use it. On the same 53 graphs the circuits are now identical to those before the rewrite, all at fidelity 1. --- .../stabilizer/functions/stabilizer.py | 174 ++++++++++++------ .../test_inverse_circuit_reduction.py | 163 ++++++++++++++-- 2 files changed, 259 insertions(+), 78 deletions(-) diff --git a/graphiq/backends/stabilizer/functions/stabilizer.py b/graphiq/backends/stabilizer/functions/stabilizer.py index fc196e60..54959e96 100644 --- a/graphiq/backends/stabilizer/functions/stabilizer.py +++ b/graphiq/backends/stabilizer/functions/stabilizer.py @@ -135,69 +135,127 @@ def inverse_circuit(tableau): :return: the tableau that is converted to the basis state, list of gate instructions :rtype: StabilizerTableau, list[tuple] """ - circuit_list = [] n_qubits = tableau.n_qubits - tableau = canonical_form(tableau) + zero_state = StabilizerTableau(n_qubits) + backup = tableau.copy() + reduced, circuit_list = _reduce_to_zero_state(tableau, pivot_every_column=False) + if reduced == zero_state: + return reduced, circuit_list + + # The first Hadamard block can leave the later blocks without a pivot, or end on + # |0...0> written with generators other than the bare Z on each qubit, which the + # stabilizer inner product reads row by row. Start again from the input, in the + # same object, with the block that places a pivot on every column. + tableau.table = backup.table + tableau.phase = backup.phase + reduced, circuit_list = _reduce_to_zero_state(tableau, pivot_every_column=True) + if reduced != zero_state: + raise ValueError( + "the tableau did not reduce to the all-zero state; it is not a valid " + "stabilizer state" + ) + return reduced, circuit_list - # Hadamard block - # The blocks below clear the X matrix by column operations, keying off the diagonal - # entry of each column, and they leave a column alone when its qubit is already - # reduced. So this block has to put a generator on every diagonal entry: an X pivot - # on the qubits that still need work, the bare Z on the ones that do not. - # - # The canonical form leaves the X matrix in reduced row echelon form, with the - # generators that have no X support gathered in the rows underneath and their Z - # parts reduced in turn. The leading entry of each row therefore names the columns - # that carry an X pivot, and a generator that is a bare Z on a single qubit says - # that qubit is already reduced -- unentangled, and in one of the two computational - # basis states -- so no gate but the sign fix belongs on it. Every other - # column without an X pivot gets a Hadamard, which moves that column's Z entries - # into the X matrix. That makes the X matrix invertible on the columns that need a - # pivot: a generator with no X support is orthogonal to the X row space, because the - # group is abelian, and such an element supported only on the pivot columns is the - # identity -- in reduced row echelon form the product against pivot row i collapses - # to that element's entry at pivot column i, so commutation kills the pivot - # coordinates one at a time. Their Z entries on the other columns are therefore - # independent. - x_pivot_columns = set() - reduced_columns = set() - for row_i in range(n_qubits): - x_support = np.nonzero(tableau.x_matrix[row_i])[0] - if x_support.size: - x_pivot_columns.add(int(x_support[0])) - elif np.count_nonzero(tableau.z_matrix[row_i]) == 1: - reduced_columns.add(int(np.nonzero(tableau.z_matrix[row_i])[0][0])) - for j in range(n_qubits): - if j not in x_pivot_columns and j not in reduced_columns: - circuit_list.append(("H", j)) - tableau = transform.hadamard_gate(tableau, j) +def _reduce_to_zero_state(tableau, pivot_every_column): + """ + Reduce the tableau towards the tableau of :math:`|0\\rangle^{\\otimes n}`, + with one of two versions of the first Hadamard block - # Move the generator for each column onto the diagonal and clear the column below - # it. These are row operations: they change which generators are written down, not - # the state, so they cost no gates. An already-reduced qubit takes its own bare Z - # generator, which the eliminations cannot have touched -- they only add into rows - # that have X support in the column being cleared. - for j in range(n_qubits): - if j in reduced_columns: - pivot_row = _bare_z_row(tableau, j) - else: - rows_below = np.nonzero(tableau.x_matrix[j:n_qubits, j])[0] - pivot_row = j + int(rows_below[0]) if rows_below.size else None - if pivot_row is None or pivot_row < j: - # Unreachable on a valid stabilizer state. It must raise rather than - # assert: with assertions off, a pivot_row below j would swap a row - # upward over an already-placed column and return a wrong tableau with - # a circuit that does not zero the state -- silently, which is how this - # defect survived in the first place. - raise ValueError( - f"column {j} has no generator left to pivot on; the tableau is " - f"not a valid stabilizer state" + :param tableau: the input tableau, reduced in place + :type tableau: StabilizerTableau + :param pivot_every_column: False to apply H only to a Z-type column with support to + its right; True to apply H to every column that has no X pivot and is not a + bare-Z qubit, and then place a pivot on every diagonal entry + :type pivot_every_column: bool + :return: the reduced tableau, list of gate instructions + :rtype: StabilizerTableau, list[tuple] + """ + circuit_list = [] + n_qubits = tableau.n_qubits + tableau = canonical_form(tableau) + + if pivot_every_column: + # Hadamard block + # The blocks below clear the X matrix by column operations, keying off the + # diagonal entry of each column, and they leave a column alone when its qubit is + # already reduced. So this block has to put a generator on every diagonal entry: + # an X pivot on the qubits that still need work, the bare Z on the ones that do + # not. + # + # The canonical form leaves the X matrix in reduced row echelon form, with the + # generators that have no X support gathered in the rows underneath and their Z + # parts reduced in turn. The leading entry of each row therefore names the + # columns that carry an X pivot, and a generator that is a bare Z on a single + # qubit says that qubit is already reduced -- unentangled, and in one of the two + # computational basis states -- so no gate but the sign fix belongs on it. Every + # other column without an X pivot gets a Hadamard, which moves that column's Z + # entries into the X matrix. That makes the X matrix invertible on the columns + # that need a pivot: a generator with no X support is orthogonal to the X row + # space, because the group is abelian, and such an element supported only on the + # pivot columns is the identity -- in reduced row echelon form the product + # against pivot row i collapses to that element's entry at pivot column i, so + # commutation kills the pivot coordinates one at a time. Their Z entries on the + # other columns are therefore independent. + x_pivot_columns = set() + reduced_columns = set() + for row_i in range(n_qubits): + x_support = np.nonzero(tableau.x_matrix[row_i])[0] + if x_support.size: + x_pivot_columns.add(int(x_support[0])) + elif np.count_nonzero(tableau.z_matrix[row_i]) == 1: + reduced_columns.add(int(np.nonzero(tableau.z_matrix[row_i])[0][0])) + + for j in range(n_qubits): + if j not in x_pivot_columns and j not in reduced_columns: + circuit_list.append(("H", j)) + tableau = transform.hadamard_gate(tableau, j) + + # Move the generator for each column onto the diagonal and clear the column + # below it. These are row operations: they change which generators are written + # down, not the state, so they cost no gates. An already-reduced qubit takes its + # own bare Z generator, which the eliminations cannot have touched -- they only + # add into rows that have X support in the column being cleared. + for j in range(n_qubits): + if j in reduced_columns: + pivot_row = _bare_z_row(tableau, j) + else: + rows_below = np.nonzero(tableau.x_matrix[j:n_qubits, j])[0] + pivot_row = j + int(rows_below[0]) if rows_below.size else None + if pivot_row is None or pivot_row < j: + # Unreachable on a valid stabilizer state. It must raise rather than + # assert: with assertions off, a pivot_row below j would swap a row + # upward over an already-placed column and return a wrong tableau with a + # circuit that does not zero the state -- silently, which is how this + # defect survived in the first place. + raise ValueError( + f"column {j} has no generator left to pivot on; the tableau is " + f"not a valid stabilizer state" + ) + tableau = tab_row_swap(tableau, j, pivot_row) + for row_i in range(j + 1, n_qubits): + if tableau.x_matrix[row_i, j] == 1: + tableau = tab_row_sum(tableau, j, row_i) + else: + # Hadamard block + pivot = [0, 0] + for j in range(n_qubits): + pivot[1] = j + x_list, y_list, z_list = pauli_type_finder( + tableau.x_matrix, tableau.z_matrix, pivot ) - tableau = tab_row_swap(tableau, j, pivot_row) - for row_i in range(j + 1, n_qubits): - if tableau.x_matrix[row_i, j] == 1: - tableau = tab_row_sum(tableau, j, row_i) + if x_list: + tableau = tab_row_swap(tableau, pivot[0], x_list[0]) + elif y_list: + tableau = tab_row_swap(tableau, pivot[0], y_list[0]) + elif z_list: + tableau = tab_row_swap(tableau, pivot[0], z_list[-1]) + if np.any(tableau.x_matrix[pivot[0], j + 1 : n_qubits]) or np.any( + tableau.z_matrix[pivot[0], j + 1 : n_qubits] + ): + circuit_list.append(("H", j)) + tableau = transform.hadamard_gate(tableau, j) + pivot[0] = pivot[0] + 1 # CNOT block for j in range(n_qubits): diff --git a/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py b/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py index a9b23d8e..5492d9ef 100644 --- a/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py +++ b/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py @@ -14,31 +14,41 @@ # limitations under the License. """ ``inverse_circuit`` reduces a stabilizer state to -:math:`|0\\rangle^{\\otimes n}` and hands back the gates that do it. Its -Hadamard block is what makes the reduction possible: the blocks after it clear -the x matrix by column operations, which needs a pivot on every diagonal entry -of that matrix, and only Hadamards can create one in a column whose generators -are all Z. - -The guarantee those blocks rely on, and which these tests pin down, is that the -Hadamard block leaves the x matrix with a pivot in every column. The state -below is a case where a per-column walk that parks one generator per column -without checking that the generator has support there runs out of generators -before the last column. - -The reduction is silent -- nothing in ``inverse_circuit`` raises when it fails --- so these tests assert on the returned tableau directly rather than only -through a reference. A state that did not reduce still carries X or Y -generators, which needs no oracle to spot. +:math:`|0\\rangle^{\\otimes n}` and hands back the gates that do it. The +blocks after its opening Hadamard block clear the x matrix by column operations, +which needs a pivot on every diagonal entry of that matrix, and only Hadamards +can create one in a column whose generators are all Z. + +It first reduces with a Hadamard block that does not always leave such a pivot, +and keeps that result only if it is exactly the standard all-zero tableau. +Otherwise it starts again from the input with a block that places a pivot on +every column, and raises if that also misses. The states below include one where +the first block runs out of generators before the last column, and one where it +reaches the all-zero state written with other Z generators. + +These tests assert on the returned tableau directly, by exact equality with the +standard all-zero tableau, which needs no reference circuit. """ import numpy as np +from graphiq.backends.stabilizer.functions.metric import fidelity +from graphiq.backends.stabilizer.functions.rep_conversion import ( + clifford_from_stabilizer, +) from graphiq.backends.stabilizer.functions.stabilizer import ( canonical_form, inverse_circuit, ) -from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.functions.transformation import ( + control_z_gate, + cnot_gate, + hadamard_gate, + phase_gate, + run_circuit, + x_gate, + z_gate, +) from graphiq.backends.stabilizer.tableau import StabilizerTableau @@ -90,9 +100,9 @@ def test_reducing_the_all_zero_state_asks_for_no_gates(): def test_the_reducing_circuit_skips_the_qubits_that_are_already_reduced(): - # A Bell pair on qubits 0 and 2, with qubits 1 and 3 in |0> and |1>. A - # generator that is a bare Z on one qubit says that qubit is unentangled - # and in |0> or |1>, so no gate but the sign fix belongs on it. + # A Bell pair on qubits 0 and 2, with qubits 1 and 3 in |0> and |1>. + # Qubits 1 and 3 are unentangled and already in a computational basis + # state, so no gate but the sign fix belongs on them. state = _stabilizer_state( ["XIXI", "ZIZI", "IZII", "IIIZ"], [0, 0, 0, 1], @@ -102,3 +112,116 @@ def test_the_reducing_circuit_skips_the_qubits_that_are_already_reduced(): assert reduced == StabilizerTableau(4) assert [gate for gate in circuit_list if set(gate[1:]) & {1, 3}] == [("X", 3)] + + +#: The tableau TimeReversedSolver hands to ``inverse_circuit`` to disentangle +#: the emitters, for one random six-qubit case (four photons, two emitters). +#: The first Hadamard block reduces it, so its circuit is the one that block +#: gives. +_EMITTER_STAGE = ( + ["ZIIIII", "IZIIII", "IIZIII", "IIIZII", "IIIIYX", "IIIIZZ"], + [0, 0, 0, 0, 0, 0], +) + + +def test_a_state_the_first_hadamard_block_reduces_keeps_its_circuit(): + state = _stabilizer_state(*_EMITTER_STAGE) + + reduced, circuit_list = inverse_circuit(state.copy()) + + assert circuit_list == [ + ("CNOT", 4, 5), + ("CZ", 4, 5), + ("P", 4), + ("H", 4), + ("X", 4), + ] + assert reduced == StabilizerTableau(6) + + +#: A nine-qubit state that the first Hadamard block reduces to |0...0>, but +#: with a generator that is ``Z_7 Z_8`` rather than ``Z_7``. The state is +#: right, but the inner product in the stabilizer metric reads the returned +#: generators row by row and needs each to be the bare ``Z_j``. +_ZERO_STATE_WRITTEN_WITH_OTHER_GENERATORS = ( + [ + "IXIXIZIIX", + "YXZXIIYXX", + "IIZIZIIII", + "IZIZIIIII", + "YXYXXIYZX", + "YYYXXYYXX", + "XXZXIIXXX", + "IIZIIZIXI", + "IZZIZIIIZ", + ], + [1, 1, 1, 0, 0, 1, 0, 0, 1], +) + + +def test_the_reduced_tableau_is_the_standard_all_zero_tableau(): + state = _stabilizer_state(*_ZERO_STATE_WRITTEN_WITH_OTHER_GENERATORS) + + reduced, circuit_list = inverse_circuit(state.copy()) + + assert reduced == StabilizerTableau(9) + assert canonical_form(run_circuit(state.copy(), list(circuit_list))) == ( + StabilizerTableau(9) + ) + + +def test_fidelity_tells_a_state_from_one_a_single_x_makes_orthogonal(): + labels, phase = _ZERO_STATE_WRITTEN_WITH_OTHER_GENERATORS + state = clifford_from_stabilizer(_stabilizer_state(labels, phase)) + + # X on qubit 2 anticommutes with a generator, so it gives an orthogonal + # state. + flipped = x_gate(state.copy(), 2) + # Generator 0 stabilizes the state, so applying it gives the same state. + same = state.copy() + for qubit, pauli in enumerate(labels[0]): + if pauli in "XY": + same = x_gate(same, qubit) + if pauli in "ZY": + same = z_gate(same, qubit) + + assert fidelity(state, flipped) == 0 + assert fidelity(state, same) == 1 + + +def _random_stabilizer_state(rng, n_qubits, n_gates): + tableau = StabilizerTableau(n_qubits) + for _ in range(n_gates): + gate = rng.integers(5) + if gate < 3: + qubit = int(rng.integers(n_qubits)) + tableau = (hadamard_gate, phase_gate, x_gate)[gate](tableau, qubit) + else: + control, target = (int(q) for q in rng.choice(n_qubits, 2, replace=False)) + tableau = (cnot_gate, control_z_gate)[gate - 3](tableau, control, target) + return tableau + + +def _states_to_reduce(): + yield _stabilizer_state(*_NO_PIVOT_IN_THE_LAST_COLUMN) + yield _stabilizer_state(*_ZERO_STATE_WRITTEN_WITH_OTHER_GENERATORS) + yield _stabilizer_state(*_EMITTER_STAGE) + rng = np.random.default_rng(0) + for _ in range(200): + yield _random_stabilizer_state( + rng, int(rng.integers(2, 8)), int(rng.choice([5, 20, 60])) + ) + + +def test_every_state_reduces_in_place_to_the_standard_all_zero_tableau(): + for state in _states_to_reduce(): + n_qubits = state.n_qubits + argument = state.copy() + + reduced, circuit_list = inverse_circuit(argument) + + assert reduced is argument + assert reduced == StabilizerTableau(n_qubits) + assert canonical_form(run_circuit(state.copy(), list(circuit_list))) == ( + StabilizerTableau(n_qubits) + ) From a74322cdcf5a505c8d818399946448f88f41bd92 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Thu, 24 Sep 2026 23:04:17 -0400 Subject: [PATCH 121/227] [fix] Try pairwise basis sums before single vectors in the LC check The deterministic search in is_lc_equivalent tries sums of at most two solution-basis vectors. Adding the single basis vectors to it put them ahead of the pairwise sums. On a connected graph where a pairwise sum and a single vector are both valid, the returned Clifford operation, and the gates converter_gate_list builds from it, changed. K4 against itself is one such graph. The boolean result did not change. The pairwise sums now come first, in the same order as before, and the single vectors follow them. On a connected graph that a pairwise sum solves, the returned solution is again the first valid pairwise sum. The single vectors still decide the graphs that no pairwise sum solves, and the per-component check is unchanged. So the returned Clifford operation no longer differs because single vectors are tried first. On 948 connected graph pairs (structured, random and every connected atlas graph with 3 to 6 nodes against LC-equivalent partners), the returned solution had differed from the pairwise-only search on 29. It now matches that search on all 948: the same boolean and solution, and the same gate list on the 525 non-atlas pairs. The test of the candidate list now also checks that the pairwise sums come first, in combinations order. --- graphiq/backends/lc_equivalence_check.py | 12 ++++++---- .../test_graph_LC_equivalence.py | 24 ++++++++++++++++++- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index b9cf894d..2ab69d5e 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -335,14 +335,16 @@ def _is_lc_equivalent_connected(adj_matrix1, adj_matrix2, mode, seed): else: # mode == "deterministic" (mode is validated by the wrapper, so this is the only # remaining branch). Bouchet (4.4): a valid Q, when one exists, is a sum of at most two - # vectors of basis B, so the candidates are the k basis vectors followed by their - # k(k-1)/2 pairwise sums. The empty sum (the zero vector) is also "a sum of <= 2 vectors - # of B", but it fails the per-vertex constraint (4.2) at every vertex (a_i d_i + b_i c_i - # = 0 mod 2, never 1), so it is skipped rather than tried. + # vectors of basis B, so the candidates are the k(k-1)/2 pairwise sums of the basis + # vectors followed by the k basis vectors themselves. The pairwise sums go first so that + # a graph a pairwise sum solves gets the first such sum as its solution; the single + # vectors only decide the cases no pairwise sum solves. The empty sum (the zero vector) + # is also "a sum of <= 2 vectors of B", but it fails the per-vertex constraint (4.2) at + # every vertex (a_i d_i + b_i c_i = 0 mod 2, never 1), so it is skipped rather than tried. basis = solution_basis candidates = chain( - (b % 2 for b in basis), (((b1 + b2) % 2) for b1, b2 in combinations(basis, 2)), + (b % 2 for b in basis), ) for solution in candidates: if _is_valid_clifford(solution): diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index c1aded4f..1a5bae57 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import time +from itertools import combinations import matplotlib.pyplot as plt import pytest @@ -482,7 +483,12 @@ def fake_is_valid_clifford(vector): assert solution is None assert len(recorded) == k + k * (k - 1) // 2 - singles = recorded[:k] + # The pairwise sums come first, in the order combinations gives them, so a graph that a + # pairwise sum solves gets the same solution it got before the singles were added. + n_pairs = k * (k - 1) // 2 + for candidate, (b1, b2) in zip(recorded[:n_pairs], combinations(basis, 2)): + assert np.array_equal(candidate, (b1 + b2) % 2) + singles = recorded[n_pairs:] remaining_basis = list(basis) for single in singles: match = next( @@ -496,6 +502,22 @@ def fake_is_valid_clifford(vector): assert remaining_basis == [] +def test_a_graph_a_pairwise_sum_solves_keeps_that_solution(): + # K4 against itself: its solution basis has 5 vectors, one single vector is valid and so are + # 3 pairwise sums. The first valid pairwise sum is the solution returned. + k4 = _adjacency(nx.complete_graph(4)) + + equivalent, solution = is_lc_equivalent(k4, k4, mode="deterministic") + + assert equivalent + assert solution.tolist() == [ + [[0, 1], [1, 0]], + [[1, 0], [0, 1]], + [[0, 1], [1, 0]], + [[1, 0], [0, 1]], + ] + + def test_symmetric_connected_graphs(): """ Regression guard, already passing before this change: a handful of large, connected, From c54417172118707986beb762c206da135922332d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 10:08:09 -0400 Subject: [PATCH 122/227] [test] Pin that density_to_graph reads eigenvectors as columns The existing mixed-state test uses 0.7|+><+| + 0.3|-><-| on one qubit. Every 1-qubit branch has the adjacency zeros((1, 1)) and the weights come straight from the eigenvalues, so the test cannot see which vectors are read. Reading row i instead of column i still passes. The new test uses 0.7|empty> + 0.3|triangle> on 3 qubits: two orthogonal graph states, whose eigenvector matrix is not symmetric. The test asserts that precondition, then checks that the weighted branches come back as exactly those two graphs with their weights. Reading rows gives a different result, so the test fails. --- .../test_state_rep_conversion.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_state_representation/test_state_rep_conversion.py b/tests/test_state_representation/test_state_rep_conversion.py index a2240262..e30feb38 100644 --- a/tests/test_state_representation/test_state_rep_conversion.py +++ b/tests/test_state_representation/test_state_rep_conversion.py @@ -276,6 +276,37 @@ def test_density_to_graph_mixed_state_decomposes_into_eigenstates(): assert np.array_equal(adj, np.zeros((1, 1))) +def test_density_to_graph_reads_each_branch_from_an_eigenvector_column(): + # 0.7 |empty> + 0.3 |triangle> on 3 qubits: the two graph states are orthogonal, so they + # are the eigenvectors of rho with nonzero weight. The 1-qubit test above cannot see which + # vectors are read: every 1-qubit branch has the adjacency zeros((1, 1)). Here the branches + # differ, and eigh's eigenvector matrix is not symmetric, so reading a row instead of a + # column builds a different projector. + rho = 0.7 * rc.graph_to_density(nx.empty_graph(3)) + 0.3 * rc.graph_to_density( + nx.complete_graph(3) + ) + eigenvalues, eigenvectors = np.linalg.eigh(rho) + weighted = [i for i in range(len(eigenvalues)) if eigenvalues[i] > 1e-9] + assert not np.allclose(eigenvectors, eigenvectors.T) + for i in weighted: + column, row = eigenvectors[:, i], eigenvectors[i] + assert not np.allclose( + np.outer(column, column.conj()), np.outer(row, row.conj()) + ) + + graph_list = rc.density_to_graph(rho) + + # every eigenvalue gets an entry; the zero-weight ones come from an arbitrary basis of the + # null space, so only the weighted entries are checked + branches = sorted( + (round(float(p_i), 9), adj.tolist()) for p_i, adj in graph_list if p_i > 1e-9 + ) + assert branches == [ + (0.3, nx.to_numpy_array(nx.complete_graph(3)).tolist()), + (0.7, np.zeros((3, 3)).tolist()), + ] + + def test_state_to_graph_rejects_an_unrepresentable_input(): # Neither an adjacency matrix, a graph, nor a stabilizer/Clifford tableau: falls through # every isinstance check to the final else arm. From 806f80114b4147075dc4c184e07bd79cf9ccf479 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 10:08:09 -0400 Subject: [PATCH 123/227] [fix] Reject an unrecognised measurement_determinism before measuring z_measurement_gate checked measurement_determinism only after the row sums, the row copy and the reset of the measured row. So a caller that caught the ValueError was left holding a half-measured tableau. The outcome is now picked, and the argument checked, before the tableau is touched. The same comparisons and the same single random draw are used as before, so valid calls behave the same. --- .../backends/stabilizer/functions/clifford.py | 29 ++++++++++--------- .../stabilizer/test_stabilizer_state.py | 27 +++++++++++++++-- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index 49d33bb2..3ae42d27 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -64,6 +64,20 @@ def z_measurement_gate( break if x_p != 0: # probabilistic outcome + # Pick the outcome before touching the tableau, so a rejected + # measurement_determinism leaves it as it was. + # We have the option to remove randomness and pick a specific outcome + # measurement_determinism is only used when we know random measurement outcome is possible + if measurement_determinism == "probabilistic": + outcome = np.random.randint(0, 2) + elif measurement_determinism == 1: + outcome = 1 + elif measurement_determinism == 0: + outcome = 0 + else: + raise ValueError( + f'measurement_determinism parameter must be "probabilistic", 0, or 1' + ) x_matrix = tableau.table_x z_matrix = tableau.table_z r_vector = tableau.phase @@ -93,20 +107,7 @@ def z_measurement_gate( # and no factor of i. tableau.iphase[x_p] = 0 - # set that row of the phase vector to random measurement outcome 0 or 1 with an equal probability. - # We have the option to remove randomness and pick a specific outcome - # measurement_determinism is only used when we know random measurement outcome is possible - if measurement_determinism == "probabilistic": - outcome = np.random.randint(0, 2) - elif measurement_determinism == 1: - outcome = 1 - elif measurement_determinism == 0: - outcome = 0 - else: - raise ValueError( - f'measurement_determinism parameter must be "probabilistic", 0, or 1' - ) - + # set that row of the phase vector to the measurement outcome tableau.phase[x_p] = outcome tableau.table = table return tableau, outcome, x_p diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_state.py b/tests/test_state_representation/stabilizer/test_stabilizer_state.py index 54224708..db9e5b47 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_state.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_state.py @@ -92,10 +92,31 @@ def test_pure_and_mixed_stabilizers_compare_unequal_without_raising(): assert (mixed == pure) is False -@pytest.mark.parametrize("measurement_determinism", ["probabilstic", 2, None]) -def test_measurement_rejects_an_unrecognised_determinism(measurement_determinism): - # |+> has a random Z outcome, so the determinism setting is consulted +def _plus_state(): state = Stabilizer(1) state.apply_hadamard(0) + return state + + +def _cz_graph_state(): + state = Stabilizer(2) + state.apply_hadamard(0) + state.apply_hadamard(1) + state.apply_cz(0, 1) + return state + + +@pytest.mark.parametrize("make_state", [_plus_state, _cz_graph_state]) +@pytest.mark.parametrize("measurement_determinism", ["probabilstic", 2, None]) +def test_measurement_rejects_an_unrecognised_determinism( + make_state, measurement_determinism +): + # qubit 0 has a random Z outcome in both states, so the determinism setting is + # consulted; the rejected call must leave the tableau as it found it + state = make_state() + before = state.tableau.copy() with pytest.raises(ValueError, match="measurement_determinism"): state.apply_measurement(0, measurement_determinism=measurement_determinism) + assert np.array_equal(state.tableau.table, before.table) + assert np.array_equal(state.tableau.phase, before.phase) + assert np.array_equal(state.tableau.iphase, before.iphase) From 6026aa7f1fb05e9d17ce038c87b992f76eb6924d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 10:08:09 -0400 Subject: [PATCH 124/227] [test] Cover the metric names Metrics registers and the GraphMetric exclusion Metrics accepts each of the seven metric classes added to Metrics._all by name, and still rejects GraphMetric, whose evaluate signature does not match the evaluate(state, circuit) contract Metrics calls. --- tests/test_metrics/test_metrics.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py index 00ca6cde..c304d3b9 100644 --- a/tests/test_metrics/test_metrics.py +++ b/tests/test_metrics/test_metrics.py @@ -371,6 +371,29 @@ def test_metrics_unrecognized_metric_raises(): Metrics(["NotARealMetric"]) +@pytest.mark.parametrize( + "name", + [ + "CircuitEmitterCount", + "CircuitCnotCount", + "CircuitUnitaryCount", + "CircuitMaxEmitDepth", + "CircuitMaxEmitResetDepth", + "CircuitMaxEmitEffDepth", + "CircuitMeasureCount", + ], +) +def test_metrics_accepts_each_circuit_metric_by_name(name): + metrics = Metrics([name]) + assert [type(metric).__name__ for metric in metrics._metrics] == [name] + + +def test_metrics_rejects_graph_metric_by_name(): + # GraphMetric's evaluate takes a graph-metric name, not (state, circuit) + with pytest.raises(UserWarning): + Metrics(["GraphMetric"]) + + def test_infidelity_graph_state_marked_mixed_matches_stabilizer_target(): """Regression coverage for the Infidelity.evaluate bug where the mixed- stabilizer branch checked `isinstance(state.rep_data, MixedStabilizer)` From 43ee392979075db0818991ede2eb47c95e9c3443 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 10:08:09 -0400 Subject: [PATCH 125/227] [fix] Count each one-qubit gate once in the data-collection unitary count result_maker's "n_unitary" counted over ["SigmaX", "SigmaX", "SigmaX", "Phase", "Hadamard", "CNOT"]. So each SigmaX counted three times, and SigmaY, SigmaZ and PhaseDagger were not counted. It now uses the same gate list as CircuitUnitaryCount, where this was fixed earlier. "n_unitary" values change for circuits with any of those gates. result_maker records "n_unitary" by default, so the column changes in orbit_analyzer's result and in the CSV and JSON files find_best writes, including those graph_analyzer, LC_scaling_test and iso_scaling_test write through it (find_best on a 5-cycle with 4 reorderings: [17, 39, 56, 40] -> [17, 31, 44, 32]). find_best's cost does not use it, so graph_analyzer's best and worst picks do not change. No example, notebook or benchmark in the repo records it. --- graphiq/data_collection/user_interface.py | 10 +++++- tests/test_data_collection/__init__.py | 14 ++++++++ .../test_user_interface.py | 32 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/test_data_collection/__init__.py create mode 100644 tests/test_data_collection/test_user_interface.py diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index b92edd8e..4481717b 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -185,7 +185,15 @@ def result_maker(result, graph_met_list=None, circ_met_list=None): n_emitters.append(c.n_emitters) if "n_unitary" in circ_met_list: n_u = 0 - for label in ["SigmaX", "SigmaX", "SigmaX", "Phase", "Hadamard", "CNOT"]: + for label in [ + "SigmaX", + "SigmaY", + "SigmaZ", + "Phase", + "PhaseDagger", + "Hadamard", + "CNOT", + ]: if label in c.node_dict: n_u += len(c.get_node_by_labels([label])) n_unitary.append(n_u) diff --git a/tests/test_data_collection/__init__.py b/tests/test_data_collection/__init__.py new file mode 100644 index 00000000..fd1ce610 --- /dev/null +++ b/tests/test_data_collection/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py new file mode 100644 index 00000000..5576e53b --- /dev/null +++ b/tests/test_data_collection/test_user_interface.py @@ -0,0 +1,32 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import graphiq.circuit.ops as ops +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.data_collection.user_interface import result_maker +from graphiq.metrics import CircuitUnitaryCount +from graphiq.solvers.solver_result import SolverResult + + +def test_result_maker_counts_each_one_qubit_gate_once(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + for gate in [ops.SigmaX, ops.SigmaY, ops.SigmaZ, ops.PhaseDagger, ops.Hadamard]: + circuit.add(gate(register=0, reg_type="e")) + + result = result_maker( + SolverResult([circuit]), graph_met_list=[], circ_met_list=["n_unitary"] + ) + + assert result["n_unitary"] == [5] + assert result["n_unitary"][0] == CircuitUnitaryCount().evaluate(None, circuit) From b6b3cc4e61ed1eb26392677aeb494197a6f70a83 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 10:08:09 -0400 Subject: [PATCH 126/227] [test] Pin that a raise while applying split noise restores the gate's noise A controlled gate whose legs both carry AdditionNoiseBase noise has its noise split into a pre-gate and a post-gate half. op.noise is bound to each half in turn and restored in a finally. The existing test for a raising compile fails at the first gate, before the split, so it passes even without the restore. The new test raises inside the pre-gate and inside the post-gate noise application. It checks that the CNOT's noise is restored and that the circuit compiles correctly afterwards. --- tests/test_noise/test_noise_placement.py | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_noise/test_noise_placement.py b/tests/test_noise/test_noise_placement.py index 6a9b6b9a..47cad791 100644 --- a/tests/test_noise/test_noise_placement.py +++ b/tests/test_noise/test_noise_placement.py @@ -217,6 +217,43 @@ def _boom(self, state, op, n_quantum, q_index, classical_registers): assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) +@pytest.mark.parametrize("failing_call", [1, 2]) +def test_op_noise_restored_when_noise_application_raises(monkeypatch, failing_call): + """A raise while a half of the split noise is bound must restore the gate's noise. + + The CNOT's noise is split into a pre-gate and a post-gate half, and `op.noise` is + bound to each in turn. Call 1 is the pre-gate half, call 2 the post-gate half; the + other ops carry no noise and never reach `_apply_additional_noise`. + """ + circuit, cnot = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), nm.PauliError("Z") + ) + original = cnot.noise + original_legs = list(cnot.noise) + apply_noise = DensityMatrixCompiler._apply_additional_noise + bound = [] + + def _fail_on_call(self, state, op, n_quantum, q_index): + assert op is cnot + bound.append([not isinstance(leg, nm.NoNoise) for leg in op.noise]) + if len(bound) == failing_call: + raise RuntimeError("noise application failed") + return apply_noise(self, state, op, n_quantum, q_index) + + monkeypatch.setattr(DensityMatrixCompiler, "_apply_additional_noise", _fail_on_call) + with pytest.raises(RuntimeError, match="noise application failed"): + _compile_dm(circuit) + + # Which leg carries real noise in each bound half: the X leg before, the Z leg after. + assert bound == [[True, False], [False, True]][:failing_call] + assert cnot.noise is original + assert list(cnot.noise) == original_legs + + monkeypatch.undo() + expected = _explicit_circuit([(ops.SigmaX, 0)], [(ops.SigmaZ, 1)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + def test_non_addition_noise_on_a_leg_is_rejected(): """Both legs must be `AdditionNoiseBase`; differing *positions* are fine.""" replacement = nm.OneQubitGateReplacement(np.eye(2)) From 63083a9467687fd0069fb40017cf1910cb5f3ef5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 10:08:09 -0400 Subject: [PATCH 127/227] [docs] Correct false or stale comments, docstrings and a test name Text-only fixes, apart from one test rename: - compiler_base: a noise half left bound after a raise passes the isinstance guard (NoNoise is an AdditionNoiseBase) or reads as no noise; either way legs are silently dropped. The comment claimed the guard would fail. - test_noise_placement: the raising-compile test fails at the first operation, before the CNOT's noise is split, so it does not reach the unwind; its docstring now says what it pins and names the test that does. History framing in two comments is removed. - MixedStabilizer.trace_out_qubits: the reason keep is computed first. - The measurement parity tests: present-tense descriptions in place of review vocabulary. - A MixedGraph is rejected by the QuantumState constructor with TypeError, and a MixedGraph rep_data is reachable publicly through convert_representation("g") from a mixed stabilizer state. - A garbled sentence, a call-site count, a pointer to a lowercase "todo" note, a test name that claimed to check phases, a missing :raises: in inverse_circuit, and a history clause in _reduce_to_zero_state. --- graphiq/backends/compiler_base.py | 8 ++-- graphiq/backends/stabilizer/compiler.py | 4 +- .../stabilizer/functions/stabilizer.py | 4 +- graphiq/backends/stabilizer/state.py | 3 +- .../test_measurement_cnot_and_reset_parity.py | 38 +++++++++---------- .../test_noise_models_unsupported_backends.py | 12 +++--- tests/test_noise/test_noise_placement.py | 19 +++++----- .../stabilizer/test_apply_x_measurement.py | 6 +-- .../test_clifford_tableau_labels.py | 2 +- .../test_remove_qubit_sign_exactness.py | 4 +- .../test_quantum_state.py | 6 +-- 11 files changed, 52 insertions(+), 54 deletions(-) diff --git a/graphiq/backends/compiler_base.py b/graphiq/backends/compiler_base.py index 911d0161..be3cd597 100644 --- a/graphiq/backends/compiler_base.py +++ b/graphiq/backends/compiler_base.py @@ -168,9 +168,11 @@ def compile(self, circuit: CircuitBase, initial_state=None): for leg in noise_copy ] # `op` belongs to the caller's circuit, so the rebinding - # below has to be undone on every exit path -- a - # half-built list left behind here fails the isinstance - # guard the next time this circuit is compiled. + # below has to be undone on every exit path. A half list + # left behind would pass the isinstance guard on the next + # compile (NoNoise is an AdditionNoiseBase), or read as no + # noise at all, and the legs it left out would be + # silently dropped. try: op.noise = pre_noise self._apply_additional_noise( diff --git a/graphiq/backends/stabilizer/compiler.py b/graphiq/backends/stabilizer/compiler.py index 0329d9d3..07156b6c 100644 --- a/graphiq/backends/stabilizer/compiler.py +++ b/graphiq/backends/stabilizer/compiler.py @@ -129,8 +129,8 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): # outcome has one entry per mixture branch, and branches can # disagree; classical_registers only has room for one value # per bit, so we report branch 0's outcome. The gate itself - # is still applied correctly per-branch above. See the TODO - # on MixedStabilizer.apply_measurement. + # is still applied correctly per-branch above. See the note + # in MixedStabilizer.apply_measurement. classical_registers[op.c_register] = outcome[0] else: diff --git a/graphiq/backends/stabilizer/functions/stabilizer.py b/graphiq/backends/stabilizer/functions/stabilizer.py index 54959e96..4b7fda9b 100644 --- a/graphiq/backends/stabilizer/functions/stabilizer.py +++ b/graphiq/backends/stabilizer/functions/stabilizer.py @@ -132,6 +132,7 @@ def inverse_circuit(tableau): :param tableau: the input tableau :type tableau: StabilizerTableau + :raises ValueError: if the tableau is not a valid stabilizer state :return: the tableau that is converted to the basis state, list of gate instructions :rtype: StabilizerTableau, list[tuple] """ @@ -226,8 +227,7 @@ def _reduce_to_zero_state(tableau, pivot_every_column): # Unreachable on a valid stabilizer state. It must raise rather than # assert: with assertions off, a pivot_row below j would swap a row # upward over an already-placed column and return a wrong tableau with a - # circuit that does not zero the state -- silently, which is how this - # defect survived in the first place. + # circuit that does not zero the state, with no error. raise ValueError( f"column {j} has no generator left to pivot on; the tableau is " f"not a valid stabilizer state" diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index f42c59f8..c080da14 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -743,8 +743,7 @@ def trace_out_qubits( :rtype: None """ # partial_trace takes the qubits to keep, not the ones to trace out. - # Both are computed before the mixture is rebuilt, since n_qubits is - # read from it. + # keep is computed once, before the comprehension, for every term. n_qubits = self.n_qubits keep = [q for q in range(n_qubits) if q not in set(qubit_positions)] self._mixture = [ diff --git a/tests/test_backends/test_measurement_cnot_and_reset_parity.py b/tests/test_backends/test_measurement_cnot_and_reset_parity.py index 37e41ccc..0fb5732b 100644 --- a/tests/test_backends/test_measurement_cnot_and_reset_parity.py +++ b/tests/test_backends/test_measurement_cnot_and_reset_parity.py @@ -18,10 +18,10 @@ (`ClassicalCNOT`, `ClassicalCZ`) on `MixedStabilizer`. Every measurement-outcome assertion here pins `measurement_determinism = 1`. -Both defects this file targets are invisible at `determinism = 0`: the emitter -is projected to |0> anyway, and the stabilizer's never-written 0.0 is correct -by accident. The default is "probabilistic", so a test that does not pin -determinism would pass on the buggy code about half the time. +At `determinism = 0` the emitter is projected to |0> anyway and the recorded +outcome is 0.0, so a missing reset or a missing register write would go +unseen. The default is "probabilistic", so a test that does not pin +determinism would catch either only about half the time. """ import numpy as np @@ -102,8 +102,8 @@ def _stabilizer_qubit_density(state, qubit, n_qubits): # --------------------------------------------------------------------------- -# Mutant: the emitter is |0> after MeasurementCNOTandReset, on both backends, -# pinned at determinism = 1. +# The emitter is |0> after MeasurementCNOTandReset, on both backends, pinned +# at determinism = 1. # --------------------------------------------------------------------------- @@ -122,9 +122,8 @@ def test_stabilizer_emitter_is_reset_after_measurement_cnot_and_reset(): # --------------------------------------------------------------------------- -# Mutant: classical_registers[c_register] holds the outcome on both backends, -# pinned at determinism = 1 (correct value 1.0; the stabilizer currently -# gives 0.0). +# classical_registers[c_register] holds the outcome on both backends, pinned +# at determinism = 1, where the outcome is 1.0. # --------------------------------------------------------------------------- @@ -141,13 +140,13 @@ def test_stabilizer_creg_records_the_outcome(): # --------------------------------------------------------------------------- -# Mutant: a circuit that re-uses the emitter after the reset. Without this, -# the fix is pinned only at the op boundary -- this is the failure the issue -# exists for: feed-forward work that reads the emitter after a measurement. +# A circuit that re-uses the emitter after the reset, as feed-forward work +# that reads the emitter after a measurement does. The tests above check the +# state right after the op; these check what a later op sees. # # A second op (MeasurementZ, not another MeasurementCNOTandReset) reads the -# emitter directly, so this is decoupled from defect 2 (the stabilizer's own -# missing register write) and isolates defect 1 (the DM reset). +# emitter directly, so this depends only on the reset, not on +# MeasurementCNOTandReset's own register write. # --------------------------------------------------------------------------- @@ -176,11 +175,10 @@ def test_stabilizer_emitter_reuse_after_reset(): # --------------------------------------------------------------------------- -# Mutant: MixedStabilizer writes a scalar, not a list, for -# MeasurementCNOTandReset, ClassicalCNOT and ClassicalCZ. Red today for all -# three: ClassicalCNOT and ClassicalCZ raise ValueError (they try to assign -# the whole outcomes list into a scalar slot of the classical_registers -# ndarray), and MeasurementCNOTandReset writes nothing at all (defect 2). +# MixedStabilizer writes a scalar, not a list, for MeasurementCNOTandReset, +# ClassicalCNOT and ClassicalCZ. The outcome has one entry per mixture +# branch, and a classical_registers slot holds one value, so writing the +# whole list would raise ValueError. # # `noise_simulation = True` with every op carrying NoNoise still routes # through compile_one_gate (CompilerBase.compile's no_noise branch), but now @@ -230,7 +228,7 @@ def test_mixed_stabilizer_writes_a_scalar_not_a_list(op_cls): # --------------------------------------------------------------------------- -# Mutant: no stray stdout from a MixedStabilizer ClassicalCNOT compile. +# No stray stdout from a MixedStabilizer ClassicalCNOT compile. # --------------------------------------------------------------------------- diff --git a/tests/test_noise/test_noise_models_unsupported_backends.py b/tests/test_noise/test_noise_models_unsupported_backends.py index c1069ce2..ead068cf 100644 --- a/tests/test_noise/test_noise_models_unsupported_backends.py +++ b/tests/test_noise/test_noise_models_unsupported_backends.py @@ -35,12 +35,12 @@ def _state_with_mixed_graph_rep(): """ A QuantumState whose backing representation is a MixedGraph. - MixedGraph cannot reach QuantumState.rep_data through either public entry - point. The constructor's validate_data has no arm for it, and its rejection - path crashes with AttributeError while formatting the message. The - rep_data setter re-initialises the representation and Graph(data) raises - TypeError on a MixedGraph input. Assigning _rep_data directly is the only - way to construct this state. + The constructor rejects a MixedGraph with TypeError, and the rep_data + setter re-initialises the representation, where Graph(data) raises + TypeError on a MixedGraph. A MixedGraph rep_data can be reached publicly + only by converting a mixed stabilizer state with + convert_representation("g"); assigning _rep_data directly gives a + MixedGraph rep_data without that detour. :return: the state :rtype: QuantumState diff --git a/tests/test_noise/test_noise_placement.py b/tests/test_noise/test_noise_placement.py index 47cad791..6d497063 100644 --- a/tests/test_noise/test_noise_placement.py +++ b/tests/test_noise/test_noise_placement.py @@ -52,8 +52,8 @@ def _before_gate(noise): def _noisy_cnot_circuit(control_noise, target_noise): """H(0), then a CNOT(0, 1) carrying one noise model per leg. - Two things about the probe are load-bearing, and both were arrived at by - watching a weaker one pass against the broken compiler: + Two things about the probe are load-bearing; a weaker probe passes even + when the noise is placed on the wrong side of the gate: The Hadamard leaves qubit 0 in an X eigenstate, so the Pauli errors do not act as a global phase on a Z eigenstate -- which a density-matrix comparison @@ -115,9 +115,8 @@ def _compile_dm(circuit): # -------------------------------------------------------------------------- -# The two positions that agree. No coverage existed for either before this -# file, so these are written to pass against the unmodified compiler and -# then to pin that behaviour across any restructuring of the placement code. +# The two positions that agree. These pin the existing behaviour across any +# restructuring of the placement code. # -------------------------------------------------------------------------- @@ -188,12 +187,12 @@ def test_op_noise_restored_after_compile(): def test_op_noise_restored_when_gate_compilation_raises(monkeypatch): - """A failed compile must not leave the circuit poisoned. + """A compile that raises must leave the circuit usable. - Without an unwind, `op.noise` keeps a half-built list holding the - `nm.NoNoise` *class* in one slot. That slot then fails the - `isinstance(..., AdditionNoiseBase)` guard on the next compile, so a - circuit with nothing wrong with it reports a defect it does not have. + The raise comes from the first operation, before the CNOT's noise is split, so + this pins that the CNOT's noise list is untouched and that the circuit + compiles correctly afterwards. It does not reach the unwind around the + split; test_op_noise_restored_when_noise_application_raises does. """ circuit, cnot = _noisy_cnot_circuit( _before_gate(nm.PauliError("X")), nm.PauliError("Z") diff --git a/tests/test_state_representation/stabilizer/test_apply_x_measurement.py b/tests/test_state_representation/stabilizer/test_apply_x_measurement.py index 78213921..c989791f 100644 --- a/tests/test_state_representation/stabilizer/test_apply_x_measurement.py +++ b/tests/test_state_representation/stabilizer/test_apply_x_measurement.py @@ -84,9 +84,9 @@ def test_measuring_one_qubit_of_a_bell_pair_in_the_x_basis_leaves_the_other_corr def test_measuring_qubit_one_of_an_asymmetric_state_measures_that_qubit(): # The Bell case above cannot see which qubit was measured: that state is # symmetric under exchanging the two, so measuring either gives the same - # answer. H then CNOT then H on qubit 1 breaks the symmetry -- qubit 0 is - # so this pins that the position argument reaches the measurement rather - # than the method always measuring qubit 0. + # answer. H then CNOT then H on qubit 1 breaks the symmetry, so this pins + # that the position argument reaches the measurement rather than the + # method always measuring qubit 0. # # H, CNOT, H leaves (|0>|+> + |1>|->)/sqrt(2). Forcing qubit 1 onto the -1 # eigenstate of X selects the second branch, so the state collapses to diff --git a/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py b/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py index e20e98ca..8a1d0295 100644 --- a/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py +++ b/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py @@ -71,7 +71,7 @@ def test_the_setters_leave_the_phase_vectors_alone(): assert np.array_equal(tableau.iphase, [1, 0, 0, 0]) -def test_a_tableau_renders_both_halves_and_both_phases(): +def test_a_tableau_renders_both_halves(): # __str__ is the only caller of the label getters in the library, so this # is what keeps that path from breaking unnoticed. rendered = str(CliffordTableau(1)) diff --git a/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py b/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py index 5e5af642..ea5a7bb0 100644 --- a/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py +++ b/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py @@ -190,8 +190,8 @@ def _ghz4(): def test_partial_trace_of_multiple_qubits_forced_to_one_leaves_ket_1(): # sfc.partial_trace removes three qubits from a GHZ4 state in one call - # (each a remove_qubit under the hood), which is the multi-qubit path - # ~14 production call sites actually use. Forcing all three removed + # (each a remove_qubit under the hood), which is the multi-qubit removal + # path the solvers' emitter trace uses. Forcing all three removed # measurements to outcome 1 must leave the surviving qubit at |1>. tableau = partial_trace(_ghz4(), keep=[0], dims=4 * [2], measurement_determinism=1) diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index f524967f..4b40fdb3 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -97,9 +97,9 @@ def test_str_of_a_mixed_graph(): def test_graph_to_density_mixed_arm_reached_through_the_private_helper(): - # QuantumState.validate_data has no MixedGraph arm, so a MixedGraph cannot enter a - # QuantumState through the constructor at all -- the mixed-graph conversion path can only be - # reached by calling the private helper directly on an instance whose "mixed" flag is set. + # This calls the private helper directly on an instance whose "mixed" flag is set, since + # the constructor rejects a MixedGraph. The same arm is also reached publicly by converting + # a mixed stabilizer state to "g" and then to "dm". qs = QuantumState(nx.path_graph(3), rep_type="g", mixed=True) graph_a = nx.path_graph(3) graph_b = nx.cycle_graph(3) From 98c640a9294e7a7722b8279ecd7da0439967a9bc Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 10:43:15 -0400 Subject: [PATCH 128/227] [fix] Skip zero-weight eigenvectors when converting a mixed density matrix to stabilizers density_to_stabilizer eigen-decomposes a mixed input and converts the projector of every eigenvector, including those with eigenvalue zero. Those span the null space in an arbitrary basis and are not graph states, so the graph state validation always raised: no mixed input of any size converted. Eigenvectors with eigenvalue <= 1e-9 are now skipped, so a mixture of mutually orthogonal graph states with distinct weights converts. The docstring states that other mixtures (degenerate or non-orthogonal) are generally still rejected. QuantumState's dm -> s conversion with mixed=False keeps only the first term, which after this change would silently turn a mixed state into its lowest-weight branch. It now raises ValueError for a mixed density matrix unless the state was created with mixed=True. Such inputs raised AssertionError before, so no conversion that succeeded changes. A density matrix that is a single graph state with trace below 1 (0.9 |G> in place) now converts as well, where it raised AssertionError. It leaves one term, so the ValueError does not apply: with mixed=True it becomes a MixedStabilizer of weight 0.9, and with mixed=False the pure |G>, which drops the weight, so Infidelity against a stabilizer target |G> gives 0.0 for it rather than the 0.1 it lost. --- graphiq/backends/state_rep_conversion.py | 10 ++++ graphiq/state.py | 6 +++ tests/test_metrics/test_metrics.py | 35 +++++++++++++ .../test_state_rep_conversion.py | 52 +++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index b0fc2879..cf77cab3 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -314,8 +314,14 @@ def density_to_stabilizer(input_matrix): This works only for graph states, not for general stabilizer states that are not graph states + A mixed input is decomposed spectrally and each eigenvector with an eigenvalue above 1e-9 is + converted. This is guaranteed to succeed for a mixture of mutually orthogonal graph states + (with an all-zero phase vector) with distinct weights; the eigenvectors of any other mixture + generally are not such graph states, and it is then rejected. + :param input_matrix: a density matrix :type input_matrix: numpy.ndarray + :raise AssertionError: if input_matrix, or an eigenvector of a mixed input, is not a graph state :return: a stabilizer representation :rtype: list[(float, StabilizerTableau)] """ @@ -333,6 +339,10 @@ def density_to_stabilizer(input_matrix): eigenvalues, eigenvectors = linalg.eigh(input_matrix) stabilizer = [] for i in range(len(eigenvalues)): + # a zero-weight eigenvector is an arbitrary null-space vector, not a + # graph state, and does not contribute to the mixture + if eigenvalues[i] <= 1e-9: + continue v = eigenvectors[:, i] rho_i = np.outer(v, np.conjugate(v)) graph = density_to_graph(rho_i) diff --git a/graphiq/state.py b/graphiq/state.py index 97e3697b..e1eef1ed 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -327,6 +327,7 @@ def _density_to_stabilizer(self, rep): :param rep: density matrix representation :type rep: DensityMatrix + :raises ValueError: if the state is mixed but self.mixed is False :return: stabilizer representation :rtype: Stabilizer or MixedStabilizer """ @@ -339,6 +340,11 @@ def _density_to_stabilizer(self, rep): new_rep = MixedStabilizer(new_tab_list) else: + if len(new_data) != 1: + raise ValueError( + "Cannot convert a mixed density matrix to a stabilizer state with " + "mixed=False; create the QuantumState with mixed=True" + ) new_tableau = CliffordTableau(new_data[0][1]) new_rep = Stabilizer(new_tableau) return new_rep diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py index c304d3b9..fd5384e8 100644 --- a/tests/test_metrics/test_metrics.py +++ b/tests/test_metrics/test_metrics.py @@ -25,6 +25,10 @@ import graphiq.backends.density_matrix.functions as dmf import graphiq.circuit.ops as ops from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.backends.state_rep_conversion import graph_to_density from graphiq.circuit.circuit_dag import CircuitDAG from graphiq.state import QuantumState from graphiq.metrics import ( @@ -424,6 +428,37 @@ def test_infidelity_graph_state_marked_mixed_matches_stabilizer_target(): assert infidelity == pytest.approx(0.0, abs=1e-9) +def _empty_plus_triangle_mixture(): + # two orthogonal 3-qubit graph states with distinct weights + return 0.7 * graph_to_density(nx.empty_graph(3)) + 0.3 * graph_to_density( + nx.complete_graph(3) + ) + + +def test_infidelity_of_a_mixed_density_matrix_against_a_stabilizer_target(): + rho = _empty_plus_triangle_mixture() + state = QuantumState(rho, rep_type="dm", mixed=True) + target = QuantumState( + get_clifford_tableau_from_graph(nx.empty_graph(3)), rep_type="s" + ) + + infidelity = Infidelity(target=target).evaluate(state, None) + + expected = 1 - dmf.fidelity(graph_to_density(nx.empty_graph(3)), rho) + assert infidelity == pytest.approx(expected) + + +def test_infidelity_rejects_a_mixed_density_matrix_not_marked_mixed(): + # Converting it would keep one branch of the mixture and score only that branch. + state = QuantumState(_empty_plus_triangle_mixture(), rep_type="dm") + target = QuantumState( + get_clifford_tableau_from_graph(nx.empty_graph(3)), rep_type="s" + ) + + with pytest.raises(ValueError, match="mixed=True"): + Infidelity(target=target).evaluate(state, None) + + def test_trace_distance_stabilizer_state_against_its_own_density_matrix_is_zero(): # TraceDistance.evaluate only compares density matrices; a stabilizer-represented state is # converted to "dm" on the fly (a copy, so the original state is left untouched). diff --git a/tests/test_state_representation/test_state_rep_conversion.py b/tests/test_state_representation/test_state_rep_conversion.py index e30feb38..142ff299 100644 --- a/tests/test_state_representation/test_state_rep_conversion.py +++ b/tests/test_state_representation/test_state_rep_conversion.py @@ -21,7 +21,9 @@ import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.stabilizer.functions.linalg as sla import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.state import MixedStabilizer from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.state import QuantumState def test_position_finder_single_z_stabilized_qubit_needs_hadamard(): @@ -307,6 +309,56 @@ def test_density_to_graph_reads_each_branch_from_an_eigenvector_column(): ] +def _empty_plus_triangle_mixture(): + # 0.7 |empty> + 0.3 |triangle> on 3 qubits: two orthogonal graph states with distinct + # weights, so they are the eigenvectors of rho with nonzero eigenvalue. The other six + # eigenvalues are zero, and their eigenvectors are not graph states. + return 0.7 * rc.graph_to_density(nx.empty_graph(3)) + 0.3 * rc.graph_to_density( + nx.complete_graph(3) + ) + + +def test_density_to_stabilizer_converts_an_orthogonal_graph_state_mixture(): + rho = _empty_plus_triangle_mixture() + + stabilizer = rc.density_to_stabilizer(rho) + + assert len(stabilizer) == 2 + assert sorted(float(p_i) for p_i, _ in stabilizer) == pytest.approx([0.3, 0.7]) + assert np.allclose(rc.stabilizer_to_density(stabilizer), rho) + + +def test_mixed_density_matrix_state_converts_to_a_mixed_stabilizer(): + rho = _empty_plus_triangle_mixture() + state = QuantumState(rho, rep_type="dm", mixed=True) + + state.convert_representation("s") + + assert isinstance(state.rep_data, MixedStabilizer) + state.convert_representation("dm") + assert np.allclose(state.rep_data.data, rho) + + +def test_mixed_density_matrix_state_not_marked_mixed_is_not_converted_to_one_branch(): + # With mixed=False a stabilizer conversion can hold one tableau only; keeping one branch + # of a mixture would silently change the state. + state = QuantumState(_empty_plus_triangle_mixture(), rep_type="dm") + + with pytest.raises(ValueError, match="mixed=True"): + state.convert_representation("s") + + +def test_density_to_stabilizer_rejects_a_mixture_of_non_orthogonal_graph_states(): + # The empty and edge graphs are the only 2-qubit graph states, and they are not + # orthogonal, so no eigenvector of their mixture is a graph state. + rho = 0.6 * rc.graph_to_density(nx.empty_graph(2)) + 0.4 * rc.graph_to_density( + nx.complete_graph(2) + ) + + with pytest.raises(AssertionError, match="not a graph state"): + rc.density_to_stabilizer(rho) + + def test_state_to_graph_rejects_an_unrepresentable_input(): # Neither an adjacency matrix, a graph, nor a stabilizer/Clifford tableau: falls through # every isinstance check to the final else arm. From 4eeb6022e19c5ef4dc868f8a7f4703595cae4bbb Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 12:06:54 -0400 Subject: [PATCH 129/227] [fix] Package only graphiq, not the tests, examples and visualization directories pyproject.toml's [tool.setuptools.packages.find] had where = ["."] and no include. Its namespace discovery (on by default in pyproject.toml) takes every top-level directory as a package, so the wheel installed top-level tests, examples and visualization packages into site-packages next to graphiq. include = ["graphiq*"] limits discovery to graphiq and its subpackages. Namespace discovery stays on, because graphiq.data_collection has no __init__.py and relies on it. Nothing is lost for users: graphiq does not import those directories, and visualization's Flask app needs templates and static files the wheel never shipped, so it only ever ran from a checkout. Also drop py-modules = ["graphiq"]: graphiq is a package, not a module. It only made the build report that graphiq.py was not found, and the built wheel is the same without it. --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9fcc7c8f..0b2678a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,9 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools] -py-modules = ["graphiq"] - [tool.setuptools.packages.find] where = ["."] +include = ["graphiq*"] [project] name = "graphiq" From 7b86a24281bdb90a029f47d01e4c4d8e4fce7c91 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 13:20:50 -0400 Subject: [PATCH 130/227] [docs] Correct backend and state docstrings that disagree with the code Docstring-only; no code changes. - CompilerBase.compile: document initial_state and the errors the compile path actually raises (AssertionError, RuntimeError, ValueError, NotImplementedError); noise_simulation's setter raises ValueError. - DensityMatrix measurement: forced outcomes the state does not allow, and the ValueError for an unrecognised measurement_determinism. - z_measurement_gate: say what the third return value is. - is_lc_equivalent: False in 'random' mode may only mean no solution was sampled. - density_to_graph returns (weight, adjacency) pairs for a mixed input; stabilizer_to_graph returns networkx graphs; stabilizer_to_density accepts list[str], which carries no signs. - QuantumState: which representation is chosen when rep_type is None, and the errors the constructor, draw and validate_data raise. - MixedStabilizer.__eq__, StabilizerTableau, Graph.add_node, height_plotter and a few empty or misnamed :return:/:type: fields. --- graphiq/backends/compiler_base.py | 18 +++++++++++-- graphiq/backends/density_matrix/functions.py | 8 ++++-- graphiq/backends/density_matrix/state.py | 10 +++++++ graphiq/backends/graph/state.py | 3 ++- graphiq/backends/lc_equivalence_check.py | 6 +++-- .../backends/stabilizer/functions/clifford.py | 9 ++++--- .../backends/stabilizer/functions/height.py | 4 +-- graphiq/backends/stabilizer/state.py | 5 ++-- graphiq/backends/stabilizer/tableau.py | 3 ++- graphiq/backends/state_rep_conversion.py | 19 +++++++++++--- graphiq/state.py | 26 ++++++++++++++----- 11 files changed, 85 insertions(+), 26 deletions(-) diff --git a/graphiq/backends/compiler_base.py b/graphiq/backends/compiler_base.py index be3cd597..2b766b25 100644 --- a/graphiq/backends/compiler_base.py +++ b/graphiq/backends/compiler_base.py @@ -66,7 +66,10 @@ def noise_simulation(self, value): Set the flag for running noise. :param value: True or False - :return: + :type value: bool + :raises ValueError: if value is not a bool + :return: nothing + :rtype: None """ if isinstance(value, bool): self._noise_simulation = value @@ -80,7 +83,18 @@ def compile(self, circuit: CircuitBase, initial_state=None): :param circuit: the circuit to compile :type circuit: CircuitBase - :raises ValueError: if there is a circuit Operation which is incompatible with this compiler + :param initial_state: the state to start from; if None (or otherwise falsy), every register + starts in |0> + :type initial_state: QuantumState or None + :raises AssertionError: if initial_state is not a QuantumState, or its qubit count does + not match the circuit's + :raises RuntimeError: if the circuit contains an Operation class this compiler does not + support + :raises ValueError: if a controlled gate's two legs are not both an AdditionNoiseBase, if + an operation's noise is neither an AdditionNoiseBase nor a ReplacementNoiseBase, or if a + MeasurementZ carries a (non-NoNoise) AdditionNoiseBase + :raises NotImplementedError: if a MeasurementZ carries a ReplacementNoiseBase, for which + the compiler has no noisy arm :return: the state produced by the circuit :rtype: QuantumState """ diff --git a/graphiq/backends/density_matrix/functions.py b/graphiq/backends/density_matrix/functions.py index e3c20807..8f3457de 100644 --- a/graphiq/backends/density_matrix/functions.py +++ b/graphiq/backends/density_matrix/functions.py @@ -31,7 +31,8 @@ def dagger(matrix): Returns the hermitian conjugate of an input matrix. :param matrix: input matrix :type matrix: np.ndarray - :return: + :return: the conjugate transpose of matrix + :rtype: np.ndarray """ return matrix.conjugate().T @@ -257,7 +258,7 @@ def _get_multi_qubit_gate(n_qubits, target_gates_dict): :type n_qubits: int :param target_gates_dict: a dictionary where the key is the qubit position and the value is a single-qubit gate that is non-identity components of the final gate - :type target_gates_dct: dict + :type target_gates_dict: dict :return: the resulting matrix that acts on the whole state :rtype: numpy.ndarray """ @@ -938,6 +939,9 @@ def amplitude_damping_operators(damping_prob): """ Return two channel operators corresponding to the single-qubit amplitude damping channel. + :param damping_prob: the damping probability, in [0, 1] + :type damping_prob: float + :raises AssertionError: if damping_prob is outside [0, 1] :return: channel operator :rtype: (numpy.ndarray, numpy.ndarray) """ diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index 7bf5253f..1f9ce7cb 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -157,7 +157,12 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 + or p(1) >= 1 + In modes 0 and 1, a forced outcome the state does not allow returns + the other outcome :type measurement_determinism: str/int + :raises ValueError: if the density matrix of the state has a different size from the POVM elements, + or if measurement_determinism is not "probabilistic", 0, or 1 :return: the measurement outcome :rtype: int """ @@ -216,7 +221,12 @@ def apply_measurement_controlled_gate( :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 + or p(1) >= 1 + In modes 0 and 1, a forced outcome the state does not allow returns + the other outcome :type measurement_determinism: str/int + :raises ValueError: if the density matrix of the state has a different size from the POVM elements, + or if measurement_determinism is not "probabilistic", 0, or 1 :raises AssertionError: if target_gate has different dimensions from the density matrix of the state :return: the measurement outcome :rtype: int diff --git a/graphiq/backends/graph/state.py b/graphiq/backends/graph/state.py index 681a0efd..a227c90a 100644 --- a/graphiq/backends/graph/state.py +++ b/graphiq/backends/graph/state.py @@ -140,7 +140,8 @@ def add_node(self, node_to_add, lc_gate=None): :type node_to_add: int :param lc_gate: a list of local Clifford gates :type lc_gate: list(str) - :raises ValueError: if node_to_add is of an invalid datatype + :raises ValueError: if node_to_add is None, which networkx rejects as a node + :raises TypeError: if node_to_add is not hashable :return: nothing :rtype: None """ diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index 2ab69d5e..0963afaa 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -205,8 +205,10 @@ def is_lc_equivalent(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): reduced matrix is less than the rank of coefficient matrix, or if the number of linearly dependent columns is not equal to :math:`4n - rank` (for :math:`n` being the number of nodes in the graph) :raises ValueError: if ``mode`` is neither ``"deterministic"`` nor ``"random"`` - :return: If a solution is found, returns True and an array of single-qubit Clifford :math:`2 \\times 2` - matrices in the symplectic formalism. If not, graphs are not LC equivalent and returns False, None. + :return: ``(True, solution)`` if a solution is found, where ``solution`` is an array of single-qubit + Clifford :math:`2 \\times 2` matrices in the symplectic formalism, or ``(False, None)`` if none is found. + In ``'deterministic'`` mode ``False`` means the graphs are not LC equivalent; in ``'random'`` mode it + can also mean only that the sampling did not find a solution :rtype: bool, numpy.ndarray or None """ if mode not in ("deterministic", "random"): diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index 3ae42d27..a58285f3 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -46,8 +46,9 @@ def z_measurement_gate( select a specific outcome :type measurement_determinism: str or int :raises ValueError: if the outcome is random and measurement_determinism is not "probabilistic", 0 or 1 - :return: the resulting state after gate action, the measurement outcome, whether the measurement outcome is - probabilistic (zero means deterministic) + :return: the resulting state after gate action, the measurement outcome, and x_p, which says whether the + measurement outcome is probabilistic: zero means deterministic; otherwise the outcome is random and x_p is + the index (at least n_qubits) of the stabilizer row that anticommutes with the measurement :rtype: CliffordTableau, int, int """ n_qubits = tableau.n_qubits # number of qubits @@ -262,7 +263,7 @@ def reset_x( :param measurement_determinism: if the outcome is probabilistic from the simulation, we have the option to select a specific outcome :type measurement_determinism: str or int - :return: + :return: the tableau after the reset :rtype: CliffordTableau """ new_tableau = reset_z( @@ -290,7 +291,7 @@ def reset_y( :param measurement_determinism: if the outcome is probabilistic from the simulation, we have the option to select a specific outcome :type measurement_determinism: str or int - :return: + :return: the tableau after the reset :rtype: CliffordTableau """ new_tableau = reset_z( diff --git a/graphiq/backends/stabilizer/functions/height.py b/graphiq/backends/stabilizer/functions/height.py index 37f08bf4..aa562c32 100644 --- a/graphiq/backends/stabilizer/functions/height.py +++ b/graphiq/backends/stabilizer/functions/height.py @@ -164,8 +164,8 @@ def height_plotter(h_dict): :param h_dict: the height function dict which is the output of the ``height_dict``. :type h_dict: dict - :return: maximum of height function over all qubits. - :rtype: int + :return: nothing + :rtype: None """ h_max = h_dict[max(h_dict, key=h_dict.get)] positions = list(h_dict.keys()) diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index c080da14..7a15c3f9 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -807,8 +807,9 @@ def __eq__(self, other): :param other: the other MixedStabilizer to be compared :type other: MixedStabilizer - :return: True if the stabilizer tableaux of two MixedStabilizer objects - are the same; NotImplemented if other is not a MixedStabilizer + :return: True if the two mixtures have the same length and, after each is sorted by + probability, every pair of entries has the same probability (up to np.isclose) and + the same stabilizer tableau; NotImplemented if other is not a MixedStabilizer :rtype: bool or NotImplementedType """ if not isinstance(other, MixedStabilizer): diff --git a/graphiq/backends/stabilizer/tableau.py b/graphiq/backends/stabilizer/tableau.py index 59159480..a794eadc 100644 --- a/graphiq/backends/stabilizer/tableau.py +++ b/graphiq/backends/stabilizer/tableau.py @@ -112,7 +112,8 @@ def __init__(self, data, phase=None): :type data: int or numpy.ndarray or [numpy.ndarray, numpy.ndarray] :param phase: the phase vector, or None for an all-zero default :type phase: numpy.ndarray or list[int] or None - :raises ValueError: if phase is not None and its shape is not (n_qubits,) + :raises ValueError: if data is a list whose two matrices are not square 2-D arrays, or if phase is not None + and its shape is not (n_qubits,) """ if isinstance(data, int): self._table = np.hstack([np.zeros((data, data)), np.eye(data)]).astype(int) diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index cf77cab3..12d19d7f 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -46,6 +46,8 @@ def density_to_graph(input_matrix, threshold=0.1, validate=True): Converts a density matrix state representation to a graph representation via an adjacency matrix. It assumes qubit systems. + A mixed input is decomposed spectrally, and each eigenvector is converted to a graph weighted by its eigenvalue. + :param input_matrix: a density matrix to be converted to a graph :type input_matrix: numpy.ndarray :param threshold: a minimum threshold value of negativity for the state to assume entangled @@ -53,7 +55,8 @@ def density_to_graph(input_matrix, threshold=0.1, validate=True): :param validate: validate the input density is a graph state if True :type validate: bool :raise AssertionError: if input_matrix is not a valid graph state when validate is True - :return: an adjacency matrix representation for a pure state or a list of adjacency matrices for a mixed state + :return: an adjacency matrix representation for a pure state or a list of (weight, adjacency matrix) pairs + for a mixed state :rtype: numpy.ndarray or list[(float, numpy.ndarray)] """ if dmf.is_pure(input_matrix): @@ -146,6 +149,7 @@ def _graph_to_density_pure(graph_data): :param graph_data: the graph from which we will build a density matrix :type graph_data: networkX.Graph or numpy.ndarray + :raises TypeError: if graph_data is neither a networkx.Graph nor a numpy.ndarray :return: a density matrix as a numpy array :rtype: numpy.ndarray """ @@ -225,8 +229,9 @@ def stabilizer_to_graph(input_stabilizer, validate=True): :param validate: validate if input_stabilizer is a graph state when enabled :type validate: bool :raises AssertionError: if input stabilizer is not a graph state when validate is True + :raises ValueError: if input_stabilizer is neither a list nor a StabilizerTableau :return: a graph representation - :rtype: [(float, numpy.ndarray)] + :rtype: list[(float, networkx.Graph)] """ graph_list = [] if isinstance(input_stabilizer, list): @@ -255,8 +260,16 @@ def stabilizer_to_density(input_stabilizer): """ Convert a stabilizer to a density matrix + A list[str] input carries no signs: every generator is read with a +1 phase, and a sign character + such as "-" is rejected. Only a StabilizerTableau input keeps its phase vector. + :param input_stabilizer: the stabilizer representation - :type input_stabilizer: list[(float, StabilizerTableau)] or StabilizerTableau + :type input_stabilizer: list[(float, StabilizerTableau)] or StabilizerTableau or list[str] + :raises ValueError: if input_stabilizer is a list[str] with a character other than I, X, Y, Z + (case-insensitive), with generators of inconsistent lengths, or whose number of generators differs from + the number of qubits + :raises ValueError: if input_stabilizer is a list whose first element is neither str nor tuple, or if it is + itself neither a list nor a StabilizerTableau :return: a density matrix :rtype: numpy.ndarray """ diff --git a/graphiq/state.py b/graphiq/state.py index e1eef1ed..776bccb3 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -77,13 +77,20 @@ def __init__(self, data, rep_type=None, mixed=False): Density matrices representations support np.ndarray or int inputs Stabilizer representations take int or CliffordTableau Graph representations take networkx.Graph - :type data: list OR numpy.ndarray OR Graph OR nx.Graph or CliffordTableau + :type data: int OR list OR numpy.ndarray OR nx.Graph or CliffordTableau :param rep_type: selected representation to initialize; - if not specified, the default choice is the density matrix if the number of qubits is - less than the threshold value or stabilizer otherwise. + if not specified, the density matrix is chosen for int or numpy.ndarray data if the number + of qubits is less than the threshold value, otherwise the stabilizer for int or + CliffordTableau data, otherwise the graph for nx.Graph data. :type rep_type: str :param mixed: boolean flag to initialize as a mixed state or not (mainly used for Stabilizer rep_type) :type mixed: boolean + :raises TypeError: if validate_data rejects data + :raises AssertionError: if data is a non-square numpy.ndarray, or if the initialized + representation does not have the expected number of qubits + :raises ValueError: if rep_type is not a supported representation name, or if rep_type is None + and no representation can be chosen for data (including a numpy.ndarray with too many qubits + for the density matrix) :return: nothing :rtype: None """ @@ -193,8 +200,11 @@ def show(self, show=True, ax=None): :param show: if True, the state representation is plotted. Otherwise, it is drawn but not plotted :type show: bool - :param ax: axis/axes on which to plot the state representation + :param ax: axis/axes on which to plot the state representation; ignored for the density matrix + representation :type ax: matplotlib.Axis + :raises NotImplementedError: for the stabilizer representation or a mixed graph state + :raises ValueError: for an unknown representation type :return: fig, ax (the figure and axes on which data was plotted) :rtype: matplotlib.Figure, matplotlib.Axis """ @@ -378,6 +388,7 @@ def _stabilizer_to_graph(self, rep): :param rep: stabilizer representation :type rep: Stabilizer or MixedStabilizer + :raises AssertionError: if the state is not a graph state :return: graph representation :rtype: Graph or MixedGraph """ @@ -497,9 +508,10 @@ def validate_data(cls, data): Validate data type for input data :param data: input data - :type data: int or np.ndarray or CliffordTableau or nx.Graph - :return: True and the number of qubits if the data type is valid - :rtype: bool, int + :type data: int or np.ndarray or CliffordTableau or nx.Graph or list[(float, CliffordTableau)] + :raises AssertionError: if data is a numpy.ndarray that is not a square matrix + :return: True and the number of qubits if the data type is valid; False and None for an unsupported data type + :rtype: bool, int or None """ valid = True if isinstance(data, int): From b77611369960dbb5e0ec2283ee5662cbd87d53b5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 13:20:50 -0400 Subject: [PATCH 131/227] [docs] Add missing docstrings to the backends and state modules Docstring-only; no code changes. Adds module, class and method docstrings where they were missing in graphiq/backends and graphiq/state.py, each stating what the code does, including its defaults and the errors it raises. --- graphiq/__init__.py | 8 +++ graphiq/backends/density_matrix/__init__.py | 3 + graphiq/backends/density_matrix/state.py | 8 +++ graphiq/backends/graph/state.py | 30 ++++++++ graphiq/backends/stabilizer/__init__.py | 3 + .../backends/stabilizer/clifford_tableau.py | 31 ++++++++ .../functions/local_cliff_equi_check.py | 9 +++ graphiq/backends/stabilizer/state.py | 72 +++++++++++++++++++ graphiq/backends/stabilizer/tableau.py | 20 ++++++ graphiq/backends/state_base.py | 12 ++++ graphiq/state.py | 42 +++++++++++ 11 files changed, 238 insertions(+) diff --git a/graphiq/__init__.py b/graphiq/__init__.py index b0d565ba..7610c39b 100644 --- a/graphiq/__init__.py +++ b/graphiq/__init__.py @@ -12,6 +12,14 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +GraphiQ: a design framework for photonic graph state generation circuits. + +The names re-exported here make up the minimal pipeline: describe a target `QuantumState`, +search for a `CircuitDAG` that generates it with `TimeReversedSolver`, simulate that circuit +with `StabilizerCompiler`, and score the result with `Infidelity`. Everything else is reached +through the subpackages. +""" DENSITY_MATRIX_ARRAY_LIBRARY = "numpy" from graphiq.backends.stabilizer.compiler import StabilizerCompiler diff --git a/graphiq/backends/density_matrix/__init__.py b/graphiq/backends/density_matrix/__init__.py index 8446c64b..81747dd9 100644 --- a/graphiq/backends/density_matrix/__init__.py +++ b/graphiq/backends/density_matrix/__init__.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Density-matrix state representation and the array library it is built on. +""" from graphiq import DENSITY_MATRIX_ARRAY_LIBRARY # todo, reload numpy/jax if needed? possible package conflicts diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index 1f9ce7cb..278f2d5b 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -82,6 +82,14 @@ def from_graph(cls, graph): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + :param data: the candidate data + :type data: any + :return: True if data is an int or a numpy.ndarray + :rtype: bool + """ return isinstance(data, (int, np.ndarray)) @property diff --git a/graphiq/backends/graph/state.py b/graphiq/backends/graph/state.py index a227c90a..a7cb2cca 100644 --- a/graphiq/backends/graph/state.py +++ b/graphiq/backends/graph/state.py @@ -96,6 +96,12 @@ def valid_datatype(cls, data): @property def n_qubits(self): + """ + The number of qubits in the state, which is the number of nodes of the graph + + :return: the number of qubits + :rtype: int + """ return self.n_nodes def find_lc(self, node_id): @@ -324,6 +330,20 @@ class MixedGraph(StateRepresentationBase): """ def __init__(self, data, *args, **kwargs): + """ + Create a MixedGraph representation object + + A single Graph becomes a one-element mixture of probability 1.0. A bare + ``networkx.Graph`` is not accepted here; wrap it in a ``Graph`` first. + + :param data: a Graph to hold as a one-element mixture, or a ready mixture as a list of + (probability_i, Graph_i) + :type data: Graph or list[tuple] + :raises TypeError: if data is neither a Graph nor a list + :raises AssertionError: if a list entry is not a (float, Graph) pair + :return: nothing + :rtype: None + """ if isinstance(data, Graph): self._mixture = [ (1.0, data), @@ -340,6 +360,16 @@ def __init__(self, data, *args, **kwargs): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + A list is accepted only if every entry is a (float, Graph) pair. + + :param data: the candidate data + :type data: any + :return: True if data is a Graph or such a list + :rtype: bool + """ valid = isinstance(data, (Graph, list)) if isinstance(data, list): valid = valid and all( diff --git a/graphiq/backends/stabilizer/__init__.py b/graphiq/backends/stabilizer/__init__.py index 075a2598..6605def9 100644 --- a/graphiq/backends/stabilizer/__init__.py +++ b/graphiq/backends/stabilizer/__init__.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Stabilizer-formalism state representations, tableaux and their Clifford operations. +""" from graphiq.backends.stabilizer.functions.rep_conversion import ( clifford_from_stabilizer, ) diff --git a/graphiq/backends/stabilizer/clifford_tableau.py b/graphiq/backends/stabilizer/clifford_tableau.py index 76bb0ee2..749c7b10 100644 --- a/graphiq/backends/stabilizer/clifford_tableau.py +++ b/graphiq/backends/stabilizer/clifford_tableau.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +The Clifford tableau: destabilizer and stabilizer generators with their phase exponents. +""" import copy import numpy as np @@ -22,6 +25,12 @@ class CliffordTableau(TableauBase): + """ + A full Clifford tableau: destabilizer generators stacked above stabilizer generators, + with a phase vector (exponents of :math:`-1`) and a separate i-phase exponent vector + that tracks residual factors of :math:`i`. + """ + def __init__(self, data, phase=None, *args, **kwargs): """ Constructor for CliffordTableau @@ -83,6 +92,12 @@ def _initialize_phase(self, phase): self._iphase = np.zeros(2 * self.n_qubits).astype(int) def copy(self): + """ + A deep copy of the tableau, sharing no array with the original + + :return: an independent copy + :rtype: CliffordTableau + """ return copy.deepcopy(self) @property @@ -376,6 +391,22 @@ def to_stabilizer(self): return StabilizerTableau(self.stabilizer, self.phase[self.n_qubits :]) def _reset(self, new_table, new_phase, new_iphase): + """ + Replace the table, phase and i-phase together, re-deriving the qubit count and shape + + The table holds destabilizers and stabilizers stacked, so the qubit count is half its + row count. + + :param new_table: the replacement table, whose row count is twice the new qubit count + :type new_table: numpy.ndarray + :param new_phase: the replacement phase vector, of length 2 * n_qubits + :type new_phase: numpy.ndarray + :param new_iphase: the replacement i-phase exponent vector, of length 2 * n_qubits + :type new_iphase: numpy.ndarray + :raises ValueError: if new_phase or new_iphase does not match the new qubit count + :return: nothing + :rtype: None + """ new_n_qubits = int(new_table.shape[0] / 2) if new_phase.shape != (2 * new_n_qubits,): raise ValueError( diff --git a/graphiq/backends/stabilizer/functions/local_cliff_equi_check.py b/graphiq/backends/stabilizer/functions/local_cliff_equi_check.py index 0c680ae5..5214ccc5 100644 --- a/graphiq/backends/stabilizer/functions/local_cliff_equi_check.py +++ b/graphiq/backends/stabilizer/functions/local_cliff_equi_check.py @@ -12,6 +12,15 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Local-Clifford equivalence between two stabilizer states, and the gate sequences and circuits +that convert one into the other. + +``lc_check`` and ``state_converter_circuit`` take either state as a StabilizerTableau, a +CliffordTableau, a networkx graph or an adjacency matrix, and put both through +``state_to_graph`` first. ``converter_gate_list`` works on two graphs, and ``str_to_op`` turns +the resulting gate tuples into operation objects. +""" import networkx as nx import numpy as np diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index 7a15c3f9..0868ff92 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -26,7 +26,26 @@ class Stabilizer(StateRepresentationBase): + """ + A pure state representation using the stabilizer formalism, backed by a single + CliffordTableau. + + General unitaries are not supported; the state evolves by Clifford gates, Z and X + measurements, reset to |0>, and the qubit-removing operations (remove, trace out). For a + probability-weighted mixture of tableaus, see MixedStabilizer. + """ + def __init__(self, data, *args, **kwargs): + """ + Create a Stabilizer representation object + + :param data: the number of qubits to initialize in the all-zero state, or an existing + tableau to adopt (not copied) + :type data: int or CliffordTableau + :raises TypeError: if data is neither an int nor a CliffordTableau + :return: nothing + :rtype: None + """ super().__init__(data, *args, **kwargs) if isinstance(data, int): self._tableau = CliffordTableau(data) @@ -39,6 +58,14 @@ def __init__(self, data, *args, **kwargs): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + :param data: the candidate data + :type data: any + :return: True if data is an int or a CliffordTableau + :rtype: bool + """ return isinstance(data, (int, CliffordTableau)) @property @@ -101,6 +128,17 @@ def data(self, value): self.tableau = value def apply_unitary(self, qubit_position, unitary): + """ + Not supported: the stabilizer formalism represents Clifford operations only. + + :param qubit_position: the qubit the unitary would act on + :type qubit_position: int + :param unitary: the unitary that would be applied + :type unitary: numpy.ndarray + :raises NotImplementedError: always + :return: nothing + :rtype: None + """ raise NotImplementedError( "Stabilizer backend does not support general unitary operation." ) @@ -356,6 +394,19 @@ class MixedStabilizer(StateRepresentationBase): """ def __init__(self, data, *args, **kwargs): + """ + Create a MixedStabilizer representation object + + An int or a single tableau becomes a one-element mixture of probability 1.0. + + :param data: the number of qubits to initialize in the all-zero state, a single tableau, + or a ready mixture as a list of (probability_i, tableau_i) + :type data: int or CliffordTableau or list[tuple] + :raises TypeError: if data is none of those + :raises AssertionError: if a list entry is not a (float, CliffordTableau) pair + :return: nothing + :rtype: None + """ if isinstance(data, int): self._mixture = [ (1.0, CliffordTableau(data)), @@ -377,6 +428,16 @@ def __init__(self, data, *args, **kwargs): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + A list is accepted only if every entry is a (float, CliffordTableau) pair. + + :param data: the candidate data + :type data: any + :return: True if data is an int, a CliffordTableau, or such a list + :rtype: bool + """ valid = isinstance(data, (int, CliffordTableau, list)) if isinstance(data, list): valid = valid and all( @@ -510,6 +571,17 @@ def reduce(self): self._mixture = mixture_reduce def apply_unitary(self, qubit_position, unitary): + """ + Not supported: the stabilizer formalism represents Clifford operations only. + + :param qubit_position: the qubit the unitary would act on + :type qubit_position: int + :param unitary: the unitary that would be applied + :type unitary: numpy.ndarray + :raises NotImplementedError: always + :return: nothing + :rtype: None + """ raise NotImplementedError( "Stabilizer backend does not support general unitary operation." ) diff --git a/graphiq/backends/stabilizer/tableau.py b/graphiq/backends/stabilizer/tableau.py index a794eadc..d9ac557c 100644 --- a/graphiq/backends/stabilizer/tableau.py +++ b/graphiq/backends/stabilizer/tableau.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +The tableau base class and the stabilizer tableau: a binary symplectic table plus its phase vector. +""" import copy from abc import ABC @@ -151,6 +154,12 @@ def __init__(self, data, phase=None): self.shape = (self.n_qubits, 2 * self.n_qubits) def copy(self): + """ + A deep copy of the tableau, sharing no array with the original + + :return: an independent copy + :rtype: StabilizerTableau + """ return copy.deepcopy(self) @property @@ -258,6 +267,17 @@ def validate(self): return sfu.is_stabilizer(self._table) def _reset(self, new_table, new_phase): + """ + Replace the table and phase together, re-deriving the qubit count and shape + + :param new_table: the replacement table, whose row count gives the new qubit count + :type new_table: numpy.ndarray + :param new_phase: the replacement phase vector, of length n_qubits + :type new_phase: numpy.ndarray + :raises ValueError: if new_phase's shape does not match the new qubit count + :return: nothing + :rtype: None + """ new_n_qubits = int(new_table.shape[0]) if new_phase.shape != (new_n_qubits,): raise ValueError( diff --git a/graphiq/backends/state_base.py b/graphiq/backends/state_base.py index 75de8170..cb9535d2 100644 --- a/graphiq/backends/state_base.py +++ b/graphiq/backends/state_base.py @@ -74,4 +74,16 @@ def data(self, data): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + This base implementation raises, because the accepted datatypes are a property of + each concrete representation. Every subclass overrides it to return a bool. + + :param data: the candidate data + :type data: any + :raises ValueError: always, on the abstract base class + :return: nothing + :rtype: None + """ raise ValueError(f"Not implemented for abstract (base) class") diff --git a/graphiq/state.py b/graphiq/state.py index 776bccb3..93ce2f05 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -147,6 +147,24 @@ def rep_data(self): @rep_data.setter def rep_data(self, data): + """ + Replace the representation data, rebuilding it as the current rep_type + + The width is not changed: n_qubits keeps its value, and a density matrix, graph or + newly built stabilizer representation of a different size raises AssertionError. An + existing Stabilizer (or, when the state is mixed, MixedStabilizer) object is adopted as + is, without that check. + + :param data: data valid for the current rep_type + :type data: int or numpy.ndarray or CliffordTableau or list[(float, CliffordTableau)] or nx.Graph + or Graph (for "dm") or Stabilizer or MixedStabilizer + :raises AssertionError: if the new representation does not have n_qubits qubits, or if a + mixture list is malformed + :raises TypeError: if data is not a valid type for the current rep_type + :raises ValueError: if the data is not a valid density matrix, or if rep_type is None + :return: nothing + :rtype: None + """ self._rep_data = self._initialize_representation(self._rep_type, data) @property @@ -447,10 +465,34 @@ def _graph_to_stabilizer(self, rep): return new_rep def _identity_fun(self, rep): + """ + Helper function. Return a representation unchanged. + + Sits on the diagonal of the conversion table, where source and target representation + are the same. ``convert_representation`` does nothing when they match, so these + entries are never reached. + + :param rep: the representation to pass through + :type rep: DensityMatrix or Stabilizer or MixedStabilizer or Graph or MixedGraph + :return: the same representation, not a copy + :rtype: DensityMatrix or Stabilizer or MixedStabilizer or Graph or MixedGraph + """ return rep @staticmethod def _get_rep_type_name(rep_type): + """ + Normalize a representation-type alias to its canonical short name. + + Maps "s", "stab" and "stabilizer" to "s", "dm" and "density matrix" to "dm", and + "g" and "graph" to "g". None passes through as None. + + :param rep_type: a representation type or one of its aliases + :type rep_type: str or None + :raises ValueError: if rep_type is neither None nor a supported representation + :return: the canonical short name, or None + :rtype: str or None + """ if rep_type in ("s", "stab", "stabilizer"): return "s" elif rep_type in ("dm", "density matrix"): From 2a7a9c555cc0db690a732327e9ec2894d1bc305d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 13:20:50 -0400 Subject: [PATCH 132/227] [docs] Correct circuit, metric, noise and IO docstrings that disagree with the code Docstring-only; no code changes. - CircuitDAG.to_json: warn that operations without a name mapping (the parameterized gates, MeasurementZ, PhaseDagger, Identity) do not round-trip, and that from_json then raises TypeError. - OperationBase: the parameter names in the docstring now match the signature (q_registers_type, not photon/emitter registers). - OneQubitGateWrapper.unwrap: a list-valued noise is paired with operations by index before the reversal. - Output: the class docstring called it the Input operation. - Infidelity.evaluate: the AssertionError and ValueError cases. - AdditionNoiseBase: noise goes after the gate by default, not before. - IO save/load methods: document their parameters and returns. - time_depend_noise: fill in missing types and a wrong return type. --- graphiq/circuit/circuit_base.py | 7 ++++++- graphiq/circuit/circuit_dag.py | 9 +++++++++ graphiq/circuit/ops.py | 11 +++++++---- graphiq/io.py | 24 ++++++++++++++++++++++++ graphiq/metrics.py | 5 +++-- graphiq/noise/noise_models.py | 3 ++- graphiq/noise/time_depend_noise.py | 5 +++-- 7 files changed, 54 insertions(+), 10 deletions(-) diff --git a/graphiq/circuit/circuit_base.py b/graphiq/circuit/circuit_base.py index 3e9c1777..e93041c0 100644 --- a/graphiq/circuit/circuit_base.py +++ b/graphiq/circuit/circuit_base.py @@ -444,7 +444,12 @@ def map(self): return self._map def _default_map(self): - """Default map, in which each op is mapped to itself (no parameter sharing, except for copied ops)""" + """ + Default map, in which each op is mapped to itself (no parameter sharing, except for copied ops) + + :return: a mapping from the id of each op in the unwrapped sequence to itself + :rtype: dict + """ _map = {id(op): id(op) for op in self.sequence(unwrapped=True)} return _map diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index 3c317451..a3dbdbbc 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -72,8 +72,10 @@ def __init__( :param n_classical: the number of classical bits in the system :type n_classical: int :param openqasm_imports: None or an (ordered) dictionary, where the keys are import strings for openqasm + (the values are not used) :type openqasm_imports: a dictionary (with str keys) or None :param openqasm_defs: None or an (ordered) dictionary, where the keys are definitions strings for openqasm gates + (the values are not used) :type openqasm_defs: a dictionary (with str keys) or None :return: nothing :rtype: None @@ -1033,6 +1035,13 @@ def to_json(self): """ Function to convert circuit object to json data format. + .. warning:: + Operations for which ``ops.class_to_name_mapping`` returns None do not round-trip. These include + the parameterized gates (e.g. RX, RY, RZ, ParameterizedOneQubitRotation, + ParameterizedControlledRotationQubit) as well as MeasurementZ, PhaseDagger and Identity. Such an + operation is written with type None, or dropped from the op_list of a OneQubitGateWrapper, and gate + parameters are not stored. from_json then raises TypeError on a type None entry. + :return: circuit json object :rtype: dict """ diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 2a7017cc..3dbba5a3 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -78,8 +78,8 @@ def __init__( of registers the gate requires :type q_registers: tuple (tuple of: integers OR tuples of length 2) :param q_registers_type: tuple of strings, each is either 'p' (photonic qubit) or 'e' (emitter qubit) - :type q_registers: tuple (of str) - :param c_registers: same as photon/emitter_registers, but for the classical registers + :type q_registers_type: tuple (of str) + :param c_registers: same as q_registers, but for the classical registers :type c_registers: tuple (tuple of: integers OR tuples of length 2) :param noise: Noise model :type noise: graphiq.noise.noise_models.NoiseBase @@ -87,7 +87,8 @@ def __init__( :type params: tuple :param param_info: a dictionary that specifies all the information regarding parameters :type param_info: dict - :raises AssertionError: if photon_register, emitter_register, c_registers are not tuples, + :raises AssertionError: if q_registers, q_registers_type, c_registers are not tuples, + OR if q_registers and q_registers_type differ in length, OR if the elements of the tuple do not correspond to the notation described above :return: nothing :rtype: None @@ -619,6 +620,8 @@ def unwrap(self): Unwraps the Operation into a list of sub-operations (this can be useful for any operations which are composed of multiple other operations) in the reverse order, which corresponds to the order of applying gates + A list-valued ``noise`` is paired with ``operations`` by index before the reversal. + :return: a sequence of base operations (i.e. operations which are not compositions of other operations) :rtype: list """ @@ -1032,7 +1035,7 @@ def __init__(self, register=None, reg_type="e"): class Output(InputOutputOperationBase): """ - Input Operation. Serves as a placeholder in the circuit so that we know that this is the final operation on a + Output Operation. Serves as a placeholder in the circuit so that we know that this is the final operation on a qubit/cbit (i.e. there are no subsequent operations on it) """ diff --git a/graphiq/io.py b/graphiq/io.py index 56399338..90174625 100644 --- a/graphiq/io.py +++ b/graphiq/io.py @@ -282,6 +282,13 @@ def load_np_array(self, filename, complex_vals=False): def _save_json(variable, path): """ Helper method for saving to json files + + :param variable: the object to save + :type variable: JSON-serializable object + :param path: path of the file to which variable should be saved + :type path: str or pathlib.Path + :return: the function returns nothing + :rtype: None """ with open(path, "w+") as json_file: json.dump(variable, json_file, indent=4) @@ -290,6 +297,11 @@ def _save_json(variable, path): def _load_json(path): """ Helper method for loading from json files + + :param path: path of the file from which we are loading the object + :type path: str or pathlib.Path + :return: the object decoded from the file by json.load + :rtype: may vary """ with open(path) as json_file: data = json.load(json_file) @@ -299,6 +311,13 @@ def _load_json(path): def _save_txt(variable, path): """ Helper method for saving to text files + + :param variable: the text to save + :type variable: str + :param path: path of the file to which variable should be saved + :type path: str or pathlib.Path + :return: the function returns nothing + :rtype: None """ with open(path, "w") as txt_file: txt_file.write(variable) @@ -307,6 +326,11 @@ def _save_txt(variable, path): def _load_txt(path): """ Helper method for loading from text files + + :param path: path of the file from which we are loading the text + :type path: str or pathlib.Path + :return: the contents of the file + :rtype: str """ f = open(path, "r") data = f.read() diff --git a/graphiq/metrics.py b/graphiq/metrics.py index 32636072..a997e489 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -76,7 +76,6 @@ def __init__(self, target, log_steps=1, *args, **kwargs): :type target: QuantumState :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :raises AssertionError: if targe is not a valid density matrix :return: nothing :rtype: None """ @@ -110,7 +109,9 @@ def evaluate(self, state, circuit): :param circuit: circuit which generated state Not used for the fidelity evaluation, but argument is provided for API consistency :type circuit: CircuitBase (or subclass of it) - :raises AssertionError: if the state is not a valid density matrix + :raises AssertionError: if the target is a MixedStabilizer that is not one tableau with probability 1.0, + or, for a density matrix target, if the state or the target is not a valid density matrix + :raises ValueError: if the target's rep_type is neither "s" nor "dm" :return: infidelity = 1 - fidelity :rtype: float """ diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 91b5b8f4..37af1efb 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -89,7 +89,8 @@ def apply(self, state: QuantumState, n_quantum, reg_list): class AdditionNoiseBase(NoiseBase): """ - Base class for noise added before the operation + Base class for noise added to the operation: after it by default, or before it if + noise_parameters["After gate"] is False """ def __init__(self, noise_parameters=None): diff --git a/graphiq/noise/time_depend_noise.py b/graphiq/noise/time_depend_noise.py index a8392a62..927da822 100644 --- a/graphiq/noise/time_depend_noise.py +++ b/graphiq/noise/time_depend_noise.py @@ -285,7 +285,7 @@ def __init__(self, prob, nodes_and_regs): :type prob: float :param nodes_and_regs: a list [(node, (reg, reg_type), noise_type)] to specify where in the DAG error gate are supposed to be inserted and what type of error is used for that specific location. - :type nodes_and_regs: + :type nodes_and_regs: list[tuple] """ self._prob = prob self._nodes_and_regs = ( @@ -580,12 +580,13 @@ def cut_off_counter(cut_off_prob, time, rate): :param cut_off_prob: the minimum total probability of all events we want to consider :type cut_off_prob: float + :param time: the time duration :type time: float :param rate: the average rate of events happening per unit time :type rate: float :return: the maximum number of events needed to be considered, and the actual total probability of cases considered with this cut-off count. - :rtype: int + :rtype: tuple[int, float] """ cut_off_count = 0 prob_sum = poisson_prob(cut_off_count, time, rate) From 45e3c7dfe25ac371914da8b6d44747d1f79b127c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 13:20:50 -0400 Subject: [PATCH 133/227] [docs] Add missing docstrings to the circuit, metric, noise and visualizer modules Docstring-only; no code changes. Adds module, class and method docstrings where they were missing in graphiq/circuit, graphiq/metrics.py, graphiq/io.py, graphiq/noise and graphiq/visualizers. Among them: - Infidelity: with a stabilizer target and a MixedStabilizer state the value is 1 - sum_i p_i F_i, and photon loss can leave the weights summing to less than 1, so lost probability counts as infidelity. - OneQubitGateWrapper: how scalar noise is carried by unwrap, and that noise[i] goes with operations[i] while unwrap returns the operations in reverse. - RX, RY, RZ: the param_info shape the constructor accepts. - The controlled-pair operations: the AssertionError for a noise list whose length is not 2. --- graphiq/circuit/circuit_base.py | 10 + graphiq/circuit/circuit_dag.py | 37 +++ graphiq/circuit/ops.py | 314 ++++++++++++++++++ graphiq/circuit/register.py | 11 + graphiq/io.py | 4 + graphiq/metrics.py | 17 + graphiq/noise/model_parameters.py | 10 + graphiq/noise/monte_carlo_noise.py | 16 + graphiq/noise/noise_models.py | 37 +++ graphiq/noise/time_depend_noise.py | 12 + graphiq/visualizers/__init__.py | 5 + graphiq/visualizers/density_matrix.py | 4 + graphiq/visualizers/openqasm_visualization.py | 5 + graphiq/visualizers/solver_logs.py | 4 + 14 files changed, 486 insertions(+) diff --git a/graphiq/circuit/circuit_base.py b/graphiq/circuit/circuit_base.py index e93041c0..05840dc4 100644 --- a/graphiq/circuit/circuit_base.py +++ b/graphiq/circuit/circuit_base.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Base class (interface) that every circuit representation implements. +""" + import copy import random import warnings @@ -62,6 +66,12 @@ def __init__(self, openqasm_imports=None, openqasm_defs=None): @property def register(self): + """ + The register dictionary of the circuit, keyed by register type + + :return: a shallow copy of the register dictionary + :rtype: dict + """ return self._registers.register @property diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index a3dbdbbc..3da3009a 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -1196,6 +1196,23 @@ def assign_noise(self, noise_model_map): return empty_circ def _noisy_gates(self, noise_model_map): + """ + Helper function that returns the circuit's operations with a noise model attached to each + + Operates on a copy, so the circuit itself is not modified. Each OneQubitGateWrapper receives a list with one + noise model per gate returned by its ``unwrap``, in that order; note that the wrapper pairs ``noise[i]`` with + ``operations[i]``, and ``unwrap`` returns the operations in reverse. A controlled-pair operation receives its + section's list of two models, or its single model duplicated. Every other operation receives the model its + register section names. Operations without an entry get NoNoise. + + :param noise_model_map: a nested dict of register-type section to operation class name to noise model, as + accepted by ``assign_noise`` + :type noise_model_map: dict[str, dict] + :raises AssertionError: if a controlled-pair operation's noise list does not have length 2 + :raises KeyError: if noise_model_map has no entry for an operation's register-type section + :return: the operations (Input and Output excluded), each carrying its noise model + :rtype: list[ops.OperationBase] + """ # copy first: the noise is set on the ops in place, and these must not # be this circuit's own ops seq = self.copy()._slim_seq() @@ -1242,6 +1259,20 @@ def _noisy_gates(self, noise_model_map): return noisy_ops def _find_wrapped_noise(self, op_type_list, mapping): + """ + Helper function that looks up one noise model per gate wrapped inside a OneQubitGateWrapper + + A gate whose class name is absent from the mapping gets NoNoise, so the returned list always has the same + length as op_type_list. + + :param op_type_list: the classes of the wrapped gates; ``_noisy_gates`` passes them in the order returned by + the wrapper's ``unwrap`` + :type op_type_list: list[type] + :param mapping: one section of a noise model map, keyed by operation class name + :type mapping: dict + :return: a noise model for each entry of op_type_list, in the same order + :rtype: list[graphiq.noise.noise_models.NoiseBase] + """ noise_list = [] for op_type in op_type_list: op_name = op_type.__name__ @@ -1252,6 +1283,12 @@ def _find_wrapped_noise(self, op_type_list, mapping): return noise_list def _slim_seq(self): + """ + Helper function that returns the operation sequence with the Input and Output placeholders removed + + :return: the operations of the circuit, in topological order, with wrappers not unwrapped + :rtype: list[ops.OperationBase] + """ seq = self.sequence() length = len(seq) - 1 for i, op in enumerate(seq[::-1]): diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 3dbba5a3..44d53c64 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -601,6 +601,31 @@ class OneQubitGateWrapper(OneQubitOperationBase): """ def __init__(self, operations: list, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a wrapper treating a list of one-qubit operations as a single component + + A NoNoise given as a scalar is expanded to one NoNoise per wrapped operation. Any other scalar noise model is + stored as-is. For an addition-type model, ``unwrap`` then gives every wrapped gate NoNoise and carries that + model on a separate Identity operation, applied after the gates when its "After gate" parameter is True and + before them otherwise; ``unwrap`` raises KeyError for any other scalar model (one without an "After gate" + parameter). + + :param operations: the one-qubit operation classes to compose; ``unwrap`` applies them last entry first + :type operations: list[type] + :param register: the (quantum) register on which the composed operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: one noise model per wrapped operation, in the same order as ``operations`` (``noise[i]`` is + applied with ``operations[i]``), or a single model for the whole wrapper + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises ValueError: if operations is empty + :raises AssertionError: if any entry is an operation class that is not a OneQubitOperationBase subclass, or + is itself a OneQubitGateWrapper + :raises TypeError: if any entry is not a class (e.g. an operation instance) + :return: nothing + :rtype: None + """ if isinstance(noise, nm.NoNoise): noise = len(operations) * [nm.NoNoise()] super().__init__(register, reg_type, noise) @@ -651,6 +676,15 @@ def unwrap(self): return gates[::-1] def openqasm_info(self): + """ + Returns the information needed to generate an openQASM script using this operation + (needed imports, how to define a gate, how to use a gate), packaged as a single object. + + Unlike the class-level lookup on OperationBase, this returns the per-instance value built from the wrapped + operation list at construction time. + + :return: the openqasm information for this wrapper's operation list + """ return self._openqasm_info @@ -662,6 +696,18 @@ class Hadamard(OneQubitOperationBase): _openqasm_info = oq_lib.hadamard_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Hadamard gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -673,6 +719,18 @@ class SigmaX(OneQubitOperationBase): _openqasm_info = oq_lib.sigma_x_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Pauli X gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -684,6 +742,18 @@ class SigmaY(OneQubitOperationBase): _openqasm_info = oq_lib.sigma_y_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Pauli Y gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -695,6 +765,18 @@ class SigmaZ(OneQubitOperationBase): _openqasm_info = oq_lib.sigma_z_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Pauli Z gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -706,6 +788,18 @@ class Phase(OneQubitOperationBase): _openqasm_info = oq_lib.phase_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Phase (S) gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -717,6 +811,18 @@ class PhaseDagger(OneQubitOperationBase): _openqasm_info = oq_lib.phase_dagger_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Phase-dagger (S-dagger) gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -732,6 +838,25 @@ class ParameterizedOneQubitRotation(OneQubitOperationBase): def __init__( self, register=0, reg_type="e", noise=nm.NoNoise(), params=None, param_info=None ): + """ + Create a general parameterized one-qubit rotation, with angles (theta, phi, lambda) + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :param params: an ordered list of parameter values for the rotation angles; defaults to (0.0, 0.0, 0.0) + :type params: tuple + :param param_info: bounds and labels for each parameter; defaults to (-pi, pi) for each of theta, phi and + lambda + :type param_info: dict + :raises ValueError: if params has a length other than 3, or param_info is not a dict with 'bounds' and + 'labels' entries of length 3 + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) if params is None: @@ -773,6 +898,31 @@ def __init__( params=None, param_info=None, ): + """ + Create a parameterized two-qubit controlled rotation, with angles (theta, phi, lambda) + + :param control: control register/qubit for the Operation + :type control: int OR tuple (of ints, length 2) + :param control_type: 'p' if photonic qubit, 'e' if emitter qubit + :type control_type: str + :param target: target register/qubit for the Operation + :type target: int OR tuple (of ints, length 2) + :param target_type: 'p' if photonic qubit, 'e' if emitter qubit + :type target_type: str + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :param params: an ordered list of parameter values for the rotation angles; defaults to (0.0, 0.0, 0.0) + :type params: tuple + :param param_info: bounds and labels for each parameter; defaults to (-pi, pi) for each of theta, phi and + lambda + :type param_info: dict + :raises ValueError: if params has a length other than 3, or param_info is not a dict with 'bounds' and + 'labels' entries of length 3 + :raises AssertionError: if noise is a list whose length is not 2 + :return: nothing + :rtype: None + """ super().__init__(control, control_type, target, target_type, noise) if params is None: params = (0.0, 0.0, 0.0) @@ -803,6 +953,26 @@ class RY(ParameterizedOneQubitRotation): def __init__( self, register=0, reg_type="e", noise=nm.NoNoise(), params=None, param_info=None ): + """ + Create a rotation around the Y axis + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) + :type params: tuple + :param param_info: bounds and labels for the parameter; defaults to bounds (-pi, pi) and label 'theta'. A + dict passed here must wrap each in a length-1 sequence, e.g. {"bounds": ((-pi, pi),), "labels": + ("theta",)}; the stored default's own shape does not pass that check + :type param_info: dict + :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and + 'labels' entries of length 1 + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) if params is None: @@ -833,6 +1003,26 @@ class RX(ParameterizedOneQubitRotation): def __init__( self, register=0, reg_type="e", noise=nm.NoNoise(), params=None, param_info=None ): + """ + Create a rotation around the X axis + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) + :type params: tuple + :param param_info: bounds and labels for the parameter; defaults to bounds (-pi, pi) and label 'theta'. A + dict passed here must wrap each in a length-1 sequence, e.g. {"bounds": ((-pi, pi),), "labels": + ("theta",)}; the stored default's own shape does not pass that check + :type param_info: dict + :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and + 'labels' entries of length 1 + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) if params is None: @@ -865,6 +1055,26 @@ class RZ(ParameterizedOneQubitRotation): def __init__( self, register=0, reg_type="e", noise=nm.NoNoise(), params=None, param_info=None ): + """ + Create a rotation around the Z axis + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) + :type params: tuple + :param param_info: bounds and labels for the parameter; defaults to bounds (-pi, pi) and label 'phi'. A + dict passed here must wrap each in a length-1 sequence, e.g. {"bounds": ((-pi, pi),), "labels": + ("phi",)}; the stored default's own shape does not pass that check + :type param_info: dict + :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and + 'labels' entries of length 1 + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) if params is None: @@ -894,6 +1104,18 @@ class Identity(OneQubitOperationBase): _openqasm_info = oq_lib.empty_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create an Identity gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -907,6 +1129,24 @@ class CNOT(ControlledPairOperationBase): def __init__( self, control=0, control_type="e", target=0, target_type="e", noise=nm.NoNoise() ): + """ + Create a CNOT gate Operation + + :param control: control register/qubit for the Operation + :type control: int OR tuple (of ints, length 2) + :param control_type: 'p' if photonic qubit, 'e' if emitter qubit + :type control_type: str + :param target: target register/qubit for the Operation + :type target: int OR tuple (of ints, length 2) + :param target_type: 'p' if photonic qubit, 'e' if emitter qubit + :type target_type: str + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises AssertionError: if noise is a list whose length is not 2 + :return: nothing + :rtype: None + """ super().__init__(control, control_type, target, target_type, noise) @@ -920,6 +1160,24 @@ class CZ(ControlledPairOperationBase): def __init__( self, control=0, control_type="e", target=0, target_type="e", noise=nm.NoNoise() ): + """ + Create a CZ (controlled-Z) gate Operation + + :param control: control register/qubit for the Operation + :type control: int OR tuple (of ints, length 2) + :param control_type: 'p' if photonic qubit, 'e' if emitter qubit + :type control_type: str + :param target: target register/qubit for the Operation + :type target: int OR tuple (of ints, length 2) + :param target_type: 'p' if photonic qubit, 'e' if emitter qubit + :type target_type: str + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises AssertionError: if noise is a list whose length is not 2 + :return: nothing + :rtype: None + """ super().__init__(control, control_type, target, target_type, noise) @@ -955,6 +1213,27 @@ def __init__( c_register: object = 0, noise: object = nm.NoNoise(), ) -> object: + """ + Create an Operation that measures the control in the Z basis, applies X to the target conditioned on the + outcome, records the outcome in the classical register, and resets the control + + :param control: the control register/qubit + :type control: int OR tuple (of ints, length 2) + :param control_type: 'p' if photonic qubit, 'e' if emitter qubit + :type control_type: str + :param target: target register/qubit for the Operation + :type target: int OR tuple (of ints, length 2) + :param target_type: 'p' if photonic qubit, 'e' if emitter qubit + :type target_type: str + :param c_register: the classical register/cbit + :type c_register: int OR tuple (of ints, length 2) + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises AssertionError: if noise is a list whose length is not 2 + :return: nothing + :rtype: None + """ super().__init__(control, control_type, target, target_type, c_register, noise) @@ -1030,6 +1309,19 @@ class Input(InputOutputOperationBase): """ def __init__(self, register=None, reg_type="e"): + """ + Create an Input placeholder Operation + + :param register: the register/qubit which this I/O operation acts on + :type register: int OR tuple (of ints, length 2) + :param reg_type: the input/output is for a quantum photonic register if 'p', + for a quantum emitter register if 'e', + and for a classical register if 'c' + :type reg_type: str + :raises ValueError: if reg_type is not 'p', 'e' or 'c' + :return: nothing + :rtype: None + """ super().__init__(register, reg_type=reg_type) @@ -1040,6 +1332,19 @@ class Output(InputOutputOperationBase): """ def __init__(self, register=None, reg_type="e"): + """ + Create an Output placeholder Operation + + :param register: the register/qubit which this I/O operation acts on + :type register: int OR tuple (of ints, length 2) + :param reg_type: the input/output is for a quantum photonic register if 'p', + for a quantum emitter register if 'e', + and for a classical register if 'c' + :type reg_type: str + :raises ValueError: if reg_type is not 'p', 'e' or 'c' + :return: nothing + :rtype: None + """ super().__init__(register, reg_type=reg_type) @@ -1047,6 +1352,15 @@ def __init__(self, register=None, reg_type="e"): def local_clifford_composition(): + """ + Returns the two gate-sequence lists whose products generate every one-qubit local Clifford + + Concatenating one entry of the first list with one entry of the second gives each of the 24 one-qubit Cliffords + (up to a global phase) exactly once, which is how ``one_qubit_cliffords`` enumerates them. + + :return: the six-entry list and the four-entry list of gate-class sequences + :rtype: tuple[list, list] + """ a = [ [Identity], [Hadamard, Phase, Hadamard, Phase], diff --git a/graphiq/circuit/register.py b/graphiq/circuit/register.py index d5ec2792..3afac85d 100644 --- a/graphiq/circuit/register.py +++ b/graphiq/circuit/register.py @@ -12,6 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Register bookkeeping for circuits: which qubit and cbit registers exist, and their sizes. +""" + + class Register: """ Register class object, the class includes a dictionary which map the register type as the key and the register array @@ -76,6 +81,12 @@ def __setitem__(self, key, value): @property def n_quantum(self): + """ + The total number of quantum registers, counting every register type except classical ('c') + + :return: the number of quantum registers + :rtype: int + """ q_sum = 0 for key in self._registers: diff --git a/graphiq/io.py b/graphiq/io.py index 90174625..edfd37d3 100644 --- a/graphiq/io.py +++ b/graphiq/io.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Saving and loading of data, figures and dataframes, with consistent file types and folder naming. +""" + import datetime import json import os diff --git a/graphiq/metrics.py b/graphiq/metrics.py index a997e489..f32035dd 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -68,6 +68,15 @@ def increment(self): class Infidelity(MetricBase): + """ + Infidelity of a state with respect to a fixed target state, 1 - F. + + Zero means the state matches the target. With a stabilizer target and a MixedStabilizer state, the value is + 1 - sum_i p_i F_i over the mixture's tableaux. The weights p_i need not sum to 1 (photon loss lowers them), so + missing probability counts as infidelity. The target must be in the stabilizer ("s") or density matrix ("dm") + representation. + """ + def __init__(self, target, log_steps=1, *args, **kwargs): """ Creates an Infidelity Metric object, which computes 1-fidelity with respect to the ideal_state @@ -156,6 +165,14 @@ def evaluate(self, state, circuit): class TraceDistance(MetricBase): + """ + Trace distance between a state and a fixed target state. + + Zero means the states are identical and one means they are perfectly distinguishable. The evaluated state is + converted to a density matrix if it is not one already, but the target is not: a target held in any other + representation is rejected. + """ + def __init__(self, target, log_steps=1, *args, **kwargs): """ Creates a TraceDistance Metric object, which computes the trace distance between the current state and the diff --git a/graphiq/noise/model_parameters.py b/graphiq/noise/model_parameters.py index e55ea85d..09260f00 100644 --- a/graphiq/noise/model_parameters.py +++ b/graphiq/noise/model_parameters.py @@ -12,6 +12,16 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Default gate-duration and noise-parameter data consumed by :mod:`graphiq.noise.time_depend_noise`. + +``gate_duration_dict`` gives each operation type it lists a duration of 1 (0 for ``Input`` and ``Output``); types +it does not list, such as ``PhaseDagger`` or ``OneQubitGateWrapper``, have no default duration. +``noise_parameters`` is the default noise-parameter dict, merged into any partial one a caller supplies; +``error_ops`` maps the ``"x"``/``"y"``/``"z"``/``"I"`` noise-type letters to the operation class ``insert_error`` +inserts for each. ``equal_time_dict`` builds a gate-duration dict with one duration for every given operation type. +""" + import graphiq.circuit.ops as ops diff --git a/graphiq/noise/monte_carlo_noise.py b/graphiq/noise/monte_carlo_noise.py index 0b4f9e12..949650b2 100644 --- a/graphiq/noise/monte_carlo_noise.py +++ b/graphiq/noise/monte_carlo_noise.py @@ -366,6 +366,22 @@ def _get_rnd_noise_obj(self, reg_type, gate_name): def parallel_monte_carlo(mc: MonteCarloNoise, n_parallel, n_single): + """ + Run a MonteCarloNoise simulation as n_parallel ray tasks of n_single samples each + + The first task uses ``mc.seed`` (0 if it is None) and the others use n_parallel - 1 distinct seeds drawn from + ``mc.rng`` in [0, 100 * n_parallel). ``mc.n_sample`` is restored afterwards, and ``mc.all_scores`` is set to + every task's scores, pooled in task order. Requires the optional ``ray`` dependency. + + :param mc: the Monte Carlo simulation to run + :type mc: MonteCarloNoise + :param n_parallel: the number of ray tasks + :type n_parallel: int + :param n_single: the number of samples in each task + :type n_single: int + :return: the average infidelity over all n_parallel * n_single samples + :rtype: float + """ import ray rnd_array = mc.rng.choice(n_parallel * 100, n_parallel - 1, replace=False) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 37af1efb..5fac5aaf 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -94,6 +94,17 @@ class AdditionNoiseBase(NoiseBase): """ def __init__(self, noise_parameters=None): + """ + Initialize a base class for noise added before or after a gate + + If ``noise_parameters`` is None or not a dict, it is replaced with ``{"After gate": True}``. Otherwise, an + ``"After gate"`` key set to True is added to it when not already present. + + :param noise_parameters: a dictionary of parameters to describe the noise model + :type noise_parameters: dict or None + :return: nothing + :rtype: None + """ if noise_parameters is None or type(noise_parameters) is not dict: noise_parameters = {"After gate": True} else: @@ -125,6 +136,15 @@ class ReplacementNoiseBase(NoiseBase): """ def __init__(self, noise_parameters=None): + """ + Initialize a base class for noisy gate that replaces the original gate + + :param noise_parameters: a dictionary of parameters to describe the noise model, or None for an empty + dictionary + :type noise_parameters: dict or None + :return: nothing + :rtype: None + """ super().__init__(noise_parameters) def apply(self, state: QuantumState, n_quantum, reg_list): @@ -767,6 +787,14 @@ class PhotonLoss(AdditionNoiseBase): """ def __init__(self, loss_rate): + """ + Construct a PhotonLoss object + + :param loss_rate: the probability that a photon is lost while undergoing this gate + :type loss_rate: float + :return: nothing + :rtype: None + """ noise_parameters = {"loss rate": loss_rate} super().__init__(noise_parameters) @@ -1046,6 +1074,15 @@ class ResetError(NoiseBase): """ def __init__(self, noise_parameters=None): + """ + Construct a ResetError object + + :param noise_parameters: a dictionary of parameters to describe the noise model, or None for an empty + dictionary + :type noise_parameters: dict or None + :return: nothing + :rtype: None + """ super().__init__(noise_parameters) def apply(self, state: QuantumState, n_quantum, reg_list): diff --git a/graphiq/noise/time_depend_noise.py b/graphiq/noise/time_depend_noise.py index 927da822..651d6c55 100644 --- a/graphiq/noise/time_depend_noise.py +++ b/graphiq/noise/time_depend_noise.py @@ -12,6 +12,18 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Time-dependent noise as a Poisson process over the gate durations of an ideal circuit. + +``NoisyEnsemble`` partitions each noisy register's operation history into sections (``section_finder``), finds the +probability of each placement of errors across those sections from the section durations and an error rate +(``prob_tree_finder``, using a Poisson distribution -- ``poisson_prob`` -- truncated at a cut-off probability, +``cut_off_counter``), and expresses the result as a weighted list of ``NoiseEvent`` objects (``to_event_tree``), one +event tree per noisy register and noise type, all merged into one (``unwrapper``). ``insert_error`` turns an event +tree into a list of noisy circuits and their probabilities -- a "circuit tree" -- by inserting the corresponding error +operation, from ``graphiq.noise.model_parameters.error_ops``, into a copy of the ideal circuit. +""" + import copy import math from itertools import combinations_with_replacement diff --git a/graphiq/visualizers/__init__.py b/graphiq/visualizers/__init__.py index a5ef896b..e5f7c6e9 100644 --- a/graphiq/visualizers/__init__.py +++ b/graphiq/visualizers/__init__.py @@ -12,6 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Matplotlib-based visualization helpers: density-matrix heatmaps and bar plots, graph drawing, solver-log plots, +and openqasm-driven circuit diagrams. +""" + from .density_matrix import density_matrix_bars, density_matrix_heatmap from .graph import * from .solver_logs import * diff --git a/graphiq/visualizers/density_matrix.py b/graphiq/visualizers/density_matrix.py index c4931073..57428c46 100644 --- a/graphiq/visualizers/density_matrix.py +++ b/graphiq/visualizers/density_matrix.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Plots a density matrix's real and imaginary parts side by side, either as 2D heatmaps or as 3D bar plots. +""" + import matplotlib.pyplot as plt import numpy as np import seaborn as sns diff --git a/graphiq/visualizers/openqasm_visualization.py b/graphiq/visualizers/openqasm_visualization.py index 8f623645..e2b8dad5 100644 --- a/graphiq/visualizers/openqasm_visualization.py +++ b/graphiq/visualizers/openqasm_visualization.py @@ -12,6 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Renders an openqasm 2 circuit description as a circuit diagram via Qiskit's circuit drawer. +:meth:`graphiq.circuit.circuit_base.CircuitBase.draw_circuit` uses :func:`draw_openqasm` to draw circuits. +""" + import matplotlib.pyplot as plt from qiskit import QuantumCircuit diff --git a/graphiq/visualizers/solver_logs.py b/graphiq/visualizers/solver_logs.py index 9531a82d..c8175f27 100644 --- a/graphiq/visualizers/solver_logs.py +++ b/graphiq/visualizers/solver_logs.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Plots a solver's per-iteration logs (cost value and circuit depth) as a 2x2 grid of subplots. +""" + import matplotlib.pyplot as plt From 0a922576957522dfa407da38f0f1e5e949fd4d6e Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 13:20:50 -0400 Subject: [PATCH 134/227] [docs] Correct solver, benchmark and utility docstrings that disagree with the code Docstring-only; no code changes. - benchmarks/pipeline: run_combinations takes solvers and targets as (class, attributes) pairs and compilers and metrics as classes, and benchmark_graph_search_solver runs serially. - benchmarks/circuits: document the (circuit, ideal state) returns, and the GHZ variational circuit has ``layers`` layers, not one. - AlternateTargetSolver: the TypeError and ValueError the constructor raises. - SolverBase.seed: the recorded seed is process-wide, and what it does and does not govern. - HybridEvolutionarySolver: drop parameters the constructor does not take. - TimeReversedSolver: the index arguments and where MeasurementCNOTandReset is inserted. - relabel_module: node-label requirements, orbit output, and that _compare_graphs_visual always raises at its label check. - preprocessing, draw, openqasm_lib and evolutionary_solver: defaults, exact-type checks, typos and empty :return: fields. --- graphiq/benchmarks/circuits.py | 25 +++++++++++++++++- graphiq/benchmarks/pipeline.py | 16 +++++++----- graphiq/solvers/alternate_target_solver.py | 4 +++ graphiq/solvers/evolutionary_solver.py | 9 +++++-- graphiq/solvers/hybrid_solvers.py | 10 +------- graphiq/solvers/solver_base.py | 9 +++++++ graphiq/solvers/time_reversed_solver.py | 12 ++++----- graphiq/utils/draw.py | 8 +++--- graphiq/utils/openqasm_lib.py | 4 +++ graphiq/utils/preprocessing.py | 10 +++++--- graphiq/utils/relabel_module.py | 30 +++++++++++++++++----- 11 files changed, 99 insertions(+), 38 deletions(-) diff --git a/graphiq/benchmarks/circuits.py b/graphiq/benchmarks/circuits.py index 6eba20da..fb01bde9 100644 --- a/graphiq/benchmarks/circuits.py +++ b/graphiq/benchmarks/circuits.py @@ -37,6 +37,10 @@ def bell_state_circuit(): """ Two-qubit Bell state preparation circuit + + :return: the circuit that prepares the Bell state, and the ideal Bell state as a QuantumState in density + matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ rho = ket2dm((tensor(2 * [state_ketz0()]) + tensor(2 * [state_ketz1()]))) / 2.0 ideal_state = QuantumState(rho, rep_type="dm") @@ -49,6 +53,10 @@ def bell_state_circuit(): def ghz3_state_circuit(): """ Three-qubit GHZ state + + :return: the circuit that prepares the 3-qubit GHZ state, and the ideal GHZ state as a QuantumState in density + matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ rho = ket2dm((tensor(3 * [state_ketz0()]) + tensor(3 * [state_ketz1()]))) / 2.0 ideal_state = QuantumState(rho, rep_type="dm") @@ -84,6 +92,10 @@ def ghz3_state_circuit(): def ghz4_state_circuit(): """ Four-qubit GHZ state + + :return: the circuit that prepares the 4-qubit GHZ state, and the ideal GHZ state as a QuantumState in density + matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ rho = ket2dm((tensor(4 * [state_ketz0()]) + tensor(4 * [state_ketz1()]))) / 2.0 @@ -122,6 +134,10 @@ def ghz4_state_circuit(): def linear_cluster_3qubit_circuit(): """ Three-qubit linear cluster state + + :return: the circuit that prepares the 3-qubit linear cluster state, and the ideal linear cluster state as a + QuantumState in density matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ graph = nx.Graph([(1, 2), (2, 3)]) state = DensityMatrix.from_graph(graph) @@ -151,6 +167,10 @@ def linear_cluster_3qubit_circuit(): def linear_cluster_4qubit_circuit(): """ Four-qubit linear cluster state + + :return: the circuit that prepares the 4-qubit linear cluster state, and the ideal linear cluster state as a + QuantumState in density matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ graph = nx.Graph([(1, 2), (2, 3), (3, 4)]) state = DensityMatrix.from_graph(graph) @@ -184,6 +204,9 @@ def variational_entangling_layer_2qubit(): """ Variational circuit composed of one local rotation and entangling rotation gate, with the ideal state being a Bell state. + + :return: the circuit, and the ideal Bell state as a QuantumState in density matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ ket = dmf.tensor([dmf.state_ketz0(), dmf.state_ketz0()]) + dmf.tensor( [dmf.state_ketz1(), dmf.state_ketz1()] @@ -204,7 +227,7 @@ def variational_entangling_layer_2qubit(): def strongly_entangling_layer(n_qubits=3, layers=1): """ - Variational circuit composed of a single entangling layer. Ideal state is an n-qubit GHZ state. + Variational circuit composed of ``layers`` entangling layers. Ideal state is an n-qubit GHZ state. """ ket = ( diff --git a/graphiq/benchmarks/pipeline.py b/graphiq/benchmarks/pipeline.py index 856c0ce3..89f8f545 100644 --- a/graphiq/benchmarks/pipeline.py +++ b/graphiq/benchmarks/pipeline.py @@ -42,13 +42,15 @@ def run_combinations(solvers, targets, compilers, metrics): """ Produces a benchmarking run for all possible combinations from lists of solvers, targets, compilers, and metrics. - :param solvers: a list of Solver classes (not instanced) - :type solvers: list - :param targets: a list of the target states - :type targets: list - :param compilers: list of compilers to use + :param solvers: a list of (Solver, solver_attr) pairs, where Solver is a solver class (not instanced) and + solver_attr is a dictionary of extra keyword arguments passed to its constructor + :type solvers: list[tuple] + :param targets: a list of (target, target_attr) pairs, where target is the target state and target_attr is a + dictionary providing the "n_photon" and "n_emitter" values passed to the solver + :type targets: list[tuple] + :param compilers: a list of compiler classes (not instanced); each is instantiated with no arguments :type compilers: list - :param metrics: list of metrics to use + :param metrics: a list of metric classes (not instanced); each is instantiated as Metric(target=target) :type metrics: list :return: a dictionary mapping each run name ("run0", "run1", ...) to a dictionary defining a single benchmarking run @@ -223,7 +225,7 @@ def benchmark_run_graph_search_solver(run, name, io, circuit_format="qasm"): def benchmark_graph_search_solver(runs, io, circuit_format="qasm"): """ - Runs a sequence of benchmark runs, either using parallel or serial processing. + Runs a sequence of benchmark runs serially. :param runs: a dictionary mapping each run name to a run, each being a dictionary containing the important class instances (solvers, etc.) diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 056fe0f3..957347ac 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -86,6 +86,10 @@ def __init__( :type solver_setting: AlternateTargetSolverSetting or None :param seed: a random seed :type seed: int + :raises TypeError: if noise_model_mapping is not exactly a dict (subclasses are rejected), the string + "depolarizing", or None + :raises ValueError: if target is a mixed QuantumState, or a QuantumState whose rep_type is not + "s", "dm", or "g" """ if solver_setting is None: solver_setting = AlternateTargetSolverSetting( diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index 87162477..813d5c9c 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -473,7 +473,8 @@ def update_logs(self, population: list, iteration: int): :type population: list :param iteration: iteration integer, from 0 to n_stop-1 :type iteration: int - :return: + :return: nothing + :rtype: None """ # get the scores from the population/hof as a list scores_pop = list(zip(*population))[0] @@ -517,9 +518,13 @@ def save_circuits(self, population: list, hof: list, iteration: int = -1): Saves the population and/or the HoF circuits over iterations as openQASM strings. :param population: list of (score, circuit) pairs + :type population: list :param hof: list of (score, circuit) pairs + :type hof: list :param iteration: integer step in the solver algorithm - :return: + :type iteration: int + :return: nothing + :rtype: None """ if self.io is None or self.setting.save_openqasm == "none": return diff --git a/graphiq/solvers/hybrid_solvers.py b/graphiq/solvers/hybrid_solvers.py index 903848ff..24189212 100644 --- a/graphiq/solvers/hybrid_solvers.py +++ b/graphiq/solvers/hybrid_solvers.py @@ -60,18 +60,10 @@ def __init__( :type compiler: CompilerBase :param io: input/output object for saving logs, intermediate results, circuits, etc. :type io: IO - :param n_hof: the size of the hall of fame (hof) - :type n_hof: int - :param selection_active: use selection in the evolutionary algorithm - :type selection_active: bool - :param use_adapt_probability: use adapted probability in the evolutionary algorithm - :type use_adapt_probability: bool - :param save_openqasm: save population, hof, or both to openQASM strings (options: None, "hof", "pop", "both") - :type save_openqasm: str, None :param solver_setting: the solver's settings; a fresh default is built when None :type solver_setting: EvolutionarySolverSetting or None :param noise_model_mapping: a dictionary that associates each operation type to a noise model - :type noise_model_mapping: dict + :type noise_model_mapping: dict or None """ if solver_setting is None: solver_setting = EvolutionarySolverSetting() diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index 8c948910..607e32ee 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -82,6 +82,15 @@ def seed(seed=None): Sets the seed for both the numpy.random and random packages. Accessible by any Solver subclass + The seed is recorded in last_seed, a class-level attribute on SolverBase (reported as + solver_info["seed"] by the solvers that log it). Like the numpy.random and random seeds it + sets, it is shared by the whole process: a solver reports the seed most recently set by any + solver, not necessarily its own. Measurement outcomes in the stabilizer and density matrix + backends are drawn from the global numpy.random state, so this seed does govern them. + It does not reach components that build their own unseeded np.random.default_rng() + (for example, AlternateTargetSolver's Monte Carlo seeding when a seed is missing, and the + random graph generators in benchmarks/graph_states.py when no np_rng is given). + :param seed: a random number seed :type seed: int or None :return: nothing diff --git a/graphiq/solvers/time_reversed_solver.py b/graphiq/solvers/time_reversed_solver.py index b4dec679..59f8ffe9 100644 --- a/graphiq/solvers/time_reversed_solver.py +++ b/graphiq/solvers/time_reversed_solver.py @@ -547,9 +547,9 @@ def _add_emitter_photon_cnot(self, circuit, emitter_index, photon_index): :param circuit: a quantum circuit :type circuit: CircuitDAG - :param emitter_index: the index of the emitter qubit + :param emitter_index: the emitter's index relative to the emitter block :type emitter_index: int - :param photon_index: the index of the photonic qubit + :param photon_index: the photon's index :type photon_index: int :return: nothing :rtype: None @@ -625,14 +625,14 @@ def _single_out_emitter(self, circuit, tableau, generator_index, emitter_index): def _add_measurement_cnot_and_reset(self, circuit, emitter_index, photon_index): """ - Add a MeasurementCNOTandReset operation from an emitter qubit to a photonic qubit such that no consecutive - MeasurementCNOTReset is allowed. This operation cannot be added before the photonic qubit is initialized. + Add a MeasurementCNOTandReset operation from an emitter qubit to a photonic qubit. The operation is inserted + right after the input nodes of both qubits, i.e. at the start of the circuit; no ordering rules are checked. :param circuit: a quantum circuit :type circuit: CircuitDAG - :param emitter_index: the index of the emitter qubit + :param emitter_index: the emitter's index relative to the emitter block :type emitter_index: int - :param photon_index: the index of the photonic qubit + :param photon_index: the photon's index :type photon_index: int :return: nothing :rtype: None diff --git a/graphiq/utils/draw.py b/graphiq/utils/draw.py index b63f995f..a4fc56c4 100644 --- a/graphiq/utils/draw.py +++ b/graphiq/utils/draw.py @@ -235,7 +235,7 @@ def add_register(self, reg_name, size, reg_type="qreg"): the html figure, with a position mapping to that label. :param reg_name: register name - :type reg_type: str + :type reg_name: str :param size: size of the register :type size: int :param reg_type: type of the register (can only be "qreg" or "creg") @@ -272,13 +272,15 @@ def add_gate( :param gate_name: gate name :type gate_name: str - :param qargs: qargs of thge gate + :param qargs: qargs of the gate :type qargs: list :param params: params of the gate :type params: dict :param controls: gate controls for gate like CX, it will specify where the control at + :type controls: list + :raises ValueError: if qargs contains more than one entry :return: visualization info of the gate - rtype: dict + :rtype: dict """ if len(qargs) > 1: raise ValueError("Gate that act on multi-qargs is not supported yet.") diff --git a/graphiq/utils/openqasm_lib.py b/graphiq/utils/openqasm_lib.py index ef756e11..496e33bc 100644 --- a/graphiq/utils/openqasm_lib.py +++ b/graphiq/utils/openqasm_lib.py @@ -50,6 +50,10 @@ def __init__( :type definitions: str or list :param usage: A function which creates a gate string given q_reg, q_reg_type, c_reg :type usage: function + :param multi_comp: True if the operation is made of multiple components (visual blocks); when converting a + circuit to openQASM, barriers are inserted to keep such an operation separate from its + neighbours + :type multi_comp: bool :return: function returns nothing :rtype: None """ diff --git a/graphiq/utils/preprocessing.py b/graphiq/utils/preprocessing.py index c10939a7..3bb5a19c 100644 --- a/graphiq/utils/preprocessing.py +++ b/graphiq/utils/preprocessing.py @@ -170,9 +170,10 @@ def get_lc_graph_by_max_edge(graph, n_graphs, graph_metric, n_trial=5): :type n_graphs: int :param graph_metric: a function scoring a graph, called as graph_metric(graph) :type graph_metric: function - :param n_trial: the number of local-complementation rounds + :param n_trial: the number of local-complementation rounds; defaults to 5 :type n_trial: int - :raises TypeError: if graph is neither an nx.Graph nor a numpy.ndarray + :raises TypeError: if the type of graph is not exactly nx.Graph or numpy.ndarray (subclasses such as + nx.DiGraph or nx.MultiGraph are rejected) :return: at most n_graphs (score, graph) tuples, sorted by ascending score :rtype: list[(float, nx.Graph)] """ @@ -233,9 +234,10 @@ def get_lc_graph_by_max_neighbor_edge(graph, n_graphs, graph_metric, n_trial=5): :type n_graphs: int :param graph_metric: a function scoring a graph, called as graph_metric(graph) :type graph_metric: function - :param n_trial: the number of local-complementation rounds + :param n_trial: the number of local-complementation rounds; defaults to 5 :type n_trial: int - :raises TypeError: if graph is neither an nx.Graph nor a numpy.ndarray + :raises TypeError: if the type of graph is not exactly nx.Graph or numpy.ndarray (subclasses such as + nx.DiGraph or nx.MultiGraph are rejected) :return: at most n_graphs (score, graph) tuples, sorted by ascending score :rtype: list[(float, nx.Graph)] """ diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 9020925e..56defde0 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -366,21 +366,24 @@ def lc_orbit_finder( come up with new distinct graphs in the orbit of the input graph. The comp_depth determines the maximum depth of the orbit explored. - :param graph: original graph + :param graph: original graph; its nodes must be labelled 0..n-1 in iteration order, since node labels are also + used as adjacency-matrix indices :type graph: nx.Graph :param comp_depth: the maximum length of the sequence of local-complementations applied on the graph; if None, continue till the required number of graphs are found, or no new graphs are found. :type comp_depth: int :param orbit_size_thresh: sets a limit on the maximum number of orbit graphs to look for :type orbit_size_thresh: int - :param with_iso: if true, isomorph graphs will be kept in the orbit + :param with_iso: if true, isomorphic graphs will be kept in the orbit :type with_iso: bool :param rand: if true, the search starts from a graph obtained by applying random local complementations to - the original graph, instead of from the original graph itself, and the nodes are tried in a random order + the original graph, instead of from the original graph itself, and the nodes are tried in a random order; + the returned list then starts with that graph instead of the original one :type rand: bool :param rep_allowed: if true the orbit finder does not check for iso or auto morphism; useful for large graphs. :type rep_allowed: bool - :return: list of distinct graphs in the orbit of original graph + :return: list of graphs in the orbit of original graph, starting with the graph the search starts from; the + graphs are distinct unless rep_allowed is true, in which case repeats may occur :rtype: list[nx.Graph] """ orbit_list = [graph.copy()] @@ -556,11 +559,14 @@ def check_isomorphism(graph, g_list, _only_auto=False): def _equal_graphs(g1, g2): """ + Check whether two graphs have the same adjacency matrix, with the nodes of each graph taken in its iteration + order and edge weights ignored. This compares labelled graphs; it is not an isomorphism test. + :param g1: a graph :type g1: nx.Graph :param g2: a graph :type g2: nx.Graph - :return: given two graphs this function return true if they have the same adjacency matrix. + :return: True if the two graphs have the same adjacency matrix, False otherwise :rtype: bool """ adj1 = (nx.to_numpy_array(g1)).astype(bool) @@ -570,7 +576,19 @@ def _equal_graphs(g1, g2): def _compare_graphs_visual(g, new_g, new_labels): """ - Visual demonstration of initial graph, the relabeled one. + Visual demonstration of initial graph, the relabeled one. It relabels g by mapping node i to new_labels[i], + meant to draw g, the relabeled graph, and new_g in three stacked panels. + + In the current code this always raises AssertionError at its label check, which compares a list with a + NodeView, so nothing past the relabelling is drawn. + + :param g: the initial graph, with nodes labelled 0..n-1 + :type g: nx.Graph + :param new_g: a graph to draw next to the relabeled one + :type new_g: nx.Graph + :param new_labels: the new labels, where new_labels[i] is the new label of node i + :type new_labels: list or numpy.ndarray + :raises AssertionError: always, at the check of the relabeled node labels """ nx.draw_networkx(g, with_labels=True, pos=nx.kamada_kawai_layout(g)) fig, (ax1, ax2, ax3) = plt.subplots(3, figsize=(15, 25)) From 9bf01c1941f610b24ac4a7a3f7c51016d4ecb433 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 13:20:51 -0400 Subject: [PATCH 135/227] [docs] Add missing docstrings to the solver, benchmark and utility modules Docstring-only; no code changes. Adds module, class and method docstrings where they were missing in graphiq/solvers, graphiq/benchmarks and graphiq/utils. Among them: - AlternateTargetSolver: how candidates are scored with noise simulation off, on, and on with Monte Carlo, and that compiler and metric are stored but not used by solve(). - The solver settings classes: every field and its default. - relabel_module and lc_equivalence: what each orbit and example function returns. --- graphiq/benchmarks/benchmark_utils.py | 19 ++++ graphiq/benchmarks/lc_equivalence.py | 111 +++++++++++++++++++ graphiq/benchmarks/pipeline.py | 13 +++ graphiq/solvers/__init__.py | 4 + graphiq/solvers/alternate_target_solver.py | 87 +++++++++++++++ graphiq/solvers/evolutionary_solver.py | 46 ++++++++ graphiq/solvers/solver_base.py | 77 +++++++++++++ graphiq/solvers/solver_result.py | 47 ++++++++ graphiq/utils/circuit_comparison.py | 23 ++++ graphiq/utils/draw.py | 6 + graphiq/utils/openqasm_lib.py | 123 +++++++++++++++++++++ graphiq/utils/preprocessing.py | 21 ++++ graphiq/utils/relabel_module.py | 91 +++++++++++++++ 13 files changed, 668 insertions(+) diff --git a/graphiq/benchmarks/benchmark_utils.py b/graphiq/benchmarks/benchmark_utils.py index 6347f0ae..209d7d7b 100644 --- a/graphiq/benchmarks/benchmark_utils.py +++ b/graphiq/benchmarks/benchmark_utils.py @@ -31,6 +31,25 @@ def circuit_measurement_independent(circuit, compiler): + """ + Check whether the compiled state, after tracing out the last qubit, is the same regardless + of which classical measurement outcome is forced during compilation. + + Compiles the circuit twice, once with the compiler's measurement determinism forced to 0 + and once forced to 1, then restores the compiler's original measurement_determinism + setting. Only compares the two deterministic ("all 0s", "all 1s") resolutions, not every + possible measurement outcome, and only works with a density-matrix compiler/state at the + moment. + + :param circuit: the circuit to compile + :type circuit: CircuitDAG + :param compiler: the compiler to compile it with; its measurement_determinism attribute is + overwritten twice and then restored + :type compiler: DensityMatrixCompiler + :return: whether the two traced-out states are numerically close, and the two traced-out + states themselves + :rtype: tuple[bool, QuantumState, QuantumState] + """ # Note: this doesn't check every option, only "all 0s, all 1s" # TODO: make applicable to other representations. This only works on density matrices at the moment original_measurement_determinism = compiler.measurement_determinism diff --git a/graphiq/benchmarks/lc_equivalence.py b/graphiq/benchmarks/lc_equivalence.py index 11e698fb..4ccf6d7b 100644 --- a/graphiq/benchmarks/lc_equivalence.py +++ b/graphiq/benchmarks/lc_equivalence.py @@ -12,6 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Demo scripts that build a graph, draw it and graphs reached from it by local +complementation, and print an alternate-circuit search report for each graph drawn. Run this +module directly to execute whichever example is left uncommented under ``__main__``. +""" + import matplotlib.pyplot as plt from graphiq.benchmarks.alternate_circuits import ( exemplary_run, @@ -45,6 +51,22 @@ def _first_distinct_graph(candidates, graph): def run_multiple_graphs_and_report(graph, lc_nodes): + """ + Draw the given graph and, for each node in lc_nodes, the graph reached from it by one local + complementation at that node, and print an alternate-circuit search report for each, so + ``1 + len(lc_nodes)`` searches and reports in all. + + Each local complementation is computed from the original graph, not cumulatively, so + lc_nodes describes a fan out from the one graph rather than a chain. Every graph drawn is + the graph its accompanying report describes. + + :param graph: the graph to draw and search + :type graph: networkX.Graph + :param lc_nodes: nodes to apply a local complementation to, one at a time + :type lc_nodes: list + :return: nothing + :rtype: None + """ results = [exemplary_run(graph, None)] graphs = [graph] for node in lc_nodes: @@ -59,28 +81,70 @@ def run_multiple_graphs_and_report(graph, lc_nodes): def linear_4_example(): + """ + Run and report on a 4-node linear cluster graph (path 0-1-2-3) and the local + complementations of it at nodes 1 and 2. + + :return: nothing + :rtype: None + """ graph = nx.Graph([(0, 1), (1, 2), (2, 3)]) run_multiple_graphs_and_report(graph, [1, 2]) def square_4_example(): + """ + Run and report on a 4-node graph (a 4-cycle 0-1-3-2-0 plus the diagonal edge 1-2) and the + local complementations of it at nodes 0, 1 and 2. + + :return: nothing + :rtype: None + """ graph = nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (1, 2)]) # nodes 0 and 3 share the neighbourhood {1, 2}, so LC(0) == LC(3) run_multiple_graphs_and_report(graph, [0, 1, 2]) def repeater_graph_example(n_inner_qubits): + """ + Run and report on a repeater graph state with n_inner_qubits inner qubits, and the local + complementation of it at node 1. + + :param n_inner_qubits: number of qubits in the repeater graph's inner layer + :type n_inner_qubits: int + :return: nothing + :rtype: None + """ graph = repeater_graph_states(n_inner_qubits) run_multiple_graphs_and_report(graph, [1]) def star_graph_example(n_points): + """ + Run and report on a star graph with n_points leaves attached to the central node, and the + local complementation of it at the central node (0). + + :param n_points: number of leaf nodes attached to the central node + :type n_points: int + :return: nothing + :rtype: None + """ graph = nx.star_graph(n_points) # LC at a degree-1 leaf is the identity; only the centre changes the graph run_multiple_graphs_and_report(graph, [0]) def biclique_example(n_pairs): + """ + Run and report on the complete bipartite graph K(n_pairs, n_pairs) (nodes 0..n_pairs-1 on one + side, n_pairs..2*n_pairs-1 on the other, every cross edge present), and the local + complementations of it at nodes 0 and n_pairs. + + :param n_pairs: number of nodes on each side of the bipartite graph + :type n_pairs: int + :return: nothing + :rtype: None + """ # built by the generator so nodes are inserted in ascending order: the local # complementation indexes nodes by adjacency-matrix position, and an edge # list inserts them as 0, n_pairs, ... and complements the wrong ones. @@ -90,6 +154,15 @@ def biclique_example(n_pairs): def imperfect_repeater_graph_example(): + """ + Draw and report on two 11-node graphs that share a hub node (2) linked to five inner nodes + (1, 4, 6, 8, 10), each inner node carrying one pendant leaf (0, 3, 5, 7, 9 respectively): the + first also has a clique on the five inner nodes, the second omits it. Neither is a repeater + graph state, which has an even number of nodes. + + :return: nothing + :rtype: None + """ edges_set1 = [(2, 1), (2, 4), (2, 6), (2, 8), (2, 10)] edges_set1 += [(0, 1), (3, 4), (5, 6), (7, 8), (9, 10)] edges_set2 = edges_set1.copy() @@ -108,6 +181,18 @@ def imperfect_repeater_graph_example(): def heuristic_example1(): + """ + Draw and report on the complete graph on 5 nodes, then on a graph reached from it by the + max-edge local-complementation heuristic. + + The heuristic returns (metric value, graph) pairs, lowest metric value first, and the graph + used here is the first one differing from the input (see ``_first_distinct_graph``). All ten + retained candidates score -2 on ``graph_max_eccentricity``, so which one is drawn carries no + meaning beyond being one of the lowest-scoring set. + + :return: nothing + :rtype: None + """ graph = nx.complete_graph(5) nx.draw(graph, with_labels=True) result = exemplary_run(graph, None) @@ -122,6 +207,20 @@ def heuristic_example1(): def heuristic_example2(): + """ + Draw and report on the complete bipartite graph K(3, 3), then on one further local + complementation of the graph the max-edge heuristic selects -- the lines that would draw and + search the heuristic's own graph are commented out. + + The heuristic returns (metric value, graph) pairs, lowest metric value first, and the graph + taken from it here is the first one differing from K(3, 3) itself (see + ``_first_distinct_graph``): K(3, 3) ties with all its candidates at -2, so pair 0 is the + input unchanged. That graph is ``LC(K(3, 3), 0)``, so the further complementation is at + node 3; complementing at node 0 again would return K(3, 3) itself. + + :return: nothing + :rtype: None + """ graph = nx.complete_bipartite_graph(3, 3) nx.draw(graph, with_labels=True) result = exemplary_run(graph, None) @@ -142,6 +241,18 @@ def heuristic_example2(): def heuristic_example3(): + """ + Draw and report on the complete bipartite graph K(3, 3), then on a graph reached from it by + the max-neighbor-edge local-complementation heuristic. + + The heuristic returns (metric value, graph) pairs, lowest metric value first, and the graph + used here is the first one differing from K(3, 3) itself (see ``_first_distinct_graph``): + K(3, 3) ties with all its candidates at -2, so pair 0 is the input unchanged. Which distinct + candidate is used carries no meaning beyond being one of the lowest-scoring set. + + :return: nothing + :rtype: None + """ graph = nx.complete_bipartite_graph(3, 3) nx.draw(graph, with_labels=True) result = exemplary_run(graph, None) diff --git a/graphiq/benchmarks/pipeline.py b/graphiq/benchmarks/pipeline.py index 89f8f545..c0ac79d5 100644 --- a/graphiq/benchmarks/pipeline.py +++ b/graphiq/benchmarks/pipeline.py @@ -12,6 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Utilities for assembling and running a solver benchmarking pipeline: combining solvers, targets, +compilers and metrics into individual runs, executing those runs (optionally in parallel with +ray), and saving the resulting data. +""" + import pandas as pd import time import psutil @@ -27,6 +33,13 @@ def metadata(): + """ + Collect metadata about the machine running the benchmark. + + :return: a dictionary with the system name, OS version, machine architecture, processor, and + CPU count + :rtype: dict + """ uname = platform.uname() md = dict( system=uname.system, diff --git a/graphiq/solvers/__init__.py b/graphiq/solvers/__init__.py index 02a41768..9ecb9b79 100644 --- a/graphiq/solvers/__init__.py +++ b/graphiq/solvers/__init__.py @@ -12,4 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Solvers that search for a circuit generating a target state. +""" + from graphiq.solvers.solver_base import SolverBase, RandomSearchSolver diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 957347ac..5a278c8f 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -54,6 +54,29 @@ class AlternateTargetSolver: + """ + Searches for circuits generating the target graph by way of alternative target graphs. + + Rather than one fixed target, the solver also tries isomorphic relabellings of the target and + local-Clifford-equivalent graphs of each relabelling, since these can be far cheaper to + generate. Each candidate's circuit comes from TimeReversedSolver, followed by the + local-Clifford gates that convert it to the relabelled target. + + How candidates are scored depends on noise_model_mapping and the setting's monte_carlo flag, + and every path writes to the same ``score`` column of the result: + + - noise simulation off (noise_model_mapping None): no noisy simulation is scored, and every + candidate gets the placeholder score 0.0001. + - noise simulation on, Monte Carlo off: the noisy circuit is compiled on ``noise_compiler`` + and scored with Infidelity against the relabelled target (see noise_score). + - noise simulation on, Monte Carlo on: the circuit is run on the compiler in + ``monte_carlo_params`` (a StabilizerCompiler when that entry is None) and scored against a + noiseless compile of that candidate's own circuit, so it reports sensitivity to noise + rather than agreement with the target, and is not comparable with the other path. + + ``compiler`` and ``metric`` are stored, but no path of solve() uses them. + """ + def __init__( self, target: nx.Graph or QuantumState = None, @@ -545,6 +568,70 @@ def __init__( save_openqasm: str = "none", callback_func: dict = None, ): + """ + Create an AlternateTargetSolverSetting object + + :param allow_relabel: whether isomorphic relabellings of the target are searched. + When False, only the labelling given is used, and n_iso_graphs is ignored + :type allow_relabel: bool + :param n_iso_graphs: the number of isomorphic relabellings to look for, including the + original graph + :type n_iso_graphs: int + :param rel_inc_thresh: threshold between 0 and 1 on the relative increase in how many + distinct isomorphic graphs are found as the label pool grows; the search stops once + that ratio falls below it, so a value closer to 0 searches closer to exhaustively + :type rel_inc_thresh: float + :param allow_exhaustive: whether the relabelling search may enumerate every permutation + rather than sampling; the runtime can be very long if it does + :type allow_exhaustive: bool + :param sort_emit: whether the graphs found are sorted, after the search, by the number + of emitters needed to generate each + :type sort_emit: bool + :param label_map: passed to iso_finder, which then also returns a label map per graph. + solve() keeps only the adjacency matrices from that tuple and computes each relabel + map itself, so this does not change the result + :type label_map: bool + :param iso_thresh: how many random label trials to attempt while looking for new + permutations; None uses five times the number of labellings sought + :type iso_thresh: int or None + :param allow_lc: whether local-Clifford-equivalent graphs are tried at all. When + False, each isomorphic graph is its own only candidate and lc_method is not + consulted + :type allow_lc: bool + :param n_lc_graphs: the number of local-Clifford-equivalent graphs to try, or None for + no cap + :type n_lc_graphs: int or None + :param lc_orbit_depth: how far into the local-complementation orbit to search, or None + to continue until enough graphs are found or no new ones appear; the "rgs", "linear" + and "depth_first" methods ignore it + :type lc_orbit_depth: int or None + :param depolarizing_rate: the depolarizing rate used to build the noise map when the + solver's noise_model_mapping is "depolarizing" + :type depolarizing_rate: float + :param monte_carlo: whether candidates are scored with Monte Carlo noise; only consulted + when noise simulation is on + :type monte_carlo: bool + :param monte_carlo_params: overrides merged over the Monte Carlo defaults (n_sample, + map, compiler, seed, n_parallel, n_single); None takes the defaults unchanged + :type monte_carlo_params: dict or None + :param graph_metric: a function scoring a graph, called as graph_metric(graph). Stored + for callers; nothing in the solver reads it + :type graph_metric: function + :param lc_method: which local-complementation search to use -- one of "rgs", "linear", + "depth_first", "lc_with_iso", "random", "random_with_iso", "random_with_rep", or + None, the default, which searches the orbit without a specialised strategy. Any + other value makes solve() raise ValueError when allow_lc is True + :type lc_method: str or None + :param verbose: stored for callers; AlternateTargetSolver never prints progress + :type verbose: bool + :param save_openqasm: stored for callers; AlternateTargetSolver never writes openQASM + :type save_openqasm: str + :param callback_func: stored for callers, no callback is ever invoked; an empty dict + is built when None + :type callback_func: dict or None + :return: nothing + :rtype: None + """ self.allow_relabel = allow_relabel self.allow_lc = allow_lc self._n_iso_graphs = n_iso_graphs diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index 813d5c9c..d230fd89 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -50,6 +50,29 @@ def __init__( verbose=False, save_openqasm: str = "none", ): + """ + Create an EvolutionarySolverSetting object + + :param n_hof: the number of best circuits kept in the hall of fame + :type n_hof: int + :param n_stop: the number of iterations to run before stopping + :type n_stop: int + :param n_pop: the number of candidate circuits held per iteration + :type n_pop: int + :param tournament_k: the number of candidates entered in each selection tournament + :type tournament_k: int + :param selection_active: whether tournament selection is applied between iterations + :type selection_active: bool + :param use_adapt_probability: whether the transition probabilities adapt during the search + :type use_adapt_probability: bool + :param verbose: whether the solver prints the best score after each iteration + :type verbose: bool + :param save_openqasm: which circuits are saved as openQASM each iteration when the solver + has an IO: "hof", "pop" or "both"; "none" (or any other value) saves nothing + :type save_openqasm: str + :return: nothing + :rtype: None + """ super().__init__(n_hof=n_hof, n_stop=n_stop, n_pop=n_pop) self._tournament_k = tournament_k self._selection_active = selection_active @@ -541,11 +564,34 @@ def save_circuits(self, population: list, hof: list, iteration: int = -1): return def _save_pop(self, pop, iteration): + """ + Write every circuit of one iteration's population out as openQASM, one file per circuit + under "pop/", through the solver's IO. + + :param pop: the population, as (score, circuit) pairs + :type pop: list[tuple] + :param iteration: the iteration number, used in each file name + :type iteration: int + :return: nothing + :rtype: None + """ for i, (score, circuit) in enumerate(pop): name = f"pop/iteration{iteration}_pop{i}.txt" self.io.save_json(circuit.to_openqasm(), name) def _save_hof(self, hof, iteration): + """ + Write every circuit of the hall of fame out as openQASM, one file per circuit under "hof/", + through the solver's IO. Every entry must hold a circuit: a still-unfilled (np.inf, None) + slot raises AttributeError. + + :param hof: the hall of fame, as (score, circuit) pairs + :type hof: list[tuple] + :param iteration: the iteration number, used in each file name + :type iteration: int + :return: nothing + :rtype: None + """ for i, (score, circuit) in enumerate(hof): name = f"hof/iteration{iteration}_hof{i}.txt" self.io.save_json(circuit.to_openqasm(), name) diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index 607e32ee..b97d8968 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -60,6 +60,24 @@ def __init__( io: IO = None, solver_setting=None, ): + """ + Create a SolverBase object + + :param target: the target state the solver searches for a circuit to generate + :type target: QuantumState + :param metric: the objective the solver minimizes + :type metric: MetricBase + :param compiler: the compiler used to simulate each candidate circuit + :type compiler: CompilerBase + :param circuit: an initial circuit to start from, or None + :type circuit: CircuitBase or None + :param io: an IO object used to save results, or None for no saving + :type io: IO or None + :param solver_setting: the solver's own settings object, or None + :type solver_setting: object or None + :return: nothing + :rtype: None + """ self.target = target self.metric = metric self.compiler = compiler @@ -72,6 +90,13 @@ def __init__( @abstractmethod def solve(self, *args): + """ + Run the search. Each concrete solver implements its own. + + :raises NotImplementedError: always, on this base class + :return: nothing + :rtype: None + """ raise NotImplementedError( "Base Solver class, solver method is not implemented." ) @@ -101,6 +126,14 @@ def seed(seed=None): @property def solver_info(self): + """ + Solver-specific information to record alongside a result. + + Empty on the base class; a subclass overrides it with whatever describes its own search. + + :return: a dictionary of solver information + :rtype: dict + """ return {} def _wrap_noise(self, op, noise_model_mapping): @@ -174,12 +207,29 @@ def _identify_noise(self, op, noise_model_mapping): class RandomSearchSolverSetting(ABC): + """ + The settings every random-search-style solver shares: hall-of-fame size, how long to run, + and how many candidates to hold per iteration. + """ + def __init__( self, n_hof=5, n_stop=50, n_pop=50, ): + """ + Create a RandomSearchSolverSetting object + + :param n_hof: the number of best circuits kept in the hall of fame + :type n_hof: int + :param n_stop: the number of iterations to run before stopping + :type n_stop: int + :param n_pop: the number of candidate circuits held per iteration + :type n_pop: int + :return: nothing + :rtype: None + """ self._n_hof = n_hof self._n_stop = n_stop self._n_pop = n_pop @@ -237,6 +287,25 @@ def __init__( *args, **kwargs, ): + """ + Create a RandomSearchSolver object + + :param target: the target state the solver searches for a circuit to generate + :type target: QuantumState + :param metric: the objective the solver minimizes + :type metric: MetricBase + :param compiler: the compiler used to simulate each candidate circuit + :type compiler: CompilerBase + :param circuit: an initial circuit to start from, or None + :type circuit: CircuitBase or None + :param io: an IO object used to save results, or None for no saving + :type io: IO or None + :param solver_setting: the hall-of-fame size, stopping point and population size, stored as + self.setting; a fresh default is built when None + :type solver_setting: RandomSearchSolverSetting or None + :return: nothing + :rtype: None + """ super().__init__(target, metric, compiler, circuit, io) # built per instance: a default instance in the signature would be @@ -305,6 +374,14 @@ def tournament_selection(self, population, k=2): return population_new def solve(self, *args): + """ + Run the search. Not implemented on RandomSearchSolver; a subclass such as + EvolutionarySolver overrides it. + + :raises NotImplementedError: always, on this class + :return: nothing + :rtype: None + """ raise NotImplementedError( "Base Solver class, solver method is not implemented." ) diff --git a/graphiq/solvers/solver_result.py b/graphiq/solvers/solver_result.py index 629f09aa..0f18e6a3 100644 --- a/graphiq/solvers/solver_result.py +++ b/graphiq/solvers/solver_result.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Tabular container for a solver's output circuits and their per-circuit properties. +""" + import json import warnings @@ -29,6 +33,20 @@ class SolverResult: """ def __init__(self, circuit_list, properties=None): + """ + Create a SolverResult object + + Each named property becomes a column of the same length as circuit_list, filled with + None until a value is assigned. + + :param circuit_list: the circuits this result describes, one per row + :type circuit_list: list[CircuitBase] + :param properties: the names of the property columns to create, or None for none + :type properties: list[str] or None + :raises AssertionError: if properties is neither a list nor None + :return: nothing + :rtype: None + """ self._data = { "circuit": circuit_list, "circuit_id": [f"c{i}" for i in range(len(circuit_list))], @@ -190,6 +208,20 @@ def to_df(self): return df def save2json(self, path, filename): + """ + Write the result to a json file. + + Circuits are serialized as openQASM and the "g" column as edge lists, since neither is + json-serializable as it stands. + + :param path: the directory to write into + :type path: str + :param filename: the file name, without the .json suffix + :type filename: str + :raises KeyError: if the result has no "g" column + :return: nothing + :rtype: None + """ data_dict = self._data.copy() data_dict["g"] = [list(nx.to_edgelist(g)) for g in self._data["g"]] data_dict["circuit"] = [c.to_openqasm() for c in self._data["circuit"]] @@ -197,6 +229,21 @@ def save2json(self, path, filename): json.dump(data_dict, f) def load_json(self, path, filename): + """ + Load a json file's contents into this result, overwriting the columns the file + carries. Columns already held but absent from the file are left in place, so this + merges rather than replaces. + + The "g" column is rebuilt from edge lists into graphs; every other column is loaded + as stored, so a circuit column arrives as openQASM strings rather than circuits. + + :param path: the directory to read from + :type path: str + :param filename: the file name, without the .json suffix + :type filename: str + :return: nothing + :rtype: None + """ with open(f"{path}/{filename}.json", "r") as f: loaded_dict = json.load(f) for properties in loaded_dict.keys(): diff --git a/graphiq/utils/circuit_comparison.py b/graphiq/utils/circuit_comparison.py index 6bf66fb6..e596c17b 100644 --- a/graphiq/utils/circuit_comparison.py +++ b/graphiq/utils/circuit_comparison.py @@ -339,6 +339,17 @@ def remove_redundant_circuits(circuit_list): def check_redundant_circuit(circuit1, circuit2): + """ + Check whether two circuits are redundant (the same) using the direct comparison method, + after unwrapping wrapped nodes and removing identity operations on copies of both. + + :param circuit1: circuit that to be compared + :type circuit1: CircuitDAG + :param circuit2: circuit that to be compared + :type circuit2: CircuitDAG + :return: whether the two circuits are the same + :rtype: bool + """ circuit1_copy = circuit1.copy() circuit2_copy = circuit2.copy() @@ -357,6 +368,18 @@ class CircuitStorage: """ def __init__(self, check_function=None, disable_circuit_comparison=False): + """ + Construct a CircuitStorage. + + :param check_function: function used to check whether a new circuit is redundant with + one already stored; defaults to check_redundant_circuit when not given + :type check_function: function or None + :param disable_circuit_comparison: if True, skip the redundancy check and always add + new circuits + :type disable_circuit_comparison: bool + :return: nothing + :rtype: None + """ self.circuit_list = [] self.disable_circuit_comparison = disable_circuit_comparison diff --git a/graphiq/utils/draw.py b/graphiq/utils/draw.py index a4fc56c4..9d0732d8 100644 --- a/graphiq/utils/draw.py +++ b/graphiq/utils/draw.py @@ -12,6 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Circuit visualization data for an external web app: Painter builds per-operation drawing +information, through its own drawing API or from an OpenQASM 2.0 string, with Columns +managing the column layout, and posts the result as json to a web app on localhost. +""" + import json import requests diff --git a/graphiq/utils/openqasm_lib.py b/graphiq/utils/openqasm_lib.py index 496e33bc..d102e8d6 100644 --- a/graphiq/utils/openqasm_lib.py +++ b/graphiq/utils/openqasm_lib.py @@ -163,6 +163,12 @@ def register_initialization_string(e_reg, p_reg, c_reg): def cnot_info(): + """ + OpenQASM info for the CNOT (CX) gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "" @@ -173,6 +179,12 @@ def usage(q_reg, q_reg_type, c_reg): def sigma_x_info(): + """ + OpenQASM info for the Pauli X gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate x a { U(pi, 0, pi) a; }" @@ -183,6 +195,12 @@ def usage(q_reg, q_reg_type, c_reg): def sigma_y_info(): + """ + OpenQASM info for the Pauli Y gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate y a { U(pi, pi / 2, pi / 2) a; }" @@ -193,6 +211,12 @@ def usage(q_reg, q_reg_type, c_reg): def sigma_z_info(): + """ + OpenQASM info for the Pauli Z gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate z a { U(0, pi/2, pi/2) a; }" @@ -203,6 +227,12 @@ def usage(q_reg, q_reg_type, c_reg): def hadamard_info(): + """ + OpenQASM info for the Hadamard gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate h a { U(pi/2, 0, pi) a; }" @@ -213,6 +243,12 @@ def usage(q_reg, q_reg_type, c_reg): def phase_info(): + """ + OpenQASM info for the phase (S) gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ # TODO: investigate why the code fails if we name this gate "p" instead... imports = [] definition = "gate s a { U(0, pi/2, 0) a; }" @@ -224,6 +260,13 @@ def usage(q_reg, q_reg_type, c_reg): def phase_dagger_info(): # this is equal to applying P gate 3 times + """ + OpenQASM info for the adjoint phase (S-dagger) gate, equal to applying the phase (S) gate + three times. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate sdg a { U(0, -pi/2, 0) a; }" @@ -236,6 +279,13 @@ def usage(q_reg, q_reg_type, c_reg): def parameterized_info(): + """ + OpenQASM info for the general single-qubit rotation gate U(theta, phi, lambda), applied + with three parameters supplied through the ``params`` keyword of ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ # todo: update to the default rotation in Qiskit plotting imports = [] definition = "gate u(theta, phi, lambda) a { U(theta, phi, lambda) a; }" @@ -249,6 +299,14 @@ def usage(q_reg, q_reg_type, c_reg, params): def cparameterized_info(): + """ + OpenQASM info for the controlled general single-qubit rotation gate CU3(theta, phi, + lambda), applied with three parameters supplied through the ``params`` keyword of + ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ # todo: update to the default rotation in Qiskit plotting imports = [] definition = """ @@ -270,6 +328,13 @@ def usage(q_reg, q_reg_type, c_reg, params): def ry_info(): + """ + OpenQASM info for the RY(theta) rotation gate, applied with the rotation angle supplied + through the ``params`` keyword of ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate ry(theta) a { U(theta,0,0) a; }" @@ -280,6 +345,13 @@ def usage(q_reg, q_reg_type, c_reg, params): def rx_info(): + """ + OpenQASM info for the RX(theta) rotation gate, applied with the rotation angle supplied + through the ``params`` keyword of ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate rx(theta) a { U(theta, -pi/2,pi/2) a; }" @@ -290,6 +362,13 @@ def usage(q_reg, q_reg_type, c_reg, params): def rz_info(): + """ + OpenQASM info for the RZ(phi) rotation gate, applied with the rotation angle supplied + through the ``params`` keyword of ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = " gate rz(phi) a { U(phi) a; }" @@ -343,6 +422,13 @@ def usage(q_reg, q_reg_type, c_reg): def cz_info(): + """ + OpenQASM info for the controlled-Z (CZ) gate, implemented as a Hadamard on the target, a + CNOT, then a Hadamard on the target. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate cz a, b { U(pi/2, 0, pi) b; CX a, b; U(pi/2, 0, pi) b; }" # H on target, CX, H on target @@ -353,6 +439,14 @@ def usage(q_reg, q_reg_type, c_reg): def classical_cnot_info(): + """ + OpenQASM info for a measurement-controlled CNOT: the control qubit is measured into a + classical register, and an X gate is applied to the target qubit conditioned on that + measurement outcome being 1. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = sigma_x_info().definitions[0] @@ -366,6 +460,14 @@ def usage(q_reg, q_reg_type, c_reg): def classical_cz_info(): + """ + OpenQASM info for a measurement-controlled CZ: the control qubit is measured into a + classical register, and a Z gate is applied to the target qubit conditioned on that + measurement outcome being 1. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = sigma_z_info().definitions[0] @@ -379,6 +481,14 @@ def usage(q_reg, q_reg_type, c_reg): def measurement_cnot_and_reset(): + """ + OpenQASM info for a measurement-controlled CNOT followed by resetting the control qubit: + the control qubit is measured into a classical register, an X gate is applied to the + target qubit conditioned on that outcome being 1, then the control qubit is reset to 0. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = sigma_x_info().definitions[0] @@ -394,6 +504,12 @@ def usage(q_reg, q_reg_type, c_reg): def z_measurement_info(): + """ + OpenQASM info for a Z-basis measurement, storing the outcome into a classical register. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "" @@ -404,4 +520,11 @@ def usage(q_reg, q_reg_type, c_reg): def empty_info(): + """ + Empty OpenQASM info, shared by the identity gate and the input/output placeholder + operations: no imports, an empty definition string, and a usage string that is always empty. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ return OpenQASMInfo("", [], "", lambda x, y, z: "", False) diff --git a/graphiq/utils/preprocessing.py b/graphiq/utils/preprocessing.py index 3bb5a19c..000fb326 100644 --- a/graphiq/utils/preprocessing.py +++ b/graphiq/utils/preprocessing.py @@ -12,6 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Graph-level metrics and local-complementation search helpers used to rank candidate graph +states -- e.g. for greedy selection among the graphs reachable by local complementation. +""" + import heapq import networkx as nx @@ -204,6 +209,14 @@ def get_lc_graph_by_max_edge(graph, n_graphs, graph_metric, n_trial=5): def _count_n_neighbor_edges(adj_matrix): + """ + Count, for each node, the number of edges among that node's neighbors. + + :param adj_matrix: adjacency matrix of the graph + :type adj_matrix: numpy.ndarray + :return: array giving, per node, the number of edges among its neighbors + :rtype: numpy.ndarray + """ n_node = adj_matrix.shape[0] edges_count_list = np.zeros(n_node) for index in range(n_node): @@ -214,6 +227,14 @@ def _count_n_neighbor_edges(adj_matrix): def _count_n_neighbor(adj_matrix): + """ + Count, for each node, its number of neighbors (degree). + + :param adj_matrix: adjacency matrix of the graph + :type adj_matrix: numpy.ndarray + :return: array giving, per node, its number of neighbors + :rtype: numpy.ndarray + """ n_node = adj_matrix.shape[0] neighbor_count_list = np.zeros(n_node) for index in range(n_node): diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 56defde0..76c63ed9 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -495,6 +495,23 @@ def linear_partial_orbit(graph: nx.Graph): def depth_first_orbit(graph: nx.Graph): + """ + Find pairwise non-isomorphic representatives from the local-complementation orbit of a + graph, via a depth-first search over local-complementation sequences on nodes 0 to n-1 + (see :func:`_depth_first`), then build every graph reached along the way by re-applying, + from scratch, every prefix of every path the search recorded. + + This is far smaller than the labelled orbit, because :func:`_depth_first` prunes against + every graph found in any branch: the 5-cycle returns 3 graphs where its labelled orbit has + 132, and the 4-path returns 4 where the orbit has 11. A caller that needs specific + labellings rather than one representative per isomorphism class cannot use this. + + :param graph: original graph + :type graph: nx.Graph + :return: one graph per isomorphism class reached, including the original graph, in no + particular order + :rtype: list[nx.Graph] + """ n = len(graph) g = graph.copy() _, _, _, path_list = _depth_first(g, n) @@ -628,6 +645,22 @@ def _compare_graphs_visual(g, new_g, new_labels): def _retrieve_seq(n, seq_dict=None): + """ + Recursively build the sequence of node indices to locally-complement, in order, to reach + every new graph obtainable by local-complementation operations on n nodes -- an + intermediate step used by :func:`_full_seq` to build the full sequence for larger node + counts. The entries run from 2 up to n inclusive and never include 0 or 1: for example + ``_retrieve_seq(3)`` is ``[3, 2, 3, 2, 2]``. + + :param n: the node count this sub-sequence is built for + :type n: int + :param seq_dict: cache of previously computed sequences, keyed by node count; a cached + entry is reused instead of recomputed, but this function does not write new results + back into it + :type seq_dict: dict or None + :return: the sequence of node indices + :rtype: list[int] + """ # an intermediate step finder to complete the full sequence of nodes eligible for generation of new graphs by LC ops if seq_dict is not None: if n in seq_dict: @@ -652,6 +685,19 @@ def _retrieve_seq(n, seq_dict=None): def _full_seq(n): + """ + Build the sequence of node indices to locally-complement, in order, that yields every new + graph reachable via local-complementation operations confined to nodes 0 to n - 1, + intended for use with node counts large compared to n (at least 2 * n). A 0 is inserted + after every entry built from :func:`_retrieve_seq`, including the last, and [0, 1] is + prepended -- ``_full_seq(3)`` is ``[0, 1, 2, 0, 2, 0]``. For n of 1 or less the result is + just ``[0]``, with no prefix. + + :param n: upper node index (exclusive) the sequence's operations are confined to + :type n: int + :return: the sequence of node indices + :rtype: list[int] + """ # for infinite (or at least larger than 2*n) find sequence of ndes for LC operations that result in new graphs # obtained by operations on nodes upto 'n'. seq = [] @@ -672,6 +718,18 @@ def _full_seq(n): def _partial_orbit(n): + """ + Build, for a linear graph (path graph) on n nodes, the sequence of node indices to + locally-complement that reaches a partial (non-exhaustive) sample of its + local-complementation orbit, and return every prefix of that sequence. + + :param n: number of nodes in the linear graph + :type n: int + :return: prefixes of the node-index sequence; applying each prefix in order as successive + local complementations, starting from the original linear graph, yields one member of + the sampled orbit + :rtype: list[list[int]] + """ m = int(n / 2) + 1 if n % 2 == 0: seq1 = _full_seq(m - 1) @@ -695,6 +753,39 @@ def _partial_orbit(n): def _depth_first( g0, n, g_orbit=None, path=None, path_list=None, orbit_list=None, exact=False ): + """ + Recursively depth-first search local-complementation sequences on nodes 0 to n - 1, + starting from g0. At each node visited that produces a graph not isomorphic (via + ``nx.vf2pp_is_isomorphic``) to any graph recorded so far -- in every branch explored + already, not merely along the current path -- the search recurses from that new graph. + Because the check is global, the search keeps one representative per isomorphism class + rather than every labelling. The maximal node-index paths explored, together with the + graphs visited along each, are recorded into path_list and orbit_list. Call with only g0, + n and (optionally) exact -- the remaining keyword arguments initialize the search state on + that first call and are threaded through by the recursion afterwards. + + :param g0: graph to search from at this recursion step + :type g0: nx.Graph + :param n: number of nodes to try local complementation on at each step + :type n: int + :param g_orbit: graphs visited along the current path, seeded with [g0] on the top-level + call + :type g_orbit: list[nx.Graph] or None + :param path: sequence of node indices local-complemented so far to reach g0 + :type path: list[int] or None + :param path_list: accumulator of the maximal paths explored; a path that is a prefix of one + already recorded is skipped, so callers wanting the prefixes must rebuild them + :type path_list: list[list[int]] or None + :param orbit_list: accumulator of the graphs visited along each path in path_list + :type orbit_list: list[list[nx.Graph]] or None + :param exact: if True, isomorphism is checked on the subgraphs induced by + ``range(n + 1)`` rather than on the whole graphs. On a graph labelled 0 to n - 1 that + range covers every node, so the flag has no effect there; no caller sets it + :type exact: bool + :return: the updated (g_orbit, orbit_list, path, path_list), with the branch taken at this + recursion step backtracked out of g_orbit and path + :rtype: tuple[list[nx.Graph], list[list[nx.Graph]], list[int], list[list[int]]] + """ if g_orbit is None and path is None and path_list is None and orbit_list is None: g_orbit = [g0] orbit_list = [] From 8751b63f794f75974edd0703a6087d99b4dd27e6 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 15:31:25 -0400 Subject: [PATCH 136/227] [fix] Define rz with the three-parameter U gate in the OpenQASM output OpenQASM 2.0's built-in U takes three parameters (theta, phi, lambda), so the definition `gate rz(phi) a { U(phi) a; }` emitted for every circuit using RZ was invalid. It is now `U(0,0,phi)`, a rotation about Z by phi. --- graphiq/utils/openqasm_lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphiq/utils/openqasm_lib.py b/graphiq/utils/openqasm_lib.py index d102e8d6..e397b7ec 100644 --- a/graphiq/utils/openqasm_lib.py +++ b/graphiq/utils/openqasm_lib.py @@ -370,7 +370,7 @@ def rz_info(): :rtype: OpenQASMInfo """ imports = [] - definition = " gate rz(phi) a { U(phi) a; }" + definition = " gate rz(phi) a { U(0,0,phi) a; }" def usage(q_reg, q_reg_type, c_reg, params): return f"rz({params[0]}) {q_reg_type[0]}{q_reg[0]}[0];" From a5a6c4390f447399b04f8413d5c518bba0542ef8 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 15:31:25 -0400 Subject: [PATCH 137/227] [fix] Build find_lc_operations' complementations on the first graph, which the returned sequence starts from is_lc_equivalent solves for the Clifford that maps the first graph state to the second, and lc_graph_operations must be given the graph that Clifford acts on. find_lc_operations passed the second graph instead, so for some LC-equivalent pairs the sequence could not be built, and in general it did not take the first graph to the second. It now passes adj_matrix1, and gains a docstring stating the direction: applying the returned complementations to the first graph gives the second. The existing test is flipped to that direction, and a new test uses two pairs on which building from the second graph fails outright. Nothing in graphiq calls find_lc_operations. --- graphiq/backends/lc_equivalence_check.py | 28 ++++++++++++- .../test_graph_LC_equivalence.py | 40 ++++++++++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index 0963afaa..38c6d4ec 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -638,9 +638,35 @@ def lc_graph_operations(adj_matrix, solution): def find_lc_operations(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): + r""" + Finds the sequence of local complementations that transforms the first graph into the second one, + if the two graph states are local-Clifford equivalent. + + The direction is fixed by the argument order: the returned operations are applied to the graph of + ``adj_matrix1`` (in the given order) to obtain the graph of ``adj_matrix2``. To obtain the reverse + transformation (``adj_matrix2`` into ``adj_matrix1``), call this function with the arguments swapped. + + Internally, :py:func:`is_lc_equivalent` solves for the Clifford operator :math:`Q` that maps the first + graph state to the second, and :py:func:`lc_graph_operations` is called with ``adj_matrix1`` (the graph + that :math:`Q` acts on, since :math:`R = C\theta + D` must use that same :math:`\theta`). + + :param adj_matrix1: the adjacency matrix of the first (initial) graph, i.e. the graph the returned + operations are applied to + :type adj_matrix1: numpy.ndarray + :param adj_matrix2: the adjacency matrix of the second (target) graph + :type adj_matrix2: numpy.ndarray + :param mode: the chosen mode for finding solutions, either 'deterministic' (default) or 'random'. + :type mode: str + :param seed: an optional input to set the random seed for the random search approach + :type seed: int + :raises ValueError: if the two graphs are not local-Clifford equivalent + :return: the ordered list of node indices on which to apply local complementations to transform the + first graph into the second + :rtype: list[int] + """ equivalency, solution = is_lc_equivalent(adj_matrix1, adj_matrix2, mode, seed) if equivalency: - op_list = lc_graph_operations(adj_matrix2, solution) + op_list = lc_graph_operations(adj_matrix1, solution) return op_list else: raise ValueError("These two graphs are not local-Clifford equivalent.") diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index 1a5bae57..617d09a1 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -241,7 +241,7 @@ def test_is_lc_equivalent_star5_and_star5_plus_edge_are_not_equivalent(mode): assert solution is None -def test_find_lc_operations_recovers_first_graph_from_second(): +def test_find_lc_operations_maps_first_graph_to_second(): # These two 5-node graphs are LC equivalent via a multi-step local # complementation sequence (not a single node, unlike star4/k4), which is # needed to tell apart which of the two adjacency matrices the sequence @@ -253,12 +253,42 @@ def test_find_lc_operations_recovers_first_graph_from_second(): op_list = find_lc_operations(adj_matrix1, adj_matrix2, mode="deterministic", seed=0) - # applying the returned local complementations to the second graph should - # turn it into the first graph - g = nx.from_numpy_array(adj_matrix2) + # find_lc_operations returns the local complementations that map the FIRST + # graph onto the second; applying them to the first graph yields the second. + g = nx.from_numpy_array(adj_matrix1) for node in op_list: g = local_comp_graph(g, node) - assert np.array_equal(nx.to_numpy_array(g).astype(int), adj_matrix1) + assert np.array_equal(nx.to_numpy_array(g).astype(int), adj_matrix2) + + +@pytest.mark.parametrize( + "edges1, edges2", + [ + ( + [(0, 1), (1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)], + [(0, 1), (1, 4), (2, 4), (3, 4)], + ), + ( + [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4)], + [(0, 3), (0, 4), (1, 2), (2, 3), (2, 4), (3, 4)], + ), + ], +) +def test_find_lc_operations_are_built_on_the_first_graph(edges1, edges2): + # For these pairs, building the sequence on the second graph's adjacency + # matrix fails outright, so the test fails if find_lc_operations reverts to + # it (the pair in the test above yields a valid sequence either way). + adj_matrix1 = nx.to_numpy_array(nx.Graph(edges1), nodelist=range(5)).astype(int) + adj_matrix2 = nx.to_numpy_array(nx.Graph(edges2), nodelist=range(5)).astype(int) + + op_list = find_lc_operations(adj_matrix1, adj_matrix2, mode="deterministic", seed=0) + + g = nx.from_numpy_array(adj_matrix1) + for node in op_list: + g = local_comp_graph(g, node) + assert np.array_equal( + nx.to_numpy_array(g, nodelist=range(5)).astype(int), adj_matrix2 + ) def _adjacency(graph): From a34983023d11876b00434942bf79dca154d01695 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 15:31:25 -0400 Subject: [PATCH 138/227] [fix] Reject a list of (p, nx.Graph) in QuantumState.validate_data instead of raising AttributeError validate_data's list arm accepted an nx.Graph first entry and then read its n_qubits, which an nx.Graph does not have, so every QuantumState constructor call with such a list raised AttributeError. No representation here can be built from a list of nx.Graph, so the list arm now accepts only CliffordTableau entries, and such a list reaches the existing rejection: validate_data returns (False, None) and the constructor raises its TypeError. The only return value that changes is validate_data's for a list whose first entry is an nx.Graph carrying an n_qubits attribute, which used to give (True, n) although no representation could then be built from it. --- graphiq/state.py | 4 +--- .../test_quantum_state.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/graphiq/state.py b/graphiq/state.py index 93ce2f05..0d5362f1 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -568,9 +568,7 @@ def validate_data(cls, data): elif isinstance(data, nx.Graph): n_qubits = data.number_of_nodes() elif isinstance(data, list): - if isinstance(data[0][1], CliffordTableau) or isinstance( - data[0][1], nx.Graph - ): + if isinstance(data[0][1], CliffordTableau): n_qubits = data[0][1].n_qubits else: return False, None diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 4b40fdb3..fcd7139d 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -233,3 +233,23 @@ def test_show_on_a_mixed_graph_state_raises_not_implemented(): with pytest.raises(NotImplementedError, match="mixed graph state"): state.show(show=False) + + +def _nx_graph_mixture(): + return [(0.5, nx.path_graph(3)), (0.5, nx.star_graph(2))] + + +def test_validate_data_rejects_a_list_of_networkx_graphs(): + # No representation here can be built from a list of (p, nx.Graph), so + # validate_data reports it as unsupported rather than reading n_qubits, + # which an nx.Graph does not have. + assert QuantumState.validate_data(_nx_graph_mixture()) == (False, None) + + +def test_constructor_rejects_a_list_of_networkx_graphs_with_type_error(): + with pytest.raises(TypeError, match="Data's type is incorrect"): + QuantumState(_nx_graph_mixture(), rep_type="g", mixed=True) + + +def test_validate_data_accepts_a_list_of_clifford_tableaux(): + assert QuantumState.validate_data([(1.0, CliffordTableau(3))]) == (True, 3) From 1cc80c7127852e727d124bcb798e9b4ba6ea9ab5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 15:47:45 -0400 Subject: [PATCH 139/227] [fix] Pair a OneQubitGateWrapper's noise with its gates in constructor order OneQubitGateWrapper.unwrap pairs noise[i] with operations[i] and then reverses into application order. CircuitDAG._noisy_gates (used by assign_noise) and MonteCarloNoise._noisy_gates built the wrapper's noise list from unwrap() instead, i.e. in reverse, so a per-gate noise model landed on the wrong sub-gate: with Hadamard noise, a [Hadamard, Phase] wrapper carried it on Phase. Both now build the list from op.operations. A wrapper can also carry one model for the whole wrapper, which unwrap applies through a separate Identity. Both writers used to turn that into a list one entry too long, so a later unwrap or compile raised AssertionError. They now leave such a wrapper, and its model, as it is. Results: runs without noise simulation are unchanged. With the "depolarizing" mapping and Monte Carlo off, scores are unchanged: every gate class gets the same one-qubit depolarizing channel, which commutes with every gate. With Monte Carlo on, seeded scores change although their expectation does not, because the same random Paulis now land on the right sub-gates (e.g. MonteCarloNoise on path_graph(5), 40 samples, seed 7, rate 0.3: 0.925 -> 0.875). Mappings that give different gate classes different models are now applied to the intended gates. --- graphiq/circuit/circuit_dag.py | 22 ++++-- graphiq/noise/monte_carlo_noise.py | 6 +- tests/test_circuits/test_assign_noise.py | 92 ++++++++++++++++++++++ tests/test_noise/test_monte_carlo_noise.py | 58 +++++++++++--- 4 files changed, 157 insertions(+), 21 deletions(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index 3da3009a..81b6a51d 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -1180,7 +1180,9 @@ def assign_noise(self, noise_model_map): :param noise_model_map: a nested dictionary whose outer keys are register-type sections ("e", "p", "ee", "ep", ...) and whose inner dictionaries map an operation's class name to its noise model; for a two-qubit operation, the section is its control type followed by its target type, and the noise - model may be a list of two (one per leg). Operations without an entry get NoNoise + model may be a list of two (one per leg). Operations without an entry get NoNoise. A + OneQubitGateWrapper carrying a single model for the whole wrapper keeps it, and the mapping is not applied + to it :type noise_model_map: dict[str, dict] :return: a new circuit :rtype: CircuitDAG @@ -1200,10 +1202,10 @@ def _noisy_gates(self, noise_model_map): Helper function that returns the circuit's operations with a noise model attached to each Operates on a copy, so the circuit itself is not modified. Each OneQubitGateWrapper receives a list with one - noise model per gate returned by its ``unwrap``, in that order; note that the wrapper pairs ``noise[i]`` with - ``operations[i]``, and ``unwrap`` returns the operations in reverse. A controlled-pair operation receives its - section's list of two models, or its single model duplicated. Every other operation receives the model its - register section names. Operations without an entry get NoNoise. + noise model per wrapped gate, in the order of its ``operations``, since the wrapper pairs ``noise[i]`` with + ``operations[i]``; a wrapper carrying a single model for the whole wrapper keeps it and is left unchanged. + A controlled-pair operation receives its section's list of two models, or its single model duplicated. Every + other operation receives the model its register section names. Operations without an entry get NoNoise. :param noise_model_map: a nested dict of register-type section to operation class name to noise model, as accepted by ``assign_noise`` @@ -1220,7 +1222,11 @@ def _noisy_gates(self, noise_model_map): for op in seq: is_controlled = False if isinstance(op, ops.OneQubitGateWrapper): - op_type_seq = [type(gate) for gate in op.unwrap()] + if not isinstance(op.noise, list): + # a single model for the whole wrapper is left as it is + noisy_ops.append(op) + continue + op_type_seq = list(op.operations) noise_list = self._find_wrapped_noise( op_type_seq, noise_model_map[op.reg_type] ) @@ -1265,8 +1271,8 @@ def _find_wrapped_noise(self, op_type_list, mapping): A gate whose class name is absent from the mapping gets NoNoise, so the returned list always has the same length as op_type_list. - :param op_type_list: the classes of the wrapped gates; ``_noisy_gates`` passes them in the order returned by - the wrapper's ``unwrap`` + :param op_type_list: the classes of the wrapped gates; ``_noisy_gates`` passes the wrapper's ``operations``, + in constructor order :type op_type_list: list[type] :param mapping: one section of a noise model map, keyed by operation class name :type mapping: dict diff --git a/graphiq/noise/monte_carlo_noise.py b/graphiq/noise/monte_carlo_noise.py index 949650b2..61f143ee 100644 --- a/graphiq/noise/monte_carlo_noise.py +++ b/graphiq/noise/monte_carlo_noise.py @@ -294,7 +294,11 @@ def _noisy_gates(self): for op in seq: is_controlled = False if isinstance(op, ops.OneQubitGateWrapper): - op_type_seq = [type(gate) for gate in op.unwrap()] + if not isinstance(op.noise, list): + # a single model for the whole wrapper is left as it is + noisy_ops.append(op) + continue + op_type_seq = list(op.operations) noise_list = self._find_wrapped_noise(op_type_seq, op.reg_type) op.noise = noise_list noisy_ops.append(op) diff --git a/tests/test_circuits/test_assign_noise.py b/tests/test_circuits/test_assign_noise.py index da39cf9d..b9a6cb2d 100644 --- a/tests/test_circuits/test_assign_noise.py +++ b/tests/test_circuits/test_assign_noise.py @@ -12,9 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import numpy as np + import graphiq.benchmarks.circuits as circ import graphiq.circuit.ops as ops import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.circuit.circuit_dag import CircuitDAG from graphiq.noise.monte_carlo_noise import McNoiseMap, MonteCarloNoise @@ -122,3 +125,92 @@ def test_monte_carlo_trials_leave_the_source_circuit_alone(): assert all(isinstance(leg, nm.NoNoise) for leg in op.noise) else: assert isinstance(op.noise, nm.NoNoise) + + +def _one_emitter_wrapper_circuit(operations, noise=nm.NoNoise()): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add( + ops.OneQubitGateWrapper(operations, register=0, reg_type="e", noise=noise) + ) + return circuit + + +def _emitter_only_mapping(emitter_section): + return {"e": emitter_section, "p": {}, "ee": {}, "ep": {}} + + +def _the_wrapper(circuit): + wrappers = [ + op for op in circuit.sequence() if isinstance(op, ops.OneQubitGateWrapper) + ] + assert len(wrappers) == 1 + return wrappers[0] + + +def _noisy_state(circuit): + compiler = DensityMatrixCompiler() + compiler.noise_simulation = True + return compiler.compile(circuit=circuit).rep_data.data + + +def test_assign_noise_pairs_wrapper_noise_with_its_sub_gate(): + # a wrapper pairs noise[i] with operations[i], so the model mapped to Hadamard must end up + # on the Hadamard sub-gate, not on the sub-gate at the mirrored position in unwrap() order + hadamard_noise = nm.DepolarizingNoise(0.1) + circuit = _one_emitter_wrapper_circuit([ops.Hadamard, ops.Phase]) + + noisy = circuit.assign_noise(_emitter_only_mapping({"Hadamard": hadamard_noise})) + + noise_by_gate = { + type(gate).__name__: gate.noise for gate in _the_wrapper(noisy).unwrap() + } + assert noise_by_gate["Hadamard"] is hadamard_noise + assert isinstance(noise_by_gate["Phase"], nm.NoNoise) + + +def test_assign_noise_wrapper_noise_reaches_the_right_sub_gate_in_the_compiled_state(): + # [Hadamard, SigmaX] applies SigmaX first, then Hadamard; a Z error on the Hadamard gives a + # different state from a Z error on the SigmaX, so the compiled state shows which gate got it + circuit = _one_emitter_wrapper_circuit([ops.Hadamard, ops.SigmaX]) + noisy = circuit.assign_noise( + _emitter_only_mapping({"Hadamard": nm.PauliError("Z")}) + ) + + reference = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + reference.add(ops.SigmaX(register=0, reg_type="e")) + reference.add(ops.Hadamard(register=0, reg_type="e", noise=nm.PauliError("Z"))) + control = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + control.add(ops.SigmaX(register=0, reg_type="e", noise=nm.PauliError("Z"))) + control.add(ops.Hadamard(register=0, reg_type="e")) + + state = _noisy_state(noisy) + reference_state = _noisy_state(reference) + control_state = _noisy_state(control) + + assert not np.allclose(reference_state, control_state) + assert np.allclose(state, reference_state) + assert not np.allclose(state, control_state) + + +def test_assign_noise_leaves_a_whole_wrapper_model_in_place(): + # a wrapper carrying one model for the whole wrapper keeps it; the per-gate entries of the + # mapping are not applied to it + wrapper_noise = nm.PauliError("X") + circuit = _one_emitter_wrapper_circuit( + [ops.Hadamard, ops.SigmaX], noise=wrapper_noise + ) + + noisy = circuit.assign_noise( + _emitter_only_mapping({"Hadamard": nm.DepolarizingNoise(0.1)}) + ) + + wrapper = _the_wrapper(noisy) + assert isinstance(wrapper.noise, nm.PauliError) + assert wrapper.noise.noise_parameters == wrapper_noise.noise_parameters + gates = wrapper.unwrap() + assert len(gates) == 3 + assert {type(gate).__name__ for gate in gates} == { + "Hadamard", + "SigmaX", + "Identity", + } diff --git a/tests/test_noise/test_monte_carlo_noise.py b/tests/test_noise/test_monte_carlo_noise.py index f97a23cd..10d11609 100644 --- a/tests/test_noise/test_monte_carlo_noise.py +++ b/tests/test_noise/test_monte_carlo_noise.py @@ -13,9 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -How MonteCarloNoise draws the noise it attaches to each operation of a circuit: the order it -visits the sub-gates of a wrapped operation, and the independent draws for the two legs of a -controlled gate. +How MonteCarloNoise draws the noise it attaches to each operation of a circuit: which sub-gate +of a wrapped operation each draw is paired with, a wrapped operation that carries a single model +for the whole wrapper, and the independent draws for the two legs of a controlled gate. """ import pytest @@ -26,9 +26,9 @@ from graphiq.noise.monte_carlo_noise import McNoiseMap, MonteCarloNoise -def test_noisy_gates_maps_a_wrapped_ops_sub_gates_in_unwrap_order(): - # OneQubitGateWrapper.unwrap() returns its sub-gates in the reverse of the order given to - # the constructor, and that is the order _noisy_gates/_find_wrapped_noise draw noise in. +def test_noisy_gates_maps_a_wrapped_ops_sub_gates_in_constructor_order(): + # OneQubitGateWrapper pairs noise[i] with operations[i], in the order given to the + # constructor, so _noisy_gates/_find_wrapped_noise draw noise in that order too. # Mapping the first two sub-gates at probability 1.0 avoids the residual NoNoise draw that # would otherwise make the ordering assertion flaky; the third, unmapped sub-gate is what # reaches the NoNoise arm for an op with no entry in the mapping. @@ -50,19 +50,53 @@ def test_noisy_gates_maps_a_wrapped_ops_sub_gates_in_unwrap_order(): assert len(noisy_ops) == 1 noise = noisy_ops[0].noise - # unwrap() order is [SigmaZ, SigmaX, Hadamard] for operations=[Hadamard, SigmaX, SigmaZ] - assert isinstance(noise[0], nm.NoNoise) + # constructor order is operations=[Hadamard, SigmaX, SigmaZ] assert ( - isinstance(noise[1], nm.PauliError) - and noise[1].noise_parameters["Pauli error"] == "Z" + isinstance(noise[0], nm.PauliError) + and noise[0].noise_parameters["Pauli error"] == "X" ) assert ( - isinstance(noise[2], nm.PauliError) - and noise[2].noise_parameters["Pauli error"] == "X" + isinstance(noise[1], nm.PauliError) + and noise[1].noise_parameters["Pauli error"] == "Z" ) + assert isinstance(noise[2], nm.NoNoise) + + noise_by_gate = {type(gate).__name__: gate.noise for gate in noisy_ops[0].unwrap()} + assert noise_by_gate["Hadamard"].noise_parameters["Pauli error"] == "X" + assert noise_by_gate["SigmaX"].noise_parameters["Pauli error"] == "Z" + assert isinstance(noise_by_gate["SigmaZ"], nm.NoNoise) assert mc.n_noisy_gates == 2 +def test_noisy_gates_leaves_a_whole_wrapper_model_in_place(): + # a wrapper carrying one model for the whole wrapper keeps it: no per-gate draw replaces + # it, and none is counted + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add( + ops.OneQubitGateWrapper( + [ops.Hadamard, ops.SigmaX], + register=0, + reg_type="e", + noise=nm.PauliError("X"), + ) + ) + + mc = MonteCarloNoise(circuit) + mc.n_noisy_gates = 0 + mc_noise_model = McNoiseMap() + mc_noise_model.add_gate_noise("e", "Hadamard", [(nm.PauliError("Z"), 1.0)]) + mc.mc_noise_model = mc_noise_model + + noisy_ops = mc._noisy_gates() + + assert len(noisy_ops) == 1 + wrapper = noisy_ops[0] + assert isinstance(wrapper.noise, nm.PauliError) + assert wrapper.noise.noise_parameters["Pauli error"] == "X" + assert len(wrapper.unwrap()) == 3 + assert mc.n_noisy_gates == 0 + + def test_noisy_gates_controlled_gate_legs_can_differ_across_seeds(): # The controlled-gate noise-LIST arm is unreachable through the public API: the # McNoiseMap.mapping setter, which add_gate_noise reaches through add_noise_tuple, From a3ada66b54383b43ba19ccf4271406d633ec59c3 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 19:17:50 -0400 Subject: [PATCH 140/227] [perf] Fold mixed-stabilizer branches by the state they represent, not by tableau bytes MixedStabilizer.reduce merged only branches whose whole CliffordTableau compared equal, destabilizer rows and signs included. A Pauli error also flips destabilizer signs, so branches holding the same stabilizer state rarely merged, and each depolarizing gate kept multiplying the mixture: a noisy StabilizerCompiler run of a 4-photon linear cluster ended with 1024 branches for 16 distinct states, and a 5-photon one took minutes. reduce now groups branches by a key built from the canonical form of the stabilizer half and its signs, keeping the first branch of each group and summing probabilities. The mixed state is the same up to rounding in the summed probabilities, and the mixture stays bounded by the number of distinct states. Results: expected values are unchanged up to rounding. With measurement determinism 0 or 1 no outcome changes, and scores and summed probabilities change only by rounding in the last digits: search_for_alternative_circuits, which compiles this way with determinism 1, scores a 4-node ring's TimeReversedSolver circuit under noise_model_loss_and_depolarizing(0.01, 0.01) as 0.104373358769414 instead of 0.10437335876941412, and reports its photon survival probability as 0.9801000000000005 instead of 0.9800999999999997. A seeded search can therefore rank two candidates whose exact scores tie differently. With "probabilistic" determinism, each branch draws its own measurement outcome from the global numpy random stream, so fewer branches means different draws: a single seeded compile of a circuit with an uncorrected probabilistic measurement can give a different state, score and classical register value, and anything drawing from the global stream afterwards sees different numbers. This applies only to a StabilizerCompiler with noise simulation on (e.g. TimeReversedSolver or AlternateTargetSolver given one as compiler or noise compiler); the default AlternateTargetSolver noise compiler is the density matrix one. --- graphiq/backends/stabilizer/state.py | 112 ++++++-- .../test_mixed_stabilizer_reduce.py | 243 ++++++++++++++++++ .../test_mixed_stabilizer_reduce.py | 11 +- 3 files changed, 338 insertions(+), 28 deletions(-) create mode 100644 tests/test_noise/test_mixed_stabilizer_reduce.py diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index 0868ff92..a49557cc 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -545,30 +545,98 @@ def tableau(self): "Simulating using a mixed state representation, no tableau defined." ) - def reduce(self): - """ - Fold tableaux that compare equal into a single entry, summing their probabilities. + @staticmethod + def _state_key(tableau): + """ + A hashable key identifying the STATE a branch's tableau represents, + as opposed to the raw tableau data. + + Two CliffordTableau that represent the same state can differ in their + destabilizer rows (a free choice of completion) and in the order/linear + combination of their stabilizer generators, so raw data equality + (``CliffordTableau.__eq__``) is too strict for grouping branches of a + mixture. The key is instead built from the stabilizer half only, put + into canonical form, together with its phase -- phase is included + because two branches whose stabilizer groups agree up to canonical + form but differ in sign (e.g. |0> vs |1>) are physically distinct + states and must not be folded together. + + ``canonical_form`` mutates the StabilizerTableau it is given in place, + and both halves of that tableau are views into the CliffordTableau + until ``StabilizerTableau.__init__`` copies them: + ``CliffordTableau.stabilizer`` is a view into the table, and + ``to_stabilizer`` slices the phase vector rather than copying it. Two + separate copies therefore stand between this and the branch being + reduced -- ``np.copy`` for the table, and ``phase.astype(int)`` for the + phase. The phase one is the easier to lose in a refactor and the more + damaging: ``tab_row_swap`` assigns ``tableau.phase = row_swap(...)``, + and ``row_swap`` mutates its argument in place before the setter runs, + so without that copy an ordinary canonical-form row swap would corrupt + the parent CliffordTableau's phase vector directly. + + Internally, the row operations ``canonical_form`` performs discard + the i-phase of each row (only the +-1 phase is tracked). This is + harmless specifically inside canonical form reduction of a + stabilizer's own generators: since stabilizer generators mutually + commute, the sum of any two rows' g-function terms is always even, so + no residual factor of i is ever dropped on the floor. + + Separately from that, the key ignores the CliffordTableau's whole + i-phase vector, destabilizer and stabilizer rows alike, because + ``to_stabilizer`` keeps only the +-1 phase and a StabilizerTableau has + no i-phase at all. ``reduce`` does not check the i-phase either, so a + branch with a non-zero i-phase is folded into the first branch of the + same state and disappears; the i-phase check made when converting to + a density matrix sees it only if it is the branch kept. No + correct gate produces such a tableau. + + :param tableau: a branch's tableau + :type tableau: CliffordTableau + :return: a hashable key identifying the state + :rtype: tuple[bytes, bytes] + """ + canonical = canonical_form(tableau.to_stabilizer()) + table_bytes = np.ascontiguousarray(canonical.table.astype(np.int8)).tobytes() + phase_bytes = np.ascontiguousarray(canonical.phase.astype(np.int8)).tobytes() + return table_bytes, phase_bytes - :return: nothing - :rtype: None + def reduce(self): """ - # TODO: explore other ways of reduction and further simplification using a standard form. - # Copy: the mixture setter stores the caller's list by reference, and the - # pop below would otherwise take an element out from under them. - mixture_temp = list(self._mixture) - mixture_reduce = [] - while len(mixture_temp) != 0: - p0, t0 = mixture_temp.pop(0) - remaining = [] - for p_i, t_i in mixture_temp: - if t0 == t_i: - p0 += p_i - else: - remaining.append((p_i, t_i)) - mixture_temp = remaining - - mixture_reduce.append((p0, t0)) - self._mixture = mixture_reduce + Fold branches that represent the same state into a single entry, + summing their probabilities. + + Branches are grouped by state identity (see ``_state_key``) rather + than by raw tableau equality, so two branches that reach the same + state by different routes -- e.g. different destabilizer completions, + or stabilizer generators produced in a different order -- fold + together even though ``CliffordTableau.__eq__`` would call them + unequal. + + ``reduce`` itself costs more per call than a raw-equality scan, since + ``canonical_form`` is O(n^3) Python-level row operations paid once per + branch. The simulation as a whole is faster, because it merges + branches that raw equality misses (on a noisy compile at + n_emitters=3, a mixture of 64 branches reduces to 8), and every later + operation on the mixture iterates all of it. + + :return: nothing + :rtype: None + """ + # Grouping builds a new list rather than mutating in place: the + # mixture setter stores the caller's list by reference, so rewriting + # it here would change it out from under the caller. `order` records + # the first-seen order of the groups, which the result follows. + groups = {} + order = [] + for p_i, t_i in self._mixture: + key = self._state_key(t_i) + if key in groups: + p0, t0 = groups[key] + groups[key] = (p0 + p_i, t0) + else: + groups[key] = (p_i, t_i) + order.append(key) + self._mixture = [groups[key] for key in order] def apply_unitary(self, qubit_position, unitary): """ diff --git a/tests/test_noise/test_mixed_stabilizer_reduce.py b/tests/test_noise/test_mixed_stabilizer_reduce.py new file mode 100644 index 00000000..a0c4e19a --- /dev/null +++ b/tests/test_noise/test_mixed_stabilizer_reduce.py @@ -0,0 +1,243 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``MixedStabilizer.reduce`` folds branches that represent the same stabilizer +state, even when their tableaux differ: another route to the state, another +destabilizer completion or signs, or the generators in another order. Branches +of different states stay apart, the mixed state is unchanged, and the input +tableaux are not modified. The folding of tableaux that are ``==`` to each +other is covered in +tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py. +""" + +import numpy as np + +import graphiq.backends.stabilizer.functions.transformation as transform +import graphiq.backends.state_rep_conversion as rc +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.state import MixedStabilizer +from graphiq.circuit.circuit_dag import CircuitDAG + + +def _density(mixture): + return sum(p * rc.stabilizer_to_density(t.to_stabilizer()) for p, t in mixture) + + +def _bell_routes(): + route_a = CliffordTableau(2) + route_a = transform.hadamard_gate(route_a, 0) + route_a = transform.cnot_gate(route_a, 0, 1) + + route_b = CliffordTableau(2) + route_b = transform.hadamard_gate(route_b, 1) + route_b = transform.cnot_gate(route_b, 1, 0) + return route_a, route_b + + +def _row_swapped_zero_state(): + # the generators Z0, Z1 in the other order, with the destabilizer rows + # X0, X1 swapped along with them so the tableau stays valid + swapped_table = CliffordTableau(2).table.copy() + swapped_table[[2, 3]] = swapped_table[[3, 2]] + swapped_table[[0, 1]] = swapped_table[[1, 0]] + return CliffordTableau(swapped_table) + + +def _noisy_hadamard_circuit(n_emitters, depolarizing_prob): + circ = CircuitDAG(n_emitter=n_emitters, n_photon=0, n_classical=0) + for i in range(n_emitters): + circ.add( + ops.Hadamard( + register=i, + reg_type="e", + noise=nm.DepolarizingNoise(depolarizing_prob=depolarizing_prob), + ) + ) + return circ + + +def _noisy_compiler(): + compiler = StabilizerCompiler() + compiler.noise_simulation = True + compiler.measurement_determinism = 1 + return compiler + + +def test_two_routes_to_the_same_state_reduce_to_one_branch(): + route_a, route_b = _bell_routes() + assert route_a != route_b # the tableaux themselves differ + + mixed = MixedStabilizer([(0.5, route_a), (0.5, route_b)]) + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, _ = mixed.mixture[0] + assert probability == 1.0 + + +def test_branches_differing_only_in_stabilizer_phase_stay_separate(): + # |00> and |10>: the same generators up to sign are different states + zero_state = CliffordTableau(2) + one_state = CliffordTableau(2) + one_state.phase[one_state.n_qubits] = 1 + + mixed = MixedStabilizer([(0.5, zero_state), (0.5, one_state)]) + mixed.reduce() + + assert len(mixed.mixture) == 2 + + +def test_branches_differing_only_in_destabilizer_rows_merge(): + # stabilizers Z0, Z1 completed by the destabilizers X0Z1, X1Z0 instead of + # X0, X1: another valid tableau of |00> + same_state = CliffordTableau(2) + other_completion = CliffordTableau(2) + other_completion.destabilizer = np.array([[1, 0, 0, 1], [0, 1, 1, 0]]) + + assert same_state != other_completion # the tableaux themselves differ + + mixed = MixedStabilizer([(0.5, same_state), (0.5, other_completion)]) + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, _ = mixed.mixture[0] + assert probability == 1.0 + + +def test_branches_differing_only_in_destabilizer_signs_merge(): + # what a Pauli error does to a branch whose state it leaves alone: Z on |0> + # flips the sign of the destabilizer X only + plain = CliffordTableau(1) + flipped = transform.z_gate(CliffordTableau(1), 0) + assert plain != flipped # the tableaux themselves differ + + mixed = MixedStabilizer([(0.75, plain), (0.25, flipped)]) + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, _ = mixed.mixture[0] + assert probability == 1.0 + + +def test_branches_differing_only_in_generator_row_order_merge(): + reference = CliffordTableau(2) + reordered = _row_swapped_zero_state() + assert reference != reordered # the tableaux themselves differ + + mixed = MixedStabilizer([(0.5, reference), (0.5, reordered)]) + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, _ = mixed.mixture[0] + assert probability == 1.0 + + +def test_reduce_does_not_modify_any_input_tableau(): + # Bringing a branch to canonical form must not write back into it. The + # stabilizer half of route_b and of the row-swapped tableau is not already + # canonical, so a write-back would show on them; the signed copy of route_b + # also shows a write-back through its phase vector. + route_a, route_b = _bell_routes() + signed_b = transform.z_gate(route_b.copy(), 0) + inputs = [ + route_a, + route_b, + CliffordTableau(2), + _row_swapped_zero_state(), + signed_b, + ] + snapshots = [ + (np.copy(t.table), np.copy(t.phase), np.copy(t.iphase)) for t in inputs + ] + + mixed = MixedStabilizer([(0.2, t) for t in inputs]) + mixed.reduce() + + assert len(mixed.mixture) == 3 + for t, (table, phase, iphase) in zip(inputs, snapshots): + assert np.array_equal(t.table, table) + assert np.array_equal(t.phase, phase) + assert np.array_equal(t.iphase, iphase) + + +def test_reduce_preserves_total_probability_on_a_noisy_compile(monkeypatch): + # a real noise-simulation mixture: folding must not drop or double-count + # probability mass, and must leave the mixed state as it was + circ = _noisy_hadamard_circuit(3, 0.1) + + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) + unreduced = _noisy_compiler().compile(circ).rep_data + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", True) + reduced = _noisy_compiler().compile(circ).rep_data + assert isinstance(unreduced, MixedStabilizer) + assert isinstance(reduced, MixedStabilizer) + + total_unreduced = sum(p for p, _ in unreduced.mixture) + total_reduced = sum(p for p, _ in reduced.mixture) + assert np.isclose(total_unreduced, 1.0) + assert np.isclose(total_reduced, total_unreduced) + assert np.allclose(_density(reduced.mixture), _density(unreduced.mixture)) + + +def test_reduce_leaves_the_mixed_state_unchanged(monkeypatch): + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) + mixed = _noisy_compiler().compile(_noisy_hadamard_circuit(3, 0.1)).rep_data + n_before = len(mixed.mixture) + rho_before = _density(mixed.mixture) + + mixed.reduce() + + assert len(mixed.mixture) < n_before + assert np.isclose(sum(p for p, _ in mixed.mixture), 1.0) + assert np.allclose(_density(mixed.mixture), rho_before) + + +def test_noisy_compile_keeps_one_branch_per_distinct_state(): + # depolarizing noise after H on each of three emitters: 4^3 Kraus branches, + # but only the 2^3 states |+/->^3 + mixed = _noisy_compiler().compile(_noisy_hadamard_circuit(3, 0.1)).rep_data + + assert len(mixed.mixture) == 8 + assert np.isclose(sum(p for p, _ in mixed.mixture), 1.0) + + +def test_mixture_over_every_two_qubit_tableau_folds_to_the_sixty_states(): + # breadth-first over Clifford gates from |00>, keeping every tableau reached + # (many per state): folding must give exactly one branch per stabilizer state + gates = [(transform.hadamard_gate, (q,)) for q in range(2)] + gates += [(transform.phase_gate, (q,)) for q in range(2)] + gates += [(transform.x_gate, (q,)) for q in range(2)] + gates += [(transform.cnot_gate, (0, 1)), (transform.cnot_gate, (1, 0))] + seen, frontier, tableaux = set(), [CliffordTableau(2)], [] + while frontier: + next_frontier = [] + for t in frontier: + key = (t.table.tobytes(), t.phase.tobytes()) + if key in seen: + continue + seen.add(key) + tableaux.append(t) + next_frontier.extend(gate(t.copy(), *args) for gate, args in gates) + frontier = next_frontier + + mixed = MixedStabilizer([(1 / len(tableaux), t) for t in tableaux]) + rho = _density(mixed.mixture) + mixed.reduce() + + assert len(mixed.mixture) == 60 + assert np.allclose(_density(mixed.mixture), rho) diff --git a/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py b/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py index 5ebcf933..2801bd71 100644 --- a/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py +++ b/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py @@ -18,12 +18,11 @@ matched entry and moving on, without also re-checking the entry that shifted into its place, would leave later duplicates unmerged. -These tests build mixtures out of tableaux that are ``==`` to each other -- -copies of the very same tableau -- so the only thing under test is whether -every match gets folded in, not whether the equality check itself is generous -enough to recognise two different routes to the same state. That is a -separate limitation of ``CliffordTableau.__eq__``, which compares destabilizer -rows and generator order without canonicalizing, and is not what this tests. +These tests build mixtures out of copies of the very same tableau, so the +only thing under test is whether every match gets folded in. Folding +different tableaux of the same state (other destabilizer rows or signs, or +another generator order) is covered by +tests/test_noise/test_mixed_stabilizer_reduce.py. """ from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau From c6e1090add9abad0586a2826f086dd0f33d55286 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 21:14:32 -0400 Subject: [PATCH 141/227] [fix] Compare _compare_graphs_visual's labels with a list, so the check can pass The label check compared a list with g_relabeled.nodes(), a NodeView. A list never equals a NodeView, so the assert failed on every input and the function never drew anything. It now compares with list(g_relabeled.nodes()), which is what the check meant: the relabeled graph's nodes, in order, are exactly new_labels. Repeated labels and a label list of the wrong length are still rejected. The docstring now describes what the function draws, that every node of new_g must be one of new_labels (it is drawn with the relabeled graph's layout), and when the check raises. The function is private and has no callers, so no results change. --- graphiq/utils/relabel_module.py | 16 +++++++----- tests/test_utils/test_relabel_module.py | 34 ++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 76c63ed9..07ca9973 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -594,18 +594,20 @@ def _equal_graphs(g1, g2): def _compare_graphs_visual(g, new_g, new_labels): """ Visual demonstration of initial graph, the relabeled one. It relabels g by mapping node i to new_labels[i], - meant to draw g, the relabeled graph, and new_g in three stacked panels. - - In the current code this always raises AssertionError at its label check, which compares a list with a - NodeView, so nothing past the relabelling is drawn. + and draws g, the relabeled graph, and new_g in three stacked panels. g is also first drawn on the current + axes. :param g: the initial graph, with nodes labelled 0..n-1 :type g: nx.Graph - :param new_g: a graph to draw next to the relabeled one + :param new_g: a graph to draw next to the relabeled one; every node of new_g must be one of new_labels, + since it is drawn with the relabeled graph's layout :type new_g: nx.Graph :param new_labels: the new labels, where new_labels[i] is the new label of node i :type new_labels: list or numpy.ndarray - :raises AssertionError: always, at the check of the relabeled node labels + :raises AssertionError: if the relabeled graph's nodes, in order, are not new_labels, e.g. for repeated + labels, a label list of the wrong length, or a g whose nodes do not iterate as 0..n-1 + :return: nothing + :rtype: None """ nx.draw_networkx(g, with_labels=True, pos=nx.kamada_kawai_layout(g)) fig, (ax1, ax2, ax3) = plt.subplots(3, figsize=(15, 25)) @@ -613,7 +615,7 @@ def _compare_graphs_visual(g, new_g, new_labels): n = len(new_labels) relabel_map_dict = dict(zip([*range(n)], new_labels)) g_relabeled = nx.relabel_nodes(g, relabel_map_dict) - assert list(new_labels) == g_relabeled.nodes() + assert list(new_labels) == list(g_relabeled.nodes()) nx.draw_networkx( g_relabeled, diff --git a/tests/test_utils/test_relabel_module.py b/tests/test_utils/test_relabel_module.py index b654bc1f..b8f8fbfc 100644 --- a/tests/test_utils/test_relabel_module.py +++ b/tests/test_utils/test_relabel_module.py @@ -14,6 +14,7 @@ # limitations under the License. import math +import matplotlib.pyplot as plt import networkx as nx import numpy as np import pytest @@ -21,7 +22,7 @@ from graphiq.backends.stabilizer.functions.height import height_max from graphiq.benchmarks.graph_states import linear_cluster_state from graphiq.utils.relabel_module import * -from graphiq.utils.relabel_module import _label_finder +from graphiq.utils.relabel_module import _compare_graphs_visual, _label_finder def random_connected_graph(n, p, seed=None): @@ -155,3 +156,34 @@ def test_sort_emit_and_label_map_apply_at_the_high_symmetry_stop(): n_emit = _emitter_counts(found[0]) assert n_emit == sorted(n_emit) assert len(set(n_emit)) > 1 # mixed counts, so the sort is actually exercised + + +def test_compare_graphs_visual_draws_the_three_panels(monkeypatch): + monkeypatch.setattr(plt, "show", lambda: None) + g = nx.path_graph(4) + new_labels = [2, 0, 3, 1] + new_g = nx.relabel_nodes(g, dict(enumerate(new_labels))) + plt.close("all") + try: + _compare_graphs_visual(g, new_g, new_labels) + panels = [ + plt.figure(n) for n in plt.get_fignums() if len(plt.figure(n).axes) == 3 + ] + assert len(panels) == 1 + assert all(ax.collections for ax in panels[0].axes) + finally: + plt.close("all") + + +def test_compare_graphs_visual_rejects_repeated_labels(monkeypatch): + # relabel_nodes merges nodes that share a label, so the relabeled graph + # does not have exactly new_labels as its nodes + monkeypatch.setattr(plt, "show", lambda: None) + g = nx.path_graph(4) + new_labels = [0, 0, 1, 2] + new_g = nx.relabel_nodes(g, dict(enumerate(new_labels))) + try: + with pytest.raises(AssertionError): + _compare_graphs_visual(g, new_g, new_labels) + finally: + plt.close("all") From d84039d48313bb39d7d45884a22266089297a705 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 22:14:09 -0400 Subject: [PATCH 142/227] [fix] Share one noise lookup between the solvers and CircuitDAG.assign_noise SolverBase._identify_noise and CircuitDAG._noisy_gates read the same noise-model mapping with two separately written rule sets that had drifted apart. assign_noise read only bare class names, so the per-leg _control and _target keys that the solvers honour had no effect there. The benchmark maps in graphiq/benchmarks/alternate_circuits.py give their CNOT depolarizing noise only through those keys. AlternateTargetSolver scores candidate circuits through assign_noise, so with those maps it scored every CNOT as noiseless. Both readers now call graphiq/noise/noise_mapping.py. register_section picks the mapping section from the op's register types, and noise_for_op applies one rule set. Maps that use only bare names give the same noise as before, on both paths. Result changes: - assign_noise with noise_model_loss_and_depolarizing now applies the CNOT noise. With the density-matrix compiler and TimeReversedSolver circuits, the infidelity changes from 0.0 to: - linear4: 0.0361 at (0.01, 0.01) and 0.314 at (0.1, 0.1); - ring4: 0.0862 at (0.01, 0.01) and 0.5948 at (0.1, 0.1). - For a controlled-pair op, a bare list value of the wrong length now raises ValueError, on both paths. It used to raise AssertionError in assign_noise and pass silently in _identify_noise. - In _identify_noise, a bare two-element list is now returned as it is, not as a list of two copies. - A per-leg key whose value is NoNoise() now takes precedence over a bare key. No in-tree map hits any of the last three cases. --- graphiq/circuit/circuit_dag.py | 49 +++------- graphiq/noise/noise_mapping.py | 110 ++++++++++++++++++++++ graphiq/solvers/solver_base.py | 43 ++------- tests/test_circuits/test_assign_noise.py | 89 ++++++++++++++++++ tests/test_noise/test_noise_mapping.py | 113 +++++++++++++++++++++++ 5 files changed, 333 insertions(+), 71 deletions(-) create mode 100644 graphiq/noise/noise_mapping.py create mode 100644 tests/test_noise/test_noise_mapping.py diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index 81b6a51d..312b60e4 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -38,6 +38,7 @@ import graphiq.circuit.ops as ops from graphiq.circuit.circuit_base import CircuitBase +from graphiq.noise.noise_mapping import noise_for_op, register_section from graphiq.noise.noise_models import NoNoise from graphiq.utils.circuit_comparison import compare_circuits from graphiq.visualizers.dag import dag_topology_pos @@ -1179,10 +1180,11 @@ def assign_noise(self, noise_model_map): :param noise_model_map: a nested dictionary whose outer keys are register-type sections ("e", "p", "ee", "ep", ...) and whose inner dictionaries map an operation's class name to its noise model; for a - two-qubit operation, the section is its control type followed by its target type, and the noise - model may be a list of two (one per leg). Operations without an entry get NoNoise. A - OneQubitGateWrapper carrying a single model for the whole wrapper keeps it, and the mapping is not applied - to it + two-qubit operation, the section is its control type followed by its target type, and its per-leg keys + ``_control`` and ``_target`` are read first; only when neither is present does the bare class name + apply, and its noise model may then be a list of two (one per leg). Operations without an entry get + NoNoise. A OneQubitGateWrapper carrying a single model for the whole wrapper keeps it, and the mapping is + not applied to it :type noise_model_map: dict[str, dict] :return: a new circuit :rtype: CircuitDAG @@ -1204,13 +1206,15 @@ def _noisy_gates(self, noise_model_map): Operates on a copy, so the circuit itself is not modified. Each OneQubitGateWrapper receives a list with one noise model per wrapped gate, in the order of its ``operations``, since the wrapper pairs ``noise[i]`` with ``operations[i]``; a wrapper carrying a single model for the whole wrapper keeps it and is left unchanged. - A controlled-pair operation receives its section's list of two models, or its single model duplicated. Every - other operation receives the model its register section names. Operations without an entry get NoNoise. + A controlled-pair operation receives its ``_control`` and ``_target`` models, or, when neither key is + present, its bare name's list of two models or its single model duplicated. Every other operation receives + the model its register section names. Operations without an entry get NoNoise. The lookup is + ``graphiq.noise.noise_mapping.noise_for_op``, which ``SolverBase._identify_noise`` also uses. :param noise_model_map: a nested dict of register-type section to operation class name to noise model, as accepted by ``assign_noise`` :type noise_model_map: dict[str, dict] - :raises AssertionError: if a controlled-pair operation's noise list does not have length 2 + :raises ValueError: if a controlled-pair operation's bare noise list does not have length 2 :raises KeyError: if noise_model_map has no entry for an operation's register-type section :return: the operations (Input and Output excluded), each carrying its noise model :rtype: list[ops.OperationBase] @@ -1220,7 +1224,6 @@ def _noisy_gates(self, noise_model_map): seq = self.copy()._slim_seq() noisy_ops = [] for op in seq: - is_controlled = False if isinstance(op, ops.OneQubitGateWrapper): if not isinstance(op.noise, list): # a single model for the whole wrapper is left as it is @@ -1233,34 +1236,8 @@ def _noisy_gates(self, noise_model_map): op.noise = noise_list noisy_ops.append(op) else: - if isinstance( - op, - ( - ops.ControlledPairOperationBase, - ops.ClassicalControlledPairOperationBase, - ), - ): - control_type = op.control_type - target_type = op.target_type - mapping = noise_model_map[control_type + target_type] - is_controlled = True - else: - mapping = noise_model_map[op.reg_type] - name = type(op).__name__ - if name in mapping: - noise_object = mapping[name] - else: - noise_object = NoNoise() - if is_controlled: - if isinstance(noise_object, list): - assert ( - len(noise_object) == 2 - ), "controlled gate noise list must be of length 2" - op.noise = noise_object - else: - op.noise = [noise_object, noise_object] - else: - op.noise = noise_object + mapping = noise_model_map[register_section(op)] + op.noise = noise_for_op(op, mapping) noisy_ops.append(op) return noisy_ops diff --git a/graphiq/noise/noise_mapping.py b/graphiq/noise/noise_mapping.py new file mode 100644 index 00000000..5f5ba29a --- /dev/null +++ b/graphiq/noise/noise_mapping.py @@ -0,0 +1,110 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +A single noise lookup shared by ``SolverBase._identify_noise`` and +``CircuitDAG._noisy_gates``, so the two callers read a noise-model mapping the same way instead +of maintaining two independently drifting implementations. + +A noise-model mapping is a nested dict: the outer keys are register-type sections (``"e"``, +``"p"``, ``"ee"``, ``"ep"`` in every production mapping), and each section maps an operation's +class name (optionally suffixed ``_control``/``_target`` for a two-legged op) to a noise model. +""" + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm + + +def register_section(op) -> str: + """ + The mapping section an operation instance reads its noise from. + + For a ``ControlledPairOperationBase`` or ``ClassicalControlledPairOperationBase`` + instance this is ``op.control_type + op.target_type`` (e.g. ``"ee"``, ``"ep"``). For every + other op it is ``op.reg_type``. + + Every production mapping carries exactly the four sections ``"e"``, ``"p"``, ``"ee"``, + ``"ep"``. A section this function returns that the mapping does not carry (e.g. a + photon-controlled pair op giving ``"pe"`` or ``"pp"``) is not handled here: the caller's own + lookup raises ``KeyError``, exactly as ``CircuitDAG._noisy_gates`` did before this lookup was + shared. + + :param op: an operation instance + :type op: ops.OperationBase + :return: the section name + :rtype: str + """ + if isinstance( + op, (ops.ControlledPairOperationBase, ops.ClassicalControlledPairOperationBase) + ): + return op.control_type + op.target_type + + return op.reg_type + + +def noise_for_op(op, section): + """ + The noise model(s) ``op`` reads out of one mapping section. + + ``op`` may be a class or an instance. For a ``ControlledPairOperationBase`` or + ``ClassicalControlledPairOperationBase`` (control, target), the per-leg keys + ``_control`` / ``_target`` are read first; the bare ```` key is only used as a + fallback when *neither* per-leg key is present, in which case a scalar value is broadcast to + both legs and a list value is returned as-is after checking it has length 2. So a section + that sets a per-leg key for one leg and also sets the bare key does not fall back to the + bare key for the other leg; that leg is ``NoNoise()``. + + For every other op, only the bare ```` key is read; its value (or ``NoNoise()``) is + returned unchanged. + + This matches what ``SolverBase._identify_noise`` returned before it delegated here for + bare scalar keys and for per-leg keys, except in three cases for a pair op. A bare list + value used to come back as a list of two copies of that list; a length-2 list is now + returned as it is, and any other length raises ``ValueError``. A bare key used to reach both + legs when every per-leg key present had the value ``NoNoise()``; a present per-leg key now + wins. + + :param op: an operation, as a class or an instance + :type op: type[ops.OperationBase] or ops.OperationBase + :param section: one section of a noise-model mapping (e.g. ``noise_model_mapping["ee"]``) + :type section: dict + :return: a noise model, or a list of two for a pair op + :rtype: nm.NoiseBase or list[nm.NoiseBase] + :raises ValueError: if a pair op's bare list value does not have length 2 + """ + is_instance = isinstance(op, ops.OperationBase) + cls = type(op) if is_instance else op + op_name = cls.__name__ + + if issubclass( + cls, (ops.ControlledPairOperationBase, ops.ClassicalControlledPairOperationBase) + ): + op_control = op_name + "_control" + op_target = op_name + "_target" + if op_control in section or op_target in section: + control_noise = section.get(op_control, nm.NoNoise()) + target_noise = section.get(op_target, nm.NoNoise()) + return [control_noise, target_noise] + if op_name in section: + value = section[op_name] + if isinstance(value, list): + if len(value) != 2: + raise ValueError( + f"{op_name} noise list must have length 2, got {len(value)}" + ) + return value + return [value, value] + return [nm.NoNoise(), nm.NoNoise()] + + return section.get(op_name, nm.NoNoise()) diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index b97d8968..b9cd3166 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -29,6 +29,7 @@ from graphiq.circuit.circuit_base import CircuitBase, ops from graphiq.io import IO from graphiq.metrics import MetricBase +from graphiq.noise.noise_mapping import noise_for_op class SolverBase(ABC): @@ -159,51 +160,23 @@ def _wrap_noise(self, op, noise_model_mapping): def _identify_noise(self, op, noise_model_mapping): """ - A helper function to identify the noise model for an operation + A helper function to identify the noise model for an operation. Delegates to + ``graphiq.noise.noise_mapping.noise_for_op``, which also backs + ``CircuitDAG._noisy_gates``, so both callers read a noise-model mapping the same way. :param op: an operation :type op: ops.OperationBase :param noise_model_mapping: a dictionary that stores the mapping between an operation and its associated noise model. A two-legged op is looked up by its per-leg keys, ``_control`` and ``_target``, first; the bare ```` key is only used as a - fallback when neither per-leg key is present, in which case the same model is - returned for both legs + fallback when neither per-leg key is present, in which case a single model is + returned for both legs and a list of two is returned as it is :type noise_model_mapping: dict + :raises ValueError: if a two-legged op's bare list value does not have length 2 :return: a noise model :rtype: nm.NoiseBase """ - # first make sure whether the passed operations are instances (of a class) or classes. - if isinstance(op, ops.OperationBase): - # if op is an instance, turn it into the class itself. - op = type(op) - # op is a class here, so this must be issubclass, not isinstance - if issubclass( - op, - (ops.ControlledPairOperationBase, ops.ClassicalControlledPairOperationBase), - ): - op_name = op.__name__ - op_control = op.__name__ + "_control" - op_target = op.__name__ + "_target" - if op_control in noise_model_mapping.keys(): - control_noise = noise_model_mapping[op_control] - else: - control_noise = nm.NoNoise() - if op_target in noise_model_mapping.keys(): - target_noise = noise_model_mapping[op_target] - else: - target_noise = nm.NoNoise() - if op_name in noise_model_mapping.keys() and type( - target_noise - ) == nm.NoNoise == type(control_noise): - return [noise_model_mapping[op_name], noise_model_mapping[op_name]] - else: - return [control_noise, target_noise] - else: - op_name = op.__name__ - if op_name in noise_model_mapping.keys(): - return noise_model_mapping[op_name] - else: - return nm.NoNoise() + return noise_for_op(op, noise_model_mapping) class RandomSearchSolverSetting(ABC): diff --git a/tests/test_circuits/test_assign_noise.py b/tests/test_circuits/test_assign_noise.py index b9a6cb2d..1efe2092 100644 --- a/tests/test_circuits/test_assign_noise.py +++ b/tests/test_circuits/test_assign_noise.py @@ -18,8 +18,10 @@ import graphiq.circuit.ops as ops import graphiq.noise.noise_models as nm from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.benchmarks.alternate_circuits import noise_model_loss_and_depolarizing from graphiq.circuit.circuit_dag import CircuitDAG from graphiq.noise.monte_carlo_noise import McNoiseMap, MonteCarloNoise +from graphiq.solvers.solver_base import SolverBase def _assert_every_leg_is_no_noise(op): @@ -214,3 +216,90 @@ def test_assign_noise_leaves_a_whole_wrapper_model_in_place(): "SigmaX", "Identity", } + + +class _MinimalSolver(SolverBase): + """A concrete SolverBase subclass with no search behaviour, so that ``_identify_noise`` can be called + without constructing a full solver.""" + + def solve(self, *args): + raise NotImplementedError + + +def test_assign_noise_honours_per_leg_keys(): + control_noise = nm.DepolarizingNoise(0.1) + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + + mapping = { + "e": {}, + "p": {}, + "ee": {"CNOT_control": control_noise}, + "ep": {}, + } + noisy = circuit.assign_noise(mapping) + + cnots = [op for op in noisy.sequence() if isinstance(op, ops.CNOT)] + assert len(cnots) == 1 + assert cnots[0].noise[0] is control_noise + assert isinstance(cnots[0].noise[1], nm.NoNoise) + + +def test_assign_noise_and_identify_noise_agree(): + ee_control_noise = nm.DepolarizingNoise(0.1) + ee_target_noise = nm.DepolarizingNoise(0.2) + ep_noise = nm.DepolarizingNoise(0.3) + hadamard_noise = nm.DepolarizingNoise(0.4) + + mapping = { + "e": {"Hadamard": hadamard_noise}, + "p": {}, + "ee": { + "CNOT_control": ee_control_noise, + "CNOT_target": ee_target_noise, + }, + "ep": {"MeasurementCNOTandReset": ep_noise}, + } + + circuit = CircuitDAG(n_emitter=2, n_photon=1, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + + noisy = circuit.assign_noise(mapping) + solver = _MinimalSolver(target=None, metric=None, compiler=None) + # each op's section, picked by hand + sections = {ops.Hadamard: "e", ops.CNOT: "ee", ops.MeasurementCNOTandReset: "ep"} + + checked = 0 + for op in noisy.sequence(): + if isinstance(op, (ops.Input, ops.Output)): + continue + expected = solver._identify_noise(type(op), mapping[sections[type(op)]]) + if isinstance(expected, list): + assert len(op.noise) == len(expected) + for actual_leg, expected_leg in zip(op.noise, expected): + assert actual_leg is expected_leg + else: + assert op.noise is expected + checked += 1 + assert checked == 3 + + +def test_assign_noise_with_the_loss_and_depolarizing_benchmark_mapping_reaches_the_cnot_legs(): + circuit = CircuitDAG(n_emitter=2, n_photon=1, n_classical=0) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + + noisy = circuit.assign_noise(noise_model_loss_and_depolarizing(0.1, 0.05)) + + ee_cnot, ep_cnot = [op for op in noisy.sequence() if isinstance(op, ops.CNOT)] + assert ee_cnot.target_type == "e" and ep_cnot.target_type == "p" + assert isinstance(ee_cnot.noise[0], nm.DepolarizingNoise) + assert isinstance(ee_cnot.noise[1], nm.DepolarizingNoise) + assert isinstance(ep_cnot.noise[0], nm.DepolarizingNoise) + assert isinstance(ep_cnot.noise[1], nm.NoNoise) diff --git a/tests/test_noise/test_noise_mapping.py b/tests/test_noise/test_noise_mapping.py new file mode 100644 index 00000000..8c212cf1 --- /dev/null +++ b/tests/test_noise/test_noise_mapping.py @@ -0,0 +1,113 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.noise.noise_mapping import noise_for_op, register_section + +# --- register_section --- + + +def test_register_section_of_a_pair_op_uses_control_and_target_types(): + op = ops.CNOT(control=0, control_type="e", target=1, target_type="e") + assert register_section(op) == "ee" + + op = ops.CNOT(control=0, control_type="e", target=1, target_type="p") + assert register_section(op) == "ep" + + +def test_register_section_of_a_classically_controlled_pair_op(): + op = ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + assert register_section(op) == "ep" + + +def test_register_section_of_a_one_qubit_op_uses_reg_type(): + op = ops.Hadamard(register=0, reg_type="p") + assert register_section(op) == "p" + + +# --- noise_for_op: pair ops (class and instance) --- + + +def test_noise_for_op_on_a_pair_class_honours_per_leg_keys(): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + section = {"CNOT_control": control_noise, "CNOT_target": target_noise} + + result = noise_for_op(ops.CNOT, section) + + assert result[0] is control_noise + assert result[1] is target_noise + + +def test_noise_for_op_on_a_pair_instance_honours_per_leg_keys(): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + section = {"CNOT_control": control_noise, "CNOT_target": target_noise} + op = ops.CNOT(control=0, target=1) + + result = noise_for_op(op, section) + + assert result[0] is control_noise + assert result[1] is target_noise + + +def test_noise_for_op_on_a_pair_op_bare_key_reaches_both_legs(): + noise = nm.DepolarizingNoise(0.1) + section = {"CNOT": noise} + + result = noise_for_op(ops.CNOT, section) + + assert len(result) == 2 + assert result[0] is noise + assert result[1] is noise + + +def test_noise_for_op_on_a_pair_op_bare_key_with_a_two_element_list_passes_through(): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + section = {"CNOT": [control_noise, target_noise]} + + result = noise_for_op(ops.CNOT, section) + + assert result[0] is control_noise + assert result[1] is target_noise + + +def test_noise_for_op_on_a_pair_op_bare_key_with_a_three_element_list_raises(): + section = {"CNOT": [nm.NoNoise(), nm.NoNoise(), nm.NoNoise()]} + + with pytest.raises(ValueError): + noise_for_op(ops.CNOT, section) + + +def test_noise_for_op_on_a_pair_op_explicit_no_noise_leg_key_takes_precedence_over_the_bare_key(): + bare_noise = nm.DepolarizingNoise(0.1) + section = {"CNOT": bare_noise, "CNOT_control": nm.NoNoise()} + + result = noise_for_op(ops.CNOT, section) + + assert isinstance(result[0], nm.NoNoise) + assert isinstance(result[1], nm.NoNoise) + + +def test_noise_for_op_on_a_one_qubit_op_reads_the_bare_key(): + noise = nm.DepolarizingNoise(0.1) + + assert noise_for_op(ops.Hadamard, {"Hadamard": noise}) is noise + assert isinstance(noise_for_op(ops.Hadamard, {}), nm.NoNoise) From 579a1f4ecbb44075f656848502226e609fa67c0b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 22:14:09 -0400 Subject: [PATCH 143/227] [fix] Reject a graph with an isolated vertex in graph_to_circ instead of raising IndexError graph_to_circ in graphiq/solvers/alternate_target_solver.py, and the copy in graphiq/data_collection/correlation_module.py, failed on any graph with an isolated vertex, with "IndexError: index 0 is out of bounds" from TimeReversedSolver._add_photon_absorption. An isolated vertex is a photon entangled with no other qubit, so no emitter can absorb it. Both functions now raise a ValueError that says so. The check runs right after an adjacency-matrix input is converted to a graph. The check matches the failing set exactly. Every simple graph with 1 to 7 nodes was run through both functions: - the 209 graphs with an isolated vertex all raised the IndexError; - the 1043 without one all solved, including 48 disconnected graphs. So no input that worked before is rejected, and no result changes. The message gives the number of isolated vertices, not their labels. AlternateTargetSolver passes relabelled graphs to graph_to_circ, so labels would not name the target's vertices. --- graphiq/data_collection/correlation_module.py | 9 +++ graphiq/solvers/alternate_target_solver.py | 9 +++ .../test_correlation_module.py | 56 +++++++++++++++++++ .../test_alternate_target_solver.py | 34 +++++++++++ 4 files changed, 108 insertions(+) create mode 100644 tests/test_data_collection/test_correlation_module.py diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 159e21d2..4e62d4c9 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -1035,12 +1035,21 @@ def graph_to_circ(graph, show=False): :type show: bool :return: A circuit corresponding to the input graph :rtype: CircuitDAG + :raises ValueError: if the graph has an isolated vertex """ if not isinstance(graph, nx.Graph): graph = nx.from_numpy_array(graph) assert isinstance( graph, nx.Graph ), "input must be a networkx graph object or a numpy adjacency matrix" + n_isolated = nx.number_of_isolates(graph) + if n_isolated: + raise ValueError( + f"graph has an isolated vertex ({n_isolated} in total); an isolated " + "vertex is a photon entangled with no other qubit, which " + "TimeReversedSolver cannot absorb into an emitter, so graph_to_circ " + "does not support it" + ) tabs = converter.graph_to_stabilizer(graph) c_tableau = CliffordTableau(tabs[0][1]) ideal_state = QuantumState(c_tableau, rep_type="stabilizer") diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 5a278c8f..6bc75a97 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -513,12 +513,21 @@ def graph_to_circ(graph, noise_model_mapping=None, show=False): :type show: bool :return: A circuit corresponding to the input graph :rtype: CircuitDAG + :raises ValueError: if the graph has an isolated vertex """ if not isinstance(graph, nx.Graph): graph = nx.from_numpy_array(graph) assert isinstance( graph, nx.Graph ), "input must be a networkx graph object or a numpy adjacency matrix" + n_isolated = nx.number_of_isolates(graph) + if n_isolated: + raise ValueError( + f"graph has an isolated vertex ({n_isolated} in total); an isolated " + "vertex is a photon entangled with no other qubit, which " + "TimeReversedSolver cannot absorb into an emitter, so graph_to_circ " + "does not support it" + ) n = graph.number_of_nodes() c_tableau = get_clifford_tableau_from_graph(graph) ideal_state = QuantumState(c_tableau, rep_type="s") diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py new file mode 100644 index 00000000..4bdbef35 --- /dev/null +++ b/tests/test_data_collection/test_correlation_module.py @@ -0,0 +1,56 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import networkx as nx +import numpy as np +import pytest + +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.data_collection.correlation_module import graph_to_circ +from graphiq.metrics import Infidelity +from graphiq.state import QuantumState + +_p3_plus_k1 = nx.disjoint_union(nx.path_graph(3), nx.empty_graph(1)) + + +@pytest.mark.parametrize( + "graph", + [ + _p3_plus_k1, + nx.disjoint_union(nx.empty_graph(1), nx.path_graph(3)), + nx.to_numpy_array(_p3_plus_k1), + ], + ids=["isolate_last", "isolate_first", "adjacency_matrix"], +) +def test_graph_to_circ_rejects_a_graph_with_an_isolated_vertex(graph): + with pytest.raises(ValueError, match="isolated"): + graph_to_circ(graph) + + +@pytest.mark.parametrize("outcome", [0, 1]) +def test_graph_to_circ_solves_a_disconnected_graph_without_an_isolated_vertex(outcome): + graph = nx.disjoint_union(nx.path_graph(2), nx.path_graph(2)) + circuit = graph_to_circ(graph) + + target = QuantumState(get_clifford_tableau_from_graph(graph), rep_type="s") + # force every measurement to 0, then to 1, so each outcome is checked every run + compiler = StabilizerCompiler() + compiler.measurement_determinism = outcome + state = compiler.compile(circuit) + n_photon = graph.number_of_nodes() + state.partial_trace(list(range(n_photon)), [2] * circuit.n_quantum) + assert np.isclose(Infidelity(target).evaluate(state, circuit), 0.0) diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index 56de7323..b038f3fe 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -286,3 +286,37 @@ def test_mc_depol_builds_the_expected_noise_map(): # The residual is built as 1 - 3 * (rate / 3), which differs from 1 - rate by one ulp # for some rates, so the comparison is approximate rather than bit-exact. assert no_noise_prob == pytest.approx(1 - rate) + + +# graph_to_circ input checks + +_p3_plus_k1 = nx.disjoint_union(nx.path_graph(3), nx.empty_graph(1)) + + +@pytest.mark.parametrize( + "graph", + [ + _p3_plus_k1, + nx.disjoint_union(nx.empty_graph(1), nx.path_graph(3)), + nx.to_numpy_array(_p3_plus_k1), + ], + ids=["isolate_last", "isolate_first", "adjacency_matrix"], +) +def test_graph_to_circ_rejects_a_graph_with_an_isolated_vertex(graph): + with pytest.raises(ValueError, match="isolated"): + graph_to_circ(graph) + + +@pytest.mark.parametrize("outcome", [0, 1]) +def test_graph_to_circ_solves_a_disconnected_graph_without_an_isolated_vertex(outcome): + graph = nx.disjoint_union(nx.path_graph(2), nx.path_graph(2)) + circuit = graph_to_circ(graph) + + target = QuantumState(get_clifford_tableau_from_graph(graph), rep_type="s") + # force every measurement to 0, then to 1, so each outcome is checked every run + compiler = StabilizerCompiler() + compiler.measurement_determinism = outcome + state = compiler.compile(circuit) + n_photon = graph.number_of_nodes() + state.partial_trace(list(range(n_photon)), [2] * circuit.n_quantum) + assert np.isclose(Infidelity(target).evaluate(state, circuit), 0.0) From fcac0a32e8ecb4192ee00cae79f60e10289a8602 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 25 Sep 2026 23:21:51 -0400 Subject: [PATCH 144/227] [fix] Give a wrapper the section's OneQubitGateWrapper noise in assign_noise, as the solvers do A noise-model section can carry a "OneQubitGateWrapper" key: one model for a whole OneQubitGateWrapper. SolverBase._wrap_noise honours it. CircuitDAG.assign_noise ignored it and looked each wrapped gate up by name instead. So under noise_model_loss_and_depolarizing and noise_model_pure_loss in graphiq/benchmarks/alternate_circuits.py, assign_noise gave a photon wrapper such as [Hadamard, Phase, Hadamard, Phase, SigmaX] 2 or 5 losses instead of the one a solver gives it. _noisy_gates now gives a wrapper the key's value as the wrapper's constructor would store it, with a single NoNoise expanded to one per gate. Sections without the key are unchanged. Result change: only AlternateTargetSolver scores with noise simulation on and Monte Carlo off go through assign_noise. The benchmark and example searches build noise through the solvers and are unchanged. For graph_to_circ circuits on the 81 connected gnp(4-6, 0.5) graphs with seeds 0-39, compiled with the stabilizer compiler: - loss+depol (0.1, 0.1): 38 infidelities drop, by 0.0099 to 0.0949; - pure loss (0.1): 38 infidelities drop, by 0.2256 to 0.4237. linear4 and ring4 are unchanged. The density-matrix compiler, the AlternateTargetSolver default, shows no change on these circuits, because its measurement renormalises the state and the extra losses all precede a measurement. A non-addition model under the key has no "After gate" parameter, so it now raises KeyError when the circuit is compiled, as it already does on the solver path. MonteCarloNoise still reads wrapped gates one by one. --- graphiq/circuit/circuit_dag.py | 35 ++++++--- tests/test_circuits/test_assign_noise.py | 92 +++++++++++++++++++++++- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index 312b60e4..e7c8e8df 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -1183,8 +1183,10 @@ def assign_noise(self, noise_model_map): two-qubit operation, the section is its control type followed by its target type, and its per-leg keys ``_control`` and ``_target`` are read first; only when neither is present does the bare class name apply, and its noise model may then be a list of two (one per leg). Operations without an entry get - NoNoise. A OneQubitGateWrapper carrying a single model for the whole wrapper keeps it, and the mapping is - not applied to it + NoNoise. A section's ``"OneQubitGateWrapper"`` key gives each wrapper in it that key's value (a + single model for the whole wrapper, or a list with one per wrapped gate), and the section's per-gate + keys are then not read for wrappers. A OneQubitGateWrapper carrying a single model for the whole + wrapper keeps it, and the mapping is not applied to it :type noise_model_map: dict[str, dict] :return: a new circuit :rtype: CircuitDAG @@ -1203,9 +1205,14 @@ def _noisy_gates(self, noise_model_map): """ Helper function that returns the circuit's operations with a noise model attached to each - Operates on a copy, so the circuit itself is not modified. Each OneQubitGateWrapper receives a list with one - noise model per wrapped gate, in the order of its ``operations``, since the wrapper pairs ``noise[i]`` with - ``operations[i]``; a wrapper carrying a single model for the whole wrapper keeps it and is left unchanged. + Operates on a copy, so the circuit itself is not modified. A wrapper carrying a single model for the whole + wrapper keeps it and is left unchanged. Otherwise, when the wrapper's section has a ``"OneQubitGateWrapper"`` + key, the wrapper receives that value as it is, except that a NoNoise becomes one NoNoise per wrapped gate, as + in the wrapper's constructor; this is the noise ``SolverBase._wrap_noise`` gives a wrapper. A single model + under the key is applied once for the whole wrapper by ``OneQubitGateWrapper.unwrap``, which reads its "After + gate" parameter, so a model without one (not an addition-type model) raises KeyError when the circuit is + compiled. Without the key, the wrapper receives a list with one noise model per wrapped gate, in the order of + its ``operations``, since the wrapper pairs ``noise[i]`` with ``operations[i]``. A controlled-pair operation receives its ``_control`` and ``_target`` models, or, when neither key is present, its bare name's list of two models or its single model duplicated. Every other operation receives the model its register section names. Operations without an entry get NoNoise. The lookup is @@ -1229,11 +1236,19 @@ def _noisy_gates(self, noise_model_map): # a single model for the whole wrapper is left as it is noisy_ops.append(op) continue - op_type_seq = list(op.operations) - noise_list = self._find_wrapped_noise( - op_type_seq, noise_model_map[op.reg_type] - ) - op.noise = noise_list + section = noise_model_map[op.reg_type] + if "OneQubitGateWrapper" in section: + # the key's value for the whole wrapper, as + # SolverBase._wrap_noise gives it + noise = section["OneQubitGateWrapper"] + # as in the wrapper's constructor, a single NoNoise becomes + # one per wrapped gate + if isinstance(noise, NoNoise): + noise = len(op.operations) * [NoNoise()] + op.noise = noise + else: + op_type_seq = list(op.operations) + op.noise = self._find_wrapped_noise(op_type_seq, section) noisy_ops.append(op) else: mapping = noise_model_map[register_section(op)] diff --git a/tests/test_circuits/test_assign_noise.py b/tests/test_circuits/test_assign_noise.py index 1efe2092..28f8cf2c 100644 --- a/tests/test_circuits/test_assign_noise.py +++ b/tests/test_circuits/test_assign_noise.py @@ -13,12 +13,17 @@ # See the License for the specific language governing permissions and # limitations under the License. import numpy as np +import pytest import graphiq.benchmarks.circuits as circ import graphiq.circuit.ops as ops import graphiq.noise.noise_models as nm from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler -from graphiq.benchmarks.alternate_circuits import noise_model_loss_and_depolarizing +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.benchmarks.alternate_circuits import ( + noise_model_loss_and_depolarizing, + noise_model_pure_loss, +) from graphiq.circuit.circuit_dag import CircuitDAG from graphiq.noise.monte_carlo_noise import McNoiseMap, MonteCarloNoise from graphiq.solvers.solver_base import SolverBase @@ -303,3 +308,88 @@ def test_assign_noise_with_the_loss_and_depolarizing_benchmark_mapping_reaches_t assert isinstance(ee_cnot.noise[1], nm.DepolarizingNoise) assert isinstance(ep_cnot.noise[0], nm.DepolarizingNoise) assert isinstance(ep_cnot.noise[1], nm.NoNoise) + + +# the five-gate photon wrapper graph_to_circ emits +_FIVE_GATE_WRAPPER = [ops.Hadamard, ops.Phase, ops.Hadamard, ops.Phase, ops.SigmaX] + + +def _one_photon_wrapper_circuit(operations): + circuit = CircuitDAG(n_emitter=0, n_photon=1, n_classical=0) + circuit.add(ops.OneQubitGateWrapper(operations, register=0, reg_type="p")) + return circuit + + +def _photon_only_mapping(photon_section): + return {"e": {}, "p": photon_section, "ee": {}, "ep": {}} + + +def test_assign_noise_gives_a_wrapper_the_section_wrapper_key_model(): + gate_noise = nm.DepolarizingNoise(0.1) + wrapper_noise = nm.PhotonLoss(0.1) + section = { + "Hadamard": gate_noise, + "Phase": gate_noise, + "SigmaX": gate_noise, + "OneQubitGateWrapper": wrapper_noise, + } + circuit = _one_photon_wrapper_circuit(_FIVE_GATE_WRAPPER) + + noisy = circuit.assign_noise(_photon_only_mapping(section)) + + assert _the_wrapper(noisy).noise is wrapper_noise + + +@pytest.mark.parametrize( + "wrapper_noise", + [nm.PhotonLoss(0.1), [nm.PhotonLoss(0.1)] + 4 * [nm.NoNoise()]], + ids=["scalar", "list"], +) +def test_assign_noise_wrapper_key_agrees_with_the_solvers(wrapper_noise): + section = { + "Hadamard": nm.DepolarizingNoise(0.1), + "OneQubitGateWrapper": wrapper_noise, + } + solver = _MinimalSolver(target=None, metric=None, compiler=None) + solver_built = ops.OneQubitGateWrapper( + _FIVE_GATE_WRAPPER, + register=0, + reg_type="p", + noise=solver._wrap_noise(_FIVE_GATE_WRAPPER, section), + ) + + noisy = _one_photon_wrapper_circuit(_FIVE_GATE_WRAPPER).assign_noise( + _photon_only_mapping(section) + ) + + assert _the_wrapper(noisy).noise is solver_built.noise + assert solver_built.noise is wrapper_noise + + +def test_assign_noise_no_noise_wrapper_key_gives_every_wrapped_gate_no_noise(): + section = { + "Hadamard": nm.DepolarizingNoise(0.1), + "OneQubitGateWrapper": nm.NoNoise(), + } + + noisy = _one_photon_wrapper_circuit(_FIVE_GATE_WRAPPER).assign_noise( + _photon_only_mapping(section) + ) + + noise = _the_wrapper(noisy).noise + assert isinstance(noise, list) + assert len(noise) == len(_FIVE_GATE_WRAPPER) + assert all(isinstance(model, nm.NoNoise) for model in noise) + + +def test_assign_noise_wrapper_key_loss_is_applied_once_per_wrapper(): + noisy = _one_photon_wrapper_circuit(_FIVE_GATE_WRAPPER).assign_noise( + noise_model_pure_loss(0.1) + ) + + compiler = StabilizerCompiler() + compiler.noise_simulation = True + state = compiler.compile(circuit=noisy) + + # one loss for the wrapper, not one per lossy gate (0.9 ** 5) + assert np.isclose(state.rep_data.probability, 0.9) From 0281091702eb851e46e22b8bee4150c9f5db8397 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sat, 26 Sep 2026 17:14:51 -0400 Subject: [PATCH 145/227] [fix] Keep a density matrix's photon-loss trace through measurement, and score it in Infidelity DensityMatrix.apply_measurement divided the projected state by the outcome's probability, which reset the trace to 1. A photon loss before a measurement was therefore erased, and the density-matrix backend scored lossy circuits as if nothing had been lost. A loss after the last measurement left a trace below 1, which made Infidelity's density-matrix arm fail dmf.fidelity's trace-1 assertion. apply_measurement now reads the trace before measuring. When it differs from 1 by more than 1e-12, the result is multiplied by it, so the division is by the outcome's probability relative to the trace. A state whose trace is 1 up to rounding gives exactly the result it gave before. Infidelity scores a density-matrix state with trace t not close to 1 as t * F(target, rho / t). The fidelity scales with rho's trace, so the missing trace counts as infidelity, as the stabilizer arm's 1 - sum_i p_i F_i does. Results change for density-matrix runs with photon loss: they now count the loss instead of ignoring it or raising AssertionError, and against a stabilizer target their infidelity matches the stabilizer compiler's up to rounding (linear4 under loss 0.1: 0.0 -> 0.1). convert_representation("g") on such a state raises AssertionError instead of TypeError. A state with trace above 1, which no in-tree channel produces, keeps that trace and is scored rather than raising. AlternateTargetSolver scores candidates on the density-matrix compiler by default, so a run given a photon-loss noise_model_mapping, such as noise_model_pure_loss or noise_model_loss_and_depolarizing in graphiq/benchmarks/alternate_circuits.py, now gets the stabilizer noise compiler's scores up to rounding; on path_graph(4), cycle_graph(4) and star_graph(4) with those maps its solve() raised AssertionError. No in-tree default, example or benchmark result changes: nothing in the package gives AlternateTargetSolver a photon-loss map, search_for_alternative_circuits uses those maps with the stabilizer compiler, the examples, benchmarks and notebooks that use the density-matrix compiler apply no photon loss, and the "depolarizing" mapping the data-collection helpers use is trace-preserving, with bit-identical scores. --- graphiq/backends/density_matrix/state.py | 9 ++ graphiq/metrics.py | 21 +++- tests/test_metrics/test_metrics.py | 106 ++++++++++++++++++ .../density_matrix/test_measurements.py | 62 ++++++++++ 4 files changed, 193 insertions(+), 5 deletions(-) diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index 278f2d5b..9431e77c 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -160,6 +160,9 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") """ Apply a measurement, either deterministically (with a certain outcome) or probabilistically + The post-measurement state keeps the pre-measurement trace, so a subnormalised state (photon loss) + stays subnormalised. A trace within 1e-12 of 1 counts as 1, so a loss below 1e-12 is not kept. + :param projectors: a list of projective measurements in the computational basis :type projectors: list[numpy.ndarray] :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected @@ -176,6 +179,7 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") """ if self._data.shape == projectors[0].shape: + trace = np.real(np.trace(self._data)) probs = [] for m in projectors: prob = np.real(np.trace(self._data @ m)) @@ -208,6 +212,11 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") # this assumes that the projector, m, has the properties: m = sqrt(m) and m = m.dag() self._data = (m @ self._data @ np.transpose(np.conjugate(m))) / norm + # divide by probs[outcome] / trace instead, the outcome's probability + # relative to the state's trace, so photon loss is not renormalised + # away; a trace of 1 up to rounding drift keeps the old result exactly + if abs(trace - 1.0) > 1e-12: + self._data = self._data * trace else: raise ValueError( diff --git a/graphiq/metrics.py b/graphiq/metrics.py index f32035dd..4d596054 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -73,8 +73,9 @@ class Infidelity(MetricBase): Zero means the state matches the target. With a stabilizer target and a MixedStabilizer state, the value is 1 - sum_i p_i F_i over the mixture's tableaux. The weights p_i need not sum to 1 (photon loss lowers them), so - missing probability counts as infidelity. The target must be in the stabilizer ("s") or density matrix ("dm") - representation. + missing probability counts as infidelity. Likewise, a density matrix state with trace t below 1 (photon loss) + is scored as t F(rho / t), so its missing trace counts as infidelity. The target must be in the stabilizer + ("s") or density matrix ("dm") representation. """ def __init__(self, target, log_steps=1, *args, **kwargs): @@ -119,7 +120,8 @@ def evaluate(self, state, circuit): Not used for the fidelity evaluation, but argument is provided for API consistency :type circuit: CircuitBase (or subclass of it) :raises AssertionError: if the target is a MixedStabilizer that is not one tableau with probability 1.0, - or, for a density matrix target, if the state or the target is not a valid density matrix + or, for a density matrix target, if the target is not a valid density matrix, or the state is not + positive semi-definite or has zero trace :raises ValueError: if the target's rep_type is neither "s" nor "dm" :return: infidelity = 1 - fidelity :rtype: float @@ -148,11 +150,20 @@ def evaluate(self, state, circuit): elif self.target.rep_type == "dm": if state.rep_type == "dm": - fid = dmf.fidelity(self.target.rep_data.data, state.rep_data.data) + rho = state.rep_data.data else: tmp_state = state.copy() tmp_state.convert_representation("dm") - fid = dmf.fidelity(self.target.rep_data.data, tmp_state.rep_data.data) + rho = tmp_state.rep_data.data + target = self.target.rep_data.data + trace = np.real(np.trace(rho)) + if np.isclose(trace, 1.0): + fid = dmf.fidelity(target, rho) + else: + # photon loss leaves trace < 1; the fidelity scales with rho's + # trace, F(sigma, t rho') = t F(sigma, rho'), so the missing trace + # counts as infidelity, as the stabilizer arm's sum_i p_i F_i does + fid = trace * dmf.fidelity(target, rho / trace) else: raise ValueError("Cannot compute the infidelity.") diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py index fd5384e8..626a8768 100644 --- a/tests/test_metrics/test_metrics.py +++ b/tests/test_metrics/test_metrics.py @@ -24,12 +24,16 @@ import graphiq.backends.density_matrix.functions as dmf import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, ) from graphiq.backends.state_rep_conversion import graph_to_density +from graphiq.benchmarks.alternate_circuits import noise_model_pure_loss from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.solvers.alternate_target_solver import graph_to_circ from graphiq.state import QuantumState from graphiq.metrics import ( CircuitCnotCount, @@ -487,3 +491,105 @@ def test_trace_distance_raises_when_target_is_not_a_density_matrix(): with pytest.raises(ValueError, match="Cannot compute the trace distance"): TraceDistance(target=target).evaluate(state, None) + + +def _path3_mixture(): + return 0.5 * graph_to_density(nx.path_graph(3)) + 0.5 * graph_to_density( + nx.star_graph(2) + ) + + +@pytest.mark.parametrize( + "rho", [graph_to_density(nx.path_graph(3)), _path3_mixture()], ids=["pure", "mixed"] +) +def test_infidelity_counts_the_missing_trace_of_a_lossy_density_matrix(rho): + target = QuantumState(rho, rep_type="dm") + state = QuantumState(rho, rep_type="dm") + # lose 0.1 in place; building the state from 0.9 * rho would renormalise it + state.rep_data.apply_channel([np.sqrt(0.9) * np.eye(8)]) + + assert Infidelity(target=target).evaluate(state, None) == pytest.approx(0.1) + + +def test_infidelity_of_a_density_matrix_with_trace_one_up_to_rounding_is_unchanged(): + target = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + state = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + # four gate and dephasing steps leave the trace at 1 - 1.4e-15 + h = np.array([[1, 1], [1, -1]]) / np.sqrt(2) + unitaries = [np.kron(np.eye(2), np.kron(h, h)), np.kron(h, np.eye(4))] + dephasing = [np.sqrt(0.9) * np.eye(8), np.sqrt(0.1) * np.diag([1, -1] * 4)] + for step in range(4): + state.rep_data.apply_unitary(unitaries[step % 2]) + state.rep_data.apply_channel(dephasing) + sigma, rho = target.rep_data.data, state.rep_data.data + trace = np.real(np.trace(rho)) + assert np.trace(rho) != 1.0 and np.isclose(trace, 1) + # the lossy-state formula rounds differently here, so the check can tell + assert 1 - trace * dmf.fidelity(sigma, rho / trace) != 1 - dmf.fidelity(sigma, rho) + + infidelity = Infidelity(target=target).evaluate(state, None) + + assert infidelity == 1 - dmf.fidelity(sigma, rho) + + +def _lossy_linear4(compiler, determinism, loss=0.1, lossy_gate_after=False): + circuit = graph_to_circ(nx.path_graph(4)).assign_noise(noise_model_pure_loss(loss)) + if lossy_gate_after: + # a loss after photon 3's last measurement, then a gate undoing the + # lossy Hadamard so the state still overlaps the target + circuit.add(ops.Hadamard(register=3, reg_type="p", noise=nm.PhotonLoss(0.1))) + circuit.add(ops.Hadamard(register=3, reg_type="p")) + compiler.noise_simulation = True + compiler.measurement_determinism = determinism + state = compiler.compile(circuit) + state.partial_trace(keep=list(range(4)), dims=(4 + circuit.n_emitters) * [2]) + return state + + +def _stabilizer_target_linear4(): + return QuantumState(get_clifford_tableau_from_graph(nx.path_graph(4)), rep_type="s") + + +def _density_matrix_target_linear4(): + return QuantumState(graph_to_density(nx.path_graph(4)), rep_type="dm") + + +@pytest.mark.parametrize("determinism", [0, 1]) +def test_infidelity_of_a_lossy_density_matrix_against_a_stabilizer_target(determinism): + state = _lossy_linear4(DensityMatrixCompiler(), determinism) + + infidelity = Infidelity(target=_stabilizer_target_linear4()).evaluate(state, None) + + assert infidelity == pytest.approx(0.1) + + +@pytest.mark.parametrize("determinism", [0, 1]) +@pytest.mark.parametrize("loss", [0.1, 1e-6]) +def test_density_matrix_infidelity_under_loss_matches_the_stabilizer_one( + loss, determinism +): + dm_state = _lossy_linear4(DensityMatrixCompiler(), determinism, loss) + stab_state = _lossy_linear4(StabilizerCompiler(), determinism, loss) + + dm = Infidelity(target=_density_matrix_target_linear4()).evaluate(dm_state, None) + stab = Infidelity(target=_stabilizer_target_linear4()).evaluate(stab_state, None) + + assert stab == pytest.approx(loss, rel=1e-6) + # relative only: an absolute tolerance near 1e-6 would accept 0.0 + assert dm == pytest.approx(stab, rel=1e-6) + + +@pytest.mark.parametrize("determinism", [0, 1]) +def test_density_matrix_infidelity_with_a_loss_after_the_last_measurement(determinism): + dm_state = _lossy_linear4( + DensityMatrixCompiler(), determinism, lossy_gate_after=True + ) + stab_state = _lossy_linear4( + StabilizerCompiler(), determinism, lossy_gate_after=True + ) + + dm = Infidelity(target=_density_matrix_target_linear4()).evaluate(dm_state, None) + stab = Infidelity(target=_stabilizer_target_linear4()).evaluate(stab_state, None) + + assert stab == pytest.approx(0.19) + assert dm == pytest.approx(stab) diff --git a/tests/test_state_representation/density_matrix/test_measurements.py b/tests/test_state_representation/density_matrix/test_measurements.py index cfdcb58d..7640c011 100644 --- a/tests/test_state_representation/density_matrix/test_measurements.py +++ b/tests/test_state_representation/density_matrix/test_measurements.py @@ -15,6 +15,7 @@ import matplotlib.pyplot as plt import networkx as nx import numpy as np +import pytest import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.state_rep_conversion as rc @@ -55,3 +56,64 @@ def test_x_measurement(): graph_adj = rc.density_to_graph(rho.data, validate=False) # nx.draw(nx.from_numpy_array(graph_adj),with_labels=True) plt.show() + + +_PLUS = np.full((2, 2), 0.5) +_Z_PROJECTORS = [np.diag([1.0, 0.0]), np.diag([0.0, 1.0])] + + +@pytest.mark.parametrize("determinism", [0, 1, "probabilistic"]) +def test_measurement_keeps_the_trace_of_a_subnormalised_state(determinism): + # photon loss leaves a trace below 1, which the measurement must keep + np.random.seed(0) + rho = DensityMatrix(0.9 * _PLUS, normalized=False) + + outcome = rho.apply_measurement(_Z_PROJECTORS, measurement_determinism=determinism) + + assert np.allclose(rho.data, 0.9 * _Z_PROJECTORS[outcome]) + + +@pytest.mark.parametrize("determinism", [0, 1]) +def test_measurement_keeps_a_trace_just_below_one(determinism): + # a loss of 1e-6 is far above rounding drift, so it is kept too + rho = DensityMatrix(0.999999 * _PLUS, normalized=False) + + rho.apply_measurement(_Z_PROJECTORS, measurement_determinism=determinism) + + assert np.real(np.trace(rho.data)) == pytest.approx(0.999999, rel=1e-12) + + +def _path3_with_trace_one_up_to_rounding(): + # four gate and dephasing steps leave the trace at 1 - 1.4e-15 + h = np.array([[1, 1], [1, -1]]) / np.sqrt(2) + unitaries = [np.kron(np.eye(2), np.kron(h, h)), np.kron(h, np.eye(4))] + dephasing = [np.sqrt(0.9) * np.eye(8), np.sqrt(0.1) * np.diag([1, -1] * 4)] + rho = DensityMatrix(rc.graph_to_density(nx.path_graph(3))) + for step in range(4): + rho.apply_unitary(unitaries[step % 2]) + rho.apply_channel(dephasing) + return rho + + +@pytest.mark.parametrize("determinism", [0, 1]) +def test_measurement_of_a_state_with_trace_one_up_to_rounding_is_unchanged( + determinism, +): + rho = _path3_with_trace_one_up_to_rounding() + before = rho.data.copy() + trace = np.real(np.trace(before)) + assert np.trace(before) != 1.0 + projectors = [ + np.kron(np.diag([1.0, 0.0]), np.eye(4)), + np.kron(np.diag([0.0, 1.0]), np.eye(4)), + ] + + outcome = rho.apply_measurement(projectors, measurement_determinism=determinism) + + m = projectors[outcome] + expected = (m @ before @ np.transpose(np.conjugate(m))) / np.real( + np.trace(before @ m) + ) + # scaling by this trace would change the matrix, so the check can tell + assert not np.array_equal(trace * expected, expected) + assert np.array_equal(rho.data, expected) From e203ea8a1616de4a35485d959cbc353bd8f4f3f2 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sat, 26 Sep 2026 20:38:04 -0400 Subject: [PATCH 146/227] [fix] Let dmf.fidelity compare two mixed states: fix its range check and sqrtm_psd for complex input fidelity's branch for two mixed states raised Warning("Fidelity should be between 0 and 1") whenever the value was not close to 1, so any two different mixed states raised although the value it had computed was correct. The check now raises only when the value exceeds 1 beyond np.isclose's tolerance; the value is a square, so it cannot go below 0. sqrtm_psd, which only that branch uses, returned V sqrt(L) V^T. For a complex Hermitian matrix the square root is V sqrt(L) V^dagger, so it now conjugates the eigenvectors. For real-valued input the eigenvectors are real and the output is bit-identical. No existing result changes: every in-tree target is a pure state, which takes the separate tr(rho sigma) branch, and the mixed-branch cases the tests already reach sit at fidelity 1. Infidelity with a mixed density-matrix target and a mixed state now returns a value instead of raising. --- graphiq/backends/density_matrix/functions.py | 5 +- tests/test_metrics/test_metrics.py | 19 ++++++++ .../test_density_matrix_functions.py | 48 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/graphiq/backends/density_matrix/functions.py b/graphiq/backends/density_matrix/functions.py index 8f3457de..f5a465e1 100644 --- a/graphiq/backends/density_matrix/functions.py +++ b/graphiq/backends/density_matrix/functions.py @@ -612,7 +612,7 @@ def sqrtm_psd(input_matrix): assert is_psd(input_matrix) eig_vals, eig_vecs = eigh(input_matrix) eig_vals = np.maximum(eig_vals, 0) - return (eig_vecs * np.sqrt(eig_vals)) @ eig_vecs.T + return (eig_vecs * np.sqrt(eig_vals)) @ np.conjugate(eig_vecs).T def hermitianize(input_matrix): @@ -659,7 +659,8 @@ def fidelity(rho, sigma): rho_final = sqrtm_psd(rho_sigma) f = np.real(np.trace(rho_final)) ** 2 - if not np.isclose(f, 1.0): + # f is a square, so only the upper bound can fail + if f > 1 and not np.isclose(f, 1.0): raise Warning(f"Fidelity should be between 0 and 1. Value if {f}.") f = np.maximum(np.minimum(f, 1.0), 0.0) return f diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py index 626a8768..b03a889b 100644 --- a/tests/test_metrics/test_metrics.py +++ b/tests/test_metrics/test_metrics.py @@ -593,3 +593,22 @@ def test_density_matrix_infidelity_with_a_loss_after_the_last_measurement(determ assert stab == pytest.approx(0.19) assert dm == pytest.approx(stab) + + +_MIXED_RHO = np.array([[0.5, -0.2j], [0.2j, 0.5]]) +_MIXED_SIGMA = np.array([[0.6, 0.1 - 0.1j], [0.1 + 0.1j, 0.4]]) +# for qubits, F = tr(rho sigma) + 2 sqrt(det rho det sigma) +_F_MIXED = 0.54 + 2 * np.sqrt(0.21 * 0.22) + + +@pytest.mark.parametrize("loss", [0.0, 0.1]) +def test_infidelity_of_a_mixed_density_matrix_against_a_mixed_target(loss): + target = QuantumState(_MIXED_RHO, rep_type="dm") + state = QuantumState(_MIXED_SIGMA, rep_type="dm") + if loss: + # in place; building the state from (1 - loss) * sigma would renormalise it + state.rep_data.apply_channel([np.sqrt(1 - loss) * np.eye(2)]) + + infidelity = Infidelity(target=target).evaluate(state, None) + + assert infidelity == pytest.approx(1 - (1 - loss) * _F_MIXED) diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py index b36677d4..4a360bd9 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py @@ -14,6 +14,7 @@ # limitations under the License. import pytest +import graphiq.backends.density_matrix.functions as dmf from graphiq.backends.density_matrix.functions import * @@ -180,3 +181,50 @@ def test_partial_trace_matches_an_explicit_sum_over_traced_basis_states(keep): expected += iso.T @ rho @ iso assert np.allclose(partial_trace(rho, keep, [2, 2, 2]), expected) + + +# two non-commuting mixed qubit states, both complex +_RHO = np.array([[0.5, -0.2j], [0.2j, 0.5]]) +_SIGMA = np.array([[0.6, 0.1 - 0.1j], [0.1 + 0.1j, 0.4]]) +# for qubits, F = tr(rho sigma) + 2 sqrt(det rho det sigma) +_F_RHO_SIGMA = 0.54 + 2 * np.sqrt(0.21 * 0.22) + + +def test_fidelity_of_two_different_real_mixed_states(): + p, q = np.array([0.7, 0.3]), np.array([0.5, 0.5]) + # commuting states: F = (sum_i sqrt(p_i q_i))^2 + expected = np.sum(np.sqrt(p * q)) ** 2 + + assert fidelity(np.diag(p), np.diag(q)) == pytest.approx(expected) + + +def test_fidelity_of_a_complex_mixed_state_with_itself_is_one(): + assert fidelity(_RHO, _RHO.copy()) == pytest.approx(1.0) + + +def test_fidelity_of_two_complex_mixed_states(): + assert fidelity(_RHO, _SIGMA) == pytest.approx(_F_RHO_SIGMA) + + +def test_sqrtm_psd_of_a_complex_matrix_squares_back_to_it(): + root = sqrtm_psd(_RHO) + + assert np.allclose(root @ root, _RHO) + + +def test_sqrtm_psd_of_a_real_valued_matrix_is_unchanged(): + # complex dtype with zero imaginary parts, as the compiler's states carry + matrix = np.array([[0.6, 0.2], [0.2, 0.4]], dtype=complex) + eig_vals, eig_vecs = eigh(matrix) + + expected = (eig_vecs * np.sqrt(np.maximum(eig_vals, 0))) @ eig_vecs.T + + assert np.array_equal(sqrtm_psd(matrix), expected) + + +def test_fidelity_still_raises_above_one(monkeypatch): + # fidelity looks sqrtm_psd up in its own module, so patch it there + monkeypatch.setattr(dmf, "sqrtm_psd", lambda m: 1.1 * np.eye(2)) + + with pytest.raises(Warning): + fidelity(np.diag([0.7, 0.3]), np.diag([0.5, 0.5])) From d086051ccfc084c0045c9e09c5ed44ee977c4c0d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sat, 26 Sep 2026 21:51:30 -0400 Subject: [PATCH 147/227] [packaging] Drop unused packages from requirements.txt and declare requests requirements.txt listed three packages nothing in the repository imports or runs: autograd, and genbadge and defusedxml, which were only needed by a coverage-badge workflow step that has since been removed. It also listed networkx and matplotlib a second time, unpinned, after their pinned entries. Remove those five lines. graphiq/utils/draw.py imports requests, but dependencies did not list it; it was only installed through qiskit's dependency chain. Declare it directly. Every install resolved from the current pins already has it. --- pyproject.toml | 1 + requirements.txt | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0b2678a8..d1cbe0e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "pandas", "psutil", "py-cpuinfo", + "requests", # "qiskit", "qiskit ~= 0.38.0", "pylatexenc" diff --git a/requirements.txt b/requirements.txt index 94a59939..9268c4b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,10 @@ numpy~=1.24.4 matplotlib~=3.7.5 scipy~=1.10.1 networkx~=3.1 -autograd pytest~=7.1.3 -genbadge coverage pandas~=1.5.0 seaborn~=0.12.0 -defusedxml qiskit~=0.38.0 pylatexenc ray[default] @@ -17,5 +14,3 @@ psutil jax optax tqdm -networkx -matplotlib \ No newline at end of file From 8e154ef7cfb09ecd97d2137361534645eff3f222 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sat, 26 Sep 2026 22:06:16 -0400 Subject: [PATCH 148/227] [packaging] Remove the trailing space from the Science/Research classifier "Intended Audience :: Science/Research " is not a valid trove classifier. setuptools rejects the project configuration with "`project.classifiers[1]` must be trove-classifier" whenever it validates classifiers: always when the trove-classifiers package is installed in the build environment, and on setuptools 61.0-61.2 (with Python 3.12 or older) even without it when their download of the classifier list from PyPI succeeds. The version range allowed by setuptools>=61.0 therefore includes setups that could not build the project. The wheel's METADATA differs only in that classifier line; the file list is unchanged. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d1cbe0e1..69a0a4c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ classifiers = [ "Development Status :: 4 - Beta", # Indicate who your project is intended for - "Intended Audience :: Science/Research ", + "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Physics", "Topic :: Security :: Cryptography", "Topic :: Scientific/Engineering :: Information Analysis", From aafd402b9bd651a1e34d157238a6d54640ef3e9d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sat, 26 Sep 2026 22:38:06 -0400 Subject: [PATCH 149/227] [test] Leave the data-collection scripts out of coverage measurement graphiq/data_collection/ holds research scripts (orbit and correlation analysis, a tkinter input GUI) that nothing else in the package imports. Under the codecov job's --source=. they count as 1271 statements of largely uncovered library code. Add a .coveragerc that omits them from measurement; their tests still run, and the package, wheel and sdist are unchanged. .coveragerc is used rather than a [tool.coverage] section in pyproject.toml because coverage reads it without needing tomli, which the Python 3.9 job has only as a dependency of pytest. --- .coveragerc | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..242837d9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + */graphiq/data_collection/* From 1ea211cf67fca53705fbe3369b5bb040028aa5e5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sat, 26 Sep 2026 23:27:56 -0400 Subject: [PATCH 150/227] [fix] Refuse to convert a mixed=False density matrix whose trace is not close to 1 to a stabilizer state QuantumState's dm -> s conversion with mixed=False checked only that density_to_stabilizer returned one entry, then kept its tableau and dropped its weight. A pure graph state that lost trace after construction (0.9 |G>; scored against a stabilizer target by Infidelity it gave 0.0 instead of 0.1. Before the zero-weight eigenvector skip this input raised. A Stabilizer holds no weight, so the conversion now raises ValueError, with the advice it already gives for a mixture, when the single weight is not close to 1. A trace within about 1e-5 of 1 still converts as before. A state created with mixed=True keeps the weight in a MixedStabilizer, as it did already. No default, example or benchmark result changes: outside Monte Carlo, compiled states that carry noise are mixed=True, and a Monte Carlo density-matrix compile is scored against a density-matrix ideal state, which takes no dm -> s conversion. --- graphiq/state.py | 9 +++--- tests/test_metrics/test_metrics.py | 13 +++++++++ .../test_state_rep_conversion.py | 29 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/graphiq/state.py b/graphiq/state.py index 0d5362f1..f41203b5 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -355,7 +355,7 @@ def _density_to_stabilizer(self, rep): :param rep: density matrix representation :type rep: DensityMatrix - :raises ValueError: if the state is mixed but self.mixed is False + :raises ValueError: if the state is mixed, or its trace is not close to 1, but self.mixed is False :return: stabilizer representation :rtype: Stabilizer or MixedStabilizer """ @@ -368,10 +368,11 @@ def _density_to_stabilizer(self, rep): new_rep = MixedStabilizer(new_tab_list) else: - if len(new_data) != 1: + if len(new_data) != 1 or not np.isclose(new_data[0][0], 1.0): raise ValueError( - "Cannot convert a mixed density matrix to a stabilizer state with " - "mixed=False; create the QuantumState with mixed=True" + "Cannot convert a mixed density matrix, or one whose trace is not close " + "to 1, to a stabilizer state with mixed=False; create the QuantumState " + "with mixed=True" ) new_tableau = CliffordTableau(new_data[0][1]) new_rep = Stabilizer(new_tableau) diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py index b03a889b..57535ecb 100644 --- a/tests/test_metrics/test_metrics.py +++ b/tests/test_metrics/test_metrics.py @@ -463,6 +463,19 @@ def test_infidelity_rejects_a_mixed_density_matrix_not_marked_mixed(): Infidelity(target=target).evaluate(state, None) +def test_infidelity_rejects_a_lossy_density_matrix_not_marked_mixed(): + # Converting it to a stabilizer state would drop the lost 0.1 and score it lossless. + state = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + state.rep_data.apply_channel([np.sqrt(0.9) * np.eye(8)]) + assert np.real(np.trace(state.rep_data.data)) == pytest.approx(0.9) + target = QuantumState( + get_clifford_tableau_from_graph(nx.path_graph(3)), rep_type="s" + ) + + with pytest.raises(ValueError, match="mixed=True"): + Infidelity(target=target).evaluate(state, None) + + def test_trace_distance_stabilizer_state_against_its_own_density_matrix_is_zero(): # TraceDistance.evaluate only compares density matrices; a stabilizer-represented state is # converted to "dm" on the fly (a copy, so the original state is left untouched). diff --git a/tests/test_state_representation/test_state_rep_conversion.py b/tests/test_state_representation/test_state_rep_conversion.py index 142ff299..52d595eb 100644 --- a/tests/test_state_representation/test_state_rep_conversion.py +++ b/tests/test_state_representation/test_state_rep_conversion.py @@ -348,6 +348,35 @@ def test_mixed_density_matrix_state_not_marked_mixed_is_not_converted_to_one_bra state.convert_representation("s") +def _lossy_path3_state(mixed): + state = QuantumState( + rc.graph_to_density(nx.path_graph(3)), rep_type="dm", mixed=mixed + ) + # lose 0.1 in place; building the state from 0.9 * rho would renormalise it + state.rep_data.apply_channel([np.sqrt(0.9) * np.eye(8)]) + assert np.real(np.trace(state.rep_data.data)) == pytest.approx(0.9) + return state + + +def test_subnormalised_density_matrix_state_not_marked_mixed_is_not_converted(): + # A Stabilizer holds one tableau and no weight, so converting would drop the 0.9 and + # leave the lossless state. + state = _lossy_path3_state(mixed=False) + + with pytest.raises(ValueError, match="mixed=True"): + state.convert_representation("s") + + +def test_subnormalised_density_matrix_state_marked_mixed_keeps_its_weight(): + state = _lossy_path3_state(mixed=True) + + state.convert_representation("s") + + assert isinstance(state.rep_data, MixedStabilizer) + assert len(state.rep_data.mixture) == 1 + assert state.rep_data.mixture[0][0] == pytest.approx(0.9) + + def test_density_to_stabilizer_rejects_a_mixture_of_non_orthogonal_graph_states(): # The empty and edge graphs are the only 2-qubit graph states, and they are not # orthogonal, so no eigenvector of their mixture is a graph state. From 301d4ca23b64371d80003a886d23d3bad01f1105 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 00:35:25 -0400 Subject: [PATCH 151/227] [fix] Accept any generator set of a graph state in stabilizer_to_graph's validation stabilizer_to_graph(validate=True) checked the input against the found graph's stabilizer with a literal tableau comparison, so only the generator set written as [I | A] passed. The same graph state with its generators in another order or form, such as the star state a circuit leaves as XZZ, ZXI, IXX, raised "Input stabilizer is not a graph state." That broke QuantumState's s -> g conversion and an AlternateTargetSolver stabilizer target for such states, although the graph found was right. The validation now compares canonical forms, computed on copies, so it accepts every generator set of the same state and still rejects states that are not the graph state, signs included. mixed_stabilizer_equivalency is unchanged. An input that converted before converts to the same graph, so no default, example or benchmark result changes. --- graphiq/backends/state_rep_conversion.py | 14 +++- .../test_alternate_target_solver.py | 14 ++++ .../test_quantum_state.py | 14 ++++ .../test_state_rep_conversion.py | 72 +++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index 12d19d7f..df8eda01 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -249,8 +249,20 @@ def stabilizer_to_graph(input_stabilizer, validate=True): ) if validate: + # the same state has many generator sets, so compare canonical forms; + # canonical_form works in place, hence the copies + if isinstance(input_stabilizer, list): + input_canonical = [ + (p_i, canonical_form(s_i.copy())) for p_i, s_i in input_stabilizer + ] + else: + input_canonical = canonical_form(input_stabilizer.copy()) + graph_canonical = [ + (p_i, canonical_form(s_i.copy())) + for p_i, s_i in graph_to_stabilizer(graph_list) + ] assert mixed_stabilizer_equivalency( - input_stabilizer, graph_to_stabilizer(graph_list) + input_canonical, graph_canonical ), "Input stabilizer is not a graph state." return graph_list diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index b038f3fe..0a8c79c9 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -14,6 +14,8 @@ # limitations under the License. import pytest as pytest +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.transformation import run_circuit from graphiq.benchmarks.graph_states import linear_cluster_state from graphiq.solvers.alternate_target_solver import * from graphiq.utils.relabel_module import _label_finder, automorph_check @@ -320,3 +322,15 @@ def test_graph_to_circ_solves_a_disconnected_graph_without_an_isolated_vertex(ou n_photon = graph.number_of_nodes() state.partial_trace(list(range(n_photon)), [2] * circuit.n_quantum) assert np.isclose(Infidelity(target).evaluate(state, circuit), 0.0) + + +def test_a_stabilizer_target_not_in_standard_form_is_accepted(): + # the star graph state (centre 0) as a circuit leaves it: generators XZZ, ZXI, IXX + tableau = run_circuit( + CliffordTableau(3), + [("H", 0), ("CNOT", 0, 1), ("CNOT", 1, 2), ("H", 1), ("H", 2)], + ) + + solver = AlternateTargetSolver(target=QuantumState(tableau, rep_type="s")) + + assert sorted(solver.target_graph.edges) == [(0, 1), (0, 2)] diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index fcd7139d..40b4c8df 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -24,6 +24,7 @@ from graphiq.backends.graph.state import Graph, MixedGraph from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau from graphiq.backends.stabilizer.functions.clifford import create_n_ket0_state +from graphiq.backends.stabilizer.functions.transformation import run_circuit from graphiq.state import DENSITY_MATRIX_QUBIT_THRESH, QuantumState @@ -72,6 +73,19 @@ def test_convert_representation_graph_stabilizer_graph_round_trip_pure_arm(): assert nx.is_isomorphic(state.rep_data.data, graph) +def test_convert_representation_stabilizer_to_graph_of_a_circuit_prepared_graph_state(): + # the star graph state (centre 0) as a circuit leaves it: generators XZZ, ZXI, IXX + tableau = run_circuit( + CliffordTableau(3), + [("H", 0), ("CNOT", 0, 1), ("CNOT", 1, 2), ("H", 1), ("H", 2)], + ) + state = QuantumState(tableau, rep_type="s") + + state.convert_representation("g") + + assert sorted(state.rep_data.data.edges) == [(0, 1), (0, 2)] + + def test_convert_representation_stabilizer_to_graph_mixed_arm(): path3 = nx.path_graph(3) star3 = nx.star_graph(2) diff --git a/tests/test_state_representation/test_state_rep_conversion.py b/tests/test_state_representation/test_state_rep_conversion.py index 52d595eb..0ec1d306 100644 --- a/tests/test_state_representation/test_state_rep_conversion.py +++ b/tests/test_state_representation/test_state_rep_conversion.py @@ -21,6 +21,8 @@ import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.stabilizer.functions.linalg as sla import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.transformation import run_circuit from graphiq.backends.stabilizer.state import MixedStabilizer from graphiq.backends.stabilizer.tableau import StabilizerTableau from graphiq.state import QuantumState @@ -111,6 +113,76 @@ def test_stabilizer_to_graph(): assert nx.is_isomorphic(graph1, graph2_list[0][1]) +# the 3-qubit star graph state (centre 0), prepared by a circuit: its generators are +# XZZ, ZXI, IXX rather than the rows of [I | A] +_STAR_CIRCUIT = [("H", 0), ("CNOT", 0, 1), ("CNOT", 1, 2), ("H", 1), ("H", 2)] + + +def _star_tableau(): + return run_circuit(CliffordTableau(3), _STAR_CIRCUIT).to_stabilizer() + + +def _row_swapped_path3_tableau(): + tableau = StabilizerTableau([np.eye(3), nx.to_numpy_array(nx.path_graph(3))]) + return StabilizerTableau(tableau.table[[1, 0, 2]]) + + +def test_stabilizer_to_graph_accepts_a_graph_state_not_in_standard_form(): + tableau = _star_tableau() + assert np.allclose( + rc.stabilizer_to_density(tableau), rc.graph_to_density(nx.star_graph(2)) + ) + + graph_list = rc.stabilizer_to_graph(tableau) + + assert len(graph_list) == 1 + assert sorted(graph_list[0][1].edges) == [(0, 1), (0, 2)] + + +def test_stabilizer_to_graph_accepts_row_permuted_generators(): + graph_list = rc.stabilizer_to_graph(_row_swapped_path3_tableau()) + + assert sorted(graph_list[0][1].edges) == [(0, 1), (1, 2)] + + +def test_stabilizer_to_graph_accepts_a_list_of_graph_states_not_in_standard_form(): + graph_list = rc.stabilizer_to_graph( + [(0.3, _star_tableau()), (0.7, _row_swapped_path3_tableau())] + ) + + assert [p_i for p_i, _ in graph_list] == [0.3, 0.7] + assert sorted(graph_list[0][1].edges) == [(0, 1), (0, 2)] + assert sorted(graph_list[1][1].edges) == [(0, 1), (1, 2)] + + +def test_stabilizer_to_graph_still_rejects_a_state_that_is_not_a_graph_state(): + # |0>|+>, and the star state with Z applied to qubit 1 + product = run_circuit(CliffordTableau(2), [("H", 1)]).to_stabilizer() + signed_star = run_circuit( + CliffordTableau(3), _STAR_CIRCUIT + [("Z", 1)] + ).to_stabilizer() + + for tableau in (product, signed_star): + with pytest.raises(AssertionError, match="not a graph state"): + rc.stabilizer_to_graph(tableau) + + +def test_stabilizer_to_graph_leaves_its_input_unchanged(): + # the validation compares canonical forms, which are computed in place + tableau = _star_tableau() + mixture = [(0.3, _star_tableau()), (0.7, _row_swapped_path3_tableau())] + expected = [ + (t.table.copy(), t.phase.copy()) for t in [tableau] + [t for _, t in mixture] + ] + + rc.stabilizer_to_graph(tableau) + rc.stabilizer_to_graph(mixture) + + for t, (table, phase) in zip([tableau] + [t for _, t in mixture], expected): + assert np.array_equal(t.table, table) + assert np.array_equal(t.phase, phase) + + def test_stabilizer_and_density_conversion(): x_matrix = np.eye(4) graph = nx.Graph([(1, 2), (2, 3), (3, 4)]) From b5c587e1a0d8fb5b0ff8cdf338aa37d7a7f20268 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 00:35:25 -0400 Subject: [PATCH 152/227] [fix] Give the forced density-matrix measurement a rounding tolerance With measurement_determinism 1 (or 0), apply_measurement took the forced outcome whenever its probability was above 0, so a probability that was only rounding error was taken and divided by. Hadamard twice on one emitter leaves |0> with p(1) = 6e-34; forcing 1 returned outcome 1 and the state |1><1|, a branch the circuit cannot produce. In general the divided block is roundoff scaled up by 1/p, which can also be indefinite. A probability now counts as 0 at or below a resolution floor, dim * eps * sum(|p|), below which it carries no information left over from building the state. The floor is eps-scale on purpose: a real branch such as p(0) = 2.5e-13 after a rotation by pi - 1e-6 stays far above it. Negative values still count as 0, as before. det=0 no longer also requires p(1) < 1: that check is redundant with the floor, and for an unnormalised state with trace above 1 it wrongly forced outcome 1. Outcomes change only where the forced probability is in (0, floor], or under det=0 where the other probability is at least 1. A zero-trace state behaves as before. The test suite and the example notebooks that force outcomes on the density-matrix compiler take no different forced outcome. The probabilistic mode takes one uniform draw per call as before, so a draw changes only if it lands within the floor of the boundary between the outcomes. Seeded runs with determinism 0 or 1 change where a forced branch had a rounding-level probability. An invalid state whose probabilities are dominated by a negative value (for example diag([-1e-3, 1e-19]) set in place) now reaches NaN: the probabilistic mode raises ValueError, determinism 1 returns outcome 0 and determinism 0 outcome 1, each with a NaN state. Before, all three returned outcome 1. --- graphiq/backends/density_matrix/state.py | 62 ++++--- .../density_matrix/test_measurements.py | 154 +++++++++++++++++- 2 files changed, 190 insertions(+), 26 deletions(-) diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index 9431e77c..fc5f6103 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -30,6 +30,24 @@ density_matrix_bars, ) +# the backend is double precision; the tolerance below assumes it +_PROB_EPS = numpy.finfo(numpy.float64).eps + + +def _forced_measurement_tolerance(shape0, raw_probs): + """ + The resolution floor below which a measured probability is rounding error left by the + arithmetic that built the state, rather than a reachable outcome + + :param shape0: the density matrix's first dimension + :type shape0: int + :param raw_probs: the unclamped probability of each outcome, Tr(rho m) + :type raw_probs: numpy.ndarray + :return: the tolerance + :rtype: float + """ + return shape0 * _PROB_EPS * np.sum(np.abs(raw_probs)) + class DensityMatrix(StateRepresentationBase): """ @@ -166,9 +184,10 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") :param projectors: a list of projective measurements in the computational basis :type projectors: list[numpy.ndarray] :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected - if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 - if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 - or p(1) >= 1 + if 1, measurement results default to 1 unless p(1) is at or below the + numerical noise floor + if 0, measurement results default to 0 unless p(0) is at or below the + numerical noise floor In modes 0 and 1, a forced outcome the state does not allow returns the other outcome :type measurement_determinism: str/int @@ -180,29 +199,21 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") if self._data.shape == projectors[0].shape: trace = np.real(np.trace(self._data)) - probs = [] - for m in projectors: - prob = np.real(np.trace(self._data @ m)) - if prob < 0: - prob = 0 - probs.append(prob) - probs = np.array(probs) + raw_probs = np.array( + [np.real(np.trace(self._data @ m)) for m in projectors] + ) + # the error on a diagonal entry is O(eps * trace), accumulated across the + # matrix width, so the floor scales with the total, not with probs[i]; at + # or below it a probability counts as 0, and so does any negative value, + # as before + tol = _forced_measurement_tolerance(self._data.shape[0], raw_probs) + probs = np.array([prob if prob > tol else 0.0 for prob in raw_probs]) if measurement_determinism == "probabilistic": outcome = numpy.random.choice([0, 1], p=probs / np.sum(probs)) elif measurement_determinism == 1: - if probs[1] > 0: - outcome = 1 - else: - outcome = 0 - + outcome = 1 if probs[1] > tol else 0 elif measurement_determinism == 0: - # probs[0] is what outcome 0 divides by below; testing only - # probs[1] < 1 let a rounding-error probs[1] = 1 - 1e-16 with - # probs[0] = 0 through, producing a NaN matrix - if probs[1] < 1 and probs[0] > 0: - outcome = 0 - else: - outcome = 1 + outcome = 0 if probs[0] > tol else 1 else: raise ValueError( f'measurement_determinism parameter must be "probabilistic", 0, or 1' @@ -236,9 +247,10 @@ def apply_measurement_controlled_gate( :param target_gate: the gate to be applied if the measurement outcome is 1 :type target_gate: numpy.ndarray :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected - if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 - if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 - or p(1) >= 1 + if 1, measurement results default to 1 unless p(1) is at or below the + numerical noise floor + if 0, measurement results default to 0 unless p(0) is at or below the + numerical noise floor In modes 0 and 1, a forced outcome the state does not allow returns the other outcome :type measurement_determinism: str/int diff --git a/tests/test_state_representation/density_matrix/test_measurements.py b/tests/test_state_representation/density_matrix/test_measurements.py index 7640c011..5df8d493 100644 --- a/tests/test_state_representation/density_matrix/test_measurements.py +++ b/tests/test_state_representation/density_matrix/test_measurements.py @@ -19,7 +19,13 @@ import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.state_rep_conversion as rc -from graphiq.backends.density_matrix.state import DensityMatrix +import graphiq.circuit.ops as ops +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.density_matrix.state import ( + DensityMatrix, + _forced_measurement_tolerance, +) +from graphiq.circuit.circuit_dag import CircuitDAG def test_y_measurement(): @@ -117,3 +123,149 @@ def test_measurement_of_a_state_with_trace_one_up_to_rounding_is_unchanged( # scaling by this trace would change the matrix, so the check can tell assert not np.array_equal(trace * expected, expected) assert np.array_equal(rho.data, expected) + + +# A forced outcome is taken only if its probability is above a resolution floor, +# tol = dim * eps * sum(|probs|); below it the probability is rounding error. These +# build states with normalized=False, since the constructor renormalises the very +# roundoff under test. + + +def _forced(rho, determinism, n_qubits=1): + state = DensityMatrix(np.asarray(rho, dtype=complex), normalized=False) + outcome = state.apply_measurement( + dmf.projectors_zbasis(n_qubits, 0), measurement_determinism=determinism + ) + return outcome, state.data + + +def _skewed_block(trace, coherence=3e-16): + # a 2x2 block with the given trace and a fixed off-diagonal roundoff: indefinite for + # a trace well below the coherence, so dividing by that trace would amplify a + # negative eigenvalue to order one; positive semidefinite well above it + a = b = trace / 2 + return np.array([[a, coherence], [coherence, b]]) + + +def _forced_boundary_state(forced_index, forced_trace, other_trace): + # 2 qubits, block diagonal: the forced outcome's block is _skewed_block(forced_trace) + # and the other outcome's block is diag([other_trace, 0]) + forced_block = _skewed_block(forced_trace) + other_block = np.diag([other_trace, 0.0]) + rho = np.zeros((4, 4), dtype=complex) + if forced_index == 0: + rho[:2, :2], rho[2:, 2:] = forced_block, other_block + else: + rho[:2, :2], rho[2:, 2:] = other_block, forced_block + return rho + + +def test_a_rounding_error_probability_is_not_forced(): + # Hadamard twice is the identity, so the state is |0>, but p(1) comes out as rounding + # error; the precondition makes the test fail loudly, rather than pass without + # testing anything, on a platform whose roundoff differs + circuit = CircuitDAG(n_emitter=1, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + state = DensityMatrixCompiler().compile(circuit).rep_data + projectors = dmf.projectors_zbasis(1, 0) + assert 0 < np.real(np.trace(state.data @ projectors[1])) < 1e-20 + + outcome = state.apply_measurement(projectors, measurement_determinism=1) + + assert outcome == 0 + assert np.allclose(state.data, np.diag([1.0, 0.0])) + + +@pytest.mark.parametrize("over_one", [False, True]) +@pytest.mark.parametrize("k", [0.5, 2, 10]) +@pytest.mark.parametrize("determinism", [0, 1]) +def test_forced_measurement_boundary_is_where_the_code_computes_it( + determinism, k, over_one +): + # The forced outcome's block trace is k * tol, with tol computed by the code's own + # function, so a small shift in roundoff does not flip the test; only a change to the + # tolerance does. Below tol the forced outcome is not taken (the block is indefinite + # there); above it, it is, and the result is still a valid density matrix. over_one + # puts the trace just above 1 (and, for k <= 2, the other outcome's probability), + # which must not matter. tol is taken for a total of 1, which the state's total + # matches to within 4e-15. + tol = _forced_measurement_tolerance(4, np.array([1.0])) + forced_trace = k * tol + other_trace = 1.0 - forced_trace + (3.55e-15 if over_one else 0.0) + rho = _forced_boundary_state(determinism, forced_trace, other_trace) + + outcome, data = _forced(rho, determinism, n_qubits=2) + + assert outcome == (determinism if k > 1 else 1 - determinism) + assert dmf.is_density_matrix(data) + + +def test_forcing_zero_on_a_state_with_trace_above_one(): + # an unnormalised state: p(1) = 1 does not make outcome 0 impossible + outcome, _ = _forced(np.diag([1.0, 1.0]), 0) + + assert outcome == 0 + + +@pytest.mark.parametrize("n_qubits", [1, 2, 3, 4]) +def test_a_state_is_psd_accepts_is_still_measurable(n_qubits): + # every eigenvalue is within is_psd's -1e-15, but the outcome-1 block sums to + # -0.9e-15 * 2^(n-1); a negative probability counts as 0 + dim = 2**n_qubits + diagonal = np.zeros(dim) + diagonal[dim // 2 :] = -0.9e-15 + diagonal[0] = 1 + 0.9e-15 * (dim // 2) + rho = np.diag(diagonal) + assert dmf.is_density_matrix(rho) + + for determinism in (0, 1, "probabilistic"): + outcome, _ = _forced(rho, determinism, n_qubits=n_qubits) + assert outcome == 0 + + +def test_a_branch_far_above_tolerance_is_honoured(): + # theta = pi - 1e-6 gives p(0) = cos^2(theta / 2) ~= 2.5e-13, far above tol (~4.4e-16 + # at one qubit): a real branch, which a coarse threshold such as 1e-9 would discard + circuit = CircuitDAG(n_emitter=1, n_classical=0) + circuit.add( + ops.ParameterizedOneQubitRotation( + register=0, reg_type="e", params=(np.pi - 1e-6, 0.0, 0.0) + ) + ) + state = DensityMatrixCompiler().compile(circuit).rep_data + + outcome = state.apply_measurement( + dmf.projectors_zbasis(1, 0), measurement_determinism=0 + ) + + assert outcome == 0 + assert dmf.is_density_matrix(state.data) + + +def test_an_int64_state_does_not_crash(): + # normalized=False keeps an integer matrix as it is + state = DensityMatrix(np.array([[1, 0], [0, 0]]), normalized=False) + + outcome = state.apply_measurement( + dmf.projectors_zbasis(1, 0), measurement_determinism=0 + ) + + assert outcome == 0 + + +def test_an_impossible_forced_outcome_returns_the_other_one(): + assert _forced(np.diag([1.0, 0.0]), 1)[0] == 0 + assert _forced(np.diag([0.0, 1.0]), 0)[0] == 1 + + +def test_an_ordinary_forced_measurement_is_unaffected(): + assert _forced(np.diag([0.5, 0.5]), 0)[0] == 0 + assert _forced(np.diag([0.5, 0.5]), 1)[0] == 1 + + +def test_a_redirected_forced_outcome_keeps_a_subnormalised_trace(): + outcome, data = _forced(np.diag([0.5, 0.0]), 1) + + assert outcome == 0 + assert np.real(np.trace(data)) == pytest.approx(0.5) From 68de7ff91a11706685d0338e78cadc7c3fdaa3eb Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 00:36:54 -0400 Subject: [PATCH 153/227] [fix] Let QuantumState convert a density matrix to a graph state The dm -> g conversion raised for every input. For a pure state, density_to_graph returns an adjacency matrix, which Graph does not accept (TypeError). For a mixed one it returns (weight, adjacency) pairs, one per eigenvalue, which MixedGraph does not accept either (AssertionError); its zero-weight entries are graphs read off arbitrary null-space vectors in any case. A pure state now converts to a Graph built from the adjacency matrix, node by node and edge by edge, so isolated nodes are kept and the edges carry no weight attribute. A mixed state marked mixed=True converts to a MixedGraph of its branches with a weight above 1e-9, the cut density_to_stabilizer makes. Under mixed=True a pure result is held as a one-entry MixedGraph, as the stabilizer-to-graph conversion does, so converting back to a density matrix works. A state marked mixed=False whose density matrix is not pure now raises ValueError, as its conversion to a stabilizer state does. Converting such a mixed=True result on to a stabilizer state still raises TypeError, as it already does for a mixed=True stabilizer state converted to a graph and back; that is a separate defect. No default, example or benchmark result changes, since no conversion from a density matrix to a graph could run before. --- graphiq/state.py | 39 +++++++++- .../test_quantum_state.py | 72 +++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/graphiq/state.py b/graphiq/state.py index f41203b5..534b5a85 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -63,6 +63,23 @@ def _reject_unrepresentable_i_phase(tableau): ) +def _adjacency_to_graph(adjacency): + """ + Build a plain networkx graph from a 0/1 adjacency matrix, keeping isolated nodes. + + nx.from_numpy_array alone would put a weight attribute on every edge. + + :param adjacency: the adjacency matrix + :type adjacency: numpy.ndarray + :return: the graph + :rtype: networkx.Graph + """ + graph = nx.Graph() + graph.add_nodes_from(range(adjacency.shape[0])) + graph.add_edges_from(nx.from_numpy_array(np.real(adjacency)).edges()) + return graph + + class QuantumState: """ The QuantumState class is the unified API for accessing state representation backends. @@ -339,14 +356,32 @@ def _density_to_graph(self, rep): :param rep: density matrix representation :type rep: DensityMatrix + :raises ValueError: if the state is mixed, or its trace is not close to 1, but self.mixed is False + :raises AssertionError: if the density matrix is not a graph state :return: graph representation :rtype: Graph or MixedGraph """ new_data = rc.density_to_graph(rep.data) if isinstance(new_data, list): - new_rep = MixedGraph(new_data) + if not self.mixed: + raise ValueError( + "Cannot convert a mixed density matrix, or one whose trace is not close " + "to 1, to a graph state with mixed=False; create the QuantumState with " + "mixed=True" + ) + # a zero-weight eigenvector is an arbitrary null-space vector, not part of + # the mixture + new_rep = MixedGraph( + [ + (p_i, Graph(_adjacency_to_graph(adj_i))) + for p_i, adj_i in new_data + if p_i > 1e-9 + ] + ) else: - new_rep = Graph(new_data) + graph = Graph(_adjacency_to_graph(new_data)) + # a mixed state holds a MixedGraph, as _stabilizer_to_graph gives it + new_rep = MixedGraph(graph) if self.mixed else graph return new_rep def _density_to_stabilizer(self, rep): diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 40b4c8df..36ea74fc 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -86,6 +86,78 @@ def test_convert_representation_stabilizer_to_graph_of_a_circuit_prepared_graph_ assert sorted(state.rep_data.data.edges) == [(0, 1), (0, 2)] +def _edge_set(graph): + return {frozenset(edge) for edge in graph.edges} + + +@pytest.mark.parametrize( + "edges", [[(0, 1), (1, 2)], [(0, 1)]], ids=["path3", "isolated_node"] +) +def test_convert_representation_density_matrix_to_graph_pure_arm(edges): + graph = nx.Graph(edges) + graph.add_nodes_from(range(3)) + rho = rc.graph_to_density(graph) + state = QuantumState(rho, rep_type="dm") + + state.convert_representation("g") + + assert isinstance(state.rep_data, Graph) + assert set(state.rep_data.data.nodes) == {0, 1, 2} + assert _edge_set(state.rep_data.data) == _edge_set(graph) + assert all(not data for _, _, data in state.rep_data.data.edges(data=True)) + state.convert_representation("dm") + assert np.allclose(state.rep_data.data, rho) + + +def test_convert_representation_density_matrix_to_graph_pure_arm_marked_mixed(): + rho = rc.graph_to_density(nx.path_graph(3)) + state = QuantumState(rho, rep_type="dm", mixed=True) + + state.convert_representation("g") + + assert isinstance(state.rep_data, MixedGraph) + assert len(state.rep_data.mixture) == 1 + weight, graph = state.rep_data.mixture[0] + assert weight == 1.0 + assert _edge_set(graph.data) == _edge_set(nx.path_graph(3)) + state.convert_representation("dm") + assert np.allclose(state.rep_data.data, rho) + + +def _empty_plus_triangle_mixture(): + # two orthogonal graph states with distinct weights + return 0.7 * rc.graph_to_density(nx.empty_graph(3)) + 0.3 * rc.graph_to_density( + nx.complete_graph(3) + ) + + +def test_convert_representation_density_matrix_to_graph_mixed_arm(): + rho = _empty_plus_triangle_mixture() + state = QuantumState(rho, rep_type="dm", mixed=True) + + state.convert_representation("g") + + assert isinstance(state.rep_data, MixedGraph) + # the six zero-weight eigenvectors are not branches of the mixture + assert len(state.rep_data.mixture) == 2 + branches = sorted( + (round(float(p_i), 9), sorted(map(sorted, _edge_set(g_i.data)))) + for p_i, g_i in state.rep_data.mixture + ) + assert branches == [(0.3, [[0, 1], [0, 2], [1, 2]]), (0.7, [])] + for _, g_i in state.rep_data.mixture: + assert set(g_i.data.nodes) == {0, 1, 2} + state.convert_representation("dm") + assert np.allclose(state.rep_data.data, rho) + + +def test_convert_representation_density_matrix_to_graph_rejects_a_mixture_not_marked_mixed(): + state = QuantumState(_empty_plus_triangle_mixture(), rep_type="dm") + + with pytest.raises(ValueError, match="mixed=True"): + state.convert_representation("g") + + def test_convert_representation_stabilizer_to_graph_mixed_arm(): path3 = nx.path_graph(3) star3 = nx.star_graph(2) From 333591b4ee5c3032cc0ab123fbe32304a9577579 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 01:05:07 -0400 Subject: [PATCH 154/227] [fix] Keep HybridEvolutionarySolver's density-matrix target when the density-matrix compiler is used population_initialization seeds the search with TimeReversedSolver, which converts its target to a stabilizer state in place. It was handed the solver's own target, which the metric normally shares, so with a density-matrix target the metric switched to its stabilizer arm midway through solve(). With the density-matrix compiler that arm then had to convert compiled states that are not graph states, and solve() raised "Input matrix is not a graph state." For a density-matrix target with the density-matrix compiler, TimeReversedSolver now gets a converted copy, so the target and the metric keep their representation and the search completes. Only that combination changes. Every other target and compiler pairing passes the same object as before, so its scores, cost and side effects are unchanged. With a stabilizer compiler a density-matrix or graph target is still converted in place: for a graph target that is what lets the metric score at all, and for a density-matrix target it keeps the metric on its stabilizer arm, as before. A density-matrix target with the density-matrix compiler and a metric built on a separate copy already solved; its scores are unchanged and its target is no longer converted. --- graphiq/solvers/hybrid_solvers.py | 9 +++- tests/test_solvers/test_hybrid_solvers.py | 53 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/graphiq/solvers/hybrid_solvers.py b/graphiq/solvers/hybrid_solvers.py index 24189212..a0580fa8 100644 --- a/graphiq/solvers/hybrid_solvers.py +++ b/graphiq/solvers/hybrid_solvers.py @@ -147,8 +147,15 @@ def population_initialization(self): :return: an initial population :rtype: list """ + # TimeReversedSolver converts its target to "s" in place, which would move a + # metric sharing a "dm" target onto its stabilizer arm, unable to score the + # non-graph-state density matrices this compiler produces during the search + target = self.target + if target.rep_type == "dm" and self.compiler.name == "dm": + target = target.copy() + target.convert_representation("s") deterministic_solver = TimeReversedSolver( - target=self.target, + target=target, metric=self.metric, compiler=self.compiler, noise_model_mapping=self.noise_model_mapping, diff --git a/tests/test_solvers/test_hybrid_solvers.py b/tests/test_solvers/test_hybrid_solvers.py index b394c997..bf486b40 100644 --- a/tests/test_solvers/test_hybrid_solvers.py +++ b/tests/test_solvers/test_hybrid_solvers.py @@ -14,6 +14,7 @@ # limitations under the License. import numpy as np import networkx as nx +import pytest from graphiq.benchmarks.circuits import ( linear_cluster_4qubit_circuit, linear_cluster_3qubit_circuit, @@ -22,7 +23,9 @@ ) from graphiq.state import QuantumState from graphiq.metrics import Infidelity +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.state_rep_conversion import graph_to_density from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, ) @@ -156,3 +159,53 @@ def test_alternate_circuits_w_noise3(): graph = nx.star_graph(3) noise_model = noise_model_loss_and_depolarizing(error_rate, loss_rate) exemplary_test(graph, noise_model, None, 1000) + + +def _solve_path3(target, compiler): + metric = Infidelity(target) + solver = HybridEvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + solver_setting=EvolutionarySolverSetting(n_stop=3, n_pop=5), + ) + HybridEvolutionarySolver.seed(3) + solver.solve() + return solver, metric + + +def test_a_density_matrix_target_with_the_density_matrix_compiler_is_solved(): + # the deterministic solver seeding the population converts its target to "s" in + # place; the metric sharing this target must keep scoring density matrices + target = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + + solver, metric = _solve_path3(target, DensityMatrixCompiler()) + + assert target.rep_type == "dm" + assert metric.target is target + assert all(0 <= score <= 1 for score, _ in solver.hof) + # the first evaluation is the deterministic circuit, which prepares the target + assert metric.log[0] == pytest.approx(0, abs=1e-9) + + +def test_a_density_matrix_target_with_the_stabilizer_compiler_is_unchanged(): + target = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + + solver, _ = _solve_path3(target, StabilizerCompiler()) + + assert [score for score, _ in solver.hof] == pytest.approx( + [0.5, 0.5, 0.75, 0.75, 0.75] + ) + # the stabilizer compiler's states are still scored against the converted target; + # this assertion, not the scores, is what tells the two metric arms apart + assert target.rep_type == "s" + + +def test_a_graph_target_with_the_stabilizer_compiler_still_solves(): + target = QuantumState(nx.path_graph(3), rep_type="g") + + solver, _ = _solve_path3(target, StabilizerCompiler()) + + assert [score for score, _ in solver.hof] == pytest.approx( + [0.5, 0.5, 0.75, 0.75, 0.75] + ) From 29061b1ef28627d1f15e922fb304b8affec0fb68 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 01:07:42 -0400 Subject: [PATCH 155/227] [fix] Skip unfilled hall-of-fame slots when saving them as openQASM With save_openqasm "hof" or "both", EvolutionarySolver wrote every hall-of-fame entry as openQASM at each iteration, but an entry no candidate has filled yet is the placeholder (inf, None). So a search whose population was smaller than the hall of fame raised "'NoneType' object has no attribute 'to_openqasm'" at the first save. Placeholders are now skipped. They always sit at the end of the hall of fame, so the saved files stay numbered hof0, hof1, ... without gaps. Nothing is computed differently, and no in-tree run saves the hall of fame, so no result changes. --- graphiq/solvers/evolutionary_solver.py | 6 ++- .../test_evolutionary_random_solvers.py | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index d230fd89..64692511 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -582,8 +582,7 @@ def _save_pop(self, pop, iteration): def _save_hof(self, hof, iteration): """ Write every circuit of the hall of fame out as openQASM, one file per circuit under "hof/", - through the solver's IO. Every entry must hold a circuit: a still-unfilled (np.inf, None) - slot raises AttributeError. + through the solver's IO. A slot no candidate has filled yet, (np.inf, None), is skipped. :param hof: the hall of fame, as (score, circuit) pairs :type hof: list[tuple] @@ -593,6 +592,9 @@ def _save_hof(self, hof, iteration): :rtype: None """ for i, (score, circuit) in enumerate(hof): + # a slot no candidate has filled yet holds (np.inf, None) + if circuit is None: + continue name = f"hof/iteration{iteration}_hof{i}.txt" self.io.save_json(circuit.to_openqasm(), name) diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index 88b428ee..ec051210 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -581,3 +581,42 @@ def test_replacing_an_unfixed_photon_gate_leaves_it_removable(): assert "Fixed" not in circuit.dag.nodes[node]["op"].labels solver.remove_op(circuit, node) assert node not in circuit.dag.nodes + + +def _saved_files(folder): + if not folder.exists(): + return set() + return {path.name for path in folder.iterdir()} + + +@pytest.mark.parametrize("save_openqasm", ["hof", "both"]) +def test_saving_the_hall_of_fame_skips_unfilled_slots(tmp_path, save_openqasm): + # two candidates per iteration fill 2, 4, then all 5 slots of the hall of fame; + # a slot no candidate has filled yet has no circuit to save + _, target = linear_cluster_3qubit_circuit() + setting = EvolutionarySolverSetting( + n_stop=3, n_pop=2, n_hof=5, save_openqasm=save_openqasm + ) + solver = EvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=DensityMatrixCompiler(), + n_emitter=1, + n_photon=3, + solver_setting=setting, + io=IO(path=tmp_path, verbose=False), + ) + EvolutionarySolver.seed(1) + + solver.solve() + + filled = {0: 2, 1: 4, 2: 5} + assert _saved_files(tmp_path / "hof") == { + f"iteration{k}_hof{i}.txt" for k, n in filled.items() for i in range(n) + } + expected_pop = ( + {f"iteration{k}_pop{j}.txt" for k in range(3) for j in range(2)} + if save_openqasm == "both" + else set() + ) + assert _saved_files(tmp_path / "pop") == expected_pop From f48008e786174bbc87c03d20f4eea3f3fbba3597 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 01:07:42 -0400 Subject: [PATCH 156/227] [fix] Adapt only the transformations a solver allows in adapt_probabilities With use_adapt_probability, EvolutionarySolver.adapt_probabilities updated four fixed entries of trans_probs each iteration: the emitter and photon one-qubit transformations, remove_op, and the emitter CNOT with more than one emitter. trans_probs can hold fewer, through set_allowed_transformations, a subclass or direct assignment, and then the first iteration raised KeyError. Each update now runs only when its transformation is in trans_probs. A transformation the caller left out stays out, and the CNOT entry is still adapted only with more than one emitter. With the default transformations every entry is present and the arithmetic is unchanged, so no result changes. --- graphiq/solvers/evolutionary_solver.py | 32 ++++---- .../test_evolutionary_random_solvers.py | 79 +++++++++++++++++++ 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index 64692511..8dba14a9 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -306,20 +306,24 @@ def adapt_probabilities(self): :rtype: None """ # TODO: check whether the input iteration is needed. - self.trans_probs[self.add_emitter_one_qubit_op] = max( - self.trans_probs[self.add_emitter_one_qubit_op] - - 1 / self.setting.n_stop / 3, - 0.01, - ) - self.trans_probs[self.replace_photon_one_qubit_op] = max( - self.trans_probs[self.replace_photon_one_qubit_op] - - 1 / self.setting.n_stop / 3, - 0.01, - ) - self.trans_probs[self.remove_op] = min( - self.trans_probs[self.remove_op] + 1 / self.setting.n_stop, 0.99 - ) - if self.n_emitter > 1: + # trans_probs may not contain every transformation; adapt only those present + if self.add_emitter_one_qubit_op in self.trans_probs: + self.trans_probs[self.add_emitter_one_qubit_op] = max( + self.trans_probs[self.add_emitter_one_qubit_op] + - 1 / self.setting.n_stop / 3, + 0.01, + ) + if self.replace_photon_one_qubit_op in self.trans_probs: + self.trans_probs[self.replace_photon_one_qubit_op] = max( + self.trans_probs[self.replace_photon_one_qubit_op] + - 1 / self.setting.n_stop / 3, + 0.01, + ) + if self.remove_op in self.trans_probs: + self.trans_probs[self.remove_op] = min( + self.trans_probs[self.remove_op] + 1 / self.setting.n_stop, 0.99 + ) + if self.n_emitter > 1 and self.add_emitter_cnot in self.trans_probs: self.trans_probs[self.add_emitter_cnot] = max( self.trans_probs[self.add_emitter_cnot] - 1 / self.setting.n_stop / 3, 0.01, diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index ec051210..73339a7b 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -487,6 +487,85 @@ def test_adapt_probabilities_also_shrinks_the_cnot_key_with_multiple_emitters(): assert solver.trans_probs[solver.add_emitter_cnot] < before[solver.add_emitter_cnot] +def test_adapt_probabilities_adapts_only_the_allowed_transformations(): + solver = EvolutionarySolver(None, None, None, n_emitter=1) + solver.set_allowed_transformations( + {solver.add_emitter_one_qubit_op: 0.5, solver.remove_op: 0.5} + ) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert set(solver.trans_probs) == set(before) + assert ( + solver.trans_probs[solver.add_emitter_one_qubit_op] + < before[solver.add_emitter_one_qubit_op] + ) + assert solver.trans_probs[solver.remove_op] > before[solver.remove_op] + assert np.isclose(sum(solver.trans_probs.values()), 1) + + +def test_adapt_probabilities_without_the_cnot_transformation(): + solver = EvolutionarySolver(None, None, None, n_emitter=2) + solver.set_allowed_transformations( + { + solver.add_emitter_one_qubit_op: 1 / 3, + solver.replace_photon_one_qubit_op: 1 / 3, + solver.remove_op: 1 / 3, + } + ) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert set(solver.trans_probs) == set(before) + for key in (solver.add_emitter_one_qubit_op, solver.replace_photon_one_qubit_op): + assert solver.trans_probs[key] < before[key] + assert solver.trans_probs[solver.remove_op] > before[solver.remove_op] + assert np.isclose(sum(solver.trans_probs.values()), 1) + + +def test_adapt_probabilities_without_the_emitter_one_qubit_or_remove_transformations(): + solver = EvolutionarySolver(None, None, None, n_emitter=1) + solver.set_allowed_transformations( + {solver.replace_photon_one_qubit_op: 0.5, solver.add_emitter_cnot: 0.5} + ) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert set(solver.trans_probs) == set(before) + assert ( + solver.trans_probs[solver.replace_photon_one_qubit_op] + < before[solver.replace_photon_one_qubit_op] + ) + assert np.isclose(sum(solver.trans_probs.values()), 1) + + +def test_adapt_probabilities_leaves_the_cnot_key_on_a_single_emitter_solver(): + # only the normalisation moves the cnot entry on a one-emitter solver, so its ratio + # to remove_op is the one remove_op's own update gives + solver = EvolutionarySolver(None, None, None, n_emitter=1) + solver.set_allowed_transformations( + { + solver.add_emitter_one_qubit_op: 0.25, + solver.replace_photon_one_qubit_op: 0.25, + solver.remove_op: 0.25, + solver.add_emitter_cnot: 0.25, + } + ) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + after = solver.trans_probs + new_remove = min(before[solver.remove_op] + 1 / solver.setting.n_stop, 0.99) + assert np.isclose( + after[solver.add_emitter_cnot] / after[solver.remove_op], + before[solver.add_emitter_cnot] / new_remove, + ) + + def test_adapt_probabilities_stay_non_strict_monotone_over_many_calls(): # The clamp to [0.01, 0.99] happens before normalisation, so it is not observable in the raw # values -- normalised, every decreasing key plateaus at a fixed point (0.0099 for one From 5c3e726aa23f50b60dbbf89db3f595cd0dff6c15 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 01:16:44 -0400 Subject: [PATCH 157/227] [fix] Let benchmark_data save its results and make the benchmark_utils script run With save_directory set, benchmark_data saved the solver's solver_info with json, and an EvolutionarySolver's solver_info holds its EvolutionarySolverSetting object, so the save raised "Object of type EvolutionarySolverSetting is not JSON serializable". The saved copy now holds the setting's text description instead; the returned solver data and the solver's solver_info are unchanged. Each circuit_target{j}_retry{i}.json file was written with the solver info again. It now holds that circuit's data (name, seed, runtime, measurement independence and openQASM description), which was already built for it and returned. The module's __main__ block imported the benchmarks package, which has no ghz3_state_circuit, and passed (circuit, state) pairs where benchmark_data expects a (circuit, target dict) pair. It now imports the circuits module and builds the ghz3 target in the expected shape. The four-target list that the next line overwrote is removed; the script still benchmarks ghz3 only. benchmark_data returns the same values as before, and nothing else in the package calls it. --- graphiq/benchmarks/benchmark_utils.py | 28 ++++++--- tests/test_benchmarking/test_benchmarking.py | 64 ++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/graphiq/benchmarks/benchmark_utils.py b/graphiq/benchmarks/benchmark_utils.py index 209d7d7b..d238eb47 100644 --- a/graphiq/benchmarks/benchmark_utils.py +++ b/graphiq/benchmarks/benchmark_utils.py @@ -24,7 +24,7 @@ from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.solvers.evolutionary_solver import EvolutionarySolver -from graphiq import benchmarks as circ +from graphiq.benchmarks import circuits as circ from graphiq.metrics import Infidelity from graphiq.io import IO @@ -167,7 +167,14 @@ def benchmark_data( solver.seed(i + seed_offset) solver_data = solver.solver_info if save_directory and i == 0 and j == 0: - io.save_json(solver_data, "solver_info.json") + # a solver setting object is not JSON serializable; save its description + io.save_json( + { + key: str(value) if key == "solver setting" else value + for key, value in solver_data.items() + }, + "solver_info.json", + ) start_time = time.time() solver.solve() @@ -195,7 +202,7 @@ def benchmark_data( } hof_circuits.append(circuit_data) if save_directory is not None: - io.save_json(solver_data, f"circuit_target{j}_retry{i}.json") + io.save_json(circuit_data, f"circuit_target{j}_retry{i}.json") io.save_txt( circuit_data["Circuit description"], f"circuit_description_target{j}_retry{i}.txt", @@ -217,13 +224,18 @@ def benchmark_data( if __name__ == "__main__": + circuit, state = circ.ghz3_state_circuit() target_list = [ - circ.ghz3_state_circuit(), - circ.ghz4_state_circuit(), - circ.linear_cluster_3qubit_circuit(), - circ.linear_cluster_4qubit_circuit(), + ( + circuit, + { + "name": "ghz3", + "dm": state, + "n_emitters": circuit.n_emitters, + "n_photons": circuit.n_photons, + }, + ) ] - target_list = [circ.ghz3_state_circuit()] compiler = DensityMatrixCompiler() compiler.measurement_determinism = 1 df = benchmark_data( diff --git a/tests/test_benchmarking/test_benchmarking.py b/tests/test_benchmarking/test_benchmarking.py index cdd23555..f9a9445d 100644 --- a/tests/test_benchmarking/test_benchmarking.py +++ b/tests/test_benchmarking/test_benchmarking.py @@ -12,7 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import functools +import json +import runpy + from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.benchmarks.benchmark_utils import benchmark_data from graphiq.io import IO from graphiq.metrics import Infidelity from graphiq.solvers.evolutionary_solver import ( @@ -59,5 +64,64 @@ def test_one_benchmarking_run(): # print(df) +def _only_benchmark_folder(path): + (folder,) = path.joinpath("benchmarks").glob("*_solver-benchmarks_*") + return folder + + +def test_benchmark_data_saves_its_results(tmp_path, monkeypatch): + monkeypatch.setattr(IO, "default_path", tmp_path) + circuit, state = ghz3_state_circuit() + target = { + "name": "ghz3", + "dm": state, + "n_emitters": circuit.n_emitters, + "n_photons": circuit.n_photons, + } + compiler = DensityMatrixCompiler() + compiler.measurement_determinism = 1 + setting = EvolutionarySolverSetting(n_stop=2, n_pop=4) + + solver_data, _, hof_circuits = benchmark_data( + functools.partial(EvolutionarySolver, solver_setting=setting), + [(circuit, target)], + Infidelity, + compiler, + seed_offset=3, + save_directory="benchmarks", + ) + + folder = _only_benchmark_folder(tmp_path) + with open(folder.joinpath("solver_info.json")) as file: + solver_info = json.load(file) + assert solver_info["solver setting"] == str(setting) + others = { + key: value for key, value in solver_data.items() if key != "solver setting" + } + assert { + key: value for key, value in solver_info.items() if key != "solver setting" + } == json.loads(json.dumps(others)) + + with open(folder.joinpath("circuit_target0_retry0.json")) as file: + circuit_info = json.load(file) + assert set(circuit_info) == set(hof_circuits[0]) + assert circuit_info["name"] == "ghz3" + assert circuit_info["seed"] == 3 + assert circuit_info["Circuit description"] == hof_circuits[0]["Circuit description"] + + # the returned solver info still holds the setting object + assert solver_data["solver setting"] is setting + + +def test_the_benchmark_utils_script_runs(tmp_path, monkeypatch): + monkeypatch.setattr(IO, "default_path", tmp_path) + + runpy.run_module("graphiq.benchmarks.benchmark_utils", run_name="__main__") + + folder = _only_benchmark_folder(tmp_path) + assert folder.joinpath("solver_info.json").is_file() + assert folder.joinpath("circuit_target0_retry0.json").is_file() + + if __name__ == "__main__": test_one_benchmarking_run() From 6db9d3557bc01d6b05002658451271ffb61a528a Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 01:19:46 -0400 Subject: [PATCH 158/227] [fix] Stop CompilerBase.compile from changing a pure stabilizer initial_state When an initial_state was supplied, compile passed initial_state.rep_data.data to the new QuantumState by reference. A stabilizer state stores its tableau without copying, so the compiled state and the caller's pure stabilizer initial_state shared one CliffordTableau, and compiling the circuit evolved the caller's state too. compile now copies the data before building the compiled state, so a pure stabilizer initial_state can be reused across compiles. A density matrix could already be shared the same way, but its operations replace the array rather than write into it, so the caller's matrix was never changed; copying it changes nothing. A mixed stabilizer's list of tableaux is copied only shallowly, so a mixed initial_state is still changed by a noisy, non-Monte-Carlo stabilizer compile, as before; any other compile rejects a mixed initial_state with a TypeError. The only caller in the package, the validation in the local-Clifford converter, does not read its initial state again, so no result changes. --- graphiq/backends/compiler_base.py | 5 +++++ .../stabilizer/test_stabilizer_compiler.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/graphiq/backends/compiler_base.py b/graphiq/backends/compiler_base.py index 2b766b25..6d4c0dc3 100644 --- a/graphiq/backends/compiler_base.py +++ b/graphiq/backends/compiler_base.py @@ -107,7 +107,12 @@ def compile(self, circuit: CircuitBase, initial_state=None): assert ( initial_state.n_qubits == circuit.n_quantum ), "the number of qubits in initial state must match the circuit" + # copy the data so evolving the compiled state does not change the + # tableau of a caller's pure stabilizer initial_state (a mixed + # stabilizer's list of tableaux is copied only shallowly) state_data = initial_state.rep_data.data + if hasattr(state_data, "copy"): + state_data = state_data.copy() else: state_data = circuit.n_quantum diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py index 6ce99fcf..4d5b7afc 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py @@ -20,6 +20,8 @@ import graphiq.circuit.ops as ops from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.tableau import * +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.state import QuantumState from graphiq.benchmarks.circuits import ( linear_cluster_4qubit_circuit, linear_cluster_3qubit_circuit, @@ -112,3 +114,17 @@ def test_compile_circuit2(): compiler.measurement_determinism = 1 output_stabilizer = compiler.compile(dag) print(output_stabilizer.rep_data) + + +def test_compile_leaves_the_initial_state_unchanged(): + circuit = CircuitDAG(n_emitter=0, n_photon=2, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="p")) + initial_state = QuantumState(CliffordTableau(2), rep_type="s") + initial_tableau = initial_state.rep_data.data + before = initial_tableau.copy() + + compiled_state = StabilizerCompiler().compile(circuit, initial_state=initial_state) + + assert initial_state.rep_data.data == before + assert compiled_state.rep_data.data is not initial_tableau + assert compiled_state.rep_data.data != before From a39556a3d02e737d11267d258735c106d578ecd6 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 01:50:07 -0400 Subject: [PATCH 159/227] [fix] Deep-copy the initial state's data in CompilerBase.compile compile copied initial_state.rep_data.data with .copy(). For a mixed stabilizer state that data is a list of (probability, tableau) pairs, and a list's copy is shallow, so the compiled state shared the caller's tableaux and a noisy, non-Monte-Carlo stabilizer compile changed them. The data is now copied with copy.deepcopy. For a pure stabilizer tableau, which .copy() already deep-copied, a density matrix, which no operation writes into, or a graph, which the density-matrix compiler only reads, nothing changes. The only caller in the package passes a pure state, so no result changes. --- graphiq/backends/compiler_base.py | 9 ++++----- .../stabilizer/test_stabilizer_compiler.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/graphiq/backends/compiler_base.py b/graphiq/backends/compiler_base.py index 6d4c0dc3..c3f9c78e 100644 --- a/graphiq/backends/compiler_base.py +++ b/graphiq/backends/compiler_base.py @@ -19,6 +19,7 @@ The Base class defines an API which all compiler implementations should follow """ +import copy import logging from abc import ABC, abstractmethod @@ -108,11 +109,9 @@ def compile(self, circuit: CircuitBase, initial_state=None): initial_state.n_qubits == circuit.n_quantum ), "the number of qubits in initial state must match the circuit" # copy the data so evolving the compiled state does not change the - # tableau of a caller's pure stabilizer initial_state (a mixed - # stabilizer's list of tableaux is copied only shallowly) - state_data = initial_state.rep_data.data - if hasattr(state_data, "copy"): - state_data = state_data.copy() + # caller's initial_state; deep, since a mixed stabilizer's data is a + # list of (probability, tableau) pairs + state_data = copy.deepcopy(initial_state.rep_data.data) else: state_data = circuit.n_quantum diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py index 4d5b7afc..63d34c59 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py @@ -128,3 +128,20 @@ def test_compile_leaves_the_initial_state_unchanged(): assert initial_state.rep_data.data == before assert compiled_state.rep_data.data is not initial_tableau assert compiled_state.rep_data.data != before + + +def test_compile_leaves_a_mixed_initial_state_unchanged(): + circuit = CircuitDAG(n_emitter=0, n_photon=2, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="p")) + initial_state = QuantumState( + [(0.5, CliffordTableau(2)), (0.5, CliffordTableau(2))], rep_type="s", mixed=True + ) + before = [(p, tableau.copy()) for p, tableau in initial_state.rep_data.mixture] + compiler = StabilizerCompiler() + # a noisy compile evolves a mixed stabilizer state + compiler.noise_simulation = True + + compiled_state = compiler.compile(circuit, initial_state=initial_state) + + assert initial_state.rep_data.mixture == before + assert compiled_state.rep_data.mixture != before From f62ac1246105d1bc75812affb3ad21e4d6157325 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:55 -0400 Subject: [PATCH 160/227] [fix] Apply a wrapper's gates last entry first in its openQASM body single_qubit_wrapper_info wrote the body of the gate definition for a OneQubitGateWrapper with its operations in list order, so in openQASM, which runs a gate body top to bottom, operations[0] was applied first. graphiq applies a wrapper last entry first: unwrap returns the gates in reverse, the compilers apply exactly those gates, and local_clifford_to_matrix_map builds the matching matrix product. For a wrapper of two or more non-Identity gates that does not read the same backwards, such as [Hadamard, Phase], the exported definition described a different unitary from the one graphiq simulated. The body now lists the gates in reverse, so an exported wrapper does what unwrap does. The gate name (hs for [Hadamard, Phase]), the gate symbol, the imports and the other definitions are unchanged. No computed result changes: the compilers never read openQASM. The exported openQASM text for multi-gate wrappers changes, in the definition body only. Nothing in the package reads the body back: CircuitDAG.from_openqasm rebuilds a wrapper from its gate name, and the drawings show it as one box labelled with its symbol, so round trips and drawings are the same as before. Files exported earlier and read by another tool, such as qiskit, describe the wrappers in the wrong order. --- graphiq/utils/openqasm_lib.py | 7 +++-- tests/test_circuits/test_openqasm.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/graphiq/utils/openqasm_lib.py b/graphiq/utils/openqasm_lib.py index e397b7ec..87e580cd 100644 --- a/graphiq/utils/openqasm_lib.py +++ b/graphiq/utils/openqasm_lib.py @@ -382,7 +382,8 @@ def single_qubit_wrapper_info(op_list): """ This function combines the openqasm info from multiple base Operations, to create a composed block of multiple operations - :param op_list: list of operation classes which will be applied by the Operation block + :param op_list: list of operation classes which will be applied by the Operation block, + in OneQubitGateWrapper order: the last entry is applied first :type op_list: list :return: OpenQASMInfo object corresponding to the composed gate :rtype: OpenQASMInfo @@ -406,7 +407,9 @@ def single_qubit_wrapper_info(op_list): definitions = definitions + oq_info.define_gate gate_name += oq_info.gate_name gate_symbol += oq_info.gate_symbol - def_usage += f"{oq_info.gate_name} a;\n" + # a OneQubitGateWrapper applies its operations last entry first, so the + # definition body lists them in reverse + def_usage = f"{oq_info.gate_name} a;\n" + def_usage if gate_name in gate_name_dict: # i.e. gate is already somehow defined return gate_name_dict[gate_name] diff --git a/tests/test_circuits/test_openqasm.py b/tests/test_circuits/test_openqasm.py index 71792480..4d2c4e1f 100644 --- a/tests/test_circuits/test_openqasm.py +++ b/tests/test_circuits/test_openqasm.py @@ -16,6 +16,7 @@ import numpy as np import pytest from qiskit import QuantumCircuit +from qiskit.quantum_info import Operator import graphiq.circuit.ops as ops import graphiq.utils.openqasm_lib as oq_lib @@ -166,6 +167,47 @@ def test_gates_wrapper_1(gate_list): raise e +@pytest.mark.parametrize( + "gate_list", + [ + [ops.Hadamard, ops.Phase], + [ops.Phase, ops.Hadamard], + [ops.Hadamard, ops.SigmaX], + [ops.Hadamard, ops.SigmaY, ops.Phase], + [ops.Hadamard, ops.PhaseDagger, ops.SigmaY], + [ops.Hadamard, ops.Phase, ops.Hadamard, ops.Phase], + [ops.Phase, ops.Identity, ops.Hadamard], + [ops.Identity, ops.SigmaX], + [ops.Hadamard], + ], +) +def test_wrapper_openqasm_applies_the_last_gate_first(gate_list): + # the compilers apply a wrapper through unwrap(), so its openQASM must match + wrapper = ops.OneQubitGateWrapper(gate_list, register=0, reg_type="e") + wrapped = CircuitDAG(n_emitter=1) + wrapped.add(wrapper) + unwrapped = CircuitDAG(n_emitter=1) + for op in wrapper.unwrap(): + unwrapped.add(op) + u_wrapped = Operator(QuantumCircuit.from_qasm_str(wrapped.to_openqasm())) + u_unwrapped = Operator(QuantumCircuit.from_qasm_str(unwrapped.to_openqasm())) + assert u_wrapped.equiv(u_unwrapped) + + +def test_wrapper_openqasm_name_and_symbol_unchanged(): + gate_list = [ops.Hadamard, ops.Phase, ops.SigmaY] + circuit = CircuitDAG(n_emitter=1) + circuit.add(ops.OneQubitGateWrapper(gate_list, register=0, reg_type="e")) + assert "hsy e0[0];" in circuit.to_openqasm() + assert circuit.openqasm_symbols["hsy"] == "U=HPY" + (wrapper,) = [ + op + for op in CircuitDAG.from_openqasm(circuit.to_openqasm()).sequence() + if isinstance(op, ops.OneQubitGateWrapper) + ] + assert wrapper.operations == gate_list + + @visualization def test_load_circuit_1(initialization_circuit): """ From e6fbe020dfdef8700f3d4c6153e5b004290dea41 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:55 -0400 Subject: [PATCH 161/227] [fix] Report the classical-bit index of a measurement in OpenQASMParser OpenQASMParser._parse_measure checked whether the classical-bit argument of a measurement had an index but then stored the qubit's index. For "measure q[i] -> c[j];" the parsed creg index was therefore i instead of j. The existing test measures q[1] into c[1], where the two agree. The creg index is now read from the classical-bit argument. No in-tree result changes. graphiq's own export uses registers of size one, so the two indices always agree, and the Painter does not read the creg index. Only parse() on openQASM that measures q[i] into c[j] with i != j now reports j. --- graphiq/utils/openqasm_parser.py | 2 +- tests/test_utils/test_openqasm_parser.py | 38 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/graphiq/utils/openqasm_parser.py b/graphiq/utils/openqasm_parser.py index 45f9fe66..e3cb7a65 100644 --- a/graphiq/utils/openqasm_parser.py +++ b/graphiq/utils/openqasm_parser.py @@ -288,7 +288,7 @@ def _parse_measure(self, measure_node): "name": creg_node.name, } if hasattr(creg_node, "index"): - measure_info["creg"]["index"] = qreg_node.index + measure_info["creg"]["index"] = creg_node.index return measure_info diff --git a/tests/test_utils/test_openqasm_parser.py b/tests/test_utils/test_openqasm_parser.py index 0f50de77..06860fa9 100644 --- a/tests/test_utils/test_openqasm_parser.py +++ b/tests/test_utils/test_openqasm_parser.py @@ -136,6 +136,44 @@ def test_parser_parse_measure(): assert parse_measure(measure_node) == expected_output +@pytest.mark.parametrize( + "measure, q_index, c_index", + [ + ("measure q[2] -> c[0];", 2, 0), + ("measure q[0] -> c[2];", 0, 2), + ("measure q[1] -> c[1];", 1, 1), + ], +) +def test_parser_parse_measure_keeps_the_creg_index(measure, q_index, c_index): + parser = Qasm(data=f"qreg q[3];\ncreg c[3];\n{measure}\n") + measure_node = parser.parse().children[-1] + + expected_output = { + "type": "measure", + "qreg": {"name": "q", "index": q_index}, + "creg": {"name": "c", "index": c_index}, + } + + myparser = OpenQASMParser(openqasm_str="") + parse_measure = myparser.use_parse("measure") + assert parse_measure(measure_node) == expected_output + + +def test_parser_parse_measure_whole_register(): + parser = Qasm(data="qreg q[3];\ncreg c[3];\nmeasure q -> c;\n") + measure_node = parser.parse().children[-1] + + expected_output = { + "type": "measure", + "qreg": {"name": "q"}, + "creg": {"name": "c"}, + } + + myparser = OpenQASMParser(openqasm_str="") + parse_measure = myparser.use_parse("measure") + assert parse_measure(measure_node) == expected_output + + def test_parser_parse_reset(): openqasm = """ qreg q[3]; From a84dd373c668ddd30afe285d29c3a675a57b779a Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:55 -0400 Subject: [PATCH 162/227] [fix] Store RX, RY and RZ default param_info in the per-parameter shape With param_info left as None, RX, RY and RZ stored the bounds as the flat pair (-pi, pi) and the label as the bare string "theta" ("phi" for RZ). CircuitBase.initialize_parameters reads one (lb, ub) pair per parameter, so on any circuit holding one of these gates with its default param_info it raised "TypeError: cannot unpack non-iterable float object". GradientDescentSolver.solve calls initialize_parameters when no initial_params are given, so it failed the same way. The default also failed the gates' own _validate_param_info check, so passing a gate's default param_info back in raised ValueError. The default is now {"bounds": ((-pi, pi),), "labels": ("theta",)} (("phi",) for RZ), the shape the validator, initialize_parameters and the three-parameter rotation gates already use. The docstrings now state that default. No in-tree result changes: nothing in the package initializes a circuit that holds RX, RY or RZ, and circuits without them draw the same parameters as before. Such circuits used to raise; each of these gates now draws one value from random.uniform(-pi, pi). --- graphiq/circuit/ops.py | 27 ++++++------- tests/test_circuits/test_dag_circuit.py | 51 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 44d53c64..e6db2523 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -964,9 +964,8 @@ def __init__( :type noise: graphiq.noise.noise_models.NoiseBase :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) :type params: tuple - :param param_info: bounds and labels for the parameter; defaults to bounds (-pi, pi) and label 'theta'. A - dict passed here must wrap each in a length-1 sequence, e.g. {"bounds": ((-pi, pi),), "labels": - ("theta",)}; the stored default's own shape does not pass that check + :param param_info: bounds and labels for the parameter; defaults to {"bounds": ((-pi, pi),), "labels": + ("theta",)}. A dict passed here must have that shape: 'bounds' and 'labels' each a length-1 sequence :type param_info: dict :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and 'labels' entries of length 1 @@ -982,8 +981,8 @@ def __init__( raise ValueError("Length of params must be 1") if param_info is None: param_info = { - "bounds": (-np.pi, np.pi), - "labels": "theta", + "bounds": ((-np.pi, np.pi),), + "labels": ("theta",), } else: if not self._validate_param_info(param_info, 1): @@ -1014,9 +1013,8 @@ def __init__( :type noise: graphiq.noise.noise_models.NoiseBase :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) :type params: tuple - :param param_info: bounds and labels for the parameter; defaults to bounds (-pi, pi) and label 'theta'. A - dict passed here must wrap each in a length-1 sequence, e.g. {"bounds": ((-pi, pi),), "labels": - ("theta",)}; the stored default's own shape does not pass that check + :param param_info: bounds and labels for the parameter; defaults to {"bounds": ((-pi, pi),), "labels": + ("theta",)}. A dict passed here must have that shape: 'bounds' and 'labels' each a length-1 sequence :type param_info: dict :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and 'labels' entries of length 1 @@ -1034,8 +1032,8 @@ def __init__( if param_info is None: param_info = { - "bounds": (-np.pi, np.pi), - "labels": "theta", + "bounds": ((-np.pi, np.pi),), + "labels": ("theta",), } else: if not self._validate_param_info(param_info, 1): @@ -1066,9 +1064,8 @@ def __init__( :type noise: graphiq.noise.noise_models.NoiseBase :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) :type params: tuple - :param param_info: bounds and labels for the parameter; defaults to bounds (-pi, pi) and label 'phi'. A - dict passed here must wrap each in a length-1 sequence, e.g. {"bounds": ((-pi, pi),), "labels": - ("phi",)}; the stored default's own shape does not pass that check + :param param_info: bounds and labels for the parameter; defaults to {"bounds": ((-pi, pi),), "labels": + ("phi",)}. A dict passed here must have that shape: 'bounds' and 'labels' each a length-1 sequence :type param_info: dict :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and 'labels' entries of length 1 @@ -1085,8 +1082,8 @@ def __init__( raise ValueError("Length of params must be 1") if param_info is None: param_info = { - "bounds": (-np.pi, np.pi), - "labels": "phi", + "bounds": ((-np.pi, np.pi),), + "labels": ("phi",), } else: if not self._validate_param_info(param_info, 1): diff --git a/tests/test_circuits/test_dag_circuit.py b/tests/test_circuits/test_dag_circuit.py index 6839fba4..edae5ca7 100644 --- a/tests/test_circuits/test_dag_circuit.py +++ b/tests/test_circuits/test_dag_circuit.py @@ -14,6 +14,7 @@ # limitations under the License. import random +import numpy as np import pytest import graphiq.circuit.ops as ops @@ -679,3 +680,53 @@ def test_initialize_parameters_different_seed_gives_different_parameter_values() parameters_b = circuit_b.initialize_parameters(seed=43) assert list(parameters_a.values()) != list(parameters_b.values()) + + +@pytest.mark.parametrize("gate", [ops.RX, ops.RY, ops.RZ]) +def test_initialize_parameters_samples_default_rotation_gate_bounds(gate): + circuit = CircuitDAG(n_emitter=1) + op = gate(register=0, reg_type="e") + circuit.add(op) + + parameters = circuit.initialize_parameters(seed=0) + + random.seed(0) + expected = [random.uniform(-np.pi, np.pi)] + assert list(parameters.values()) == [expected] + assert list(op.params) == expected + + +@pytest.mark.parametrize("gate", [ops.RX, ops.RY, ops.RZ]) +def test_default_rotation_gate_param_info_is_accepted_back(gate): + assert ops.OperationBase._validate_param_info(gate().param_info, 1) + gate(param_info=gate().param_info) + + +def test_initialize_parameters_draws_in_sequence_order_across_gate_kinds(): + circuit = CircuitDAG(n_emitter=1) + gates = [ + ops.ParameterizedOneQubitRotation(register=0, reg_type="e"), + ops.RX(register=0, reg_type="e"), + ops.ParameterizedOneQubitRotation(register=0, reg_type="e"), + ] + for op in gates: + circuit.add(op) + + circuit.initialize_parameters(seed=7) + + random.seed(7) + draws = [random.uniform(-np.pi, np.pi) for _ in range(7)] + assert [list(op.params) for op in gates] == [draws[0:3], draws[3:4], draws[4:7]] + + +def test_initialize_parameters_of_the_variational_layer_is_unchanged(): + from graphiq.benchmarks.circuits import variational_entangling_layer_2qubit + + circuit, _ = variational_entangling_layer_2qubit() + + parameters = circuit.initialize_parameters(seed=42) + + assert list(parameters.values()) == [ + [0.8760444114976647, -2.984445443853267, -1.4135324813693264], + [-1.7391182232484002, 1.48579245840624, 1.1102356231618007], + ] From 4c54d860f7ab3eb38e15a81d653de300e4633dce Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:56 -0400 Subject: [PATCH 163/227] [fix] Let rnd_graph with an integer seed return a connected graph rnd_graph redraws a random graph until it is connected, but it passed the same seed to every draw. networkx builds a fresh random.Random from an integer seed on each call, so every retry returned the same graph, and a seed whose first draw was disconnected made rnd_graph loop forever; for G(6, 0.3), 35 of the seeds 0-49 did. An integer seed is now turned into one random.Random before the loop, so the retries continue a single seeded stream. The result is still the same for the same seed. The first draw uses the same generator state as before, so a seed whose first draw is connected gives the same graph as before. The Barabasi-Albert model always draws a connected graph and does not change. No caller in the package passes a seed, and unseeded calls do not change. --- graphiq/data_collection/correlation_module.py | 3 ++ .../test_correlation_module.py | 31 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 4e62d4c9..aeebd2ac 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -1017,6 +1017,9 @@ def rnd_graph(n, p=None, m=None, seed=None, model="erdos"): elif model == "albert": rnd_maker = nx.barabasi_albert_graph x = m + if isinstance(seed, int): + # one seeded stream for all draws, so a disconnected draw is not repeated forever + seed = random.Random(seed) g = nx.Graph() g.add_edges_from(((0, 1), (2, 3))) while not nx.is_connected(g): diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py index 4bdbef35..43595e64 100644 --- a/tests/test_data_collection/test_correlation_module.py +++ b/tests/test_data_collection/test_correlation_module.py @@ -20,7 +20,7 @@ from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, ) -from graphiq.data_collection.correlation_module import graph_to_circ +from graphiq.data_collection.correlation_module import graph_to_circ, rnd_graph from graphiq.metrics import Infidelity from graphiq.state import QuantumState @@ -54,3 +54,32 @@ def test_graph_to_circ_solves_a_disconnected_graph_without_an_isolated_vertex(ou n_photon = graph.number_of_nodes() state.partial_trace(list(range(n_photon)), [2] * circuit.n_quantum) assert np.isclose(Infidelity(target).evaluate(state, circuit), 0.0) + + +def test_rnd_graph_with_a_seed_redraws_until_connected(monkeypatch): + n, p, seed = 6, 0.3, 0 + assert not nx.is_connected(nx.gnp_random_graph(n, p, seed=seed)) + + draw = nx.gnp_random_graph + calls = [] + + def bounded(*args, **kwargs): + calls.append(1) + if len(calls) > 100: + raise RuntimeError("rnd_graph drew 100 graphs without a connected one") + return draw(*args, **kwargs) + + monkeypatch.setattr(nx, "gnp_random_graph", bounded) + graph = rnd_graph(n, p, seed=seed) + assert nx.is_connected(graph) + assert graph.number_of_nodes() == n + assert sorted(rnd_graph(n, p, seed=seed).edges()) == sorted(graph.edges()) + + +def test_rnd_graph_with_a_seed_keeps_a_connected_first_draw(): + n, p = 6, 0.3 + seeds = [s for s in range(50) if nx.is_connected(nx.gnp_random_graph(n, p, seed=s))] + assert seeds + for s in seeds: + expected = nx.gnp_random_graph(n, p, seed=s) + assert sorted(rnd_graph(n, p, seed=s).edges()) == sorted(expected.edges()) From 304f0ae7c41b292bc2042da9b659bc8bed908b4e Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:56 -0400 Subject: [PATCH 164/227] [fix] Let GraphCorr work without an initial graph GraphCorr.__init__ set _initial_graph only when an initial graph was given. A GraphCorr built from a graph_list, or for random graphs, raised AttributeError on .initial_graph, although the docstring treats initial_graph as optional. The num_isomorph setter failed the same way, because it always rebuilt graph_list from the initial graph, so the number of relabelings per graph that _min_circ_met_over_relabel uses with relabel_trials could not be changed after construction. __init__ now sets _initial_graph to None when none is given, and the num_isomorph setter rebuilds graph_list only when there is an initial graph, as __init__ does. Without one, the setter only stores the new count. initial_graph now returns None in these modes, and num_isomorph can be set in them, leaving graph_list unchanged. With an initial graph nothing changes, and no result in the package changes. --- graphiq/data_collection/correlation_module.py | 4 ++- .../test_correlation_module.py | 32 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index aeebd2ac..83180e63 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -115,6 +115,7 @@ def __init__( raise ValueError("The circuit metric should be a valid string") if initial_graph is None: + self._initial_graph = None self._graph_list = graph_list if graph_list: assert isinstance( @@ -164,7 +165,8 @@ def num_isomorph(self): def num_isomorph(self, num): assert isinstance(num, int) self._num_isomorph = num - self.graph_list = self._graph_list_maker(self._initial_graph, count=num) + if self._initial_graph is not None: + self.graph_list = self._graph_list_maker(self._initial_graph, count=num) @property def relabel_trials(self): diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py index 43595e64..8fdf226c 100644 --- a/tests/test_data_collection/test_correlation_module.py +++ b/tests/test_data_collection/test_correlation_module.py @@ -20,10 +20,40 @@ from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, ) -from graphiq.data_collection.correlation_module import graph_to_circ, rnd_graph +from graphiq.data_collection.correlation_module import ( + GraphCorr, + graph_to_circ, + rnd_graph, +) from graphiq.metrics import Infidelity from graphiq.state import QuantumState + +def test_graph_corr_initial_graph_is_none_without_an_initial_graph(): + assert GraphCorr(graph_list=[nx.path_graph(3)]).initial_graph is None + assert GraphCorr().initial_graph is None + + +def test_num_isomorph_setter_without_an_initial_graph_sets_the_relabel_count( + monkeypatch, +): + graph = nx.cycle_graph(6) + corr = GraphCorr(circ_metric="num_emit", graph_list=[graph], relabel_trials=1) + corr.num_isomorph = 3 + assert corr.num_isomorph == 3 + assert corr.graph_list == [graph] + + counts = [] + + def record(g, count): + counts.append(count) + return [g] + + monkeypatch.setattr(GraphCorr, "_graph_list_maker", staticmethod(record)) + corr._min_circ_met_over_relabel(graph) + assert counts == [3] + + _p3_plus_k1 = nx.disjoint_union(nx.path_graph(3), nx.empty_graph(1)) From 957999d140c384e1122e9bf68bd43c55f337fea9 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:56 -0400 Subject: [PATCH 165/227] [fix] Let GraphCorr use relabel_trials after graph_circ_dict graph_circ_dict caches a circuit for each graph in graph_list, and once the cache existed _circ_met_value looked every graph up in it. With relabel_trials set, _min_circ_met_over_relabel passes newly built relabelings of each graph, which are not in the cache, since networkx graphs are keyed by identity. The lookup raised KeyError, so finder failed, and met_distribution and met_met reported "Metric not found". A graph that is not in the cache is now used as it is. Every circuit metric already handles a graph, building its circuit as it does without the cache, and a cached graph still uses its cached circuit. The affected calls raised before, and now return the same minimum over the relabelings as without the cache. Calls without the cache or without relabel_trials do not change. --- graphiq/data_collection/correlation_module.py | 2 +- .../test_correlation_module.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 83180e63..055c246a 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -653,7 +653,7 @@ def _circ_met_value(self, g): if isinstance(g, nx.Graph): g_temp = g # use the circuit list instead of the graph if possible - g = self._graph_circ_dictionary[g] if self._graph_circ_dictionary else g + g = self._graph_circ_dictionary.get(g, g) if self._graph_circ_dictionary else g # if graph list is provided, g is now a circuit and g_temp is the corresponding graph if self.circ_metric == "num_emit": circ_value = height_max(graph=g_temp) diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py index 8fdf226c..96c152c7 100644 --- a/tests/test_data_collection/test_correlation_module.py +++ b/tests/test_data_collection/test_correlation_module.py @@ -23,6 +23,7 @@ from graphiq.data_collection.correlation_module import ( GraphCorr, graph_to_circ, + graph_to_cnot, rnd_graph, ) from graphiq.metrics import Infidelity @@ -54,6 +55,26 @@ def record(g, count): assert counts == [3] +def test_relabel_trials_work_after_graph_circ_dict(monkeypatch): + graph = nx.cycle_graph(6) + perm = [0, 1, 2, 3, 5, 4] + adj = nx.to_numpy_array(graph) + relabeled = nx.from_numpy_array(adj[np.ix_(perm, perm)]) + assert graph_to_cnot(graph) == 3 + assert graph_to_cnot(relabeled) == 2 + monkeypatch.setattr( + GraphCorr, "_graph_list_maker", staticmethod(lambda g, count: [g, relabeled]) + ) + + corr = GraphCorr( + circ_metric="cnot", graph_list=[graph], relabel_trials=2, num_isomorph=1 + ) + corr.graph_circ_dict() + assert relabeled not in corr._graph_circ_dictionary + + assert corr._min_circ_met_over_relabel(graph) == 2 + + _p3_plus_k1 = nx.disjoint_union(nx.path_graph(3), nx.empty_graph(1)) From 7fe86f11da8d600d8337937c4f98d24acdffa031 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:56 -0400 Subject: [PATCH 166/227] [fix] Update NodeCorr's adjacency matrix when its graph is set The NodeCorr graph setter stored the new graph but not its adjacency matrix, and _update_data rebuilds adj_list and met_list from that matrix. After nc.graph = new_graph, graph reported the new graph while adj, adj_list and met_list still described the previous one, so order_corr, met_order_error and next_node_corr silently analysed the old graph. The setter now also stores the new graph's adjacency matrix, converted the same way as in __init__, before the data are rebuilt. After the setter every analysis describes the new graph. Construction and the other setters do not change, and nothing in the package assigns NodeCorr.graph, so no result changes. --- graphiq/data_collection/correlation_module.py | 1 + .../test_data_collection/test_correlation_module.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 055c246a..303151b4 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -762,6 +762,7 @@ def graph(self): def graph(self, g): assert isinstance(g, nx.Graph) self._graph = g + self._adj = nx.to_numpy_array(g) self._update_data() @property diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py index 96c152c7..cdf6221e 100644 --- a/tests/test_data_collection/test_correlation_module.py +++ b/tests/test_data_collection/test_correlation_module.py @@ -22,6 +22,7 @@ ) from graphiq.data_collection.correlation_module import ( GraphCorr, + NodeCorr, graph_to_circ, graph_to_cnot, rnd_graph, @@ -75,6 +76,17 @@ def test_relabel_trials_work_after_graph_circ_dict(monkeypatch): assert corr._min_circ_met_over_relabel(graph) == 2 +def test_node_corr_graph_setter_updates_the_adjacency_matrix(): + corr = NodeCorr(nx.path_graph(4), relabel_trials=3, show_plot=False) + new_graph = nx.cycle_graph(5) + corr.graph = new_graph + + assert np.array_equal(corr.adj, nx.to_numpy_array(new_graph)) + assert corr.adj_list + for adj in corr.adj_list: + assert nx.is_isomorphic(nx.from_numpy_array(adj), new_graph) + + _p3_plus_k1 = nx.disjoint_union(nx.path_graph(3), nx.empty_graph(1)) From bc0ca073a7f3d92a889f765027a291f67dfc66ee Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:56 -0400 Subject: [PATCH 167/227] [fix] Let find_best run without a dir_path and return its results find_best declares dir_path=None as its default, but after running the solver it always saved a CSV file and a JSON file under dir_path. With the default, os.path.exists(None) raised TypeError, so a call without a dir_path spent the solve and then lost the results it had computed. The files are now written only when a dir_path is given. Without one, find_best returns its results dictionary, as its docstring describes, and writes nothing. With a dir_path, the saved files and the returned dictionary are the same as before. graph_analyzer, the only caller in the package, always passes a dir_path, so no result changes. --- graphiq/data_collection/user_interface.py | 11 ++++++----- tests/test_data_collection/test_user_interface.py | 14 +++++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index 4481717b..83111f23 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -446,11 +446,12 @@ def find_best( # del out_dict['std of score'] del out_dict["graph_metric"] df = pd.DataFrame(out_dict) - if not os.path.exists(dir_path): - os.makedirs(dir_path) - filename = dir_path + f"/{file_name}.csv" - df.to_csv(filename, index=False) - result.save2json(dir_path, f"res_{file_name}") + if dir_path is not None: + if not os.path.exists(dir_path): + os.makedirs(dir_path) + filename = dir_path + f"/{file_name}.csv" + df.to_csv(filename, index=False) + result.save2json(dir_path, f"res_{file_name}") cost = [] for i in range(len(result)): cost.append( diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py index 5576e53b..fd246de4 100644 --- a/tests/test_data_collection/test_user_interface.py +++ b/tests/test_data_collection/test_user_interface.py @@ -12,9 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import networkx as nx + import graphiq.circuit.ops as ops from graphiq.circuit.circuit_dag import CircuitDAG -from graphiq.data_collection.user_interface import result_maker +from graphiq.data_collection.user_interface import find_best, result_maker from graphiq.metrics import CircuitUnitaryCount from graphiq.solvers.solver_result import SolverResult @@ -30,3 +32,13 @@ def test_result_maker_counts_each_one_qubit_gate_once(): assert result["n_unitary"] == [5] assert result["n_unitary"][0] == CircuitUnitaryCount().evaluate(None, circuit) + + +def test_find_best_without_dir_path_returns_results_and_writes_nothing( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + out = find_best(nx.to_numpy_array(nx.path_graph(3)), n_reordering=1, conf=0) + + assert len(out["cost"]) >= 1 + assert list(tmp_path.iterdir()) == [] From 971b9ad6cd069139cf68abe0dfbef630e21e9b42 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:56 -0400 Subject: [PATCH 168/227] [fix] Register the n_cnots property that rgs_analysis fills rgs_analysis registered the property "n_cnot" with add_properties but stored the CNOT counts under "n_cnots". The result therefore carried an extra "n_cnot" column of None values, which save2json wrote into the saved JSON, and its property list named "n_cnot" instead of the "n_cnots" column that holds the counts. rgs_analysis now registers "n_cnots", the name the rest of the module uses for the CNOT count. The saved JSON no longer has the empty "n_cnot" column. The "n_cnots" values and the correlations and plots made from them do not change. --- graphiq/data_collection/user_interface.py | 2 +- .../test_user_interface.py | 25 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index 83111f23..799a3b17 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -752,7 +752,7 @@ def rgs_analysis(res_rgsn, dir_path: str, filename: str): n_cnots.append(len(c.get_node_by_labels(["Emitter-Emitter", "CNOT"]))) else: n_cnots.append(0) - res_rgsn.add_properties("n_cnot") + res_rgsn.add_properties("n_cnots") res_rgsn["n_cnots"] = n_cnots graph_met_list = [ "node_connect", diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py index fd246de4..53a9bb10 100644 --- a/tests/test_data_collection/test_user_interface.py +++ b/tests/test_data_collection/test_user_interface.py @@ -12,11 +12,14 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import json + import networkx as nx import graphiq.circuit.ops as ops from graphiq.circuit.circuit_dag import CircuitDAG -from graphiq.data_collection.user_interface import find_best, result_maker +from graphiq.data_collection.correlation_module import graph_to_circ, graph_to_cnot +from graphiq.data_collection.user_interface import find_best, result_maker, rgs_analysis from graphiq.metrics import CircuitUnitaryCount from graphiq.solvers.solver_result import SolverResult @@ -42,3 +45,23 @@ def test_find_best_without_dir_path_returns_results_and_writes_nothing( assert len(out["cost"]) >= 1 assert list(tmp_path.iterdir()) == [] + + +def test_rgs_analysis_saves_n_cnots_without_an_empty_n_cnot_column( + tmp_path, monkeypatch +): + ui = "graphiq.data_collection.user_interface" + monkeypatch.setattr(f"{ui}.correlation_checker", lambda *a, **k: None) + monkeypatch.setattr(f"{ui}.plot_figs", lambda *a, **k: None) + graph = nx.cycle_graph(5) + result = SolverResult([graph_to_circ(graph)], ["g"]) + result["g"] = [graph] + (tmp_path / "RGS" / "run").mkdir(parents=True) + + rgs_analysis(result, str(tmp_path), "run") + + saved = json.loads((tmp_path / "RGS" / "run" / "run.json").read_text()) + assert "n_cnot" not in saved + assert saved["n_cnots"] == [graph_to_cnot(graph)] + assert "n_cnots" in result._properties + assert "n_cnot" not in result._properties From 8578383e5048a2e61404a1991a8c8e20d342c2dd Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:56 -0400 Subject: [PATCH 169/227] [fix] Create rgs_analysis's save directory before saving rgs_analysis saves its result to /RGS//.json, but save2json creates no directories and nothing in rgs_analysis made that one. Unless the caller had created that exact directory first, the save raised FileNotFoundError after all the graph metrics had been computed and before the correlations and plots were made. rgs_analysis now creates the directory before saving, as find_best and graph_analyzer already do for theirs. The save location is unchanged. rgs_analysis now saves its JSON and goes on to the correlations and plots where it raised before. A caller who created the directory beforehand sees no change. --- graphiq/data_collection/user_interface.py | 1 + tests/test_data_collection/test_user_interface.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index 799a3b17..aa2a2e2d 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -780,6 +780,7 @@ def rgs_analysis(res_rgsn, dir_path: str, filename: str): for i, g in enumerate(res_rgsn["g"]): res_rgsn["graph_metric"][i][met] = graph_met_value(met, g) full_filename = os.path.join(os.path.join(dir_path, "RGS"), filename) + os.makedirs(full_filename, exist_ok=True) res_rgsn.save2json(full_filename, f"{filename}") correlation_checker(res_rgsn, ["n_cnots"], graph_met_list) plot_figs( diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py index 53a9bb10..9781e2de 100644 --- a/tests/test_data_collection/test_user_interface.py +++ b/tests/test_data_collection/test_user_interface.py @@ -65,3 +65,17 @@ def test_rgs_analysis_saves_n_cnots_without_an_empty_n_cnot_column( assert saved["n_cnots"] == [graph_to_cnot(graph)] assert "n_cnots" in result._properties assert "n_cnot" not in result._properties + + +def test_rgs_analysis_creates_its_save_directory(tmp_path, monkeypatch): + ui = "graphiq.data_collection.user_interface" + monkeypatch.setattr(f"{ui}.correlation_checker", lambda *a, **k: None) + monkeypatch.setattr(f"{ui}.plot_figs", lambda *a, **k: None) + graph = nx.cycle_graph(5) + result = SolverResult([graph_to_circ(graph)], ["g"]) + result["g"] = [graph] + + rgs_analysis(result, str(tmp_path), "run") + + saved = json.loads((tmp_path / "RGS" / "run" / "run.json").read_text()) + assert saved["n_cnots"] == [graph_to_cnot(graph)] From dc75888f1d0c1d0eb56a8c239a3407051322b8c4 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:56 -0400 Subject: [PATCH 170/227] [fix] Make plot_map_based take the result it plots plot_map_based called plot_figs without a result, so every call raised TypeError. It took no arguments and grouped the rows of a module-level placeholder dict, which plot_figs could not read either. plot_map_based now takes a result, groups its row indices by relabel map and passes the result and each group to plot_figs. The groups are numbered in order of first appearance, so the map_{i}_len{k} directory names are the same on every run; the placeholder's set-based grouping ordered them by hash, which for maps holding the (-1, "self") entry changes with PYTHONHASHSEED. The module-level placeholder names are kept. plot_map_based could not run before, so no saved result changes. --- graphiq/data_collection/user_interface.py | 7 +++-- .../test_user_interface.py | 28 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index aa2a2e2d..b8fd618a 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -326,9 +326,12 @@ def corr_with_mean(list1, list2, print_result=True): index_map_dict[tuple(result["map"][i].items())].append(i) -def plot_map_based(): +def plot_map_based(result): + index_map_dict = {} + for i in range(len(result)): + index_map_dict.setdefault(tuple(result["map"][i].items()), []).append(i) for i, index_list in enumerate(index_map_dict.values()): - plot_figs(indices=index_list, dir_name=f"map_{i}_len{len(index_list)}") + plot_figs(result, indices=index_list, dir_name=f"map_{i}_len{len(index_list)}") def plot_graphs(result): diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py index 9781e2de..3484221e 100644 --- a/tests/test_data_collection/test_user_interface.py +++ b/tests/test_data_collection/test_user_interface.py @@ -19,7 +19,12 @@ import graphiq.circuit.ops as ops from graphiq.circuit.circuit_dag import CircuitDAG from graphiq.data_collection.correlation_module import graph_to_circ, graph_to_cnot -from graphiq.data_collection.user_interface import find_best, result_maker, rgs_analysis +from graphiq.data_collection.user_interface import ( + find_best, + plot_map_based, + result_maker, + rgs_analysis, +) from graphiq.metrics import CircuitUnitaryCount from graphiq.solvers.solver_result import SolverResult @@ -37,6 +42,27 @@ def test_result_maker_counts_each_one_qubit_gate_once(): assert result["n_unitary"][0] == CircuitUnitaryCount().evaluate(None, circuit) +def test_plot_map_based_plots_each_relabel_map_group(monkeypatch): + calls = [] + + def record(result, indices=None, dir_name="new", **kwargs): + calls.append((result, indices, dir_name)) + + monkeypatch.setattr("graphiq.data_collection.user_interface.plot_figs", record) + # the shape get_relabel_map returns for an unchanged labelling + identity = {-1: "self", 0: 0, 1: 1} + result = SolverResult([None, None, None], ["map"]) + result["map"] = [identity, {0: 1, 1: 0}, dict(identity)] + + plot_map_based(result) + + assert [(c[1], c[2]) for c in calls] == [ + ([0, 2], "map_0_len2"), + ([1], "map_1_len1"), + ] + assert all(c[0] is result for c in calls) + + def test_find_best_without_dir_path_returns_results_and_writes_nothing( tmp_path, monkeypatch ): From b55651e11afcab8f7b42e9a602e1208bd534c56d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:57 -0400 Subject: [PATCH 171/227] [fix] Read a photon control's own noise leg in photon_survival_rate A two-qubit operation stores its noise as one model per leg, in (control, target) order, and the compilers apply noise[0] to the control and noise[1] to the target. photon_survival_rate always read noise[1], so for a photon that is the control of a two-qubit operation whose legs carry different noise it multiplied in the other qubit's loss rate. For example, a photon-photon CNOT with noise [PhotonLoss(0.5), PhotonLoss(0.1)] gave [0.9, 0.9] instead of [0.5, 0.9]. It now reads noise[0] when the walked photon register is the operation's control, and noise[1] otherwise, as before. Nothing in the package calls photon_survival_rate, so no in-tree result changes. For other callers, the returned rates change only for photons that control a two-qubit operation with different noise on its two legs; a single noise model is stored on both legs, and the solvers' emitter-photon CNOTs have the photon as target. --- graphiq/utils/photon_loss.py | 8 +- tests/test_utils/test_photon_loss.py | 126 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 tests/test_utils/test_photon_loss.py diff --git a/graphiq/utils/photon_loss.py b/graphiq/utils/photon_loss.py index 47792338..4a51ff56 100644 --- a/graphiq/utils/photon_loss.py +++ b/graphiq/utils/photon_loss.py @@ -47,9 +47,13 @@ def photon_survival_rate(circuit): if edge[2] == f"p{reg}" ] node = out_edge[0][1] - op_noise = circuit.dag.nodes[node]["op"].noise + op = circuit.dag.nodes[node]["op"] + op_noise = op.noise if isinstance(op_noise, list): - op_noise = op_noise[1] + # a two-qubit operation keeps one noise model per leg, in + # (control, target) order + on_control = op.q_registers_type[0] == "p" and op.q_registers[0] == reg + op_noise = op_noise[0] if on_control else op_noise[1] if isinstance(op_noise, PhotonLoss): survive[reg] = (1 - op_noise.noise_parameters["loss rate"]) * survive[ diff --git a/tests/test_utils/test_photon_loss.py b/tests/test_utils/test_photon_loss.py new file mode 100644 index 00000000..a738fc84 --- /dev/null +++ b/tests/test_utils/test_photon_loss.py @@ -0,0 +1,126 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +import graphiq.circuit.ops as ops +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.noise.noise_models import NoNoise, PhotonLoss +from graphiq.utils.photon_loss import photon_survival_rate + + +def test_photon_survival_rate_reads_the_control_leg_for_a_photon_control(): + circuit = CircuitDAG(n_emitter=0, n_photon=2) + circuit.add( + ops.CNOT( + control=0, + control_type="p", + target=1, + target_type="p", + noise=[PhotonLoss(0.5), PhotonLoss(0.1)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.5, 0.9]) + + +def test_photon_survival_rate_reads_the_control_leg_of_a_classically_controlled_gate(): + circuit = CircuitDAG(n_emitter=0, n_photon=2, n_classical=1) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, + control_type="p", + target=1, + target_type="p", + c_register=0, + noise=[PhotonLoss(0.5), PhotonLoss(0.1)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.5, 0.9]) + + +def test_photon_survival_rate_photon_control_with_lossless_target(): + circuit = CircuitDAG(n_emitter=0, n_photon=2) + circuit.add( + ops.CZ( + control=0, + control_type="p", + target=1, + target_type="p", + noise=[PhotonLoss(0.3), NoNoise()], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.7, 1]) + + +def test_photon_survival_rate_reads_the_control_leg_when_the_target_is_an_emitter(): + circuit = CircuitDAG(n_emitter=1, n_photon=1) + circuit.add( + ops.CNOT( + control=0, + control_type="p", + target=0, + target_type="e", + noise=[PhotonLoss(0.2), PhotonLoss(0.4)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.8]) + + +def test_photon_survival_rate_reads_the_target_leg_for_a_photon_target(): + circuit = CircuitDAG(n_emitter=1, n_photon=1) + circuit.add( + ops.CNOT( + control=0, + control_type="e", + target=0, + target_type="p", + noise=[PhotonLoss(0.2), PhotonLoss(0.4)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.6]) + + +def test_photon_survival_rate_multiplies_losses_along_the_register(): + circuit = CircuitDAG(n_emitter=1, n_photon=1) + circuit.add( + ops.CNOT( + control=0, + control_type="e", + target=0, + target_type="p", + noise=PhotonLoss(0.25), + ) + ) + circuit.add(ops.Hadamard(register=0, reg_type="p", noise=PhotonLoss(0.5))) + + assert photon_survival_rate(circuit) == pytest.approx([0.375]) + + +def test_photon_survival_rate_counts_each_leg_of_an_unwrapped_wrapper(): + circuit = CircuitDAG(n_emitter=0, n_photon=1) + circuit.add( + ops.OneQubitGateWrapper( + [ops.Hadamard, ops.Phase], + register=0, + reg_type="p", + noise=[PhotonLoss(0.5), PhotonLoss(0.5)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.25]) From e5e6596bea892d8a0ae19f9f7b35c130ac070b9c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:04:57 -0400 Subject: [PATCH 172/227] [fix] Convert a graph state marked mixed to a density matrix QuantumState._graph_to_density asserted that a state marked mixed holds a MixedGraph, but _initialize_graph always builds a plain Graph. So QuantumState(graph, rep_type="g", mixed=True) raised AssertionError on convert_representation("dm"), and Infidelity and TraceDistance against a density-matrix target raised the same error for such a state. The assert is removed, and the mixture arm is taken only when the state is marked mixed and holds a MixedGraph. A plain Graph is a pure state and converts through the pure arm, which gives the same matrix as a one-element mixture. A MixedGraph on a state not marked mixed still raises TypeError, as before. No in-tree result changes: nothing in the package builds a graph state marked mixed. Only this conversion, which used to raise, now returns the graph state's density matrix. --- graphiq/state.py | 5 +++-- tests/test_metrics/test_metrics.py | 8 ++++++++ tests/test_state_representation/test_quantum_state.py | 11 +++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/graphiq/state.py b/graphiq/state.py index 534b5a85..71db7986 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -469,8 +469,9 @@ def _graph_to_density(self, rep): :return: density matrix representation :rtype: DensityMatrix """ - if self.mixed: - assert isinstance(rep, MixedGraph) + # a mixed state may hold a plain Graph (_initialize_graph never builds a + # MixedGraph) + if self.mixed and isinstance(rep, MixedGraph): rho = 0 for p_i, t_i in rep.mixture: rho += p_i * rc.graph_to_density(t_i.data) diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py index 57535ecb..2f2f92af 100644 --- a/tests/test_metrics/test_metrics.py +++ b/tests/test_metrics/test_metrics.py @@ -432,6 +432,14 @@ def test_infidelity_graph_state_marked_mixed_matches_stabilizer_target(): assert infidelity == pytest.approx(0.0, abs=1e-9) +def test_infidelity_and_trace_distance_of_a_graph_state_marked_mixed_against_a_dm_target(): + state = QuantumState(nx.path_graph(3), rep_type="g", mixed=True) + target = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + + assert Infidelity(target).evaluate(state, None) == pytest.approx(0.0, abs=1e-9) + assert TraceDistance(target).evaluate(state, None) == pytest.approx(0.0, abs=1e-9) + + def _empty_plus_triangle_mixture(): # two orthogonal 3-qubit graph states with distinct weights return 0.7 * graph_to_density(nx.empty_graph(3)) + 0.3 * graph_to_density( diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 36ea74fc..46450a07 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -206,6 +206,17 @@ def test_convert_representation_graph_to_density_matrix_pure_arm(): assert np.allclose(state.rep_data.data, rc.graph_to_density(graph)) +def test_convert_representation_graph_marked_mixed_to_density_matrix(): + # a state built from a graph with mixed=True holds a plain Graph, not a MixedGraph + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g", mixed=True) + + state.convert_representation("dm") + + assert state.rep_type == "dm" + assert np.allclose(state.rep_data.data, rc.graph_to_density(graph)) + + def test_convert_representation_graph_to_stabilizer_pure_arm(): graph = nx.path_graph(3) state = QuantumState(graph, rep_type="g") From 12eb241da818fb6af0bbae1cddcae4d1e9ad1ba3 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:16:45 -0400 Subject: [PATCH 173/227] [fix] Let iso_graph_finder accept node labels other than 0..n-1 iso_graph_finder used each node label directly as a row and column index of its n x n adjacency matrix. Graphs with any other labels failed: labels 1..n, labels with gaps, strings and labels below -n raised IndexError, and iso_equal_check failed with them. Negative labels from -n to -1 wrapped silently; when one landed on the same row as another label, some returned graphs lost an edge. Index the matrix by each label's position in sorted order instead. For labels 0..n-1 that position is the label itself, so the output is unchanged there: same graphs, same 0/1 entries, same order. The docstring now says what entry k of the returned list is. Effect on results: none in-tree. Every in-tree caller passes a graph labelled 0..n-1. Calls that raised IndexError now return the isomorphic graphs, labelled 0..n-1 by sorted-label rank. --- graphiq/backends/lc_equivalence_check.py | 8 +- .../test_graph_LC_equivalence.py | 88 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index 38c6d4ec..ff2f06d8 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -701,12 +701,16 @@ def iso_graph_finder(input_graph): :param input_graph: input graph :type input_graph: networkx.Graph - :return: the list of graphs that are isomorphic to G + :return: the list of graphs that are isomorphic to G, one per permutation of its sorted labels: entry k is + G relabelled by the k-th permutation of its sorted labels, with each label then replaced by its rank in + sorted order :rtype: list[networkx.Graph] """ iso_graphs = [] list_nodes = sorted(input_graph) n_nodes = len(list_nodes) + # adjacency-matrix row/column of each label, so labels need not be 0..n-1 + position = {node: i for i, node in enumerate(list_nodes)} all_permu = list(permutations(list_nodes, len(list_nodes))) for each_permu in all_permu: @@ -715,7 +719,7 @@ def iso_graph_finder(input_graph): g_copy = nx.relabel_nodes(input_graph, map_dict, copy=True) for node_id1 in list_nodes: for node_id2 in list(g_copy.neighbors(node_id1)): - adj_matrix[node_id1, node_id2] = 1 + adj_matrix[position[node_id1], position[node_id2]] = 1 g_copy = nx.to_networkx_graph(adj_matrix) iso_graphs.append(g_copy) diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index 617d09a1..bb943840 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import time -from itertools import combinations +from itertools import combinations, permutations import matplotlib.pyplot as plt import pytest @@ -630,3 +630,89 @@ def test_state_converter_circuit_on_disconnected_pair(): two_isolated = nx.empty_graph(2) circuit2 = state_converter_circuit(two_isolated, two_isolated, validate=True) assert circuit2 is not None + + +def _expected_iso_matrices(graph): + """ + Adjacency matrices iso_graph_finder should return for graph, built without calling it: + entry k is graph relabelled by the k-th permutation of its sorted labels, with each + label then replaced by its rank in sorted order. + """ + labels = sorted(graph) + rank = {label: i for i, label in enumerate(labels)} + n = len(labels) + expected = [] + for perm in permutations(labels): + relabel = dict(zip(labels, perm)) + matrix = np.zeros((n, n)) + for u, v in graph.edges: + matrix[rank[relabel[u]], rank[relabel[v]]] = 1 + matrix[rank[relabel[v]], rank[relabel[u]]] = 1 + expected.append(matrix) + return expected + + +@pytest.mark.parametrize( + "edges", + [ + [(1, 2), (2, 3)], # labels 1..n + [(3, 2), (2, 1), (1, 4)], # labels 1..n, inserted out of sorted order + [("c", "b"), ("b", "a"), ("a", "d")], # strings, inserted out of sorted order + [(0, 5), (5, 2), (2, 9)], # gaps, inserted out of sorted order + [ + (-2, -1), + (-1, 0), + (0, 3), + ], # negative labels, -1 and 3 on the same row if unmapped + [(-5, 0), (0, 1)], # a label below -n + ], +) +def test_iso_graph_finder_accepts_labels_other_than_0_to_n_minus_1(edges): + graph = nx.Graph(edges) + result = iso_graph_finder(graph) + expected = _expected_iso_matrices(graph) + assert len(result) == len(expected) + assert all(list(g.nodes) == list(range(len(graph))) for g in result) + assert all( + np.array_equal(nx.to_numpy_array(g), e) for g, e in zip(result, expected) + ) + + +def test_iso_equal_check_accepts_labels_other_than_0_to_n_minus_1(): + k3 = nx.Graph([(1, 2), (2, 3), (1, 3)]) + star = nx.Graph([(1, 2), (1, 3)]) + assert iso_equal_check(k3, star)[0] + + +def _path4_inserted_out_of_order(): + graph = nx.Graph() + graph.add_nodes_from([2, 0, 3, 1]) + graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + return graph + + +def _weighted_path4(): + graph = nx.Graph() + graph.add_edge(0, 1, weight=5.0) + graph.add_edge(1, 2, weight=2.0) + graph.add_edge(2, 3, weight=0.5) + return graph + + +@pytest.mark.parametrize( + "graph", [nx.path_graph(4), _path4_inserted_out_of_order(), _weighted_path4()] +) +def test_iso_graph_finder_output_for_labels_0_to_n_minus_1(graph): + # pins the output, its order and its 0/1 entries for graphs labelled 0..n-1 + spec = [ + nx.to_numpy_array( + nx.relabel_nodes(graph, dict(zip(range(4), perm))), + nodelist=range(4), + weight=None, + ) + for perm in permutations(range(4)) + ] + result = iso_graph_finder(graph) + assert len(result) == 24 + assert all(list(g.nodes) == [0, 1, 2, 3] for g in result) + assert all(np.array_equal(nx.to_numpy_array(g), s) for g, s in zip(result, spec)) From cedf60ec2624bcdff60d3040585c2d0189bacde1 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:16:45 -0400 Subject: [PATCH 174/227] [docs] State that local_comp_graph picks the node by position local_comp_graph uses node_id as a row of nx.to_numpy_array(input_graph), i.e. a position in the graph's node iteration order, and labels the returned graph 0..n-1 by that position. Its docstring called node_id "the index of the node" and did not say that the output is relabelled, which reads as if node_id were a label. Document the positional behaviour, the output labelling and when positions and labels coincide. Add a test that pins the positional behaviour on a graph whose nodes were added out of order. Effect on results: none. Only the docstring and a test change; the code is untouched. --- graphiq/backends/lc_equivalence_check.py | 15 ++++++++++----- .../test_graph_LC_equivalence.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index ff2f06d8..241b58a5 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -729,15 +729,20 @@ def iso_graph_finder(input_graph): def local_comp_graph(input_graph, node_id): """ - Applies a local complementation on the node (specified by node_id) of the input graph input_graph - and returns the resulting graph. + Applies a local complementation on one node of the input graph input_graph and returns the resulting graph. + + The node is chosen by position, not by label: node_id indexes the rows of ``nx.to_numpy_array(input_graph)``, + i.e. the node iteration order of input_graph, and the returned graph is labelled 0..n-1 by that same position. + Positions and labels coincide when input_graph's nodes are 0..n-1 in ascending iteration order, as for a graph + built with ``nx.from_numpy_array`` or returned by this function. :param input_graph: input graph :type input_graph: networkx.Graph - :param node_id: the index of the node to apply tho local complementation on + :param node_id: the position, in input_graph's node iteration order, of the node to apply the local + complementation on :type node_id: int - :raises AssertionError: if the node index node_id is not in the graph - :return: a new transformed graph + :raises AssertionError: if node_id is not a position in the graph (not in 0..n-1) + :return: a new transformed graph, labelled 0..n-1 by position in input_graph's node iteration order :rtype: networkx.Graph """ # TODO: integrate this with the state representation functions diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index bb943840..44f56392 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -716,3 +716,18 @@ def test_iso_graph_finder_output_for_labels_0_to_n_minus_1(graph): assert len(result) == 24 assert all(list(g.nodes) == [0, 1, 2, 3] for g in result) assert all(np.array_equal(nx.to_numpy_array(g), s) for g, s in zip(result, spec)) + + +def test_local_comp_graph_selects_the_node_by_position(): + # the path 0-1-2, with its nodes added as 2, 0, 1 + graph = nx.Graph() + graph.add_nodes_from([2, 0, 1]) + graph.add_edges_from([(0, 1), (1, 2)]) + # position 2 holds label 1, the middle of the path + result = local_comp_graph(graph, 2) + assert list(result.nodes) == [0, 1, 2] + assert sorted(map(sorted, result.edges)) == [[0, 1], [0, 2], [1, 2]] + # position 0 holds label 2, an end of the path: the input relabelled by position + result = local_comp_graph(graph, 0) + assert list(result.nodes) == [0, 1, 2] + assert sorted(map(sorted, result.edges)) == [[0, 2], [1, 2]] From a4fad2963fa986bceebdad3d957d0c03590e85ab Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:16:45 -0400 Subject: [PATCH 175/227] [test] Add regression tests for unfilled hall-of-fame slots The hall of fame starts as (np.inf, None) placeholders, and two earlier fixes made RandomSearchSolver.update_hof and EvolutionarySolver.update_logs handle them. Neither had a test of its own. test_an_inf_scored_candidate_fills_an_unfilled_hall_of_fame_slot calls update_hof with a single candidate scored inf. It ties with the first placeholder and must take that slot instead of reading .dag on the placeholder's None circuit. The new update_logs test fills two of five slots with circuits of depth 7 and 9 and calls update_logs directly. It checks that the hof depth columns describe only those two entries, where reading .depth on a placeholder used to raise AttributeError. Both tests are deterministic and call the method under test directly. Tests only; no result changes. --- .../test_evolutionary_random_solvers.py | 29 +++++++++++++++++++ tests/test_solvers/test_solver_base.py | 20 +++++++++++++ 2 files changed, 49 insertions(+) diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index 73339a7b..ee4917aa 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -699,3 +699,32 @@ def test_saving_the_hall_of_fame_skips_unfilled_slots(tmp_path, save_openqasm): else set() ) assert _saved_files(tmp_path / "pop") == expected_pop + + +def _solver_with_two_filled_hall_of_fame_slots(): + # n_pop = 2 < n_hof = 5: after one iteration, three slots are still (np.inf, None) + _, target = linear_cluster_3qubit_circuit() + solver = EvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=DensityMatrixCompiler(), + n_emitter=1, + n_photon=3, + solver_setting=EvolutionarySolverSetting(n_pop=2, n_hof=5), + ) + shallow, _ = linear_cluster_3qubit_circuit() # depth 7 + deep, _ = linear_cluster_4qubit_circuit() # depth 9 + population = [(0.25, shallow), (0.5, deep)] + solver.update_hof(population) + assert [c is None for _, c in solver.hof] == [False, False, True, True, True] + return solver, population + + +def test_update_logs_leaves_unfilled_hall_of_fame_slots_out_of_depth_statistics(): + solver, population = _solver_with_two_filled_hall_of_fame_slots() + + solver.update_logs(population=population, iteration=0) + + log = solver.logs["hof"][0] + assert (log["depth_mean"], log["depth_variance"]) == (8.0, 1.0) + assert (log["depth_min"], log["depth_max"]) == (7, 9) diff --git a/tests/test_solvers/test_solver_base.py b/tests/test_solvers/test_solver_base.py index 1213cbc2..bfbcda87 100644 --- a/tests/test_solvers/test_solver_base.py +++ b/tests/test_solvers/test_solver_base.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import numpy as np + +from graphiq.benchmarks.circuits import linear_cluster_3qubit_circuit from graphiq.solvers.solver_base import * @@ -80,3 +83,20 @@ def test_tournament_selection_k_one_with_a_single_element_population_copies_that for entry in new_population: assert entry == element assert entry is not element + + +def test_an_inf_scored_candidate_fills_an_unfilled_hall_of_fame_slot(): + # the hall of fame starts as (np.inf, None) placeholders; a candidate scored inf ties + # with the first one and must take its place rather than compare against its None circuit + solver = RandomSearchSolver( + None, None, None, solver_setting=RandomSearchSolverSetting(n_hof=3) + ) + circuit, _ = linear_cluster_3qubit_circuit() + + solver.update_hof([(np.inf, circuit)]) + + score, kept = solver.hof[0] + assert score == np.inf + assert kept is not None and kept is not circuit + assert len(kept.dag.nodes) == len(circuit.dag.nodes) + assert solver.hof[1:] == [(np.inf, None), (np.inf, None)] From 44bac7acda4dfd6136dbab98453acd5d246ec99e Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:16:45 -0400 Subject: [PATCH 176/227] [fix] Leave unfilled hall-of-fame slots out of the hof cost statistics EvolutionarySolver.update_logs computed the hof cost statistics over every hall-of-fame slot, including the (np.inf, None) placeholders of slots no candidate has filled yet. The depth statistics already skip them. So while a placeholder remained, the hof log row had cost_mean = inf, cost_variance = nan (with a numpy RuntimeWarning) and cost_max = inf, and its cost and depth columns described different sets of entries. The cost statistics now skip the same slots, those whose circuit is None. A real candidate scored inf still fills a slot and still counts. No hall-of-fame or population entry, solver result, saved circuit or population log changes, and update_logs draws no random numbers. The hof log changes only in rows logged while a placeholder remains, which happens while fewer than n_hof candidates with a non-NaN score have been seen, e.g. when n_pop < n_hof in the first iterations. In those rows cost_mean, cost_variance and cost_max become the mean, variance and maximum over the filled slots. cost_min, the depth columns, rows logged once the hall of fame is full, and the population log are unchanged. For such runs, benchmark_run writes a different log_hof.csv and log.pdf, and plot_solver_logs now draws the hof cost points it used to drop. The default settings and the in-tree benchmarks and examples have n_pop >= n_hof, so their logs are unchanged (absent NaN scores). --- graphiq/solvers/evolutionary_solver.py | 4 ++- .../test_evolutionary_random_solvers.py | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index 8dba14a9..3cc87317 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -505,7 +505,9 @@ def update_logs(self, population: list, iteration: int): """ # get the scores from the population/hof as a list scores_pop = list(zip(*population))[0] - scores_hof = list(zip(*self.hof))[0] + # skip unfilled hall-of-fame slots here too; a real candidate scored inf + # still fills a slot and counts + scores_hof = [score for (score, circuit) in self.hof if circuit is not None] depth_pop = [circuit.depth for (_, circuit) in population] # self.hof can still contain unfilled (np.inf, None) placeholder diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index ee4917aa..27620d1a 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -728,3 +728,38 @@ def test_update_logs_leaves_unfilled_hall_of_fame_slots_out_of_depth_statistics( log = solver.logs["hof"][0] assert (log["depth_mean"], log["depth_variance"]) == (8.0, 1.0) assert (log["depth_min"], log["depth_max"]) == (7, 9) + + +def test_hall_of_fame_log_cost_statistics_leave_out_unfilled_slots(): + solver, population = _solver_with_two_filled_hall_of_fame_slots() + + solver.update_logs(population=population, iteration=0) + + log = solver.logs["hof"][0] + assert log["cost_mean"] == pytest.approx(0.375) + assert log["cost_variance"] == pytest.approx(0.015625) + assert (log["cost_min"], log["cost_max"]) == (0.25, 0.5) + + +def test_hall_of_fame_log_cost_statistics_keep_an_inf_scored_candidate(): + # a real candidate scored inf fills a slot, unlike an unfilled (np.inf, None) slot + _, target = linear_cluster_3qubit_circuit() + solver = EvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=DensityMatrixCompiler(), + n_emitter=1, + n_photon=3, + solver_setting=EvolutionarySolverSetting(n_pop=2, n_hof=5), + ) + finite, _ = linear_cluster_3qubit_circuit() + infinite, _ = linear_cluster_3qubit_circuit() + population = [(0.25, finite), (np.inf, infinite)] + solver.update_hof(population) + assert [c is None for _, c in solver.hof] == [False, False, True, True, True] + + solver.update_logs(population=population, iteration=0) + + log = solver.logs["hof"][0] + assert log["cost_mean"] == np.inf + assert log["cost_max"] == np.inf From 71ec0f0cb394ac803e8e190c9f4b0ab745d2543d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:16:46 -0400 Subject: [PATCH 177/227] [fix] Tell control from target of classical pair ops when comparing circuit_is_isomorphic labels each DAG edge "c" or "t" so that a pair operation with its control and target swapped does not compare equal. The labelling helper only recognised ControlledPairOperationBase, and ClassicalControlledPairOperationBase is not a subclass of it, so every edge of a ClassicalCNOT, ClassicalCZ or MeasurementCNOTandReset got no label. When control and target have the same register type, nothing else tells them apart, and swapping the roles gave a circuit reported as isomorphic, although the control is measured and the target is acted on. remove_redundant_circuits then dropped one of the two circuits. Give classical pair operations the same "c"/"t" labels as quantum ones. Circuits without a classical pair operation get the same labels as before, and a register relabelling still compares isomorphic. No solver, benchmark or example calls circuit_is_isomorphic; results elsewhere in the package are unchanged. --- graphiq/utils/circuit_comparison.py | 7 ++- tests/test_utils/test_circuit_comparison.py | 57 +++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/graphiq/utils/circuit_comparison.py b/graphiq/utils/circuit_comparison.py index e596c17b..66fadbd0 100644 --- a/graphiq/utils/circuit_comparison.py +++ b/graphiq/utils/circuit_comparison.py @@ -249,7 +249,8 @@ def edge_match(e1, e2): def _create_edge_control_target_attr(operation, reg_type, reg): """ Helper function that return the correct control_target attribute for the edge, base on reg_type, reg, and operation. - If the operation is ControlledPairOperationBase return either 'c' or 't' else return None. + If the operation is a ControlledPairOperationBase or a ClassicalControlledPairOperationBase, return 'c' or 't' + for its control and target registers, else None. :param operation: operation :type operation: OperationBase @@ -260,7 +261,9 @@ def _create_edge_control_target_attr(operation, reg_type, reg): :return: control_target attribute. Can be 'c', 't' or None :rtype: str or nothing """ - if isinstance(operation, ControlledPairOperationBase): + if isinstance( + operation, (ControlledPairOperationBase, ClassicalControlledPairOperationBase) + ): if reg_type == operation.control_type and reg == operation.control: return "c" if reg_type == operation.target_type and reg == operation.target: diff --git a/tests/test_utils/test_circuit_comparison.py b/tests/test_utils/test_circuit_comparison.py index 2ffc271b..2074486c 100644 --- a/tests/test_utils/test_circuit_comparison.py +++ b/tests/test_utils/test_circuit_comparison.py @@ -212,6 +212,54 @@ def test_circuit_is_isomorphic_8(): assert not circuit_is_isomorphic(circuit_1, circuit_2) +def _classical_pair_circuit(op_class, control, target): + # e0 gets a Hadamard first, so e0 and e1 are distinguishable up to relabelling + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=1) + circuit.add(Hadamard(register=0, reg_type="e")) + circuit.add( + op_class( + control=control, + control_type="e", + target=target, + target_type="e", + c_register=0, + ) + ) + return circuit + + +@pytest.mark.parametrize( + "op_class", [ClassicalCNOT, ClassicalCZ, MeasurementCNOTandReset] +) +def test_circuit_is_isomorphic_distinguishes_classical_pair_op_roles(op_class): + # the control is measured and the target is acted on, so swapping them + # gives a different circuit + circuit_1 = _classical_pair_circuit(op_class, control=0, target=1) + circuit_2 = _classical_pair_circuit(op_class, control=1, target=0) + + assert not circuit_is_isomorphic(circuit_1, circuit_2) + + +@pytest.mark.parametrize( + "op_class", [ClassicalCNOT, ClassicalCZ, MeasurementCNOTandReset] +) +def test_circuit_is_isomorphic_classical_pair_op_under_register_relabelling( + op_class, +): + circuit = _classical_pair_circuit(op_class, control=0, target=1) + assert circuit_is_isomorphic( + circuit, _classical_pair_circuit(op_class, control=0, target=1) + ) + + # the same circuit with e0 and e1 exchanged + relabelled = CircuitDAG(n_emitter=2, n_photon=0, n_classical=1) + relabelled.add(Hadamard(register=1, reg_type="e")) + relabelled.add( + op_class(control=1, control_type="e", target=0, target_type="e", c_register=0) + ) + assert circuit_is_isomorphic(circuit, relabelled) + + # Test remove_redundant_circuits() def test_remove_redundant_circuits_1(): circuit_1 = CircuitDAG(n_emitter=2, n_photon=1, n_classical=0) @@ -235,6 +283,15 @@ def test_remove_redundant_circuits_1(): assert len(new_list) == 1 +def test_remove_redundant_circuits_keeps_classical_pair_ops_with_swapped_roles(): + circuit_list = [ + _classical_pair_circuit(ClassicalCNOT, control=0, target=1), + _classical_pair_circuit(ClassicalCNOT, control=1, target=0), + ] + + assert len(remove_redundant_circuits(circuit_list)) == 2 + + # Test outside the pipeline def test_circuit_equivalency_3(): # Same circuit emitter depth, but different in operations From c15101a5bb5040a026262a9941af5f0af6e3b31d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:16:46 -0400 Subject: [PATCH 178/227] [fix] Give bipartite_partial_transpose the right shape for unequal dims bipartite_partial_transpose reshaped the (dim1*dim2) x (dim1*dim2) matrix to [dim1, dim1, dim2, dim2] before permuting its axes. The row and column indices of a bipartite matrix split as [dim1, dim2] each, so the right shape is [dim1, dim2, dim1, dim2], and the permutation the function applies is the partial transpose for that shape. With equal dimensions the two shapes coincide; when the dimensions differ and both are greater than 1, the axes were misread and a wrong matrix of the right shape came back without an error. negativity inherited it: a product state split 2 | 4 scored about 0.51 instead of 0, and a Bell pair split 2 | 4 scored 0 instead of 0.5. Reshape to [dim1, dim2, dim1, dim2]; the permutation is unchanged. Every caller in the package passes equal dimensions (density_to_graph uses negativity on 2 x 2 blocks), and for equal dimensions the result is bitwise identical, so results elsewhere in the package are unchanged. --- graphiq/backends/density_matrix/functions.py | 2 +- .../test_density_matrix_functions.py | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/graphiq/backends/density_matrix/functions.py b/graphiq/backends/density_matrix/functions.py index f5a465e1..c26ba20d 100644 --- a/graphiq/backends/density_matrix/functions.py +++ b/graphiq/backends/density_matrix/functions.py @@ -728,7 +728,7 @@ def bipartite_partial_transpose(rho, dim1, dim2, subsys): ) rho_pt = ( - rho.reshape(np.array([dim1, dim1, dim2, dim2])) + rho.reshape(np.array([dim1, dim2, dim1, dim2])) .transpose(pt_index) .reshape(rho.shape) ) diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py index 4a360bd9..988e4ea9 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py @@ -228,3 +228,66 @@ def test_fidelity_still_raises_above_one(monkeypatch): with pytest.raises(Warning): fidelity(np.diag([0.7, 0.3]), np.diag([0.5, 0.5])) + + +def _elementwise_partial_transpose(rho, dim1, dim2, subsys): + # the definition: with row index (i, j) and column index (k, l), transposing + # subsystem 0 exchanges i and k, and transposing subsystem 1 exchanges j and l + out = np.zeros_like(rho) + for i in range(dim1): + for j in range(dim2): + for k in range(dim1): + for l in range(dim2): + if subsys == 0: + out[k * dim2 + j, i * dim2 + l] = rho[ + i * dim2 + j, k * dim2 + l + ] + else: + out[i * dim2 + l, k * dim2 + j] = rho[ + i * dim2 + j, k * dim2 + l + ] + return out + + +@pytest.mark.parametrize("subsys", [0, 1]) +@pytest.mark.parametrize("dim1, dim2", [(2, 2), (3, 3), (2, 3), (3, 2), (2, 4), (4, 2)]) +def test_bipartite_partial_transpose_matches_the_elementwise_definition( + dim1, dim2, subsys +): + rng = np.random.default_rng(0) + a = rng.normal(size=(dim1 * dim2, dim1 * dim2)) + 1j * rng.normal( + size=(dim1 * dim2, dim1 * dim2) + ) + rho = a @ a.conj().T + rho = rho / np.trace(rho) + + assert np.allclose( + bipartite_partial_transpose(rho, dim1, dim2, subsys), + _elementwise_partial_transpose(rho, dim1, dim2, subsys), + ) + + +def test_bipartite_partial_transpose_of_a_product_transposes_one_factor(): + rng = np.random.default_rng(1) + a = rng.normal(size=(2, 2)) + b = rng.normal(size=(3, 3)) + + assert np.allclose( + bipartite_partial_transpose(np.kron(a, b), 2, 3, 0), np.kron(a.T, b) + ) + assert np.allclose( + bipartite_partial_transpose(np.kron(a, b), 2, 3, 1), np.kron(a, b.T) + ) + + +def test_negativity_with_unequal_dimensions(): + # a Bell pair on qubits 0 and 1, with qubit 2 in |0>, split as qubit 0 | qubits 1, 2 + bell = np.array([1, 0, 0, 1]) / np.sqrt(2) + state = np.kron(bell, np.array([1, 0])) + assert np.isclose(negativity(np.outer(state, state.conj()), 2, 4), 0.5) + + # a product state across the same 2 | 4 split has no negativity + plus = np.array([1, 1]) / np.sqrt(2) + phi = np.array([1, 0, 1j, 0]) / np.sqrt(2) + product = np.kron(np.outer(plus, plus.conj()), np.outer(phi, phi.conj())) + assert np.isclose(negativity(product, 2, 4), 0, atol=1e-12) From 3d13d6cc3ec7218121fcdaa07f7805d3c4716099 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:16:46 -0400 Subject: [PATCH 179/227] [fix] Take the global phase from the largest entry of the unitary check_equivalent_unitaries took the global phase from the first entry np.nonzero found in the second matrix. An exact zero that carries floating-point round-off counts as nonzero, so the phase came out as a ratio against a value like 1e-17, and two unitaries equal up to a phase were reported as not equivalent. The answer could depend on argument order: sigmax() against H S S H built from the package's own gates was rejected, while the reverse order was accepted. Read the phase from the largest-magnitude entry of the second matrix instead. Each column of a unitary has unit norm, so that entry is at least 1/sqrt(n) in magnitude, well above round-off. The two tolerance checks that follow are unchanged. Results in the package are unchanged. Its only caller, find_local_clifford_by_matrix, which TimeReversedSolver and the graph backend reach through simplify_local_clifford, always passes one of the 24 Clifford products as the second argument. For each of them the largest entry is the first nonzero one, so the same entry is read and the same division is done as before. --- graphiq/backends/density_matrix/functions.py | 10 +++-- tests/test_circuits/test_ops.py | 10 +++++ .../test_density_matrix_functions.py | 37 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/graphiq/backends/density_matrix/functions.py b/graphiq/backends/density_matrix/functions.py index c26ba20d..31fe8803 100644 --- a/graphiq/backends/density_matrix/functions.py +++ b/graphiq/backends/density_matrix/functions.py @@ -912,6 +912,8 @@ def check_equivalent_unitaries(unitary_op1, unitary_op2): """ Check if two input matrices are equivalent unitaries up to a global phase + The global phase is estimated from the largest-magnitude entry of unitary_op2. + :param unitary_op1: the first input matrix :type unitary_op1: numpy.ndarray :param unitary_op2: the second input matrix @@ -922,9 +924,11 @@ def check_equivalent_unitaries(unitary_op1, unitary_op2): if not (is_unitary(unitary_op1) and is_unitary(unitary_op2)): return False - nonzero = np.nonzero(unitary_op2) - row = nonzero[0][0] - column = nonzero[1][0] + # Read the phase from the largest entry of unitary_op2: an exact zero may + # carry round-off, and a ratio against a round-off value is not a phase. + # Each column of a unitary has unit norm, so its largest entry has magnitude + # at least 1/sqrt(n), well above round-off. + row, column = np.unravel_index(np.argmax(np.abs(unitary_op2)), unitary_op2.shape) # differ by a global phase global_phase = unitary_op1[row, column] / unitary_op2[row, column] diff --git a/tests/test_circuits/test_ops.py b/tests/test_circuits/test_ops.py index a1df02a4..b0869eef 100644 --- a/tests/test_circuits/test_ops.py +++ b/tests/test_circuits/test_ops.py @@ -12,6 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import itertools + import numpy as np import pytest @@ -157,6 +159,14 @@ def test_get_local_clifford_matrix_by_name(): ) == [ops.Identity, ops.SigmaZ] +def test_find_local_clifford_by_matrix_returns_each_candidate_itself(): + gate_list1, gate_list2 = ops.local_clifford_composition() + for op1, op2 in itertools.product(gate_list1, gate_list2): + matrix1 = ops.local_clifford_to_matrix_map(op1) + matrix2 = ops.local_clifford_to_matrix_map(op2) + assert ops.find_local_clifford_by_matrix(matrix1 @ matrix2) == op1 + op2 + + def test_op(): u1 = dmf.phase() @ dmf.hadamard() u2 = dmf.hadamard() @ dmf.phase() @ dmf.hadamard() @ dmf.phase() @ dmf.sigmax() diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py index 988e4ea9..c332c5b9 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py @@ -291,3 +291,40 @@ def test_negativity_with_unequal_dimensions(): phi = np.array([1, 0, 1j, 0]) / np.sqrt(2) product = np.kron(np.outer(plus, plus.conj()), np.outer(phi, phi.conj())) assert np.isclose(negativity(product, 2, 4), 0, atol=1e-12) + + +def test_check_equivalent_unitaries_ignores_round_off_at_exact_zeros(): + x = sigmax().astype(complex) + + noisy_x = x.copy() + noisy_x[0, 0] = 1e-17 + + noisy_ix = (1j * x).copy() + noisy_ix[0, 0] = 1e-17 + + exact_ix = 1j * x + + assert is_unitary(noisy_x) + assert is_unitary(noisy_ix) + assert is_unitary(exact_ix) + + # both operands carry the same round-off at the exact-zero entry + assert check_equivalent_unitaries(noisy_ix, noisy_x) + # only the second operand carries it + assert check_equivalent_unitaries(exact_ix, noisy_x) + + # pairs that differ must still be rejected + noisy_identity = np.eye(2, dtype=complex) + noisy_identity[0, 1] = 1e-17 + assert not check_equivalent_unitaries( + np.diag([1, -1]).astype(complex), noisy_identity + ) + assert not check_equivalent_unitaries(x, np.eye(2, dtype=complex)) + + +def test_check_equivalent_unitaries_of_a_gate_built_x(): + # H S S H is X up to round-off, which sits at its exact-zero diagonal + x_from_gates = hadamard() @ phase() @ phase() @ hadamard() + + assert check_equivalent_unitaries(sigmax(), x_from_gates) + assert check_equivalent_unitaries(1j * sigmax(), x_from_gates) From 4023de3f7790e0af68b4525fe7dd511cbd204b75 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:16:46 -0400 Subject: [PATCH 180/227] [fix] Let circuit.parameters set ops that are missing from the map The parameters setter looked each op up with self.map[id(op)]. The map is built when the circuit is constructed, before any op exists, and rebuilt only by initialize_parameters, so the setter raised KeyError for a circuit never initialised, for ops added after initialisation, for a copy (new objects, old ids) and for any circuit containing a OneQubitGateWrapper, whose sub-gates are new objects on every unwrap. GradientDescentSolver.solve(initial_params=...) on such a circuit failed at its first step. Rebuild the map from fmap, as initialize_parameters does, when some op is missing from it, and look keys up with .get, so a wrapper's sub-gates, which carry no parameters, get the empty tuple they already have. When every op is in the map it is used as it is, so a call that worked before assigns exactly the same values, including when fmap was changed after initialisation. A circuit with a wrapper rebuilds the map on every call. A copy given the original's id-keyed dict now leaves its parameterised ops empty, as the setter already does for any missing key, and fails when compiled instead of at the setter. Results elsewhere in the package are unchanged: the example notebook and the variational benchmark circuit initialise their parameters and have no wrapper. --- graphiq/circuit/circuit_base.py | 13 ++- tests/test_circuits/test_dag_circuit.py | 102 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/graphiq/circuit/circuit_base.py b/graphiq/circuit/circuit_base.py index 05840dc4..dc49354d 100644 --- a/graphiq/circuit/circuit_base.py +++ b/graphiq/circuit/circuit_base.py @@ -423,8 +423,17 @@ def parameters(self): def parameters(self, parameters: dict): self._parameters = parameters - for op in self.sequence(unwrapped=True): - op.params = self._parameters.get(self.map[id(op)], tuple()) + sequence = self.sequence(unwrapped=True) + # the map is built when parameters are initialised; ops added (or copied) + # since then are not in it, so rebuild it the way initialize_parameters does + # (a OneQubitGateWrapper's sub-gates are new objects on every unwrap, so a + # circuit with a wrapper rebuilds the map on every call) + if any(id(op) not in self._map for op in sequence): + self._map = self._fmap() + for op in sequence: + # a wrapper's sub-gates are new objects on every unwrap and are never in + # the map; they carry no parameters + op.params = self._parameters.get(self._map.get(id(op)), tuple()) @property def fmap(self): diff --git a/tests/test_circuits/test_dag_circuit.py b/tests/test_circuits/test_dag_circuit.py index edae5ca7..ce765b35 100644 --- a/tests/test_circuits/test_dag_circuit.py +++ b/tests/test_circuits/test_dag_circuit.py @@ -730,3 +730,105 @@ def test_initialize_parameters_of_the_variational_layer_is_unchanged(): [0.8760444114976647, -2.984445443853267, -1.4135324813693264], [-1.7391182232484002, 1.48579245840624, 1.1102356231618007], ] + + +def _rotation(register=0): + return ops.ParameterizedOneQubitRotation( + register=register, reg_type="e", params=(0.0, 1.0, 1.0) + ) + + +def _shared_weight_fmap(circuit): + return lambda: { + id(op): op.__class__.__name__ for op in circuit.sequence(unwrapped=True) + } + + +def test_parameters_setter_accepts_ops_added_after_construction(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + rotation = _rotation() + circuit.add(rotation) + + circuit.parameters = {id(rotation): [0.1, 0.2, 0.3]} + + assert rotation.params == [0.1, 0.2, 0.3] + + +def test_parameters_setter_accepts_ops_added_after_initialize_parameters(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + rotation1 = _rotation() + circuit.add(rotation1) + circuit.initialize_parameters(seed=0) + rotation2 = _rotation() + circuit.add(rotation2) + + circuit.parameters = { + id(rotation1): [0.1, 0.2, 0.3], + id(rotation2): [0.4, 0.5, 0.6], + } + + assert rotation1.params == [0.1, 0.2, 0.3] + assert rotation2.params == [0.4, 0.5, 0.6] + + +def test_parameters_setter_with_a_one_qubit_gate_wrapper(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + rotation = _rotation() + circuit.add(rotation) + circuit.add( + ops.OneQubitGateWrapper([ops.Hadamard, ops.Phase], register=0, reg_type="e") + ) + circuit.initialize_parameters(seed=0) + + circuit.parameters = {id(rotation): [0.1, 0.2, 0.3]} + + assert rotation.params == [0.1, 0.2, 0.3] + + +def test_parameters_setter_with_a_shared_weight_fmap(): + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + rotation0 = _rotation(0) + rotation1 = _rotation(1) + circuit.add(rotation0) + circuit.add(rotation1) + circuit.fmap = _shared_weight_fmap(circuit) + + circuit.parameters = {"ParameterizedOneQubitRotation": [0.1, 0.2, 0.3]} + + assert rotation0.params == [0.1, 0.2, 0.3] + assert rotation1.params == [0.1, 0.2, 0.3] + + +def test_parameters_setter_keeps_the_map_built_by_initialize_parameters(): + # when every op is already in the map, the setter uses it as it is, even + # if fmap was changed after the parameters were initialised + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + rotation0 = _rotation(0) + rotation1 = _rotation(1) + circuit.add(rotation0) + circuit.add(rotation1) + circuit.initialize_parameters(seed=0) + circuit.fmap = _shared_weight_fmap(circuit) + + circuit.parameters = { + id(rotation0): [0.1, 0.2, 0.3], + id(rotation1): [0.4, 0.5, 0.6], + } + + assert rotation0.params == [0.1, 0.2, 0.3] + assert rotation1.params == [0.4, 0.5, 0.6] + + +def test_parameters_setter_after_initialize_parameters_assigns_given_values(): + from graphiq.benchmarks.circuits import variational_entangling_layer_2qubit + + circuit, _ = variational_entangling_layer_2qubit() + initial = circuit.initialize_parameters(seed=42) + shifted = {key: [value + 1 for value in values] for key, values in initial.items()} + + circuit.parameters = shifted + + parameterized = [op for op in circuit.sequence(unwrapped=True) if op.params] + assert parameterized + for op in parameterized: + assert list(op.params) == shifted[circuit.map[id(op)]] From 95cbc14204f32108892577c35df9ce3fd2fee182 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:57:18 -0400 Subject: [PATCH 181/227] [fix] Convert a mixed graph state holding a MixedGraph to a stabilizer QuantumState._graph_to_stabilizer passed rep.data straight to graph_to_stabilizer. For a MixedGraph that is a list of (probability, graphiq Graph) pairs, and graph_to_stabilizer accepts only networkx graphs or adjacency matrices, so the conversion raised TypeError. A state marked mixed gets a MixedGraph whenever it is converted from a stabilizer or density matrix to a graph, so s -> g -> s and dm -> g -> s both raised under mixed=True. When the state is marked mixed and holds a MixedGraph, the mixture is now unpacked into (probability, networkx graph) pairs first, as _graph_to_density already does. A plain Graph, and every state not marked mixed, converts as before; a MixedGraph on a state not marked mixed still raises TypeError. No in-tree result changes: nothing in the package converts a state to a graph and then to a stabilizer. Only these conversions, which used to raise, now return a MixedStabilizer with the mixture's probabilities and one stabilizer tableau per graph. --- graphiq/state.py | 10 +++- .../test_quantum_state.py | 58 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/graphiq/state.py b/graphiq/state.py index 71db7986..b038cfb5 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -488,7 +488,15 @@ def _graph_to_stabilizer(self, rep): :return: stabilizer representation :rtype: Stabilizer or MixedStabilizer """ - new_data = rc.graph_to_stabilizer(rep.data) + # a mixed state may hold a plain Graph (_initialize_graph never builds a + # MixedGraph); a MixedGraph's mixture holds graphiq Graphs, and + # rc.graph_to_stabilizer takes networkx graphs + if self.mixed and isinstance(rep, MixedGraph): + new_data = rc.graph_to_stabilizer( + [(p_i, g_i.data) for p_i, g_i in rep.mixture] + ) + else: + new_data = rc.graph_to_stabilizer(rep.data) # new_data is of type list[(float, StabilizerTableau)] if self.mixed: new_tab_list = [] diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 46450a07..4819afc1 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -25,6 +25,7 @@ from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau from graphiq.backends.stabilizer.functions.clifford import create_n_ket0_state from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.state import MixedStabilizer from graphiq.state import DENSITY_MATRIX_QUBIT_THRESH, QuantumState @@ -227,6 +228,63 @@ def test_convert_representation_graph_to_stabilizer_pure_arm(): assert state.rep_data.data.to_stabilizer() == expected +def _graph_tableau(graph): + return CliffordTableau(rc.graph_to_stabilizer(graph)[0][1]) + + +def test_convert_representation_mixed_stabilizer_to_graph_and_back(): + path3 = nx.path_graph(3) + star3 = nx.star_graph(2) + mixture = [(0.7, _graph_tableau(path3)), (0.3, _graph_tableau(star3))] + state = QuantumState(mixture, rep_type="s", mixed=True) + + state.convert_representation("g") + state.convert_representation("s") + + mixed_stabilizer = state.rep_data + assert isinstance(mixed_stabilizer, MixedStabilizer) + assert [p_i for p_i, _ in mixed_stabilizer.mixture] == [0.7, 0.3] + assert mixed_stabilizer.mixture[0][1] == _graph_tableau(path3) + assert mixed_stabilizer.mixture[1][1] == _graph_tableau(star3) + # the two tableaux differ, so the comparisons above can fail + assert not mixed_stabilizer.mixture[0][1] == _graph_tableau(star3) + + +def test_convert_representation_density_matrix_marked_mixed_to_graph_to_stabilizer(): + path3 = nx.path_graph(3) + state = QuantumState(rc.graph_to_density(path3), rep_type="dm", mixed=True) + + state.convert_representation("g") + state.convert_representation("s") + + mixed_stabilizer = state.rep_data + assert isinstance(mixed_stabilizer, MixedStabilizer) + assert len(mixed_stabilizer.mixture) == 1 + assert np.isclose(mixed_stabilizer.mixture[0][0], 1.0) + assert mixed_stabilizer.mixture[0][1] == _graph_tableau(path3) + + +def test_convert_representation_plain_graph_marked_mixed_to_stabilizer(): + # a state built from a graph with mixed=True holds a plain Graph, not a MixedGraph + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g", mixed=True) + + state.convert_representation("s") + + mixed_stabilizer = state.rep_data + assert isinstance(mixed_stabilizer, MixedStabilizer) + assert [p_i for p_i, _ in mixed_stabilizer.mixture] == [1.0] + assert mixed_stabilizer.mixture[0][1] == _graph_tableau(graph) + + +def test_graph_to_stabilizer_rejects_a_mixed_graph_on_a_state_not_marked_mixed(): + # This calls the private helper directly, since the constructor rejects a MixedGraph. + qs = QuantumState(nx.path_graph(3), rep_type="g") + + with pytest.raises(TypeError): + qs._graph_to_stabilizer(MixedGraph(Graph(nx.path_graph(3)))) + + @pytest.mark.parametrize("rep_type", ["g", None]) def test_partial_trace_on_a_graph_state_says_it_is_not_implemented(rep_type): # rep_type=None autodetects the graph representation; it is refused like an From b621f5b1d9197ff27df4b4a29f0f458e8c3734d5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:57:19 -0400 Subject: [PATCH 182/227] [fix] Create LC_scaling_known's save directory before saving LC_scaling_known writes bwCNOT.csv and bwDepth.csv into /, but nothing in it created that directory, and pandas' to_csv creates none. LC_scaling_test and iso_scaling_test get theirs from graph_analyzer; LC_scaling_known calls nothing that makes it. Unless the caller had created the directory first, it raised OSError after all its statistics were computed, and the statistics were lost. LC_scaling_known now creates the directory just before saving, as rgs_analysis does. The save location and file contents are unchanged. No in-tree code calls LC_scaling_known. The new call draws no random numbers and comes after the sampling, so the returned statistics and the files match a run where the directory already existed. A caller who created it beforehand sees no change. --- graphiq/data_collection/user_interface.py | 1 + .../test_user_interface.py | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index b8fd618a..42f46345 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -741,6 +741,7 @@ def LC_scaling_known(result, dir_path: str, graph_class: str, n_lc_list=None): df_cnot = pd.DataFrame((c_means, c_stds)) df_depth = pd.DataFrame((d_means, d_stds)) + os.makedirs(os.path.join(dir_path, graph_class), exist_ok=True) output_path1 = os.path.join(os.path.join(dir_path, graph_class), "bwCNOT.csv") df_cnot.to_csv(output_path1, index=False, header=False) output_path2 = os.path.join(os.path.join(dir_path, graph_class), "bwDepth.csv") diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py index 3484221e..8421d597 100644 --- a/tests/test_data_collection/test_user_interface.py +++ b/tests/test_data_collection/test_user_interface.py @@ -15,11 +15,15 @@ import json import networkx as nx +import numpy as np +import pandas as pd import graphiq.circuit.ops as ops +from graphiq.benchmarks.circuits import ghz3_state_circuit from graphiq.circuit.circuit_dag import CircuitDAG from graphiq.data_collection.correlation_module import graph_to_circ, graph_to_cnot from graphiq.data_collection.user_interface import ( + LC_scaling_known, find_best, plot_map_based, result_maker, @@ -105,3 +109,23 @@ def test_rgs_analysis_creates_its_save_directory(tmp_path, monkeypatch): saved = json.loads((tmp_path / "RGS" / "run" / "run.json").read_text()) assert saved["n_cnots"] == [graph_to_cnot(graph)] + + +def test_lc_scaling_known_creates_its_save_directory(tmp_path): + circuit, _ = ghz3_state_circuit() + result = SolverResult([circuit] * 3, ["n_cnots", "max_emit_eff_depth"]) + result["n_cnots"] = [1, 2, 4] + result["max_emit_eff_depth"] = [3, 5, 6] + np.random.seed(0) + + c_means, c_stds, d_means, d_stds = LC_scaling_known( + result, str(tmp_path), "cls", n_lc_list=[1, 2] + ) + + # the files hold the returned statistics, whatever the random draws were + assert (tmp_path / "cls" / "bwCNOT.csv").read_text() == pd.DataFrame( + (c_means, c_stds) + ).to_csv(index=False, header=False) + assert (tmp_path / "cls" / "bwDepth.csv").read_text() == pd.DataFrame( + (d_means, d_stds) + ).to_csv(index=False, header=False) From b3dae04030632352eceb0e5e4de89f3b5392faa9 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:57:19 -0400 Subject: [PATCH 183/227] [fix] Draw an indexed measure or reset on its one qubit in Painter Painter.load_openqasm_str chose between one qubit and the whole register with hasattr(node, "index"). The node is the dict that OpenQASMParser returns, and a dict has no index attribute, so the test was always false: "reset q[1];" was drawn as a reset of every qubit of q, and "measure q[2] -> c[0];" as a measurement of every qubit of q, each into the matching bit of c. The tests now look for the keys the parser writes: "index" in node for a reset, "index" in node["qreg"] for a measure. The indexed branches themselves are unchanged, and whole-register instructions still expand to every qubit. No in-tree drawing changes. graphiq's own openQASM export measures and resets index 0 of size-1 registers, where both branches give the same op, so the tutorial notebook that loads the ghz3 export draws the same ops as before. Only an indexed measure or reset in a multi-qubit register, from openQASM written elsewhere, is drawn differently. --- graphiq/utils/draw.py | 4 ++-- tests/test_utils/test_draw.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/graphiq/utils/draw.py b/graphiq/utils/draw.py index 9d0732d8..f47b9c0d 100644 --- a/graphiq/utils/draw.py +++ b/graphiq/utils/draw.py @@ -526,7 +526,7 @@ def load_openqasm_str(self, openqasm_str): self.add_gate(gate_name="CX", qargs=[qargs], controls=[controls]) if node["type"] == "measure": creg_size = parser.get_register_size(node["creg"]["name"], "creg") - if hasattr(node, "index"): + if "index" in node["qreg"]: qreg = self.to_reg_label( node["qreg"]["name"], node["qreg"]["index"] ) @@ -542,7 +542,7 @@ def load_openqasm_str(self, openqasm_str): creg = self.to_reg_label(node["creg"]["name"], creg_size) self.add_measurement(qreg=qreg, creg=creg, cbit=i) if node["type"] == "reset": - if hasattr(node, "index"): + if "index" in node: qreg = self.to_reg_label(node["name"], node["index"]) self.add_reset(qreg) else: diff --git a/tests/test_utils/test_draw.py b/tests/test_utils/test_draw.py index d390a23c..4a116114 100644 --- a/tests/test_utils/test_draw.py +++ b/tests/test_utils/test_draw.py @@ -390,3 +390,23 @@ def test_load_openqasm_1(): {"type": "measure", "col": 3, "qreg": "p[2]", "creg": "c[4]", "cbit": 2}, {"type": "measure", "col": 4, "qreg": "p[3]", "creg": "c[4]", "cbit": 3}, ] + + +def test_load_openqasm_draws_an_indexed_measure_and_reset_on_one_qubit(): + openqasm_str = """ + OPENQASM 2.0; + + qreg q[3]; + creg c[3]; + + reset q[1]; + measure q[2] -> c[0]; + """ + + painter = Painter() + painter.load_openqasm_str(openqasm_str) + + assert painter.ops == [ + {"type": "reset", "col": 0, "qreg": "q[1]"}, + {"type": "measure", "col": 0, "qreg": "q[2]", "creg": "c[3]", "cbit": 0}, + ] From 782610a5dd534b6d46ba36ebc90b216123235293 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 02:57:19 -0400 Subject: [PATCH 184/227] [docs] Say which LC helpers read node ids as positions local_comp_graph picks its node by position in the graph's iteration order and returns a graph labelled 0..n-1 by position, but three callers did not say what that means for them. rgs_orbit_finder passes node labels to it as positions, so its graph must be labelled 0..n-1 in iteration order; every graph after the first comes back labelled by position. linear_partial_orbit reads no labels at all: it applies fixed position sequences that walk the path only if the path runs through the nodes in iteration order, and its graphs are labelled by position, so the first is literally the input only when the labels are already 0..n-1 in that order. The lc_nodes of run_multiple_graphs_and_report are positions, which are the labels in every example in its module. Docstrings only; no behaviour or result changes. --- graphiq/benchmarks/lc_equivalence.py | 6 +++++- graphiq/utils/relabel_module.py | 15 +++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/graphiq/benchmarks/lc_equivalence.py b/graphiq/benchmarks/lc_equivalence.py index 4ccf6d7b..e3035ccd 100644 --- a/graphiq/benchmarks/lc_equivalence.py +++ b/graphiq/benchmarks/lc_equivalence.py @@ -56,13 +56,17 @@ def run_multiple_graphs_and_report(graph, lc_nodes): complementation at that node, and print an alternate-circuit search report for each, so ``1 + len(lc_nodes)`` searches and reports in all. + Each local complementation returns a graph labelled 0..n-1 by position. + Each local complementation is computed from the original graph, not cumulatively, so lc_nodes describes a fan out from the one graph rather than a chain. Every graph drawn is the graph its accompanying report describes. :param graph: the graph to draw and search :type graph: networkX.Graph - :param lc_nodes: nodes to apply a local complementation to, one at a time + :param lc_nodes: the nodes to apply a local complementation to, one at a time, given as positions in graph's + node iteration order (local_comp_graph reads them so); these are the node labels when graph's nodes are + 0..n-1 in iteration order, as in every example in this module :type lc_nodes: list :return: nothing :rtype: None diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 07ca9973..5d85fd32 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -440,9 +440,11 @@ def rgs_orbit_finder(graph: nx.Graph): """ Takes a repeater graph state, and returns the full list of distinct graphs in the orbit. The first graph in the list is the original graph state. - :param graph: original graph + :param graph: original graph; its nodes must be labelled 0..n-1 in iteration order, since node labels are + also used as adjacency-matrix indices by local_comp_graph :type graph: nx.Graph - :return: a full list of graphs in the LC orbit of the graph state + :return: a full list of graphs in the LC orbit of the graph state, each after the first labelled 0..n-1 by + position :rtype: list """ n = len(graph) @@ -473,9 +475,14 @@ def linear_partial_orbit(graph: nx.Graph): """ Takes a linear cluster state, and returns a list of distinct graphs in the orbit. Not exhaustive. The first graph in the list is the original graph state. - :param graph: original graph + :param graph: original graph, a path that runs through its nodes in iteration order (the first node in + iteration order is one end, the next is its neighbour, and so on); node labels are never read. On a path + in another order the graphs are still in its LC orbit, but the first need not be the original and they + need not be distinct :type graph: nx.Graph - :return: a list of graphs in the LC orbit of the linear cluster state + :return: a list of graphs in the LC orbit of the linear cluster state, labelled 0..n-1 by position in graph's + iteration order, so the first is literally the original graph only when graph's labels are already 0..n-1 + in iteration order :rtype: list """ orbit_list = [] From 9dfd7f37e9630497f126857717665d333ce2e904 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 03:26:29 -0400 Subject: [PATCH 185/227] [docs] Make the simulate_circuit notebook run on the current API examples/startup_guide/simulate_circuit.ipynb stopped at its first partial trace: DensityMatrixCompiler.compile returns a QuantumState, which has no .data, so cell 7 raised AttributeError and the next three cells failed on the missing state_a. The solver cell then passed a raw density matrix to Infidelity, which needs a QuantumState target, and raised on target.rep_type. Read the array as state.rep_data.data, wrap the solver's target in QuantumState(..., rep_type="dm") and fix the comment that named the wrong accessor. Draw the metric log on a new figure, so that under a non-interactive backend it does not land on the 3D axes left by the density matrix bar plot. Clear the stored outputs. They came from an old run: they held absolute local paths and results from settings the solver no longer reads, and a regenerated copy would again embed a local path, a timestamp and an unseeded solver trace. No package code changes. --- examples/startup_guide/simulate_circuit.ipynb | 175 +++--------------- 1 file changed, 22 insertions(+), 153 deletions(-) diff --git a/examples/startup_guide/simulate_circuit.ipynb b/examples/startup_guide/simulate_circuit.ipynb index 91ac73a3..da4c360b 100644 --- a/examples/startup_guide/simulate_circuit.ipynb +++ b/examples/startup_guide/simulate_circuit.ipynb @@ -9,7 +9,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -37,7 +37,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -67,30 +67,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "(
, )" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# plot the circuit as a directed, acyclic graph\n", "circuit.draw_dag()" @@ -105,7 +84,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -114,37 +93,24 @@ "compiler = DensityMatrixCompiler()\n", "state = compiler.compile(\n", " circuit\n", - ") # this returns a DensityMatrix object, and you can get underlying complex array as state.data" + ") # this returns a QuantumState object, and you can get underlying complex array as state.rep_data.data" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# trace out the ancilla qubit\n", - "state_a = partial_trace(state.data, keep=(0, 1, 2, 3), dims=5 * [2])" + "state_a = partial_trace(state.rep_data.data, keep=(0, 1, 2, 3), dims=5 * [2])" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "fig, ax = density_matrix_bars(state_a)\n", "fig.suptitle(\"Simulated circuit density matrix\")\n", @@ -153,17 +119,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Fidelity with ideal state: 1.0000000125303306\n" - ] - } - ], + "outputs": [], "source": [ "# compare to the ideal density matrix\n", "ideal_state = ket2dm(\n", @@ -175,17 +133,9 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "30/06/2022, 10:07:25 | Saved to C:\\Users\\julie\\Documents\\_graduate\\git_repositories\\graph-compiler\\data\\2022-06-30 example-folder - 69eF\\partial_density_matrix.np successfully.\n" - ] - } - ], + "outputs": [], "source": [ "# save the resulting quantum state\n", "from graphiq.io import IO\n", @@ -203,106 +153,24 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from graphiq import metrics\n", + "from graphiq.state import QuantumState\n", "from graphiq.solvers.evolutionary_solver import EvolutionarySolver, EvolutionarySolverSetting" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Iteration 0 | Best score: 0.9844\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "C:\\Users\\julie\\Documents\\_graduate\\git_repositories\\graph-compiler\\src\\solvers\\solver_base.py:33: UserWarning: Initial circuit for EvolutionarySolver is 'None'. \n", - " warnings.warn(f\"Initial circuit for {self.__class__.__name__} is 'None'. \")\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Iteration 1 | Best score: 0.9844\n", - "Iteration 2 | Best score: 0.9844\n", - "Iteration 3 | Best score: 0.9375\n", - "Iteration 4 | Best score: 0.9375\n", - "Iteration 5 | Best score: 0.9375\n", - "Iteration 6 | Best score: 0.9375\n", - "Iteration 7 | Best score: 0.9375\n", - "Iteration 8 | Best score: 0.9375\n", - "Iteration 9 | Best score: 0.9375\n", - "Iteration 10 | Best score: 0.9375\n", - "Iteration 11 | Best score: 0.9375\n", - "Iteration 12 | Best score: 0.9375\n", - "Iteration 13 | Best score: 0.9375\n", - "Iteration 14 | Best score: 0.7500\n", - "Iteration 15 | Best score: 0.7500\n", - "Iteration 16 | Best score: 0.7500\n", - "Iteration 17 | Best score: 0.7500\n", - "Iteration 18 | Best score: 0.7500\n", - "Iteration 19 | Best score: 0.0000\n", - "Iteration 20 | Best score: 0.0000\n", - "Iteration 21 | Best score: 0.0000\n", - "Iteration 22 | Best score: 0.0000\n", - "Iteration 23 | Best score: 0.0000\n", - "Iteration 24 | Best score: 0.0000\n", - "Iteration 25 | Best score: 0.0000\n", - "Iteration 26 | Best score: 0.0000\n", - "Iteration 27 | Best score: 0.0000\n", - "Iteration 28 | Best score: 0.0000\n", - "Iteration 29 | Best score: 0.0000\n", - "Iteration 30 | Best score: 0.0000\n", - "Iteration 31 | Best score: 0.0000\n", - "Iteration 32 | Best score: 0.0000\n", - "Iteration 33 | Best score: 0.0000\n", - "Iteration 34 | Best score: 0.0000\n", - "Iteration 35 | Best score: 0.0000\n", - "Iteration 36 | Best score: 0.0000\n", - "Iteration 37 | Best score: 0.0000\n", - "Iteration 38 | Best score: 0.0000\n", - "Iteration 39 | Best score: 0.0000\n", - "Iteration 40 | Best score: 0.0000\n", - "Iteration 41 | Best score: 0.0000\n", - "Iteration 42 | Best score: 0.0000\n", - "Iteration 43 | Best score: 0.0000\n", - "Iteration 44 | Best score: 0.0000\n", - "Iteration 45 | Best score: 0.0000\n", - "Iteration 46 | Best score: 0.0000\n", - "Iteration 47 | Best score: 0.0000\n", - "Iteration 48 | Best score: 0.0000\n", - "Iteration 49 | Best score: 0.0000\n", - "[0.015625, 0.015625, 0.015625, 0.015625, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.0, 0.015624999999999993, 0.0, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015624999999999993, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.0039062499999999965, 0.00390625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015625, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015625, 0.00390625, 0.0, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.0, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015625, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015625, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015625, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.015624999999999993, 0.015625, 0.0039062499999999965, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999965, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015625, 0.00390625, 0.015625, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.00390625, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999965, 0.015625, 0.0039062499999999965, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.00390625, 0.0, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.00390625, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.00390625, 0.0, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.2499999999999999, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0, 0.0, 0.0039062499999999983, 0.00390625, 0.0, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0, 0.0, 0.015625, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.2499999999999999, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.0, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.9999999999999996, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.9999999999999996, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.9999999999999996, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.0, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0, 0.0039062499999999965, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.00390625, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.0039062499999999965, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.00390625, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.00390625, 0.0, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0, 0.0039062499999999965, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0, 0.0039062499999999965, 0.06249999999999997, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.00390625, 0.06249999999999997, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.06249999999999997, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.2499999999999999, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.00390625, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.2499999999999999, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.2499999999999999, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.2499999999999999, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.015624999999999993, 0.00390625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.2499999999999999, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0, 0.015624999999999993, 0.2499999999999999, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.2499999999999999, 0.2499999999999999, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.00390625, 0.06249999999999997, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.2499999999999999, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.00390625, 0.06249999999999997, 0.0, 0.015625, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.2499999999999999, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015625, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.2499999999999999, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.2499999999999999, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.2499999999999999, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.01562499999999999, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.9999999999999996, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0, 0.015624999999999993, 0.2499999999999999, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.01562499999999999, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.0, 0.015625, 0.0, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.2499999999999999, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.01562499999999999, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.06249999999999997, 0.015625, 0.0, 0.0039062499999999983, 0.0, 0.2499999999999999, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.2499999999999999, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.01562499999999999, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.2499999999999999, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.0, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.01562499999999999, 0.015624999999999993, 0.00390625, 0.015625, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.2499999999999999, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0039062499999999965, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.2499999999999999, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.00390625, 0.06249999999999997, 0.06249999999999997, 0.0, 0.015624999999999993, 0.0, 0.2499999999999999, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0, 0.2499999999999999, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.0, 0.00390625, 0.00390625, 0.0, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.0, 0.00390625, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.00390625, 0.0, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.0, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.00390625, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.00390625, 0.0, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0, 0.00390625, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.2499999999999999]\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ - "target = ket2dm(\n", - " (tensor(4 * [state_ketz0()]) + tensor(4 * [state_ketz1()])) / np.sqrt(2)\n", + "target = QuantumState(\n", + " ket2dm((tensor(4 * [state_ketz0()]) + tensor(4 * [state_ketz1()])) / np.sqrt(2)),\n", + " rep_type=\"dm\",\n", ")\n", "\n", "# we pass one metric to use as the cost function. we can also pass more to be evaluated but not used as the cost\n", @@ -325,6 +193,7 @@ "# we now have access to the metrics (don't need to pass anything back, as we are logging it in the Metric instance)\n", "print(metric.log)\n", "\n", + "plt.figure()\n", "plt.plot(metric.log)\n", "plt.show()" ] From 857bd03e439c9fdebd2188922cc48ac06bb3e36d Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 03:26:30 -0400 Subject: [PATCH 186/227] [test] Assert what twelve tests computed and discarded Twelve tests ran code and threw the result away, so they passed whatever that code returned. Each now asserts the behaviour its name and body already exercise: - test_op: check_equivalent_unitaries on its two phase-equal pairs; - test_density_matrix_get_single_qubit_gate: the embedded gate matrices; - test_graph_lc: the local Cliffords find_lc returns for nodes 1 and 3; - test_qubit_insertion, test_qubit_removal and test_qubit_insertion_removal: the resulting tableaux; - test_state1: the labels and the phase after the Hadamard; - test_compile_circuit and test_compile_circuit2: the stabilizer compile against the density-matrix compile, for both measurement outcomes; - test_stabilizer_fidelity2: the two backends agree on each outcome (infidelity 0.75 for outcome 0, 1.0 for outcome 1); - test_mixed_state_measurement: a mixed stabilizer measured in Z collapses the measured qubit, which the old body, at emitter depth 2, could not tell from a measurement that does nothing; the depth is now 3; - test_solver_logging: the logged iterations. Tests only; no package code changes. --- tests/test_circuits/test_ops.py | 4 +- tests/test_noise/test_mixed_stabilizers.py | 70 ++++++++++++++++++- .../test_evolutionary_random_solvers.py | 3 + .../test_density_matrix_state.py | 13 +++- .../graph/test_graph_rep.py | 6 +- .../stabilizer/test_stabilizer_compiler.py | 35 +++++++--- .../stabilizer/test_stabilizer_functions.py | 37 +++++++--- .../stabilizer/test_stabilizer_state.py | 18 ++++- 8 files changed, 155 insertions(+), 31 deletions(-) diff --git a/tests/test_circuits/test_ops.py b/tests/test_circuits/test_ops.py index b0869eef..c4297adf 100644 --- a/tests/test_circuits/test_ops.py +++ b/tests/test_circuits/test_ops.py @@ -170,10 +170,10 @@ def test_find_local_clifford_by_matrix_returns_each_candidate_itself(): def test_op(): u1 = dmf.phase() @ dmf.hadamard() u2 = dmf.hadamard() @ dmf.phase() @ dmf.hadamard() @ dmf.phase() @ dmf.sigmax() - print(f"is u1 equivalent to u2 = {dmf.check_equivalent_unitaries(u1, u2)}") + assert dmf.check_equivalent_unitaries(u1, u2) u3 = dmf.hadamard() @ dmf.phase() @ dmf.hadamard() @ dmf.phase() u4 = dmf.phase() @ dmf.hadamard() @ dmf.sigmax() - print(f"is u3 equivalent to u4 = {dmf.check_equivalent_unitaries(u3, u4)}") + assert dmf.check_equivalent_unitaries(u3, u4) def test_one_qubit_gate_wrapper_rejects_nested_wrapper(): diff --git a/tests/test_noise/test_mixed_stabilizers.py b/tests/test_noise/test_mixed_stabilizers.py index fcdf5205..884e95ad 100644 --- a/tests/test_noise/test_mixed_stabilizers.py +++ b/tests/test_noise/test_mixed_stabilizers.py @@ -12,9 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import copy + import numpy as np import pytest +import graphiq.backends.stabilizer.functions.clifford as sfc import graphiq.circuit.ops as ops import graphiq.noise.noise_models as nm from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler @@ -114,14 +117,75 @@ def test_mixed_state_tableaux_reduction(lam, monkeypatch): assert no_len_reduction > len_reduction +def _x_p_values(state, qubit): + """ + The set of x_p values (see z_measurement_gate) a probe measurement of + `qubit` finds across every branch of `state`'s mixture. + + Probes on a deep copy of each branch's tableau, because the probabilistic + path of z_measurement_gate mutates its tableau argument (the deterministic + path does not, but a probe of a qubit that is still indefinite hits the + probabilistic path). + measurement_determinism=1 is ignored on the deterministic path, so it only + fixes the draw for a branch that is still probabilistic; it does not force + a deterministic branch's outcome. + """ + return { + sfc.z_measurement_gate(copy.deepcopy(t), qubit, measurement_determinism=1)[2] + for _, t in state.rep_data.mixture + } + + def test_mixed_state_measurement(monkeypatch): compiler = StabilizerCompiler() compiler.measurement_determinism = "probabilistic" compiler.noise_simulation = True - n_emitters, n_photons, d_emitter, lam = 1, 1, 2, 0.2 + # d_emitter = 3 (odd): at an even depth the emitter is already a Z + # eigenstate before the measurement, so there is no indefinite-to-definite + # transition to observe and the `before` probe below cannot hold. Odd + # depth is what makes the before/after pair meaningful. + n_emitters, n_photons, d_emitter, lam = 1, 1, 3, 0.2 circ, circ_ideal = create_circuits(n_emitters, n_photons, d_emitter, lam) - circ.add(ops.MeasurementZ(register=0, reg_type="e")) monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) - state = compiler.compile(circ) + + before = compiler.compile(circ) + + circ.add(ops.MeasurementZ(register=0, reg_type="e")) + after = compiler.compile(circ) + assert isinstance(after.rep_data, MixedStabilizer) + + # Photons are indexed before emitters (CompilerBase.reg_to_index_func), so + # with one photon and one emitter the emitter is qubit 1 and the photon + # is qubit 0. + emitter = n_photons + 0 + photon = 0 + + # Unfolded: the three depolarizing Hadamards on the emitter give + # 4**3 = 64 branches, both before and after the measurement. Anchored + # against a flag-on compile of the measured circuit, which folds down to + # 2, so this reads as "unfolded is 64 where folded is 2" rather than a + # bare pinned constant. The folded count is stable under "probabilistic" + # determinism: the 64 branches draw independently, so reaching a single + # branch would need all of them to agree. + assert len(before.rep_data.mixture) == 64 == len(after.rep_data.mixture) + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", True) + folded = compiler.compile(circ) + assert len(folded.rep_data.mixture) == 2 + + # Probabilistic measurement of a mixture must neither renormalise nor + # drop branches. + assert np.isclose(after.rep_data.probability, 1.0) + + # The measurement did something: every branch's emitter goes from + # indefinite to definite. x_p is a stabilizer row index (see + # z_measurement_gate); 0 doubles as "deterministic", so a plain truth + # test is the documented contract, not the literal value. (Measured: + # {3} before, {0} after; photon stays {2} throughout.) + assert all(x != 0 for x in _x_p_values(before, emitter)) + assert all(x == 0 for x in _x_p_values(after, emitter)) + + # The photon is untouched -- guards against the measurement landing on + # the wrong qubit index. + assert all(x != 0 for x in _x_p_values(after, photon)) diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index 27620d1a..5b8711b5 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -402,6 +402,9 @@ def test_solver_logging(seed): solver.solve() solver.logs_to_df() + # one row per iteration in each log + assert list(solver.logs["population"]["iteration"]) == [*range(5)] + assert list(solver.logs["hof"]["iteration"]) == [*range(5)] def test_stabilizer_linear3(): diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_state.py b/tests/test_state_representation/density_matrix/test_density_matrix_state.py index e1c96bc9..c5b30523 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_state.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_state.py @@ -43,9 +43,16 @@ def test_density_matrix_creation_graph(): def test_density_matrix_get_single_qubit_gate(): - # TODO: replace these prints by assert np.allclose( <>, expected) - print(dmf.get_one_qubit_gate(3, 0, dmf.sigmaz())) - print(dmf.get_two_qubit_controlled_gate(3, 0, 1, dmf.sigmax())) + # qubit 0 is the leftmost tensor factor + assert np.allclose( + dmf.get_one_qubit_gate(3, 0, dmf.sigmaz()), + np.diag([1, 1, 1, 1, -1, -1, -1, -1]), + ) + assert np.allclose( + dmf.get_two_qubit_controlled_gate(3, 0, 1, dmf.sigmax()), + np.kron(np.kron(np.diag([1, 0]), np.eye(2)), np.eye(2)) + + np.kron(np.kron(np.diag([0, 1]), dmf.sigmax()), np.eye(2)), + ) def test_density_matrix_eq_against_a_non_density_matrix(): diff --git a/tests/test_state_representation/graph/test_graph_rep.py b/tests/test_state_representation/graph/test_graph_rep.py index d00ba370..9697c59e 100644 --- a/tests/test_state_representation/graph/test_graph_rep.py +++ b/tests/test_state_representation/graph/test_graph_rep.py @@ -15,6 +15,7 @@ import networkx as nx import pytest +import graphiq.backends.density_matrix.functions as dmf import graphiq.circuit.ops as ops from graphiq.backends.graph.state import Graph, MixedGraph @@ -34,7 +35,10 @@ def test_graph_lc(): lc_dict = {0: ["x"], 1: ["h"], 2: ["s"], 3: ["s", "h"]} graph = nx.Graph([(0, 1), (1, 2), (1, 3), (2, 3)]) state = Graph(graph, lc_dict) - print(f"Local Clifford gate at node 1 is {state.find_lc(1)}") + assert state.find_lc(1) == [ops.Hadamard, ops.Identity] + assert dmf.check_equivalent_unitaries( + ops.local_clifford_to_matrix_map(state.find_lc(3)), dmf.phase() @ dmf.hadamard() + ) def test_mixed_graph(): diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py index 63d34c59..fb72a591 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py @@ -18,6 +18,7 @@ import graphiq.backends.stabilizer.functions.stabilizer as sfs import graphiq.backends.state_rep_conversion as rc import graphiq.circuit.ops as ops +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.tableau import * from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau @@ -88,10 +89,19 @@ def test_compile_circuit(): ) dag.validate() dag.draw_circuit() - compiler = StabilizerCompiler() - compiler.measurement_determinism = 1 - output_stabilizer = compiler.compile(dag) - print(output_stabilizer.rep_data) + # the measurement is random: check each outcome against the DM backend + for determinism in (0, 1): + compiler = StabilizerCompiler() + compiler.measurement_determinism = determinism + output_stabilizer = compiler.compile(dag) + dm_compiler = DensityMatrixCompiler() + dm_compiler.measurement_determinism = determinism + assert np.allclose( + rc.stabilizer_to_density( + output_stabilizer.rep_data.tableau.to_stabilizer() + ), + dm_compiler.compile(dag).rep_data.data, + ) def test_compile_circuit2(): @@ -110,10 +120,19 @@ def test_compile_circuit2(): ) dag.validate() dag.draw_circuit() - compiler = StabilizerCompiler() - compiler.measurement_determinism = 1 - output_stabilizer = compiler.compile(dag) - print(output_stabilizer.rep_data) + # the measurement is random: check each outcome against the DM backend + for determinism in (0, 1): + compiler = StabilizerCompiler() + compiler.measurement_determinism = determinism + output_stabilizer = compiler.compile(dag) + dm_compiler = DensityMatrixCompiler() + dm_compiler.measurement_determinism = determinism + assert np.allclose( + rc.stabilizer_to_density( + output_stabilizer.rep_data.tableau.to_stabilizer() + ), + dm_compiler.compile(dag).rep_data.data, + ) def test_compile_leaves_the_initial_state_unchanged(): diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py index 023279df..8f8df022 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py @@ -147,6 +147,13 @@ def test_echelon_form(n_nodes): def test_qubit_insertion(): tableau = sfc.create_n_plus_state(4) tableau = sfc.insert_qubit(tableau, 1) + assert tableau == sfc.tensor( + [ + sfc.create_n_plus_state(1), + sfc.create_n_ket0_state(1), + sfc.create_n_plus_state(3), + ] + ) def test_qubit_insertion2(): @@ -158,12 +165,15 @@ def test_qubit_insertion2(): def test_qubit_removal(): tableau = sfc.create_n_plus_state(4) tableau = sfc.remove_qubit(tableau, 1) + # the measurement outcome is random, but every outcome leaves |+>^3 + assert tableau == sfc.create_n_plus_state(3) def test_qubit_insertion_removal(): tableau = sfc.create_n_plus_state(4) tableau = sfc.insert_qubit(tableau, 1) - sfc.remove_qubit(tableau, 1) + tableau = sfc.remove_qubit(tableau, 1) + assert tableau == sfc.create_n_plus_state(4) def test_stabilizer_inner_product(): @@ -200,16 +210,21 @@ def test_stabilizer_fidelity2(): dag.draw_circuit() _, target_state = linear_cluster_3qubit_circuit() metric = Infidelity(target_state) - dm_compiler = DensityMatrixCompiler() - dm_compiler.measurement_determinism = 1 - stab_compiler = StabilizerCompiler() - stab_compiler.measurement_determinism = 1 - state1 = dm_compiler.compile(dag) - state1.partial_trace([*range(3)], 4 * [2]) - print(metric.evaluate(state1, dag)) - state2 = stab_compiler.compile(dag) - state2.partial_trace([*range(3)], 4 * [2]) - print(metric.evaluate(state2, dag)) + # the emitter measurement is random; both backends must agree on each outcome + for determinism, expected in ((0, 0.75), (1, 1.0)): + dm_compiler = DensityMatrixCompiler() + dm_compiler.measurement_determinism = determinism + stab_compiler = StabilizerCompiler() + stab_compiler.measurement_determinism = determinism + state1 = dm_compiler.compile(dag) + state1.partial_trace([*range(3)], 4 * [2]) + infidelity1 = metric.evaluate(state1, dag) + state2 = stab_compiler.compile(dag) + state2.partial_trace([*range(3)], 4 * [2]) + infidelity2 = metric.evaluate(state2, dag) + assert np.isclose(infidelity1, infidelity2) + # outcome 0 leaves the photons at infidelity 0.75, outcome 1 at 1.0 + assert np.isclose(infidelity1, expected) def test_clifford_from_stabilizer(): diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_state.py b/tests/test_state_representation/stabilizer/test_stabilizer_state.py index db9e5b47..12571f08 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_state.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_state.py @@ -19,8 +19,8 @@ def test_state1(): state = Stabilizer(4) - print(state.tableau.destabilizer_to_labels()) - print(state.tableau.stabilizer_to_labels()) + assert state.tableau.destabilizer_to_labels() == ["XIII", "IXII", "IIXI", "IIIX"] + assert state.tableau.stabilizer_to_labels() == ["ZIII", "IZII", "IIZI", "IIIZ"] state.tableau.stabilizer_z = np.array( [[0, 1, 0, 0], [1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0]] ) @@ -28,8 +28,20 @@ def test_state1(): [[0, 1, 0, 0], [1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0]] ) state.apply_hadamard(1) + # H on qubit 1 swaps its X and Z columns + assert state.tableau.stabilizer_to_labels() == ["IXII", "ZIZI", "IXIZ", "IIZI"] + assert state.tableau.destabilizer_to_labels() == ["XXII", "ZZZI", "IXXZ", "IIZX"] + assert not state.tableau.phase.any() state.tableau.destabilizer_from_labels(["XYXI", "ZYZI", "YIII", "ZZZZ"]) - print(state.tableau.destabilizer) + assert np.array_equal( + state.tableau.destabilizer, + [ + [1, 1, 1, 0, 0, 1, 0, 0], + [0, 1, 0, 0, 1, 1, 1, 0], + [1, 0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 1, 1, 1, 1], + ], + ) def _plus_on_qubit_1(n_qubits): From 96142194876e6e840a0b2455e6f23566b06fabdd Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 05:58:42 -0400 Subject: [PATCH 187/227] [fix] Import jax's config object, not the removed jax.config module Selecting the jax array library for the density-matrix backend ran `from jax.config import config`. JAX deprecated the jax.config module and removed it: from 0.4.25 the module no longer defines `config`, and 0.4.30 has no such module, so importing the backend failed with ImportError (ModuleNotFoundError from 0.4.30). An unpinned `pip install graphiq[all]` on Python 3.10 now gets jax 0.4.38. Import jax and call `jax.config.update("jax_enable_x64", True)`. `jax.config` is the config object in every release checked, from 0.2.28 to 0.4.38. The default numpy path is unchanged. The new test puts a stand-in jax package in sys.modules, selects the jax library and reloads the backend module. A second test does the same with the real jax when it is installed. --- graphiq/backends/density_matrix/__init__.py | 4 +- .../test_density_matrix_array_library.py | 84 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/test_backends/test_density_matrix_array_library.py diff --git a/graphiq/backends/density_matrix/__init__.py b/graphiq/backends/density_matrix/__init__.py index 81747dd9..67fceae2 100644 --- a/graphiq/backends/density_matrix/__init__.py +++ b/graphiq/backends/density_matrix/__init__.py @@ -19,9 +19,9 @@ # todo, reload numpy/jax if needed? possible package conflicts if DENSITY_MATRIX_ARRAY_LIBRARY == "jax": - from jax.config import config + import jax - config.update( + jax.config.update( "jax_enable_x64", True ) # 32-bit precision can lead to issues when comparing matrices diff --git a/tests/test_backends/test_density_matrix_array_library.py b/tests/test_backends/test_density_matrix_array_library.py new file mode 100644 index 00000000..1d2a5d81 --- /dev/null +++ b/tests/test_backends/test_density_matrix_array_library.py @@ -0,0 +1,84 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Selecting the jax array library for the density-matrix backend. +""" + +import importlib +import sys +import types + +import numpy as np +import pytest + +import graphiq +import graphiq.backends.density_matrix as dm_package + + +def _fake_jax(calls): + # current jax: `jax.config` is the Config object, and there is no + # `jax.config` submodule to import from + jax = types.ModuleType("jax") + jax.config = types.SimpleNamespace(update=lambda *args: calls.append(args)) + jax_numpy = types.ModuleType("jax.numpy") + jax_numpy_linalg = types.ModuleType("jax.numpy.linalg") + jax_numpy_linalg.eig = object() + jax_scipy = types.ModuleType("jax.scipy") + jax_scipy_linalg = types.ModuleType("jax.scipy.linalg") + jax_scipy_linalg.eigh = object() + jax.numpy, jax_numpy.linalg = jax_numpy, jax_numpy_linalg + jax.scipy, jax_scipy.linalg = jax_scipy, jax_scipy_linalg + return { + "jax": jax, + "jax.numpy": jax_numpy, + "jax.numpy.linalg": jax_numpy_linalg, + "jax.scipy": jax_scipy, + "jax.scipy.linalg": jax_scipy_linalg, + } + + +def test_jax_array_library_import_uses_the_jax_config_attribute(monkeypatch): + calls = [] + modules = _fake_jax(calls) + try: + with monkeypatch.context() as m: + for name, module in modules.items(): + m.setitem(sys.modules, name, module) + m.setattr(graphiq, "DENSITY_MATRIX_ARRAY_LIBRARY", "jax") + importlib.reload(dm_package) + assert calls == [("jax_enable_x64", True)] + assert dm_package.numpy is modules["jax.numpy"] + assert dm_package.eig is modules["jax.numpy.linalg"].eig + assert dm_package.eigh is modules["jax.scipy.linalg"].eigh + finally: + # the context has restored the flag and sys.modules; a reload keeps + # old attributes, so drop the stand-in jax before rebinding numpy + dm_package.__dict__.pop("jax", None) + importlib.reload(dm_package) + assert dm_package.numpy is np + assert "jax" not in vars(dm_package) + + +def test_jax_array_library_imports_with_real_jax(monkeypatch): + jax = pytest.importorskip("jax") + try: + with monkeypatch.context() as m: + m.setattr(graphiq, "DENSITY_MATRIX_ARRAY_LIBRARY", "jax") + importlib.reload(dm_package) + assert dm_package.numpy is jax.numpy + assert jax.config.jax_enable_x64 + finally: + importlib.reload(dm_package) + assert dm_package.numpy is np From 0e53792502845b97b69ebeed828c2b2541ba735e Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 05:58:42 -0400 Subject: [PATCH 188/227] [fix] Reject an unsupported metric_weight when building Metrics Metrics.__init__ handled a metric_weight of None, a list, a numpy array or a callable, and did nothing for any other type. The object was built without a weighting function, and its first evaluate raised AttributeError: 'Metrics' object has no attribute 'weighting_func'. The message named neither the argument nor its type. A tuple of weights was the easy way to hit this. Raise TypeError in the constructor for any type outside the documented set, and say so in the docstring. Every accepted type takes the same path as before. --- graphiq/metrics.py | 6 ++++++ tests/test_metrics/test_metrics.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/graphiq/metrics.py b/graphiq/metrics.py index 4d596054..0437539b 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -791,6 +791,7 @@ def __init__( :type metric_weight: None, numpy.ndarray, list, or Function :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int + :raises TypeError: if metric_weight is not None, a list, a numpy.ndarray or a callable :return: function returns nothing :rtype: None """ @@ -833,6 +834,11 @@ def weighting_func(state, circuit): self.weighting_func = weighting_func elif callable(metric_weight): self.weighting_func = metric_weight + else: + raise TypeError( + "metric_weight must be None, a list, a numpy.ndarray or a callable; " + f"got {type(metric_weight).__name__}." + ) def evaluate(self, state, circuit): """ diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py index 2f2f92af..294d8a98 100644 --- a/tests/test_metrics/test_metrics.py +++ b/tests/test_metrics/test_metrics.py @@ -358,6 +358,14 @@ def test_metrics_callable_weighting(): assert metrics.evaluate(None, circuit) == 99 +@pytest.mark.parametrize("metric_weight", [(1, 10), 2.0]) +def test_metrics_rejects_an_unsupported_weight_type(metric_weight): + # a tuple or a scalar used to be accepted, and the first evaluate then + # raised AttributeError for the missing weighting function + with pytest.raises(TypeError, match="metric_weight"): + Metrics([CircuitDepth(), CircuitEmitterCount()], metric_weight=metric_weight) + + def test_metrics_per_metric_log(): circuit = _emitter_circuit(1) circuit.add(ops.Hadamard(register=0, reg_type="e")) From 9da87be3954ca7c56ab5022ab571e3e1010abbba Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 05:58:42 -0400 Subject: [PATCH 189/227] [fix] Close the file IO.load_txt reads IO._load_txt opened its file and returned the contents without closing it. CPython closed it when the last reference went away and warned "ResourceWarning: unclosed file". Without reference counting, or while a reference survived, the handle stayed open. The other IO helpers already use a with-block. Read the file inside `with open(path, "r")`. The returned text and the errors raised are unchanged. --- graphiq/io.py | 4 ++-- tests/test_utils/test_io.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/graphiq/io.py b/graphiq/io.py index edfd37d3..54be3b20 100644 --- a/graphiq/io.py +++ b/graphiq/io.py @@ -336,6 +336,6 @@ def _load_txt(path): :return: the contents of the file :rtype: str """ - f = open(path, "r") - data = f.read() + with open(path, "r") as f: + data = f.read() return data diff --git a/tests/test_utils/test_io.py b/tests/test_utils/test_io.py index 0b1933c1..95c8e712 100644 --- a/tests/test_utils/test_io.py +++ b/tests/test_utils/test_io.py @@ -12,6 +12,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import builtins import datetime import matplotlib.pyplot as plt @@ -19,6 +20,7 @@ import pandas as pd import pytest +import graphiq.io from graphiq.io import IO @@ -59,6 +61,24 @@ def test_save_and_load_txt_round_trip(tmp_path): assert io.load_txt("data.txt") == "hello world" +def test_load_txt_closes_its_file(tmp_path, monkeypatch): + io = IO(path=str(tmp_path), verbose=False) + io.save_txt("hello world", "data.txt") + opened = [] + + def recording_open(*args, **kwargs): + file = builtins.open(*args, **kwargs) + opened.append(file) + return file + + # a module global shadows the builtin inside graphiq/io.py only; the list + # keeps each file alive, so only an explicit close can close it + monkeypatch.setattr(graphiq.io, "open", recording_open, raising=False) + assert io.load_txt("data.txt") == "hello world" + assert len(opened) == 1 + assert opened[0].closed + + def test_save_and_load_dataframe_round_trip(tmp_path): io = IO(path=str(tmp_path), verbose=False) df = pd.DataFrame({"x": [1, 2], "y": [3, 4]}) From de734c9a2803586488056fa72cd2087db0238cf7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 05:58:42 -0400 Subject: [PATCH 190/227] [fix] Draw on the axes CircuitDAG.draw_dag is given without a figure draw_dag documents fig and ax as separate optional arguments, but it created a new figure unless both were passed. A call with only ax drew the DAG on a new figure, returned that figure, and left the caller's axes empty. Create a new figure only when no axes is given. When an axes comes without a figure, take its root figure, as draw_graph does: ax.figure.figure, which is the Figure itself for ordinary axes and the root Figure for axes in a SubFigure. Calls with no arguments, or with both, run as before. A fig passed without an ax is still not used. --- graphiq/circuit/circuit_dag.py | 5 +++- tests/test_circuits/test_dag_circuit.py | 35 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index e7c8e8df..5682cc6f 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -524,8 +524,11 @@ def draw_dag(self, show=True, fig=None, ax=None): # TODO: fix this such that we can see double edges properly! pos = dag_topology_pos(self.dag, method="topology") - if ax is None or fig is None: + if ax is None: fig, ax = plt.subplots() + elif fig is None: + # ax.figure is a SubFigure when ax sits in one; its .figure is the root Figure. + fig = ax.figure.figure nx.draw(self.dag, pos=pos, ax=ax, with_labels=True) if show: plt.show() diff --git a/tests/test_circuits/test_dag_circuit.py b/tests/test_circuits/test_dag_circuit.py index ce765b35..9a96bab6 100644 --- a/tests/test_circuits/test_dag_circuit.py +++ b/tests/test_circuits/test_dag_circuit.py @@ -14,6 +14,7 @@ # limitations under the License. import random +import matplotlib.pyplot as plt import numpy as np import pytest @@ -832,3 +833,37 @@ def test_parameters_setter_after_initialize_parameters_assigns_given_values(): assert parameterized for op in parameterized: assert list(op.params) == shifted[circuit.map[id(op)]] + + +def _one_hadamard_dag(): + dag = CircuitDAG(n_emitter=1, n_photon=1, n_classical=0) + dag.add(ops.Hadamard(register=0, reg_type="e")) + return dag + + +def test_draw_dag_draws_on_the_ax_it_is_given_without_a_fig(): + dag = _one_hadamard_dag() + fig0, ax0 = plt.subplots() + try: + n_figures = len(plt.get_fignums()) + fig, ax = dag.draw_dag(show=False, ax=ax0) + assert ax is ax0 + assert fig is fig0 + assert len(ax0.collections) > 0 + assert len(plt.get_fignums()) == n_figures + finally: + plt.close("all") + + +def test_draw_dag_returns_the_root_figure_for_an_ax_in_a_subfigure(): + dag = _one_hadamard_dag() + root = plt.figure() + try: + axs = root.subfigures(1, 2)[0].subplots() + n_figures = len(plt.get_fignums()) + fig, ax = dag.draw_dag(show=False, ax=axs) + assert ax is axs + assert fig is root + assert len(plt.get_fignums()) == n_figures + finally: + plt.close("all") From f5d33ff7945af3777fd035242e47478f001c1014 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 05:58:42 -0400 Subject: [PATCH 191/227] [fix] Keep the caller's gate list intact in run_circuit(reverse=True) run_circuit ran the inverse circuit by reversing circuit_list in place with list.reverse(), so the caller's list came back reversed. Stabilizer.apply_circuit(..., reverse=True) passes its list straight through and had the same side effect. A second inverse run with the same list then applied the gates in forward order with P and P_dag swapped, which is neither the circuit nor its inverse. Iterate over a reversed copy instead. The gates applied and their order are unchanged, so the one in-tree reverse=True caller, clifford_from_stabilizer, gives the same tableau as before. --- .../stabilizer/functions/transformation.py | 2 +- .../stabilizer/test_run_circuit_reverse.py | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/test_state_representation/stabilizer/test_run_circuit_reverse.py diff --git a/graphiq/backends/stabilizer/functions/transformation.py b/graphiq/backends/stabilizer/functions/transformation.py index a5449a6e..0616a9df 100644 --- a/graphiq/backends/stabilizer/functions/transformation.py +++ b/graphiq/backends/stabilizer/functions/transformation.py @@ -251,7 +251,7 @@ def run_circuit(tableau, circuit_list, reverse=False): :rtype: CliffordTableau or StabilizerTableau """ if reverse: - circuit_list.reverse() + circuit_list = circuit_list[::-1] for ops in circuit_list: if ops[0] == "H": tableau = hadamard_gate(tableau, ops[1]) diff --git a/tests/test_state_representation/stabilizer/test_run_circuit_reverse.py b/tests/test_state_representation/stabilizer/test_run_circuit_reverse.py new file mode 100644 index 00000000..515655f4 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_run_circuit_reverse.py @@ -0,0 +1,48 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +run_circuit(..., reverse=True) runs the inverse circuit without changing the +caller's gate list. +""" + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.state import Stabilizer + +# the X gate puts a sign in the forward state, so a round trip catches a phase error +_GATES = [("H", 0), ("P", 0), ("CNOT", 0, 1), ("X", 1)] + + +def test_run_circuit_reverse_leaves_the_gate_list_unchanged(): + gates = list(_GATES) + run_circuit(CliffordTableau(2), gates, reverse=True) + assert gates == _GATES + + +def test_run_circuit_reverse_twice_with_one_list_gives_the_same_state(): + gates = list(_GATES) + first = run_circuit(CliffordTableau(2), gates, reverse=True) + second = run_circuit(CliffordTableau(2), gates, reverse=True) + assert first == second + # after two uses the same list still inverts the circuit + forward = run_circuit(CliffordTableau(2), list(_GATES)) + assert forward != CliffordTableau(2) + assert run_circuit(forward, gates, reverse=True) == CliffordTableau(2) + + +def test_stabilizer_apply_circuit_reverse_leaves_the_gate_list_unchanged(): + gates = list(_GATES) + Stabilizer(2).apply_circuit(gates, reverse=True) + assert gates == _GATES From 29620218acb811225626087fdca76e14f8011a37 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 05:58:43 -0400 Subject: [PATCH 192/227] [fix] Make rref's rank check reject a single identity generator rref's rank check asserted pivot[0] >= n_qubits - 1 after reduction. A full-rank tableau gives every generator a pivot and ends at pivot[0] == n_qubits. Ending one short means exactly one generator reduced to the identity: an identity generator on input, or one that is a product of the others. The check accepted that case, rejected only two or more, and was always true on one qubit, although its message promises to catch an identity generator. Require pivot[0] == n_qubits. The row count cannot exceed n_qubits. Clifford gates and row operations on distinct rows keep rank, so valid states still pass, and the solver and height tests pass unchanged. --- .../stabilizer/functions/stabilizer.py | 4 ++-- .../stabilizer/test_stabilizer_functions.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/graphiq/backends/stabilizer/functions/stabilizer.py b/graphiq/backends/stabilizer/functions/stabilizer.py index 4b7fda9b..1445b1f6 100644 --- a/graphiq/backends/stabilizer/functions/stabilizer.py +++ b/graphiq/backends/stabilizer/functions/stabilizer.py @@ -513,9 +513,9 @@ def rref(tableau): n_qubits = tableau.n_qubits while pivot[0] <= n_qubits - 1 and pivot[1] <= n_qubits - 1: tableau, pivot = one_step_rref(tableau, pivot) - # rank check + # rank check: every generator must have received a pivot assert ( - pivot[0] >= n_qubits - 1 + pivot[0] == n_qubits ), "Invalid input. One of the stabilizers is identity on all qubits!" return tableau diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py index 8f8df022..28eb068e 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py @@ -314,3 +314,21 @@ def test_binary_symplectic_product_of_different_row_counts(): # Z_i anticommutes with X_i only assert np.array_equal(product, np.array([[1, 0, 0, 0], [0, 1, 0, 0]])) assert sfu.is_symplectic_self_orthogonal(stabilizer) + + +@pytest.mark.parametrize( + "z_rows", + [ + # one identity generator: Z0, Z1, I + [[1, 0, 0], [0, 1, 0], [0, 0, 0]], + # dependent generators: Z0, Z1, Z0Z1 reduces to Z0, Z1, I + [[1, 0, 0], [0, 1, 0], [1, 1, 0]], + # the only generator on one qubit is the identity + [[0]], + ], +) +def test_rref_rejects_a_generator_that_reduces_to_the_identity(z_rows): + z_matrix = np.array(z_rows) + tableau = StabilizerTableau([np.zeros_like(z_matrix), z_matrix]) + with pytest.raises(AssertionError): + rref(tableau) From 74574072d6dc9073b195e4573cefae4a377273ec Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 05:58:43 -0400 Subject: [PATCH 193/227] [fix] Keep HybridEvolutionarySolver's compiler noise-free without noise TimeReversedSolver.solve sets the shared compiler's noise_simulation to its own flag, and the hybrid solver sets that flag to False, so the deterministic seed turns noise simulation off. The line after it restored the flag for the search, but always to True. With noise_model_mapping=None, every compile of the search therefore ran in noise mode and returned a mixed state. solve() also left the caller's compiler with noise_simulation=True, so the next compile gave a MixedStabilizer although no noise was asked for. Restore the value EvolutionarySolver.solve chose from noise_model_mapping. With a noise mapping that is still True. Without one, every operation carries NoNoise, so compiled states and scores are unchanged. Only the representation moves from mixed back to pure. --- graphiq/solvers/hybrid_solvers.py | 4 +- tests/test_solvers/test_hybrid_solvers.py | 66 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/graphiq/solvers/hybrid_solvers.py b/graphiq/solvers/hybrid_solvers.py index a0580fa8..0ed6d06b 100644 --- a/graphiq/solvers/hybrid_solvers.py +++ b/graphiq/solvers/hybrid_solvers.py @@ -162,7 +162,9 @@ def population_initialization(self): ) deterministic_solver.noise_simulation = False deterministic_solver.solve() - self.compiler.noise_simulation = True + # the deterministic solve set the shared compiler's flag to False; restore + # the one solve() chose from noise_model_mapping for the search + self.compiler.noise_simulation = self.noise_simulation _, ideal_circuit = deterministic_solver.result population = [] for j in range(self.setting.n_pop): diff --git a/tests/test_solvers/test_hybrid_solvers.py b/tests/test_solvers/test_hybrid_solvers.py index bf486b40..44bc4f70 100644 --- a/tests/test_solvers/test_hybrid_solvers.py +++ b/tests/test_solvers/test_hybrid_solvers.py @@ -209,3 +209,69 @@ def test_a_graph_target_with_the_stabilizer_compiler_still_solves(): assert [score for score, _ in solver.hof] == pytest.approx( [0.5, 0.5, 0.75, 0.75, 0.75] ) + + +def _path3_hybrid(noise_model_mapping=None): + target = QuantumState( + get_clifford_tableau_from_graph(nx.path_graph(3)), rep_type="s" + ) + compiler = StabilizerCompiler() + solver = HybridEvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=compiler, + noise_model_mapping=noise_model_mapping, + solver_setting=EvolutionarySolverSetting(n_stop=2, n_pop=2), + ) + return solver, compiler + + +def _record_search_compiles(solver, compiler, monkeypatch): + # record the `mixed` flag of every state compiled after the population is + # initialised; the deterministic solve that seeds it compiles first + recorded = [] + initialize = solver.population_initialization + compile_ = compiler.compile + + def recording_compile(circuit, initial_state=None): + state = compile_(circuit, initial_state) + recorded.append(state.mixed) + return state + + def initialize_then_record(): + population = initialize() + monkeypatch.setattr(compiler, "compile", recording_compile) + return population + + monkeypatch.setattr(solver, "population_initialization", initialize_then_record) + return recorded + + +def test_hybrid_without_noise_leaves_the_compiler_noise_free(): + solver, compiler = _path3_hybrid() + HybridEvolutionarySolver.seed(0) + solver.solve() + assert solver.noise_simulation is False + assert compiler.noise_simulation is False + assert compiler.compile(solver.result[1]).mixed is False + + +def test_hybrid_without_noise_searches_with_noise_free_compiles(monkeypatch): + solver, compiler = _path3_hybrid() + recorded = _record_search_compiles(solver, compiler, monkeypatch) + HybridEvolutionarySolver.seed(0) + solver.solve() + assert recorded + assert not any(recorded) + + +def test_hybrid_with_a_noise_mapping_still_simulates_noise(monkeypatch): + solver, compiler = _path3_hybrid( + noise_model_mapping={"e": {}, "p": {}, "ee": {}, "ep": {}} + ) + recorded = _record_search_compiles(solver, compiler, monkeypatch) + HybridEvolutionarySolver.seed(0) + solver.solve() + assert solver.noise_simulation is True + assert recorded + assert all(recorded) From ef9cc475eee73a983a6fbbefc33bf5d4beebfea7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 05:58:43 -0400 Subject: [PATCH 194/227] [docs] Say what GraphCorr's relabel_trials and num_isomorph control The relabel_trials docstring said it set how many relabelings of each graph were tried, and that the result was the minimum or maximum over them. The code reads relabel_trials only as an on/off switch. The labelings come from num_isomorph, and the result is always a minimum. The list holds at most num_isomorph distinct labelings, the given one included, so at the default num_isomorph=1 no other labeling is used. Describe that behaviour in both parameters. No code changes. --- graphiq/data_collection/correlation_module.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 303151b4..478eee1a 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -74,14 +74,17 @@ def __init__( :param graph_list: a list of graphs; if provided the sample to study would be this set of given graphs :type graph_list: list[networkx.Graph] :param trials: the size of the random graph sample to analyze - :param relabel_trials: if provided, is used to evaluate the circuit metric over this number of different - relabeling of each graph in the sample, such that the final circuit metric value is the minimum or maximum over - all these relabeling trials. This is done to make the metric value somewhat labeling-agnostic, since the graph - metrics used here are independent of the labeling too. + :param relabel_trials: if truthy, the circuit metric of each graph in the sample is the minimum over at most + num_isomorph distinct labelings of it, the given labeling included (fewer if the graph has fewer distinct + relabelings or the search stops early). The value only switches this on; num_isomorph sets the count, and + at the default num_isomorph=1 no other labeling is used. This makes the metric value somewhat + labeling-agnostic, since the graph metrics used here are independent of the labeling too. If None or 0, + each graph is evaluated as given. :type relabel_trials: int :param num_isomorph: if an initial graph is provided to work with, this parameter determines the size of the sample that is acquired by relabeling of the initial graph. The actual sample size might be less than this - parameter if the maximum number of isomorphism is reached. + parameter if the maximum number of isomorphism is reached. When relabel_trials is set, it is also the most + distinct labelings, the given one included, over which each graph's circuit metric is minimised. :type num_isomorph: int """ # if we have an initial graph, correlations are found based on the relabeling of that graph. Otherwise, if a From 3ad68c3edffb07020f7a3db882356f3a21473e43 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 12:07:14 -0400 Subject: [PATCH 195/227] [fix] Divide GraphCorr's cnot_per_photon by the number of photons GraphCorr._circ_met_value computed "cnot_per_photon" as graph_to_cnot(graph=g) / len([g_temp]). [g_temp] is a one-element list, so the divisor was always 1 and the metric returned the raw emitter-emitter CNOT count, the same value as "cnot". The docstrings call it the number of CNOTs per number of photons. The divisor is now g_temp.number_of_nodes(), the same photon count that "num_emit_per_photon" uses. Values for "cnot_per_photon" become the CNOT count divided by n, where they were equal to "cnot". When every graph in the sample has one n, the Pearson correlations are unchanged up to floating-point rounding, since all values scale by one constant. That holds for corr_p_dependence, and for finder(n=..., p=...) when no graph_list is set (with a graph_list already set, finder ignores n and uses the list, which may mix sizes). The minimum over relabelings and the adj_met_sorted_list order also stay the same, because relabelings keep n. In corr_n_dependence each finder call has one n, so each correlation is unchanged up to rounding, but each curve's x-values are divided by that curve's own n, which moves the curves relative to each other. The lists that _rep_counter builds (returned by met_met, NodeCorr.met_order_error and NodeCorr.next_node_corr) come out in set iteration order, which follows the tuples' hashes; the new values can reorder them even when n is fixed. The points are the old ones with the cnot_per_photon coordinate divided by that graph's n. When the sample mixes graph sizes, as _rnd_graph_list_maker does with n drawn from (5, 99) or with a user graph_list, the correlations change; the change is what the metric was meant to measure. Default ("num_emit") results do not change. --- graphiq/data_collection/correlation_module.py | 2 +- tests/test_data_collection/test_correlation_module.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 478eee1a..721b3b89 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -669,7 +669,7 @@ def _circ_met_value(self, g): elif self.circ_metric == "cnot": circ_value = graph_to_cnot(graph=g) elif self.circ_metric == "cnot_per_photon": - circ_value = graph_to_cnot(graph=g) / len([g_temp]) + circ_value = graph_to_cnot(graph=g) / g_temp.number_of_nodes() elif self.circ_metric == "cnot_per_emitter": circ_value = graph_to_cnot(graph=g) / height_max(graph=g_temp) else: diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py index cdf6221e..e2bd2e8c 100644 --- a/tests/test_data_collection/test_correlation_module.py +++ b/tests/test_data_collection/test_correlation_module.py @@ -146,3 +146,11 @@ def test_rnd_graph_with_a_seed_keeps_a_connected_first_draw(): for s in seeds: expected = nx.gnp_random_graph(n, p, seed=s) assert sorted(rnd_graph(n, p, seed=s).edges()) == sorted(expected.edges()) + + +def test_cnot_per_photon_divides_the_cnot_count_by_the_number_of_photons(): + graph = nx.cycle_graph(6) + n_cnot = graph_to_cnot(graph) + assert n_cnot > 0 + corr = GraphCorr(circ_metric="cnot_per_photon", graph_list=[graph]) + assert corr._circ_met_value(graph) == pytest.approx(n_cnot / 6) From 0555984c3152eba14e6018a418d1b77cab031f99 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 12:07:15 -0400 Subject: [PATCH 196/227] [fix] Compare graph_analyzer's worst fidelity against the worst so far graph_analyzer updated worst_fid whenever a case's fidelity was below best_fid[1], not below worst_fid[1]. Every case below the running best overwrote worst_fid, so the reported worst graph was the last such case in iteration order, not the lowest-fidelity one. The cost, n_cnot and depth trackers and best_fid each compare against themselves. The fidelity check now compares against worst_fid[1], following the same pattern. The initial value ("", 1, [0]) and the strict < are unchanged. bests.txt's "worst graph w.r.t fidelity" line and graph_analyzer(...)[4] now name the lowest-fidelity case; before, they named it only when it happened to be the last case below the running best. On ties the first case to reach the minimum is kept, where before the last case below the running best was kept (fidelities [0.9, 0.5, 0.5]: iso 2 before, iso 1 now). Ties are realistic, since find_best rounds fidelity to 5 decimals. A worst line that was left blank now names a case whenever some fidelity is below 1: before, worst_fid stayed ("", 1, [0]) whenever no case was below the running best when seen, as with a single case or ascending fidelities ([0.8] and [0.5, 0.9]: "" before, iso 0 now). If every fidelity is 1, worst_fid stays ("", 1, [0]) as before. best_fid, the cost, n_cnot and depth trackers, and the bwCNOT.csv / bwDepth.csv files written by LC_scaling_test and iso_scaling_test do not change; the bw lists those two functions return, and the bests.txt each of their runs writes, carry the corrected worst case. --- graphiq/data_collection/user_interface.py | 2 +- .../test_user_interface.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index 42f46345..d34a342e 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -543,7 +543,7 @@ def graph_analyzer( if fidelity: if out_dict["fidelity"][j] > best_fid[1]: best_fid = (f"LC: {i} iso: {j}", out_dict["fidelity"][j], edge_list) - if out_dict["fidelity"][j] < best_fid[1]: + if out_dict["fidelity"][j] < worst_fid[1]: worst_fid = ( f"LC: {i} iso: {j}", out_dict["fidelity"][j], diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py index 8421d597..73cbbc40 100644 --- a/tests/test_data_collection/test_user_interface.py +++ b/tests/test_data_collection/test_user_interface.py @@ -17,6 +17,7 @@ import networkx as nx import numpy as np import pandas as pd +import pytest import graphiq.circuit.ops as ops from graphiq.benchmarks.circuits import ghz3_state_circuit @@ -25,6 +26,7 @@ from graphiq.data_collection.user_interface import ( LC_scaling_known, find_best, + graph_analyzer, plot_map_based, result_maker, rgs_analysis, @@ -129,3 +131,35 @@ def test_lc_scaling_known_creates_its_save_directory(tmp_path): assert (tmp_path / "cls" / "bwDepth.csv").read_text() == pd.DataFrame( (d_means, d_stds) ).to_csv(index=False, header=False) + + +@pytest.mark.parametrize( + "fidelities, best, worst", + [ + ([0.9, 0.5, 0.7], ("LC: 0 iso: 0", 0.9), ("LC: 0 iso: 1", 0.5)), + ([0.9, 0.5, 0.5], ("LC: 0 iso: 0", 0.9), ("LC: 0 iso: 1", 0.5)), + ([0.5, 0.9], ("LC: 0 iso: 1", 0.9), ("LC: 0 iso: 0", 0.5)), + ], +) +def test_graph_analyzer_reports_the_lowest_fidelity_as_worst( + tmp_path, monkeypatch, fidelities, best, worst +): + ui = "graphiq.data_collection.user_interface" + path3 = nx.to_numpy_array(nx.path_graph(3)) + monkeypatch.setattr(f"{ui}.lcs", lambda *args, **kwargs: [path3]) + n_cases = len(fidelities) + cases = { + "cost": list(range(1, n_cases + 1)), + "edges": [[(0, 1), (1, 2)]] * n_cases, + "n_cnots": [0] * n_cases, + "max_emit_reset_depth": [1] * n_cases, + "fidelity": fidelities, + } + monkeypatch.setattr(f"{ui}.find_best", lambda *args, **kwargs: cases) + + out = graph_analyzer([0, 1, 1, 2], dir_path=str(tmp_path), graph_class="c") + + assert out[0][:2] == best + assert out[4][:2] == worst + bests = (tmp_path / "c" / "bests.txt").read_text() + assert f"worst graph w.r.t fidelity {worst[0]}" in bests From b1626bd3045dedb01c4f74845dc71c184afc03a6 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 12:07:15 -0400 Subject: [PATCH 197/227] [fix] Leave Input and Output out of graph_to_emit_depth's gate count graph_to_emit_depth counted each emitter's gates as len(circ.reg_gate_history(reg=e_i)[1]). reg_gate_history returns the register's nodes from its Input node to its Output node inclusive, so each count included two nodes that are not gates, and the result was 2 higher than the maximum number of gates on one emitter that the docstring describes. CircuitMaxEmitDepth and user_interface's result_maker "max_emit_depth" subtract the two nodes from the same count. The count now subtracts 2, as CircuitMaxEmitDepth and result_maker do. Every "max_emit_depth" value from GraphCorr / NodeCorr drops by exactly 2. Each emitter's history has exactly one Input and one Output node, so the maximum over emitters drops by 2 too. Pearson correlations do not change (up to floating-point rounding), since all values shift by one constant: finder's corr and avg_corr, and NodeCorr.order_corr's overall correlation. met_distribution's returned average drops by 2; its standard deviation does not change. The _avg_maker / _rep_counter groupings and the adj_met_sorted_list order do not change, and _avg_maker's output keeps its order (it sorts). The lists that _rep_counter builds come out in set iteration order, which follows the tuples' hashes, so shifting x by 2 reorders them. This affects the lists returned by met_met, NodeCorr.met_order_error and NodeCorr.next_node_corr: the same points shifted by 2, in a new order. Otherwise only the absolute values and the plot axes shift. --- graphiq/data_collection/correlation_module.py | 2 +- .../test_correlation_module.py | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 721b3b89..ec963066 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -1124,7 +1124,7 @@ def graph_to_emit_depth(graph): circ.unwrap_nodes() circ.remove_identity() for e_i in range(circ.n_emitters): - depth[e_i] = len(circ.reg_gate_history(reg=e_i)[1]) + depth[e_i] = len(circ.reg_gate_history(reg=e_i)[1]) - 2 return max(depth.values()) diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py index e2bd2e8c..bc79a4e3 100644 --- a/tests/test_data_collection/test_correlation_module.py +++ b/tests/test_data_collection/test_correlation_module.py @@ -16,18 +16,21 @@ import numpy as np import pytest +import graphiq.circuit.ops as ops from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, ) +from graphiq.circuit.circuit_dag import CircuitDAG from graphiq.data_collection.correlation_module import ( GraphCorr, NodeCorr, graph_to_circ, graph_to_cnot, + graph_to_emit_depth, rnd_graph, ) -from graphiq.metrics import Infidelity +from graphiq.metrics import CircuitMaxEmitDepth, Infidelity from graphiq.state import QuantumState @@ -154,3 +157,15 @@ def test_cnot_per_photon_divides_the_cnot_count_by_the_number_of_photons(): assert n_cnot > 0 corr = GraphCorr(circ_metric="cnot_per_photon", graph_list=[graph]) assert corr._circ_met_value(graph) == pytest.approx(n_cnot / 6) + + +def test_graph_to_emit_depth_counts_only_the_gates_on_the_emitter(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.SigmaX(register=0, reg_type="e")) + assert graph_to_emit_depth(circuit) == 2 + + graph_circuit = graph_to_circ(nx.cycle_graph(6)) + assert graph_to_emit_depth(graph_circuit) == CircuitMaxEmitDepth().evaluate( + None, graph_circuit + ) From 9296f9f6059cb320df80b64e3c3da3303ee49fc9 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 12:07:15 -0400 Subject: [PATCH 198/227] [fix] Make benchmark_run save openQASM, as it asks for benchmark_run set run["solver"].save_openqasm = "both" before solving, but save_openqasm is a property of EvolutionarySolverSetting, and EvolutionarySolver.save_circuits reads it from solver.setting. The assignment only created an unused attribute on the solver, so no benchmark run saved its population or hall of fame, although the pipeline asks for both. The value is now set on the solver's setting. A solver without a setting is left as before: benchmark_run still needs of it only io, solve(), logs and hof, as its test's stub solver shows. Scores, circuits, logs and the hall of fame do not change: saving draws no random numbers, and seeded runs give the same results. What changes is what a run writes. For an EvolutionarySolver (or subclass), each iteration i now also writes pop/iteration{i}_pop{j}.txt for every population member j and hof/iteration{i}_hof{k}.txt for every filled hall-of-fame slot k, below min(n_hof, (i + 1) * n_pop), under the run's directory, as openQASM saved through IO.save_json. That is about 2750 files per run for pipeline.py's example (n_stop 50, n_pop 50, n_hof 5) and about 12000 in total for benchmarks/solvers.py's four runs. The time column of benchmark_solver_run.csv now includes writing them. benchmark_run leaves the solver's setting at "both", so after a serial benchmark() the caller's setting object is left at "both" too (runs built from one run_combinations entry share it), and a later solve() of that solver with an IO attached also writes openQASM. Under ray each run is serialized into its task, so the caller's object should stay unchanged there (not checked: ray is not installed here). --- graphiq/benchmarks/pipeline.py | 4 ++- tests/test_benchmarking/test_pipeline.py | 37 +++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/graphiq/benchmarks/pipeline.py b/graphiq/benchmarks/pipeline.py index c0ac79d5..b487eca2 100644 --- a/graphiq/benchmarks/pipeline.py +++ b/graphiq/benchmarks/pipeline.py @@ -111,7 +111,9 @@ def benchmark_run(run: dict, name: str, io: IO): t0 = time.time() run["solver"].io = io - run["solver"].save_openqasm = "both" + # save_openqasm is a setting; a solver without a setting saves no openQASM + if hasattr(run["solver"], "setting"): + run["solver"].setting.save_openqasm = "both" run["solver"].solve() t1 = time.time() diff --git a/tests/test_benchmarking/test_pipeline.py b/tests/test_benchmarking/test_pipeline.py index 1720e80c..970a9638 100644 --- a/tests/test_benchmarking/test_pipeline.py +++ b/tests/test_benchmarking/test_pipeline.py @@ -12,7 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import json + import graphiq.noise.monte_carlo_noise as mcn +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, @@ -23,9 +26,14 @@ AlternateTargetSolverSetting, AlternateTargetSolver, ) +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.state import QuantumState +from graphiq.benchmarks.circuits import linear_cluster_3qubit_circuit from graphiq.benchmarks.graph_states import linear_cluster_state -from graphiq.benchmarks.pipeline import benchmark_run_graph_search_solver +from graphiq.benchmarks.pipeline import benchmark_run, benchmark_run_graph_search_solver def test_benchmark_run_graph_search_solver(): @@ -111,3 +119,30 @@ def save_figure(self, fig, filename): assert d["compiler"] == "StabilizerCompiler" assert d["metric"] == "Infidelity" + + +def test_benchmark_run_saves_the_population_and_hof_as_openqasm(tmp_path): + circuit, target = linear_cluster_3qubit_circuit() + compiler = DensityMatrixCompiler() + metric = Infidelity(target) + # n_pop >= n_hof, so every hall-of-fame slot is filled from the first iteration + setting = EvolutionarySolverSetting(n_stop=2, n_pop=3, n_hof=2) + solver = EvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + n_emitter=circuit.n_emitters, + n_photon=circuit.n_photons, + solver_setting=setting, + ) + solver.seed(0) + run = dict(solver=solver, target=target, compiler=compiler, metric=metric) + + benchmark_run(run, "run0", IO(path=tmp_path, verbose=False)) + + saved = {p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*.txt")} + assert saved == { + f"pop/iteration{i}_pop{j}.txt" for i in range(2) for j in range(3) + } | {f"hof/iteration{i}_hof{j}.txt" for i in range(2) for j in range(2)} + best = json.loads((tmp_path / "hof" / "iteration1_hof0.txt").read_text()) + assert best == solver.hof[0][1].to_openqasm() From a10284a4a6af167b4786b502327834c9e4e68bfa Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 12:07:15 -0400 Subject: [PATCH 199/227] [docs] Say what benchmark_data needs of its solver, targets and compiler benchmark_data's docstring typed solver_class as SolverBase and said nothing more. It builds each solver as solver_class(target=..., metric=..., compiler=..., n_emitter=..., n_photon=...) and then reads solver.hof[0], so TimeReversedSolver (which takes neither n_emitter nor n_photon, and has no hof) and HybridEvolutionarySolver (which works out n_emitter and n_photon itself) cannot be passed as they are. The docstring now states that call and the hof requirement, that EvolutionarySolver qualifies (through functools.partial for a solver_setting), that HybridEvolutionarySolver can be passed through a callable dropping the two keywords, for targets whose density matrix is itself a graph state, and that TimeReversedSolver cannot be benchmarked. It says the compiler must be a density-matrix one, since circuit_measurement_independent compares density matrices and a StabilizerCompiler raises TypeError there. The targets entry now describes the (target_circuit, target) pairs it takes: the circuit is not read, and target is a dict with "name", "n_emitters", "n_photons" and the state under the key target_type. Docstrings only; no behaviour changes. --- graphiq/benchmarks/benchmark_utils.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/graphiq/benchmarks/benchmark_utils.py b/graphiq/benchmarks/benchmark_utils.py index d238eb47..7fe3961a 100644 --- a/graphiq/benchmarks/benchmark_utils.py +++ b/graphiq/benchmarks/benchmark_utils.py @@ -98,13 +98,28 @@ def benchmark_data( d) Final circuit depth [not done] e) Runtime of solver iteration [not done] - :param solver_class: the solver being benchmarked - :type solver_class: SolverBase - :param targets: a list of (target_circuit, target_state) on which to evaluate - :type targets: list + :param solver_class: the solver being benchmarked: a class, or any callable returning a solver, + that is called with the keyword arguments target, metric, compiler, n_emitter and n_photon + (the last two from the target's "n_emitters" and "n_photons"). The solver must have a hall + of fame, hof, after solve(), whose first entry is the best (score, circuit) pair. + EvolutionarySolver qualifies (use functools.partial to give it a solver_setting). A class + that works out n_emitter and n_photon itself, such as HybridEvolutionarySolver, can be + passed through a callable that drops them, e.g. + lambda n_emitter, n_photon, **kwargs: HybridEvolutionarySolver(**kwargs); for + HybridEvolutionarySolver this works only for targets whose density matrix is itself a + graph state (not the GHZ targets in graphiq.benchmarks.circuits, for example). + TimeReversedSolver has no hof and cannot be benchmarked. + :type solver_class: type[SolverBase] or callable + :param targets: a list of (target_circuit, target) pairs on which to evaluate; target_circuit + is not read, and target is a dict holding "name", "n_emitters", "n_photons" and the target + state under the key target_type + :type targets: list[tuple] :param metric_class: the class of the cost function metric to use in benchmarking :type metric_class: MetricBase - :param compiler: the compiler which the solver should use + :param compiler: the compiler which the solver should use; it must be a density-matrix + compiler, since each best circuit is also checked with circuit_measurement_independent, + which compares density matrices (a StabilizerCompiler raises TypeError there) + :type compiler: DensityMatrixCompiler :param target_type: a string describing the target state representation :type target_type: str :param per_target_retries: the number of times we should reseed and run the same solver problem From 5ad94b170a6f8ac7c518382aa1af370d8f21cac5 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 12:07:15 -0400 Subject: [PATCH 200/227] [fix] Give the emitter-depth metrics 0 on a circuit with no emitters CircuitMaxEmitDepth, CircuitMaxEmitResetDepth and CircuitMaxEmitEffDepth build one value per emitter and take the max. On a circuit with no emitters the dict is empty, so evaluate raised ValueError: max() arg is an empty sequence, directly and through Metrics([...]). The other circuit metrics return a value on the same circuit; CircuitEmitterCount gives 0. Pass default=0 to the three max calls. A circuit with no emitters now scores depth_penalty(0) on each metric, which is 0 by default, and the log records that value; a Metrics wrapper holding them returns 0.0. A circuit with at least one emitter gets the same value as before, since max with a default returns the same result on a non-empty dict. Nothing in the package evaluates these metrics on a circuit without emitters, so no solver, benchmark or example result changes. --- graphiq/metrics.py | 6 +++--- tests/test_metrics/test_metrics.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/graphiq/metrics.py b/graphiq/metrics.py index 0437539b..d7675eee 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -495,7 +495,7 @@ def evaluate(self, state, circuit): e_depth = {} for e_i in range(c.n_emitters): e_depth[e_i] = len(c.reg_gate_history(reg=e_i)[1]) - 2 - depth = max(e_depth.values()) + depth = max(e_depth.values(), default=0) val = self.depth_penalty(depth) self.increment() @@ -559,7 +559,7 @@ def evaluate(self, state, circuit): m_list[j + 1] - m_list[j] for j in range(len(m_list) - 1) ] reset_depths[e_i] = max(reset_intervals) - depth = max(reset_depths.values()) + depth = max(reset_depths.values(), default=0) val = self.depth_penalty(depth) self.increment() @@ -625,7 +625,7 @@ def evaluate(self, state, circuit): for j in range(len(node_list) - 1) ] eff_depth[e_i] = max(depth_diff) - depth = max(eff_depth.values()) + depth = max(eff_depth.values(), default=0) val = self.depth_penalty(depth) self.increment() diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py index 294d8a98..ce60469c 100644 --- a/tests/test_metrics/test_metrics.py +++ b/tests/test_metrics/test_metrics.py @@ -277,6 +277,37 @@ def test_circuit_max_emit_eff_depth_diverges_from_reset_depth_with_cross_registe assert CircuitMaxEmitEffDepth().evaluate(None, circuit) == 8 +def _circuit_without_emitters(): + circuit = _emitter_circuit(0, n_photon=2) + circuit.add(ops.Hadamard(register=0, reg_type="p")) + circuit.add(ops.CNOT(control=0, control_type="p", target=1, target_type="p")) + return circuit + + +_EMITTER_DEPTH_METRICS = [ + CircuitMaxEmitDepth, + CircuitMaxEmitResetDepth, + CircuitMaxEmitEffDepth, +] + + +@pytest.mark.parametrize("metric_class", _EMITTER_DEPTH_METRICS) +def test_emitter_depth_metrics_on_a_circuit_without_emitters(metric_class): + assert metric_class().evaluate(None, _circuit_without_emitters()) == 0 + + +def test_emitter_depth_metrics_by_name_on_a_circuit_without_emitters(): + metrics = Metrics([m.__name__ for m in _EMITTER_DEPTH_METRICS]) + assert metrics.evaluate(None, _circuit_without_emitters()) == 0.0 + + +@pytest.mark.parametrize("metric_class", _EMITTER_DEPTH_METRICS) +def test_emitter_depth_penalty_applies_without_emitters(metric_class): + metric = metric_class(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, _circuit_without_emitters()) == 100 + assert metric.log == [100] + + @pytest.fixture def path_graph(): # 0 - 1 - 2 - 3, a simple, hand-checkable graph From f288ded6872bd978d75e8ea433e162b46ce42e69 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 13:54:18 -0400 Subject: [PATCH 201/227] [fix] Centre density_matrix_bars' colour map on zero bar_plot passed each entry to the diverging colour map as entry * 0.8. The map covers [0, 1] and clips below 0, so every negative entry and zero got the deep-blue end, told apart only by alpha, and a positive entry of at most 1 stayed in the blue half: 0.5 was a pale blue. The imaginary panel of any real density matrix was drawn all deep blue. Map each entry through 0.5 + 0.5 * entry / max_height, the band bar_plot already computes for the z-limits and the alpha. max_height is always 0.25, 0.5 or 1.0, never 0, and no entry of a density matrix exceeds it, so the position stays in [0, 1]. No numeric result changes; nothing reads the colours back. Every bar figure changes colour: zero bars become the neutral grey-white cmap_div(0.5) (so the imaginary panel of a real density matrix is all neutral instead of all deep blue), negatives run from neutral to blue, and positives from neutral to red. Any saved bar figure regenerated after the fix looks different. Bar heights, z-limits, ticks and alpha are unchanged. Three consequences of normalising by the panel's own band: - colour is relative to each panel's band (max_height 0.25, 0.5 or 1.0), so the same value can get a different colour in two panels whose bands differ (for example 0.2 is at 0.9 in a 0.25 panel and at 0.6 in a 1.0 panel), including the real and imaginary panels of one matrix; - the bars still disagree with density_matrix_heatmap (fixed vmin=-1, vmax=1) whenever max_height < 1; their colour positions agree only in the 1.0 band (the bars also keep their own alpha); - the end colours are reached only in the 1.0 band, by an entry of exactly +1 or -1; in the 0.25 and 0.5 bands every entry is strictly below max_height, so the extremes stay just short of the ends. --- graphiq/visualizers/density_matrix.py | 3 +- .../test_density_matrix_visualizer.py | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/test_utils/test_density_matrix_visualizer.py diff --git a/graphiq/visualizers/density_matrix.py b/graphiq/visualizers/density_matrix.py index 57428c46..255c9509 100644 --- a/graphiq/visualizers/density_matrix.py +++ b/graphiq/visualizers/density_matrix.py @@ -86,7 +86,8 @@ def bar_plot(deltaz, ax): deltaz = deltaz.flatten() colors = cmap_div( - deltaz.ravel() * 0.8, alpha=1 - 0.5 * (np.abs(deltaz.ravel()) / max_height) + 0.5 + 0.5 * deltaz.ravel() / max_height, + alpha=1 - 0.5 * (np.abs(deltaz.ravel()) / max_height), ) ax.bar3d(x, y, z, dx, dy, deltaz, color=colors) ax.set(zlim=zlim) diff --git a/tests/test_utils/test_density_matrix_visualizer.py b/tests/test_utils/test_density_matrix_visualizer.py new file mode 100644 index 00000000..bab4b355 --- /dev/null +++ b/tests/test_utils/test_density_matrix_visualizer.py @@ -0,0 +1,80 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import matplotlib.pyplot as plt +import numpy as np +import pytest +from mpl_toolkits.mplot3d import Axes3D + +from graphiq.visualizers.density_matrix import cmap_div, density_matrix_bars + +# Row-major entries 0.4, -0.1, -0.4, 0. The largest magnitude is 0.4, so the real +# panel's range is [-0.5, 0.5] and entry x sits at 0.5 + x on the colour map. +# In floats 0.5 + 0.5 * -0.4 / 0.5 is 0.09999999999999998, not 0.1; the exact +# equalities below hold because the colour map is a 256-entry lookup table and +# both land on entry 25. +RHO = np.array([[0.4, -0.1], [-0.4, 0.0]], dtype=complex) + + +@pytest.fixture +def bar_colours(monkeypatch): + """The color= array each bar3d call receives, one entry per panel.""" + recorded = [] + bar3d = Axes3D.bar3d + + def spy(self, *args, color=None, **kwargs): + recorded.append(np.asarray(color)) + return bar3d(self, *args, color=color, **kwargs) + + monkeypatch.setattr(Axes3D, "bar3d", spy) + fig, _ = density_matrix_bars(RHO) + plt.close(fig) + return recorded + + +def rgb(position): + return np.asarray(cmap_div(position))[:3] + + +def test_bar_colour_of_a_zero_entry_is_the_neutral_centre(bar_colours): + real = bar_colours[0] + np.testing.assert_array_equal(real[3, :3], rgb(0.5)) + + +def test_negative_entries_of_different_size_get_different_colours(bar_colours): + real = bar_colours[0] + np.testing.assert_array_equal(real[1, :3], rgb(0.4)) # -0.1 + np.testing.assert_array_equal(real[2, :3], rgb(0.1)) # -0.4 + assert not np.array_equal(real[1, :3], real[2, :3]) + + +def test_bar_colours_are_symmetric_about_zero(bar_colours): + real = bar_colours[0] + np.testing.assert_array_equal(real[0, :3], rgb(0.9)) # +0.4 + np.testing.assert_array_equal(real[2, :3], rgb(0.1)) # -0.4 + + +def test_an_all_zero_panel_is_drawn_in_the_neutral_colour(bar_colours): + # the imaginary part of a real matrix: every bar is 0 + imag = bar_colours[1] + for colour in imag: + np.testing.assert_array_equal(colour[:3], rgb(0.5)) + + +def test_bar_alpha_is_unchanged(bar_colours): + real, imag = bar_colours + np.testing.assert_allclose( + real[:, 3], 1 - 0.5 * np.abs([0.4, -0.1, -0.4, 0.0]) / 0.5 + ) + np.testing.assert_array_equal(imag[:, 3], np.ones(4)) From a49cfa6962b51176526703fb639eea2b3d991cb7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 13:54:18 -0400 Subject: [PATCH 202/227] [fix] Give result_maker's emitter depths and depth 0 without emitters result_maker builds one value per emitter for max_emit_depth, max_emit_reset_depth and max_emit_eff_depth, and reads one register depth per emitter for depth, then takes the max of each. On a circuit with no emitters all four sequences are empty, so result_maker raised ValueError: max() arg is an empty sequence, including with its default metric list. n_emitters, n_cnots, n_measurements and n_unitary give 0 on the same circuit. Pass default=0 to the four max calls, as the CircuitMaxEmitDepth, CircuitMaxEmitResetDepth and CircuitMaxEmitEffDepth metrics already do; each of those evaluates depth_penalty(0), which is 0 with the default penalty. The depth column has no metric counterpart; 0 matches what an emitter with no gates already gets there. Nothing changes for any circuit with at least one emitter: every column keeps its value. That covers every circuit the package's own data-collection functions produce, so no orbit_analyzer, find_best, graph_analyzer, benchmark, example or test result changes. A direct result_maker call on a circuit with no emitters now returns 0 in max_emit_depth, max_emit_reset_depth, max_emit_eff_depth and depth instead of raising. (For comparison, an emitter with no gates gives 1 for the reset and eff depths, since Input to Output is one interval; a circuit with no emitters has no interval, hence 0, as the library metrics give with their default penalty.) --- graphiq/data_collection/user_interface.py | 8 ++++---- tests/test_data_collection/test_user_interface.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index d34a342e..55a0ee4b 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -173,13 +173,13 @@ def result_maker(result, graph_met_list=None, circ_met_list=None): ] eff_depth[e_i] = max(depth_diff) if "max_emit_depth" in circ_met_list: - max_emit_depth.append(max(e_depth.values())) + max_emit_depth.append(max(e_depth.values(), default=0)) if "max_emit_reset_depth" in circ_met_list: - max_emit_reset_depth.append(max(reset_depths.values())) + max_emit_reset_depth.append(max(reset_depths.values(), default=0)) if "max_emit_eff_depth" in circ_met_list: - max_emit_eff_depth.append(max(eff_depth.values())) + max_emit_eff_depth.append(max(eff_depth.values(), default=0)) if "depth" in circ_met_list: - depth.append(max(c.register_depth["e"])) + depth.append(max(c.register_depth["e"], default=0)) # calculate n_emitter and n_cnots if "n_emitters" in circ_met_list: n_emitters.append(c.n_emitters) diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py index 73cbbc40..6c9edbeb 100644 --- a/tests/test_data_collection/test_user_interface.py +++ b/tests/test_data_collection/test_user_interface.py @@ -48,6 +48,18 @@ def test_result_maker_counts_each_one_qubit_gate_once(): assert result["n_unitary"][0] == CircuitUnitaryCount().evaluate(None, circuit) +def test_result_maker_gives_0_emitter_depths_for_a_circuit_without_emitters(): + circuit = CircuitDAG(n_emitter=0, n_photon=2, n_classical=0) + + result = result_maker(SolverResult([circuit]), graph_met_list=[]) + + assert result["n_emitters"] == [0] + assert result["max_emit_depth"] == [0] + assert result["max_emit_reset_depth"] == [0] + assert result["max_emit_eff_depth"] == [0] + assert result["depth"] == [0] + + def test_plot_map_based_plots_each_relabel_map_group(monkeypatch): calls = [] From 1950949752bd5694af515b4447247180d3ff5f16 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 13:54:18 -0400 Subject: [PATCH 203/227] [docs] Say where IO saves by default, and fix its usage example IO.default_path is data/ in the parent folder of the graphiq package. The comment above it said it pointed to the repository's data/ wherever the repository was, which holds only for a source checkout or an editable install (pip install -e). For a regular install (pip install graphiq, or pip install . from a checkout) io.py sits in site-packages, so the default is /data. Nothing in the docstring said where a default IO writes. The class docstring now says so, and the comment matches it. The usage example called IO.create_new_save_folder with include_uuid= and then io.save_df, none of which exist; it now calls IO.new_directory with include_id= and io.save_dataframe, the current names for the same calls. Docstring and comment only; where IO saves is unchanged. --- graphiq/io.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/graphiq/io.py b/graphiq/io.py index 54be3b20..9d1fa588 100644 --- a/graphiq/io.py +++ b/graphiq/io.py @@ -48,11 +48,19 @@ class IO: io.load_txt(filename="filename.txt") or - io = IO.create_new_save_folder(folder="subfolder", include_date=True, include_uuid=True) - io.save_df(df, filename="dataframe.txt") + io = IO.new_directory(folder="subfolder", include_date=True, include_id=True) + io.save_dataframe(df, filename="dataframe.txt") + + Without a path, IO and IO.new_directory save under IO.default_path, the folder + `data/` in the parent folder of the graphiq package. In a source checkout or an + editable install (pip install -e) that is the repository's `data/`; for a regular + install (pip install graphiq, or pip install . from a checkout) it is + `/data`. Pass path= to save somewhere else. """ - # default save path always points to `data/` no matter where this repository is located + # default save path: `data/` in the parent folder of the graphiq package, i.e. the + # repository's `data/` in a source checkout or an editable install (pip install -e), + # and `/data` for a regular (non-editable) install default_path = pathlib.Path(__file__).parent.parent.joinpath("data") def __init__(self, path=None, verbose=True): From 2fbbbc981fba8c16616490f2edd72610864e04a4 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 14:12:07 -0400 Subject: [PATCH 204/227] [fix] Reject None in GraphCorr's initial_graph setter The initial_graph setter's assert accepted None, but the setter then passed the value to _graph_list_maker, which calls nx.to_numpy_array on it, so assigning None always raised TypeError: 'NoneType' object is not iterable. It raised after storing the value, leaving initial_graph None while graph_list still held the old graph's relabelings. No version of the setter ever handled None. Accept only a networkx graph, as the assert's message already says. Assigning None now raises AssertionError before anything changes, so initial_graph and graph_list keep their previous values. Assigning a graph behaves as before, and GraphCorr(initial_graph=None) still works: the constructor handles None on its own path. Under python -O the assert is stripped and assigning None fails as it did before. Nothing in the package assigns initial_graph, so no result changes. --- graphiq/data_collection/correlation_module.py | 4 +--- .../test_data_collection/test_correlation_module.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index ec963066..f0767b46 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -139,9 +139,7 @@ def initial_graph(self): @initial_graph.setter def initial_graph(self, g): - assert isinstance(g, nx.Graph) or ( - g is None - ), "initial graph must be a networkx graph object" + assert isinstance(g, nx.Graph), "initial graph must be a networkx graph object" self._initial_graph = g self.graph_list = self._graph_list_maker( self._initial_graph, count=self._num_isomorph diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py index bc79a4e3..b20e449d 100644 --- a/tests/test_data_collection/test_correlation_module.py +++ b/tests/test_data_collection/test_correlation_module.py @@ -39,6 +39,18 @@ def test_graph_corr_initial_graph_is_none_without_an_initial_graph(): assert GraphCorr().initial_graph is None +def test_initial_graph_setter_rejects_none_and_leaves_the_object_unchanged(): + graph = nx.path_graph(3) + corr = GraphCorr(initial_graph=graph, num_isomorph=2) + before = corr.graph_list + with pytest.raises( + AssertionError, match="initial graph must be a networkx graph object" + ): + corr.initial_graph = None + assert corr.initial_graph is graph + assert corr.graph_list is before + + def test_num_isomorph_setter_without_an_initial_graph_sets_the_relabel_count( monkeypatch, ): From 30078da9d7632f902bde3c812595b2d589d3a37a Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 16:37:02 -0400 Subject: [PATCH 205/227] [test] Drop test_one_benchmarking_run, which ran nothing test_one_benchmarking_run built a solver, a run dict and an IO, and then stopped: its benchmark(...) call has been commented out since 2023, and benchmark is no longer imported in the file, so the call could not run as written. The test could fail only if a constructor raised, which the other tests already cover. Delete it and the __main__ block that called it. Every import stays in use by the remaining tests. No source changes. --- tests/test_benchmarking/test_benchmarking.py | 41 -------------------- 1 file changed, 41 deletions(-) diff --git a/tests/test_benchmarking/test_benchmarking.py b/tests/test_benchmarking/test_benchmarking.py index f9a9445d..8ca1dca4 100644 --- a/tests/test_benchmarking/test_benchmarking.py +++ b/tests/test_benchmarking/test_benchmarking.py @@ -27,43 +27,6 @@ from graphiq.benchmarks.circuits import ghz3_state_circuit -def test_one_benchmarking_run(): - # define a single run by creating an instance of each object - _, target = ghz3_state_circuit() - compiler = DensityMatrixCompiler() - metric = Infidelity(target=target) - - solver1 = EvolutionarySolver( - target=target, - metric=metric, - compiler=compiler, - n_photon=3, - n_emitter=1, - solver_setting=EvolutionarySolverSetting(n_stop=10, n_pop=5), - ) - - example_run1 = { - "solver": solver1, - "target": target, - "metric": metric, - "compiler": compiler, - } - - # put all runs into a dict, with a descriptive name - runs = { - "example_run1": example_run1, - } - - io = IO.new_directory( - folder="test_benchmarks", - include_date=False, - include_time=False, - include_id=False, - ) - # df = benchmark(runs=runs, io=io, remote=False) - # print(df) - - def _only_benchmark_folder(path): (folder,) = path.joinpath("benchmarks").glob("*_solver-benchmarks_*") return folder @@ -121,7 +84,3 @@ def test_the_benchmark_utils_script_runs(tmp_path, monkeypatch): folder = _only_benchmark_folder(tmp_path) assert folder.joinpath("solver_info.json").is_file() assert folder.joinpath("circuit_target0_retry0.json").is_file() - - -if __name__ == "__main__": - test_one_benchmarking_run() From 5e67661f0cdc297d63e305ab2e5edb9513c94a3b Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 16:37:02 -0400 Subject: [PATCH 206/227] [fix] Draw on the figure CircuitDAG.draw_dag is given without an axes draw_dag documents fig as the figure on which to draw the DAG, but a call with fig and no ax ignored it: the DAG was drawn on a new pyplot figure, that new figure was returned, and the caller's figure was left unchanged. When fig is given without ax, draw on fig's current axes, fig.gca(), which adds one subplot if fig has none, and return fig with that axes. No new figure is opened. Calls with no arguments, with ax only, or with both fig and ax behave as before. The docstring now says which axes a fig-only call uses. No numbers are computed, so no result changes. --- graphiq/circuit/circuit_dag.py | 8 ++++-- tests/test_circuits/test_dag_circuit.py | 34 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index 5682cc6f..d561c9ab 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -514,7 +514,8 @@ def draw_dag(self, show=True, fig=None, ax=None): :param show: if True, the DAG is displayed (shown). If False, the DAG is drawn but not displayed :type show: bool - :param fig: fig on which to draw the DAG (optional) + :param fig: fig on which to draw the DAG (optional); without ax, the DAG is drawn on fig's current axes, + which is created if fig has none :type fig: None or matplotlib.pyplot.figure :param ax: ax on which to draw the DAG (optional) :type ax: None or matplotlib.pyplot.axes @@ -525,7 +526,10 @@ def draw_dag(self, show=True, fig=None, ax=None): pos = dag_topology_pos(self.dag, method="topology") if ax is None: - fig, ax = plt.subplots() + if fig is None: + fig, ax = plt.subplots() + else: + ax = fig.gca() elif fig is None: # ax.figure is a SubFigure when ax sits in one; its .figure is the root Figure. fig = ax.figure.figure diff --git a/tests/test_circuits/test_dag_circuit.py b/tests/test_circuits/test_dag_circuit.py index 9a96bab6..27c231c8 100644 --- a/tests/test_circuits/test_dag_circuit.py +++ b/tests/test_circuits/test_dag_circuit.py @@ -867,3 +867,37 @@ def test_draw_dag_returns_the_root_figure_for_an_ax_in_a_subfigure(): assert len(plt.get_fignums()) == n_figures finally: plt.close("all") + + +def test_draw_dag_draws_on_the_fig_it_is_given_without_an_ax(): + dag = _one_hadamard_dag() + fig0 = plt.figure() + other = plt.figure() # the pyplot-current figure, which draw_dag should leave alone + try: + n_figures = len(plt.get_fignums()) + fig, ax = dag.draw_dag(show=False, fig=fig0) + assert fig is fig0 + assert fig0.axes == [ax] + assert len(ax.collections) > 0 + assert other.axes == [] + assert len(plt.get_fignums()) == n_figures + finally: + plt.close("all") + + +def test_draw_dag_draws_on_the_current_axes_of_a_fig_given_without_an_ax(): + dag = _one_hadamard_dag() + fig0, (ax_left, ax_middle, ax_right) = plt.subplots(1, 3) + try: + fig0.sca(ax_middle) + n_figures = len(plt.get_fignums()) + fig, ax = dag.draw_dag(show=False, fig=fig0) + assert fig is fig0 + assert ax is ax_middle + assert fig0.axes == [ax_left, ax_middle, ax_right] + assert len(ax_middle.collections) > 0 + assert len(ax_left.collections) == 0 + assert len(ax_right.collections) == 0 + assert len(plt.get_fignums()) == n_figures + finally: + plt.close("all") From a662bfb1146b01cd3bc33a898848bac6bd72a357 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 16:37:02 -0400 Subject: [PATCH 207/227] [fix] Ignore edge weights in local_comp_graph local_comp_graph built its adjacency matrix with nx.to_numpy_array(input_graph), which fills each entry with the edge's weight attribute. The matrix product then ran in float64 on those weights with a single mod 2 at the end, so a weighted graph could give a result that is not a local complementation of the graph: an edge with weight 2.0, for example, was treated as absent. Read the adjacency with weight=None, and say in the docstring that edge weights are ignored. local_comp_graph now ignores edge weights. Its result is that of the same graph with its weights removed: every edge counts once, and between two nodes of a MultiGraph the parallel edges count by the parity of their number (an even number cancels). For a graph whose edges all have weight 1 or no weight, which covers every graph the package builds (including graphs from nx.from_numpy_array of a 0/1 matrix and graphs returned by local_comp_graph), the result is unchanged. Before, any other weight could change the result, for example an even, fractional, very large odd, nan or inf weight, and a non-numeric string or a complex weight raised. No in-tree call passes such a weight, so no result, test or benchmark in the package changes, and no random numbers are drawn. --- graphiq/backends/lc_equivalence_check.py | 4 ++-- .../test_graph_LC_equivalence.py | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index 241b58a5..6b32bf2b 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -736,7 +736,7 @@ def local_comp_graph(input_graph, node_id): Positions and labels coincide when input_graph's nodes are 0..n-1 in ascending iteration order, as for a graph built with ``nx.from_numpy_array`` or returned by this function. - :param input_graph: input graph + :param input_graph: input graph; edge weights are ignored, so every edge counts once :type input_graph: networkx.Graph :param node_id: the position, in input_graph's node iteration order, of the node to apply the local complementation on @@ -748,7 +748,7 @@ def local_comp_graph(input_graph, node_id): # TODO: integrate this with the state representation functions n_nodes = input_graph.number_of_nodes() assert n_nodes > node_id >= 0, "node index is not in the graph" - adj_matrix = nx.to_numpy_array(input_graph).astype(int) + adj_matrix = nx.to_numpy_array(input_graph, weight=None).astype(int) identity = np.eye(n_nodes, n_nodes) gamma_matrix = np.zeros((n_nodes, n_nodes)) diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index 44f56392..1d8f8b7f 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -731,3 +731,12 @@ def test_local_comp_graph_selects_the_node_by_position(): result = local_comp_graph(graph, 0) assert list(result.nodes) == [0, 1, 2] assert sorted(map(sorted, result.edges)) == [[0, 2], [1, 2]] + + +def test_local_comp_graph_reads_edges_not_weights(): + # the path 0-1-2 with a weight of 2.0 on edge 0-1 + graph = nx.path_graph(3) + graph[0][1]["weight"] = 2.0 + result = local_comp_graph(graph, 1) + # the unweighted path's result: complementing at the middle node joins its ends + assert sorted(map(sorted, result.edges)) == [[0, 1], [0, 2], [1, 2]] From a662d171a94262d2c390e8176edbc7e95ba04cfa Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 17:13:53 -0400 Subject: [PATCH 208/227] [fix] Return the figure of the axes density_matrix_heatmap is given density_matrix_heatmap documents its return value as fig (figure handle), axs, but a call with axs returned None in place of the figure. The heatmaps were drawn on the given axes as documented. When axs is given, return axs[0]'s root figure: the figure holding axs[0], or the root figure when the axes sit in a SubFigure. If the two axes come from different figures, it is the figure of the real-part axes. The docstring now says that axs is two axes, real part first, and which figure is returned. Calls without axs behave as before. No numbers are computed, so no result changes. --- graphiq/visualizers/density_matrix.py | 10 ++-- .../test_density_matrix_visualizer.py | 60 ++++++++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/graphiq/visualizers/density_matrix.py b/graphiq/visualizers/density_matrix.py index 255c9509..5a79d801 100644 --- a/graphiq/visualizers/density_matrix.py +++ b/graphiq/visualizers/density_matrix.py @@ -30,15 +30,17 @@ def density_matrix_heatmap(rho, axs=None): :param rho: a complex numpy array representing the density matrix :type rho: numpy.ndarray - :param axs: axis to plot on - :type axs: plt.Axis - :return: fig (figure handle), axs (list of axes handles) + :param axs: two axes to plot on, the real part on axs[0] and the imaginary part on axs[1] (optional); + when given, the returned figure is axs[0]'s root figure + :type axs: sequence of two matplotlib.Axes + :return: fig (figure handle), axs (the axes plotted on) :rtype: matplotlib.Figure, matplotlib.Axes """ if axs is None: fig, axs = plt.subplots(nrows=1, ncols=2, figsize=[10, 4]) else: - fig = None + # axs[0].figure is a SubFigure when the axes sit in one; its .figure is the root Figure. + fig = axs[0].figure.figure kwargs = dict(vmin=-1, vmax=1, cmap=cmap_div, linewidths=0.5, square=True) axs[0].set(title="Real") diff --git a/tests/test_utils/test_density_matrix_visualizer.py b/tests/test_utils/test_density_matrix_visualizer.py index bab4b355..942b70de 100644 --- a/tests/test_utils/test_density_matrix_visualizer.py +++ b/tests/test_utils/test_density_matrix_visualizer.py @@ -17,7 +17,11 @@ import pytest from mpl_toolkits.mplot3d import Axes3D -from graphiq.visualizers.density_matrix import cmap_div, density_matrix_bars +from graphiq.visualizers.density_matrix import ( + cmap_div, + density_matrix_bars, + density_matrix_heatmap, +) # Row-major entries 0.4, -0.1, -0.4, 0. The largest magnitude is 0.4, so the real # panel's range is [-0.5, 0.5] and entry x sits at 0.5 + x on the colour map. @@ -78,3 +82,57 @@ def test_bar_alpha_is_unchanged(bar_colours): real[:, 3], 1 - 0.5 * np.abs([0.4, -0.1, -0.4, 0.0]) / 0.5 ) np.testing.assert_array_equal(imag[:, 3], np.ones(4)) + + +def test_heatmap_returns_the_figure_of_the_axes_it_is_given(): + fig, axs = plt.subplots(nrows=1, ncols=2) + try: + returned_fig, returned_axs = density_matrix_heatmap(RHO, axs=axs) + assert returned_fig is fig + assert returned_axs is axs + finally: + plt.close(fig) + + +def test_heatmap_given_a_tuple_of_axes_returns_their_figure_and_the_tuple(): + fig, axs = plt.subplots(nrows=1, ncols=2) + axs = tuple(axs) + try: + returned_fig, returned_axs = density_matrix_heatmap(RHO, axs=axs) + assert returned_fig is fig + assert returned_axs is axs + finally: + plt.close(fig) + + +def test_heatmap_given_axes_in_a_subfigure_returns_the_root_figure(): + fig = plt.figure() + axs = fig.subfigures(1, 1).subplots(1, 2) + try: + returned_fig, _ = density_matrix_heatmap(RHO, axs=axs) + assert returned_fig is fig + finally: + plt.close(fig) + + +def test_heatmap_given_axes_from_two_figures_returns_the_first_axes_figure(): + fig_real, ax_real = plt.subplots() + fig_imag, ax_imag = plt.subplots() + try: + returned_fig, _ = density_matrix_heatmap(RHO, axs=[ax_real, ax_imag]) + assert returned_fig is fig_real + # the imaginary heatmap is still drawn there + assert len(ax_imag.collections) == 1 + finally: + plt.close(fig_real) + plt.close(fig_imag) + + +def test_heatmap_without_axes_still_opens_a_new_two_panel_figure(): + before = set(plt.get_fignums()) + fig, axs = density_matrix_heatmap(RHO) + try: + assert fig.number not in before + assert [ax.figure for ax in axs] == [fig, fig] + finally: + plt.close(fig) From 23199ade55e70e2afec4d05f3e56667c47df9758 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 17:15:04 -0400 Subject: [PATCH 209/227] [fix] Walk linear_partial_orbit's local complementations along the path _partial_orbit gives fixed local-complementation sequences for the path 0-1-...-(n-1): an index x means the x-th node along the path. linear_partial_orbit passed each x straight to local_comp_graph, which reads it as the x-th node in the graph's iteration order. The two agree only when the path runs through the nodes in iteration order. For any other path, the wrong nodes were complemented: the first graph could differ from the input, and the list could repeat graphs. Walk the path from the end that comes first in iteration order and map each index to that node's position. Also require the graph to be connected: the existing check (n - 1 edges, maximum degree 2) accepted a shorter path plus disjoint cycles, which is not a linear cluster state, and now raises the same "input graph is not a linear graph" AssertionError. The docstring states both. For a path already in iteration order, including nx.path_graph(n), the output is unchanged, graphs and labels alike. For a path in another order, the output is the in-order orbit carried along the path, labelled 0..n-1 by position as before, with the input first and no repeats. The only caller is AlternateTargetSolver with lc_method="linear", which is not the default, was used nowhere in the package, its benchmarks or examples, and had no test, so no default or in-tree result changes. With that setting, candidate graphs change for iso graphs or targets not in path order; with allow_relabel=True that is typically every relabelling after the first. --- graphiq/utils/relabel_module.py | 22 +++-- .../test_alternate_target_solver.py | 21 ++++- tests/test_utils/test_relabel_module.py | 84 ++++++++++++++++++- 3 files changed, 117 insertions(+), 10 deletions(-) diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 5d85fd32..65770f78 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -475,14 +475,14 @@ def linear_partial_orbit(graph: nx.Graph): """ Takes a linear cluster state, and returns a list of distinct graphs in the orbit. Not exhaustive. The first graph in the list is the original graph state. - :param graph: original graph, a path that runs through its nodes in iteration order (the first node in - iteration order is one end, the next is its neighbour, and so on); node labels are never read. On a path - in another order the graphs are still in its LC orbit, but the first need not be the original and they - need not be distinct + :param graph: original graph, a path (linear graph) whose nodes may come in any iteration order and carry any + labels; the local complementations are applied along the path, from whichever end comes first in graph's + node iteration order :type graph: nx.Graph + :raises AssertionError: if graph is not a path on at least 3 nodes :return: a list of graphs in the LC orbit of the linear cluster state, labelled 0..n-1 by position in graph's - iteration order, so the first is literally the original graph only when graph's labels are already 0..n-1 - in iteration order + iteration order, so the first is the original graph relabelled that way (literally the original graph when + graph's labels are already 0..n-1 in iteration order) :rtype: list """ orbit_list = [] @@ -491,12 +491,18 @@ def linear_partial_orbit(graph: nx.Graph): # check that graph is linear degrees = [degree for _, degree in g.degree()] assert ( - n - 1 == graph.size() and max(degrees) == 2 + n - 1 == graph.size() and max(degrees) == 2 and nx.is_connected(g) ), "input graph is not a linear graph" + # _partial_orbit indexes nodes along the path, but local_comp_graph reads positions in + # iteration order, so map each path index to the position of that node, walking the path + # from the end that comes first in iteration order + position = {node: i for i, node in enumerate(g)} + start = next(node for node in g if g.degree(node) == 1) + path = [position[node] for node in nx.dfs_preorder_nodes(g, start)] for lc_ops in _partial_orbit(n): new_g = g for x in lc_ops: - new_g = local_comp_graph(new_g, x) + new_g = local_comp_graph(new_g, path[x]) orbit_list.append(new_g) return orbit_list diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index 0a8c79c9..962ca74a 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -18,7 +18,11 @@ from graphiq.backends.stabilizer.functions.transformation import run_circuit from graphiq.benchmarks.graph_states import linear_cluster_state from graphiq.solvers.alternate_target_solver import * -from graphiq.utils.relabel_module import _label_finder, automorph_check +from graphiq.utils.relabel_module import ( + _label_finder, + _partial_orbit, + automorph_check, +) linear_cluster = nx.from_numpy_array(nx.to_numpy_array(linear_cluster_state(4).data)) @@ -200,6 +204,21 @@ def test_allow_relabel_false_searches_only_the_given_labelling(): assert {i: properties["map"][i] for i in range(n)} == {i: i for i in range(n)} +def test_linear_lc_method_on_a_path_not_in_iteration_order(): + # nodes 0..5 inserted in order, path 2-0-4-1-5-3 + order = [2, 0, 4, 1, 5, 3] + target = nx.Graph() + target.add_nodes_from(range(6)) + target.add_edges_from(zip(order, order[1:])) + setting = AlternateTargetSolverSetting(n_iso_graphs=1, n_lc_graphs=10) + setting.allow_relabel = False + setting.lc_method = "linear" + solver = AlternateTargetSolver(target=target, solver_setting=setting, seed=1) + results = solver.solve() + candidates = {frozenset(map(frozenset, r[1]["g"].edges())) for r in results} + assert len(candidates) == len(_partial_orbit(6)) + + def test_allow_lc_false_keeps_the_isomorphic_graph_unchanged(): setting = AlternateTargetSolverSetting(n_iso_graphs=1, n_lc_graphs=4) setting.allow_relabel = False diff --git a/tests/test_utils/test_relabel_module.py b/tests/test_utils/test_relabel_module.py index b8f8fbfc..490a4d17 100644 --- a/tests/test_utils/test_relabel_module.py +++ b/tests/test_utils/test_relabel_module.py @@ -19,10 +19,15 @@ import numpy as np import pytest +from graphiq.backends.lc_equivalence_check import local_comp_graph from graphiq.backends.stabilizer.functions.height import height_max from graphiq.benchmarks.graph_states import linear_cluster_state from graphiq.utils.relabel_module import * -from graphiq.utils.relabel_module import _compare_graphs_visual, _label_finder +from graphiq.utils.relabel_module import ( + _compare_graphs_visual, + _label_finder, + _partial_orbit, +) def random_connected_graph(n, p, seed=None): @@ -187,3 +192,80 @@ def test_compare_graphs_visual_rejects_repeated_labels(monkeypatch): _compare_graphs_visual(g, new_g, new_labels) finally: plt.close("all") + + +def _edge_set(g): + return {frozenset(e) for e in g.edges} + + +def _path_in_order(order, nodes=None): + # nodes are inserted in sorted order (or as given), then the edges run along order + g = nx.Graph() + g.add_nodes_from(sorted(order) if nodes is None else nodes) + g.add_edges_from(zip(order, order[1:])) + return g + + +@pytest.mark.parametrize( + "order", [[1, 0, 2, 3], [0, 2, 1, 4, 3], [2, 0, 4, 1, 5, 3], [3, 6, 0, 5, 1, 4, 2]] +) +def test_linear_partial_orbit_follows_a_path_not_in_iteration_order(order): + g = _path_in_order(order) + orbit = linear_partial_orbit(g) + assert len(orbit) == len(_partial_orbit(len(g))) + assert _edge_set(orbit[0]) == _edge_set(g) + assert len({frozenset(_edge_set(h)) for h in orbit}) == len(orbit) + for i, a in enumerate(orbit): + for b in orbit[:i]: + assert not nx.is_isomorphic(a, b) + + +@pytest.mark.parametrize( + "order", [[1, 0, 2, 3], [2, 0, 4, 1, 5, 3], [3, 6, 0, 5, 1, 4, 2]] +) +def test_linear_partial_orbit_matches_the_in_order_orbit_carried_along_the_path(order): + # the reference applies _partial_orbit's sequences to the path 0-1-...-(n-1) and + # carries the result onto the path walked from the end that comes first in + # iteration order + n = len(order) + reference = [] + for lc_ops in _partial_orbit(n): + r = nx.path_graph(n) + for x in lc_ops: + r = local_comp_graph(r, x) + reference.append(r) + walk = order if order[0] < order[-1] else order[::-1] + orbit = linear_partial_orbit(_path_in_order(order)) + assert [_edge_set(h) for h in orbit] == [ + _edge_set(nx.relabel_nodes(r, dict(enumerate(walk)))) for r in reference + ] + + +def test_linear_partial_orbit_labels_by_position_for_other_labels(): + nodes = ["c", "a", "d", "b"] + # the path a-c-d-b runs through positions 1-0-2-3 + g = _path_in_order(["a", "c", "d", "b"], nodes) + orbit = linear_partial_orbit(g) + assert list(orbit[0].nodes) == [0, 1, 2, 3] + assert _edge_set(orbit[0]) == _edge_set( + nx.relabel_nodes(g, {v: i for i, v in enumerate(nodes)}) + ) + + +def test_linear_partial_orbit_in_order_path_is_unchanged(): + orbit = linear_partial_orbit(nx.path_graph(5)) + assert [sorted(tuple(sorted(e)) for e in h.edges) for h in orbit] == [ + [(0, 1), (1, 2), (2, 3), (3, 4)], + [(0, 1), (0, 2), (1, 2), (2, 3), (3, 4)], + [(0, 2), (0, 3), (1, 2), (1, 3), (2, 3), (3, 4)], + [(0, 2), (0, 3), (1, 2), (1, 3), (3, 4)], + [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)], + [(0, 1), (0, 2), (0, 3), (2, 3), (3, 4)], + ] + + +def test_linear_partial_orbit_rejects_a_path_plus_a_cycle(): + # n - 1 edges and maximum degree 2, but not connected + g = nx.Graph([(0, 1), (1, 2), (2, 3), (4, 5), (5, 6), (6, 4)]) + with pytest.raises(AssertionError, match="not a linear graph"): + linear_partial_orbit(g) From 168c44aab568d9f2f01eb04d6b9eb99ae09b4798 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 18:07:19 -0400 Subject: [PATCH 210/227] [style] Format metrics.py with black 24 CI's lint job runs black 24 --check on graphiq/. It rejects the parenthesised n_cnot_penalty default in CircuitCnotCount.__init__: the line fits on one line since the attribute was renamed from n_emitter_penalty, so black joins it. Join the line as black 24 does. Formatting only: the code, the comment and every result are unchanged, and black 24 --check now passes on all of graphiq/. --- graphiq/metrics.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/graphiq/metrics.py b/graphiq/metrics.py index d7675eee..5cd196c0 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -360,9 +360,7 @@ def __init__(self, log_steps=1, n_cnot_penalty=None, *args, **kwargs): super().__init__(log_steps=log_steps, *args, **kwargs) self.differentiable = False if n_cnot_penalty is None: - self.n_cnot_penalty = ( - lambda x: x - ) # by default, the number of CNOTs itself + self.n_cnot_penalty = lambda x: x # by default, the number of CNOTs itself else: self.n_cnot_penalty = n_cnot_penalty From d1627259b48836f6eddf2853c99924ad734e39ee Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 18:07:19 -0400 Subject: [PATCH 211/227] [test] Keep test_benchmark_run_graph_search_solver's output in tmp_path test_benchmark_run_graph_search_solver built its IO with no path, so IO fell back to its default folder: data/ in the checkout for a source or editable install, or /data for a regular install. Every run left a dated benchmarks folder there, holding solver_result.csv and a circuits folder, and nothing removed it. data/ is gitignored, so the folders piled up unseen. Pass path=tmp_path, as the neighbouring benchmark_run tests do, and assert that solver_result.csv is saved under tmp_path. That assertion fails if the IO falls back to the default folder again. No source changes. --- tests/test_benchmarking/test_pipeline.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_benchmarking/test_pipeline.py b/tests/test_benchmarking/test_pipeline.py index 970a9638..fe48e241 100644 --- a/tests/test_benchmarking/test_pipeline.py +++ b/tests/test_benchmarking/test_pipeline.py @@ -36,7 +36,7 @@ from graphiq.benchmarks.pipeline import benchmark_run, benchmark_run_graph_search_solver -def test_benchmark_run_graph_search_solver(): +def test_benchmark_run_graph_search_solver(tmp_path): target_graph = linear_cluster_state(4) target_graph = target_graph.data target_tableau = get_clifford_tableau_from_graph(target_graph) @@ -82,11 +82,17 @@ def test_benchmark_run_graph_search_solver(): "compiler": compiler, } io = IO.new_directory( - folder="benchmarks", include_date=True, include_time=True, include_id=False + path=tmp_path, + folder="benchmarks", + include_date=True, + include_time=True, + include_id=False, ) data = benchmark_run_graph_search_solver(example_run, "test_pipeline", io) assert type(data) == dict + # the run saves its results under tmp_path, not the default data/ folder + assert (tmp_path / io.path.name / "solver_result.csv").is_file() def test_benchmark_run_records_metric_name(monkeypatch, tmp_path): From 9dd9583c2bfbcd1b9e87b4b228fb6aa6c428c61f Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 18:07:19 -0400 Subject: [PATCH 212/227] [fix] Derive QuantumState.n_qubits from its representation QuantumState.n_qubits was stored once in __init__ and never updated. After partial_trace took a 4-qubit state to 3 it still reported 4, while rep_data reported 3. The same happened after resizing the live representation directly, e.g. state.rep_data.remove_qubit(0) or state.rep_data.add_node(...). The stale count broke two things: the rep_data setter's width asserts compared incoming data against it, so assigning a traced state's own data back raised AssertionError while data of the old, pre-trace width was accepted; and compile(circuit, initial_state=state) rejected a traced state whose data was exactly the circuit's width. n_qubits is now a read-only property returning rep_data.n_qubits, so it cannot go stale. DensityMatrix gains the n_qubits property the other representations already had. __init__ keeps the count from validate_data in a local, and the representation helpers take the expected width as an argument: __init__ passes that count, and the rep_data setter passes the state's current width. The width asserts themselves, their messages and their exception types are unchanged. Behaviour changes: - QuantumState.n_qubits reports the representation's current width after partial_trace or any resize made through rep_data. - QuantumState.n_qubits is read-only: assigning to it raises AttributeError. Before, the assignment was accepted and desynchronised the count from the data. Nothing in graphiq assigned it outside __init__. - After a resize, the rep_data setter accepts data of the new width and rejects data of the old width with AssertionError (the reverse of before). A state that was never resized is checked as before. - An existing Stabilizer or MixedStabilizer object assigned through rep_data is still adopted without a width check; the state's n_qubits now follows its width. - compile(circuit, initial_state=state) accepts a traced state for a circuit of its new width, and rejects it with "the number of qubits in initial state must match the circuit" for its old width. Before, the old width was accepted: an empty circuit returned a state narrower than the circuit, and a gate on a missing qubit failed later. - The "Density matrix is not recommended" warnings, in the rep_data setter and in convert_representation, are judged on the current width rather than the construction-time count. - Pickles and copies report the current width; n_qubits is no longer an instance attribute. A pickle made before this change still loads and reports the current width. - DensityMatrix.n_qubits is new. A 9x9 density matrix is still rejected with AssertionError, at construction and through the setter, and malformed setter input fails as before. Results of code paths that never resize a state are unchanged. --- graphiq/backends/density_matrix/state.py | 11 ++ graphiq/state.py | 82 ++++++---- .../test_quantum_state.py | 142 ++++++++++++++++++ 3 files changed, 205 insertions(+), 30 deletions(-) diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index fc5f6103..f8d51512 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -110,6 +110,17 @@ def valid_datatype(cls, data): """ return isinstance(data, (int, np.ndarray)) + @property + def n_qubits(self): + """ + Number of qubits represented by this density matrix, derived from + the matrix dimension, assuming qubits (dimension 2 per subsystem) + + :return: the number of qubits + :rtype: int + """ + return int(np.log2(self.data.shape[0])) + @property def trace(self): """ diff --git a/graphiq/state.py b/graphiq/state.py index b038cfb5..43ee87b0 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -104,7 +104,7 @@ def __init__(self, data, rep_type=None, mixed=False): :type mixed: boolean :raises TypeError: if validate_data rejects data :raises AssertionError: if data is a non-square numpy.ndarray, or if the initialized - representation does not have the expected number of qubits + representation does not have the number of qubits validate_data reports for data :raises ValueError: if rep_type is not a supported representation name, or if rep_type is None and no representation can be chosen for data (including a numpy.ndarray with too many qubits for the density matrix) @@ -114,7 +114,7 @@ def __init__(self, data, rep_type=None, mixed=False): self.mixed = mixed self._rep_type = self._get_rep_type_name(rep_type) - valid, self.n_qubits = self.validate_data(data) + valid, n_qubits = self.validate_data(data) if not valid: raise TypeError( @@ -124,17 +124,16 @@ def __init__(self, data, rep_type=None, mixed=False): # record the detected representation's name: show, # convert_representation and the metrics and solvers read rep_type # to dispatch - if ( - self.n_qubits < DENSITY_MATRIX_QUBIT_THRESH - and DensityMatrix.valid_datatype(data) + if n_qubits < DENSITY_MATRIX_QUBIT_THRESH and DensityMatrix.valid_datatype( + data ): - self._rep_data = self._initialize_dm(data) + self._rep_data = self._initialize_dm(data, n_qubits) self._rep_type = "dm" elif Stabilizer.valid_datatype(data): - self._rep_data = self._initialize_stabilizer(data) + self._rep_data = self._initialize_stabilizer(data, n_qubits) self._rep_type = "s" elif Graph.valid_datatype(data): - self._rep_data = self._initialize_graph(data) + self._rep_data = self._initialize_graph(data, n_qubits) self._rep_type = "g" elif DensityMatrix.valid_datatype(data): raise ValueError( @@ -147,7 +146,7 @@ def __init__(self, data, rep_type=None, mixed=False): ) elif isinstance(rep_type, str): - self._rep_data = self._initialize_representation(rep_type, data) + self._rep_data = self._initialize_representation(rep_type, data, n_qubits) else: raise ValueError("passed rep_type argument must be a String") @@ -167,10 +166,11 @@ def rep_data(self, data): """ Replace the representation data, rebuilding it as the current rep_type - The width is not changed: n_qubits keeps its value, and a density matrix, graph or - newly built stabilizer representation of a different size raises AssertionError. An - existing Stabilizer (or, when the state is mixed, MixedStabilizer) object is adopted as - is, without that check. + The new data must have the state's current width, n_qubits, which is read off the + representation being replaced: a density matrix, graph or newly built stabilizer + representation of a different size raises AssertionError. An existing Stabilizer (or, + when the state is mixed, MixedStabilizer) object is adopted as is, without that check, + and its width becomes the state's width. :param data: data valid for the current rep_type :type data: int or numpy.ndarray or CliffordTableau or list[(float, CliffordTableau)] or nx.Graph @@ -182,7 +182,9 @@ def rep_data(self, data): :return: nothing :rtype: None """ - self._rep_data = self._initialize_representation(self._rep_type, data) + self._rep_data = self._initialize_representation( + self._rep_type, data, self.n_qubits + ) @property def rep_type(self): @@ -203,6 +205,17 @@ def rep_type(self): def rep_type(self, new_rep_type): self._rep_type = self._get_rep_type_name(new_rep_type) + @property + def n_qubits(self): + """ + Number of qubits in the state, derived from the current representation, so it follows + any change of width, such as a partial_trace or a resize made through rep_data + + :return: the number of qubits + :rtype: int + """ + return self._rep_data.n_qubits + def partial_trace(self, keep, dims): r""" Calculates the partial trace on all state representations which are currently defined @@ -261,13 +274,15 @@ def show(self, show=True, ax=None): return fig, ax - def _initialize_dm(self, data): + def _initialize_dm(self, data, n_qubits): """ Initializes a density matrix based on the data :param data: either a graph or ndarray matrix :type data: Graph OR nx.Graph OR numpy.ndarray - :raises AssertionError: if the density matrix being initialized does not have self.n_qubits + :param n_qubits: the number of qubits the density matrix must have + :type n_qubits: int + :raises AssertionError: if the density matrix being initialized does not have n_qubits :return: the density matrix representation :rtype: DensityMatrix """ @@ -276,33 +291,38 @@ def _initialize_dm(self, data): else: dm = DensityMatrix(data) - assert dm.data.shape[0] == dm.data.shape[1] == 2**self.n_qubits + assert dm.data.shape[0] == dm.data.shape[1] == 2**n_qubits return dm - def _initialize_graph(self, data): + def _initialize_graph(self, data, n_qubits): """ Initializes a graph state based on the data :param data: data to construct the Graph representation :type data: networkX.Graph - :raises AssertionError: if the graph being initialized does not have self.n_qubits + :param n_qubits: the number of qubits the graph must have + :type n_qubits: int + :raises AssertionError: if the graph being initialized does not have n_qubits :return: the graph representation :rtype: Graph """ graph = Graph(data) - assert graph.n_qubits == self.n_qubits, ( - f"Expected {self.n_qubits} qubits, " f"graph rep_type has {graph.n_qubits}" + assert graph.n_qubits == n_qubits, ( + f"Expected {n_qubits} qubits, " f"graph rep_type has {graph.n_qubits}" ) return graph - def _initialize_stabilizer(self, data): + def _initialize_stabilizer(self, data, n_qubits): """ Initializes a stabilizer state based on the data :param data: data to construct the stabilizer state representation :type data: Stabilizer or int or CliffordTableau or MixedStabilizer or list[(float, CliffordTableau)] - :raises AssertionError: if the stabilizer being initialized does not have self.n_qubits + :param n_qubits: the number of qubits a newly built stabilizer must have; an existing + Stabilizer or MixedStabilizer object is returned as is, without that check + :type n_qubits: int + :raises AssertionError: if the stabilizer being initialized does not have n_qubits :return: a MixedStabilizer if self.mixed is True, a Stabilizer otherwise :rtype: Stabilizer or MixedStabilizer """ @@ -317,13 +337,13 @@ def _initialize_stabilizer(self, data): else: stabilizer = MixedStabilizer(data) - assert stabilizer.n_qubits == self.n_qubits, ( - f"Expected {self.n_qubits} qubits, " + assert stabilizer.n_qubits == n_qubits, ( + f"Expected {n_qubits} qubits, " f"Stabilizer representation has {stabilizer.n_qubits}" ) return stabilizer - def _initialize_representation(self, rep_type, data): + def _initialize_representation(self, rep_type, data, n_qubits): """ Helper function to initialize any given representation @@ -331,22 +351,24 @@ def _initialize_representation(self, rep_type, data): :type rep_type: str :param data: data with which the rep_type should be initialized :type data: int OR Graph OR nx.Graph OR numpy.ndarray OR CliffordTableau OR Stabilizer OR MixedStabilizer + :param n_qubits: the number of qubits the representation must have + :type n_qubits: int :raises ValueError: if rep_type is invalid :return: the initialized representation :rtype: DensityMatrix or Graph or Stabilizer or MixedStabilizer """ if rep_type in ("dm", "density matrix"): - if self.n_qubits > DENSITY_MATRIX_QUBIT_THRESH: + if n_qubits > DENSITY_MATRIX_QUBIT_THRESH: warnings.warn( UserWarning( "Density matrix is not recommended for a state of this size" ) ) - return self._initialize_dm(data) + return self._initialize_dm(data, n_qubits) elif rep_type in ("g", "graph"): - return self._initialize_graph(data) + return self._initialize_graph(data, n_qubits) elif rep_type in ("s", "stab", "stabilizer"): - return self._initialize_stabilizer(data) + return self._initialize_stabilizer(data, n_qubits) else: raise ValueError("Passed rep_type is invalid") diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 4819afc1..852bd4fa 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -408,3 +408,145 @@ def test_constructor_rejects_a_list_of_networkx_graphs_with_type_error(): def test_validate_data_accepts_a_list_of_clifford_tableaux(): assert QuantumState.validate_data([(1.0, CliffordTableau(3))]) == (True, 3) + + +# QuantumState.n_qubits used to be stored once in __init__ and never updated, so a traced or +# otherwise resized state kept reporting its old width while the representation reported the +# real one. It is now derived from the representation, and the rep_data setter checks the +# incoming data against that live width. + + +def _traced_state(rep_type, mixed=False): + """A 4-qubit state traced down to its first 3 qubits.""" + state = QuantumState(4, rep_type=rep_type, mixed=mixed) + state.partial_trace(keep=[0, 1, 2], dims=4 * [2]) + return state + + +@pytest.mark.parametrize("rep_type", ["dm", "s"]) +def test_n_qubits_follows_a_partial_trace(rep_type): + assert _traced_state(rep_type).n_qubits == 3 + + +def test_n_qubits_follows_a_partial_trace_on_a_mixed_stabilizer(): + # Reaches the count through mixture[0][1], a different path from the pure case. + assert _traced_state("s", mixed=True).n_qubits == 3 + + +def test_n_qubits_follows_a_resize_made_through_the_representation(): + # rep_data hands out the live representation, so resizing it bypasses QuantumState. A count + # updated only in partial_trace would still go stale here. + state = QuantumState(4, rep_type="s") + + state.rep_data.remove_qubit(0) + + assert state.n_qubits == 3 + + +@pytest.mark.parametrize( + "rep_type, mixed", + [("dm", False), ("s", False), ("s", True)], + ids=["dm", "s", "mixed s"], +) +def test_a_traced_state_accepts_its_own_data_back(rep_type, mixed): + # Raw data, not the representation object, so the assignment is rebuilt and goes through + # the width check, which used to compare it against the pre-trace count. + state = _traced_state(rep_type, mixed=mixed) + rep = state.rep_data + + state.rep_data = rep.mixture if mixed else rep.data + + assert state.n_qubits == 3 + + +@pytest.mark.parametrize("rep_type", ["dm", "s"]) +def test_a_traced_state_rejects_data_of_its_old_width(rep_type): + state = _traced_state(rep_type) + + with pytest.raises(AssertionError): + state.rep_data = np.eye(16, dtype=complex) / 16 if rep_type == "dm" else 4 + + +@pytest.mark.parametrize( + "n_nodes, accepted", [(4, True), (3, False)], ids=["new width", "old width"] +) +def test_a_graph_state_grown_in_place_checks_against_its_new_width(n_nodes, accepted): + state = QuantumState(nx.path_graph(3), rep_type="g") + state.rep_data.add_node(3) + + if accepted: + state.rep_data = nx.path_graph(n_nodes) + else: + with pytest.raises(AssertionError): + state.rep_data = nx.path_graph(n_nodes) + + assert state.n_qubits == 4 + + +def test_assigning_a_wider_density_matrix_is_rejected(): + state = QuantumState(np.eye(8, dtype=complex) / 8, rep_type="dm") + + with pytest.raises(AssertionError): + state.rep_data = np.eye(32, dtype=complex) / 32 + + assert state.n_qubits == 3 + + +def test_n_qubits_is_read_only(): + state = QuantumState(3, rep_type="s") + + with pytest.raises(AttributeError): + state.n_qubits = 5 + + +@pytest.mark.parametrize( + "assign", [False, True], ids=["at construction", "through the setter"] +) +def test_a_non_power_of_two_density_matrix_is_rejected(assign): + bad = np.eye(9, dtype=complex) / 9 + + if assign: + state = QuantumState(np.eye(8, dtype=complex) / 8, rep_type="dm") + with pytest.raises(AssertionError): + state.rep_data = bad + else: + with pytest.raises(AssertionError): + QuantumState(bad, rep_type="dm") + + +def _emitter_circuit(n_emitter, hadamards): + from graphiq.circuit.circuit_dag import CircuitDAG + import graphiq.circuit.ops as ops + + circuit = CircuitDAG(n_emitter=n_emitter, n_photon=0, n_classical=0) + for register in hadamards: + circuit.add(ops.Hadamard(register=register, reg_type="e")) + return circuit + + +def test_a_traced_state_can_seed_a_circuit_of_its_new_width(): + # CompilerBase asserts initial_state.n_qubits == circuit.n_quantum, so a stale count rejected + # a state whose data was exactly the circuit's width. The Hadamard applied before the trace + # shows the compiled state really started from initial_state. + from graphiq.backends.stabilizer.compiler import StabilizerCompiler + + state = QuantumState(4, rep_type="s") + state.rep_data.apply_hadamard(0) + state.partial_trace(keep=[0, 1, 2], dims=4 * [2]) + + compiled = StabilizerCompiler().compile( + _emitter_circuit(3, [2]), initial_state=state + ) + expected = StabilizerCompiler().compile(_emitter_circuit(3, [0, 2])) + + assert compiled.n_qubits == 3 + assert compiled.rep_data.data == expected.rep_data.data + + +def test_a_traced_state_cannot_seed_a_circuit_of_its_old_width(): + from graphiq.backends.stabilizer.compiler import StabilizerCompiler + + with pytest.raises(AssertionError, match="must match the circuit"): + StabilizerCompiler().compile( + _emitter_circuit(4, []), initial_state=_traced_state("s") + ) From 68c5957fcf2de558e746e9fb7e0f123614c63161 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:28:49 -0400 Subject: [PATCH 213/227] [fix] Place crazy's alternate-order positions by the relabelling the graph gets With alternate_order=True, crazy relabels its graph with relabel(adj, nodes), which sends old node i to new label nodes[i]. The position dict was built the other way round, giving new label i the position of old node nodes[i]. The two agree only when every swapped pair of columns has the same size. Otherwise pos=True placed some nodes in the wrong column: for n_list [2, 3], three of the six edges were drawn within a single column. Give node nodes[i] the position that node i has in the plain layout. The graph itself is unchanged for every input, so its labelling and emission order stay as they were. Only the positions returned with pos=True and alternate_order=True change, and only when a swapped column pair has unequal sizes. Nothing in the package calls crazy with pos=True, so no in-tree result changes. --- graphiq/benchmarks/graph_states.py | 2 +- tests/test_benchmarking/test_graph_states.py | 37 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/graphiq/benchmarks/graph_states.py b/graphiq/benchmarks/graph_states.py index c9a901ff..36eef9c3 100644 --- a/graphiq/benchmarks/graph_states.py +++ b/graphiq/benchmarks/graph_states.py @@ -274,7 +274,7 @@ def crazy(n_list: list, pos=False, alternate_order=False): g = nx.from_numpy_array(new_adj) pos_dict2 = {} for i, n in enumerate(range(len(pos_dict))): - pos_dict2[n] = pos_dict[nodes[i]] + pos_dict2[nodes[i]] = pos_dict[i] pos_dict = pos_dict2 if pos: return g, pos_dict diff --git a/tests/test_benchmarking/test_graph_states.py b/tests/test_benchmarking/test_graph_states.py index a8c62c36..2d3a1296 100644 --- a/tests/test_benchmarking/test_graph_states.py +++ b/tests/test_benchmarking/test_graph_states.py @@ -73,6 +73,43 @@ def test_crazy(n_list): assert nx.is_isomorphic(graph, alternate_graph) +@pytest.mark.parametrize("n_list", [[1, 2], [2, 3], [2, 3, 1], [3, 1, 2, 2], [3, 3, 3]]) +def test_crazy_alternate_order_positions_put_each_edge_between_adjacent_columns( + n_list, +): + graph, pos = gs.crazy(n_list, pos=True, alternate_order=True) + assert sorted(pos) == list(range(sum(n_list))) + # no two nodes are drawn at the same place + assert len({tuple(xy) for xy in pos.values()}) == len(pos) + for x, n in enumerate(n_list): + assert sum(1 for xy in pos.values() if xy[0] == x) == n + # a crazy graph only has edges between consecutive columns + for u, v in graph.edges(): + assert abs(pos[u][0] - pos[v][0]) == 1 + + +def test_crazy_alternate_order_keeps_its_graph_labelling(): + # pins the existing labelling of the alternate-order graph, which positions + # must follow, not an intended emission order + def edges(graph): + return sorted(tuple(sorted(e)) for e in graph.edges()) + + graph, _ = gs.crazy([2, 3], pos=True, alternate_order=True) + assert edges(graph) == [(0, 2), (0, 3), (1, 2), (1, 3), (2, 4), (3, 4)] + graph, _ = gs.crazy([1, 2], pos=True, alternate_order=True) + assert edges(graph) == [(0, 1), (1, 2)] + + +def test_crazy_alternate_order_positions_for_equal_column_pairs_are_unchanged(): + _, pos = gs.crazy([2, 2], pos=True, alternate_order=True) + assert {k: tuple(v) for k, v in pos.items()} == { + 0: (1, 0), + 1: (1, -1), + 2: (0, 0), + 3: (0, -1), + } + + @pytest.mark.parametrize("row_col", [(2, 2), (2, 3), (3, 3), (3, 4)]) def test_two_d_cluster(row_col): graph = gs.two_d_cluster(row_col) From 7193fc5678cd840484629cb110700624691a65e3 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:28:49 -0400 Subject: [PATCH 214/227] [fix] Reject an unknown criteria string in section_finder section_finder documents a ValueError for an invalid criteria, but it raised one only when criteria was neither a string nor a list. A string that named none of its options, such as the typo "reg_as_ctrl", matched no branch and silently left the path unsliced, as a single section. A NoisyEnsemble given that criteria in its noise parameters then built its event trees from that one section, with no error, and could score a different infidelity than the intended criteria would have. Raise the same ValueError for an unknown string. The five valid strings and a list of op classes behave as before, so no result from a valid criteria changes. --- graphiq/noise/time_depend_noise.py | 5 ++++ tests/test_noise/test_time_depend_noise.py | 34 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/graphiq/noise/time_depend_noise.py b/graphiq/noise/time_depend_noise.py index 651d6c55..9df064a1 100644 --- a/graphiq/noise/time_depend_noise.py +++ b/graphiq/noise/time_depend_noise.py @@ -503,6 +503,11 @@ def section_finder(circ, ordered_path, criteria, reg=0, reg_type="e"): slice_nodes = reg_as_control(circ, ordered_path, reg, reg_type) elif criteria == "all_gates": slice_nodes = ordered_path + else: + raise ValueError( + "criteria should be either a list of class types in graphiq.ops or one of the strings: measure, " + "emission, multi_reg_gates, reg_as_control, all_gates" + ) elif isinstance(criteria, list): slice_nodes = [ nod for nod in ordered_path if type(circ.dag.nodes[nod]["op"]) in criteria diff --git a/tests/test_noise/test_time_depend_noise.py b/tests/test_noise/test_time_depend_noise.py index 56602445..38c1a774 100644 --- a/tests/test_noise/test_time_depend_noise.py +++ b/tests/test_noise/test_time_depend_noise.py @@ -126,3 +126,37 @@ def test_noise_type_list_falls_back_to_default_noise_type_str_value(): noise_parameters={"noise_type": "xz"}, ) assert noisy_ensemble.noise_type_list((1, "e")) == ["xz"] + + +@pytest.mark.parametrize("criteria", ["reg_as_ctrl", "measurement", "all gates", ""]) +def test_section_finder_rejects_an_unknown_criteria_string(criteria): + circ, _ = linear_cluster_4qubit_circuit() + path = tdn.NoisyEnsemble.reg_history(circ, 0, "e") + with pytest.raises(ValueError, match="criteria should be"): + tdn.section_finder(circ, path, criteria) + + +def test_noisy_ensemble_with_an_unknown_criteria_string_raises(): + circ, _ = linear_cluster_4qubit_circuit() + noisy = tdn.NoisyEnsemble( + circ, [(0, "e")], noise_parameters={"criteria": "reg_as_ctrl"} + ) + with pytest.raises(ValueError, match="criteria should be"): + noisy.all_branches() + + +@pytest.mark.parametrize( + "criteria, section_lengths", + [ + ("measure", [10, 1]), + ("reg_as_control", [3, 2, 2, 1, 2, 1]), + ("all_gates", [1] * 11), + ], +) +def test_section_finder_keeps_the_sections_of_a_valid_criteria( + criteria, section_lengths +): + circ, _ = linear_cluster_4qubit_circuit() + path = tdn.NoisyEnsemble.reg_history(circ, 0, "e") + sections = tdn.section_finder(circ, path, criteria) + assert [len(s) for s in sections] == section_lengths From 44fe525d57bbc67bc96f997ae03cb0d157bd64af Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:35:58 -0400 Subject: [PATCH 215/227] [docs] Correct backend docstrings that contradict the code Fifteen docstring lines in the backends described behaviour the code does not have: - bipartite_partial_transpose raises ValueError when subsys is neither 0 nor 1; it never checks whether the matrix is bipartite. - project_and_remove's rho was described as the matrix whose negativity is evaluated, copied from negativity. - DensityMatrixCompiler.compile_one_noisy_gate takes the QuantumState, as compile_one_gate does, not a DensityMatrix. - graph_to_density raises TypeError for a graphiq Graph; it accepts a networkx graph, an adjacency matrix or a list of (probability, graph) pairs. - is_lc_equivalent and Graph.lc_equivalent return a (found, solution) pair, not an array, and Graph.lc_equivalent takes one other Graph. In 'random' mode False only means no solution was found, which Graph.lc_equivalent's return line now says as is_lc_equivalent's does. - _solution_basis_finder returns the solution-space basis vectors, _col_finder a list, and lc_graph_operations a list of node indices, not operation names. - Graph.add_edge adds a missing node before adding the edge. - create_n_ket1_state returns |1>^n, and add_columns adds columns. Docstrings only; no behaviour or result changes. --- graphiq/backends/density_matrix/compiler.py | 5 +++-- graphiq/backends/density_matrix/functions.py | 4 ++-- graphiq/backends/graph/state.py | 11 ++++++----- graphiq/backends/lc_equivalence_check.py | 13 +++++++------ graphiq/backends/stabilizer/functions/clifford.py | 2 +- graphiq/backends/stabilizer/functions/linalg.py | 2 +- graphiq/backends/state_rep_conversion.py | 4 ++-- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/graphiq/backends/density_matrix/compiler.py b/graphiq/backends/density_matrix/compiler.py index e3026289..91dcb582 100644 --- a/graphiq/backends/density_matrix/compiler.py +++ b/graphiq/backends/density_matrix/compiler.py @@ -180,8 +180,9 @@ def compile_one_noisy_gate( """ Compile one noisy gate - :param state: the density matrix representation of the state to be evolved - :type state: DensityMatrix + :param state: the QuantumState representation of the state to be evolved, where a density matrix representation + can be accessed + :type state: QuantumState :param op: the operation to be applied :type op: OperationBase :param n_quantum: the number of qubits diff --git a/graphiq/backends/density_matrix/functions.py b/graphiq/backends/density_matrix/functions.py index 31fe8803..ad5e5c4e 100644 --- a/graphiq/backends/density_matrix/functions.py +++ b/graphiq/backends/density_matrix/functions.py @@ -705,7 +705,7 @@ def bipartite_partial_transpose(rho, dim1, dim2, subsys): :type subsys: int :raises AssertionError: if the dimension of the subsystems and the matrix dimension do not match - :raises ValueError: if the input matrix is not bipartite + :raises ValueError: if subsys is neither 0 nor 1 :return: the partial transpose matrix of rho :rtype: numpy.ndarray """ @@ -762,7 +762,7 @@ def project_and_remove(rho, locations): Return the density matrix after applying Z measurements on qubits specified by locations mask. It removes all these qubits under measurements. - :param rho: the density matrix to evaluate the negativity + :param rho: the density matrix to measure :type rho: numpy.ndarray :param locations: a list of zeros/ones :type locations: list or numpy.ndarray diff --git a/graphiq/backends/graph/state.py b/graphiq/backends/graph/state.py index a7cb2cca..61cf5b07 100644 --- a/graphiq/backends/graph/state.py +++ b/graphiq/backends/graph/state.py @@ -161,7 +161,7 @@ def add_node(self, node_to_add, lc_gate=None): def add_edge(self, first_node, second_node): """ - Add an edge between two nodes. If any of these two nodes does not exist, no edge is added. + Add an edge between two nodes. Any of these two nodes that does not exist is added first. :param first_node: the first node on which to add an edge :type first_node: int @@ -196,9 +196,9 @@ def get_nodes(self): def lc_equivalent(self, other_graph, mode="deterministic"): r""" - Determines whether two graph states are local-Clifford equivalent or not, given the adjacency matrices of the two. - It takes two adjacency matrices as input and returns a numpy.ndarray containing $n (2 \times 2 array)s$ - = clifford operations on each qubit. + Determines whether this graph state and other_graph are local-Clifford equivalent or not. + It returns whether a local-Clifford solution was found, together with an array of $n (2 \times 2 array)s$ + = clifford operations on each qubit (None if none was found). :param other_graph: the other graph against which to check LC equivalence :type other_graph: Graph @@ -211,7 +211,8 @@ def lc_equivalent(self, other_graph, mode="deterministic"): columns is not equal to $4n - rank$ (for $n$ being the number of nodes in the graph) :raises ValueError: if ``mode`` is neither ``"deterministic"`` nor ``"random"`` :return: If a solution is found, returns True and an array of single-qubit Clifford $2 \times 2$ matrices - in the symplectic formalism. If not, graphs are not LC equivalent and returns False, None. + in the symplectic formalism. If not, returns False, None: in 'deterministic' mode the graphs are not LC + equivalent; in 'random' mode the search may only have failed to find a solution. :rtype: bool, numpy.ndarray or None """ g1 = nx.to_numpy_array(self.data).astype(int) diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index 6b32bf2b..db9fb5d1 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -180,8 +180,8 @@ def is_lc_equivalent(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): r""" Determines whether two graph states are local-Clifford equivalent or not, given the adjacency matrices of the two. - It takes two adjacency matrices as input and returns a numpy.ndarray containing $n$ ($2 \times 2$ arrays - = clifford operations on each qubit. + It takes two adjacency matrices as input and returns whether a local-Clifford solution was found, together with + an array of $n$ ($2 \times 2$) arrays = clifford operations on each qubit (None if none was found). The check is run once per connected component of the two graphs: Bouchet (4.3) bounds the codimension of the solution space's affine subspace of valid vectors by 2 only for a connected graph, so on a graph with @@ -366,7 +366,8 @@ def _solution_basis_finder(reduced_coeff_matrix, col_list): :param col_list: list of the columns' indices in the coefficient matrix that are linearly dependent. :type col_list: list[int] or numpy.ndarray :raises AssertionError: if the solution basis found is wrong - :return: an array containing the elements of the total Clifford operation needed to convert one graph to another. + :return: the basis vectors of the solution space, as an array of shape (len(col_list), number of columns of + reduced_coeff_matrix, 1) :rtype: numpy.ndarray """ @@ -547,7 +548,7 @@ def _col_finder(row_reduced_coeff_matrix): :type row_reduced_coeff_matrix: numpy.ndarray :raises ValueError: if the input row_reduced_coeff_matrix is not a binary matrix :return: a list of the indices of the columns which are not linearly independent - :rtype: numpy.ndarray + :rtype: list[int] """ dependent_columns = [] pivot = [0, 0] @@ -612,8 +613,8 @@ def lc_graph_operations(adj_matrix, solution): :type adj_matrix: numpy.ndarray :param solution: an array of single-qubit Clifford :math:`2 \\times 2` matrices in the symplectic formalism :type solution: numpy.ndarray - :return: a list of the names of the operations that need to be applied on each qubit in the correct order. - :rtype: list[str] + :return: the ordered list of node indices on which to apply local complementations + :rtype: list[int] """ # takes an adjacency matrix and the solution (Clifford operation) and returns the list of local complementations # needed for graph transformation. diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index a58285f3..5ee9045b 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -492,7 +492,7 @@ def create_n_ket1_state(n_qubits): :param n_qubits: number of qubits :type n_qubits: int - :return: the state :math:`|0\\rangle^{\\otimes n}` + :return: the state :math:`|1\\rangle^{\\otimes n}` :rtype: CliffordTableau """ tableau = CliffordTableau(n_qubits) diff --git a/graphiq/backends/stabilizer/functions/linalg.py b/graphiq/backends/stabilizer/functions/linalg.py index 19a8421f..8b2f04e5 100644 --- a/graphiq/backends/stabilizer/functions/linalg.py +++ b/graphiq/backends/stabilizer/functions/linalg.py @@ -73,7 +73,7 @@ def column_swap(input_matrix, first_col, second_col): def add_columns(input_matrix, col_to_add, target_col): """ - Add two rows together modulo 2 and put it in the row of the second input + Add two columns together modulo 2 and put it in the column of the second input :param input_matrix: a binary matrix :type input_matrix: numpy.ndarray diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index df8eda01..b1b266e6 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -121,8 +121,8 @@ def graph_to_density(input_graph): :param input_graph: the graph from which we will build a density matrix :type input_graph: networkx.Graph or numpy.ndarray - :raise TypeError: if input_graph is not of the type of networkx.Graph or a graphiq.backends.graph.state.Graph - or an adjacency matrix given by a numpy array + :raise TypeError: if input_graph is not of the type of networkx.Graph, an adjacency matrix given by a numpy array, + or a list of (probability, graph) pairs :return: a density matrix as a numpy array :rtype: numpy.ndarray """ From 00167809daae821221864cf0746313226ac275aa Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:37:29 -0400 Subject: [PATCH 216/227] [docs] Correct circuit and openQASM docstrings that contradict the code Seventeen docstring lines in the circuit package and the openQASM, drawing and comparison utilities described behaviour the code does not have: - CircuitDAG.from_json returns the circuit it builds, and calculate_reg_depth returns a list. - OperationBase.openqasm_info and find_local_clifford_by_matrix never return None; both raise ValueError instead (find_local_clifford_by_matrix's :raises line already says so). - The q_registers setter and _update_q_reg replace the whole quantum-register tuple and check its length against q_registers; operations have no emitter_registers or photon_registers. The c_registers setter checks c_reg against c_registers. - MeasurementZ's noise type is graphiq.noise.noise_models.NoiseBase; there is no src package. - OpenQASMParser.parse is a generator of per-node info dicts. - Columns.set_all_col_element sets one row position in every column, and only where the entry is still 0. - ged_adaptive returns whether the GED is zero, not the distance. Docstrings only; no behaviour or result changes. --- graphiq/circuit/circuit_dag.py | 6 +++--- graphiq/circuit/ops.py | 19 +++++++++---------- graphiq/utils/circuit_comparison.py | 3 ++- graphiq/utils/draw.py | 6 +++--- graphiq/utils/openqasm_parser.py | 4 ++-- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index d561c9ab..43fa5735 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -980,7 +980,7 @@ def calculate_reg_depth(self, reg_type: str): :param reg_type: str indicates register type. Can be "e", "p", or "c" :type reg_type: str :return: the array of register depth of all register in the register type - :rtype: numpy.array + :rtype: list[int] """ if reg_type not in self._register_depth: raise ValueError(f"register type {reg_type} is not in this circuit") @@ -1095,8 +1095,8 @@ def from_json(cls, data_dict): :param data_dict: circuit json dict :type data_dict: dict - :return: nothing - :rtype: None + :return: the circuit built from data_dict + :rtype: CircuitDAG """ circuit = CircuitDAG( n_photon=data_dict["n_photons"], diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index e6db2523..4440552f 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -130,7 +130,7 @@ def openqasm_info(cls): (needed imports, how to define a gate, how to use a gate), packaged as a single object. This is used by the Circuit classes to generate openQASM scripts when needed - :return: the operation class's openqasm information (possibly None) + :return: the operation class's openqasm information """ if cls._openqasm_info is None: raise ValueError("Operation does not have an openQASM translation") @@ -169,15 +169,15 @@ def c_registers(self): @q_registers.setter def q_registers(self, q_reg): """ - Allows us to change the emitter_registers object on which an Operation acts + Allows us to change the q_registers object on which an Operation acts This should only be used by the circuit class! Subclass-specific implementations of this function also update other register-related fields (e.g. register, target, control) automatically - when photon_registers is updated + when q_registers is updated - :param q_reg: new emitter_register which the operation should target + :param q_reg: new q_registers tuple which the operation should target :type q_reg: tuple - :raises ValueError: if the new q_reg object does not match the length of self.emitter_registers (Operations + :raises ValueError: if the new q_reg object does not match the length of self.q_registers (Operations should not have variable register numbers) :return: function returns nothing :rtype: None @@ -195,7 +195,7 @@ def c_registers(self, c_reg): :param c_reg: new c_register which the operation should target :type c_reg: tuple - :raises ValueError: if the new q_reg object does not match the length of self.q_registers (Operations + :raises ValueError: if the new c_reg object does not match the length of self.c_registers (Operations should not have variable register numbers) :return: function returns nothing :rtype: None @@ -258,9 +258,9 @@ def _update_q_reg(self, q_reg): This is broken into a separate function because subclasses will need to use this as well, and using the super() keyword gets messy with class properties - :param q_reg: new emitter_register which the operation should target + :param q_reg: new q_registers tuple which the operation should target :type q_reg: tuple - :raises ValueError: if the new q_reg object does not match the length of self.emitter_registers (Operations + :raises ValueError: if the new q_reg object does not match the length of self.q_registers (Operations should not have variable register numbers) :return: function returns nothing :rtype: None @@ -1254,7 +1254,7 @@ def __init__(self, register=0, reg_type="e", c_register=0, noise=nm.NoNoise()): :param c_register: the classical register to which the measurement result is saved :type c_register: int OR tuple (of ints, length 2) :param noise: Noise model - :type noise: src.noise.noise_models.NoiseBase + :type noise: graphiq.noise.noise_models.NoiseBase :return: this function returns nothing :rtype: None """ @@ -1445,7 +1445,6 @@ def find_local_clifford_by_matrix(matrix): :type matrix: numpy.ndarray :raises ValueError: if the matrix does not correspond to a valid one-qubit Clifford gate. :return: the local Clifford gate specified by a list of basic gates it consists of - or None if the input matrix is not a valid one-qubit Clifford gate :rtype: list """ gate_list1, gate_list2 = local_clifford_composition() diff --git a/graphiq/utils/circuit_comparison.py b/graphiq/utils/circuit_comparison.py index 66fadbd0..5b3703c6 100644 --- a/graphiq/utils/circuit_comparison.py +++ b/graphiq/utils/circuit_comparison.py @@ -120,7 +120,8 @@ def ged_adaptive(circuit1, circuit2, threshold=30): :type circuit2: CircuitDAG :param threshold: threshold :type threshold: int - :return: exact/approximated GED between circuits(cost needed to transform self.dag to circuit_compare.dag) + :return: whether the (exact or approximate) GED between the two circuits is zero (exact when both have fewer than + threshold nodes) :rtype: bool """ diff --git a/graphiq/utils/draw.py b/graphiq/utils/draw.py index f47b9c0d..7eea671b 100644 --- a/graphiq/utils/draw.py +++ b/graphiq/utils/draw.py @@ -107,14 +107,14 @@ def expand_cols(self, size: int = 1): def set_all_col_element(self, index: int, value: int = -1): """ - Set all the value in every column from the first column to the index column. + Set the value at position index (a register row) in every column, wherever it is still 0. This function is used when adding barriers, since after adding barriers, new operations will be added after the barriers. - :param index: The index of the column + :param index: the position (register row) within each column to set :type index: int - :param value: The value that all column value will be set to if current value is not 0 + :param value: The value that each column's entry will be set to if its current value is 0 :type value: int :return: nothing :rtype: None diff --git a/graphiq/utils/openqasm_parser.py b/graphiq/utils/openqasm_parser.py index e3cb7a65..82de2a4e 100644 --- a/graphiq/utils/openqasm_parser.py +++ b/graphiq/utils/openqasm_parser.py @@ -88,8 +88,8 @@ class to parse the script to a list of node. Then, from there, the parser will c Note: For faster data processing time, the yield syntax is used here, which will return the parse info immediately after each node parsed. - :return: parsed node info - :rtype: dict + :return: a generator yielding the parsed info of each node + :rtype: generator (of dict) """ parser = Qasm(data=self.openqasm).parse() def_arr = ["qreg", "creg", "gate"] From aca2ffa783a317fb9310bf311ebaf9743ea46b8c Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:37:32 -0400 Subject: [PATCH 217/227] [docs] Correct noise, metrics and IO docstrings that contradict the code Fourteen docstring lines described behaviour the code does not have: - TwoQubitControlledGateReplacement applies the target gate when the control is |1>, not |0>. - CircuitUnitaryCount counts unitary gates; CircuitMaxEmitDepth, CircuitMaxEmitResetDepth and CircuitMaxEmitEffDepth score the maximum emitter depth, reset depth and effective depth, not the circuit depth. - NoisyEnsemble.all_branches keeps each event tree built for a noisy register above the cut-off probability; the combined tree is their product and can fall below it. - multi_reg_nodes selects nodes by their operation's base class, not by their number of edges. - IO.load_np_array returns the loaded array. Docstrings only; no behaviour or result changes. --- graphiq/io.py | 4 ++-- graphiq/metrics.py | 25 +++++++++++++++---------- graphiq/noise/noise_models.py | 2 +- graphiq/noise/time_depend_noise.py | 8 +++++--- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/graphiq/io.py b/graphiq/io.py index 9d1fa588..592616c4 100644 --- a/graphiq/io.py +++ b/graphiq/io.py @@ -279,8 +279,8 @@ def load_np_array(self, filename, complex_vals=False): :type filename: str :param complex_vals: True if we expect the numpy array to be complex, False otherwise :type complex_vals: bool - :return: the function returns nothing - :rtype: None + :return: the loaded numpy array + :rtype: numpy.ndarray """ full_path = self.path.joinpath(filename) file = np.loadtxt( diff --git a/graphiq/metrics.py b/graphiq/metrics.py index 5cd196c0..c4bd1df8 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -392,7 +392,7 @@ def evaluate(self, state, circuit): class CircuitUnitaryCount(MetricBase): """ - A metric which calculates the circuit depth + A metric which calculates the circuit's number of unitary gates """ def __init__(self, log_steps=1, n_unitary_penalty=None, *args, **kwargs): @@ -461,7 +461,7 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :param depth_penalty: a function which calculates a "cost"/penalty as a function of circuit depth + :param depth_penalty: a function which calculates a "cost"/penalty as a function of the maximum emitter depth :type depth_penalty: function :return: the function returns nothing :rtype: None @@ -477,14 +477,15 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): def evaluate(self, state, circuit): """ - Calculates a scalar function of the circuit depth + Calculates a scalar function of the circuit's maximum emitter depth :param state: state which was created by the circuit. This is not actually used by this metric object, but is nonetheless provided to guarantee a uniform API between Metric-type objects :type state: QuantumState :param circuit: the circuit to evaluate :type circuit: CircuitBase (or a subclass of it) - :return: the scalar penalty resulting from circuit depth. By default, this is the circuit depth itself + :return: the scalar penalty resulting from the maximum emitter depth. By default, this is the maximum emitter + depth itself :rtype: float or int """ c = circuit.copy() @@ -513,7 +514,8 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :param depth_penalty: a function which calculates a "cost"/penalty as a function of circuit depth + :param depth_penalty: a function which calculates a "cost"/penalty as a function of the maximum emitter reset + depth :type depth_penalty: function :return: the function returns nothing :rtype: None @@ -529,14 +531,15 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): def evaluate(self, state, circuit): """ - Calculates a scalar function of the circuit depth + Calculates a scalar function of the circuit's maximum emitter reset depth :param state: state which was created by the circuit. This is not actually used by this metric object, but is nonetheless provided to guarantee a uniform API between Metric-type objects :type state: QuantumState :param circuit: the circuit to evaluate :type circuit: CircuitBase (or a subclass of it) - :return: the scalar penalty resulting from circuit depth. By default, this is the circuit depth itself + :return: the scalar penalty resulting from the maximum emitter reset depth. By default, this is the maximum + emitter reset depth itself :rtype: float or int """ c = circuit.copy() @@ -577,7 +580,8 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :param depth_penalty: a function which calculates a "cost"/penalty as a function of circuit depth + :param depth_penalty: a function which calculates a "cost"/penalty as a function of the maximum emitter + effective depth :type depth_penalty: function :return: the function returns nothing :rtype: None @@ -593,14 +597,15 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): def evaluate(self, state, circuit): """ - Calculates a scalar function of the circuit depth + Calculates a scalar function of the circuit's maximum emitter effective depth :param state: state which was created by the circuit. This is not actually used by this metric object, but is nonetheless provided to guarantee a uniform API between Metric-type objects :type state: QuantumState :param circuit: the circuit to evaluate :type circuit: CircuitBase (or a subclass of it) - :return: the scalar penalty resulting from circuit depth. By default, this is the circuit depth itself + :return: the scalar penalty resulting from the maximum emitter effective depth. By default, this is the maximum + emitter effective depth itself :rtype: float or int """ c = circuit.copy() diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index 5fac5aaf..ca734c40 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -267,7 +267,7 @@ def __init__( Construct a TwoQubitControlledGateReplacement noise model :param target_unitary: the target gate to be applied to the target qubit if the control qubit is - in :math:`|0\\rangle` state + in :math:`|1\\rangle` state :type target_unitary: numpy.ndarray :param pre_gate_ctr_noise: the noise (unitary) added to the control qubit before the gate :type pre_gate_ctr_noise: numpy.ndarray diff --git a/graphiq/noise/time_depend_noise.py b/graphiq/noise/time_depend_noise.py index 9df064a1..841463da 100644 --- a/graphiq/noise/time_depend_noise.py +++ b/graphiq/noise/time_depend_noise.py @@ -152,8 +152,9 @@ def all_branches(self): This method calculates all possible NoiseEvents that can be applied on the NoisyEnsemble object. It corresponds to the event tree that is resulted from adding noise to the circuit. An event tree is a list of branches, where each branch has a probability of happening and corresponds to a series of error gates happening at different - parts of the ideal circuit. The total probability of an event tree is between the cut-off probability, which - can be set in noise parameters, and unity. + parts of the ideal circuit. The total probability of each event tree built for a noisy register (one per noise + type) is between the cut-off probability, which can be set in noise parameters, and unity; the combined event + tree's total is their product and can be lower. :return: An event tree, which is a list of NoiseEvents :rtype: list[NoiseEvent] @@ -398,7 +399,8 @@ def multi_reg_nodes(circ, node_list): :type circ: CircuitDAG :param node_list: a list of some operation nodes in the DAG :type node_list: list - :return: list of the nodes that have more than one input or output edges in the DAG. + :return: list of the nodes whose operation class directly subclasses ControlledPairOperationBase or + ClassicalControlledPairOperationBase :rtype: list """ multi_reg_superclass = [ From 7c26a6436bfc8df9406d9c0c21fedd6121133bfc Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:37:33 -0400 Subject: [PATCH 218/227] [docs] Correct solver and benchmark docstrings that contradict the code Eleven docstring lines described behaviour the code does not have: - SolverBase._identify_noise returns a list of two noise models for a two-legged operation, which every solver caller passes. - GradientDescentSolver.solve runs n_step updates (there is no n_steps), and AlternateTargetSolver's constructor named a class that does not exist. - lattice_cluster_state takes a tuple of sizes, one per dimension, and two_d_cluster and three_d_cluster build grid cluster states, not the crazy graph. - search_for_alternative_circuits and the exemplary_* helpers need an EvolutionarySolverSetting; a RandomSearchSolverSetting raises AttributeError in solve. - report_alternate_circuits takes the list of (score, probability, circuit) tuples the search returns. Docstrings only; no behaviour or result changes. --- graphiq/benchmarks/alternate_circuits.py | 10 +++++----- graphiq/benchmarks/graph_states.py | 8 +++++--- graphiq/solvers/alternate_target_solver.py | 2 +- graphiq/solvers/gradient_descent_solver.py | 2 +- graphiq/solvers/solver_base.py | 4 ++-- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/graphiq/benchmarks/alternate_circuits.py b/graphiq/benchmarks/alternate_circuits.py index 582c37e0..c83e3a75 100644 --- a/graphiq/benchmarks/alternate_circuits.py +++ b/graphiq/benchmarks/alternate_circuits.py @@ -45,7 +45,7 @@ def search_for_alternative_circuits( :param metric_class: a metric class :type metric_class: MetricBase or its subclass :param solver_setting: specifies the setting of a chosen solver - :type solver_setting: RandomSearchSolverSetting or EvolutionarySolverSetting + :type solver_setting: EvolutionarySolverSetting :param random_seed: a random seed :type random_seed: int :return: results are stored in terms of (score, survival probability, circuit) @@ -120,7 +120,7 @@ def report_alternate_circuits(results): A way to print out results :param results: results are stored in terms of (score, survival probability, circuit) - :type results: tuple + :type results: list[tuple(float, float, CircuitDAG)] :return: nothing :rtype: None """ @@ -230,7 +230,7 @@ def exemplary_run(graph, noise_model_mapping, solver_setting=None, random_seed=1 :param noise_model_mapping: a way to map noises to gates :type noise_model_mapping: dict :param solver_setting: specifies the setting of the chosen solver - :type solver_setting: RandomSearchSolverSetting or EvolutionarySolverSetting + :type solver_setting: EvolutionarySolverSetting or None :param random_seed: a random seed :type random_seed: int :return: results are stored in terms of (score, survival probability, circuit) @@ -253,7 +253,7 @@ def exemplary_test(graph, noise_model_mapping, solver_setting=None, random_seed= :param noise_model_mapping: a way to map noises to gates :type noise_model_mapping: dict :param solver_setting: specifies the setting of the chosen solver - :type solver_setting: RandomSearchSolverSetting or EvolutionarySolverSetting + :type solver_setting: EvolutionarySolverSetting or None :param random_seed: a random seed :type random_seed: int :return: nothing @@ -276,7 +276,7 @@ def exemplary_multiple_test( :param random_numbers: a list of random numbers :type random_numbers: list[int] :param solver_setting: specifies the setting of the chosen solver - :type solver_setting: RandomSearchSolverSetting or EvolutionarySolverSetting + :type solver_setting: EvolutionarySolverSetting or None :return: nothing :rtype: None """ diff --git a/graphiq/benchmarks/graph_states.py b/graphiq/benchmarks/graph_states.py index 36eef9c3..9cd3d7e3 100644 --- a/graphiq/benchmarks/graph_states.py +++ b/graphiq/benchmarks/graph_states.py @@ -43,7 +43,7 @@ def linear_cluster_state(n_qubits): def lattice_cluster_state(dimension): """ - Generates an n_side by n_side cluster state lattice + Generates a cluster state lattice with the number of qubits along each dimension given by dimension :param dimension: a tuple containing the number of qubits along each dimension :type dimension: tuple @@ -288,7 +288,8 @@ def two_d_cluster(row_col: tuple, pos=False): :param row_col: a tuple of number of rows and columns in the cluster state :param pos: if True the function also returns the position dictionary for the graph - :return: a networkx graph that represents the crazy graph or a tuple including the graph and its node-position dict + :return: a networkx graph that represents the 2D cluster state or a tuple including the graph and its node-position + dict :rtype: networkx.Graph or (networkx.Graph, pos_dict) """ g = nx.Graph() @@ -333,7 +334,8 @@ def three_d_cluster(row_col_depth: tuple, pos=False): :param row_col_depth: a tuple of number of rows and columns and depth of the cluster state :param pos: if True the function also returns the position dictionary for the graph - :return: a networkx graph that represents the crazy graph or a tuple including the graph and its node-position dict + :return: a networkx graph that represents the 3D cluster state or a tuple including the graph and its node-position + dict :rtype: networkx.Graph or (networkx.Graph, pos_dict) """ g = nx.Graph() diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 6bc75a97..cd48ca18 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -89,7 +89,7 @@ def __init__( seed=None, ): """ - Constructor for AlternateGraphSolver + Constructor for AlternateTargetSolver :param target: target graph state :type target: QuantumState or nx.Graph diff --git a/graphiq/solvers/gradient_descent_solver.py b/graphiq/solvers/gradient_descent_solver.py index 976f108b..685380d5 100644 --- a/graphiq/solvers/gradient_descent_solver.py +++ b/graphiq/solvers/gradient_descent_solver.py @@ -92,7 +92,7 @@ def compute_cost( def solve(self, initial_params=None): """ - Main gradient descent algorithm, performing `n_steps` updates with the provided optimizer. + Main gradient descent algorithm, performing `n_step` updates with the provided optimizer. :param initial_params: initial parameter dictionary :type initial_params: dict diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index b9cd3166..4af6cde1 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -173,8 +173,8 @@ def _identify_noise(self, op, noise_model_mapping): returned for both legs and a list of two is returned as it is :type noise_model_mapping: dict :raises ValueError: if a two-legged op's bare list value does not have length 2 - :return: a noise model - :rtype: nm.NoiseBase + :return: a noise model, or a list of two (control, target) for a two-legged op + :rtype: nm.NoiseBase or list[nm.NoiseBase] """ return noise_for_op(op, noise_model_mapping) From 727dc22b08f9df30443d43fd038e56321fac70f1 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:37:35 -0400 Subject: [PATCH 219/227] [docs] Correct data-collection, relabelling and plotting docstrings that contradict the code Nineteen docstring lines described behaviour the code does not have: - GraphCorr._graph_list_maker returns the initial graph followed by up to count labelings, the first of which is the initial one again. - GraphCorr.finder returns no standard deviations (they are only drawn as error bars), and its (x, y) tuple is (unique circuit metric values, average graph metric values). - corr_n_dependence's constant_np is a number, the n*p kept constant, not a flag. - met_met, NodeCorr.met_order_error, NodeCorr.next_node_corr and _rep_counter return the repetition counts as a numpy array. - NodeCorr.order_corr averages the correlations, not the graph metric. - correlation_checker prints and returns nothing; corr_with_mean also returns the unique values, means and standard deviations. - _label_finder samples from all permutations when n_node is below 8 as well as when exhaustive is set, and then ignores new_label_set. - get_relabel_map adds -1: "self" when the two graphs are equal. - density_matrix_heatmap and density_matrix_bars return two axes. Docstrings only; no behaviour or result changes. --- graphiq/data_collection/correlation_module.py | 41 ++++++++++--------- graphiq/data_collection/user_interface.py | 5 ++- graphiq/utils/relabel_module.py | 14 ++++--- graphiq/visualizers/density_matrix.py | 4 +- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index f0767b46..5a4be8cb 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -208,7 +208,8 @@ def circ_metric(self, new_met): @staticmethod def _graph_list_maker(g, count): """ - makes a list of relabeled graphs out of the initial one. List size = count. + makes a list of relabeled graphs out of the initial one: the initial graph followed by up to count labelings of + it, the first of which is the initial labeling again. :param g: initial input graph :type g: nx.Graph @@ -263,10 +264,10 @@ def finder(self, show_plot=True, n=None, p=None, swap_axes=False, graph_type=Non """ finds and plots correlation between one graph and one circuit metric for a set of graphs. For all the graphs in the sample that have the same metric value, it takes the average of their graph metric value and returns the - average values alongside the standard deviation of corresponding to each bunch of graphs that share the same - circuit metric value. For instance, for all graph with the same number of emitters, take the average of their - graph metric, say connectivity, and plot this data point (num_emitter, avg_connect) on a diagram. The standard - deviation is taken for the graph metric of those graphs with the same circuit metric. + average values; the standard deviation of each bunch of graphs that share the same circuit metric value is only + drawn as error bars when show_plot is True. For instance, for all graph with the same number of emitters, take + the average of their graph metric, say connectivity, and plot this data point (num_emitter, avg_connect) on a + diagram. The standard deviation is taken for the graph metric of those graphs with the same circuit metric. For correlation between two graph or circuit metrics try the 'met_met' method instead. " :param show_plot: if True a plot of graph metric vs circuit metric will be shown @@ -282,7 +283,7 @@ def finder(self, show_plot=True, n=None, p=None, swap_axes=False, graph_type=Non :type graph_type: str :return: pearson correlation between all graph metric vs circuit metric values, pearson correaltion for the average graph metric values vs unique circuit metric values, and a tuple of lists containing the x and y data - point of the plot, corresponding to the average graph metric values and unique circuit metric values. + point of the plot: the unique circuit metric values and the corresponding average graph metric values. :rtype: float, float, tuple:(list, list) """ graph_met_list = [] @@ -379,10 +380,10 @@ def corr_n_dependence(self, p, n_step=10, constant_np=None): :type p: float :param n_step: the step with which the n is changed, at least 10 steps will be considered. :type n_step: int - :param constant_np: if True, for each n, p is chosen such that the value of n*p remains constant, meaning that - the average degree of each node in the graph remains the same even if the sizes of the graphs in the sample - change - :type constant_np: bool + :param constant_np: if given, for each n, p is set to constant_np / n so that n*p stays equal to constant_np + (the intended average degree, larger than 1), meaning that the average degree of each node in the graph remains + the same even if the sizes of the graphs in the sample change + :type constant_np: float or None :return: nothing """ # number of nodes starting from 5 and going up to at least 100, or possibly 10 times the chosen n_step @@ -498,8 +499,8 @@ def met_met(self, met1, met2, n=None, p=None, show_plot=True): :param show_plot: if True a distribution diagram is shown :type show_plot: bool :return: all pairs of (fist, second) metric values of all graphs, in two separate lists and their respective - repetition count over the sample in a third list. - :rtype: list, list, list + repetition count over the sample in a numpy array. + :rtype: list, list, numpy.ndarray """ met1_list = [] met2_list = [] @@ -802,7 +803,7 @@ def metric(self, new_met): def order_corr(self): """ Determines and plots the relation of average pearson correlation function ,between nodes' emission order and the - metric-value order, and the circuit metric value. The average of the graph metric is taken for all graphs that + metric-value order, and the circuit metric value. The average of that correlation is taken for all graphs that have the same circuit metric value. :return: three lists, the list of unique metric values, the list of average correlation, the list of standard @@ -861,9 +862,9 @@ def met_order_error(self): The larger the data point is plotted, the more the number of graphs in the sample that had the same metric and error values. - :return: three lists, the list of unique metric values, the list of sum squared errors, the list of repetition - count for each of the data points over the whole sample. - :rtype: list, list, list + :return: two lists and a numpy array: the list of unique metric values, the list of sum squared errors, the + array of repetition count for each of the data points over the whole sample. + :rtype: list, list, numpy.ndarray """ adj_list, circ_met_list = self.adj_list, self.met_list n = self.adj.shape[0] @@ -900,9 +901,9 @@ def next_node_corr(self): The larger the data point is plotted, the more the number of graphs in the sample that had the same metric and error values. - :return: three lists, the list of unique metric values, the list of sum squared errors, the list of repetition - count for each of the data points over the whole sample. - :rtype: list, list, list + :return: two lists and a numpy array: the list of unique metric values, the list of sum squared errors, the + array of repetition count for each of the data points over the whole sample. + :rtype: list, list, numpy.ndarray """ # based on discarding the part of the graph state that has been emitted so far. adj_list, circ_met_list = self.adj_list, self.met_list @@ -1249,7 +1250,7 @@ def _rep_counter(x_data, y_data): :type x_data: An iterable :param y_data: A set of numeric data the same size as the x_data :type y_data: An iterable - :return: Returns all respective x, y value in two lists removing the redundancies and a third list containing the + :return: Returns all respective x, y value in two lists removing the redundancies and a numpy array containing the repetition count of each of the data points. """ assert len(x_data) == len(y_data), "two data sets must be of same size" diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index 55a0ee4b..1f8be914 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -270,7 +270,7 @@ def correlation_checker(result, list_mets1, list_mets2): :param result: result object :param list_mets1: list of circuit metrics :param list_mets2: list of grpah metrics - :return: all combination of graph and circuit metrics linear correlations will be calculated + :return: nothing; the Spearman and Pearson correlations of every combination of the two metric lists are printed """ for met1 in list_mets1: for met2 in list_mets2: @@ -297,7 +297,8 @@ def corr_with_mean(list1, list2, print_result=True): :param list1: to be used as reference :param list2: to be averaged over for each case - :return: pearson correlation coefficient + :return: pearson correlation coefficient, and a tuple of the unique list1 values, the mean list2 value for each and + their standard deviations """ corr_dict = dict() for i, x in enumerate(list1): diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 65770f78..28d12f51 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -211,16 +211,17 @@ def _label_finder( n_label, n_node, new_label_set=None, exhaustive=False, seed=None, thresh=None ): """ - Finds n_label number of permutations for the sequence {0, 1, ..., n_node-1}. If exhaustive search is enabled the - process happens through sampling from the total set of all possible permutations (size = n_node factorial). - Otherwise, new permutations are generated randomly until the required number of distinct cases are found, or - the threshold for number of random trials is reached. + Finds n_label number of permutations for the sequence {0, 1, ..., n_node-1}. If exhaustive search is enabled, or + n_node is less than 8, the process happens through sampling from the total set of all possible permutations + (size = n_node factorial). Otherwise, new permutations are generated randomly until the required number of distinct + cases are found, or the threshold for number of random trials is reached. :param n_label: number of permutations needed :type n_label: int :param n_node: length of the initial sequence {0, 1, ..., n_node-1} :type n_node: int - :param new_label_set: a given set of permutations that the function will add the new cases to. + :param new_label_set: a given set of permutations that the function will add the new cases to; ignored when n_node + is less than 8 or exhaustive is True. :type new_label_set: set :param exhaustive: If true the permutation will be sampled from the set of all possible permutations. There might be a significant increase in runtime for large sequences. @@ -333,7 +334,8 @@ def get_relabel_map(g1, g2): :type g1: nx.Graph or np.ndarray :param g2: networkx graph or adjacency matrix :type g2: nx.Graph or np.ndarray - :return: a dictionary mapping each node of g1 to a node of g2. The map may not be unique. + :return: a dictionary mapping each node of g1 to a node of g2. The map may not be unique. If g1 and g2 have the same + adjacency matrix, the dictionary also has the entry -1: "self". :rtype: dict """ # g1 and g2 can be adj matrices or nx graphs. diff --git a/graphiq/visualizers/density_matrix.py b/graphiq/visualizers/density_matrix.py index 5a79d801..4a446d5e 100644 --- a/graphiq/visualizers/density_matrix.py +++ b/graphiq/visualizers/density_matrix.py @@ -34,7 +34,7 @@ def density_matrix_heatmap(rho, axs=None): when given, the returned figure is axs[0]'s root figure :type axs: sequence of two matplotlib.Axes :return: fig (figure handle), axs (the axes plotted on) - :rtype: matplotlib.Figure, matplotlib.Axes + :rtype: matplotlib.Figure, sequence of two matplotlib.Axes """ if axs is None: fig, axs = plt.subplots(nrows=1, ncols=2, figsize=[10, 4]) @@ -59,7 +59,7 @@ def density_matrix_bars(rho): :param rho: a complex numpy array representing the density matrix :type rho: numpy.ndarray :return: fig (figure handle), axs (list of axes handles) - :rtype: matplotlib.Figure, matplotlib.Axes + :rtype: matplotlib.Figure, list[matplotlib.Axes] """ def bar_plot(deltaz, ax): From 8e527d3c200403b60a3521cc9335731e85f885f8 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:37:56 -0400 Subject: [PATCH 220/227] [docs] Say where the solvers notebook's save_openqasm lives and what it saves 6_solvers.ipynb listed self.save_openqasm among the solver's attributes. It is a field of EvolutionarySolverSetting, a string: "hof", "pop" or "both" write those circuits out as openQASM after each generation, through the solver's io, and the default "none" saves nothing. Without an io nothing is written. One markdown line; no code or saved output changes. --- examples/startup_guide/6_solvers.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/startup_guide/6_solvers.ipynb b/examples/startup_guide/6_solvers.ipynb index dbc6cf2e..ce515df5 100644 --- a/examples/startup_guide/6_solvers.ipynb +++ b/examples/startup_guide/6_solvers.ipynb @@ -404,7 +404,7 @@ "* `self.noise_simulation`: if true, the optimization is performed with noise. Otherwise, the optimization is noiseless.\n", "* `n_hof`: the number of different circuits we maintain in the hall of fame\n", "* `self.noise_model_mapping`: noise models (as previously discussed)\n", - "* `self.save_openqasm`: whether we want to save the circuit population, hof, or both to openQASM strings" + "* `setting.save_openqasm`: which circuits the solver writes out as openQASM after each generation: `\"hof\"`, `\"pop\"` or `\"both\"` (the default, `\"none\"`, saves nothing). They are saved only when the solver was given an `io`." ] }, { From 0c3c8866a061c6e2fef0763b2cfc542b14e11309 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 20:37:56 -0400 Subject: [PATCH 221/227] [docs] Drop the saved traceback from the variational-circuits notebook 8_variational_circuits.ipynb kept a TracerArrayConversionError traceback, with local paths from an old run, as the output of its optimisation-loop cell. Clear that cell's outputs and execution count. Only the saved output changes; the cell's source and every other cell are untouched. --- .../8_variational_circuits.ipynb | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/examples/startup_guide/8_variational_circuits.ipynb b/examples/startup_guide/8_variational_circuits.ipynb index c1714b30..15e1eb3d 100644 --- a/examples/startup_guide/8_variational_circuits.ipynb +++ b/examples/startup_guide/8_variational_circuits.ipynb @@ -268,34 +268,8 @@ }, { "cell_type": "code", - "execution_count": 9, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - " 0%| | 0/100 [00:00 4\u001B[0m grads \u001B[38;5;241m=\u001B[39m \u001B[43mjax\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mgrad\u001B[49m\u001B[43m(\u001B[49m\u001B[43mcompute_loss\u001B[49m\u001B[43m)\u001B[49m\u001B[43m(\u001B[49m\u001B[43mparams\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mcircuit\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 5\u001B[0m updates, opt_state \u001B[38;5;241m=\u001B[39m optimizer\u001B[38;5;241m.\u001B[39mupdate(grads, opt_state)\n\u001B[1;32m 6\u001B[0m params \u001B[38;5;241m=\u001B[39m optax\u001B[38;5;241m.\u001B[39mapply_updates(params, updates)\n", - " \u001B[0;31m[... skipping hidden 10 frame]\u001B[0m\n", - "Cell \u001B[0;32mIn[7], line 3\u001B[0m, in \u001B[0;36mcompute_loss\u001B[0;34m(params, circuit)\u001B[0m\n\u001B[1;32m 1\u001B[0m \u001B[38;5;28;01mdef\u001B[39;00m \u001B[38;5;21mcompute_loss\u001B[39m(params: \u001B[38;5;28mdict\u001B[39m, circuit: CircuitDAG):\n\u001B[1;32m 2\u001B[0m circuit\u001B[38;5;241m.\u001B[39mparameters \u001B[38;5;241m=\u001B[39m params\n\u001B[0;32m----> 3\u001B[0m output_state \u001B[38;5;241m=\u001B[39m \u001B[43mcompiler\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mcompile\u001B[49m\u001B[43m(\u001B[49m\u001B[43mcircuit\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 4\u001B[0m cost \u001B[38;5;241m=\u001B[39m metric\u001B[38;5;241m.\u001B[39mevaluate(output_state, circuit)\n\u001B[1;32m 5\u001B[0m \u001B[38;5;28;01mreturn\u001B[39;00m cost\n", - "File \u001B[0;32m~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/compiler_base.py:121\u001B[0m, in \u001B[0;36mCompilerBase.compile\u001B[0;34m(self, circuit, initial_state)\u001B[0m\n\u001B[1;32m 118\u001B[0m no_noise \u001B[38;5;241m=\u001B[39m no_noise \u001B[38;5;129;01mor\u001B[39;00m \u001B[38;5;28misinstance\u001B[39m(op\u001B[38;5;241m.\u001B[39mnoise, nm\u001B[38;5;241m.\u001B[39mNoNoise)\n\u001B[1;32m 120\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m no_noise:\n\u001B[0;32m--> 121\u001B[0m \u001B[38;5;28;43mself\u001B[39;49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mcompile_one_gate\u001B[49m\u001B[43m(\u001B[49m\n\u001B[1;32m 122\u001B[0m \u001B[43m \u001B[49m\u001B[43mstate\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mop\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mcircuit\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mn_quantum\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mq_index\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mclassical_registers\u001B[49m\n\u001B[1;32m 123\u001B[0m \u001B[43m \u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 125\u001B[0m \u001B[38;5;28;01melse\u001B[39;00m:\n\u001B[1;32m 126\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m is_controlled_op:\n", - "File \u001B[0;32m~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/compiler.py:86\u001B[0m, in \u001B[0;36mDensityMatrixCompiler.compile_one_gate\u001B[0;34m(self, state, op, n_quantum, q_index, classical_registers)\u001B[0m\n\u001B[1;32m 80\u001B[0m \u001B[38;5;28;01mpass\u001B[39;00m \u001B[38;5;66;03m# TODO: should think about best way to handle inputs/outputs\u001B[39;00m\n\u001B[1;32m 82\u001B[0m \u001B[38;5;28;01melif\u001B[39;00m \u001B[38;5;28misinstance\u001B[39m(op, ops\u001B[38;5;241m.\u001B[39mOneQubitOperationBase):\n\u001B[1;32m 83\u001B[0m unitary \u001B[38;5;241m=\u001B[39m dm\u001B[38;5;241m.\u001B[39mget_one_qubit_gate(\n\u001B[1;32m 84\u001B[0m n_quantum,\n\u001B[1;32m 85\u001B[0m q_index(op\u001B[38;5;241m.\u001B[39mregister, op\u001B[38;5;241m.\u001B[39mreg_type),\n\u001B[0;32m---> 86\u001B[0m \u001B[38;5;28;43mself\u001B[39;49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mops\u001B[49m\u001B[43m[\u001B[49m\u001B[43mop\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[38;5;18;43m__class__\u001B[39;49m\u001B[43m]\u001B[49m\u001B[43m(\u001B[49m\u001B[38;5;241;43m*\u001B[39;49m\u001B[43mparams\u001B[49m\u001B[43m)\u001B[49m,\n\u001B[1;32m 87\u001B[0m )\n\u001B[1;32m 88\u001B[0m state\u001B[38;5;241m.\u001B[39mapply_unitary(unitary)\n\u001B[1;32m 90\u001B[0m \u001B[38;5;28;01melif\u001B[39;00m \u001B[38;5;28misinstance\u001B[39m(op, ops\u001B[38;5;241m.\u001B[39mControlledPairOperationBase):\n", - "File \u001B[0;32m~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/compiler.py:31\u001B[0m, in \u001B[0;36mDensityMatrixCompiler.\u001B[0;34m(theta, phi, lam)\u001B[0m\n\u001B[1;32m 19\u001B[0m \u001B[38;5;66;03m# TODO: [longer term] refactor to take a QuantumState object input instead of creating its own initial state?\u001B[39;00m\n\u001B[1;32m 21\u001B[0m name \u001B[38;5;241m=\u001B[39m \u001B[38;5;124m\"\u001B[39m\u001B[38;5;124mdm\u001B[39m\u001B[38;5;124m\"\u001B[39m\n\u001B[1;32m 22\u001B[0m ops \u001B[38;5;241m=\u001B[39m { \u001B[38;5;66;03m# the accepted operations and the single-qubit action needed for each gate\u001B[39;00m\n\u001B[1;32m 23\u001B[0m ops\u001B[38;5;241m.\u001B[39mInput: \u001B[38;5;28;01mlambda\u001B[39;00m: \u001B[38;5;28;01mNone\u001B[39;00m,\n\u001B[1;32m 24\u001B[0m ops\u001B[38;5;241m.\u001B[39mIdentity: \u001B[38;5;28;01mlambda\u001B[39;00m: \u001B[38;5;28;01mNone\u001B[39;00m,\n\u001B[1;32m 25\u001B[0m ops\u001B[38;5;241m.\u001B[39mPhase: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39mphase(),\n\u001B[1;32m 26\u001B[0m ops\u001B[38;5;241m.\u001B[39mPhaseDagger: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39mphase_dag(),\n\u001B[1;32m 27\u001B[0m ops\u001B[38;5;241m.\u001B[39mHadamard: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39mhadamard(),\n\u001B[1;32m 28\u001B[0m ops\u001B[38;5;241m.\u001B[39mSigmaX: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmax(),\n\u001B[1;32m 29\u001B[0m ops\u001B[38;5;241m.\u001B[39mSigmaY: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmay(),\n\u001B[1;32m 30\u001B[0m ops\u001B[38;5;241m.\u001B[39mSigmaZ: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmaz(),\n\u001B[0;32m---> 31\u001B[0m ops\u001B[38;5;241m.\u001B[39mParameterizedOneQubitRotation: \u001B[38;5;28;01mlambda\u001B[39;00m theta, phi, lam: \u001B[43mdm\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mparameterized_one_qubit_unitary\u001B[49m\u001B[43m(\u001B[49m\n\u001B[1;32m 32\u001B[0m \u001B[43m \u001B[49m\u001B[43mtheta\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mphi\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mlam\u001B[49m\n\u001B[1;32m 33\u001B[0m \u001B[43m \u001B[49m\u001B[43m)\u001B[49m,\n\u001B[1;32m 34\u001B[0m ops\u001B[38;5;241m.\u001B[39mParameterizedControlledRotationQubit: \u001B[38;5;28;01mlambda\u001B[39;00m theta, phi, lam: dm\u001B[38;5;241m.\u001B[39mparameterized_one_qubit_unitary(\n\u001B[1;32m 35\u001B[0m theta, phi, lam\n\u001B[1;32m 36\u001B[0m ),\n\u001B[1;32m 37\u001B[0m ops\u001B[38;5;241m.\u001B[39mCNOT: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmax(),\n\u001B[1;32m 38\u001B[0m ops\u001B[38;5;241m.\u001B[39mCZ: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmaz(),\n\u001B[1;32m 39\u001B[0m ops\u001B[38;5;241m.\u001B[39mClassicalCNOT: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmax(),\n\u001B[1;32m 40\u001B[0m ops\u001B[38;5;241m.\u001B[39mClassicalCZ: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmaz(),\n\u001B[1;32m 41\u001B[0m ops\u001B[38;5;241m.\u001B[39mMeasurementZ: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmaz(),\n\u001B[1;32m 42\u001B[0m ops\u001B[38;5;241m.\u001B[39mMeasurementCNOTandReset: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmax(),\n\u001B[1;32m 43\u001B[0m ops\u001B[38;5;241m.\u001B[39mOutput: \u001B[38;5;28;01mlambda\u001B[39;00m: \u001B[38;5;28;01mNone\u001B[39;00m,\n\u001B[1;32m 44\u001B[0m }\n\u001B[1;32m 46\u001B[0m \u001B[38;5;28;01mdef\u001B[39;00m \u001B[38;5;21m__init__\u001B[39m(\u001B[38;5;28mself\u001B[39m, \u001B[38;5;241m*\u001B[39margs, \u001B[38;5;241m*\u001B[39m\u001B[38;5;241m*\u001B[39mkwargs):\n\u001B[1;32m 47\u001B[0m \u001B[38;5;250m \u001B[39m\u001B[38;5;124;03m\"\"\"\u001B[39;00m\n\u001B[1;32m 48\u001B[0m \u001B[38;5;124;03m Create a compiler which acts on a DensityMatrix state representation\u001B[39;00m\n\u001B[1;32m 49\u001B[0m \n\u001B[1;32m 50\u001B[0m \u001B[38;5;124;03m :return: nothing\u001B[39;00m\n\u001B[1;32m 51\u001B[0m \u001B[38;5;124;03m :rtype: None\u001B[39;00m\n\u001B[1;32m 52\u001B[0m \u001B[38;5;124;03m \"\"\"\u001B[39;00m\n", - "File \u001B[0;32m~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/functions.py:794\u001B[0m, in \u001B[0;36mparameterized_one_qubit_unitary\u001B[0;34m(theta, phi, lam)\u001B[0m\n\u001B[1;32m 775\u001B[0m \u001B[38;5;28;01mdef\u001B[39;00m \u001B[38;5;21mparameterized_one_qubit_unitary\u001B[39m(theta, phi, lam):\n\u001B[1;32m 776\u001B[0m \u001B[38;5;250m \u001B[39m\u001B[38;5;124;03m\"\"\"\u001B[39;00m\n\u001B[1;32m 777\u001B[0m \u001B[38;5;124;03m Define a generic 3-parameter one-qubit unitary gate.\u001B[39;00m\n\u001B[1;32m 778\u001B[0m \n\u001B[0;32m (...)\u001B[0m\n\u001B[1;32m 790\u001B[0m \u001B[38;5;124;03m :rtype: numpy.ndarray\u001B[39;00m\n\u001B[1;32m 791\u001B[0m \u001B[38;5;124;03m \"\"\"\u001B[39;00m\n\u001B[1;32m 792\u001B[0m gate \u001B[38;5;241m=\u001B[39m np\u001B[38;5;241m.\u001B[39marray(\n\u001B[1;32m 793\u001B[0m [\n\u001B[0;32m--> 794\u001B[0m [\u001B[43mnp\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mcos\u001B[49m\u001B[43m(\u001B[49m\u001B[43mtheta\u001B[49m\u001B[43m \u001B[49m\u001B[38;5;241;43m/\u001B[39;49m\u001B[43m \u001B[49m\u001B[38;5;241;43m2\u001B[39;49m\u001B[43m)\u001B[49m, \u001B[38;5;241m-\u001B[39mnp\u001B[38;5;241m.\u001B[39mexp(\u001B[38;5;241m1\u001B[39mj \u001B[38;5;241m*\u001B[39m lam) \u001B[38;5;241m*\u001B[39m np\u001B[38;5;241m.\u001B[39msin(theta \u001B[38;5;241m/\u001B[39m \u001B[38;5;241m2\u001B[39m)],\n\u001B[1;32m 795\u001B[0m [\n\u001B[1;32m 796\u001B[0m np\u001B[38;5;241m.\u001B[39mexp(\u001B[38;5;241m1\u001B[39mj \u001B[38;5;241m*\u001B[39m phi) \u001B[38;5;241m*\u001B[39m np\u001B[38;5;241m.\u001B[39msin(theta \u001B[38;5;241m/\u001B[39m \u001B[38;5;241m2\u001B[39m),\n\u001B[1;32m 797\u001B[0m np\u001B[38;5;241m.\u001B[39mexp(\u001B[38;5;241m1\u001B[39mj \u001B[38;5;241m*\u001B[39m (phi \u001B[38;5;241m+\u001B[39m lam)) \u001B[38;5;241m*\u001B[39m np\u001B[38;5;241m.\u001B[39mcos(theta \u001B[38;5;241m/\u001B[39m \u001B[38;5;241m2\u001B[39m),\n\u001B[1;32m 798\u001B[0m ],\n\u001B[1;32m 799\u001B[0m ]\n\u001B[1;32m 800\u001B[0m )\n\u001B[1;32m 801\u001B[0m \u001B[38;5;28;01mreturn\u001B[39;00m gate\n", - "File \u001B[0;32m~/.virtualenvs/graph-compiler/lib/python3.9/site-packages/jax/_src/core.py:640\u001B[0m, in \u001B[0;36mTracer.__array__\u001B[0;34m(self, *args, **kw)\u001B[0m\n\u001B[1;32m 639\u001B[0m \u001B[38;5;28;01mdef\u001B[39;00m \u001B[38;5;21m__array__\u001B[39m(\u001B[38;5;28mself\u001B[39m, \u001B[38;5;241m*\u001B[39margs, \u001B[38;5;241m*\u001B[39m\u001B[38;5;241m*\u001B[39mkw):\n\u001B[0;32m--> 640\u001B[0m \u001B[38;5;28;01mraise\u001B[39;00m TracerArrayConversionError(\u001B[38;5;28mself\u001B[39m)\n", - "\u001B[0;31mTracerArrayConversionError\u001B[0m: The numpy.ndarray conversion method __array__() was called on traced array with shape float32[].\nSee https://jax.readthedocs.io/en/latest/errors.html#jax.errors.TracerArrayConversionError" - ] - } - ], + "execution_count": null, + "outputs": [], "source": [ "cost_curve = []\n", "for step in (pbar := tqdm.tqdm(range(n_step), disable=(not progress))):\n", From d618a35c01fd538cac653a726fd25e21637c29c3 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 21:30:29 -0400 Subject: [PATCH 222/227] [docs] Say what does not work on the jax density-matrix path in this version GradientDescentSolver and 8_variational_circuits.ipynb present the jax array library as usable, and nothing said that it is not. In this version: - setting graphiq.DENSITY_MATRIX_ARRAY_LIBRARY after import graphiq does not switch the density-matrix backend to jax: importing graphiq already imports that backend, which reads the setting once and keeps numpy. It only lifts GradientDescentSolver's own check, which reads the setting each time a solver is made. The backend uses jax only if the constant is edited in graphiq/__init__.py; - with jax selected, no density-matrix QuantumState can be built from a matrix: QuantumState accepts only numpy arrays, while DensityMatrix accepts only jax arrays; - the density-matrix arm of Infidelity cannot be differentiated, since it takes the trace with numpy and branches on its value. What still works: with jax selected by editing the constant, GradientDescentSolver runs with states built from a number of qubits and a jax-differentiable metric of the user's own. GradientDescentSolver's class docstring now says this, and the notebook's title cell says the notebook does not run as written and that its saved outputs come from an older version. Text only: no code, cell source other than the title cell, or saved output changes. --- examples/startup_guide/8_variational_circuits.ipynb | 4 +++- graphiq/solvers/gradient_descent_solver.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/examples/startup_guide/8_variational_circuits.ipynb b/examples/startup_guide/8_variational_circuits.ipynb index 15e1eb3d..aefb6039 100644 --- a/examples/startup_guide/8_variational_circuits.ipynb +++ b/examples/startup_guide/8_variational_circuits.ipynb @@ -3,7 +3,9 @@ { "cell_type": "markdown", "source": [ - "# Parameterized quantum circuits [Experimental]" + "# Parameterized quantum circuits [Experimental]\n", + "\n", + "**Not supported in this version.** This notebook does not run as written: setting `graphiq.DENSITY_MATRIX_ARRAY_LIBRARY` after `import graphiq` does not switch the density-matrix backend to `jax`; it keeps numpy. With `jax` selected (by editing `DENSITY_MATRIX_ARRAY_LIBRARY` in `graphiq/__init__.py`), no density-matrix `QuantumState` can be built from a matrix (`QuantumState` rejects jax arrays and `DensityMatrix` rejects numpy arrays), so the target below cannot be built, and the density-matrix arm of `Infidelity` cannot be differentiated. Its saved outputs come from an older version." ], "metadata": { "collapsed": false, diff --git a/graphiq/solvers/gradient_descent_solver.py b/graphiq/solvers/gradient_descent_solver.py index 685380d5..f195ca7d 100644 --- a/graphiq/solvers/gradient_descent_solver.py +++ b/graphiq/solvers/gradient_descent_solver.py @@ -37,6 +37,14 @@ class GradientDescentSolver(SolverBase): A solver class based on gradient descent algorithms. Requires the use of `jax` as the numerical backend. The optimizer routine is provided by the `optax` package. + + Not supported in this version: setting `graphiq.DENSITY_MATRIX_ARRAY_LIBRARY` to `jax` after + `import graphiq` only lifts this class's own check; the density-matrix backend keeps numpy. With + `jax` selected (by editing `DENSITY_MATRIX_ARRAY_LIBRARY` in `graphiq/__init__.py`), no + density-matrix QuantumState can be built from a matrix (QuantumState rejects jax arrays and + DensityMatrix rejects numpy arrays), and the density-matrix arm of Infidelity cannot be + differentiated. With `jax` selected that way, GradientDescentSolver runs with states built from a + number of qubits and a jax-differentiable metric of the user's own. """ name = "gradient-descent-solver" From 5e9e73f08bc3b9029dba79ac527ef18d7892e5d3 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 21:30:29 -0400 Subject: [PATCH 223/227] [fix] Stop _label_finder returning repeated permutations for small graphs For graphs under 8 nodes, or whenever it is asked for an exhaustive search, _label_finder drew its permutations with replacement (rng.choice(perm[1:], n_label - 1)), so it could return the same permutation more than once. automorph_check removes the duplicate graphs, so callers saw missing relabellings rather than duplicate ones: iso_finder could stop short of the graphs that exist and warn "Maximum of N possible isomorphic graphs exist" for a count below the real one. For example, iso_finder on a 4-node path, which has 12 distinct labellings, returned 8 to 11 of them for 169 of seeds 0-199. _label_finder now keeps the same draw, drops the repeats, and tops up without replacement from the permutations that were not drawn. A draw with no repeat gives exactly the rows it gave before, in the same order. It also returns [[0]] for a 1-node graph, where it raised ValueError, so iso_finder on a 1-node graph returns that graph. This changes seeded results. A seeded iso_finder or AlternateTargetSolver run changes only if one of its rounds drew a repeated permutation; it may then return different, and sometimes more, relabelled graphs. On a grid of 1480 seeded iso_finder calls on 4- to 7-node graphs, 350 drew a repeat and 236 of those changed. Graphs of 8 or more nodes take the other branch unless the search goes exhaustive. The data_collection helpers find_best, graph_analyzer and iso_scaling_test run with seed 1 by default, so their default outputs change. find_best now finds every reordering: 12 instead of 11 for a 4-node path, 60 instead of 51 for a 5-node path, 360 instead of 311 for a 6-node path. The CSV and JSON files it saves, and graph_analyzer's bests.txt ("number of iso found", "total number of cases" and the best/worst picks when they depend on the new cases), change with it. orbit_analyzer and lcs ask for one ordering and are unchanged. new_label_set is still ignored in this branch, so each iso_finder round still redraws its labellings instead of extending the previous round's. test_label_map_has_one_map_per_returned_matrix_after_a_longer_search moves from seed 5 to seed 0: seed 5 needed a longer search only because its first draw repeated a permutation, while seed 0 needs one because of the graph's own symmetry. --- graphiq/utils/relabel_module.py | 18 +++++++--- tests/test_utils/test_relabel_module.py | 46 +++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 28d12f51..8b7b82b2 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -244,10 +244,20 @@ def _label_finder( ), f"The input number of permutations is more than the maximum possible" if n_node < 8 or exhaustive: perm = list(permutations([*range(n_node)])) - initial_perm = np.array([[*range(n_node)]]) - labels_list = rng.choice(perm[1:], n_label - 1) - labels_list = np.concatenate((initial_perm, labels_list), axis=0) - return labels_list + # the draw is with replacement: keep the first occurrence of each permutation, + # then top up without replacement from the permutations not drawn + drawn = rng.choice(perm[1:], n_label - 1) + labels_list = [perm[0]] + seen = {perm[0]} + for label in map(tuple, drawn): + if label not in seen: + seen.add(label) + labels_list.append(label) + if len(labels_list) < n_label: + unused = [label for label in perm if label not in seen] + top_up = rng.choice(len(unused), n_label - len(labels_list), replace=False) + labels_list += [unused[i] for i in sorted(top_up)] + return np.array(labels_list) else: if new_label_set is None: new_label_set = set() diff --git a/tests/test_utils/test_relabel_module.py b/tests/test_utils/test_relabel_module.py index 490a4d17..45fc7971 100644 --- a/tests/test_utils/test_relabel_module.py +++ b/tests/test_utils/test_relabel_module.py @@ -142,9 +142,9 @@ def test_label_map_applies_when_the_first_batch_is_enough(): def test_label_map_has_one_map_per_returned_matrix_after_a_longer_search(): adj = _linear_cluster_adj(4) - assert not _returns_on_first_batch(adj, 5, seed=5) + assert not _returns_on_first_batch(adj, 5, seed=0) - found = iso_finder(adj, 5, sort_emit=False, label_map=True, seed=5) + found = iso_finder(adj, 5, sort_emit=False, label_map=True, seed=0) _assert_maps_pair_with_matrices(adj, found) assert len(found[0]) == 5 @@ -269,3 +269,45 @@ def test_linear_partial_orbit_rejects_a_path_plus_a_cycle(): g = nx.Graph([(0, 1), (1, 2), (2, 3), (4, 5), (5, 6), (6, 4)]) with pytest.raises(AssertionError, match="not a linear graph"): linear_partial_orbit(g) + + +@pytest.mark.parametrize("n_node", [3, 4, 5]) +@pytest.mark.parametrize("seed", range(5)) +def test_label_finder_returns_distinct_permutations_for_small_graphs(n_node, seed): + # below 8 nodes the permutations are drawn from all n_node! of them; asking for + # every one must return every one, with the identity first + n_label = math.factorial(n_node) + labels = _label_finder(n_label, n_node, seed=seed) + assert labels.shape == (n_label, n_node) + assert {tuple(label) for label in labels} == set(permutations(range(n_node))) + assert tuple(labels[0]) == tuple(range(n_node)) + + +@pytest.mark.parametrize("seed", range(10)) +def test_iso_finder_finds_every_labelling_of_a_four_node_path(seed): + # a path on 4 nodes has 4!/2 = 12 distinct labellings + adj = nx.to_numpy_array(nx.path_graph(4)) + found = iso_finder(adj, 12, seed=seed) + assert len({tuple(a.astype(int).flatten()) for a in found}) == 12 + + +@pytest.mark.parametrize("n_node, n_label, seed", [(4, 5, 0), (5, 10, 4), (6, 20, 1)]) +def test_label_finder_keeps_a_draw_that_has_no_repeat(n_node, n_label, seed): + # a seeded draw without repeats gives the same permutations, in the same order, as + # drawing n_label - 1 of the non-identity permutations with replacement + perm = list(permutations(range(n_node))) + drawn = np.random.default_rng(seed).choice(perm[1:], n_label - 1) + assert len({tuple(d) for d in drawn}) == n_label - 1 + expected = np.concatenate((np.array([perm[0]]), drawn), axis=0) + assert np.array_equal(_label_finder(n_label, n_node, seed=seed), expected) + + +def test_label_finder_on_one_node_returns_the_identity(): + assert _label_finder(1, 1, seed=0).tolist() == [[0]] + + +def test_iso_finder_on_one_node_returns_the_graph(): + adj = np.zeros((1, 1)) + found = iso_finder(adj, 1, seed=0) + assert len(found) == 1 + assert np.array_equal(found[0], adj) From cce2f0702ec7387a93915bd7868ded36a359dc64 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 21:30:29 -0400 Subject: [PATCH 224/227] [fix] Look up AlternateTargetSolver's relabel map by node label in noise_score noise_score reads the target's adjacency matrix in the graph's node order, but it built the relabelling permutation by looking the relabel map up with the integers 0..n-1. The map is keyed by the target's own node labels. So with noise simulation on (a noise_model_mapping given, not Monte Carlo), a target whose nodes are not labelled 0..n-1 raised KeyError. That includes linear_cluster_state(n).data, which is labelled 1..n, graphs with string labels, and QuantumState graph targets built on such graphs. A target labelled 0..n-1 but with its nodes inserted out of order raised nothing, but was scored against a relabelled target that was not the graph its circuit makes: with depolarizing noise and seed 1, the path 2-0-1 inserted as [2, 0, 1] scored 0.762727 for its first candidate instead of 0.076624. Look the map up by each node's label, in the order the adjacency matrix is read, and drop the node count that only the old lookup used. Every target labelled 0..n-1 in insertion order builds the same permutation as before, so its scores are bit-identical. That covers every target the tests passed to the solver before this change and every graph t_graph builds itself; a graph drawn with t_graph's "draw" or passed in with "nx" can be labelled either way. A target labelled otherwise now gets a score instead of KeyError, and a 0..n-1 target inserted out of order now gets the correct score (unless the old permutation happened to be an automorphism of the target), so its candidates can rank differently. Noise-free and Monte Carlo solves do not call noise_score and are unchanged. --- graphiq/solvers/alternate_target_solver.py | 3 +- .../test_alternate_target_solver.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index cd48ca18..83f09969 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -400,9 +400,8 @@ def noise_score(self, circuit, relabel_map): """ graph = self.target_graph old_adj = nx.to_numpy_array(graph) - n = graph.number_of_nodes() # relabel the target graph - new_labels = np.array([relabel_map[i] for i in range(n)]) + new_labels = np.array([relabel_map[v] for v in graph.nodes()]) new_adj = relabel(old_adj, new_labels) graph = nx.from_numpy_array(new_adj) diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index 962ca74a..9079dddc 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -353,3 +353,42 @@ def test_a_stabilizer_target_not_in_standard_form_is_accepted(): solver = AlternateTargetSolver(target=QuantumState(tableau, rep_type="s")) assert sorted(solver.target_graph.edges) == [(0, 1), (0, 2)] + + +def _noisy_scores(target): + """Scores of a seeded depolarizing solve (density-matrix noise compiler, two candidates).""" + setting = AlternateTargetSolverSetting(n_iso_graphs=2, n_lc_graphs=1) + solver = AlternateTargetSolver( + target=target, + solver_setting=setting, + noise_model_mapping="depolarizing", + seed=1, + ) + return [properties["score"] for _, properties in solver.solve()] + + +@pytest.mark.parametrize( + "target", + [ + linear_cluster_state(3).data, + QuantumState(linear_cluster_state(3).data, rep_type="g"), + nx.relabel_nodes(nx.path_graph(3), {0: "a", 1: "b", 2: "c"}), + ], + ids=["nodes 1..3", "graph QuantumState", "string nodes"], +) +def test_noisy_solve_accepts_a_target_not_labelled_0_to_n_minus_1(target): + assert _noisy_scores(target) == pytest.approx(_noisy_scores(nx.path_graph(3))) + + +def test_noisy_solve_scores_a_target_inserted_out_of_label_order_as_its_relabelling(): + # the path 2-0-1 with its nodes inserted as [2, 0, 1]; solve reads the target in + # insertion order, so it must score as the same graph relabelled 0..n-1 in that order + out_of_order = nx.Graph([(2, 0), (0, 1)]) + in_order = nx.convert_node_labels_to_integers(out_of_order) + assert _noisy_scores(out_of_order) == pytest.approx(_noisy_scores(in_order)) + + +def test_noisy_solve_keeps_the_score_of_a_target_labelled_in_order(): + assert _noisy_scores(nx.path_graph(3)) == pytest.approx( + [0.0766238732123441, 0.08587445241234315] + ) From 65d4bbb7d82e21e02ae0f76e9fef9d0e88903274 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Sun, 27 Sep 2026 22:06:52 -0400 Subject: [PATCH 225/227] [update] Bump version to 0.1.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 69a0a4c2..a722a523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ include = ["graphiq*"] [project] name = "graphiq" -version = "0.1.1" +version = "0.1.2" requires-python = ">3.8, <4" dependencies = [ From c64dfe5e7b0cecadb8031c5c0804c7711ba5cba7 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Mon, 28 Sep 2026 09:57:14 -0400 Subject: [PATCH 226/227] [fix] Install only ray from the all extra in the Python 3.8 CI job The pytest workflow installs the package with its all extra (jax, optax, ray) on Python 3.8, 3.9 and 3.10. On 3.8 that install now fails with a pip ResolutionImpossible between optax and jaxlib: PyPI no longer has any jaxlib release with a Python 3.8 wheel, optax 0.1.8 and earlier depend on jaxlib, and later optax releases require Python 3.9 or newer. So the 3.8 job stopped before running any test. On 3.8 the job now installs ray alone, which test_solver_monte_carlo needs for its parallel Monte Carlo run; 3.9 and 3.10 still install the whole extra, so jax and optax are tested there. The package, its dependencies and its results do not change. --- .github/workflows/pytest.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7938e963..17d54043 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -21,7 +21,9 @@ jobs: python -m pip install --upgrade pip pip install flake8 pytest pip install . - pip install .[all] + # optax needs jaxlib, which has no Python 3.8 wheels on PyPI any more, + # so on 3.8 only ray is installed from the "all" extra. + if [ "${{ matrix.python-version }}" = "3.8" ]; then pip install ray; else pip install .[all]; fi # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | From df40b93ba315651a17d45481764737a86e940c98 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Mon, 28 Sep 2026 10:25:23 -0400 Subject: [PATCH 227/227] [test] Build the forced-measurement test's rounding-level state with a tiny rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_a_rounding_error_probability_is_not_forced built |0> as Hadamard twice on one emitter and required p(1) to be nonzero and below 1e-20. Whether H·H leaves any p(1) at all depends on the BLAS kernel numpy uses: with numpy 1.24.4, kernels that fuse multiply-add leave 6e-34, while the kernels without FMA that were tried (older x86 cores, and Rosetta, which does not advertise AVX) leave exactly 0, so the precondition failed there even though the measurement code is correct. The circuit now applies one rotation by 2e-17, which leaves p(1) = sin^2(1e-17), about 1e-34. That is a single product with nothing to cancel, so it is the same nonzero value on every kernel, and far below the rounding floor. The test still compiles the circuit, and still fails on the code before the floor (outcome 1 instead of 0). The library does not change. --- .../density_matrix/test_measurements.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_state_representation/density_matrix/test_measurements.py b/tests/test_state_representation/density_matrix/test_measurements.py index 5df8d493..9913b573 100644 --- a/tests/test_state_representation/density_matrix/test_measurements.py +++ b/tests/test_state_representation/density_matrix/test_measurements.py @@ -161,12 +161,16 @@ def _forced_boundary_state(forced_index, forced_trace, other_trace): def test_a_rounding_error_probability_is_not_forced(): - # Hadamard twice is the identity, so the state is |0>, but p(1) comes out as rounding - # error; the precondition makes the test fail loudly, rather than pass without - # testing anything, on a platform whose roundoff differs + # a rotation by 2e-17 leaves p(1) = sin^2(1e-17), about 1e-34: one product with + # nothing to cancel, so it is nonzero on every BLAS kernel. It is a real branch, but + # far below the floor, which cannot tell a probability that small from rounding + # error; the precondition checks that p(1) is nonzero and far below the floor circuit = CircuitDAG(n_emitter=1, n_classical=0) - circuit.add(ops.Hadamard(register=0, reg_type="e")) - circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.ParameterizedOneQubitRotation( + register=0, reg_type="e", params=(2e-17, 0.0, 0.0) + ) + ) state = DensityMatrixCompiler().compile(circuit).rep_data projectors = dmf.projectors_zbasis(1, 0) assert 0 < np.real(np.trace(state.data @ projectors[1])) < 1e-20