Repository: qiskit-community/prototype-zne (language: python)

Issue:
Refactor `ZNEStrategy`
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues to confirm this idea does not exist. -->

### What is the expected enhancement?
<!-- Please, provide a clear and concise description of the expected behavior for this enhancement. -->
With the new interface in Qiskit Terra 0.22, `ZNEStrategy` no longer needs to be a frozen class. On top of that, all the internal functionality that it holds (now) makes it not very well suited to be a `dataclass` altogether; instead, something more tailored would be preferred.


Discussion / hints:

Fix patch:
diff --git a/zne/utils/unset.py b/zne/utils/unset.py
new file mode 100644
index 0000000..aa3466d
--- /dev/null
+++ b/zne/utils/unset.py
@@ -0,0 +1,43 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2022.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Module for singleton type to represent unset args."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+class UnsetType:
+    """Singleton type to represent unset args."""
+
+    __slots__ = ()
+
+    def __new__(cls) -> UnsetType:
+        if not hasattr(cls, "instance"):
+            cls.instance = super().__new__(cls)
+        return cls.instance
+
+    def __bool__(self) -> bool:
+        return False
+
+    def __eq__(self, __o: Any) -> bool:
+        return self is __o
+
+    def __copy__(self) -> UnsetType:
+        return self
+
+    def __deepcopy__(self, memo: dict) -> UnsetType:
+        return self
+
+
+UNSET = UnsetType()
diff --git a/zne/utils/validation.py b/zne/utils/validation.py
index 62ebd26..28f6153 100644
--- a/zne/utils/validation.py
+++ b/zne/utils/validation.py
@@ -14,131 +14,150 @@
 
 from __future__ import annotations
 
-from collections.abc import Sequence, Set
-from typing import Any, Tuple, Union
+from collections.abc import Callable
+from copy import deepcopy
+from typing import Any
 
-IsInstanceTypes = Union[type, Tuple[Union[type, tuple], ...]]
+from .unset import UNSET, UnsetType
 
 
-################################################################################
-## VALIDATION
-################################################################################
-def validate_object_type(
-    obj: object,
-    class_or_tuple: IsInstanceTypes,
-    *,
-    default: Any = Ellipsis,
-) -> Any:
-    """Validate type with same interface as ``isinstance``.
+class quality:  # pylint: disable=invalid-name
+    """Quality attribute similar to property but geared towards custom validation.
 
     Args:
-        obj: The object to validate
-        class_or_tuple: A type, tuple of types, or Union to validate against.
-        default: If provided and ``obj`` is None return this instead (validated).
-
-    Returns:
-        The validated object or default.
-
-    Raises:
-        TypeError: If ``obj`` is not of a type specified in ``class_or_tuple``.
-        TypeError: If ``class_or_tuple`` is incompatible with ``isinstance``.
+        fval: function to be used for validating an attribute value on `__set__`
+        feff: function to be used for performing side-effects on `__set__` and
+            `__delete__` (e.g. erasing cache)
+        default: value to assume if set to `...`
+        null: value to assume if set to `None`
     """
-    if default is not ... and obj is None:  # Ellipsis avoids infinite recurssion
-        return validate_object_type(default, class_or_tuple)
-    try:
-        if not isinstance(obj, class_or_tuple):
-            raise TypeError(f"Expected {class_or_tuple} but got {type(obj)} instead.")
-    except TypeError as error:
-        raise TypeError("Invalid types provided: incompatible with ``isinstance``.") from error
-    else:
-        return obj
-
-
-def validate_sequence(  # pylint: disable=too-many-arguments
-    seq: Sequence,
-    element_types: IsInstanceTypes = object,
-    *,
-    standard_type: type | None = None,
-    cast_single: bool = False,
-    allow_set: bool = False,
-    default: Any = Ellipsis,
-) -> Sequence:
-    """Validate sequence and (optioanlly) the type of its elements.
 
-    Args:
-        seq: A sequence object ot validate.
-        element_types: A type, tuple of types, or Union for the elements in the sequence.
-        standard_type: A callable to cast the returned sequence to a standard type, or None.
-        cast_single: If ``True``, allows single element input (casted to tuple).
-        allow_set: If ``True``, ``Set`` objects will be allowed as sequences.
-        default: If provided and ``seq`` is ``None`` return this instead (validated).
-            Ellipsis (i.e. ``...``) disables the functinoality.
-
-    Returns:
-        The validated (casted) sequence or default.
-
-    Raises:
-        ValueError: If ``standard_type`` input fails to cast the return value's type.
-    """
-    ## DEFAULT
-    if default is not ... and seq is None:  # Ellipsis avoids infinite recurssion
-        return validate_sequence(
-            default,
-            element_types,
-            standard_type=standard_type,
-            cast_single=cast_single,
-            allow_set=allow_set,
+    __slots__ = "fval", "feff", "_default", "_null", "_name"
+
+    # TODO: update doc to fval's like property does for fget
+    def __init__(
+        self,
+        fval: Callable[[Any, Any], Any] | None = None,
+        feff: Callable[[Any], None] | None = None,
+        *,
+        default: Any = UNSET,
+        null: Any = UNSET,
+    ) -> None:
+        self.fval: Callable[[Any, Any], Any] | None = fval
+        self.feff: Callable[[Any], None] | None = feff
+        self._default: Any = default
+        self._null: Any = null
+        self._name: str | None = None
+
+    def __call__(
+        self,
+        fval: Callable[[Any, Any], Any] | None | UnsetType = UNSET,
+        feff: Callable[[Any], None] | None | UnsetType = UNSET,
+        *,
+        default: Any = UNSET,
+        null: Any = UNSET,
+    ) -> quality:
+        """Returns a shallow copy of the quality with updated attributes."""
+        copy = self.__class__(
+            fval=fval or self.fval,  # type: ignore
+            feff=feff or self.feff,  # type: ignore
+            default=default or self.default,
+            null=null or self.null,
         )
-
-    ## ALLOW SET
-    SequenceType: IsInstanceTypes = (Sequence, Set) if allow_set else Sequence
-
-    ## CAST SINGLE
-    element_types = _validate_isinstance_types(element_types)
-    if cast_single and isinstance(seq, element_types):
-        # TODO: handle arbitrarily deep recursive sequences gracefully (now up to depth two)
-        if not isinstance(seq, SequenceType) or any(not isinstance(e, element_types) for e in seq):
-            seq = (seq,)
-
-    ## VALIDATION
-    validate_object_type(seq, SequenceType)
-    for element in seq:
-        validate_object_type(element, element_types)
-
-    ## STANDARD TYPE
-    if standard_type is not None:
-        try:
-            seq = standard_type(seq)
-            return validate_sequence(seq, element_types, allow_set=allow_set)
-        except (TypeError, ValueError) as error:
-            raise ValueError(
-                "Incompatible `standard_type` input, type casting cannot be performed."
-            ) from error
-
-    ## RETURN
-    return seq
-
-
-################################################################################
-## AUXILIARY
-################################################################################
-def _validate_isinstance_types(types: IsInstanceTypes) -> tuple:
-    """Validate that types are a valid second argument for ``isinstance`` call.
-
-    Args:
-        types: To validate.
-
-    Returns:
-        The input types if validated in tuple form.
-
-    Raises:
-        TypeError: If types are invalid.
-    """
-    try:
-        isinstance(None, types)
-    except TypeError as error:
-        raise TypeError("Invalid types provided: incompatible with ``isinstance``.") from error
-    else:
-        if not isinstance(types, tuple):
-            types = (types,)
-        return types
+        copy._name = self.name
+        return copy
+
+    def __copy__(self) -> quality:
+        return self()
+
+    def __deepcopy__(self, memo: dict) -> quality:
+        fval = deepcopy(self.fval, memo)
+        feff = deepcopy(self.feff, memo)
+        default = deepcopy(self._default, memo)
+        null = deepcopy(self._null, memo)
+        return self(fval, feff, default=default, null=null)
+
+    ################################################################################
+    ## PROPERTIES
+    ################################################################################
+    @property
+    def default(self) -> Any:
+        """Default value for the managed attribute to be used for `...`.
+
+        Enforced immutable to avoid side-effects.
+        """
+        return deepcopy(self._default)
+
+    @property
+    def null(self) -> Any:
+        """Null value for the managed attribute to be used for `None`.
+
+        Enforced immutable to avoid side-effects.
+        """
+        return deepcopy(self._null)
+
+    @property
+    def name(self) -> str:
+        """Name for attr in managed instance as provided by `__set_name__`."""
+        return self._name
+
+    @property
+    def private_name(self) -> str:
+        """Private name to store attr value at in managed instance."""
+        if self.name is None:
+            return None
+        return "__quality_" + self.name
+
+    ################################################################################
+    ## DESCRIPTOR PROTOCOL
+    ################################################################################
+    def __set_name__(self, owner: type, name: str) -> None:
+        self._name = name
+
+    def __get__(self, obj: object, objtype: type = None) -> Any:
+        if obj is None:
+            return self
+        if hasattr(obj, self.private_name):
+            return getattr(obj, self.private_name)
+        setattr(obj, self.name, UNSET)
+        return getattr(obj, self.name)
+
+    def __set__(self, obj: object, value: Any) -> None:
+        value = self._apply_defaults(value)
+        if self.fval is not None:
+            value = self.fval(obj, value)
+        setattr(obj, self.private_name, value)
+        if self.feff is not None:
+            self.feff(obj)
+
+    def __delete__(self, obj: object) -> None:
+        if hasattr(obj, self.private_name):
+            delattr(obj, self.private_name)
+        if self.feff is not None:
+            self.feff(obj)
+
+    ################################################################################
+    ## DECORATORS
+    ################################################################################
+    def validator(self, fval: Callable[[Any, Any], Any] | None) -> quality:
+        """Returns a copy of the quality with a different validator."""
+        return self(fval=fval)
+
+    def side_effect(self, feff: Callable[[Any], None] | None) -> quality:
+        """Returns a copy of the quality with a different side effect."""
+        return self(feff=feff)
+
+    ################################################################################
+    ## AUXILIARY
+    ################################################################################
+    def _apply_defaults(self, value: Any) -> Any:
+        """Apply defaults to user input value."""
+        default = self.default or self.null
+        null = self.null or self.default
+        if value is UNSET:
+            value = default or None
+        elif value is Ellipsis and default is not UNSET:
+            value = default
+        elif value is None and null is not UNSET:
+            value = null
+        return value
diff --git a/zne/zne_strategy.py b/zne/zne_strategy.py
index 037223e..d2d61a7 100644
--- a/zne/zne_strategy.py
+++ b/zne/zne_strategy.py
@@ -15,9 +15,6 @@
 from __future__ import annotations
 
 from collections.abc import Iterable, Iterator, Sequence
-from copy import copy
-from dataclasses import dataclass
-from functools import cached_property
 from typing import Any
 from warnings import warn
 
@@ -25,62 +22,65 @@
 from qiskit import QuantumCircuit
 from qiskit.primitives import EstimatorResult
 from qiskit.primitives.utils import _circuit_key
-from qiskit.providers import Backend
-from qiskit.transpiler import PassManager, StagedPassManager
-from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
 
 from .extrapolation import Extrapolator, LinearExtrapolator
-from .noise_amplification import CxAmplifier, NoiseAmplifier
+from .noise_amplification import NoiseAmplifier, TwoQubitAmplifier
 from .types import EstimatorResultData, Metadata, RegressionDatum, ZNECache, ZNECacheKey
 from .utils.grouping import from_common_key, group_elements_gen, merge_dicts
 from .utils.typing import isreal
+from .utils.validation import quality
 
-NOISE_AMPLIFICATION_STAGE: str = "noise_amplification"
 
-
-@dataclass
 class ZNEStrategy:
-    """Zero Noise Extrapolation configuration dataclass.
+    """Zero Noise Extrapolation strategy.
 
     Args:
+        noise_factors: An list of real valued noise factors that determine by what amount the
+            circuits' noise is amplified.
         noise_amplifier: A noise amplification strategy implementing the :class:`NoiseAmplifier`
             interface. A dictionary of currently available options can be imported as
             ``NOISE_AMPLIFIER_LIBRARY``.
-        noise_factors: An list of real valued noise factors that determine by what amount the
-            circuits' noise is amplified.
         extrapolator: An extrapolation strategy implementing the :class:`Extrapolator`
             interface. A dictionary of currently available options can be imported as
             ``EXTRAPOLATOR_LIBRARY``.
-        transpilation_level: Default preset :class:`~qiskit.transpiler.PassManager` level.
-            If ``None``, default will be empty (i.e. skip transpilation). If transpiler is
-            also provided, it will take precedence over this default.
-        transpiler: A :class:`~qiskit.transpiler.PassManager` object expressing the
-            transpilation strategy to be performed; if ``None``, defaults will apply. Noise
-            amplification will be performed:
-
-            #. In ``noise_amplification`` stage if defined, provided that transpiler is of type
-            :class:`~qiskit.transpiler.StagedPassManager` (i.e. overriding said stage but
-            maintaining pre and post stages intact).
-            #. Right before first ``scheduling`` stage if defined, provided that transpiler
-            is of type :class:`~qiskit.transpiler.StagedPassManager` without an explicit
-            ``noise_amplification`` stage (i.e. a new ``noise_amplification`` stage will be
-            created right before ``scheduling``).
-            #. After all specified transpilation passes otherwise.
 
     Raises:
         TypeError: If input does not match the type specification.
         ValueError: If noise factors are empty or less than one.
-        FrozenInstanceError: On attempt to update any data field.
     """
 
-    noise_factors: Sequence[float] = (1,)
-    noise_amplifier: NoiseAmplifier = CxAmplifier()
-    extrapolator: Extrapolator = LinearExtrapolator()
-    transpilation_level: int | None = 1
-    transpiler: PassManager | None = None
+    _DEFINING_ATTRS = (
+        "noise_factors",
+        "noise_amplifier",
+        "extrapolator",
+    )
+
+    def __init__(
+        self,
+        noise_factors: Sequence[float] | None = None,
+        noise_amplifier: NoiseAmplifier | None = None,
+        extrapolator: Extrapolator | None = None,
+    ) -> None:
+        self.noise_factors = noise_factors
+        self.noise_amplifier = noise_amplifier
+        self.extrapolator = extrapolator
+
+    def __repr__(self) -> str:
+        attrs_str = ", ".join(
+            f"{attr}={repr(getattr(self, attr))}" for attr in self._DEFINING_ATTRS
+        )
+        return f"ZNEStrategy({attrs_str})"
+
+    def __eq__(self, __o: object) -> bool:
+        if not isinstance(__o, self.__class__):
+            return False
+        return all(getattr(self, attr) == getattr(__o, attr) for attr in self._DEFINING_ATTRS)
+
+    def __bool__(self) -> bool:
+        return not self.is_noop
 
     ################################################################################
-    ## NEW
+    ## CONSTRUCTORS
     ################################################################################
     @classmethod
     def noop(cls) -> ZNEStrategy:
@@ -88,247 +88,82 @@ def noop(cls) -> ZNEStrategy:
         return cls(noise_factors=(1,))
 
     ################################################################################
-    ## INIT
+    ## PROPERTIES
     ################################################################################
-    def __post_init__(self) -> None:
-        """Input parsing and validation."""
-        self._validate_noise_amplifier()
-        self._validate_noise_factors()
-        self._validate_extrapolator()
-        self._validate_transpilation_level()
-        self._validate_transpiler()
-
-    def _validate_noise_amplifier(self) -> None:
-        """Validate noise amplifier."""
-        if not isinstance(self.noise_amplifier, NoiseAmplifier):
-            raise TypeError(
-                f"Expected NoiseAmplifier object, received {type(self.noise_amplifier)} instead."
-            )
+    @quality(default=(1,))
+    def noise_factors(self, noise_factors: Sequence[float]) -> tuple[float, ...]:
+        """Noise factors for ZNE.
 
-    def _validate_noise_factors(self) -> None:
-        """Validate noise factors config."""
-        if not isinstance(self.noise_factors, Sequence):
+        Validation logic defined as required by `quality`.
+        """
+        if not isinstance(noise_factors, Sequence):
             raise TypeError(
-                f"Expected Sequence object, received {type(self.noise_factors)} instead."
+                f"Expected `Sequence` noise factors, received `{type(noise_factors)}` instead."
             )
-        if not isinstance(self.noise_factors, tuple):
-            self.noise_factors = tuple(self.noise_factors)
-        if not self.noise_factors:
+        noise_factors = tuple(noise_factors)
+        if not noise_factors:
             raise ValueError("Noise factors must not be empty.")
-        for noise_factor in self.noise_factors:  # type: float
-            if not isreal(noise_factor):
-                raise TypeError("Noise factors must be real valued.")
-            if noise_factor < 1:
-                raise ValueError("Noise factors must be greater than or equal to one.")
-        noise_factors = tuple(sorted(self.noise_factors))
-        if self.noise_factors != noise_factors:
+        if not all(isreal(nf) for nf in noise_factors):
+            raise TypeError("Noise factors must be real valued.")
+        if any(nf < 1 for nf in noise_factors):
+            raise ValueError("Noise factors must be greater than or equal to one.")
+
+        unsorted_noise_factors = noise_factors
+        noise_factors = tuple(sorted(noise_factors))
+        if noise_factors != unsorted_noise_factors:
             warn("Unordered noise factors detected and rearranged.", UserWarning, stacklevel=3)
-            self.noise_factors = noise_factors
-        noise_factors = tuple(sorted(set(self.noise_factors)))
-        if self.noise_factors != noise_factors:
+
+        duplicate_noise_factors = noise_factors
+        noise_factors = tuple(sorted(set(noise_factors)))
+        if noise_factors != duplicate_noise_factors:
             warn("Duplicate noise factors detected and erased.", UserWarning, stacklevel=3)
-            self.noise_factors = noise_factors
 
-    def _validate_extrapolator(self) -> None:
-        """Validate extrapolator."""
-        if not isinstance(self.extrapolator, Extrapolator):
-            raise TypeError(
-                f"Expected Extrapolator object, received {type(self.extrapolator)} instead."
-            )
+        return noise_factors
 
-    def _validate_transpilation_level(self) -> None:
-        """Validate transpilation level config."""
-        if not isinstance(self.transpilation_level, (int, type(None))):
+    @quality(default=TwoQubitAmplifier())
+    def noise_amplifier(self, noise_amplifier: NoiseAmplifier) -> NoiseAmplifier:
+        """Noise amplifier strategy for ZNE.
+
+        Validation logic defined as required by `quality`.
+        """
+        if not isinstance(noise_amplifier, NoiseAmplifier):
             raise TypeError(
-                f"Expected optional int, received {type(self.transpilation_level)} instead."
+                f"Expected `NoiseAmplifier` object, received `{type(noise_amplifier)}` instead."
             )
+        return noise_amplifier
 
-    def _validate_transpiler(self) -> None:
-        """Validate transpiler."""
-        if isinstance(self.transpiler, (StagedPassManager, type(None))):
-            pass
-        elif isinstance(self.transpiler, PassManager):
-            stages = {"transpilation": self.transpiler}
-            transpiler = StagedPassManager(stages, **stages)
-            self.transpiler = transpiler
-        else:
+    @quality(default=LinearExtrapolator())
+    def extrapolator(self, extrapolator: Extrapolator) -> Extrapolator:
+        """Extrapoaltor strategy for ZNE.
+
+        Validation logic defined as required by `quality`.
+        """
+        if not isinstance(extrapolator, Extrapolator):
             raise TypeError(
-                f"Expected optional PassManager object, received {type(self.transpiler)} instead."
+                f"Expected `Extrapolator` object, received `{type(extrapolator)}` instead."
             )
+        return extrapolator
 
-    ################################################################################
-    ## PROPERTIES
-    ################################################################################
-    @cached_property
+    @property
     def num_noise_factors(self) -> int:
         """Number of noise factors."""
         return len(self.noise_factors)
 
-    @cached_property
+    @property
     def performs_noise_amplification(self) -> bool:
         """Checks if noise amplification is performed."""
         return any(nf > 1 for nf in self.noise_factors)
 
-    @cached_property
+    @property
     def performs_zne(self) -> bool:
         """Checks if zero noise extrapolation is performed."""
         return self.performs_noise_amplification and self.num_noise_factors > 1
 
-    @cached_property
+    @property
     def is_noop(self) -> bool:
         """Checks if strategy is no-op."""
         return not self.performs_noise_amplification and not self.performs_zne
 
-    ################################################################################
-    ## TRANSPILATION (experimental)
-    ################################################################################
-    def build_transpilers(self, backend: Backend) -> tuple[StagedPassManager, ...]:
-        """Generate noisy transpilers from configuration data for provided backend.
-
-        Args:
-            backend: target backend for the transpilers.
-
-        Returns:
-            An tuple of :class:`~qiskit.transpiler.StagedPassManager`s according to configs,
-            with a ``noise_amplification`` stage, and for different noise_factors.
-        """
-
-        def build_transpiler(noise_factor: float) -> StagedPassManager:
-            if self.performs_noise_amplification:
-                return self.build_noisy_transpiler(backend, noise_factor)
-            return self.build_noiseless_transpiler(backend)
-
-        return tuple(build_transpiler(nf) for nf in self.noise_factors)
-
-    def build_noisy_transpiler(self, backend: Backend, noise_factor: float) -> StagedPassManager:
-        """Generate transpiler from configuration data for provided backend and noise factor.
-
-        Args:
-            backend: target backend for the transpiler.
-            noise_factor: real number representing the amount that the circuit's noise
-                will be amplified.
-
-        Returns:
-            An instance of :class:`~qiskit.transpiler.StagedPassManager` according to configs,
-            with a ``noise_amplification`` stage.
-
-        Raises:
-            TypeError: If provided noise factor is not real valued.
-        """
-        if not isreal(noise_factor):
-            raise TypeError("Noise factor must be real valued.")
-        transpiler: StagedPassManager = self.build_noiseless_transpiler(backend)
-        setattr(
-            transpiler,
-            NOISE_AMPLIFICATION_STAGE,
-            self.noise_amplifier.build_pass_manager(noise_factor),
-        )
-        return transpiler
-
-    def build_noiseless_transpiler(self, backend: Backend) -> StagedPassManager:
-        """Generate noiseless transpiler from configuration data for provided backend.
-
-        Args:
-            backend: target backend for the transpiler.
-
-        Returns:
-            An new instance of :class:`~qiskit.transpiler.StagedPassManager` according to configs,
-            with a void ``noise_amplification`` stage.
-        """
-        transpiler: StagedPassManager = (
-            self.transpiler
-            if self.transpiler is not None
-            else self.build_default_transpiler(backend)
-        )
-        if NOISE_AMPLIFICATION_STAGE in transpiler.stages:
-            transpiler = self._clear_noise_amplification_stage(transpiler, warn_user=True)
-        else:
-            transpiler = self._insert_void_noise_amplification_stage(transpiler)
-        return transpiler
-
-    def build_default_transpiler(self, backend: Backend) -> StagedPassManager:
-        """Generate default transpiler from configuration data for provided backend.
-
-        Args:
-            backend: target backend for the transpiler.
-
-        Returns:
-            The default transpiler pass manager according to configs.
-
-        Raises:
-            TypeError: If backend is not of the :class:`~qiskit.providers.Backend`
-        """
-        if self.transpilation_level is None:
-            return StagedPassManager()
-        if not isinstance(backend, Backend):
-            raise TypeError(f"Expected Backend object, received {type(backend)} instead.")
-        return generate_preset_pass_manager(self.transpilation_level, backend=backend)
-
-    @staticmethod
-    def _clear_noise_amplification_stage(
-        pass_manager: StagedPassManager, warn_user: bool = False
-    ) -> StagedPassManager:
-        """Set ``noise_amplification`` stage in input StagedPassManager to ``None``.
-
-        Args:
-            pass_manager: The :class:`~qiskit.transpiler.StagedPassManager` object to clear.
-
-        Returns:
-            StagedPassManager: A shallow copy of the input pass manager with its
-                ``noise_amplification`` stage set to ``None``.
-
-        Raises:
-            ValueError: If ``noise_amplification`` is not present.
-        """
-        if NOISE_AMPLIFICATION_STAGE not in pass_manager.stages:
-            raise ValueError("Missing ``noise_amplification`` stage.")
-        pass_manager = copy(pass_manager)
-        if getattr(pass_manager, NOISE_AMPLIFICATION_STAGE) is not None:
-            setattr(pass_manager, NOISE_AMPLIFICATION_STAGE, None)
-            if warn_user:
-                warn("Noise amplification stage cleared.", UserWarning)
-        return pass_manager
-
-    def _insert_void_noise_amplification_stage(
-        self,
-        pass_manager: StagedPassManager,
-    ) -> StagedPassManager:
-        """Insert void ``noise_amplification`` stage to input StagedPassManager.
-
-        #. Before first ``scheduling`` stage if any.
-        #. After all other passes otherwise.
-
-        Args:
-            pass_manager: The :class:`~qiskit.transpiler.StagedPassManager` object to insert
-                the stage into.
-
-        Returns:
-            StagedPassManager: A shallow copy of the input pass manager with an additional void
-                ``noise_amplification`` stage.
-
-        Raises:
-            ValueError: If ``noise_amplification`` is already present.
-        """
-        if NOISE_AMPLIFICATION_STAGE in pass_manager.stages:
-            raise ValueError("Found ``noise_amplification`` stage already present.")
-        stage_names = self._build_noisy_stages(pass_manager.stages)
-        stages = {stage: getattr(pass_manager, stage, None) for stage in set(stage_names)}
-        return StagedPassManager(stage_names, **stages)
-
-    @staticmethod
-    def _build_noisy_stages(original_stages: Sequence[str]) -> tuple[str, ...]:
-        """Build noisy stages tuple from original stages."""
-
-        def generate_noisy_stages(original_stages: Sequence[str]) -> Iterator[str]:
-            noise_amplification_stages: Iterator[str] = iter([NOISE_AMPLIFICATION_STAGE])
-            for stage in original_stages:
-                if stage == "scheduling":
-                    yield from noise_amplification_stages
-                yield stage
-            yield from noise_amplification_stages
-
-        return tuple(generate_noisy_stages(original_stages))
-
     ################################################################################
     ## NOISE AMPLIFICATION
     ################################################################################


Test patch:
diff --git a/test/__init__.py b/test/__init__.py
index f151b29..4f200e5 100644
--- a/test/__init__.py
+++ b/test/__init__.py
@@ -41,6 +41,7 @@ def test_version():
 NO_NUM = [t for t in NO_REAL if type(t) != type(COMPLEX)]
 ITERS = [t for t in TYPES if t in [STR, LST, TPL, DICT]]
 NO_ITERS = [t for t in TYPES if t not in ITERS]
+NO_ITERS_NONE = [t for t in NO_ITERS if type(t) != type(NONE)]
 
 
 ################################################################################
diff --git a/test/test_zne_strategy.py b/test/test_zne_strategy.py
index 064999c..63ed3ed 100644
--- a/test/test_zne_strategy.py
+++ b/test/test_zne_strategy.py
@@ -12,127 +12,181 @@
 
 from collections.abc import Sequence
 from itertools import count, product
-from test import TYPES
-from unittest.mock import Mock, patch
+from unittest.mock import Mock
 
 from numpy import array
 from pytest import fixture, mark, raises, warns
 from qiskit import QuantumCircuit
 from qiskit.circuit.random import random_circuit
 from qiskit.primitives import EstimatorResult
-from qiskit.providers import Backend
-from qiskit.transpiler import PassManager, StagedPassManager
-
-from zne.extrapolation import EXTRAPOLATOR_LIBRARY, Extrapolator, LinearExtrapolator
-from zne.noise_amplification import NOISE_AMPLIFIER_LIBRARY, CxAmplifier, NoiseAmplifier
-from zne.utils.typing import isreal
-from zne.zne_strategy import NOISE_AMPLIFICATION_STAGE, ZNEStrategy
-
-
-class TestZNEStrategy:
-    ################################################################################
-    ## FIXTURE
-    ################################################################################
-    @fixture(scope="function")
-    def amplifier_mock(self):
-        amplifier = Mock(NoiseAmplifier)
-        amplifier.amplify_circuit_noise.side_effect = count()
-        return amplifier
-
-    @fixture(scope="function")
-    def extrapolator_mock(self):
-        def infer(target, data):
-            _, y, _ = zip(*data)
-            return 1, 0, {}
 
-        extrapolator = Mock(Extrapolator)
-        extrapolator.infer.side_effect = infer
-        return extrapolator
+from zne.extrapolation import Extrapolator, LinearExtrapolator
+from zne.noise_amplification import NoiseAmplifier
+from zne.noise_amplification.folding_amplifier import TwoQubitAmplifier
+from zne.zne_strategy import ZNEStrategy
 
-    ################################################################################
-    ## NEW
-    ################################################################################
-    def test_noop(self):
-        zne_strategy = ZNEStrategy.noop()
-        assert zne_strategy.is_noop
+from . import NO_ITERS_NONE, NO_NONE
+
+
+################################################################################
+## FIXTURES
+################################################################################
+@fixture(scope="function")
+def amplifier_mock():
+    """NoiseAmplifier mock object."""
+    amplifier = Mock(NoiseAmplifier)
+    amplifier.amplify_circuit_noise.side_effect = count()
+    return amplifier
+
+
+@fixture(scope="function")
+def extrapolator_mock():
+    """Extrapolator mock object."""
+
+    def infer(target, data):
+        _, y, _ = zip(*data)
+        return 1, 0, {}
+
+    extrapolator = Mock(Extrapolator)
+    extrapolator.infer.side_effect = infer
+    return extrapolator
+
+
+################################################################################
+## TESTS
+################################################################################
+def test_definition():
+    assert ZNEStrategy._DEFINING_ATTRS == (
+        "noise_factors",
+        "noise_amplifier",
+        "extrapolator",
+    )
+
+
+class TestInit:
+    """Test ZNEStrategy initialization logic."""
 
-    ################################################################################
-    ## INIT
-    ################################################################################
     def test_defaults(self):
-        DEFAULT_NOISE_AMPLIFIER = CxAmplifier()
-        DEFAULT_NOISE_FACTORS = (1,)
-        DEFAULT_EXTRAPOLATOR = LinearExtrapolator()
-        DEFAULT_TRANSPILER_LEVEL = 1
-        DEFAULT_TRANSPILER = None
-        zne_strategy = ZNEStrategy()
-        assert zne_strategy.noise_amplifier == DEFAULT_NOISE_AMPLIFIER
-        assert zne_strategy.noise_factors == DEFAULT_NOISE_FACTORS
-        assert zne_strategy.extrapolator == DEFAULT_EXTRAPOLATOR
-        assert zne_strategy.transpilation_level == DEFAULT_TRANSPILER_LEVEL
-        assert zne_strategy.transpiler == DEFAULT_TRANSPILER
+        """Test default configuration."""
+        assert ZNEStrategy().noise_amplifier == TwoQubitAmplifier()
+        assert ZNEStrategy().noise_factors == (1,)
+        assert ZNEStrategy().extrapolator == LinearExtrapolator()
 
     @mark.parametrize(
-        "NoiseAmplifier, noise_factors, Extrapolator, transpilation_level, transpiler",
-        cases := list(
-            product(
-                NOISE_AMPLIFIER_LIBRARY.values(),
-                [(1,), (1, 3), (1, 3, 5)],
-                EXTRAPOLATOR_LIBRARY.values(),
-                [None, 0, 1, 2, 3],
-                [None, StagedPassManager()],
-            )
-        ),
-        ids=[f"{na.name}-{nf}-{e.name}" for na, nf, e, _, _ in cases],
+        "noise_factors",
+        [(1,), (1, 3), (1, 3, 5)],
     )
-    def test_zne_strategy(
-        self, NoiseAmplifier, noise_factors, Extrapolator, transpilation_level, transpiler
+    def test_custom(
+        self,
+        noise_factors,
     ):
-        noise_amplifier = NoiseAmplifier()
-        extrapolator = Extrapolator()
+        """Test custom configuration.
+
+        Proper inputs can be assumed since validation is tested separately.
+        """
+        noise_amplifier = Mock(NoiseAmplifier)
+        extrapolator = Mock(Extrapolator)
         zne_strategy = ZNEStrategy(
-            noise_amplifier=noise_amplifier,
             noise_factors=noise_factors,
+            noise_amplifier=noise_amplifier,
             extrapolator=extrapolator,
-            transpilation_level=transpilation_level,
-            transpiler=transpiler,
         )
-        assert zne_strategy.noise_amplifier is noise_amplifier
         assert zne_strategy.noise_factors == noise_factors
+        assert zne_strategy.noise_amplifier is noise_amplifier
         assert zne_strategy.extrapolator is extrapolator
-        assert zne_strategy.transpilation_level == transpilation_level
-        assert zne_strategy.transpiler is transpiler
 
-    @mark.parametrize("obj", TYPES, ids=[str(type(t).__name__) for t in TYPES])
-    def test_type_error(self, obj):
-        with raises(TypeError):
-            _ = ZNEStrategy(noise_amplifier=obj)
-        with raises(TypeError):
-            _ = ZNEStrategy(extrapolator=obj)
-        if not isinstance(obj, Sequence):
-            with raises(TypeError):
-                _ = ZNEStrategy(noise_factors=obj)
-        if not isinstance(obj, (int, type(None))):
-            with raises(TypeError):
-                _ = ZNEStrategy(transpilation_level=obj)
-        if obj is not None:
-            with raises(TypeError):
-                _ = ZNEStrategy(transpiler=obj)
+
+@mark.parametrize(
+    "noise_factors",
+    [(1,), (1, 3), (1, 3, 5)],
+)
+class TestMagic:
+    """Test generic ZNEStrategy magic methods."""
+
+    def test_repr(self, noise_factors):
+        """Test ZNEStrategy.__repr__() magic method."""
+        noise_amplifier = Mock(NoiseAmplifier)
+        extrapolator = Mock(Extrapolator)
+        zne_strategy = ZNEStrategy(
+            noise_factors=noise_factors,
+            noise_amplifier=noise_amplifier,
+            extrapolator=extrapolator,
+        )
+        expected = "ZNEStrategy("
+        expected += f"noise_factors={repr(noise_factors)}, "
+        expected += f"noise_amplifier={repr(noise_amplifier)}, "
+        expected += f"extrapolator={repr(extrapolator)})"
+        assert repr(zne_strategy) == expected
+
+    def test_eq(self, noise_factors):
+        """Test ZNEStrategy.__eq__() magic method."""
+        noise_amplifier = Mock(NoiseAmplifier)
+        extrapolator = Mock(Extrapolator)
+        zne_strategy = ZNEStrategy(
+            noise_factors=noise_factors,
+            noise_amplifier=noise_amplifier,
+            extrapolator=extrapolator,
+        )
+        assert zne_strategy == ZNEStrategy(
+            noise_factors=noise_factors,
+            noise_amplifier=noise_amplifier,
+            extrapolator=extrapolator,
+        )
+        assert zne_strategy != ZNEStrategy(
+            noise_factors=(*noise_factors, 707),
+            noise_amplifier=noise_amplifier,
+            extrapolator=extrapolator,
+        )
+        assert zne_strategy != ZNEStrategy(
+            noise_factors=noise_factors,
+            noise_amplifier=Mock(NoiseAmplifier),
+            extrapolator=extrapolator,
+        )
+        assert zne_strategy != ZNEStrategy(
+            noise_factors=noise_factors,
+            noise_amplifier=noise_amplifier,
+            extrapolator=Mock(Extrapolator),
+        )
+        assert zne_strategy != "zne_strategy"
+
+    def test_bool(self, noise_factors):
+        noise_amplifier = Mock(NoiseAmplifier)
+        extrapolator = Mock(Extrapolator)
+        zne_strategy = ZNEStrategy(
+            noise_factors=noise_factors,
+            noise_amplifier=noise_amplifier,
+            extrapolator=extrapolator,
+        )
+        truth_value = not zne_strategy.is_noop
+        assert bool(zne_strategy) is truth_value
+
+
+class TestConstructors:
+    """Test ZNEStrategy constructors."""
+
+    def test_noop(self):
+        zne_strategy = ZNEStrategy.noop()
+        assert zne_strategy.is_noop
+
+
+class TestNoiseFactors:
+    """Test ZNEStrategy `noise_factors` property."""
+
+    def test_default(self):
+        zne_strategy = ZNEStrategy()
+        zne_strategy.noise_factors = None
+        assert zne_strategy.noise_factors == (1,)
 
     @mark.parametrize(
-        "noise_factors, expected",
-        cases := list(
-            zip(
-                [(1,), (3,), (1, 3), (1, 3, 5), [1, 3, 5], [1.2, 3, 5.4]],
-                [(1,), (3,), (1, 3), (1, 3, 5), (1, 3, 5), (1.2, 3, 5.4)],
-            )
-        ),
-        ids=[f"{nf}" for nf, _ in cases],
+        "noise_factors",
+        cases := [(1,), (3,), (1, 3), (1, 3, 5), [1, 3, 5], [1.2, 3, 5.4]],
+        ids=[f"{nf}" for nf in cases],
     )
-    def test_noise_factors(self, noise_factors, expected):
-        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
-        assert zne_strategy.noise_factors == expected
-        assert zne_strategy.num_noise_factors == len(expected)
+    def test_dispatch(self, noise_factors):
+        """Test proper noise factors of different types."""
+        zne_strategy = ZNEStrategy()
+        zne_strategy.noise_factors = noise_factors
+        assert zne_strategy.noise_factors == tuple(noise_factors)
 
     @mark.parametrize(
         "noise_factors, expected",
@@ -144,11 +198,12 @@ def test_noise_factors(self, noise_factors, expected):
         ),
         ids=[f"{nf}" for nf, _ in cases],
     )
-    def test_noise_factors_sort(self, noise_factors, expected):
+    def test_sort(self, noise_factors, expected):
+        """Test unsorted noise factors."""
+        zne_strategy = ZNEStrategy()
         with warns(UserWarning):
-            zne_strategy = ZNEStrategy(noise_factors=noise_factors)
+            zne_strategy.noise_factors = noise_factors
         assert zne_strategy.noise_factors == expected
-        assert zne_strategy.num_noise_factors == len(expected)
 
     @mark.parametrize(
         "noise_factors, expected",
@@ -160,291 +215,139 @@ def test_noise_factors_sort(self, noise_factors, expected):
         ),
         ids=[f"{nf}" for nf, _ in cases],
     )
-    def test_noise_factors_duplicates(self, noise_factors, expected):
+    def test_duplicates(self, noise_factors, expected):
+        """Test duplicate noise factors."""
+        zne_strategy = ZNEStrategy()
         with warns(UserWarning):
-            zne_strategy = ZNEStrategy(noise_factors=noise_factors)
+            zne_strategy.noise_factors = noise_factors
         assert zne_strategy.noise_factors == expected
-        assert zne_strategy.num_noise_factors == len(expected)
+
+    @mark.parametrize(
+        "noise_factors",
+        cases := NO_ITERS_NONE,
+        ids=[f"{type(c)}" for c in cases],
+    )
+    def test_sequence(self, noise_factors):
+        """Test type error is raised if noise factors are not Sequence."""
+        zne_strategy = ZNEStrategy()
+        with raises(TypeError):
+            zne_strategy.noise_factors = noise_factors
 
     @mark.parametrize(
         "noise_factors",
         cases := [(), []],
         ids=[f"{type(c)}" for c in cases],
     )
-    def test_noise_factors_empty_error(self, noise_factors):
+    def test_empty(self, noise_factors):
+        """Test value error is raised for empty lists of noise factors."""
+        zne_strategy = ZNEStrategy()
         with raises(ValueError):
-            _ = ZNEStrategy(noise_factors=noise_factors)
+            zne_strategy.noise_factors = noise_factors
 
     @mark.parametrize(
         "noise_factors",
         cases := ["1", True, False, float("NaN"), [1, 3, "5"]],
         ids=[f"{type(c)}" for c in cases],
     )
-    def test_noise_factors_real_type_error(self, noise_factors):
+    def test_real(self, noise_factors):
+        """Test type error is raised if noise factors are not real numbers."""
         if not isinstance(noise_factors, Sequence):
             noise_factors = [noise_factors]
+        zne_strategy = ZNEStrategy()
         with raises(TypeError):
-            _ = ZNEStrategy(noise_factors=noise_factors)
+            zne_strategy.noise_factors = noise_factors
 
     @mark.parametrize(
         "noise_factors",
-        cases := [0, 0.9999, -1, -0.5],
+        cases := [0, 0.9999, -1, -0.5, (1, 0), (0.9, 1.2)],
         ids=[f"{c}" for c in cases],
     )
-    def test_noise_factors_value_error(self, noise_factors):
+    def test_geq_one(self, noise_factors):
+        """Test value error is raised if any noise factor is less than one."""
         if not isinstance(noise_factors, Sequence):
             noise_factors = [noise_factors]
+        zne_strategy = ZNEStrategy()
         with raises(ValueError):
-            _ = ZNEStrategy(noise_factors=noise_factors)
-
-    def test_validate_transpiler(self):
-        # None
-        zne_strategy = ZNEStrategy(transpiler=None)
-        assert zne_strategy.transpiler is None
-
-        # StagedPassManager
-        transpiler = StagedPassManager()
-        zne_strategy = ZNEStrategy(transpiler=transpiler)
-        assert zne_strategy.transpiler is transpiler
-
-        # PassManager
-        transpiler = PassManager()
-        zne_strategy = ZNEStrategy(transpiler=transpiler)
-        assert zne_strategy.transpiler.stages == ("transpilation",)
-        assert zne_strategy.transpiler.transpilation is transpiler
-
-    ################################################################################
-    ## PROPERTIES
-    ################################################################################
-    @mark.parametrize("noise_factors", [(1, 3), (1.2,), (2.1, 4.5)])
-    def test_performs_noise_amplification_true(self, noise_factors):
-        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
-        assert zne_strategy.performs_noise_amplification
+            zne_strategy.noise_factors = noise_factors
 
-    @mark.parametrize("noise_factors", [(1,), [1]])
-    def test_performs_noise_amplification_false(self, noise_factors):
-        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
-        assert not zne_strategy.performs_noise_amplification
 
-    @mark.parametrize("noise_factors", [(1, 3), (1.2, 2.4)])
-    def test_performs_zne_true(self, noise_factors):
-        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
-        assert zne_strategy.performs_zne
-
-    @mark.parametrize("noise_factors", [(1,), (2.1,)])
-    def test_performs_zne_false(self, noise_factors):
-        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
-        assert not zne_strategy.performs_zne
+class TestNoiseAmplifier:
+    """Test ZNEStrategy `noise_amplifier` property."""
 
-    @mark.parametrize("noise_factors", [(1,), (2.1,), (1, 3), (2.1, 4.5)])
-    def test_is_noop(self, noise_factors):
-        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
-        if tuple(noise_factors) == (1,):
-            assert zne_strategy.is_noop
-        else:
-            assert not zne_strategy.is_noop
-
-    ################################################################################
-    ## TRANSPILATION
-    ################################################################################
-    @staticmethod
-    def test_noise_amplification_stage_name():
-        assert NOISE_AMPLIFICATION_STAGE == "noise_amplification"
+    def test_default(self):
+        zne_strategy = ZNEStrategy()
+        zne_strategy.noise_amplifier = None
+        assert zne_strategy.noise_amplifier == TwoQubitAmplifier()
 
     @mark.parametrize(
-        "noise_factors",
-        cases := [(1,), (3,), (1, 3), (1, 3, 5)],
-        ids=[f"{nf}" for nf in cases],
+        "noise_amplifier",
+        cases := NO_NONE,
+        ids=[f"{type(c)}" for c in cases],
     )
-    def test_build_transpilers(self, noise_factors):
-        backend = Backend()
-        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
+    def test_type_error(self, noise_amplifier):
+        """Test type error is raised if not `NoiseAmplifier`."""
+        zne_strategy = ZNEStrategy()
+        with raises(TypeError):
+            zne_strategy.noise_amplifier = noise_amplifier
 
-        target_generate = (
-            "build_noisy_transpiler"
-            if zne_strategy.performs_noise_amplification
-            else "build_noiseless_transpiler"
-        )
 
-        def side_effect(*args):
-            if zne_strategy.performs_noise_amplification:
-                _, nf = args
-                return f"TRANSPILER<{nf}>"
-            return "TRANSPILER<1>"
-
-        mock_generate = Mock(side_effect=side_effect)
-        setattr(zne_strategy, target_generate, mock_generate)
-        transpilers = zne_strategy.build_transpilers(backend)
-        for nf in zne_strategy.noise_factors:
-            if zne_strategy.performs_noise_amplification:
-                mock_generate.assert_any_call(backend, nf)
-            else:
-                mock_generate.assert_any_call(backend)
-            assert side_effect(backend, nf) in transpilers
-        assert len(transpilers) == zne_strategy.num_noise_factors
-        assert mock_generate.call_count == zne_strategy.num_noise_factors
+class TestExtrapolator:
+    """Test ZNEStrategy `extrapolator` property."""
+
+    def test_default(self):
+        zne_strategy = ZNEStrategy()
+        zne_strategy.extrapolator = None
+        assert zne_strategy.extrapolator == LinearExtrapolator()
 
     @mark.parametrize(
-        "noise_factor",
-        cases := [1, 3, 5, 2.4, 1.2],
-        ids=[f"{c}" for c in cases],
+        "extrapolator",
+        cases := NO_NONE,
+        ids=[f"{type(c)}" for c in cases],
     )
-    def test_build_noisy_transpiler(self, noise_factor):
-        backend = Backend()
+    def test_type_error(self, extrapolator):
+        """Test type error is raised if not `NoiseAmplifier`."""
         zne_strategy = ZNEStrategy()
+        with raises(TypeError):
+            zne_strategy.extrapolator = extrapolator
 
-        noise_amplifier = Mock()
-        noise_amplifier.build_pass_manager = Mock(
-            return_value=(noise_amplification := f"NOISE-AMPLIFICATION<{noise_factor}>")
-        )
-        zne_strategy.noise_amplifier = noise_amplifier
-
-        mock_generate = Mock(return_value=(transpiler := Mock()))
-        zne_strategy.build_noiseless_transpiler = mock_generate
-
-        zne_strategy.build_noisy_transpiler(backend, noise_factor)
 
-        mock_generate.assert_called_once_with(backend)
-        noise_amplifier.build_pass_manager.assert_called_once_with(noise_factor)
-        assert getattr(transpiler, NOISE_AMPLIFICATION_STAGE) == noise_amplification
+class TestProperties:
+    """Test generic ZNEStrategy properties."""
 
-    @mark.parametrize("obj", TYPES, ids=[str(type(t).__name__) for t in TYPES])
-    def test_build_noisy_transpiler_type_error(self, obj):
-        zne_strategy = ZNEStrategy()
-        if not isreal(obj):
-            with raises(TypeError):
-                _ = zne_strategy.build_noisy_transpiler(Backend(), obj)
-
-    def test_build_noiseless_transpiler(self):
-        return_value = "<NOISLESS-TRANSPILER>"
-
-        # Transpiler with noise_amplification stage
-        transpiler = StagedPassManager([NOISE_AMPLIFICATION_STAGE])
-        zne_strategy = ZNEStrategy(transpiler=transpiler)
-        mock_clear = Mock(return_value=return_value)
-        zne_strategy._clear_noise_amplification_stage = mock_clear
-        result = zne_strategy.build_noiseless_transpiler(Backend())
-        mock_clear.assert_called_once_with(transpiler, warn_user=True)
-        assert result == return_value
-
-        # No transpiler
-        transpiler = StagedPassManager()
-        zne_strategy = ZNEStrategy(transpiler=transpiler)
-        mock_insert = Mock(return_value=return_value)
-        zne_strategy._insert_void_noise_amplification_stage = mock_insert
-        result = zne_strategy.build_noiseless_transpiler(Backend())
-        mock_insert.assert_called_once_with(transpiler)
-        assert result == return_value
+    @mark.parametrize("noise_factors", [(1,), (1, 3), (1.2,), (2.1, 4.5)])
+    def test_performs_noise_amplification(self, noise_factors):
+        """Test if ZNEStrategy performs noise amplification."""
+        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
+        truth_value = any(nf > 1 for nf in noise_factors)
+        if truth_value:
+            assert zne_strategy.performs_noise_amplification
+        else:
+            assert not zne_strategy.performs_noise_amplification
 
-    @mark.parametrize(
-        "transpilation_level", cases := [None, 0, 1, 2, 3], ids=[f"{c}" for c in cases]
-    )
-    def test_build_default_transpiler(self, transpilation_level):
-        backend = Backend()
-        zne_strategy = ZNEStrategy(transpilation_level=transpilation_level)
-
-        return_value = f"TRANSPILER-{transpilation_level}"
-        with patch(
-            "zne.zne_strategy.generate_preset_pass_manager", return_value=return_value
-        ) as mock_generate:
-            transpiler = zne_strategy.build_default_transpiler(backend)
-        if transpilation_level is None:
-            assert isinstance(transpiler, StagedPassManager)
-            assert len(transpiler.passes()) == 0
+    @mark.parametrize("noise_factors", [(1,), (1, 3), (1.2,), (2.1, 4.5)])
+    def test_performs_zne(self, noise_factors):
+        """Test if ZNEStrategy performs zero noise extrapolation."""
+        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
+        truth_value = any(nf > 1 for nf in noise_factors) and len(noise_factors) > 1
+        if truth_value:
+            assert zne_strategy.performs_zne
         else:
-            mock_generate.assert_called_once_with(transpilation_level, backend=backend)
-            assert transpiler == return_value
+            assert not zne_strategy.performs_zne
 
-    @mark.parametrize("obj", TYPES, ids=[str(type(t).__name__) for t in TYPES])
-    def test_build_default_transpiler_type_error(self, obj):
-        zne_strategy = ZNEStrategy()
-        with raises(TypeError):
-            zne_strategy.build_default_transpiler(obj)
-
-    def test_clear_noise_amplification_stage(self):
-        stages = {NOISE_AMPLIFICATION_STAGE: PassManager()}
-
-        # None
-        spm = StagedPassManager([NOISE_AMPLIFICATION_STAGE])
-        clear_spm = ZNEStrategy._clear_noise_amplification_stage(spm)
-        for stage in spm.stages:
-            if stage != NOISE_AMPLIFICATION_STAGE:
-                assert getattr(clear_spm, stage) is getattr(spm, stage)
-        assert clear_spm is not spm
-        assert getattr(clear_spm, NOISE_AMPLIFICATION_STAGE) is None
-
-        # Not None
-        spm = StagedPassManager(stages, **stages)
-        clear_spm = ZNEStrategy._clear_noise_amplification_stage(spm)
-        for stage in spm.stages:
-            if stage != NOISE_AMPLIFICATION_STAGE:
-                assert getattr(clear_spm, stage) is getattr(spm, stage)
-        assert clear_spm is not spm
-        assert getattr(clear_spm, NOISE_AMPLIFICATION_STAGE) is None
-        assert getattr(spm, NOISE_AMPLIFICATION_STAGE) is not None
-
-    def test_clear_noise_amplification_stage_user_warning(self):
-        stages = {NOISE_AMPLIFICATION_STAGE: PassManager()}
-        spm = StagedPassManager(stages, **stages)
-        with warns(UserWarning):
-            _ = ZNEStrategy._clear_noise_amplification_stage(spm, warn_user=True)
+    @mark.parametrize("noise_factors", [(1,), (1, 3), (1.2,), (2.1, 4.5)])
+    def test_is_noop(self, noise_factors):
+        """Test if ZNEStrategy is no-op."""
+        zne_strategy = ZNEStrategy(noise_factors=noise_factors)
+        truth_value = tuple(noise_factors) == (1,)
+        if truth_value:
+            assert zne_strategy.is_noop
+        else:
+            assert not zne_strategy.is_noop
 
-    def test_clear_noise_amplification_stage_value_error(self):
-        spm = StagedPassManager()
-        with raises(ValueError):
-            _ = ZNEStrategy._clear_noise_amplification_stage(spm)
-
-    def test_insert_void_noise_amplification_stage(self):
-        stages = {
-            "alpha": PassManager(),
-            "beta": PassManager(),
-            "gamma": PassManager(),
-            "scheduling": PassManager(),
-            "omega": PassManager(),
-        }
-        spm = StagedPassManager(stages, **stages)
-        noise_spm = ZNEStrategy()._insert_void_noise_amplification_stage(spm)
-        assert NOISE_AMPLIFICATION_STAGE in noise_spm.stages
-        for stage in set(noise_spm.stages):
-            assert getattr(noise_spm, stage) is getattr(spm, stage, None)
-
-    def test_insert_void_noise_amplification_stage_value_error(self):
-        spm = StagedPassManager([NOISE_AMPLIFICATION_STAGE])
-        with raises(ValueError):
-            _ = ZNEStrategy()._insert_void_noise_amplification_stage(spm)
 
-    @mark.parametrize(
-        "stages, expected",
-        cases := tuple(
-            zip(
-                [
-                    (),
-                    ("alpha",),
-                    ("alpha", "omega"),
-                    ("alpha", "scheduling"),
-                    ("scheduling",),
-                    ("scheduling", "omega"),
-                    ("scheduling", "scheduling"),
-                ],
-                [
-                    (NOISE_AMPLIFICATION_STAGE,),
-                    ("alpha", NOISE_AMPLIFICATION_STAGE),
-                    ("alpha", "omega", NOISE_AMPLIFICATION_STAGE),
-                    ("alpha", NOISE_AMPLIFICATION_STAGE, "scheduling"),
-                    (NOISE_AMPLIFICATION_STAGE, "scheduling"),
-                    (NOISE_AMPLIFICATION_STAGE, "scheduling", "omega"),
-                    (NOISE_AMPLIFICATION_STAGE, "scheduling", "scheduling"),
-                ],
-            )
-        ),
-        ids=[i for i, _ in enumerate(cases)],
-    )
-    def test_build_noisy_stages(self, stages, expected):
-        assert expected == ZNEStrategy._build_noisy_stages(stages)
+class TestNoiseAmplification:
+    """Test ZNEStrategy noise amplification logic."""
 
-    ################################################################################
-    ## NOISE AMPLIFICATION
-    ################################################################################
     def test_amplify_circuit_noise(self, amplifier_mock):
         noise_factors = (1, 2, 3)
         zne_strategy = ZNEStrategy(noise_factors=noise_factors, noise_amplifier=amplifier_mock)
@@ -533,9 +436,10 @@ def test_map_to_noisy_circuits(self, num_noise_factors, arg, expected):
         zne_strategy = ZNEStrategy(noise_factors=[n for n in range(1, num_noise_factors + 1)])
         assert zne_strategy.map_to_noisy_circuits(arg) == expected
 
-    ################################################################################
-    ## EXTRAPOLATION
-    ################################################################################
+
+class TestExtrapolation:
+    """Test ZNEStrategy extrapolation logic."""
+
     @mark.parametrize(
         "noise_factors, values, variances, num_results, extrapolate_return",
         [
@@ -549,7 +453,7 @@ def test_mitigate_noisy_result(
         self, noise_factors, values, variances, num_results, extrapolate_return
     ):
         pred, meta = extrapolate_return
-        extrapolator = Mock()
+        extrapolator = Mock(Extrapolator)
         extrapolator.extrapolate_zero = Mock(return_value=tuple(extrapolate_return))
         zne_strategy = ZNEStrategy(noise_factors=noise_factors)
         zne_strategy.extrapolator = extrapolator
diff --git a/test/utils/test_unset.py b/test/utils/test_unset.py
new file mode 100644
index 0000000..a6bac67
--- /dev/null
+++ b/test/utils/test_unset.py
@@ -0,0 +1,43 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2022.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+from copy import copy, deepcopy
+
+from zne.utils.unset import UNSET, UnsetType
+
+
+class TestUnsetType:
+    """Test `UnsetType` class."""
+
+    def test_singleton(self):
+        """Test that class is a singleton."""
+        assert UnsetType() is UnsetType()
+        assert UNSET is UnsetType()
+
+    def test_bool(self):
+        """Test that instances evaluate to `False`."""
+        assert not UNSET
+        assert not UnsetType()
+
+    def test_eq(self):
+        """Test equality."""
+        assert UnsetType() == UnsetType()
+        assert UNSET == UnsetType()
+
+    def test_copy(self):
+        """Test copy."""
+        assert copy(UNSET) is UNSET
+
+    def test_deepcopy(self):
+        """Test deepcopy."""
+        assert deepcopy(UNSET, memo := {}) is UNSET
+        assert not memo
diff --git a/test/utils/test_validation.py b/test/utils/test_validation.py
index b35872c..1f129ba 100644
--- a/test/utils/test_validation.py
+++ b/test/utils/test_validation.py
@@ -10,83 +10,320 @@
 # copyright notice, and modified files need to carry a notice indicating
 # that they have been altered from the originals.
 
-from itertools import product
+from copy import copy, deepcopy
+from typing import Any
+from unittest.mock import Mock
 
 from pytest import mark, raises
 
-from zne.utils.validation import (
-    _validate_isinstance_types,
-    validate_object_type,
-    validate_sequence,
-)
-
-from .. import TYPES
+from zne.utils.unset import UNSET
+from zne.utils.validation import quality
 
 
 ################################################################################
-## VALIDATION
+## AUXILIARY
 ################################################################################
-@mark.parametrize("obj", [*(t for t in TYPES if t == t)])
-def test_validate_object_type(obj):
-    assert obj == validate_object_type(None, type(obj), default=obj)
-    assert obj == validate_object_type(obj, type(obj))
-    assert obj == validate_object_type(obj, (type(obj), *[type(t) for t in TYPES]))
-    with raises(TypeError):
-        validate_object_type(obj, [])
-    with raises(TypeError):
-        validate_object_type(obj, tuple(type(t) for t in TYPES if not isinstance(obj, type(t))))
-
-
-@mark.parametrize(
-    "seq, typecast",
-    cases := tuple(
-        product(
-            [
-                [0],
-                (0,),
-                [0, 1],
-                (0, 1),
-                [0, 1, 2],
-                (0, 1, 2),
-                [(), (0,), (0, 1)],
-                ((), (0,), (0, 1)),
-            ],
-            [list, tuple],
-        )
-    ),
-    ids=[f"{s}-{t.__name__}" for s, t in cases],
-)
-def test_validate_sequence(seq, typecast):
-    assert validate_sequence(seq) == seq
-    assert validate_sequence(None, default=seq) == seq
-    assert validate_sequence(seq, cast_single=True) == seq
-    assert validate_sequence(seq, standard_type=typecast) == typecast(seq)
-    assert validate_sequence(None, standard_type=typecast, default=seq) == typecast(seq)
-    assert validate_sequence(seq, standard_type=typecast, cast_single=True) == typecast(seq)
-    if len(seq) > 0:
-        el = seq[-1]
-        el_type = type(el)
-        assert validate_sequence(seq, el_type) == seq
-        assert validate_sequence(el, el_type, cast_single=True) == (el,)
-        assert validate_sequence(seq, el_type, standard_type=typecast) == typecast(seq)
-        assert validate_sequence(el, el_type, standard_type=typecast, cast_single=True) == typecast(
-            [el]
-        )
-    with raises(ValueError):
-        validate_sequence(seq, standard_type=type(None))
-    s = set(range(len(seq)))
-    assert validate_sequence(s, allow_set=True) == s
-    assert validate_sequence(tuple(s), allow_set=True, standard_type=set) == s
+class EqualCopies:
+    """Auxiliary wrapper class to check that deepcopies are equal but not the same object."""
+
+    def __init__(self, value: Any = None) -> None:
+        self.value = value
+
+    def __eq__(self, __o: object) -> bool:
+        if not isinstance(__o, self.__class__):
+            return False
+        return self.value == __o.value
 
 
 ################################################################################
-## AUXILIARY
+## TESTS
 ################################################################################
-@mark.parametrize("obj", [*(t for t in TYPES if t == t)])
-def test_validate_isinstance_types(obj):
-    tp = type(obj)
-    assert _validate_isinstance_types(tp) == (tp,)
-    assert _validate_isinstance_types((int, tp)) == (int, tp)
-    assert _validate_isinstance_types((tp, tp)) == (tp, tp)
-    with raises(TypeError):
-        _validate_isinstance_types(obj)
+class TestQualityInit:
+    """Test `quality` descriptor init."""
+
+    def test_defaults(self):
+        q = quality()
+        assert q.fval is None
+        assert q.feff is None
+        assert q.default is UNSET
+        assert q.null is UNSET
+
+    def test_custom(self):
+        fval = Mock()
+        feff = Mock()
+        default = EqualCopies("default")
+        null = EqualCopies("null")
+        q = quality(fval, feff, default=default, null=null)
+        assert q.fval is fval
+        assert q.feff is feff
+        assert q.default == default
+        assert q.null == null
+
+    def test_keyword_only(self):
+        with raises(TypeError):
+            quality(None, None, "default")
+
+    def test_uset_name(self):
+        q = quality()
+        assert q.name is None
+        assert q.private_name is None
+
+
+class TestQualityCall:
+    """Test `quality` descriptor call."""
+
+    @mark.parametrize(
+        "keys",
+        [
+            ("fval",),
+            ("feff",),
+            ("fval", "feff"),
+        ],
+    )
+    def test_mutable(self, keys):
+        q = quality()
+        kwargs = {k: Mock() for k in keys}
+        qq = q(**kwargs)
+        assert q is not qq
+        for key, value in kwargs.items():
+            assert getattr(qq, key) is value
+            assert getattr(q, key) is not value
+
+    @mark.parametrize(
+        "keys",
+        [
+            ("default",),
+            ("null",),
+            ("default", "null"),
+        ],
+    )
+    def test_immutable(self, keys):
+        q = quality()
+        kwargs = {k: EqualCopies(k) for k in keys}
+        qq = q(**kwargs)
+        assert q is not qq
+        for key, value in kwargs.items():
+            assert getattr(qq, key) == value
+            assert getattr(q, key) != value
+
+    @mark.parametrize("name", [None, "attr", "name", "foo", "bar"])
+    def test_name(self, name):
+        q = quality()
+        q._name = name
+        qq = q()
+        assert q is not qq
+        assert qq.name == q.name
+        assert qq.private_name == q.private_name
+
+
+@mark.parametrize("name", [None, "attr", "name", "foo", "bar"])
+class TestQualityCopy:
+    """Test `quality` descriptor copy logic."""
+
+    def test_copy(self, name):
+        fval = Mock()
+        feff = Mock()
+        default = EqualCopies("default")
+        null = EqualCopies("null")
+        descriptor = quality(fval, feff, default=default, null=null)
+        descriptor._name = name
+        duplicate = copy(descriptor)
+        assert descriptor is not duplicate
+        assert descriptor.fval is duplicate.fval
+        assert descriptor.feff is duplicate.feff
+        assert descriptor.default == duplicate.default
+        assert descriptor.null == duplicate.null
+        assert descriptor.name == duplicate.name
+        assert descriptor.private_name == duplicate.private_name
+
+    def test_deepcopy(self, name):
+        fval = EqualCopies("fval")
+        feff = EqualCopies("feff")
+        default = EqualCopies("default")
+        null = EqualCopies("null")
+        descriptor = quality(fval, feff, default=default, null=null)
+        descriptor._name = name
+        duplicate = deepcopy(descriptor, memo := {})
+
+        assert descriptor is not duplicate
+        assert descriptor.fval is not duplicate.fval
+        assert descriptor.fval == duplicate.fval
+        assert descriptor.feff is not duplicate.feff
+        assert descriptor.feff == duplicate.feff
+        assert descriptor.default is not duplicate.default
+        assert descriptor.default == duplicate.default
+        assert descriptor.null is not duplicate.null
+        assert descriptor.null == duplicate.null
+        assert descriptor.name == duplicate.name
+        assert descriptor.private_name == duplicate.private_name
+
+        memo_values = [fval, feff, default, null]
+        memo_values += [v.__dict__ for v in memo_values]
+        for value in memo.values():
+            if isinstance(value, quality):
+                assert value is duplicate
+            elif isinstance(value, list):
+                assert descriptor in value
+                for v in memo_values:
+                    assert v in value
+            else:
+                assert value in memo_values
+
+
+class TestQualityProperties:
+    """Test `quality` descriptor properties."""
+
+    @mark.parametrize("property", ["default", "null", "name", "private_name"])
+    def test_immutable(self, property):
+        descriptor = quality()
+        with raises(AttributeError):
+            setattr(descriptor, property, ...)
+
+    @mark.parametrize("property", ["default", "null"])
+    def test_copy(self, property):
+        p = EqualCopies(property)
+        descriptor = quality(**{property: p})
+        assert getattr(descriptor, property) is not p
+        assert getattr(descriptor, property) == p
+
+
+class TestQualityDescriptor:
+    """Test `quality` descriptor protocol."""
+
+    @mark.parametrize("attr", [None, "attr", "name", "foo", "bar"])
+    def test_set_name(self, attr):
+        """Test `quality` descriptor `__set_name__` logic."""
+        descriptor = quality()
+        type("Dummy", (), {attr: descriptor})
+        assert descriptor.name == attr
+        assert descriptor.private_name == attr if attr is None else "__quality_" + attr
+
+    def test_get(self):
+        """Test `quality` descriptor `__get__` logic."""
+        descriptor = quality()
+        Dummy = type("Dummy", (), {"q": descriptor})
+        assert Dummy.q is descriptor
+
+    @mark.parametrize(
+        "kwargs",
+        [
+            {"default": UNSET, "null": UNSET},
+            {"default": EqualCopies("default"), "null": UNSET},
+            {"default": UNSET, "null": EqualCopies("null")},
+            {"default": EqualCopies("default"), "null": EqualCopies("null")},
+        ],
+    )
+    class TestSet:
+        """Test `quality` descriptor `__set__` logic."""
+
+        def test_unset(self, kwargs):
+            default = kwargs.get("default")
+            null = kwargs.get("null")
+            descriptor = quality(**kwargs)
+            Dummy = type("Dummy", (), {"q": descriptor})
+            d = Dummy()
+            expected = None if default is UNSET and null is UNSET else default or null
+            assert d.q is not UNSET
+            assert d.q == expected
+            assert d.q == getattr(d, descriptor.private_name)
+
+        def test_none(self, kwargs):
+            default = kwargs.get("default")
+            null = kwargs.get("null")
+            descriptor = quality(**kwargs)
+            Dummy = type("Dummy", (), {"q": descriptor})
+            d = Dummy()
+            d.q = None
+            expected = None if default is UNSET and null is UNSET else null or default
+            assert d.q is not UNSET
+            assert d.q == expected
+            assert d.q == getattr(d, descriptor.private_name)
+
+        def test_ellipsis(self, kwargs):
+            default = kwargs.get("default")
+            null = kwargs.get("null")
+            descriptor = quality(**kwargs)
+            Dummy = type("Dummy", (), {"q": descriptor})
+            d = Dummy()
+            d.q = ...
+            expected = ... if default is UNSET and null is UNSET else default or null
+            assert d.q is not UNSET
+            assert d.q == expected
+            assert d.q == getattr(d, descriptor.private_name)
+
+        def test_set(self, kwargs):
+            descriptor = quality(**kwargs)
+            Dummy = type("Dummy", (), {"q": descriptor})
+            d = Dummy()
+            q = Mock()
+            d.q = q
+            assert d.q is q
+            assert d.q is getattr(d, descriptor.private_name)
+
+    def test_delete(self):
+        """Test `quality` descriptor `__delete__` logic."""
+        descriptor = quality()
+        Dummy = type("Dummy", (), {"q": descriptor})
+        d = Dummy()
+        assert not hasattr(d, descriptor.private_name)
+        del d.q  # Does not raise error
+        q = Mock()
+        d.q = q
+        assert getattr(d, descriptor.private_name) is q
+        del d.q
+        assert not hasattr(d, descriptor.private_name)
+
+    def test_fval(self):
+        """Test `quality` descriptor `fval` attribute."""
+        fval = Mock()
+        descriptor = quality(fval=fval)
+        Dummy = type("Dummy", (), {"q": descriptor})
+        d = Dummy()
+        q = Mock()
+        d.q = q
+        fval.assert_called_once_with(d, q)
+
+    def test_feff(self):
+        """Test `quality` descriptor `feff` attribute."""
+        feff = Mock()
+        descriptor = quality(feff=feff)
+        Dummy = type("Dummy", (), {"q": descriptor})
+        d = Dummy()
+        q = Mock()
+        d.q = q
+        feff.assert_called_once_with(d)
+        feff.reset_mock()
+        del d.q
+        feff.assert_called_once_with(d)
+
+
+class TestQualityDecorators:
+    """Test `quality` descriptor decorators."""
+
+    def test_validator(self):
+        """Test validator decorator."""
+        q = quality()
+        fval = Mock()
+        qq = q.validator(fval)
+        assert qq is not q
+        assert qq.fval is fval
+        assert qq.fval is not q.fval
+        assert qq.feff is q.feff
+        assert qq.default == q.default
+        assert qq.null == q.null
+        assert qq.name == q.name
+        assert qq.private_name == q.private_name
+
+    def test_side_effect(self):
+        """Test side effect decorator."""
+        q = quality()
+        feff = Mock()
+        qq = q.side_effect(feff)
+        assert qq is not q
+        assert qq.fval is q.fval
+        assert qq.feff is feff
+        assert qq.feff is not q.feff
+        assert qq.default == q.default
+        assert qq.null == q.null
+        assert qq.name == q.name
+        assert qq.private_name == q.private_name