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,812 @@
1
+ """Symmetric tensor support: SymmetricTensor, as_symmetric, and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from flopscope._ndarray import FlopscopeArray, _asplainflopscope
8
+ from flopscope._perm_group import SymmetryGroup
9
+ from flopscope._symmetry_utils import (
10
+ broadcast_group,
11
+ inserted_axes_symmetry,
12
+ intersect_groups,
13
+ normalize_symmetry_input,
14
+ reduce_group,
15
+ remap_group_axes,
16
+ restrict_group_to_axes,
17
+ validate_symmetry_group,
18
+ )
19
+ from flopscope.errors import SymmetryError
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Validation
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def validate_symmetry(
27
+ data: np.ndarray,
28
+ axis_groups: list[tuple[int, ...]],
29
+ ) -> None:
30
+ """Validate that *data* has the claimed symmetry.
31
+
32
+ For each group, checks that all dims have equal sizes and that all
33
+ pairwise transpositions are satisfied within tolerance.
34
+
35
+ Raises
36
+ ------
37
+ SymmetryError
38
+ If the data is not symmetric along the claimed axes.
39
+ """
40
+ for group in axis_groups:
41
+ if len(group) < 2:
42
+ continue
43
+ # Check equal sizes.
44
+ sizes = [data.shape[d] for d in group]
45
+ if len(set(sizes)) != 1:
46
+ raise SymmetryError(axes=group, max_deviation=float("inf"))
47
+ # Check pairwise transpositions.
48
+ for i in range(len(group)):
49
+ for j in range(i + 1, len(group)):
50
+ axes = list(range(data.ndim))
51
+ axes[group[i]], axes[group[j]] = axes[group[j]], axes[group[i]]
52
+ transposed = data.transpose(axes)
53
+ if not np.allclose(data, transposed, atol=1e-6, rtol=1e-5):
54
+ max_dev = float(np.max(np.abs(data - transposed)))
55
+ raise SymmetryError(axes=group, max_deviation=max_dev)
56
+
57
+
58
+ def symmetrize(
59
+ data: np.ndarray,
60
+ *,
61
+ symmetry,
62
+ ) -> SymmetricTensor:
63
+ """Project an array onto the invariant subspace of a permutation group.
64
+
65
+ This applies Reynolds symmetrization:
66
+
67
+ ``R_G(T) = (1 / |G|) * sum_{g in G} g · T``
68
+
69
+ Parameters
70
+ ----------
71
+ data : array_like
72
+ Input array to project.
73
+ symmetry : SymmetryGroup
74
+ Symmetry group to average over. If ``symmetry.axes`` is ``None``, axes are
75
+ interpreted as ``tuple(range(symmetry.degree))``.
76
+
77
+ Returns
78
+ -------
79
+ SymmetricTensor
80
+ The projected tensor, validated and wrapped as a :class:`SymmetricTensor`.
81
+
82
+ Raises
83
+ ------
84
+ SymmetryError
85
+ If ``data`` has incompatible dimensions for ``group`` axes or if the
86
+ projected result cannot be validated as symmetric for ``group``.
87
+
88
+ Notes
89
+ -----
90
+ ``symmetrize`` performs exact Reynolds averaging internally and then delegates
91
+ to :func:`as_symmetric` so the result participates in downstream symmetry
92
+ tracking and validation in the usual way.
93
+
94
+ Estimated FLOP cost is approximately:
95
+
96
+ ``|G| * n_elem + n_elem`` for projection, plus validation from
97
+ ``as_symmetric``:
98
+
99
+ - ``|G|`` transposed add passes over ``n_elem`` elements
100
+ - one final scaling pass
101
+ - symmetry checks inside validation
102
+
103
+ where ``|G|`` is the group order and ``n_elem = data.size``.
104
+
105
+ The canonical pattern for generating random data with symmetry is:
106
+
107
+ ``fnp.random.symmetric(shape, symmetry_group, distribution=...)``.
108
+
109
+ Examples
110
+ --------
111
+ >>> import flopscope as flops
112
+ >>> import flopscope.numpy as fnp
113
+ >>> data = fnp.random.randn(4, 4)
114
+ >>> S = flops.symmetrize(data, flops.SymmetryGroup.symmetric(axes=(0, 1)))
115
+ >>> S.is_symmetric((0, 1))
116
+ True
117
+ """
118
+ array = np.asarray(data)
119
+ group = _resolve_symmetry_argument(
120
+ array,
121
+ symmetry=symmetry,
122
+ )
123
+ assert group is not None # required=True raises if symmetry is None
124
+ validate_symmetry_group(group, ndim=array.ndim, shape=array.shape)
125
+ group_axes = group.axes if group.axes is not None else tuple(range(group.degree))
126
+ symmetrized = np.zeros_like(array, dtype=np.result_type(array, np.float64))
127
+
128
+ for g in group.elements():
129
+ perm = list(range(array.ndim))
130
+ for local_idx, tensor_axis in enumerate(group_axes):
131
+ perm[tensor_axis] = group_axes[g.array_form[local_idx]]
132
+ symmetrized = symmetrized + np.transpose(array, perm)
133
+
134
+ return as_symmetric(symmetrized / group.order(), symmetry=group)
135
+
136
+
137
+ def validate_symmetry_groups(data: np.ndarray, groups: list) -> None:
138
+ """Validate that *data* is symmetric under the given SymmetryGroups.
139
+
140
+ Raises
141
+ ------
142
+ ValueError
143
+ If a group has no axes set.
144
+ SymmetryError
145
+ If the data is not symmetric under the claimed group.
146
+ """
147
+ for group in groups:
148
+ axes = group.axes
149
+ if axes is None:
150
+ axes = tuple(range(group.degree))
151
+ group._axes = axes
152
+ validate_symmetry_group(group, ndim=data.ndim, shape=data.shape)
153
+ for orbit in group.orbits():
154
+ sizes = {data.shape[axes[i]] for i in orbit}
155
+ if len(sizes) != 1:
156
+ raise SymmetryError(
157
+ axes=tuple(axes[i] for i in orbit), max_deviation=float("inf")
158
+ )
159
+ for gen in group.generators:
160
+ if gen.is_identity:
161
+ continue
162
+ perm = list(range(data.ndim))
163
+ for i in range(group.degree):
164
+ perm[axes[i]] = axes[gen._array_form[i]]
165
+ transposed = data.transpose(perm)
166
+ if not np.allclose(data, transposed, atol=1e-6, rtol=1e-5):
167
+ max_dev = float(np.max(np.abs(data - transposed)))
168
+ raise SymmetryError(axes=tuple(axes), max_deviation=max_dev)
169
+
170
+
171
+ def _resolve_symmetry_argument(
172
+ data: np.ndarray,
173
+ *,
174
+ symmetry,
175
+ required: bool = True,
176
+ ):
177
+ if symmetry is None:
178
+ if required:
179
+ raise ValueError("symmetry must be provided")
180
+ return None
181
+ return normalize_symmetry_input(symmetry, ndim=np.asarray(data).ndim)
182
+
183
+
184
+ def is_symmetric(
185
+ data: np.ndarray,
186
+ *,
187
+ symmetry,
188
+ atol: float = 1e-6,
189
+ rtol: float = 1e-5,
190
+ ) -> bool:
191
+ """Check whether *data* is invariant under the given symmetry.
192
+
193
+ Parameters
194
+ ----------
195
+ data : numpy.ndarray
196
+ The array to test.
197
+ symmetry : SymmetryGroup or array-like specification
198
+ Symmetry to verify, normalized via :func:`normalize_symmetry_input`.
199
+ atol : float, optional
200
+ Absolute tolerance used by :func:`numpy.allclose`. Default ``1e-6``.
201
+ rtol : float, optional
202
+ Relative tolerance used by :func:`numpy.allclose`. Default ``1e-5``.
203
+
204
+ Returns
205
+ -------
206
+ bool
207
+ ``True`` if *data* is invariant under every group element, otherwise
208
+ ``False``.
209
+
210
+ Examples
211
+ --------
212
+ >>> import flopscope as flops
213
+ >>> import flopscope.numpy as fnp
214
+ >>> matrix = fnp.array([[1.0, 2.0], [2.0, 3.0]])
215
+ >>> flops.is_symmetric(
216
+ ... matrix, symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1))
217
+ ... )
218
+ True
219
+ """
220
+ group = _resolve_symmetry_argument(
221
+ data,
222
+ symmetry=symmetry,
223
+ required=False,
224
+ )
225
+ if group is None:
226
+ return False
227
+
228
+ array = np.asarray(data)
229
+ group_axes = group.axes if group.axes is not None else tuple(range(group.degree))
230
+ validate_symmetry_group(group, ndim=array.ndim, shape=array.shape)
231
+
232
+ for elem in group.elements():
233
+ axes = list(range(array.ndim))
234
+ for src_local, dst_local in enumerate(elem.array_form):
235
+ axes[group_axes[src_local]] = group_axes[dst_local]
236
+ if not np.allclose(array, array.transpose(axes), atol=atol, rtol=rtol):
237
+ return False
238
+ return True
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Symmetry-loss warning helper
243
+ # ---------------------------------------------------------------------------
244
+
245
+ from flopscope.errors import ( # noqa: E402
246
+ _warn_symmetry_loss,
247
+ ) # re-exported for back-compat
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Symmetry propagation helpers
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def propagate_symmetry_slice(
255
+ groups: list[SymmetryGroup],
256
+ shape: tuple[int, ...],
257
+ key,
258
+ ) -> list[SymmetryGroup] | None:
259
+ """Compute new symmetry groups after ``__getitem__(key)``.
260
+
261
+ Parameters
262
+ ----------
263
+ groups : list of SymmetryGroup
264
+ Each group has ``axes`` indicating which tensor dimensions it acts on.
265
+ shape : tuple of int
266
+ Original tensor shape.
267
+ key : indexing key
268
+ The slicing/indexing key.
269
+
270
+ Returns
271
+ -------
272
+ list of SymmetryGroup or None
273
+ Surviving groups, or ``None`` if no symmetry survives.
274
+ """
275
+ ndim = len(shape)
276
+
277
+ if not isinstance(key, tuple):
278
+ key = (key,)
279
+
280
+ for k in key:
281
+ # Bool indices are boolean masks in numpy (add a size-1 batch axis for
282
+ # True / size-0 for False), not integer scalars. `isinstance(True, int)`
283
+ # is also True in Python, so we must check bool BEFORE the int branch
284
+ # below would silently misclassify it as an integer scalar index.
285
+ if isinstance(k, (np.ndarray, list, bool, np.bool_)):
286
+ return None
287
+
288
+ # Expand Ellipsis.
289
+ expanded: list = []
290
+ ellipsis_seen = False
291
+ for k in key:
292
+ if k is Ellipsis:
293
+ if ellipsis_seen:
294
+ raise IndexError("only one Ellipsis allowed")
295
+ ellipsis_seen = True
296
+ n_newaxis_in_key = sum(1 for kk in key if kk is None)
297
+ n_explicit = len(key) - 1 - n_newaxis_in_key
298
+ n_fill = ndim - n_explicit
299
+ expanded.extend([slice(None)] * n_fill)
300
+ else:
301
+ expanded.append(k)
302
+ if not ellipsis_seen:
303
+ n_newaxis = sum(1 for k in expanded if k is None)
304
+ while len(expanded) - n_newaxis < ndim:
305
+ expanded.append(slice(None))
306
+ key_expanded = expanded
307
+
308
+ # Classify each original dim.
309
+ old_dim_idx = 0
310
+ dim_actions: dict[int, str | tuple] = {}
311
+
312
+ for k in key_expanded:
313
+ if k is None:
314
+ continue
315
+ if old_dim_idx >= ndim:
316
+ break
317
+ if isinstance(k, (int, np.integer)):
318
+ dim_actions[old_dim_idx] = "removed"
319
+ old_dim_idx += 1
320
+ elif isinstance(k, slice):
321
+ start, stop, step = k.indices(shape[old_dim_idx])
322
+ if start == 0 and stop == shape[old_dim_idx] and step == 1:
323
+ dim_actions[old_dim_idx] = "untouched"
324
+ else:
325
+ new_size = max(
326
+ 0, (stop - start + (step - (1 if step > 0 else -1))) // step
327
+ )
328
+ dim_actions[old_dim_idx] = ("resized", new_size)
329
+ old_dim_idx += 1
330
+ else:
331
+ return None
332
+
333
+ while old_dim_idx < ndim:
334
+ dim_actions[old_dim_idx] = "untouched"
335
+ old_dim_idx += 1
336
+
337
+ # Build old→new dim mapping.
338
+ removed_dims = {d for d, a in dim_actions.items() if a == "removed"}
339
+ old_to_new: dict[int, int | None] = {}
340
+ newaxis_positions: list[int] = []
341
+ orig_idx = 0
342
+ for k in key_expanded:
343
+ if k is None:
344
+ newaxis_positions.append(orig_idx)
345
+ else:
346
+ orig_idx += 1
347
+
348
+ new_idx = 0
349
+ for d in range(ndim):
350
+ while newaxis_positions and newaxis_positions[0] <= d:
351
+ newaxis_positions.pop(0)
352
+ new_idx += 1
353
+ if d in removed_dims:
354
+ old_to_new[d] = None
355
+ else:
356
+ old_to_new[d] = new_idx
357
+ new_idx += 1
358
+
359
+ # Process each group.
360
+ new_groups: list[SymmetryGroup] = []
361
+ for group in groups:
362
+ axes = group.axes
363
+ if axes is None:
364
+ continue
365
+
366
+ # Map tensor axes to group-local indices.
367
+ local_removed: set[int] = set()
368
+ local_kept: list[int] = []
369
+ for local_idx, tensor_dim in enumerate(axes):
370
+ action = dim_actions.get(tensor_dim, "untouched")
371
+ if action == "removed":
372
+ local_removed.add(local_idx)
373
+ else:
374
+ local_kept.append(local_idx)
375
+
376
+ if not local_kept:
377
+ continue
378
+
379
+ if any(
380
+ dim_actions.get(axes[local_idx], "untouched") != "untouched"
381
+ for local_idx in local_kept
382
+ ):
383
+ continue
384
+
385
+ # Pointwise stabilizer: each removed axis must map to itself.
386
+ # (Setwise would only be valid when all removed axes share the
387
+ # same slice value, which we can't determine in general.)
388
+ stab = group.pointwise_stabilizer(local_removed)
389
+
390
+ # Restrict to kept local indices.
391
+ kept_tuple = tuple(local_kept)
392
+ if len(kept_tuple) < 2:
393
+ continue
394
+
395
+ restricted = restrict_group_to_axes(stab, tuple(axes[k] for k in kept_tuple))
396
+ if restricted is None:
397
+ continue
398
+
399
+ final = remap_group_axes(
400
+ restricted,
401
+ {axes[k]: old_to_new[axes[k]] for k in kept_tuple}, # type: ignore[arg-type]
402
+ )
403
+ if final is None:
404
+ continue
405
+ new_groups.append(final)
406
+
407
+ # Build the inserted-axis group from freshly-inserted None positions.
408
+ inserted_output_positions: list[int] = []
409
+ out_idx = 0
410
+ for k in key_expanded:
411
+ if k is None:
412
+ inserted_output_positions.append(out_idx)
413
+ out_idx += 1
414
+ elif isinstance(k, (int, np.integer)):
415
+ pass # axis removed; no output slot
416
+ else:
417
+ out_idx += 1
418
+
419
+ inserted_group = inserted_axes_symmetry(inserted_output_positions)
420
+ if inserted_group is not None:
421
+ new_groups.append(inserted_group)
422
+
423
+ return new_groups if new_groups else None
424
+
425
+
426
+ def propagate_symmetry_reduce(
427
+ groups: list[SymmetryGroup],
428
+ ndim: int,
429
+ axis: int | tuple[int, ...] | None,
430
+ keepdims: bool = False,
431
+ ) -> list[SymmetryGroup] | None:
432
+ """Compute new symmetry groups after a reduction.
433
+
434
+ Parameters
435
+ ----------
436
+ groups : list of SymmetryGroup
437
+ Each group has ``axes`` indicating which tensor dimensions it acts on.
438
+ ndim : int
439
+ Original tensor rank.
440
+ axis : int, tuple of int, or None
441
+ Axes being reduced.
442
+ keepdims : bool
443
+ Whether reduced dims are kept at size 1.
444
+
445
+ Returns
446
+ -------
447
+ list of SymmetryGroup or None
448
+ Surviving groups, or ``None`` if no symmetry survives.
449
+ """
450
+ new_groups: list[SymmetryGroup] = []
451
+ for group in groups:
452
+ reduced = reduce_group(group, ndim=ndim, axis=axis, keepdims=keepdims)
453
+ if reduced is not None:
454
+ new_groups.append(reduced)
455
+
456
+ return new_groups if new_groups else None
457
+
458
+
459
+ def intersect_symmetry(
460
+ groups_a: list[SymmetryGroup] | None,
461
+ groups_b: list[SymmetryGroup] | None,
462
+ shape_a: tuple[int, ...],
463
+ shape_b: tuple[int, ...],
464
+ output_shape: tuple[int, ...],
465
+ ) -> list[SymmetryGroup] | None:
466
+ """Intersect symmetry groups for binary ops, accounting for broadcasting.
467
+
468
+ For groups acting on the same output axes, computes the element-set
469
+ intersection. Broadcast-stretched dimensions (size 1 → larger) are
470
+ removed from groups before intersecting.
471
+
472
+ Parameters
473
+ ----------
474
+ groups_a, groups_b : list of SymmetryGroup or None
475
+ Symmetry groups for each operand.
476
+ shape_a, shape_b : tuple of int
477
+ Input shapes (before broadcasting).
478
+ output_shape : tuple of int
479
+ Broadcast output shape.
480
+
481
+ Returns
482
+ -------
483
+ list of SymmetryGroup or None
484
+ Groups present in both operands, or *None* if no shared symmetry.
485
+ """
486
+ if groups_a is None or groups_b is None:
487
+ return None
488
+
489
+ ndim_out = len(output_shape)
490
+
491
+ aligned_a = [
492
+ aligned
493
+ for group in groups_a
494
+ if (
495
+ aligned := broadcast_group(
496
+ group, input_shape=shape_a, output_shape=output_shape
497
+ )
498
+ )
499
+ is not None
500
+ ]
501
+ aligned_b = [
502
+ aligned
503
+ for group in groups_b
504
+ if (
505
+ aligned := broadcast_group(
506
+ group, input_shape=shape_b, output_shape=output_shape
507
+ )
508
+ )
509
+ is not None
510
+ ]
511
+
512
+ # Intersect: for groups acting on the same output axes, compute element intersection.
513
+ b_by_axes: dict[tuple[int, ...], SymmetryGroup] = {}
514
+ for g in aligned_b:
515
+ if g.axes is not None:
516
+ b_by_axes[g.axes] = g
517
+
518
+ intersection: list[SymmetryGroup] = []
519
+ for ga in aligned_a:
520
+ if ga.axes is None:
521
+ continue
522
+ gb = b_by_axes.get(ga.axes)
523
+ if gb is None:
524
+ continue
525
+ common = intersect_groups(ga, gb, ndim=ndim_out)
526
+ if common is not None:
527
+ intersection.append(common)
528
+
529
+ return intersection if intersection else None
530
+
531
+
532
+ # ---------------------------------------------------------------------------
533
+ # SymmetricTensor (np.ndarray subclass)
534
+ # ---------------------------------------------------------------------------
535
+
536
+
537
+ def _merge_symmetry_groups(groups) -> SymmetryGroup | None:
538
+ groups = [group for group in groups if group is not None]
539
+ if not groups:
540
+ return None
541
+ if len(groups) == 1:
542
+ return groups[0]
543
+ return SymmetryGroup.direct_product(*groups)
544
+
545
+
546
+ def _wrap_tensor_result(data: np.ndarray, symmetry: SymmetryGroup | None):
547
+ if symmetry is None:
548
+ return _asplainflopscope(data)
549
+ return SymmetricTensor(data, symmetry=symmetry)
550
+
551
+
552
+ class SymmetricTensor(FlopscopeArray):
553
+ """An ndarray that carries symmetry metadata.
554
+
555
+ Do not instantiate directly; use :func:`as_symmetric`.
556
+ """
557
+
558
+ __slots__ = ("_symmetry", "_symmetry_inferred")
559
+
560
+ def __new__(
561
+ cls,
562
+ input_array: np.ndarray,
563
+ *,
564
+ symmetry: SymmetryGroup,
565
+ ) -> SymmetricTensor:
566
+ obj = np.asarray(input_array).view(cls)
567
+ obj._symmetry = symmetry
568
+ obj._symmetry_inferred = False
569
+ return obj
570
+
571
+ def __array_finalize__(self, obj: object) -> None:
572
+ self._symmetry = None
573
+ self._symmetry_inferred = False
574
+
575
+ def __array_wrap__(self, out_arr, context=None, return_scalar=False):
576
+ result = super().__array_wrap__(out_arr, context, return_scalar)
577
+ if return_scalar:
578
+ return result
579
+ if isinstance(result, SymmetricTensor) and result._symmetry is None:
580
+ return _asplainflopscope(np.asarray(result))
581
+ return result
582
+
583
+ # -- public API --
584
+
585
+ @property
586
+ def symmetry(self) -> SymmetryGroup:
587
+ """Exact symmetry group carried by this tensor."""
588
+ return self._symmetry # type: ignore[return-value]
589
+
590
+ def is_symmetric(
591
+ self,
592
+ *,
593
+ symmetry=None,
594
+ atol: float = 1e-6,
595
+ rtol: float = 1e-5,
596
+ ) -> bool:
597
+ """Check whether the data satisfies the given (or carried) symmetry."""
598
+ group = (
599
+ self._symmetry
600
+ if symmetry is None
601
+ else _resolve_symmetry_argument(
602
+ self,
603
+ symmetry=symmetry,
604
+ required=False,
605
+ )
606
+ )
607
+ if group is None:
608
+ return False
609
+ return is_symmetric(np.asarray(self), symmetry=group, atol=atol, rtol=rtol)
610
+
611
+ # -- slicing with symmetry propagation --
612
+
613
+ def __getitem__(self, key): # type: ignore[override]
614
+ """Index with symmetry propagation.
615
+
616
+ Computes the pointwise-stabilizer subgroup for axes removed by
617
+ integer indexing, then restricts surviving groups to the output
618
+ axes. Returns a plain ``ndarray`` when no symmetry survives.
619
+ Emits :class:`~flopscope.errors.SymmetryLossWarning` on partial or
620
+ total symmetry loss.
621
+ """
622
+ result = super().__getitem__(key)
623
+ if not isinstance(result, np.ndarray) or result.ndim == 0:
624
+ return result if not isinstance(result, np.ndarray) else np.asarray(result)
625
+
626
+ if self._symmetry is None:
627
+ # Even with no input symmetry, multiple inserted None axes form a
628
+ # free symmetric group on those axes. Run the propagator with an
629
+ # empty groups list so the inserted-axis logic still fires.
630
+ new_groups = propagate_symmetry_slice([], self.shape, key)
631
+ if new_groups:
632
+ return _wrap_tensor_result(
633
+ np.asarray(result), _merge_symmetry_groups(new_groups)
634
+ )
635
+ return _asplainflopscope(np.asarray(result))
636
+
637
+ new_groups = propagate_symmetry_slice([self._symmetry], self.shape, key)
638
+ new_symmetry = _merge_symmetry_groups(new_groups or [])
639
+ if new_groups is not None:
640
+ # Fire only on real structural reduction: the new group's order is
641
+ # strictly less than the original's. This silences false alarms on
642
+ # operations that gain symmetry (e.g. `a[None, :, None, :]`
643
+ # produces a richer Young group) or merely relabel axes without
644
+ # losing structure (e.g. `a[None, :, :]` shifts axes, preserves
645
+ # order). It under-fires on the rare case where original sym is
646
+ # broken and a same-order new group is gained on different axes;
647
+ # the gained group is still attached to the result so a careful
648
+ # user can inspect `.symmetry` directly.
649
+ if (
650
+ new_symmetry is not None
651
+ and self._symmetry.axes is not None
652
+ and new_symmetry.order() < self._symmetry.order()
653
+ ):
654
+ _warn_symmetry_loss(
655
+ [self._symmetry.axes],
656
+ "slicing reduced symmetric group structure",
657
+ )
658
+ return _wrap_tensor_result(np.asarray(result), new_symmetry)
659
+
660
+ if self._symmetry.axes is not None:
661
+ _warn_symmetry_loss(
662
+ [self._symmetry.axes],
663
+ "slicing removed all symmetric dim groups",
664
+ )
665
+ return _asplainflopscope(np.asarray(result))
666
+
667
+ # -- copy preserves metadata --
668
+
669
+ def copy(self, order: str = "C") -> SymmetricTensor: # type: ignore[override]
670
+ out = super().copy(order=order).view(type(self)) # type: ignore[arg-type]
671
+ out._symmetry = self._symmetry
672
+ return out
673
+
674
+ def reshape(self, *shape, **kwargs): # type: ignore[override]
675
+ from flopscope._free_ops import reshape as _reshape
676
+
677
+ return _reshape(self, *shape, **kwargs)
678
+
679
+ def ravel(self, order: str = "C"): # type: ignore[override]
680
+ from flopscope._free_ops import ravel as _ravel
681
+
682
+ return _ravel(self, order=order)
683
+
684
+ def flatten(self, order: str = "C"): # type: ignore[override]
685
+ from flopscope._free_ops import ravel as _ravel
686
+ from flopscope._symmetry_transport import transport_ravel
687
+ from flopscope.errors import _warn_symmetry_loss
688
+
689
+ in_group = self._symmetry
690
+ if in_group is not None:
691
+ out_group = transport_ravel(in_group, input_shape=np.asarray(self).shape)
692
+ if out_group is None:
693
+ _warn_symmetry_loss(
694
+ lost_dims=[in_group.axes or tuple(range(in_group.degree))],
695
+ reason="flatten collapses to a single axis; block cannot fit",
696
+ )
697
+ # Pass the raw ndarray view so _ravel does not emit a second warning.
698
+ out = _ravel(np.asarray(self), order=order)
699
+ return np.array(out, copy=True)
700
+
701
+ def squeeze(self, axis=None): # type: ignore[override]
702
+ from flopscope._free_ops import squeeze as _squeeze
703
+
704
+ return _squeeze(self, axis=axis) # type: ignore[arg-type]
705
+
706
+ def astype( # type: ignore[override]
707
+ self,
708
+ dtype,
709
+ order: str = "K",
710
+ casting: str = "unsafe",
711
+ subok: bool = False,
712
+ copy: bool = True,
713
+ ):
714
+ return _asplainflopscope(
715
+ np.asarray(self).astype(
716
+ dtype,
717
+ order=order, # type: ignore[arg-type]
718
+ casting=casting, # type: ignore[arg-type]
719
+ subok=False,
720
+ copy=copy,
721
+ )
722
+ )
723
+
724
+ def transpose(self, *axes): # type: ignore[override]
725
+ if not axes or axes == (None,):
726
+ order = tuple(reversed(range(self.ndim)))
727
+ elif len(axes) == 1 and isinstance(axes[0], (tuple, list)):
728
+ order = tuple(axes[0])
729
+ else:
730
+ order = tuple(axes)
731
+ result = np.transpose(np.asarray(self), axes=order)
732
+ mapping = {old: new for new, old in enumerate(order)}
733
+ return _wrap_tensor_result(
734
+ result,
735
+ remap_group_axes(self._symmetry, mapping),
736
+ )
737
+
738
+ def swapaxes(self, axis1: int, axis2: int): # type: ignore[override]
739
+ order = list(range(self.ndim))
740
+ axis1 %= self.ndim
741
+ axis2 %= self.ndim
742
+ order[axis1], order[axis2] = order[axis2], order[axis1]
743
+ return self.transpose(tuple(order))
744
+
745
+ @property
746
+ def T(self):
747
+ return self.transpose()
748
+
749
+ # -- pickling --
750
+
751
+ def __reduce__(self):
752
+ pickled_state = super().__reduce__()
753
+ return (
754
+ pickled_state[0],
755
+ pickled_state[1],
756
+ pickled_state[2] + (self._symmetry,), # type: ignore[operator]
757
+ )
758
+
759
+ def __setstate__(self, state):
760
+ if len(state) < 2 or not isinstance(state[-1], SymmetryGroup):
761
+ raise ValueError("legacy symmetry payloads are not supported")
762
+ super().__setstate__(state[:-1])
763
+ self._symmetry = state[-1]
764
+
765
+
766
+ # ---------------------------------------------------------------------------
767
+ # Factory
768
+ # ---------------------------------------------------------------------------
769
+
770
+
771
+ def as_symmetric(
772
+ data: np.ndarray,
773
+ *,
774
+ symmetry,
775
+ ) -> SymmetricTensor:
776
+ """Wrap *data* as a :class:`SymmetricTensor` after validating symmetry.
777
+
778
+ Parameters
779
+ ----------
780
+ data : numpy.ndarray
781
+ The tensor data.
782
+ symmetry : SymmetryGroup or shorthand
783
+ Exact symmetry input accepted by :func:`normalize_symmetry_input`.
784
+
785
+ Returns
786
+ -------
787
+ SymmetricTensor
788
+ View of ``data`` carrying validated symmetry metadata.
789
+
790
+ Raises
791
+ ------
792
+ SymmetryError
793
+ If the data does not satisfy the claimed symmetry.
794
+
795
+ Examples
796
+ --------
797
+ >>> import flopscope as flops
798
+ >>> import flopscope.numpy as fnp
799
+ >>>
800
+ >>> matrix = fnp.array([[1.0, 2.0], [2.0, 3.0]])
801
+ >>> tagged = flops.as_symmetric(matrix, (0, 1))
802
+ >>> tagged.symmetric_axes
803
+ [(0, 1)]
804
+ """
805
+ group = _resolve_symmetry_argument(
806
+ data,
807
+ symmetry=symmetry,
808
+ )
809
+ assert group is not None # required=True raises if symmetry is None
810
+ array = np.asarray(data)
811
+ validate_symmetry_groups(array, [group])
812
+ return SymmetricTensor(array, symmetry=group)