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,1478 @@
1
+ """Contains PathInfo, StepInfo, and build_path_info (stripped from opt_einsum contract.py).
2
+
3
+ Excluded: contract, _core_contract, ContractExpression, _einsum, _tensordot,
4
+ _transpose, backends/sharing imports, _filter_einsum_defaults,
5
+ format_const_einsum_str, shape_only.
6
+
7
+ The local contract_path() body was removed in Task 7+8; upstream opt_einsum is
8
+ used directly via __init__.py's wrapper.
9
+ """
10
+
11
+ from dataclasses import dataclass, field
12
+ from decimal import Decimal
13
+ from functools import cached_property
14
+ from hashlib import sha1
15
+ from typing import Any
16
+
17
+ from flopscope._perm_group import SymmetryGroup
18
+
19
+ from . import _helpers as helpers
20
+ from ._hsluv import rgb_distance_hex, rich_label_palette
21
+
22
+ __all__ = [
23
+ "build_path_info",
24
+ "PathInfo",
25
+ "PreReduction",
26
+ "StepInfo",
27
+ ]
28
+
29
+ _RICH_SYMMETRY_STYLES = {
30
+ "S": "bold bright_cyan",
31
+ "C": "bold bright_magenta",
32
+ "D": "bold bright_yellow",
33
+ "W": "bold bright_green",
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PreReduction:
39
+ """Metadata for a single pre-reduction performed on a binary step's input.
40
+
41
+ When a label is summed in this step AND appears in only ONE of the two
42
+ inputs, that input is pre-reduced along the isolated axis BEFORE the
43
+ main contraction. This mirrors PyTorch's `sumproduct_pair` optimization
44
+ (`aten/src/ATen/native/Linear.cpp`).
45
+
46
+ Attributes:
47
+ operand_index: 0 or 1 — which input of the binary step (left or right).
48
+ removed_labels: labels dropped from this input's subscript (sorted tuple).
49
+ cost: flops charged by this pre-reduction (from reduction_accumulation_cost).
50
+ surviving_subscript: this input's subscript after dropping removed_labels.
51
+ reduced_symmetry_fingerprint: canonical sym fingerprint of the post-
52
+ reduction operand symmetry; fed to the residual contraction's
53
+ per-input fingerprint. None when the operand had no declared
54
+ symmetry or the reduction killed all surviving symmetry.
55
+ """
56
+
57
+ operand_index: int
58
+ removed_labels: tuple[str, ...]
59
+ cost: int
60
+ surviving_subscript: str
61
+ reduced_symmetry_fingerprint: tuple | None = None
62
+
63
+
64
+ @dataclass
65
+ class StepInfo:
66
+ """Per-step diagnostics for a contraction path."""
67
+
68
+ subscript: str
69
+ """Einsum subscript for this step, e.g. ``"ijk,ai->ajk"``."""
70
+
71
+ flop_cost: int
72
+ """FLOP cost (FMA = 1 op)."""
73
+
74
+ input_shapes: list[tuple[int, ...]]
75
+ """Shapes of the input operands for this step."""
76
+
77
+ output_shape: tuple[int, ...]
78
+ """Shape of the output operand for this step."""
79
+
80
+ input_groups: list = field(default_factory=list)
81
+ """SymmetryGroup for each input in this step."""
82
+
83
+ output_group: object | None = None
84
+ """SymmetryGroup of the output, or None."""
85
+
86
+ dense_flop_cost: int = 0
87
+ """FLOP cost without symmetry (FMA = 1 op)."""
88
+
89
+ symmetry_savings: float = 0.0
90
+ """Fraction saved: ``1 - (flop_cost / dense_flop_cost)``. Zero when no symmetry."""
91
+
92
+ inner_group: object | None = None
93
+ """SymmetryGroup among the contracted (summed) labels, or None."""
94
+
95
+ inner_applied: bool = False
96
+ """Whether inner (W-side) symmetry was actually applied at this step."""
97
+
98
+ blas_type: str | bool = False
99
+ """BLAS classification for this step (e.g. 'GEMM', 'SYMM', False)."""
100
+
101
+ path_indices: tuple[int, ...] = ()
102
+ """The SSA-id contraction tuple for this step (the entry from
103
+ ``PathInfo.path[i]``). Useful for cross-referencing the table with
104
+ the raw path field."""
105
+
106
+ merged_subset: frozenset[int] | None = None
107
+ """Subset of *original* operand positions that this step's output
108
+ intermediate covers. For step 0 contracting two original operands i
109
+ and j, this is ``frozenset({i, j})``. For later steps it's the union
110
+ of the subsets of all SSA inputs being contracted."""
111
+
112
+ pre_reductions: tuple[PreReduction, ...] = field(default_factory=tuple)
113
+ """Pre-reductions applied to binary step inputs before the main contraction.
114
+
115
+ Each entry records a label that was summed out of exactly one input prior
116
+ to the contraction itself (mirroring PyTorch's ``sumproduct_pair``
117
+ optimization). Empty for all existing steps — populated by Task 4."""
118
+
119
+ cost_source: str | None = None
120
+ """Which cost category produced ``flop_cost`` (Sprint 4).
121
+
122
+ One of ``"per-input"`` (Cat A wreath/sigma), ``"joint-burnside"`` (Cat C
123
+ on the merged-subset joint group), or ``"output-burnside"`` (Cat B on
124
+ the merged output group's orbit count). ``None`` only on stale
125
+ pre-Sprint-4 instances.
126
+
127
+ Surfaced by the renderer for transparency ("why this number?") and used
128
+ by regression tests to pin which category was selected."""
129
+
130
+ @property
131
+ def flop_count(self) -> int:
132
+ """Alias for ``flop_cost`` (adapter compatibility)."""
133
+ return self.flop_cost
134
+
135
+
136
+ @dataclass
137
+ class PathInfo:
138
+ """Information about a contraction path."""
139
+
140
+ path: list[tuple[int, ...]]
141
+ """The optimized contraction path (list of index-tuples)."""
142
+
143
+ steps: list[StepInfo]
144
+ """Per-step diagnostics."""
145
+
146
+ naive_cost: int
147
+ """Naive (single-step) FLOP cost (FMA = 1 op)."""
148
+
149
+ optimized_cost: int
150
+ """Sum of per-step costs (FMA = 1 op)."""
151
+
152
+ largest_intermediate: int
153
+ """Number of elements in the largest intermediate tensor."""
154
+
155
+ speedup: float
156
+ """``naive_cost / optimized_cost``."""
157
+
158
+ input_subscripts: str = ""
159
+ """Comma-separated input subscripts, e.g. ``"ij,jk,kl"``."""
160
+
161
+ output_subscript: str = ""
162
+ """Output subscript, e.g. ``"il"``."""
163
+
164
+ size_dict: dict[str, int] = field(default_factory=dict)
165
+ """Mapping from index label to dimension size."""
166
+
167
+ optimizer_used: str = ""
168
+ """Name of the path-finding function actually invoked. For ``optimize='auto'``
169
+ or ``'auto-hq'`` this resolves to the underlying inner choice
170
+ (e.g. ``'optimal'``, ``'branch_2'``, ``'dynamic_programming'``,
171
+ ``'random_greedy_128'``) so users can tell which algorithm produced
172
+ the path. For explicit choices it matches the requested name. Empty
173
+ string for the trivial num_ops <= 2 case where no optimizer runs."""
174
+
175
+ # Legacy fields for backward-compat with opt_einsum tests
176
+ contraction_list: list = field(default_factory=list)
177
+ scale_list: list[int] = field(default_factory=list)
178
+ size_list: list[int] = field(default_factory=list)
179
+ _oe_naive_cost: int = 0
180
+ _oe_opt_cost: int = 0
181
+
182
+ @property
183
+ def opt_cost(self) -> Decimal:
184
+ """Legacy: opt_einsum-style cost (FMA = 1 op)."""
185
+ return Decimal(self._oe_opt_cost)
186
+
187
+ @property
188
+ def eq(self) -> str:
189
+ return f"{self.input_subscripts}->{self.output_subscript}"
190
+
191
+ @staticmethod
192
+ def _preferred_label_style_index(label: str, total_slots: int) -> int | None:
193
+ """Return the preferred stable palette slot for a label."""
194
+ if not label or not label[0].isalpha():
195
+ return None
196
+ return int.from_bytes(sha1(label.encode("utf-8")).digest()[:2], "big") % (
197
+ total_slots
198
+ )
199
+
200
+ @cached_property
201
+ def _label_styles(self) -> dict[str, str]:
202
+ """Assign non-colliding styles for the active labels in this expression."""
203
+ labels = list(dict.fromkeys(ch for ch in self.eq if ch.isalpha()))
204
+ slot_count = max(64, len(labels))
205
+ label_styles = tuple(
206
+ f"bold {color}" for color in rich_label_palette(slot_count)
207
+ )
208
+ used_slots: set[int] = set()
209
+ styles: dict[str, str] = {}
210
+ total_slots = len(label_styles)
211
+
212
+ for label in labels:
213
+ preferred = self._preferred_label_style_index(label, total_slots)
214
+ if preferred is None:
215
+ styles[label] = "bold"
216
+ continue
217
+
218
+ if not used_slots:
219
+ used_slots.add(preferred)
220
+ styles[label] = label_styles[preferred]
221
+ continue
222
+
223
+ best_slot: int | None = None
224
+ best_score: tuple[float, int] | None = None
225
+ for slot, style in enumerate(label_styles):
226
+ if slot in used_slots:
227
+ continue
228
+
229
+ color = style.rsplit(" ", 1)[-1]
230
+ min_distance = min(
231
+ rgb_distance_hex(color, label_styles[used_slot].rsplit(" ", 1)[-1])
232
+ for used_slot in used_slots
233
+ )
234
+ circular_preference_distance = min(
235
+ (slot - preferred) % total_slots,
236
+ (preferred - slot) % total_slots,
237
+ )
238
+ score = (min_distance, -circular_preference_distance)
239
+ if best_score is None or score > best_score:
240
+ best_score = score
241
+ best_slot = slot
242
+
243
+ assert best_slot is not None
244
+ used_slots.add(best_slot)
245
+ styles[label] = label_styles[best_slot]
246
+
247
+ return styles
248
+
249
+ def _label_style(self, label: str) -> str:
250
+ """Return the resolved style for a label within this expression."""
251
+ return self._label_styles.get(label, "bold")
252
+
253
+ def _style_text_charwise(self, text: str):
254
+ from rich.text import Text
255
+
256
+ result = Text()
257
+ for ch in text:
258
+ if ch.isalpha():
259
+ result.append(ch, style=self._label_style(ch))
260
+ elif ch in ",->[]{}()<>✓×:":
261
+ result.append(ch, style="dim")
262
+ else:
263
+ result.append(ch)
264
+ return result
265
+
266
+ def _rich_symmetry_token_text(self, token: str):
267
+ from rich.text import Text
268
+
269
+ if token == "-":
270
+ return Text("-", style="dim")
271
+ if token in {"×", "→"}:
272
+ return Text(token, style="dim")
273
+ if token.startswith("PermGroup⟨"):
274
+ return self._style_text_charwise(token)
275
+
276
+ result = Text()
277
+ if token.startswith("W"):
278
+ sym_style = _RICH_SYMMETRY_STYLES["W"]
279
+ result.append("W", style=sym_style)
280
+ if token.startswith("W✓"):
281
+ result.append("✓", style=sym_style)
282
+ if ":" in token:
283
+ result.append(":", style=sym_style)
284
+ remainder = token.split(":", 1)[1].lstrip() if ":" in token else token[1:]
285
+ if remainder:
286
+ result.append(" ", style="dim")
287
+ result.append_text(self._rich_symmetry_token_text(remainder))
288
+ return result
289
+
290
+ if token[0] in _RICH_SYMMETRY_STYLES and token[1:].split("{", 1)[0].isdigit():
291
+ prefix = token[0]
292
+ digits = []
293
+ i = 1
294
+ while i < len(token) and token[i].isdigit():
295
+ digits.append(token[i])
296
+ i += 1
297
+ result.append(prefix, style=_RICH_SYMMETRY_STYLES[prefix])
298
+ result.append("".join(digits), style=_RICH_SYMMETRY_STYLES[prefix])
299
+ if i < len(token) and token[i] == "{":
300
+ result.append("{", style="dim")
301
+ i += 1
302
+ while i < len(token) and token[i] != "}":
303
+ ch = token[i]
304
+ if ch.isalpha():
305
+ result.append(ch, style=self._label_style(ch))
306
+ elif ch == ",":
307
+ result.append(ch, style="dim")
308
+ else:
309
+ result.append(ch)
310
+ i += 1
311
+ if i < len(token) and token[i] == "}":
312
+ result.append("}", style="dim")
313
+ return result
314
+
315
+ return self._style_text_charwise(token)
316
+
317
+ def _rich_step_sym_text(self, step: StepInfo):
318
+ from rich.text import Text
319
+
320
+ in_parts = [self._fmt_sym(s) for s in step.input_groups]
321
+ out_part = self._fmt_sym(step.output_group) # type: ignore[arg-type]
322
+ w_part = self._fmt_sym(step.inner_group) # type: ignore[arg-type]
323
+ if all(p == "-" for p in in_parts) and out_part == "-" and w_part == "-":
324
+ return Text("-", style="dim")
325
+
326
+ result = Text()
327
+ for idx, part in enumerate(in_parts):
328
+ if idx:
329
+ result.append(" × ", style="dim")
330
+ result.append_text(self._rich_symmetry_token_text(part))
331
+ result.append(" → ", style="dim")
332
+ result.append_text(self._rich_symmetry_token_text(out_part))
333
+ if w_part != "-":
334
+ result.append(" [", style="dim")
335
+ result.append(
336
+ "W✓" if step.inner_applied else "W", style=_RICH_SYMMETRY_STYLES["W"]
337
+ )
338
+ result.append(": ", style="dim")
339
+ result.append_text(self._rich_symmetry_token_text(w_part))
340
+ result.append("]", style="dim")
341
+ return result
342
+
343
+ def _rich_eq_text(self):
344
+ """Render the full einsum expression with global label styling."""
345
+ from rich.text import Text
346
+
347
+ result = Text()
348
+ prefix = "Complete contraction: "
349
+ result.append(prefix, style="bold")
350
+ result.append_text(self._style_text_charwise(self.eq))
351
+ return result
352
+
353
+ def _rich_subscript_text(self, subscript: str):
354
+ """Render a subscript or step expression with global label styling."""
355
+ return self._style_text_charwise(subscript)
356
+
357
+ def _rich_index_sizes_text(self):
358
+ """Render the index-size summary with label styling."""
359
+ return self._style_text_charwise(self._fmt_index_sizes())
360
+
361
+ def _fmt_overall_savings(self) -> str:
362
+ """Format total optimized-vs-dense savings for the whole contraction."""
363
+ if self.naive_cost <= 0:
364
+ return "0.0%"
365
+ return f"{1 - (self.optimized_cost / self.naive_cost):.1%}"
366
+
367
+ def _rich_metric_pill(
368
+ self,
369
+ label: str,
370
+ value: str | Any,
371
+ *,
372
+ highlight: bool = False,
373
+ value_style: str | None = None,
374
+ border_style: str | None = None,
375
+ ):
376
+ from rich import box
377
+ from rich.panel import Panel
378
+ from rich.text import Text
379
+
380
+ resolved_value_style = value_style or ("bold cyan" if highlight else "bold")
381
+ resolved_border_style = border_style or ("cyan" if highlight else "dim")
382
+ body = Text()
383
+ body.append(label, style="bold")
384
+ body.append(": ", style="dim")
385
+ if not value:
386
+ body.append("-", style=resolved_value_style)
387
+ elif isinstance(value, Text):
388
+ if highlight:
389
+ value = value.copy()
390
+ value.stylize(resolved_value_style)
391
+ body.append_text(value)
392
+ else:
393
+ body.append(str(value), style=resolved_value_style)
394
+ return Panel.fit(
395
+ body,
396
+ box=box.ROUNDED,
397
+ padding=(0, 1),
398
+ border_style=resolved_border_style,
399
+ )
400
+
401
+ def _rich_summary_strip(self):
402
+ from rich.columns import Columns
403
+
404
+ pills = []
405
+ pills.append(self._rich_metric_pill("Naive cost", f"{self.naive_cost:,}"))
406
+ pills.append(
407
+ self._rich_metric_pill(
408
+ "Optimized cost", f"{self.optimized_cost:,}", highlight=True
409
+ )
410
+ )
411
+ speedup_style = "bold green" if self.speedup > 1 else "bold"
412
+ speedup_border = "green" if self.speedup > 1 else "dim"
413
+ pills.append(
414
+ self._rich_metric_pill(
415
+ "Speedup",
416
+ f"{self.speedup:.3f}x",
417
+ value_style=speedup_style,
418
+ border_style=speedup_border,
419
+ )
420
+ )
421
+ savings_style = (
422
+ "bold green" if self.optimized_cost < self.naive_cost else "bold"
423
+ )
424
+ savings_border = "green" if self.optimized_cost < self.naive_cost else "dim"
425
+ pills.append(
426
+ self._rich_metric_pill(
427
+ "Savings",
428
+ self._fmt_overall_savings(),
429
+ value_style=savings_style,
430
+ border_style=savings_border,
431
+ )
432
+ )
433
+ pills.append(
434
+ self._rich_metric_pill(
435
+ "Largest intermediate", f"{self.largest_intermediate:,} elements"
436
+ )
437
+ )
438
+ if self.size_dict:
439
+ pills.append(
440
+ self._rich_metric_pill("Index sizes", self._rich_index_sizes_text())
441
+ )
442
+ if self.optimizer_used:
443
+ pills.append(self._rich_metric_pill("Optimizer", self.optimizer_used))
444
+ return Columns(pills, expand=True, equal=False, padding=(0, 1))
445
+
446
+ def _rich_verbose_detail_text(
447
+ self, step: StepInfo, cumulative: int, *, step_index: int | None = None
448
+ ):
449
+ from rich.text import Text
450
+
451
+ shape = (
452
+ "(" + ",".join(str(d) for d in step.output_shape) + ")"
453
+ if step.output_shape
454
+ else "()"
455
+ )
456
+ result = Text()
457
+ if step_index is not None:
458
+ result.append(f"step {step_index}: ", style="dim")
459
+ result.append("subset=", style="dim")
460
+ result.append(self._fmt_subset(step.merged_subset), style="bold")
461
+ result.append("\n")
462
+ result.append("out_shape=", style="dim")
463
+ result.append(shape, style="bold")
464
+ result.append("\n")
465
+ result.append("cumulative=", style="dim")
466
+ result.append(f"{cumulative:,}", style="bold cyan")
467
+ # NEW: per-step M / α / −O from attached accumulation.
468
+ acc_step = getattr(step, "_acc_step", None)
469
+ if acc_step is not None:
470
+ m_value = acc_step.m_total
471
+ alpha_value = acc_step.alpha or 0
472
+ o_value = (
473
+ acc_step.per_component[0].num_output_orbits
474
+ if acc_step.per_component
475
+ else 0
476
+ )
477
+ result.append("\n")
478
+ result.append("M=", style="dim")
479
+ result.append(str(m_value), style="bold")
480
+ result.append(" α=", style="dim")
481
+ result.append(str(alpha_value), style="bold")
482
+ result.append(" −O=", style="dim")
483
+ result.append(str(o_value), style="bold")
484
+ return result
485
+
486
+ def _fmt_index_sizes(self) -> str:
487
+ """Format index sizes compactly. Groups indices with the same size."""
488
+ if not self.size_dict:
489
+ return ""
490
+ from collections import defaultdict
491
+
492
+ by_size: dict[int, list[str]] = defaultdict(list)
493
+ for idx, sz in self.size_dict.items():
494
+ by_size[sz].append(idx)
495
+ parts = []
496
+ for sz, idxs in sorted(by_size.items(), key=lambda kv: (-len(kv[1]), -kv[0])):
497
+ idxs_sorted = sorted(idxs)
498
+ parts.append(f"{'='.join(idxs_sorted)}={sz}")
499
+ return ", ".join(parts)
500
+
501
+ @staticmethod
502
+ def _fmt_contract(step: StepInfo) -> str:
503
+ """Format the path-supplied contraction tuple, e.g. '(0, 1)'."""
504
+ if not step.path_indices:
505
+ return "-"
506
+ if len(step.path_indices) == 2:
507
+ return f"({step.path_indices[0]}, {step.path_indices[1]})"
508
+ return "(" + ",".join(str(p) for p in step.path_indices) + ")"
509
+
510
+ @staticmethod
511
+ def _try_named_group(k: int, order: int) -> str | None:
512
+ """Return the named prefix (e.g. 'S3') if recognised, else None."""
513
+ if order == 1:
514
+ return None
515
+ from math import factorial
516
+
517
+ if order == factorial(k):
518
+ return f"S{k}"
519
+ if order == k:
520
+ return f"C{k}"
521
+ if order == 2 * k and k >= 3:
522
+ return f"D{k}"
523
+ return None
524
+
525
+ @staticmethod
526
+ def _fmt_generators(group: SymmetryGroup, labels: tuple) -> str:
527
+ """Format generators in cycle notation with labels."""
528
+ parts = []
529
+ for gen in group.generators:
530
+ if gen.is_identity:
531
+ continue
532
+ cycles = gen.cyclic_form
533
+ if not cycles:
534
+ continue
535
+ perm_str = "".join(
536
+ "(" + " ".join(labels[i] for i in cycle) + ")" for cycle in cycles
537
+ )
538
+ parts.append(perm_str)
539
+ return ", ".join(parts) if parts else "e"
540
+
541
+ def _fmt_sym(self, group: SymmetryGroup | None) -> str:
542
+ """Format a SymmetryGroup for display."""
543
+ if group is None:
544
+ return "-"
545
+ labels = group._labels or tuple(str(i) for i in range(group.degree))
546
+ k = group.degree
547
+ order = group.order()
548
+
549
+ name = self._try_named_group(k, order)
550
+ if name is not None:
551
+ return f"{name}{{{','.join(labels)}}}"
552
+
553
+ orbits = [orb for orb in group.orbits() if len(orb) >= 2]
554
+ if not orbits:
555
+ return "-"
556
+
557
+ if len(orbits) == 1:
558
+ orbit = orbits[0]
559
+ moved_labels = tuple(labels[i] for i in sorted(orbit))
560
+ mk = len(moved_labels)
561
+ name = self._try_named_group(mk, order)
562
+ if name is not None:
563
+ return f"{name}{{{','.join(moved_labels)}}}"
564
+
565
+ gen_str = self._fmt_generators(group, labels)
566
+ return f"PermGroup⟨{gen_str}⟩"
567
+
568
+ def _fmt_step_regime(self, step) -> str:
569
+ """Return the regime name for a step, or '-' when unknown.
570
+
571
+ FlopscopePathInfo.__str__ patches `_regime` per step from
572
+ accumulation.per_step before calling format_table.
573
+ """
574
+ return getattr(step, "_regime", "-")
575
+
576
+ def _fmt_step_sym(self, step: StepInfo) -> str:
577
+ """Format inputs→output symmetry transformation for one step."""
578
+ in_parts = [self._fmt_sym(s) for s in step.input_groups]
579
+ out_part = self._fmt_sym(step.output_group) # type: ignore[arg-type]
580
+ w_part = self._fmt_sym(step.inner_group) # type: ignore[arg-type]
581
+ if all(p == "-" for p in in_parts) and out_part == "-" and w_part == "-":
582
+ return ""
583
+ result = f"{' × '.join(in_parts)} → {out_part}"
584
+ if w_part != "-":
585
+ w_prefix = "W✓" if step.inner_applied else "W"
586
+ result += f" [{w_prefix}: {w_part}]"
587
+ return result
588
+
589
+ def _fmt_unique_dense(self, step: StepInfo) -> str:
590
+ """Show output and inner unique/dense element counts."""
591
+ from math import prod
592
+
593
+ def _unique_elements(
594
+ indices: frozenset[str],
595
+ size_dict: dict[str, int],
596
+ perm_group: SymmetryGroup | None,
597
+ ) -> int:
598
+ """Count unique elements for a set of subscript indices under symmetry."""
599
+ if not indices:
600
+ return 1
601
+ if perm_group is not None:
602
+ labels = perm_group._labels or tuple(
603
+ sorted(indices)[: perm_group.degree]
604
+ )
605
+ pg_size_dict: dict[int, int] = {}
606
+ accounted: set[str] = set()
607
+ for i, lbl in enumerate(labels):
608
+ pg_size_dict[i] = size_dict[lbl]
609
+ accounted.add(lbl)
610
+ count = perm_group.burnside_unique_count(pg_size_dict)
611
+ for idx in indices:
612
+ if idx not in accounted:
613
+ count *= size_dict[idx]
614
+ return count
615
+ return prod(size_dict[i] for i in indices)
616
+
617
+ if step.flop_cost == step.dense_flop_cost:
618
+ return "-"
619
+
620
+ parts: list[str] = []
621
+
622
+ if step.output_group is not None and step.output_shape:
623
+ out_str = step.subscript.split("->")[1] if "->" in step.subscript else ""
624
+ out_total = prod(step.output_shape)
625
+ out_unique = _unique_elements(
626
+ frozenset(out_str),
627
+ self.size_dict,
628
+ perm_group=step.output_group, # type: ignore[arg-type]
629
+ )
630
+ if out_unique != out_total:
631
+ parts.append(f"V:{out_unique:,}/{out_total:,}")
632
+
633
+ if step.inner_applied and step.inner_group is not None:
634
+ lhs = (
635
+ step.subscript.split("->")[0]
636
+ if "->" in step.subscript
637
+ else step.subscript
638
+ )
639
+ out_str = step.subscript.split("->")[1] if "->" in step.subscript else ""
640
+ contracted = frozenset(lhs.replace(",", "")) - frozenset(out_str)
641
+ if contracted:
642
+ inner_total = prod(self.size_dict[c] for c in contracted)
643
+ inner_unique = _unique_elements(
644
+ contracted,
645
+ self.size_dict,
646
+ perm_group=step.inner_group, # type: ignore[arg-type]
647
+ )
648
+ if inner_unique != inner_total:
649
+ parts.append(f"W:{inner_unique:,}/{inner_total:,}")
650
+
651
+ return " ".join(parts) if parts else "-"
652
+
653
+ @staticmethod
654
+ def _fmt_subset(s: frozenset[int] | None) -> str:
655
+ if s is None:
656
+ return "-"
657
+ if not s:
658
+ return "{}"
659
+ return "{" + ",".join(str(i) for i in sorted(s)) + "}"
660
+
661
+ def _header_lines(self) -> list[str]:
662
+ sizes_line = self._fmt_index_sizes()
663
+ header_lines = [
664
+ f" Complete contraction: {self.eq}",
665
+ f" Naive cost (flopscope): {self.naive_cost:,}",
666
+ f" Optimized cost (flopscope): {self.optimized_cost:,}",
667
+ f" Speedup: {self.speedup:.3f}x",
668
+ f" Savings: {self._fmt_overall_savings()}",
669
+ f" Largest intermediate: {self.largest_intermediate:,} elements",
670
+ ]
671
+ if sizes_line:
672
+ header_lines.append(f" Index sizes: {sizes_line}")
673
+ if self.optimizer_used:
674
+ header_lines.append(f" Optimizer: {self.optimizer_used}")
675
+ return header_lines
676
+
677
+ def _rich_step_table(self, verbose: bool = False):
678
+ from rich import box
679
+ from rich.table import Table
680
+
681
+ any_unique = any(
682
+ s.dense_flop_cost > 0 and s.flop_cost != s.dense_flop_cost
683
+ for s in self.steps
684
+ )
685
+ any_regime = any(hasattr(s, "_regime") for s in self.steps)
686
+
687
+ contract_width = max(
688
+ len("contract"),
689
+ max((len(self._fmt_contract(step)) for step in self.steps), default=0),
690
+ )
691
+ subscript_width = min(
692
+ 24,
693
+ max(
694
+ len("subscript"),
695
+ max((len(step.subscript) for step in self.steps), default=0),
696
+ ),
697
+ )
698
+ flops_width = max(
699
+ len("flops"),
700
+ max((len(f"{step.flop_cost:,}") for step in self.steps), default=0),
701
+ )
702
+ dense_width = max(
703
+ len("dense_flops"),
704
+ max((len(f"{step.dense_flop_cost:,}") for step in self.steps), default=0),
705
+ )
706
+ savings_width = max(
707
+ len("savings"),
708
+ max(
709
+ (len(f"{step.symmetry_savings:0.1%}") for step in self.steps), default=0
710
+ ),
711
+ )
712
+ blas_width = max(
713
+ len("blas"),
714
+ max(
715
+ (
716
+ len(str(step.blas_type) if step.blas_type else "-")
717
+ for step in self.steps
718
+ ),
719
+ default=0,
720
+ ),
721
+ )
722
+ regime_width = None
723
+ if any_regime:
724
+ regime_width = max(
725
+ len("regime"),
726
+ max(
727
+ (len(getattr(step, "_regime", "-")) for step in self.steps),
728
+ default=len("regime"),
729
+ ),
730
+ )
731
+ unique_width = None
732
+ if any_unique:
733
+ unique_width = max(
734
+ len("unique/total"),
735
+ max(
736
+ (len(self._fmt_unique_dense(step)) for step in self.steps),
737
+ default=0,
738
+ ),
739
+ )
740
+
741
+ table = Table(
742
+ show_header=True,
743
+ header_style="bold",
744
+ expand=True,
745
+ box=box.HEAVY,
746
+ pad_edge=False,
747
+ padding=(0, 1),
748
+ collapse_padding=True,
749
+ )
750
+ table.add_column("step", justify="right", no_wrap=True, width=len("step"))
751
+ table.add_column("contract", justify="left", no_wrap=True, width=contract_width)
752
+ table.add_column("subscript", overflow="fold", width=subscript_width)
753
+ if any_regime:
754
+ table.add_column("regime", justify="left", no_wrap=True, width=regime_width)
755
+ table.add_column("flops", justify="right", no_wrap=True, width=flops_width)
756
+ table.add_column(
757
+ "dense_flops", justify="right", no_wrap=True, width=dense_width
758
+ )
759
+ table.add_column("savings", justify="right", no_wrap=True, width=savings_width)
760
+ table.add_column("blas", no_wrap=True, width=blas_width)
761
+ if any_unique:
762
+ table.add_column("unique/total", no_wrap=True, width=unique_width)
763
+ table.add_column(
764
+ "symmetry (inputs → output)",
765
+ overflow="fold",
766
+ min_width=len("symmetry (inputs → output)"),
767
+ ratio=1,
768
+ )
769
+
770
+ cumulative = 0
771
+ for i, step in enumerate(self.steps):
772
+ row = [
773
+ str(i),
774
+ self._fmt_contract(step),
775
+ self._rich_subscript_text(step.subscript),
776
+ ]
777
+ if any_regime:
778
+ row.append(getattr(step, "_regime", "-"))
779
+ row += [
780
+ f"{step.flop_cost:,}",
781
+ f"{step.dense_flop_cost:,}",
782
+ f"{step.symmetry_savings:>7.1%}",
783
+ str(step.blas_type) if step.blas_type else "-",
784
+ ]
785
+ if any_unique:
786
+ row.append(self._fmt_unique_dense(step))
787
+ row.append(self._rich_step_sym_text(step) or "-")
788
+ table.add_row(*row)
789
+ # Sprint 3 (#55): pre-reduction sub-rows
790
+ pre_reductions = step.pre_reductions
791
+ if pre_reductions:
792
+ from rich.text import Text
793
+
794
+ lhs = step.subscript.split("->")[0]
795
+ operand_subs = lhs.split(",")
796
+ for pre in pre_reductions:
797
+ op_name = f"op{pre.operand_index}"
798
+ labels_str = ",".join(pre.removed_labels)
799
+ original_sub = (
800
+ operand_subs[pre.operand_index]
801
+ if 0 <= pre.operand_index < len(operand_subs)
802
+ else ""
803
+ )
804
+ sub_text = Text(
805
+ f" pre-reduce {op_name} {{{labels_str}}}: "
806
+ f"{pre.cost:,} ops "
807
+ f"(subscript: {original_sub} → {pre.surviving_subscript})",
808
+ style="dim",
809
+ )
810
+ detail_row = [""] * len(table.columns)
811
+ detail_row[2] = sub_text # type: ignore[call-overload, assignment]
812
+ table.add_row(*detail_row)
813
+ # Residual contraction sub-row
814
+ residual_cost = step.flop_cost - sum(p.cost for p in pre_reductions)
815
+ effective_subs = list(operand_subs)
816
+ for pre in pre_reductions:
817
+ if 0 <= pre.operand_index < len(effective_subs):
818
+ effective_subs[pre.operand_index] = pre.surviving_subscript
819
+ step_output_part = (
820
+ step.subscript.split("->")[1] if "->" in step.subscript else ""
821
+ )
822
+ residual_subscript = ",".join(effective_subs) + "->" + step_output_part
823
+ detail_row = [""] * len(table.columns)
824
+ detail_row[2] = Text( # type: ignore[call-overload, assignment]
825
+ f" residual contraction: {residual_subscript} → "
826
+ f"{residual_cost:,} ops",
827
+ style="dim",
828
+ )
829
+ table.add_row(*detail_row)
830
+ if verbose:
831
+ cumulative += step.flop_cost
832
+ detail_row = [""] * len(table.columns)
833
+ detail_row[2] = self._rich_verbose_detail_text(step, cumulative) # type: ignore[call-overload, assignment]
834
+ detail_row[-1] = self._rich_verbose_detail_text( # type: ignore[call-overload, assignment]
835
+ step, cumulative, step_index=i
836
+ )
837
+ table.add_row(*detail_row)
838
+
839
+ return table
840
+
841
+ def _rich_renderable(self, verbose: bool = False):
842
+ from rich.console import Group
843
+ from rich.panel import Panel
844
+
845
+ expr = self._rich_eq_text()
846
+ summary = self._rich_summary_strip()
847
+ table = self._rich_step_table(verbose=verbose)
848
+ return Panel(
849
+ Group(expr, summary, table),
850
+ title="[bold cyan]einsum_path[/bold cyan]",
851
+ border_style="cyan",
852
+ )
853
+
854
+ def format_table(self, verbose: bool = False) -> str:
855
+ """Render the path info as a printable table.
856
+
857
+ Parameters
858
+ ----------
859
+ verbose : bool, optional
860
+ When True, emit an additional indented details row under each
861
+ step showing the operand subset covered by the intermediate,
862
+ its output shape, the unique-vs-dense element counts that the
863
+ symmetry savings derive from, and the cumulative cost so far.
864
+ Useful for debugging why a particular step's savings are what
865
+ they are. Default False.
866
+ """
867
+ sym_strs = [self._fmt_step_sym(s) for s in self.steps]
868
+ max_sym_width = max((len(s) for s in sym_strs), default=0)
869
+ header_lines = self._header_lines()
870
+
871
+ # Common columns: step, contract, subscript, regime, flops, dense_flops, savings, blas
872
+ # Plus: symmetry (when any step has symmetry) and unique/dense (when any
873
+ # step has reduced cost).
874
+ any_unique = any(
875
+ s.dense_flop_cost > 0 and s.flop_cost != s.dense_flop_cost
876
+ for s in self.steps
877
+ )
878
+
879
+ contract_strs = [self._fmt_contract(s) for s in self.steps]
880
+ contract_col_width = max(
881
+ len("contract"), max((len(c) for c in contract_strs), default=0)
882
+ )
883
+ unique_col_width = max(
884
+ len("unique/total"),
885
+ max((len(self._fmt_unique_dense(s)) for s in self.steps), default=0),
886
+ )
887
+ regime_strs = [self._fmt_step_regime(s) for s in self.steps]
888
+ regime_col_width = max(
889
+ len("regime"), max((len(r) for r in regime_strs), default=0)
890
+ )
891
+
892
+ # Build the header line
893
+ cols = [
894
+ f"{'step':>4}",
895
+ f"{'contract':<{contract_col_width}}",
896
+ f"{'subscript':<30}",
897
+ f"{'regime':<{regime_col_width}}",
898
+ f"{'flops':>14}",
899
+ f"{'dense_flops':>14}",
900
+ f"{'savings':>8}",
901
+ f"{'blas':<8}",
902
+ ]
903
+ if any_unique:
904
+ cols.append(f"{'unique/total':<{unique_col_width}}")
905
+ sym_col_width = min(max(max_sym_width, len("symmetry (inputs → output)")), 60)
906
+ cols.append(f"{'symmetry (inputs → output)':<{sym_col_width}}")
907
+
908
+ header_row = " ".join(cols)
909
+ width = max(len(header_row), 84)
910
+ lines = header_lines + ["-" * width, header_row, "-" * width]
911
+
912
+ cumulative = 0
913
+ for i, step in enumerate(self.steps):
914
+ blas_label = str(step.blas_type) if step.blas_type else "-"
915
+ row_parts = [
916
+ f"{i:>4}",
917
+ f"{contract_strs[i]:<{contract_col_width}}",
918
+ f"{step.subscript:<30}",
919
+ f"{regime_strs[i]:<{regime_col_width}}",
920
+ f"{step.flop_cost:>14,}",
921
+ f"{step.dense_flop_cost:>14,}",
922
+ f"{step.symmetry_savings:>7.1%}",
923
+ f"{blas_label:<8}",
924
+ ]
925
+ if any_unique:
926
+ row_parts.append(f"{self._fmt_unique_dense(step):<{unique_col_width}}")
927
+ sym_str = sym_strs[i] or "-"
928
+ if len(sym_str) > sym_col_width:
929
+ sym_str = sym_str[: sym_col_width - 1] + "…"
930
+ row_parts.append(f"{sym_str:<{sym_col_width}}")
931
+ lines.append(" ".join(row_parts))
932
+
933
+ # Sprint 3 (#55): pre-reduction sub-rows
934
+ pre_reductions = step.pre_reductions
935
+ if pre_reductions:
936
+ indent = " " * 8
937
+ lhs = step.subscript.split("->")[0]
938
+ operand_subs = lhs.split(",")
939
+ # Per-pre-reduction sub-row
940
+ for pre in pre_reductions:
941
+ op_name = f"op{pre.operand_index}"
942
+ labels_str = ",".join(pre.removed_labels)
943
+ original_sub = (
944
+ operand_subs[pre.operand_index]
945
+ if 0 <= pre.operand_index < len(operand_subs)
946
+ else ""
947
+ )
948
+ sub_line = (
949
+ f"{indent}pre-reduce {op_name} {{{labels_str}}}: "
950
+ f"{pre.cost:,} ops "
951
+ f"(subscript: {original_sub} → {pre.surviving_subscript})"
952
+ )
953
+ lines.append(sub_line)
954
+ # Residual contraction sub-row
955
+ residual_cost = step.flop_cost - sum(p.cost for p in pre_reductions)
956
+ effective_subs = list(operand_subs)
957
+ for pre in pre_reductions:
958
+ if 0 <= pre.operand_index < len(effective_subs):
959
+ effective_subs[pre.operand_index] = pre.surviving_subscript
960
+ step_output_part = (
961
+ step.subscript.split("->")[1] if "->" in step.subscript else ""
962
+ )
963
+ residual_subscript = ",".join(effective_subs) + "->" + step_output_part
964
+ lines.append(
965
+ f"{indent}residual contraction: {residual_subscript} → "
966
+ f"{residual_cost:,} ops"
967
+ )
968
+
969
+ cumulative += step.flop_cost
970
+ if verbose:
971
+ # Indented details row: subset, out_shape, cumulative cost.
972
+ # Aligned under the subscript column for visual clarity.
973
+ subset_str = self._fmt_subset(step.merged_subset)
974
+ shape_str = (
975
+ "(" + ",".join(str(d) for d in step.output_shape) + ")"
976
+ if step.output_shape
977
+ else "()"
978
+ )
979
+ detail_parts = [
980
+ f"subset={subset_str}",
981
+ f"out_shape={shape_str}",
982
+ f"cumulative={cumulative:,}",
983
+ ]
984
+ # NEW: per-step M / α / −O from attached accumulation.
985
+ acc_step = getattr(step, "_acc_step", None)
986
+ if acc_step is not None:
987
+ m_value = acc_step.m_total
988
+ alpha_value = acc_step.alpha or 0
989
+ o_value = (
990
+ acc_step.per_component[0].num_output_orbits
991
+ if acc_step.per_component
992
+ else 0
993
+ )
994
+ detail_parts.append(f"M={m_value} α={alpha_value} −O={o_value}")
995
+ lines.append(" " + " ".join(detail_parts))
996
+
997
+ return "\n".join(lines)
998
+
999
+ def __rich__(self):
1000
+ return self._rich_renderable()
1001
+
1002
+ def print(self, verbose: bool = False) -> None:
1003
+ """Print using Rich when available, otherwise plain text.
1004
+
1005
+ Notes
1006
+ -----
1007
+ Builtin ``print(info)`` still goes through ``__str__`` and remains
1008
+ the plain-text fallback. This convenience method chooses the Rich
1009
+ renderer whenever Rich is importable, including the Rich verbose
1010
+ layout when ``verbose=True``.
1011
+ """
1012
+ import builtins
1013
+
1014
+ try:
1015
+ from rich.console import Console
1016
+ except ImportError:
1017
+ builtins.print(self.format_table(verbose=verbose))
1018
+ return None
1019
+
1020
+ if verbose:
1021
+ Console().print(self._rich_renderable(verbose=True))
1022
+ else:
1023
+ Console().print(self)
1024
+ return None
1025
+
1026
+ def __str__(self) -> str:
1027
+ return self.format_table(verbose=False)
1028
+
1029
+ def __repr__(self) -> str:
1030
+ return self.__str__()
1031
+
1032
+
1033
+ # ── per-step cost helper ───────────────────────────────────────────
1034
+
1035
+
1036
+ def symmetric_flop_count(
1037
+ idx_contract,
1038
+ inner,
1039
+ num_terms,
1040
+ size_dict,
1041
+ *,
1042
+ input_subscripts=None,
1043
+ output_subscript=None,
1044
+ input_shapes=None,
1045
+ per_input_groups=None,
1046
+ ):
1047
+ """Per-step symmetry-aware cost. Delegates to compute_accumulation_cost
1048
+ on the binary sub-expression so path-walker per-step costs match
1049
+ accumulation per-step costs by construction.
1050
+
1051
+ When ``per_input_groups`` is provided (a sequence of SymmetryGroup-or-None,
1052
+ one per input), the cache call uses those as the per-operand symmetry
1053
+ fingerprint so propagated intermediate symmetries reduce the per-step
1054
+ cost. When None, falls back to all-None fingerprints (legacy/dense path).
1055
+
1056
+ Legacy fallback: when subscripts/shapes are not provided (older callers),
1057
+ falls back to the dense direct-event count from helpers.flop_count.
1058
+ """
1059
+ if input_subscripts is None or input_shapes is None:
1060
+ return helpers.flop_count(
1061
+ idx_contraction=idx_contract,
1062
+ inner=inner,
1063
+ num_terms=num_terms,
1064
+ size_dictionary=size_dict,
1065
+ )
1066
+
1067
+ from flopscope._accumulation._cache import get_accumulation_cost_cached
1068
+ from flopscope._config import get_setting
1069
+
1070
+ canonical = ",".join(input_subscripts) + "->" + (output_subscript or "")
1071
+ partition_budget = int(get_setting("partition_budget")) # type: ignore[arg-type]
1072
+
1073
+ if per_input_groups is not None:
1074
+ # Oracle-derived groups encode generators in label-sorted space:
1075
+ # group._labels = alphabetically sorted tuple of subscript chars;
1076
+ # generator[i] = j means label _labels[i] maps to _labels[j].
1077
+ # Source A in _collect_pi_permutations instead expects generators in
1078
+ # tensor-axis space: generator[i] = j means axis i maps to axis j.
1079
+ # Convert by re-expressing each generator via the subscript char order.
1080
+ from flopscope._accumulation._cost import compute_accumulation_cost
1081
+ from flopscope._perm_group import SymmetryGroup as _SG
1082
+ from flopscope._perm_group import _PermutationCompat as _Perm
1083
+
1084
+ def _oracle_group_to_tensor_axes(group: _SG, subscript: str) -> _SG | None:
1085
+ """Re-express an oracle label-sorted group in tensor-axis space.
1086
+
1087
+ The oracle creates SymmetryGroup with _labels = sorted char tuple
1088
+ and axes = identity range. Source A in the subgraph oracle uses
1089
+ axes[g_pos] as a tensor axis index, so passing the oracle group
1090
+ directly maps position 0 to axis 0, which is wrong when the chars
1091
+ are sorted (not in subscript order).
1092
+
1093
+ This function builds a new SymmetryGroup whose generators act on
1094
+ axis indices of ``subscript`` directly, so Source A interprets them
1095
+ correctly.
1096
+ """
1097
+ if group is None or group._labels is None:
1098
+ return group # type: ignore[return-value]
1099
+ labels = group._labels # e.g. ('b', 'c', 'i') sorted
1100
+ char_to_axis = {c: i for i, c in enumerate(subscript)}
1101
+ label_to_lpos = {lbl: k for k, lbl in enumerate(labels)}
1102
+
1103
+ new_gens = []
1104
+ for gen in group.generators:
1105
+ arr = list(gen.array_form) # label-space permutation
1106
+ axis_gen = list(range(len(subscript)))
1107
+ for ax, char in enumerate(subscript):
1108
+ if char in label_to_lpos:
1109
+ lpos = label_to_lpos[char]
1110
+ target_char = labels[arr[lpos]]
1111
+ if target_char in char_to_axis:
1112
+ axis_gen[ax] = char_to_axis[target_char]
1113
+ new_gens.append(_Perm(axis_gen))
1114
+ if not new_gens:
1115
+ return None
1116
+ new_grp = _SG(*new_gens, axes=tuple(range(len(subscript))))
1117
+ return new_grp
1118
+
1119
+ converted = tuple(
1120
+ _oracle_group_to_tensor_axes(g, sub) if g is not None else None
1121
+ for g, sub in zip(per_input_groups, input_subscripts, strict=False)
1122
+ )
1123
+ cost = compute_accumulation_cost(
1124
+ canonical_subscripts=canonical,
1125
+ input_parts=tuple(input_subscripts),
1126
+ output_subscript=output_subscript or "",
1127
+ shapes=tuple(tuple(s) for s in input_shapes),
1128
+ per_op_symmetries=converted,
1129
+ identity_pattern=None,
1130
+ partition_budget=partition_budget,
1131
+ )
1132
+ return cost.total
1133
+
1134
+ sym_fingerprint = tuple(None for _ in input_subscripts)
1135
+ cost = get_accumulation_cost_cached(
1136
+ canonical_subscripts=canonical,
1137
+ input_parts=tuple(input_subscripts),
1138
+ output_subscript=output_subscript or "",
1139
+ shapes=tuple(tuple(s) for s in input_shapes),
1140
+ sym_fingerprint=sym_fingerprint,
1141
+ identity_pattern=None,
1142
+ partition_budget=partition_budget,
1143
+ )
1144
+ return cost.total
1145
+
1146
+
1147
+ # ── build_path_info adapter (Task 5) ───────────────────────────────
1148
+
1149
+
1150
+ def build_path_info(
1151
+ upstream_path,
1152
+ upstream_info,
1153
+ *,
1154
+ size_dict,
1155
+ optimizer_used: str = "",
1156
+ per_op_symmetries=None,
1157
+ identity_pattern=None,
1158
+ ):
1159
+ """Adapt upstream opt_einsum's PathInfo to flopscope's PathInfo.
1160
+
1161
+ Per-step ``flop_cost`` is recomputed using flopscope's
1162
+ ``_helpers.flop_count`` (FMA = 1 by default; configurable via the
1163
+ ``fma_cost`` setting). ``naive_cost`` and ``optimized_cost`` are also
1164
+ recomputed from the per-step costs.
1165
+
1166
+ Parameters
1167
+ ----------
1168
+ upstream_path : list[tuple[int, ...]]
1169
+ The contraction path returned by opt_einsum.contract_path.
1170
+ upstream_info : opt_einsum.contract.PathInfo
1171
+ Upstream's PathInfo with contraction_list, naive_cost, etc.
1172
+ size_dict : dict[str, int]
1173
+ Label -> dimension size mapping.
1174
+ optimizer_used : str, optional
1175
+ Name of the optimizer that produced ``upstream_path``. Propagated
1176
+ into the returned PathInfo for display. Defaults to ``''``.
1177
+ per_op_symmetries : sequence of SymmetryGroup or None, optional
1178
+ Per-operand declared symmetries (parallel to operands). When provided,
1179
+ a SubgraphSymmetryOracle is built and queried per step to populate
1180
+ ``input_groups``, ``output_group``, and ``inner_group`` on each
1181
+ StepInfo. Defaults to ``None`` (all dense, no symmetry).
1182
+
1183
+ Returns
1184
+ -------
1185
+ PathInfo
1186
+ flopscope's PathInfo with FMA-aware per-step costs.
1187
+ """
1188
+ from math import prod
1189
+
1190
+ # Walk the contraction list. Each entry has the shape:
1191
+ # (idx_contract: tuple[int,...], idx_removed: frozenset[str],
1192
+ # einsum_str: str, remaining: tuple[str,...] | None, do_blas: bool|str)
1193
+ steps_out: list[StepInfo] = []
1194
+ largest_intermediate = 0
1195
+
1196
+ # Reconstruct merged_subset tracking from the path itself.
1197
+ # upstream_path[i] gives the original (pre-sort) indices for step i.
1198
+ _first_remaining = (
1199
+ upstream_info.contraction_list[0][3]
1200
+ if upstream_info.contraction_list
1201
+ and upstream_info.contraction_list[0][3] is not None
1202
+ else None
1203
+ )
1204
+ num_ops = (
1205
+ (len(_first_remaining) + 1)
1206
+ if _first_remaining is not None
1207
+ else (len(list(upstream_path)) + 1)
1208
+ )
1209
+
1210
+ # ssa_to_subset tracks which original operands each SSA id covers.
1211
+ ssa_to_subset: dict[int, frozenset[int]] = {
1212
+ k: frozenset({k}) for k in range(num_ops)
1213
+ }
1214
+ ssa_ids: list[int] = list(range(num_ops))
1215
+ next_ssa = num_ops
1216
+
1217
+ # Bug B fix: build a SubgraphSymmetryOracle from the declared operand
1218
+ # symmetries so per-step input_groups / output_group / inner_group are
1219
+ # populated correctly instead of always being empty / None.
1220
+ # The oracle is built once here and queried per step below.
1221
+ oracle = None
1222
+ if per_op_symmetries is not None:
1223
+ orig_input_parts = getattr(upstream_info, "input_subscripts", None)
1224
+ orig_output = getattr(upstream_info, "output_subscript", "")
1225
+ if orig_input_parts is not None:
1226
+ _orig_parts_list = orig_input_parts.split(",")
1227
+ if len(_orig_parts_list) == num_ops:
1228
+ try:
1229
+ import numpy as _np
1230
+
1231
+ from flopscope._opt_einsum._subgraph_symmetry import (
1232
+ SubgraphSymmetryOracle,
1233
+ )
1234
+
1235
+ # Build dummy operands with the right shapes, then alias
1236
+ # positions in the same identity-group to share object
1237
+ # identity. The oracle's Source-B (identical-operand
1238
+ # swap) and Source-C (coordinated relabel) π-generators
1239
+ # rely on object identity (``_dummy_ops[i] is _dummy_ops[j]``);
1240
+ # without aliasing, residual symmetries that come from
1241
+ # identical operands (e.g. A @ A → S₂ on output) would
1242
+ # silently be omitted from the per-step annotation.
1243
+ _dummy_ops: list = [
1244
+ _np.empty(tuple(size_dict[c] for c in part))
1245
+ for part in _orig_parts_list
1246
+ ]
1247
+ if identity_pattern is not None:
1248
+ for _group in identity_pattern:
1249
+ _canonical = _dummy_ops[_group[0]]
1250
+ for _pos in _group[1:]:
1251
+ _dummy_ops[_pos] = _canonical
1252
+
1253
+ def _sym_to_group_list(sym: Any, subscript: str) -> list | None:
1254
+ """Convert per_op_symmetry entry → oracle per_op_groups list.
1255
+
1256
+ Source A generators in ``_collect_pi_permutations`` require
1257
+ ``group._labels`` to be populated (the function short-circuits
1258
+ on ``group._labels is None``). User-supplied groups created
1259
+ via ``SymmetryGroup.symmetric(axes=...)`` have ``axes`` set
1260
+ but ``_labels`` empty, so we synthesize labels here from the
1261
+ operand's subscript at the symmetry's axis positions.
1262
+
1263
+ We build a fresh ``SymmetryGroup`` so the user's object
1264
+ stays untouched (the oracle is per-call and these clones
1265
+ live only for its duration).
1266
+ """
1267
+ if sym is None:
1268
+ return None
1269
+ if not isinstance(sym, SymmetryGroup):
1270
+ return None
1271
+ if sym._labels is not None:
1272
+ return [sym]
1273
+ if sym.axes is None:
1274
+ return [sym]
1275
+ try:
1276
+ labels = tuple(subscript[ax] for ax in sym.axes)
1277
+ except (IndexError, TypeError):
1278
+ return None
1279
+ clone = SymmetryGroup(*sym.generators, axes=sym.axes)
1280
+ clone._labels = labels
1281
+ return [clone]
1282
+
1283
+ oracle = SubgraphSymmetryOracle(
1284
+ operands=_dummy_ops,
1285
+ subscript_parts=_orig_parts_list,
1286
+ per_op_groups=[
1287
+ _sym_to_group_list(s, part)
1288
+ for s, part in zip(
1289
+ per_op_symmetries, _orig_parts_list, strict=False
1290
+ )
1291
+ ],
1292
+ output_chars=orig_output,
1293
+ )
1294
+ except Exception:
1295
+ oracle = None
1296
+
1297
+ # SSA current-subsets list for oracle queries — parallel to ssa_ids list.
1298
+ # Each entry is the frozenset of original operand indices covered by the
1299
+ # corresponding current operand. Starts as singletons.
1300
+ current_oracle_subsets: list[frozenset[int]] = [
1301
+ frozenset({k}) for k in range(num_ops)
1302
+ ]
1303
+
1304
+ for step_idx, entry in enumerate(upstream_info.contraction_list):
1305
+ idx_removed = entry[1] # frozenset of label chars removed (inner product)
1306
+ einsum_str = entry[2] # e.g. "jk,ij->ik"
1307
+ do_blas = entry[4] # BLAS classification string or False
1308
+
1309
+ # The original path indices for this step (pre-sort, from upstream_path).
1310
+ original_path_tuple: tuple[int, ...] = tuple(upstream_path[step_idx])
1311
+
1312
+ if "->" in einsum_str:
1313
+ lhs, rhs = einsum_str.split("->", 1)
1314
+ else:
1315
+ lhs, rhs = einsum_str, ""
1316
+
1317
+ lhs_parts = lhs.split(",")
1318
+ num_terms = len(lhs_parts)
1319
+
1320
+ # Reconstruct idx_contraction (set of all labels touched) from lhs
1321
+ idx_contraction: frozenset[str] = frozenset(
1322
+ c for part in lhs_parts for c in part
1323
+ )
1324
+
1325
+ inner = bool(idx_removed)
1326
+
1327
+ input_shapes_for_step: list[tuple[int, ...]] = [
1328
+ tuple(size_dict[c] for c in part) for part in lhs_parts
1329
+ ]
1330
+ output_shape_for_step: tuple[int, ...] = tuple(size_dict[c] for c in rhs)
1331
+
1332
+ # Bug B fix: query the oracle for per-step symmetry groups BEFORE computing
1333
+ # cost so that propagated intermediate symmetries reduce the per-step cost.
1334
+ # The oracle uses current_oracle_subsets (not yet updated for this step)
1335
+ # so querying before the SSA merge gives us the current inputs' symmetries.
1336
+ step_input_groups: list = []
1337
+ step_output_group: object | None = None
1338
+ step_inner_group: object | None = None
1339
+
1340
+ # Reconstruct merged_subset by tracking which original operands each
1341
+ # SSA id covers. The path gives us the positions to contract.
1342
+ contract_positions = tuple(sorted(original_path_tuple, reverse=True))
1343
+ new_merged_subset: frozenset[int] = frozenset()
1344
+ for ci in contract_positions:
1345
+ if ci < len(ssa_ids):
1346
+ new_merged_subset = new_merged_subset | ssa_to_subset[ssa_ids[ci]]
1347
+
1348
+ if oracle is not None:
1349
+ try:
1350
+ # Gather which oracle-tracked subsets map to each lhs input.
1351
+ # opt_einsum pops positions highest-to-lowest (contract_positions
1352
+ # is sorted descending), so the lhs subscript order matches
1353
+ # the descending-position order of the path entry.
1354
+ step_input_subsets = [
1355
+ current_oracle_subsets[pos]
1356
+ for pos in sorted(original_path_tuple, reverse=True)
1357
+ if pos < len(current_oracle_subsets)
1358
+ ]
1359
+ for inp_subset in step_input_subsets:
1360
+ ss = oracle.sym(inp_subset)
1361
+ step_input_groups.append(ss.output) # V-side group for this input
1362
+ # For output_group and inner_group, query the merged subset.
1363
+ merged_ss = oracle.sym(new_merged_subset)
1364
+ step_output_group = merged_ss.output
1365
+ step_inner_group = merged_ss.inner
1366
+ except Exception:
1367
+ step_input_groups = []
1368
+ step_output_group = None
1369
+ step_inner_group = None
1370
+
1371
+ cost = symmetric_flop_count(
1372
+ idx_contraction,
1373
+ inner,
1374
+ num_terms,
1375
+ size_dict,
1376
+ input_subscripts=lhs_parts,
1377
+ output_subscript=rhs,
1378
+ input_shapes=input_shapes_for_step,
1379
+ per_input_groups=step_input_groups if step_input_groups else None,
1380
+ )
1381
+
1382
+ # Dense cost: what this step would cost without any symmetry reduction.
1383
+ step_dense_flop_cost = helpers.flop_count(
1384
+ idx_contraction,
1385
+ inner,
1386
+ num_terms,
1387
+ size_dict,
1388
+ input_subscripts=lhs_parts,
1389
+ output_subscript=rhs,
1390
+ input_shapes=input_shapes_for_step,
1391
+ )
1392
+ # Fraction of dense cost saved by symmetry (0.0 when no symmetry or
1393
+ # when the accumulation model costs more than the dense baseline due to
1394
+ # FMA vs. flop_count differences on this branch).
1395
+ step_symmetry_savings = (
1396
+ max(0.0, 1.0 - cost / step_dense_flop_cost)
1397
+ if step_dense_flop_cost > 0
1398
+ else 0.0
1399
+ )
1400
+
1401
+ if output_shape_for_step:
1402
+ largest_intermediate = max(
1403
+ largest_intermediate, prod(output_shape_for_step)
1404
+ )
1405
+
1406
+ # Complete the SSA merge (popping positions into next_ssa).
1407
+ for ci in contract_positions:
1408
+ if ci < len(ssa_ids):
1409
+ ssa_ids.pop(ci)
1410
+ ssa_to_subset[next_ssa] = new_merged_subset
1411
+ ssa_ids.append(next_ssa)
1412
+ next_ssa += 1
1413
+
1414
+ # Update current_oracle_subsets to mirror the SSA merge above.
1415
+ # Guard against out-of-range indices the same way the ssa_ids loop does.
1416
+ _oracle_contract_positions = tuple(
1417
+ pos
1418
+ for pos in sorted(original_path_tuple, reverse=True)
1419
+ if pos < len(current_oracle_subsets)
1420
+ )
1421
+ if _oracle_contract_positions:
1422
+ merged_oracle_subset: frozenset[int] = frozenset().union(
1423
+ *(current_oracle_subsets[pos] for pos in _oracle_contract_positions)
1424
+ )
1425
+ for pos in _oracle_contract_positions:
1426
+ current_oracle_subsets.pop(pos)
1427
+ current_oracle_subsets.append(merged_oracle_subset)
1428
+ else:
1429
+ current_oracle_subsets.append(frozenset())
1430
+
1431
+ steps_out.append(
1432
+ StepInfo(
1433
+ subscript=einsum_str,
1434
+ flop_cost=cost,
1435
+ input_shapes=input_shapes_for_step,
1436
+ output_shape=output_shape_for_step,
1437
+ blas_type=do_blas,
1438
+ path_indices=original_path_tuple,
1439
+ merged_subset=new_merged_subset,
1440
+ # Diagnostic fields: dense baseline and symmetry savings.
1441
+ dense_flop_cost=step_dense_flop_cost,
1442
+ symmetry_savings=step_symmetry_savings,
1443
+ # Bug B fix: symmetry groups from oracle (empty list / None when
1444
+ # per_op_symmetries was not provided or oracle build failed).
1445
+ input_groups=step_input_groups,
1446
+ output_group=step_output_group,
1447
+ inner_group=step_inner_group,
1448
+ )
1449
+ )
1450
+
1451
+ optimized_cost = sum(s.flop_cost for s in steps_out)
1452
+
1453
+ # Bug A fix: naive_cost uses the same α/M model as the per-step dense_flop_cost
1454
+ # (helpers.flop_count, no symmetry), so header "Savings" and per-step "savings"
1455
+ # columns are computed from the same model. The old approach used
1456
+ # helpers.flop_count over ALL labels as if they were contracted in one shot,
1457
+ # which can be a very different number than the sum of per-step dense costs.
1458
+ naive_cost = sum(s.dense_flop_cost for s in steps_out)
1459
+
1460
+ speedup = (naive_cost / optimized_cost) if optimized_cost > 0 else 1.0
1461
+
1462
+ return PathInfo(
1463
+ path=list(upstream_path),
1464
+ steps=steps_out,
1465
+ naive_cost=naive_cost,
1466
+ optimized_cost=optimized_cost,
1467
+ largest_intermediate=largest_intermediate,
1468
+ speedup=speedup,
1469
+ input_subscripts=getattr(upstream_info, "input_subscripts", ""),
1470
+ output_subscript=getattr(upstream_info, "output_subscript", ""),
1471
+ size_dict=dict(size_dict),
1472
+ optimizer_used=optimizer_used,
1473
+ contraction_list=list(upstream_info.contraction_list),
1474
+ scale_list=list(getattr(upstream_info, "scale_list", [])),
1475
+ size_list=list(getattr(upstream_info, "size_list", [])),
1476
+ _oe_naive_cost=naive_cost,
1477
+ _oe_opt_cost=optimized_cost,
1478
+ )