Skip to content
Merged
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,61 @@
# Changelog

## Unreleased

### Billing impact

One change here raises prices, in one narrow case. **`full` and `full_like`
with a non-scalar `fill_value` no longer carry an inferred symmetry tag**, so
operations on their results are priced as the dense arrays they are — measured
at `+50%` on `fnp.sin` and `+55%` on `fnp.einsum("ij,ij->")` for a `(3, 3)`
result built from a length-3 fill.

The rise reaches only calls that were being given a tag the data did not
support: a non-scalar fill into a shape with two or more equal-length axes.
A non-scalar fill into any other shape was never tagged and is unchanged
(a `(3, 4)` result still costs 384 on `fnp.sin`, a `(5,)` result still 160),
and a scalar `fill_value` keeps its tag and its price at every shape. Values
are unchanged throughout.

**It ships as a new version opening a new phase, so no submission is
re-evaluated against it: nothing already scored is repriced.**

### Symmetry

- **A symmetry tag now matches the buffer it is attached to.** `as_symmetric`
and the public `SymmetricTensor` constructor validate within a tolerance,
but the cost model reads the resulting tag as exact. They now copy one
representative per orbit before attaching it, so the two agree. Data that is
already exactly invariant is passed through unchanged, so the common case
keeps its zero-copy view semantics — including its use as an `out=`
destination with a caller-chosen layout. Charges are unchanged.

- **`symmetrize` gains `mode=`.** The default, `"reynolds-projection"`, is the
existing behaviour and is unchanged in both result and price. The new
`"canonical-copy"` keeps each orbit's lexicographically first entry instead
of averaging the orbit, bills `numel(data)` — the rate every other
materializing copy pays — and preserves the input dtype rather than
promoting to float64. It reads only the group's generators, so it stays
available for groups too large to enumerate. `fnp.random.symmetric` takes
the same argument.

- **Reynolds symmetrization refuses an unenumerable group before charging for
it.** `symmetrize` and `fnp.random.symmetric` previously computed the cost
of a projection from a closed-form group order, charged it, and only then
hit the enumeration limit. They now raise `ValueError` first, naming
`mode="canonical-copy"`, and the refused call costs nothing.

- **`full` and `full_like` no longer infer symmetry from shape when
`fill_value` is not a scalar.** The inferred group describes a constant
fill; a broadcast array fill writes distinct values into positions it would
otherwise report as redundant.

- **Symmetry attachment inside the package routes through
`wrap_with_derived_symmetry`.** Trust is anchored to
`wrap_with_trusted_symmetry` alone, so `wrap_with_symmetry` — which nothing
internal calls — is validated and charged like any other caller-supplied
claim.

## v0.12.0 (2026-08-21)

### Billing impact
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cost-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ each backed by a CI-enforced test you can open and read:
| **No substitution arbitrage** | a bit-identical alias cannot bill cheaper than its canonical (e.g. `acos` *is* `arccos` — the 16× ufunc-alias fix); equivalent contractions (`dot`/`inner`/`matmul`/`einsum`) share one cost engine | `test_ufunc_alias_parity.py`, `test_random_weight_aliasing.py`; the shared einsum engine ([§Contraction](#contraction-einsum-family)) |
| **No unpriceable or mispriceable dtype** | a non-numeric dtype (`dtype.kind` outside the allowlist `"biufc"` — object, string, bytes, structured/void, datetime64, timedelta64) is refused before any charge — as an operand, an explicit `dtype=`, or an `out=` destination — because it either has unbounded per-element cost (object) or a real per-element cost no flat rate captures (the rest); a dtype that is still zero-itemsize once NumPy materialises it is the one exception, since it carries no data either way — `'U0'`/`'S0'` are not, because NumPy promotes them to `'U1'`/`'S1'` on allocation | `tests/test_object_dtype_ban.py` |
| **No cheap in-op path** | top-k `svd(k=)` cannot yield a *full* decomposition below full price (the `min(4mnk, economy)` cap + `k ≥ min → full` guard); invalid `k` (`< 1` or `> min(m, n)`) is rejected before any billing | `test_svd_topk_cost.py` (cap / guard / monotonicity); `test_linalg.py` (invalid-`k` `ValueError`) |
| **No free symmetry tag over a bare top-level constructor call** | a *bare, top-level* `SymmetricTensor(data, symmetry=…)` call cannot mint a tag over data that doesn't actually satisfy the claimed group: it now validates the claim through the same check `as_symmetric(data, symmetry=…)` already used, raising `SymmetryError` on a mismatch and charging `k·(7n − 1)` (`k` = non-identity generators, `n = data.size`) for a genuine one — and symmetry a flopscope op derives *internally* (e.g. `exp` propagating the tag of an already-validated operand, or a slice/transpose view) is exempt from re-paying, since it never reaches an unvalidated, caller-supplied claim. **Two narrower, in-process-only routes remain open and are NOT closed by this**: calling `flopscope._symmetry_utils.wrap_with_symmetry` directly (it only checks that the group's axes fit `ndim`, never buffer contents — trusted by this constructor for its ~30 legitimate internal uses in `_array_ops.py`), and constructing inside a participant callback a counted host op invokes (`fnp.apply_along_axis`, `fnp.piecewise`, …), which inherits the host op's trust. Neither is remotely reachable — `wrap_with_symmetry` is outside the server's op REGISTRY and unexported, and callback ops raise `RemoteCallbackError` on the server backend — so both are pinned as `strict=True` `xfail` regression tests rather than fixed, to avoid repricing the ~34 internal call sites that legitimately rely on the same trust | `tests/test_symmetric_tensor_new_validation.py` (incl. the two pinned `xfail`s); `tests/test_symmetric_cost.py` (`k·(7n−1)` rate) |
| **A symmetry tag matches the buffer it is attached to** | symmetry validation uses a tolerance (`np.allclose`, `atol=1e-6`, `rtol=1e-5`) while the cost model reads the tag as exact — it prices every position in an orbit after the first as a redundant degree of freedom and does not read the buffer again. The two untrusted ingress points, `as_symmetric(data, symmetry=…)` and a *bare, top-level* `SymmetricTensor(data, symmetry=…)`, therefore validate the claim (raising `SymmetryError` on a mismatch, charging `k·(7n − 1)` for a genuine one, `k` = non-identity generators, `n = data.size`) and then canonicalize: each orbit takes the value at its lexicographically smallest index, so values that survived only within the tolerance do not reach the tag. Data already exactly invariant is passed through unchanged, so the charge and the zero-copy view semantics are unaffected; the caller's array is never modified. Symmetry a flopscope op derives *internally* (e.g. `exp` propagating an already-validated operand's tag, or a slice/transpose view) is exempt, since it never carries a fresh caller-supplied claim. **Scope:** this is a property of those two ingress points, not a package-wide invariant — a Reynolds projection sums each orbit in a fixed element order, so its own output is typically invariant only to about an ulp, as is a symmetric matmul. That residue is rounding rather than caller-placed information, so it is sound for accounting; code needing bit-exactness should use an ingress point or `_canonical_symmetry.is_exactly_invariant`. In-process code can attach a tag regardless (`arr.view(SymmetricTensor)` plus an attribute assignment needs nothing from this package), so the boundary that holds is the wire: the server dispatches registered ops only, and none of those names is registered | `tests/test_symmetry_canonicalization.py`; `tests/test_symmetric_tensor_new_validation.py`; `tests/test_symmetric_cost.py` (`k·(7n−1)` rate) |
| **Free-tier discipline** | weight 0 is limited to views/metadata, untouched (zero-page or uninitialized) allocation, and the narrow `astype`/`asarray` no-op (`copy=False` with an already-matching dtype; a dtype-free or dtype-matching `asarray`) — every other cast or copy, including a same-dtype `astype(copy=True)`, bills `numel` like `copy`. Any **metered** op that writes a new buffer — copied, replicated, constant-filled, gathered, or scattered — carries weight ≥ 1 (ndarray methods inherited from numpy are outside the meter by design — see [§The meter boundary](#the-meter-boundary)). Every value-test is charged wherever it hides: `a.nonzero()` (method), `where(1-arg)`, `argwhere`, `flatnonzero`, `count_nonzero` | `test_weight_tier_policy.py`; `test_data_movement_free_tier.py` (free-labels consistency guard) |
| **No free-gather discount** | a computed-index gather (`take`, `take_along_axis`, `choose`) is metered at the access tier (weight 4.0) like any other non-sequential read, so precomputing a look-up table and then gathering from it no longer buys a categorical discount; only genuine view-indexing (a static/basic index, `arr[i]`) stays free | `test_data_movement_free_tier.py`; [§Copy and gather](#copy-and-gather) |
| **Complex packing non-profitable** | folding two real payloads into one complex op's real/imag lanes bills the op's true complex structure (`multiply` factor 6, matmul exact `≈4.13×`), so the pack costs more than the honest real work it replaces | `tests/test_dtype_cost.py` (packing tests) |
Expand Down
14 changes: 11 additions & 3 deletions src/flopscope/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,22 @@ def reduction_cache_info():


def clear_cache() -> None:
"""Clear all flopscope LRU caches (einsum + reduction).
"""Clear all flopscope LRU caches (einsum, reduction, symmetry orbit maps).

Convenience aggregate over :func:`einsum_clear_caches` and
:func:`reduction_clear_cache`. Use the per-domain variants if you
only need to invalidate one cache.
:func:`reduction_clear_cache`, plus the symmetry orbit-map cache. Use the
per-domain variants if you only need to invalidate one cache.

The orbit-map cache is the one worth clearing for memory rather than
correctness: each entry holds one index per element of a distinct
``(shape, group action)``, so it grows with the tensors it has been asked
about rather than with the number of operations.
"""
from flopscope._canonical_symmetry import clear_canonical_map_cache

einsum_clear_caches()
reduction_clear_cache()
clear_canonical_map_cache()


def remote_unsupported_ops() -> frozenset[str]:
Expand Down
Loading
Loading