From d62c0469101a62dafef0b03a59e27beb1d16b38d Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Fri, 15 May 2026 07:26:15 -0400 Subject: [PATCH] Add a layer inference transpiler pass --- samplomatic/transpiler/__init__.py | 9 +- .../generate_boxing_pass_manager.py | 18 + samplomatic/transpiler/layer_inference.py | 408 ++++++++++++++++++ samplomatic/transpiler/passes/__init__.py | 1 + .../passes/insert_layer_barriers.py | 52 +++ .../test_transpiler/test_layer_inference.py | 285 ++++++++++++ 6 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 samplomatic/transpiler/layer_inference.py create mode 100644 samplomatic/transpiler/passes/insert_layer_barriers.py create mode 100644 test/unit/test_transpiler/test_layer_inference.py diff --git a/samplomatic/transpiler/__init__.py b/samplomatic/transpiler/__init__.py index 70b48f08..ffcbc9f0 100644 --- a/samplomatic/transpiler/__init__.py +++ b/samplomatic/transpiler/__init__.py @@ -1,6 +1,6 @@ # This code is a Qiskit project. # -# (C) Copyright IBM 2025. +# (C) Copyright IBM 2025, 2026. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -13,3 +13,10 @@ """Transpiler""" from .generate_boxing_pass_manager import generate_boxing_pass_manager +from .layer_inference import ( + LayerInferenceError, + _InferredLayer, + infer_layers, + insert_layer_barriers, +) +from .passes import InsertLayerBarriers diff --git a/samplomatic/transpiler/generate_boxing_pass_manager.py b/samplomatic/transpiler/generate_boxing_pass_manager.py index e44edf99..d47f6009 100644 --- a/samplomatic/transpiler/generate_boxing_pass_manager.py +++ b/samplomatic/transpiler/generate_boxing_pass_manager.py @@ -12,6 +12,7 @@ """generate_boxing_pass_manager""" +from collections.abc import Iterable, Sequence # noqa: F401 from typing import Literal from qiskit.transpiler import PassManager @@ -26,6 +27,7 @@ AddTerminalRightDressedBoxes, GroupGatesIntoBoxes, GroupMeasIntoBoxes, + InsertLayerBarriers, ) from .passes.insert_noops import AddNoopsActiveAccum, AddNoopsActiveCircuit, AddNoopsAll @@ -68,6 +70,7 @@ def generate_boxing_pass_manager( "immediately", "finally", "after_stratification", "never", True, False ] = "after_stratification", add_tags: Literal["none", "unique_box", "unique_instance", "noise_ref"] = "none", + infer_layers: "bool | Sequence[Iterable[tuple[int, int]]]" = False, ) -> PassManager: """Construct a pass manager to group the operations in a circuit into boxes. @@ -234,6 +237,17 @@ def generate_boxing_pass_manager( skipped (no :class:`~.Tag` is added). Typically used together with a non-``'none'`` ``inject_noise_targets`` value. + infer_layers: Whether to insert helper barriers at inferred 2Q layer boundaries + using :class:`~.InsertLayerBarriers` before grouping. The supported values are: + + * ``False`` (default) to disable layer inference entirely. + * ``True`` to infer the layer templates from any barrier-separated regions of + the input circuit. + * A sequence of layer templates (each a collection of qubit-index pairs + ``(q0, q1)``) to use as explicit layer types. Useful when the input circuit + has had its barriers removed by the transpiler so that the layer pattern + cannot be recovered by inference alone. + Returns: A pass manager that groups operations into boxes. @@ -250,6 +264,10 @@ def generate_boxing_pass_manager( if remove_barriers == "immediately": passes.append(RemoveBarriers()) + if infer_layers is not False: + templates = None if infer_layers is True else infer_layers + passes.append(InsertLayerBarriers(layer_templates=templates)) + if enable_gates: passes.append( GroupGatesIntoBoxes([Twirl(group=twirling_group, decomposition=decomposition)]) diff --git a/samplomatic/transpiler/layer_inference.py b/samplomatic/transpiler/layer_inference.py new file mode 100644 index 00000000..897a3072 --- /dev/null +++ b/samplomatic/transpiler/layer_inference.py @@ -0,0 +1,408 @@ +# This code is a Qiskit project. +# +# (C) Copyright IBM 2026. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Pattern-free 2Q layer inference for transpiled circuits. + +When a layered circuit (e.g. a Trotter circuit ``(ABCCBA)^n``) is transpiled +with some of its layer barriers removed, the transpiler is free to reorder +commuting 2Q gates and fuse same-pair occurrences across layer boundaries. +The greedy box-grouping algorithm in :class:`~.GroupGatesIntoBoxes` then has +no way to recover the intended layer structure. + +This module provides :func:`infer_layers` and :func:`insert_layer_barriers`, +which detect 2Q layer boundaries by walking the circuit DAG in topological +order and placing each 2Q gate into the **earliest layer** whose qubits are +free *and* whose accumulated set of tenable layer types still contains the +gate's pair. After detection, the circuit is re-emitted with full-width +barriers at layer boundaries. The barriers guide +:class:`~.GroupGatesIntoBoxes` and are removed before +:class:`~.AbsorbSingleQubitGates` when used with the default +``remove_barriers="after_stratification"`` setting of +:func:`~.generate_boxing_pass_manager`. + +Unlike the earlier prototype, the algorithm requires neither the layer +*sequence* nor a per-pair gate-block size. It tolerates same-pair fusion at +step boundaries (the fused block is just one block on a fresh layer of that +type), and reorderings the transpiler effects via disjoint-qubit +commutation (a gate that doesn't fit any open layer simply opens a new +one). Reorderings exploiting gate-level commutation between gates that +share a qubit are not undone by this algorithm, so supply explicit +``layer_templates`` whenever the circuit may exhibit such reorderings. +""" + +import heapq +from collections import defaultdict +from collections.abc import Iterable, Sequence +from dataclasses import dataclass + +from qiskit.circuit import Barrier, QuantumCircuit +from qiskit.converters import circuit_to_dag, dag_to_circuit +from qiskit.dagcircuit import DAGCircuit + +from ..aliases import DAGOpNode + + +class LayerInferenceError(ValueError): + """Raised when the layer-inference algorithm cannot make progress.""" + + +@dataclass(frozen=True) +class _InferredLayer: + """A detected 2Q layer.""" + + nodes: tuple[DAGOpNode, ...] + """The 2Q :class:`DAGOpNode` instances assigned to this layer.""" + + pairs: frozenset[frozenset[int]] + """The qubit-index pairs covered by this layer, as length-2 frozensets.""" + + tenable: frozenset[int] + """Indices into the ``layer_templates`` argument. + + These specify the template types still consistent with this layer. In a "well-formed" circuit + this is a singleton; a length greater than 1 means the layer is locally type-ambiguous. + """ + + +def infer_layers( + circuit: QuantumCircuit, + layer_templates: Sequence[Iterable[tuple[int, int]]] | None = None, +) -> list[_InferredLayer]: + """Detect 2Q layer structure of a (transpiled) circuit. + + Args: + circuit: The circuit to analyse. Qubit indices in ``layer_templates`` + refer to positions in ``circuit.qubits``. For an ISA circuit, + translate logical pairs through + ``isa.layout.final_index_layout(filter_ancillas=True)`` first. + layer_templates: Optional list of layer types, each a collection of + qubit-index pairs ``(q0, q1)``. Pair direction is irrelevant + (``(0, 1)`` and ``(1, 0)`` are the same). If ``None``, templates + are inferred from any barrier-separated regions in the circuit + (best-effort). + + Returns: + A list of :class:`InferredLayer` instances in execution order. + + Raises: + LayerInferenceError: If a 2Q gate's pair is not in any layer + template (e.g. a SWAP introduced by routing). + """ + if layer_templates is None: + layer_templates = _infer_templates_from_barriers(circuit) + + templates = [frozenset(frozenset(pair) for pair in t) for t in layer_templates] + qubit_index = {q: i for i, q in enumerate(circuit.qubits)} + dag = circuit_to_dag(circuit) + layers, _ = _assign_layers(dag, templates, qubit_index) + return layers + + +def insert_layer_barriers( + circuit: QuantumCircuit, + layer_templates: Sequence[Iterable[tuple[int, int]]] | None = None, +) -> QuantumCircuit: + """Insert full-width barriers at inferred 2Q layer boundaries. + + The result is suitable for feeding into + :func:`~.generate_boxing_pass_manager` with the default + ``remove_barriers="after_stratification"``. + + Args: + circuit: The circuit to barrier-stratify. + layer_templates: As in :func:`infer_layers`. ``None`` enables + barrier-region inference. + + Returns: + A new :class:`QuantumCircuit` with original barriers dropped and a + full-width :class:`~qiskit.circuit.Barrier` inserted at every + detected layer boundary. + + Raises: + LayerInferenceError: If layer detection cannot make progress. + """ + if layer_templates is None: + layer_templates = _infer_templates_from_barriers(circuit) + + templates = [frozenset(frozenset(pair) for pair in t) for t in layer_templates] + qubit_index = {q: i for i, q in enumerate(circuit.qubits)} + dag = circuit_to_dag(circuit) + layers, node_layer = _assign_layers(dag, templates, qubit_index) + return _emit_with_layer_barriers(dag, layers, node_layer) + + +def _qpair_indices(node: DAGOpNode, qubit_index: dict) -> frozenset[int]: + """Return the unordered pair of qubit indices for a 2Q op node.""" + return frozenset(qubit_index[q] for q in node.qargs) + + +def _is_2q_gate(node: DAGOpNode) -> bool: + """Whether a DAG op node is a standard 2Q gate (not barrier/measure/box).""" + if node.op.name in ("barrier", "measure", "reset", "box"): + return False + return getattr(node.op, "num_qubits", 0) == 2 + + +def _assign_layers( + dag: DAGCircuit, + templates: list[frozenset[frozenset[int]]], + qubit_index: dict, +) -> tuple[list[_InferredLayer], dict[DAGOpNode, int]]: + """Walk ``dag`` and place each 2Q gate in the earliest tenable layer. + + Each "layer" carries: + * a *tenable* set of template-type indices — narrowed as gates are + placed so the layer never mixes incompatible types, + * a *used qubits* set, + * a list of placed gate nodes. + + Per-qubit "next-available-layer" indices are maintained so that two gates sharing a qubit always + land in different layers (and the later one lands no earlier than ``next_available + 1``). + + Returns the ordered list of detected layers (containing only non-empty ones) and a map from node + to its layer index in that list. + """ + n_types = len(templates) + if n_types == 0: + return _assign_layers_no_templates(dag, qubit_index) + + # Compute, for each qubit pair, the set of compatible layer-type indices. + pair_to_types: dict[frozenset[int], frozenset[int]] = {} + for t_idx, template in enumerate(templates): + for pair in template: + existing = pair_to_types.get(pair, frozenset()) + pair_to_types[pair] = existing | {t_idx} + + # Per-layer state. + layer_tenable: list[frozenset[int]] = [] + layer_used: list[set[int]] = [] + layer_nodes: list[list[DAGOpNode]] = [] + layer_pairs: list[list[frozenset[int]]] = [] + + next_available: dict[int, int] = defaultdict(int) + node_layer: dict[DAGOpNode, int] = {} + + def _ensure_layer(idx: int) -> None: + while len(layer_tenable) <= idx: + layer_tenable.append(frozenset(range(n_types))) + layer_used.append(set()) + layer_nodes.append([]) + layer_pairs.append([]) + + for node in dag.topological_op_nodes(): + if node.op.name == "barrier": + qidxs = [qubit_index[q] for q in node.qargs] + fresh = max((next_available[q] for q in qidxs), default=0) + fresh = max(fresh, len(layer_tenable)) + for q in qidxs: + next_available[q] = fresh + continue + if not _is_2q_gate(node): + continue + + pair = _qpair_indices(node, qubit_index) + compatible = pair_to_types.get(pair) + if compatible is None: + sorted_pair = tuple(sorted(pair)) + raise LayerInferenceError( + f"2Q gate '{node.op.name}' on qubits {sorted_pair} does not match any " + f"layer template. (If this is a SWAP from routing, that case is out of " + f"scope; otherwise add the pair to layer_templates.)" + ) + + qidxs = [qubit_index[q] for q in node.qargs] + target = max((next_available[q] for q in qidxs), default=0) + while True: + _ensure_layer(target) + new_tenable = layer_tenable[target] & compatible + qubit_free = not (set(qidxs) & layer_used[target]) + if qubit_free and new_tenable: + layer_tenable[target] = new_tenable + layer_used[target].update(qidxs) + layer_nodes[target].append(node) + layer_pairs[target].append(pair) + node_layer[node] = target + for q in qidxs: + next_available[q] = target + 1 + break + target += 1 + + layers: list[_InferredLayer] = [] + layer_remap: dict[int, int] = {} + for old_idx, nodes in enumerate(layer_nodes): + if not nodes: + continue + layer_remap[old_idx] = len(layers) + layers.append( + _InferredLayer( + tuple(nodes), + frozenset(layer_pairs[old_idx]), + layer_tenable[old_idx], + ) + ) + + node_layer = {n: layer_remap[i] for n, i in node_layer.items()} + return layers, node_layer + + +def _assign_layers_no_templates( + dag: DAGCircuit, qubit_index: dict +) -> tuple[list[_InferredLayer], dict[DAGOpNode, int]]: + """Qubit-conflict-only layer assignment (no template constraint). + + Used as the inference primitive on barrier-separated regions, and as + the degenerate fallback when no templates are available. + """ + layer_used: list[set[int]] = [] + layer_nodes: list[list[DAGOpNode]] = [] + layer_pairs: list[list[frozenset[int]]] = [] + next_available: dict[int, int] = defaultdict(int) + node_layer: dict[DAGOpNode, int] = {} + + def _ensure_layer(idx: int) -> None: + while len(layer_used) <= idx: + layer_used.append(set()) + layer_nodes.append([]) + layer_pairs.append([]) + + for node in dag.topological_op_nodes(): + if node.op.name == "barrier": + qidxs = [qubit_index[q] for q in node.qargs] + fresh = max((next_available[q] for q in qidxs), default=0) + fresh = max(fresh, len(layer_used)) + for q in qidxs: + next_available[q] = fresh + continue + if not _is_2q_gate(node): + continue + + pair = _qpair_indices(node, qubit_index) + qidxs = [qubit_index[q] for q in node.qargs] + target = max((next_available[q] for q in qidxs), default=0) + while True: + _ensure_layer(target) + if not (set(qidxs) & layer_used[target]): + layer_used[target].update(qidxs) + layer_nodes[target].append(node) + layer_pairs[target].append(pair) + node_layer[node] = target + for q in qidxs: + next_available[q] = target + 1 + break + target += 1 + + layers: list[_InferredLayer] = [] + layer_remap: dict[int, int] = {} + for old_idx, nodes in enumerate(layer_nodes): + if not nodes: + continue + layer_remap[old_idx] = len(layers) + layers.append(_InferredLayer(tuple(nodes), frozenset(layer_pairs[old_idx]), frozenset())) + + node_layer = {n: layer_remap[i] for n, i in node_layer.items()} + return layers, node_layer + + +# --------------------------------------------------------------------------- +# Template inference fallback +# --------------------------------------------------------------------------- + + +def _infer_templates_from_barriers( + circuit: QuantumCircuit, +) -> list[set[tuple[int, int]]]: + """Infer layer templates by running qubit-conflict-only detection. + + Each closed layer's pair-set is collected as a candidate template type and deduplicated. + Best-effort: relies on the transpiler having preserved at least one clean barrier-separated + region per type. + """ + qubit_index = {q: i for i, q in enumerate(circuit.qubits)} + dag = circuit_to_dag(circuit) + layers, _ = _assign_layers_no_templates(dag, qubit_index) + + seen: list[frozenset[frozenset[int]]] = [] + for layer in layers: + if layer.pairs not in seen: + seen.append(layer.pairs) + + return [{tuple(sorted(p)) for p in pair_set} for pair_set in seen] + + +# --------------------------------------------------------------------------- +# Re-emission with barriers +# --------------------------------------------------------------------------- + + +def _emit_with_layer_barriers( + dag: DAGCircuit, + layers: list[_InferredLayer], + node_layer: dict[DAGOpNode, int], +) -> QuantumCircuit: + """Re-linearise the DAG, emitting full-width barriers at boundaries. + + A priority-driven topological sort: each 2Q gate's priority is its detected layer index, 1Q + gates emit ASAP (priority -1), existing barriers are dropped (priority -2). A full-width barrier + is inserted whenever the popped 2Q gate's layer is strictly greater than the + most-recently-emitted layer. + """ + op_nodes = list(dag.op_nodes()) + in_degree: dict[DAGOpNode, int] = {n: 0 for n in op_nodes} + op_node_set = set(op_nodes) + successors: dict[DAGOpNode, list[DAGOpNode]] = {n: [] for n in op_nodes} + for u in op_nodes: + for v in dag.successors(u): + if v in op_node_set: + successors[u].append(v) + in_degree[v] += 1 + + counter = 0 + heap: list[tuple[int, int, int, DAGOpNode]] = [] + + def priority(node: DAGOpNode) -> int: + if node.op.name == "barrier": + return -2 + if node in node_layer: + return node_layer[node] + return -1 + + def push(node: DAGOpNode) -> None: + nonlocal counter + heapq.heappush(heap, (priority(node), counter, id(node), node)) + counter += 1 + + for n in op_nodes: + if in_degree[n] == 0: + push(n) + + new_dag = dag.copy_empty_like() + num_qubits = len(dag.qubits) + qubit_list = list(dag.qubits) + last_layer_emitted: int | None = None + + while heap: + _, _, _, node = heapq.heappop(heap) + if node.op.name == "barrier": + pass # drop existing barriers + else: + if node in node_layer: + cur = node_layer[node] + if last_layer_emitted is not None and cur > last_layer_emitted: + new_dag.apply_operation_back(Barrier(num_qubits), qubit_list, []) + last_layer_emitted = cur + new_dag.apply_operation_back(node.op, node.qargs, node.cargs) + for s in successors[node]: + in_degree[s] -= 1 + if in_degree[s] == 0: + push(s) + + return dag_to_circuit(new_dag) diff --git a/samplomatic/transpiler/passes/__init__.py b/samplomatic/transpiler/passes/__init__.py index 842bfe60..215fdb8c 100644 --- a/samplomatic/transpiler/passes/__init__.py +++ b/samplomatic/transpiler/passes/__init__.py @@ -19,3 +19,4 @@ from .group_gates_into_boxes import GroupGatesIntoBoxes from .group_meas_into_boxes import GroupMeasIntoBoxes from .inline_boxes import InlineBoxes +from .insert_layer_barriers import InsertLayerBarriers diff --git a/samplomatic/transpiler/passes/insert_layer_barriers.py b/samplomatic/transpiler/passes/insert_layer_barriers.py new file mode 100644 index 00000000..d50aa842 --- /dev/null +++ b/samplomatic/transpiler/passes/insert_layer_barriers.py @@ -0,0 +1,52 @@ +# This code is a Qiskit project. +# +# (C) Copyright IBM 2026. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""InsertLayerBarriers""" + +from collections.abc import Iterable, Sequence + +from qiskit.converters import circuit_to_dag, dag_to_circuit +from qiskit.dagcircuit import DAGCircuit +from qiskit.transpiler.basepasses import TransformationPass + +from ..layer_inference import insert_layer_barriers + + +class InsertLayerBarriers(TransformationPass): + """Insert full-width barriers at inferred 2Q layer boundaries. + + Wraps :func:`~.insert_layer_barriers` as a Qiskit transpiler pass. Intended to run before + :class:`~.GroupGatesIntoBoxes` so that the barriers it inserts guide gate grouping. When used + inside :func:`~.generate_boxing_pass_manager` with the default + ``remove_barriers="after_stratification"``, the barriers are removed again before single-qubit + absorption, leaving 1Q dressing intact. + + Args: + layer_templates: Optional list of layer types, each a collection of + qubit-index pairs ``(q0, q1)``. If ``None``, templates are + inferred from any barrier-separated regions in the input. + """ + + def __init__( + self, + layer_templates: Sequence[Iterable[tuple[int, int]]] | None = None, + ): + super().__init__() + self.layer_templates = ( + None if layer_templates is None else [list(t) for t in layer_templates] + ) + + def run(self, dag: DAGCircuit) -> DAGCircuit: + """Run the pass on a DAG.""" + circuit = dag_to_circuit(dag) + new_circuit = insert_layer_barriers(circuit, self.layer_templates) + return circuit_to_dag(new_circuit) diff --git a/test/unit/test_transpiler/test_layer_inference.py b/test/unit/test_transpiler/test_layer_inference.py new file mode 100644 index 00000000..d2f9ec14 --- /dev/null +++ b/test/unit/test_transpiler/test_layer_inference.py @@ -0,0 +1,285 @@ +# This code is a Qiskit project. +# +# (C) Copyright IBM 2026. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Tests for samplomatic.transpiler.layer_inference.""" + +import pytest +from qiskit import transpile +from qiskit.circuit import Parameter, QuantumCircuit +from qiskit.transpiler import CouplingMap + +from samplomatic.annotations import InjectNoise +from samplomatic.transpiler import ( + InsertLayerBarriers, + LayerInferenceError, + _InferredLayer, + generate_boxing_pass_manager, + infer_layers, + insert_layer_barriers, +) +from samplomatic.utils import find_unique_box_instructions, get_annotation + + +def _count_unique_gate_layers(boxed: QuantumCircuit) -> int: + gate_instrs = [ + i + for i in boxed.data + if i.operation.name == "box" and get_annotation(i.operation, InjectNoise) is not None + ] + return len(find_unique_box_instructions(gate_instrs)) + + +def _add_rzz_layer(qc: QuantumCircuit, pairs, angle): + for q0, q1 in sorted(pairs): + qc.rz(-angle / 2, q0) + qc.rz(-angle / 2, q1) + qc.rzz(angle, q0, q1) + qc.rz(angle / 2, q0) + qc.rz(angle / 2, q1) + + +def _trotter_circuit(num_qubits, layer_a, layer_b, layer_c, n_steps, m_barrier_free): + """Build an (ABCCBA)^n Trotter circuit. + + End-of-step barriers are inserted everywhere except after the last + ``m_barrier_free`` steps. + """ + theta = Parameter("θ") + qc = QuantumCircuit(num_qubits) + for step in range(n_steps): + for pairs in [layer_a, layer_b, layer_c, layer_c, layer_b, layer_a]: + _add_rzz_layer(qc, pairs, theta) + if step < n_steps - m_barrier_free: + qc.barrier() + qc.measure_all() + return qc + + +def _chain_three_coloring(num_qubits): + """Compute the standard 3-edge-coloring of a 1D chain (every third edge).""" + layer_a = {(i, i + 1) for i in range(0, num_qubits - 1, 3)} + layer_b = {(i, i + 1) for i in range(1, num_qubits - 1, 3)} + layer_c = {(i, i + 1) for i in range(2, num_qubits - 1, 3)} + return layer_a, layer_b, layer_c + + +@pytest.fixture +def boxing_pm(): + return generate_boxing_pass_manager( + enable_gates=True, + enable_measures=False, + inject_noise_targets="gates", + twirling_strategy="active", + ) + + +@pytest.fixture +def chain_isa_factory(): + """Return a function that builds an ISA Trotter circuit on a 12-qubit chain.""" + n = 12 + layer_a, layer_b, layer_c = _chain_three_coloring(n) + edges = [(i, i + 1) for i in range(n - 1)] + coupling = CouplingMap(edges + [(b, a) for a, b in edges]) + + logical = _trotter_circuit(n, layer_a, layer_b, layer_c, n_steps=4, m_barrier_free=2) + + def _build(seed): + return transpile( + logical, + basis_gates=["rzz", "rz", "sx", "x", "measure"], + coupling_map=coupling, + optimization_level=3, + seed_transpiler=seed, + ) + + return _build, [layer_a, layer_b, layer_c] + + +class TestExplicitTemplates: + """Layer inference with caller-supplied templates.""" + + def test_recovers_three_unique_types_across_seeds(self, boxing_pm, chain_isa_factory): + build_isa, templates = chain_isa_factory + for seed in range(8): + isa = build_isa(seed) + fixed = insert_layer_barriers(isa, templates) + assert ( + _count_unique_gate_layers(boxing_pm.run(fixed)) == 3 + ), f"seed={seed}: expected 3 unique gate layers" + + def test_failure_mode_without_fix(self, boxing_pm, chain_isa_factory): + build_isa, _ = chain_isa_factory + # Without the fix, the greedy boxing algorithm produces more than 3 unique + # types (typically 7) because it can't see through the transpiler's + # disjoint-qubit reorderings of the barrier-free section. + for seed in range(8): + isa = build_isa(seed) + assert ( + _count_unique_gate_layers(boxing_pm.run(isa)) > 3 + ), f"seed={seed}: expected the greedy baseline to fail" + + def test_via_pass_manager_kwarg(self, chain_isa_factory): + build_isa, templates = chain_isa_factory + pm = generate_boxing_pass_manager( + enable_gates=True, + enable_measures=False, + inject_noise_targets="gates", + twirling_strategy="active", + infer_layers=templates, + ) + for seed in range(4): + isa = build_isa(seed) + assert _count_unique_gate_layers(pm.run(isa)) == 3 + + def test_infer_layers_returns_full_layers(self, chain_isa_factory): + build_isa, templates = chain_isa_factory + isa = build_isa(seed=0) + layers = infer_layers(isa, templates) + # Every detected layer should have a singleton tenable type. + for layer in layers: + assert len(layer.tenable) == 1, f"layer {layer} is type-ambiguous" + # All four Trotter steps × 6 logical layers per step = 24 logical layers. + # The transpiler may or may not fuse same-pair adjacencies, but the count + # is bounded above by 24. + assert len(layers) <= 24 + + +class TestSamePairFusion: + """Same-pair fusion: a fused block on a single pair becomes one layer. + + This is the regime where the original notebook prototype's + count-divisibility check fails. + """ + + def test_two_adjacent_a_blocks_on_same_pair(self): + # Two RZZ gates back-to-back on the same pair (no dressing in between) + # — the kind of structure left over after transpile fuses two A-layer + # occurrences. The new algorithm produces two A-layers (one block each), + # not a count-divisibility error. + qc = QuantumCircuit(2) + qc.rzz(0.3, 0, 1) + qc.rzz(0.4, 0, 1) + templates = [{(0, 1)}] + layers = infer_layers(qc, templates) + assert len(layers) == 2 + for layer in layers: + assert layer.tenable == frozenset({0}) + + def test_count_mismatch_does_not_raise(self): + # Three RZZ on (0,1) with templates [{(0,1)}] (one type, one occurrence + # per layer): the original notebook would have raised a "must be a + # multiple of occurrences" error. The new algorithm just produces three + # separate layers of type 0. + qc = QuantumCircuit(2) + qc.rzz(0.1, 0, 1) + qc.rzz(0.2, 0, 1) + qc.rzz(0.3, 0, 1) + layers = infer_layers(qc, [{(0, 1)}]) + assert len(layers) == 3 + + +class TestTemplateInference: + """Layer inference without explicit templates.""" + + def test_falls_back_to_barrier_split(self): + # Two ABCCBA-style segments with a barrier in between. Inference should + # discover the same set of pair-set types in both halves. + qc = QuantumCircuit(6) + layer_a = [(0, 1), (2, 3), (4, 5)] + layer_b = [(1, 2), (3, 4)] + layer_c = [(0, 5)] + for pairs in [layer_a, layer_b, layer_c, layer_b, layer_a]: + for q0, q1 in pairs: + qc.rzz(0.1, q0, q1) + qc.barrier() + for pairs in [layer_a, layer_b, layer_c, layer_b, layer_a]: + for q0, q1 in pairs: + qc.rzz(0.1, q0, q1) + layers = infer_layers(qc) + # Inference is best-effort; here the qubit-conflict-only walk gives + # exactly the expected per-segment structure because B and C share + # qubits with A. We assert non-empty rather than a precise count. + assert len(layers) > 0 + + +class TestErrorPaths: + """Layer inference error reporting.""" + + def test_pair_not_in_any_template_raises(self): + qc = QuantumCircuit(3) + qc.rzz(0.1, 0, 1) + qc.rzz(0.1, 1, 2) # pair (1,2) not in any template + templates = [{(0, 1)}] + with pytest.raises(LayerInferenceError, match=r"qubits \(1, 2\)"): + infer_layers(qc, templates) + + def test_empty_templates_falls_through(self): + # With templates=[] (empty list), every closed layer is anonymous — + # the algorithm should still produce some assignment without error. + qc = QuantumCircuit(2) + qc.rzz(0.1, 0, 1) + layers = infer_layers(qc, []) + assert len(layers) == 1 + + +class TestInsertLayerBarriers: + """End-to-end tests: insert_layer_barriers + boxing pass manager.""" + + def test_idempotent_on_already_barriered_circuit(self, boxing_pm): + # If the circuit already has barriers between every layer, the + # algorithm should not change the unique-layer count. + qc = QuantumCircuit(4) + layer_a = [(0, 1), (2, 3)] + layer_b = [(1, 2)] + for pairs in [layer_a, layer_b, layer_a]: + for q0, q1 in pairs: + qc.rzz(0.1, q0, q1) + qc.barrier() + qc.measure_all() + templates = [{(0, 1), (2, 3)}, {(1, 2)}] + before = _count_unique_gate_layers(boxing_pm.run(qc)) + after = _count_unique_gate_layers(boxing_pm.run(insert_layer_barriers(qc, templates))) + assert before == after == 2 + + def test_pass_class_alias(self, chain_isa_factory): + # The TransformationPass wrapper produces the same result as the + # bare function. + from qiskit.transpiler import PassManager + + build_isa, templates = chain_isa_factory + isa = build_isa(seed=0) + pm = PassManager([InsertLayerBarriers(layer_templates=templates)]) + out_pass = pm.run(isa) + out_func = insert_layer_barriers(isa, templates) + # Same number of barriers (a quick structural check). + n_pass = sum(1 for inst in out_pass.data if inst.operation.name == "barrier") + n_func = sum(1 for inst in out_func.data if inst.operation.name == "barrier") + assert n_pass == n_func + + +class TestInferredLayerDataclass: + """Sanity tests for the InferredLayer dataclass.""" + + def test_immutable(self): + layer = _InferredLayer(nodes=(), pairs=frozenset(), tenable=frozenset({0})) + with pytest.raises(Exception): # noqa: B017 + layer.tenable = frozenset({1}) + + def test_fields(self): + qc = QuantumCircuit(2) + qc.rzz(0.1, 0, 1) + layers = infer_layers(qc, [{(0, 1)}]) + assert len(layers) == 1 + layer = layers[0] + assert isinstance(layer, _InferredLayer) + assert layer.pairs == frozenset({frozenset({0, 1})}) + assert layer.tenable == frozenset({0})