Skip to content

Commit 1e0cd87

Browse files
Feature: Add utility to decompose Stim Detector Error Models (#188)
## Summary This PR introduces a new module `src/py/decomposer/decompose_errors.py` that provides functionality to decompose `stim.DetectorErrorModel` (DEM) error instructions into independent components (e.g., separating X-basis and Z-basis errors in surface code simulations). It also includes the inverse operation (`undecompose_errors`) to flatten decomposed errors back into their atomic representation. While stim has a generic inbuilt decomposer that does not require detector annotations, it is often suboptimal. The purpose of this PR is to provide a more robust implementation, that requires detector annotations. This utility allows users to: 1. Define components based on detector coordinates or IDs. 2. Automatically split composite errors into component-specific parts using the `^` separator (e.g., converting `error(p) D0 D1` into `error(p) D0 ^ D1`). 3. Ensure logical observables are correctly assigned to the appropriate component during splitting. ## Key Changes ### `src/py/decomposer/decompose_errors.py` - **`decompose_errors_using_detector_assignment`**: Core logic that iterates through a flattened DEM. It identifies "atomic" errors (those affecting only one component) and uses them to reconstruct valid decompositions for "composite" errors. - **`decompose_errors_for_stim_surface_code_coords`**: A specialized helper that decomposes errors based on standard surface code checkerboard coordinates (separating X and Z parity checks). - **`undecompose_errors`**: A utility to merge separated error components back into a single instruction, useful for verification and round-tripping. - **Helper functions**: `reduce_symmetric_difference` and `get_component_obs_matching_undecomposed_obs` to handle the constraint satisfaction required to assign observables to the correct component. ### `src/py/decomposer/decompose_errors_test.py` - Added unit tests for helper functions. - Added integration tests for 2-component and 3-component decomposition scenarios. - Added negative tests ensuring `ValueError` is raised if an error cannot be validly decomposed (e.g., missing atomic components or inconsistent observables). - Added a round-trip test (`decompose` -> `undecompose`) using a generated `surface_code:rotated_memory_x` circuit to ensure data integrity. ## Example Usage ```python import stim from src.py.decomposer.decompose_errors import decompose_errors_using_last_coordinate_index # A DEM where D0 (coord 0) and D1 (coord 1) are triggered by a single error dem = stim.DetectorErrorModel(""" detector(0) D0 detector(1) D1 error(0.1) D0 D1 L0 """) # Decompose based on the last coordinate # This splits the error into parts while maintaining the total logical effect result = decompose_errors_using_last_coordinate_index(dem) # Output: error(0.1) D0 ^ D1 L0 Co-authored-by: Noah Shutty <noajshu@users.noreply.github.com>
1 parent 1e70959 commit 1e0cd87

File tree

2 files changed

+565
-0
lines changed

2 files changed

+565
-0
lines changed

src/py/decompose_errors.py

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import stim
2+
from functools import reduce
3+
import itertools
4+
from collections import defaultdict
5+
from collections.abc import Callable, Iterable
6+
7+
8+
def reduce_symmetric_difference(items: Iterable[int]) -> tuple[int]:
9+
"""
10+
Calculates the symmetric difference of a multiset of items.
11+
12+
Returns items that appear an odd number of times in the input.
13+
"""
14+
unpaired_set = reduce(lambda acc, i: acc ^ {i}, items, set())
15+
return tuple(sorted(unpaired_set))
16+
17+
18+
def reduce_set_symmetric_difference(sets: Iterable[Iterable[int]]) -> tuple[int]:
19+
return reduce_symmetric_difference(itertools.chain.from_iterable(sets))
20+
21+
22+
def undecomposed_error_detectors_and_observables(
23+
instruction: stim.DemInstruction,
24+
) -> tuple[tuple[int], tuple[int]]:
25+
"""Outputs the indices of the detectors and observables in a stim error,
26+
undecomposing the error if necessary."""
27+
if instruction.type != "error":
28+
raise ValueError(f"DEM instruction must be an error, not {instruction.type}")
29+
detectors = reduce_symmetric_difference(
30+
d.val for d in instruction.targets_copy() if d.is_relative_detector_id()
31+
)
32+
observables = reduce_symmetric_difference(
33+
o.val for o in instruction.targets_copy() if o.is_logical_observable_id()
34+
)
35+
return detectors, observables
36+
37+
38+
def get_component_obs_matching_undecomposed_obs(
39+
obs_options_by_component: list[set[tuple[int]]], error_obs: tuple[int]
40+
) -> list[tuple[int]] | None:
41+
"""Given the possible observables that could be a symptom of each component
42+
of a dem error, find the assignment of observables to components that is
43+
consistent with the observables associated with the undecomposed error.
44+
Returns None if there is no assignment that is consistent with the observables
45+
of the undecomposed error.
46+
47+
Parameters
48+
----------
49+
obs_options_by_component : list[set[tuple[int]]]
50+
The possible observables consistent with each component. Here
51+
`obs_options_by_component[i]` is a set of tuples, where each tuple
52+
contains the indices of observables that could have been flipped by
53+
component i. For example, these could be observables flipped by
54+
an undecomposable error elsewhere in the dem that has the same detectors
55+
as the component. Note that if there is more than one choice for a given
56+
component (i.e. if `len(obs_options_by_component[i]) > 1`) then the dem
57+
must have distance at most 2. If the distance is more than 2, then this
58+
function makes the trivial assignment of assigning the only possble
59+
observables to each component.
60+
error_obs : tuple[int]
61+
The observables flipped by the undecomposed error.
62+
63+
Returns
64+
-------
65+
list[tuple[int]]
66+
Assignment of observables to each component.
67+
"""
68+
error_obs_set = set(reduce_symmetric_difference(error_obs))
69+
for obs_combinations in itertools.product(*obs_options_by_component):
70+
obs_from_combination = reduce_set_symmetric_difference(obs_combinations)
71+
if set(obs_from_combination) == error_obs_set:
72+
return list(obs_combinations)
73+
return None
74+
75+
76+
def decompose_errors_using_detector_assignment(
77+
dem: stim.DetectorErrorModel, detector_component_func: Callable[[int], int]
78+
) -> stim.DetectorErrorModel:
79+
"""Decomposes errors in the detector error model `dem` based on an assignment of
80+
detectors to components by the function `detector_component_func`.
81+
82+
An undecomposed error is an error that flips detectors that are all in the same
83+
component. A decomposed error is an error that flips detectors from more than one
84+
component, but is decomposed into components where each component corresponds
85+
to an undecomposed error elsewhere in the dem. The symmetric difference of the
86+
detectors and observables in the components of a decomposed error will equal
87+
the detectors and observables of the original error in the dem.
88+
See https://github.com/quantumlib/Stim/blob/main/doc/file_format_dem_detector_error_model.md#error-instruction
89+
for more details on the Stim ERROR instruction format, including decomposition.
90+
If the dem provided was already decomposed, this decomposition will be ignored
91+
(each error will be undecomposed before the new decomposition is applied).
92+
93+
Parameters
94+
----------
95+
dem : stim.DetectorErrorModel
96+
The detector error model to decompose.
97+
detector_component_func : Callable[[int], int]
98+
A function that maps a detector id to its component. i.e. This could map
99+
a detector index to 0 if it is X-type or to 1 if it is Z-type.
100+
101+
Returns
102+
-------
103+
stim.DetectorErrorModel
104+
The decomposed detector error model
105+
"""
106+
dem = dem.flattened()
107+
108+
single_component_dets_to_obs: dict[tuple[int], set[tuple[int]]] = defaultdict(set)
109+
110+
for instruction in dem:
111+
if instruction.type != "error":
112+
continue
113+
114+
detectors, observables = undecomposed_error_detectors_and_observables(
115+
instruction=instruction
116+
)
117+
118+
if len(set(detector_component_func(d) for d in detectors)) == 1:
119+
single_component_dets_to_obs[detectors].add(observables)
120+
121+
output_dem = stim.DetectorErrorModel()
122+
123+
for instruction in dem:
124+
if instruction.type != "error":
125+
output_dem.append(instruction)
126+
continue
127+
128+
detectors, observables = undecomposed_error_detectors_and_observables(
129+
instruction=instruction
130+
)
131+
det_components = {d: detector_component_func(d) for d in detectors}
132+
unique_components = sorted(set(det_components.values()))
133+
num_components = len(unique_components)
134+
135+
dets_by_component = []
136+
obs_options_by_component = []
137+
138+
for c in unique_components:
139+
component_dets = tuple(
140+
sorted(d for d in detectors if det_components[d] == c)
141+
)
142+
if component_dets not in single_component_dets_to_obs:
143+
raise ValueError(
144+
f"The dem error `{instruction}` needs to be decomposed into components, however "
145+
f"the component with detectors {component_dets} is not present as its own error "
146+
"in the dem."
147+
)
148+
dets_by_component.append(component_dets)
149+
obs_options_by_component.append(
150+
single_component_dets_to_obs[component_dets]
151+
)
152+
153+
# Assign observables to each component, such that they are consistent with the
154+
# observables of the undecomposed error
155+
consistent_obs_by_component = get_component_obs_matching_undecomposed_obs(
156+
obs_options_by_component=obs_options_by_component, error_obs=observables
157+
)
158+
159+
if consistent_obs_by_component is None:
160+
raise ValueError(
161+
f"The error instruction `{instruction}` could not be decomposed, due to its "
162+
"observables not being consistent with the observables of any available "
163+
f"choices of components."
164+
)
165+
166+
targets = []
167+
for i in range(num_components):
168+
targets.extend(
169+
stim.target_relative_detector_id(d) for d in dets_by_component[i]
170+
)
171+
targets.extend(
172+
stim.target_logical_observable_id(o)
173+
for o in consistent_obs_by_component[i]
174+
)
175+
if i != num_components - 1:
176+
targets.append(stim.target_separator())
177+
178+
decomposed_instruction = stim.DemInstruction(
179+
type=instruction.type,
180+
args=instruction.args_copy(),
181+
targets=targets,
182+
tag=instruction.tag,
183+
)
184+
output_dem.append(decomposed_instruction)
185+
186+
return output_dem
187+
188+
189+
def decompose_errors_using_detector_coordinate_assignment(
190+
dem: stim.DetectorErrorModel, coord_to_component_func: Callable[[list[float]], int]
191+
) -> stim.DetectorErrorModel:
192+
"""Decomposes errors in the detector error model `dem` based on an assignment of
193+
detectors to components using a function of the detector coordinates.
194+
195+
A detector with coordinates `coords` is assigned to component
196+
`coord_to_component_func(coords)`. If an error flips detectors that are all
197+
in component `i` then this error itself is assigned as an error in component `i`.
198+
This error is said to be undecomposable. If an error flips a set of detectors that
199+
belong to more than one component, then this function attempts to decompose the
200+
error into undecomposable errors (i.e. errors with detectors in a single component).
201+
For a definition of errors and decompositions see:
202+
https://github.com/quantumlib/Stim/blob/main/doc/file_format_dem_detector_error_model.md#error-instruction.
203+
204+
205+
Parameters
206+
----------
207+
dem : stim.DetectorErrorModel
208+
The detector error model to decompose
209+
coord_to_component_func : Callable[[list[float]], int]
210+
A function that coordinates of a detector to an integer corresponding to
211+
the index of a component, to be used for the decomposition. The coordinates
212+
are provided as a list of floats.
213+
214+
Returns
215+
-------
216+
stim.DetectorErrorModel
217+
The decomposed detector error model. Note that the DEM will also be flattened.
218+
"""
219+
detector_coords = dem.get_detector_coordinates()
220+
221+
def component_using_coords(detector_id: int) -> int:
222+
return coord_to_component_func(detector_coords[detector_id])
223+
224+
return decompose_errors_using_detector_assignment(
225+
dem=dem, detector_component_func=component_using_coords
226+
)
227+
228+
229+
def detector_coord_to_basis_for_stim_surface_code_convention(coord: tuple[int]) -> int:
230+
"""For detector coordinates consistent with the stim.Circuit.generated
231+
surface code circuits, return the basis from the detector coordinate.
232+
Returns 0 for X basis and 1 for Z basis detector."""
233+
x = coord[0]
234+
y = coord[1]
235+
return 1 - ((x // 2 + y // 2) % 2)
236+
237+
238+
def decompose_errors_using_last_coordinate_index(
239+
dem: stim.DetectorErrorModel,
240+
) -> stim.DetectorErrorModel:
241+
"""Decomposes errors in the detector error model `dem` based on an assignment of
242+
detectors to components by the last element of each detector coordinate.
243+
244+
An undecomposed error is an error that flips detectors that are all in the same
245+
component. A decomposed error is an error that flips detectors from more than one
246+
component, but is decomposed into components where each component corresponds
247+
to an undecomposed error elsewhere in the dem. The symmetric difference of the
248+
detectors and observables in the components of a decomposed error will equal
249+
the detectors and observables of the original error in the dem.
250+
If the dem provided was already decomposed, this decomposition will be ignored
251+
(each error will be undecomposed before the new decomposition is applied).
252+
253+
Parameters
254+
----------
255+
dem : stim.DetectorErrorModel
256+
The detector error model to decompose.
257+
258+
Returns
259+
-------
260+
stim.DetectorErrorModel
261+
The decomposed detector error model
262+
"""
263+
detector_coords = dem.get_detector_coordinates()
264+
265+
def last_coordinate_component(detector_id: int) -> int:
266+
return detector_coords[detector_id][-1]
267+
268+
return decompose_errors_using_detector_assignment(
269+
dem=dem, detector_component_func=last_coordinate_component
270+
)
271+
272+
273+
def decompose_errors_for_stim_surface_code_coords(
274+
dem: stim.DetectorErrorModel,
275+
) -> stim.DetectorErrorModel:
276+
"""Decomposes the errors in the dem, such that each component
277+
of a decomposed error only triggers detectors of one basis (X or Z)
278+
based on an assignment of detector coordinates to X or Z basis
279+
consistent with the convention used in stim.Circuit.generated
280+
surface code circuits.
281+
282+
A detector is assumed to be X-type if `(x // 2 + y // 2) % 2 == 1`
283+
and is assumed to be Z-type if `(x // 2 + y // 2) % 2 == 0` where
284+
the detector has coordinates (x, y, ...).
285+
286+
Parameters
287+
----------
288+
dem : stim.DetectorErrorModel
289+
The detector error model to decompose
290+
291+
Returns
292+
-------
293+
stim.DetectorErrorModel
294+
The decomposed detector error model
295+
"""
296+
detector_coords = dem.get_detector_coordinates()
297+
298+
def stim_surface_code_det_component(detector_id: int) -> int:
299+
return detector_coord_to_basis_for_stim_surface_code_convention(
300+
detector_coords[detector_id]
301+
)
302+
303+
return decompose_errors_using_detector_assignment(
304+
dem=dem, detector_component_func=stim_surface_code_det_component
305+
)
306+
307+
308+
def undecompose_errors(dem: stim.DetectorErrorModel) -> stim.DetectorErrorModel:
309+
"""Returns a detector error model with any error decompositions removed.
310+
311+
If an error is decomposed into components in the dem, it will be replaced with a
312+
single undecomposed error instruction (of the same probability) with detectors
313+
equal to the symmetric difference of the detectors of the components, and
314+
likewise for the observables. Repeat blocks are preserved, rather than flattened.
315+
316+
Parameters
317+
----------
318+
dem : stim.DetectorErrorModel
319+
The detector error model to undecompose
320+
321+
Returns
322+
-------
323+
stim.DetectorErrorModel
324+
The undecomposed detector error model
325+
"""
326+
undecomposed_dem = stim.DetectorErrorModel()
327+
for instruction in dem:
328+
if instruction.type == "repeat":
329+
undecomposed_dem.append(
330+
instruction=stim.DemRepeatBlock(
331+
repeat_count=instruction.repeat_count,
332+
block=undecompose_errors(instruction.body_copy()),
333+
)
334+
)
335+
continue
336+
337+
if instruction.type != "error":
338+
undecomposed_dem.append(instruction=instruction)
339+
continue
340+
341+
detectors, observables = undecomposed_error_detectors_and_observables(
342+
instruction=instruction
343+
)
344+
345+
targets = [stim.target_relative_detector_id(d) for d in detectors] + [
346+
stim.target_logical_observable_id(o) for o in observables
347+
]
348+
349+
undecomposed_dem.append(
350+
stim.DemInstruction(
351+
type=instruction.type,
352+
args=instruction.args_copy(),
353+
targets=targets,
354+
tag=instruction.tag,
355+
)
356+
)
357+
return undecomposed_dem

0 commit comments

Comments
 (0)