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