Skip to main content

caps_sa/
sample_sort.rs

1//! In-memory CaPS-SA-style suffix array construction.
2//!
3//! Phase 1 of the port: a parallel merge-sort with LCP-enhanced two-way merge,
4//! exactly the inner sorting kernel of upstream CaPS-SA's `Suffix_Array::merge`
5//! and `Suffix_Array::merge_sort` (see `include/Suffix_Array.hpp` and
6//! `src/Suffix_Array.cpp`). The sample-sort partitioning around this kernel
7//! (`select_pivots` → `distribute_sub_subarrays` → `merge_sub_subarrays`) is
8//! Phase 2 / 3 work; the kernel here already produces a correct LCP-annotated
9//! suffix array, and `rayon::join` gives parallel divide for free.
10//!
11//! The LCP-enhanced merge maintains:
12//!
13//! * `m` — the LCP between the last-output element and the current top of the
14//!   *other* stream.
15//! * `l_a` = `lcp_a[i_a]` — the LCP between the current top of the
16//!   last-output stream and its immediate predecessor (which is the
17//!   last-output element).
18//!
19//! Three cases per step:
20//!
21//! * `l_a > m`: the next candidate from the last-output stream agrees with the
22//!   last-output element past where the other stream diverged — it lies on the
23//!   same side of the other stream's top as the last-output element did, so it
24//!   wins. No symbol comparison needed.
25//! * `l_a < m`: the next candidate diverges from the last-output element
26//!   *inside* the prefix shared with the other stream's top. Since the stream
27//!   is sorted, the new candidate is larger than its predecessor; at the
28//!   divergence offset it therefore exceeds the other stream's top — the
29//!   other stream wins. No symbol comparison needed.
30//! * `l_a == m`: undetermined; extend the LCP from offset `m` by an actual
31//!   symbol scan and compare.
32
33use crate::Index;
34use crate::lcp::{LcpDispatch, Symbol};
35use crate::lcp_memo::GeometricMemo;
36use crate::limits::{LimitProvider, PlainText};
37use rayon::join;
38
39/// How many merge steps ahead the text prefetch runs. Large enough to cover a
40/// DRAM round trip at the merge's step rate, small enough that the prefetched
41/// line is still resident when the step that needs it arrives.
42const PREFETCH_DISTANCE: usize = 8;
43
44/// Hint the CPU to start pulling `text[at]` into cache.
45///
46/// A no-op on targets without a stable prefetch intrinsic, and harmless when
47/// `at` is out of bounds: the address is never dereferenced, only used as a
48/// prefetch operand, and prefetch instructions on both supported targets
49/// ignore faulting addresses.
50#[inline(always)]
51fn prefetch_symbol<S>(text: &[S], at: usize) {
52    let _ = (text, at);
53    #[cfg(target_arch = "x86_64")]
54    unsafe {
55        std::arch::x86_64::_mm_prefetch(
56            text.as_ptr().add(at.min(text.len())) as *const i8,
57            std::arch::x86_64::_MM_HINT_T0,
58        );
59    }
60    #[cfg(target_arch = "aarch64")]
61    unsafe {
62        // `core::arch::aarch64::_prefetch` is still unstable, so emit the
63        // instruction directly. `prfm` never faults.
64        let p = text.as_ptr().add(at.min(text.len()));
65        std::arch::asm!("prfm pldl1keep, [{p}]", p = in(reg) p, options(nostack, readonly, preserves_flags));
66    }
67}
68
69/// Tunable options for SA construction.
70#[derive(Clone, Debug)]
71pub struct Opts {
72    /// Bound on extension comparisons inside the merge. `usize::MAX` (default)
73    /// is unbounded — required for full lexicographic correctness when the
74    /// caller's text doesn't guarantee comparisons terminate via sentinels
75    /// within a known window.
76    pub max_context: usize,
77}
78
79impl Default for Opts {
80    fn default() -> Self {
81        Self {
82            max_context: usize::MAX,
83        }
84    }
85}
86
87/// Build the suffix array of `text` in memory and return it.
88///
89/// Generic over the symbol type `S` (`Ord + Copy`, e.g. `u8`, `u16`, `u32`)
90/// and the index type `I` (`u32`, `u64`, `usize`). Pick the narrowest `I`
91/// that can hold `text.len()`.
92///
93/// Produces a *standard lexicographic* suffix array. The "shorter suffix is
94/// smaller when one runs off the end of `text`" tie-break is applied — i.e.
95/// the algorithm behaves as if `text` is followed by an implicit symbol
96/// smaller than all of `S`.
97pub fn build_in_memory<S, I>(text: &[S]) -> Vec<I>
98where
99    S: Symbol,
100    I: Index,
101{
102    build_in_memory_with_opts(text, &Opts::default())
103}
104
105/// Variant of [`build_in_memory`] that accepts tuning options.
106pub fn build_in_memory_with_opts<S, I>(text: &[S], opts: &Opts) -> Vec<I>
107where
108    S: Symbol,
109    I: Index,
110{
111    build_in_memory_with(text, &PlainText::new(text.len()), opts)
112}
113
114/// Variant of [`build_in_memory`] that accepts a [`LimitProvider`].
115/// With [`PlainText`] this is identical to [`build_in_memory`]; with
116/// [`SegmentedText`][crate::limits::SegmentedText] the LCP scans stop
117/// at segment boundaries.
118pub fn build_in_memory_with<S, I, L>(text: &[S], lp: &L, opts: &Opts) -> Vec<I>
119where
120    S: Symbol,
121    I: Index,
122    L: LimitProvider,
123{
124    let n = text.len();
125    let positions: Vec<I> = (0..n).map(I::from_usize).collect();
126    build_in_memory_for_positions_with(text, positions, lp, opts)
127}
128
129/// Sort the caller-supplied `positions` by the lexicographic order of
130/// their suffixes in `text`. Returns the positions reordered so that
131/// `text[output[i]..]` is the i-th smallest suffix among the input set.
132///
133/// Equivalent to [`build_in_memory`] for the special case
134/// `positions = (0..text.len()).collect()`; the explicit-positions form
135/// lets callers skip suffixes they don't want included in the sort —
136/// e.g. STAR-style genome indexing where only ACGT-starting positions
137/// participate in the SA, avoiding the O(n) work of sorting and then
138/// discarding the spacer-starting positions inside bin-padding.
139///
140/// The suffix at each position is still the slice `text[position..]`;
141/// no positions are dropped from the input. To filter, the caller
142/// constructs `positions` with only the indices they want.
143pub fn build_in_memory_for_positions<S, I>(text: &[S], positions: Vec<I>) -> Vec<I>
144where
145    S: Symbol,
146    I: Index,
147{
148    build_in_memory_for_positions_with_opts(text, positions, &Opts::default())
149}
150
151/// Variant of [`build_in_memory_for_positions`] that accepts tuning options.
152pub fn build_in_memory_for_positions_with_opts<S, I>(
153    text: &[S],
154    positions: Vec<I>,
155    opts: &Opts,
156) -> Vec<I>
157where
158    S: Symbol,
159    I: Index,
160{
161    build_in_memory_for_positions_with(text, positions, &PlainText::new(text.len()), opts)
162}
163
164/// Variant of [`build_in_memory_for_positions`] that accepts both a
165/// [`LimitProvider`] (for segmented LCP truncation) and tuning options.
166/// With [`PlainText`] this is identical to
167/// [`build_in_memory_for_positions_with_opts`].
168pub fn build_in_memory_for_positions_with<S, I, L>(
169    text: &[S],
170    positions: Vec<I>,
171    lp: &L,
172    opts: &Opts,
173) -> Vec<I>
174where
175    S: Symbol,
176    I: Index,
177    L: LimitProvider,
178{
179    let n = positions.len();
180    if n == 0 {
181        return Vec::new();
182    }
183
184    let mut sa: Vec<I> = positions;
185    let mut sa_w: Vec<I> = vec![I::zero(); n];
186    let mut lcp_arr: Vec<I> = vec![I::zero(); n];
187    let mut lcp_w: Vec<I> = vec![I::zero(); n];
188
189    // Choose the LCP implementation once for the whole build; the captured
190    // function pointer travels through the recursion in a register, so the
191    // inner merge loop pays no atomic load or feature-detection branch.
192    let dispatch = LcpDispatch::detect();
193
194    merge_sort(
195        text,
196        lp,
197        &mut sa,
198        &mut sa_w,
199        &mut lcp_arr,
200        &mut lcp_w,
201        opts.max_context,
202        dispatch,
203    );
204
205    sa
206}
207
208/// Recursive merge-sort with LCP maintenance.
209///
210/// Pre: `sa.len() == sa_w.len() == lcp_arr.len() == lcp_w.len()`. The contents
211/// of `sa` are the suffix positions to sort (typically an identity
212/// permutation at the top level). All other buffers are scratch / output.
213///
214/// Post: `sa` is sorted in ascending lexicographic order on
215/// `text[sa[i]..]`; `lcp_arr[0] = 0` and `lcp_arr[i] = lcp(text[sa[i-1]..],
216/// text[sa[i]..])` for `i >= 1`.
217///
218/// Visible to the rest of the crate so the external-memory path can sort
219/// individual subarrays of positions using the same kernel.
220#[allow(clippy::too_many_arguments)] // 4 buffers + text + lp + ctx + dispatch
221pub(crate) fn merge_sort<S, I, L>(
222    text: &[S],
223    lp: &L,
224    sa: &mut [I],
225    sa_w: &mut [I],
226    lcp_arr: &mut [I],
227    lcp_w: &mut [I],
228    max_ctx: usize,
229    dispatch: LcpDispatch,
230) where
231    S: Symbol,
232    I: Index,
233    L: LimitProvider,
234{
235    let n = sa.len();
236    debug_assert_eq!(sa_w.len(), n);
237    debug_assert_eq!(lcp_arr.len(), n);
238    debug_assert_eq!(lcp_w.len(), n);
239
240    if n <= 1 {
241        if n == 1 {
242            lcp_arr[0] = I::zero();
243        }
244        return;
245    }
246
247    let mid = n / 2;
248    let (sa_l, sa_r) = sa.split_at_mut(mid);
249    let (sa_w_l, sa_w_r) = sa_w.split_at_mut(mid);
250    let (lcp_l, lcp_r) = lcp_arr.split_at_mut(mid);
251    let (lcp_w_l, lcp_w_r) = lcp_w.split_at_mut(mid);
252
253    join(
254        || merge_sort(text, lp, sa_l, sa_w_l, lcp_l, lcp_w_l, max_ctx, dispatch),
255        || merge_sort(text, lp, sa_r, sa_w_r, lcp_r, lcp_w_r, max_ctx, dispatch),
256    );
257
258    // Merge the two sorted halves (still living in `sa`) into the workspace,
259    // then copy the workspace back into the destination so the caller's
260    // postcondition holds on `sa` / `lcp_arr`.
261    merge(
262        text, lp, sa_l, sa_r, lcp_l, lcp_r, sa_w, lcp_w, max_ctx, dispatch,
263    );
264    sa.copy_from_slice(sa_w);
265    lcp_arr.copy_from_slice(lcp_w);
266}
267
268/// Sort one subarray that is already owned by an outer Rayon task.
269///
270/// Spawning recursive Rayon joins here oversubscribes phase 1's thousands of
271/// independent tasks and performs scheduler bookkeeping at every merge-tree
272/// node. Keeping the recursion local still leaves ample outer parallelism.
273#[allow(clippy::too_many_arguments)]
274pub(crate) fn merge_sort_task_local<S, I, L>(
275    text: &[S],
276    lp: &L,
277    sa: &mut [I],
278    sa_w: &mut [I],
279    lcp_arr: &mut [I],
280    lcp_w: &mut [I],
281    max_ctx: usize,
282    dispatch: LcpDispatch,
283) where
284    S: Symbol,
285    I: Index,
286    L: LimitProvider,
287{
288    debug_assert_eq!(sa.len(), sa_w.len());
289    debug_assert_eq!(sa.len(), lcp_arr.len());
290    debug_assert_eq!(sa.len(), lcp_w.len());
291    if sa.is_empty() {
292        return;
293    }
294
295    // Both sides begin with the same unsorted positions. Recursive calls swap
296    // source and destination roles, so every merge level writes directly into
297    // the side consumed by its parent and no level needs a copy-back pass.
298    sa_w.copy_from_slice(sa);
299    merge_sort_ping_pong(text, lp, sa_w, lcp_w, sa, lcp_arr, max_ctx, dispatch);
300}
301
302#[allow(clippy::too_many_arguments)]
303fn merge_sort_ping_pong<S, I, L>(
304    text: &[S],
305    lp: &L,
306    src_sa: &mut [I],
307    src_lcp: &mut [I],
308    dst_sa: &mut [I],
309    dst_lcp: &mut [I],
310    max_ctx: usize,
311    dispatch: LcpDispatch,
312) where
313    S: Symbol,
314    I: Index,
315    L: LimitProvider,
316{
317    let n = src_sa.len();
318    debug_assert_eq!(src_lcp.len(), n);
319    debug_assert_eq!(dst_sa.len(), n);
320    debug_assert_eq!(dst_lcp.len(), n);
321    if n <= 1 {
322        if n == 1 {
323            dst_sa[0] = src_sa[0];
324            dst_lcp[0] = I::zero();
325        }
326        return;
327    }
328
329    let mid = n / 2;
330    {
331        let (src_sa_l, src_sa_r) = src_sa.split_at_mut(mid);
332        let (src_lcp_l, src_lcp_r) = src_lcp.split_at_mut(mid);
333        let (dst_sa_l, dst_sa_r) = dst_sa.split_at_mut(mid);
334        let (dst_lcp_l, dst_lcp_r) = dst_lcp.split_at_mut(mid);
335        merge_sort_ping_pong(
336            text, lp, dst_sa_l, dst_lcp_l, src_sa_l, src_lcp_l, max_ctx, dispatch,
337        );
338        merge_sort_ping_pong(
339            text, lp, dst_sa_r, dst_lcp_r, src_sa_r, src_lcp_r, max_ctx, dispatch,
340        );
341    }
342
343    let (src_sa_l, src_sa_r) = src_sa.split_at(mid);
344    let (src_lcp_l, src_lcp_r) = src_lcp.split_at(mid);
345    merge(
346        text, lp, src_sa_l, src_sa_r, src_lcp_l, src_lcp_r, dst_sa, dst_lcp, max_ctx, dispatch,
347    );
348}
349
350/// LCP-enhanced two-way merge of two sorted suffix arrays.
351///
352/// `x` / `lcp_x` and `y` / `lcp_y` must each be sorted with `lcp_*[0] == 0`
353/// and `lcp_*[i] = lcp(arr[i-1], arr[i])` for `i >= 1`. The result is written
354/// into `z` / `lcp_z` (length `x.len() + y.len()`).
355///
356/// Visible to the rest of the crate so the external-memory path can cascade
357/// 2-way merges across each partition's sub-subarrays during Phase 4.
358macro_rules! merge_extension {
359    (direct, $text:expr, $dispatch:expr, $p:expr, $q:expr, $known:expr, $max_ext:expr) => {
360        $dispatch.lcp($text, $p + $known, $q + $known, $max_ext)
361    };
362    ((memo $memo:ident), $text:expr, $dispatch:expr, $p:expr, $q:expr, $known:expr, $max_ext:expr) => {{
363        let probe = $memo.probe($max_ext);
364        let got = $dispatch.lcp($text, $p + $known, $q + $known, probe);
365        if got < probe || probe == $max_ext {
366            got
367        } else {
368            $memo.lcp_after_probe($text, $dispatch, $p, $q, $known, probe, $max_ext)
369        }
370    }};
371    ((memo_profiled $memo:ident), $text:expr, $dispatch:expr, $p:expr, $q:expr, $known:expr, $max_ext:expr) => {{
372        let probe = $memo.probe($max_ext);
373        let got = $dispatch.lcp($text, $p + $known, $q + $known, probe);
374        $memo.record_probe_profiled(got, probe, $max_ext);
375        if got < probe || probe == $max_ext {
376            got
377        } else {
378            $memo.lcp_after_probe_profiled($text, $dispatch, $p, $q, $known, probe, $max_ext)
379        }
380    }};
381    ((training $memo:ident), $text:expr, $dispatch:expr, $p:expr, $q:expr, $known:expr, $max_ext:expr) => {{
382        let got = $dispatch.lcp($text, $p + $known, $q + $known, $max_ext);
383        $memo.observe_training($p, $q, $known, got, $max_ext);
384        got
385    }};
386    ((training_profiled $memo:ident), $text:expr, $dispatch:expr, $p:expr, $q:expr, $known:expr, $max_ext:expr) => {{
387        let got = $dispatch.lcp($text, $p + $known, $q + $known, $max_ext);
388        $memo.observe_training_profiled($p, $q, $known, got, $max_ext);
389        got
390    }};
391}
392
393// Keep the direct and memoized kernels as separate monomorphized functions.
394// A trait/wrapper abstraction measurably perturbed code generation in the
395// original hot loop even when memoization was disabled.  This macro retains a
396// single source of truth while changing only the LCP-extension expression.
397macro_rules! merge_body {
398    ($lookup:tt; $text:ident, $lp:ident, $x:ident, $y:ident, $lcp_x:ident, $lcp_y:ident, $z:ident, $lcp_z:ident, $max_ctx:ident, $dispatch:ident) => {{
399        let len_x = $x.len();
400        let len_y = $y.len();
401        debug_assert_eq!($z.len(), len_x + len_y);
402        debug_assert_eq!($lcp_z.len(), len_x + len_y);
403
404        if len_x == 0 {
405            $z.copy_from_slice($y);
406            $lcp_z.copy_from_slice($lcp_y);
407            return;
408        }
409        if len_y == 0 {
410            $z.copy_from_slice($x);
411            $lcp_z.copy_from_slice($lcp_x);
412            return;
413        }
414
415        // The "swap-on-output-from-B" trick from upstream CaPS-SA: we always
416        // label the stream we last output from as `A`, and the other as `B`.
417        let mut arr_a: &[I] = $x;
418        let mut arr_b: &[I] = $y;
419        let mut lcp_a: &[I] = $lcp_x;
420        let mut lcp_b: &[I] = $lcp_y;
421        let mut len_a = len_x;
422        let mut len_b = len_y;
423        let mut i_a: usize = 0;
424        let mut i_b: usize = 0;
425        let mut m: usize = 0;
426        let mut k: usize = 0;
427        let mut lim_a_cache: Option<(usize, usize)> = None;
428        let mut lim_b_cache: Option<(usize, usize)> = None;
429
430        while i_a < len_a && i_b < len_b {
431            if i_a + PREFETCH_DISTANCE < len_a {
432                prefetch_symbol($text, arr_a[i_a + PREFETCH_DISTANCE].to_usize() + m);
433            }
434            if i_b + PREFETCH_DISTANCE < len_b {
435                prefetch_symbol($text, arr_b[i_b + PREFETCH_DISTANCE].to_usize() + m);
436            }
437
438            let l_a = lcp_a[i_a].to_usize();
439            let (output_a, lcp_for_output, new_m) = if l_a > m {
440                (true, l_a, m)
441            } else if l_a < m {
442                (false, m, l_a)
443            } else {
444                let p_a = arr_a[i_a].to_usize();
445                let p_b = arr_b[i_b].to_usize();
446                let lim_a = match lim_a_cache {
447                    Some((idx, lim)) if idx == i_a => lim,
448                    _ => {
449                        let lim = $lp.lim_at(p_a);
450                        lim_a_cache = Some((i_a, lim));
451                        lim
452                    }
453                };
454                let lim_b = match lim_b_cache {
455                    Some((idx, lim)) if idx == i_b => lim,
456                    _ => {
457                        let lim = $lp.lim_at(p_b);
458                        lim_b_cache = Some((i_b, lim));
459                        lim
460                    }
461                };
462                let cap = lim_a.min(lim_b).min($max_ctx);
463                let remaining_ctx = cap.saturating_sub(m);
464                let ext = merge_extension!($lookup, $text, $dispatch, p_a, p_b, m, remaining_ctx);
465                let total = m + ext;
466                // `cap` includes max_ctx as well as both suffix limits.  If
467                // the scan exhausts max_ctx, comparison is deliberately
468                // truncated and must use the configured boundary tie-break;
469                // reading one more symbol here would disagree with
470                // LcpDispatch::suffix_cmp_with and phase-2 pivot ordering.
471                let a_smaller = if total < cap {
472                    $text[p_a + total] < $text[p_b + total]
473                } else {
474                    $lp.boundary_order(p_a, lim_a, p_b, lim_b).is_lt()
475                };
476                (a_smaller, m, total)
477            };
478
479            if output_a {
480                $z[k] = arr_a[i_a];
481                $lcp_z[k] = I::from_usize(lcp_for_output);
482                i_a += 1;
483                lim_a_cache = None;
484            } else {
485                $z[k] = arr_b[i_b];
486                $lcp_z[k] = I::from_usize(lcp_for_output);
487                i_b += 1;
488                lim_b_cache = None;
489                std::mem::swap(&mut arr_a, &mut arr_b);
490                std::mem::swap(&mut lcp_a, &mut lcp_b);
491                std::mem::swap(&mut len_a, &mut len_b);
492                std::mem::swap(&mut i_a, &mut i_b);
493                std::mem::swap(&mut lim_a_cache, &mut lim_b_cache);
494            }
495            m = new_m;
496            k += 1;
497        }
498
499        drain(arr_a, lcp_a, i_a, len_a, $z, $lcp_z, &mut k, m);
500        drain(arr_b, lcp_b, i_b, len_b, $z, $lcp_z, &mut k, m);
501    }};
502}
503
504#[allow(clippy::too_many_arguments)] // CaPS-SA's merge takes 5 buffers + text + lp + ctx + dispatch
505pub(crate) fn merge<S, I, L>(
506    text: &[S],
507    lp: &L,
508    x: &[I],
509    y: &[I],
510    lcp_x: &[I],
511    lcp_y: &[I],
512    z: &mut [I],
513    lcp_z: &mut [I],
514    max_ctx: usize,
515    dispatch: LcpDispatch,
516) where
517    S: Symbol,
518    I: Index,
519    L: LimitProvider,
520{
521    merge_body!(direct; text, lp, x, y, lcp_x, lcp_y, z, lcp_z, max_ctx, dispatch);
522}
523
524/// Phase-4 variant of [`merge`] that reuses exact LCP intervals discovered by
525/// earlier levels of the same partition cascade.
526#[allow(clippy::too_many_arguments)]
527pub(crate) fn merge_memoized<S, I, L>(
528    text: &[S],
529    lp: &L,
530    x: &[I],
531    y: &[I],
532    lcp_x: &[I],
533    lcp_y: &[I],
534    z: &mut [I],
535    lcp_z: &mut [I],
536    max_ctx: usize,
537    dispatch: LcpDispatch,
538    memo: &mut GeometricMemo,
539) where
540    S: Symbol,
541    I: Index,
542    L: LimitProvider,
543{
544    merge_body!((memo memo); text, lp, x, y, lcp_x, lcp_y, z, lcp_z, max_ctx, dispatch);
545}
546
547/// Instrumented counterpart of [`merge_memoized`]. Selected once per
548/// partition so normal memoized comparisons contain no counter branches.
549#[allow(clippy::too_many_arguments)]
550pub(crate) fn merge_memoized_profiled<S, I, L>(
551    text: &[S],
552    lp: &L,
553    x: &[I],
554    y: &[I],
555    lcp_x: &[I],
556    lcp_y: &[I],
557    z: &mut [I],
558    lcp_z: &mut [I],
559    max_ctx: usize,
560    dispatch: LcpDispatch,
561    memo: &mut GeometricMemo,
562) where
563    S: Symbol,
564    I: Index,
565    L: LimitProvider,
566{
567    merge_body!((memo_profiled memo); text, lp, x, y, lcp_x, lcp_y, z, lcp_z, max_ctx, dispatch);
568}
569
570#[allow(clippy::too_many_arguments)]
571pub(crate) fn merge_memoized_training<S, I, L>(
572    text: &[S],
573    lp: &L,
574    x: &[I],
575    y: &[I],
576    lcp_x: &[I],
577    lcp_y: &[I],
578    z: &mut [I],
579    lcp_z: &mut [I],
580    max_ctx: usize,
581    dispatch: LcpDispatch,
582    memo: &mut GeometricMemo,
583) where
584    S: Symbol,
585    I: Index,
586    L: LimitProvider,
587{
588    merge_body!((training memo); text, lp, x, y, lcp_x, lcp_y, z, lcp_z, max_ctx, dispatch);
589}
590
591#[allow(clippy::too_many_arguments)]
592pub(crate) fn merge_memoized_training_profiled<S, I, L>(
593    text: &[S],
594    lp: &L,
595    x: &[I],
596    y: &[I],
597    lcp_x: &[I],
598    lcp_y: &[I],
599    z: &mut [I],
600    lcp_z: &mut [I],
601    max_ctx: usize,
602    dispatch: LcpDispatch,
603    memo: &mut GeometricMemo,
604) where
605    S: Symbol,
606    I: Index,
607    L: LimitProvider,
608{
609    merge_body!((training_profiled memo); text, lp, x, y, lcp_x, lcp_y, z, lcp_z, max_ctx, dispatch);
610}
611
612#[inline]
613#[allow(clippy::too_many_arguments)] // drain handles both source streams via labelled args
614fn drain<I: Index>(
615    arr: &[I],
616    lcp_src: &[I],
617    mut i: usize,
618    len: usize,
619    z: &mut [I],
620    lcp_z: &mut [I],
621    k: &mut usize,
622    boundary_m: usize,
623) {
624    let mut first = true;
625    while i < len {
626        z[*k] = arr[i];
627        lcp_z[*k] = if first {
628            I::from_usize(boundary_m)
629        } else {
630            lcp_src[i]
631        };
632        first = false;
633        i += 1;
634        *k += 1;
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    /// Brute-force reference suffix array via `sort_by` over byte slices.
643    fn brute_force_sa(text: &[u8]) -> Vec<u32> {
644        let mut sa: Vec<u32> = (0..text.len() as u32).collect();
645        sa.sort_by(|&a, &b| text[a as usize..].cmp(&text[b as usize..]));
646        sa
647    }
648
649    fn assert_matches_brute(text: &[u8]) {
650        let got: Vec<u32> = build_in_memory(text);
651        let want = brute_force_sa(text);
652        assert_eq!(got, want, "mismatch on text {text:?}");
653    }
654
655    /// Run the production kernel and return **both** the suffix array and
656    /// the LCP array it computes as a byproduct.
657    ///
658    /// The public entry points discard the LCP array, but it is not an
659    /// incidental artefact: the next merge level *consumes* it in the
660    /// three-case decision, so a single wrong LCP entry silently reorders
661    /// suffixes at the level above. It therefore needs direct coverage.
662    fn build_sa_and_lcp(text: &[u8], max_ctx: usize) -> (Vec<u32>, Vec<u32>) {
663        let n = text.len();
664        let mut sa: Vec<u32> = (0..n as u32).collect();
665        let mut sa_w = vec![0u32; n];
666        let mut lcp_arr = vec![0u32; n];
667        let mut lcp_w = vec![0u32; n];
668        merge_sort(
669            text,
670            &PlainText::new(n),
671            &mut sa,
672            &mut sa_w,
673            &mut lcp_arr,
674            &mut lcp_w,
675            max_ctx,
676            LcpDispatch::detect(),
677        );
678        (sa, lcp_arr)
679    }
680
681    /// Byte-at-a-time LCP of `text[a..]` and `text[b..]`, capped at `max_ctx`.
682    fn naive_lcp(text: &[u8], a: usize, b: usize, max_ctx: usize) -> usize {
683        let lim = (text.len() - a).min(text.len() - b).min(max_ctx);
684        (0..lim).take_while(|&i| text[a + i] == text[b + i]).count()
685    }
686
687    /// Assert the LCP-array postcondition stated on [`merge_sort`]:
688    /// `lcp[0] == 0` and `lcp[i] == lcp(text[sa[i-1]..], text[sa[i]..])`.
689    fn assert_lcp_valid(text: &[u8], max_ctx: usize) {
690        let (sa, lcp) = build_sa_and_lcp(text, max_ctx);
691        if sa.is_empty() {
692            return;
693        }
694        assert_eq!(lcp[0], 0, "lcp[0] must be 0 (text {text:?})");
695        for i in 1..sa.len() {
696            let want = naive_lcp(text, sa[i - 1] as usize, sa[i] as usize, max_ctx);
697            assert_eq!(
698                lcp[i] as usize,
699                want,
700                "lcp[{i}] wrong for sa[{}]={} vs sa[{i}]={} (text {text:?})",
701                i - 1,
702                sa[i - 1],
703                sa[i],
704            );
705        }
706    }
707
708    #[test]
709    fn lcp_array_matches_naive_on_fixtures() {
710        for text in [
711            b"banana".as_slice(),
712            b"mississippi",
713            b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
714            b"abababababababababababababab",
715            b"a",
716            b"",
717        ] {
718            assert_lcp_valid(text, usize::MAX);
719        }
720    }
721
722    #[test]
723    fn lcp_array_matches_naive_on_random() {
724        use rand::{RngExt, SeedableRng};
725        let mut rng = rand::rngs::StdRng::seed_from_u64(0x1CB0);
726        for &sigma in &[2u8, 4, 6, 255] {
727            for &n in &[2usize, 3, 7, 16, 17, 63, 64, 65, 200, 1000, 5000] {
728                let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..sigma)).collect();
729                assert_lcp_valid(&text, usize::MAX);
730            }
731        }
732    }
733
734    /// Long runs of one symbol are the worst case for the LCP invariant:
735    /// adjacent suffixes share almost everything, so every `lcp[i]` is
736    /// large and an off-by-one is easy to miss.
737    #[test]
738    fn lcp_array_on_long_runs_and_periodic_text() {
739        assert_lcp_valid(&vec![7u8; 2000], usize::MAX);
740        let periodic: Vec<u8> = (0..2000).map(|i| (i % 3) as u8).collect();
741        assert_lcp_valid(&periodic, usize::MAX);
742        // A run embedded in noise, the shape a poly-N genome block has.
743        let mut mixed: Vec<u8> = (0..500).map(|i| (i % 4) as u8).collect();
744        mixed.extend(std::iter::repeat_n(4u8, 1500));
745        mixed.extend((0..500).map(|i| (i % 4) as u8));
746        assert_lcp_valid(&mixed, usize::MAX);
747    }
748
749    #[test]
750    fn lcp_array_respects_max_context() {
751        use rand::{RngExt, SeedableRng};
752        let mut rng = rand::rngs::StdRng::seed_from_u64(0xC7A);
753        for &max_ctx in &[1usize, 2, 4, 16] {
754            for &n in &[64usize, 500] {
755                let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..3u8)).collect();
756                let (sa, lcp) = build_sa_and_lcp(&text, max_ctx);
757                for i in 1..sa.len() {
758                    let want = naive_lcp(&text, sa[i - 1] as usize, sa[i] as usize, max_ctx);
759                    assert_eq!(
760                        lcp[i] as usize, want,
761                        "lcp[{i}] wrong with max_ctx={max_ctx}"
762                    );
763                }
764            }
765        }
766    }
767
768    #[test]
769    fn suffix_array_respects_finite_max_context() {
770        use rand::{RngExt, SeedableRng};
771
772        let dispatch = LcpDispatch::detect();
773        let mut rng = rand::rngs::StdRng::seed_from_u64(0x0F11_7EC7);
774        for &max_ctx in &[0usize, 1, 2, 4, 16] {
775            for &n in &[2usize, 3, 7, 64, 500] {
776                let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..3u8)).collect();
777                let opts = Opts {
778                    max_context: max_ctx,
779                };
780                let got: Vec<u32> = build_in_memory_with_opts(&text, &opts);
781                let mut want: Vec<u32> = (0..n as u32).collect();
782                want.sort_by(|&a, &b| dispatch.suffix_cmp(&text, a as usize, b as usize, max_ctx));
783                assert_eq!(
784                    got, want,
785                    "finite-context SA mismatch (n={n}, max_ctx={max_ctx})"
786                );
787            }
788        }
789    }
790
791    #[test]
792    fn empty_text() {
793        let sa: Vec<u32> = build_in_memory::<u8, u32>(&[]);
794        assert!(sa.is_empty());
795    }
796
797    #[test]
798    fn single_symbol() {
799        let sa: Vec<u32> = build_in_memory(&[7u8]);
800        assert_eq!(sa, vec![0]);
801    }
802
803    #[test]
804    fn banana() {
805        assert_matches_brute(b"banana");
806    }
807
808    #[test]
809    fn mississippi() {
810        assert_matches_brute(b"mississippi");
811    }
812
813    #[test]
814    fn small_distinct_sentinel() {
815        // Alphabet 0..=5 with a unique terminator. Models the
816        // sentinel-transformed STAR text on a tiny example.
817        let text: Vec<u8> = vec![0, 1, 2, 0, 1, 5, 0, 2, 1, 6];
818        let got: Vec<u32> = build_in_memory(&text);
819        let want = brute_force_sa(&text);
820        assert_eq!(got, want);
821    }
822
823    #[test]
824    fn random_byte_texts() {
825        use rand::{RngExt, SeedableRng};
826        let mut rng = rand::rngs::StdRng::seed_from_u64(0xC0FFEE);
827        for &n in &[1usize, 2, 3, 7, 33, 200, 1000] {
828            let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..6u8)).collect();
829            let got: Vec<u32> = build_in_memory(&text);
830            let want = brute_force_sa(&text);
831            assert_eq!(got, want, "mismatch on random text len={n}");
832        }
833    }
834
835    #[test]
836    fn for_positions_full_set_matches_build_in_memory() {
837        // Same output as build_in_memory when positions is the identity.
838        let text = b"banana";
839        let want: Vec<u32> = build_in_memory(text);
840        let positions: Vec<u32> = (0..text.len() as u32).collect();
841        let got = build_in_memory_for_positions(text, positions);
842        assert_eq!(got, want);
843    }
844
845    #[test]
846    fn for_positions_subset_matches_brute_force() {
847        // Sort only the even positions of "mississippi" by their
848        // suffixes; verify against brute force.
849        let text = b"mississippi";
850        let positions: Vec<u32> = (0..text.len() as u32).step_by(2).collect();
851        let mut want = positions.clone();
852        want.sort_by(|&a, &b| text[a as usize..].cmp(&text[b as usize..]));
853        let got = build_in_memory_for_positions(text, positions);
854        assert_eq!(got, want);
855    }
856
857    #[test]
858    fn for_positions_random_subsets() {
859        use rand::{RngExt, SeedableRng};
860        let mut rng = rand::rngs::StdRng::seed_from_u64(0xFEED);
861        for &n in &[33usize, 200, 1000] {
862            let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..6u8)).collect();
863            // Random subset of positions.
864            let mut positions: Vec<u32> = (0..n as u32).collect();
865            // Drop a random ~30%.
866            positions.retain(|_| rng.random_range(0..10) < 7);
867            let mut want = positions.clone();
868            want.sort_by(|&a, &b| text[a as usize..].cmp(&text[b as usize..]));
869            let got = build_in_memory_for_positions(&text, positions);
870            assert_eq!(got, want, "subset sort mismatch n={n}");
871        }
872    }
873
874    #[test]
875    fn random_with_unique_terminator() {
876        // Distinct large terminator at the end — mimics the transform we'll
877        // apply for STAR.
878        use rand::{RngExt, SeedableRng};
879        let mut rng = rand::rngs::StdRng::seed_from_u64(0xBEEF);
880        for &n in &[1usize, 50, 500] {
881            let mut text: Vec<u8> = (0..n).map(|_| rng.random_range(0..5u8)).collect();
882            text.push(250); // unique max
883            let got: Vec<u32> = build_in_memory(&text);
884            let want = brute_force_sa(&text);
885            assert_eq!(got, want);
886        }
887    }
888
889    // ---- segmented SA tests ----
890
891    use crate::limits::SegmentedText;
892
893    /// Compare two suffixes under the segmented comparator (LCP
894    /// truncated at the boundary, "shorter-is-smaller" tie-break).
895    fn segmented_cmp(text: &[u8], lp: &SegmentedText, a: usize, b: usize) -> std::cmp::Ordering {
896        use crate::limits::LimitProvider;
897        let lim_a = lp.lim_at(a);
898        let lim_b = lp.lim_at(b);
899        let lim = lim_a.min(lim_b);
900        for i in 0..lim {
901            if text[a + i] != text[b + i] {
902                return text[a + i].cmp(&text[b + i]);
903            }
904        }
905        lim_a.cmp(&lim_b)
906    }
907
908    /// Assert that `sa` is a valid segmented SA over `text` partitioned
909    /// by `lengths`:
910    ///
911    /// 1. it's a permutation of the positions in `positions`, and
912    /// 2. every adjacent pair is in non-decreasing comparator order.
913    ///
914    /// Comparator-equivalent suffixes can appear in any relative order —
915    /// caps-sa's merge isn't a stable sort, so we don't pin a canonical
916    /// permutation.
917    fn assert_segmented_sa_valid(text: &[u8], lengths: &[usize], positions: &[u32], sa: &[u32]) {
918        let lp = SegmentedText::from_lengths(text.len(), lengths);
919        let mut expected = positions.to_vec();
920        expected.sort();
921        let mut got_sorted = sa.to_vec();
922        got_sorted.sort();
923        assert_eq!(got_sorted, expected, "sa is not a permutation of positions");
924        for w in sa.windows(2) {
925            let a = w[0] as usize;
926            let b = w[1] as usize;
927            let ord = segmented_cmp(text, &lp, a, b);
928            assert_ne!(
929                ord,
930                std::cmp::Ordering::Greater,
931                "out of order: pos {a} > pos {b} under segmented comparator",
932            );
933        }
934    }
935
936    #[test]
937    fn segmented_in_memory_matches_brute_force_small() {
938        // 4 segments: "hello" | "world" | "banana" | "mississippi"
939        let text: Vec<u8> = b"helloworldbananamississippi".to_vec();
940        let lengths = &[5usize, 5, 6, 11];
941        let lp = SegmentedText::from_lengths(text.len(), lengths);
942        let sa: Vec<u32> = build_in_memory_with(&text, &lp, &Opts::default());
943        let all_positions: Vec<u32> = (0..text.len() as u32).collect();
944        assert_segmented_sa_valid(&text, lengths, &all_positions, &sa);
945    }
946
947    #[test]
948    fn segmented_single_segment_equals_unsegmented() {
949        // A single segment covering the whole text is the same as the
950        // non-segmented SA — confirms the LimitProvider path doesn't
951        // perturb the standard order when there's nothing to truncate.
952        let text = b"mississippi";
953        let lp = SegmentedText::from_lengths(text.len(), &[text.len()]);
954        let got_segmented: Vec<u32> = build_in_memory_with(text, &lp, &Opts::default());
955        let got_plain: Vec<u32> = build_in_memory(text);
956        assert_eq!(got_segmented, got_plain);
957    }
958
959    #[test]
960    fn segmented_random_validity() {
961        use rand::{RngExt, SeedableRng};
962        let mut rng = rand::rngs::StdRng::seed_from_u64(0x5E6);
963        for _ in 0..20 {
964            let n_segments = rng.random_range(1..10usize);
965            let lengths: Vec<usize> = (0..n_segments)
966                .map(|_| rng.random_range(5..50usize))
967                .collect();
968            let n: usize = lengths.iter().sum();
969            // Small alphabet so the LCP-truncation case actually fires.
970            let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..3u8)).collect();
971            let lp = SegmentedText::from_lengths(n, &lengths);
972            let sa: Vec<u32> = build_in_memory_with(&text, &lp, &Opts::default());
973            let all_positions: Vec<u32> = (0..n as u32).collect();
974            assert_segmented_sa_valid(&text, &lengths, &all_positions, &sa);
975        }
976    }
977
978    #[test]
979    fn segmented_for_positions_subset_validity() {
980        // Filter to even positions only, sort with segmentation.
981        let text: Vec<u8> = b"helloworldbananamississippi".to_vec();
982        let lengths = &[5usize, 5, 6, 11];
983        let positions: Vec<u32> = (0..text.len() as u32).step_by(2).collect();
984        let lp = SegmentedText::from_lengths(text.len(), lengths);
985        let sa =
986            build_in_memory_for_positions_with(&text, positions.clone(), &lp, &Opts::default());
987        assert_segmented_sa_valid(&text, lengths, &positions, &sa);
988    }
989
990    // ---- STAR-convention boundary_order tests ----
991
992    /// A `LimitProvider` wrapping [`SegmentedText`] with STAR's
993    /// `spacer-as-largest` boundary semantics: the suffix that hits
994    /// its limit first is *larger*, equivalently the longer-`lim`
995    /// suffix is smaller, with an ascending-position tie-break when
996    /// `lim_a == lim_b`. Used by rustar-aligner's `sa_build` to keep
997    /// byte-for-byte STAR compatibility on the segmented arm.
998    struct StarConvention {
999        inner: SegmentedText,
1000    }
1001
1002    impl crate::limits::LimitProvider for StarConvention {
1003        fn lim_at(&self, p: usize) -> usize {
1004            self.inner.lim_at(p)
1005        }
1006        fn boundary_order(
1007            &self,
1008            p_a: usize,
1009            lim_a: usize,
1010            p_b: usize,
1011            lim_b: usize,
1012        ) -> std::cmp::Ordering {
1013            lim_b.cmp(&lim_a).then(p_a.cmp(&p_b))
1014        }
1015    }
1016
1017    /// Brute-force SA under STAR's convention (longer-lim is smaller,
1018    /// position tie-break). Used as the oracle for the differential
1019    /// test. With a position tie-break the SA is uniquely determined,
1020    /// so this can be compared with `assert_eq!`.
1021    fn star_brute_force_sa(text: &[u8], lengths: &[usize]) -> Vec<u32> {
1022        use crate::limits::LimitProvider;
1023        let lp = SegmentedText::from_lengths(text.len(), lengths);
1024        let mut sa: Vec<u32> = (0..text.len() as u32).collect();
1025        sa.sort_by(|&a, &b| {
1026            let pa = a as usize;
1027            let pb = b as usize;
1028            let lim_a = lp.lim_at(pa);
1029            let lim_b = lp.lim_at(pb);
1030            let lim = lim_a.min(lim_b);
1031            for i in 0..lim {
1032                if text[pa + i] != text[pb + i] {
1033                    return text[pa + i].cmp(&text[pb + i]);
1034                }
1035            }
1036            // STAR convention: longer-lim is smaller, then position.
1037            lim_b.cmp(&lim_a).then(pa.cmp(&pb))
1038        });
1039        sa
1040    }
1041
1042    #[test]
1043    fn star_convention_matches_brute_force_small() {
1044        // 4 segments: "hello" | "world" | "banana" | "mississippi"
1045        let text: Vec<u8> = b"helloworldbananamississippi".to_vec();
1046        let lengths = &[5usize, 5, 6, 11];
1047        let lp = StarConvention {
1048            inner: SegmentedText::from_lengths(text.len(), lengths),
1049        };
1050        let got: Vec<u32> = build_in_memory_with(&text, &lp, &Opts::default());
1051        let want = star_brute_force_sa(&text, lengths);
1052        assert_eq!(got, want, "STAR-convention SA mismatch");
1053    }
1054
1055    /// Exercises the STAR-specific within-segment longer-is-smaller
1056    /// case: in "AAAA" with one segment, STAR orders the longest
1057    /// suffix first (`AAAA < AAA < AA < A`) — opposite of the
1058    /// standard SA's `A < AA < AAA < AAAA`.
1059    #[test]
1060    fn star_convention_within_segment_longer_first() {
1061        let text = b"AAAA";
1062        let lp = StarConvention {
1063            inner: SegmentedText::from_lengths(text.len(), &[text.len()]),
1064        };
1065        let got: Vec<u32> = build_in_memory_with(text, &lp, &Opts::default());
1066        // Position 0 = "AAAA" (lim 4), 1 = "AAA" (lim 3), 2 = "AA"
1067        // (lim 2), 3 = "A" (lim 1). Longer-lim is smaller, so 0 < 1
1068        // < 2 < 3.
1069        assert_eq!(got, vec![0u32, 1, 2, 3]);
1070    }
1071
1072    #[test]
1073    fn star_convention_random_matches_brute_force() {
1074        use rand::{RngExt, SeedableRng};
1075        let mut rng = rand::rngs::StdRng::seed_from_u64(0xCAFE);
1076        for _ in 0..20 {
1077            let n_segments = rng.random_range(1..10usize);
1078            let lengths: Vec<usize> = (0..n_segments)
1079                .map(|_| rng.random_range(5..50usize))
1080                .collect();
1081            let n: usize = lengths.iter().sum();
1082            // Small alphabet so the boundary-tie-break case fires.
1083            let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..3u8)).collect();
1084            let lp = StarConvention {
1085                inner: SegmentedText::from_lengths(n, &lengths),
1086            };
1087            let got: Vec<u32> = build_in_memory_with(&text, &lp, &Opts::default());
1088            let want = star_brute_force_sa(&text, &lengths);
1089            assert_eq!(
1090                got, want,
1091                "STAR-convention SA mismatch (lengths={lengths:?}, text={text:?})",
1092            );
1093        }
1094    }
1095}