Skip to main content

semiflow/
adjoint_fp.rs

1//! Adjoint Fokker-Planck Chernoff on M(ℝ^D), weak-* topology (ADR-0107 AMD1, §38).
2//!
3//! `⟨f, S*(τ)ρ⟩ := ⟨S(τ)f, ρ⟩` (Theorem A.2). Lemma A.1 (§38.3):
4//! `S*(τ)δ_x = ¼δ_{x+h} + ¼δ_{x-h} + ½δ_{x+k} + τc·δ_x`, h=2√(aτ), k=2bτ.
5//!
6//! Not [`crate::AdjointChernoff`] (§15, ADR-0114): that is on `C_b`/`L²`;
7//! this is on the MEASURE space M(ℝ^D) (`MeasureState` vs `GridFn`).
8
9// Grid/index/count values (usize) cast to f64 for coordinate and coefficient computations;
10// all values are grid sizes or step counts ≪ 2^52, so precision loss is impossible in practice.
11#![allow(clippy::cast_precision_loss)]
12
13use alloc::vec::Vec;
14use core::marker::PhantomData;
15
16use crate::{
17    chernoff::{ChernoffFunction, Growth},
18    error::SemiflowError,
19    float::SemiflowFloat,
20    scratch::ScratchPool,
21    state::State,
22};
23
24// GaussianComponent (private helper for Dirac-count reduction in long-time use)
25#[derive(Clone)]
26struct GaussianComponent<F: SemiflowFloat, const D: usize> {
27    mean: [F; D],
28    /// Isotropic variance σ² (diagonal covariance σ²·I). Must be > 0.
29    variance: F,
30    weight: F,
31}
32
33// ---------------------------------------------------------------------------
34// MeasureState<F, D>
35// ---------------------------------------------------------------------------
36
37/// Sparse signed-measure state on M(ℝ^D) for adjoint Fokker-Planck Chernoff.
38/// Representation: finite weighted-Dirac sum + optional Gaussian background.
39/// Lemma A.1 pushes each Dirac at `x` to four children `x±h, x+k, x` with
40/// weights `(1/4, 1/4, 1/2, τc)`. The 4^n blow-up is resolved by `gridless.rs`.
41#[derive(Clone)]
42pub struct MeasureState<F: SemiflowFloat, const D: usize> {
43    diracs: Vec<([F; D], F)>,
44    gaussians: Vec<GaussianComponent<F, D>>,
45}
46
47impl<F: SemiflowFloat, const D: usize> MeasureState<F, D> {
48    /// Construct a single Dirac `δ_position` with signed weight.
49    #[must_use]
50    pub fn dirac(position: [F; D], weight: F) -> Self {
51        Self {
52            diracs: alloc::vec![(position, weight)],
53            gaussians: Vec::new(),
54        }
55    }
56
57    /// Construct a Gaussian-smoothed measure (isotropic variance).
58    ///
59    /// # Errors
60    /// [`SemiflowError::DomainViolation`] if variance ≤ 0 or weight non-finite.
61    pub fn gaussian(mean: [F; D], variance: F, weight: F) -> Result<Self, SemiflowError> {
62        if !variance.is_finite() || variance <= F::zero() {
63            return Err(SemiflowError::DomainViolation {
64                what: "MeasureState::gaussian: variance must be finite and > 0",
65                value: variance.to_f64().unwrap_or(f64::NAN),
66            });
67        }
68        if !weight.is_finite() {
69            return Err(SemiflowError::DomainViolation {
70                what: "MeasureState::gaussian: weight must be finite",
71                value: weight.to_f64().unwrap_or(f64::NAN),
72            });
73        }
74        Ok(Self {
75            diracs: Vec::new(),
76            gaussians: alloc::vec![GaussianComponent {
77                mean,
78                variance,
79                weight
80            }],
81        })
82    }
83
84    /// Construct from `(position, weight)` pairs; no validity checks.
85    #[must_use]
86    pub fn from_particles(particles: &[([F; D], F)]) -> Self {
87        Self {
88            diracs: particles.to_vec(),
89            gaussians: Vec::new(),
90        }
91    }
92
93    /// Total-variation norm `‖ρ‖_TV` — rate modulator in Theorem A.2 (§38.4).
94    #[must_use]
95    pub fn total_variation(&self) -> F {
96        let d = self.diracs.iter().fold(F::zero(), |a, (_, w)| a + w.abs());
97        let g = self
98            .gaussians
99            .iter()
100            .fold(F::zero(), |a, g| a + g.weight.abs());
101        d + g
102    }
103
104    /// Second moment `⟨x², ρ⟩` — tightness monitor per Lemma A.3 (§38.5).
105    #[must_use]
106    pub fn second_moment(&self) -> F {
107        let d = self.diracs.iter().fold(F::zero(), |acc, (pos, w)| {
108            acc + pos.iter().fold(F::zero(), |s, &xi| s + xi * xi) * *w
109        });
110        let g = self.gaussians.iter().fold(F::zero(), |acc, g| {
111            let norm2 = g.mean.iter().fold(F::zero(), |s, &xi| s + xi * xi);
112            let d_f = F::from(D as f64).unwrap_or(F::one());
113            acc + (norm2 + d_f * g.variance) * g.weight
114        });
115        d + g
116    }
117
118    /// Dual pairing `⟨f, ρ⟩` for a test function f ∈ `C_b`.
119    /// Gaussians use point evaluation at the mean.
120    #[must_use]
121    pub fn pair<G: Fn(&[F; D]) -> F>(&self, f: G) -> F {
122        let d = self
123            .diracs
124            .iter()
125            .fold(F::zero(), |a, (p, w)| a + f(p) * *w);
126        let g = self
127            .gaussians
128            .iter()
129            .fold(F::zero(), |a, g| a + f(&g.mean) * g.weight);
130        d + g
131    }
132
133    /// Number of Dirac components (diagnostic).
134    #[must_use]
135    pub fn n_diracs(&self) -> usize {
136        self.diracs.len()
137    }
138
139    /// Mass-weighted centre of mass (first moment), per axis (§38.12).
140    ///
141    /// Returns `E[x_d] = (Σ_diracs w·pos[d] + Σ_gauss w·mean[d]) / mass` for
142    /// each axis `d`, where `mass = total_variation()`.
143    ///
144    /// If `mass = 0`, returns all-zeros (undefined mean for the zero measure).
145    #[must_use]
146    // Const-generic array loops: `result[d]` must stay index-based because
147    // the inner body also references `pos[d]` / `g.mean[d]` by the same `d`.
148    #[allow(clippy::needless_range_loop)]
149    pub fn first_moment(&self) -> [F; D] {
150        let mass = self.total_variation();
151        if mass == F::zero() {
152            return [F::zero(); D];
153        }
154        let mut result = [F::zero(); D];
155        for (pos, w) in &self.diracs {
156            for d in 0..D {
157                result[d] += pos[d] * *w;
158            }
159        }
160        for g in &self.gaussians {
161            for d in 0..D {
162                result[d] += g.mean[d] * g.weight;
163            }
164        }
165        for d in 0..D {
166            result[d] /= mass;
167        }
168        result
169    }
170
171    /// Total scalar variance `Var = E[|x|²] − |E[x]|²` (§38.12).
172    ///
173    /// Uses mass-normalised second moment: `E[|x|²] = second_moment() / mass`.
174    /// Returns 0 if `mass = 0`.
175    #[must_use]
176    pub fn variance(&self) -> F {
177        let mass = self.total_variation();
178        if mass == F::zero() {
179            return F::zero();
180        }
181        let e_x2 = self.second_moment() / mass;
182        let mu = self.first_moment();
183        let mu_sq = mu.iter().fold(F::zero(), |acc, &xi| acc + xi * xi);
184        e_x2 - mu_sq
185    }
186
187    /// Per-axis variance `Var_d = E[x_d²] − (E[x_d])²` (§38.12).
188    ///
189    /// Returns all-zeros if `mass = 0`.
190    #[must_use]
191    // Const-generic loops: `e_x2_per[d]` and `result[d]` must stay
192    // index-based since the body also accesses `pos[d]` / `g.mean[d]`.
193    #[allow(clippy::needless_range_loop)]
194    pub fn variance_per_axis(&self) -> [F; D] {
195        let mass = self.total_variation();
196        if mass == F::zero() {
197            return [F::zero(); D];
198        }
199        // Per-axis unnormalised second moment: Σ_diracs w·pos[d]² + Σ_gauss w·(mean[d]²+var)
200        let mut e_x2_per = [F::zero(); D];
201        for (pos, w) in &self.diracs {
202            for d in 0..D {
203                e_x2_per[d] += pos[d] * pos[d] * *w;
204            }
205        }
206        for g in &self.gaussians {
207            for d in 0..D {
208                e_x2_per[d] += (g.mean[d] * g.mean[d] + g.variance) * g.weight;
209            }
210        }
211        let mu = self.first_moment();
212        let mut result = [F::zero(); D];
213        for d in 0..D {
214            result[d] = e_x2_per[d] / mass - mu[d] * mu[d];
215        }
216        result
217    }
218
219    /// Extract Dirac positions and weights as `(positions, weights)` (D=1 binding ABI).
220    /// Gaussians are not included; use on pure-Dirac states from `apply_into`.
221    /// Used by `G_BINDING_ADJOINT_FP_PARITY` integration tests.
222    #[must_use]
223    pub fn to_flat_buffers_d1(&self) -> (Vec<F>, Vec<F>)
224    where
225        [F; 1]: Sized,
226        F: Copy,
227    {
228        let pos: Vec<F> = self.diracs.iter().map(|(p, _)| p[0]).collect();
229        let wts: Vec<F> = self.diracs.iter().map(|(_, w)| *w).collect();
230        (pos, wts)
231    }
232
233    fn zero_in_place(&mut self) {
234        self.diracs.clear();
235        self.gaussians.clear();
236    }
237
238    /// Crate-private: raw Dirac slice (used by `gridless.rs`).
239    #[must_use]
240    pub(crate) fn as_diracs_slice(&self) -> &[([F; D], F)] {
241        &self.diracs
242    }
243
244    pub(crate) fn push_dirac_raw(&mut self, pos: [F; D], weight: F) {
245        self.diracs.push((pos, weight));
246    }
247
248    /// Crate-private: clear and reserve (used by `gridless.rs`).
249    pub(crate) fn clear_diracs_with_capacity(&mut self, cap: usize) {
250        self.diracs.clear();
251        self.diracs.reserve(cap);
252    }
253}
254
255/// `State<F>` impl for `MeasureState<F, D>` (dynamic-length signed measure).
256///
257/// `len()` = atom count (grows after `axpy_into`). `axpy_into` APPENDS atoms;
258/// `norm_sup()` returns `‖ρ‖_TV`. Fixed-length invariants from `GridFn1D`
259/// do NOT apply here.
260impl<F: SemiflowFloat, const D: usize> State<F> for MeasureState<F, D> {
261    fn len(&self) -> usize {
262        self.diracs.len() + self.gaussians.len()
263    }
264
265    fn axpy_into(&mut self, alpha: F, src: &Self) {
266        for (pos, w) in &src.diracs {
267            self.diracs.push((*pos, alpha * *w));
268        }
269        for g in &src.gaussians {
270            self.gaussians.push(GaussianComponent {
271                mean: g.mean,
272                variance: g.variance,
273                weight: alpha * g.weight,
274            });
275        }
276    }
277
278    fn copy_from(&mut self, src: &Self) {
279        self.diracs.clone_from(&src.diracs);
280        self.gaussians.clone_from(&src.gaussians);
281    }
282
283    fn zero_into(&mut self) {
284        self.zero_in_place();
285    }
286
287    /// Returns `‖ρ‖_TV` as a surrogate (TV-norm is the natural measure norm).
288    fn norm_sup(&self) -> F {
289        self.total_variation()
290    }
291
292    fn scale_into(&mut self, k: F) {
293        for (_, w) in &mut self.diracs {
294            *w *= k;
295        }
296        for g in &mut self.gaussians {
297            g.weight *= k;
298        }
299    }
300}
301
302// Lemma A.1 push (§38.3) — 1D 4-Dirac kernel; defined before Adjointable (batch H8).
303// a, b, c are generator coefficients per Lemma A.1; single-char names match math notation.
304#[allow(clippy::many_single_char_names)]
305fn lemma_a1_push<F: SemiflowFloat>(
306    tau: F,
307    a: F,
308    b: F,
309    c: F,
310    src: &MeasureState<F, 1>,
311    dst: &mut MeasureState<F, 1>,
312) -> Result<(), SemiflowError> {
313    if !tau.is_finite() || tau < F::zero() {
314        return Err(SemiflowError::DomainViolation {
315            what: "adjoint_fp: tau must be finite and >= 0",
316            value: tau.to_f64().unwrap_or(f64::NAN),
317        });
318    }
319    let two = F::from(2.0).unwrap_or(F::one() + F::one());
320    let four = two + two;
321    let quarter = F::one() / four;
322    let half = F::one() / two;
323    let h = two * (a * tau).sqrt(); // Lemma A.1: h = 2√(aτ)
324    let k = two * b * tau; // Lemma A.1: k = 2bτ
325    let tc = tau * c;
326
327    dst.zero_in_place();
328    for (pos, w) in &src.diracs {
329        let x = pos[0];
330        dst.diracs.push(([x + h], quarter * *w));
331        dst.diracs.push(([x - h], quarter * *w));
332        dst.diracs.push(([x + k], half * *w));
333        dst.diracs.push(([x], tc * *w));
334    }
335    // Gaussian: mean shifts by k, variance grows by 2aτ (Lemma A.3 §38.5).
336    for g in &src.gaussians {
337        dst.gaussians.push(GaussianComponent {
338            mean: [g.mean[0] + k],
339            variance: g.variance + two * a * tau,
340            weight: (F::one() + tc) * g.weight,
341        });
342    }
343    Ok(())
344}
345
346/// Supertrait of `ChernoffFunction<F>`: admits a weak-* adjoint on M(ℝ^D).
347/// Blanket impl (D=1) = Lemma A.1 4-Dirac push; override for performance.
348pub trait Adjointable<F: SemiflowFloat, const D: usize>: ChernoffFunction<F> {
349    /// Push-forward one Chernoff step on the measure side (Lemma A.1, §38.3).
350    ///
351    /// `a, b, c` are the generator coefficients; `tau` is the step size.
352    ///
353    /// # Errors
354    /// [`SemiflowError::DomainViolation`] if `tau < 0` or non-finite.
355    // τ + 3 generator coefficients + src + dst = 7; all are required for the Lemma A.1 push.
356    #[allow(clippy::too_many_arguments)]
357    fn apply_adjoint_into(
358        &self,
359        tau: F,
360        a: F,
361        b: F,
362        c: F,
363        src: &MeasureState<F, D>,
364        dst: &mut MeasureState<F, D>,
365    ) -> Result<(), SemiflowError>;
366}
367
368// Blanket Adjointable impl for D = 1 via Lemma A.1.
369impl<C, F> Adjointable<F, 1> for C
370where
371    C: ChernoffFunction<F>,
372    F: SemiflowFloat,
373{
374    fn apply_adjoint_into(
375        &self,
376        tau: F,
377        a: F,
378        b: F,
379        c: F,
380        src: &MeasureState<F, 1>,
381        dst: &mut MeasureState<F, 1>,
382    ) -> Result<(), SemiflowError> {
383        lemma_a1_push(tau, a, b, c, src, dst)
384    }
385}
386
387/// Adjoint Fokker-Planck Chernoff on M(ℝ^D) (Theorem A.2, §38.4).
388///
389/// Wraps any `C: Adjointable<F, D>` via `⟨f, S*(τ)ρ⟩ := ⟨S(τ)f, ρ⟩`.
390/// `order()` and `growth()` inherit from `C`. `type S = MeasureState<F, D>`.
391///
392/// ```rust,no_run
393/// use semiflow::{AdjointFokkerPlanckChernoff, DiffusionChernoff, Grid1D, MeasureState};
394/// let grid = Grid1D::new(-4.0, 4.0, 64).unwrap();
395/// let fwd = DiffusionChernoff::new(|_| 0.5, |_| 0.0, |_| 0.0, 0.5, grid);
396/// let adj = AdjointFokkerPlanckChernoff::new(fwd, 0.5, 0.0, 0.0);
397/// ```
398#[derive(Clone)]
399pub struct AdjointFokkerPlanckChernoff<C, F: SemiflowFloat, const D: usize>
400where
401    C: Adjointable<F, D>,
402{
403    forward: C,
404    a: F, // diffusion coefficient a (h = 2√(aτ))
405    b: F, // drift coefficient b (k = 2bτ)
406    c: F, // reaction coefficient c (mass factor 1 + τc)
407    _phantom: PhantomData<F>,
408}
409
410impl<C, F, const D: usize> AdjointFokkerPlanckChernoff<C, F, D>
411where
412    C: Adjointable<F, D>,
413    F: SemiflowFloat,
414{
415    /// Wrap a forward Chernoff function into its weak-* dual (§38.2).
416    ///
417    /// `a, b, c` are the generator coefficients of `L = a·∂²_x + b·∂_x + c`
418    /// (Theorem 4, Galkin-Remizov 2025 *IJM* eq. 11). The adjoint S* uses
419    /// `h = 2√(aτ)`, `k = 2bτ`, mass factor `1 + τc` (Lemma A.1, §38.3).
420    #[must_use]
421    pub fn new(forward: C, a: F, b: F, c: F) -> Self {
422        Self {
423            forward,
424            a,
425            b,
426            c,
427            _phantom: PhantomData,
428        }
429    }
430
431    /// Borrow the wrapped forward Chernoff function.
432    #[must_use]
433    pub fn forward(&self) -> &C {
434        &self.forward
435    }
436
437    /// Diffusion coefficient a (push-distance `h = 2√(aτ)`).
438    #[must_use]
439    pub fn a(&self) -> F {
440        self.a
441    }
442
443    /// Drift coefficient b (push-distance `k = 2bτ`).
444    #[must_use]
445    pub fn b(&self) -> F {
446        self.b
447    }
448
449    /// Reaction coefficient c (mass factor `1 + τc`).
450    #[must_use]
451    pub fn c(&self) -> F {
452        self.c
453    }
454}
455
456impl<C, F, const D: usize> ChernoffFunction<F> for AdjointFokkerPlanckChernoff<C, F, D>
457where
458    C: Adjointable<F, D>,
459    F: SemiflowFloat,
460{
461    type S = MeasureState<F, D>;
462
463    fn apply_into(
464        &self,
465        tau: F,
466        src: &MeasureState<F, D>,
467        dst: &mut MeasureState<F, D>,
468        _scratch: &mut ScratchPool<F>,
469    ) -> Result<(), SemiflowError> {
470        Adjointable::apply_adjoint_into(&self.forward, tau, self.a, self.b, self.c, src, dst)
471    }
472
473    /// Inherits order from the forward kernel (Theorem A.2 identical rate, §38.4).
474    fn order(&self) -> u32 {
475        self.forward.order()
476    }
477
478    /// Growth bound inherited: `‖S*(τ)‖_{M→M} = ‖S(τ)‖_{C_b→C_b}` (Bogachev §4).
479    fn growth(&self) -> Growth<F> {
480        self.forward.growth()
481    }
482}
483
484#[cfg(test)]
485include!(concat!(
486    env!("CARGO_MANIFEST_DIR"),
487    "/src/adjoint_fp_tests.rs"
488));