r-index in Python
A full-text index whose size is proportional to the number of runs in the BWT.
- File
r_index.py, 168 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/r-index/. The authors are listed in prov/D8XXKM.jsonl.
from bisect import bisect_left, bisect_right
from collections.abc import Hashable, Iterable, Sequence
from typing import Any
def _suffix_array(t: Sequence[int]) -> list[int]:
"""Suffix array of t (ending with a unique smallest 0) by prefix doubling; no recursion."""
n = len(t)
rank = list(t)
sa = list(range(n))
k = 1
while True:
key = [(rank[i], rank[i + k] if i + k < n else -1) for i in range(n)]
sa.sort(key=key.__getitem__)
new = [0] * n
for j in range(1, n):
new[sa[j]] = new[sa[j - 1]] + (key[sa[j]] != key[sa[j - 1]])
rank = new
if n == 0 or rank[sa[-1]] == n - 1:
return sa
k *= 2
class RIndex:
"""The r-index of Gagie, Navarro and Prezza over a text S.
Symbols are hashable and mutually comparable; they are mapped to ranks
1..sigma, and a sentinel 0, smaller than all symbols, is appended, so the
index is built on t = S$ of length n + 1. Positions are 0-based. After
construction only O(r) values are kept, where r is the number of runs of
the Burrows-Wheeler transform of t: the run heads and starts, rank
tables per symbol, the suffix array samples at the run ends, and the phi
samples at the run starts. The suffix array is built naively here (the
real index is built in compressed space); queries use binary search,
so each step costs O(log r) instead of the O(log log) of predecessor
structures.
"""
def __init__(self, text: Iterable[Hashable] = ()) -> None:
s = list(text)
alphabet = sorted(set(s))
self.code: dict[Any, int] = {c: i + 1 for i, c in enumerate(alphabet)}
t = [self.code[c] for c in s] + [0]
n1 = len(t)
self.n = n1 - 1
sa = _suffix_array(t)
bwt = [t[i - 1] for i in sa] # t[-1] is the sentinel
# runs of the BWT
self.heads: list[int] = []
self.starts: list[int] = []
for i, c in enumerate(bwt):
if i == 0 or c != bwt[i - 1]:
self.heads.append(c)
self.starts.append(i)
r = len(self.starts)
self.r = r
ends = [self.starts[k + 1] - 1 for k in range(r - 1)] + [n1 - 1]
# per symbol: the indices of its runs, their starts and the counts before each run
sigma = len(alphabet) + 1
self.run_starts: list[list[int]] = [[] for _ in range(sigma)]
self.run_ends: list[list[int]] = [[] for _ in range(sigma)]
self.before: list[list[int]] = [[] for _ in range(sigma)]
self.end_sample: list[list[int]] = [[] for _ in range(sigma)]
total = [0] * sigma
for k in range(r):
c = self.heads[k]
self.run_starts[c].append(self.starts[k])
self.run_ends[c].append(ends[k])
self.before[c].append(total[c])
self.end_sample[c].append(sa[ends[k]]) # SA at the end of each run
total[c] += ends[k] - self.starts[k] + 1
self.C = [0] * (sigma + 1)
for c in range(sigma):
self.C[c + 1] = self.C[c] + total[c]
self.last_sample = sa[n1 - 1]
# phi(j) = SA[ISA[j] - 1]; sampled at j = SA[p] - 1 for the run starts p
isa = [0] * n1
for i, j in enumerate(sa):
isa[j] = i
pairs = []
for p in self.starts:
key = sa[p] - 1
if key >= 0 and isa[key] > 0:
pairs.append((key, sa[isa[key] - 1]))
pairs.sort()
self.phi_keys = [a for a, _ in pairs]
self.phi_vals = [b for _, b in pairs]
def _rank(self, c: int, i: int) -> int:
"""Number of c in BWT[0:i]."""
k = bisect_right(self.run_starts[c], i - 1) - 1
if k < 0:
return 0
return self.before[c][k] + min(i, self.run_ends[c][k] + 1) - self.run_starts[c][k]
def _bwt_at(self, i: int) -> int:
return self.heads[bisect_right(self.starts, i) - 1]
def _phi(self, x: int) -> int:
k = bisect_left(self.phi_keys, x) # @line L10: the smallest key >= x
return self.phi_vals[k] - (self.phi_keys[k] - x)
def _search(self, p: Sequence[Hashable]) -> tuple[int, int, int]:
"""Backward search with the toehold: (sp, ep, SA[ep - 1]), or ep <= sp if absent."""
sp, ep, s = 0, self.n + 1, self.last_sample # @line L1
for k in range(len(p) - 1, -1, -1): # @line L2
c = self.code.get(p[k]) # @line L3
if c is None:
return 0, 0, -1
if self._bwt_at(ep - 1) == c: # @line L4: the toehold moves with LF
q_sa = s
else: # @line L5: the last c before ep ends a run, whose SA value is sampled
j = bisect_right(self.run_starts[c], ep - 1) - 1
if j < 0 or self.run_ends[c][j] < sp:
return 0, 0, -1
q_sa = self.end_sample[c][j]
sp, ep = self.C[c] + self._rank(c, sp), self.C[c] + self._rank(c, ep) # @line L6
if sp >= ep: # @line L7
return 0, 0, -1
s = q_sa - 1 # @line L8
return sp, ep, s
def count(self, p: Sequence[Hashable]) -> int:
sp, ep, _ = self._search(p)
return max(0, ep - sp)
def contains(self, p: Sequence[Hashable]) -> bool:
return self.count(p) > 0
def locate(self, p: Sequence[Hashable]) -> list[int]:
"""All starting positions of p in S, from the last row of its range up.
The empty pattern occurs at the n + 1 positions 0..n.
"""
sp, ep, s = self._search(p)
out = []
if ep > sp:
out.append(s) # @line L9: SA[ep - 1], then phi for the rows above
for _ in range(ep - sp - 1):
s = self._phi(s)
out.append(s)
return out
def stored_values(self) -> int:
"""The number of integers kept after construction."""
per_symbol = sum(len(x) for x in self.run_starts) * 4
return 2 * self.r + per_symbol + len(self.C) + 2 * len(self.phi_keys) + 1
def check_invariant(self) -> None:
"""Runs are maximal and nonempty; the tables are consistent and of size O(r)."""
r = self.r
assert len(self.heads) == len(self.starts) == r
assert r == 0 or self.starts[0] == 0
for k in range(1, r):
assert self.starts[k] > self.starts[k - 1] and self.heads[k] != self.heads[k - 1]
assert sum(len(x) for x in self.run_starts) == r
for c in range(len(self.run_starts)):
for j in range(len(self.run_starts[c])):
assert self.run_starts[c][j] <= self.run_ends[c][j]
if j:
run = self.run_ends[c][j - 1] - self.run_starts[c][j - 1] + 1
assert self.before[c][j] == self.before[c][j - 1] + run
assert self.C[-1] == self.n + 1
assert len(self.phi_keys) <= r and self.phi_keys == sorted(self.phi_keys)
assert self.stored_values() <= 8 * r + len(self.C) + 1
test_r_index.py 87 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/r-index/. The authors are listed in prov/D8XXKM.jsonl.
import random
from r_index import RIndex
def occurrences(s: str, p: str) -> list[int]:
return [i for i in range(len(s) - len(p) + 1) if s[i:i + len(p)] == p]
def bwt_runs(s: str) -> int:
t = s + "\0"
sa = sorted(range(len(t)), key=lambda i: t[i:])
b = [t[i - 1] for i in sa]
return sum(1 for i in range(len(b)) if i == 0 or b[i] != b[i - 1])
def test_example() -> None:
idx = RIndex("banana")
idx.check_invariant()
assert idx.r == 5 and idx.heads == [1, 3, 2, 0, 1] and idx.starts == [0, 1, 3, 4, 5]
assert idx.locate("ana") == [1, 3] and idx.count("an") == 2 and idx.count("a") == 3
assert idx.locate("nab") == [] and not idx.contains("x")
assert idx.phi_keys == [0, 3, 4, 5] and idx.phi_vals == [1, 5, 0, 6]
def test_edges() -> None:
idx = RIndex("")
idx.check_invariant()
assert idx.r == 1 and idx.count("a") == 0 and idx.locate("") == [0]
for n in (1, 2, 9, 300):
s = "a" * n
idx = RIndex(s)
idx.check_invariant()
assert idx.r == 2
assert sorted(idx.locate("aa")) == occurrences(s, "aa")
assert idx.count("a" * n) == 1 and idx.count("a" * (n + 1)) == 0
s = "ab" * 200
idx = RIndex(s)
idx.check_invariant()
assert idx.r == bwt_runs(s) and idx.r <= 4
assert sorted(idx.locate("bab")) == occurrences(s, "bab")
assert sorted(RIndex("abcab").locate("")) == list(range(6))
idx = RIndex([3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5])
assert idx.locate([5]) and sorted(idx.locate([5])) == [4, 8, 10]
def test_against_naive() -> None:
rng = random.Random(2018)
for trial in range(1500):
sigma = "abcd"[: rng.choice([1, 2, 4])]
n = rng.randint(0, 18)
s = "".join(rng.choice(sigma) for _ in range(n))
if trial % 25 == 0:
s = rng.choice(["a" * n, "ab" * (n // 2), ""])
idx = RIndex(s)
idx.check_invariant()
assert idx.r == bwt_runs(s)
for _ in range(8):
if s and rng.random() < 0.6:
i = rng.randrange(len(s))
p = s[i:i + rng.randint(1, 5)]
else:
p = "".join(rng.choice(sigma + "e") for _ in range(rng.randint(1, 4)))
occ = occurrences(s, p)
assert sorted(idx.locate(p)) == occ
assert idx.count(p) == len(occ) and idx.contains(p) == bool(occ)
def test_repetitive_text_is_small() -> None:
"""On a highly repetitive text the index keeps far fewer values than n."""
rng = random.Random(3)
base = "".join(rng.choice("acgt") for _ in range(50))
copies = []
for _ in range(40):
c = list(base)
c[rng.randrange(50)] = rng.choice("acgt")
copies.append("".join(c))
s = "".join(copies)
idx = RIndex(s)
idx.check_invariant()
assert idx.r < len(s) // 4
assert idx.stored_values() < 8 * idx.r + 10
p = base[10:30]
assert sorted(idx.locate(p)) == occurrences(s, p)
What the next level needs
- the tests did not run in this build