Repository: Qiskit/qiskit-terra (python)

Issue:
Number of experiments in primitive results
### What should we add?

In the current specification of `SamplerResult` and `EstimatorResult` there is no unified way of retrieving the the total number of experiments that are comprised within one result object. It would be great to add a property `result.num_experiments` that would retrieve it.

Additionally, when one of these objects is instantiated, no checks are performed to verify that the number of experiments is consistent across all data fields. This should be performed as well.

Pull request:
Add BaseResult functionality to primitives module
<!--
⚠️ If you do not respect this template, your pull request will be closed.
⚠️ Your pull request title should be short detailed and understandable for all.
⚠️ Also, please add a release note file using reno if the change needs to be
  documented in the release notes.
⚠️ If your pull request fixes an open issue, please link to the issue.

- [x] I have added the tests to cover my changes.
- [ ] I have updated the documentation accordingly.
- [ ] I have read the CONTRIBUTING document.
-->

### Summary
Adds a primitive result base class to provide common functionality to all inheriting result dataclasses.

Closes #8089
Changelog: New Feature

### Details and comments
- Adds `result.num_experiments` property.
- Adds `result.experiments` property.
- Checks for consistency in the number of experiments across data fields after instantiation (i.e. on `__post_init__`).

### To do
- [x] Translate tests from pytest to unittest
- [x] Add release note
- [x] Complete docstring (e.g. `autosummary`)



Patch:
diff --git a/qiskit/primitives/__init__.py b/qiskit/primitives/__init__.py
index bd00dedc7..58ee1f41d 100644
--- a/qiskit/primitives/__init__.py
+++ b/qiskit/primitives/__init__.py
@@ -46,11 +46,13 @@ Results
 .. autosummary::
    :toctree: ../stubs/
 
+   BasePrimitiveResult
    EstimatorResult
    SamplerResult
 """
 
 from .base_estimator import BaseEstimator
+from .base_result import BasePrimitiveResult
 from .base_sampler import BaseSampler
 from .estimator import Estimator
 from .estimator_result import EstimatorResult
diff --git a/qiskit/primitives/base_result.py b/qiskit/primitives/base_result.py
new file mode 100644
index 000000000..2c23ea1d7
--- /dev/null
+++ b/qiskit/primitives/base_result.py
@@ -0,0 +1,87 @@
+# 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.
+"""
+Primitive result abstract base class
+"""
+
+from __future__ import annotations
+
+from abc import ABC
+from collections.abc import Iterator, Sequence
+from dataclasses import fields
+from typing import Any, Dict
+
+from numpy import ndarray
+
+
+ExperimentData = Dict[str, Any]
+
+
+class BasePrimitiveResult(ABC):
+    """Primitive result abstract base class.
+
+    Base class for Primitive results meant to provide common functionality to all inheriting
+    result dataclasses.
+    """
+
+    def __post_init__(self) -> None:
+        """
+        Verify that all fields in any inheriting result dataclass are consistent, after
+        instantiation, with the number of experiments being represented.
+
+        This magic method is specific of `dataclasses.dataclass`, therefore all inheriting
+        classes must have this decorator.
+
+        Raises:
+            TypeError: If one of the data fields is not a Sequence or ``numpy.ndarray``.
+            ValueError: Inconsistent number of experiments across data fields.
+        """
+        for value in self._field_values:  # type: Sequence
+            # TODO: enforce all data fields to be tuples instead of sequences
+            if not isinstance(value, (Sequence, ndarray)) or isinstance(value, (str, bytes)):
+                raise TypeError(
+                    f"Expected sequence or `numpy.ndarray`, provided {type(value)} instead."
+                )
+            if len(value) != self.num_experiments:
+                raise ValueError("Inconsistent number of experiments across data fields.")
+
+    @property  # TODO: functools.cached_property when py37 is droppped
+    def num_experiments(self) -> int:
+        """Number of experiments in any inheriting result dataclass."""
+        value: Sequence = self._field_values[0]
+        return len(value)
+
+    @property  # TODO: functools.cached_property when py37 is droppped
+    def experiments(self) -> tuple[ExperimentData, ...]:
+        """Experiment data dicts in any inheriting result dataclass."""
+        return tuple(self._generate_experiments())
+
+    def _generate_experiments(self) -> Iterator[ExperimentData]:
+        """Generate experiment data dicts in any inheriting result dataclass."""
+        names: tuple[str, ...] = self._field_names
+        for values in zip(*self._field_values):
+            yield dict(zip(names, values))
+
+    def decompose(self) -> Iterator[BasePrimitiveResult]:
+        """Generate single experiment result objects from self."""
+        for values in zip(*self._field_values):
+            yield self.__class__(*[(v,) for v in values])
+
+    @property  # TODO: functools.cached_property when py37 is droppped
+    def _field_names(self) -> tuple[str, ...]:
+        """Tuple of field names in any inheriting result dataclass."""
+        return tuple(field.name for field in fields(self))
+
+    @property  # TODO: functools.cached_property when py37 is droppped
+    def _field_values(self) -> tuple:
+        """Tuple of field values in any inheriting result dataclass."""
+        return tuple(getattr(self, name) for name in self._field_names)
diff --git a/qiskit/primitives/estimator_result.py b/qiskit/primitives/estimator_result.py
index 46ea00447..9ac811fb6 100644
--- a/qiskit/primitives/estimator_result.py
+++ b/qiskit/primitives/estimator_result.py
@@ -18,12 +18,14 @@ from __future__ import annotations
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any
 
+from .base_result import BasePrimitiveResult
+
 if TYPE_CHECKING:
     import numpy as np
 
 
 @dataclass(frozen=True)
-class EstimatorResult:
+class EstimatorResult(BasePrimitiveResult):
     """Result of Estimator.
 
     .. code-block:: python
diff --git a/qiskit/primitives/sampler_result.py b/qiskit/primitives/sampler_result.py
index 9581e951d..5dbb12d20 100644
--- a/qiskit/primitives/sampler_result.py
+++ b/qiskit/primitives/sampler_result.py
@@ -20,9 +20,11 @@ from typing import Any
 
 from qiskit.result import QuasiDistribution
 
+from .base_result import BasePrimitiveResult
+
 
 @dataclass(frozen=True)
-class SamplerResult:
+class SamplerResult(BasePrimitiveResult):
     """Result of Sampler.
 
     .. code-block:: python
diff --git a/releasenotes/notes/primitive-base-result-92808b069299c19e.yaml b/releasenotes/notes/primitive-base-result-92808b069299c19e.yaml
new file mode 100644
index 000000000..ed0641f3c
--- /dev/null
+++ b/releasenotes/notes/primitive-base-result-92808b069299c19e.yaml
@@ -0,0 +1,9 @@
+---
+features:
+  - |
+    Adds a primitive result :class:`qiskit.primitives.base_result.BasePrimitiveResult` to provide common functionality to all inheriting result dataclasses.
+    * Adds :py:meth:`result.num_experiments` property.
+    * Adds :py:meth:`result.experiments` property.
+    * Adds :py:meth:`result.decompose`.
+    * Validates data types after instantiation (i.e. on dataclass :py:meth:`__post_init__`).
+    * Checks for consistency in the number of experiments across data fields after instantiation (i.e. on dataclass :py:meth:`__post_init__`).


Test patch:
diff --git a/test/python/primitives/test_result.py b/test/python/primitives/test_result.py
new file mode 100644
index 000000000..2a688e6d2
--- /dev/null
+++ b/test/python/primitives/test_result.py
@@ -0,0 +1,93 @@
+# 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.
+
+"""Tests for BasePrimitiveResult."""
+
+from __future__ import annotations
+
+from collections.abc import Collection
+from dataclasses import dataclass
+from typing import Any
+
+from ddt import data, ddt, unpack
+
+from qiskit.primitives.base_result import BasePrimitiveResult
+from qiskit.test import QiskitTestCase
+
+
+################################################################################
+## STUB DATACLASS
+################################################################################
+@dataclass
+class Result(BasePrimitiveResult):
+    """Dummy result dataclass implementing BasePrimitiveResult."""
+
+    field_1: Collection[Any]
+    field_2: Collection[Any]
+
+
+################################################################################
+## TESTS
+################################################################################
+@ddt
+class TestBasePrimitiveResult(QiskitTestCase):
+    """Tests BasePrimitiveResult."""
+
+    @data(0, 1.2, True, "sequence", b"sequence", {"name": "value"})
+    def test_post_init_type_error(self, field_1):
+        """Tests post init type error."""
+        self.assertRaises(TypeError, Result, *(field_1, []))
+
+    @data(([1], []), ([], [1]), ([1, 2], []), ([1], [1, 2]))
+    @unpack
+    def test_post_init_value_error(self, field_1, field_2):
+        """Tests post init value error."""
+        self.assertRaises(ValueError, Result, *(field_1, field_2))
+
+    @data(0, 1, 2, 3)
+    def test_num_experiments(self, num_experiments):
+        """Tests {num_experiments} num_experiments."""
+        result = Result([0] * num_experiments, [1] * num_experiments)
+        self.assertEqual(num_experiments, result.num_experiments)
+
+    @data(0, 1, 2, 3)
+    def test_experiments(self, num_experiments):
+        """Test experiment data."""
+        field_1 = list(range(num_experiments))
+        field_2 = [i + 1 for i in range(num_experiments)]
+        experiments = Result(field_1, field_2).experiments
+        self.assertIsInstance(experiments, tuple)
+        for i, exp in enumerate(experiments):
+            self.assertEqual(exp, {"field_1": i, "field_2": i + 1})
+
+    @data(0, 1, 2, 3)
+    def test_decompose(self, num_experiments):
+        """Test decompose."""
+        field_1 = list(range(num_experiments))
+        field_2 = [i + 1 for i in range(num_experiments)]
+        result = Result(field_1, field_2)
+        for i, res in enumerate(result.decompose()):
+            self.assertIsInstance(res, Result)
+            f1, f2 = (i,), (i + 1,)
+            self.assertEqual(res, Result(f1, f2))
+
+    def test_field_names(self):
+        """Tests field names ("field_1", "field_2")."""
+        result = Result([], [])
+        self.assertEqual(result._field_names, ("field_1", "field_2"))
+
+    @data(([], []), ([0], [0]), ([0], [1]))
+    @unpack
+    def test_field_values(self, field_1, field_2):
+        """Tests field values ({field_1}, {field_2})."""
+        result = Result(field_1, field_2)
+        self.assertEqual(result._field_values, (field_1, field_2))