Repository: dwavesystems/dwave-cloud-client
Pull request #634 (2024-06-28 13:53:53)

Add `decode qp problem`


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 7f9c558..347db1d 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -3,7 +3,7 @@ version: 2.1
 parameters:
   cache-generation:
     type: integer
-    default: 3
+    default: 4
 
 orbs:
   win: circleci/windows@5.0
@@ -58,7 +58,7 @@ jobs:
             if [[ "<< parameters.dimod-numpy-version >>" ]]; then
               pip install << parameters.dimod-numpy-version >>
             else
-              pip uninstall dimod dwave-networkx -y
+              pip uninstall dimod -y
             fi
 
       - run: &install-package
@@ -94,7 +94,9 @@ jobs:
 
       - run: &brew-install-pyenv
           name: Install pyenv
-          command: brew install pyenv
+          command: |
+            brew update
+            brew install pyenv
 
       - run: &pyenv-install-python
           name: Install python
diff --git a/docs/reference/resources.rst b/docs/reference/resources.rst
index 73e5dad..8859712 100644
--- a/docs/reference/resources.rst
+++ b/docs/reference/resources.rst
@@ -58,6 +58,7 @@ Methods
    Client.get_regions
    Client.get_solver
    Client.get_solvers
+   Client.solvers
    Client.is_solver_handled
    Client.retrieve_answer
    Client.close
diff --git a/dwave/cloud/coders.py b/dwave/cloud/coders.py
index bca06b0..d48aad0 100644
--- a/dwave/cloud/coders.py
+++ b/dwave/cloud/coders.py
@@ -14,20 +14,43 @@
 
 import struct
 import base64
-from typing import Callable
+from typing import Callable, Dict, List, Tuple, Union
+
+try:
+    # note: TypedDict is available in py38+, but NotRequired only in py311+
+    from typing import NotRequired, TypedDict
+except ImportError:
+    from typing_extensions import NotRequired, TypedDict
 
 from dwave.cloud.utils.qubo import uniform_get, active_qubits
 
 __all__ = [
-    'encode_problem_as_qp', 'decode_qp', 'decode_qp_numpy',
+    'encode_problem_as_qp', 'decode_qp', 'decode_qp_numpy', 'decode_qp_problem',
     'encode_problem_as_bq', 'decode_bq',
     'encode_problem_as_ref', 'decode_binary_ref',
     'bqm_as_file',
 ]
 
 
-def encode_problem_as_qp(solver, linear, quadratic, offset=0,
-                         undirected_biases=False):
+# a plain dict version of `dwave.cloud.api.models.StructuredProblemData`
+class EncodedQP(TypedDict):
+    format: str
+    lin: str
+    quad: str
+    offset: NotRequired[float]
+
+class QuadraticProblem(TypedDict):
+    linear: Dict[int, float]
+    quadratic: Dict[Tuple[int, int], float]
+    offset: NotRequired[float]
+
+
+def encode_problem_as_qp(solver: 'dwave.cloud.solver.StructuredSolver',
+                         linear: Union[List[float], Dict[int, float]],
+                         quadratic: Dict[Tuple[int, int], float],
+                         offset: float = 0,
+                         undirected_biases: bool = False
+                         ) -> EncodedQP:
     """Encode the binary quadratic problem for submission to a given solver,
     using the `qp` format for data.
 
@@ -90,6 +113,34 @@ def encode_problem_as_qp(solver, linear, quadratic, offset=0,
     }
 
 
+def decode_qp_problem(solver: 'dwave.cloud.solver.StructuredSolver',
+                      qp: EncodedQP,
+                      undirected_edges: bool = True
+                      ) -> QuadraticProblem:
+    """Decode quadratic unconstrained binary problem encoded using SAPI's
+    ``qp`` format."""
+
+    encoding_qubits = solver._encoding_qubits
+    encoding_couplers = solver._encoding_couplers
+
+    lin = base64.decodebytes(qp['lin'].encode('ascii'))
+    lin = struct.unpack('<' + ('d' * int(len(lin)/8)), lin)
+    linear = {qubit: lin[idx] for idx, qubit in enumerate(encoding_qubits)}
+
+    quad = base64.decodebytes(qp['quad'].encode('ascii'))
+    quad = struct.unpack('<' + ('d' * int(len(quad)/8)), quad)
+    quadratic = {(q1,q2): quad[idx] for idx, (q1,q2) in enumerate(encoding_couplers)}
+
+    # return quadratic as upper triangular (regardless of encoding couplers)
+    if undirected_edges:
+        quadratic = {(q1,q2): quadratic.get((q1,q2), 0) + quadratic.get((q2,q1), 0)
+                     for (q1,q2) in solver.undirected_edges}
+
+    offset = qp.get('offset', 0.0)
+
+    return QuadraticProblem(linear=linear, quadratic=quadratic, offset=offset)
+
+
 def decode_qp(msg):
     """Decode SAPI response that uses `qp` format, without numpy.
 
diff --git a/dwave/cloud/solver.py b/dwave/cloud/solver.py
index f2e2264..f75f080 100644
--- a/dwave/cloud/solver.py
+++ b/dwave/cloud/solver.py
@@ -393,7 +393,7 @@ class BaseUnstructuredSolver(BaseSolver):
                 Optional problem encoding and upload parameters.
 
         Returns:
-            :class:`concurrent.futures.Future`\ [str]:
+            :class:`concurrent.futures.Future`[str]:
                 Problem ID in a Future. Problem ID can be used to submit
                 problems by reference.
         """
@@ -554,19 +554,19 @@ class BQMSolver(BaseUnstructuredSolver):
         return self.sample_problem(bqm, label=label, **params)
 
     def upload_bqm(self, bqm):
-        r"""Upload the specified :term:`BQM` to SAPI, returning a Problem ID
+        """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
-        :meth:`~BQMSolver.sample_bqm` method).
+        `.sample_bqm` method).
 
         Args:
-            bqm (:class:`~dimod.binary.BinaryQuadraticModel`\ /bytes-like/file-like):
+            bqm (:class:`~dimod.BinaryQuadraticModel`/bytes-like/file-like):
                 A binary quadratic model given either as an in-memory
-                :class:`~dimod.binary.BinaryQuadraticModel` object, or as raw data
+                :class:`~dimod.BinaryQuadraticModel` object, or as raw data
                 (encoded serialized model) in either a file-like or a bytes-like
                 object.
 
         Returns:
-            :class:`concurrent.futures.Future`\ [str]:
+            :class:`concurrent.futures.Future`[str]:
                 Problem ID in a Future. Problem ID can be used to submit
                 problems by reference.
 
@@ -675,7 +675,7 @@ class DQMSolver(BaseUnstructuredSolver):
                 object.
 
         Returns:
-            :class:`concurrent.futures.Future`\ [str]:
+            :class:`concurrent.futures.Future`[str]:
                 Problem ID in a Future. Problem ID can be used to submit
                 problems by reference.
 
@@ -763,7 +763,7 @@ class CQMSolver(BaseUnstructuredSolver):
                 object.
 
         Returns:
-            :class:`concurrent.futures.Future`\ [str]:
+            :class:`concurrent.futures.Future`[str]:
                 Problem ID in a Future. Problem ID can be used to submit
                 problems by reference.
 
@@ -909,7 +909,7 @@ class NLSolver(BaseUnstructuredSolver):
                 like ``max_num_states``.
 
         Returns:
-            :class:`concurrent.futures.Future`\ [str]:
+            :class:`concurrent.futures.Future`[str]:
                 Problem ID in a Future. Problem ID can be used to submit
                 problems by reference.
 
diff --git a/releasenotes/notes/add-qp-problem-decoder-3ee1c61fb7be8957.yaml b/releasenotes/notes/add-qp-problem-decoder-3ee1c61fb7be8957.yaml
new file mode 100644
index 0000000..e8147cf
--- /dev/null
+++ b/releasenotes/notes/add-qp-problem-decoder-3ee1c61fb7be8957.yaml
@@ -0,0 +1,5 @@
+---
+features:
+  - |
+    Add ``dwave.cloud.coders.decode_qp_problem``, a decoder of quadratic
+    unconstrained binary problems in SAPI's ``"qp"`` format.


Test patch:
diff --git a/tests/test_coders.py b/tests/test_coders.py
index 7732c43..0291b79 100644
--- a/tests/test_coders.py
+++ b/tests/test_coders.py
@@ -15,6 +15,7 @@
 import re
 import copy
 import base64
+import random
 import struct
 import unittest
 
@@ -24,13 +25,15 @@ except ImportError:
     dimod = None
 
 import numpy as np
+from parameterized import parameterized
 from plucky import pluck
 
 from dwave.cloud.coders import (
-    encode_problem_as_qp, decode_qp, decode_qp_numpy,
+    encode_problem_as_qp, decode_qp, decode_qp_numpy, decode_qp_problem,
     encode_problem_as_bq, decode_bq, encode_problem_as_ref)
 from dwave.cloud.solver import StructuredSolver, UnstructuredSolver
-from dwave.cloud.utils.qubo import generate_const_ising_problem
+from dwave.cloud.testing import mocks
+from dwave.cloud.utils.qubo import generate_const_ising_problem, generate_random_ising_problem
 
 # parse string version as tuple
 if dimod:
@@ -190,6 +193,25 @@ class TestQPCoders(CodersTestBase):
         # [-1]
         self.assertEqual(request['quad'], self.encode_doubles([-1]))
 
+    @parameterized.expand([
+        ("K2", lambda: mocks.qpu_clique_solver_data(2)),
+        ("C4", lambda: mocks.qpu_chimera_solver_data(4)),
+        ("P16", lambda: mocks.qpu_pegasus_solver_data(16))
+    ])
+    @unittest.skipUnless(dimod, "dimod required for mock solver generators")
+    def test_qp_problem_decode(self, name, get_solver_data):
+        solver = StructuredSolver(client=None, data=get_solver_data())
+
+        linear, quadratic = generate_random_ising_problem(solver)
+        offset = random.random()
+
+        encoded_qp = encode_problem_as_qp(solver, linear, quadratic, offset)
+        qp = decode_qp_problem(solver, encoded_qp)
+
+        self.assertEqual(qp['linear'], linear)
+        self.assertEqual(qp['quadratic'], quadratic)
+        self.assertEqual(qp['offset'], offset)
+
 
 class TestQPDecoders(CodersTestBase):
     """Test QP decoders correctly decode response data."""