Skip to content
Open
61 changes: 61 additions & 0 deletions src/mqt/yaqs/digital/utils/qasm_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Copyright (c) 2025 - 2026 Chair for Design Automation, TUM
# All rights reserved.
#
# SPDX-License-Identifier: MIT
#
# Licensed under the MIT License

"""QASM loading utilities shared by the Simulator and EquivalenceChecker."""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from qiskit import qasm2, qasm3

if TYPE_CHECKING:
from qiskit.circuit import QuantumCircuit


def _first_non_comment_line(text: str) -> str:
"""Return the first non-empty, non-comment line from QASM-like text.

Args:
text: Multiline string to scan.

Returns:
The first line that is not empty and does not start with ``//``,
or an empty string if no such line exists.
"""
for line in text.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("//"):
return stripped
return ""
Comment on lines +21 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a Google-style docstring to _first_non_comment_line.

The function lacks a docstring. As per coding guidelines, all Python functions should have Google-style docstrings that describe purpose, arguments, and return values.

📝 Suggested docstring
 def _first_non_comment_line(text: str) -> str:
+    """Return the first non-blank, non-comment line from QASM text.
+
+    Args:
+        text: QASM source text, potentially with leading comments.
+
+    Returns:
+        The first line that is neither blank nor a ``//`` comment,
+        or an empty string if no such line exists.
+    """
     for line in text.splitlines():
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _first_non_comment_line(text: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("//"):
return stripped
return ""
def _first_non_comment_line(text: str) -> str:
"""Return the first non-blank, non-comment line from QASM text.
Args:
text: QASM source text, potentially with leading comments.
Returns:
The first line that is neither blank nor a ``//`` comment,
or an empty string if no such line exists.
"""
for line in text.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("//"):
return stripped
return ""
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mqt/yaqs/digital/utils/qasm_utils.py` around lines 21 - 26, Add a
Google-style docstring to the _first_non_comment_line function: describe its
purpose (return the first non-empty, non-comment line from a QASM-like text),
document the argument (text: str — multiline input), and the return value (str —
the first non-comment line or empty string if none). Place the docstring
immediately below the def _first_non_comment_line(...) line and follow Google
docstring sections "Args:" and "Returns:".

Source: Coding guidelines



def load_circuit(circuit: QuantumCircuit | str | Path) -> QuantumCircuit:
"""Load a QuantumCircuit from a QASM string, file path, or return it unchanged.

Args:
circuit: A ``QuantumCircuit``, a raw QASM string, or a path to a ``.qasm`` file.

Returns:
The corresponding ``QuantumCircuit``.
"""
if not isinstance(circuit, (str, Path)):
return circuit

if isinstance(circuit, str):
header = _first_non_comment_line(circuit)
if header.startswith("OPENQASM"):
if header.startswith("OPENQASM 3"): # pragma: no cover
return qasm3.loads(circuit)
return qasm2.loads(circuit)

path = Path(circuit)
content = path.read_text(encoding="utf-8")
if _first_non_comment_line(content).startswith("OPENQASM 3"): # pragma: no cover
return qasm3.load(str(path))
return qasm2.load(str(path))
16 changes: 13 additions & 3 deletions src/mqt/yaqs/equivalence_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
from .core.data_structures.mpo import MPO
from .digital.utils.contraction_utils import iterate
from .digital.utils.matrix_utils import check_equivalence_matrix
from .digital.utils.qasm_utils import load_circuit

if TYPE_CHECKING:
from pathlib import Path

from qiskit.circuit import QuantumCircuit

from .parallel_utils import MPContext
Expand Down Expand Up @@ -167,17 +170,21 @@ def _resolve_representation(self, num_qubits: int) -> Literal["matrix", "mpo"]:

def check(
self,
circuit1: QuantumCircuit,
circuit2: QuantumCircuit,
circuit1: QuantumCircuit | str | Path,
circuit2: QuantumCircuit | str | Path,
) -> dict[str, bool | float | str]:
"""Check whether two quantum circuits are equivalent.

If the circuits differ only up to global phase and numerical error, the composed
operator ``U2† U1`` approximates the identity.

Args:
circuit1: First quantum circuit.
circuit1: First quantum circuit. Accepts a :class:`~qiskit.circuit.QuantumCircuit`,
a ``Path`` to a ``.qasm`` file, or a ``str`` — either a filesystem path to a
``.qasm`` file or raw OpenQASM text (distinguished by whether the first
non-comment line starts with ``OPENQASM``).
circuit2: Second quantum circuit (must have the same number of qubits).
Accepts the same types as ``circuit1``.

Returns:
dict[str, bool | float | str]: ``equivalent`` (bool), ``elapsed_time`` (float, seconds),
Expand All @@ -186,6 +193,9 @@ def check(
Raises:
ValueError: If the circuits have different numbers of qubits.
"""
circuit1 = load_circuit(circuit1)
circuit2 = load_circuit(circuit2)

if circuit1.num_qubits != circuit2.num_qubits:
msg = "Circuits must have the same number of qubits."
raise ValueError(msg)
Expand Down
13 changes: 10 additions & 3 deletions src/mqt/yaqs/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,18 @@

from .core.data_structures.noise_model import NoiseModel

from pathlib import Path

from .analog.analog_tjm import analog_tjm_1, analog_tjm_2
from .analog.ensemble import ensemble_member_worker
from .analog.lindblad import lindblad_evolve, preprocess_lindblad
from .analog.mcwf import mcwf, preprocess_mcwf
from .digital.digital_tjm import digital_tjm
from .digital.utils.qasm_utils import load_circuit

__all__ = ["Simulator", "available_cpus", "run_backend_parallel"]


# ---------------------------------------------------------------------------
# 4) TYPE VARS FOR GENERIC PARALLEL RUNNERS
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -630,7 +634,7 @@ def __init__(
def run(
self,
initial_state: State | list[State],
operator: Hamiltonian | QuantumCircuit,
operator: Hamiltonian | QuantumCircuit | str | Path,
sim_params: AnalogSimParams | StrongSimParams | WeakSimParams,
noise_model: NoiseModel | None = None,
) -> Result:
Expand All @@ -648,7 +652,8 @@ def run(
or a list of states for deterministic analog unitary ensemble evolution
(``AnalogSimParams`` only).
operator: :class:`~mqt.yaqs.core.data_structures.hamiltonian.Hamiltonian` for analog
simulations or a :class:`~qiskit.circuit.QuantumCircuit` for circuit simulations.
simulations, or a :class:`~qiskit.circuit.QuantumCircuit`, raw QASM ``str``, or
``Path`` to a ``.qasm`` file for circuit simulations.
sim_params: Simulation parameters specifying the simulation mode and settings.
noise_model: The noise model to apply. If provided, it is sampled once at the
beginning of the run to generate a concrete noise realization (static disorder).
Expand All @@ -662,8 +667,10 @@ def run(
Raises:
ValueError: If no output is specified (neither observables nor ``get_state``).
TypeError: If the provided ``initial_state`` type is incompatible with the
selected simulation mode.
"""
if not isinstance(sim_params, AnalogSimParams) and isinstance(operator, (str, Path)):
operator = load_circuit(operator)

if isinstance(initial_state, list) and any(not isinstance(state, State) for state in initial_state):
msg = "initial_state list must contain only State objects."
raise TypeError(msg)
Expand Down
9 changes: 9 additions & 0 deletions tests/circuit3.qasm
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
OPENQASM 3.0;
include "stdgates.inc";

qubit[2] q;
bit[2] c;

h q[0];
cx q[0], q[1];
c = measure q;
98 changes: 98 additions & 0 deletions tests/digital/utils/test_qasm_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright (c) 2025 - 2026 Chair for Design Automation, TUM
# All rights reserved.
#
# SPDX-License-Identifier: MIT
#
# Licensed under the MIT License

"""Tests for QASM loading utilities."""

from __future__ import annotations

from pathlib import Path

import pytest
from qiskit import QuantumCircuit

from mqt.yaqs.digital.utils.qasm_utils import (
_first_non_comment_line, # noqa: PLC2701 — tests exercise the private function directly
load_circuit,
)

QASM2_STRING = """\
OPENQASM 2.0;
include "qelib1.inc";
qreg q[1];
h q[0];
"""

QASM3_STRING = """\
OPENQASM 3.0;
qubit[1] q;
h q[0];
"""


def test_first_non_comment_line_skips_comments() -> None:
"""Lines starting with // are skipped; the first real line is returned."""
text = "// comment\n// another\nOPENQASM 2.0;"
assert _first_non_comment_line(text) == "OPENQASM 2.0;"


def test_first_non_comment_line_empty_returns_empty() -> None:
"""An all-comment or blank text returns an empty string."""
assert not _first_non_comment_line("// only comments\n// still comments")
assert not _first_non_comment_line("")


def test_load_circuit_passthrough_quantum_circuit() -> None:
"""A QuantumCircuit is returned unchanged."""
qc = QuantumCircuit(1)
qc.h(0)
result = load_circuit(qc)
assert result is qc


def test_load_circuit_qasm2_string() -> None:
"""A raw QASM 2 string is parsed and returned as a QuantumCircuit."""
qc = load_circuit(QASM2_STRING)
assert isinstance(qc, QuantumCircuit)
assert qc.num_qubits == 1


def test_load_circuit_qasm2_path_object() -> None:
"""A QASM 2 file given as a Path is loaded and returned as a QuantumCircuit."""
qasm_file = Path(__file__).parent.parent.parent / "circuit.qasm"
qc = load_circuit(qasm_file)
assert isinstance(qc, QuantumCircuit)


def test_load_circuit_qasm2_str_path() -> None:
"""A QASM 2 file given as a str path is loaded and returned as a QuantumCircuit."""
qasm_file = str(Path(__file__).parent.parent.parent / "circuit.qasm")
qc = load_circuit(qasm_file)
assert isinstance(qc, QuantumCircuit)


def test_load_circuit_qasm3_string() -> None:
"""A raw QASM 3 string is parsed and returned as a QuantumCircuit."""
pytest.importorskip("qiskit_qasm3_import")
qc = load_circuit(QASM3_STRING)
assert isinstance(qc, QuantumCircuit)
assert qc.num_qubits == 1


def test_load_circuit_qasm3_path_object() -> None:
"""A QASM 3 file given as a Path is loaded and returned as a QuantumCircuit."""
pytest.importorskip("qiskit_qasm3_import")
qasm_file = Path(__file__).parent.parent.parent / "circuit3.qasm"
qc = load_circuit(qasm_file)
assert isinstance(qc, QuantumCircuit)


def test_load_circuit_qasm3_str_path() -> None:
"""A QASM 3 file given as a str path is loaded and returned as a QuantumCircuit."""
pytest.importorskip("qiskit_qasm3_import")
qasm_file = str(Path(__file__).parent.parent.parent / "circuit3.qasm")
qc = load_circuit(qasm_file)
assert isinstance(qc, QuantumCircuit)
91 changes: 67 additions & 24 deletions tests/test_equivalence_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,30 +229,6 @@ def test_checker_rejects_non_int_max_workers() -> None:
EquivalenceChecker(max_workers=1.5) # ty: ignore[invalid-argument-type]


def test_checker_rejects_invalid_representation() -> None:
"""Unknown ``representation`` strings are rejected at construction."""
with pytest.raises(ValueError, match="representation must be one of"):
EquivalenceChecker(representation="tensor") # ty: ignore[invalid-argument-type]


def test_checker_rejects_bool_matrix_max_qubits() -> None:
"""``matrix_max_qubits`` must be a true integer, not a boolean."""
with pytest.raises(TypeError, match="matrix_max_qubits"):
EquivalenceChecker(matrix_max_qubits=True)


def test_checker_rejects_negative_matrix_max_qubits() -> None:
"""``matrix_max_qubits`` must be non-negative."""
with pytest.raises(ValueError, match="non-negative"):
EquivalenceChecker(matrix_max_qubits=-1)


def test_check_rejects_mismatched_qubit_counts() -> None:
"""``check`` requires both circuits to have the same width."""
with pytest.raises(ValueError, match="same number of qubits"):
EquivalenceChecker().check(QuantumCircuit(2), QuantumCircuit(3))


def test_equivalence_checker_defaults_parallel_true() -> None:
"""``parallel`` defaults to ``True`` (MPO thread pool still gated by qubit count)."""
assert EquivalenceChecker().parallel is True
Expand Down Expand Up @@ -349,3 +325,70 @@ def test_long_range_mpo_parallel() -> None:
serial = EquivalenceChecker(representation="mpo", parallel=False).check(qc1, qc2)
parallel = EquivalenceChecker(representation="mpo", parallel=True, max_workers=2).check(qc1, qc2)
assert serial["equivalent"] == parallel["equivalent"]


def test_check_accepts_qasm2_path_object() -> None:
"""Check that a QASM 2 file given as a Path object is accepted and returns equivalent."""
qasm_path = Path(__file__).parent / "circuit.qasm"

checker = EquivalenceChecker(representation="mpo")
result = checker.check(qasm_path, qasm_path)
assert result["equivalent"] is True


def test_check_accepts_qasm2_str_path() -> None:
"""Check that a QASM 2 file given as a str path is accepted and returns equivalent."""
qasm_path = str(Path(__file__).parent / "circuit.qasm")

checker = EquivalenceChecker(representation="mpo")
result = checker.check(qasm_path, qasm_path)
assert result["equivalent"] is True


def test_check_qasm_path_vs_quantumcircuit_agree() -> None:
"""Verify that loading via path and via QuantumCircuit gives the same equivalence result."""
qasm_path = Path(__file__).parent / "circuit.qasm"
qc = load(filename=str(qasm_path))
checker = EquivalenceChecker(representation="mpo")
result_path = checker.check(qasm_path, qasm_path)
result_qc = checker.check(qc, qc)
assert result_path["equivalent"] == result_qc["equivalent"]


def test_check_accepts_qasm3_path_object() -> None:
"""Check that a QASM 3 file given as a Path object is accepted and returns equivalent."""
pytest.importorskip("qiskit_qasm3_import")
qasm_file = Path(__file__).parent / "circuit3.qasm"

checker = EquivalenceChecker(representation="matrix")
result = checker.check(qasm_file, qasm_file)
assert result["equivalent"] is True


def test_check_accepts_qasm3_str_path() -> None:
"""Check that a QASM 3 file given as a str path is accepted and returns equivalent."""
pytest.importorskip("qiskit_qasm3_import")
qasm_file = str(Path(__file__).parent / "circuit3.qasm")

checker = EquivalenceChecker(representation="matrix")
result = checker.check(qasm_file, qasm_file)
assert result["equivalent"] is True
Comment on lines +330 to +375

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add integration tests for raw OpenQASM string inputs to check().

These additions verify Path and string-path handling, but the PR feature also includes direct QASM string inputs. Adding raw-QASM integration tests here will guard the public EquivalenceChecker.check() contract directly.

Proposed test additions
+def test_check_accepts_qasm2_raw_string() -> None:
+    """Check that raw OpenQASM 2 text input is accepted."""
+    qasm2 = """\
+OPENQASM 2.0;
+include "qelib1.inc";
+qreg q[1];
+h q[0];
+"""
+    checker = EquivalenceChecker(representation="matrix")
+    result = checker.check(qasm2, qasm2)
+    assert result["equivalent"] is True
+
+
+def test_check_accepts_qasm3_raw_string() -> None:
+    """Check that raw OpenQASM 3 text input is accepted."""
+    pytest.importorskip("qiskit_qasm3_import")
+    qasm3 = """\
+OPENQASM 3.0;
+include "stdgates.inc";
+qubit[1] q;
+h q[0];
+"""
+    checker = EquivalenceChecker(representation="matrix")
+    result = checker.check(qasm3, qasm3)
+    assert result["equivalent"] is True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_equivalence_checker.py` around lines 330 - 375, Add integration
tests that pass raw OpenQASM content strings into EquivalenceChecker.check();
specifically, create new tests like test_check_accepts_qasm2_raw_string and
test_check_accepts_qasm3_raw_string that read the existing "circuit.qasm" and
"circuit3.qasm" files via Path(...).read_text() and call checker.check(qasm_str,
qasm_str) (use representation="mpo" for QASM2 and representation="matrix" for
QASM3). For QASM3 test, wrap with pytest.importorskip("qiskit_qasm3_import") as
done in test_check_accepts_qasm3_path_object, and assert result["equivalent"] is
True to match the other path/string tests.



def test_check_accepts_qasm2_raw_string() -> None:
"""Check that a raw QASM 2 string (not a file path) is accepted and returns equivalent."""
qasm_str = (Path(__file__).parent / "circuit.qasm").read_text(encoding="utf-8")

checker = EquivalenceChecker(representation="mpo")
result = checker.check(qasm_str, qasm_str)
assert result["equivalent"] is True


def test_check_accepts_qasm3_raw_string() -> None:
"""Check that a raw QASM 3 string (not a file path) is accepted and returns equivalent."""
pytest.importorskip("qiskit_qasm3_import")
qasm_str = (Path(__file__).parent / "circuit3.qasm").read_text(encoding="utf-8")

checker = EquivalenceChecker(representation="matrix")
result = checker.check(qasm_str, qasm_str)
assert result["equivalent"] is True
Loading
Loading