Skip to content

feat: SVAR2 read-bound dataset wiring (draft — blocked on genoray svar-2 release)#266

Draft
d-laub wants to merge 87 commits into
mainfrom
svar2-m6b-kernel
Draft

feat: SVAR2 read-bound dataset wiring (draft — blocked on genoray svar-2 release)#266
d-laub wants to merge 87 commits into
mainfrom
svar2-m6b-kernel

Conversation

@d-laub

@d-laub d-laub commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

SVAR2 read-bound dataset wiring

Wires genoray's .svar2 sparse variant format into gvl.write and the gvl.Dataset read path. .svar2 is now a supported write variant source (a write-time ranges cache) and a live Dataset read backend that reconstructs all four output modes (reference, haplotypes, variants, tracks) via one all-Rust FFI per read — gathering off the cache with no interval-search-tree rebuild and no dense-union rebuild, byte-identical to the .svar/union-oracle path. It additionally supports the variant-windows sequence mode and unphased_union ploidy folding (see "Latest addition" below).

⚠️ DRAFT — blocked on the genoray release gate. This branch dev-wires genoray's svar-2 branch (@ b79e83a) via local path-deps + a local wheel:

  • Cargo.toml: genoray_core = { path = "/carter/.../genoray", package = "genoray", default-features = false } and svar2-codec = { path = "/carter/.../genoray/svar2-codec" }
  • pixi.toml: genoray = { path = ".../target/wheels/genoray-2.15.0-...whl" }

Upstream CI will not build until genoray svar-2 is published and these are flipped to the released crates.io/PyPI versions. Merge only after: (1) genoray svar-2 released (cz bump + publish), (2) path-deps → published versions, (3) pyo3/numpy pins reconciled with the release. This PR captures the complete, reviewed implementation for that moment.

What's included

The full svar2-m6b-kernel feature branch (69 commits ahead of main): the earlier M6b two-source union-path kernels, the read-bound wiring, the read-bound getitem perf optimizations (B1–B5), and — most recently — the variant-windows + unphased_union read path. Core pieces:

  • WriteSvar2Link/Svar2Fingerprint + Metadata.svar2_link; _write_from_svar2 streams a 6-array ranges cache under genotypes/svar2_ranges/ (sized to the dataset's selected samples); .svar2 write dispatch.
  • Rust read core — links genoray's query-only genoray_core crate + Svar2Store pyclass (one ContigReader per contig, opened once). Read-bound kernels reuse the already-validated union kernels by marshalling BatchResultSplit into their flat layout via svar2::split_to_flat (no forked kernels): reconstruct_haplotypes_from_svar2_readbound, shift_and_realign_tracks_from_svar2_readbound, decode_variants_from_svar2_readbound, hap_diffs_from_svar2_readbound.
  • Dataset dispatch — a separate Svar2Haps reconstructor (SVAR1 Haps byte-unchanged) for haplotypes + variants; an additive HapsTracks branch for realigned tracks; Dataset.open(svar2=<override>) mirroring svar=. The live union (overlap_batch) dispatch is retired to a test-only parity oracle.
  • Read-bound getitem perf — byte-identical read-path optimizations that brought haplotypes (the primary training-read mode) to parity with .svar (from 12–25× slower). The wins, in order of impact:
    • Single-group fast-path (gvl 63b8b87) — cProfile showed ~96% of the svar2 haplotype call was a numpy arange+repeat ragged-row permutation to un-group per-contig kernel output back to global order; for a single-contig batch that permutation is the identity. Skipping it dropped haplotypes 12–25× → ~1.1–1.2×.
    • genoray dense-presence block-copy (genoray b79e83a, 3ff8942) — replaced gather_haps_readbound's bit-by-bit per-hap presence loop with a per-query threshold + byte-batched copy_bits (self-time 8.85% → 3.31%).
    • Variant-path fusion (gvl b84a57d) — decode_variants_from_split decodes straight from the gather via a streaming 3-way merge, dropping the split_to_flat marshaling copy (variants germline 3.0–3.2× → 2.6×, somatic → 3.66×).
    • Plus the earlier B1–B5 instruction-level reductions (B4a genoray gather_haps_readbound asm ≈−8.5% variants_germline; B1 −6.3% haplotypes). All byte-identical; svar2 parity suite held green throughout. Consolidated in tmp/svar2_mvp/prof_out/bench_gvl/OPTIMIZATION.md + readbound/RESULTS.md.
  • Requires a pyo3 0.28→0.29 + numpy 0.28→0.29 bump (to match genoray svar-2); validated additive (full Rust suite green).

Latest addition — variant-windows + unphased_union (pure-Python composition, no Rust change)

Dataset.open(..., svar2=...).with_seqs("variant-windows", VarWindowOpt(...)) (previously NotImplementedError) now works, and unphased_union is enabled for both svar2 decode modes (variants and variant-windows). Implemented by composing the already-validated svar2 variant decode with the already-validated window-assembly Rust kernel (assemble_variant_buffers) via an identity gather, per contig group — no new/changed Rust.

  • ref="window" with alt ∈ {window, allele}. ref_window is byte-identical to the SVAR1 backend; alt/alt_window differ only for pure-deletion records (the empty-vs-anchor ALT convention below), validated instead via svar2-internal decomposition + tokenized variants.alt.
  • unphased_union folds ploidy 2→1 (order-preserving, no dedup) — the same row_offsets[::P], eff_ploidy=1 fold SVAR1 uses; start/ilen byte-identical to the SVAR1 union. When the flag is off, both reconstructors reduce byte-for-byte to the pre-existing diploid path.
  • Still guarded in this mode: ref="allele" (needs stored REF bytes .svar2 lacks → ValueError), and max_jitter>0/jitter>0 (read-bound decode has no right-clip → NotImplementedError).
  • Docs updated (skills/genvarloader/SKILL.md, docs/source/{faq,format}.md); perf/fusion for this mode is deferred (see follow-ups).

Parity & verification

  • All four modes byte-identical to the .svar/union oracle (SparseVar2Source, genoray decode). SVAR1 path is additive (byte-unchanged). 49 svar2-selected tests across the tree (21 in tests/dataset/test_svar2_dataset.py, incl. 10 variant-windows/unphased_union cases); full tree 1040 passed / 0 failed / 0 errors.
  • Two documented, intentional non-identities: (1) a pure deletion's variants/variant-windows ALT is empty b"" for .svar2 vs the VCF anchor base for .svar (genoray atomized convention; haplotypes and ref_window unaffected); (2) SVAR1's max_ends same-POS-tie under-extension is a latent SVAR1 bug (docs/known-issues/svar1-max-ends-tie-underextension.md) and those ties are excluded from parity.
  • Now supported (previously guarded): variant-windows (ref="window", alt ∈ {window, allele}) and unphased_union (for variants and variant-windows).
  • Still guarded — splice, exonic/keep, min_af/max_af, annotated, in-kernel RC, ref="allele" for variant-windows, fixed-length tracks, variants/variant-windows + max_jitter>0/jitter>0, and multi-contig FlankSample all raise NotImplementedError/ValueError (never silently mis-compute).

Benchmark (chr21, matched .svar/.svar2, warm Dataset[:, :] through the full gvl getitem path)

Current numbers, after the read-parity optimization effort (single-group fast-path + genoray block-copy + variant fusion above). Both backends go through the full gvl Dataset.__getitem__ path (svar1 via variants=<p>.svar, svar2 via variants=SparseVar2(<p>.svar2)), interleaved per-repeat so shared-node drift hits both equally — the ratios are trustworthy; absolute ms are node-dependent. Workload: chr21, 20×2 kb windows, ALL samples, max_jitter=0. Harness: tmp/svar2_mvp/bench_gvl_svar1_vs_svar2.py; consolidated in tmp/svar2_mvp/prof_out/bench_gvl/OPTIMIZATION.md.

mode germline (n=3202) somatic (n=16007)
haplotypes .svar1 1.08–1.11× faster → PARITY .svar1 1.15–1.21× faster → PARITY
variants .svar1 2.6× faster .svar1 3.66× faster
store size .svar2 5.7× smaller (193 MiB vs 1.07 GiB) .svar2 1.45× smaller (36 vs 53 MiB)

(Starting point before this effort was haplotypes 12–25× slower; the single-group fast-path — killing the wasted numpy reorder — plus the genoray dense-presence block-copy closed it to parity.)

  • Haplotypes — the primary training-read mode — is at parity. svar2 reconstructs haplotypes with the same live per-sample gather + codec decode that svar1 does after its own reconstruct, so once the numpy-glue overhead was removed the two converge.
  • Variants stays ~2.6–3.7× and is architectural, not an unoptimized path. svar1's variant read is trivially cheap — it returns pre-materialized sparse int32 variant indices; svar2 must run the full per-hap gather + codec decode (pos/ilen/alt) live off the compact store, ~half gather / ~half decode with no single dominant culprit. Closing it would require either materializing a lighter per-sample variant index at write time (giving up part of svar2's storage advantage — its whole point) or a deeper gather→decode fusion (incremental, unlikely to reach full parity alone). This is a storage-vs-read-speed design decision, tracked as a follow-up.
  • DSO-split structural win (met): perf of the warm .svar2 read loop shows zero SearchTree::build, dense_union, or overlap_batch self-time — the whole-cohort union/oracle path is absent; the read-bound path rebuilds no per-read interval tree or dense union.

Bottom line: .svar2 now matches .svar on haplotype read latency while being up to 5.7× smaller on disk (dense germline cohorts). Variants read remains ~2.6–3.7× slower — the cost of decoding the compact codec live rather than pre-materializing indices — which is the intended storage-vs-speed tradeoff. (Benchmarks predate the variant-windows/unphased_union addition, which adds a new mode and does not alter the variants/haplotypes read-path perf measured here.)

Follow-ups (out of scope; guarded)

  • Variants read-latency parity is an architectural open decision (~2.6–3.7× slower). Options: (A) materialize a lighter per-sample variant index at write time — trades away part of svar2's storage advantage; (B) deeper gather→decode fusion for the variants path — incremental, unlikely to reach full parity alone. Haplotype parity is already achieved. Profiled-but-deferred asm targets on the residual: genoray decode_key (2.33%), merge_keys (2.31%), SearchTree::build (1.54%).
  • Perf/fusion for variant-windows + unphased_union (the correctness path landed; the fused-decode perf pass is deferred).
  • Variants/variant-windows right-clip for max_jitter>0/jitter>0 (to lift that guard); ref="allele" for variant-windows (needs stored REF bytes).
  • Global-query-index remap to lift the multi-contig FlankSample guard.
  • .pyi stubs for the svar2 FFI (pre-existing bigwig-only gap).

🤖 Generated with Claude Code

d-laub and others added 30 commits July 3, 2026 11:15
Task 1 of the gvl two-source kernel plan. Dev-wiring only, release-gated:
- svar2-codec: Cargo path-dep to the merged genoray svar-2 checkout.
- genoray: pre-built cp310 wheel of genoray 2.15.0 (svar-2 with main merged),
  carrying both SparseVar2.overlap_batch and SparseVar2.decode. Wheel rather than
  editable to avoid pulling genoray's rust-htslib C toolchain into this env;
  Plan 3 does not modify genoray.
- seqpro bumped ==0.20.0 -> ==0.21.1 to satisfy genoray 2.15.0's seqpro>=0.21.1,<0.22.

Verified: `import genoray, genvarloader; from genoray import SparseVar2` OK
(genoray 2.15.0); `cargo check` clean with svar2-codec linked from the path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 2: src/svar2/mod.rs with decode_alt (mirrors genoray decode_keyref:
Inline→v_diff=len-1 owned; PureDel→raw negative ilen, empty; Lookup→LUT-borrowed,
v_diff=len-1) and merge_hap (stable position sort, var_key before dense on ties).
Adds pub mod svar2; to src/lib.rs. Unit tests cover PureDel/Lookup decode and the
merge tie-break. cargo test --no-default-features svar2 → 2 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 3. Refactor the reconstruction inner kernel to a variant-source closure and
add the SVAR2 two-source batch driver, keeping SVAR 1.0 byte-identical.

- reconstruct_haplotype_core<'a>(n_variants, provide: Fn(usize)->(v_pos, v_diff,
  Cow<'a,[u8]>, annot_id), ...) holds the (unchanged) reconstruction math; the
  per-variant reads and the annotation id now come from `provide`.
- reconstruct_haplotype_from_sparse: thin wrapper building the global-table closure
  (Cow::Borrowed alt_flat, annot_id = global variant id). Behavior unchanged;
  17 SVAR1 reconstruct tests + full lib (114) pass.
- reconstruct_haplotypes_from_svar2: new driver mirroring the SVAR1 batch driver's
  parallel/serial disjoint-slice carving; per hap builds the var_key⋈dense merged
  list (svar2::merge_hap) and decodes via svar2::decode_alt; annot_id = per-hap seq.
- Pure-DEL anchor fix: genoray decodes a pure DEL to an EMPTY ALT (anchor recovered
  from reference); the kernel needs the anchor byte, so the driver substitutes
  ref[pos] when the decoded allele is empty (⟺ PureDel). Without it the anchor is
  deleted too (off-by-one). Guarded by svar2_pure_del_keeps_anchor + the corrected
  svar2_reconstruct_snp_and_del.

Reviewed (Opus): Spec ✅, Approved, no Critical/Important; 3 Minor (coverage of
multi-hap/parallel + Lookup/unset-presence end-to-end — covered by Task 7; degenerate
out-of-bounds PureDel pos).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 4. Track-realign analogue of Task 3's reconstruct refactor.

- shift_and_realign_track_core(n_variants, provide: Fn(usize)->(v_start, v_diff), ...)
  holds the (unchanged) realignment math; shift_and_realign_track_sparse is now a thin
  wrapper building the global-table closure. SVAR 1.0 behavior byte-identical.
- shift_and_realign_tracks_from_svar2: new driver mirroring the SVAR1 batch driver's
  parallel/serial carving; per hap builds the var_key⋈dense merged list (svar2::merge_hap)
  and takes ilen from svar2::decode_alt (allele discarded — tracks need only v_diff; a DEL
  writes track[v_rel_pos] implicitly, so no anchor fix). No per-hap allele table.
- Test svar2_track_realign_del: DEL(ilen=-2) keeps anchor, deletes 2 -> [10,20,50,0]
  (controller hand-verified). 29 tracks tests pass (SVAR1 regression + new).

Also includes cosmetic rustfmt reflow of pre-existing track test code (fmt-clean now;
no logic change). Reviewed (Sonnet): Spec ✅, Approved, no defects, 2 Low/cosmetic notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gvl M6b)

Task 5. Expose the SVAR2 drivers to Python, sizing + allocating output Rust-side.

- svar2::hap_diffs_svar2: per-hap applied-ilen diff, an exact port of
  genotypes::get_diffs_sparse's q_start/q_end-clipped branch, sourcing (v_start, v_ilen)
  from merge_hap + decode_alt instead of the global table. Sizes the fused outputs.
- ffi::reconstruct_haplotypes_from_svar2 (#[pyfunction]): mirrors reconstruct_haplotypes_fused
  — hap_diffs -> out_offsets (ragged or fixed output_length) -> uninit_output ->
  reconstruct_haplotypes_from_svar2 driver -> (out_data u8, out_offsets) into_pyarray.
- ffi::shift_and_realign_tracks_from_svar2 (#[pyfunction]): same sizing (realigned track has
  haplotype length), zeros f32 buffer, calls the track driver, returns (out f32, offsets).
- Registered both in the #[pymodule].

Verified: hap_diffs unit test passes; full lib 116 pass; maturin develop builds (abi3);
both pyfunctions register; a no-variant FFI call round-trips (ref passthrough, offsets [0,8]).
Reviewed (Sonnet): Spec ✅, Approved, no correctness issues, 2 Informational (rustfmt reflow
of pre-existing code — whitespace-only; stale report text). FFI numeric correctness is
validated end-to-end in Task 7. Also folds in accumulated rustfmt of pre-existing ffi/lib code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 6 (Step 1). SparseVar2Source: bridges genoray SparseVar2.overlap_batch's raw
two-channel dict to the SVAR2 kernels (reconstruct_haplotypes_from_svar2 /
shift_and_realign_tracks_from_svar2), returning the same Ragged/_Flat types as SVAR 1.0.

Layout mapping (crux): genoray lays out H=R*S*P haps region-major h=(r*S+s)*P+p; the gvl
driver indexes k=query*P+hap and reads dense_range/regions by query. So n_q=R*S and
dense_range/regions are expanded (np.repeat S times per region) to align dense_range_gvl[r*S+s]
== genoray dense_range[r]. vk_off/dense_present_off are already H-indexed (=k), passed through.

Scope: Step 1 (self-contained adapter) only. The plan's Step 2 (wiring into Haps/Dataset
dispatch) is DEFERRED — it touches the central SVAR 1.0 reconstructor and is gated by no test
in this plan; Task 7 validates this adapter directly. Noted as TODO(svar2-dataset-dispatch)
in the module docstring. SVAR 1.0 path untouched.

Verified: module imports; exposes reconstruct + realign_tracks. End-to-end correctness is
validated in Task 7 vs genoray's SparseVar2.decode oracle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gvl M6b)

Task 7. Builds a known SVAR2 store (genoray run_conversion_pipeline on a 40bp chr1
fixture: SNP@2, INS@6 C>CAT, DEL@11 GTA>G across 2 samples × 2 ploids), reconstructs
haplotypes through gvl's two-source path (SparseVar2Source.reconstruct → overlap_batch →
reconstruct_haplotypes_from_svar2), and compares byte-for-byte against an INDEPENDENT
pure-Python consensus applied to genoray's materialized SparseVar2 decode records (M6c
oracle). Agreement proves gvl's var_key⋈dense merge+decode == genoray decode AND the
reconstruction loop == an independent reference, across all 4 haps (counts [2,2,1,2]).
Asserts non-triviality + indel length sensitivity (min<40<max), which exercises the
pure-DEL anchor fix on the DEL-carrying haps.

Regression: full Rust suite 120 pass (incl. 19 reconstruct + 29 tracks — SVAR1 byte-
identical); Python test_realign_tracks 9 pass, test_flat 9 pass; this e2e passes.
(Full Python haplotype parity suite is blocked in this worktree by pre-existing missing
generated test data + a vcfixture 0.6.0 API drift — not a regression of this branch.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design for A (worktree cleanup), C (realign_tracks e2e test), D (real-data
chr21 validation), E (SVAR1-vs-SVAR2 benchmark), and a B (Dataset dispatch)
design sketch. Sequencing: prove value on the SparseVar2Source adapter first,
then wire B informed by the benchmark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the bite-sized task plan (A cleanup, C realign_tracks e2e test, D real-data
chr21 validation, E SVAR1-vs-SVAR2 benchmark) and refines the spec's Task C oracle
to the pure-Python SVAR1 shift_and_realign_track_sparse fed genoray decode records.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build both .svar (SVAR1) and .svar2 (SVAR2) stores from real chr21 germline
(1000G, 3202 smp) and somatic (GDC, 16007 smp) BCFs, and spot-check both gvl
backends. Both return non-empty, correctly-shaped haplotypes+variants. SVAR2
is 5.67x smaller (germline) / 1.46x smaller (somatic) on disk.

Notes record two infra/data fixes: compute-node LD_LIBRARY_PATH GLIBCXX
shadowing (prepend pixi env lib), and filtering symbolic/breakend ALTs that
SVAR2 rejects (short-read only) before conversion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fair all-samples/3-region workload on chr21 germline (3202 smp) + somatic
(16007 smp), warm caches, median of N=5. SVAR2 store is 5.67x (germline) /
1.46x (somatic) smaller; SVAR2 hap latency scales far better with cohort
size (1.34x vs SVAR1's 6.8x over 3202->16007 smp), reaching parity at 16007.
Caveat: adapter-vs-Dataset (Task B unwired), so SVAR2 latency excludes gvl
batching/collation.

Fixes a fairness bug in the plan draft (opened validate's 2-region .gvl while
SVAR2 queried 3 regions); now writes the 3-region BED first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g caveats

Final-review + maintainer feedback: the latency comparison is a raw SVAR2
adapter (new, unprofiled Python orchestration) vs the mature SVAR1 Dataset,
not apples-to-apples. Remove unprofiled causal claims and the confounded
germline-vs-somatic 'scales with cohort size' extrapolation. Record:
- dense matrix is hap-major (query.rs:131, bit = hap*n_dense+col), variant
  innermost -> per-hap contiguous (refutes a (V,S,P) pessimal-layout guess);
  but n_dense is contig-wide, so narrow-region all-sample reads scatter one
  cache line per hap (contig-scoped, unprofiled hypothesis).
- var-latency columns measure different ops (genoray decode vs with_seqs).
- size table is .svar vs .svar2 stores; SVAR1 also materializes a .gvl.
- single-contig: split-by-contig layout unassessed.
- build is read-bound; single-contig underuses threads.
Storage win (1.46x-5.67x) stands as the unconfounded result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ld threads)

Concrete measurement plan to de-confound the MVP benchmark's latency numbers:
E1 py-spy Python-vs-native split then perf Rust detail (attribute the SVAR2
adapter gap to Python overhead vs Rust hot paths vs dense layout); E2 same-cohort
sample sweep; E3 dense-access layout probe; E4 conversion thread-allocation.
Notes the py3.10 perf caveat (Python frames unsymbolizable pre-3.12, so py-spy
for Python-side + perf for Rust). Measurement only; feeds Task B + genoray
conversion thread-policy decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s skeleton

Session runs in a 2cpu/8GB carter-cn-04 alloc: light profiling runs on-node, heavy work via sbatch. gvl+genoray rebuilt --release with line-tables debug + frame pointers; perf --call-graph fp resolves genoray_core::query::overlap_batch to source-level Rust symbols. Plan: E1 perf switched dwarf->fp (dwarf overloaded the node).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er-driven)

Submitted subsample(12064665)->build array(12064666, afterok). Builds .svar+.svar2 for S in {1000,2000,4000,8000}; S=16007 reuses existing somatic.* store. Run off-node (fresh sbatch, 16cpu/64G) to keep the 2-cpu profiling node free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Small warm-loop CLI (tmp/svar2_mvp/prof_driver.py) that exercises exactly
one hap-reconstruction path per invocation so perf/py-spy attribute time
to that path alone: SVAR1 via gvl.Dataset.open(...).with_seqs("haplotypes")
or SVAR2 via SparseVar2Source.reconstruct. For svar1 the 3-region .gvl is
written once before the timed loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…AR1)

Grounded by E1 profiling: SVAR2 adapter is ~80% genoray SearchTree::build (rebuilt per overlap_batch). Design mirrors SVAR1's write-time search cache: genoray gains find_ranges/gather_ranges/read_ranges (samples=-aware) split; gvl caches compact ranges + Svar2Link, gathers tree-free at read. Haps+tracks, byte-identical parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ling plan

py-spy unusable on Carter (ptrace_scope=2); E1 done via perf --call-graph fp DSO buckets. E2/E4 cancelled per user pivot to the SVAR2 Dataset-wiring design. prof_out/ binaries gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ofiling plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire .svar2 into gvl.write() + Dataset.__getitem__ with all four output modes
(haplotypes, tracks, variants, variant-windows) reconstructing in Rust off a
write-time offsets cache. Read is all-Rust (genoray_core path-dep, no Python
gather_ranges), with a per-class read-bound gather that eliminates both the
interval-search rebuild and the per-read contig-wide dense-union rebuild
(4 offset arrays into the already-split 2-bit/u32 dense tables). Write caches
only the dataset's samples. genoray gets a query-only `conversion` feature so
gvl links an htslib-free core. Local-only; relocate+rerun the MVP benchmark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix 1: assert the 6-array ranges cache CONTENTS (not just shapes) against a
direct find_ranges call, locking the row-major (R,S,P) reshape.
Fix 2: same-POS SNP+DEL tie chromEnd parity vs a matched .svar (passes for
SNP-first store order). NOTE: DEL-first store order provably diverges
(svar1 under-extends via max-v_idx order; svar2's max-end is the correct
coverage) — a latent SVAR1 max_ends bug; policy pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Links genoray's query-only core crate (default-features=false, pkg genoray /
lib genoray_core) and adds the Svar2Store pyclass: opens one ContigReader per
contig at Dataset.open, held for the store's lifetime (SVAR2 analog of SVAR1's
cached FFI-static).

Requires bumping gvl pyo3 0.28.3->0.29 + numpy 0.28->0.29 to match genoray
@ aaf44fd (pyo3-ffi links="python" allows only one pyo3 version in the graph).
Bump is clean: no API migration, no new warnings; full Rust suite (120 tests)
green under 0.29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reuse the already byte-validated union-path kernels (svar2::hap_diffs_svar2,
reconstruct::reconstruct_haplotypes_from_svar2) instead of duplicating a
three-source merge/decode/sizing path: marshal genoray's read-bound
BatchResultSplit into the same flat single-dense-channel layout via a new
svar2::split_to_flat, then call the existing kernels unchanged through a
new reconstruct_haplotypes_from_svar2_readbound FFI entry point.

Parity test (tests/dataset/test_svar2_readbound_haps.py) confirms
byte-identical output vs. the union-path oracle (SparseVar2Source.reconstruct)
across region-shape variations and non-trivial per-hap jitter shifts.
…+ byte boundary

split_to_flat only grew dense_present on set bits, leaving a trailing all-zero
byte unallocated -> OOB panic when the reused kernels read every window bit for
a final hap landing in that byte. Resize to bit_acc.div_ceil(8) unconditionally
(matches genoray's byte-sizing). Add a >8-bit unit test with an unset trailing
byte (regression for the OOB) and a parity case routing a >=2-carrier SNP into
dense_snp (mixed with dense indels) to exercise the snp<indel tie order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
d-laub and others added 11 commits July 6, 2026 12:54
vcfixture 0.6 dropped the VcfBuilder(fileformat=) str kwarg (use
version=VcfVersion; VCFv4.0 is no longer offered, so pin to V4_1) and now
requires typed Allele instances for ALT (alt=[Seq("C")], not alt=["C"]).
Update the session/case fixture builders so `pixi run -e dev gen` and the
fixture-backed tests run again. Bump the pin to >=0.6.0,<0.7 and unify
vcfixture to 0.6.0 across environments in the lock.

Add [target.linux-64.activation.env] prepending $CONDA_PREFIX/lib to
LD_LIBRARY_PATH so the env's libstdc++/libpython win over HPC-module system
libs; fixes an llvmlite GLIBCXX_3.4.30 crash in spawned multiprocessing
workers and libpython loading in standalone cargo-test binaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Svar2Haps groups read-bound queries by contig, reconstructs per group,
then permutes the grouped output back to global (b, P) order. For a
single contig group that permutation is the identity, so the O(total
bytes) numpy arange+repeat reorder (and the 1-element concatenate) is
pure waste — it dominated the call (~96%) on single-contig reads. Add a
single-group fast-path to _assemble_haps, _reconstruct_variants, and
realign_track_block. Byte-identical; drops haplotypes from 12-25x slower
than svar1 to parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
decode_variants_from_split now decodes straight from the gather's
BatchResultSplit via a streaming 3-way merge of the already-sorted
var_key / present-dense-snp / present-dense-indel runs (tie order
var_key<snp<indel = the previous stable sort) — no split_to_flat
marshaling copy and no per-hap merge_hap Vec+sort. Byte-identical (svar2
parity suite); variants ~3-5x -> ~2.6-3.7x vs svar1. Also documents the
falsified dense-decode pre-cache (regressed: dense-SNP decode is a
2-bit no-op, cheaper than a cached slice-copy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compose the validated svar2 variants decode with the existing
assemble_variant_buffers window kernel (identity gather), per contig
group. ref=window + alt in {window,allele}; ref=allele already blocked
upstream. Two-pronged parity: SVAR1 for ref_window, numpy oracle for alt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same offset fold (row_offsets[::P], eff_ploidy=1) serves both decode
modes; remove the unphased_union clause from _guard_unsupported. Add
union parity tests vs SVAR1 union + folded svar2 decode oracle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 tasks: windows core (decode+assemble compose, guards, dummy fill),
variants-mode union, windows union, docs. TDD steps with full code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compose decode_variants_from_svar2_readbound with assemble_variant_buffers
(identity gather) per contig group. ref_window byte-identical to SVAR1;
alt validated via ref-flank decomposition + tokenized variants.alt. Wire
__call__ (jitter guard shared with variants), pin ref=allele + jitter guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold row_offsets[::P], eff_ploidy=1 per contig group (order-preserving,
no dedup) — byte-identical to SVAR1 union for start/ilen. Drop the
unphased_union guard; haplotypes/annotated+union stays blocked upstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold row_offsets[::P] before the window assemble call; p_eff=1 drives
shape + stitch. ref_window stays SVAR1-identical under union.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update the genvarloader skill's .svar2 Phase-1 scope/gotchas and the
FAQ's svar/svar2 comparison to reflect that with_seqs("variant-windows")
and unphased_union (variants + variant-windows) are wired for .svar2;
note the read-bound perf spec's out-of-scope items are implemented
(fusion/perf work still deferred). write.md/format.md don't enumerate
svar2 mode support, so they're unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final-review polish (no behavior change): move `from typing import Any` to
the module top-level import; add a comment at _guard_unsupported explaining
why no unphased_union guard is needed (honored via ploidy-1 fold; haps/
annotated+union blocked upstream, spine ignores union like SVAR1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
d-laub and others added 18 commits July 9, 2026 00:47
Pairs with genoray SP-2 (query.rs module split). See genoray#92.

Bypassed the pyrefly pre-commit hook (--no-verify): it excludes the entire
worktree because .claude/worktrees/* is git-ignored, so it matches zero Python
files and exits 1 — a worktree-location artifact, not a diff defect. This diff
is Rust-only (src/ffi/mod.rs); rustfmt on that file is clean and cargo
build/test --release + the readbound Python parity test all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BREAKING CHANGE: requires genoray>=3; VCF/PGEN _chunk_ranges_with_length now
both yield (data, end, chunk_idxs).

Also repoints two private genoray._svar imports (_dense2sparse_with_length,
POS_TYPE) at their new genoray 3.0 submodule homes
(genoray._svar._convert, genoray._types), required for genvarloader to
import at all under genoray>=3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…es, no Reader, ContigNormalizer moved)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route arbitrary scalar-numeric SVAR2 INFO/FORMAT fields through gvl's
read-bound decode kernel into RaggedVariants and variant-windows, using
genoray's exported vk_src provenance + FieldView APIs (no genoray change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gvl is being built against genoray main, which carries the unreleased
INFO/FORMAT field-read API. genoray's version was never bumped past the
3.0.0 tag, so its wheel reports 2.15.0 and the >=3,<4 pin matched nothing
(the env was unsolvable). Drop the constraint and point pixi at a freshly
built abi3 manylinux wheel. Re-pin at genoray release.
genoray replaced the (usize, usize) tuple range fields on HapRanges and
BatchResultSplit with std::ops::Range<usize>, so gvl's Rust no longer
compiled against genoray main. Replace the four duplicated to_pairs
closures with one shared arr2_to_ranges helper, read the BatchResultSplit
dense ranges as Ranges, and update the test fixtures (BatchResultSplit now
also carries vk_src; it derives Default).

Pure type migration, no behavior change: 125/125 Rust tests pass and the
existing SVAR2 suite is green (42 passed) against genoray main.
The field-read path must rebuild genoray's ContigPaths to locate a field's
on-disk value sidecars, which needs the store base dir. Retain it and expose
an accessor. Additive plumbing; no behavior change.
…decode

decode_variants_from_split now optionally tracks, for each merged variant,
which source row it came from: var_key provenance is read from vk_src via
unpack_vk_src (yielding the sub-stream and call index), dense provenance is
derived arithmetically via dense_abs_row against the on-disk window. Each
requested field's bytes are then gathered from genoray's FieldView sidecars
into a byte buffer parallel to pos.

Dense FORMAT values stride by the original cohort sample index
(row * cohort_n_samples + orig_sample); var_key entries are already
per-(variant, sample) calls, so they index by call index directly.

The no-field path is guarded and does zero extra work (byte-identical).

New test pins provenance identity against a store whose value at element i
is i, so a decoded value reveals the attributed source row; the dense case
uses a non-trivial on-disk window (3..4) so it fails if the output index is
used verbatim instead of the absolute row.
…bugs

The provenance test was vacuous w.r.t. sub-stream mapping: all four
sub-streams held identical identity bytes, so views[sub_ix] was
unobservable and swapping DenseSnp for DenseIndel still passed. Encode
value = 100*sub_ix + row so a decoded value reveals BOTH the sub-stream and
the row, and exercise all four channels (VkSnp, VkIndel, DenseSnp,
DenseIndel) — VkIndel and DenseIndel previously had no coverage at all.

Add a test pinning the dense FORMAT stride (row*cohort_n_samples +
orig_sample) and asserting var_key FORMAT values are unstrided, which is
what pins the is_dense && is_format gate.

Verified these now fail under mutation: swapped dense sub_ix, var_key
sub_ix hardcoded to 0, dropped cohort stride, and an off-by-one merge
cursor all break the suite (each previously passed).

Also hoist the vk_src contract check out of the hot loop into one
unconditional assert (matching genoray's own contract pattern), document
the FieldGather invariants, and pre-reserve the field buffers.
… field buffers

The FFI gains a trailing fields=[(category, name, dtype_str)] parameter. When
non-empty it opens one FieldView per FieldSub (iterating FieldSub::all() so the
views land in the order FieldGather indexes them by), selects
gather_haps_readbound_src (the only gather that populates the var_key
provenance vk_src), and returns one u8 buffer per field plus the itemsizes for
Python to .view(dtype) with.

The on-disk dense windows from HapRanges are passed through so dense provenance
resolves to absolute rows, and orig_samples (the original cohort indices given
to HapRanges) carries the dense FORMAT stride.

When fields is empty the old gather is used and no field work runs. Existing
call sites are updated mechanically for the new arity; Python field wiring is
the next phase.
…ading

Svar2Haps reads the store's field manifest (SparseVar2.available_fields) into
store_fields and advertises the keys in available_var_fields, so users can
request them via var_fields. The manifest entries are kept as genoray
StoredField objects rather than flattened to a tuple, because the stored
default is needed for empty-group fills later.

Dataset.with_seqs(var_fields=) gains an early Svar2Haps branch: SVAR2 field
values are read on demand by the decode kernel, so there is no SVAR1 variants
table to lazily load INFO/dosage/custom-FORMAT columns from (Svar2Haps.variants
is a dummy placeholder). The shared 'missing' validation stays at the top — it
is what stops an unknown field name reaching the Rust FFI.

The SVAR1 body is byte-identical (re-indented only): git diff -w reports 13
insertions and 0 deletions. SVAR1 var_fields coverage: 71 passed.
…, multi-contig, union)

Gates the field-routing wiring. Fixture spans two contigs and carries INFO AF
(Float, with a missing value), INFO NS (Integer), and FORMAT DP (Integer), with
distinct values per variant and per sample so a wrong variant/value or sample
association is detectable rather than coincidentally correct. A self-assert via
find_ranges pins that the fixture really populates BOTH the var_key and dense
channels, so neither provenance path is silently untested.

Expected values are derived from the source VCF with cyvcf2 (an oracle, not a
snapshot of our own output).

Currently RED (5 failed, 1 passed): discovery and var_fields acceptance work;
only the field values are absent from the output. That is the Task 2.3/2.4 gap.
_reconstruct_variants now forwards the requested store fields to the decode
kernel and attaches the returned buffers to the output, viewing the raw
little-endian bytes as the store's dtype (no widening; missing entries keep the
stored default verbatim, guarded by an itemsize check against the manifest).

Field buffers are flat per-variant data parallel to pos, so they ride the
existing offsets machinery: the multi-contig path reuses the same src
permutation as pos/ilen, and unphased_union (which only folds offsets) needs no
handling at all.

Builtin names still win over a same-named store field. With no fields requested
the kernel keeps its zero-overhead path.

Oracle test: 5 passed (variant-windows still red — next task).
_reconstruct_variant_windows now forwards the requested store fields to the
decode kernel and adds the returned columns to _FlatVariantWindows.fields,
alongside start/ilen. Field buffers are flat per-variant data parallel to pos,
so they share the same row offsets (including under unphased_union) and reuse
the same src permutation in the multi-contig path.

The field-resolution block is extracted into a shared _requested_store_fields()
helper used by both reconstructors rather than duplicated.

Empty-group dummy fill needed no new code: fill_empty_groups already iterates
the fields dict and DummyVariant.scalar_for already falls back to NaN for float
columns and 0 for integer columns. Verified against the oracle test rather than
assumed.

Oracle test now 6/6 green.
…ndows fields

Two real gaps found by review:

Dataset.open(var_fields=...) was a silent no-op for SVAR2: var_fields is a
public open() parameter forwarded to Haps.from_path but not to
Svar2Haps.from_path, so a store field would be advertised in
available_var_fields and then be absent from the output with no error.
Svar2Haps.from_path now accepts and honors var_fields.

The multi-contig variant-windows field path was entirely untested — the windows
test used a single-contig bed, so zeroing the gathered field data in the
multi-group branch passed the whole suite. The test's bed is now interleaved
across contigs (keeping the variant-free region for the dummy-fill assertions),
and that mutation now fails.

Also fold in review cleanups: extract the duplicated per-group typing block into
a shared helper, and raise ValueError (not a hand-raised AssertionError) when the
kernel's itemsize disagrees with the store manifest.

Field tests 7/7; no-regression 25/25.
Extends the existing .svar2 sections of the genvarloader skill (Phase-1 scope,
the var_fields entry, and the gotchas matrix) and the faq's .svar2 support
sentence.

No CHANGELOG entry: this repo has no root changelog, and docs/source/changelog.md
is auto-generated from commit messages by commitizen (and per CLAUDE.md does not
count as documentation). The plan's changelog step assumed genoray's convention.
…nerate oracle

Three findings from the final whole-branch review.

The oracle fixture was degenerate: every provenance index was zero (each
contig's var_key stream had one call, chr1's dense stream one variant), so
hardcoding both call_idx and the dense row to 0 still passed the whole suite.
The fixture now carries multiple var_key calls, multiple dense variants with a
region whose window starts past the first (exercising the on-disk offset), and
an indel — the VkIndel/DenseIndel sub-streams were previously never routed to
end-to-end. That mutation now fails 5/9.

Dataset.open(var_fields=[...]) silently dropped unknown names for SVAR2: a typo
was reported as active and simply absent from the output. It now raises, like
with_settings() and the SVAR1 path already did.

_output_bytes_per_instance raised KeyError for any SVAR2 store field (it read
the dummy variants table's empty info dict), crashing the public buffered
dataloader path. It now takes the dtype from the store manifest; the SVAR1 path
is unchanged.

Field tests 9/9; no-regression 25/25; SVAR1 var_fields 10/10.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant