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/operators.py ADDED
@@ -0,0 +1,667 @@
1
+ """
2
+ symvb.operators — second-quantized operators acting on Slater determinants.
3
+
4
+ A small expression-tree framework for building physical operators from
5
+ elementary creation/annihilation operators and applying them to symvb
6
+ Slater dets.
7
+
8
+ Scope
9
+ -----
10
+ This module is for **orthogonal-AO, second-quantized** operators —
11
+ spin (S², Sz, S±, Sᵢ·Sⱼ), number / double-occupancy, hopping
12
+ (c†_i c_j), η-pairing, orbital-permutation symmetries, and the
13
+ projectors / VB structures built from them. The Slater determinants
14
+ are assumed orthonormal (i.e. AO overlap s = 0).
15
+
16
+ For non-orthogonal AOs (s ≠ 0) — i.e. for evaluating ⟨D | H | D'⟩
17
+ with one- and two-electron integrals — use the existing
18
+ `symvb.Molecule.build_matrix` pipeline, which goes through the Löwdin
19
+ cofactor expansion. The two paths are complementary, not redundant.
20
+
21
+ Quick tour
22
+ ----------
23
+ from symvb import operators as op
24
+
25
+ # primitives
26
+ op.c('a', 'alpha') # c_{a, α}
27
+ op.cdag('a', 'beta') # c†_{a, β}
28
+
29
+ # composites
30
+ op.number('a') # n̂_a = n̂_aα + n̂_aβ
31
+ op.double_occ('a') # n̂_aα n̂_aβ
32
+ op.hop('a', 'b', 'alpha') # c†_{a,α} c_{b,α} + h.c.
33
+ op.s_squared(['a', 'b']) # total S²
34
+ op.s_dot('a', 'b') # S_a · S_b
35
+
36
+ # algebra
37
+ H = -op.hop('a', 'b') + op.double_occ('a') + op.double_occ('b')
38
+
39
+ # action on a state
40
+ H.apply('aB') # → FixedPsi
41
+ H.apply(symvb.FixedPsi('aB')) # also accepted
42
+
43
+ # matrix in any basis (det strings, SlaterDets, FixedPsis):
44
+ H.matrix(['aB', 'bA', 'aA', 'bB']) # → sympy Matrix
45
+
46
+ Convention
47
+ ----------
48
+ Determinants are symvb strings: lowercase = α, uppercase = β. Internally
49
+ operators work in the **interleaved canonical form** that matches
50
+ `symvb.functions.generate_det_strings` and `symvb.spin._canon_det` —
51
+ sorted α and β are paired up in lex order, with any unmatched extras
52
+ appended (e.g. canonical for α={a,c}, β={b,d} is `aBcD`).
53
+
54
+ Jordan–Wigner ordering is also interleaved: `(a,α) < (a,β) < (b,α) <
55
+ (b,β) < …`. Operators emit FixedPsi states keyed by canonical strings,
56
+ with coefficients in the *user-string* convention — i.e. they are the
57
+ multipliers FixedPsi will reconstruct the physical state from when
58
+ parsing the string in left-to-right creation order.
59
+
60
+ Compose with `+`, `-`, scalar `*`, and `@` (composition); `A @ B`
61
+ means "B acts first, then A".
62
+ """
63
+ from __future__ import annotations
64
+
65
+ import numpy as np
66
+ import sympy as sp
67
+
68
+ import symvb
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Canonical det conversion (interleaved; matches generate_det_strings)
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def _parse_det(det_string):
76
+ """symvb det string → (alpha_set, beta_set, sign).
77
+
78
+ `sign` is the ±1 such that |det_string⟩_user = sign · |canonical(α,β)⟩,
79
+ where |canonical(α,β)⟩ has its creation operators in interleaved JW
80
+ order: (a,α) < (a,β) < (b,α) < (b,β) < … . Concretely, parse the
81
+ string left-to-right (creation order) into a list of (orb, spin)
82
+ spin-orbital tuples and return the parity of inversions in that list
83
+ when compared against tuple lex order.
84
+ """
85
+ alpha = set()
86
+ beta = set()
87
+ so_seq = []
88
+ for ch in det_string:
89
+ if ch.islower():
90
+ assert ch not in alpha, f"two electrons in spin-orbital ({ch}, α)"
91
+ alpha.add(ch)
92
+ so_seq.append((ch, 0))
93
+ else:
94
+ lc = ch.lower()
95
+ assert lc not in beta, f"two electrons in spin-orbital ({lc}, β)"
96
+ beta.add(lc)
97
+ so_seq.append((lc, 1))
98
+ return frozenset(alpha), frozenset(beta), _inversion_sign(so_seq)
99
+
100
+
101
+ def _inversion_sign(seq):
102
+ inv = 0
103
+ n = len(seq)
104
+ for i in range(n):
105
+ for j in range(i + 1, n):
106
+ if seq[i] > seq[j]:
107
+ inv += 1
108
+ return 1 if inv % 2 == 0 else -1
109
+
110
+
111
+ def _canonical_string(alpha, beta):
112
+ """Interleaved canonical det string for (α, β); matches
113
+ `symvb.functions.generate_det_strings` for any |α| / |β|.
114
+
115
+ Pairs sorted α and β letters in lex order, then appends extras of
116
+ whichever block is longer: e.g. ({a,b,c}, {d}) → 'aDbc'.
117
+ """
118
+ a = sorted(alpha)
119
+ b = sorted(beta)
120
+ Na, Nb = len(a), len(b)
121
+ s = ''
122
+ for i in range(min(Na, Nb)):
123
+ s += a[i] + b[i].upper()
124
+ for i in range(Nb, Na): # extra alphas
125
+ s += a[i]
126
+ for i in range(Na, Nb): # extra betas
127
+ s += b[i].upper()
128
+ return s
129
+
130
+
131
+ def canonicalize(det_string):
132
+ """Return (canonical_string, sign) for a symvb det string.
133
+
134
+ `|det_string⟩_user = sign · |canonical_string⟩_canonical`.
135
+ """
136
+ a, b, s = _parse_det(det_string)
137
+ return _canonical_string(a, b), s
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Elementary creation/annihilation in interleaved JW order
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def _apply_c(alpha, beta, orb, spin, create):
145
+ """Apply c[†]_{orb,spin} in interleaved canonical ordering.
146
+
147
+ Returns (new_alpha, new_beta, sign) or (None, None, 0) if annihilated.
148
+ Spin: 0 = α, 1 = β. Interleaved JW order: (orb, α) < (orb, β) <
149
+ (orb', α) < (orb', β) for orb < orb'.
150
+ """
151
+ occ = (orb in alpha) if spin == 0 else (orb in beta)
152
+ if create == occ:
153
+ return None, None, 0
154
+
155
+ # Count occupied modes strictly before (orb, spin) in interleaved order.
156
+ count = 0
157
+ for x in sorted(alpha | beta):
158
+ if x < orb:
159
+ count += int(x in alpha) + int(x in beta)
160
+ elif x == orb:
161
+ # (x, α) < (x, β); only counts if we are creating/annihilating β
162
+ # and the α-mode on the same orbital is occupied.
163
+ if spin == 1 and x in alpha:
164
+ count += 1
165
+ sgn = 1 if count % 2 == 0 else -1
166
+
167
+ if spin == 0:
168
+ new_a = (alpha | {orb}) if create else (alpha - {orb})
169
+ new_b = beta
170
+ else:
171
+ new_a = alpha
172
+ new_b = (beta | {orb}) if create else (beta - {orb})
173
+ return frozenset(new_a), frozenset(new_b), sgn
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Operator base class
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def _is_scalar(x):
181
+ return isinstance(x, (int, float, complex, sp.Expr)) and not isinstance(x, bool)
182
+
183
+
184
+ def _nonzero(x):
185
+ if isinstance(x, sp.Expr):
186
+ return sp.simplify(x) != 0
187
+ return x != 0
188
+
189
+
190
+ def _state_iter(state):
191
+ """Yield (det_string, coef) pairs from any supported state form."""
192
+ if isinstance(state, str):
193
+ yield (state, 1)
194
+ return
195
+ # SlaterDet has a det_string attribute but no dets/coefs.
196
+ if hasattr(state, 'det_string') and not hasattr(state, 'dets'):
197
+ yield (state.det_string, 1)
198
+ return
199
+ if hasattr(state, 'dets') and hasattr(state, 'coefs'):
200
+ for d, c in zip(state.dets, state.coefs):
201
+ yield (d.det_string, c)
202
+ return
203
+ if isinstance(state, dict):
204
+ for k, v in state.items():
205
+ yield (k, v)
206
+ return
207
+ raise TypeError(f"unsupported state type: {type(state).__name__}")
208
+
209
+
210
+ def _state_to_canonical_dict(state):
211
+ """{canonical_string: canonical-basis-coefficient} for any state form.
212
+
213
+ The output dict represents the state in the canonical basis (i.e.
214
+ coefficients on |canonical_string⟩_canonical, the interleaved-JW-ordered
215
+ canonical state). Conversion to user-string FixedPsi multiplies by
216
+ canonicalize(canonical_string)[1].
217
+ """
218
+ out = {}
219
+ for det_str, c_user in _state_iter(state):
220
+ a, b, sgn = _parse_det(det_str) # |det_str⟩_user = sgn · |canon(a,b)⟩
221
+ key = _canonical_string(a, b)
222
+ out[key] = out.get(key, 0) + c_user * sgn
223
+ return {k: v for k, v in out.items() if _nonzero(v)}
224
+
225
+
226
+ def _canonical_dict_to_fixedpsi(canon_dict):
227
+ """{canonical_string: canonical-coef} → FixedPsi (user-basis).
228
+
229
+ For each canonical string key, the FixedPsi coefficient is the
230
+ canonical coefficient times the sign relating |canonical_string⟩_user
231
+ (FixedPsi's interpretation) back to |canonical_string⟩_canonical.
232
+ Both ±1, so multiplication and division are the same.
233
+ """
234
+ psi = symvb.FixedPsi()
235
+ for canon_str, c_canon in canon_dict.items():
236
+ if not _nonzero(c_canon):
237
+ continue
238
+ sgn_user = canonicalize(canon_str)[1]
239
+ c_user = c_canon * sgn_user
240
+ if _nonzero(c_user):
241
+ psi.add_str_det(canon_str, coef=c_user)
242
+ return psi
243
+
244
+
245
+ class Operator:
246
+ """Abstract second-quantized operator.
247
+
248
+ Subclasses must implement
249
+ _apply_internal((alpha_set, beta_set))
250
+ -> dict {(α', β'): canonical-basis coefficient}
251
+ operating in the canonical (interleaved-JW) representation.
252
+
253
+ Public API:
254
+ apply(state) -> FixedPsi
255
+ matrix(basis) -> sympy Matrix in user basis
256
+ expectation(state) -> scalar (assumes orthonormal basis dets)
257
+ Algebra: +, -, scalar *, @ (composition).
258
+ """
259
+
260
+ # ---- subclass hook ----
261
+ def _apply_internal(self, ab):
262
+ raise NotImplementedError
263
+
264
+ # ---- internal: canonical-basis dict ----
265
+ def _apply_canonical(self, state):
266
+ """{canonical_string: canonical-basis coefficient} after applying self.
267
+
268
+ Goes through the canonical-basis representation; not part of the
269
+ public API. Used by `apply`, `matrix`, and `expectation`.
270
+ """
271
+ in_dict = _state_to_canonical_dict(state)
272
+ out = {}
273
+ for canon_in, c_in in in_dict.items():
274
+ a, b, _ = _parse_det(canon_in) # canon_in is its own canonical string
275
+ for (aa, bb), coef in self._apply_internal((a, b)).items():
276
+ key = _canonical_string(aa, bb)
277
+ out[key] = out.get(key, 0) + c_in * coef
278
+ return {k: v for k, v in out.items() if _nonzero(v)}
279
+
280
+ # ---- public API ----
281
+ def apply(self, state):
282
+ """Action on |state⟩. Returns a FixedPsi (user-basis).
283
+
284
+ `state` can be a symvb det string, a SlaterDet, a FixedPsi, or a
285
+ dict {det_string: coef}. The returned FixedPsi has dets keyed by
286
+ canonical (interleaved) strings.
287
+ """
288
+ return _canonical_dict_to_fixedpsi(self._apply_canonical(state))
289
+
290
+ def matrix(self, basis):
291
+ """Build M_ij = ⟨bᵢ| O |bⱼ⟩ as a sympy Matrix.
292
+
293
+ Each `bᵢ` may be a det string, a SlaterDet, or a FixedPsi. The
294
+ underlying symvb dets are assumed orthonormal (s = 0). Use
295
+ `Molecule.build_matrix` for the s ≠ 0 path.
296
+ """
297
+ N = len(basis)
298
+ basis_canon = [_state_to_canonical_dict(b) for b in basis]
299
+ M = sp.zeros(N, N)
300
+ for j in range(N):
301
+ applied = {}
302
+ for canon_d, c_canon in basis_canon[j].items():
303
+ a, b, _ = _parse_det(canon_d)
304
+ for (aa, bb), coef in self._apply_internal((a, b)).items():
305
+ key = _canonical_string(aa, bb)
306
+ applied[key] = applied.get(key, 0) + c_canon * coef
307
+ for i in range(N):
308
+ inner = 0
309
+ for d, c in applied.items():
310
+ if d in basis_canon[i]:
311
+ inner = inner + sp.conjugate(basis_canon[i][d]) * c
312
+ M[i, j] = M[i, j] + inner
313
+ return M
314
+
315
+ def expectation(self, state):
316
+ """⟨ψ| O |ψ⟩ assuming `state`'s dets are orthonormal."""
317
+ canon = _state_to_canonical_dict(state)
318
+ total = 0
319
+ for d_j, c_j in canon.items():
320
+ a, b, _ = _parse_det(d_j)
321
+ for (aa, bb), coef in self._apply_internal((a, b)).items():
322
+ d_i = _canonical_string(aa, bb)
323
+ c_i = canon.get(d_i, 0)
324
+ total = total + sp.conjugate(c_i) * c_j * coef
325
+ return sp.simplify(total)
326
+
327
+ # ---- algebra ----
328
+ def __add__(self, other):
329
+ if other == 0:
330
+ return self
331
+ if not isinstance(other, Operator):
332
+ return NotImplemented
333
+ terms = []
334
+ for op in (self, other):
335
+ if isinstance(op, _Sum):
336
+ terms.extend(op.terms)
337
+ else:
338
+ terms.append((1, op))
339
+ return _Sum(terms)
340
+
341
+ def __radd__(self, other):
342
+ if other == 0:
343
+ return self
344
+ return self.__add__(other)
345
+
346
+ def __neg__(self):
347
+ if isinstance(self, _Sum):
348
+ return _Sum([(-c, op) for c, op in self.terms])
349
+ return _Sum([(-1, self)])
350
+
351
+ def __sub__(self, other):
352
+ return self + (-other)
353
+
354
+ def __mul__(self, other):
355
+ # scalar * operator OR operator * operator (composition)
356
+ if isinstance(other, Operator):
357
+ return self.__matmul__(other)
358
+ if _is_scalar(other):
359
+ if isinstance(self, _Sum):
360
+ return _Sum([(c * other, op) for c, op in self.terms])
361
+ return _Sum([(other, self)])
362
+ return NotImplemented
363
+
364
+ def __rmul__(self, other):
365
+ if _is_scalar(other):
366
+ return self.__mul__(other)
367
+ return NotImplemented
368
+
369
+ def __matmul__(self, other):
370
+ if not isinstance(other, Operator):
371
+ return NotImplemented
372
+ factors = []
373
+ if isinstance(self, _Product):
374
+ factors.extend(self.factors)
375
+ else:
376
+ factors.append(self)
377
+ if isinstance(other, _Product):
378
+ factors.extend(other.factors)
379
+ else:
380
+ factors.append(other)
381
+ return _Product(factors)
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Internal node types
386
+ # ---------------------------------------------------------------------------
387
+
388
+ class _LadderOp(Operator):
389
+ """Primitive c[†]_{orb,spin}."""
390
+
391
+ def __init__(self, orb, spin, dagger):
392
+ self.orb = orb
393
+ self.spin = spin
394
+ self.dagger = dagger
395
+
396
+ def _apply_internal(self, ab):
397
+ a, b, s = _apply_c(ab[0], ab[1], self.orb, self.spin, self.dagger)
398
+ if a is None:
399
+ return {}
400
+ return {(a, b): s}
401
+
402
+ def __repr__(self):
403
+ sym = 'c†' if self.dagger else 'c'
404
+ sp_label = 'α' if self.spin == 0 else 'β'
405
+ return f"{sym}_{{{self.orb},{sp_label}}}"
406
+
407
+
408
+ class _Sum(Operator):
409
+ """Linear combination Σ_k c_k · O_k."""
410
+
411
+ def __init__(self, terms):
412
+ self.terms = list(terms)
413
+
414
+ def _apply_internal(self, ab):
415
+ out = {}
416
+ for coef, op in self.terms:
417
+ for k, v in op._apply_internal(ab).items():
418
+ out[k] = out.get(k, 0) + coef * v
419
+ return {k: v for k, v in out.items() if _nonzero(v)}
420
+
421
+ def __repr__(self):
422
+ return ' + '.join(f"{c}·{op}" for c, op in self.terms)
423
+
424
+
425
+ class _Product(Operator):
426
+ """Composition F[0] · F[1] · … · F[-1]; rightmost applies first."""
427
+
428
+ def __init__(self, factors):
429
+ self.factors = list(factors)
430
+
431
+ def _apply_internal(self, ab):
432
+ cur = {ab: 1}
433
+ for op in reversed(self.factors):
434
+ new = {}
435
+ for ab_in, c_in in cur.items():
436
+ for ab_out, c_out in op._apply_internal(ab_in).items():
437
+ new[ab_out] = new.get(ab_out, 0) + c_in * c_out
438
+ cur = {k: v for k, v in new.items() if _nonzero(v)}
439
+ if not cur:
440
+ return {}
441
+ return cur
442
+
443
+ def __repr__(self):
444
+ return ' '.join(repr(f) for f in self.factors)
445
+
446
+
447
+ class _OrbitalPerm(Operator):
448
+ """Relabel orbital labels per `orbital_map`. Fermion sign tracked
449
+ using a unified inversion count over the (orb, spin) tuple sequence,
450
+ which is correct for interleaved JW even when the permutation
451
+ crosses the α/β interleave."""
452
+
453
+ def __init__(self, orbital_map):
454
+ self.orbital_map = dict(orbital_map)
455
+
456
+ def _apply_internal(self, ab):
457
+ a, b = ab
458
+ # Original creation order in interleaved canonical JW.
459
+ orig_modes = sorted(
460
+ [(x, 0) for x in a] + [(x, 1) for x in b]
461
+ )
462
+ new_modes = [(self.orbital_map.get(o, o), s) for (o, s) in orig_modes]
463
+ if len(set(new_modes)) != len(new_modes):
464
+ return {}
465
+ new_a = frozenset(o for (o, s) in new_modes if s == 0)
466
+ new_b = frozenset(o for (o, s) in new_modes if s == 1)
467
+ return {(new_a, new_b): _inversion_sign(new_modes)}
468
+
469
+ def __repr__(self):
470
+ items = sorted(self.orbital_map.items())
471
+ return 'P[' + ','.join(f"{k}→{v}" for k, v in items) + ']'
472
+
473
+
474
+ class _Identity(Operator):
475
+ def _apply_internal(self, ab):
476
+ return {ab: 1}
477
+
478
+ def __repr__(self):
479
+ return 'I'
480
+
481
+
482
+ def identity():
483
+ return _Identity()
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # High-level constructors
488
+ # ---------------------------------------------------------------------------
489
+
490
+ def _spin_index(spin):
491
+ if spin in (0, 'a', 'alpha', 'α'):
492
+ return 0
493
+ if spin in (1, 'b', 'beta', 'β'):
494
+ return 1
495
+ raise ValueError(f"unknown spin label: {spin!r}")
496
+
497
+
498
+ def c(orb, spin='alpha'):
499
+ """Annihilation operator c_{orb,spin}."""
500
+ return _LadderOp(orb, _spin_index(spin), dagger=False)
501
+
502
+
503
+ def cdag(orb, spin='alpha'):
504
+ """Creation operator c†_{orb,spin}."""
505
+ return _LadderOp(orb, _spin_index(spin), dagger=True)
506
+
507
+
508
+ def number(orb, spin=None):
509
+ """Occupation operator n̂_{orb,spin}; spin=None gives n̂_orb = n̂_α + n̂_β."""
510
+ if spin is None:
511
+ return number(orb, 'alpha') + number(orb, 'beta')
512
+ return cdag(orb, spin) @ c(orb, spin)
513
+
514
+
515
+ def double_occ(orb):
516
+ """Double-occupancy projector n̂_{orb,α} n̂_{orb,β}."""
517
+ return number(orb, 'alpha') @ number(orb, 'beta')
518
+
519
+
520
+ def hop(i, j, spin=None, hermitian=True):
521
+ """Hopping c†_i c_j (+ h.c. if hermitian); spin=None sums α and β."""
522
+ if spin is None:
523
+ return hop(i, j, 'alpha', hermitian) + hop(i, j, 'beta', hermitian)
524
+ fwd = cdag(i, spin) @ c(j, spin)
525
+ if not hermitian or i == j:
526
+ return fwd
527
+ return fwd + cdag(j, spin) @ c(i, spin)
528
+
529
+
530
+ # ---- spin operators ----
531
+
532
+ def s_z(orbs):
533
+ """Total Sz = (1/2) Σᵢ (n̂_{i,α} − n̂_{i,β})."""
534
+ half = sp.Rational(1, 2)
535
+ return sum((half * (number(o, 'alpha') - number(o, 'beta')) for o in orbs),
536
+ start=0)
537
+
538
+
539
+ def s_plus(orbs):
540
+ """S₊ = Σᵢ c†_{i,α} c_{i,β}."""
541
+ return sum((cdag(o, 'alpha') @ c(o, 'beta') for o in orbs), start=0)
542
+
543
+
544
+ def s_minus(orbs):
545
+ """S₋ = Σᵢ c†_{i,β} c_{i,α}."""
546
+ return sum((cdag(o, 'beta') @ c(o, 'alpha') for o in orbs), start=0)
547
+
548
+
549
+ def s_squared(orbs):
550
+ """S² = S₊ S₋ + S_z² − S_z."""
551
+ Sz = s_z(orbs)
552
+ return s_plus(orbs) @ s_minus(orbs) + Sz @ Sz - Sz
553
+
554
+
555
+ def s_dot(i, j):
556
+ """Sᵢ · Sⱼ = Sᵢ_z Sⱼ_z + (1/2)(Sᵢ₊ Sⱼ₋ + Sᵢ₋ Sⱼ₊)."""
557
+ half = sp.Rational(1, 2)
558
+ Szi = half * (number(i, 'alpha') - number(i, 'beta'))
559
+ Szj = half * (number(j, 'alpha') - number(j, 'beta'))
560
+ Spi = cdag(i, 'alpha') @ c(i, 'beta')
561
+ Smi = cdag(i, 'beta') @ c(i, 'alpha')
562
+ Spj = cdag(j, 'alpha') @ c(j, 'beta')
563
+ Smj = cdag(j, 'beta') @ c(j, 'alpha')
564
+ return Szi @ Szj + half * (Spi @ Smj + Smi @ Spj)
565
+
566
+
567
+ # ---- η-pairing ----
568
+
569
+ def eta_plus(site_signs):
570
+ """η₊ = Σᵢ sᵢ c†_{i,α} c†_{i,β}. `site_signs`: dict {orb: ±1}."""
571
+ return sum((s * (cdag(o, 'alpha') @ cdag(o, 'beta'))
572
+ for o, s in site_signs.items()), start=0)
573
+
574
+
575
+ def eta_minus(site_signs):
576
+ """η₋ = Σᵢ sᵢ c_{i,β} c_{i,α}."""
577
+ return sum((s * (c(o, 'beta') @ c(o, 'alpha'))
578
+ for o, s in site_signs.items()), start=0)
579
+
580
+
581
+ def eta_z(orbs):
582
+ """η_z = (1/2) (N̂ − L), with L = len(orbs)."""
583
+ half = sp.Rational(1, 2)
584
+ Nhat = sum((number(o) for o in orbs), start=0)
585
+ L = len(orbs)
586
+ return half * Nhat + (-half * L) * identity()
587
+
588
+
589
+ def eta_squared(site_signs):
590
+ """η² = η₊ η₋ + η_z² − η_z."""
591
+ orbs = list(site_signs.keys())
592
+ Ez = eta_z(orbs)
593
+ return eta_plus(site_signs) @ eta_minus(site_signs) + Ez @ Ez - Ez
594
+
595
+
596
+ # ---- permutation / symmetry ----
597
+
598
+ def transposition(i, j):
599
+ """Permute orbital labels i ↔ j."""
600
+ return _OrbitalPerm({i: j, j: i})
601
+
602
+
603
+ def orbital_perm(orbital_map):
604
+ """Generic orbital-label permutation; map is a dict {old: new}."""
605
+ return _OrbitalPerm(orbital_map)
606
+
607
+
608
+ def _enumerate_orbital_group(generators):
609
+ """Closure of the group generated by orbital-permutation maps."""
610
+
611
+ def compose(p, q):
612
+ keys = set(p.keys()) | set(q.keys())
613
+ return {k: p.get(q.get(k, k), q.get(k, k)) for k in keys}
614
+
615
+ def normalize(p):
616
+ return tuple(sorted((k, v) for k, v in p.items() if k != v))
617
+
618
+ seen = {(): {}}
619
+ queue = [{}]
620
+ while queue:
621
+ g = queue.pop()
622
+ for gen in generators:
623
+ new = compose(gen, g)
624
+ key = normalize(new)
625
+ if key not in seen:
626
+ seen[key] = new
627
+ queue.append(new)
628
+ return list(seen.values())
629
+
630
+
631
+ def reynolds_projector(generators):
632
+ """P^{(triv)} = (1/|G|) Σ_g g, the trivial-irrep projector.
633
+
634
+ `generators` is a list of orbital-permutation dicts. Group closure
635
+ is computed automatically and fermion signs are tracked, so this
636
+ works on over-half-filled bases (e.g. C₄H₄²⁻) too.
637
+ """
638
+ group = _enumerate_orbital_group(generators)
639
+ G = len(group)
640
+ inv_G = sp.Rational(1, G)
641
+ return sum((inv_G * orbital_perm(g) for g in group), start=0)
642
+
643
+
644
+ # ---- VB-flavoured composites ----
645
+
646
+ def singlet_proj(i, j):
647
+ """Spin-singlet projector for two electrons distributed on (i, j).
648
+
649
+ P_S^{ij} = 1/4 − Sᵢ · Sⱼ. On the (1, 1) sector (one electron each on
650
+ i and j) this is the singlet-vs-triplet projector with eigenvalues
651
+ {0, 1}; outside that sector it is *not* a projector — interpret with
652
+ care (e.g. on a closed shell |aA⟩ it returns ¼ |aA⟩).
653
+ """
654
+ return sp.Rational(1, 4) * identity() - s_dot(i, j)
655
+
656
+
657
+ def bond_singlet_creator(i, j):
658
+ """Bond singlet creator: (1/√2)(c†_{i,α} c†_{j,β} − c†_{i,β} c†_{j,α}).
659
+
660
+ Acting on a state with i and j unoccupied appends a normalized
661
+ two-electron singlet bond on (i, j). Stacked applications build a
662
+ Rumer structure.
663
+ """
664
+ return (sp.Rational(1, 1) / sp.sqrt(2)) * (
665
+ cdag(i, 'alpha') @ cdag(j, 'beta')
666
+ - cdag(i, 'beta') @ cdag(j, 'alpha')
667
+ )