Skip to main content

caps_sa/
lcp_memo.rs

1//! Per-partition geometric memoization of exact LCP intervals.
2//!
3//! An entry `(delta, end) -> start` proves that positions on one text
4//! diagonal match on `[start, end)` and mismatch at `end`.  The mismatch is
5//! essential: comparisons stopped only by a context or segment cap are lower
6//! bounds and are never admitted.
7
8use crate::lcp::{LcpDispatch, Symbol};
9use std::num::NonZeroUsize;
10
11const DEFAULT_PROBE: usize = 256;
12const DEFAULT_MIN_LCP: usize = 1_024;
13const DEFAULT_CAPACITY: usize = 4_096;
14const DEFAULT_ACTIVATE_ENTRIES: usize = 64;
15
16/// Controls whether phase-4 LCP comparisons use geometric memoization.
17///
18/// Memoization is disabled by default. Callers whose inputs contain many
19/// repeated long contexts can opt in with [`Self::Geometric`].
20#[non_exhaustive]
21#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
22pub enum LcpMemoizationPolicy {
23    /// Use the ordinary LCP-enhanced merge kernel without allocating a table.
24    #[default]
25    Disabled,
26    /// Learn and reuse exact LCP intervals within each partition cascade.
27    Geometric(GeometricMemoizationConfig),
28}
29
30impl LcpMemoizationPolicy {
31    /// Enable geometric memoization with the GRCh38-tuned defaults.
32    ///
33    /// This is equivalent to
34    /// `LcpMemoizationPolicy::Geometric(GeometricMemoizationConfig::default())`
35    /// without requiring callers to name the configuration type.
36    pub const fn geometric() -> Self {
37        Self::Geometric(GeometricMemoizationConfig::DEFAULT)
38    }
39}
40
41impl From<GeometricMemoizationConfig> for LcpMemoizationPolicy {
42    fn from(config: GeometricMemoizationConfig) -> Self {
43        Self::Geometric(config)
44    }
45}
46
47/// Tuning controls for [`LcpMemoizationPolicy::Geometric`].
48///
49/// The defaults were selected on a complete ruSTAR-shaped GRCh38 plus GENCODE
50/// workload. Tables are local to a single phase-4 partition cascade and are
51/// dropped when that partition is emitted.
52///
53/// ```
54/// use caps_sa::{GeometricMemoizationConfig, LcpMemoizationPolicy};
55/// use std::num::NonZeroUsize;
56///
57/// let config = GeometricMemoizationConfig::default()
58///     .with_probe_symbols(NonZeroUsize::new(512).unwrap());
59/// let policy = LcpMemoizationPolicy::from(config);
60/// # let _ = policy;
61/// ```
62#[non_exhaustive]
63#[derive(Copy, Clone, Debug, PartialEq, Eq)]
64pub struct GeometricMemoizationConfig {
65    probe_symbols: NonZeroUsize,
66    min_lcp_symbols: NonZeroUsize,
67    activate_after_entries: NonZeroUsize,
68    max_entries_per_partition: NonZeroUsize,
69}
70
71impl GeometricMemoizationConfig {
72    /// The default configuration measured on ruSTAR-shaped GRCh38 plus
73    /// GENCODE input.
74    pub const DEFAULT: Self = Self {
75        probe_symbols: NonZeroUsize::new(DEFAULT_PROBE).unwrap(),
76        min_lcp_symbols: NonZeroUsize::new(DEFAULT_MIN_LCP).unwrap(),
77        activate_after_entries: NonZeroUsize::new(DEFAULT_ACTIVATE_ENTRIES).unwrap(),
78        max_entries_per_partition: NonZeroUsize::new(DEFAULT_CAPACITY).unwrap(),
79    };
80
81    /// Number of symbols compared normally before an active-table lookup.
82    pub const fn probe_symbols(&self) -> usize {
83        self.probe_symbols.get()
84    }
85
86    /// Return this configuration with a new ordinary-comparison probe length.
87    ///
88    /// Production callers should normally retain the measured default so
89    /// short comparisons stay on the direct path.
90    pub const fn with_probe_symbols(mut self, probe_symbols: NonZeroUsize) -> Self {
91        self.probe_symbols = probe_symbols;
92        self
93    }
94
95    /// Minimum exact LCP length, including any prefix already known by the
96    /// merge, that is eligible for admission.
97    pub const fn min_lcp_symbols(&self) -> usize {
98        self.min_lcp_symbols.get()
99    }
100
101    /// Return this configuration with a new exact-LCP admission threshold.
102    pub const fn with_min_lcp_symbols(mut self, min_lcp_symbols: NonZeroUsize) -> Self {
103        self.min_lcp_symbols = min_lcp_symbols;
104        self
105    }
106
107    /// Number of learned entries required before a partition starts looking
108    /// entries up.
109    pub const fn activate_after_entries(&self) -> usize {
110        self.activate_after_entries.get()
111    }
112
113    /// Return this configuration with a new lazy-activation threshold.
114    ///
115    /// If this exceeds [`Self::max_entries_per_partition`], that partition
116    /// remains in the training kernel for its entire lifetime.
117    pub const fn with_activate_after_entries(
118        mut self,
119        activate_after_entries: NonZeroUsize,
120    ) -> Self {
121        self.activate_after_entries = activate_after_entries;
122        self
123    }
124
125    /// Hard entry bound for one phase-4 partition table.
126    pub const fn max_entries_per_partition(&self) -> usize {
127        self.max_entries_per_partition.get()
128    }
129
130    /// Return this configuration with a new per-partition entry bound.
131    ///
132    pub const fn with_max_entries_per_partition(
133        mut self,
134        max_entries_per_partition: NonZeroUsize,
135    ) -> Self {
136        self.max_entries_per_partition = max_entries_per_partition;
137        self
138    }
139}
140
141impl Default for GeometricMemoizationConfig {
142    fn default() -> Self {
143        Self::DEFAULT
144    }
145}
146
147/// Compact internal representation copied into the hot path once per build.
148#[derive(Copy, Clone, Debug, PartialEq, Eq)]
149pub(crate) struct MemoConfig {
150    pub(crate) probe: usize,
151    pub(crate) min_lcp: usize,
152    pub(crate) capacity: usize,
153    pub(crate) activate_entries: usize,
154}
155
156impl From<GeometricMemoizationConfig> for MemoConfig {
157    fn from(config: GeometricMemoizationConfig) -> Self {
158        Self {
159            probe: config.probe_symbols.get(),
160            min_lcp: config.min_lcp_symbols.get(),
161            capacity: config.max_entries_per_partition.get(),
162            activate_entries: config.activate_after_entries.get(),
163        }
164    }
165}
166
167/// Counters accumulated without synchronization inside one partition.
168#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
169pub(crate) struct MemoStats {
170    pub(crate) tables: u64,
171    pub(crate) active_tables: u64,
172    pub(crate) tables_0_15: u64,
173    pub(crate) tables_16_31: u64,
174    pub(crate) tables_32_63: u64,
175    pub(crate) tables_64_127: u64,
176    pub(crate) tables_128_255: u64,
177    pub(crate) tables_256_plus: u64,
178    pub(crate) calls: u64,
179    pub(crate) cold_direct: u64,
180    pub(crate) probe_resolved: u64,
181    pub(crate) lookups: u64,
182    pub(crate) direct_hits: u64,
183    pub(crate) gap_hits: u64,
184    pub(crate) misses: u64,
185    pub(crate) inserts: u64,
186    pub(crate) extensions: u64,
187    pub(crate) capacity_rejects: u64,
188    pub(crate) scanned_matches: u64,
189    pub(crate) skipped_matches: u64,
190    pub(crate) final_entries: u64,
191    pub(crate) max_entries: u64,
192    pub(crate) unique_diagonals: u64,
193    pub(crate) singleton_diagonals: u64,
194    pub(crate) max_entries_per_diagonal: u64,
195    pub(crate) lookup_steps: u64,
196    pub(crate) insert_steps: u64,
197    pub(crate) insert_shifts: u64,
198    pub(crate) gap_mismatches: u64,
199    pub(crate) gap_caps: u64,
200}
201
202impl MemoStats {
203    pub(crate) fn add_assign(&mut self, other: Self) {
204        self.tables = self.tables.saturating_add(other.tables);
205        self.active_tables = self.active_tables.saturating_add(other.active_tables);
206        self.tables_0_15 = self.tables_0_15.saturating_add(other.tables_0_15);
207        self.tables_16_31 = self.tables_16_31.saturating_add(other.tables_16_31);
208        self.tables_32_63 = self.tables_32_63.saturating_add(other.tables_32_63);
209        self.tables_64_127 = self.tables_64_127.saturating_add(other.tables_64_127);
210        self.tables_128_255 = self.tables_128_255.saturating_add(other.tables_128_255);
211        self.tables_256_plus = self.tables_256_plus.saturating_add(other.tables_256_plus);
212        self.calls = self.calls.saturating_add(other.calls);
213        self.cold_direct = self.cold_direct.saturating_add(other.cold_direct);
214        self.probe_resolved = self.probe_resolved.saturating_add(other.probe_resolved);
215        self.lookups = self.lookups.saturating_add(other.lookups);
216        self.direct_hits = self.direct_hits.saturating_add(other.direct_hits);
217        self.gap_hits = self.gap_hits.saturating_add(other.gap_hits);
218        self.misses = self.misses.saturating_add(other.misses);
219        self.inserts = self.inserts.saturating_add(other.inserts);
220        self.extensions = self.extensions.saturating_add(other.extensions);
221        self.capacity_rejects = self.capacity_rejects.saturating_add(other.capacity_rejects);
222        self.scanned_matches = self.scanned_matches.saturating_add(other.scanned_matches);
223        self.skipped_matches = self.skipped_matches.saturating_add(other.skipped_matches);
224        self.final_entries = self.final_entries.saturating_add(other.final_entries);
225        self.max_entries = self.max_entries.max(other.max_entries);
226        self.unique_diagonals = self.unique_diagonals.saturating_add(other.unique_diagonals);
227        self.singleton_diagonals = self
228            .singleton_diagonals
229            .saturating_add(other.singleton_diagonals);
230        self.max_entries_per_diagonal = self
231            .max_entries_per_diagonal
232            .max(other.max_entries_per_diagonal);
233        self.lookup_steps = self.lookup_steps.saturating_add(other.lookup_steps);
234        self.insert_steps = self.insert_steps.saturating_add(other.insert_steps);
235        self.insert_shifts = self.insert_shifts.saturating_add(other.insert_shifts);
236        self.gap_mismatches = self.gap_mismatches.saturating_add(other.gap_mismatches);
237        self.gap_caps = self.gap_caps.saturating_add(other.gap_caps);
238    }
239}
240
241#[derive(Copy, Clone, Debug, PartialEq, Eq)]
242struct MemoEntry {
243    diagonal: usize,
244    end: usize,
245    start: usize,
246}
247
248/// A bounded successor map local to one phase-4 partition cascade.
249pub(crate) struct GeometricMemo {
250    config: MemoConfig,
251    entries: Vec<MemoEntry>,
252    stats: MemoStats,
253}
254
255impl GeometricMemo {
256    pub(crate) fn new(config: MemoConfig) -> Self {
257        let initial_capacity = config.capacity.min(256);
258        Self {
259            config,
260            entries: Vec::with_capacity(initial_capacity),
261            stats: MemoStats::default(),
262        }
263    }
264
265    pub(crate) fn finish(mut self) -> MemoStats {
266        self.stats.tables = 1;
267        self.stats.final_entries = self.entries.len() as u64;
268        self.stats.active_tables = u64::from(self.is_active());
269        match self.entries.len() {
270            0..=15 => self.stats.tables_0_15 = 1,
271            16..=31 => self.stats.tables_16_31 = 1,
272            32..=63 => self.stats.tables_32_63 = 1,
273            64..=127 => self.stats.tables_64_127 = 1,
274            128..=255 => self.stats.tables_128_255 = 1,
275            _ => self.stats.tables_256_plus = 1,
276        }
277        let mut group_start = 0usize;
278        while group_start < self.entries.len() {
279            let diagonal = self.entries[group_start].diagonal;
280            let mut group_end = group_start + 1;
281            while group_end < self.entries.len() && self.entries[group_end].diagonal == diagonal {
282                group_end += 1;
283            }
284            let group_len = group_end - group_start;
285            self.stats.unique_diagonals += 1;
286            self.stats.singleton_diagonals += u64::from(group_len == 1);
287            self.stats.max_entries_per_diagonal =
288                self.stats.max_entries_per_diagonal.max(group_len as u64);
289            group_start = group_end;
290        }
291        self.stats
292    }
293
294    #[inline]
295    pub(crate) fn is_active(&self) -> bool {
296        self.entries.len() >= self.config.activate_entries
297    }
298
299    #[inline]
300    pub(crate) fn probe(&self, max_ext: usize) -> usize {
301        max_ext.min(self.config.probe)
302    }
303
304    /// Extend the LCP of `text[p..]` and `text[q..]` after a prefix of
305    /// `known` symbols has already been proved equal by the merge invariant.
306    /// The returned extension is bounded by `max_ext`.
307    #[cfg(test)]
308    #[inline]
309    pub(crate) fn lcp<S: Symbol>(
310        &mut self,
311        text: &[S],
312        dispatch: LcpDispatch,
313        p: usize,
314        q: usize,
315        known: usize,
316        max_ext: usize,
317    ) -> usize {
318        if self.is_active() {
319            self.lcp_active_impl::<S, false>(text, dispatch, p, q, known, max_ext)
320        } else {
321            let got = dispatch.lcp(text, p + known, q + known, max_ext);
322            self.observe_training_impl::<false>(p, q, known, got, max_ext);
323            got
324        }
325    }
326
327    /// Instrumented form of [`Self::lcp`]. Keeping the choice outside the hot
328    /// merge loop lets LLVM erase all counter branches from normal runs.
329    #[cfg(test)]
330    #[inline]
331    pub(crate) fn lcp_profiled<S: Symbol>(
332        &mut self,
333        text: &[S],
334        dispatch: LcpDispatch,
335        p: usize,
336        q: usize,
337        known: usize,
338        max_ext: usize,
339    ) -> usize {
340        if self.is_active() {
341            self.lcp_active_impl::<S, true>(text, dispatch, p, q, known, max_ext)
342        } else {
343            let got = dispatch.lcp(text, p + known, q + known, max_ext);
344            self.observe_training_impl::<true>(p, q, known, got, max_ext);
345            got
346        }
347    }
348
349    #[inline]
350    pub(crate) fn observe_training(
351        &mut self,
352        p: usize,
353        q: usize,
354        known: usize,
355        got: usize,
356        max_ext: usize,
357    ) {
358        self.observe_training_impl::<false>(p, q, known, got, max_ext);
359    }
360
361    #[inline]
362    pub(crate) fn observe_training_profiled(
363        &mut self,
364        p: usize,
365        q: usize,
366        known: usize,
367        got: usize,
368        max_ext: usize,
369    ) {
370        self.observe_training_impl::<true>(p, q, known, got, max_ext);
371    }
372
373    #[inline]
374    fn observe_training_impl<const STATS: bool>(
375        &mut self,
376        p: usize,
377        q: usize,
378        known: usize,
379        got: usize,
380        max_ext: usize,
381    ) {
382        if STATS {
383            self.stats.calls = self.stats.calls.saturating_add(1);
384            self.stats.cold_direct = self.stats.cold_direct.saturating_add(1);
385            self.stats.scanned_matches = self.stats.scanned_matches.saturating_add(got as u64);
386        }
387        let total = known + got;
388        // Long LCPs are rare. Test the selective condition first so ordinary
389        // short mismatches pay one branch, not the exactness and active-state
390        // checks as well.
391        if total >= self.config.min_lcp && got < max_ext && !self.is_active() {
392            let (base, other) = if p < q { (p, q) } else { (q, p) };
393            self.insert_exact::<STATS>(base, other - base, total);
394        }
395    }
396
397    #[cfg(test)]
398    #[inline]
399    fn lcp_active_impl<S: Symbol, const STATS: bool>(
400        &mut self,
401        text: &[S],
402        dispatch: LcpDispatch,
403        p: usize,
404        q: usize,
405        known: usize,
406        max_ext: usize,
407    ) -> usize {
408        if STATS {
409            self.stats.calls = self.stats.calls.saturating_add(1);
410        }
411        if max_ext == 0 || p == q {
412            return dispatch.lcp(
413                text,
414                p.saturating_add(known),
415                q.saturating_add(known),
416                max_ext,
417            );
418        }
419
420        let scan_p = p.saturating_add(known);
421        let scan_q = q.saturating_add(known);
422
423        // Resolve ordinary short comparisons before paying for a table query.
424        let probe = self.probe(max_ext);
425        let got = dispatch.lcp(text, scan_p, scan_q, probe);
426        self.add_scanned::<STATS>(got);
427        if got < probe {
428            if STATS {
429                self.stats.probe_resolved = self.stats.probe_resolved.saturating_add(1);
430            }
431            return got;
432        }
433        if probe == max_ext {
434            if STATS {
435                self.stats.probe_resolved = self.stats.probe_resolved.saturating_add(1);
436            }
437            return got;
438        }
439
440        self.lcp_after_probe_impl::<S, STATS>(text, dispatch, p, q, known, probe, max_ext)
441    }
442
443    #[inline]
444    #[allow(clippy::too_many_arguments)]
445    pub(crate) fn lcp_after_probe<S: Symbol>(
446        &mut self,
447        text: &[S],
448        dispatch: LcpDispatch,
449        p: usize,
450        q: usize,
451        known: usize,
452        probe: usize,
453        max_ext: usize,
454    ) -> usize {
455        debug_assert!(self.is_active());
456        self.lcp_after_probe_impl::<S, false>(text, dispatch, p, q, known, probe, max_ext)
457    }
458
459    #[inline]
460    #[allow(clippy::too_many_arguments)]
461    pub(crate) fn lcp_after_probe_profiled<S: Symbol>(
462        &mut self,
463        text: &[S],
464        dispatch: LcpDispatch,
465        p: usize,
466        q: usize,
467        known: usize,
468        probe: usize,
469        max_ext: usize,
470    ) -> usize {
471        debug_assert!(self.is_active());
472        self.lcp_after_probe_impl::<S, true>(text, dispatch, p, q, known, probe, max_ext)
473    }
474
475    #[inline]
476    pub(crate) fn record_probe_profiled(&mut self, got: usize, probe: usize, max_ext: usize) {
477        self.stats.calls = self.stats.calls.saturating_add(1);
478        self.stats.scanned_matches = self.stats.scanned_matches.saturating_add(got as u64);
479        if got < probe || probe == max_ext {
480            self.stats.probe_resolved = self.stats.probe_resolved.saturating_add(1);
481        }
482    }
483
484    #[allow(clippy::too_many_arguments)]
485    fn lcp_after_probe_impl<S: Symbol, const STATS: bool>(
486        &mut self,
487        text: &[S],
488        dispatch: LcpDispatch,
489        p: usize,
490        q: usize,
491        known: usize,
492        probe: usize,
493        max_ext: usize,
494    ) -> usize {
495        let scan_p = p.saturating_add(known);
496        let scan_q = q.saturating_add(known);
497        let (base, other) = if p < q { (p, q) } else { (q, p) };
498        let diagonal = other - base;
499
500        let query = base.saturating_add(known).saturating_add(probe);
501        let remaining = max_ext - probe;
502
503        if STATS {
504            self.stats.lookups = self.stats.lookups.saturating_add(1);
505        }
506        let successor_index = if STATS {
507            let mut steps = 0u64;
508            let index = self.entries.partition_point(|entry| {
509                steps += 1;
510                (entry.diagonal, entry.end) < (diagonal, query)
511            });
512            self.stats.lookup_steps = self.stats.lookup_steps.saturating_add(steps);
513            index
514        } else {
515            self.entries
516                .partition_point(|entry| (entry.diagonal, entry.end) < (diagonal, query))
517        };
518        let successor = self.entries.get(successor_index).and_then(|entry| {
519            (entry.diagonal == diagonal).then_some((successor_index, entry.end, entry.start))
520        });
521
522        let Some((successor_index, end, start)) = successor else {
523            if STATS {
524                self.stats.misses = self.stats.misses.saturating_add(1);
525            }
526            return probe
527                + self.scan_tail_and_insert::<S, STATS>(
528                    text, dispatch, p, q, base, diagonal, known, probe, remaining,
529                );
530        };
531
532        if start <= query {
533            // The query lies inside a proved interval.  Its endpoint is an
534            // observed mismatch, unless the caller's cap stops us first.
535            let available = end - query;
536            let skipped = available.min(remaining);
537            if STATS {
538                self.stats.direct_hits = self.stats.direct_hits.saturating_add(1);
539            }
540            self.add_skipped::<STATS>(skipped);
541            self.extend_start::<STATS>(successor_index, base);
542            return probe + skipped;
543        }
544
545        // Scan the unknown gap before the stored interval.  If it matches,
546        // the interval can be extended left through both the gap and the
547        // merge's already-known prefix.
548        let gap = start - query;
549        let gap_cap = gap.min(remaining);
550        let gap_lcp = dispatch.lcp(
551            text,
552            scan_p.saturating_add(probe),
553            scan_q.saturating_add(probe),
554            gap_cap,
555        );
556        self.add_scanned::<STATS>(gap_lcp);
557        if gap_lcp < gap_cap {
558            if STATS {
559                self.stats.gap_mismatches = self.stats.gap_mismatches.saturating_add(1);
560            }
561            self.insert_exact::<STATS>(
562                base,
563                diagonal,
564                known.saturating_add(probe).saturating_add(gap_lcp),
565            );
566            return probe + gap_lcp;
567        }
568        if gap_cap == remaining {
569            if STATS {
570                self.stats.gap_caps = self.stats.gap_caps.saturating_add(1);
571            }
572            return max_ext;
573        }
574
575        let interval_len = end - start;
576        let skipped = interval_len.min(remaining - gap);
577        if STATS {
578            self.stats.gap_hits = self.stats.gap_hits.saturating_add(1);
579        }
580        self.add_skipped::<STATS>(skipped);
581        self.extend_start::<STATS>(successor_index, base);
582        probe + gap + skipped
583    }
584
585    #[allow(clippy::too_many_arguments)]
586    fn scan_tail_and_insert<S: Symbol, const STATS: bool>(
587        &mut self,
588        text: &[S],
589        dispatch: LcpDispatch,
590        p: usize,
591        q: usize,
592        base: usize,
593        diagonal: usize,
594        known: usize,
595        already_scanned: usize,
596        remaining: usize,
597    ) -> usize {
598        let got = dispatch.lcp(
599            text,
600            p.saturating_add(known).saturating_add(already_scanned),
601            q.saturating_add(known).saturating_add(already_scanned),
602            remaining,
603        );
604        self.add_scanned::<STATS>(got);
605        if got < remaining {
606            self.insert_exact::<STATS>(
607                base,
608                diagonal,
609                known.saturating_add(already_scanned).saturating_add(got),
610            );
611        }
612        got
613    }
614
615    fn insert_exact<const STATS: bool>(&mut self, base: usize, diagonal: usize, lcp: usize) {
616        if lcp < self.config.min_lcp {
617            return;
618        }
619        let end = base.saturating_add(lcp);
620        let key = (diagonal, end);
621        let index = if STATS {
622            let mut steps = 0u64;
623            let index = self.entries.partition_point(|entry| {
624                steps += 1;
625                (entry.diagonal, entry.end) < key
626            });
627            self.stats.insert_steps = self.stats.insert_steps.saturating_add(steps);
628            index
629        } else {
630            self.entries
631                .partition_point(|entry| (entry.diagonal, entry.end) < key)
632        };
633        if let Some(entry) = self
634            .entries
635            .get_mut(index)
636            .filter(|entry| (entry.diagonal, entry.end) == key)
637        {
638            if base < entry.start {
639                entry.start = base;
640                if STATS {
641                    self.stats.extensions = self.stats.extensions.saturating_add(1);
642                }
643            }
644            return;
645        }
646        if self.entries.len() >= self.config.capacity {
647            if STATS {
648                self.stats.capacity_rejects = self.stats.capacity_rejects.saturating_add(1);
649            }
650            return;
651        }
652        if STATS {
653            self.stats.insert_shifts = self
654                .stats
655                .insert_shifts
656                .saturating_add((self.entries.len() - index) as u64);
657        }
658        self.entries.insert(
659            index,
660            MemoEntry {
661                diagonal,
662                end,
663                start: base,
664            },
665        );
666        if STATS {
667            self.stats.inserts = self.stats.inserts.saturating_add(1);
668            self.stats.max_entries = self.stats.max_entries.max(self.entries.len() as u64);
669        }
670    }
671
672    fn extend_start<const STATS: bool>(&mut self, index: usize, start: usize) {
673        let Some(entry) = self.entries.get_mut(index) else {
674            return;
675        };
676        if start < entry.start {
677            entry.start = start;
678            if STATS {
679                self.stats.extensions = self.stats.extensions.saturating_add(1);
680            }
681        }
682    }
683
684    fn add_scanned<const STATS: bool>(&mut self, value: usize) {
685        if STATS {
686            self.stats.scanned_matches = self.stats.scanned_matches.saturating_add(value as u64);
687        }
688    }
689
690    fn add_skipped<const STATS: bool>(&mut self, value: usize) {
691        if STATS {
692            self.stats.skipped_matches = self.stats.skipped_matches.saturating_add(value as u64);
693        }
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    fn entry_start(memo: &GeometricMemo, diagonal: usize, end: usize) -> Option<usize> {
702        memo.entries
703            .iter()
704            .find(|entry| entry.diagonal == diagonal && entry.end == end)
705            .map(|entry| entry.start)
706    }
707
708    fn config() -> MemoConfig {
709        MemoConfig {
710            probe: 64,
711            min_lcp: 128,
712            capacity: 64,
713            activate_entries: 1,
714        }
715    }
716
717    fn naive_lcp<S: Symbol>(text: &[S], p: usize, q: usize, cap: usize) -> usize {
718        let lim = text
719            .len()
720            .saturating_sub(p)
721            .min(text.len().saturating_sub(q))
722            .min(cap);
723        (0..lim).take_while(|&i| text[p + i] == text[q + i]).count()
724    }
725
726    fn repeated_blocks() -> Vec<u8> {
727        let mut text = vec![b'A'; 6_100];
728        text[2_000] = b'C';
729        text[5_000] = b'G';
730        text
731    }
732
733    #[test]
734    fn direct_hit_returns_exact_subsumed_lcp() {
735        let text = repeated_blocks();
736        let dispatch = LcpDispatch::detect();
737        let mut memo = GeometricMemo::new(config());
738        assert_eq!(
739            memo.lcp_profiled(&text, dispatch, 0, 3_000, 0, 2_500),
740            2_000
741        );
742        assert_eq!(
743            memo.lcp_profiled(&text, dispatch, 500, 3_500, 0, 2_000),
744            1_500
745        );
746        let stats = memo.finish();
747        assert_eq!(stats.inserts, 1);
748        assert_eq!(stats.direct_hits, 1);
749        assert!(stats.skipped_matches >= 1_400);
750    }
751
752    #[test]
753    fn gap_hit_extends_existing_endpoint() {
754        let text = repeated_blocks();
755        let dispatch = LcpDispatch::detect();
756        let mut memo = GeometricMemo::new(config());
757        assert_eq!(
758            memo.lcp_profiled(&text, dispatch, 500, 3_500, 0, 2_000),
759            1_500
760        );
761        assert_eq!(
762            memo.lcp_profiled(&text, dispatch, 0, 3_000, 0, 2_500),
763            2_000
764        );
765        assert_eq!(entry_start(&memo, 3_000, 2_000), Some(0));
766        let stats = memo.finish();
767        assert_eq!(stats.gap_hits, 1);
768        assert_eq!(stats.extensions, 1);
769    }
770
771    #[test]
772    fn capped_match_is_not_admitted_as_exact() {
773        let text = repeated_blocks();
774        let dispatch = LcpDispatch::detect();
775        let mut memo = GeometricMemo::new(config());
776        assert_eq!(memo.lcp(&text, dispatch, 0, 3_000, 0, 512), 512);
777        assert!(memo.entries.is_empty());
778    }
779
780    #[test]
781    fn known_prefix_is_included_in_admitted_interval() {
782        let text = repeated_blocks();
783        let dispatch = LcpDispatch::detect();
784        let mut memo = GeometricMemo::new(config());
785        assert_eq!(memo.lcp(&text, dispatch, 0, 3_000, 1_000, 1_500), 1_000);
786        assert_eq!(entry_start(&memo, 3_000, 2_000), Some(0));
787    }
788
789    #[test]
790    fn randomized_queries_match_naive_for_u8_and_i8() {
791        let mut state = 0xd1b5_4a32_d192_ed03u64;
792        let mut next = || {
793            state ^= state << 13;
794            state ^= state >> 7;
795            state ^= state << 17;
796            state
797        };
798        let text_u8: Vec<u8> = (0..16_384).map(|_| (next() % 5) as u8).collect();
799        let text_i8: Vec<i8> = text_u8.iter().map(|&x| x as i8 - 2).collect();
800        check_randomized(&text_u8, &mut next);
801        check_randomized(&text_i8, &mut next);
802    }
803
804    fn check_randomized<S: Symbol>(text: &[S], next: &mut impl FnMut() -> u64) {
805        let dispatch = LcpDispatch::detect();
806        let mut memo = GeometricMemo::new(MemoConfig {
807            probe: 4,
808            min_lcp: 8,
809            capacity: 1_024,
810            activate_entries: 1,
811        });
812        for _ in 0..20_000 {
813            let p = next() as usize % (text.len() - 1);
814            let mut q = next() as usize % (text.len() - 1);
815            if q == p {
816                q = (q + 1) % (text.len() - 1);
817            }
818            let full = naive_lcp(text, p, q, usize::MAX);
819            let known = if full == 0 {
820                0
821            } else {
822                next() as usize % (full + 1)
823            };
824            let cap = 1 + next() as usize % 64;
825            let want = naive_lcp(text, p + known, q + known, cap);
826            let got = memo.lcp(text, dispatch, p, q, known, cap);
827            assert_eq!(got, want, "p={p} q={q} known={known} cap={cap}");
828        }
829    }
830
831    #[test]
832    fn capacity_is_strict_but_existing_endpoint_can_extend() {
833        let text = repeated_blocks();
834        let dispatch = LcpDispatch::detect();
835        let mut memo = GeometricMemo::new(MemoConfig {
836            capacity: 1,
837            ..config()
838        });
839        assert_eq!(memo.lcp(&text, dispatch, 500, 3_500, 0, 2_000), 1_500);
840        // A different diagonal cannot add a second entry.
841        let _ = memo.lcp(&text, dispatch, 0, 3_001, 0, 2_500);
842        // The existing endpoint is still extendable while at capacity.
843        assert_eq!(memo.lcp(&text, dispatch, 0, 3_000, 0, 2_500), 2_000);
844        assert_eq!(memo.entries.len(), 1);
845        assert_eq!(entry_start(&memo, 3_000, 2_000), Some(0));
846    }
847}