Source code for feynlag.groups.discrete

"""Discrete symmetries: Z_N and S_3 (extensible to other finite groups).

A discrete group needs only (a) irrep labels with finite generator matrices
and (b) the assignment of field multiplets to irreps.  Invariance of a term
under the whole group is equivalent to invariance under the generators.

Multiplets may span several :class:`~feynlag.fields.Field` objects — e.g. an
S₃ doublet of Higgs SU(2) doublets ``(H1, H2)``: the generator mixes the two
fields component-by-component (each gauge component transforms in parallel).
"""

import sympy as sp

from .base import SymmetryGroup

__all__ = ["DiscreteSymmetry", "ZN", "S3"]


def _component_list(obj):
    """Component symbols of a Field, or a bare Symbol wrapped in a list."""
    if hasattr(obj, "components"):
        return list(obj.components)
    if isinstance(obj, sp.Symbol):
        return [obj]
    raise TypeError(f"expected a Field or Symbol, got {type(obj)}")


def _is_fermion(obj):
    from ..fields import Fermion
    return isinstance(obj, Fermion)


[docs] class DiscreteSymmetry(SymmetryGroup): """Base class: irreps defined by generator matrices. Subclasses populate ``self._irrep_generators``: ``{irrep_label: [Matrix, ...]}`` — one matrix per group generator, of dimension ``dim(irrep)``. """
[docs] def __init__(self, name): super().__init__(name) self._irrep_generators = {} #: list of (irrep_label, tuple_of_multiplet_members) self.assignments = []
@property def irreps(self): return list(self._irrep_generators) def rep_dim(self, irrep): return self._irrep_generators[irrep][0].shape[0] def assign(self, irrep, *multiplet): """Assign a field multiplet to an irrep. ``multiplet`` has ``dim(irrep)`` members (Fields or Symbols); members of a >1-dimensional multiplet must have matching component structure. Example: ``S3.assign('2', H1, H2)`` — (H1, H2) is an S₃ doublet. """ if irrep not in self._irrep_generators: raise ValueError(f"{self.name} has no irrep {irrep!r}; " f"available: {self.irreps}") dim = self.rep_dim(irrep) if len(multiplet) != dim: raise ValueError(f"irrep {irrep!r} of {self.name} needs " f"{dim} member(s), got {len(multiplet)}") lengths = {len(_component_list(m)) for m in multiplet} if len(lengths) != 1: raise ValueError("multiplet members must have matching " "component structure") fermion_flags = {_is_fermion(m) for m in multiplet} if len(fermion_flags) != 1: raise ValueError( "a discrete multiplet cannot mix Fermion and non-Fermion " "members (their components transform via structurally " "different substitution mechanisms — Indexed vs. plain " "Symbol — so a mixed assignment would silently drop " "invariance checking for one side)") self.assignments.append((irrep, tuple(multiplet))) return self def generator_maps(self): """Substitution dicts, one per group generator, over all assignments. Map ``g``: for each multiplet ``(m₁…m_d)`` in irrep ``r`` with generator matrix ``M = ρ_r(g)``, each component slot ``j`` transforms as ``m_i[j] → Σ_k M[i,k] m_k[j]``. ``PartialMu(m_i[j])`` gets the identical linear map (discrete generators are spacetime-constant, so ``∂_μ`` commutes with them exactly) — this lets ``feynlag.invariance.check_discrete_invariance`` handle ``Dmu``-built kinetic terms without differentiating through the opaque ``PartialMu`` wrapper. Fermion multiplets are skipped here (their components are ``IndexedBase``, never used as bare atoms — only as ``Indexed(base, flavor)`` inside ``Bilinear``\\ s); they go through :meth:`fermion_generator_data` instead. """ from ..operators import PartialMu n_gens = len(next(iter(self._irrep_generators.values()))) maps = [dict() for _ in range(n_gens)] for irrep, multiplet in self.assignments: if _is_fermion(multiplet[0]): continue comp_lists = [_component_list(m) for m in multiplet] n_slots = len(comp_lists[0]) for gen_index, M in enumerate(self._irrep_generators[irrep]): for i in range(len(multiplet)): for j in range(n_slots): image = sum(M[i, k] * comp_lists[k][j] for k in range(len(multiplet))) dimage = sum(M[i, k] * PartialMu(comp_lists[k][j]) for k in range(len(multiplet))) maps[gen_index][comp_lists[i][j]] = image maps[gen_index][PartialMu(comp_lists[i][j])] = dimage return maps def components(self): """All component symbols across every assigned multiplet.""" return [c for _, multiplet in self.assignments for m in multiplet for c in _component_list(m)] def fermion_generator_data(self): """Per-generator transform data for fermion multiplets. Discrete transformations are *finite* (not infinitesimal), so a multiplet member substitutes directly: ``ψ'_i = Σ_k M[i,k] ψ_k`` (the same formula :meth:`generator_maps` uses for bosons). Fermion components are ``IndexedBase``-typed and only ever appear as ``Indexed(base, flavor)``, so this can't be a flat substitution dict (the flavor index on the matched node must be preserved) — instead it returns, per generator, a dict:: {IndexedBase: (parallel_list_of_IndexedBase_at_that_slot, local_index_i, transform_matrix)} which a ``.replace()``-based builder consumes (see ``feynlag.invariance._fermion_transform_discrete``). The bar leg uses ``X = (M⁻¹)ᵀ`` rather than ``M``: for ``ψ̄Γψ`` (summed diagonally over the multiplet index) to be invariant under ``ψ_i → Σ_k M[i,k]ψ_k``, requiring ``Σ_i ψ̄'_iψ'_i = Σ_i ψ̄_iψ_i`` for the substitution ``ψ̄'_i = Σ_k X[i,k]ψ̄_k`` forces ``Σ_i X[i,k]M[i,l] = δ_{kl}``, i.e. ``XᵀM = I``, i.e. ``X = (M⁻¹)ᵀ`` (verified directly, not just asserted). Storing ``Xᵀ``'s transpose here means the *same* ``Mat[i,k]`` access pattern the field-leg builder uses also gives the correct bar-leg coefficient. ``X`` only coincides with ``M`` itself when ``M`` is real orthogonal (true for ``S3``'s irreps, not for ``ZN``'s complex-phase irreps) — computed generically via ``M.inv().T`` rather than assumed. """ n_gens = len(next(iter(self._irrep_generators.values()))) data = [dict() for _ in range(n_gens)] for irrep, multiplet in self.assignments: if not _is_fermion(multiplet[0]): continue comp_lists = [list(m.components) for m in multiplet] bar_lists = [list(m.bar_components) for m in multiplet] n_slots = len(comp_lists[0]) d = len(multiplet) for gen_index, M in enumerate(self._irrep_generators[irrep]): X = M.inv().T for i in range(d): for j in range(n_slots): comp_at_slot = [comp_lists[k][j] for k in range(d)] bar_at_slot = [bar_lists[k][j] for k in range(d)] data[gen_index][comp_lists[i][j]] = ( comp_at_slot, i, M) data[gen_index][bar_lists[i][j]] = ( bar_at_slot, i, X) return data
[docs] class ZN(DiscreteSymmetry): """Cyclic group Z_N: irrep ``k`` acts as ``φ → ω^k φ``, ``ω = e^{2πi/N}``. Assign with the integer charge: ``Z2.assign(1, H2)`` for ``H2 → -H2``. """
[docs] def __init__(self, name, N): super().__init__(name) self.N = int(N) omega = sp.exp(2 * sp.pi * sp.I / self.N) for k in range(self.N): self._irrep_generators[k] = [sp.Matrix([[omega ** k]])]
[docs] class S3(DiscreteSymmetry): """The permutation group S₃ with irreps ``'1'``, ``'1p'``, ``'2'``. Generators: ``a`` = 3-cycle, ``b`` = transposition. The 2-dimensional irrep uses the real orthogonal basis: ``ρ(a) = R(2π/3)``, ``ρ(b) = diag(1, -1)``. Clebsch–Gordan for doublets ``x = (x₁,x₂)``, ``y = (y₁,y₂)`` in this basis (2⊗2 = 1 ⊕ 1' ⊕ 2), available via :meth:`doublet_product`. """
[docs] def __init__(self, name="S3"): super().__init__(name) c, s = sp.Rational(-1, 2), sp.sqrt(3) / 2 rot = sp.Matrix([[c, -s], [s, c]]) # rotation by 2π/3 ref = sp.Matrix([[1, 0], [0, -1]]) # reflection self._irrep_generators = { "1": [sp.Matrix([[1]]), sp.Matrix([[1]])], "1p": [sp.Matrix([[1]]), sp.Matrix([[-1]])], "2": [rot, ref], }
@staticmethod def doublet_product(x, y): """CG decomposition of ``2 ⊗ 2`` in the real orthogonal basis. Args: x, y: 2-element sequences (components of the two doublets). Returns: dict with keys ``'1'``, ``'1p'``, ``'2'``: - ``'1'`` : ``x₁y₁ + x₂y₂`` (invariant), - ``'1p'`` : ``x₁y₂ − x₂y₁``, - ``'2'`` : ``(x₁y₁ − x₂y₂, −(x₁y₂ + x₂y₁))`` (doublet). The doublet component follows from ``D = conj(w·v)`` with ``w = x₁ + i x₂``, ``v = y₁ + i y₂``: under the 2π/3 rotation ``w → e^{iθ} w`` one gets ``D → e^{2πi/3} D``, and under the reflection ``D → conj(D)`` — exactly the (ρ(a), ρ(b)) action on ``(Re D, Im D)``. """ x1, x2 = x y1, y2 = y return { "1": x1 * y1 + x2 * y2, "1p": x1 * y2 - x2 * y1, "2": (x1 * y1 - x2 * y2, -(x1 * y2 + x2 * y1)), }