"""Feynman diagram representation using networkx.
Each diagram is a MultiGraph where:
- Nodes are either external points (observable fields) or interaction vertices
- Edges are propagators (C or R)
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from itertools import permutations, product as iter_product
from typing import Sequence
import networkx as nx
from .fields import FieldOperator
from .propagators import contract_pair
from .vertices import VertexInstance
from .wick import Pairing
[docs]
@dataclass
class FeynmanDiagram:
"""Graph-based representation of a single Feynman diagram."""
graph: nx.MultiGraph = field(default_factory=nx.MultiGraph)
_node_counter: int = field(default=0, repr=False)
[docs]
def add_external_point(
self,
label: str,
field_type: str,
component: str | None = None,
spatial: str = "",
full_label: str | None = None,
) -> str:
"""Add an external point (observable field) to the diagram.
Args:
label: Default display label for the node (typically a
compact form like ``"$\\phi_a$"``).
field_type: ``"physical"`` or ``"response"``.
component: Component index (``None`` for scalar fields).
spatial: Spatial argument string.
full_label: Optional richer label that includes the
spatial argument (e.g. ``"$\\phi_a(x_1)$"``).
Stashed under the ``full_label`` node attribute so
renderers can opt into it via the ``LABEL_FULL``
format flag. When ``None`` no ``full_label`` key is
set (renderers fall back to ``label``).
Returns:
The unique node ID assigned to this external point.
"""
node_id = f"ext_{self._node_counter}"
self._node_counter += 1
node_data: dict[str, object] = dict(
node_type="external",
label=label,
field_type=field_type,
component=component,
spatial=spatial,
)
if full_label is not None:
node_data["full_label"] = full_label
self.graph.add_node(node_id, **node_data)
return node_id
[docs]
def add_vertex(
self,
coupling: str,
copy_id: int = 0,
spatial_vars: Sequence[str] = (),
) -> str:
"""Add an interaction vertex to the diagram.
Args:
coupling: Coupling constant name (e.g. ``"F"``, ``"g"``).
copy_id: Which copy of the vertex in the expansion.
spatial_vars: Spatial integration variables for this vertex.
Returns:
The unique node ID assigned to this vertex.
"""
node_id = f"vert_{self._node_counter}"
self._node_counter += 1
self.graph.add_node(
node_id,
node_type="vertex",
label=coupling,
coupling=coupling,
copy_id=copy_id,
spatial_vars=list(spatial_vars),
)
return node_id
[docs]
def add_propagator(
self,
node1: str,
node2: str,
kind: str,
index_left: str | None = None,
index_right: str | None = None,
spatial_left: str = "",
spatial_right: str = "",
phi_end: str | None = None,
psi_end: str | None = None,
) -> None:
"""Add a propagator edge between two nodes.
Args:
node1: Source node ID.
node2: Target node ID.
kind: ``"C"`` for correlation or ``"R"`` for response.
index_left: Left component index (``None`` for scalars).
index_right: Right component index (``None`` for scalars).
spatial_left: Left spatial argument.
spatial_right: Right spatial argument.
phi_end: For R propagators, the node ID on the physical
(φ) side. ``None`` for C propagators.
psi_end: For R propagators, the node ID on the response
(ψ) side. ``None`` for C propagators.
Note:
Arrow-direction convention for R propagators. An R edge
``R = ⟨φ ψ⟩`` is *directed*: when rendered, the arrowhead
points **from the response (ψ) end to the physical (φ)
end** — i.e. the arrow lands on ``phi_end``. This encodes
the causal/retarded flow (a perturbation entering at the ψ
leg produces the response at the φ leg). Both renderers
honour this: :class:`~sft_wick.drawing.DiagramRenderer`
(matplotlib) and
:class:`~sft_wick.drawing_tikz.TikzRenderer` (TikZ). C
propagators are undirected and carry no arrow.
"""
self.graph.add_edge(
node1,
node2,
kind=kind,
index_left=index_left,
index_right=index_right,
spatial_left=spatial_left,
spatial_right=spatial_right,
phi_end=phi_end,
psi_end=psi_end,
)
@property
def external_nodes(self) -> list[str]:
return [n for n, d in self.graph.nodes(data=True) if d.get("node_type") == "external"]
@property
def vertex_nodes(self) -> list[str]:
return [n for n, d in self.graph.nodes(data=True) if d.get("node_type") == "vertex"]
@property
def n_loops(self) -> int:
"""Number of loops = E - V + connected_components."""
e = self.graph.number_of_edges()
v = self.graph.number_of_nodes()
c = nx.number_connected_components(self.graph)
return e - v + c
@property
def is_connected(self) -> bool:
if self.graph.number_of_nodes() == 0:
return True
return nx.is_connected(self.graph)
[docs]
@classmethod
def from_pairing(
cls,
observable_ops: list[FieldOperator],
vertex_instances: list[VertexInstance],
pairing: Pairing,
) -> FeynmanDiagram:
"""Construct a diagram from a Wick contraction pairing.
Args:
observable_ops: External field operators.
vertex_instances: Instantiated interaction vertices.
pairing: Tuple of ``(i, j)`` index pairs from Wick
contraction.
Returns:
A fully-constructed ``FeynmanDiagram`` with external nodes,
vertex nodes, and propagator edges.
"""
diagram = cls()
# Build the full operator list (same order as in wick contraction)
all_ops: list[FieldOperator] = list(observable_ops)
for vi in vertex_instances:
all_ops.extend(vi.field_operators)
# Map operator UID -> graph node ID
uid_to_node: dict[int, str] = {}
# Add external nodes. The displayed label is *compact* by
# default ($\phi_a$); the full form ($\phi_a(x_1)$) is stashed
# in ``full_label`` so the rendering layer can opt back into
# it via ``label_format=LABEL_FULL``. See render_labels.py.
for op in observable_ops:
name = r"\phi" if op.is_physical else r"\psi"
if op.component_index is not None:
compact_label = rf"${name}_{{{op.component_index}}}$"
full_label = rf"${name}_{{{op.component_index}}}({op.spatial_arg})$"
else:
compact_label = f"${name}$"
full_label = rf"${name}({op.spatial_arg})$"
node_id = diagram.add_external_point(
label=compact_label,
field_type=op.field_type.value,
component=op.component_index,
spatial=op.spatial_arg,
full_label=full_label,
)
uid_to_node[op.uid] = node_id
# Add vertex nodes (one per vertex instance, not per operator)
vi_to_node: dict[int, str] = {}
for vi in vertex_instances:
node_id = diagram.add_vertex(
coupling=vi.vertex.coupling,
copy_id=vi.copy_id,
spatial_vars=vi.spatial_variables,
)
vi_to_node[vi.copy_id] = node_id
for op in vi.field_operators:
if op.uid in uid_to_node:
raise ValueError(
f"UID collision: vertex operator {op} has the same "
f"uid={op.uid} as a previously registered operator. "
f"Call reset_uid_counter() before creating fields, "
f"not between field creation and compute_moment()."
)
uid_to_node[op.uid] = node_id
# Add edges for each contraction pair
for i, j in pairing:
op_i, op_j = all_ops[i], all_ops[j]
prop = contract_pair(op_i, op_j)
if prop is not None:
node_a = uid_to_node[op_i.uid]
node_b = uid_to_node[op_j.uid]
# Track R-propagator direction (which end is φ, which is ψ)
phi_end = None
psi_end = None
if prop.kind == "R":
if op_i.is_physical:
phi_end, psi_end = node_a, node_b
else:
phi_end, psi_end = node_b, node_a
diagram.add_propagator(
node_a,
node_b,
kind=prop.kind,
index_left=prop.index_left,
index_right=prop.index_right,
spatial_left=prop.spatial_left,
spatial_right=prop.spatial_right,
phi_end=phi_end,
psi_end=psi_end,
)
return diagram
[docs]
def summary(self, short: bool = False) -> str:
"""Short textual description of the diagram topology.
Args:
short: If ``True``, return a compact single-line summary
suitable for subplot titles.
"""
n_ext = len(self.external_nodes)
n_vert = len(self.vertex_nodes)
n_edges = self.graph.number_of_edges()
loops = self.n_loops
if short:
conn = "conn" if self.is_connected else "disc"
return f"{n_ext}ext, {n_vert}vert, {n_edges}prop, {loops}L, {conn}"
conn = "connected" if self.is_connected else "disconnected"
return f"Diagram: {n_ext} external, {n_vert} vertices, {n_edges} propagators, {loops} loops, {conn}"