"""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}"
)