Skip to content

Commit be75d71

Browse files
Fix #880: return simplified QubitOperator from symmetry_conserving_bravyi_kitaev (#1440)
Closes #880. ## Problem `get_sparse_operator` raises on the operator produced by `symmetry_conserving_bravyi_kitaev`: ```python from openfermion import get_sparse_operator, FermionOperator, symmetry_conserving_bravyi_kitaev fermion_op = FermionOperator("0^ 1^") qubit_op = symmetry_conserving_bravyi_kitaev(fermion_op, 4, 2) get_sparse_operator(qubit_op) # ValueError: axis 0 index 7 exceeds matrix dimension 4 ``` ## Root cause `symmetry_conserving_bravyi_kitaev` finishes by calling `remove_indices`, which shifts qubit indices. When two qubits are mapped onto the **same** new index, a term ends up with multiple Paulis acting on one qubit, e.g.: ``` ((0, X), (1, Y), (1, X)) ``` `remove_indices` writes these terms straight into the operator’s `.terms` dict, so they bypass the simplification `QubitOperator` normally performs on construction. `qubit_operator_sparse` assumes each qubit appears at most once per term (it grows the tensor product one factor per Pauli), so a repeated qubit makes the per-term matrix larger than the `n_qubits` Hilbert space and the assembly raises. ## Fix Rebuild the operator after `remove_indices` so every term is routed back through `QubitOperator`’s simplification, restoring canonical one-Pauli-per-qubit form (e.g. `((0, X), (1, Y), (1, X)) → ((0, X), (1, Z))`). This keeps the operator mathematically identical — the existing eigenspectrum-based tests still pass — while satisfying the invariant that `QubitOperator`s are simplified, which `get_sparse_operator` and other consumers rely on. ## Tests Added `test_output_is_simplified_qubit_operator`, a regression test built from the reporter's reproducer. It now asserts the transform equals an independently worked-out, fully-simplified `QubitOperator` for that input (rather than re-deriving the expectation from the output), checks every term is canonical (one Pauli per qubit), and checks `get_sparse_operator` no longer raises. The existing eigenspectrum-based tests continue to pass, confirming the operator is unchanged mathematically. Verified with the repo's check scripts: `check/pytest -m "not slow" src/openfermion/transforms/opconversions/remove_symmetry_qubits_test.py` passes, and `check/format-incremental` and `check/pylint-changed-files` are clean. `check/mypy` reports no issues in the changed module. --------- Co-authored-by: stark256-spec <stark256-spec@users.noreply.github.com>
1 parent 2871f09 commit be75d71

2 files changed

Lines changed: 49 additions & 5 deletions

File tree

src/openfermion/transforms/opconversions/remove_symmetry_qubits.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import copy
1818

19-
from openfermion.ops.operators import FermionOperator
19+
from openfermion.ops.operators import FermionOperator, QubitOperator
2020
from openfermion.transforms.opconversions.bravyi_kitaev_tree import bravyi_kitaev_tree
2121
from openfermion.transforms.opconversions.term_reordering import reorder
2222
from openfermion.utils.indexing import up_then_down
@@ -95,11 +95,21 @@ def symmetry_conserving_bravyi_kitaev(fermion_hamiltonian, active_orbitals, acti
9595
qubit_hamiltonian, active_orbitals, parity_final_orb
9696
)
9797
qubit_hamiltonian = edit_hamiltonian_for_spin(
98-
qubit_hamiltonian, active_orbitals / 2, parity_middle_orb
98+
qubit_hamiltonian, active_orbitals // 2, parity_middle_orb
9999
)
100-
qubit_hamiltonian = remove_indices(qubit_hamiltonian, (active_orbitals / 2, active_orbitals))
100+
qubit_hamiltonian = remove_indices(qubit_hamiltonian, (active_orbitals // 2, active_orbitals))
101+
102+
# remove_indices() shifts qubit indices and can map two qubits onto the
103+
# same index, producing terms with multiple Paulis acting on one qubit
104+
# (e.g. ((0, 'X'), (1, 'Y'), (1, 'X'))). Rebuilding the operator routes
105+
# each term back through QubitOperator's simplification, restoring the
106+
# canonical one-Pauli-per-qubit form that consumers such as
107+
# get_sparse_operator require. See issue #880.
108+
simplified_hamiltonian = QubitOperator()
109+
for term, coefficient in qubit_hamiltonian.terms.items():
110+
simplified_hamiltonian += QubitOperator(term, coefficient)
101111

102-
return qubit_hamiltonian
112+
return simplified_hamiltonian
103113

104114

105115
def edit_hamiltonian_for_spin(qubit_hamiltonian, spin_orbital, orbital_parity):

src/openfermion/transforms/opconversions/remove_symmetry_qubits_test.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import unittest
1818

19+
import numpy
1920
import pytest
2021

2122
from openfermion.hamiltonians import fermi_hubbard
@@ -26,7 +27,7 @@
2627
jw_get_ground_state_at_particle_number,
2728
)
2829
from openfermion.linalg import eigenspectrum
29-
from openfermion.ops.operators import FermionOperator
30+
from openfermion.ops.operators import FermionOperator, QubitOperator
3031

3132
from openfermion.transforms.opconversions.remove_symmetry_qubits import (
3233
symmetry_conserving_bravyi_kitaev,
@@ -152,3 +153,36 @@ def test_single_operator(self):
152153
e_trafo = eigenspectrum(trafo_op)
153154
# Check eigenvalues
154155
self.assertSequenceEqual(e_op.tolist(), e_trafo.tolist())
156+
157+
def test_output_is_simplified_qubit_operator(self):
158+
# Regression test for issue #880: symmetry_conserving_bravyi_kitaev
159+
# used to return QubitOperators with un-simplified terms (multiple
160+
# Paulis acting on the same qubit, e.g. ((0, 'X'), (1, 'Y'), (1, 'X'))
161+
# left behind when remove_indices maps two qubit indices onto one),
162+
# which made get_sparse_operator raise a ValueError.
163+
op = FermionOperator("0^ 1^")
164+
trafo_op = symmetry_conserving_bravyi_kitaev(op, active_orbitals=4, active_fermions=2)
165+
166+
# The result must equal the known, fully-simplified operator for this
167+
# input, worked out independently (rather than re-derived from
168+
# trafo_op, which would be a no-op now that the output is simplified).
169+
expected_op = (
170+
QubitOperator(((0, "X"), (1, "Z")), -0.25)
171+
+ QubitOperator(((0, "X"),), -0.25)
172+
+ QubitOperator(((0, "Y"), (1, "Z")), 0.25j)
173+
+ QubitOperator(((0, "Y"),), 0.25j)
174+
)
175+
self.assertEqual(trafo_op, expected_op)
176+
177+
# Every term is canonical: at most one Pauli per qubit.
178+
for term in trafo_op.terms:
179+
qubits = [qubit for qubit, _ in term]
180+
self.assertEqual(len(qubits), len(set(qubits)))
181+
182+
# get_sparse_operator must no longer raise on the result, and must
183+
# produce the sparse matrix of the independently-specified operator.
184+
self.assertTrue(
185+
numpy.allclose(
186+
get_sparse_operator(trafo_op).toarray(), get_sparse_operator(expected_op).toarray()
187+
)
188+
)

0 commit comments

Comments
 (0)