Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/sphinx/api/qec/cpp_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Detector Error Model
.. doxygenfunction:: cudaq::qec::dem_from_memory_circuit(const code &, operation, std::size_t, cudaq::noise_model &)
.. doxygenfunction:: cudaq::qec::x_dem_from_memory_circuit(const code &, operation, std::size_t, cudaq::noise_model &)
.. doxygenfunction:: cudaq::qec::z_dem_from_memory_circuit(const code &, operation, std::size_t, cudaq::noise_model &)
.. doxygenfunction:: cudaq::qec::dem_from_stim_text(const std::string &)
.. doxygenfunction:: cudaq::qec::dem_from_stim_text(const std::string &, bool)

Decoder Interfaces
==================
Expand Down
5 changes: 5 additions & 0 deletions docs/sphinx/examples/qec/cpp/stim_dem_decoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ int main() {
const std::string dem_text = R"(error(0.1) D0 L0
error(0.1) D1 L0
error(0.05) D0 D1
error(0.02) D0 ^ D1
)";

auto decoder = cudaq::qec::get_decoder("single_error_lut", dem_text);
auto dem = cudaq::qec::dem_from_stim_text(dem_text);
auto dem_decomposed =
cudaq::qec::dem_from_stim_text(dem_text, /*decompose_errors=*/true);

std::cout << "detectors: " << dem.num_detectors() << "\n";
std::cout << "error mechanisms: " << dem.num_error_mechanisms() << "\n";
std::cout << "observables: " << dem.num_observables() << "\n";
std::cout << "error mechanisms (decomposed): "
<< dem_decomposed.num_error_mechanisms() << "\n";

const std::vector<std::vector<cudaq::qec::float_t>> syndromes = {
{0.0, 0.0}, {1.0, 0.0}, {0.0, 1.0}, {1.0, 1.0}};
Expand Down
3 changes: 3 additions & 0 deletions docs/sphinx/examples/qec/python/stim_dem_decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
error(0.1) D0 L0
error(0.1) D1 L0
error(0.05) D0 D1
error(0.02) D0 ^ D1
"""

decoder = qec.get_decoder("single_error_lut", dem_text)
dem = qec.dem_from_stim_text(dem_text)
dem_decomposed = qec.dem_from_stim_text(dem_text, decompose_errors=True)

print("detectors:", dem.num_detectors())
print("error mechanisms:", dem.num_error_mechanisms())
print("observables:", dem.num_observables())
print("error mechanisms (decomposed):", dem_decomposed.num_error_mechanisms())

syndromes = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=np.uint8)
results = decoder.decode_batch(syndromes)
Expand Down
7 changes: 6 additions & 1 deletion libs/qec/include/cudaq/qec/detector_error_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ struct detector_error_model {

/// Parse Stim DEM text into detector/observable flip matrices and error rates.
/// DEM-native decoders should consume raw DEM text instead.
detector_error_model dem_from_stim_text(const std::string &dem_text);
/// If @p decompose_errors is true, error mechanisms that carry an explicit
/// graphlike decomposition (components separated by '^' in the DEM text) are
/// expanded into one column per component; otherwise the '^' separators are
/// ignored and each error instruction produces a single column.
detector_error_model dem_from_stim_text(const std::string &dem_text,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changing the only declaration from dem_from_stim_text(const std::string&) to dem_from_stim_text(const std::string&, bool) preserves source compatibility via the default argument, but removes the old exported C++ symbol. Any already-built downstream code/plugin linked against the one-argument symbol would fail to load.

Can we keep the one-argument overload and add a separate two-argument overload? The one-argument overload can simply delegate to dem_from_stim_text(dem_text, false).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I agree that this is somewhat of a breaking change. However since we don't have a hard downstream dependency (we typically expect downstream to rebuild against the latest main/release), I would prefer that we don't add this overload just to preserve an old mangled symbol.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

That makes sense. If we expect downstream consumers to rebuild against the latest main/release, I’m fine not preserving the old one-arg mangled symbol here. Thanks for clarifying.

bool decompose_errors = false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I want a refund on my high hopes due this name decompose_errors ;). I thought it meant that we're (cudaqx) doing the decomposition. Can we use something like use_stim_err_decomp or some other concise name to indicate that this flags toggles whether we'll ignore or use the stim decomposition? Feel free to suggest a better name. I don't quite like what I suggested but can't think of a better one


} // namespace cudaq::qec
42 changes: 35 additions & 7 deletions libs/qec/lib/detector_error_model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@

namespace cudaq::qec {

detector_error_model dem_from_stim_text(const std::string &dem_text) {
detector_error_model dem_from_stim_text(const std::string &dem_text,
bool decompose_errors) {
auto dem = [&dem_text]() {
try {
return stim::DetectorErrorModel(dem_text);
Expand Down Expand Up @@ -50,11 +51,10 @@ detector_error_model dem_from_stim_text(const std::string &dem_text) {
std::to_string(prob) +
" out of range [0, 1] at instruction index " +
std::to_string(instruction_index));

std::vector<std::size_t> dets;
std::vector<std::size_t> obs;
for (const auto &target : inst.target_data) {
if (target.is_separator())
continue;
auto push_target = [&](const stim::DemTarget &target) {
if (target.is_relative_detector_id()) {
dets.push_back(static_cast<std::size_t>(target.val()));
} else if (target.is_observable_id()) {
Expand All @@ -66,10 +66,38 @@ detector_error_model dem_from_stim_text(const std::string &dem_text) {
") contains an unsupported target kind; only D* (detector) and "
"L* (observable) targets are supported by the fallback parser");
}
};

if (decompose_errors) {
// Each segment delimited by '^' in the DEM text becomes its own column.
auto flush = [&]() {
if (!dets.empty() || !obs.empty()) {
detector_hits.push_back(dets);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This stores the raw component before duplicate detector targets are XOR-cancelled. For example, D0 D0 ^ D1 later becomes an all-zero detector column with a nonzero rate. That is not graphlike and will break consumers such as the PyMatching plugin, which requires each column to have one or two detector endpoints.

Can we canonicalize/XOR-cancel each component before appending, then reject decomposed components with 0 or more than 2 detector endpoints instead of emitting them?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I agree that it probably makes the most sense to remove all-zero columns, but it looks like there is an existing test case that expects it as an output:

TEST(StimDemGetDecoder, RepeatedDetectorOrObservableTargetsXorFold) {
const std::string dem_text = R"(error(0.1) D0 D0
error(0.1) L0 L0
)";
auto dem = cudaq::qec::dem_from_stim_text(dem_text);
ASSERT_EQ(dem.num_detectors(), 1u);
ASSERT_EQ(dem.num_observables(), 1u);
ASSERT_EQ(dem.num_error_mechanisms(), 2u);
EXPECT_EQ(dem.detector_error_matrix.at({0u, 0u}), 0u)
<< "duplicate D0 in error 0 should XOR-cancel to 0";
EXPECT_EQ(dem.observables_flips_matrix.at({0u, 1u}), 0u)
<< "duplicate L0 in error 1 should XOR-cancel to 0";
}

Does it make sense for these all-zero columns to be removed only when decompose_errors=true, or to change this test?

observable_hits.push_back(obs);
rates.push_back(prob);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This changes the probability semantics for decomposed Stim errors. In Stim, error(p) A ^ B is still one correlated error mechanism with a suggested decomposition, but this creates multiple CUDA-Q columns each with probability p. Existing CUDA-Q consumers treat error_rates / error_rate_vec as per-column independent probabilities, so this would make the split components independently sampleable/weighted.

Can we avoid exposing decomposed columns as independent error mechanisms, or preserve the correlation explicitly, e.g. with shared error_ids plus clear consumer support? Otherwise decompose_errors=True should probably not populate ordinary error_rates in a way that looks probability-preserving.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I agree with Vedika's concern that this changes the semantics of the error rate vec. I do think that we need to have both meanings. Since error_rates has an established meaning in this codebase (physical error rates), I suggest that we add another way rather than overloading the existing meaning of error_rates

dets.clear();
obs.clear();
}
};
for (const auto &target : inst.target_data) {
if (target.is_separator()) {
flush();
} else {
push_target(target);
}
}
flush();
} else {
// Ignore '^' separators; all targets become a single column.
for (const auto &target : inst.target_data) {
if (target.is_separator())
continue;
push_target(target);
}
detector_hits.push_back(std::move(dets));
observable_hits.push_back(std::move(obs));
rates.push_back(prob);
}
detector_hits.push_back(std::move(dets));
observable_hits.push_back(std::move(obs));
rates.push_back(prob);
++instruction_index;
});

Expand Down
13 changes: 9 additions & 4 deletions libs/qec/python/bindings/py_decoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -766,10 +766,15 @@ void bindDecoder(nb::module_ &mod) {
)pbdoc",
nb::arg("num_syndromes_per_round"));

qecmod.def(
"dem_from_stim_text", &dem_from_stim_text,
"Parse a Stim detector error model string into a DetectorErrorModel.",
nb::arg("dem_text"));
qecmod.def("dem_from_stim_text", &dem_from_stim_text,
R"pbdoc(
Parse a Stim detector error model string into a DetectorErrorModel.

Args:
dem_text: A Stim detector error model string.
decompose_errors: If error mechanism separated by ``^`` are decomposed
)pbdoc",
nb::arg("dem_text"), nb::arg("decompose_errors") = false);

// Expose decorator function that handles inheritance
qecmod.def("decoder", [&](const std::string &name) {
Expand Down
78 changes: 78 additions & 0 deletions libs/qec/python/tests/test_decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,84 @@ def test_dem_from_stim_text_explicit_parse_then_get_decoder():
assert decoder.get_block_size() == 3


def test_dem_from_stim_text_decompose_errors():
dem_text = ("error(0.05) D0 D1 L0\n"
"error(0.03) D2 L1\n"
"error(0.1) D0 D2 ^ D1 D3\n")

# ── decompose_errors=False
dem_no = qec.dem_from_stim_text(dem_text, decompose_errors=False)
assert dem_no.num_detectors() == 4
assert dem_no.num_observables() == 2
assert dem_no.num_error_mechanisms() == 3

# Also confirm that the default matches explicit False.
assert qec.dem_from_stim_text(dem_text).num_error_mechanisms() == 3

explicit_H_no = np.array([[1, 0, 1], [1, 0, 1], [0, 1, 1], [0, 0, 1]],
dtype=np.uint8)
explicit_O_no = np.array([[1, 0, 0], [0, 1, 0]], dtype=np.uint8)

np.testing.assert_array_equal(
np.array(dem_no.detector_error_matrix, dtype=np.uint8), explicit_H_no)
np.testing.assert_array_equal(
np.array(dem_no.observables_flips_matrix, dtype=np.uint8),
explicit_O_no)
np.testing.assert_allclose(dem_no.error_rates, [0.05, 0.03, 0.1],
atol=1e-12)

# ── decompose_errors=True
dem_yes = qec.dem_from_stim_text(dem_text, decompose_errors=True)
assert dem_yes.num_detectors() == 4
assert dem_yes.num_observables() == 2
assert dem_yes.num_error_mechanisms() == 4 # instruction 3 splits into 2

explicit_H_yes = np.array(
[[1, 0, 1, 0], [1, 0, 0, 1], [0, 1, 1, 0], [0, 0, 0, 1]],
dtype=np.uint8)
explicit_O_yes = np.array([[1, 0, 0, 0], [0, 1, 0, 0]], dtype=np.uint8)

np.testing.assert_array_equal(
np.array(dem_yes.detector_error_matrix, dtype=np.uint8), explicit_H_yes)
np.testing.assert_array_equal(
np.array(dem_yes.observables_flips_matrix, dtype=np.uint8),
explicit_O_yes)
np.testing.assert_allclose(dem_yes.error_rates, [0.05, 0.03, 0.1, 0.1],
atol=1e-12)


def test_dem_from_stim_text_decompose_errors_edge_cases():
A = lambda d: np.array(d, dtype=np.uint8)

# 1. No '^' in DEM — decompose_errors=True must be a no-op.
dem_text = "error(0.1) D0 D1 L0\nerror(0.2) D1 D2\n"
no = qec.dem_from_stim_text(dem_text, decompose_errors=False)
yes = qec.dem_from_stim_text(dem_text, decompose_errors=True)
np.testing.assert_array_equal(A(no.detector_error_matrix),
A(yes.detector_error_matrix))
np.testing.assert_array_equal(A(no.observables_flips_matrix),
A(yes.observables_flips_matrix))
np.testing.assert_allclose(no.error_rates, yes.error_rates, atol=1e-12)

# 2. Observable flips split across components — each L stays with its '^' segment.
# error(0.1) D0 L0 ^ D1 L1 → col0: D0+L0, col1: D1+L1
dem_text = "error(0.1) D0 L0 ^ D1 L1\n"
dem = qec.dem_from_stim_text(dem_text, decompose_errors=True)
assert dem.num_error_mechanisms() == 2
np.testing.assert_array_equal(A(dem.detector_error_matrix),
A([[1, 0], [0, 1]]))
np.testing.assert_array_equal(A(dem.observables_flips_matrix),
A([[1, 0], [0, 1]]))

# 3. Repeated detector within one component XOR-cancels to 0.
# error(0.1) D0 D0 ^ D1 → col0: D0 appears twice → cancels; col1: D1
dem_text = "error(0.1) D0 D0 ^ D1\n"
dem = qec.dem_from_stim_text(dem_text, decompose_errors=True)
assert dem.num_error_mechanisms() == 2
np.testing.assert_array_equal(A(dem.detector_error_matrix),
A([[0, 0], [0, 1]]))


def test_get_decoder_rejects_malformed_stim_dem_text():
with pytest.raises(RuntimeError):
qec.get_decoder("single_error_lut", "not a valid DEM")
Expand Down
Loading