Skip to main content

molpack/
objective.rs

1//! Objective function and gradient computation.
2//! Exact port of `computef.f90`, `computeg.f90`, `fparc.f90`, `gparc.f90`.
3
4use crate::cell::{index_cell, setcell};
5use crate::constraints::{EvalMode, EvalOutput};
6use crate::context::{ATOM_FLAG_FIXED, ATOM_FLAG_SHORT, NONE_IDX, PackContext};
7use crate::euler::{compcart, eulerrmat, eulerrmat_derivatives};
8use molrs::types::F;
9#[cfg(feature = "rayon")]
10use rayon::prelude::*;
11
12#[derive(Clone, Copy)]
13enum ExpandMode {
14    F,
15    G,
16    FG,
17}
18
19/// Precomputed PBC constants so the inner pair loops can wrap without
20/// a per-axis division. `inv_length[k] = 1/length[k]` when PBC is active on
21/// axis k, else 0. `any_active` short-circuits the entire wrap in the common
22/// non-PBC workload.
23#[derive(Clone, Copy)]
24struct PbcConstants {
25    length: [F; 3],
26    inv_length: [F; 3],
27    any_active: bool,
28}
29
30#[inline(always)]
31fn pbc_constants(sys: &PackContext) -> PbcConstants {
32    let length = sys.pbc_length;
33    let mut inv_length = [0.0 as F; 3];
34    let mut any_active = false;
35    for k in 0..3 {
36        if sys.pbc_periodic[k] && length[k] > 0.0 {
37            inv_length[k] = 1.0 / length[k];
38            any_active = true;
39        }
40    }
41    PbcConstants {
42        length,
43        inv_length,
44        any_active,
45    }
46}
47
48#[inline(always)]
49fn pbc_wrap_delta(dx: F, dy: F, dz: F, pbc: &PbcConstants) -> (F, F, F) {
50    if !pbc.any_active {
51        return (dx, dy, dz);
52    }
53    (
54        dx - (dx * pbc.inv_length[0]).round() * pbc.length[0],
55        dy - (dy * pbc.inv_length[1]).round() * pbc.length[1],
56        dz - (dz * pbc.inv_length[2]).round() * pbc.length[2],
57    )
58}
59
60// Parallel pair evaluation is user-selected via
61// `Molpack::parallel_pair_eval(true)` → `PackContext::parallel_pair_eval`.
62// The library does not attempt to auto-detect when rayon pays off: the
63// crossover is workload-shaped (call frequency, work-per-call,
64// movebad proportion) rather than something inferrable from a single
65// per-pack metric like `active_cells.len()`.
66
67/// Per-atom hot-path state pulled out once before the inner `jcart` loop
68/// inside `fparc` / `gparc` / `fgparc` (and the rayon variants `fparc_stats`
69/// and `fgparc_into`).
70///
71/// Before this type, each of those four kernels had the same 15-line
72/// prologue: read `atom_props[icart]`, derive `fixed_i`, `use_short_i`,
73/// `shrad_i`, `shscl_i`, and cache `any_fixed_atoms` / `any_short_radius`
74/// as `has_fixed` / `has_short`. Drift between the four copies would
75/// silently change kernel behaviour — centralising it here means one
76/// edit site. The struct is `#[inline(always)]`-constructed so the call
77/// compiles to the same loads the inlined prologue produced (verified
78/// by the `pair_kernel` bench — see the commit message for Fix 2).
79#[derive(Clone, Copy)]
80struct AtomHotState {
81    props: crate::context::AtomProps,
82    /// Cached `sys.any_short_radius` — guards the cold short-radius fetch.
83    has_short: bool,
84    /// Atom `i` itself is fixed — used only together with the `j`-fixed
85    /// check to short-circuit the pair.
86    fixed_i: bool,
87    /// Atom `i` itself opts into the short-radius penalty — one half of
88    /// the `use_short_i || use_short_j` gate.
89    use_short_i: bool,
90    shrad_i: F,
91    shscl_i: F,
92}
93
94impl AtomHotState {
95    #[inline(always)]
96    fn load(icart: usize, sys: &PackContext) -> Self {
97        let props = sys.atom_props[icart];
98        let has_short = sys.any_short_radius;
99        let has_fixed = sys.any_fixed_atoms;
100        let fixed_i = has_fixed && (props.flags & ATOM_FLAG_FIXED != 0);
101        let use_short_i = has_short && (props.flags & ATOM_FLAG_SHORT != 0);
102        // The short-radius `Vec`s are cold when `any_short_radius` is
103        // false, so skip the load entirely — the branch below folds away
104        // when `has_short` is a compile-time constant in the caller
105        // (rare) or branch-predicts perfectly otherwise.
106        let shrad_i = if has_short {
107            sys.short_radius[icart]
108        } else {
109            0.0
110        };
111        let shscl_i = if has_short {
112            sys.short_radius_scale[icart]
113        } else {
114            0.0
115        };
116        Self {
117            props,
118            has_short,
119            fixed_i,
120            use_short_i,
121            shrad_i,
122            shscl_i,
123        }
124    }
125}
126
127/// Compute objective function value and update `sys.xcart`.
128/// Port of `computef.f90`.
129///
130/// `x` layout: [COM₀..COMₙ (3N)] ++ [euler₀..eulerₙ (3N)]
131///
132/// Takes the geometry-cache fast path when `x` and the cell grid are identical
133/// to the last expansion: the Cartesian rebuild and linked-cell rebuild are
134/// skipped and only the constraint + pair kernels re-run on the stored state.
135/// The packer hits this path on every outer iteration when it re-evaluates at
136/// unscaled radii after `pgencan` returns, since radius mutation does not
137/// invalidate the cache key.
138pub fn compute_f(x: &[F], sys: &mut PackContext) -> F {
139    sys.debug_assert_atom_props_sync();
140    sys.increment_ncf();
141    sys.fdist = 0.0;
142    sys.frest = 0.0;
143
144    if matches_cached_geometry(x, sys) {
145        let mut f = accumulate_constraint_values_from_xcart(sys);
146        if !sys.init1 {
147            f += accumulate_pair_f(sys);
148            f += accumulate_collective_f(sys);
149        }
150        return f;
151    }
152
153    if !sys.init1 {
154        sys.resetcells();
155    }
156
157    let mut f = expand_molecules(x, sys, ExpandMode::F);
158
159    if sys.init1 {
160        update_cached_geometry(x, sys);
161        return f;
162    }
163
164    f += accumulate_pair_f(sys);
165    f += accumulate_collective_f(sys);
166    update_cached_geometry(x, sys);
167
168    f
169}
170
171/// Compute objective function value and gradient in one pass over geometry/state.
172/// This avoids rebuilding Cartesian coordinates and cell lists twice.
173///
174/// Takes the geometry-cache fast path when `x` and the cell grid match the
175/// last expansion — the Cartesian rebuild and linked-cell rebuild are skipped.
176pub fn compute_fg(x: &[F], sys: &mut PackContext, g: &mut [F]) -> F {
177    sys.debug_assert_atom_props_sync();
178    sys.increment_ncf();
179    sys.increment_ncg();
180    sys.fdist = 0.0;
181    sys.frest = 0.0;
182    sys.work.gxcar.fill([0.0; 3]);
183
184    if matches_cached_geometry(x, sys) {
185        let mut f = accumulate_constraint_values_and_gradients_from_xcart(sys);
186        if !sys.init1 {
187            f += accumulate_pair_fg(sys);
188            f += accumulate_collective_fg(sys);
189        }
190        project_cartesian_gradient(x, sys, g);
191        return f;
192    }
193
194    if !sys.init1 {
195        sys.resetcells();
196    }
197
198    let mut f = expand_molecules(x, sys, ExpandMode::FG);
199
200    if !sys.init1 {
201        f += accumulate_pair_fg(sys);
202        f += accumulate_collective_fg(sys);
203    }
204
205    update_cached_geometry(x, sys);
206    project_cartesian_gradient(x, sys, g);
207
208    f
209}
210
211/// Contribution of one *i*–*j* atom pair to the packing objective.
212///
213/// `grad` is the force to **add** to atom *i* and **subtract** from atom *j*
214/// (Newton's third law); it folds the main and short-range terms together and
215/// stays `[0; 3]` whenever the pair doesn't overlap or `GRAD` is `false`.
216/// `overlap` is `datom < tol` — callers use it to skip the gradient write for
217/// the common non-overlapping pair. `violation` is `tol_ini - datom`, only
218/// meaningful (and only computed) when `VIOLATION` is `true`.
219#[derive(Clone, Copy)]
220struct PairContribution {
221    energy: F,
222    grad: [F; 3],
223    violation: F,
224    overlap: bool,
225}
226
227/// The single source of truth for per-pair packing physics: the distance
228/// penalty, its gradient, the short-range correction, and the overlap
229/// violation. Every neighbor-walk variant (serial/parallel, value-only/fused,
230/// move-tracking or not) calls this — they differ only in *where* they apply
231/// the result, never in the formula.
232///
233/// Returns `None` when the pair is skipped (same molecule, or both fixed). The
234/// two const generics let each caller drop the work it doesn't need at zero
235/// runtime cost: `GRAD` removes the force math for value-only passes,
236/// `VIOLATION` removes the `fdist` term for the gradient-only pass.
237///
238/// Per-atom reads go through `sys.atom_props` — an AoS mirror packing the hot
239/// fields into one cache line per atom (see [`AtomProps`]).
240#[inline(always)]
241fn pair_term<const GRAD: bool, const VIOLATION: bool>(
242    hot: &AtomHotState,
243    xi: [F; 3],
244    jcart: usize,
245    sys: &PackContext,
246    pbc: &PbcConstants,
247) -> Option<PairContribution> {
248    let props_j = sys.atom_props[jcart];
249    // Skip same molecule.
250    if hot.props.ibmol == props_j.ibmol && hot.props.ibtype == props_j.ibtype {
251        return None;
252    }
253    // Skip two fixed atoms (only matters if the system has any at all).
254    if hot.fixed_i && (props_j.flags & ATOM_FLAG_FIXED != 0) {
255        return None;
256    }
257
258    let xj = sys.xcart[jcart];
259    let (dx, dy, dz) = pbc_wrap_delta(xi[0] - xj[0], xi[1] - xj[1], xi[2] - xj[2], pbc);
260    let datom = dx * dx + dy * dy + dz * dz;
261    let rsum = hot.props.radius + props_j.radius;
262    let tol = rsum * rsum;
263
264    let mut energy: F = 0.0;
265    let mut grad = [0.0; 3];
266    let overlap = datom < tol;
267
268    if overlap {
269        let penalty = datom - tol;
270        let scale = hot.props.fscale * props_j.fscale;
271        energy += scale * penalty * penalty;
272        if GRAD {
273            let dtemp = scale * 4.0 * penalty;
274            grad[0] += dtemp * dx;
275            grad[1] += dtemp * dy;
276            grad[2] += dtemp * dz;
277        }
278        if hot.has_short && (hot.use_short_i || (props_j.flags & ATOM_FLAG_SHORT != 0)) {
279            let short_rsum = hot.shrad_i + sys.short_radius[jcart];
280            let short_tol = short_rsum * short_rsum;
281            if datom < short_tol {
282                let short_penalty = datom - short_tol;
283                let mut sr_scale = (hot.shscl_i * sys.short_radius_scale[jcart]).sqrt();
284                sr_scale *= (tol * tol) / (short_tol * short_tol);
285                let sr_pair_scale = scale * sr_scale;
286                energy += sr_pair_scale * short_penalty * short_penalty;
287                if GRAD {
288                    let dtemp2 = sr_pair_scale * 4.0 * short_penalty;
289                    grad[0] += dtemp2 * dx;
290                    grad[1] += dtemp2 * dy;
291                    grad[2] += dtemp2 * dz;
292                }
293            }
294        }
295    }
296
297    let violation = if VIOLATION {
298        let rsum_ini = hot.props.radius_ini + props_j.radius_ini;
299        let tol_ini = rsum_ini * rsum_ini;
300        tol_ini - datom
301    } else {
302        0.0
303    };
304
305    Some(PairContribution {
306        energy,
307        grad,
308        violation,
309        overlap,
310    })
311}
312
313/// Atom-pair distance penalty function.
314/// Port of `fparc.f90`.
315///
316/// Returns `(penalty_sum, fdist_max)`. The caller aggregates `fdist_max`
317/// across pair traversal and updates `sys.fdist` once at the end, so the
318/// inner loop keeps a data dependency on a local register instead of an
319/// `&mut PackContext` field.
320///
321/// The per-atom reads go through `sys.atom_props` — an AoS mirror that
322/// packs the ten or so hot fields into one cache line per atom (see
323/// [`AtomProps`]).
324#[inline(always)]
325fn fparc(icart: usize, first_jcart: u32, sys: &mut PackContext, pbc: &PbcConstants) -> (F, F) {
326    let mut result = 0.0;
327    let mut local_fdist: F = 0.0;
328    let mut jcart_id = first_jcart;
329    let xi = sys.xcart[icart];
330    let hot = AtomHotState::load(icart, sys);
331    let move_flag = sys.move_flag;
332
333    while jcart_id != NONE_IDX {
334        let jcart = jcart_id as usize;
335        let next = sys.latomnext[jcart];
336        if let Some(c) = pair_term::<false, true>(&hot, xi, jcart, sys, pbc) {
337            result += c.energy;
338            if c.violation > local_fdist {
339                local_fdist = c.violation;
340            }
341            if move_flag {
342                if c.violation > sys.fdist_atom[icart] {
343                    sys.fdist_atom[icart] = c.violation;
344                }
345                if c.violation > sys.fdist_atom[jcart] {
346                    sys.fdist_atom[jcart] = c.violation;
347                }
348            }
349        }
350        jcart_id = next;
351    }
352
353    (result, local_fdist)
354}
355
356/// Compute gradient `g` from current system state.
357/// Port of `computeg.f90`.
358pub fn compute_g(x: &[F], sys: &mut PackContext, g: &mut [F]) {
359    sys.debug_assert_atom_props_sync();
360    sys.increment_ncg();
361    // Zero Cartesian gradient
362    sys.work.gxcar.fill([0.0; 3]);
363
364    if matches_cached_geometry(x, sys) {
365        accumulate_constraint_gradient_from_xcart(sys);
366        if !sys.init1 {
367            accumulate_pair_g(sys);
368            let _ = accumulate_collective_fg(sys);
369        }
370        project_cartesian_gradient(x, sys, g);
371        return;
372    }
373
374    if !sys.init1 {
375        sys.resetcells();
376    }
377
378    expand_molecules(x, sys, ExpandMode::G);
379
380    if !sys.init1 {
381        accumulate_pair_g(sys);
382        let _ = accumulate_collective_fg(sys);
383    }
384
385    update_cached_geometry(x, sys);
386    project_cartesian_gradient(x, sys, g);
387}
388
389/// Expand all active molecules into `sys.xcart`, applying either constraint
390/// values (`computef`) or constraint gradients (`computeg`), and rebuilding the
391/// cell list.
392///
393/// One phase-structured implementation shared by the serial and rayon paths.
394/// **Phase A** rebuilds each molecule's Cartesian coordinates (`eulerrmat` +
395/// `compcart`) and evaluates its box/restraint constraints, writing **disjoint**
396/// per-atom `xcart` / `gxcar` (and, under `move_flag`, `frest_atom`) slots — so it
397/// has no cross-task write conflict and parallelises cleanly. **Phase B** replays
398/// the linked-cell insertion serially in the identical molecule/atom order (that
399/// insertion mutates a shared list and is inherently serial). The *only*
400/// difference between a serial and a parallel run is whether Phase A iterates
401/// with `iter` or `par_iter` — see [`expand_reduce`]. There is no second
402/// algorithm, and the resulting cell lists are bit-identical either way.
403fn expand_molecules(x: &[F], sys: &mut PackContext, mode: ExpandMode) -> F {
404    // Cheap serial pass: one descriptor per active molecule (pure index
405    // arithmetic, no trig), reusing the persistent workspace buffer.
406    let mut descs = std::mem::take(&mut sys.work.mol_descs);
407    fill_active_mol_descs(&mut descs, sys);
408
409    // Move the per-atom write targets out of `sys` so Phase A can share
410    // `&PackContext` immutably while each molecule writes its own disjoint slots
411    // through raw pointers (constraint reads — coor, restraints, iratom — stay on
412    // the shared borrow). Sound for the serial `iter` path too: it just runs the
413    // per-molecule closures sequentially.
414    let mut xcart = std::mem::take(&mut sys.xcart);
415    let mut gxcar = std::mem::take(&mut sys.work.gxcar);
416    let mut frest_atom = std::mem::take(&mut sys.frest_atom);
417    let scale = sys.scale;
418    let scale2 = sys.scale2;
419    let move_flag = sys.move_flag;
420    // Per-atom `frest_atom` bookkeeping is movebad-only; that path always runs
421    // serially, so it never races even though the parallel branch could write it.
422    let parallel = sys.parallel_pair_eval && !move_flag;
423
424    #[derive(Clone, Copy)]
425    struct Slots {
426        xcart: *mut [F; 3],
427        gxcar: *mut [F; 3],
428        frest_atom: *mut F,
429    }
430    // SAFETY: each molecule owns a disjoint, contiguous `icart` range, so no two
431    // tasks ever write the same slot.
432    unsafe impl Send for Slots {}
433    unsafe impl Sync for Slots {}
434    impl Slots {
435        // Accessors take `self` so a closure that calls them captures the whole
436        // (`Sync`) wrapper, not its bare `*mut` fields (which would make the
437        // closure non-`Sync` and break `par_iter`).
438        /// # Safety
439        /// `i` must be in bounds and owned solely by the calling task.
440        #[inline(always)]
441        unsafe fn xcart_at(self, i: usize) -> *mut [F; 3] {
442            unsafe { self.xcart.add(i) }
443        }
444        /// # Safety
445        /// `i` must be in bounds and owned solely by the calling task.
446        #[inline(always)]
447        unsafe fn gxcar_at<'a>(self, i: usize) -> &'a mut [F; 3] {
448            unsafe { &mut *self.gxcar.add(i) }
449        }
450        /// # Safety
451        /// `i` must be in bounds and owned solely by the calling task.
452        #[inline(always)]
453        unsafe fn frest_atom_at(self, i: usize) -> *mut F {
454            unsafe { self.frest_atom.add(i) }
455        }
456    }
457    let slots = Slots {
458        xcart: xcart.as_mut_ptr(),
459        gxcar: gxcar.as_mut_ptr(),
460        frest_atom: frest_atom.as_mut_ptr(),
461    };
462
463    // Run Phase A in an inner scope so the shared `&PackContext` borrow (held by
464    // the closure) is released before we move the buffers back into `sys`.
465    let (f_total, frest_max) = {
466        let sys_ro: &PackContext = sys;
467        // Phase A, per molecule — independent, so it runs under `iter`/`par_iter`.
468        let body = |&(itype, icart0, ilubar, ilugan): &(usize, usize, usize, usize)| -> (F, F) {
469            let (v1, v2, v3) = eulerrmat(x[ilugan], x[ilugan + 1], x[ilugan + 2]);
470            let xcm = [x[ilubar], x[ilubar + 1], x[ilubar + 2]];
471            let idbase = sys_ro.idfirst[itype];
472            let na = sys_ro.natoms[itype];
473            let mut f_local: F = 0.0;
474            let mut frest_local: F = 0.0;
475            for iatom in 0..na {
476                let icart = icart0 + iatom;
477                let pos = compcart(&xcm, &sys_ro.coor[idbase + iatom], &v1, &v2, &v3);
478                // SAFETY: `icart` is owned by this molecule alone.
479                unsafe {
480                    *slots.xcart_at(icart) = pos;
481                }
482                let start = sys_ro.iratom_offsets[icart];
483                let end = sys_ro.iratom_offsets[icart + 1];
484                if start == end {
485                    continue;
486                }
487                // Value (F / FG): same `.f` call order as the legacy serial loop.
488                if matches!(mode, ExpandMode::F | ExpandMode::FG) {
489                    let mut fplus = 0.0;
490                    for &irest in &sys_ro.iratom_data[start..end] {
491                        fplus += sys_ro.restraints[irest].f(&pos, scale, scale2);
492                    }
493                    f_local += fplus;
494                    if fplus > frest_local {
495                        frest_local = fplus;
496                    }
497                    if move_flag {
498                        // SAFETY: disjoint per-atom slot.
499                        unsafe {
500                            *slots.frest_atom_at(icart) += fplus;
501                        }
502                    }
503                }
504                // Gradient (G / FG): accumulate into this atom's own slot.
505                if matches!(mode, ExpandMode::G | ExpandMode::FG) {
506                    // SAFETY: disjoint slot, as above.
507                    let gc = unsafe { slots.gxcar_at(icart) };
508                    for &irest in &sys_ro.iratom_data[start..end] {
509                        let _ = sys_ro.restraints[irest].fg(&pos, scale, scale2, gc);
510                    }
511                }
512            }
513            (f_local, frest_local)
514        };
515        expand_reduce(&descs, &body, parallel)
516    };
517
518    // Restore the moved-out buffers before the (serial) cell-insertion phase.
519    sys.xcart = xcart;
520    sys.work.gxcar = gxcar;
521    sys.frest_atom = frest_atom;
522    if frest_max > sys.frest {
523        sys.frest = frest_max;
524    }
525
526    // Phase B: serial linked-cell insertion in the identical order, so the lists
527    // are bit-identical no matter how Phase A iterated.
528    if !sys.init1 {
529        for &(itype, icart0, _, _) in &descs {
530            let na = sys.natoms[itype];
531            for iatom in 0..na {
532                let icart = icart0 + iatom;
533                let pos = sys.xcart[icart];
534                insert_atom_in_cell(icart, &pos, sys);
535            }
536        }
537    }
538
539    sys.work.mol_descs = descs;
540    f_total
541}
542
543/// Run the independent per-molecule Phase-A `body` over `descs` and reduce to
544/// `(f_total, frest_max)`. This is the single place where the serial and
545/// parallel expand paths diverge: `par_iter` when `parallel`, plain `iter`
546/// otherwise — the only `iter`-vs-`par_iter` switch in the expand pass.
547#[cfg(feature = "rayon")]
548fn expand_reduce<B>(descs: &[(usize, usize, usize, usize)], body: &B, parallel: bool) -> (F, F)
549where
550    B: Fn(&(usize, usize, usize, usize)) -> (F, F) + Sync,
551{
552    use rayon::prelude::*;
553    if parallel {
554        descs
555            .par_iter()
556            .map(body)
557            .reduce(|| (0.0 as F, 0.0 as F), |a, b| (a.0 + b.0, a.1.max(b.1)))
558    } else {
559        descs
560            .iter()
561            .map(body)
562            .fold((0.0 as F, 0.0 as F), |a, b| (a.0 + b.0, a.1.max(b.1)))
563    }
564}
565
566/// Non-rayon build: the expand pass is always serial.
567#[cfg(not(feature = "rayon"))]
568fn expand_reduce<B>(descs: &[(usize, usize, usize, usize)], body: &B, _parallel: bool) -> (F, F)
569where
570    B: Fn(&(usize, usize, usize, usize)) -> (F, F),
571{
572    descs
573        .iter()
574        .map(body)
575        .fold((0.0 as F, 0.0 as F), |a, b| (a.0 + b.0, a.1.max(b.1)))
576}
577
578/// Fill `descs` with one `(itype, icart0, ilubar, ilugan)` descriptor per
579/// **active** molecule (clearing any previous contents first), mirroring the
580/// expand/project loops' `comptype` skipping and `ilubar`/`ilugan` compaction.
581/// `ilugan` (the Euler-angle offset) starts at `ntotmol*3`, which is correct for
582/// both full and per-type compact `x` (`SwapState::set_type` shrinks `ntotmol`
583/// to the phase's molecule count).
584fn fill_active_mol_descs(descs: &mut Vec<(usize, usize, usize, usize)>, sys: &PackContext) {
585    descs.clear();
586    let mut ilubar = 0usize;
587    let mut ilugan = sys.ntotmol * 3;
588    let mut icart = 0usize;
589    for itype in 0..sys.ntype {
590        if !sys.comptype[itype] {
591            icart += sys.nmols[itype] * sys.natoms[itype];
592            continue;
593        }
594        for _ in 0..sys.nmols[itype] {
595            descs.push((itype, icart, ilubar, ilugan));
596            icart += sys.natoms[itype];
597            ilubar += 3;
598            ilugan += 3;
599        }
600    }
601}
602
603#[inline(always)]
604fn accumulate_constraint_value(icart: usize, pos: &[F; 3], sys: &mut PackContext) -> F {
605    let mut fplus = 0.0;
606    let start = sys.iratom_offsets[icart];
607    let end = sys.iratom_offsets[icart + 1];
608    for &irest in &sys.iratom_data[start..end] {
609        fplus += sys.restraints[irest].f(pos, sys.scale, sys.scale2);
610    }
611    if fplus > sys.frest {
612        sys.frest = fplus;
613    }
614    if sys.move_flag {
615        sys.frest_atom[icart] += fplus;
616    }
617    fplus
618}
619
620#[inline(always)]
621fn accumulate_constraint_gradient(icart: usize, pos: &[F; 3], sys: &mut PackContext) {
622    let start = sys.iratom_offsets[icart];
623    let end = sys.iratom_offsets[icart + 1];
624    let scale = sys.scale;
625    let scale2 = sys.scale2;
626    let gc = &mut sys.work.gxcar[icart];
627    for &irest in &sys.iratom_data[start..end] {
628        // fg returns the penalty value too; discard it — only gradient accumulation matters here
629        let _ = sys.restraints[irest].fg(pos, scale, scale2, gc);
630    }
631}
632
633#[inline]
634fn accumulate_constraint_gradient_from_xcart(sys: &mut PackContext) {
635    let mut icart = 0usize;
636
637    for itype in 0..sys.ntype {
638        if !sys.comptype[itype] {
639            icart += sys.nmols[itype] * sys.natoms[itype];
640            continue;
641        }
642
643        for _imol in 0..sys.nmols[itype] {
644            for _iatom in 0..sys.natoms[itype] {
645                let pos = sys.xcart[icart];
646                accumulate_constraint_gradient(icart, &pos, sys);
647                icart += 1;
648            }
649        }
650    }
651}
652
653/// F-only counterpart to [`accumulate_constraint_gradient_from_xcart`]. Walks
654/// `sys.xcart` (assumed current from a prior expansion) and re-applies each
655/// restraint's function value, returning the accumulated penalty. Used by the
656/// `compute_f` cache fast path when the Cartesian expansion can be skipped.
657#[inline]
658fn accumulate_constraint_values_from_xcart(sys: &mut PackContext) -> F {
659    let mut f = 0.0;
660    let mut icart = 0usize;
661
662    for itype in 0..sys.ntype {
663        if !sys.comptype[itype] {
664            icart += sys.nmols[itype] * sys.natoms[itype];
665            continue;
666        }
667
668        for _imol in 0..sys.nmols[itype] {
669            for _iatom in 0..sys.natoms[itype] {
670                let pos = sys.xcart[icart];
671                f += accumulate_constraint_value(icart, &pos, sys);
672                icart += 1;
673            }
674        }
675    }
676
677    f
678}
679
680/// Combined F+G counterpart to [`accumulate_constraint_gradient_from_xcart`].
681/// Used by the `compute_fg` cache fast path.
682#[inline]
683fn accumulate_constraint_values_and_gradients_from_xcart(sys: &mut PackContext) -> F {
684    let mut f = 0.0;
685    let mut icart = 0usize;
686
687    for itype in 0..sys.ntype {
688        if !sys.comptype[itype] {
689            icart += sys.nmols[itype] * sys.natoms[itype];
690            continue;
691        }
692
693        for _imol in 0..sys.nmols[itype] {
694            for _iatom in 0..sys.natoms[itype] {
695                let pos = sys.xcart[icart];
696                f += accumulate_constraint_value(icart, &pos, sys);
697                accumulate_constraint_gradient(icart, &pos, sys);
698                icart += 1;
699            }
700        }
701    }
702
703    f
704}
705
706/// Starting `icart` of free type `itype` — prefix sum of `nmols·natoms` over
707/// preceding types. Cheap (`ntype` is small) and used only by collective terms.
708#[inline]
709fn type_icart_start(sys: &PackContext, itype: usize) -> usize {
710    let mut start = 0usize;
711    for t in 0..itype {
712        start += sys.nmols[t] * sys.natoms[t];
713    }
714    start
715}
716
717/// Sum of all collective (group-level) restraint penalties. Each restraint is
718/// evaluated once over **all copies** of its type (gathered from `sys.xcart`).
719/// Read-only — used by the value-only `compute_f` paths.
720fn accumulate_collective_f(sys: &PackContext) -> F {
721    if sys.collective.is_empty() {
722        return 0.0;
723    }
724    let (scale, scale2) = (sys.scale, sys.scale2);
725    let mut total = 0.0;
726    for (itype, r) in &sys.collective {
727        let itype = *itype;
728        if !sys.comptype[itype] {
729            continue;
730        }
731        let start = type_icart_start(sys, itype);
732        let len = sys.nmols[itype] * sys.natoms[itype];
733        if len == 0 {
734            continue;
735        }
736        total += r.f(&sys.xcart[start..start + len], scale, scale2);
737    }
738    total
739}
740
741/// Value + gradient of all collective restraints. The coupled gradient is
742/// scattered into `sys.work.gxcar` so the subsequent `project_cartesian_gradient`
743/// maps it onto each molecule's COM/Euler DOF (monatomic species → identity on z).
744/// Returns the summed penalty value. Used by `compute_fg` (value) and
745/// `compute_g` (gradient; value discarded).
746fn accumulate_collective_fg(sys: &mut PackContext) -> F {
747    if sys.collective.is_empty() {
748        return 0.0;
749    }
750    let (scale, scale2) = (sys.scale, sys.scale2);
751    // Move the list out so `sys.xcart` (read) and `sys.work.gxcar` (write) are
752    // borrowed as disjoint fields without aliasing the whole `sys`.
753    let collective = std::mem::take(&mut sys.collective);
754    let mut total = 0.0;
755    for (itype, r) in &collective {
756        let itype = *itype;
757        if !sys.comptype[itype] {
758            continue;
759        }
760        let start = type_icart_start(sys, itype);
761        let len = sys.nmols[itype] * sys.natoms[itype];
762        if len == 0 {
763            continue;
764        }
765        let coords: Vec<[F; 3]> = sys.xcart[start..start + len].to_vec();
766        let mut grads = vec![[0.0 as F; 3]; len];
767        total += r.fg(&coords, scale, scale2, &mut grads);
768        for (k, g) in grads.iter().enumerate() {
769            let gc = &mut sys.work.gxcar[start + k];
770            gc[0] += g[0];
771            gc[1] += g[1];
772            gc[2] += g[2];
773        }
774    }
775    sys.collective = collective;
776    total
777}
778
779#[inline(always)]
780fn insert_atom_in_cell(icart: usize, pos: &[F; 3], sys: &mut PackContext) {
781    let cell = setcell(
782        pos,
783        &sys.pbc_min,
784        &sys.pbc_length,
785        &sys.cell_length,
786        &sys.ncells,
787        &sys.pbc_periodic,
788    );
789    let icell = index_cell(&cell, &sys.ncells);
790    sys.latomnext[icart] = sys.latomfirst[icell];
791    sys.latomfirst[icell] = icart as u32;
792
793    if sys.empty_cell[icell] {
794        sys.empty_cell[icell] = false;
795        sys.active_cells.push(icell);
796        sys.lcellnext[icell] = sys.lcellfirst;
797        sys.lcellfirst = icell as u32;
798    }
799
800    // `ibtype` / `ibmol` used to be written here on every eval; they are
801    // now set once in `Molpack::pack` and mirrored into `atom_props`.
802}
803
804#[inline(always)]
805fn accumulate_pair_f(sys: &mut PackContext) -> F {
806    #[cfg(feature = "rayon")]
807    if sys.parallel_pair_eval && !sys.move_flag {
808        let (f, fdist_max) = accumulate_pair_f_parallel(sys);
809        sys.fdist = sys.fdist.max(fdist_max);
810        return f;
811    }
812
813    let pbc = pbc_constants(sys);
814    let mut f = 0.0;
815    let mut fdist_local: F = 0.0;
816    let mut icell_id = sys.lcellfirst;
817    while icell_id != NONE_IDX {
818        let icell = icell_id as usize;
819        let neighbors = sys.neighbor_cells_f[icell];
820
821        let mut icart_id = sys.latomfirst[icell];
822        while icart_id != NONE_IDX {
823            let icart = icart_id as usize;
824            let (df, dfd) = fparc(icart, sys.latomnext[icart], sys, &pbc);
825            f += df;
826            if dfd > fdist_local {
827                fdist_local = dfd;
828            }
829            for &ncell in &neighbors {
830                let (df, dfd) = fparc(icart, sys.latomfirst[ncell], sys, &pbc);
831                f += df;
832                if dfd > fdist_local {
833                    fdist_local = dfd;
834                }
835            }
836
837            icart_id = sys.latomnext[icart];
838        }
839
840        icell_id = sys.lcellnext[icell];
841    }
842
843    if fdist_local > sys.fdist {
844        sys.fdist = fdist_local;
845    }
846    f
847}
848
849#[cfg(feature = "rayon")]
850fn accumulate_pair_f_parallel(sys: &PackContext) -> (F, F) {
851    let pbc = pbc_constants(sys);
852    sys.active_cells
853        .par_iter()
854        .map(|&icell| {
855            let mut f: F = 0.0;
856            let mut fdist_max: F = 0.0;
857            let neighbors = sys.neighbor_cells_f[icell];
858
859            let mut icart_id = sys.latomfirst[icell];
860            while icart_id != NONE_IDX {
861                let icart = icart_id as usize;
862                let (f_same, fdist_same) = fparc_stats(icart, sys.latomnext[icart], sys, &pbc);
863                f += f_same;
864                fdist_max = fdist_max.max(fdist_same);
865
866                for &ncell in &neighbors {
867                    let (f_neigh, fdist_neigh) =
868                        fparc_stats(icart, sys.latomfirst[ncell], sys, &pbc);
869                    f += f_neigh;
870                    fdist_max = fdist_max.max(fdist_neigh);
871                }
872
873                icart_id = sys.latomnext[icart];
874            }
875
876            (f, fdist_max)
877        })
878        .reduce(
879            || (0.0 as F, 0.0 as F),
880            |(f1, d1), (f2, d2)| (f1 + f2, d1.max(d2)),
881        )
882}
883
884#[inline(always)]
885fn accumulate_pair_g(sys: &mut PackContext) {
886    let pbc = pbc_constants(sys);
887    let mut icell_id = sys.lcellfirst;
888    while icell_id != NONE_IDX {
889        let icell = icell_id as usize;
890        let neighbors = sys.neighbor_cells_g[icell];
891
892        let mut icart_id = sys.latomfirst[icell];
893        while icart_id != NONE_IDX {
894            let icart = icart_id as usize;
895            gparc(icart, sys.latomnext[icart], sys, &pbc);
896            for &ncell in &neighbors {
897                gparc(icart, sys.latomfirst[ncell], sys, &pbc);
898            }
899
900            icart_id = sys.latomnext[icart];
901        }
902
903        icell_id = sys.lcellnext[icell];
904    }
905}
906
907#[inline(always)]
908fn accumulate_pair_fg(sys: &mut PackContext) -> F {
909    #[cfg(feature = "rayon")]
910    if sys.parallel_pair_eval && !sys.move_flag {
911        let (f, fdist_max) = accumulate_pair_fg_parallel(sys);
912        if fdist_max > sys.fdist {
913            sys.fdist = fdist_max;
914        }
915        return f;
916    }
917
918    let pbc = pbc_constants(sys);
919    let mut f = 0.0;
920    let mut fdist_local: F = 0.0;
921    let mut icell_id = sys.lcellfirst;
922    while icell_id != NONE_IDX {
923        let icell = icell_id as usize;
924        let neighbors = sys.neighbor_cells_g[icell];
925
926        let mut icart_id = sys.latomfirst[icell];
927        while icart_id != NONE_IDX {
928            let icart = icart_id as usize;
929            let (df, dfd) = fgparc(icart, sys.latomnext[icart], sys, &pbc);
930            f += df;
931            if dfd > fdist_local {
932                fdist_local = dfd;
933            }
934            for &ncell in &neighbors {
935                let (df, dfd) = fgparc(icart, sys.latomfirst[ncell], sys, &pbc);
936                f += df;
937                if dfd > fdist_local {
938                    fdist_local = dfd;
939                }
940            }
941
942            icart_id = sys.latomnext[icart];
943        }
944
945        icell_id = sys.lcellnext[icell];
946    }
947
948    if fdist_local > sys.fdist {
949        sys.fdist = fdist_local;
950    }
951    f
952}
953
954/// Base pointer into the per-worker scratch gradient buffer
955/// ([`crate::context::WorkBuffers::grad_partials`]). `*mut [F; 3]` is not
956/// `Send`/`Sync`; this wrapper asserts each worker only ever dereferences its
957/// own `[t*ntotat .. (t+1)*ntotat)` region, which is disjoint across the
958/// concurrently-running tasks (keyed by the unique rayon pool thread index).
959#[cfg(feature = "rayon")]
960#[derive(Clone, Copy)]
961struct PartialPtr {
962    base: *mut [F; 3],
963    ntotat: usize,
964}
965// SAFETY: see type doc — regions are keyed by the unique pool thread index, so
966// no two concurrently-running tasks alias the same slot.
967#[cfg(feature = "rayon")]
968unsafe impl Send for PartialPtr {}
969#[cfg(feature = "rayon")]
970unsafe impl Sync for PartialPtr {}
971#[cfg(feature = "rayon")]
972impl PartialPtr {
973    /// # Safety
974    /// `t` must be the calling worker's unique pool index and `i < ntotat`.
975    #[inline(always)]
976    unsafe fn slot<'a>(self, t: usize, i: usize) -> &'a mut [F; 3] {
977        unsafe { &mut *self.base.add(t * self.ntotat + i) }
978    }
979}
980
981/// Parallel counterpart to [`accumulate_pair_fg`]. rayon work-steals over
982/// `active_cells` using the **same 13-neighbor half-stencil the serial path
983/// walks** ([`PackContext::neighbor_cells_g`]), so each unordered pair is
984/// visited exactly once — none of the ~2× redundant distance work an
985/// atom-centric full-stencil pass incurs.
986///
987/// Race freedom *without* that redundancy comes from per-worker scratch buffers
988/// ([`crate::context::WorkBuffers::grad_partials`]): worker `t` accumulates
989/// every `gi += d` / `gj -= d` half-stencil write into its own private region
990/// `[t*ntotat .. (t+1)*ntotat)`, so no two concurrently-running tasks touch the
991/// same slot even though a half-stencil writes into neighbor cells. After the
992/// sweep the regions are reduced into `sys.work.gxcar` (which already holds the
993/// constraint gradient from `expand_molecules`). The zeroing and the reduction
994/// are themselves parallel and cost O(ntotat) wall-clock when `nthreads ≈
995/// cores`.
996///
997/// The contribution set is identical to the serial path (same pairs, same
998/// signs); only the summation order differs, within the tolerance the
999/// `parallel_equivalence` tests pin. Gated on `!move_flag` by the caller (the
1000/// per-atom `fdist_atom` bookkeeping is intentionally skipped).
1001#[cfg(feature = "rayon")]
1002fn accumulate_pair_fg_parallel(sys: &mut PackContext) -> (F, F) {
1003    let ntotat = sys.ntotat;
1004    let nthreads = rayon::current_num_threads().max(1);
1005
1006    // Per-worker scratch: take it out of `sys` so the context can be shared
1007    // immutably across tasks while each worker writes its own region through a
1008    // raw pointer. Sized to `nthreads * ntotat` and zeroed in parallel (each
1009    // worker clears its own region).
1010    let mut partials = std::mem::take(&mut sys.work.grad_partials);
1011    if partials.len() != nthreads * ntotat {
1012        partials.resize(nthreads * ntotat, [0.0; 3]);
1013    }
1014    partials
1015        .par_chunks_mut(ntotat.max(1))
1016        .for_each(|region| region.fill([0.0; 3]));
1017
1018    let pbc = pbc_constants(sys);
1019    let sys_ro: &PackContext = sys;
1020    let pptr = PartialPtr {
1021        base: partials.as_mut_ptr(),
1022        ntotat,
1023    };
1024
1025    let (f_total, fdist_max) = sys_ro
1026        .active_cells
1027        .par_iter()
1028        .map(|&icell| {
1029            // The worker's private region index. Inside a rayon parallel
1030            // closure this is always `Some(0..nthreads)`.
1031            let t = rayon::current_thread_index().unwrap_or(0);
1032            let neighbors = &sys_ro.neighbor_cells_g[icell];
1033            let mut f_local: F = 0.0;
1034            let mut fdist_local: F = 0.0;
1035            let mut icart_id = sys_ro.latomfirst[icell];
1036            while icart_id != NONE_IDX {
1037                let icart = icart_id as usize;
1038                // Same cell: forward atoms only (latomnext), each pair once.
1039                let (df, dfd) = fgparc_into(icart, sys_ro.latomnext[icart], sys_ro, pptr, t, &pbc);
1040                f_local += df;
1041                if dfd > fdist_local {
1042                    fdist_local = dfd;
1043                }
1044                // The 13 forward neighbor cells.
1045                for &ncell in neighbors {
1046                    let (df, dfd) =
1047                        fgparc_into(icart, sys_ro.latomfirst[ncell], sys_ro, pptr, t, &pbc);
1048                    f_local += df;
1049                    if dfd > fdist_local {
1050                        fdist_local = dfd;
1051                    }
1052                }
1053                icart_id = sys_ro.latomnext[icart];
1054            }
1055            (f_local, fdist_local)
1056        })
1057        .reduce(|| (0.0 as F, 0.0 as F), |a, b| (a.0 + b.0, a.1.max(b.1)));
1058
1059    // Reduce the per-worker regions into the constraint gradient already in
1060    // gxcar. Parallel over atom chunks; within a chunk each worker region is
1061    // read as a contiguous slice (cache-friendly).
1062    let mut grad = std::mem::take(&mut sys.work.gxcar);
1063    let chunk = ntotat.div_ceil(nthreads).max(1);
1064    {
1065        let partials_ref: &[[F; 3]] = &partials;
1066        grad.par_chunks_mut(chunk)
1067            .enumerate()
1068            .for_each(|(ci, gchunk)| {
1069                let start = ci * chunk;
1070                let len = gchunk.len();
1071                for t in 0..nthreads {
1072                    let base = t * ntotat + start;
1073                    let region = &partials_ref[base..base + len];
1074                    for (g, p) in gchunk.iter_mut().zip(region) {
1075                        g[0] += p[0];
1076                        g[1] += p[1];
1077                        g[2] += p[2];
1078                    }
1079                }
1080            });
1081    }
1082
1083    sys.work.gxcar = grad;
1084    sys.work.grad_partials = partials;
1085    (f_total, fdist_max)
1086}
1087
1088/// [`fgparc`] for the parallel half-stencil path: identical pair math and the
1089/// same `gi += d` / `gj -= d` writes (each unordered pair once), but the writes
1090/// land in worker `t`'s private scratch region via `pptr` instead of
1091/// `sys.work.gxcar`, and the `move_flag` per-atom bookkeeping is omitted (the
1092/// caller gates on `!move_flag`). Reads a shared `&PackContext`; returns
1093/// `(penalty_sum, fdist_max)`.
1094///
1095/// `gi` (slot `icart`) and `gj` (slot `jcart`) are always distinct atoms — the
1096/// same-molecule skip rules out `icart == jcart` — and each `&mut` is scoped so
1097/// the two never coexist, so the disjoint raw writes carry no aliasing hazard.
1098#[cfg(feature = "rayon")]
1099#[inline(always)]
1100fn fgparc_into(
1101    icart: usize,
1102    first_jcart: u32,
1103    sys: &PackContext,
1104    pptr: PartialPtr,
1105    t: usize,
1106    pbc: &PbcConstants,
1107) -> (F, F) {
1108    let mut result: F = 0.0;
1109    let mut local_fdist: F = 0.0;
1110    let mut jcart_id = first_jcart;
1111    let xi = sys.xcart[icart];
1112    let hot = AtomHotState::load(icart, sys);
1113
1114    while jcart_id != NONE_IDX {
1115        let jcart = jcart_id as usize;
1116        let next = sys.latomnext[jcart];
1117        if let Some(c) = pair_term::<true, true>(&hot, xi, jcart, sys, pbc) {
1118            result += c.energy;
1119            if c.overlap {
1120                // SAFETY: `t` is this worker's unique region; `icart`/`jcart`
1121                // are distinct in-bounds atoms; each `&mut` is dropped before
1122                // the next.
1123                {
1124                    let gi = unsafe { pptr.slot(t, icart) };
1125                    gi[0] += c.grad[0];
1126                    gi[1] += c.grad[1];
1127                    gi[2] += c.grad[2];
1128                }
1129                {
1130                    let gj = unsafe { pptr.slot(t, jcart) };
1131                    gj[0] -= c.grad[0];
1132                    gj[1] -= c.grad[1];
1133                    gj[2] -= c.grad[2];
1134                }
1135            }
1136            if c.violation > local_fdist {
1137                local_fdist = c.violation;
1138            }
1139        }
1140        jcart_id = next;
1141    }
1142
1143    (result, local_fdist)
1144}
1145
1146/// Atom-pair gradient accumulation into `sys.work.gxcar`.
1147/// Port of `gparc.f90`.
1148#[inline(always)]
1149fn gparc(icart: usize, first_jcart: u32, sys: &mut PackContext, pbc: &PbcConstants) {
1150    let mut jcart_id = first_jcart;
1151    let xi = sys.xcart[icart];
1152    let hot = AtomHotState::load(icart, sys);
1153
1154    while jcart_id != NONE_IDX {
1155        let jcart = jcart_id as usize;
1156        let next = sys.latomnext[jcart];
1157        if let Some(c) = pair_term::<true, false>(&hot, xi, jcart, sys, pbc)
1158            && c.overlap
1159        {
1160            let gi = &mut sys.work.gxcar[icart];
1161            gi[0] += c.grad[0];
1162            gi[1] += c.grad[1];
1163            gi[2] += c.grad[2];
1164            let gj = &mut sys.work.gxcar[jcart];
1165            gj[0] -= c.grad[0];
1166            gj[1] -= c.grad[1];
1167            gj[2] -= c.grad[2];
1168        }
1169        jcart_id = next;
1170    }
1171}
1172
1173/// Atom-pair function and gradient accumulation into `sys.work.gxcar`.
1174///
1175/// Returns `(penalty_sum, fdist_max)`. Caller reduces `fdist_max` locally
1176/// and writes `sys.fdist` once after the cell walk completes.
1177#[inline(always)]
1178fn fgparc(icart: usize, first_jcart: u32, sys: &mut PackContext, pbc: &PbcConstants) -> (F, F) {
1179    let mut result = 0.0;
1180    let mut local_fdist: F = 0.0;
1181    let mut jcart_id = first_jcart;
1182    let xi = sys.xcart[icart];
1183    let hot = AtomHotState::load(icart, sys);
1184    let move_flag = sys.move_flag;
1185
1186    while jcart_id != NONE_IDX {
1187        let jcart = jcart_id as usize;
1188        let next = sys.latomnext[jcart];
1189        if let Some(c) = pair_term::<true, true>(&hot, xi, jcart, sys, pbc) {
1190            result += c.energy;
1191            if c.overlap {
1192                let gi = &mut sys.work.gxcar[icart];
1193                gi[0] += c.grad[0];
1194                gi[1] += c.grad[1];
1195                gi[2] += c.grad[2];
1196                let gj = &mut sys.work.gxcar[jcart];
1197                gj[0] -= c.grad[0];
1198                gj[1] -= c.grad[1];
1199                gj[2] -= c.grad[2];
1200            }
1201            if c.violation > local_fdist {
1202                local_fdist = c.violation;
1203            }
1204            if move_flag {
1205                if c.violation > sys.fdist_atom[icart] {
1206                    sys.fdist_atom[icart] = c.violation;
1207                }
1208                if c.violation > sys.fdist_atom[jcart] {
1209                    sys.fdist_atom[jcart] = c.violation;
1210                }
1211            }
1212        }
1213        jcart_id = next;
1214    }
1215
1216    (result, local_fdist)
1217}
1218
1219#[cfg(feature = "rayon")]
1220#[inline(always)]
1221fn fparc_stats(icart: usize, first_jcart: u32, sys: &PackContext, pbc: &PbcConstants) -> (F, F) {
1222    let mut result: F = 0.0;
1223    let mut fdist_max: F = 0.0;
1224    let mut jcart_id = first_jcart;
1225    let xi = sys.xcart[icart];
1226    let hot = AtomHotState::load(icart, sys);
1227
1228    while jcart_id != NONE_IDX {
1229        let jcart = jcart_id as usize;
1230        let next = sys.latomnext[jcart];
1231        if let Some(c) = pair_term::<false, true>(&hot, xi, jcart, sys, pbc) {
1232            result += c.energy;
1233            if c.violation > fdist_max {
1234                fdist_max = c.violation;
1235            }
1236        }
1237        jcart_id = next;
1238    }
1239
1240    (result, fdist_max)
1241}
1242
1243/// Project each active molecule's atom Cartesian gradient onto its own 6 DOF
1244/// (COM + Euler), writing only its own **disjoint** `g[ilubar..]` / `g[ilugan..]`
1245/// slots. Because no slot is summed across molecules and each molecule's
1246/// accumulation order matches the legacy serial loop's (same atoms, same axis
1247/// order), the result is **bit-identical** whether Phase A iterates with `iter`
1248/// or `par_iter` — the only difference between the serial and parallel paths
1249/// (see [`project_for_each`]). The dominant cost is `eulerrmat_derivatives`
1250/// (trig) per molecule.
1251fn project_cartesian_gradient(x: &[F], sys: &mut PackContext, g: &mut [F]) {
1252    g.iter_mut().for_each(|v| *v = 0.0);
1253
1254    let mut descs = std::mem::take(&mut sys.work.mol_descs);
1255    fill_active_mol_descs(&mut descs, sys);
1256    let move_flag = sys.move_flag;
1257    let parallel = sys.parallel_pair_eval && !move_flag;
1258
1259    #[derive(Clone, Copy)]
1260    struct GPtr(*mut F);
1261    // SAFETY: per-molecule `ilubar`/`ilugan` ranges are disjoint, so no two tasks
1262    // write the same `g` index.
1263    unsafe impl Send for GPtr {}
1264    unsafe impl Sync for GPtr {}
1265    impl GPtr {
1266        // Takes `self` so a closure that calls it captures the whole (`Sync`)
1267        // wrapper, not the bare `*mut` field.
1268        /// # Safety
1269        /// `i` must index a slot owned solely by the calling task.
1270        #[inline(always)]
1271        unsafe fn write(self, i: usize, v: F) {
1272            unsafe {
1273                *self.0.add(i) = v;
1274            }
1275        }
1276    }
1277    let gptr = GPtr(g.as_mut_ptr());
1278
1279    // Inner scope so the shared `&PackContext` borrow ends before `sys` is reused.
1280    {
1281        let sys_ro: &PackContext = sys;
1282        let body = |&(itype, icart0, ilubar, ilugan): &(usize, usize, usize, usize)| {
1283            let beta = x[ilugan];
1284            let gama = x[ilugan + 1];
1285            let teta = x[ilugan + 2];
1286            let (dv1beta, dv1gama, dv1teta, dv2beta, dv2gama, dv2teta, dv3beta, dv3gama, dv3teta) =
1287                eulerrmat_derivatives(beta, gama, teta);
1288
1289            let idbase = sys_ro.idfirst[itype];
1290            let na = sys_ro.natoms[itype];
1291            let mut gcom = [0.0 as F; 3];
1292            let mut gang = [0.0 as F; 3];
1293            for iatom in 0..na {
1294                let gx = sys_ro.work.gxcar[icart0 + iatom];
1295                let cr = sys_ro.coor[idbase + iatom];
1296                for k in 0..3 {
1297                    gcom[k] += gx[k];
1298                }
1299                for k in 0..3 {
1300                    gang[0] +=
1301                        (cr[0] * dv1beta[k] + cr[1] * dv2beta[k] + cr[2] * dv3beta[k]) * gx[k];
1302                    gang[1] +=
1303                        (cr[0] * dv1gama[k] + cr[1] * dv2gama[k] + cr[2] * dv3gama[k]) * gx[k];
1304                    gang[2] +=
1305                        (cr[0] * dv1teta[k] + cr[1] * dv2teta[k] + cr[2] * dv3teta[k]) * gx[k];
1306                }
1307            }
1308            // SAFETY: `ilubar`/`ilugan` index this molecule's own disjoint slots.
1309            unsafe {
1310                for k in 0..3 {
1311                    gptr.write(ilubar + k, gcom[k]);
1312                    gptr.write(ilugan + k, gang[k]);
1313                }
1314            }
1315        };
1316        project_for_each(&descs, &body, parallel);
1317    }
1318
1319    sys.work.mol_descs = descs;
1320}
1321
1322/// Run the independent per-molecule projection `body` over `descs`. The single
1323/// place the serial and parallel projection paths diverge: `par_iter` when
1324/// `parallel`, plain `iter` otherwise. No reduction — each task writes disjoint
1325/// `g` slots.
1326#[cfg(feature = "rayon")]
1327fn project_for_each<B>(descs: &[(usize, usize, usize, usize)], body: &B, parallel: bool)
1328where
1329    B: Fn(&(usize, usize, usize, usize)) + Sync,
1330{
1331    use rayon::prelude::*;
1332    if parallel {
1333        descs.par_iter().for_each(body);
1334    } else {
1335        descs.iter().for_each(body);
1336    }
1337}
1338
1339/// Non-rayon build: projection is always serial.
1340#[cfg(not(feature = "rayon"))]
1341fn project_for_each<B>(descs: &[(usize, usize, usize, usize)], body: &B, _parallel: bool)
1342where
1343    B: Fn(&(usize, usize, usize, usize)),
1344{
1345    descs.iter().for_each(body);
1346}
1347
1348#[inline]
1349fn matches_cached_geometry(x: &[F], sys: &PackContext) -> bool {
1350    sys.work.matches_cached_geometry(
1351        x,
1352        &sys.comptype,
1353        sys.init1,
1354        sys.ncells,
1355        sys.cell_length,
1356        sys.pbc_min,
1357        sys.pbc_length,
1358        sys.pbc_periodic,
1359    )
1360}
1361
1362#[inline]
1363fn update_cached_geometry(x: &[F], sys: &mut PackContext) {
1364    sys.work.update_cached_geometry(
1365        x,
1366        &sys.comptype,
1367        sys.init1,
1368        sys.ncells,
1369        sys.cell_length,
1370        sys.pbc_min,
1371        sys.pbc_length,
1372        sys.pbc_periodic,
1373    );
1374}
1375
1376// ── Phase A.5 — Objective trait ────────────────────────────────────────────
1377//
1378// `Objective` is the abstraction the packer's GENCAN loop will talk to. At
1379// this checkpoint the trait is defined and implemented for `PackContext` but
1380// no call site has been rewired yet (`pgencan` still takes `&mut PackContext`
1381// directly). Phase A.6 swaps `pgencan`'s signature to `&mut dyn Objective`
1382// and is gated by an explicit extract-bench-loop commit so the dyn-dispatch
1383// cost lands with a measurement attached.
1384//
1385// The trait is intentionally shaped to match what the GENCAN loop reads and
1386// writes today — no speculative extra methods.
1387
1388/// Abstracts the packer's objective function so the optimizer can talk to
1389/// any `(f, g)` oracle, not just `PackContext`.
1390///
1391/// Implementors are responsible for:
1392/// - Returning `f`, worst-atom distance violation (`fdist`), worst-molecule
1393///   restraint violation (`frest`) in one pass, via [`evaluate`].
1394/// - Exposing the cumulative function / gradient counters that the caller
1395///   resets between phases and reads for logging.
1396///
1397/// The trait does **not** own bounds (`l`, `u`): those are problem-specific
1398/// and the caller (e.g. `pgencan::build_bounds`) builds them from the
1399/// concrete context it has in hand.
1400///
1401/// See spec `.claude/specs/molrs-pack-plugin-arch.md` §9 Phase A step 5 for
1402/// the rationale; Phase B will expose this trait publicly as the extension
1403/// hook for custom objectives.
1404///
1405/// [`evaluate`]: Self::evaluate
1406pub trait Objective {
1407    /// Unified evaluation entry point. `mode` selects between `f` only,
1408    /// gradient only, and both. When a gradient is requested, `gradient`
1409    /// must be `Some(buf)` with `buf.len() == x.len()`; when it is not,
1410    /// `gradient` is ignored.
1411    ///
1412    /// On return, the implementor's internal `fdist` / `frest` state
1413    /// reflects this call (so a subsequent `self.fdist()` / `self.frest()`
1414    /// returns the same values as the `EvalOutput`'s `fdist_max` /
1415    /// `frest_max`).
1416    fn evaluate(&mut self, x: &[F], mode: EvalMode, gradient: Option<&mut [F]>) -> EvalOutput;
1417
1418    /// Worst-atom distance violation from the most recent `evaluate` call.
1419    fn fdist(&self) -> F;
1420
1421    /// Worst-molecule restraint violation from the most recent `evaluate`
1422    /// call.
1423    fn frest(&self) -> F;
1424
1425    /// Cumulative count of function-value evaluations since the last
1426    /// `reset_eval_counters` call.
1427    fn ncf(&self) -> usize;
1428
1429    /// Cumulative count of gradient evaluations since the last
1430    /// `reset_eval_counters` call.
1431    fn ncg(&self) -> usize;
1432
1433    /// Zero the function / gradient counters. GENCAN calls this at the
1434    /// start of each outer iteration.
1435    fn reset_eval_counters(&mut self);
1436
1437    /// Fill `l` and `u` (each of length `x.len()` at the `pgencan` entry)
1438    /// with per-variable bounds. The default implementation sets every
1439    /// variable to `[-1e20, +1e20]` (effectively unbounded); `PackContext`
1440    /// overrides it to add the Euler-angle bounds implied by
1441    /// `constrain_rotation`.
1442    ///
1443    /// Landed in A.6 so `pgencan` no longer needs `&mut PackContext` for
1444    /// anything beyond evaluation — bounds construction is now behind the
1445    /// trait too.
1446    fn bounds(&self, l: &mut [F], u: &mut [F]) {
1447        debug_assert_eq!(l.len(), u.len(), "bounds: l/u length mismatch");
1448        l.fill(-1.0e20);
1449        u.fill(1.0e20);
1450    }
1451}
1452
1453impl Objective for PackContext {
1454    #[inline]
1455    fn evaluate(&mut self, x: &[F], mode: EvalMode, gradient: Option<&mut [F]>) -> EvalOutput {
1456        PackContext::evaluate(self, x, mode, gradient)
1457    }
1458
1459    #[inline]
1460    fn fdist(&self) -> F {
1461        self.fdist
1462    }
1463
1464    #[inline]
1465    fn frest(&self) -> F {
1466        self.frest
1467    }
1468
1469    #[inline]
1470    fn ncf(&self) -> usize {
1471        PackContext::ncf(self)
1472    }
1473
1474    #[inline]
1475    fn ncg(&self) -> usize {
1476        PackContext::ncg(self)
1477    }
1478
1479    #[inline]
1480    fn reset_eval_counters(&mut self) {
1481        PackContext::reset_eval_counters(self);
1482    }
1483
1484    /// Port of `gencan::build_bounds` (pre-A.6). COM variables (first `n/2`)
1485    /// stay `[-1e20, +1e20]`; Euler variables (last `n/2`) inherit
1486    /// `rot_bound` when `constrain_rot` is set for the owning type /
1487    /// molecule / axis.
1488    fn bounds(&self, l: &mut [F], u: &mut [F]) {
1489        debug_assert_eq!(l.len(), u.len(), "bounds: l/u length mismatch");
1490        let n = l.len();
1491        l.fill(-1.0e20);
1492        u.fill(1.0e20);
1493        let mut i = n / 2;
1494        for itype in 0..self.ntype {
1495            if !self.comptype[itype] {
1496                continue;
1497            }
1498            for _imol in 0..self.nmols[itype] {
1499                for axis in 0..3 {
1500                    if self.constrain_rot[itype][axis] {
1501                        let center = self.rot_bound[itype][axis][0];
1502                        let half_width = self.rot_bound[itype][axis][1].abs();
1503                        l[i] = center - half_width;
1504                        u[i] = center + half_width;
1505                    }
1506                    i += 1;
1507                }
1508            }
1509        }
1510        debug_assert_eq!(i, n);
1511    }
1512}
1513
1514#[cfg(test)]
1515mod objective_trait_tests {
1516    use super::*;
1517    use crate::PackContext;
1518
1519    /// Pins `<PackContext as Objective>::evaluate` against the inherent
1520    /// `PackContext::evaluate`: calling through a `&mut dyn Objective` must
1521    /// return byte-identical `EvalOutput` and leave identical `fdist` /
1522    /// `frest` state when fed the same empty-molecule context.
1523    ///
1524    /// With `ntotmol=0`, `x` is empty; `evaluate(FOnly)` runs the full
1525    /// constraints-container dispatch on empty state and returns
1526    /// `(0, 0, 0)` deterministically. The test still exercises the trait
1527    /// dispatch path so a future behavioural divergence (e.g. the impl
1528    /// forwarding to the wrong method) would fail loudly.
1529    #[test]
1530    fn dyn_objective_matches_inherent_evaluate() {
1531        fn build(ntotat: usize) -> PackContext {
1532            let mut sys = PackContext::new(ntotat, 0, 0);
1533            sys.radius.fill(0.75);
1534            sys.radius_ini.fill(1.5);
1535            sys.work.radiuswork.resize(ntotat, 0.0);
1536            sys.sync_atom_props();
1537            sys
1538        }
1539        let x: Vec<F> = Vec::new();
1540
1541        let mut via_inherent = build(4);
1542        let out_inherent = PackContext::evaluate(&mut via_inherent, &x, EvalMode::FOnly, None);
1543
1544        let mut via_trait_owner = build(4);
1545        let out_trait = {
1546            let obj: &mut dyn Objective = &mut via_trait_owner;
1547            obj.evaluate(&x, EvalMode::FOnly, None)
1548        };
1549
1550        assert_eq!(out_inherent.f_total, out_trait.f_total, "f_total mismatch");
1551        assert_eq!(
1552            out_inherent.fdist_max, out_trait.fdist_max,
1553            "fdist_max mismatch"
1554        );
1555        assert_eq!(
1556            out_inherent.frest_max, out_trait.frest_max,
1557            "frest_max mismatch"
1558        );
1559        assert_eq!(
1560            via_inherent.fdist, via_trait_owner.fdist,
1561            "post-call fdist drift"
1562        );
1563        assert_eq!(
1564            via_inherent.frest, via_trait_owner.frest,
1565            "post-call frest drift"
1566        );
1567
1568        // Counter + reset contract
1569        let obj: &mut dyn Objective = &mut via_trait_owner;
1570        assert_eq!(obj.ncf(), 1, "ncf should be 1 after one FOnly evaluate");
1571        assert_eq!(obj.ncg(), 0, "ncg should stay 0 under FOnly");
1572        obj.reset_eval_counters();
1573        assert_eq!(obj.ncf(), 0, "ncf should be 0 after reset");
1574        assert_eq!(obj.ncg(), 0, "ncg should be 0 after reset");
1575    }
1576}