Source code for qolumbina.utils.quantum_measurement

"""Utilities for appending Pauli-basis measurements to Qiskit circuits.

This module resolves user-facing measurement specifications into the concrete
objects that Qiskit needs:

* a dense Pauli-basis list with one entry per qubit; and
* a qubit-to-classical-bit measurement mapping.

The public entry point is :meth:`QuantumMeasurement.measure`. It supports the
legacy dense basis form, a sparse basis dictionary, automatic classical-bit
mapping, and explicit conflict detection when both basis and mapping are
provided.
"""

from qiskit import QuantumCircuit
from enum import Enum, auto
from typing import Optional, cast

[docs] class MeaBasisType(Enum): """Supported measurement-basis input categories. The enum is intentionally small because measurement currently supports only Pauli bases. The value determines how the user input is normalized into a dense basis list before gates are appended to a circuit. .. attribute:: PAULI_STRING Dense basis list, for example ``["x", "i", "z"]``. The list must cover every qubit of the measured circuit. .. attribute:: PAULI_MAPPING Sparse basis mapping, for example ``{0: "x", 3: "z"}``. Unspecified qubits are treated as ``"i"`` and are therefore not measured. """ PAULI_STRING = auto() # e.g., ["x", "z"] PAULI_MAPPING = auto() # e.g., {0: "x", 3: "z"}
[docs] def infer_measurement_basis_type(mea_input) -> MeaBasisType: """Infer the measurement-basis input category. Supported inputs are: * ``list[str]``: dense Pauli basis list. * ``dict[int, str]``: sparse mapping from qubit index to Pauli basis. This function validates only the container and element types. Semantic checks such as valid basis labels, valid qubit ranges, dense-list length, and mapping compatibility are handled later by :class:`QuantumMeasurement`. :param mea_input: Measurement-basis input to classify. :type mea_input: list[str] or dict[int, str] :returns: The inferred basis input category. :rtype: MeaBasisType :raises TypeError: If ``mea_input`` is not ``list[str]`` or ``dict[int, str]``. :raises ValueError: If ``mea_input`` is an empty dense list. """ if isinstance(mea_input, list): if not all(isinstance(e, str) for e in mea_input): raise TypeError("mea_input must be a list[str]") if len(mea_input) == 0: raise ValueError("mea_input cannot be empty") return MeaBasisType.PAULI_STRING if isinstance(mea_input, dict): if not all(isinstance(q_idx, int) for q_idx in mea_input): raise TypeError("mea_input dict keys must be int qubit indices") if not all(isinstance(basis, str) for basis in mea_input.values()): raise TypeError("mea_input dict values must be str basis labels") return MeaBasisType.PAULI_MAPPING raise TypeError("mea_input must be a list[str] or dict[int, str]")
[docs] class QuantumMeasurement: """Append Pauli-basis measurement operations to a circuit. ``QuantumMeasurement`` separates two concerns: * ``meas_basis`` decides which qubits are measured and in which Pauli basis. A basis value of ``"i"`` means identity/no measurement. * ``meas_mapping`` decides where each measured qubit is written in the classical register. The two specifications must agree after basis resolution. In other words, the set of qubits whose basis is not ``"i"`` must exactly match ``meas_mapping.keys()``. This explicit conflict check prevents accidental full-circuit measurements when the caller intended a partial measurement. Supported Pauli basis labels are ``"x"``, ``"y"``, ``"z"``, and ``"i"``. Measurement in X and Y basis is implemented by applying the standard basis rotations before Qiskit's computational-basis measurement. """ def __init__( self, qc: QuantumCircuit, meas_basis: list[str] | dict[int, str], meas_mapping: dict[int, int] ) -> None: """Create a measurement appender for an already allocated circuit. The constructor does not modify ``qc``. Call :meth:`apply` to append the actual gates, or use the convenience constructor :meth:`QuantumMeasurement.measure`. :param qc: Circuit that already contains the required classical bits. :type qc: qiskit.QuantumCircuit :param meas_basis: Dense or sparse Pauli measurement basis. A dense list must cover every qubit in ``qc``. A sparse dict leaves unspecified qubits unmeasured. :type meas_basis: list[str] or dict[int, str] :param meas_mapping: Mapping from measured qubit index to classical-bit index. Its keys must exactly match the measured qubits. :type meas_mapping: dict[int, int] """ self.qc = qc self._input_state_data = meas_basis self._input_data_type = infer_measurement_basis_type(meas_basis) self._meas_mapping = meas_mapping self._pauli_string: Optional[list[str]] = None def _pauli_x_measure(self, q_idx: int, c_idx: int): """Measure one qubit in Pauli-X basis. The X basis is reduced to computational-basis measurement by applying a Hadamard gate before ``measure``. """ self.qc.h(q_idx) self.qc.measure(q_idx, c_idx) def _pauli_y_measure(self, q_idx: int, c_idx: int): """Measure one qubit in Pauli-Y basis. The Y basis is reduced to computational-basis measurement by applying ``Sdg`` followed by ``H`` before ``measure``. """ self.qc.sdg(q_idx) self.qc.h(q_idx) self.qc.measure(q_idx, c_idx) def _pauli_z_measure(self, q_idx: int, c_idx: int): """Measure one qubit in Pauli-Z/computational basis.""" self.qc.measure(q_idx, c_idx) PAULI_BASIS_SET = {"i", "x", "y", "z"} PAULI_STRING_TRANS = { "x": "_pauli_x_measure", "y": "_pauli_y_measure", "z": "_pauli_z_measure", "i": None, }
[docs] def apply(self) -> None: """Append the resolved measurement gates to ``self.qc``. This method normalizes sparse basis inputs to a dense Pauli string, validates basis labels and mapping indices, checks that basis and mapping describe the same measured qubits, and then appends the appropriate basis-rotation and measurement gates. :raises TypeError: If the measurement mapping has invalid key/value types. :raises ValueError: If the dense basis length does not match the circuit width, if basis labels are unsupported, if qubit/classical indices are invalid, if multiple qubits map to the same classical bit, or if basis and mapping conflict. """ if self._input_data_type == MeaBasisType.PAULI_STRING: # Dense basis input is already indexed by qubit. It must cover the # full circuit so that every qubit has an explicit basis decision. self._pauli_string = cast(list[str], self._input_state_data) if len(self._pauli_string) != self.qc.num_qubits: raise ValueError( f"the length of pauli string ({len(self._pauli_string)}) " f"does not match the number of qubits ({self.qc.num_qubits})" ) elif self._input_data_type == MeaBasisType.PAULI_MAPPING: # Sparse basis input names only the qubits that the caller wants to # constrain. All other qubits default to identity/no measurement. sparse_basis = cast(dict[int, str], self._input_state_data) self._validate_qubit_indices(sparse_basis.keys()) self._pauli_string = ["i"] * self.qc.num_qubits for qubit, pauli_basis in sparse_basis.items(): self._pauli_string[qubit] = pauli_basis if self._pauli_string is None: raise ValueError("measurement basis could not be resolved") self._validate_basis_values(self._pauli_string) self._validate_mapping(self._meas_mapping) self._validate_basis_mapping_compatibility(self._pauli_string, self._meas_mapping) # Iterate in qubit-index order. This keeps gate insertion deterministic # and matches the dense basis list order used throughout the framework. for qubit, pauli_basis in enumerate(self._pauli_string): if pauli_basis != "i": # Skip identity measurement clbit = self._meas_mapping[qubit] self._apply_pauli_basis_measurement(pauli_basis, qubit, clbit)
# Convenience constructor
[docs] @classmethod def measure( cls, qc: QuantumCircuit, meas_basis: list[str] | dict[int, str] | None, input_meas_mapping: Optional[dict[int, int]] = None ) -> QuantumCircuit: """Resolve a measurement specification and append measurement gates. Measurement specification is resolved as follows: - If both ``meas_basis`` and ``input_meas_mapping`` are ``None``, every qubit is measured in Pauli-Z basis with the default ``q_i -> c_i`` mapping. This preserves the original full-measurement behavior. - If ``input_meas_mapping`` is provided but ``meas_basis`` is ``None``, only the qubits appearing in the mapping keys are measured, all in Pauli-Z basis. - If ``meas_basis`` is provided but ``input_meas_mapping`` is ``None``, only qubits whose basis is not ``"i"`` are measured. Classical bits are assigned contiguously in ascending qubit-index order; for example, measured qubits ``[0, 3, 4]`` map to classical bits ``[0, 1, 2]``. - If both are provided, the non-``"i"`` qubits from ``meas_basis`` must exactly match ``input_meas_mapping.keys()``. Missing or extra mapping entries are treated as conflicts and raise ``ValueError``. ``meas_basis`` can be a dense ``list[str]`` covering all circuit qubits or a sparse ``dict[int, str]`` whose unspecified qubits are treated as ``"i"``. .. important:: ``qc`` must already have enough classical bits for the resolved mapping. If the caller does not know the required width in advance, call :meth:`required_clbits` before constructing ``qc``. .. rubric:: Examples Control only ``input_meas_mapping``. The basis is omitted, so only the mapped qubits are measured and they default to Pauli-Z basis: .. code-block:: python qc = QuantumCircuit(5, 2) QuantumMeasurement.measure( qc, meas_basis=None, input_meas_mapping={1: 0, 4: 1}, ) # Resolved basis: ["i", "z", "i", "i", "z"] # Resolved mapping: {1: 0, 4: 1} # Measurements appended: q1 -> c0, q4 -> c1. Control only ``meas_basis``. The mapping is omitted, so classical bits are assigned contiguously in ascending measured-qubit order: .. code-block:: python qc = QuantumCircuit(5, 3) QuantumMeasurement.measure( qc, meas_basis=["z", "i", "i", "x", "y"], ) # Measured qubits: [0, 3, 4] # Resolved mapping: {0: 0, 3: 1, 4: 2} # q0 is measured in Z, q3 in X, and q4 in Y. Use sparse ``meas_basis`` and explicit ``input_meas_mapping`` together. The two specifications are accepted only when their qubit sets match: .. code-block:: python qc = QuantumCircuit(6, 2) QuantumMeasurement.measure( qc, meas_basis={2: "x", 5: "z"}, input_meas_mapping={2: 1, 5: 0}, ) # Resolved basis: ["i", "i", "x", "i", "i", "z"] # Resolved mapping: {2: 1, 5: 0} A conflicting pair raises ``ValueError``: .. code-block:: python QuantumMeasurement.measure( qc, meas_basis={2: "x", 5: "z"}, input_meas_mapping={2: 1, 4: 0}, ) :param qc: Circuit to mutate by appending measurement operations. :type qc: qiskit.QuantumCircuit :param meas_basis: Dense basis list, sparse basis mapping, or ``None``. :type meas_basis: list[str] or dict[int, str] or None :param input_meas_mapping: Optional explicit qubit-to-classical-bit mapping. :type input_meas_mapping: dict[int, int] or None :returns: The same circuit object after measurement gates are appended. :rtype: qiskit.QuantumCircuit :raises TypeError: If basis or mapping containers have invalid key/value types. :raises ValueError: If the specification is inconsistent, if the circuit has insufficient classical bits, or if an index is outside the valid range. """ resolved_basis, meas_mapping = cls.resolve_measurement_spec( qc.num_qubits, meas_basis, input_meas_mapping ) if len(meas_mapping) > 0 and qc.num_clbits < 1: raise ValueError("there is no classical bit, so quantum measurement cannot be implemented.") if meas_mapping and max(meas_mapping.values()) >= qc.num_clbits: raise ValueError( f"measurement mapping uses classical bit {max(meas_mapping.values())}, " f"but the circuit only has {qc.num_clbits} classical bits" ) cls(qc, resolved_basis, meas_mapping).apply() return qc
[docs] @classmethod def required_clbits( cls, num_qubits: int, meas_basis: list[str] | dict[int, str] | None, input_meas_mapping: Optional[dict[int, int]] = None ) -> int: """Return the classical-register width required by a measurement spec. Callers that allocate a circuit before invoking :meth:`measure` should use this helper so that allocation, default handling, and conflict checks follow the same rules as the measurement implementation. The returned value is ``max(meas_mapping.values()) + 1`` after the specification is resolved. This preserves sparse explicit classical-bit mappings. For example, ``{2: 5}`` requires six classical bits even though it measures only one qubit. :param num_qubits: Number of qubits in the circuit that will be measured. :type num_qubits: int :param meas_basis: Dense basis list, sparse basis mapping, or ``None``. :type meas_basis: list[str] or dict[int, str] or None :param input_meas_mapping: Optional explicit qubit-to-classical-bit mapping. :type input_meas_mapping: dict[int, int] or None :returns: Minimum classical register width needed by the resolved measurement mapping. :rtype: int :raises TypeError: If basis or mapping containers have invalid key/value types. :raises ValueError: If the measurement specification is invalid or internally inconsistent. """ _, meas_mapping = cls.resolve_measurement_spec( num_qubits, meas_basis, input_meas_mapping ) if not meas_mapping: return 0 return max(meas_mapping.values()) + 1
[docs] @classmethod def resolve_measurement_spec( cls, num_qubits: int, meas_basis: list[str] | dict[int, str] | None, input_meas_mapping: Optional[dict[int, int]] = None ) -> tuple[list[str], dict[int, int]]: """Resolve measurement inputs into a dense basis and concrete mapping. The returned basis always has length ``num_qubits``. The returned mapping contains exactly the qubits whose basis is not ``"i"``. This method is the single source of truth for all default handling: * ``None``/``None`` becomes full Pauli-Z measurement. * mapping-only input becomes sparse Pauli-Z measurement on mapped qubits. * basis-only input gets contiguous classical bits in ascending qubit order. * basis-plus-mapping input is accepted only if the measured qubit sets match exactly. :param num_qubits: Number of qubits in the measured circuit. :type num_qubits: int :param meas_basis: Dense basis list, sparse basis mapping, or ``None``. :type meas_basis: list[str] or dict[int, str] or None :param input_meas_mapping: Optional explicit qubit-to-classical-bit mapping. :type input_meas_mapping: dict[int, int] or None :returns: ``(resolved_basis, resolved_mapping)`` where ``resolved_basis`` is a dense list of length ``num_qubits`` and ``resolved_mapping`` maps each measured qubit to a classical bit. :rtype: tuple[list[str], dict[int, int]] :raises TypeError: If basis or mapping containers have invalid key/value types. :raises ValueError: If ``num_qubits`` is invalid, if dense basis length is wrong, if indices are out of range, if basis labels are invalid, or if basis and mapping conflict. """ if not isinstance(num_qubits, int) or num_qubits < 0: raise ValueError("num_qubits must be a non-negative integer") if meas_basis is None: if input_meas_mapping is None: # Legacy default: no user measurement specification means full # Pauli-Z measurement with q_i -> c_i. resolved_basis = ["z"] * num_qubits resolved_mapping = {q_idx: q_idx for q_idx in range(num_qubits)} else: # New partial-measurement default: mapping-only input names the # only qubits to measure, all in Pauli-Z basis. cls._validate_mapping_static(input_meas_mapping, num_qubits) resolved_mapping = dict(input_meas_mapping) resolved_basis = ["i"] * num_qubits for qubit in resolved_mapping: resolved_basis[qubit] = "z" else: resolved_basis = cls._resolve_basis(num_qubits, meas_basis) measured_qubits = [ qubit for qubit, pauli_basis in enumerate(resolved_basis) if pauli_basis != "i" ] if input_meas_mapping is None: # Basis-only input auto-generates a compact output register. resolved_mapping = { qubit: clbit for clbit, qubit in enumerate(measured_qubits) } else: # Explicit mapping is checked below against the measured qubits # so conflicting basis/mapping specifications fail early. cls._validate_mapping_static(input_meas_mapping, num_qubits) resolved_mapping = dict(input_meas_mapping) cls._validate_basis_values(resolved_basis) cls._validate_mapping_static(resolved_mapping, num_qubits) cls._validate_basis_mapping_compatibility(resolved_basis, resolved_mapping) return resolved_basis, resolved_mapping
# Internal methods def _apply_pauli_basis_measurement(self, meas_basis: str, q_idx: int, c_idx: int) -> None: """Append the gate sequence for one Pauli-basis measurement. :param meas_basis: One of ``"x"``, ``"y"``, ``"z"``, or ``"i"``. :type meas_basis: str :param q_idx: Qubit index to measure. :type q_idx: int :param c_idx: Classical-bit index receiving the measurement result. :type c_idx: int :raises ValueError: If ``meas_basis`` is not supported. """ if meas_basis not in self.PAULI_BASIS_SET: raise ValueError(f"undefined measurement basis: {meas_basis}") method_name = self.PAULI_STRING_TRANS[meas_basis] if method_name is not None: getattr(self, method_name)(q_idx, c_idx) @classmethod def _resolve_basis( cls, num_qubits: int, meas_basis: list[str] | dict[int, str] ) -> list[str]: """Normalize dense or sparse basis input to a dense Pauli string. :param num_qubits: Expected circuit width. :type num_qubits: int :param meas_basis: Dense basis list or sparse basis mapping. :type meas_basis: list[str] or dict[int, str] :returns: Dense basis list with length ``num_qubits``. :rtype: list[str] :raises TypeError: If ``meas_basis`` has an unsupported type. :raises ValueError: If dense basis length is wrong or sparse qubit indices are outside the valid range. """ input_type = infer_measurement_basis_type(meas_basis) if input_type == MeaBasisType.PAULI_STRING: dense_basis = cast(list[str], meas_basis) if len(dense_basis) != num_qubits: raise ValueError( f"the length of pauli string ({len(dense_basis)}) " f"does not match the number of qubits ({num_qubits})" ) return list(dense_basis) sparse_basis = cast(dict[int, str], meas_basis) cls._validate_qubit_indices_static(sparse_basis.keys(), num_qubits) dense_basis = ["i"] * num_qubits for qubit, pauli_basis in sparse_basis.items(): dense_basis[qubit] = pauli_basis return dense_basis @classmethod def _validate_basis_values(cls, meas_basis: list[str]) -> None: """Validate that every basis label is supported. :param meas_basis: Dense Pauli basis list. :type meas_basis: list[str] :raises ValueError: If any basis label is outside :attr:`PAULI_BASIS_SET`. """ invalid_bases = sorted(set(meas_basis) - cls.PAULI_BASIS_SET) if invalid_bases: raise ValueError(f"undefined measurement basis values: {invalid_bases}") @classmethod def _validate_basis_mapping_compatibility( cls, meas_basis: list[str], meas_mapping: dict[int, int] ) -> None: """Ensure basis and mapping describe the same measured qubits. A qubit is considered measured exactly when its basis is not ``"i"``. The explicit mapping must contain exactly those qubits as keys. :param meas_basis: Dense Pauli basis list. :type meas_basis: list[str] :param meas_mapping: Qubit-to-classical-bit mapping. :type meas_mapping: dict[int, int] :raises ValueError: If a measured qubit is missing from the mapping or if the mapping contains an unmeasured qubit. """ measured_qubits = { qubit for qubit, pauli_basis in enumerate(meas_basis) if pauli_basis != "i" } mapped_qubits = set(meas_mapping.keys()) if measured_qubits != mapped_qubits: missing_mapping = sorted(measured_qubits - mapped_qubits) extra_mapping = sorted(mapped_qubits - measured_qubits) raise ValueError( "measurement basis and measurement mapping conflict: " f"missing mapped qubits for measured bases {missing_mapping}; " f"mapping includes unmeasured qubits {extra_mapping}" ) def _validate_mapping(self, meas_mapping: dict[int, int]) -> None: """Validate a mapping against this instance's circuit width. :param meas_mapping: Qubit-to-classical-bit mapping. :type meas_mapping: dict[int, int] :raises TypeError: If the mapping is not ``dict[int, int]``. :raises ValueError: If qubit indices are invalid, classical-bit indices are negative, or duplicate classical-bit targets are present. """ self._validate_mapping_static(meas_mapping, self.qc.num_qubits) @classmethod def _validate_mapping_static(cls, meas_mapping: dict[int, int], num_qubits: int) -> None: """Validate mapping type, qubit range, and classical-bit targets. This method validates non-negativity and uniqueness of classical-bit targets, but it does not know the actual allocated classical-register width. :meth:`measure` performs that final width check against ``qc.num_clbits``. :param meas_mapping: Qubit-to-classical-bit mapping. :type meas_mapping: dict[int, int] :param num_qubits: Number of valid qubits. :type num_qubits: int :raises TypeError: If the mapping or its key/value types are invalid. :raises ValueError: If qubit indices are outside the valid range, if classical-bit indices are negative, or if multiple qubits target the same classical bit. """ if not isinstance(meas_mapping, dict): raise TypeError("meas_mapping must be dict[int, int]") if not all(isinstance(q_idx, int) for q_idx in meas_mapping): raise TypeError("meas_mapping keys must be int qubit indices") if not all(isinstance(c_idx, int) for c_idx in meas_mapping.values()): raise TypeError("meas_mapping values must be int classical-bit indices") cls._validate_qubit_indices_static(meas_mapping.keys(), num_qubits) invalid_clbits = [c_idx for c_idx in meas_mapping.values() if c_idx < 0] if invalid_clbits: raise ValueError(f"classical-bit indices must be non-negative: {invalid_clbits}") if len(set(meas_mapping.values())) != len(meas_mapping): raise ValueError("meas_mapping cannot map multiple qubits to the same classical bit") def _validate_qubit_indices(self, qubit_indices) -> None: """Validate qubit indices against this instance's circuit width. :param qubit_indices: Iterable of qubit indices. :type qubit_indices: Iterable[int] :raises ValueError: If any qubit index is outside the circuit range. """ self._validate_qubit_indices_static(qubit_indices, self.qc.num_qubits) @staticmethod def _validate_qubit_indices_static(qubit_indices, num_qubits: int) -> None: """Validate qubit indices against an explicit qubit count. :param qubit_indices: Iterable of qubit indices. :type qubit_indices: Iterable[int] :param num_qubits: Number of valid qubits. :type num_qubits: int :raises ValueError: If any qubit index is smaller than ``0`` or greater than or equal to ``num_qubits``. """ invalid_qubits = [ q_idx for q_idx in qubit_indices if q_idx < 0 or q_idx >= num_qubits ] if invalid_qubits: raise ValueError( f"qubit indices {invalid_qubits} are outside the valid range " f"0..{num_qubits - 1}" )