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,510 @@
|
|
|
1
|
+
"""Per-op symmetry transport rules for NumPy-protocol shape ops.
|
|
2
|
+
|
|
3
|
+
Each `transport_<op>(group_or_groups, *, ...)` function returns either a new
|
|
4
|
+
`SymmetryGroup` (the output's surviving symmetry) or `None` (no non-trivial
|
|
5
|
+
group survives — caller emits `SymmetryLossWarning`).
|
|
6
|
+
|
|
7
|
+
See `.aicrowd/superpowers/specs/2026-05-22-shape-op-symmetry-transport-design.md`
|
|
8
|
+
for the per-op rules and rationale.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from flopscope._perm_group import SymmetryGroup
|
|
18
|
+
from flopscope._symmetry_utils import (
|
|
19
|
+
_normalize_reps_for_output,
|
|
20
|
+
broadcast_group,
|
|
21
|
+
group_orbits_on_axes,
|
|
22
|
+
intersect_groups,
|
|
23
|
+
remap_group_axes,
|
|
24
|
+
remap_group_for_expand_dims,
|
|
25
|
+
restrict_group_to_axes,
|
|
26
|
+
setwise_stabilizer,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _zero_size_guard(fn):
|
|
31
|
+
"""Decorator: any transport receiving a zero-size shape drops to None."""
|
|
32
|
+
|
|
33
|
+
@functools.wraps(fn)
|
|
34
|
+
def wrapper(*args, **kwargs):
|
|
35
|
+
for key in ("input_shape", "output_shape"):
|
|
36
|
+
shape = kwargs.get(key)
|
|
37
|
+
if shape is not None and 0 in shape:
|
|
38
|
+
return None
|
|
39
|
+
return fn(*args, **kwargs)
|
|
40
|
+
|
|
41
|
+
return wrapper
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@_zero_size_guard
|
|
45
|
+
def transport_reshape(
|
|
46
|
+
group: SymmetryGroup | None,
|
|
47
|
+
*,
|
|
48
|
+
input_shape: tuple[int, ...],
|
|
49
|
+
output_shape: tuple[int, ...],
|
|
50
|
+
) -> SymmetryGroup | None:
|
|
51
|
+
if group is None:
|
|
52
|
+
return None
|
|
53
|
+
A = group.axes or tuple(range(group.degree))
|
|
54
|
+
m = input_shape[
|
|
55
|
+
A[0]
|
|
56
|
+
] # block axis size (must be equal for all a in A by group validity)
|
|
57
|
+
|
|
58
|
+
A_sorted = sorted(A)
|
|
59
|
+
n = len(A_sorted)
|
|
60
|
+
|
|
61
|
+
# Skeleton segment products from input (n+1 segments interleaved with n block axes).
|
|
62
|
+
seg_input = []
|
|
63
|
+
prev = -1
|
|
64
|
+
for a in A_sorted:
|
|
65
|
+
seg_input.append(math.prod(input_shape[prev + 1 : a]))
|
|
66
|
+
prev = a
|
|
67
|
+
seg_input.append(math.prod(input_shape[prev + 1 :]))
|
|
68
|
+
|
|
69
|
+
# Walk through output, matching one segment then one block axis at a time.
|
|
70
|
+
out_positions: list[int] = []
|
|
71
|
+
pos = 0
|
|
72
|
+
for k in range(n):
|
|
73
|
+
target = seg_input[k]
|
|
74
|
+
accum = 1
|
|
75
|
+
while accum < target and pos < len(output_shape):
|
|
76
|
+
accum *= output_shape[pos]
|
|
77
|
+
pos += 1
|
|
78
|
+
if accum != target:
|
|
79
|
+
return None
|
|
80
|
+
# Skip length-1 padding axes between segment and block axis (unless m == 1).
|
|
81
|
+
if m != 1:
|
|
82
|
+
while pos < len(output_shape) and output_shape[pos] == 1:
|
|
83
|
+
pos += 1
|
|
84
|
+
if pos >= len(output_shape) or output_shape[pos] != m:
|
|
85
|
+
return None
|
|
86
|
+
out_positions.append(pos)
|
|
87
|
+
pos += 1
|
|
88
|
+
|
|
89
|
+
if math.prod(output_shape[pos:]) != seg_input[-1]:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
axis_map = {a: out_positions[A_sorted.index(a)] for a in A}
|
|
93
|
+
return remap_group_axes(group, axis_map)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@_zero_size_guard
|
|
97
|
+
def transport_ravel(
|
|
98
|
+
group: SymmetryGroup | None,
|
|
99
|
+
*,
|
|
100
|
+
input_shape: tuple[int, ...],
|
|
101
|
+
) -> SymmetryGroup | None:
|
|
102
|
+
return transport_reshape(
|
|
103
|
+
group,
|
|
104
|
+
input_shape=input_shape,
|
|
105
|
+
output_shape=(math.prod(input_shape),),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def transport_concatenate(
|
|
110
|
+
groups: Sequence[SymmetryGroup | None],
|
|
111
|
+
*,
|
|
112
|
+
output_ndim: int,
|
|
113
|
+
axis: int | None,
|
|
114
|
+
) -> SymmetryGroup | None:
|
|
115
|
+
if axis is None:
|
|
116
|
+
return None
|
|
117
|
+
if any(g is None for g in groups):
|
|
118
|
+
return None
|
|
119
|
+
# Restrict each input's group to axes != axis.
|
|
120
|
+
restricted = []
|
|
121
|
+
for g in groups:
|
|
122
|
+
assert g is not None # narrowed by the earlier any(g is None) check
|
|
123
|
+
A = g.axes if g.axes is not None else tuple(range(g.degree))
|
|
124
|
+
a = axis % (max(output_ndim, len(A) + 1))
|
|
125
|
+
keep = tuple(x for x in A if x != a)
|
|
126
|
+
if len(keep) < 2:
|
|
127
|
+
return None
|
|
128
|
+
r = restrict_group_to_axes(g, keep)
|
|
129
|
+
if r is None:
|
|
130
|
+
return None
|
|
131
|
+
restricted.append(r)
|
|
132
|
+
# Intersect across all restricted groups.
|
|
133
|
+
result = restricted[0]
|
|
134
|
+
for g in restricted[1:]:
|
|
135
|
+
result = intersect_groups(result, g, ndim=output_ndim)
|
|
136
|
+
if result is None:
|
|
137
|
+
return None
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def transport_stack(
|
|
142
|
+
groups: Sequence[SymmetryGroup | None],
|
|
143
|
+
*,
|
|
144
|
+
output_ndim: int,
|
|
145
|
+
axis: int = 0,
|
|
146
|
+
) -> SymmetryGroup | None:
|
|
147
|
+
if any(g is None for g in groups):
|
|
148
|
+
return None
|
|
149
|
+
k = axis % output_ndim
|
|
150
|
+
# Shift each input's block axes >= k by +1.
|
|
151
|
+
shifted = []
|
|
152
|
+
for g in groups:
|
|
153
|
+
assert g is not None # narrowed by the earlier any(g is None) check
|
|
154
|
+
A = g.axes if g.axes is not None else tuple(range(g.degree))
|
|
155
|
+
axis_map = {a: (a if a < k else a + 1) for a in A}
|
|
156
|
+
r = remap_group_axes(g, axis_map)
|
|
157
|
+
if r is None:
|
|
158
|
+
return None
|
|
159
|
+
shifted.append(r)
|
|
160
|
+
# Intersect.
|
|
161
|
+
result = shifted[0]
|
|
162
|
+
for g in shifted[1:]:
|
|
163
|
+
result = intersect_groups(result, g, ndim=output_ndim)
|
|
164
|
+
if result is None:
|
|
165
|
+
return None
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def transport_vstack(
|
|
170
|
+
groups: Sequence[SymmetryGroup | None],
|
|
171
|
+
*,
|
|
172
|
+
output_ndim: int,
|
|
173
|
+
input_ndims: Sequence[int],
|
|
174
|
+
) -> SymmetryGroup | None:
|
|
175
|
+
# vstack: atleast_2d each input, then concat axis=0.
|
|
176
|
+
# For any input that's 1-D, the promoted form has the data axis at position 1,
|
|
177
|
+
# and 1-D inputs carry no multi-axis group anyway -> None for them.
|
|
178
|
+
promoted = []
|
|
179
|
+
for g, nd in zip(groups, input_ndims, strict=True):
|
|
180
|
+
if nd >= 2:
|
|
181
|
+
promoted.append(g)
|
|
182
|
+
else:
|
|
183
|
+
# 1-D input promoted to (1, N) -- no multi-axis group.
|
|
184
|
+
promoted.append(None)
|
|
185
|
+
return transport_concatenate(promoted, output_ndim=output_ndim, axis=0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def transport_hstack(
|
|
189
|
+
groups: Sequence[SymmetryGroup | None],
|
|
190
|
+
*,
|
|
191
|
+
output_ndim: int,
|
|
192
|
+
input_ndims: Sequence[int],
|
|
193
|
+
) -> SymmetryGroup | None:
|
|
194
|
+
# hstack: concat axis=0 if all 1-D, else axis=1.
|
|
195
|
+
if all(nd == 1 for nd in input_ndims):
|
|
196
|
+
return transport_concatenate(groups, output_ndim=output_ndim, axis=0)
|
|
197
|
+
return transport_concatenate(groups, output_ndim=output_ndim, axis=1)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def transport_column_stack(
|
|
201
|
+
groups: Sequence[SymmetryGroup | None],
|
|
202
|
+
*,
|
|
203
|
+
output_ndim: int,
|
|
204
|
+
input_ndims: Sequence[int],
|
|
205
|
+
) -> SymmetryGroup | None:
|
|
206
|
+
# column_stack: promote 1-D (N,) -> (N, 1), then concat axis=1.
|
|
207
|
+
# 1-D inputs become column vectors with no multi-axis group.
|
|
208
|
+
promoted = []
|
|
209
|
+
for g, nd in zip(groups, input_ndims, strict=True):
|
|
210
|
+
if nd >= 2:
|
|
211
|
+
promoted.append(g)
|
|
212
|
+
else:
|
|
213
|
+
promoted.append(None)
|
|
214
|
+
return transport_concatenate(promoted, output_ndim=output_ndim, axis=1)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@_zero_size_guard
|
|
218
|
+
def transport_split(
|
|
219
|
+
group: SymmetryGroup | None,
|
|
220
|
+
*,
|
|
221
|
+
input_shape: tuple[int, ...],
|
|
222
|
+
axis: int = 0,
|
|
223
|
+
) -> SymmetryGroup | None:
|
|
224
|
+
if group is None:
|
|
225
|
+
return None
|
|
226
|
+
a = axis % len(input_shape)
|
|
227
|
+
A = group.axes or tuple(range(group.degree))
|
|
228
|
+
keep = tuple(x for x in A if x != a)
|
|
229
|
+
if len(keep) < 2:
|
|
230
|
+
return None
|
|
231
|
+
return restrict_group_to_axes(group, keep)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@_zero_size_guard
|
|
235
|
+
def transport_hsplit(
|
|
236
|
+
group: SymmetryGroup | None,
|
|
237
|
+
*,
|
|
238
|
+
input_shape: tuple[int, ...],
|
|
239
|
+
) -> SymmetryGroup | None:
|
|
240
|
+
# hsplit uses axis=0 for 1-D, axis=1 otherwise.
|
|
241
|
+
if len(input_shape) == 1:
|
|
242
|
+
return transport_split(group, input_shape=input_shape, axis=0)
|
|
243
|
+
return transport_split(group, input_shape=input_shape, axis=1)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@_zero_size_guard
|
|
247
|
+
def transport_vsplit(
|
|
248
|
+
group: SymmetryGroup | None,
|
|
249
|
+
*,
|
|
250
|
+
input_shape: tuple[int, ...],
|
|
251
|
+
) -> SymmetryGroup | None:
|
|
252
|
+
return transport_split(group, input_shape=input_shape, axis=0)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@_zero_size_guard
|
|
256
|
+
def transport_dsplit(
|
|
257
|
+
group: SymmetryGroup | None,
|
|
258
|
+
*,
|
|
259
|
+
input_shape: tuple[int, ...],
|
|
260
|
+
) -> SymmetryGroup | None:
|
|
261
|
+
return transport_split(group, input_shape=input_shape, axis=2)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@_zero_size_guard
|
|
265
|
+
def transport_atleast_1d(
|
|
266
|
+
group: SymmetryGroup | None,
|
|
267
|
+
*,
|
|
268
|
+
input_shape: tuple[int, ...],
|
|
269
|
+
) -> SymmetryGroup | None:
|
|
270
|
+
if group is None:
|
|
271
|
+
return None
|
|
272
|
+
# For any input with rank >= 1, atleast_1d is identity.
|
|
273
|
+
# (Rank 0 -> rank 1, but rank 0 can't carry a multi-axis group.)
|
|
274
|
+
return group
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@_zero_size_guard
|
|
278
|
+
def transport_atleast_2d(
|
|
279
|
+
group: SymmetryGroup | None,
|
|
280
|
+
*,
|
|
281
|
+
input_shape: tuple[int, ...],
|
|
282
|
+
) -> SymmetryGroup | None:
|
|
283
|
+
if group is None:
|
|
284
|
+
return None
|
|
285
|
+
# For any input with rank >= 2 (which is the only case that can carry
|
|
286
|
+
# a non-trivial multi-axis group), atleast_2d is identity.
|
|
287
|
+
return group
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@_zero_size_guard
|
|
291
|
+
def transport_atleast_3d(
|
|
292
|
+
group: SymmetryGroup | None,
|
|
293
|
+
*,
|
|
294
|
+
input_shape: tuple[int, ...],
|
|
295
|
+
) -> SymmetryGroup | None:
|
|
296
|
+
if group is None:
|
|
297
|
+
return None
|
|
298
|
+
if len(input_shape) >= 3:
|
|
299
|
+
# No-op.
|
|
300
|
+
return group
|
|
301
|
+
if len(input_shape) == 2:
|
|
302
|
+
# NumPy appends a trailing length-1 axis: (M, N) -> (M, N, 1).
|
|
303
|
+
# Block axes don't shift.
|
|
304
|
+
return group
|
|
305
|
+
# len(input_shape) <= 1: cannot carry a multi-axis group; defensive None.
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@_zero_size_guard
|
|
310
|
+
def transport_broadcast_to(
|
|
311
|
+
group: SymmetryGroup | None,
|
|
312
|
+
*,
|
|
313
|
+
input_shape: tuple[int, ...],
|
|
314
|
+
output_shape: tuple[int, ...],
|
|
315
|
+
) -> SymmetryGroup | None:
|
|
316
|
+
# NOTE: broadcast_group handles `group is None` correctly — it can still
|
|
317
|
+
# produce a non-None result when prepended equal-size axes form a new
|
|
318
|
+
# S_k symmetry on the output. Do not short-circuit on None here.
|
|
319
|
+
return broadcast_group(
|
|
320
|
+
group,
|
|
321
|
+
input_shape=input_shape,
|
|
322
|
+
output_shape=output_shape,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def transport_expand_dims(
|
|
327
|
+
group: SymmetryGroup | None,
|
|
328
|
+
*,
|
|
329
|
+
input_ndim: int,
|
|
330
|
+
axis,
|
|
331
|
+
) -> SymmetryGroup | None:
|
|
332
|
+
return remap_group_for_expand_dims(group, ndim=input_ndim, axis=axis)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@_zero_size_guard
|
|
336
|
+
def transport_squeeze(
|
|
337
|
+
group: SymmetryGroup | None,
|
|
338
|
+
*,
|
|
339
|
+
input_shape: tuple[int, ...],
|
|
340
|
+
axis: int | tuple[int, ...] | None,
|
|
341
|
+
) -> SymmetryGroup | None:
|
|
342
|
+
if group is None:
|
|
343
|
+
return None
|
|
344
|
+
A = group.axes or tuple(range(group.degree))
|
|
345
|
+
# Resolve axis to a sorted tuple of axes being squeezed.
|
|
346
|
+
if axis is None:
|
|
347
|
+
squeezed = tuple(i for i, s in enumerate(input_shape) if s == 1)
|
|
348
|
+
elif isinstance(axis, int):
|
|
349
|
+
squeezed = (axis % len(input_shape),)
|
|
350
|
+
else:
|
|
351
|
+
squeezed = tuple(sorted(a % len(input_shape) for a in axis))
|
|
352
|
+
# Rule (b): if any squeezed axis is in the block, drop.
|
|
353
|
+
if any(a in A for a in squeezed):
|
|
354
|
+
return None
|
|
355
|
+
# Rule (a): shift block axes > each squeezed axis down by 1, preserving order.
|
|
356
|
+
surviving = sorted(i for i in range(len(input_shape)) if i not in squeezed)
|
|
357
|
+
new_index = {old: new for new, old in enumerate(surviving)}
|
|
358
|
+
axis_map = {a: new_index[a] for a in A}
|
|
359
|
+
return remap_group_axes(group, axis_map)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def transport_flip(
|
|
363
|
+
group: SymmetryGroup | None,
|
|
364
|
+
*,
|
|
365
|
+
ndim: int,
|
|
366
|
+
axes_flipped,
|
|
367
|
+
) -> SymmetryGroup | None:
|
|
368
|
+
if group is None:
|
|
369
|
+
return None
|
|
370
|
+
# Normalize axes_flipped: None -> all, int -> tuple, negative wrap.
|
|
371
|
+
if axes_flipped is None:
|
|
372
|
+
F = set(range(ndim))
|
|
373
|
+
elif isinstance(axes_flipped, int):
|
|
374
|
+
F = {axes_flipped % ndim}
|
|
375
|
+
else:
|
|
376
|
+
F = {a % ndim for a in axes_flipped}
|
|
377
|
+
A = set(group.axes or range(group.degree))
|
|
378
|
+
F_A = F & A
|
|
379
|
+
if not F_A or F_A == A:
|
|
380
|
+
return group
|
|
381
|
+
return setwise_stabilizer(group, fixed_set=F_A)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@_zero_size_guard
|
|
385
|
+
def transport_tile(
|
|
386
|
+
group: SymmetryGroup | None,
|
|
387
|
+
*,
|
|
388
|
+
input_shape: tuple[int, ...],
|
|
389
|
+
output_shape: tuple[int, ...],
|
|
390
|
+
reps,
|
|
391
|
+
) -> SymmetryGroup | None:
|
|
392
|
+
if group is None:
|
|
393
|
+
return None
|
|
394
|
+
A = group.axes or tuple(range(group.degree))
|
|
395
|
+
shift = len(output_shape) - len(input_shape)
|
|
396
|
+
reps_norm = _normalize_reps_for_output(reps, output_ndim=len(output_shape))
|
|
397
|
+
# Orbit-constancy check on output-positioned reps.
|
|
398
|
+
for orbit in group_orbits_on_axes(group, A):
|
|
399
|
+
reps_in_orbit = {reps_norm[a + shift] for a in orbit}
|
|
400
|
+
if len(reps_in_orbit) > 1:
|
|
401
|
+
return None
|
|
402
|
+
if shift == 0:
|
|
403
|
+
return group
|
|
404
|
+
# Block axes shift in the output; relabel the group.
|
|
405
|
+
axis_map = {a: a + shift for a in A}
|
|
406
|
+
return remap_group_axes(group, axis_map)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@_zero_size_guard
|
|
410
|
+
def transport_repeat(
|
|
411
|
+
group: SymmetryGroup | None,
|
|
412
|
+
*,
|
|
413
|
+
input_shape: tuple[int, ...],
|
|
414
|
+
axis: int | None,
|
|
415
|
+
) -> SymmetryGroup | None:
|
|
416
|
+
if group is None:
|
|
417
|
+
return None
|
|
418
|
+
if axis is None:
|
|
419
|
+
# repeat(axis=None) ravels first -> never preserves multi-axis sym.
|
|
420
|
+
return None
|
|
421
|
+
a = axis % len(input_shape)
|
|
422
|
+
A = set(group.axes or range(group.degree))
|
|
423
|
+
if a in A:
|
|
424
|
+
return None
|
|
425
|
+
return group
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@_zero_size_guard
|
|
429
|
+
def transport_roll(
|
|
430
|
+
group: SymmetryGroup | None,
|
|
431
|
+
*,
|
|
432
|
+
input_shape: tuple[int, ...],
|
|
433
|
+
axis: int | Sequence[int] | None,
|
|
434
|
+
) -> SymmetryGroup | None:
|
|
435
|
+
if group is None:
|
|
436
|
+
return None
|
|
437
|
+
if axis is None:
|
|
438
|
+
# roll(axis=None) flattens first -> always drops.
|
|
439
|
+
return None
|
|
440
|
+
if isinstance(axis, int):
|
|
441
|
+
rolled = {axis % len(input_shape)}
|
|
442
|
+
else:
|
|
443
|
+
rolled = {a % len(input_shape) for a in axis}
|
|
444
|
+
A = set(group.axes or range(group.degree))
|
|
445
|
+
if rolled & A:
|
|
446
|
+
return None
|
|
447
|
+
return group
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def transport_transpose(
|
|
451
|
+
group: SymmetryGroup | None,
|
|
452
|
+
*,
|
|
453
|
+
ndim: int,
|
|
454
|
+
axes: Sequence[int] | None = None,
|
|
455
|
+
) -> SymmetryGroup | None:
|
|
456
|
+
if group is None:
|
|
457
|
+
return None
|
|
458
|
+
if axes is None:
|
|
459
|
+
order = tuple(reversed(range(ndim)))
|
|
460
|
+
else:
|
|
461
|
+
order = tuple(a % ndim for a in axes)
|
|
462
|
+
mapping = {old: new for new, old in enumerate(order)}
|
|
463
|
+
return remap_group_axes(group, mapping)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def transport_swapaxes(
|
|
467
|
+
group: SymmetryGroup | None,
|
|
468
|
+
*,
|
|
469
|
+
ndim: int,
|
|
470
|
+
axis1: int,
|
|
471
|
+
axis2: int,
|
|
472
|
+
) -> SymmetryGroup | None:
|
|
473
|
+
if group is None:
|
|
474
|
+
return None
|
|
475
|
+
a1 = axis1 % ndim
|
|
476
|
+
a2 = axis2 % ndim
|
|
477
|
+
order = list(range(ndim))
|
|
478
|
+
order[a1], order[a2] = order[a2], order[a1]
|
|
479
|
+
mapping = {old: new for new, old in enumerate(order)}
|
|
480
|
+
return remap_group_axes(group, mapping)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def transport_moveaxis(
|
|
484
|
+
group: SymmetryGroup | None,
|
|
485
|
+
*,
|
|
486
|
+
ndim: int,
|
|
487
|
+
source: int | Sequence[int],
|
|
488
|
+
destination: int | Sequence[int],
|
|
489
|
+
) -> SymmetryGroup | None:
|
|
490
|
+
if group is None:
|
|
491
|
+
return None
|
|
492
|
+
src = (source,) if isinstance(source, int) else tuple(source)
|
|
493
|
+
dst = (destination,) if isinstance(destination, int) else tuple(destination)
|
|
494
|
+
src = tuple(s % ndim for s in src)
|
|
495
|
+
dst = tuple(d % ndim for d in dst)
|
|
496
|
+
order = [i for i in range(ndim) if i not in src]
|
|
497
|
+
for d, s in sorted(zip(dst, src, strict=True)):
|
|
498
|
+
order.insert(d, s)
|
|
499
|
+
mapping = {old: new for new, old in enumerate(order)}
|
|
500
|
+
return remap_group_axes(group, mapping)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def transport_matrix_transpose(
|
|
504
|
+
group: SymmetryGroup | None,
|
|
505
|
+
*,
|
|
506
|
+
ndim: int,
|
|
507
|
+
) -> SymmetryGroup | None:
|
|
508
|
+
if ndim < 2:
|
|
509
|
+
return None
|
|
510
|
+
return transport_swapaxes(group, ndim=ndim, axis1=ndim - 2, axis2=ndim - 1)
|