Skip to content

fix: Record measurements into named classical registers - #411

Merged
yitchen-tim merged 33 commits into
mainfrom
registers-2-route
Oct 16, 2026
Merged

yitchen-tim merged 33 commits into
mainfrom
registers-2-route

Conversation

@yitchen-tim

@yitchen-tim yitchen-tim commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Routes every measurement with a classical destination into the register declared for it, so a measurement is recorded as "qubit q into element e of register r" instead of a program-wide classical index. Removes the temporary flat-index API from #410 and consolidates the pending-measurement flush paths into one.

Stack: #410 (register model) → #411 (route measurements). Based on #410.

Background

#410 added ClassicalRegister, the scoped RegisterTable, and a temporary add_measure_by_index that still wrote every measurement into the anonymous register. This PR connects the interpreter's register identity to the circuit, which fixes register aliasing and scope shadowing for measurement destinations.

It also fixes an inconsistency between the two simulation flows. On main, _branch_measurement only records a measurement in the circuit when the destination is indexed. A scalar destination (b = measure q[0]) in a branched program never reaches the circuit, so _run_branched sees no measurements and reports every qubit. The same program without branching reports only q[0].

Results are still reported as one flat measurements array. Until per-register results are available through the output field, a program that measures into more than one register is rejected with a clear error instead of returning bits that cannot be attributed to a register.

Behavior changes

  • Breaking: a program that measures into more than one bit register raises ValueError. Measure into one register instead, for example bit[2] b; with b[0] and b[1]. Declaring bits that are never measured into is still allowed.
  • Breaking: a branched program reports only the qubits it measures. Previously, a program whose only measurements were scalar mid-circuit measurements (b = measure q[0]) reported every qubit.
  • Breaking: measuring into a variable that isn't a bit, for example int x; x = measure q;, raises TypeError.
  • A measurement is no longer missing from the results when a later measurement's result is read first.
  • A register element measured only inside a branch that never runs has no column in measurements.
  • A classical write to a measured bit keeps its value. Previously, in b[0] = measure q[0]; b[0] = 0; if (b[0]) { ... } the measurement could replace the written 0, so the branch was taken. measurements still reports the measured outcome, the same as main.
  • In the interpreter, with shots = 0, a condition on a measured bit reads it as 0 instead of failing. run_openqasm(shots=0) with a measurement still fails.
  • Invalid measurement destinations raise IndexError instead of writing to hidden indices: an index outside the register, or a register smaller than the number of measured qubits.

More details about the changes

openqasm/program_context.py

  • add_measure resolves the destination to (register, register_indices) once through _resolve_destination. A measurement with no destination goes into the anonymous register and never branches.
  • Pending MCM entries are (target, register, register_indices, overwritten). Matching uses the register handle, so a shadowing declaration never flushes the outer variable's measurement.
  • A non-bit declaration is recorded as None in RegisterTable, so it hides an outer bit of the same name.
  • One _apply_pending_mcm_targets replaces _maybe_transition_to_branched and the per-variable and per-qubit flush paths.
  • _branch_measurement always records the measurement in the circuit. _record_measurement_outcomes replaces _update_identifier_target and _update_indexed_target. A scalar bit is a size 1 register.
  • update_value marks classically written elements on pending measurement entries so that applying the measurement later does not overwrite the program's value. A write never forces a flush.
  • Per-qubit bookkeeping uses zip(..., strict=True), so a qubit and register index count mismatch raises instead of dropping qubits.
  • get_value and get_value_by_identifier no longer wrap raw path values into literals.

openqasm/circuit.py

  • Removes add_measure_by_index, target_classical_indices, Circuit.clear_measurement, and ClassicalRegister.clear.
  • Adds ClassicalRegister.measured, measured_registers, and validate_single_measured_register.

openqasm/simulation_path.py

  • mcm_outcomes is keyed by (register, register_index) instead of a flat classical index, and exposed as a property.

simulator.py

  • Both flows call validate_single_measured_register and build measuredQubits from measurement_slots.
  • The branched overlay writes each path's MCM outcome into its measurement slot, vectorized per path.

Introduce ClassicalRegister and the register list on Circuit, with
measurement columns derived from which register elements hold a
measurement source. The context gets a scoped RegisterTable and every
bit declaration creates a register. Nothing routes measurements through
the registers yet: the old flat-index entry point survives as
Circuit.add_measure_by_index, implemented on the anonymous register, and
target_classical_indices is derived from it. Both are temporary and go
away when measurements are recorded into declared registers.

No behaviour change. The only visible difference is that
Circuit.measured_qubits is now in classical index order instead of
registration order, which is the order the simulator already used.

Test preparation for the follow-up: MCM tests measure into one declared
register instead of relying on the measure-all fallback.
@yitchen-tim
yitchen-tim added this pull request to stack #412 October 5, 2026 15:23
(because they had a classical destination but nothing forced them to be
applied) never affected control flow, so they are plain measurements of
the final state. Once execution has branched nothing is ever pending (see
``_apply_pending_mcm_targets``).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I keep these docstring to give context about where and when these private functions are called. I feel they can be tricky to understand.

@@ -2613,6 +2613,24 @@ def test_simple_conditional_feedforward(self, simulator):
assert set(counter.keys()) == {"00", "11"}
assert 0.4 < counter["00"] / 1000 < 0.6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In this test file, many tests have measurements become fully specified. The partial measurements is covered in test_scalar_bit_mcm_reports_only_the_measured_qubit.

Also, storing measurements into multiple registers becomes not allowed until the output is supported (we have PR opened: #399). Users must store measurement into a single register for now. I have changed few tests so that there are only one register for measurements.

@codecov

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (822f98e) to head (17d3245).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #411   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           49        49           
  Lines         5050      5027   -23     
  Branches       590       579   -11     
=========================================
- Hits          5050      5027   -23     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Temporary entry point for callers that still address measurement columns by
a program-wide classical index. It is removed once measurements are routed
through declared registers via ``add_measure``.
def validate_single_measured_register(self) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a temporary validation and it will be removed once output is supported.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To raise awareness: AutoQASM declares a new bit for every measure() call, so this check rejects most AutoQASM programs, including ones without mid-circuit measurements:

@aq.main(num_qubits=2)
def bell():
    h(0)
    cnot(0, 1)
    measure(0)
    measure(1)

LocalSimulator().run(bell, shots=100)

Generated OpenQASM:

qubit[2] __qubits__;
h __qubits__[0];
cnot __qubits__[0], __qubits__[1];
bit __bit_0__;
__bit_0__ = measure __qubits__[0];
bit __bit_1__;
__bit_1__ = measure __qubits__[1];
  • Released simulator: {'00': 51, '11': 49}
  • This PR: ValueError: ... recorded into 2: bit_0, bit_1. Declare one bit register and measure into it.

AutoQASM users can't follow that advice because they don't write the declarations.

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.

also just for awareness - I have this PR stacked on top of Cody's output PR which makes everything work for AutoQASM programs: #401

We don't need to verify it now, but if/when we do rework output variable support, we should just ensure that PR also gets incorporated appropriately (and, ultimately, that amazon-braket/autoqasm#119 will pass CI once the output-supporting LocalSimulator is released)

Comment thread src/braket/default_simulator/openqasm/circuit.py Outdated
@yitchen-tim
yitchen-tim marked this pull request as ready for review October 5, 2026 18:55
@yitchen-tim
yitchen-tim requested a review from a team as a code owner October 5, 2026 18:55
The argument holds the destination index within the register for each
measured qubit, so name it for that.
Measurement columns are measurement slots in the register model, so
use that term in comments and docstrings.
zip() silently dropped qubits when register_indices did not have one
entry per measured qubit. Raise a ValueError before anything is bound.
The register model does not change MCM semantics, so restore the
original scalar-bit MCM programs. The rewrites they need move to the
change that records measurements into declared registers.
A declaration whose initializer depends on a mid-circuit measurement was
replayed in full for every path, so a bit declaration created one
register per path. Declare the variable once and evaluate and assign the
initializer per path. The initializer is evaluated before the
declaration so a shadowed name still reads the outer variable.
Declaring bit registers inside a for loop is not a supported pattern, so
the tests should not depend on it. Coverage is unchanged.
Route every measurement with a classical destination through the
register declared for it, so a measurement is recorded as "qubit q into
element e of register r" instead of a program-wide classical index. The
interpreter carries register identity from its scoped register table
down to the circuit, and the simulator reports one column per measured
register element in declaration then element order.

Remove the temporary flat-index entry point (add_measure_by_index and
the derived target_classical_indices) along with the three pending
measurement flush paths it served; a single _apply_pending_mcm_targets
applies all pending measurements together.

Behaviour changes:
- Distinct bit registers no longer alias each other's elements.
- A scalar bit destination is one element and one column.
- A shadowing bit declaration gets its own register.
- Applying one pending measurement applies all of them, so a later
  pending measurement is not dropped from the results.
- Measurements into a register are reported even after branching.
- A classical write to a measured bit releases the measurement, so the
  element is no longer a column.
- Results can only be reported for a single measured register until
  per-register results are available through the output field.
Follow the add_measure rename through the measurement destination
plumbing: _resolve_destination, pending entries, _branch_measurement,
and _release_measurements.
Use measurement slot in comments and docstrings for the register
elements reported in measurements, and name the overlay loop variables
after the slots they enumerate.
Each measured qubit pairs with exactly one register index and one bound
slot. Make a length mismatch an error instead of silently dropping
qubits.
Declaring bit registers inside a for loop is not a supported pattern.
Reach the same out-of-scope paths with a subroutine whose local bit
shadows the measurement destination.
Base automatically changed from registers-1-model to main October 8, 2026 19:40
Route every measurement with a classical destination through the
register declared for it, so a measurement is recorded as "qubit q into
element e of register r" instead of a program-wide classical index. The
interpreter carries register identity from its scoped register table
down to the circuit, and the simulator reports one column per measured
register element in declaration then element order.

Remove the temporary flat-index entry point (add_measure_by_index and
the derived target_classical_indices) along with the three pending
measurement flush paths it served; a single _apply_pending_mcm_targets
applies all pending measurements together.

Behaviour changes:
- Distinct bit registers no longer alias each other's elements.
- A scalar bit destination is one element and one column.
- A shadowing bit declaration gets its own register.
- Applying one pending measurement applies all of them, so a later
  pending measurement is not dropped from the results.
- Measurements into a register are reported even after branching.
- A classical write to a measured bit releases the measurement, so the
  element is no longer a column.
- Results can only be reported for a single measured register until
  per-register results are available through the output field.
Follow the add_measure rename through the measurement destination
plumbing: _resolve_destination, pending entries, _branch_measurement,
and _release_measurements.
Use measurement slot in comments and docstrings for the register
elements reported in measurements, and name the overlay loop variables
after the slots they enumerate.
Each measured qubit pairs with exactly one register index and one bound
slot. Make a length mismatch an error instead of silently dropping
qubits.
Declaring bit registers inside a for loop is not a supported pattern.
Reach the same out-of-scope paths with a subroutine whose local bit
shadows the measurement destination.
Comment thread src/braket/default_simulator/openqasm/circuit.py
Comment thread src/braket/default_simulator/openqasm/program_context.py Outdated
Comment thread src/braket/default_simulator/openqasm/program_context.py Outdated
Comment thread src/braket/default_simulator/openqasm/program_context.py
Comment thread src/braket/default_simulator/simulator.py

@nilsquet nilsquet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I added two comments inline and have some further nits:

  • The description still mentions _release_measurements, which was removed in 1fa35ee.
  • Circuit.clear_measurement (circuit.py L175) and ClassicalRegister.clear (circuit.py L57) are now unused.
  • The description says a condition on a measured bit reads as 0 with shots = 0. That's only true inside the interpreter; run_openqasm(shots=0) with a measurement still fails.
  • program_context.py L947–948: "For interpreter that supports multiple classical registers, the name of the register is another arg." is unclear. Suggest: "Contexts that support multiple classical registers receive the destination variable as the classical_destination keyword argument."
  • program_context.py L1271: the declare_variable docstring line is 116 characters, over the 100-character limit.

# A deferred measurement may be flushed after its destination went out of
# scope (or was shadowed); the outcome is still recorded for ``measurements``
# but there is no program variable left to update.
in_scope = self.register_table.get_register(register.name) is register

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This crashes on a program that works on main:

def flip(qubit r) { bit b; x r; }
qubit[2] q;
bit b;
h q[0];
b = measure q[0];
flip(q[0]);
if (b) { x q[1]; }

AttributeError: 'NoneType' object has no attribute 'value'

The x r inside flip forces the deferred measurement. At that point the name b points to the local b, so in_scope is False and the outer b is never set. After flip returns, if (b) reads None.

When the local is not a bit, there is no crash. The program returns wrong counts instead:

def flip(qubit r) { int c; x r; }
qubit[2] q;
bit[2] c;
h q[0];
c[0] = measure q[0];
flip(q[0]);
if (c[0]) { x q[1]; }
c[1] = measure q[1];

Expected {00, 11}, but this PR returns {00, 10}: the outer c is never updated, so the if is never taken. Main crashes on this one, so it's not a regression from a working program, but a wrong result is harder to notice than a crash. _apply_pending_without_shots has the same name-based scope check.

The measurement already carries its register, so could we store the per-path value by register instead of looking it up by name? That would fix both cases and the shots = 0 path. test_shadowed_deferred_measurement_with_shots doesn't catch this because it never reads b after the call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deferring the fix to another PR.

updates (e.g., ``arr[0] = 5``) are handled by reading the current
value from the path, applying the index update, and writing back.
"""
self._flush_pending_mcm_for_variable(get_identifier_name(variable))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This makes any write to a bit with a deferred measurement branch the simulation, even when nothing reads the result:

Program (1000 shots) main PR
qubit[12] q; bit[12] b; h q; b = measure q; b[0] = 0; 0.23s 22.6s
qubit[10] q; bit[11] b; h q; b[0:9] = measure q; b[10] = 1; 0.01s 2.8s

A write doesn't depend on the measured value. It replaces it, so it shouldn't need branching. Could we keep the measurement deferred and just mark the overwritten element so the flush skips writing it back? The results would still report the measured value.

@yitchen-tim yitchen-tim Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I tried yet another solution for constant overwrite. Instead of force flushing it, as you suggested, I mark a pending mcm as overwritten when an overwrite happens. The overwritten mark prevent a pending mcm writing value into a bit.

Temporary entry point for callers that still address measurement columns by
a program-wide classical index. It is removed once measurements are routed
through declared registers via ``add_measure``.
def validate_single_measured_register(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To raise awareness: AutoQASM declares a new bit for every measure() call, so this check rejects most AutoQASM programs, including ones without mid-circuit measurements:

@aq.main(num_qubits=2)
def bell():
    h(0)
    cnot(0, 1)
    measure(0)
    measure(1)

LocalSimulator().run(bell, shots=100)

Generated OpenQASM:

qubit[2] __qubits__;
h __qubits__[0];
cnot __qubits__[0], __qubits__[1];
bit __bit_0__;
__bit_0__ = measure __qubits__[0];
bit __bit_1__;
__bit_1__ = measure __qubits__[1];
  • Released simulator: {'00': 51, '11': 49}
  • This PR: ValueError: ... recorded into 2: bit_0, bit_1. Declare one bit register and measure into it.

AutoQASM users can't follow that advice because they don't write the declarations.

program-wide classical index. It is removed once measurements are routed
through declared registers via ``add_measure``.
def validate_single_measured_register(self) -> None:
"""Reject programs whose measurements span more than one register.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Again, for awareness: Whether this check fires depends on which branches run, so the same program can pass or fail from run to run:

qubit[2] q;
bit b;
bit r;
ry(0.3) q[0];
b = measure q[0];
if (b) { r = measure q[1]; }

With 10 shots, 16 of 20 seeds return results and 4 raise ValueError. r only counts as measured when at least one shot takes the branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deferring the fix to the output PRs.

@yitchen-tim
yitchen-tim merged commit 86c0cc8 into main Oct 16, 2026
30 checks passed
@yitchen-tim
yitchen-tim deleted the registers-2-route branch October 16, 2026 18:02
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.

3 participants