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
flopscope/_ndarray.py ADDED
@@ -0,0 +1,1126 @@
1
+ """Subclass of ``numpy.ndarray`` that routes every operation through
2
+ flopscope's FLOP-counted ``me.*`` functions.
3
+
4
+ ``FlopscopeArray`` overrides:
5
+
6
+ - **Arithmetic / comparison / bitwise / shift dunders** so ``a + b``,
7
+ ``a @ b``, ``a == b``, ``a & b``, ``a << b`` etc. route through
8
+ ``me.add``, ``me.matmul``, etc.
9
+ - **In-place dunders** (``__iadd__``, …, ``__imatmul__``) with a
10
+ symmetry-corruption guard that refuses ``A_sym += B`` when the
11
+ result would weaken or destroy ``A_sym``'s tagged symmetry. ``A @= B``
12
+ on shape-changing matmul falls back to CPython's documented
13
+ rebind-the-name semantics.
14
+ - **25 ndarray methods** (``sum``, ``mean``, ``dot``, ``argsort``,
15
+ ``compress``, ``trace``, ``round``, ``clip``, ``ptp``, …) so
16
+ ``a.sum()`` and ``fnp.sum(a)`` produce the same FLOP count.
17
+ - **In-place ``sort`` / ``partition``** which refuse on a
18
+ ``SymmetricTensor`` (the reorder would break symmetry).
19
+
20
+ It also implements two NumPy protocols:
21
+
22
+ - ``__array_ufunc__`` (NEP 13) — ``np.add(a, b)``, ``np.add.reduce(a)``,
23
+ ``np.add.outer(a, b)``, ``np.add.at(a, idx, val)``, multi-output
24
+ ufuncs (``np.divmod`` / ``np.frexp`` / ``np.modf``), etc.
25
+ - ``__array_function__`` (NEP 18) — explicit allowlist routing
26
+ ``np.<func>(flopscope, …)`` for ~108 callables (reductions, sorting,
27
+ shape ops, linear algebra, comparisons, histograms, …) plus a
28
+ ``_PASSTHROUGH`` set of zero-FLOP type/shape queries.
29
+
30
+ Recursion-prevention helpers (``_to_base_ndarray``,
31
+ ``_to_base_ndarray_tree``) view-cast ``FlopscopeArray`` to plain
32
+ ``np.ndarray`` for the inner NumPy call, breaking the cycle that
33
+ would otherwise re-dispatch through these protocols.
34
+
35
+ Because ``FlopscopeArray`` inherits from ``numpy.ndarray``,
36
+ ``isinstance(x, numpy.ndarray)`` returns ``True``. All ``me.*``
37
+ functions return ``FlopscopeArray`` (or ``SymmetricTensor`` when symmetry
38
+ survives the operation). See PR #67 for the complete protocol design.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import functools as _functools
44
+ import inspect as _inspect
45
+ from typing import Any
46
+
47
+ import numpy as _np
48
+ from numpy.typing import DTypeLike
49
+
50
+
51
+ def _me():
52
+ """Lazy import of flopscope.numpy to avoid circular imports.
53
+
54
+ The dunder methods need fnp.add, fnp.multiply etc. — these counted
55
+ numpy ops live under flopscope.numpy in the JAX-style public API
56
+ (top-level flopscope exposes only primitives like BudgetContext).
57
+ Defer the import until first use to break the import cycle:
58
+ flopscope.numpy → flopscope._free_ops/etc. → this module.
59
+ """
60
+ import flopscope.numpy as _fnp
61
+
62
+ return _fnp
63
+
64
+
65
+ # Eagerly captured at import time so ``tests/numpy_compat`` monkeypatching
66
+ # (which replaces ``np.<name>`` with ``fnp.<name>``) doesn't accidentally
67
+ # install the flopscope-replacements into ``_PASSTHROUGH``. The set holds the
68
+ # *original* numpy callables.
69
+ _PASSTHROUGH_NAMES = (
70
+ # Zero-FLOP type/shape queries:
71
+ "ndim",
72
+ "shape",
73
+ "size",
74
+ # Zero-FLOP type-system queries (added by Task 4 for #62/#58 followup):
75
+ "result_type",
76
+ "can_cast",
77
+ "min_scalar_type",
78
+ "promote_types",
79
+ "find_common_type",
80
+ "mintypecode",
81
+ # Test-harness assertion that should not count FLOPs:
82
+ "array_equal",
83
+ # Zero-FLOP memory-layout queries (#72):
84
+ "may_share_memory",
85
+ "shares_memory",
86
+ "byte_bounds",
87
+ # Zero-FLOP boolean predicates (#72 audit):
88
+ "iscomplexobj",
89
+ "isrealobj",
90
+ "isfortran",
91
+ "isscalar",
92
+ # Zero-FLOP shape arithmetic (#72 audit):
93
+ "broadcast_shapes",
94
+ )
95
+
96
+
97
+ def _build_passthrough():
98
+ """Build the ``_PASSTHROUGH`` set, eagerly at import time."""
99
+ s = set()
100
+ for name in _PASSTHROUGH_NAMES:
101
+ fn = getattr(_np, name, None)
102
+ if fn is not None:
103
+ s.add(fn)
104
+ return s
105
+
106
+
107
+ _INITIAL_PASSTHROUGH = _build_passthrough()
108
+
109
+
110
+ class FlopscopeArray(_np.ndarray):
111
+ """A numpy ndarray subclass with FLOP-tracked operators.
112
+
113
+ Behaves exactly like numpy.ndarray except that arithmetic and
114
+ related operators route through flopscope's counted fnp.* functions
115
+ so the active BudgetContext sees them.
116
+ """
117
+
118
+ def __new__(
119
+ cls, shape, dtype=float, buffer=None, offset=0, strides=None, order=None
120
+ ):
121
+ return super().__new__(cls, shape, dtype, buffer, offset, strides, order)
122
+
123
+ def __array_finalize__(self, obj):
124
+ # Called when numpy creates a view or slice of this subclass.
125
+ # No subclass state to propagate.
126
+ pass
127
+
128
+ def __array_wrap__(self, out_arr, context=None, return_scalar=False):
129
+ """Honor numpy's `return_scalar` request from ufunc reductions.
130
+
131
+ When a ufunc reduction collapses to a single value (e.g.
132
+ ``np.bitwise_or.reduce(np.array([True], dtype=object))``), numpy
133
+ passes ``return_scalar=True`` to ``__array_wrap__`` so the caller
134
+ receives a Python scalar rather than a 0-d ndarray. The default
135
+ ndarray behaviour respects this flag; we forward it explicitly so
136
+ the same behaviour holds when the input is a FlopscopeArray.
137
+
138
+ For non-scalar results we let numpy preserve the subclass (the
139
+ default behaviour) so views, slices, and ufunc outputs stay
140
+ FlopscopeArrays — keeping operator overloads and FLOP tracking
141
+ intact for chained expressions.
142
+
143
+ Flopscope does not guarantee ndarray flag fidelity for subclass
144
+ results. In particular, view-casting a fresh ufunc result into a
145
+ subclass often reports ``OWNDATA=False`` because the subclass is a
146
+ view over the ufunc's base ndarray. We intentionally keep that
147
+ no-copy behaviour because ndarray-subclass operations are on a hot
148
+ path and avoiding extra copies is a higher priority than exact
149
+ flag parity with bare ndarrays.
150
+ """
151
+ if return_scalar:
152
+ return out_arr[()]
153
+ return super().__array_wrap__(out_arr, context, return_scalar)
154
+
155
+ # ----- numpy ufunc protocol (NEP 13) -----
156
+
157
+ _REDUCE_TO_WHEST = {
158
+ "add": "sum",
159
+ "multiply": "prod",
160
+ "maximum": "max",
161
+ "minimum": "min",
162
+ "logical_and": "all",
163
+ "logical_or": "any",
164
+ }
165
+ _ACCUMULATE_TO_WHEST = {
166
+ "add": "cumsum",
167
+ "multiply": "cumprod",
168
+ }
169
+
170
+ def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
171
+ """Route ufunc calls through flopscope's counted functions.
172
+
173
+ Triggered for:
174
+ - ``np.add(flopscope, x)`` → method='__call__'
175
+ - ``np.add.reduce(flopscope)`` → method='reduce'
176
+ - ``np.add.accumulate(flopscope)`` → method='accumulate'
177
+ - ``np.add.outer(flopscope, w2)`` → method='outer'
178
+ - ``np.add.reduceat(flopscope, ix)`` → method='reduceat'
179
+ - ``np.add.at(flopscope, ix, val)`` → method='at'
180
+
181
+ ``out`` is passed by NumPy as a tuple of length ``ufunc.nout``.
182
+ For single-output ufuncs we unwrap the 1-tuple to ``out=arr``;
183
+ for multi-output ufuncs (``divmod``, ``frexp``, ``modf``) we
184
+ forward the tuple as-is to the corresponding ``fnp.*`` wrapper,
185
+ which knows how to handle per-slot stripping and identity.
186
+
187
+ ``reduce`` / ``accumulate`` first try the optimized routing in
188
+ :attr:`_REDUCE_TO_WHEST` / :attr:`_ACCUMULATE_TO_WHEST`; on a
189
+ miss (e.g. ``np.subtract.reduce``) they fall back to a generic
190
+ path in :mod:`flopscope._pointwise` that strips, charges
191
+ :func:`reduction_cost`, and routes back through the raw
192
+ ``ufunc.<method>``. ``outer`` / ``reduceat`` / ``at`` always
193
+ use the generic path. ``ufunc.at`` refuses on SymmetricTensor
194
+ operands (the in-place fancy-index write would break symmetry).
195
+ """
196
+ from flopscope._budget import _called_from_wrapper
197
+
198
+ if _called_from_wrapper():
199
+ raise RuntimeError(
200
+ f"WhestArray reached numpy.{ufunc.__name__} from inside an fnp "
201
+ f"wrapper — missing _to_base_ndarray() strip. Check the "
202
+ f"calling fnp wrapper and add a strip before the numpy call."
203
+ )
204
+
205
+ # Emit one-time auto-route warning (de-duped per call site).
206
+ import warnings as _warnings
207
+
208
+ _warnings.warn(
209
+ f"np.{ufunc.__name__}(WhestArray) auto-routed to fnp.{ufunc.__name__}; "
210
+ f"call fnp.{ufunc.__name__} directly to avoid this warning.",
211
+ UserWarning,
212
+ stacklevel=2,
213
+ )
214
+
215
+ me = _me()
216
+
217
+ np_target_name = None # used to drive _filter_to_np_signature below
218
+ whest_fn = None
219
+ if method == "__call__":
220
+ whest_fn = getattr(me, ufunc.__name__, None)
221
+ np_target_name = ufunc.__name__
222
+ elif method == "reduce":
223
+ target = self._REDUCE_TO_WHEST.get(ufunc.__name__)
224
+ if target is not None:
225
+ whest_fn = getattr(me, target, None)
226
+ np_target_name = target
227
+ # NumPy's ufunc.reduce defaults to axis=0; flopscope's me.sum etc.
228
+ # default to axis=None (full reduction). Force NumPy default.
229
+ kwargs.setdefault("axis", 0)
230
+ elif method == "accumulate":
231
+ target = self._ACCUMULATE_TO_WHEST.get(ufunc.__name__)
232
+ if target is not None:
233
+ whest_fn = getattr(me, target, None)
234
+ np_target_name = target
235
+ kwargs.setdefault("axis", 0)
236
+
237
+ if whest_fn is not None:
238
+ # Optimized routing-table path: forward to the dedicated
239
+ # ``fnp.*`` wrapper.
240
+ #
241
+ # Unwrap single-output ``out`` tuple.
242
+ if out is not None:
243
+ if isinstance(out, tuple) and len(out) == 1:
244
+ kwargs["out"] = out[0]
245
+ else:
246
+ kwargs["out"] = out
247
+ # Filter kwargs against the target NumPy callable's signature so
248
+ # ufunc-internal kwargs (e.g. ``dtype=`` from np.all → np.add.reduce)
249
+ # don't reach a function-form flopscope wrapper that doesn't accept
250
+ # them.
251
+ if np_target_name is not None:
252
+ kwargs = _filter_to_np_signature(
253
+ getattr(_np, np_target_name, None), kwargs
254
+ )
255
+ return whest_fn(*inputs, **kwargs)
256
+
257
+ # Generic ufunc-method paths for ops without a dedicated flopscope
258
+ # equivalent. Lazy-imported to avoid the _ndarray ↔ _pointwise
259
+ # circular-dependency loop.
260
+ if method in ("outer", "reduce", "accumulate", "reduceat", "at"):
261
+ from flopscope._pointwise import (
262
+ _counted_ufunc_accumulate_generic,
263
+ _counted_ufunc_at,
264
+ _counted_ufunc_outer,
265
+ _counted_ufunc_reduce_generic,
266
+ _counted_ufunc_reduceat,
267
+ )
268
+
269
+ # Unwrap single-output ``out`` tuple for the generic paths
270
+ # too. ``ufunc.at`` doesn't take ``out``.
271
+ out_for_generic = out
272
+ if out is not None and isinstance(out, tuple) and len(out) == 1:
273
+ out_for_generic = out[0]
274
+
275
+ if method == "outer":
276
+ return _counted_ufunc_outer(
277
+ ufunc, *inputs, out=out_for_generic, **kwargs
278
+ )
279
+ if method == "reduce":
280
+ kwargs.setdefault("axis", 0)
281
+ return _counted_ufunc_reduce_generic(
282
+ ufunc, *inputs, out=out_for_generic, **kwargs
283
+ )
284
+ if method == "accumulate":
285
+ kwargs.setdefault("axis", 0)
286
+ return _counted_ufunc_accumulate_generic(
287
+ ufunc, *inputs, out=out_for_generic, **kwargs
288
+ )
289
+ if method == "reduceat":
290
+ return _counted_ufunc_reduceat(
291
+ ufunc, *inputs, out=out_for_generic, **kwargs
292
+ )
293
+ if method == "at":
294
+ # ``ufunc.at`` does not accept ``out=``.
295
+ return _counted_ufunc_at(ufunc, *inputs, **kwargs)
296
+
297
+ return NotImplemented
298
+
299
+ # ----- numpy array-function protocol (NEP 18) -----
300
+
301
+ # Lazy-built dispatch map. Populated on first __array_function__ call
302
+ # because flopscope's namespace uses lazy submodule loading.
303
+ _ARRAY_FUNCTION_DISPATCH: dict | None = None
304
+ _PASSTHROUGH: set | None = None
305
+
306
+ @classmethod
307
+ def _get_array_function_dispatch(cls):
308
+ """Build (once) and return the np-callable → flopscope-callable map."""
309
+ if cls._ARRAY_FUNCTION_DISPATCH is not None:
310
+ return cls._ARRAY_FUNCTION_DISPATCH
311
+
312
+ me = _me()
313
+ d: dict = {}
314
+
315
+ def _bind(np_attr_path, we_attr_path):
316
+ """Look up np.<path> and me.<path>, add to dispatch map.
317
+
318
+ Silently skip pairs where one side is missing (e.g. linalg
319
+ functions added in newer NumPy versions).
320
+ """
321
+ np_obj = _np
322
+ for part in np_attr_path.split("."):
323
+ np_obj = getattr(np_obj, part, None)
324
+ if np_obj is None:
325
+ return
326
+ we_obj = me
327
+ for part in we_attr_path.split("."):
328
+ we_obj = getattr(we_obj, part, None)
329
+ if we_obj is None:
330
+ return
331
+ if callable(np_obj) and callable(we_obj):
332
+ d[np_obj] = we_obj
333
+
334
+ # ----- Reductions -----
335
+ for name in (
336
+ "sum",
337
+ "prod",
338
+ "mean",
339
+ "min",
340
+ "max",
341
+ "std",
342
+ "var",
343
+ "all",
344
+ "any",
345
+ "cumsum",
346
+ "cumprod",
347
+ "argmin",
348
+ "argmax",
349
+ "ptp",
350
+ "median",
351
+ "average",
352
+ "percentile",
353
+ "quantile",
354
+ # NumPy 2.x retained ``amax``/``amin`` as exported aliases
355
+ # for ``max``/``min``. Bind them so ``np.amax(FlopscopeArray)``
356
+ # routes through flopscope instead of raising TypeError.
357
+ "amax",
358
+ "amin",
359
+ ):
360
+ _bind(name, name)
361
+
362
+ # ----- Sorting / selection -----
363
+ for name in (
364
+ "sort",
365
+ "argsort",
366
+ "lexsort",
367
+ "partition",
368
+ "argpartition",
369
+ "searchsorted",
370
+ "digitize",
371
+ ):
372
+ _bind(name, name)
373
+
374
+ # ----- Set / unique -----
375
+ for name in (
376
+ "unique",
377
+ "unique_all",
378
+ "unique_counts",
379
+ "unique_inverse",
380
+ "unique_values",
381
+ "in1d",
382
+ "isin",
383
+ "intersect1d",
384
+ "union1d",
385
+ "setdiff1d",
386
+ "setxor1d",
387
+ ):
388
+ _bind(name, name)
389
+
390
+ # ----- Free / structural (asarray excluded) -----
391
+ for name in (
392
+ "where",
393
+ "tile",
394
+ "repeat",
395
+ "flip",
396
+ "roll",
397
+ "pad",
398
+ "triu",
399
+ "tril",
400
+ "diagonal",
401
+ "broadcast_to",
402
+ "meshgrid",
403
+ "copy",
404
+ "astype",
405
+ "trace",
406
+ "diff",
407
+ "gradient",
408
+ "clip",
409
+ "round",
410
+ ):
411
+ _bind(name, name)
412
+
413
+ # ----- Shape / view ops -----
414
+ # The flopscope counterparts (``me.transpose``, ``me.swapaxes``,
415
+ # ``me.moveaxis``, etc.) handle ``SymmetricTensor`` axis
416
+ # remapping correctly via ``wrap_with_symmetry`` /
417
+ # ``remap_group_axes``. Routing through the allowlist preserves
418
+ # symmetry; PASSTHROUGH would silently strip it via
419
+ # ``_to_base_ndarray_tree`` before the raw NumPy call.
420
+ for name in (
421
+ "transpose",
422
+ "swapaxes",
423
+ "moveaxis",
424
+ "reshape",
425
+ "ravel",
426
+ "expand_dims",
427
+ "squeeze",
428
+ "concatenate",
429
+ "stack",
430
+ "vstack",
431
+ "hstack",
432
+ "column_stack",
433
+ "split",
434
+ "hsplit",
435
+ "vsplit",
436
+ "dsplit",
437
+ "atleast_1d",
438
+ "atleast_2d",
439
+ "atleast_3d",
440
+ "broadcast_to",
441
+ "matrix_transpose", # numpy 2.x ufunc-like
442
+ ):
443
+ _bind(name, name)
444
+
445
+ # ----- Linear algebra -----
446
+ for name in (
447
+ "dot",
448
+ "matmul",
449
+ "einsum",
450
+ "tensordot",
451
+ "inner",
452
+ "outer",
453
+ "cross",
454
+ ):
455
+ _bind(name, name)
456
+
457
+ # ----- Comparisons -----
458
+ for name in (
459
+ "allclose",
460
+ "isclose",
461
+ "array_equiv",
462
+ # NOTE: array_equal is in _PASSTHROUGH instead.
463
+ ):
464
+ _bind(name, name)
465
+
466
+ # ----- Histograms / counts -----
467
+ for name in (
468
+ "histogram",
469
+ "histogram2d",
470
+ "histogramdd",
471
+ "histogram_bin_edges",
472
+ "bincount",
473
+ "vander",
474
+ "apply_over_axes",
475
+ "piecewise",
476
+ ):
477
+ _bind(name, name)
478
+
479
+ # ----- linalg submodule -----
480
+ for name in (
481
+ "norm",
482
+ "solve",
483
+ "det",
484
+ "inv",
485
+ "pinv",
486
+ "eig",
487
+ "eigh",
488
+ "eigvals",
489
+ "eigvalsh",
490
+ "svd",
491
+ "qr",
492
+ "cholesky",
493
+ "matrix_rank",
494
+ "lstsq",
495
+ "multi_dot",
496
+ "matrix_power",
497
+ "slogdet",
498
+ ):
499
+ _bind(f"linalg.{name}", f"linalg.{name}")
500
+
501
+ cls._ARRAY_FUNCTION_DISPATCH = d
502
+ return d
503
+
504
+ @classmethod
505
+ def _get_passthrough(cls):
506
+ """Return the eagerly-captured passthrough set."""
507
+ if cls._PASSTHROUGH is not None:
508
+ return cls._PASSTHROUGH
509
+ cls._PASSTHROUGH = set(_INITIAL_PASSTHROUGH)
510
+ return cls._PASSTHROUGH
511
+
512
+ def __array_function__(self, func, types, args, kwargs):
513
+ """Route ``np.<func>(flopscope, ...)`` calls through flopscope.
514
+
515
+ Two distinct paths:
516
+
517
+ - **Inside an fnp wrapper (depth > 0):** A wrapper forgot to strip a
518
+ ``FlopscopeArray`` before calling raw numpy, and numpy's NEP 18
519
+ dispatch caught us. This is a flopscope bug (the polyval class from
520
+ issue #69). Raise ``RuntimeError`` at the leak site so the bug is
521
+ impossible to miss. PASSTHROUGH (zero-FLOP queries like np.shape,
522
+ np.ndim, np.size) is exempt — these are safe even inside a wrapper
523
+ and must not trigger the tripwire.
524
+
525
+ - **Top-level call (depth == 0):** A user wrote ``np.<func>(whest)``
526
+ directly. PASSTHROUGH set is checked first for zero-FLOP queries.
527
+ Otherwise we route through the allowlist to ``fnp.<func>`` AND emit
528
+ a ``UserWarning`` so the user knows to call ``fnp.<func>`` directly.
529
+ Warning is de-duped per call site by Python's ``warnings`` module.
530
+ """
531
+ # PASSTHROUGH first: zero-FLOP queries are always safe, even inside
532
+ # wrappers. Reordering: putting this BEFORE the tripwire avoids false
533
+ # positives when a wrapper queries np.shape(a)/np.ndim(a) on a
534
+ # still-FlopscopeArray input.
535
+ if func in self._get_passthrough():
536
+ stripped_args = _to_base_ndarray_tree(args)
537
+ stripped_kwargs = {k: _to_base_ndarray_tree(v) for k, v in kwargs.items()}
538
+ return func(*stripped_args, **stripped_kwargs)
539
+
540
+ from flopscope._budget import _called_from_wrapper
541
+
542
+ if _called_from_wrapper():
543
+ raise RuntimeError(
544
+ f"WhestArray reached numpy.{func.__name__} from inside an fnp "
545
+ f"wrapper — missing _to_base_ndarray() strip. Check the "
546
+ f"calling fnp wrapper and add a strip before the numpy call."
547
+ )
548
+
549
+ dispatch = self._get_array_function_dispatch()
550
+ we_func = dispatch.get(func)
551
+ if we_func is None:
552
+ return NotImplemented
553
+
554
+ import warnings as _warnings
555
+
556
+ _warnings.warn(
557
+ f"np.{func.__name__}(WhestArray) auto-routed to fnp.{func.__name__}; "
558
+ f"call fnp.{func.__name__} directly to avoid this warning.",
559
+ UserWarning,
560
+ stacklevel=2,
561
+ )
562
+ return we_func(*args, **kwargs)
563
+
564
+ # ----- ndarray method overrides (route through me.* for budget parity) -----
565
+ # We forward *args, **kwargs to be forward-compatible with NumPy's
566
+ # evolving method signatures (dtype, out, where, casting, keepdims, axis
567
+ # as positional or keyword).
568
+
569
+ def sum(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
570
+ return _me().sum(self, *args, **kwargs)
571
+
572
+ def mean(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
573
+ return _me().mean(self, *args, **kwargs)
574
+
575
+ def prod(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
576
+ return _me().prod(self, *args, **kwargs)
577
+
578
+ def min(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
579
+ return _me().min(self, *args, **kwargs)
580
+
581
+ def max(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
582
+ return _me().max(self, *args, **kwargs)
583
+
584
+ def std(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
585
+ return _me().std(self, *args, **kwargs)
586
+
587
+ def var(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
588
+ return _me().var(self, *args, **kwargs)
589
+
590
+ def all(self, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
591
+ return _me().all(self, *args, **kwargs)
592
+
593
+ def any(self, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
594
+ return _me().any(self, *args, **kwargs)
595
+
596
+ def cumsum(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
597
+ return _me().cumsum(self, *args, **kwargs)
598
+
599
+ def cumprod(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
600
+ return _me().cumprod(self, *args, **kwargs)
601
+
602
+ def argmin(self, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
603
+ return _me().argmin(self, *args, **kwargs)
604
+
605
+ def argmax(self, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
606
+ return _me().argmax(self, *args, **kwargs)
607
+
608
+ def ptp(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
609
+ return _me().ptp(self, *args, **kwargs)
610
+
611
+ def trace(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
612
+ return _me().trace(self, *args, **kwargs)
613
+
614
+ def round(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
615
+ return _me().round(self, *args, **kwargs)
616
+
617
+ def clip(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
618
+ return _me().clip(self, *args, **kwargs)
619
+
620
+ def astype( # type: ignore[override]
621
+ self,
622
+ dtype: DTypeLike,
623
+ order: Any = "K",
624
+ casting: Any = "unsafe",
625
+ subok: bool = True,
626
+ copy: bool = True,
627
+ ) -> FlopscopeArray:
628
+ return _np.ndarray.astype(
629
+ self, dtype, order=order, casting=casting, subok=subok, copy=copy
630
+ ) # type: ignore[return-value]
631
+
632
+ # ----- Other ndarray methods -----
633
+
634
+ def dot(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
635
+ return _me().dot(self, *args, **kwargs)
636
+
637
+ def conj(self) -> FlopscopeArray:
638
+ return _me().conjugate(self)
639
+
640
+ def conjugate(self) -> FlopscopeArray:
641
+ return _me().conjugate(self)
642
+
643
+ def argsort(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
644
+ return _me().argsort(self, *args, **kwargs)
645
+
646
+ def argpartition(self, kth: Any, *args: Any, **kwargs: Any) -> FlopscopeArray:
647
+ return _me().argpartition(self, kth, *args, **kwargs)
648
+
649
+ def take(self, indices: Any, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
650
+ return _me().take(self, indices, *args, **kwargs)
651
+
652
+ def repeat(self, repeats: Any, *args: Any, **kwargs: Any) -> FlopscopeArray:
653
+ return _me().repeat(self, repeats, *args, **kwargs)
654
+
655
+ def searchsorted(self, v: Any, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
656
+ return _me().searchsorted(self, v, *args, **kwargs)
657
+
658
+ def compress(self, condition: Any, *args: Any, **kwargs: Any) -> FlopscopeArray:
659
+ # ndarray.compress(condition) -> np.compress(condition, arr)
660
+ return _me().compress(condition, self, *args, **kwargs)
661
+
662
+ # In-place sort/partition: NumPy mutates self and returns None.
663
+ # Charge FLOPs through me.sort/partition, then copy result into self.
664
+ # Guard against in-place mutation that would silently break symmetry.
665
+
666
+ def _check_inplace_breaks_symmetry(self, op_name):
667
+ """Refuse in-place ops that would invalidate SymmetricTensor metadata.
668
+
669
+ ``self._symmetry`` is set by SymmetricTensor; plain FlopscopeArrays
670
+ do not have it (or it's None). Guarding via getattr keeps this
671
+ method valid on both subclasses without a forward reference.
672
+ """
673
+ sym = getattr(self, "_symmetry", None)
674
+ if sym is not None:
675
+ raise ValueError(
676
+ f"in-place {op_name} on a SymmetricTensor would break "
677
+ f"symmetry on axes {sym.axes}; call fnp.{op_name}(arr) for "
678
+ f"an unsymmetric copy instead."
679
+ )
680
+
681
+ def sort(self, *args: Any, **kwargs: Any) -> None:
682
+ self._check_inplace_breaks_symmetry("sort")
683
+ result = _me().sort(self, *args, **kwargs)
684
+ # Strip both self and result before np.copyto: keeps the
685
+ # invariant ("never pass a flopscope subclass to a raw NumPy call")
686
+ # explicit even though np.copyto is currently NOT in the
687
+ # __array_function__ allowlist.
688
+ _np.copyto(_to_base_ndarray(self), _to_base_ndarray(result))
689
+
690
+ def partition(self, kth: Any, *args: Any, **kwargs: Any) -> None:
691
+ self._check_inplace_breaks_symmetry("partition")
692
+ result = _me().partition(self, kth, *args, **kwargs)
693
+ _np.copyto(_to_base_ndarray(self), _to_base_ndarray(result))
694
+
695
+ def _inplace_from_result(self, result, op_name):
696
+ """Apply ``result`` into ``self`` in place; refuse if the
697
+ operation would destroy or weaken symmetry metadata.
698
+
699
+ Compare ``self.symmetry`` and ``result.symmetry`` directly via
700
+ ``SymmetryGroup.__eq__`` (PR #51 made these value-equal with an
701
+ identity short-circuit and per-instance canonical-action cache).
702
+ Scalar in-place ops (``a += 1.0``) keep every group identically,
703
+ so the comparison passes via the identity short-circuit and the
704
+ copy proceeds cleanly.
705
+
706
+ ``_to_base_ndarray(self)`` is required around ``np.copyto``
707
+ because ``np.copyto`` could otherwise dispatch via
708
+ ``__array_function__`` if it ever lands in the allowlist --
709
+ without the strip, the call would recurse back through flopscope.
710
+ """
711
+ self_sym = getattr(self, "_symmetry", None)
712
+ result_sym = getattr(result, "_symmetry", None)
713
+ if self_sym is not None:
714
+ if self_sym != result_sym:
715
+ raise ValueError(
716
+ f"in-place {op_name} would destroy or weaken symmetry "
717
+ f"metadata on axes {self_sym.axes} (result symmetry: "
718
+ f"{result_sym.axes if result_sym is not None else None}); "
719
+ f"use ``self = fnp.{op_name}(self, other)`` to accept the "
720
+ f"new result explicitly."
721
+ )
722
+ _np.copyto(_to_base_ndarray(self), _to_base_ndarray(result))
723
+ return self
724
+
725
+ # ----- Binary arithmetic -----
726
+
727
+ def __add__(self, other: Any) -> FlopscopeArray:
728
+ return _me().add(self, other)
729
+
730
+ def __radd__(self, other: Any) -> FlopscopeArray:
731
+ return _me().add(other, self)
732
+
733
+ def __iadd__(self, other: Any) -> FlopscopeArray:
734
+ result = _me().add(self, other)
735
+ return self._inplace_from_result(result, "add")
736
+
737
+ def __sub__(self, other: Any) -> FlopscopeArray: # type: ignore[override]
738
+ return _me().subtract(self, other)
739
+
740
+ def __rsub__(self, other: Any) -> FlopscopeArray: # type: ignore[override]
741
+ return _me().subtract(other, self)
742
+
743
+ def __isub__(self, other: Any) -> FlopscopeArray:
744
+ result = _me().subtract(self, other)
745
+ return self._inplace_from_result(result, "subtract")
746
+
747
+ def __mul__(self, other: Any) -> FlopscopeArray:
748
+ return _me().multiply(self, other)
749
+
750
+ def __rmul__(self, other: Any) -> FlopscopeArray:
751
+ return _me().multiply(other, self)
752
+
753
+ def __imul__(self, other: Any) -> FlopscopeArray:
754
+ result = _me().multiply(self, other)
755
+ return self._inplace_from_result(result, "multiply")
756
+
757
+ def __truediv__(self, other: Any) -> FlopscopeArray: # type: ignore[override]
758
+ return _me().true_divide(self, other)
759
+
760
+ def __rtruediv__(self, other: Any) -> FlopscopeArray:
761
+ return _me().true_divide(other, self)
762
+
763
+ def __itruediv__(self, other: Any) -> FlopscopeArray:
764
+ result = _me().true_divide(self, other)
765
+ return self._inplace_from_result(result, "true_divide")
766
+
767
+ def __floordiv__(self, other: Any) -> FlopscopeArray: # type: ignore[override]
768
+ return _me().floor_divide(self, other)
769
+
770
+ def __rfloordiv__(self, other: Any) -> FlopscopeArray:
771
+ return _me().floor_divide(other, self)
772
+
773
+ def __ifloordiv__(self, other: Any) -> FlopscopeArray:
774
+ result = _me().floor_divide(self, other)
775
+ return self._inplace_from_result(result, "floor_divide")
776
+
777
+ def __mod__(self, other: Any) -> FlopscopeArray:
778
+ return _me().mod(self, other)
779
+
780
+ def __rmod__(self, other: Any) -> FlopscopeArray:
781
+ return _me().mod(other, self)
782
+
783
+ def __imod__(self, other: Any) -> FlopscopeArray:
784
+ result = _me().mod(self, other)
785
+ return self._inplace_from_result(result, "mod")
786
+
787
+ def __pow__(self, other: Any) -> FlopscopeArray:
788
+ return _me().power(self, other)
789
+
790
+ def __rpow__(self, other: Any) -> FlopscopeArray:
791
+ return _me().power(other, self)
792
+
793
+ def __ipow__(self, other: Any) -> FlopscopeArray:
794
+ result = _me().power(self, other)
795
+ return self._inplace_from_result(result, "power")
796
+
797
+ def __matmul__(self, other: Any) -> FlopscopeArray:
798
+ return _me().matmul(self, other)
799
+
800
+ def __rmatmul__(self, other: Any) -> FlopscopeArray:
801
+ return _me().matmul(other, self)
802
+
803
+ def __imatmul__(self, other: Any) -> FlopscopeArray:
804
+ # __imatmul__ is special: matmul output shape may differ from
805
+ # self.shape, in which case in-place mutation is impossible.
806
+ # CPython's documented in-place fallback rebinds the name to the
807
+ # new (out-of-place) result. NumPy raises ValueError on shape
808
+ # mismatch; we follow the CPython fallback so typical pipelines
809
+ # using ``A @= B`` to grow state work cleanly.
810
+ result = _me().matmul(self, other)
811
+ if result.shape != self.shape:
812
+ return result
813
+ return self._inplace_from_result(result, "matmul")
814
+
815
+ # ----- Unary arithmetic -----
816
+
817
+ def __neg__(self) -> FlopscopeArray:
818
+ return _me().negative(self)
819
+
820
+ def __pos__(self) -> FlopscopeArray:
821
+ return _me().positive(self)
822
+
823
+ def __abs__(self) -> FlopscopeArray:
824
+ return _me().abs(self)
825
+
826
+ def __invert__(self) -> FlopscopeArray:
827
+ return _me().invert(self)
828
+
829
+ # ----- Comparison -----
830
+
831
+ def __eq__(self, other: Any) -> FlopscopeArray:
832
+ return _me().equal(self, other)
833
+
834
+ def __ne__(self, other: Any) -> FlopscopeArray:
835
+ return _me().not_equal(self, other)
836
+
837
+ def __lt__(self, other: Any) -> FlopscopeArray:
838
+ return _me().less(self, other)
839
+
840
+ def __le__(self, other: Any) -> FlopscopeArray:
841
+ return _me().less_equal(self, other)
842
+
843
+ def __gt__(self, other: Any) -> FlopscopeArray:
844
+ return _me().greater(self, other)
845
+
846
+ def __ge__(self, other: Any) -> FlopscopeArray:
847
+ return _me().greater_equal(self, other)
848
+
849
+ def __hash__(self) -> int: # type: ignore[override]
850
+ # numpy ndarray is unhashable; preserve that.
851
+ raise TypeError(f"unhashable type: '{type(self).__name__}'")
852
+
853
+ # ----- Bitwise -----
854
+
855
+ def __and__(self, other: Any) -> FlopscopeArray:
856
+ return _me().bitwise_and(self, other)
857
+
858
+ def __rand__(self, other: Any) -> FlopscopeArray:
859
+ return _me().bitwise_and(other, self)
860
+
861
+ def __iand__(self, other: Any) -> FlopscopeArray:
862
+ result = _me().bitwise_and(self, other)
863
+ return self._inplace_from_result(result, "bitwise_and")
864
+
865
+ def __or__(self, other: Any) -> FlopscopeArray:
866
+ return _me().bitwise_or(self, other)
867
+
868
+ def __ror__(self, other: Any) -> FlopscopeArray:
869
+ return _me().bitwise_or(other, self)
870
+
871
+ def __ior__(self, other: Any) -> FlopscopeArray:
872
+ result = _me().bitwise_or(self, other)
873
+ return self._inplace_from_result(result, "bitwise_or")
874
+
875
+ def __xor__(self, other: Any) -> FlopscopeArray:
876
+ return _me().bitwise_xor(self, other)
877
+
878
+ def __rxor__(self, other: Any) -> FlopscopeArray:
879
+ return _me().bitwise_xor(other, self)
880
+
881
+ def __ixor__(self, other: Any) -> FlopscopeArray:
882
+ result = _me().bitwise_xor(self, other)
883
+ return self._inplace_from_result(result, "bitwise_xor")
884
+
885
+ def __lshift__(self, other: Any) -> FlopscopeArray:
886
+ return _me().left_shift(self, other)
887
+
888
+ def __rlshift__(self, other: Any) -> FlopscopeArray:
889
+ return _me().left_shift(other, self)
890
+
891
+ def __ilshift__(self, other: Any) -> FlopscopeArray:
892
+ result = _me().left_shift(self, other)
893
+ return self._inplace_from_result(result, "left_shift")
894
+
895
+ def __rshift__(self, other: Any) -> FlopscopeArray:
896
+ return _me().right_shift(self, other)
897
+
898
+ def __rrshift__(self, other: Any) -> FlopscopeArray:
899
+ return _me().right_shift(other, self)
900
+
901
+ def __irshift__(self, other: Any) -> FlopscopeArray:
902
+ result = _me().right_shift(self, other)
903
+ return self._inplace_from_result(result, "right_shift")
904
+
905
+
906
+ def wrap_module_returns(module, skip_names=None, check_module=True):
907
+ """Patch all public callables in a module to wrap return values.
908
+
909
+ Walks the module's namespace, finds public functions defined in
910
+ that module, and replaces them with wrappers that convert any
911
+ numpy.ndarray return value into a FlopscopeArray (zero-copy view).
912
+
913
+ Tuple/list of arrays are also handled element-wise.
914
+
915
+ Args:
916
+ module: The module object to patch.
917
+ skip_names: Optional set of function names to leave unwrapped
918
+ (e.g. functions that return scalars or shape tuples).
919
+ check_module: If True (default), only wrap functions whose
920
+ __module__ matches the module being patched.
921
+ Set to False for modules that re-export from
922
+ sub-modules (e.g. flopscope.numpy.linalg).
923
+ """
924
+ import functools
925
+
926
+ skip = set(skip_names or ())
927
+
928
+ for name in list(vars(module)):
929
+ if name.startswith("_") or name in skip:
930
+ continue
931
+ obj = getattr(module, name)
932
+ if not callable(obj):
933
+ continue
934
+ if check_module:
935
+ if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
936
+ continue
937
+
938
+ original = obj
939
+
940
+ @functools.wraps(original)
941
+ def wrapped(*args, _orig=original, **kwargs):
942
+ result = _orig(*args, **kwargs)
943
+ if isinstance(result, _np.ndarray):
944
+ return _asflopscope(result)
945
+ if isinstance(result, tuple):
946
+ wrapped_elems = [
947
+ _asflopscope(r) if isinstance(r, _np.ndarray) else r for r in result
948
+ ]
949
+ # Preserve named tuple type (e.g. UniqueAllResult).
950
+ if type(result) is not tuple and hasattr(type(result), "_fields"):
951
+ return type(result)(*wrapped_elems)
952
+ return tuple(wrapped_elems)
953
+ if isinstance(result, list):
954
+ return [
955
+ _asflopscope(r) if isinstance(r, _np.ndarray) else r for r in result
956
+ ]
957
+ return result
958
+
959
+ wrapped.__wrapped__ = original
960
+
961
+ setattr(module, name, wrapped)
962
+
963
+
964
+ def _asflopscope(x):
965
+ """Convert any array-like to FlopscopeArray without forcing ownership copies.
966
+
967
+ - FlopscopeArray: returned as-is
968
+ - numpy.ndarray subclass (e.g. SymmetricTensor): returned as-is to
969
+ preserve subclass metadata
970
+ - plain numpy.ndarray: view-cast to FlopscopeArray (zero-copy)
971
+ - other: np.asarray first, then view-cast (also zero-copy with
972
+ respect to the ndarray returned by ``np.asarray``)
973
+
974
+ Flopscope deliberately does not promise ``OWNDATA`` parity for subclass
975
+ results. Avoiding extra copies is preferred because this conversion
976
+ sits on a hot path for many small-array operations.
977
+ """
978
+ if isinstance(x, FlopscopeArray):
979
+ return x
980
+ if type(x) is not _np.ndarray and isinstance(x, _np.ndarray):
981
+ # Other ndarray subclass (e.g. SymmetricTensor) — preserve as-is.
982
+ return x
983
+ if isinstance(x, _np.ndarray):
984
+ return x.view(FlopscopeArray)
985
+ arr = _np.asarray(x)
986
+ return arr.view(FlopscopeArray)
987
+
988
+
989
+ def _asplainflopscope(x):
990
+ """Convert any array-like to a base FlopscopeArray.
991
+
992
+ Unlike :func:`_asflopscope`, this always drops ndarray subclasses so callers can
993
+ explicitly return a plain tracked array after metadata becomes invalid.
994
+ As with :func:`_asflopscope`, this is intentionally no-copy: the result may
995
+ report ``OWNDATA=False`` even when the underlying base ndarray owns the
996
+ data.
997
+ """
998
+ arr = _np.asarray(x)
999
+ return arr.view(FlopscopeArray)
1000
+
1001
+
1002
+ def _to_base_ndarray(x):
1003
+ """View a flopscope array as a plain ``np.ndarray`` (zero-copy).
1004
+
1005
+ Distinct from :func:`_asplainflopscope` (which returns a ``FlopscopeArray`` —
1006
+ still a numpy subclass that triggers ``__array_function__``).
1007
+ Required before calling ``_np.<func>(x)`` from inside our own
1008
+ ``__array_ufunc__`` / ``__array_function__`` handlers, so that
1009
+ NumPy's protocol dispatch (which would route FlopscopeArray inputs back
1010
+ through ``me.<func>``) does not recurse infinitely.
1011
+
1012
+ Only ``FlopscopeArray`` instances (and subclasses like
1013
+ ``SymmetricTensor``) are stripped — other ``numpy.ndarray``
1014
+ subclasses (e.g. ``np.matrix``, user-defined ``ArraySubclass``)
1015
+ pass through unchanged so NumPy's standard subclass-propagation
1016
+ behaviour preserves their type on the result.
1017
+
1018
+ Non-array inputs (Python scalars, lists) pass through unchanged so
1019
+ that NEP 50 weak-typing rules continue to apply when flopscope wrappers
1020
+ forward these to NumPy.
1021
+
1022
+ Examples
1023
+ --------
1024
+ >>> a = FlopscopeArray((4,), dtype=float)
1025
+ >>> type(_to_base_ndarray(a)) is _np.ndarray
1026
+ True
1027
+ >>> _to_base_ndarray(2.0) == 2.0
1028
+ True
1029
+ """
1030
+ if isinstance(x, FlopscopeArray):
1031
+ return x.view(_np.ndarray)
1032
+ return x
1033
+
1034
+
1035
+ def _to_base_ndarray_tree(x):
1036
+ """Recursively strip flopscope subclasses from arrays inside tuples/lists.
1037
+
1038
+ Use for arguments that accept *containers* of arrays passed through
1039
+ ``__array_function__``:
1040
+ - ``np.lexsort(keys)`` — keys is a sequence of arrays.
1041
+ - ``np.meshgrid(*xi)`` — xi is a tuple of arrays.
1042
+ - ``np.concatenate(arrays)`` / ``np.stack(arrays)`` — arrays is a sequence.
1043
+ - ``out=(out1, out2)`` — multi-output ufunc out tuples.
1044
+
1045
+ Plain ``_to_base_ndarray`` only handles a single array; using it on
1046
+ a list of FlopscopeArrays leaves the inner FlopscopeArrays intact and
1047
+ recursion can still happen through them.
1048
+
1049
+ Preserves namedtuples (e.g. ``UniqueAllResult``) by re-constructing
1050
+ the type with stripped fields.
1051
+
1052
+ Intentional scope: tuples, lists, namedtuples, and bare arrays only.
1053
+ Dicts and other generic mappings are NOT recursed into.
1054
+
1055
+ Only ``FlopscopeArray`` instances (and subclasses like
1056
+ ``SymmetricTensor``) are stripped — other ``numpy.ndarray``
1057
+ subclasses (e.g. ``np.matrix``, user-defined ``ArraySubclass``)
1058
+ pass through unchanged so NumPy's standard subclass-propagation
1059
+ behaviour preserves their type on the result.
1060
+ """
1061
+ if isinstance(x, FlopscopeArray):
1062
+ return x.view(_np.ndarray)
1063
+ if isinstance(x, tuple):
1064
+ stripped = tuple(_to_base_ndarray_tree(e) for e in x)
1065
+ # Preserve namedtuple type if present.
1066
+ if type(x) is not tuple and hasattr(type(x), "_fields"):
1067
+ return type(x)(*stripped)
1068
+ return stripped
1069
+ if isinstance(x, list):
1070
+ return [_to_base_ndarray_tree(e) for e in x]
1071
+ return x
1072
+
1073
+
1074
+ @_functools.cache
1075
+ def _signature_kwargs_accepted(np_func):
1076
+ """Return frozenset of kwarg names accepted by ``np_func``.
1077
+
1078
+ Returns:
1079
+ - ``None`` if ``np_func`` is ``None`` or signature inspection fails.
1080
+ - Empty frozenset if ``np_func`` accepts ``**kwargs`` (sentinel meaning
1081
+ "accepts every kwarg name; do not filter").
1082
+ - Otherwise a frozenset of accepted parameter names.
1083
+
1084
+ Cached: NumPy callable identities are stable for the process
1085
+ lifetime, so ``@functools.cache`` is safe and necessary on this hot
1086
+ path. PR #51 memoized similar per-call introspection
1087
+ (``unique_elements_for_shape``, ``_canonical_axis_action``); this
1088
+ follows the same pattern. See PR #51 perf comment for why per-call
1089
+ ``inspect.signature`` is unacceptable.
1090
+ """
1091
+ if np_func is None:
1092
+ return None
1093
+ try:
1094
+ sig = _inspect.signature(np_func)
1095
+ except (ValueError, TypeError):
1096
+ return None
1097
+ for p in sig.parameters.values():
1098
+ if p.kind == _inspect.Parameter.VAR_KEYWORD:
1099
+ return frozenset() # sentinel: accepts everything
1100
+ return frozenset(
1101
+ n
1102
+ for n, p in sig.parameters.items()
1103
+ if p.kind != _inspect.Parameter.VAR_POSITIONAL
1104
+ )
1105
+
1106
+
1107
+ def _filter_to_np_signature(np_func, kwargs):
1108
+ """Drop kwargs that ``np_func`` does not accept.
1109
+
1110
+ NumPy's ``ufunc.reduce`` always supplies ``dtype=`` / ``keepdims=`` /
1111
+ ``out=``, but the equivalent function-form wrapper (``np.all``,
1112
+ ``np.any``, etc.) accepts only a subset. Forwarding the full
1113
+ ufunc-reduce kwarg set to those function-form wrappers raises
1114
+ ``TypeError``.
1115
+
1116
+ Falls back to the original kwargs when ``inspect.signature`` cannot
1117
+ introspect (e.g. C-implemented functions). Per-function signature
1118
+ lookup is cached via :func:`_signature_kwargs_accepted` — this is on
1119
+ the per-ufunc-call hot path; uncached lookup would be a perf cliff.
1120
+ """
1121
+ accepted = _signature_kwargs_accepted(np_func)
1122
+ if accepted is None:
1123
+ return kwargs
1124
+ if not accepted: # empty frozenset = sentinel for **kwargs accepted
1125
+ return kwargs
1126
+ return {k: v for k, v in kwargs.items() if k in accepted}