Repository: dwavesystems/dwave-cloud-client
Pull request #617 (2024-05-10 17:24:53)

Add `binary-ref` answer support

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems) Report
Attention: Patch coverage is `87.50000%` with `7 lines` in your changes are missing coverage. Please review.
> Project coverage is 88.43%. Comparing base [(`910db86`)](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/commit/910db869e06196b23b2e703fda923a1f10c869a5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems) to head [(`a0bb10c`)](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems).

| [Files](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems) | Patch % | Lines |
|---|---|---|
| [dwave/cloud/coders.py](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?src=pr&el=tree&filepath=dwave%2Fcloud%2Fcoders.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems#diff-ZHdhdmUvY2xvdWQvY29kZXJzLnB5) | 69.23% | [4 Missing :warning: ](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems) |
| [dwave/cloud/client/base.py](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?src=pr&el=tree&filepath=dwave%2Fcloud%2Fclient%2Fbase.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems#diff-ZHdhdmUvY2xvdWQvY2xpZW50L2Jhc2UucHk=) | 91.30% | [2 Missing :warning: ](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems) |
| [dwave/cloud/solver.py](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?src=pr&el=tree&filepath=dwave%2Fcloud%2Fsolver.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems#diff-ZHdhdmUvY2xvdWQvc29sdmVyLnB5) | 88.88% | [1 Missing :warning: ](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #617      +/-   ##
==========================================
- Coverage   88.49%   88.43%   -0.06%     
==========================================
  Files          33       33              
  Lines        4380     4428      +48     
==========================================
+ Hits         3876     3916      +40     
- Misses        504      512       +8     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/dwavesystems/dwave-cloud-client/pull/617?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dwavesystems).


Patch:
diff --git a/dwave/cloud/api/constants.py b/dwave/cloud/api/constants.py
index 50ca8ca..5fec11d 100644
--- a/dwave/cloud/api/constants.py
+++ b/dwave/cloud/api/constants.py
@@ -58,6 +58,11 @@ class ProblemEncodingFormat(str, enum.Enum):
 class AnswerEncodingFormat(str, enum.Enum):
     QP = "qp"
     BQ = "bq"   # dimod (de-)serialization-based
+    BINARY_REF = "binary-ref"
+
+
+class BinaryRefAuthMethod(str, enum.Enum):
+    SAPI_TOKEN = "sapi-token"
 
 
 class ProblemType(str, enum.Enum):
diff --git a/dwave/cloud/api/models.py b/dwave/cloud/api/models.py
index 3a7b13d..73e1906 100644
--- a/dwave/cloud/api/models.py
+++ b/dwave/cloud/api/models.py
@@ -16,7 +16,7 @@ from datetime import datetime
 from typing import List, Union, Optional, Dict, Any
 from typing_extensions import Annotated     # backport for py37, py38
 
-from pydantic import BaseModel, RootModel
+from pydantic import BaseModel, RootModel, Field
 from pydantic.functional_validators import AfterValidator
 
 from dwave.cloud.api import constants
@@ -63,8 +63,18 @@ class UnstructuredProblemAnswer(BaseModel):
     data: dict
 
 
+class UnstructuredProblemAnswerBinaryRef(BaseModel):
+    format: constants.AnswerEncodingFormat = constants.AnswerEncodingFormat.BINARY_REF
+    auth_method: constants.BinaryRefAuthMethod = constants.BinaryRefAuthMethod.SAPI_TOKEN
+    url: str
+    timing: dict
+    shape: dict
+
+
 class ProblemAnswer(RootModel):
-    root: Union[StructuredProblemAnswer, UnstructuredProblemAnswer]
+    root: Union[StructuredProblemAnswer,
+                UnstructuredProblemAnswer,
+                UnstructuredProblemAnswerBinaryRef]
 
     def __getattr__(self, item):
         return getattr(self.root, item)
diff --git a/dwave/cloud/api/resources.py b/dwave/cloud/api/resources.py
index 7b63aa5..af9bf0b 100644
--- a/dwave/cloud/api/resources.py
+++ b/dwave/cloud/api/resources.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import io
 import json
 from typing import List, Union, Optional, Callable, get_type_hints
 from functools import wraps
@@ -217,6 +218,23 @@ class Problems(ResourceBase):
         answer = response.json()['answer']
         return models.ProblemAnswer.model_validate(answer)
 
+    @accepts(media_type='application/octet-stream')
+    def get_answer_data(self, answer: models.UnstructuredProblemAnswerBinaryRef,
+                        output: Optional[io.IOBase] = None) -> io.IOBase:
+        """Retrieve binary-ref answer data."""
+        if answer.auth_method != constants.BinaryRefAuthMethod.SAPI_TOKEN:
+            raise ValueError(f"Authentication method {answer.auth_method!r} not supported.")
+
+        if output is None:
+            output = io.BytesIO()
+
+        for chunk in self.session.get(answer.url, stream=True).iter_content(chunk_size=8192):
+            output.write(chunk)
+
+        output.seek(0)
+
+        return output
+
     @accepts(media_type='application/vnd.dwave.sapi.problem-message+json', version='>=2.1,<3')
     def get_problem_messages(self, problem_id: str) -> List[dict]:
         """Retrieve list of problem messages."""
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index b1457fe..c07a77f 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -34,6 +34,7 @@ Examples:
 
 """
 
+import io
 import re
 import time
 import copy
@@ -50,10 +51,10 @@ import codecs
 import concurrent.futures
 
 from itertools import chain, zip_longest
-from functools import partial, wraps, cached_property
+from functools import partial, wraps
 from collections import namedtuple
 from concurrent.futures import ThreadPoolExecutor
-from typing import Dict
+from typing import Dict, Optional, Union
 
 import requests
 import urllib3
@@ -350,6 +351,9 @@ class Client(object):
     _UPLOAD_REQUEST_RETRIES = 2
     _UPLOAD_RETRIES_BACKOFF = lambda retry: 2 ** retry
 
+    # Binary-ref answer download parameters
+    _DOWNLOAD_ANSWER_THREAD_COUNT = 2
+
     @staticmethod
     def _legacy_config_property_proxy(self, legacy_property, config_path):
         warnings.warn(
@@ -536,6 +540,10 @@ class Client(object):
         self._encode_problem_executor = \
             ThreadPoolExecutor(self._ENCODE_PROBLEM_THREAD_COUNT)
 
+        # Setup binary-ref answer download executors
+        self._download_answer_executor = \
+            ThreadPoolExecutor(self._DOWNLOAD_ANSWER_THREAD_COUNT)
+
     def create_session(self):
         """Create a new requests session based on client's (self) params.
 
@@ -613,6 +621,9 @@ class Client(object):
         logger.debug("Shutting down problem encoder executor")
         self._encode_problem_executor.shutdown()
 
+        logger.debug("Shutting down answer download executor")
+        self._download_answer_executor.shutdown()
+
         # Send kill-task to all worker threads
         # Note: threads can't be 'killed' in Python, they have to die by
         # natural causes
@@ -1602,6 +1613,45 @@ class Client(object):
         finally:
             session.close()
 
+    def _download_answer_binary_ref(self, *, auth_method: str, url: str,
+                                    output: Optional[io.IOBase] = None) -> concurrent.futures.Future:
+        """Initiate binary-ref answer download, returning the binary data in
+        :class:`~concurrent.futures.Future`.
+
+        Args:
+            auth_method:
+                Authentication method used to access data at ``url``.
+
+            url:
+                Answer binary data.
+
+        Returns:
+            :class:`concurrent.futures.Future`[bytes | io.IOBase]:
+                Answer data in a Future.
+        """
+        return self._download_answer_executor.submit(
+            self._download_answer_worker, auth_method=auth_method, url=url, output=output)
+
+    def _download_answer_worker(self, *, auth_method: str, url: str,
+                                output: Optional[io.IOBase] = None) -> io.IOBase:
+        if auth_method != api.constants.BinaryRefAuthMethod.SAPI_TOKEN:
+            raise ValueError(f"Authentication method {auth_method!r} not supported.")
+        if output is None:
+            output = io.BytesIO()
+
+        logger.debug("Downloading binary-ref answer from %r using %r method.",
+                     url, auth_method)
+
+        with self.create_session() as session:
+            size = 0
+            for chunk in session.get(url, stream=True).iter_content(chunk_size=8192):
+                size += output.write(chunk)
+            output.seek(0)
+
+        logger.debug("Answer data downloaded from %r. Written %r bytes.", url, size)
+
+        return output
+
     def upload_problem_encoded(self, problem, problem_id=None):
         """Initiate multipart problem upload, returning the Problem ID in a
         :class:`~concurrent.futures.Future`.
diff --git a/dwave/cloud/coders.py b/dwave/cloud/coders.py
index 489bd53..d4f0d96 100644
--- a/dwave/cloud/coders.py
+++ b/dwave/cloud/coders.py
@@ -14,13 +14,14 @@
 
 import struct
 import base64
+from typing import Callable
 
 from dwave.cloud.utils import uniform_get, active_qubits
 
 __all__ = [
     'encode_problem_as_qp', 'decode_qp', 'decode_qp_numpy',
     'encode_problem_as_bq', 'decode_bq',
-    'encode_problem_as_ref',
+    'encode_problem_as_ref', 'decode_binary_ref',
     'bqm_as_file',
 ]
 
@@ -345,7 +346,10 @@ def decode_bq(msg):
                            "Re-install the library with 'bqm' support.")
 
     answer = msg['answer']
-    assert answer['format'] == 'bq'
+    if answer['format'] != 'bq':
+        raise ValueError(f"Unsupported answer format: {answer['format']}")
+    if 'data' not in answer:
+        raise ValueError("Incomplete answer")
 
     result = {}
 
@@ -358,6 +362,24 @@ def decode_bq(msg):
     return result
 
 
+def decode_binary_ref(msg: dict, ref_resolver: Callable) -> dict:
+    """Decode binary-ref answer."""
+
+    answer = msg['answer']
+    if answer['format'] != 'binary-ref':
+        raise ValueError(f"Unsupported answer format: {answer['format']}")
+    if 'auth_method' not in answer or 'url' not in answer:
+        raise ValueError("Incomplete binary-ref answer")
+
+    result = {
+        'problem_type': msg['type'],
+        'timing': answer.get('timing', {}),
+        'shape': answer.get('shape', {}),
+        'answer': ref_resolver(auth_method=answer['auth_method'], url=answer['url'])
+    }
+    return result
+
+
 def bqm_as_file(bqm, **options):
     """Encode in-memory BQM as DIMODBQM binary file format.
 
diff --git a/dwave/cloud/computation.py b/dwave/cloud/computation.py
index 3f296dd..594c330 100644
--- a/dwave/cloud/computation.py
+++ b/dwave/cloud/computation.py
@@ -26,14 +26,15 @@ waiting for and retrieving results, cancelling enqueued jobs, etc.
 Some :class:`Future` methods are blocking.
 """
 
+import io
 import time
 import threading
 import functools
 import weakref
 
-from operator import itemgetter
-from dateutil.parser import parse
 from concurrent.futures import TimeoutError
+from dateutil.parser import parse
+from operator import itemgetter
 
 from dwave.cloud.utils import (
     utcnow, datetime_to_timestamp, aliasdict, deprecated)
@@ -151,6 +152,9 @@ class Future(object):
         self._results_ready_event = threading.Event()
         self._other_events = []
 
+        # set when answer data is downloaded
+        self._answer_data_ready_event = threading.Event()
+
         # current poll back-off interval, in seconds
         self._poll_backoff = None
 
@@ -160,6 +164,9 @@ class Future(object):
         # weakref to resolved (already constructed) sampleset
         self._sampleset = None
 
+        # file buffer for binary-ref answer data
+        self._answer_data = io.BytesIO()
+
     # make Future ordered
 
     def __lt__(self, other):
@@ -873,6 +880,10 @@ class Future(object):
         """
         return self.result()['problem_type']
 
+    @property
+    def answer_data(self):
+        return self.result().get('answer')
+
     def __getitem__(self, key):
         """Provide a simple results item getter. Blocks if future is unresolved.
 
@@ -902,6 +913,10 @@ class Future(object):
             self._decode()
             self._alias_result()
 
+            # signal answer data downloaded
+            if 'answer' in self._result:
+                self._answer_data_ready_event.set()
+
             # create a direct reference to sampleset if already available in response
             # NB: in this case we don't actually need a weakref (strong ref is fine),
             # but we use it for consistency
@@ -938,7 +953,7 @@ class Future(object):
         """Decode answer data from the response."""
         start = time.time()
         self._patch_offset()
-        self._result = self.solver.decode_response(self._message)
+        self._result = self.solver.decode_response(self._message, answer_data=self._answer_data)
         self.parse_time = time.time() - start
         return self._result
 
diff --git a/dwave/cloud/solver.py b/dwave/cloud/solver.py
index 6541f25..55eb31e 100644
--- a/dwave/cloud/solver.py
+++ b/dwave/cloud/solver.py
@@ -28,17 +28,19 @@ You can list all solvers available to a :class:`~dwave.cloud.client.Client` with
 
 """
 
+import io
 import copy
 import json
 import logging
 import typing
 from collections.abc import Mapping
+from functools import partial
 
 from dwave.cloud.exceptions import (
     InvalidAPIResponseError, SolverPropertyMissingError,
     UnsupportedSolverError, ProblemStructureError)
 from dwave.cloud.coders import (
-    encode_problem_as_qp, encode_problem_as_ref,
+    encode_problem_as_qp, encode_problem_as_ref, decode_binary_ref,
     decode_qp_numpy, decode_qp, decode_bq, bqm_as_file)
 from dwave.cloud.utils import reformat_qubo_as_ising, NumpyEncoder
 from dwave.cloud.computation import Future
@@ -182,18 +184,26 @@ class BaseSolver(object):
         else:
             return decode_qp(msg)
 
-    def decode_response(self, msg):
+    def _download_binary_ref(self, *, auth_method: str, url: str,
+                             output: typing.Optional[io.IOBase] = None) -> typing.Union[bytes, io.IOBase]:
+        return self.client._download_answer_binary_ref(
+            auth_method=auth_method, url=url, output=output).result()
+
+    def decode_response(self, msg, answer_data: typing.Optional[io.IOBase] = None):
         if msg['type'] not in self._handled_problem_types:
             raise ValueError('Unknown problem type received.')
 
         fmt = msg.get('answer', {}).get('format')
         if fmt not in self._handled_encoding_formats:
-            raise ValueError('Unhandled answer encoding format received.')
+            raise ValueError(f'Unhandled answer encoding format received: {fmt!r}.')
 
         if fmt == 'qp':
             return self._decode_qp(msg)
         elif fmt == 'bq':
             return decode_bq(msg)
+        elif fmt == 'binary-ref':
+            ref_resolver = partial(self._download_binary_ref, output=answer_data)
+            return decode_binary_ref(msg, ref_resolver=ref_resolver)
         else:
             raise ValueError("Don't know how to decode %r answer format" % fmt)
 
diff --git a/releasenotes/notes/add-binary-ref-answer-support-7b1a843980a4fd7e.yaml b/releasenotes/notes/add-binary-ref-answer-support-7b1a843980a4fd7e.yaml
new file mode 100644
index 0000000..e8c5409
--- /dev/null
+++ b/releasenotes/notes/add-binary-ref-answer-support-7b1a843980a4fd7e.yaml
@@ -0,0 +1,15 @@
+---
+features:
+  - |
+    Add support for ``binary-ref`` answer encoding format, where answer data
+    is not immediately present in the answer, but on an URI referenced in the
+    answer response.
+  - |
+    Add ``dwave.cloud.computation.Future.answer_data`` property to hold the
+    answer data of a resolved future. A call to ``.answer_data`` will block
+    until computation future is resolved.
+  - |
+    Add ``dwave.cloud.api.models.UnstructuredProblemAnswerBinaryRef`` model, a
+    subclass of ``ProblemAnswer`` for ``binary-ref`` answers, and a method
+    for downloading answer data (resolving binary-ref),
+    ``dwave.cloud.api.resources.Problems.get_answer_data``.


Test patch:
diff --git a/tests/api/mocks.py b/tests/api/mocks.py
index f384ded..ffba531 100644
--- a/tests/api/mocks.py
+++ b/tests/api/mocks.py
@@ -12,8 +12,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import io
 import uuid
-from typing import Union, Tuple
+from typing import Any, Optional, Tuple, Union
 
 from dwave.cloud.coders import encode_problem_as_qp, encode_problem_as_ref
 from dwave.cloud.utils import utcrel, generate_const_ising_problem
@@ -74,6 +75,12 @@ class SapiMockResponses:
             "answer": self.answer.copy()
         }
 
+    def problem_answer_data(self, problem: Optional[Any] = None) -> Optional[io.IOBase]:
+        return None
+
+    def problem_answer_data_uri(self, problem: Optional[Any] = None) -> Optional[str]:
+        return None
+
     def problem_info(self, **kwargs) -> dict:
         response = {
             "id": self.problem_id,
@@ -238,8 +245,35 @@ class UnstructuredSapiMockResponses(SapiMockResponses):
         kwargs.setdefault('solver', solver)
         kwargs.setdefault('problem', bqm)
         kwargs.setdefault('problem_type', 'bqm')
-        kwargs.setdefault('answer', self._problem_answer(sampleset))
+        kwargs.setdefault('answer', self._problem_answer(sampleset=sampleset))
         super().__init__(**kwargs)
 
         # unstructured problem specific
         self.problem_data_id = str(uuid.uuid4())    # mock `self.problem` uploaded
+
+
+class UnstructuredSapiMockResponsesWithBinaryRefAnswer(UnstructuredSapiMockResponses):
+
+    def _problem_answer(self, **kwargs) -> dict:
+        answer = {
+            "format": "binary-ref",
+            "auth_method": "sapi-token",
+            "url": self.problem_answer_data_uri(kwargs.get('problem')),
+            "timing": {
+                "qpu_access_time": 100,
+                "run_time": 1000
+            },
+            "shape": {}
+        }
+        answer.update(**kwargs)
+        return answer
+
+    def problem_answer_data(self, problem: Optional[Any] = None) -> Optional[io.IOBase]:
+        return io.BytesIO(b'123')
+
+    def problem_answer_data_uri(self, problem: Optional[Any] = None) -> str:
+        return "http://127.0.0.1/answer/data"
+
+    def __init__(self, **kwargs):
+        kwargs.setdefault('answer', self._problem_answer(**kwargs))
+        super().__init__(**kwargs)
diff --git a/tests/api/resources/test_problems.py b/tests/api/resources/test_problems.py
index ef60281..c22b2a8 100644
--- a/tests/api/resources/test_problems.py
+++ b/tests/api/resources/test_problems.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import abc
+import io
 import json
 import uuid
 import unittest
@@ -33,12 +34,14 @@ except ImportError:
 from dwave.cloud.client.base import Client
 from dwave.cloud.coders import (
     encode_problem_as_qp, decode_qp,
-    encode_problem_as_ref, decode_bq)
+    encode_problem_as_ref, decode_bq, decode_binary_ref)
 from dwave.cloud.api.resources import Problems
 from dwave.cloud.api import exceptions, models, constants
 
 from tests import config
-from tests.api.mocks import StructuredSapiMockResponses, UnstructuredSapiMockResponses
+from tests.api.mocks import (
+    StructuredSapiMockResponses, UnstructuredSapiMockResponses,
+    UnstructuredSapiMockResponsesWithBinaryRefAnswer)
 
 
 class AssertionSatisfied(Exception):
@@ -208,6 +211,19 @@ class ProblemResourcesBaseTests(abc.ABC):
         self.assertIsInstance(answer, models.ProblemAnswer)
         self.verify_problem_answer(answer)
 
+    def test_get_problem_answer_data(self):
+        """Problem answer data retrieved from a binary-ref."""
+
+        answer = self.api.get_problem_answer(problem_id=self.problem_id)
+
+        if answer.format != constants.AnswerEncodingFormat.BINARY_REF:
+            self.skipTest("no binary-ref answer data to download")
+
+        data = self.api.get_answer_data(answer)
+
+        self.assertIsInstance(data, io.IOBase)
+        self.assertEqual(data.read(), self.p1.problem_answer_data().read())
+
     def test_get_problem_messages(self):
         """Problem messages retrieved by problem id."""
 
@@ -352,10 +368,21 @@ class StructuredProblemTestsMixin:
 class UnstructuredProblemTestsMixin:
 
     def verify_problem_answer(self, answer: models.ProblemAnswer):
-        ans = decode_bq(msg=dict(answer=answer.model_dump(), type=self.problem_type.value))
-        ss = ans['sampleset']
-        self.assertEqual(ss.variables, self.bqm.variables)
-        dimod.testing.assert_sampleset_energies(ss, self.bqm)
+        if answer.format is constants.AnswerEncodingFormat.BQ:
+            ans = decode_bq(
+                msg=dict(answer=answer.model_dump(), type=self.problem_type.value))
+            ss = ans['sampleset']
+            self.assertEqual(ss.variables, self.bqm.variables)
+            dimod.testing.assert_sampleset_energies(ss, self.bqm)
+        elif answer.format is constants.AnswerEncodingFormat.BINARY_REF:
+            mock_answer_data = b'123456'
+            ans = decode_binary_ref(
+                msg=dict(answer=answer.model_dump(by_alias=True), type=self.problem_type.value),
+                ref_resolver=lambda **kw: mock_answer_data)
+            answer_data = ans['answer']
+            self.assertEqual(answer_data, mock_answer_data)
+        else:
+            raise ValueError(f"Unsupported answer encoding format: {answer.format!r}")
 
 
 class ProblemResourcesMockerMixin:
@@ -383,6 +410,8 @@ class ProblemResourcesMockerMixin:
         p1_params = self.params
         p1_metadata = self.p1.problem_metadata(submitted_by=self.token)
         p1_answer = self.p1.problem_answer()
+        p1_answer_data = self.p1.problem_answer_data()
+        p1_answer_data_uri = self.p1.problem_answer_data_uri()
         p1_messages = self.p1.problem_messages()
         p1_info = self.p1.problem_info(params=p1_params, metadata=p1_metadata)
 
@@ -426,6 +455,10 @@ class ProblemResourcesMockerMixin:
             url(f'problems/{p1_id}/answer'),
             json=p1_answer,
             request_headers=headers)
+        self.mocker.get(
+            p1_answer_data_uri,
+            body=p1_answer_data,
+            request_headers=headers)
         self.mocker.get(
             url(f'problems/{p1_id}/messages'),
             json=p1_messages,
@@ -598,7 +631,7 @@ class TestMockProblemsUnstructured(UnstructuredProblemTestsMixin,
                                    ProblemResourcesBaseTests,
                                    unittest.TestCase):
     """Verify `dwave.cloud.api.resources.Problems` handle unstructured problems
-    using mocked SAPI responses.
+    with answers in the `bq` format, using mocked SAPI responses.
     """
 
     @classmethod
@@ -619,6 +652,30 @@ class TestMockProblemsUnstructured(UnstructuredProblemTestsMixin,
         cls.solver_id = cls.p1.solver.id
 
 
+@unittest.skipUnless(dimod, "dimod not installed")
+class TestMockProblemsUnstructuredWithBinaryRefAsnwer(TestMockProblemsUnstructured):
+    """Verify `dwave.cloud.api.resources.Problems` handle unstructured problems
+    with answers in the `binary-ref` format, using mocked SAPI responses.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        """Configure attributes required (used) by ProblemResourcesBaseTests."""
+
+        cls.p1 = UnstructuredSapiMockResponsesWithBinaryRefAnswer()
+        cls.p2 = UnstructuredSapiMockResponsesWithBinaryRefAnswer()
+        cls.p3 = UnstructuredSapiMockResponsesWithBinaryRefAnswer()
+
+        cls.bqm = cls.p1.problem
+        cls.problem_data = models.ProblemData.model_validate(cls.p1.problem_data())
+        cls.problem_type = constants.ProblemType(cls.p1.problem_type)
+        cls.problem_id = cls.p1.problem_id
+        cls.problem_label = cls.p1.problem_label
+
+        cls.params = dict(time_limit=3)
+        cls.solver_id = cls.p1.solver.id
+
+
 @unittest.skipUnless(config, "SAPI access not configured")
 class TestCloudProblemsStructured(StructuredProblemTestsMixin,
                                   ProblemResourcesBaseTests,
diff --git a/tests/test_mock_unstructured_submission.py b/tests/test_mock_unstructured_submission.py
index 94c80fc..b9d2bc5 100644
--- a/tests/test_mock_unstructured_submission.py
+++ b/tests/test_mock_unstructured_submission.py
@@ -43,7 +43,7 @@ def unstructured_solver_data(problem_type='bqm'):
         "description": "A test unstructured solver"
     }
 
-def complete_reply(sampleset, id_="problem-id", type_='bqm', label=None):
+def complete_reply_bq(sampleset, id_="problem-id", type_='bqm', label=None):
     """Reply with the sampleset as a solution."""
 
     return json.dumps([{
@@ -60,6 +60,30 @@ def complete_reply(sampleset, id_="problem-id", type_='bqm', label=None):
         "label": label
     }])
 
+def complete_reply_binary_ref(answer_data_uri, id_="problem-id", type_='cqm', label=None):
+    """Reply with `answer_data_uri` in binary-ref."""
+
+    return json.dumps([{
+        "status": "COMPLETED",
+        "solver": "solver-name",
+        "solved_on": "2019-07-31T12:34:56Z",
+        "submitted_on": "2019-07-31T12:34:56Z",
+        "answer": {
+            "format": "binary-ref",
+            "auth_method": "sapi-token",
+            "url": answer_data_uri,
+            "timing": {
+                "qpu_access_time": 1
+            }
+        },
+        "type": type_,
+        "id": id_,
+        "label": label
+    }])
+
+def answer_data_reply(data):
+    return data
+
 def choose_reply(path, replies):
     """Choose the right response based on the path and make a mock response."""
 
@@ -102,7 +126,7 @@ class TestUnstructuredSolver(unittest.TestCase):
                 ss = dimod.ExactSolver().sample(bqm)
                 ss.info.update(problem_id=mock_problem_id)
                 session.post = lambda path, _: choose_reply(
-                    path, {'problems/': complete_reply(ss, id_=mock_problem_id)})
+                    path, {'problems/': complete_reply_bq(ss, id_=mock_problem_id)})
 
                 fut = solver.sample_bqm(bqm)
                 numpy.testing.assert_array_equal(fut.sampleset, ss)
@@ -122,7 +146,7 @@ class TestUnstructuredSolver(unittest.TestCase):
                 ss = dimod.ExactSolver().sample_ising(lin, quad)
                 ss.info.update(problem_id=mock_problem_id)
                 session.post = lambda path, _: choose_reply(
-                    path, {'problems/': complete_reply(ss, id_=mock_problem_id)})
+                    path, {'problems/': complete_reply_bq(ss, id_=mock_problem_id)})
 
                 fut = solver.sample_ising(lin, quad)
                 numpy.testing.assert_array_equal(fut.sampleset, ss)
@@ -135,7 +159,7 @@ class TestUnstructuredSolver(unittest.TestCase):
                 ss = dimod.ExactSolver().sample_qubo(qubo)
                 ss.info.update(problem_id=mock_problem_id)
                 session.post = lambda path, _: choose_reply(
-                    path, {'problems/': complete_reply(ss, id_=mock_problem_id)})
+                    path, {'problems/': complete_reply_bq(ss, id_=mock_problem_id)})
 
                 fut = solver.sample_qubo(qubo)
                 numpy.testing.assert_array_equal(fut.sampleset, ss)
@@ -182,7 +206,7 @@ class TestUnstructuredSolver(unittest.TestCase):
                 ss = dimod.ExactSolver().sample(dimod.BQM.empty('SPIN'))
                 ss.info.update(problem_id=mock_problem_id)
                 session.post = lambda path, _: choose_reply(
-                    path, {'problems/': complete_reply(ss, id_=mock_problem_id, type_=problem_type)})
+                    path, {'problems/': complete_reply_bq(ss, id_=mock_problem_id, type_=problem_type)})
 
                 # verify decoding works
                 fut = solver.sample_cqm(cqm)
@@ -225,7 +249,7 @@ class TestUnstructuredSolver(unittest.TestCase):
                 ss = dimod.ExactSolver().sample(dimod.BQM.empty('SPIN'))
                 ss.info.update(problem_id=mock_problem_id)
                 session.post = lambda path, _: choose_reply(
-                    path, {'problems/': complete_reply(ss, id_=mock_problem_id, type_=problem_type)})
+                    path, {'problems/': complete_reply_bq(ss, id_=mock_problem_id, type_=problem_type)})
 
                 # verify decoding works
                 fut = solver.sample_dqm(dqm)
@@ -255,7 +279,7 @@ class TestUnstructuredSolver(unittest.TestCase):
                     # direct bqm sampling
                     ss = dimod.ExactSolver().sample(bqm)
                     session.post = lambda path, _: choose_reply(
-                        path, {'problems/': complete_reply(ss)})
+                        path, {'problems/': complete_reply_bq(ss)})
 
                     fut = solver.sample_bqm(bqm)
 
@@ -386,7 +410,7 @@ class TestProblemLabel(unittest.TestCase):
                 # construct mock response
                 ss = dimod.ExactSolver().sample(bqm)
                 session.post = lambda path, _: choose_reply(
-                    path, {'problems/': complete_reply(ss, id_=mock_problem_id, label=label)})
+                    path, {'problems/': complete_reply_bq(ss, id_=mock_problem_id, label=label)})
 
                 # sample and verify label
                 fut = solver.sample_bqm(bqm, label=label)
@@ -400,6 +424,55 @@ class TestProblemLabel(unittest.TestCase):
                 self.assertIsInstance(fut._sampleset(), dimod.SampleSet)
 
 
+class TestAnswerDownloadFromBinaryRef(unittest.TestCase):
+
+    def test_binary_ref_answer_download(self):
+        # mock problem
+        cqm = mock.Mock()
+        cqm.to_file.return_value = io.BytesIO(b'123')
+
+        mock_answer_data = b'abc'
+
+        problem_type = 'cqm'
+
+        # use a global mocked session, so we can modify it on the fly
+        session = mock.Mock()
+        setattr(session, '__enter__', mock.Mock())
+        setattr(session, '__exit__', mock.Mock())
+        session.__enter__.return_value = session
+        session.__exit__.return_value = None
+        session.get.return_value.iter_content.return_value = iter([mock_answer_data])
+
+        # upload is now part of submit, so we need to mock it
+        mock_problem_id = 'mock-problem-id'
+        def mock_upload(self, cqm_file):
+            cqm_file.close()
+            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 _: session,
+                                 upload_problem_encoded=mock_upload):
+            with Client(endpoint='endpoint', token='token') as client:
+                solver = CQMSolver(client, unstructured_solver_data(problem_type=problem_type))
+
+                # solver has to support binary-ref
+                solver._handled_encoding_formats.add('binary-ref')
+
+                # use mock answer data
+                answer_url = f'/problems/{mock_problem_id}/answer/data/'
+                session.post = lambda path, _: choose_reply(
+                    path, {
+                        'problems/': complete_reply_binary_ref(answer_url, id_=mock_problem_id, type_=problem_type),
+                        answer_url: answer_data_reply(mock_answer_data),
+                    })
+
+                # verify decoding works
+                fut = solver.sample_cqm(cqm)
+                self.assertIsInstance(fut.answer_data, io.IOBase)
+                self.assertEqual(fut.answer_data.read(), mock_answer_data)
+                self.assertEqual(fut.problem_type, problem_type)
+
+
 class TestSerialization(unittest.TestCase):
 
     class AssertionSatisfied(Exception):