Skip to main content

molframe_spatial/queries/
reduce.rs

1//! Fixed-radius queries that reduce pairs instead of returning them.
2//!
3//! A materialising pair query retains `O(pairs)` bytes. At a billion atoms and
4//! a typical contact cutoff that is hundreds of gigabytes for a result the
5//! caller immediately collapses into a per-residue map or a union-find. This
6//! module folds each pair as it is produced, so peak retained bytes track the
7//! accumulator rather than the pair count (FR-516) while keeping the worker
8//! parallelism a materialising query already had.
9
10use super::search::validate_cutoff;
11use super::visit::{emit_unique_cross_candidate, visit_indices};
12use crate::{
13    CellList, NeighborPair, PeriodicBox, SpatialBackend, SpatialError, SpatialSearchOptions,
14};
15use molframe_core::ExecutionContext;
16use molframe_core::parallel::{BlockPlan, map_blocks_in};
17use molframe_core::selection::AtomSelection;
18
19/// Everything a fixed-radius query needs, so a reduction takes three arguments.
20#[derive(Clone, Copy, Debug)]
21pub struct PairQuery<'a> {
22    /// Coordinates that every index in both selections refers to.
23    pub positions: &'a [[f32; 3]],
24    /// The selection each pair's first endpoint is drawn from.
25    pub left: &'a AtomSelection,
26    /// The selection each pair's second endpoint is drawn from.
27    pub right: &'a AtomSelection,
28    /// The inclusive distance within which two atoms form a pair.
29    pub cutoff: f32,
30    /// Backend and grid selection.
31    pub options: SpatialSearchOptions,
32    /// The periodic cell to apply the minimum image convention over, if any.
33    pub periodic: Option<&'a PeriodicBox>,
34    /// Shared execution budget and native worker pool.
35    pub context: &'a ExecutionContext,
36}
37
38/// Folds every fixed-radius pair into per-block accumulators.
39///
40/// The accumulators come back in block order, which is a function of the cell
41/// count and never of `workers`, so merging them in the order returned gives
42/// the same result at any worker count (FR-515). The caller supplies the merge,
43/// because only it knows whether the reduction is a minimum, a union or a sum.
44///
45/// Falls back to a single accumulator when the workload has no block
46/// decomposition — a cross-selection query, a non-cell backend, a periodic box
47/// or one worker — so the caller's merge is written once either way.
48///
49/// # Errors
50///
51/// Returns the same validation and backend errors as the materialising query.
52pub fn reduce_pairs_within_unsorted<T, I, F>(
53    query: &PairQuery<'_>,
54    init: I,
55    fold: F,
56) -> Result<Vec<T>, SpatialError>
57where
58    T: Send,
59    I: Fn() -> T + Sync,
60    F: Fn(&mut T, NeighborPair) + Sync,
61{
62    let &PairQuery {
63        positions,
64        left,
65        right,
66        cutoff,
67        options,
68        periodic,
69        context,
70    } = query;
71
72    validate_cutoff(cutoff)?;
73    let indices = super::indices::IndexWorkspace::new(left, right, positions.len(), context)?;
74    let left_indices = indices.left();
75    let right_indices = indices.right();
76    let plan = options.plan(left_indices.len(), right_indices.len(), cutoff)?;
77
78    // A cell list over a non-periodic workload is the only shape with a block
79    // decomposition: cells own disjoint same-selection pairs, and each query
80    // atom owns its own cross-selection pairs.
81    let workers = context.worker_budget();
82    let blockable = plan.backend == SpatialBackend::CellList && periodic.is_none() && workers > 1;
83    let same_selection = left_indices == right_indices;
84
85    if blockable {
86        let index = CellList::build_in(
87            positions,
88            right_indices,
89            cutoff,
90            periodic.copied(),
91            options.cell_grid,
92            context,
93        )?;
94
95        if same_selection {
96            if let Some(parts) = crate::backends::cell::reduce_pairs_same_selection_parallel(
97                &index,
98                left_indices,
99                cutoff,
100                context,
101                &init,
102                &fold,
103            )? {
104                return Ok(parts);
105            }
106        } else {
107            return reduce_cross_selection(
108                &index,
109                left_indices,
110                right_indices,
111                cutoff,
112                context,
113                &init,
114                &fold,
115            );
116        }
117    }
118
119    let mut single = init();
120    visit_indices(query, left_indices, right_indices, |pair| {
121        fold(&mut single, pair);
122    })?;
123    Ok(vec![single])
124}
125
126#[cfg(test)]
127#[path = "reduce_tests.rs"]
128mod tests;
129
130/// Folds cross-selection pairs into per-block accumulators.
131///
132/// Every pair belongs to exactly one query atom, so partitioning the query
133/// indices partitions the pairs. Block boundaries come from the query count and
134/// never from `workers`, so merging in the order returned is worker-count
135/// independent (FR-515).
136fn reduce_cross_selection<T, I, F>(
137    index: &CellList<'_>,
138    left: &[u32],
139    right: &[u32],
140    cutoff: f32,
141    context: &ExecutionContext,
142    init: &I,
143    fold: &F,
144) -> Result<Vec<T>, SpatialError>
145where
146    T: Send,
147    I: Fn() -> T + Sync,
148    F: Fn(&mut T, NeighborPair) + Sync,
149{
150    /// Query atoms per block. Local density varies, so blocks stay well below
151    /// one worker's share rather than being one share each.
152    const BLOCK_QUERIES: usize = 256;
153
154    let plan = BlockPlan::new(left.len(), BLOCK_QUERIES);
155    let produced = map_blocks_in(plan, context, |_, range| {
156        let mut accumulator = init();
157        let Some(chunk) = left.get(range) else {
158            return Ok(accumulator);
159        };
160        index.for_each_candidate(chunk, cutoff, |left_atom, right_atom, squared| {
161            emit_unique_cross_candidate(left_atom, right_atom, squared, left, right, &mut |pair| {
162                fold(&mut accumulator, pair);
163            });
164        })?;
165        Ok(accumulator)
166    })
167    .map_err(|_| SpatialError::WorkerPanicked)?;
168
169    produced.into_iter().collect()
170}