Skip to content

Align idle-noise vocabulary across DEM, engines and neo, and emit the requested Pauli channel - #420

Draft
ciaranra wants to merge 48 commits into
devfrom
guppy-dem-idle-docs
Draft

Align idle-noise vocabulary across DEM, engines and neo, and emit the requested Pauli channel#420
ciaranra wants to merge 48 commits into
devfrom
guppy-dem-idle-docs

Conversation

@ciaranra

@ciaranra ciaranra commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Started as idle-gate handling for the Guppy-to-DEM pipeline and grew into an alignment of the
idle-noise vocabulary across NoiseParameters (DEM), general_noise() (engines) and
exp/pecos-neo, plus a correction to what the DEM actually emits.

The two halves:

  1. One vocabulary. The same rate and the same model dictionary now mean the same thing on
    every surface, with no unit conversion and no mode flags.
  2. The DEM emits the channel it was asked for. A noise model is a mutually exclusive Pauli
    channel; a DEM is a set of independent mechanisms. Those are two unravelings of the same
    map, and the builder was not converting between them.

The workflow example is the short version — its DEM block and its simulator block are now
character-identical apart from the constructor name:

.with_p_idle_linear(0.01, {"X": 0.25, "Y": 0.25, "Z": 0.5})
.with_p_idle_sin_squared(0.03, {"Z": 1.0})

Behaviour changes reviewers should look at first

These change results or break calls. Nothing here is a silent no-op fix; each one moves numbers
or requires a call-site edit.

change impact
general_noise() is noiseless by default previously applied ten nonzero parameters (p1, p2, p_prep, p_meas_*, idle, emission/seepage ratios). .auto() restores that preset verbatim. Anything relying on implicit noise now gets silence.
DEM mechanism probabilities changed idle and gate channels are now converted from the exclusive to the independent unraveling. 1q 6.666667e-046.671117e-04; 2q 1.333333e-031.345946e-03. The 2q case was ~0.93% of the two-qubit error weight.
neo incoherent quadratic dephasing was sin²(θ), now sin²(θ/2) — the exact Pauli twirl of its own coherent RZ(θ). Changes results for anyone using incoherent quadratic idle in neo.
noise-builder setters renamed with_p1_probabilitywith_p1, with_meas_probabilitywith_p_meas, etc. Hard rename, no aliases.
with_p2_idle(probability)with_idle_after_2q(duration) different quantity, not just a new name. Old with_p2_idle(0.01) with no linear rate now produces nothing; the equivalent is with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0).
with_p_idle_coherent(bool)with_p_idle_quadratic_coherent(bool) the flag only ever selected how the quadratic rate was read. with_p_idle_coherent is now a family setter taking (rate, model) over RX/RY/RZ.
importance sampling silently ran unnoised simulation.rs discarded the configured NoiseResource before it reached the runner, so importance sampling through the high-level API applied no noise model at all. Results were near-zero failures with meaningless weights.
injected noise gates are never dropped the importance runner matched only X/Y/Z with a bare _ => {} (#436); non-Clifford gates were skipped without a rotation executor (#437). Now: executed where supported, rejected at configuration time with an actionable message, or panic as a broken invariant.
legacy engines idle setters removed with_p_idle_linear_rate, with_p_idle_linear_model, with_p_idle_quadratic_rate, with_p_idle_quadratic_coherent, with_p_idle_coherent_to_incoherent_factor, and both with_average_p_idle_* are gone (#424 ramp steps 2-3). The families replace them. The quadratic conversion is not 1:1: with_p_idle_quadratic_rate(r) becomes with_p_idle_sin_squared(r * PI, {"Z": 1.0}), because the legacy rate was cycles-per-time and was scaled by factor/2 * 2*PI internally. Copying the number across without the PI gives a channel wrong by that factor.
T1/T2 idle derivation changed from_t1_t2 / with_idle_t1_t2 now use the first-order Pauli twirl with T2 as total transverse coherence: rX = rY = 1/(4*T1), rZ = 1/(2*T2) - 1/(4*T1), quadratic zero. T2 > 2*T1 is now rejected rather than silently clamped, and short times are no longer clamped to 1. This moves numbers for every T1/T2 user; a documented example was silently producing maximal noise before (#438).
DEM idle config is rate-plus-weight-map the Rust NoiseConfig's nine per-axis f64 fields collapse into one IdleNoiseFamily { rate, weights } instantiated three times, mirroring pecos-engines' sampler shape. An unset map means symmetric X/Y/Z — a property nine flat fields could not have. Emitted DEM text is byte-identical; this is a representation change, not a physics change.
DEM per-axis idle setters removed all twelve with_p_idle_{,x_,y_,z_}{linear,quadratic,quadratic_sine}_rate setters are gone; the three families are the only public idle API. NoiseParameters(p_idle_z_linear_rate=...) now raises TypeError. Two of the removed names collided with live general_noise() setters meaning different physics. Replacement is with_p_idle_linear(r, {"Z": 1.0}) etc.; DEM fixtures pin that this reproduces the old output byte-for-byte.
neo with_p_idle_linear(rate)(rate, model) with_p_idle_linear_weights removed. exp/ crate, deliberate.

Idle-noise vocabulary

Three families on every surface, each named for its own law, each a rate plus a model:

family law model
with_p_idle_linear p = rate·m·t normalized distribution over X/Y/Z/L
with_p_idle_sin_squared p = sin²(rate·m·t) unnormalized multipliers over X/Y/Z/L
with_p_idle_coherent angle = rate·m·t unnormalized multipliers over RX/RY/RZ

Rates are radians per time unit throughout — engines' and coherent_to_incoherent_factor/2
conversions apply only to the legacy with_p_idle_quadratic_rate path, which is unchanged.
Mixing a legacy spelling with its family equivalent is a loud build-time error; those two
differ by 1.5π at the old default factor, so silent precedence between them was the bug worth
preventing.

with_idle_after_2q(duration) places idle sites on each two-qubit gate operand, matching the
DEM pass, on both engines and neo.

DEM channel conversion

Independent mechanisms compose — two firing together produce a third error — so writing the
channel's per-Pauli probabilities straight in understates every Pauli. The conversion is exact
and closed-form via the Pauli-channel characters, and runs on propagated flip-signatures
rather than Pauli labels, since a DEM mechanism is (probability, flip-set) with no Pauli
attached. At that layer undetectable Paulis drop and equal signatures merge by addition, which
collapses most apparent infeasibilities: a 2-qubit gate's 15 Paulis typically become 3
mechanisms.

Exact representability holds iff pY ≥ pX·pZ and cyclically. Realistic noise clears this by
orders of magnitude; only an exactly-Y-free channel takes a residual, bounded by pX·pZ,
reported as a total-variation distance on dem.idle_noise_residuals and warned on at build.

Two pre-existing defects surfaced and are fixed: equal signatures were merged with
combine_probabilities (the independent rule) rather than summed, undercounting mutually
exclusive Paulis by 2·p₁·p₂; and after-2q idle in neo fired only when the gate's Pauli error
also fired.

Prep and measurement are single Bernoulli events and are deliberately not converted — a
test pins that they stay exact.

Controlling the approximation warning

When a channel is not exactly representable, the builder emits the closest non-negative fit and
warns. .with_residual_warning_threshold(fraction) sets the tolerance below which that warning
is not worth raising, as a fraction of the channel's total error weight — so one value means
the same thing across a sweep, which an absolute probability cannot. Default 0.0 warns on any
residual; values outside [0, 1] are rejected, and a value above 1.0 says explicitly that the
setter is relative, since that is the likely misreading.

The threshold gates the warning only. dem.idle_noise_residuals and the audit entry stay fully
populated whatever it is set to, so a quieted approximation remains provable after the fact.
Residuals now also carry channel_weight and relative_magnitude.

Suppressing warnings wholesale remains warnings.filterwarnings' job; this setter encodes a
physics tolerance, not log-noise management.

Docs

  • docs/workflows/guppy-dem-decoding.md — five-stage worked example, hand-written repetition
    code, DEM sampling and sim() sampling, Tesseract / BP+OSD / PyMatching.
  • docs/user-guide/dem-from-guppy.md — idle-noise families, stim export, validity domain.
  • Design record: pecos-docs/design/neo-noise-event-emission.md, including the review panel
    that rejected adding event emission to NoiseResponse.

Issues resolved

Closes #436 — importance runner silently dropped every injected noise gate except X/Y/Z.
Closes #437 — non-Clifford gates silently skipped when no rotation executor is configured.
Closes #433NoiseModel renamed to NoiseParameters, lifted to pecos, fluent setters.
Closes #428 — DEM construction reshaped into the house builder pattern.
Closes #430DemAwareDecoder no longer wraps observable bits past 64.
Closes #438 — the T1/T2 idle derivation is dimensionally consistent and no longer clamps.
Closes #424 — ramp steps 2 and 3; the legacy idle spellings are removed.

Still open

  • Doc tests for qec-guppy transversal-CNOT examples fail with a HUGR execution stall #427 — pre-existing on dev, not a regression from this PR: the stalling ops live in
    crates/pecos-hugr, which this branch does not touch, and qec-guppy.md is byte-identical to
    dev. Still unisolated; needs its own bisect.
  • The Python-side canonical internals still name per-axis rates (419 production references),
    translating to the family form at the Rust boundary. Unreachable from the public API, so this
    is an implementation detail rather than an omission.

@ciaranra ciaranra added the enhancement New feature or request label Aug 3, 2026
Comment thread docs/workflows/guppy-dem-decoding.md Outdated
Comment thread docs/workflows/guppy-dem-decoding.md Outdated
Comment thread docs/workflows/guppy-dem-decoding.md Outdated

pymatching = PyMatchingDecoder.from_dem(terminal_graphlike_text)
tesseract = TesseractDecoder.from_dem(source_graphlike_text, preset="fast")
bp_osd = DemAwareDecoder.from_dem(raw_text, decoder_type="bp_osd")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should have a BpOsdDecoder or a LdpcDecoder...


pymatching_errors += pymatching.decode(syndrome).correction[0] != actual
tesseract_errors += (tesseract.decode_syndrome(syndrome).observables_mask & 1) != actual
bp_osd_errors += (bp_osd.decode_syndrome(syndrome).observables_mask & 1) != actual

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should think about having a nice convenience function for each of these decoders that looks nice/same name...

Comment thread docs/workflows/guppy-dem-decoding.md
Comment thread docs/workflows/guppy-dem-decoding.md Outdated
Comment thread docs/workflows/guppy-dem-decoding.md
.with_p_idle_linear_model({"X": 0.25, "Y": 0.25, "Z": 0.5})
.with_p_idle_coherent(False)
.with_p_idle_quadratic_rate(0.03 / math.pi)
.with_idle_after_2q(1.0)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

do the general_noise().with_idle_after_2q(...) ignore other idles by default like the DEM builder does... should we have and option to not ignore other idles?

@ciaranra ciaranra changed the title Idle-gate passes and fail-loud idle-noise guard for the Guppy DEM pipeline Align idle-noise vocabulary across DEM, engines and neo, and emit the requested Pauli channel Aug 5, 2026
)

dem_build = (
DetectorErrorModel.builder()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

does the builder have .runtime(...) that accepts Selene-shaped runtime plugins?

.with_p_prep(0.001)
.with_p_meas(0.001)
.with_single_qubit_probability(0.0005)
.with_two_qubit_probability(0.002)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this should be using with_p1(...), with_p2(...) etc...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment