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,1414 @@
1
+ """AccumulationCost orchestrator + per-component cost wrapping.
2
+
3
+ Aggregates the ladder primitive (compute_accumulation) into
4
+ einsum-shaped cost reports. Future reduction code reuses run_ladder_per_component
5
+ and adds its own aggregator (aggregate_reduction) that uses different cost arithmetic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from dataclasses import replace as _dc_replace
14
+
15
+ from ._burnside import size_aware_burnside
16
+ from ._components import Component
17
+ from ._ladder import (
18
+ AccumulationResult,
19
+ RegimeId,
20
+ RegimeStep,
21
+ Shape,
22
+ compute_accumulation,
23
+ )
24
+ from ._output_orbit import restrict_stabilizer_to_positions
25
+
26
+
27
+ def _build_symmetric_proxy(shape, sym):
28
+ """Build a proxy operand for reduction_accumulation_cost.
29
+
30
+ reduction_accumulation_cost only inspects shape + symmetry on its input;
31
+ a numpy.empty(shape) wrapped in a SymmetricTensor (if sym is non-None) is
32
+ sufficient and avoids materializing actual values. We bypass validation
33
+ (as_symmetric would raise for uninitialized data) since we only need the
34
+ symmetry metadata, not correct tensor values. Used by per-step
35
+ pre-reduction detection to compute Tier-1 reduction cost without owning
36
+ the operand data itself.
37
+ """
38
+ import numpy as np
39
+
40
+ arr = np.empty(shape)
41
+ if sym is None:
42
+ return arr
43
+ from flopscope._symmetric import SymmetricTensor
44
+
45
+ return SymmetricTensor(arr, symmetry=sym)
46
+
47
+
48
+ def compute_step_cost_from_joint_group(
49
+ joint_group: object,
50
+ v_labels: tuple[str, ...],
51
+ w_labels: tuple[str, ...],
52
+ sizes: dict[str, int],
53
+ num_terms: int,
54
+ dimino_budget: int,
55
+ ) -> int | None:
56
+ """Per-step cost via Burnside on the merged-subset's joint group.
57
+
58
+ Sprint 2 / Cat C: complements the per-input fingerprint cost by handling
59
+ cross-step identity-swap cases (e.g. §5(a) step 2 = 160 → 55) that
60
+ per-input cannot see because intermediates lose their "from the same
61
+ original operand" identity.
62
+
63
+ Cost formula (Regime 1 / functionalProjection):
64
+ M = |orbits of joint_group on (V ∪ W)-tuples|
65
+ O = |orbits of joint_group's V-projection on V-tuples|
66
+ total = (num_terms − 1) · M + M − O (for k = 2: 2M − O)
67
+
68
+ Returns:
69
+ The computed total cost, or None when:
70
+ - joint_group is None / has no generators (trivial),
71
+ - V is not preserved setwise by joint_group (Regime 2 — caller
72
+ must fall back to per-input path),
73
+ - dimino enumeration exceeds dimino_budget,
74
+ - joint_group's _labels does not cover (v_labels + w_labels).
75
+ """
76
+ from flopscope._config import get_setting, set_setting
77
+ from flopscope._perm_group import _dimino
78
+ from flopscope._perm_group import _PermutationCompat as _Perm
79
+
80
+ if joint_group is None:
81
+ return None
82
+ if not getattr(joint_group, "generators", None):
83
+ return None
84
+
85
+ joint_labels = getattr(joint_group, "_labels", None)
86
+ if joint_labels is None:
87
+ return None
88
+
89
+ canonical_labels = tuple(v_labels) + tuple(w_labels)
90
+ if set(joint_labels) != set(canonical_labels):
91
+ return None
92
+
93
+ n_v = len(v_labels)
94
+ n_total = len(canonical_labels)
95
+ canonical_idx = {lbl: i for i, lbl in enumerate(canonical_labels)}
96
+ joint_idx = {lbl: i for i, lbl in enumerate(joint_labels)}
97
+
98
+ canonical_gens: list[_Perm] = []
99
+ for gen in joint_group.generators: # type: ignore[union-attr]
100
+ arr = gen.array_form
101
+ canonical_perm = [0] * n_total
102
+ for lbl in canonical_labels:
103
+ src_jpos = joint_idx[lbl]
104
+ dst_jlbl = joint_labels[arr[src_jpos]]
105
+ canonical_perm[canonical_idx[lbl]] = canonical_idx[dst_jlbl]
106
+ for vpos in range(n_v):
107
+ if canonical_perm[vpos] >= n_v:
108
+ return None
109
+ canonical_gens.append(_Perm(canonical_perm))
110
+
111
+ if not canonical_gens:
112
+ return None
113
+
114
+ # Temporarily override dimino_budget to honour the caller's request.
115
+ prev_budget = int(get_setting("dimino_budget")) # type: ignore[arg-type]
116
+ try:
117
+ set_setting("dimino_budget", dimino_budget)
118
+ all_elems = _dimino(tuple(canonical_gens))
119
+ except Exception:
120
+ return None
121
+ finally:
122
+ set_setting("dimino_budget", prev_budget)
123
+
124
+ combined_sizes = tuple(sizes[lbl] for lbl in canonical_labels)
125
+ M = size_aware_burnside(all_elems, combined_sizes)
126
+
127
+ v_elems = [_Perm(list(elem.array_form[:n_v])) for elem in all_elems]
128
+ v_sizes = combined_sizes[:n_v]
129
+ num_v_orbits = size_aware_burnside(v_elems, v_sizes)
130
+
131
+ return (num_terms - 1) * M + M - num_v_orbits
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class ComponentCost:
136
+ """Per-component cost. alpha is None when this component's regime returned
137
+ unavailable (partition budget exceeded with brute-force disabled by policy)."""
138
+
139
+ labels: tuple[str, ...]
140
+ va: tuple[str, ...]
141
+ wa: tuple[str, ...]
142
+ sizes: tuple[int, ...]
143
+
144
+ m: int
145
+ alpha: int | None
146
+ dense_count: int
147
+ num_output_orbits: int
148
+
149
+ regime_id: RegimeId
150
+ shape: Shape
151
+
152
+ group_name: str
153
+ group_order: int
154
+
155
+ regime_trace: tuple[RegimeStep, ...]
156
+ unavailable_reason: str | None = None
157
+
158
+ def describe(self) -> dict[str, str]:
159
+ """LaTeX strings built on demand (Task 23 fills in the body)."""
160
+ from ._cost_descriptions import describe_component
161
+
162
+ return describe_component(self)
163
+
164
+
165
+ def run_ladder_per_component(
166
+ components: Sequence[Component],
167
+ *,
168
+ partition_budget: int,
169
+ ) -> tuple[ComponentCost, ...]:
170
+ """For each Component, run the ladder + Burnside, return ComponentCosts.
171
+
172
+ Pure transformation, no aggregation policy. Reused by both einsum and (future)
173
+ reduction code paths.
174
+ """
175
+ out: list[ComponentCost] = []
176
+ for c in components:
177
+ result: AccumulationResult = compute_accumulation(
178
+ labels=c.labels,
179
+ va=c.va,
180
+ wa=c.wa,
181
+ elements=c.elements,
182
+ generators=c.generators,
183
+ sizes=c.sizes,
184
+ visible_positions=c.visible_positions,
185
+ partition_budget=partition_budget,
186
+ )
187
+ # M is always computable via Burnside, even when α is unavailable.
188
+ if c.elements and len(c.elements) > 0:
189
+ m = size_aware_burnside(c.elements, c.sizes)
190
+ else:
191
+ m = math.prod(c.sizes) if c.sizes else 1
192
+ dense_count = math.prod(c.sizes) if c.sizes else 1
193
+
194
+ # num_output_orbits: orbits of the visible-label tuples under the
195
+ # output stabilizer (the subgroup of G that preserves V setwise,
196
+ # restricted to V). Used by aggregate_einsum to apply the same
197
+ # "first cell of each orbit is a free copy" off-by-one correction
198
+ # that reduction_accumulation_cost already applies.
199
+ v_sizes = tuple(c.sizes[p] for p in c.visible_positions)
200
+ if not v_sizes:
201
+ num_output_orbits = 1
202
+ elif c.elements and len(c.elements) > 0:
203
+ h_elements = restrict_stabilizer_to_positions(
204
+ c.elements, c.visible_positions
205
+ )
206
+ num_output_orbits = size_aware_burnside(h_elements, v_sizes)
207
+ else:
208
+ num_output_orbits = math.prod(v_sizes)
209
+
210
+ unavailable_reason: str | None = None
211
+ if result.regime_id == "unavailable":
212
+ # The "unavailable" trace step's reason is the ladder's last word.
213
+ unavailable_step = next(
214
+ (s for s in result.trace if s.regime_id == "unavailable"),
215
+ None,
216
+ )
217
+ if unavailable_step is not None:
218
+ unavailable_reason = unavailable_step.reason
219
+
220
+ out.append(
221
+ ComponentCost(
222
+ labels=c.labels,
223
+ va=c.va,
224
+ wa=c.wa,
225
+ sizes=c.sizes,
226
+ m=m,
227
+ alpha=result.count,
228
+ dense_count=dense_count,
229
+ num_output_orbits=num_output_orbits,
230
+ regime_id=result.regime_id,
231
+ shape=result.shape,
232
+ group_name=c.group_name,
233
+ group_order=c.order,
234
+ regime_trace=result.trace,
235
+ unavailable_reason=unavailable_reason,
236
+ )
237
+ )
238
+ return tuple(out)
239
+
240
+
241
+ # ── AccumulationCost + aggregate_einsum ──────────────────────────────
242
+
243
+
244
+ import warnings
245
+
246
+ from flopscope._opt_einsum._contract import PreReduction
247
+ from flopscope.errors import CostFallbackWarning
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class AccumulationCost:
252
+ """Whole-einsum cost. When any component is unavailable, total falls back to
253
+ the dense baseline (k · dense_baseline) and a CostFallbackWarning fires."""
254
+
255
+ total: int
256
+ mu: int | None
257
+ alpha: int | None
258
+ m_total: int
259
+ dense_baseline: int
260
+ num_terms: int
261
+
262
+ per_component: tuple[ComponentCost, ...]
263
+
264
+ fallback_used: bool
265
+ unavailable_components: tuple[int, ...] = ()
266
+ unavailable_reason: str | None = None
267
+
268
+ # NEW: path-aware decomposition. Empty for k<=2 (no path walked).
269
+ per_step: tuple[AccumulationCost, ...] = ()
270
+ path: tuple[tuple[int, ...], ...] | None = None
271
+
272
+ # Sprint 3 (#55): per-step pre-reductions, one tuple per step (empty when no isolation).
273
+ pre_reductions_per_step: tuple[tuple[PreReduction, ...], ...] = ()
274
+
275
+ # Sprint 4: which cost category produced this step's total ("per-input",
276
+ # "joint-burnside", or "output-burnside"). None for aggregate (multi-step)
277
+ # AccumulationCost objects and pre-Sprint-4 stale instances.
278
+ cost_source: str | None = None
279
+
280
+ def describe(self) -> dict:
281
+ """Human-readable + LaTeX summary, built on demand."""
282
+ from ._cost_descriptions import describe_total
283
+
284
+ return describe_total(self)
285
+
286
+ @property
287
+ def savings_ratio(self) -> float:
288
+ """total / (k · dense_baseline). 1.0 means no savings; lower is better."""
289
+ denom = self.num_terms * self.dense_baseline
290
+ return self.total / denom if denom > 0 else 1.0
291
+
292
+
293
+ def aggregate_einsum(
294
+ component_costs: Sequence[ComponentCost],
295
+ *,
296
+ num_terms: int,
297
+ dense_baseline: int,
298
+ ) -> AccumulationCost:
299
+ """Aggregate per-component costs into the einsum cost:
300
+ ``total = (k-1)·∏M + ∏α − ∏num_output_orbits``.
301
+
302
+ The final ``−∏num_output_orbits`` term applies the same off-by-one
303
+ correction that ``reduction_accumulation_cost`` uses: the first cell of
304
+ each output orbit is a free copy, and only the remaining accumulations
305
+ cost. When there is no actual reduction (``∏α == ∏num_output_orbits``),
306
+ the α contribution collapses to zero, leaving only the ``(k-1)·∏M``
307
+ multiplication chain.
308
+
309
+ Fallback policy: if any component has alpha=None, total = k · dense_baseline
310
+ (the no-symmetry direct-event count) and a CostFallbackWarning fires.
311
+ """
312
+ failing = [i for i, c in enumerate(component_costs) if c.alpha is None]
313
+
314
+ m_total = 1
315
+ for c in component_costs:
316
+ m_total *= c.m
317
+
318
+ if not failing:
319
+ alpha_product = 1
320
+ output_orbit_product = 1
321
+ for c in component_costs:
322
+ assert c.alpha is not None # for type narrowing
323
+ alpha_product *= c.alpha
324
+ output_orbit_product *= c.num_output_orbits
325
+ mu = (num_terms - 1) * m_total
326
+ total = mu + alpha_product - output_orbit_product
327
+ return AccumulationCost(
328
+ total=total,
329
+ mu=mu,
330
+ alpha=alpha_product,
331
+ m_total=m_total,
332
+ dense_baseline=dense_baseline,
333
+ num_terms=num_terms,
334
+ per_component=tuple(component_costs),
335
+ fallback_used=False,
336
+ )
337
+
338
+ # Fallback: charge dense.
339
+ fallback_total = num_terms * dense_baseline
340
+ first_failing = component_costs[failing[0]]
341
+ reason = first_failing.unavailable_reason or "partition_budget exceeded"
342
+ failing_labels = ", ".join(first_failing.labels)
343
+ warnings.warn(
344
+ CostFallbackWarning(
345
+ f"einsum: component {list(failing)} ({failing_labels}) returned "
346
+ f"unavailable — charging dense cost {fallback_total} = "
347
+ f"{num_terms} × {dense_baseline}. Failing reason: {reason}. "
348
+ f"Raise via flopscope.configure(partition_budget=...) to attempt "
349
+ f"exact counting."
350
+ ),
351
+ stacklevel=4,
352
+ )
353
+ return AccumulationCost(
354
+ total=fallback_total,
355
+ mu=None,
356
+ alpha=None,
357
+ m_total=m_total,
358
+ dense_baseline=dense_baseline,
359
+ num_terms=num_terms,
360
+ per_component=tuple(component_costs),
361
+ fallback_used=True,
362
+ unavailable_components=tuple(failing),
363
+ unavailable_reason=reason,
364
+ )
365
+
366
+
367
+ # ── End-to-end orchestrator ──────────────────────────────────────────
368
+
369
+
370
+ from collections.abc import Sequence as _Seq
371
+ from typing import Any as _Any
372
+ from typing import cast as _cast
373
+
374
+ from ._bipartite import build_bipartite, build_incidence_matrix
375
+ from ._components import decompose_into_components
376
+ from ._detection import build_full_group, run_sigma_loop
377
+ from ._wreath import enumerate_wreath
378
+
379
+
380
+ def _build_size_map(
381
+ input_parts: _Seq[str],
382
+ shapes: _Seq[_Seq[int]],
383
+ ) -> dict[str, int]:
384
+ """Build label → size from operand shapes. Validates that each label
385
+ appears with consistent sizes across operands."""
386
+ size_map: dict[str, int] = {}
387
+ for part, shape in zip(input_parts, shapes, strict=True):
388
+ for label, dim in zip(part, shape, strict=True):
389
+ existing = size_map.get(label)
390
+ if existing is not None and existing != dim:
391
+ raise ValueError(
392
+ f"label '{label}' has inconsistent sizes {existing} and {dim} "
393
+ f"across operands"
394
+ )
395
+ size_map[label] = dim
396
+ return size_map
397
+
398
+
399
+ def _operand_names_from_identity_pattern(
400
+ num_ops: int,
401
+ identity_pattern: tuple[tuple[int, ...], ...] | None,
402
+ ) -> tuple[str, ...]:
403
+ """Generate operand names that respect the identity pattern.
404
+ Operands sharing the same id (per identity_pattern) get the same name."""
405
+ if identity_pattern is None:
406
+ return tuple(f"op_{i}" for i in range(num_ops))
407
+ name_of: dict[int, str] = {}
408
+ for group in identity_pattern:
409
+ shared_name = f"op_grp_{group[0]}"
410
+ for pos in group:
411
+ name_of[pos] = shared_name
412
+ return tuple(name_of.get(i, f"op_{i}") for i in range(num_ops))
413
+
414
+
415
+ def _per_op_symmetry_for_wreath(sym: _Any) -> _Any:
416
+ """Pass declared symmetry through to enumerate_h. SymmetryGroup objects
417
+ pass through unchanged; strings (like 'symmetric') also pass through."""
418
+ return sym
419
+
420
+
421
+ def _walk_path_and_aggregate(
422
+ *,
423
+ canonical_subscripts: str,
424
+ input_parts: _Seq[str],
425
+ output_subscript: str,
426
+ shapes: _Seq[_Seq[int]],
427
+ per_op_symmetries: _Seq[_Any],
428
+ identity_pattern: tuple[tuple[int, ...], ...] | None,
429
+ partition_budget: int,
430
+ dense_baseline: int,
431
+ full_expression_component_costs: tuple[ComponentCost, ...] | None = None,
432
+ ) -> AccumulationCost:
433
+ """Walk opt_einsum's binary contraction path and sum per-step costs.
434
+
435
+ Decomposes a k>=3 einsum into binary contractions via opt_einsum.contract_path.
436
+ Each binary step calls compute_accumulation_cost recursively (k=2 path),
437
+ treating intermediate tensors as dense (no symmetry carried forward).
438
+
439
+ ``full_expression_component_costs``: the ComponentCost tuple from the caller's
440
+ wreath/sigma computation of the FULL k-ary expression. Stored verbatim in
441
+ per_component so that JS-parity tests (which inspect per_component directly)
442
+ remain unaffected by the path decomposition.
443
+
444
+ Returns an AccumulationCost with per_step and path populated.
445
+ """
446
+ import opt_einsum as _oe
447
+
448
+ from flopscope._opt_einsum._contract import build_path_info
449
+
450
+ num_ops = len(input_parts)
451
+
452
+ # Build shape-only operands for opt_einsum (it only needs shape info).
453
+ # We use shapes=True to avoid materializing tensors.
454
+ import numpy as _np
455
+
456
+ dummy_operands = [_np.empty(shape) for shape in shapes]
457
+ upstream_path, upstream_info = _oe.contract_path(
458
+ canonical_subscripts,
459
+ *dummy_operands,
460
+ optimize="auto",
461
+ )
462
+
463
+ path_info = build_path_info(
464
+ upstream_path,
465
+ upstream_info,
466
+ size_dict=upstream_info.size_dict,
467
+ )
468
+
469
+ # Build SubgraphSymmetryOracle for the full expression so per-step
470
+ # binary contractions inherit their symmetry from the declared groups
471
+ # rather than treating every intermediate as dense.
472
+ from flopscope._opt_einsum._subgraph_symmetry import SubgraphSymmetryOracle
473
+
474
+ def _group_to_oracle_list(sym: _Any, subscript: str) -> list | None:
475
+ """Convert a per_op_symmetry entry to the oracle's per_op_groups format.
476
+
477
+ The oracle's Source A generator loop requires ``group._labels`` to be
478
+ set (the subscript chars the group acts on, in axis order). When the
479
+ declared SymmetryGroup has ``axes`` but ``_labels is None``, we derive
480
+ _labels from the operand subscript and the group's axes mapping.
481
+ """
482
+ from flopscope._perm_group import SymmetryGroup as _SG
483
+
484
+ if sym is None:
485
+ return None
486
+ if not isinstance(sym, _SG):
487
+ # String symmetries (e.g. 'symmetric') are not SymmetryGroup objects;
488
+ # skip them here — the oracle only consumes SymmetryGroup generators.
489
+ return None
490
+ # Ensure _labels is set so the oracle's Source A generator loop can
491
+ # map group-axis positions to subscript characters.
492
+ if sym._labels is None and sym.axes is not None:
493
+ # axes[g_pos] = tensor axis index; subscript[axis_idx] = char.
494
+ try:
495
+ derived_labels = tuple(subscript[ax] for ax in sym.axes)
496
+ except IndexError:
497
+ derived_labels = None
498
+ if derived_labels is not None:
499
+ # Build a new group with _labels set (avoid mutating the original).
500
+ import copy as _copy
501
+
502
+ labeled_group = _copy.copy(sym)
503
+ labeled_group._labels = derived_labels
504
+ return [labeled_group]
505
+ return [sym]
506
+
507
+ # Build oracle operands that mirror the original identity pattern:
508
+ # identical operands (same Python id in the original call) must share
509
+ # the same Python object here so the oracle's Source B generator (identical-
510
+ # operand swaps) fires correctly for subset queries.
511
+ oracle_operands = list(dummy_operands) # start with distinct fresh objects
512
+ if identity_pattern:
513
+ for group in identity_pattern:
514
+ # All positions in `group` referred to the same object originally.
515
+ # Reuse the dummy for the first position for all positions in the group.
516
+ canonical_obj = oracle_operands[group[0]]
517
+ for pos in group[1:]:
518
+ oracle_operands[pos] = canonical_obj
519
+
520
+ oracle = SubgraphSymmetryOracle(
521
+ operands=oracle_operands,
522
+ subscript_parts=list(input_parts),
523
+ per_op_groups=[
524
+ _group_to_oracle_list(s, sub)
525
+ for s, sub in zip(per_op_symmetries, input_parts, strict=False)
526
+ ],
527
+ output_chars=output_subscript,
528
+ )
529
+
530
+ def _subset_sym_fingerprint(subset: frozenset[int], step_subscript: str) -> tuple:
531
+ """Return an accumulation-cache fingerprint for a step-input subset.
532
+
533
+ Queries the oracle for the V-side (output) group of the subset of
534
+ original operands, then serialises it in the (axes, gens) format
535
+ expected by get_accumulation_cost_cached's sym_fingerprint.
536
+ Returns None (dense) when the oracle finds no symmetry.
537
+
538
+ Oracle groups encode generators in label-sorted space (_labels gives the
539
+ sorted char tuple and each generator acts on positions of that tuple).
540
+ The cache reconstructs a group with _labels derived from the step
541
+ subscript in axis order, so the generator would be misinterpreted unless
542
+ we convert it to tensor-axis space first.
543
+ """
544
+ from flopscope._perm_group import SymmetryGroup as _SG
545
+ from flopscope._perm_group import _PermutationCompat as _Perm
546
+
547
+ ss = oracle.sym(subset)
548
+ grp = ss.output # V-side (free-label) group for this subset
549
+ if grp is None or not isinstance(grp, _SG):
550
+ return None # type: ignore[return-value]
551
+ if not grp.generators:
552
+ return None # type: ignore[return-value]
553
+
554
+ # Convert oracle's label-sorted generators to tensor-axis-space so that
555
+ # when the cache reconstructs the group (with _labels derived from the
556
+ # step subscript in axis order), the generator is correctly interpreted.
557
+ if grp._labels is not None:
558
+ labels = grp._labels # sorted char tuple
559
+ char_to_axis = {c: i for i, c in enumerate(step_subscript)}
560
+ label_to_lpos = {lbl: k for k, lbl in enumerate(labels)}
561
+ new_gens = []
562
+ for gen in grp.generators:
563
+ arr = list(gen.array_form)
564
+ axis_gen = list(range(len(step_subscript)))
565
+ for ax, char in enumerate(step_subscript):
566
+ if char in label_to_lpos:
567
+ lpos = label_to_lpos[char]
568
+ target_char = labels[arr[lpos]]
569
+ if target_char in char_to_axis:
570
+ axis_gen[ax] = char_to_axis[target_char]
571
+ new_gens.append(_Perm(axis_gen))
572
+ axes = tuple(range(len(step_subscript)))
573
+ gens = tuple(tuple(g.array_form) for g in new_gens)
574
+ else:
575
+ axes = grp.axes if grp.axes is not None else tuple(range(grp.degree))
576
+ gens = tuple(tuple(g.array_form) for g in grp.generators)
577
+
578
+ if not gens:
579
+ return None # type: ignore[return-value]
580
+ return (axes, gens)
581
+
582
+ # SSA-to-subset: tracks which original operand positions each current
583
+ # operand covers. Starts as singletons; merged as the path progresses.
584
+ # We walk path_info.path in parallel with path_info.steps.
585
+ current_subsets: list[frozenset[int]] = [frozenset({i}) for i in range(num_ops)]
586
+
587
+ from ._cache import get_accumulation_cost_cached
588
+
589
+ per_step_costs: list[AccumulationCost] = []
590
+ pre_reductions_per_step: list[tuple[PreReduction, ...]] = []
591
+ for step_idx, step in enumerate(path_info.steps):
592
+ step_subscript = step.subscript
593
+ if "->" in step_subscript:
594
+ step_lhs, step_output = step_subscript.split("->", 1)
595
+ else:
596
+ step_lhs, step_output = step_subscript, ""
597
+ step_input_parts = step_lhs.split(",")
598
+ step_shapes = step.input_shapes
599
+
600
+ # Derive which original operands each step input covers.
601
+ # path_info.path[step_idx] gives the current-list positions to contract.
602
+ raw_path_entry = path_info.path[step_idx]
603
+ # Sort descending so that popping by index doesn't shift earlier indices.
604
+ contract_positions = tuple(sorted(raw_path_entry, reverse=True))
605
+
606
+ # Gather the input subsets matching the opt_einsum operand order in the
607
+ # step subscript. opt_einsum pops positions from highest to lowest (to
608
+ # avoid index shifting), so the subscript's left-to-right operand order
609
+ # corresponds to descending position order in the path entry.
610
+ # E.g. path entry (0,1) with subscript "jk,ij->..." means position 1
611
+ # (higher) contributes "jk" first and position 0 contributes "ij" second.
612
+ step_input_subsets = [
613
+ current_subsets[pos]
614
+ for pos in sorted(
615
+ raw_path_entry, reverse=True
616
+ ) # descending = subscript order
617
+ ]
618
+
619
+ # ── Sprint 3 (#55): per-binary-step pre-reduction of isolated summed labels ──
620
+ # When a label is summed in this step AND appears in only one input, pre-reduce
621
+ # that operand along the isolated axis BEFORE the main contraction. Mirrors
622
+ # PyTorch's sumproduct_pair.
623
+ from flopscope._accumulation._public import (
624
+ _per_op_sym_fingerprint,
625
+ reduction_accumulation_cost,
626
+ )
627
+ from flopscope._symmetry_utils import reduce_group
628
+
629
+ # Per-input symmetries for this step (pre-reduction).
630
+ # For single-original-operand inputs (no upstream contraction), the input's
631
+ # symmetry is per_op_symmetries[only_original]. Multi-original inputs
632
+ # (intermediates) have no declared per-input symmetry.
633
+ def _input_sym(subset):
634
+ if len(subset) == 1:
635
+ only = next(iter(subset))
636
+ return per_op_symmetries[only]
637
+ return None
638
+
639
+ left_sym_pre = _input_sym(step_input_subsets[0])
640
+ right_sym_pre = (
641
+ _input_sym(step_input_subsets[1]) if len(step_input_subsets) > 1 else None
642
+ )
643
+
644
+ # Detect isolated summed labels.
645
+ summed_set = set("".join(step_input_parts)) - set(step_output)
646
+ left_labels_set = (
647
+ set(step_input_parts[0]) if len(step_input_parts) > 0 else set()
648
+ )
649
+ right_labels_set = (
650
+ set(step_input_parts[1]) if len(step_input_parts) > 1 else set()
651
+ )
652
+ left_only_summed = (left_labels_set & summed_set) - right_labels_set
653
+ right_only_summed = (right_labels_set & summed_set) - left_labels_set
654
+
655
+ pre_reductions_for_step: list[PreReduction] = []
656
+ pre_reduce_cost_total = 0
657
+
658
+ # Defaults: no rewrite.
659
+ effective_left_part = step_input_parts[0]
660
+ effective_right_part = step_input_parts[1] if len(step_input_parts) > 1 else ""
661
+ effective_left_shape = step_shapes[0]
662
+ effective_right_shape = step_shapes[1] if len(step_shapes) > 1 else ()
663
+ effective_left_sym = left_sym_pre
664
+ effective_right_sym = right_sym_pre
665
+
666
+ # Pre-reduce left operand
667
+ if left_only_summed and len(step_input_parts) > 0:
668
+ reduce_axes = tuple(
669
+ i for i, c in enumerate(step_input_parts[0]) if c in left_only_summed
670
+ )
671
+ left_proxy = _build_symmetric_proxy(step_shapes[0], left_sym_pre)
672
+ reduce_cost = reduction_accumulation_cost(
673
+ left_proxy, axis=reduce_axes
674
+ ).total
675
+ pre_reduce_cost_total += reduce_cost
676
+
677
+ surviving_subscript = "".join(
678
+ c for c in step_input_parts[0] if c not in left_only_summed
679
+ )
680
+ surviving_shape = tuple(
681
+ s for i, s in enumerate(step_shapes[0]) if i not in set(reduce_axes)
682
+ )
683
+ surviving_sym = reduce_group(
684
+ left_sym_pre,
685
+ ndim=len(step_input_parts[0]),
686
+ axis=reduce_axes,
687
+ )
688
+
689
+ effective_left_part = surviving_subscript
690
+ effective_left_shape = surviving_shape
691
+ effective_left_sym = surviving_sym
692
+ pre_reductions_for_step.append(
693
+ PreReduction(
694
+ operand_index=0,
695
+ removed_labels=tuple(sorted(left_only_summed)),
696
+ cost=reduce_cost,
697
+ surviving_subscript=surviving_subscript,
698
+ reduced_symmetry_fingerprint=(
699
+ _per_op_sym_fingerprint(surviving_sym)
700
+ if surviving_sym is not None
701
+ else None
702
+ ),
703
+ )
704
+ )
705
+
706
+ # Pre-reduce right operand
707
+ if right_only_summed and len(step_input_parts) > 1:
708
+ reduce_axes = tuple(
709
+ i for i, c in enumerate(step_input_parts[1]) if c in right_only_summed
710
+ )
711
+ right_proxy = _build_symmetric_proxy(step_shapes[1], right_sym_pre)
712
+ reduce_cost = reduction_accumulation_cost(
713
+ right_proxy, axis=reduce_axes
714
+ ).total
715
+ pre_reduce_cost_total += reduce_cost
716
+
717
+ surviving_subscript = "".join(
718
+ c for c in step_input_parts[1] if c not in right_only_summed
719
+ )
720
+ surviving_shape = tuple(
721
+ s for i, s in enumerate(step_shapes[1]) if i not in set(reduce_axes)
722
+ )
723
+ surviving_sym = reduce_group(
724
+ right_sym_pre,
725
+ ndim=len(step_input_parts[1]),
726
+ axis=reduce_axes,
727
+ )
728
+
729
+ effective_right_part = surviving_subscript
730
+ effective_right_shape = surviving_shape
731
+ effective_right_sym = surviving_sym
732
+ pre_reductions_for_step.append(
733
+ PreReduction(
734
+ operand_index=1,
735
+ removed_labels=tuple(sorted(right_only_summed)),
736
+ cost=reduce_cost,
737
+ surviving_subscript=surviving_subscript,
738
+ reduced_symmetry_fingerprint=(
739
+ _per_op_sym_fingerprint(surviving_sym)
740
+ if surviving_sym is not None
741
+ else None
742
+ ),
743
+ )
744
+ )
745
+
746
+ # Override step_input_parts / step_shapes to the EFFECTIVE versions so the
747
+ # downstream existing code (per-input fingerprint + cache lookup + Cat C
748
+ # joint-Burnside + V-only fallback) sees the rewritten step.
749
+ if pre_reductions_for_step:
750
+ if len(step_input_parts) >= 2:
751
+ step_input_parts = [effective_left_part, effective_right_part]
752
+ step_shapes = [effective_left_shape, effective_right_shape]
753
+ else:
754
+ step_input_parts = [effective_left_part]
755
+ step_shapes = [effective_left_shape]
756
+
757
+ # Build per-step sym_fingerprint by querying the oracle per input subset.
758
+ # Pass the step subscript so generators can be converted from oracle
759
+ # label-sorted space to tensor-axis space matching the step subscript.
760
+ # Override per-input fingerprints when pre-reduction occurred, since the
761
+ # effective symmetry differs from what _subset_sym_fingerprint would compute
762
+ # by querying the oracle on the (unreduced) subset.
763
+ step_sym_fp_list = []
764
+ for idx, (subset, sub_part) in enumerate(
765
+ zip(step_input_subsets, step_input_parts, strict=False)
766
+ ):
767
+ if idx == 0 and left_only_summed:
768
+ step_sym_fp_list.append(
769
+ _per_op_sym_fingerprint(effective_left_sym)
770
+ if effective_left_sym is not None
771
+ else None
772
+ )
773
+ elif idx == 1 and right_only_summed:
774
+ step_sym_fp_list.append(
775
+ _per_op_sym_fingerprint(effective_right_sym)
776
+ if effective_right_sym is not None
777
+ else None
778
+ )
779
+ else:
780
+ step_sym_fp_list.append(_subset_sym_fingerprint(subset, sub_part))
781
+ step_sym_fp: tuple = tuple(step_sym_fp_list)
782
+
783
+ # Derive the step's identity_pattern from the original expression's
784
+ # identity_pattern. A step identity exists only when BOTH inputs are:
785
+ # (a) singleton subsets (original operands, not intermediates), AND
786
+ # (b) in the same identity group in the original expression.
787
+ # The wreath/sigma framework correctly handles identical operands even
788
+ # when they appear with different subscripts in the step — the
789
+ # swap generator combined with per-operand declared symmetry produces
790
+ # the correct joint group.
791
+ step_identity_pattern: tuple[tuple[int, ...], ...] | None = None
792
+ if identity_pattern:
793
+ # Map each singleton original position to its local step index.
794
+ orig_to_local: dict[int, int] = {}
795
+ for local_idx_inner, subset in enumerate(step_input_subsets):
796
+ if len(subset) == 1:
797
+ orig_to_local[next(iter(subset))] = local_idx_inner
798
+ # For each original identity group, restrict to this step's singletons.
799
+ local_groups = []
800
+ for orig_group in identity_pattern:
801
+ step_members = tuple(
802
+ orig_to_local[op] for op in orig_group if op in orig_to_local
803
+ )
804
+ if len(step_members) >= 2:
805
+ local_groups.append(step_members)
806
+ if local_groups:
807
+ step_identity_pattern = tuple(local_groups)
808
+
809
+ step_canonical = ",".join(step_input_parts) + "->" + step_output
810
+
811
+ # Route per-step binary calls through the shared LRU cache so that
812
+ # identical sub-steps across different top-level expressions hit once.
813
+ step_cost = get_accumulation_cost_cached(
814
+ canonical_subscripts=step_canonical,
815
+ input_parts=tuple(step_input_parts),
816
+ output_subscript=step_output,
817
+ shapes=tuple(tuple(s) for s in step_shapes),
818
+ sym_fingerprint=step_sym_fp,
819
+ identity_pattern=step_identity_pattern,
820
+ partition_budget=partition_budget,
821
+ )
822
+
823
+ # Sprint 4: candidate-list cost selection.
824
+ # Compute all valid Burnside-based candidates and pick the min.
825
+ #
826
+ # - Cat A (per-input wreath/sigma): always valid, baseline (step_cost).
827
+ # - Cat B (output-orbit Burnside): valid when merged_output_group is
828
+ # non-trivial; formula ``O_out · (2·W − 1)``. Previously gated to
829
+ # fire only when Cat A found no savings; now competes unconditionally.
830
+ # - Cat C (joint-Burnside on V ∪ W): valid in Regime 1 with V preserved
831
+ # setwise; via compute_step_cost_from_joint_group().
832
+ #
833
+ # The winning category is threaded into AccumulationCost.cost_source.
834
+ candidates: list[tuple[AccumulationCost, str]] = [(step_cost, "per-input")]
835
+
836
+ if oracle is not None:
837
+ merged_subset_local = frozenset().union(*step_input_subsets)
838
+ ss_merged = oracle.sym(merged_subset_local)
839
+
840
+ # Step-local size dict (used by both Cat B and Cat C).
841
+ step_size_dict: dict[str, int] = {}
842
+ for sub, shape in zip(step_input_parts, step_shapes, strict=False):
843
+ for char, sz in zip(sub, shape, strict=False):
844
+ step_size_dict[char] = sz
845
+
846
+ # --- Cat C candidate: joint group ---
847
+ joint_group = ss_merged.joint
848
+ # Sprint 3 (#55): project joint group to surviving labels when
849
+ # pre-reduction removed labels from this step's subscripts.
850
+ removed_labels_in_step = set(left_only_summed) | set(right_only_summed)
851
+ if joint_group is not None and removed_labels_in_step:
852
+ joint_labels = joint_group._labels
853
+ if joint_labels is not None:
854
+ removed_positions = {
855
+ i
856
+ for i, lbl in enumerate(joint_labels)
857
+ if lbl in removed_labels_in_step
858
+ }
859
+ if removed_positions:
860
+ stabilized = joint_group.setwise_stabilizer(removed_positions)
861
+ surviving_positions = tuple(
862
+ i
863
+ for i in range(len(joint_labels))
864
+ if i not in removed_positions
865
+ )
866
+ joint_group = stabilized.restrict(surviving_positions)
867
+ if joint_group is not None:
868
+ # restrict() may drop _labels; restore them so the
869
+ # downstream helper can match them to the step's
870
+ # effective V/W labels.
871
+ new_labels = tuple(
872
+ joint_labels[i] for i in surviving_positions
873
+ )
874
+ if joint_group._labels is None:
875
+ joint_group._labels = new_labels
876
+ if joint_group.order() <= 1:
877
+ joint_group = None
878
+ if joint_group is not None and joint_group.generators:
879
+ # V/W label tuples in canonical order (V first, then W).
880
+ v_labels_tup = tuple(step_output)
881
+ seen_w: set[str] = set()
882
+ w_labels_tup = tuple(
883
+ c
884
+ for sub in step_input_parts
885
+ for c in sub
886
+ if c not in v_labels_tup and not (c in seen_w or seen_w.add(c))
887
+ )
888
+ from flopscope._config import get_setting
889
+
890
+ joint_total = compute_step_cost_from_joint_group(
891
+ joint_group=joint_group,
892
+ v_labels=v_labels_tup,
893
+ w_labels=w_labels_tup,
894
+ sizes=step_size_dict,
895
+ num_terms=len(step_input_parts),
896
+ dimino_budget=int(get_setting("dimino_budget")), # type: ignore[arg-type]
897
+ )
898
+ if joint_total is not None:
899
+ joint_cost = AccumulationCost(
900
+ total=joint_total,
901
+ mu=step_cost.mu,
902
+ alpha=step_cost.alpha,
903
+ m_total=step_cost.m_total,
904
+ dense_baseline=step_cost.dense_baseline,
905
+ num_terms=step_cost.num_terms,
906
+ per_component=step_cost.per_component,
907
+ fallback_used=step_cost.fallback_used,
908
+ unavailable_components=step_cost.unavailable_components,
909
+ unavailable_reason=step_cost.unavailable_reason,
910
+ per_step=step_cost.per_step,
911
+ path=step_cost.path,
912
+ )
913
+ candidates.append((joint_cost, "joint-burnside"))
914
+
915
+ # --- Cat B candidate: output-orbit Burnside (UNCONDITIONAL) ---
916
+ # Previously fallback-only (fires only when Cat A == dense FMA);
917
+ # now competes with Cat A and Cat C in the candidate list.
918
+ step_all_chars = set("".join(step_input_parts))
919
+ step_dense_baseline_val = math.prod(
920
+ step_size_dict.get(c, 1) for c in step_all_chars
921
+ )
922
+ step_output_dense = math.prod(step_size_dict.get(c, 1) for c in step_output)
923
+ merged_output_grp = ss_merged.output if ss_merged else None
924
+ if merged_output_grp is not None and merged_output_grp.generators:
925
+ from flopscope._perm_group import _dimino
926
+ from flopscope._perm_group import _PermutationCompat as _Perm
927
+
928
+ labels = merged_output_grp._labels
929
+ if labels is not None:
930
+ char_to_axis = {c: i for i, c in enumerate(step_output)}
931
+ label_to_lpos = {lbl: k for k, lbl in enumerate(labels)}
932
+ axis_gens: list[_Perm] = []
933
+ for gen in merged_output_grp.generators:
934
+ arr = list(gen.array_form)
935
+ axis_gen = list(range(len(step_output)))
936
+ for ax, char in enumerate(step_output):
937
+ if char in label_to_lpos:
938
+ lpos = label_to_lpos[char]
939
+ tgt = labels[arr[lpos]]
940
+ if tgt in char_to_axis:
941
+ axis_gen[ax] = char_to_axis[tgt]
942
+ axis_gens.append(_Perm(axis_gen))
943
+ else:
944
+ axis_gens = list(merged_output_grp.generators)
945
+
946
+ try:
947
+ all_elems = _dimino(tuple(axis_gens))
948
+ output_sizes = tuple(step_size_dict.get(c, 1) for c in step_output)
949
+ corrected_orbits = size_aware_burnside(all_elems, output_sizes)
950
+ if 0 < corrected_orbits < step_output_dense:
951
+ step_w_chars = step_all_chars - set(step_output)
952
+ step_w_size = (
953
+ math.prod(step_size_dict.get(c, 1) for c in step_w_chars)
954
+ if step_w_chars
955
+ else 1
956
+ )
957
+ corrected_total = corrected_orbits * (2 * step_w_size - 1)
958
+ if corrected_total > 0:
959
+ cat_b_cost = AccumulationCost(
960
+ total=corrected_total,
961
+ mu=step_w_size * corrected_orbits - corrected_orbits,
962
+ alpha=step_w_size * corrected_orbits,
963
+ m_total=step_w_size * corrected_orbits,
964
+ dense_baseline=step_dense_baseline_val,
965
+ num_terms=step_cost.num_terms,
966
+ per_component=step_cost.per_component,
967
+ fallback_used=step_cost.fallback_used,
968
+ unavailable_components=step_cost.unavailable_components,
969
+ unavailable_reason=step_cost.unavailable_reason,
970
+ per_step=step_cost.per_step,
971
+ path=step_cost.path,
972
+ )
973
+ candidates.append((cat_b_cost, "output-burnside"))
974
+ except Exception:
975
+ pass # dimino budget exceeded or other; skip Cat B
976
+
977
+ # Pick the tightest candidate.
978
+ step_cost, cost_source_chosen = min(candidates, key=lambda x: x[0].total)
979
+
980
+ # Stamp cost_source onto the winning AccumulationCost (frozen
981
+ # dataclass → use dataclasses.replace).
982
+ step_cost = _dc_replace(step_cost, cost_source=cost_source_chosen)
983
+
984
+ # Sprint 3: step total = pre_reductions + residual contraction.
985
+ # Runs AFTER candidate selection so the chosen cost_source is preserved.
986
+ if pre_reduce_cost_total > 0:
987
+ step_cost = _dc_replace(
988
+ step_cost, total=step_cost.total + pre_reduce_cost_total
989
+ )
990
+
991
+ per_step_costs.append(step_cost)
992
+ pre_reductions_per_step.append(tuple(pre_reductions_for_step))
993
+
994
+ # Update current_subsets: remove contracted inputs (highest index first
995
+ # to preserve lower indices), then append the merged output subset.
996
+ merged_subset: frozenset[int] = frozenset().union(*step_input_subsets)
997
+ for pos in contract_positions:
998
+ current_subsets.pop(pos)
999
+ current_subsets.append(merged_subset)
1000
+
1001
+ total = sum(s.total for s in per_step_costs)
1002
+ mu_total = sum((s.mu or 0) for s in per_step_costs)
1003
+ alpha_total = sum((s.alpha or 0) for s in per_step_costs)
1004
+ # m_total for the path-level result must reflect the number of unique output
1005
+ # elements in the FULL k-ary expression, not the product of per-step intermediate
1006
+ # m_total values (which would multiply intermediate tensor sizes together and
1007
+ # always exceed dense_baseline, making _has_savings() return False).
1008
+ # full_expression_component_costs holds the full-expression per_component data
1009
+ # computed by the wreath/sigma pass before the path decomposition.
1010
+ if full_expression_component_costs:
1011
+ m_total = math.prod(c.m for c in full_expression_component_costs)
1012
+ else:
1013
+ # Fallback: sum of per-step m_total values is still wrong, but if we have
1014
+ # no component data just use dense_baseline as a conservative estimate.
1015
+ m_total = dense_baseline
1016
+ fallback_used = any(s.fallback_used for s in per_step_costs)
1017
+ unavailable_components: tuple[int, ...] = tuple(
1018
+ i for i, s in enumerate(per_step_costs) if s.fallback_used
1019
+ )
1020
+ unavailable_reason = next(
1021
+ (s.unavailable_reason for s in per_step_costs if s.unavailable_reason),
1022
+ None,
1023
+ )
1024
+
1025
+ return AccumulationCost(
1026
+ total=total,
1027
+ mu=mu_total if mu_total > 0 else None,
1028
+ alpha=alpha_total if alpha_total > 0 else None,
1029
+ m_total=m_total,
1030
+ dense_baseline=dense_baseline,
1031
+ num_terms=num_ops,
1032
+ per_component=full_expression_component_costs or (),
1033
+ fallback_used=fallback_used,
1034
+ unavailable_components=unavailable_components,
1035
+ unavailable_reason=unavailable_reason,
1036
+ per_step=tuple(per_step_costs),
1037
+ path=tuple(tuple(p) for p in path_info.path),
1038
+ pre_reductions_per_step=tuple(pre_reductions_per_step),
1039
+ )
1040
+
1041
+
1042
+ def compute_accumulation_cost(
1043
+ *,
1044
+ canonical_subscripts: str,
1045
+ input_parts: _Seq[str],
1046
+ output_subscript: str,
1047
+ shapes: _Seq[_Seq[int]],
1048
+ per_op_symmetries: _Seq[_Any] | None,
1049
+ identity_pattern: tuple[tuple[int, ...], ...] | None,
1050
+ partition_budget: int | None = None,
1051
+ ) -> AccumulationCost:
1052
+ """End-to-end whole-expression cost.
1053
+
1054
+ Inputs:
1055
+ canonical_subscripts: the einsum string after canonicalization
1056
+ (e.g. 'ij,jk->ik').
1057
+ input_parts: per-operand subscript strings.
1058
+ output_subscript: output labels (may be empty).
1059
+ shapes: per-operand shape tuples.
1060
+ per_op_symmetries: parallel to operands; SymmetryGroup objects, strings
1061
+ ('symmetric', 'cyclic', 'dihedral'), or None.
1062
+ identity_pattern: tuple of operand-position tuples that share id.
1063
+ partition_budget: per-component partition cap; defaults to global setting.
1064
+
1065
+ Dimino-budget fallback: any internal call to ``_dimino`` that exceeds the
1066
+ configured ``dimino_budget`` (default 500_000) raises
1067
+ :class:`_DiminoBudgetExceeded`. This function catches the exception,
1068
+ emits a :class:`CostFallbackWarning`, and returns an
1069
+ :class:`AccumulationCost` with ``fallback_used=True`` and
1070
+ ``total = k * dense_baseline`` (the no-symmetry direct-event count) —
1071
+ the same shape the partition-budget fallback uses.
1072
+ """
1073
+ from flopscope._config import get_setting
1074
+ from flopscope._perm_group import _DiminoBudgetExceeded
1075
+
1076
+ num_ops = len(input_parts)
1077
+ if per_op_symmetries is None:
1078
+ per_op_symmetries = (None,) * num_ops
1079
+ if partition_budget is None:
1080
+ partition_budget = _cast(int, get_setting("partition_budget"))
1081
+
1082
+ operand_names = _operand_names_from_identity_pattern(num_ops, identity_pattern)
1083
+
1084
+ graph = build_bipartite(
1085
+ subscripts=tuple(input_parts),
1086
+ output=output_subscript,
1087
+ operand_names=operand_names,
1088
+ )
1089
+ matrix_data = build_incidence_matrix(graph)
1090
+
1091
+ # Build per-operand wreath inputs.
1092
+ axis_ranks = tuple(len(part) for part in input_parts)
1093
+ u_offsets = tuple(sum(axis_ranks[:i]) for i in range(num_ops))
1094
+ grouped_ops: set[int] = set()
1095
+ for grp in graph.identical_groups:
1096
+ grouped_ops.update(grp)
1097
+ singleton_groups = [(i,) for i in range(num_ops) if i not in grouped_ops]
1098
+ identical_groups_all = (*graph.identical_groups, *singleton_groups)
1099
+
1100
+ # Pre-compute sizes / dense_baseline up-front so the bail-handler can use
1101
+ # them without redoing graph work.
1102
+ size_map = _build_size_map(input_parts, shapes)
1103
+ sizes = tuple(size_map[lbl] for lbl in graph.all_labels)
1104
+ dense_baseline = math.prod(sizes) if sizes else 1
1105
+
1106
+ try:
1107
+ wreath_elements = list(
1108
+ enumerate_wreath(
1109
+ identical_groups=identical_groups_all,
1110
+ per_op_symmetry=tuple(
1111
+ _per_op_symmetry_for_wreath(s) for s in per_op_symmetries
1112
+ ),
1113
+ axis_ranks=axis_ranks,
1114
+ u_offsets=u_offsets,
1115
+ )
1116
+ )
1117
+
1118
+ sigma_results = run_sigma_loop(graph, matrix_data, tuple(wreath_elements))
1119
+ detected = build_full_group(sigma_results, all_labels=graph.all_labels)
1120
+
1121
+ # Decompose into components.
1122
+ components = decompose_into_components(
1123
+ detected_group=detected,
1124
+ v_labels=graph.free_labels,
1125
+ w_labels=graph.summed_labels,
1126
+ sizes=sizes,
1127
+ )
1128
+
1129
+ component_costs = run_ladder_per_component(
1130
+ components,
1131
+ partition_budget=partition_budget,
1132
+ )
1133
+ except _DiminoBudgetExceeded as exc:
1134
+ fallback_total = num_ops * dense_baseline
1135
+ reason = (
1136
+ f"dimino_budget exceeded ({exc.seen_count} > {exc.budget}); "
1137
+ f"auto-inferred symmetry group is too large to enumerate exactly"
1138
+ )
1139
+ warnings.warn(
1140
+ CostFallbackWarning(
1141
+ f"accumulation: {reason} — charging dense cost {fallback_total} "
1142
+ f"= {num_ops} × {dense_baseline}. Raise via "
1143
+ f"flopscope.configure(dimino_budget=...) to attempt exact counting."
1144
+ ),
1145
+ stacklevel=4,
1146
+ )
1147
+ return AccumulationCost(
1148
+ total=fallback_total,
1149
+ mu=None,
1150
+ alpha=None,
1151
+ m_total=1,
1152
+ dense_baseline=dense_baseline,
1153
+ num_terms=num_ops,
1154
+ per_component=(),
1155
+ fallback_used=True,
1156
+ unavailable_components=(),
1157
+ unavailable_reason=reason,
1158
+ )
1159
+
1160
+ if num_ops >= 3:
1161
+ return _walk_path_and_aggregate(
1162
+ canonical_subscripts=canonical_subscripts,
1163
+ input_parts=input_parts,
1164
+ output_subscript=output_subscript,
1165
+ shapes=shapes,
1166
+ per_op_symmetries=per_op_symmetries,
1167
+ identity_pattern=identity_pattern,
1168
+ partition_budget=partition_budget,
1169
+ dense_baseline=dense_baseline,
1170
+ full_expression_component_costs=component_costs,
1171
+ )
1172
+
1173
+ # ── Sprint 3 (#55): 2-op case — pre-reduce isolated summed labels ────────
1174
+ # For binary (2-operand) einsums, detect isolated summed labels, compute
1175
+ # pre-reduction costs, then re-evaluate the RESIDUAL contraction on the
1176
+ # reduced subscripts. Total = pre_reduction_costs + residual_cost.
1177
+ if num_ops == 2:
1178
+ from flopscope._accumulation._public import (
1179
+ _per_op_sym_fingerprint,
1180
+ reduction_accumulation_cost,
1181
+ )
1182
+ from flopscope._symmetry_utils import reduce_group
1183
+
1184
+ input_parts_list = list(input_parts)
1185
+ shapes_list = [tuple(s) for s in shapes]
1186
+
1187
+ summed_set = set("".join(input_parts_list)) - set(output_subscript)
1188
+ left_labels_set = set(input_parts_list[0])
1189
+ right_labels_set = set(input_parts_list[1])
1190
+ left_only_summed = (left_labels_set & summed_set) - right_labels_set
1191
+ right_only_summed = (right_labels_set & summed_set) - left_labels_set
1192
+
1193
+ pre_reductions_for_step: list[PreReduction] = []
1194
+ pre_reduce_cost_total = 0
1195
+
1196
+ left_sym = per_op_symmetries[0] if per_op_symmetries else None
1197
+ right_sym = per_op_symmetries[1] if per_op_symmetries else None
1198
+
1199
+ # Default effective values (no-op if no pre-reduction).
1200
+ eff_left_part = input_parts_list[0]
1201
+ eff_right_part = input_parts_list[1]
1202
+ eff_left_shape = shapes_list[0]
1203
+ eff_right_shape = shapes_list[1]
1204
+ eff_left_sym = left_sym
1205
+ eff_right_sym = right_sym
1206
+
1207
+ # Pre-reduce left operand
1208
+ if left_only_summed:
1209
+ reduce_axes = tuple(
1210
+ i for i, c in enumerate(input_parts_list[0]) if c in left_only_summed
1211
+ )
1212
+ left_proxy = _build_symmetric_proxy(shapes_list[0], left_sym)
1213
+ reduce_cost = reduction_accumulation_cost(
1214
+ left_proxy, axis=reduce_axes
1215
+ ).total
1216
+ pre_reduce_cost_total += reduce_cost
1217
+
1218
+ surviving_subscript = "".join(
1219
+ c for c in input_parts_list[0] if c not in left_only_summed
1220
+ )
1221
+ surviving_shape = tuple(
1222
+ s for i, s in enumerate(shapes_list[0]) if i not in set(reduce_axes)
1223
+ )
1224
+ surviving_sym = reduce_group(
1225
+ left_sym,
1226
+ ndim=len(input_parts_list[0]),
1227
+ axis=reduce_axes,
1228
+ )
1229
+ eff_left_part = surviving_subscript
1230
+ eff_left_shape = surviving_shape
1231
+ eff_left_sym = surviving_sym
1232
+ pre_reductions_for_step.append(
1233
+ PreReduction(
1234
+ operand_index=0,
1235
+ removed_labels=tuple(sorted(left_only_summed)),
1236
+ cost=reduce_cost,
1237
+ surviving_subscript=surviving_subscript,
1238
+ reduced_symmetry_fingerprint=(
1239
+ _per_op_sym_fingerprint(surviving_sym)
1240
+ if surviving_sym is not None
1241
+ else None
1242
+ ),
1243
+ )
1244
+ )
1245
+
1246
+ # Pre-reduce right operand
1247
+ if right_only_summed:
1248
+ reduce_axes = tuple(
1249
+ i for i, c in enumerate(input_parts_list[1]) if c in right_only_summed
1250
+ )
1251
+ right_proxy = _build_symmetric_proxy(shapes_list[1], right_sym)
1252
+ reduce_cost = reduction_accumulation_cost(
1253
+ right_proxy, axis=reduce_axes
1254
+ ).total
1255
+ pre_reduce_cost_total += reduce_cost
1256
+
1257
+ surviving_subscript = "".join(
1258
+ c for c in input_parts_list[1] if c not in right_only_summed
1259
+ )
1260
+ surviving_shape = tuple(
1261
+ s for i, s in enumerate(shapes_list[1]) if i not in set(reduce_axes)
1262
+ )
1263
+ surviving_sym = reduce_group(
1264
+ right_sym,
1265
+ ndim=len(input_parts_list[1]),
1266
+ axis=reduce_axes,
1267
+ )
1268
+ eff_right_part = surviving_subscript
1269
+ eff_right_shape = surviving_shape
1270
+ eff_right_sym = surviving_sym
1271
+ pre_reductions_for_step.append(
1272
+ PreReduction(
1273
+ operand_index=1,
1274
+ removed_labels=tuple(sorted(right_only_summed)),
1275
+ cost=reduce_cost,
1276
+ surviving_subscript=surviving_subscript,
1277
+ reduced_symmetry_fingerprint=(
1278
+ _per_op_sym_fingerprint(surviving_sym)
1279
+ if surviving_sym is not None
1280
+ else None
1281
+ ),
1282
+ )
1283
+ )
1284
+
1285
+ if pre_reductions_for_step:
1286
+ # Compute residual cost on the REDUCED subscripts/shapes/symmetries.
1287
+ from ._cache import get_accumulation_cost_cached
1288
+
1289
+ eff_canonical = f"{eff_left_part},{eff_right_part}->{output_subscript}"
1290
+ eff_sym_fp = (
1291
+ _per_op_sym_fingerprint(eff_left_sym)
1292
+ if eff_left_sym is not None
1293
+ else None,
1294
+ _per_op_sym_fingerprint(eff_right_sym)
1295
+ if eff_right_sym is not None
1296
+ else None,
1297
+ )
1298
+ residual_cost = get_accumulation_cost_cached(
1299
+ canonical_subscripts=eff_canonical,
1300
+ input_parts=(eff_left_part, eff_right_part),
1301
+ output_subscript=output_subscript,
1302
+ shapes=(eff_left_shape, eff_right_shape),
1303
+ sym_fingerprint=eff_sym_fp,
1304
+ identity_pattern=identity_pattern,
1305
+ partition_budget=partition_budget,
1306
+ )
1307
+ return AccumulationCost(
1308
+ total=residual_cost.total + pre_reduce_cost_total,
1309
+ mu=residual_cost.mu,
1310
+ alpha=residual_cost.alpha,
1311
+ m_total=residual_cost.m_total,
1312
+ dense_baseline=dense_baseline,
1313
+ num_terms=residual_cost.num_terms,
1314
+ per_component=component_costs,
1315
+ fallback_used=residual_cost.fallback_used,
1316
+ unavailable_components=residual_cost.unavailable_components,
1317
+ unavailable_reason=residual_cost.unavailable_reason,
1318
+ per_step=residual_cost.per_step,
1319
+ path=residual_cost.path,
1320
+ pre_reductions_per_step=(tuple(pre_reductions_for_step),),
1321
+ )
1322
+
1323
+ return aggregate_einsum(
1324
+ component_costs=component_costs,
1325
+ num_terms=num_ops,
1326
+ dense_baseline=dense_baseline,
1327
+ )
1328
+
1329
+
1330
+ # ── Reduction-cost API hook (designed for, not implemented) ──────────
1331
+
1332
+
1333
+ def aggregate_reduction(
1334
+ component_costs: Sequence[ComponentCost],
1335
+ *,
1336
+ op_factor: int = 1,
1337
+ dense_baseline: int,
1338
+ output_dense: int,
1339
+ extra_ops: int = 0,
1340
+ ) -> AccumulationCost:
1341
+ """Aggregate per-component costs into a ufunc.reduce cost.
1342
+
1343
+ Formula:
1344
+ total = op_factor × (∏α_c - output_dense) + extra_ops
1345
+
1346
+ ``output_dense`` is the **orbit-aware** output count: the orchestrator
1347
+ passes ``_num_output_orbits(input_shape, axes_summed, symmetry)`` through
1348
+ this slot. For dense inputs this equals ``prod(output_shape)`` so the
1349
+ name is consistent with the locked-stub docstring.
1350
+
1351
+ Fallback (any component unavailable):
1352
+ total = output_dense × (input_axis_size - 1) × op_factor + extra_ops
1353
+ where input_axis_size = dense_baseline / output_dense.
1354
+
1355
+ See .aicrowd/superpowers/specs/2026-05-13-symmetry-aware-reduction-cost-design.md.
1356
+ """
1357
+ failing = [i for i, c in enumerate(component_costs) if c.alpha is None]
1358
+
1359
+ m_total = 1
1360
+ for c in component_costs:
1361
+ m_total *= c.m
1362
+
1363
+ if failing:
1364
+ # Floor-division is safe: the orchestrator passes
1365
+ # dense_baseline = prod(input_shape) and output_dense = num_output_orbits,
1366
+ # which evenly divides for symmetry=None and represents the orbit-discounted
1367
+ # input-axis size for symmetric inputs.
1368
+ input_axis_size = dense_baseline // output_dense if output_dense else 0
1369
+ fallback_total = (
1370
+ output_dense * max(0, input_axis_size - 1) * op_factor + extra_ops
1371
+ )
1372
+ first = component_costs[failing[0]]
1373
+ reason = first.unavailable_reason or "partition_budget exceeded"
1374
+ labels = ", ".join(first.labels)
1375
+ warnings.warn(
1376
+ CostFallbackWarning(
1377
+ f"reduction: component {list(failing)} ({labels}) returned "
1378
+ f"unavailable — charging dense cost {fallback_total}. "
1379
+ f"Failing reason: {reason}."
1380
+ ),
1381
+ stacklevel=4,
1382
+ )
1383
+ return AccumulationCost(
1384
+ total=fallback_total,
1385
+ mu=None,
1386
+ alpha=None,
1387
+ m_total=m_total,
1388
+ dense_baseline=dense_baseline,
1389
+ num_terms=1,
1390
+ per_component=tuple(component_costs),
1391
+ fallback_used=True,
1392
+ unavailable_components=tuple(failing),
1393
+ unavailable_reason=reason,
1394
+ )
1395
+
1396
+ alpha_product = 1
1397
+ for c in component_costs:
1398
+ assert c.alpha is not None # for type narrowing
1399
+ alpha_product *= c.alpha
1400
+
1401
+ # Invariant: output_dense (num_output_orbits) <= alpha_product. The
1402
+ # orchestrator computes both consistently. If you ever see a negative
1403
+ # total here, the orchestrator passed an inconsistent output_dense.
1404
+ total = op_factor * (alpha_product - output_dense) + extra_ops
1405
+ return AccumulationCost(
1406
+ total=total,
1407
+ mu=0, # k=1 single-operand reduction
1408
+ alpha=alpha_product,
1409
+ m_total=m_total,
1410
+ dense_baseline=dense_baseline,
1411
+ num_terms=1,
1412
+ per_component=tuple(component_costs),
1413
+ fallback_used=False,
1414
+ )