Repository: dwavesystems/dwave-cloud-client

Issue:
`StructuredSolver.sample_bqm()` ignores energy offset
`StructuredSolver.sample_bqm(bqm)` submits linear/quadratic coefficients to the QPU, but it drops off the constant energy offset (if it exists). SAPI doesn't accept energy offset, but `sample_bqm` should apply it to QPU results, if it was provided.

This means we need to "patch" energies returned by SAPI not only in the sampleset, but also in raw solutions data.

If we are doing this for `sample_bqm`, we might as well add optional `offset` parameter to `sample_ising` and `sample_qubo` methods. In both `StructuredSolver` and `UnstructuredSolver` interfaces.

Discussion:

Patch:
diff --git a/dwave/cloud/coders.py b/dwave/cloud/coders.py
index 62475fd..7d498ab 100644
--- a/dwave/cloud/coders.py
+++ b/dwave/cloud/coders.py
@@ -24,7 +24,8 @@ from dwave.cloud.utils import (
 __all__ = ['encode_problem_as_qp', 'decode_qp', 'decode_qp_numpy']
 
 
-def encode_problem_as_qp(solver, linear, quadratic, undirected_biases=False):
+def encode_problem_as_qp(solver, linear, quadratic, offset=0,
+                         undirected_biases=False):
     """Encode the binary quadratic problem for submission to a given solver,
     using the `qp` format for data.
 
@@ -38,6 +39,9 @@ def encode_problem_as_qp(solver, linear, quadratic, undirected_biases=False):
         quadratic (dict[(variable, variable), bias]):
             Quadratic terms of the model.
 
+        offset (number, default=0):
+            Constant offset applied to the model.
+
         undirected_biases (boolean, default=False):
             Are (quadratic) biases specified on undirected edges?
 
@@ -79,7 +83,8 @@ def encode_problem_as_qp(solver, linear, quadratic, undirected_biases=False):
     return {
         'format': 'qp',
         'lin': lin.decode('utf-8'),
-        'quad': quad.decode('utf-8')
+        'quad': quad.decode('utf-8'),
+        'offset': offset
     }
 
 
@@ -99,6 +104,11 @@ def decode_qp(msg):
         result['num_occurrences'] = _decode_ints(result['num_occurrences'])
     result['energies'] = _decode_doubles(result['energies'])
 
+    # adjust energies by offset (in future this might be handled by SAPI)
+    offset = result.setdefault('offset', 0)
+    if offset:
+        result['energies'] = [en + offset for en in result['energies']]
+
     # Measure out the size of the binary solution data
     num_solutions = len(result['energies'])
     num_variables = len(result['active_variables'])
@@ -208,6 +218,12 @@ def decode_qp_numpy(msg, return_matrix=True):
     result['energies'] = np.frombuffer(base64.b64decode(result['energies']),
                                        dtype=double_type)
 
+    # adjust energies by offset (in future this might be handled by SAPI)
+    offset = result.setdefault('offset', 0)
+    if offset:
+        # we need to make a copy because frombuffer returns read-only array
+        result['energies'] = result['energies'] + offset
+
     if 'num_occurrences' in result:
         result['num_occurrences'] = \
             np.frombuffer(base64.b64decode(result['num_occurrences']),
diff --git a/dwave/cloud/computation.py b/dwave/cloud/computation.py
index e7c6077..2d7ad04 100644
--- a/dwave/cloud/computation.py
+++ b/dwave/cloud/computation.py
@@ -156,6 +156,9 @@ class Future(object):
         # current poll back-off interval, in seconds
         self._poll_backoff = None
 
+        # XXX: energy offset carried via Future, until implemented in SAPI
+        self._offset = 0
+
     # TODO: remove in 0.9.0
     @property
     def error(self):
@@ -878,9 +881,21 @@ class Future(object):
 
         return self._result
 
+    def _patch_offset(self):
+        # XXX: This is a temporary fix, until SAPI starts returning the offset
+        # in answer (for structured solvers only).
+        # It will patch `self._message` to include the offset as set in
+        # `self._offset`, but only if SAPI answer does not contain offset already.
+        msg = self._message
+        fmt = msg.get('answer', {}).get('format')
+        if fmt == 'qp':
+            if 'offset' not in msg['answer']:
+                msg['answer']['offset'] = self._offset
+
     def _decode(self):
         """Decode answer data from the response."""
         start = time.time()
+        self._patch_offset()
         self._result = self.solver.decode_response(self._message)
         self.parse_time = time.time() - start
         return self._result
diff --git a/dwave/cloud/solver.py b/dwave/cloud/solver.py
index 762658a..dd1a45c 100644
--- a/dwave/cloud/solver.py
+++ b/dwave/cloud/solver.py
@@ -274,12 +274,23 @@ class UnstructuredSolver(BaseSolver):
     _handled_problem_types = {"bqm"}
     _handled_encoding_formats = {"bq"}
 
-    def sample_ising(self, linear, quadratic, **params):
-        """Sample from the specified :term:`BQM`.
+    def sample_ising(self, linear, quadratic, offset=0, **params):
+        """Sample from the specified :term:`Ising` model.
 
         Args:
-            bqm (:class:`~dimod.BinaryQuadraticModel`):
-                A binary quadratic model.
+            linear (dict/list):
+                Linear biases of the Ising problem. 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.
+
+            quadratic (dict[(variable, variable), bias]):
+                Quadratic terms of the model (J), stored in a dict. With keys
+                that are 2-tuples of variables and values are quadratic biases
+                associated with the pair of variables (the interaction).
+
+            offset (optional, default=0):
+                Constant offset applied to the model.
 
             **params:
                 Parameters for the sampling method, solver-specific.
@@ -296,16 +307,21 @@ class UnstructuredSolver(BaseSolver):
             raise RuntimeError("Can't use solver of type 'bqm' without dimod. "
                                "Re-install the library with 'bqm' support.")
 
-        bqm = dimod.BinaryQuadraticModel.from_ising(linear, quadratic)
+        bqm = dimod.BinaryQuadraticModel.from_ising(linear, quadratic, offset)
         return self.sample_bqm(bqm, **params)
 
-    def sample_qubo(self, qubo, **params):
+    def sample_qubo(self, qubo, offset=0, **params):
         """Sample from the specified :term:`QUBO`.
 
         Args:
-            qubo (dict[(int, int), float]):
+            qubo (dict):
                 Coefficients of a quadratic unconstrained binary optimization
-                (QUBO) model.
+                (QUBO) problem. Should be a dict of the form `{(u, v): bias, ...}`
+                where `u`, `v`, are binary-valued variables and `bias` is their
+                associated coefficient.
+
+            offset (optional, default=0):
+                Constant offset applied to the model.
 
             **params:
                 Parameters for the sampling method, solver-specific.
@@ -322,7 +338,7 @@ class UnstructuredSolver(BaseSolver):
             raise RuntimeError("Can't use solver of type 'bqm' without dimod. "
                                "Re-install the library with 'bqm' support.")
 
-        bqm = dimod.BinaryQuadraticModel.from_qubo(qubo)
+        bqm = dimod.BinaryQuadraticModel.from_qubo(qubo, offset)
         return self.sample_bqm(bqm, **params)
 
     def _encode_any_problem_as_bqm_ref(self, problem, params):
@@ -572,18 +588,24 @@ class StructuredSolver(BaseSolver):
 
     # Sampling methods
 
-    def sample_ising(self, linear, quadratic, **params):
+    def sample_ising(self, linear, quadratic, offset=0, **params):
         """Sample from the specified :term:`Ising` model.
 
         Args:
-            linear (list/dict):
-                Linear terms of the model (h).
+            linear (dict/list):
+                Linear biases of the Ising problem. 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.
 
             quadratic (dict[(int, int), float]):
                 Quadratic terms of the model (J), stored in a dict. With keys
                 that are 2-tuples of variables and values are quadratic biases
                 associated with the pair of variables (the interaction).
 
+            offset (optional, default=0):
+                Constant offset applied to the model.
+
             **params:
                 Parameters for the sampling method, solver-specific.
 
@@ -614,15 +636,20 @@ 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, params)
+        return self._sample('ising', linear, quadratic, offset, params)
 
-    def sample_qubo(self, qubo, **params):
+    def sample_qubo(self, qubo, offset=0, **params):
         """Sample from the specified :term:`QUBO`.
 
         Args:
             qubo (dict[(int, int), float]):
                 Coefficients of a quadratic unconstrained binary optimization
-                (QUBO) model.
+                (QUBO) problem. Should be a dict of the form `{(u, v): bias, ...}`
+                where `u`, `v`, are binary-valued variables and `bias` is their
+                associated coefficient.
+
+            offset (optional, default=0):
+                Constant offset applied to the model.
 
             **params:
                 Parameters for the sampling method, solver-specific.
@@ -653,8 +680,8 @@ class StructuredSolver(BaseSolver):
             (1, 0)
 
         """
-        linear, quadratic = reformat_qubo_as_ising(qubo)
-        return self._sample('qubo', linear, quadratic, params)
+        linear, quadratic, offset = reformat_qubo_as_ising(qubo, offset)
+        return self._sample('qubo', linear, quadratic, offset, params)
 
     def sample_bqm(self, bqm, **params):
         """Sample from the specified :term:`BQM`.
@@ -679,15 +706,16 @@ class StructuredSolver(BaseSolver):
                                "Re-install the library with 'bqm' support.")
 
         if bqm.vartype is dimod.SPIN:
-            return self._sample('ising', bqm.linear, bqm.quadratic, params,
-                                undirected_biases=True)
+            return self._sample('ising', bqm.linear, bqm.quadratic, bqm.offset,
+                                params, undirected_biases=True)
         elif bqm.vartype is dimod.BINARY:
-            return self._sample('qubo', bqm.linear, bqm.quadratic, params,
-                                undirected_biases=True)
+            return self._sample('qubo', bqm.linear, bqm.quadratic, bqm.offset,
+                                params, undirected_biases=True)
         else:
             raise TypeError("unknown/unsupported vartype")
 
-    def _sample(self, type_, linear, quadratic, params, undirected_biases=False):
+    def _sample(self, type_, linear, quadratic, offset, params,
+                undirected_biases=False):
         """Internal method for `sample_ising`, `sample_qubo` and `sample_bqm`.
 
         Args:
@@ -697,6 +725,9 @@ class StructuredSolver(BaseSolver):
             quadratic (dict[(int, int), float]):
                 Quadratic terms of the model.
 
+            offset (number):
+                Constant offset applied to the model.
+
             params (dict):
                 Parameters for the sampling method, solver-specific.
 
@@ -709,7 +740,9 @@ class StructuredSolver(BaseSolver):
             :class:`Future`
         """
 
-        args = dict(type_=type_, linear=linear, quadratic=quadratic, params=params)
+        args = dict(type_=type_, linear=linear, quadratic=quadratic,
+                    offset=offset, params=params,
+                    undirected_biases=undirected_biases)
         dispatch_event('before_sample', obj=self, args=args)
 
         # Check the problem
@@ -730,7 +763,7 @@ class StructuredSolver(BaseSolver):
 
         body_data = json.dumps({
             'solver': self.id,
-            'data': encode_problem_as_qp(self, linear, quadratic,
+            'data': encode_problem_as_qp(self, linear, quadratic, offset,
                                          undirected_biases=undirected_biases),
             'type': type_,
             'params': combined_params
@@ -740,6 +773,9 @@ class StructuredSolver(BaseSolver):
         body = Present(result=body_data)
         computation = Future(solver=self, id_=None, return_matrix=self.return_matrix)
 
+        # XXX: offset is carried on Future until implemented in SAPI
+        computation._offset = offset
+
         logger.debug("Submitting new problem to: %s", self.id)
         self.client._submit(body, computation)
 
diff --git a/dwave/cloud/utils.py b/dwave/cloud/utils.py
index 70df60c..ed65736 100644
--- a/dwave/cloud/utils.py
+++ b/dwave/cloud/utils.py
@@ -44,12 +44,13 @@ __all__ = ['evaluate_ising', 'uniform_iterator', 'uniform_get',
 logger = logging.getLogger(__name__)
 
 
-def evaluate_ising(linear, quad, state):
+def evaluate_ising(linear, quad, state, offset=0):
     """Calculate the energy of a state given the Hamiltonian.
 
     Args:
         linear: Linear Hamiltonian terms.
         quad: Quadratic Hamiltonian terms.
+        offset: Energy offset.
         state: Vector of spins describing the system state.
 
     Returns:
@@ -58,10 +59,10 @@ def evaluate_ising(linear, quad, state):
 
     # If we were given a numpy array cast to list
     if _numpy and isinstance(state, np.ndarray):
-        return evaluate_ising(linear, quad, state.tolist())
+        return evaluate_ising(linear, quad, state.tolist(), offset=offset)
 
     # Accumulate the linear and quadratic values
-    energy = 0.0
+    energy = offset
     for index, value in uniform_iterator(linear):
         energy += state[index] * value
     for (index_a, index_b), value in quad.items():
@@ -132,7 +133,7 @@ def uniform_get(sequence, index, default=None):
         return sequence[index] if index < len(sequence) else default
 
 
-def reformat_qubo_as_ising(qubo):
+def reformat_qubo_as_ising(qubo, offset=0.0):
     """Split QUBO coefficients into linear and quadratic terms (the Ising form).
 
     Args:
@@ -140,15 +141,18 @@ def reformat_qubo_as_ising(qubo):
             Coefficients of a quadratic unconstrained binary optimization
             (QUBO) model.
 
+        offset (optional, default=0):
+            Constant offset applied to the model.
+
     Returns:
-        (dict[int, float], dict[(int, int), float])
+        (dict[int, float], dict[(int, int), float], float)
 
     """
 
     lin = {u: bias for (u, v), bias in qubo.items() if u == v}
     quad = {(u, v): bias for (u, v), bias in qubo.items() if u != v}
 
-    return lin, quad
+    return lin, quad, float(offset)
 
 
 def strip_head(sequence, values):


Test patch:
diff --git a/tests/test_coders.py b/tests/test_coders.py
index 3eba6db..fdb184d 100644
--- a/tests/test_coders.py
+++ b/tests/test_coders.py
@@ -55,7 +55,7 @@ def get_unstructured_solver():
     return UnstructuredSolver(client=None, data=data)
 
 
-class TestQPCoders(unittest.TestCase):
+class CodersTestBase(unittest.TestCase):
     nan = float('nan')
 
     # response to a 5-qubit problem from a 5 qubit machine
@@ -80,6 +80,9 @@ class TestQPCoders(unittest.TestCase):
     res_energies = (-15.0,)
     res_num_occurrences = (100,)
 
+
+class TestQPCoders(CodersTestBase):
+
     def encode_doubles(self, values):
         return base64.b64encode(struct.pack('<' + ('d' * len(values)), *values)).decode('utf-8')
 
@@ -93,6 +96,21 @@ class TestQPCoders(unittest.TestCase):
         self.assertEqual(request['lin'],  self.encode_doubles([1, 1, 1, 1]))
         self.assertEqual(request['quad'], self.encode_doubles([-1, -1, -1, -1]))
 
+    def test_qp_request_encodes_offset(self):
+        """Test `offset` is stored in problem data."""
+
+        solver = get_structured_solver()
+        linear, quadratic = generate_const_ising_problem(solver, h=1, j=-1)
+
+        # default offset is zero
+        request = encode_problem_as_qp(solver, linear, quadratic)
+        self.assertEqual(request['offset'], 0)
+
+        # test explicit offset
+        offset = 2.0
+        request = encode_problem_as_qp(solver, linear, quadratic, offset)
+        self.assertEqual(request['offset'], offset)
+
     def test_qp_request_encoding_sub_qubits(self):
         """Inactive qubits should be encoded as NaNs. Inactive couplers should be omitted."""
 
@@ -158,6 +176,10 @@ class TestQPCoders(unittest.TestCase):
         # [-1]
         self.assertEqual(request['quad'], self.encode_doubles([-1]))
 
+
+class TestQPDecoders(CodersTestBase):
+    """Test QP decoders correctly decode response data."""
+
     def test_qp_response_decoding(self):
         res = decode_qp(copy.deepcopy(self.res_msg))
 
@@ -189,6 +211,20 @@ class TestQPCoders(unittest.TestCase):
         np.testing.assert_array_equal(res.get('num_occurrences'), np.array(self.res_num_occurrences))
 
 
+class TestQPDecodersWithOffset(TestQPDecoders):
+    """Test decoders correctly apply energy offset."""
+
+    res_offset = 2.0
+
+    def setUp(self):
+        self.res_msg['answer']['offset'] = self.res_offset
+        self.res_energies = [en + self.res_offset for en in self.res_energies]
+
+    def test_offset_present_in_answer(self):
+        res = decode_qp(copy.deepcopy(self.res_msg))
+        self.assertEqual(res.get('offset'), self.res_offset)
+
+
 class TestBQCoders(unittest.TestCase):
 
     def test_bq_request_encoding_empty_bqm(self):
diff --git a/tests/test_events.py b/tests/test_events.py
index dc06b7c..8fa0b12 100644
--- a/tests/test_events.py
+++ b/tests/test_events.py
@@ -129,12 +129,15 @@ class TestEventDispatch(unittest.TestCase):
         # sample
         lin = {0: 1}
         quad = {(0, 1): 1}
+        offset = 2
         params = dict(num_reads=100)
-        future = self.solver.sample_ising(lin, quad, **params)
+        future = self.solver.sample_ising(lin, quad, offset, **params)
 
         # test entry values
         before = memo['before_sample']
-        args = dict(type_='ising', linear=lin, quadratic=quad, params=params)
+        args = dict(type_='ising', linear=lin, quadratic=quad,
+                    offset=offset, params=params,
+                    undirected_biases=False)
         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 425438b..a8b5442 100644
--- a/tests/test_mock_submission.py
+++ b/tests/test_mock_submission.py
@@ -29,6 +29,11 @@ from requests.structures import CaseInsensitiveDict
 from requests.exceptions import HTTPError
 from concurrent.futures import TimeoutError
 
+try:
+    import dimod
+except ImportError:
+    dimod = None
+
 from dwave.cloud.utils import evaluate_ising, generate_const_ising_problem
 from dwave.cloud.client import Client, Solver
 from dwave.cloud.computation import Future
@@ -61,15 +66,15 @@ def solver_data(id_, incomplete=False):
     return obj
 
 
-def complete_reply(id_, solver_name):
+def complete_reply(id_, solver_name, answer=None, msg=None):
     """Reply with solutions for the test problem."""
-    return json.dumps({
+    response = {
         "status": "COMPLETED",
         "solved_on": "2013-01-18T10:26:00.020954",
         "solver": solver_name,
         "submitted_on": "2013-01-18T10:25:59.941674",
         "answer": {
-            'format': 'qp',
+            "format": "qp",
             "num_variables": 5,
             "energies": 'AAAAAAAALsA=',
             "num_occurrences": 'ZAAAAA==',
@@ -79,7 +84,17 @@ def complete_reply(id_, solver_name):
         },
         "type": "ising",
         "id": id_
-    })
+    }
+
+    # optional answer fields override
+    if answer:
+        response['answer'].update(answer)
+
+    # optional msg, top-level override
+    if msg:
+        response.update(msg)
+
+    return json.dumps(response)
 
 
 def complete_no_answer_reply(id_, solver_name):
@@ -182,13 +197,25 @@ def choose_reply(path, replies, statuses=None, date=None):
 
 
 class _QueryTest(unittest.TestCase):
-    def _check(self, results, linear, quad, num):
+    def _check(self, results, linear, quad, offset=0, num_reads=1):
         # Did we get the right number of samples?
-        self.assertTrue(100 == sum(results.occurrences))
+        self.assertEqual(num_reads, sum(results.occurrences))
 
-        # Make sure the number of occurrences and energies are all correct
+        # Make sure energies are correct in raw results
         for energy, state in zip(results.energies, results.samples):
-            self.assertTrue(energy == evaluate_ising(linear, quad, state))
+            self.assertEqual(energy, evaluate_ising(linear, quad, state, offset=offset))
+
+        # Make sure the sampleset matches raw results
+        for record, energy, num_occurrences, state in \
+                zip(results.sampleset.record,
+                    results['energies'],
+                    results['num_occurrences'],
+                    results['solutions']):
+            recalc_energy = evaluate_ising(linear, quad, state, offset=offset)
+            self.assertEqual(energy, recalc_energy)
+            self.assertEqual(energy, float(record.energy))
+            self.assertEqual(num_occurrences, int(record.num_occurrences))
+            self.assertEqual(state, list(record.sample))
 
 
 @mock.patch('time.sleep', lambda *x: None)
@@ -211,12 +238,12 @@ class MockSubmission(_QueryTest):
                 solver = Solver(client, solver_data('abc123'))
 
                 linear, quadratic = test_problem(solver)
-                results = solver.sample_ising(linear, quadratic, num_reads=100)
+                results = solver.sample_ising(linear, quadratic)
 
                 with self.assertRaises(IOError):
                     results.samples
 
-    def test_submit_ok_reply(self):
+    def test_submit_ising_ok_reply(self):
         """Handle a normal query and response."""
 
         # each thread can have its instance of a session because
@@ -236,9 +263,111 @@ class MockSubmission(_QueryTest):
                 solver = Solver(client, solver_data('abc123'))
 
                 linear, quadratic = test_problem(solver)
-                results = solver.sample_ising(linear, quadratic, num_reads=100)
+                params = dict(num_reads=100)
+                results = solver.sample_ising(linear, quadratic, **params)
+
+                self._check(results, linear, quadratic, **params)
+
+    @unittest.skipUnless(dimod, "dimod required for 'Solver.sample_bqm'")
+    def test_submit_bqm_ising_ok_reply(self):
+        """Handle a normal query and response."""
+
+        # each thread can have its instance of a session because
+        # the mocked responses are stateless
+        def create_mock_session(client):
+            session = mock.Mock()
+            session.post = lambda a, _: choose_reply(a, {
+                'problems/': '[%s]' % complete_no_answer_reply(
+                    '123', 'abc123')})
+            session.get = lambda a: choose_reply(a, {
+                'problems/123/': complete_reply(
+                    '123', 'abc123')})
+            return session
+
+        with mock.patch.object(Client, 'create_session', create_mock_session):
+            with Client('endpoint', 'token') as client:
+                solver = Solver(client, solver_data('abc123'))
+
+                h, J = test_problem(solver)
+                bqm = dimod.BinaryQuadraticModel.from_ising(h, J)
+
+                params = dict(num_reads=100)
+                results = solver.sample_bqm(bqm, **params)
+
+                self._check(results, h, J, **params)
+
+    def test_submit_qubo_ok_reply(self):
+        """Handle a normal query and response."""
+
+        qubo_msg_diff = dict(type="qubo")
+        qubo_answer_diff = {
+            'energies': 'AAAAAAAAAAA=',
+            'solutions': 'AA==',
+            'active_variables': 'AAAAAAQAAAA='
+        }
+
+        # each thread can have its instance of a session because
+        # the mocked responses are stateless
+        def create_mock_session(client):
+            session = mock.Mock()
+            session.post = lambda a, _: choose_reply(a, {
+                'problems/': '[%s]' % complete_no_answer_reply(
+                    '123', 'abc123')})
+            session.get = lambda a: choose_reply(a, {
+                'problems/123/': complete_reply(
+                    '123', 'abc123', answer=qubo_answer_diff, msg=qubo_msg_diff)})
+            return session
+
+        with mock.patch.object(Client, 'create_session', create_mock_session):
+            with Client('endpoint', 'token') as client:
+                solver = Solver(client, solver_data('abc123'))
+
+                qubo = {(0, 0): 4.0, (0, 4): -4, (4, 4): 4.0}
+                offset = -2.0
+                params = dict(num_reads=100)
+
+                results = solver.sample_qubo(qubo, offset, **params)
+
+                # make sure energies are correct in raw results
+                for energy, sample in zip(results.energies, results.samples):
+                    self.assertEqual(energy, evaluate_ising({}, qubo, sample, offset=offset))
+
+    @unittest.skipUnless(dimod, "dimod required for 'Solver.sample_bqm'")
+    def test_submit_bqm_qubo_ok_reply(self):
+        """Handle a normal query and response."""
+
+        qubo_msg_diff = dict(type="qubo")
+        qubo_answer_diff = {
+            'energies': 'AAAAAAAAAAA=',
+            'solutions': 'AA==',
+            'active_variables': 'AAAAAAQAAAA='
+        }
+
+        # each thread can have its instance of a session because
+        # the mocked responses are stateless
+        def create_mock_session(client):
+            session = mock.Mock()
+            session.post = lambda a, _: choose_reply(a, {
+                'problems/': '[%s]' % complete_no_answer_reply(
+                    '123', 'abc123')})
+            session.get = lambda a: choose_reply(a, {
+                'problems/123/': complete_reply(
+                    '123', 'abc123', answer=qubo_answer_diff, msg=qubo_msg_diff)})
+            return session
+
+        with mock.patch.object(Client, 'create_session', create_mock_session):
+            with Client('endpoint', 'token') as client:
+                solver = Solver(client, solver_data('abc123'))
+
+                qubo = {(0, 0): 4.0, (0, 4): -4, (4, 4): 4.0}
+                offset = -2.0
+                params = dict(num_reads=100)
+
+                results = solver.sample_qubo(qubo, offset, **params)
 
-                self._check(results, linear, quadratic, 100)
+                # make sure energies are correct in raw results
+                for energy, sample in zip(results.energies, results.samples):
+                    self.assertEqual(energy, evaluate_ising({}, qubo, sample, offset=offset))
 
     def test_submit_error_reply(self):
         """Handle an error on problem submission."""
@@ -257,7 +386,7 @@ class MockSubmission(_QueryTest):
                 solver = Solver(client, solver_data('abc123'))
 
                 linear, quadratic = test_problem(solver)
-                results = solver.sample_ising(linear, quadratic, num_reads=100)
+                results = solver.sample_ising(linear, quadratic)
 
                 with self.assertRaises(SolverFailureError):
                     results.samples
@@ -300,7 +429,7 @@ class MockSubmission(_QueryTest):
                 solver = Solver(client, solver_data('abc123'))
 
                 linear, quadratic = test_problem(solver)
-                results = solver.sample_ising(linear, quadratic, num_reads=100)
+                results = solver.sample_ising(linear, quadratic)
 
                 with self.assertRaises(CanceledFutureError):
                     results.samples
@@ -365,9 +494,10 @@ class MockSubmission(_QueryTest):
                 solver = Solver(client, solver_data('abc123'))
 
                 linear, quadratic = test_problem(solver)
-                results = solver.sample_ising(linear, quadratic, num_reads=100)
+                params = dict(num_reads=100)
+                results = solver.sample_ising(linear, quadratic, **params)
 
-                self._check(results, linear, quadratic, 100)
+                self._check(results, linear, quadratic, **params)
 
                 # test future has eta_min and eta_max parsed correctly
                 self.assertEqual(results.eta_min, eta_min)
@@ -393,10 +523,11 @@ class MockSubmission(_QueryTest):
                 solver = Solver(client, solver_data('abc123'))
 
                 linear, quadratic = test_problem(solver)
-                results = solver.sample_ising(linear, quadratic, num_reads=100)
+                params = dict(num_reads=100)
+                results = solver.sample_ising(linear, quadratic, **params)
 
                 with self.assertRaises(SolverFailureError):
-                    self._check(results, linear, quadratic, 100)
+                    self._check(results, linear, quadratic, **params)
 
     def test_submit_continue_then_ok_and_error_reply(self):
         """Handle polling for the status of multiple problems."""
@@ -451,13 +582,14 @@ class MockSubmission(_QueryTest):
                 solver = Solver(client, solver_data('abc123'))
 
                 linear, quadratic = test_problem(solver)
+                params = dict(num_reads=100)
 
-                results1 = solver.sample_ising(linear, quadratic, num_reads=100)
-                results2 = solver.sample_ising(linear, quadratic, num_reads=100)
+                results1 = solver.sample_ising(linear, quadratic, **params)
+                results2 = solver.sample_ising(linear, quadratic, **params)
 
                 with self.assertRaises(SolverFailureError):
-                    self._check(results1, linear, quadratic, 100)
-                self._check(results2, linear, quadratic, 100)
+                    self._check(results1, linear, quadratic, **params)
+                self._check(results2, linear, quadratic, **params)
 
     def test_exponential_backoff_polling(self):
         "After each poll, back-off should double"
@@ -799,3 +931,96 @@ class TestComputationID(unittest.TestCase):
 
                 # verify the id is now available
                 self.assertEqual(future.wait_id(), submission_id)
+
+
+@mock.patch('time.sleep', lambda *x: None)
+class TestOffsetHandling(_QueryTest):
+
+    def test_submit_offset_answer_includes_it(self):
+        """Handle a normal query with offset and response that includes it."""
+
+        # ising problem energy offset
+        offset = 3
+
+        # each thread can have its instance of a session because
+        # the mocked responses are stateless
+        def create_mock_session(client):
+            session = mock.Mock()
+            session.post = lambda a, _: choose_reply(a, {
+                'problems/': '[%s]' % complete_no_answer_reply(
+                    '123', 'abc123')})
+            session.get = lambda a: choose_reply(a, {
+                'problems/123/': complete_reply(
+                    '123', 'abc123', answer=dict(offset=offset))})
+            return session
+
+        with mock.patch.object(Client, 'create_session', create_mock_session):
+            with Client('endpoint', 'token') as client:
+                solver = Solver(client, solver_data('abc123'))
+
+                linear, quadratic = test_problem(solver)
+                params = dict(num_reads=100)
+                results = solver.sample_ising(linear, quadratic, offset, **params)
+
+                self._check(results, linear, quadratic, offset=offset, **params)
+
+    def test_submit_offset_answer_does_not_include_it(self):
+        """Handle a normal query with offset and response that doesn't include it."""
+
+        # ising problem energy offset
+        offset = 3
+
+        # each thread can have its instance of a session because
+        # the mocked responses are stateless
+        def create_mock_session(client):
+            session = mock.Mock()
+            session.post = lambda a, _: choose_reply(a, {
+                'problems/': '[%s]' % complete_no_answer_reply(
+                    '123', 'abc123')})
+            session.get = lambda a: choose_reply(a, {
+                'problems/123/': complete_reply(
+                    '123', 'abc123')})
+            return session
+
+        with mock.patch.object(Client, 'create_session', create_mock_session):
+            with Client('endpoint', 'token') as client:
+                solver = Solver(client, solver_data('abc123'))
+
+                linear, quadratic = test_problem(solver)
+                params = dict(num_reads=100)
+                results = solver.sample_ising(linear, quadratic, offset, **params)
+
+                # although SAPI response doesn't include offset, Future should patch it on-the-fly
+                self._check(results, linear, quadratic, offset=offset, **params)
+
+    def test_submit_offset_wrong_offset_in_answer(self):
+        """Energy levels don't match because offset in answer is respected, even if wrong"""
+
+        # ising problem energy offset
+        offset = 3
+        answer_offset = 2 * offset      # make it wrong
+
+        # each thread can have its instance of a session because
+        # the mocked responses are stateless
+        def create_mock_session(client):
+            session = mock.Mock()
+            session.post = lambda a, _: choose_reply(a, {
+                'problems/': '[%s]' % complete_no_answer_reply(
+                    '123', 'abc123')})
+            session.get = lambda a: choose_reply(a, {
+                'problems/123/': complete_reply(
+                    '123', 'abc123', answer=dict(offset=answer_offset))})
+            return session
+
+        with mock.patch.object(Client, 'create_session', create_mock_session):
+            with Client('endpoint', 'token') as client:
+                solver = Solver(client, solver_data('abc123'))
+
+                linear, quadratic = test_problem(solver)
+                params = dict(num_reads=100)
+                results = solver.sample_ising(linear, quadratic, offset, **params)
+
+                # since SAPI response includes offset, Future shouldn't patch it;
+                # but because the offset in answer is wrong, energies are off
+                with self.assertRaises(AssertionError):
+                    self._check(results, linear, quadratic, offset=offset, **params)
diff --git a/tests/test_solver.py b/tests/test_solver.py
index caf4555..8076db1 100644
--- a/tests/test_solver.py
+++ b/tests/test_solver.py
@@ -93,15 +93,19 @@ class PropertyLoading(unittest.TestCase):
 
 
 class _QueryTest(unittest.TestCase):
-    def _submit_and_check(self, solver, linear, quad, **param):
-        results = solver.sample_ising(linear, quad, num_reads=100, **param)
+    def _submit_and_check(self, solver, linear, quad, **kwargs):
+        results = solver.sample_ising(linear, quad, num_reads=100, **kwargs)
 
         # Did we get the right number of samples?
         self.assertEqual(100, sum(results.occurrences))
 
+        # offset is optional
+        offset = kwargs.get('offset', 0)
+
         # Make sure the number of occurrences and energies are all correct
         for energy, state in zip(results.energies, results.samples):
-            self.assertAlmostEqual(energy, evaluate_ising(linear, quad, state))
+            self.assertAlmostEqual(
+                energy, evaluate_ising(linear, quad, state, offset=offset))
 
         return results
 
@@ -161,6 +165,14 @@ class Submission(_QueryTest):
             linear, quad = generate_random_ising_problem(solver)
             self._submit_and_check(solver, linear, quad)
 
+    def test_submit_full_ising_problem_with_offset(self):
+        """Submit a problem with all supported coefficients set, including energy offset."""
+
+        with Client(**config) as client:
+            solver = client.get_solver()
+            linear, quad = generate_random_ising_problem(solver)
+            self._submit_and_check(solver, linear, quad, offset=3)
+
     def test_submit_list_problem(self):
         """Submit a problem using a list for the linear terms."""
 
@@ -195,9 +207,10 @@ class Submission(_QueryTest):
             solver = client.get_solver()
 
             linear, quad = generate_random_ising_problem(solver)
+            offset = 3
 
             # sample ising as bqm
-            bqm = dimod.BinaryQuadraticModel.from_ising(linear, quad)
+            bqm = dimod.BinaryQuadraticModel.from_ising(linear, quad, offset)
             response = solver.sample_bqm(bqm, num_reads=100)
             sampleset = response.sampleset
 
@@ -216,9 +229,10 @@ class Submission(_QueryTest):
             solver = client.get_solver()
 
             _, quad = generate_random_ising_problem(solver)
+            offset = 5
 
             # sample qubo as bqm
-            bqm = dimod.BinaryQuadraticModel.from_qubo(quad)
+            bqm = dimod.BinaryQuadraticModel.from_qubo(quad, offset)
             response = solver.sample_bqm(bqm, num_reads=100)
             sampleset = response.sampleset
 
@@ -229,6 +243,46 @@ class Submission(_QueryTest):
             numpy.testing.assert_array_almost_equal(
                 bqm.energies(sampleset), sampleset.record.energy)
 
+    @unittest.skipUnless(dimod, "dimod required for 'Solver.sample_bqm'")
+    def test_all_sampling_methods_are_consistent(self):
+        """Submit Ising/QUBO/BQM and verify the results are consistent."""
+
+        with Client(**config) as client:
+            solver = client.get_solver()
+
+            # simple problem with a large energy gap
+            # (ground state: [-1, -1] @ -2.0)
+            h = {0: 1, 4: 1}
+            J = {(0, 4): -1}
+            offset = 1.0
+
+            bqm = dimod.BinaryQuadraticModel.from_ising(h, J, offset)
+            params = dict(num_reads=100)
+
+            # sample_ising
+            response = solver.sample_ising(h, J, offset, **params)
+            ss_ising = response.sampleset
+
+            # sample_qubo
+            qubo = bqm.to_qubo()
+            response = solver.sample_qubo(*qubo, **params)
+            ss_qubo = response.sampleset
+
+            # sample_bqm
+            response = solver.sample_bqm(bqm, **params)
+            ss_bqm = response.sampleset
+
+            # this simple problem should always be solved to optimality
+            self.assertTrue(len(ss_ising) == len(ss_qubo) == len(ss_bqm) == 1)
+
+            # make sure all energies are correct
+            numpy.testing.assert_array_almost_equal(
+                bqm.energies(ss_ising), ss_ising.record.energy)
+            numpy.testing.assert_array_almost_equal(
+                ss_ising.record.energy, ss_qubo.record.energy)
+            numpy.testing.assert_array_almost_equal(
+                ss_qubo.record.energy, ss_bqm.record.energy)
+
     def test_reverse_annealing(self):
         with Client(**config) as client:
             solver = client.get_solver()