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 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}"