6. Mass Matrices

Physics statement

Once the vacuum is fixed (5. Spontaneous Symmetry Breaking), the physical mass spectrum is read off the second derivatives of the potential (scalars) or the kinetic sector (gauge bosons) at that vacuum — the Hessian of \(V\) is, by definition, the mass-squared matrix of the fluctuation fields. Fermion masses come from a different place: the coefficient of the bilinear mass term in the Yukawa/mass sector, evaluated at the vacuum.

Real scalar fluctuations

scalar_mass_matrix(potential, vacuum, fields, tadpole_subs=None) (vacuum/masses.py:38) is the direct implementation: \(M^2_{ij} = \partial^2V/\partial\phi_i\partial\phi_j\vert_{\rm vacuum}\), computed with sp.derive_by_array applied twice (build_mass_matrix, masses.py:20) over the vacuum-shifted potential, then evaluated at the vacuum and tadpole-substituted (_at_vacuum_matrix, masses.py:29 — factor each nonzero entry so downstream diagonalization sees the simplest possible symbolic form). fields here are real fluctuation symbols — a CP-even block, a CP-odd block, or any other real sub-block the caller wants a Hessian for.

The Dummy-conjugate trick (complex/charged fields)

A charged scalar’s physical mass term comes from \(M^2_{ij} = \partial^2V/\partial\bar\phi_i\partial\phi_j\) — differentiating with respect to the conjugate field. SymPy has no notion of differentiating with respect to conjugate(phi) directly (it treats conjugate(phi) as a function of phi, not an independent variable, so sp.diff against it either errors or returns something meaningless). charged_mass_matrix (vacuum/masses.py:55) works around this with the Dummy-conjugate trick, ported from the DLRSM1 reference:

  1. Replace every conjugate(field) in the potential with a fresh, unrelated sp.Dummy symbol: dummies = {conjugate(f): Dummy(f"{f.name}_conj") for f in fields}.

  2. Differentiate build_mass_matrix with respect to the Dummies (rows) and the original fields (columns) — now a completely ordinary independent-variable derivative, since a Dummy has no algebraic relationship to f as far as SymPy is concerned.

  3. Substitute the Dummies back to conjugate(field) in the resulting matrix entries.

  4. Evaluate at the vacuum and apply tadpole substitutions as before.

The trick works because \(\partial^2V/\partial\bar\phi_i\partial\phi_j\) only needs \(\bar\phi_i\) to behave as an independent variable from \(\phi_i\) for the duration of the differentiation — which is exactly what “treat \(\phi\) and \(\bar\phi\) as independent for holomorphic/antiholomorphic calculus” means in the Wirtinger-derivative sense physicists use implicitly when they write \(\partial V/\partial\phi^*\). Introducing a literal SymPy Dummy in place of conjugate(phi) makes that independence explicit and mechanical rather than relying on SymPy inferring it (which it does not).

Gauge boson masses

gauge_mass_matrix(kinetic, vacuum, gauge_components, tadpole_subs=None) (vacuum/masses.py:74) starts from the kinetic sector rather than the potential: \(\mathcal L_{\rm kin} = (D_\mu\phi)^\dagger(D^\mu\phi)\) evaluated at the vacuum. At the vacuum every scalar is constant (all fluctuations set to zero, VEVs are genuine constants), so every PartialMu term vanishes identically — the algorithm applies at_vac.replace(PartialMu, lambda arg: 0) directly rather than going through the momentum-space machinery, since there is nothing left to carry a momentum tag once the derivative terms are gone. What survives is purely the \(-igT^aA^a_\mu\langle\phi\rangle\) piece of \(D_\mu\phi\), squared — a pure quadratic form in the gauge boson components, whose Hessian \(M^2_{ab} = \partial^2\mathcal L_{\rm kin,vac}/\partial A^a\partial A^b\) is the gauge mass matrix, with no sign flip (the vector mass term in the Lagrangian is conventionally written \(+\tfrac12 M^2_{ab}A^aA^b\), matching how \((D_\mu\phi)^\dagger(D^\mu\phi)\) already carries the correct overall sign for a kinetic-derived mass term, unlike the potential-derived scalar case where \(\mathcal L \supset -V\) flips the sign relative to a naive Hessian of \(\mathcal L\) itself).

Fermion mass matrices

Fermion masses live on the other track entirely (8. Extracting Vertices): fermion_mass_matrix(L_fermionic, bar_base, field_base, vacuum, nflavors, indices, gamma=None) (vertices/bilinear.py:218) collects the coefficient of Bilinear(bar_base[i], Γ, field_base[j]) in the vacuum-evaluated fermionic Lagrangian (after expand_bilinear distributes any composite legs), normalizes each term’s flavor indices to the canonical (i, j) symbols, and builds the matrix as \(M_{ij} = -\text{coefficient}\) — the minus sign because the Lagrangian mass term is conventionally written \(\mathcal L \supset -\bar\psi M\chi\) (CONVENTIONS.md), the fermionic analog of the potential’s \(\mathcal L \supset -V\) sign flip above.

Validation

  • tests/test_scalar_pipeline_sm.py::test_higgs_and_goldstone_masses — the SM real/charged scalar Hessians, \(m_h^2 = 2\lambda v^2\).

  • tests/test_scalar_pipeline_thdm.py::test_cp_even_mass_matrix, ::test_charged_mass_matrix_and_mHp — the Dummy-conjugate trick exercised on the 2HDM charged sector.

  • tests/test_gauge_sector_sm.py::test_gauge_mass_matrix — the SM \(W\)/\(B\) mass matrix from the kinetic sector, \(m_W = gv/2\).

  • tests/test_fermion_sector.py::TestYukawa — the fermion mass matrix from a Yukawa sector.

Minimal snippet

h = sp.Symbol("H0_r", real=True)
Mh2 = model.mass_matrix([h])                 # [[2 * lam * v**2]]
MW2 = model.gauge_mass_matrix([W1, W2, W3, B])