Skip to main content

molrs/builder/
walk.rs

1//! Self-avoiding random walk (SARW) configuration, growth-strategy trait, and
2//! the multi-chain `generate` driver.
3//!
4//! Self-avoidance is decided entirely by an [`OccupancyGrid`](super::occupancy)
5//! — cell occupancy, never pairwise distance. Boundaries are per-axis: a
6//! periodic axis wraps a step to the opposite side; a non-periodic axis
7//! reflects the step elastically off the wall (its normal component flips,
8//! preserving the bond length). Output coordinates therefore always lie inside
9//! the box.
10
11use std::fmt;
12
13use ndarray::Array1;
14use rand::SeedableRng;
15use rand::rngs::StdRng;
16
17use super::occupancy::{OccupancyGrid, OccupancyMode};
18use crate::spatial::simbox::BoxError;
19use crate::spatial::simbox::SimBox;
20use crate::types::{F, F3, Pbc3};
21
22/// How many attempts a strategy gets to seed the first monomer of a chain
23/// before reporting a dead-end for that placement.
24pub(crate) const FIRST_POINT_TRIES: usize = 64;
25
26/// How many candidate steps the driver tries per monomer before backtracking.
27const STEP_TRIES: usize = 40;
28
29/// Errors returned by [`SelfAvoidingWalk::generate`].
30#[derive(Debug, Clone, PartialEq)]
31pub enum WalkError {
32    /// A configuration field is out of range (non-positive length/density, zero
33    /// chain length or chain count, or a box too small for the bond length).
34    InvalidConfig(String),
35    /// The simulation box could not be constructed.
36    BoxError(String),
37    /// The walk trapped itself: per-step retries, backtracking, and whole-chain
38    /// restarts were all exhausted. `monomer` is the furthest length reached.
39    DeadEnd {
40        /// Index of the chain that failed to complete.
41        chain: usize,
42        /// Furthest monomer count reached on that chain across all attempts.
43        monomer: usize,
44    },
45}
46
47impl fmt::Display for WalkError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            WalkError::InvalidConfig(m) => write!(f, "invalid SARW configuration: {m}"),
51            WalkError::BoxError(m) => write!(f, "box construction failed: {m}"),
52            WalkError::DeadEnd { chain, monomer } => write!(
53                f,
54                "self-avoiding walk dead-ended on chain {chain} after reaching {monomer} monomers"
55            ),
56        }
57    }
58}
59
60impl std::error::Error for WalkError {}
61
62impl From<BoxError> for WalkError {
63    fn from(e: BoxError) -> Self {
64        WalkError::BoxError(format!("{e:?}"))
65    }
66}
67
68/// Convert a fixed `[F; 3]` point to the public [`F3`] (`Array1<f64>`) form.
69pub(crate) fn to_f3(p: [F; 3]) -> F3 {
70    Array1::from_vec(vec![p[0], p[1], p[2]])
71}
72
73/// Apply per-axis boundary conditions to a raw candidate grown from `tip`.
74///
75/// Periodic axes wrap into `[0, a)`; non-periodic axes reflect the step's
76/// normal component off the wall (preserving `|candidate - tip|`). The result
77/// is always inside the box.
78pub(crate) fn apply_boundary(tip: [F; 3], mut cand: [F; 3], a: [F; 3], pbc: Pbc3) -> [F; 3] {
79    for ax in 0..3 {
80        if pbc[ax] {
81            cand[ax] = cand[ax].rem_euclid(a[ax]);
82        } else if cand[ax] < 0.0 || cand[ax] >= a[ax] {
83            // Elastic reflection: flip the step's normal component about the
84            // tip, which keeps the bond length exact.
85            cand[ax] = 2.0 * tip[ax] - cand[ax];
86            // Guard against a rare double overshoot (bond shorter than the box
87            // makes this unreachable in practice).
88            cand[ax] = cand[ax].clamp(0.0, a[ax] * (1.0 - 1e-12));
89        }
90    }
91    cand
92}
93
94/// A monomer-placement policy for the self-avoiding walk.
95///
96/// Implementors are plain structs injected into [`SelfAvoidingWalk`] as the
97/// generic `strategy` field — there are no factory functions. A strategy
98/// declares how occupancy is judged ([`occupancy_mode`](GrowthStrategy::occupancy_mode)),
99/// may round the box edge to its lattice
100/// ([`adjust_box_edge`](GrowthStrategy::adjust_box_edge)), and proposes raw
101/// candidate geometry; the driver applies boundaries and the occupancy test.
102pub trait GrowthStrategy {
103    /// The occupancy model used to reject overlapping placements.
104    fn occupancy_mode(&self, bond_length: F) -> OccupancyMode;
105
106    /// Optionally enlarge the cubic box edge (e.g. to a lattice-commensurate
107    /// multiple). Default: leave it unchanged.
108    fn adjust_box_edge(&self, edge: F, bond_length: F) -> F {
109        let _ = bond_length;
110        edge
111    }
112
113    /// Propose a position for the first monomer of a chain (already in-box).
114    fn propose_first(&self, simbox: &SimBox, bond_length: F, rng: &mut StdRng) -> [F; 3];
115
116    /// Propose a raw next position one `bond_length` from `tip` (the driver
117    /// applies boundary conditions and the occupancy test).
118    fn propose_step(&self, tip: [F; 3], bond_length: F, rng: &mut StdRng) -> [F; 3];
119}
120
121/// Configuration for a periodic/reflective, fixed-bond-length self-avoiding
122/// random walk that grows `n_chains` independent chains of `chain_length`
123/// monomers each.
124///
125/// Construct it as a struct literal and inject a [`GrowthStrategy`] via the
126/// `strategy` field, then call [`generate`](SelfAvoidingWalk::generate):
127///
128/// ```
129/// use molrs::builder::{OffLattice, SelfAvoidingWalk};
130///
131/// let walk = SelfAvoidingWalk {
132///     n_chains: 2,
133///     chain_length: 20,
134///     bond_length: 1.53,
135///     target_density: 0.05,
136///     pbc: [true, true, true],
137///     seed: 9062,
138///     strategy: OffLattice { excluded_radius: 1.0 },
139/// };
140/// let out = walk.generate().unwrap();
141/// assert_eq!(out.paths.len(), 2);
142/// ```
143///
144/// `target_density` is in **monomers per unit volume** — mass is out of scope,
145/// so the cubic box edge is `a = (n_chains * chain_length / target_density).cbrt()`
146/// (a lattice strategy may round it up to stay commensurate).
147pub struct SelfAvoidingWalk<S: GrowthStrategy> {
148    /// Number of independent chains to grow.
149    pub n_chains: usize,
150    /// Number of monomers per chain.
151    pub chain_length: usize,
152    /// Fixed distance between consecutive monomers.
153    pub bond_length: F,
154    /// Target number density (monomers per unit volume) used to size the box.
155    pub target_density: F,
156    /// Per-axis boundary flags: `true` = periodic (wrap), `false` = reflective.
157    pub pbc: Pbc3,
158    /// Seed for the deterministic RNG; equal seeds give identical paths.
159    pub seed: u64,
160    /// The monomer-placement policy (a struct implementing [`GrowthStrategy`]).
161    pub strategy: S,
162}
163
164/// The result of [`SelfAvoidingWalk::generate`]: one point list per chain plus
165/// the box that was used. No topology, chemistry, or IO.
166pub struct WalkOutput {
167    /// One inner vector per chain, each holding `chain_length` 3D points, all
168    /// inside the box (periodic axes wrapped, reflective axes reflected).
169    pub paths: Vec<Vec<F3>>,
170    /// The cubic periodic/reflective box the paths were grown in.
171    pub simbox: SimBox,
172}
173
174impl<S: GrowthStrategy> SelfAvoidingWalk<S> {
175    /// Grow all chains and return their paths plus the box used.
176    ///
177    /// Deterministic in `seed`. Returns [`WalkError::InvalidConfig`] for
178    /// out-of-range parameters, [`WalkError::BoxError`] if the box cannot be
179    /// built, and [`WalkError::DeadEnd`] if a chain cannot be completed within
180    /// the retry/backtrack/restart budget.
181    pub fn generate(&self) -> Result<WalkOutput, WalkError> {
182        if self.bond_length <= 0.0 {
183            return Err(WalkError::InvalidConfig("bond_length must be > 0".into()));
184        }
185        if self.target_density <= 0.0 {
186            return Err(WalkError::InvalidConfig(
187                "target_density must be > 0".into(),
188            ));
189        }
190        if self.chain_length == 0 {
191            return Err(WalkError::InvalidConfig("chain_length must be > 0".into()));
192        }
193        if self.n_chains == 0 {
194            return Err(WalkError::InvalidConfig("n_chains must be > 0".into()));
195        }
196
197        let n_total = self.n_chains * self.chain_length;
198        let raw_edge = (n_total as F / self.target_density).cbrt();
199        let edge = self.strategy.adjust_box_edge(raw_edge, self.bond_length);
200        // A bond must be shorter than half the box so wrapping/reflection keeps
201        // consecutive monomers exactly `bond_length` apart.
202        if edge <= 2.0 * self.bond_length {
203            return Err(WalkError::InvalidConfig(
204                "box edge too small for bond length; lower the density".into(),
205            ));
206        }
207        let simbox = SimBox::cube(edge, Array1::zeros(3), self.pbc)?;
208        let a = [edge, edge, edge];
209
210        let mode = self.strategy.occupancy_mode(self.bond_length);
211        let mut grid = OccupancyGrid::new(mode, &simbox, self.pbc);
212        let mut rng = StdRng::seed_from_u64(self.seed);
213        let mut paths: Vec<Vec<F3>> = Vec::with_capacity(self.n_chains);
214
215        let max_backtrack = 50 * self.chain_length + 1000;
216        const MAX_CHAIN_RESTARTS: usize = 8;
217
218        for c in 0..self.n_chains {
219            let mut best_reached = 0usize;
220            let mut grown: Option<Vec<[F; 3]>> = None;
221            for _ in 0..MAX_CHAIN_RESTARTS {
222                if let Some(chain) = self.grow_chain(
223                    &simbox,
224                    a,
225                    &mut grid,
226                    &mut rng,
227                    max_backtrack,
228                    &mut best_reached,
229                ) {
230                    grown = Some(chain);
231                    break;
232                }
233            }
234            let chain = grown.ok_or(WalkError::DeadEnd {
235                chain: c,
236                monomer: best_reached,
237            })?;
238            paths.push(chain.iter().map(|p| to_f3(*p)).collect());
239        }
240
241        Ok(WalkOutput { paths, simbox })
242    }
243
244    /// Grow a single chain with per-step backtracking against the shared grid.
245    /// Returns `None` (after un-occupying its own cells) if the backtrack
246    /// budget is exhausted, so the caller may restart the chain.
247    fn grow_chain(
248        &self,
249        simbox: &SimBox,
250        a: [F; 3],
251        grid: &mut OccupancyGrid,
252        rng: &mut StdRng,
253        max_backtrack: usize,
254        best_reached: &mut usize,
255    ) -> Option<Vec<[F; 3]>> {
256        let mut chain: Vec<[F; 3]> = Vec::with_capacity(self.chain_length);
257        let mut backtracks = 0usize;
258
259        while chain.len() < self.chain_length {
260            let placed = if let Some(&tip) = chain.last() {
261                let mut hit = None;
262                for _ in 0..STEP_TRIES {
263                    let raw = self.strategy.propose_step(tip, self.bond_length, rng);
264                    let cand = apply_boundary(tip, raw, a, self.pbc);
265                    if grid.is_free(cand, Some(tip)) {
266                        hit = Some(cand);
267                        break;
268                    }
269                }
270                hit
271            } else {
272                let mut hit = None;
273                for _ in 0..FIRST_POINT_TRIES {
274                    let p = self.strategy.propose_first(simbox, self.bond_length, rng);
275                    if grid.is_free(p, None) {
276                        hit = Some(p);
277                        break;
278                    }
279                }
280                hit
281            };
282
283            match placed {
284                Some(p) => {
285                    grid.insert(p);
286                    chain.push(p);
287                    if chain.len() > *best_reached {
288                        *best_reached = chain.len();
289                    }
290                }
291                None => {
292                    if let Some(popped) = chain.pop() {
293                        grid.remove(popped);
294                    }
295                    backtracks += 1;
296                    if backtracks > max_backtrack {
297                        // Un-occupy everything this attempt placed before giving up.
298                        for p in &chain {
299                            grid.remove(*p);
300                        }
301                        return None;
302                    }
303                }
304            }
305        }
306        Some(chain)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::builder::{FccLattice, OffLattice};
314
315    const B: F = 1.53;
316
317    fn off() -> SelfAvoidingWalk<OffLattice> {
318        SelfAvoidingWalk {
319            n_chains: 3,
320            chain_length: 20,
321            bond_length: B,
322            target_density: 0.05,
323            pbc: [true, true, true],
324            seed: 9062,
325            strategy: OffLattice {
326                excluded_radius: 1.0,
327            },
328        }
329    }
330
331    fn fcc() -> SelfAvoidingWalk<FccLattice> {
332        SelfAvoidingWalk {
333            n_chains: 3,
334            chain_length: 20,
335            bond_length: B,
336            target_density: 0.05,
337            pbc: [true, true, true],
338            seed: 9062,
339            strategy: FccLattice,
340        }
341    }
342
343    fn fcc_reflective() -> SelfAvoidingWalk<FccLattice> {
344        SelfAvoidingWalk {
345            pbc: [false, false, false],
346            ..fcc()
347        }
348    }
349
350    fn off_reflective() -> SelfAvoidingWalk<OffLattice> {
351        SelfAvoidingWalk {
352            pbc: [false, false, false],
353            ..off()
354        }
355    }
356
357    fn out_off() -> WalkOutput {
358        off().generate().unwrap()
359    }
360    fn out_fcc() -> WalkOutput {
361        fcc().generate().unwrap()
362    }
363
364    fn pt(v: &F3) -> [F; 3] {
365        [v[0], v[1], v[2]]
366    }
367
368    fn min_image_dist(sb: &SimBox, x: &F3, y: &F3) -> F {
369        let d = sb.shortest_vector_impl(pt(x), pt(y));
370        (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
371    }
372
373    // ac-005: exact chain count and per-chain length, both strategies.
374    #[test]
375    fn shape_is_exact() {
376        for paths in [out_off().paths, out_fcc().paths] {
377            assert_eq!(paths.len(), 3);
378            for chain in &paths {
379                assert_eq!(chain.len(), 20usize);
380            }
381        }
382    }
383
384    // ac-001: same seed + config => byte-identical coordinates.
385    #[test]
386    fn deterministic_under_seed() {
387        for (a, b) in [
388            (off().generate().unwrap(), off().generate().unwrap()),
389            (fcc().generate().unwrap(), fcc().generate().unwrap()),
390        ] {
391            for (ca, cb) in a.paths.iter().zip(b.paths.iter()) {
392                for (pa, pb) in ca.iter().zip(cb.iter()) {
393                    assert_eq!(pt(pa), pt(pb), "coordinates must match exactly");
394                }
395            }
396        }
397    }
398
399    // ac-002 / ac-012: consecutive intra-chain monomers are exactly
400    // bond_length apart, under periodic wrap and reflective boundaries, for
401    // both strategies.
402    #[test]
403    fn bond_length_invariant() {
404        let cases = [
405            (out_off(), 1e-9),
406            (out_fcc(), 1e-9),
407            (off_reflective().generate().unwrap(), 1e-9),
408            (fcc_reflective().generate().unwrap(), 1e-9),
409        ];
410        for (out, tol) in cases {
411            for chain in &out.paths {
412                for w in chain.windows(2) {
413                    let d = min_image_dist(&out.simbox, &w[0], &w[1]);
414                    assert!((d - B).abs() <= tol, "bond {d} != {B} (tol {tol})");
415                }
416            }
417        }
418    }
419
420    // ac-003: OffLattice excluded volume holds under minimum image (the grid
421    // BlockClear rule guarantees the geometric separation).
422    #[test]
423    fn offlattice_excluded_volume() {
424        let r = 1.0;
425        let out = out_off();
426        let all: Vec<&F3> = out.paths.iter().flatten().collect();
427        for i in 0..all.len() {
428            for j in (i + 1)..all.len() {
429                let d = min_image_dist(&out.simbox, all[i], all[j]);
430                assert!(d >= r - 1e-9, "pair distance {d} < excluded_radius {r}");
431            }
432        }
433    }
434
435    // ac-004: FccLattice never places two monomers closer than the nn spacing,
436    // under both periodic and reflective boundaries.
437    #[test]
438    fn fcc_no_collision() {
439        for out in [out_fcc(), fcc_reflective().generate().unwrap()] {
440            let all: Vec<&F3> = out.paths.iter().flatten().collect();
441            for i in 0..all.len() {
442                for j in (i + 1)..all.len() {
443                    let d = min_image_dist(&out.simbox, all[i], all[j]);
444                    assert!(d >= B - 1e-9, "pair distance {d} < nn spacing {B}");
445                }
446            }
447        }
448    }
449
450    // ac-006: OffLattice box volume matches n_total / density exactly; FCC box
451    // is the smallest lattice-commensurate box >= that.
452    #[test]
453    fn density_box_convention() {
454        let w = off();
455        let n_total = (w.n_chains * w.chain_length) as F;
456        let expected = n_total / w.target_density;
457        let v = w.generate().unwrap().simbox.volume();
458        assert!((v - expected).abs() / expected <= 1e-6, "off volume {v}");
459
460        let fv = fcc().generate().unwrap().simbox.volume();
461        assert!(
462            fv >= expected - 1e-6,
463            "fcc volume {fv} < requested {expected}"
464        );
465    }
466
467    // ac-012 (boundary): every output coordinate lies inside the box for both
468    // periodic and reflective settings, for both strategies.
469    #[test]
470    fn output_inside_box() {
471        for out in [
472            out_off(),
473            out_fcc(),
474            off_reflective().generate().unwrap(),
475            fcc_reflective().generate().unwrap(),
476        ] {
477            let edge = out.simbox.lengths()[0];
478            for p in out.paths.iter().flatten() {
479                for k in 0..3 {
480                    assert!(
481                        p[k] >= 0.0 && p[k] < edge,
482                        "coord {} out of [0,{edge})",
483                        p[k]
484                    );
485                }
486            }
487        }
488    }
489
490    // ac-009 (first half): invalid configs return WalkError, no panic.
491    #[test]
492    fn invalid_config_errors() {
493        let bad = |w: SelfAvoidingWalk<OffLattice>| {
494            matches!(w.generate(), Err(WalkError::InvalidConfig(_)))
495        };
496        assert!(bad(SelfAvoidingWalk {
497            bond_length: 0.0,
498            ..off()
499        }));
500        assert!(bad(SelfAvoidingWalk {
501            target_density: 0.0,
502            ..off()
503        }));
504        assert!(bad(SelfAvoidingWalk {
505            chain_length: 0,
506            ..off()
507        }));
508        assert!(bad(SelfAvoidingWalk {
509            n_chains: 0,
510            ..off()
511        }));
512    }
513
514    // ac-009 (second half): an over-dense FCC box exhausts retries -> DeadEnd.
515    #[test]
516    fn exhausted_growth_is_dead_end() {
517        let w = SelfAvoidingWalk {
518            n_chains: 4,
519            chain_length: 50,
520            bond_length: B,
521            target_density: 6.10,
522            pbc: [true, true, true],
523            seed: 1,
524            strategy: FccLattice,
525        };
526        assert!(matches!(w.generate(), Err(WalkError::DeadEnd { .. })));
527    }
528
529    // ac-007 / ac-008: struct-literal construction with an injected strategy
530    // struct; WalkOutput carries only paths + simbox (compile-time contract).
531    #[test]
532    fn struct_injection_and_output_contract() {
533        let out: WalkOutput = SelfAvoidingWalk {
534            n_chains: 1,
535            chain_length: 5,
536            bond_length: B,
537            target_density: 0.05,
538            pbc: [true, true, true],
539            seed: 7,
540            strategy: FccLattice,
541        }
542        .generate()
543        .unwrap();
544        let _paths: &Vec<Vec<F3>> = &out.paths;
545        let _box: &SimBox = &out.simbox;
546    }
547}