diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d4652ef
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+target/
+fuzz/target/
+site/
+docs/
+benchmarks/
+logo/
+.git/
+.github/
+.cache/
+bindings/*/target/
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 8b2ad00..198e3c0 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -15,10 +15,12 @@ Thank you for your interest in contributing to **snpick**! We welcome contributi
- [Code of Conduct](#code-of-conduct)
## Project Description
-`snpick` is a tool designed to identify **Multi-Nucleotide Variants (MNVs)** within the same codon in genomic sequences. MNVs occur when multiple Single Nucleotide Variants (SNVs) are present within the same codon, leading to the translation of a different amino acid. This tool addresses limitations in current annotation programs like **ANNOVAR** or **SnpEff**, which are primarily designed to work with individual SNVs and might overlook the actual amino acid changes resulting from MNVs.
+`snpick` is a fast, memory-efficient tool for extracting variable (SNP) sites from whole-genome **FASTA alignments**. It produces reduced alignments ready for phylogenetic inference with ascertainment-bias correction (ASC) in **IQ-TREE** and **RAxML**, and can optionally emit a VCF file. Its zero-copy, memory-mapped architecture scales to thousands of genomes in seconds with minimal RAM, where matrix-in-memory tools such as **snp-sites** struggle.
-### Current Limitations
-**IMPORTANT**: This script currently works only with **SNVs** against a reference genome. Insertions and deletions that modify the reading frame are not supported yet.
+### Scope
+- Input is a **FASTA alignment**: every sequence must have the same length. The first sequence is used as the reference for `REF`/`ALT` polarization.
+- Standard bases `A/C/G/T` (case-insensitive) define variability; IUPAC ambiguous bases (N, R, Y, β¦) are treated as missing data rather than alleles.
+- Gaps (`-`) are ignored by default and can be included as a 5th character with `-g`.
## How to Contribute
We appreciate all contributions, whether itβs fixing bugs, proposing new features, improving the documentation, or suggesting a new direction for the tool.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 6baf038..e1230f6 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,4 +1,4 @@
-# Pull Request Template for get_MNV
+# Pull Request Template for SNPick
## Description
Please include a summary of the changes made in this pull request. Mention any related issues or features that are being addressed.
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..89881e5
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,40 @@
+name: Container
+
+on:
+ push:
+ tags: [ "[0-9]+.[0-9]+.[0-9]+" ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository_owner }}/snpick
+ tags: |
+ type=ref,event=tag
+ type=raw,value=latest
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..9479fcf
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,43 @@
+name: Docs
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'docs/**'
+ - 'overrides/**'
+ - 'mkdocs.yml'
+ - 'CHANGELOG.md'
+ - '.github/CONTRIBUTING.md'
+ - '.github/workflows/docs.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Full history so git-revision-date-localized can read commit dates.
+ fetch-depth: 0
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+ cache: pip
+
+ - name: Install Cairo/Pango (social cards)
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libcairo2-dev libfreetype6-dev libffi-dev \
+ libjpeg-dev libpng-dev libz-dev pngquant
+
+ - name: Install MkDocs Material and plugins
+ run: pip install -r docs/requirements.txt
+
+ - name: Build and deploy to gh-pages
+ # GitHub Actions sets CI=true, which enables the social-cards plugin.
+ run: mkdocs gh-deploy --force
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1e96c29..d4d7046 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -20,6 +20,7 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
+ skip: ${{ steps.tagcheck.outputs.skip }}
steps:
- uses: actions/checkout@v4
@@ -32,16 +33,17 @@ jobs:
echo "π¦ Version: $VERSION"
- name: Check if tag already exists
+ id: tagcheck
run: |
if git ls-remote --tags origin | grep -q "refs/tags/${{ steps.version.outputs.tag }}$"; then
echo "β οΈ Tag ${{ steps.version.outputs.tag }} already exists β skipping release."
- echo "SKIP=true" >> "$GITHUB_ENV"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
fi
# Build matrix for cross-platform binaries
build:
needs: release
- if: needs.release.outputs.version != ''
+ if: needs.release.outputs.version != '' && needs.release.outputs.skip != 'true'
strategy:
fail-fast: false
matrix:
@@ -94,6 +96,7 @@ jobs:
# Create GitHub release with all binaries
publish:
needs: [release, build]
+ if: needs.release.outputs.skip != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index 9fc0c18..0361062 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -2,7 +2,7 @@ name: Rust
on:
push:
- branches: [ "main", "1.0.1" ]
+ branches: [ "main", "1.0.2" ]
pull_request:
branches: [ "main" ]
@@ -10,19 +10,34 @@ env:
CARGO_TERM_COLOR: always
jobs:
- build:
- runs-on: ubuntu-latest
+ test:
+ name: Test (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ ubuntu-latest, ubuntu-24.04-arm, macos-14, macos-15-intel ]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
- - name: Clippy
- run: cargo clippy -- -D warnings
+ - name: Clippy
+ run: cargo clippy -- -D warnings
- - name: Build
- run: cargo build --verbose
+ - name: Test
+ run: cargo test --verbose
- - name: Test
- run: cargo test --verbose
+ - name: Build (release)
+ run: cargo build --release
- - name: Build (release)
- run: cargo build --release
+ msrv:
+ name: MSRV 1.74
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@1.74.0
+ - name: Build on the minimum supported Rust version
+ run: cargo build --verbose
diff --git a/.gitignore b/.gitignore
index e7035df..18f414b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,20 @@ test_*.fasta
test_*.vcf
*.vcf
!src/
+
+# MkDocs build output
+site/
+
+# MkDocs Material social-cards / plugin cache
+.cache/
+
+# cargo-fuzz
+fuzz/target/
+fuzz/corpus/
+fuzz/artifacts/
+fuzz/Cargo.lock
+
+# binding crates
+bindings/*/target/
+bindings/*/Cargo.lock
+bindings/wasm/pkg/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..7c78d3f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,82 @@
+# Changelog
+
+All notable changes to SNPick are documented here. The format is based on
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
+adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.2] - 2026-07-18
+
+### Added
+
+**Filtering & selection**
+- Per-site filtering: `--max-missing`, `--mac`, `--maf`, `--min-samples`,
+ `--max-alleles`. Filtered sites stay variable (never re-enter `fconst`), so ASC
+ correction remains valid.
+- `--keep-samples` / `--exclude-samples` (comma-separated IDs or `@file`) subset
+ the panel before the scan, so `fconst` is recomputed for the retained samples.
+- `--mask ` (and `--mask-ref`) excludes regions from both the output and `fconst`.
+
+**Coordinates & references**
+- `--reference ` chooses the REF/polarity sequence and the VCF `##reference`.
+- `--ref-coords` writes VCF `POS` as ungapped reference positions.
+- `--sites-output ` maps each site's alignment column to its reference position.
+
+**I/O & formats**
+- Transparent gzip/bgzip input, plus stdin (`-f -`) and stdout (`-o -`) streaming.
+- `--format {fasta,phylip,nexus}` for the reduced alignment.
+- `--stats-json ` (or `-`) writes a typed JSON run summary with the `fconst` array.
+
+**Robustness & QC**
+- `--dry-run` (stats only) and `--check` (composition audit) exit without writing.
+- `--on-invalid {ignore,warn,error}` guards against non-nucleotide input.
+- `--iupac-mode resolve` optionally resolves IUPAC codes to their bases.
+- Empty/duplicate sequence IDs are rejected (`--allow-dup-ids` to permit).
+- `-v`/`-vv` diagnostics; distinct exit codes (`2` bad input, `1` I/O).
+
+**Threading & VCF**
+- `-t, --threads ` pins the Rayon thread pool (deterministic wall-clock; output
+ is unaffected by thread count).
+- `--chrom ` sets the VCF `CHROM` / `##contig` name (default `1`).
+- `-q, --quiet` silences the `[snpick]` progress logs (errors still print).
+- A valid header-only VCF is written when `--vcf` is requested but there are no
+ variable sites, so pipelines declaring the `.vcf` output no longer break.
+
+**Packaging**
+- Published as a reusable Rust library crate, with Python (pyo3/maturin) and
+ WebAssembly bindings, and Docker/Apptainer container recipes.
+
+### Fixed
+- Silent SNP loss when a single-line FASTA record contained a blank line: such
+ records were read with misaligned byte offsets. They now use the
+ newline-skipping scanner.
+- Gap reference bases produced an invalid VCF `REF` of `-` under `--include-gaps`;
+ the `REF` is now rendered as `N` (a valid VCF base). ALT gaps stay `*` (the snp-sites
+ convention), so `REF` and `ALT` gap encodings differ by design.
+- All-gap columns were tallied in no category under `--include-gaps`, breaking the
+ reported `variable + constant + ambiguous == length` invariant.
+- Bare output filenames (`-o snps.fasta`, no directory) were rejected during path
+ resolution, breaking the documented quickstart.
+- The release workflow's "tag already exists" guard was never read, so merges
+ without a version bump re-published over existing tags.
+- The README's pre-built binary download command pointed at an asset name the
+ release never publishes (404).
+
+### Performance
+- VCF data rows are assembled in a reused byte buffer instead of one formatted
+ write per genotype field β a large speedup on many-sample inputs. Output is
+ byte-identical.
+
+## [1.0.1] - 2026-03-31
+
+- First Bioconda release.
+- Cross-platform Build & Release CI workflow (Linux/macOS, x86_64/aarch64).
+- README overhaul and benchmarks.
+
+## [1.0.0] - 2024-11-16
+
+- Initial release: zero-copy memory-mapped extraction of variable sites from
+ FASTA alignments, optional VCF v4.2 output, and ASC `fconst` reporting.
+
+[1.0.2]: https://github.com/PathoGenOmics-Lab/snpick/compare/1.0.1...1.0.2
+[1.0.1]: https://github.com/PathoGenOmics-Lab/snpick/compare/1.0.0...1.0.1
+[1.0.0]: https://github.com/PathoGenOmics-Lab/snpick/releases/tag/1.0.0
diff --git a/Cargo.lock b/Cargo.lock
index b413335..1242ac7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,27 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anes"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
+
[[package]]
name = "anstream"
version = "0.6.15"
@@ -51,6 +72,78 @@ dependencies = [
"windows-sys",
]
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
[[package]]
name = "clap"
version = "4.5.60"
@@ -97,6 +190,51 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "criterion"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
+dependencies = [
+ "anes",
+ "cast",
+ "ciborium",
+ "clap",
+ "criterion-plot",
+ "is-terminal",
+ "itertools",
+ "num-traits",
+ "once_cell",
+ "oorandom",
+ "plotters",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "tinytemplate",
+ "walkdir",
+]
+
+[[package]]
+name = "criterion-plot"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
+dependencies = [
+ "cast",
+ "itertools",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -122,30 +260,181 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "is-terminal"
+version = "0.4.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
[[package]]
name = "memmap2"
version = "0.9.10"
@@ -155,6 +444,80 @@ dependencies = [
"libc",
]
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "plotters"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
+dependencies = [
+ "num-traits",
+ "plotters-backend",
+ "plotters-svg",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
+
+[[package]]
+name = "plotters-svg"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
+dependencies = [
+ "plotters-backend",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.87"
@@ -164,15 +527,90 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "proptest"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bit-set",
+ "bit-vec",
+ "bitflags",
+ "num-traits",
+ "rand",
+ "rand_chacha",
+ "rand_xorshift",
+ "regex-syntax",
+ "rusty-fork",
+ "tempfile",
+ "unarray",
+]
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
[[package]]
name = "quote"
-version = "1.0.37"
+version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core",
+]
+
[[package]]
name = "rayon"
version = "1.11.0"
@@ -193,12 +631,139 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "rusty-fork"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
+dependencies = [
+ "fnv",
+ "quick-error",
+ "tempfile",
+ "wait-timeout",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
[[package]]
name = "snpick"
-version = "1.0.1"
+version = "1.0.2"
dependencies = [
"clap",
+ "criterion",
+ "flate2",
"memmap2",
+ "proptest",
"rayon",
]
@@ -219,6 +784,35 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "tinytemplate"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
[[package]]
name = "unicode-ident"
version = "1.0.13"
@@ -231,6 +825,98 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -303,3 +989,35 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index 257d500..3395b03 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,12 +1,48 @@
[package]
name = "snpick"
-version = "1.0.1"
+version = "1.0.2"
edition = "2021"
+rust-version = "1.74"
+authors = [
+ "Paula Ruiz-Rodriguez ",
+ "Mireia Coscolla",
+]
+description = "Fast, memory-efficient extraction of variable sites from FASTA alignments."
+license = "GPL-3.0-or-later"
+repository = "https://github.com/PathoGenOmics-Lab/snpick"
+homepage = "https://pathogenomics-lab.github.io/snpick/"
+documentation = "https://pathogenomics-lab.github.io/snpick/"
+readme = "README.md"
+keywords = ["bioinformatics", "phylogenetics", "snp", "fasta", "vcf"]
+categories = ["science", "command-line-utilities"]
+exclude = ["benchmarks/", "docs/", "logo/", "site/", ".github/"]
+
+[features]
+default = ["parallel", "mmap"]
+# Parallel scan via Rayon (disable to build for targets without threads, e.g. wasm).
+parallel = ["dep:rayon"]
+# Memory-mapped / gzip / stdin input (disable for wasm, which has no mmap).
+mmap = ["dep:memmap2"]
[dependencies]
clap = { version = "4.5.21", features = ["derive"] }
-memmap2 = "0.9.10"
-rayon = "1.11.0"
+memmap2 = { version = "0.9.10", optional = true }
+rayon = { version = "1.11.0", optional = true }
+# Pure-Rust (miniz_oxide) backend keeps the build C-toolchain-free for Bioconda.
+flate2 = "1.0"
+
+[[bin]]
+name = "snpick"
+path = "src/main.rs"
+required-features = ["parallel", "mmap"]
+
+[dev-dependencies]
+proptest = "1"
+criterion = "0.5"
+
+[[bench]]
+name = "scan"
+harness = false
[profile.release]
opt-level = 3
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a029dd1
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,21 @@
+# syntax=docker/dockerfile:1
+
+# ---- Build stage: static musl binary (snpick has no C dependencies) ----
+FROM rust:1-slim AS build
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends musl-tools \
+ && rm -rf /var/lib/apt/lists/*
+RUN rustup target add x86_64-unknown-linux-musl
+WORKDIR /src
+COPY . .
+RUN cargo build --release --target x86_64-unknown-linux-musl --bin snpick \
+ && strip target/x86_64-unknown-linux-musl/release/snpick
+
+# ---- Runtime stage: minimal scratch image ----
+FROM scratch
+LABEL org.opencontainers.image.title="snpick" \
+ org.opencontainers.image.description="Fast extraction of variable sites from FASTA alignments" \
+ org.opencontainers.image.source="https://github.com/PathoGenOmics-Lab/snpick" \
+ org.opencontainers.image.licenses="GPL-3.0-or-later"
+COPY --from=build /src/target/x86_64-unknown-linux-musl/release/snpick /usr/local/bin/snpick
+ENTRYPOINT ["/usr/local/bin/snpick"]
diff --git a/README.md b/README.md
index 905337a..a19186e 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,11 @@
[](https://anaconda.org/bioconda/snpick)
[](https://anaconda.org/bioconda/snpick)
[](http://bioconda.github.io/recipes/snpick/README.html)
+[](https://pathogenomics-lab.github.io/snpick/)
**Fast, memory-efficient extraction of variable sites from FASTA alignments.**
-[Quick Start](#-quick-start) Β· [Features](#-features) Β· [Usage](#-usage) Β· [Benchmarks](#-benchmarks) Β· [Citation](#-citation)
+**[π Documentation](https://pathogenomics-lab.github.io/snpick/)** Β· [Quick Start](#-quick-start) Β· [Features](#-features) Β· [Usage](#-usage) Β· [Benchmarks](#-benchmarks) Β· [Citation](#-citation) Β· [Changelog](CHANGELOG.md)
@@ -30,13 +31,15 @@ SNPick extracts variable (SNP) sites from whole-genome FASTA alignments. It prod
**Why not snp-sites?** snp-sites works well for small datasets but struggles with large alignments β it loads everything into memory and scales poorly. SNPick uses a zero-copy memory-mapped architecture that handles thousands of genomes in seconds with minimal RAM.
+> π **Full documentation β [pathogenomics-lab.github.io/snpick](https://pathogenomics-lab.github.io/snpick/)** β installation, the complete usage reference, output formats, benchmarks, architecture, and a hands-on [tutorial](https://pathogenomics-lab.github.io/snpick/tutorials/tutorial/).
+
### SNPick vs snp-sites
| | **SNPick** | **snp-sites** |
|---|---|---|
| Architecture | Zero-copy mmap, parallel scan | Full matrix in memory |
-| 250 seqs Γ 4.4 Mbp | **0.9 s**, 105 MB | 9.5 s, 520 MB |
-| 1000 seqs Γ 4.4 Mbp | **~3 s**, ~140 MB | >26 min (killed), 3+ GB |
+| 250 seqs Γ 4.4 Mbp | **1.72 s**, 105 MB | 9.38 s, 213 MB |
+| 1000 seqs Γ 4.4 Mbp | **10.27 s**, 217 MB | killed (OOM) |
| ASC fconst output | β Built-in | β Not supported |
| VCF output | β Optional | β Default |
| Gap handling | β Optional (`-g`) | β Default |
@@ -53,11 +56,18 @@ conda install -c bioconda snpick
# Extract variable sites
snpick -f alignment.fasta -o snps.fasta
-# With VCF output
-snpick -f alignment.fasta -o snps.fasta --vcf
+# With a VCF (reference-anchored POS) and a machine-readable summary
+snpick -f alignment.fasta.gz -o snps.fasta --vcf --chrom NC_000962.3 \
+ --ref-coords --stats-json stats.json
+
+# Drop singletons, keep biallelic sites, mask repetitive regions
+snpick -f alignment.fasta -o snps.fasta --mac 2 --max-alleles 2 --mask exclude.bed
-# Include gaps as informative
-snpick -f alignment.fasta -o snps.fasta -g
+# NEXUS output on 8 threads, quietly
+snpick -f alignment.fasta -o snps.nex --format nexus -t 8 -q
+
+# Just the statistics, no files written
+snpick -f alignment.fasta --dry-run --stats-json -
```
---
@@ -83,16 +93,54 @@ iqtree2 -s snps.fasta -m GTR+ASC -fconst 744123,1382922,1382180,743556
### VCF generation
-Optional VCF v4.2 output with per-sample genotypes. Reference allele taken from the first sequence. Ambiguous bases reported as missing (`.`).
+Optional VCF v4.2 output with per-sample genotypes. Reference allele taken from the first sequence. Ambiguous bases reported as missing (`.`). `POS` is the 1-based alignment column (not an ungapped reference coordinate) and `CHROM` defaults to `1` β set it with `--chrom` (e.g. `NC_000962.3`) to match your reference.
### IUPAC and gap handling
- **Ambiguous bases** (N, R, Y, etc.): not counted as alleles β positions are only variable if they have β₯2 standard bases (A, C, G, T)
-- **Gaps** (`-`): ignored by default, included as a 5th character with `-g`
+- **Gaps** (`-`): ignored by default, included as a 5th character with `-g`. In the VCF, gap alleles are written as `*` (an alignment-gap convention shared with snp-sites; note some downstream tools read `*` as a spanning deletion)
### Parallel processing
-Automatic multi-threaded scanning via Rayon when the dataset is large enough. Falls back to single-threaded for small inputs to avoid overhead.
+Automatic multi-threaded scanning via Rayon when the dataset is large enough. Falls back to single-threaded for small inputs to avoid overhead. Cap the thread count with `-t/--threads` (e.g. to match a SLURM allocation); the thread count never changes the output, only the wall-clock time.
+
+### Per-site filtering
+
+Drop low-quality or uninformative sites in the same pass β no round-trip through `vcftools`:
+
+- `--max-missing ` β maximum fraction of missing genotypes per site
+- `--mac ` / `--maf ` β minimum minor-allele count / frequency (drop singletons, sequencing-error alleles)
+- `--min-samples ` β minimum samples with data
+- `--max-alleles ` β e.g. `2` keeps only biallelic sites
+
+Filtered sites are **variable** sites removed from the output only β they never re-enter `fconst`, so ASC stays valid.
+
+### Sample selection
+
+`--keep-samples` / `--exclude-samples` (comma-separated IDs or `@file`) subset the panel **before** the scan, so `fconst` and site classification are recomputed for exactly the retained samples β a site variable only because of a dropped outlier correctly becomes constant.
+
+### Region masking & reference coordinates
+
+- `--mask ` excludes regions (PE/PPE, mobile elements, resistance genes) from both the output **and** `fconst`. `--mask-ref` reads the BED in reference coordinates.
+- `--reference ` chooses the REF/polarity sequence (and the VCF `##reference`).
+- `--ref-coords` writes VCF `POS` as ungapped reference positions; `--sites-output ` maps each site's alignment column β reference position β REF/ALT.
+
+### Output formats
+
+`--format {fasta,phylip,nexus}` β FASTA (default), relaxed PHYLIP (IQ-TREE / RAxML) or a NEXUS DATA block (MrBayes / PAUP* / SplitsTree).
+
+### Compressed & streaming I/O
+
+Reads plain or **gzip/bgzip** FASTA transparently, from a file or **stdin** (`-f -`); writes the reduced FASTA to a file or **stdout** (`-o -`) for piping.
+
+### Machine-readable output & QC
+
+- `--stats-json ` (or `-` for stdout) β a typed JSON summary (counts + the `fconst` array), so pipelines never scrape stderr.
+- `--dry-run` β report statistics without writing any output.
+- `--check` β audit the alignment composition (A/C/G/T, N, gap, IUPAC, invalid fractions) and exit.
+- `--on-invalid {ignore,warn,error}` β guard against non-nucleotide input (e.g. a protein alignment).
+- `--iupac-mode resolve` β optionally resolve IUPAC codes (R = A|G, β¦) to their bases when classifying.
+- `-v`/`-vv` β extra diagnostics and an allele-class histogram.
---
@@ -115,28 +163,84 @@ cargo build --release
# Binary at target/release/snpick
```
-### Pre-built binary (Linux)
+### Pre-built binary
+
+Grab the binary for your platform from the [latest release](https://github.com/PathoGenOmics-Lab/snpick/releases/latest) β Linux (`x86_64`, `aarch64`) and macOS (`x86_64`, `aarch64`), with `SHA256SUMS.txt` published for verification:
```bash
-wget https://github.com/PathoGenOmics-Lab/snpick/releases/latest/download/snpick
-chmod +x snpick
+# choose: snpick-linux-x86_64 | snpick-linux-aarch64 | snpick-macos-x86_64 | snpick-macos-aarch64
+curl -LO https://github.com/PathoGenOmics-Lab/snpick/releases/latest/download/snpick-linux-x86_64
+chmod +x snpick-linux-x86_64
+./snpick-linux-x86_64 --help
```
+### Container
+
+```bash
+docker run --rm -v "$PWD:/data" ghcr.io/pathogenomics-lab/snpick \
+ -f /data/alignment.fasta -o /data/snps.fasta
+```
+
+Also available as an [Apptainer/Singularity](snpick.def) image.
+
+### Library & bindings
+
+snpick is also a Rust **library crate** ([docs](https://pathogenomics-lab.github.io/snpick/)), with **Python** (`bindings/python`, via [maturin](https://www.maturin.rs)) and **WebAssembly** (`bindings/wasm`) bindings for use in notebooks, pipelines and the browser.
+
---
## ποΈ Usage
```
-snpick [OPTIONS] --fasta --output
-SNPick maintains **O(L)** memory regardless of sequence count, while snp-sites requires **O(NΓL)**.
+SNPick's memory grows far more slowly than snp-sites' **O(NΓL)** β β39 MB at 10 sequences up to β217 MB at 1000, versus snp-sites holding the full matrix in memory until it is killed on large inputs.
---
diff --git a/benches/scan.rs b/benches/scan.rs
new file mode 100644
index 0000000..beabc84
--- /dev/null
+++ b/benches/scan.rs
@@ -0,0 +1,32 @@
+//! Criterion micro-benchmark for the pass-1 bitmask scan. Advisory only β run locally with
+//! `cargo bench`; not a CI gate (shared runners produce noisy thresholds).
+
+use criterion::{black_box, criterion_group, criterion_main, Criterion};
+use snpick::fasta::index_fasta;
+use snpick::scan::pass1_scan;
+use snpick::types::build_lookup;
+
+fn make_alignment(nseq: usize, len: usize) -> Vec {
+ let mut fa = Vec::new();
+ for i in 0..nseq {
+ fa.extend_from_slice(format!(">s{}\n", i).as_bytes());
+ let mut seq = vec![b'A'; len];
+ seq[i % len] = b'C';
+ seq[(i * 7) % len] = b'G';
+ fa.extend_from_slice(&seq);
+ fa.push(b'\n');
+ }
+ fa
+}
+
+fn bench_scan(c: &mut Criterion) {
+ let fa = make_alignment(500, 20_000);
+ let lookup = build_lookup(false);
+ let (recs, sl, layout) = index_fasta(&fa).unwrap();
+ c.bench_function("pass1_scan_500x20000", |b| {
+ b.iter(|| pass1_scan(black_box(&fa), &recs, sl, layout, &lookup))
+ });
+}
+
+criterion_group!(benches, bench_scan);
+criterion_main!(benches);
diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml
new file mode 100644
index 0000000..2aad707
--- /dev/null
+++ b/bindings/python/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "snpick-python"
+version = "1.0.2"
+edition = "2021"
+publish = false
+description = "Python bindings for snpick"
+license = "GPL-3.0-or-later"
+
+# Own workspace so it does not affect the parent crate's build.
+[workspace]
+
+[lib]
+name = "snpick"
+crate-type = ["cdylib"]
+
+[dependencies]
+pyo3 = { version = "0.22", features = ["extension-module", "abi3-py38"] }
+snpick_core = { path = "../..", package = "snpick" }
diff --git a/bindings/python/README.md b/bindings/python/README.md
new file mode 100644
index 0000000..b059915
--- /dev/null
+++ b/bindings/python/README.md
@@ -0,0 +1,24 @@
+# snpick (Python bindings)
+
+Python bindings for [snpick](https://github.com/PathoGenOmics-Lab/snpick), built on the Rust
+library crate with [pyo3](https://pyo3.rs) and [maturin](https://www.maturin.rs).
+
+## Build
+
+```bash
+cd bindings/python
+pip install maturin
+maturin develop --release # or: maturin build --release
+```
+
+## Usage
+
+```python
+import snpick
+
+s = snpick.stats("alignment.fasta") # gzip and "-" (stdin) accepted
+print(s["variable_sites"], s["fconst"])
+```
+
+`stats(path, include_gaps=False)` returns a dict with `sequences`, `alignment_length`,
+`variable_sites`, `constant_sites`, `ambiguous_sites`, and the `fconst` tuple `(A, C, G, T)`.
diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml
new file mode 100644
index 0000000..177d038
--- /dev/null
+++ b/bindings/python/pyproject.toml
@@ -0,0 +1,24 @@
+[build-system]
+requires = ["maturin>=1.5,<2"]
+build-backend = "maturin"
+
+[project]
+name = "snpick"
+version = "1.0.2"
+description = "Fast extraction of variable sites from FASTA alignments"
+readme = "README.md"
+requires-python = ">=3.8"
+license = { text = "GPL-3.0-or-later" }
+authors = [{ name = "Paula Ruiz-Rodriguez" }]
+classifiers = [
+ "Programming Language :: Rust",
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
+]
+
+[project.urls]
+Homepage = "https://github.com/PathoGenOmics-Lab/snpick"
+Documentation = "https://pathogenomics-lab.github.io/snpick/"
+
+[tool.maturin]
+module-name = "snpick"
+features = ["pyo3/extension-module"]
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
new file mode 100644
index 0000000..8f98af7
--- /dev/null
+++ b/bindings/python/src/lib.rs
@@ -0,0 +1,51 @@
+//! Python bindings for snpick, built on the `snpick` library crate.
+//!
+//! Build a wheel with maturin:
+//! cd bindings/python && maturin develop --release
+//!
+//! Then in Python:
+//! >>> import snpick
+//! >>> snpick.stats("alignment.fasta")
+//! {'sequences': 250, 'alignment_length': 4411532, 'variable_sites': 158934, ...}
+
+use pyo3::exceptions::PyIOError;
+use pyo3::prelude::*;
+use pyo3::types::PyDict;
+
+use snpick_core::fasta::{get_ref_seq, index_fasta};
+use snpick_core::input::map_input;
+use snpick_core::scan::{analyze, pass1_scan};
+use snpick_core::types::build_lookup;
+
+/// Compute variable-site statistics for a FASTA alignment (gzip and '-' for stdin are accepted).
+///
+/// Returns a dict with `sequences`, `alignment_length`, `variable_sites`, `constant_sites`,
+/// `ambiguous_sites`, and the `fconst` tuple (A, C, G, T).
+#[pyfunction]
+#[pyo3(signature = (path, include_gaps = false))]
+fn stats(py: Python<'_>, path: &str, include_gaps: bool) -> PyResult> {
+ let input = map_input(path).map_err(|e| PyIOError::new_err(e.to_string()))?;
+ let data = &input.mmap[..];
+ let lookup = build_lookup(include_gaps);
+ let (records, seq_len, layout) =
+ index_fasta(data).map_err(|e| PyIOError::new_err(e.to_string()))?;
+ let bm = pass1_scan(data, &records, seq_len, layout, &lookup);
+ let rs = get_ref_seq(data, &records[0], seq_len, layout);
+ let (v, sc) = analyze(&bm, &rs, &lookup, include_gaps);
+
+ let d = PyDict::new_bound(py);
+ d.set_item("sequences", records.len())?;
+ d.set_item("alignment_length", seq_len)?;
+ d.set_item("variable_sites", v.len())?;
+ d.set_item("constant_sites", sc.constant.total())?;
+ d.set_item("ambiguous_sites", sc.ambiguous)?;
+ d.set_item("fconst", (sc.constant.a, sc.constant.c, sc.constant.g, sc.constant.t))?;
+ Ok(d.unbind())
+}
+
+#[pymodule]
+fn snpick(m: &Bound<'_, PyModule>) -> PyResult<()> {
+ m.add("__version__", env!("CARGO_PKG_VERSION"))?;
+ m.add_function(wrap_pyfunction!(stats, m)?)?;
+ Ok(())
+}
diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml
new file mode 100644
index 0000000..99d4323
--- /dev/null
+++ b/bindings/wasm/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "snpick-wasm"
+version = "1.0.2"
+edition = "2021"
+publish = false
+description = "WebAssembly bindings for snpick"
+license = "GPL-3.0-or-later"
+
+# Own workspace so it does not affect the parent crate's build.
+[workspace]
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+wasm-bindgen = "0.2"
+# The library builds without rayon/memmap for wasm (no threads, no mmap).
+snpick_core = { path = "../..", package = "snpick", default-features = false }
+
+[profile.release]
+opt-level = "s"
+lto = true
diff --git a/bindings/wasm/README.md b/bindings/wasm/README.md
new file mode 100644
index 0000000..a44c659
--- /dev/null
+++ b/bindings/wasm/README.md
@@ -0,0 +1,25 @@
+# snpick (WebAssembly bindings)
+
+Run snpick's variable-site classification in the browser or Node via WebAssembly.
+
+The `snpick` library is compiled with `default-features = false`, which drops rayon (WASM has no
+threads) and memmap (no mmap), using the sequential in-memory scan instead.
+
+## Build
+
+```bash
+cargo install wasm-pack
+cd bindings/wasm
+wasm-pack build --target web
+```
+
+## Usage
+
+```js
+import init, { variable_site_count, fconst } from "./pkg/snpick_wasm.js";
+
+await init();
+const fasta = new TextEncoder().encode(">a\nACGT\n>b\nACGA\n");
+console.log(variable_site_count(fasta, false)); // 1
+console.log(fconst(fasta, false)); // Uint32Array [1, 1, 1, 0]
+```
diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs
new file mode 100644
index 0000000..99b68b1
--- /dev/null
+++ b/bindings/wasm/src/lib.rs
@@ -0,0 +1,41 @@
+//! WebAssembly bindings for snpick. The `snpick` library is compiled with
+//! `default-features = false`, so it drops rayon (no threads) and memmap (no mmap) and runs the
+//! sequential in-memory scan β suitable for the browser / Node.
+//!
+//! Build with wasm-pack:
+//! cd bindings/wasm && wasm-pack build --target web
+
+use wasm_bindgen::prelude::*;
+
+use snpick_core::fasta::{get_ref_seq, index_fasta};
+use snpick_core::scan::{analyze, pass1_scan};
+use snpick_core::types::build_lookup;
+
+/// Count the variable (SNP) sites in an in-memory FASTA alignment. Returns 0 on parse error.
+#[wasm_bindgen]
+pub fn variable_site_count(fasta: &[u8], include_gaps: bool) -> usize {
+ let lookup = build_lookup(include_gaps);
+ let (records, seq_len, layout) = match index_fasta(fasta) {
+ Ok(x) => x,
+ Err(_) => return 0,
+ };
+ let bitmask = pass1_scan(fasta, &records, seq_len, layout, &lookup);
+ let ref_seq = get_ref_seq(fasta, &records[0], seq_len, layout);
+ let (variable, _) = analyze(&bitmask, &ref_seq, &lookup, include_gaps);
+ variable.len()
+}
+
+/// The four ASC `fconst` constant-site counts (A, C, G, T) for an in-memory FASTA alignment.
+#[wasm_bindgen]
+pub fn fconst(fasta: &[u8], include_gaps: bool) -> Vec {
+ let lookup = build_lookup(include_gaps);
+ let (records, seq_len, layout) = match index_fasta(fasta) {
+ Ok(x) => x,
+ Err(_) => return vec![0, 0, 0, 0],
+ };
+ let bitmask = pass1_scan(fasta, &records, seq_len, layout, &lookup);
+ let ref_seq = get_ref_seq(fasta, &records[0], seq_len, layout);
+ let (_, counts) = analyze(&bitmask, &ref_seq, &lookup, include_gaps);
+ let c = &counts.constant;
+ vec![c.a as u32, c.c as u32, c.g as u32, c.t as u32]
+}
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..5732a3a
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,72 @@
+---
+tags:
+ - internals
+---
+
+# Architecture
+
+```mermaid
+flowchart LR
+ A([Input FASTA]) -->|mmap once| B[Index records]
+ B --> C[Pass 1: bitmask scan]
+ C -->|parallel Β· OR-merge| D[Analyze / classify]
+ D --> E{Variable columns}
+ B --> F[Pass 2: sparse extract]
+ E --> F
+ F --> G([Reduced FASTA])
+ F --> H([VCF v4.2])
+```
+
+SNPick memory-maps the input once and shares it, read-only, across two passes β the **full
+alignment matrix is never materialized in memory**; sequence bytes are read straight from the
+shared mmap. (Compressed or piped input is first spooled to a temp file so it can be mapped, and
+the single reference sequence is copied out once for polarity β both O(L), not O(NΓL).)
+
+## Indexing
+
+The FASTA is scanned once to record, for each sequence, the byte offset of its data and its
+length as zero-copy slices into the mmap. All sequences must have the same length (an alignment
+requirement); a mismatch is a hard error. The indexer detects whether the file is single-line or
+wrapped (multi-line) so later passes can pick the fastest access strategy.
+
+## Pass 1 β bitmask scan
+
+Each position accumulates a 5-bit mask (`A`, `C`, `G`, `T`, and gap) by OR-ing every sequence's
+base at that column. Once built, each column is classified:
+
+- **variable** β more than one allele present,
+- **constant** β exactly one standard base,
+- **ambiguous-only** β no standard base (all N/gap without `-g`).
+
+For large inputs the scan runs in parallel: records are split into disjoint chunks, each thread
+fills its own bitmask, and the partials are merged with OR. Because OR is commutative and
+associative, the parallel result is **byte-for-byte identical** to a sequential scan β thread
+count never affects output. Small inputs fall back to a single-threaded scan to avoid overhead.
+
+Before the hot loop, SNPick prefaults the mapped pages and hints the OS that access is sequential,
+eliminating soft page faults on multi-gigabyte files.
+
+## Pass 2 β sparse extraction
+
+Only the variable columns are read back, via sparse random access into the mmap, and written to
+the reduced FASTA. When a VCF is requested the same pass fills a compact genotype matrix, which
+is then emitted as VCF rows assembled in a reused byte buffer (avoiding a formatted write per
+genotype).
+
+## Design notes
+
+- **Lookup tables** β 256-byte arrays give O(1) nucleotide classification and case conversion.
+- **Auto-vectorized scan** β the single-line hot loop uses a branchless A/C/G/T(+gap) kernel that
+ LLVM vectorizes (SSE2/AVX2/NEON); a test asserts it is byte-identical to the table lookup.
+- **Zero-copy** β IDs, descriptions, and sequence bytes are slices into the single mmap.
+- **O(L) memory (reduced-alignment path)** β for FASTA/PHYLIP/NEXUS output the working set scales
+ with alignment length, not the number of sequences, which is what lets SNPick handle thousands
+ of genomes with a small footprint. Requesting a VCF (`--vcf`) additionally holds a genotype
+ matrix of `variable_sites Γ samples` bytes (so it grows with sequence count, up to O(LΓN)),
+ bounded by a 4 GB guard above which SNPick errors instead of allocating.
+
+The core is a **library crate** (`snpick`) of focused modules β `fasta` (indexing), `scan`
+(pass 1 + classification), `extract` (pass 2), `vcf`, `filter` (per-site counting), `coords`
+(reference coordinates & BED masking), `audit` (composition), `input` (gzip/stdin) and `types` β
+with the CLI as a thin binary on top. `rayon` and `memmap2` sit behind default-on `parallel` and
+`mmap` features, so the library also builds for `wasm32`.
diff --git a/docs/assets/benchmark.png b/docs/assets/benchmark.png
new file mode 100644
index 0000000..0834b60
Binary files /dev/null and b/docs/assets/benchmark.png differ
diff --git a/docs/assets/benchmark_length.png b/docs/assets/benchmark_length.png
new file mode 100644
index 0000000..a48e7ce
Binary files /dev/null and b/docs/assets/benchmark_length.png differ
diff --git a/docs/assets/logo.png b/docs/assets/logo.png
new file mode 100644
index 0000000..20af2c8
Binary files /dev/null and b/docs/assets/logo.png differ
diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg
new file mode 100644
index 0000000..c1ac613
--- /dev/null
+++ b/docs/assets/logo.svg
@@ -0,0 +1,75 @@
+
+
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
new file mode 100644
index 0000000..f58183e
--- /dev/null
+++ b/docs/benchmarks.md
@@ -0,0 +1,36 @@
+---
+tags:
+ - performance
+---
+
+# Benchmarks
+
+Benchmarks use simulated *M. tuberculosis*-like genomes (4.4 Mbp, ~65% GC, 3.6% variable sites).
+The figures below are the committed run in
+[`benchmarks/results.tsv`](https://github.com/PathoGenOmics-Lab/snpick/blob/main/benchmarks/results.tsv);
+absolute wall-clock and peak memory depend on the host CPU and thread count.
+
+## Scaling by number of sequences
+
+
+
+
+
+## Scaling by sequence length
+
+
+
+
+
+SNPick's memory grows far more slowly than snp-sites' **O(N Γ L)**: snp-sites holds the full
+matrix in memory and is eventually killed on large inputs, while SNPick's footprint rises only
+gently with sequence count (β39 MB at 10 sequences up to β217 MB at 1000).
+
+| Dataset | SNPick | snp-sites |
+|---|---|---|
+| 250 seqs Γ 4.4 Mbp | **1.72 s**, 105 MB | 9.38 s, 213 MB |
+| 1000 seqs Γ 4.4 Mbp | **10.27 s**, 217 MB | killed (OOM) |
+
+!!! tip "Reproducing"
+ Wall-clock depends on core count; pin it with [`--threads`](usage.md#core)
+ for comparable runs. The extracted sites and VCF are identical regardless of thread count.
diff --git a/docs/changelog.md b/docs/changelog.md
new file mode 100644
index 0000000..786b75d
--- /dev/null
+++ b/docs/changelog.md
@@ -0,0 +1 @@
+--8<-- "CHANGELOG.md"
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..0c7bc9c
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1 @@
+--8<-- ".github/CONTRIBUTING.md"
diff --git a/docs/includes/abbreviations.md b/docs/includes/abbreviations.md
new file mode 100644
index 0000000..9469645
--- /dev/null
+++ b/docs/includes/abbreviations.md
@@ -0,0 +1,16 @@
+*[SNP]: Single-Nucleotide Polymorphism
+*[SNPs]: Single-Nucleotide Polymorphisms
+*[SNV]: Single-Nucleotide Variant
+*[ASC]: Ascertainment-bias Correction
+*[VCF]: Variant Call Format
+*[WGA]: Whole-Genome Alignment
+*[IUPAC]: International Union of Pure and Applied Chemistry β nucleotide ambiguity codes
+*[mmap]: memory-mapped file
+*[HPC]: High-Performance Computing
+*[SLURM]: Simple Linux Utility for Resource Management (job scheduler)
+*[LTO]: Link-Time Optimization
+*[CLI]: Command-Line Interface
+*[REF]: Reference allele (VCF column)
+*[ALT]: Alternate allele(s) (VCF column)
+*[GT]: Genotype (VCF FORMAT field)
+*[NS]: Number of Samples with data (VCF INFO field)
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..4848eca
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,115 @@
+---
+tags:
+ - overview
+---
+
+# SNPick
+
+
+
+**Fast, memory-efficient extraction of variable sites from FASTA alignments.**
+
+[Get started :material-rocket-launch:](installation.md){ .md-button .md-button--primary }
+[Usage reference :material-console:](usage.md){ .md-button }
+[View on GitHub :fontawesome-brands-github:](https://github.com/PathoGenOmics-Lab/snpick){ .md-button }
+
+SNPick extracts variable (SNP) sites from whole-genome FASTA alignments. It produces reduced
+alignments ready for phylogenetic inference with ascertainment-bias correction (ASC) in
+**IQ-TREE** and **RAxML**, and optionally generates VCF files.
+
+!!! question "Why not snp-sites?"
+ snp-sites works well for small datasets but struggles with large alignments β it loads the
+ whole matrix into memory and scales poorly. SNPick uses a zero-copy, memory-mapped
+ architecture that handles thousands of genomes in seconds with minimal RAM.
+
+## What you get
+
+
+
+- :material-dna:{ .lg .middle } __Variable sites only__
+
+ ---
+
+ A reduced FASTA with just the informative columns β a drop-in, much smaller input for
+ phylogenetics.
+
+- :material-tree:{ .lg .middle } __ASC-ready__
+
+ ---
+
+ Constant-site counts (`fconst`) printed for IQ-TREE's `+ASC` models, so branch lengths stay
+ unbiased.
+
+- :material-file-table:{ .lg .middle } __Optional VCF__
+
+ ---
+
+ VCF v4.2 with per-sample genotypes, a configurable contig name, alignment-column `POS`
+ (or ungapped reference coordinates with `--ref-coords`), and gap/ambiguity handling.
+
+- :material-filter:{ .lg .middle } __Filter & mask__
+
+ ---
+
+ Per-site missingness / MAC / MAF / allele filters, BED region masking and sample
+ selection β without breaking `fconst`.
+
+- :material-pipe:{ .lg .middle } __Pipeline-native__
+
+ ---
+
+ gzip & stdin/stdout streaming, PHYLIP/NEXUS output, and a `--stats-json` sidecar so nothing
+ scrapes stderr.
+
+- :material-lightning-bolt:{ .lg .middle } __Built for scale__
+
+ ---
+
+ Zero-copy mmap + parallel, auto-vectorized scan: **O(L)** memory, thousands of genomes in
+ seconds.
+
+