Fermi-Hubbard Model via Majorana Propagation
This notebook demonstrates how to simulate the real-time quantum dynamics of the 1D Fermi-Hubbard model using the Majorana Propagation (MP) simulator. The goal is to track how the spin-up occupancy at a given site evolves in time starting from a simple, unentangled initial state.
This 60-site example comes from the following paper, using the same model parameters, the same half-filled Néel initial state, and the spin-up occupancy at the central site as the observable. The expected runtime for the whole MP-based simulation in this notebook is around 10-15 seconds.
G. S. Hartnett, K. S. Najafi, A. Khindanov, H. Liao, M. Schutzman, M. R. Hush, M. J. Biercuk, Y. Baum,
Fast, accurate, high-resolution simulation of large-scale Fermi-Hubbard models on a digital quantum processor,
arXiv:2605.04025 (2026). https://arxiv.org/abs/2605.04025
from __future__ import annotations
import matplotlib.pyplot as plt
import numpy as np
from monoprop import Circuit, ExpGate, MajoranaPropagator
from monoprop.fermi import FermiOperator1. Define the model
The 1D Fermi-Hubbard Hamiltonian is:
The fermionic modes are indexed by site with spin states interleaved: at each site , spin-up occupies mode and spin-down occupies mode . The function below returns the ordered list of local terms for the first-order Trotter decomposition — hopping bonds first, then on-site interactions, then chemical potential terms.
def mode(site, spin):
"""Return the interleaved mode index for a given site and spin (even index for spin-up, odd for spin-down)."""
return 2 * site if spin == "up" else 2 * site + 1
def hubbard_fermion_terms(num_sites, hopping, interaction, chemical_potential):
"""Return the ordered list of local FermionOperator terms for the first-order Trotter decomposition of the 1D Hubbard model."""
terms = []
# nearest-neighbour hopping (both spin species)
for site in range(num_sites - 1):
for spin in ("up", "down"):
left, right = mode(site, spin), mode(site + 1, spin)
op_terms = [((left, "+"), (right, "-")), ((right, "+"), (left, "-"))]
terms.append(
FermiOperator(terms=op_terms, coefficients=[-hopping, -hopping])
)
# on-site Hubbard interaction
for site in range(num_sites):
up, down = mode(site, "up"), mode(site, "down")
terms.append(
FermiOperator(
terms=[((up, "+"), (up, "-"), (down, "+"), (down, "-"))],
coefficients=[interaction],
)
)
# chemical potential (one term per spin-orbital)
for site in range(num_sites):
for spin in ("up", "down"):
m = mode(site, spin)
terms.append(
FermiOperator(
terms=[((m, "+"), (m, "-"))],
coefficients=[-chemical_potential],
)
)
return termsHere are the key physical parameters for the model and computational parameters for the Trotter evolution:
num_sites— number of lattice sites ( fermionic modes total).hopping— hopping amplitude (energy scale).interaction— on-site interaction (attractive).chemical_potential— set to 0 for half filling.trotter_dt— Trotter time step .trotter_steps— number of Trotter steps.
# physical parameters
num_sites = 60
hopping = 1.0
interaction = -2.0
chemical_potential = 0.0
# time-evolution parameters
trotter_dt = 0.2
trotter_steps = 30
# one mode per spin-orbital
num_qubits = 2 * num_sitesEach local Hamiltonian term is converted to Majorana generators and packed into our internal representations of the circuit,
being a list of ExpGate objects.
def build_trotter_gates(num_sites, hopping, interaction, chemical_potential):
"""Convert each local Hubbard term into a Majorana generator gate for the MP simulator."""
ferm_ops = hubbard_fermion_terms(
num_sites, hopping, interaction, chemical_potential
)
return [ExpGate(term) for term in ferm_ops]
trotter_gates = build_trotter_gates(num_sites, hopping, interaction, chemical_potential)
# each gate shares the same Trotter time step
trotter_parameters = [trotter_dt for _ in trotter_gates]We start with the half-filled Néel state as in the original paper. With start_spin="down", even sites begin spin-down occupied and odd sites spin-up occupied; the function below returns the corresponding list of occupied mode indices.
def neel_occupied_modes(num_sites, start_spin="up"):
"""Return the list of occupied mode indices for the half-filled Néel state, alternating spin between even and odd sites."""
other = "down" if start_spin == "up" else "up"
return [
mode(site, start_spin if site % 2 == 0 else other) for site in range(num_sites)
]
occupied = neel_occupied_modes(num_sites, start_spin="down")
fermi_circuit = Circuit(
gates=trotter_gates,
parameters=trotter_parameters,
initial_state=occupied,
)We measure the spin-up occupancy observable at the central site . In the Majorana basis:
The MP simulator tracks the monomial part; the constant shift is accounted for analytically.
def number_operator_majorana(site, spin):
"""Return the Majorana-basis representation of the number operator n_{site, spin}."""
m = mode(site, spin)
return FermiOperator(
terms=[((m, "+"), (m, "-"))], coefficients=[1.0], num_modes=num_qubits
)
obs_site = num_sites // 2
obs_spin = "up"
observable = number_operator_majorana(obs_site, obs_spin)3. Run the simulation
MajoranaPropagator maintains the observable as a sum of weighted Majorana monomials. Each call to propagate applies one Trotter step, conjugating each monomial by the local generators and generating higher-weight monomials through operator spreading.
After each step, simulator.expectation_value() returns the observable and simulator.size() the number of active monomials.
simulator = MajoranaPropagator(
observable,
fermi_circuit.initial_state,
cutoff=4, # maximum Majorana monomial length kept
cutoff_type="length",
lower_atol=1e-4,
)
# check the initial state has 0 expectation value
print("Initial expectation value =", simulator.expectation_value())Initial expectation value = 0.0times = np.arange(trotter_steps + 1) * trotter_dt
values = np.empty(trotter_steps + 1)
term_counts = np.empty(trotter_steps + 1, dtype=int)
values[0] = simulator.expectation_value()
term_counts[0] = simulator.size()
for step in range(trotter_steps):
simulator.propagate(fermi_circuit)
values[step + 1] = simulator.expectation_value()
term_counts[step + 1] = simulator.size()
print(
f" step {step + 1:2d}/{trotter_steps} "
f"t={times[step + 1]:.1f} "
f"<n>={values[step + 1]:.4f} "
f"terms={term_counts[step + 1]:,}"
) step 1/30 t=0.2 <n>=0.0759 terms=32
step 2/30 t=0.4 <n>=0.2648 terms=460
step 3/30 t=0.6 <n>=0.4747 terms=1,197
step 4/30 t=0.8 <n>=0.6151 terms=2,451
step 5/30 t=1.0 <n>=0.6467 terms=4,495
step 6/30 t=1.2 <n>=0.5934 terms=7,344
step 7/30 t=1.4 <n>=0.5140 terms=11,233
step 8/30 t=1.6 <n>=0.4603 terms=16,588
step 9/30 t=1.8 <n>=0.4506 terms=23,089
step 10/30 t=2.0 <n>=0.4703 terms=30,643
step 11/30 t=2.2 <n>=0.4934 terms=39,384
step 12/30 t=2.4 <n>=0.5039 terms=49,663
step 13/30 t=2.6 <n>=0.5031 terms=61,319
step 14/30 t=2.8 <n>=0.5014 terms=73,899
step 15/30 t=3.0 <n>=0.5052 terms=87,388
step 16/30 t=3.2 <n>=0.5116 terms=101,969
step 17/30 t=3.4 <n>=0.5130 terms=118,202
step 18/30 t=3.6 <n>=0.5058 terms=134,297
step 19/30 t=3.8 <n>=0.4941 terms=149,958
step 20/30 t=4.0 <n>=0.4858 terms=166,851
step 21/30 t=4.2 <n>=0.4860 terms=183,546
step 22/30 t=4.4 <n>=0.4940 terms=199,652
step 23/30 t=4.6 <n>=0.5038 terms=215,819
step 24/30 t=4.8 <n>=0.5100 terms=230,997
step 25/30 t=5.0 <n>=0.5102 terms=245,309
step 26/30 t=5.2 <n>=0.5056 terms=258,395
step 27/30 t=5.4 <n>=0.4994 terms=270,983
step 28/30 t=5.6 <n>=0.4945 terms=281,947
step 29/30 t=5.8 <n>=0.4926 terms=291,284
step 30/30 t=6.0 <n>=0.4944 terms=299,4354. Results
The observable starts at zero (the central site is spin-down occupied in the Néel state), rises as hopping spreads spin-up electrons across the chain, and oscillates around the thermal value of (dashed line). The right panel shows the number of active Majorana monomials, which grows with time until truncation saturates the expansion.
fig, axes = plt.subplots(1, 2, figsize=(12, 4), constrained_layout=True)
# left: expectation value vs time
ax = axes[0]
ax.plot(
times,
values,
color="#33766f",
linewidth=2,
marker="s",
markersize=6,
markerfacecolor="none",
markeredgecolor="#33766f",
label="Majorana Propagation",
)
ax.axhline(0.5, color="gray", linestyle="--", linewidth=1, label="thermal")
ax.set_xlabel("Time $t$", fontsize=12)
ax.set_ylabel(rf"$\langle n_{{{obs_site},\uparrow}} \rangle$", fontsize=12)
ax.set_title(
f"{num_sites}-site Hubbard model ($U={interaction}$, $t={hopping}$)", fontsize=11
)
ax.set_xlim(0, times[-1])
ax.set_ylim(-0.05, 0.75)
ax.spines[["top", "right"]].set_visible(False)
ax.legend(frameon=False, fontsize=9)
# right: active monomial count (proxy for computational cost)
ax2 = axes[1]
ax2.plot(
times,
term_counts,
color="#7d1cb1",
linewidth=2,
marker="o",
markersize=5,
markerfacecolor="none",
)
ax2.set_xlabel("Time $t$", fontsize=12)
ax2.set_ylabel("Active Majorana monomials", fontsize=12)
ax2.set_title("Number of terms vs time", fontsize=11)
ax2.set_xlim(0, times[-1])
ax2.spines[["top", "right"]].set_visible(False)
plt.show()