diff --git a/CHANGELOG.md b/CHANGELOG.md index a2eb0c68..e06d531e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Types of changes: ## Unreleased ### Added +- Added support for OpenQASM 2 `opaque` declarations, which previously failed at parse time and blocked vendor include files such as Quantinuum's `hqslib1.inc`. An opaque gate is treated as a black box: emitted as written, counted as one layer of depth. `to_qasm3()` rejects such a program. ([#370](https://github.com/qBraid/pyqasm/issues/370)) - Added an `include_dir` kwarg to `loads()` and `load()`, naming the directory custom `include` statements resolve against. A program given as a string could not resolve includes at all, and failed later naming the gate rather than the include. Resolution is opt-in: without the kwarg, no files are read. ([#368](https://github.com/qBraid/pyqasm/issues/368)) ### Improved / Modified diff --git a/src/README.md b/src/README.md index fe470714..a32785ce 100644 --- a/src/README.md +++ b/src/README.md @@ -41,6 +41,35 @@ Source code for OpenQASM 3 program validator and semantic analyzer | ComplexType | ✅ | Completed | | AngleType | ✅ | Completed | | ExternDeclaration | ✅ | Completed | +| opaque (OpenQASM 2 only) | ✅ | Emitted as written | + +## Opaque gates + +`opaque NAME(params) qubits;` is OpenQASM 2 syntax that was removed in OpenQASM 3. It +declares a hardware primitive: a gate with a name and an arity and no decomposition. +Quantinuum's `hqslib1.inc` opens with six of them. + +pyqasm rewrites the declaration before parsing and records the name, so `validate()`, +`depth()`, `has_measurements()` and the qubit-renumbering passes all work. A call to an +opaque gate is emitted as written rather than unrolled, and counts as one layer of depth, +exactly as an external gate does. `unroll()` never drops that treatment: unlike +`external_gates`, which the caller sets per call, an opaque gate is a property of the +program and has no decomposition to fall back on. + +Two things to know: + +- **The declaration is not re-emitted.** Unrolling drops it, the same way it drops the + `gate` definition of an external gate, so the output carries calls to a gate it does + not declare and does not load back into pyqasm on its own. +- **`opaque` in an OpenQASM 3 program is still a parse error.** The rewrite is gated on + the `OPENQASM 2` header, because the keyword is not OpenQASM 3 syntax. +- **`rebase()` reports it as unsupported.** An opaque primitive has no decomposition, so + it cannot be rewritten onto a standard basis set; it reaches the existing + unsupported-gate path and is named there. +- **`to_qasm3()` refuses a program that declares one.** OpenQASM 3 removed `opaque` and + has no equivalent for a gate with no decomposition, and a body-less `gate` in + OpenQASM 3 means the identity — so converting would silently turn each hardware + primitive into a no-op. ## Pragmas diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 49565f0c..83d0b296 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -27,7 +27,11 @@ from pyqasm.exceptions import ValidationError from pyqasm.maps import SUPPORTED_QASM_VERSIONS from pyqasm.modules import Qasm2Module, Qasm3Module, QasmModule -from pyqasm.preprocess import process_include_sources, process_include_statements +from pyqasm.preprocess import ( + process_include_sources, + process_include_statements, + rewrite_opaque_declarations, +) if TYPE_CHECKING: import openqasm3.ast @@ -165,9 +169,12 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule: """ _validate_kwargs(kwargs) include_dir = kwargs.pop("include_dir", None) + opaque_gates: set[str] = set() if isinstance(program, str): if include_dir is not None: program = process_include_sources(program, include_dir) + # after include resolution, so an opaque in a vendor include is rewritten too + program, opaque_gates = rewrite_opaque_declarations(program) try: program = openqasm3.parse(program) except openqasm3.parser.QASM3ParsingError as err: @@ -191,6 +198,7 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule: qasm_module = Qasm3Module if program.version.startswith("3") else Qasm2Module module = qasm_module("main", program) + module._opaque_gates = opaque_gates # `is not None`, not truthiness: a falsy value is a caller value, not an omission. # An explicit None means "not passed", so defaults like extern_functions={} and # frame_in_def_cal=True are never clobbered. diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index 9d65b07a..a28b6184 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -200,6 +200,8 @@ def __init__(self, name: str, program: Program): self._validated_program = False self._unrolled_ast = Program(statements=[]) self._external_gates: list[str] = [] + # declared `opaque` in the source, so unroll() never flushes these + self._opaque_gates: set[str] = set() self._decompose_native_gates: Optional[bool] = None self._device_qubits: Optional[int] = None self._consolidate_qubits: Optional[bool] = False diff --git a/src/pyqasm/modules/qasm2.py b/src/pyqasm/modules/qasm2.py index fd176d9d..26604ace 100644 --- a/src/pyqasm/modules/qasm2.py +++ b/src/pyqasm/modules/qasm2.py @@ -135,9 +135,20 @@ def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module: as_str (bool): Flag to indicate if the conversion should be to a string or to a Qasm3Module object. Default is False. + Raises: + ValidationError: If the program declares an `opaque` gate. OpenQASM 3 removed + `opaque` and has no equivalent, so there is no correct translation. + Returns: str | Qasm3Module: The module in openqasm3 format. """ + if self._opaque_gates: + # OpenQASM 3 removed `opaque` from the language + raise ValidationError( + "Cannot convert to OpenQASM 3: the program declares the opaque gate(s) " + f"{', '.join(sorted(self._opaque_gates))}. OpenQASM 3 removed 'opaque' and " + "has no equivalent for a gate with no decomposition." + ) qasm_program = deepcopy(self._original_program) # replace the include with stdgates.inc for stmt in qasm_program.statements: diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index b903b059..62afcb4a 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -46,9 +46,74 @@ class IncludeContext: r'^\s*include\s+"(?:stdgates\.inc|qelib1\.inc)";\s*', re.MULTILINE ), "include": re.compile(r'^\s*include\s+"([^"]+)";\s*', re.MULTILINE), + # OPENQASM 2 only. Captures indent, name, the parenthesised parameter list if any, + # and the qubit list: "opaque Rz(lam) q;", "opaque ZZ() q1,q2;", "opaque zz q1,q2;" + "opaque": re.compile( + r"^([ \t]*)opaque\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\([^)]*\))?\s*([^;{}]*?)\s*;", + re.MULTILINE, + ), + "openqasm2": re.compile(r"^\s*OPENQASM\s+2(?:\.\d+)?;", re.MULTILINE), + # a string literal, a `//` line comment, or a `/* */` block comment (unterminated + # one included). The string alternative comes first so a `//` inside an include path + # is consumed as a string and never mistaken for a comment. + "string_or_comment": re.compile(r'"[^"\n]*"|//[^\n]*|/\*.*?(?:\*/|\Z)', re.DOTALL), } +def _blank_comments(program: str) -> str: + """Overwrite `//` and `/* */` comments with spaces. + + Blanked rather than deleted so line and column numbers are unchanged: the parser + reports spans against this text, and deleting a comment would shift every position + after it. + + Args: + program (str): The OpenQASM program text. + + Returns: + str: The text with comment characters replaced by spaces. + """ + + def blank(match: re.Match) -> str: + text = match.group() + # keep string literals as they are; only comments are blanked + return text if text.startswith('"') else re.sub(r"[^\n]", " ", text) + + return PATTERNS["string_or_comment"].sub(blank, program) + + +def rewrite_opaque_declarations(program: str) -> tuple[str, set[str]]: + """Rewrite OpenQASM 2 ``opaque`` declarations into gate definitions with empty bodies. + + The ``openqasm3`` parser pyqasm routes qasm2 through has no production for ``opaque``, + so this runs before parsing. The empty body carries the gate's name and arity only, + which is all an opaque gate has (issue #370). Gated on the qasm2 header, since + ``opaque`` is not OpenQASM 3 syntax. + + Comments are blanked first, so a declaration commented out with ``/* */`` is neither + rewritten nor recorded. The result goes straight to the parser, which discards + comments anyway. + + Args: + program (str): The OpenQASM program text. + + Returns: + tuple[str, set[str]]: The rewritten program, and the names declared opaque. + """ + code = _blank_comments(program) + if not PATTERNS["openqasm2"].search(code): + return program, set() + + names: set[str] = set() + + def _replace(match: re.Match) -> str: + indent, name, params, qubits = match.groups() + names.add(name) + return f"{indent}gate {name}{params or ''} {qubits} {{ }}" + + return PATTERNS["opaque"].sub(_replace, code), names + + def process_include_statements(filename: str, include_dir: str | None = None) -> str: """ Recursively processes include statements in an OpenQASM file, replacing them with the diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index d837f149..bd45db75 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -128,6 +128,9 @@ def __init__( # pylint: disable=too-many-arguments # cycle of any length between gate definitions (issue #369) self._gate_expansion_chain: list[str] = [] self._external_gates: list[str] = [] if external_gates is None else external_gates + # opaque gates come from the program itself, so they are read off the module + # rather than taken from unroll()'s kwargs (issue #370) + self._opaque_gates: set[str] = getattr(module, "_opaque_gates", set()) self._subroutine_defns: dict[ str, qasm3_ast.SubroutineDefinition | qasm3_ast.ExternDeclaration ] = {} @@ -1287,6 +1290,24 @@ def _visit_continue(self, statement: qasm3_ast.ContinueStatement) -> None: error_node=statement, ) + def _is_black_box_gate(self, gate_name: str) -> bool: + """Check whether a gate must be emitted as written instead of being unrolled. + + True for a gate the caller named in ``external_gates``, any gate inside a verbatim + box, and one the program declared ``opaque`` (issue #370). + + Args: + gate_name (str): The name of the gate being applied. + + Returns: + bool: True if the gate is emitted as written. + """ + return ( + self._in_verbatim_box + or gate_name in self._external_gates + or gate_name in self._opaque_gates + ) + def _visit_custom_gate_operation( self, operation: qasm3_ast.QuantumGate, @@ -1344,7 +1365,11 @@ def _visit_custom_gate_operation( # 'or', not '=': a nested gate must stay suppressed inside an enclosing external # gate rather than re-enable recording for the body its parent skips (issue #367) prev_recording = self._recording_ext_gate_depth - is_external = self._in_verbatim_box or gate_name in self._external_gates + # 'or', not '=': a nested gate must stay suppressed inside an enclosing external + # gate rather than re-enable recording for the body its parent skips (issue #367) + prev_recording = self._recording_ext_gate_depth + is_external = self._is_black_box_gate(gate_name) + self._recording_ext_gate_depth = prev_recording or is_external self._recording_ext_gate_depth = prev_recording or is_external result = [] @@ -1703,7 +1728,7 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man for _ in range(power_value): if isinstance(operation, qasm3_ast.QuantumPhase): result.extend(self._visit_phase_operation(operation, inverse_value, ctrls)) - elif self._in_verbatim_box or operation.name.name in self._external_gates: + elif self._is_black_box_gate(operation.name.name): result.extend(self._visit_external_gate_operation(operation, inverse_value, ctrls)) elif operation.name.name in self._custom_gates: result.extend(self._visit_custom_gate_operation(operation, inverse_value, ctrls)) diff --git a/tests/qasm2/test_opaque.py b/tests/qasm2/test_opaque.py new file mode 100644 index 00000000..55e15cdf --- /dev/null +++ b/tests/qasm2/test_opaque.py @@ -0,0 +1,298 @@ +# Copyright 2025 qBraid +# +# 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. + +""" +Module containing unit tests for OpenQASM 2 `opaque` declarations (issue #370). + +An opaque gate is a hardware primitive: it is declared with a name and an arity and has +no decomposition. pyqasm parses the declaration and emits a call to it as written. + +""" + +import pytest + +from pyqasm.elements import BasisSet +from pyqasm.entrypoint import dumps, load, loads +from pyqasm.exceptions import RebaseError, ValidationError +from tests.utils import check_unrolled_qasm + +# The six opaque primitives Quantinuum's hqslib1.inc opens with, plus the two gates it +# defines in terms of them. Written out here rather than vendored, so the test does not +# depend on pytket being installed. +HQSLIB1_LIKE_INC = """ +opaque Rz(lam) q; +opaque U1q(theta, phi) q; +opaque ZZ() q1,q2; +opaque RZZ(theta) q1,q2; +opaque Rxxyyzz(alpha, beta, gamma) q1,q2; +opaque Rxxyyzz_zphase(alpha, beta, gamma, z0, z1) q1,q2; + +gate U(a,b,c) q { U1q(a, b) q; Rz(c) q; } +gate CX c,t { ZZ c,t; } +""" + +OPAQUE_PROGRAM = """ +OPENQASM 2.0; +include "qelib1.inc"; +opaque ZZ() q1,q2; +opaque Rz(lam) q; +qreg q[3]; +""" + + +def test_opaque_declaration_parses(): + """The reported failure was at parse time: every program carrying an opaque + declaration raised `Failed to parse OpenQASM string` (issue #370).""" + qasm_str = """ + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + opaque custom_gate(a,b,c) p,q,r; + """ + module = loads(qasm_str) + module.validate() + assert module._opaque_gates == {"custom_gate"} + + +@pytest.mark.parametrize( + "declaration, name", + [ + ("opaque Rz(lam) q;", "Rz"), + ("opaque ZZ() q1,q2;", "ZZ"), + ("opaque zz q1,q2;", "zz"), + ("opaque Rxxyyzz(alpha, beta, gamma) q1,q2;", "Rxxyyzz"), + ], +) +def test_opaque_declaration_forms(declaration, name): + """All three qasm2 spellings parse: parameters, empty parentheses, and no + parentheses at all.""" + module = loads(f'OPENQASM 2.0;\ninclude "qelib1.inc";\n{declaration}\nqreg q[2];\n') + module.validate() + assert module._opaque_gates == {name} + + +@pytest.mark.parametrize( + "commented", + [ + "// opaque hidden q;", + " //opaque hidden q;", + "/* opaque hidden q; */", + "/*\nopaque hidden q;\n*/", + ], +) +def test_commented_out_opaque_is_not_declared(commented): + """The rewrite runs on source text, so it must see code only. A declaration inside a + `//` or `/* */` comment is neither rewritten nor recorded -- recording it would make + a real gate of that name be emitted as written instead of unrolled.""" + module = loads(f'OPENQASM 2.0;\ninclude "qelib1.inc";\n{commented}\nqreg q[1];\nh q[0];\n') + module.unroll() + assert not module._opaque_gates + assert "h q[0];" in dumps(module) + + +@pytest.mark.parametrize("path", ["dir//lib.inc", "dir/*x*/lib.inc"]) +def test_comment_markers_inside_a_string_are_not_blanked(path): + """Blanking runs over source text, so a `//` or `/*` inside an include path must be + recognised as a string and left alone -- blanking it would truncate the statement.""" + module = loads(f'OPENQASM 2.0;\ninclude "{path}";\nqreg q[1];\nh q[0];\n') + module.unroll() + assert f'include "{path}";' in dumps(module) + + +def test_opaque_declaration_with_a_trailing_comment(): + """Blanking comments must not disturb the declaration they sit beside.""" + module = loads( + 'OPENQASM 2.0;\ninclude "qelib1.inc";\n' + "opaque ZZ() a,b; // the native two-qubit gate\nqreg q[2];\nZZ q[0],q[1];\n" + ) + module.unroll() + assert module._opaque_gates == {"ZZ"} + assert "ZZ q[0], q[1];" in dumps(module) + + +def test_opaque_gate_is_emitted_as_written(): + """An opaque gate has no decomposition, so unrolling emits the call unchanged.""" + expected_qasm_str = """ + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + ZZ q[0], q[1]; + Rz(0.5) q[2]; + """ + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\nRz(0.5) q[2];\n") + module.unroll() + check_unrolled_qasm(dumps(module), expected_qasm_str) + + +def test_opaque_gate_arity_is_validated(): + """The declared arity is the whole of what an opaque declaration carries, so a call + that does not match it must be rejected.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0];\n") + with pytest.raises(ValidationError, match="Qubit count mismatch for gate 'ZZ'"): + module.validate() + + +@pytest.mark.parametrize("external_gates", [None, [], ["h"], ["ZZ"]]) +def test_opaque_gate_is_never_flushed_by_unroll(external_gates): + """`unroll()` resets `external_gates` on every call, so opaque gates are tracked + separately: an opaque gate has no decomposition to fall back on, and must survive + an unroll that names other gates, or none.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + module.unroll(external_gates=external_gates) + assert "ZZ q[0], q[1];" in dumps(module) + + +def test_opaque_gate_counts_as_one_towards_depth(): + """One emitted statement is one layer, the same contract an external gate has.""" + module = loads(OPAQUE_PROGRAM + "h q[0];\nZZ q[0],q[1];\n") + module.unroll() + assert module.depth() == 2 + + +def test_opaque_gate_inside_a_custom_gate_body(): + """A custom gate that calls an opaque primitive unrolls down to the primitive and + stops there, rather than failing on an undeclared operation.""" + expected_qasm_str = """ + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + ZZ q[0], q[1]; + Rz(0.5) q[0]; + """ + module = loads(OPAQUE_PROGRAM + "gate wrap a,b { ZZ a,b; Rz(0.5) a; }\nwrap q[0],q[1];\n") + module.unroll() + check_unrolled_qasm(dumps(module), expected_qasm_str) + + +@pytest.mark.parametrize( + "transform, expected", + [ + ("remove_idle_qubits", "ZZ q[0], q[1];"), + ("reverse_qubit_order", "ZZ q[2], q[0];"), + ], +) +def test_opaque_gate_survives_qubit_transformations(transform, expected): + """An opaque call is an ordinary gate statement, so the passes that renumber qubits + must rewrite its operands like any other.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[2];\n") + module.unroll() + getattr(module, transform)() + assert expected in dumps(module) + + +def test_opaque_gate_survives_qubit_consolidation(): + """Consolidation rewrites the operands onto the internal register.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + module.unroll(consolidate_qubits=True) + assert "ZZ __PYQASM_QUBITS__[0], __PYQASM_QUBITS__[1];" in dumps(module) + + +def test_opaque_is_not_qasm3_syntax(): + """`opaque` was removed in OpenQASM 3, so a qasm3 program carrying one must keep + failing to parse. The rewrite is gated on the OPENQASM 2 header.""" + qasm_str = """ + OPENQASM 3.0; + include "stdgates.inc"; + opaque foo q; + qubit[1] q; + """ + with pytest.raises(ValidationError, match="Failed to parse OpenQASM string"): + loads(qasm_str) + + +def test_quantinuum_style_program_loads_from_a_string(tmp_path): + """The driver for the issue: a bell state compiled for H-series hardware, whose + include file declares its primitives opaque. It could not be loaded at all. The + program is a string, so `include_dir` locates the include (issue #368) -- both + changes are needed to load a compiled program.""" + (tmp_path / "hqslib1.inc").write_text(HQSLIB1_LIKE_INC, encoding="utf-8") + program = """ + OPENQASM 2.0; + include "hqslib1.inc"; + + qreg q[2]; + creg c[2]; + rz(1.0*pi) q[0]; + rz(3.5*pi) q[1]; + U1q(0.5*pi,0.5*pi) q[0]; + U1q(2.5*pi,0.0*pi) q[1]; + rz(0.5*pi) q[0]; + RZZ(0.5*pi) q[0],q[1]; + measure q[0] -> c[0]; + U1q(3.5*pi,0.5*pi) q[1]; + measure q[1] -> c[1]; + """ + module = loads(program, include_dir=str(tmp_path)) + module.unroll() + + assert module.num_qubits == 2 + assert module.num_clbits == 2 + assert module.has_measurements() + unrolled = dumps(module) + # the three opaque primitives the program calls survive as written + assert "U1q(1.5707963267948966, 1.5707963267948966) q[0];" in unrolled + assert "RZZ(1.5707963267948966) q[0], q[1];" in unrolled + assert unrolled.count("measure") == 2 + + +def test_load_resolves_opaque_declarations_from_a_file(tmp_path): + """The rewrite runs after include inlining, so an opaque declaration inside an + included file is reached by load() too.""" + (tmp_path / "hqslib1.inc").write_text(HQSLIB1_LIKE_INC, encoding="utf-8") + path = tmp_path / "prog.qasm" + path.write_text( + 'OPENQASM 2.0;\ninclude "hqslib1.inc";\nqreg q[2];\nRZZ(0.5) q[0],q[1];\n', + encoding="utf-8", + ) + + module = load(str(path)) + module.unroll() + assert "RZZ(0.5) q[0], q[1];" in dumps(module) + + +@pytest.mark.parametrize("basis_set", [BasisSet.ROTATIONAL_CX, BasisSet.CLIFFORD_T]) +def test_rebase_reports_an_opaque_gate_by_name(basis_set): + """An opaque primitive has no decomposition, so it cannot be rebased onto a standard + basis set. It reaches the existing unsupported-gate path and is named there, rather + than crashing inside the decomposer.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + module.unroll() + with pytest.raises(RebaseError, match="Gate 'ZZ' is not supported"): + module.rebase(basis_set) + + +@pytest.mark.parametrize("as_str", [True, False]) +def test_to_qasm3_refuses_a_program_with_opaque_gates(as_str): + """OpenQASM 3 removed `opaque` and has no equivalent. pyqasm carries an opaque gate + as a body-less gate definition, and OpenQASM 3 reads a body-less gate as the + identity, so converting would silently turn each hardware primitive into a no-op. + Refusing is loud; the alternative is not.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + with pytest.raises(ValidationError, match="OpenQASM 3 removed 'opaque'"): + module.to_qasm3(as_str=as_str) + + +def test_to_qasm3_still_works_without_opaque_gates(): + """The guard must be scoped to programs that actually declare an opaque gate.""" + module = loads('OPENQASM 2.0;\ninclude "qelib1.inc";\nqreg q[2];\nh q[0];\n') + assert "OPENQASM 3.0" in module.to_qasm3(as_str=True) + + +def test_opaque_declaration_is_dropped_from_the_unrolled_output(): + """Documented limitation: like an external gate's definition, the declaration is not + re-emitted, so the unrolled output does not load back into pyqasm on its own. Pinned + so the day it changes is a deliberate one.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + module.unroll() + assert "opaque" not in dumps(module)