This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
escapepod-rs is a pure Rust implementation for reading and writing POD5 files, the native file format for Oxford Nanopore sequencing data. The workspace splits the library into two layers — escapepod-pod5 for format I/O and escapepod-signal for signal-processing algorithms — plus the escapepod-cli crate (the escpod CLI binary, with an optional umbrella library re-exporting the layers) and Python bindings (escapepod-python).
- Rust 1.95 or later (matches
workspace.package.rust-version)
# Build
cargo build --release
# Build with training support (enables SVM model training)
cargo build --release --features train
# Build with GPU-accelerated DTW (experimental, opt-in; needs the CUDA
# driver + libnvrtc at runtime — no nvcc required at build time).
# The pixi `gpu` env provides libnvrtc via conda-forge's cuda-nvrtc package
# and sets LD_LIBRARY_PATH automatically:
pixi run -e gpu cargo build --release --features gpu -p escapepod-cli
# Test / bench on a GPU node (SLURM account `gpu_rbi`, partition `gpu`):
srun -p gpu -A gpu_rbi -c 16 --gres=gpu:1 \
pixi run -e gpu cargo nextest run --features gpu -p escapepod-signal --test gpu_dtw
srun -p gpu -A gpu_rbi -c 16 --gres=gpu:1 \
pixi run -e gpu cargo bench --features gpu --bench hot_paths_gpu
# Use on a node with a visible GPU:
escpod demux classify --model model.json reads.fp.csv --gpu -o out.tsv
# Run tests (cargo-nextest)
cargo nextest run
# Doctests — nextest does not run doctests, keep them on a separate invocation
cargo test --doc --workspace
# Run a specific test
cargo nextest run test_round_trip_single_read
# Run clippy lints
cargo clippy
# Run the CLI (after building)
./target/release/escpod <command>
# mold is the linker full time: the base [activation] in pixi.toml exports
# LD_PRELOAD=$CONDA_PREFIX/lib/mold/mold-wrapper.so for EVERY environment, so
# any `pixi run [-e <env>] cargo …` (and maturin) links with mold — no
# `mold -run` and no `-e dev` needed for that. Works with system gcc 11.5 (no
# `-fuse-ld=mold` support required) and needs no glibc-static. Release
# artifacts shipped via GitHub Releases build in CI against musl (static),
# outside pixi, and are unaffected; local builds remain dynamic gnu by design.
#
# The `dev` env additionally provides cargo-nextest + convenience task wrappers
# (which are now just bare `cargo …`, since mold is already on):
pixi run -e dev build # cargo build
pixi run -e dev build-rel # cargo build --release (dynamic gnu)
pixi run -e dev test # cargo nextest run
pixi run -e dev test-doc # cargo test --doc (nextest skips doctests)
pixi run -e dev check # cargo check
pixi run -e dev clippy # cargo clippy --workspace --all-targets
# GPU builds also link with mold (dev-gpu, or any gpu env):
pixi run -e dev-gpu cargo build --features gpu -p escapepod-cliLocal Linux/x86_64 builds pin -C target-cpu=x86-64-v3 via .cargo/config.toml
(AVX2 + FMA + BMI2 + POPCNT + F16C). This is portable across every node in
the cluster (Broadwell login, Cascade Lake rna, Ice Lake gpu). Do not
use target-cpu=native: a binary built on a gpu node uses Ice Lake-only
instructions (VBMI, VPCLMULQDQ, …) that SIGILL on rna. Hot kernels that
want AVX-512 do so via #[target_feature] + runtime is_x86_feature_detected!
dispatch, not a global baseline bump.
The login node has only 2 cores — wrap any heavy build or bench in srun -p rna:
# build / test — 32 logical CPUs (16 physical cores + HT) is enough.
# These already link with mold (base activation, see above); no -e dev needed.
srun -p rna -c 32 --mem=32G pixi run cargo build --release
srun -p rna -c 32 --mem=32G pixi run cargo nextest run --workspace
# The dev env adds cargo-nextest + task wrappers. mold is multithreaded on the
# link step, so the 32-core allocation helps both compile and link phases.
srun -p rna -c 32 --mem=32G pixi run -e dev build-rel
srun -p rna -c 32 --mem=32G pixi run -e dev test
# throughput-sensitive demux runs — ask for a full socket (48 logical = 24 physical + HT).
# SLURM's `-c 32` only allocates 16 physical cores on rna's Gold 6240R, not 32;
# `-c 48` fills the socket and gives ~20% more wall-clock speedup on fingerprint
# without crossing NUMA boundaries. Crossing sockets (`-c 64`+) is a crapshoot on
# a shared node — other jobs on the second socket regress wall time.
srun -p rna -c 48 --mem=64G pixi run escpod demux fingerprint …The rna/gpu partitions above are the Beevol (CU Anschutz) cluster. On
Alpine the SLURM model differs: every partition needs an explicit --qos=
matched to the partition plus -A amc-general (the CU Anschutz allocation) — the
partition name alone is not enough. Translation:
| Purpose | Beevol | Alpine |
|---|---|---|
| CPU build / test / bench | srun -p rna -c 32 --mem=32G |
srun -p amilan --qos=normal -A amc-general -c 32 --mem=32G |
| CPU full node (throughput) | srun -p rna -c 48 |
srun -p amilan --qos=normal -A amc-general -c 64 --mem=120G |
| GPU test / bench | srun -p gpu -A gpu_rbi -c 16 --gres=gpu:1 |
srun -p aa100 --qos=gpu-normal -A amc-general -c 16 --gres=gpu:1 |
| Light build (login is 2 cores) | login node | srun -p acompile --qos=compile -A amc-general -c 16 --mem=32G |
QOS↔partition: amilan→normal|long; aa100/al40→gpu-normal|gpu-long;
acompile→compile; atesting→testing (1 h cap). Default mem is 3840 MB/core.
Notes specific to Alpine:
amilannodes are AMD EPYC Milan, 2×32 = 64 physical cores, no hyperthreading, 245 GB. So the Beevol "-c 48fills a socket /-c 64crosses NUMA" tuning does not apply —-c Nis N physical cores; one full node is 64 across 2 sockets.- GPU CUDA work must use
aa100(A100) oral40(L40).ami100is AMD/ROCm and cannot run the CUDAgpu/cnn-gpufeatures. - All of amilan/aa100/acompile are Zen3 x86-64, so the pinned
target-cpu=x86-64-v3baseline is portable across them — build once, run anywhere, no SIGILL risk. - Toolchain lives in pixi, not on the bare PATH: mold links every env (base
activation);
-e devadds cargo-nextest + task wrappers,-e warpdemux-bench(hyperfine + pod5, forbenchmarks/benchmark.sh),-e gpu(CUDA runtime / libnvrtc). Wrap invocations accordingly, e.g.srun -p amilan --qos=normal -A amc-general -c 32 --mem=32G pixi run -e dev test.
release— ship build: fat LTO,codegen-units=1, stripped,panic=abort.release-with-debug— release speed with symbols retained (forsamply/perf).bench— inherits release; used bycargo benchso microbenchmarks match release codegen.profiling— inherits release but turns LTO off and splitscodegen-units=16so profilers see real call graphs instead of inlined soup.dist— ship build for release artifacts.dev.package."*"— dev dependencies compile atopt-level = 2so test iteration is fast.
crates/escapepod-signal/benches/hot_paths.rs covers the audit hot paths: DTW, resquiggle DP, fingerprint MAD, VBZ encode/decode, DTW matrix.
# Full run
cargo bench --bench hot_paths
# Subset
cargo bench --bench hot_paths -- vbz
# A/B against a saved baseline
cargo bench --bench hot_paths -- --save-baseline <name>
cargo bench --bench hot_paths -- --baseline <name> # compare future runsEnv overrides: ESCAPEPOD_BENCH_THREADS=N (rayon pool size for the matrix bench), ESCAPEPOD_BENCH_SAMPLES=N (criterion sample size for slow groups).
cargo build --release
./benchmarks/benchmark.sh /path/to/pod5/dirRuns inspect summary, view, merge, filter, subset via hyperfine against Python pod5 (installed in the pixi env). Results persist as JSON under /tmp/escapepod_benchmark/. Historical numbers are in benchmarks/README.md.
# 1. build with symbols kept
cargo build --profile release-with-debug -p escapepod-cli
# 2. record with samply (pixi-provided binary recommended)
samply record target/release-with-debug/escpod <args>
# 3. flamegraph-style view in browserFor perf/valgrind where inlining hides frames, swap release-with-debug for profiling (LTO off).
All CLI status/progress/warning output flows through tracing to stderr (the
custom EscpodFormatter in main.rs renders timestamp LEVEL [target] message).
Command data — TSV/CSV rows, inspect/summary reports, ID lists — stays on
stdout via println!, so it can be piped/redirected independently of logs.
Default level is info (status visible out of the box). Control via CLI flags
or RUST_LOG (which always wins when set):
escpod inspect summary file.pod5 # info (default): status + warnings
escpod -v merge *.pod5 -o out.pod5 # debug
escpod -vv merge *.pod5 -o out.pod5 # trace
escpod -q merge … # errors only (status + progress bars suppressed)
RUST_LOG=escapepod_signal::reader=trace escpod view file.pod5 # module-scopedProgress bars/spinners (progress.rs) are status output too: they auto-hide
when the level drops below info (i.e. under -q). Multi-line styled report
blocks (e.g. merge --profile timings, demux summaries) are gated on
tracing::enabled!(Level::INFO) rather than emitted as per-line events.
When adding output: use tracing::info!/warn!/error! for diagnostics
(don't hand-prefix messages with Warning:/Note: — the formatter prints the
level); use println!→stdout only for the command's actual data product.
- escapepod-pod5: POD5 format I/O layer — reader, writer, VBZ compression, footer parsing, block-level merge/filter/repack/subset operations.
- escapepod-signal: Signal-processing algorithms (DTW, resquiggle, segmentation) layered on top of
escapepod-pod5. Re-exports the pod5 surface so downstream consumers can depend on a single crate. - escapepod-demux: WarpDemuX-compatible barcode demultiplexing — SVM model loaders, DTW+SVM classifier, Platt scaling, optional SVM training (
train), GPU DTW batch classify (gpu), boundary-CNN adapter detection (cnn-detect, CPU tract; optional onnxruntime CUDA viacnn-gpu). Depends onescapepod-signalfor DTW and fingerprint primitives. - escapepod-cli: the
escpodCLI binary (built by the defaultclifeature) plus an optional umbrella library (imported asescapepod_cli) that re-exportspod5/signal/demuxbehind feature flags fordefault-features = falseconsumers. Demux is part of the defaultclifeature (it adds no third-party crates — ndarray/serde_json/rayon/uuid are already in the graph);default-features = falseconsumers can still select it explicitly. Forward featurestrain,gpu,cnn-detect,cnn-gpupropagate to the demux crate and remain opt-in because they need extra toolchains or a CUDA runtime. - escapepod-python: pyo3 bindings.
POD5 is a container format wrapping Apache Arrow IPC (Feather v2) tables:
<POD5 signature>
<section marker>
<Signal table (Arrow IPC)><section marker>
<Run Info table (Arrow IPC)><section marker>
<Reads table (Arrow IPC)><section marker>
<FOOTER magic>
<FlatBuffer footer>
<footer length>
<section marker>
<POD5 signature>
- reader/file_reader.rs: Memory-mapped file reader using
memmap2. Opens POD5 files, parses the FlatBuffer footer, and provides iterators over reads and signal data. - writer/file_writer.rs: Buffered writer that constructs POD5 files. Handles signal chunking, VBZ compression, and batching of Arrow record batches.
- compression/: VBZ signal compression (SVB16 + ZSTD pipeline)
svb16/mod.rs: Scalar SVB16 encode/decode + runtime dispatch to SIMD.svb16/simd_ssse3.rs: SSSE3 encode/decode (~2× vs scalar on x86_64 w/ SSSE3).svb16/tables.rs:pshufbshuffle + length tables for both directions.vbz.rs: Full VBZ pipeline combining SVB16 with ZSTD compression
- footer.rs: Manual FlatBuffer parsing for the POD5 footer (locates embedded Arrow tables)
- schema/: Arrow schema definitions for reads, signal, and run_info tables
- types.rs: Core data types (
ReadData,RunInfoData,EndReason, etc.) - merge.rs: File merging operations with run info deduplication
- operations/: High-level file operations
filter.rs: Filter reads by criteria (ID list, sample count, end reason)repack.rs: Repack files with new compression settingssubset.rs: Split reads into multiple files by barcode or CSV mapping
- dtw/: Dynamic Time Warping distance computation
distance.rs: DTW algorithm with Sakoe-Chiba band constraintfingerprint.rs: Signal fingerprint representationkernel.rs: DTW distance to kernel conversion for SVMcuda/: GPU-accelerated DTW distance matrix (opt-ingpufeature)
- segmentation/: Signal segmentation algorithms
llr.rs: Log-Likelihood Ratio boundary detectionttest.rs: T-test based changepoint segmentation (scipy-compatible peak detection)normalize.rs: MAD, z-score, and min-max normalization
- resquiggle/: Signal-to-base alignment refinement (banded DP, rescaling, drift correction)
model.rs:WarpDemuxModelandDtwSvmModelJSON loaders.classify.rs: Per-read DTW classifier (classify_read), shared top-2 threshold logic, optional batched GPU classifier.svm.rs: Full SVM predictor — RBF kernel, OvO dual coefficients, Platt scaling, multiclass probability coupling, batched GPU classify.probability.rs: softmax / coupling utilities.train.rs(feature:train): SVM training via linfa-svm, optional GPU all-pairs DTW matrix.adapter_cnn.rs(feature:cnn-detect): runs a user-supplied boundary-CNN ONNX graph ([B,1,L] -> [B,2,L]) through tract-onnx at runtime, one read at a time (CPU). Architecture-agnostic — works with escapepod-models'adapter_rna004TCN (CC-BY) or an ADAPTedBoundariesCNNexport (CC BY-NC, NOT bundled). Sharedprep_adapter_signal/decode_adapter_end/group_by_lenhelpers + a batcheddetect_adapter_end_batch(exact-length grouping, no cross-read padding) back both the CPU and GPU paths bit-identically. Prep truncates the model input tosearch_window + receptive-field margin(ESCAPEPOD_CNN_MARGIN, default 256 ⇒ cap 806; output-preserving since the local graph can't reach past it) — this also collapses every read longer than the cap onto one length so the GPU can batch them. A load-time shape probe rejects a wrong-output-shape model up front instead of silently writingadapter_end=0.adapter_cnn_gpu.rs(feature:cnn-gpu, impliescnn-detect): the architecture-agnostic GPU path — same ONNX graph + same prep/decode, run batched through onnxruntime's CUDA execution provider via theortcrate (load-dynamic; needs a CUDA-enabledlibonnxruntimeonORT_DYLIB_PATH+ a visible GPU at run time, nothing at build time).escpod demux detect --method cnn --gpu. The TCN is inference-bound, not I/O-bound (LLR detect = 0.25 s/20k vs CNN = 77 s/20k; ~99.6% is inference), so GPU pays off — ~7.6× end-to-end on an A30 at 20k reads (grows at scale; isolated inference is ~99× tract).detect --gpuruns a dedicated GPU consumer thread (the ort/CUDA session builds while CPU producers decode+prep in parallel, overlapping init) fed prepped, length-bucketed blocks through a bounded channel (AdapterCnnGpu::detect_prepped). The fused pipeline (escpod demux --method cnn --gpu) also runs GPU detection: all producers (produce_cpu/produce_cpu_gbm/produce_gpu) detect viaDetector::detect_batchover windowed decode-once blocks (DETECT_WINDOW), so the GPU does one batched call per window while preserving the single-stream I/O (#72) — measured ~7.2× end-to-end (116 s→16.2 s, A30, 20k GBM) with 99.99% classification parity vs CPU detect. NOT the old arch-locked CUDA kernel (removed in #80, hardwired to BoundariesCNN); this runs any[B,1,L]->[B,2,L]graph. tract has no efficient batched conv, so CPU detection stays per-read.
view: Display reads as TSV with configurable columnsinspect: Show file metadata (summary, reads list, specific read)summary: Comprehensive summary with statisticsmerge: Combine multiple POD5 files (parallel reading with rayon)filter: Extract reads by ID list or criteria (sample count, end reason)bam-filter: Filter reads based on paired BAM file (mapped status, region, quality)repack: Repack files for optimized storagesubset: Split reads into multiple files based on CSV mappingresquiggle: Refine signal-to-base mapping using banded DP with POD5 signal and BAM move tables. Takes a k-mer level table via--kmer-table <path>or a named model via--kmer-model <name>(DNA + RNA;dna_r10.4.1_e8.2_400bps,rna004, …). Named models resolve from a local cache ($ESCAPEPOD_KMER_CACHE→$XDG_CACHE_HOME/escapepod/kmer_models→~/.cache/…) that is never populated at runtime — build with--features models-downloadand prefetch on a networked login node (escpod resquiggle models fetch --all) before submitting compute jobs (Alpine/Beevol compute nodes can't reach GitHub). Tables come from nanoporetech/kmer_models (MPL-2.0), pinned to a commit + sha256; the code path usesureq/rustls so the static-musl release stays OpenSSL-free.demux: Barcode demultiplexing workflow with subcommands:detect: LLR-based adapter boundary detectionfingerprint: T-test segmentation for barcode fingerprintsclassify: DTW-based barcode classificationsplit: Split reads by barcode into separate filestrain: Train reference fingerprints from known samplestrain-svm: Train SVM model (requirestrainfeature)
Block-level copying: For merge/filter operations, signal data is kept compressed (CompressedSignalChunk with Arc<[u8]>) to avoid decompression/recompression overhead. Use add_read_with_compressed_signal() instead of add_read() when copying between files.
Dictionary tracking: The writer maintains O(1) lookup for pore types and end reasons using HashMap indexes alongside Vec storage for Arrow dictionary encoding.
Run info deduplication: When merging files, run infos are deduplicated by acquisition_id to avoid redundant entries.
- arrow: Arrow IPC file reading/writing
- memmap2: Memory-mapped file I/O
- zstd: ZSTD compression for VBZ
- flatbuffers: FlatBuffer footer parsing
- uuid: Read ID handling
- csv: CSV parsing for filter IDs and barcode mappings
- byteorder, thiserror, tempfile, rayon
- escapepod-pod5: re-exported as
pod5plus the full type surface - ndarray: Array operations for signal processing
- rand, flate2: resquiggle internals
- serde/serde_json: JSON model serialization (demux)
- linfa/linfa-svm: SVM training (optional, requires
trainfeature)
- clap: CLI argument parsing
- rayon: Parallel iteration for merge operations
- tabled: Table formatting for CLI output
- noodles-bam/sam: BAM file support for bam-filter command
- walkdir: Directory traversal
Test POD5 files from Oxford Nanopore are in ext/nanopore-dna-data/pod5/. The ext/pod5-file-format/ directory contains the official POD5 specification.