feat: SVAR2 read-bound dataset wiring (draft — blocked on genoray svar-2 release)#266
Draft
d-laub wants to merge 87 commits into
Draft
feat: SVAR2 read-bound dataset wiring (draft — blocked on genoray svar-2 release)#266d-laub wants to merge 87 commits into
d-laub wants to merge 87 commits into
Conversation
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>
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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SVAR2 read-bound dataset wiring
Wires genoray's
.svar2sparse variant format intogvl.writeand thegvl.Datasetread path..svar2is now a supportedwritevariant source (a write-time ranges cache) and a liveDatasetread 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 thevariant-windowssequence mode andunphased_unionploidy folding (see "Latest addition" below).What's included
The full
svar2-m6b-kernelfeature branch (69 commits ahead ofmain): the earlier M6b two-source union-path kernels, the read-bound wiring, the read-boundgetitemperf optimizations (B1–B5), and — most recently — thevariant-windows+unphased_unionread path. Core pieces:Svar2Link/Svar2Fingerprint+Metadata.svar2_link;_write_from_svar2streams a 6-array ranges cache undergenotypes/svar2_ranges/(sized to the dataset's selected samples);.svar2write dispatch.genoray_corecrate +Svar2Storepyclass (oneContigReaderper contig, opened once). Read-bound kernels reuse the already-validated union kernels by marshallingBatchResultSplitinto their flat layout viasvar2::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.Svar2Hapsreconstructor (SVAR1Hapsbyte-unchanged) for haplotypes + variants; an additiveHapsTracksbranch for realigned tracks;Dataset.open(svar2=<override>)mirroringsvar=. The live union (overlap_batch) dispatch is retired to a test-only parity oracle.getitemperf — 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:63b8b87) — cProfile showed ~96% of the svar2 haplotype call was a numpyarange+repeatragged-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×.b79e83a,3ff8942) — replacedgather_haps_readbound's bit-by-bit per-hap presence loop with a per-query threshold + byte-batchedcopy_bits(self-time 8.85% → 3.31%).b84a57d) —decode_variants_from_splitdecodes straight from the gather via a streaming 3-way merge, dropping thesplit_to_flatmarshaling copy (variants germline 3.0–3.2× → 2.6×, somatic → 3.66×).gather_haps_readboundasm ≈−8.5%variants_germline; B1 −6.3% haplotypes). All byte-identical; svar2 parity suite held green throughout. Consolidated intmp/svar2_mvp/prof_out/bench_gvl/OPTIMIZATION.md+readbound/RESULTS.md.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(...))(previouslyNotImplementedError) now works, andunphased_unionis enabled for both svar2 decode modes (variantsandvariant-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"withalt ∈ {window, allele}.ref_windowis byte-identical to the SVAR1 backend;alt/alt_windowdiffer only for pure-deletion records (the empty-vs-anchor ALT convention below), validated instead via svar2-internal decomposition + tokenizedvariants.alt.unphased_unionfolds ploidy 2→1 (order-preserving, no dedup) — the samerow_offsets[::P],eff_ploidy=1fold SVAR1 uses;start/ilenbyte-identical to the SVAR1 union. When the flag is off, both reconstructors reduce byte-for-byte to the pre-existing diploid path.ref="allele"(needs stored REF bytes.svar2lacks →ValueError), andmax_jitter>0/jitter>0(read-bound decode has no right-clip →NotImplementedError).skills/genvarloader/SKILL.md,docs/source/{faq,format}.md); perf/fusion for this mode is deferred (see follow-ups).Parity & verification
.svar/union oracle (SparseVar2Source, genoraydecode). SVAR1 path is additive (byte-unchanged). 49 svar2-selected tests across the tree (21 intests/dataset/test_svar2_dataset.py, incl. 10variant-windows/unphased_unioncases); full tree 1040 passed / 0 failed / 0 errors.variants/variant-windowsALT is emptyb""for.svar2vs the VCF anchor base for.svar(genoray atomized convention; haplotypes andref_windowunaffected); (2) SVAR1'smax_endssame-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.variant-windows(ref="window",alt ∈ {window, allele}) andunphased_union(forvariantsandvariant-windows).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-contigFlankSampleall raiseNotImplementedError/ValueError(never silently mis-compute).Benchmark (chr21, matched
.svar/.svar2, warmDataset[:, :]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 viavariants=<p>.svar, svar2 viavariants=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 intmp/svar2_mvp/prof_out/bench_gvl/OPTIMIZATION.md..svar11.08–1.11× faster → PARITY.svar11.15–1.21× faster → PARITY.svar12.6× faster.svar13.66× faster.svar25.7× smaller (193 MiB vs 1.07 GiB).svar21.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.)
perfof the warm.svar2read loop shows zeroSearchTree::build,dense_union, oroverlap_batchself-time — the whole-cohort union/oracle path is absent; the read-bound path rebuilds no per-read interval tree or dense union.Bottom line:
.svar2now matches.svaron 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 thevariant-windows/unphased_unionaddition, which adds a new mode and does not alter thevariants/haplotypesread-path perf measured here.)Follow-ups (out of scope; guarded)
decode_key(2.33%),merge_keys(2.31%),SearchTree::build(1.54%).variant-windows+unphased_union(the correctness path landed; the fused-decode perf pass is deferred).max_jitter>0/jitter>0(to lift that guard);ref="allele"for variant-windows (needs stored REF bytes).FlankSampleguard..pyistubs for the svar2 FFI (pre-existing bigwig-only gap).🤖 Generated with Claude Code