Repository: dwavesystems/dwave-system

Issue:
Hybrid samplers ignore user's config value for solver
`LeapHybridSampler` and `LeapHybridDQMSampler` [update required solver features](https://github.com/dwavesystems/dwave-system/blob/2f82124f518f7a57ae6b7f74c84a8aa62715ca4d/dwave/system/samplers/leap_hybrid_sampler.py#L82-L90) on kwargs level only, effectively ignoring user's config from file/env.

This behaviour predates #288 and fixes in #317, where we addressed such behaviour only in the QPU samplers.

This issue can be seen as a hybrid analogue of #335.

Discussion:

Patch:
diff --git a/docs/reference/samplers.rst b/docs/reference/samplers.rst
index e4659b3..444a51f 100644
--- a/docs/reference/samplers.rst
+++ b/docs/reference/samplers.rst
@@ -102,6 +102,7 @@ Properties
 
    LeapHybridSampler.properties
    LeapHybridSampler.parameters
+   LeapHybridSampler.default_solver
 
 
 Methods
@@ -128,6 +129,7 @@ Properties
 
    LeapHybridDQMSampler.properties
    LeapHybridDQMSampler.parameters
+   LeapHybridDQMSampler.default_solver
 
 
 Methods
diff --git a/dwave/system/__init__.py b/dwave/system/__init__.py
index bd41344..8112a19 100644
--- a/dwave/system/__init__.py
+++ b/dwave/system/__init__.py
@@ -21,3 +21,5 @@ from dwave.system.composites import *
 import dwave.system.composites
 
 from dwave.system.utilities import *
+
+from dwave.system.package_info import __version__
diff --git a/dwave/system/samplers/dwave_sampler.py b/dwave/system/samplers/dwave_sampler.py
index b15ba74..c0fd6d0 100644
--- a/dwave/system/samplers/dwave_sampler.py
+++ b/dwave/system/samplers/dwave_sampler.py
@@ -128,7 +128,6 @@ class DWaveSampler(dimod.Sampler, dimod.Structured):
 
     """
     def __init__(self, failover=False, retry_interval=-1, **config):
-
         # strongly prefer QPU solvers; requires kwarg-level override
         config.setdefault('client', 'qpu')
 
diff --git a/dwave/system/samplers/leap_hybrid_sampler.py b/dwave/system/samplers/leap_hybrid_sampler.py
index b5d9a25..45d0fcc 100644
--- a/dwave/system/samplers/leap_hybrid_sampler.py
+++ b/dwave/system/samplers/leap_hybrid_sampler.py
@@ -41,6 +41,13 @@ from dwave.cloud import Client
 __all__ = ['LeapHybridSampler', 'LeapHybridDQMSampler']
 
 
+# taken from https://stackoverflow.com/a/39542816, licensed under CC BY-SA 3.0
+# not needed in py39+
+class classproperty(property):
+    def __get__(self, obj, objtype=None):
+        return super(classproperty, self).__get__(objtype)
+
+
 class LeapHybridSampler(dimod.Sampler):
     """A class for using Leap's cloud-based hybrid BQM solvers.
 
@@ -58,7 +65,11 @@ class LeapHybridSampler(dimod.Sampler):
     ``category=hybrid`` and ``supported_problem_type=bqm``. By default, online
     hybrid BQM solvers are returned ordered by latest ``version``.
 
-    Inherits from :class:`dimod.Sampler`.
+    The default specification for filtering and ordering solvers by features is
+    available as :attr:`.default_solver` property. Explicitly specifying a
+    solver in a configuration file, an environment variable, or keyword
+    arguments overrides this specification. See the example below on how to
+    extend it instead.
 
     Args:
         **config:
@@ -81,37 +92,50 @@ class LeapHybridSampler(dimod.Sampler):
         >>> bqm = dimod.BQM.from_qubo(qubo)
         ...
         >>> # Find a good solution
-        >>> sampler = LeapHybridSampler()    # doctest: +SKIP
-        >>> sampleset = sampler.sample(bqm)           # doctest: +SKIP
+        >>> sampler = LeapHybridSampler()       # doctest: +SKIP
+        >>> sampleset = sampler.sample(bqm)     # doctest: +SKIP
+
+        This example specializes the default solver selection by filtering out
+        bulk BQM solvers. (Bulk solvers are throughput-optimal for heavy/batch
+        workloads, have a higher start-up latency, and are not well suited for
+        live workloads. Not all Leap accounts have access to bulk solvers.)
+
+        >>> from dwave.system import LeapHybridSampler
+        ...
+        >>> solver = LeapHybridSampler.default_solver
+        >>> solver.update(name__regex=".*(?<!bulk)$")       # name shouldn't end with "bulk"
+        >>> sampler = LeapHybridSampler(solver=solver)      # doctest: +SKIP
+        >>> sampler.solver        # doctest: +SKIP
+        BQMSolver(id='hybrid_binary_quadratic_model_version2')
 
     """
 
     _INTEGER_BQM_SIZE_THRESHOLD = 10000
 
-    def __init__(self, solver=None, connection_close=True, **config):
+    @classproperty
+    def default_solver(cls):
+        """dict: Features used to select the latest accessible hybrid BQM solver."""
+        return dict(supported_problem_types__contains='bqm',
+                    order_by='-properties.version')
 
-        # we want a Hybrid solver by default, but allow override
+    def __init__(self, **config):
+        # strongly prefer hybrid solvers; requires kwarg-level override
         config.setdefault('client', 'hybrid')
 
-        if solver is None:
-            solver = {}
-
-        if isinstance(solver, abc.Mapping):
-            # TODO: instead of solver selection, try with user's default first
-            if solver.setdefault('category', 'hybrid') != 'hybrid':
-                raise ValueError("the only 'category' this sampler supports is 'hybrid'")
-            if solver.setdefault('supported_problem_types__contains', 'bqm') != 'bqm':
-                raise ValueError("the only problem type this sampler supports is 'bqm'")
+        # default to short-lived session to prevent resets on slow uploads
+        config.setdefault('connection_close', True)
 
-            # prefer the latest version, but allow kwarg override
-            solver.setdefault('order_by', '-properties.version')
-
-        self.client = Client.from_config(
-            solver=solver, connection_close=connection_close, **config)
+        # prefer the latest hybrid BQM solver available, but allow for an easy
+        # override on any config level above the defaults (file/env/kwarg)
+        defaults = config.setdefault('defaults', {})
+        if not isinstance(defaults, abc.Mapping):
+            raise TypeError("mapping expected for 'defaults'")
+        defaults.update(solver=self.default_solver)
 
+        self.client = Client.from_config(**config)
         self.solver = self.client.get_solver()
 
-        # For explicitly named solvers:
+        # check user-specified solver conforms to our requirements
         if self.properties.get('category') != 'hybrid':
             raise ValueError("selected solver is not a hybrid solver.")
         if 'bqm' not in self.solver.supported_problem_types:
@@ -277,6 +301,12 @@ class LeapHybridDQMSampler:
     ``category=hybrid`` and ``supported_problem_type=dqm``. By default, online
     hybrid DQM solvers are returned ordered by latest ``version``.
 
+    The default specification for filtering and ordering solvers by features is
+    available as :attr:`.default_solver` property. Explicitly specifying a
+    solver in a configuration file, an environment variable, or keyword
+    arguments overrides this specification. See the example in :class:`.LeapHybridSampler`
+    on how to extend it instead.
+
     Args:
         **config:
             Keyword arguments passed to :meth:`dwave.cloud.client.Client.from_config`.
@@ -316,40 +346,37 @@ class LeapHybridDQMSampler:
         >>> print("{} beats {}".format(cases[sampleset.first.sample['my_hand']],
         ...                            cases[sampleset.first.sample['their_hand']]))   # doctest: +SKIP
         rock beats scissors
-
     """
 
-    def __init__(self, solver=None, connection_close=True, **config):
+    @classproperty
+    def default_solver(self):
+        """dict: Features used to select the latest accessible hybrid DQM solver."""
+        return dict(supported_problem_types__contains='dqm',
+                    order_by='-properties.version')
 
-        # we want a Hybrid solver by default, but allow override
+    def __init__(self, **config):
+        # strongly prefer hybrid solvers; requires kwarg-level override
         config.setdefault('client', 'hybrid')
 
-        if solver is None:
-            solver = {}
+        # default to short-lived session to prevent resets on slow uploads
+        config.setdefault('connection_close', True)
 
-        if isinstance(solver, abc.Mapping):
-            # TODO: instead of solver selection, try with user's default first
-            if solver.setdefault('category', 'hybrid') != 'hybrid':
-                raise ValueError("the only 'category' this sampler supports is 'hybrid'")
-            if solver.setdefault('supported_problem_types__contains', 'dqm') != 'dqm':
-                raise ValueError("the only problem type this sampler supports is 'dqm'")
-
-            # prefer the latest version, but allow kwarg override
-            solver.setdefault('order_by', '-properties.version')
-
-        self.client = Client.from_config(
-            solver=solver, connection_close=connection_close, **config)
+        # prefer the latest hybrid DQM solver available, but allow for an easy
+        # override on any config level above the defaults (file/env/kwarg)
+        defaults = config.setdefault('defaults', {})
+        if not isinstance(defaults, abc.Mapping):
+            raise TypeError("mapping expected for 'defaults'")
+        defaults.update(solver=self.default_solver)
 
+        self.client = Client.from_config(**config)
         self.solver = self.client.get_solver()
 
-        # For explicitly named solvers:
+        # check user-specified solver conforms to our requirements
         if self.properties.get('category') != 'hybrid':
             raise ValueError("selected solver is not a hybrid solver.")
         if 'dqm' not in self.solver.supported_problem_types:
             raise ValueError("selected solver does not support the 'dqm' problem type.")
 
-        # overwrite the (static)
-
     @property
     def properties(self):
         """dict: Solver properties as returned by a SAPI query.
diff --git a/requirements.txt b/requirements.txt
index df3f999..b6e2618 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,10 @@
 --extra-index-url https://pypi.dwavesys.com/simple
 
-dimod==0.9.15
-dwave-cloud-client==0.8.4
-dwave-networkx==0.8.4
+dimod==0.9.16
+dwave-cloud-client==0.8.7
+dwave-networkx==0.8.8
 dwave-drivers==0.4.4
-dwave-tabu==0.3.1
+dwave-tabu==0.4.1
 homebase==1.0.1
-minorminer==0.2.4
-numpy==1.19.4; python_version >= '3.6'
-numpy==1.18.5; python_version == '3.5'
+minorminer==0.2.6
+numpy==1.19.4


Test patch:
diff --git a/tests/test_leaphybridsolver.py b/tests/test_leaphybridsolver.py
index e3eaeb5..fd2c23b 100644
--- a/tests/test_leaphybridsolver.py
+++ b/tests/test_leaphybridsolver.py
@@ -36,7 +36,7 @@ class MockClient:
 
     def get_solver(self, **filters):
 
-        if isinstance(self.args['solver'], str) and self.args['solver'] == 'not_hybrid_solver':
+        if self.args.get('solver') == 'not_hybrid_solver':
             return MockBadLeapHybridSolver()
 
         if self.args.get('client', 'base') not in ['base', 'hybrid']:
@@ -48,87 +48,66 @@ class TestLeapHybridSampler(unittest.TestCase):
 
     @mock.patch('dwave.system.samplers.leap_hybrid_sampler.Client')
     def test_solver_init(self, mock_client):
-
         mock_client.from_config.side_effect = MockClient
 
+        default_solver = dict(
+            supported_problem_types__contains='bqm',
+            order_by='-properties.version')
+        self.assertEqual(LeapHybridSampler.default_solver, default_solver)
+
+        defaults = dict(solver=default_solver)
+
         # Default call
         mock_client.reset_mock()
         LeapHybridSampler()
         mock_client.from_config.assert_called_once_with(
-            client='hybrid', connection_close=True,
-            solver={'category': 'hybrid',
-                    'supported_problem_types__contains': 'bqm',
-                    'order_by': '-properties.version'})
+            client='hybrid',
+            connection_close=True,
+            defaults=defaults)
 
         # Non-hybrid client setting
         mock_client.reset_mock()
         with self.assertRaises(SolverNotFoundError):
             LeapHybridSampler(client='qpu')
 
-        # Explicitly set category to hybrid
-        mock_client.reset_mock()
-        LeapHybridSampler(solver={'category': 'hybrid',
-                                  'supported_problem_types__contains': 'bqm'})
-        mock_client.from_config.assert_called_once_with(
-            client='hybrid', connection_close=True,
-            solver={'category': 'hybrid',
-                    'supported_problem_types__contains': 'bqm',
-                    'order_by': '-properties.version'})
-
-        # Explicitly set category to not hybrid
-        with self.assertRaises(ValueError):
-            LeapHybridSampler(solver={'category': 'not hybrid'})
-
-        # Set irrelevant paremeters
-        mock_client.reset_mock()
-        LeapHybridSampler(solver={'qpu': True})
-        mock_client.from_config.assert_called_once_with(
-            client='hybrid', connection_close=True,
-            solver={'qpu': True, 'category': 'hybrid',
-                    'supported_problem_types__contains': 'bqm',
-                    'order_by': '-properties.version'})
-
-        mock_client.reset_mock()
-        LeapHybridSampler(solver={'qpu': True, 'anneal_schedule': False})
-        mock_client.from_config.assert_called_once_with(
-            client='hybrid', connection_close=True,
-            solver={'anneal_schedule': False, 'qpu': True, 'category': 'hybrid',
-                    'supported_problem_types__contains': 'bqm',
-                    'order_by': '-properties.version'})
-
-        # Named solver: hybrid
+        # Explicitly set solver def
         mock_client.reset_mock()
-        LeapHybridSampler(solver="hybrid_solver")
+        LeapHybridSampler(solver={'supported_problem_types__contains': 'bqm'})
         mock_client.from_config.assert_called_once_with(
-            client='hybrid', connection_close=True, solver="hybrid_solver")
+            client='hybrid',
+            solver={'supported_problem_types__contains': 'bqm'},
+            connection_close=True,
+            defaults=defaults)
 
+        # Named solver
+        solver_name = 'hybrid-solver-name'
         mock_client.reset_mock()
-        LeapHybridSampler(connection_close=False, solver="hybrid_solver")
+        LeapHybridSampler(solver=solver_name)
         mock_client.from_config.assert_called_once_with(
-            client='hybrid', connection_close=False, solver="hybrid_solver")
+            client='hybrid',
+            solver=solver_name,
+            connection_close=True,
+            defaults=defaults)
 
         # Named solver: non-hybrid
         with self.assertRaises(ValueError):
-            LeapHybridSampler(solver="not_hybrid_solver")
+            LeapHybridSampler(solver='not_hybrid_solver')
 
         # Set connection_close to False
         mock_client.reset_mock()
         LeapHybridSampler(connection_close=False)
         mock_client.from_config.assert_called_once_with(
-            client='hybrid', connection_close=False,
-            solver={'category': 'hybrid',
-                    'supported_problem_types__contains': 'bqm',
-                    'order_by': '-properties.version'})
+            client='hybrid',
+            connection_close=False,
+            defaults=defaults)
 
         mock_client.reset_mock()
-        LeapHybridSampler(connection_close=False,
-                          solver={'category': 'hybrid',
-                                  'supported_problem_types__contains': 'bqm'})
+        LeapHybridSampler(connection_close=False, solver=solver_name)
         mock_client.from_config.assert_called_once_with(
-            client='hybrid', connection_close=False,
-            solver={'category': 'hybrid',
-                    'supported_problem_types__contains': 'bqm',
-                    'order_by': '-properties.version'})
+            client='hybrid',
+            solver=solver_name,
+            connection_close=False,
+            defaults=defaults)
 
     @mock.patch('dwave.system.samplers.leap_hybrid_sampler.Client')
     def test_sample_bqm(self, mock_client):