sft-wick 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sft_wick/__init__.py +191 -0
- sft_wick/_util.py +17 -0
- sft_wick/action.py +52 -0
- sft_wick/diagrams.py +378 -0
- sft_wick/drawing.py +883 -0
- sft_wick/drawing_tikz.py +497 -0
- sft_wick/evaluate.py +3982 -0
- sft_wick/expressions.py +530 -0
- sft_wick/fields.py +125 -0
- sft_wick/indices.py +30 -0
- sft_wick/latex.py +72 -0
- sft_wick/perturbation.py +2421 -0
- sft_wick/propagators.py +74 -0
- sft_wick/render_labels.py +166 -0
- sft_wick/render_layout.py +319 -0
- sft_wick/render_style.py +429 -0
- sft_wick/simplify.py +1106 -0
- sft_wick/vertices.py +217 -0
- sft_wick/wick.py +690 -0
- sft_wick/workflow/__init__.py +93 -0
- sft_wick/workflow/cache.py +120 -0
- sft_wick/workflow/cli.py +187 -0
- sft_wick/workflow/config.py +964 -0
- sft_wick/workflow/expansion.py +493 -0
- sft_wick/workflow/propagators.py +298 -0
- sft_wick/workflow/r_contracted.py +177 -0
- sft_wick/workflow/result.py +204 -0
- sft_wick/workflow/specs.py +768 -0
- sft_wick/workflow/system.py +495 -0
- sft_wick-0.2.0.dist-info/METADATA +603 -0
- sft_wick-0.2.0.dist-info/RECORD +34 -0
- sft_wick-0.2.0.dist-info/WHEEL +4 -0
- sft_wick-0.2.0.dist-info/entry_points.txt +2 -0
- sft_wick-0.2.0.dist-info/licenses/LICENSE +13 -0
sft_wick/__init__.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""sft-wick: Wick's theorem contractions for statistical field theory.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from sft_wick import Field, Vertex, Action, compute_moment
|
|
5
|
+
|
|
6
|
+
phi = Field('phi', 'physical', n_components=3)
|
|
7
|
+
psi = Field('psi', 'response', n_components=3)
|
|
8
|
+
|
|
9
|
+
v = Vertex(fields=[phi, phi, psi], coupling='F')
|
|
10
|
+
action = Action(vertices=[v])
|
|
11
|
+
|
|
12
|
+
obs = [psi('a', 'x'), phi('b', 'x'), phi('c', 'x'), phi('d', 'x')]
|
|
13
|
+
result = compute_moment(obs, action, order=1)
|
|
14
|
+
print(result.to_latex())
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .action import Action
|
|
18
|
+
from .diagrams import FeynmanDiagram
|
|
19
|
+
from .drawing import DiagramRenderer
|
|
20
|
+
from .drawing_tikz import TikzRenderer
|
|
21
|
+
from .render_labels import (
|
|
22
|
+
default_external_label,
|
|
23
|
+
default_vertex_label,
|
|
24
|
+
resolve_label,
|
|
25
|
+
)
|
|
26
|
+
from .render_layout import compute_layout
|
|
27
|
+
from .render_style import (
|
|
28
|
+
LABEL_COMPACT,
|
|
29
|
+
LABEL_FULL,
|
|
30
|
+
LABEL_TIME_F,
|
|
31
|
+
LabelStyle,
|
|
32
|
+
LayoutParams,
|
|
33
|
+
NodeStyle,
|
|
34
|
+
PropagatorStyle,
|
|
35
|
+
RenderStyle,
|
|
36
|
+
default_style,
|
|
37
|
+
grayscale_style,
|
|
38
|
+
minimal_style,
|
|
39
|
+
publication_style,
|
|
40
|
+
)
|
|
41
|
+
from .expressions import (
|
|
42
|
+
I,
|
|
43
|
+
ZERO,
|
|
44
|
+
ONE,
|
|
45
|
+
Expr,
|
|
46
|
+
ImaginaryUnit,
|
|
47
|
+
IntegralOver,
|
|
48
|
+
Product,
|
|
49
|
+
Propagator,
|
|
50
|
+
Rational,
|
|
51
|
+
Sum,
|
|
52
|
+
SumOverIndex,
|
|
53
|
+
Symbol,
|
|
54
|
+
apply_response_phase,
|
|
55
|
+
)
|
|
56
|
+
from .fields import Field, FieldOperator, FieldType, reset_uid_counter
|
|
57
|
+
from .indices import IndexContext
|
|
58
|
+
from .latex import LaTeXFormatter
|
|
59
|
+
from .evaluate import (
|
|
60
|
+
DiagramIntegrand,
|
|
61
|
+
PropagatorCache,
|
|
62
|
+
PropagatorModel,
|
|
63
|
+
SpatialStructure,
|
|
64
|
+
analyze_spatial,
|
|
65
|
+
integrate_diagrams,
|
|
66
|
+
integrate_moment,
|
|
67
|
+
integrate_two_point_qmc,
|
|
68
|
+
)
|
|
69
|
+
from .perturbation import DiagramTerm, PerturbativeResult, compute_moment, compute_moment_numerical
|
|
70
|
+
from .propagators import contract_pair
|
|
71
|
+
from .simplify import collect_by_diagram, collect_by_topology, diagonal_propagators, simplify
|
|
72
|
+
from .vertices import Vertex, VertexInstance
|
|
73
|
+
from .wick import wick_contract
|
|
74
|
+
|
|
75
|
+
# ---------- High-level workflow API (user-facing wrapper) ---------- #
|
|
76
|
+
from .workflow import ( # noqa: E402
|
|
77
|
+
build_R_contracted_callable,
|
|
78
|
+
ConstantImpulse,
|
|
79
|
+
CustomImpulse,
|
|
80
|
+
CustomKernel,
|
|
81
|
+
DiagonalA,
|
|
82
|
+
Expansion,
|
|
83
|
+
ExplicitR,
|
|
84
|
+
ExponentialSpatial,
|
|
85
|
+
ExponentialTemporal,
|
|
86
|
+
FieldSpec,
|
|
87
|
+
GaussianNoise,
|
|
88
|
+
GaussianSpatial,
|
|
89
|
+
GaussianTemporal,
|
|
90
|
+
GeneralKappa2,
|
|
91
|
+
Kappa2,
|
|
92
|
+
LegendreAngular,
|
|
93
|
+
LinearOp,
|
|
94
|
+
LocalVertex,
|
|
95
|
+
NonLocalVertex,
|
|
96
|
+
Propagators,
|
|
97
|
+
Result,
|
|
98
|
+
SeparableRotation,
|
|
99
|
+
SeparableTranslation,
|
|
100
|
+
Sigma2,
|
|
101
|
+
SweepResult,
|
|
102
|
+
System,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"build_R_contracted_callable",
|
|
107
|
+
"Action",
|
|
108
|
+
"ConstantImpulse",
|
|
109
|
+
"LABEL_COMPACT",
|
|
110
|
+
"LABEL_FULL",
|
|
111
|
+
"LABEL_TIME_F",
|
|
112
|
+
"LabelStyle",
|
|
113
|
+
"LayoutParams",
|
|
114
|
+
"NodeStyle",
|
|
115
|
+
"PropagatorStyle",
|
|
116
|
+
"RenderStyle",
|
|
117
|
+
"TikzRenderer",
|
|
118
|
+
"compute_layout",
|
|
119
|
+
"default_external_label",
|
|
120
|
+
"default_style",
|
|
121
|
+
"default_vertex_label",
|
|
122
|
+
"grayscale_style",
|
|
123
|
+
"minimal_style",
|
|
124
|
+
"publication_style",
|
|
125
|
+
"resolve_label",
|
|
126
|
+
"CustomImpulse",
|
|
127
|
+
"CustomKernel",
|
|
128
|
+
"DiagonalA",
|
|
129
|
+
"DiagramIntegrand",
|
|
130
|
+
"DiagramRenderer",
|
|
131
|
+
"DiagramTerm",
|
|
132
|
+
"Expansion",
|
|
133
|
+
"ExplicitR",
|
|
134
|
+
"ExponentialSpatial",
|
|
135
|
+
"ExponentialTemporal",
|
|
136
|
+
"Expr",
|
|
137
|
+
"FieldSpec",
|
|
138
|
+
"GaussianNoise",
|
|
139
|
+
"GaussianSpatial",
|
|
140
|
+
"GaussianTemporal",
|
|
141
|
+
"GeneralKappa2",
|
|
142
|
+
"Kappa2",
|
|
143
|
+
"LegendreAngular",
|
|
144
|
+
"LinearOp",
|
|
145
|
+
"LocalVertex",
|
|
146
|
+
"NonLocalVertex",
|
|
147
|
+
"Propagators",
|
|
148
|
+
"Result",
|
|
149
|
+
"SeparableRotation",
|
|
150
|
+
"SeparableTranslation",
|
|
151
|
+
"Sigma2",
|
|
152
|
+
"SweepResult",
|
|
153
|
+
"System",
|
|
154
|
+
"FeynmanDiagram",
|
|
155
|
+
"Field",
|
|
156
|
+
"FieldOperator",
|
|
157
|
+
"FieldType",
|
|
158
|
+
"I",
|
|
159
|
+
"ImaginaryUnit",
|
|
160
|
+
"IndexContext",
|
|
161
|
+
"IntegralOver",
|
|
162
|
+
"LaTeXFormatter",
|
|
163
|
+
"ONE",
|
|
164
|
+
"PerturbativeResult",
|
|
165
|
+
"Product",
|
|
166
|
+
"Propagator",
|
|
167
|
+
"PropagatorCache",
|
|
168
|
+
"PropagatorModel",
|
|
169
|
+
"Rational",
|
|
170
|
+
"Sum",
|
|
171
|
+
"SpatialStructure",
|
|
172
|
+
"SumOverIndex",
|
|
173
|
+
"Symbol",
|
|
174
|
+
"Vertex",
|
|
175
|
+
"VertexInstance",
|
|
176
|
+
"ZERO",
|
|
177
|
+
"analyze_spatial",
|
|
178
|
+
"integrate_diagrams",
|
|
179
|
+
"integrate_moment",
|
|
180
|
+
"integrate_two_point_qmc",
|
|
181
|
+
"apply_response_phase",
|
|
182
|
+
"collect_by_diagram",
|
|
183
|
+
"collect_by_topology",
|
|
184
|
+
"compute_moment",
|
|
185
|
+
"compute_moment_numerical",
|
|
186
|
+
"contract_pair",
|
|
187
|
+
"diagonal_propagators",
|
|
188
|
+
"reset_uid_counter",
|
|
189
|
+
"simplify",
|
|
190
|
+
"wick_contract",
|
|
191
|
+
]
|
sft_wick/_util.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Internal utility functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import prod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def double_factorial(n: int) -> int:
|
|
9
|
+
"""Compute n!! = n * (n-2) * (n-4) * ... * 1.
|
|
10
|
+
|
|
11
|
+
For odd n: n!! = 1 * 3 * 5 * ... * n
|
|
12
|
+
For even n: n!! = 2 * 4 * 6 * ... * n
|
|
13
|
+
0!! = 1, (-1)!! = 1
|
|
14
|
+
"""
|
|
15
|
+
if n <= 0:
|
|
16
|
+
return 1
|
|
17
|
+
return prod(range(n, 0, -2))
|
sft_wick/action.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Action definition and multinomial expansion for perturbation theory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from itertools import combinations_with_replacement
|
|
6
|
+
from math import factorial
|
|
7
|
+
from typing import Iterator
|
|
8
|
+
|
|
9
|
+
from .vertices import Vertex
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Action:
|
|
13
|
+
r"""The interaction action :math:`S_{\mathrm{int}}` as a sum of vertex terms.
|
|
14
|
+
|
|
15
|
+
The free action :math:`S_0` is implicit --- it defines the propagator
|
|
16
|
+
rules (C and R). The ``Action`` stores only the interaction vertices.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
vertices: List of :class:`~sft_wick.vertices.Vertex` templates
|
|
20
|
+
that make up :math:`S_{\mathrm{int}}`.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
vertices: The stored list of vertex templates.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, vertices: list[Vertex]) -> None:
|
|
27
|
+
self.vertices = list(vertices)
|
|
28
|
+
|
|
29
|
+
def all_vertex_combinations(
|
|
30
|
+
self, order: int
|
|
31
|
+
) -> Iterator[tuple[tuple[Vertex, ...], int]]:
|
|
32
|
+
"""Generate all ways to pick `order` vertices (with repetition).
|
|
33
|
+
|
|
34
|
+
S_int^n = (v_0 + v_1 + ...)^n expands via the multinomial theorem.
|
|
35
|
+
|
|
36
|
+
Yields:
|
|
37
|
+
(vertex_sequence, multinomial_coefficient) where vertex_sequence
|
|
38
|
+
has length `order` and multinomial_coefficient = n! / (n_0! * n_1! * ...).
|
|
39
|
+
"""
|
|
40
|
+
if order == 0:
|
|
41
|
+
yield (), 1
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
k = len(self.vertices)
|
|
45
|
+
for combo in combinations_with_replacement(range(k), order):
|
|
46
|
+
vertex_seq = tuple(self.vertices[i] for i in combo)
|
|
47
|
+
# Compute multinomial coefficient
|
|
48
|
+
counts = [combo.count(i) for i in range(k)]
|
|
49
|
+
coeff = factorial(order)
|
|
50
|
+
for c in counts:
|
|
51
|
+
coeff //= factorial(c)
|
|
52
|
+
yield vertex_seq, coeff
|
sft_wick/diagrams.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Feynman diagram representation using networkx.
|
|
2
|
+
|
|
3
|
+
Each diagram is a MultiGraph where:
|
|
4
|
+
- Nodes are either external points (observable fields) or interaction vertices
|
|
5
|
+
- Edges are propagators (C or R)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from itertools import permutations, product as iter_product
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
import networkx as nx
|
|
16
|
+
|
|
17
|
+
from .fields import FieldOperator
|
|
18
|
+
from .propagators import contract_pair
|
|
19
|
+
from .vertices import VertexInstance
|
|
20
|
+
from .wick import Pairing
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FeynmanDiagram:
|
|
25
|
+
"""Graph-based representation of a single Feynman diagram."""
|
|
26
|
+
|
|
27
|
+
graph: nx.MultiGraph = field(default_factory=nx.MultiGraph)
|
|
28
|
+
_node_counter: int = field(default=0, repr=False)
|
|
29
|
+
|
|
30
|
+
def add_external_point(
|
|
31
|
+
self,
|
|
32
|
+
label: str,
|
|
33
|
+
field_type: str,
|
|
34
|
+
component: str | None = None,
|
|
35
|
+
spatial: str = "",
|
|
36
|
+
full_label: str | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Add an external point (observable field) to the diagram.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
label: Default display label for the node (typically a
|
|
42
|
+
compact form like ``"$\\phi_a$"``).
|
|
43
|
+
field_type: ``"physical"`` or ``"response"``.
|
|
44
|
+
component: Component index (``None`` for scalar fields).
|
|
45
|
+
spatial: Spatial argument string.
|
|
46
|
+
full_label: Optional richer label that includes the
|
|
47
|
+
spatial argument (e.g. ``"$\\phi_a(x_1)$"``).
|
|
48
|
+
Stashed under the ``full_label`` node attribute so
|
|
49
|
+
renderers can opt into it via the ``LABEL_FULL``
|
|
50
|
+
format flag. When ``None`` no ``full_label`` key is
|
|
51
|
+
set (renderers fall back to ``label``).
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The unique node ID assigned to this external point.
|
|
55
|
+
"""
|
|
56
|
+
node_id = f"ext_{self._node_counter}"
|
|
57
|
+
self._node_counter += 1
|
|
58
|
+
node_data: dict[str, object] = dict(
|
|
59
|
+
node_type="external",
|
|
60
|
+
label=label,
|
|
61
|
+
field_type=field_type,
|
|
62
|
+
component=component,
|
|
63
|
+
spatial=spatial,
|
|
64
|
+
)
|
|
65
|
+
if full_label is not None:
|
|
66
|
+
node_data["full_label"] = full_label
|
|
67
|
+
self.graph.add_node(node_id, **node_data)
|
|
68
|
+
return node_id
|
|
69
|
+
|
|
70
|
+
def add_vertex(
|
|
71
|
+
self,
|
|
72
|
+
coupling: str,
|
|
73
|
+
copy_id: int = 0,
|
|
74
|
+
spatial_vars: Sequence[str] = (),
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Add an interaction vertex to the diagram.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
coupling: Coupling constant name (e.g. ``"F"``, ``"g"``).
|
|
80
|
+
copy_id: Which copy of the vertex in the expansion.
|
|
81
|
+
spatial_vars: Spatial integration variables for this vertex.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The unique node ID assigned to this vertex.
|
|
85
|
+
"""
|
|
86
|
+
node_id = f"vert_{self._node_counter}"
|
|
87
|
+
self._node_counter += 1
|
|
88
|
+
self.graph.add_node(
|
|
89
|
+
node_id,
|
|
90
|
+
node_type="vertex",
|
|
91
|
+
label=coupling,
|
|
92
|
+
coupling=coupling,
|
|
93
|
+
copy_id=copy_id,
|
|
94
|
+
spatial_vars=list(spatial_vars),
|
|
95
|
+
)
|
|
96
|
+
return node_id
|
|
97
|
+
|
|
98
|
+
def add_propagator(
|
|
99
|
+
self,
|
|
100
|
+
node1: str,
|
|
101
|
+
node2: str,
|
|
102
|
+
kind: str,
|
|
103
|
+
index_left: str | None = None,
|
|
104
|
+
index_right: str | None = None,
|
|
105
|
+
spatial_left: str = "",
|
|
106
|
+
spatial_right: str = "",
|
|
107
|
+
phi_end: str | None = None,
|
|
108
|
+
psi_end: str | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Add a propagator edge between two nodes.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
node1: Source node ID.
|
|
114
|
+
node2: Target node ID.
|
|
115
|
+
kind: ``"C"`` for correlation or ``"R"`` for response.
|
|
116
|
+
index_left: Left component index (``None`` for scalars).
|
|
117
|
+
index_right: Right component index (``None`` for scalars).
|
|
118
|
+
spatial_left: Left spatial argument.
|
|
119
|
+
spatial_right: Right spatial argument.
|
|
120
|
+
phi_end: For R propagators, the node ID on the physical
|
|
121
|
+
(φ) side. ``None`` for C propagators.
|
|
122
|
+
psi_end: For R propagators, the node ID on the response
|
|
123
|
+
(ψ) side. ``None`` for C propagators.
|
|
124
|
+
|
|
125
|
+
Note:
|
|
126
|
+
Arrow-direction convention for R propagators. An R edge
|
|
127
|
+
``R = ⟨φ ψ⟩`` is *directed*: when rendered, the arrowhead
|
|
128
|
+
points **from the response (ψ) end to the physical (φ)
|
|
129
|
+
end** — i.e. the arrow lands on ``phi_end``. This encodes
|
|
130
|
+
the causal/retarded flow (a perturbation entering at the ψ
|
|
131
|
+
leg produces the response at the φ leg). Both renderers
|
|
132
|
+
honour this: :class:`~sft_wick.drawing.DiagramRenderer`
|
|
133
|
+
(matplotlib) and
|
|
134
|
+
:class:`~sft_wick.drawing_tikz.TikzRenderer` (TikZ). C
|
|
135
|
+
propagators are undirected and carry no arrow.
|
|
136
|
+
"""
|
|
137
|
+
self.graph.add_edge(
|
|
138
|
+
node1,
|
|
139
|
+
node2,
|
|
140
|
+
kind=kind,
|
|
141
|
+
index_left=index_left,
|
|
142
|
+
index_right=index_right,
|
|
143
|
+
spatial_left=spatial_left,
|
|
144
|
+
spatial_right=spatial_right,
|
|
145
|
+
phi_end=phi_end,
|
|
146
|
+
psi_end=psi_end,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def external_nodes(self) -> list[str]:
|
|
151
|
+
return [n for n, d in self.graph.nodes(data=True) if d.get("node_type") == "external"]
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def vertex_nodes(self) -> list[str]:
|
|
155
|
+
return [n for n, d in self.graph.nodes(data=True) if d.get("node_type") == "vertex"]
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def n_loops(self) -> int:
|
|
159
|
+
"""Number of loops = E - V + connected_components."""
|
|
160
|
+
e = self.graph.number_of_edges()
|
|
161
|
+
v = self.graph.number_of_nodes()
|
|
162
|
+
c = nx.number_connected_components(self.graph)
|
|
163
|
+
return e - v + c
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def is_connected(self) -> bool:
|
|
167
|
+
if self.graph.number_of_nodes() == 0:
|
|
168
|
+
return True
|
|
169
|
+
return nx.is_connected(self.graph)
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_pairing(
|
|
173
|
+
cls,
|
|
174
|
+
observable_ops: list[FieldOperator],
|
|
175
|
+
vertex_instances: list[VertexInstance],
|
|
176
|
+
pairing: Pairing,
|
|
177
|
+
) -> FeynmanDiagram:
|
|
178
|
+
"""Construct a diagram from a Wick contraction pairing.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
observable_ops: External field operators.
|
|
182
|
+
vertex_instances: Instantiated interaction vertices.
|
|
183
|
+
pairing: Tuple of ``(i, j)`` index pairs from Wick
|
|
184
|
+
contraction.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
A fully-constructed ``FeynmanDiagram`` with external nodes,
|
|
188
|
+
vertex nodes, and propagator edges.
|
|
189
|
+
"""
|
|
190
|
+
diagram = cls()
|
|
191
|
+
|
|
192
|
+
# Build the full operator list (same order as in wick contraction)
|
|
193
|
+
all_ops: list[FieldOperator] = list(observable_ops)
|
|
194
|
+
for vi in vertex_instances:
|
|
195
|
+
all_ops.extend(vi.field_operators)
|
|
196
|
+
|
|
197
|
+
# Map operator UID -> graph node ID
|
|
198
|
+
uid_to_node: dict[int, str] = {}
|
|
199
|
+
|
|
200
|
+
# Add external nodes. The displayed label is *compact* by
|
|
201
|
+
# default ($\phi_a$); the full form ($\phi_a(x_1)$) is stashed
|
|
202
|
+
# in ``full_label`` so the rendering layer can opt back into
|
|
203
|
+
# it via ``label_format=LABEL_FULL``. See render_labels.py.
|
|
204
|
+
for op in observable_ops:
|
|
205
|
+
name = r"\phi" if op.is_physical else r"\psi"
|
|
206
|
+
if op.component_index is not None:
|
|
207
|
+
compact_label = rf"${name}_{{{op.component_index}}}$"
|
|
208
|
+
full_label = rf"${name}_{{{op.component_index}}}({op.spatial_arg})$"
|
|
209
|
+
else:
|
|
210
|
+
compact_label = f"${name}$"
|
|
211
|
+
full_label = rf"${name}({op.spatial_arg})$"
|
|
212
|
+
|
|
213
|
+
node_id = diagram.add_external_point(
|
|
214
|
+
label=compact_label,
|
|
215
|
+
field_type=op.field_type.value,
|
|
216
|
+
component=op.component_index,
|
|
217
|
+
spatial=op.spatial_arg,
|
|
218
|
+
full_label=full_label,
|
|
219
|
+
)
|
|
220
|
+
uid_to_node[op.uid] = node_id
|
|
221
|
+
|
|
222
|
+
# Add vertex nodes (one per vertex instance, not per operator)
|
|
223
|
+
vi_to_node: dict[int, str] = {}
|
|
224
|
+
for vi in vertex_instances:
|
|
225
|
+
node_id = diagram.add_vertex(
|
|
226
|
+
coupling=vi.vertex.coupling,
|
|
227
|
+
copy_id=vi.copy_id,
|
|
228
|
+
spatial_vars=vi.spatial_variables,
|
|
229
|
+
)
|
|
230
|
+
vi_to_node[vi.copy_id] = node_id
|
|
231
|
+
for op in vi.field_operators:
|
|
232
|
+
if op.uid in uid_to_node:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"UID collision: vertex operator {op} has the same "
|
|
235
|
+
f"uid={op.uid} as a previously registered operator. "
|
|
236
|
+
f"Call reset_uid_counter() before creating fields, "
|
|
237
|
+
f"not between field creation and compute_moment()."
|
|
238
|
+
)
|
|
239
|
+
uid_to_node[op.uid] = node_id
|
|
240
|
+
|
|
241
|
+
# Add edges for each contraction pair
|
|
242
|
+
for i, j in pairing:
|
|
243
|
+
op_i, op_j = all_ops[i], all_ops[j]
|
|
244
|
+
prop = contract_pair(op_i, op_j)
|
|
245
|
+
if prop is not None:
|
|
246
|
+
node_a = uid_to_node[op_i.uid]
|
|
247
|
+
node_b = uid_to_node[op_j.uid]
|
|
248
|
+
|
|
249
|
+
# Track R-propagator direction (which end is φ, which is ψ)
|
|
250
|
+
phi_end = None
|
|
251
|
+
psi_end = None
|
|
252
|
+
if prop.kind == "R":
|
|
253
|
+
if op_i.is_physical:
|
|
254
|
+
phi_end, psi_end = node_a, node_b
|
|
255
|
+
else:
|
|
256
|
+
phi_end, psi_end = node_b, node_a
|
|
257
|
+
|
|
258
|
+
diagram.add_propagator(
|
|
259
|
+
node_a,
|
|
260
|
+
node_b,
|
|
261
|
+
kind=prop.kind,
|
|
262
|
+
index_left=prop.index_left,
|
|
263
|
+
index_right=prop.index_right,
|
|
264
|
+
spatial_left=prop.spatial_left,
|
|
265
|
+
spatial_right=prop.spatial_right,
|
|
266
|
+
phi_end=phi_end,
|
|
267
|
+
psi_end=psi_end,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return diagram
|
|
271
|
+
|
|
272
|
+
def canonical_form(self) -> tuple:
|
|
273
|
+
"""Return a hashable canonical form for this diagram's topology.
|
|
274
|
+
|
|
275
|
+
Two diagrams have the same canonical form if and only if they
|
|
276
|
+
are isomorphic under relabeling of vertex nodes that share the
|
|
277
|
+
same coupling type. External nodes are distinguished by their
|
|
278
|
+
position in the observable. Edge kind (C/R) and R-direction
|
|
279
|
+
are structural; component indices and spatial arguments on
|
|
280
|
+
edges are ignored.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
A hashable tuple ``(ext_meta, vert_meta, edges)`` that is
|
|
284
|
+
identical for topologically equivalent diagrams.
|
|
285
|
+
"""
|
|
286
|
+
g = self.graph
|
|
287
|
+
ext_nodes = self.external_nodes
|
|
288
|
+
vert_nodes = self.vertex_nodes
|
|
289
|
+
|
|
290
|
+
# External nodes are distinguishable — label them 0..N-1
|
|
291
|
+
ext_label: dict[str, int] = {}
|
|
292
|
+
for i, n in enumerate(ext_nodes):
|
|
293
|
+
ext_label[n] = i
|
|
294
|
+
|
|
295
|
+
# Group vertex nodes by coupling type
|
|
296
|
+
coupling_groups: dict[str, list[str]] = defaultdict(list)
|
|
297
|
+
for n in vert_nodes:
|
|
298
|
+
coupling_groups[g.nodes[n]["coupling"]].append(n)
|
|
299
|
+
|
|
300
|
+
sorted_couplings = sorted(coupling_groups.keys())
|
|
301
|
+
group_lists = [coupling_groups[c] for c in sorted_couplings]
|
|
302
|
+
|
|
303
|
+
# Try all permutations within each coupling group
|
|
304
|
+
perm_generators = [permutations(grp) for grp in group_lists]
|
|
305
|
+
|
|
306
|
+
best: tuple | None = None
|
|
307
|
+
|
|
308
|
+
for perm_combo in iter_product(*perm_generators):
|
|
309
|
+
label_map = dict(ext_label)
|
|
310
|
+
counter = len(ext_nodes)
|
|
311
|
+
for group_perm in perm_combo:
|
|
312
|
+
for node in group_perm:
|
|
313
|
+
label_map[node] = counter
|
|
314
|
+
counter += 1
|
|
315
|
+
|
|
316
|
+
edges: list[tuple] = []
|
|
317
|
+
for u, v, data in g.edges(data=True):
|
|
318
|
+
kind = data["kind"]
|
|
319
|
+
if kind == "C":
|
|
320
|
+
lu, lv = label_map[u], label_map[v]
|
|
321
|
+
edges.append(("C", min(lu, lv), max(lu, lv)))
|
|
322
|
+
else:
|
|
323
|
+
# R is directed: use phi_end / psi_end if available
|
|
324
|
+
pe = data.get("phi_end")
|
|
325
|
+
se = data.get("psi_end")
|
|
326
|
+
if pe is not None and se is not None:
|
|
327
|
+
edges.append(("R", label_map[pe], label_map[se]))
|
|
328
|
+
else:
|
|
329
|
+
# Fallback: use storage order
|
|
330
|
+
edges.append(("R", label_map[u], label_map[v]))
|
|
331
|
+
|
|
332
|
+
form = tuple(sorted(edges))
|
|
333
|
+
if best is None or form < best:
|
|
334
|
+
best = form
|
|
335
|
+
|
|
336
|
+
# If no vertices (and thus no permutations to try), handle the
|
|
337
|
+
# trivial case where iter_product produces exactly one empty combo
|
|
338
|
+
if best is None:
|
|
339
|
+
label_map = dict(ext_label)
|
|
340
|
+
edges = []
|
|
341
|
+
for u, v, data in g.edges(data=True):
|
|
342
|
+
kind = data["kind"]
|
|
343
|
+
lu, lv = label_map.get(u, -1), label_map.get(v, -1)
|
|
344
|
+
if kind == "C":
|
|
345
|
+
edges.append(("C", min(lu, lv), max(lu, lv)))
|
|
346
|
+
else:
|
|
347
|
+
pe = data.get("phi_end")
|
|
348
|
+
se = data.get("psi_end")
|
|
349
|
+
if pe is not None and se is not None:
|
|
350
|
+
edges.append(("R", label_map.get(pe, -1), label_map.get(se, -1)))
|
|
351
|
+
else:
|
|
352
|
+
edges.append(("R", lu, lv))
|
|
353
|
+
best = tuple(sorted(edges))
|
|
354
|
+
|
|
355
|
+
ext_meta = tuple(
|
|
356
|
+
(g.nodes[n]["field_type"], g.nodes[n].get("component"), g.nodes[n]["spatial"])
|
|
357
|
+
for n in ext_nodes
|
|
358
|
+
)
|
|
359
|
+
vert_meta = tuple((c, len(coupling_groups[c])) for c in sorted_couplings)
|
|
360
|
+
|
|
361
|
+
return (ext_meta, vert_meta, best)
|
|
362
|
+
|
|
363
|
+
def summary(self, short: bool = False) -> str:
|
|
364
|
+
"""Short textual description of the diagram topology.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
short: If ``True``, return a compact single-line summary
|
|
368
|
+
suitable for subplot titles.
|
|
369
|
+
"""
|
|
370
|
+
n_ext = len(self.external_nodes)
|
|
371
|
+
n_vert = len(self.vertex_nodes)
|
|
372
|
+
n_edges = self.graph.number_of_edges()
|
|
373
|
+
loops = self.n_loops
|
|
374
|
+
if short:
|
|
375
|
+
conn = "conn" if self.is_connected else "disc"
|
|
376
|
+
return f"{n_ext}ext, {n_vert}vert, {n_edges}prop, {loops}L, {conn}"
|
|
377
|
+
conn = "connected" if self.is_connected else "disconnected"
|
|
378
|
+
return f"Diagram: {n_ext} external, {n_vert} vertices, {n_edges} propagators, {loops} loops, {conn}"
|