Skip to content

fix: emit OpenQASM 2 syntax for classical conditionals - #338

Open
ryanhill1 wants to merge 4 commits into
mainfrom
fix-qasm2-branching-syntax
Open

fix: emit OpenQASM 2 syntax for classical conditionals#338
ryanhill1 wants to merge 4 commits into
mainfrom
fix-qasm2-branching-syntax

Conversation

@ryanhill1

@ryanhill1 ryanhill1 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary of changes

Closes #337.

Qasm2Module._qasm_ast_to_str serialized with the stock openqasm3 printer and then patched the result with regexes that only cover declarations (qubit[n] qqreg q[n]). Branching statements went through the OpenQASM 3 printer untouched, so a valid QASM 2 conditional round-tripped as a braced block under an OPENQASM 2.0; header:

if(m==1) x q[1];   ->   if (m == 1) {
                          x q[1];
                        }

Confirmed rejected by a real QASM 2 parser:

qiskit.qasm2.QASM2ParseError: '<input>:7,12: needed a gate application, measurement or reset, but instead saw {'

The unrolled path was worse

The issue's repro does not call unroll(). Once it does, unroll() ravels a register comparison into a nested chain of per-bit tests:

if(m==2) x q[2];   ->   if (m[0] == true) {
                          if (m[1] == false) {
                            x q[2];
                          }
                        }

That is invalid QASM 2 three times over — braces, creg indexing in the condition, and a true literal. Since the reported impact path is the qBraid SDK normalization pass, which unrolls, fixing only the braces would have left it broken.

Fix

Qasm2Printer, an openqasm3.printer.Printer subclass overriding visit_BranchingStatement to emit QASM 2's if (creg == int) <statement>:

  • Braces → per-statement guards. A body that unrolled into N statements becomes N guarded lines, the only shape QASM 2 has.
  • Condition collapse. The nested per-bit chain is walked back into the whole-register comparison it came from, inverting the unroller's MSB-first bit ordering, so if(m==2) round-trips as if (m == 2).
  • Errors instead of corruption where QASM 2 genuinely cannot express the branch: else blocks, nested ifs, branches spanning registers, partial-register conditions (reachable through >=/<= unrolling), non-equality operators, and a bit compared against a non-bit value.

The chain walk also has to reject constraints that cannot coexist in the single comparison QASM 2 allows. Without that, three shapes collapsed to whichever constraint was walked last and emitted a valid-looking program triggering on different values than the source — if(m==1) if(m==0) x q[0]; became if (m == 0) x q[0];, dropping the outer condition outright. Caught in review; see 65984ac and 14858df.

One semantic hazard the per-statement expansion introduces is guarded rather than shipped: guarding each statement re-tests the register before every one, which is only faithful while the body leaves that register alone. if(m==1) measure q -> m; would silently change meaning, so a multi-statement body assigning to the register under test raises a ValidationError explaining why.

Tests

Written first and committed failing (278f394), then made to pass by the fix (5b27fb1).

  • test_branch_emits_qasm2_syntax — the issue repro, checked against the full expected program.
  • test_branch_body_expands_to_one_conditional_per_statement — a broadcast body becomes one guarded statement per qubit.
  • test_multibit_branch_survives_unrolling — parametrized over every value of a 2-bit register, asserting the per-bit chain collapses back to the original comparison.
  • test_branch_body_statement_types — gate, reset, and measure bodies each emit brace-free.
  • test_branch_body_writing_tested_register_raises — the semantic hazard above is rejected.
  • test_conflicting_chain_constraints_raise — the three shapes that previously collapsed to the wrong condition.
  • test_inexpressible_branches_raise — one case per rejection path: else, cross-register nesting, partial-register conditions, non-equality operators.

Every emitted program in the cases above was additionally verified to parse with qiskit.qasm2.loads.

Related bug, not fixed here

if(m==1) barrier q; is accepted on input but is not valid QASM 2 — barrier is not a <qop>, so it cannot be a conditional body. That is an input-validation gap rather than a serializer gap, so it is fixed separately in #339.

Failing tests for the qasm2 serializer emitting OpenQASM 3 syntax for
classical conditionals:

- braced if-blocks instead of `if (creg == int) <statement>`;
- after `unroll()`, the per-bit chain the unroller ravels a register
  comparison into (`if (m[0] == true) { if (m[1] == false) { ... } }`),
  which additionally uses creg indexing and a `true` literal that
  OpenQASM 2 has no syntax for;
- a body writing to the register under test, which cannot be expanded
  into one guarded statement per body statement without re-evaluating
  the condition mid-body.
`Qasm2Module._qasm_ast_to_str` serialized with the stock openqasm3 printer
and patched the result with regexes covering only declarations, so
branching statements were emitted verbatim in OpenQASM 3 form under an
`OPENQASM 2.0;` header. Downstream QASM 2 parsers reject the result.

Adds `Qasm2Printer`, an `openqasm3.printer.Printer` subclass overriding
`visit_BranchingStatement` to emit `if (creg == int) <statement>`:

- a body that unrolled into several statements becomes one guarded
  statement each, QASM 2 having no braced blocks;
- the nested per-bit chain `unroll()` ravels a register comparison into
  is walked back into that comparison, inverting the unroller's
  MSB-first bit ordering so `if(m==2)` round-trips as `if (m == 2)`;
- branches QASM 2 cannot express — `else` blocks, nested `if`s, branches
  spanning registers, partial-register conditions (reachable via `>=`
  and `<=` unrolling) — raise a `ValidationError`.

A multi-statement body is guarded per statement, which re-tests the
register before each one. That is only faithful while the body leaves
the register alone, so a body assigning to the register under test is
rejected rather than silently given new semantics.

Output verified against qiskit's OpenQASM 2 parser.
@ryanhill1
ryanhill1 requested a review from TheGupta2012 as a code owner July 31, 2026 17:47
@argus-eye

argus-eye Bot commented Jul 31, 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.

Running Argus review...

Estimated cost

  • Files changed: 3
  • Diff lines (±): 333

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

@coderabbitai

coderabbitai Bot commented Jul 31, 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: f7042c1e-60ed-4ac9-97c2-336c5ea564d0

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

codecov-commenter commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/pyqasm/modules/qasm2.py 91.66% 8 Missing ⚠️

📢 Thoughts on this report? Let us know!

@argus-eye

This comment has been minimized.

@argus-eye argus-eye Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔎 Argus · 9/10 — Cleanly ships OpenQASM 2 classical conditional syntax with solid coverage

🔍 PR intent vs diff (LLM analysis)

Argus read the diff against the stated intent. This is not an execution log — reviewer still needs to test behavior.

Goal: Emit valid OpenQASM 2 classical conditional syntax (if (creg == int) ) instead of OpenQASM 3 braced if-blocks.
Not in scope:

  • Fix if(m==1) barrier q; input-validation gap (handled separately in #339)
    Stated acceptance criteria (from PR/issue — not independently verified):
  • Branching emits brace-free if (creg == int) QASM 2 syntax
  • Unrolled multibit nested per-bit chains collapse back to whole-register comparison
  • Multi-statement bodies expand to one guarded conditional per statement
  • else blocks, nested ifs, cross-register and partial-register conditions raise errors
  • Multi-statement body writing the tested register raises ValidationError
  • Emitted programs parse with qiskit.qasm2.loads
  • Tests cover issue repro, body expansion, multibit unroll collapse, statement types, and write hazard

✅ Intent delivered

Verdict: This PR correctly emits brace-free if (creg == int) <statement> QASM 2 syntax, collapses multibit unrolls, expands multi-statement bodies, and guards the stated error cases. Ready to merge; the two warnings are minor polish.

🟡 2 P1 · 3 files reviewed

Architecture: Serialization and validation paths stay well separated; the write-hazard check and collapse logic keep semantic risk contained without overreaching scope.


Simulation Results

Tested 1 scenarios, 1 potential issues found:

Scenario: src/pyqasm/modules/qasm2.py: Conditional-body filtering blacklists only barrier instead of whitelisting the three statement kinds the QASM 2 grammar allows as an if body
Verdict: Broken (82% sure)
Why: If-body filter only bans barrier instead of allowing gate, measure, reset.
Fix: Whitelist only QuantumGate, Measure, and Reset as QASM2 if bodies.

Minor notes (1)

Low-confidence or minor observations — safe to ignore.

  • tests/qasm2/test_branching.py:L50 [suggestion] Could we add one skip-guarded test that feeds the emitted program to qiskit.qasm2.loads, so the dialect contract is validated by a real QASM 2 parser and not only by string comparison?

2 findings · 2 inline · 0 folded

🔢 120.7k tokens · $0.9119 total
Stage Tokens Cost
Intent 4.7k $0.0133
Triage 3.7k $0.0087
Lead agent 1.9k $0.0070
Review · security 63.7k $0.5181
Review 31.1k $0.3137
Acceptance 2.3k $0.0087
Simulation 9.6k $0.0322
Scoring 2.4k $0.0066
Synthesis 1.3k $0.0036

Contract: production/full · checked: bug_hunter, security, architecture, regression · review took 17m40s

Dashboard → · React 👎 to dismiss · Reply to any inline comment or use @argus-eye help to chat

Comment thread src/pyqasm/modules/qasm2.py Outdated
Comment thread tests/qasm2/test_branching.py
Review feedback on #338.

The chain walk in _flatten_branch collapses nested branches into one
comparison, but nothing checks that the constraints it collects can
coexist. Three shapes are silently reduced to whichever constraint is
walked last, emitting a valid-looking QASM 2 program that triggers on
different values than the source:

    if(m==1) if(m==0) x q[0];        -> if (m == 0) x q[0];
    if(m[0]==1) if(m[0]==0) x q[0];  -> if (m == 0) x q[0];
    if(m[0]==2) x q[0];              -> if (m == 1) x q[0];

The first drops the outer condition entirely, so the PR's claim that
nested ifs raise is false for a chain of them.

Also adds coverage for the four rejection paths the PR does introduce
but never exercised -- else blocks, cross-register nesting,
partial-register conditions and non-equality operators -- which passed
already and now stay pinned.
Review feedback on #338.

The chain walk collected each nested branch's constraint but never
checked they could coexist, so three shapes were silently reduced to
whichever constraint happened to be walked last and emitted as a
valid-looking QASM 2 program triggering on different values:

    if(m==1) if(m==0) x q[0];        -> if (m == 0) x q[0];
    if(m[0]==1) if(m[0]==0) x q[0];  -> if (m == 0) x q[0];
    if(m[0]==2) x q[0];              -> if (m == 1) x q[0];

The first dropped the outer condition outright, so the guard that was
supposed to reject nested ifs never fired for a chain of them -- the
walk consumed the nesting before the check could see it.

Each is now rejected where the constraint is folded in. The legitimate
case the collapse exists for, a per-bit chain from unrolling, is
unaffected: those carry distinct bit indices and boolean values.

Constraint folding is extracted to _add_chain_constraint to keep
_flatten_branch within the branch limit.
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.

qasm2 serializer emits OpenQASM 3-style braced if-blocks (invalid QASM 2)

2 participants