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.
- benchmarks/__init__.py +1 -0
- benchmarks/__main__.py +6 -0
- benchmarks/_baseline.py +171 -0
- benchmarks/_bitwise.py +231 -0
- benchmarks/_complex.py +176 -0
- benchmarks/_contractions.py +291 -0
- benchmarks/_fft.py +198 -0
- benchmarks/_impl_urls.py +139 -0
- benchmarks/_linalg.py +197 -0
- benchmarks/_linalg_delegates.py +407 -0
- benchmarks/_metadata.py +141 -0
- benchmarks/_misc.py +653 -0
- benchmarks/_perf.py +321 -0
- benchmarks/_perm_group_calibration.py +175 -0
- benchmarks/_pointwise.py +372 -0
- benchmarks/_polynomial.py +193 -0
- benchmarks/_random.py +209 -0
- benchmarks/_reductions.py +136 -0
- benchmarks/_sorting.py +289 -0
- benchmarks/_stats.py +137 -0
- benchmarks/_window.py +92 -0
- benchmarks/accumulation/__init__.py +0 -0
- benchmarks/accumulation/bench_cost_compute.py +138 -0
- benchmarks/dashboard.py +312 -0
- benchmarks/runner.py +636 -0
- flopscope/__init__.py +273 -0
- flopscope/_accumulation/__init__.py +13 -0
- flopscope/_accumulation/_bipartite.py +121 -0
- flopscope/_accumulation/_burnside.py +51 -0
- flopscope/_accumulation/_cache.py +146 -0
- flopscope/_accumulation/_components.py +153 -0
- flopscope/_accumulation/_cost.py +1414 -0
- flopscope/_accumulation/_cost_descriptions.py +63 -0
- flopscope/_accumulation/_detection.py +318 -0
- flopscope/_accumulation/_ladder.py +191 -0
- flopscope/_accumulation/_output_orbit.py +104 -0
- flopscope/_accumulation/_partition.py +290 -0
- flopscope/_accumulation/_path_info.py +211 -0
- flopscope/_accumulation/_public.py +169 -0
- flopscope/_accumulation/_reduction.py +310 -0
- flopscope/_accumulation/_regimes.py +303 -0
- flopscope/_accumulation/_shape.py +33 -0
- flopscope/_accumulation/_wreath.py +209 -0
- flopscope/_budget.py +1027 -0
- flopscope/_config.py +118 -0
- flopscope/_counting_ops.py +451 -0
- flopscope/_display.py +478 -0
- flopscope/_docstrings.py +59 -0
- flopscope/_dtypes.py +20 -0
- flopscope/_einsum.py +717 -0
- flopscope/_errstate.py +25 -0
- flopscope/_flops.py +282 -0
- flopscope/_free_ops.py +2654 -0
- flopscope/_ndarray.py +1126 -0
- flopscope/_opt_einsum/LICENSE +21 -0
- flopscope/_opt_einsum/NOTICE +59 -0
- flopscope/_opt_einsum/__init__.py +209 -0
- flopscope/_opt_einsum/_contract.py +1478 -0
- flopscope/_opt_einsum/_helpers.py +164 -0
- flopscope/_opt_einsum/_hsluv.py +273 -0
- flopscope/_opt_einsum/_path_random.py +462 -0
- flopscope/_opt_einsum/_paths.py +1653 -0
- flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
- flopscope/_opt_einsum/_symmetry.py +140 -0
- flopscope/_opt_einsum/_typing.py +37 -0
- flopscope/_perm_group.py +717 -0
- flopscope/_pointwise.py +2522 -0
- flopscope/_polynomial.py +278 -0
- flopscope/_registry.py +3216 -0
- flopscope/_sorting_ops.py +571 -0
- flopscope/_symmetric.py +812 -0
- flopscope/_symmetry_transport.py +510 -0
- flopscope/_symmetry_utils.py +669 -0
- flopscope/_type_info.py +12 -0
- flopscope/_unwrap.py +70 -0
- flopscope/_validation.py +83 -0
- flopscope/_version_check.py +46 -0
- flopscope/_weights.py +195 -0
- flopscope/_window.py +177 -0
- flopscope/accounting.py +565 -0
- flopscope/data/default_weights.json +462 -0
- flopscope/data/weights.csv +509 -0
- flopscope/errors.py +197 -0
- flopscope/numpy/__init__.py +878 -0
- flopscope/numpy/fft/__init__.py +55 -0
- flopscope/numpy/fft/_free.py +51 -0
- flopscope/numpy/fft/_transforms.py +695 -0
- flopscope/numpy/linalg/__init__.py +105 -0
- flopscope/numpy/linalg/_aliases.py +126 -0
- flopscope/numpy/linalg/_compound.py +161 -0
- flopscope/numpy/linalg/_decompositions.py +353 -0
- flopscope/numpy/linalg/_properties.py +533 -0
- flopscope/numpy/linalg/_solvers.py +444 -0
- flopscope/numpy/linalg/_svd.py +122 -0
- flopscope/numpy/random/__init__.py +684 -0
- flopscope/numpy/random/_cost_formulas.py +115 -0
- flopscope/numpy/random/_counted_classes.py +241 -0
- flopscope/numpy/testing/__init__.py +13 -0
- flopscope/numpy/typing/__init__.py +30 -0
- flopscope/py.typed +0 -0
- flopscope/stats/__init__.py +84 -0
- flopscope/stats/_base.py +77 -0
- flopscope/stats/_cauchy.py +146 -0
- flopscope/stats/_erf.py +190 -0
- flopscope/stats/_expon.py +146 -0
- flopscope/stats/_laplace.py +150 -0
- flopscope/stats/_logistic.py +148 -0
- flopscope/stats/_lognorm.py +160 -0
- flopscope/stats/_ndtri.py +133 -0
- flopscope/stats/_norm.py +149 -0
- flopscope/stats/_truncnorm.py +186 -0
- flopscope/stats/_uniform.py +141 -0
- flopscope-0.2.0.dist-info/METADATA +23 -0
- flopscope-0.2.0.dist-info/RECORD +115 -0
- flopscope-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""LaTeX and human-readable descriptions, built on demand from regime metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
LATEX_BY_REGIME: dict[str, str] = {
|
|
6
|
+
"trivial": r"\alpha = M = |X| = \prod_{\ell \in L} n_\ell",
|
|
7
|
+
"functionalProjection": r"\alpha = M = |X / G|",
|
|
8
|
+
"singleton": (
|
|
9
|
+
r"\alpha = \frac{n_\Omega}{|G|}\sum_{g}\left(\prod_{c \in R} n_c\right)"
|
|
10
|
+
r"\left(n_\Omega^{c_\Omega(g)} - (n_\Omega-1)^{c_\Omega(g)}\right)"
|
|
11
|
+
),
|
|
12
|
+
"young": r"\alpha = \binom{n_L+|V|-1}{|V|}\binom{n_L+|W|-1}{|W|}",
|
|
13
|
+
"partitionCount": (
|
|
14
|
+
r"\alpha = \sum_{\tilde{x}\in P_{\mathrm{typed}}(L)/G} "
|
|
15
|
+
r"\frac{\prod_s (n_s)_{b_s(\tilde{x})}}{|\overline{G}_{\tilde{x}}|}"
|
|
16
|
+
r"\,|A_{\tilde{x}}/H|"
|
|
17
|
+
),
|
|
18
|
+
"unavailable": r"\alpha = \text{unavailable}",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
LATEX_SYMBOLIC_BY_REGIME: dict[str, str] = {
|
|
22
|
+
"trivial": r"\alpha = M",
|
|
23
|
+
"functionalProjection": r"\alpha = M",
|
|
24
|
+
"singleton": r"\alpha = \#\{(O,Q): \pi_V(O)\cap Q\ne\varnothing\},\ |V|=1",
|
|
25
|
+
"young": r"\alpha = |\mathrm{Multiset}_n(V)|\,|\mathrm{Multiset}_n(W)|",
|
|
26
|
+
"partitionCount": r"\alpha = \#\{(O,Q): \pi_V(O)\cap Q\ne\varnothing\}",
|
|
27
|
+
"unavailable": r"\alpha = \text{unavailable}",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def describe_component(component) -> dict[str, str]:
|
|
32
|
+
"""LaTeX strings for a single component."""
|
|
33
|
+
return {
|
|
34
|
+
"latex": LATEX_BY_REGIME.get(component.regime_id, ""),
|
|
35
|
+
"latex_symbolic": LATEX_SYMBOLIC_BY_REGIME.get(component.regime_id, ""),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def describe_total(cost) -> dict:
|
|
40
|
+
"""Summary dict for a whole-einsum AccumulationCost."""
|
|
41
|
+
return {
|
|
42
|
+
"total": cost.total,
|
|
43
|
+
"mu": cost.mu,
|
|
44
|
+
"alpha": cost.alpha,
|
|
45
|
+
"m_total": cost.m_total,
|
|
46
|
+
"dense_baseline": cost.dense_baseline,
|
|
47
|
+
"num_terms": cost.num_terms,
|
|
48
|
+
"savings_ratio": cost.savings_ratio,
|
|
49
|
+
"fallback_used": cost.fallback_used,
|
|
50
|
+
"unavailable_components": cost.unavailable_components,
|
|
51
|
+
"per_component": [
|
|
52
|
+
{
|
|
53
|
+
"labels": c.labels,
|
|
54
|
+
"m": c.m,
|
|
55
|
+
"alpha": c.alpha,
|
|
56
|
+
"regime_id": c.regime_id,
|
|
57
|
+
"shape": c.shape,
|
|
58
|
+
"group_name": c.group_name,
|
|
59
|
+
**c.describe(),
|
|
60
|
+
}
|
|
61
|
+
for c in cost.per_component
|
|
62
|
+
],
|
|
63
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""σ-loop, π-canonical derivation, and whole-expression G_pt construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from flopscope._perm_group import _Permutation as Permutation
|
|
10
|
+
|
|
11
|
+
from ._bipartite import BipartiteGraph, IncidenceMatrix
|
|
12
|
+
from ._wreath import WreathElement
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SigmaResult:
|
|
17
|
+
"""One row of the σ-loop. Either σ is identity (skipped, π=identity), or σ is
|
|
18
|
+
non-identity and we either accepted a π or rejected (no fingerprint match)."""
|
|
19
|
+
|
|
20
|
+
is_valid: bool
|
|
21
|
+
is_identity: bool
|
|
22
|
+
skipped: bool
|
|
23
|
+
pi: dict[str, str] | None
|
|
24
|
+
pi_kind: Literal["identity", "v-only", "w-only", "cross-v-w"] | None
|
|
25
|
+
reason: str | None = None
|
|
26
|
+
sigma_row_perm: tuple[int, ...] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def derive_pi_canonical(
|
|
30
|
+
sigma_col_of: dict[str, tuple[int, ...]],
|
|
31
|
+
fp_to_labels: dict[tuple[int, ...], frozenset[str]],
|
|
32
|
+
v_labels: frozenset[str],
|
|
33
|
+
w_labels: frozenset[str],
|
|
34
|
+
) -> dict[str, str] | None:
|
|
35
|
+
"""For each label, look up σ(M)'s column fingerprint in fp_to_labels and pick the
|
|
36
|
+
lex-first unused candidate. Validates bijectivity. Returns None when no
|
|
37
|
+
consistent π exists.
|
|
38
|
+
|
|
39
|
+
Note: π may legitimately mix V and W labels — cross-V/W actions are part of
|
|
40
|
+
the detected symmetry. The deprecated 'partition-preserving rejection' is NOT
|
|
41
|
+
reintroduced here. Mirrors algorithm.js#derivePi.
|
|
42
|
+
"""
|
|
43
|
+
pi: dict[str, str] = {}
|
|
44
|
+
used: set[str] = set()
|
|
45
|
+
for label in sorted(v_labels | w_labels):
|
|
46
|
+
fp = sigma_col_of[label]
|
|
47
|
+
candidates = fp_to_labels.get(fp)
|
|
48
|
+
if not candidates:
|
|
49
|
+
return None
|
|
50
|
+
pick = next((c for c in sorted(candidates) if c not in used), None)
|
|
51
|
+
if pick is None:
|
|
52
|
+
return None
|
|
53
|
+
pi[label] = pick
|
|
54
|
+
used.add(pick)
|
|
55
|
+
return pi
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def classify_pi(
|
|
59
|
+
pi: dict[str, str],
|
|
60
|
+
v_labels: frozenset[str],
|
|
61
|
+
w_labels: frozenset[str],
|
|
62
|
+
) -> dict[str, object]:
|
|
63
|
+
"""Classify a π's action: identity, v-only (preserves W pointwise),
|
|
64
|
+
w-only (preserves V pointwise), or cross-v-w."""
|
|
65
|
+
pi_is_identity = all(pi[lbl] == lbl for lbl in pi)
|
|
66
|
+
moves_v = any(pi[lbl] != lbl for lbl in v_labels if lbl in pi)
|
|
67
|
+
moves_w = any(pi[lbl] != lbl for lbl in w_labels if lbl in pi)
|
|
68
|
+
crosses_vw = any(
|
|
69
|
+
(lbl in v_labels and pi[lbl] in w_labels)
|
|
70
|
+
or (lbl in w_labels and pi[lbl] in v_labels)
|
|
71
|
+
for lbl in pi
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if pi_is_identity:
|
|
75
|
+
kind = "identity"
|
|
76
|
+
elif crosses_vw:
|
|
77
|
+
kind = "cross-v-w"
|
|
78
|
+
elif moves_v and not moves_w:
|
|
79
|
+
kind = "v-only"
|
|
80
|
+
elif moves_w and not moves_v:
|
|
81
|
+
kind = "w-only"
|
|
82
|
+
else:
|
|
83
|
+
kind = "cross-v-w" # both V and W move, but no V↔W swap — still classified as cross
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"piIsIdentity": pi_is_identity,
|
|
87
|
+
"piKind": kind,
|
|
88
|
+
"crosses": crosses_vw,
|
|
89
|
+
"movesV": moves_v,
|
|
90
|
+
"movesW": moves_w,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_sigma_loop(
|
|
95
|
+
graph: BipartiteGraph,
|
|
96
|
+
matrix_data: IncidenceMatrix,
|
|
97
|
+
wreath_elements: Sequence[WreathElement],
|
|
98
|
+
) -> tuple[SigmaResult, ...]:
|
|
99
|
+
"""Run the σ-loop: for each wreath element, derive π and classify it.
|
|
100
|
+
Mirrors algorithm.js#runSigmaLoop."""
|
|
101
|
+
results: list[SigmaResult] = []
|
|
102
|
+
v_labels = graph.free_labels
|
|
103
|
+
w_labels = graph.summed_labels
|
|
104
|
+
all_labels = graph.all_labels
|
|
105
|
+
|
|
106
|
+
for element in wreath_elements:
|
|
107
|
+
sigma_row_perm = tuple(element.row_perm.array_form)
|
|
108
|
+
is_identity = all(v == i for i, v in enumerate(sigma_row_perm))
|
|
109
|
+
|
|
110
|
+
if is_identity:
|
|
111
|
+
identity_pi = {lbl: lbl for lbl in all_labels}
|
|
112
|
+
results.append(
|
|
113
|
+
SigmaResult(
|
|
114
|
+
is_valid=True,
|
|
115
|
+
is_identity=True,
|
|
116
|
+
skipped=True,
|
|
117
|
+
pi=identity_pi,
|
|
118
|
+
pi_kind="identity",
|
|
119
|
+
sigma_row_perm=sigma_row_perm,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Compute σ(M) column fingerprints.
|
|
125
|
+
sigma_col_of: dict[str, tuple[int, ...]] = {}
|
|
126
|
+
for label in all_labels:
|
|
127
|
+
sigma_col_of[label] = tuple(
|
|
128
|
+
graph.incidence[sigma_row_perm[k]].get(label, 0)
|
|
129
|
+
for k in range(len(sigma_row_perm))
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
pi = derive_pi_canonical(
|
|
133
|
+
sigma_col_of, matrix_data.fp_to_labels, v_labels, w_labels
|
|
134
|
+
)
|
|
135
|
+
if pi is None:
|
|
136
|
+
results.append(
|
|
137
|
+
SigmaResult(
|
|
138
|
+
is_valid=False,
|
|
139
|
+
is_identity=False,
|
|
140
|
+
skipped=False,
|
|
141
|
+
pi=None,
|
|
142
|
+
pi_kind=None,
|
|
143
|
+
reason="No matching π (fingerprint mismatch)",
|
|
144
|
+
sigma_row_perm=sigma_row_perm,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
classification = classify_pi(pi, v_labels, w_labels)
|
|
150
|
+
results.append(
|
|
151
|
+
SigmaResult(
|
|
152
|
+
is_valid=True,
|
|
153
|
+
is_identity=False,
|
|
154
|
+
skipped=False,
|
|
155
|
+
pi=pi,
|
|
156
|
+
pi_kind=classification["piKind"], # type: ignore[arg-type]
|
|
157
|
+
sigma_row_perm=sigma_row_perm,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return tuple(results)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── build_full_group ─────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
import math
|
|
168
|
+
|
|
169
|
+
from flopscope._perm_group import _dimino
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class DetectedGroup:
|
|
174
|
+
"""The whole-expression G_pt."""
|
|
175
|
+
|
|
176
|
+
all_labels: tuple[str, ...]
|
|
177
|
+
generators: tuple[Permutation, ...]
|
|
178
|
+
elements: tuple[Permutation, ...]
|
|
179
|
+
group_name: str
|
|
180
|
+
action_summary: str
|
|
181
|
+
valid_pi_results: tuple[SigmaResult, ...]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _minimal_generators(
|
|
185
|
+
candidates: Sequence[Permutation],
|
|
186
|
+
) -> list[Permutation]:
|
|
187
|
+
"""Greedy minimal generating set: keep only generators that grow the closure."""
|
|
188
|
+
if len(candidates) <= 1:
|
|
189
|
+
return list(candidates)
|
|
190
|
+
selected: list[Permutation] = []
|
|
191
|
+
current_size = 1
|
|
192
|
+
for candidate in candidates:
|
|
193
|
+
trial = (*selected, candidate)
|
|
194
|
+
elements = _dimino(trial)
|
|
195
|
+
if len(elements) > current_size:
|
|
196
|
+
selected.append(candidate)
|
|
197
|
+
current_size = len(elements)
|
|
198
|
+
return selected
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _classify_group_name(
|
|
202
|
+
labels: Sequence[str],
|
|
203
|
+
generators: Sequence[Permutation],
|
|
204
|
+
elements: Sequence[Permutation],
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Mirror of fullGroup.js + algorithm.js#classifyGroupName.
|
|
207
|
+
|
|
208
|
+
Recognizes S_n, C_n, D_n, Z_2, S_2, and falls back to PermGroup⟨...⟩."""
|
|
209
|
+
order = len(elements)
|
|
210
|
+
degree = len(labels)
|
|
211
|
+
if degree < 2 or order <= 1:
|
|
212
|
+
return "trivial"
|
|
213
|
+
|
|
214
|
+
moved_set: set[int] = set()
|
|
215
|
+
for el in elements:
|
|
216
|
+
for i, v in enumerate(el.array_form):
|
|
217
|
+
if v != i:
|
|
218
|
+
moved_set.add(i)
|
|
219
|
+
moved_indices = sorted(moved_set)
|
|
220
|
+
moved_labels = [labels[i] for i in moved_indices] if moved_indices else list(labels)
|
|
221
|
+
effective_degree = len(moved_labels) or degree
|
|
222
|
+
label_set = "{" + ",".join(moved_labels) + "}"
|
|
223
|
+
|
|
224
|
+
if order == math.factorial(effective_degree):
|
|
225
|
+
return f"S{effective_degree}{label_set}"
|
|
226
|
+
if order == effective_degree and effective_degree >= 3:
|
|
227
|
+
# Cyclic check
|
|
228
|
+
for el in elements:
|
|
229
|
+
cycles = el.cyclic_form
|
|
230
|
+
if len(cycles) == 1 and len(cycles[0]) == effective_degree:
|
|
231
|
+
return f"C{effective_degree}{label_set}"
|
|
232
|
+
if order == 2 * effective_degree and effective_degree >= 3:
|
|
233
|
+
return (
|
|
234
|
+
f"D{effective_degree}{label_set}" # heuristic — matches JS classification
|
|
235
|
+
)
|
|
236
|
+
if order == 2 and degree == 2:
|
|
237
|
+
return f"S2{label_set}"
|
|
238
|
+
if order == 2 and effective_degree > 2:
|
|
239
|
+
return f"Z2{label_set}"
|
|
240
|
+
|
|
241
|
+
# Fallback: build cycle notation inline using cyclic_form + label substitution
|
|
242
|
+
def _gen_cycle_str(gen: Permutation, lbl_list: Sequence[str]) -> str:
|
|
243
|
+
cycles = gen.cyclic_form
|
|
244
|
+
if not cycles:
|
|
245
|
+
return "()"
|
|
246
|
+
return "".join(
|
|
247
|
+
"(" + " ".join(lbl_list[idx] for idx in cycle) + ")" for cycle in cycles
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
gen_str = ", ".join(_gen_cycle_str(g, labels) for g in generators)
|
|
251
|
+
return f"PermGroup⟨{gen_str}⟩"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _action_summary(
|
|
255
|
+
pi_kinds_seen: set[str],
|
|
256
|
+
) -> str:
|
|
257
|
+
"""Summarize the action of G as 'V-only' / 'W-only' / 'cross-V/W' / 'trivial'."""
|
|
258
|
+
if not pi_kinds_seen or pi_kinds_seen <= {"identity"}:
|
|
259
|
+
return "trivial"
|
|
260
|
+
if "cross-v-w" in pi_kinds_seen:
|
|
261
|
+
return "cross-V/W"
|
|
262
|
+
if "v-only" in pi_kinds_seen and "w-only" in pi_kinds_seen:
|
|
263
|
+
return "V-only × W-only"
|
|
264
|
+
if "v-only" in pi_kinds_seen:
|
|
265
|
+
return "V-only"
|
|
266
|
+
if "w-only" in pi_kinds_seen:
|
|
267
|
+
return "W-only"
|
|
268
|
+
return "trivial"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_full_group(
|
|
272
|
+
sigma_results: Sequence[SigmaResult],
|
|
273
|
+
*,
|
|
274
|
+
all_labels: Sequence[str],
|
|
275
|
+
) -> DetectedGroup:
|
|
276
|
+
"""Collect valid πs as full-degree permutations on `all_labels`, dedupe, find
|
|
277
|
+
a minimal generating set via greedy growth, run dimino. Mirrors fullGroup.js#buildFullGroup."""
|
|
278
|
+
label_idx = {lbl: i for i, lbl in enumerate(all_labels)}
|
|
279
|
+
candidates: list[Permutation] = []
|
|
280
|
+
seen_keys: set[tuple[int, ...]] = set()
|
|
281
|
+
pi_kinds_seen: set[str] = set()
|
|
282
|
+
valid: list[SigmaResult] = []
|
|
283
|
+
|
|
284
|
+
for r in sigma_results:
|
|
285
|
+
if not r.is_valid:
|
|
286
|
+
continue
|
|
287
|
+
if r.pi is not None:
|
|
288
|
+
valid.append(r)
|
|
289
|
+
if r.skipped or r.pi_kind == "identity":
|
|
290
|
+
if r.pi_kind is not None:
|
|
291
|
+
pi_kinds_seen.add(r.pi_kind)
|
|
292
|
+
continue
|
|
293
|
+
if r.pi is None:
|
|
294
|
+
continue
|
|
295
|
+
if r.pi_kind is not None:
|
|
296
|
+
pi_kinds_seen.add(r.pi_kind)
|
|
297
|
+
arr = tuple(label_idx[r.pi[lbl]] for lbl in all_labels)
|
|
298
|
+
if arr in seen_keys:
|
|
299
|
+
continue
|
|
300
|
+
seen_keys.add(arr)
|
|
301
|
+
candidates.append(Permutation(list(arr)))
|
|
302
|
+
|
|
303
|
+
minimal = _minimal_generators(candidates)
|
|
304
|
+
if minimal:
|
|
305
|
+
elements = _dimino(tuple(minimal))
|
|
306
|
+
else:
|
|
307
|
+
elements = [Permutation.identity(len(all_labels))] if all_labels else []
|
|
308
|
+
|
|
309
|
+
group_name = _classify_group_name(all_labels, minimal, elements)
|
|
310
|
+
|
|
311
|
+
return DetectedGroup(
|
|
312
|
+
all_labels=tuple(all_labels),
|
|
313
|
+
generators=tuple(minimal),
|
|
314
|
+
elements=tuple(elements),
|
|
315
|
+
group_name=group_name,
|
|
316
|
+
action_summary=_action_summary(pi_kinds_seen),
|
|
317
|
+
valid_pi_results=tuple(valid),
|
|
318
|
+
)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Regime ladder dispatcher and supporting data types.
|
|
2
|
+
|
|
3
|
+
The ladder runs per-component:
|
|
4
|
+
Stage 1: trivial short-circuit (|G| ≤ 1)
|
|
5
|
+
Stage 2a: functionalProjection takes priority (covers shape ∈ {allVisible, allSummed,
|
|
6
|
+
and mixed-but-functional})
|
|
7
|
+
Stage 2b: mixed regimes ladder — singleton, young, partitionCount
|
|
8
|
+
Fallthrough: 'unavailable' (brute-force orbit B.8 is excluded by policy)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal, cast
|
|
16
|
+
|
|
17
|
+
from flopscope._perm_group import _Permutation as Permutation
|
|
18
|
+
|
|
19
|
+
from ._shape import Shape
|
|
20
|
+
|
|
21
|
+
RegimeId = Literal[
|
|
22
|
+
"trivial",
|
|
23
|
+
"functionalProjection",
|
|
24
|
+
"singleton",
|
|
25
|
+
"young",
|
|
26
|
+
"partitionCount",
|
|
27
|
+
"unavailable",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
Decision = Literal["fired", "refused"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RegimeContext:
|
|
35
|
+
"""Input to a regime's recognize() and compute()."""
|
|
36
|
+
|
|
37
|
+
labels: tuple[str, ...]
|
|
38
|
+
va: tuple[str, ...]
|
|
39
|
+
wa: tuple[str, ...]
|
|
40
|
+
elements: tuple[Permutation, ...]
|
|
41
|
+
generators: tuple[Permutation, ...]
|
|
42
|
+
sizes: tuple[int, ...]
|
|
43
|
+
visible_positions: tuple[int, ...]
|
|
44
|
+
partition_budget: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Verdict:
|
|
49
|
+
"""Output of regime.recognize()."""
|
|
50
|
+
|
|
51
|
+
fired: bool
|
|
52
|
+
reason: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class RegimeOutput:
|
|
57
|
+
"""Output of regime.compute()."""
|
|
58
|
+
|
|
59
|
+
count: int
|
|
60
|
+
sub_steps: tuple[dict, ...] = ()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Regime:
|
|
65
|
+
"""A regime is identified by id and consists of recognize + compute callables."""
|
|
66
|
+
|
|
67
|
+
id: RegimeId
|
|
68
|
+
recognize: Callable[[RegimeContext], Verdict]
|
|
69
|
+
compute: Callable[[RegimeContext], RegimeOutput]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class RegimeStep:
|
|
74
|
+
"""One entry in the regime-dispatch trace."""
|
|
75
|
+
|
|
76
|
+
regime_id: RegimeId
|
|
77
|
+
decision: Decision
|
|
78
|
+
reason: str
|
|
79
|
+
sub_steps: tuple[dict, ...] = ()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class AccumulationResult:
|
|
84
|
+
"""Output of compute_accumulation() — the ladder's primitive output for one
|
|
85
|
+
component. Reused by future reduction-cost.
|
|
86
|
+
|
|
87
|
+
`count` is None when regime_id == 'unavailable' (partition budget exceeded
|
|
88
|
+
with brute-force disabled by policy)."""
|
|
89
|
+
|
|
90
|
+
count: int | None
|
|
91
|
+
regime_id: RegimeId
|
|
92
|
+
shape: Shape
|
|
93
|
+
trace: tuple[RegimeStep, ...]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── Dispatcher ───────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
import math
|
|
100
|
+
|
|
101
|
+
from flopscope._config import get_setting
|
|
102
|
+
|
|
103
|
+
from ._regimes import FUNCTIONAL_PROJECTION_REGIME, MIXED_REGIMES
|
|
104
|
+
from ._shape import detect_shape
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def compute_accumulation(
|
|
108
|
+
*,
|
|
109
|
+
labels: Sequence[str],
|
|
110
|
+
va: Sequence[str],
|
|
111
|
+
wa: Sequence[str],
|
|
112
|
+
elements: Sequence[Permutation],
|
|
113
|
+
generators: Sequence[Permutation],
|
|
114
|
+
sizes: Sequence[int],
|
|
115
|
+
visible_positions: Sequence[int],
|
|
116
|
+
partition_budget: int | None = None,
|
|
117
|
+
) -> AccumulationResult:
|
|
118
|
+
"""Run the regime ladder for a single component.
|
|
119
|
+
|
|
120
|
+
Stages mirror accumulationCount.js:
|
|
121
|
+
1. trivial short-circuit for |G| <= 1
|
|
122
|
+
2a. functionalProjection priority check (covers shape allVisible/allSummed
|
|
123
|
+
and mixed-but-functional)
|
|
124
|
+
2b. mixed-shape ladder: singleton, young, partitionCount
|
|
125
|
+
|
|
126
|
+
Returns AccumulationResult with count=None when no regime fires within
|
|
127
|
+
the partition budget (brute-force orbit B.8 is excluded by policy).
|
|
128
|
+
"""
|
|
129
|
+
if partition_budget is None:
|
|
130
|
+
partition_budget = cast(int, get_setting("partition_budget"))
|
|
131
|
+
|
|
132
|
+
shape = detect_shape(va=va, wa=wa, elements=elements)
|
|
133
|
+
|
|
134
|
+
# Stage 1: trivial short-circuit
|
|
135
|
+
if not elements or len(elements) <= 1:
|
|
136
|
+
return AccumulationResult(
|
|
137
|
+
count=math.prod(sizes) if sizes else 1,
|
|
138
|
+
regime_id="trivial",
|
|
139
|
+
shape=shape,
|
|
140
|
+
trace=(RegimeStep("trivial", "fired", "|G| = 1"),),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
ctx = RegimeContext(
|
|
144
|
+
labels=tuple(labels),
|
|
145
|
+
va=tuple(va),
|
|
146
|
+
wa=tuple(wa),
|
|
147
|
+
elements=tuple(elements),
|
|
148
|
+
generators=tuple(generators),
|
|
149
|
+
sizes=tuple(sizes),
|
|
150
|
+
visible_positions=tuple(visible_positions),
|
|
151
|
+
partition_budget=partition_budget,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
trace: list[RegimeStep] = []
|
|
155
|
+
|
|
156
|
+
# Stage 2a: functionalProjection priority
|
|
157
|
+
verdict = FUNCTIONAL_PROJECTION_REGIME.recognize(ctx)
|
|
158
|
+
if verdict.fired:
|
|
159
|
+
out = FUNCTIONAL_PROJECTION_REGIME.compute(ctx)
|
|
160
|
+
trace.append(
|
|
161
|
+
RegimeStep(
|
|
162
|
+
"functionalProjection",
|
|
163
|
+
"fired",
|
|
164
|
+
verdict.reason,
|
|
165
|
+
out.sub_steps,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return AccumulationResult(
|
|
169
|
+
out.count, "functionalProjection", shape, tuple(trace)
|
|
170
|
+
)
|
|
171
|
+
trace.append(RegimeStep("functionalProjection", "refused", verdict.reason))
|
|
172
|
+
|
|
173
|
+
# Stage 2b: mixed-shape ladder
|
|
174
|
+
for regime in MIXED_REGIMES:
|
|
175
|
+
verdict = regime.recognize(ctx)
|
|
176
|
+
if not verdict.fired:
|
|
177
|
+
trace.append(RegimeStep(regime.id, "refused", verdict.reason))
|
|
178
|
+
continue
|
|
179
|
+
out = regime.compute(ctx)
|
|
180
|
+
trace.append(RegimeStep(regime.id, "fired", verdict.reason, out.sub_steps))
|
|
181
|
+
return AccumulationResult(out.count, regime.id, shape, tuple(trace))
|
|
182
|
+
|
|
183
|
+
# Fallthrough: brute-force is excluded by policy → unavailable
|
|
184
|
+
trace.append(
|
|
185
|
+
RegimeStep(
|
|
186
|
+
"unavailable",
|
|
187
|
+
"fired",
|
|
188
|
+
"no exact regime fired within partition budget; brute-force disabled by policy",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
return AccumulationResult(None, "unavailable", shape, tuple(trace))
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""H = Stab_G(V)|_V helpers and canonical tuple operations.
|
|
2
|
+
|
|
3
|
+
Permutation convention matches `_perm_group.py`: source -> target arrays.
|
|
4
|
+
For tuple action: out[perm.arr[source]] = tuple[source].
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
|
|
11
|
+
from flopscope._perm_group import _Permutation as Permutation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tuple_array_key(tup: Sequence) -> str:
|
|
15
|
+
"""Stable string key for a tuple, used for orbit dedup. Mirrors JS tupleArrayKey."""
|
|
16
|
+
return "|".join(str(v) for v in tup)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def preserves_position_set(perm: Permutation, positions: Sequence[int]) -> bool:
|
|
20
|
+
"""True iff perm maps the position set into itself (setwise stabilizer test)."""
|
|
21
|
+
position_set = set(positions)
|
|
22
|
+
for source in positions:
|
|
23
|
+
if perm.array_form[source] not in position_set:
|
|
24
|
+
return False
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def restrict_to_positions(
|
|
29
|
+
perm: Permutation, positions: Sequence[int]
|
|
30
|
+
) -> Permutation | None:
|
|
31
|
+
"""Restrict perm to the given positions, returning a local-coordinate Permutation.
|
|
32
|
+
Returns None when perm doesn't preserve the position set.
|
|
33
|
+
"""
|
|
34
|
+
if not preserves_position_set(perm, positions):
|
|
35
|
+
return None
|
|
36
|
+
local_index = {
|
|
37
|
+
global_pos: local_pos for local_pos, global_pos in enumerate(positions)
|
|
38
|
+
}
|
|
39
|
+
arr = [local_index[perm.array_form[global_source]] for global_source in positions]
|
|
40
|
+
return Permutation(arr)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def restrict_stabilizer_to_positions(
|
|
44
|
+
elements: Iterable[Permutation], positions: Sequence[int]
|
|
45
|
+
) -> tuple[Permutation, ...]:
|
|
46
|
+
"""Restrict every G element that preserves `positions` to the local action on `positions`.
|
|
47
|
+
Deduplicates by string key (kernel of restriction collapses to one local element)."""
|
|
48
|
+
degree = len(positions)
|
|
49
|
+
if degree == 0:
|
|
50
|
+
return (Permutation.identity(0),)
|
|
51
|
+
|
|
52
|
+
by_key: dict[str, Permutation] = {}
|
|
53
|
+
for element in elements:
|
|
54
|
+
restricted = restrict_to_positions(element, positions)
|
|
55
|
+
if restricted is not None:
|
|
56
|
+
by_key[",".join(str(v) for v in restricted.array_form)] = restricted
|
|
57
|
+
|
|
58
|
+
if not by_key:
|
|
59
|
+
identity = Permutation.identity(degree)
|
|
60
|
+
return (identity,)
|
|
61
|
+
|
|
62
|
+
return tuple(by_key.values())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_permutation_to_tuple_array(tup: Sequence, perm: Permutation) -> list:
|
|
66
|
+
"""Apply perm to a tuple under the source-to-target convention:
|
|
67
|
+
out[perm.array_form[source]] = tup[source].
|
|
68
|
+
Mirrors JS applyPermutationToTupleArray.
|
|
69
|
+
"""
|
|
70
|
+
next_tuple: list = [None] * len(tup)
|
|
71
|
+
for source in range(len(tup)):
|
|
72
|
+
next_tuple[perm.array_form[source]] = tup[source]
|
|
73
|
+
return next_tuple
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def canonical_tuple_under_group(tup: Sequence, elements: Iterable[Permutation]) -> str:
|
|
77
|
+
"""Return the lex-smallest tuple key over the orbit of `tup` under `elements`."""
|
|
78
|
+
elements_tuple = tuple(elements)
|
|
79
|
+
if not elements_tuple:
|
|
80
|
+
return tuple_array_key(tup)
|
|
81
|
+
best: str | None = None
|
|
82
|
+
for element in elements_tuple:
|
|
83
|
+
moved = apply_permutation_to_tuple_array(tup, element)
|
|
84
|
+
key = tuple_array_key(moved)
|
|
85
|
+
if best is None or key < best:
|
|
86
|
+
best = key
|
|
87
|
+
assert best is not None
|
|
88
|
+
return best
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def visible_tuple_from_full_tuple(
|
|
92
|
+
full_tuple: Sequence, visible_positions: Sequence[int]
|
|
93
|
+
) -> list:
|
|
94
|
+
"""Project a full assignment tuple to its visible-label coordinates."""
|
|
95
|
+
return [full_tuple[position] for position in visible_positions]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def projection_is_functional(
|
|
99
|
+
elements: Iterable[Permutation], visible_positions: Sequence[int]
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""True iff every g in elements preserves the visible position set as a set.
|
|
102
|
+
When True, projection π_V descends to a well-defined map X/G → Y/H.
|
|
103
|
+
"""
|
|
104
|
+
return all(preserves_position_set(g, visible_positions) for g in elements)
|