diff --git a/src/mqt/qecc/circuit_synthesis/faults.py b/src/mqt/qecc/circuit_synthesis/faults.py index 8da7d68e..f227e62b 100644 --- a/src/mqt/qecc/circuit_synthesis/faults.py +++ b/src/mqt/qecc/circuit_synthesis/faults.py @@ -28,14 +28,27 @@ class PureFaultSet: """Represents a collection of pure faults (X-type or Z-type) in a quantum circuit.""" - def __init__(self, num_qubits: int) -> None: + def __init__(self, num_qubits: int, kind: str = "X") -> None: """Initialize a PureFaultSet object. Args: num_qubits: The number of qubits in the circuit. + kind: The type of faults that this PureFaultSet represents ('X' or 'Z'). """ self.num_qubits = num_qubits self.faults = np.zeros((0, num_qubits), dtype=np.int8) # Pure faults as binary vectors + self.kind = kind + + @property + def kind(self) -> str: + """Return the type of faults in the set ('X' or 'Z').""" + return self._kind + + @kind.setter + def kind(self, value: str) -> None: + """Set the type of faults in the set ('X' or 'Z').""" + assert value.upper() in {"X", "Z"}, "Kind must be either 'X' or 'Z'." + self._kind = value.upper() def add_fault(self, fault: npt.NDArray[np.int8]) -> None: """Add a fault to the fault set. @@ -72,10 +85,14 @@ def combine(self, other: PureFaultSet, inplace: bool = False) -> PureFaultSet: raise ValueError(msg) combined_faults = np.vstack([self.faults, other.faults]) + if self.kind != other.kind: + msg = "Fault sets must have the same kind to combine." + raise ValueError(msg) + if inplace: self.faults = combined_faults return self - return PureFaultSet.from_fault_array(combined_faults) + return PureFaultSet.from_fault_array(combined_faults, kind=self.kind) def to_array(self) -> npt.NDArray[np.int8]: """Convert the fault set to a numpy array. @@ -86,7 +103,7 @@ def to_array(self) -> npt.NDArray[np.int8]: return self.faults @classmethod - def from_fault_array(cls, array: npt.NDArray[np.int8]) -> PureFaultSet: + def from_fault_array(cls, array: npt.NDArray[np.int8], kind: str = "X") -> PureFaultSet: """Create a PureFaultSet from a numpy array of faults. Returns: @@ -95,7 +112,7 @@ def from_fault_array(cls, array: npt.NDArray[np.int8]) -> PureFaultSet: if array.ndim != 2: msg = "Input array must be 2-dimensional." raise ValueError(msg) - fault_set = cls(array.shape[1]) + fault_set = cls(array.shape[1], kind=kind) fault_set.faults = np.unique(array, axis=0) return fault_set @@ -124,7 +141,9 @@ def from_cnot_circuit(cls, circ: CNOTCircuit, kind: str = "X", reduce: bool = Fa qubit_faults[ctrl].append(new_fault) # Create the fault set - fs = cls.from_fault_array(np.array([fault for faults in qubit_faults for fault in faults], dtype=np.int8)) + fs = cls.from_fault_array( + np.array([fault for faults in qubit_faults for fault in faults], dtype=np.int8), kind=kind + ) if not reduce: return fs @@ -242,7 +261,7 @@ def __eq__(self, other: object) -> bool: """ if not isinstance(other, PureFaultSet): return False - return self.num_qubits == other.num_qubits and self.to_set() == other.to_set() + return self.num_qubits == other.num_qubits and self.to_set() == other.to_set() and self.kind == other.kind def __hash__(self) -> int: """Return a hash of the PureFaultSet. @@ -258,7 +277,7 @@ def copy(self) -> PureFaultSet: Returns: A new PureFaultSet object with the same faults and number of qubits. """ - new_set = PureFaultSet(self.num_qubits) + new_set = PureFaultSet(self.num_qubits, kind=self.kind) new_set.faults = np.copy(self.faults) return new_set @@ -352,7 +371,7 @@ def filter_faults(self, pred: Callable[[npt.NDArray[np.int8]], bool], inplace: b self.faults = filtered return self - return PureFaultSet.from_fault_array(filtered) + return PureFaultSet.from_fault_array(filtered, kind=self.kind) def permute_qubits(self, permutation: npt.NDArray[np.int8] | list[int], inplace: bool = True) -> PureFaultSet: """Permute the qubits in the fault set according to a given permutation. @@ -373,7 +392,38 @@ def permute_qubits(self, permutation: npt.NDArray[np.int8] | list[int], inplace: self.faults = permuted_faults return self - return PureFaultSet.from_fault_array(permuted_faults) + return PureFaultSet.from_fault_array(permuted_faults, kind=self.kind) + + def apply_cnot(self, control: int, target: int, inplace: bool = True) -> PureFaultSet: + """Apply a CNOT gate to the faults in the set, based on the type of faults (X or Z). + + Args: + control: The index of the control qubit. + target: The index of the target qubit. + inplace: If True, modifies the current fault set. If False, returns a new PureFaultSet with updated faults. + + Returns: + A new PureFaultSet with updated faults if inplace is False. + """ + if not (0 <= control < self.num_qubits) or not (0 <= target < self.num_qubits): + msg = f"Control and target indices must be between 0 and {self.num_qubits - 1}." + raise ValueError(msg) + # Dev Note: We do not allow negative indices so that we can easily check if control and target are different + if control == target: + msg = "Control and target qubits must be different." + raise ValueError(msg) + + updated_faults = np.copy(self.faults) + if self.kind == "X": + updated_faults[:, target] ^= updated_faults[:, control] + else: # self.kind == "Z" + updated_faults[:, control] ^= updated_faults[:, target] + + if inplace: + self.faults = updated_faults + return self + + return PureFaultSet.from_fault_array(updated_faults, kind=self.kind) def coset_leader(fault: npt.NDArray[np.int8], generators: npt.NDArray[np.int8]) -> npt.NDArray[np.int8]: diff --git a/tests/circuit_synthesis/test_faults.py b/tests/circuit_synthesis/test_faults.py index a4f220d6..8f972dfb 100644 --- a/tests/circuit_synthesis/test_faults.py +++ b/tests/circuit_synthesis/test_faults.py @@ -64,16 +64,42 @@ def test_add_fault_invalid_length(): def test_combine_fault_sets(): """Test combining two fault sets.""" - fault_set_1 = PureFaultSet(num_qubits=3) + fault_set_1 = PureFaultSet(num_qubits=3, kind="Z") fault_set_1.add_fault(np.array([1, 0, 1], dtype=np.int8)) - fault_set_2 = PureFaultSet(num_qubits=3) + fault_set_2 = PureFaultSet(num_qubits=3, kind="Z") fault_set_2.add_fault(np.array([0, 1, 0], dtype=np.int8)) # Combine the fault sets combined_fault_set = fault_set_1.combine(fault_set_2) expected = np.array([[1, 0, 1], [0, 1, 0]], dtype=np.int8) assert combined_fault_set.to_set() == set(map(tuple, expected)), "Fault sets were not combined correctly." + assert combined_fault_set.kind == "Z", "Fault kind was not preserved when combining fault sets." + + +def test_combine_fault_sets_different_kind(): + """Test combining two fault sets with different kinds.""" + fault_set_1 = PureFaultSet(num_qubits=3, kind="X") + fault_set_1.add_fault(np.array([1, 0, 1], dtype=np.int8)) + + fault_set_2 = PureFaultSet(num_qubits=3, kind="Z") + fault_set_2.add_fault(np.array([0, 1, 0], dtype=np.int8)) + + with pytest.raises(ValueError, match=r"Fault sets must have the same kind to combine."): + _ = fault_set_1.combine(fault_set_2) + + +def test_combine_fault_sets_inplace_false_propagates_kind(): + """Test that non-inplace combining preserves the left fault set kind.""" + fault_set_1 = PureFaultSet(num_qubits=3, kind="Z") + fault_set_1.add_fault(np.array([1, 0, 1], dtype=np.int8)) + + fault_set_2 = PureFaultSet(num_qubits=3, kind="Z") + fault_set_2.add_fault(np.array([0, 1, 0], dtype=np.int8)) + + combined_fault_set = fault_set_1.combine(fault_set_2, inplace=False) + assert combined_fault_set.kind == "Z", "Fault kind was not propagated for inplace=False combine." + assert fault_set_1.kind == "Z", "Original fault set kind should remain unchanged." def test_combine_fault_sets_invalid(): @@ -98,6 +124,14 @@ def test_from_fault_array(): assert set(map(tuple, result)) == set(map(tuple, faults)), "Fault set was not created correctly from array." +def test_from_fault_array_invalid_dimension(): + """Test creating a PureFaultSet from an array with invalid dimensions.""" + faults = np.array([1, 0, 1], dtype=np.int8) # 1D array instead of 2D + + with pytest.raises(ValueError, match=r"Input array must be 2-dimensional."): + PureFaultSet.from_fault_array(faults) + + @pytest.mark.parametrize( ("stabs_fixture", "initial_faults", "expected_faults"), [ @@ -619,21 +653,133 @@ def test_not_t_distinct_four_qubits(): def test_permute_qubits_basic(): """Test basic permutation of faults.""" faults = np.array([[1, 1, 0], [0, 1, 1]], dtype=np.int8) - fault_set = PureFaultSet.from_fault_array(faults) + fault_set = PureFaultSet.from_fault_array(faults, kind="Z") permutation = [2, 0, 1] permuted_fault_set = fault_set.permute_qubits(permutation, inplace=False) assert np.array_equal(permuted_fault_set.faults, faults[:, permutation]), "Faults were not permuted correctly" - assert fault_set == PureFaultSet.from_fault_array(faults), "Original fault set should remain unchanged" + assert fault_set == PureFaultSet.from_fault_array(faults, kind="Z"), "Original fault set should remain unchanged" + assert permuted_fault_set.kind == "Z", "Fault kind should be preserved after permutation" + assert fault_set.kind == "Z", "Original fault kind should be preserved after permutation" def test_permute_qubits_inplace(): """Test inplace permutation of fault set.""" faults = np.array([[1, 1, 0], [0, 0, 1]], dtype=np.int8) - fault_set = PureFaultSet.from_fault_array(faults) + fault_set = PureFaultSet.from_fault_array(faults, kind="Z") permutation = [2, 0, 1] fault_set.permute_qubits(permutation, inplace=True) - assert fault_set != PureFaultSet.from_fault_array(faults), "Faults were not permuted correctly in place" + assert fault_set != PureFaultSet.from_fault_array(faults, kind="Z"), "Faults were not permuted correctly in place" + + +def test_invalid_fault_kind(): + """Test that an invalid kind raises an assertion error.""" + with pytest.raises(AssertionError, match=r"Kind must be either 'X' or 'Z'."): + pfs = PureFaultSet(5, kind="Y") + + with pytest.raises(AssertionError, match=r"Kind must be either 'X' or 'Z'."): + pfs = PureFaultSet.from_fault_array(np.array([[1, 0, 1]], dtype=np.int8), kind="Y") + + pfs = PureFaultSet(5) + with pytest.raises(AssertionError, match=r"Kind must be either 'X' or 'Z'."): + pfs.kind = "Y" + + +def test_apply_cnot_x(): + """Test applying a CNOT gate to the fault set.""" + faults1 = np.array([[1, 0, 0]], dtype=np.int8) + fault_set1 = PureFaultSet.from_fault_array(faults1, kind="X") + + # Apply CNOT with control=0 and target=1 + fault_set1.apply_cnot(control=0, target=1) + + expected_faults1 = np.array([[1, 1, 0]], dtype=np.int8) + assert np.array_equal(fault_set1.to_array(), expected_faults1), ( + "CNOT gate was not applied correctly to the fault set" + ) + + faults2 = np.array([[0, 1, 0]], dtype=np.int8) + fault_set2 = PureFaultSet.from_fault_array(faults2, kind="X") + + # Apply CNOT with control=0 and target=1 + fault_set2.apply_cnot(control=0, target=1) + + expected_faults2 = np.array([[0, 1, 0]], dtype=np.int8) + assert np.array_equal(fault_set2.to_array(), expected_faults2), ( + "CNOT gate was not applied correctly to the fault set" + ) + + +def test_apply_cnot_z(): + """Test applying a CNOT gate to the fault set.""" + faults1 = np.array([[1, 0, 0]], dtype=np.int8) + fault_set1 = PureFaultSet.from_fault_array(faults1, kind="Z") + + # Apply CNOT with control=0 and target=1 + fault_set1.apply_cnot(control=0, target=1) + + expected_faults1 = np.array([[1, 0, 0]], dtype=np.int8) + assert np.array_equal(fault_set1.to_array(), expected_faults1), ( + "CNOT gate was not applied correctly to the fault set" + ) + + faults2 = np.array([[0, 1, 0]], dtype=np.int8) + fault_set2 = PureFaultSet.from_fault_array(faults2, kind="Z") + + # Apply CNOT with control=0 and target=1 + fault_set2.apply_cnot(control=0, target=1) + + expected_faults2 = np.array([[1, 1, 0]], dtype=np.int8) + assert np.array_equal(fault_set2.to_array(), expected_faults2), ( + "CNOT gate was not applied correctly to the fault set" + ) + + +def test_apply_cnot_invalid_qubits(): + """Test that applying a CNOT gate with invalid qubit indices raises an error.""" + faults = np.array([[1, 0, 0]], dtype=np.int8) + fault_set = PureFaultSet.from_fault_array(faults) + + with pytest.raises(ValueError, match=r"Control and target qubits must be different."): + fault_set.apply_cnot(control=0, target=0) + + with pytest.raises(ValueError, match=r"Control and target indices must be between 0 and 2."): + fault_set.apply_cnot(control=3, target=1) + + with pytest.raises(ValueError, match=r"Control and target indices must be between 0 and 2."): + fault_set.apply_cnot(control=-1, target=1) + + +def test_apply_cnot_not_inplace(): + """Test that applying a CNOT gate does not modify the original fault set when inplace=False.""" + faults = np.array([[1, 0, 0]], dtype=np.int8) + fault_set = PureFaultSet.from_fault_array(faults) + + # Apply CNOT with control=0 and target=1 without modifying the original fault set + new_fault_set = fault_set.apply_cnot(control=0, target=1, inplace=False) + + expected_new_faults = np.array([[1, 1, 0]], dtype=np.int8) + assert np.array_equal(new_fault_set.to_array(), expected_new_faults), ( + "CNOT gate was not applied correctly to the new fault set" + ) + assert np.array_equal(fault_set.to_array(), faults), "Original fault set should remain unchanged" + + +def test_pure_fault_set_copy(): + """Test that PureFaultSet.copy() returns an independent copy.""" + faults = np.array([[1, 0, 0], [0, 1, 1]], dtype=np.int8) + fault_set = PureFaultSet.from_fault_array(faults, kind="Z") + + copied_fault_set = fault_set.copy() + + assert copied_fault_set is not fault_set + assert np.array_equal(copied_fault_set.to_array(), fault_set.to_array()) + assert copied_fault_set.kind == fault_set.kind + + copied_fault_set.apply_cnot(control=0, target=1) + + assert not np.array_equal(copied_fault_set.to_array(), fault_set.to_array()) + assert np.array_equal(fault_set.to_array(), np.unique(faults, axis=0))