Skip to main content

caps_sa/
ext_mem.rs

1//! External-memory suffix array construction.
2//!
3//! Implements upstream CaPS-SA's *sample-sort + LCP-enhanced merge*
4//! external-memory algorithm:
5//!
6//! 1. **Presample pivots.** Sort a small deterministic sample and pick
7//!    `p − 1` pivots at evenly-spaced ranks, splitting the suffix order into
8//!    `p` partitions.
9//! 2. **Sort + distribute.** Split the selected positions into `p` subarrays.
10//!    In parallel, sort each with a task-local LCP merge-sort, split it by the
11//!    pivots, and write each sorted slice directly to its final partition
12//!    bucket. The complete intermediate subarray spill is avoided.
13//! 3. **Per-partition merge.** Load each partition's bucket into RAM
14//!    (≈ `n / p` records); cascade 2-way LCP-enhanced merges across the
15//!    `p` sub-subarrays to produce that partition's globally-sorted slice.
16//! 4. **Stream output.** Iterate partitions in order and emit each
17//!    position via the caller's closure.
18//!
19//! Peak RAM is the text plus bounded per-worker phase-1 scratch and in-flight
20//! `O(n / p)` partition merge buffers. The full suffix array is never
21//! materialized in RAM.
22
23use std::cmp::Ordering;
24use std::io;
25use std::num::NonZeroUsize;
26use std::path::{Path, PathBuf};
27use std::sync::Mutex;
28use std::time::Instant;
29
30use rayon::prelude::*;
31
32use crate::Index;
33use crate::ext_bucket::{BucketPool, BucketRecord, InMemBucket, SaLcp, SaLcpBucketStore};
34use crate::lcp::{LcpDispatch, Symbol};
35use crate::lcp_memo::{
36    GeometricMemo, GeometricMemoizationConfig, LcpMemoizationPolicy, MemoConfig, MemoStats,
37};
38use crate::limits::{LimitProvider, PlainText};
39use crate::sample_sort;
40
41/// Emit a phase-timing line to stderr if `CAPS_SA_PROFILE` is set in
42/// the environment. Used to localise where the ext-mem path spends its
43/// time without paying the cost of always logging — see
44/// `bench/README.md` "Where AVX-512 helps and where it doesn't" for
45/// how this is used.
46fn profile_log(message: &str) {
47    if std::env::var_os("CAPS_SA_PROFILE").is_some() {
48        eprintln!("caps-sa profile  {message}");
49    }
50}
51
52/// Tunable options for [`build_ext_mem`].
53///
54/// This structure is non-exhaustive so future releases can add options
55/// without repeatedly breaking callers that use struct literals. Start from
56/// [`Self::default`] or [`Self::from_env`] and use the builder methods.
57#[non_exhaustive]
58#[derive(Clone, Debug)]
59pub struct ExtMemOpts {
60    /// Bound on suffix-comparison context. Comparisons equal through this many
61    /// symbols are resolved by [`LimitProvider::boundary_order`], so
62    /// `usize::MAX` (the default) is required for ordinary full lexicographic
63    /// ordering unless the caller deliberately wants truncated contexts.
64    pub max_context: usize,
65    /// Number of subarrays (`p` in upstream CaPS-SA). `0` (default) targets
66    /// roughly 65,536 selected positions per subarray, clamped to at least one
67    /// subarray per Rayon worker and at most 8,192 (and never above `n`).
68    pub subproblem_count: usize,
69    /// Directory for temp files. Defaults to [`std::env::temp_dir`].
70    pub work_dir: PathBuf,
71    /// Number of physical files backing the final partition buckets. `0`
72    /// (default) picks `rayon::current_num_threads()` —
73    /// the right answer in practice: one writable inode per worker
74    /// keeps kernel-level write contention bounded.
75    ///
76    /// The `p` logical buckets (typically thousands at genome scale) collapse
77    /// onto this pool of anonymous tempfiles via
78    /// `bucket_id % physical_file_count`. Larger values lower kernel
79    /// write contention; smaller values are kinder to networked
80    /// filesystems with high metadata cost. The `CAPS_SA_N_PHYS` env
81    /// var overrides this for one-off benches.
82    pub physical_file_count: usize,
83    /// Use the bounded ordered phase-4 emitter instead of the default
84    /// chunk collect-then-emit path. This can reduce transient merged
85    /// partition residency on skewed workloads, but is slower on the
86    /// GRCh38 32-thread benchmark because of channel coordination and
87    /// backpressure, so it is opt-in.
88    pub ordered_phase4_emit: bool,
89    /// Policy for reusing exact long-LCP intervals during phase-4 partition
90    /// merges. Disabled by default; callers with long repeated contexts can
91    /// opt into [`LcpMemoizationPolicy::Geometric`].
92    pub lcp_memoization: LcpMemoizationPolicy,
93    // Diagnostic-only implementation detail selected by `from_env()`. It is
94    // intentionally not public API: counters are emitted to the profiling log
95    // rather than returned to callers, and their shape may evolve freely.
96    collect_lcp_memoization_stats: bool,
97}
98
99impl Default for ExtMemOpts {
100    fn default() -> Self {
101        Self {
102            max_context: usize::MAX,
103            subproblem_count: 0,
104            work_dir: std::env::temp_dir(),
105            physical_file_count: 0,
106            ordered_phase4_emit: false,
107            lcp_memoization: LcpMemoizationPolicy::Disabled,
108            collect_lcp_memoization_stats: false,
109        }
110    }
111}
112
113impl ExtMemOpts {
114    /// Convenience constructor with the supplied `work_dir` and defaults
115    /// for everything else.
116    pub fn with_work_dir(work_dir: impl AsRef<Path>) -> Self {
117        Self {
118            work_dir: work_dir.as_ref().to_path_buf(),
119            ..Self::default()
120        }
121    }
122
123    /// Defaults plus caps-sa environment overrides.
124    ///
125    /// Recognised variables:
126    /// - `CAPS_SA_WORK_DIR` / `CAPS_SA_TMPDIR`: temp-file directory
127    /// - `CAPS_SA_SUBPROBLEMS`: subarray count (`p`)
128    /// - `CAPS_SA_N_PHYS`: physical backing-file count
129    /// - `CAPS_SA_MAX_CONTEXT`: LCP comparison cap
130    /// - `CAPS_SA_ORDERED_PHASE4=1|true|yes|on`: bounded ordered phase-4 emit
131    /// - `CAPS_SA_GEOMETRIC_MEMO=1|true|yes|on`: geometric LCP memoization
132    /// - `CAPS_SA_MEMO_PROBE`: ordinary symbols compared before table lookup
133    /// - `CAPS_SA_MEMO_MIN_LCP`: minimum exact LCP admitted to the table
134    /// - `CAPS_SA_MEMO_ACTIVATE_ENTRIES`: entries learned before table lookup
135    /// - `CAPS_SA_MEMO_CAPACITY`: maximum entries per partition table
136    /// - `CAPS_SA_MEMO_STATS=1|true|yes|on`: detailed memoization counters
137    ///
138    /// Invalid and zero-valued numeric overrides are ignored, preserving the
139    /// corresponding defaults. Memoization tuning variables take effect only
140    /// when `CAPS_SA_GEOMETRIC_MEMO` enables the policy.
141    pub fn from_env() -> Self {
142        let mut opts = Self::default();
143        if let Some(dir) =
144            std::env::var_os("CAPS_SA_WORK_DIR").or_else(|| std::env::var_os("CAPS_SA_TMPDIR"))
145        {
146            opts.work_dir = PathBuf::from(dir);
147        }
148        if let Some(v) = read_env_usize("CAPS_SA_SUBPROBLEMS") {
149            opts.subproblem_count = v;
150        }
151        if let Some(v) = read_env_usize("CAPS_SA_N_PHYS") {
152            opts.physical_file_count = v;
153        }
154        if let Some(v) = read_env_usize("CAPS_SA_MAX_CONTEXT") {
155            opts.max_context = v;
156        }
157        if read_env_bool("CAPS_SA_ORDERED_PHASE4") {
158            opts.ordered_phase4_emit = true;
159        }
160        if read_env_bool("CAPS_SA_GEOMETRIC_MEMO") {
161            let mut config = GeometricMemoizationConfig::default();
162            if let Some(v) = read_env_nonzero_usize("CAPS_SA_MEMO_PROBE") {
163                config = config.with_probe_symbols(v);
164            }
165            if let Some(v) = read_env_nonzero_usize("CAPS_SA_MEMO_MIN_LCP") {
166                config = config.with_min_lcp_symbols(v);
167            }
168            if let Some(v) = read_env_nonzero_usize("CAPS_SA_MEMO_ACTIVATE_ENTRIES") {
169                config = config.with_activate_after_entries(v);
170            }
171            if let Some(v) = read_env_nonzero_usize("CAPS_SA_MEMO_CAPACITY") {
172                config = config.with_max_entries_per_partition(v);
173            }
174            opts.lcp_memoization = LcpMemoizationPolicy::Geometric(config);
175        }
176        opts.collect_lcp_memoization_stats = read_env_bool("CAPS_SA_MEMO_STATS");
177        opts
178    }
179
180    /// Builder-style setter for [`Self::max_context`].
181    pub fn max_context(mut self, max_context: usize) -> Self {
182        self.max_context = max_context;
183        self
184    }
185
186    /// Builder-style setter for [`Self::subproblem_count`].
187    pub fn subproblem_count(mut self, subproblem_count: usize) -> Self {
188        self.subproblem_count = subproblem_count;
189        self
190    }
191
192    /// Builder-style setter for [`Self::work_dir`].
193    pub fn work_dir(mut self, work_dir: impl AsRef<Path>) -> Self {
194        self.work_dir = work_dir.as_ref().to_path_buf();
195        self
196    }
197
198    /// Builder-style setter for [`Self::physical_file_count`].
199    pub fn physical_file_count(mut self, physical_file_count: usize) -> Self {
200        self.physical_file_count = physical_file_count;
201        self
202    }
203
204    /// Builder-style setter for [`Self::ordered_phase4_emit`].
205    pub fn ordered_phase4_emit(mut self, ordered_phase4_emit: bool) -> Self {
206        self.ordered_phase4_emit = ordered_phase4_emit;
207        self
208    }
209
210    /// Builder-style setter for [`Self::lcp_memoization`].
211    pub fn lcp_memoization(mut self, lcp_memoization: impl Into<LcpMemoizationPolicy>) -> Self {
212        self.lcp_memoization = lcp_memoization.into();
213        self
214    }
215}
216
217fn read_env_usize(name: &str) -> Option<usize> {
218    std::env::var(name).ok()?.parse().ok()
219}
220
221fn read_env_nonzero_usize(name: &str) -> Option<NonZeroUsize> {
222    NonZeroUsize::new(read_env_usize(name)?)
223}
224
225fn read_env_bool(name: &str) -> bool {
226    std::env::var(name)
227        .ok()
228        .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
229}
230
231/// Error type for `try_*` builders whose output callback can return
232/// an application-specific error.
233#[derive(Debug)]
234pub enum BuildError<E> {
235    /// Temporary-file or bucket I/O failed inside caps-sa.
236    Io(io::Error),
237    /// The caller's output callback rejected an emitted suffix-array
238    /// position.
239    Emit(E),
240}
241
242impl<E> From<io::Error> for BuildError<E> {
243    fn from(err: io::Error) -> Self {
244        Self::Io(err)
245    }
246}
247
248fn into_io_result(result: Result<(), BuildError<io::Error>>) -> io::Result<()> {
249    match result {
250        Ok(()) => Ok(()),
251        Err(BuildError::Io(err) | BuildError::Emit(err)) => Err(err),
252    }
253}
254
255/// Build the suffix array of `text` with bounded RAM, streaming each
256/// output position to `emit` in lexicographic order.
257///
258/// Returns an [`io::Error`] if temp-file I/O fails. The callback may also
259/// return an error to abort construction; partial work is discarded and
260/// temp files are cleaned up when their bucket drops.
261///
262/// Equivalent in semantics to [`crate::build_in_memory`]: produces a
263/// standard lexicographic suffix array with the "shorter suffix is
264/// smaller when one runs off the end" tie-break.
265pub fn build_ext_mem<S, F>(text: &[S], opts: &ExtMemOpts, emit: F) -> io::Result<()>
266where
267    S: Symbol,
268    F: FnMut(u64) -> io::Result<()>,
269{
270    into_io_result(try_build_ext_mem(text, opts, emit))
271}
272
273/// Generic-error variant of [`build_ext_mem`].
274pub fn try_build_ext_mem<S, E, F>(
275    text: &[S],
276    opts: &ExtMemOpts,
277    emit: F,
278) -> Result<(), BuildError<E>>
279where
280    S: Symbol,
281    F: FnMut(u64) -> Result<(), E>,
282{
283    try_build_ext_mem_with(text, &PlainText::new(text.len()), opts, emit)
284}
285
286/// Variant of [`build_ext_mem`] that accepts a [`LimitProvider`]. With
287/// [`PlainText`] this matches [`build_ext_mem`] exactly (and
288/// monomorphizes to identical assembly); with
289/// [`SegmentedText`][crate::limits::SegmentedText] the LCP scans stop
290/// at segment boundaries.
291pub fn build_ext_mem_with<S, L, F>(text: &[S], lp: &L, opts: &ExtMemOpts, emit: F) -> io::Result<()>
292where
293    S: Symbol,
294    L: LimitProvider,
295    F: FnMut(u64) -> io::Result<()>,
296{
297    into_io_result(try_build_ext_mem_with(text, lp, opts, emit))
298}
299
300/// Generic-error variant of [`build_ext_mem_with`].
301pub fn try_build_ext_mem_with<S, L, E, F>(
302    text: &[S],
303    lp: &L,
304    opts: &ExtMemOpts,
305    emit: F,
306) -> Result<(), BuildError<E>>
307where
308    S: Symbol,
309    L: LimitProvider,
310    F: FnMut(u64) -> Result<(), E>,
311{
312    // Dispatch on text size: when every suffix position fits in `u32`
313    // (n ≤ 2^32), use the narrow record type to halve all the SaLcp
314    // bytes — bucket disk I/O, phase-1 records, and the per-partition
315    // load in phase 4.
316    if text.len() <= u32::MAX as usize + 1 {
317        build_ext_mem_inner::<S, u32, L, E, F>(
318            text,
319            PositionSource::Identity(text.len()),
320            lp,
321            opts,
322            emit,
323        )
324    } else {
325        build_ext_mem_inner::<S, u64, L, E, F>(
326            text,
327            PositionSource::Identity(text.len()),
328            lp,
329            opts,
330            emit,
331        )
332    }
333}
334
335/// Like [`build_ext_mem`] but sorts only the caller-supplied
336/// `positions` by the lexicographic order of their suffixes in `text`.
337/// Suffix content is always `text[position..]`; no positions are
338/// dropped from the input. To filter, the caller constructs
339/// `positions` with only the indices they want.
340///
341/// This lets STAR-style genome indexing skip the bin-padding
342/// pathology: pass only the ACGT-starting positions and the
343/// spacer-suffix work disappears.
344///
345/// `positions` does not need to be pre-sorted in any way.
346pub fn build_ext_mem_for_positions<S, F>(
347    text: &[S],
348    positions: Vec<u64>,
349    opts: &ExtMemOpts,
350    emit: F,
351) -> io::Result<()>
352where
353    S: Symbol,
354    F: FnMut(u64) -> io::Result<()>,
355{
356    into_io_result(try_build_ext_mem_for_positions(text, positions, opts, emit))
357}
358
359/// Generic-error variant of [`build_ext_mem_for_positions`].
360pub fn try_build_ext_mem_for_positions<S, E, F>(
361    text: &[S],
362    positions: Vec<u64>,
363    opts: &ExtMemOpts,
364    emit: F,
365) -> Result<(), BuildError<E>>
366where
367    S: Symbol,
368    F: FnMut(u64) -> Result<(), E>,
369{
370    try_build_ext_mem_for_positions_with(text, positions, &PlainText::new(text.len()), opts, emit)
371}
372
373/// Variant of [`build_ext_mem_for_positions`] that accepts a
374/// [`LimitProvider`]. See [`build_ext_mem_with`] for the semantics.
375pub fn build_ext_mem_for_positions_with<S, L, F>(
376    text: &[S],
377    positions: Vec<u64>,
378    lp: &L,
379    opts: &ExtMemOpts,
380    emit: F,
381) -> io::Result<()>
382where
383    S: Symbol,
384    L: LimitProvider,
385    F: FnMut(u64) -> io::Result<()>,
386{
387    into_io_result(try_build_ext_mem_for_positions_with(
388        text, positions, lp, opts, emit,
389    ))
390}
391
392/// Generic-error variant of [`build_ext_mem_for_positions_with`].
393pub fn try_build_ext_mem_for_positions_with<S, L, E, F>(
394    text: &[S],
395    positions: Vec<u64>,
396    lp: &L,
397    opts: &ExtMemOpts,
398    emit: F,
399) -> Result<(), BuildError<E>>
400where
401    S: Symbol,
402    L: LimitProvider,
403    F: FnMut(u64) -> Result<(), E>,
404{
405    // We hold a reference to `positions` for the duration of the build;
406    // `phase1_sort_sample_spill` copies each chunk out. The Vec is
407    // dropped once phase 1 returns.
408    if text.len() <= u32::MAX as usize + 1 {
409        build_ext_mem_inner::<S, u32, L, E, F>(
410            text,
411            PositionSource::Subset(&positions),
412            lp,
413            opts,
414            emit,
415        )
416    } else {
417        build_ext_mem_inner::<S, u64, L, E, F>(
418            text,
419            PositionSource::Subset(&positions),
420            lp,
421            opts,
422            emit,
423        )
424    }
425}
426
427/// Like [`build_ext_mem_for_positions`] but takes a **predicate** over
428/// text positions instead of a pre-materialised `Vec<u64>` of kept
429/// positions.
430///
431/// caps-sa walks the predicate **once** to build a bitmap of kept
432/// positions + a tiny per-block popcount prefix-sum (together ~`n / 8`
433/// bytes — ~770 MB on the human genome, vs the ~50 GB the equivalent
434/// `Vec<u64>` would take). Phase 1's per-subarray fill is then driven
435/// by popcount-walking the bitmap; the predicate is **never invoked
436/// again** after the initial build. See the crate-internal
437/// `FilteredSource` for the memory accounting and the inner loop.
438///
439/// Use this entry when the caller already has the text in RAM and
440/// the kept positions are described by a cheap per-position
441/// predicate (e.g. STAR's `text[p] < 4` for ACGT-only suffix
442/// sampling). It is **the right entry for genome-scale inputs** —
443/// the `Vec<u64>` path can dominate peak RSS otherwise.
444///
445/// `keep` is invoked from rayon worker threads in parallel during
446/// the bitmap build; it must be `Send + Sync` (typically a plain
447/// closure capturing only `&[u8]` references is fine).
448pub fn build_ext_mem_for_filter<S, F, Pred>(
449    text: &[S],
450    keep: Pred,
451    opts: &ExtMemOpts,
452    emit: F,
453) -> io::Result<()>
454where
455    S: Symbol,
456    F: FnMut(u64) -> io::Result<()>,
457    Pred: Fn(u64) -> bool + Send + Sync,
458{
459    into_io_result(try_build_ext_mem_for_filter(text, keep, opts, emit))
460}
461
462/// Generic-error variant of [`build_ext_mem_for_filter`].
463pub fn try_build_ext_mem_for_filter<S, E, F, Pred>(
464    text: &[S],
465    keep: Pred,
466    opts: &ExtMemOpts,
467    emit: F,
468) -> Result<(), BuildError<E>>
469where
470    S: Symbol,
471    F: FnMut(u64) -> Result<(), E>,
472    Pred: Fn(u64) -> bool + Send + Sync,
473{
474    try_build_ext_mem_for_filter_with(text, keep, &PlainText::new(text.len()), opts, emit)
475}
476
477/// Variant of [`build_ext_mem_for_filter`] that accepts a
478/// [`LimitProvider`]. See [`build_ext_mem_with`] for the semantics.
479pub fn build_ext_mem_for_filter_with<S, L, F, Pred>(
480    text: &[S],
481    keep: Pred,
482    lp: &L,
483    opts: &ExtMemOpts,
484    emit: F,
485) -> io::Result<()>
486where
487    S: Symbol,
488    L: LimitProvider,
489    F: FnMut(u64) -> io::Result<()>,
490    Pred: Fn(u64) -> bool + Send + Sync,
491{
492    into_io_result(try_build_ext_mem_for_filter_with(
493        text, keep, lp, opts, emit,
494    ))
495}
496
497/// Generic-error variant of [`build_ext_mem_for_filter_with`].
498pub fn try_build_ext_mem_for_filter_with<S, L, E, F, Pred>(
499    text: &[S],
500    keep: Pred,
501    lp: &L,
502    opts: &ExtMemOpts,
503    emit: F,
504) -> Result<(), BuildError<E>>
505where
506    S: Symbol,
507    L: LimitProvider,
508    F: FnMut(u64) -> Result<(), E>,
509    Pred: Fn(u64) -> bool + Send + Sync,
510{
511    let filtered = FilteredSource::new(text.len(), keep);
512    if text.len() <= u32::MAX as usize + 1 {
513        build_ext_mem_inner::<S, u32, L, E, F>(
514            text,
515            PositionSource::Filtered(filtered),
516            lp,
517            opts,
518            emit,
519        )
520    } else {
521        build_ext_mem_inner::<S, u64, L, E, F>(
522            text,
523            PositionSource::Filtered(filtered),
524            lp,
525            opts,
526            emit,
527        )
528    }
529}
530
531fn build_ext_mem_inner<S, I, L, E, F>(
532    text: &[S],
533    source: PositionSource<'_>,
534    lp: &L,
535    opts: &ExtMemOpts,
536    mut emit: F,
537) -> Result<(), BuildError<E>>
538where
539    S: Symbol,
540    I: Index,
541    L: LimitProvider,
542    SaLcp<I>: BucketRecord,
543    F: FnMut(u64) -> Result<(), E>,
544{
545    let n = source.len();
546    if n == 0 {
547        return Ok(());
548    }
549    let p = effective_subproblem_count(n, opts.subproblem_count);
550    let dispatch = LcpDispatch::detect();
551    let work_dir = opts.work_dir.clone();
552
553    // The fused phase 1 writes directly to partition buckets, so this path
554    // needs only one file pool. The previous subarray pool held a complete
555    // spilled copy that phase 3 read and wrote again before the final merge.
556    let n_phys = effective_physical_file_count(opts.physical_file_count);
557    let phase3_pool = BucketPool::new(n_phys, &work_dir)?;
558
559    profile_log(&format!(
560        "build_ext_mem n={n} p={p} index_width={}b n_phys={n_phys}",
561        std::mem::size_of::<I>() * 8
562    ));
563
564    let part_factory = |j: usize| phase3_pool.new_bucket::<SaLcp<I>>(j);
565
566    let t = Instant::now();
567    let pivots = phase0_presample_pivots::<S, I, L>(text, lp, &source, p, opts, dispatch);
568    profile_log(&format!(
569        "phase0 (presample pivots)  {:.3}s",
570        t.elapsed().as_secs_f64()
571    ));
572
573    let t = Instant::now();
574    let mut partition_buckets = phase1_sort_and_distribute::<S, I, L, _, _>(
575        text,
576        lp,
577        &source,
578        &pivots,
579        p,
580        opts,
581        dispatch,
582        part_factory,
583    )?;
584    profile_log(&format!(
585        "phase1 (sort+distribute)   {:.3}s",
586        t.elapsed().as_secs_f64()
587    ));
588
589    // Drop the position source as soon as phase 1 returns. For
590    // `PositionSource::Subset` this frees
591    // the caller's `Vec<u64>` (e.g. ~47 GB on a human-scale
592    // _for_positions build); for `PositionSource::Filtered` it
593    // frees the bitmap + cumsum (~770 MB); for `Identity` it's a
594    // no-op. Phase 4 needs only the text and partition buckets.
595    drop(source);
596
597    let t = Instant::now();
598    let result = phase4_merge_and_emit::<S, I, L, _, E, F>(
599        text,
600        lp,
601        &mut partition_buckets,
602        opts.max_context,
603        opts.ordered_phase4_emit,
604        memo_config(opts.lcp_memoization),
605        opts.collect_lcp_memoization_stats,
606        &mut emit,
607        dispatch,
608    );
609    profile_log(&format!(
610        "phase4 (merge+emit)          {:.3}s",
611        t.elapsed().as_secs_f64()
612    ));
613    result
614}
615
616/// Same algorithm as [`build_ext_mem_inner`] but with the disk-backed
617/// [`ExtMemBucket`] replaced by [`InMemBucket`] throughout — phase 1
618/// sorts each subarray and keeps the result in a `Vec<SaLcp<I>>`,
619/// phase 3 distributes into in-RAM partition Vecs, phase 4 cascade-
620/// merges the in-RAM partitions. No disk I/O.
621///
622/// Trades RAM for wall time: peak memory is ~`n × sizeof(SaLcp<I>)`
623/// (the post-phase-1 records sitting around until phase 3 consumes
624/// them), so ~25 GB on the human genome with `I = u32`. In exchange,
625/// the disk-spill / distribute-write / partition-load round-trip is
626/// gone — useful on machines with enough RAM to hold the working set.
627fn build_in_memory_ss_inner<S, I, L, E, F>(
628    text: &[S],
629    source: PositionSource<'_>,
630    lp: &L,
631    opts: &ExtMemOpts,
632    mut emit: F,
633) -> Result<(), BuildError<E>>
634where
635    S: Symbol,
636    I: Index,
637    L: LimitProvider,
638    SaLcp<I>: BucketRecord,
639    F: FnMut(u64) -> Result<(), E>,
640{
641    let n = source.len();
642    if n == 0 {
643        return Ok(());
644    }
645    let p = effective_subproblem_count(n, opts.subproblem_count);
646    let dispatch = LcpDispatch::detect();
647
648    let factory = |_i: usize| InMemBucket::<SaLcp<I>>::new();
649
650    let (mut subarray_buckets, samples) =
651        phase1_sort_sample_spill::<S, I, L, _, _>(text, lp, &source, p, opts, dispatch, factory)?;
652    // Same rationale as in `build_ext_mem_inner` — drop the source
653    // as soon as phase 1's `fill_chunk` calls have stopped.
654    drop(source);
655    let pivots = phase2_select_pivots::<S, I, L>(text, lp, samples, p, opts.max_context, dispatch);
656    let mut partition_buckets = phase3_distribute::<S, I, L, _, _>(
657        text,
658        lp,
659        &mut subarray_buckets,
660        &pivots,
661        p,
662        opts,
663        dispatch,
664        factory,
665    )?;
666    drop(subarray_buckets);
667    phase4_merge_and_emit::<S, I, L, _, E, F>(
668        text,
669        lp,
670        &mut partition_buckets,
671        opts.max_context,
672        opts.ordered_phase4_emit,
673        memo_config(opts.lcp_memoization),
674        opts.collect_lcp_memoization_stats,
675        &mut emit,
676        dispatch,
677    )
678}
679
680fn memo_config(policy: LcpMemoizationPolicy) -> Option<MemoConfig> {
681    match policy {
682        LcpMemoizationPolicy::Disabled => None,
683        LcpMemoizationPolicy::Geometric(config) => Some(config.into()),
684    }
685}
686
687/// In-memory variant of the sample-sort algorithm used by
688/// [`build_ext_mem`]. Skips all disk I/O at the cost of holding the
689/// (`pos`, `lcp`) records in RAM throughout. Picks `u32` records when
690/// `n ≤ 2³²`, falls back to `u64` otherwise. The caller's `emit`
691/// closure is called once per output position in lex order, just like
692/// in the ext-mem path.
693pub fn build_in_memory_sample_sort<S, F>(text: &[S], opts: &ExtMemOpts, emit: F) -> io::Result<()>
694where
695    S: Symbol,
696    F: FnMut(u64) -> io::Result<()>,
697{
698    into_io_result(try_build_in_memory_sample_sort(text, opts, emit))
699}
700
701/// Generic-error variant of [`build_in_memory_sample_sort`].
702pub fn try_build_in_memory_sample_sort<S, E, F>(
703    text: &[S],
704    opts: &ExtMemOpts,
705    emit: F,
706) -> Result<(), BuildError<E>>
707where
708    S: Symbol,
709    F: FnMut(u64) -> Result<(), E>,
710{
711    try_build_in_memory_sample_sort_with(text, &PlainText::new(text.len()), opts, emit)
712}
713
714/// Variant of [`build_in_memory_sample_sort`] that accepts a
715/// [`LimitProvider`].
716pub fn build_in_memory_sample_sort_with<S, L, F>(
717    text: &[S],
718    lp: &L,
719    opts: &ExtMemOpts,
720    emit: F,
721) -> io::Result<()>
722where
723    S: Symbol,
724    L: LimitProvider,
725    F: FnMut(u64) -> io::Result<()>,
726{
727    into_io_result(try_build_in_memory_sample_sort_with(text, lp, opts, emit))
728}
729
730/// Generic-error variant of [`build_in_memory_sample_sort_with`].
731pub fn try_build_in_memory_sample_sort_with<S, L, E, F>(
732    text: &[S],
733    lp: &L,
734    opts: &ExtMemOpts,
735    emit: F,
736) -> Result<(), BuildError<E>>
737where
738    S: Symbol,
739    L: LimitProvider,
740    F: FnMut(u64) -> Result<(), E>,
741{
742    if text.len() <= u32::MAX as usize + 1 {
743        build_in_memory_ss_inner::<S, u32, L, E, F>(
744            text,
745            PositionSource::Identity(text.len()),
746            lp,
747            opts,
748            emit,
749        )
750    } else {
751        build_in_memory_ss_inner::<S, u64, L, E, F>(
752            text,
753            PositionSource::Identity(text.len()),
754            lp,
755            opts,
756            emit,
757        )
758    }
759}
760
761/// Subset-positions variant of [`build_in_memory_sample_sort`]. Same
762/// shape as [`build_ext_mem_for_positions`].
763pub fn build_in_memory_sample_sort_for_positions<S, F>(
764    text: &[S],
765    positions: Vec<u64>,
766    opts: &ExtMemOpts,
767    emit: F,
768) -> io::Result<()>
769where
770    S: Symbol,
771    F: FnMut(u64) -> io::Result<()>,
772{
773    into_io_result(try_build_in_memory_sample_sort_for_positions(
774        text, positions, opts, emit,
775    ))
776}
777
778/// Generic-error variant of [`build_in_memory_sample_sort_for_positions`].
779pub fn try_build_in_memory_sample_sort_for_positions<S, E, F>(
780    text: &[S],
781    positions: Vec<u64>,
782    opts: &ExtMemOpts,
783    emit: F,
784) -> Result<(), BuildError<E>>
785where
786    S: Symbol,
787    F: FnMut(u64) -> Result<(), E>,
788{
789    try_build_in_memory_sample_sort_for_positions_with(
790        text,
791        positions,
792        &PlainText::new(text.len()),
793        opts,
794        emit,
795    )
796}
797
798/// Variant of [`build_in_memory_sample_sort_for_positions`] that
799/// accepts a [`LimitProvider`].
800pub fn build_in_memory_sample_sort_for_positions_with<S, L, F>(
801    text: &[S],
802    positions: Vec<u64>,
803    lp: &L,
804    opts: &ExtMemOpts,
805    emit: F,
806) -> io::Result<()>
807where
808    S: Symbol,
809    L: LimitProvider,
810    F: FnMut(u64) -> io::Result<()>,
811{
812    into_io_result(try_build_in_memory_sample_sort_for_positions_with(
813        text, positions, lp, opts, emit,
814    ))
815}
816
817/// Generic-error variant of [`build_in_memory_sample_sort_for_positions_with`].
818pub fn try_build_in_memory_sample_sort_for_positions_with<S, L, E, F>(
819    text: &[S],
820    positions: Vec<u64>,
821    lp: &L,
822    opts: &ExtMemOpts,
823    emit: F,
824) -> Result<(), BuildError<E>>
825where
826    S: Symbol,
827    L: LimitProvider,
828    F: FnMut(u64) -> Result<(), E>,
829{
830    if text.len() <= u32::MAX as usize + 1 {
831        build_in_memory_ss_inner::<S, u32, L, E, F>(
832            text,
833            PositionSource::Subset(&positions),
834            lp,
835            opts,
836            emit,
837        )
838    } else {
839        build_in_memory_ss_inner::<S, u64, L, E, F>(
840            text,
841            PositionSource::Subset(&positions),
842            lp,
843            opts,
844            emit,
845        )
846    }
847}
848
849/// Source of the positions to sort.
850///
851/// - [`PositionSource::Identity`] is the all-suffixes case `0..n`;
852///   avoids materialising a `Vec<u64>` of length `n`, which on the
853///   human genome would itself be ~50 GB.
854/// - [`PositionSource::Subset`] holds a caller-supplied `&[u64]` of
855///   the positions to sort, in any order. Random-access by index.
856/// - [`PositionSource::Filtered`] is the streaming-predicate variant:
857///   the caller hands in a `Fn(u64) -> bool` over text positions and
858///   caps-sa walks the filter forward through the text on demand. A
859///   tiny prefix-sum (`text.len() / BLOCK_SIZE × 8` B ≈ 760 KB on
860///   the human genome at the 64 KB block size) lets `fill_chunk`
861///   locate the i-th kept position in `O(log n_blocks + BLOCK_SIZE +
862///   chunk_size)`. **No kept-positions list is materialised** — the
863///   memory saving over `Subset` is `≈ 8 × n_kept` bytes, dominant
864///   on genome-scale inputs.
865enum PositionSource<'a> {
866    Identity(usize),
867    Subset(&'a [u64]),
868    Filtered(FilteredSource),
869}
870
871/// Block size in **u64 words** for [`FilteredSource`]'s popcount
872/// prefix-sum. With 1024 words per block (64 K text bits) the
873/// prefix-sum stores one `u64` per block — ~760 KB for the human
874/// genome — and each `fill_chunk` walks at most one block (≈ 8 KB)
875/// in cache before emitting the first kept position.
876const FILTERED_WORDS_PER_BLOCK: usize = 1024;
877
878/// Streaming position source backed by a **bitmap** of kept positions
879/// plus a per-block popcount prefix-sum. No `Vec<u64>` of kept positions
880/// is ever materialised.
881///
882/// Memory: `(n + 7) / 8` bytes for the bitmap (~770 MB on the human
883/// genome at `n ≈ 6.2 B`) + `8 × n_blocks` for the prefix sum
884/// (~760 KB). The 47 GB `Vec<u64>` the [`Subset`][PositionSource::Subset]
885/// variant requires goes away entirely.
886///
887/// Lookup path inside [`fill_chunk`]:
888/// 1. `partition_point` the prefix-sum to find the block containing
889///    the `start`-th kept position (`O(log n_blocks)` ≈ 17 ops).
890/// 2. Walk that block's bitmap words `popcount`-by-`popcount` until
891///    we've skipped the right number of set bits to reach `start`.
892/// 3. Walk forward through bitmap words using `trailing_zeros`-style
893///    iteration, emitting each set bit's position to `dst`. Inner
894///    loop is `O(chunk_size / 64)` u64 ops — no per-text-position
895///    closure calls, branch-light, cache-resident.
896///
897/// A truly `O(1)` select-1 structure (darray / Elias-Fano) on top
898/// of the bitmap would shrink step (1)+(2) further; with
899/// `chunk_size` ≈ 750 K and only `p` ≈ 8192 fill_chunk calls per
900/// build, the `O(log n_blocks)` + at-most-one-block-walk cost is
901/// already a rounding error. See `bench/README.md` for the
902/// follow-up note if that ever changes.
903struct FilteredSource {
904    text_len: usize,
905    total_kept: usize,
906    /// Bitmap: `(bitmap[w] >> b) & 1 == 1` iff position `64 * w + b`
907    /// is kept. Length = `text_len.div_ceil(64)`.
908    bitmap: Vec<u64>,
909    /// `cumsum[i] = sum of set bits in
910    /// `bitmap[0 .. i * FILTERED_WORDS_PER_BLOCK]`. Length =
911    /// `n_blocks + 1`; the last entry equals `total_kept`.
912    cumsum: Vec<u64>,
913}
914
915impl FilteredSource {
916    /// Build a [`FilteredSource`] by walking the predicate once over
917    /// `0..text_len` to fill the bitmap, then accumulating per-block
918    /// popcounts.
919    ///
920    /// The predicate is invoked exactly `text_len` times here; once
921    /// the bitmap is built, `fill_chunk` never calls it again. This
922    /// trades one full predicate pass for zero per-position calls
923    /// during all subsequent random-access `fill_chunk`s — a clear
924    /// win when (as in caps-sa's phase 1) every position is read at
925    /// least once.
926    fn new<Pred>(text_len: usize, keep: Pred) -> Self
927    where
928        Pred: Fn(u64) -> bool + Send + Sync,
929    {
930        let n_words = text_len.div_ceil(64);
931        // Parallel per-word bitmap build. Each word reads 64 text
932        // positions (clamped at `text_len`), packs them into a u64.
933        let bitmap: Vec<u64> = (0..n_words)
934            .into_par_iter()
935            .map(|w| {
936                let mut word: u64 = 0;
937                let base = (w as u64) * 64;
938                let limit = ((w + 1) * 64).min(text_len) - w * 64;
939                for b in 0..limit {
940                    if keep(base + b as u64) {
941                        word |= 1u64 << b;
942                    }
943                }
944                word
945            })
946            .collect();
947
948        // Per-block popcount cumsum. Each block covers
949        // `FILTERED_WORDS_PER_BLOCK` words = `FILTERED_BITS_PER_BLOCK`
950        // text positions.
951        let n_blocks = n_words.div_ceil(FILTERED_WORDS_PER_BLOCK);
952        let per_block: Vec<u64> = (0..n_blocks)
953            .into_par_iter()
954            .map(|i| {
955                let start = i * FILTERED_WORDS_PER_BLOCK;
956                let end = ((i + 1) * FILTERED_WORDS_PER_BLOCK).min(n_words);
957                let mut c: u64 = 0;
958                for &word in &bitmap[start..end] {
959                    c += word.count_ones() as u64;
960                }
961                c
962            })
963            .collect();
964        let mut cumsum = Vec::with_capacity(n_blocks + 1);
965        let mut s: u64 = 0;
966        cumsum.push(0);
967        for &k in &per_block {
968            s += k;
969            cumsum.push(s);
970        }
971        let total_kept = s as usize;
972        Self {
973            text_len,
974            total_kept,
975            bitmap,
976            cumsum,
977        }
978    }
979
980    /// Number of kept positions.
981    #[inline]
982    fn len(&self) -> usize {
983        self.total_kept
984    }
985
986    /// Fill `dst` with the next `dst.len()` kept positions starting
987    /// from the `start`-th (0-based) kept position. See type-level
988    /// doc for the algorithm; this is the hot path during phase 1
989    /// fill_chunk.
990    fn fill_chunk<I: Index>(&self, start: usize, dst: &mut [I]) {
991        debug_assert!(start + dst.len() <= self.total_kept);
992        if dst.is_empty() {
993            return;
994        }
995
996        // (1) Locate the block containing the `start`-th set bit.
997        // `partition_point(|c| c <= start)` gives the first cumsum
998        // entry strictly greater than `start`; previous index is the
999        // containing block.
1000        let pp = self.cumsum.partition_point(|&c| c <= start as u64);
1001        debug_assert!(pp > 0);
1002        let block_idx = pp - 1;
1003        let mut word_idx = block_idx * FILTERED_WORDS_PER_BLOCK;
1004        let mut skip = start as u64 - self.cumsum[block_idx];
1005
1006        // (2) Skip the first `skip` set bits — possibly spanning
1007        // several bitmap words. Whole words with `popcount ≤ skip`
1008        // are consumed wholesale; the final partial word has its
1009        // lowest `skip` set bits cleared so the emit loop sees only
1010        // un-skipped 1s.
1011        //
1012        // Note: a naive `while skip >= 64 { … }` is wrong because a
1013        // word's popcount can be far less than 64; we must subtract
1014        // the actual popcount each iteration, not 64. This matters
1015        // any time the bitmap is sparser than ~50%.
1016        let n_words = self.bitmap.len();
1017        let mut word: u64 = if word_idx < n_words {
1018            self.bitmap[word_idx]
1019        } else {
1020            0
1021        };
1022        while skip > 0 {
1023            let pc = word.count_ones() as u64;
1024            if skip < pc {
1025                // Consume `skip` lowest set bits inside the current
1026                // word; emit loop continues from the remaining ones.
1027                for _ in 0..skip {
1028                    word &= word - 1;
1029                }
1030                break;
1031            }
1032            // Skip ≥ pc: consume the whole word and advance.
1033            skip -= pc;
1034            word_idx += 1;
1035            word = if word_idx < n_words {
1036                self.bitmap[word_idx]
1037            } else {
1038                0
1039            };
1040        }
1041
1042        // (3) Walk `word`+subsequent words, emitting one position per
1043        // set bit. Uses `trailing_zeros` to jump straight to the next
1044        // 1 inside a word, then clears it via `word &= word - 1`.
1045        let mut written = 0usize;
1046        let need = dst.len();
1047        loop {
1048            while word != 0 && written < need {
1049                let bit = word.trailing_zeros() as u64;
1050                let pos = (word_idx as u64) * 64 + bit;
1051                debug_assert!((pos as usize) < self.text_len);
1052                dst[written] = I::from_usize(pos as usize);
1053                written += 1;
1054                word &= word - 1;
1055            }
1056            if written == need {
1057                break;
1058            }
1059            word_idx += 1;
1060            debug_assert!(
1061                word_idx < n_words,
1062                "FilteredSource::fill_chunk: walked past bitmap end \
1063                 ({written}/{need} emitted, word_idx={word_idx}, n_words={n_words})"
1064            );
1065            word = self.bitmap[word_idx];
1066        }
1067    }
1068}
1069
1070impl<'a> PositionSource<'a> {
1071    fn len(&self) -> usize {
1072        match self {
1073            Self::Identity(n) => *n,
1074            Self::Subset(p) => p.len(),
1075            Self::Filtered(f) => f.len(),
1076        }
1077    }
1078
1079    /// Fill `dst` with positions for the half-open subarray range
1080    /// `[start, start + dst.len())`, narrowing the caller's `u64`
1081    /// positions into `I` via [`Index::from_usize`]. For
1082    /// [`PositionSource::Identity`] this generates the contiguous
1083    /// integer range on the fly; for [`PositionSource::Subset`] it
1084    /// reads from the caller's slice; for [`PositionSource::Filtered`]
1085    /// it walks the predicate forward from the right text block.
1086    fn fill_chunk<I: Index>(&self, start: usize, dst: &mut [I]) {
1087        match self {
1088            Self::Identity(_) => {
1089                for (i, slot) in dst.iter_mut().enumerate() {
1090                    *slot = I::from_usize(start + i);
1091                }
1092            }
1093            Self::Subset(p) => {
1094                let end = start + dst.len();
1095                for (slot, &v) in dst.iter_mut().zip(p[start..end].iter()) {
1096                    *slot = I::from_usize(v as usize);
1097                }
1098            }
1099            Self::Filtered(f) => f.fill_chunk(start, dst),
1100        }
1101    }
1102}
1103
1104/// Target subarray size used by [`effective_subproblem_count`] when
1105/// auto-picking `p`. Smaller means more (smaller) subarrays — lower
1106/// per-task phase-1 scratch, at the cost of more pivot splits and logical
1107/// partition pieces.
1108const PHASE1_TARGET_CHUNK: usize = 65_536;
1109/// Hard cap on the number of subarrays. Matches upstream CaPS-SA's
1110/// default of 8192. The cap keeps pivot splitting, logical partition metadata,
1111/// and the number of lock acquisitions bounded.
1112const PHASE1_MAX_PARTITIONS: usize = 8192;
1113
1114/// Resolve [`ExtMemOpts::physical_file_count`] for the current build.
1115/// `0` (the default) means "let the runtime decide"; we pick
1116/// `rayon::current_num_threads()` so the pool has one inode per
1117/// concurrent writer, which empirically matches per-bucket-file wall
1118/// time while collapsing thousands of small files into dozens of
1119/// large ones. The `CAPS_SA_N_PHYS` env var overrides at the call
1120/// site for benchmarks.
1121fn effective_physical_file_count(requested: usize) -> usize {
1122    if let Some(v) = std::env::var("CAPS_SA_N_PHYS")
1123        .ok()
1124        .and_then(|s| s.parse::<usize>().ok())
1125        .filter(|&v| v >= 1)
1126    {
1127        return v;
1128    }
1129    if requested >= 1 {
1130        return requested;
1131    }
1132    rayon::current_num_threads().max(1)
1133}
1134
1135fn effective_subproblem_count(n: usize, requested: usize) -> usize {
1136    if n == 0 {
1137        return 0;
1138    }
1139    let raw = if requested == 0 {
1140        let nthreads = rayon::current_num_threads().max(1);
1141        let p_from_size = n.div_ceil(PHASE1_TARGET_CHUNK);
1142        // At least one chunk per thread (otherwise we leave cores idle),
1143        // at most `PHASE1_MAX_PARTITIONS` (so pivot splitting and partition
1144        // metadata stay manageable). For
1145        // small inputs `p_from_size` is well below the cap, so the
1146        // formula degrades gracefully to roughly "one chunk per thread";
1147        // for human-scale inputs the cap binds and per-task scratch
1148        // stays in the tens-of-MB range.
1149        p_from_size.clamp(nthreads, PHASE1_MAX_PARTITIONS)
1150    } else {
1151        requested
1152    };
1153    raw.clamp(1, n)
1154}
1155
1156/// Phase 1: sort each subarray in parallel, sample from it, and spill
1157/// `(position, lcp)` records to its own [`ExtMemBucket`].
1158///
1159/// One Rayon task per subarray. When `p` provides at least one outer task per
1160/// worker (including every auto-picked build), the sort recursion stays local
1161/// to that task, avoiding nested scheduler work and preventing a worker from
1162/// retaining one subarray's scratch while stealing another outer task. An
1163/// explicit smaller `p` retains recursive parallelism. With auto `p` (target
1164/// chunk ~64 K records until the 8,192 cap binds), peak scratch is bounded by
1165/// the number of workers rather than the number of subarrays.
1166#[allow(clippy::too_many_arguments)]
1167fn phase1_sort_sample_spill<S, I, L, B, MkB>(
1168    text: &[S],
1169    lp: &L,
1170    source: &PositionSource<'_>,
1171    p: usize,
1172    opts: &ExtMemOpts,
1173    dispatch: LcpDispatch,
1174    mk_bucket: MkB,
1175) -> io::Result<(Vec<B>, Vec<I>)>
1176where
1177    S: Symbol,
1178    I: Index,
1179    L: LimitProvider,
1180    SaLcp<I>: BucketRecord,
1181    B: SaLcpBucketStore<I> + Send,
1182    MkB: Fn(usize) -> B + Send + Sync,
1183{
1184    let n = source.len();
1185    let chunk_size = n.div_ceil(p);
1186    let samples_target_total = sample_target_total(n, p);
1187    let task_local_sort = p >= rayon::current_num_threads().max(1);
1188
1189    let per_subarray: Vec<(B, Vec<I>)> = (0..p)
1190        .into_par_iter()
1191        .map(|i| {
1192            let start = (i * chunk_size).min(n);
1193            let end = ((i + 1) * chunk_size).min(n);
1194            let len = end - start;
1195
1196            let mut bucket = mk_bucket(i);
1197            if len == 0 {
1198                return Ok::<_, io::Error>((bucket, Vec::new()));
1199            }
1200
1201            // In-memory sort of this subarray with LCP maintenance.
1202            let mut sa: Vec<I> = vec![I::zero(); len];
1203            source.fill_chunk(start, &mut sa);
1204            let mut sa_w = vec![I::zero(); len];
1205            let mut lcp_arr = vec![I::zero(); len];
1206            let mut lcp_w = vec![I::zero(); len];
1207            if task_local_sort {
1208                sample_sort::merge_sort_task_local(
1209                    text,
1210                    lp,
1211                    &mut sa,
1212                    &mut sa_w,
1213                    &mut lcp_arr,
1214                    &mut lcp_w,
1215                    opts.max_context,
1216                    dispatch,
1217                );
1218            } else {
1219                sample_sort::merge_sort(
1220                    text,
1221                    lp,
1222                    &mut sa,
1223                    &mut sa_w,
1224                    &mut lcp_arr,
1225                    &mut lcp_w,
1226                    opts.max_context,
1227                    dispatch,
1228                );
1229            }
1230
1231            // Pull `samples_per_subarray` evenly-spaced positions out of
1232            // the now-sorted subarray. Deterministic — no RNG needed for
1233            // pivot selection to be globally well-distributed.
1234            let samples_per_subarray = samples_target_total.div_ceil(p).min(len);
1235            let samples = evenly_spaced(&sa, samples_per_subarray);
1236
1237            // Spill (position, lcp) records to the bucket. `lcp[0]`
1238            // remains 0 (set by the merge-sort base case), making each
1239            // subarray its own well-formed LCP-annotated sorted run.
1240            bucket.add_soa(&sa, &lcp_arr)?;
1241
1242            Ok((bucket, samples))
1243        })
1244        .collect::<Result<Vec<_>, _>>()?;
1245
1246    let mut buckets = Vec::with_capacity(p);
1247    let mut all_samples = Vec::with_capacity(samples_target_total);
1248    for (bucket, samples) in per_subarray {
1249        buckets.push(bucket);
1250        all_samples.extend(samples);
1251    }
1252    Ok((buckets, all_samples))
1253}
1254
1255/// Target sample count *across all subarrays*. Matches upstream CaPS-SA's
1256/// "`c · ln n`" rule per subarray with `c = 4`, so the global pool is
1257/// `p · 4 · ln n` samples.
1258fn sample_target_total(n: usize, p: usize) -> usize {
1259    let ln_n = (n as f64).ln().max(1.0);
1260    let per = (4.0 * ln_n).ceil() as usize;
1261    // At least p (so we have enough to pick p-1 pivots) and at most n.
1262    p.saturating_mul(per).clamp(p, n)
1263}
1264
1265/// Pick `count` evenly-spaced elements from a slice. Deterministic, which
1266/// keeps the algorithm reproducible without an RNG dependency.
1267fn evenly_spaced<T: Copy>(xs: &[T], count: usize) -> Vec<T> {
1268    let n = xs.len();
1269    if count == 0 || n == 0 {
1270        return Vec::new();
1271    }
1272    if count >= n {
1273        return xs.to_vec();
1274    }
1275    // Pick indices at positions (i + 0.5) · n / count for i in 0..count,
1276    // i.e. evenly-spaced midpoints. Avoids both endpoints — keeps pivots
1277    // away from extreme corners of the order.
1278    (0..count)
1279        .map(|i| xs[(2 * i + 1) * n / (2 * count)])
1280        .collect()
1281}
1282
1283/// Phase 2: globally sort the pooled samples and pick `p − 1` pivots at
1284/// evenly-spaced ranks.
1285fn phase2_select_pivots<S, I, L>(
1286    text: &[S],
1287    lp: &L,
1288    mut samples: Vec<I>,
1289    p: usize,
1290    max_ctx: usize,
1291    dispatch: LcpDispatch,
1292) -> Vec<I>
1293where
1294    S: Symbol,
1295    I: Index,
1296    L: LimitProvider,
1297{
1298    if p <= 1 || samples.is_empty() {
1299        return Vec::new();
1300    }
1301    let n_samples = samples.len();
1302    let mut sa_w = vec![I::zero(); n_samples];
1303    let mut lcp = vec![I::zero(); n_samples];
1304    let mut lcp_w = vec![I::zero(); n_samples];
1305    sample_sort::merge_sort(
1306        text,
1307        lp,
1308        &mut samples,
1309        &mut sa_w,
1310        &mut lcp,
1311        &mut lcp_w,
1312        max_ctx,
1313        dispatch,
1314    );
1315
1316    // p-1 pivots at evenly-spaced ranks across the sorted sample pool.
1317    (1..p).map(|j| samples[(j * n_samples) / p]).collect()
1318}
1319
1320/// Phase 3: walk each subarray *in parallel*, load it into RAM,
1321/// binary-search the pivots to find its `p` sub-subarray boundaries,
1322/// and append each sub-subarray to the corresponding partition bucket.
1323///
1324/// Partition buckets are wrapped in a [`Mutex`] each so multiple
1325/// threads can write to different partitions concurrently without
1326/// shard-merging afterwards. With `p` in the thousands and `T` in the
1327/// tens, lock contention is negligible (probability that two threads
1328/// want the same partition at the same instant is `~T/p`); the lock
1329/// scope per acquisition is one `add_slice` + `mark_boundary` of a
1330/// few-KB sub-subarray.
1331///
1332/// Phase 4 doesn't care about the relative order of sub-subarrays
1333/// within a partition — only that each one between consecutive
1334/// boundaries is internally sorted. Both properties hold under
1335/// arbitrary thread interleaving.
1336#[allow(clippy::too_many_arguments)]
1337fn phase3_distribute<S, I, L, B, MkB>(
1338    text: &[S],
1339    lp: &L,
1340    subarray_buckets: &mut [B],
1341    pivots: &[I],
1342    p: usize,
1343    opts: &ExtMemOpts,
1344    dispatch: LcpDispatch,
1345    mk_bucket: MkB,
1346) -> io::Result<Vec<B>>
1347where
1348    S: Symbol,
1349    I: Index,
1350    L: LimitProvider,
1351    SaLcp<I>: BucketRecord,
1352    B: SaLcpBucketStore<I> + Send,
1353    MkB: Fn(usize) -> B + Send + Sync,
1354{
1355    let _ = opts; // work_dir is used only by the ext-mem factory closure now
1356    let partition_buckets: Vec<Mutex<B>> = (0..p).map(|j| Mutex::new(mk_bucket(j))).collect();
1357
1358    subarray_buckets
1359        .par_iter_mut()
1360        .try_for_each(|sub_bucket| -> io::Result<()> {
1361            if sub_bucket.total_records() == 0 {
1362                return Ok(());
1363            }
1364            let records = sub_bucket.load_all()?;
1365
1366            // Find p-1 split points by binary-searching each pivot's
1367            // *upper bound* in the sorted subarray.
1368            let mut splits = Vec::with_capacity(p + 1);
1369            splits.push(0usize);
1370            for &pivot in pivots {
1371                splits.push(upper_bound_by_pivot(
1372                    &records,
1373                    pivot,
1374                    text,
1375                    lp,
1376                    opts.max_context,
1377                    dispatch,
1378                ));
1379            }
1380            splits.push(records.len());
1381
1382            // Distribute each sub-subarray. Reset the first record's
1383            // `lcp` to 0 so the per-partition merge sees a well-formed
1384            // boundary.
1385            for j in 0..p {
1386                let lo = splits[j];
1387                let hi = splits[j + 1];
1388                if lo >= hi {
1389                    continue;
1390                }
1391                let mut bucket = partition_buckets[j].lock().unwrap();
1392                bucket.add_slice_reset_first_lcp(&records[lo..hi])?;
1393                bucket.mark_boundary();
1394            }
1395            Ok(())
1396        })?;
1397
1398    // Unwrap the Mutexes — at this point only this thread holds
1399    // references, so the locks are uncontended.
1400    Ok(partition_buckets
1401        .into_iter()
1402        .map(|m| m.into_inner().expect("partition mutex poisoned"))
1403        .collect())
1404}
1405
1406/// Choose `p - 1` pivots before sorting the phase-1 subarrays.
1407///
1408/// Any sorted splitters produce a correct sample sort; their quality affects
1409/// partition balance, not ordering. Selecting them from a cheap block-strided
1410/// pre-sample lets phase 1 write sorted pieces directly to final partition
1411/// buckets instead of spilling and re-reading every record in a separate
1412/// distribution phase.
1413fn phase0_presample_pivots<S, I, L>(
1414    text: &[S],
1415    lp: &L,
1416    source: &PositionSource<'_>,
1417    p: usize,
1418    opts: &ExtMemOpts,
1419    dispatch: LcpDispatch,
1420) -> Vec<I>
1421where
1422    S: Symbol,
1423    I: Index,
1424    L: LimitProvider,
1425{
1426    let n = source.len();
1427    if p <= 1 || n == 0 {
1428        return Vec::new();
1429    }
1430
1431    // PositionSource::fill_chunk is substantially cheaper for a contiguous
1432    // block than for the same number of singleton calls, particularly for the
1433    // filtered bitmap source used by ruSTAR.
1434    const BLOCK: usize = 64;
1435    let target = sample_target_total(n, p).min(n);
1436    let n_blocks = target.div_ceil(BLOCK).max(1);
1437    let stride = (n / n_blocks).max(1);
1438
1439    let mut sample: Vec<I> = Vec::with_capacity(n_blocks * BLOCK);
1440    let mut start = 0usize;
1441    while start < n && sample.len() < target {
1442        let len = BLOCK.min(n - start);
1443        let base = sample.len();
1444        sample.resize(base + len, I::zero());
1445        source.fill_chunk(start, &mut sample[base..]);
1446        start += stride;
1447    }
1448    if sample.is_empty() {
1449        return Vec::new();
1450    }
1451
1452    let m = sample.len();
1453    let mut sa_w = vec![I::zero(); m];
1454    let mut lcp = vec![I::zero(); m];
1455    let mut lcp_w = vec![I::zero(); m];
1456    sample_sort::merge_sort(
1457        text,
1458        lp,
1459        &mut sample,
1460        &mut sa_w,
1461        &mut lcp,
1462        &mut lcp_w,
1463        opts.max_context,
1464        dispatch,
1465    );
1466
1467    (1..p).map(|i| sample[(i * m / p).min(m - 1)]).collect()
1468}
1469
1470/// Sort each phase-1 subarray and distribute its sorted pieces directly to
1471/// the final partition buckets.
1472///
1473/// Pivots have already been selected by [`phase0_presample_pivots`], so the
1474/// subarrays never need to be written to one bucket pool and read back into a
1475/// second one. The partition pieces remain individually sorted and retain an
1476/// LCP reset at every boundary, exactly as the cascade merge requires.
1477#[allow(clippy::too_many_arguments)]
1478fn phase1_sort_and_distribute<S, I, L, B, MkB>(
1479    text: &[S],
1480    lp: &L,
1481    source: &PositionSource<'_>,
1482    pivots: &[I],
1483    p: usize,
1484    opts: &ExtMemOpts,
1485    dispatch: LcpDispatch,
1486    mk_bucket: MkB,
1487) -> io::Result<Vec<B>>
1488where
1489    S: Symbol,
1490    I: Index,
1491    L: LimitProvider,
1492    SaLcp<I>: BucketRecord,
1493    B: SaLcpBucketStore<I> + Send,
1494    MkB: Fn(usize) -> B + Send + Sync,
1495{
1496    let n = source.len();
1497    let chunk_size = n.div_ceil(p);
1498    let partition_buckets: Vec<Mutex<B>> = (0..p).map(|j| Mutex::new(mk_bucket(j))).collect();
1499    let task_local_sort = p >= rayon::current_num_threads().max(1);
1500
1501    (0..p).into_par_iter().try_for_each(|i| -> io::Result<()> {
1502        let start = (i * chunk_size).min(n);
1503        let end = ((i + 1) * chunk_size).min(n);
1504        let len = end - start;
1505        if len == 0 {
1506            return Ok(());
1507        }
1508
1509        let mut sa: Vec<I> = vec![I::zero(); len];
1510        source.fill_chunk(start, &mut sa);
1511        let mut sa_w = vec![I::zero(); len];
1512        let mut lcp_arr = vec![I::zero(); len];
1513        let mut lcp_w = vec![I::zero(); len];
1514        if task_local_sort {
1515            sample_sort::merge_sort_task_local(
1516                text,
1517                lp,
1518                &mut sa,
1519                &mut sa_w,
1520                &mut lcp_arr,
1521                &mut lcp_w,
1522                opts.max_context,
1523                dispatch,
1524            );
1525        } else {
1526            sample_sort::merge_sort(
1527                text,
1528                lp,
1529                &mut sa,
1530                &mut sa_w,
1531                &mut lcp_arr,
1532                &mut lcp_w,
1533                opts.max_context,
1534                dispatch,
1535            );
1536        }
1537        drop(sa_w);
1538        drop(lcp_w);
1539
1540        // Consecutive pivots have non-decreasing upper bounds. Galloping from
1541        // the previous split avoids restarting a full binary search p times.
1542        let mut splits = Vec::with_capacity(p + 1);
1543        splits.push(0usize);
1544        let mut from = 0usize;
1545        for &pivot in pivots {
1546            from =
1547                upper_bound_positions_from(&sa, from, pivot, text, lp, opts.max_context, dispatch);
1548            splits.push(from);
1549        }
1550        splits.push(sa.len());
1551
1552        for j in 0..p {
1553            let (lo, hi) = (splits[j], splits[j + 1]);
1554            if lo >= hi {
1555                continue;
1556            }
1557            let mut bucket = partition_buckets[j].lock().unwrap();
1558            bucket.add_soa_reset_first_lcp(&sa[lo..hi], &lcp_arr[lo..hi])?;
1559            bucket.mark_boundary();
1560        }
1561        Ok(())
1562    })?;
1563
1564    Ok(partition_buckets
1565        .into_iter()
1566        .map(|m| m.into_inner().expect("partition mutex poisoned"))
1567        .collect())
1568}
1569
1570/// Upper bound of `pivot` in `records`, searched forward from the previous
1571/// pivot's split. Pivots are sorted, so upper bounds never move backward.
1572fn upper_bound_positions_from<S, I, L>(
1573    positions: &[I],
1574    from: usize,
1575    pivot: I,
1576    text: &[S],
1577    lp: &L,
1578    max_ctx: usize,
1579    dispatch: LcpDispatch,
1580) -> usize
1581where
1582    S: Symbol,
1583    I: Index,
1584    L: LimitProvider,
1585{
1586    let n = positions.len();
1587    let greater = |i: usize| -> bool {
1588        dispatch.suffix_cmp_with(text, lp, positions[i].to_usize(), pivot.to_usize(), max_ctx)
1589            == Ordering::Greater
1590    };
1591
1592    if from >= n {
1593        return n;
1594    }
1595    if greater(from) {
1596        return from;
1597    }
1598
1599    let mut lo = from;
1600    let mut step = 1usize;
1601    loop {
1602        let probe = from.saturating_add(step);
1603        if probe >= n {
1604            break;
1605        }
1606        if greater(probe) {
1607            let mut hi = probe;
1608            while lo + 1 < hi {
1609                let mid = lo + (hi - lo) / 2;
1610                if greater(mid) {
1611                    hi = mid;
1612                } else {
1613                    lo = mid;
1614                }
1615            }
1616            return hi;
1617        }
1618        lo = probe;
1619        step = step.saturating_mul(2);
1620    }
1621
1622    let mut hi = n;
1623    while lo + 1 < hi {
1624        let mid = lo + (hi - lo) / 2;
1625        if greater(mid) {
1626            hi = mid;
1627        } else {
1628            lo = mid;
1629        }
1630    }
1631    hi
1632}
1633
1634/// Upper-bound binary search: returns the first index `i` such that the
1635/// suffix at `records[i].pos` is **strictly greater than** the suffix at
1636/// `pivot`.
1637fn upper_bound_by_pivot<S, I, L>(
1638    records: &[SaLcp<I>],
1639    pivot: I,
1640    text: &[S],
1641    lp: &L,
1642    max_ctx: usize,
1643    dispatch: LcpDispatch,
1644) -> usize
1645where
1646    S: Symbol,
1647    I: Index,
1648    L: LimitProvider,
1649{
1650    let mut lo = 0;
1651    let mut hi = records.len();
1652    while lo < hi {
1653        let mid = lo + (hi - lo) / 2;
1654        match dispatch.suffix_cmp_with(
1655            text,
1656            lp,
1657            records[mid].pos.to_usize(),
1658            pivot.to_usize(),
1659            max_ctx,
1660        ) {
1661            Ordering::Greater => hi = mid,
1662            Ordering::Equal | Ordering::Less => lo = mid + 1,
1663        }
1664    }
1665    lo
1666}
1667
1668/// Phase 4 + 5: parallel-merge partitions in chunks of `num_threads`,
1669/// emitting each chunk's results in lex order before starting the next.
1670///
1671/// Up to `4 × T` partitions are dispatched per chunk so workers can steal
1672/// around partition-size skew. Each active merge holds two position/LCP sides;
1673/// completed position results are drained sequentially via `emit`. Between
1674/// chunks all merge storage is dropped, so residency scales with the bounded
1675/// in-flight chunk rather than the total suffix-array length.
1676#[allow(clippy::too_many_arguments)]
1677fn phase4_merge_and_emit<S, I, L, B, E, F>(
1678    text: &[S],
1679    lp: &L,
1680    partition_buckets: &mut [B],
1681    max_ctx: usize,
1682    ordered_emit: bool,
1683    memo_config: Option<MemoConfig>,
1684    collect_memo_stats: bool,
1685    emit: &mut F,
1686    dispatch: LcpDispatch,
1687) -> Result<(), BuildError<E>>
1688where
1689    S: Symbol,
1690    I: Index,
1691    L: LimitProvider,
1692    SaLcp<I>: BucketRecord,
1693    B: SaLcpBucketStore<I> + Send,
1694    F: FnMut(u64) -> Result<(), E>,
1695{
1696    let n_partitions = partition_buckets.len();
1697    if n_partitions == 0 {
1698        return Ok(());
1699    }
1700    // `chunk_size = 4 × num_threads` (not `= num_threads`): with one
1701    // partition per thread per chunk, rayon's `par_iter_mut` assigns
1702    // 1-to-1 with no opportunity to steal, and the chunk's wall is
1703    // set by its slowest partition. Sample-sort partition sizes vary
1704    // ~2× from random sampling, so the slow tail leaves ~half the
1705    // cores idle waiting (observed: 52% parallel efficiency on
1706    // GRCh38 / 32 t).
1707    //
1708    // Bumping the chunk to `4 × num_threads` gives rayon four
1709    // partitions per thread to dispatch — fast threads can steal from
1710    // slow neighbours, smoothing out the size variance. Peak RAM
1711    // grows linearly: each in-flight merged partition holds its
1712    // result `Vec<I>` (~3 MB at human-genome scale with `u32`
1713    // indices), so the chunk's transient cost goes from `32 × 3 MB =
1714    // 96 MB` to `128 × 3 MB = 384 MB` — well within the budget we
1715    // already spend on phase 1.
1716    let chunk_size = rayon::current_num_threads().max(1) * 4;
1717
1718    // Per-thread CPU-µs accumulators for the two parallel sub-steps. They
1719    // add across threads, so the printed values are CPU-time (sum), not
1720    // wall-time; the ratio between them still tells us where the work is.
1721    use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
1722    let profile = std::env::var_os("CAPS_SA_PROFILE").is_some();
1723    let memo_profiled = profile && collect_memo_stats && memo_config.is_some();
1724    let load_us = AtomicU64::new(0);
1725    let merge_us = AtomicU64::new(0);
1726    let memo_stats = Mutex::new(MemoStats::default());
1727    let mut emit_secs: f64 = 0.0;
1728
1729    let mut start = 0;
1730    while start < n_partitions {
1731        let end = (start + chunk_size).min(n_partitions);
1732        let chunk = &mut partition_buckets[start..end];
1733        if ordered_emit {
1734            phase4_merge_chunk_ordered_emit(
1735                text,
1736                lp,
1737                chunk,
1738                max_ctx,
1739                emit,
1740                dispatch,
1741                memo_config,
1742                &memo_stats,
1743                memo_profiled,
1744                profile,
1745                &load_us,
1746                &merge_us,
1747                &mut emit_secs,
1748            )?;
1749        } else {
1750            phase4_merge_chunk_collect_emit(
1751                text,
1752                lp,
1753                chunk,
1754                max_ctx,
1755                emit,
1756                dispatch,
1757                memo_config,
1758                &memo_stats,
1759                memo_profiled,
1760                profile,
1761                &load_us,
1762                &merge_us,
1763                &mut emit_secs,
1764            )?;
1765        }
1766        start = end;
1767    }
1768    if profile {
1769        profile_log(&format!(
1770            "phase4 breakdown CPU: load {:.3}s merge {:.3}s; wall emit {:.3}s",
1771            load_us.load(AtomicOrdering::Relaxed) as f64 * 1e-6,
1772            merge_us.load(AtomicOrdering::Relaxed) as f64 * 1e-6,
1773            emit_secs,
1774        ));
1775        if let Some(config) = memo_config.filter(|_| memo_profiled) {
1776            let stats = *memo_stats.lock().expect("memo profile mutex poisoned");
1777            profile_log(&format!(
1778                "geometric memo probe={} min_lcp={} cap={} activate_entries={} tables={} active_tables={} table_bins=[{},{},{},{},{},{}] calls={} training_direct={} probe_resolved={} lookups={} direct_hits={} gap_hits={} gap_mismatches={} gap_caps={} misses={} inserts={} extensions={} cap_rejects={} final_entries={} max_entries={} unique_diagonals={} singleton_diagonals={} max_entries_per_diagonal={} lookup_steps={} insert_steps={} insert_shifts={} scanned_matches={} skipped_matches={}",
1779                config.probe,
1780                config.min_lcp,
1781                config.capacity,
1782                config.activate_entries,
1783                stats.tables,
1784                stats.active_tables,
1785                stats.tables_0_15,
1786                stats.tables_16_31,
1787                stats.tables_32_63,
1788                stats.tables_64_127,
1789                stats.tables_128_255,
1790                stats.tables_256_plus,
1791                stats.calls,
1792                stats.cold_direct,
1793                stats.probe_resolved,
1794                stats.lookups,
1795                stats.direct_hits,
1796                stats.gap_hits,
1797                stats.gap_mismatches,
1798                stats.gap_caps,
1799                stats.misses,
1800                stats.inserts,
1801                stats.extensions,
1802                stats.capacity_rejects,
1803                stats.final_entries,
1804                stats.max_entries,
1805                stats.unique_diagonals,
1806                stats.singleton_diagonals,
1807                stats.max_entries_per_diagonal,
1808                stats.lookup_steps,
1809                stats.insert_steps,
1810                stats.insert_shifts,
1811                stats.scanned_matches,
1812                stats.skipped_matches,
1813            ));
1814        }
1815    }
1816    Ok(())
1817}
1818
1819#[allow(clippy::too_many_arguments)]
1820fn phase4_merge_chunk_collect_emit<S, I, L, B, E, F>(
1821    text: &[S],
1822    lp: &L,
1823    chunk: &mut [B],
1824    max_ctx: usize,
1825    emit: &mut F,
1826    dispatch: LcpDispatch,
1827    memo_config: Option<MemoConfig>,
1828    memo_stats: &Mutex<MemoStats>,
1829    memo_profiled: bool,
1830    profile: bool,
1831    load_us: &std::sync::atomic::AtomicU64,
1832    merge_us: &std::sync::atomic::AtomicU64,
1833    emit_secs: &mut f64,
1834) -> Result<(), BuildError<E>>
1835where
1836    S: Symbol,
1837    I: Index,
1838    L: LimitProvider,
1839    SaLcp<I>: BucketRecord,
1840    B: SaLcpBucketStore<I> + Send,
1841    F: FnMut(u64) -> Result<(), E>,
1842{
1843    // Default fast path: let rayon merge the whole chunk with minimal
1844    // coordination, then emit the collected partition results in order.
1845    let merged: Vec<Vec<I>> = chunk
1846        .par_iter_mut()
1847        .map(|bucket| -> io::Result<Vec<I>> {
1848            merge_one_partition(
1849                text,
1850                lp,
1851                bucket,
1852                max_ctx,
1853                dispatch,
1854                memo_config,
1855                memo_stats,
1856                memo_profiled,
1857                profile,
1858                load_us,
1859                merge_us,
1860            )
1861        })
1862        .collect::<Result<Vec<_>, io::Error>>()?;
1863
1864    let t = Instant::now();
1865    for positions in merged {
1866        for pos in positions {
1867            emit(pos.to_usize() as u64).map_err(BuildError::Emit)?;
1868        }
1869    }
1870    if profile {
1871        *emit_secs += t.elapsed().as_secs_f64();
1872    }
1873    Ok(())
1874}
1875
1876#[allow(clippy::too_many_arguments)]
1877fn phase4_merge_chunk_ordered_emit<S, I, L, B, E, F>(
1878    text: &[S],
1879    lp: &L,
1880    chunk: &mut [B],
1881    max_ctx: usize,
1882    emit: &mut F,
1883    dispatch: LcpDispatch,
1884    memo_config: Option<MemoConfig>,
1885    memo_stats: &Mutex<MemoStats>,
1886    memo_profiled: bool,
1887    profile: bool,
1888    load_us: &std::sync::atomic::AtomicU64,
1889    merge_us: &std::sync::atomic::AtomicU64,
1890    emit_secs: &mut f64,
1891) -> Result<(), BuildError<E>>
1892where
1893    S: Symbol,
1894    I: Index,
1895    L: LimitProvider,
1896    SaLcp<I>: BucketRecord,
1897    B: SaLcpBucketStore<I> + Send,
1898    F: FnMut(u64) -> Result<(), E>,
1899{
1900    let n_jobs = chunk.len();
1901    let channel_bound = (rayon::current_num_threads().max(1) * 2).min(n_jobs).max(1);
1902    let (tx, rx) = std::sync::mpsc::sync_channel::<(usize, io::Result<Vec<I>>)>(channel_bound);
1903    let mut pending = std::collections::BTreeMap::<usize, Vec<I>>::new();
1904    let mut next_to_emit = 0usize;
1905    let mut received = 0usize;
1906    let mut io_err: Option<io::Error> = None;
1907    let mut emit_err: Option<E> = None;
1908
1909    std::thread::scope(|thread_scope| {
1910        let worker = thread_scope.spawn(|| {
1911            chunk
1912                .par_iter_mut()
1913                .enumerate()
1914                .for_each_with(tx, |tx, (local_idx, bucket)| {
1915                    let result = merge_one_partition(
1916                        text,
1917                        lp,
1918                        bucket,
1919                        max_ctx,
1920                        dispatch,
1921                        memo_config,
1922                        memo_stats,
1923                        memo_profiled,
1924                        profile,
1925                        load_us,
1926                        merge_us,
1927                    );
1928                    let _ = tx.send((local_idx, result));
1929                });
1930        });
1931
1932        while received < n_jobs {
1933            let (local_idx, result) = rx
1934                .recv()
1935                .expect("phase4 worker channel closed before all partitions completed");
1936            received += 1;
1937            match result {
1938                Ok(positions) => {
1939                    pending.insert(local_idx, positions);
1940                }
1941                Err(err) => {
1942                    if io_err.is_none() {
1943                        io_err = Some(err);
1944                    }
1945                }
1946            }
1947
1948            while let Some(positions) = pending.remove(&next_to_emit) {
1949                if io_err.is_none() && emit_err.is_none() {
1950                    let t = Instant::now();
1951                    for pos in positions {
1952                        // Widen back to the public `u64` emit contract.
1953                        if let Err(err) = emit(pos.to_usize() as u64) {
1954                            emit_err = Some(err);
1955                            break;
1956                        }
1957                    }
1958                    if profile {
1959                        *emit_secs += t.elapsed().as_secs_f64();
1960                    }
1961                }
1962                next_to_emit += 1;
1963            }
1964        }
1965        worker.join().expect("phase4 merge worker panicked");
1966    });
1967
1968    if let Some(err) = io_err {
1969        return Err(BuildError::Io(err));
1970    }
1971    if let Some(err) = emit_err {
1972        return Err(BuildError::Emit(err));
1973    }
1974    Ok(())
1975}
1976
1977#[allow(clippy::too_many_arguments)]
1978fn merge_one_partition<S, I, L, B>(
1979    text: &[S],
1980    lp: &L,
1981    bucket: &mut B,
1982    max_ctx: usize,
1983    dispatch: LcpDispatch,
1984    memo_config: Option<MemoConfig>,
1985    memo_stats: &Mutex<MemoStats>,
1986    memo_profiled: bool,
1987    profile: bool,
1988    load_us: &std::sync::atomic::AtomicU64,
1989    merge_us: &std::sync::atomic::AtomicU64,
1990) -> io::Result<Vec<I>>
1991where
1992    S: Symbol,
1993    I: Index,
1994    L: LimitProvider,
1995    SaLcp<I>: BucketRecord,
1996    B: SaLcpBucketStore<I>,
1997{
1998    use std::sync::atomic::Ordering as AtomicOrdering;
1999
2000    if bucket.total_records() == 0 {
2001        return Ok(Vec::new());
2002    }
2003    let t = Instant::now();
2004    let (positions, lcps) = bucket.load_all_soa()?;
2005    let boundaries: Vec<usize> = bucket.boundaries().to_vec();
2006    if profile {
2007        load_us.fetch_add(t.elapsed().as_micros() as u64, AtomicOrdering::Relaxed);
2008    }
2009
2010    let t = Instant::now();
2011    let workspace = CascadeWorkspace::<I>::from_soa(positions, lcps);
2012    let result = if let Some(config) = memo_config {
2013        let mut memo = GeometricMemo::new(config);
2014        let result = if memo_profiled {
2015            workspace.cascade_merge_memoized_profiled(
2016                text,
2017                lp,
2018                &boundaries,
2019                max_ctx,
2020                dispatch,
2021                &mut memo,
2022            )
2023        } else {
2024            workspace.cascade_merge_memoized(text, lp, &boundaries, max_ctx, dispatch, &mut memo)
2025        };
2026        if memo_profiled {
2027            memo_stats
2028                .lock()
2029                .expect("memo profile mutex poisoned")
2030                .add_assign(memo.finish());
2031        }
2032        result
2033    } else {
2034        workspace.cascade_merge(text, lp, &boundaries, max_ctx, dispatch)
2035    };
2036    if profile {
2037        merge_us.fetch_add(t.elapsed().as_micros() as u64, AtomicOrdering::Relaxed);
2038    }
2039    Ok(result)
2040}
2041
2042/// Reusable ping-pong scratch for the partition cascade merge.
2043///
2044/// Holds two `(sa, lcp)` buffers each sized to the largest partition seen.
2045/// The cascade alternates reads from one side and writes to the other,
2046/// flipping a `src_is_a` flag after each level. Avoids the
2047/// per-level allocations that the previous immutable-`Vec` cascade
2048/// performed for every pair of sub-subarrays.
2049struct CascadeWorkspace<I> {
2050    a_sa: Vec<I>,
2051    a_lcp: Vec<I>,
2052    b_sa: Vec<I>,
2053    b_lcp: Vec<I>,
2054}
2055
2056impl<I: Index> CascadeWorkspace<I> {
2057    fn from_soa(a_sa: Vec<I>, a_lcp: Vec<I>) -> Self {
2058        assert_eq!(a_sa.len(), a_lcp.len());
2059        let n = a_sa.len();
2060        Self {
2061            a_sa,
2062            a_lcp,
2063            b_sa: vec![I::zero(); n],
2064            b_lcp: vec![I::zero(); n],
2065        }
2066    }
2067
2068    /// Cascade 2-way LCP-enhanced merges across the sub-subarrays of one
2069    /// partition (delimited by `boundaries`) until a single sorted run
2070    /// remains. **Consumes the workspace** and returns the result side
2071    /// as a `Vec<I>`; the other three buffers (`a_lcp`, the opposing
2072    /// `*_sa`, the opposing `*_lcp`) drop immediately. This shape lets
2073    /// the caller skip the per-partition `to_vec()` round-trip that
2074    /// would otherwise sit briefly alongside all four workspace buffers
2075    /// at peak.
2076    fn cascade_merge<S, L>(
2077        self,
2078        text: &[S],
2079        lp: &L,
2080        boundaries: &[usize],
2081        max_ctx: usize,
2082        dispatch: LcpDispatch,
2083    ) -> Vec<I>
2084    where
2085        S: Symbol,
2086        L: LimitProvider,
2087    {
2088        self.cascade_merge_impl(text, lp, boundaries, max_ctx, dispatch, None, false)
2089    }
2090
2091    #[allow(clippy::too_many_arguments)]
2092    fn cascade_merge_memoized<S, L>(
2093        self,
2094        text: &[S],
2095        lp: &L,
2096        boundaries: &[usize],
2097        max_ctx: usize,
2098        dispatch: LcpDispatch,
2099        memo: &mut GeometricMemo,
2100    ) -> Vec<I>
2101    where
2102        S: Symbol,
2103        L: LimitProvider,
2104    {
2105        self.cascade_merge_impl(text, lp, boundaries, max_ctx, dispatch, Some(memo), false)
2106    }
2107
2108    #[allow(clippy::too_many_arguments)]
2109    fn cascade_merge_memoized_profiled<S, L>(
2110        self,
2111        text: &[S],
2112        lp: &L,
2113        boundaries: &[usize],
2114        max_ctx: usize,
2115        dispatch: LcpDispatch,
2116        memo: &mut GeometricMemo,
2117    ) -> Vec<I>
2118    where
2119        S: Symbol,
2120        L: LimitProvider,
2121    {
2122        self.cascade_merge_impl(text, lp, boundaries, max_ctx, dispatch, Some(memo), true)
2123    }
2124
2125    #[allow(clippy::too_many_arguments)]
2126    fn cascade_merge_impl<S, L>(
2127        mut self,
2128        text: &[S],
2129        lp: &L,
2130        boundaries: &[usize],
2131        max_ctx: usize,
2132        dispatch: LcpDispatch,
2133        mut memo: Option<&mut GeometricMemo>,
2134        memo_profiled: bool,
2135    ) -> Vec<I>
2136    where
2137        S: Symbol,
2138        L: LimitProvider,
2139    {
2140        let n = self.a_sa.len();
2141        if n == 0 {
2142            return Vec::new();
2143        }
2144
2145        // Side A was decoded directly from the bucket in SOA form. Collect the
2146        // lengths of the non-empty sub-subarrays.
2147        let mut run_lens: Vec<usize> = boundaries
2148            .windows(2)
2149            .filter_map(|w| {
2150                let l = w[1] - w[0];
2151                if l > 0 { Some(l) } else { None }
2152            })
2153            .collect();
2154        let mut src_is_a = true;
2155        while run_lens.len() > 1 {
2156            run_lens = self.merge_one_level(
2157                src_is_a,
2158                &run_lens,
2159                text,
2160                lp,
2161                max_ctx,
2162                dispatch,
2163                memo.as_deref_mut(),
2164                memo_profiled,
2165            );
2166            src_is_a = !src_is_a;
2167        }
2168
2169        // Take ownership of the buffer holding the result, truncate to
2170        // the actual record count, drop the other three buffers with
2171        // `self` going out of scope.
2172        let mut result = if src_is_a { self.a_sa } else { self.b_sa };
2173        result.truncate(n);
2174        result
2175    }
2176
2177    /// Pair the runs in `run_lens` (last odd one passes through unchanged),
2178    /// running each pair through the LCP-enhanced 2-way merge from the
2179    /// `src_is_a`-selected buffer side into the other. Returns the new
2180    /// run-length list (each entry is the sum of the two it replaced, or
2181    /// the carry-over for an odd tail).
2182    #[allow(clippy::too_many_arguments)]
2183    fn merge_one_level<S, L>(
2184        &mut self,
2185        src_is_a: bool,
2186        run_lens: &[usize],
2187        text: &[S],
2188        lp: &L,
2189        max_ctx: usize,
2190        dispatch: LcpDispatch,
2191        mut memo: Option<&mut GeometricMemo>,
2192        memo_profiled: bool,
2193    ) -> Vec<usize>
2194    where
2195        S: Symbol,
2196        L: LimitProvider,
2197    {
2198        // Destructure self so the borrow checker can see the two sides as
2199        // disjoint locals — we borrow one immutably and the other mutably.
2200        let Self {
2201            a_sa,
2202            a_lcp,
2203            b_sa,
2204            b_lcp,
2205        } = self;
2206        let (src_sa, src_lcp, dst_sa, dst_lcp) = if src_is_a {
2207            (
2208                a_sa.as_slice(),
2209                a_lcp.as_slice(),
2210                b_sa.as_mut_slice(),
2211                b_lcp.as_mut_slice(),
2212            )
2213        } else {
2214            (
2215                b_sa.as_slice(),
2216                b_lcp.as_slice(),
2217                a_sa.as_mut_slice(),
2218                a_lcp.as_mut_slice(),
2219            )
2220        };
2221
2222        let mut new_lens = Vec::with_capacity(run_lens.len().div_ceil(2));
2223        let mut src_off = 0usize;
2224        let mut dst_off = 0usize;
2225        let mut i = 0;
2226        while i < run_lens.len() {
2227            let l1 = run_lens[i];
2228            if i + 1 < run_lens.len() {
2229                let l2 = run_lens[i + 1];
2230                let x_end = src_off + l1;
2231                let xy_end = x_end + l2;
2232                let dst_end = dst_off + l1 + l2;
2233                if let Some(memo) = memo.as_deref_mut() {
2234                    if memo.is_active() && memo_profiled {
2235                        sample_sort::merge_memoized_profiled(
2236                            text,
2237                            lp,
2238                            &src_sa[src_off..x_end],
2239                            &src_sa[x_end..xy_end],
2240                            &src_lcp[src_off..x_end],
2241                            &src_lcp[x_end..xy_end],
2242                            &mut dst_sa[dst_off..dst_end],
2243                            &mut dst_lcp[dst_off..dst_end],
2244                            max_ctx,
2245                            dispatch,
2246                            memo,
2247                        );
2248                    } else if memo.is_active() {
2249                        sample_sort::merge_memoized(
2250                            text,
2251                            lp,
2252                            &src_sa[src_off..x_end],
2253                            &src_sa[x_end..xy_end],
2254                            &src_lcp[src_off..x_end],
2255                            &src_lcp[x_end..xy_end],
2256                            &mut dst_sa[dst_off..dst_end],
2257                            &mut dst_lcp[dst_off..dst_end],
2258                            max_ctx,
2259                            dispatch,
2260                            memo,
2261                        );
2262                    } else if memo_profiled {
2263                        sample_sort::merge_memoized_training_profiled(
2264                            text,
2265                            lp,
2266                            &src_sa[src_off..x_end],
2267                            &src_sa[x_end..xy_end],
2268                            &src_lcp[src_off..x_end],
2269                            &src_lcp[x_end..xy_end],
2270                            &mut dst_sa[dst_off..dst_end],
2271                            &mut dst_lcp[dst_off..dst_end],
2272                            max_ctx,
2273                            dispatch,
2274                            memo,
2275                        );
2276                    } else {
2277                        sample_sort::merge_memoized_training(
2278                            text,
2279                            lp,
2280                            &src_sa[src_off..x_end],
2281                            &src_sa[x_end..xy_end],
2282                            &src_lcp[src_off..x_end],
2283                            &src_lcp[x_end..xy_end],
2284                            &mut dst_sa[dst_off..dst_end],
2285                            &mut dst_lcp[dst_off..dst_end],
2286                            max_ctx,
2287                            dispatch,
2288                            memo,
2289                        );
2290                    }
2291                } else {
2292                    sample_sort::merge(
2293                        text,
2294                        lp,
2295                        &src_sa[src_off..x_end],
2296                        &src_sa[x_end..xy_end],
2297                        &src_lcp[src_off..x_end],
2298                        &src_lcp[x_end..xy_end],
2299                        &mut dst_sa[dst_off..dst_end],
2300                        &mut dst_lcp[dst_off..dst_end],
2301                        max_ctx,
2302                        dispatch,
2303                    );
2304                }
2305                new_lens.push(l1 + l2);
2306                src_off = xy_end;
2307                dst_off = dst_end;
2308                i += 2;
2309            } else {
2310                // Odd run carries over unchanged.
2311                let end = dst_off + l1;
2312                dst_sa[dst_off..end].copy_from_slice(&src_sa[src_off..src_off + l1]);
2313                dst_lcp[dst_off..end].copy_from_slice(&src_lcp[src_off..src_off + l1]);
2314                new_lens.push(l1);
2315                src_off += l1;
2316                dst_off = end;
2317                i += 1;
2318            }
2319        }
2320        new_lens
2321    }
2322}
2323
2324#[cfg(test)]
2325mod tests {
2326    use super::*;
2327    use crate::build_in_memory;
2328    use std::ffi::OsString;
2329    use tempfile::tempdir;
2330
2331    static ENV_LOCK: Mutex<()> = Mutex::new(());
2332
2333    struct EnvGuard(Vec<(&'static str, Option<OsString>)>);
2334
2335    impl EnvGuard {
2336        fn capture(keys: &[&'static str]) -> Self {
2337            Self(
2338                keys.iter()
2339                    .map(|&key| (key, std::env::var_os(key)))
2340                    .collect(),
2341            )
2342        }
2343
2344        fn set(&self, key: &'static str, value: &str) {
2345            // The test serializes every mutation through ENV_LOCK and restores
2346            // all touched variables before releasing it.
2347            unsafe { std::env::set_var(key, value) };
2348        }
2349    }
2350
2351    impl Drop for EnvGuard {
2352        fn drop(&mut self) {
2353            for (key, value) in self.0.drain(..) {
2354                // See EnvGuard::set: this runs while the serial test lock is
2355                // still held and restores the process environment exactly.
2356                unsafe {
2357                    if let Some(value) = value {
2358                        std::env::set_var(key, value);
2359                    } else {
2360                        std::env::remove_var(key);
2361                    }
2362                }
2363            }
2364        }
2365    }
2366
2367    fn ext_mem_sa(text: &[u8], p: usize) -> Vec<u64> {
2368        let dir = tempdir().unwrap();
2369        let opts = ExtMemOpts {
2370            subproblem_count: p,
2371            physical_file_count: 1,
2372            work_dir: dir.path().to_path_buf(),
2373            ..ExtMemOpts::default()
2374        };
2375        let mut out: Vec<u64> = Vec::with_capacity(text.len());
2376        build_ext_mem(text, &opts, |pos| {
2377            out.push(pos);
2378            Ok(())
2379        })
2380        .unwrap();
2381        out
2382    }
2383
2384    fn ext_mem_sa_with_policy(
2385        text: &[u8],
2386        p: usize,
2387        lcp_memoization: LcpMemoizationPolicy,
2388    ) -> Vec<u64> {
2389        let dir = tempdir().unwrap();
2390        let opts = ExtMemOpts {
2391            subproblem_count: p,
2392            physical_file_count: 1,
2393            work_dir: dir.path().to_path_buf(),
2394            lcp_memoization,
2395            ..ExtMemOpts::default()
2396        };
2397        let mut out = Vec::with_capacity(text.len());
2398        build_ext_mem(text, &opts, |pos| {
2399            out.push(pos);
2400            Ok(())
2401        })
2402        .unwrap();
2403        out
2404    }
2405
2406    #[test]
2407    fn memoization_policy_defaults_to_disabled() {
2408        let opts = ExtMemOpts::default();
2409        assert_eq!(opts.lcp_memoization, LcpMemoizationPolicy::Disabled);
2410        assert!(!opts.collect_lcp_memoization_stats);
2411
2412        let config = GeometricMemoizationConfig::default();
2413        assert_eq!(config.probe_symbols(), 256);
2414        assert_eq!(config.min_lcp_symbols(), 1_024);
2415        assert_eq!(config.activate_after_entries(), 64);
2416        assert_eq!(config.max_entries_per_partition(), 4_096);
2417        assert_eq!(
2418            LcpMemoizationPolicy::geometric(),
2419            LcpMemoizationPolicy::Geometric(config)
2420        );
2421    }
2422
2423    #[test]
2424    fn geometric_policy_matches_direct_output() {
2425        let mut text = Vec::new();
2426        for i in 0..600 {
2427            text.extend_from_slice(b"ACGTACGTACGTACGTACGTACGTACGT");
2428            text.push((i % 5) as u8);
2429        }
2430        text.push(200);
2431
2432        let direct = ext_mem_sa_with_policy(&text, 16, LcpMemoizationPolicy::Disabled);
2433        let config = GeometricMemoizationConfig::default()
2434            .with_probe_symbols(NonZeroUsize::new(8).unwrap())
2435            .with_min_lcp_symbols(NonZeroUsize::new(16).unwrap())
2436            .with_activate_after_entries(NonZeroUsize::new(1).unwrap())
2437            .with_max_entries_per_partition(NonZeroUsize::new(128).unwrap());
2438        let memoized = ext_mem_sa_with_policy(&text, 16, LcpMemoizationPolicy::Geometric(config));
2439        assert_eq!(memoized, direct);
2440    }
2441
2442    #[test]
2443    fn from_env_parses_memoization_policy_and_rejects_zero_values() {
2444        let _lock = ENV_LOCK.lock().unwrap();
2445        let keys = [
2446            "CAPS_SA_GEOMETRIC_MEMO",
2447            "CAPS_SA_MEMO_PROBE",
2448            "CAPS_SA_MEMO_MIN_LCP",
2449            "CAPS_SA_MEMO_ACTIVATE_ENTRIES",
2450            "CAPS_SA_MEMO_CAPACITY",
2451            "CAPS_SA_MEMO_STATS",
2452        ];
2453        let env = EnvGuard::capture(&keys);
2454        env.set("CAPS_SA_GEOMETRIC_MEMO", "true");
2455        env.set("CAPS_SA_MEMO_PROBE", "32");
2456        env.set("CAPS_SA_MEMO_MIN_LCP", "256");
2457        env.set("CAPS_SA_MEMO_ACTIVATE_ENTRIES", "8");
2458        env.set("CAPS_SA_MEMO_CAPACITY", "512");
2459        env.set("CAPS_SA_MEMO_STATS", "yes");
2460
2461        let opts = ExtMemOpts::from_env();
2462        let LcpMemoizationPolicy::Geometric(config) = opts.lcp_memoization else {
2463            panic!("environment should enable geometric memoization");
2464        };
2465        assert_eq!(config.probe_symbols(), 32);
2466        assert_eq!(config.min_lcp_symbols(), 256);
2467        assert_eq!(config.activate_after_entries(), 8);
2468        assert_eq!(config.max_entries_per_partition(), 512);
2469        assert!(opts.collect_lcp_memoization_stats);
2470
2471        env.set("CAPS_SA_MEMO_PROBE", "0");
2472        env.set("CAPS_SA_MEMO_MIN_LCP", "0");
2473        env.set("CAPS_SA_MEMO_ACTIVATE_ENTRIES", "0");
2474        env.set("CAPS_SA_MEMO_CAPACITY", "0");
2475        let opts = ExtMemOpts::from_env();
2476        let LcpMemoizationPolicy::Geometric(config) = opts.lcp_memoization else {
2477            panic!("environment should still enable geometric memoization");
2478        };
2479        assert_eq!(config, GeometricMemoizationConfig::default());
2480    }
2481
2482    fn assert_matches_in_memory(text: &[u8], p: usize) {
2483        let want: Vec<u32> = build_in_memory(text);
2484        let want64: Vec<u64> = want.iter().map(|&x| x as u64).collect();
2485        let got = ext_mem_sa(text, p);
2486        assert_eq!(got, want64, "mismatch on text {text:?} with p={p}");
2487    }
2488
2489    #[test]
2490    fn ext_mem_empty() {
2491        let got = ext_mem_sa(b"", 4);
2492        assert!(got.is_empty());
2493    }
2494
2495    #[test]
2496    fn ext_mem_single_partition() {
2497        assert_matches_in_memory(b"banana", 1);
2498    }
2499
2500    #[test]
2501    fn ext_mem_p_greater_than_n() {
2502        assert_matches_in_memory(b"abc", 10);
2503    }
2504
2505    #[test]
2506    fn ext_mem_banana_p4() {
2507        assert_matches_in_memory(b"banana", 4);
2508    }
2509
2510    #[test]
2511    fn ext_mem_mississippi_p3() {
2512        assert_matches_in_memory(b"mississippi", 3);
2513    }
2514
2515    #[test]
2516    fn ext_mem_random_byte_texts() {
2517        use rand::{RngExt, SeedableRng};
2518        let mut rng = rand::rngs::StdRng::seed_from_u64(0xCAFE);
2519        for &n in &[16usize, 100, 1000, 5000] {
2520            for &p in &[1usize, 2, 4, 16] {
2521                let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..6u8)).collect();
2522                assert_matches_in_memory(&text, p);
2523            }
2524        }
2525    }
2526
2527    #[test]
2528    fn ext_mem_with_unique_terminator() {
2529        use rand::{RngExt, SeedableRng};
2530        let mut rng = rand::rngs::StdRng::seed_from_u64(0xF00D);
2531        for &n in &[10usize, 200, 2000] {
2532            for &p in &[1usize, 3, 8] {
2533                let mut text: Vec<u8> = (0..n).map(|_| rng.random_range(0..5u8)).collect();
2534                text.push(200);
2535                assert_matches_in_memory(&text, p);
2536            }
2537        }
2538    }
2539
2540    fn ext_mem_for_positions(text: &[u8], positions: Vec<u64>, p: usize) -> Vec<u64> {
2541        let dir = tempdir().unwrap();
2542        let opts = ExtMemOpts {
2543            subproblem_count: p,
2544            physical_file_count: 1,
2545            work_dir: dir.path().to_path_buf(),
2546            ..ExtMemOpts::default()
2547        };
2548        let mut out: Vec<u64> = Vec::with_capacity(positions.len());
2549        build_ext_mem_for_positions(text, positions, &opts, |pos| {
2550            out.push(pos);
2551            Ok(())
2552        })
2553        .unwrap();
2554        out
2555    }
2556
2557    #[test]
2558    fn ext_mem_for_positions_full_set_matches_ext_mem() {
2559        let text = b"mississippi";
2560        let want = ext_mem_sa(text, 3);
2561        let positions: Vec<u64> = (0..text.len() as u64).collect();
2562        let got = ext_mem_for_positions(text, positions, 3);
2563        assert_eq!(got, want);
2564    }
2565
2566    #[test]
2567    fn ext_mem_for_positions_subset_matches_brute_force() {
2568        let text = b"mississippi";
2569        let positions: Vec<u64> = (0..text.len() as u64).step_by(2).collect();
2570        let mut want = positions.clone();
2571        want.sort_by(|&a, &b| text[a as usize..].cmp(&text[b as usize..]));
2572        let got = ext_mem_for_positions(text, positions, 4);
2573        assert_eq!(got, want);
2574    }
2575
2576    #[test]
2577    fn ext_mem_for_positions_random_subsets() {
2578        use rand::{RngExt, SeedableRng};
2579        let mut rng = rand::rngs::StdRng::seed_from_u64(0xC0DE);
2580        for &n in &[50usize, 500, 2000] {
2581            for &p in &[1usize, 3, 8] {
2582                let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..5u8)).collect();
2583                let mut positions: Vec<u64> = (0..n as u64).collect();
2584                positions.retain(|_| rng.random_range(0..10) < 7);
2585                let mut want = positions.clone();
2586                want.sort_by(|&a, &b| text[a as usize..].cmp(&text[b as usize..]));
2587                let got = ext_mem_for_positions(&text, positions, p);
2588                assert_eq!(got, want, "subset ext-mem mismatch n={n} p={p}");
2589            }
2590        }
2591    }
2592
2593    fn in_memory_sample_sort(text: &[u8], p: usize) -> Vec<u64> {
2594        let dir = tempdir().unwrap();
2595        let opts = ExtMemOpts {
2596            subproblem_count: p,
2597            physical_file_count: 1,
2598            work_dir: dir.path().to_path_buf(),
2599            ..ExtMemOpts::default()
2600        };
2601        let mut out: Vec<u64> = Vec::with_capacity(text.len());
2602        build_in_memory_sample_sort(text, &opts, |pos| {
2603            out.push(pos);
2604            Ok(())
2605        })
2606        .unwrap();
2607        out
2608    }
2609
2610    #[test]
2611    fn in_memory_sample_sort_matches_in_memory() {
2612        for text in [b"banana" as &[u8], b"mississippi", b"abracadabra"] {
2613            let want: Vec<u32> = build_in_memory(text);
2614            let want64: Vec<u64> = want.iter().map(|&x| x as u64).collect();
2615            let got = in_memory_sample_sort(text, 0);
2616            assert_eq!(got, want64, "in-mem sample-sort mismatch on {text:?}");
2617        }
2618    }
2619
2620    #[test]
2621    fn in_memory_sample_sort_random_byte_texts() {
2622        use rand::{RngExt, SeedableRng};
2623        let mut rng = rand::rngs::StdRng::seed_from_u64(0xC0DE_C0DE);
2624        for &n in &[16usize, 200, 2000] {
2625            for &p in &[1usize, 4, 16] {
2626                let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..6u8)).collect();
2627                let want: Vec<u32> = build_in_memory(&text);
2628                let want64: Vec<u64> = want.iter().map(|&x| x as u64).collect();
2629                let got = in_memory_sample_sort(&text, p);
2630                assert_eq!(got, want64, "in-mem ss mismatch n={n} p={p}");
2631            }
2632        }
2633    }
2634
2635    /// Helper that drives [`build_ext_mem_for_filter`] and collects the
2636    /// emitted positions.
2637    fn ext_mem_for_filter<Pred>(text: &[u8], keep: Pred, p: usize) -> Vec<u64>
2638    where
2639        Pred: Fn(u64) -> bool + Send + Sync,
2640    {
2641        let dir = tempdir().unwrap();
2642        let opts = ExtMemOpts {
2643            subproblem_count: p,
2644            physical_file_count: 1,
2645            work_dir: dir.path().to_path_buf(),
2646            ..ExtMemOpts::default()
2647        };
2648        let mut out: Vec<u64> = Vec::new();
2649        build_ext_mem_for_filter(text, keep, &opts, |pos| {
2650            out.push(pos);
2651            Ok(())
2652        })
2653        .unwrap();
2654        out
2655    }
2656
2657    #[test]
2658    fn ext_mem_for_filter_matches_for_positions_on_full_set() {
2659        // Filter that accepts every position → must equal the
2660        // identity-positions ext-mem build.
2661        let text = b"mississippi";
2662        let want = ext_mem_sa(text, 3);
2663        let got = ext_mem_for_filter(text, |_p| true, 3);
2664        assert_eq!(got, want);
2665    }
2666
2667    #[test]
2668    fn ext_mem_for_filter_matches_for_positions_on_dna_subset() {
2669        // STAR-style "keep ACGT (`< 4`), drop N (`4`)/spacer (`5`)"
2670        // filter. The filter API must produce exactly the same SA as
2671        // pre-materialising the kept positions and going through the
2672        // _for_positions path.
2673        use rand::{RngExt, SeedableRng};
2674        let mut rng = rand::rngs::StdRng::seed_from_u64(0xCA_755A);
2675        for &n in &[50usize, 500, 2000] {
2676            for &p in &[1usize, 3, 8] {
2677                let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..6u8)).collect();
2678                let positions: Vec<u64> = (0..n as u64).filter(|&i| text[i as usize] < 4).collect();
2679                let want = ext_mem_for_positions(&text, positions, p);
2680                let got = ext_mem_for_filter(&text, |i| text[i as usize] < 4, p);
2681                assert_eq!(got, want, "filter vs positions mismatch n={n} p={p}");
2682            }
2683        }
2684    }
2685
2686    #[test]
2687    fn ext_mem_for_filter_handles_block_aligned_boundaries() {
2688        // Exercise the bitmap word/block boundaries by using a text
2689        // longer than one popcount block (1024 × 64 bits = 64 K
2690        // positions) — but stay under that to keep the test fast.
2691        // 200 K positions touches the cumsum's second block too.
2692        use rand::{RngExt, SeedableRng};
2693        let mut rng = rand::rngs::StdRng::seed_from_u64(0xB10C_C0DE);
2694        let n = 200_000usize;
2695        let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..6u8)).collect();
2696        let positions: Vec<u64> = (0..n as u64).filter(|&i| text[i as usize] < 4).collect();
2697        let want = ext_mem_for_positions(&text, positions, 8);
2698        let got = ext_mem_for_filter(&text, |i| text[i as usize] < 4, 8);
2699        assert_eq!(got, want, "filter API mismatch across block boundaries");
2700    }
2701
2702    #[test]
2703    fn ext_mem_for_filter_sparse_predicate() {
2704        // ~5% acceptance — exercises long runs of zero-bits in the
2705        // bitmap (skip-loop across whole words).
2706        use rand::{RngExt, SeedableRng};
2707        let mut rng = rand::rngs::StdRng::seed_from_u64(0x5_AA_55);
2708        let n = 50_000usize;
2709        let text: Vec<u8> = (0..n).map(|_| rng.random_range(0..20u8)).collect();
2710        let positions: Vec<u64> = (0..n as u64).filter(|&i| text[i as usize] < 1).collect();
2711        let want = ext_mem_for_positions(&text, positions, 4);
2712        let got = ext_mem_for_filter(&text, |i| text[i as usize] < 1, 4);
2713        assert_eq!(got, want, "filter API mismatch on sparse predicate");
2714    }
2715
2716    #[derive(Debug, PartialEq, Eq)]
2717    enum EmitTestError {
2718        Stop,
2719    }
2720
2721    #[test]
2722    fn try_ext_mem_returns_typed_emit_error() {
2723        let dir = tempdir().unwrap();
2724        let opts = ExtMemOpts {
2725            subproblem_count: 2,
2726            physical_file_count: 1,
2727            work_dir: dir.path().to_path_buf(),
2728            ..ExtMemOpts::default()
2729        };
2730        let mut seen = 0usize;
2731        let err = try_build_ext_mem(b"banana", &opts, |_pos| {
2732            seen += 1;
2733            if seen == 2 {
2734                Err(EmitTestError::Stop)
2735            } else {
2736                Ok(())
2737            }
2738        })
2739        .unwrap_err();
2740
2741        assert!(matches!(err, BuildError::Emit(EmitTestError::Stop)));
2742    }
2743
2744    #[test]
2745    fn ext_mem_repetitive_does_not_blow_up() {
2746        // Many copies of a long repeat — what killed the Phase 2 v1
2747        // linear-scan merge. The sample-sort + LCP-enhanced cascade
2748        // should handle it in proportional time.
2749        use std::time::Instant;
2750        let unit = b"ACGTACGTACGTACGTACGTACGTACGT"; // 28 bases
2751        let mut text: Vec<u8> = Vec::new();
2752        for _ in 0..100 {
2753            text.extend_from_slice(unit);
2754        }
2755        text.push(200);
2756        let start = Instant::now();
2757        let got = ext_mem_sa(&text, 8);
2758        let elapsed = start.elapsed();
2759        let want: Vec<u32> = build_in_memory(&text);
2760        let want64: Vec<u64> = want.iter().map(|&x| x as u64).collect();
2761        assert_eq!(got, want64);
2762        // Sanity: should finish in well under a second on this input.
2763        assert!(
2764            elapsed.as_secs() < 2,
2765            "ext-mem build on a tiny repetitive text took {elapsed:?}"
2766        );
2767    }
2768}