Skip to content

Commit da3d1f6

Browse files
logannyeclaude
andcommitted
docs(plan): Phase B4a — ReferenceView (self-contained reference access) plan
Single TDD task: a borrowed zero-copy ReferenceView over Reference2bit (base_at + decode_window) + ReferenceIndex::reference_view() + re-exports, gated by decode==original over an N-bearing multi-contig index. Reuses view.rs section/align_to helpers. Derived from spec 913b993. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 913b993 commit da3d1f6

1 file changed

Lines changed: 234 additions & 0 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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

Comments
 (0)