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,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)