Repository: dwavesystems/dwave-cloud-client
Pull request #432 (2020-09-23 19:59:32)

Add DQM solver
- abstract `dwave.cloud.solver.UnstructuredSolver` to `.BaseUnstructuredSolver`
- alias `dwave.cloud.solver.BQMSolver` as `.UnstructuredSolver`
- add `dwave.cloud.solver.DQMSolver`, with `sample_dqm` method, as well as fallback `sample_{bqm,ising,qubo}`


Patch:
diff --git a/dwave/cloud/coders.py b/dwave/cloud/coders.py
index 2b69cbe..a624fc1 100644
--- a/dwave/cloud/coders.py
+++ b/dwave/cloud/coders.py
@@ -357,7 +357,7 @@ def decode_bq(msg):
     result['sampleset'] = dimod.SampleSet.from_serializable(answer['data'])
 
     # include problem type
-    result['problem_type'] = 'bqm'
+    result['problem_type'] = msg['type']
 
     return result
 
@@ -394,6 +394,6 @@ def bqm_as_file(bqm, **options):
     # test explicitly to avoid copy on cast if possible
     fileviewable = (dimod.AdjArrayBQM, dimod.AdjVectorBQM, dimod.AdjMapBQM)
     if not isinstance(bqm, fileviewable):
-        bqm = AdjVectorBQM(bqm)
+        bqm = dimod.AdjVectorBQM(bqm)
 
     return BQMFileView(bqm, **options)
diff --git a/dwave/cloud/computation.py b/dwave/cloud/computation.py
index f6c7f6b..0ad8392 100644
--- a/dwave/cloud/computation.py
+++ b/dwave/cloud/computation.py
@@ -785,7 +785,7 @@ class Future(object):
         samples = [[sample[v] for v in variables] for sample in self.samples]
 
         # infer vartype from problem type
-        # note: KeyError on unknown problem types. BQMs should be handled above.
+        # note: KeyError on unknown problem types. BQM/DQM should be handled above.
         vartype_from_problem_type = {'ising': 'SPIN', 'qubo': 'BINARY'}
         vartype = vartype_from_problem_type[self.problem_type]
 
diff --git a/dwave/cloud/solver.py b/dwave/cloud/solver.py
index ed01a8d..561dd93 100644
--- a/dwave/cloud/solver.py
+++ b/dwave/cloud/solver.py
@@ -49,7 +49,10 @@ try:
 except ImportError:
     _numpy = False
 
-__all__ = ['Solver', 'BaseSolver', 'StructuredSolver', 'UnstructuredSolver']
+__all__ = [
+    'Solver', 'BaseSolver', 'StructuredSolver',
+    'BaseUnstructuredSolver', 'BQMSolver', 'DQMSolver', 'UnstructuredSolver',
+]
 
 logger = logging.getLogger(__name__)
 
@@ -236,8 +239,8 @@ class BaseSolver(object):
             return self.id.startswith('hybrid')
 
 
-class UnstructuredSolver(BaseSolver):
-    """Class for D-Wave unstructured solvers.
+class BaseUnstructuredSolver(BaseSolver):
+    """Base class for D-Wave unstructured solvers.
 
     This class provides :term:`Ising`, :term:`QUBO` and :term:`BQM` sampling
     methods and encapsulates the solver description returned from the D-Wave
@@ -254,9 +257,6 @@ class UnstructuredSolver(BaseSolver):
         Events are not yet dispatched from unstructured solvers.
     """
 
-    _handled_problem_types = {"bqm"}
-    _handled_encoding_formats = {"bq"}
-
     def sample_ising(self, linear, quadratic, offset=0, **params):
         """Sample from the specified :term:`Ising` model.
 
@@ -287,8 +287,8 @@ class UnstructuredSolver(BaseSolver):
         try:
             import dimod
         except ImportError: # pragma: no cover
-            raise RuntimeError("Can't use solver of type 'bqm' without dimod. "
-                               "Re-install the library with 'bqm' support.")
+            raise RuntimeError("Can't use unstructured solver without dimod. "
+                               "Re-install the library with 'bqm'/'dqm' support.")
 
         bqm = dimod.BinaryQuadraticModel.from_ising(linear, quadratic, offset)
         return self.sample_bqm(bqm, **params)
@@ -318,19 +318,51 @@ class UnstructuredSolver(BaseSolver):
         try:
             import dimod
         except ImportError: # pragma: no cover
-            raise RuntimeError("Can't use solver of type 'bqm' without dimod. "
-                               "Re-install the library with 'bqm' support.")
+            raise RuntimeError("Can't use unstructured solver without dimod. "
+                               "Re-install the library with 'bqm'/'dqm' support.")
 
         bqm = dimod.BinaryQuadraticModel.from_qubo(qubo, offset)
         return self.sample_bqm(bqm, **params)
 
-    def _encode_problem_as_ref(self, problem, params):
+    def _encode_problem_for_upload(self, problem):
+        """Encode problem for upload to solver.
+
+        Args:
+            problem (dimod-model-like):
+                Problem of type `._handled_problem_types`.
+
+        Returns:
+            file-like:
+                Binary stream ready for reading and seeking.
+        """
+        raise NotImplementedError
+
+    def upload_problem(self, problem):
+        """Encode and upload the problem.
+
+        Args:
+            problem (dimod-model-like):
+                Quadratic model handled by the solver, for example
+                :class:`dimod.BQM` or :class:`dimod.DQM`.
+
+        Returns:
+            :class:`concurrent.futures.Future`[str]:
+                Problem ID in a Future. Problem ID can be used to submit
+                problems by reference.
+        """
+        data = self._encode_problem_for_upload(problem)
+        return self.client.upload_problem_encoded(data)
+
+    def _encode_problem_for_submission(self, problem, problem_type, params):
         """Encode `problem` for submitting in `ref` format. Upload the
         problem if it's not already uploaded.
 
         Args:
-            problem (:class:`~dimod.BinaryQuadraticModel`/str):
-                A binary quadratic model, or a reference to one (Problem ID).
+            problem (dimod-model-like/str):
+                A quadratic model, or a reference to one (Problem ID).
+
+            problem_type (str):
+                Problem type, one of the handled problem types by the solver.
 
             params (dict):
                 Parameters for the sampling method, solver-specific.
@@ -345,49 +377,108 @@ class UnstructuredSolver(BaseSolver):
         else:
             logger.debug("To encode the problem for submit in the 'ref' format, "
                          "we need to upload it first.")
-            problem_id = self.upload_bqm(problem).result()
+            problem_id = self.upload_problem(problem).result()
 
         body = json.dumps({
             'solver': self.id,
             'data': encode_problem_as_ref(problem_id),
-            'type': 'bqm',
+            'type': problem_type,
             'params': params
         })
         logger.trace("Sampling request encoded as: %s", body)
 
         return body
 
-    def sample_bqm(self, bqm, **params):
-        """Sample from the specified :term:`BQM`.
+    def sample_problem(self, problem, problem_type=None, **params):
+        """Sample from the specified problem.
 
         Args:
-            bqm (:class:`~dimod.BinaryQuadraticModel`/str):
-                A binary quadratic model, or a reference to one
-                (Problem ID returned by `.upload_bqm` method).
+            problem (dimod-model-like/str):
+                A quadratic model (e.g. :class:`dimod.BQM`/:class:`dimod.DQM`),
+                or a reference to one (Problem ID returned by
+                :meth:`.upload_problem` method).
+
+            problem_type (str, optional):
+                Problem type, one of the handled problem types by the solver.
+                If not specified, the first handled problem type is used.
 
             **params:
                 Parameters for the sampling method, solver-specific.
 
         Returns:
             :class:`~dwave.cloud.computation.Future`
-
-        Note:
-            To use this method, dimod package has to be installed.
         """
 
+        # infer problem_type; for now just the the first handled (always just one)
+        if problem_type is None:
+            problem_type = next(iter(self._handled_problem_types))
+
         # encode the request (body as future)
         body = self.client._encode_problem_executor.submit(
-            self._encode_problem_as_ref,
-            problem=bqm, params=params)
+            self._encode_problem_for_submission,
+            problem=problem, problem_type=problem_type, params=params)
 
         # computation future holds a reference to the remote job
-        computation = Future(solver=self, id_=None, return_matrix=self.return_matrix)
+        computation = Future(
+            solver=self, id_=None, return_matrix=self.return_matrix)
 
         logger.debug("Submitting new problem to: %s", self.id)
         self.client._submit(body, computation)
 
         return computation
 
+
+class BQMSolver(BaseUnstructuredSolver):
+    """Class for D-Wave unstructured binary quadratic model solvers.
+
+    This class provides :term:`Ising`, :term:`QUBO` and :term:`BQM` sampling
+    methods and encapsulates the solver description returned from the D-Wave
+    cloud API.
+
+    Args:
+        client (:class:`~dwave.cloud.client.Client`):
+            Client that manages access to this solver.
+
+        data (`dict`):
+            Data from the server describing this solver.
+
+    Note:
+        Events are not yet dispatched from unstructured solvers.
+    """
+
+    _handled_problem_types = {"bqm"}
+    _handled_encoding_formats = {"bq"}
+
+    def _encode_problem_for_upload(self, bqm):
+        try:
+            data = bqm_as_file(bqm)
+        except Exception as e:
+            logger.debug("BQM conversion to file failed with %r, "
+                         "assuming data already encoded.", e)
+            # assume `bqm` given as file, ready for upload
+            data = bqm
+
+        return data
+
+    def sample_bqm(self, bqm, **params):
+        """Sample from the specified :term:`BQM`.
+
+        Args:
+            bqm (:class:`~dimod.BinaryQuadraticModel`/str):
+                A binary quadratic model, or a reference to one
+                (Problem ID returned by `.upload_bqm` method).
+
+            **params:
+                Parameters for the sampling method, solver-specific.
+
+        Returns:
+            :class:`~dwave.cloud.computation.Future`
+
+        Note:
+            To use this method, dimod package has to be installed.
+        """
+        return self.sample_problem(bqm, **params)
+
     def upload_bqm(self, bqm):
         """Upload the specified :term:`BQM` to SAPI, returning a Problem ID
         that can be used to submit the BQM to this solver (i.e. call the
@@ -408,16 +499,93 @@ class UnstructuredSolver(BaseSolver):
         Note:
             To use this method, dimod package has to be installed.
         """
+        return self.upload_problem(bqm)
+
+
+class DQMSolver(BaseUnstructuredSolver):
+    """Class for D-Wave unstructured discrete quadratic model solvers.
+
+    This class provides a :term:`DQM` sampling
+    method and encapsulates the solver description returned from the D-Wave
+    cloud API.
 
+    Args:
+        client (:class:`~dwave.cloud.client.Client`):
+            Client that manages access to this solver.
+
+        data (`dict`):
+            Data from the server describing this solver.
+
+    Note:
+        Events are not yet dispatched from unstructured solvers.
+    """
+
+    _handled_problem_types = {"dqm"}
+    _handled_encoding_formats = {"bq"}
+
+    def _encode_problem_for_upload(self, dqm):
         try:
-            data = bqm_as_file(bqm)
+            # note: SpooledTemporaryFile currently returned by DQM.to_file
+            # does not implement io.BaseIO interface, so we use the underlying
+            # (and internal) file-like object for now
+            data = dqm.to_file()._file
         except Exception as e:
-            logger.debug("BQM conversion to file failed with %r, "
+            logger.debug("DQM conversion to file failed with %r, "
                          "assuming data already encoded.", e)
-            # assume `bqm` given as file, ready for upload
-            data = bqm
+            # assume `dqm` given as file, ready for upload
+            data = dqm
 
-        return self.client.upload_problem_encoded(data)
+        logger.debug("Problem encoded for upload: %r", data)
+        return data
+
+    def sample_bqm(self, bqm, **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)
+
+        return self.sample_dqm(dqm, **params)
+
+    def sample_dqm(self, dqm, **params):
+        """Sample from the specified :term:`DQM`.
+
+        Args:
+            dqm (:class:`~dimod.DiscreteQuadraticModel`/str):
+                A discrete quadratic model, or a reference to one
+                (Problem ID returned by `.upload_dqm` method).
+
+            **params:
+                Parameters for the sampling method, solver-specific.
+
+        Returns:
+            :class:`~dwave.cloud.computation.Future`
+
+        Note:
+            To use this method, dimod package has to be installed.
+        """
+        return self.sample_problem(dqm, **params)
+
+    def upload_dqm(self, dqm):
+        """Upload the specified :term:`DQM` to SAPI, returning a Problem ID
+        that can be used to submit the DQM to this solver (i.e. call the
+        `.sample_dqm` method).
+
+        Args:
+            dqm (:class:`~dimod.DiscreteQuadraticModel`/bytes-like/file-like):
+                A discrete quadratic model given either as an in-memory
+                :class:`~dimod.DiscreteQuadraticModel` object, or as raw data
+                (encoded serialized model) in either a file-like or a bytes-like
+                object.
+
+        Returns:
+            :class:`concurrent.futures.Future`[str]:
+                Problem ID in a Future. Problem ID can be used to submit
+                problems by reference.
+
+        Note:
+            To use this method, dimod package has to be installed.
+        """
+        return self.upload_problem(dqm)
 
 
 class StructuredSolver(BaseSolver):
@@ -808,8 +976,8 @@ class StructuredSolver(BaseSolver):
 
 # for backwards compatibility:
 Solver = StructuredSolver
-
+UnstructuredSolver = BQMSolver
 
 # list of all available solvers, ordered according to loading attempt priority
 # (more specific first)
-available_solvers = [StructuredSolver, UnstructuredSolver]
\ No newline at end of file
+available_solvers = [StructuredSolver, BQMSolver, DQMSolver]
diff --git a/dwave/cloud/utils.py b/dwave/cloud/utils.py
index 161be65..d13018f 100644
--- a/dwave/cloud/utils.py
+++ b/dwave/cloud/utils.py
@@ -669,3 +669,27 @@ class aliasdict(dict):
         new = type(self)(self)
         new.alias(self.aliases)
         return new
+
+
+def bqm_to_dqm(bqm):
+    """Represent a :class:`dimod.BQM` as a :class:`dimod.DQM`."""
+    try:
+        from dimod import DiscreteQuadraticModel
+    except ImportError: # pragma: no cover
+        raise RuntimeError(
+            "dimod package with support for DiscreteQuadraticModel required."
+            "Re-install the library with 'dqm' support.")
+
+    dqm = DiscreteQuadraticModel()
+
+    ising = bqm.spin
+
+    for v, bias in ising.linear.items():
+        dqm.add_variable(2, label=v)
+        dqm.set_linear(v, [-bias, bias])
+
+    for (u, v), bias in ising.quadratic.items():
+        biases = np.array([[bias, -bias], [-bias, bias]], dtype=np.float64)
+        dqm.set_quadratic(u, v, biases)
+
+    return dqm
diff --git a/requirements.txt b/requirements.txt
index 6d477ef..e0cbad5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,5 +5,6 @@ click>=7
 plucky>=0.4.3
 
 # optional bqm support
+# note: dqm supported in dimod>0.9.6
 dimod>=0.8.15
 numpy>=1.16
\ No newline at end of file
diff --git a/setup.py b/setup.py
index ca04bfb..c159292 100644
--- a/setup.py
+++ b/setup.py
@@ -25,6 +25,9 @@ extras_require = {
 
     # bqm support
     'bqm': ['dimod>=0.8.15', 'numpy>=1.16'],
+
+    # dqm support
+    'bqm': ['dimod>=0.9.7', 'numpy>=1.16'],
 }
 
 # Packages provided. Only include packages under the 'dwave' namespace.


Test patch:
diff --git a/tests/test_coders.py b/tests/test_coders.py
index 223430e..b5209c8 100644
--- a/tests/test_coders.py
+++ b/tests/test_coders.py
@@ -295,12 +295,15 @@ class TestBQCoders(unittest.TestCase):
     def test_bq_response_decoding(self):
         """Answer to simple problem properly decoded."""
 
+        problem_type = 'bqm'
         ss = dimod.SampleSet.from_samples(
             ([[0, 1], [1, 0]], 'ab'), vartype='BINARY', energy=0)
 
-        msg = dict(answer=dict(format='bq', data=ss.to_serializable()))
+        msg = dict(
+            answer=dict(format='bq', data=ss.to_serializable()),
+            type=problem_type)
 
         res = decode_bq(msg)
 
-        self.assertEqual(res.get('problem_type'), 'bqm')
+        self.assertEqual(res.get('problem_type'), problem_type)
         self.assertEqual(res.get('sampleset'), ss)
diff --git a/tests/test_mock_unstructured_submission.py b/tests/test_mock_unstructured_submission.py
index b5af6f2..a49f719 100644
--- a/tests/test_mock_unstructured_submission.py
+++ b/tests/test_mock_unstructured_submission.py
@@ -14,6 +14,7 @@
 
 """Test problem submission to mock unstructured solvers."""
 
+import io
 import json
 import unittest
 from unittest import mock
@@ -22,21 +23,22 @@ import dimod
 import numpy
 
 from dwave.cloud.client import Client
-from dwave.cloud.solver import UnstructuredSolver
+from dwave.cloud.solver import (
+    BaseUnstructuredSolver, UnstructuredSolver, BQMSolver, DQMSolver)
 from dwave.cloud.concurrency import Present
 
 
-def unstructured_solver_data():
+def unstructured_solver_data(problem_type='bqm'):
     return {
         "properties": {
-            "supported_problem_types": ["bqm"],
+            "supported_problem_types": [problem_type],
             "parameters": {"num_reads": "Number of samples to return."}
         },
         "id": "test-unstructured-solver",
         "description": "A test unstructured solver"
     }
 
-def complete_reply(sampleset):
+def complete_reply(sampleset, problem_type='bqm'):
     """Reply with the sampleset as a solution."""
 
     return json.dumps([{
@@ -48,7 +50,7 @@ def complete_reply(sampleset):
             "format": "bq",
             "data": sampleset.to_serializable()
         },
-        "type": "bqm",
+        "type": problem_type,
         "id": "problem-id"
     }])
 
@@ -66,8 +68,8 @@ def choose_reply(path, replies):
 
 class TestUnstructuredSolver(unittest.TestCase):
 
-    def test_submit_immediate_reply(self):
-        """Construction of and sampling from an unstructured solver works."""
+    def test_sample_bqm_immediate_reply(self):
+        """Construction of and sampling from an unstructured BQM solver works."""
 
         # build a test problem
         bqm = dimod.BQM.from_ising({}, {'ab': 1})
@@ -83,8 +85,11 @@ class TestUnstructuredSolver(unittest.TestCase):
         # construct a functional solver by mocking client and api response data
         with mock.patch.object(Client, 'create_session', lambda self: session):
             with Client('endpoint', 'token') as client:
-                with mock.patch.object(UnstructuredSolver, 'upload_bqm', mock_upload):
-                    solver = UnstructuredSolver(client, unstructured_solver_data())
+                with mock.patch.object(BaseUnstructuredSolver, 'upload_problem', mock_upload):
+                    solver = BQMSolver(client, unstructured_solver_data())
+
+                    # make sure this still works
+                    _ = UnstructuredSolver(client, unstructured_solver_data())
 
                     # direct bqm sampling
                     ss = dimod.ExactSolver().sample(bqm)
@@ -128,6 +133,41 @@ class TestUnstructuredSolver(unittest.TestCase):
                     numpy.testing.assert_array_equal(fut.energies, ss.record.energy)
                     numpy.testing.assert_array_equal(fut.num_occurrences, ss.record.num_occurrences)
 
+    def test_sample_dqm_smoke_test(self):
+        """Construction of and sampling from an unstructured DQM solver works."""
+        # note: full test pending dimod release that supports DQM
+
+        # mock a DQM problem
+        #bqm = dimod.BQM.from_ising({}, {'ab': 1})
+        dqm = mock.Mock()
+        dqm.to_file.return_value._file = io.BytesIO(b'123')
+
+        problem_type = 'dqm'
+
+        # 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.object(Client, 'create_session', lambda self: session):
+            with Client('endpoint', 'token') as client:
+                with mock.patch.object(BaseUnstructuredSolver, 'upload_problem', mock_upload):
+                    solver = DQMSolver(client, unstructured_solver_data(problem_type=problem_type))
+
+                    # use bqm for now
+                    ss = dimod.ExactSolver().sample(dimod.BQM.empty('SPIN'))
+                    session.post = lambda path, _: choose_reply(
+                        path, {'problems/': complete_reply(ss, problem_type=problem_type)})
+
+                    # verify decoding works
+                    fut = solver.sample_dqm(dqm)
+                    numpy.testing.assert_array_equal(fut.sampleset, ss)
+                    numpy.testing.assert_array_equal(fut.problem_type, problem_type)
+
     def test_upload_failure(self):
         """Submit should gracefully fail if upload as part of submit fails."""
 
@@ -145,7 +185,7 @@ class TestUnstructuredSolver(unittest.TestCase):
         # construct a functional solver by mocking client and api response data
         with mock.patch.object(Client, 'create_session', lambda self: session):
             with Client('endpoint', 'token') as client:
-                with mock.patch.object(UnstructuredSolver, 'upload_bqm', mock_upload):
+                with mock.patch.object(BaseUnstructuredSolver, 'upload_problem', mock_upload):
                     solver = UnstructuredSolver(client, unstructured_solver_data())
 
                     # direct bqm sampling
@@ -175,8 +215,8 @@ class TestUnstructuredSolver(unittest.TestCase):
         # construct a functional solver by mocking client and api response data
         with mock.patch.object(Client, 'create_session', lambda self: session):
             with Client('endpoint', 'token') as client:
-                with mock.patch.object(UnstructuredSolver, 'upload_bqm', mock_upload):
-                    solver = UnstructuredSolver(client, unstructured_solver_data())
+                with mock.patch.object(BaseUnstructuredSolver, 'upload_problem', mock_upload):
+                    solver = BQMSolver(client, unstructured_solver_data())
 
                     futs = [solver.sample_bqm(bqm) for _ in range(100)]