|
| 1 | +# Phase B4a — self-contained reference access (`ReferenceView`) Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | +
|
| 5 | +**Goal:** Read the reference sequence directly from a persisted index — a borrowed, zero-copy `ReferenceView` over the `Reference2bit` section that decodes bases on demand (no full-reference allocation) — so B4b (aligner DP window) and B4c (variants `ref_base`) can be served from the `.idx` alone. |
| 6 | + |
| 7 | +**Architecture:** A `ReferenceView<'a>` in `genomics/index/view.rs` holding borrowed `&'a [u64]` slices over the mmap'd `Reference2bit` data + ambiguity bits, obtained via the existing checked `as_u64_slice`/`section_bytes`/`slice_exact`/`read_u64` helpers (same zero-copy discipline as `FmIndexView`). `base_at(global)` decodes one ASCII base on demand, mirroring `CompressedDNA::base_at`; `decode_window` fills a bounded caller buffer. Constructed via `ReferenceIndex::reference_view()`. |
| 8 | + |
| 9 | +**Tech Stack:** Rust 2021 (MSRV 1.72 — no `div_ceil`); checked `slice::align_to` (little-endian host); no new dependencies. |
| 10 | + |
| 11 | +This is sub-stage **B4a** of Phase B4 (wire consumers onto the persisted multi-contig index), from the design `docs/superpowers/specs/2026-05-27-phase-b4a-reference-view-design.md`. It follows B3a/B3b/B3c (merged, PRs #15/#16/#17). **Out of scope (deferred):** wiring `ReferenceView` into the aligner (**B4b**) and into `variants`/pileup (**B4c**). B4a delivers and gates only the reader. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## File structure |
| 16 | + |
| 17 | +- `src/genomics/index/view.rs` — **Modify.** Add `ReferenceView<'a>` (struct + `pub(crate) fn new` + `len`/`is_empty`/`base_at`/`decode_window`), reusing the file's existing `section_bytes`/`as_u64_slice`/`slice_exact`/`read_u64` helpers. |
| 18 | +- `src/genomics/index/io.rs` — **Modify.** Add `ReferenceIndex::reference_view()` (mirrors the existing `view()`/`genome_view()` accessors); add the equivalence-gate test to the existing `#[cfg(test)] mod tests` (reusing `temp_path`/`sample_index`). |
| 19 | +- `src/genomics/index/mod.rs` — **Modify.** Re-export `ReferenceView` from the `view` line. |
| 20 | +- `src/genomics/mod.rs` — **Modify.** Add `ReferenceView` to the `pub use index::{…}` line. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Task 1: `ReferenceView` + `reference_view()` accessor + equivalence gate |
| 25 | + |
| 26 | +**Files:** |
| 27 | +- Modify: `src/genomics/index/view.rs`, `src/genomics/index/io.rs`, `src/genomics/index/mod.rs`, `src/genomics/mod.rs` |
| 28 | + |
| 29 | +- [ ] **Step 1: Write the failing equivalence-gate test.** In `src/genomics/index/io.rs`, append to the existing `#[cfg(test)] mod tests` (it has `temp_path` + `sample_index`, and `sample_index` is multi-contig + `N`-bearing): |
| 30 | + |
| 31 | +```rust |
| 32 | + #[test] |
| 33 | + fn reference_view_decodes_bytes_identical_to_in_ram() { |
| 34 | + let idx = sample_index(); |
| 35 | + let path = temp_path("refview"); |
| 36 | + IndexWriter::create(&path).unwrap().write_genome_index(&idx).unwrap(); |
| 37 | + let loaded = IndexReader::open(&path).unwrap(); |
| 38 | + let rv = loaded.reference_view().unwrap(); |
| 39 | + let reference = idx.reference(); |
| 40 | + |
| 41 | + // base_at over the whole reference equals the original (incl. N positions). |
| 42 | + assert_eq!(rv.len(), reference.len()); |
| 43 | + assert!(!rv.is_empty()); |
| 44 | + for i in 0..rv.len() { |
| 45 | + assert_eq!(rv.base_at(i), reference[i], "base_at mismatch @ {i}"); |
| 46 | + } |
| 47 | + |
| 48 | + // decode_window over several ranges == the corresponding slices, including |
| 49 | + // a boundary-spanning range, an N-bearing range, the full reference, and an |
| 50 | + // over-range request (end clamped to len). |
| 51 | + let mut buf = Vec::new(); |
| 52 | + let n = reference.len(); |
| 53 | + for (s, e) in [(0usize, 10usize), (6, 14), (12, 22), (0, n), (n - 3, n + 5)] { |
| 54 | + rv.decode_window(s, e, &mut buf); |
| 55 | + assert_eq!(buf.as_slice(), &reference[s..e.min(n)], "decode_window {s}..{e}"); |
| 56 | + } |
| 57 | + let _ = std::fs::remove_file(path); |
| 58 | + } |
| 59 | + |
| 60 | + #[test] |
| 61 | + fn reference_view_is_a_small_borrow() { |
| 62 | + // Bounded: the view is slices + a scalar, not an owned copy of the reference. |
| 63 | + assert!( |
| 64 | + std::mem::size_of::<crate::genomics::ReferenceView<'_>>() <= 64, |
| 65 | + "ReferenceView must be a small borrow, got {}", |
| 66 | + std::mem::size_of::<crate::genomics::ReferenceView<'_>>() |
| 67 | + ); |
| 68 | + } |
| 69 | +``` |
| 70 | + |
| 71 | +- [ ] **Step 2: Run them to verify they fail.** |
| 72 | + |
| 73 | +Run: `cargo test --lib index::io::tests::reference_view 2>&1 | tail -20` |
| 74 | +Expected: compile error — `ReferenceIndex::reference_view` and `crate::genomics::ReferenceView` do not exist. |
| 75 | + |
| 76 | +- [ ] **Step 3: Implement `ReferenceView` in `src/genomics/index/view.rs`.** Add this (place it after the `GenomeIndexView` impl block, before the private free-fn helpers `section_bytes`/`as_u64_slice`/…): |
| 77 | + |
| 78 | +```rust |
| 79 | +/// A borrowed, zero-copy view over the persisted 2-bit forward reference |
| 80 | +/// (`Reference2bit`). Decodes bases on demand from the mmap — no full-reference |
| 81 | +/// allocation — so consumers (the aligner DP window in B4b, variants' `ref_base` |
| 82 | +/// in B4c) read the reference from the `.idx` alone. Uses **global** (concatenated) |
| 83 | +/// coordinates; `(contig, pos)` mapping stays with `ContigSet`. The little-endian |
| 84 | +/// host requirement is the same as `FmIndexView` (checked in `new`). |
| 85 | +#[derive(Debug)] |
| 86 | +pub struct ReferenceView<'a> { |
| 87 | + len: usize, |
| 88 | + /// 2-bit packed bases, 32 per `u64` word (A/C/G/T = 0/1/2/3). |
| 89 | + data: &'a [u64], |
| 90 | + /// Ambiguity bits, one per base (a set bit marks `N`). |
| 91 | + amb: &'a [u64], |
| 92 | +} |
| 93 | + |
| 94 | +impl<'a> ReferenceView<'a> { |
| 95 | + /// Parse + validate the `Reference2bit` section into a borrowed view. |
| 96 | + pub(crate) fn new(bytes: &'a [u8], sections: &[SectionEntry]) -> Result<Self, IndexIoError> { |
| 97 | + if cfg!(target_endian = "big") { |
| 98 | + return Err(IndexIoError::Invalid( |
| 99 | + "zero-copy reference view requires a little-endian host".to_string(), |
| 100 | + )); |
| 101 | + } |
| 102 | + let section = section_bytes(bytes, sections, SectionKind::Reference2bit)?; |
| 103 | + let mut o = 0usize; |
| 104 | + let len = read_u64(section, &mut o)? as usize; |
| 105 | + let data_words = read_u64(section, &mut o)? as usize; |
| 106 | + let amb_words = read_u64(section, &mut o)? as usize; |
| 107 | + let data = as_u64_slice(slice_exact(section, &mut o, data_words.saturating_mul(8))?)?; |
| 108 | + let amb = as_u64_slice(slice_exact(section, &mut o, amb_words.saturating_mul(8))?)?; |
| 109 | + // The slices must cover `len` bases so `base_at` cannot index out of bounds. |
| 110 | + if data.len().saturating_mul(32) < len || amb.len().saturating_mul(64) < len { |
| 111 | + return Err(IndexIoError::Invalid( |
| 112 | + "Reference2bit section too small for the declared length".to_string(), |
| 113 | + )); |
| 114 | + } |
| 115 | + Ok(Self { len, data, amb }) |
| 116 | + } |
| 117 | + |
| 118 | + /// Number of reference bases. |
| 119 | + pub fn len(&self) -> usize { |
| 120 | + self.len |
| 121 | + } |
| 122 | + |
| 123 | + /// Whether the reference is empty. |
| 124 | + pub fn is_empty(&self) -> bool { |
| 125 | + self.len == 0 |
| 126 | + } |
| 127 | + |
| 128 | + /// The ASCII base at global position `global`. Mirrors `CompressedDNA::base_at`: |
| 129 | + /// a set ambiguity bit decodes to `N`, otherwise the 2-bit code maps to A/C/G/T. |
| 130 | + pub fn base_at(&self, global: usize) -> u8 { |
| 131 | + debug_assert!(global < self.len, "reference index out of range"); |
| 132 | + if self.amb[global / 64] & (1u64 << (global % 64)) != 0 { |
| 133 | + return b'N'; |
| 134 | + } |
| 135 | + let code = ((self.data[global / 32] >> ((global % 32) * 2)) & 0b11) as u8; |
| 136 | + match code { |
| 137 | + 0 => b'A', |
| 138 | + 1 => b'C', |
| 139 | + 2 => b'G', |
| 140 | + _ => b'T', |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + /// Decode `[start, end.min(len))` into `out` (cleared first). `end` is clamped |
| 145 | + /// to `len`, so an over-range request never panics; `start <= end` is the |
| 146 | + /// caller's contract. Bounded by the window size — never the whole genome. |
| 147 | + pub fn decode_window(&self, start: usize, end: usize, out: &mut Vec<u8>) { |
| 148 | + out.clear(); |
| 149 | + let end = end.min(self.len); |
| 150 | + for i in start..end { |
| 151 | + out.push(self.base_at(i)); |
| 152 | + } |
| 153 | + } |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +(`section_bytes`, `as_u64_slice`, `slice_exact`, `read_u64` already exist as private free functions in `view.rs`. `SectionEntry`/`SectionKind` and `IndexIoError` are already imported there. The 2-bit decode is a deliberate 4-line mirror of `CompressedDNA::base_at` in `genomics/compressed_dna.rs`; the equivalence gate is the guarantee against drift.) |
| 158 | + |
| 159 | +- [ ] **Step 4: Add the `reference_view()` accessor in `src/genomics/index/io.rs`.** In `impl ReferenceIndex`, after `genome_view()`, add: |
| 160 | + |
| 161 | +```rust |
| 162 | + /// Borrow a zero-copy view over the persisted 2-bit reference (`Reference2bit`). |
| 163 | + pub fn reference_view( |
| 164 | + &self, |
| 165 | + ) -> Result<crate::genomics::index::view::ReferenceView<'_>, IndexIoError> { |
| 166 | + crate::genomics::index::view::ReferenceView::new(self.mmap.as_bytes(), &self.sections) |
| 167 | + } |
| 168 | +``` |
| 169 | + |
| 170 | +- [ ] **Step 5: Re-export `ReferenceView`.** In `src/genomics/index/mod.rs`, change: |
| 171 | + |
| 172 | +```rust |
| 173 | +pub use view::{FmIndexView, GenomeIndexView}; |
| 174 | +``` |
| 175 | + |
| 176 | +to: |
| 177 | + |
| 178 | +```rust |
| 179 | +pub use view::{FmIndexView, GenomeIndexView, ReferenceView}; |
| 180 | +``` |
| 181 | + |
| 182 | +In `src/genomics/mod.rs`, add `ReferenceView` to the `pub use index::{…}` list (keep it alphabetical / consistent with the existing line), e.g.: |
| 183 | + |
| 184 | +```rust |
| 185 | +pub use index::{ |
| 186 | + estimate_build_working_set, render_plan_line, FmIndexView, GenomeIndexView, IndexBuildReport, |
| 187 | + IndexHeader, IndexReader, IndexWriter, ReferenceIndex, ReferenceView, |
| 188 | +}; |
| 189 | +``` |
| 190 | + |
| 191 | +(Match the exact existing item set in that `use` — only ADD `ReferenceView`; do not drop anything.) |
| 192 | + |
| 193 | +- [ ] **Step 6: Run the gate + build + fmt.** |
| 194 | + |
| 195 | +Run: `cargo test --lib index::io::tests::reference_view 2>&1 | tail -20` (both `reference_view_decodes_bytes_identical_to_in_ram` and `reference_view_is_a_small_borrow` PASS), then |
| 196 | +`cargo test --lib 2>&1 | grep -E "test result:"` (no failures), then |
| 197 | +`cargo build --lib 2>&1 | grep -iE 'error|warning'` (none — `ReferenceView`/`base_at`/`decode_window`/`reference_view` are all used by the tests, so no dead-code warning), then |
| 198 | +`cargo fmt --all` then `cargo fmt --all -- --check` (clean). |
| 199 | + |
| 200 | +- [ ] **Step 7: Commit (stage the 4 files).** |
| 201 | + |
| 202 | +```bash |
| 203 | +git add src/genomics/index/view.rs src/genomics/index/io.rs src/genomics/index/mod.rs src/genomics/mod.rs |
| 204 | +git commit -m "feat(genomics/index/view): ReferenceView — zero-copy reference access from the index" \ |
| 205 | + -m "A borrowed ReferenceView over the persisted Reference2bit section (base_at + decode_window, on-demand 2-bit decode mirroring CompressedDNA::base_at, no full-reference Vec), via ReferenceIndex::reference_view(). Gated by base_at/decode_window == the original reference over an N-bearing multi-contig index. The shared reference-access foundation B4b (aligner DP window) and B4c (variants ref_base) consume." \ |
| 206 | + -m "Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" |
| 207 | +``` |
| 208 | + |
| 209 | +Then verify: `git show --stat HEAD` lists ONLY the 4 files; `git status --short` is empty. |
| 210 | + |
| 211 | +--- |
| 212 | + |
| 213 | +## Final verification (before the B4a PR) |
| 214 | + |
| 215 | +- `cargo test` — full suite green (the new `reference_view_*` tests + the unchanged B3b/B3c suites). |
| 216 | +- `cargo build 2>&1 | grep -iE 'error|warning'` — clean. |
| 217 | +- `cargo fmt --all -- --check` — clean. |
| 218 | +- No-rebuild (structural): `reference_view()` reads only the mmap; `grep -n "reference_view" src/genomics/index/io.rs` shows it calling `ReferenceView::new`, never `BlockedFMIndex::build`/`sais_u32`. |
| 219 | + |
| 220 | +## Self-Review |
| 221 | + |
| 222 | +- **Spec coverage (`2026-05-27-phase-b4a-reference-view-design.md`):** |
| 223 | + - §4 `ReferenceView` (`new`/`len`/`base_at`/`decode_window`) ✔ Task 1 Step 3. |
| 224 | + - §4 `ReferenceIndex::reference_view()` ✔ Step 4. |
| 225 | + - §5 on-demand zero-copy (borrowed slices, no `Vec`; `decode_window` bounded) ✔ — the struct holds `&'a [u64]` + a scalar; gated by `reference_view_is_a_small_borrow`. |
| 226 | + - §6 gates — equivalence (`base_at`/`decode_window` == original over N-bearing multi-contig) ✔; bounded (`size_of` ≤ 64) ✔; integrity (`new` rejects a too-small section) ✔ (the `data.len()*32 < len` / `amb.len()*64 < len` check); no rebuild ✔ (final verification grep + reads-only-mmap). |
| 227 | + - §7 testing (build→serialize→open→`reference_view`, ranges incl. boundary-spanning + N + over-range clamp) ✔. |
| 228 | +- **Type/name consistency:** `ReferenceView<'a> { len: usize, data: &'a [u64], amb: &'a [u64] }`; `new(bytes, sections) -> Result<Self, IndexIoError>`; `len()`, `is_empty()`, `base_at(usize) -> u8`, `decode_window(usize, usize, &mut Vec<u8>)`; `ReferenceIndex::reference_view() -> Result<ReferenceView<'_>, IndexIoError>`; re-exported as `crate::genomics::ReferenceView`. The decode matches `CompressedDNA::base_at` (ambiguity word `i/64` bit `i%64`; data word `i/32` shift `(i%32)*2`). |
| 229 | +- **No placeholders:** every step ships complete code or an exact command + expected output. |
| 230 | +- **MSRV 1.72:** no `div_ceil`; `saturating_mul` for the length/extent math; `1u64 << (i % 64)` / `(i % 32) * 2` are plain shifts. |
| 231 | +- **Reuse/DRY:** reuses `view.rs`'s `section_bytes`/`as_u64_slice`/`slice_exact`/`read_u64`; mirrors the `FmIndexView::new` construction + LE-host pattern; the 2-bit decode mirrors `CompressedDNA::base_at` (drift caught by the gate). |
| 232 | +- **Scope:** reader only — no aligner (B4b) or variants/pileup (B4c) wiring; `ReferenceView` is global-coordinate (no contig API). |
| 233 | +- **Shared-tree hazard:** the implementer/reviewer dispatches must forbid `cargo fix`/mutating git, stage only the named files, and self-verify `git show --stat HEAD`; the coordinator verifies the commit stat + clean tree at the task boundary. |
| 234 | +``` |
0 commit comments