seqpro
Python/Rust package for fast biological-sequence processing. Python+NumPy+Numba for hot loops, a small Rust extension (src/kshuffle.rs) for graph-algorithm ops, and a Rust-native Ragged array (_core.Ragged) for variable-length batches. Imported as import seqpro as sp.
When to use
- Encoding/decoding DNA, RNA, or protein sequences (OHE, integer tokens, padding).
- Sequence augmentation: reverse complement, k-mer shuffle, jitter, random draws.
- Sequence stats: GC content, nucleotide composition, length.
- Variable-length batches (e.g. peaks, transcripts of different sizes) →
sp.Ragged.
- Genomic interval I/O:
sp.bed, sp.gtf.
Conventions (load these into working memory)
- Public API: see
python/seqpro/__init__.py for the full export list. Re-read it before assuming a symbol exists.
- Input types (
SeqType in python/seqpro/_utils.py): str, bytes, nested str lists, or ndarray with dtype str_/object_/bytes_/uint8. sp.cast_seqs(...) normalizes string-like inputs to |S1 bytes arrays; uint8 (OHE) is left untouched.
- Canonical in-memory dtypes:
|S1 for string sequences, uint8 for one-hot.
- Axis arguments are required and explicit: most functions take
length_axis and (for OHE) ohe_axis as positional/keyword ints. Negative indices allowed. check_axes() validates and raises early — don't catch and paper over.
- No Python loops over sequences in library code. Hot paths use NumPy, Numba kernels in
_numba.py, or the Rust extension. If you're tempted to write a for over residues, look for an existing vectorized op or a Numba kernel first.
- Alphabets are singletons:
sp.DNA, sp.RNA, sp.AA. Construct custom ones via sp.NucleotideAlphabet / sp.AminoAlphabet (python/seqpro/alphabets/_alphabets.py).
AminoAlphabet.translate(seqs, ..., validate=False, unknown="X"): translates nucleotides → amino acids. Case-insensitive: lowercase/soft-masked acgt always translate. unknown= controls non-canonical codons (anything outside {A,C,G,T}): a single character (default "X") pads one marker per bad codon; the literal "drop" removes bad codons and returns a Ragged (even for dense input, since lengths then vary). validate=True is the single fast-fail path — it raises (case-insensitively) on N/IUPAC/non-one-hot input and, when it returns, guarantees exact translation. There is no separate error mode.
- Transforms (
python/seqpro/transforms/) wrap functional ops as callables — use these in data pipelines instead of inline lambdas.
Quick reference
| Task |
Call |
Notes |
| Normalize input |
sp.cast_seqs(x) |
→ ` |
| One-hot encode |
sp.ohe(x, alphabet, length_axis=-1) |
last axis added for OHE dim |
| Decode OHE |
sp.decode_ohe(x, alphabet, ohe_axis=-1) |
|
| Tokenize / detokenize |
sp.tokenize / sp.decode_tokens |
integer ids; parallel=True/False forces/disables the parallel kernel (default None = size heuristic) |
| Pad |
sp.pad_seqs(x, pad_val, length=...) |
|
| Reverse complement |
sp.reverse_complement(x, alphabet, length_axis=-1) |
works on str/bytes/OHE |
| K-mer shuffle |
sp.k_shuffle(x, k, length_axis=-1, seed=...) |
calls Rust _k_shuffle |
| Jitter |
sp.jitter(x, max_jitter, length_axis=-1) |
|
| Random sequences |
sp.random_seqs(shape, alphabet, seed=...) |
|
| GC content |
sp.gc_content(x, length_axis=-1) |
|
| Nucleotide content |
sp.nucleotide_content(x, alphabet, length_axis=-1) |
|
| Coverage binning |
sp.bin_coverage(arr, bin_width, length_axis) |
|
| BED / GTF I/O |
sp.bed.read_bedlike(...), sp.gtf.read_gtf(...) |
polars/pyranges-backed |
| Hash ragged strings |
rag.hash("sha256"|"md5"|"rapidhash") |
calls Rust kernel; seed for rapidhash only |
For exact signatures and kwargs, read the docstring directly (sp.<fn>? in a REPL, or open the source — files are short).
Ragged — variable-length sequence batches
sp.Ragged (backed by python/seqpro/rag/_core.py) is the canonical container for batches where sequences differ in length. It is a Rust-native class implementing NDArrayOperatorsMixin (NOT a subclass of ak.Array) with exactly one ragged dimension, plus zero-copy access to the underlying flat NumPy buffer and offsets.
Mental model
A Ragged has three things:
data: a flat contiguous NDArray of shape (total_elements, *fixed_trailing_dims). Zero-copy access via rag.data.
offsets: an int64 array. Shape (N+1,) (contiguous, the common case) or (2, N) starts/stops (after some slices). Access via rag.offsets.
shape: a tuple like (batch, None, ohe_dim) where exactly one entry is None — that's the ragged axis. rag.rag_dim gives its index.
rag.lengths derives segment lengths from offsets (cheap, returns an ndarray).
Construction
import numpy as np, seqpro as sp
# From lengths (most common — you have a flat numeric data buffer and per-segment lengths)
data = np.frombuffer(b"ACGTACGTACG", dtype=np.uint8) # uint8 for char-as-number
lengths = np.array([4, 3, 4])
rag = sp.rag.Ragged.from_lengths(data, lengths) # shape (3, None)
# From explicit offsets (also accepts S1 char arrays when shape has a None)
offsets = np.array([0, 4, 7, 11], dtype=np.int64)
char_data = np.frombuffer(b"ACGTACGTACG", dtype="S1")
rag = sp.rag.Ragged.from_offsets(char_data, shape=(3, None), offsets=offsets) # shape (3, None)
# Empty with known shape
rag = sp.rag.Ragged.empty((10, None, 4), dtype=np.uint8) # batch of 10 OHE seqs
Ragged.empty(shape, dtype) requires exactly one None in shape. Trailing fixed dims (e.g. the OHE axis) go after the None.
Working with Ragged — do this, not that
| Task |
Do |
Don't |
| Bulk numeric op on the flat data |
rag.data[:] = ... or rag.data.view(...) — zero-copy |
Iterate for seq in rag: |
Apply a np.ufunc |
Just call it: np.exp(rag) — dispatched via __array_ufunc__ (NDArrayOperatorsMixin) to return a Ragged |
Manually unpack and rebuild |
| Count top-level rows |
len(rag) — returns shape[0] (raises if shape[0] is the ragged axis) |
rag.shape[0] with manual int-cast |
Index one group of an opaque-string Ragged |
rag[i] → a Ragged of bytes, one per string (len(rag[i]) == rag.lengths[i]); rag[i][j] is one bytes |
Expect one concatenated bytes — that was the pre-0.22 behavior and it silently dropped the per-string boundaries |
| Insert a leading size-1 axis |
rag[np.newaxis] — returns Ragged with shape (1, *old_shape) |
Manual from_offsets rebuild |
| Reinterpret bytes/dtype |
rag.view(np.uint8) |
np.asarray(rag).view(...) (loses ragged structure) |
| Reshape non-ragged axes |
rag.reshape(batch, None, k, 4) |
Touch rag.data.shape directly |
| Drop a size-1 axis |
rag.squeeze(axis) (returns ndarray if collapses to 1D) |
|
| Densify to NumPy |
rag.to_numpy() (pads/raises per allow_missing) |
Loop and stack |
| Pack into contiguous buffer |
rag.to_packed() or sp.rag.to_packed(rag) — Numba-parallelized, safe on np.memmap; copy=False for zero-copy passthrough when already packed |
ak.to_packed(rag) |
| Densify + right-pad to fixed length |
sp.rag.to_padded(rag, pad_value, *, length=None) — flat-buffer numba kernel; length=None pads to batch max, explicit length pads/truncates; ragged-axis-last, non-record only |
rag.to_numpy() with manual slicing or ak_str.rpad (~3× slower; the awkward path allocates extra intermediates) |
| Concatenate along ragged axis |
sp.rag.concatenate(rags, axis) — concatenate a list of Ragged arrays along the ragged axis (axis must be the None dim, negative allowed); offset-arithmetic + buffered copy via Rust/rayon kernel; numeric dtypes (int32, float32, …) |
ak.concatenate(rags, axis=…) |
| Strip to plain awkward |
rag.to_ak() |
|
Record-layout Ragged (multi-field)
Build by calling sp.rag.zip (or equivalently Ragged.from_fields) with a dict of single-field Raggeds that share the same offsets object. The result is a Ragged with a record layout:
import numpy as np, seqpro as sp
from seqpro.rag._utils import lengths_to_offsets
lengths = np.array([4, 3])
shared_offsets = lengths_to_offsets(lengths)
seq_rag = sp.rag.Ragged.from_offsets(seq_flat, shape=(2, None), offsets=shared_offsets) # |S1
score_rag = sp.rag.Ragged.from_offsets(score_flat, shape=(2, None), offsets=shared_offsets) # f4
batch = sp.rag.zip({"seq": seq_rag, "score": score_rag}) # → Ragged (record layout)
# equivalently: batch = sp.rag.Ragged.from_fields({"seq": seq_rag, "score": score_rag})
assert isinstance(batch, sp.rag.Ragged)
batch["score"].data[:] *= 2.0 # zero-copy mutation of the flat score buffer
The inputs must share the same offsets object (pass the same shared_offsets array to each from_offsets call) — that's what makes the result a single-ragged-dim record. Passing independently-constructed from_lengths results raises ValueError because their offsets objects differ even if lengths are equal.
rag.dtype returns a NumPy structured dtype (e.g. [("seq","S1"),("score","f4")]), purely as a descriptor — memory is SoA, not AoS.
rag.data returns a dict keyed by field name, not a single array. Always type-check before indexing.
rag["field"] gives zero-copy single-field access and shares the parent's offsets object. Its .data is the flat NumPy buffer for that field.
rag.to_numpy() on a record layout returns a dict {field: dense ndarray} (raises if any field is still jagged — lengths must be uniform for a dense conversion).
view and apply are not defined on record layouts — operate per-field.
- Peeling a row (
rag[i] with an integer i) returns a dict whose entries all have the same length: numeric/char fields as ndarray, opaque-string fields as a Ragged of bytes. That's what makes zip(row["start"], row["alt"]) correct.
Hashing strings
Hash each string in a Ragged (opaque-string or S1-chars leaf, any depth) with
a parallel Rust kernel:
digests = rag.hash("sha256") # (N, 32) uint8, one digest per string
md5s = rag.hash("md5") # (N, 16) uint8
fast = rag.hash("rapidhash") # (N,) uint64
seeded = rag.hash("rapidhash", seed=42) # seed valid for rapidhash only
# equivalently: sp.rag.hash(rag, "sha256")
Output mirrors the structure above the string level: a regular NumPy array
when strings aren't grouped (flat (N, …) / leading fixed dims), or a Ragged
reusing the outer offsets when they are ((G, None, 16/32) for md5/sha256,
(G, None) uint64 for rapidhash). Numeric and record-layout Rageds are
rejected.
NumPy interop — what you can rely on
_core.Ragged implements NDArrayOperatorsMixin and __array_ufunc__ directly (no awkward dependency):
- NumPy ufuncs (
np.add, np.exp, etc.) on a non-record Ragged return a Ragged. Record layouts raise NotImplementedError — operate on individual fields.
rag.to_packed() / sp.rag.to_packed(rag) is the canonical way to materialize a contiguous, zero-based buffer — Numba-parallelized and safe on np.memmap. Use copy=False for a zero-copy passthrough when the array is already packed (raises ValueError if not).
- Don't rely on awkward (
ak.*) APIs on _core.Ragged — the backend no longer registers ak.behavior. Use rag.to_ak() to get an ak.Array if you need awkward interop, but prefer the native API.
When in doubt, read python/seqpro/rag/_core.py — it's the live backend and the docstrings are the source of truth. _layout.py, _ops.py, and _utils.py in the same dir contain supporting internals.
Common pitfalls
- Offsets layout drifts after slicing.
rag.offsets may become (2, N) starts/stops instead of (N+1,). Check rag.is_contiguous / call rag.to_packed() before any code that assumes (N+1,).
rag.data on a record layout is a dict. Code like rag.data.shape will fail; branch on isinstance(rag.data, dict) or use rag.parts and inspect.
Ragged must have exactly one None in shape. Constructing from data whose ragged structure doesn't match raises in __init__. Use from_lengths / from_offsets when in doubt.
- The Rust k-shuffle expects contiguous
uint8 with the last axis as sequence length. sp.k_shuffle handles this for you; if calling seqpro._k_shuffle directly, ensure layout.
Where to look (don't memorize — read the source)
| Need |
File |
| Public surface |
python/seqpro/__init__.py |
| Input casting / axis helpers |
python/seqpro/_utils.py |
| OHE / tokens / padding |
python/seqpro/_encoders.py |
| Augmentations |
python/seqpro/_modifiers.py |
| Stats |
python/seqpro/_analyzers.py |
| Alphabets |
python/seqpro/alphabets/_alphabets.py |
| Ragged |
python/seqpro/rag/_core.py |
| Transforms (pipeline objects) |
python/seqpro/transforms/ |
| BED/GTF |
python/seqpro/bed.py, gtf.py |
| Rust k-shuffle |
src/kshuffle.rs |
| Tests as usage examples |
tests/ |
| Rendered docs |
site/ (built from docs/) |
Don'ts
- Don't write Python
for loops over residues or positions in library code. Look for a vectorized op, a Numba kernel in _numba.py, or extend one.
- Don't assume an axis — always pass
length_axis (and ohe_axis where relevant) explicitly.
- Don't reach into
Ragged internals (_layout, _rl, __init__ shortcuts) from user code; use data, offsets, fields, from_lengths, from_offsets, from_fields, empty.
- Don't introduce strings into
Ragged. ASCII bytes (|S1) only.
- Don't add a feature or change a public signature without updating this skill — see CLAUDE.md.
1---2name: seqpro3description: Use when writing Python that processes biological sequences (DNA/RNA/protein) with the seqpro package — encoding, one-hot, k-mer shuffling, reverse complement, GC content, variable-length sequence batches, or anything involving seqpro's `Ragged` array. Covers the seqpro API surface and the conventions you need to use it correctly.4---56# seqpro78Python/Rust package for fast biological-sequence processing. Python+NumPy+Numba for hot loops, a small Rust extension (`src/kshuffle.rs`) for graph-algorithm ops, and a Rust-native `Ragged` array (`_core.Ragged`) for variable-length batches. Imported as `import seqpro as sp`.910## When to use1112- Encoding/decoding DNA, RNA, or protein sequences (OHE, integer tokens, padding).13- Sequence augmentation: reverse complement, k-mer shuffle, jitter, random draws.14- Sequence stats: GC content, nucleotide composition, length.15- Variable-length batches (e.g. peaks, transcripts of different sizes) → `sp.Ragged`.16- Genomic interval I/O: `sp.bed`, `sp.gtf`.1718## Conventions (load these into working memory)1920- **Public API**: see `python/seqpro/__init__.py` for the full export list. Re-read it before assuming a symbol exists.21- **Input types** (`SeqType` in `python/seqpro/_utils.py`): str, bytes, nested str lists, or `ndarray` with dtype `str_`/`object_`/`bytes_`/`uint8`. `sp.cast_seqs(...)` normalizes string-like inputs to `|S1` bytes arrays; `uint8` (OHE) is left untouched.22- **Canonical in-memory dtypes**: `|S1` for string sequences, `uint8` for one-hot.23- **Axis arguments are required and explicit**: most functions take `length_axis` and (for OHE) `ohe_axis` as positional/keyword ints. Negative indices allowed. `check_axes()` validates and raises early — don't catch and paper over.24- **No Python loops over sequences in library code.** Hot paths use NumPy, Numba kernels in `_numba.py`, or the Rust extension. If you're tempted to write a `for` over residues, look for an existing vectorized op or a Numba kernel first.25- **Alphabets are singletons**: `sp.DNA`, `sp.RNA`, `sp.AA`. Construct custom ones via `sp.NucleotideAlphabet` / `sp.AminoAlphabet` (`python/seqpro/alphabets/_alphabets.py`).26- **`AminoAlphabet.translate(seqs, ..., validate=False, unknown="X")`**: translates nucleotides → amino acids. Case-insensitive: lowercase/soft-masked `acgt` always translate. `unknown=` controls non-canonical codons (anything outside `{A,C,G,T}`): a single character (default `"X"`) pads one marker per bad codon; the literal `"drop"` removes bad codons and returns a `Ragged` (even for dense input, since lengths then vary). `validate=True` is the single fast-fail path — it raises (case-insensitively) on `N`/IUPAC/non-one-hot input and, when it returns, guarantees exact translation. There is no separate `error` mode.27- **Transforms** (`python/seqpro/transforms/`) wrap functional ops as callables — use these in data pipelines instead of inline lambdas.2829## Quick reference3031| Task | Call | Notes |32|---|---|---|33| Normalize input | `sp.cast_seqs(x)` | → `|S1` bytes, or passthrough for OHE |34| One-hot encode | `sp.ohe(x, alphabet, length_axis=-1)` | last axis added for OHE dim |35| Decode OHE | `sp.decode_ohe(x, alphabet, ohe_axis=-1)` | |36| Tokenize / detokenize | `sp.tokenize` / `sp.decode_tokens` | integer ids; `parallel=True/False` forces/disables the parallel kernel (default `None` = size heuristic) |37| Pad | `sp.pad_seqs(x, pad_val, length=...)` | |38| Reverse complement | `sp.reverse_complement(x, alphabet, length_axis=-1)` | works on str/bytes/OHE |39| K-mer shuffle | `sp.k_shuffle(x, k, length_axis=-1, seed=...)` | calls Rust `_k_shuffle` |40| Jitter | `sp.jitter(x, max_jitter, length_axis=-1)` | |41| Random sequences | `sp.random_seqs(shape, alphabet, seed=...)` | |42| GC content | `sp.gc_content(x, length_axis=-1)` | |43| Nucleotide content | `sp.nucleotide_content(x, alphabet, length_axis=-1)` | |44| Coverage binning | `sp.bin_coverage(arr, bin_width, length_axis)` | |45| BED / GTF I/O | `sp.bed.read_bedlike(...)`, `sp.gtf.read_gtf(...)` | polars/pyranges-backed |46| Hash ragged strings | `rag.hash("sha256"\|"md5"\|"rapidhash")` | calls Rust kernel; seed for rapidhash only |4748For exact signatures and kwargs, read the docstring directly (`sp.<fn>?` in a REPL, or open the source — files are short).4950## `Ragged` — variable-length sequence batches5152`sp.Ragged` (backed by `python/seqpro/rag/_core.py`) is the canonical container for batches where sequences differ in length. It is a Rust-native class implementing `NDArrayOperatorsMixin` (NOT a subclass of `ak.Array`) with **exactly one ragged dimension**, plus zero-copy access to the underlying flat NumPy buffer and offsets.5354### Mental model5556A `Ragged` has three things:5758- **`data`**: a flat contiguous `NDArray` of shape `(total_elements, *fixed_trailing_dims)`. Zero-copy access via `rag.data`.59- **`offsets`**: an `int64` array. Shape `(N+1,)` (contiguous, the common case) **or** `(2, N)` starts/stops (after some slices). Access via `rag.offsets`.60- **`shape`**: a tuple like `(batch, None, ohe_dim)` where exactly one entry is `None` — that's the ragged axis. `rag.rag_dim` gives its index.6162`rag.lengths` derives segment lengths from offsets (cheap, returns an `ndarray`).6364### Construction6566```python67import numpy as np, seqpro as sp6869# From lengths (most common — you have a flat numeric data buffer and per-segment lengths)70data = np.frombuffer(b"ACGTACGTACG", dtype=np.uint8) # uint8 for char-as-number71lengths = np.array([4, 3, 4])72rag = sp.rag.Ragged.from_lengths(data, lengths) # shape (3, None)7374# From explicit offsets (also accepts S1 char arrays when shape has a None)75offsets = np.array([0, 4, 7, 11], dtype=np.int64)76char_data = np.frombuffer(b"ACGTACGTACG", dtype="S1")77rag = sp.rag.Ragged.from_offsets(char_data, shape=(3, None), offsets=offsets) # shape (3, None)7879# Empty with known shape80rag = sp.rag.Ragged.empty((10, None, 4), dtype=np.uint8) # batch of 10 OHE seqs81```8283`Ragged.empty(shape, dtype)` requires exactly one `None` in `shape`. Trailing fixed dims (e.g. the OHE axis) go after the `None`.8485### Working with `Ragged` — do this, not that8687| Task | Do | Don't |88|---|---|---|89| Bulk numeric op on the flat data | `rag.data[:] = ...` or `rag.data.view(...)` — zero-copy | Iterate `for seq in rag:` |90| Apply a `np.ufunc` | Just call it: `np.exp(rag)` — dispatched via `__array_ufunc__` (NDArrayOperatorsMixin) to return a `Ragged` | Manually unpack and rebuild |91| Count top-level rows | `len(rag)` — returns `shape[0]` (raises if `shape[0]` is the ragged axis) | `rag.shape[0]` with manual int-cast |92| Index one group of an opaque-string `Ragged` | `rag[i]` → a `Ragged` of `bytes`, one per string (`len(rag[i]) == rag.lengths[i]`); `rag[i][j]` is one `bytes` | Expect one concatenated `bytes` — that was the pre-0.22 behavior and it silently dropped the per-string boundaries |93| Insert a leading size-1 axis | `rag[np.newaxis]` — returns `Ragged` with shape `(1, *old_shape)` | Manual `from_offsets` rebuild |94| Reinterpret bytes/dtype | `rag.view(np.uint8)` | `np.asarray(rag).view(...)` (loses ragged structure) |95| Reshape non-ragged axes | `rag.reshape(batch, None, k, 4)` | Touch `rag.data.shape` directly |96| Drop a size-1 axis | `rag.squeeze(axis)` (returns `ndarray` if collapses to 1D) | |97| Densify to NumPy | `rag.to_numpy()` (pads/raises per `allow_missing`) | Loop and stack |98| Pack into contiguous buffer | `rag.to_packed()` or `sp.rag.to_packed(rag)` — Numba-parallelized, safe on `np.memmap`; `copy=False` for zero-copy passthrough when already packed | `ak.to_packed(rag)` |99| Densify + right-pad to fixed length | `sp.rag.to_padded(rag, pad_value, *, length=None)` — flat-buffer numba kernel; `length=None` pads to batch max, explicit `length` pads/truncates; ragged-axis-last, non-record only | `rag.to_numpy()` with manual slicing or `ak_str.rpad` (~3× slower; the awkward path allocates extra intermediates) |100| Concatenate along ragged axis | `sp.rag.concatenate(rags, axis)` — concatenate a list of `Ragged` arrays along the ragged axis (`axis` must be the `None` dim, negative allowed); offset-arithmetic + buffered copy via Rust/rayon kernel; numeric dtypes (int32, float32, …) | `ak.concatenate(rags, axis=…)` |101| Strip to plain awkward | `rag.to_ak()` | |102103### Record-layout `Ragged` (multi-field)104105Build by calling `sp.rag.zip` (or equivalently `Ragged.from_fields`) with a dict of single-field `Ragged`s that share the **same offsets object**. The result is a `Ragged` with a record layout:106107```python108import numpy as np, seqpro as sp109from seqpro.rag._utils import lengths_to_offsets110111lengths = np.array([4, 3])112shared_offsets = lengths_to_offsets(lengths)113114seq_rag = sp.rag.Ragged.from_offsets(seq_flat, shape=(2, None), offsets=shared_offsets) # |S1115score_rag = sp.rag.Ragged.from_offsets(score_flat, shape=(2, None), offsets=shared_offsets) # f4116117batch = sp.rag.zip({"seq": seq_rag, "score": score_rag}) # → Ragged (record layout)118# equivalently: batch = sp.rag.Ragged.from_fields({"seq": seq_rag, "score": score_rag})119assert isinstance(batch, sp.rag.Ragged)120121batch["score"].data[:] *= 2.0 # zero-copy mutation of the flat score buffer122```123124The inputs **must share the same offsets object** (pass the same `shared_offsets` array to each `from_offsets` call) — that's what makes the result a single-ragged-dim record. Passing independently-constructed `from_lengths` results raises `ValueError` because their offsets objects differ even if lengths are equal.125126- `rag.dtype` returns a NumPy *structured* dtype (e.g. `[("seq","S1"),("score","f4")]`), purely as a descriptor — memory is SoA, not AoS.127- `rag.data` returns a **dict keyed by field name**, not a single array. Always type-check before indexing.128- `rag["field"]` gives zero-copy single-field access and shares the parent's offsets object. Its `.data` is the flat NumPy buffer for that field.129- `rag.to_numpy()` on a record layout returns a **dict `{field: dense ndarray}`** (raises if any field is still jagged — lengths must be uniform for a dense conversion).130- `view` and `apply` are **not defined** on record layouts — operate per-field.131- Peeling a row (`rag[i]` with an integer `i`) returns a **dict** whose entries all have the same length: numeric/char fields as `ndarray`, opaque-string fields as a `Ragged` of `bytes`. That's what makes `zip(row["start"], row["alt"])` correct.132133### Hashing strings134135Hash each string in a `Ragged` (opaque-string or S1-chars leaf, any depth) with136a parallel Rust kernel:137138```python139digests = rag.hash("sha256") # (N, 32) uint8, one digest per string140md5s = rag.hash("md5") # (N, 16) uint8141fast = rag.hash("rapidhash") # (N,) uint64142seeded = rag.hash("rapidhash", seed=42) # seed valid for rapidhash only143# equivalently: sp.rag.hash(rag, "sha256")144```145146Output mirrors the structure *above* the string level: a regular NumPy array147when strings aren't grouped (flat `(N, …)` / leading fixed dims), or a `Ragged`148reusing the outer offsets when they are (`(G, None, 16/32)` for md5/sha256,149`(G, None)` uint64 for rapidhash). Numeric and record-layout Rageds are150rejected.151152### NumPy interop — what you can rely on153154`_core.Ragged` implements `NDArrayOperatorsMixin` and `__array_ufunc__` directly (no awkward dependency):155156- NumPy ufuncs (`np.add`, `np.exp`, etc.) on a non-record `Ragged` return a `Ragged`. Record layouts raise `NotImplementedError` — operate on individual fields.157- `rag.to_packed()` / `sp.rag.to_packed(rag)` is the canonical way to materialize a contiguous, zero-based buffer — Numba-parallelized and safe on `np.memmap`. Use `copy=False` for a zero-copy passthrough when the array is already packed (raises `ValueError` if not).158- Don't rely on awkward (`ak.*`) APIs on `_core.Ragged` — the backend no longer registers `ak.behavior`. Use `rag.to_ak()` to get an `ak.Array` if you need awkward interop, but prefer the native API.159160When in doubt, read `python/seqpro/rag/_core.py` — it's the live backend and the docstrings are the source of truth. `_layout.py`, `_ops.py`, and `_utils.py` in the same dir contain supporting internals.161162### Common pitfalls163164- **Offsets layout drifts after slicing.** `rag.offsets` may become `(2, N)` starts/stops instead of `(N+1,)`. Check `rag.is_contiguous` / call `rag.to_packed()` before any code that assumes `(N+1,)`.165- **`rag.data` on a record layout is a dict.** Code like `rag.data.shape` will fail; branch on `isinstance(rag.data, dict)` or use `rag.parts` and inspect.166- **`Ragged` must have exactly one `None` in `shape`.** Constructing from data whose ragged structure doesn't match raises in `__init__`. Use `from_lengths` / `from_offsets` when in doubt.167- **The Rust k-shuffle expects contiguous `uint8` with the last axis as sequence length.** `sp.k_shuffle` handles this for you; if calling `seqpro._k_shuffle` directly, ensure layout.168169## Where to look (don't memorize — read the source)170171| Need | File |172|---|---|173| Public surface | `python/seqpro/__init__.py` |174| Input casting / axis helpers | `python/seqpro/_utils.py` |175| OHE / tokens / padding | `python/seqpro/_encoders.py` |176| Augmentations | `python/seqpro/_modifiers.py` |177| Stats | `python/seqpro/_analyzers.py` |178| Alphabets | `python/seqpro/alphabets/_alphabets.py` |179| Ragged | `python/seqpro/rag/_core.py` |180| Transforms (pipeline objects) | `python/seqpro/transforms/` |181| BED/GTF | `python/seqpro/bed.py`, `gtf.py` |182| Rust k-shuffle | `src/kshuffle.rs` |183| Tests as usage examples | `tests/` |184| Rendered docs | `site/` (built from `docs/`) |185186## Don'ts187188- Don't write Python `for` loops over residues or positions in library code. Look for a vectorized op, a Numba kernel in `_numba.py`, or extend one.189- Don't assume an axis — always pass `length_axis` (and `ohe_axis` where relevant) explicitly.190- Don't reach into `Ragged` internals (`_layout`, `_rl`, `__init__` shortcuts) from user code; use `data`, `offsets`, `fields`, `from_lengths`, `from_offsets`, `from_fields`, `empty`.191- Don't introduce strings into `Ragged`. ASCII bytes (`|S1`) only.192- Don't add a feature or change a public signature without updating this skill — see CLAUDE.md.