Repository: dwavesystems/dimod

Issue:
SampleSet.aggregate() does not preserve order, destroys equality test
Consider (dimod v0.8.10):
```
import dimod
bqm = dimod.BinaryQuadraticModel({}, {'ab': 1}, 0, dimod.SPIN)
ss1 = dimod.SampleSet.from_samples_bqm([{'a': 1, 'b': -1}, {'a': -1, 'b': 1}], bqm)
ss2 = ss1.aggregate()
```
then we have:
```
>>> ss1
SampleSet(rec.array([([ 1, -1], -1., 1), ([-1,  1], -1., 1)],
          dtype=[('sample', 'i1', (2,)), ('energy', '<f8'), ('num_occurrences', '<i8')]), ['a', 'b'], {}, 'SPIN')

>>> ss2
SampleSet(rec.array([([-1,  1], -1., 1), ([ 1, -1], -1., 1)],
          dtype=[('sample', 'i1', (2,)), ('energy', '<f8'), ('num_occurrences', '<i8')]), ['a', 'b'], {}, 'SPIN')

>>> ss1 == ss2
False
```

Not sure if this qualifies as a bug in your book, but we can call it a feature (request). :smile: 

Discussion:

Patch:
diff --git a/dimod/generators/__init__.py b/dimod/generators/__init__.py
index b6548fd6..b2f27548 100644
--- a/dimod/generators/__init__.py
+++ b/dimod/generators/__init__.py
@@ -16,4 +16,4 @@
 from dimod.generators.chimera import chimera_anticluster
 from dimod.generators.constraints import combinations
 from dimod.generators.fcl import frustrated_loop
-from dimod.generators.random import uniform, randint
+from dimod.generators.random import uniform, randint, ran_r
diff --git a/dimod/generators/random.py b/dimod/generators/random.py
index 74313138..a96133ab 100644
--- a/dimod/generators/random.py
+++ b/dimod/generators/random.py
@@ -21,7 +21,7 @@ import numpy.random
 from dimod.binary_quadratic_model import BinaryQuadraticModel
 from dimod.decorators import graph_argument
 
-__all__ = ['uniform', 'randint']
+__all__ = ['uniform', 'ran_r', 'randint']
 
 
 @graph_argument('graph')
@@ -133,3 +133,73 @@ def randint(graph, vartype, low=0, high=1, cls=BinaryQuadraticModel,
 
     return cls.from_numpy_vectors(ldata, (irow, icol, qdata), offset, vartype,
                                   variable_order=variables)
+
+
+@graph_argument('graph')
+def ran_r(r, graph, cls=BinaryQuadraticModel, seed=None):
+    """Generate an Ising model for a RANr problem.
+
+    In RANr problems all linear biases are zero and quadratic values are uniformly
+    selected integers between -r to r, excluding zero. This class of problems is relevant
+    for binary quadratic models (BQM) with spin variables (Ising models).
+
+    This generator of RANr problems follows the definition in [Kin2015]_\ .
+
+    Args:
+        r (int):
+            Order of the RANr problem.
+
+        graph (int/tuple[nodes, edges]/:obj:`~networkx.Graph`):
+            The graph to build the BQM for. Either an
+            integer n, interpreted as a complete graph of size n, or
+            a nodes/edges pair, or a NetworkX graph.
+
+        cls (:class:`.BinaryQuadraticModel`):
+            Binary quadratic model class to build from.
+
+        seed (int, optional, default=None):
+            Random seed.
+
+    Returns:
+        :obj:`.BinaryQuadraticModel`.
+
+    Examples:
+
+    >>> import networkx as nx
+    >>> K_7 = nx.complete_graph(7)
+    >>> bqm = dimod.generators.random.ran_r(1, K_7)
+
+    .. [Kin2015] James King, Sheir Yarkoni, Mayssam M. Nevisi, Jeremy P. Hilton,
+        Catherine C. McGeoch. Benchmarking a quantum annealing processor with the
+        time-to-target metric. https://arxiv.org/abs/1508.05087
+
+    """
+    if not isinstance(r, int):
+        raise TypeError("r should be a positive integer")
+    if r < 1:
+        raise ValueError("r should be a positive integer")
+
+    if seed is None:
+        seed = numpy.random.randint(2**32, dtype=np.uint32)
+    rnd = numpy.random.RandomState(seed)
+
+    variables, edges = graph
+
+    index = {v: idx for idx, v in enumerate(variables)}
+
+    if edges:
+        irow, icol = zip(*((index[u], index[v]) for u, v in edges))
+    else:
+        irow = icol = tuple()
+
+    ldata = np.zeros(len(variables))
+
+    rvals = np.empty(2*r)
+    rvals[0:r] = range(-r, 0)
+    rvals[r:] = range(1, r+1)
+    qdata = rnd.choice(rvals, size=len(variables))
+
+    offset = 0
+
+    return cls.from_numpy_vectors(ldata, (irow, icol, qdata), offset, vartype='SPIN',
+                                  variable_order=variables)
diff --git a/dimod/sampleset.py b/dimod/sampleset.py
index 40c050d4..48f0ba39 100644
--- a/dimod/sampleset.py
+++ b/dimod/sampleset.py
@@ -987,15 +987,19 @@ class SampleSet(abc.Iterable, abc.Sized):
             other fields are.
 
         """
-
         _, indices, inverse = np.unique(self.record.sample, axis=0,
                                         return_index=True, return_inverse=True)
 
+        # unique also sorts the array which we don't want, so we undo the sort
+        order = np.argsort(indices)
+        indices = indices[order]
+
         record = self.record[indices]
 
         # fix the number of occurrences
         record.num_occurrences = 0
         for old_idx, new_idx in enumerate(inverse):
+            new_idx = order[new_idx]
             record[new_idx].num_occurrences += self.record[old_idx].num_occurrences
 
         # dev note: we don't check the energies as they should be the same
diff --git a/docs/reference/generators.rst b/docs/reference/generators.rst
index f948c9bb..25bc5cad 100644
--- a/docs/reference/generators.rst
+++ b/docs/reference/generators.rst
@@ -43,4 +43,5 @@ Random
    :toctree: generated/
 
    randint
-   uniform
\ No newline at end of file
+   ran_r
+   uniform


Test patch:
diff --git a/tests/test_generators.py b/tests/test_generators.py
index 1e0bd712..d4d367e2 100644
--- a/tests/test_generators.py
+++ b/tests/test_generators.py
@@ -75,6 +75,38 @@ class TestRandomRandint(unittest.TestCase):
 
         self.assertNotEqual(bqm2, bqm1)
 
+class TestRandomRanR(unittest.TestCase):
+    def test_singleton(self):
+        bqm = dimod.generators.random.ran_r(1, 1)
+
+        # should have a single node
+        self.assertEqual(len(bqm), 1)
+        self.assertIn(0, bqm.variables)
+
+    def test_empty(self):
+        bqm = dimod.generators.random.ran_r(1, 0)
+
+        # should have no nodes
+        self.assertEqual(len(bqm), 0)
+
+    def test_seed(self):
+        bqm0 = dimod.generators.random.ran_r(3, 100, seed=506)
+        bqm1 = dimod.generators.random.ran_r(3, 100, seed=506)
+
+        self.assertEqual(bqm0, bqm1)
+
+        bqm2 = dimod.generators.random.ran_r(3, 100, seed=123)
+
+        self.assertNotEqual(bqm2, bqm1)
+
+    def test_values(self):
+        bqm = dimod.generators.random.ran_r(5, 10)
+
+        self.assertFalse(all(bqm.linear.values()))
+        self.assertTrue(all(val != 0 for val in bqm.quadratic.values()))
+        self.assertTrue(all(val <= 5 for val in bqm.quadratic.values()))
+        self.assertTrue(all(val >= -5 for val in bqm.quadratic.values()))
+
 
 class TestChimeraAnticluster(unittest.TestCase):
     def test_singletile(self):
diff --git a/tests/test_sampleset.py b/tests/test_sampleset.py
index 141552a8..176627ee 100644
--- a/tests/test_sampleset.py
+++ b/tests/test_sampleset.py
@@ -261,6 +261,52 @@ class TestAggregate(unittest.TestCase):
         self.assertEqual(samples,
                          dimod.SampleSet.from_samples(([[-1, 1], [-1, 1]], 'ab'), dimod.SPIN, energy=[0.0, 0.0]))
 
+    def test_order_preservation_2x2_unique(self):
+        bqm = dimod.BinaryQuadraticModel({}, {'ab': 1}, 0, dimod.SPIN)
+
+        # these are unique so order should be preserved
+        ss1 = dimod.SampleSet.from_samples_bqm([{'a': 1, 'b': -1},
+                                                {'a': -1, 'b': 1}],
+                                               bqm)
+        ss2 = ss1.aggregate()
+
+        self.assertEqual(ss1, ss2)
+
+    def test_order_preservation(self):
+        bqm = dimod.BinaryQuadraticModel({}, {'ab': 1}, 0, dimod.SPIN)
+
+        ss1 = dimod.SampleSet.from_samples_bqm([{'a': 1, 'b': -1},
+                                                {'a': -1, 'b': 1},
+                                                {'a': 1, 'b': -1}],
+                                               bqm)
+        ss2 = ss1.aggregate()
+
+        target = dimod.SampleSet.from_samples_bqm([{'a': 1, 'b': -1},
+                                                   {'a': -1, 'b': 1}],
+                                                  bqm,
+                                                  num_occurrences=[2, 1])
+
+        self.assertEqual(target, ss2)
+
+    def test_order_preservation_doubled(self):
+        bqm = dimod.BinaryQuadraticModel({}, {'ab': 1, 'bc': -1}, 0, dimod.SPIN)
+        ss1 = dimod.SampleSet.from_samples_bqm(([[1, 1, 0],
+                                                 [1, 0, 0],
+                                                 [0, 0, 0],
+                                                 [0, 0, 0],
+                                                 [1, 1, 0],
+                                                 [1, 0, 0],
+                                                 [0, 0, 0]], 'abc'),
+                                               bqm)
+
+        target = dimod.SampleSet.from_samples_bqm(([[1, 1, 0],
+                                                    [1, 0, 0],
+                                                    [0, 0, 0]], 'abc'),
+                                                  bqm,
+                                                  num_occurrences=[2, 2, 3])
+
+        self.assertEqual(target, ss1.aggregate())
+
 
 class TestAppend(unittest.TestCase):
     def test_sampleset1_append1(self):