Skip to main content

molpack/context/
pack_context.rs

1//! Packing runtime context — mirrors Packmol `compute_data` behavior.
2
3use std::sync::Arc;
4
5use crate::cell::{cell_ind, icell_to_cell, index_cell};
6use crate::constraints::{Constraints, EvalMode, EvalOutput};
7use crate::restraint::{AtomRestraint, Restraint};
8use molrs::Element;
9use molrs::types::F;
10
11use super::model::ModelData;
12use super::state::{RuntimeState, RuntimeStateMut};
13use super::work_buffers::WorkBuffers;
14
15/// Index of a restraint assigned to a specific atom.
16pub type RestraintRef = usize;
17
18/// `flags` bit for a fixed-structure atom inside [`AtomProps`].
19pub const ATOM_FLAG_FIXED: u32 = 1 << 0;
20/// `flags` bit for an atom that participates in the optional short-radius
21/// penalty inside [`AtomProps`].
22pub const ATOM_FLAG_SHORT: u32 = 1 << 1;
23
24/// Sentinel for "no entry" in the linked-cell lists (`latomfirst`,
25/// `latomnext`, `latomfix`, `lcellfirst`, `lcellnext`). Using `u32::MAX`
26/// instead of `Option<usize>` shrinks each slot from 16 B (unoptimized
27/// `Option<usize>`) to 4 B, which dominates the pair-kernel cache
28/// footprint where these arrays are traversed per atom visit. Valid
29/// indices must satisfy `idx < NONE_IDX`; `PackContext::new` debug-asserts
30/// this for `ntotat` and `ncell_total`.
31pub const NONE_IDX: u32 = u32::MAX;
32
33/// Compact AoS view of every atom's **hot** pair-kernel inputs.
34///
35/// The pair kernel in `objective::fparc` / `gparc` / `fgparc` reads the
36/// molecule identity, radii, `fscale`, and a flag byte every time it
37/// looks at another atom. Scattering those across seven separate `Vec<_>`s
38/// turns each visit into seven independent cache-line fetches. Packing
39/// them into one 40-byte struct (8-byte aligned) collapses that to at
40/// most two cache-line fetches, with a matching load for `xcart[jcart]`
41/// kept separate because positions change every evaluation.
42///
43/// Layout (40 bytes, natural 8-byte alignment — two atoms fit 80 bytes,
44/// i.e. ~1.25 cache lines so most pair visits hit a single line):
45/// - `ibmol, ibtype` — 2 × u32 (8 bytes) — same-molecule skip.
46/// - `fscale, radius, radius_ini` — 3 × F (24 bytes) — distance kernel.
47/// - `flags` — u32 (4 bytes) — `ATOM_FLAG_FIXED`, `ATOM_FLAG_SHORT`.
48/// - private padding — u32 (4 bytes) to keep the struct multiple of 8.
49///
50/// Cold-path fields (`short_radius`, `short_radius_scale`) stay in
51/// separate `Vec<F>`s on `PackContext` so the common "no short radius"
52/// workload does not pay to load them.
53#[repr(C)]
54#[derive(Clone, Copy, Debug, Default)]
55pub struct AtomProps {
56    pub ibmol: u32,
57    pub ibtype: u32,
58    pub fscale: F,
59    pub radius: F,
60    pub radius_ini: F,
61    pub flags: u32,
62    /// 8-byte alignment padding. Private so the struct's size remains a
63    /// layout detail: flipping `F` or adding fields recomputes size at
64    /// compile time (see [`ATOM_PROPS_SIZE`] below) without churning the
65    /// public API.
66    _padding: u32,
67}
68
69/// Compile-time assertion that [`AtomProps`] stays 40 bytes (`F = f64`).
70/// Shrinking it again without noticing would regress the pair-kernel
71/// cache-line budget; growing it past 48 bytes would cost an extra
72/// fetch per atom visit. The check is always compiled (not
73/// `#[cfg(test)]`) so release builds catch layout drift too.
74pub const ATOM_PROPS_SIZE: usize = 40;
75const _ATOM_PROPS_IS_40_BYTES: [(); ATOM_PROPS_SIZE] = [(); std::mem::size_of::<AtomProps>()];
76
77/// Neighbor offsets used by `computef.f90` (13 forward neighbors).
78const NEIGHBOR_OFFSETS_F: [(isize, isize, isize); 13] = [
79    (1, 0, 0),
80    (0, 1, 0),
81    (0, 0, 1),
82    (1, -1, 0),
83    (1, 0, -1),
84    (0, 1, -1),
85    (0, 1, 1),
86    (1, 1, 0),
87    (1, 0, 1),
88    (1, -1, -1),
89    (1, -1, 1),
90    (1, 1, -1),
91    (1, 1, 1),
92];
93
94/// Neighbor offsets used by `computeg.f90` (13 forward neighbors, different order).
95const NEIGHBOR_OFFSETS_G: [(isize, isize, isize); 13] = [
96    (1, 0, 0),
97    (0, 1, 0),
98    (0, 0, 1),
99    (0, 1, 1),
100    (0, 1, -1),
101    (1, 1, 0),
102    (1, 0, 1),
103    (1, -1, 0),
104    (1, 0, -1),
105    (1, 1, 1),
106    (1, 1, -1),
107    (1, -1, 1),
108    (1, -1, -1),
109];
110
111/// Full runtime context for one packing execution.
112/// All arrays are 0-based; Fortran 1-based arrays are shifted by -1.
113pub struct PackContext {
114    // ---- Constraints facade ----
115    pub constraints: Constraints,
116
117    // ---- Atom Cartesian coordinates (updated every function evaluation) ----
118    /// Current Cartesian positions: `xcart[icart]` = `[x, y, z]`. Size: ntotat.
119    pub xcart: Vec<[F; 3]>,
120    /// Element per atom: `elements[icart]`. Size: ntotat. `None` means unknown/"X".
121    pub elements: Vec<Option<Element>>,
122
123    // ---- Reference (centered) coordinates ----
124    /// Reference coordinates `coor[idatom]` = `[x, y, z]`. Size: total atoms across all types.
125    pub coor: Vec<[F; 3]>,
126
127    // ---- Radii ----
128    /// Current radii (may be scaled): `radius[icart]`. Size: ntotat.
129    pub radius: Vec<F>,
130    /// Original (unscaled) radii: `radius_ini[icart]`. Size: ntotat.
131    pub radius_ini: Vec<F>,
132    /// Function scaling per atom: `fscale[icart]`. Size: ntotat.
133    pub fscale: Vec<F>,
134
135    // ---- Short radius (optional secondary penalty) ----
136    pub use_short_radius: Vec<bool>,
137    pub short_radius: Vec<F>,
138    pub short_radius_scale: Vec<F>,
139    /// Summary flag — `true` iff any atom has `use_short_radius` set. Lets
140    /// the objective hot loop skip the short-radius branch entirely for the
141    /// common case (no short-radius usage). Maintained incrementally via
142    /// setters + re-synced by [`Self::sync_atom_props`].
143    pub any_short_radius: bool,
144    /// Summary flag — `true` iff any atom is a fixed-structure atom. Lets
145    /// the objective hot loop skip the `fixedatom[i] && fixedatom[j]`
146    /// short-circuit when there are no fixed atoms. Maintained
147    /// incrementally via setters + re-synced by [`Self::sync_atom_props`].
148    pub any_fixed_atoms: bool,
149    /// Incremental counter driving `any_fixed_atoms`. Private because
150    /// the `any_*` flag is the observable contract; setters keep both
151    /// counters and flag consistent.
152    n_fixed_atoms: usize,
153    /// Incremental counter driving `any_short_radius`.
154    n_short_radius: usize,
155
156    /// AoS mirror of the frequently-read per-atom fields. Kept in sync
157    /// with the individual `Vec<_>`s by [`Self::sync_atom_props`] plus the
158    /// per-field setters (`set_radius`, `set_fscale`, `set_fixed_atom`,
159    /// `set_use_short_radius`, `set_ibmol`, `set_ibtype`). The objective
160    /// hot kernels read exclusively from here; callers mutating the
161    /// underlying `Vec<_>`s directly must call [`Self::sync_atom_props`]
162    /// before the next `evaluate()`, or the debug-build invariant in
163    /// [`Self::debug_assert_atom_props_sync`] will catch the drift.
164    pub atom_props: Vec<AtomProps>,
165
166    // ---- Objective function accumulators ----
167    /// Maximum inter-molecular distance violation (fdist in Fortran).
168    pub fdist: F,
169    /// Maximum constraint violation (frest in Fortran).
170    pub frest: F,
171    /// Per-atom distance violation (for movebad).
172    pub fdist_atom: Vec<F>,
173    /// Per-atom constraint violation (for movebad).
174    pub frest_atom: Vec<F>,
175
176    // ---- Molecule topology ----
177    /// Number of molecules per type: `nmols[itype]`. 0-based type index.
178    pub nmols: Vec<usize>,
179    /// Number of atoms per type: `natoms[itype]`. 0-based type index.
180    pub natoms: Vec<usize>,
181    /// First datum atom index (0-based) for each type: `idfirst[itype]`.
182    pub idfirst: Vec<usize>,
183    /// Total number of types (free).
184    pub ntype: usize,
185    /// Total number of types including fixed types.
186    pub ntype_with_fixed: usize,
187    /// Total number of free molecules.
188    pub ntotmol: usize,
189    /// Total number of atoms (free + fixed).
190    pub ntotat: usize,
191    /// Number of fixed atoms.
192    pub nfixedat: usize,
193
194    // ---- Rotation constraints (Packmol constrain_rotation) ----
195    /// Rotation constraint flags per free type in Euler variable order
196    /// [beta(y), gama(z), teta(x)].
197    pub constrain_rot: Vec<[bool; 3]>,
198    /// Rotation bounds per free type and Euler variable:
199    /// [center_rad, half_width_rad].
200    pub rot_bound: Vec<[[F; 2]; 3]>,
201
202    // ---- Restraints ----
203    /// All restraints pool: `restraints[irest]`.
204    pub restraints: Vec<Arc<dyn AtomRestraint>>,
205    /// CSR offsets for per-atom restraint indices:
206    /// restraints of atom `icart` are in `iratom_data[iratom_offsets[icart]..iratom_offsets[icart+1]]`.
207    pub iratom_offsets: Vec<usize>,
208    /// Flattened per-atom restraint indices.
209    pub iratom_data: Vec<RestraintRef>,
210    /// Group-level restraints, paired with the (0-based) type they act on:
211    /// `(itype, restraint)`. Evaluated once per group in the objective with the
212    /// coordinates of all copies of `itype`; the coupled gradient is scattered
213    /// back into `work.gxcar`. Empty in the common (no collective restraint) case.
214    pub collective: Vec<(usize, Arc<dyn Restraint>)>,
215
216    // ---- Cell list bookkeeping ----
217    /// Type index per atom: `ibtype[icart]` (0-based type index).
218    pub ibtype: Vec<usize>,
219    /// Molecule index within its type: `ibmol[icart]` (0-based).
220    pub ibmol: Vec<usize>,
221    /// Is this a fixed atom?
222    pub fixedatom: Vec<bool>,
223    /// Is this type being computed in the current iteration?
224    pub comptype: Vec<bool>,
225
226    // ---- Cell geometry ----
227    pub ncells: [usize; 3],
228    pub cell_length: [F; 3],
229    pub pbc_length: [F; 3],
230    pub pbc_min: [F; 3],
231    /// Per-axis periodicity flags. `pbc_periodic[k] == true` means axis
232    /// `k` wraps in the pair-kernel minimum image and the cell list;
233    /// `false` means the cell list clamps and no wrap is applied. Set
234    /// from restraints that override `AtomRestraint::periodic_box()`.
235    pub pbc_periodic: [bool; 3],
236
237    // ---- Linked cell lists ----
238    /// `latomfirst[icell]` = first atom index in cell, `NONE_IDX` if empty.
239    /// Stored as flat Vec indexed by `index_cell`.
240    pub latomfirst: Vec<u32>,
241    /// `latomnext[icart]` = next atom in the same cell (`NONE_IDX` = end).
242    pub latomnext: Vec<u32>,
243    /// Fixed atom list per cell (permanent), `NONE_IDX` if cell has no fixed atoms.
244    pub latomfix: Vec<u32>,
245    /// Occupied cell linked list: first cell (`NONE_IDX` if none occupied).
246    pub lcellfirst: u32,
247    /// `lcellnext[icell]` = next occupied cell (`NONE_IDX` = end).
248    pub lcellnext: Vec<u32>,
249    /// Is cell empty?
250    pub empty_cell: Vec<bool>,
251    /// Cells that contain fixed atoms and must be restored on every reset.
252    pub fixed_cells: Vec<usize>,
253    /// Cells touched during the previous objective/gradient evaluation.
254    pub active_cells: Vec<usize>,
255    /// Precomputed 13 forward-neighbor cell indices per cell for `compute_f`.
256    pub neighbor_cells_f: Vec<[usize; 13]>,
257    /// Precomputed 13 forward-neighbor cell indices per cell for `compute_g`.
258    /// The parallel gradient path ([`crate::objective`]) walks this same
259    /// half-stencil — each pair once — accumulating into per-worker scratch
260    /// buffers, so no full 26-neighbor list is needed.
261    pub neighbor_cells_g: Vec<[usize; 13]>,
262
263    // ---- State flags ----
264    /// If true, skip pair-distance computations (constraints only during init).
265    pub init1: bool,
266    /// If true, accumulate per-atom fdist/frest (movebad mode).
267    pub move_flag: bool,
268    /// Run the pair-kernel reductions (`accumulate_pair_f`,
269    /// `accumulate_pair_fg`) on rayon. Off by default — parallelism is
270    /// an explicit opt-in via [`Molpack::with_parallel_eval`](crate::Molpack::with_parallel_eval) because the
271    /// crossover is workload-shaped and can't be inferred reliably from
272    /// `active_cells.len()`. The flag is stored regardless of the
273    /// `rayon` feature so the `Molpack` API stays the same; when the
274    /// feature is off the field is read but the parallel path doesn't
275    /// exist and the serial branch runs unconditionally.
276    pub parallel_pair_eval: bool,
277
278    // ---- Algorithm parameters ----
279    pub scale: F,
280    pub scale2: F,
281
282    // ---- Bounding box ----
283    pub sizemin: [F; 3],
284    pub sizemax: [F; 3],
285
286    // ---- Maximum internal distances per type ----
287    pub dmax: Vec<F>,
288
289    // ---- Work buffers ----
290    pub work: WorkBuffers,
291
292    // ---- Output frame (owned, built incrementally) ----
293    /// Frame that accumulates constant columns (element, mol_id) during init
294    /// and receives position columns at the end of packing.
295    pub frame: molrs::Frame,
296
297    // ---- Debug: call counters (zeroed per pgencan call) ----
298    ncf: usize,
299    ncg: usize,
300}
301
302impl PackContext {
303    /// Allocate and zero-initialize all arrays.
304    pub fn new(ntotat: usize, ntotmol: usize, ntype: usize) -> Self {
305        let ncells = [1, 1, 1];
306        let ncell_total = ncells[0] * ncells[1] * ncells[2];
307        debug_assert!(
308            ntotat < NONE_IDX as usize,
309            "ntotat={ntotat} must fit in u32 (< NONE_IDX)"
310        );
311        Self {
312            constraints: Constraints,
313            xcart: vec![[0.0; 3]; ntotat],
314            elements: vec![None; ntotat],
315            coor: Vec::new(),
316            radius: vec![0.0; ntotat],
317            radius_ini: vec![0.0; ntotat],
318            fscale: vec![1.0; ntotat],
319            use_short_radius: vec![false; ntotat],
320            short_radius: vec![0.0; ntotat],
321            short_radius_scale: vec![0.0; ntotat],
322            any_short_radius: false,
323            any_fixed_atoms: false,
324            n_fixed_atoms: 0,
325            n_short_radius: 0,
326            atom_props: vec![AtomProps::default(); ntotat],
327            fdist: 0.0,
328            frest: 0.0,
329            fdist_atom: vec![0.0; ntotat],
330            frest_atom: vec![0.0; ntotat],
331            nmols: Vec::new(),
332            natoms: Vec::new(),
333            idfirst: Vec::new(),
334            ntype,
335            ntype_with_fixed: ntype,
336            ntotmol,
337            ntotat,
338            nfixedat: 0,
339            constrain_rot: vec![[false; 3]; ntype],
340            rot_bound: vec![[[0.0; 2]; 3]; ntype],
341            restraints: Vec::new(),
342            iratom_offsets: vec![0; ntotat + 1],
343            iratom_data: Vec::new(),
344            collective: Vec::new(),
345            ibtype: vec![0; ntotat],
346            ibmol: vec![0; ntotat],
347            fixedatom: vec![false; ntotat],
348            comptype: vec![true; ntype],
349            ncells,
350            cell_length: [1.0; 3],
351            pbc_length: [1.0; 3],
352            pbc_min: [0.0; 3],
353            pbc_periodic: [false; 3],
354            latomfirst: vec![NONE_IDX; ncell_total],
355            latomnext: vec![NONE_IDX; ntotat],
356            latomfix: vec![NONE_IDX; ncell_total],
357            lcellfirst: NONE_IDX,
358            lcellnext: vec![NONE_IDX; ncell_total],
359            empty_cell: vec![true; ncell_total],
360            fixed_cells: Vec::new(),
361            active_cells: Vec::new(),
362            neighbor_cells_f: vec![[0; 13]; ncell_total],
363            neighbor_cells_g: vec![[0; 13]; ncell_total],
364            init1: false,
365            move_flag: false,
366            parallel_pair_eval: false,
367            scale: 1.0,
368            scale2: crate::numerics::DEFAULT_SCALE2,
369            sizemin: [0.0; 3],
370            sizemax: [0.0; 3],
371            dmax: vec![0.0; ntype],
372            work: WorkBuffers::new(ntotat),
373            frame: molrs::Frame::new(),
374            ncf: 0,
375            ncg: 0,
376        }
377    }
378
379    /// Context view for mostly static model data.
380    #[inline]
381    pub fn model(&self) -> ModelData<'_> {
382        ModelData { ctx: self }
383    }
384
385    /// Read-only runtime state view.
386    #[inline]
387    pub fn runtime(&self) -> RuntimeState<'_> {
388        RuntimeState { ctx: self }
389    }
390
391    /// Mutable runtime state view.
392    #[inline]
393    pub fn runtime_mut(&mut self) -> RuntimeStateMut<'_> {
394        RuntimeStateMut { ctx: self }
395    }
396
397    /// Unified constraints evaluation entrypoint.
398    #[inline]
399    pub fn evaluate(&mut self, x: &[F], mode: EvalMode, gradient: Option<&mut [F]>) -> EvalOutput {
400        let constraints = self.constraints;
401        constraints.evaluate(x, self, mode, gradient)
402    }
403
404    /// Resize cell list arrays after ncells is set.
405    pub fn resize_cell_arrays(&mut self) {
406        let nc = self.ncells[0] * self.ncells[1] * self.ncells[2];
407        debug_assert!(
408            nc < NONE_IDX as usize,
409            "ncell_total={nc} must fit in u32 (< NONE_IDX)"
410        );
411        self.latomfirst = vec![NONE_IDX; nc];
412        self.latomfix = vec![NONE_IDX; nc];
413        self.lcellnext = vec![NONE_IDX; nc];
414        self.empty_cell = vec![true; nc];
415        self.fixed_cells.clear();
416        self.active_cells.clear();
417        self.neighbor_cells_f = vec![[0; 13]; nc];
418        self.neighbor_cells_g = vec![[0; 13]; nc];
419        self.rebuild_neighbor_cells();
420    }
421
422    /// Reset cell lists (called at start of each compute_f/compute_g).
423    /// Port of `resetcells.f90`.
424    pub fn resetcells(&mut self) {
425        self.lcellfirst = NONE_IDX;
426        for &icell in &self.active_cells {
427            self.latomfirst[icell] = NONE_IDX;
428            self.lcellnext[icell] = NONE_IDX;
429            self.empty_cell[icell] = true;
430        }
431        self.active_cells.clear();
432
433        for &icell in &self.fixed_cells {
434            self.latomfirst[icell] = self.latomfix[icell];
435            self.empty_cell[icell] = false;
436            self.lcellnext[icell] = self.lcellfirst;
437            self.lcellfirst = icell as u32;
438            self.active_cells.push(icell);
439        }
440
441        // Reset latomnext for free atoms only
442        let free_atoms = self.ntotat - self.nfixedat;
443        self.latomnext[..free_atoms].fill(NONE_IDX);
444    }
445
446    #[inline]
447    pub fn reset_eval_counters(&mut self) {
448        self.ncf = 0;
449        self.ncg = 0;
450    }
451
452    /// Rebuild `atom_props` from the individual per-atom `Vec<_>`s. Call
453    /// once after packer setup has populated every per-atom field, and
454    /// whenever a field has been mutated via the underlying `Vec<_>`
455    /// directly rather than through a setter.
456    ///
457    /// Also refreshes the summary flags (`any_fixed_atoms`,
458    /// `any_short_radius`) and their backing counters.
459    pub fn sync_atom_props(&mut self) {
460        let n = self.ntotat;
461        if self.atom_props.len() != n {
462            self.atom_props.resize(n, AtomProps::default());
463        }
464        let mut n_fixed = 0usize;
465        let mut n_short = 0usize;
466        for i in 0..n {
467            let fixed = self.fixedatom[i];
468            let use_short = self.use_short_radius[i];
469            if fixed {
470                n_fixed += 1;
471            }
472            if use_short {
473                n_short += 1;
474            }
475            let mut flags = 0u32;
476            if fixed {
477                flags |= ATOM_FLAG_FIXED;
478            }
479            if use_short {
480                flags |= ATOM_FLAG_SHORT;
481            }
482            self.atom_props[i] = AtomProps {
483                ibmol: self.ibmol[i] as u32,
484                ibtype: self.ibtype[i] as u32,
485                flags,
486                _padding: 0,
487                fscale: self.fscale[i],
488                radius: self.radius[i],
489                radius_ini: self.radius_ini[i],
490            };
491        }
492        self.n_fixed_atoms = n_fixed;
493        self.n_short_radius = n_short;
494        self.any_fixed_atoms = n_fixed > 0;
495        self.any_short_radius = n_short > 0;
496    }
497
498    /// Update atom `i`'s live radius on both the `Vec<F>` and the AoS
499    /// mirror.  Preferred over writing `sys.radius[i]` directly: the
500    /// hot-loop kernel reads `atom_props[i].radius`, so a raw write
501    /// would silently desynchronize.
502    #[inline]
503    pub fn set_radius(&mut self, i: usize, value: F) {
504        self.radius[i] = value;
505        if i < self.atom_props.len() {
506            self.atom_props[i].radius = value;
507        }
508    }
509
510    /// Update atom `i`'s live `fscale` on both storages. Used by the
511    /// scaling-phase paths that modulate per-atom weight.
512    #[inline]
513    pub fn set_fscale(&mut self, i: usize, value: F) {
514        self.fscale[i] = value;
515        if i < self.atom_props.len() {
516            self.atom_props[i].fscale = value;
517        }
518    }
519
520    /// Toggle atom `i`'s fixed-structure flag and keep the `atom_props`
521    /// mirror, the `any_fixed_atoms` summary flag, and the private
522    /// counter in lock-step.
523    #[inline]
524    pub fn set_fixed_atom(&mut self, i: usize, is_fixed: bool) {
525        let was_fixed = self.fixedatom[i];
526        if was_fixed == is_fixed {
527            return;
528        }
529        self.fixedatom[i] = is_fixed;
530        if i < self.atom_props.len() {
531            let flags = &mut self.atom_props[i].flags;
532            if is_fixed {
533                *flags |= ATOM_FLAG_FIXED;
534            } else {
535                *flags &= !ATOM_FLAG_FIXED;
536            }
537        }
538        if is_fixed {
539            self.n_fixed_atoms += 1;
540        } else {
541            self.n_fixed_atoms -= 1;
542        }
543        self.any_fixed_atoms = self.n_fixed_atoms > 0;
544    }
545
546    /// Toggle atom `i`'s `use_short_radius` flag, maintaining the mirror,
547    /// summary flag, and counter.
548    #[inline]
549    pub fn set_use_short_radius(&mut self, i: usize, use_short: bool) {
550        let was = self.use_short_radius[i];
551        if was == use_short {
552            return;
553        }
554        self.use_short_radius[i] = use_short;
555        if i < self.atom_props.len() {
556            let flags = &mut self.atom_props[i].flags;
557            if use_short {
558                *flags |= ATOM_FLAG_SHORT;
559            } else {
560                *flags &= !ATOM_FLAG_SHORT;
561            }
562        }
563        if use_short {
564            self.n_short_radius += 1;
565        } else {
566            self.n_short_radius -= 1;
567        }
568        self.any_short_radius = self.n_short_radius > 0;
569    }
570
571    /// Update `ibmol[i]` and the matching mirror field.
572    #[inline]
573    pub fn set_ibmol(&mut self, i: usize, value: usize) {
574        self.ibmol[i] = value;
575        if i < self.atom_props.len() {
576            self.atom_props[i].ibmol = value as u32;
577        }
578    }
579
580    /// Update `ibtype[i]` and the matching mirror field.
581    #[inline]
582    pub fn set_ibtype(&mut self, i: usize, value: usize) {
583        self.ibtype[i] = value;
584        if i < self.atom_props.len() {
585            self.atom_props[i].ibtype = value as u32;
586        }
587    }
588
589    /// Debug-only invariant: every `atom_props[i]` matches the state
590    /// derivable from the individual per-atom `Vec<_>`s, and the
591    /// summary counters / flags agree with reality.
592    ///
593    /// O(ntotat) per call in debug builds; compiled to a single
594    /// early-return in release builds (see the `cfg!(debug_assertions)`
595    /// gate below — with `#[inline(always)]` the release body DCE's).
596    /// The objective hot loop calls this at the entry of `compute_f` /
597    /// `compute_g` / `compute_fg` so direct-write drift fires at the
598    /// next evaluate instead of silently producing wrong energies.
599    #[inline(always)]
600    pub fn debug_assert_atom_props_sync(&self) {
601        if !cfg!(debug_assertions) {
602            return;
603        }
604        let n = self.ntotat;
605        assert_eq!(
606            self.atom_props.len(),
607            n,
608            "atom_props length {} != ntotat {} — call sync_atom_props after a resize",
609            self.atom_props.len(),
610            n
611        );
612        let mut n_fixed = 0usize;
613        let mut n_short = 0usize;
614        for i in 0..n {
615            let ap = &self.atom_props[i];
616            let expected_fixed = self.fixedatom[i];
617            let expected_short = self.use_short_radius[i];
618            let expected_flags = if expected_fixed { ATOM_FLAG_FIXED } else { 0 }
619                | if expected_short { ATOM_FLAG_SHORT } else { 0 };
620            if expected_fixed {
621                n_fixed += 1;
622            }
623            if expected_short {
624                n_short += 1;
625            }
626            assert_eq!(
627                ap.ibmol, self.ibmol[i] as u32,
628                "atom_props[{i}].ibmol drift: mirror={} vec={}",
629                ap.ibmol, self.ibmol[i]
630            );
631            assert_eq!(
632                ap.ibtype, self.ibtype[i] as u32,
633                "atom_props[{i}].ibtype drift"
634            );
635            assert_eq!(
636                ap.fscale, self.fscale[i],
637                "atom_props[{i}].fscale drift — did you write sys.fscale[{i}] directly?"
638            );
639            assert_eq!(
640                ap.radius, self.radius[i],
641                "atom_props[{i}].radius drift — use set_radius()"
642            );
643            assert_eq!(
644                ap.radius_ini, self.radius_ini[i],
645                "atom_props[{i}].radius_ini drift"
646            );
647            assert_eq!(
648                ap.flags, expected_flags,
649                "atom_props[{i}].flags drift — did you write sys.fixedatom/use_short_radius directly?"
650            );
651        }
652        assert_eq!(
653            self.n_fixed_atoms, n_fixed,
654            "n_fixed_atoms counter drift: stored={} derived={}",
655            self.n_fixed_atoms, n_fixed
656        );
657        assert_eq!(
658            self.n_short_radius, n_short,
659            "n_short_radius counter drift: stored={} derived={}",
660            self.n_short_radius, n_short
661        );
662        assert_eq!(self.any_fixed_atoms, n_fixed > 0);
663        assert_eq!(self.any_short_radius, n_short > 0);
664    }
665
666    #[inline]
667    pub fn increment_ncf(&mut self) {
668        self.ncf += 1;
669    }
670
671    #[inline]
672    pub fn increment_ncg(&mut self) {
673        self.ncg += 1;
674    }
675
676    #[inline]
677    pub fn ncf(&self) -> usize {
678        self.ncf
679    }
680
681    #[inline]
682    pub fn ncg(&self) -> usize {
683        self.ncg
684    }
685
686    fn rebuild_neighbor_cells(&mut self) {
687        let (nx, ny, nz) = (self.ncells[0], self.ncells[1], self.ncells[2]);
688        let nc = nx * ny * nz;
689        for icell in 0..nc {
690            let cell = icell_to_cell(icell, &self.ncells);
691            let (ci, cj, ck) = (cell[0], cell[1], cell[2]);
692
693            let mut nbs_f = [0usize; 13];
694            for (idx, &(di, dj, dk)) in NEIGHBOR_OFFSETS_F.iter().enumerate() {
695                let ncell = [
696                    cell_ind(ci as isize + di, nx),
697                    cell_ind(cj as isize + dj, ny),
698                    cell_ind(ck as isize + dk, nz),
699                ];
700                nbs_f[idx] = index_cell(&ncell, &self.ncells);
701            }
702            self.neighbor_cells_f[icell] = nbs_f;
703
704            let mut nbs_g = [0usize; 13];
705            for (idx, &(di, dj, dk)) in NEIGHBOR_OFFSETS_G.iter().enumerate() {
706                let ncell = [
707                    cell_ind(ci as isize + di, nx),
708                    cell_ind(cj as isize + dj, ny),
709                    cell_ind(ck as isize + dk, nz),
710                ];
711                nbs_g[idx] = index_cell(&ncell, &self.ncells);
712            }
713            self.neighbor_cells_g[icell] = nbs_g;
714        }
715    }
716}
717
718#[cfg(test)]
719mod atom_props_tests {
720    use super::*;
721
722    fn tiny_ctx(ntotat: usize) -> PackContext {
723        let mut sys = PackContext::new(ntotat, ntotat, 1);
724        for i in 0..ntotat {
725            sys.ibmol[i] = i;
726            sys.ibtype[i] = 0;
727            sys.radius[i] = 1.0;
728            sys.radius_ini[i] = 1.0;
729            sys.fscale[i] = 1.0;
730        }
731        sys.sync_atom_props();
732        sys
733    }
734
735    #[test]
736    fn atom_props_size_is_40_bytes_on_f64() {
737        // Runtime echo of the compile-time `_ATOM_PROPS_IS_40_BYTES`
738        // assertion — cheap and also readable as failing test output.
739        assert_eq!(std::mem::size_of::<AtomProps>(), 40);
740        assert_eq!(std::mem::align_of::<AtomProps>(), 8);
741        assert_eq!(ATOM_PROPS_SIZE, 40);
742    }
743
744    #[test]
745    fn sync_atom_props_populates_mirror_and_flags() {
746        let mut sys = PackContext::new(3, 3, 1);
747        sys.ibmol = vec![10, 20, 30];
748        sys.ibtype = vec![1, 2, 3];
749        sys.fscale = vec![0.5, 0.25, 0.125];
750        sys.radius = vec![1.1, 2.2, 3.3];
751        sys.radius_ini = vec![1.0, 2.0, 3.0];
752        sys.fixedatom = vec![false, true, false];
753        sys.use_short_radius = vec![false, false, true];
754        sys.sync_atom_props();
755
756        for i in 0..3 {
757            assert_eq!(sys.atom_props[i].ibmol, sys.ibmol[i] as u32);
758            assert_eq!(sys.atom_props[i].ibtype, sys.ibtype[i] as u32);
759            assert_eq!(sys.atom_props[i].fscale, sys.fscale[i]);
760            assert_eq!(sys.atom_props[i].radius, sys.radius[i]);
761            assert_eq!(sys.atom_props[i].radius_ini, sys.radius_ini[i]);
762        }
763        assert_eq!(sys.atom_props[0].flags, 0);
764        assert_eq!(sys.atom_props[1].flags, ATOM_FLAG_FIXED);
765        assert_eq!(sys.atom_props[2].flags, ATOM_FLAG_SHORT);
766        assert!(sys.any_fixed_atoms);
767        assert!(sys.any_short_radius);
768        sys.debug_assert_atom_props_sync();
769    }
770
771    #[test]
772    fn set_radius_keeps_mirror_in_sync() {
773        let mut sys = tiny_ctx(4);
774        sys.set_radius(2, 7.25);
775        assert_eq!(sys.radius[2], 7.25);
776        assert_eq!(sys.atom_props[2].radius, 7.25);
777        // Other atoms unchanged.
778        assert_eq!(sys.atom_props[0].radius, 1.0);
779        assert_eq!(sys.atom_props[3].radius, 1.0);
780        sys.debug_assert_atom_props_sync();
781    }
782
783    #[test]
784    fn set_fscale_keeps_mirror_in_sync() {
785        let mut sys = tiny_ctx(4);
786        sys.set_fscale(1, 0.125);
787        assert_eq!(sys.fscale[1], 0.125);
788        assert_eq!(sys.atom_props[1].fscale, 0.125);
789        sys.debug_assert_atom_props_sync();
790    }
791
792    #[test]
793    fn set_fixed_atom_updates_mirror_flag_counter_and_summary() {
794        let mut sys = tiny_ctx(3);
795        assert!(!sys.any_fixed_atoms);
796        assert_eq!(sys.n_fixed_atoms, 0);
797
798        sys.set_fixed_atom(1, true);
799        assert!(sys.fixedatom[1]);
800        assert_eq!(sys.atom_props[1].flags & ATOM_FLAG_FIXED, ATOM_FLAG_FIXED);
801        assert_eq!(sys.n_fixed_atoms, 1);
802        assert!(sys.any_fixed_atoms);
803        sys.debug_assert_atom_props_sync();
804
805        // Setting the same value again is a no-op — counter must not re-increment.
806        sys.set_fixed_atom(1, true);
807        assert_eq!(sys.n_fixed_atoms, 1);
808
809        sys.set_fixed_atom(0, true);
810        assert_eq!(sys.n_fixed_atoms, 2);
811        sys.set_fixed_atom(1, false);
812        assert_eq!(sys.n_fixed_atoms, 1);
813        assert!(sys.any_fixed_atoms);
814        sys.set_fixed_atom(0, false);
815        assert_eq!(sys.n_fixed_atoms, 0);
816        assert!(!sys.any_fixed_atoms);
817        sys.debug_assert_atom_props_sync();
818    }
819
820    #[test]
821    fn set_use_short_radius_updates_mirror_flag_counter_and_summary() {
822        let mut sys = tiny_ctx(3);
823        sys.set_use_short_radius(2, true);
824        assert!(sys.use_short_radius[2]);
825        assert_eq!(sys.atom_props[2].flags & ATOM_FLAG_SHORT, ATOM_FLAG_SHORT);
826        assert_eq!(sys.n_short_radius, 1);
827        assert!(sys.any_short_radius);
828        sys.debug_assert_atom_props_sync();
829
830        sys.set_use_short_radius(2, false);
831        assert_eq!(sys.n_short_radius, 0);
832        assert!(!sys.any_short_radius);
833        assert_eq!(sys.atom_props[2].flags & ATOM_FLAG_SHORT, 0);
834        sys.debug_assert_atom_props_sync();
835    }
836
837    #[test]
838    fn set_fixed_and_short_flags_coexist_on_same_atom() {
839        let mut sys = tiny_ctx(2);
840        sys.set_fixed_atom(0, true);
841        sys.set_use_short_radius(0, true);
842        assert_eq!(
843            sys.atom_props[0].flags,
844            ATOM_FLAG_FIXED | ATOM_FLAG_SHORT,
845            "both flags must combine without clobbering each other"
846        );
847        sys.set_fixed_atom(0, false);
848        assert_eq!(
849            sys.atom_props[0].flags, ATOM_FLAG_SHORT,
850            "clearing FIXED must leave SHORT intact"
851        );
852        sys.debug_assert_atom_props_sync();
853    }
854
855    #[test]
856    fn set_ibmol_and_set_ibtype_keep_mirror_in_sync() {
857        let mut sys = tiny_ctx(3);
858        sys.set_ibmol(1, 42);
859        assert_eq!(sys.ibmol[1], 42);
860        assert_eq!(sys.atom_props[1].ibmol, 42);
861        sys.set_ibtype(2, 7);
862        assert_eq!(sys.ibtype[2], 7);
863        assert_eq!(sys.atom_props[2].ibtype, 7);
864        sys.debug_assert_atom_props_sync();
865    }
866
867    /// Regression guard: the pre-Fix-1 state had two paths that wrote
868    /// `sys.fixedatom[i]` directly (Packmol-faithful but redundant).
869    /// Should one ever be reintroduced and fail to re-sync, the
870    /// debug-build invariant must catch it. This test flips the flag
871    /// directly on the `Vec<bool>` and confirms the assertion panics.
872    ///
873    /// Gated on `debug_assertions` because the underlying invariant is
874    /// a `debug_assert!` — release builds compile it out and the
875    /// `#[should_panic]` would never fire.
876    #[test]
877    #[cfg(debug_assertions)]
878    #[should_panic(expected = "atom_props")]
879    fn debug_invariant_catches_direct_fixedatom_write() {
880        let mut sys = tiny_ctx(2);
881        sys.fixedatom[0] = true; // bypass setter — simulates the bug class
882        sys.debug_assert_atom_props_sync();
883    }
884
885    #[test]
886    #[cfg(debug_assertions)]
887    #[should_panic(expected = "atom_props")]
888    fn debug_invariant_catches_direct_fscale_write() {
889        let mut sys = tiny_ctx(2);
890        sys.fscale[1] = 99.0;
891        sys.debug_assert_atom_props_sync();
892    }
893
894    /// Counter consistency under mixed operations: `sync_atom_props`
895    /// and the setters must produce identical `n_fixed_atoms` /
896    /// `n_short_radius` values. This guards against a setter
897    /// forgetting to increment/decrement.
898    #[test]
899    fn counters_match_sync_after_mixed_mutations() {
900        let mut sys = tiny_ctx(10);
901        for i in [0usize, 3, 7] {
902            sys.set_fixed_atom(i, true);
903        }
904        for i in [2usize, 5] {
905            sys.set_use_short_radius(i, true);
906        }
907        let pre_n_fixed = sys.n_fixed_atoms;
908        let pre_n_short = sys.n_short_radius;
909
910        // Re-sync from Vecs and compare — counters must match.
911        sys.sync_atom_props();
912        assert_eq!(sys.n_fixed_atoms, pre_n_fixed);
913        assert_eq!(sys.n_short_radius, pre_n_short);
914        assert_eq!(sys.n_fixed_atoms, 3);
915        assert_eq!(sys.n_short_radius, 2);
916    }
917}