Código fuente para qilisdk.digital.circuit_transpiler_passes.custom_layout_pass

# Copyright 2026 Qilimanjaro Quantum Tech
#
# 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 __future__ import annotations

from collections import deque
from typing import TYPE_CHECKING

from qilisdk.digital import CNOT, CZ, RX, RY, RZ, SWAP, U3, Circuit, Gate, M
from qilisdk.digital.topology import build_topology_graph

from .circuit_transpiler_pass import CircuitTranspilerPass

if TYPE_CHECKING:
    from rustworkx import PyGraph

    from qilisdk.digital.types import LayoutMap, Topology


[documentos] class CustomLayoutPass(CircuitTranspilerPass): """ Apply a user-specified initial layout (logical→physical mapping) and retarget all gates accordingly. The returned circuit is resized to the device size (i.e., the number of qubits of the coupling graph). Parameters ---------- topology : Topology Coupling map provided either as an edge list or as an undirected ``PyGraph`` whose node indices represent *physical* qubits. mapping : LayoutMap Logical→physical mapping provided by the user. For example, for a 2-qubit circuit: {0: 5, 1: 2} means logical q0→phys 5, logical q1→phys 2. Behavior -------- - Validates that `mapping` covers *every* logical qubit in the input circuit, is injective (no repeated physical indices), and only references physical qubits that exist in `topology`. - Stores the user-requested layout in `context.initial_layout` (same field set by `SabreLayoutPass`). - Inserts SWAPs (with corresponding un-swaps) along shortest paths of the coupling graph when a user-requested 2Q interaction would otherwise violate connectivity, keeping logical qubits on their requested physical nodes for subsequent operations. - Returns a *new* `Circuit` whose `nqubits` equals the chip size (len of topology's qubits) and whose gates are retargeted to the mapped physical qubits. - Exposes `last_layout` (list[int]) for diagnostics, mirroring SabreLayout. Notes ----- * SWAPs are emitted eagerly using shortest paths whenever a 2Q gate is not locally executable on the chosen mapping, and are undone immediately so the mapping remains stable for subsequent operations. * ``last_layout`` matches the user-provided mapping; the same list is stored in ``context.initial_layout`` for diagnostics. """ _TWO_QUBIT_ARITY = 2 def __init__(self, topology: Topology, layout: LayoutMap) -> None: """Initialize the pass with a fixed logical-to-physical layout. Args: topology (Topology): Coupling map as an edge list or ``PyGraph``. layout (LayoutMap): User-specified layout ``logical -> physical``. """
[documentos] self.topology: PyGraph[int, None] = build_topology_graph(topology)
# Store a copy with explicit int coercion self._user_layout: LayoutMap = {int(k): int(v) for k, v in layout.items()}
[documentos] def run(self, circuit: Circuit) -> Circuit: """Retarget a circuit using the user layout, inserting temporary SWAPs when needed. Args: circuit (Circuit): Logical circuit to retarget. Returns: Circuit: New circuit in physical qubit indices sized to the topology. Raises: ValueError: If the topology is empty, mapping coverage is invalid, mapping is not injective, mapping targets unknown physical qubits, or a two-qubit interaction is unroutable. NotImplementedError: If a gate with arity greater than two is encountered. RuntimeError: If routing swaps fail to make a two-qubit gate adjacent. """ num_logical_qubits = circuit.nqubits physical_nodes = [int(node_index) for node_index in self.topology.node_indices()] if not physical_nodes: raise ValueError("Coupling graph has no nodes.") # As in SabreLayoutPass: assume nodes are 0..(n_physical-1) num_physical_qubits = max(physical_nodes) + 1 valid_physical_nodes = set(physical_nodes) # ---- validations on provided mapping ---- mapping_keys = set(self._user_layout.keys()) expected_logical_qubits = set(range(num_logical_qubits)) if mapping_keys != expected_logical_qubits: missing_logical_qubits = sorted(expected_logical_qubits - mapping_keys) extraneous_logical_qubits = sorted(mapping_keys - expected_logical_qubits) error_parts = [] if missing_logical_qubits: error_parts.append(f"missing logical qubits {missing_logical_qubits}") if extraneous_logical_qubits: error_parts.append(f"extraneous logical keys {extraneous_logical_qubits}") raise ValueError( "User mapping must map *every* logical qubit in the circuit exactly once; " + "; ".join(error_parts) ) mapped_physical_qubits = list(self._user_layout.values()) if len(set(mapped_physical_qubits)) != len(mapped_physical_qubits): # find duplicates for a clearer error seen_physical_qubits: set[int] = set() duplicate_physical_qubits: list[int] = [] for physical_qubit in mapped_physical_qubits: if physical_qubit in seen_physical_qubits: duplicate_physical_qubits.append(physical_qubit) else: seen_physical_qubits.add(physical_qubit) duplicate_physical_qubits.sort() raise ValueError(f"User mapping is not injective; duplicated physical qubits: {duplicate_physical_qubits}") invalid_physical_qubits = sorted(set(mapped_physical_qubits) - valid_physical_nodes) if invalid_physical_qubits: raise ValueError( f"Mapping refers to physical qubits not present in the coupling graph: {invalid_physical_qubits}" ) # Build the layout list[int] where layout[logical] = physical and keep a copy for diagnostics logical_to_physical_layout = [self._user_layout[logical_qubit] for logical_qubit in range(num_logical_qubits)] initial_layout = logical_to_physical_layout.copy() # Track which logical qubit currently occupies each physical node inverse_layout: list[int | None] = [None] * num_physical_qubits for logical_qubit, physical_qubit in enumerate(logical_to_physical_layout): inverse_layout[physical_qubit] = logical_qubit # Retarget gates, inserting SWAPs along shortest paths whenever a 2Q interaction # would otherwise violate the coupling constraints. SWAPs are added in pairs so # that the logical-to-physical mapping is restored after each routed 2Q gate. output_circuit = Circuit(num_physical_qubits) for gate in circuit.gates: gate_qubits = gate.qubits if len(gate_qubits) <= 1 or isinstance(gate, M): mapped_qubits = tuple(logical_to_physical_layout[logical_qubit] for logical_qubit in gate_qubits) output_circuit.add(self._retarget_gate(gate, mapped_qubits)) continue if len(gate_qubits) != CustomLayoutPass._TWO_QUBIT_ARITY: raise NotImplementedError( f"CustomLayoutPass currently supports routing for 1Q/2Q gates only; " f"received {type(gate).__name__} acting on {len(gate_qubits)} qubits." ) logical_qubit_a, logical_qubit_b = gate_qubits if self.topology.has_edge( logical_to_physical_layout[logical_qubit_a], logical_to_physical_layout[logical_qubit_b], ): mapped_qubits = ( logical_to_physical_layout[logical_qubit_a], logical_to_physical_layout[logical_qubit_b], ) output_circuit.add(self._retarget_gate(gate, mapped_qubits)) continue routing_path = self._shortest_path( logical_to_physical_layout[logical_qubit_a], logical_to_physical_layout[logical_qubit_b], ) if routing_path is None or len(routing_path) < CustomLayoutPass._TWO_QUBIT_ARITY: raise ValueError( "User mapping cannot be routed on the provided topology; no path between " f"physical qubits {logical_to_physical_layout[logical_qubit_a]} and " f"{logical_to_physical_layout[logical_qubit_b]}." ) applied_swap_edges: list[tuple[int, int]] = [] # Move the second qubit along the path until it neighbors the first one. for path_position in range(len(routing_path) - 1, 1, -1): physical_node_a, physical_node_b = routing_path[path_position - 1], routing_path[path_position] output_circuit.add(SWAP(physical_node_a, physical_node_b)) self._apply_swap_to_layout( logical_to_physical_layout, inverse_layout, physical_node_a, physical_node_b, ) applied_swap_edges.append((physical_node_a, physical_node_b)) mapped_qubits = ( logical_to_physical_layout[logical_qubit_a], logical_to_physical_layout[logical_qubit_b], ) if not self.topology.has_edge(*mapped_qubits): raise RuntimeError( "Failed to route gate after inserting swaps; resulting qubits are still non-adjacent." ) output_circuit.add(self._retarget_gate(gate, mapped_qubits)) # Restore the original mapping so later 1Q gates remain on the requested qubits. for physical_node_a, physical_node_b in reversed(applied_swap_edges): output_circuit.add(SWAP(physical_node_a, physical_node_b)) self._apply_swap_to_layout( logical_to_physical_layout, inverse_layout, physical_node_a, physical_node_b, ) if self.context is not None: self.context.initial_layout = initial_layout self.context.final_layout = self._user_layout self.append_circuit_to_context(output_circuit) return output_circuit
# --------- retargeting helpers (mirrors SabreLayoutPass) --------- @staticmethod def _retarget_gate(gate: Gate, mapped_qubits: tuple[int, ...]) -> Gate: """Recreate a supported gate on new qubit indices. Args: gate (Gate): Gate to retarget. mapped_qubits (tuple[int, ...]): Physical qubits where the gate should act. Returns: Gate: Retargeted gate equivalent to ``gate`` on ``mapped_qubits``. Raises: NotImplementedError: If the gate type is not supported by this pass. """ # 1-qubit basics if isinstance(gate, RX): return RX(mapped_qubits[0], theta=gate.theta) if isinstance(gate, RY): return RY(mapped_qubits[0], theta=gate.theta) if isinstance(gate, RZ): return RZ(mapped_qubits[0], phi=gate.phi) if isinstance(gate, U3): return U3(mapped_qubits[0], theta=gate.theta, phi=gate.phi, gamma=gate.gamma) # 2-qubit basics if isinstance(gate, CNOT): return CNOT(mapped_qubits[0], mapped_qubits[1]) if isinstance(gate, CZ): return CZ(mapped_qubits[0], mapped_qubits[1]) if isinstance(gate, SWAP): return SWAP(mapped_qubits[0], mapped_qubits[1]) # Measurement (possibly multi-qubit) if isinstance(gate, M): return M(*mapped_qubits) raise NotImplementedError( f"Retargeting not implemented for gate type {type(gate).__name__} with arity {gate.nqubits}" ) @staticmethod def _apply_swap_to_layout( logical_to_physical_layout: list[int], inverse_layout: list[int | None], physical_node_a: int, physical_node_b: int, ) -> None: """Update forward and inverse mappings after applying a physical SWAP. Args: logical_to_physical_layout (list[int]): Forward mapping ``logical -> physical``. inverse_layout (list[int | None]): Inverse mapping ``physical -> logical``. physical_node_a (int): First swapped physical node. physical_node_b (int): Second swapped physical node. """ logical_qubit_on_a = inverse_layout[physical_node_a] logical_qubit_on_b = inverse_layout[physical_node_b] inverse_layout[physical_node_a], inverse_layout[physical_node_b] = logical_qubit_on_b, logical_qubit_on_a if logical_qubit_on_a is not None: logical_to_physical_layout[logical_qubit_on_a] = physical_node_b if logical_qubit_on_b is not None: logical_to_physical_layout[logical_qubit_on_b] = physical_node_a def _shortest_path(self, start: int, goal: int) -> list[int] | None: """Return one shortest path between two physical qubits in the coupling graph. Args: start (int): Source physical qubit. goal (int): Target physical qubit. Returns: list[int] | None: Path from ``start`` to ``goal`` if reachable; otherwise ``None``. """ if start == goal: return [start] visited_nodes = {start} parent_by_node: dict[int, int] = {} queue: deque[int] = deque([start]) while queue: current_node = queue.popleft() for neighbor in self.topology.neighbors(current_node): neighbor_node = int(neighbor) if neighbor_node in visited_nodes: continue parent_by_node[neighbor_node] = current_node if neighbor_node == goal: path = [goal] while path[-1] != start: path.append(parent_by_node[path[-1]]) path.reverse() return path visited_nodes.add(neighbor_node) queue.append(neighbor_node) return None