1use std::collections::BTreeSet;
4
5pub const NUCLEOSOME_HALF_WIDTH: usize = 73;
7
8pub const NUCLEOSOME_SIZE: usize = 2 * NUCLEOSOME_HALF_WIDTH + 1;
10
11pub const SATURATED_SCORE: f64 = 100.0;
16
17#[derive(Debug, Clone, Copy)]
19pub struct CallParams {
20 pub half_width: usize,
22 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 pub fn exclusion(&self) -> usize {
41 self.spacer + 2 * self.half_width + 1
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct NucleosomeCall {
51 pub dyad: usize,
52 pub score: f64,
53}
54
55impl NucleosomeCall {
56 pub fn reported_score(&self) -> f64 {
58 self.score.min(SATURATED_SCORE)
59 }
60}
61
62pub 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
85pub 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 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 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]; 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 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), (20, 100, 5), ] {
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, ¶ms);
213 let slow = perl_greedy(&calls, ¶ms);
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, ¶ms);
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 let params = CallParams::default();
240 let exclusion = params.exclusion();
241 let calls = vec![
242 NucleosomeCall {
243 dyad: 100,
244 score: 99.0,
245 }, NucleosomeCall {
247 dyad: exclusion,
248 score: 98.0,
249 }, NucleosomeCall {
251 dyad: exclusion + 1,
252 score: 1.0,
253 }, ];
255 let chosen = greedy_non_overlapping(&calls, ¶ms);
256 assert_eq!(chosen.len(), 1);
257 assert_eq!(chosen[0].dyad, exclusion + 1);
258 assert_eq!(chosen, perl_greedy(&calls, ¶ms));
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, ¶ms);
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), (hw + 1, 1.0), (len - hw - 1, 1.0), (len - hw, 1.0), (500, 0.0), ];
296 let calls = call_nucleosomes(&scores, len, ¶ms);
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}