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,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)