Vector-like fermions in feynlag: SM + a vector-like lepton doublet¶
A vector-like fermion has both chiralities in the same gauge representation, so — unlike a chiral SM fermion — it can carry a bare, gauge-invariant Dirac mass all by itself. Mixed with an ordinary SM fermion through the Higgs, it produces a textbook flavor-mixing problem: a 2×2 mass matrix diagonalized by two independent angles (θ_L, θ_R, one per chirality), and — because the heavy and light states are no longer pure gauge eigenstates — flavor-changing neutral currents (FCNCs) that don’t exist in the SM.
This notebook walks through examples/sm_vll.py stage by stage, reusing
the same declare → write → check → break-symmetry → diagonalize → extract
pipeline as SM_Feynman_Rules_Tutorial.ipynb
(read that one first if ExternalParameter, Dmu, Scalar, or Model
are unfamiliar — this notebook won’t re-explain them). What’s new here:
a vector-like doublet Ψ = (N, E), built as two
WeylFermions with identical reps (not aDiracFermion— see §2),a bare mass term
-M Ψ̄_L Ψ_Rthat needs no scalar VEV to exist,biunitary (2×2 SVD) diagonalization with two physically distinct angles, via the analytic
diagonalize_svd_2x2helper,rotating fermion fields into their mass basis and re-extracting Feynman rules — which exercises
expand_bilinear, a piece of machinery this model is the first to need.
The headline physics we’ll derive and verify symbolically:
the Z-FCNC lives only in the right-handed current (the left-handed Z coupling is exactly protected),
a clean Higgs-coupling sum rule for the deviation from
-m_e/v,a new right-handed W current with no SM analogue.
import sympy as sp
from feynlag import (
Bilinear, DiracGamma, Dmu, ExternalParameter, InternalParameter,
Lagrangian, Model, Rotation, SU2, Scalar, U1, WeylFermion, dag,
diagonalize_svd_2x2, diracPL, diracPR, expand_bilinear,
extract_fermion_vertices, fermion_gauge_current, fermion_mass_matrix,
rotation_2x2, weinberg_rotation, charged_current_rotation,
)
1. Symmetries and parameters¶
Same SU2L/U1Y electroweak setup as the plain-SM tutorial, plus three
new couplings for the VLL sector:
ye— the ordinary (tiny) SM electron Yukawa,lamE— the mixing Yukawa between the heavy doublet ande_R,MPsi— the bare vector-like mass. It needsunit_dim=1(mass dimension 1) soModel.check_invariance()’s power-counting check can see thatMPsi · (dimension-3 Bilinear)is a dimension-4 operator — exactly likevneedsunit_dim=1for the Yukawa terms below.
gw = ExternalParameter("gw", 0.6535, positive=True)
g1 = ExternalParameter("g1", 0.3580, positive=True)
SU2L, U1Y = SU2("SU2L", coupling=gw), U1("U1Y", coupling=g1)
v = ExternalParameter("v", 246.0, positive=True, unit_dim=1)
lam = ExternalParameter("lam", 0.129, tex="\\lambda")
mu2 = InternalParameter("mu2", unit_dim=2, tex="\\mu^2")
ye = ExternalParameter("ye", 0.01, positive=True, tex="y_e")
lamE = ExternalParameter("lamE", 0.4, positive=True, tex="\\lambda_E")
MPsi = ExternalParameter("MPsi", 1000.0, positive=True, unit_dim=1, tex="M_\\Psi")
MPsi.unit_dim
1
2. Fields¶
The Higgs and the SM lepton generation are declared exactly as in the SM
tutorial (one flavor: nflavors=1, so the mixing physics isn’t obscured
by 3-generation flavor indices).
H = Scalar("H", reps={SU2L: 2, U1Y: sp.Rational(1, 2)},
component_names=["Gp", "H0"])
H.expand_vev({H.components[1]: v})
Ll = WeylFermion("Ll", reps={SU2L: 2, U1Y: -sp.Rational(1, 2)},
chirality="L", nflavors=1, component_names=["nuL", "eL"])
eRf = WeylFermion("eRf", reps={U1Y: -1}, chirality="R", nflavors=1,
component_names=["eR"])
Ll.components, eRf.components
([nuL, eL], [eR])
Now the vector-like doublet. PsiL and PsiR are given the same
reps (SU2L: 2, U1Y: -1/2) as Ll — both chiralities in one
representation is precisely what makes a bare mass gauge invariant (a
chiral SM fermion can’t have one: L_L and e_R transform differently,
so nothing gauge-invariant connects them without a scalar).
Why two WeylFermions and not a single DiracFermion? DiracFermion’s
chirality=None default routes through fermion_gauge_current with the
identity projector, producing a bare DiracGamma(mu)*diracI structure
that dirac_conjugate doesn’t know how to Dirac-conjugate — it raises
NotImplementedError, silently breaking the hermiticity check. That path
is untested (see fields.py’s DiracFermion docstring); modeling the VLL
as two WeylFermions with identical reps sidesteps it entirely and is a
well-exercised pattern.
PsiL = WeylFermion("PsiL", reps={SU2L: 2, U1Y: -sp.Rational(1, 2)},
chirality="L", nflavors=1, component_names=["NL", "EL"])
PsiR = WeylFermion("PsiR", reps={SU2L: 2, U1Y: -sp.Rational(1, 2)},
chirality="R", nflavors=1, component_names=["NR", "ER"])
W, B = SU2L.bosons("W"), U1Y.bosons("B")
PsiL.components, PsiR.components
([NL, EL], [NR, ER])
Gp, H0 = H.components
nuL, eL = Ll.components
nuLbar, eLbar = Ll.bar_components
eR, eRbar = eRf.components[0], eRf.bar_components[0]
NL, EL = PsiL.components
NLbar, ELbar = PsiL.bar_components
NR, ER = PsiR.components
NRbar, ERbar = PsiR.bar_components
i = sp.Symbol("fl_i", integer=True)
3. The Lagrangian¶
Three Yukawa-sector pieces, built up one at a time so the + h.c.
pattern is visible at each step (Bilinear(bar, gamma, field) is the
opaque fermion-sandwich atom; diracPR/diracPL are the chiral
projectors — see the SM tutorial for the full explanation of this
convention).
SM Yukawa: -y_e L̄_L H e_R + h.c.¶
Identical in form to the plain-SM lepton Yukawa.
LYuk = -(ye.s * Gp * Bilinear(nuLbar[i], diracPR, eR[i])
+ ye.s * H0 * Bilinear(eLbar[i], diracPR, eR[i]))
LYuk += -(ye.s * sp.conjugate(Gp) * Bilinear(eRbar[i], diracPL, nuL[i])
+ ye.s * sp.conjugate(H0) * Bilinear(eRbar[i], diracPL, eL[i]))
LYuk
Mixing Yukawa: -λ_E Ψ̄_L H e_R + h.c.¶
Ψ_L carries exactly L_L’s reps, so the component pattern is identical
to the SM Yukawa above — just with NL/EL in place of nuL/eL. This
single term is what ties the heavy doublet to the light SM lepton.
LYuk += -(lamE.s * Gp * Bilinear(NLbar[i], diracPR, eR[i])
+ lamE.s * H0 * Bilinear(ELbar[i], diracPR, eR[i]))
LYuk += -(lamE.s * sp.conjugate(Gp) * Bilinear(eRbar[i], diracPL, NL[i])
+ lamE.s * sp.conjugate(H0) * Bilinear(eRbar[i], diracPL, EL[i]))
LYuk
Bare vector-like mass: -M_Ψ Ψ̄_L Ψ_R + h.c.¶
No Higgs field appears — this term needs no VEV to exist, which is the
whole point of “vector-like.” Both N and E get this same mass M_Ψ.
LYuk += -MPsi.s * (Bilinear(NLbar[i], diracPR, NR[i])
+ Bilinear(ELbar[i], diracPR, ER[i]))
LYuk += -MPsi.s * (Bilinear(NRbar[i], diracPL, NL[i])
+ Bilinear(ERbar[i], diracPL, EL[i]))
LYuk
Finally the gauge currents (fermion_gauge_current is fully generic —
one call per fermion, regardless of how many gauge groups it’s charged
under):
current = (fermion_gauge_current(Ll, i) + fermion_gauge_current(eRf, i)
+ fermion_gauge_current(PsiL, i)
+ fermion_gauge_current(PsiR, i))
current
4. Assemble the Model and check invariance¶
The kinetic/potential Higgs sector is the standard SM piece. Assembling
everything into a Model and calling check_invariance() is the
correctness gate: this is the first model in feynlag where a bare
fermion mass and a mixing Yukawa between two different Fermion
objects get checked for gauge invariance, hermiticity (via
Bilinear._eval_conjugate and the bar_partner registry — which has to
correctly pair Ll/eRf legs with PsiL/PsiR legs), and mass
dimension all at once.
HdH = (dag(H) * H.mat)[0]
V = -mu2.s * HdH + lam.s * HdH**2
DH = Dmu(H)
L = Lagrangian()
L.add((dag(DH) * DH)[0], sector="kinetic")
L.add(-V, sector="potential")
L.add(LYuk, sector="yukawa")
L.add(current, sector="yukawa")
model = Model("SM-VLL", gauge_groups=[SU2L, U1Y],
fields=[H, Ll, eRf, PsiL, PsiR, W, B],
parameters=[gw, g1, v, lam, mu2, ye, lamE, MPsi],
lagrangian=L)
report = model.check_invariance()
report.raise_on_failure()
report
InvarianceReport(15 checks, OK)
5. EWSB and gauge rotations¶
Identical to the SM tutorial: solve the tadpole for mu2, then rotate
(W3, B) -> (Z, A) (Weinberg angle) and (W1, W2) -> (W+, W-).
model.solve_tadpoles([mu2])
{mu2: lam*v**2}
# physical basis: the standard Weinberg + W± rotations, from feynlag.models
Z, A = weinberg_rotation(model, SU2L, U1Y)
Wp, Wm = charged_current_rotation(model, SU2L, wp="W^+", wm="W^-")
6. The charged-lepton mass matrix¶
fermion_mass_matrix(L, bar_base, field_base, ...) extracts the
coefficient of one (bar, field) Bilinear pair from the vacuum-shifted
Lagrangian — it makes no assumption that bar_base and field_base
belong to the same Fermion object, so calling it 4× over
(ē_L, Ē_L) × (e_R, E_R) and assembling a 2×2 by hand gives the full
mixing mass matrix:
j = sp.Symbol("fl_j", integer=True)
bars, rights = (eLbar, ELbar), (eR, ER)
M2 = sp.Matrix(2, 2, lambda a, b: fermion_mass_matrix(
LYuk, bars[a], rights[b], model.vacuum, 1, (i, j), gamma=diracPR)[0, 0])
M2
The lower-triangular shape (zero in the top-right) says something
physical: there is no bare L̄_L Ψ_R mass in our Lagrangian (it’s
removable by a field redefinition, so we never wrote one) — the only way
e_R talks to the doublet sector is through λ_E.
The neutral sector has no such mixing at all: N is a pure Dirac state
of mass exactly M_Ψ, and the SM neutrino stays exactly massless (no
Bilinear anywhere pairs nuL with a right-handed state).
m_N = fermion_mass_matrix(LYuk, NLbar, NR, model.vacuum, 1, (i, j),
gamma=diracPR)[0, 0]
m_nu = fermion_mass_matrix(LYuk, nuLbar, NR, model.vacuum, 1, (i, j),
gamma=diracPR)[0, 0]
m_N, m_nu
(MPsi, 0)
7. Biunitary diagonalization¶
A non-symmetric mass matrix needs two rotations — one for the
bar/left-handed fields, one for the field/right-handed ones — the
biunitary (SVD) construction: θ_L diagonalizes M·Mᵀ, θ_R
diagonalizes Mᵀ·M. diagonalize_svd’s generic route
(Matrix.diagonalize(normalize=True)) returns unusable nested-radical
expressions for symbolic entries, so diagonalize_svd_2x2 instead calls
the same analytic tan 2θ machinery used for scalar mixing
(solve_mixing_angle_2x2) on M·Mᵀ and Mᵀ·M separately.
Given the matrix’s shape above (M[0,1]=0), expect θ_L to be small —
it can only arise from the product y_e λ_E, both small numbers —
while θ_R picks up the direct λ_E v / M_Ψ mixing and is the
physically large angle. That’s the defining signature of a vector-like
doublet (for a vector-like singlet it’s the other way around).
thL, thR = sp.symbols("thL thR", real=True)
e1L, e2L = sp.IndexedBase("e1L"), sp.IndexedBase("e2L")
e1R, e2R = sp.IndexedBase("e1R"), sp.IndexedBase("e2R")
e1Lbar, e2Lbar = sp.IndexedBase("e1Lbar"), sp.IndexedBase("e2Lbar")
e1Rbar, e2Rbar = sp.IndexedBase("e1Rbar"), sp.IndexedBase("e2Rbar")
rotL, rotR = diagonalize_svd_2x2(M2, [eL[i], EL[i]], [eR[i], ER[i]],
[e1L[i], e2L[i]], [e1R[i], e2R[i]],
angle_left=thL, angle_right=thR)
rotL.angle_relation, rotR.angle_relation
(Eq(tan(2*thL), -2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2)),
Eq(2*tan(thR)/(1 - tan(thR)**2), (2*sqrt(2)*MPsi*lamE*v*sin(atan(2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2))/2)**2 - 2*sqrt(2)*MPsi*v*ye*sin(atan(2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2))/2)*cos(atan(2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2))/2))/(-2*MPsi**2*sin(atan(2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2))/2)**2 + lamE**2*v**2*sin(atan(2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2))/2)**2 - 2*lamE*v**2*ye*sin(atan(2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2))/2)*cos(atan(2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2))/2) + v**2*ye**2*cos(atan(2*lamE*v**2*ye/(2*MPsi**2 + lamE**2*v**2 - v**2*ye**2))/2)**2)))
Dual verification (symbolic derivation + numeric check, per
CONVENTIONS.md): plug in a benchmark point and confirm the rotation
really diagonalizes M, with the light state landing in slot 1.
bench = {ye.s: 0.01, lamE.s: 0.4, v.s: 246.0, MPsi.s: 1000.0}
thL_num = float(rotL.angle_solution.subs(bench))
thR_num = float(rotR.angle_solution.subs(bench))
D = rotation_2x2(thL_num) * M2.subs(bench) * rotation_2x2(thR_num).T
assert abs(D[0, 1]) < 1e-9 and abs(D[1, 0]) < 1e-9
assert abs(D[0, 0]) < abs(D[1, 1]) # field 1 = light state
thL_num, thR_num, float(D[0, 0]), float(D[1, 1])
(-0.00012044923364816906,
-0.06946755651195814,
1.735287235094696,
1002.417724593002)
8. Registering the fermion rotations¶
Rewriting the Lagrangian in the mass basis uses the same Rotation
machinery as the boson case above — Rotation([eL[i], EL[i]], [e1L[i], e2L[i]], rotation_2x2(θ)) — but with Indexed old-fields instead of
plain Symbols. Two things are specific to fermions:
Register two rotations per chirality, not one: the field-side rotation (
eL, EL -> e1L, e2L) and an independent bar-side rotation (eLbar, ELbar -> e1Lbar, e2Lbar) with the same matrix — aBilinearhas a bar leg and a field leg, and both need rewriting.Everything needs a shared flavor-index symbol. Every term above was written with the same
i, so a singlexreplacereaches every occurrence at once.
Here’s the subtlety this newly exercises. After xreplace, a Bilinear
slot holds cosθ·e1L[i] + sinθ·e2L[i] — a SymPy Add — trapped inside an
opaque, custom Function that has no linearity rule built in. Watch what
a raw Bilinear looks like right after the substitution, before anything
distributes it:
demo_bar, demo_field = sp.IndexedBase("demo_bar"), sp.IndexedBase("demo_field")
demo_new1, demo_new2 = sp.IndexedBase("demo_n1"), sp.IndexedBase("demo_n2")
th_demo = sp.Symbol("th_{demo}", real=True)
demo_term = Bilinear(demo_bar[i], diracPR, demo_field[i])
# what Rotation.substitution() would build for field[i] -> R^-1 * new[i];
# written out directly here since a real Rotation needs 2 old fields:
demo_sub = {demo_field[i]: sp.cos(th_demo) * demo_new1[i]
+ sp.sin(th_demo) * demo_new2[i]}
demo_rotated = demo_term.xreplace(demo_sub)
demo_rotated, demo_rotated.field
(Bilinear(demo_bar[fl_i], PR, sin(th_{demo})*demo_n2[fl_i] + cos(th_{demo})*demo_n1[fl_i]),
sin(th_{demo})*demo_n2[fl_i] + cos(th_{demo})*demo_n1[fl_i])
The field slot is now an unresolved Add — extract_fermion_vertices
would group by this entire composite key rather than splitting it into
two clean (coefficient, Indexed) contributions. expand_bilinear
(added to vertices/bilinear.py alongside this example) distributes
Bilinear over Add-valued legs, exploiting that it’s linear in each of
its bar/field slots:
expand_bilinear(demo_rotated)
Two clean terms, each with a plain Indexed leg and the right
cosθ/sinθ coefficient. extract_fermion_vertices and
fermion_mass_matrix both call expand_bilinear automatically now, so
the real model below doesn’t need to do this by hand — but this is
exactly what would silently go wrong without it.
Now register the four real rotations:
model.rotate(rotL)
model.rotate(Rotation([eLbar[i], ELbar[i]], [e1Lbar[i], e2Lbar[i]],
rotation_2x2(thL)))
model.rotate(rotR)
model.rotate(Rotation([eRbar[i], ERbar[i]], [e1Rbar[i], e2Rbar[i]],
rotation_2x2(thR)))
9. Extracting the physical couplings¶
physical_lagrangian applies the vacuum shift, tadpole solution, and
every registered rotation (bosonic and fermionic) in one pass;
extract_fermion_vertices then groups by (bar, gamma, field) and peels
off the boson legs — exactly as in the SM tutorial.
h = sp.Symbol("H0_r", real=True)
LYuk_phys = model.physical_lagrangian(sector="yukawa")
table = extract_fermion_vertices(LYuk_phys, [Z, A, Wp, Wm, h])
mu = sp.Symbol("mu", integer=True)
gammaL = DiracGamma(mu) * diracPL
gammaR = DiracGamma(mu) * diracPR
def coeff(bar, gamma, field, boson):
entry = table.get((bar, gamma, field))
if entry is None:
return sp.S.Zero
return entry.get(1, {}).get((boson,), sp.S.Zero)
Z couplings — the headline result¶
e_L and E_L are both T³ = -1/2 doublet members, so rotating
between them by θ_L doesn’t change the diagonal Z coupling and produces
exactly zero FCNC — the left-handed current is protected by the
doublet structure, independent of any mixing angle.
e_R (T³ = 0, an SM singlet) and E_R (T³ = -1/2, from the doublet)
are different under SU(2)_L, so rotating them by θ_R both shifts
the light state’s diagonal coupling and generates a genuine FCNC.
sp.simplify(coeff(e1Lbar[i], gammaL, e1L[i], Z)) # LH diagonal: unchanged
sp.simplify(coeff(e1Lbar[i], gammaL, e2L[i], Z)) # LH FCNC: exactly zero
sp.simplify(coeff(e1Rbar[i], gammaR, e1R[i], Z)) # RH diagonal: shifted
sp.simplify(coeff(e1Rbar[i], gammaR, e2R[i], Z)) # RH FCNC: -(gZ/2) sinθR cosθR
Photon couplings — a sanity check¶
U(1)_EM is unbroken, so the rotation must not touch the electric
charge: diagonal coupling -e in both mass eigenstates, and zero
photon FCNC (a mismatch here would mean a bug in the rotation, not new
physics).
sp.simplify(coeff(e1Lbar[i], gammaL, e1L[i], A))
sp.simplify(coeff(e1Lbar[i], gammaL, e2L[i], A))
W couplings — a genuinely new right-handed current¶
The left-handed current splits between the light and heavy states by the
usual cosθ_L/sinθ_L pattern. But because Ψ_R is also an SU(2)_L
doublet (unlike the SM’s e_R singlet), there’s a right-handed charged
current W⁺ N̄_R e_R ∝ sinθ_R that has no SM analogue at all.
sp.simplify(coeff(nuLbar[i], gammaL, e1L[i], Wp))
sp.simplify(coeff(nuLbar[i], gammaL, e2L[i], Wp))
sp.simplify(coeff(NRbar[i], gammaR, e1R[i], Wp)) # the new RH current
Higgs couplings — a sum rule¶
The light lepton’s Higgs coupling deviates from the SM value -m_e/v
by a clean, closed-form amount: sinθ_L sinθ_R M_Ψ/v. Rather than just
printing the coupling, build the SM mass formula for this mixed system
and verify the deviation symbolically — the residue below should
simplify to exactly zero.
cL, sL = sp.cos(thL), sp.sin(thL)
cR, sR = sp.cos(thR), sp.sin(thR)
m_e_expr = (cL * ye.s + sL * lamE.s) * v.s / sp.sqrt(2) * cR + sL * sR * MPsi.s
h_e1e1 = coeff(e1Lbar[i], diracPR, e1R[i], h)
residue = sp.simplify(h_e1e1 + (m_e_expr - sL * sR * MPsi.s) / v.s)
residue
10. Recap¶
Starting from one bare mass term and one mixing Yukawa, feynlag
mechanically derived: a non-trivial 2×2 mixing matrix, two independent
diagonalization angles with the doublet’s characteristic
θ_L-small/θ_R-large hierarchy, an exactly-zero left-handed Z-FCNC
alongside a nonzero right-handed one, a new right-handed W current, and a
closed-form Higgs-coupling sum rule — all derived, not assumed, and all
cross-checked symbolically above.
Exact pinned formulas for every coupling here (including the ones we only spot-checked):
tests/test_vll.py.The full flat script this notebook walks through:
examples/sm_vll.py.For the plain-SM baseline this model extends:
SM_Feynman_Rules_Tutorial.ipynb/examples/sm_scalar_gauge.py.