Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- The cycle time [seconds] can be set when instantiating the `QuantifySchedulerExporter` through the `cycle_time`
parameter.
- `CircuitBuilder` accepts multiple (qu)bit registers through `add_register` method.
- Add `interaction_graph` as a property of the `Circuit`.

## [ 0.9.0 ] - [ 2025-12-19 ]

Expand Down
20 changes: 19 additions & 1 deletion opensquirrel/circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from collections import Counter, defaultdict
from collections.abc import Callable
from itertools import combinations
from typing import TYPE_CHECKING, Any

from opensquirrel.ir import Instruction
from opensquirrel.ir.non_unitary import Measure
from opensquirrel.ir.statement import AsmDeclaration

Expand All @@ -21,6 +23,7 @@

InstructionCount = dict[str, int]
MeasurementToBitMap = defaultdict[str, list[int]]
InteractionGraph = dict[tuple[int, int], int]


class Circuit:
Expand Down Expand Up @@ -117,6 +120,20 @@ def measurement_to_bit_map(self) -> MeasurementToBitMap:
m2b_map[str(qubit_index)].append(bit_index)
return m2b_map

@property
def interaction_graph(self) -> InteractionGraph:
"""Interaction graph of the circuit."""
graph = {}
for statement in self.ir.statements:
if not isinstance(statement, Instruction):
continue
qubit_indices = statement.qubit_indices
if len(qubit_indices) >= 2:
for q_i, q_j in combinations(qubit_indices, 2):
edge = (min(q_i, q_j), max(q_i, q_j))
graph[edge] = graph.get(edge, 0) + 1
return graph

def asm_filter(self, backend_name: str) -> None:
self.ir.statements = [
statement
Expand Down Expand Up @@ -146,7 +163,8 @@ def map(self, mapper: Mapper) -> None:
"""
from opensquirrel.passes.mapper.qubit_remapper import remap_ir

mapping = mapper.map(self.ir, self.qubit_register_size)
mapping = mapper.map(self, self.qubit_register_size)

remap_ir(self, mapping)

def merge(self, merger: Merger) -> None:
Expand Down
5 changes: 2 additions & 3 deletions opensquirrel/passes/mapper/general_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from opensquirrel.ir import IR
from opensquirrel import Circuit
from opensquirrel.passes.mapper.mapping import Mapping


Expand All @@ -16,5 +16,4 @@ class Mapper(ABC):
def __init__(self, **kwargs: Any) -> None: ...

@abstractmethod
def map(self, ir: IR, qubit_register_size: int) -> Mapping:
raise NotImplementedError
def map(self, circuit: Circuit, qubit_register_size: int) -> Mapping: ...
12 changes: 8 additions & 4 deletions opensquirrel/passes/mapper/mip_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from opensquirrel.passes.mapper.mapping import Mapping

if TYPE_CHECKING:
from opensquirrel import Connectivity
from opensquirrel import Circuit, Connectivity
from opensquirrel.ir import IR

DISTANCE_UL = 999999
Expand Down Expand Up @@ -67,7 +67,11 @@ def __init__(
self.num_w_vars = 0
self.num_vars = 0

def map(self, ir: IR, qubit_register_size: int) -> Mapping:
def map(
self,
circuit: Circuit,
qubit_register_size: int,
) -> Mapping:
"""
Find an initial mapping of virtual qubits to physical qubits that minimizes
the sum of distances between mapped operands of all two-qubit gates, using
Expand All @@ -78,7 +82,7 @@ def map(self, ir: IR, qubit_register_size: int) -> Mapping:
gates, given the connectivity.

Args:
ir (IR): The intermediate representation of the quantum circuit to be mapped.
circuit (Circuit): The quantum circuit to be mapped.
qubit_register_size (int): The number of virtual qubits in the circuit.

Returns:
Expand All @@ -102,7 +106,7 @@ def map(self, ir: IR, qubit_register_size: int) -> Mapping:
raise RuntimeError(error_message)

distance = self._get_distance()
reference_counter = self._get_reference_counter(ir, self.num_virtual_qubits)
reference_counter = self._get_reference_counter(circuit.ir, self.num_virtual_qubits)

cost, constraints, integrality, bounds = self._get_linearized_formulation(reference_counter, distance)

Expand Down
41 changes: 36 additions & 5 deletions opensquirrel/passes/mapper/qgym_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
pass

if TYPE_CHECKING:
from opensquirrel import Connectivity
from opensquirrel import Circuit, Connectivity

try:
from stable_baselines3.common.base_class import BaseAlgorithm
Expand All @@ -40,13 +40,17 @@ def __init__(
self.env = InitialMapping(connection_graph=self.hardware_connectivity, **(env_kwargs or {}))
self.agent = self._load_agent(agent_class, agent_path)

def map(self, ir: IR, qubit_register_size: int) -> Mapping:
def map(
self,
circuit: Circuit,
qubit_register_size: int,
) -> Mapping:
"""
Compute an initial logical-to-physical qubit mapping using a trained
Stable-Baselines3 agent acting in the QGym InitialMapping environment.

Args:
ir (IR): Intermediate representation of the quantum circuit to be mapped.
circuit (Circuit): The quantum circuit to be mapped.
qubit_register_size (int): Number of logical (virtual) qubits in the circuit.

Returns:
Expand All @@ -65,7 +69,11 @@ def map(self, ir: IR, qubit_register_size: int) -> Mapping:
)
raise ValueError(msg)

circuit_graph = self._ir_to_interaction_graph(ir)
circuit_graph = (
self._ir_to_graph(circuit.ir)
if not circuit.interaction_graph
else self._convert_interaction_graph(circuit.interaction_graph)
)

obs, _ = self.env.reset(options={"interaction_graph": circuit_graph})

Expand Down Expand Up @@ -110,7 +118,7 @@ def _load_agent(agent_class: str, agent_path: str) -> BaseAlgorithm:
return cast("BaseAlgorithm", agent_cls.load(agent_path))

@staticmethod
def _ir_to_interaction_graph(ir: IR) -> nx.Graph:
def _ir_to_graph(ir: IR) -> nx.Graph:
"""Build an undirected interaction graph representation of the IR.

Args:
Expand All @@ -135,6 +143,29 @@ def _ir_to_interaction_graph(ir: IR) -> nx.Graph:
interaction_graph.add_edge(q_i, q_j, weight=1)
return interaction_graph

@staticmethod
def _convert_interaction_graph(edges: dict[tuple[int, int], int]) -> nx.Graph:
"""Convert Circuit's simple interaction graph to NetworkX graph.

Args:
edges: Dictionary mapping (qubit_i, qubit_j) tuples to interaction weights.

Returns:
NetworkX graph representation of the quantum circuit, compatible with QGym.
"""
graph = nx.Graph()

all_nodes = set()
for q_i, q_j in edges:
all_nodes.add(q_i)
all_nodes.add(q_j)
graph.add_nodes_from(all_nodes)

for (q_i, q_j), weight in edges.items():
graph.add_edge(q_i, q_j, weight=weight)

return graph

@staticmethod
def _get_mapping(last_obs: Any, qubit_register_size: int) -> Mapping:
"""Extract and convert QGym's physical-to-logical mapping to OpenSquirrel's logical-to-physical mapping.
Expand Down
20 changes: 16 additions & 4 deletions opensquirrel/passes/mapper/simple_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@
from opensquirrel.passes.mapper.mapping import Mapping

if TYPE_CHECKING:
from opensquirrel.ir import IR
from opensquirrel import Circuit


class IdentityMapper(Mapper):
def __init__(self, **kwargs: Any) -> None:
"""An ``IdentityMapper`` maps each virtual qubit to exactly the same physical qubit."""
super().__init__(**kwargs)

def map(self, ir: IR, qubit_register_size: int) -> Mapping:
def map(
self,
circuit: Circuit,
qubit_register_size: int,
) -> Mapping:
"""Create identity mapping."""
return Mapping(list(range(qubit_register_size)))

Expand All @@ -38,7 +42,11 @@ def __init__(self, mapping: Mapping, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._mapping = mapping

def map(self, ir: IR, qubit_register_size: int) -> Mapping:
def map(
self,
circuit: Circuit,
qubit_register_size: int,
) -> Mapping:
"""Return the hardcoded mapping."""
if qubit_register_size != self._mapping.size():
msg = (
Expand All @@ -60,7 +68,11 @@ def __init__(self, seed: int | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.seed = seed

def map(self, ir: IR, qubit_register_size: int) -> Mapping:
def map(
self,
circuit: Circuit,
qubit_register_size: int,
) -> Mapping:
"""Create a random mapping."""
if self.seed:
random.seed(self.seed)
Expand Down
10 changes: 5 additions & 5 deletions opensquirrel/passes/router/astar_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ def _astar_pathfinder(self, graph: nx.Graph, source: int, target: int) -> Any:
graph,
source=source,
target=target,
heuristic=lambda q0_index, q1_index: calculate_distance(
q0_index, q1_index, num_columns, self._distance_metric
)
if self._distance_metric
else None,
heuristic=lambda q0_index, q1_index: (
calculate_distance(q0_index, q1_index, num_columns, self._distance_metric)
if self._distance_metric
else None
),
)
3 changes: 2 additions & 1 deletion tests/ir/test_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest
from numpy.typing import NDArray

from opensquirrel.common import ATOL
from opensquirrel.ir import Axis, AxisLike, Bit, Float, Int, Qubit
from opensquirrel.ir.expression import Expression

Expand All @@ -15,7 +16,7 @@ def test_type_error(self) -> None:
Float("f") # type: ignore

def test_init(self) -> None:
assert Float(1).value == 1.0
assert Float(1).value == pytest.approx(1.0, abs=ATOL)


class TestInt:
Expand Down
2 changes: 1 addition & 1 deletion tests/passes/exporter/test_quantify_scheduler_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def _check_waiting_cycles(exported_schedule: Schedule, expected_waiting_cycles:
exported_schedule.schedulables.values(), expected_waiting_cycles, strict=False
):
waiting_time = schedulable_data.data["timing_constraints"][0].rel_time
assert waiting_time == -1.0 * expected_waiting_cycle * CYCLE_TIME
assert waiting_time == pytest.approx(-1.0 * expected_waiting_cycle * CYCLE_TIME, abs=ATOL)


@pytest.fixture
Expand Down
4 changes: 2 additions & 2 deletions tests/passes/mapper/test_general_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from opensquirrel.passes.mapper.mapping import Mapping

if TYPE_CHECKING:
from opensquirrel.ir import IR
from opensquirrel import Circuit


class TestMapper:
Expand All @@ -20,7 +20,7 @@ def __init__(self, qubit_register_size: int, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._qubit_register = qubit_register_size

def map(self, ir: IR, qubit_register_size: int) -> Mapping:
def map(self, circuit: Circuit, qubit_register_size: int) -> Mapping:
return Mapping(list(range(self._qubit_register)))

Mapper2(qubit_register_size=1)
Expand Down
23 changes: 12 additions & 11 deletions tests/passes/mapper/test_mip_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,32 +101,33 @@ def test_identity_mapping(mapper: str, circuit: str, expected_mapping: Mapping,
mapper_fixture = request.getfixturevalue(mapper)
circuit_fixture = request.getfixturevalue(circuit)

computed_mapping = mapper_fixture.map(circuit_fixture.ir, circuit_fixture.qubit_register_size)
computed_mapping = mapper_fixture.map(circuit_fixture, circuit_fixture.qubit_register_size)

assert computed_mapping == expected_mapping


def test_mip_mapper_remaps_when_needed(mapper2: MIPMapper, circuit2: Circuit) -> None:
if sys.platform.startswith("linux") or sys.platform == "win32":
expected_mapping = Mapping([2, 1, 3, 0, 4, 5, 6])
elif sys.platform == "darwin": # pragma: no cover
expected_mapping = Mapping([3, 4, 2, 0, 1, 5, 6])
else: # pragma: no cover
pytest.skip(f"Unknown platform: {sys.platform}")
mapping = mapper2.map(circuit2.ir, circuit2.qubit_register_size)
if sys.version_info < (3, 11):
if sys.platform.startswith("linux") or sys.platform == "win32":
expected_mapping = Mapping([2, 1, 3, 0, 4, 5, 6])
else:
expected_mapping = Mapping([3, 4, 2, 0, 1, 5, 6])
else:
expected_mapping = Mapping([5, 1, 0, 3, 4, 2, 6])
mapping = mapper2.map(circuit2, circuit2.qubit_register_size)

assert mapping == expected_mapping


def test_more_logical_qubits_than_physical(mapper1: MIPMapper, circuit3: Circuit) -> None:
with pytest.raises(RuntimeError, match=r"Number of virtual qubits (.*) exceeds number of physical qubits (.*)"):
mapper1.map(circuit3.ir, circuit3.qubit_register_size)
mapper1.map(circuit3, circuit3.qubit_register_size)


def test_timeout(mapper3: MIPMapper, circuit2: Circuit) -> None:
with pytest.raises(RuntimeError, match="MIP solver failed"):
# timeout used: 0.000001
mapper3.map(circuit2.ir, circuit2.qubit_register_size)
mapper3.map(circuit2, circuit2.qubit_register_size)


def test_fewer_virtual_than_physical_qubits(mapper1: MIPMapper) -> None:
Expand All @@ -136,7 +137,7 @@ def test_fewer_virtual_than_physical_qubits(mapper1: MIPMapper) -> None:
builder.CNOT(1, 2)
circuit = builder.to_circuit()

mapping = mapper1.map(circuit.ir, circuit.qubit_register_size)
mapping = mapper1.map(circuit, circuit.qubit_register_size)

assert len(mapping) == 3

Expand Down
Loading