Skip to main content

integral_core/
os.rs

1//! One-electron integrals by Obara–Saika (OS) recurrence.
2//!
3//! All routines here work on **primitive** Cartesian Gaussians and accumulate
4//! their (coefficient-weighted) contribution into a caller-provided output
5//! block. Normalization, contraction, and shell bookkeeping live in the `integral`
6//! driver crate. The output block for a shell pair `(la, lb)` is row-major with
7//! shape `[n_cart(la), n_cart(lb)]`, using the Cartesian component order defined
8//! in [`integral_math::am`].
9//!
10//! ## Conventions
11//!
12//! A primitive is `g(r) = (x-A_x)^{lx}(y-A_y)^{ly}(z-A_z)^{lz} e^{-α|r-A|²}`
13//! (no normalization). For a pair `(α, A)`, `(β, B)`:
14//!
15//! ```text
16//!   p = α + β,   P = (αA + βB)/p,   μ = αβ/p,   K_AB = e^{-μ|A-B|²}.
17//! ```
18//!
19//! ## Buffer sizing (see `ARCHITECTURE.md`)
20//!
21//! Angular momentum is capped at [`MAX_L`]. The separable 1D tables use fixed
22//! stack arrays sized to the cap (strategy (a): over-allocate to the cap rather
23//! than the nightly-gated `generic_const_exprs`). The nuclear-attraction path
24//! uses small heap maps for its auxiliary `(triple, m)` table; tightening that
25//! to stack buffers / const-generic monomorphization is a later optimization.
26
27use std::collections::HashMap;
28
29use integral_math::am::{cart_components, n_cart};
30use integral_math::boys::boys_array;
31
32/// Maximum supported angular momentum per shell for the one-electron OS engine.
33/// Above this, a future Rys engine takes over (see `ARCHITECTURE.md`).
34pub const MAX_L: usize = 6;
35
36/// 1D table dimension: indices `0..=MAX_L+2` (the `+2` is needed by the kinetic
37/// recurrence, which reaches `j+2`).
38const TBL: usize = MAX_L + 3;
39
40/// A 3-vector (atom center or origin), in atomic units (bohr).
41pub type Vec3 = [f64; 3];
42
43/// A single primitive Gaussian: exponent, center, and angular momentum.
44#[derive(Debug, Clone, Copy)]
45pub struct Prim {
46    pub exp: f64,
47    pub center: Vec3,
48    pub l: usize,
49}
50
51impl Prim {
52    /// Construct a primitive from its exponent, center, and angular momentum.
53    #[must_use]
54    pub fn new(exp: f64, center: Vec3, l: usize) -> Self {
55        Prim { exp, center, l }
56    }
57}
58
59/// Gaussian-product quantities for a primitive pair.
60#[derive(Debug, Clone, Copy)]
61struct Pair {
62    p: f64,
63    one_over_2p: f64,
64    p_center: Vec3,
65    mu: f64,
66    ab2: f64,
67}
68
69fn make_pair(alpha: f64, a: Vec3, beta: f64, b: Vec3) -> Pair {
70    let p = alpha + beta;
71    let p_center = [
72        (alpha * a[0] + beta * b[0]) / p,
73        (alpha * a[1] + beta * b[1]) / p,
74        (alpha * a[2] + beta * b[2]) / p,
75    ];
76    let mu = alpha * beta / p;
77    let ab2 = (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
78    Pair {
79        p,
80        one_over_2p: 0.5 / p,
81        p_center,
82        mu,
83        ab2,
84    }
85}
86
87/// Fill the 1D overlap (and, with `e_max > 0`, multipole) table along one axis.
88///
89/// `table[i][j][e]` is the 1D integral `⟨ (x-A)^i | (x-O)^e | (x-B)^j ⟩` with the
90/// Gaussian weight, where the multipole origin enters through `po = P - O`.
91/// `e = 0` is the plain overlap factor `S_x(i,j)`. The recurrences are
92///
93/// ```text
94///   S(i+1,j) = pa·S(i,j) + (1/2p)[ i·S(i-1,j) + j·S(i,j-1) ]
95///   S(i,j+1) = pb·S(i,j) + (1/2p)[ i·S(i-1,j) + j·S(i,j-1) ]
96///   M_e(i,j,e+1) = po·M(i,j,e) + (1/2p)[ i·M(i-1,j,e) + j·M(i,j-1,e) + e·M(i,j,e-1) ]
97/// ```
98fn overlap_1d(
99    pair: &Pair,
100    pa: f64,
101    pb: f64,
102    base: f64,
103    i_max: usize,
104    j_max: usize,
105) -> [[f64; TBL]; TBL] {
106    let mut s = [[0.0f64; TBL]; TBL];
107    let h = pair.one_over_2p;
108    s[0][0] = base;
109    // Build the j = 0 column by raising i.
110    for i in 1..=i_max {
111        let prev2 = if i >= 2 {
112            (i - 1) as f64 * s[i - 2][0]
113        } else {
114            0.0
115        };
116        s[i][0] = pa * s[i - 1][0] + h * prev2;
117    }
118    // Raise j for every i.
119    for j in 1..=j_max {
120        for i in 0..=i_max {
121            let from_i = if i >= 1 {
122                i as f64 * s[i - 1][j - 1]
123            } else {
124                0.0
125            };
126            let from_j = if j >= 2 {
127                (j - 1) as f64 * s[i][j - 2]
128            } else {
129                0.0
130            };
131            s[i][j] = pb * s[i][j - 1] + h * (from_i + from_j);
132        }
133    }
134    s
135}
136
137/// Per-axis base overlap factor `S(0,0) = sqrt(π/p) · e^{-μ d²}` where `d` is the
138/// separation along that axis.
139fn axis_base(pair: &Pair, d: f64) -> f64 {
140    (std::f64::consts::PI / pair.p).sqrt() * (-pair.mu * d * d).exp()
141}
142
143/// Accumulate `scale · ⟨a|b⟩` (overlap) into `out` for the shell pair.
144pub fn overlap_into(a: Prim, b: Prim, scale: f64, out: &mut [f64]) {
145    let Prim {
146        exp: alpha,
147        center: a,
148        l: la,
149    } = a;
150    let Prim {
151        exp: beta,
152        center: b,
153        l: lb,
154    } = b;
155    let pair = make_pair(alpha, a, beta, b);
156    let sx = overlap_1d(
157        &pair,
158        pair.p_center[0] - a[0],
159        pair.p_center[0] - b[0],
160        axis_base(&pair, a[0] - b[0]),
161        la,
162        lb,
163    );
164    let sy = overlap_1d(
165        &pair,
166        pair.p_center[1] - a[1],
167        pair.p_center[1] - b[1],
168        axis_base(&pair, a[1] - b[1]),
169        la,
170        lb,
171    );
172    let sz = overlap_1d(
173        &pair,
174        pair.p_center[2] - a[2],
175        pair.p_center[2] - b[2],
176        axis_base(&pair, a[2] - b[2]),
177        la,
178        lb,
179    );
180
181    let comps_a = cart_components(la);
182    let comps_b = cart_components(lb);
183    let nb = n_cart(lb);
184    for (ia, ca) in comps_a.iter().enumerate() {
185        for (ib, cb) in comps_b.iter().enumerate() {
186            let v = sx[ca[0]][cb[0]] * sy[ca[1]][cb[1]] * sz[ca[2]][cb[2]];
187            out[ia * nb + ib] += scale * v;
188        }
189    }
190}
191
192/// Accumulate `scale · ⟨a| -½∇² |b⟩` (kinetic energy) into `out`.
193///
194/// Uses the 1D kinetic factor (acting on the ket exponent `β`)
195/// `T_x(i,j) = β(2j+1)S(i,j) - 2β²S(i,j+2) - ½ j(j-1)S(i,j-2)` and
196/// `T = T_x S_y S_z + S_x T_y S_z + S_x S_y T_z`.
197pub fn kinetic_into(a: Prim, b: Prim, scale: f64, out: &mut [f64]) {
198    let Prim {
199        exp: alpha,
200        center: a,
201        l: la,
202    } = a;
203    let Prim {
204        exp: beta,
205        center: b,
206        l: lb,
207    } = b;
208    let pair = make_pair(alpha, a, beta, b);
209    // Overlap tables need j up to lb+2 for the kinetic recurrence.
210    let s: [[[f64; TBL]; TBL]; 3] = [
211        overlap_1d(
212            &pair,
213            pair.p_center[0] - a[0],
214            pair.p_center[0] - b[0],
215            axis_base(&pair, a[0] - b[0]),
216            la,
217            lb + 2,
218        ),
219        overlap_1d(
220            &pair,
221            pair.p_center[1] - a[1],
222            pair.p_center[1] - b[1],
223            axis_base(&pair, a[1] - b[1]),
224            la,
225            lb + 2,
226        ),
227        overlap_1d(
228            &pair,
229            pair.p_center[2] - a[2],
230            pair.p_center[2] - b[2],
231            axis_base(&pair, a[2] - b[2]),
232            la,
233            lb + 2,
234        ),
235    ];
236
237    let kin = |axis: usize, i: usize, j: usize| -> f64 {
238        let t = &s[axis];
239        let term_plus = 2.0 * beta * beta * t[i][j + 2];
240        let term_mid = beta * (2 * j + 1) as f64 * t[i][j];
241        let term_minus = if j >= 2 {
242            0.5 * (j * (j - 1)) as f64 * t[i][j - 2]
243        } else {
244            0.0
245        };
246        term_mid - term_plus - term_minus
247    };
248
249    let comps_a = cart_components(la);
250    let comps_b = cart_components(lb);
251    let nb = n_cart(lb);
252    for (ia, ca) in comps_a.iter().enumerate() {
253        for (ib, cb) in comps_b.iter().enumerate() {
254            let sx = s[0][ca[0]][cb[0]];
255            let sy = s[1][ca[1]][cb[1]];
256            let sz = s[2][ca[2]][cb[2]];
257            let tx = kin(0, ca[0], cb[0]);
258            let ty = kin(1, ca[1], cb[1]);
259            let tz = kin(2, ca[2], cb[2]);
260            let v = tx * sy * sz + sx * ty * sz + sx * sy * tz;
261            out[ia * nb + ib] += scale * v;
262        }
263    }
264}
265
266/// Accumulate `scale · ⟨a| (r-O)_k |b⟩` (Cartesian dipole) into the three
267/// component blocks `out_x`, `out_y`, `out_z`, with multipole origin `o`.
268pub fn dipole_into(
269    a: Prim,
270    b: Prim,
271    o: Vec3,
272    scale: f64,
273    out_x: &mut [f64],
274    out_y: &mut [f64],
275    out_z: &mut [f64],
276) {
277    let Prim {
278        exp: alpha,
279        center: a,
280        l: la,
281    } = a;
282    let Prim {
283        exp: beta,
284        center: b,
285        l: lb,
286    } = b;
287    let pair = make_pair(alpha, a, beta, b);
288    // Multipole tables along each axis: m[axis][i][j][e], e in {0,1}.
289    let mut m = [[[[0.0f64; 2]; TBL]; TBL]; 3];
290    for axis in 0..3 {
291        let pa = pair.p_center[axis] - a[axis];
292        let pb = pair.p_center[axis] - b[axis];
293        let po = pair.p_center[axis] - o[axis];
294        let base = axis_base(&pair, a[axis] - b[axis]);
295        let s = overlap_1d(&pair, pa, pb, base, la, lb);
296        let h = pair.one_over_2p;
297        for i in 0..=la {
298            for j in 0..=lb {
299                m[axis][i][j][0] = s[i][j];
300                // e = 1 multipole: M(i,j,1) = po·S(i,j) + (1/2p)[i·S(i-1,j)+j·S(i,j-1)]
301                let from_i = if i >= 1 { i as f64 * s[i - 1][j] } else { 0.0 };
302                let from_j = if j >= 1 { j as f64 * s[i][j - 1] } else { 0.0 };
303                m[axis][i][j][1] = po * s[i][j] + h * (from_i + from_j);
304            }
305        }
306    }
307
308    let comps_a = cart_components(la);
309    let comps_b = cart_components(lb);
310    let nb = n_cart(lb);
311    for (ia, ca) in comps_a.iter().enumerate() {
312        for (ib, cb) in comps_b.iter().enumerate() {
313            let s0 = [
314                m[0][ca[0]][cb[0]][0],
315                m[1][ca[1]][cb[1]][0],
316                m[2][ca[2]][cb[2]][0],
317            ];
318            let m1 = [
319                m[0][ca[0]][cb[0]][1],
320                m[1][ca[1]][cb[1]][1],
321                m[2][ca[2]][cb[2]][1],
322            ];
323            let idx = ia * nb + ib;
324            out_x[idx] += scale * m1[0] * s0[1] * s0[2];
325            out_y[idx] += scale * s0[0] * m1[1] * s0[2];
326            out_z[idx] += scale * s0[0] * s0[1] * m1[2];
327        }
328    }
329}
330
331/// Accumulate `scale · Σ_C (−Z_C) ⟨a| 1/|r−C| |b⟩` (nuclear attraction) into
332/// `out`, summed over the given point charges `charges = [(C, Z)]`.
333///
334/// Builds `Θ^m(a, 0)` by the OS vertical recurrence with the Boys auxiliary
335/// index, then transfers angular momentum to centre B by the horizontal
336/// recurrence `(a, b+1_j) = (a+1_j, b) + (A−B)_j (a, b)`.
337pub fn nuclear_into(a: Prim, b: Prim, charges: &[(Vec3, f64)], scale: f64, out: &mut [f64]) {
338    let Prim {
339        exp: alpha,
340        center: a,
341        l: la,
342    } = a;
343    let Prim {
344        exp: beta,
345        center: b,
346        l: lb,
347    } = b;
348    let pair = make_pair(alpha, a, beta, b);
349    let p = pair.p;
350    let k_ab = (-pair.mu * pair.ab2).exp();
351    let total_l = la + lb;
352    let ab = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
353
354    let comps_a = cart_components(la);
355    let comps_b = cart_components(lb);
356    let nb = n_cart(lb);
357
358    for &(c, z) in charges {
359        // Boys arguments: U = p |P - C|².
360        let pc = [
361            pair.p_center[0] - c[0],
362            pair.p_center[1] - c[1],
363            pair.p_center[2] - c[2],
364        ];
365        let u = p * (pc[0] * pc[0] + pc[1] * pc[1] + pc[2] * pc[2]);
366        let mut fm = vec![0.0f64; total_l + 1];
367        boys_array(total_l, u, &mut fm);
368
369        // Base Θ^m(0,0,0) = (2π/p) K_AB F_m(U).
370        let pref = 2.0 * std::f64::consts::PI / p * k_ab;
371        let pa = [
372            pair.p_center[0] - a[0],
373            pair.p_center[1] - a[1],
374            pair.p_center[2] - a[2],
375        ];
376
377        // Vertical recurrence: theta[triple] = vec over m of Θ^m(triple, 0).
378        let theta = nuclear_vertical(total_l, p, pa, pc, &fm, pref);
379
380        // Horizontal recurrence on B, memoized within this nucleus.
381        let mut hrr_cache: HashMap<([u8; 3], [u8; 3]), f64> = HashMap::new();
382        for (ia, ca) in comps_a.iter().enumerate() {
383            for (ib, cb) in comps_b.iter().enumerate() {
384                let g = hrr(
385                    [ca[0] as u8, ca[1] as u8, ca[2] as u8],
386                    [cb[0] as u8, cb[1] as u8, cb[2] as u8],
387                    ab,
388                    &theta,
389                    &mut hrr_cache,
390                );
391                out[ia * nb + ib] += scale * (-z) * g;
392            }
393        }
394    }
395}
396
397/// Build `Θ^0(a, 0)` for every Cartesian triple `a` with `|a| <= total_l`.
398///
399/// Returns a map from triple to `Θ^0`. Intermediate orders `Θ^m` are kept only
400/// while building. The recurrence (b = 0 branch) is
401///
402/// ```text
403///   Θ^m(t,0) = (P−A)_i Θ^m(a,0) − (P−C)_i Θ^{m+1}(a,0)
404///            + (a_i/2p)[ Θ^m(a−1_i,0) − Θ^{m+1}(a−1_i,0) ],   a = t − 1_i.
405/// ```
406fn nuclear_vertical(
407    total_l: usize,
408    p: f64,
409    pa: Vec3,
410    pc: Vec3,
411    fm: &[f64],
412    pref: f64,
413) -> HashMap<[u8; 3], f64> {
414    // Full m-ladder per triple while building; collapse to Θ^0 at the end.
415    let mut ladder: HashMap<[u8; 3], Vec<f64>> = HashMap::new();
416    let one_over_2p = 0.5 / p;
417
418    // |a| = 0.
419    let base: Vec<f64> = (0..=total_l).map(|m| pref * fm[m]).collect();
420    ladder.insert([0, 0, 0], base);
421
422    for n in 1..=total_l {
423        for t in triples_with_sum(n) {
424            // Choose lowering direction: first nonzero component.
425            let i = if t[0] > 0 {
426                0
427            } else if t[1] > 0 {
428                1
429            } else {
430                2
431            };
432            let mut a = t;
433            a[i] -= 1;
434            let a_i = a[i] as f64; // exponent in direction i of the source triple
435
436            let mut aa = a; // a - 1_i
437            let have_second = aa[i] > 0;
438            if have_second {
439                aa[i] -= 1;
440            }
441
442            let m_count = total_l - n + 1;
443            let mut vals = vec![0.0f64; m_count];
444            let src = &ladder[&a];
445            let src2 = if have_second {
446                Some(ladder[&aa].clone())
447            } else {
448                None
449            };
450            for m in 0..m_count {
451                let mut v = pa[i] * src[m] - pc[i] * src[m + 1];
452                if let Some(ref s2) = src2 {
453                    v += a_i * one_over_2p * (s2[m] - s2[m + 1]);
454                }
455                vals[m] = v;
456            }
457            ladder.insert(t, vals);
458        }
459    }
460
461    ladder.into_iter().map(|(k, v)| (k, v[0])).collect()
462}
463
464/// Horizontal recurrence transferring angular momentum from A to B:
465/// `(a, b) = (a+1_j, b−1_j) + (A−B)_j (a, b−1_j)`, with base `(a,0) = Θ^0(a,0)`.
466fn hrr(
467    a: [u8; 3],
468    b: [u8; 3],
469    ab: Vec3,
470    theta: &HashMap<[u8; 3], f64>,
471    cache: &mut HashMap<([u8; 3], [u8; 3]), f64>,
472) -> f64 {
473    if b[0] == 0 && b[1] == 0 && b[2] == 0 {
474        return theta[&a];
475    }
476    if let Some(&v) = cache.get(&(a, b)) {
477        return v;
478    }
479    // Lower b along its first nonzero component.
480    let j = if b[0] > 0 {
481        0
482    } else if b[1] > 0 {
483        1
484    } else {
485        2
486    };
487    let mut b_low = b;
488    b_low[j] -= 1;
489    let mut a_up = a;
490    a_up[j] += 1;
491
492    let v = hrr(a_up, b_low, ab, theta, cache) + ab[j] * hrr(a, b_low, ab, theta, cache);
493    cache.insert((a, b), v);
494    v
495}
496
497/// Enumerate Cartesian triples `(i,j,k)` with `i+j+k == n`, canonical order.
498fn triples_with_sum(n: usize) -> Vec<[u8; 3]> {
499    let mut out = Vec::new();
500    for lx in (0..=n).rev() {
501        for ly in (0..=(n - lx)).rev() {
502            out.push([lx as u8, ly as u8, (n - lx - ly) as u8]);
503        }
504    }
505    out
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use integral_math::norm::cart_norm;
512
513    const PI: f64 = std::f64::consts::PI;
514
515    fn norm_s(alpha: f64) -> f64 {
516        cart_norm(alpha, 0, 0, 0)
517    }
518
519    #[test]
520    fn s_s_overlap_matches_analytic() {
521        // Normalized s|s overlap between two centres.
522        let (a, b) = (1.2, 0.8);
523        let ca = [0.0, 0.0, 0.0];
524        let cb = [0.0, 0.0, 1.3];
525        let mut out = [0.0];
526        let scale = norm_s(a) * norm_s(b);
527        overlap_into(Prim::new(a, ca, 0), Prim::new(b, cb, 0), scale, &mut out);
528
529        let p = a + b;
530        let mu = a * b / p;
531        let r2 = 1.3_f64.powi(2);
532        let raw = (PI / p).powf(1.5) * (-mu * r2).exp();
533        let expected = norm_s(a) * norm_s(b) * raw;
534        assert!(
535            (out[0] - expected).abs() < 1e-13,
536            "{} vs {}",
537            out[0],
538            expected
539        );
540    }
541
542    #[test]
543    fn same_center_normalized_s_overlap_is_one() {
544        let a = 0.9;
545        let c = [0.2, -0.4, 0.5];
546        let mut out = [0.0];
547        overlap_into(
548            Prim::new(a, c, 0),
549            Prim::new(a, c, 0),
550            norm_s(a) * norm_s(a),
551            &mut out,
552        );
553        assert!((out[0] - 1.0).abs() < 1e-13, "{}", out[0]);
554    }
555
556    #[test]
557    fn s_s_kinetic_matches_analytic() {
558        // T_ss = μ(3 - 2μR²) S_ss  (for unnormalized primitives).
559        let (a, b) = (1.1, 0.7);
560        let ca = [0.0, 0.0, 0.0];
561        let cb = [0.0, 0.6, 0.0];
562        let mut t = [0.0];
563        kinetic_into(Prim::new(a, ca, 0), Prim::new(b, cb, 0), 1.0, &mut t);
564
565        let p = a + b;
566        let mu = a * b / p;
567        let r2 = 0.6_f64.powi(2);
568        let s_ss = (PI / p).powf(1.5) * (-mu * r2).exp();
569        let expected = mu * (3.0 - 2.0 * mu * r2) * s_ss;
570        assert!((t[0] - expected).abs() < 1e-13, "{} vs {}", t[0], expected);
571    }
572
573    #[test]
574    fn nuclear_s_s_matches_analytic() {
575        // (s|1/r_C|s) = (2π/p) e^{-μR²} F_0(p|P-C|²)  (unnormalized).
576        use integral_math::boys::boys;
577        let (a, b) = (1.3, 0.9);
578        let ca = [0.0, 0.0, 0.0];
579        let cb = [0.0, 0.0, 1.0];
580        let c = [0.0, 0.0, 0.4];
581        let mut v = [0.0];
582        // charge Z = 1 → integrand is -(1)·(s|1/r|s); compare magnitude.
583        nuclear_into(
584            Prim::new(a, ca, 0),
585            Prim::new(b, cb, 0),
586            &[(c, 1.0)],
587            1.0,
588            &mut v,
589        );
590
591        let p = a + b;
592        let mu = a * b / p;
593        let pcenter = (a * 0.0 + b * 1.0) / p;
594        let r2 = 1.0_f64.powi(2);
595        let pc2 = (pcenter - 0.4).powi(2);
596        let expected = -(2.0 * PI / p) * (-mu * r2).exp() * boys(0, p * pc2);
597        assert!((v[0] - expected).abs() < 1e-13, "{} vs {}", v[0], expected);
598    }
599
600    /// `⟨a|O|b⟩` blocks must satisfy `block(la,lb) == block(lb,la)^T` for any
601    /// symmetric one-electron operator. Exercises higher-`l` Cartesian indexing
602    /// (the s-only analytic tests above cannot). Validated here for overlap,
603    /// kinetic, and nuclear at p and d.
604    #[test]
605    fn blocks_are_transpose_symmetric() {
606        let pa = Prim::new(1.4, [0.1, 0.0, -0.2], 0);
607        let pb = Prim::new(0.6, [0.0, 0.5, 0.3], 0);
608        let charges = [([0.2, -0.1, 0.4], 3.0), ([-0.3, 0.2, 0.0], 1.0)];
609
610        for (la, lb) in [(1, 0), (1, 1), (2, 1), (2, 2)] {
611            let a = Prim { l: la, ..pa };
612            let b = Prim { l: lb, ..pb };
613            let (na, nb) = (n_cart(la), n_cart(lb));
614
615            for kind in 0..3 {
616                let mut fwd = vec![0.0; na * nb];
617                let mut rev = vec![0.0; nb * na];
618                match kind {
619                    0 => {
620                        overlap_into(a, b, 1.0, &mut fwd);
621                        overlap_into(b, a, 1.0, &mut rev);
622                    }
623                    1 => {
624                        kinetic_into(a, b, 1.0, &mut fwd);
625                        kinetic_into(b, a, 1.0, &mut rev);
626                    }
627                    _ => {
628                        nuclear_into(a, b, &charges, 1.0, &mut fwd);
629                        nuclear_into(b, a, &charges, 1.0, &mut rev);
630                    }
631                }
632                for i in 0..na {
633                    for j in 0..nb {
634                        let f = fwd[i * nb + j];
635                        let r = rev[j * na + i];
636                        assert!(
637                            (f - r).abs() < 1e-12 * f.abs().max(1.0),
638                            "kind={kind} (la={la},lb={lb}) [{i},{j}]: {f} vs {r}"
639                        );
640                    }
641                }
642            }
643        }
644    }
645
646    #[test]
647    fn dipole_same_center_s_is_position_times_overlap() {
648        // ⟨s| (r-O) |s⟩ with both functions at center R: equals (R-O)·⟨s|s⟩.
649        let a = 1.0;
650        let r = [0.3, -0.2, 0.7];
651        let o = [0.1, 0.1, 0.1];
652        let scale = norm_s(a) * norm_s(a);
653        let (mut dx, mut dy, mut dz) = ([0.0], [0.0], [0.0]);
654        dipole_into(
655            Prim::new(a, r, 0),
656            Prim::new(a, r, 0),
657            o,
658            scale,
659            &mut dx,
660            &mut dy,
661            &mut dz,
662        );
663        // Overlap is 1 (normalized, same center), so dipole = R - O.
664        assert!((dx[0] - (r[0] - o[0])).abs() < 1e-13, "{}", dx[0]);
665        assert!((dy[0] - (r[1] - o[1])).abs() < 1e-13, "{}", dy[0]);
666        assert!((dz[0] - (r[2] - o[2])).abs() < 1e-13, "{}", dz[0]);
667    }
668}