Skip to main content

molpack/
packer.rs

1//! Main packing orchestration.
2//! Port of the outer loop in `app/packmol.f90`.
3
4use std::sync::Arc;
5
6use molrs::Element;
7use molrs::spatial::region::simbox::SimBox;
8use molrs::types::F;
9use ndarray::Array1;
10use rand::SeedableRng;
11use rand::rngs::SmallRng;
12
13use crate::constraints::EvalMode;
14use crate::context::PackContext;
15use crate::error::PackError;
16use crate::euler::{compcart, eulerfixed};
17use crate::gencan::{GencanParams, GencanWorkspace, pgencan};
18use crate::handler::{
19    Handler, LammpsLogHandler, MolpackLogLevel, PhaseInfo, PhaseReport, StepInfo,
20};
21use crate::initial::{SwapState, init_xcart_from_x, initial};
22use crate::movebad::{MoveBadConfig, movebad};
23use crate::numerics::objective_small_floor;
24use crate::relaxer::RelaxerRunner;
25use crate::restraint::AtomRestraint;
26use crate::target::{CenteringMode, Target};
27
28/// Result of a packing run.
29///
30/// The `frame` contains an "atoms" block with x, y, z, element, mol_id
31/// columns — moved from the packing context (zero-copy ownership transfer).
32#[derive(Debug, Clone)]
33pub struct PackResult {
34    /// Atoms frame with x, y, z (f32), element (String), mol_id (i64).
35    pub frame: molrs::Frame,
36    /// Maximum inter-molecular distance violation at termination.
37    pub fdist: F,
38    /// Maximum constraint violation at termination.
39    pub frest: F,
40    /// Whether the packing converged (`fdist < precision && frest < precision`).
41    pub converged: bool,
42}
43
44impl PackResult {
45    /// Extract atom positions as `Vec<[F; 3]>` (SoA→AoS conversion).
46    pub fn positions(&self) -> Vec<[F; 3]> {
47        let atoms = self.frame.get("atoms").expect("frame has no 'atoms' block");
48        let x = atoms.get_float("x").expect("no 'x' column");
49        let y = atoms.get_float("y").expect("no 'y' column");
50        let z = atoms.get_float("z").expect("no 'z' column");
51        x.iter()
52            .zip(y.iter())
53            .zip(z.iter())
54            .map(|((&xi, &yi), &zi)| [xi, yi, zi])
55            .collect()
56    }
57
58    /// Number of atoms in the result.
59    #[inline]
60    pub fn natoms(&self) -> usize {
61        self.frame.get("atoms").and_then(|b| b.nrows()).unwrap_or(0)
62    }
63}
64
65/// Default Packmol parameters
66const PRECISION: F = 0.01;
67// Packmol default from getinp.f90: discale = 1.1d0
68const DISCALE: F = 1.1;
69/// Fixed GENCAN inner iteration limit (Packmol default maxit = 20).
70const GENCAN_MAXIT: usize = 20;
71/// Packmol default sidemax (getinp.f90).
72const SIDEMAX: F = 1000.0;
73/// Packmol default movefrac.
74const MOVEFRAC: F = 0.05;
75/// Default minimum atom-atom distance tolerance (Packmol's `dism` default = 2.0 Å).
76/// Atom radii are set to `tolerance / 2` for all atoms, matching Packmol's
77/// `radius(i) = dism/2.d0` (packmol.f90 line 283).
78const DEFAULT_TOLERANCE: F = 2.0;
79/// Default RNG seed (Packmol's `seed` default = 1234567, getinp.f90 line 33).
80const DEFAULT_SEED: u64 = 1_234_567;
81
82/// The packer.
83pub struct Molpack {
84    handlers: Vec<Box<dyn Handler>>,
85    /// Global restraints — broadcast to every target at `pack()` time
86    /// (semantic equivalence to calling `target.with_restraint(r.clone())`
87    /// on every target; no separate global-storage code path).
88    global_restraints: Vec<Arc<dyn AtomRestraint>>,
89    precision: F,
90    discale: F,
91    /// Minimum atom-atom distance (Packmol's `tolerance`/`dism`). Default 2.0 Å.
92    /// Atom radii = `tolerance / 2`.
93    tolerance: F,
94    /// GENCAN inner iterations (`maxit` keyword).
95    inner_iterations: usize,
96    /// Initialization outer loops (`nloop0` keyword). `None` means Packmol default (20*ntype).
97    init_passes: Option<usize>,
98    /// Maximum system half-size used in initial restmol stage (`sidemax` keyword).
99    init_box_half_size: F,
100    /// Fraction of molecules perturbed when packing stalls (Packmol's `movefrac`).
101    perturb_fraction: F,
102    /// Randomize perturbation target selection (Packmol's `movebadrandom`).
103    random_perturb: bool,
104    /// Master switch for the stall-perturbation heuristic (inverts Packmol's
105    /// `disable_movebad`: `true` = perturb enabled, `false` = disabled).
106    perturb: bool,
107    /// Seed for the internal RNG. Default `1_234_567` (Packmol's default;
108    /// deterministic — the same seed reproduces the same packing).
109    seed: u64,
110    /// Run the pair-kernel reductions on rayon. Off by default: see
111    /// [`with_parallel_eval`][Self::with_parallel_eval].
112    parallel_eval: bool,
113    /// Global periodic-boundary box, as set by
114    /// [`with_periodic_box`][Self::with_periodic_box] (Packmol `pbc`
115    /// keyword). When both this and a restraint-declared PBC are
116    /// present, they must match exactly or `pack()` returns
117    /// [`PackError::ConflictingPeriodicBoxes`].
118    periodic_box: Option<PeriodicSpec>,
119    /// Built-in LAMMPS-style screen output detail.
120    log_level: MolpackLogLevel,
121    /// Print every N outer iterations when `log_level` includes progress.
122    log_frequency: usize,
123    /// Reject initial random placements that overlap a fixed molecule
124    /// (Packmol's `avoid_overlap`, default on). Critical when packing a dense
125    /// solvent around a large fixed solute: without it ~15-20% of the solvent
126    /// seeds inside the solute, inflating the initial overlap ~2× and stalling
127    /// GENCAN. Has no effect when there are no fixed molecules.
128    avoid_overlap: bool,
129}
130
131impl Default for Molpack {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl Molpack {
138    /// Create a packer with default settings and no handlers.
139    ///
140    /// All tuning knobs are set via `with_*` methods below; they all have
141    /// defensible defaults, so `Molpack::new().pack(&targets, max_loops)`
142    /// is a valid invocation. The one argument that has no default is
143    /// `max_loops` — it depends on system size and convergence difficulty,
144    /// so it lives on the terminal [`pack`][Self::pack] call, not on the
145    /// builder.
146    pub fn new() -> Self {
147        Self {
148            handlers: Vec::new(),
149            global_restraints: Vec::new(),
150            precision: PRECISION,
151            discale: DISCALE,
152            tolerance: DEFAULT_TOLERANCE,
153            inner_iterations: GENCAN_MAXIT,
154            init_passes: None,
155            init_box_half_size: SIDEMAX,
156            perturb_fraction: MOVEFRAC,
157            random_perturb: false,
158            perturb: true,
159            seed: DEFAULT_SEED,
160            parallel_eval: false,
161            periodic_box: None,
162            log_level: MolpackLogLevel::Quiet,
163            log_frequency: 1,
164            avoid_overlap: true,
165        }
166    }
167
168    /// Append a progress handler. Multiple handlers compose in call order.
169    pub fn with_handler(mut self, h: impl Handler + 'static) -> Self {
170        self.handlers.push(Box::new(h));
171        self
172    }
173
174    /// Append a **global** restraint — applied to every atom of every
175    /// target at `pack()` time.
176    ///
177    /// Semantic equivalence (scope law):
178    /// ```text
179    /// molpack.with_global_restraint(r)
180    ///   ≡ for each target: target.with_restraint(r.clone())
181    /// ```
182    ///
183    /// Implementation mirrors the equivalence — no separate "global
184    /// restraint" storage path in `PackContext`; the restraint is cloned
185    /// into each target's `molecule_restraints` when `pack()` is invoked.
186    pub fn with_global_restraint(mut self, r: impl AtomRestraint + 'static) -> Self {
187        self.global_restraints.push(Arc::new(r));
188        self
189    }
190
191    /// Convergence precision for `fdist` and `frest` (default `0.01`).
192    pub fn with_precision(mut self, p: F) -> Self {
193        self.precision = p;
194        self
195    }
196
197    /// Minimum atom-atom distance tolerance (default `2.0 Å`).
198    /// Atom radii are set to `tolerance / 2`.
199    pub fn with_tolerance(mut self, t: F) -> Self {
200        self.tolerance = t;
201        self
202    }
203
204    /// GENCAN inner iteration count (default `20`; Packmol `maxit`).
205    pub fn with_inner_iterations(mut self, n: usize) -> Self {
206        self.inner_iterations = n;
207        self
208    }
209
210    /// Whether to reject initial random placements that overlap a fixed
211    /// molecule (default `true`; Packmol's `avoid_overlap`). Leave on unless
212    /// you have a specific reason to allow solvent to seed inside a fixed
213    /// solute — disabling it can slow dense-solvation packing by 10× or more.
214    pub fn with_avoid_overlap(mut self, enabled: bool) -> Self {
215        self.avoid_overlap = enabled;
216        self
217    }
218
219    /// Initialization outer-loop passes (Packmol `nloop0`).
220    /// `0` restores the Packmol default of `20 * ntype`.
221    pub fn with_init_passes(mut self, n: usize) -> Self {
222        self.init_passes = if n == 0 { None } else { Some(n) };
223        self
224    }
225
226    /// Maximum half-size of the initial placement box (default `1000.0`;
227    /// Packmol `sidemax`).
228    pub fn with_init_box_half_size(mut self, f: F) -> Self {
229        self.init_box_half_size = f;
230        self
231    }
232
233    /// Declare a global periodic-boundary box (Packmol `pbc`). Every axis
234    /// is treated as periodic. When set, the packer's cell grid is built
235    /// from `max - min`, bypassing the fallback that derives a box from
236    /// post-Phase-1 atom positions — which can be ±`sidemax` wide when
237    /// the script has no spatial constraints and drives `ncells` to
238    /// 10⁸+ cells.
239    ///
240    /// If any restraint also declares a `periodic_box()`, the two must
241    /// match exactly (bounds + flags) or `pack()` returns
242    /// [`PackError::ConflictingPeriodicBoxes`].
243    pub fn with_periodic_box(mut self, min: [F; 3], max: [F; 3]) -> Self {
244        self.periodic_box = Some((min, max, [true; 3]));
245        self
246    }
247
248    /// Fraction of molecules re-sampled when packing stalls (default `0.05`;
249    /// Packmol `movefrac`).
250    pub fn with_perturb_fraction(mut self, f: F) -> Self {
251        self.perturb_fraction = f;
252        self
253    }
254
255    /// Randomize perturbation target selection (default `false`;
256    /// Packmol `movebadrandom`).
257    pub fn with_random_perturb(mut self, enabled: bool) -> Self {
258        self.random_perturb = enabled;
259        self
260    }
261
262    /// Enable the stall-perturbation heuristic (default `true`;
263    /// inverts Packmol's `disable_movebad`). Pass `false` to disable.
264    pub fn with_perturb(mut self, enabled: bool) -> Self {
265        self.perturb = enabled;
266        self
267    }
268
269    /// Seed for the internal RNG (default `1_234_567`, matching Packmol;
270    /// deterministic — the same seed reproduces the same packing).
271    pub fn with_seed(mut self, seed: u64) -> Self {
272        self.seed = seed;
273        self
274    }
275
276    /// Run the pair-kernel reductions on rayon worker threads (default
277    /// `false`).
278    ///
279    /// Parallelism is opt-in because the crossover where rayon pays off
280    /// is **workload-shaped, not size-shaped** — the per-call parallel
281    /// speedup measured on isolated `compute_fg` doesn't predict
282    /// end-to-end `Molpack::pack` wall clock (task-dispatch overhead
283    /// accumulates across thousands of calls per pack; the perturbation
284    /// pass skips the parallel path entirely; real workloads often
285    /// *regress* even when `active_cells.len()` clears any naive
286    /// threshold). The user knows their workload shape; the library
287    /// doesn't.
288    ///
289    /// Compile with `--features rayon` for this flag to have any
290    /// effect. Without the feature the pair kernel is serial
291    /// unconditionally and this setting is silently ignored.
292    pub fn with_parallel_eval(mut self, enabled: bool) -> Self {
293        self.parallel_eval = enabled;
294        self
295    }
296
297    /// Enable or disable built-in LAMMPS-style screen output.
298    ///
299    /// This is intentionally a builder-level concern: `pack()` returns the
300    /// packed [`molrs::Frame`], while timing, optimization diagnostics, and
301    /// system summaries are streamed to stderr when enabled.
302    pub fn with_lammps_output(mut self, enabled: bool) -> Self {
303        self.log_level = if enabled {
304            MolpackLogLevel::Progress
305        } else {
306            MolpackLogLevel::Quiet
307        };
308        self
309    }
310
311    /// Set built-in screen-log detail.
312    ///
313    /// `Quiet` is the default. `Summary` prints system/phase/final summaries,
314    /// `Progress` also prints thermo-style per-step lines, and `Verbose` adds
315    /// extra diagnostic columns.
316    pub fn with_log_level(mut self, level: MolpackLogLevel) -> Self {
317        self.log_level = level;
318        self
319    }
320
321    /// Print every `n` outer iterations for progress/verbose logs.
322    ///
323    /// Values below 1 are clamped to 1.
324    pub fn with_log_frequency(mut self, n: usize) -> Self {
325        self.log_frequency = n.max(1);
326        self
327    }
328
329    /// Run the packing.
330    ///
331    /// `max_loops` is the outer iteration budget; it is positional
332    /// because there is no defensible default (the right value depends
333    /// on system size and convergence difficulty). Every other knob
334    /// lives on the builder.
335    ///
336    /// Returns the packed [`molrs::Frame`]. Enable built-in screen logging
337    /// with [`with_lammps_output`][Self::with_lammps_output] or
338    /// [`with_log_level`][Self::with_log_level] for timing, optimization
339    /// diagnostics, and system summaries.
340    pub fn pack(
341        &mut self,
342        targets: &[Target],
343        max_loops: usize,
344    ) -> Result<molrs::Frame, PackError> {
345        Ok(self.pack_with_report(targets, max_loops)?.frame)
346    }
347
348    /// Run the packing and retain structured convergence diagnostics.
349    ///
350    /// This is mainly for tests, bindings, and advanced programmatic callers.
351    /// The primary user-facing API is [`pack`][Self::pack], which returns only
352    /// the packed frame.
353    pub fn pack_with_report(
354        &mut self,
355        targets: &[Target],
356        max_loops: usize,
357    ) -> Result<PackResult, PackError> {
358        if targets.is_empty() {
359            return Err(PackError::NoTargets);
360        }
361
362        for (i, t) in targets.iter().enumerate() {
363            if t.natoms() == 0 {
364                return Err(PackError::EmptyMolecule(i));
365            }
366        }
367
368        // Scope equivalence (spec §4): broadcast global restraints to each target.
369        // If none were added via `Molpack::add_restraint`, this is a no-op.
370        let broadcast_targets: Vec<Target>;
371        let targets: &[Target] = if self.global_restraints.is_empty() {
372            targets
373        } else {
374            broadcast_targets = targets
375                .iter()
376                .map(|t| {
377                    let mut t = t.clone();
378                    for r in &self.global_restraints {
379                        t.molecule_restraints.push(Arc::clone(r));
380                    }
381                    t
382                })
383                .collect();
384            &broadcast_targets
385        };
386        // Derive the system periodic box from two independent sources:
387        //   1. `Molpack::with_periodic_box` — global PBC (script `pbc`).
388        //   2. `AtomRestraint::periodic_box` — per-restraint declarations,
389        //      e.g. `InsideBoxRestraint` with any axis marked periodic.
390        //
391        // The two must not disagree; if both are present they must
392        // match exactly (same bounds, same per-axis flags). Validate
393        // the global PBC extent here since `derive_periodic_box`
394        // already does this for restraint-sourced boxes.
395        if let Some((min, max, _)) = self.periodic_box {
396            let length = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
397            if length.iter().any(|&v| v <= 0.0) {
398                return Err(PackError::InvalidPBCBox { min, max });
399            }
400        }
401        let pbc = match (self.periodic_box, derive_periodic_box(targets)?) {
402            (None, derived) => derived,
403            (Some(global), None) => Some(global),
404            (Some(global), Some(derived)) if global == derived => Some(global),
405            (Some(global), Some(derived)) => {
406                return Err(PackError::ConflictingPeriodicBoxes {
407                    first: global,
408                    second: derived,
409                });
410            }
411        };
412
413        let mut rng = SmallRng::seed_from_u64(self.seed);
414
415        // Split into free and fixed targets
416        let free_targets: Vec<&Target> = targets.iter().filter(|t| t.fixed_at.is_none()).collect();
417        let fixed_targets: Vec<&Target> = targets.iter().filter(|t| t.fixed_at.is_some()).collect();
418
419        let ntype = free_targets.len();
420        let ntype_with_fixed = ntype + fixed_targets.len();
421
422        let mut handlers = std::mem::take(&mut self.handlers);
423        if self.log_level.is_enabled() {
424            handlers.push(Box::new(LammpsLogHandler::new(
425                self.log_level,
426                self.log_frequency,
427                self.tolerance,
428                self.precision,
429                self.seed,
430                max_loops,
431                ntype_with_fixed,
432                pbc,
433            )));
434        }
435
436        // Count atoms
437        let ntotmol_free: usize = free_targets.iter().map(|t| t.count).sum();
438        let ntotat_free: usize = free_targets.iter().map(|t| t.count * t.natoms()).sum();
439        let ntotat_fixed: usize = fixed_targets.iter().map(|t| t.natoms()).sum();
440        let ntotat = ntotat_free + ntotat_fixed;
441
442        // Variable count: 3N COM + 3N Euler angles (only free molecules)
443        let n = 6 * ntotmol_free;
444
445        // Build PackContext
446        let mut sys = PackContext::new(ntotat, ntotmol_free, ntype);
447        sys.ntype_with_fixed = ntype_with_fixed;
448        sys.nfixedat = ntotat_fixed;
449        // comptype is initialized with size ntype; resize to include fixed types
450        sys.comptype = vec![true; ntype_with_fixed];
451
452        // Fill nmols, natoms, idfirst for free types
453        let mut cum_atoms = 0usize;
454        let mut coor = Vec::new();
455        let mut maxmove_per_type = vec![0usize; ntype];
456
457        sys.nmols = vec![0; ntype_with_fixed];
458        sys.natoms = vec![0; ntype_with_fixed];
459        sys.idfirst = vec![0; ntype_with_fixed];
460        sys.constrain_rot = vec![[false; 3]; ntype];
461        sys.rot_bound = vec![[[0.0; 2]; 3]; ntype];
462
463        for (itype, target) in free_targets.iter().enumerate() {
464            sys.nmols[itype] = target.count;
465            sys.natoms[itype] = target.natoms();
466            sys.idfirst[itype] = cum_atoms;
467            coor.extend_from_slice(reference_coords(target));
468            cum_atoms += target.natoms();
469
470            maxmove_per_type[itype] = target.perturb_budget.unwrap_or(target.count);
471            for k in 0..3 {
472                if let Some((center, half_width)) = target.rotation_bound[k] {
473                    sys.constrain_rot[itype][k] = true;
474                    sys.rot_bound[itype][k][0] = center.radians();
475                    sys.rot_bound[itype][k][1] = half_width.radians();
476                }
477            }
478        }
479
480        for (fi, target) in fixed_targets.iter().enumerate() {
481            let itype = ntype + fi;
482            sys.nmols[itype] = 1;
483            sys.natoms[itype] = target.natoms();
484            sys.idfirst[itype] = cum_atoms;
485            coor.extend_from_slice(reference_coords(target));
486            cum_atoms += target.natoms();
487        }
488        sys.coor = coor;
489
490        // Assign radii, element symbols, and per-atom (itype, imol) tags.
491        // Packmol uses `radius = tolerance/2` for ALL atoms (packmol.f90 line 283:
492        //   `radius(i) = dism/2.d0`), not VdW radii from the PDB file.
493        // `ibtype` / `ibmol` are derivable from position in the sequential
494        // atom layout, so we set them here once instead of having
495        // `insert_atom_in_cell` rewrite the same constants on every eval.
496        let atom_radius = self.tolerance / 2.0;
497        let mut icart = 0usize;
498        for (itype, target) in free_targets.iter().enumerate() {
499            for imol in 0..target.count {
500                for iatom in 0..target.natoms() {
501                    sys.radius[icart] = atom_radius;
502                    sys.radius_ini[icart] = atom_radius;
503                    sys.ibtype[icart] = itype;
504                    sys.ibmol[icart] = imol;
505                    sys.elements[icart] = Element::by_symbol(&target.elements[iatom]);
506                    icart += 1;
507                }
508            }
509        }
510        for (fi, target) in fixed_targets.iter().enumerate() {
511            let itype = ntype + fi;
512            for iatom in 0..target.natoms() {
513                sys.radius[icart] = atom_radius;
514                sys.radius_ini[icart] = atom_radius;
515                sys.ibtype[icart] = itype;
516                sys.ibmol[icart] = 0;
517                sys.elements[icart] = Element::by_symbol(&target.elements[iatom]);
518                icart += 1;
519            }
520        }
521
522        // Assign restraints: per-atom
523        let mut irest_pool = Vec::new();
524        let mut iratom_lists: Vec<Vec<usize>> = vec![Vec::new(); ntotat];
525        let mut icart = 0usize;
526        for target in free_targets.iter() {
527            for _imol in 0..target.count {
528                for iatom in 0..target.natoms() {
529                    // molecule-level restraints applied to all atoms
530                    for r in &target.molecule_restraints {
531                        let irest = irest_pool.len();
532                        irest_pool.push(std::sync::Arc::clone(r));
533                        iratom_lists[icart].push(irest);
534                    }
535                    // atom-subset restraints
536                    for (indices, restraint) in &target.atom_restraints {
537                        if indices.contains(&iatom) {
538                            let irest = irest_pool.len();
539                            irest_pool.push(std::sync::Arc::clone(restraint));
540                            iratom_lists[icart].push(irest);
541                        }
542                    }
543                    icart += 1;
544                }
545            }
546        }
547        // Fixed atoms: no restraints needed (they are placed directly)
548        sys.restraints = irest_pool;
549        sys.iratom_offsets.clear();
550        sys.iratom_offsets.reserve(ntotat + 1);
551        sys.iratom_offsets.push(0);
552        for atom_restraints in &iratom_lists {
553            let next = sys.iratom_offsets.last().copied().unwrap_or(0) + atom_restraints.len();
554            sys.iratom_offsets.push(next);
555        }
556        sys.iratom_data.clear();
557        sys.iratom_data
558            .reserve(sys.iratom_offsets.last().copied().unwrap_or(0));
559        for atom_restraints in iratom_lists {
560            sys.iratom_data.extend(atom_restraints);
561        }
562
563        // Group-level (collective) restraints: one entry per (free type, restraint).
564        // The free target's position is its 0-based type index `itype`.
565        sys.collective.clear();
566        for (itype, target) in free_targets.iter().enumerate() {
567            for r in &target.collective_restraints {
568                sys.collective.push((itype, std::sync::Arc::clone(r)));
569            }
570        }
571
572        // Handle fixed molecules: place them using eulerfixed
573        let free_atoms = ntotat_free;
574        let mut fixed_icart = free_atoms;
575        for target in fixed_targets.iter() {
576            let fp = target.fixed_at.as_ref().unwrap();
577            let (v1, v2, v3) = eulerfixed(
578                fp.orientation[0].radians(),
579                fp.orientation[1].radians(),
580                fp.orientation[2].radians(),
581            );
582            let ref_coords = reference_coords(target);
583            for ref_coord in ref_coords.iter().take(target.natoms()) {
584                let pos = compcart(&fp.position, ref_coord, &v1, &v2, &v3);
585                sys.xcart[fixed_icart] = pos;
586                sys.fixedatom[fixed_icart] = true;
587                fixed_icart += 1;
588            }
589        }
590        // Populate the AoS `atom_props` mirror from the individual per-atom
591        // Vecs now that every hot-loop field is finalized. `sync_atom_props`
592        // also refreshes the `any_fixed_atoms` / `any_short_radius`
593        // summary flags used by the hot-loop fast paths.
594        sys.sync_atom_props();
595        // Plumb the builder's opt-in parallel flag through to the
596        // objective kernels.
597        sys.parallel_pair_eval = self.parallel_eval;
598
599        // Write constant columns (element, mol_id) into the output frame.
600        // These don't change during optimization; positions are added at the end.
601        crate::frame::init_frame_constants(&mut sys);
602
603        // Initialize x vector
604        let mut x = vec![0.0 as F; n];
605
606        // Notify handlers immediately (before any heavy computation)
607        for h in handlers.iter_mut() {
608            h.on_start(ntotat, ntotmol_free);
609        }
610
611        // Run initialization
612        sys.ntotmol = ntotmol_free;
613        let init_passes = self.init_passes.unwrap_or(20 * ntype);
614        let movebad_cfg = MoveBadConfig {
615            movefrac: self.perturb_fraction,
616            maxmove_per_type: &maxmove_per_type,
617            movebadrandom: self.random_perturb,
618            gencan_maxit: self.inner_iterations,
619        };
620        initial(
621            &mut x,
622            &mut sys,
623            self.precision,
624            self.discale,
625            self.init_box_half_size,
626            init_passes,
627            pbc,
628            self.avoid_overlap,
629            &movebad_cfg,
630            &mut rng,
631        );
632
633        // Notify handlers: initialization complete, xcart is valid
634        for h in handlers.iter_mut() {
635            h.on_initialized(&sys);
636        }
637
638        // Build relaxer runners from target relaxers (RelaxerRunner carries mutable MC state).
639        // Each entry: (type_index, Vec<Box<dyn RelaxerRunner>>).
640        let mut relaxer_runners: Vec<(usize, Vec<Box<dyn RelaxerRunner>>)> = free_targets
641            .iter()
642            .enumerate()
643            .filter(|(_, t)| !t.relaxers.is_empty())
644            .map(|(i, t)| {
645                let base = sys.idfirst[i];
646                let na = sys.natoms[i];
647                let ref_slice = &sys.coor[base..base + na];
648                // Pass the molecule template (full topology) so a force-field
649                // relaxer can compile its potential; `None` for coord-only targets.
650                let frame = t.template.as_ref();
651                let runners = t
652                    .relaxers
653                    .iter()
654                    .map(|r| r.spawn(frame, ref_slice))
655                    .collect();
656                (i, runners)
657            })
658            .collect();
659
660        // max_loops controls the outer loop count, matching Packmol's `nloop` parameter.
661        let gencan_params = GencanParams {
662            maxit: self.inner_iterations,
663            maxfc: self.inner_iterations * 10,
664            iprint: 0,
665            ..Default::default()
666        };
667
668        let mut converged = false;
669        let mut gencan_workspace = GencanWorkspace::new();
670
671        // ── Main optimization loop ─────────────────────────────────────────────
672        //
673        // Matches Packmol's `app/packmol.f90` main loop exactly:
674        //   For each type (itype 1..ntype): swaptype(action=1) → pack → restore
675        //   Then all types (itype = ntype+1): pack with full x
676        //
677        // Per-type phases use a compact x (n = nmols[itype]*6) via SwapState,
678        // reducing GENCAN problem size by up to 60x vs full n.
679
680        // Save initial full x before phasing (Packmol swaptype action=0 at line 740)
681        let mut swap = SwapState::init(&x, &sys);
682
683        let total_phases = ntype + 1;
684
685        for phase in 0..=(ntype) {
686            let outcome = run_phase(
687                phase,
688                ntype,
689                ntype_with_fixed,
690                total_phases,
691                max_loops,
692                self.discale,
693                self.precision,
694                !self.perturb,
695                &movebad_cfg,
696                &gencan_params,
697                &mut sys,
698                &mut x,
699                &mut swap,
700                &mut relaxer_runners,
701                &mut handlers,
702                &mut gencan_workspace,
703                &mut rng,
704            );
705            match outcome {
706                PhaseOutcome::Continue => {}
707                PhaseOutcome::Converged => {
708                    converged = true;
709                    break;
710                }
711            }
712        }
713
714        if !converged {
715            log::warn!(
716                "  Pack did not fully converge (fdist={:.4e}, frest={:.4e})",
717                sys.fdist,
718                sys.frest
719            );
720        }
721
722        // Rebuild final xcart from x (all types active)
723        for itype in 0..ntype_with_fixed {
724            sys.comptype[itype] = true;
725        }
726        sys.ntotmol = ntotmol_free;
727        init_xcart_from_x(&x, &mut sys);
728
729        // Notify handlers of final state
730        for h in handlers.iter_mut() {
731            h.on_finish(&sys);
732        }
733
734        // Assemble the topology-complete result frame: replay each target's
735        // template onto the packed coordinates (shared Rust core, so every
736        // language binding gets an identical frame). Stamp the periodic box.
737        let xcart = std::mem::take(&mut sys.xcart);
738        let positions = positions_in_target_order(targets, &xcart, ntotat_free);
739        let mut frame = crate::assemble::assemble_frame(targets, &positions);
740        if let Some((min, max, flags)) = pbc {
741            let lengths = Array1::from_vec(vec![max[0] - min[0], max[1] - min[1], max[2] - min[2]]);
742            let origin = Array1::from_vec(min.to_vec());
743            if let Ok(simbox) = SimBox::ortho(lengths, origin, flags) {
744                frame.simbox = Some(simbox);
745            }
746        }
747        if self.log_level.is_enabled() {
748            handlers.pop();
749        }
750        self.handlers = handlers;
751
752        Ok(PackResult {
753            frame,
754            fdist: sys.fdist,
755            frest: sys.frest,
756            converged,
757        })
758    }
759}
760
761/// Resolved periodic-box spec: `(min, max, periodic_flags)`. Shared by
762/// `derive_periodic_box` and its callers.
763type PeriodicSpec = ([F; 3], [F; 3], [bool; 3]);
764
765/// Scan every restraint on every target for a `AtomRestraint::periodic_box`
766/// override. Returns `Ok(None)` if no restraint declares one, `Ok(Some(...))`
767/// if exactly one unique declaration exists (duplicates with identical
768/// bounds + flags are allowed — they come from `with_global_restraint`
769/// broadcast and from two targets sharing the same restraint object).
770/// Returns `Err(ConflictingPeriodicBoxes)` when two declarations disagree
771/// and `Err(InvalidPBCBox)` if the declared box has a non-positive extent
772/// on any axis.
773fn derive_periodic_box(targets: &[Target]) -> Result<Option<PeriodicSpec>, PackError> {
774    let mut found: Option<PeriodicSpec> = None;
775    for target in targets {
776        let restraints = target
777            .molecule_restraints
778            .iter()
779            .chain(target.atom_restraints.iter().map(|(_, r)| r));
780        for r in restraints {
781            if let Some(candidate) = r.periodic_box() {
782                let (min, max, _periodic) = candidate;
783                let length = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
784                if length.iter().any(|&v| v <= 0.0) {
785                    return Err(PackError::InvalidPBCBox { min, max });
786                }
787                match found {
788                    None => found = Some(candidate),
789                    Some(existing) if existing == candidate => {}
790                    Some(existing) => {
791                        return Err(PackError::ConflictingPeriodicBoxes {
792                            first: existing,
793                            second: candidate,
794                        });
795                    }
796                }
797            }
798        }
799    }
800    Ok(found)
801}
802
803fn reference_coords(target: &Target) -> &[[F; 3]] {
804    match target.centering {
805        CenteringMode::Center => &target.ref_coords,
806        CenteringMode::Off => &target.input_coords,
807        CenteringMode::Auto => {
808            if target.fixed_at.is_some() {
809                &target.input_coords
810            } else {
811                &target.ref_coords
812            }
813        }
814    }
815}
816
817/// Reorder packed coordinates from the packer's internal `xcart` layout
818/// (all free targets first, then all fixed targets) into the target-declared
819/// order that [`crate::assemble::assemble_frame`] expects (target-by-target,
820/// copy-by-copy, atom-by-atom).
821///
822/// Without this, a fixed target declared *before* a free target — e.g. a
823/// `fixed` protein in a solvation box — has its topology replayed onto the
824/// free atoms' coordinates, scrambling the rigid molecule. A no-op when no
825/// target is fixed (the free blocks are already in declared order).
826fn positions_in_target_order(
827    targets: &[Target],
828    xcart: &[[F; 3]],
829    n_free_atoms: usize,
830) -> Vec<[F; 3]> {
831    let mut out = Vec::with_capacity(xcart.len());
832    let mut free_cursor = 0usize;
833    let mut fixed_cursor = n_free_atoms;
834    for t in targets {
835        if t.fixed_at.is_some() {
836            let n = t.natoms();
837            out.extend_from_slice(&xcart[fixed_cursor..fixed_cursor + n]);
838            fixed_cursor += n;
839        } else {
840            let n = t.count * t.natoms();
841            out.extend_from_slice(&xcart[free_cursor..free_cursor + n]);
842            free_cursor += n;
843        }
844    }
845    out
846}
847
848/// Evaluate the packing objective once under **unscaled** radii (`radius_ini`),
849/// restoring the caller's `radius` values on return.
850///
851/// On return, `sys.fdist` / `sys.frest` / `sys.fdist_atom` / `sys.frest_atom`
852/// reflect the unscaled evaluation (the radius-dependent inner state); only
853/// `sys.radius` itself is rolled back to what it was on entry.
854///
855/// Returns `(f_total, fdist, frest)` from the unscaled evaluation — the exact
856/// triple the packer's main loop feeds to `flast` / `fimp` / handler `StepInfo`.
857///
858/// Pulled out of `pack()` in phase A.4.1 to de-duplicate three inline copies
859/// of the same swap-evaluate-restore dance (Packmol `computef` emulation).
860pub fn evaluate_unscaled(sys: &mut PackContext, xwork: &[F]) -> (F, F, F) {
861    sys.work.radiuswork.copy_from_slice(&sys.radius);
862    for i in 0..sys.ntotat {
863        sys.set_radius(i, sys.radius_ini[i]);
864    }
865    let f_total = sys.evaluate(xwork, EvalMode::FOnly, None).f_total;
866    let fdist = sys.fdist;
867    let frest = sys.frest;
868    for i in 0..sys.ntotat {
869        sys.set_radius(i, sys.work.radiuswork[i]);
870    }
871    (f_total, fdist, frest)
872}
873
874/// Outcome of one main-loop iteration inside a packing phase.
875///
876/// Pulled out of `pack()` in phase A.4.3 to isolate the ~140-line per-iteration
877/// body that runs movebad → relaxers → pgencan → radii schedule. `Continue`
878/// means "run the next iteration"; `Converged` means the convergence predicate
879/// fired inside this iteration; `EarlyStop` means a `Handler::should_stop()`
880/// returned true.
881#[derive(Debug, Clone, Copy, PartialEq, Eq)]
882pub enum IterOutcome {
883    Continue,
884    Converged,
885    EarlyStop,
886}
887
888/// Run one iteration of a packing phase's main loop.
889///
890/// Matches Packmol's per-iteration sequence in `app/packmol.f90` lines 815-948:
891///
892/// 1. `movebad` when `radscale == 1.0` and previous `fimp <= 10%` (unless
893///    disabled).
894/// 2. Per-target relaxer MC block.
895/// 3. `pgencan` on the working coordinate vector.
896/// 4. Unscaled-radii statistics (`fdist` / `frest` / `fimp`).
897/// 5. Handler `on_step` notification; early stop if any handler opts in.
898/// 6. Convergence check (`fdist < precision && frest < precision`).
899/// 7. Radii reduction schedule (only when `radscale > 1.0`).
900///
901/// The function takes each piece of mutable outer-loop state by `&mut` so the
902/// caller (the outer phase for-loop in `pack()`) retains ownership across
903/// iterations.
904#[allow(clippy::too_many_arguments)]
905pub fn run_iteration(
906    loop_idx: usize,
907    max_loops: usize,
908    is_all: bool,
909    phase: usize,
910    phase_info: PhaseInfo,
911    precision: F,
912    disable_movebad: bool,
913    movebad_cfg: &MoveBadConfig,
914    gencan_params: &GencanParams,
915    sys: &mut PackContext,
916    xwork: &mut [F],
917    swap: &mut SwapState,
918    flast: &mut F,
919    fimp_prev: &mut F,
920    radscale: &mut F,
921    relaxer_runners: &mut Vec<(usize, Vec<Box<dyn RelaxerRunner>>)>,
922    handlers: &mut [Box<dyn Handler>],
923    gencan_workspace: &mut GencanWorkspace,
924    rng: &mut SmallRng,
925) -> IterOutcome {
926    // movebad: Packmol triggers when radscale==1.0 AND fimp<=10.0
927    // (packmol.f90 line 815). fimp here is from the PREVIOUS iteration.
928    // After movebad, reset flast to the post-movebad f (Packmol line 821).
929    if !disable_movebad && *radscale == 1.0 && *fimp_prev <= 10.0 {
930        movebad(xwork, sys, precision, movebad_cfg, rng, gencan_workspace);
931        // Reset flast to the post-movebad f value so fimp is measured
932        // relative to movebad's starting point.
933        *flast = evaluate_unscaled(sys, xwork).0;
934    }
935
936    // Relaxer MC block: run per-target relaxers between movebad and pgencan.
937    // Each relaxer modifies the reference coords (coor) for its type.
938    for (itype, runners) in relaxer_runners.iter_mut() {
939        if !is_all && *itype != phase {
940            continue;
941        }
942
943        let base = sys.idfirst[*itype];
944        let na = sys.natoms[*itype];
945
946        for runner in runners.iter_mut() {
947            let saved: Vec<[F; 3]> = sys.coor[base..base + na].to_vec();
948            let f_before = sys.evaluate(xwork, EvalMode::FOnly, None).f_total;
949
950            let result = runner.on_iter(
951                &saved,
952                f_before,
953                &mut |trial: &[[F; 3]]| {
954                    sys.coor[base..base + na].copy_from_slice(trial);
955                    let f = sys.evaluate(xwork, EvalMode::FOnly, None).f_total;
956                    sys.coor[base..base + na].copy_from_slice(&saved);
957                    f
958                },
959                rng,
960            );
961
962            if let Some(new_coords) = result {
963                sys.coor[base..base + na].copy_from_slice(&new_coords);
964            }
965        }
966    }
967
968    // GENCAN on working x (compact for per-type, full for all-type)
969    sys.reset_eval_counters();
970    let res = pgencan(xwork, sys, gencan_params, precision, gencan_workspace);
971
972    // Save compact results back to swap (for restore later)
973    if !is_all {
974        swap.save_type(phase, xwork, sys);
975    }
976
977    // Compute statistics with unscaled radii
978    // (Packmol lines 833-841: radiuswork + computef + restore)
979    let (fx_unscaled, fdist, frest) = evaluate_unscaled(sys, xwork);
980
981    // fimp: percentage improvement in unscaled f from last iteration
982    // Packmol line 846: if(flast>0) fimp = -100*(fx-flast)/flast
983    let mut fimp = if *flast > 0.0 {
984        -100.0 * (fx_unscaled - *flast) / *flast
985    } else if fx_unscaled < objective_small_floor() {
986        100.0 // already converged
987    } else {
988        F::INFINITY
989    };
990    // Packmol lines 848-849: clamp to [-99.99, 99.99]
991    fimp = fimp.clamp(-99.99, 99.99);
992    *flast = fx_unscaled;
993    *fimp_prev = fimp;
994
995    if !handlers.is_empty() {
996        let relaxer_acceptance: Vec<(usize, F)> = relaxer_runners
997            .iter()
998            .flat_map(|(itype, runners)| runners.iter().map(move |r| (*itype, r.acceptance_rate())))
999            .collect();
1000
1001        let step_info = StepInfo {
1002            loop_idx,
1003            max_loops,
1004            phase: phase_info,
1005            fdist,
1006            frest,
1007            improvement_pct: fimp,
1008            radscale: *radscale,
1009            precision,
1010            relaxer_acceptance,
1011        };
1012        for h in handlers.iter_mut() {
1013            h.on_step(&step_info, sys);
1014        }
1015
1016        if handlers.iter().any(|h| h.should_stop()) {
1017            log::debug!("  Early stop requested at loop {loop_idx}");
1018            return IterOutcome::EarlyStop;
1019        }
1020    }
1021
1022    log::debug!(
1023        "    loop={loop_idx} f={:.4e} fdist={:.4e} frest={:.4e} radscale={:.4} fimp={:.2}% ncf={} ncg={} inform={}",
1024        res.f,
1025        fdist,
1026        frest,
1027        *radscale,
1028        fimp,
1029        sys.ncf(),
1030        sys.ncg(),
1031        res.inform
1032    );
1033
1034    // Check convergence
1035    if fdist < precision && frest < precision {
1036        log::debug!("  Converged at phase {phase} loop {loop_idx}");
1037        return IterOutcome::Converged;
1038    }
1039
1040    // Radii reduction schedule (Packmol lines 940-948):
1041    //   if (fdist<precision && fimp<10%) || fimp<2%: reduce radscale
1042    if *radscale > 1.0 && (fimp < 2.0 || (fdist < precision && fimp < 10.0)) {
1043        *radscale = (0.9 * *radscale).max(1.0);
1044        for i in 0..sys.ntotat {
1045            let new_r = sys.radius_ini[i].max(0.9 * sys.radius[i]);
1046            sys.set_radius(i, new_r);
1047        }
1048    }
1049
1050    IterOutcome::Continue
1051}
1052
1053/// Outcome of one outer-loop phase in `pack()`.
1054///
1055/// Pulled out of `pack()` in phase A.4.2 to isolate the outer per-phase scaffold
1056/// (handler phase-start notification, comptype reconfiguration, radii reset,
1057/// swap setup, pre-loop precision short-circuit, inner GENCAN loop, swap
1058/// restore / xwork-back copy). `Continue` means the outer phase loop should
1059/// proceed; `Converged` means the all-type phase converged and the outer loop
1060/// should break.
1061#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1062pub enum PhaseOutcome {
1063    Continue,
1064    Converged,
1065}
1066
1067/// Run one phase of the main packing loop (per-type or all-type).
1068///
1069/// Matches the outer `for phase in 0..=ntype` body of Packmol `app/packmol.f90`
1070/// lines 740-990 (the swaptype / comptype dance bracketing the GENCAN inner
1071/// loop). For a per-type phase (`phase < ntype`), `xwork` is a compact
1072/// `nmols[phase] * 6`-element slice produced by `SwapState::set_type`; for the
1073/// all-type phase (`phase == ntype`), `xwork` is a full `6 * ntotmol_free`
1074/// clone of `x`.
1075///
1076/// The function takes the outer-loop state (`sys`, `x`, `swap`,
1077/// `relaxer_runners`, `handlers`, `gencan_workspace`, `rng`) by `&mut` so that
1078/// state persists across phases, exactly as the inlined body did.
1079///
1080/// Returns `PhaseOutcome::Converged` **only** when the all-type phase
1081/// converges (either on its entry precision check or inside the inner loop);
1082/// every per-type phase returns `Continue` regardless of whether that type
1083/// converged on its own (Packmol lets the all-type phase decide).
1084#[allow(clippy::too_many_arguments)]
1085pub fn run_phase(
1086    phase: usize,
1087    ntype: usize,
1088    ntype_with_fixed: usize,
1089    total_phases: usize,
1090    max_loops: usize,
1091    discale: F,
1092    precision: F,
1093    disable_movebad: bool,
1094    movebad_cfg: &MoveBadConfig,
1095    gencan_params: &GencanParams,
1096    sys: &mut PackContext,
1097    x: &mut Vec<F>,
1098    swap: &mut SwapState,
1099    relaxer_runners: &mut Vec<(usize, Vec<Box<dyn RelaxerRunner>>)>,
1100    handlers: &mut [Box<dyn Handler>],
1101    gencan_workspace: &mut GencanWorkspace,
1102    rng: &mut SmallRng,
1103) -> PhaseOutcome {
1104    let is_all = phase == ntype;
1105
1106    let phase_info = PhaseInfo {
1107        phase,
1108        total_phases,
1109        molecule_type: if is_all { None } else { Some(phase) },
1110    };
1111
1112    // Reset handler state between phases (e.g. EarlyStopHandler stall counter)
1113    for h in handlers.iter_mut() {
1114        h.on_phase_start(&phase_info);
1115    }
1116
1117    // Set comptype for this phase
1118    for itype in 0..ntype_with_fixed {
1119        sys.comptype[itype] = if is_all {
1120            true
1121        } else {
1122            itype >= ntype || itype == phase
1123        };
1124    }
1125
1126    log::debug!(
1127        "  Packing phase {phase} ({})",
1128        if is_all {
1129            "all".to_string()
1130        } else {
1131            format!("type {phase}")
1132        }
1133    );
1134
1135    // Compact x to this type (action=1) or restore full x (all-type phase)
1136    // Packmol resets radscale = discale at the START of each phase.
1137    let mut radscale = discale;
1138    for icart in 0..sys.ntotat {
1139        sys.set_radius(icart, discale * sys.radius_ini[icart]);
1140    }
1141
1142    // Get working x vector (compact for per-type, full for all-type)
1143    let mut xwork: Vec<F> = if !is_all {
1144        // Compact: n = nmols[phase] * 6
1145        // Re-save current x (action=0) then compact (action=1)
1146        *swap = SwapState::init(x, sys);
1147        swap.set_type(phase, sys)
1148    } else {
1149        // All-type: restore full x (action=3), use it directly
1150        swap.restore(x, sys);
1151        x.clone()
1152    };
1153
1154    // Packmol checks whether the current approximation is already a solution
1155    // before entering the GENCAN loop for this phase (packmol.f90 lines 775-782).
1156    sys.evaluate(&xwork, EvalMode::FOnly, None);
1157    if sys.fdist < precision && sys.frest < precision {
1158        let report = PhaseReport {
1159            iterations: 0,
1160            fdist: sys.fdist,
1161            frest: sys.frest,
1162            converged: true,
1163        };
1164        for h in handlers.iter_mut() {
1165            h.on_phase_end(&phase_info, &report);
1166        }
1167        if !is_all {
1168            swap.save_type(phase, &xwork, sys);
1169            swap.restore(x, sys);
1170            return PhaseOutcome::Continue;
1171        } else {
1172            x.clone_from(&xwork);
1173            return PhaseOutcome::Converged;
1174        }
1175    }
1176
1177    // Initialize flast = unscaled f before gencanloop
1178    // (Packmol lines 796-803: compute bestf/flast with unscaled radii)
1179    let mut flast = evaluate_unscaled(sys, &xwork).0;
1180
1181    // fimp from previous iteration — used for movebad gate (Packmol packmol.f90 line 798).
1182    // Initialized to 1e99 so movebad is NOT called on the first iteration.
1183    let mut fimp_prev = F::INFINITY;
1184    let mut converged_inner = false;
1185    let mut iterations = 0usize;
1186
1187    for loop_idx in 0..max_loops {
1188        let outcome = run_iteration(
1189            loop_idx,
1190            max_loops,
1191            is_all,
1192            phase,
1193            phase_info,
1194            precision,
1195            disable_movebad,
1196            movebad_cfg,
1197            gencan_params,
1198            sys,
1199            &mut xwork,
1200            swap,
1201            &mut flast,
1202            &mut fimp_prev,
1203            &mut radscale,
1204            relaxer_runners,
1205            handlers,
1206            gencan_workspace,
1207            rng,
1208        );
1209        iterations += 1;
1210        match outcome {
1211            IterOutcome::Continue => {}
1212            IterOutcome::Converged => {
1213                converged_inner = true;
1214                break;
1215            }
1216            IterOutcome::EarlyStop => break,
1217        }
1218    }
1219
1220    let report = PhaseReport {
1221        iterations,
1222        fdist: sys.fdist,
1223        frest: sys.frest,
1224        converged: converged_inner,
1225    };
1226    for h in handlers.iter_mut() {
1227        h.on_phase_end(&phase_info, &report);
1228    }
1229
1230    // After per-type phase: save results + restore full x
1231    // After all-type phase: copy xwork back to x
1232    if !is_all {
1233        // save_type was called inside the loop; restore full x now.
1234        // Per-type convergence does NOT exit the outer phase loop.
1235        swap.restore(x, sys);
1236        PhaseOutcome::Continue
1237    } else {
1238        x.clone_from(&xwork);
1239        if converged_inner {
1240            PhaseOutcome::Converged
1241        } else {
1242            PhaseOutcome::Continue
1243        }
1244    }
1245}
1246
1247#[cfg(test)]
1248mod reorder_tests {
1249    use super::*;
1250
1251    #[test]
1252    fn fixed_target_declared_first_maps_to_its_xcart_block() {
1253        // Target A: fixed, 2 atoms, declared first.
1254        // Target B: free, 3 atoms x 2 copies = 6 atoms.
1255        // Internal xcart is free-first/fixed-last: [B(0..6) | A(6..8)].
1256        let a = Target::from_coords(&[[0.0; 3]; 2], &[1.0; 2], 1).fixed_at([0.0, 0.0, 0.0]);
1257        let b = Target::from_coords(&[[0.0; 3]; 3], &[1.0; 3], 2);
1258        let targets = vec![a, b];
1259        // x-coordinate encodes the xcart slot index, so we can read the mapping.
1260        let xcart: Vec<[F; 3]> = (0..8).map(|i| [i as F, 0.0, 0.0]).collect();
1261        let out = positions_in_target_order(&targets, &xcart, 6);
1262        let x: Vec<F> = out.iter().map(|p| p[0]).collect();
1263        // Declared order: A's fixed block (slots 6,7) then B's free block (0..6).
1264        assert_eq!(x, vec![6.0, 7.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
1265    }
1266
1267    #[test]
1268    fn no_fixed_targets_is_identity() {
1269        let a = Target::from_coords(&[[0.0; 3]; 2], &[1.0; 2], 1);
1270        let b = Target::from_coords(&[[0.0; 3]; 3], &[1.0; 3], 2);
1271        let targets = vec![a, b];
1272        let xcart: Vec<[F; 3]> = (0..8).map(|i| [i as F, 0.0, 0.0]).collect();
1273        let out = positions_in_target_order(&targets, &xcart, 8);
1274        let x: Vec<F> = out.iter().map(|p| p[0]).collect();
1275        assert_eq!(x, (0..8).map(|i| i as F).collect::<Vec<_>>());
1276    }
1277}