Repository: pydata/sparse (python)

Issue:
Bug: Complex conjugate makes matrices incompatible
### sparse version checks

- [x] I checked that this issue has not been reported before [list of issues](https://github.com/pydata/sparse/issues).

- [x] I have confirmed this bug exists on the latest version of sparse.

- [x] I have confirmed this bug exists on the main branch of sparse.


### Describe the bug

When I take the conjugate transpose of a complex sparse matrix it becomes unusable because of the changed fill_value `0j -> -0j` (i.e. I cannot multiply other matrices with it)

### Steps or code to reproduce the bug

Considering a minimal example with the Pauli-Y matrix and its conjugate:

```python
import numpy as np
import sparse

Y = np.array([[0, -1j], [+1j, 0]])
Ysp = sparse.COO.from_numpy(Y)
print(Ysp.shape, Ysp.dtype, Ysp.fill_value)
# ((2, 2), dtype('complex128'), np.complex128(0j))
print(Ysp @ Ysp) # works fine
print(Ysp.conj().T @ Y) # <---- this gives error
print(Ysp.conj().T @ Ysp) # <---- also error
print(Ysp.conj().T @ Ysp.conj().T) # <---- also error
```
One can fix the error by manually overriding the `fill_value` property. But this seems like a hack.

### Expected results

Ysp.conj().T @ Y  should give 2x2 identity with dtype=complex128

### Actual results

Error message:

> ---------------------------------------------------------------------------
> ValueError                                Traceback (most recent call last)
> Cell In[271], line 1
> ----> 1 Ysp.conj().T @ Y
>       2 Ysp.conj().T @ Ysp.conj().T
>       3 # Ysp @ Ysp
> 
> File [.../.venv/lib/python3.13/site-packages/sparse/numba_backend/_coo/core.py:962](.../.venv/lib/python3.13/site-packages/sparse/numba_backend/_coo/core.py:962), in COO.__matmul__(self, other)
>     959 from .._common import matmul
>     961 try:
> --> 962     return matmul(self, other)
>     963 except NotImplementedError:
>     964     return NotImplemented
> 
> File [.../.venv/lib/python3.13/site-packages/sparse/numba_backend/_common.py:241](.../.venv/lib/python3.13/site-packages/sparse/numba_backend/_common.py:241), in matmul(a, b)
>     218 def matmul(a, b):
>     219     """Perform the equivalent of [`numpy.matmul`][] on two arrays.
>     220 
>     221     Parameters
>    (...)    239     - `COO.__matmul__`: Equivalent function for COO objects.
>     240     """
> --> 241     check_zero_fill_value(a, b)
>     242     if not hasattr(a, "ndim") or not hasattr(b, "ndim"):
>     243         raise TypeError(f"Cannot perform dot product on types {type(a)}, {type(b)}")
> 
> File [.../.venv/lib/python3.13/site-packages/sparse/numba_backend/_utils.py:592](.../.venv/lib/python3.13/site-packages/sparse/numba_backend/_utils.py:592), in check_zero_fill_value(*args)
>     590 for i, arg in enumerate(args):
>     591     if hasattr(arg, "fill_value") and not equivalent(arg.fill_value, _zero_of_dtype(arg.dtype)):
> --> 592         raise ValueError(
>     593             f"This operation requires zero fill values, but argument {i:d} had a fill value of {arg.fill_value!s}."
>     594         )
> 
> ValueError: This operation requires zero fill values, but argument 0 had a fill value of -0j.

### Please describe your system.

1. OS and version: Centos Rocky Linux 9.5 (Blue Onyx)
2. sparse version `sparse.__version__ = 0.16.0`
3. NumPy version `np.__version__  = 2.2.4`
4. Numba version `numba.__version__ = 0.61.2`


### Relevant log output

```shell

```

Pull request description:
fix: Fix for `check_zero_fill_value` and `equivalent`
<!--
# Thanks for contributing a pull request!
## Please make sure you see our contribution guidelines: https://github.com/pydata/sparse/blob/main/docs/contributing.md

Your PR title should start with any of these abbreviatons: `build`, `chore`, `ci`, `depr`, `docs`, `feat`, `fix`, `perf`, `refactor`, `release`, `test`. Add a `!`at the end, if it is a breaking change.
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] 🪄 Feature
- [x] 🐞 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📚 Documentation
- [ ] 🧪 Test
- [ ] 🛠️ Other

## Related issues

- Closes #869 

## Checklist

- [x] Code follows style guide
- [x] Tests added
- [ ] Documented the changes

***

## Please explain your changes below.
Fixes an issue with complex dtypes and `loose=True`.
Use loose comparisons for `check_zero_fill_value`.


Patch:
diff --git a/sparse/numba_backend/_utils.py b/sparse/numba_backend/_utils.py
index fc685a9..6fb1479 100644
--- a/sparse/numba_backend/_utils.py
+++ b/sparse/numba_backend/_utils.py
@@ -440,7 +440,7 @@ def equivalent(x, y, /, loose=False):
 
     if loose:
         if np.issubdtype(dt, np.complexfloating):
-            return equivalent(x.real, y.real) & equivalent(x.imag, y.imag)
+            return equivalent(x.real, y.real, loose=True) & equivalent(x.imag, y.imag, loose=True)
 
         # TODO: Rec array handling
         return (x == y) | ((x != x) & (y != y))
@@ -559,7 +559,7 @@ def check_fill_value(x, /, *, accept_fv=None) -> None:
         raise ValueError(f"{x.fill_value=} but should be in {accept_fv}.")
 
 
-def check_zero_fill_value(*args):
+def check_zero_fill_value(*args, loose=True):
     """
     Checks if all the arguments have zero fill-values.
 
@@ -588,7 +588,7 @@ def check_zero_fill_value(*args):
     ValueError: This operation requires zero fill values, but argument 1 had a fill value of 0.5.
     """
     for i, arg in enumerate(args):
-        if hasattr(arg, "fill_value") and not equivalent(arg.fill_value, _zero_of_dtype(arg.dtype)):
+        if hasattr(arg, "fill_value") and not equivalent(arg.fill_value, _zero_of_dtype(arg.dtype), loose=loose):
             raise ValueError(
                 f"This operation requires zero fill values, but argument {i:d} had a fill value of {arg.fill_value!s}."
             )


Test patch:
diff --git a/sparse/numba_backend/tests/test_coo.py b/sparse/numba_backend/tests/test_coo.py
index 8297563..fef578f 100644
--- a/sparse/numba_backend/tests/test_coo.py
+++ b/sparse/numba_backend/tests/test_coo.py
@@ -1919,3 +1919,13 @@ def test_to_invalid_device():
     s = sparse.random((5, 5), density=0.5)
     with pytest.raises(ValueError, match=r"Only .* is supported."):
         s.to_device("invalid_device")
+
+
+# regression test for gh-869
+def test_xH_x():
+    Y = np.array([[0, -1j], [+1j, 0]])
+    Ysp = COO.from_numpy(Y)
+
+    assert_eq(Ysp.conj().T @ Y, Y.conj().T @ Y)
+    assert_eq(Ysp.conj().T @ Ysp, Y.conj().T @ Y)
+    assert_eq(Y.conj().T @ Ysp.conj().T, Y.conj().T @ Y.conj().T)