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,2522 @@
1
+ """Counted pointwise operations and reductions for flopscope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins as _builtins
6
+ import functools as _functools
7
+ import inspect as _inspect
8
+ import warnings as _warnings
9
+ from math import prod as _math_prod
10
+ from typing import Any
11
+
12
+ import numpy as _np
13
+ from numpy.typing import ArrayLike
14
+
15
+ from flopscope._budget import _call_numpy, _counted_wrapper
16
+ from flopscope._config import get_setting as _get_setting
17
+ from flopscope._docstrings import attach_docstring
18
+ from flopscope._flops import _ceil_log2
19
+ from flopscope._flops import (
20
+ analytical_pointwise_cost as pointwise_cost,
21
+ )
22
+ from flopscope._flops import (
23
+ analytical_reduction_cost as reduction_cost,
24
+ )
25
+ from flopscope._ndarray import (
26
+ FlopscopeArray,
27
+ _asflopscope,
28
+ _to_base_ndarray,
29
+ _to_base_ndarray_tree,
30
+ )
31
+ from flopscope._perm_group import _DiminoBudgetExceeded
32
+ from flopscope._symmetric import SymmetricTensor
33
+ from flopscope._symmetric import is_symmetric as _is_symmetric
34
+ from flopscope._symmetry_utils import (
35
+ broadcast_group,
36
+ direct_product_groups,
37
+ intersect_groups,
38
+ reduce_group,
39
+ remap_group_axes,
40
+ restrict_group_to_axes,
41
+ unique_elements_for_shape,
42
+ )
43
+ from flopscope._validation import maybe_check_nan_inf, require_budget
44
+ from flopscope.errors import (
45
+ CostFallbackWarning,
46
+ SymmetryError,
47
+ UnsupportedFunctionError,
48
+ _warn_symmetry_loss,
49
+ )
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Factory helpers
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _symmetry_of(value):
57
+ return value.symmetry if isinstance(value, SymmetricTensor) else None
58
+
59
+
60
+ def _supports_out_argument(np_func) -> bool:
61
+ if isinstance(np_func, _np.ufunc):
62
+ return True
63
+ try:
64
+ return "out" in _inspect.signature(np_func).parameters
65
+ except (TypeError, ValueError):
66
+ return False
67
+
68
+
69
+ def _prepare_symmetric_out(out, target_symmetry):
70
+ if not isinstance(out, SymmetricTensor):
71
+ return target_symmetry
72
+ carried_symmetry = out.symmetry
73
+ inferred = getattr(out, "_symmetry_inferred", False)
74
+ if target_symmetry is None:
75
+ if inferred:
76
+ return None
77
+ raise ValueError("out symmetry does not match result symmetry")
78
+ if carried_symmetry is not None and carried_symmetry != target_symmetry:
79
+ if inferred:
80
+ return None
81
+ raise ValueError("out symmetry does not match result symmetry")
82
+
83
+ if not _is_symmetric(_np.asarray(out), symmetry=target_symmetry):
84
+ if inferred:
85
+ return None
86
+ axes = target_symmetry.axes
87
+ if axes is None:
88
+ axes = tuple(range(target_symmetry.degree))
89
+ raise SymmetryError(axes=tuple(axes), max_deviation=float("inf"))
90
+ return target_symmetry
91
+
92
+
93
+ def _validate_result_symmetry(result, symmetry):
94
+ if symmetry is None:
95
+ return
96
+ result_arr = _np.asarray(result)
97
+ # Skip numerical validation when the result has non-finite entries:
98
+ # np.allclose treats inf-inf=nan as not-close, which would raise a
99
+ # false SymmetryError. The symmetry was already enforced structurally
100
+ # by the (symmetric) inputs; numerical checks on inf/nan are meaningless.
101
+ if not _np.all(_np.isfinite(result_arr)):
102
+ return
103
+ if not _is_symmetric(result_arr, symmetry=symmetry):
104
+ axes = symmetry.axes
105
+ if axes is None:
106
+ axes = tuple(range(symmetry.degree))
107
+ raise SymmetryError(axes=tuple(axes), max_deviation=float("inf"))
108
+
109
+
110
+ def _is_oversized_for_cost_model(group):
111
+ """``True`` if walking ``group``'s elements would be prohibitively slow.
112
+
113
+ Uses ``group.order()`` against the configured ``dimino_budget``.
114
+ For known-kind groups, ``order()`` is O(1) closed form (#71) — the
115
+ check is cheap. For unknown-kind groups, ``order()`` runs ``_dimino``;
116
+ if it exceeds the budget mid-enumeration, ``_DiminoBudgetExceeded``
117
+ raises and we treat the group as oversized.
118
+ """
119
+ if group is None:
120
+ return False
121
+ budget = int(_get_setting("dimino_budget")) # type: ignore[arg-type]
122
+ try:
123
+ return group.order() > budget
124
+ except _DiminoBudgetExceeded:
125
+ return True
126
+
127
+
128
+ @_functools.cache
129
+ def _seen_oversized(op_name: str, group_order: int) -> bool:
130
+ """Return ``True`` once per ``(op, |G|)`` pair, ``False`` thereafter.
131
+
132
+ Used by :func:`_warn_oversized_once` to dedup warnings per
133
+ process. The ``lru_cache`` does the deduplication; we use the
134
+ miss-vs-hit discipline at the call site (see that function).
135
+ """
136
+ return True
137
+
138
+
139
+ def _warn_oversized_once(op_name: str, group_order: int) -> None:
140
+ """Emit :class:`CostFallbackWarning` once per ``(op_name, |G|)``.
141
+
142
+ Hot paths (e.g. numpy compat tests doing thousands of ufunc calls
143
+ on the same auto-inferred ``S_n`` symmetry) would otherwise spam
144
+ one warning per call. The warning fires once per process for each
145
+ ``(op, |G|)`` pair so users get the diagnostic without log
146
+ flooding.
147
+
148
+ Honours ``flops.configure(symmetry_warnings=False)`` — shares the
149
+ flag with :class:`SymmetryLossWarning` since both are
150
+ symmetry-related diagnostics.
151
+ """
152
+ if not _get_setting("symmetry_warnings"):
153
+ return
154
+ info_before = _seen_oversized.cache_info()
155
+ _seen_oversized(op_name, group_order)
156
+ if _seen_oversized.cache_info().hits > info_before.hits:
157
+ return # already warned for this (op, |G|) pair
158
+ budget = int(_get_setting("dimino_budget")) # type: ignore[arg-type]
159
+ _warnings.warn(
160
+ f"{op_name}: skipping symmetry-aware cost adjustment for a "
161
+ f"SymmetryGroup of order {group_order} (budget {budget}); "
162
+ f"charging dense cost. Group enumeration would exceed the budget. "
163
+ f"Suppress with flops.configure(symmetry_warnings=False).",
164
+ CostFallbackWarning,
165
+ stacklevel=4,
166
+ )
167
+
168
+
169
+ def _symmetry_adjusted_cost(dense_cost, output_shape, output_symmetry):
170
+ """Scale a dense FLOP cost by the output's symmetry-savings ratio.
171
+
172
+ Placeholder model: for an output of shape ``output_shape`` with
173
+ permutation symmetry ``output_symmetry``, the number of *unique*
174
+ elements is at most ``unique_elements_for_shape(output_symmetry,
175
+ output_shape)``. We scale the dense cost by ``unique / dense`` so
176
+ the budget reflects the symmetry savings a symmetry-aware
177
+ implementation could realise.
178
+
179
+ For non-symmetric outputs, the ratio is ``1.0`` and ``cost ==
180
+ dense_cost`` (no behaviour change for users without
181
+ SymmetricTensor inputs). For symmetric outputs, the ratio drops
182
+ below 1 and captures redundant-element savings.
183
+
184
+ TODO: this is a placeholder. The real algorithmic cost depends on
185
+ whether the underlying NumPy call (or the flopscope wrapper) actually
186
+ skips redundant work — today, our wrappers compute the dense
187
+ output and discard the duplicates. Replace with a per-op
188
+ algorithmic-cost model when one is available.
189
+ """
190
+ if output_symmetry is None:
191
+ return int(dense_cost)
192
+ # Use the Python builtins to avoid the module-level ``max`` /
193
+ # ``prod`` reduction wrappers that shadow them in this module.
194
+ dense_output = _builtins.max(_math_prod(output_shape), 1)
195
+ if dense_output <= 1:
196
+ return int(dense_cost)
197
+ unique = unique_elements_for_shape(output_symmetry, output_shape)
198
+ if unique >= dense_output:
199
+ return int(dense_cost)
200
+ # Integer-division form avoids float drift on large arrays.
201
+ return _builtins.max(int(dense_cost) * int(unique) // dense_output, 1)
202
+
203
+
204
+ def _call_with_optional_out(np_func, *args, out=None, supports_out=False, **kwargs):
205
+ # Strip flopscope subclasses (FlopscopeArray / SymmetricTensor) from arrays so
206
+ # the raw NumPy call does not re-dispatch through ``__array_ufunc__`` /
207
+ # ``__array_function__`` and recurse infinitely. Python scalars and
208
+ # other non-array values pass through unchanged so NEP 50 weak-typing
209
+ # rules continue to apply at the NumPy boundary.
210
+ args = tuple(_to_base_ndarray(a) for a in args)
211
+ # ``where=`` kwarg may be a FlopscopeArray bool mask; strip it. Other
212
+ # array-valued kwargs (e.g. ``axes`` lists for matmul / einsum
213
+ # tensor-axis specs) typically aren't ndarrays, but tree-strip is
214
+ # cheap and safe for nested arg containers.
215
+ for k, v in list(kwargs.items()):
216
+ if isinstance(v, _np.ndarray):
217
+ kwargs[k] = _to_base_ndarray(v)
218
+ elif isinstance(v, (tuple, list)):
219
+ kwargs[k] = _to_base_ndarray_tree(v)
220
+ out_stripped = _to_base_ndarray(out) if out is not None else None
221
+ if out is None:
222
+ return _call_numpy(np_func, *args, **kwargs)
223
+ if supports_out:
224
+ return _call_numpy(np_func, *args, out=out_stripped, **kwargs)
225
+ result = _call_numpy(np_func, *args, **kwargs)
226
+ # Fallback copy when np_func doesn't natively support out=. This is
227
+ # flopscope's overhead, NOT routed through _call_numpy.
228
+ _np.copyto(out_stripped, _np.asarray(result), casting="unsafe") # type: ignore[arg-type]
229
+ return out
230
+
231
+
232
+ def _call_with_optional_multi_out(np_func, *args, out=None, nout, **kwargs):
233
+ """Multi-output sibling of :func:`_call_with_optional_out`.
234
+
235
+ ``out`` is either ``None`` (numpy allocates all outputs) or a tuple of
236
+ length ``nout``. Each slot is either an ndarray write-target or
237
+ ``None`` (let numpy allocate that one slot).
238
+
239
+ Returns a tuple of length ``nout``. Identity is preserved per-slot:
240
+ if the caller supplied a non-``None`` array at slot *i*, the
241
+ returned tuple's *i*-th element is exactly the same object. ``None``
242
+ slots are filled with the freshly-allocated plain ndarray that numpy
243
+ returned.
244
+ """
245
+ args = tuple(_to_base_ndarray(a) for a in args)
246
+ for k, v in list(kwargs.items()):
247
+ if isinstance(v, _np.ndarray):
248
+ kwargs[k] = _to_base_ndarray(v)
249
+ elif isinstance(v, (tuple, list)):
250
+ kwargs[k] = _to_base_ndarray_tree(v)
251
+ if out is None:
252
+ return _call_numpy(np_func, *args, **kwargs)
253
+ if not isinstance(out, tuple) or len(out) != nout:
254
+ length_repr = len(out) if hasattr(out, "__len__") else "?"
255
+ raise TypeError(
256
+ f"multi-output {getattr(np_func, '__name__', '?')} requires "
257
+ f"out= to be a tuple of length {nout}; got "
258
+ f"{type(out).__name__} of length {length_repr}"
259
+ )
260
+ stripped = tuple(_to_base_ndarray(o) if o is not None else None for o in out)
261
+ result = _call_numpy(np_func, *args, out=stripped, **kwargs)
262
+ # Numpy returns a tuple of the stripped buffers (or fresh allocations
263
+ # for None slots). Replace each non-None slot with the caller's
264
+ # original to preserve object identity.
265
+ return tuple(
266
+ orig if orig is not None else r for orig, r in zip(out, result, strict=True)
267
+ )
268
+
269
+
270
+ def _wrap_result(result, *, out=None, symmetry=None):
271
+ if out is not None:
272
+ if not isinstance(out, SymmetricTensor):
273
+ _validate_result_symmetry(result, symmetry)
274
+ return out
275
+ effective_symmetry = _prepare_symmetric_out(out, symmetry)
276
+ _validate_result_symmetry(result, effective_symmetry)
277
+ _np.copyto(_np.asarray(out), _np.asarray(result), casting="unsafe")
278
+ return out
279
+ if symmetry is not None:
280
+ return SymmetricTensor(_np.asarray(result), symmetry=symmetry)
281
+ return _asflopscope(result)
282
+
283
+
284
+ def _wrap_multi_result(result, *, out=None, symmetry=None):
285
+ """Wrap each element of a multi-output result tuple.
286
+
287
+ For elementwise multi-output ufuncs (``divmod`` / ``frexp`` /
288
+ ``modf``), every output inherits the same ``symmetry`` as the
289
+ (broadcast) input. ``out`` is an optional tuple of caller-provided
290
+ write targets matching ``result`` 1:1; ``None`` slots get fresh
291
+ wrappers, non-``None`` slots get identity + symmetry validation
292
+ routed through :func:`_wrap_result`.
293
+ """
294
+ if not isinstance(result, tuple):
295
+ return _wrap_result(result, out=out, symmetry=symmetry)
296
+ if out is None:
297
+ return tuple(_wrap_result(part, symmetry=symmetry) for part in result)
298
+ return tuple(
299
+ _wrap_result(part, out=o, symmetry=symmetry)
300
+ for part, o in zip(result, out, strict=True)
301
+ )
302
+
303
+
304
+ def _pointwise_symmetry(operands, output_shape):
305
+ aligned_groups = []
306
+ dense_operand_present = False
307
+
308
+ for operand, symmetry in operands:
309
+ if operand.ndim == 0:
310
+ continue
311
+ if symmetry is None:
312
+ dense_operand_present = True
313
+ continue
314
+ aligned = broadcast_group(
315
+ symmetry,
316
+ input_shape=operand.shape,
317
+ output_shape=output_shape,
318
+ )
319
+ if aligned is not None:
320
+ aligned_groups.append(aligned)
321
+
322
+ if not aligned_groups:
323
+ return None, []
324
+ if dense_operand_present:
325
+ return None, aligned_groups
326
+
327
+ output_symmetry = aligned_groups[0]
328
+ for aligned in aligned_groups[1:]:
329
+ output_symmetry = intersect_groups(
330
+ output_symmetry,
331
+ aligned,
332
+ ndim=len(output_shape),
333
+ )
334
+ if output_symmetry is None:
335
+ break
336
+ return output_symmetry, aligned_groups
337
+
338
+
339
+ @_counted_wrapper
340
+ def _counted_unary(np_func, op_name: str):
341
+ supports_out = _supports_out_argument(np_func)
342
+
343
+ @_counted_wrapper
344
+ def wrapper(
345
+ x: ArrayLike, out: FlopscopeArray | None = None, **kwargs: Any
346
+ ) -> FlopscopeArray:
347
+ budget = require_budget()
348
+ if not isinstance(x, _np.ndarray):
349
+ x = _np.asarray(x)
350
+ symmetry = _symmetry_of(x)
351
+ symmetry = _prepare_symmetric_out(out, symmetry)
352
+ cost = pointwise_cost(x.shape, symmetry=symmetry)
353
+ with budget.deduct(op_name, flop_cost=cost, subscripts=None, shapes=(x.shape,)):
354
+ result = _call_with_optional_out(
355
+ np_func,
356
+ x,
357
+ out=None if isinstance(out, SymmetricTensor) else out,
358
+ supports_out=supports_out,
359
+ **kwargs,
360
+ )
361
+ maybe_check_nan_inf(result, op_name)
362
+ return _wrap_result(result, out=out, symmetry=symmetry) # type: ignore[return-value]
363
+
364
+ wrapper.__name__ = op_name
365
+ wrapper.__qualname__ = op_name
366
+ attach_docstring(wrapper, np_func, "counted_unary", "numel(output) FLOPs")
367
+ try:
368
+ wrapper.__signature__ = _inspect.signature(np_func) # pyright: ignore[reportFunctionMemberAccess]
369
+ except (ValueError, TypeError):
370
+ pass
371
+ return wrapper
372
+
373
+
374
+ @_counted_wrapper
375
+ def _counted_unary_multi(np_func, op_name: str):
376
+ """Factory for unary functions that return multiple arrays (modf, frexp).
377
+
378
+ Supports ``out=(out1, out2)`` (or with ``None`` slots for partial
379
+ allocation) — per-slot stripping and identity preservation are routed
380
+ through :func:`_call_with_optional_multi_out`. Symmetry of the input
381
+ is inherited by every output (elementwise ufuncs).
382
+ """
383
+ nout = getattr(np_func, "nout", 2)
384
+
385
+ @_counted_wrapper
386
+ def wrapper(
387
+ x: ArrayLike,
388
+ out: tuple[FlopscopeArray, FlopscopeArray] | None = None,
389
+ **kwargs: Any,
390
+ ) -> tuple[FlopscopeArray, FlopscopeArray]:
391
+ budget = require_budget()
392
+ if not isinstance(x, _np.ndarray):
393
+ x = _np.asarray(x)
394
+ symmetry = _symmetry_of(x)
395
+ cost = pointwise_cost(x.shape, symmetry=symmetry)
396
+ with budget.deduct(op_name, flop_cost=cost, subscripts=None, shapes=(x.shape,)):
397
+ result = _call_with_optional_multi_out(
398
+ np_func,
399
+ x,
400
+ out=out,
401
+ nout=nout,
402
+ **kwargs,
403
+ )
404
+ return _wrap_multi_result(result, out=out, symmetry=symmetry) # type: ignore[return-value]
405
+
406
+ wrapper.__name__ = op_name
407
+ wrapper.__qualname__ = op_name
408
+ attach_docstring(wrapper, np_func, "counted_unary", "numel(input) FLOPs")
409
+ try:
410
+ wrapper.__signature__ = _inspect.signature(np_func) # pyright: ignore[reportFunctionMemberAccess]
411
+ except (ValueError, TypeError):
412
+ pass
413
+ return wrapper
414
+
415
+
416
+ @_counted_wrapper
417
+ def _counted_binary(np_func, op_name: str):
418
+ supports_out = _supports_out_argument(np_func)
419
+
420
+ @_counted_wrapper
421
+ def wrapper(
422
+ x: ArrayLike, y: ArrayLike, out: FlopscopeArray | None = None, **kwargs: Any
423
+ ) -> FlopscopeArray:
424
+ budget = require_budget()
425
+ # Preserve original (possibly Python-scalar) values for the actual
426
+ # numpy call so that NEP 50 weak-typing rules apply correctly. We
427
+ # only need ndarray views for shape and symmetry inspection below.
428
+ x_orig, y_orig = x, y
429
+ if not isinstance(x, _np.ndarray):
430
+ x = _np.asarray(x)
431
+ if not isinstance(y, _np.ndarray):
432
+ y = _np.asarray(y)
433
+ output_shape = _np.broadcast_shapes(x.shape, y.shape)
434
+ x_sym = _symmetry_of(x)
435
+ y_sym = _symmetry_of(y)
436
+ x_is_scalar = x.ndim == 0
437
+ y_is_scalar = y.ndim == 0
438
+ if x_is_scalar ^ y_is_scalar:
439
+ out_symmetry = y_sym if x_is_scalar else x_sym
440
+ aligned_inputs = [out_symmetry] if out_symmetry is not None else []
441
+ else:
442
+ out_symmetry, aligned_inputs = _pointwise_symmetry(
443
+ ((x, x_sym), (y, y_sym)),
444
+ output_shape,
445
+ )
446
+ out_symmetry = _prepare_symmetric_out(out, out_symmetry)
447
+
448
+ cost = pointwise_cost(output_shape, symmetry=out_symmetry)
449
+ with budget.deduct(
450
+ op_name, flop_cost=cost, subscripts=None, shapes=(x.shape, y.shape)
451
+ ):
452
+ # Call the underlying ufunc with the ORIGINAL inputs so that
453
+ # Python-scalar dtype promotion (NEP 50) and FloatingPointError
454
+ # propagation (np.errstate) work exactly as in plain numpy.
455
+ result = _call_with_optional_out(
456
+ np_func,
457
+ x_orig,
458
+ y_orig,
459
+ out=None if isinstance(out, SymmetricTensor) else out,
460
+ supports_out=supports_out,
461
+ **kwargs,
462
+ )
463
+ maybe_check_nan_inf(result, op_name)
464
+ if out_symmetry is not None:
465
+ lost = []
466
+ for group in aligned_inputs:
467
+ if group != out_symmetry and group.axes is not None:
468
+ lost.append(group.axes)
469
+ if lost:
470
+ _warn_symmetry_loss(
471
+ list(dict.fromkeys(lost)),
472
+ f"{op_name} — groups not shared by both operands",
473
+ )
474
+ else:
475
+ lost = [group.axes for group in aligned_inputs if group.axes is not None]
476
+ if lost:
477
+ _warn_symmetry_loss(
478
+ list(dict.fromkeys(lost)),
479
+ f"{op_name} — no symmetry groups shared by both operands",
480
+ )
481
+ return _wrap_result(result, out=out, symmetry=out_symmetry) # type: ignore[return-value]
482
+
483
+ wrapper.__name__ = op_name
484
+ wrapper.__qualname__ = op_name
485
+ attach_docstring(wrapper, np_func, "counted_binary", "numel(output) FLOPs")
486
+ try:
487
+ wrapper.__signature__ = _inspect.signature(np_func) # pyright: ignore[reportFunctionMemberAccess]
488
+ except (ValueError, TypeError):
489
+ pass
490
+ return wrapper
491
+
492
+
493
+ @_counted_wrapper
494
+ def _counted_binary_multi(np_func, op_name: str):
495
+ """Factory for binary functions that return multiple arrays (divmod).
496
+
497
+ Mirrors :func:`_counted_binary` for the multi-output case: scalar
498
+ operand special-case, symmetry-loss warning on unshared input
499
+ groups, per-slot ``out=`` identity preservation. Cost is charged
500
+ once (the underlying numpy ufunc produces all outputs in a single
501
+ pass).
502
+ """
503
+ nout = getattr(np_func, "nout", 2)
504
+
505
+ @_counted_wrapper
506
+ def wrapper(
507
+ x: ArrayLike,
508
+ y: ArrayLike,
509
+ out: tuple[FlopscopeArray, FlopscopeArray] | None = None,
510
+ **kwargs: Any,
511
+ ) -> tuple[FlopscopeArray, FlopscopeArray]:
512
+ budget = require_budget()
513
+ # Preserve original (possibly Python-scalar) values for the actual
514
+ # numpy call so that NEP 50 weak-typing rules apply correctly. We
515
+ # only need ndarray views for shape and symmetry inspection below.
516
+ x_orig, y_orig = x, y
517
+ if not isinstance(x, _np.ndarray):
518
+ x = _np.asarray(x)
519
+ if not isinstance(y, _np.ndarray):
520
+ y = _np.asarray(y)
521
+ output_shape = _np.broadcast_shapes(x.shape, y.shape)
522
+ x_sym = _symmetry_of(x)
523
+ y_sym = _symmetry_of(y)
524
+ x_is_scalar = x.ndim == 0
525
+ y_is_scalar = y.ndim == 0
526
+ if x_is_scalar ^ y_is_scalar:
527
+ out_symmetry = y_sym if x_is_scalar else x_sym
528
+ aligned_inputs = [out_symmetry] if out_symmetry is not None else []
529
+ else:
530
+ out_symmetry, aligned_inputs = _pointwise_symmetry(
531
+ ((x, x_sym), (y, y_sym)),
532
+ output_shape,
533
+ )
534
+ cost = pointwise_cost(output_shape, symmetry=out_symmetry)
535
+ with budget.deduct(
536
+ op_name, flop_cost=cost, subscripts=None, shapes=(x.shape, y.shape)
537
+ ):
538
+ # Pass the ORIGINAL inputs so NEP 50 dtype-promotion rules
539
+ # apply at the NumPy boundary. Stripping happens inside the
540
+ # helper for ndarray-typed values only.
541
+ result = _call_with_optional_multi_out(
542
+ np_func,
543
+ x_orig,
544
+ y_orig,
545
+ out=out,
546
+ nout=nout,
547
+ **kwargs,
548
+ )
549
+ # Symmetry-loss warnings (parity with _counted_binary).
550
+ if out_symmetry is not None:
551
+ lost = []
552
+ for group in aligned_inputs:
553
+ if group != out_symmetry and group.axes is not None:
554
+ lost.append(group.axes)
555
+ if lost:
556
+ _warn_symmetry_loss(
557
+ list(dict.fromkeys(lost)),
558
+ f"{op_name} — groups not shared by both operands",
559
+ )
560
+ else:
561
+ lost = [group.axes for group in aligned_inputs if group.axes is not None]
562
+ if lost:
563
+ _warn_symmetry_loss(
564
+ list(dict.fromkeys(lost)),
565
+ f"{op_name} — no symmetry groups shared by both operands",
566
+ )
567
+ return _wrap_multi_result(result, out=out, symmetry=out_symmetry) # type: ignore[return-value]
568
+
569
+ wrapper.__name__ = op_name
570
+ wrapper.__qualname__ = op_name
571
+ attach_docstring(wrapper, np_func, "counted_binary", "numel(output) FLOPs")
572
+ try:
573
+ wrapper.__signature__ = _inspect.signature(np_func) # pyright: ignore[reportFunctionMemberAccess]
574
+ except (ValueError, TypeError):
575
+ pass
576
+ return wrapper
577
+
578
+
579
+ # ---------------------------------------------------------------------------
580
+ # Generic ufunc-method helpers (outer, reduceat, at, generic reduce/accumulate)
581
+ # ---------------------------------------------------------------------------
582
+
583
+
584
+ @_counted_wrapper
585
+ def _counted_ufunc_outer(ufunc, a, b, *, out=None, **kwargs):
586
+ """Cost-tracked ``ufunc.outer(a, b)`` for any binary ufunc.
587
+
588
+ Output shape is ``a.shape + b.shape``; output symmetry is the direct
589
+ product of the input symmetries (with ``b``'s axes lifted by
590
+ ``a.ndim`` so they refer to the correct slots in the combined
591
+ output). Cost is symmetry-adjusted: dense ``a.size * b.size``
592
+ scaled by ``unique / dense`` of the output (see
593
+ :func:`_symmetry_adjusted_cost`).
594
+ """
595
+ budget = require_budget()
596
+ if not isinstance(a, _np.ndarray):
597
+ a = _np.asarray(a)
598
+ if not isinstance(b, _np.ndarray):
599
+ b = _np.asarray(b)
600
+ a_sym = _symmetry_of(a)
601
+ b_sym = _symmetry_of(b)
602
+ output_shape = tuple(a.shape) + tuple(b.shape)
603
+ dense = _builtins.max(a.size * b.size, 1)
604
+ # The cost-model branch below enumerates |output_symmetry| group
605
+ # elements; if either input group's |G| exceeds dimino_budget the
606
+ # enumeration would be infeasible (np.ones((1,)*33) → S_33 with
607
+ # 33! ≈ 8.7e36 elements). The cost adjustment is irrelevant when
608
+ # the output is trivially small anyway.
609
+ if _is_oversized_for_cost_model(a_sym) or _is_oversized_for_cost_model(b_sym):
610
+ try:
611
+ oversized_order = (
612
+ a_sym.order() if _is_oversized_for_cost_model(a_sym) else b_sym.order() # type: ignore[union-attr]
613
+ )
614
+ except _DiminoBudgetExceeded:
615
+ # Unknown-kind group exceeds budget mid-enumeration; can't
616
+ # compute exact |G|. Use sentinel so all such groups share
617
+ # one dedup slot for the warning.
618
+ oversized_order = -1
619
+ _warn_oversized_once(f"{ufunc.__name__}.outer", oversized_order)
620
+ out_sym = None
621
+ cost = dense
622
+ else:
623
+ # Lift ``b``'s symmetry axes into the combined output's slot range.
624
+ b_sym_lifted = b_sym
625
+ if b_sym is not None and b_sym.axes is not None:
626
+ axis_map = {ax: ax + a.ndim for ax in b_sym.axes}
627
+ b_sym_lifted = remap_group_axes(b_sym, axis_map)
628
+ out_sym = direct_product_groups(a_sym, b_sym_lifted)
629
+ cost = _symmetry_adjusted_cost(dense, output_shape, out_sym)
630
+ out_stripped = _to_base_ndarray(out) if out is not None else None
631
+ with budget.deduct(
632
+ f"{ufunc.__name__}.outer",
633
+ flop_cost=cost,
634
+ subscripts=None,
635
+ shapes=(a.shape, b.shape),
636
+ ):
637
+ result = ufunc.outer(
638
+ _to_base_ndarray(a),
639
+ _to_base_ndarray(b),
640
+ out=out_stripped,
641
+ **kwargs,
642
+ )
643
+ return _wrap_result(result, out=out, symmetry=out_sym)
644
+
645
+
646
+ @_counted_wrapper
647
+ def _counted_ufunc_reduce_generic(
648
+ ufunc, a, *, axis=0, out=None, keepdims=False, **kwargs
649
+ ):
650
+ """Cost-tracked fallback for ``ufunc.reduce`` of arbitrary binary ufuncs.
651
+
652
+ Used for ufuncs not in :class:`FlopscopeArray._REDUCE_TO_WHEST` (e.g.
653
+ ``subtract``, ``logical_xor``, ``bitwise_or``). Cost equals
654
+ :func:`reduction_cost` (numel of input, or the symmetry-aware
655
+ unique count); output symmetry follows
656
+ :func:`reduce_group(symmetry, ndim, axis, keepdims)`.
657
+ """
658
+ budget = require_budget()
659
+ if not isinstance(a, _np.ndarray):
660
+ a = _np.asarray(a)
661
+ sym = _symmetry_of(a)
662
+ cost = reduction_cost(a.shape, axis=axis, symmetry=sym)
663
+ out_sym = (
664
+ reduce_group(sym, ndim=a.ndim, axis=axis, keepdims=keepdims)
665
+ if sym is not None
666
+ else None
667
+ )
668
+ out_stripped = _to_base_ndarray(out) if out is not None else None
669
+ with budget.deduct(
670
+ f"{ufunc.__name__}.reduce",
671
+ flop_cost=cost,
672
+ subscripts=None,
673
+ shapes=(a.shape,),
674
+ ):
675
+ result = ufunc.reduce(
676
+ _to_base_ndarray(a),
677
+ axis=axis,
678
+ out=out_stripped,
679
+ keepdims=keepdims,
680
+ **kwargs,
681
+ )
682
+ return _wrap_result(result, out=out, symmetry=out_sym)
683
+
684
+
685
+ @_counted_wrapper
686
+ def _counted_ufunc_accumulate_generic(ufunc, a, *, axis=0, out=None, **kwargs):
687
+ """Cost-tracked fallback for ``ufunc.accumulate`` of arbitrary binary ufuncs.
688
+
689
+ Used for ufuncs not in :class:`FlopscopeArray._ACCUMULATE_TO_WHEST`.
690
+ Cost equals :func:`reduction_cost` (cumulative ops touch every
691
+ element). Output shape matches input shape, but accumulation along
692
+ ``axis`` breaks any permutation symmetry that includes that axis.
693
+ Output symmetry: surviving stabilizer with ``keepdims=True`` (drops
694
+ symmetry on the accumulate axis only).
695
+ """
696
+ budget = require_budget()
697
+ if not isinstance(a, _np.ndarray):
698
+ a = _np.asarray(a)
699
+ sym = _symmetry_of(a)
700
+ cost = reduction_cost(a.shape, axis=axis, symmetry=sym)
701
+ out_sym = (
702
+ reduce_group(sym, ndim=a.ndim, axis=axis, keepdims=True)
703
+ if sym is not None
704
+ else None
705
+ )
706
+ out_stripped = _to_base_ndarray(out) if out is not None else None
707
+ with budget.deduct(
708
+ f"{ufunc.__name__}.accumulate",
709
+ flop_cost=cost,
710
+ subscripts=None,
711
+ shapes=(a.shape,),
712
+ ):
713
+ result = ufunc.accumulate(
714
+ _to_base_ndarray(a),
715
+ axis=axis,
716
+ out=out_stripped,
717
+ **kwargs,
718
+ )
719
+ return _wrap_result(result, out=out, symmetry=out_sym)
720
+
721
+
722
+ @_counted_wrapper
723
+ def _counted_ufunc_reduceat(ufunc, a, indices, *, axis=0, out=None, **kwargs):
724
+ """Cost-tracked ``ufunc.reduceat(a, indices, axis=...)``.
725
+
726
+ Cost is dense ``numel(input)`` — every element is touched by
727
+ exactly one segment. Output symmetry is ``None``: arbitrary segment
728
+ boundaries don't respect any axis-permutation group action.
729
+ """
730
+ budget = require_budget()
731
+ if not isinstance(a, _np.ndarray):
732
+ a = _np.asarray(a)
733
+ cost = _builtins.max(int(a.size), 1)
734
+ out_stripped = _to_base_ndarray(out) if out is not None else None
735
+ # Strip ``indices`` only when it's already a flopscope-typed ndarray —
736
+ # otherwise let numpy handle the dtype coercion (e.g. an empty
737
+ # Python list must reach numpy as-is so it doesn't get the float64
738
+ # default that ``np.asarray([])`` would assign).
739
+ indices_stripped = (
740
+ _to_base_ndarray(indices) if isinstance(indices, _np.ndarray) else indices
741
+ )
742
+ with budget.deduct(
743
+ f"{ufunc.__name__}.reduceat",
744
+ flop_cost=cost,
745
+ subscripts=None,
746
+ shapes=(a.shape,),
747
+ ):
748
+ result = ufunc.reduceat(
749
+ _to_base_ndarray(a),
750
+ indices_stripped,
751
+ axis=axis,
752
+ out=out_stripped,
753
+ **kwargs,
754
+ )
755
+ return _wrap_result(result, out=out, symmetry=None)
756
+
757
+
758
+ @_counted_wrapper
759
+ def _counted_ufunc_at(ufunc, a, indices, *args, **kwargs):
760
+ """Cost-tracked ``ufunc.at(a, indices[, values])`` (in-place fancy index).
761
+
762
+ ``ufunc.at`` is the in-place unbuffered counterpart to fancy
763
+ indexing — for repeated indices, each application is performed
764
+ rather than deduplicated. The mutation propagates back through
765
+ :func:`_to_base_ndarray`'s zero-copy view-cast.
766
+
767
+ **Refusal on SymmetricTensor**: the asymmetric-index write almost
768
+ certainly breaks the tagged symmetry, so we refuse rather than
769
+ silently corrupt metadata. Users can downgrade with
770
+ ``_asplainflopscope(a)`` first if they really want the unbuffered
771
+ update on a view.
772
+ """
773
+ if isinstance(a, SymmetricTensor):
774
+ sym = a.symmetry
775
+ sym_axes = sym.axes if sym is not None else None
776
+ raise ValueError(
777
+ f"in-place ufunc.{ufunc.__name__}.at on a SymmetricTensor would "
778
+ f"break symmetry on axes {sym_axes}; downgrade to plain FlopscopeArray "
779
+ f"(e.g. via ``_asplainflopscope(a)``) before calling "
780
+ f"np.{ufunc.__name__}.at(...)."
781
+ )
782
+ budget = require_budget()
783
+ # ``indices`` can be many things: int, list of ints, ndarray, slice,
784
+ # Ellipsis, or a tuple thereof (for multi-axis fancy indexing).
785
+ # ``ufunc.at`` accepts all of these. Only convert to ndarray when
786
+ # it's already array-like; let scalars / slices / Ellipsis through
787
+ # unchanged so numpy's own semantics apply.
788
+ indices_stripped = (
789
+ _to_base_ndarray(indices) if isinstance(indices, _np.ndarray) else indices
790
+ )
791
+ if isinstance(indices, _np.ndarray):
792
+ n_ops = _builtins.max(int(_np.size(indices)), 1)
793
+ elif hasattr(a, "size"):
794
+ # Conservative for non-array index forms (slice / Ellipsis): use
795
+ # the input size as an upper bound on the touched cells.
796
+ n_ops = _builtins.max(int(a.size), 1)
797
+ else:
798
+ n_ops = 1
799
+ # Strip any flopscope-typed positional values too.
800
+ stripped_args = tuple(
801
+ _to_base_ndarray(v) if isinstance(v, _np.ndarray) else v for v in args
802
+ )
803
+ with budget.deduct(
804
+ f"{ufunc.__name__}.at",
805
+ flop_cost=n_ops,
806
+ subscripts=None,
807
+ shapes=(a.shape,) if hasattr(a, "shape") else (),
808
+ ):
809
+ ufunc.at(
810
+ _to_base_ndarray(a),
811
+ indices_stripped,
812
+ *stripped_args,
813
+ **kwargs,
814
+ )
815
+ return None # numpy's ufunc.at returns None (mutation is the side effect)
816
+
817
+
818
+ @_counted_wrapper
819
+ def _counted_reduction(
820
+ np_func, op_name: str, cost_multiplier: int = 1, extra_output: bool = False
821
+ ):
822
+ supports_out = _supports_out_argument(np_func)
823
+
824
+ # Per-factory signature introspection for positional `out`.
825
+ # NumPy reductions place `out` at different positional slots;
826
+ # method overrides forwarding through ``*args`` need to find it
827
+ # correctly for each underlying function. ``_axis_is_second_positional``
828
+ # tracks whether `axis` is at slot 1 AND positional-acceptable (true for
829
+ # sum/prod/argmax) or otherwise (false for cumulative_sum where axis is
830
+ # KEYWORD_ONLY, and for percentile/quantile whose slot 1 is `q`).
831
+ try:
832
+ _sig_params = _inspect.signature(np_func).parameters
833
+ _params = list(_sig_params)
834
+ except (ValueError, TypeError):
835
+ _sig_params = {}
836
+ _params = []
837
+ _axis_is_second_positional = (
838
+ len(_params) >= 2
839
+ and _params[1] == "axis"
840
+ and _sig_params["axis"].kind
841
+ in (
842
+ _inspect.Parameter.POSITIONAL_ONLY,
843
+ _inspect.Parameter.POSITIONAL_OR_KEYWORD,
844
+ )
845
+ )
846
+ _args_offset = 2 if _axis_is_second_positional else 1
847
+ _out_args_idx = (
848
+ _params.index("out") - _args_offset
849
+ if "out" in _params and _params.index("out") >= _args_offset
850
+ else None
851
+ )
852
+
853
+ @_counted_wrapper
854
+ def wrapper(
855
+ a: ArrayLike, axis: int | None = None, *args: Any, **kwargs: Any
856
+ ) -> FlopscopeArray:
857
+ budget = require_budget()
858
+ if not isinstance(a, _np.ndarray):
859
+ a = _np.asarray(a)
860
+ symmetry = a.symmetry if isinstance(a, SymmetricTensor) else None
861
+ keepdims = kwargs.get("keepdims", False)
862
+
863
+ # Resolve `out` from either kwargs OR a positional slot in args
864
+ # (per-function — see _out_args_idx computed at factory build time).
865
+ args_list = list(args)
866
+ out = kwargs.pop("out", None)
867
+ out_came_from_args = False
868
+ if (
869
+ out is None
870
+ and _out_args_idx is not None
871
+ and 0 <= _out_args_idx < len(args_list)
872
+ and isinstance(args_list[_out_args_idx], _np.ndarray)
873
+ ):
874
+ out = args_list[_out_args_idx]
875
+ out_came_from_args = True
876
+
877
+ new_symmetry = (
878
+ reduce_group(symmetry, ndim=len(a.shape), axis=axis, keepdims=keepdims)
879
+ if symmetry is not None
880
+ else None
881
+ )
882
+ _prepare_symmetric_out(out, new_symmetry)
883
+ cost = reduction_cost(a.shape, axis, symmetry=symmetry) * cost_multiplier
884
+ if extra_output:
885
+ # Pre-compute extra cost from output shape without running numpy yet
886
+ if axis is None:
887
+ extra_cost = 1 # scalar output
888
+ else:
889
+ ax = axis if axis >= 0 else axis + a.ndim
890
+ if keepdims:
891
+ out_shape = a.shape[:ax] + (1,) + a.shape[ax + 1 :]
892
+ else:
893
+ out_shape = a.shape[:ax] + a.shape[ax + 1 :]
894
+ extra_cost = pointwise_cost(out_shape)
895
+ cost += extra_cost
896
+ out_for_np = None if isinstance(out, SymmetricTensor) else out
897
+ if out_came_from_args:
898
+ # Stripped out goes back into the same positional slot.
899
+ # _out_args_idx is not None here (out_came_from_args requires it)
900
+ args_list[_out_args_idx] = out_for_np # type: ignore[index]
901
+ np_out_kwarg = None
902
+ np_supports_out_for_call = False
903
+ else:
904
+ np_out_kwarg = out_for_np
905
+ np_supports_out_for_call = supports_out
906
+
907
+ with budget.deduct(op_name, flop_cost=cost, subscripts=None, shapes=(a.shape,)):
908
+ if _axis_is_second_positional:
909
+ result = _call_with_optional_out(
910
+ np_func,
911
+ a,
912
+ axis,
913
+ *args_list,
914
+ out=np_out_kwarg,
915
+ supports_out=np_supports_out_for_call,
916
+ **kwargs,
917
+ )
918
+ else:
919
+ # axis is keyword-only or at slot 3+; pass via kwargs.
920
+ result = _call_with_optional_out(
921
+ np_func,
922
+ a,
923
+ *args_list,
924
+ axis=axis,
925
+ out=np_out_kwarg,
926
+ supports_out=np_supports_out_for_call,
927
+ **kwargs,
928
+ )
929
+
930
+ # Propagate symmetry through reduction.
931
+ if out is not None:
932
+ return _wrap_result(result, out=out, symmetry=new_symmetry) # type: ignore[return-value]
933
+
934
+ if symmetry is not None:
935
+ if new_symmetry is not None:
936
+ reduced_axes = (
937
+ set(range(a.ndim))
938
+ if axis is None
939
+ else (
940
+ {axis % a.ndim}
941
+ if isinstance(axis, int)
942
+ else {ax % a.ndim for ax in axis}
943
+ )
944
+ )
945
+ symmetry_axes = (
946
+ set(symmetry.axes)
947
+ if symmetry.axes is not None
948
+ else set(range(symmetry.degree))
949
+ )
950
+ if reduced_axes & symmetry_axes and new_symmetry != symmetry:
951
+ if symmetry.axes is not None:
952
+ _warn_symmetry_loss([symmetry.axes], f"{op_name} reduced dims")
953
+ else:
954
+ if symmetry is not None and symmetry.axes is not None:
955
+ _warn_symmetry_loss(
956
+ [symmetry.axes],
957
+ f"{op_name} removed all symmetric dim groups",
958
+ )
959
+ return _wrap_result(result, symmetry=new_symmetry) # type: ignore[return-value]
960
+
961
+ wrapper.__name__ = op_name
962
+ wrapper.__qualname__ = op_name
963
+ cost_desc = (
964
+ f"numel(input) * {cost_multiplier} FLOPs"
965
+ if cost_multiplier > 1
966
+ else "numel(input) FLOPs"
967
+ )
968
+ if extra_output:
969
+ cost_desc += " + numel(output)"
970
+ attach_docstring(wrapper, np_func, "counted_reduction", cost_desc)
971
+ try:
972
+ wrapper.__signature__ = _inspect.signature(np_func) # pyright: ignore[reportFunctionMemberAccess]
973
+ except (ValueError, TypeError):
974
+ pass
975
+ return wrapper
976
+
977
+
978
+ # ---------------------------------------------------------------------------
979
+ # Unary ops (original)
980
+ # ---------------------------------------------------------------------------
981
+
982
+ exp = _counted_unary(_np.exp, "exp")
983
+ log = _counted_unary(_np.log, "log")
984
+ log2 = _counted_unary(_np.log2, "log2")
985
+ log10 = _counted_unary(_np.log10, "log10")
986
+ abs = _counted_unary(_np.abs, "abs")
987
+ negative = _counted_unary(_np.negative, "negative")
988
+ sqrt = _counted_unary(_np.sqrt, "sqrt")
989
+ square = _counted_unary(_np.square, "square")
990
+ sin = _counted_unary(_np.sin, "sin")
991
+ cos = _counted_unary(_np.cos, "cos")
992
+ tanh = _counted_unary(_np.tanh, "tanh")
993
+ sign = _counted_unary(_np.sign, "sign")
994
+ ceil = _counted_unary(_np.ceil, "ceil")
995
+ floor = _counted_unary(_np.floor, "floor")
996
+
997
+ # ---------------------------------------------------------------------------
998
+ # Unary ops (new)
999
+ # ---------------------------------------------------------------------------
1000
+
1001
+ absolute = _counted_unary(_np.absolute, "absolute")
1002
+ acos = _counted_unary(_np.acos, "acos")
1003
+ acosh = _counted_unary(_np.acosh, "acosh")
1004
+ angle = _counted_unary(_np.angle, "angle")
1005
+ angle.__signature__ = _inspect.signature(_np.angle) # pyright: ignore[reportFunctionMemberAccess]
1006
+ arccos = _counted_unary(_np.arccos, "arccos")
1007
+ arccosh = _counted_unary(_np.arccosh, "arccosh")
1008
+ arcsin = _counted_unary(_np.arcsin, "arcsin")
1009
+ arcsinh = _counted_unary(_np.arcsinh, "arcsinh")
1010
+ arctan = _counted_unary(_np.arctan, "arctan")
1011
+ arctanh = _counted_unary(_np.arctanh, "arctanh")
1012
+
1013
+
1014
+ @_counted_wrapper
1015
+ def around(
1016
+ a: ArrayLike, decimals: int = 0, out: FlopscopeArray | None = None
1017
+ ) -> FlopscopeArray | Any:
1018
+ """Counted version of np.around. Cost = numel(output) FLOPs."""
1019
+ budget = require_budget()
1020
+ a_is_scalar = not isinstance(a, _np.ndarray) and _np.ndim(a) == 0
1021
+ if not isinstance(a, _np.ndarray):
1022
+ a = _np.asarray(a)
1023
+ symmetry = _symmetry_of(a)
1024
+ _prepare_symmetric_out(out, symmetry)
1025
+ cost = pointwise_cost(a.shape, symmetry=symmetry)
1026
+ with budget.deduct("around", flop_cost=cost, subscripts=None, shapes=(a.shape,)):
1027
+ result = _call_with_optional_out(
1028
+ _np.around,
1029
+ a,
1030
+ decimals=decimals,
1031
+ out=None if isinstance(out, SymmetricTensor) else out,
1032
+ supports_out=True,
1033
+ )
1034
+ maybe_check_nan_inf(result, "around")
1035
+ if (
1036
+ a_is_scalar
1037
+ and out is None
1038
+ and _np.ndim(result) == 0
1039
+ and hasattr(result, "item")
1040
+ ):
1041
+ return result.item()
1042
+ return _wrap_result(result, out=out, symmetry=symmetry)
1043
+
1044
+
1045
+ attach_docstring(around, _np.around, "counted_unary", "numel(output) FLOPs")
1046
+ asin = _counted_unary(_np.asin, "asin")
1047
+ asinh = _counted_unary(_np.asinh, "asinh")
1048
+ atan = _counted_unary(_np.atan, "atan")
1049
+ atanh = _counted_unary(_np.atanh, "atanh")
1050
+ if hasattr(_np, "bitwise_count"):
1051
+ bitwise_count = _counted_unary(_np.bitwise_count, "bitwise_count")
1052
+ else:
1053
+
1054
+ def bitwise_count(*args: Any, **kwargs: Any) -> FlopscopeArray:
1055
+ raise UnsupportedFunctionError("bitwise_count", min_version="2.1")
1056
+
1057
+
1058
+ bitwise_invert = _counted_unary(_np.bitwise_invert, "bitwise_invert")
1059
+ bitwise_not = _counted_unary(_np.bitwise_not, "bitwise_not")
1060
+ cbrt = _counted_unary(_np.cbrt, "cbrt")
1061
+ conj = _counted_unary(_np.conj, "conj")
1062
+ conjugate = _counted_unary(_np.conjugate, "conjugate")
1063
+ cosh = _counted_unary(_np.cosh, "cosh")
1064
+ deg2rad = _counted_unary(_np.deg2rad, "deg2rad")
1065
+ degrees = _counted_unary(_np.degrees, "degrees")
1066
+ exp2 = _counted_unary(_np.exp2, "exp2")
1067
+ expm1 = _counted_unary(_np.expm1, "expm1")
1068
+ fabs = _counted_unary(_np.fabs, "fabs")
1069
+ fix = _counted_unary(_np.fix, "fix")
1070
+ fix.__signature__ = _inspect.signature(_np.fix) # pyright: ignore[reportFunctionMemberAccess]
1071
+ i0 = _counted_unary(_np.i0, "i0")
1072
+ imag = _counted_unary(_np.imag, "imag")
1073
+ imag.__signature__ = _inspect.signature(_np.imag) # pyright: ignore[reportFunctionMemberAccess]
1074
+ invert = _counted_unary(_np.invert, "invert")
1075
+ iscomplex = _counted_unary(_np.iscomplex, "iscomplex")
1076
+ iscomplexobj = _counted_unary(_np.iscomplexobj, "iscomplexobj")
1077
+ isnat = _counted_unary(_np.isnat, "isnat")
1078
+ isneginf = _counted_unary(_np.isneginf, "isneginf")
1079
+ isneginf.__signature__ = _inspect.signature(_np.isneginf) # pyright: ignore[reportFunctionMemberAccess]
1080
+ isposinf = _counted_unary(_np.isposinf, "isposinf")
1081
+ isposinf.__signature__ = _inspect.signature(_np.isposinf) # pyright: ignore[reportFunctionMemberAccess]
1082
+ isreal = _counted_unary(_np.isreal, "isreal")
1083
+ isrealobj = _counted_unary(_np.isrealobj, "isrealobj")
1084
+ log1p = _counted_unary(_np.log1p, "log1p")
1085
+ logical_not = _counted_unary(_np.logical_not, "logical_not")
1086
+ nan_to_num = _counted_unary(_np.nan_to_num, "nan_to_num")
1087
+ nan_to_num.__signature__ = _inspect.signature(_np.nan_to_num) # pyright: ignore[reportFunctionMemberAccess]
1088
+ positive = _counted_unary(_np.positive, "positive")
1089
+ rad2deg = _counted_unary(_np.rad2deg, "rad2deg")
1090
+ radians = _counted_unary(_np.radians, "radians")
1091
+ real = _counted_unary(_np.real, "real")
1092
+ real.__signature__ = _inspect.signature(_np.real) # pyright: ignore[reportFunctionMemberAccess]
1093
+ real_if_close = _counted_unary(_np.real_if_close, "real_if_close")
1094
+ real_if_close.__signature__ = _inspect.signature(_np.real_if_close) # pyright: ignore[reportFunctionMemberAccess]
1095
+ reciprocal = _counted_unary(_np.reciprocal, "reciprocal")
1096
+ rint = _counted_unary(_np.rint, "rint")
1097
+
1098
+
1099
+ @_counted_wrapper
1100
+ def round(
1101
+ a: ArrayLike, decimals: int = 0, out: FlopscopeArray | None = None
1102
+ ) -> FlopscopeArray | Any:
1103
+ """Counted version of np.round. Cost = numel(output) FLOPs."""
1104
+ budget = require_budget()
1105
+ a_is_scalar = not isinstance(a, _np.ndarray) and _np.ndim(a) == 0
1106
+ if not isinstance(a, _np.ndarray):
1107
+ a = _np.asarray(a)
1108
+ symmetry = _symmetry_of(a)
1109
+ _prepare_symmetric_out(out, symmetry)
1110
+ cost = pointwise_cost(a.shape, symmetry=symmetry)
1111
+ with budget.deduct("round", flop_cost=cost, subscripts=None, shapes=(a.shape,)):
1112
+ result = _call_with_optional_out(
1113
+ _np.round,
1114
+ a,
1115
+ decimals=decimals,
1116
+ out=None if isinstance(out, SymmetricTensor) else out,
1117
+ supports_out=True,
1118
+ )
1119
+ maybe_check_nan_inf(result, "round")
1120
+ if (
1121
+ a_is_scalar
1122
+ and out is None
1123
+ and _np.ndim(result) == 0
1124
+ and hasattr(result, "item")
1125
+ ):
1126
+ return result.item()
1127
+ return _wrap_result(result, out=out, symmetry=symmetry)
1128
+
1129
+
1130
+ attach_docstring(round, _np.round, "counted_unary", "numel(output) FLOPs")
1131
+ signbit = _counted_unary(_np.signbit, "signbit")
1132
+ sinc = _counted_unary(_np.sinc, "sinc")
1133
+ sinh = _counted_unary(_np.sinh, "sinh")
1134
+
1135
+
1136
+ @_counted_wrapper
1137
+ def sort_complex(a: ArrayLike) -> FlopscopeArray:
1138
+ """Counted version of np.sort_complex. Cost: n*ceil(log2(n))."""
1139
+ import math
1140
+
1141
+ budget = require_budget()
1142
+ if not isinstance(a, _np.ndarray):
1143
+ a = _np.asarray(a)
1144
+ n = a.size
1145
+ log2n = math.ceil(math.log2(n)) if n > 1 else 1
1146
+ cost = n * log2n
1147
+ with budget.deduct(
1148
+ "sort_complex", flop_cost=cost, subscripts=None, shapes=(a.shape,)
1149
+ ):
1150
+ result = _call_numpy(_np.sort_complex, _to_base_ndarray(a))
1151
+ return result # type: ignore[return-value] # wrapped at fnp.sort_complex import time
1152
+
1153
+
1154
+ spacing = _counted_unary(_np.spacing, "spacing")
1155
+ tan = _counted_unary(_np.tan, "tan")
1156
+ trunc = _counted_unary(_np.trunc, "trunc")
1157
+
1158
+ # Multi-output unary ops
1159
+ modf = _counted_unary_multi(_np.modf, "modf")
1160
+ frexp = _counted_unary_multi(_np.frexp, "frexp")
1161
+
1162
+
1163
+ # isclose is binary (takes 2 args) but classified as unary in registry
1164
+ @_counted_wrapper
1165
+ def isclose(a: ArrayLike, b: ArrayLike, **kwargs: Any) -> FlopscopeArray | bool:
1166
+ """Counted version of np.isclose. Cost = numel(output) FLOPs."""
1167
+ budget = require_budget()
1168
+ a_is_scalar = not isinstance(a, _np.ndarray) and _np.ndim(a) == 0
1169
+ b_is_scalar = not isinstance(b, _np.ndarray) and _np.ndim(b) == 0
1170
+ # Keep Python scalars as-is so NEP 50 type promotion works correctly
1171
+ # (converting them to np.asarray before passing would coerce to float64
1172
+ # and break float32 vs Python-float comparisons).
1173
+ a_arr = a if isinstance(a, _np.ndarray) else _np.asarray(a)
1174
+ b_arr = b if isinstance(b, _np.ndarray) else _np.asarray(b)
1175
+ output_shape = _np.broadcast_shapes(a_arr.shape, b_arr.shape)
1176
+ out_symmetry, _ = _pointwise_symmetry(
1177
+ ((a_arr, _symmetry_of(a_arr)), (b_arr, _symmetry_of(b_arr))),
1178
+ output_shape,
1179
+ )
1180
+ cost = pointwise_cost(output_shape, symmetry=out_symmetry)
1181
+ with budget.deduct(
1182
+ "isclose", flop_cost=cost, subscripts=None, shapes=(a_arr.shape, b_arr.shape)
1183
+ ):
1184
+ result = _call_numpy(
1185
+ _np.isclose, _to_base_ndarray(a), _to_base_ndarray(b), **kwargs
1186
+ )
1187
+ if a_is_scalar and b_is_scalar and _np.ndim(result) == 0:
1188
+ return bool(result)
1189
+ return _wrap_result(result, symmetry=out_symmetry) # type: ignore[return-value]
1190
+
1191
+
1192
+ attach_docstring(isclose, _np.isclose, "counted_unary", "numel(output) FLOPs")
1193
+ isclose.__signature__ = _inspect.signature(_np.isclose) # pyright: ignore[reportFunctionMemberAccess]
1194
+
1195
+
1196
+ # ---------------------------------------------------------------------------
1197
+ # Binary ops (original)
1198
+ # ---------------------------------------------------------------------------
1199
+
1200
+ add = _counted_binary(_np.add, "add")
1201
+ subtract = _counted_binary(_np.subtract, "subtract")
1202
+ multiply = _counted_binary(_np.multiply, "multiply")
1203
+ divide = _counted_binary(_np.divide, "divide")
1204
+ maximum = _counted_binary(_np.maximum, "maximum")
1205
+ minimum = _counted_binary(_np.minimum, "minimum")
1206
+ power = _counted_binary(_np.power, "power")
1207
+ mod = _counted_binary(_np.mod, "mod")
1208
+
1209
+ # ---------------------------------------------------------------------------
1210
+ # Binary ops (new)
1211
+ # ---------------------------------------------------------------------------
1212
+
1213
+ arctan2 = _counted_binary(_np.arctan2, "arctan2")
1214
+ atan2 = _counted_binary(_np.atan2, "atan2")
1215
+ bitwise_and = _counted_binary(_np.bitwise_and, "bitwise_and")
1216
+ bitwise_left_shift = _counted_binary(_np.bitwise_left_shift, "bitwise_left_shift")
1217
+ bitwise_or = _counted_binary(_np.bitwise_or, "bitwise_or")
1218
+ bitwise_right_shift = _counted_binary(_np.bitwise_right_shift, "bitwise_right_shift")
1219
+ bitwise_xor = _counted_binary(_np.bitwise_xor, "bitwise_xor")
1220
+ copysign = _counted_binary(_np.copysign, "copysign")
1221
+ equal = _counted_binary(_np.equal, "equal")
1222
+ float_power = _counted_binary(_np.float_power, "float_power")
1223
+ floor_divide = _counted_binary(_np.floor_divide, "floor_divide")
1224
+ fmax = _counted_binary(_np.fmax, "fmax")
1225
+ fmin = _counted_binary(_np.fmin, "fmin")
1226
+ fmod = _counted_binary(_np.fmod, "fmod")
1227
+ gcd = _counted_binary(_np.gcd, "gcd")
1228
+ greater = _counted_binary(_np.greater, "greater")
1229
+ greater_equal = _counted_binary(_np.greater_equal, "greater_equal")
1230
+ heaviside = _counted_binary(_np.heaviside, "heaviside")
1231
+ hypot = _counted_binary(_np.hypot, "hypot")
1232
+ lcm = _counted_binary(_np.lcm, "lcm")
1233
+ ldexp = _counted_binary(_np.ldexp, "ldexp")
1234
+ left_shift = _counted_binary(_np.left_shift, "left_shift")
1235
+ less = _counted_binary(_np.less, "less")
1236
+ less_equal = _counted_binary(_np.less_equal, "less_equal")
1237
+ logaddexp = _counted_binary(_np.logaddexp, "logaddexp")
1238
+ logaddexp2 = _counted_binary(_np.logaddexp2, "logaddexp2")
1239
+ logical_and = _counted_binary(_np.logical_and, "logical_and")
1240
+ logical_or = _counted_binary(_np.logical_or, "logical_or")
1241
+ logical_xor = _counted_binary(_np.logical_xor, "logical_xor")
1242
+ nextafter = _counted_binary(_np.nextafter, "nextafter")
1243
+ not_equal = _counted_binary(_np.not_equal, "not_equal")
1244
+ pow = _counted_binary(_np.pow, "pow")
1245
+ remainder = _counted_binary(_np.remainder, "remainder")
1246
+ right_shift = _counted_binary(_np.right_shift, "right_shift")
1247
+ true_divide = _counted_binary(_np.true_divide, "true_divide")
1248
+
1249
+
1250
+ if hasattr(_np, "vecdot"):
1251
+
1252
+ @_counted_wrapper
1253
+ def vecdot(a: ArrayLike, b: ArrayLike, **kwargs: Any) -> FlopscopeArray: # pyright: ignore[reportRedeclaration]
1254
+ """Counted version of np.vecdot.
1255
+
1256
+ Vector dot product along last axis. Each output element is the dot
1257
+ product of two vectors of length K (the last axis), costing K FLOPs.
1258
+ Total cost = batch_size * K = numel(a) when a and b have the same shape.
1259
+ """
1260
+ budget = require_budget()
1261
+ if not isinstance(a, _np.ndarray):
1262
+ a = _np.asarray(a)
1263
+ if not isinstance(b, _np.ndarray):
1264
+ b = _np.asarray(b)
1265
+ # Cost = output_elements * contracted_axis_size
1266
+ # For vecdot, the last axis is contracted.
1267
+ contracted = a.shape[-1] if a.ndim > 0 else 1
1268
+ out_shape = (
1269
+ _np.broadcast_shapes(a.shape[:-1], b.shape[:-1]) if a.ndim > 0 else ()
1270
+ )
1271
+ cost = (
1272
+ _builtins.max(int(_np.prod(out_shape)) * contracted, 1)
1273
+ if out_shape
1274
+ else contracted
1275
+ )
1276
+ out = kwargs.pop("out", None)
1277
+ out_stripped = _to_base_ndarray(out) if out is not None else None
1278
+ with budget.deduct(
1279
+ "vecdot", flop_cost=cost, subscripts=None, shapes=(a.shape, b.shape)
1280
+ ):
1281
+ result = _call_numpy(
1282
+ _np.vecdot,
1283
+ _to_base_ndarray(a),
1284
+ _to_base_ndarray(b),
1285
+ out=out_stripped,
1286
+ **kwargs,
1287
+ )
1288
+ return out if out is not None else result # type: ignore[return-value]
1289
+
1290
+ else:
1291
+
1292
+ def vecdot(*args: Any, **kwargs: Any) -> FlopscopeArray: # pyright: ignore[reportRedeclaration]
1293
+ raise UnsupportedFunctionError("vecdot", min_version="2.1")
1294
+
1295
+
1296
+ if hasattr(_np, "matvec"):
1297
+
1298
+ @_counted_wrapper
1299
+ def matvec(a: ArrayLike, b: ArrayLike, **kwargs: Any) -> FlopscopeArray: # pyright: ignore[reportRedeclaration]
1300
+ """Counted version of np.matvec.
1301
+
1302
+ Matrix-vector product. A is (..., m, n), v is (..., n), result is (..., m).
1303
+ Cost = output_size * contracted_axis (A's last axis).
1304
+ """
1305
+ budget = require_budget()
1306
+ if not isinstance(a, _np.ndarray):
1307
+ a = _np.asarray(a)
1308
+ if not isinstance(b, _np.ndarray):
1309
+ b = _np.asarray(b)
1310
+ contracted = a.shape[-1] if a.ndim > 0 else 1
1311
+ # output shape: (..., m) where m = a.shape[-2]
1312
+ out_m = a.shape[-2] if a.ndim >= 2 else 1
1313
+ batch = a.shape[:-2] if a.ndim > 2 else ()
1314
+ cost = _builtins.max(
1315
+ int(_np.prod(batch)) * out_m * contracted if batch else out_m * contracted,
1316
+ 1,
1317
+ )
1318
+ out = kwargs.pop("out", None)
1319
+ out_stripped = _to_base_ndarray(out) if out is not None else None
1320
+ with budget.deduct(
1321
+ "matvec", flop_cost=cost, subscripts=None, shapes=(a.shape, b.shape)
1322
+ ):
1323
+ result = _call_numpy(
1324
+ _np.matvec,
1325
+ _to_base_ndarray(a),
1326
+ _to_base_ndarray(b),
1327
+ out=out_stripped,
1328
+ **kwargs,
1329
+ )
1330
+ return out if out is not None else result # type: ignore[return-value]
1331
+
1332
+ else:
1333
+
1334
+ def matvec(*args: Any, **kwargs: Any) -> FlopscopeArray: # pyright: ignore[reportRedeclaration]
1335
+ raise UnsupportedFunctionError("matvec", min_version="2.2")
1336
+
1337
+
1338
+ if hasattr(_np, "vecmat"):
1339
+
1340
+ @_counted_wrapper
1341
+ def vecmat(a: ArrayLike, b: ArrayLike, **kwargs: Any) -> FlopscopeArray: # pyright: ignore[reportRedeclaration]
1342
+ """Counted version of np.vecmat.
1343
+
1344
+ Vector-matrix product. v is (..., n), A is (..., n, m), result is (..., m).
1345
+ Cost = output_size * contracted_axis (v's last axis).
1346
+ """
1347
+ budget = require_budget()
1348
+ if not isinstance(a, _np.ndarray):
1349
+ a = _np.asarray(a)
1350
+ if not isinstance(b, _np.ndarray):
1351
+ b = _np.asarray(b)
1352
+ contracted = a.shape[-1] if a.ndim > 0 else 1
1353
+ # output shape: (..., m) where m = b.shape[-1]
1354
+ out_m = b.shape[-1] if b.ndim >= 2 else 1
1355
+ batch = b.shape[:-2] if b.ndim > 2 else ()
1356
+ cost = _builtins.max(
1357
+ int(_np.prod(batch)) * out_m * contracted if batch else out_m * contracted,
1358
+ 1,
1359
+ )
1360
+ out = kwargs.pop("out", None)
1361
+ out_stripped = _to_base_ndarray(out) if out is not None else None
1362
+ with budget.deduct(
1363
+ "vecmat", flop_cost=cost, subscripts=None, shapes=(a.shape, b.shape)
1364
+ ):
1365
+ result = _call_numpy(
1366
+ _np.vecmat,
1367
+ _to_base_ndarray(a),
1368
+ _to_base_ndarray(b),
1369
+ out=out_stripped,
1370
+ **kwargs,
1371
+ )
1372
+ return out if out is not None else result # type: ignore[return-value]
1373
+
1374
+ else:
1375
+
1376
+ def vecmat(*args: Any, **kwargs: Any) -> FlopscopeArray: # pyright: ignore[reportRedeclaration]
1377
+ raise UnsupportedFunctionError("vecmat", min_version="2.2")
1378
+
1379
+
1380
+ # Multi-output binary ops
1381
+ divmod = _counted_binary_multi(_np.divmod, "divmod")
1382
+
1383
+
1384
+ # ---------------------------------------------------------------------------
1385
+ # Special: clip
1386
+ # ---------------------------------------------------------------------------
1387
+
1388
+
1389
+ @_counted_wrapper
1390
+ def clip(
1391
+ a: ArrayLike, *args: Any, out: FlopscopeArray | None = None, **kwargs: Any
1392
+ ) -> FlopscopeArray:
1393
+ """Counted version of np.clip. Cost = numel(input) or unique_elements if symmetric."""
1394
+ budget = require_budget()
1395
+ if not isinstance(a, _np.ndarray):
1396
+ a = _np.asarray(a)
1397
+ operand_arrays = [(a, _symmetry_of(a))]
1398
+ for value in args:
1399
+ if value is None:
1400
+ continue
1401
+ arr = value if isinstance(value, _np.ndarray) else _np.asarray(value)
1402
+ operand_arrays.append((arr, _symmetry_of(arr)))
1403
+ for key in ("a_min", "a_max", "min", "max"):
1404
+ value = kwargs.get(key)
1405
+ if value is None:
1406
+ continue
1407
+ arr = value if isinstance(value, _np.ndarray) else _np.asarray(value)
1408
+ operand_arrays.append((arr, _symmetry_of(arr)))
1409
+ symmetry, _ = _pointwise_symmetry(operand_arrays, a.shape)
1410
+ _prepare_symmetric_out(out, symmetry)
1411
+ cost = pointwise_cost(a.shape, symmetry=symmetry)
1412
+ with budget.deduct("clip", flop_cost=cost, subscripts=None, shapes=(a.shape,)):
1413
+ # Delegate all argument handling (validation, min/max/a_min/a_max) to numpy
1414
+ result = _call_with_optional_out(
1415
+ _np.clip,
1416
+ a,
1417
+ *args,
1418
+ out=None if isinstance(out, SymmetricTensor) else out,
1419
+ supports_out=True,
1420
+ **kwargs,
1421
+ )
1422
+ if a.dtype.kind in ("f", "c"):
1423
+ maybe_check_nan_inf(result, "clip")
1424
+ return _wrap_result(result, out=out, symmetry=symmetry) # type: ignore[return-value]
1425
+
1426
+
1427
+ attach_docstring(clip, _np.clip, "counted_custom", "numel(input) FLOPs")
1428
+ clip.__signature__ = _inspect.signature(_np.clip) # pyright: ignore[reportFunctionMemberAccess]
1429
+
1430
+
1431
+ # ---------------------------------------------------------------------------
1432
+ # Reductions (original)
1433
+ # ---------------------------------------------------------------------------
1434
+
1435
+ sum = _counted_reduction(_np.sum, "sum")
1436
+ max = _counted_reduction(_np.max, "max")
1437
+ min = _counted_reduction(_np.min, "min")
1438
+ prod = _counted_reduction(_np.prod, "prod")
1439
+
1440
+
1441
+ @_counted_wrapper
1442
+ def mean(
1443
+ a: ArrayLike,
1444
+ axis: int | None = None,
1445
+ dtype=None,
1446
+ out: FlopscopeArray | None = None,
1447
+ keepdims: bool = False,
1448
+ **kwargs: Any,
1449
+ ) -> FlopscopeArray:
1450
+ """Counted version of np.mean.
1451
+
1452
+ Cost = sum_cost (orbit-mapping FLOPs via Tier-1 model)
1453
+ + num_output_orbits (one divide per output orbit).
1454
+ """
1455
+ from flopscope._accumulation._reduction import (
1456
+ _normalize_axis,
1457
+ _num_output_orbits,
1458
+ compute_reduction_accumulation_cost,
1459
+ )
1460
+
1461
+ budget = require_budget()
1462
+ if not isinstance(a, _np.ndarray):
1463
+ a = _np.asarray(a)
1464
+ symmetry = _symmetry_of(a)
1465
+ keepdims = bool(keepdims)
1466
+
1467
+ axes_summed = _normalize_axis(axis, a.ndim)
1468
+ num_orbits = _num_output_orbits(tuple(a.shape), axes_summed, symmetry)
1469
+ cost = compute_reduction_accumulation_cost(
1470
+ input_shape=tuple(a.shape),
1471
+ axes_summed=axes_summed,
1472
+ symmetry=symmetry,
1473
+ op_factor=1,
1474
+ extra_ops=num_orbits, # one divide per output orbit
1475
+ ).total
1476
+
1477
+ new_symmetry = (
1478
+ reduce_group(symmetry, ndim=a.ndim, axis=axis, keepdims=keepdims)
1479
+ if symmetry is not None
1480
+ else None
1481
+ )
1482
+ _prepare_symmetric_out(out, new_symmetry)
1483
+ out_for_np = None if isinstance(out, SymmetricTensor) else out
1484
+
1485
+ with budget.deduct("mean", flop_cost=cost, subscripts=None, shapes=(a.shape,)):
1486
+ result = _call_with_optional_out(
1487
+ _np.mean,
1488
+ a,
1489
+ axis=axis,
1490
+ out=out_for_np,
1491
+ keepdims=keepdims,
1492
+ dtype=dtype,
1493
+ supports_out=True,
1494
+ **kwargs,
1495
+ )
1496
+
1497
+ if out is not None:
1498
+ return _wrap_result(result, out=out, symmetry=new_symmetry) # type: ignore[return-value]
1499
+ return _wrap_result(result, symmetry=new_symmetry) # type: ignore[return-value]
1500
+
1501
+
1502
+ mean.__signature__ = _inspect.signature(_np.mean) # pyright: ignore[reportFunctionMemberAccess]
1503
+
1504
+ std = _counted_reduction(_np.std, "std")
1505
+ var = _counted_reduction(_np.var, "var")
1506
+ argmax = _counted_reduction(_np.argmax, "argmax")
1507
+ argmin = _counted_reduction(_np.argmin, "argmin")
1508
+ cumsum = _counted_reduction(_np.cumsum, "cumsum")
1509
+ cumprod = _counted_reduction(_np.cumprod, "cumprod")
1510
+
1511
+ # ---------------------------------------------------------------------------
1512
+ # Reductions (new)
1513
+ # ---------------------------------------------------------------------------
1514
+
1515
+ all = _counted_reduction(_np.all, "all")
1516
+ amax = _counted_reduction(_np.amax, "amax")
1517
+ amin = _counted_reduction(_np.amin, "amin")
1518
+ any = _counted_reduction(_np.any, "any")
1519
+ average = _counted_reduction(_np.average, "average")
1520
+ _count_nonzero_counted = _counted_reduction(_np.count_nonzero, "count_nonzero")
1521
+
1522
+
1523
+ def count_nonzero(
1524
+ a: ArrayLike, axis: int | tuple[int, ...] | None = None, *, keepdims: bool = False
1525
+ ) -> FlopscopeArray | int:
1526
+ """Counted version of ``numpy.count_nonzero``. Cost: numel(input) FLOPs.
1527
+
1528
+ When ``axis is None`` (and not ``keepdims``) the result is always
1529
+ coerced to a Python ``int``. This is unconditional because flopscope's
1530
+ ``_counted_reduction`` factory wraps scalar results via ``_asflopscope``
1531
+ on every numpy version, so without this coercion users would see a
1532
+ ``FlopscopeArray`` rather than the plain ``int`` that ``numpy.count_nonzero``
1533
+ documents. The coercion also normalizes the numpy 2.3+ change where
1534
+ the raw numpy return type became a numpy scalar.
1535
+ """
1536
+ result = _count_nonzero_counted(a, axis=axis, keepdims=keepdims) # type: ignore[arg-type]
1537
+ if axis is None and not keepdims:
1538
+ return int(result)
1539
+ return result
1540
+
1541
+
1542
+ attach_docstring(
1543
+ count_nonzero, _np.count_nonzero, "counted_reduction", "numel(input) FLOPs"
1544
+ )
1545
+ if hasattr(_np, "cumulative_prod"):
1546
+ cumulative_prod = _counted_reduction(_np.cumulative_prod, "cumulative_prod")
1547
+ else:
1548
+
1549
+ def cumulative_prod(*args: Any, **kwargs: Any) -> FlopscopeArray:
1550
+ raise UnsupportedFunctionError("cumulative_prod", min_version="2.1")
1551
+
1552
+
1553
+ if hasattr(_np, "cumulative_sum"):
1554
+ cumulative_sum = _counted_reduction(_np.cumulative_sum, "cumulative_sum")
1555
+ else:
1556
+
1557
+ def cumulative_sum(*args: Any, **kwargs: Any) -> FlopscopeArray:
1558
+ raise UnsupportedFunctionError("cumulative_sum", min_version="2.1")
1559
+
1560
+
1561
+ def _tier2_reduction_cost(a, axis, dense_per_output_cost: int) -> int:
1562
+ """Tier-2 reduction cost for non-ufunc reductions.
1563
+
1564
+ Returns num_output_orbits × dense_per_output_cost. When *a* has no
1565
+ symmetry, num_output_orbits == num_output_elems and the cost equals
1566
+ the dense baseline.
1567
+ """
1568
+ from flopscope._accumulation._reduction import (
1569
+ _normalize_axis,
1570
+ output_discounted_reduction_cost,
1571
+ )
1572
+
1573
+ sym = _symmetry_of(a)
1574
+ axes_summed = _normalize_axis(axis, a.ndim)
1575
+ return output_discounted_reduction_cost(
1576
+ input_shape=tuple(a.shape),
1577
+ axes_summed=axes_summed,
1578
+ symmetry=sym,
1579
+ dense_per_output_cost=dense_per_output_cost,
1580
+ )
1581
+
1582
+
1583
+ @_counted_wrapper
1584
+ def median(
1585
+ a: ArrayLike,
1586
+ axis: int | None = None,
1587
+ out: FlopscopeArray | None = None,
1588
+ keepdims: bool = False,
1589
+ **kwargs: Any,
1590
+ ) -> FlopscopeArray:
1591
+ """Counted version of np.median.
1592
+
1593
+ Cost = num_output_orbits × axis_dim (Tier-2 partition-based model).
1594
+ """
1595
+ import math as _math
1596
+
1597
+ budget = require_budget()
1598
+ if not isinstance(a, _np.ndarray):
1599
+ a = _np.asarray(a)
1600
+ sym = _symmetry_of(a)
1601
+
1602
+ # Dense per-output cost for partition-based median: axis_dim (one pass).
1603
+ if axis is None:
1604
+ axis_dim = _math.prod(a.shape) if a.shape else 1
1605
+ elif isinstance(axis, int):
1606
+ axis_dim = a.shape[axis]
1607
+ else:
1608
+ axis_dim = _math.prod(a.shape[ax] for ax in axis)
1609
+
1610
+ cost = _tier2_reduction_cost(a, axis, dense_per_output_cost=axis_dim)
1611
+
1612
+ out_sym = (
1613
+ reduce_group(sym, ndim=a.ndim, axis=axis, keepdims=keepdims)
1614
+ if sym is not None
1615
+ else None
1616
+ )
1617
+ out_stripped = _to_base_ndarray(out) if out is not None else None
1618
+ with budget.deduct(
1619
+ "median",
1620
+ flop_cost=cost,
1621
+ subscripts=None,
1622
+ shapes=(a.shape,),
1623
+ ):
1624
+ result = _call_numpy(
1625
+ _np.median,
1626
+ _to_base_ndarray(a),
1627
+ axis=axis,
1628
+ out=out_stripped,
1629
+ keepdims=keepdims,
1630
+ **kwargs,
1631
+ )
1632
+ return _wrap_result(result, out=out, symmetry=out_sym) # type: ignore[return-value]
1633
+
1634
+
1635
+ median.__signature__ = _inspect.signature(_np.median) # pyright: ignore[reportFunctionMemberAccess]
1636
+
1637
+ nanargmax = _counted_reduction(_np.nanargmax, "nanargmax")
1638
+ nanargmin = _counted_reduction(_np.nanargmin, "nanargmin")
1639
+ nancumprod = _counted_reduction(_np.nancumprod, "nancumprod")
1640
+ nancumsum = _counted_reduction(_np.nancumsum, "nancumsum")
1641
+ nanmax = _counted_reduction(_np.nanmax, "nanmax")
1642
+ nanmean = _counted_reduction(_np.nanmean, "nanmean")
1643
+ nanmedian = _counted_reduction(_np.nanmedian, "nanmedian")
1644
+ nanmin = _counted_reduction(_np.nanmin, "nanmin")
1645
+ nanpercentile = _counted_reduction(_np.nanpercentile, "nanpercentile")
1646
+ nanprod = _counted_reduction(_np.nanprod, "nanprod")
1647
+ nanquantile = _counted_reduction(_np.nanquantile, "nanquantile")
1648
+ nanstd = _counted_reduction(_np.nanstd, "nanstd")
1649
+ nansum = _counted_reduction(_np.nansum, "nansum")
1650
+ nanvar = _counted_reduction(_np.nanvar, "nanvar")
1651
+
1652
+
1653
+ @_counted_wrapper
1654
+ def percentile(
1655
+ a: ArrayLike,
1656
+ q: float | ArrayLike,
1657
+ axis: int | tuple[int, ...] | None = None,
1658
+ out: FlopscopeArray | None = None,
1659
+ keepdims: bool = False,
1660
+ **kwargs: Any,
1661
+ ) -> FlopscopeArray:
1662
+ """Counted version of np.percentile.
1663
+
1664
+ Cost = num_output_orbits × axis_dim (Tier-2 partition-based model).
1665
+ """
1666
+ import math as _math
1667
+
1668
+ budget = require_budget()
1669
+ if not isinstance(a, _np.ndarray):
1670
+ a = _np.asarray(a)
1671
+ sym = _symmetry_of(a)
1672
+
1673
+ # Dense per-output cost for partition-based percentile: axis_dim (one pass).
1674
+ if axis is None:
1675
+ axis_dim = _math.prod(a.shape) if a.shape else 1
1676
+ elif isinstance(axis, int):
1677
+ axis_dim = a.shape[axis]
1678
+ else:
1679
+ axis_dim = _math.prod(a.shape[ax] for ax in axis)
1680
+
1681
+ cost = _tier2_reduction_cost(a, axis, dense_per_output_cost=axis_dim)
1682
+
1683
+ out_sym = (
1684
+ reduce_group(sym, ndim=a.ndim, axis=axis, keepdims=keepdims)
1685
+ if sym is not None
1686
+ else None
1687
+ )
1688
+ out_stripped = _to_base_ndarray(out) if out is not None else None
1689
+ with budget.deduct(
1690
+ "percentile",
1691
+ flop_cost=cost,
1692
+ subscripts=None,
1693
+ shapes=(a.shape,),
1694
+ ):
1695
+ result = _call_numpy(
1696
+ _np.percentile,
1697
+ _to_base_ndarray(a),
1698
+ q,
1699
+ axis=axis,
1700
+ out=out_stripped,
1701
+ keepdims=keepdims,
1702
+ **kwargs,
1703
+ )
1704
+ return _wrap_result(result, out=out, symmetry=out_sym) # type: ignore[return-value]
1705
+
1706
+
1707
+ percentile.__signature__ = _inspect.signature(_np.percentile) # pyright: ignore[reportFunctionMemberAccess]
1708
+
1709
+
1710
+ @_counted_wrapper
1711
+ def quantile(
1712
+ a: ArrayLike,
1713
+ q: float | ArrayLike,
1714
+ axis: int | tuple[int, ...] | None = None,
1715
+ out: FlopscopeArray | None = None,
1716
+ keepdims: bool = False,
1717
+ **kwargs: Any,
1718
+ ) -> FlopscopeArray:
1719
+ """Counted version of np.quantile.
1720
+
1721
+ Cost = num_output_orbits × axis_dim (Tier-2 partition-based model).
1722
+ """
1723
+ import math as _math
1724
+
1725
+ budget = require_budget()
1726
+ if not isinstance(a, _np.ndarray):
1727
+ a = _np.asarray(a)
1728
+ sym = _symmetry_of(a)
1729
+
1730
+ # Dense per-output cost for partition-based quantile: axis_dim (one pass).
1731
+ if axis is None:
1732
+ axis_dim = _math.prod(a.shape) if a.shape else 1
1733
+ elif isinstance(axis, int):
1734
+ axis_dim = a.shape[axis]
1735
+ else:
1736
+ axis_dim = _math.prod(a.shape[ax] for ax in axis)
1737
+
1738
+ cost = _tier2_reduction_cost(a, axis, dense_per_output_cost=axis_dim)
1739
+
1740
+ out_sym = (
1741
+ reduce_group(sym, ndim=a.ndim, axis=axis, keepdims=keepdims)
1742
+ if sym is not None
1743
+ else None
1744
+ )
1745
+ out_stripped = _to_base_ndarray(out) if out is not None else None
1746
+ with budget.deduct(
1747
+ "quantile",
1748
+ flop_cost=cost,
1749
+ subscripts=None,
1750
+ shapes=(a.shape,),
1751
+ ):
1752
+ result = _call_numpy(
1753
+ _np.quantile,
1754
+ _to_base_ndarray(a),
1755
+ q,
1756
+ axis=axis,
1757
+ out=out_stripped,
1758
+ keepdims=keepdims,
1759
+ **kwargs,
1760
+ )
1761
+ return _wrap_result(result, out=out, symmetry=out_sym) # type: ignore[return-value]
1762
+
1763
+
1764
+ quantile.__signature__ = _inspect.signature(_np.quantile) # pyright: ignore[reportFunctionMemberAccess]
1765
+
1766
+ # ptp: numpy 2.0 removed it from ndarray but np.ptp still exists
1767
+ if hasattr(_np, "ptp"):
1768
+ ptp = _counted_reduction(_np.ptp, "ptp")
1769
+ else:
1770
+
1771
+ @_counted_wrapper
1772
+ def ptp(a: ArrayLike, axis: int | None = None, **kwargs: Any) -> FlopscopeArray:
1773
+ """Peak-to-peak range. Cost = numel(input) FLOPs."""
1774
+ budget = require_budget()
1775
+ if not isinstance(a, _np.ndarray):
1776
+ a = _np.asarray(a)
1777
+ cost = reduction_cost(a.shape, axis)
1778
+ with budget.deduct("ptp", flop_cost=cost, subscripts=None, shapes=(a.shape,)):
1779
+ stripped = _to_base_ndarray(a)
1780
+ result = _call_numpy(_np.max, stripped, axis=axis, **kwargs) - _call_numpy(
1781
+ _np.min, stripped, axis=axis, **kwargs
1782
+ )
1783
+ return result # type: ignore[return-value] # wrapped at fnp.ptp import time
1784
+
1785
+ attach_docstring(ptp, _np.max, "counted_reduction", "numel(input) FLOPs")
1786
+
1787
+
1788
+ # ---------------------------------------------------------------------------
1789
+ # dot and matmul
1790
+ # ---------------------------------------------------------------------------
1791
+
1792
+
1793
+ @_counted_wrapper
1794
+ def dot(a: ArrayLike, b: ArrayLike) -> FlopscopeArray:
1795
+ """Counted version of np.dot."""
1796
+ budget = require_budget()
1797
+ if not isinstance(a, _np.ndarray):
1798
+ a = _np.asarray(a)
1799
+ if not isinstance(b, _np.ndarray):
1800
+ b = _np.asarray(b)
1801
+ subs: str | None
1802
+ if a.ndim == 2 and b.ndim == 2:
1803
+ subs = "ij,jk->ik"
1804
+ elif a.ndim == 1 and b.ndim == 1:
1805
+ subs = "i,i->"
1806
+ else:
1807
+ subs = None
1808
+ if subs is None:
1809
+ cost: int = a.size * b.size
1810
+ output_sym = None
1811
+ canonical_subs: str | None = None
1812
+ else:
1813
+ from flopscope._einsum import _resolve_cost_and_output_symmetry
1814
+
1815
+ info = _resolve_cost_and_output_symmetry(subs, a, b)
1816
+ cost = info.accumulation.total
1817
+ output_sym = info.output_symmetry
1818
+ canonical_subs = info.canonical_subscripts
1819
+ inputs_were_whest = isinstance(a, _np.ndarray) and (
1820
+ type(a) is not _np.ndarray or type(b) is not _np.ndarray
1821
+ )
1822
+ with budget.deduct(
1823
+ "dot", flop_cost=cost, subscripts=canonical_subs, shapes=(a.shape, b.shape)
1824
+ ):
1825
+ result = _call_numpy(_np.dot, _to_base_ndarray(a), _to_base_ndarray(b))
1826
+ maybe_check_nan_inf(result, "dot")
1827
+ if output_sym is not None:
1828
+ _validate_result_symmetry(result, output_sym)
1829
+ return SymmetricTensor(_np.asarray(result), symmetry=output_sym) # type: ignore[return-value]
1830
+ return _asflopscope(result) if inputs_were_whest else result # type: ignore[return-value]
1831
+
1832
+
1833
+ attach_docstring(dot, _np.dot, "counted_custom", "depends on operand dimensions")
1834
+
1835
+
1836
+ @_counted_wrapper
1837
+ def matmul(a: ArrayLike, b: ArrayLike) -> FlopscopeArray:
1838
+ """Counted version of np.matmul."""
1839
+ budget = require_budget()
1840
+ if not isinstance(a, _np.ndarray):
1841
+ a = _np.asarray(a)
1842
+ if not isinstance(b, _np.ndarray):
1843
+ b = _np.asarray(b)
1844
+ # Subscripts for the 2D×2D and 1D×1D cases; everything else falls
1845
+ # back to a dense size×size estimate (batched matmul, mixed ndim).
1846
+ subs: str | None
1847
+ if a.ndim == 2 and b.ndim == 2:
1848
+ subs = "ij,jk->ik"
1849
+ elif a.ndim == 1 and b.ndim == 1:
1850
+ subs = "i,i->"
1851
+ else:
1852
+ subs = None
1853
+ if subs is None:
1854
+ cost: int = a.size * b.size
1855
+ output_sym = None
1856
+ canonical_subs: str | None = None
1857
+ else:
1858
+ from flopscope._einsum import _resolve_cost_and_output_symmetry
1859
+
1860
+ info = _resolve_cost_and_output_symmetry(subs, a, b)
1861
+ cost = info.accumulation.total
1862
+ output_sym = info.output_symmetry
1863
+ canonical_subs = info.canonical_subscripts
1864
+ inputs_were_whest = isinstance(a, _np.ndarray) and (
1865
+ type(a) is not _np.ndarray or type(b) is not _np.ndarray
1866
+ )
1867
+ with budget.deduct(
1868
+ "matmul", flop_cost=cost, subscripts=canonical_subs, shapes=(a.shape, b.shape)
1869
+ ):
1870
+ with _np.errstate(divide="ignore", over="ignore", invalid="ignore"):
1871
+ result = _call_numpy(_np.matmul, _to_base_ndarray(a), _to_base_ndarray(b))
1872
+ maybe_check_nan_inf(result, "matmul")
1873
+ if output_sym is not None:
1874
+ _validate_result_symmetry(result, output_sym)
1875
+ return SymmetricTensor(_np.asarray(result), symmetry=output_sym) # type: ignore[return-value]
1876
+ return _asflopscope(result) if inputs_were_whest else result # type: ignore[return-value]
1877
+
1878
+
1879
+ attach_docstring(matmul, _np.matmul, "counted_custom", "depends on operand dimensions")
1880
+
1881
+
1882
+ # ---------------------------------------------------------------------------
1883
+ # Custom ops (new)
1884
+ # ---------------------------------------------------------------------------
1885
+
1886
+
1887
+ @_counted_wrapper
1888
+ def inner(a: ArrayLike, b: ArrayLike) -> FlopscopeArray:
1889
+ """Counted version of np.inner."""
1890
+ budget = require_budget()
1891
+ if not isinstance(a, _np.ndarray):
1892
+ a = _np.asarray(a)
1893
+ if not isinstance(b, _np.ndarray):
1894
+ b = _np.asarray(b)
1895
+ subs: str | None
1896
+ if a.ndim == 1 and b.ndim == 1:
1897
+ subs = "i,i->"
1898
+ elif a.ndim == 2 and b.ndim == 2:
1899
+ subs = "ij,kj->ik"
1900
+ else:
1901
+ subs = None
1902
+ if subs is None:
1903
+ cost: int = (
1904
+ a.size
1905
+ if (a.ndim <= 1 and b.ndim <= 1)
1906
+ else a.size * (b.shape[-1] if b.ndim > 1 else 1)
1907
+ )
1908
+ output_sym = None
1909
+ canonical_subs: str | None = None
1910
+ else:
1911
+ from flopscope._einsum import _resolve_cost_and_output_symmetry
1912
+
1913
+ info = _resolve_cost_and_output_symmetry(subs, a, b)
1914
+ cost = info.accumulation.total
1915
+ output_sym = info.output_symmetry
1916
+ canonical_subs = info.canonical_subscripts
1917
+ with budget.deduct(
1918
+ "inner", flop_cost=cost, subscripts=canonical_subs, shapes=(a.shape, b.shape)
1919
+ ):
1920
+ result = _call_numpy(_np.inner, _to_base_ndarray(a), _to_base_ndarray(b))
1921
+ if output_sym is not None:
1922
+ _validate_result_symmetry(result, output_sym)
1923
+ return SymmetricTensor(_np.asarray(result), symmetry=output_sym) # type: ignore[return-value]
1924
+ return result # type: ignore[return-value]
1925
+
1926
+
1927
+ attach_docstring(inner, _np.inner, "counted_custom", "product of matching dims")
1928
+
1929
+
1930
+ @_counted_wrapper
1931
+ def outer(
1932
+ a: ArrayLike, b: ArrayLike, out: FlopscopeArray | None = None
1933
+ ) -> FlopscopeArray:
1934
+ """Counted version of np.outer."""
1935
+ budget = require_budget()
1936
+ # Capture aliasing BEFORE asarray conversion so outer(v, v) is detected
1937
+ # even when v is a list or other non-ndarray type.
1938
+ a_orig_is_b_orig = a is b
1939
+ if not isinstance(a, _np.ndarray):
1940
+ a = _np.asarray(a)
1941
+ if not isinstance(b, _np.ndarray):
1942
+ b = _np.asarray(b)
1943
+ # outer flattens its inputs to 1-D, so "i,j->ij" is always the right
1944
+ # einsum subscripts regardless of original ndim.
1945
+ from flopscope._einsum import _resolve_cost_and_output_symmetry
1946
+
1947
+ a_flat = a.ravel() if a.ndim != 1 else a
1948
+ b_flat = b.ravel() if b.ndim != 1 else b
1949
+ # Preserve operand-aliasing through the asarray boundary: if the user
1950
+ # passed the same Python object for both operands, treat them as one
1951
+ # array for the helper's identity-pattern detection.
1952
+ if a_orig_is_b_orig:
1953
+ b_flat = a_flat
1954
+ info = _resolve_cost_and_output_symmetry("i,j->ij", a_flat, b_flat)
1955
+ cost = info.accumulation.total
1956
+ output_sym = info.output_symmetry
1957
+ canonical_subs = info.canonical_subscripts
1958
+ if output_sym is not None:
1959
+ output_sym = _prepare_symmetric_out(out, output_sym)
1960
+ with budget.deduct(
1961
+ "outer", flop_cost=cost, subscripts=canonical_subs, shapes=(a.shape, b.shape)
1962
+ ):
1963
+ result = _call_numpy(
1964
+ _np.outer,
1965
+ _to_base_ndarray(a),
1966
+ _to_base_ndarray(b),
1967
+ out=None if isinstance(out, SymmetricTensor) else out,
1968
+ )
1969
+ if output_sym is None:
1970
+ if out is not None:
1971
+ return out
1972
+ return result # type: ignore[return-value]
1973
+ return _wrap_result(result, out=out, symmetry=output_sym) # type: ignore[return-value]
1974
+
1975
+
1976
+ attach_docstring(outer, _np.outer, "counted_custom", "n(n+1)/2 FLOPs when v outer v")
1977
+
1978
+
1979
+ def _tensordot_parse_axes(a_ndim, b_ndim, axes):
1980
+ """Parse ``np.tensordot``'s ``axes`` argument into ``(a_axes, b_axes)``.
1981
+
1982
+ Accepts the same forms as numpy: ``int N`` (contract last N of ``a``
1983
+ with first N of ``b``), ``(int, int)`` (single-axis pair), or
1984
+ ``(iterable, iterable)`` (per-axis pairing). Returns a pair of
1985
+ tuples of contracted axis indices.
1986
+ """
1987
+ if isinstance(axes, int):
1988
+ return (
1989
+ tuple(range(a_ndim - axes, a_ndim)),
1990
+ tuple(range(axes)),
1991
+ )
1992
+ a_spec, b_spec = axes
1993
+ a_axes = (a_spec,) if isinstance(a_spec, int) else tuple(a_spec)
1994
+ b_axes = (b_spec,) if isinstance(b_spec, int) else tuple(b_spec)
1995
+ return a_axes, b_axes
1996
+
1997
+
1998
+ def _surviving_symmetry_after_contraction(group, surviving_axes):
1999
+ """Restrict ``group`` to the axes that remain after contraction.
2000
+
2001
+ Returns ``None`` if the surviving axes don't carry any of the
2002
+ group's permutations (e.g. the contraction broke a 2-axis S₂).
2003
+ The returned group is still indexed in the *original* tensor's
2004
+ axis space — call :func:`remap_group_axes` afterwards to relabel.
2005
+ """
2006
+ if group is None:
2007
+ return None
2008
+ group_axes = group.axes if group.axes is not None else tuple(range(group.degree))
2009
+ wanted = tuple(ax for ax in surviving_axes if ax in group_axes)
2010
+ if len(wanted) < 2:
2011
+ return None
2012
+ return restrict_group_to_axes(group, axes=wanted)
2013
+
2014
+
2015
+ @_counted_wrapper
2016
+ def tensordot(a: ArrayLike, b: ArrayLike, axes: Any = 2) -> FlopscopeArray:
2017
+ """Counted version of ``np.tensordot``.
2018
+
2019
+ The dense FLOP cost is ``a.size * b.size / contracted_size``. When
2020
+ either operand carries a :class:`SymmetricTensor` symmetry, flopscope
2021
+ composes the surviving (post-contraction) symmetry on the output
2022
+ axes via :func:`flopscope._symmetry_utils.direct_product_groups` and
2023
+ scales the cost by the unique-element fraction of the output (see
2024
+ :func:`_symmetry_adjusted_cost`). Above degree 12 the adjustment is
2025
+ skipped and :class:`flopscope.errors.CostFallbackWarning` fires.
2026
+ """
2027
+ budget = require_budget()
2028
+ if not isinstance(a, _np.ndarray):
2029
+ a = _np.asarray(a)
2030
+ if not isinstance(b, _np.ndarray):
2031
+ b = _np.asarray(b)
2032
+ a_contract_axes, b_contract_axes = _tensordot_parse_axes(a.ndim, b.ndim, axes)
2033
+ # Fast path: a full inner contraction over all axes maps cleanly to
2034
+ # einsum and benefits from joint-operand savings when a is b.
2035
+ is_full_inner = (
2036
+ a.ndim == b.ndim
2037
+ and a_contract_axes == tuple(range(a.ndim))
2038
+ and b_contract_axes == tuple(range(b.ndim))
2039
+ and a.ndim >= 1
2040
+ )
2041
+ if is_full_inner:
2042
+ # Build matching einsum subscripts (e.g. ndim=2 -> "ij,ij->").
2043
+ letters = "abcdefghijklmnopqrstuvwxyz"[: a.ndim]
2044
+ subs = f"{letters},{letters}->"
2045
+ from flopscope._einsum import _resolve_cost_and_output_symmetry
2046
+
2047
+ info = _resolve_cost_and_output_symmetry(subs, a, b)
2048
+ cost = info.accumulation.total
2049
+ canonical_subs = info.canonical_subscripts
2050
+ out_sym = info.output_symmetry # scalar output — always None
2051
+ with budget.deduct(
2052
+ "tensordot",
2053
+ flop_cost=cost,
2054
+ subscripts=canonical_subs,
2055
+ shapes=(a.shape, b.shape),
2056
+ ):
2057
+ result = _call_numpy(
2058
+ _np.tensordot, _to_base_ndarray(a), _to_base_ndarray(b), axes=axes
2059
+ )
2060
+ if out_sym is not None:
2061
+ return _wrap_result(result, symmetry=out_sym) # type: ignore[return-value]
2062
+ return result # type: ignore[return-value]
2063
+ # Fallback: keep the existing sophisticated direct_product_groups path
2064
+ # for partial contractions and unusual axes specs.
2065
+ contracted = 1
2066
+ for ax in a_contract_axes:
2067
+ if 0 <= ax < a.ndim:
2068
+ contracted *= a.shape[ax]
2069
+ # Surviving (non-contracted) axes for each operand.
2070
+ a_surviving = tuple(i for i in range(a.ndim) if i not in a_contract_axes)
2071
+ b_surviving = tuple(i for i in range(b.ndim) if i not in b_contract_axes)
2072
+ output_shape = tuple(a.shape[i] for i in a_surviving) + tuple(
2073
+ b.shape[j] for j in b_surviving
2074
+ )
2075
+ # output_size * contracted = (a.size / contracted) * (b.size / contracted) * contracted
2076
+ # = a.size * b.size / contracted
2077
+ dense = _builtins.max(a.size * b.size // contracted, 1) if contracted > 0 else 1
2078
+ # Compose output symmetry from each input's surviving symmetry, with
2079
+ # b's axes lifted past a's surviving count so they refer to their
2080
+ # final slots in the combined output. Bail on the composition when
2081
+ # either group's |G| exceeds dimino_budget (see
2082
+ # ``_is_oversized_for_cost_model``).
2083
+ a_sym = _symmetry_of(a)
2084
+ b_sym = _symmetry_of(b)
2085
+ if _is_oversized_for_cost_model(a_sym) or _is_oversized_for_cost_model(b_sym):
2086
+ try:
2087
+ oversized_order = (
2088
+ a_sym.order() if _is_oversized_for_cost_model(a_sym) else b_sym.order() # type: ignore[union-attr]
2089
+ )
2090
+ except _DiminoBudgetExceeded:
2091
+ # Unknown-kind group exceeds budget mid-enumeration; can't
2092
+ # compute exact |G|. Use sentinel so all such groups share
2093
+ # one dedup slot for the warning.
2094
+ oversized_order = -1
2095
+ _warn_oversized_once("tensordot", oversized_order)
2096
+ out_sym = None
2097
+ cost = dense
2098
+ else:
2099
+ a_sym_kept = _surviving_symmetry_after_contraction(a_sym, a_surviving)
2100
+ b_sym_kept = _surviving_symmetry_after_contraction(b_sym, b_surviving)
2101
+ a_sym_remapped = (
2102
+ remap_group_axes(
2103
+ a_sym_kept, {ax: new for new, ax in enumerate(a_surviving)}
2104
+ )
2105
+ if a_sym_kept is not None
2106
+ else None
2107
+ )
2108
+ b_offset = len(a_surviving)
2109
+ b_sym_remapped = (
2110
+ remap_group_axes(
2111
+ b_sym_kept,
2112
+ {ax: new + b_offset for new, ax in enumerate(b_surviving)},
2113
+ )
2114
+ if b_sym_kept is not None
2115
+ else None
2116
+ )
2117
+ out_sym = direct_product_groups(a_sym_remapped, b_sym_remapped)
2118
+ cost = _symmetry_adjusted_cost(dense, output_shape, out_sym)
2119
+ with budget.deduct(
2120
+ "tensordot", flop_cost=cost, subscripts=None, shapes=(a.shape, b.shape)
2121
+ ):
2122
+ result = _call_numpy(
2123
+ _np.tensordot, _to_base_ndarray(a), _to_base_ndarray(b), axes=axes
2124
+ )
2125
+ if out_sym is not None:
2126
+ return _wrap_result(result, symmetry=out_sym) # type: ignore[return-value]
2127
+ return result # type: ignore[return-value] # wrapped at fnp.tensordot import time
2128
+
2129
+
2130
+ attach_docstring(tensordot, _np.tensordot, "counted_custom", "product of all dims")
2131
+
2132
+
2133
+ @_counted_wrapper
2134
+ def vdot(a: ArrayLike, b: ArrayLike) -> FlopscopeArray:
2135
+ """Counted version of np.vdot."""
2136
+ budget = require_budget()
2137
+ if not isinstance(a, _np.ndarray):
2138
+ a = _np.asarray(a)
2139
+ if not isinstance(b, _np.ndarray):
2140
+ b = _np.asarray(b)
2141
+ from flopscope._einsum import _resolve_cost_and_output_symmetry
2142
+
2143
+ a_flat = a.ravel() if a.ndim != 1 else a
2144
+ b_flat = b.ravel() if b.ndim != 1 else b
2145
+ info = _resolve_cost_and_output_symmetry("i,i->", a_flat, b_flat)
2146
+ cost = info.accumulation.total
2147
+ canonical_subs = info.canonical_subscripts
2148
+ with budget.deduct(
2149
+ "vdot", flop_cost=cost, subscripts=canonical_subs, shapes=(a.shape, b.shape)
2150
+ ):
2151
+ result = _call_numpy(_np.vdot, _to_base_ndarray(a), _to_base_ndarray(b))
2152
+ # vdot returns a scalar, never a SymmetricTensor.
2153
+ return result # type: ignore[return-value]
2154
+
2155
+
2156
+ attach_docstring(vdot, _np.vdot, "counted_custom", "size of input FLOPs")
2157
+
2158
+
2159
+ @_counted_wrapper
2160
+ def kron(a: ArrayLike, b: ArrayLike) -> FlopscopeArray:
2161
+ """Counted version of np.kron."""
2162
+ budget = require_budget()
2163
+ if not isinstance(a, _np.ndarray):
2164
+ a = _np.asarray(a)
2165
+ if not isinstance(b, _np.ndarray):
2166
+ b = _np.asarray(b)
2167
+ # kron output size = a.size * b.size
2168
+ cost = _builtins.max(a.size * b.size, 1)
2169
+ with budget.deduct(
2170
+ "kron", flop_cost=cost, subscripts=None, shapes=(a.shape, b.shape)
2171
+ ):
2172
+ result = _call_numpy(_np.kron, _to_base_ndarray(a), _to_base_ndarray(b))
2173
+ return result # type: ignore[return-value] # wrapped at fnp.kron import time
2174
+
2175
+
2176
+ attach_docstring(kron, _np.kron, "counted_custom", "output size FLOPs")
2177
+
2178
+
2179
+ @_counted_wrapper
2180
+ def cross(a: ArrayLike, b: ArrayLike, **kwargs: Any) -> FlopscopeArray:
2181
+ """Counted version of np.cross.
2182
+
2183
+ Cost model: 5 ops per output element (3 mults + 1 mult + 1 sub per output
2184
+ triple component, which is 5 element-wise ops per output scalar). Issue #69.
2185
+ """
2186
+ budget = require_budget()
2187
+ if not isinstance(a, _np.ndarray):
2188
+ a = _np.asarray(a)
2189
+ if not isinstance(b, _np.ndarray):
2190
+ b = _np.asarray(b)
2191
+ # np.cross supports axisa/axisb/axisc kwargs that change output shape,
2192
+ # so we need the output shape to compute the cost. For default-axis usage,
2193
+ # the output has the same total size as a (cross(a[..., 3], b[..., 3])).
2194
+ # Using a.size instead of a.shape[0]*3 correctly handles batched inputs
2195
+ # of any shape (e.g. (B, N, 3) → cost = B*N*3*5, not B*3*5). Issue #69.
2196
+ # Putting the numpy call inside `with budget.deduct(...)` ensures backend
2197
+ # wall-time is attributed to this op (issue #69 — previously called
2198
+ # outside the budget block).
2199
+ stripped_a = _to_base_ndarray(a)
2200
+ stripped_b = _to_base_ndarray(b)
2201
+ cost_provisional = _builtins.max(a.size * 5, 1)
2202
+ with budget.deduct(
2203
+ "cross",
2204
+ flop_cost=cost_provisional,
2205
+ subscripts=None,
2206
+ shapes=(a.shape, b.shape),
2207
+ ):
2208
+ result = _call_numpy(_np.cross, stripped_a, stripped_b, **kwargs)
2209
+ return result # type: ignore[return-value]
2210
+
2211
+
2212
+ attach_docstring(cross, _np.cross, "counted_custom", "5 * output.size FLOPs")
2213
+ cross.__signature__ = _inspect.signature(_np.cross) # pyright: ignore[reportFunctionMemberAccess]
2214
+
2215
+
2216
+ @_counted_wrapper
2217
+ def diff(a: ArrayLike, n: int = 1, axis: int = -1, **kwargs: Any) -> FlopscopeArray:
2218
+ """Counted version of np.diff."""
2219
+ budget = require_budget()
2220
+ if not isinstance(a, _np.ndarray):
2221
+ a = _np.asarray(a)
2222
+ # numpy.diff implements `for _ in range(n): a = subtract(a[1:], a[:-1])`,
2223
+ # so the cost is the SUM over n iterations of (numel_along_axis - k) for
2224
+ # k = 1..n. Closed form: n*L - n*(n+1)//2, scaled by the product of
2225
+ # the other axes' sizes. Issue #69.
2226
+ ax = axis if axis >= 0 else axis + a.ndim
2227
+ L = a.shape[ax]
2228
+ prod_outside = int(_np.prod(a.shape[:ax]))
2229
+ prod_inside = int(_np.prod(a.shape[ax + 1 :]))
2230
+ per_iter_sum = n * L - n * (n + 1) // 2
2231
+ cost = _builtins.max(prod_outside * per_iter_sum * prod_inside, 1)
2232
+ with budget.deduct(
2233
+ "diff",
2234
+ flop_cost=cost,
2235
+ subscripts=None,
2236
+ shapes=(a.shape,),
2237
+ ):
2238
+ result = _call_numpy(_np.diff, _to_base_ndarray(a), n=n, axis=axis, **kwargs)
2239
+ return result # type: ignore[return-value] # wrapped at fnp.diff import time
2240
+
2241
+
2242
+ attach_docstring(
2243
+ diff, _np.diff, "counted_custom", "n*L - n*(n+1)/2 FLOPs along the diff axis"
2244
+ )
2245
+ diff.__signature__ = _inspect.signature(_np.diff) # pyright: ignore[reportFunctionMemberAccess]
2246
+
2247
+
2248
+ @_counted_wrapper
2249
+ def gradient(
2250
+ f: ArrayLike, *varargs: ArrayLike, **kwargs: Any
2251
+ ) -> FlopscopeArray | list[FlopscopeArray]:
2252
+ """Counted version of np.gradient.
2253
+
2254
+ Cost model: numpy.gradient computes second-order central differences
2255
+ along each axis (one subtract for the interior + one divide-by-2),
2256
+ plus first-order forward/backward differences at the two boundaries.
2257
+ Per axis i: interior elements = f.size * (shape[i] - 2) / shape[i];
2258
+ two ops (subtract + divide) on those interior elements.
2259
+ Total: sum over axes of 2 * f.size * (shape[i] - 2) / shape[i].
2260
+ For large uniform arrays this ≈ 2 * ndim * f.size.
2261
+ Issue #69 — old formula was `f.size` regardless of ndim.
2262
+ """
2263
+ budget = require_budget()
2264
+ if not isinstance(f, _np.ndarray):
2265
+ f = _np.asarray(f)
2266
+ if f.ndim == 0:
2267
+ cost = 1
2268
+ else:
2269
+ cost = _builtins.max(
2270
+ _builtins.sum(
2271
+ 2 * f.size * _builtins.max(f.shape[ax] - 2, 0) // f.shape[ax]
2272
+ for ax in range(f.ndim)
2273
+ ),
2274
+ 1,
2275
+ )
2276
+ with budget.deduct("gradient", flop_cost=cost, subscripts=None, shapes=(f.shape,)):
2277
+ result = _call_numpy(
2278
+ _np.gradient,
2279
+ _to_base_ndarray(f),
2280
+ *[_to_base_ndarray(v) for v in varargs],
2281
+ **kwargs,
2282
+ )
2283
+ return result # type: ignore[return-value] # wrapped at fnp.gradient import time
2284
+
2285
+
2286
+ attach_docstring(
2287
+ gradient,
2288
+ _np.gradient,
2289
+ "counted_custom",
2290
+ "sum_axis(2 * f.size * (shape[ax]-2) / shape[ax]) FLOPs",
2291
+ )
2292
+ gradient.__signature__ = _inspect.signature(_np.gradient) # pyright: ignore[reportFunctionMemberAccess]
2293
+
2294
+
2295
+ @_counted_wrapper
2296
+ def ediff1d(ary: ArrayLike, **kwargs: Any) -> FlopscopeArray:
2297
+ """Counted version of np.ediff1d."""
2298
+ budget = require_budget()
2299
+ if not isinstance(ary, _np.ndarray):
2300
+ ary = _np.asarray(ary)
2301
+ # Output size = ary.size - 1 (plus any to_begin/to_end extras)
2302
+ to_begin = kwargs.get("to_begin", None)
2303
+ to_end = kwargs.get("to_end", None)
2304
+ extra = 0
2305
+ if to_begin is not None:
2306
+ extra += _np.asarray(to_begin).size
2307
+ if to_end is not None:
2308
+ extra += _np.asarray(to_end).size
2309
+ cost = _builtins.max(ary.size - 1 + extra, 1)
2310
+ with budget.deduct(
2311
+ "ediff1d",
2312
+ flop_cost=cost,
2313
+ subscripts=None,
2314
+ shapes=(ary.shape,),
2315
+ ):
2316
+ # ``to_begin`` / ``to_end`` kwargs may be FlopscopeArrays — strip via tree.
2317
+ stripped_kwargs = {
2318
+ k: _to_base_ndarray(v) if isinstance(v, _np.ndarray) else v
2319
+ for k, v in kwargs.items()
2320
+ }
2321
+ result = _call_numpy(_np.ediff1d, _to_base_ndarray(ary), **stripped_kwargs)
2322
+ return result # type: ignore[return-value] # wrapped at fnp.ediff1d import time
2323
+
2324
+
2325
+ attach_docstring(ediff1d, _np.ediff1d, "counted_custom", "numel(output) FLOPs")
2326
+ ediff1d.__signature__ = _inspect.signature(_np.ediff1d) # pyright: ignore[reportFunctionMemberAccess]
2327
+
2328
+
2329
+ @_counted_wrapper
2330
+ def convolve(a: ArrayLike, v: ArrayLike, mode: str = "full") -> FlopscopeArray:
2331
+ """Counted version of np.convolve."""
2332
+ budget = require_budget()
2333
+ if not isinstance(a, _np.ndarray):
2334
+ a = _np.asarray(a)
2335
+ if not isinstance(v, _np.ndarray):
2336
+ v = _np.asarray(v)
2337
+ cost = _builtins.max(2 * a.size * v.size - a.size - v.size, 1)
2338
+ with budget.deduct(
2339
+ "convolve",
2340
+ flop_cost=cost,
2341
+ subscripts=None,
2342
+ shapes=(a.shape, v.shape),
2343
+ ):
2344
+ result = _call_numpy(
2345
+ _np.convolve, _to_base_ndarray(a), _to_base_ndarray(v), mode=mode
2346
+ ) # type: ignore[arg-type]
2347
+ return result # type: ignore[return-value] # wrapped at fnp.convolve import time
2348
+
2349
+
2350
+ attach_docstring(
2351
+ convolve, _np.convolve, "counted_custom", "2*n*m - n - m FLOPs (FMA=1)"
2352
+ )
2353
+
2354
+
2355
+ @_counted_wrapper
2356
+ def correlate(a: ArrayLike, v: ArrayLike, mode: str = "valid") -> FlopscopeArray:
2357
+ """Counted version of np.correlate."""
2358
+ budget = require_budget()
2359
+ if not isinstance(a, _np.ndarray):
2360
+ a = _np.asarray(a)
2361
+ if not isinstance(v, _np.ndarray):
2362
+ v = _np.asarray(v)
2363
+ cost = _builtins.max(2 * a.size * v.size - a.size - v.size, 1)
2364
+ with budget.deduct(
2365
+ "correlate",
2366
+ flop_cost=cost,
2367
+ subscripts=None,
2368
+ shapes=(a.shape, v.shape),
2369
+ ):
2370
+ result = _call_numpy(
2371
+ _np.correlate, _to_base_ndarray(a), _to_base_ndarray(v), mode=mode
2372
+ ) # type: ignore[arg-type]
2373
+ return result # type: ignore[return-value] # wrapped at fnp.correlate import time
2374
+
2375
+
2376
+ attach_docstring(
2377
+ correlate, _np.correlate, "counted_custom", "2*n*m - n - m FLOPs (FMA=1)"
2378
+ )
2379
+
2380
+
2381
+ def _cov_cost(x, y=None):
2382
+ """Cost for corrcoef/cov: 2 * f^2 * s.
2383
+
2384
+ For a (f, s) input: f features, s samples.
2385
+ Covariance requires f^2 dot products of length s, plus mean subtraction.
2386
+ """
2387
+ if not isinstance(x, _np.ndarray):
2388
+ x = _np.asarray(x)
2389
+ if x.ndim == 1:
2390
+ f, s = 1, x.shape[0]
2391
+ else:
2392
+ f, s = x.shape[0], x.shape[1]
2393
+ if y is not None:
2394
+ y_arr = _np.asarray(y)
2395
+ f2 = 1 if y_arr.ndim == 1 else y_arr.shape[0]
2396
+ f += f2
2397
+ return _builtins.max(2 * f * f * s, 1)
2398
+
2399
+
2400
+ @_counted_wrapper
2401
+ def corrcoef(x: ArrayLike, y: ArrayLike | None = None, **kwargs: Any) -> FlopscopeArray:
2402
+ """Counted version of np.corrcoef. Cost: 2 * f^2 * s FLOPs."""
2403
+ budget = require_budget()
2404
+ if not isinstance(x, _np.ndarray):
2405
+ x = _np.asarray(x)
2406
+ cost = _cov_cost(x, y)
2407
+ with budget.deduct("corrcoef", flop_cost=cost, subscripts=None, shapes=(x.shape,)):
2408
+ result = _call_numpy(
2409
+ _np.corrcoef,
2410
+ _to_base_ndarray(x),
2411
+ y=_to_base_ndarray(y) if y is not None else None, # type: ignore[arg-type]
2412
+ **kwargs,
2413
+ )
2414
+ return result # type: ignore[return-value] # wrapped at fnp.corrcoef import time
2415
+
2416
+
2417
+ attach_docstring(corrcoef, _np.corrcoef, "counted_custom", r"$2 f^2 s$ FLOPs")
2418
+ corrcoef.__signature__ = _inspect.signature(_np.corrcoef) # pyright: ignore[reportFunctionMemberAccess]
2419
+
2420
+
2421
+ @_counted_wrapper
2422
+ def cov(m: ArrayLike, y: ArrayLike | None = None, **kwargs: Any) -> FlopscopeArray:
2423
+ """Counted version of np.cov. Cost: 2 * f^2 * s FLOPs."""
2424
+ budget = require_budget()
2425
+ if not isinstance(m, _np.ndarray):
2426
+ m = _np.asarray(m)
2427
+ cost = _cov_cost(m, y)
2428
+ with budget.deduct("cov", flop_cost=cost, subscripts=None, shapes=(m.shape,)):
2429
+ result = _call_numpy(
2430
+ _np.cov,
2431
+ _to_base_ndarray(m),
2432
+ y=_to_base_ndarray(y) if y is not None else None, # type: ignore[arg-type]
2433
+ **kwargs,
2434
+ )
2435
+ return result # type: ignore[return-value] # wrapped at fnp.cov import time
2436
+
2437
+
2438
+ attach_docstring(cov, _np.cov, "counted_custom", r"$2 f^2 s$ FLOPs")
2439
+ cov.__signature__ = _inspect.signature(_np.cov) # pyright: ignore[reportFunctionMemberAccess]
2440
+
2441
+
2442
+ @_counted_wrapper
2443
+ def trapezoid(
2444
+ y: ArrayLike, x: ArrayLike | None = None, dx: float = 1.0, axis: int = -1
2445
+ ) -> FlopscopeArray:
2446
+ """Counted version of np.trapezoid."""
2447
+ budget = require_budget()
2448
+ if not isinstance(y, _np.ndarray):
2449
+ y = _np.asarray(y)
2450
+ with budget.deduct(
2451
+ "trapezoid", flop_cost=y.size, subscripts=None, shapes=(y.shape,)
2452
+ ):
2453
+ result = _call_numpy(
2454
+ _np.trapezoid,
2455
+ _to_base_ndarray(y),
2456
+ x=_to_base_ndarray(x) if x is not None else None, # type: ignore[arg-type]
2457
+ dx=dx,
2458
+ axis=axis,
2459
+ )
2460
+ return result # type: ignore[return-value] # wrapped at fnp.trapezoid import time
2461
+
2462
+
2463
+ attach_docstring(trapezoid, _np.trapezoid, "counted_custom", "numel(input) FLOPs")
2464
+
2465
+
2466
+ if hasattr(_np, "trapz"):
2467
+
2468
+ @_counted_wrapper
2469
+ def trapz( # pyright: ignore[reportRedeclaration]
2470
+ y: ArrayLike, x: ArrayLike | None = None, dx: float = 1.0, axis: int = -1
2471
+ ) -> FlopscopeArray:
2472
+ """Counted version of np.trapz (deprecated alias for trapezoid)."""
2473
+ budget = require_budget()
2474
+ if not isinstance(y, _np.ndarray):
2475
+ y = _np.asarray(y)
2476
+ with budget.deduct(
2477
+ "trapz", flop_cost=y.size, subscripts=None, shapes=(y.shape,)
2478
+ ):
2479
+ result = _call_numpy(
2480
+ _np.trapz,
2481
+ _to_base_ndarray(y),
2482
+ x=_to_base_ndarray(x) if x is not None else None,
2483
+ dx=dx,
2484
+ axis=axis,
2485
+ )
2486
+ return result # type: ignore[return-value] # wrapped at fnp.trapz import time
2487
+
2488
+ attach_docstring(trapz, _np.trapz, "counted_custom", "numel(input) FLOPs")
2489
+
2490
+ else:
2491
+
2492
+ def trapz(*args, **kwargs):
2493
+ raise UnsupportedFunctionError(
2494
+ "trapz", max_version="2.4", replacement="trapezoid"
2495
+ )
2496
+
2497
+
2498
+ @_counted_wrapper
2499
+ def interp(x: ArrayLike, xp: ArrayLike, fp: ArrayLike, **kwargs: Any) -> FlopscopeArray:
2500
+ """Counted version of np.interp. Cost: n * ceil(log2(len(xp))) FLOPs."""
2501
+ budget = require_budget()
2502
+ if not isinstance(x, _np.ndarray):
2503
+ x = _np.asarray(x)
2504
+ xp_arr = _np.asarray(xp)
2505
+ n = _builtins.max(x.size, 1)
2506
+ xp_len = _builtins.max(xp_arr.size, 1)
2507
+ cost = _builtins.max(n * _ceil_log2(xp_len), 1)
2508
+ with budget.deduct(
2509
+ "interp", flop_cost=cost, subscripts=None, shapes=(x.shape, xp_arr.shape)
2510
+ ):
2511
+ result = _call_numpy(
2512
+ _np.interp,
2513
+ _to_base_ndarray(x),
2514
+ _to_base_ndarray(xp), # type: ignore[arg-type]
2515
+ _to_base_ndarray(fp), # type: ignore[arg-type]
2516
+ **kwargs,
2517
+ )
2518
+ return result # type: ignore[return-value] # wrapped at fnp.interp import time
2519
+
2520
+
2521
+ attach_docstring(interp, _np.interp, "counted_custom", "n * ceil(log2(xp)) FLOPs")
2522
+ interp.__signature__ = _inspect.signature(_np.interp) # pyright: ignore[reportFunctionMemberAccess]