Repository: sandialabs/pyttb

Issue:
Align sptensor comparison operator functionality
Currently, there is a TODO listed in the `sptensor` class, noting that the comparison operators (`__gt__`, `__ge__`, `__lt__`, `__le__`, `__eq__`, `__ne__`) share a lot of code and could be streamlined.

When evaluating `sptensor` related to #286, I also saw that comparisons involving empty tensors led to errors that were not useful. The operator `__lt__` works for empty `sptensor` objects, but the others do not. This should be fixed.

**Suggestion:** Provide support for all comparison operators to handle empty tensors. When either or both operands are empty tensors (sparse or dense), the results should be an empty tensor of the type of the first operand (i.e., `self`).

Reproducing the current functionality:

```python
>>> import pyttb as ttb
>>> import numpy as np

>>> S = ttb.sptensor(shape=(2,2))

>>> S < S
empty sparse tensor of shape (2, 2)

>>> S > S
empty sparse tensor of shape (2, 2)

>>> S == S
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/dmdunla/dev/github.com/pyttb/pyttb/sptensor.py", line 2657, in __eq__
    (self.vals[nzsubsIdx] == other.vals[iother]).transpose()[0], :
IndexError: index 0 is out of bounds for axis 0 with size 0

>>> S != S
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/dmdunla/dev/github.com/pyttb/pyttb/sptensor.py", line 2751, in __ne__
    self.extract(self.subs[subs2]) != other.extract(self.subs[subs2])
  File "/Users/dmdunla/dev/github.com/pyttb/pyttb/sptensor.py", line 717, in extract
    invalid = (searchsubs < 0) | (searchsubs >= np.array(self.shape))
ValueError: operands could not be broadcast together with shapes (0,0) (2,) 

>>> S <= S
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/dmdunla/dev/github.com/pyttb/pyttb/sptensor.py", line 3070, in __le__
    subs = np.vstack((subs1, subs2, subs3, subs4))
  File "/Users/dmdunla/opt/anaconda3/lib/python3.9/site-packages/numpy/core/shape_base.py", line 289, in vstack
    return _nx.concatenate(arrs, 0, dtype=dtype, casting=casting)
ValueError: all the input array dimensions except for the concatenation axis must match exactly, but along dimension 1, the array at index 0 has size 0 and the array at index 3 has size 2

>>> S >= S
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/dmdunla/dev/github.com/pyttb/pyttb/sptensor.py", line 3235, in __ge__
    return other.__le__(self)
  File "/Users/dmdunla/dev/github.com/pyttb/pyttb/sptensor.py", line 3070, in __le__
    subs = np.vstack((subs1, subs2, subs3, subs4))
  File "/Users/dmdunla/opt/anaconda3/lib/python3.9/site-packages/numpy/core/shape_base.py", line 289, in vstack
    return _nx.concatenate(arrs, 0, dtype=dtype, casting=casting)
ValueError: all the input array dimensions except for the concatenation axis must match exactly, but along dimension 1, the array at index 0 has size 0 and the array at index 3 has size 2

>>> S < S.to_tensor()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/dmdunla/dev/github.com/pyttb/pyttb/sptensor.py", line 3175, in __lt__
    subs2 = self.subs[self.vals.transpose()[0] < other[self.subs], :]
IndexError: index 0 is out of bounds for axis 0 with size 0

>>> S.to_tensor() < S
tensor of shape (2, 2)
data[:, :] =
[[False False]
 [False False]]

>>> T = ttb.tensor()
>>> T
empty tensor of shape ()
data = []

>>> T < T
empty tensor of shape (0,)
data = []
```

Hints from issue comments:
@jeremy-myers: There are some curiosities with empty `sptensor` objects that may help explain the bug. Consider these two empty `sptensor`s:
```
>>> S = ttb.sptensor(shape=(2,2)) # taken from Danny's example
>>> T = ttb.sptensor(np.array([]), np.array([]), shape=(4,4,4)) # adapted from test_sptensor.py, test_sptensor__lt__()
>>> S < S
empty sparse tensor of shape (2, 2) # as seen in Danny's example
>>> T < T
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/jermyer/dev/pyttb/pyttb/sptensor.py", line 2967, in __lt__
    subs1 = np.empty(shape=(0, other.subs.shape[1]))
                               ~~~~~~~~~~~~~~~~^^^
IndexError: tuple index out of range
```
Zooming in more closely:
```
>>> S.subs
array([], shape=(1, 0), dtype=int64)
>>> S.vals
array([], shape=(1, 0), dtype=float64)
>>> T.subs
array([], dtype=float64)
>>> T.vals
array([], dtype=float64)
```
Provided a shape, `S.subs` and `S.vals` have a `shape` (even though it isn't consistent with the shape of `S`. Not provided a shape, `T.subs` and `T.vals` don't have a `shape` (which I think is expected). However, `__lt__()` fails here since the returned empty container has a shape parameter that depends on the shape of the `subs`, which in the case of `T` is a 1D array. 

Solution patch:
diff --git a/pyttb/sptensor.py b/pyttb/sptensor.py
index c7ffef7..3e5ad2d 100644
--- a/pyttb/sptensor.py
+++ b/pyttb/sptensor.py
@@ -9,6 +9,7 @@ from __future__ import annotations
 import logging
 import warnings
 from collections.abc import Iterable, Sequence
+from operator import ge, gt, le, lt
 from typing import (
     Any,
     Callable,
@@ -146,6 +147,13 @@ class sptensor:
                 f"max subscripts are "
                 f"{tuple(np.max(subs, axis=0) + 1)}"
             )
+        else:
+            # In case user provides an empty array in weird format
+            subs = np.array([], ndmin=2, dtype=int)
+
+        if vals.size == 0:
+            # In case user provides an empty array in weird format
+            vals = np.array([], dtype=vals.dtype, ndmin=2)
 
         if copy:
             self.subs = subs.copy()
@@ -385,6 +393,9 @@ class sptensor:
                [1, 1]])
         """
         # Preallocate (discover any memory issues here!)
+        if len(self.shape) == 0:
+            return np.empty(shape=(1, 0), dtype=int)
+
         s = np.zeros(shape=(np.prod(self.shape), self.ndims))
 
         # Generate appropriately sized ones vectors
@@ -920,6 +931,8 @@ class sptensor:
         if self.shape != other.shape:
             return False
         if isinstance(other, ttb.sptensor):
+            if self.nnz != other.nnz:
+                return False
             return (self - other).nnz == 0
         if isinstance(other, ttb.tensor):
             return other.isequal(self)
@@ -2571,25 +2584,28 @@ class sptensor:
         >>> S[1, 1] = 1.0
         >>> S == S
         sparse tensor of shape (2, 2) with 4 nonzeros
-        [0, 0] = True
-        [0, 1] = True
-        [1, 0] = True
-        [1, 1] = True
+        [0, 0] = 1.0
+        [0, 1] = 1.0
+        [1, 0] = 1.0
+        [1, 1] = 1.0
 
         Compare with a scalar value, returning only a single `True` value:
 
         >>> S == 1
         sparse tensor of shape (2, 2) with 1 nonzeros
-        [1, 1] = True
+        [1, 1] = 1.0
         """
         # Case 1: other is a scalar
         if isinstance(other, (float, int)):
             if other == 0:
                 return self.logical_not()
             idx = self.vals == other
+            subs = np.empty(shape=(0, self.ndims), dtype=int)
+            if self.nnz > 0:
+                subs = self.subs[idx.transpose()[0]]
             return sptensor(
-                self.subs[idx.transpose()[0]],
-                True * np.ones((self.subs.shape[0], 1)).astype(bool),
+                subs,
+                True * np.ones((self.subs.shape[0], 1)).astype(self.vals.dtype),
                 self.shape,
             )
 
@@ -2615,16 +2631,17 @@ class sptensor:
             nzsubsIdx = tt_intersect_rows(self.subs, other.subs)
             nzsubs = self.subs[nzsubsIdx]
             iother = tt_intersect_rows(other.subs, self.subs)
-            znzsubs = nzsubs[
-                (self.vals[nzsubsIdx] == other.vals[iother]).transpose()[0], :
-            ]
+            equal_subs = self.vals[nzsubsIdx] == other.vals[iother]
+            znzsubs = np.empty(shape=(0, other.ndims), dtype=int)
+            if equal_subs.size > 0:
+                znzsubs = nzsubs[(equal_subs).transpose()[0], :]
 
             return sptensor(
                 np.vstack((zzerosubs, znzsubs)),
                 True
-                * np.ones((zzerosubs.shape[0] + znzsubs.shape[0])).astype(bool)[
-                    :, None
-                ],
+                * np.ones((zzerosubs.shape[0] + znzsubs.shape[0])).astype(
+                    self.vals.dtype
+                )[:, None],
                 self.shape,
             )
 
@@ -2635,18 +2652,23 @@ class sptensor:
             zzerosubs = otherzerosubs[(self[otherzerosubs] == 0).transpose()[0], :]
 
             # Find where their nonzeros intersect
-            othervals = other[self.subs]
-            znzsubs = self.subs[(othervals[:, None] == self.vals).transpose()[0], :]
+            znzsubs = np.empty(shape=(0, other.ndims), dtype=int)
+            if self.nnz > 0:
+                othervals = other[self.subs]
+                znzsubs = self.subs[(othervals[:, None] == self.vals).transpose()[0], :]
 
             return sptensor(
                 np.vstack((zzerosubs, znzsubs)),
-                True * np.ones((zzerosubs.shape[0] + znzsubs.shape[0])).astype(bool),
+                True
+                * np.ones((zzerosubs.shape[0] + znzsubs.shape[0], 1)).astype(
+                    self.vals.dtype
+                ),
                 self.shape,
             )
 
         assert False, "Comparison allowed with sptensor, tensor, or scalar only."
 
-    def __ne__(self, other):
+    def __ne__(self, other):  # noqa: PLR0912
         """
         Element-wise not equal operator (!=).
 
@@ -2668,9 +2690,9 @@ class sptensor:
 
         >>> S != 1
         sparse tensor of shape (2, 2) with 3 nonzeros
-        [0, 0] = True
-        [0, 1] = True
-        [1, 0] = True
+        [0, 0] = 1.0
+        [0, 1] = 1.0
+        [1, 0] = 1.0
         """
         # Case 1: One argument is a scalar
         if isinstance(other, (float, int)):
@@ -2678,12 +2700,14 @@ class sptensor:
                 return ttb.sptensor(
                     self.subs, True * np.ones((self.subs.shape[0], 1)), self.shape
                 )
-            subs1 = self.subs[self.vals.transpose()[0] != other, :]
+            subs1 = np.empty(shape=(0, self.ndims), dtype=int)
+            if self.nnz > 0:
+                subs1 = self.subs[self.vals.transpose()[0] != other, :]
             subs2Idx = tt_setdiff_rows(self.allsubs(), self.subs)
             subs2 = self.allsubs()[subs2Idx, :]
             return ttb.sptensor(
                 np.vstack((subs1, subs2)),
-                True * np.ones((subs2.shape[0], 1)).astype(bool),
+                True * np.ones((subs2.shape[0], 1)).astype(self.vals.dtype),
                 self.shape,
             )
 
@@ -2701,19 +2725,28 @@ class sptensor:
             nonUniqueOther = tt_intersect_rows(other.subs, self.subs)
             otherIdx = True * np.ones(other.subs.shape[0], dtype=bool)
             otherIdx[nonUniqueOther] = False
-            subs1 = np.concatenate((self.subs[selfIdx], other.subs[otherIdx]))
+            self_subs = np.empty(shape=(0, other.ndims), dtype=int)
+            if selfIdx.size > 0 and self.nnz > 0:
+                self_subs = self.subs[selfIdx]
+            other_subs = np.empty(shape=(0, other.ndims), dtype=int)
+            if otherIdx.size > 0 and other.nnz > 0:
+                other_subs = other.subs[otherIdx]
+            subs1 = np.concatenate((self_subs, other_subs))
             # subs1 = setxor(self.subs, other.subs,'rows')
             # find entries where both are nonzero, but inequal
-            subs2 = tt_intersect_rows(self.subs, other.subs)
-            subs_pad = np.zeros((self.subs.shape[0],)).astype(bool)
-            subs_pad[subs2] = (
-                self.extract(self.subs[subs2]) != other.extract(self.subs[subs2])
-            ).transpose()[0]
-            subs2 = self.subs[subs_pad, :]
+            subs2 = np.empty(shape=(0, other.ndims), dtype=int)
+            if self.nnz != 0 and other.nnz != 0:
+                subs2 = tt_intersect_rows(self.subs, other.subs)
+                subs_pad = np.zeros((self.subs.shape[0],)).astype(bool)
+                subs_pad[subs2] = (
+                    self.extract(self.subs[subs2]) != other.extract(self.subs[subs2])
+                ).transpose()[0]
+                subs2 = self.subs[subs_pad, :]
             # put it all together
             return ttb.sptensor(
                 np.vstack((subs1, subs2)),
-                True * np.ones((subs1.shape[0] + subs2.shape[0], 1)).astype(bool),
+                True
+                * np.ones((subs1.shape[0] + subs2.shape[0], 1)).astype(self.vals.dtype),
                 self.shape,
             )
 
@@ -2727,15 +2760,18 @@ class sptensor:
                 subs1Idx = tt_setdiff_rows(self.allsubs(), unionSubs)
                 subs1 = self.allsubs()[subs1Idx]
             else:
-                subs1 = np.empty((0, self.subs.shape[1]))
+                subs1 = np.empty((0, self.ndims))
             # find entries where x is nonzero but not equal to y
-            subs2 = self.subs[self.vals.transpose()[0] != other[self.subs], :]
+            subs2 = np.empty((0, self.ndims))
+            if self.nnz > 0:
+                subs2 = self.subs[self.vals.transpose()[0] != other[self.subs], :]
             if subs2.size == 0:
-                subs2 = np.empty((0, self.subs.shape[1]))
+                subs2 = np.empty((0, self.ndims))
             # put it all together
             return ttb.sptensor(
                 np.vstack((subs1, subs2)),
-                True * np.ones((subs1.shape[0] + subs2.shape[0], 1)).astype(bool),
+                True
+                * np.ones((subs1.shape[0] + subs2.shape[0], 1)).astype(self.vals.dtype),
                 self.shape,
             )
 
@@ -2781,6 +2817,10 @@ class sptensor:
         if not isinstance(other, ttb.sptensor) or self.shape != other.shape:
             assert False, "Must be two sparse tensors of the same shape"
 
+        if self.nnz == 0:
+            return -other.copy()
+        elif other.nnz == 0:
+            return self.copy()
         return ttb.sptensor.from_aggregator(
             np.vstack((self.subs, other.subs)),
             np.vstack((self.vals, -1 * other.vals)),
@@ -2951,39 +2991,30 @@ class sptensor:
             return self.__mul__(other)
         assert False, "This object cannot be multiplied by sptensor"
 
-    def __le__(self, other):  # noqa: PLR0912
-        """
-        Less than or equal operator (<=).
+    def _compare(self, other, operator, opposite_operator, include_zero=False):  # noqa: PLR0912
+        """Generalized Comparison operation
 
         Parameters
         ----------
-        other:
-            Oject to compare with.
-
-        Examples
-        --------
-        Compare a :class:`pyttb.sptensor` with itself:
-
-        >>> S = ttb.sptensor(shape=(2, 2))
-        >>> S[1, 1] = 1.0
-        >>> S <= S
-        sparse tensor of shape (2, 2) with 4 nonzeros
-        [1, 1] = 1.0
-        [0, 0] = 1.0
-        [0, 1] = 1.0
-        [1, 0] = 1.0
-
-        Compare with a scalar:
-
-        >>> S <= -1
-        empty sparse tensor of shape (2, 2)
-        """
-        # TODO le,lt,ge,gt have a lot of code duplication, look at generalizing them
-        #  for future maintainabilty
+        operator:
+            Primary comparison.
+        opposite_operator:
+            There is some symmetry around zero encoded in the logic.
+            This isn't just logical not.
+        include_zero:
+            Whether or not to treat matching zeros as true.
+        """
+        if operator not in (ge, gt, le, lt):
+            raise ValueError(
+                "Internal comparison operator called for unsupported operator"
+                f" {operator=}"
+            )
         # Case 1: One argument is a scalar
         if isinstance(other, (float, int)):
-            subs1 = self.subs[(self.vals <= other).transpose()[0], :]
-            if other >= 0:
+            subs1 = np.empty(shape=(0, self.ndims), dtype=int)
+            if self.nnz > 0:
+                subs1 = self.subs[(operator(self.vals, other)).transpose()[0], :]
+            if opposite_operator(other, 0):
                 subs2 = self.allsubs()[tt_setdiff_rows(self.allsubs(), self.subs), :]
                 subs = np.vstack((subs1, subs2))
             else:
@@ -3004,45 +3035,70 @@ class sptensor:
             if self.subs.size > 0:
                 subs1 = self.subs[tt_setdiff_rows(self.subs, other.subs), :]
                 if subs1.size > 0:
-                    subs1 = subs1[(self.extract(subs1) < 0).transpose()[0], :]
+                    subs1 = subs1[
+                        np.logical_not(
+                            opposite_operator(self.extract(subs1), 0)
+                        ).transpose()[0],
+                        :,
+                    ]
             else:
-                subs1 = np.empty(shape=(0, other.subs.shape[1]))
+                subs1 = np.empty(shape=(0, other.ndims), dtype=int)
 
             # self zero, other not zero
             if other.subs.size > 0:
                 subs2 = other.subs[tt_setdiff_rows(other.subs, self.subs), :]
                 if subs2.size > 0:
-                    subs2 = subs2[(other.extract(subs2) > 0).transpose()[0], :]
+                    subs2 = subs2[
+                        np.logical_not(operator(other.extract(subs2), 0)).transpose()[
+                            0
+                        ],
+                        :,
+                    ]
             else:
-                subs2 = np.empty(shape=(0, self.subs.shape[1]))
+                subs2 = np.empty(shape=(0, self.ndims), dtype=int)
 
             # self and other not zero
             if self.subs.size > 0:
                 subs3 = self.subs[tt_intersect_rows(self.subs, other.subs), :]
                 if subs3.size > 0:
                     subs3 = subs3[
-                        (self.extract(subs3) <= other.extract(subs3)).transpose()[0], :
+                        operator(self.extract(subs3), other.extract(subs3)).transpose()[
+                            0
+                        ],
+                        :,
                     ]
             else:
-                subs3 = np.empty(shape=(0, other.subs.shape[1]))
-
-            # self and other zero
-            xzerosubs = self.allsubs()[tt_setdiff_rows(self.allsubs(), self.subs), :]
-            yzerosubs = other.allsubs()[tt_setdiff_rows(other.allsubs(), other.subs), :]
-            subs4 = xzerosubs[tt_intersect_rows(xzerosubs, yzerosubs), :]
-
-            # assemble
-            subs = np.vstack((subs1, subs2, subs3, subs4))
+                subs3 = np.empty(shape=(0, other.ndims), dtype=int)
+
+            if include_zero:
+                # self and other zero
+                xzerosubs = self.allsubs()[
+                    tt_setdiff_rows(self.allsubs(), self.subs), :
+                ]
+                yzerosubs = other.allsubs()[
+                    tt_setdiff_rows(other.allsubs(), other.subs), :
+                ]
+                subs4 = xzerosubs[tt_intersect_rows(xzerosubs, yzerosubs), :]
+
+                # assemble
+                subs = np.vstack((subs1, subs2, subs3, subs4), dtype=int)
+            else:
+                # assemble
+                subs = np.vstack((subs1, subs2, subs3), dtype=int)
             return ttb.sptensor(subs, True * np.ones((len(subs), 1)), self.shape)
 
         # Case 2b: One dense tensor
         if isinstance(other, ttb.tensor):
             # self zero
-            subs1, _ = (other >= 0).find()
+            subs1, _ = opposite_operator(other, 0).find()
             subs1 = subs1[tt_setdiff_rows(subs1, self.subs), :]
 
             # self nonzero
-            subs2 = self.subs[self.vals.transpose()[0] <= other[self.subs], :]
+            subs2 = np.empty(shape=(0, self.ndims), dtype=int)
+            if self.nnz > 0:
+                subs2 = self.subs[
+                    operator(self.vals.transpose()[0], other[self.subs]), :
+                ]
 
             # assemble
             subs = np.vstack((subs1, subs2))
@@ -3051,7 +3107,36 @@ class sptensor:
         # Otherwise
         assert False, "Comparison allowed with sptensor, tensor, or scalar only."
 
-    def __lt__(self, other):  # noqa: PLR0912
+    def __le__(self, other):
+        """
+        Less than or equal operator (<=).
+
+        Parameters
+        ----------
+        other:
+            Oject to compare with.
+
+        Examples
+        --------
+        Compare a :class:`pyttb.sptensor` with itself:
+
+        >>> S = ttb.sptensor(shape=(2, 2))
+        >>> S[1, 1] = 1.0
+        >>> S <= S
+        sparse tensor of shape (2, 2) with 4 nonzeros
+        [1, 1] = 1.0
+        [0, 0] = 1.0
+        [0, 1] = 1.0
+        [1, 0] = 1.0
+
+        Compare with a scalar:
+
+        >>> S <= -1
+        empty sparse tensor of shape (2, 2)
+        """
+        return self._compare(other, le, ge, True)
+
+    def __lt__(self, other):
         """
         Less than operator (<).
 
@@ -3077,71 +3162,7 @@ class sptensor:
         [0, 1] = 1.0
         [1, 0] = 1.0
         """
-        # Case 1: One argument is a scalar
-        if isinstance(other, (float, int)):
-            subs1 = self.subs[(self.vals < other).transpose()[0], :]
-            if other > 0:
-                subs2 = self.allsubs()[tt_setdiff_rows(self.allsubs(), self.subs), :]
-                subs = np.vstack((subs1, subs2))
-            else:
-                subs = subs1
-            return ttb.sptensor(subs, True * np.ones((len(subs), 1)), self.shape)
-
-        # Case 2: Both x and y are tensors of some sort
-        # Check that the sizes match
-        if (
-            isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor))
-            and self.shape != other.shape
-        ):
-            assert False, "Size mismatch"
-
-        # Case 2a: Two sparse tensors
-        if isinstance(other, ttb.sptensor):
-            # self not zero, other zero
-            if self.subs.size > 0:
-                subs1 = self.subs[tt_setdiff_rows(self.subs, other.subs), :]
-                if subs1.size > 0:
-                    subs1 = subs1[(self.extract(subs1) < 0).transpose()[0], :]
-            else:
-                subs1 = np.empty(shape=(0, other.subs.shape[1]))
-
-            # self zero, other not zero
-            if other.subs.size > 0:
-                subs2 = other.subs[tt_setdiff_rows(other.subs, self.subs), :]
-                if subs2.size > 0:
-                    subs2 = subs2[(other.extract(subs2) > 0).transpose()[0], :]
-            else:
-                subs2 = np.empty(shape=(0, self.subs.shape[1]))
-
-            # self and other not zero
-            if self.subs.size > 0:
-                subs3 = self.subs[tt_intersect_rows(self.subs, other.subs), :]
-                if subs3.size > 0:
-                    subs3 = subs3[
-                        (self.extract(subs3) < other.extract(subs3)).transpose()[0], :
-                    ]
-            else:
-                subs3 = np.empty(shape=(0, other.subs.shape[1]))
-
-            # assemble
-            subs = np.vstack((subs1, subs2, subs3))
-            return ttb.sptensor(subs, True * np.ones((len(subs), 1)), self.shape)
-
-        # Case 2b: One dense tensor
-        if isinstance(other, ttb.tensor):
-            # self zero
-            subs1, _ = (other > 0).find()
-            subs1 = subs1[tt_setdiff_rows(subs1, self.subs), :]
-
-            # self nonzero
-            subs2 = self.subs[self.vals.transpose()[0] < other[self.subs], :]
-
-            # assemble
-            subs = np.vstack((subs1, subs2))
-            return ttb.sptensor(subs, True * np.ones((len(subs), 1)), self.shape)
-
-        # Otherwise
-        assert False, "Comparison allowed with sptensor, tensor, or scalar only."
+        return self._compare(other, lt, gt)
 
     def __ge__(self, other):
         """
@@ -3171,49 +3192,7 @@ class sptensor:
         sparse tensor of shape (2, 2) with 1 nonzeros
         [1, 1] = 1.0
         """
-        # Case 1: Argument is a scalar
-        if isinstance(other, (float, int)):
-            subs1 = self.subs[(self.vals >= other).transpose()[0], :]
-            if other <= 0:
-                subs2 = tt_setdiff_rows(self.allsubs(), self.subs)
-                subs = np.vstack((subs1, self.allsubs()[subs2]))
-            else:
-                subs = subs1
-            return ttb.sptensor(subs, True * np.ones((len(subs), 1)), self.shape)
-
-        # Case 2: Both x and y are tensors of some sort
-        # Check that the sizes match
-        if (
-            isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor))
-            and self.shape != other.shape
-        ):
-            assert False, "Size mismatch"
-
-        # Case 2a: Two sparse tensors
-        if isinstance(other, ttb.sptensor):
-            return other.__le__(self)
-
-        # Case 2b: One dense tensor
-        if isinstance(other, ttb.tensor):
-            # self zero
-            subs1, _ = (other <= 0).find()
-            subs1 = subs1[tt_setdiff_rows(subs1, self.subs), :]
-
-            # self nonzero
-            subs2 = self.subs[
-                (self.vals >= other[self.subs][:, None]).transpose()[0],
-                :,
-            ]
-
-            # assemble
-            return ttb.sptensor(
-                np.vstack((subs1, subs2)),
-                True * np.ones((len(subs1) + len(subs2), 1)),
-                self.shape,
-            )
-
-        # Otherwise
-        assert False, "Comparison allowed with sptensor, tensor, or scalar only."
+        return self._compare(other, ge, le, True)
 
     def __gt__(self, other):
         """
@@ -3239,50 +3218,7 @@ class sptensor:
         sparse tensor of shape (2, 2) with 1 nonzeros
         [1, 1] = 1.0
         """
-        # Case 1: Argument is a scalar
-        if isinstance(other, (float, int)):
-            subs1 = self.subs[(self.vals > other).transpose()[0], :]
-            if other < 0:
-                subs2 = tt_setdiff_rows(self.allsubs(), self.subs)
-                subs = np.vstack((subs1, self.allsubs()[subs2]))
-            else:
-                subs = subs1
-            return ttb.sptensor(subs, True * np.ones((len(subs), 1)), self.shape)
-
-        # Case 2: Both x and y are tensors of some sort
-        # Check that the sizes match
-        if (
-            isinstance(other, (ttb.sptensor, ttb.tensor, ttb.ktensor))
-            and self.shape != other.shape
-        ):
-            assert False, "Size mismatch"
-
-        # Case 2a: Two sparse tensors
-        if isinstance(other, ttb.sptensor):
-            return other.__lt__(self)
-
-        # Case 2b: One dense tensor
-        if isinstance(other, ttb.tensor):
-            # self zero and other < 0
-            subs1, _ = (other < 0).find()
-            if subs1.size > 0:
-                subs1 = subs1[tt_setdiff_rows(subs1, self.subs), :]
-
-            # self and other nonzero
-            subs2 = self.subs[
-                (self.vals > other[self.subs][:, None]).transpose()[0],
-                :,
-            ]
-
-            # assemble
-            return ttb.sptensor(
-                np.vstack((subs1, subs2)),
-                True * np.ones((len(subs1) + len(subs2), 1)),
-                self.shape,
-            )
-
-        # Otherwise
-        assert False, "Comparison allowed with sptensor, tensor, or scalar only."
+        return self._compare(other, gt, lt)
 
     def __truediv__(self, other):  # noqa: PLR0912, PLR0915
         """


Test patch:
diff --git a/tests/test_sptensor.py b/tests/test_sptensor.py
index 10a5d7f..72360bf 100644
--- a/tests/test_sptensor.py
+++ b/tests/test_sptensor.py
@@ -57,6 +57,13 @@ def test_sptensor_initialization_from_data(sample_sptensor):
     with pytest.raises(ValueError):
         ttb.sptensor(vals=data["vals"])
 
+    # Make sure an explicit empty tensor matches an implicit one
+    explicitEmptySptensor = ttb.sptensor(np.array([]), np.array([]), data["shape"])
+    implicitEmptySptensor = ttb.sptensor(shape=data["shape"])
+    assert np.array_equal(explicitEmptySptensor.subs, implicitEmptySptensor.subs)
+    assert np.array_equal(explicitEmptySptensor.vals, implicitEmptySptensor.vals)
+    assert explicitEmptySptensor.shape == implicitEmptySptensor.shape
+
     with pytest.raises(AssertionError):
         shape = (3, 3, 1)
         invalid_subs = np.array([[1, 1, 1], [1, 3, 2], [2, 2, 2]])
@@ -665,6 +672,9 @@ def test_sptensor_allsubs(sample_sptensor):
                 result.append([i, j, k])
     assert np.array_equal(sptensorInstance.allsubs(), np.array(result))
 
+    empty_sptensor = ttb.sptensor()
+    assert empty_sptensor.allsubs().size == 0
+
 
 def test_sptensor_logical_not(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
@@ -721,7 +731,6 @@ def test_sptensor_logical_or(sample_sptensor):
 
 
 def test_sptensor__eq__(sample_sptensor):
-    # TODO fix == against empty sptensor
     (data, sptensorInstance) = sample_sptensor
 
     eqSptensor = sptensorInstance == 0.0
@@ -759,6 +768,26 @@ def test_sptensor__eq__(sample_sptensor):
         sptensorInstance == np.ones((4, 4, 4))
     assert "Comparison allowed with sptensor, tensor, or scalar only." in str(excinfo)
 
+    # Test comparisons of empty sptensors
+    empty_sptensor = ttb.sptensor()
+    cmp = empty_sptensor == empty_sptensor
+    # With no shape there is no size so must remain empty
+    assert cmp.isequal(empty_sptensor)
+
+    empty_sptensor = ttb.sptensor(shape=(2, 2))
+    cmp = empty_sptensor == empty_sptensor
+    # With a shape then all implicit zeros match
+    assert cmp.isequal(empty_sptensor.logical_not())
+
+    cmp = empty_sptensor == empty_sptensor.to_tensor()
+    # With a shape then all implicit zeros match
+    assert cmp.isequal(empty_sptensor.logical_not())
+
+    cmp = empty_sptensor == 0.0
+    assert cmp.isequal(empty_sptensor.logical_not())
+    cmp = empty_sptensor == 1.0
+    assert cmp.isequal(empty_sptensor)
+
 
 def test_sptensor__ne__(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
@@ -803,6 +832,26 @@ def test_sptensor__ne__(sample_sptensor):
         sptensorInstance != np.ones((4, 4, 4))
     assert "Comparison allowed with sptensor, tensor, or scalar only." in str(excinfo)
 
+    # Test comparisons of empty sptensors
+    empty_sptensor = ttb.sptensor()
+    cmp = empty_sptensor != empty_sptensor
+    # With no shape there is no size so must remain empty
+    assert cmp.isequal(empty_sptensor)
+
+    empty_sptensor = ttb.sptensor(shape=(2, 2))
+    cmp = empty_sptensor != empty_sptensor
+    # With a shape then all implicit zeros match
+    assert cmp.isequal(empty_sptensor)
+
+    cmp = empty_sptensor != empty_sptensor.to_tensor()
+    # With a shape then all implicit zeros match
+    assert cmp.isequal(empty_sptensor)
+
+    cmp = empty_sptensor != 0.0
+    assert cmp.isequal(empty_sptensor)
+    cmp = empty_sptensor != 1.0
+    assert cmp.isequal(empty_sptensor.logical_not())
+
 
 def test_sptensor__find(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
@@ -831,6 +880,11 @@ def test_sptensor__sub__(sample_sptensor):
     subSptensor = sptensorInstance - 0
     assert np.array_equal(subSptensor.data, sptensorInstance.to_tensor().data)
 
+    # Sptensor - empty
+    assert sptensorInstance.isequal(
+        sptensorInstance - ttb.sptensor(shape=sptensorInstance.shape)
+    )
+
 
 def test_sptensor__add__(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
@@ -871,6 +925,12 @@ def test_sptensor_isequal(sample_sptensor):
     # Sptensor equality with not sptensor or tensor
     assert not sptensorInstance.isequal(np.ones(data["shape"]))
 
+    # Empty tensor
+    empty_tensor = ttb.sptensor(shape=(2, 2))
+    not_empty_tensor = empty_tensor.copy()
+    not_empty_tensor[0, 0] = 1
+    assert not empty_tensor.isequal(not_empty_tensor)
+
 
 def test_sptensor__pos__(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
@@ -968,6 +1028,15 @@ def test_sptensor_double(sample_sptensor):
     assert sptensorInstance.double().shape == data["shape"]
 
 
+def test_sptensor_compare(sample_sptensor):
+    # This is kind of a test just for coverage sake
+    # mostly make clear that the operator check was intentional
+    empty_sptensor = ttb.sptensor()
+    arbitrary_value = 1
+    with pytest.raises(ValueError):
+        empty_sptensor._compare(arbitrary_value, "bad_operator", "bad_operator")
+
+
 def test_sptensor__le__(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
 
@@ -1009,6 +1078,26 @@ def test_sptensor__le__(sample_sptensor):
         sptensorInstance <= "string"
     assert "Comparison allowed with sptensor, tensor, or scalar only." in str(excinfo)
 
+    # Test comparisons of empty sptensors
+    empty_sptensor = ttb.sptensor()
+    cmp = empty_sptensor <= empty_sptensor
+    # With no shape there is no size so must remain empty
+    assert cmp.isequal(empty_sptensor)
+
+    empty_sptensor = ttb.sptensor(shape=(2, 2))
+    cmp = empty_sptensor <= empty_sptensor
+    # With a shape then all implicit zeros match
+    assert cmp.isequal(empty_sptensor.logical_not())
+
+    cmp = empty_sptensor <= empty_sptensor.to_tensor()
+    # With a shape then all implicit zeros match
+    assert cmp.isequal(empty_sptensor.logical_not())
+
+    cmp = empty_sptensor <= 0.0
+    assert cmp.isequal(empty_sptensor.logical_not())
+    cmp = empty_sptensor <= -1.0
+    assert cmp.isequal(empty_sptensor)
+
 
 def test_sptensor__ge__(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
@@ -1042,6 +1131,26 @@ def test_sptensor__ge__(sample_sptensor):
         sptensorInstance >= "string"
     assert "Comparison allowed with sptensor, tensor, or scalar only." in str(excinfo)
 
+    # Test comparisons of empty sptensors
+    empty_sptensor = ttb.sptensor()
+    cmp = empty_sptensor >= empty_sptensor
+    # With no shape there is no size so must remain empty
+    assert cmp.isequal(empty_sptensor)
+
+    empty_sptensor = ttb.sptensor(shape=(2, 2))
+    cmp = empty_sptensor >= empty_sptensor
+    # With a shape then all implicit zeros match
+    assert cmp.isequal(empty_sptensor.logical_not())
+
+    cmp = empty_sptensor >= empty_sptensor.to_tensor()
+    # With a shape then all implicit zeros match
+    assert cmp.isequal(empty_sptensor.logical_not())
+
+    cmp = empty_sptensor >= 0.0
+    assert cmp.isequal(empty_sptensor.logical_not())
+    cmp = empty_sptensor >= 1.0
+    assert cmp.isequal(empty_sptensor)
+
 
 def test_sptensor__gt__(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
@@ -1076,6 +1185,23 @@ def test_sptensor__gt__(sample_sptensor):
         sptensorInstance > "string"
     assert "Comparison allowed with sptensor, tensor, or scalar only." in str(excinfo)
 
+    # Test comparisons of empty sptensors
+    empty_sptensor = ttb.sptensor()
+    cmp = empty_sptensor > empty_sptensor
+    assert cmp.isequal(empty_sptensor)
+
+    empty_sptensor = ttb.sptensor(shape=(2, 2))
+    cmp = empty_sptensor > empty_sptensor
+    assert cmp.isequal(empty_sptensor)
+
+    cmp = empty_sptensor > empty_sptensor.to_tensor()
+    assert cmp.isequal(empty_sptensor)
+
+    cmp = empty_sptensor > -1.0
+    assert cmp.isequal(empty_sptensor.logical_not())
+    cmp = empty_sptensor > 1.0
+    assert cmp.isequal(empty_sptensor)
+
 
 def test_sptensor__lt__(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor
@@ -1108,6 +1234,24 @@ def test_sptensor__lt__(sample_sptensor):
         sptensorInstance < "string"
     assert "Comparison allowed with sptensor, tensor, or scalar only." in str(excinfo)
 
+    # Test comparisons of empty sptensors
+    empty_sptensor = ttb.sptensor()
+    cmp = empty_sptensor < empty_sptensor
+    assert cmp.isequal(empty_sptensor)
+
+    empty_sptensor = ttb.sptensor(shape=(2, 2))
+    cmp = empty_sptensor < empty_sptensor
+    assert cmp.isequal(empty_sptensor)
+
+    empty_sptensor = ttb.sptensor(shape=(2, 2))
+    cmp = empty_sptensor < empty_sptensor.to_tensor()
+    assert cmp.isequal(empty_sptensor)
+
+    cmp = empty_sptensor < 1.0
+    assert cmp.isequal(empty_sptensor.logical_not())
+    cmp = empty_sptensor < -1.0
+    assert cmp.isequal(empty_sptensor)
+
 
 def test_sptensor_innerprod(sample_sptensor):
     (data, sptensorInstance) = sample_sptensor