Repository: dwavesystems/dwave-cloud-client
Pull request #444 (2021-01-12 20:58:46)

Add problem labelling support


Patch:
diff --git a/dwave/cloud/client.py b/dwave/cloud/client.py
index 341728f..7986b84 100644
--- a/dwave/cloud/client.py
+++ b/dwave/cloud/client.py
@@ -1286,6 +1286,7 @@ class Client(object):
                 raise InvalidAPIResponseError("'id' missing in problem description response")
 
             future.id = message['id']
+            future.label = message.get('label')
             future.remote_status = status = message['status']
 
             # The future may not have the ID set yet
diff --git a/dwave/cloud/computation.py b/dwave/cloud/computation.py
index 0ad8392..fd5e0e7 100644
--- a/dwave/cloud/computation.py
+++ b/dwave/cloud/computation.py
@@ -117,6 +117,9 @@ class Future(object):
         #: The id the server will use to identify this problem, None until the id is actually known
         self.id = id_
 
+        #: Problem label, as (optionally) set on submission. None until parsed from a response.
+        self.label = None
+
         #: `datetime` the Future was created (immediately before enqueued in Client's submit queue)
         self.time_created = utcnow()
 
@@ -789,8 +792,10 @@ class Future(object):
         vartype_from_problem_type = {'ising': 'SPIN', 'qubo': 'BINARY'}
         vartype = vartype_from_problem_type[self.problem_type]
 
-        # include timing and id in info
+        # include timing and id/label in info
         info = dict(timing=self.timing, problem_id=self.id)
+        if self.label is not None:
+            info.update(problem_label=self.label)
 
         sampleset = dimod.SampleSet.from_samples(
             (samples, variables), vartype=vartype,
diff --git a/dwave/cloud/solver.py b/dwave/cloud/solver.py
index 1335a7c..56b7f34 100644
--- a/dwave/cloud/solver.py
+++ b/dwave/cloud/solver.py
@@ -145,7 +145,7 @@ class BaseSolver(object):
             id_: Identification of the query.
 
         Returns:
-            :obj: `Future`
+            :class:`~dwave.cloud.computation.Future`
         """
         future = Future(self, id_, self.return_matrix)
         self.client._poll(future)
@@ -257,7 +257,7 @@ class BaseUnstructuredSolver(BaseSolver):
         Events are not yet dispatched from unstructured solvers.
     """
 
-    def sample_ising(self, linear, quadratic, offset=0, **params):
+    def sample_ising(self, linear, quadratic, offset=0, label=None, **params):
         """Sample from the specified :term:`Ising` model.
 
         Args:
@@ -275,11 +275,15 @@ class BaseUnstructuredSolver(BaseSolver):
             offset (optional, default=0):
                 Constant offset applied to the model.
 
+            label (str, optional):
+                Problem label you can optionally tag submissions with for ease
+                of identification.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
         Returns:
-            :class:`Future`
+            :class:`~dwave.cloud.computation.Future`
 
         Note:
             To use this method, dimod package has to be installed.
@@ -291,9 +295,9 @@ class BaseUnstructuredSolver(BaseSolver):
                                "Re-install the library with 'bqm'/'dqm' support.")
 
         bqm = dimod.BinaryQuadraticModel.from_ising(linear, quadratic, offset)
-        return self.sample_bqm(bqm, **params)
+        return self.sample_bqm(bqm, label=label, **params)
 
-    def sample_qubo(self, qubo, offset=0, **params):
+    def sample_qubo(self, qubo, offset=0, label=None, **params):
         """Sample from the specified :term:`QUBO`.
 
         Args:
@@ -306,11 +310,15 @@ class BaseUnstructuredSolver(BaseSolver):
             offset (optional, default=0):
                 Constant offset applied to the model.
 
+            label (str, optional):
+                Problem label you can optionally tag submissions with for ease
+                of identification.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
         Returns:
-            :class:`Future`
+            :class:`~dwave.cloud.computation.Future`
 
         Note:
             To use this method, dimod package has to be installed.
@@ -322,7 +330,7 @@ class BaseUnstructuredSolver(BaseSolver):
                                "Re-install the library with 'bqm'/'dqm' support.")
 
         bqm = dimod.BinaryQuadraticModel.from_qubo(qubo, offset)
-        return self.sample_bqm(bqm, **params)
+        return self.sample_bqm(bqm, label=label, **params)
 
     def _encode_problem_for_upload(self, problem):
         """Encode problem for upload to solver.
@@ -353,7 +361,8 @@ class BaseUnstructuredSolver(BaseSolver):
         data = self._encode_problem_for_upload(problem)
         return self.client.upload_problem_encoded(data)
 
-    def _encode_problem_for_submission(self, problem, problem_type, params):
+    def _encode_problem_for_submission(self, problem, problem_type, params,
+                                       label=None):
         """Encode `problem` for submitting in `ref` format. Upload the
         problem if it's not already uploaded.
 
@@ -367,6 +376,9 @@ class BaseUnstructuredSolver(BaseSolver):
             params (dict):
                 Parameters for the sampling method, solver-specific.
 
+            label (str, optional):
+                Problem label.
+
         Returns:
             str:
                 JSON-encoded problem submit body
@@ -379,17 +391,20 @@ class BaseUnstructuredSolver(BaseSolver):
                          "we need to upload it first.")
             problem_id = self.upload_problem(problem).result()
 
-        body = json.dumps({
+        body = {
             'solver': self.id,
             'data': encode_problem_as_ref(problem_id),
             'type': problem_type,
             'params': params
-        })
-        logger.trace("Sampling request encoded as: %s", body)
+        }
+        if label is not None:
+            body['label'] = label
+        body_data = json.dumps(body)
+        logger.trace("Sampling request encoded as: %s", body_data)
 
-        return body
+        return body_data
 
-    def sample_problem(self, problem, problem_type=None, **params):
+    def sample_problem(self, problem, problem_type=None, label=None, **params):
         """Sample from the specified problem.
 
         Args:
@@ -402,6 +417,10 @@ class BaseUnstructuredSolver(BaseSolver):
                 Problem type, one of the handled problem types by the solver.
                 If not specified, the first handled problem type is used.
 
+            label (str, optional):
+                Problem label you can optionally tag submissions with for ease
+                of identification.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
@@ -416,7 +435,8 @@ class BaseUnstructuredSolver(BaseSolver):
         # encode the request (body as future)
         body = self.client._encode_problem_executor.submit(
             self._encode_problem_for_submission,
-            problem=problem, problem_type=problem_type, params=params)
+            problem=problem, problem_type=problem_type, params=params,
+            label=label)
 
         # computation future holds a reference to the remote job
         computation = Future(
@@ -460,7 +480,7 @@ class BQMSolver(BaseUnstructuredSolver):
 
         return data
 
-    def sample_bqm(self, bqm, **params):
+    def sample_bqm(self, bqm, label=None, **params):
         """Sample from the specified :term:`BQM`.
 
         Args:
@@ -468,6 +488,10 @@ class BQMSolver(BaseUnstructuredSolver):
                 A binary quadratic model, or a reference to one
                 (Problem ID returned by `.upload_bqm` method).
 
+            label (str, optional):
+                Problem label you can optionally tag submissions with for ease
+                of identification.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
@@ -477,7 +501,7 @@ class BQMSolver(BaseUnstructuredSolver):
         Note:
             To use this method, dimod package has to be installed.
         """
-        return self.sample_problem(bqm, **params)
+        return self.sample_problem(bqm, label=label, **params)
 
     def upload_bqm(self, bqm):
         """Upload the specified :term:`BQM` to SAPI, returning a Problem ID
@@ -535,16 +559,16 @@ class DQMSolver(BaseUnstructuredSolver):
         logger.debug("Problem encoded for upload: %r", data)
         return data
 
-    def sample_bqm(self, bqm, **params):
+    def sample_bqm(self, bqm, label=None, **params):
         from dwave.cloud.utils import bqm_to_dqm
 
         # to sample BQM problems, we need to convert them to DQM
         dqm = bqm_to_dqm(bqm)
 
         # TODO: convert sampleset back
-        return self.sample_dqm(dqm, **params)
+        return self.sample_dqm(dqm, label=label, **params)
 
-    def sample_dqm(self, dqm, **params):
+    def sample_dqm(self, dqm, label=None, **params):
         """Sample from the specified :term:`DQM`.
 
         Args:
@@ -552,6 +576,10 @@ class DQMSolver(BaseUnstructuredSolver):
                 A discrete quadratic model, or a reference to one
                 (Problem ID returned by `.upload_dqm` method).
 
+            label (str, optional):
+                Problem label you can optionally tag submissions with for ease
+                of identification.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
@@ -561,7 +589,7 @@ class DQMSolver(BaseUnstructuredSolver):
         Note:
             To use this method, dimod package has to be installed.
         """
-        return self.sample_problem(dqm, **params)
+        return self.sample_problem(dqm, label=label, **params)
 
     def upload_dqm(self, dqm):
         """Upload the specified :term:`DQM` to SAPI, returning a Problem ID
@@ -710,7 +738,7 @@ class StructuredSolver(BaseSolver):
 
     # Sampling methods
 
-    def sample_ising(self, linear, quadratic, offset=0, **params):
+    def sample_ising(self, linear, quadratic, offset=0, label=None, **params):
         """Sample from the specified :term:`Ising` model.
 
         Args:
@@ -725,14 +753,18 @@ class StructuredSolver(BaseSolver):
                 that are 2-tuples of variables and values are quadratic biases
                 associated with the pair of variables (the interaction).
 
-            offset (optional, default=0):
+            offset (float, optional, default=0):
                 Constant offset applied to the model.
 
+            label (str, optional):
+                Problem label you can optionally tag submissions with for ease
+                of identification.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
         Returns:
-            :class:`Future`
+            :class:`~dwave.cloud.computation.Future`
 
         Examples:
             This example creates a client using the local system's default D-Wave Cloud Client
@@ -758,9 +790,9 @@ class StructuredSolver(BaseSolver):
         """
         # Our linear and quadratic objective terms are already separated in an
         # ising model so we can just directly call `_sample`.
-        return self._sample('ising', linear, quadratic, offset, params)
+        return self._sample('ising', linear, quadratic, offset, params, label=label)
 
-    def sample_qubo(self, qubo, offset=0, **params):
+    def sample_qubo(self, qubo, offset=0, label=None, **params):
         """Sample from the specified :term:`QUBO`.
 
         Args:
@@ -773,11 +805,15 @@ class StructuredSolver(BaseSolver):
             offset (optional, default=0):
                 Constant offset applied to the model.
 
+            label (str, optional):
+                Problem label you can optionally tag submissions with for ease
+                of identification.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
         Returns:
-            :class:`Future`
+            :class:`~dwave.cloud.computation.Future`
 
         Examples:
             This example creates a client using the local system's default D-Wave Cloud Client
@@ -803,20 +839,24 @@ class StructuredSolver(BaseSolver):
 
         """
         linear, quadratic = reformat_qubo_as_ising(qubo)
-        return self._sample('qubo', linear, quadratic, offset, params)
+        return self._sample('qubo', linear, quadratic, offset, params, label=label)
 
-    def sample_bqm(self, bqm, **params):
+    def sample_bqm(self, bqm, label=None, **params):
         """Sample from the specified :term:`BQM`.
 
         Args:
             bqm (:class:`~dimod.BinaryQuadraticModel`):
                 A binary quadratic model.
 
+            label (str, optional):
+                Problem label you can optionally tag submissions with for ease
+                of identification.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
         Returns:
-            :class:`Future`
+            :class:`~dwave.cloud.computation.Future`
 
         Note:
             To use this method, dimod package has to be installed.
@@ -828,16 +868,17 @@ class StructuredSolver(BaseSolver):
                                "Re-install the library with 'bqm' support.")
 
         if bqm.vartype is dimod.SPIN:
-            return self._sample('ising', bqm.linear, bqm.quadratic, bqm.offset,
-                                params, undirected_biases=True)
+            problem_type = 'ising'
         elif bqm.vartype is dimod.BINARY:
-            return self._sample('qubo', bqm.linear, bqm.quadratic, bqm.offset,
-                                params, undirected_biases=True)
+            problem_type = 'qubo'
         else:
             raise TypeError("unknown/unsupported vartype")
 
+        return self._sample(problem_type, bqm.linear, bqm.quadratic, bqm.offset,
+                            params, label=label, undirected_biases=True)
+
     def _sample(self, type_, linear, quadratic, offset, params,
-                undirected_biases=False):
+                label=None, undirected_biases=False):
         """Internal method for `sample_ising`, `sample_qubo` and `sample_bqm`.
 
         Args:
@@ -853,17 +894,20 @@ class StructuredSolver(BaseSolver):
             params (dict):
                 Parameters for the sampling method, solver-specific.
 
+            label (str, optional):
+                Problem label.
+
             undirected_biases (boolean, default=False):
                 Are (quadratic) biases specified on undirected edges? For
                 triangular or symmetric matrix of quadratic biases set it to
                 ``True``.
 
         Returns:
-            :class:`Future`
+            :class:`~dwave.cloud.computation.Future`
         """
 
         args = dict(type_=type_, linear=linear, quadratic=quadratic,
-                    offset=offset, params=params,
+                    offset=offset, params=params, label=label,
                     undirected_biases=undirected_biases)
         dispatch_event('before_sample', obj=self, args=args)
 
@@ -883,13 +927,16 @@ class StructuredSolver(BaseSolver):
         # transform some of the parameters in-place
         self._format_params(type_, combined_params)
 
-        body_data = json.dumps({
+        body_dict = {
             'solver': self.id,
             'data': encode_problem_as_qp(self, linear, quadratic, offset,
                                          undirected_biases=undirected_biases),
             'type': type_,
             'params': combined_params
-        })
+        }
+        if label is not None:
+            body_dict['label'] = label
+        body_data = json.dumps(body_dict)
         logger.trace("Encoded sample request: %s", body_data)
 
         body = Present(result=body_data)


Test patch:
diff --git a/tests/test_events.py b/tests/test_events.py
index 5323a28..76a933c 100644
--- a/tests/test_events.py
+++ b/tests/test_events.py
@@ -138,7 +138,7 @@ class TestEventDispatch(unittest.TestCase):
         before = memo['before_sample']
         args = dict(type_='ising', linear=lin, quadratic=quad,
                     offset=offset, params=params,
-                    undirected_biases=False)
+                    undirected_biases=False, label=None)
         self.assertEqual(before['obj'], self.solver)
         self.assertDictEqual(before['args'], args)
 
diff --git a/tests/test_mock_submission.py b/tests/test_mock_submission.py
index b1baf7f..c1da578 100644
--- a/tests/test_mock_submission.py
+++ b/tests/test_mock_submission.py
@@ -29,6 +29,7 @@ from dateutil.parser import parse as parse_datetime
 from requests.structures import CaseInsensitiveDict
 from requests.exceptions import HTTPError
 from concurrent.futures import TimeoutError
+from parameterized import parameterized
 
 try:
     import dimod
@@ -68,7 +69,7 @@ def solver_data(id_, incomplete=False):
     return obj
 
 
-def complete_reply(id_, solver_name, answer=None, msg=None):
+def complete_reply(id_, solver_name, answer=None, msg=None, label=""):
     """Reply with solutions for the test problem."""
     response = {
         "status": "COMPLETED",
@@ -85,7 +86,8 @@ def complete_reply(id_, solver_name, answer=None, msg=None):
             "timing": {}
         },
         "type": "ising",
-        "id": id_
+        "id": id_,
+        "label": label
     }
 
     # optional answer fields override
@@ -99,7 +101,7 @@ def complete_reply(id_, solver_name, answer=None, msg=None):
     return json.dumps(response)
 
 
-def complete_no_answer_reply(id_, solver_name):
+def complete_no_answer_reply(id_, solver_name, label=""):
     """A reply saying a problem is finished without providing the results."""
     return json.dumps({
         "status": "COMPLETED",
@@ -107,11 +109,12 @@ def complete_no_answer_reply(id_, solver_name):
         "solver": solver_name,
         "submitted_on": "2012-12-05T19:06:57+00:00",
         "type": "ising",
-        "id": id_
+        "id": id_,
+        "label": label
     })
 
 
-def error_reply(id_, solver_name, error):
+def error_reply(id_, solver_name, error, label=""):
     """A reply saying an error has occurred."""
     return json.dumps({
         "status": "FAILED",
@@ -120,6 +123,7 @@ def error_reply(id_, solver_name, error):
         "submitted_on": "2013-01-18T10:25:59.941674",
         "type": "ising",
         "id": id_,
+        "label": label,
         "error_message": error
     })
 
@@ -132,7 +136,7 @@ def immediate_error_reply(code, msg):
     })
 
 
-def cancel_reply(id_, solver_name):
+def cancel_reply(id_, solver_name, label=""):
     """A reply saying a problem was canceled."""
     return json.dumps({
         "status": "CANCELLED",
@@ -140,7 +144,8 @@ def cancel_reply(id_, solver_name):
         "solver": solver_name,
         "submitted_on": "2013-01-18T10:25:59.941674",
         "type": "ising",
-        "id": id_
+        "id": id_,
+        "label": label
     })
 
 
@@ -149,7 +154,7 @@ def datetime_in_future(seconds=0):
     return now + timedelta(seconds=seconds)
 
 
-def continue_reply(id_, solver_name, now=None, eta_min=None, eta_max=None):
+def continue_reply(id_, solver_name, now=None, eta_min=None, eta_max=None, label=""):
     """A reply saying a problem is still in the queue."""
 
     if not now:
@@ -161,7 +166,8 @@ def continue_reply(id_, solver_name, now=None, eta_min=None, eta_max=None):
         "solver": solver_name,
         "submitted_on": now.isoformat(),
         "type": "ising",
-        "id": id_
+        "id": id_,
+        "label": label
     }
     if eta_min:
         resp.update({
@@ -1081,3 +1087,102 @@ class TestComputationDeprecations(_QueryTest):
                 # .occurrences is deprecated in 0.8.0, scheduled for removal in 0.10.0+
                 with self.assertWarns(DeprecationWarning):
                     results.occurrences
+
+
+class TestProblemLabel(unittest.TestCase):
+
+    class PrimaryAssertionSatisfied(Exception):
+        """Raised by `on_submit_label_verifier` to signal correct label."""
+
+    def on_submit_label_verifier(self, expected_label):
+        """Factory for mock Client._submit() that will verify existence, and
+        optionally validate label value.
+        """
+
+        # replacement for Client._submit()
+        def _submit(client, body_data, computation):
+            body = json.loads(body_data.result())
+
+            if 'label' not in body:
+                if expected_label is None:
+                    raise TestProblemLabel.PrimaryAssertionSatisfied
+                else:
+                    raise AssertionError("label field missing")
+
+            label = body['label']
+            if label != expected_label:
+                raise AssertionError(
+                    "unexpected label value: {!r} != {!r}".format(label, expected_label))
+
+            raise TestProblemLabel.PrimaryAssertionSatisfied
+
+        return _submit
+
+    def generate_sample_problems(self, solver):
+        linear, quadratic = test_problem(solver)
+
+        # test sample_{ising,qubo,bqm}
+        problems = [("sample_ising", (linear, quadratic)),
+                    ("sample_qubo", (quadratic,))]
+        if dimod:
+            bqm = dimod.BQM.from_ising(linear, quadratic)
+            problems.append(("sample_bqm", (bqm,)))
+
+        return problems
+
+    @parameterized.expand([
+        ("undefined", None),
+        ("empty", ""),
+        ("string", "text label")
+    ])
+    @mock.patch.object(Client, 'create_session', lambda client: mock.Mock())
+    def test_label_is_sent(self, name, label):
+        """Problem label is set on problem submit."""
+
+        with Client('endpoint', 'token') as client:
+            solver = Solver(client, solver_data('solver'))
+            problems = self.generate_sample_problems(solver)
+
+            for method_name, problem_args in problems:
+                with self.subTest(method_name=method_name):
+                    sample = getattr(solver, method_name)
+
+                    with mock.patch.object(Client, '_submit', self.on_submit_label_verifier(label)):
+
+                        with self.assertRaises(self.PrimaryAssertionSatisfied):
+                            sample(*problem_args, label=label).result()
+
+    @parameterized.expand([
+        ("undefined", None),
+        ("empty", ""),
+        ("string", "text label")
+    ])
+    def test_label_is_received(self, name, label):
+        """Problem label is set from response in result/sampleset."""
+
+        def make_session_generator(label):
+            def create_mock_session(client):
+                session = mock.Mock()
+                session.post = lambda a, _: choose_reply(a, {
+                    'problems/': '[%s]' % complete_no_answer_reply(
+                        '123', 'abc123', label=None)})
+                session.get = lambda a: choose_reply(a, {
+                    'problems/123/': complete_reply(
+                        '123', 'abc123', label=label)})
+                return session
+            return create_mock_session
+
+        with mock.patch.object(Client, 'create_session', make_session_generator(label)):
+            with Client('endpoint', 'token') as client:
+                solver = Solver(client, solver_data('abc123'))
+                problems = self.generate_sample_problems(solver)
+
+                for method_name, problem_args in problems:
+                    with self.subTest(method_name=method_name):
+                        sample = getattr(solver, method_name)
+
+                        future = sample(*problem_args, label=label)
+                        info = future.sampleset.info    # ensure future is resolved
+
+                        self.assertEqual(future.label, label)
+                        self.assertEqual(info.get('problem_label'), label)
diff --git a/tests/test_mock_unstructured_submission.py b/tests/test_mock_unstructured_submission.py
index 087d28f..b212311 100644
--- a/tests/test_mock_unstructured_submission.py
+++ b/tests/test_mock_unstructured_submission.py
@@ -18,6 +18,7 @@ import io
 import json
 import unittest
 from unittest import mock
+from parameterized import parameterized
 
 import dimod
 import numpy
@@ -38,7 +39,7 @@ def unstructured_solver_data(problem_type='bqm'):
         "description": "A test unstructured solver"
     }
 
-def complete_reply(sampleset, problem_type='bqm'):
+def complete_reply(sampleset, problem_type='bqm', label=""):
     """Reply with the sampleset as a solution."""
 
     return json.dumps([{
@@ -51,7 +52,8 @@ def complete_reply(sampleset, problem_type='bqm'):
             "data": sampleset.to_serializable()
         },
         "type": problem_type,
-        "id": "problem-id"
+        "id": "problem-id",
+        "label": label
     }])
 
 def choose_reply(path, replies):
@@ -229,3 +231,71 @@ class TestUnstructuredSolver(unittest.TestCase):
                     for fut in futs:
                         with self.assertRaises(type(mock_upload_exc)):
                             fut.result()
+
+
+class TestProblemLabel(unittest.TestCase):
+
+    class PrimaryAssertionSatisfied(Exception):
+        """Raised by `on_submit_label_verifier` to signal correct label."""
+
+    def on_submit_label_verifier(self, expected_label):
+        """Factory for mock Client._submit() that will verify existence, and
+        optionally validate label value.
+        """
+
+        # replacement for Client._submit()
+        def _submit(client, body_data, computation):
+            body = json.loads(body_data.result())
+
+            if 'label' not in body:
+                if expected_label is None:
+                    raise TestProblemLabel.PrimaryAssertionSatisfied
+                else:
+                    raise AssertionError("label field missing")
+
+            label = body['label']
+            if label != expected_label:
+                raise AssertionError(
+                    "unexpected label value: {!r} != {!r}".format(label, expected_label))
+
+            raise TestProblemLabel.PrimaryAssertionSatisfied
+
+        return _submit
+
+    @parameterized.expand([
+        ("undefined", None),
+        ("empty", ""),
+        ("string", "text label")
+    ])
+    @mock.patch.object(Client, 'create_session', lambda client: mock.Mock())
+    def test_label_is_sent(self, name, label):
+        """Problem label is set on problem submit."""
+
+        bqm = dimod.BQM.from_ising({}, {'ab': 1})
+
+        # use a global mocked session, so we can modify it on-fly
+        session = mock.Mock()
+
+        # upload is now part of submit, so we need to mock it
+        mock_problem_id = 'mock-problem-id'
+        def mock_upload(self, bqm):
+            return Present(result=mock_problem_id)
+
+        # construct a functional solver by mocking client and api response data
+        with mock.patch.multiple(Client, create_session=lambda self: session,
+                                 upload_problem_encoded=mock_upload):
+            with Client('endpoint', 'token') as client:
+                solver = BQMSolver(client, unstructured_solver_data())
+
+                problems = [("sample_ising", (bqm.linear, bqm.quadratic)),
+                            ("sample_qubo", (bqm.quadratic,)),
+                            ("sample_bqm", (bqm,))]
+
+                for method_name, problem_args in problems:
+                    with self.subTest(method_name=method_name):
+                        sample = getattr(solver, method_name)
+
+                        with mock.patch.object(Client, '_submit', self.on_submit_label_verifier(label)):
+
+                            with self.assertRaises(self.PrimaryAssertionSatisfied):
+                                sample(*problem_args, label=label).result()
diff --git a/tests/test_solver.py b/tests/test_solver.py
index 4db7d80..a726a01 100644
--- a/tests/test_solver.py
+++ b/tests/test_solver.py
@@ -113,6 +113,11 @@ class _QueryTest(unittest.TestCase):
             self.assertAlmostEqual(
                 energy, evaluate_ising(linear, quad, state, offset=offset))
 
+        # label is optional
+        label = kwargs.get('label')
+        if label is not None:
+            self.assertEqual(results.label, label)
+
         return results
 
 
@@ -205,6 +210,14 @@ class Submission(_QueryTest):
 
             self._submit_and_check(solver, linear, quad)
 
+    def test_problem_label(self):
+        """Problem label is set."""
+
+        with Client(**config) as client:
+            solver = client.get_solver()
+            linear, quad = generate_random_ising_problem(solver)
+            self._submit_and_check(solver, linear, quad, label="test")
+
     @unittest.skipUnless(dimod, "dimod required for 'Solver.sample_bqm'")
     def test_submit_bqm_ising_problem(self):
         """Submit an Ising BQM with all supported coefficients set."""