Repository: quantumlib/Cirq

Issue:
Density matrix simulator simulates noise incorrectly
The code below constructs two circuits, each with one measurement gate that should have the same measurement results. Instead, one measures 0 and the other measures 1.
```
import cirq

q = cirq.LineQubit(0)
circuit1 = cirq.Circuit(cirq.measure(q))
circuit2 = circuit1 + cirq.I(q)

print('Circuit 1:')
print(circuit1)
print()
print('Circuit2:')
print(circuit2)
print()

simulator = cirq.DensityMatrixSimulator(noise=cirq.X)
result1 = simulator.run(circuit1, repetitions=10)
result2 = simulator.run(circuit2, repetitions=10)

print('Result 1:')
print(result1)
print()
print('Result 2:')
print(result2)
print()
```
Output:
```
Circuit 1:
0: ───M───

Circuit2:
0: ───M───I───

Result 1:
0=1111111111

Result 2:
0=0000000000
```


Discussion:
The reason this happens is that if all measurements are terminal, the code will first simulate all noisy moments and then simulate all measurements. This is incorrect for Circuit 1 above because the moment with the measurement gets simulated (with noise, ignoring the measurement) before the measurement itself is performed.

However, I think this example raises the question of whether the output of Circuit 1 is actually the correct one (putting aside the fact that in this case it resulted from incorrect behavior). It seems a bit strange to specify a bit flip noise model but have all the measurements be perfect because the noise is applied after the measurement rather than before.
This is partially fixed by #2487 which is in the review queue.
In what way is it partially fixed? Would it change the output of my code snippet?
Oh I see, this is a different issue. That PR added the option to not perform perfect measurements during the simulation. 

Well, I have run your code with ignore_measurement_results=True and it looks even stranger:
```
import cirq

q = cirq.LineQubit(0)
circuit1 = cirq.Circuit(cirq.measure(q))
circuit2 = circuit1 + cirq.I(q)

print('Circuit 1:')
print(circuit1)
print()
print('Circuit2:')
print(circuit2)
print()

simulator = cirq.DensityMatrixSimulator(noise=cirq.X, ignore_measurement_results=True)
result1 = simulator.run(circuit1, repetitions=10)
result2 = simulator.run(circuit2, repetitions=10)

print('Result 1:')
print(result1)
print()
print('Result 2:')
print(result2)
print()
```
```
Circuit 1:
0: ───M───

Circuit2:
0: ───M───I───

Result 1:
0=1111111111

Result 2:

```

Looks like terminal measurements have some special treatment now, which feels wrong.

Found where it happens in density_matrix_simulator.py:

```
    def _run(self, circuit: circuits.Circuit,
             param_resolver: study.ParamResolver,
             repetitions: int) -> Dict[str, np.ndarray]:
        """See definition in `cirq.SimulatesSamples`."""
        param_resolver = param_resolver or study.ParamResolver({})
        resolved_circuit = protocols.resolve_parameters(circuit,
                                                        param_resolver)
        self._check_all_resolved(resolved_circuit)

        if circuit.are_all_measurements_terminal():
            return self._run_sweep_sample(resolved_circuit, repetitions)
        return self._run_sweep_repeat(resolved_circuit, repetitions)
```

`self.run_sweep_sample` directly calls `step_result.sample_measurement_ops` and `self.run_sweep_repeat` does more nuanced things (including dephasing).

Not yet sure what is the best way to fix this.
I think the correct fix for this issue is to modify the "simulate then sample" optimization to discard all operations that come after measurements. The optimization could also be extended to apply to any circuit where all measurements are outside of each other's "light cone" if we wanted to be ambitious.
@Strilanc what you propose would not fix this issue. In my opening post, the incorrect behavior arises in simulating Circuit 1, which does not have any operations after measurements.

One possible way to fix this is: when all measurements are terminal, simulate the noisy circuit _ignoring_ the measurements. Then perform the measurements. However, this is tricky when there are qubits with no operations on them other than the measurement. Just removing the measurement will also remove that qubit from the circuit.
Actually, what you said makes sense; we just need to discard the operations _after_ applying the noise model.

Resolving patch:
diff --git a/cirq/sim/density_matrix_simulator.py b/cirq/sim/density_matrix_simulator.py
--- a/cirq/sim/density_matrix_simulator.py
+++ b/cirq/sim/density_matrix_simulator.py
@@ -11,19 +11,20 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
 """Simulator for density matrices that simulates noisy quantum circuits."""
 
 import collections
 
-from typing import Dict, Iterator, List, Type, Union
+from typing import Dict, Iterator, List, TYPE_CHECKING, Type, Union
 
 import numpy as np
 
 from cirq import circuits, ops, protocols, study, value, devices
 from cirq.sim import density_matrix_utils, simulator
 
-import cirq
+if TYPE_CHECKING:
+    from typing import Tuple
+    import cirq
 
 
 class _StateAndBuffers:
@@ -164,32 +165,30 @@ def _run(self, circuit: circuits.Circuit,
              repetitions: int) -> Dict[str, np.ndarray]:
         """See definition in `cirq.SimulatesSamples`."""
         param_resolver = param_resolver or study.ParamResolver({})
-        resolved_circuit = protocols.resolve_parameters(circuit,
-                                                        param_resolver)
+        resolved_circuit = protocols.resolve_parameters(circuit, param_resolver)
         self._check_all_resolved(resolved_circuit)
 
         if circuit.are_all_measurements_terminal():
             return self._run_sweep_sample(resolved_circuit, repetitions)
         return self._run_sweep_repeat(resolved_circuit, repetitions)
 
-    def _run_sweep_sample(self,
-                          circuit: circuits.Circuit,
+    def _run_sweep_sample(self, circuit: circuits.Circuit,
                           repetitions: int) -> Dict[str, np.ndarray]:
         for step_result in self._base_iterator(
                 circuit=circuit,
                 qubit_order=ops.QubitOrder.DEFAULT,
                 initial_state=0,
-                perform_measurements=False):
+                all_measurements_are_terminal=True):
             pass
-        measurement_ops = [op for _, op, _ in
-                           circuit.findall_operations_with_gate_type(
-                               ops.MeasurementGate)]
+        measurement_ops = [
+            op for _, op, _ in circuit.findall_operations_with_gate_type(
+                ops.MeasurementGate)
+        ]
         return step_result.sample_measurement_ops(measurement_ops,
                                                   repetitions,
                                                   seed=self._prng)
 
-    def _run_sweep_repeat(self,
-                          circuit: circuits.Circuit,
+    def _run_sweep_repeat(self, circuit: circuits.Circuit,
                           repetitions: int) -> Dict[str, np.ndarray]:
         measurements = {}  # type: Dict[str, List[np.ndarray]]
         if repetitions == 0:
@@ -199,10 +198,7 @@ def _run_sweep_repeat(self,
 
         for _ in range(repetitions):
             all_step_results = self._base_iterator(
-                circuit,
-                qubit_order=ops.QubitOrder.DEFAULT,
-                initial_state=0,
-                perform_measurements=True)
+                circuit, qubit_order=ops.QubitOrder.DEFAULT, initial_state=0)
             for step_result in all_step_results:
                 for k, v in step_result.measurements.items():
                     if not k in measurements:
@@ -228,8 +224,7 @@ def _simulator_iterator(self, circuit: circuits.Circuit,
         resolved_circuit = protocols.resolve_parameters(circuit, param_resolver)
         self._check_all_resolved(resolved_circuit)
         actual_initial_state = 0 if initial_state is None else initial_state
-        return self._base_iterator(resolved_circuit,
-                                   qubit_order,
+        return self._base_iterator(resolved_circuit, qubit_order,
                                    actual_initial_state)
 
     def _apply_op_channel(self, op: ops.Operation, state: _StateAndBuffers,
@@ -249,12 +244,11 @@ def _apply_op_channel(self, op: ops.Operation, state: _StateAndBuffers,
                 state.buffers[i] = state.tensor
         state.tensor = result
 
-    def _base_iterator(
-            self,
-            circuit: circuits.Circuit,
-            qubit_order: ops.QubitOrderOrList,
-            initial_state: Union[int, np.ndarray],
-            perform_measurements: bool = True) -> Iterator:
+    def _base_iterator(self,
+                       circuit: circuits.Circuit,
+                       qubit_order: ops.QubitOrderOrList,
+                       initial_state: Union[int, np.ndarray],
+                       all_measurements_are_terminal=False) -> Iterator:
         qubits = ops.QubitOrder.as_qubit_order(qubit_order).order_for(
             circuit.all_qubits())
         qid_shape = protocols.qid_shape(qubits)
@@ -264,6 +258,8 @@ def _base_iterator(
             len(qid_shape),
             qid_shape=qid_shape,
             dtype=self._dtype)
+        measured = collections.defaultdict(
+            bool)  # type: Dict[Tuple[cirq.Qid, ...], bool]
         if len(circuit) == 0:
             yield DensityMatrixStepResult(initial_matrix, {}, qubit_map,
                                           self._dtype)
@@ -296,30 +292,32 @@ def keep(potential_op: ops.Operation) -> bool:
             for op in channel_ops_and_measurements:
                 indices = [qubit_map[qubit] for qubit in op.qubits]
                 # TODO: support more general measurements.
+                if all_measurements_are_terminal and measured[op.qubits]:
+                    continue
                 if isinstance(op.gate, ops.MeasurementGate):
+                    measured[op.qubits] = True
                     meas = op.gate
-                    if perform_measurements:
-                        if self._ignore_measurement_results:
-                            for i, q in enumerate(op.qubits):
-                                self._apply_op_channel(
-                                    cirq.phase_damp(1).on(q), state,
-                                    [indices[i]])
-                        else:
-                            invert_mask = meas.full_invert_mask()
-                            # Measure updates inline.
-                            bits, _ = (
-                                density_matrix_utils.measure_density_matrix(
-                                    state.tensor,
-                                    indices,
-                                    qid_shape=qid_shape,
-                                    out=state.tensor,
-                                    seed=self._prng))
-                            corrected = [
-                                bit ^ (bit < 2 and mask)
-                                for bit, mask in zip(bits, invert_mask)
-                            ]
-                            key = protocols.measurement_key(meas)
-                            measurements[key].extend(corrected)
+                    if all_measurements_are_terminal:
+                        continue
+                    if self._ignore_measurement_results:
+                        for i, q in enumerate(op.qubits):
+                            self._apply_op_channel(
+                                ops.phase_damp(1).on(q), state, [indices[i]])
+                    else:
+                        invert_mask = meas.full_invert_mask()
+                        # Measure updates inline.
+                        bits, _ = (density_matrix_utils.measure_density_matrix(
+                            state.tensor,
+                            indices,
+                            qid_shape=qid_shape,
+                            out=state.tensor,
+                            seed=self._prng))
+                        corrected = [
+                            bit ^ (bit < 2 and mask)
+                            for bit, mask in zip(bits, invert_mask)
+                        ]
+                        key = protocols.measurement_key(meas)
+                        measurements[key].extend(corrected)
                 else:
                     # TODO: Use apply_channel similar to apply_unitary.
                     self._apply_op_channel(op, state, indices)
@@ -363,10 +361,10 @@ class DensityMatrixStepResult(simulator.StepResult):
     """
 
     def __init__(self,
-            density_matrix: np.ndarray,
-            measurements: Dict[str, np.ndarray],
-            qubit_map: Dict[ops.Qid, int],
-            dtype: Type[np.number] = np.complex64):
+                 density_matrix: np.ndarray,
+                 measurements: Dict[str, np.ndarray],
+                 qubit_map: Dict[ops.Qid, int],
+                 dtype: Type[np.number] = np.complex64):
         """DensityMatrixStepResult.
 
         Args:
@@ -466,9 +464,8 @@ class DensityMatrixSimulatorState():
             ordering of the basis in density_matrix.
     """
 
-    def __init__(self,
-            density_matrix: np.ndarray,
-            qubit_map: Dict[ops.Qid, int]):
+    def __init__(self, density_matrix: np.ndarray,
+                 qubit_map: Dict[ops.Qid, int]):
         self.density_matrix = density_matrix
         self.qubit_map = qubit_map
         self._qid_shape = simulator._qubit_map_to_shape(qubit_map)
@@ -526,10 +523,9 @@ class DensityMatrixTrialResult(simulator.SimulationTrialResult):
         final_density_matrix: The final density matrix of the system.
     """
 
-    def __init__(self,
-            params: study.ParamResolver,
-            measurements: Dict[str, np.ndarray],
-            final_simulator_state: DensityMatrixSimulatorState) -> None:
+    def __init__(self, params: study.ParamResolver,
+                 measurements: Dict[str, np.ndarray],
+                 final_simulator_state: DensityMatrixSimulatorState) -> None:
         super().__init__(params=params,
                          measurements=measurements,
                          final_simulator_state=final_simulator_state)
@@ -538,8 +534,9 @@ def __init__(self,
             final_simulator_state.density_matrix, (size, size))
 
     def _value_equality_values_(self):
-        measurements = {k: v.tolist() for k, v in
-                        sorted(self.measurements.items())}
+        measurements = {
+            k: v.tolist() for k, v in sorted(self.measurements.items())
+        }
         return (self.params, measurements, self._final_simulator_state)
 
     def __str__(self):


Test patch:
diff --git a/cirq/sim/density_matrix_simulator_test.py b/cirq/sim/density_matrix_simulator_test.py
--- a/cirq/sim/density_matrix_simulator_test.py
+++ b/cirq/sim/density_matrix_simulator_test.py
@@ -1100,3 +1100,15 @@ def _unitary_(self):
     )
     assert np.all(cirq.DensityMatrixSimulator().run(c).measurements['a'] ==
                   [[0, 1, 0, 2, 3]])
+
+
+def test_simulate_noise_with_terminal_measurements():
+    q = cirq.LineQubit(0)
+    circuit1 = cirq.Circuit(cirq.measure(q))
+    circuit2 = circuit1 + cirq.I(q)
+
+    simulator = cirq.DensityMatrixSimulator(noise=cirq.X)
+    result1 = simulator.run(circuit1, repetitions=10)
+    result2 = simulator.run(circuit2, repetitions=10)
+
+    assert result1 == result2