Skip to main content

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}