fix: reject barrier as an OpenQASM 2 conditional body - #339
Conversation
The QASM 2 grammar admits only a <qop> as the body of an `if`:
<if> := if ( <id> == <nninteger> ) <qop>
<qop> := <uop> | measure ... | reset ...
`barrier` is a separate production, so `if(m==1) barrier q;` is not a
valid QASM 2 program. pyqasm accepts it and emits it unchanged, which
downstream QASM 2 parsers reject.
Tests assert the rejection, alongside cases pinning down what must keep
validating: every operation that *is* a <qop> in a conditional body, and
an unconditional barrier.
`Qasm2Module._filter_statements` checked only the type of each top-level statement, so nothing inspected what a `BranchingStatement` carried in its body. A conditional barrier passed validation and was serialized verbatim as `if (m == 1) barrier q[0], q[1];`, which QASM 2 parsers reject — barrier is not a <qop>, and only a <qop> may follow an `if`. Filtering now recurses into conditional bodies (both blocks, at any depth) and raises a ValidationError naming the offending statement.
Argus reviewAuto-review is off for this repo. Tick the box below to run a review on this PR. Running Argus review... Estimated cost
Tip: you can also comment |
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
🔎 Argus · 9/10 — Cleanly rejects barrier in OpenQASM 2 conditionals with solid nested 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: Reject barrier as an OpenQASM 2 conditional body, since the grammar allows only a qop (gate, measure, or reset) there.
Not in scope:
- Independent of #338 (printing/serialization of conditionals)
Stated acceptance criteria (from PR/issue — not independently verified): - Recurse into conditional if_block and else_block at any nesting depth and reject barrier there
- Raise ValidationError via raise_qasm3_error with source span for conditional barrier
- Accept conditional qops: gate, two-qubit gate, reset, measure
- Accept unconditional barriers outside conditionals
- Tests: test_conditional_barrier_rejected, test_conditional_barrier_rejected_when_nested, test_conditional_qop_accepted, test_unconditional_barrier_accepted
✅ Intent delivered
Verdict: This PR correctly enforces the qop-only grammar for OpenQASM 2 conditional bodies, including nested if/else, with clear ValidationError spans and good acceptance tests. Ready to merge; the two warnings are minor polish.
🟡 2 P1 · 3 files reviewed
Architecture: Validation is scoped tightly to conditional bodies without disturbing unconditional barriers or legitimate qops, which keeps the change easy to reason about.
2 findings · 2 inline · 0 folded
🔢 186.1k tokens · $1.2897 total
| Stage | Tokens | Cost |
|---|---|---|
| Intent | 4.1k | $0.0126 |
| Triage | 3.3k | $0.0087 |
| Lead agent | 1.8k | $0.0063 |
| Review · bug_hunter | 31.2k | $0.1673 |
| Review · security | 32.8k | $0.1885 |
| Review · architecture | 31.7k | $0.2841 |
| Review · regression | 58.9k | $0.4644 |
| Review | 18.7k | $0.1467 |
| Scoring | 2.4k | $0.0072 |
| Synthesis | 1.4k | $0.0039 |
Contract: production/full · checked: bug_hunter, security, architecture, regression · review took 14m4s
Dashboard → · React 👎 to dismiss · Reply to any inline comment or use @argus-eye help to chat
Review feedback on #339. `test_conditional_barrier_rejected_when_nested` claimed to exercise the recursive descent but its program had no nesting -- it duplicated the `barrier q;` parametrized case, so dropping the recursion left every test green. It now nests (`if(m==1) if(m==0) barrier q;`), verified to fail when the recursion is removed. Adds failing tests for the wider hole behind that one: filtering blacklists `barrier` alone, so any other non-qop body the parser accepts validates cleanly and is emitted as invalid QASM 2. `delay` and `box` bodies both do this today -- neither has any QASM 2 syntax, and `delay` is even rejected at the top level by the existing whitelist.
Review feedback on #339. Filtering blacklisted `barrier` alone, so every other non-qop body the parser accepts still validated and was emitted verbatim. `delay` and `box` bodies both did this -- neither has any QASM 2 syntax, and `delay` is already rejected by the top-level whitelist, so a conditional was the one place it could slip through. Conditional bodies are now checked against the <qop> production itself (QuantumGate, QuantumMeasurementStatement, QuantumReset), with branching statements recursed into as before. Barrier keeps its specific message; anything else is named by node type.
|
@TheGupta2012 flagging this one for a deliberate look before merge — it is a breaking behaviour change, and the scope grew during review, so I would rather you sign off on it than have it slip through as a routine bug fix. What breaksPrograms that validate today will start raising if(m==1) barrier q; // was: emitted as-is
if(m==1) delay[10ns] q; // was: emitted as-is
if(m==1) box { x q[0]; } // was: emitted as-isWhy I think it is rightNone of these are expressible in OpenQASM 2. The grammar admits only a
The program was already broken — this only moves the failure from a downstream parser's syntax error to a validation error with a source span and the offending statement named. The scope growth you should be aware ofThe PR originally rejected If you would rather not break usersTwo smaller options, happy to switch to either:
My recommendation is to keep it as-is: silently emitting invalid QASM 2 is the worst of the three, since the failure surfaces far from its cause. But this is a maintainer call, not mine. The sibling PR #338 (the #337 serializer fix) has no such concern — it only changes how conditionals are printed, and every program that validated before still validates. |
There was a problem hiding this comment.
Approved with comments — tightly scoped, correctly placed, well tested. Ready to merge; the one Medium is a pre-existing gap this PR brings into focus, not a defect in the diff.
Findings
All findings are posted inline on the relevant lines. Checklist:
- M1 —
QuantumPhasereaches conditional bodies viarzz/rxx; whitelist does not cover it - L1 —
_qop_statementsis instance state for a constant - L2 — Non-
barriercases report an AST class name, not a QASM keyword
How it was tested
- Rejection stage. Instrumented:
loads = OK, thenRAISE ValidationErroratvalidate(), before any unrolling —accept()calls_filter_statements()beforevisit_basic_block. - Coverage. Broadcast
barrier q;, singlebarrier q[0];, a barrier reached only through a nestedif, and a barrier in anelseblock all raise with the located message. - Over-rejection guard. Single- and two-qubit gate bodies,
reset,measureinto another register, and gates with multi-statement decompositions (crz→ 12 statements,u3→ 5 rotations) all still validate, unroll and serialize. Top-level barriers untouched. - Whitelist sweep. ~45 qelib1/pyqasm gate shapes as conditional bodies, inspecting unrolled
if_blocknode kinds — every gate butrzz/rxxproduced onlyQuantumGate. - Reachable public-API sequences diffed against
mainto separate the M1 regression from pre-existing failures. - Suite on branch: 647 passed, 4 skipped (excluding
tests/cli, which fails identically onmainin this environment). Branch 1 commit behindmain; merges cleanly with #335/#338/#341/#344/#345 (CHANGELOG only), combined 697 passed.
Next steps
Merge when ready — none of the three blocks. File the follow-up requested in M1 and reference it here; L1 and L2 are small and worth folding in now while the file is open. L2 is attached as an applicable suggestion.
Land this before #338. #338's printer emits QASM 2 output for exactly the construct this PR rejects, so merging in that order means the invalid case never reaches the new printer.
The failing-test-first commit ordering (ddb79e4 → 3ca979d) and the follow-up that made the nested test actually nest are both good discipline. test_conditional_qop_accepted and test_unconditional_barrier_accepted passing before the fix is what makes them valuable — they pin the boundary the fix must not cross.
| self._filter_branch_body(stmt) | ||
| # TODO: add more filtering here if needed | ||
|
|
||
| def _filter_branch_body(self, statement: qasm3_ast.BranchingStatement): |
There was a problem hiding this comment.
[M1] QuantumPhase can reach a conditional body — this whitelist does not cover it — Implementation · Medium (pre-existing gap; not a blocker)
Rationale: the whitelist is the right shape, but the <qop> set is not quite the set of things that actually arrive here. Two legal qelib1 gates decompose to a body containing a QuantumPhase node:
if(m==1) rzz(0.3) q[0],q[1];
if(m==1) rxx(0.3) q[0],q[1];
Sweeping ~45 qelib1/pyqasm gate shapes as conditional bodies and inspecting the unrolled if_block node kinds: every other gate produced only QuantumGate, but these two produce ['QuantumPhase', 'QuantumGate', ...]. Two consequences on this branch:
1. The output-level hole this PR closes for barrier is still open here. After unroll(), dumps() emits:
if (m[0] == true) {
gphase(-0.15) q[0], q[1];
cx q[0], q[1];
...
}
gphase has no QASM 2 syntax at all — the same accept-then-emit-invalid pattern this PR is fixing, arriving from the unroller rather than from the source program. (The m[0] == true half of that line is #338's territory.)
2. A narrow regression. _filter_statements runs over self._statements, and remove_idle_qubits() / reverse_qubit_order() both reassign _statements = _unrolled_ast.statements. Since unroll() has no re-entry guard, a second pass filters the unrolled body and raises:
ValidationError: statement of type QuantumPhase is not supported as the body of an 'if'
in QASM 2.0, which allows only a gate, measurement or reset
naming an AST class for a program the user wrote as rzz. Diffing the reachable public-API sequences against main: reverse_qubit_order() then remove_idle_qubits() passes on main and raises here. The other sequences (remove_idle_qubits() then unroll(), etc.) already fail on main for an unrelated pre-existing reason (Index 1 out of range for register of size 1), and remove_idle_qubits() then dumps() is fine on both.
Attribution: the underlying gap is pre-existing, not introduced here. QuantumPhase is absent from _whitelist_statements too, so unconditional rzz(0.3) q[0],q[1]; followed by remove_idle_qubits() + unroll() already raises Statement of type <class 'openqasm3.ast.QuantumPhase'> not supported in QASM 2.0 on main. This PR extends the same gap into conditional bodies, which is consistent — it just widens where it surfaces by one construct.
Change requested: not a blocker, and not worth growing the diff for. Please file a follow-up for QASM 2 global-phase handling — either the unroller should not emit gphase for a Qasm2Module, or _qasm_ast_to_str should fold/drop it — and reference it here. As a cheap in-PR mitigation, giving QuantumPhase its own message (global phase is not representable in QASM 2, rather than statement of type QuantumPhase) would keep the user-facing error honest in the meantime.
There was a problem hiding this comment.
Follow-up filed as #351, referenced here and in the code. Kept the diff out of the underlying gap as you asked.
Reproduced first: if(m==1) rzz(0.3) q[0],q[1]; unrolls to ['QuantumPhase', 'QuantumGate', ...] and emits gphase(-0.15) q[0], q[1];, and reverse_qubit_order() then remove_idle_qubits() raises here where it passes on main. #351 covers both routes to a fix (unroller not emitting gphase for a Qasm2Module, or _qasm_ast_to_str folding it) and notes the _whitelist_statements half.
Took the cheap in-PR mitigation you suggested — QuantumPhase now gets its own message:
Global phase is not representable in QASM 2.0, so it cannot appear in a conditional body; it is introduced by unrolling gates such as 'rzz' and 'rxx'
test_conditional_global_phase_reports_global_phase pins it via the reverse_qubit_order() + remove_idle_qubits() sequence you found.
| } | ||
| # the QASM 2.0 <qop> production: a gate application, a measurement or a reset. | ||
| # only these may be the body of an 'if'. | ||
| self._qop_statements = ( |
There was a problem hiding this comment.
[L1] Instance state for a constant — Maintenance · Low
Rationale: this tuple is rebuilt in __init__ on every module construction but never varies per instance. It is a fixed grammar production, and reads more clearly as one.
Change requested: hoist to a module-level _QOP_STATEMENTS tuple, alongside where the existing whitelist lives. Same for _whitelist_statements if worthwhile, though that is outside this PR's scope. Left as prose rather than a suggestion because the change spans two locations — the removal here and the new constant at module scope — so it is not applicable as a single patch.
There was a problem hiding this comment.
Resolved in ae8fedc — hoisted to a module-level _QOP_STATEMENTS tuple with the grammar comment attached to it.
Left _whitelist_statements where it is, per your note that it is outside this PR's scope.
| name = "barrier" if isinstance(inner_stmt, qasm3_ast.QuantumBarrier) else None | ||
| described = f"'{name}'" if name else f"statement of type {type(inner_stmt).__name__}" |
There was a problem hiding this comment.
[L2] Non-barrier cases report an AST class name — Implementation · Low
Rationale: barrier gets a friendly name; everything else falls back to statement of type {type(inner_stmt).__name__}, so a delay body reports statement of type DelayInstruction and a box reports statement of type Box. Since delay and box are called out in the description as the wider hole behind the barrier case, they are worth naming properly — the user wrote a QASM keyword, not an AST class.
Change requested:
| name = "barrier" if isinstance(inner_stmt, qasm3_ast.QuantumBarrier) else None | |
| described = f"'{name}'" if name else f"statement of type {type(inner_stmt).__name__}" | |
| keywords = { | |
| qasm3_ast.QuantumBarrier: "barrier", | |
| qasm3_ast.DelayInstruction: "delay", | |
| qasm3_ast.Box: "box", | |
| } | |
| name = keywords.get(type(inner_stmt)) | |
| described = f"'{name}'" if name else f"statement of type {type(inner_stmt).__name__}" |
If L1 is taken too, the mapping reads better hoisted to module scope next to _QOP_STATEMENTS rather than rebuilt per loop iteration.
There was a problem hiding this comment.
Resolved in ae8fedc — suggestion applied, hoisted to module scope as _NON_QOP_KEYWORDS alongside _QOP_STATEMENTS since L1 was taken, rather than rebuilt per loop iteration.
test_conditional_non_qop_rejected now asserts the keyword rather than just body of an 'if', so delay and box are pinned by name.
# Conflicts: # CHANGELOG.md
- _qop_statements becomes a module-level _QOP_STATEMENTS; it is a fixed grammar production, not per-instance state (L1) - non-qop bodies report the keyword the user wrote via _NON_QOP_KEYWORDS, so a delay body says 'delay' rather than 'statement of type DelayInstruction' (L2) - QuantumPhase gets its own message naming global phase and the rzz/rxx decomposition that introduces it, instead of an AST class name; the underlying gphase gap is tracked in #351 (M1 mitigation)
Summary of changes
Found while fixing #337 (see #338). Independent of that PR — this one is about what gets accepted, not how it is printed.
The OpenQASM 2 grammar admits only a
<qop>as the body of anif:barrieris a separate production, soif(m==1) barrier q;is not a valid QASM 2 program — anddelay/boxhave no QASM 2 syntax at all, yet both are accepted in a conditional body too (delayis even rejected by the existing top-level whitelist, so a conditional was the one place it slipped through).Qasm2Module._filter_statementschecked only the type of each top-level statement, so nothing ever inspected what aBranchingStatementcarried in its body — the conditional barrier validated cleanly and was serialized verbatim:which downstream parsers reject:
So the invalid construct entered unflagged and left as invalid output.
Fix
_filter_statementsnow recurses into conditional bodies — bothif_blockandelse_block, at any nesting depth — and checks each statement against the<qop>production itself (QuantumGate,QuantumMeasurementStatement,QuantumReset) rather than blacklistingbarrieralone, which would let through whatever kinds it had not enumerated:reported through
raise_qasm3_errorso the message carries the source span and the offending statement:Statements are named by the keyword the user wrote —
barrier,delay,box— rather than by the AST class they parsed into.This is a behavior change: programs that previously validated now raise. That is the point — they were being turned into invalid QASM 2, and failing at validation with a located error beats failing in a downstream parser with a syntax error.
Global phase
rzz/rxxdecompose to a body containing aQuantumPhase, which QASM 2 has no syntax for at all. That gap is pre-existing and tracked in #351 — the unroller emitsgphase(...)for aQasm2Modulewhether or not a conditional is involved, so fixing it belongs there, not here. What this PR does add is an honest message for it: theQuantumPhasecase names global phase and therzz/rxxdecomposition that introduces it, rather than reportingstatement of type QuantumPhasefor a program the user wrote asrzz.Tests
Written first and committed failing (ddb79e4), then made to pass by the fix (3ca979d).
test_conditional_barrier_rejected— parametrized over broadcast, single-qubit, and multi-qubit barrier forms.test_conditional_barrier_rejected_when_nested— a barrier reached only through a nested conditional, verified to fail when the recursion is removed.test_conditional_non_qop_rejected—delayandboxbodies, the wider hole behind the barrier case, asserting each is named by its keyword.test_conditional_global_phase_reports_global_phase— therzzcase from gphase emitted for QASM 2 modules: rzz/rxx unroll to a global phase QASM 2 cannot express #351 reports global phase rather than an AST class name.test_conditional_qop_accepted— every operation that is a<qop>(gate, two-qubit gate,reset,measure) still validates, guarding against over-rejection.test_unconditional_barrier_accepted— barriers outside conditionals are untouched.The last two passed before the fix as well, which is what makes them useful: they pin the boundary the fix must not cross.
Merge order
Land this before #338. #338's printer emits QASM 2 output for exactly the construct this PR rejects, so merging in that order means the invalid case never reaches the new printer. Both PRs add conditional checks to
_filter_statements, so expect a small conflict there.