Skip to main content

integral/
df.rs

1//! Density-fitting (RI) integrals: 3-center `(μν|P)` and 2-center `(P|Q)`
2//! Coulomb integrals over a main basis plus an auxiliary fitting basis.
3//!
4//! Both reduce to the existing 4-center Coulomb kernels by attaching a **unit
5//! `s` dummy** to the auxiliary index: a zero-exponent, coefficient-1 `s`
6//! primitive is the constant function `1`, so
7//!
8//! ```text
9//!   (μν|P) = (μν|P·1ₛ)        (P|Q) = (P·1ₛ|Q·1ₛ)
10//! ```
11//!
12//! *exactly* (no limit is taken — the Gaussian-product machinery is evaluated
13//! at `α = 0`, where every pair quantity is finite: the pair exponent is
14//! `ζ = α_P + 0 = α_P`, the prefactor `K = exp(0) = 1`, and the pair center is
15//! the aux center itself). The dummy is exempt from primitive normalization
16//! (see [`Shell::primitive_coeff`]). Both ERI engines (OS/HGP and Rys) divide
17//! only by *pair* exponent sums, never by an individual primitive exponent, so
18//! the zero-exponent index is safe in either.
19//!
20//! The public surface mirrors the 4-center family: dense + `_with(Engine)`
21//! variants, kind-aware spherical sizes, row-major blocks with the **last
22//! index fastest**, an aux-side Schwarz bound, and a parallel-ready
23//! [`Eri3cBuilder`] with the same partition/fill contract as
24//! [`crate::EriBuilder`].
25
26use crate::integrals::{
27    canonical_shell_pairs, effective_coeffs, quartet_into_scratch, quartet_into_scratch_erf,
28    Engine, EriKernel, QuartetScratch,
29};
30use crate::shell::{Basis, Shell};
31use crate::spherical::shell_transform;
32
33/// The unit `s` dummy at `center`: one zero-exponent primitive, coefficient 1,
34/// i.e. the constant function `1` (normalization-exempt; see module docs).
35/// Shared with the density-fitting gradient builders in `grad.rs`.
36pub(crate) fn unit_s(center: [f64; 3]) -> Shell {
37    Shell::new(0, center, vec![0.0], vec![1.0]).expect("unit s dummy shell is always valid")
38}
39
40impl Basis {
41    /// 2-center Coulomb metric `(P|Q) = ∫∫ φ_P(1) r₁₂⁻¹ φ_Q(2) d1 d2` over
42    /// `self` as the **auxiliary** basis.
43    ///
44    /// Row-major `[naux, naux]` with `naux = self.nao()`, kind-aware (spherical
45    /// shells contribute their `2l+1` components). The matrix is **exactly
46    /// symmetric**: each canonical shell pair is evaluated once and mirrored,
47    /// so `(P|Q)` and `(Q|P)` are the same `f64`.
48    #[must_use]
49    pub fn eri_2c(&self) -> Vec<f64> {
50        self.eri_2c_with(Engine::Auto)
51    }
52
53    /// Like [`Basis::eri_2c`] but forces a specific [`Engine`] (or
54    /// [`Engine::Auto`]). Both engines produce the same metric to tolerance.
55    #[must_use]
56    pub fn eri_2c_with(&self, engine: Engine) -> Vec<f64> {
57        let naux = self.nao();
58        let offs = self.offsets();
59        let shells = self.shells();
60        let eff: Vec<Vec<f64>> = shells.iter().map(effective_coeffs).collect();
61        let c2s: Vec<Option<Vec<f64>>> = shells.iter().map(shell_transform).collect();
62        let dummies: Vec<Shell> = shells.iter().map(|s| unit_s(s.center())).collect();
63        let unit_eff = [1.0];
64        let mut out = vec![0.0; naux * naux];
65        let mut scratch = QuartetScratch::default();
66        for (p, sp) in shells.iter().enumerate() {
67            for (q, sq) in shells.iter().enumerate().take(p + 1) {
68                let len = quartet_into_scratch(
69                    &mut scratch,
70                    engine,
71                    [sp, &dummies[p], sq, &dummies[q]],
72                    [&eff[p], &unit_eff, &eff[q], &unit_eff],
73                    [c2s[p].as_deref(), None, c2s[q].as_deref(), None],
74                );
75                let (np, nq) = (sp.n_func(), sq.n_func());
76                debug_assert_eq!(len, np * nq);
77                for a in 0..np {
78                    for b in 0..nq {
79                        let v = scratch.block[a * nq + b];
80                        out[(offs[p] + a) * naux + offs[q] + b] = v;
81                        out[(offs[q] + b) * naux + offs[p] + a] = v;
82                    }
83                }
84            }
85        }
86        out
87    }
88
89    /// 2-center metric over the chosen [`EriKernel`] — layout identical to
90    /// [`Basis::eri_2c`] (row-major `[naux, naux]`, exactly symmetric).
91    ///
92    /// [`EriKernel::Coulomb`] routes to [`Basis::eri_2c`] itself (bit-identical
93    /// output); [`EriKernel::Erf`] evaluates `(P| erf(ω·r₁₂)/r₁₂ |Q)` via the
94    /// same zero-exponent unit-`s` dummy construction, on the Rys engine (see
95    /// [`Basis::eri_kernel`]).
96    ///
97    /// # Panics
98    /// Panics if `k` is `Erf { omega }` with `ω ≤ 0`, NaN, or infinite.
99    #[must_use]
100    pub fn eri_2c_kernel(&self, k: EriKernel) -> Vec<f64> {
101        let omega = match k {
102            EriKernel::Coulomb => return self.eri_2c(),
103            EriKernel::Erf { omega } => {
104                crate::integrals::check_erf_omega(omega);
105                omega
106            }
107        };
108        let naux = self.nao();
109        let offs = self.offsets();
110        let shells = self.shells();
111        let eff: Vec<Vec<f64>> = shells.iter().map(effective_coeffs).collect();
112        let c2s: Vec<Option<Vec<f64>>> = shells.iter().map(shell_transform).collect();
113        let dummies: Vec<Shell> = shells.iter().map(|s| unit_s(s.center())).collect();
114        let unit_eff = [1.0];
115        let mut out = vec![0.0; naux * naux];
116        let mut scratch = QuartetScratch::default();
117        for (p, sp) in shells.iter().enumerate() {
118            for (q, sq) in shells.iter().enumerate().take(p + 1) {
119                let len = quartet_into_scratch_erf(
120                    &mut scratch,
121                    [sp, &dummies[p], sq, &dummies[q]],
122                    [&eff[p], &unit_eff, &eff[q], &unit_eff],
123                    [c2s[p].as_deref(), None, c2s[q].as_deref(), None],
124                    omega,
125                );
126                let (np, nq) = (sp.n_func(), sq.n_func());
127                debug_assert_eq!(len, np * nq);
128                for a in 0..np {
129                    for b in 0..nq {
130                        let v = scratch.block[a * nq + b];
131                        out[(offs[p] + a) * naux + offs[q] + b] = v;
132                        out[(offs[q] + b) * naux + offs[p] + a] = v;
133                    }
134                }
135            }
136        }
137        out
138    }
139
140    /// One 3-center Coulomb shell block `(ij|P)`: shells `ish, jsh` over `self`
141    /// (the main basis), shell `psh` over `aux` (the auxiliary basis).
142    ///
143    /// Row-major `[n_i, n_j, n_p]` with **`P` fastest-varying** (so a
144    /// per-shell-pair GEMM against the metric is contiguous), kind-aware like
145    /// [`Basis::eri_block`]: `n_x` is the shell's `n_func` (`n_cart` for
146    /// Cartesian, `2l+1` for spherical) in the usual component order.
147    #[must_use]
148    pub fn eri_3c_block(&self, aux: &Basis, ish: usize, jsh: usize, psh: usize) -> Vec<f64> {
149        self.eri_3c_block_with(Engine::Auto, aux, ish, jsh, psh)
150    }
151
152    /// Like [`Basis::eri_3c_block`] but forces a specific [`Engine`] (or
153    /// [`Engine::Auto`]). Both engines produce the same block to tolerance.
154    #[must_use]
155    pub fn eri_3c_block_with(
156        &self,
157        engine: Engine,
158        aux: &Basis,
159        ish: usize,
160        jsh: usize,
161        psh: usize,
162    ) -> Vec<f64> {
163        let s = self.shells();
164        let (si, sj) = (&s[ish], &s[jsh]);
165        let sp = &aux.shells()[psh];
166        let dummy = unit_s(sp.center());
167        let (mi, mj, mp) = (
168            shell_transform(si),
169            shell_transform(sj),
170            shell_transform(sp),
171        );
172        let mut scratch = QuartetScratch::default();
173        let len = quartet_into_scratch(
174            &mut scratch,
175            engine,
176            [si, sj, sp, &dummy],
177            [
178                &effective_coeffs(si),
179                &effective_coeffs(sj),
180                &effective_coeffs(sp),
181                &[1.0],
182            ],
183            [mi.as_deref(), mj.as_deref(), mp.as_deref(), None],
184        );
185        scratch.block[..len].to_vec()
186    }
187
188    /// One 3-center shell block over the chosen [`EriKernel`] — layout identical
189    /// to [`Basis::eri_3c_block`] (row-major `[n_i, n_j, n_p]`, `P` fastest).
190    ///
191    /// [`EriKernel::Coulomb`] routes to [`Basis::eri_3c_block`] itself
192    /// (bit-identical output); [`EriKernel::Erf`] evaluates
193    /// `(ij| erf(ω·r₁₂)/r₁₂ |P)` via the same zero-exponent unit-`s` dummy
194    /// construction, on the Rys engine (see [`Basis::eri_kernel`]). The Coulomb
195    /// Schwarz factors ([`Basis::schwarz_bounds`] / [`Basis::schwarz_aux_bounds`])
196    /// remain valid upper bounds for the attenuated blocks (`erf(ωr)/r ≤ 1/r`).
197    ///
198    /// # Panics
199    /// Panics if `k` is `Erf { omega }` with `ω ≤ 0`, NaN, or infinite.
200    #[must_use]
201    pub fn eri_3c_block_kernel(
202        &self,
203        aux: &Basis,
204        ish: usize,
205        jsh: usize,
206        psh: usize,
207        k: EriKernel,
208    ) -> Vec<f64> {
209        let omega = match k {
210            EriKernel::Coulomb => return self.eri_3c_block(aux, ish, jsh, psh),
211            EriKernel::Erf { omega } => {
212                crate::integrals::check_erf_omega(omega);
213                omega
214            }
215        };
216        let s = self.shells();
217        let (si, sj) = (&s[ish], &s[jsh]);
218        let sp = &aux.shells()[psh];
219        let dummy = unit_s(sp.center());
220        let (mi, mj, mp) = (
221            shell_transform(si),
222            shell_transform(sj),
223            shell_transform(sp),
224        );
225        let mut scratch = QuartetScratch::default();
226        let len = quartet_into_scratch_erf(
227            &mut scratch,
228            [si, sj, sp, &dummy],
229            [
230                &effective_coeffs(si),
231                &effective_coeffs(sj),
232                &effective_coeffs(sp),
233                &[1.0],
234            ],
235            [mi.as_deref(), mj.as_deref(), mp.as_deref(), None],
236            omega,
237        );
238        scratch.block[..len].to_vec()
239    }
240
241    /// Auxiliary-side Schwarz factors over `self` as the aux basis, one per
242    /// shell: `QP[p] = sqrt(max_{μ∈p} (μ|μ))` with `(μ|μ)` the diagonal of the
243    /// 2-center block `(p|p)`.
244    ///
245    /// Together with the main-basis [`Basis::schwarz_bounds`] this bounds every
246    /// 3-center integral: `|(μν|P)| ≤ Q[i,j] · QP[p]` for `μν` in shell pair
247    /// `(i, j)` and `P` in aux shell `p` (Cauchy–Schwarz in the Coulomb inner
248    /// product). Kind-aware, like the 4-center bounds.
249    #[must_use]
250    pub fn schwarz_aux_bounds(&self) -> Vec<f64> {
251        self.schwarz_aux_bounds_with(Engine::Auto)
252    }
253
254    /// Like [`Basis::schwarz_aux_bounds`] but with a forced [`Engine`]. The
255    /// bound is engine-independent to tolerance.
256    #[must_use]
257    pub fn schwarz_aux_bounds_with(&self, engine: Engine) -> Vec<f64> {
258        let shells = self.shells();
259        let eff: Vec<Vec<f64>> = shells.iter().map(effective_coeffs).collect();
260        let c2s: Vec<Option<Vec<f64>>> = shells.iter().map(shell_transform).collect();
261        let unit_eff = [1.0];
262        let mut scratch = QuartetScratch::default();
263        let mut bounds = Vec::with_capacity(shells.len());
264        for (p, sp) in shells.iter().enumerate() {
265            let dummy = unit_s(sp.center());
266            let len = quartet_into_scratch(
267                &mut scratch,
268                engine,
269                [sp, &dummy, sp, &dummy],
270                [&eff[p], &unit_eff, &eff[p], &unit_eff],
271                [c2s[p].as_deref(), None, c2s[p].as_deref(), None],
272            );
273            let np = sp.n_func();
274            debug_assert_eq!(len, np * np);
275            let mut mx = 0.0_f64;
276            for mu in 0..np {
277                mx = mx.max(scratch.block[mu * np + mu].abs());
278            }
279            bounds.push(mx.sqrt());
280        }
281        bounds
282    }
283
284    /// Create a parallel-ready [`Eri3cBuilder`] filling `(ij|P)` with `ij` over
285    /// `self` (the main basis) and `P` over `aux`, with the default
286    /// [`Engine::Auto`] dispatch. Equivalent to [`Eri3cBuilder::new`].
287    #[must_use]
288    pub fn eri_3c_builder<'a>(&'a self, aux: &'a Basis) -> Eri3cBuilder<'a> {
289        Eri3cBuilder::new(self, aux)
290    }
291}
292
293/// A reusable plan for assembling the dense 3-center tensor `(ij|P)` —
294/// row-major `[nao, nao, naux]`, `P` fastest — in parallel over canonical
295/// **bra shell-pairs** of the main basis, with no in-crate threading runtime.
296///
297/// Same contract as [`crate::EriBuilder`]: [`Eri3cBuilder::partition`] slices
298/// the caller's buffer into one `naux` row slab per `(μ, ν)` AO pair via
299/// `chunks_exact_mut` (provably disjoint `&mut` views, no `unsafe`), and hands
300/// each canonical bra-pair `(i ≥ j)` exactly the rows it owns — the `(i, j)`
301/// band and, when `i ≠ j`, the `(j, i)` band. A driver (e.g. rayon at the call
302/// site) fills the returned tasks concurrently with [`Eri3cBuilder::fill`];
303/// chemx's existing LPT dispatch over [`crate::EriBuilder`] tasks carries over
304/// unchanged.
305///
306/// Unlike the 4-center builder there is no bra↔ket exchange to trade away: the
307/// only symmetry is the bra swap `(μν|P) = (νμ|P)`, so each canonical bra-pair
308/// evaluates every aux shell once and writes both orderings.
309///
310/// # Example — serial
311/// ```
312/// use integral::{Basis, Shell};
313/// let main = Basis::new(vec![
314///     Shell::new(0, [0.0, 0.0, 0.0], vec![0.8], vec![1.0]).unwrap(),
315///     Shell::new(1, [0.1, 0.0, 0.0], vec![0.6], vec![1.0]).unwrap(),
316/// ]);
317/// let aux = Basis::new(vec![
318///     Shell::new(0, [0.0, 0.0, 0.0], vec![1.2], vec![1.0]).unwrap(),
319/// ]);
320/// let b = main.eri_3c_builder(&aux);
321/// let tensor = b.build();
322/// assert_eq!(tensor.len(), main.nao() * main.nao() * aux.nao());
323/// ```
324#[derive(Debug)]
325pub struct Eri3cBuilder<'b> {
326    main: &'b [Shell],
327    aux: &'b [Shell],
328    engine: Engine,
329    /// Output-AO offset of each main-basis shell (function-space).
330    offs: Vec<usize>,
331    /// `n_func` of each main-basis shell.
332    nfunc: Vec<usize>,
333    /// Total main-basis output AOs.
334    nao: usize,
335    /// Aux-shell AO offsets and total aux AOs.
336    aux_offs: Vec<usize>,
337    naux: usize,
338    /// Effective contraction coefficients per shell (`d_i · N(α_i, l)`).
339    eff: Vec<Vec<f64>>,
340    aux_eff: Vec<Vec<f64>>,
341    /// Cached `c2s` transform per shell (`None` = Cartesian).
342    c2s: Vec<Option<Vec<f64>>>,
343    aux_c2s: Vec<Option<Vec<f64>>>,
344    /// One unit-`s` dummy per aux shell, at that shell's center.
345    dummies: Vec<Shell>,
346    /// Canonical main-basis shell pairs `(i ≥ j)`, the parallel grain.
347    pairs: Vec<(usize, usize)>,
348}
349
350impl<'b> Eri3cBuilder<'b> {
351    /// Build a plan for `(ij|P)` over `main` × `aux` with the default
352    /// [`Engine::Auto`] dispatch.
353    #[must_use]
354    pub fn new(main: &'b Basis, aux: &'b Basis) -> Self {
355        Self::with_engine(main, aux, Engine::Auto)
356    }
357
358    /// Build a plan that forces a specific [`Engine`] (or [`Engine::Auto`]).
359    #[must_use]
360    pub fn with_engine(main: &'b Basis, aux: &'b Basis, engine: Engine) -> Self {
361        let shells = main.shells();
362        let aux_shells = aux.shells();
363        Eri3cBuilder {
364            main: shells,
365            aux: aux_shells,
366            engine,
367            offs: main.offsets(),
368            nfunc: shells.iter().map(Shell::n_func).collect(),
369            nao: main.nao(),
370            aux_offs: aux.offsets(),
371            naux: aux.nao(),
372            eff: shells.iter().map(effective_coeffs).collect(),
373            aux_eff: aux_shells.iter().map(effective_coeffs).collect(),
374            c2s: shells.iter().map(shell_transform).collect(),
375            aux_c2s: aux_shells.iter().map(shell_transform).collect(),
376            dummies: aux_shells.iter().map(|s| unit_s(s.center())).collect(),
377            pairs: canonical_shell_pairs(shells.len()),
378        }
379    }
380
381    /// The canonical bra shell-pairs `(i, j)` with `i ≥ j`, in build order —
382    /// the **external parallel grain**, aligned index-for-index with the tasks
383    /// returned by [`Eri3cBuilder::partition`].
384    #[must_use]
385    pub fn bra_pairs(&self) -> &[(usize, usize)] {
386        &self.pairs
387    }
388
389    /// Length of the dense output buffer, `nao² · naux`. Allocate
390    /// `vec![0.0; output_len()]` before [`Eri3cBuilder::partition`].
391    #[must_use]
392    pub fn output_len(&self) -> usize {
393        self.nao * self.nao * self.naux
394    }
395
396    /// Partition a freshly-zeroed `nao²·naux` output buffer into one
397    /// [`Bra3cFill`] task per canonical bra-pair (aligned with
398    /// [`Eri3cBuilder::bra_pairs`]). Each task borrows **only** the `(μ, ν)`
399    /// row slabs it owns; the borrows are mutually disjoint, so the tasks may
400    /// be filled concurrently into the same buffer.
401    ///
402    /// # Panics
403    /// If `out.len() != output_len()`.
404    #[must_use]
405    pub fn partition<'o>(&self, out: &'o mut [f64]) -> Vec<Bra3cFill<'o>> {
406        let nao = self.nao;
407        assert_eq!(
408            out.len(),
409            nao * nao * self.naux,
410            "3c output buffer must be nao²·naux = {} elements",
411            nao * nao * self.naux
412        );
413
414        // One mutable slab of naux elements per (μ, ν) AO row, row = μ·nao + ν.
415        let mut slabs: Vec<Option<&'o mut [f64]>> =
416            out.chunks_exact_mut(self.naux).map(Some).collect();
417        debug_assert_eq!(slabs.len(), nao * nao);
418
419        let mut tasks = Vec::with_capacity(self.pairs.len());
420        for &(i, j) in &self.pairs {
421            let (ni, nj) = (self.nfunc[i], self.nfunc[j]);
422            let (oi, oj) = (self.offs[i], self.offs[j]);
423
424            // (i, j) band: rows (μ∈i, ν∈j), row-major (a, b).
425            let mut ij_band = Vec::with_capacity(ni * nj);
426            for a in 0..ni {
427                for b in 0..nj {
428                    ij_band.push(claim_row(&mut slabs, (oi + a) * nao + (oj + b)));
429                }
430            }
431
432            // (j, i) band: rows (μ∈j, ν∈i), row-major (b, a). Empty when i == j.
433            let mut ji_band = Vec::new();
434            if i != j {
435                ji_band.reserve(nj * ni);
436                for b in 0..nj {
437                    for a in 0..ni {
438                        ji_band.push(claim_row(&mut slabs, (oj + b) * nao + (oi + a)));
439                    }
440                }
441            }
442
443            tasks.push(Bra3cFill {
444                bra: (i, j),
445                ij_band,
446                ji_band,
447            });
448        }
449
450        debug_assert!(
451            slabs.iter().all(Option::is_none),
452            "partition left {} output rows unclaimed",
453            slabs.iter().filter(|s| s.is_some()).count()
454        );
455
456        tasks
457    }
458
459    /// Fill one bra-pair's owned rows: evaluate `(ij|P)` once per aux shell and
460    /// write the block into the `(i, j)` band and (when `i ≠ j`) the bra-swapped
461    /// `(j, i)` band. Writes touch only `task`'s rows, so this may run
462    /// concurrently with [`Eri3cBuilder::fill`] on every *other* task.
463    pub fn fill(&self, task: &mut Bra3cFill<'_>) {
464        self.fill_filtered(task, |_| true);
465    }
466
467    /// Like [`Eri3cBuilder::fill`] but evaluates only the aux shells `p` for
468    /// which `keep(p)` is true, skipping the rest entirely — the hook for
469    /// per-aux-shell Schwarz screening (`Q[ij]·QP[p] < τ`) *inside* a bra-pair
470    /// task. Skipped shells' output slots are left untouched, i.e. zero in the
471    /// freshly-zeroed buffer [`Eri3cBuilder::partition`] requires.
472    ///
473    /// With `keep = |_| true` this is the identical code path to
474    /// [`Eri3cBuilder::fill`] (bitwise-equal output).
475    pub fn fill_filtered(&self, task: &mut Bra3cFill<'_>, keep: impl Fn(usize) -> bool) {
476        let (i, j) = task.bra;
477        let (si, sj) = (&self.main[i], &self.main[j]);
478        let (ni, nj) = (self.nfunc[i], self.nfunc[j]);
479        let mut scratch = QuartetScratch::default();
480        for (p, sp) in self.aux.iter().enumerate() {
481            if !keep(p) {
482                continue;
483            }
484            let len = quartet_into_scratch(
485                &mut scratch,
486                self.engine,
487                [si, sj, sp, &self.dummies[p]],
488                [&self.eff[i], &self.eff[j], &self.aux_eff[p], &[1.0]],
489                [
490                    self.c2s[i].as_deref(),
491                    self.c2s[j].as_deref(),
492                    self.aux_c2s[p].as_deref(),
493                    None,
494                ],
495            );
496            let np = sp.n_func();
497            debug_assert_eq!(len, ni * nj * np);
498            let op = self.aux_offs[p];
499            let block = &scratch.block[..len];
500            for a in 0..ni {
501                for b in 0..nj {
502                    let row = &block[(a * nj + b) * np..(a * nj + b + 1) * np];
503                    task.ij_band[a * nj + b][op..op + np].copy_from_slice(row);
504                    if i != j {
505                        task.ji_band[b * ni + a][op..op + np].copy_from_slice(row);
506                    }
507                }
508            }
509        }
510    }
511
512    /// Assemble the whole dense `(ij|P)` tensor on the current thread by
513    /// filling every bra-pair in sequence — the identical code path a parallel
514    /// driver runs, just serially.
515    #[must_use]
516    pub fn build(&self) -> Vec<f64> {
517        let mut out = vec![0.0; self.output_len()];
518        let mut tasks = self.partition(&mut out);
519        for task in &mut tasks {
520            self.fill(task);
521        }
522        out
523    }
524}
525
526/// One unit of parallel 3-center work: the `(μ, ν)` output rows owned by a
527/// single canonical bra-pair `(i, j)`, handed out by [`Eri3cBuilder::partition`].
528/// Distinct tasks borrow disjoint regions of the same buffer, so a driver may
529/// fill them across threads with no synchronisation ([`Eri3cBuilder::fill`]).
530#[derive(Debug)]
531pub struct Bra3cFill<'o> {
532    bra: (usize, usize),
533    /// Slabs for rows `(μ∈i, ν∈j)`, row-major `(a, b)` → index `a·n_j + b`;
534    /// each slab is the `naux`-long `P` row of that `(μ, ν)`.
535    ij_band: Vec<&'o mut [f64]>,
536    /// Slabs for rows `(μ∈j, ν∈i)`, row-major `(b, a)` → index `b·n_i + a`.
537    /// Empty when `i == j`.
538    ji_band: Vec<&'o mut [f64]>,
539}
540
541impl Bra3cFill<'_> {
542    /// The canonical bra shell-pair `(i, j)` (`i ≥ j`) this task fills.
543    #[must_use]
544    pub fn bra(&self) -> (usize, usize) {
545        self.bra
546    }
547}
548
549/// Take the slab for output `row`, asserting it has not already been claimed
550/// (a double-claim would violate the disjointness contract).
551fn claim_row<'o>(slabs: &mut [Option<&'o mut [f64]>], row: usize) -> &'o mut [f64] {
552    slabs[row]
553        .take()
554        .expect("output row claimed by two bra-pairs (disjointness violated)")
555}