from qiskit.quantum_info import Pauli, SparsePauliOp
from qiskit_nature.second_q.operators import FermionicOp
from qiskit_nature.second_q.mappers.fermionic_mapper import FermionicMapper
from itertools import combinations, permutations
from functools import reduce
import math
from tqdm import tqdm
from .majoranaop import MajoranaOp
from .mode_based_mapper import ModeBasedMapper
def _walk_string(
i: int, mapping: dict[int, tuple[str, int]], nqubits: int, nstrings: int
):
string = ["I" for _ in range(nqubits)]
while (
i in mapping
): # move up the tree until we get to the root (the root has no parent and is not in mapping)
op, i = mapping[i]
string[i - nstrings] = op
return "".join(string)
def _select_nodes(
terms: list[tuple[int, ...]],
nodes: set[int],
round: int,
nqubits: int,
tree: dict[int, tuple[int, int, int]],
mapping: dict[int, tuple[str, int]],
descendants: list[int],
ancestors: list[int],
):
minimum_pauli_weight = float("inf")
selection: tuple[int, int, int] | None = None
for xx, zz in tqdm( # bash every permutation of xx, zz and greedily choose best
permutations(nodes, 2),
total=math.perm(len(nodes), 2),
leave=False,
desc=f"Qubit {round + 1}/{nqubits}",
colour="#03925e",
ascii="░▒█",
):
# calculate Pauli weight of each selection
vac = nqubits * 2
desc = descendants[xx]
if (
desc == vac
): # the VAC operator should have a mapping to only Z operators. This enforces that.
continue
if (
desc % 2 == 0
): # now the Y branch will be the corresponding other majorana operator
swap = False
pair = desc + 1
else:
swap = True # opposite pairing, swap x and y
pair = desc - 1
yy = ancestors[pair]
if zz == yy: # check to make sure it isn't the same
continue
if swap:
xx, yy = yy, xx
pauli_weight = 0
for term in terms:
# for each mode in the term, map it to the corresponding gate based on the selection (or identity).
# XOR on simplectic vectors is equivalent to gates minus phase change.
term = reduce(
lambda x, y: (x[0] ^ y[0], x[1] ^ y[1]),
map(
lambda i: (
(True, False)
if i == xx
else (
(True, True)
if i == yy
else ((False, True) if i == zz else (False, False))
)
),
term,
),
(False, False),
)
# considered as a simplectic vector; if either is true, then we have a gate acting on this qubit.
if term[0] or term[1]:
pauli_weight += 1
# is the selection better ?
# if yes, update selection.
if pauli_weight < minimum_pauli_weight:
minimum_pauli_weight = pauli_weight
selection = xx, yy, zz
assert selection is not None
return selection
def _compile_fermionic_op(fermionic_op: FermionicOp, nqubits: int | None = None):
if nqubits is None:
nqubits = fermionic_op.register_length
nstrings = 2 * nqubits + 1
# turn the Hamiltonian into Majorana form and ignore the coefficients
terms = [
tuple(ms[1] for ms in term[0])
for term in MajoranaOp.from_fermionic_op(fermionic_op).terms()
if not math.isclose(abs(term[1]), 0)
]
# generate all terms, all initial nodes (strings)
nodes = set(range(nstrings))
# mapping, node -> branch, parent
mapping: dict[int, tuple[str, int]] = {}
# mapping for parent -> (x,y,z)
tree: dict[int, tuple[int, int, int]] = {}
descendants: list[int] = [-1] * (nstrings + nqubits)
ancestors: list[int] = [-1] * (nstrings + nqubits)
for i in range(nstrings):
descendants[i] = i
ancestors[i] = i
for round in range(nqubits):
# the qubit that will become the new parent
qubit_id = nstrings + round
# select the node with lowest Pauli weight
selection = _select_nodes(
terms, nodes, round, nqubits, tree, mapping, descendants, ancestors
)
# update nodes and terms, record solution
for node, op in zip(selection, "XYZ"):
nodes.remove(node)
mapping[node] = (op, qubit_id)
tree[qubit_id] = selection
nodes.add(qubit_id)
descendants[qubit_id] = descendants[selection[2]]
ancestors[descendants[selection[2]]] = qubit_id
# reduce the Hamiltonian
# this allows us to consider individual qubits when computing intermediary pauli weights.
for i in range(len(terms)):
term = tuple(idx for idx in terms[i] if idx not in selection)
terms[i] = (
term
if (len(terms[i]) - len(term)) % 2 == 0
else (term + (qubit_id,))
# if two modes were in the selection, they are siblings under a common node, and thus will cancel each other out. Otherwise, add in the new node.
)
terms = list(filter(lambda x: len(x) != 0, terms))
# generate solution
# next statement helps see tree structure
# print_tree(nstrings + nqubits - 1, tree, nstrings, ["I" for _ in range(nqubits)])
return [_walk_string(i, mapping, nqubits, nstrings) for i in range(nstrings - 1)]
[docs]
class HATTMapper(ModeBasedMapper, FermionicMapper):
"""Heuristic anticommutation tree mapper for fermionic operators.
The mapper can be built directly from a ``FermionicOp`` or from a saved
list of Pauli-table strings. The resulting table is then reused when
mapping additional operators with the same register size.
"""
def __init__(
self, loader: FermionicOp | list[str], nqubits: int | None = None
) -> None:
"""Initialize a HATT mapper from an operator or an existing Pauli table.
Args:
loader (FermionicOp | list[str]): Fermionic operator used to compile
the table, or a list of raw Pauli-table strings loaded from disk.
nqubits (int | None, optional): Register length. If omitted, it is
inferred from the compiled or loaded table.
"""
if isinstance(loader, FermionicOp):
raw_pauli_table = _compile_fermionic_op(loader, nqubits)
else:
raw_pauli_table = loader
self.raw_pauli_table = raw_pauli_table
self.nqubits = nqubits if nqubits is not None else len(raw_pauli_table[0])
[docs]
def map(self, second_q_ops: FermionicOp, *, _: int | None = None) -> SparsePauliOp: # type: ignore
"""Map a fermionic operator using the precomputed HATT Pauli table.
Args:
second_q_ops (FermionicOp): Fermionic operator to map.
_ (int | None, optional): Ignored compatibility argument.
Returns:
SparsePauliOp: Qubit operator in Qiskit's Pauli representation.
"""
return super().map(second_q_ops, register_length=self.nqubits) # type: ignore
[docs]
def pauli_table(self, register_length: int):
"""Return the Pauli table expected by Qiskit Nature mappers.
Args:
register_length (int): Register length requested by the mapper API.
The stored HATT table determines the actual Pauli strings.
Returns:
list[tuple[Pauli, Pauli]]: Majorana-pair Pauli table.
"""
table = []
for i in range(0, len(self.raw_pauli_table), 2):
table.append(
(Pauli(self.raw_pauli_table[i]), Pauli(self.raw_pauli_table[i + 1]))
)
return table
[docs]
def save(self, path: str):
"""Write the raw Pauli table to a text file.
Args:
path (str): Output filename.
"""
with open(path, "w") as pauli_table_file:
pauli_table_file.write("\n".join(self.raw_pauli_table))
[docs]
@staticmethod
def load(path: str):
"""Load a HATT mapper from a saved raw Pauli-table file.
Args:
path (str): Input filename created by :meth:`save`.
Returns:
HATTMapper: Mapper initialized with the stored table.
"""
with open(path, "r") as pauli_table_file:
lines = list(map(str.strip, pauli_table_file.readlines()))
return HATTMapper(lines)