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,310 @@
1
+ """Reduction-cost orchestrator + helpers.
2
+
3
+ Implements the two-tier reduction cost model:
4
+ - Tier 1 (ufunc.reduce): orbit-mapping α via compute_accumulation_cost
5
+ - Tier 2 (non-ufunc): dense × (num_output_orbits / num_output_elems)
6
+
7
+ See .aicrowd/superpowers/specs/2026-05-13-symmetry-aware-reduction-cost-design.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from flopscope._perm_group import SymmetryGroup
17
+
18
+
19
+ def _normalize_axis(
20
+ axis: int | tuple[int, ...] | list[int] | None,
21
+ ndim: int,
22
+ ) -> tuple[int, ...]:
23
+ """Normalize an axis specifier to a sorted tuple of non-negative ints.
24
+
25
+ - None → all axes (full reduction)
26
+ - int → singleton, with negative-index wrapping
27
+ - tuple/list → sorted unique, with negative-index wrapping
28
+ """
29
+ if axis is None or ndim == 0:
30
+ # 0-d input has no reducible axes. Numpy accepts ``axis=0`` on 0-d
31
+ # arrays as a no-op (matching ``ufunc.reduce``'s default) and raises
32
+ # for any other concrete axis before this cost path is reached, so
33
+ # we short-circuit instead of evaluating ``axis % 0``.
34
+ return tuple(range(ndim))
35
+ if isinstance(axis, int):
36
+ return (axis % ndim,)
37
+ return tuple(sorted({a % ndim for a in axis}))
38
+
39
+
40
+ def _num_output_orbits(
41
+ input_shape: tuple[int, ...],
42
+ axes_summed: tuple[int, ...],
43
+ symmetry: SymmetryGroup | None,
44
+ ) -> int:
45
+ """Count orbits of the output multi-index under the output stabilizer.
46
+
47
+ Output shape = input_shape with axes_summed removed.
48
+ Output stabilizer = reduce_group(symmetry, ndim=ndim, axis=axes_summed).
49
+ Returns the size-aware Burnside orbit count on the output shape.
50
+ """
51
+ ndim = len(input_shape)
52
+ axes_set = frozenset(axes_summed)
53
+ output_shape = tuple(input_shape[i] for i in range(ndim) if i not in axes_set)
54
+
55
+ if not output_shape:
56
+ return 1
57
+
58
+ if symmetry is None:
59
+ return math.prod(output_shape)
60
+
61
+ from flopscope._symmetry_utils import reduce_group
62
+
63
+ from ._burnside import size_aware_burnside
64
+
65
+ output_group = reduce_group(
66
+ symmetry,
67
+ ndim=ndim,
68
+ axis=axes_summed,
69
+ keepdims=False,
70
+ )
71
+
72
+ if output_group is None:
73
+ return math.prod(output_shape)
74
+
75
+ # The group's permutation indices are LOCAL (0..degree-1).
76
+ # Map them to the axes the group actually operates on in output_shape,
77
+ # then extract only those sizes so Burnside sees the right array shape.
78
+ group_output_axes = (
79
+ output_group.axes
80
+ if output_group.axes is not None
81
+ else tuple(range(output_group.degree))
82
+ )
83
+ group_sizes = tuple(output_shape[ax] for ax in group_output_axes)
84
+
85
+ elements = output_group.elements()
86
+ orbits_on_group_dims = size_aware_burnside(elements, group_sizes)
87
+
88
+ # Remaining output dims (not in the group) contribute independently.
89
+ group_axes_set = frozenset(group_output_axes)
90
+ remaining = math.prod(
91
+ output_shape[i] for i in range(len(output_shape)) if i not in group_axes_set
92
+ )
93
+ return orbits_on_group_dims * max(remaining, 1)
94
+
95
+
96
+ def output_discounted_reduction_cost(
97
+ input_shape: tuple[int, ...],
98
+ axes_summed: tuple[int, ...],
99
+ symmetry: SymmetryGroup | None,
100
+ dense_per_output_cost: int,
101
+ ) -> int:
102
+ """Tier-2 reduction cost: num_output_orbits × dense_per_output_cost.
103
+
104
+ For non-ufunc reductions (median, percentile, quantile) where we can't
105
+ decompose the operation internally. The discount uses only the output
106
+ stabilizer; input symmetry doesn't help unless the operation is
107
+ decomposable.
108
+
109
+ Equivalent to: dense_total × (num_output_orbits / num_output_elems).
110
+ """
111
+ num_orbits = _num_output_orbits(input_shape, axes_summed, symmetry)
112
+ return num_orbits * dense_per_output_cost
113
+
114
+
115
+ def compute_reduction_accumulation_cost(
116
+ input_shape: tuple[int, ...],
117
+ axes_summed: tuple[int, ...],
118
+ symmetry: SymmetryGroup | None = None,
119
+ *,
120
+ op_factor: int = 1,
121
+ extra_ops: int = 0,
122
+ partition_budget: int | None = None,
123
+ ):
124
+ """Tier-1 reduction cost: orbit-mapping α via the existing ladder.
125
+
126
+ Constructs a single-operand 'fake einsum' (input subscripts = full label
127
+ set, output subscripts = labels not in axes_summed), runs the existing
128
+ component decomposition + ladder, then aggregates via aggregate_reduction.
129
+
130
+ Off-by-one fix (#56): cost = op_factor × (α - num_output_orbits) + extra_ops.
131
+
132
+ Parameters
133
+ ----------
134
+ input_shape : tuple of int
135
+ Shape of the input array.
136
+ axes_summed : tuple of int
137
+ Axes to reduce. Must be non-negative, sorted, unique.
138
+ Use _normalize_axis to prepare.
139
+ symmetry : SymmetryGroup, optional
140
+ Pointwise symmetry of the input. None for dense inputs.
141
+ op_factor : int, default 1
142
+ FLOPs per binary accumulation event.
143
+ extra_ops : int, default 0
144
+ Output-side dense additions (e.g. num_output_orbits for mean's divide).
145
+ partition_budget : int, optional
146
+ Override the partition counter budget. Defaults to global setting.
147
+
148
+ Returns
149
+ -------
150
+ AccumulationCost
151
+ Same shape as einsum_accumulation_cost — fields total/mu/alpha/m_total/...
152
+ """
153
+ from ._cost import aggregate_reduction, compute_accumulation_cost
154
+
155
+ ndim = len(input_shape)
156
+ if not axes_summed:
157
+ # No reduction at all — cost is 0 (just identity).
158
+ return _trivial_zero_cost(input_shape, op_factor, extra_ops)
159
+
160
+ # Build a fake einsum: 'abc...' input, output is labels of kept axes.
161
+ if ndim > 26:
162
+ # Fall back to dense — labelers can't go beyond a-z cleanly. Unusual.
163
+ return _dense_fallback_cost(
164
+ input_shape,
165
+ axes_summed,
166
+ symmetry,
167
+ op_factor,
168
+ extra_ops,
169
+ )
170
+
171
+ axes_set = frozenset(axes_summed)
172
+ labels = [chr(ord("a") + i) for i in range(ndim)]
173
+ input_subs = "".join(labels)
174
+ output_subs = "".join(labels[i] for i in range(ndim) if i not in axes_set)
175
+ canonical_subscripts = f"{input_subs}->{output_subs}"
176
+
177
+ # Single operand, per-op symmetry = the input symmetry.
178
+ per_op_symmetries = (symmetry,)
179
+
180
+ from flopscope._perm_group import _DiminoBudgetExceeded
181
+
182
+ try:
183
+ einsum_cost = compute_accumulation_cost(
184
+ canonical_subscripts=canonical_subscripts,
185
+ input_parts=(input_subs,),
186
+ output_subscript=output_subs,
187
+ shapes=(input_shape,),
188
+ per_op_symmetries=per_op_symmetries,
189
+ identity_pattern=None, # single operand, no identity grouping
190
+ partition_budget=partition_budget,
191
+ )
192
+
193
+ # `output_dense` carries num_output_orbits — orbit-aware, computed
194
+ # directly via _num_output_orbits. For dense inputs this equals
195
+ # prod(output_shape) so the locked-stub docstring's intent is preserved.
196
+ num_output_orbits = _num_output_orbits(
197
+ input_shape=input_shape,
198
+ axes_summed=axes_summed,
199
+ symmetry=symmetry,
200
+ )
201
+ except _DiminoBudgetExceeded as exc:
202
+ # _num_output_orbits' element enumeration can also exceed the budget
203
+ # on an oversized output stabilizer. Charge dense and warn.
204
+ # (compute_accumulation_cost emits its own CostFallbackWarning when it
205
+ # bails before this; if it succeeded but _num_output_orbits bailed,
206
+ # this is the only warning the caller sees.)
207
+ import warnings as _warnings
208
+
209
+ from flopscope.errors import CostFallbackWarning
210
+
211
+ _warnings.warn(
212
+ CostFallbackWarning(
213
+ f"reduction: dimino_budget exceeded ({exc.seen_count} > {exc.budget}) "
214
+ f"while computing output orbit count — charging dense cost. "
215
+ f"Raise via flopscope.configure(dimino_budget=...) to attempt "
216
+ f"exact counting."
217
+ ),
218
+ stacklevel=3,
219
+ )
220
+ return _dense_fallback_cost(
221
+ input_shape, axes_summed, symmetry, op_factor, extra_ops
222
+ )
223
+ dense_baseline = math.prod(input_shape) if input_shape else 1
224
+
225
+ # If compute_accumulation_cost already bailed (e.g. dimino_budget exceeded
226
+ # on an auto-inferred S_n), it returns an AccumulationCost with
227
+ # fallback_used=True and an empty per_component tuple. aggregate_reduction
228
+ # would then mis-interpret the empty tuple as "no failures"; re-shape the
229
+ # cost under the reduction-fallback formula instead.
230
+ if einsum_cost.fallback_used and not einsum_cost.per_component:
231
+ from ._cost import AccumulationCost
232
+
233
+ input_axis_size = (
234
+ dense_baseline // num_output_orbits if num_output_orbits else 0
235
+ )
236
+ fallback_total = (
237
+ num_output_orbits * max(0, input_axis_size - 1) * op_factor + extra_ops
238
+ )
239
+ return AccumulationCost(
240
+ total=fallback_total,
241
+ mu=None,
242
+ alpha=None,
243
+ m_total=1,
244
+ dense_baseline=dense_baseline,
245
+ num_terms=1,
246
+ per_component=(),
247
+ fallback_used=True,
248
+ unavailable_components=(),
249
+ unavailable_reason=einsum_cost.unavailable_reason,
250
+ )
251
+
252
+ # Re-aggregate the per-component costs under the reduction formula.
253
+ return aggregate_reduction(
254
+ einsum_cost.per_component,
255
+ op_factor=op_factor,
256
+ dense_baseline=dense_baseline,
257
+ output_dense=num_output_orbits,
258
+ extra_ops=extra_ops,
259
+ )
260
+
261
+
262
+ def _trivial_zero_cost(
263
+ input_shape: tuple[int, ...],
264
+ op_factor: int,
265
+ extra_ops: int,
266
+ ):
267
+ """Cost of a 'no axes reduced' degenerate case."""
268
+ from ._cost import AccumulationCost
269
+
270
+ return AccumulationCost(
271
+ total=extra_ops,
272
+ mu=0,
273
+ alpha=0,
274
+ m_total=1,
275
+ dense_baseline=math.prod(input_shape) if input_shape else 1,
276
+ num_terms=1,
277
+ per_component=(),
278
+ fallback_used=False,
279
+ )
280
+
281
+
282
+ def _dense_fallback_cost(
283
+ input_shape: tuple[int, ...],
284
+ axes_summed: tuple[int, ...],
285
+ symmetry: SymmetryGroup | None,
286
+ op_factor: int,
287
+ extra_ops: int,
288
+ ):
289
+ """ndim > 26 fallback: charge dense without symmetry."""
290
+ from ._cost import AccumulationCost
291
+
292
+ axes_set = frozenset(axes_summed)
293
+ dense = math.prod(input_shape) if input_shape else 1
294
+ output_dense = (
295
+ math.prod(input_shape[i] for i in range(len(input_shape)) if i not in axes_set)
296
+ if any(i not in axes_set for i in range(len(input_shape)))
297
+ else 1
298
+ )
299
+ input_axis_size = dense // output_dense if output_dense else 0
300
+ total = output_dense * max(0, input_axis_size - 1) * op_factor + extra_ops
301
+ return AccumulationCost(
302
+ total=total,
303
+ mu=None,
304
+ alpha=None,
305
+ m_total=1,
306
+ dense_baseline=dense,
307
+ num_terms=1,
308
+ per_component=(),
309
+ fallback_used=True,
310
+ )
@@ -0,0 +1,303 @@
1
+ """The four mixed-shape accumulation regimes plus functionalProjection.
2
+
3
+ Each regime is a Regime instance with `recognize(ctx) -> Verdict` and
4
+ `compute(ctx) -> RegimeOutput`. The dispatcher in _ladder.py iterates them
5
+ in priority order; functionalProjection is checked first (Stage 2a) and the
6
+ remaining three form the mixed-shape ladder (Stage 2b).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ._burnside import size_aware_burnside
12
+ from ._ladder import Regime, RegimeContext, RegimeOutput, Verdict
13
+ from ._output_orbit import projection_is_functional
14
+
15
+ # ── functionalProjection ─────────────────────────────────────────────
16
+
17
+
18
+ def _functional_projection_recognize(ctx: RegimeContext) -> Verdict:
19
+ if projection_is_functional(ctx.elements, ctx.visible_positions):
20
+ return Verdict(
21
+ fired=True,
22
+ reason="each product orbit reaches exactly one stored output representative",
23
+ )
24
+ return Verdict(
25
+ fired=False,
26
+ reason="some pointwise symmetry moves an output label into a summed label",
27
+ )
28
+
29
+
30
+ def _functional_projection_compute(ctx: RegimeContext) -> RegimeOutput:
31
+ count = size_aware_burnside(ctx.elements, ctx.sizes)
32
+ return RegimeOutput(
33
+ count=count,
34
+ sub_steps=(
35
+ {
36
+ "step": "projection-functional",
37
+ "reason": "G preserves V setwise; projection descends to output reps",
38
+ "count": count,
39
+ },
40
+ ),
41
+ )
42
+
43
+
44
+ FUNCTIONAL_PROJECTION_REGIME: Regime = Regime(
45
+ id="functionalProjection",
46
+ recognize=_functional_projection_recognize,
47
+ compute=_functional_projection_compute,
48
+ )
49
+
50
+
51
+ # ── singleton (B.5: |V| = 1) ─────────────────────────────────────────
52
+
53
+
54
+ def _label_orbit(elements, label_idx: int) -> list[int]:
55
+ """G-orbit of a single label position."""
56
+ seen = {label_idx}
57
+ changed = True
58
+ while changed:
59
+ changed = False
60
+ for g in elements:
61
+ for p in list(seen):
62
+ q = g.array_form[p]
63
+ if q not in seen:
64
+ seen.add(q)
65
+ changed = True
66
+ return sorted(seen)
67
+
68
+
69
+ def _cycles_on_subset(perm, subset: list[int]) -> int:
70
+ """Count cycles of perm restricted to a perm-invariant subset."""
71
+ subset_set = set(subset)
72
+ seen: set[int] = set()
73
+ cycles = 0
74
+ for start in subset:
75
+ if start in seen:
76
+ continue
77
+ cycles += 1
78
+ cur = start
79
+ while cur not in seen:
80
+ if cur not in subset_set:
81
+ raise ValueError("subset not invariant under perm")
82
+ seen.add(cur)
83
+ cur = perm.array_form[cur]
84
+ return cycles
85
+
86
+
87
+ def _subset_cycle_product(perm, subset: list[int], sizes) -> int:
88
+ """∏ n_c over cycles of perm in `subset`. Each cycle's labels must share a size."""
89
+ subset_set = set(subset)
90
+ seen: set[int] = set()
91
+ product = 1
92
+ for start in subset:
93
+ if start in seen:
94
+ continue
95
+ cycle: list[int] = []
96
+ cur = start
97
+ while cur not in seen:
98
+ if cur not in subset_set:
99
+ raise ValueError("subset not invariant under perm")
100
+ seen.add(cur)
101
+ cycle.append(cur)
102
+ cur = perm.array_form[cur]
103
+ n0 = sizes[cycle[0]]
104
+ for i in cycle:
105
+ if sizes[i] != n0:
106
+ raise ValueError("singleton: cycle in R has mixed sizes")
107
+ product *= n0
108
+ return product
109
+
110
+
111
+ def _singleton_recognize(ctx: RegimeContext) -> Verdict:
112
+ if len(ctx.va) == 1:
113
+ return Verdict(fired=True, reason="|V| = 1")
114
+ return Verdict(fired=False, reason=f"|V| = {len(ctx.va)}, not 1")
115
+
116
+
117
+ def _singleton_compute(ctx: RegimeContext) -> RegimeOutput:
118
+ v_pos = ctx.visible_positions[0]
119
+ omega = _label_orbit(ctx.elements, v_pos)
120
+ n_omega = ctx.sizes[v_pos]
121
+ for idx in omega:
122
+ if ctx.sizes[idx] != n_omega:
123
+ raise ValueError(
124
+ f"singleton: orbit of label has mixed sizes at {ctx.labels[idx]}"
125
+ )
126
+ omega_set = set(omega)
127
+ rest = [i for i in range(len(ctx.labels)) if i not in omega_set]
128
+
129
+ total = 0
130
+ for g in ctx.elements:
131
+ rest_factor = _subset_cycle_product(g, rest, ctx.sizes)
132
+ c_omega = _cycles_on_subset(g, omega)
133
+ total += rest_factor * (n_omega**c_omega - (n_omega - 1) ** c_omega)
134
+ count = (n_omega * total) // len(ctx.elements)
135
+ return RegimeOutput(count=count, sub_steps=())
136
+
137
+
138
+ SINGLETON_REGIME: Regime = Regime(
139
+ id="singleton",
140
+ recognize=_singleton_recognize,
141
+ compute=_singleton_compute,
142
+ )
143
+
144
+
145
+ # ── young (B.6: G = Sym(L), uniform sizes, |V| ≥ 2) ──────────────────
146
+
147
+
148
+ import math
149
+
150
+
151
+ def _multiset_count(n: int, k: int) -> int:
152
+ """Number of size-k multisets from [n]. C(n + k - 1, k)."""
153
+ if k == 0:
154
+ return 1
155
+ num = 1
156
+ den = 1
157
+ for i in range(k):
158
+ num *= n + k - 1 - i
159
+ den *= i + 1
160
+ return num // den
161
+
162
+
163
+ def _young_recognize(ctx: RegimeContext) -> Verdict:
164
+ if not ctx.elements or len(ctx.elements) <= 1:
165
+ return Verdict(fired=False, reason="|G| <= 1")
166
+ if len(ctx.va) < 2:
167
+ return Verdict(fired=False, reason="|V| < 2; singleton handles this")
168
+
169
+ expected_full_sym = math.factorial(len(ctx.labels))
170
+ if len(ctx.elements) != expected_full_sym:
171
+ return Verdict(
172
+ fired=False,
173
+ reason=f"|G|={len(ctx.elements)} != |L|!={expected_full_sym}",
174
+ )
175
+
176
+ label_to_idx = {lbl: i for i, lbl in enumerate(ctx.labels)}
177
+ v_idx_set = {label_to_idx[lbl] for lbl in ctx.va}
178
+ has_cross = any(
179
+ any(g.array_form[label_to_idx[lbl]] not in v_idx_set for lbl in ctx.va)
180
+ for g in ctx.elements
181
+ )
182
+ if not has_cross:
183
+ return Verdict(fired=False, reason="no cross-V/W element")
184
+
185
+ if not ctx.sizes:
186
+ return Verdict(fired=False, reason="no sizes provided")
187
+
188
+ n_l = ctx.sizes[0]
189
+ if any(s != n_l for s in ctx.sizes):
190
+ return Verdict(fired=False, reason="mixed label sizes")
191
+
192
+ return Verdict(fired=True, reason="G = Sym(L); Young equation applies")
193
+
194
+
195
+ def _young_compute(ctx: RegimeContext) -> RegimeOutput:
196
+ n_l = ctx.sizes[0]
197
+ visible_multisets = _multiset_count(n_l, len(ctx.va))
198
+ summed_multisets = _multiset_count(n_l, len(ctx.wa))
199
+ count = visible_multisets * summed_multisets
200
+ return RegimeOutput(
201
+ count=count,
202
+ sub_steps=(
203
+ {
204
+ "step": "full-symmetric-output-orbit-formula",
205
+ "n": n_l,
206
+ "v_count": len(ctx.va),
207
+ "w_count": len(ctx.wa),
208
+ "visible_multisets": visible_multisets,
209
+ "summed_multisets": summed_multisets,
210
+ "count": count,
211
+ },
212
+ ),
213
+ )
214
+
215
+
216
+ YOUNG_REGIME: Regime = Regime(
217
+ id="young",
218
+ recognize=_young_recognize,
219
+ compute=_young_compute,
220
+ )
221
+
222
+
223
+ # ── partitionCount (B.7: typed equality-pattern enumeration) ─────────
224
+
225
+
226
+ from ._output_orbit import restrict_stabilizer_to_positions
227
+ from ._partition import (
228
+ count_map_orbits_under_h,
229
+ generate_typed_set_partitions,
230
+ induced_block_action_size,
231
+ induced_prefix_maps,
232
+ num_blocks,
233
+ partition_key,
234
+ partition_orbit_reps,
235
+ typed_labeling_count,
236
+ )
237
+
238
+
239
+ def _partition_count_recognize(ctx: RegimeContext) -> Verdict:
240
+ partitions = generate_typed_set_partitions(ctx.sizes)
241
+ if len(partitions) > ctx.partition_budget:
242
+ return Verdict(
243
+ fired=False,
244
+ reason=(
245
+ f"typed partition count {len(partitions)} exceeds "
246
+ f"budget {ctx.partition_budget}"
247
+ ),
248
+ )
249
+ return Verdict(
250
+ fired=True,
251
+ reason=f"typed partition count over {len(partitions)} equality patterns",
252
+ )
253
+
254
+
255
+ def _partition_count_compute(ctx: RegimeContext) -> RegimeOutput:
256
+ h_elements = restrict_stabilizer_to_positions(ctx.elements, ctx.visible_positions)
257
+ partitions = generate_typed_set_partitions(ctx.sizes)
258
+ reps = partition_orbit_reps(partitions, ctx.elements)
259
+
260
+ total = 0
261
+ sub_steps: list[dict] = []
262
+ for partition in reps:
263
+ labelings = typed_labeling_count(partition, ctx.sizes)
264
+ block_action = induced_block_action_size(partition, ctx.elements)
265
+ if labelings % block_action != 0:
266
+ raise ValueError(
267
+ f"partition {partition_key(partition)} has labelings={labelings} "
268
+ f"not divisible by block action size={block_action} — "
269
+ f"invariant violation in G"
270
+ )
271
+ input_orbits = labelings // block_action
272
+ maps = induced_prefix_maps(partition, ctx.elements, ctx.visible_positions)
273
+ output_orbits = count_map_orbits_under_h(maps, h_elements)
274
+ term = input_orbits * output_orbits
275
+ total += term
276
+ sub_steps.append(
277
+ {
278
+ "partition_key": partition_key(partition),
279
+ "blocks": num_blocks(partition),
280
+ "typed_labelings": labelings,
281
+ "block_action_size": block_action,
282
+ "input_orbit_count": input_orbits,
283
+ "induced_map_count": len(maps),
284
+ "output_orbit_count": output_orbits,
285
+ "contribution": term,
286
+ }
287
+ )
288
+
289
+ return RegimeOutput(count=total, sub_steps=tuple(sub_steps))
290
+
291
+
292
+ PARTITION_COUNT_REGIME: Regime = Regime(
293
+ id="partitionCount",
294
+ recognize=_partition_count_recognize,
295
+ compute=_partition_count_compute,
296
+ )
297
+
298
+
299
+ MIXED_REGIMES: tuple[Regime, ...] = (
300
+ SINGLETON_REGIME,
301
+ YOUNG_REGIME,
302
+ PARTITION_COUNT_REGIME,
303
+ )
@@ -0,0 +1,33 @@
1
+ """Shape classifier — Stage 1 of the cost ladder.
2
+
3
+ The shape classifier is structural and runs before the regime ladder. It produces
4
+ a label that goes onto the per-component output for diagnostic display alongside
5
+ the regime ID. (regime_id is computational; shape is structural.)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from typing import Literal
12
+
13
+ from flopscope._perm_group import _Permutation as Permutation
14
+
15
+ Shape = Literal["trivial", "allVisible", "allSummed", "mixed"]
16
+
17
+ SHAPES: tuple[Shape, ...] = ("trivial", "allVisible", "allSummed", "mixed")
18
+
19
+
20
+ def detect_shape(
21
+ *,
22
+ va: Sequence[str],
23
+ wa: Sequence[str],
24
+ elements: Sequence[Permutation],
25
+ ) -> Shape:
26
+ """Classify a component's structural shape from its V/W partition and group size."""
27
+ if not elements or len(elements) <= 1:
28
+ return "trivial"
29
+ if len(wa) == 0:
30
+ return "allVisible"
31
+ if len(va) == 0:
32
+ return "allSummed"
33
+ return "mixed"