Repository: quantumlib/Cirq

Issue:
repeat_until incompatible with run repetitions
**Description of the issue**

Subcircuits with `repeat_until` can have non-deterministic number of measurements for a given key. `ResultDict` expects a flat array of measurement results. This can cause exceptions when run with multiple repetitions.

**How to reproduce the issue**

```python
def test_repeat_until_repetitions():
    q = cirq.LineQubit(0)
    key = cirq.MeasurementKey('m')
    sim = cirq.Simulator()
    c = cirq.Circuit(
        cirq.CircuitOperation(
            cirq.FrozenCircuit(cirq.X(q) ** 0.2, cirq.measure(q, key=key)),
            use_repetition_ids=False,
            repeat_until=cirq.KeyCondition(key),
        )
    )
    sim.run(c, repetitions=10)
```

Error:
```
cirq-core/cirq/work/sampler.py:53: in run
    return self.run_sweep(program, param_resolver, repetitions)[0]
cirq-core/cirq/sim/simulator.py:73: in run_sweep
    return list(self.run_sweep_iter(program, params, repetitions))
cirq-core/cirq/sim/simulator.py:104: in run_sweep_iter
    records = self._run(
cirq-core/cirq/sim/simulator_base.py:299: in _run
    return {str(k): np.array(v, dtype=np.uint8) for k, v in records.items()}
E   ValueError: setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. The detected shape was (10,) + inhomogeneous part.
```

**Cirq version**
0.15.0.dev

@95-martin-orion @maffoo WDYT?




Discussion:
This is definitely an issue, but at least it appears contained to this case - AFAIK there's no situation where a measurement would have a different number of qubits across multiple repetitions / appearances within a repetition.

Numpy doesn't seem to have a nice solution for this. The closest I found was [`numpy.ma.array`](https://numpy.org/doc/stable/reference/maskedarray.generic.html#what-is-a-masked-array) (aka masked arrays), which require a bunch of overhead and still don't give the desired behavior in situations like `arr=[[1], [2, 3]]; len(arr[0])`. What currently consumes this data? Could we get away with a simple list-of-lists, or placeholders for unrealized measurements?

Alternatively, this might be a sign that we finally need the "do not expose in results" tag for measurements...
In a case like this I would probably lean toward padding the measurement arrays with zeros to keep the overall shape rectangular. Since the keys that go into computing the loop termination condition are part of the result, the code that analyzes the result can figure out where things terminated on any given repetition of the circuit. I haven't thought too much about this, though.

Resolving patch:
diff --git a/cirq-core/cirq/sim/simulator_base.py b/cirq-core/cirq/sim/simulator_base.py
--- a/cirq-core/cirq/sim/simulator_base.py
+++ b/cirq-core/cirq/sim/simulator_base.py
@@ -279,7 +279,7 @@ def _run(
                 measurement_ops, repetitions, seed=self._prng, _allow_repeated=True
             )
 
-        records: Dict['cirq.MeasurementKey', List[np.ndarray]] = {}
+        records: Dict['cirq.MeasurementKey', List[Sequence[Sequence[int]]]] = {}
         for i in range(repetitions):
             for step_result in self._core_iterator(
                 general_suffix,
@@ -296,7 +296,15 @@ def _run(
                 if k not in records:
                     records[k] = []
                 records[k].append([cr])
-        return {str(k): np.array(v, dtype=np.uint8) for k, v in records.items()}
+
+        def pad_evenly(results: Sequence[Sequence[Sequence[int]]]):
+            largest = max(len(result) for result in results)
+            xs = np.zeros((len(results), largest, len(results[0][0])), dtype=np.uint8)
+            for i, result in enumerate(results):
+                xs[i, 0 : len(result), :] = result
+            return xs
+
+        return {str(k): pad_evenly(v) for k, v in records.items()}
 
     def simulate_sweep_iter(
         self,


Test patch:
diff --git a/cirq-core/cirq/sim/simulator_base_test.py b/cirq-core/cirq/sim/simulator_base_test.py
--- a/cirq-core/cirq/sim/simulator_base_test.py
+++ b/cirq-core/cirq/sim/simulator_base_test.py
@@ -394,6 +394,22 @@ def _has_unitary_(self):
     assert op2.count == 2
 
 
+def test_inhomogeneous_measurement_count_padding():
+    q = cirq.LineQubit(0)
+    key = cirq.MeasurementKey('m')
+    sim = cirq.Simulator()
+    c = cirq.Circuit(
+        cirq.CircuitOperation(
+            cirq.FrozenCircuit(cirq.X(q) ** 0.2, cirq.measure(q, key=key)),
+            use_repetition_ids=False,
+            repeat_until=cirq.KeyCondition(key),
+        )
+    )
+    results = sim.run(c, repetitions=10)
+    for i in range(10):
+        assert np.sum(results.records['m'][i, :, :]) == 1
+
+
 def test_deprecated_final_step_result():
     class OldCountingSimulator(CountingSimulator):
         def _create_simulator_trial_result(  # type: ignore