flopscope 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.
Files changed (115) hide show
  1. benchmarks/__init__.py +1 -0
  2. benchmarks/__main__.py +6 -0
  3. benchmarks/_baseline.py +171 -0
  4. benchmarks/_bitwise.py +231 -0
  5. benchmarks/_complex.py +176 -0
  6. benchmarks/_contractions.py +291 -0
  7. benchmarks/_fft.py +198 -0
  8. benchmarks/_impl_urls.py +139 -0
  9. benchmarks/_linalg.py +197 -0
  10. benchmarks/_linalg_delegates.py +407 -0
  11. benchmarks/_metadata.py +141 -0
  12. benchmarks/_misc.py +653 -0
  13. benchmarks/_perf.py +321 -0
  14. benchmarks/_perm_group_calibration.py +175 -0
  15. benchmarks/_pointwise.py +372 -0
  16. benchmarks/_polynomial.py +193 -0
  17. benchmarks/_random.py +209 -0
  18. benchmarks/_reductions.py +136 -0
  19. benchmarks/_sorting.py +289 -0
  20. benchmarks/_stats.py +137 -0
  21. benchmarks/_window.py +92 -0
  22. benchmarks/accumulation/__init__.py +0 -0
  23. benchmarks/accumulation/bench_cost_compute.py +138 -0
  24. benchmarks/dashboard.py +312 -0
  25. benchmarks/runner.py +636 -0
  26. flopscope/__init__.py +273 -0
  27. flopscope/_accumulation/__init__.py +13 -0
  28. flopscope/_accumulation/_bipartite.py +121 -0
  29. flopscope/_accumulation/_burnside.py +51 -0
  30. flopscope/_accumulation/_cache.py +146 -0
  31. flopscope/_accumulation/_components.py +153 -0
  32. flopscope/_accumulation/_cost.py +1414 -0
  33. flopscope/_accumulation/_cost_descriptions.py +63 -0
  34. flopscope/_accumulation/_detection.py +318 -0
  35. flopscope/_accumulation/_ladder.py +191 -0
  36. flopscope/_accumulation/_output_orbit.py +104 -0
  37. flopscope/_accumulation/_partition.py +290 -0
  38. flopscope/_accumulation/_path_info.py +211 -0
  39. flopscope/_accumulation/_public.py +169 -0
  40. flopscope/_accumulation/_reduction.py +310 -0
  41. flopscope/_accumulation/_regimes.py +303 -0
  42. flopscope/_accumulation/_shape.py +33 -0
  43. flopscope/_accumulation/_wreath.py +209 -0
  44. flopscope/_budget.py +1027 -0
  45. flopscope/_config.py +118 -0
  46. flopscope/_counting_ops.py +451 -0
  47. flopscope/_display.py +478 -0
  48. flopscope/_docstrings.py +59 -0
  49. flopscope/_dtypes.py +20 -0
  50. flopscope/_einsum.py +717 -0
  51. flopscope/_errstate.py +25 -0
  52. flopscope/_flops.py +282 -0
  53. flopscope/_free_ops.py +2654 -0
  54. flopscope/_ndarray.py +1126 -0
  55. flopscope/_opt_einsum/LICENSE +21 -0
  56. flopscope/_opt_einsum/NOTICE +59 -0
  57. flopscope/_opt_einsum/__init__.py +209 -0
  58. flopscope/_opt_einsum/_contract.py +1478 -0
  59. flopscope/_opt_einsum/_helpers.py +164 -0
  60. flopscope/_opt_einsum/_hsluv.py +273 -0
  61. flopscope/_opt_einsum/_path_random.py +462 -0
  62. flopscope/_opt_einsum/_paths.py +1653 -0
  63. flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
  64. flopscope/_opt_einsum/_symmetry.py +140 -0
  65. flopscope/_opt_einsum/_typing.py +37 -0
  66. flopscope/_perm_group.py +717 -0
  67. flopscope/_pointwise.py +2522 -0
  68. flopscope/_polynomial.py +278 -0
  69. flopscope/_registry.py +3216 -0
  70. flopscope/_sorting_ops.py +571 -0
  71. flopscope/_symmetric.py +812 -0
  72. flopscope/_symmetry_transport.py +510 -0
  73. flopscope/_symmetry_utils.py +669 -0
  74. flopscope/_type_info.py +12 -0
  75. flopscope/_unwrap.py +70 -0
  76. flopscope/_validation.py +83 -0
  77. flopscope/_version_check.py +46 -0
  78. flopscope/_weights.py +195 -0
  79. flopscope/_window.py +177 -0
  80. flopscope/accounting.py +565 -0
  81. flopscope/data/default_weights.json +462 -0
  82. flopscope/data/weights.csv +509 -0
  83. flopscope/errors.py +197 -0
  84. flopscope/numpy/__init__.py +878 -0
  85. flopscope/numpy/fft/__init__.py +55 -0
  86. flopscope/numpy/fft/_free.py +51 -0
  87. flopscope/numpy/fft/_transforms.py +695 -0
  88. flopscope/numpy/linalg/__init__.py +105 -0
  89. flopscope/numpy/linalg/_aliases.py +126 -0
  90. flopscope/numpy/linalg/_compound.py +161 -0
  91. flopscope/numpy/linalg/_decompositions.py +353 -0
  92. flopscope/numpy/linalg/_properties.py +533 -0
  93. flopscope/numpy/linalg/_solvers.py +444 -0
  94. flopscope/numpy/linalg/_svd.py +122 -0
  95. flopscope/numpy/random/__init__.py +684 -0
  96. flopscope/numpy/random/_cost_formulas.py +115 -0
  97. flopscope/numpy/random/_counted_classes.py +241 -0
  98. flopscope/numpy/testing/__init__.py +13 -0
  99. flopscope/numpy/typing/__init__.py +30 -0
  100. flopscope/py.typed +0 -0
  101. flopscope/stats/__init__.py +84 -0
  102. flopscope/stats/_base.py +77 -0
  103. flopscope/stats/_cauchy.py +146 -0
  104. flopscope/stats/_erf.py +190 -0
  105. flopscope/stats/_expon.py +146 -0
  106. flopscope/stats/_laplace.py +150 -0
  107. flopscope/stats/_logistic.py +148 -0
  108. flopscope/stats/_lognorm.py +160 -0
  109. flopscope/stats/_ndtri.py +133 -0
  110. flopscope/stats/_norm.py +149 -0
  111. flopscope/stats/_truncnorm.py +186 -0
  112. flopscope/stats/_uniform.py +141 -0
  113. flopscope-0.2.0.dist-info/METADATA +23 -0
  114. flopscope-0.2.0.dist-info/RECORD +115 -0
  115. flopscope-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,544 @@
1
+ """Subset-keyed subgraph symmetry detection for einsum intermediates.
2
+
3
+ One oracle per contract_path call. Given the original operand list,
4
+ subscript parts, per-operand declared symmetries, and output subscript,
5
+ builds a bipartite graph once and exposes ``.sym(subset)`` which returns
6
+ a ``SubsetSymmetry`` with ``.output`` (V-side) and ``.inner`` (W-side)
7
+ symmetries, computed lazily on first access and cached.
8
+
9
+ Each axis of each operand gets its own U-vertex in the bipartite graph
10
+ (no axis merging). The σ-loop iterates over generators from three sources:
11
+ (A) per-operand internal symmetry generators, (B) identical-operand swap
12
+ generators, and (C) coordinated axis relabeling for identical operands
13
+ with the same subscript (W-side only). Dimino's algorithm builds the full
14
+ row-permutation group from these generators, and π is derived for each
15
+ group element via column-fingerprint hash lookup.
16
+
17
+ See docs/explanation/subgraph-symmetry.md for the algorithm walkthrough.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ from flopscope._perm_group import SymmetryGroup
26
+ from flopscope._perm_group import _Permutation as Perm
27
+
28
+ from ._symmetry import SubsetSymmetry
29
+
30
+ _MISSING = object()
31
+
32
+
33
+ def _derive_pi_canonical(
34
+ sigma_col_of: dict[str, tuple[int, ...]],
35
+ fp_to_labels: dict[tuple[int, ...], set[str]],
36
+ v_labels: frozenset[str],
37
+ w_labels: frozenset[str],
38
+ ) -> dict[str, str] | None:
39
+ """Build π by canonical hash lookup. Returns None on failure.
40
+
41
+ For each label ℓ, looks up σ(M)'s column fingerprint in fp_to_labels
42
+ and picks the lex-first unused candidate. Validates that π is a
43
+ bijection and preserves the V/W partition.
44
+ """
45
+ pi: dict[str, str] = {}
46
+ used: set[str] = set()
47
+ all_labels = v_labels | w_labels
48
+
49
+ for label in sorted(all_labels):
50
+ fp = sigma_col_of[label]
51
+ candidates = fp_to_labels.get(fp)
52
+ if not candidates:
53
+ return None
54
+ pick = None
55
+ for c in sorted(candidates):
56
+ if c not in used:
57
+ pick = c
58
+ break
59
+ if pick is None:
60
+ return None
61
+ pi[label] = pick
62
+ used.add(pick)
63
+
64
+ # Validate V→V and W→W.
65
+ for lbl, target in pi.items():
66
+ if lbl in v_labels and target not in v_labels:
67
+ return None
68
+ if lbl in w_labels and target not in w_labels:
69
+ return None
70
+
71
+ return pi
72
+
73
+
74
+ def _collect_pi_permutations(
75
+ graph: EinsumBipartite,
76
+ sub: _Subgraph,
77
+ row_order: tuple[int, ...],
78
+ col_of: dict[str, tuple[int, ...]],
79
+ fp_to_labels: dict[tuple[int, ...], set[str]],
80
+ ) -> tuple[list[Perm], list[Perm], list[Perm]]:
81
+ """Collect V, W, and joint permutation generators via the expanded σ-loop.
82
+
83
+ Generators come from two sources:
84
+
85
+ Source A — per-operand internal symmetry generators. For each operand
86
+ in the subset that has declared groups, each generator's array form
87
+ (mapped through ``group.axes``) permutes U-vertex positions within
88
+ that operand's block.
89
+
90
+ Source B — identical-operand swap generators. For each pair of
91
+ adjacent operands in an identical-operand group, an adjacent
92
+ transposition that swaps their entire U-vertex blocks.
93
+
94
+ For each generator, the induced column permutation π is derived via
95
+ ``_derive_pi_canonical``.
96
+
97
+ Returns
98
+ -------
99
+ v_perms : list[Perm]
100
+ Non-identity permutations on V labels (output / free side).
101
+ w_perms : list[Perm]
102
+ Non-identity permutations on W labels (inner / summed side).
103
+ joint_perms : list[Perm]
104
+ Non-identity permutations on sorted(V ∪ W) labels (joint group).
105
+ """
106
+ v_perms: list[Perm] = []
107
+ w_perms: list[Perm] = []
108
+ joint_perms: list[Perm] = []
109
+ all_labels = sub.v_labels | sub.w_labels
110
+ v_sorted = tuple(sorted(sub.v_labels))
111
+ w_sorted = tuple(sorted(sub.w_labels))
112
+ all_sorted = tuple(sorted(all_labels))
113
+ v_idx = {lbl: i for i, lbl in enumerate(v_sorted)}
114
+ w_idx = {lbl: i for i, lbl in enumerate(w_sorted)}
115
+ all_idx = {lbl: i for i, lbl in enumerate(all_sorted)}
116
+
117
+ n_rows = len(row_order)
118
+ identity_row = tuple(range(n_rows))
119
+
120
+ # Map: operand_idx -> list of positions in row_order belonging to it.
121
+ op_to_u_indices: dict[int, list[int]] = {}
122
+ for pos, u_idx in enumerate(row_order):
123
+ op = graph.u_operand[u_idx]
124
+ op_to_u_indices.setdefault(op, []).append(pos)
125
+
126
+ # Collect row-permutation generators, then derive π for each.
127
+ row_perm_generators: list[tuple[int, ...]] = []
128
+
129
+ # --- Source A: per-operand internal symmetry generators ---
130
+ for op_idx in sorted({graph.u_operand[u] for u in row_order}):
131
+ groups = graph.per_op_groups[op_idx]
132
+ if groups is None:
133
+ continue
134
+ positions = op_to_u_indices.get(op_idx, [])
135
+ if not positions:
136
+ continue
137
+ for group in groups:
138
+ # Map group axis indices to positions within this operand's
139
+ # block in row_order. group.axes[i] is the tensor axis index
140
+ # that group position i acts on. Since we no longer merge,
141
+ # each axis position in the subscript maps 1:1 to a U-vertex.
142
+ # The operand's subscript gives us the axis->position mapping.
143
+ subscript = graph.operand_subscripts[op_idx]
144
+ # group._labels are the subscript chars the group acts on.
145
+ # For each generator, we need to map its array_form through
146
+ # the axis indirection to produce a row permutation.
147
+ # Build: group_pos -> row_order position
148
+ # group._labels[g_pos] is the char at group position g_pos.
149
+ # We need to find which axis position in the subscript that
150
+ # char corresponds to, respecting group.axes.
151
+ if group.axes is not None:
152
+ # group.axes[g_pos] = tensor axis index
153
+ gpos_to_rowpos = {}
154
+ for g_pos in range(group.degree):
155
+ axis_idx = group.axes[g_pos]
156
+ if axis_idx < len(positions):
157
+ gpos_to_rowpos[g_pos] = positions[axis_idx]
158
+ elif group._labels is not None:
159
+ # No axes, but _labels maps group positions to subscript
160
+ # chars. Find the subscript position of each label.
161
+ gpos_to_rowpos = {}
162
+ for g_pos in range(group.degree):
163
+ lbl = group._labels[g_pos]
164
+ # Find position of this label in the subscript
165
+ sub_pos = subscript.find(lbl)
166
+ if sub_pos >= 0 and sub_pos < len(positions):
167
+ gpos_to_rowpos[g_pos] = positions[sub_pos]
168
+ else:
169
+ # Default: group position i acts on operand axis i
170
+ gpos_to_rowpos = {}
171
+ for g_pos in range(group.degree):
172
+ if g_pos < len(positions):
173
+ gpos_to_rowpos[g_pos] = positions[g_pos]
174
+
175
+ for gen in group.generators:
176
+ arr = gen.array_form
177
+ # Build row permutation: start from identity, then
178
+ # permute the positions that this generator acts on.
179
+ row_perm = list(identity_row)
180
+ is_identity = True
181
+ for g_pos in range(len(arr)):
182
+ if arr[g_pos] != g_pos:
183
+ src = gpos_to_rowpos.get(g_pos)
184
+ dst = gpos_to_rowpos.get(arr[g_pos])
185
+ if src is not None and dst is not None:
186
+ row_perm[src] = identity_row[dst]
187
+ is_identity = False
188
+ if not is_identity:
189
+ row_perm_generators.append(tuple(row_perm))
190
+
191
+ # --- Source B: identical-operand swap generators ---
192
+ for group in sub.id_groups:
193
+ group_sorted = sorted(group)
194
+ for idx in range(len(group_sorted) - 1):
195
+ op_a = group_sorted[idx]
196
+ op_b = group_sorted[idx + 1]
197
+ pos_a = op_to_u_indices.get(op_a, [])
198
+ pos_b = op_to_u_indices.get(op_b, [])
199
+ if len(pos_a) != len(pos_b):
200
+ continue # block sizes must match
201
+ row_perm = list(identity_row)
202
+ for pa, pb in zip(pos_a, pos_b, strict=False):
203
+ row_perm[pa] = identity_row[pb]
204
+ row_perm[pb] = identity_row[pa]
205
+ row_perm_generators.append(tuple(row_perm))
206
+
207
+ # --- Source C: coordinated axis relabeling for identical operands ---
208
+ # When identical operands share the same subscript pattern, permuting
209
+ # axes uniformly across all copies is equivalent to relabeling dummy
210
+ # indices. Only valid when BOTH labels involved in the swap are
211
+ # summed (W-side) — relabeling free (V-side) labels changes the output.
212
+ # Generate adjacent transpositions on W-only axis pairs, applied to
213
+ # every copy simultaneously.
214
+ for group in sub.id_groups:
215
+ group_sorted = sorted(group)
216
+ # Check all operands in this group have the same subscript
217
+ subs_list = [graph.operand_subscripts[op] for op in group_sorted]
218
+ if len(set(subs_list)) != 1:
219
+ continue # different subscripts — can't do coordinated relabeling
220
+ subscript = subs_list[0]
221
+ rank = len(subscript)
222
+ if rank < 2:
223
+ continue
224
+ # Find which axis positions have W-only (summed) labels
225
+ w_axes = [ax for ax in range(rank) if subscript[ax] in sub.w_labels]
226
+ if len(w_axes) < 2:
227
+ continue
228
+ # Generate adjacent transpositions on W-only axes
229
+ for idx in range(len(w_axes) - 1):
230
+ ax_a = w_axes[idx]
231
+ ax_b = w_axes[idx + 1]
232
+ row_perm = list(identity_row)
233
+ is_identity = True
234
+ for op_idx in group_sorted:
235
+ positions = op_to_u_indices.get(op_idx, [])
236
+ if ax_a >= len(positions) or ax_b >= len(positions):
237
+ continue
238
+ pa, pb = positions[ax_a], positions[ax_b]
239
+ row_perm[pa] = identity_row[pb]
240
+ row_perm[pb] = identity_row[pa]
241
+ is_identity = False
242
+ if not is_identity:
243
+ row_perm_generators.append(tuple(row_perm))
244
+
245
+ # --- Build a group on row positions and enumerate all elements ---
246
+ if not row_perm_generators:
247
+ return v_perms, w_perms, joint_perms
248
+
249
+ row_gens = [Perm(list(g)) for g in row_perm_generators]
250
+ row_group = SymmetryGroup(*row_gens)
251
+
252
+ for sigma_perm in row_group.elements():
253
+ sigma_row_perm = sigma_perm.array_form
254
+ # Skip identity σ — it always gives π = identity.
255
+ if all(sigma_row_perm[k] == k for k in range(n_rows)):
256
+ continue
257
+
258
+ # Compute σ(M)'s column fingerprints.
259
+ sigma_col_of: dict[str, tuple[int, ...]] = {}
260
+ for label in all_labels:
261
+ sigma_col_of[label] = tuple(
262
+ graph.incidence[row_order[sigma_row_perm[k]]].get(label, 0)
263
+ for k in range(n_rows)
264
+ )
265
+
266
+ # Derive π.
267
+ pi = _derive_pi_canonical(
268
+ sigma_col_of, fp_to_labels, sub.v_labels, sub.w_labels
269
+ )
270
+ if pi is None:
271
+ continue
272
+
273
+ # Restrict π to V labels — emit Perm if non-identity.
274
+ if sub.v_labels and any(pi.get(lbl, lbl) != lbl for lbl in sub.v_labels):
275
+ arr = [v_idx[pi.get(lbl, lbl)] for lbl in v_sorted]
276
+ v_perms.append(Perm(arr))
277
+
278
+ # Restrict π to W labels — emit Perm if non-identity.
279
+ if sub.w_labels and any(pi.get(lbl, lbl) != lbl for lbl in sub.w_labels):
280
+ arr = [w_idx[pi.get(lbl, lbl)] for lbl in w_sorted]
281
+ w_perms.append(Perm(arr))
282
+
283
+ # Joint permutation on sorted(V ∪ W) — emit Perm if non-identity.
284
+ if any(pi.get(lbl, lbl) != lbl for lbl in all_sorted):
285
+ arr_joint = [all_idx[pi.get(lbl, lbl)] for lbl in all_sorted]
286
+ joint_perms.append(Perm(arr_joint))
287
+
288
+ return v_perms, w_perms, joint_perms
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class EinsumBipartite:
293
+ """Bipartite graph representation of an einsum expression.
294
+
295
+ Left vertices U: one per (operand_idx, axis_position). Each axis of
296
+ each operand gets its own U-vertex (no merging). For a dense operand
297
+ with subscript "ai" we get two U vertices — one for axis 0 ({a}),
298
+ one for axis 1 ({i}). A fully symmetric operand T with subscript
299
+ "ij" also gets two U vertices — one per axis.
300
+
301
+ Right vertices are labels, partitioned at the top level into
302
+ free_labels (V, the final output) and summed_labels (W, contracted
303
+ at the top level). Subset induction may reclassify labels from W
304
+ to V when they cross the cut.
305
+ """
306
+
307
+ # Parallel tuples over U vertices:
308
+ u_vertices: tuple[tuple[int, int], ...] # (operand_idx, class_id)
309
+ u_labels: tuple[frozenset[str], ...] # which labels this class contains
310
+ u_operand: tuple[int, ...] # operand index
311
+ incidence: tuple[dict[str, int], ...] # {label -> multiplicity}
312
+
313
+ # Right vertices, top-level partition:
314
+ free_labels: frozenset[str] # V at the top level
315
+ summed_labels: frozenset[str] # W at the top level
316
+
317
+ # Python-identity groups: partition of [0..num_operands),
318
+ # non-singleton blocks enumerate identical operands.
319
+ identical_operand_groups: tuple[tuple[int, ...], ...]
320
+
321
+ # Per-operand label set, needed for subset induction to compute
322
+ # crossing labels efficiently without re-scanning incidence.
323
+ operand_labels: tuple[frozenset[str], ...]
324
+
325
+ # Per-operand subscript string, needed by the block path helper
326
+ # to reconstruct free-to-one-operand label sets in positional order.
327
+ operand_subscripts: tuple[str, ...] # parallel to operand_labels
328
+
329
+ # Declared symmetry groups per operand, preserved from construction
330
+ # so the fingerprint fast path can use exact declared groups instead
331
+ # of always promoting to S_k.
332
+ per_op_groups: tuple[tuple[SymmetryGroup, ...] | None, ...]
333
+
334
+
335
+ def _build_bipartite(
336
+ operands: list[Any],
337
+ subscript_parts: list[str],
338
+ per_op_groups: list[list[SymmetryGroup] | None],
339
+ output_chars: str,
340
+ ) -> EinsumBipartite:
341
+ """Construct the bipartite graph for an einsum expression.
342
+
343
+ Parameters
344
+ ----------
345
+ operands : list
346
+ Original operand objects; Python identity is used to detect
347
+ repeated operands.
348
+ subscript_parts : list[str]
349
+ Per-operand subscript strings (e.g., ["ij", "jk"]).
350
+ per_op_groups : list
351
+ Declared symmetry for each operand, as a list of
352
+ SymmetryGroup objects (with ``_labels`` set), or None.
353
+ output_chars : str
354
+ Output subscript string.
355
+ """
356
+ u_vertices: list[tuple[int, int]] = []
357
+ u_labels: list[frozenset[str]] = []
358
+ u_operand: list[int] = []
359
+ incidence: list[dict[str, int]] = []
360
+ operand_labels: list[frozenset[str]] = []
361
+
362
+ for op_idx, sub in enumerate(subscript_parts):
363
+ operand_labels.append(frozenset(sub))
364
+ per_op_groups[op_idx]
365
+
366
+ # Each axis gets its own U-vertex (no merging). The σ-loop handles
367
+ # symmetry detection via per-operand generators instead.
368
+ class_of_position: dict[int, int] = {k: k for k in range(len(sub))}
369
+
370
+ # Build one U vertex per axis, with incidence = label multiplicity.
371
+ num_classes = len(sub)
372
+ class_incidence: list[dict[str, int]] = [{} for _ in range(num_classes)]
373
+ class_labels: list[set[str]] = [set() for _ in range(num_classes)]
374
+ for k, c in enumerate(sub):
375
+ cls = class_of_position[k]
376
+ class_incidence[cls][c] = class_incidence[cls].get(c, 0) + 1
377
+ class_labels[cls].add(c)
378
+
379
+ for cls in range(num_classes):
380
+ u_vertices.append((op_idx, cls))
381
+ u_labels.append(frozenset(class_labels[cls]))
382
+ u_operand.append(op_idx)
383
+ incidence.append(class_incidence[cls])
384
+
385
+ # Partition labels into free (V) vs summed (W) at the top level
386
+ output_set = frozenset(output_chars)
387
+ all_labels = frozenset().union(*operand_labels) if operand_labels else frozenset()
388
+ free_labels = all_labels & output_set
389
+ summed_labels = all_labels - output_set
390
+
391
+ # Identical-operand groups via Python id
392
+ id_to_positions: dict[int, list[int]] = {}
393
+ for op_idx, op in enumerate(operands):
394
+ id_to_positions.setdefault(id(op), []).append(op_idx)
395
+ identical_operand_groups = tuple(
396
+ tuple(positions)
397
+ for positions in id_to_positions.values()
398
+ if len(positions) >= 2
399
+ )
400
+
401
+ return EinsumBipartite(
402
+ u_vertices=tuple(u_vertices),
403
+ u_labels=tuple(u_labels),
404
+ u_operand=tuple(u_operand),
405
+ incidence=tuple(incidence),
406
+ free_labels=free_labels,
407
+ summed_labels=summed_labels,
408
+ identical_operand_groups=identical_operand_groups,
409
+ operand_labels=tuple(operand_labels),
410
+ operand_subscripts=tuple(subscript_parts),
411
+ per_op_groups=tuple(
412
+ tuple(gs) if gs is not None else None for gs in per_op_groups
413
+ ),
414
+ )
415
+
416
+
417
+ @dataclass(frozen=True)
418
+ class _Subgraph:
419
+ """Induced subgraph of an EinsumBipartite on a subset of operands.
420
+
421
+ u_local: list of U-vertex indices (indices into graph.u_vertices) that
422
+ belong to operands in the subset.
423
+ v_labels: labels that are free at this step (output or crossing the cut).
424
+ w_labels: labels that are summed entirely within the subset.
425
+ id_groups: identical-operand groups restricted to the subset.
426
+ """
427
+
428
+ u_local: tuple[int, ...]
429
+ v_labels: frozenset[str]
430
+ w_labels: frozenset[str]
431
+ id_groups: tuple[tuple[int, ...], ...]
432
+
433
+
434
+ def _induce_subgraph(graph: EinsumBipartite, subset: frozenset[int]) -> _Subgraph:
435
+ u_local = tuple(
436
+ idx for idx, op_idx in enumerate(graph.u_operand) if op_idx in subset
437
+ )
438
+
439
+ labels_in_subset: set[str] = set()
440
+ for idx in u_local:
441
+ labels_in_subset.update(graph.incidence[idx].keys())
442
+
443
+ # Labels appearing in operands outside the subset (crossing the cut).
444
+ outside_labels: set[str] = set()
445
+ for op_idx, op_lbls in enumerate(graph.operand_labels):
446
+ if op_idx not in subset:
447
+ outside_labels.update(op_lbls)
448
+
449
+ # V at this step = labels_in_subset ∩ (free_labels ∪ outside_labels)
450
+ v_labels = frozenset(labels_in_subset & (graph.free_labels | outside_labels))
451
+ w_labels = frozenset(labels_in_subset - v_labels)
452
+
453
+ id_groups = tuple(
454
+ tuple(sorted(set(g) & subset))
455
+ for g in graph.identical_operand_groups
456
+ if len(set(g) & subset) >= 2
457
+ )
458
+
459
+ return _Subgraph(
460
+ u_local=u_local,
461
+ v_labels=v_labels,
462
+ w_labels=w_labels,
463
+ id_groups=id_groups,
464
+ )
465
+
466
+
467
+ class SubgraphSymmetryOracle:
468
+ """Subset-keyed symmetry oracle for einsum intermediates.
469
+
470
+ One oracle per contract_path call. Symmetries are computed lazily
471
+ on first access to a subset and cached in memory. Returns a
472
+ ``SubsetSymmetry`` with ``.output`` (V-side) and ``.inner`` (W-side).
473
+ """
474
+
475
+ def __init__(
476
+ self,
477
+ operands: list[Any],
478
+ subscript_parts: list[str],
479
+ per_op_groups: list[list[SymmetryGroup] | None],
480
+ output_chars: str,
481
+ ) -> None:
482
+ self._graph = _build_bipartite(
483
+ operands=operands,
484
+ subscript_parts=subscript_parts,
485
+ per_op_groups=per_op_groups,
486
+ output_chars=output_chars,
487
+ )
488
+ self._cache: dict[frozenset[int], SubsetSymmetry] = {}
489
+
490
+ def sym(self, subset: frozenset[int]) -> SubsetSymmetry:
491
+ cached = self._cache.get(subset, _MISSING)
492
+ if cached is not _MISSING:
493
+ return cached # type: ignore[return-value]
494
+ result = _compute_subset_symmetry(self._graph, subset)
495
+ self._cache[subset] = result
496
+ return result
497
+
498
+
499
+ def _compute_subset_symmetry(
500
+ graph: EinsumBipartite,
501
+ subset: frozenset[int],
502
+ ) -> SubsetSymmetry:
503
+ sub = _induce_subgraph(graph, subset)
504
+ if not sub.v_labels and not sub.w_labels:
505
+ return SubsetSymmetry(None, None)
506
+
507
+ # Column fingerprints for π derivation.
508
+ row_order = sub.u_local
509
+ all_labels = sub.v_labels | sub.w_labels
510
+ col_of: dict[str, tuple[int, ...]] = {}
511
+ for label in all_labels:
512
+ col_of[label] = tuple(graph.incidence[u].get(label, 0) for u in row_order)
513
+
514
+ fp_to_labels: dict[tuple[int, ...], set[str]] = {}
515
+ for lbl, fp in col_of.items():
516
+ fp_to_labels.setdefault(fp, set()).add(lbl)
517
+
518
+ # Collect exact π generators via σ-loop.
519
+ v_perms, w_perms, joint_perms = _collect_pi_permutations(
520
+ graph, sub, row_order, col_of, fp_to_labels
521
+ )
522
+ v_sorted = tuple(sorted(sub.v_labels))
523
+ w_sorted = tuple(sorted(sub.w_labels))
524
+ all_sorted = tuple(sorted(all_labels))
525
+
526
+ # Build V-side group (only from σ-loop results, no fast path).
527
+ v_group: SymmetryGroup | None = None
528
+ if v_perms:
529
+ v_group = SymmetryGroup(*v_perms, axes=tuple(range(len(v_sorted))))
530
+ v_group._labels = v_sorted
531
+
532
+ # Build W-side group (only from σ-loop results, no fast path).
533
+ w_group: SymmetryGroup | None = None
534
+ if w_perms:
535
+ w_group = SymmetryGroup(*w_perms, axes=tuple(range(len(w_sorted))))
536
+ w_group._labels = w_sorted
537
+
538
+ # Build joint group on sorted(V ∪ W).
539
+ joint_group: SymmetryGroup | None = None
540
+ if joint_perms:
541
+ joint_group = SymmetryGroup(*joint_perms, axes=tuple(range(len(all_sorted))))
542
+ joint_group._labels = all_sorted
543
+
544
+ return SubsetSymmetry(output=v_group, inner=w_group, joint=joint_group)
@@ -0,0 +1,140 @@
1
+ """Symmetry-aware cost helpers for opt_einsum contraction paths.
2
+
3
+ This module provides:
4
+ - unique_elements / compute_unique_size: count distinct elements under symmetry.
5
+ - symmetric_flop_count: FLOP estimate reduced by output symmetry.
6
+
7
+ Detection of symmetries is handled by ``_subgraph_symmetry.SubgraphSymmetryOracle``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Collection
13
+ from dataclasses import dataclass
14
+
15
+ from flopscope._perm_group import SymmetryGroup
16
+
17
+ from ._helpers import flop_count
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class SubsetSymmetry:
22
+ """Symmetry info for one contraction subset, split by V/W.
23
+
24
+ Attributes
25
+ ----------
26
+ output : SymmetryGroup or None
27
+ Exact permutation group for the output (V) labels.
28
+ inner : SymmetryGroup or None
29
+ Exact permutation group for the inner (W) labels.
30
+ joint : SymmetryGroup or None
31
+ Value-preserving joint group acting on all V ∪ W labels together
32
+ (in sorted order). Exposes cross-step identity-swap symmetries
33
+ that per-projection fingerprints miss. ``_labels`` is set to the
34
+ sorted tuple of all subset labels.
35
+ """
36
+
37
+ output: SymmetryGroup | None
38
+ inner: SymmetryGroup | None
39
+ joint: SymmetryGroup | None = None
40
+
41
+
42
+ def unique_elements(
43
+ indices: frozenset[str],
44
+ size_dict: dict[str, int],
45
+ perm_group: SymmetryGroup | None = None,
46
+ ) -> int:
47
+ """Count distinct elements of a tensor with the given symmetry.
48
+
49
+ When perm_group is provided, uses Burnside's lemma for exact counting.
50
+ When None, returns the dense count (product of all sizes).
51
+ """
52
+ if not indices:
53
+ return 1
54
+
55
+ if perm_group is not None:
56
+ if perm_group._labels is not None:
57
+ label_list = list(perm_group._labels)
58
+ else:
59
+ label_list = sorted(indices)[: perm_group.degree]
60
+ pg_size_dict: dict[int, int] = {}
61
+ accounted: set[str] = set()
62
+ for i, lbl in enumerate(label_list):
63
+ pg_size_dict[i] = size_dict[lbl]
64
+ accounted.add(lbl)
65
+ count = perm_group.burnside_unique_count(pg_size_dict)
66
+ for idx in indices:
67
+ if idx not in accounted:
68
+ count *= size_dict[idx]
69
+ return count
70
+
71
+ # No symmetry -- dense count.
72
+ count = 1
73
+ for idx in indices:
74
+ count *= size_dict[idx]
75
+ return count
76
+
77
+
78
+ compute_unique_size = unique_elements
79
+
80
+
81
+ def symmetric_flop_count(
82
+ idx_contraction: Collection[str],
83
+ inner: bool,
84
+ num_terms: int,
85
+ size_dictionary: dict[str, int],
86
+ *,
87
+ output_group: SymmetryGroup | None = None,
88
+ output_indices: frozenset[str] | None = None,
89
+ inner_group: SymmetryGroup | None = None,
90
+ inner_indices: frozenset[str] | None = None,
91
+ use_inner_symmetry: bool = True,
92
+ per_operand_free_counts: tuple[int, ...] | None = None,
93
+ ) -> int:
94
+ r"""FLOP count for a symmetric tensor contraction.
95
+
96
+ Computes the direct-evaluation cost, reduced by the output and inner
97
+ symmetry ratios when the corresponding groups are provided.
98
+
99
+ Inner symmetry reduction is applied only when ``use_inner_symmetry``
100
+ is True and **all** labels in ``inner_group`` are present as
101
+ contracted indices in this specific step. If any W-group labels were
102
+ contracted at earlier steps and are no longer present, the inner
103
+ reduction is skipped.
104
+
105
+ Parameters
106
+ ----------
107
+ use_inner_symmetry : bool, optional
108
+ Whether to apply the inner (W-side) symmetry reduction.
109
+ Default ``True``.
110
+ per_operand_free_counts : tuple of int, optional
111
+ Number of free (non-contracted) indices contributed by each
112
+ operand. For a pairwise contraction this is ``(s, t)``.
113
+ Reserved for future cost models; not currently used.
114
+ """
115
+ from ._helpers import compute_size_by_dict
116
+
117
+ # --- Direct-evaluation estimate ---
118
+ cost = flop_count(idx_contraction, inner, num_terms, size_dictionary)
119
+
120
+ if output_group is not None and output_indices is not None:
121
+ total = compute_size_by_dict(output_indices, size_dictionary)
122
+ unique = unique_elements(
123
+ output_indices, size_dictionary, perm_group=output_group
124
+ )
125
+ cost = cost * unique // total
126
+
127
+ # Inner symmetry: only apply when ALL group labels are being contracted
128
+ # at this step. The oracle's W-group may span labels from prior steps
129
+ # that no longer exist in the current pairwise contraction.
130
+ if use_inner_symmetry and inner_group is not None and inner_indices is not None:
131
+ group_labels = set(inner_group._labels) if inner_group._labels else set()
132
+ if group_labels and group_labels <= set(inner_indices):
133
+ total_inner = compute_size_by_dict(inner_indices, size_dictionary)
134
+ if total_inner > 0:
135
+ unique_inner = unique_elements(
136
+ inner_indices, size_dictionary, perm_group=inner_group
137
+ )
138
+ cost = cost * unique_inner // total_inner
139
+
140
+ return max(cost, 1)