Skip to content

feat: support OpenQASM 2 opaque declarations as black-box gates - #378

Open
TheGupta2012 wants to merge 4 commits into
support-include-sources-in-loadsfrom
support-opaque-declarations
Open

feat: support OpenQASM 2 opaque declarations as black-box gates#378
TheGupta2012 wants to merge 4 commits into
support-include-sources-in-loadsfrom
support-opaque-declarations

Conversation

@TheGupta2012

@TheGupta2012 TheGupta2012 commented Aug 18, 2026

Copy link
Copy Markdown
Member

Fixes #370. Supersedes #54.

The problem

pyqasm could not parse any OpenQASM 2 program containing an opaque declaration:

pyqasm.loads('OPENQASM 2.0;\ninclude "qelib1.inc";\nqreg q[2];\nopaque custom_gate(a,b,c) p,q,r;\n')
# ValidationError: Failed to parse OpenQASM string:

This blocked vendor include files — notably Quantinuum's hqslib1.inc, whose six hardware primitives are all declared opaque — so no compiled program from H-series hardware could be loaded at all.

The failure is at parse time, not in the visitor. opaque is OpenQASM 2 syntax that OpenQASM 3 removed, and pyqasm routes qasm2 through the openqasm3 parser, which has no production for it — the string opaque appears nowhere in the installed openqasm3 package. This is why #54's suggestion to handle it in the visitor could not work.

The change

The rewrite lives in source preprocessing, where include inlining already happens:

opaque Rz(lam) q;   ->   gate Rz(lam) q { }

plus the name recorded on the module in _opaque_gates. An opaque gate has no decomposition by definition, so the empty body is a carrier for its name and arity only; the recorded name is what makes it a black box.

  • Routing. _visit_generic_gate_operation sends an opaque call to _visit_external_gate_operation, so it is emitted as written and counts as one layer of depth — exactly the treatment an external gate gets. A new _is_black_box_gate() helper collects the three cases that share this treatment (external, verbatim-box, opaque).
  • Never flushed. unroll() resets external_gates on every call. Opaque gates are stored on the module instead, because they are a property of the program and have no decomposition to fall back on. Parametrized test over None / [] / ["h"] / ["ZZ"].
  • Gated on the version. The rewrite only runs when the OPENQASM 2 header matches, so opaque in a qasm3 program keeps failing to parse.
  • Runs after include inlining, so an opaque inside a vendor include file is reached by both load() and loads(..., include_dir=...).

What works

The issue's bell state, with an hqslib1-shaped include, loads and unrolls:

qreg q[2]; creg c[2];
rz(3.141592653589793) q[0];
U1q(1.5707963267948966, 1.5707963267948966) q[0];
RZZ(1.5707963267948966) q[0], q[1];
measure q[0] -> c[0];

validate(), depth(), has_measurements(), num_qubits, remove_idle_qubits(), reverse_qubit_order() and unroll(consolidate_qubits=True) all handle opaque calls, and arity is validated (ZZ q[0];Qubit count mismatch for gate 'ZZ'. Expected 2 qubits, but got 1).

Better than the issue predicted: it also handles hqslib1's U and CX, which are defined in terms of the opaque primitives. The issue author had to drop those; they now unroll down to the primitives and stop there.

Three limits, all deliberate, all tested

Behaviour Why
The declaration is not re-emitted in the unrolled output Matches the documented precedent for external gates, whose gate definition unrolling already drops. Consequence: the output has calls to a gate it does not declare, so it does not load back into pyqasm on its own. A printer hook for re-emitting opaque is deliberately not built in v1.
rebase() raises RebaseError: Gate 'ZZ' is not supported in the '<basis>' set 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 — not a crash, and no change was needed.
to_qasm3() now raises for a program declaring one A body-less gate means the identity in OpenQASM 3. Emitting the carrier definition would silently turn every hardware primitive into a no-op. OpenQASM 3 removed opaque and has no equivalent, so there is no correct translation and refusing is the loud option. Nothing inside src/ calls to_qasm3, so this is confined to the public API, and these programs could not be loaded before this PR — no workflow regresses. Easily relaxed if someone proposes a representation.

All three are documented in a new Opaque gates section in src/README.md, alongside the existing Pragmas notes.

Verification

  • Full suite: 817 passed, 4 skipped. No existing expectation changed.
  • tox -e format-check: pylint 10.00/10, isort, black, mypy and headers all clean.
  • Mutation-tested. With the four source files reverted, 19 of 20 opaque tests fail. The one that passes is test_opaque_is_not_qasm3_syntax — correctly, since it pins behaviour this PR must not change.
  • The hqslib1-like fixture is written out in the test file rather than vendored, so nothing depends on pytket being installed.

Merge order

This is stacked on #377 (include_dir), because both touch loads() and preprocess.py. Merge #377 first.

The #375 conflict is already resolved. #375 (issues #367 and #369) has merged, and this branch is rebased on top of it. Both PRs rewrote the _recording_ext_gate_depth assignment at the top of _visit_custom_gate_operation; the resolution keeps #375's save/restore shape with this PR's helper:

prev_recording = self._recording_ext_gate_depth
is_external = self._is_black_box_gate(gate_name)      # <- not the inlined condition
self._recording_ext_gate_depth = prev_recording or is_external

Resolving it the other way — keeping self._in_verbatim_box or gate_name in self._external_gates — would make opaque gates record zero depth, because is_external also feeds the single depth-recording block below. test_opaque_gate_counts_as_one_towards_depth covers it.

Related

@argus-eye

argus-eye Bot commented Aug 18, 2026

Copy link
Copy Markdown

Argus review

Auto-review is off for this repo. Tick the box below to run a review on this PR.

  • Trigger Argus review

Estimated cost

  • Files changed: 8
  • Diff lines (±): 384
  • Historical avg: ~243.6k tokens · ~$0.95 · across last 10 review(s)

Tip: you can also comment @argus-eye review at any time.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 26dad6da-3de8-408b-a169-e1af3db13364

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@TheGupta2012
TheGupta2012 force-pushed the support-opaque-declarations branch from 9ce4a97 to 2d636ef Compare August 18, 2026 07:31
@TheGupta2012
TheGupta2012 force-pushed the support-opaque-declarations branch from 2d636ef to d831f3d Compare August 18, 2026 07:33
@TheGupta2012
TheGupta2012 force-pushed the support-opaque-declarations branch from d831f3d to a26ca6b Compare August 18, 2026 08:01
@TheGupta2012
TheGupta2012 force-pushed the support-opaque-declarations branch from cb49446 to 896ca19 Compare August 19, 2026 07:54
@TheGupta2012
TheGupta2012 force-pushed the support-opaque-declarations branch from 9cf45a3 to c895a7f Compare August 19, 2026 08:25
@TheGupta2012
TheGupta2012 force-pushed the support-opaque-declarations branch from c895a7f to a9676b2 Compare August 19, 2026 08:27
@TheGupta2012
TheGupta2012 requested a review from ryanhill1 August 19, 2026 09:08
ryanhill1
ryanhill1 previously approved these changes Aug 19, 2026
TheGupta2012 and others added 4 commits August 19, 2026 20:05
Any program containing an opaque declaration failed to parse, so no
compiled program from Quantinuum H-series hardware could be loaded: the
primitives in hqslib1.inc are all declared opaque.

opaque is OpenQASM 2 syntax that OpenQASM 3 removed, and pyqasm routes
qasm2 through the openqasm3 parser, which has no grammar production for it.
The failure therefore happens before any visitor code runs, and a fix in
the visitor cannot reach it. Rewrite the declaration in source
preprocessing instead, gated on the OPENQASM 2 header so that opaque in a
qasm3 program keeps failing to parse.

An opaque gate has no decomposition by definition, so it is carried as a
gate definition with an empty body -- a marker for its name and arity --
and its name is recorded on the module. A call to it is routed to the
external-gate path and emitted as written, counting as one layer of depth.
The names live on the module rather than in unroll()'s kwargs, because an
opaque gate is a property of the program and has no decomposition for
unroll() to fall back on when it flushes external_gates.

to_qasm3() now raises for such a program. A body-less gate means the
identity in OpenQASM 3, so converting one would silently turn a hardware
primitive into a no-op.

Fixes #370

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rewrite ran over raw source, so 'opaque foo q;' inside a /* */ block
comment was recorded in _opaque_gates. A real gate of that name would then
be emitted as written instead of unrolled.

Blank comments to spaces before matching, preserving length so the match
spans still index the original text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… back

The blanked copy was only used to locate matches, which meant carrying
offsets and splicing each rewrite into the original. Its one consumer is
openqasm3.parse on the next line, and the parser discards comments, so the
blanked text can be returned directly and the splice loop dropped.

Blanking rather than deleting still matters: the parser reports spans
against this text, so removing a comment would shift every line and column
after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the hand-written character scanner. Differential-tested against it
over 20k inputs, including hqslib1.inc and adversarial cases -- unterminated
block comments, '//' beating '/*', nested-looking markers -- with no
mismatches.

Also fixes a case the scanner shared and that mattered once the blanked text
started going to the parser: a '//' or '/*' inside an include path was
blanked, truncating the statement. Matching string literals first consumes
them before either comment alternative can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TheGupta2012
TheGupta2012 disabled the stack merge August 20, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

opaque declarations fail to parse, blocking Quantinuum hqslib1 and any qasm2 file using them

3 participants