Model building with feynlag: suggest + anomaly cancellation¶
The other tutorials (SM_Feynman_Rules_Tutorial, SM_VLL_Tutorial,
SM_U1X_Tutorial) take a Lagrangian you have already written by hand and push it
through the analysis pipeline. This notebook goes one step earlier — it shows the
model-building tools that help you write a consistent Lagrangian in the first
place:
feynlag.suggest— enumerate every gauge- and discrete-invariant operator your field content admits, up to a mass dimension, so you review a basis instead of hand-deriving invariants;feynlag.anomalies— compute the triangle-anomaly coefficients symbolically, turning “is this chiral gauge theory even consistent?” into a set of polynomial conditions on the charges.
We build a small dark sector: a gauged \(U(1)_D\) dark force with a complex dark Higgs \(S\) and a dark fermion \(\chi\). Along the way the tools will tell us which charge assignment is consistent, which operators are allowed, and catch a deliberate mistake.
1. Why enumerate?¶
The FeynRules-style workflow inverts model building: you write down every term you think is allowed, then hope the invariance checker flags whatever you got wrong or forgot. For anything past the SM that is error-prone — a missing \((S^\dagger S)^2\) quartic, a Yukawa whose charges don’t actually close, a bare mass that a symmetry forbids.
feynlag.suggest turns it around: declare the fields and symmetries, and let the
library enumerate the invariant operator basis. The engine is feynlag’s own,
already-tested invariance machinery used as an oracle — candidates are built from
gauge-invariant blocks, filtered for \(U(1)\) neutrality, projected onto the
discrete-invariant subspace, reduced to a linearly independent basis, and then every
emitted term is re-verified against the full check_* battery.
import sympy as sp
sp.init_printing()
from feynlag import (
ExternalParameter, InternalParameter, Lagrangian, Model, Scalar, U1,
WeylFermion, dag, Dmu, diracPR, diracPL, DiracGamma, Bilinear,
extract_fermion_vertices, fermion_feynman_rule, fermion_gauge_current,
fermion_mass_matrix, check_anomaly_free, anomaly_coefficients,
suggest_potential, suggest_yukawa, suggest_kinetic, build_lagrangian,
)
2. Fields with symbolic charges¶
We gauge a single abelian factor \(U(1)_D\) with coupling \(g_D\). The dark Higgs \(S\) carries charge \(q_S\) and the dark fermion is a pair of Weyl fields \(\chi_L,\chi_R\) carrying charge \(q_\chi\). We keep both charges symbolic — every pipeline stage (generators, invariance checking, gauge currents, mass matrices) handles symbol-valued \(U(1)\) charges; the one exception is UFO export, whose particle table needs numeric charges.
Declaring \(\chi_L\) and \(\chi_R\) with the same charge \(q_\chi\) makes the dark fermion vector-like (a Dirac fermion). Whether that is forced or a choice is exactly what the anomaly analysis in §3 will tell us.
gD = ExternalParameter("gD", 0.5, positive=True)
U1D = U1("U1D", coupling=gD)
q_chi = sp.Symbol("q_chi", real=True) # dark-fermion charge (symbolic)
q_S = sp.Symbol("q_S", real=True) # dark-Higgs charge (symbolic)
chiL = WeylFermion("chiL", reps={U1D: q_chi}, chirality="L",
component_names=["chiL"])
chiR = WeylFermion("chiR", reps={U1D: q_chi}, chirality="R",
component_names=["chiR"])
S = Scalar("S", reps={U1D: q_S}, component_names=["S0"])
print("chiL charge:", chiL.reps[U1D])
print("chiR charge:", chiR.reps[U1D], " (same -> vector-like Dirac fermion)")
print("S charge:", S.reps[U1D])
chiL charge: q_chi
chiR charge: q_chi (same -> vector-like Dirac fermion)
S charge: q_S
3. Anomaly cancellation constrains the charges¶
A chiral \(U(1)\) gauge theory is only consistent if its triangle anomalies vanish. With a
single abelian factor the relevant coefficients are the cubic \([U(1)_D]^3\) and the mixed
gravitational–gauge \(\mathrm{grav}^2\text{-}U(1)_D\). anomaly_coefficients reduces every
fermion to a left-handed Weyl field (a right-handed field contributes with its charge
sign flipped) and returns the coefficients as polynomials in the charge symbols.
For our vector-like assignment (\(q_{\chi_L}=q_{\chi_R}=q_\chi\)) the right-handed contribution exactly cancels the left-handed one:
model_vec = Model("darkU1_vec", gauge_groups=[U1D], fields=[chiL, chiR])
coeffs_vec = anomaly_coefficients(model_vec)
coeffs_vec
{'[U1D][U1D][U1D]': 0, 'grav^2-U1D': 0}
report_vec = check_anomaly_free(model_vec)
print(report_vec)
print("nonzero coefficients:", report_vec.nonzero or "none — anomaly-free for ANY q_chi")
AnomalyReport(2 coefficients, anomaly-free)
nonzero coefficients: none — anomaly-free for ANY q_chi
Now suppose we had tried a chiral assignment instead — \(\chi_R\) with a different charge \(q_R\). The anomaly coefficients no longer cancel automatically; they become conditions that pin \(q_R\):
q_R = sp.Symbol("q_R", real=True)
chiR_chiral = WeylFermion("chiRx", reps={U1D: q_R}, chirality="R",
component_names=["chiRx"])
model_chiral = Model("darkU1_chiral", gauge_groups=[U1D],
fields=[chiL, chiR_chiral])
coeffs_chiral = anomaly_coefficients(model_chiral)
coeffs_chiral
{'[U1D][U1D][U1D]': -q_R**3 + q_chi**3, 'grav^2-U1D': -q_R + q_chi}
solution = sp.solve([coeffs_chiral["[U1D][U1D][U1D]"],
coeffs_chiral["grav^2-U1D"]], [q_R], dict=True)
print("anomaly-free requires q_R =", solution[0][q_R])
anomaly-free requires q_R = q_chi
So for a single dark fermion pair, anomaly freedom forces \(q_R = q_\chi\) — the fermion must be vector-like. (A genuinely chiral dark \(U(1)\) needs extra fermions arranged so the cubes and the linear charges each sum to zero, exactly as the SM hypercharges do.) We take the minimal consistent option: a vector-like \(\chi\).
4. Enumerate the invariant operators¶
With a consistent charge assignment in hand, let suggest write the Lagrangian’s operator
content.
Scalar potential. Only \(S^\dagger S\) is a neutral quadratic (a bare \(S\), \(S^2\), \(S^3\) all carry net \(U(1)_D\) charge), so the potential is the familiar Mexican-hat pair:
potential = suggest_potential([S], [U1D])
for t in potential:
print(f" dim {t.dim}: ", end="")
display(t)
potential
dim 2:
dim 4:
[SuggestedTerm(S0 S0† [potential, dim 2]),
SuggestedTerm(S0 S0 S0† S0† [potential, dim 4])]
Fermion mass / Yukawa. Because \(\chi\) is vector-like, the gauge-invariant operator is
a bare Dirac mass \(\bar\chi_L\chi_R\) (dimension 3) — the dark Higgs cannot give \(\chi\)
a Yukawa mass here, since \(\bar\chi_L S\,\chi_R\) would need \(q_S = q_{\chi_L}-q_{\chi_R}=0\).
suggest_yukawa returns exactly that bare mass, with its \(+\,\text{h.c.}\) completed:
yukawa = suggest_yukawa([chiL, chiR], [S], [U1D])
for t in yukawa:
print(f" dim {t.dim}: ", end="")
display(t)
dim 3:
The tool catches a mistake. Suppose we fat-fingered \(\chi_R\)’s charge to \(q_\chi + 1\). Now no gauge-invariant mass term exists at all — the enumeration comes back empty, telling us the dark fermion would be massless and the model is incomplete:
chiR_bad = WeylFermion("chiRb", reps={U1D: q_chi + 1}, chirality="R",
component_names=["chiRb"])
yukawa_bad = suggest_yukawa([chiL, chiR_bad], [S], [U1D])
print("suggested mass terms for the mistuned charge:", yukawa_bad,
"\n-> empty: no invariant mass, the model as declared is inconsistent")
suggested mass terms for the mistuned charge: []
-> empty: no invariant mass, the model as declared is inconsistent
5. Attach couplings with build_lagrangian¶
build_lagrangian takes the suggested basis, attaches a fresh coupling to each operator
(potential/Yukawa terms enter as \(-c\cdot\mathcal{O}\), matching feynlag’s
\(\mathcal{L}\supset -V\) convention; generated couplings carry the right mass dimension
\(4-\dim\)), and returns a filled Lagrangian. suggest_kinetic adds the canonical
kinetic scaffolding — \((D_\mu S)^\dagger(D^\mu S)\) and the \(\chi\) gauge currents.
kinetic = suggest_kinetic([S, chiL, chiR])
L_auto, gen_params = build_lagrangian(potential + yukawa + kinetic)
print("generated couplings:", [p.name for p in gen_params],
" (dims:", [p.unit_dim for p in gen_params], ")")
print("kinetic scaffolding terms:", [t.label for t in kinetic])
generated couplings: ['c1', 'c2', 'c3'] (dims: [2, 0, 1] )
kinetic scaffolding terms: ['(DμS)†(DμS)', 'chiL gauge current', 'chiR gauge current']
That L_auto already forms a consistent model. For the physical pipeline below, though,
we adopt feynlag’s standard parametrization (as in every examples/ model): the
\(S^\dagger S\) mass coefficient is an InternalParameter fixed by the tadpole
condition, rather than a free external. The operator content is identical to what
suggest enumerated — we are only choosing which parameter the vacuum equation solves for.
6. Run the pipeline¶
We build the physical model (charges kept symbolic), break \(U(1)_D\) with \(\langle S\rangle = v_D/\sqrt2\), solve the tadpole, and read off the dark-photon mass, the \(\chi\) Dirac mass, and the \(Z_D\chi\chi\) gauge coupling.
vD = ExternalParameter("vD", 1000.0, positive=True, unit_dim=1)
lamS = ExternalParameter("lamS", 0.2, positive=True)
muS2 = InternalParameter("muS2", unit_dim=2) # fixed by the tadpole
Mchi = ExternalParameter("Mchi", 300.0, positive=True, unit_dim=1)
Sp = Scalar("S", reps={U1D: q_S}, component_names=["S0"])
Sp.expand_vev({Sp.components[0]: vD}) # S0 -> (vD + S0_r + i S0_i)/sqrt(2)
cL = WeylFermion("chiL", reps={U1D: q_chi}, chirality="L", component_names=["chiL"])
cR = WeylFermion("chiR", reps={U1D: q_chi}, chirality="R", component_names=["chiR"])
Xb = U1D.bosons("X") # the dark photon field
SdS = (dag(Sp) * Sp.mat)[0]
V = -muS2.s * SdS + lamS.s * SdS**2
DS = Dmu(Sp)
i, j = sp.symbols("i j", integer=True)
clc, crc = cL.components[0], cR.components[0]
clb, crb = cL.bar_components[0], cR.bar_components[0]
# bare Dirac mass -M (chibar_L chi_R + h.c.) -- the operator suggest_yukawa found
Lmass = -(Mchi.s * Bilinear(clb[i], diracPR, crc[i])
+ Mchi.s * Bilinear(crb[i], diracPL, clc[i]))
current = fermion_gauge_current(cL, i) + fermion_gauge_current(cR, i)
L = Lagrangian()
L.add((dag(DS) * DS)[0], sector="kinetic")
L.add(-V, sector="potential")
L.add(Lmass, sector="yukawa")
L.add(current, sector="yukawa")
model = Model("darkU1", gauge_groups=[U1D], fields=[Sp, cL, cR, Xb],
parameters=[gD, vD, lamS, muS2, Mchi], lagrangian=L)
report = model.check_invariance()
print(report)
report.raise_on_failure()
print("tadpole solution:", model.solve_tadpoles([muS2]))
InvarianceReport(11 checks, OK)
tadpole solution: {muS2: lamS*vD**2}
Dark-photon mass. The dark photon eats the CP-odd part of \(S\) and picks up a mass \(m_{Z_D}^2 = g_D^2\,q_S^2\,v_D^2\) — note the explicit \(q_S\) dependence, carried symbolically straight through the mass matrix:
X0 = Xb.components[0]
M_ZD2 = sp.simplify(model.gauge_mass_matrix([X0])[0, 0])
print("m_ZD^2 =", M_ZD2)
m_ZD^2 = gD**2*q_S**2*vD**2
Dark-fermion mass. The bare Dirac term gives \(\chi\) the mass \(M_\chi\) directly:
M_chi = fermion_mass_matrix(Lmass, clb, crc, model.vacuum, 1, (i, j), gamma=diracPR)
print("chi Dirac mass matrix:", M_chi)
chi Dirac mass matrix: Matrix([[Mchi]])
Dark gauge coupling. Extracting the fermion vertices, the \(Z_D\chi\chi\) coupling is the expected vector current \(i\,g_D\,q_\chi\,\gamma^\mu\) (same on both chiralities — \(\chi\) is vector-like), with the symbolic charge intact:
Lg = sp.expand(model.physical_lagrangian(sector="yukawa"))
table = extract_fermion_vertices(Lg, [X0])
mu = sp.Symbol("mu", integer=True)
gL, gR = DiracGamma(mu) * diracPL, DiracGamma(mu) * diracPR
cLcoup = table.get((clb[i], gL, clc[i]), {}).get(1, {}).get((X0,), 0)
cRcoup = table.get((crb[i], gR, crc[i]), {}).get(1, {}).get((X0,), 0)
print("Z_D chiL chiL :", sp.simplify(fermion_feynman_rule(cLcoup, gL, (X0,))))
print("Z_D chiR chiR :", sp.simplify(fermion_feynman_rule(cRcoup, gR, (X0,))))
Z_D chiL chiL : I*gD*q_chi*gamma(mu)*PL
Z_D chiR chiR : I*gD*q_chi*gamma(mu)*PR
7. Recap¶
Starting from nothing but field declarations with symbolic charges, the model-building tools:
Step |
Tool |
What it gave us |
|---|---|---|
Consistency |
|
anomaly-free ⟺ \(\chi\) vector-like (\(q_R=q_\chi\)) |
Operator basis |
|
\(\{S^\dagger S,\,(S^\dagger S)^2\}\) and the bare Dirac mass — and an empty result flagging a mistuned charge |
Assembly |
|
a filled, coupling-tagged |
Physics |
the standard pipeline |
\(m_{Z_D}^2=g_D^2q_S^2v_D^2\), \(m_\chi=M_\chi\), \(Z_D\chi\chi = i g_D q_\chi\gamma^\mu\) |
Every suggested operator is re-verified against the full invariance battery
(verify=True by default), so the basis you review is guaranteed consistent.
Where to go next: SM_Feynman_Rules_Tutorial walks the full analysis pipeline for the
Standard Model; SM_VLL_Tutorial covers vector-like fermions and mass-basis rotations;
SM_U1X_Tutorial extends the SM by a \(U(1)_X\) with tree-level \(Z\)–\(Z'\) mixing. Each hand-
writes its Lagrangian — the suggest tools here can bootstrap that first step for a new
model of your own.