Skip to main content

caps_sa/
limits.rs

1//! Per-suffix length providers for segmented suffix-array construction.
2//!
3//! In the standard SA construction the "natural length" of the suffix
4//! starting at position `p` is `text.len() - p`. For *segmented* texts
5//! (multi-string SAs, splice-junction indexes, etc.) we want LCP
6//! comparisons to stop at the next segment boundary instead — the
7//! suffix logically ends there, and the merge resolves cross-segment
8//! ordering by "shorter-suffix-is-smaller" (the standard generalised-SA
9//! convention).
10//!
11//! The [`LimitProvider`] trait abstracts the per-suffix length lookup
12//! and is plumbed through every site in `merge` / `cascade_merge` /
13//! `suffix_cmp` that previously computed `n - p` inline.
14//! [`PlainText`] is the zero-cost default — its `lim_at` is
15//! `#[inline(always)]` and folds to the same `n - p` expression the
16//! current code emits, so the non-segmented path generates **bit-
17//! identical assembly** to today's after monomorphization.
18//! [`SegmentedText`] holds a sorted cumulative-ends `Vec<u64>` and
19//! does a `partition_point` per lookup; the merge can cache the
20//! result across LCP calls so the cost amortises to ~one binary
21//! search per output record.
22//!
23//! See `bench/README.md` "Approach 3 — segmented LCP" for the design
24//! rationale and the comparison against the `[u8; 3]` (24-bit-text)
25//! alternative.
26
27/// Per-suffix length provider. The merge and cascade-merge code use
28/// `lp.lim_at(p)` instead of `text.len() - p`; the LCP function itself
29/// is unchanged (the merge passes the appropriately-capped
30/// `max_ctx` to the existing SIMD path).
31///
32/// Implementations must be `Sync` so the rayon-parallel sort can
33/// share one provider across worker threads.
34pub trait LimitProvider: Sync {
35    /// Logical length of the suffix starting at position `p` in
36    /// symbols — i.e. the number of comparable symbols before the
37    /// next segment boundary or end-of-text. Must be at most
38    /// `text.len() - p`.
39    fn lim_at(&self, p: usize) -> usize;
40
41    /// Order to resolve when one or both suffixes hit their boundary
42    /// before any byte of their shared prefix differs. The default
43    /// is `lim_a.cmp(&lim_b)` — "shorter-suffix-is-smaller", the
44    /// standard generalised-SA / multi-string-SA convention, what a
45    /// `Vec<&str>` sort with `&str` ordering produces.
46    ///
47    /// Custom impls can override for different boundary conventions.
48    /// The motivating example is STAR's `spacer-as-largest` ordering:
49    /// the suffix that hits a spacer first is *larger*, equivalently
50    /// the longer-`lim` one is smaller, with an ascending-position
51    /// tie-break when both `lim`s coincide:
52    ///
53    /// ```ignore
54    /// fn boundary_order(&self, p_a: usize, lim_a: usize,
55    ///                   p_b: usize, lim_b: usize) -> Ordering {
56    ///     lim_b.cmp(&lim_a).then(p_a.cmp(&p_b))
57    /// }
58    /// ```
59    ///
60    /// `p_a` / `p_b` are the suffix start positions in the same
61    /// coordinate space the merge sees (the spacer-free text's
62    /// coordinates when invoked through `*_with` entries on a
63    /// rustar-aligner-style spacer-free text). The default impl
64    /// ignores them; impls that want a position tie-break use them.
65    #[inline]
66    fn boundary_order(
67        &self,
68        p_a: usize,
69        lim_a: usize,
70        p_b: usize,
71        lim_b: usize,
72    ) -> std::cmp::Ordering {
73        let _ = (p_a, p_b);
74        lim_a.cmp(&lim_b)
75    }
76}
77
78/// Default provider for non-segmented texts: `lim_at(p) = n - p`.
79/// Stored as a single `usize`; the `#[inline(always)]` `lim_at`
80/// folds at monomorphization time into the same `n - p` the merge
81/// used before this abstraction existed, so non-segmented callers
82/// pay zero overhead.
83#[derive(Copy, Clone, Debug)]
84pub struct PlainText {
85    /// Total text length in symbols.
86    pub n: usize,
87}
88
89impl PlainText {
90    /// New `PlainText` for a text of `n` symbols.
91    #[inline]
92    pub fn new(n: usize) -> Self {
93        Self { n }
94    }
95}
96
97impl LimitProvider for PlainText {
98    #[inline(always)]
99    fn lim_at(&self, p: usize) -> usize {
100        self.n - p
101    }
102}
103
104/// Provider for texts partitioned into segments at known cumulative
105/// end positions. `lim_at(p)` returns the distance from `p` to the next
106/// boundary. Large collections automatically add a compact coarse directory,
107/// reducing the binary search to the boundaries inside one position block.
108///
109/// Base storage is `8 × n_segments` bytes for the cumulative ends. For at
110/// least 256 segments, the optional `u32` directory adds at most 8 MiB and at
111/// most another `8 × n_segments` bytes. This remains much smaller than a
112/// per-symbol boundary bitmap or a widened text alphabet at genome scale.
113///
114/// Lookup is `O(log n_segments)` in the fallback and `O(log b)` with the
115/// directory, where `b` is the number of boundaries in one coarse block. The
116/// merge also caches `lim_p`/`lim_q` while a suffix remains at a run front.
117///
118/// Two constructors:
119/// - [`from_lengths`][Self::from_lengths] takes per-segment lengths
120///   and builds the cumulative-ends list internally. Most ergonomic
121///   when the caller has `[chr_len_0, chr_len_1, …]` already.
122/// - [`from_ends`][Self::from_ends] takes the sorted cumulative
123///   ends directly. Useful when the caller already has them — e.g.
124///   STAR's `chr_start[]` table.
125///
126/// Both constructors require the segments to cover the whole text
127/// (`sum(lengths) == text_len`, or `ends.last() == Some(text_len)`).
128#[derive(Clone, Debug)]
129pub struct SegmentedText {
130    n: usize,
131    /// Sorted, strictly-increasing cumulative end positions. After
132    /// segment 0 of length 100 ends at index 100, `ends[0] = 100`.
133    /// After segment 1 of length 50 (positions 100..150),
134    /// `ends[1] = 150`. The last entry equals the total text length.
135    ends: Vec<u64>,
136    /// Coarse position-to-boundary index for large segment collections.
137    directory: Option<BoundaryDirectory>,
138}
139
140#[derive(Clone, Debug)]
141struct BoundaryDirectory {
142    block_shift: u32,
143    /// Number of segment ends at or before each power-of-two block start.
144    first_after_block_start: Vec<u32>,
145}
146
147impl BoundaryDirectory {
148    /// Small segment collections already fit comfortably in cache and do not
149    /// repay an extra directory lookup. Large collections get at most two
150    /// million coarse blocks and roughly two blocks per end when text length
151    /// permits it. The directory therefore occupies at most 8 MiB and at most
152    /// eight additional bytes per segment.
153    const MIN_ENDS: usize = 256;
154    const MAX_BLOCKS: usize = 2_000_000;
155
156    fn build(n: usize, ends: &[u64]) -> Option<Self> {
157        if ends.len() < Self::MIN_ENDS || ends.len() > u32::MAX as usize || n == 0 {
158            return None;
159        }
160
161        let target_blocks = ends.len().saturating_mul(2).clamp(1, Self::MAX_BLOCKS);
162        let min_block_size = n.div_ceil(target_blocks);
163        let block_size = min_block_size
164            .checked_next_power_of_two()
165            .unwrap_or(1usize << (usize::BITS - 1));
166        let block_shift = block_size.trailing_zeros();
167        let n_blocks = n.div_ceil(block_size);
168        let mut first_after_block_start = Vec::with_capacity(n_blocks + 1);
169        let mut end_index = 0usize;
170        for block in 0..=n_blocks {
171            let block_start = block.saturating_mul(block_size).min(n) as u64;
172            while end_index < ends.len() && ends[end_index] <= block_start {
173                end_index += 1;
174            }
175            first_after_block_start.push(end_index as u32);
176        }
177        Some(Self {
178            block_shift,
179            first_after_block_start,
180        })
181    }
182}
183
184impl SegmentedText {
185    /// Build from per-segment lengths. The sum must equal `text_len`.
186    pub fn from_lengths(text_len: usize, lengths: &[usize]) -> Self {
187        let mut ends = Vec::with_capacity(lengths.len());
188        let mut cum: u64 = 0;
189        for &len in lengths {
190            cum += len as u64;
191            ends.push(cum);
192        }
193        assert_eq!(
194            cum as usize, text_len,
195            "SegmentedText::from_lengths: per-segment lengths sum to {cum} but text_len is {text_len}",
196        );
197        let directory = BoundaryDirectory::build(text_len, &ends);
198        Self {
199            n: text_len,
200            ends,
201            directory,
202        }
203    }
204
205    /// Build from sorted, strictly-increasing cumulative end positions.
206    /// `ends.last()` must equal `text_len`.
207    pub fn from_ends(text_len: usize, ends: Vec<u64>) -> Self {
208        assert!(
209            ends.windows(2).all(|w| w[0] < w[1]),
210            "SegmentedText::from_ends: ends must be strictly increasing",
211        );
212        match ends.last() {
213            Some(&last) => assert_eq!(
214                last as usize, text_len,
215                "SegmentedText::from_ends: last end ({last}) != text_len ({text_len})",
216            ),
217            None => assert_eq!(
218                text_len, 0,
219                "SegmentedText::from_ends: empty ends but text_len ({text_len}) != 0",
220            ),
221        }
222        let directory = BoundaryDirectory::build(text_len, &ends);
223        Self {
224            n: text_len,
225            ends,
226            directory,
227        }
228    }
229
230    /// Total text length in symbols.
231    #[inline]
232    pub fn text_len(&self) -> usize {
233        self.n
234    }
235
236    /// Number of segments.
237    #[inline]
238    pub fn n_segments(&self) -> usize {
239        self.ends.len()
240    }
241
242    /// Cumulative end positions, sorted, strictly increasing.
243    /// `ends()[i]` is the position one past the last symbol of
244    /// segment `i`.
245    #[inline]
246    pub fn ends(&self) -> &[u64] {
247        &self.ends
248    }
249}
250
251impl LimitProvider for SegmentedText {
252    #[inline]
253    fn lim_at(&self, p: usize) -> usize {
254        if let Some(directory) = &self.directory
255            && p < self.n
256        {
257            let block = p >> directory.block_shift;
258            let lo = directory.first_after_block_start[block] as usize;
259            let mut hi = directory.first_after_block_start[block + 1] as usize;
260            // If the block has no boundary, include the first boundary from a
261            // later block so the local search still contains its answer.
262            hi = hi.max(lo + 1).min(self.ends.len());
263            let i = lo + self.ends[lo..hi].partition_point(|&b| b <= p as u64);
264            return self.ends[i] as usize - p;
265        }
266        // First boundary strictly greater than p.
267        let i = self.ends.partition_point(|&b| b <= p as u64);
268        if i < self.ends.len() {
269            self.ends[i] as usize - p
270        } else {
271            // p past the last boundary: just text-end.
272            self.n - p
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn plain_text_lim_at_matches_n_minus_p() {
283        let lp = PlainText::new(100);
284        assert_eq!(lp.lim_at(0), 100);
285        assert_eq!(lp.lim_at(50), 50);
286        assert_eq!(lp.lim_at(99), 1);
287        assert_eq!(lp.lim_at(100), 0);
288    }
289
290    #[test]
291    fn segmented_from_lengths_cumulates_ends() {
292        let lp = SegmentedText::from_lengths(15, &[3, 5, 7]);
293        assert_eq!(lp.n_segments(), 3);
294        assert_eq!(lp.ends(), &[3, 8, 15]);
295    }
296
297    #[test]
298    #[should_panic(expected = "sum to")]
299    fn segmented_from_lengths_rejects_undercoverage() {
300        let _ = SegmentedText::from_lengths(20, &[3, 5, 7]);
301    }
302
303    #[test]
304    fn segmented_lim_at_caps_at_next_boundary() {
305        let lp = SegmentedText::from_lengths(15, &[3, 5, 7]);
306        // Segment 0 = [0, 3): boundary at 3.
307        assert_eq!(lp.lim_at(0), 3);
308        assert_eq!(lp.lim_at(1), 2);
309        assert_eq!(lp.lim_at(2), 1);
310        // Segment 1 = [3, 8): boundary at 8.
311        assert_eq!(lp.lim_at(3), 5);
312        assert_eq!(lp.lim_at(5), 3);
313        assert_eq!(lp.lim_at(7), 1);
314        // Segment 2 = [8, 15): boundary at 15.
315        assert_eq!(lp.lim_at(8), 7);
316        assert_eq!(lp.lim_at(14), 1);
317        assert_eq!(lp.lim_at(15), 0);
318    }
319
320    #[test]
321    fn segmented_handles_single_segment_text() {
322        let lp = SegmentedText::from_lengths(10, &[10]);
323        assert_eq!(lp.lim_at(0), 10);
324        assert_eq!(lp.lim_at(5), 5);
325        assert_eq!(lp.lim_at(10), 0);
326    }
327
328    #[test]
329    fn segmented_directory_matches_binary_search() {
330        let lengths: Vec<usize> = (0..2_000).map(|i| 1 + i % 97).collect();
331        let n = lengths.iter().sum();
332        let indexed = SegmentedText::from_lengths(n, &lengths);
333        assert!(indexed.directory.is_some());
334
335        for p in 0..=n {
336            let i = indexed.ends.partition_point(|&b| b <= p as u64);
337            let want = if i < indexed.ends.len() {
338                indexed.ends[i] as usize - p
339            } else {
340                n - p
341            };
342            assert_eq!(indexed.lim_at(p), want, "p={p}");
343        }
344    }
345
346    #[test]
347    fn segmented_handles_empty_text() {
348        let lp = SegmentedText::from_lengths(0, &[]);
349        assert_eq!(lp.n_segments(), 0);
350        // No suffixes to query, but the constructor accepts it.
351    }
352
353    #[test]
354    fn segmented_from_ends_matches_from_lengths() {
355        let a = SegmentedText::from_lengths(15, &[3, 5, 7]);
356        let b = SegmentedText::from_ends(15, vec![3, 8, 15]);
357        assert_eq!(a.ends(), b.ends());
358        for p in 0..=15 {
359            assert_eq!(a.lim_at(p), b.lim_at(p), "p={p}");
360        }
361    }
362}