Skip to main content

symcurve/curve/
calls.rs

1//! Nucleosome calling from symmetry scores, and greedy selection of non-overlapping calls.
2
3use std::collections::BTreeSet;
4
5/// Half a nucleosome's footprint: a call spans `dyad - 73 ..= dyad + 73`.
6pub const NUCLEOSOME_HALF_WIDTH: usize = 73;
7
8/// A nucleosome's footprint in bases, `2 * NUCLEOSOME_HALF_WIDTH + 1`.
9pub const NUCLEOSOME_SIZE: usize = 2 * NUCLEOSOME_HALF_WIDTH + 1;
10
11/// Scores at or above this are reported clamped to it.
12///
13/// The symmetry stage substitutes this value where a dyad's symmetry component came out
14/// exactly zero, which is a saturated score rather than a measured one.
15pub const SATURATED_SCORE: f64 = 100.0;
16
17/// The parameters of nucleosome calling.
18#[derive(Debug, Clone, Copy)]
19pub struct CallParams {
20    /// Half the called footprint, on each side of the dyad.
21    pub half_width: usize,
22    /// Minimum gap required between two accepted calls, beyond the footprint itself.
23    pub spacer: usize,
24}
25
26impl Default for CallParams {
27    fn default() -> Self {
28        Self {
29            half_width: NUCLEOSOME_HALF_WIDTH,
30            spacer: 30,
31        }
32    }
33}
34
35impl CallParams {
36    /// How far apart two accepted dyads must be.
37    ///
38    /// The reference adds the footprint and the spacer, so with the defaults a call
39    /// excludes anything within 177 bases of it on either side.
40    pub fn exclusion(&self) -> usize {
41        self.spacer + 2 * self.half_width + 1
42    }
43}
44
45/// One called nucleosome, positioned by its dyad.
46///
47/// `dyad` is a zero-based index into the record, matching how the reference indexes its
48/// arrays. The call covers `dyad - half_width ..= dyad + half_width`.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct NucleosomeCall {
51    pub dyad: usize,
52    pub score: f64,
53}
54
55impl NucleosomeCall {
56    /// The score as reported, clamped at the saturated value.
57    pub fn reported_score(&self) -> f64 {
58        self.score.min(SATURATED_SCORE)
59    }
60}
61
62/// Turn symmetry scores into overlapping nucleosome calls.
63///
64/// `scores` are `(dyad, score)` pairs with zero-based dyads, in ascending order. A dyad is
65/// called when its score is above zero and its whole footprint fits inside the record.
66///
67/// Following the reference, the bounds are strict on both sides: `dyad - half_width` must
68/// be greater than zero, not merely non-negative, and `dyad + half_width` must be less
69/// than the record length rather than within it. That drops one otherwise-callable dyad at
70/// each end, and is reproduced so the positions match.
71pub fn call_nucleosomes(
72    scores: &[(usize, f64)],
73    record_len: usize,
74    params: &CallParams,
75) -> Vec<NucleosomeCall> {
76    scores
77        .iter()
78        .filter(|(dyad, score)| {
79            *score > 0.0 && *dyad > params.half_width && dyad + params.half_width < record_len
80        })
81        .map(|&(dyad, score)| NucleosomeCall { dyad, score })
82        .collect()
83}
84
85/// Select non-overlapping calls, taking the highest scoring first.
86///
87/// Candidates are considered in descending score order, and one is accepted only if no
88/// already-accepted dyad lies within [`CallParams::exclusion`] of it. The result is
89/// returned in ascending dyad order, as the reference prints it.
90///
91/// The reference scans every accepted call for every candidate, which is quadratic and
92/// dominates its runtime at chromosome scale. Accepted dyads are kept in a sorted set here
93/// and the exclusion window is a range query over it, which is the same rule in
94/// `O(n log n)`.
95///
96/// Two behaviours of the reference are reproduced deliberately. Its accepted-position
97/// array is initialised holding a single zero, and its scan includes that element, so a
98/// phantom call at position 0 rejects every candidate within the exclusion window of it;
99/// no dyad at or below `exclusion` can ever be accepted. And where scores tie, the
100/// reference's order comes from Perl hash iteration, which is randomised per process, so
101/// its choice among equal scores is not reproducible even against itself; ties are broken
102/// here by ascending dyad so that this implementation at least is deterministic.
103pub fn greedy_non_overlapping(
104    calls: &[NucleosomeCall],
105    params: &CallParams,
106) -> Vec<NucleosomeCall> {
107    let exclusion = params.exclusion();
108    let mut order: Vec<&NucleosomeCall> = calls.iter().collect();
109    order.sort_by(|a, b| {
110        b.score
111            .partial_cmp(&a.score)
112            .unwrap_or(std::cmp::Ordering::Equal)
113            .then(a.dyad.cmp(&b.dyad))
114    });
115
116    // Seeded with the reference's phantom position 0.
117    let mut accepted: BTreeSet<usize> = BTreeSet::from([0]);
118    let mut chosen: Vec<NucleosomeCall> = Vec::new();
119
120    for call in order {
121        let low = call.dyad.saturating_sub(exclusion);
122        let high = call.dyad.saturating_add(exclusion);
123        if accepted.range(low..=high).next().is_none() {
124            accepted.insert(call.dyad);
125            chosen.push(*call);
126        }
127    }
128
129    chosen.sort_by_key(|c| c.dyad);
130    chosen
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// A direct transcription of the reference's GREEDYPOS, kept quadratic so it reads
138    /// against the Perl and can serve as an oracle for the fast version.
139    ///
140    /// ```perl
141    /// my @position = 0;
142    /// foreach $dyad (sort { $calls{$b} <=> $calls{$a} } keys %calls) {
143    ///     $index = 0;
144    ///     for (my $i = 0; $i <= $no; $i++) {
145    ///         if (($pos >= $position[$i]-$spacer-$size) and ($pos <= $position[$i]+$spacer+$size)) { $index = 1; }
146    ///     }
147    ///     if ($index == 0) { $no++; $position[$no] = $pos; }
148    /// }
149    /// ```
150    fn perl_greedy(calls: &[NucleosomeCall], params: &CallParams) -> Vec<NucleosomeCall> {
151        let exclusion = params.exclusion() as i64;
152        let mut order: Vec<&NucleosomeCall> = calls.iter().collect();
153        order.sort_by(|a, b| {
154            b.score
155                .partial_cmp(&a.score)
156                .unwrap_or(std::cmp::Ordering::Equal)
157                .then(a.dyad.cmp(&b.dyad))
158        });
159        let mut position: Vec<i64> = vec![0]; // `my @position = 0;`
160        let mut chosen = Vec::new();
161        for call in order {
162            let pos = call.dyad as i64;
163            let mut index = false;
164            for &p in &position {
165                if pos >= p - exclusion && pos <= p + exclusion {
166                    index = true;
167                }
168            }
169            if !index {
170                position.push(pos);
171                chosen.push(*call);
172            }
173        }
174        chosen.sort_by_key(|c| c.dyad);
175        chosen
176    }
177
178    fn synthetic_calls(n: usize, span: usize, seed: u64) -> Vec<NucleosomeCall> {
179        let mut x = seed;
180        let mut out: Vec<NucleosomeCall> = (0..n)
181            .map(|i| {
182                x ^= x << 13;
183                x ^= x >> 7;
184                x ^= x << 17;
185                NucleosomeCall {
186                    dyad: (x as usize) % span,
187                    // Deliberately coarse so scores tie often.
188                    score: ((x >> 20) % 50) as f64 / 10.0 + 0.1 + i as f64 * 0.0,
189                }
190            })
191            .collect();
192        out.sort_by_key(|c| c.dyad);
193        out.dedup_by_key(|c| c.dyad);
194        out
195    }
196
197    #[test]
198    fn test_greedy_matches_the_reference_transcription() {
199        for (n, span, seed) in [
200            (50usize, 2000usize, 1u64),
201            (200, 10_000, 2),
202            (500, 20_000, 3),
203            (1000, 5_000, 4), // dense: most candidates rejected
204            (20, 100, 5),     // everything inside the phantom's exclusion
205        ] {
206            let calls = synthetic_calls(n, span, seed);
207            for spacer in [0usize, 30, 200] {
208                let params = CallParams {
209                    half_width: NUCLEOSOME_HALF_WIDTH,
210                    spacer,
211                };
212                let fast = greedy_non_overlapping(&calls, &params);
213                let slow = perl_greedy(&calls, &params);
214                assert_eq!(fast, slow, "n={n} span={span} spacer={spacer}");
215            }
216        }
217    }
218
219    #[test]
220    fn test_accepted_calls_are_far_enough_apart() {
221        let params = CallParams::default();
222        let calls = synthetic_calls(800, 40_000, 9);
223        let chosen = greedy_non_overlapping(&calls, &params);
224        assert!(chosen.len() > 10);
225        for pair in chosen.windows(2) {
226            assert!(
227                pair[1].dyad - pair[0].dyad > params.exclusion(),
228                "{} and {} are too close",
229                pair[0].dyad,
230                pair[1].dyad
231            );
232        }
233    }
234
235    #[test]
236    fn test_phantom_position_zero_blocks_the_start() {
237        // The reference's accepted array starts holding a zero and its scan includes it,
238        // so nothing within the exclusion window of position 0 can be accepted.
239        let params = CallParams::default();
240        let exclusion = params.exclusion();
241        let calls = vec![
242            NucleosomeCall {
243                dyad: 100,
244                score: 99.0,
245            }, // inside the phantom's window
246            NucleosomeCall {
247                dyad: exclusion,
248                score: 98.0,
249            }, // exactly on the boundary
250            NucleosomeCall {
251                dyad: exclusion + 1,
252                score: 1.0,
253            }, // just outside
254        ];
255        let chosen = greedy_non_overlapping(&calls, &params);
256        assert_eq!(chosen.len(), 1);
257        assert_eq!(chosen[0].dyad, exclusion + 1);
258        assert_eq!(chosen, perl_greedy(&calls, &params));
259    }
260
261    #[test]
262    fn test_higher_scores_win() {
263        let params = CallParams::default();
264        let base = 10_000usize;
265        let calls = vec![
266            NucleosomeCall {
267                dyad: base,
268                score: 1.0,
269            },
270            NucleosomeCall {
271                dyad: base + 10,
272                score: 5.0,
273            },
274            NucleosomeCall {
275                dyad: base + 20,
276                score: 3.0,
277            },
278        ];
279        let chosen = greedy_non_overlapping(&calls, &params);
280        assert_eq!(chosen.len(), 1);
281        assert_eq!(chosen[0].score, 5.0, "the strongest candidate should win");
282    }
283
284    #[test]
285    fn test_calling_bounds_follow_the_reference() {
286        let params = CallParams::default();
287        let hw = params.half_width;
288        let len = 1000usize;
289        let scores = vec![
290            (hw, 1.0),           // dyad - hw == 0, rejected: the test is strict
291            (hw + 1, 1.0),       // first callable
292            (len - hw - 1, 1.0), // last callable
293            (len - hw, 1.0),     // dyad + hw == len, rejected
294            (500, 0.0),          // zero score is never called
295        ];
296        let calls = call_nucleosomes(&scores, len, &params);
297        let dyads: Vec<usize> = calls.iter().map(|c| c.dyad).collect();
298        assert_eq!(dyads, vec![hw + 1, len - hw - 1]);
299    }
300
301    #[test]
302    fn test_saturated_scores_are_reported_clamped() {
303        let call = NucleosomeCall {
304            dyad: 500,
305            score: SATURATED_SCORE,
306        };
307        assert_eq!(call.reported_score(), 100.0);
308        let call = NucleosomeCall {
309            dyad: 500,
310            score: 250.0,
311        };
312        assert_eq!(call.reported_score(), 100.0);
313        let call = NucleosomeCall {
314            dyad: 500,
315            score: 0.25,
316        };
317        assert_eq!(call.reported_score(), 0.25);
318    }
319}