Repository: Qiskit/qiskit-ibm-runtime
Pull request #2254 (2025-05-07 11:41:13)

Added a check to verify that observables don't contain projections
### Summary

Fixes #2250


### Details and comments

- This PR's code runs successfully with both Qiskit's main branch and stable branch.
- I changed coefficients in the test to real numbers, because complex coefficients are not allowed, and trigger an exception.
- Similarly, I changed the invalid letters in the test from `J` to `0, 1, +, -, l, r`, because `J` is caught already early, by `SparseObservable`. The goal of the test is to verify that qiskit-ibm-runtime imposes further constraints than `SparseObservable`. 




Patch:
diff --git a/qiskit_ibm_runtime/exceptions.py b/qiskit_ibm_runtime/exceptions.py
index 9a295d4a..cbdb40bb 100644
--- a/qiskit_ibm_runtime/exceptions.py
+++ b/qiskit_ibm_runtime/exceptions.py
@@ -52,7 +52,7 @@ class IBMBackendApiError(IBMBackendError):
     pass
 
 
-class IBMInputValueError(IBMError):
+class IBMInputValueError(IBMError, ValueError):
     """Error raised due to invalid input value."""
 
     pass
diff --git a/qiskit_ibm_runtime/utils/validations.py b/qiskit_ibm_runtime/utils/validations.py
index fb9168d0..2deda7ba 100644
--- a/qiskit_ibm_runtime/utils/validations.py
+++ b/qiskit_ibm_runtime/utils/validations.py
@@ -14,6 +14,7 @@
 from typing import List, Sequence, Optional, Any, Union
 import warnings
 import keyword
+import numpy as np
 
 from qiskit import QuantumCircuit
 from qiskit.transpiler import Target
@@ -30,9 +31,9 @@ def validate_classical_registers(pubs: List[SamplerPub]) -> None:
         pubs: The list of pubs to validate
 
     Raises:
-        ValueError: If any circuit has a size-0 creg.
-        ValueError: If any circuit has a creg whose name is not a valid identifier.
-        ValueError: If any circuit has a creg whose name is a Python keyword.
+        IBMInputValueError: If any circuit has a size-0 creg.
+        IBMInputValueError: If any circuit has a creg whose name is not a valid identifier.
+        IBMInputValueError: If any circuit has a creg whose name is a Python keyword.
     """
 
     for index, pub in enumerate(pubs):
@@ -46,17 +47,17 @@ def validate_classical_registers(pubs: List[SamplerPub]) -> None:
         for reg in pub.circuit.cregs:
             # size 0 classical register will crash the server-side sampler
             if reg.size == 0:
-                raise ValueError(
+                raise IBMInputValueError(
                     f"Classical register {reg.name} is of size 0, which is not allowed"
                 )
             if not reg.name.isidentifier():
-                raise ValueError(
+                raise IBMInputValueError(
                     f"Classical register names must be valid identifiers, but {reg.name} "
                     f"is not. Valid identifiers contain only alphanumeric letters "
                     f"(a-z and A-Z), decimal digits (0-9), or underscores (_)"
                 )
             if keyword.iskeyword(reg.name):
-                raise ValueError(
+                raise IBMInputValueError(
                     f"Classical register names cannot be Python keywords, but {reg.name} "
                     f"is such a keyword. You can see the Python keyword list here: "
                     f"https://docs.python.org/3/reference/lexical_analysis.html#keywords"
@@ -70,11 +71,16 @@ def validate_estimator_pubs(pubs: List[EstimatorPub]) -> None:
         pubs: The list of pubs to validate
 
     Raises:
-        ValueError: If any observable array is of size 0
+        IBMInputValueError: If any observable array is of size 0
     """
     for pub in pubs:
         if pub.observables.shape == (0,):
-            raise ValueError("Empty observables array is not allowed")
+            raise IBMInputValueError("Empty observables array is not allowed")
+
+        for obs in np.array(pub.observables.ravel()):
+            for pauli in obs:
+                if any(single_pauli not in ["I", "X", "Y", "Z"] for single_pauli in pauli):
+                    raise IBMInputValueError("Observables alphabet is limited to I, X, Y, Z")
 
 
 def validate_isa_circuits(circuits: Sequence[QuantumCircuit], target: Target) -> None:
diff --git a/release-notes/unreleased/2250.feat.rst b/release-notes/unreleased/2250.feat.rst
new file mode 100644
index 00000000..2502ebc4
--- /dev/null
+++ b/release-notes/unreleased/2250.feat.rst
@@ -0,0 +1,1 @@
+We added a validation that observables consist only of Pauli operations, residing in the alphabet {I, X, Y, Z}.
\ No newline at end of file
diff --git a/release-notes/unreleased/2250.other.rst b/release-notes/unreleased/2250.other.rst
new file mode 100644
index 00000000..0e81fe1d
--- /dev/null
+++ b/release-notes/unreleased/2250.other.rst
@@ -0,0 +1,1 @@
+:class:`.IBMInputValueError` now inherits from ``ValueError``, in addition to the existing parent class :class:`.IBMError`. Some validation functions, which previously raised ``ValueError`` exceptions, now raise :class:`.IBMInputValueError` exceptions.
\ No newline at end of file


Test patch:
diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py
index 46afbb90..c2338298 100644
--- a/test/unit/test_estimator.py
+++ b/test/unit/test_estimator.py
@@ -222,13 +222,13 @@ class TestEstimatorV2(IBMTestCase):
     def test_invalid_basis(self):
         """Test observable containing invalid basis."""
         all_obs = [
-            ["JJ"],
-            {"JJ": 1 + 2j},
-            [["0J", "YY"]],
+            ["Y0"],
+            {"1X": 2},
+            [["rZ", "YY"]],
             [
                 [
-                    {"XX": 1 + 2j},
-                    {"JJ": 1 + 2j},
+                    {"XX": 3},
+                    {"++": 4},
                 ]
             ],
         ]
@@ -237,8 +237,8 @@ class TestEstimatorV2(IBMTestCase):
         estimator = EstimatorV2(mode=get_mocked_backend())
         for obs in all_obs:
             with self.subTest(obs=obs):
-                with self.assertRaises(ValueError):
-                    estimator.run((circuit, obs))
+                with self.assertRaisesRegex(ValueError, "Observable"):
+                    estimator.run([(circuit, obs)])
 
     def test_unsupported_dynamical_decoupling_with_dynamic_circuits(self):
         """Test that running on dynamic circuits with dynamical decoupling enabled is not allowed"""
@@ -265,7 +265,7 @@ class TestEstimatorV2(IBMTestCase):
         inst = EstimatorV2(mode=backend)
         circ = QuantumCircuit(2)
         obs = []
-        with self.assertRaisesRegex(ValueError, "Empty observables array is not allowed"):
+        with self.assertRaisesRegex(IBMInputValueError, "Empty observables array is not allowed"):
             inst.run(pubs=[(circ, obs)])
 
     def test_gate_not_in_target(self):