Push zunrplorkwkt #70

Merged
coissac merged 93 commits from push-zunrplorkwkt into main 2026-08-28 23:15:38 +00:00
5 changed files with 269 additions and 3 deletions
Showing only changes of commit ad0b7173a3 - Show all commits
+28
View File
@@ -111,14 +111,42 @@ fn read_varint(data: &[u8], pos: &mut usize) -> u32 {
// ── PersistentSparseBitMatrix ───────────────────────────────────────────────
/// Row-sparse `n × n_cols` binary matrix: each of the `n` rows (k-mer
/// slots) stores only the small set of `n_cols` (genome) columns it has set
/// — a singleton genome index, or a shared, deduplicated multi-genome set,
/// rather than an `n_cols`-wide bit row.
///
/// `is_multi[slot]` is the row-kind flag; it also acts as the split point
/// between `singleton` and `multi` — `rank0(slot)`/`rank1(slot)` convert a
/// slot into its position within whichever of the two arrays holds it
/// (see [`for_each_genome_in_row`](Self::for_each_genome_in_row)):
/// - `is_multi[slot] == false`: `singleton[rank0(slot)]` is the one genome
/// index present at that slot.
/// - `is_multi[slot] == true`: `multi[rank1(slot)]` is a `dict_id` into the
/// deduplicated multi-genome dictionary — many slots sharing the same
/// genome set (common in practice) point at the same entry instead of
/// repeating it.
///
/// The dictionary itself is `dict_offsets`/`dict_values`: each distinct
/// multi-genome set is varint-encoded (ascending genome indices, delta
/// isn't taken — see [`write_varint`]/[`read_varint`]) as a byte run in
/// `dict_values`, and `dict_offsets` (an [`EliasFano`] over the
/// `n_distinct_multi` sets' *byte* offsets — monotone by construction, one
/// entry per `dict_id`) gives `dict_entry_range(dict_id)`'s `[start, end)`
/// window in
/// `dict_values`. Looked up only by known `dict_id`, never searched by
/// value.
pub struct PersistentSparseBitMatrix {
is_multi: PersistentRankSelectBitVec,
singleton: PersistentFixedIntVec,
multi: PersistentFixedIntVec,
dict_offsets: EliasFano,
dict_values: Vec<u8>,
/// Row count (k-mer slots).
n: usize,
/// Column count (genomes).
n_cols: usize,
/// Number of distinct multi-genome sets in the dictionary.
n_distinct_multi: usize,
}
+67
View File
@@ -76,6 +76,73 @@ impl EliasFano {
let high_part = pos as u64 - i as u64;
(high_part << self.low_width) | self.low.get(i)
}
/// Index of the smallest encoded value `>= x`, or `None` if every value
/// is `< x`. Shared by [`next_geq`](Self::next_geq) and
/// [`predecessor`](Self::predecessor) — the latter needs the index,
/// not just the value, to step one position back.
///
/// The high bitvector is the unary code `1^c0 0 1^c1 0 ... 1^cH 0`
/// (`c_h` = count of values whose high part is `h`) — each `0` marks a
/// bucket boundary. `select0(h - 1) + 1` jumps straight to the start of
/// bucket `h`; `rank1` on that position converts it to the value's
/// index. From there the scan is bounded by bucket `h`'s local density,
/// not by `n`: once the high part exceeds `h` the value already
/// exceeds `x` (`x < (h + 1) << low_width` by construction of `h`).
fn next_geq_index(&self, x: u64) -> Option<usize> {
if self.n == 0 {
return None;
}
let h = x >> self.low_width;
let start = if h == 0 {
0
} else {
let zeros_before_bucket = h - 1;
if zeros_before_bucket >= self.high.count_zeros() {
return None; // bucket h is beyond the last one present
}
self.high.select0(zeros_before_bucket) + 1
};
let mut i = self.high.rank1(start) as usize;
while i < self.n {
if self.get(i) >= x {
return Some(i);
}
i += 1;
}
None
}
/// Smallest encoded value `>= x`, or `None` if every value is `< x`.
pub fn next_geq(&self, x: u64) -> Option<u64> {
self.next_geq_index(x).map(|i| self.get(i))
}
/// Whether `x` is present in the sequence.
pub fn contains(&self, x: u64) -> bool {
self.next_geq(x) == Some(x)
}
/// Smallest encoded value `> x`, or `None` if every value is `<= x`.
pub fn successor(&self, x: u64) -> Option<u64> {
let x_plus_1 = x.checked_add(1)?; // x == u64::MAX: nothing can exceed it
self.next_geq(x_plus_1)
}
/// Largest encoded value `<= x`, or `None` if every value is `> x`.
pub fn predecessor(&self, x: u64) -> Option<u64> {
let idx = self.next_geq_index(x).unwrap_or(self.n);
// `idx` is the first index with value `>= x`. If it lands exactly
// on `x`, that's the answer (largest value `<= x` is `x` itself);
// otherwise step back one to the last value `< x`.
if idx < self.n && self.get(idx) == x {
Some(x)
} else if idx == 0 {
None
} else {
Some(self.get(idx - 1))
}
}
}
// ── EliasFanoBuilder ─────────────────────────────────────────────────────────
+41 -3
View File
@@ -1,11 +1,12 @@
//! Rank/select-capable bitvector — extends the crate's existing bit-count
//! (`count_ones`, a global reduction) with `rank1`/`rank0` (count of 1s/0s
//! in a prefix) and `select1` (position of the k-th 1 bit). Needed by two
//! consumers in the sparse presence-matrix design (see
//! in a prefix) and `select1`/`select0` (position of the k-th 1/0 bit).
//! Needed by two consumers in the sparse presence-matrix design (see
//! `DevDocMD/architecture/siblings.md` and the sparse-matrix plan): the
//! `is_multi` row-kind flag (needs rank, to locate a row's position within
//! whichever of the two split arrays it belongs to) and the Elias-Fano high
//! bits (needs select).
//! bits (needs select1 to decode a value by index, select0 to jump to a
//! bucket boundary for `next_geq`/`contains`).
//!
//! Two-level structure, the standard succinct-bitvector approach: a
//! cumulative rank sampled every [`BLOCK_WORDS`] words, plus a linear scan
@@ -161,6 +162,43 @@ impl PersistentRankSelectBitVec {
}
unreachable!("select1({k}): ran off the end of its own block — rank/select index inconsistent");
}
/// Position of the `k`-th (0-indexed) 0 bit. Panics if fewer than
/// `k + 1` zeros exist.
///
/// Mirrors [`select1`](Self::select1): binary-searches a per-block
/// zero count derived from `block_ranks` (`block_start_bit -
/// block_ranks[block]`, no separate index needed), then locates the
/// exact bit within the winning word via `SelectInWord` on `!w`.
pub fn select0(&self, k: u64) -> usize {
let total_zeros = self.count_zeros();
assert!(k < total_zeros, "select0({k}) out of range: only {total_zeros} zeros");
let block_ranks = self.block_ranks();
let words = self.bit_words();
// Binary search: last block whose cumulative zero-count is <= k.
let mut lo = 0usize;
let mut hi = block_ranks.len();
while lo + 1 < hi {
let mid = lo + (hi - lo) / 2;
let zeros_at_mid = (mid * BLOCK_WORDS * 64) as u64 - block_ranks[mid];
if zeros_at_mid <= k { lo = mid; } else { hi = mid; }
}
let block_idx = lo;
let zeros_before_block = (block_idx * BLOCK_WORDS * 64) as u64 - block_ranks[block_idx];
let mut remaining = k - zeros_before_block;
let start_word = block_idx * BLOCK_WORDS;
let end_word = (start_word + BLOCK_WORDS).min(words.len());
for (i, &w) in words[start_word..end_word].iter().enumerate() {
let zw = !w;
let c = zw.count_ones() as u64;
if remaining < c {
return (start_word + i) * 64 + zw.select_in_word(remaining as usize);
}
remaining -= c;
}
unreachable!("select0({k}): ran off the end of its own block — rank/select index inconsistent");
}
}
// ── PersistentRankSelectBitVecBuilder ───────────────────────────────────────
+100
View File
@@ -94,6 +94,106 @@ fn push_out_of_order_panics() {
b.push(5); // decreasing — must panic
}
fn naive_next_geq(values: &[u64], x: u64) -> Option<u64> {
values.iter().copied().find(|&v| v >= x)
}
fn naive_successor(values: &[u64], x: u64) -> Option<u64> {
values.iter().copied().find(|&v| v > x)
}
fn naive_predecessor(values: &[u64], x: u64) -> Option<u64> {
values.iter().copied().filter(|&v| v <= x).next_back()
}
#[test]
fn next_geq_and_contains_small() {
let values = [0u64, 3, 3, 7, 42, 42, 42, 100];
let (_dir, ef) = build(&values, 1000);
for x in 0..=105u64 {
assert_eq!(ef.next_geq(x), naive_next_geq(&values, x), "next_geq({x})");
assert_eq!(ef.contains(x), values.contains(&x), "contains({x})");
}
}
#[test]
fn next_geq_empty_bucket_skips_to_next() {
// Bucket for values in [8, 16) is empty — next_geq(9) must skip it.
let values = [0u64, 1, 20, 21];
let (_dir, ef) = build(&values, 100);
assert_eq!(ef.next_geq(9), Some(20));
assert_eq!(ef.next_geq(21), Some(21));
assert_eq!(ef.next_geq(22), None);
assert!(!ef.contains(9));
assert!(ef.contains(21));
}
#[test]
fn next_geq_empty_sequence() {
let (_dir, ef) = build(&[], 1000);
assert_eq!(ef.next_geq(0), None);
assert!(!ef.contains(0));
}
#[test]
fn next_geq_beyond_last_value() {
let values = [5u64, 10, 15];
let (_dir, ef) = build(&values, 100);
assert_eq!(ef.next_geq(16), None);
assert_eq!(ef.next_geq(999), None);
}
#[test]
fn successor_and_predecessor_small() {
let values = [0u64, 3, 3, 7, 42, 42, 42, 100];
let (_dir, ef) = build(&values, 1000);
for x in 0..=105u64 {
assert_eq!(ef.successor(x), naive_successor(&values, x), "successor({x})");
assert_eq!(ef.predecessor(x), naive_predecessor(&values, x), "predecessor({x})");
}
}
#[test]
fn successor_at_u64_max_is_none() {
let values = [1u64, 2, 3];
let (_dir, ef) = build(&values, 100);
assert_eq!(ef.successor(u64::MAX), None);
}
#[test]
fn predecessor_before_first_value_is_none() {
let values = [5u64, 10, 15];
let (_dir, ef) = build(&values, 100);
assert_eq!(ef.predecessor(4), None);
assert_eq!(ef.predecessor(5), Some(5));
}
#[test]
fn successor_predecessor_empty_sequence() {
let (_dir, ef) = build(&[], 1000);
assert_eq!(ef.successor(0), None);
assert_eq!(ef.predecessor(0), None);
}
#[test]
fn next_geq_matches_naive_realistic_offsets() {
let mut offset = 0u64;
let mut values = Vec::new();
let mut rng_state = 98765u64;
for _ in 0..2000 {
values.push(offset);
rng_state ^= rng_state << 13;
rng_state ^= rng_state >> 7;
rng_state ^= rng_state << 17;
offset += 1 + (rng_state % 8);
}
let universe = offset + 1;
let (_dir, ef) = build(&values, universe);
for x in (0..universe).step_by(7) {
assert_eq!(ef.next_geq(x), naive_next_geq(&values, x), "next_geq({x})");
}
}
#[test]
fn reopen_after_close_matches_original() {
let values: Vec<u64> = (0..2000).map(|i| i * 5 + (i % 3)).collect();
+33
View File
@@ -22,6 +22,10 @@ fn naive_select1(bits: &[bool], k: u64) -> usize {
bits.iter().enumerate().filter(|&(_, &b)| b).nth(k as usize).unwrap().0
}
fn naive_select0(bits: &[bool], k: u64) -> usize {
bits.iter().enumerate().filter(|&(_, &b)| !b).nth(k as usize).unwrap().0
}
#[test]
fn get_matches_input() {
let bits = [true, false, true, true, false, false, true];
@@ -91,6 +95,35 @@ fn select1_matches_naive_multi_block() {
}
}
#[test]
fn select0_matches_naive_small() {
let bits = [true, false, true, true, false, false, true, true, false, true];
let (_dir, r) = build(&bits);
let n_zeros = bits.iter().filter(|&&b| !b).count() as u64;
for k in 0..n_zeros {
assert_eq!(r.select0(k), naive_select0(&bits, k), "select0({k})");
}
}
#[test]
fn select0_matches_naive_multi_block() {
let n = 3000;
let bits: Vec<bool> = (0..n).map(|i| (i * 13 + 5) % 17 == 0).collect();
let (_dir, r) = build(&bits);
let n_zeros = bits.iter().filter(|&&b| !b).count() as u64;
for k in (0..n_zeros).step_by(23) {
assert_eq!(r.select0(k), naive_select0(&bits, k), "select0({k})");
}
}
#[test]
#[should_panic]
fn select0_out_of_range_panics() {
let bits = [true, true, false];
let (_dir, r) = build(&bits);
r.select0(1); // only one 0-bit (k=0 valid), k=1 must panic
}
#[test]
fn rank_select_round_trip() {
// For every one-bit's position p, select1(rank1(p)) == p.