4. Checking Invariance¶
Physics statement¶
A Lagrangian term is physical only if it respects the symmetries the model
declares: gauge invariance under every continuous group, invariance under
every discrete symmetry, hermiticity of the total Lagrangian, and mass
dimension \(\le 4\) (renormalizability). feynlag’s strategy for all of this
is explicit component transformation — build the actual transformed
expression and check that it equals the original — rather than tracking
abstract representation labels and inferring invariance from index
contraction rules. This is slower symbolically but categorically safer: it
catches the invariance violation a purely bookkeeping-based checker would
miss if a term were mistyped.
Model.check_invariance() (lagrangian.py:327) is the entry point,
running every check below over every Lagrangian term and every declared
group, and collecting failures into an InvarianceReport rather than
raising on the first one.
4.1 Gauge invariance¶
Algorithm¶
For each (term, group) pair, check_gauge_invariance (invariance.py:185)
via gauge_variation:
Introduce one real
Dummyparameter \(\alpha^a\) per generator ofgroup.Build the substitution map
_transform_mapsending every componentcomp → comp + iα^a(T^a\cdot\text{comps})_i(first-order in \(\alpha\)), and the identical linear substitution forPartialMu(comp).Apply the map (after Leibniz-expanding any compound
PartialMu, so only atomicPartialMu(component)nodes remain to match the substitution keys), plus the parallel fermion-leg transform if the term contains aBilinear.Compute
delta = expand(transformed − term), then for each \(\alpha^a\) takediff(delta, α^a)and set every \(\alpha\) to zero — this isolates the \(O(\alpha)\) coefficient of each generator.The term is gauge-invariant iff every one of these coefficients is identically zero.
Derivation: why the transform commutes with \(\partial_\mu\)¶
The infinitesimal gauge transformation of a field is
\(\delta\phi = i\alpha^aT^a\phi\) with \(\alpha^a\) spacetime-constant
(the check tests global, not local, invariance of the raw kinetic term —
the compensating \(-igT^aA^a_\mu\) piece inside \(D_\mu\) is what promotes this
to a genuine local symmetry, and it’s already baked into any term written
with Dmu). Because \(\alpha^a\) carries no \(x\)-dependence,
In other words, the variation operator and \(\partial_\mu\) commute
exactly for a global transformation. This is the entire justification for
_transform_map giving PartialMu(comp) the identical linear
substitution as comp itself (invariance.py:64) — there is no need to
push the substitution through the PartialMu wrapper by differentiating,
which is fortunate, because PartialMu has no fdiff rule (see
3. Writing the Lagrangian) and any such attempt would leave an unevaluated,
non-cancelling Subs(Derivative(...)) residue. Substituting outside both
operators, using their known commutation, sidesteps the trap entirely. This
exact bug — differentiating through PartialMu — was hit and fixed once
for Dmu-built kinetic terms during development; understanding why the
commuting-substitution route is not just a shortcut but the correct fix
is why this derivation is spelled out here rather than left implicit in the
code comment.
Fermion legs: a different substitution mechanism¶
Fermion components are IndexedBase-typed and only ever appear as
Indexed(base, flavor_index) — a bare xreplace dict (keyed on whole
symbols) cannot handle this, because the flavor index on the matched node
must be preserved symbolically in the replacement. _fermion_transform
(invariance.py:87) instead uses sympy.Basic.replace with a predicate +
builder function: the predicate matches any Indexed node whose base is
a known fermion component, and the builder receives the matched node itself
(so it can read off node.indices) and returns
— the bar leg transforming in the conjugate (dual) representation, sign and
index order flipped relative to the field leg. Since \(T\) is hermitian (PDG
convention) and \(\delta\psi = i\alpha T\psi\), the bar-leg convention
\(\delta\bar\psi = -i\alpha\,\bar\psi T\) is exactly what makes a singlet
bilinear invariant: \(\delta(\bar\psi\psi) = \delta\bar\psi\cdot\psi +
\bar\psi\cdot\delta\psi = -i\alpha\,\bar\psi T\psi + i\alpha\,\bar\psi T\psi
= 0\) — the standard “dual representation” prescription, generalized here to
matrix multiplication order matching the flavor-preserved index structure.
After the fermion transform is applied,
expand_bilinear (8. Extracting Vertices) distributes the now Add-valued
bar/field legs of any Bilinear node before the \(O(\alpha)\) coefficient
extraction runs.
4.2 Discrete invariance¶
Algorithm¶
Discrete transformations are finite — no \(\alpha\)-linearization or
differentiation is needed, because a group element like \(S_3\)’s
transposition is not infinitesimally close to the identity.
check_discrete_invariance (invariance.py:198) iterates over
group.generator_maps() (one substitution dict per generator) and, for
each, checks expand(transformed − term) == 0 directly (falling back to
simplify for the residual when the raw expand doesn’t immediately
vanish — needed because \(Z_N\) phases like \(\omega^k\) often require trig/exp
simplification to visibly cancel).
Derivation: the bar-leg matrix \(X = (M^{-1})^T\)¶
For a discrete multiplet \((\psi_1,\dots,\psi_d)\) in irrep matrix \(M\)
(group.assign(irrep, ...)), the field-leg substitution is the direct
image of the representation: \(\psi_i' = \sum_k M_{ik}\psi_k\). The bar leg
needs its own matrix \(X\), and \(X \ne M\) in general — it must be
derived, not assumed, from the physical requirement that the diagonal
bilinear sum \(\sum_i \bar\psi_i\psi_i\) (the fermion kinetic/mass term
pattern) stays invariant. Writing \(\bar\psi_i' = \sum_k X_{ik}\bar\psi_k\):
Invariance requires this to equal \(\sum_k \bar\psi_k\psi_k\), i.e. the coefficient tensor \(\sum_i X_{ik}M_{il}\) must equal \(\delta_{kl}\) — in matrix form \(X^TM = I\), i.e.
fermion_generator_data() (groups/discrete.py:126) computes this
generically as M.inv().T for every generator, rather than assuming a
shortcut. \(X\) only coincides with \(M\) itself when \(M\) is real orthogonal
(\(M^{-1} = M^T \Rightarrow (M^{-1})^T = M\)) — true for every \(S_3\) irrep
generator (built from real rotations/reflections, see 2. Declaration: Parameters, Fields, Groups),
but false for \(Z_N\)’s complex-phase irreps, where \(X = \bar M\) instead
(a unitary, non-orthogonal \(M = [\omega^k]\) gives \(M^{-1} = M^\dagger =
\bar M^T\), so \((M^{-1})^T = \bar M\)). Computing M.inv().T generically
rather than hard-coding “use \(M\) for bosons, \(M\) again for fermions” is
precisely what makes the discrete-fermion invariance check correct for
both group families without a special case per group type.
The bosonic substitution in generator_maps() needs no analogous
derivation because a scalar bilinear pattern doesn’t arise the same way —
bosonic invariants are written explicitly (e.g. via S3.doublet_product)
and checked by the same finite-substitution machinery without a bar leg.
4.3 Hermiticity¶
Algorithm¶
For bosonic sectors, check_hermiticity(expr) (invariance.py:221) is
literally expand(expr - conjugate(expr)) == 0 (with a simplify fallback
for the residual). Model.check_invariance runs this once per sector on
the sector’s total (not per-term), because cross-term cancellations
(e.g. a Yukawa term and its explicitly-written + h.c. partner) are
expected and must not each individually vanish.
Derivation: the fermion-bilinear conjugation rule¶
A Yukawa or gauge-current term written with a Bilinear needs its own
hermitian-conjugate rule, since sympy.conjugate has no built-in notion of
Dirac adjoints. Bilinear._eval_conjugate (vertices/bilinear.py:65)
implements
the standard field-theory identity for a fermion sandwich, with the bar/field
roles swapped, flavor indices carried over on their own field (the field-2
index goes with the new bar, the field-1 index with the new field), and
\(\Gamma \to \bar\Gamma\). dirac.dirac_conjugate (dirac.py:476) computes
\(\bar\Gamma\) for exactly the catalog of structures the library actually
builds:
\(\bar\Gamma = 1\) for \(\Gamma = 1\) (
diracI) — trivial.Bare \(\gamma^\mu\) is self-conjugate. In the standard Dirac representation with metric \((+,-,-,-)\), \(\gamma^0\) is hermitian and \(\gamma^{\mu\dagger} = \gamma^0\gamma^\mu\gamma^0\), so \(\bar\gamma^\mu = \gamma^0(\gamma^0\gamma^\mu\gamma^0)\gamma^0 = \gamma^\mu\) (using \((\gamma^0)^2=1\)).
Bare chiral projectors swap. \(P_{L,R} = (1\mp\gamma_5)/2\) are hermitian, but \(\gamma_5\) anticommutes with every \(\gamma^\mu\) (Clifford algebra), hence with \(\gamma^0\) in particular: \(\gamma^0 P_L\gamma^0 = \gamma^0\frac{1-\gamma_5}{2}\gamma^0 = \frac{1-(-\gamma_5)}{2}\cdot(\gamma^0)^2 \cdot(\ldots)\); carrying through the anticommutation carefully gives \(\gamma^0 P_L \gamma^0 = P_R\) and vice versa — a bare projector’s conjugate is the opposite chirality projector.
A vector current is self-conjugate. For \(\Gamma = \gamma^\mu P_L\) (or \(P_R\)), the two effects above cancel: \(\gamma^\mu P_L = P_R\gamma^\mu\) (moving \(\gamma^\mu\) past \(P_L\) flips its chirality by the same anticommutation), so \(\bar\Gamma = \gamma^0(\gamma^\mu P_L)^\dagger\gamma^0 = \gamma^0 P_L\gamma^{\mu\dagger}\gamma^0\) works out to \(\gamma^\mu P_L\) again — the familiar “V\(-\)A ports to V\(-\)A” statement: the h.c. of \(\bar\nu\gamma^\mu P_L\ell\) is \(\bar\ell\gamma^\mu P_L\nu\), same \(\Gamma\), bar/field swapped. This is exactly
fermion_gauge_current’s output structure, which is why a gauge current needs no hand-written+ h.c.term to pass the hermiticity check — it is hermitian by construction.
Everything outside this catalog (diracI, diracPL, diracPR, bare
DiracGamma(mu), and DiracGamma(mu)*diracPL/diracPR) raises
NotImplementedError rather than guessing — e.g. products of two or more
gamma matrices need the Clifford algebra \(\{\gamma^\mu,\gamma^\nu\}=2g^{\mu\nu}\)
applied first, and reverse under conjugation, which is genuinely more work
than the library currently implements. This means: a Yukawa term written
without its explicit + h.c. partner now genuinely fails
Model.check_invariance()’s hermiticity check — the library will not
silently assume a Yukawa is self-conjugate the way a gauge current is.
4.4 Mass dimension¶
check_mass_dimension (invariance.py:233) does symbolic power counting
in mass units. It assigns dimension \(3/2\) to every fermion component and
\(1\) to every scalar/vector component and every VEV/fluctuation symbol,
reads parameter dimensions off Parameter.unit_dim, and injects a fresh
positive symbol \(u\) (mass unit) via term.xreplace({s: u**d for s, d in dims.items()}) — but first rewrites PartialMu(arg) → u*arg (derivative
adds one power of mass) and collapses every opaque Bilinear(bar, gamma, field) node to a bare \(u^3\) (two spin-\(\tfrac12\) legs, \(\tfrac32+\tfrac32=3\)
— the internal Dirac/flavor structure carries no mass dimension of its
own; a four-fermion operator is thus two Bilinears \(\to u^6\)). The worst
monomial’s degree in \(u\) (numerator minus denominator degree, via
sp.degree) is the term’s mass dimension; the check passes iff it’s
\(\le\,\)max_dim.
The ceiling is the max_dim argument (default \(4\), renormalizable),
threaded through Model.check_invariance(max_dim=…) and validate(max_dim=…).
Raise it to admit higher-dimension effective operators — max_dim=6 for the
four-fermion (FFFF) operators of 8. Extracting Vertices, max_dim=5 for a Weinberg
operator. Note that a properly-written EFT term carries a Wilson coefficient
whose dimension compensates the operator’s (e.g. \(G_F\) is \(u^{-2}\)), making the
term dimension \(4\) regardless; the flag matters under the operator-dimension
convention, where the coefficient is kept dimensionless and the operator’s
field content (\(u^6\) for four fermions) shows through.
Design gotchas summary¶
Never differentiate through
PartialMu/Bilinear. Both are customFunctions with nofdiff; every transform in this chapter is built to substitute outside them by exploiting a commutation property, never by callingsp.diffon an expression where they wrap a variable-dependent argument (see 3. Writing the Lagrangian for the full statement of this trap).as_coeff_Mul()is the wrong tool for peeling a coefficient off anIndexedatom. It defaults torational=Trueand only pulls out Rational coefficients, silently leaving Dummy/Symbol factors (like thealphafrom_fermion_transform) bundled into the “atom” side. Code that splitscoeff * Indexed(...)(e.g.expand_bilinear’s_split_indexed_term, 8. Extracting Vertices) must instead find the singleIndexedfactor explicitly viaas_ordered_factors().Discrete-multiplet assignment rejects mixed Fermion/non-Fermion members (
DiscreteSymmetry.assign, 2. Declaration: Parameters, Fields, Groups) — a design consequence of the field-leg (xreplace) vs. fermion-leg (.replace()with index-preserving builder) split described above.
Validation¶
tests/test_invariance.py::TestGaugeInvariance,::TestFermionGaugeInvariance— the \(O(\alpha)\) coefficient extraction, bosonic and fermionic.tests/test_invariance.py::TestDiscreteInvariance,::TestFermionDiscreteInvariance— finite-generator substitution, including the \(X=(M^{-1})^T\) bar-leg case for both \(S_3\) and \(Z_N\).tests/test_invariance.py::TestHermiticityAndDimension— bosonic hermiticity and power counting.tests/test_dirac.py— the Clifford algebra anddirac_conjugatecatalog directly.tests/test_invariance.py::TestModelReport—Model.check_invariance()end to end, including theInvarianceReportfailure-collection contract.
Minimal snippet¶
report = model.check_invariance(hermiticity=True, dimension=True)
if not report.ok:
report.raise_on_failure() # or inspect report.failures directly