Repository navigation
Expand file tree
/
Copy pathgestalt.rs
More file actions
2089 lines (2007 loc) · 89.9 KB
/
Copy pathgestalt.rs
File metadata and controls
2089 lines (2007 loc) · 89.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Gestalt-Chain: fast **exact** Ratcliff–Obershelp via a suffix automaton.
//!
//! difflib's `ratio` is slow because `find_longest_match` is O(|a|·|b|) (it rescans every
//! occurrence of popular characters). The matched-block total M is the recursive
//! longest-common-substring decomposition; a **suffix automaton** finds the longest common
//! substring in O(|a|+|b|) regardless of character frequency, and recursing on the left/
//! right remainders reproduces difflib's M with the *same* tie-break (longest; earliest in
//! a; earliest in b). So `gestalt_ratio` equals `difflib.SequenceMatcher.ratio()` exactly.
//!
//! Hot-path engineering for the all-pairs join:
//! * transitions are stored **CSR** (per-state sorted slice) → O(log deg) lookup, O(len)
//! memory, no hashing — and crucially no O(deg) scan at the high-degree root, which the
//! scan hammers when strings are dissimilar;
//! * the b-side automaton is **prebuilt once per string** (`build_sam`) and reused for
//! every pair, so the all-pairs cost is n builds + n² scans, not n² builds;
//! * the scan reads one 32-byte slot per state whose inline transition continues along `b`,
//! so a match that keeps extending costs one load per character (`Sam::fast`);
//! * the recursion's narrow windows (`b` side of a few characters) go to a direct row-by-row
//! comparison (`longest_direct`) instead of climbing the automaton for every position, and
//! the early-exit recursions take the largest window first so their bounds close sooner;
//! * the scan can stop the moment a single match proves the pair above a bound
//! (`matching_stats_bounded`), and a common prefix or suffix decides many pairs before it.
//! * **prefetch hints attempted on the per-iteration `node[state]` load** — the SAM walk is a
//! data-dependent pointer chase the hardware prefetcher cannot anticipate. A `prfm pldl1keep`
//! (`AArch64`) / `_mm_prefetch` (x86) experiment was MEASURED on M3 Pro and did NOT pay off
//! (-10% to -2% across mypy/sympy/django) — the SAMs fit in L2 already, and the prefetch
//! instruction burns execution slots without producing a hit-rate improvement. See the tombstone
//! near `matching_stats_into` for details.
//!
//! Operates on `char` (code points), so it is bit-identical to difflib on non-ASCII.
// Note: a software-prefetch experiment (prfm pldl1keep / _mm_prefetch for the next iteration's
// node[state] load) was tried and DID NOT pay off on M3 Pro — the prefetch instruction itself
// burned execution slots without producing a measurable hit-rate improvement, because the SAM
// pages fit comfortably in L2 for the per-pair working set (~100KB for the two SAMs in play)
// and the hardware prefetcher already keeps the hot stretches warm. Net: -10% to -2% across
// mypy/sympy/django. Left here as a tombstone so future readers don't waste a day on it.
/// Size of the root's direct transition table — covers ASCII (the canonical text's alphabet).
const ROOT_TBL: usize = 128;
/// `#[cfg(feature = "instrument")]` per-call counters for data-driven perf decisions. Compiles to
/// no-op in default builds (no atomic ops, no codegen). Enabled via `cargo build --features
/// instrument`. The counters use `Ordering::Relaxed` because we only care about totals at
/// end-of-workload (no causal ordering needed) — the relaxed cost is one atomic add per inc.
///
/// Cross-thread aggregation: every rayon worker writes into the same global atomics; we read them
/// after the workload completes via `instrument::dump()`. Use `instrument::reset()` between
/// successive workload runs in the same process so the numbers stay per-workload.
#[cfg(feature = "instrument")]
#[allow(clippy::all, clippy::pedantic)] // diagnostics only; never in a default build
pub mod instrument {
use std::sync::atomic::{AtomicU64, Ordering};
/// Histogram bucket count for chain walk + recursion depth. Anything deeper than 63 falls in
/// the last bucket — measured tail on canonical Python is < 30 in practice, so 64 is plenty.
pub const HIST_BUCKETS: usize = 64;
pub static LONGEST_IN_CALLS: AtomicU64 = AtomicU64::new(0);
pub static PAIRS_PROCESSED: AtomicU64 = AtomicU64::new(0);
pub static MAX_LE_CALLS: AtomicU64 = AtomicU64::new(0);
pub static MAX_LE_FAST_PATH: AtomicU64 = AtomicU64::new(0); // lastpos<=x or firstpos>x shortcut
pub static MAX_LE_LINEAR: AtomicU64 = AtomicU64::new(0); // cnt <= LINEAR_MAX scan
pub static MAX_LE_LINEAR_LEN_SUM: AtomicU64 = AtomicU64::new(0); // total entries scanned
pub static MAX_LE_SEGTREE: AtomicU64 = AtomicU64::new(0); // merge-sort-tree walk
pub static MIN_IN_CALLS: AtomicU64 = AtomicU64::new(0);
pub static MIN_IN_FAST_PATH: AtomicU64 = AtomicU64::new(0);
pub static MIN_IN_LINEAR: AtomicU64 = AtomicU64::new(0);
pub static MIN_IN_LINEAR_LEN_SUM: AtomicU64 = AtomicU64::new(0);
pub static MIN_IN_SEGTREE: AtomicU64 = AtomicU64::new(0);
pub static FMATCH_ZERO: AtomicU64 = AtomicU64::new(0);
pub static FMATCH_NONZERO: AtomicU64 = AtomicU64::new(0);
pub static FMATCH_SUM: AtomicU64 = AtomicU64::new(0); // sum of all non-zero fmatch values
/// Per log2(min(|aw|,|bw|)) bucket: longest_in calls, positions iterated, chain walks, chain steps, calls that found nothing.
pub static WIN_CALLS: [AtomicU64; 24] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; 24] };
pub static WIN_POS: [AtomicU64; 24] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; 24] };
pub static WIN_WALKS: [AtomicU64; 24] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; 24] };
pub static WIN_STEPS: [AtomicU64; 24] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; 24] };
pub static WIN_ZERO: [AtomicU64; 24] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; 24] };
/// Chain steps + longest_in calls by pair category: 0 exact, 1 edge test, 2 capped.
pub static CAT_STEPS: [AtomicU64; 3] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; 3] };
pub static CAT_CALLS: [AtomicU64; 3] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; 3] };
pub static CAT_PAIRS: [AtomicU64; 3] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; 3] };
thread_local! {
pub static TL_STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
pub static TL_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
pub fn pair_done(cat: usize) {
let s = TL_STEPS.with(|c| c.replace(0));
let n = TL_CALLS.with(|c| c.replace(0));
CAT_STEPS[cat].fetch_add(s, Ordering::Relaxed);
CAT_CALLS[cat].fetch_add(n, Ordering::Relaxed);
CAT_PAIRS[cat].fetch_add(1, Ordering::Relaxed);
}
pub static CHAIN_DEPTHS: [AtomicU64; HIST_BUCKETS] = {
// const init via repeated AtomicU64::new(0) — array initializer.
const ZERO: AtomicU64 = AtomicU64::new(0);
[ZERO; HIST_BUCKETS]
};
pub static RECURSION_DEPTHS: [AtomicU64; HIST_BUCKETS] = {
const ZERO: AtomicU64 = AtomicU64::new(0);
[ZERO; HIST_BUCKETS]
};
pub fn reset() {
LONGEST_IN_CALLS.store(0, Ordering::Relaxed);
PAIRS_PROCESSED.store(0, Ordering::Relaxed);
MAX_LE_CALLS.store(0, Ordering::Relaxed);
MAX_LE_FAST_PATH.store(0, Ordering::Relaxed);
MAX_LE_LINEAR.store(0, Ordering::Relaxed);
MAX_LE_LINEAR_LEN_SUM.store(0, Ordering::Relaxed);
MAX_LE_SEGTREE.store(0, Ordering::Relaxed);
MIN_IN_CALLS.store(0, Ordering::Relaxed);
MIN_IN_FAST_PATH.store(0, Ordering::Relaxed);
MIN_IN_LINEAR.store(0, Ordering::Relaxed);
MIN_IN_LINEAR_LEN_SUM.store(0, Ordering::Relaxed);
MIN_IN_SEGTREE.store(0, Ordering::Relaxed);
FMATCH_ZERO.store(0, Ordering::Relaxed);
FMATCH_NONZERO.store(0, Ordering::Relaxed);
FMATCH_SUM.store(0, Ordering::Relaxed);
for b in &CHAIN_DEPTHS {
b.store(0, Ordering::Relaxed);
}
for b in &RECURSION_DEPTHS {
b.store(0, Ordering::Relaxed);
}
}
/// Dump all counters as a human-readable multi-line string. Includes derived stats
/// (avg per pair, % linear vs seg-tree, percentile of chain depth, etc.).
#[must_use]
pub fn dump() -> String {
let mut s = String::with_capacity(2048);
let pairs = PAIRS_PROCESSED.load(Ordering::Relaxed).max(1);
let li = LONGEST_IN_CALLS.load(Ordering::Relaxed);
let mxc = MAX_LE_CALLS.load(Ordering::Relaxed).max(1);
let mxfp = MAX_LE_FAST_PATH.load(Ordering::Relaxed);
let mxln = MAX_LE_LINEAR.load(Ordering::Relaxed);
let mxlnsum = MAX_LE_LINEAR_LEN_SUM.load(Ordering::Relaxed);
let mxsg = MAX_LE_SEGTREE.load(Ordering::Relaxed);
let mic = MIN_IN_CALLS.load(Ordering::Relaxed);
let micfp = MIN_IN_FAST_PATH.load(Ordering::Relaxed);
let micln = MIN_IN_LINEAR.load(Ordering::Relaxed);
let miclnsum = MIN_IN_LINEAR_LEN_SUM.load(Ordering::Relaxed);
let micsg = MIN_IN_SEGTREE.load(Ordering::Relaxed);
let fz = FMATCH_ZERO.load(Ordering::Relaxed);
let fnz = FMATCH_NONZERO.load(Ordering::Relaxed);
let fsum = FMATCH_SUM.load(Ordering::Relaxed);
let ftotal = (fz + fnz).max(1);
s.push_str(&format!("=== gestalt::instrument dump ===\n"));
s.push_str(&format!(
"pairs processed: {}\n",
PAIRS_PROCESSED.load(Ordering::Relaxed),
));
s.push_str(&format!(
"longest_in calls: {} ({:.1} per pair)\n",
li,
li as f64 / pairs as f64,
));
s.push_str(&format!(
"max_le calls: {} ({:.1} per pair, {:.1} per longest_in)\n",
mxc,
mxc as f64 / pairs as f64,
mxc as f64 / li.max(1) as f64,
));
s.push_str(&format!(
" fast-path: {} ({:.1}%)\n",
mxfp,
mxfp as f64 / mxc as f64 * 100.0,
));
s.push_str(&format!(
" linear scan: {} ({:.1}%) avg scan len: {:.1}\n",
mxln,
mxln as f64 / mxc as f64 * 100.0,
mxlnsum as f64 / mxln.max(1) as f64,
));
s.push_str(&format!(
" seg-tree: {} ({:.1}%)\n",
mxsg,
mxsg as f64 / mxc as f64 * 100.0,
));
s.push_str(&format!(
"min_in calls: {} ({:.1} per pair)\n",
mic,
mic as f64 / pairs as f64,
));
s.push_str(&format!(
" fast-path: {} ({:.1}%)\n",
micfp,
micfp as f64 / mic.max(1) as f64 * 100.0,
));
s.push_str(&format!(
" linear scan: {} ({:.1}%) avg scan len: {:.1}\n",
micln,
micln as f64 / mic.max(1) as f64 * 100.0,
miclnsum as f64 / micln.max(1) as f64,
));
s.push_str(&format!(
" seg-tree: {} ({:.1}%)\n",
micsg,
micsg as f64 / mic.max(1) as f64 * 100.0,
));
s.push_str(&format!(
"fmatch values: {:.1}% zero ({} z / {} nz) avg-nz {:.2}\n",
fz as f64 / ftotal as f64 * 100.0,
fz,
fnz,
fsum as f64 / fnz.max(1) as f64,
));
let mut chain_total = 0u64;
let chain: Vec<u64> = CHAIN_DEPTHS.iter().map(|a| a.load(Ordering::Relaxed)).collect();
for &v in &chain {
chain_total += v;
}
s.push_str(&format!(
"chain walk depths ({} total walks):\n",
chain_total,
));
let pct = |target: f64| -> usize {
let mut acc = 0u64;
for (i, &v) in chain.iter().enumerate() {
acc += v;
if acc as f64 >= target * chain_total as f64 {
return i;
}
}
chain.len() - 1
};
s.push_str(&format!(
" p50={} p90={} p95={} p99={} max-bucket={}\n",
pct(0.50),
pct(0.90),
pct(0.95),
pct(0.99),
chain.iter().rposition(|&v| v > 0).unwrap_or(0),
));
// Compact histogram preview (first 16 buckets)
s.push_str(" hist[0..16]: ");
for &v in chain.iter().take(16) {
s.push_str(&format!("{} ", v));
}
s.push('\n');
for (k, name) in ["exact", "edge", "capped"].iter().enumerate() {
s.push_str(&format!("category {name:>6}: pairs {:>9} longest_in {:>10} steps {:>12}\n", CAT_PAIRS[k].load(Ordering::Relaxed), CAT_CALLS[k].load(Ordering::Relaxed), CAT_STEPS[k].load(Ordering::Relaxed)));
}
s.push_str("windows by log2(min side): calls / positions / walks / steps / found-nothing\n");
for k in 0..24 {
let c = WIN_CALLS[k].load(Ordering::Relaxed);
if c == 0 { continue; }
s.push_str(&format!(" 2^{:<2} {:>10} {:>12} {:>12} {:>12} {:>10}\n", k, c, WIN_POS[k].load(Ordering::Relaxed), WIN_WALKS[k].load(Ordering::Relaxed), WIN_STEPS[k].load(Ordering::Relaxed), WIN_ZERO[k].load(Ordering::Relaxed)));
}
let mut rec_total = 0u64;
let rec: Vec<u64> = RECURSION_DEPTHS.iter().map(|a| a.load(Ordering::Relaxed)).collect();
for &v in &rec {
rec_total += v;
}
s.push_str(&format!(
"recursion depths ({} stack pushes):\n",
rec_total,
));
s.push_str(" hist[0..16]: ");
for &v in rec.iter().take(16) {
s.push_str(&format!("{} ", v));
}
s.push('\n');
s
}
}
/// Helper that the instrument hooks call when the feature is enabled; no-op otherwise.
#[cfg(feature = "instrument")]
#[inline(always)]
#[allow(clippy::inline_always)]
fn instr_inc(c: &std::sync::atomic::AtomicU64, by: u64) {
c.fetch_add(by, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(feature = "instrument")]
#[inline(always)]
#[allow(clippy::inline_always)]
fn instr_hist(buckets: &[std::sync::atomic::AtomicU64; instrument::HIST_BUCKETS], depth: usize) {
buckets[depth.min(instrument::HIST_BUCKETS - 1)]
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
/// Suffix automaton with CSR (sorted-per-state) transitions — built once, queried by scans.
///
/// For the range-restricted recursion (fix b), each state also carries its **endpos** as a
/// contiguous slice `[dfs_in, dfs_in+dfs_cnt)` of `epos` (the end-positions in b, laid out so
/// that every subtree of the suffix-link tree is contiguous). Small sets are scanned linearly;
/// the few states whose set is larger than [`LINEAR_MAX`] carry a sorted copy in `big_sorted`,
/// and "is there an end-position in [lo,hi] within this state's subtree, and the min/max such"
/// is one binary search there — so the whole RO recursion runs on this one prebuilt SAM, with
/// **no sub-builds**.
#[derive(Clone)]
pub struct Sam {
// Transitions packed as `(char as u64) << 32 | to`, sorted by char within each state's range
// [edge_lo, edge_hi). Co-locating char+target means the binary-search key and the taken edge's
// target live on the same cache line (was two parallel arrays `csr_char`/`csr_to`).
edges: Vec<u64>,
// Direct lookup for the root's ASCII transitions (`root_next[c] = state`, or -1). The root is
// the high-degree state hit after every match reset; this makes its transition O(1) instead
// of a binary search. Non-ASCII root chars (rare) fall back to the edge search, so it's general.
root_next: Vec<i32>,
// The scan's per-state slot, one 32-byte line:
// [0] c0 [1] t0 first inline transition — for a per-position state the one that continues
// along `b`, so a match that keeps extending costs one load per character
// [2] c1 [3] t1 second inline transition (the state's next one in char order)
// [4] edge_lo [5] edge_hi the state's range in `edges`, for a state with more than two
// [6] link [7] link_len the fallback: suffix link and `len(link)`, the matched length after it
// Nearly every state has one or two transitions, so a miss on the inline pair means a
// fallback, not a search; `c0`/`c1` are `u32::MAX` (no code point) when absent. (A 128-bit
// character set in place of the second transition and the range was measured slower.)
fast: Vec<ScanSlot>,
// Per position of `b`, the state reached by reading `b[..=pos]` from the root (the state
// created for that position). A scan of a string sharing a prefix with `b` starts there.
pos_state: Vec<u32>,
// End-positions laid out by subtree (each state's endpos = contiguous slice
// [dfs_in, dfs_in+dfs_cnt)). For small endpos sets a cache-friendly linear scan of this beats
// any tree descent.
epos: Vec<u32>,
// Chain-walk hot slot, 32 bytes per state:
// [0] len [1] link [2] link_len [3] sorted_off (u32::MAX if dfs_cnt <= LINEAR_MAX)
// [4] firstpos [5] lastpos [6] dfs_in [7] dfs_cnt
// One cache-line-aligned load supplies everything `longest_in`'s chain walk + `max_le`/`min_in`
// need for one state. Replaces three separate scattered accesses; PMU said L1D miss rate was
// 0.51 % and accounted for 18 % of cycles, distributed across those per-state arrays.
chain_slot: Vec<[u32; 8]>,
// Sorted end-positions of the states with more than `LINEAR_MAX` of them (short, frequent
// substrings near the root), concatenated; a state's copy starts at `chain_slot[s][3]`.
// Replaces a merge-sort tree over all positions: those queries were under 1 % of the endpos
// queries on real code, and the tree cost one allocation per leaf to build.
big_sorted: Vec<u32>,
}
/// One state's scan slot (see `Sam::fast`), aligned so a state never straddles two cache lines.
#[derive(Clone, Copy)]
#[repr(C, align(32))]
struct ScanSlot([u32; 8]);
/// Below this endpos-set size, `max_le`/`min_in` linear-scan the state's contiguous endpos slice
/// (cache-friendly) instead of binary-searching a sorted copy — most queried sets are this small.
const LINEAR_MAX: usize = 256;
impl Sam {
fn empty() -> Self {
Sam {
edges: Vec::new(),
root_next: Vec::new(),
fast: Vec::new(),
pos_state: Vec::new(),
epos: Vec::new(),
chain_slot: Vec::new(),
big_sorted: Vec::new(),
}
}
/// The packed `[len, link, edge_lo, edge_hi]` per state — what the GPU port
/// (`gpu::matching_stats_gpu`) serializes into a Metal buffer. `link` is the root's link (-1)
/// stored as 0, never read. Assembled from the CPU tables on request.
#[must_use]
pub fn nodes(&self) -> Vec<[u32; 4]> {
self.chain_slot
.iter()
.zip(&self.fast)
.map(|(cs, f)| [cs[0], cs[1], f.0[4], f.0[5]])
.collect()
}
/// Read-only view of the packed edge slice: `(char << 32) | target_state`, sorted by char
/// within each state's `[edge_lo, edge_hi)` range. The GPU kernel does binary search over
/// this slice exactly as `csr_lookup` does on the CPU.
#[must_use]
pub fn edges_packed(&self) -> &[u64] {
&self.edges
}
/// Read-only view of the root's direct ASCII transition table (`root_next[c] = state`, or
/// `-1` for missing). 128 entries per SAM. The GPU kernel uses this to skip the binary
/// search at the root state, exactly as the CPU does.
#[must_use]
pub fn root_next_table(&self) -> &[i32] {
&self.root_next
}
/// Largest end-position `<= x` among the state's endpos, from its pre-loaded chain slot
/// `cs` (see `chain_slot`). Used by `longest_in`'s chain walk where `cs` was already pulled
/// from `chain_slot[cur]` — no second per-state load.
fn max_le_slot(&self, cs: &[u32; 8], x: u32) -> Option<u32> {
#[cfg(feature = "instrument")]
instr_inc(&instrument::MAX_LE_CALLS, 1);
// O(1) fast path: x doesn't split the state's [firstpos, lastpos] span.
if cs[5] <= x {
#[cfg(feature = "instrument")]
instr_inc(&instrument::MAX_LE_FAST_PATH, 1);
return Some(cs[5]);
}
if cs[4] > x {
#[cfg(feature = "instrument")]
instr_inc(&instrument::MAX_LE_FAST_PATH, 1);
return None;
}
let cnt = cs[7] as usize;
if cnt <= LINEAR_MAX {
#[cfg(feature = "instrument")]
{
instr_inc(&instrument::MAX_LE_LINEAR, 1);
instr_inc(&instrument::MAX_LE_LINEAR_LEN_SUM, cnt as u64);
}
let lo = cs[6] as usize;
let mut best = 0u32;
// SAFETY: `cs` is a valid SAM state's chain slot, so the slice [cs[6], cs[6]+cs[7]) is
// within `self.epos` (built that way at SAM construction).
#[allow(clippy::undocumented_unsafe_blocks)]
for &v in unsafe { self.epos.get_unchecked(lo..lo + cnt) } {
best = best.max(if v <= x { v } else { 0 });
}
return Some(best);
}
#[cfg(feature = "instrument")]
instr_inc(&instrument::MAX_LE_SEGTREE, 1);
let so = cs[3] as usize;
node_max_le(&self.big_sorted[so..so + cnt], x)
}
/// Smallest end-position in `[lo, hi]`, from the pre-loaded chain slot. See `max_le_slot`.
fn min_in_slot(&self, cs: &[u32; 8], lo: u32, hi: u32) -> Option<u32> {
#[cfg(feature = "instrument")]
instr_inc(&instrument::MIN_IN_CALLS, 1);
if cs[4] >= lo {
#[cfg(feature = "instrument")]
instr_inc(&instrument::MIN_IN_FAST_PATH, 1);
return (cs[4] <= hi).then_some(cs[4]);
}
if cs[5] < lo {
#[cfg(feature = "instrument")]
instr_inc(&instrument::MIN_IN_FAST_PATH, 1);
return None;
}
let cnt = cs[7] as usize;
if cnt <= LINEAR_MAX {
#[cfg(feature = "instrument")]
{
instr_inc(&instrument::MIN_IN_LINEAR, 1);
instr_inc(&instrument::MIN_IN_LINEAR_LEN_SUM, cnt as u64);
}
let off = cs[6] as usize;
let mut best = u32::MAX;
// SAFETY: `cs` is from a valid SAM state; endpos slice within bounds (same as max_le).
#[allow(clippy::undocumented_unsafe_blocks)]
for &v in unsafe { self.epos.get_unchecked(off..off + cnt) } {
best = best.min(if v >= lo && v <= hi { v } else { u32::MAX });
}
return (best != u32::MAX).then_some(best);
}
#[cfg(feature = "instrument")]
instr_inc(&instrument::MIN_IN_SEGTREE, 1);
let so = cs[3] as usize;
node_min_in(&self.big_sorted[so..so + cnt], lo, hi)
}
}
/// Largest value `<= x` in a sorted slice, or None.
fn node_max_le(sorted: &[u32], x: u32) -> Option<u32> {
let k = sorted.partition_point(|&v| v <= x);
(k > 0).then(|| sorted[k - 1])
}
/// Smallest value in `[lo, hi]` in a sorted slice, or None.
fn node_min_in(sorted: &[u32], lo: u32, hi: u32) -> Option<u32> {
let k = sorted.partition_point(|&v| v < lo);
sorted.get(k).copied().filter(|&v| v <= hi)
}
/// Transitions a builder state keeps inline; the rest overflow into the list arena. Nearly every
/// state of a code string's automaton has one or two, so `find` almost never touches the arena.
const INLINE: usize = 4;
/// "No code point" marker for an empty inline transition slot (no `char` is `u32::MAX`).
const NONE: u32 = u32::MAX;
/// Transient builder (Ukkonen online SAM). One per thread, reused across builds: every arena and
/// scratch vector keeps its capacity, so building a SAM allocates only the vectors the finished
/// `Sam` owns.
struct Builder {
// per state: up to `INLINE` transitions as `[c0, t0, c1, t1, ...]`, `NONE` when empty
inl: Vec<[u32; 2 * INLINE]>,
deg: Vec<u32>, // per state: number of transitions, inline and overflowed
// overflow arena (a state's transitions beyond `INLINE`), linked per state from `head`
edge_char: Vec<u32>,
edge_to: Vec<u32>,
edge_next: Vec<i32>,
head: Vec<i32>,
// The root's ASCII transitions as a direct table (`-1` = none): the root has by far the most
// transitions (one per distinct character) and every extension whose character is new to
// the current suffixes walks up to it.
root_tbl: Vec<i32>,
link: Vec<i32>,
len: Vec<u32>,
firstpos: Vec<u32>,
primary: Vec<bool>, // true for the per-position state (its firstpos is a real end-position)
last: u32,
// finalize scratch
off: Vec<u32>,
child_head: Vec<u32>,
child_arr: Vec<u32>,
lastpos: Vec<u32>,
}
thread_local! {
/// The per-thread SAM builder — arenas and scratch retained across `build_sam` calls.
static BUILDER: std::cell::RefCell<Builder> = std::cell::RefCell::new(Builder::empty());
}
impl Builder {
fn empty() -> Self {
Builder {
inl: Vec::new(),
deg: Vec::new(),
edge_char: Vec::new(),
edge_to: Vec::new(),
edge_next: Vec::new(),
head: Vec::new(),
root_tbl: Vec::new(),
link: Vec::new(),
len: Vec::new(),
firstpos: Vec::new(),
primary: Vec::new(),
last: 0,
off: Vec::new(),
child_head: Vec::new(),
child_arr: Vec::new(),
lastpos: Vec::new(),
}
}
/// Clear the arenas (keeping capacity) and re-seed the root — for buffer reuse.
fn reset(&mut self, cap: usize) {
self.inl.clear();
self.deg.clear();
self.edge_char.clear();
self.edge_to.clear();
self.edge_next.clear();
self.head.clear();
self.root_tbl.clear();
self.root_tbl.resize(ROOT_TBL, -1);
self.link.clear();
self.len.clear();
self.firstpos.clear();
self.primary.clear();
self.inl.reserve(2 * cap + 1);
self.deg.reserve(2 * cap + 1);
self.head.reserve(2 * cap + 1);
self.link.reserve(2 * cap + 1);
self.len.reserve(2 * cap + 1);
self.firstpos.reserve(2 * cap + 1);
self.primary.reserve(2 * cap + 1);
self.new_state(0, -1, 0, false); // root state 0 is not a per-position state
self.last = 0;
}
#[allow(clippy::cast_sign_loss)]
fn find(&self, state: u32, key: u32) -> Option<u32> {
if state == 0 && (key as usize) < ROOT_TBL {
let t = self.root_tbl[key as usize];
return (t >= 0).then_some(t as u32);
}
let e = &self.inl[state as usize];
// empty slots hold `NONE`, which no key equals
if e[0] == key {
return Some(e[1]);
}
if e[2] == key {
return Some(e[3]);
}
if e[4] == key {
return Some(e[5]);
}
if e[6] == key {
return Some(e[7]);
}
if self.deg[state as usize] as usize > INLINE {
let mut x = self.head[state as usize];
while x != -1 {
let idx = x as usize;
if self.edge_char[idx] == key {
return Some(self.edge_to[idx]);
}
x = self.edge_next[idx];
}
}
None
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn add_edge(&mut self, state: u32, key: u32, to: u32) {
let s = state as usize;
let d = self.deg[s] as usize;
if d < INLINE {
self.inl[s][2 * d] = key;
self.inl[s][2 * d + 1] = to;
} else {
self.edge_char.push(key);
self.edge_to.push(to);
self.edge_next.push(self.head[s]);
self.head[s] = (self.edge_char.len() - 1) as i32;
}
self.deg[s] = (d + 1) as u32;
if state == 0 && (key as usize) < ROOT_TBL {
self.root_tbl[key as usize] = to as i32;
}
}
/// Redirect the existing transition `state --key-->` to `to` (the clone step).
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
fn set_edge(&mut self, state: u32, key: u32, to: u32) {
let s = state as usize;
if state == 0 && (key as usize) < ROOT_TBL {
self.root_tbl[key as usize] = to as i32;
}
let e = &mut self.inl[s];
for k in 0..INLINE {
if e[2 * k] == key {
e[2 * k + 1] = to;
return;
}
}
let mut x = self.head[s];
while x != -1 {
let idx = x as usize;
if self.edge_char[idx] == key {
self.edge_to[idx] = to;
return;
}
x = self.edge_next[idx];
}
self.add_edge(state, key, to);
}
#[allow(clippy::cast_possible_truncation)]
fn new_state(&mut self, len: u32, link: i32, firstpos: u32, primary: bool) -> u32 {
self.inl.push([NONE; 2 * INLINE]);
self.deg.push(0);
self.head.push(-1);
self.len.push(len);
self.link.push(link);
self.firstpos.push(firstpos);
self.primary.push(primary);
(self.head.len() - 1) as u32
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)]
fn extend(&mut self, c: char, pos: usize) {
let key = c as u32;
let cur = self.new_state(self.len[self.last as usize] + 1, -1, pos as u32, true);
let mut p = self.last as i32;
while p != -1 && self.find(p as u32, key).is_none() {
self.add_edge(p as u32, key, cur);
p = self.link[p as usize];
}
if p == -1 {
self.link[cur as usize] = 0;
} else {
let q = self.find(p as u32, key).unwrap();
if self.len[p as usize] + 1 == self.len[q as usize] {
self.link[cur as usize] = q as i32;
} else {
let clone = self.new_state(self.len[p as usize] + 1, self.link[q as usize], self.firstpos[q as usize], false);
// the clone starts with q's transitions
let qi = q as usize;
let inline = self.inl[qi];
for k in 0..(self.deg[qi] as usize).min(INLINE) {
self.add_edge(clone, inline[2 * k], inline[2 * k + 1]);
}
let mut x = self.head[qi];
while x != -1 {
let idx = x as usize;
self.add_edge(clone, self.edge_char[idx], self.edge_to[idx]);
x = self.edge_next[idx];
}
while p != -1 && self.find(p as u32, key) == Some(q) {
self.set_edge(p as u32, key, clone);
p = self.link[p as usize];
}
self.link[q as usize] = clone as i32;
self.link[cur as usize] = clone as i32;
}
}
self.last = cur;
}
/// Convert the builder's transitions into the packed `edges` layout (per-state edges sorted
/// by char, co-located char+target) and derive every query table, into a fresh `Sam`. `b` is
/// the string the automaton was built from — the per-position states' inline transition is
/// the one that continues along it.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::needless_range_loop)]
fn finalize(&mut self, b: &[char]) -> Sam {
let mut out = Sam::empty();
let nstates = self.head.len();
// per-state edge counts → exclusive prefix-sum offsets
let off = &mut self.off;
off.clear();
off.resize(nstates + 1, 0);
for state in 0..nstates {
off[state + 1] = off[state] + self.deg[state];
}
let nedges = off[nstates] as usize;
// edges: (char << 32 | to), sorted by char within each state's [off[s], off[s+1]) range.
// sorting the packed u64 sorts by char (high bits) since a state's chars are distinct.
// Nearly every state has one or two transitions: packed in place and insertion-sorted.
out.edges.resize(nedges, 0);
self.pack_edges(&mut out, nstates);
self.tables(&mut out, nstates, b);
self.build_endpos(&mut out, nstates, b.len());
out
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::needless_range_loop)]
fn pack_edges(&mut self, out: &mut Sam, nstates: usize) {
let off = self.off.as_slice();
let (inl, deg, head, edge_char, edge_to, edge_next) = (
self.inl.as_slice(),
self.deg.as_slice(),
self.head.as_slice(),
self.edge_char.as_slice(),
self.edge_to.as_slice(),
self.edge_next.as_slice(),
);
let edges = out.edges.as_mut_slice();
for state in 0..nstates {
let base = off[state] as usize;
let mut k = base;
let mut place = |packed: u64, k: &mut usize| {
// insertion into the sorted prefix [base, *k)
let mut p = *k;
while p > base && edges[p - 1] > packed {
edges[p] = edges[p - 1];
p -= 1;
}
edges[p] = packed;
*k += 1;
};
let e = &inl[state];
for slot in 0..(deg[state] as usize).min(INLINE) {
place((u64::from(e[2 * slot]) << 32) | u64::from(e[2 * slot + 1]), &mut k);
}
let mut x = head[state];
while x != -1 {
let idx = x as usize;
place((u64::from(edge_char[idx]) << 32) | u64::from(edge_to[idx]), &mut k);
x = edge_next[idx];
}
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::needless_range_loop)]
fn tables(&mut self, out: &mut Sam, nstates: usize, b: &[char]) {
let off = self.off.as_slice();
let edges = out.edges.as_slice();
let (primary, firstpos, link_of, len_of) =
(self.primary.as_slice(), self.firstpos.as_slice(), self.link.as_slice(), self.len.as_slice());
// root direct transition table (ASCII): fill from the root's edges
out.root_next.resize(ROOT_TBL, -1);
for &e in &edges[off[0] as usize..off[1] as usize] {
let ci = (e >> 32) as usize;
if ci < ROOT_TBL {
out.root_next[ci] = i32::try_from((e & 0xFFFF_FFFF) as u32).expect("state fits i32");
}
}
// the scan slot: two inline transitions, the edge range, the suffix link and its length
out.fast.reserve(nstates);
for s in 0..nstates {
let (lo, hi) = (off[s] as usize, off[s + 1] as usize);
let es = &edges[lo..hi];
let split = |e: u64| ((e >> 32) as u32, (e & 0xFFFF_FFFF) as u32);
// e0: the continuation along `b` for a per-position state, else the first edge
let mut e0 = es.first().map_or((NONE, 0), |&e| split(e));
if primary[s] {
let next = firstpos[s] as usize + 1;
if next < b.len() {
let t = csr_lookup(edges, lo, hi, b[next]);
if t >= 0 {
e0 = (b[next] as u32, t as u32);
}
}
}
// e1: the first edge in char order that is not e0
let e1 = es.iter().map(|&e| split(e)).find(|&(c, _)| c != e0.0).unwrap_or((NONE, 0));
let link_idx_signed = link_of[s];
let link = if link_idx_signed < 0 { 0 } else { link_idx_signed as u32 };
let llen = if link_idx_signed < 0 { 0 } else { len_of[link_idx_signed as usize] };
out.fast.push(ScanSlot([e0.0, e0.1, e1.0, e1.1, lo as u32, hi as u32, link, llen]));
}
out.pos_state.resize(b.len(), 0);
for s in 0..nstates {
if primary[s] {
out.pos_state[firstpos[s] as usize] = s as u32;
}
}
}
/// Build the endpos range structure (fix b): lay the end-positions out so every state's endpos
/// set is one contiguous slice `[dfs_in, dfs_in+dfs_cnt)` of `epos` (its subtree in the
/// suffix-link tree), fill the chain slot per state, and keep a sorted copy for the few states
/// holding more than `LINEAR_MAX` positions.
///
/// No tree walk: a state's suffix link is always shorter than the state, so the states in
/// length order are a topological order of the link tree. One pass in decreasing length
/// accumulates subtree sizes and the largest position of each subtree; one pass in increasing
/// length hands each state a range inside its parent's, its own position first, its children's
/// ranges after. Any layout in which subtrees are contiguous serves the queries: a slice is
/// read as a set.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::needless_range_loop)]
fn build_endpos(&mut self, out: &mut Sam, nstates: usize, blen: usize) {
let (len_of, link_of, primary, firstpos) =
(self.len.as_slice(), self.link.as_slice(), self.primary.as_slice(), self.firstpos.as_slice());
// states in length order (counting sort: len <= blen)
self.child_head.clear();
self.child_head.resize(blen + 2, 0);
let start = self.child_head.as_mut_slice();
for s in 0..nstates {
start[len_of[s] as usize + 1] += 1;
}
for l in 0..=blen {
start[l + 1] += start[l];
}
self.child_arr.clear();
self.child_arr.resize(nstates, 0);
let by_len = self.child_arr.as_mut_slice();
self.off.clear();
self.off.extend_from_slice(&start[..=blen]);
let cursor = self.off.as_mut_slice();
for s in 0..nstates {
let l = len_of[s] as usize;
by_len[cursor[l] as usize] = s as u32;
cursor[l] += 1;
}
// decreasing length: subtree size (`cnt`) and largest position (`lastpos`) flow to the link
self.lastpos.clear();
self.lastpos.resize(nstates, 0);
let cnt = self.lastpos.as_mut_slice(); // scratch: subtree sizes
out.chain_slot.clear();
out.chain_slot.reserve(nstates);
for s in 0..nstates {
let f = out.fast[s].0;
let own = if primary[s] { firstpos[s] } else { 0 };
out.chain_slot.push([len_of[s], f[6], f[7], u32::MAX, firstpos[s], own, 0, 0]);
cnt[s] = u32::from(primary[s]);
}
let slots = out.chain_slot.as_mut_slice();
for &s in by_len[1..].iter().rev() {
let s = s as usize;
let p = link_of[s] as usize;
cnt[p] += cnt[s];
let lp = slots[s][5];
if lp > slots[p][5] {
slots[p][5] = lp;
}
}
// increasing length: ranges, parents before children; `cursor[s]` = next free slot in s's range
self.off.clear();
self.off.resize(nstates, 0);
let cursor = self.off.as_mut_slice();
out.epos.clear();
out.epos.resize(blen, 0);
let epos = out.epos.as_mut_slice();
slots[0][6] = 0;
slots[0][7] = cnt[0];
cursor[0] = 0;
for &s in by_len.iter() {
let s = s as usize;
if s != 0 {
let p = link_of[s] as usize;
let at = cursor[p];
cursor[p] += cnt[s];
slots[s][6] = at;
slots[s][7] = cnt[s];
cursor[s] = at;
}
if primary[s] {
epos[cursor[s] as usize] = firstpos[s];
cursor[s] += 1;
}
}
self.sort_big(out, nstates, blen);
}
#[allow(clippy::cast_possible_truncation)]
fn sort_big(&mut self, out: &mut Sam, nstates: usize, blen: usize) {
// sorted copies for the big endpos sets (a handful of states: short, frequent substrings);
// positions are below `blen`, so a radix sort needs one pass per byte of that
let passes = ((usize::BITS - blen.leading_zeros()) as usize).div_ceil(8).max(1);
let scratch = &mut self.child_arr;
for s in 0..nstates {
let cnt = out.chain_slot[s][7] as usize;
if cnt > LINEAR_MAX {
let start = out.chain_slot[s][6] as usize;
let so = out.big_sorted.len();
out.big_sorted.extend_from_slice(&out.epos[start..start + cnt]);
radix_sort_u32(&mut out.big_sorted[so..], scratch, passes);
out.chain_slot[s][3] = so as u32;
}
}
}
}
/// LSD radix sort of `v` (byte digits, `passes` low bytes significant), `scratch` as the buffer.
fn radix_sort_u32(v: &mut [u32], scratch: &mut Vec<u32>, passes: usize) {
let n = v.len();
scratch.clear();
scratch.resize(n, 0);
let mut counts = [0usize; 256];
for pass in 0..passes {
let shift = 8 * pass;
counts.fill(0);
for &x in v.iter() {
counts[((x >> shift) & 0xFF) as usize] += 1;
}
let mut sum = 0;
for c in &mut counts {
let k = *c;
*c = sum;
sum += k;
}
for &x in v.iter() {
let d = ((x >> shift) & 0xFF) as usize;
scratch[counts[d]] = x;
counts[d] += 1;
}
v.copy_from_slice(&scratch[..n]);
}
}
/// Build (and finalize) the suffix automaton of `b` — prebuild once, reuse across pairs.
#[must_use]
pub fn build_sam(b: &[char]) -> Sam {
BUILDER.with_borrow_mut(|bld| {
bld.reset(b.len());
for (i, &c) in b.iter().enumerate() {
bld.extend(c, i);
}
bld.finalize(b)
})
}
/// Longest substring of `a[al..ar]` that occurs in `b[bl..br)`, using the **precomputed**
/// window-independent match (`fstate`/`fmatch` = the SAM state and match length ending at each
/// a-position, from one full-`a` scan). For each position the chain walk starts at the precomputed
/// state, capping the usable length to the a-fragment (`i-al+1`) and the b-window via endpos
/// queries. Returns (`a_start`, absolute `b_start`, len) with difflib's tie-break. No re-scanning.
///
/// `chain_cap` (Phase 3 approximation knob) bounds the suffix-link chain walk to `chain_cap`
/// states ascended; deeper chains return whatever match was already found. With the empirically
/// measured distribution (p99 depth 7, p95 depth 5; see `gestalt::instrument`'s histograms), a
/// cap of 7 keeps ~99% of chains intact, capping at 5 keeps ~95%, etc. Setting `chain_cap =
/// u32::MAX` recovers the exact behaviour. The caller (`gestalt_edge_with_ms` and friends)
/// derives `chain_cap` from the user-supplied `delta` parameter via `delta_to_chain_cap`.
///
/// `wlen[i]` is the caller's running upper bound on the in-window match ending at `i`: the
/// windows of a Ratcliff–Obershelp recursion only ever shrink, so whatever this walk learns about
/// position `i` — the exact longest match inside this window, or that nothing longer than the
/// length it was pruned at exists — bounds every descendant window too, and the descendant's walk
/// for `i` is pruned by it up front. The precomputed `fmatch[i]` is the longest match ending at
/// `i` *anywhere* in `b`; on real code that is almost always long, so without this refinement a
/// narrow window re-walks the chain for every position, level after level.
///
/// A window whose `b` side is at most [`DIRECT_B_MAX`] wide goes to [`longest_direct`] instead:
/// half the recursion's windows are that narrow on real code, and there the chain walk climbs
/// far for every `a` position (the precomputed matches lie outside the window) and mostly finds
/// nothing, while a direct row-by-row comparison costs a handful of vector ops per position.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::too_many_arguments, clippy::too_many_lines)]
fn longest_in(
a: &[char],
b: &[char],
sam: &Sam,
fstate: &[u32],
fmatch: &[u32],
wlen: &mut [u32],
al: usize,
ar: usize,
bl: usize,
br: usize,
chain_cap: u32,
) -> (usize, usize, usize) {
#[cfg(feature = "instrument")]
instr_inc(&instrument::LONGEST_IN_CALLS, 1);
if br - bl <= DIRECT_B_MAX {
return longest_direct(a, b, al, ar, bl, br);
}
let blo = bl as u32;
let hi = br as u32 - 1; // caller guarantees bl < br, so br >= 1
let (mut best_len, mut best_a, mut best_b) = (0usize, 0usize, 0usize);
#[cfg(feature = "instrument")]
let (mut n_pos, mut n_walks, mut n_steps) = (0u64, 0u64, 0u64);
// SAFETY: i ∈ [al, ar) ⊆ [0, n) = fstate.len() = fmatch.len(); `cur` is always a valid SAM