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:

  1. Introduce one real Dummy parameter \(\alpha^a\) per generator of group.

  2. Build the substitution map _transform_map sending every component comp → comp + iα^a(T^a\cdot\text{comps})_i (first-order in \(\alpha\)), and the identical linear substitution for PartialMu(comp).

  3. Apply the map (after Leibniz-expanding any compound PartialMu, so only atomic PartialMu(component) nodes remain to match the substitution keys), plus the parallel fermion-leg transform if the term contains a Bilinear.

  4. Compute delta = expand(transformed − term), then for each \(\alpha^a\) take diff(delta, α^a) and set every \(\alpha\) to zero — this isolates the \(O(\alpha)\) coefficient of each generator.

  5. 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,

\[ \partial_\mu(\delta\phi) \;=\; \partial_\mu\bigl(i\alpha^aT^a\phi\bigr) \;=\; i\alpha^aT^a(\partial_\mu\phi) \;=\; \delta(\partial_\mu\phi). \]

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

\[ \delta\psi_r[i] \;=\; i\alpha\sum_c T_{rc}\,\psi_c[i], \qquad \delta\bar\psi_r[i] \;=\; -i\alpha\sum_c \bar\psi_c[i]\,T_{cr} \]

— 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\):

\[ \sum_i \bar\psi_i'\psi_i' \;=\; \sum_i\Bigl(\sum_k X_{ik}\bar\psi_k\Bigr)\Bigl(\sum_l M_{il}\psi_l\Bigr) \;=\; \sum_{k,l}\Bigl(\sum_i X_{ik}M_{il}\Bigr)\bar\psi_k\psi_l . \]

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.

\[ X = (M^{-1})^T . \]

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

\[ \bigl(\bar\psi_1\Gamma\psi_2\bigr)^\dagger \;=\; \bar\psi_2\,\bar\Gamma\,\psi_1, \qquad \bar\Gamma \equiv \gamma^0\Gamma^\dagger\gamma^0, \]

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 custom Functions with no fdiff; every transform in this chapter is built to substitute outside them by exploiting a commutation property, never by calling sp.diff on 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 an Indexed atom. It defaults to rational=True and only pulls out Rational coefficients, silently leaving Dummy/Symbol factors (like the alpha from _fermion_transform) bundled into the “atom” side. Code that splits coeff * Indexed(...) (e.g. expand_bilinear’s _split_indexed_term, 8. Extracting Vertices) must instead find the single Indexed factor explicitly via as_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 and dirac_conjugate catalog directly.

  • tests/test_invariance.py::TestModelReport — Model.check_invariance() end to end, including the InvarianceReport failure-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