Skip to content

fix: reject mixed declared/physical programs under consolidate_qubits - #362

Open
ryanhill1 wants to merge 4 commits into
mainfrom
fix-consolidate-mixed-physical
Open

fix: reject mixed declared/physical programs under consolidate_qubits#362
ryanhill1 wants to merge 4 commits into
mainfrom
fix-consolidate-mixed-physical

Conversation

@ryanhill1

@ryanhill1 ryanhill1 commented Aug 7, 2026

Copy link
Copy Markdown
Member

Fixes #353

Settles the semantics question from the issue as option 2: a program mixing declared registers with physical qubits under unroll(consolidate_qubits=True) raises a ValidationError naming the physical qubits, rather than emitting a consolidated register plus as-written $n references — two address spaces the output never relates. This preserves #344's "physical qubits are left as written" rule for pure-physical programs while refusing to emit an ambiguous program.

Falling out of the same check: a program using only physical qubits no longer receives a __PYQASM_QUBITS__ declaration nothing references (previously sized by device_qubits or the highest physical index).

The two #344 tests that pinned the old counts (test_physical_qubits_are_not_consolidated, test_physical_qubits_only) are deliberately edited, as the issue anticipated.

Also in this PR:

  • Updates the changelog to document the consolidated-qubit validation behavior.
Consolidated Qubit Validation Flow
sequenceDiagram
  participant Test as tests/qasm3/test_device_qubits.py
  participant Visitor as src/pyqasm/visitor.py ⚠️
  Test->>Visitor: unroll(consolidate_qubits=True)
  Visitor->>Visitor: inspect declared and physical qubits
  Visitor-->>Test: raise ValidationError or emit unrolled program
Loading

Auto-enriched by Argus

A program mixing declared registers with physical qubits consolidated
into two address spaces the output could not relate: a virtual
__PYQASM_QUBITS__ register plus absolute $n references, with num_qubits
conflating the two. Such programs now raise a ValidationError naming
the physical qubits. A program using only physical qubits no longer
receives an internal register declaration nothing references.

Fixes #353
@ryanhill1
ryanhill1 requested a review from TheGupta2012 as a code owner August 7, 2026 13:25
@argus-eye

argus-eye Bot commented Aug 7, 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: 3
  • Diff lines (±): 81
  • Historical avg: ~318.9k tokens · ~$1.35 · across last 6 review(s)

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

@coderabbitai

coderabbitai Bot commented Aug 7, 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: 55875e8c-d40b-4572-877f-fc6595e0f93c

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!

@ryanhill1

Copy link
Copy Markdown
Member Author

@Argus-Eye review

1 similar comment
@ryanhill1

Copy link
Copy Markdown
Member Author

@Argus-Eye review

@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 — Handles mixed qubit address spaces correctly, with one validation edge case to tighten

🔍 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 mixed declared-register and physical-qubit programs under consolidate_qubits=True and suppress unused consolidated declarations for physical-only programs.
Stated acceptance criteria (from PR/issue — not independently verified):

  • A program mixing declared registers with physical qubits under unroll(consolidate_qubits=True) raises a ValidationError naming the physical qubits.
  • Pure-physical programs preserve physical qubits as written and do not receive an unreferenced PYQASM_QUBITS declaration.
  • Update test_physical_qubits_are_not_consolidated and test_physical_qubits_only to reflect the new semantics.

✅ Intent delivered

Verdict: This implements the requested rejection and physical-only declaration behavior cleanly. It is close to merge-ready; addressing the remaining validation warning would make the semantics more robust.

🟡 1 P1 · 💡 1 P2 · 3 files reviewed

Architecture: The validation and allocation behavior is well scoped in the visitor, while tests clearly document the distinct declared-register and physical-qubit paths.


Simulation Results

Tested 2 scenarios, 2 potential issues found:

Scenario: src/pyqasm/visitor.py: Custom gates double-count branch operations after expansion
Verdict: Broken (93% sure)
Why: This change does not address custom gate expansion counting operations inside branches twice.
Fix: Update custom gate expansion to visit each branch body once when collecting or emitting operations.

Scenario: src/pyqasm/visitor.py: A trailing pragma inside a box leaks verbatim state into the enclosing scope
Verdict: Broken (97% sure)
Why: The change only alters qubit consolidation and does not restore scope after a pragma inside a box.
Fix: Save and restore verbatim pragma state when entering and leaving box scopes.

2 findings · 2 inline · 0 folded

🔢 186.1k tokens · $0.6403 total
Stage Tokens Cost
Intent 3.2k $0.0000
Triage 2.5k $0.0000
Lead agent 1.3k $0.0000
Review · bug_hunter 41.6k $0.1604
Review · security 41.2k $0.1418
Review · architecture 41.4k $0.1676
Review · regression 42.2k $0.1705
Acceptance 994 $0.0000
Simulation 9.5k $0.0000
Scoring 1.2k $0.0000
Synthesis 962 $0.0000

Contract: production/full · checked: bug_hunter, security, architecture, regression · review took 1m26s

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

Comment thread src/pyqasm/visitor.py Outdated
Comment on lines +357 to +359

def test_mixed_declared_and_physical_still_unrolls_without_consolidation():
"""The mixed-program rejection applies only under consolidate_qubits=True."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 P2 (5/10) · Testing: The regression test does not verify that the ValidationError names every physical qubit

Users could receive incomplete diagnostics for mixed programs while CI still reports the acceptance criterion as satisfied.

Suggested change
def test_mixed_declared_and_physical_still_unrolls_without_consolidation():
"""The mixed-program rejection applies only under consolidate_qubits=True."""
with pytest.raises(ValidationError) as err:
result.unroll(consolidate_qubits=True)
message = str(err.value)
assert "mixes declared registers with physical qubits" in message
assert "$2" in message

A zero-sized declared register still declares a second address space, so
qubit[0] q; h $1; now raises under consolidate_qubits=True. Also assert
the error names the physical qubit (Argus P2).

@TheGupta2012 TheGupta2012 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: approve with minor comments

Settling #353 as option 2 is a defensible call, and the implementation is placed correctly: the check sits in _qubit_register_consolidation after the device_qubits capacity check, so the existing "total qubits exceed device qubits" error still wins when both apply. The scan over _qubit_depths turns out to be complete — every path that can put a physical qubit into a program registers it. No blocking issues. Four comments below: one Medium about a validation that the early return skips (and which the docstring in this PR still promises), three Low.

Findings

  • Medium — the return unrolled_stmts on the pure-physical path skips the reserved-name check, contradicting the docstring edited in this same PR. int __PYQASM_QUBITS__ = 3; h $1; raises Variable '__PYQASM_QUBITS__' is already defined on origin/main and succeeds silently here. Hoisting the loop above the new block also restores the precise message for the qreg shape.
  • Lowsorted() on $n strings orders $10 before $2 in the error message.
  • Low — the error names the offending qubits but does not say what to do next, and no source span is available at finalize time, so the message text is all the user gets.
  • Lowtest_mixed_declared_and_physical_still_unrolls_without_consolidation asserts only num_qubits; the deleted test pinned the emitted QASM, so nothing now pins #344's "left as written" rule for a mixed program.

How it was tested

Behaviour was compared between a worktree at c18b624 and one at origin/main (7c31308), running every claim rather than reading for it.

Coverage of the physical-qubit scan (complete). A physical qubit reaching the program only through measure, reset, barrier, an if body, a box body, or a custom gate call site all land in _qubit_depths_get_op_bits registers $n for every operand shape. All six now raise; all six emitted the two-address-space output on base.

No false positive on the pure-physical path. _global_qreg_size_map holds only user-declared registers at that point, so h $1; cz $2, $1; with consolidate_qubits=True returns cleanly, num_qubits == 3, no __PYQASM_QUBITS__ — with and without device_qubits.

device_qubits ordering is right. h $9; under device_qubits=3 still raises Total qubits '(10)' exceed device qubits '(3)'. — the capacity check fires before the new block, for physical-only and mixed programs alike.

"Presence, not capacity" holds. qubit[0] q; does create a _global_qreg_size_map entry, so the truthiness test is correct. (qreg q[0]; is unreachable — the QASM 2 parser rejects a zero-size qreg outright, so the qasm3 spelling is the only one that can exercise this.)

No regression with the flag off. The mixed program unrolls unchanged under plain unroll(). A corpus of 333 declared-registers-only programs harvested from tests/ dumps byte-identically on both worktrees under consolidate_qubits=True; the only two diffs are set-iteration order inside an unrelated "Unsupported OpenQASM version" message.

OpenPulse does not trip the new check. Worth recording, because defcalgrammar "openpulse" forces _consolidate_qubits = True at visitor.py:3328 regardless of what the caller passed. Under OpenPulse, gate, measure and reset operands are renamed to __PYQASM_QUBITS__[n] before registration, so no $n key ever reaches _qubit_depths and plain unroll() on an OpenPulse program behaves identically on both worktrees.

Suite and CI. 720 passed / 714 on base (the 6 new tests), same 2 pre-existing tests/cli failures on both worktrees, so nothing is attributable to this change. black --check clean on both touched files; all GitHub checks green. isort and pylint are absent from the local venv and were not run — CI covers them.

Blast radius. An org-wide code search finds no consumer of consolidate_qubits outside pyqasm itself and the docs, so the breaking change lands narrowly. That search does not see private repositories.

Next steps

  1. Rebase onto origin/main and resolve the src/pyqasm/visitor.py conflict against #346's typing and docstring changes. #357 and #359 touch other regions of the same file.
  2. qBraid/docs v2/pyqasm/user-guide/advanced-features.mdx documents consolidate_qubits without the new rejection. Its examples use declared registers only, so they still hold, but the page should gain a line about mixed programs.
  3. Two pre-existing behaviours surfaced while testing, identical on origin/main and therefore not findings against this PR, but each worth its own issue:
    • Under OpenPulse, barrier $0 is the one physical reference left unrenamed while every other one becomes __PYQASM_QUBITS__[n], so the output carries both spellings. Index-identity makes it unambiguous in practice, only inconsistent.
    • Unrolling a module twice resets num_qubits from 3 to 2 for a mixed program, dropping the physical qubit from the count. This PR makes the path easy to reach: catching the new error and re-running unroll() without the flag is the natural remedy, and it lands on the stale count.

Comment thread src/pyqasm/visitor.py
)
# only physical qubits: nothing to consolidate, so do not declare an
# internal register nothing would reference
return unrolled_stmts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Type: Implementation
Severity: Medium

Rationale: This early return skips the INTERNAL_QUBIT_REGISTER reserved-name loop directly below, and the check is reachable with that name already declared. Verified against origin/main:

program origin/main this PR
int __PYQASM_QUBITS__ = 3; h $1; ValidationError: Variable '__PYQASM_QUBITS__' is already defined succeeds silently
qubit[2] __PYQASM_QUBITS__; h $1; same reserved-name error ...mixes declared registers with physical qubits ($1)

Neither outcome corrupts the output — no internal register is emitted on this path, so nothing actually collides. The concern is the contract: the docstring edited a few lines above still promises a raise "if the reserved register '__PYQASM_QUBITS__' is already declared", and on this path it no longer does. The second row is also a diagnostic downgrade: the user's real problem is the reserved name, but they are told about physical qubits instead.

Change Requested: Move the global_scope reserved-name loop (lines 526-532) above the new physical-qubit block, so it runs before either exit. One move fixes both rows and keeps the docstring honest. If the intent is instead that the guard should not apply when no register is emitted, narrow the docstring to say so.

Comment thread src/pyqasm/visitor.py
Comment on lines +511 to +513
physical_qubits = sorted(
name for name, _ in self._module._qubit_depths if name.startswith("$")
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Type: Implementation
Severity: Low

Rationale: sorted() on the raw $n strings sorts lexicographically. A program using $2 and $10 produces ...physical qubits ($10, $2), verified at this commit. On a device with more than ten qubits the list reads as unordered, which makes it harder to scan for the offending references.

Change Requested: Sort by the numeric index.

Suggested change
physical_qubits = sorted(
name for name, _ in self._module._qubit_depths if name.startswith("$")
)
physical_qubits = sorted(
(name for name, _ in self._module._qubit_depths if name.startswith("$")),
key=lambda name: int(name[1:]),
)

The parentheses around the generator are required once key= is added. Every name reaching this point has already passed the reg_name[1:].isdigit() guard in _get_op_bits, so int() is safe.

Comment thread src/pyqasm/visitor.py
Comment on lines +519 to +520
"Cannot consolidate qubit registers: the program mixes declared "
f"registers with physical qubits ({', '.join(physical_qubits)})",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Type: Maintenance
Severity: Low

Rationale: This is a new hard failure on a public flag: programs that unrolled successfully before now raise. The message names the physical qubits but does not tell the user how to proceed, and raise_qasm3_error is called without error_node/span here — no statement node is available at finalize time — so there is no line number either. The message text is the entire diagnostic.

Change Requested: Name the two ways out.

Suggested change
"Cannot consolidate qubit registers: the program mixes declared "
f"registers with physical qubits ({', '.join(physical_qubits)})",
"Cannot consolidate qubit registers: the program mixes declared "
f"registers with physical qubits ({', '.join(physical_qubits)}). "
"Unroll without 'consolidate_qubits=True', or rewrite the physical "
"qubits as operands of a declared register.",

# two consolidated slots plus physical $2, which sizes the count to its own index + 1.
# neither number is the declared qubit[5], which comes from device_qubits (see #353)
result.unroll()
assert result.num_qubits == 3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Type: Implementation
Severity: Low

Rationale: The test_physical_qubits_are_not_consolidated this replaces asserted the full unrolled text, which is what pinned #344's rule that a physical qubit survives unrolling as written. This test asserts only the count, and num_qubits == 3 would still pass if cz $2, q[1] came out rewritten or dropped. test_physical_qubits_only still pins the text for the pure-physical case, so the gap is specifically the mixed shape.

Change Requested: Add the output assertion. Verified against this commit — check_unrolled_qasm and dumps are already imported in this file.

Suggested change
assert result.num_qubits == 3
result.unroll()
expected_qasm = """OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
h q[0];
cz $2, q[1];
"""
check_unrolled_qasm(dumps(result), expected_qasm)
assert result.num_qubits == 3

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.

consolidate_qubits=True emits two address spaces for mixed declared/physical programs

3 participants