diff --git a/src/qldpc/circuits/__init__.py b/src/qldpc/circuits/__init__.py index 8d5cd41cf..b58342ffd 100644 --- a/src/qldpc/circuits/__init__.py +++ b/src/qldpc/circuits/__init__.py @@ -41,6 +41,7 @@ NoiseModel, NoiseRule, SI1000NoiseModel, + TargetedNoiseRule, as_noiseless_circuit, ) from .transversal import ( @@ -83,6 +84,7 @@ "NoiseModel", "NoiseRule", "SI1000NoiseModel", + "TargetedNoiseRule", "as_noiseless_circuit", "get_transversal_automorphism_group", "get_transversal_circuit", diff --git a/src/qldpc/circuits/noise_model.py b/src/qldpc/circuits/noise_model.py index 70062257a..0f28dcd5e 100644 --- a/src/qldpc/circuits/noise_model.py +++ b/src/qldpc/circuits/noise_model.py @@ -1,8 +1,10 @@ """Implementation of noise models for Stim circuits The main components of this module are: -- NoiseRule: Defines how to add noise to individual operations. - NoiseModel: Defines how noise is added to circuits. +- NoiseRule: Defines how to add noise to operations according to their gate type. +- TargetedNoiseRule: Defines how to add noise to individual operations according to gate type and + targeted qubits. - Built-in noise models: DepolarizingNoiseModel and the superconducting-inspired SI1000NoiseModel. Examples of basic usage with a predefined noise model: @@ -48,6 +50,7 @@ from __future__ import annotations import collections +import warnings from collections.abc import Collection, Iterable, Iterator import stim @@ -185,113 +188,6 @@ def as_noiseless_circuit(circuit: stim.Circuit) -> stim.Circuit: return noiseless_circuit -class NoiseRule: - """Describes how to add noise to an operation. - - This class encapsulates the noise channels and measurement error probabilities that should be - applied to a particular type of quantum operation. - """ - - def __init__( - self, - *, - after: dict[str, float | Iterable[float]] = {}, - readout_error: float = 0, - reset_error: float = 0, - ): - """Initializes a noise rule with specified error channels. - - Args: - after: A dictionary mapping noise channel names to their probability arguments. For - example, {"DEPOLARIZE2": 0.01, "PAULI_CHANNEL_1": [0.02, 0, 0]} will add two-qubit - depolarization with parameter 0.01, followed by 2% bit-flip noise. These noise - channels occur after all other operations in the moment and are applied to the same - targets as the relevant operation. - readout_error: The probability that a measurement result is reported incorrectly. Only - allowed for operations that produce measurement results. - reset_error: The probability that a qubit is reset to the wrong state. Only allowed for - operations that reset qubits. - - Raises: - ValueError: If any noise channel name is not recognized or if any net probability of an - error is not between 0 and 1 (inclusive). - """ - self.readout_error = readout_error - if not (0 <= readout_error <= 1): - raise ValueError(f"{readout_error=} is not between 0 and 1") - - self.reset_error = reset_error - if not (0 <= reset_error <= 1): - raise ValueError(f"{reset_error=} is not between 0 and 1") - - self.after = { - op: tuple(prob_or_probs) if isinstance(prob_or_probs, Iterable) else (prob_or_probs,) - for op, prob_or_probs in after.items() - } - for op, probs in self.after.items(): - if OP_TYPES[op] != NOISE: - raise ValueError(f"Invalid or unrecognized noise channel {op} in {after=}") - if not (0 <= sum(probs) <= 1): - raise ValueError( - f"The net probability of an error is not between 0 and 1 in {after=}" - ) - - def __bool__(self) -> bool: - """Is this noise rule nontrivial?""" - return bool(self.after) or bool(self.readout_error) or bool(self.reset_error) - - def noisy_operation( - self, op: stim.CircuitInstruction, *, immune_qubits: set[int] = set() - ) -> tuple[stim.CircuitInstruction, stim.Circuit]: - """Apply this noise rule to the given operation. - - Args: - op: The operation to add noise to. - - Returns: - stim.CircuitInstruction: The given operation possibly modified to account for noise. - stim.Circuit: Noise operations that should follow the given operation. - """ - targets = op.targets_copy() - if immune_qubits and any( - ( - target.is_qubit_target - or target.is_x_target - or target.is_y_target - or target.is_z_target - ) - and target.value in immune_qubits - for target in targets - ): - return op, stim.Circuit() - - args = op.gate_args_copy() - if self.readout_error: - assert op.name in JUST_MEASURE_OPS or op.name in MEASURE_AND_RESET_OPS - if not args: - args = [self.readout_error] - else: - assert len(args) == 1 - # combine bit-flip probabilities - args = [1 - (1 - self.readout_error) * (1 - args[0])] - - noisy_op = stim.CircuitInstruction(op.name, targets, args, tag=op.tag) - noise_after = stim.Circuit() - - qubit_targets = [target.value for target in targets if not target.is_combiner] - if self.reset_error: - assert op.name in JUST_RESET_OPS or op.name in MEASURE_AND_RESET_OPS - error_name = ("X" if _get_standardized_name(op)[-1] != "X" else "Z") + "_ERROR" - error_op = stim.CircuitInstruction(error_name, qubit_targets, [self.reset_error]) - noise_after.append(error_op) - - for op_name, args in self.after.items(): - error_op = stim.CircuitInstruction(op_name, qubit_targets, args) - noise_after.append(error_op) - - return noisy_op, noise_after - - class NoiseModel: """A model that defines how to add noise to quantum circuits. @@ -310,14 +206,14 @@ def __init__( idle_error: float | None = None, additional_error_waiting_for_m_or_r: float | None = None, rules: dict[str, NoiseRule] | None = None, - ): + ) -> None: """Initializes a noise model with specified parameters. Args: - clifford_1q_error: Default noise rule or depolarization probability for one-qubit unitary - Clifford gates. - clifford_2q_error: Default noise rule or depolarization probability for two-qubit unitary - Clifford gates. + clifford_1q_error: Default noise rule or depolarization probability for one-qubit + unitary Clifford gates. + clifford_2q_error: Default noise rule or depolarization probability for two-qubit + unitary Clifford gates. readout_error: Default probability of flipping measurement results. reset_error: Default probability of resetting qubits to the wrong state. idle_error: Probability of depolarization for each idling qubit in any given moment. @@ -392,6 +288,7 @@ def noisy_circuit( system_qubits: Collection[int] | None = None, immune_qubits: Collection[int] | None = None, immune_op_tag: str = DEFAULT_IMMUNE_OP_TAG, + marginalize: bool = False, insert_ticks: bool = True, ) -> stim.Circuit: f"""Construct a noisy version of the given circuit. @@ -410,6 +307,9 @@ def noisy_circuit( noiseless. Default: "{DEFAULT_IMMUNE_OP_TAG}". insert_ticks: If True, automatically inserts TICK operations to prevent qubit reuse conflicts. If False, assumes that this preprocessing is not necessary. + marginalize: If True, marginalize 2-qubit noise on the absense of errors on noise-immune + qubits. If False, gates that address noise-immune qubits are also noiseless. + Default: False. Returns: stim.Circuit: A noisy version of the input circuit. @@ -442,6 +342,7 @@ def noisy_circuit( moment_or_repeat_block.body_copy(), system_qubits=system_qubits, immune_qubits=immune_qubits, + marginalize=marginalize, ) noisy_body.append("TICK") noisy_circuit.append( @@ -458,6 +359,7 @@ def noisy_circuit( system_qubits=system_qubits, immune_qubits=immune_qubits, immune_op_tag=immune_op_tag, + marginalize=marginalize, ) return noisy_circuit @@ -470,6 +372,7 @@ def _inplace_append_noisy_moment( system_qubits: set[int], immune_qubits: set[int], immune_op_tag: str, + marginalize: bool, ) -> None: """Apps noise to a moment and appends it to a circuit (in-place). @@ -483,17 +386,19 @@ def _inplace_append_noisy_moment( immune_qubits: Set of all qubits that should not have noise applied to them. immune_op_tag: If an operation contains this string in its tag, that operation is noiseless. + marginalize: If True, marginalize 2-qubit noise onto surviving qubits instead of + removing. """ noise_after_moment = stim.Circuit() for op in moment: if immune_op_tag in op.tag or (rule := self.get_noise_rule(op)) is None: circuit.append(op) else: - noisy_op, after = rule.noisy_operation(op, immune_qubits=immune_qubits) + noisy_op, after = rule.noisy_operation(op) circuit.append(noisy_op) noise_after_moment += after - circuit += noise_after_moment + circuit += immunize_noise(noise_after_moment, immune_qubits, marginalize=marginalize) moment_was_noisy = any(immune_op_tag not in op.tag for op in moment) if moment_was_noisy and self.idle_error or self.additional_error_waiting_for_m_or_r: @@ -514,8 +419,8 @@ def _inplace_append_idle_errors( ) -> None: """Append idling errors from the given moment to the given circuit. - This method identifies which qubits are idle during a moment and applies depolarization noise - to them according to the noise model parameters. + This method identifies which qubits are idle during a moment and applies depolarization + noise to them according to the noise model parameters. Args: circuit: The circuit to append idle error operations to. @@ -613,6 +518,302 @@ def __init__(self, p: float) -> None: ) +class NoiseRule: + """Describes how to add noise to an operation. + + This class encapsulates the noise channels and measurement error probabilities that should be + applied to a particular type of quantum operation. + """ + + def __init__( + self, + *, + after: dict[str, float | Iterable[float]] = {}, + readout_error: float = 0, + reset_error: float = 0, + ) -> None: + """Initializes a noise rule with specified error channels. + + Args: + after: A dictionary mapping noise channel names to their probability arguments. For + example, {"DEPOLARIZE2": 0.01, "PAULI_CHANNEL_1": [0.02, 0, 0]} will add two-qubit + depolarization with parameter 0.01, followed by 2% bit-flip noise. These noise + channels occur after all other operations in the moment and are applied to the same + targets as the relevant operation. + readout_error: The probability that a measurement result is reported incorrectly. Only + allowed for operations that produce measurement results. + reset_error: The probability that a qubit is reset to the wrong state. Only allowed for + operations that reset qubits. + + Raises: + ValueError: If any noise channel name is not recognized or if any net probability of an + error is not between 0 and 1 (inclusive). + """ + self.readout_error = readout_error + if not (0 <= readout_error <= 1): + raise ValueError(f"{readout_error=} is not between 0 and 1") + + self.reset_error = reset_error + if not (0 <= reset_error <= 1): + raise ValueError(f"{reset_error=} is not between 0 and 1") + + self.after = { + op: tuple(prob_or_probs) if isinstance(prob_or_probs, Iterable) else (prob_or_probs,) + for op, prob_or_probs in after.items() + } + for op, probs in self.after.items(): + if OP_TYPES[op] != NOISE: + raise ValueError(f"Invalid or unrecognized noise channel {op} in {after=}") + if not (0 <= sum(probs) <= 1): + raise ValueError( + f"The net probability of an error is not between 0 and 1 in {after=}" + ) + + def __bool__(self) -> bool: + """Is this noise rule nontrivial?""" + return bool(self.after) or bool(self.readout_error) or bool(self.reset_error) + + def noisy_operation( + self, op: stim.CircuitInstruction + ) -> tuple[stim.CircuitInstruction, stim.Circuit]: + """Apply this noise rule to the given operation. + + Args: + op: The operation to add noise to. + + Returns: + stim.CircuitInstruction: The given operation possibly modified to account for noise. + stim.Circuit: Noise operations that should follow the given operation. + """ + targets = op.targets_copy() + args = op.gate_args_copy() + + if self.readout_error: + assert op.name in JUST_MEASURE_OPS or op.name in MEASURE_AND_RESET_OPS + if not args: + args = [self.readout_error] + else: + assert len(args) == 1 + # combine the bit-flip probabilities self.readout_error and args[0] + args = [1 - (1 - self.readout_error) * (1 - args[0])] + + noisy_op = stim.CircuitInstruction(op.name, targets, args, tag=op.tag) + noise_after = stim.Circuit() + + if self.reset_error: + assert op.name in JUST_RESET_OPS or op.name in MEASURE_AND_RESET_OPS + qubit_targets = [target.value for target in targets if not target.is_combiner] + error_name = ("X" if _get_standardized_name(op)[-1] != "X" else "Z") + "_ERROR" + noise_after.append( + stim.CircuitInstruction(error_name, qubit_targets, [self.reset_error]) + ) + + noise_after += self._build_noise_after(op) + + return noisy_op, noise_after + + def _build_noise_after(self, op: stim.CircuitInstruction) -> stim.Circuit: + """Build the extra noise circuit to append after the given operation. + + Subclasses may override this to customize the noise that follows an operation. Reset errors + are excluded here and handled separately in NoiseRule.noisy_operation. + + Args: + op: The operation being applied. + + Returns: + stim.Circuit: Additional noise to append after the operation. + """ + qubit_targets = [target.value for target in op.targets_copy() if not target.is_combiner] + noise = stim.Circuit() + for op_name, args in self.after.items(): + noise.append(stim.CircuitInstruction(op_name, qubit_targets, args)) + return noise + + +class TargetedNoiseRule(NoiseRule): + """Describes how to add noise to a specific circuit instruction on specific qubits. + + Unlike NoiseRule, which applies to all operations of a given type, this rule matches only an + exact gate-and-target combination, allowing fine-grained per-operation noise overrides. + """ + + def __init__( + self, + *, + noisy_op: stim.CircuitInstruction, + noise: stim.Circuit, + readout_error: float = 0, + reset_error: float = 0, + tags: Collection[str] | None = None, + ) -> None: + """Initializes a targeted noise rule for a specific circuit instruction. + + Args: + noisy_op: The circuit instruction that this rule targets. Defines the gate name and + qubit targets to match against. Gate args on this instruction are ignored during + matching. + noise: An explicit noise circuit to append after the matched operation. + readout_error: The probability that a measurement result is reported incorrectly. Only + allowed for operations that produce measurement results. + reset_error: The probability that a qubit is reset to the wrong state. Only allowed for + operations that reset qubits. + tags: If not None, only match operations whose tag exactly matches one of the given + strings. If None, match operations regardless of their tag. + + Raises: + ValueError: If readout_error or reset_error is not between 0 and 1 (inclusive). + """ + super().__init__(readout_error=readout_error, reset_error=reset_error) + self.noisy_op = noisy_op + self.noise = noise + self.tags: frozenset[str] | None = frozenset(tags) if tags is not None else None + + def __bool__(self) -> bool: + """Is this noise rule nontrivial?""" + nontrivial_noise = bool(self.noise) or bool(self.readout_error) or bool(self.reset_error) + nontrivial_targets = bool(self.noisy_op.targets_copy()) + return nontrivial_noise and nontrivial_targets + + def is_targeted_noisy_op(self, op: stim.CircuitInstruction) -> bool: + """Determine whether the given operation matches this rule's target instruction. + + Two operations match if they have the same gate name and the same qubit target values in the + same order. Gate args are ignored. If self.tags is not None, op.tag must exactly match an + element of self.tags. + + Args: + op: The circuit instruction to check. + + Returns: + True if op matches this rule's target instruction. False otherwise. + """ + if op.name != self.noisy_op.name or (self.tags is not None and op.tag not in self.tags): + return False + return op.targets_copy() == self.noisy_op.targets_copy() + + def noisy_operation( + self, op: stim.CircuitInstruction + ) -> tuple[stim.CircuitInstruction, stim.Circuit]: + """Apply this targeted noise rule to the given operation. + + Args: + op: The operation to add noise to. + + Returns: + stim.CircuitInstruction: The given operation, possibly modified to account for readout + or reset errors. + stim.Circuit: Noise operations that should follow the given operation. + """ + if not self.is_targeted_noisy_op(op): + return op, stim.Circuit() + return super().noisy_operation(op) + + def _build_noise_after(self, op: stim.CircuitInstruction) -> stim.Circuit: + return self.noise + + +# PAULI_CHANNEL_2 arg order: +# IX(0), IY(1), IZ(2), XI(3), XX(4), XY(5), XZ(6), YI(7), YX(8), YY(9), YZ(10), ZI(11), ZX(12), ZY(13), ZZ(14) +# Marginal indices when the second qubit is immune (surviving: first qubit, non-cross terms: XI, YI, ZI): +_PC2_SECOND_IMMUNE_INDICES = (3, 7, 11) +# Marginal indices when the first qubit is immune (surviving: second qubit, non-cross terms: IX, IY, IZ): +_PC2_FIRST_IMMUNE_INDICES = (0, 1, 2) + + +def _marginalize_2q_noise( + noise_op: stim.CircuitInstruction, immune_qubits: set[int], *, marginalize: bool +) -> stim.Circuit: + """Filter or marginalize a 2-qubit noise instruction over immune qubits. + + Processes each pair of targets independently. Pairs with no immune qubits are kept as-is. + Pairs where both qubits are immune are dropped. For partially-immune pairs, if marginalize is + True the surviving qubit receives a 1-qubit marginal (ignoring cross-Pauli terms); if False the + pair is dropped. Only DEPOLARIZE2 and PAULI_CHANNEL_2 support marginalization; other 2-qubit + channels emit a warning and are dropped for partially-immune pairs. + + Args: + noise_op: A 2-qubit noise instruction. + immune_qubits: Set of qubit indices that should not receive noise. + marginalize: If True, emit a 1-qubit marginal for partially-immune pairs. + + Returns: + stim.Circuit: The filtered/marginalized circuit for this instruction. + """ + result = stim.Circuit() + name = noise_op.name + args = noise_op.gate_args_copy() + targets = noise_op.targets_copy() + + for i in range(0, len(targets), 2): + q1, q2 = targets[i], targets[i + 1] + q1_immune = q1.value in immune_qubits + q2_immune = q2.value in immune_qubits + + if not q1_immune and not q2_immune: + result.append(stim.CircuitInstruction(name, [q1, q2], args)) + elif q1_immune and q2_immune: + pass # both immune: skip + elif not marginalize: + pass # partially immune, no marginalization: drop the pair + elif name == "DEPOLARIZE2": + p = args[0] + surviving = q2.value if q1_immune else q1.value + result.append(stim.CircuitInstruction("DEPOLARIZE1", [surviving], [p / 5])) + elif name == "PAULI_CHANNEL_2": + if q2_immune: + marginal = [args[idx] for idx in _PC2_SECOND_IMMUNE_INDICES] + surviving = q1.value + else: + marginal = [args[idx] for idx in _PC2_FIRST_IMMUNE_INDICES] + surviving = q2.value + result.append(stim.CircuitInstruction("PAULI_CHANNEL_1", [surviving], marginal)) + else: # pragma: no cover + warnings.warn( + f"Cannot marginalize {name} over immune qubits; noise is dropped.", + stacklevel=2, + ) + + return result + + +def immunize_noise( + noise: stim.Circuit, immune_qubits: set[int], *, marginalize: bool = False +) -> stim.Circuit: + """Return a copy of a flat noise circuit with instructions targeting immune qubits removed. + + An instruction is removed if any of its qubit targets belongs to immune_qubits. If marginalize + is True, DEPOLARIZE2 and PAULI_CHANNEL_2 instructions where only one qubit in a pair is immune + are replaced by the marginal 1-qubit noise channel on the surviving qubit (ignoring cross-Pauli + terms). + + Args: + noise: A flat noise circuit (no repeat blocks) to filter. + immune_qubits: Set of qubit indices that should not have noise applied to them. + marginalize: If True, marginalize 2-qubit noise onto surviving qubits instead of removing. + + Returns: + stim.Circuit: A filtered copy of the input circuit. + """ + if not immune_qubits: + return noise + result = stim.Circuit() + for noise_op in noise: + assert isinstance(noise_op, stim.CircuitInstruction) + if all(t.value not in immune_qubits for t in noise_op.targets_copy() if not t.is_combiner): + result.append(noise_op) + elif stim.gate_data(noise_op.name).is_two_qubit_gate: + result += _marginalize_2q_noise(noise_op, immune_qubits, marginalize=marginalize) + else: + # 1-qubit noise with multiple targets: keep only non-immune targets + surviving = [t for t in noise_op.targets_copy() if t.value not in immune_qubits] + if surviving: + result.append( + stim.CircuitInstruction(noise_op.name, surviving, noise_op.gate_args_copy()) + ) + return result + + def _get_standardized_name(op: stim.CircuitInstruction) -> str: """Stardardized name of a circuit instruction. diff --git a/src/qldpc/circuits/noise_model_test.py b/src/qldpc/circuits/noise_model_test.py index 29b041ec7..5c507628e 100644 --- a/src/qldpc/circuits/noise_model_test.py +++ b/src/qldpc/circuits/noise_model_test.py @@ -103,6 +103,123 @@ def test_gate_errors() -> None: noise_model.noisy_circuit(circuit, insert_ticks=False) +def test_targeted_gate_errors() -> None: + """TargetedNoiseRule applies noise only to exact gate-and-qubit matches.""" + + # only CX 0 1 gets depolarizing noise; CX 1 2 is unaffected + circuit = stim.Circuit(""" + CX 0 1 1 2 + TICK + M 0 + RX 1 + MR 2 + """) + + targeted_cx = circuits.TargetedNoiseRule( + noisy_op=stim.CircuitInstruction("CX", [0, 1]), + noise=stim.Circuit("DEPOLARIZE2(0.2) 0 1"), + ) + noise_model = circuits.NoiseModel(clifford_2q_error=targeted_cx) + assert _circuits_are_equivalent( + stim.Circuit(""" + CX 0 1 + DEPOLARIZE2(0.2) 0 1 + CX 1 2 + TICK + M 0 + RX 1 + MR 2 + """), + noise_model.noisy_circuit(circuit), + ) + + # only CX 0 1 gets depolarizing noise but on 1 2 + targeted_cx = circuits.TargetedNoiseRule( + noisy_op=stim.CircuitInstruction("CX", [0, 1]), + noise=stim.Circuit("DEPOLARIZE2(0.2) 1 2"), + ) + noise_model = circuits.NoiseModel(clifford_2q_error=targeted_cx) + assert _circuits_are_equivalent( + stim.Circuit(""" + CX 0 1 + DEPOLARIZE2(0.2) 1 2 + CX 1 2 + TICK + M 0 + RX 1 + MR 2 + """), + noise_model.noisy_circuit(circuit), + ) + + # targeted readout and reset errors on specific qubits + circuit = stim.Circuit(""" + H 0 + CX 0 1 1 2 + TICK + M 0 + RX 1 + MR 2 + """) + noise_model = circuits.NoiseModel( + clifford_1q_error=0.1, + clifford_2q_error=0.2, + rules={ + "MZ": circuits.TargetedNoiseRule( + noisy_op=stim.CircuitInstruction("MZ", [0]), noise=stim.Circuit(), readout_error=0.3 + ), + "RX": circuits.TargetedNoiseRule( + noisy_op=stim.CircuitInstruction("RX", [1]), noise=stim.Circuit(), reset_error=0.4 + ), + }, + ) + assert _circuits_are_equivalent( + stim.Circuit(""" + H 0 + DEPOLARIZE1(0.1) 0 + CX 0 1 + DEPOLARIZE2(0.2) 0 1 + CX 1 2 + DEPOLARIZE2(0.2) 1 2 + TICK + MZ(0.3) 0 + RX 1 + MR 2 + Z_ERROR(0.4) 1 + """), + noise_model.noisy_circuit(circuit), + ) + + # # tags: only apply noise to operations with the matching tag + circuit = stim.Circuit(""" + H[ancilla] 0 + H 1 + S 0 + TICK + CX 0 1 + """) + noise_model = circuits.NoiseModel( + clifford_2q_error=0.2, + clifford_1q_error=circuits.TargetedNoiseRule( + noisy_op=stim.CircuitInstruction("H", [0]), + tags={"ancilla"}, + noise=stim.Circuit("DEPOLARIZE1(0.1) 0"), + ), + ) + noisy_circuit = stim.Circuit(""" + H[ancilla] 0 + H 1 + DEPOLARIZE1(0.1) 0 + S 0 + TICK + CX 0 1 + DEPOLARIZE2(0.2) 0 1 + """) + print("=====Expect", noisy_circuit) + print("=====But got", noise_model.noisy_circuit(circuit)) + assert _circuits_are_equivalent(noisy_circuit, noise_model.noisy_circuit(circuit)) + + def test_idle_errors() -> None: """Add idling errors to a circuit.""" @@ -132,11 +249,14 @@ def test_immunity() -> None: # qubits can be immune to errors circuit = stim.Circuit(""" H 0 1 + CNOT 1 2 """) noise_model = circuits.DepolarizingNoiseModel(0.1, include_idling_error=False) noisy_circuit = stim.Circuit(""" H 0 1 + CNOT 1 2 DEPOLARIZE1(0.1) 1 + DEPOLARIZE2(0.1) 1 2 """) assert _circuits_are_equivalent( noisy_circuit, noise_model.noisy_circuit(circuit, immune_qubits=[0], insert_ticks=False) @@ -174,6 +294,72 @@ def test_immunity() -> None: assert noise_model.noisy_circuit(noiseless_circuit).to_tableau() == tableau +def test_immune_marginalize() -> None: + circuit = stim.Circuit(""" + CNOT 0 1 + CNOT 1 2 + CNOT 3 4 + """) + noise_model = circuits.DepolarizingNoiseModel(0.1, include_idling_error=False) + + # error marginalize on part of DEPOLARIZE2 on qubit 0 1 + noisy_circuit_marginalize = stim.Circuit(""" + CNOT 0 1 + CNOT 1 2 + CNOT 3 4 + DEPOLARIZE1(0.02) 2 + DEPOLARIZE2(0.1) 3 4 + """) + assert _circuits_are_equivalent( + noisy_circuit_marginalize, + noise_model.noisy_circuit( + circuit, immune_qubits=[0, 1], insert_ticks=False, marginalize=True + ), + ) + + # turn off marginalization + noisy_circuit_marginalize = stim.Circuit(""" + CNOT 0 1 + CNOT 1 2 + CNOT 3 4 + DEPOLARIZE2(0.1) 3 4 + """) + assert _circuits_are_equivalent( + noisy_circuit_marginalize, + noise_model.noisy_circuit( + circuit, immune_qubits=[0, 1], insert_ticks=False, marginalize=False + ), + ) + + # test PAULI_CHANNEL_2 + circuit = stim.Circuit(""" + CNOT 0 1 + """) + noise_model = circuits.NoiseModel( + clifford_2q_error=circuits.NoiseRule( + after={ + "PAULI_CHANNEL_2": [0.01, 0.02, 0.03, 0.04, 0, 0, 0, 0.05, 0, 0, 0, 0.06, 0, 0, 0] + } + ) + ) + + assert _circuits_are_equivalent( + stim.Circuit(""" + CNOT 0 1 + PAULI_CHANNEL_1(0.01, 0.02, 0.03) 1 + """), + noise_model.noisy_circuit(circuit, immune_qubits=[0], insert_ticks=False, marginalize=True), + ) + + assert _circuits_are_equivalent( + stim.Circuit(""" + CNOT 0 1 + PAULI_CHANNEL_1(0.04, 0.05, 0.06) 0 + """), + noise_model.noisy_circuit(circuit, immune_qubits=[1], insert_ticks=False, marginalize=True), + ) + + def test_classical_controls() -> None: """Classically controled gates get special treatment.""" noise_model: circuits.NoiseModel