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.
- benchmarks/__init__.py +1 -0
- benchmarks/__main__.py +6 -0
- benchmarks/_baseline.py +171 -0
- benchmarks/_bitwise.py +231 -0
- benchmarks/_complex.py +176 -0
- benchmarks/_contractions.py +291 -0
- benchmarks/_fft.py +198 -0
- benchmarks/_impl_urls.py +139 -0
- benchmarks/_linalg.py +197 -0
- benchmarks/_linalg_delegates.py +407 -0
- benchmarks/_metadata.py +141 -0
- benchmarks/_misc.py +653 -0
- benchmarks/_perf.py +321 -0
- benchmarks/_perm_group_calibration.py +175 -0
- benchmarks/_pointwise.py +372 -0
- benchmarks/_polynomial.py +193 -0
- benchmarks/_random.py +209 -0
- benchmarks/_reductions.py +136 -0
- benchmarks/_sorting.py +289 -0
- benchmarks/_stats.py +137 -0
- benchmarks/_window.py +92 -0
- benchmarks/accumulation/__init__.py +0 -0
- benchmarks/accumulation/bench_cost_compute.py +138 -0
- benchmarks/dashboard.py +312 -0
- benchmarks/runner.py +636 -0
- flopscope/__init__.py +273 -0
- flopscope/_accumulation/__init__.py +13 -0
- flopscope/_accumulation/_bipartite.py +121 -0
- flopscope/_accumulation/_burnside.py +51 -0
- flopscope/_accumulation/_cache.py +146 -0
- flopscope/_accumulation/_components.py +153 -0
- flopscope/_accumulation/_cost.py +1414 -0
- flopscope/_accumulation/_cost_descriptions.py +63 -0
- flopscope/_accumulation/_detection.py +318 -0
- flopscope/_accumulation/_ladder.py +191 -0
- flopscope/_accumulation/_output_orbit.py +104 -0
- flopscope/_accumulation/_partition.py +290 -0
- flopscope/_accumulation/_path_info.py +211 -0
- flopscope/_accumulation/_public.py +169 -0
- flopscope/_accumulation/_reduction.py +310 -0
- flopscope/_accumulation/_regimes.py +303 -0
- flopscope/_accumulation/_shape.py +33 -0
- flopscope/_accumulation/_wreath.py +209 -0
- flopscope/_budget.py +1027 -0
- flopscope/_config.py +118 -0
- flopscope/_counting_ops.py +451 -0
- flopscope/_display.py +478 -0
- flopscope/_docstrings.py +59 -0
- flopscope/_dtypes.py +20 -0
- flopscope/_einsum.py +717 -0
- flopscope/_errstate.py +25 -0
- flopscope/_flops.py +282 -0
- flopscope/_free_ops.py +2654 -0
- flopscope/_ndarray.py +1126 -0
- flopscope/_opt_einsum/LICENSE +21 -0
- flopscope/_opt_einsum/NOTICE +59 -0
- flopscope/_opt_einsum/__init__.py +209 -0
- flopscope/_opt_einsum/_contract.py +1478 -0
- flopscope/_opt_einsum/_helpers.py +164 -0
- flopscope/_opt_einsum/_hsluv.py +273 -0
- flopscope/_opt_einsum/_path_random.py +462 -0
- flopscope/_opt_einsum/_paths.py +1653 -0
- flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
- flopscope/_opt_einsum/_symmetry.py +140 -0
- flopscope/_opt_einsum/_typing.py +37 -0
- flopscope/_perm_group.py +717 -0
- flopscope/_pointwise.py +2522 -0
- flopscope/_polynomial.py +278 -0
- flopscope/_registry.py +3216 -0
- flopscope/_sorting_ops.py +571 -0
- flopscope/_symmetric.py +812 -0
- flopscope/_symmetry_transport.py +510 -0
- flopscope/_symmetry_utils.py +669 -0
- flopscope/_type_info.py +12 -0
- flopscope/_unwrap.py +70 -0
- flopscope/_validation.py +83 -0
- flopscope/_version_check.py +46 -0
- flopscope/_weights.py +195 -0
- flopscope/_window.py +177 -0
- flopscope/accounting.py +565 -0
- flopscope/data/default_weights.json +462 -0
- flopscope/data/weights.csv +509 -0
- flopscope/errors.py +197 -0
- flopscope/numpy/__init__.py +878 -0
- flopscope/numpy/fft/__init__.py +55 -0
- flopscope/numpy/fft/_free.py +51 -0
- flopscope/numpy/fft/_transforms.py +695 -0
- flopscope/numpy/linalg/__init__.py +105 -0
- flopscope/numpy/linalg/_aliases.py +126 -0
- flopscope/numpy/linalg/_compound.py +161 -0
- flopscope/numpy/linalg/_decompositions.py +353 -0
- flopscope/numpy/linalg/_properties.py +533 -0
- flopscope/numpy/linalg/_solvers.py +444 -0
- flopscope/numpy/linalg/_svd.py +122 -0
- flopscope/numpy/random/__init__.py +684 -0
- flopscope/numpy/random/_cost_formulas.py +115 -0
- flopscope/numpy/random/_counted_classes.py +241 -0
- flopscope/numpy/testing/__init__.py +13 -0
- flopscope/numpy/typing/__init__.py +30 -0
- flopscope/py.typed +0 -0
- flopscope/stats/__init__.py +84 -0
- flopscope/stats/_base.py +77 -0
- flopscope/stats/_cauchy.py +146 -0
- flopscope/stats/_erf.py +190 -0
- flopscope/stats/_expon.py +146 -0
- flopscope/stats/_laplace.py +150 -0
- flopscope/stats/_logistic.py +148 -0
- flopscope/stats/_lognorm.py +160 -0
- flopscope/stats/_ndtri.py +133 -0
- flopscope/stats/_norm.py +149 -0
- flopscope/stats/_truncnorm.py +186 -0
- flopscope/stats/_uniform.py +141 -0
- flopscope-0.2.0.dist-info/METADATA +23 -0
- flopscope-0.2.0.dist-info/RECORD +115 -0
- 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"
|