Repository: dwavesystems/dwave-system

Issue:
Implement DWaveSampler.sample method
To replace `.sample_ising` and `.sample_qubo`

Pros:
* reduce code duplication
* avoid bugs like #197 
* rely on dimod for handling input formats (e.g. #201 would be covered by https://github.com/dwavesystems/dimod/issues/503)

Cons:
* performance, this would construct a BQM from the Ising problem/QUBO then invoke the relevant `BQM.to_ising` or `BQM.to_qubo` method before calling the [cloud-client](https://github.com/dwavesystems/dwave-cloud-client)

Discussion:
arcondello: See https://github.com/dwavesystems/dwave-cloud-client/issues/368

Patch:
diff --git a/dwave/system/samplers/dwave_sampler.py b/dwave/system/samplers/dwave_sampler.py
index 10aee29..d6280b4 100644
--- a/dwave/system/samplers/dwave_sampler.py
+++ b/dwave/system/samplers/dwave_sampler.py
@@ -285,21 +285,13 @@ class DWaveSampler(dimod.Sampler, dimod.Structured):
         return nodelist
 
     @_failover
-    def sample_ising(self, h, J, warnings=None, **kwargs):
+    def sample(self, bqm, warnings=None, **kwargs):
         """Sample from the specified Ising model.
 
         Args:
-            h (dict/list):
-                Linear biases of the Ising model. If a dict, should be of the
-                form `{v: bias, ...}` where `v` is a spin-valued variable and
-                `bias` is its associated bias. If a list, it is treated as a
-                list of biases where the indices are the variable labels,
-                except in the case of missing qubits in which case 0 biases are
-                ignored while a non-zero bias set on a missing qubit raises an
-                error.
-
-            J (dict[(int, int): float]):
-                Quadratic biases of the Ising model.
+            bqm (:class:`~dimod.BinaryQuadraticModel`):
+                The binary quadratic model. Must match :attr:`.nodelist` and
+                :attr:`.edgelist`.
 
             warnings (:class:`~dwave.system.warnings.WarningAction`, optional):
                 Defines what warning action to take, if any. See
@@ -335,104 +327,46 @@ class DWaveSampler(dimod.Sampler, dimod.Structured):
         for explanations of technical terms in descriptions of Ocean tools.
 
         """
-        nodes = self.solver.nodes  # set rather than .nodelist which is a list
 
-        if isinstance(h, list):
-            # to be consistent with the cloud-client, we ignore the 0 biases
-            # on missing nodes.
-            h = dict((v, b) for v, b in enumerate(h) if b or v in nodes)
-
-        # developer note: in the future we should probably catch exceptions
-        # from the cloud client, but for now this is simpler/cleaner. We use
-        # the solver's nodes/edges because they are a set, so faster lookup
-        edges = self.solver.edges
-        if not (all(v in nodes for v in h) and
-                all((u, v) in edges or (v, u) in edges for u, v in J)):
+        solver = self.solver
+
+        if not (solver.nodes.issuperset(bqm.linear) and
+                solver.edges.issuperset(bqm.quadratic)):
             msg = "Problem graph incompatible with solver."
             raise BinaryQuadraticModelStructureError(msg)
 
-        future = self.solver.sample_ising(h, J, **kwargs)
-
-        # do as much as possible after the future is returned
-
-        variables = set(h).union(*J)
+        future = solver.sample_bqm(bqm, **kwargs)
 
         if warnings is None:
             warnings = self.warnings_default
         warninghandler = WarningHandler(warnings)
-        warninghandler.energy_scale((h, J))
-
-        hook = _result_to_response_hook(variables, dimod.SPIN, warninghandler)
-        return dimod.SampleSet.from_future(future, hook)
-
-    @_failover
-    def sample_qubo(self, Q, warnings=None, **kwargs):
-        """Sample from the specified QUBO.
-
-        Args:
-            Q (dict):
-                Coefficients of a quadratic unconstrained binary optimization (QUBO) model.
-
-            warnings (:class:`~dwave.system.warnings.WarningAction`, optional):
-                Defines what warning action to take, if any. See
-                :mod:`~dwave.system.warnings`. The default behaviour is defined
-                by :attr:`warnings_default`, which itself defaults to
-                :class:`~dwave.system.warnings.IGNORE`
-
-            **kwargs:
-                Optional keyword arguments for the sampling method, specified per solver in
-                :attr:`.DWaveSampler.parameters`. D-Wave System Documentation's
-                `solver guide <https://docs.dwavesys.com/docs/latest/doc_solver_ref.html>`_
-                describes the parameters and properties supported on the D-Wave system.
-
-        Returns:
-            :class:`dimod.SampleSet`: A `dimod` :obj:`~dimod.SampleSet` object.
-            In it this sampler also provides timing information in the `info`
-            field as described in the D-Wave System Documentation's
-            `timing guide <https://docs.dwavesys.com/docs/latest/doc_timing.html>`_.
+        warninghandler.energy_scale(bqm)
 
-        Examples:
-            This example submits a two-variable QUBO mapped directly to qubits
-            0 and 4 on a D-Wave system. Given sufficient reads (here 100), the quantum
-            computer should return the best solutions, :math:`{0, 1}` or
-            :math:`{1, 0}` on qubits 0 and 4, respectively, as its first sample
-            (samples are ordered from lowest energy).
-
-            >>> from dwave.system import DWaveSampler
-            >>> sampler = DWaveSampler()
-            >>> Q = {(0, 0): -1, (4, 4): -1, (0, 4): 2}
-            >>> sampleset = sampler.sample_qubo(Q, num_reads=100)
-            >>> sampleset.first.sample[0] != sampleset.first.sample[4]
-            True
-
-        See `Ocean Glossary <https://docs.ocean.dwavesys.com/en/latest/glossary.html>`_
-        for explanations of technical terms in descriptions of Ocean tools.
+        # need a hook so that we can check the sampleset (lazily) for
+        # warnings
+        def _hook(computation):
+            sampleset = computation.sampleset
 
-        """
+            if warninghandler is not None:
+                warninghandler.too_few_samples(sampleset)
+                if warninghandler.action is WarningAction.SAVE:
+                    sampleset.info['warnings'] = warninghandler.saved
 
-        # developer note: in the future we should probably catch exceptions
-        # from the cloud client, but for now this is simpler/cleaner. We use
-        # the solver's nodes/edges because they are a set, so faster lookup
-        nodes = self.solver.nodes
-        edges = self.solver.edges
-        if not all(u in nodes if u == v else ((u, v) in edges or (v, u) in edges)
-                   for u, v in Q):
-            msg = "Problem graph incompatible with solver."
-            raise BinaryQuadraticModelStructureError(msg)
+            return sampleset
 
-        future = self.solver.sample_qubo(Q, **kwargs)
+        return dimod.SampleSet.from_future(future, _hook)
 
-        # do as much as possible after the future is returned
-
-        variables = set().union(*Q)
-
-        if warnings is None:
-            warnings = self.warnings_default
-        warninghandler = WarningHandler(warnings)
-        warninghandler.energy_scale((Q,))
+    def sample_ising(self, h, *args, **kwargs):
+        # to be consistent with the cloud-client, we ignore the 0 biases
+        # on missing nodes for lists
+        if isinstance(h, list):
+            if len(h) > self.solver.num_qubits:
+                msg = "Problem graph incompatible with solver."
+                raise BinaryQuadraticModelStructureError(msg)
+            nodes = self.solver.nodes
+            h = dict((v, b) for v, b in enumerate(h) if b and v in nodes)
 
-        hook = _result_to_response_hook(variables, dimod.BINARY, warninghandler)
-        return dimod.SampleSet.from_future(future, hook)
+        return super().sample_ising(h, *args, **kwargs)
 
     def validate_anneal_schedule(self, anneal_schedule):
         """Raise an exception if the specified schedule is invalid for the sampler.
@@ -530,34 +464,3 @@ class DWaveSampler(dimod.Sampler, dimod.Structured):
         for (t0, s0), (t1, s1) in zip(anneal_schedule, anneal_schedule[1:]):
             if abs((s0 - s1) / (t0 - t1)) > max_slope:
                 raise ValueError("the maximum slope cannot exceed {}".format(max_slope))
-
-
-def _result_to_response_hook(variables, vartype, warninghandler=None):
-
-    def _hook(computation):
-        result = computation.result()
-
-        # get the samples. The future will return all spins so filter for the ones in variables
-        samples = [[sample[v] for v in variables] for sample in result.get('solutions')]
-
-        # construct the info field (add timing, problem id)
-        info = {}
-        if 'timing' in result:
-            info.update(timing=result['timing'])
-        if hasattr(computation, 'id'):
-            info.update(problem_id=computation.id)
-
-        sampleset = dimod.SampleSet.from_samples((samples, variables), info =info, vartype=vartype,
-                                                 energy=result['energies'],
-                                                 num_occurrences=result.get('num_occurrences', None),
-                                                 sort_labels=True)
-
-        if warninghandler is not None:
-            warninghandler.too_few_samples(sampleset)
-
-            if warninghandler.action is WarningAction.SAVE:
-                sampleset.info['warnings'] = warninghandler.saved
-
-        return sampleset
-
-    return _hook
diff --git a/requirements.txt b/requirements.txt
index 8d7ea4f..85c8a01 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,12 +1,12 @@
 --extra-index-url https://pypi.dwavesys.com/simple
 
 dimod==0.9.0
-dwave-cloud-client==0.7.0
+dwave-cloud-client==0.7.1
 dwave-networkx==0.8.4
 dwave-drivers==0.4.4
 dwave-tabu==0.2.2
 homebase==1.0.1
-minorminer==0.1.8
+minorminer==0.1.9
 six==1.11.0
 mock==2.0.0
 numpy==1.18.0
diff --git a/setup.py b/setup.py
index 293797e..d7b0c0f 100644
--- a/setup.py
+++ b/setup.py
@@ -26,7 +26,7 @@ exec(open(os.path.join(".", "dwave", "system", "package_info.py")).read())
 
 
 install_requires = ['dimod>=0.9.0,<0.10.0',
-                    'dwave-cloud-client>=0.6.0,<0.8.0',
+                    'dwave-cloud-client>=0.7.1,<0.8.0',
                     'dwave-networkx>=0.8.4',
                     'networkx>=2.0,<3.0',
                     'homebase>=1.0.0,<2.0.0',


Test patch:
diff --git a/tests/test_dwavesampler.py b/tests/test_dwavesampler.py
index ed57660..5466378 100644
--- a/tests/test_dwavesampler.py
+++ b/tests/test_dwavesampler.py
@@ -69,58 +69,30 @@ class MockSolver():
                                  'chains': ""},
                   'chip_id': 'MockSolver'}
 
-    def sample_ising(self, h, J, **kwargs):
-        for key in kwargs:
-            if key not in self.properties['parameters']:
-                raise ValueError
-        result = {'num_variables': 2048,
-                  'format': 'qp',
-                  'num_occurrences': [1],
-                  'active_variables': list(range(2048)),
-                  'solutions': [[random.choice((-1, +1)) for __ in range(2048)]],
-                  'timing': {'total_real_time': 11511, 'anneal_time_per_run': 20,
-                             'post_processing_overhead_time': 2042, 'qpu_sampling_time': 164,
-                             'readout_time_per_run': 123,
-                             'qpu_delay_time_per_sample': 21,
-                             'qpu_anneal_time_per_sample': 20,
-                             'total_post_processing_time': 2042,
-                             'qpu_programming_time': 8740,
-                             'run_time_chip': 164,
-                             'qpu_access_time': 11511,
-                             'qpu_readout_time_per_sample': 123},
-                  'occurrences': [1]}
-        result['samples'] = result['solutions']
-        result['energies'] = [dimod.ising_energy(sample, h, J) for sample in result['samples']]
-        future = Future()
-        future.id = uuid4()
-        future.set_result(result)
-        return future
+    def sample_bqm(self, bqm, num_reads=1, **kwargs):
+        info = dict(timing={'total_real_time': 11511, 'anneal_time_per_run': 20,
+                            'post_processing_overhead_time': 2042,
+                            'qpu_sampling_time': 164,
+                            'readout_time_per_run': 123,
+                            'qpu_delay_time_per_sample': 21,
+                            'qpu_anneal_time_per_sample': 20,
+                            'total_post_processing_time': 2042,
+                            'qpu_programming_time': 8740,
+                            'run_time_chip': 164,
+                            'qpu_access_time': 11511,
+                            'qpu_readout_time_per_sample': 123},
+                    problem_id=5)
+
+        samples = np.random.choice(tuple(bqm.vartype.value),
+                                   size=(num_reads, len(bqm)))
 
-    def sample_qubo(self, Q, **kwargs):
-        for key in kwargs:
-            if key not in self.properties['parameters']:
-                raise ValueError
-        result = {'num_variables': 2048,
-                  'format': 'qp',
-                  'num_occurrences': [1],
-                  'active_variables': list(range(2048)),
-                  'solutions': [[random.choice((0, 1)) for __ in range(2048)]],
-                  'timing': {'total_real_time': 11511, 'anneal_time_per_run': 20,
-                             'post_processing_overhead_time': 2042, 'qpu_sampling_time': 164,
-                             'readout_time_per_run': 123,
-                             'qpu_delay_time_per_sample': 21,
-                             'qpu_anneal_time_per_sample': 20,
-                             'total_post_processing_time': 2042,
-                             'qpu_programming_time': 8740,
-                             'run_time_chip': 164,
-                             'qpu_access_time': 11511,
-                             'qpu_readout_time_per_sample': 123},
-                  'occurrences': [1]}
-        result['samples'] = result['solutions']
-        result['energies'] = [dimod.qubo_energy(sample, Q) for sample in result['samples']]
         future = Future()
+        ss = dimod.SampleSet.from_samples_bqm((samples, bqm.variables), bqm,
+                                              info=info)
+        future.sampleset = ss
         future.id = uuid4()
-        future.set_result(result)
+        future.set_result = {}  # not actually needed
+
         return future
 
 
@@ -206,6 +178,7 @@ class TestDwaveSampler(unittest.TestCase):
 
         sampler.solver.sample_ising.side_effect = SolverOfflineError
         sampler.solver.sample_qubo.side_effect = SolverOfflineError
+        sampler.solver.sample_bqm.side_effect = SolverOfflineError
 
         with self.assertRaises(SolverOfflineError):
             sampler.sample_ising({}, {})
@@ -227,11 +200,13 @@ class TestDwaveSampler(unittest.TestCase):
 
         # one of the sample methods was called
         self.assertEqual(sampler.solver.sample_ising.call_count
-                         + sampler.solver.sample_qubo.call_count, 1)
+                         + sampler.solver.sample_qubo.call_count
+                         + sampler.solver.sample_bqm.call_count, 1)
 
         # add a side-effect
         sampler.solver.sample_ising.side_effect = SolverOfflineError
         sampler.solver.sample_qubo.side_effect = SolverOfflineError
+        sampler.solver.sample_bqm.side_effect = SolverOfflineError
 
         # and make sure get_solver makes a new mock solver
         sampler.client.get_solver.reset_mock(return_value=True)
@@ -251,6 +226,7 @@ class TestDwaveSampler(unittest.TestCase):
         # add a side-effect
         sampler.solver.sample_ising.side_effect = SolverOfflineError
         sampler.solver.sample_qubo.side_effect = SolverOfflineError
+        sampler.solver.sample_bqm.side_effect = SolverOfflineError
 
         # and make sure get_solver makes a new mock solver
         sampler.client.get_solver.side_effect = SolverNotFoundError