molpack/restraint/collective/mod.rs
1//! Collective (group-level) restraints — the **geometric distribution-matching**
2//! family.
3//!
4//! Where a per-atom [`AtomRestraint`](crate::restraint::AtomRestraint) sees one atom at a
5//! time and contributes an independent external field `∑ᵢ U(xᵢ)`, a
6//! [`Restraint`] sees *every* copy of a species at once and returns a
7//! single penalty whose gradient is **coupled across the whole group**. That
8//! coupling is what lets a species *follow* a target spatial distribution: a
9//! per-atom field built from a target density is minimised by collapsing every
10//! atom onto the density's mode, whereas a distribution-distance penalty is
11//! minimised when the empirical distribution *equals* the target.
12//!
13//! # Structure of this family
14//!
15//! Every member matches a target **distribution** of a scalar reaction
16//! coordinate ξ defined by a **geometry**, via the squared 1-D Wasserstein
17//! (sorted-CDF) metric ([`engine`]). The two axes are orthogonal:
18//!
19//! - **geometry** ([`geometry`]) — maps Cartesian coordinates to ξ and scatters
20//! `∂L/∂ξ` back onto them: `plane` (ξ = signed distance to a plane → a slab),
21//! `point` (ξ = distance to a centre → a spherical shell), …
22//! - **distribution** — the target quantile function `q(p) = F⁻¹(p)`: Gaussian,
23//! exponential, …
24//!
25//! Concrete types are the cross product, named `<Distribution><Geometry>`
26//! and implementing [`Restraint`] directly (no wrapper, no builder —
27//! same direction-3 convention as the per-atom restraints):
28//! [`GaussianPlane`], [`GaussianPoint`], … Adding a distribution is a new
29//! quantile function; adding a geometry is a new ξ/scatter pair; a new concrete
30//! type then composes the two through the shared [`engine`].
31//!
32//! **Gradient convention** mirrors [`AtomRestraint`](crate::restraint::AtomRestraint):
33//! `fg` accumulates `∂L/∂coords[i]` INTO `grads[i]` with `+=`. `coords` and
34//! `grads` have equal length (one entry per atom in the group, same order).
35
36use molrs::types::F;
37
38// ============================================================================
39// Trait
40// ============================================================================
41
42/// Group-level penalty over all copies of one species.
43///
44/// Unlike [`AtomRestraint`](crate::restraint::AtomRestraint), which is evaluated once
45/// per atom with only that atom's coordinate, a `Restraint` is
46/// evaluated once per group with the coordinates of *all* copies. Its gradient
47/// may therefore couple every particle to every other — exactly what a
48/// distribution-matching penalty needs.
49pub trait Restraint: Send + Sync + std::fmt::Debug {
50 /// Penalty value for the group's current configuration.
51 ///
52 /// `coords[i]` is the Cartesian position of the `i`-th atom in the group.
53 fn f(&self, coords: &[[F; 3]], scale: F, scale2: F) -> F;
54
55 /// Fused value + gradient. Accumulates `∂L/∂coords[i]` INTO `grads[i]`
56 /// with `+=`; returns the same value `f` would. `grads.len() == coords.len()`.
57 fn fg(&self, coords: &[[F; 3]], scale: F, scale2: F, grads: &mut [[F; 3]]) -> F;
58
59 /// If `false`, the scheduler serializes this restraint (Python-backed
60 /// collective restraints MUST return `false`).
61 fn is_parallel_safe(&self) -> bool {
62 true
63 }
64
65 /// Human-readable identifier.
66 fn name(&self) -> &'static str {
67 std::any::type_name::<Self>()
68 }
69}
70
71mod engine;
72mod exponential;
73mod gaussian;
74mod geometry;
75mod tabulated;
76
77pub use exponential::{ExponentialPlane, ExponentialPoint};
78pub use gaussian::{GaussianPlane, GaussianPoint};
79pub use tabulated::{TabulatedPlane, TabulatedPoint};
80
81/// Shared test helpers for the concrete `<Distribution><Geometry>` types:
82/// a dependency-free RNG and a finite-difference gradient check.
83#[cfg(test)]
84pub(super) mod testutil {
85 use super::Restraint;
86 use molrs::types::F;
87
88 /// Deterministic xorshift64* uniform in `[lo, hi)` — no external dep.
89 pub(crate) fn rng_uniform(seed: &mut u64, lo: F, hi: F) -> F {
90 let mut x = *seed;
91 x ^= x >> 12;
92 x ^= x << 25;
93 x ^= x >> 27;
94 *seed = x;
95 let u = (x.wrapping_mul(0x2545F4914F6CDD1D) >> 11) as F / (1u64 << 53) as F;
96 lo + u * (hi - lo)
97 }
98
99 /// Central finite-difference check of the analytic gradient along every axis.
100 /// Coordinates must be spread enough that a 1e-6 perturbation never crosses a
101 /// rank swap (where the sorted-CDF objective is non-smooth).
102 pub(crate) fn assert_fd_grad(r: &dyn Restraint, coords: &[[F; 3]]) {
103 let mut analytic = vec![[0.0 as F; 3]; coords.len()];
104 r.fg(coords, 1.0, 1.0, &mut analytic);
105 let eps = 1e-6;
106 for i in 0..coords.len() {
107 for k in 0..3 {
108 let mut plus = coords.to_vec();
109 let mut minus = coords.to_vec();
110 plus[i][k] += eps;
111 minus[i][k] -= eps;
112 let fd = (r.f(&plus, 1.0, 1.0) - r.f(&minus, 1.0, 1.0)) / (2.0 * eps);
113 assert!(
114 (fd - analytic[i][k]).abs() < 1e-4,
115 "{} atom {i} axis {k}: fd={fd}, analytic={}",
116 r.name(),
117 analytic[i][k]
118 );
119 }
120 }
121 }
122}