Source code for qkan.solver.layout

# Copyright (c) 2026, Jiun-Cheng Jiang. All rights reserved.
#
# 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.


"""
Device-independent, calibration-aware qubit selection and layout.

This module is torch-free and provider-neutral: calibration data enters
through :class:`DeviceProfile` (with adapters for qiskit backends and AWS
Braket devices) and layouts come out as plain physical-qubit index lists,
so the same selection logic serves the qiskit solver, the CUDA-Q solver,
and standalone users.

Public API:

- :class:`DeviceProfile` — frozen snapshot of per-qubit / per-edge calibration.
- :func:`rank_qubits` — top-``n`` independent qubit ranking (the
  device-independent generalization of ``best_qubits``).
- :func:`score_layout` — score a candidate layout for an interaction graph.
- :func:`best_subgraph` — best-calibrated connected subgraph for one
  multi-qubit circuit (thin complement to qiskit's own VF2 passes).
- :func:`tile_disjoint` — tile a chip with disjoint calibration-aware
  subgraphs to run several independent circuits in parallel.
"""

import functools
import math
from dataclasses import dataclass, field
from typing import Any, Literal, Mapping, Optional, Sequence, Union

try:
    import rustworkx as rx  # type: ignore

    _RUSTWORKX_AVAILABLE = True
except ImportError:
    _RUSTWORKX_AVAILABLE = False

__all__ = [
    "DeviceProfile",
    "best_subgraph",
    "rank_qubits",
    "score_layout",
    "tile_disjoint",
]

# Pessimistic fallbacks for missing calibration data (a qubit or edge with
# no data must never outrank a measured one). The qubit-level constants
# match best_qubits in the qiskit solver.
_RO_FALLBACK = 0.5
_G1_FALLBACK = 1e-2
_G2_FALLBACK = 0.5
_T2_FALLBACK_US = 50.0
# Finite stand-in for -log(0) when an error rate is reported as >= 1.
_BIG = 1e6

Interaction = Union[int, Sequence[tuple[int, int]]]


[docs] @dataclass(frozen=True) class DeviceProfile: """Provider-neutral snapshot of device calibration. Physical qubit labels run ``0..num_qubits-1``; devices with non-contiguous or 1-indexed labels simply have holes in the maps, and unreported labels are marked non-operational by the adapters. Coupling ``edges`` and the ``gate_error_2q`` map are keyed by sorted ``(low, high)`` pairs; ``t2_us`` is in microseconds. All error values are probabilities in [0, 1]; NaN entries are kept and treated as missing data at scoring time, and missing dict keys mean the device reported nothing for that qubit or edge (common for faulty qubits). Qubits mapped to False in ``operational`` are never selected. """ num_qubits: int edges: tuple[tuple[int, int], ...] = () readout_error: Mapping[int, float] = field(default_factory=dict) gate_error_1q: Mapping[int, float] = field(default_factory=dict) gate_error_2q: Mapping[tuple[int, int], float] = field(default_factory=dict) t2_us: Optional[Mapping[int, float]] = None operational: Optional[Mapping[int, bool]] = None
[docs] def has_calibration(self) -> bool: """True when the profile carries any per-qubit calibration data.""" return bool(self.readout_error or self.gate_error_1q or self.gate_error_2q)
@functools.cached_property def _edge_set(self) -> frozenset: # Cached for the layout searches, which test coupling membership # once per interaction edge per VF2 candidate. return frozenset(self.edges)
[docs] @classmethod def from_qiskit(cls, backend, *, refresh: bool = False) -> "DeviceProfile": """Build a profile from a qiskit backend. Prefers the BackendV2 ``Target`` (structured per-instruction errors); falls back to the legacy ``properties()`` API. With ``refresh=True`` the backend is asked to re-fetch calibration first — via ``backend.refresh()`` on BackendV2 (qiskit-ibm-runtime) or ``properties(refresh=True)`` on the legacy path — since both APIs cache their first snapshot. """ target = getattr(backend, "target", None) if target is not None: if refresh: try: backend.refresh() target = getattr(backend, "target", target) except Exception: pass return cls._from_qiskit_target(backend, target) if hasattr(backend, "properties"): return cls._from_qiskit_properties(backend, refresh=refresh) raise ValueError( "DeviceProfile.from_qiskit: backend exposes neither a Target nor " "the legacy properties() calibration API" )
@classmethod def _from_qiskit_target(cls, backend, target) -> "DeviceProfile": num_qubits = int(target.num_qubits or getattr(backend, "num_qubits", 0)) readout: dict[int, float] = {} g1: dict[int, float] = {} g2: dict[tuple[int, int], float] = {} edges: set[tuple[int, int]] = set() t2_us: dict[int, float] = {} def _op_errors(name): try: return target[name].items() except Exception: return [] for qargs, iprops in _op_errors("measure"): if qargs and len(qargs) == 1 and iprops is not None: err = getattr(iprops, "error", None) if err is not None: readout[qargs[0]] = float(err) for name in ("sx", "x"): for qargs, iprops in _op_errors(name): if qargs and len(qargs) == 1 and qargs[0] not in g1: err = getattr(iprops, "error", None) if iprops else None if err is not None: g1[qargs[0]] = float(err) if g1: break for name in getattr(target, "operation_names", []): for qargs, iprops in _op_errors(name): if qargs is None or len(qargs) != 2: continue edge = (min(qargs), max(qargs)) edges.add(edge) err = getattr(iprops, "error", None) if iprops else None if err is not None: # A pair may carry several 2q gates/directions; keep the # best one — the transpiler uses the calibrated direction. prev = g2.get(edge) if prev is None or float(err) < prev: g2[edge] = float(err) qprops = getattr(target, "qubit_properties", None) if qprops: for q, qp in enumerate(qprops): t2 = getattr(qp, "t2", None) if qp is not None else None if t2 is not None: t2_us[q] = float(t2) * 1e6 return cls( num_qubits=num_qubits, edges=tuple(sorted(edges)), readout_error=readout, gate_error_1q=g1, gate_error_2q=g2, t2_us=t2_us or None, ) @classmethod def _from_qiskit_properties( cls, backend, *, refresh: bool = False ) -> "DeviceProfile": try: props = ( backend.properties(refresh=True) if refresh else backend.properties() ) except TypeError: props = backend.properties() if props is None: raise ValueError( "DeviceProfile.from_qiskit: backend.properties() returned None " "(no calibration available)" ) num_qubits = int(getattr(backend, "num_qubits", 0) or len(props.qubits)) readout: dict[int, float] = {} g1: dict[int, float] = {} t2_us: dict[int, float] = {} operational: dict[int, bool] = {} for q in range(num_qubits): try: readout[q] = float(props.readout_error(q)) except Exception: pass try: g1[q] = float(props.gate_error("sx", [q])) except Exception: pass try: t2_us[q] = float(props.t2(q)) * 1e6 except Exception: pass try: operational[q] = bool(props.is_qubit_operational(q)) except Exception: pass g2: dict[tuple[int, int], float] = {} edges: set[tuple[int, int]] = set() config = None try: config = backend.configuration() except Exception: pass coupling = getattr(config, "coupling_map", None) if config else None for pair in coupling or []: a, b = int(pair[0]), int(pair[1]) edge = (min(a, b), max(a, b)) edges.add(edge) for gate in ("ecr", "cz", "cx"): try: err = float(props.gate_error(gate, [a, b])) except Exception: continue prev = g2.get(edge) if prev is None or err < prev: g2[edge] = err break return cls( num_qubits=num_qubits, edges=tuple(sorted(edges)), readout_error=readout, gate_error_1q=g1, gate_error_2q=g2, t2_us=t2_us or None, operational=operational or None, )
[docs] @classmethod def from_braket(cls, device) -> "DeviceProfile": """Build a profile from an AWS Braket device (experimental). ``device`` is a ``braket.aws.AwsDevice`` (or any object exposing the same ``properties`` model). Reads the standardized gate-model QPU calibration schema: per-qubit T1/T2, readout fidelity (or IQM's readout-error entries), and 1Q (simultaneous) randomized-benchmarking fidelity, plus per-pair 2Q gate fidelities. Fidelities are converted to errors as ``1 - fidelity``; entries whose type name starts with ``READOUT_ERROR`` are already errors and are averaged as-is. """ props = getattr(device, "properties", None) std = getattr(props, "standardized", None) if std is None: raise ValueError( "DeviceProfile.from_braket: device exposes no standardized " "calibration properties (IonQ and simulators do not publish " "per-qubit calibration)" ) paradigm = getattr(props, "paradigm", None) one_q = getattr(std, "oneQubitProperties", {}) or {} two_q = getattr(std, "twoQubitProperties", {}) or {} readout: dict[int, float] = {} g1: dict[int, float] = {} t2_us: dict[int, float] = {} labels: set[int] = set() for label, qprops in one_q.items(): q = int(label) labels.add(q) t2 = getattr(qprops, "T2", None) if t2 is not None and getattr(t2, "value", None) is not None: scale = 1e6 if str(getattr(t2, "unit", "S")).upper() == "S" else 1.0 t2_us[q] = float(t2.value) * scale ro_err: list[float] = [] rb, srb = None, None for fid in getattr(qprops, "oneQubitFidelity", []) or []: ftype = str(getattr(getattr(fid, "fidelityType", None), "name", "")) value = getattr(fid, "fidelity", None) if value is None: continue if ftype.startswith("READOUT_ERROR"): ro_err.append(float(value)) elif ftype == "READOUT": readout[q] = 1.0 - float(value) elif ftype == "SIMULTANEOUS_RANDOMIZED_BENCHMARKING": srb = 1.0 - float(value) elif ftype == "RANDOMIZED_BENCHMARKING": rb = 1.0 - float(value) if q not in readout and ro_err: readout[q] = sum(ro_err) / len(ro_err) # Packed jobs drive all qubits at once: prefer simultaneous RB. if srb is not None: g1[q] = srb elif rb is not None: g1[q] = rb g2: dict[tuple[int, int], float] = {} edges: set[tuple[int, int]] = set() for pair_label, pprops in two_q.items(): try: a_s, b_s = str(pair_label).split("-") a, b = int(a_s), int(b_s) except ValueError: continue edge = (min(a, b), max(a, b)) edges.add(edge) labels.update((a, b)) for fid in getattr(pprops, "twoQubitGateFidelity", []) or []: value = getattr(fid, "fidelity", None) if value is None: continue err = 1.0 - float(value) prev = g2.get(edge) if prev is None or err < prev: g2[edge] = err num_qubits = int(getattr(paradigm, "qubitCount", 0) or 0) if labels: num_qubits = max(num_qubits, max(labels) + 1) # Devices with 1-indexed or holey label spaces (IQM, faulty Rigetti # qubits) leave phantom indices with no data; never select those. reported = {int(label) for label in one_q} operational = ( {q: (q in reported) for q in range(num_qubits)} if reported else None ) return cls( num_qubits=num_qubits, edges=tuple(sorted(edges)), readout_error=readout, gate_error_1q=g1, gate_error_2q=g2, t2_us=t2_us or None, operational=operational, )
def _neg_log(err: float) -> float: """-ln(1 - err), clamped to finite values (fidelity-product cost).""" if err <= 0.0: return 0.0 if err >= 1.0: return _BIG return -math.log1p(-err) def _qubit_cost( profile: DeviceProfile, q: int, *, max_readout_error: Optional[float] = None, qubit_error_threshold: Optional[float] = None, filtering: bool = False, ) -> Optional[float]: """Additive cost of physical qubit ``q``. With ``filtering=True``, returns None for qubits failing a threshold or flagged non-operational (NaN fails an active threshold). Without filtering, missing/NaN data falls back to pessimistic constants and non-operational qubits cost ``_BIG``. """ operational = True if profile.operational is not None: operational = bool(profile.operational.get(q, True)) if not operational: if filtering: return None return _BIG ro = float(profile.readout_error.get(q, float("nan"))) if math.isnan(ro): if filtering and max_readout_error is not None: return None ro = _RO_FALLBACK if filtering and max_readout_error is not None and ro > max_readout_error: return None g1 = float(profile.gate_error_1q.get(q, float("nan"))) if math.isnan(g1): if filtering and qubit_error_threshold is not None: return None g1 = _G1_FALLBACK if filtering and qubit_error_threshold is not None and g1 > qubit_error_threshold: return None t2 = float("nan") if profile.t2_us is not None: t2 = float(profile.t2_us.get(q, float("nan"))) if math.isnan(t2): t2 = _T2_FALLBACK_US return _neg_log(ro) + _neg_log(g1) + 1e-4 / max(t2, 1.0) def _edge_error(profile: DeviceProfile, a: int, b: int) -> Optional[float]: """Calibrated 2q error for the physical pair, or None if not coupled.""" edge = (min(a, b), max(a, b)) if edge not in profile._edge_set: return None err = float(profile.gate_error_2q.get(edge, float("nan"))) if math.isnan(err): err = _G2_FALLBACK return err def _interaction_edges( interaction: Interaction, ) -> tuple[list[tuple[int, int]], int, dict[tuple[int, int], int]]: """Normalize an interaction spec to (unique edges, n_logical, multiplicity). Repeated pairs and both orientations of a pair (multi-layer ansaetze, direction flips) collapse to one undirected edge whose multiplicity is used as the default 2q gate count in scoring. """ if isinstance(interaction, int): if interaction < 1: raise ValueError(f"interaction qubit count must be >= 1, got {interaction}") return [], interaction, {} multiplicity: dict[tuple[int, int], int] = {} for a, b in interaction: a, b = int(a), int(b) if a == b or a < 0 or b < 0: raise ValueError(f"invalid interaction edge ({a}, {b})") edge = (min(a, b), max(a, b)) multiplicity[edge] = multiplicity.get(edge, 0) + 1 if not multiplicity: raise ValueError("interaction edge list is empty; pass an int instead") edges = sorted(multiplicity) n_logical = max(max(a, b) for a, b in edges) + 1 return edges, n_logical, multiplicity def _usable_costs( profile: DeviceProfile, *, exclude: Optional[set[int]] = None, max_readout_error: Optional[float] = None, qubit_error_threshold: Optional[float] = None, ) -> list[tuple[float, int]]: """Sorted ``(cost, qubit)`` over qubits passing the threshold filters.""" scored = [] for q in range(profile.num_qubits): if exclude and q in exclude: continue cost = _qubit_cost( profile, q, max_readout_error=max_readout_error, qubit_error_threshold=qubit_error_threshold, filtering=True, ) if cost is not None: scored.append((cost, q)) scored.sort() return scored
[docs] def rank_qubits( profile: DeviceProfile, n: int, *, max_readout_error: Optional[float] = None, qubit_error_threshold: Optional[float] = None, strict: bool = True, ) -> list[int]: """Return the ``n`` best-calibrated qubit indices, best first. Device-independent counterpart of the qiskit solver's ``best_qubits``: ranks by readout error + 1q gate error (as fidelity-product costs) with a small short-T2 penalty. NaN calibration fails an active threshold and otherwise falls back to pessimistic constants; non-operational qubits are skipped. Returns ``[]`` when the profile carries no calibration and no threshold was requested; raises ``ValueError`` when a threshold cannot be satisfied (unless ``strict=False``, which returns all usable qubits). """ if not isinstance(n, int) or n < 1: raise ValueError(f"rank_qubits: n must be a positive integer, got {n!r}") has_threshold = max_readout_error is not None or qubit_error_threshold is not None if not profile.has_calibration(): if has_threshold: raise ValueError( "rank_qubits: profile carries no calibration data; cannot " "enforce qubit calibration thresholds" ) return [] if n > profile.num_qubits: raise ValueError( f"rank_qubits: requested n={n} exceeds num_qubits={profile.num_qubits}" ) scored = _usable_costs( profile, max_readout_error=max_readout_error, qubit_error_threshold=qubit_error_threshold, ) if len(scored) < n: if not strict and scored: return [q for _cost, q in scored] raise ValueError( f"rank_qubits: only {len(scored)} qubits satisfy the requested " f"thresholds; need {n}" ) return [q for _cost, q in scored[:n]]
[docs] def score_layout( profile: DeviceProfile, interaction: Interaction, layout: Sequence[int], *, gate_counts: Optional[Mapping[Any, int]] = None, ) -> float: """Score a candidate layout (lower is better). ``layout`` is positional: ``layout[i]`` is the physical qubit hosting logical qubit ``i``. The score is a fidelity-product cost: the sum of ``count * -ln(1 - error)`` over every mapped 2q edge plus each logical qubit's readout/1q-gate cost and a small short-T2 penalty. Logical edges whose physical pair is not coupled cost a large finite penalty (they would require routing). Repeated interaction edges count as their multiplicity. ``gate_counts`` optionally overrides weights: int keys are per-logical-qubit 1q+readout multiplicity, sorted-tuple keys are per-logical-edge 2q gate counts. """ edges, n_logical, multiplicity = _interaction_edges(interaction) if len(layout) < n_logical: raise ValueError( f"layout has {len(layout)} entries but the interaction uses " f"{n_logical} logical qubits" ) if len(set(layout[:n_logical])) != n_logical: raise ValueError("layout assigns the same physical qubit twice") for i in range(n_logical): if not 0 <= int(layout[i]) < profile.num_qubits: raise ValueError( f"layout entry {layout[i]} is outside the device's " f"{profile.num_qubits}-qubit index space" ) return _score_normalized( profile, edges, multiplicity, n_logical, layout, gate_counts or {} )
def _score_normalized( profile: DeviceProfile, edges: Sequence[tuple[int, int]], multiplicity: Mapping[tuple[int, int], int], n_logical: int, layout: Sequence[int], counts: Mapping[Any, int], qubit_costs: Optional[Mapping[int, float]] = None, ) -> float: """:func:`score_layout` core over a pre-normalized, pre-validated interaction — the per-VF2-candidate hot path, so no re-normalization and per-qubit costs can be supplied from a cache.""" total = 0.0 for i in range(n_logical): phys = int(layout[i]) cost = ( qubit_costs[phys] if qubit_costs is not None else _qubit_cost(profile, phys) ) assert cost is not None total += float(counts.get(i, 1)) * cost for a, b in edges: err = _edge_error(profile, int(layout[a]), int(layout[b])) edge_cost = _BIG if err is None else _neg_log(err) key = (a, b) if (a, b) in counts else (b, a) total += float(counts.get(key, multiplicity.get((a, b), 1))) * edge_cost return total def _pruned_coupling_graph( profile: DeviceProfile, *, exclude: Optional[set[int]] = None, max_readout_error: Optional[float] = None, qubit_error_threshold: Optional[float] = None, edge_error_threshold: Optional[float] = None, ): """Coupling graph with thresholded/excluded qubits and edges removed.""" exclude = exclude or set() graph = rx.PyGraph() node_of: dict[int, int] = {} for q in range(profile.num_qubits): if q in exclude: continue cost = _qubit_cost( profile, q, max_readout_error=max_readout_error, qubit_error_threshold=qubit_error_threshold, filtering=True, ) if cost is None: continue node_of[q] = graph.add_node(q) for a, b in profile.edges: if a not in node_of or b not in node_of: continue err = float(profile.gate_error_2q.get((a, b), float("nan"))) # Dead edges (error at the physical bound) can never belong to a good # layout, and VF2's enumeration order is not score-aware — scoring # alone cannot keep them out of the sampled candidates, so prune them # from the search graph unconditionally. if not math.isnan(err) and err >= 0.999: continue if edge_error_threshold is not None: if math.isnan(err) or err > edge_error_threshold: continue graph.add_edge(node_of[a], node_of[b], None) return graph def _best_subgraph_on( profile: DeviceProfile, graph, edges: list[tuple[int, int]], n_logical: int, multiplicity: Mapping[tuple[int, int], int], *, call_limit: Optional[int], max_trials: int, ) -> Optional[tuple[list[int], float]]: """Best layout of the interaction onto the pruned graph, or None.""" # Isolated logical qubits (no interaction edges) are assigned greedily # to the best remaining qubits after the edged part is placed. edged_logicals = sorted({q for e in edges for q in e}) needle = rx.PyGraph() needle_node: dict[int, int] = {} for lq in edged_logicals: needle_node[lq] = needle.add_node(lq) for a, b in edges: needle.add_edge(needle_node[a], needle_node[b], None) if edges and rx.number_connected_components(needle) > 1: raise ValueError( "interaction graph has multiple connected components with edges; " "place each component separately (see tile_disjoint)" ) # Per-qubit costs are reused for the availability ordering and for # every candidate's score (they do not vary across VF2 trials). qubit_costs: dict[int, float] = {} for node in graph.node_indices(): q = graph[node] cost = _qubit_cost(profile, q) assert cost is not None # non-filtering mode always yields a cost qubit_costs[q] = cost available = sorted(qubit_costs, key=qubit_costs.__getitem__) best: Optional[tuple[list[int], float]] = None if edges: mappings = rx.vf2_mapping( graph, needle, subgraph=True, induced=False, id_order=False, call_limit=call_limit, ) trials = 0 for mapping in mappings: # rustworkx yields {haystack node -> needle node}. phys_of_logical: dict[int, int] = {} for g_node, n_node in mapping.items(): phys_of_logical[needle[n_node]] = graph[g_node] used = set(phys_of_logical.values()) layout = [-1] * n_logical for lq, phys in phys_of_logical.items(): layout[lq] = phys free = iter(q for q in available if q not in used) try: for lq in range(n_logical): if layout[lq] < 0: layout[lq] = next(free) except StopIteration: continue score = _score_normalized( profile, edges, multiplicity, n_logical, layout, {}, qubit_costs ) if best is None or score < best[1]: best = (layout, score) trials += 1 if trials >= max_trials: break else: if len(available) >= n_logical: layout = available[:n_logical] score = _score_normalized( profile, [], {}, n_logical, layout, {}, qubit_costs ) best = (layout, score) return best
[docs] def best_subgraph( profile: DeviceProfile, interaction: Interaction, *, max_readout_error: Optional[float] = None, qubit_error_threshold: Optional[float] = None, edge_error_threshold: Optional[float] = None, strict: bool = True, call_limit: Optional[int] = 5_000_000, max_trials: int = 2_500, ) -> list[int]: """Best-calibrated placement of one circuit's interaction graph. ``interaction`` is either an edge list over logical qubits (2q-gate pairs) or a bare int for an edge-free register (which reduces to :func:`rank_qubits`). Returns a positional layout (``result[i]`` hosts logical qubit ``i``), directly usable as a qiskit ``initial_layout``. Note qiskit's own transpiler already performs noise-aware VF2 placement at ``optimization_level`` 2-3; this function exists for explicit thresholds, custom scoring, level-1 pipelines, and non-qiskit stacks. Raises ``ValueError`` when no placement satisfies the thresholds (``strict=True``) or returns ``[]`` (``strict=False``). The interaction graph must be connected (tile disconnected pieces via :func:`tile_disjoint`) and must embed in the coupling map without routing. """ edges, n_logical, multiplicity = _interaction_edges(interaction) if not edges: return rank_qubits( profile, n_logical, max_readout_error=max_readout_error, qubit_error_threshold=qubit_error_threshold, strict=strict, ) if not _RUSTWORKX_AVAILABLE: raise ImportError( "best_subgraph requires rustworkx (installed with qiskit): " "pip install rustworkx" ) if not profile.has_calibration() and ( max_readout_error is not None or qubit_error_threshold is not None ): raise ValueError( "best_subgraph: profile carries no calibration data; cannot " "enforce calibration thresholds" ) graph = _pruned_coupling_graph( profile, max_readout_error=max_readout_error, qubit_error_threshold=qubit_error_threshold, edge_error_threshold=edge_error_threshold, ) best = _best_subgraph_on( profile, graph, edges, n_logical, multiplicity, call_limit=call_limit, max_trials=max_trials, ) if best is None: if strict: raise ValueError( "best_subgraph: no placement of the interaction graph " "satisfies the coupling map and thresholds" ) return [] return best[0]
[docs] def tile_disjoint( profile: DeviceProfile, interaction: Interaction, k: Union[int, Literal["max"]] = "max", *, buffer_hops: int = 0, max_readout_error: Optional[float] = None, qubit_error_threshold: Optional[float] = None, edge_error_threshold: Optional[float] = None, tile_score_threshold: Optional[float] = None, strict: bool = True, call_limit: Optional[int] = 50_000, max_trials: int = 500, n_logical: Optional[int] = None, ) -> list[list[int]]: """Tile the chip with disjoint calibration-aware copies of one circuit. Greedy peel: repeatedly place the interaction graph on the best remaining subgraph (:func:`best_subgraph` semantics), then remove the used qubits plus a ``buffer_hops``-neighborhood shell before placing the next copy. Stops at ``k`` tiles, when nothing placeable remains, or when the next tile's score exceeds ``tile_score_threshold``. Returns tiles ordered best-first; each tile is a positional layout for one circuit copy. For a merged qiskit job, concatenate them tile-major: ``flat = [q for tile in tiles for q in tile]``. ``n_logical`` widens each tile beyond the interaction's edge span: logical qubits without interaction edges (single-qubit-gate-only or idle wires) are assigned best-effort to the best remaining qubits. With integer ``k`` and ``strict=True``, raises ``ValueError`` when fewer than ``k`` tiles fit; ``strict=False`` returns what fits. """ if k != "max" and (not isinstance(k, int) or k < 1): raise ValueError(f"tile_disjoint: k must be 'max' or a positive int, got {k!r}") if buffer_hops < 0: raise ValueError(f"tile_disjoint: buffer_hops must be >= 0, got {buffer_hops}") edges, n_logical_span, multiplicity = _interaction_edges(interaction) if n_logical is None: n_logical = n_logical_span elif n_logical < n_logical_span: raise ValueError( f"tile_disjoint: n_logical={n_logical} is smaller than the " f"interaction's span of {n_logical_span} logical qubits" ) if edges and not _RUSTWORKX_AVAILABLE: raise ImportError( "tile_disjoint requires rustworkx (installed with qiskit): " "pip install rustworkx" ) # Adjacency over the full (unpruned) coupling map for buffer removal. neighbors: dict[int, set[int]] = {} for a, b in profile.edges: neighbors.setdefault(a, set()).add(b) neighbors.setdefault(b, set()).add(a) limit = profile.num_qubits if k == "max" else int(k) removed: set[int] = set() tiles: list[tuple[list[int], float]] = [] while len(tiles) < limit: if edges: graph = _pruned_coupling_graph( profile, exclude=removed, max_readout_error=max_readout_error, qubit_error_threshold=qubit_error_threshold, edge_error_threshold=edge_error_threshold, ) best = _best_subgraph_on( profile, graph, edges, n_logical, multiplicity, call_limit=call_limit, max_trials=max_trials, ) else: usable = _usable_costs( profile, exclude=removed, max_readout_error=max_readout_error, qubit_error_threshold=qubit_error_threshold, ) if len(usable) < n_logical or not profile.has_calibration(): best = None else: layout = [q for _c, q in usable[:n_logical]] best = (layout, score_layout(profile, n_logical, layout)) if best is None: break layout, score = best if tile_score_threshold is not None and score > tile_score_threshold: break tiles.append((layout, score)) shell = set(layout) frontier = set(layout) for _hop in range(buffer_hops): frontier = {nb for q in frontier for nb in neighbors.get(q, ())} - shell shell |= frontier removed |= shell if isinstance(k, int) and len(tiles) < k and strict: raise ValueError( f"tile_disjoint: only {len(tiles)} disjoint tiles satisfy the " f"thresholds; need {k}" ) tiles.sort(key=lambda t: t[1]) return [layout for layout, _score in tiles]