symvb 2.0.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.
- symvb/__init__.py +18 -0
- symvb/_o2_benchmark.py +163 -0
- symvb/_o2_blocked.py +301 -0
- symvb/_o2_symmetric.py +124 -0
- symvb/data.py +24 -0
- symvb/fixed_psi.py +272 -0
- symvb/functions.py +231 -0
- symvb/huckel.py +322 -0
- symvb/mo_projection.py +252 -0
- symvb/molecule.py +736 -0
- symvb/numerical.py +267 -0
- symvb/operators.py +667 -0
- symvb/orbital_permutations.py +49 -0
- symvb/slaterdet.py +142 -0
- symvb/spin.py +233 -0
- symvb/symmetry.py +467 -0
- symvb/system.py +354 -0
- symvb/test_det_conventions.py +292 -0
- symvb/test_fixed_psi.py +170 -0
- symvb/test_functions.py +95 -0
- symvb/test_molecule.py +467 -0
- symvb/test_non_default_flags.py +124 -0
- symvb/test_numerical.py +72 -0
- symvb/test_o2_agreement.py +548 -0
- symvb/test_o2_symmetric.py +193 -0
- symvb/test_operators.py +227 -0
- symvb/test_slaterdet.py +93 -0
- symvb/test_symmetry_exact.py +234 -0
- symvb/test_system.py +297 -0
- symvb-2.0.0.dist-info/METADATA +152 -0
- symvb-2.0.0.dist-info/RECORD +35 -0
- symvb-2.0.0.dist-info/WHEEL +5 -0
- symvb-2.0.0.dist-info/licenses/LICENSE +21 -0
- symvb-2.0.0.dist-info/top_level.txt +1 -0
- symvb-2.0.0.dist-info/zip-safe +1 -0
symvb/symmetry.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Automatic symmetry detection and symmetry-adapted basis construction.
|
|
3
|
+
|
|
4
|
+
Two detection strategies:
|
|
5
|
+
|
|
6
|
+
1. `degenerate_block_basis` — numerical eigenanalysis.
|
|
7
|
+
Diagonalise a single (H, S) numerically and group the eigenvectors by
|
|
8
|
+
degenerate eigenvalue. Each degenerate cluster spans an irrep subspace.
|
|
9
|
+
Fully automatic, no group theory, immediately useful but only exposes the
|
|
10
|
+
block structure at the specific parameter point used.
|
|
11
|
+
|
|
12
|
+
2. `detect_permutation_group` — graph-automorphism detection via pynauty.
|
|
13
|
+
Encode the symbolic (H, S) as a vertex- and edge-coloured graph; pynauty
|
|
14
|
+
(via Weisfeiler–Leman refinement + backtracking) returns a minimal set of
|
|
15
|
+
generators for the group of basis permutations that preserve the matrices
|
|
16
|
+
symbolically. Requires the optional dependency `pynauty`.
|
|
17
|
+
|
|
18
|
+
Also provided:
|
|
19
|
+
|
|
20
|
+
generate_group(generators, N) — enumerate a group from generators.
|
|
21
|
+
totally_symmetric_basis(generators, N) — orbit-sum basis for the trivial
|
|
22
|
+
irrep; any H commuting with the generators block-diagonalises here,
|
|
23
|
+
and the ground state of benzene-like problems lives in this block.
|
|
24
|
+
signed_totally_symmetric_basis(signed_generators, N) — numeric variant
|
|
25
|
+
tracking fermion signs (needed above half filling).
|
|
26
|
+
signed_totally_symmetric_basis_exact(signed_generators, N) — the same
|
|
27
|
+
subspace in exact arithmetic (sympy columns, no tolerances).
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import sympy as sp
|
|
33
|
+
from scipy.linalg import eigh
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def degenerate_block_basis(H_num, S_num=None, tol=1e-8):
|
|
37
|
+
"""
|
|
38
|
+
Diagonalise (H, S) and group eigenvectors by degenerate eigenvalue.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
H_num : ndarray of shape (N, N).
|
|
43
|
+
S_num : ndarray of shape (N, N), optional. Generalized overlap metric.
|
|
44
|
+
tol : eigenvalue degeneracy tolerance.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
evals : (N,) ndarray of sorted eigenvalues.
|
|
49
|
+
evecs : (N, N) ndarray; columns are eigenvectors, already a
|
|
50
|
+
symmetry-adapted basis up to within-block unitary freedom.
|
|
51
|
+
blocks : list of (eigenvalue, column_indices) pairs, one per irrep cluster.
|
|
52
|
+
"""
|
|
53
|
+
if S_num is None:
|
|
54
|
+
evals, evecs = np.linalg.eigh(H_num)
|
|
55
|
+
else:
|
|
56
|
+
evals, evecs = eigh(H_num, S_num)
|
|
57
|
+
|
|
58
|
+
blocks = []
|
|
59
|
+
i = 0
|
|
60
|
+
while i < len(evals):
|
|
61
|
+
j = i + 1
|
|
62
|
+
while j < len(evals) and abs(evals[j] - evals[i]) < tol:
|
|
63
|
+
j += 1
|
|
64
|
+
blocks.append((float(evals[i]), list(range(i, j))))
|
|
65
|
+
i = j
|
|
66
|
+
return evals, evecs, blocks
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _canon(expr):
|
|
70
|
+
"""Canonical hashable key for a sympy expression.
|
|
71
|
+
|
|
72
|
+
Sympy `Expr` objects are already canonically comparable (equal expressions
|
|
73
|
+
hash to the same value), so we use the expression itself wrapped for
|
|
74
|
+
robustness. We only fall back to `sp.expand` when two expressions that
|
|
75
|
+
might be equal don't hash the same.
|
|
76
|
+
"""
|
|
77
|
+
return sp.sympify(expr)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def detect_permutation_group(H_sym, S_sym=None):
|
|
81
|
+
"""
|
|
82
|
+
Find every basis permutation that leaves H_sym (and S_sym if given)
|
|
83
|
+
unchanged as a symbolic expression, using pynauty.
|
|
84
|
+
|
|
85
|
+
The matrices are encoded as a colored graph:
|
|
86
|
+
vertex color of i = (H[i,i], S[i,i])
|
|
87
|
+
edge color of (i,j) = (H[i,j], S[i,j])
|
|
88
|
+
Edge colors are converted to vertex colors via auxiliary vertices.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
H_sym : sympy Matrix of shape (N, N).
|
|
93
|
+
S_sym : sympy Matrix of shape (N, N), optional.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
generators : list of ndarray of shape (N,). A generating set for the
|
|
98
|
+
automorphism group; each array is a permutation of 0..N-1.
|
|
99
|
+
group_order: int. Order of the full group.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
import pynauty
|
|
103
|
+
except ImportError as e:
|
|
104
|
+
raise ImportError(
|
|
105
|
+
"pynauty is required for detect_permutation_group. "
|
|
106
|
+
"Install with `pip install pynauty` (may need `--no-binary :all:` "
|
|
107
|
+
"on older CPUs without AVX2)."
|
|
108
|
+
) from e
|
|
109
|
+
|
|
110
|
+
if not isinstance(H_sym, sp.Matrix):
|
|
111
|
+
H_sym = sp.Matrix(H_sym)
|
|
112
|
+
if S_sym is not None and not isinstance(S_sym, sp.Matrix):
|
|
113
|
+
S_sym = sp.Matrix(S_sym)
|
|
114
|
+
|
|
115
|
+
N = H_sym.shape[0]
|
|
116
|
+
|
|
117
|
+
def vcol(i):
|
|
118
|
+
if S_sym is None:
|
|
119
|
+
return (_canon(H_sym[i, i]),)
|
|
120
|
+
return (_canon(H_sym[i, i]), _canon(S_sym[i, i]))
|
|
121
|
+
|
|
122
|
+
def ecol(i, j):
|
|
123
|
+
if S_sym is None:
|
|
124
|
+
return (_canon(H_sym[i, j]),)
|
|
125
|
+
return (_canon(H_sym[i, j]), _canon(S_sym[i, j]))
|
|
126
|
+
|
|
127
|
+
zero_key = ((_canon(0),) if S_sym is None
|
|
128
|
+
else (_canon(0), _canon(0)))
|
|
129
|
+
|
|
130
|
+
# Auxiliary vertex per non-zero edge, grouped by edge-color
|
|
131
|
+
edge_class = {}
|
|
132
|
+
edge_aux = {}
|
|
133
|
+
aux = N
|
|
134
|
+
for i in range(N):
|
|
135
|
+
for j in range(i + 1, N):
|
|
136
|
+
c = ecol(i, j)
|
|
137
|
+
if c == zero_key:
|
|
138
|
+
continue
|
|
139
|
+
edge_aux[(i, j)] = aux
|
|
140
|
+
edge_class.setdefault(c, []).append(aux)
|
|
141
|
+
aux += 1
|
|
142
|
+
|
|
143
|
+
total = aux
|
|
144
|
+
|
|
145
|
+
g = pynauty.Graph(total)
|
|
146
|
+
for (i, j), a in edge_aux.items():
|
|
147
|
+
g.connect_vertex(a, [i, j])
|
|
148
|
+
|
|
149
|
+
vclass = {}
|
|
150
|
+
for i in range(N):
|
|
151
|
+
vclass.setdefault(vcol(i), []).append(i)
|
|
152
|
+
|
|
153
|
+
partition = [set(v) for v in vclass.values()] + [set(e) for e in edge_class.values()]
|
|
154
|
+
g.set_vertex_coloring(partition)
|
|
155
|
+
|
|
156
|
+
gens_raw, order, *_ = pynauty.autgrp(g)
|
|
157
|
+
|
|
158
|
+
# Trim each generator to the first N entries. pynauty should keep aux
|
|
159
|
+
# vertices within their own color class, so original vertices permute
|
|
160
|
+
# among themselves.
|
|
161
|
+
generators = []
|
|
162
|
+
for gen in gens_raw:
|
|
163
|
+
perm = np.asarray(gen[:N], dtype=int)
|
|
164
|
+
if set(perm.tolist()) == set(range(N)):
|
|
165
|
+
generators.append(perm)
|
|
166
|
+
return generators, int(order)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _compose(p, q):
|
|
170
|
+
"""Permutation composition (p o q)(i) = p[q[i]]."""
|
|
171
|
+
return np.asarray([p[q[i]] for i in range(len(p))], dtype=int)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def generate_group(generators, N=None):
|
|
175
|
+
"""
|
|
176
|
+
Enumerate every permutation in the group generated by `generators`.
|
|
177
|
+
Returns a list of ndarrays.
|
|
178
|
+
"""
|
|
179
|
+
if N is None:
|
|
180
|
+
N = len(generators[0]) if generators else 0
|
|
181
|
+
identity = tuple(range(N))
|
|
182
|
+
seen = {identity}
|
|
183
|
+
queue = [np.arange(N)]
|
|
184
|
+
elements = [np.arange(N)]
|
|
185
|
+
while queue:
|
|
186
|
+
g = queue.pop()
|
|
187
|
+
for gen in generators:
|
|
188
|
+
new = _compose(gen, g)
|
|
189
|
+
key = tuple(new.tolist())
|
|
190
|
+
if key not in seen:
|
|
191
|
+
seen.add(key)
|
|
192
|
+
queue.append(new)
|
|
193
|
+
elements.append(new)
|
|
194
|
+
return elements
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def apply_orbital_permutation(orbital_map, basis_dets, canon_fn):
|
|
198
|
+
"""
|
|
199
|
+
Induce a basis permutation from an orbital-label permutation.
|
|
200
|
+
|
|
201
|
+
Given a permutation of single-particle orbital labels (e.g. C_6 on
|
|
202
|
+
{a,b,c,d,e,f} -> {b,c,d,e,f,a}) and a list of basis determinants,
|
|
203
|
+
compute which basis index each det maps to and the associated fermion
|
|
204
|
+
sign (from re-canonicalising the permuted string). Returns None if the
|
|
205
|
+
permutation does not preserve the basis set (i.e. a permuted det is
|
|
206
|
+
not representable in the given basis).
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
orbital_map : dict mapping each lowercase label to its image.
|
|
211
|
+
Uppercase (beta) images are derived automatically.
|
|
212
|
+
basis_dets : list of strings, one per basis det, in canonical form.
|
|
213
|
+
canon_fn : callable taking a det_string, returning (canonical_string,
|
|
214
|
+
sign). Typically wraps SlaterDet(s).get_sorted().
|
|
215
|
+
|
|
216
|
+
Returns
|
|
217
|
+
-------
|
|
218
|
+
perm : ndarray of shape (N,). Basis index `i` maps to index `perm[i]`.
|
|
219
|
+
signs: ndarray of shape (N,) with +/- 1 fermion signs.
|
|
220
|
+
"""
|
|
221
|
+
lower = list(orbital_map.keys())
|
|
222
|
+
upper_map = {k.upper(): v.upper() for k, v in orbital_map.items()}
|
|
223
|
+
translate = str.maketrans({**orbital_map, **upper_map})
|
|
224
|
+
|
|
225
|
+
index_of = {d: i for i, d in enumerate(basis_dets)}
|
|
226
|
+
N = len(basis_dets)
|
|
227
|
+
perm = np.empty(N, dtype=int)
|
|
228
|
+
signs = np.empty(N, dtype=int)
|
|
229
|
+
for i, d in enumerate(basis_dets):
|
|
230
|
+
image = d.translate(translate)
|
|
231
|
+
canon, sgn = canon_fn(image)
|
|
232
|
+
if canon not in index_of:
|
|
233
|
+
return None, None
|
|
234
|
+
perm[i] = index_of[canon]
|
|
235
|
+
signs[i] = sgn
|
|
236
|
+
return perm, signs
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def totally_symmetric_basis(generators, N):
|
|
240
|
+
"""
|
|
241
|
+
Construct the orbit-sum basis for the trivial irrep: a column for each
|
|
242
|
+
orbit, uniform weight 1/sqrt(orbit_size) on the orbit members, zero
|
|
243
|
+
elsewhere. Columns are orthogonal (different orbits don't overlap) and
|
|
244
|
+
unit-normalised.
|
|
245
|
+
|
|
246
|
+
Any H commuting with every generator is block-diagonal in the resulting
|
|
247
|
+
decomposition; this function returns only the totally-symmetric block,
|
|
248
|
+
which contains the ground state of benzene-like problems.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
generators : list of ndarray permutations of 0..N-1.
|
|
253
|
+
N : int.
|
|
254
|
+
|
|
255
|
+
Returns
|
|
256
|
+
-------
|
|
257
|
+
U : ndarray of shape (N, k) where k is the number of orbits.
|
|
258
|
+
orbits : list of index lists.
|
|
259
|
+
|
|
260
|
+
Notes
|
|
261
|
+
-----
|
|
262
|
+
This is an UNSIGNED orbit-sum projector and is correct only when every
|
|
263
|
+
group operation acts on the basis with sign +1 (e.g. benzene at half-
|
|
264
|
+
filling, sub-half-filled rings). For over-half-filled fermionic Slater
|
|
265
|
+
determinants (e.g. C4H4 dianion: 6 electrons in 4 orbitals) the
|
|
266
|
+
permutation representation carries -1 signs that the orbit sum
|
|
267
|
+
misses; use `signed_totally_symmetric_basis` instead.
|
|
268
|
+
"""
|
|
269
|
+
if not generators:
|
|
270
|
+
U = np.eye(N)
|
|
271
|
+
return U, [[i] for i in range(N)]
|
|
272
|
+
|
|
273
|
+
elements = generate_group(generators, N)
|
|
274
|
+
assigned = [False] * N
|
|
275
|
+
orbits = []
|
|
276
|
+
for i in range(N):
|
|
277
|
+
if assigned[i]:
|
|
278
|
+
continue
|
|
279
|
+
orb = set()
|
|
280
|
+
for g in elements:
|
|
281
|
+
orb.add(int(g[i]))
|
|
282
|
+
for j in orb:
|
|
283
|
+
assigned[j] = True
|
|
284
|
+
orbits.append(sorted(orb))
|
|
285
|
+
|
|
286
|
+
U = np.zeros((N, len(orbits)))
|
|
287
|
+
for k, orb in enumerate(orbits):
|
|
288
|
+
for idx in orb:
|
|
289
|
+
U[idx, k] = 1.0
|
|
290
|
+
U[:, k] /= np.sqrt(len(orb))
|
|
291
|
+
return U, orbits
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def signed_totally_symmetric_basis(signed_generators, N, tol=1e-8,
|
|
295
|
+
max_order=2048):
|
|
296
|
+
"""
|
|
297
|
+
Sign-aware variant of `totally_symmetric_basis`.
|
|
298
|
+
|
|
299
|
+
Build the trivial-irrep projector P = (1/|G|) Sum_g rho(g), where
|
|
300
|
+
rho(g) is the SIGNED permutation representation on the basis (each
|
|
301
|
+
basis element transforms with a +/- 1 fermion sign). Return an
|
|
302
|
+
orthonormal basis of the +1-eigenspace of P.
|
|
303
|
+
|
|
304
|
+
Use this in place of `totally_symmetric_basis` whenever the basis
|
|
305
|
+
elements are fermionic Slater determinants and the chosen filling
|
|
306
|
+
can introduce a -1 sign upon group action -- most notably for
|
|
307
|
+
over-half-filled rings (e.g. C4H4 dianion, L=4 with N=6). For
|
|
308
|
+
sub-half-filled / half-filled benzene-like systems the signs are
|
|
309
|
+
uniformly +1 and this function recovers the same subspace as
|
|
310
|
+
`totally_symmetric_basis`.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
signed_generators : iterable of (perm, signs) tuples
|
|
315
|
+
Each generator is a (perm, signs) pair as returned by
|
|
316
|
+
`apply_orbital_permutation`. `perm[i]` is the basis index that
|
|
317
|
+
element i maps to; `signs[i]` is the +/- 1 fermion sign.
|
|
318
|
+
N : int
|
|
319
|
+
Basis dimension.
|
|
320
|
+
tol : float
|
|
321
|
+
Tolerance for identifying +1 eigenvectors of the projector.
|
|
322
|
+
max_order : int
|
|
323
|
+
Safety cap on enumerated group order.
|
|
324
|
+
|
|
325
|
+
Returns
|
|
326
|
+
-------
|
|
327
|
+
U : ndarray of shape (N, k)
|
|
328
|
+
Orthonormal columns spanning the totally-symmetric subspace.
|
|
329
|
+
group_order : int
|
|
330
|
+
|G|, the number of group elements enumerated.
|
|
331
|
+
"""
|
|
332
|
+
gens_mats = []
|
|
333
|
+
for perm, signs in signed_generators:
|
|
334
|
+
M = np.zeros((N, N))
|
|
335
|
+
for i in range(N):
|
|
336
|
+
M[int(perm[i]), i] = float(signs[i])
|
|
337
|
+
gens_mats.append(M)
|
|
338
|
+
|
|
339
|
+
def key(M):
|
|
340
|
+
return tuple(np.asarray(M).flatten().round(10))
|
|
341
|
+
|
|
342
|
+
identity = np.eye(N)
|
|
343
|
+
seen = {key(identity)}
|
|
344
|
+
elements = [identity]
|
|
345
|
+
queue = [identity]
|
|
346
|
+
while queue and len(elements) < max_order:
|
|
347
|
+
g = queue.pop()
|
|
348
|
+
for M in gens_mats:
|
|
349
|
+
new = M @ g
|
|
350
|
+
k = key(new)
|
|
351
|
+
if k not in seen:
|
|
352
|
+
seen.add(k)
|
|
353
|
+
queue.append(new)
|
|
354
|
+
elements.append(new)
|
|
355
|
+
|
|
356
|
+
P = sum(elements) / len(elements)
|
|
357
|
+
P_sym = 0.5 * (P + P.T)
|
|
358
|
+
evals, evecs = np.linalg.eigh(P_sym)
|
|
359
|
+
U = evecs[:, np.abs(evals - 1.0) < tol]
|
|
360
|
+
return U, len(elements)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def signed_totally_symmetric_basis_exact(signed_generators, N,
|
|
364
|
+
max_order=2048):
|
|
365
|
+
"""
|
|
366
|
+
Exact-arithmetic variant of `signed_totally_symmetric_basis`.
|
|
367
|
+
|
|
368
|
+
Enumerate the signed permutation group generated by `signed_generators`
|
|
369
|
+
over the integers, form the (implicit) Reynolds projector
|
|
370
|
+
P = (1/|G|) Sum_g rho(g), and return an exact orthonormal basis of its
|
|
371
|
+
image, the totally-symmetric subspace. No floating point, no
|
|
372
|
+
tolerances: the group is closed over integer (perm, sign) pairs and
|
|
373
|
+
each basis column is a signed orbit sum with entries +-1/sqrt(orbit
|
|
374
|
+
size), so the result lives in Q extended by square roots and can be
|
|
375
|
+
fed directly into symbolic reductions (U.T * H * U) and `lambdify`.
|
|
376
|
+
|
|
377
|
+
The basis ordering is deterministic: one column per sign-unfrustrated
|
|
378
|
+
orbit, orbits taken in order of their smallest basis index. An orbit
|
|
379
|
+
contributes no column when some group element fixes a member with
|
|
380
|
+
fermion sign -1 (the orbit sum then cancels exactly); this is how the
|
|
381
|
+
-1 signs of over-half-filled rings reduce the dimension relative to
|
|
382
|
+
the unsigned `totally_symmetric_basis`. When every sign is +1 the
|
|
383
|
+
columns coincide with the unsigned orbit sums.
|
|
384
|
+
|
|
385
|
+
Parameters
|
|
386
|
+
----------
|
|
387
|
+
signed_generators : iterable of (perm, signs) tuples
|
|
388
|
+
Each generator is a (perm, signs) pair as returned by
|
|
389
|
+
`apply_orbital_permutation`. `perm[i]` is the basis index that
|
|
390
|
+
element i maps to; `signs[i]` is the +/- 1 fermion sign.
|
|
391
|
+
N : int
|
|
392
|
+
Basis dimension.
|
|
393
|
+
max_order : int
|
|
394
|
+
Safety cap on the enumerated group order; exceeding it raises
|
|
395
|
+
RuntimeError (the group did not close within the cap).
|
|
396
|
+
|
|
397
|
+
Returns
|
|
398
|
+
-------
|
|
399
|
+
U : sympy Matrix of shape (N, k)
|
|
400
|
+
Exactly orthonormal columns spanning the totally-symmetric
|
|
401
|
+
subspace.
|
|
402
|
+
group_order : int
|
|
403
|
+
|G|, the number of signed group elements enumerated.
|
|
404
|
+
"""
|
|
405
|
+
gens = []
|
|
406
|
+
for perm, signs in signed_generators:
|
|
407
|
+
p = tuple(int(x) for x in perm)
|
|
408
|
+
s = tuple(int(x) for x in signs)
|
|
409
|
+
if sorted(p) != list(range(N)):
|
|
410
|
+
raise ValueError(f"generator perm {p} is not a permutation "
|
|
411
|
+
f"of 0..{N - 1}")
|
|
412
|
+
if any(x not in (-1, 1) for x in s):
|
|
413
|
+
raise ValueError("generator signs must be +-1")
|
|
414
|
+
gens.append((p, s))
|
|
415
|
+
|
|
416
|
+
# Close the group over exact integer (perm, sign) pairs.
|
|
417
|
+
# rho(g): e_i -> signs[i] * e_{perm[i]}; composing g2 after g1 gives
|
|
418
|
+
# e_i -> s1[i]*s2[p1[i]] * e_{p2[p1[i]]}.
|
|
419
|
+
identity = (tuple(range(N)), (1,) * N)
|
|
420
|
+
seen = {identity}
|
|
421
|
+
elements = [identity]
|
|
422
|
+
queue = [identity]
|
|
423
|
+
while queue:
|
|
424
|
+
p1, s1 = queue.pop()
|
|
425
|
+
for p2, s2 in gens:
|
|
426
|
+
el = (tuple(p2[p1[i]] for i in range(N)),
|
|
427
|
+
tuple(s1[i] * s2[p1[i]] for i in range(N)))
|
|
428
|
+
if el not in seen:
|
|
429
|
+
if len(elements) >= max_order:
|
|
430
|
+
raise RuntimeError(
|
|
431
|
+
f"group did not close within max_order={max_order}")
|
|
432
|
+
seen.add(el)
|
|
433
|
+
queue.append(el)
|
|
434
|
+
elements.append(el)
|
|
435
|
+
|
|
436
|
+
# One candidate column per orbit: |G| * P e_i has integer entry
|
|
437
|
+
# sum_{g: g(i)=j} signs_g(i) at position j. Within an orbit the entries
|
|
438
|
+
# are uniformly +-|Stab(i)|, or all zero when the stabilizer character
|
|
439
|
+
# is frustrated (some stabilizer element carries sign -1).
|
|
440
|
+
assigned = [False] * N
|
|
441
|
+
cols = []
|
|
442
|
+
for i in range(N):
|
|
443
|
+
if assigned[i]:
|
|
444
|
+
continue
|
|
445
|
+
coef = [0] * N
|
|
446
|
+
orbit = set()
|
|
447
|
+
for p, s in elements:
|
|
448
|
+
coef[p[i]] += s[i]
|
|
449
|
+
orbit.add(p[i])
|
|
450
|
+
for j in orbit:
|
|
451
|
+
assigned[j] = True
|
|
452
|
+
magnitudes = {abs(coef[j]) for j in orbit}
|
|
453
|
+
if magnitudes == {0}:
|
|
454
|
+
continue
|
|
455
|
+
if len(magnitudes) != 1:
|
|
456
|
+
raise RuntimeError(
|
|
457
|
+
f"non-uniform orbit sum {sorted(magnitudes)} on orbit of "
|
|
458
|
+
f"index {i}; signed generators are inconsistent")
|
|
459
|
+
m = magnitudes.pop()
|
|
460
|
+
norm = m * sp.sqrt(len(orbit))
|
|
461
|
+
v = sp.zeros(N, 1)
|
|
462
|
+
for j in orbit:
|
|
463
|
+
v[j] = sp.Integer(coef[j]) / norm
|
|
464
|
+
cols.append(v)
|
|
465
|
+
|
|
466
|
+
U = sp.Matrix.hstack(*cols) if cols else sp.zeros(N, 0)
|
|
467
|
+
return U, len(elements)
|