Skip to main content

symcurve/curve/
scan.rs

1//! Scoring whole records, piece by piece, across threads.
2//!
3//! [`crate::fasta::split_seq_by_gaps`] breaks a record at unscoreable bases, and the
4//! resulting pieces are independent: curvature is computed from a sliding window that
5//! never spans a gap, so no piece needs anything from its neighbours. That makes the
6//! pieces the natural unit of parallelism.
7
8use rayon::prelude::*;
9
10use crate::curve::iters::{CurveIter, SymCurveIterator};
11use crate::curve::matrix::RollType;
12use crate::fasta::RecordPiece;
13
14/// The curvature scores for one piece, with the positions they belong to.
15///
16/// `start` is the 1-based position of the piece's first base in the original record.
17/// `curve_start` is the 1-based position that `curves[0]` scores, which is further in by
18/// the pipeline's lead-in: the windows need context on both sides, so the first several
19/// bases of a piece get no score.
20#[derive(Debug, Clone)]
21pub struct PieceCurves {
22    pub start: usize,
23    pub curve_start: usize,
24    pub curves: Vec<f64>,
25}
26
27/// The parameters that define a curvature calculation.
28///
29/// Grouped into one struct because they travel together through every layer, and a
30/// function taking five bare numbers is easy to call wrongly.
31#[derive(Debug, Clone, Copy)]
32pub struct CurveParams {
33    pub roll_type: RollType,
34    /// Half the rolling-mean window, minus one: the window is `2 * step_b + 1`.
35    pub step_b: usize,
36    /// Distance from the midpoint base to each side of the curve window.
37    pub step_c: usize,
38    pub curve_scale: f64,
39}
40
41impl CurveParams {
42    /// How many bases at each end of a piece receive no score.
43    ///
44    /// The rolling mean consumes `step_b` on each side and the distance window a further
45    /// `step_c`, plus one for the triplet. A piece of length `n` therefore yields
46    /// `n - 2 * lead_in()` scores, the first of which belongs to the base at offset
47    /// `lead_in()`. This matches the reference implementation, which indexes curvature
48    /// from `curvstep + stepone`.
49    pub fn lead_in(&self) -> usize {
50        self.step_b + self.step_c + 1
51    }
52}
53
54/// Which stage of the calculation to produce.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Stage {
57    /// Curvature: the output of [`CurveIter`].
58    Curvature,
59    /// Symmetry of curvature around each dyad: the final stage.
60    Symmetry,
61}
62
63/// The parameters of the symmetry stage.
64#[derive(Debug, Clone, Copy)]
65pub struct SymParams {
66    /// Margin of curvature values kept on each side of a dyad. The reference uses 101.
67    pub win: usize,
68    /// Stride between dyads and between the mirrored pairs summed at each.
69    pub step: usize,
70}
71
72impl SymParams {
73    /// How many bases at each end of a piece receive no symmetry score.
74    ///
75    /// The symmetry stage consumes `win` curvature values on each side of a dyad, and each
76    /// curvature value already cost `CurveParams::lead_in` bases, so the two margins add.
77    pub fn lead_in(&self, params: &CurveParams) -> usize {
78        params.lead_in() + self.win
79    }
80
81    /// How many symmetry scores a piece of `piece_len` bases yields.
82    pub fn score_count(&self, piece_len: usize, params: &CurveParams) -> usize {
83        let curve_len = piece_len.saturating_sub(2 * params.lead_in());
84        let dyads = curve_len.saturating_sub(2 * self.win);
85        dyads.div_ceil(self.step.max(1))
86    }
87}
88
89/// The default number of scores one chunk produces.
90///
91/// Large enough that the per-chunk lead-in overhead is negligible, small enough that a
92/// handful in flight stay in cache and bound memory. Overridden from the memory budget.
93pub const DEFAULT_CHUNK_SCORES: usize = 1 << 20;
94
95/// One unit of independently scoreable work within a piece.
96///
97/// Chunks can be scored with fresh state and their results concatenated, because
98/// curvature is a distance between local averages: a chunk that starts partway into a
99/// piece has the wrong starting coordinate and the wrong accumulated twist, but the first
100/// is a translation of the traced path and the second a rotation of it, and neither
101/// changes the distances the scores are made of. Each chunk therefore reads `lead_in`
102/// bases of context beyond its output range at each end.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct Chunk {
105    /// Offset into the piece where this chunk starts reading.
106    pub in_start: usize,
107    /// Offset into the piece where this chunk stops reading, exclusive.
108    pub in_end: usize,
109    /// Index into the piece's scores of this chunk's first score.
110    pub out_start: usize,
111}
112
113/// Divide a piece of `piece_len` bases into chunks producing `chunk_scores` scores each.
114///
115/// Returns no chunks when the piece is too short to produce any score at all.
116pub fn chunks(piece_len: usize, params: &CurveParams, chunk_scores: usize) -> Vec<Chunk> {
117    let lead = params.lead_in();
118    let out_len = piece_len.saturating_sub(2 * lead);
119    let chunk_scores = chunk_scores.max(1);
120    (0..out_len)
121        .step_by(chunk_scores)
122        .map(|out_start| {
123            let out_end = (out_start + chunk_scores).min(out_len);
124            Chunk {
125                // Scoring bases [p, q + 2*lead) yields exactly the scores [p, q).
126                in_start: out_start,
127                in_end: out_end + 2 * lead,
128                out_start,
129            }
130        })
131        .collect()
132}
133
134/// Divide a piece into chunks of symmetry scores.
135///
136/// Same reasoning as [`chunks`], with a wider margin: a dyad needs `win` curvature values
137/// on each side, and each of those needed `lead_in` bases of its own. Scoring the bases
138/// `[p*step, (q-1)*step + 2*win + 2*lead_in + 1)` yields exactly the symmetry scores
139/// `[p, q)`, so the chunks tile the output without gap or overlap.
140pub fn sym_chunks(
141    piece_len: usize,
142    params: &CurveParams,
143    sym: &SymParams,
144    chunk_scores: usize,
145) -> Vec<Chunk> {
146    let step = sym.step.max(1);
147    let lead = params.lead_in();
148    let out_len = sym.score_count(piece_len, params);
149    let chunk_scores = chunk_scores.max(1);
150    (0..out_len)
151        .step_by(chunk_scores)
152        .map(|out_start| {
153            let out_end = (out_start + chunk_scores).min(out_len);
154            Chunk {
155                in_start: out_start * step,
156                in_end: (out_end - 1) * step + 2 * sym.win + 2 * lead + 1,
157                out_start,
158            }
159        })
160        .collect()
161}
162
163/// Score a run of bases through to symmetry, with fresh state.
164pub fn score_symmetry_bases(bases: &[u8], params: &CurveParams, sym: &SymParams) -> Vec<f64> {
165    CurveIter::new(
166        bases.iter().copied(),
167        params.roll_type,
168        params.step_b,
169        params.step_c,
170        params.curve_scale,
171    )
172    .sym_curve_iter(sym.win, sym.step)
173    .collect()
174}
175
176/// Score a run of bases with fresh state.
177pub fn score_bases(bases: &[u8], params: &CurveParams) -> Vec<f64> {
178    CurveIter::new(
179        bases.iter().copied(),
180        params.roll_type,
181        params.step_b,
182        params.step_c,
183        params.curve_scale,
184    )
185    .collect()
186}
187
188/// Score a single piece.
189pub fn score_piece(piece: &RecordPiece, params: &CurveParams) -> PieceCurves {
190    score_piece_chunked(piece, params, DEFAULT_CHUNK_SCORES)
191}
192
193/// Score a single piece, dividing it into chunks of the given size.
194///
195/// A record is often one enormous piece, so splitting only by piece leaves a chromosome
196/// on a single thread. Chunking within the piece both spreads that work and bounds how
197/// much of it is in memory at once.
198pub fn score_piece_chunked(
199    piece: &RecordPiece,
200    params: &CurveParams,
201    chunk_scores: usize,
202) -> PieceCurves {
203    // `bases` borrows straight out of the shared record, so no copy is made here.
204    let bases = piece.bases();
205    // collect() on an indexed parallel iterator preserves order, so the per-chunk score
206    // runs concatenate back into the piece's scores in position order.
207    let per_chunk: Vec<Vec<f64>> = chunks(bases.len(), params, chunk_scores)
208        .into_par_iter()
209        .map(|chunk| score_bases(&bases[chunk.in_start..chunk.in_end], params))
210        .collect();
211    let curves: Vec<f64> = per_chunk.concat();
212    let start = usize::from(piece.start);
213    PieceCurves {
214        start,
215        curve_start: start + params.lead_in(),
216        curves,
217    }
218}
219
220/// Score every piece, in parallel, preserving input order.
221///
222/// Pieces vary enormously in length -- a record may hold one piece covering most of a
223/// chromosome alongside many short ones -- so this splits by piece and lets rayon's work
224/// stealing handle the imbalance, rather than dividing the work evenly up front.
225pub fn score_pieces(pieces: &[RecordPiece], params: &CurveParams) -> Vec<PieceCurves> {
226    pieces
227        .par_iter()
228        .map(|piece| score_piece(piece, params))
229        .collect()
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::fasta::split_seq_by_gaps;
236    use approx::assert_relative_eq;
237
238    fn params() -> CurveParams {
239        CurveParams {
240            roll_type: RollType::Simple,
241            step_b: 5,
242            step_c: 15,
243            curve_scale: 0.33335,
244        }
245    }
246
247    fn record_of(seq: &[u8]) -> noodles_fasta::Record {
248        let mut src = b">chr42\n".to_vec();
249        src.extend_from_slice(seq);
250        src.push(b'\n');
251        let mut reader = noodles_fasta::io::Reader::new(&src[..]);
252        reader.records().next().unwrap().unwrap()
253    }
254
255    /// Scoring pieces across threads requires them to be Send and Sync. This is what
256    /// Arc buys over Rc, which is not Send; asserting it here states the requirement
257    /// rather than leaving it implied by a par_iter call elsewhere.
258    #[test]
259    fn test_record_piece_is_send_and_sync() {
260        fn assert_send_sync<T: Send + Sync>() {}
261        assert_send_sync::<RecordPiece>();
262        assert_send_sync::<PieceCurves>();
263        assert_send_sync::<CurveParams>();
264    }
265
266    #[test]
267    fn test_parallel_matches_serial() {
268        // Several pieces of differing lengths, separated by gaps.
269        let unit = b"CCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATC";
270        let mut seq = Vec::new();
271        for reps in [3usize, 1, 5, 2] {
272            for _ in 0..reps {
273                seq.extend_from_slice(unit);
274            }
275            seq.extend_from_slice(b"NNNN");
276        }
277        let pieces = split_seq_by_gaps(record_of(&seq));
278        assert_eq!(pieces.len(), 4);
279
280        let parallel = score_pieces(&pieces, &params());
281        let serial: Vec<PieceCurves> = pieces.iter().map(|p| score_piece(p, &params())).collect();
282
283        assert_eq!(parallel.len(), serial.len());
284        for (par, ser) in parallel.iter().zip(&serial) {
285            // Order must be preserved, not just the multiset of results.
286            assert_eq!(par.start, ser.start);
287            assert_eq!(par.curves.len(), ser.curves.len());
288            for (a, b) in par.curves.iter().zip(&ser.curves) {
289                assert_relative_eq!(a, b, epsilon = 1e-12);
290            }
291        }
292    }
293
294    #[test]
295    fn test_piece_start_positions_are_reported() {
296        let seq = b"CCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATCNNNNCCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATC";
297        let pieces = split_seq_by_gaps(record_of(seq));
298        let scored = score_pieces(&pieces, &params());
299        assert_eq!(scored.len(), 2);
300        assert_eq!(scored[0].start, 1);
301        assert_eq!(scored[1].start, 55);
302    }
303
304    #[test]
305    fn test_lead_in_matches_the_reference_convention() {
306        // The Perl reference scores indices curvstep+stepone .. len-curvstep-stepone,
307        // with stepone = step_b + 1 and curvstep = step_c. Check both the count and the
308        // position of the first score, since an off-by-one here shifts every value in
309        // the output file relative to the genome.
310        let p = params();
311        assert_eq!(p.lead_in(), 21); // step_b 5 + step_c 15 + 1, i.e. stepone 6 + curvstep 15
312        let unit = b"CCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATC";
313        let mut seq = Vec::new();
314        for _ in 0..4 {
315            seq.extend_from_slice(unit);
316        }
317        let n = seq.len();
318        let pieces = split_seq_by_gaps(record_of(&seq));
319        let scored = score_pieces(&pieces, &p);
320        assert_eq!(scored.len(), 1);
321        assert_eq!(scored[0].curves.len(), n - 2 * p.lead_in());
322        assert_eq!(scored[0].start, 1);
323        assert_eq!(scored[0].curve_start, 1 + p.lead_in());
324    }
325
326    #[test]
327    fn test_curve_start_is_offset_from_the_piece_not_the_record() {
328        let seq = b"NNNNNCCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATCCCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATC";
329        let pieces = split_seq_by_gaps(record_of(seq));
330        assert_eq!(pieces.len(), 1);
331        let scored = score_pieces(&pieces, &params());
332        assert_eq!(scored[0].start, 6); // after the 5 leading Ns, 1-based
333        assert_eq!(scored[0].curve_start, 6 + params().lead_in());
334    }
335
336    #[test]
337    fn test_chunk_ranges_tile_the_output_exactly() {
338        let p = params();
339        let lead = p.lead_in();
340        let piece_len = 1000usize;
341        let out_len = piece_len - 2 * lead;
342        let cs = 100usize;
343        let cs_list = chunks(piece_len, &p, cs);
344        assert_eq!(cs_list.len(), out_len.div_ceil(cs));
345        // Chunks must tile the output range with no gap and no overlap.
346        let mut expected_out = 0usize;
347        for c in &cs_list {
348            assert_eq!(c.out_start, expected_out);
349            // Scoring bases [in_start, in_end) yields in_end - in_start - 2*lead scores.
350            let produced = c.in_end - c.in_start - 2 * lead;
351            expected_out += produced;
352            assert!(c.in_end <= piece_len, "chunk reads past the piece");
353        }
354        assert_eq!(
355            expected_out, out_len,
356            "chunks do not cover the output exactly"
357        );
358    }
359
360    #[test]
361    fn test_short_piece_produces_no_chunks() {
362        let p = params();
363        for len in [0usize, 1, 2 * p.lead_in(), 2 * p.lead_in() + 1] {
364            let got = chunks(len, &p, 100);
365            let expect_scores = len.saturating_sub(2 * p.lead_in());
366            assert_eq!(got.is_empty(), expect_scores == 0, "len {len}");
367        }
368    }
369
370    #[test]
371    fn test_chunked_scoring_matches_unchunked() {
372        // The whole point of chunking is that it changes nothing about the answer.
373        // Chunk sizes are chosen to straddle the boundaries: one chunk, exact multiples,
374        // and sizes that leave a short final chunk.
375        let unit = b"CCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATC";
376        let mut seq = Vec::new();
377        for _ in 0..40 {
378            seq.extend_from_slice(unit);
379        }
380        let pieces = split_seq_by_gaps(record_of(&seq));
381        let piece = &pieces[0];
382        let p = params();
383
384        let reference = score_bases(piece.bases(), &p);
385        assert!(reference.len() > 1000);
386
387        for chunk_scores in [
388            1usize,
389            7,
390            64,
391            500,
392            1000,
393            reference.len(),
394            reference.len() * 2,
395        ] {
396            let got = score_piece_chunked(piece, &p, chunk_scores);
397            assert_eq!(
398                got.curves.len(),
399                reference.len(),
400                "length differs at chunk_scores {chunk_scores}"
401            );
402            for (i, (a, b)) in reference.iter().zip(&got.curves).enumerate() {
403                // Not bit-identical: a chunk accumulates twist over a shorter run, so the
404                // rounding differs. The values are mathematically the same.
405                assert_relative_eq!(a, b, epsilon = 1e-9, max_relative = 1e-9);
406                let _ = i;
407            }
408        }
409    }
410
411    fn sym() -> SymParams {
412        SymParams { win: 20, step: 1 }
413    }
414
415    #[test]
416    fn test_sym_chunks_tile_the_output_exactly() {
417        let p = params();
418        for step in [1usize, 3, 5] {
419            let sp = SymParams { win: 20, step };
420            let piece_len = 2000usize;
421            let out_len = sp.score_count(piece_len, &p);
422            assert!(out_len > 0);
423            for chunk_scores in [1usize, 7, 64, 500, out_len, out_len * 2] {
424                let plan = sym_chunks(piece_len, &p, &sp, chunk_scores);
425                assert_eq!(plan.len(), out_len.div_ceil(chunk_scores.max(1)));
426                let mut expected = 0usize;
427                for c in &plan {
428                    assert_eq!(c.out_start, expected);
429                    assert!(c.in_end <= piece_len, "chunk reads past the piece");
430                    // A slice of this length yields exactly the scores the chunk claims.
431                    let produced = sp.score_count(c.in_end - c.in_start, &p);
432                    expected += produced;
433                }
434                assert_eq!(expected, out_len, "step {step} chunk {chunk_scores}");
435            }
436        }
437    }
438
439    #[test]
440    fn test_chunked_symmetry_matches_unchunked() {
441        // Chunking must not change the answer, exactly as for curvature.
442        let unit = b"CCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATC";
443        let mut seq = Vec::new();
444        for _ in 0..60 {
445            seq.extend_from_slice(unit);
446        }
447        let pieces = split_seq_by_gaps(record_of(&seq));
448        let piece = &pieces[0];
449        let p = params();
450
451        for step in [1usize, 3] {
452            let sp = SymParams { win: 20, step };
453            let reference = score_symmetry_bases(piece.bases(), &p, &sp);
454            assert!(reference.len() > 500, "input too small to be meaningful");
455
456            for chunk_scores in [1usize, 7, 64, 500, reference.len(), reference.len() * 2] {
457                let plan = sym_chunks(piece.bases().len(), &p, &sp, chunk_scores);
458                let rebuilt: Vec<f64> = plan
459                    .iter()
460                    .flat_map(|c| {
461                        score_symmetry_bases(&piece.bases()[c.in_start..c.in_end], &p, &sp)
462                    })
463                    .collect();
464                assert_eq!(
465                    rebuilt.len(),
466                    reference.len(),
467                    "length differs at step {step} chunk {chunk_scores}"
468                );
469                for (a, b) in reference.iter().zip(&rebuilt) {
470                    // A chunk accumulates twist over a shorter run, so rounding differs.
471                    assert_relative_eq!(a, b, epsilon = 1e-9, max_relative = 1e-6);
472                }
473            }
474        }
475    }
476
477    #[test]
478    fn test_symmetry_lead_in_and_count() {
479        let p = params();
480        let sp = sym();
481        // Symmetry costs the curvature lead-in plus the symmetry window on each side.
482        assert_eq!(sp.lead_in(&p), p.lead_in() + sp.win);
483        for len in [0usize, 1, 2 * sp.lead_in(&p), 2 * sp.lead_in(&p) + 1, 5000] {
484            let expected = len.saturating_sub(2 * sp.lead_in(&p));
485            assert_eq!(sp.score_count(len, &p), expected, "len {len}");
486        }
487    }
488
489    #[test]
490    fn test_empty_input_is_not_an_error() {
491        let scored = score_pieces(&[], &params());
492        assert!(scored.is_empty());
493    }
494
495    #[test]
496    fn test_piece_shorter_than_the_window_yields_nothing() {
497        // A piece too short to fill the windows must produce no scores rather than panic.
498        let pieces = split_seq_by_gaps(record_of(b"ACGTACGT"));
499        assert_eq!(pieces.len(), 1);
500        let scored = score_pieces(&pieces, &params());
501        assert_eq!(scored.len(), 1);
502        assert!(scored[0].curves.is_empty());
503    }
504}