treams_core/lattice/
batch.rs

1//! Lattice sums over arrays of inputs, with their gradients.
2//!
3//! treams-rs extension: the `lsum*` ufuncs of treams broadcast their inputs but have no
4//! gradients.
5
6use super::{
7    SumGradient, SumPart, SumTangent, cell::BlochLattice, derivatives_part, sum_part, wave::Family,
8};
9use crate::{
10    Complex, Error, Result,
11    numerics::{broadcast, finite, parallel::Parallel},
12};
13
14/// Batched sums split adaptively across the rayon pool from this many outputs.
15const PARALLEL: Parallel = Parallel::AtLeast(8);
16
17/// [`sum_part`] over arrays, with the residual for the pullback.
18///
19/// Each input holds one value for all outputs or one per output. The residual keeps
20/// the inputs only; the pullback recomputes the local derivatives.
21///
22/// treams-rs extension: the `lsum*` ufuncs of `treams.lattice` broadcast their inputs
23/// but have no gradients.
24pub fn sum_array(
25    waves: Vec<Family>,
26    wavenumbers: Vec<Complex>,
27    lattices: Vec<BlochLattice>,
28    shifts: Vec<[f64; 3]>,
29    etas: Vec<Complex>,
30    parts: Vec<SumPart>,
31) -> Result<(Vec<Complex>, SumResidual)> {
32    let residual = SumResidual::new(waves, wavenumbers, lattices, shifts, etas, parts)?;
33    Ok((residual.values()?, residual))
34}
35
36/// What [`sum_array`] saves for a batch of lattice sums: the original inputs.
37/// Every input holds one shared value or one value per output.
38#[derive(Debug)]
39pub struct SumResidual {
40    waves: Vec<Family>,
41    wavenumbers: Vec<Complex>,
42    lattices: Vec<BlochLattice>,
43    shifts: Vec<[f64; 3]>,
44    etas: Vec<Complex>,
45    parts: Vec<SumPart>,
46    size: usize,
47}
48
49impl SumResidual {
50    /// Retain a batch's inputs without evaluating any lattice sums.
51    pub fn new(
52        waves: Vec<Family>,
53        wavenumbers: Vec<Complex>,
54        lattices: Vec<BlochLattice>,
55        shifts: Vec<[f64; 3]>,
56        etas: Vec<Complex>,
57        parts: Vec<SumPart>,
58    ) -> Result<Self> {
59        let size = broadcast::size(
60            &[
61                waves.len(),
62                wavenumbers.len(),
63                lattices.len(),
64                shifts.len(),
65                etas.len(),
66                parts.len(),
67            ],
68            "arrays must have equal lengths or scalar inputs",
69        )?;
70        if parts
71            .iter()
72            .any(|p| matches!(p, SumPart::Real | SumPart::Reciprocal))
73            && etas.contains(&Complex::default())
74        {
75            return Err(Error::InvalidInput(
76                "component adjoints require an explicit nonzero Ewald split".into(),
77            ));
78        }
79        Ok(Self {
80            waves,
81            wavenumbers,
82            lattices,
83            shifts,
84            etas,
85            parts,
86            size,
87        })
88    }
89
90    /// Evaluate the recorded inputs once per output.
91    pub fn values(&self) -> Result<Vec<Complex>> {
92        broadcast::map(self.size, PARALLEL, |i| {
93            let (wave, k, lattice, r, eta, part) = self.element(i);
94            sum_part(wave, k, lattice, r, eta, part)
95        })
96    }
97    /// Contract each sum's analytic local derivatives with one input direction.
98    /// `tangents` contains one shared direction or one direction per output.
99    pub fn pushforward(&self, tangents: &[SumTangent]) -> Result<Vec<Complex>> {
100        if (tangents.len() != 1 && tangents.len() != self.size)
101            || tangents.iter().any(|t| !t.is_finite())
102        {
103            return Err(Error::InvalidInput(
104                "tangents must be finite and match lattice output".into(),
105            ));
106        }
107        broadcast::map(self.size, PARALLEL, |i| {
108            let tangent = broadcast::element(tangents, i);
109            let (wave, k, lattice, r, eta, part) = self.element(i);
110            Ok(derivatives_part(wave, k, lattice, r, eta, part)?.pushforward(&tangent))
111        })
112    }
113
114    /// The inputs of output `i`.
115    fn element(&self, i: usize) -> (Family, Complex, &BlochLattice, [f64; 3], Complex, SumPart) {
116        (
117            broadcast::element(&self.waves, i),
118            broadcast::element(&self.wavenumbers, i),
119            &self.lattices[if self.lattices.len() == 1 { 0 } else { i }],
120            broadcast::element(&self.shifts, i),
121            broadcast::element(&self.etas, i),
122            broadcast::element(&self.parts, i),
123        )
124    }
125
126    /// One gradient per output, from `cotangent`, the gradient of a real loss with
127    /// respect to the sums.
128    pub fn pullback(&self, cotangent: &[Complex]) -> Result<Vec<SumGradient>> {
129        if cotangent.len() != self.size || cotangent.iter().any(|&g| !finite(g)) {
130            return Err(Error::InvalidInput(
131                "cotangent must be finite and match lattice output".into(),
132            ));
133        }
134        broadcast::map(self.size, PARALLEL, |i| {
135            let g = cotangent[i];
136            if g == Complex::default() {
137                return Ok(SumGradient::default());
138            }
139            let (wave, k, lattice, r, eta, part) = self.element(i);
140            Ok(derivatives_part(wave, k, lattice, r, eta, part)?.pullback(g))
141        })
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn residual_reuses_inputs_across_forward_and_reverse_directions() {
151        let (_, residual) = sum_array(
152            vec![Family::Spherical { l: 2, m: -1 }],
153            vec![Complex::new(2.1, 0.2)],
154            vec![BlochLattice::new(&[vec![1.6]], &[0.1]).unwrap()],
155            vec![[0.19, 0.11, 0.07], [0.21, -0.09, 0.05]],
156            vec![Complex::new(0.9, 0.03)],
157            vec![SumPart::Full],
158        )
159        .unwrap();
160        let direction = SumTangent {
161            k: Complex::new(0.1, -0.2),
162            ..SumTangent::default()
163        };
164        let first = residual.pushforward(&[direction]).unwrap();
165        let cotangent = [Complex::new(0.3, 0.4), Complex::new(-0.2, 0.1)];
166        let gradient = residual.pullback(&cotangent).unwrap();
167        let opposite = residual
168            .pushforward(&[SumTangent {
169                k: -direction.k,
170                ..SumTangent::default()
171            }])
172            .unwrap();
173        for ((forward, reverse), (g, negated)) in first
174            .iter()
175            .zip(&gradient)
176            .zip(cotangent.iter().zip(opposite))
177        {
178            let pairing = (g.conj() * forward).re;
179            let transposed = (reverse.k.conj() * direction.k).re;
180            assert!((pairing - transposed).abs() < 1e-12 * (1.0 + pairing.abs()));
181            assert_eq!(*forward, -negated);
182        }
183        assert_eq!(first, residual.pushforward(&[direction]).unwrap());
184    }
185}