Repository: netket/netket

Issue:
Conjugation of fermionic operators is wrong
the implementation of conjugation of FermionOperators actually does a transpose-conjugation. 

```python
In [2]: from netket import experimental as nkx; import numpy as np

In [3]: hi=nkx.hilbert.SpinOrbitalFermions(2)

In [4]: c=nkx.operator.fermion.destroy(hi, 1)

In [5]: cd=nkx.operator.fermion.create(hi, 1)

In [6]: np.allclose(c.to_dense().conj(), c.conj().to_dense())
Out[6]: False

In [7]: c.to_dense()
Out[7]:
array([[ 0.,  1.,  0.,  0.],
       [ 0.,  0.,  0.,  0.],
       [ 0.,  0.,  0., -1.],
       [ 0.,  0.,  0.,  0.]])

In [8]: c.conj().to_dense()
Out[8]:
array([[ 0.,  0.,  0.,  0.],
       [ 1.,  0.,  0.,  0.],
       [ 0.,  0.,  0.,  0.],
       [ 0.,  0., -1.,  0.]])
```
Transpose/conjugatee of Fermionic operators can't be concretised
the methods equivalent to

```python
    def transpose(self, *, concrete=False):
        r"""LocalOperator: Returns the transpose of this operator."""
        if concrete:
            new = self.copy()
            for aon in new._operators_dict.keys():
                new._operators_dict[aon] = new._operators_dict[aon].transpose()
            return new
        else:
            return Transpose(self)

    def conjugate(self, *, concrete=False):
        r"""LocalOperator: Returns the complex conjugate of this operator."""
        new = self.copy()
        for aon in new._operators_dict.keys():
            new._operators_dict[aon] = new._operators_dict[aon].copy().conjugate()
        return new
```

should be defined


Discussion:

@PhilipVinc , conjugate is already implemented.
ah, ok. So only transpose is missing
or maybe this was the relevant blocking issue? 

Resolving patch:
diff --git a/netket/experimental/operator/_fermion_operator_2nd_base.py b/netket/experimental/operator/_fermion_operator_2nd_base.py
--- a/netket/experimental/operator/_fermion_operator_2nd_base.py
+++ b/netket/experimental/operator/_fermion_operator_2nd_base.py
@@ -19,9 +19,9 @@
 from numbers import Number
 
 from netket.utils.types import DType
-from netket.operator._discrete_operator import DiscreteOperator
+from netket.operator import DiscreteOperator, Transpose
 from netket.operator._pauli_strings.base import _count_of_locations
-from netket.hilbert.abstract_hilbert import AbstractHilbert
+from netket.hilbert import AbstractHilbert
 from netket.utils.numbers import is_scalar, dtype as _dtype
 from netket.utils.optional_deps import import_optional_dependency
 
@@ -31,7 +31,7 @@
     _convert_terms_to_spin_blocks,
     _canonicalize_input,
     _check_hermitian,
-    _herm_conj,
+    transpose_term,
     _remove_dict_zeros,
     _verify_input,
     OperatorDict,
@@ -267,10 +267,18 @@ def weights(self) -> OperatorWeightsList:
     def operators(self) -> OperatorDict:
         """Returns a dictionary with (term, weight) key-value pairs, with terms in tuple notation.
 
-        The constant diagonal shift of the operator is associated with a term represented as an empty tuple.
+        The constant diagonal shift of the operator is associated with a term represented as an
+        empty tuple.
         """
         return self._operators
 
+    @property
+    def cutoff(self) -> float:
+        """Threshold for the weights, if the absolute value of a weight is below the
+        cutoff, it's discarded.
+        """
+        return self._cutoff
+
     def copy(self, *, dtype: Optional[DType] = None, cutoff=None):
         """
         Creates a deep copy of this operator, potentially changing the dtype of the
@@ -458,23 +466,35 @@ def __mul__(self, scalar):
         op = self.copy(dtype=dtype)
         return op.__imul__(scalar)
 
+    def transpose(self, *, concrete=False):
+        r"""Returns the transpose of this operator.
+
+        For real operators, this is equivalent to complex conjugation.
+        This implementation always returns the concrete version of the operator.
+        """
+        if concrete:
+            new = type(self)(self.hilbert, dtype=self.dtype, cutoff=self._cutoff)
+            # operators (c,c†) are real already. Only conjugate coefficients if needed.
+            new._operators = {transpose_term(k): v for k, v in self._operators.items()}
+            return new
+        else:
+            return Transpose(self)
+
     def conjugate(self, *, concrete=False):
-        r"""Returns the complex conjugate of this operator."""
+        r"""Returns the complex conjugate of this operator.
 
-        terms = list(self._operators.keys())
-        weights = list(self._operators.values())
-        terms, weights = _herm_conj(terms, weights)  # changes also the terms
+        This implementation always returns the concrete version of the operator.
+        """
+        del concrete
 
-        cls = type(self)
-        new = cls(
-            self.hilbert,
-            dtype=self.dtype,
-        )
-        new._operators, _ = _canonicalize_input(
-            terms, weights, self.dtype, self._cutoff
-        )
-        new._cutoff = self._cutoff
-        return new
+        # if operator is real dtype, then just return a copy
+        if not np.issubdtype(self.dtype, np.complex_):
+            return self.copy()
+        else:
+            new = type(self)(self.hilbert, dtype=self.dtype, cutoff=self._cutoff)
+            # operators (c,c†) are real already. Only conjugate coefficients if needed.
+            new._operators = {k: np.conjugate(v) for k, v in self._operators.items()}
+            return new
 
     def to_normal_order(self):
         """Reoder the operators to normal order.
diff --git a/netket/experimental/operator/_fermion_operator_2nd_utils.py b/netket/experimental/operator/_fermion_operator_2nd_utils.py
--- a/netket/experimental/operator/_fermion_operator_2nd_utils.py
+++ b/netket/experimental/operator/_fermion_operator_2nd_utils.py
@@ -224,13 +224,29 @@ def _herm_conj(
     terms: OperatorTermsList, weights: OperatorWeightsList = 1
 ) -> tuple[OperatorTermsList, OperatorWeightsList]:
     """Returns the hermitian conjugate of the terms and weights."""
+    conj_term = transpose_terms(terms)
+    conj_weight = [np.conjugate(weight) for weight in weights]
+    return conj_term, conj_weight
+
+
+def transpose_terms(terms: OperatorTermsList) -> OperatorTermsList:
+    """Returns the transpose of the terms (creation/destruction operators).
+    The transpose is equivalent to the hermitian transpose because they are
+    real.
+    """
     conj_term = []
-    conj_weight = []
-    # loop over all terms and weights and get the hermitian conjugate
-    for term, weight in zip(terms, weights):
+    for term in terms:
         conj_term.append([(op, 1 - int(dag)) for (op, dag) in reversed(term)])
-        conj_weight.append(np.conjugate(weight))
-    return conj_term, conj_weight
+    return conj_term
+
+
+def transpose_term(term: OperatorTerm) -> OperatorTerm:
+    """Returns the transpose of a single term (creation/destruction operators).
+    The transpose is equivalent to the hermitian transpose because they are
+    real.
+    """
+    # equivalent to transpose_terms
+    return tuple((op, 1 - int(dag)) for (op, dag) in reversed(term))
 
 
 def _convert_terms_to_spin_blocks(


Test patch:
diff --git a/test/operator/test_fermions.py b/test/operator/test_fermions.py
--- a/test/operator/test_fermions.py
+++ b/test/operator/test_fermions.py
@@ -310,12 +310,31 @@ def test_operations_fermions(Op):
     op67 += op7
     np.testing.assert_allclose((op6 + op7).to_dense(), op8.to_dense())
     np.testing.assert_allclose(op67.to_dense(), op8.to_dense())
+    np.testing.assert_allclose(
+        op67.transpose(concrete=True).to_dense(), op8.to_dense().transpose()
+    )
+    np.testing.assert_allclose(
+        op67.conjugate(concrete=True).to_dense(), op8.to_dense().conjugate()
+    )
+    np.testing.assert_allclose(
+        op67.H.collect().to_dense(), op8.to_dense().conjugate().transpose()
+    )
+    np.testing.assert_allclose(
+        op67.conjugate(concrete=True).transpose(concrete=True).to_dense(),
+        op8.to_dense().conjugate().transpose(),
+    )
 
     op8 = Op(hi, terms=("0^ 1", "2^ 3"), weights=(1 + 1j, 2 - 0.5j), constant=1.0 + 3j)
     op8_trueconj = Op(
         hi, terms=("1^ 0", "3^ 2"), weights=(1 - 1j, 2 + 0.5j), constant=1.0 - 3j
     )
-    np.testing.assert_allclose(op8.conjugate().to_dense(), op8_trueconj.to_dense())
+    np.testing.assert_allclose(
+        op8.transpose(concrete=False).conjugate().to_dense(), op8_trueconj.to_dense()
+    )
+    np.testing.assert_allclose(
+        op8.transpose(concrete=True).conjugate().to_dense(), op8_trueconj.to_dense()
+    )
+    np.testing.assert_allclose(op8.H.collect().to_dense(), op8_trueconj.to_dense())
 
     op9 = nkx.operator.FermionOperator2nd(
         hi, terms=("",), weights=(1,), constant=2, dtype=complex