1use 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
41fn 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#[non_exhaustive]
58#[derive(Clone, Debug)]
59pub struct ExtMemOpts {
60 pub max_context: usize,
65 pub subproblem_count: usize,
69 pub work_dir: PathBuf,
71 pub physical_file_count: usize,
83 pub ordered_phase4_emit: bool,
89 pub lcp_memoization: LcpMemoizationPolicy,
93 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 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 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 pub fn max_context(mut self, max_context: usize) -> Self {
182 self.max_context = max_context;
183 self
184 }
185
186 pub fn subproblem_count(mut self, subproblem_count: usize) -> Self {
188 self.subproblem_count = subproblem_count;
189 self
190 }
191
192 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 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 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 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#[derive(Debug)]
234pub enum BuildError<E> {
235 Io(io::Error),
237 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
255pub 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
273pub 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
286pub 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
300pub 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 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
335pub 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
359pub 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
373pub 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
392pub 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 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
427pub 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
462pub 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
477pub 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
497pub 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 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(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
616fn 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 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
687pub 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
701pub 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
714pub 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
730pub 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
761pub 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
778pub 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
798pub 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
817pub 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
849enum PositionSource<'a> {
866 Identity(usize),
867 Subset(&'a [u64]),
868 Filtered(FilteredSource),
869}
870
871const FILTERED_WORDS_PER_BLOCK: usize = 1024;
877
878struct FilteredSource {
904 text_len: usize,
905 total_kept: usize,
906 bitmap: Vec<u64>,
909 cumsum: Vec<u64>,
913}
914
915impl FilteredSource {
916 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 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 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 #[inline]
982 fn len(&self) -> usize {
983 self.total_kept
984 }
985
986 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 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 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 for _ in 0..skip {
1028 word &= word - 1;
1029 }
1030 break;
1031 }
1032 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 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 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
1104const PHASE1_TARGET_CHUNK: usize = 65_536;
1109const PHASE1_MAX_PARTITIONS: usize = 8192;
1113
1114fn 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 p_from_size.clamp(nthreads, PHASE1_MAX_PARTITIONS)
1150 } else {
1151 requested
1152 };
1153 raw.clamp(1, n)
1154}
1155
1156#[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 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 let samples_per_subarray = samples_target_total.div_ceil(p).min(len);
1235 let samples = evenly_spaced(&sa, samples_per_subarray);
1236
1237 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
1255fn 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 p.saturating_mul(per).clamp(p, n)
1263}
1264
1265fn 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 (0..count)
1279 .map(|i| xs[(2 * i + 1) * n / (2 * count)])
1280 .collect()
1281}
1282
1283fn 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 (1..p).map(|j| samples[(j * n_samples) / p]).collect()
1318}
1319
1320#[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; 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 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 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 Ok(partition_buckets
1401 .into_iter()
1402 .map(|m| m.into_inner().expect("partition mutex poisoned"))
1403 .collect())
1404}
1405
1406fn 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 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#[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 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
1570fn 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
1634fn 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#[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 let chunk_size = rayon::current_num_threads().max(1) * 4;
1717
1718 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 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 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
2042struct 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 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 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 let mut result = if src_is_a { self.a_sa } else { self.b_sa };
2173 result.truncate(n);
2174 result
2175 }
2176
2177 #[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 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 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 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 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 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 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 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 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 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 use std::time::Instant;
2750 let unit = b"ACGTACGTACGTACGTACGTACGTACGT"; 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 assert!(
2764 elapsed.as_secs() < 2,
2765 "ext-mem build on a tiny repetitive text took {elapsed:?}"
2766 );
2767 }
2768}