"""Flat-buffer analog of RaggedVariants: pure-numpy (data, offsets) per field, all-numpy hot path.
Converts to RaggedVariants only via to_ragged().
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
import numpy as np
import seqpro as sp
from numpy.typing import NDArray
from ..genvarloader import compact_keep_f32 as _compact_keep_f32_rust
from ..genvarloader import compact_keep_i32 as _compact_keep_i32_rust
from ..genvarloader import fill_empty_fixed_f32 as _fill_empty_fixed_f32_rust
from ..genvarloader import fill_empty_fixed_i32 as _fill_empty_fixed_i32_rust
from ..genvarloader import fill_empty_scalar_f32 as _fill_empty_scalar_f32_rust
from ..genvarloader import fill_empty_scalar_i32 as _fill_empty_scalar_i32_rust
from ..genvarloader import (
assemble_variant_buffers_i32 as _assemble_variant_buffers_i32_rust,
)
from ..genvarloader import (
assemble_variant_buffers_u8 as _assemble_variant_buffers_u8_rust,
)
from ..genvarloader import fill_empty_seq_i32 as _fill_empty_seq_i32_rust
from ..genvarloader import fill_empty_seq_u8 as _fill_empty_seq_u8_rust
from ..genvarloader import gather_alleles as _gather_alleles_rust
from ..genvarloader import gather_rows_f32 as _gather_rows_f32_rust
from ..genvarloader import gather_rows_i32 as _gather_rows_i32_rust
from ..genvarloader import rc_alleles as _rc_alleles_rust_kernel
from ._genotypes import _as_starts_stops
if TYPE_CHECKING:
from ._haps import Haps
[docs]
@dataclass(frozen=True)
class DummyVariant:
"""Per-field values for the dummy variant inserted into empty (region, sample, ploid) groups.
Unspecified info fields default to ``0`` for integer columns and ``NaN``
for float columns.
"""
start: int = -1
ilen: int = 0
dosage: float = 0.0
ref: bytes = b"N"
alt: bytes = b"N"
info: dict[str, Any] = field(default_factory=dict)
[docs]
def scalar_for(self, name: str, dtype: np.dtype):
"""Return the dummy fill value for a scalar field, as a numpy scalar of ``dtype``."""
dt = np.dtype(dtype)
if name == "start":
return dt.type(self.start)
if name == "ilen":
return dt.type(self.ilen)
if name == "dosage":
return dt.type(self.dosage)
if name in self.info:
return dt.type(self.info[name])
if np.issubdtype(dt, np.floating):
return dt.type(np.nan)
return dt.type(0)
@dataclass(slots=True)
class _FlatAlleles:
"""Two-level flat bytestring for an alt/ref allele field, shape (b, p, ~v, ~l).
Layout matches _build_allele_layout (inner-before-outer):
- byte_data: uint8 contiguous allele bytes
- seq_offsets: per-variant byte boundaries (allele_offsets), len n_variants + 1
- var_offsets: per-(b*p)-row variant boundaries (group_offsets), len b*p + 1
- shape: outer fixed dims with exactly one None (the ragged variant axis)
"""
byte_data: NDArray[np.uint8]
seq_offsets: NDArray[np.int64]
var_offsets: NDArray[np.int64]
shape: tuple[int | None, ...]
@property
def ploidy(self) -> int:
# shape is (b, p, None) for variants; ploidy is the last fixed dim.
# For a flat (2, None) shape (b*p flattened), ploidy defaults to 1.
fixed = [d for d in self.shape if d is not None]
return fixed[-1] if len(fixed) >= 2 else 1
def to_ragged(self):
from seqpro.rag import Ragged
# Build an opaque-string Ragged from flat buffers:
# byte_data — S1 char bytes
# seq_offsets — per-variant char boundaries (inner, len=n_variants+1)
# var_offsets — per-(b*p)-group variant boundaries (outer, len=b*p+1)
# Two ragged axes: (b*p, ~variants, ~chars) → collapse chars via
# to_strings() → (b*p, ~variants) opaque string → reshape to (b, p, ~v).
char_data = np.ascontiguousarray(self.byte_data).view(dtype="S1")
var_off = np.asarray(self.var_offsets, dtype=np.int64)
seq_off = np.asarray(self.seq_offsets, dtype=np.int64)
b_times_p = len(var_off) - 1
# Extract fixed dims: shape is (b, p, None) or (b*p, None).
fixed = [d for d in self.shape if d is not None]
if len(fixed) >= 2:
# Re-derive b from b*p and ploidy (last fixed dim).
p = fixed[-1]
b = b_times_p // p
else:
b = b_times_p
p = 1
return (
Ragged.from_offsets(char_data, (b_times_p, None, None), [var_off, seq_off])
.to_strings()
.reshape(b, p, None)
)
def reverse_masked(self, mask: NDArray[np.bool_]) -> "_FlatAlleles":
"""DNA reverse-complement the mask-selected rows' alleles, in place.
``mask`` is one entry per region (length ``b``); broadcast across ploidy
to a per-(b*p) row mask, then expanded per-allele inside the dispatched
``rc_alleles`` kernel (rust default, seqpro reference).
"""
m = np.ascontiguousarray(mask, np.bool_).reshape(-1)
per_bp = np.repeat(m, self.ploidy) # per-(b*p) row mask
_rc_alleles_rust(
self.byte_data,
np.asarray(self.seq_offsets, np.int64),
np.asarray(self.var_offsets, np.int64),
per_bp,
)
return self
def reshape(self, shape: int | tuple[int, ...]) -> "_FlatAlleles":
# Mirror _Flat.reshape: accept outer dims and APPEND our own ragged None.
if isinstance(shape, int):
shape = (shape,)
shape = tuple(shape)
if shape and shape[-1] is None: # be defensive: strip a trailing None
shape = shape[:-1]
new = shape + (None,)
return _FlatAlleles(self.byte_data, self.seq_offsets, self.var_offsets, new)
def squeeze(self, axis: int | None = None) -> "_FlatAlleles":
fixed = [d for d in self.shape if d is not None]
if axis is None:
fixed = [d for d in fixed if d != 1]
else:
del fixed[axis]
return _FlatAlleles(
self.byte_data, self.seq_offsets, self.var_offsets, (*fixed, None)
)
def __getitem__(self, key) -> "_FlatAlleles":
"""Slice the leading (instance) axis, rebasing both offset levels."""
if not isinstance(key, slice):
raise TypeError(
f"_FlatAlleles supports only instance-axis slicing, got {key!r}"
)
n_inst = self.shape[0]
if n_inst is None:
raise ValueError(
"_FlatAlleles.__getitem__: leading axis is the ragged axis"
)
start, stop, step = key.indices(n_inst)
if step != 1:
raise ValueError("_FlatAlleles slicing supports step=1 only")
rows_per_inst = (len(self.var_offsets) - 1) // n_inst if n_inst else 0
r0, r1 = start * rows_per_inst, stop * rows_per_inst
v0, v1 = int(self.var_offsets[r0]), int(self.var_offsets[r1])
new_var = np.ascontiguousarray(
self.var_offsets[r0 : r1 + 1] - self.var_offsets[r0]
)
new_seq = np.ascontiguousarray(
self.seq_offsets[v0 : v1 + 1] - self.seq_offsets[v0]
)
new_bytes = self.byte_data[
int(self.seq_offsets[v0]) : int(self.seq_offsets[v1])
]
new_shape = (stop - start,) + self.shape[1:]
return _FlatAlleles(new_bytes, new_seq, new_var, new_shape)
@dataclass(slots=True)
class _FlatWindow:
"""Two-level flat token buffer for ref/alt windows, shape (b, p, ~v, ~win).
Mirrors _FlatAlleles but `data` holds tokens (configured int dtype), not bytes,
so to_ragged() drops the byte/bytestring string parameters. Both inner axes
(variant count and window length) are ragged, so to_ragged() returns a numeric
two-ragged-axis _core.Ragged with shape (b, p, ~v, ~w).
"""
data: NDArray # tokens (uint8 or int32), flat
seq_offsets: NDArray[np.int64] # per-variant window offsets, n_variants + 1
var_offsets: NDArray[np.int64] # per (instance, ploid) offsets, b*p + 1
shape: tuple[int | None, ...]
def to_ragged(self):
from seqpro.rag import Ragged
# Build a numeric Ragged with shape (b, p, ~v, ~w): two ragged axes.
# var_offsets: per-(b*p)-group variant boundaries (len b*p + 1)
# seq_offsets: per-variant window token boundaries (len n_variants + 1)
fixed = [d for d in self.shape if d is not None]
if len(fixed) >= 2:
p = fixed[-1]
b = (len(self.var_offsets) - 1) // p
else:
b = len(self.var_offsets) - 1
p = 1
data = np.ascontiguousarray(self.data)
return Ragged.from_offsets(
data,
(b, p, None, None),
[
np.asarray(self.var_offsets, np.int64),
np.asarray(self.seq_offsets, np.int64),
],
)
def reshape(self, shape) -> "_FlatWindow":
if isinstance(shape, int):
shape = (shape,)
shape = tuple(shape)
# strip any trailing None defensively, then append our two ragged axes
while shape and shape[-1] is None:
shape = shape[:-1]
return _FlatWindow(
self.data, self.seq_offsets, self.var_offsets, (*shape, None, None)
)
def squeeze(self, axis: int | None = None) -> "_FlatWindow":
fixed = [d for d in self.shape if d is not None]
if axis is None:
fixed = [d for d in fixed if d != 1]
else:
del fixed[axis]
return _FlatWindow(
self.data, self.seq_offsets, self.var_offsets, (*fixed, None, None)
)
def __getitem__(self, key) -> "_FlatWindow":
"""Slice the leading (instance) axis, rebasing both offset levels."""
if not isinstance(key, slice):
raise TypeError(
f"_FlatWindow supports only instance-axis slicing, got {key!r}"
)
n_inst = self.shape[0]
if n_inst is None:
raise ValueError("_FlatWindow.__getitem__: leading axis is the ragged axis")
start, stop, step = key.indices(n_inst)
if step != 1:
raise ValueError("_FlatWindow slicing supports step=1 only")
rows_per_inst = (len(self.var_offsets) - 1) // n_inst if n_inst else 0
r0, r1 = start * rows_per_inst, stop * rows_per_inst
v0, v1 = int(self.var_offsets[r0]), int(self.var_offsets[r1])
new_var = np.ascontiguousarray(
self.var_offsets[r0 : r1 + 1] - self.var_offsets[r0]
)
new_seq = np.ascontiguousarray(
self.seq_offsets[v0 : v1 + 1] - self.seq_offsets[v0]
)
new_data = self.data[int(self.seq_offsets[v0]) : int(self.seq_offsets[v1])]
new_shape = (stop - start,) + self.shape[1:]
return _FlatWindow(new_data, new_seq, new_var, new_shape)
def _normalize_token_alphabet(alphabet: "str | bytes | sp.NucleotideAlphabet") -> bytes:
"""Normalize a token alphabet to the raw ``bytes`` downstream consumers expect.
Accepts a ``str``, ``bytes``, or ``seqpro.NucleotideAlphabet`` (e.g.
``seqpro.alphabets.DNA``). Each byte's position is its token id, so ordering is
preserved verbatim. Shared by ``VarWindowOpt.__post_init__`` and
``build_token_lut`` so both the ``VarWindowOpt`` and ``with_settings`` paths
normalize identically (see #291, #292).
"""
if isinstance(alphabet, sp.NucleotideAlphabet):
return alphabet.alphabet.encode("ascii")
if isinstance(alphabet, str):
return alphabet.encode("ascii")
return alphabet
[docs]
@dataclass(frozen=True)
class VarWindowOpt:
"""Options for ``with_seqs('variant-windows')``.
Bundles every variant-window setting in one place so they are explicit
rather than inherited from ``with_settings``. ``ref`` and ``alt`` are chosen
independently: ``"window"`` emits the flanked, tokenized window (ref =
``[start-L, end+L)`` reference read; alt = ``flank5 . alt . flank3``), while
``"allele"`` emits the bare tokenized allele with no flanks.
``token_alphabet`` accepts a ``str``, ``bytes``, or ``seqpro.NucleotideAlphabet``
(e.g. ``seqpro.alphabets.DNA``) and is normalized to ``bytes`` on construction;
each byte's position is its token id, so ordering is preserved verbatim.
"""
flank_length: int
token_alphabet: str | bytes | sp.NucleotideAlphabet
unknown_token: int
ref: Literal["window", "allele"] = "window"
alt: Literal["window", "allele"] = "window"
def __post_init__(self) -> None:
# Normalize to the raw byte alphabet so downstream consumers (e.g.
# ``build_token_lut``) only ever see ``bytes``.
object.__setattr__(
self, "token_alphabet", _normalize_token_alphabet(self.token_alphabet)
)
_WINDOW_FIELD_NAMES = ("ref_window", "alt_window", "ref", "alt")
@dataclass(slots=True)
class _FlatVariantWindows:
"""Window-mode variants output: scalar fields + per-allele token buffers.
Each allele is emitted either as a flanked window (``ref_window`` /
``alt_window``) or a bare tokenized allele (``ref`` / ``alt``); the unused
slot of each pair is ``None``. Raw (byte) alleles are intentionally absent.
Returned directly in flat output mode (the query boundary never converts it).
Reverse-complement is intentionally NOT supported (reference-oriented).
"""
fields: dict[str, Any] # start / ilen / dosage / info -> _Flat
ref_window: _FlatWindow | None = None
alt_window: _FlatWindow | None = None
ref: _FlatWindow | None = None # bare tokenized ref allele (no flanks)
alt: _FlatWindow | None = None # bare tokenized alt allele (no flanks)
@property
def shape(self) -> tuple[int | None, ...]:
return self.fields["start"].shape
def _present(self) -> dict[str, "_FlatWindow"]:
return {
n: getattr(self, n)
for n in _WINDOW_FIELD_NAMES
if getattr(self, n) is not None
}
def to_ragged(self):
out = {k: v.to_ragged() for k, v in self.fields.items()}
for n, w in self._present().items():
out[n] = w.to_ragged()
return out
def reshape(self, shape) -> "_FlatVariantWindows":
present = {n: w.reshape(shape) for n, w in self._present().items()}
return _FlatVariantWindows(
{k: v.reshape(shape) for k, v in self.fields.items()}, **present
)
def squeeze(self, axis: int | None = None) -> "_FlatVariantWindows":
present = {n: w.squeeze(axis) for n, w in self._present().items()}
return _FlatVariantWindows(
{k: v.squeeze(axis) for k, v in self.fields.items()}, **present
)
def fill_empty_groups(
self, dummy: "DummyVariant", unk: int, flank_length: int
) -> "_FlatVariantWindows":
"""Insert one all-``unk`` dummy entry into each empty (b*p) group.
Scalar fields take ``DummyVariant`` values; window fields take ``unk``.
Window length: ``2*flank_length + len(dummy allele)`` for ref/alt
windows, ``len(dummy allele)`` for bare ref/alt alleles.
"""
from .._flat import _Flat
new_fields: dict[str, Any] = {}
for name, f in self.fields.items():
fill = dummy.scalar_for(name, f.data.dtype)
nd, noff = _fill_empty_scalar(f.data, f.offsets, fill)
new_fields[name] = _Flat.from_offsets(nd, f.shape, noff)
present: dict[str, _FlatWindow] = {}
for name, w in self._present().items():
allele = dummy.alt if name in ("alt", "alt_window") else dummy.ref
base = len(allele)
win_len = (2 * flank_length + base) if name.endswith("_window") else base
dwin = np.full(win_len, unk, dtype=w.data.dtype)
nd, nvar, nseq = _fill_empty_seq(w.data, w.var_offsets, w.seq_offsets, dwin)
present[name] = _FlatWindow(nd, nseq, nvar, w.shape)
return _FlatVariantWindows(new_fields, **present)
def __getitem__(self, key) -> "_FlatVariantWindows":
"""Slice the leading (instance) axis of every scalar field and window slot."""
present = {n: w[key] for n, w in self._present().items()}
return _FlatVariantWindows(
{k: v[key] for k, v in self.fields.items()}, **present
)
@dataclass(slots=True)
class _FlatVariants:
"""Flat analog of RaggedVariants.
`fields` maps field name -> _Flat (scalar fields: start/ilen/dosage/info)
or _FlatAlleles (alt/ref).
"""
fields: dict[str, Any] = field(default_factory=dict)
flank_tokens: Any = (
None # _Flat | None — ride-along, shape (b, p, ~v, 2L); flat-mode only
)
@property
def shape(self) -> tuple[int | None, ...]:
return self.fields["start"].shape
def to_ragged(self):
from ._rag_variants import RaggedVariants
kw = {}
for name, f in self.fields.items():
kw[name] = f.to_ragged()
return RaggedVariants(**kw)
def reshape(self, shape) -> "_FlatVariants":
new = _FlatVariants({k: v.reshape(shape) for k, v in self.fields.items()})
if self.flank_tokens is not None:
from .._flat import _Flat
ft = self.flank_tokens
inner = ft.shape[-1] # 2L, fixed
# Normalize like _Flat.reshape (accept int or any sequence of dims).
outer = (shape,) if isinstance(shape, int) else tuple(shape)
new.flank_tokens = _Flat(ft.data, ft.offsets, (*outer, None, inner))
return new
def squeeze(self, axis: int | None = None) -> "_FlatVariants":
new = _FlatVariants({k: v.squeeze(axis) for k, v in self.fields.items()})
if self.flank_tokens is not None:
from .._flat import _Flat
ft = self.flank_tokens
inner = ft.shape[-1]
outer = [d for d in ft.shape[:-1] if d is not None]
if axis is None:
outer = [d for d in outer if d != 1]
else:
del outer[axis]
new.flank_tokens = _Flat(ft.data, ft.offsets, (*outer, None, inner))
return new
def __getitem__(self, key) -> "_FlatVariants":
"""Slice the leading (instance) axis, carrying ``flank_tokens`` along.
``flank_tokens`` has shape ``(b, ploidy, ~v, 2L)``: its offsets (length
``b*ploidy + 1``) bound variants per ``(instance, ploid)`` row, and each
variant contributes a fixed ``2L``-token run, so it slices with the same
two-level rebasing as ``_FlatAlleles``/``_FlatWindow`` rather than the
generic instance-axis-leading ``_Flat.__getitem__``.
"""
out = _FlatVariants({k: v[key] for k, v in self.fields.items()})
if self.flank_tokens is not None:
from .._flat import _Flat
if not isinstance(key, slice):
raise TypeError(
f"_FlatVariants supports only instance-axis slicing, got {key!r}"
)
ft = self.flank_tokens
n_inst = ft.shape[0]
if n_inst is None:
raise ValueError(
"_FlatVariants.__getitem__: flank_tokens leading axis is the ragged axis"
)
start, stop, step = key.indices(n_inst)
if step != 1:
raise ValueError("_FlatVariants slicing supports step=1 only")
inner = ft.shape[-1] # 2L, fixed per-variant token run
rows_per_inst = (len(ft.offsets) - 1) // n_inst if n_inst else 0
r0, r1 = start * rows_per_inst, stop * rows_per_inst
v0, v1 = int(ft.offsets[r0]), int(ft.offsets[r1])
new_off = np.ascontiguousarray(ft.offsets[r0 : r1 + 1] - ft.offsets[r0])
new_data = ft.data[v0 * inner : v1 * inner]
new_shape = (stop - start,) + ft.shape[1:]
out.flank_tokens = _Flat(new_data, new_off, new_shape)
return out
def reverse_masked(self, mask: NDArray[np.bool_]) -> "_FlatVariants":
# Only alt/ref alleles are reverse-complemented; scalar fields unchanged
# (matches RaggedVariants.rc_ which only touches alt/ref).
for name in ("alt", "ref"):
if name in self.fields:
self.fields[name] = self.fields[name].reverse_masked(mask)
return self
def fill_empty_groups(
self, dummy: "DummyVariant", unk: int | None = None
) -> "_FlatVariants":
"""Insert one dummy variant into each empty (b*p) group; non-empty groups are unchanged.
Every field shares the same empty-row pattern, so
the rebuilt offsets stay consistent across fields. When ``flank_tokens``
is present, its empty rows are filled with ``2L`` ``unk`` tokens.
"""
from .._flat import _Flat
new_fields: dict[str, Any] = {}
for name, f in self.fields.items():
if isinstance(f, _FlatAlleles):
db = np.frombuffer(
dummy.alt if name == "alt" else dummy.ref, np.uint8
).copy()
nd, nvar, nseq = _fill_empty_seq(
f.byte_data, f.var_offsets, f.seq_offsets, db
)
new_fields[name] = _FlatAlleles(nd, nseq, nvar, f.shape)
else:
fill = dummy.scalar_for(name, f.data.dtype)
nd, noff = _fill_empty_scalar(f.data, f.offsets, fill)
new_fields[name] = _Flat.from_offsets(nd, f.shape, noff)
out = _FlatVariants(new_fields)
if self.flank_tokens is not None:
# flank_tokens is only set on the token-enabled ride-along path, where
# unknown_token (-> unk) is always provided; so unk is non-None here.
ft = self.flank_tokens
inner = ft.shape[-1] # 2L, fixed
nd, noff = _fill_empty_fixed(ft.data, ft.offsets, inner, unk)
out.flank_tokens = _Flat(nd, noff, ft.shape)
return out
def _gather_alleles(v_idxs, allele_bytes, allele_offsets):
return _gather_alleles_rust(
np.ascontiguousarray(v_idxs, np.int32),
np.ascontiguousarray(allele_bytes, np.uint8),
np.ascontiguousarray(allele_offsets, np.int64),
)
def _gather_rows_numpy(geno_offset_idx, off2d, data):
"""Dtype-preserving row gather for arbitrary dtypes (numpy fallback)."""
geno_starts = off2d[0]
geno_stops = off2d[1]
n_rows = geno_offset_idx.shape[0]
out_offsets = np.empty(n_rows + 1, np.int64)
out_offsets[0] = 0
for i in range(n_rows):
goi = int(geno_offset_idx[i])
out_offsets[i + 1] = out_offsets[i] + (geno_stops[goi] - geno_starts[goi])
total = int(out_offsets[n_rows])
out_data = np.empty(total, data.dtype)
dst = 0
for i in range(n_rows):
goi = int(geno_offset_idx[i])
s = int(geno_starts[goi])
e = int(geno_stops[goi])
out_data[dst : dst + (e - s)] = data[s:e]
dst += e - s
return out_data, out_offsets
def _compact_keep_numpy(v_idxs, row_offsets, keep):
"""Dtype-preserving compact-keep for arbitrary dtypes (numpy fallback)."""
n_rows = row_offsets.shape[0] - 1
new_offsets = np.empty(n_rows + 1, np.int64)
new_offsets[0] = 0
for i in range(n_rows):
cnt = int(np.count_nonzero(keep[row_offsets[i] : row_offsets[i + 1]]))
new_offsets[i + 1] = new_offsets[i] + cnt
n_keep = int(new_offsets[n_rows])
new_v = np.empty(n_keep, v_idxs.dtype)
new_v[:] = v_idxs[keep]
return new_v, new_offsets
def _compact_keep(v_idxs, row_offsets, keep):
"""Dispatch compact-keep by dtype, preserving the input dtype without down-cast.
Routes int32 → compact_keep_i32 (Rust), float32 → compact_keep_f32 (Rust).
All other dtypes (e.g. int16, int64 custom FORMAT fields, issue #231) fall
back to the dtype-preserving numpy kernel so values are never silently
coerced.
"""
values = np.ascontiguousarray(v_idxs)
row_offsets = np.ascontiguousarray(row_offsets, np.int64)
keep = np.ascontiguousarray(keep, np.bool_)
if values.dtype == np.int32:
return _compact_keep_i32_rust(values, row_offsets, keep)
if values.dtype == np.float32:
return _compact_keep_f32_rust(values, row_offsets, keep)
# Arbitrary dtypes (custom FORMAT fields, e.g. int16, int64): dtype-preserving
# numpy fallback — never down-cast.
return _compact_keep_numpy(values, row_offsets, keep)
def _gather_rows(
geno_offset_idx: NDArray[np.intp],
offsets: NDArray[np.int64],
data: NDArray,
) -> tuple[NDArray, NDArray[np.int64]]:
"""Dispatch per-row gather (numba/rust), preserving data dtype.
Routes int32 and float32 to typed Rust cores; all other dtypes fall back to
the dtype-preserving numpy kernel so values are never silently down-cast
(e.g. custom per-call FORMAT fields, issue #231).
"""
goi = np.ascontiguousarray(geno_offset_idx, np.int64)
off2d = _as_starts_stops(offsets)
data = np.ascontiguousarray(data)
if data.dtype == np.int32:
return _gather_rows_i32_rust(goi, off2d, data)
if data.dtype == np.float32:
return _gather_rows_f32_rust(goi, off2d, data)
# Arbitrary custom-FORMAT-field dtypes (#231): no typed Rust core — use the
# dtype-preserving numpy kernel directly so values are never down-cast.
return _gather_rows_numpy(goi, off2d, data)
def _fill_empty_scalar_numpy(data, offsets, fill):
"""Dtype-preserving fill-empty-scalar for arbitrary dtypes (numpy fallback)."""
n_rows = offsets.shape[0] - 1
lengths = np.diff(offsets)
new_lengths = np.where(lengths > 0, lengths, 1)
new_offsets = np.empty(n_rows + 1, np.int64)
new_offsets[0] = 0
new_offsets[1:] = np.cumsum(new_lengths)
new_data = np.empty(new_offsets[n_rows], data.dtype)
for i in range(n_rows):
s, e = int(offsets[i]), int(offsets[i + 1])
d = int(new_offsets[i])
if e == s:
new_data[d] = fill
else:
new_data[d : d + (e - s)] = data[s:e]
return new_data, new_offsets
def _fill_empty_scalar(data, offsets, fill):
"""Dtype-preserving dispatch for fill-empty-scalar.
Routes int32 and float32 to typed Rust cores; all other dtypes (e.g.
custom FORMAT fields, issue #231) fall back to the dtype-preserving numpy
kernel so values are never silently down-cast.
"""
data = np.ascontiguousarray(data)
offsets = np.ascontiguousarray(offsets, np.int64)
if data.dtype == np.int32:
return _fill_empty_scalar_i32_rust(data, offsets, int(fill))
if data.dtype == np.float32:
return _fill_empty_scalar_f32_rust(data, offsets, float(fill))
# Arbitrary dtype (custom FORMAT fields): preserve dtype via numpy fallback.
return _fill_empty_scalar_numpy(data, offsets, fill)
def _fill_empty_seq_numpy(data, var_offsets, seq_offsets, dummy):
"""Dtype-preserving fill-empty-seq for arbitrary dtypes (numpy fallback)."""
n_rows = var_offsets.shape[0] - 1
L = dummy.shape[0]
nv_lengths = np.diff(var_offsets)
new_var_lengths = np.where(nv_lengths > 0, nv_lengths, 1)
new_var = np.empty(n_rows + 1, np.int64)
new_var[0] = 0
new_var[1:] = np.cumsum(new_var_lengths)
total_vars = int(new_var[n_rows])
new_seq = np.empty(total_vars + 1, np.int64)
new_seq[0] = 0
vptr = 0
for i in range(n_rows):
vs, ve = int(var_offsets[i]), int(var_offsets[i + 1])
if ve == vs:
new_seq[vptr + 1] = new_seq[vptr] + L
vptr += 1
else:
for v in range(vs, ve):
vlen = int(seq_offsets[v + 1]) - int(seq_offsets[v])
new_seq[vptr + 1] = new_seq[vptr] + vlen
vptr += 1
total_bytes = int(new_seq[total_vars])
new_data = np.empty(total_bytes, data.dtype)
vptr = 0
dptr = 0
for i in range(n_rows):
vs, ve = int(var_offsets[i]), int(var_offsets[i + 1])
if ve == vs:
new_data[dptr : dptr + L] = dummy
dptr += L
vptr += 1
else:
for v in range(vs, ve):
bs, be = int(seq_offsets[v]), int(seq_offsets[v + 1])
new_data[dptr : dptr + (be - bs)] = data[bs:be]
dptr += be - bs
vptr += 1
return new_data, new_var, new_seq
def _fill_empty_seq(data, var_offsets, seq_offsets, dummy):
"""Dtype-preserving dispatch for fill-empty-seq (two-level dummy-fill).
Routes uint8 (allele bytes) and int32 (token windows) to typed Rust cores.
All other dtypes fall back to the dtype-preserving numpy kernel so values
are never silently down-cast.
"""
data = np.ascontiguousarray(data)
var_offsets = np.ascontiguousarray(var_offsets, np.int64)
seq_offsets = np.ascontiguousarray(seq_offsets, np.int64)
dummy = np.ascontiguousarray(dummy, data.dtype)
if data.dtype == np.uint8:
return _fill_empty_seq_u8_rust(data, var_offsets, seq_offsets, dummy)
if data.dtype == np.int32:
return _fill_empty_seq_i32_rust(data, var_offsets, seq_offsets, dummy)
# Arbitrary dtype: preserve via numpy fallback.
return _fill_empty_seq_numpy(data, var_offsets, seq_offsets, dummy)
def _fill_empty_fixed_numpy(data, offsets, inner, fill):
"""Dtype-preserving fill-empty-fixed for arbitrary dtypes (numpy fallback)."""
n_rows = offsets.shape[0] - 1
lengths = np.diff(offsets)
new_lengths = np.where(lengths > 0, lengths, 1)
new_offsets = np.empty(n_rows + 1, np.int64)
new_offsets[0] = 0
new_offsets[1:] = np.cumsum(new_lengths)
total_vars = int(new_offsets[n_rows])
new_data = np.empty(total_vars * inner, data.dtype)
dptr = 0
for i in range(n_rows):
vs, ve = int(offsets[i]), int(offsets[i + 1])
if ve == vs:
new_data[dptr : dptr + inner] = fill
dptr += inner
else:
n = int(ve - vs) * inner
new_data[dptr : dptr + n] = data[vs * inner : ve * inner]
dptr += n
return new_data, new_offsets
def _fill_empty_fixed(data, offsets, inner, fill):
"""Dtype-preserving dispatch for fill-empty-fixed.
Routes int32 and float32 to typed Rust cores; all other dtypes (e.g.
custom FORMAT fields, issue #231) fall back to the dtype-preserving numpy
kernel so values are never silently down-cast.
"""
data = np.ascontiguousarray(data)
offsets = np.ascontiguousarray(offsets, np.int64)
if data.dtype == np.int32:
return _fill_empty_fixed_i32_rust(data, offsets, int(inner), int(fill))
if data.dtype == np.float32:
return _fill_empty_fixed_f32_rust(data, offsets, int(inner), float(fill))
# Arbitrary dtype (custom FORMAT fields): preserve dtype via numpy fallback.
return _fill_empty_fixed_numpy(data, offsets, inner, fill)
def _assemble_variant_buffers_numba_entry(*args, **kwargs):
"""Lazy wrapper for _assemble_variant_buffers_numba to avoid circular import.
``_flat_flanks`` imports ``_FlatWindow`` from ``_flat_variants`` at module
level, so ``_flat_variants`` cannot import from ``_flat_flanks`` at module
level. This thin wrapper defers the import to call time.
"""
from ._flat_flanks import _assemble_variant_buffers_numba
return _assemble_variant_buffers_numba(*args, **kwargs)
def _assemble_variant_buffers_rust(
mode,
v_idxs,
row_offsets,
alt_global,
alt_off_global,
ref_global,
ref_off_global,
want_ref_bytes,
want_flank,
ref_mode,
alt_mode,
flank_len,
lut,
v_contigs,
v_starts,
ilens,
reference,
ref_offsets,
pad_char,
):
"""Dtype-selecting shim: routes to assemble_variant_buffers_u8/i32 by lut dtype.
If ``lut`` is None (variants mode with no flank tokens), defaults to the u8
monomorphization (token buffers are empty so dtype is irrelevant).
"""
if lut is None:
fn = _assemble_variant_buffers_u8_rust
lut_arr = None
else:
lut_arr = np.asarray(lut)
if lut_arr.dtype == np.uint8:
fn = _assemble_variant_buffers_u8_rust
lut_arr = np.ascontiguousarray(lut_arr, np.uint8)
else:
fn = _assemble_variant_buffers_i32_rust
lut_arr = np.ascontiguousarray(lut_arr, np.int32)
return fn(
int(mode),
np.ascontiguousarray(v_idxs, np.int32),
np.ascontiguousarray(row_offsets, np.int64),
np.ascontiguousarray(alt_global, np.uint8),
np.ascontiguousarray(alt_off_global, np.int64),
None if ref_global is None else np.ascontiguousarray(ref_global, np.uint8),
None
if ref_off_global is None
else np.ascontiguousarray(ref_off_global, np.int64),
bool(want_ref_bytes),
bool(want_flank),
int(ref_mode),
int(alt_mode),
int(flank_len),
lut_arr,
np.ascontiguousarray(v_contigs, np.int32),
np.ascontiguousarray(v_starts, np.int32),
np.ascontiguousarray(ilens, np.int32),
np.ascontiguousarray(reference, np.uint8),
np.ascontiguousarray(ref_offsets, np.int64),
int(pad_char),
)
def _rc_alleles_reference(byte_data, seq_offsets, var_offsets, to_rc_row):
"""Reference backend: seqpro reverse_complement_masked on a flat allele view.
`to_rc_row` is the per-(b*p) row mask (already ploidy-broadcast); expand to
per-allele via `var_offsets`, then RC each masked allele in place. Mutates
`byte_data` in place; byte-identical to `rc_alleles_inplace`.
"""
from seqpro.rag import Ragged
from .._ragged import reverse_complement_masked
seq_off = np.ascontiguousarray(seq_offsets, np.int64)
var_off = np.ascontiguousarray(var_offsets, np.int64)
row_mask = np.ascontiguousarray(to_rc_row, np.bool_).reshape(-1)
if not row_mask.any():
return
per_allele = np.repeat(row_mask, np.diff(var_off))
n_alleles = len(seq_off) - 1
view = Ragged.from_offsets(byte_data.view("S1"), (n_alleles, None), seq_off)
reverse_complement_masked(view, per_allele) # mutates byte_data in place
def _rc_alleles_rust(byte_data, seq_offsets, var_offsets, to_rc_row):
assert byte_data.dtype == np.uint8 and byte_data.flags.c_contiguous, (
"rc_alleles requires a contiguous uint8 byte_data for in-place RC"
)
_rc_alleles_rust_kernel(
byte_data,
np.ascontiguousarray(seq_offsets, np.int64),
np.ascontiguousarray(var_offsets, np.int64),
np.ascontiguousarray(to_rc_row, np.bool_),
)
def get_variants_flat(
haps: "Haps", idx: NDArray[np.integer], regions=None
) -> "_FlatVariants | _FlatVariantWindows":
"""Flat-buffer analog of :meth:`Haps._get_variants`: builds a :class:`_FlatVariants` on the pure-numpy hot path.
Re-wrapping the result via :meth:`_FlatVariants.to_ragged` is byte-identical
to the :class:`RaggedVariants` produced by ``_get_variants``.
Replicates ONLY AF filtering (min_af/max_af); exonic filtering is not
threaded into the variants output (its ``keep``/``keep_offsets`` params are
dead in ``_get_variants``).
"""
from .._flat import _Flat
genotypes = haps.genotypes
ploidy = genotypes.shape[-2]
b = len(idx)
# (b, ploidy) indices into the sparse-genotype offsets. Flatten C-order to
# (b*ploidy,) so per-row slicing reproduces genotypes[r,s].to_packed() order.
geno_offset_idx = haps._get_geno_offset_idx(idx, genotypes).reshape(-1)
geno_offset_idx = np.ascontiguousarray(geno_offset_idx, np.intp)
geno_offsets = np.asarray(genotypes.offsets, np.int64)
geno_v_idxs = np.asarray(genotypes.data)
# v_idxs: gathered per (b*ploidy) row; row_offsets length b*ploidy + 1.
# Dispatch on offsets shape: 1-D contiguous vs 2-D starts/stops.
v_idxs, row_offsets = _gather_rows(geno_offset_idx, geno_offsets, geno_v_idxs)
# Unfiltered offsets needed for dosage parallel-gather + compaction.
unfiltered_row_offsets = row_offsets
# AF filtering (mirrors _get_variants). Computed before gathering dosage so we
# can compact dosage with the SAME keep mask + UNFILTERED offsets.
keep = None
if haps.min_af is not None or haps.max_af is not None:
geno_afs = np.asarray(haps.variants.info["AF"])[v_idxs]
keep = np.full(len(v_idxs), True, np.bool_)
if haps.min_af is not None:
keep &= geno_afs >= haps.min_af
if haps.max_af is not None:
keep &= geno_afs <= haps.max_af
# Dosage: parallel to genotypes (one value per variant, gathered by the SAME
# genotype offset ranges). Gather against UNFILTERED offsets first.
dosage_data = None
if haps.dosages is not None and "dosage" in haps.var_fields:
dos_offsets = np.asarray(haps.dosages.offsets, np.int64)
dos_all = np.asarray(haps.dosages.data)
# The returned row offsets == unfiltered_row_offsets by construction
# (genotypes and dosages share offset structure), so discard them.
dosage_data, _ = _gather_rows(geno_offset_idx, dos_offsets, dos_all)
# Apply AF compaction to v_idxs / row_offsets / dosage.
if keep is not None:
v_idxs, row_offsets = _compact_keep(v_idxs, unfiltered_row_offsets, keep)
if dosage_data is not None:
dosage_data, _ = _compact_keep(dosage_data, unfiltered_row_offsets, keep)
# Unphased ploidy-1 union: fold the C-order (b, ploidy) rows onto b rows by
# keeping every ploidy-th offset. row_offsets has length b*ploidy + 1, so the
# slice yields b + 1 offsets that span each region/sample's variants across all
# stored haplotypes. v_idxs is untouched: hap-0's calls then hap-1's, concatenated
# (no sort, no dedup; a hom call appears once per haplotype). Safe because the
# downstream consumer is permutation-invariant (issue #222). eff_ploidy drives the
# output shape and per-variant contig broadcasting below.
eff_ploidy = ploidy
if haps.unphased_union:
row_offsets = np.ascontiguousarray(row_offsets[::ploidy])
eff_ploidy = 1
shape: tuple[int | None, ...] = (b, eff_ploidy, None)
opt = haps.window_opt
# --- Build scalar (non-allele) fields shared between both return paths ---
fields: dict[str, Any] = {}
# start: ALWAYS
start_data = np.asarray(haps.variants.start)[v_idxs]
fields["start"] = _Flat.from_offsets(start_data, shape, row_offsets)
# ilen: if "ilen" in var_fields
if "ilen" in haps.var_fields:
ilen_data = np.asarray(haps.variants.ilen)[v_idxs]
fields["ilen"] = _Flat.from_offsets(ilen_data, shape, row_offsets)
# dosage: if dosages present and requested
if dosage_data is not None:
fields["dosage"] = _Flat.from_offsets(dosage_data, shape, row_offsets)
# Custom per-call FORMAT fields (issue #231): same gather/compaction as dosage.
for name, rag in haps.var_field_data.items():
if name not in haps.var_fields:
continue
cf_off = np.asarray(rag.offsets, np.int64)
cf_all = np.asarray(rag.data)
cf_data, _ = _gather_rows(geno_offset_idx, cf_off, cf_all)
if keep is not None:
cf_data, _ = _compact_keep(cf_data, unfiltered_row_offsets, keep)
fields[name] = _Flat.from_offsets(cf_data, shape, row_offsets)
# other info fields
for k in haps.var_fields:
if k in {"alt", "start", "ref", "ilen", "dosage"} or k in haps.var_field_data:
continue
info_data = np.asarray(haps.variants.info[k])[v_idxs]
fields[k] = _Flat.from_offsets(info_data, shape, row_offsets)
# --- Step 1: Compute shared kernel inputs ---
stat = haps.ffi_static
needs_fetch = (
regions is not None
and haps.token_lut is not None
and (
(issubclass(haps.kind, _FlatVariantWindows) and opt is not None)
or bool(haps.flank_length)
)
)
if needs_fetch:
regions_arr = np.asarray(regions)
group_contigs = np.repeat(regions_arr[:, 0], eff_ploidy)
v_contigs = np.repeat(group_contigs, np.diff(row_offsets)).astype(np.int32)
else:
v_contigs = np.zeros(len(v_idxs), np.int32)
ref_present = "ref" in haps.var_fields and haps.variants.ref is not None
ref_global = ref_off_global = None
if ref_present or (
issubclass(haps.kind, _FlatVariantWindows)
and opt is not None
and (opt.ref == "allele")
):
ref_global = np.asarray(haps.variants.ref.data).view(np.uint8)
ref_off_global = np.asarray(haps.variants.ref.offsets, np.int64)
# --- Step 2: variant-windows kind: emit per-allele token buffers (early return) ---
if (
regions is not None
and issubclass(haps.kind, _FlatVariantWindows)
and opt is not None
):
L = opt.flank_length
ref_mode = 1 if opt.ref == "window" else 2
alt_mode = 1 if opt.alt == "window" else 2
bufs = _assemble_variant_buffers_rust(
1, # windows mode
v_idxs,
row_offsets,
stat.alt_alleles,
stat.alt_offsets,
ref_global,
ref_off_global,
False, # want_ref_bytes (windows mode emits tokens, not raw bytes)
False, # want_flank
ref_mode,
alt_mode,
L,
haps.token_lut,
v_contigs,
stat.v_starts,
stat.ilens,
stat.ref,
stat.ref_offsets,
haps.reference.pad_char,
)
wshape = (b, eff_ploidy, None, None)
wfields = {k: v for k, v in fields.items() if k not in ("alt", "ref")}
win = _FlatVariantWindows(wfields)
for name, (data, seq_off) in bufs.items():
fw = _FlatWindow(data, np.asarray(seq_off, np.int64), row_offsets, wshape)
setattr(win, name, fw)
if haps.dummy_variant is not None:
win = win.fill_empty_groups(
haps.dummy_variant, unk=haps.unknown_token, flank_length=L
)
return win
# --- Step 3: plain-variants path: route allele bytes + flank tokens through kernel ---
want_flank = bool(
haps.flank_length and haps.token_lut is not None and regions is not None
)
L = haps.flank_length or 0
bufs = _assemble_variant_buffers_rust(
0, # variants mode
v_idxs,
row_offsets,
stat.alt_alleles,
stat.alt_offsets,
ref_global,
ref_off_global,
ref_present, # want_ref_bytes
want_flank,
0, # ref_mode (unused in variants mode)
0, # alt_mode (unused)
L,
haps.token_lut,
v_contigs,
stat.v_starts,
stat.ilens,
stat.ref if stat.ref is not None else np.zeros(0, np.uint8),
stat.ref_offsets if stat.ref_offsets is not None else np.zeros(1, np.int64),
haps.reference.pad_char if haps.reference is not None else 0,
)
# Build fields in ORIGINAL insertion order (alt FIRST, then start, ref, rest).
# Prepend alt; reconstruct from scalar fields inserting ref after start.
final_fields: dict[str, Any] = {}
alt_data, alt_seq_off = bufs["alt"]
final_fields["alt"] = _FlatAlleles(
np.asarray(alt_data, np.uint8),
np.asarray(alt_seq_off, np.int64),
row_offsets,
shape,
)
for k, v in fields.items():
if k == "start":
final_fields["start"] = v
# Insert ref immediately after start (original order: alt, start, ref, ilen, ...)
if "ref" in bufs:
ref_data, ref_seq_off = bufs["ref"]
final_fields["ref"] = _FlatAlleles(
np.asarray(ref_data, np.uint8),
np.asarray(ref_seq_off, np.int64),
row_offsets,
shape,
)
else:
final_fields[k] = v
flat = _FlatVariants(final_fields)
if "flank_tokens" in bufs:
tok, off = bufs["flank_tokens"]
flat.flank_tokens = _Flat.from_offsets(
tok, (b, eff_ploidy, None, 2 * L), np.asarray(off, np.int64)
)
# dummy-variant empty-group fill (scalars, alleles, and flank_tokens).
if haps.dummy_variant is not None:
flat = flat.fill_empty_groups(haps.dummy_variant, unk=haps.unknown_token)
return flat
def _gather_v_idxs_ss_numba(geno_offset_idx, geno_starts, geno_stops, geno_v_idxs):
"""Gather variant-index rows using starts/stops 2D form.
Pure Python fallback (no numba). Name retained for test backward-compatibility.
Returns (v_idxs, offsets) where offsets has shape (n_rows+1,).
"""
n_rows = geno_offset_idx.shape[0]
out_offsets = np.empty(n_rows + 1, np.int64)
out_offsets[0] = 0
for i in range(n_rows):
goi = int(geno_offset_idx[i])
out_offsets[i + 1] = out_offsets[i] + (
int(geno_stops[goi]) - int(geno_starts[goi])
)
total = int(out_offsets[n_rows])
out_data = np.empty(total, geno_v_idxs.dtype)
dst = 0
for i in range(n_rows):
goi = int(geno_offset_idx[i])
s = int(geno_starts[goi])
e = int(geno_stops[goi])
out_data[dst : dst + (e - s)] = geno_v_idxs[s:e]
dst += e - s
return out_data, out_offsets