integral_core/operator.rs
1//! One-electron operator DSL over polynomials in `r` and `p` (L2).
2//!
3//! This is the operator-algebra layer the architecture calls for (see
4//! `ARCHITECTURE.md`, L2): a typed
5//! representation of a one-electron integrand as a **polynomial in the position
6//! operator `r` and the momentum operator `p = −i∇`**, evaluated by
7//! **decomposition into the base Cartesian integrals the engines already
8//! produce** — no new recurrences. Adding a new integral type is then a single
9//! [`Operator`] *declaration*, not an engine change.
10//!
11//! ## Decomposition (fold the operator onto the ket)
12//!
13//! For a one-electron integral `⟨χ_a | O | χ_b⟩`, the operator `O` (a product of
14//! multiplication operators `r` and derivative operators `p`) acts on the ket
15//! function `χ_b`. Each elementary factor expands `χ_b` into a sum of pure
16//! Cartesian monomials:
17//!
18//! - **`r_i` about origin `O`:** `(r − O)_i χ_b = χ_{b+1_i} + (B − O)_i · χ_b`
19//! — raise the ket monomial by one along axis `i`, plus a shifted copy. (This
20//! is exactly the multipole step [`crate::os::dipole_into`] performs.)
21//! - **`p_i = −i ∂_{r_i}`:** since `χ_b` depends on `r − B`, `∂_{r_i} = −∂_{B_i}`,
22//! so `p_i χ_b = i · ∂_{B_i} χ_b = i · (2β · χ_{b+1_i} − b_i · χ_{b−1_i})`. The
23//! real part is the **center-derivative** combiner; the operator
24//! carries an explicit factor of `i`.
25//!
26//! Applying every factor (rightmost first — operators act right-to-left) turns
27//! `χ_b` into `Σ_{m'} c(m') · χ_{m'}`, so
28//!
29//! ```text
30//! ⟨a | O | b⟩ = i^{n_p} · Σ_{m'} c(m') · ⟨a | m'⟩,
31//! ```
32//!
33//! where `n_p` is the number of momentum factors and each `⟨a | m'⟩` is a plain
34//! **overlap** integral the [`crate::os`] engine already computes at a shifted ket
35//! momentum. The bra stays at `la`; the only engine call is `overlap_into`.
36//!
37//! ## Hermiticity / the imaginary unit (watch this)
38//!
39//! Every `p` factor contributes one `i`, so a term with `n_p` momentum factors
40//! contributes the **phase** `i^{n_p} ∈ {1, i, −1, −i}`. The real-part expansion
41//! above is routed to a real or imaginary output buffer with a sign accordingly:
42//!
43//! | `n_p mod 4` | phase | buffer | sign |
44//! |-------------|-------|--------|------|
45//! | 0 | `1` | real | `+` |
46//! | 1 | `i` | imag | `+` |
47//! | 2 | `−1` | real | `−` |
48//! | 3 | `−i` | imag | `−` |
49//!
50//! Consequently `r`, `r_i r_j`, and `p·p` (kinetic) are **real-symmetric** while
51//! `p` and `r × p` are **imaginary/antisymmetric** (anti-Hermitian real
52//! representation) — the Hermiticity character falls out of `n_p` and is not
53//! assumed. The `i`/sign convention is documented above.
54//!
55//! ## Angular-momentum limit
56//!
57//! Folding onto the ket raises it to `l_b + degree`, so the overlap engine must
58//! stay inside its validated `MAX_L`: `l_b + degree ≤ MAX_L`. The driver enforces
59//! this with a typed error; here it is a debug assertion.
60
61use integral_math::am::{cart_components, cart_index, n_cart};
62
63use crate::os::{overlap_into, Prim, Vec3, MAX_L};
64
65/// An elementary factor of a one-electron operator product, along a Cartesian
66/// axis (`0 = x`, `1 = y`, `2 = z`).
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Factor {
69 /// Position `r_axis` (relative to the operator's origin). Raises the ket
70 /// monomial by one along `axis` (plus the origin-shift copy).
71 R(usize),
72 /// Momentum `p_axis = −i ∂_{r_axis}`. The center-derivative combiner along
73 /// `axis`, carrying a factor of `i`.
74 P(usize),
75}
76
77/// One product term of an operator: a real scalar `coeff` times an ordered
78/// product of [`Factor`]s, applied **right-to-left** to the ket.
79#[derive(Debug, Clone, PartialEq)]
80pub struct Term {
81 /// Real scalar coefficient.
82 pub coeff: f64,
83 /// Ordered factors (operators act right-to-left, i.e. the last factor acts
84 /// on the ket first).
85 pub factors: Vec<Factor>,
86}
87
88impl Term {
89 /// Construct a term from its coefficient and ordered factors.
90 #[must_use]
91 pub fn new(coeff: f64, factors: Vec<Factor>) -> Self {
92 Term { coeff, factors }
93 }
94
95 /// Number of momentum (`p`) factors — sets the term's `i^{n_p}` phase.
96 #[must_use]
97 fn n_momentum(&self) -> usize {
98 self.factors
99 .iter()
100 .filter(|f| matches!(f, Factor::P(_)))
101 .count()
102 }
103}
104
105/// A one-electron operator: a **sum of [`Term`]s**, polynomial in `r` and `p`,
106/// with a documented `origin` for its `r` factors (the multipole / gauge origin).
107///
108/// Adding a new integral type is constructing one of these — see the
109/// constructors ([`Operator::dipole`], [`Operator::quadrupole`],
110/// [`Operator::momentum`], [`Operator::angular_momentum`], …). The engines are
111/// untouched.
112#[derive(Debug, Clone, PartialEq)]
113pub struct Operator {
114 /// The terms summed to form the operator.
115 pub terms: Vec<Term>,
116 /// Origin for the `r` factors (multipole / gauge origin), in bohr.
117 pub origin: Vec3,
118}
119
120impl Operator {
121 /// Construct an operator from its terms and `r`-origin.
122 #[must_use]
123 pub fn new(terms: Vec<Term>, origin: Vec3) -> Self {
124 Operator { terms, origin }
125 }
126
127 /// The **identity** operator — its integral is the plain overlap `⟨a|b⟩`.
128 #[must_use]
129 pub fn identity() -> Self {
130 Operator::new(vec![Term::new(1.0, vec![])], [0.0; 3])
131 }
132
133 /// The dipole component `r_axis` about `origin` — `⟨a|(r−O)_axis|b⟩`.
134 /// Real-symmetric.
135 #[must_use]
136 pub fn dipole(axis: usize, origin: Vec3) -> Self {
137 Operator::new(vec![Term::new(1.0, vec![Factor::R(axis)])], origin)
138 }
139
140 /// The second-moment / quadrupole component `r_i r_j` about `origin` —
141 /// `⟨a|(r−O)_i (r−O)_j|b⟩`. Real-symmetric.
142 #[must_use]
143 pub fn quadrupole(i: usize, j: usize, origin: Vec3) -> Self {
144 Operator::new(
145 vec![Term::new(1.0, vec![Factor::R(i), Factor::R(j)])],
146 origin,
147 )
148 }
149
150 /// The kinetic-energy operator `−½∇² = ½ p·p`. Real-symmetric; reproduces
151 /// [`crate::os::kinetic_into`].
152 #[must_use]
153 pub fn kinetic() -> Self {
154 let terms = (0..3)
155 .map(|ax| Term::new(0.5, vec![Factor::P(ax), Factor::P(ax)]))
156 .collect();
157 Operator::new(terms, [0.0; 3])
158 }
159
160 /// The momentum component `p_axis = −i∂_axis`. **Imaginary**/antisymmetric.
161 #[must_use]
162 pub fn momentum(axis: usize) -> Self {
163 Operator::new(vec![Term::new(1.0, vec![Factor::P(axis)])], [0.0; 3])
164 }
165
166 /// The orbital-angular-momentum component `(r × p)_axis` about `origin`,
167 /// e.g. `L_x = (r−O)_y p_z − (r−O)_z p_y`. **Imaginary**/antisymmetric (the
168 /// `i` is carried explicitly).
169 #[must_use]
170 pub fn angular_momentum(axis: usize, origin: Vec3) -> Self {
171 // Cyclic (j, k) after `axis`: x→(y,z), y→(z,x), z→(x,y).
172 let (j, k) = match axis {
173 0 => (1, 2),
174 1 => (2, 0),
175 _ => (0, 1),
176 };
177 Operator::new(
178 vec![
179 Term::new(1.0, vec![Factor::R(j), Factor::P(k)]),
180 Term::new(-1.0, vec![Factor::R(k), Factor::P(j)]),
181 ],
182 origin,
183 )
184 }
185
186 /// The operator's polynomial degree: the largest number of factors in any
187 /// term. The ket is raised by at most this much, so a shell pair is
188 /// evaluable iff `l_ket + degree ≤ MAX_L`.
189 #[must_use]
190 pub fn degree(&self) -> usize {
191 self.terms
192 .iter()
193 .map(|t| t.factors.len())
194 .max()
195 .unwrap_or(0)
196 }
197}
198
199/// Which part of the complex matrix a term contributes to, and with which sign,
200/// from its `i^{n_p}` phase.
201fn phase(n_p: usize) -> (bool, f64) {
202 // (is_imaginary, sign)
203 match n_p % 4 {
204 0 => (false, 1.0),
205 1 => (true, 1.0),
206 2 => (false, -1.0),
207 _ => (true, -1.0),
208 }
209}
210
211/// Apply one factor to a sparse monomial expansion `Σ c·χ_m` (real
212/// coefficients), returning the expanded `Σ c'·χ_{m'}`. `beta` is the ket
213/// exponent (for `p`); `b_minus_o = B − O` is the ket-center offset from the
214/// operator origin (for `r`).
215fn apply_factor(
216 factor: Factor,
217 beta: f64,
218 b_minus_o: Vec3,
219 src: &[([usize; 3], f64)],
220) -> Vec<([usize; 3], f64)> {
221 let mut out = Vec::with_capacity(src.len() * 2);
222 match factor {
223 // (r − O)_ax · χ_m = χ_{m+1_ax} + (B − O)_ax · χ_m
224 Factor::R(ax) => {
225 for &(m, c) in src {
226 let mut up = m;
227 up[ax] += 1;
228 out.push((up, c));
229 out.push((m, c * b_minus_o[ax]));
230 }
231 }
232 // Re(p_ax) · χ_m = 2β · χ_{m+1_ax} − m_ax · χ_{m−1_ax}
233 Factor::P(ax) => {
234 for &(m, c) in src {
235 let mut up = m;
236 up[ax] += 1;
237 out.push((up, c * 2.0 * beta));
238 if m[ax] >= 1 {
239 let mut dn = m;
240 dn[ax] -= 1;
241 out.push((dn, -c * m[ax] as f64));
242 }
243 }
244 }
245 }
246 out
247}
248
249/// Accumulate `scale · ⟨a | Op | b⟩` for one **primitive** pair into the real and
250/// imaginary Cartesian shell-pair blocks `out_re`/`out_im` (each row-major
251/// `n_cart(la) × n_cart(lb)`, the same layout as [`crate::os::overlap_into`]).
252///
253/// The only engine call is `overlap_into`, at ket momenta `0..=l_b + degree`;
254/// the operator's `r`/`p` factors are decomposed symbolically (see the module
255/// docs). `scale` is the contraction-coefficient product for this primitive
256/// pair, supplied by the driver.
257///
258/// # Panics
259/// Debug-asserts the block sizes and that the operator does not raise the ket
260/// beyond `MAX_L` (`l_b + Op::degree() ≤ MAX_L`); the driver enforces the latter
261/// with a typed error.
262pub fn operator_into(
263 op: &Operator,
264 a: Prim,
265 b: Prim,
266 scale: f64,
267 out_re: &mut [f64],
268 out_im: &mut [f64],
269) {
270 let (la, lb) = (a.l, b.l);
271 let beta = b.exp;
272 let deg = op.degree();
273 let lmax_ket = lb + deg;
274 debug_assert!(lmax_ket <= MAX_L, "operator raises ket beyond MAX_L");
275 let na = n_cart(la);
276 let nb = n_cart(lb);
277 debug_assert_eq!(out_re.len(), na * nb, "real block size");
278 debug_assert_eq!(out_im.len(), na * nb, "imag block size");
279
280 // Overlap blocks ⟨la | l'⟩ for every ket momentum the expansion can reach.
281 let ov: Vec<Vec<f64>> = (0..=lmax_ket)
282 .map(|lp| {
283 let mut blk = vec![0.0; na * n_cart(lp)];
284 overlap_into(
285 Prim::new(a.exp, a.center, la),
286 Prim::new(beta, b.center, lp),
287 1.0,
288 &mut blk,
289 );
290 blk
291 })
292 .collect();
293
294 let b_minus_o = [
295 b.center[0] - op.origin[0],
296 b.center[1] - op.origin[1],
297 b.center[2] - op.origin[2],
298 ];
299 let comps_b = cart_components(lb);
300
301 for term in &op.terms {
302 let (is_imag, psign) = phase(term.n_momentum());
303 let out = if is_imag { &mut *out_im } else { &mut *out_re };
304 for (ib, &bm) in comps_b.iter().enumerate() {
305 // Expand the operator acting on this ket monomial (rightmost first).
306 let mut exp = vec![(bm, 1.0)];
307 for factor in term.factors.iter().rev() {
308 exp = apply_factor(*factor, beta, b_minus_o, &exp);
309 }
310 for (m, rc) in exp {
311 let lp = m[0] + m[1] + m[2];
312 let idx = cart_index(m);
313 let np = n_cart(lp);
314 let blk = &ov[lp];
315 let w = scale * psign * term.coeff * rc;
316 for ia in 0..na {
317 out[ia * nb + ib] += w * blk[ia * np + idx];
318 }
319 }
320 }
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 // Identity reproduces the overlap block element-for-element.
329 #[test]
330 fn identity_is_overlap() {
331 let a = Prim::new(1.1, [0.0, 0.0, 0.0], 1);
332 let b = Prim::new(0.7, [0.3, -0.2, 0.5], 1);
333 let mut s = vec![0.0; 9];
334 overlap_into(a, b, 1.0, &mut s);
335 let (mut re, mut im) = (vec![0.0; 9], vec![0.0; 9]);
336 operator_into(&Operator::identity(), a, b, 1.0, &mut re, &mut im);
337 for k in 0..9 {
338 assert!((re[k] - s[k]).abs() < 1e-14, "{} vs {}", re[k], s[k]);
339 assert_eq!(im[k], 0.0);
340 }
341 }
342
343 // Dipole r_x about the ket center equals ⟨a|b+1_x⟩ (raise) exactly.
344 #[test]
345 fn dipole_about_ket_center_is_pure_raise() {
346 let a = Prim::new(1.0, [0.1, 0.0, 0.0], 0);
347 let bc = [0.4, -0.2, 0.3];
348 let b = Prim::new(0.8, bc, 0);
349 // Origin at the ket center → no shift term, pure raise to p_x.
350 let op = Operator::dipole(0, bc);
351 let (mut re, mut im) = (vec![0.0; 1], vec![0.0; 1]);
352 operator_into(&op, a, b, 1.0, &mut re, &mut im);
353 // Compare with ⟨a | (s-raised-to p_x) ⟩ = overlap(la=0, lb=1)[x].
354 let mut raise = vec![0.0; 3];
355 overlap_into(a, Prim::new(0.8, bc, 1), 1.0, &mut raise);
356 assert!((re[0] - raise[0]).abs() < 1e-14);
357 assert_eq!(im[0], 0.0);
358 }
359
360 // Momentum p_x is purely imaginary and antisymmetric for same-center s|s
361 // (⟨s|p|s⟩ = 0 at same center).
362 #[test]
363 fn momentum_same_center_s_is_zero() {
364 let c = [0.2, 0.1, -0.3];
365 let a = Prim::new(1.0, c, 0);
366 let b = Prim::new(1.0, c, 0);
367 let (mut re, mut im) = (vec![0.0; 1], vec![0.0; 1]);
368 operator_into(&Operator::momentum(0), a, b, 1.0, &mut re, &mut im);
369 assert_eq!(re[0], 0.0);
370 assert!(im[0].abs() < 1e-14, "im={}", im[0]);
371 }
372
373 // ½ p·p reproduces the kinetic block (the analytic identity), for d|d.
374 #[test]
375 fn half_p_dot_p_is_kinetic() {
376 use crate::os::kinetic_into;
377 let a = Prim::new(1.3, [0.0, 0.0, 0.0], 2);
378 let b = Prim::new(0.6, [0.4, -0.3, 0.5], 2);
379 let mut t = vec![0.0; 36];
380 kinetic_into(a, b, 1.0, &mut t);
381 let (mut re, mut im) = (vec![0.0; 36], vec![0.0; 36]);
382 operator_into(&Operator::kinetic(), a, b, 1.0, &mut re, &mut im);
383 for k in 0..36 {
384 assert!((re[k] - t[k]).abs() < 1e-12, "k={k}: {} vs {}", re[k], t[k]);
385 assert_eq!(im[k], 0.0);
386 }
387 }
388
389 // Degree drives the L reach.
390 #[test]
391 fn degree_is_max_factors() {
392 assert_eq!(Operator::identity().degree(), 0);
393 assert_eq!(Operator::dipole(0, [0.0; 3]).degree(), 1);
394 assert_eq!(Operator::momentum(1).degree(), 1);
395 assert_eq!(Operator::quadrupole(0, 1, [0.0; 3]).degree(), 2);
396 assert_eq!(Operator::kinetic().degree(), 2);
397 assert_eq!(Operator::angular_momentum(2, [0.0; 3]).degree(), 2);
398 }
399}