From 456d2fec6936ff9db635a4ad41cdddc8e8dffd26 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:46:55 +0200 Subject: [PATCH 01/62] fix: force multi-line scan path for records with blank lines A blank line after the header in an otherwise single-line file left single_line=true while seq_offset pointed at the newline, so the scan read misaligned bases and silently dropped SNPs. Detect any blank line in a record and route it through the newline-skipping scanner. --- src/fasta.rs | 8 +++++++- src/main.rs | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/fasta.rs b/src/fasta.rs index 08f21fd..8813b88 100644 --- a/src/fasta.rs +++ b/src/fasta.rs @@ -57,6 +57,7 @@ pub fn index_fasta(data: &[u8]) -> io::Result<(Vec>, usize, SeqL // Count bases let mut seq_len = 0usize; let mut line_count = 0usize; + let mut has_blank_line = false; while pos < len && data[pos] != b'>' { let line_start = pos; while pos < len && data[pos] != b'\n' && data[pos] != b'\r' { pos += 1; } @@ -64,12 +65,17 @@ pub fn index_fasta(data: &[u8]) -> io::Result<(Vec>, usize, SeqL if line_len > 0 { seq_len += line_len; line_count += 1; + } else { + has_blank_line = true; } if pos < len && data[pos] == b'\r' { pos += 1; } if pos < len && data[pos] == b'\n' { pos += 1; } } - if line_count > 1 { is_single_line = false; } + // A blank line inside the sequence shifts byte offsets, so the single-line + // fast path (data[seq_offset + pos]) would read misaligned bases. Route such + // records through the newline-skipping scanner instead. + if line_count > 1 || has_blank_line { is_single_line = false; } if records.is_empty() { if seq_len == 0 { diff --git a/src/main.rs b/src/main.rs index f1bb6cd..82b8fa6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -447,6 +447,30 @@ mod tests { std::fs::remove_file(&p).ok(); std::fs::remove_file(o).ok(); } + #[test] fn test_single_line_blank_line() { + // Blank line after the header in an otherwise single-line file: must not + // drop the SNP. The record is forced onto the newline-skipping path. + let p = tmp("blank", ">s1\n\nAAAA\n>s2\n\nAAAT\n"); + let o = "/tmp/snpick_t_blank_out.fa"; + let m = setup(&p); + let lk = build_lookup(false); + let up = build_upper(); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + assert_eq!(sl, 4); + assert!(!layout.single_line); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (mut v, _) = analyze(&bm, &rs, &lk, false); + assert_eq!(v.len(), 1); + assert_eq!(v[0].index, 3); + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + pass2_extract(&m, &mut v, &ep).unwrap(); + let c = std::fs::read_to_string(o).unwrap(); + let l: Vec<&str> = c.lines().collect(); + assert_eq!(l[1], "A"); assert_eq!(l[3], "T"); + std::fs::remove_file(&p).ok(); std::fs::remove_file(o).ok(); + } + #[test] fn test_single_sequence() { // Single sequence should produce 0 variable sites let p = tmp("sing", ">s1\nATGC\n"); From 505fd4b7e13aedb4aa4165e205f1ab0144ccdea3 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:48:35 +0200 Subject: [PATCH 02/62] fix(vcf): render gap reference as '*' instead of a bare '-' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under --include-gaps, a gap in the reference at a variable site wrote a literal '-' into the REF column, which is invalid VCF v4.2 (REF must be A/C/G/T/N) and inconsistent with the '-'→'*' mapping already used for ALT. Map REF the same way and add gap-in-REF / gap-in-ALT VCF tests. --- src/main.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/vcf.rs | 5 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 82b8fa6..ac77f60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -287,6 +287,54 @@ mod tests { std::fs::remove_file(&p).ok(); std::fs::remove_file(fo).ok(); std::fs::remove_file(vo).ok(); } + #[test] fn test_vcf_gap_ref() { + // Gap in the reference at a variable site: REF must be '*' (valid VCF v4.2), + // never a bare '-'. + let p = tmp("vgref", ">ref\nA-GC\n>s1\nATGC\n>s2\nA-GT\n"); + let fo = "/tmp/snpick_t_vgref_out.fa"; let vo = "/tmp/snpick_t_vgref.vcf"; + let m = setup(&p); + let lk = build_lookup(true); + let up = build_upper(); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (mut v, _) = analyze(&bm, &rs, &lk, true); + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl).unwrap(); + let c = std::fs::read_to_string(vo).unwrap(); + let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); + let f: Vec<&str> = dl[0].split('\t').collect(); + assert_eq!(f[1], "2"); // POS (gap-in-ref site) + assert_eq!(f[3], "*"); // REF: gap → '*', not '-' + assert_eq!(f[4], "T"); // ALT + assert!(!c.contains("\t-\t")); // no bare hyphen field anywhere + std::fs::remove_file(&p).ok(); std::fs::remove_file(fo).ok(); std::fs::remove_file(vo).ok(); + } + + #[test] fn test_vcf_gap_alt() { + // Gap sample at a multi-allelic site renders as the '*' ALT allele. + let p = tmp("vgalt", ">ref\nATGC\n>s1\nTTGC\n>s2\n-TGC\n"); + let fo = "/tmp/snpick_t_vgalt_out.fa"; let vo = "/tmp/snpick_t_vgalt.vcf"; + let m = setup(&p); + let lk = build_lookup(true); + let up = build_upper(); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (mut v, _) = analyze(&bm, &rs, &lk, true); + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl).unwrap(); + let c = std::fs::read_to_string(vo).unwrap(); + let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); + let f: Vec<&str> = dl[0].split('\t').collect(); + assert_eq!(f[1], "1"); // POS + assert_eq!(f[3], "A"); // REF + assert_eq!(f[4], "T,*"); // ALT: gap sample → '*' + std::fs::remove_file(&p).ok(); std::fs::remove_file(fo).ok(); std::fs::remove_file(vo).ok(); + } + #[test] fn test_desc_preserved() { let p = tmp("descg", ">s1 some description\nATGC\n>s2 another desc\nATCC\n"); let o = "/tmp/snpick_t_descg_out.fa"; diff --git a/src/vcf.rs b/src/vcf.rs index e69b877..f355d02 100644 --- a/src/vcf.rs +++ b/src/vcf.rs @@ -45,8 +45,11 @@ pub fn write_vcf( lut[ab as usize] = (i + 1) as u8; } + // A gap reference renders as '*' (VCF v4.2 REF must be A/C/G/T/N, never '-'), + // consistent with the '-'→'*' mapping applied to ALT above. + let ref_char = if vp.ref_base == b'-' { '*' } else { vp.ref_base as char }; write!(w, "1\t{}\t.\t{}\t{}\t.\tPASS\tNS={}\tGT", - vp.index + 1, vp.ref_base as char, alt, vp.ns)?; + vp.index + 1, ref_char, alt, vp.ns)?; let row = vi * num_samples; for si in 0..num_samples { From 37bbf0c48401b139fdb78510ac0308887a549d38 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:49:05 +0200 Subject: [PATCH 03/62] fix: count all-gap columns as ambiguous under --include-gaps An all-gap column ORs to BIT_GAP only, so ones==1 fell through the A/C/G/T tests without being tallied, breaking the reported variable+constant+ambiguous == length invariant. Extracted sites and fconst are unaffected; this corrects the stderr summary. --- src/main.rs | 17 +++++++++++++++++ src/scan.rs | 1 + 2 files changed, 18 insertions(+) diff --git a/src/main.rs b/src/main.rs index ac77f60..c2bed69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -369,6 +369,23 @@ mod tests { std::fs::remove_file(&p).ok(); } + #[test] fn test_all_gap_column_counted() { + // Under --include-gaps an all-gap column is tallied as ambiguous, so + // variable + constant + ambiguous still equals seq_length. + let p = tmp("allgap", ">s1\nA-\n>s2\nA-\n"); + let m = setup(&p); + let lk = build_lookup(true); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (v, sc) = analyze(&bm, &rs, &lk, true); + assert_eq!(v.len(), 0); + assert_eq!(sc.constant.total(), 1); + assert_eq!(sc.ambiguous, 1); + assert_eq!(sc.variable + sc.constant.total() + sc.ambiguous, sl); + std::fs::remove_file(&p).ok(); + } + #[test] fn test_paths() { let p = tmp("pdg", ">x\nA\n"); assert!(check_paths_differ(&p, "/tmp/snpick_t_pdg2.fa").is_ok()); diff --git a/src/scan.rs b/src/scan.rs index 2e18df9..f04044b 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -118,6 +118,7 @@ pub fn analyze( else if bits & BIT_C != 0 { cs.c += 1; } else if bits & BIT_G != 0 { cs.g += 1; } else if bits & BIT_T != 0 { cs.t += 1; } + else { ambiguous += 1; } // all-gap column (BIT_GAP only) under --include-gaps } else { ambiguous += 1; } From 554f2b90e8730b8c444baeeea27cfede6e84885f Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:49:37 +0200 Subject: [PATCH 04/62] fix: accept bare output filenames without a directory component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path::parent() of a directory-less name returns Some(""), not None, so the '.' fallback never fired and canonicalize failed with ENOENT — the documented 'snpick -f in.fa -o snps.fasta' quickstart errored on every run. Treat an empty parent as the current directory. --- src/main.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index c2bed69..32d910e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,12 @@ fn resolve_path(p: &str) -> io::Result { if path.exists() { return std::fs::canonicalize(path); } - let parent = path.parent().unwrap_or(Path::new(".")); + // A bare filename has an *empty* parent (Some("")), not None, so canonicalize + // would fail with ENOENT. Treat that as the current directory. + let parent = match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p, + _ => Path::new("."), + }; let parent_abs = std::fs::canonicalize(parent).map_err(|e| { io::Error::new(e.kind(), format!("Cannot resolve parent of '{}': {}", p, e)) })?; @@ -390,6 +395,8 @@ mod tests { let p = tmp("pdg", ">x\nA\n"); assert!(check_paths_differ(&p, "/tmp/snpick_t_pdg2.fa").is_ok()); assert!(check_paths_differ(&p, &p).is_err()); + // A bare (directory-less) output name must resolve to the cwd, not error. + assert!(check_paths_differ(&p, "snpick_t_pdg_bare_out.fa").is_ok()); std::fs::remove_file(&p).ok(); } From b1e17a8d8d19f1453b1fb7fb7edd100d4bb50865 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:51:41 +0200 Subject: [PATCH 05/62] perf(vcf): assemble each data row in a reused byte buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The genotype loop did one formatted write!() per field — num_var × num_samples formatted writes, the dominant cost of the VCF path on many-sample inputs. Build each row into a reused Vec with direct byte pushes (genotype index is always a single digit 0-4) and one write_all per row. Output is byte-identical; also raise the VCF writer buffer to IO_BUF. --- src/vcf.rs | 47 +++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/vcf.rs b/src/vcf.rs index f355d02..f1edf7a 100644 --- a/src/vcf.rs +++ b/src/vcf.rs @@ -7,7 +7,7 @@ use std::fs::File; use std::io::{self, BufWriter, Write}; use crate::fasta::FastaRecord; -use crate::types::VariablePosition; +use crate::types::{VariablePosition, IO_BUF}; /// Write VCF output from genotype matrix and variable positions. pub fn write_vcf( @@ -16,7 +16,7 @@ pub fn write_vcf( ) -> io::Result<()> { let out = File::create(vcf_path).map_err(|e| io::Error::new(e.kind(), format!("Cannot create VCF '{}': {}", vcf_path, e)))?; - let mut w = BufWriter::with_capacity(4 * 1024 * 1024, out); + let mut w = BufWriter::with_capacity(IO_BUF, out); // Header writeln!(w, "##fileformat=VCFv4.2")?; @@ -32,33 +32,48 @@ pub fn write_vcf( } writeln!(w)?; - // Data rows + // Data rows. Each row is assembled in a reused byte buffer, so the + // num_var × num_samples genotypes are single byte pushes rather than one + // formatted write!() per field. Output bytes are identical to the naive form. let mut lut = [255u8; 256]; + let mut row: Vec = Vec::with_capacity(64 + num_samples * 2); + let mut alt: Vec = Vec::with_capacity(8); for (vi, vp) in var_positions.iter().enumerate() { - let alt: String = vp.alt_bases.iter() - .map(|&b| if b == b'-' { "*".to_string() } else { (b as char).to_string() }) - .collect::>().join(","); + // ALT alleles, gap → '*'. + alt.clear(); + for (i, &b) in vp.alt_bases.iter().enumerate() { + if i > 0 { alt.push(b','); } + alt.push(if b == b'-' { b'*' } else { b }); + } - // Build allele → index LUT for this position + // Build allele → index LUT for this position. lut[vp.ref_base as usize] = 0; for (i, &ab) in vp.alt_bases.iter().enumerate() { lut[ab as usize] = (i + 1) as u8; } // A gap reference renders as '*' (VCF v4.2 REF must be A/C/G/T/N, never '-'), - // consistent with the '-'→'*' mapping applied to ALT above. - let ref_char = if vp.ref_base == b'-' { '*' } else { vp.ref_base as char }; - write!(w, "1\t{}\t.\t{}\t{}\t.\tPASS\tNS={}\tGT", - vp.index + 1, ref_char, alt, vp.ns)?; + // consistent with the '-'→'*' mapping applied to ALT. + let ref_byte = if vp.ref_base == b'-' { b'*' } else { vp.ref_base }; + + row.clear(); + write!(row, "1\t{}\t.\t", vp.index + 1)?; + row.push(ref_byte); + row.push(b'\t'); + row.extend_from_slice(&alt); + write!(row, "\t.\tPASS\tNS={}\tGT", vp.ns)?; - let row = vi * num_samples; + let base = vi * num_samples; for si in 0..num_samples { - let idx = lut[vcf_geno[row + si] as usize]; - if idx == 255 { write!(w, "\t.")?; } else { write!(w, "\t{}", idx)?; } + row.push(b'\t'); + let idx = lut[vcf_geno[base + si] as usize]; + // idx is 0..=4 (ref + at most 4 alts), so a single ASCII digit. + if idx == 255 { row.push(b'.'); } else { row.push(b'0' + idx); } } - writeln!(w)?; + row.push(b'\n'); + w.write_all(&row)?; - // Reset LUT entries + // Reset LUT entries. lut[vp.ref_base as usize] = 255; for &ab in &vp.alt_bases { lut[ab as usize] = 255; } } From f8fe607d70110eb4741482dec2541215e6936f11 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:53:16 +0200 Subject: [PATCH 06/62] feat(cli): add --threads to pin the Rayon thread pool Without it Rayon grabs every logical core, which oversubscribes shared HPC/SLURM nodes and makes wall-clock non-deterministic. -t/--threads caps the pool; rejects 0. The scan merges with a commutative OR, so thread count never affects the emitted FASTA/VCF, only speed. --- README.md | 3 ++- src/main.rs | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 905337a..43af552 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Optional VCF v4.2 output with per-sample genotypes. Reference allele taken from ### 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. --- @@ -137,6 +137,7 @@ snpick [OPTIONS] --fasta --output | `-g, --include-gaps` | | Treat gaps (`-`) as a 5th character | | `--vcf` | | Generate VCF file (derived from output name) | | `--vcf-output ` | | Custom VCF output path | +| `-t, --threads ` | | Threads for the parallel scan (default: all cores) | ### Example diff --git a/src/main.rs b/src/main.rs index 32d910e..5bc7142 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,19 @@ struct Args { #[arg(short = 'g', long)] include_gaps: bool, #[arg(long)] vcf: bool, #[arg(long)] vcf_output: Option, + /// Number of threads for the parallel scan (default: all logical cores). + #[arg(short = 't', long, value_parser = parse_threads)] + threads: Option, +} + +/// Parse a positive thread count (Rayon treats 0 as "use default", which would +/// be a confusing silent no-op, so reject it explicitly). +fn parse_threads(s: &str) -> Result { + let n: usize = s.parse().map_err(|_| format!("'{}' is not a valid thread count", s))?; + if n == 0 { + return Err("thread count must be at least 1".to_string()); + } + Ok(n) } // ============================================================================= @@ -73,6 +86,15 @@ fn check_paths_differ(a: &str, b: &str) -> io::Result<()> { fn run() -> io::Result<()> { let args = Args::parse(); + + // Configure the Rayon pool up front. Absent, Rayon uses all logical cores; + // pinning it keeps wall-clock deterministic on shared HPC/SLURM nodes. + if let Some(n) = args.threads { + rayon::ThreadPoolBuilder::new().num_threads(n).build_global().map_err(|e| { + io::Error::other(format!("Cannot configure {} thread(s): {}", n, e)) + })?; + } + let start = Instant::now(); let lookup = build_lookup(args.include_gaps); let upper = build_upper(); From 01723cf57ebcd34f5849268a93a4150156ff54d4 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:55:19 +0200 Subject: [PATCH 07/62] feat(vcf): add --chrom to set the CHROM / contig name The VCF contig was hardcoded to '1', forcing users to post-process every file to rename it to their reference (e.g. NC_000962.3). Make it a flag defaulting to '1' so existing output is unchanged, and reject values with whitespace that would break the tab-delimited columns. --- README.md | 3 ++- src/main.rs | 21 +++++++++++++++------ src/vcf.rs | 6 +++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 43af552..83e7931 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ 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 @@ -138,6 +138,7 @@ snpick [OPTIONS] --fasta --output | `--vcf` | | Generate VCF file (derived from output name) | | `--vcf-output ` | | Custom VCF output path | | `-t, --threads ` | | Threads for the parallel scan (default: all cores) | +| `--chrom ` | | CHROM / contig name in the VCF (default: `1`) | ### Example diff --git a/src/main.rs b/src/main.rs index 5bc7142..3c0f341 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,9 @@ struct Args { /// Number of threads for the parallel scan (default: all logical cores). #[arg(short = 't', long, value_parser = parse_threads)] threads: Option, + /// CHROM / contig name written to the VCF (e.g. NC_000962.3). + #[arg(long, default_value = "1")] + chrom: String, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -101,6 +104,12 @@ fn run() -> io::Result<()> { let do_vcf = args.vcf || args.vcf_output.is_some(); + // A CHROM with whitespace would break the tab-delimited VCF columns. + if do_vcf && (args.chrom.is_empty() || args.chrom.bytes().any(|b| b.is_ascii_whitespace())) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, + "--chrom must be non-empty and contain no whitespace.")); + } + // Validate paths check_paths_differ(&args.fasta, &args.output)?; let vcf_path = if do_vcf { @@ -189,7 +198,7 @@ fn run() -> io::Result<()> { // Write VCF if let (Some(ref geno), Some(ref vp)) = (&vcf_geno, &vcf_path) { - write_vcf(geno, num_samples, &var_positions, vp, &records, seq_length)?; + write_vcf(geno, num_samples, &var_positions, vp, &records, seq_length, &args.chrom)?; eprintln!("[snpick] VCF written to {}.", vp); } @@ -285,7 +294,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl).unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -306,7 +315,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl).unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -328,7 +337,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, true); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl).unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -352,7 +361,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, true); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl).unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -467,7 +476,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl).unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); let c = std::fs::read_to_string(fo).unwrap(); let l: Vec<&str> = c.lines().collect(); assert_eq!(l[1], "AG"); assert_eq!(l[3], "AC"); assert_eq!(l[5], "CG"); diff --git a/src/vcf.rs b/src/vcf.rs index f1edf7a..5ff1a33 100644 --- a/src/vcf.rs +++ b/src/vcf.rs @@ -12,7 +12,7 @@ use crate::types::{VariablePosition, IO_BUF}; /// Write VCF output from genotype matrix and variable positions. pub fn write_vcf( vcf_geno: &[u8], num_samples: usize, var_positions: &[VariablePosition], - vcf_path: &str, records: &[FastaRecord], seq_length: usize, + vcf_path: &str, records: &[FastaRecord], seq_length: usize, chrom: &str, ) -> io::Result<()> { let out = File::create(vcf_path).map_err(|e| io::Error::new(e.kind(), format!("Cannot create VCF '{}': {}", vcf_path, e)))?; @@ -22,7 +22,7 @@ pub fn write_vcf( writeln!(w, "##fileformat=VCFv4.2")?; writeln!(w, "##source=snpick v{}", env!("CARGO_PKG_VERSION"))?; writeln!(w, "##reference=first_sequence")?; - writeln!(w, "##contig=", seq_length)?; + writeln!(w, "##contig=", chrom, seq_length)?; writeln!(w, "##INFO=")?; writeln!(w, "##FORMAT=")?; write!(w, "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT")?; @@ -57,7 +57,7 @@ pub fn write_vcf( let ref_byte = if vp.ref_base == b'-' { b'*' } else { vp.ref_base }; row.clear(); - write!(row, "1\t{}\t.\t", vp.index + 1)?; + write!(row, "{}\t{}\t.\t", chrom, vp.index + 1)?; row.push(ref_byte); row.push(b'\t'); row.extend_from_slice(&alt); From 470ed99326cd596916e160a4f7b7191e5ef97265 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:56:07 +0200 Subject: [PATCH 08/62] feat: emit a header-only VCF when there are no variable sites Previously a --vcf run on an alignment with zero variable sites returned early and wrote no VCF at all, so a Snakemake/Nextflow rule declaring the .vcf as an output failed with a missing-file error. Write a valid header-only VCF (all samples listed) instead. --- src/main.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main.rs b/src/main.rs index 3c0f341..9d68ad0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -175,6 +175,12 @@ fn run() -> io::Result<()> { writeln!(w)?; writeln!(w)?; } w.flush()?; + // Still honour a requested VCF: emit a valid header-only file so a + // pipeline that declares the .vcf as an output doesn't break. + if let Some(ref vp) = vcf_path { + write_vcf(&[], num_samples, &[], vp, &records, seq_length, &args.chrom)?; + eprintln!("[snpick] VCF written to {} (header only — no variable sites).", vp); + } return Ok(()); } @@ -371,6 +377,21 @@ mod tests { std::fs::remove_file(&p).ok(); std::fs::remove_file(fo).ok(); std::fs::remove_file(vo).ok(); } + #[test] fn test_vcf_header_only() { + // Zero variable sites but VCF requested: a valid header-only VCF that + // still lists every sample, so downstream pipelines find the file. + let p = tmp("hdr", ">s1\nATGC\n>s2\nATGC\n"); + let vo = "/tmp/snpick_t_hdr.vcf"; + let m = setup(&p); + let (recs, sl, _layout) = index_fasta(&m).unwrap(); + write_vcf(&[], recs.len(), &[], vo, &recs, sl, "1").unwrap(); + let c = std::fs::read_to_string(vo).unwrap(); + let data: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); + assert_eq!(data.len(), 0); + assert!(c.contains("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\ts1\ts2")); + std::fs::remove_file(&p).ok(); std::fs::remove_file(vo).ok(); + } + #[test] fn test_desc_preserved() { let p = tmp("descg", ">s1 some description\nATGC\n>s2 another desc\nATCC\n"); let o = "/tmp/snpick_t_descg_out.fa"; From 152f3f5f977f8195768f17efa3e543c1d8d9e801 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:57:49 +0200 Subject: [PATCH 09/62] feat(cli): add --quiet to silence progress logs Route the [snpick] progress lines through a gate and move the pass-2 'Wrote N sequences' notice out of the extractor into the pipeline so it respects the flag too. Errors still print. All logs already go to stderr, so stdout stays clean for piping. --- README.md | 1 + src/extract.rs | 1 - src/main.rs | 28 ++++++++++++++++++++-------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 83e7931..ab28c95 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ snpick [OPTIONS] --fasta --output | `--vcf-output ` | | Custom VCF output path | | `-t, --threads ` | | Threads for the parallel scan (default: all cores) | | `--chrom ` | | CHROM / contig name in the VCF (default: `1`) | +| `-q, --quiet` | | Silence progress logs (errors still shown) | ### Example diff --git a/src/extract.rs b/src/extract.rs index 7c907fd..f9e49c5 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -94,6 +94,5 @@ pub fn pass2_extract( } } - eprintln!("[snpick] Pass 2: Wrote {} sequences to {}.", num_samples, output); if collect_vcf { Ok(Some(vcf_geno)) } else { Ok(None) } } diff --git a/src/main.rs b/src/main.rs index 9d68ad0..564efd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,14 @@ use crate::scan::{analyze, pass1_scan}; use crate::types::*; use crate::vcf::write_vcf; +/// Emit a `[snpick]` progress line to stderr unless `--quiet` was passed. +/// Errors are always printed; only progress chatter is gated. +macro_rules! progress { + ($quiet:expr, $($arg:tt)*) => { + if !$quiet { eprintln!($($arg)*); } + }; +} + // ============================================================================= // CLI // ============================================================================= @@ -34,6 +42,8 @@ struct Args { #[arg(short = 'g', long)] include_gaps: bool, #[arg(long)] vcf: bool, #[arg(long)] vcf_output: Option, + /// Silence progress logs on stderr (errors are still reported). + #[arg(short = 'q', long)] quiet: bool, /// Number of threads for the parallel scan (default: all logical cores). #[arg(short = 't', long, value_parser = parse_threads)] threads: Option, @@ -89,6 +99,7 @@ fn check_paths_differ(a: &str, b: &str) -> io::Result<()> { fn run() -> io::Result<()> { let args = Args::parse(); + let quiet = args.quiet; // Configure the Rayon pool up front. Absent, Rayon uses all logical cores; // pinning it keeps wall-clock deterministic on shared HPC/SLURM nodes. @@ -142,7 +153,7 @@ fn run() -> io::Result<()> { let (records, seq_length, layout) = index_fasta(data)?; let num_samples = records.len(); - eprintln!("[snpick] Mapped {} bytes. {} sequences × {} positions.{}", + progress!(quiet, "[snpick] Mapped {} bytes. {} sequences × {} positions.{}", data.len(), num_samples, seq_length, if layout.single_line { "" } else { " (multi-line FASTA)" }); @@ -157,15 +168,15 @@ fn run() -> io::Result<()> { drop(bitmask); drop(ref_seq); - eprintln!("[snpick] {} variable, {} constant ({}), {} ambiguous-only, {} total.", + progress!(quiet, "[snpick] {} variable, {} constant ({}), {} ambiguous-only, {} total.", site_counts.variable, site_counts.constant.total(), site_counts.constant, site_counts.ambiguous, seq_length); - eprintln!("[snpick] ASC fconst: {}", site_counts.constant.fconst()); - eprintln!("[snpick] Pass 1 took {:.2}s.", t1); + progress!(quiet, "[snpick] ASC fconst: {}", site_counts.constant.fconst()); + progress!(quiet, "[snpick] Pass 1 took {:.2}s.", t1); // Handle zero-variant case if num_var == 0 { - eprintln!("[snpick] No variable positions — writing empty output."); + progress!(quiet, "[snpick] No variable positions — writing empty output."); let out = File::create(&args.output)?; let mut w = BufWriter::new(out); for rec in &records { @@ -179,7 +190,7 @@ fn run() -> io::Result<()> { // pipeline that declares the .vcf as an output doesn't break. if let Some(ref vp) = vcf_path { write_vcf(&[], num_samples, &[], vp, &records, seq_length, &args.chrom)?; - eprintln!("[snpick] VCF written to {} (header only — no variable sites).", vp); + progress!(quiet, "[snpick] VCF written to {} (header only — no variable sites).", vp); } return Ok(()); } @@ -201,14 +212,15 @@ fn run() -> io::Result<()> { collect_vcf: do_vcf, lookup: &lookup, upper: &upper, layout, }; let vcf_geno = pass2_extract(data, &mut var_positions, &ep)?; + progress!(quiet, "[snpick] Pass 2: Wrote {} sequences to {}.", num_samples, args.output); // Write VCF if let (Some(ref geno), Some(ref vp)) = (&vcf_geno, &vcf_path) { write_vcf(geno, num_samples, &var_positions, vp, &records, seq_length, &args.chrom)?; - eprintln!("[snpick] VCF written to {}.", vp); + progress!(quiet, "[snpick] VCF written to {}.", vp); } - eprintln!("[snpick] Done in {:.2}s. {} vars from {} seqs × {} pos.", + progress!(quiet, "[snpick] Done in {:.2}s. {} vars from {} seqs × {} pos.", start.elapsed().as_secs_f64(), num_var, num_samples, seq_length); Ok(()) } From 64e7e9397dffae281679f133e40231ac97d4202b Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:00:01 +0200 Subject: [PATCH 10/62] test: cover parallel scan, N-ref fallback, lowercase, CRLF, length mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract scan_parallel and assert the chunk/merge path — the production path for whole-genome inputs, previously never exercised — matches a sequential scan byte-for-byte. Add tests for the ambiguous-reference REF fallback, lowercase normalization, the inconsistent-length error, and the CRLF single-line fast path. --- src/main.rs | 77 +++++++++++++++++++++++++++++++++++++++++ src/scan.rs | 99 +++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 150 insertions(+), 26 deletions(-) diff --git a/src/main.rs b/src/main.rs index 564efd2..8236d49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -644,6 +644,83 @@ mod tests { std::fs::remove_file(&p).ok(); std::fs::remove_file(o).ok(); } + #[test] fn test_ambiguous_ref_base() { + // Reference (first sequence) is N at a variable site: REF falls back to + // the first observed base in A,C,G,T order and the ref genotype is '.'. + let p = tmp("nref", ">ref\nNTGC\n>s1\nATGC\n>s2\nCTGC\n"); + let fo = "/tmp/snpick_t_nref_out.fa"; let vo = "/tmp/snpick_t_nref.vcf"; + let m = setup(&p); + let lk = build_lookup(false); + let up = build_upper(); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (mut v, _) = analyze(&bm, &rs, &lk, false); + assert_eq!(v.len(), 1); + assert_eq!(v[0].ref_base, b'A'); + assert_eq!(v[0].alt_bases, vec![b'C']); + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); + let c = std::fs::read_to_string(vo).unwrap(); + let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); + let f: Vec<&str> = dl[0].split('\t').collect(); + assert_eq!(f[3], "A"); // REF fallback + assert_eq!(f[4], "C"); // ALT + assert_eq!(f[7], "NS=2"); // ref N excluded + assert_eq!(f[9], "."); // ref sample genotype missing + assert_eq!(f[10], "0"); // s1 = A = ref + assert_eq!(f[11], "1"); // s2 = C = alt + std::fs::remove_file(&p).ok(); std::fs::remove_file(fo).ok(); std::fs::remove_file(vo).ok(); + } + + #[test] fn test_lowercase_normalization() { + // Soft-masked (lowercase) input classifies case-insensitively and is + // written uppercase in the reduced FASTA. + let p = tmp("lower", ">ref\natgc\n>s1\natgt\n"); + let o = "/tmp/snpick_t_lower_out.fa"; + let m = setup(&p); + let lk = build_lookup(false); + let up = build_upper(); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (mut v, _) = analyze(&bm, &rs, &lk, false); + assert_eq!(v.len(), 1); + assert_eq!(v[0].index, 3); + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + pass2_extract(&m, &mut v, &ep).unwrap(); + let c = std::fs::read_to_string(o).unwrap(); + let l: Vec<&str> = c.lines().collect(); + assert_eq!(l[1], "C"); assert_eq!(l[3], "T"); + std::fs::remove_file(&p).ok(); std::fs::remove_file(o).ok(); + } + + #[test] fn test_inconsistent_length_error() { + // Sequences of differing lengths are a malformed alignment → error. + let p = tmp("badlen", ">s1\nATGC\n>s2\nATG\n"); + let m = setup(&p); + assert!(index_fasta(&m).is_err()); + std::fs::remove_file(&p).ok(); + } + + #[test] fn test_crlf_single_line() { + // CRLF line endings on single-line sequences use the fast path. + let p = tmp("crlf1", ""); + std::fs::write(&p, b">s1\r\nATGC\r\n>s2\r\nATCC\r\n").unwrap(); + let m = setup(&p); + let lk = build_lookup(false); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + assert_eq!(sl, 4); + assert!(layout.single_line); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (v, _) = analyze(&bm, &rs, &lk, false); + assert_eq!(v.len(), 1); + assert_eq!(v[0].index, 2); + std::fs::remove_file(&p).ok(); + } + #[test] fn test_empty() { let p = tmp("empg", ""); let m = setup(&p); diff --git a/src/scan.rs b/src/scan.rs index f04044b..31675d8 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -8,6 +8,10 @@ use rayon::prelude::*; use crate::fasta::FastaRecord; use crate::types::*; +/// Minimum total scan work (records × positions) before the parallel path is +/// worth its overhead — roughly 50 seqs × 4M bp. +const PARALLEL_MIN_WORK: usize = 200_000_000; + /// Prefault mmap pages by touching one byte per OS page. /// Eliminates soft page faults during the scan loop (~0.5s on 1 GB files). #[inline(never)] @@ -31,41 +35,46 @@ pub fn pass1_scan( data: &[u8], records: &[FastaRecord], seq_length: usize, layout: SeqLayout, lookup: &[u8; 256], ) -> Vec { - let mut bitmask = vec![0u8; seq_length]; - // Prefault all pages into RAM before the hot loop prefault(data); - // Parallel: each thread scans a chunk of sequences into its own bitmask, - // then merge all partial bitmasks with OR. Threads share the mmap read-only. + // Parallelism only pays off when there's enough work per thread + // (~200M bases, e.g. 50 seqs × 4M bp). Small inputs scan sequentially. let num_threads = rayon::current_num_threads().min(records.len()); - - // Parallelism only pays off when there's enough work per thread. - // Threshold: total scan work > ~200M bases (e.g., 50 seqs × 4M bp). let total_work = records.len() * seq_length; - if num_threads <= 1 || total_work < 200_000_000 { - // Sequential fallback for small inputs + if num_threads <= 1 || total_work < PARALLEL_MIN_WORK { + let mut bitmask = vec![0u8; seq_length]; scan_sequential(data, records, seq_length, layout, lookup, &mut bitmask); + bitmask } else { - // Split records into chunks, one per thread - let chunk_size = records.len().div_ceil(num_threads); - let partial_bitmasks: Vec> = records - .par_chunks(chunk_size) - .map(|chunk| { - let mut local_bm = vec![0u8; seq_length]; - scan_sequential(data, chunk, seq_length, layout, lookup, &mut local_bm); - local_bm - }) - .collect(); - - // Merge: OR all partial bitmasks into the final one - for partial in &partial_bitmasks { - for (bm, &p) in bitmask.iter_mut().zip(partial.iter()) { - *bm |= p; - } - } + scan_parallel(data, records, seq_length, layout, lookup, num_threads) } +} +/// Parallel scan: each thread scans a disjoint chunk of records into its own +/// bitmask, then all partials are merged with OR. OR is commutative and +/// associative over the disjoint chunks, so the result is byte-for-byte +/// identical to a sequential scan. +fn scan_parallel( + data: &[u8], records: &[FastaRecord], seq_length: usize, + layout: SeqLayout, lookup: &[u8; 256], num_threads: usize, +) -> Vec { + let chunk_size = records.len().div_ceil(num_threads); + let partial_bitmasks: Vec> = records + .par_chunks(chunk_size) + .map(|chunk| { + let mut local_bm = vec![0u8; seq_length]; + scan_sequential(data, chunk, seq_length, layout, lookup, &mut local_bm); + local_bm + }) + .collect(); + + let mut bitmask = vec![0u8; seq_length]; + for partial in &partial_bitmasks { + for (bm, &p) in bitmask.iter_mut().zip(partial.iter()) { + *bm |= p; + } + } bitmask } @@ -127,3 +136,41 @@ pub fn analyze( let num_variable = vars.len(); (vars, SiteCounts { constant: cs, variable: num_variable, ambiguous }) } + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::fasta::index_fasta; + + #[test] + fn parallel_matches_sequential() { + // The parallel chunk/merge path is the production path for real + // (whole-genome) inputs but is gated behind a size threshold that no + // small test reaches. Drive it directly and assert it equals a + // sequential scan byte-for-byte. + let mut fa = Vec::new(); + for i in 0..97usize { + fa.extend_from_slice(format!(">s{}\n", i).as_bytes()); + let mut seq = vec![b'A'; 40]; + seq[i % 40] = b'C'; + seq[(i * 7) % 40] = b'G'; + seq[(i * 13) % 40] = b'T'; + fa.extend_from_slice(&seq); + fa.push(b'\n'); + } + let lk = build_lookup(false); + let (recs, sl, layout) = index_fasta(&fa).unwrap(); + + let mut seq_bm = vec![0u8; sl]; + scan_sequential(&fa, &recs, sl, layout, &lk, &mut seq_bm); + + let threads = rayon::current_num_threads().min(recs.len()).max(2); + let par_bm = scan_parallel(&fa, &recs, sl, layout, &lk, threads); + + assert_eq!(seq_bm, par_bm); + } +} From 7f780aef4100fcd88ff03833d5d64968efa7ba79 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:00:36 +0200 Subject: [PATCH 11/62] ci: gate the release build on the tag-exists check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'tag already exists' step wrote SKIP=true to $GITHUB_ENV, which is scoped to the release job and was never read — so merging any PR without bumping the version re-ran the whole matrix and re-published over the existing tag. Expose the check as a job output and gate the build and publish jobs on it. --- .github/workflows/release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 From baa4a26576319a3bc14d53e94b57e3b496480678 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:00:51 +0200 Subject: [PATCH 12/62] ci: run CI on the 1.0.2 release branch --- .github/workflows/rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9fc0c18..0140aef 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" ] From 27ac5a2e2c99bea04be0ba8511cfc5015335379f Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:01:58 +0200 Subject: [PATCH 13/62] docs: fix pre-built binary download links and note gap encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented 'releases/latest/download/snpick' URL 404s — the release publishes per-platform assets (snpick-linux-x86_64, etc.). Point the README at the real asset names, list all four platforms and the checksum file, document the '*' gap encoding in the VCF, and correct the PR template title (was 'get_MNV'). --- .github/pull_request_template.md | 2 +- README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) 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/README.md b/README.md index ab28c95..e238e5b 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Optional VCF v4.2 output with per-sample genotypes. Reference allele taken from ### 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 @@ -115,11 +115,15 @@ 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 ``` --- From ae60a944cfdba039f8899adad408f903d40a9543 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:02:39 +0200 Subject: [PATCH 14/62] docs: add CHANGELOG and link it from the README --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a3ecfa5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# 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 +- `-t, --threads ` to pin the Rayon thread pool (deterministic wall-clock on + shared HPC/SLURM nodes). Thread count never changes the output, only speed. +- `--chrom ` to set the VCF `CHROM` / `##contig` name (default `1`, e.g. + `NC_000962.3`). +- `-q, --quiet` to silence the `[snpick]` progress logs (errors still print). +- A valid header-only VCF is now written when `--vcf` is requested but the + alignment has no variable sites, so pipelines that declare the `.vcf` as an + output no longer break. + +### 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`; + they are now rendered as `*`, matching the `ALT` encoding. +- 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] + +- First Bioconda release. +- Cross-platform Build & Release CI workflow (Linux/macOS, x86_64/aarch64). +- README overhaul and benchmarks. + +## [1.0.0] + +- 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/README.md b/README.md index e238e5b..10ff64b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ **Fast, memory-efficient extraction of variable sites from FASTA alignments.** -[Quick Start](#-quick-start) · [Features](#-features) · [Usage](#-usage) · [Benchmarks](#-benchmarks) · [Citation](#-citation) +[Quick Start](#-quick-start) · [Features](#-features) · [Usage](#-usage) · [Benchmarks](#-benchmarks) · [Citation](#-citation) · [Changelog](CHANGELOG.md) From 5d0c6b0249cdc1a69f24ed3fc67de54d0e4983ca Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:03:03 +0200 Subject: [PATCH 15/62] chore: release 1.0.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b413335..de01fa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,7 +195,7 @@ dependencies = [ [[package]] name = "snpick" -version = "1.0.1" +version = "1.0.2" dependencies = [ "clap", "memmap2", diff --git a/Cargo.toml b/Cargo.toml index 257d500..80fe985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "snpick" -version = "1.0.1" +version = "1.0.2" edition = "2021" [dependencies] From 0347e9eb402d2ace84523f750ae7fe9ac4e9f35e Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:11:58 +0200 Subject: [PATCH 16/62] docs: correct snpick description in CONTRIBUTING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Project Description was copy-pasted from another project and described an MNV codon annotator. Describe what snpick actually does — extract variable sites from FASTA alignments — and its scope. --- .github/CONTRIBUTING.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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. From 835a826d382fd9d8239dc4a4cd4fe07429c52716 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:17:32 +0200 Subject: [PATCH 17/62] docs: add MkDocs Material documentation site Full docs site (Home, Installation, Usage, Output formats, Benchmarks, Architecture, Contributing, Changelog) built with Material for MkDocs. Contributing and Changelog are pulled from the repo files via snippets. A Docs workflow deploys to GitHub Pages (gh-pages) on push to main. --- .github/workflows/docs.yml | 31 +++++++++ .gitignore | 3 + docs/architecture.md | 53 ++++++++++++++ docs/assets/benchmark.png | Bin 0 -> 171167 bytes docs/assets/benchmark_length.png | Bin 0 -> 183144 bytes docs/assets/logo.png | Bin 0 -> 37919 bytes docs/assets/logo.svg | 75 ++++++++++++++++++++ docs/benchmarks.md | 27 +++++++ docs/changelog.md | 1 + docs/contributing.md | 1 + docs/index.md | 67 ++++++++++++++++++ docs/installation.md | 51 ++++++++++++++ docs/output.md | 84 ++++++++++++++++++++++ docs/requirements.txt | 1 + docs/usage.md | 116 +++++++++++++++++++++++++++++++ mkdocs.yml | 83 ++++++++++++++++++++++ 16 files changed, 593 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/architecture.md create mode 100644 docs/assets/benchmark.png create mode 100644 docs/assets/benchmark_length.png create mode 100644 docs/assets/logo.png create mode 100644 docs/assets/logo.svg create mode 100644 docs/benchmarks.md create mode 100644 docs/changelog.md create mode 100644 docs/contributing.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/output.md create mode 100644 docs/requirements.txt create mode 100644 docs/usage.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..963a54a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,31 @@ +name: Docs + +on: + push: + branches: [ main ] + paths: + - 'docs/**' + - '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 + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install MkDocs Material + run: pip install -r docs/requirements.txt + + - name: Build and deploy to gh-pages + run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index e7035df..10adb89 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ test_*.fasta test_*.vcf *.vcf !src/ + +# MkDocs build output +site/ diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..84e632e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,53 @@ +# Architecture + +```text +Input FASTA ──mmap──▶ Index records ──▶ Pass 1: bitmask scan ──▶ Analyze + │ (parallel) │ + │ ▼ + └──────────▶ Pass 2: extract sites ──▶ FASTA + VCF + (sparse random access) +``` + +SNPick memory-maps the input once and shares it, read-only, across two passes — **no copies of +the sequence data are ever made**. + +## 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. +- **Zero-copy** — IDs, descriptions, and sequence bytes are slices into the single mmap. +- **O(L) memory** — the working set scales with alignment length, not with the number of + sequences, which is what lets SNPick handle thousands of genomes with a small footprint. + +The source is split into focused modules: `fasta` (indexing), `scan` (pass 1 + classification), +`extract` (pass 2), `vcf` (VCF writer), and `types` (shared constants and lookup tables). diff --git a/docs/assets/benchmark.png b/docs/assets/benchmark.png new file mode 100644 index 0000000000000000000000000000000000000000..0834b60ba6f49e8f1160bad76309ace98e10fd71 GIT binary patch literal 171167 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 w_vuJb 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 z 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/assets/benchmark_length.png b/docs/assets/benchmark_length.png new file mode 100644 index 0000000000000000000000000000000000000000..a48e7ce2cc392520023349a68bed1f9b0e6cfd44 GIT binary patch literal 183144 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 z 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

*t^aeM@wr;qq| zdoe{2;cE5g3i0$gbZ*r>u7K$Fbu~IVmg!YsM6uNiFYFWQ^F#xpSp!&ypGz{V3}CX7 zug#GyMarEz7Ffh$hZJ9uWrw@*(aT&5AE8uE8VSeb-|r$ija(! zOt`sQPE5k6w23=&wc57Xf6anw(UwU4^WhpDw)MFlNrsbNv&q~eiJ#FG>W~KwPphAR zidMZs^{h#sk-T`E811S-Ik46E@=);5#ZLo;3Q@}f2s-S-j1$V+(3+YWs~0%QSDWCq zy=C42X9~Y}5Nve6TubP(EJi743&3C@nu&mtX8Q!CL?555bEMU@%igiUJx`Et-}2<)=to?S3^u{csq}UHYqOz22MAisv>l0%Qg2 zKS@n6+Gl2@D@pUL(!FwP2GM$qAHuEPp%-N6{_0K6FMkm1Hb_W0UGQ`N$G_^PQA`*v z!pH4*^XYHbgctpt!s49D8TXmHoGFD=%cp6%JyOM$m1Bju}J@+g;BYQs?Vg6a7txr(qwP5Ls8yL< zoyu|;=|6e+fnu_-E31swWOp5(!{}e|YbX*3GPYDtnEyR zJmoyvL-$V0AHLel%ts0;g5pN1UXE5rVsxNFnl&+eGsT4}RNDB-0BiFX_};zTHxU8` zw;!yzKf=sMn!guvC|Q$D?A?G%=E5qGf6byE%~d!Qb{kWzS4tXe)0fdkZ^#M(wOi}} zomSD=@0T?u`!W+gXQL}e6@M%DDCTfWNY%^whF{vW=ljlh((K6??16Wz<8tqb-G+wB z%x0gehSxW4-?qt!vsXHo!8_lsJi@kFz2bM_>AtlxEte^}N>8nPD3q8&1C)m%6Vk@*+E`MfpimG^lojaEaFs5y6_vr;G; zq}3^hSfxov5!Y6G!-gFzR<0Ds2w8WPy`I~iQQe?DhfOA!hCdfZuX^_~dp+*$Lk3$t z91?X3lY4I(PFPn!Y|F10sFCl<^$(ZRPRMk^hP+cfAL~Eo%18R7M3xbWmS#FTnie!V zkx#rIJ}BtQt8I=kNv-`kG4ty%~WU8>kx}Mm45*-7seDZFW^!xrPd69!Czf5QHP;_IjtdvwEYpFF?M9ZU*EQDO^g9i?@ zJfDG*TTEuRNB25aKe%E&KFs0N&6WK}jvUFMzc?xrg2zjkN52gFj}m$qN?(#PM{A&b zC53%F{&VUi>6l7yCXTSG3|sx^Xg^#VWVpUf#+BHjU}j4UH_!_`RZAWGG*x{^9<_np z@Pf-BtzUoteh&;o*wS9f5}oi>>HWwmzJ<4^LZneQjR1Q~++AVP(8ll>_jOVQPf=+c zh~X=oJIMOc)|fXV(%^wcKQ(lacW0xQ$$TsTeGZ&D=mz`rTwPsrM#D4`r+oHHy9Q2J z!J`#IcyzT|4`i4NpXKHDt0hX7JW+OZt?s`rN<9CGl-4CSv0=RLG(x+H%!No&Saa~fkfsl(U z7!aNInxoybgU~|^W#=fypZVC&LCri8K2x#C0}C)&N+!t$i&`p9!h z`U~x8;^uL^2O1hb-)Ed>yOqpDvyK>7%tM%pQdau?gI}t1N ztyOzwJz>>}FpZxfJ-&?1Df)E#$cUj$^7556k26g?Xiu{$EhmoIA0PGUt$sD}xu>s* zC|crIJT_ZKR*|h|CD4$qX^xkVgU~X~*c*N$Le1kz@uvs+y4|O(%ro{;cXzw6`u3}p z9;y8EXh4CQ!z}zATRL+q2FSDt#=(@#Hy*sMc!>#UC8P1atjMbCydM#Z9H#cgbu1s@9f+;>i{LAdG}g_syqXvZq}*Rh;Ni%}0Z4%G(p_`M?O9b)5cw63Zo* zWC{^liQ3w z;{>*{d`(>Nm#$ad5)3)b@j5@Qe9_wt63E?iVhI-(sHrS7bYDiAMrmhG5ts9Ujm_Qk z#tPf{9w&A>^IecLOe_`d>5LHwR0$6vX>>j;Q|RjPnwl%ED7t*_S1zmk5`ceZ{ks+E zn)sHtYa$o*_hl=g2~|!#bz;X?(z%$WJw0-WsxG>U3twBry!^Lsb$z;GF`k&P-jrb9 zk-S!Tipby3mJkRXis5}Ysmn0iq?A4LO_}9b{&ZCuN()I$3sTl@hrjC$#NK_?2?A`; zHY|m<+{41;e~*Di_1`~AiTo%X@}thKCQHZiK~uWo*HoA-z^Pud<-}PT(~P%`Wc^el z+(9eS+-cd=3!hL95>4ta_ghvKq}d_!nJ?i@hKn|YI?c7U>;5u5@TJR(FJz9~puE*4 z)ynV+p@_Xa7+1O%z6?jM^TPBN6yM=K+>Sk9ZGehRv9;G;?Ge$_T+l7N{r6_!qJWoo?kJ=I{qb147?cyp&wf}9@=iK*DS~gru`w5}t zfB!@3?Em=FLe%X4_!>nEPPPC1+W-H?e?5`^>G6maOaukpSS$UZnfHC)=RW6kUgver6n99PE9ZM7?i2XAv)h$M8XjD{3Zpo-N};o;~vq+S?=h_b+>?^YN?cBRmYP41|U? z42zsSOVg|l`+p72vyo9Gdqi35{ra-IZBwh+UR;6k2q2EJ7?#AASuN2VfBkAhSqudM5Dgy&$r(@{pR-D!?(8XlqVfA;igUH#B~NLBE~HTW$$Cu0l($a}|5 z(~Wm%e3#2{^FIm=5Ey|+sW$bce;Hgn6Pc338uP8FRFoD{I0ZjetF)!1#T=JWT1NLO z8nNAN#!dreKgt99#MFfEY)8rUMv^wZz+Uk0RZ?GIvtJ9QZpa_<4{pq`eEkrL)YlvU zM>krd>o>#W7ZoBEO2^*Vw+T~MIZoX?yBgiN+4Jjn?!0#Y@7t{}2Q)W;$86jt$5u5k zKno`HEWm)(k2z(_(yJQ({iKHalXj{fj`zQR|DIJ+w?z-fjh^G*7_R?#>HGZCt6wj6 zEj~GTuY?S!!tSMEW|qbT8KVXlGxkGr5{d)eOkjmml9fh9>rM#g zmrrHR5^|&Yp60-YR4aIXkmb;!vK0bhODAwj#MLIgD8w0wG&3+T=$9^*ffmfD8%z~v zp5iC08tc~U;aKy9wh__Vv{IKZhpKaRq-zI<;^c|S7NurlUN9~mN?-rcf9oJy?{`Idqw?nb#)Z?|6^-Us1hzzswyT(muX z=TU}|6|O-p!9cvSnN5f5^_(;^!9P#zeR;-Mvs?msYIP(aD?*S&vE?EW1s1?_CxRRQcHRmb6!=v46Mcq5xK)SG%F+x=?7? z*BVb1Pk%K?0o)V>`7^ft>_UGO$evuZN~^OmWGnUbKf1tEVC4ED!#8T~=Pg9KsE9~P zt@l)OV^73c#1ZD;ge)kD1j|`}808ajQZG8m-##^hQ zddR=(XcU7~DwcO}kz>5a-1@(dKk(wki-SlKIN#^1j2r_d$d7R`KfFqZ+;}}e&?y{- zttf1IK6Qn-%YpDKEh}JQ`}pzWoqdoVYsat8rDf0cmi@@)9#hRbWoGQOd22_cAn1B5 zxPOvWX+S0>@ab48npE5HvS3kAWs9khV?bBRL#gpAzbSr)Ts*YsR4U4#>inG(7-3OL zT6)6)F^|BFj#~Ws$mxBTxih(%abK=O6FZ>V#cbbR~w zOGKU#w`wJ|Bg>ESAIv2}90~A6aXm^%5Y=k1{}qwFELmVgFp{Jg2rzRVxKAqxb{(f7 zjkVZFQ_gxGZ#dnA0G#&IK z5j+{fybW5Ln?>h5L4_WnuZG_en<22fHH_yS?zDpPtd9blMSkd0rYs*1AuR{hV*C;F z>l+&vrQYyQD^G;Uw2#W(x4+|dc8Mm^>q;VQDimqVxm<4X)r*`ZVq$XD#~!nK3J+pU zPvmS8$|IWm^vM@N!su*wx{4;w*Y(`$SY<3LiwlS3*Ht1SHdlzSc)R}3JGq323b}}a z4DNa2#roefL`sjys=3DvTsi*=@MP@LNcrCQExS{Y3nT2Ji`PZdySvlHB9gJ?_46Y} zp>>4wH?mbF+kG3SL`<)~d5_=`1r&^>QbAG+}BdL)Us&Yp)7&LJh~Lj#p3Fa|iD2 z=oK6sTsEYc7a}!1uGS$@sdBjOr6fa<^g zob>*!Q+Ie7ok9EQyY2JJcVB*6yrMwdjW!$k?;FxCT9Xjl_OF***pXMyYDt+Mp>Mp6 zj>-rt{-0p|LA18A(cmnC<1=>M81cVAA_as3CSxZVq@(JpyUAc*4f+PJ54y1Koh5V` zvaNXfpN13?>-~D!pvd5q?%8V}d@s8o4gO}`e9IqUD@#jD)=mCPW(k!qgx`x#V3wzF zQ$>`VkCx9#e=3^{aJGmRG%6aiG_j)~iM<@3?`~zy5vgzH;SXo>*V+ZY=@1ctdv>yt zoIE_XLs95MPX1|K2M-=>SAYJd*zC2^XwR-X?x2ybowA}L#RHL!ETYl+7AM>>(zC3D z-t`L)-~EIoHY~9Z4i5U;ZH?9WI=ocz4$64?baW;31!JD3+-Eqmf;{0An5%VZHon?@ zBu8FBp<&=nK7_D8sjQ0IQ1GEW9jC~z1o(otREZF@puX+`K2&6KJ~bZGU-0xd1skvu zy!uivE5XY|uNC}~K&%@5PEsT8YO6CqoxJ9P059E;B-_9UGu$uYWXqd3Ki5H$^F-SD zkXL(qd-3%S9SQhTOD^NmsasoHrr^?t7s5`QIAPWha(bnak1>G#!2L4$Vg*^*+K|~# zsT6RcvgDrSQ%y}xSpwNw(hd&E6P()=gZh=!_>O35r*V92T4=-{=2G>&#Tiek6 z_hmlD-9q4i8|T>$@OuBx7HlSVlF>vLe{NMPB>xdTgQVqfvL{g~hP}e4<&+2oEW1tT`p5 zkI<^}^|e5l9B@p@<-?FxB#fJRhd?40{NVdX3Q?ZEJ{mkMUTIKAu@(qB8=t>*_AxIf z=fMEt#1ot)ck9;o;DV5i84X~~ z^W8ZR#IFW(%OtwMy(x@C?{e|8nkL2e9J^1h1`u@UA$(lE{m2?c!l+zApv>E}=u48e z)(|AM55_@N<%CVQ4@yyd7A}sb_k+Zn-8;z&4P?Xq1pR)pIe$K|_Ueh-50qSKnIM+~ z)IEh_m<|VXm;o+-@#^1|C9QF48b|2ck*iG5f* zKD~jP$e^$VGMhua0?mIm#r4$kLU@_BUwr<+oAVG<$4=cU7bevIjGk%|yFU16X4GF> z2$$_~{BK{u(81JuXHrfeCCvk80cjM_;b_4M~gd=7Ldt zd|-?Jv0mExYgS*b+I$)TDDU9k{pD%)^0DT+xO`sAf+9sfMEHPBgJVZGVg(N|f{T^? zYacB??RPzyJW?2WUm8gD#J?AV{PPL0DZyVOU&0GPoobnR&ySl6x-ftXIPvdP9yOhD z43KKaUu&sl^01W3eO3htT(y$wmp^V6kzJ_!JV-szp|s$ERsbFU`ElfHRtVx!rQTk2 z!6dVU-VxgWY(g(>CNAc~l21MjG$2CVmR_1eVvGPDMCw1s$Slz;*M}GSYtpq z;}Topvl>$mR#pC?knOwN9PB%PJ*q+V2>rAFxsgLw>y1vgs4x6Idf@c`dM{KCFM0p3 zAL#6WJMn{$$wob<4Ig8a0QWuM1=LDjm7V&@zaHAqYSc1L11)gn;1nr$6k2@-(PhM$u%d(f)T)Vl&2o`DY_=mwyk21c8_Js#DFj4@QT7 zrw$d__`lXjuE)++o8azNTY3In%8;62q3l*XUc@r|CbA!-z}JU zfdXrQjTy$q#wuQbo<#Ohohk`8FfcF^#kZM<*kD5 zf_ZwipZlu4JNXcxZcI;j^r*yx`V6?_fs(ElnFMEKgx$b_0z=?}q@qJW0LeH@#?y_` z!Vb0KsMnCBT4XZrf?Rz9eX!^;fST9kl8xR$?GpOGBRrQfEX}q zVRZ&nI$Owq=NRL~3~ywzA=~}F!||YG{|VFe)}7_4mWUhvG0aF(f%@~GokH=GU*eP0 z(15OXfI{j8|H>=pX6#`W78aYQewF}8-vJPSB10@gKi+OkDDpzGKMVccwdajOp-z+E zFPey^?|s4-@^hPZtOs5(MA=SC1D^BOMETF3KQ9DcAzu(-e@h>y%BH#Mu428fsylqyX9&j{jHsEu0ad8PdLO+E@-TG7%g5T)}n{wtfG`E%U82$P6zWTS@ zh{I(L3ifmm-=$!Hf{|si;^J?5dX!7nPq#hGXu4Ar%Zvs!r#}F4gVgmG3NKsEF}Kyu z*|0)+l2l4oHuCi=wzsEGOTM>v+53Fzr$SC66FOa8-3c!GcYgdU#SYuLyLfy0+J z)+8k*Bfy(`jY`d0W{4UaJ<^pU_w6^+8r^9j)FC)F2U0<7CQs}{Ywx}PfLA;6Oj1e7 z#{G&7{1FvCLcT&_OY;rU7Ww{Q5NFZwy`!>?icp!@2z8vHzi!0NhYue%LFEeL815?%*i>Bu;5_ywR9!hKYoy6Cml9x{(x@~{b_6*_pMqeQLQ-lAK%;SWFkMe2z zAQxj8h9R5N%x&y-6sKZINAXHA|63sXeYy6UFijhMvX#I4vK0%rDU^eMKL8_m-b|`? zK}+hoJ)(Qj=4`@D?Y#MCoP@l5P%_5tqZ7k$iRD_eIP;(kFhA-f)kqaG-jj`uCg+>_ zdv;C7!iig-!8VkinA_+Wa&@dGNu@in1g@s9KfinTZUY@Rq)Isp?~5CDrJc#9kQPT| zM5_PAzzFj~GVjx8&lFe)`6jrBI`7aII zh!Q?NOAB%2>ab&O`}yVBFdbCIM1B5Ipt8gRb1o2#(c8CgZ12dIRbya#62n~+baKuU zxO;%k^Iv6f|~DoKDLQB`gnLe%2lJui~hCu15l=~ zmn09i{p6k4nL%@q9_3>rnhf2=oR9(jbm_cf^?vjF85kJSq_<%>KxTVGC)`x*4fU-l4b3&7PxA?QD@=p zpck^@m6G4(nu|@&E0ul@p@A}~+`8&M2nHmK7VDemJhxs8#m{9`ji7HK6|<^_ejx)a zPsVU&R`Ei4OfcNHqS<4<*=3E*qV+w#*Ys|`Y5MU2h)17bS0~Y*33pTMtoNve`mp_b%ozav~rZ(=F>cjxzcP^6+0m zo`!)H_V?m{o?;Ao6h zOlfW-sA_v}*H4rzU4kMRsGLEk4$Pjw90JKg@uEItl_#i>m=)dyqTTuQZf9o;I%q&0j=h_RQyd(@ z+Q4E->z&Nq%l_naP=~MK@vB?ye2fjG6m^bjtBZOAKVOIJ@q&U%0^zLHa884%dtwI< z+*VFTChg@3U>+)aO?w2auYZI-Hx(5c;}noNHVCjfc6E4GbQop;qt6pT%(Ara@ zv)KFQiz$$I;!?F|t@l$NQb;R0TvAWRikbbZq$L$kw~yxXERO^(k5;r;mF`w&+((~S zECmAWG5UA4F|o1zuL4;hRX?hSr*WJ*WmzA*v6z@Ow)D1ShPY}6G(X-)iHW?wi`oO5 zmTUsGw8s~GJkx^5Gq8fnhPRL^iW7F_Cg)8NmK2$i#F@>508M944TI`~iMrDa&<9Yy z0~pL}bWWE2sX%hAzP^65M|avqN`lmNTq0{3Wtv&K8f%<<2t|(0ajB~nidTL?7seT$ z^!(*4aW_Yt`Gq)hh(rk;{9~{_T|VSCm4dR0aUwNPbbvXRR`Kh<7|ZnJh);oE zc_|J3e1oBzk=wOL`+^em3N8b!Y~yYCP#Z}i`1LE1>mGqt4hNMWNZHii5N-GUa|&Up zEHcSY5Z>>;eTP0y`J|2!;0QBDWq49gPs-&#W4!e5_RiTT#^);{k;p!Y%7aUuWdz_mAZglGkMc_lO;EP7`52qc0AY}8s^wQyVH{??Y^sn z(@OL2@fh#PIjZ@&EO-v+He!V*cb9`nF3!GOD)O)&y`2m@pQi(`awknsfidC&hSDDk?=Nr|ED*Pvc^=d8m^}U!L2m#$**6(tWz3K|CnhL z4^?dnsXhzD16+mLkxnp=xURgziwUK7n`U02ZN*XDox|X*P(0Lp#vtxQ-7%Cy4bG7u-u_|kRolUAqp zToDx3LlM6Leh`9)EhZUbQ~caZ#N1xL)sW09+8k)9bo(M>{LT_^Y6HLXEKm|vnC&z@ z8Pic_+qBJ!Oe<2$#kNEMd87wejB~Acg(v;S?Jgckoj+>wj8S?)BX7Pu^XrrE&@yrDcFx+LY*r`-8}6dU-~{lSVoW*W)9)X7;6|v?(viT8 zFyO|V==g<-|KZ}3 zX!7b|NLLjF-j*v$;Iy*R<2~Hfqbx$`yVsU}{J?!#skb?199&$NuNq}~@2N;ADP_+F zw;i1w;y1!EAksZY-`MMsivMXzkv%~18!++!SNYJu@tpF|G*JWmvQ$qIcwJaH@107V z!UKV@y9qxCKHh@7v3Lsh15R15P!}GcwH~521irw{$|Et~6njgGJ@j~6P7@wAW-0R^ zxH`TXGp{m5@h$D26mSNlqzPL{DEw6k_8bSKsfme+!52c~sn={=b@lbbZT71tV0K>ta#UitQKb%= zn6a1_$q9jEPoUX?t_14(kefdazju8s^Gcaar2&xYZ`*<`hqJ;krsX;F@$uobbyFvg zZ6VhZtJ3DiCwjbzA9^M;ZvWBD#+`f%*k>o6VyrvH+3~BL<-!6Z044T=_urqoKym@* z`#TGO4o|Yr$j2Jovp@Ox@#Eub#VpXz2(t=j+T7Kl6o6lWz>qltuDbj6KYm%NudJ*z2Z0s)!-NA;g_9=V z-H=RW(Qvv+>s19%F#(CW1K`ODS9f|~6X>vbg-2V_m)>SSO&h1Iz4 zAxynIOl(~1v0^z7g6?BP%)NPTA?*%<=4H?j1B8eBQxMnit-+4O3l4+V0;j@3fv7_H z{!px0x)67pRr7Nr=5xv*Eh3$ng+-}=MnL*r24FPo19UqLVKjo*Bq zw@xtXQxSGXE}Z!N;0c|y49g;uZCOMXsLNsikLbj{ybQOTFP?a~Crn^EmA*X!e0I(1 z*INb%Mb#sHuEY0>B%MYa?tIhZB}svM4ur-iA`uoSba;sp%CL zGc&Wu(#lF$I;Q27!!}t!3Hj?w2fAjHY?-%n4XOf~x1sr!qh`RJG_&j5xEb z7KeFEI5WB|dHs)2d|7W{@f>IZUqJVjI4qWAq=G6=AZR3|{pZi0pc$3xzvk}n;sy5)Uo%O zAMRH61%(lJiQ>#+TqjRjq`7PQA87lz4>TZ4x78UC4NZUpO7&-EJ_j*_@99IQ%wBn~ z;Z{990!l}}-^B-^hJCQO6v^-E=;%1RyF;1sM)r5tebEH0Cv4+;y(u& zAH+7OwoP6SUAQfDp&E=|WLE()D1#8|x3A|t@vyxxF)?up-Dag%?K|@^7NA2S0MSq< z>-Cc~kphSTpqJW>-c`d@$cr`^8wtnf>-gQM6b9Vg3@GQ#b~s+=8*bO^?52Lgy2;ur zFzi8{%CIQ|xo8NlQUXxU`(V^`zu>uZ%}K_VdY5EmPE3GqpO|%He@l(p*Bl9dMOZn`*bAv-~?b89VneDn@=G-eG3AtQSJtp{(Sdk{hDAE5s)l5hbZ`Q9=pu zs;(jtXh9ARc~p@&^EH8{OEVJbq>YUx)Y5Qi;M{d1V<7lA*He}Rl(6ju%jk7agn8Cj zqQFY=kXtQ-+MB}bLOnxn)su1w=4=P>wjK3SI|xZpwNsmqJsvJD3oFPP`57Z?Fy|oyvf{{X z;gY-W#h0I~(*~;a_w>2=D`kJ|X)dD^sc=(e?E$k%l;A=3W=_(b6 z7LHI)l_Ac&r-^fy5V_=zEYL2vjj)xL{scaA>Aw1DsY-K%)yQJ7LN+$S@7KLHv^PgR z+~5Yi>kRZdj=B4f1Y^`SJzd>D5^(6S!oWMA3G${TlJtvN=g<2dTZHa zv46oP9C2asDG^C}3Qp!f=})7Ig(dnt9Y^`zn@HX?qWtqc2BWEmq}Nq62mj!HoZ^!Fhtz#2TnlgTv%|Km~L7G zwLkAFF`dxbGGa2ZGX;{O7TqV>$QoEJRZR!bq>k7KJsCVTUhTdY4C`d8nQu7&jtq^A zklg|!QZcD46N}f70iTzbmqP+T4MUlXcz#24?>V-`tTdwiyVkA1y*jLSHc%`6n+9G}R&Q->#M7KLjPKgr892N#m z$}2?gS7xuLSqsiY!aSqrOwycTXWKB^u_rbm3B5aG={xZ|5@xtC^AXUsp;B?2UTnFjcPn@v5;2O;lQuJ0 zGZPJ0gz}1tSVP=vf$V|S(&lf(c>p-JnFT@LKz8(5>y>R8=()*-kf#12So%E{z$z{!sio7Hf+ zI>9$5m&D?GmoNBoMcf1j3k!;vR-(oMNTJnOZzG2g`#(lFMauSejVHcgqmO~;XYo+1 z>W@(ur-o<=n9kKe z8M^z^*`KbqLBj}SEuU|2dXe$T)c`eGdgj>Lu|qAQ@%gb*ReX@k*~iQ0LBk>KNMOtR z9`vE9sZOgyPN1ebJQ2pv7{y+6SEaCyA{fml*J4x@T51@=r0tyzGpL=}v z>{)A2618;o*j^-TGI;oxU`9YvH3vehtz<|@fzg&49Z8;FQisc@F2vZKF0vp2bmbw` z6LDtm)b&Vu4R;{phV?GwtNd#>v!kz0>;T{WBoHQ*Fta8oC|K?>t!(2^A@6LtKhrPg zOKa&wk7(+8P1WOzOF&xdz=t`YE0Cl_;L3ll0#$P*in>7h1%4&jcXjWJO<(H+q5A4^ z2?>d}x4pc)M)7t9AH%gu>nT-OJ8^mWEj$Bp(4Puj=*bFz$({g{Po=B}qV-){&E|;C z2!+=-L?e^{!h)HOT(?%`mN{Zvh#?TSG|#%zLPvVwVxJq3UOI&|cB zJ0i^uKUa~YNigDg8BuOJlS2-@%GF_>4w^@@uQKLcnL2jvxVbHo7{I)MCtS`)iW&*A zm04J5k!W+VxlhQufN@@ybT{ezsS~(oF8S50)NIvdfJf_Q#jn`{wCKh3ZUZprk7dk%`=#oZTzV zu%?)qA)RkO+0UPiz11FX49sGkFLcJyLLKnT5%9NzJKqtvf#cw4llBcOP?w_tOcA)E~a3 z82ghvPRJe05zBQ`Ni1J{zJc5-sy4f;t1FA&a}vbxR{JgA7uZBC1?AWRI}t*(e92h^ z!a%Br*Z+%Qz0DXHcBQs<8XjQcF+xzhhimVWXw|5>+ZFcf8%F?{+xrG{R2*O^Ybl3U%xJ) zm$e+&4Wt}Fh2JKryH}HuZ4GmTYZoo7@`c7PxHh)5MBD~P)j$Juo5^C@t|@R2(j|q0pKc3CgAp%Zi!^v1Q_qNDRfQ2DaQp zp``l2$BF>pdn3(XKE3TXtOO);GlcdwW+@de+)F}eRRyV8{2&-`OvPZ#kM(snfu&^dhqK@Hpm%vpxjHhU844EqZz92m{(Z6>6UK31ZEY>F`|W3HA7Dja z?lM6R)vSmlCbj-EyB$d98^{9$E3>$phj&KB9Io26#mY*NgJ4uLiZ z4O6UNCeT&jmRF?c@s3|){uLbwnM6TRW2t+WgDWU1Kdlk_0gUUyLm_vJa24y$P-Gh{ zt>x)x8cG4H3Uck{ef#!}_3g4VGLBQ7w7y=_{gz!J8oL+p{&^+9;$e}H49o9VlZg5t zJI2C-zbgB~8M$9ae_EBM6`jUlq{IwL`s>&g+Ju=p@XN#;-*SvvG}g4&i`2hHT|;N^2(E`G z*QxwPg2JLR`dHW!SH~rD!G;^4=d5OwKeeA2?w8gCX7qW^7Ri45>FqQIib)z+t+4?D z&F*y^k>3HY^oyGm4g8>68Bxm*f`XpK#tX#J68{GLw+s%okG{_~s}8{t)JeUcTMx>N|_B@l09FWg23XvbG%l<8xFnxz?OffOwV{|_3dQf5JiqkVXz zVno?Hd~v(X@iTJ91JguAgk3+3A5&d#*&!4i{N9EenaV80oXxB$jpfvZE75oS^Op+b zKj$&`^0kRyHp8vLo0_s|q@{=Cn5iF_c>iAgd!Cxn%e>QsAtr=7;8f?kZC$*znt5{fA=zfZX2-Ox$ zZ~&O4N()~n-;iweef8(WCDxglnOdy|#V!lmoCRAXQhT!S?BHOUHjmUqbqJa0E=k-6 z*-(cxw1D4tjvq0?^&cZ@3=92ystojTWr78V8m;PW_$bpn2;lUkplDbRmh~lP zGsWJ{f373F0c+8GYdy82FLP^7S!6%HDt+Cve!fY~Rva;ZpQZRJA9_C7%Pc!O6 zaI7q~BFc0J|Bdcxp@Hg@>Fu#}h@(e@5EsMRCca!0$XiwYL68G<=L&LI820Z6TGWmA z-{5#;qn?&um@(>tKs!^c?93GnKEjV+vYNG`0HGDMG9L!Z2%UHs7jbwnmj=R7b!{|8 zD4v*vvU&e4@gz`hU*?1XijU55nU3owZnWN;Atiu<(59ckwQR3W5hyHt``|P9cq3Zs zS-?AajQbfL@-naLN(}e7FpfFF*D)hdswLN0Hpq;2L?S4XAVuhm3mu2}s2{Y`b7d5c zi%F7u1-nP#DgL6eUFMygoqd539`C=Ykn*lc{4o&-&g_ZfhJ#{~|{LadPUR3xzO>|Y4fc;aOda24iBCa!v zU@G|al1VgnRbNi3H&!1NDJAx}F2qKVgI_@D^L5F})i3q?A=E|c`FMS~3SAj3&B5GW zTr`7ZWiUxKM0*RTwP-E~_oC7IAVW|-(uvJoi_SYQM{!b6Ge>MNR?bcYyYJ zl{#c~oa1(ZbJO+x)Xj+lEN4(l+Km1R1}C5DD9H3023OTiPEPux(WD=v?3#w(TNimi znpXNOt|BWN=s&(e15s_q*CV2r8xve+&{JrUoA(@ickPzPV&O&L!j~#BkNH|^HzzJY zd;;()KRKiLT-Li;fgyfH4;hRs>@~$UA=h9{Wfuy;07J$Eb;ndY->##c7+iFW{^En}2nTH=3#j~=kyDXe{ z&5_tK`tX~mz_8}I>NvpA_ziMlIgE;ykyG?-?8lB5-E^8TB-H@?AN zrgs~P+-r-dqJdh~Ky(X=3J4Fy#f*)OedxE=UaTj)%)&`AUC9DS|7$3YkI`tuMQe}| zhp(5Rflg7C_Glno&1;8WRx%xUb9?qvnCnOOhni&usJk$#WWO46lP$2!x|#%v4eF_r zjBey^u7Ey~;IE?Si`F&E$yI`cO?bMqArcD)Tt?NO`#5}>9i(Tu?8)?R2@QDl{bk@v ztNL%8Yj)`GX0{!G%27TmzsankxkPzGnE!1!r!2QxNs8_H$a3T)6RpeJ-yA=#>Am8K^;B6 zaO{x5P_8{3(kqb!j_qNYV)xfh-0qG;j9jH4euW4o%xuOYMrAO_VhHFlZ$!~Yo_?f4`%D1Uq(}YfZvTd#fZ;X8= z`x)gdR9;4@PKD@rr=Bbx6@y_#Kk_GgV{Rz_^5+Shchv#Y$J8z6r(|!kMnroc?NX$& zeB^*3z^r?}fr1QU)E=Tf6gQO^SZ>#%{413iYJE-(0{&zBS%fK4%hHu%{HL;Be&!#) z2KdxFyj>#IAVmKE2sFhV}-?^2bg+#4PF-{o$Ig{`#H`eR5r!{p{=Ai&LaTR=VM%Yyw*L&{uQ(V)}3O3MCoYxMv!hxxn zpv;ZI#whi(T2LqTi(p#(dK#ToBZ3o6XrBdN;_kcNELkyw%H3l%nz(MGK1*TYl=X|r zHm+|6951Yil~%xc14$ZYGj;-L*C&eV8fkL}y zjY~{^BwZ;gfA4lpd%pPW)<_+ny5 z2WDXW+_2~(NO-~&8==?>U-q}^jR@dds%s^7ExY^Cq;sdT<~ker>#Z-0cHWgIT7$&fTtU{_N3d&omtcYh1c+Kxvn)^V=ED#`&WFGd+yD%B3b|roh)3 z!_7a*C+aB-^xPRg2HmDk=HdYOJvVz@5PsZ#b+UI{~E_ZU#{HT02Rp)G$!X>8v&$Y zc`BQTyBNNfwV;g6$d(7AB)a2DF9G8Da(SnukgVtSa+?$nC%-7(RoPV@s^nLt<&Qk# zh_GOL6w|Y=`~J)k)$8;$_w&wlrV;4Ju2|kUZD%5LR83uil`lT>q^jzX)QctdX+$Rb zYs?|~o*OGmuKzNVP@=Z>uX2nzKnUX)Y{b8gVbhY;r@zZw^r*m;ZF zWN8Pb8?3)PBuD@B&>x*7-h*#VEd+DL?cZyeHf1-jLce&66@nJO6p69X%SG|4?JE8l>|uv>w7Q{ZO| zU&K*I)FnYMj4*M1ioCk-x2PP=TG&)eUaL;Ti*mcCTKpwPF_%8x>p8#z9{Z^4;VNZo z-Bqq(V9Bv^tnzN{ZzX-xM1uk&l?&?)P9(Z%!;#a&`3!QjcZ`xE4!pH#7St**$_mOM zAHLbGPWx)HXv8*TB`P%0-PKhd8hxH5}}>y}T*l?tD;((dh4}xS5W`J^j-o7X>v{&RNv>WG#s-`S6KbCzX>nB4-j5 zy)>qIqLhyW84sycS6AbWIxEiH_TsVg>OS}A(o6c1ft!ymeSF%eo2{^Mal3h+6St4y z^O4NULBHGf@rV@%ZIv(wXH2^6AItw8pYvmGy16V#E!mYa(E<6Q-KftpIP@SB5VFhy=o^ z#h^*PDe^(fM9=t2p(k1*3!wegjKzl77Kx>47wjJ38XqY)A3FPWeB4XyhZU=!en}9R zAmB9~Qs3Sexg+@MUd4wRCDfpkINbv9`2*9>6>|T*2>y?wTEo=?=l5sK%?rzGkL!RwkO7SKhwMO z=!FMI3Yu;#atDllBM}A*oo)7?uXU8Dr}4R*M5~;b^Cy_Y07R!Yq)B}NYL=lLqF#|WGG)PE0Ile3O(D8IHcWNWU`c!r$4^R^B;Z5KL@oK z@E=Q7R8W{p|4v)a!?>5;aaza3f3K#dCN$0FPls^fGYOHb-acA}6F!?0nVFe*LA}g% zLAt4ZGJagn{_H{uzMgy6&ZlmxGdWdlFv0IuWkSjE6F;}q3yE1@HrRUNd3OiLH{)E=fyVC($d!gQK-g6 z1)X{@ZT{F~h|ro)R=NIesr54f)zIdjR6PcqouR~{;#P6-iHT=rkIbO?mxF{0Hv0*B z#>Rub#R~Tqj=T3n^zLuY(l6XQL5edj`f|5bW$m)Z^Hs_~J~xz*tFyWQfA*Bj+%myN_WP|QI`22; zLF!}GmwRq^w?py8!U0WhLsUf6ROLL4sIxQkKINwE2p51cjCBpIYo)Xa!kA9+@IRZ` zpvcb=%Il0Qo=jc61Uz&5;{g_dH-q|$L(Y|BA(5pwoQtk&9Ejy7MMd!(G$ydt=a=0>E{R=KZxbHMWPUj{Dd|HZ3hKdOel%z2%{@C$#-C zKmCZu)E)kJC1~}4S)t)K2D`AoH9pWSd^7v};gug3N=r*?=lljnD;!uaUcBi1YSFlr z{XkvWxnpsh2j%(z=<|Lc9rjFN;gb7_x@kTle}3X8_-8J%=M=8Q?HoAyL*dF@25`#G z%W>XrZVDErl!+?QC{f-^w?4#_X+X@p%AsdXn|k~YCD&R_kt5hi2+becXAbc)p)f{8 zI7SEs4UvYf4 z#B7@1?jLDHb$`e=<9mzQncpj)UtDAV{Z0~9%t0o9O7r*kexk>~nm8=LXV9K9tjB@gd;~baM3zj2yOu2e#&Or_P?aM$>=%4qg(3$>9T+Wp1-& zXJyTbGjAUAxuAA-)~Jhi54@WbiVzS&{a6Asbva6fx*5Mi?GDQkFv$$yKo=tQ`7B$d zRVqHMHL9migIq|UyC8MX0}gJ6fZN$Gw3jCa2E;%3KQOXr4&5St@b3DMxxLjqwXATZ z4-8q{%j=i5FW^5zY3REde{7r5N!}UkQo@VAPiTz>w>gsI^PVYdeKrzgi^HHXeTaV7 zW?EXz)75ngdAH*#I=?ypu#iGpUgGY9GyxpRuH3~tzp2A z`X}7d{8rr)XR^wYTstsz4FG@dtHn!fQ-Z2jeqb6J8s@F7teQWk)0KfWf&h3?jxI+f zi5Xl8SFwM8FYKV4%x|`^3$^=ZuTQ^j&Aunw{f!+k=_Y2m#z+UdlG0Ko6zIS|-2UBO zm=nA>ZF1f}`N2dx2aC|b=f$-wrzH3#bSNMx8(BU-?FfXQt$=A6&7YT7&yJkZR=aZk zd)N<3^IERKxnxUy*TN&|_lp%GF8ZDS-elRX{ae}1-5pD}dF~DWy+KcbGqsO-Rd&S$ zvr)L-q2HSP#4cZD^rQUZ0Kire3z>G-JbmDbi2&qL#(G0<(Vf3&dit-#RR z$_LL(TPs$#nncFF~h}lg5Lp}y6T512ip(Erp&4(wr{dTwJu`W5T=TrDz&~L@@ z1v34<$0x=p(eb&ZijT~6JC*Mmlab3|R)_d=+b!07S!W)fymYH=e0=nTTr>9-L zx9e0!y6riqJ@O7Nls;WM`NqyI)a`BU1cmj!r$bP%T|6@|%PU~&d36zqKh{@B6=$NE zlKE8VdCrV$GDP-9+R+|nIyREsqpLgiDCU@pn;S<_u?^c)N$0xg5+&RrFp%sqqNZI? zR79{Y4z66{x^Fb}LNcQVH6jmPeas;(m(|-$&m53lpjr#k@Ec_m`^601Ni}=-%EubB zImabIZaG@4@c1TYx46jBCw_qXSdU0!?W%Zj0G8RB>;r(-eAndU|rVl!*a9eSc!iBLMw?%d&NARkaOLkiUt62#&>0H=EZ|t%S%g}FwfYZNjQC4KCKZWMW;ez zcF*S%O|gP}spzpLNGjYouJ(W0d+Wcbqb6*a z?oR2D?vABIcIl8x)#m`Ka2#{7GxE{_3EAkoCkDXP93 zfv_EMkPvMnYpepLK-NlQow2XWyW?BP(5b$VUvF=a0NDHgRy=^%ou%rZQ+nE1TFy&% zkGRX7elx~zuxQ?4I9m9u0qv1_GL+fbPhie(RAVA6Mn9`Kre=%vXQ%G$Gx!0@>p@G> z_Z~*921nX9Shaiq1p1=vSYLhnYqeCSNPFu|zK;@if-n_dmQNN0e0b-jmIW>B` z6eq%IV>0a{k*5m}!=RybadRuYHy$ZEG;cGCE%MbtVz==7o`sK(PX@xK2JvX|F!Q4} z`XxXce}I1L;sb6W@m-DN1+FyBfB9%NGphvIX$Nu7b-?FFP`>!Me2WVT+&i|nk)n|g zAf>fC{hZ$3;M6Pv3_%dKiAi5~pr|8jK4e)E@(NaB@dQcDJ80y?KGdX0!2ThZ+&A0p4Ck4fCV#!2g2H30*w9Qi@`MI*7fi*|mZX z;BUM_g@Oo)B2B~f>C=5@4-eZt$ZWn*%{MYca9!LU(Ve%!n1 zD6Y%Vr7T(tT`R*Sru*l8akBmXoXO#*M(Qs@|2OfSwgOK(HS*G%?#J9h&mnCn7r@;n zr&RZYA$*)E@hGnhUW4#o88! z@_>yf?m1`(_!1}m6&Q}iXQwx}FY1-zSZCUyf5t34XCx(OsNgW8b1M z<3z%kTbf|t&MH(O>j#}PZV&S$zY0#ck*#gSGf>&{2f%7|_Vx|>?#}+s1t2VNaBX~H z<0Kr{waMc# zm0sTG#|%w2(Qag2n-C3x@V8sRIhhQ`LV{8NJc$p~fGwnkt;r$>DtodMye|zrocgNi zpL=zzBNy*69lkbdXG4Iu&mZuVK6E(RTy>Pj;lV@R6sqMj(T~6A`f5#1(_awN z@4LY8R>x24v1zu0qI)CQgfLqS2o)+??2j{QJljd`PkRyiVE6)ICsVsJC0 z>7!C|rZlEgJ+-~5-5v5G>G*I~j@;nWz5_ZcJ|Gkx=e>jV1Z5yA4@ z7d>nW8{#&zofQfLBKX($#fDcVwDZ?fwJY6uyXyFke`g+CF8_LlPi#K~ONF5Mqdz(? z{6*%_grWQ>j9K1}zsoQ3|4|dZnk1`C`B(W}#;&64O6`scb^IspqN1XdHnJvVJw3fk ztWCsD6x8R>*WO6MY*E|rux|l6&hG9@17}xk7`#~6R-6180g+nZUhIf@bUiDK9Uce@ zd@MFf{|a>)5B+%r`fRmo2bG!#G}Edcyj1H~KI?W`8n*1rl)B!r!K=rv;%B^UUIYlY zGhS1c{Yi&I3c{wxd7`XjKADO~0EYoj5z}oOqEkF-bV4-4p?mu7zS<}%?QUyBO-;LW z>y^Vde$Q!O;l|>B;%~0>$IhJY&KyXv)3H2*HxHj(y$5NA;1R#Kv3IbnNfO7-ioPh1)U&D$~18GG9rfwy_PRRMmu7IO877L z-wQ@55He~iS9}Vn-T1xwWw{O!o4H~%hP4db@-LD8X25;1&r|N*m;B+Et^5$Lu*SJy zm%XY3`g_}W?pMpayCpAGe5}3qF=k)Jb4lQ@B4Xm8J5GF@x38A4c>6g9MOqb<^LHS) zU*G+*rKN#g73OTEOesh)np--%ie+MC^cUUWk{Rv4%V`5}(sf+Y@q;0rOs4Wo^F z(LqbUfz{g=>T6HpXHdn%)5aLxf7WkJsF<6BmA7u+*^ ztko#)7ln5xGVH$nD*s4o+Vw0d9=RL6Ac``K{?JXg?P1)qjJq+?3mh4B7W3zK zUaZCWP)@_|J-pz!>X4r3p8KQkLD+VNmXuy_C_N-_bk)VTp_7zs}jAAst>=^51%@4V-D7X@M(qiJC)CyWQvjY`u3rAg_7 z6~|}L7hT!hD?v-uN9Vyzn=6Rav#O+H3J@O5-WG2FlWoImi`cK(?+aR- zwJq9N#tVCv@h`uaMrtqd(dnBJ?_4RD$%50<(~vk#Wu3I82h&YCAj@G7e!|-A*67fU z1Lq5E^rk7`BRilRDGL6}oJjIyN&eM{f3_ zOBNV5)NxnWfJ3hGTwYmK)kaghve2#|V^^Lg7g_rbqLxl@M=+S~@Ci|bfGruT>cD)I zEKa*-DhrF#MM`ab$J5_{<>=HRTD6{XjbC+%(xH<5>CN|Zk5YC~;v;(1Q3?mi92JcjoErMIhpOSCsN?7^QO)lw zV@~hc8`cvtR45%nzKEvPJoYMSG^c@2J4l=}0~#uuQ)v})Z&VW5Y8A`(GET8}Vk?y~ ztHkA7Xd4jA%O1-k2ZFZ1h2s@anJSAzHPuV%=1Xm}pgVz4uH!snihjcHn*S?kX=TRB zXT-JkMgw+0y+(|Dgmgjn;LH!JPh=y5knPcmIJ&s|CcZD`ACCX{aUpm3_ftHzD=3O< zz56aNP6)899_!)CX&fj_B$u7jjQL~djD=p7sJoH2gx<7=J}@(RJyXZ%LX3Y2c2?@2 zBNuvq5fEk46L?d5zAQWc`cIpOZ_zBU70;_iI z^@S+)1KNm!3o{YZBC>%x<);OnGq$^ll+k5?dKCLIZWZMsgP@lzw%mgWRuH9XrRqfb zfZvB4Mmocagaag}D|c$!w|y_{Ddq7zdu&=U7Qq{$;?45d59B^JF`91wuf+0MO7hSM z{ymR&=2NjCsf&E@@ zPhGeICm{x@rmFrvEPMhu=QN(&rVf0S;z*ns4zM6~bS#K9oq5qY^=i30hNmy^BEiw{ zLf6FXdUl0^0&m|a^cS{>uGe9obSk^M!@K}dZWhUFw%4 zRODB)3e%@~<>~U2)NkCd0fTYtaQ1?h`T%Z%k$h>#x;5)19b`r#38U~6Dm#3%C)(p) zW`NFYt{u;SkSZ(X+HgHK49S4g@QS~VZPO7Ojwqo#xUBYyhSpR+ud(xDyI}Gzyz?xqg3y8?kYa1LMkC3h}zoS4l#Xj#EJl79gy4+v$Kt^?vfdOMN#b~!f6m( z9AF1-u@{u_X?9kbQo>T@76UBs)K+$6|Idd4-1-HlNwpY@Rlj=byPGFzdv&WV zk2A>`ysW!?=018j!5^HAe)~q)aAfd|IpR|QZFiqv+`Dzg&pmq}5)d&wQ3OzL{eOXw zE&&b$Rv|_qRBV+afexcn@IrJ(b%@)H#6ne(G1xSk4p!YkMk%A}4-yn0kTDYouG*!j zI+~jVX*Q6=qu_%WOxR*6VA*21a|aK9z{525F*vV{E;DCOA08Jl`IbXX`;_(fc4d=D z*2S?nrTq)M|9*~tr=i9Ea|^N^>N<3~gjB9q%bW|V$2N(7f?NgvjxKu@DKaB}|IlCt zQ`hg^e}gvNc)pAJvK*`u%s<%IP_d-t=7Tn}2ad*X600W+17gb9+Q|&>&wwvge4z>Z z?neNCxJJLlEw^3{&{#LLf?+nnF2A&v|D_<|cjHk^)?o(;gBrvE-zzU5tO+V`lj%%} zOQcBaLv*C=>hjRMXWiudDJ>wYP;d!Zy(YNhm5#PeGuq0e2RGWkb!{sqxtyAm!K0Xz5B{1u2GE2n2?16$($B{+NJIcnc+Tn$}Ar4?{d z#TP`zSUYW(%SM$?Zu^$FUk?d8u6E(ei5BVG?n5-?36G5JIKyoHFQh;$EZ*7@61KUV zX(!(=hdzF>@wIW?|fo~!zyZdK0f15GaB2h4zzCxZ{TsT|1BQg#t`K3}{{Td6d$ zWhqWZ9&X5`)ZB;LI^R(a4YUT4NHOMP^J)6DVUP(J^(yLr-cVZlC}8Zkm_6V4jUEH+ zSs{Ry*Xq!o1K`lEuU2B&$Rf_ybIQjPMfX>&;C?B(JX2r?`3WUR0x92+uK12&&rpRP z+8!9Os^VcS^YB5Wo_k(Rvy^TXaa6w(hW`IrfD*}UoVf%$_1%H`rcX!-5Dj?1Labur z6RZwi{)!Uz?EH73qEzy>&CaU!;=UT#IhHn%ljD2~h^JVfyqTwN{RrsuFOl6=n__7c zd0rrjYQ??Qlasa9k3+eDoiytl#r(~-$$8{#W@ZNS$lyawBhT(D{~|(*;^+vnnts1| zq?@W99o^p=83nWHrM|P6v&#r<$BTE(E`1r%a<%a3yVkK{D^H!BXURZrmyDXhka1Ik za+MQ162#`-MVJEig~09>6cn_26_tM?PG+}!yNdynOi0FW!PCbh z`XzhB+7}#ri{%sf^AI9Zad8;$mwybNU8U7a=6Z5d#V^Kfnmje;q-5NDWUwrvKpFqZ z)tqD1s&US3?#o%{VwfZH{c$g@%pI;#_Tab|@tR(gr_!=RQoWZ29)T(a8fK&2WJHgFej!q&SP zATSlGHEXo-lb(|Y=o}6U3*6n)!B7_YjEyJJX^+N>X>4tg+3{K zrJ1u!!V&lB>;cEBq9ErY@GG+8?_T@_{ z9AwchXfkvGv;vhn#WovjH@r22D=%mkDoth*R;miW*@CF0Wlw+qzO(^(PH1+^n6jS` zeTc8)OFEv9T(4K7QG>esf*b2ts4_k`cb>dU!R=qPiy^NT=@S(}?480MOK3D9X^bmh z^cvNe1$g*(@}33kDn7t!WwT#J_F}7WK0KoF>%?A+P89e9jSi&^r21<$QKRjlhW17e z_?>o(R~ny+ULd2et1-tws)7#=y1aE?S%aPWt2m|*Ysd1Y#v8mp0LMNRuOfSC!rN3Mj=`RY)p9beS7rgrq2KC&+89n9WQNb6aBHWyL_o87 zd;{`ENt08lq2)&%4UouJfHNtokI6mK;>jq1PqV#3$;O*DaTqhcXV?S*nJs;qPy9Z9 zY5>R*2%5fa+nIAJxkCBn`o;Ij*%B^ra`pnOouKzfS+{+Wd=!6_y~E(o680bX3GR$4 z5jScP$pH}Vwju1yi{;~U-qyMu&gg>gathWH%j-Uo?DW(paq&&(3GP72ApQP+zeOU0 zWR>IgWaVABAI-Zg1R0gr6ch^2solXEJGL2gY)bJD27;eUMo{LHs+p%Z6U02Duj7VcHnA$sD3FqTFgn|?8Qbp19qETC*u2KN!s zffHy8E!O9vGWgKiD(aubt?fPaa|}bDGv8pT1e1{_X*%SFI$9B%B%r8V(}U0AFgTD} zDLRxc?ir?sbdKOfe<&Hn8eMdq(5n0EG95rZ(EV4Vm&hN<-i%`@l8jBpWuar#TkR~A z(@Jn8MRCT>-XPe~y-UHP7#)fiIN_H}@y}wS=~(_=stV=u5=|}BYv&3lS<`gBD^)3A z$rIyAVP^an^srtmH{wEk=o9rxEkJ5>2sAtr7gz}qyKxoyy~yqC2WSq#l`F;N1d{b9 z{{z9&1V0HIU$FP%%sGVFHf&kJL^1s_dhiyK<_pqa-Z=<8l%2u6|A25g#$0co=adq_ z;RmRC!>q&bhqBaN*IvyOxxf-tp)7@slG6n0~>r%4~mD~ z9X=}@vyOm(Gs9vXb&yDTgRo5?{trV7v}xfWyQKtmV6uC_U%&c5W(Mg=cK95z1VmII zU598;2O1_V&NR6@v&PVuH9PD-m$MW=tY3)`!LWHStwAqp6S_Ps+S~QfufC0g!nW+p2Q?Mz5CmCBaIj}FCd%R0RgD%%Q#-Jrn}ld8Jo z^{hiQFqJXV6X;C-(l~G(%1tbKIa!Msp8$h`NCKL%w*X>!j2(+$ssqY3fb*#0;BwPZ zBJYcp6MrkiAwDdU$3`Py7Z66u@`Irez$CF#wiq9Vl1nxXGLF-xk$U$KC zvf@6FvOrEU0f|T7EF+(%n~b%O(L%&)E~K=2d0lF_u@-JsM2ohtivtqT-^yaD z6Z6&}4B*uyJ&Zt-$6d#y=HbZxXn5`=q3VD4uk~5G*r?fkov0FQGd}KNQwQ~`42;l;sqKq zN1R>ha>f3Ex9V^f*X@>*O3;O${7S(PXJm8@==p zxY$e`T{m$b7;GgZk_f?yS>(A$f#LKLAK0VKoAr2M)-x2T-QXWqOc0N?Q;n0;xdO zyOQ4)K-a@kg$Mwi`MDa1fR?NiV4qyw+(N0GMO3TWF4Q1J%1a;B@SgY`7dGQ41Z|wku{Ps>w?B0m6R}nR63c{ z;eFlFR|^6Of`&(zXoayI=Ipgzhr^K zYG26JGe9+Q=jkewhAsh|{}qrAo$DbFKyqEyICqW$7Kk5D>VcGdL^PkO*KXB-< zEdfh(`iJrjPxL>AJC-kXMmWL6@Uc}U*>r!HeD|Tndso((c6zxV?$Ln6ZgZHoeCY;+ zxLXZ~Sg;M;WGs@7x`p`@C*~kYBJ!ydg&|f3>ey5Hks2?Gxvrtmo)rjAH?hl7{%qyx$w z%MX5OCzX`B^v$VFUPCKKA6-5#!I4m(P^^kT$GcW+nlP-u`>AwxY<9ACA<_8i2UKqI zXKiIIMKypg(F7Sb6iARgAe4>sdjE795mhgl8I5pXgQ(qsR-~2~z(NnWDYDKwu0kJw zJ-d3-l~Pv0^f$TOl^nkwM}%_CG}?x&+kNHjmH;|xp%=l0azx7Dj6_?H)>~k7)ofjo zN#@3(PR$sI+@h|E_Fh%uC^UCHp4T@O0cts2__9|~Y5uqAY53Z19pWJ2Q-ptT)ar_L zG_T)A1ng(G2yT}s;Ol}I0>WJRWE2HNffZ@u|0^6cvb%lD+$+WM;EFew(nSRnmVB7 zK+^TFk$n?>Jch_8XnfD!N-1OsVHA|0d1@TZ;?GL6wzQDQZOrj@`~hQxC(CPXs|MN& zXTMvP0?M_&`{pKHzxsH|Q4@N7@10-nc>^xu!fb{EBXqeJd&^I_J^sTSPzulc#f7db zkK5i8o8Q)oE<DaNqQdj7ub|OtU&V1?U0Q>lJ>7iDM1LlMt2Lc1n-&&eGxqXxs&nJ>u}y8%GLl?`xM*831B|LMs| zA^xXkpTAVm^}+lSGCNL!T0u=!MgT2v?Q`}HeYBv9)Du&nYY?1my*U6m)44NK#{?v1@t%Juu( znb2jG!oR|$4=WKG>)DjFQ0OSuKC!s5TfC+vPlk(6k-guxZ?MFn0th;6MtAo2lcyWG zR9{m4W4)9AP;IM;XNI?l}%5GAPwct_$Yren&hlk0Sxq8i=Q` z*dxogh-0O(5al9$l?KM;?m^skEV-uVIf2A&gl$!#Y!0~F_{>BfDBSGnUoa(ABC`_) znsW>qaDtNmsubqcI@7R`*8|BRWS7VT?3I>)>Dul?-J8 z_}Be*B0A;wG_pUc7qfxLyN7)@l&Eg&I3c{`wb@5;`}u1_;BcmD_MQJuqWnW2m2YZcP4xHpBIL{?Paop9ioQ}XsYO!ZR=9+ zzRn2dC|KhY^jX(q=X&yYF!Leinzx%YXqy)=ACLYw9Ei{V!fZ2-O@>j0-9YGF%0Ax! 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 literal 0 HcmV?d00001 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 @@ + + + +SNPcki diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..84f02f7 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,27 @@ +# Benchmarks + +Benchmarks use simulated *M. tuberculosis*-like genomes (4.4 Mbp, ~65% GC, 3.6% variable sites). + +## Scaling by number of sequences + +

+ Benchmark: sequence scaling +

+ +## Scaling by sequence length + +

+ Benchmark: length scaling +

+ +SNPick maintains **O(L)** memory regardless of sequence count, while snp-sites requires +**O(N × L)** — it holds the full matrix in memory and is eventually killed on large inputs. + +| Dataset | SNPick | snp-sites | +|---|---|---| +| 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 | + +!!! tip "Reproducing" + Wall-clock depends on core count; pin it with [`--threads`](usage.md#control-threads-hpc-reproducibility) + 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/index.md b/docs/index.md new file mode 100644 index 0000000..d675030 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,67 @@ +# SNPick + +

+ SNPick logo +

+ +**Fast, memory-efficient extraction of variable sites from FASTA alignments.** + +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. + +## 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 | +| ASC `fconst` output | :material-check: Built-in | :material-close: Not supported | +| VCF output | :material-check: Optional | :material-check: Default | +| Gap handling | :material-check: Optional (`-g`) | :material-check: Default | +| IUPAC ambiguous | :material-check: Tracked as ambiguous | :material-alert: Treated as variant | + +## Quick start + +```bash +# Install from Bioconda +conda install -c bioconda snpick + +# Extract variable sites +snpick -f alignment.fasta -o snps.fasta + +# With a VCF, on 8 threads, quietly +snpick -f alignment.fasta -o snps.fasta --vcf -t 8 -q +``` + +
+ +- :material-download: **[Installation](installation.md)** — Bioconda, source, or a pre-built binary +- :material-console: **[Usage](usage.md)** — every flag, with examples +- :material-file-document: **[Output formats](output.md)** — reduced FASTA, VCF v4.2, ASC `fconst` +- :material-chart-line: **[Benchmarks](benchmarks.md)** — scaling by sequences and length + +
+ +## Citation + +If you use SNPick in your research, please cite: + +```bibtex +@software{snpick, + author = {Ruiz-Rodriguez, Paula and Coscolla, Mireia}, + title = {SNPick: Fast extraction of variable sites from FASTA alignments}, + url = {https://github.com/PathoGenOmics-Lab/snpick}, + doi = {10.5281/zenodo.14191809}, + license = {GPL-3.0} +} +``` + +Paula Ruiz-Rodriguez and Mireia Coscolla — Institute for Integrative Systems Biology, +I²SysBio, University of Valencia-CSIC, Valencia, Spain. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..8528889 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,51 @@ +# Installation + +## Bioconda (recommended) + +[![Bioconda version](https://anaconda.org/bioconda/snpick/badges/version.svg)](https://anaconda.org/bioconda/snpick) + +```bash +conda install -c bioconda snpick +# or +mamba install -c bioconda snpick +``` + +## 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 +# choose one: +# 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 +``` + +!!! tip "Verify the download" + Fetch `SHA256SUMS.txt` from the same release and compare the checksum of your binary + against the matching line. + +## From source + +Requires a [Rust toolchain](https://rustup.rs/) (edition 2021). + +```bash +git clone https://github.com/PathoGenOmics-Lab/snpick.git +cd snpick +cargo build --release +# Binary at target/release/snpick +``` + +The release profile enables fat LTO and a single codegen unit for maximum throughput, so a +release build takes noticeably longer than a debug build — this is expected. + +## Check the install + +```bash +snpick --version +snpick --help +``` diff --git a/docs/output.md b/docs/output.md new file mode 100644 index 0000000..eb802d6 --- /dev/null +++ b/docs/output.md @@ -0,0 +1,84 @@ +# Output formats + +## Reduced FASTA + +The primary output (`-o`) is a FASTA where every sequence keeps only the **variable columns**, +in alignment order. Headers (ID and description) are preserved verbatim; bases are upper-cased. + +Constant and ambiguous-only columns are dropped, so the reduced alignment is a drop-in input for +phylogenetic tools while staying orders of magnitude smaller. + +!!! info "No variable sites" + If the alignment has no variable columns, SNPick writes a valid FASTA with each record's + header and an empty sequence line, and exits `0`. + +## ASC `fconst` for ascertainment-bias correction + +Removing invariant sites biases branch lengths unless the model is told how many constant sites +of each base were dropped. SNPick reports these counts on stderr, ready for IQ-TREE's `+ASC` +models: + +```text +[snpick] ASC fconst: 744123,1382922,1382180,743556 +``` + +The four numbers are the constant-site counts for **A, C, G, T**. Feed them to IQ-TREE: + +```bash +iqtree2 -s snps.fasta -m GTR+ASC -fconst 744123,1382922,1382180,743556 +``` + +## VCF v4.2 + +With `--vcf` (or `--vcf-output`), SNPick also writes a VCF v4.2 file with one row per variable +site and per-sample genotypes. + +```text +##fileformat=VCFv4.2 +##source=snpick v1.0.2 +##reference=first_sequence +##contig= +##INFO= +##FORMAT= +#CHROM POS ID REF ALT QUAL FILTER INFO FORMAT ref s1 s2 +1 2 . T C . PASS NS=3 GT 0 0 1 +1 4 . C T . PASS NS=3 GT 0 1 0 +``` + +| Field | Meaning | +|---|---| +| `CHROM` | Contig name — `1` by default, override with [`--chrom`](usage.md#set-the-vcf-contig-name) | +| `POS` | **1-based alignment column** (not an ungapped reference coordinate) | +| `REF` | Base of the first sequence; if that base is ambiguous, the first observed base in A, C, G, T order | +| `ALT` | The other observed alleles, comma-separated | +| `INFO=NS` | Number of samples with data (a called base; gaps count only under `-g`) | +| `FORMAT=GT` | Per-sample allele index: `0` = REF, `1..` = the *n*-th ALT, `.` = missing/ambiguous | + +!!! warning "POS is an alignment coordinate" + `POS` is the column index in the alignment, so when the reference sequence contains gaps it + diverges from the true genomic position, and `##contig` length is the alignment length. + +### Genotype matrix guard + +The genotype matrix is `variants × samples` bytes. To avoid accidental multi-gigabyte VCFs, +SNPick refuses to emit a VCF whose matrix would exceed **4 GB** and tells you to drop `--vcf` or +reduce the input. The reduced FASTA is unaffected by this guard. + +### Header-only VCF + +If `--vcf` is requested but there are no variable sites, SNPick still writes a valid VCF +containing just the header and sample columns (no data rows), so a Snakemake/Nextflow rule that +declares the `.vcf` as an output does not break. + +## Gaps and ambiguous bases + +- **Ambiguous bases** (N, R, Y, …) are treated as **missing data**, never as alleles. A column + is variable only if it has ≥2 standard bases (A, C, G, T). Ambiguous genotypes are written as + `.` in the VCF. +- **Gaps** (`-`) are **ignored by default**. With `-g` a gap becomes a 5th allele and, in the + VCF, is rendered as `*`. + +!!! note "The `*` gap encoding" + Writing gaps as `*` is an alignment convention shared with snp-sites. Note that in strict + VCF v4.2, `*` denotes a spanning deletion, so some downstream tools may interpret gap sites + accordingly. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..4df33c5 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +mkdocs-material>=9.5 diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..6a12ddf --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,116 @@ +# Usage + +``` +snpick [OPTIONS] --fasta --output +``` + +## Options + +| Argument | Required | Description | +|---|:---:|---| +| `-f, --fasta ` | :material-check: | Input FASTA alignment (all sequences must have equal length) | +| `-o, --output ` | :material-check: | Output FASTA containing only the variable sites | +| `-g, --include-gaps` | | Treat gaps (`-`) as a 5th character instead of ignoring them | +| `--vcf` | | Also write a VCF, named after the output (`snps.fasta` → `snps.vcf`) | +| `--vcf-output ` | | Write the VCF to a custom path (implies `--vcf`) | +| `-t, --threads ` | | Threads for the parallel scan (default: all logical cores) | +| `--chrom ` | | `CHROM` / contig name written to the VCF (default: `1`) | +| `-q, --quiet` | | Silence progress logs on stderr (errors are still reported) | +| `-h, --help` | | Print help | +| `-V, --version` | | Print version | + +## Examples + +### Basic extraction + +```bash +snpick -f alignment.fasta -o snps.fasta +``` + +**Input** (`alignment.fasta`): + +```text +>sequence1 +ATGCTAGCTAGCTAGCTA +>sequence2 +ATGCTAGCTGGCTAGCTA +>sequence3 +ATGCTAGCTAGCTAGCTA +``` + +**Output** (`snps.fasta`): + +```text +>sequence1 +A +>sequence2 +G +>sequence3 +A +``` + +**stderr:** + +```text +[snpick] Mapped 63 bytes. 3 sequences × 18 positions. +[snpick] 1 variable, 17 constant (A:4 C:4 G:4 T:5), 0 ambiguous-only, 18 total. +[snpick] ASC fconst: 4,4,4,5 +[snpick] Done in 0.00s. 1 vars from 3 seqs × 18 pos. +``` + +### With a VCF + +```bash +snpick -f alignment.fasta -o snps.fasta --vcf +# or a custom path: +snpick -f alignment.fasta -o snps.fasta --vcf-output variants.vcf +``` + +See [Output formats](output.md) for the VCF layout. + +### Set the VCF contig name + +By default the VCF `CHROM` and `##contig` are `1`. Match your reference so downstream tools +(bcftools, GATK, IGV) line up without post-processing: + +```bash +snpick -f alignment.fasta -o snps.fasta --vcf --chrom NC_000962.3 +``` + +### Include gaps + +```bash +snpick -f alignment.fasta -o snps.fasta -g +``` + +Without `-g`, gap columns never make a site variable. With `-g`, a gap is treated as a 5th +allele (rendered as `*` in the VCF). See [Gaps & ambiguity](output.md#gaps-and-ambiguous-bases). + +### Control threads (HPC / reproducibility) + +The scan is parallelised with Rayon and, by default, uses every logical core. On a shared +SLURM node, pin it to your allocation: + +```bash +snpick -f alignment.fasta -o snps.fasta -t 8 +``` + +!!! note "Determinism" + The thread count **never changes the output** — the per-position bitmask is merged with a + commutative OR — only the wall-clock time. + +### Quiet mode for pipelines + +```bash +snpick -f alignment.fasta -o snps.fasta --vcf -q +``` + +All progress goes to **stderr** and `stdout` stays clean, so `-q` is only needed to silence the +`[snpick]` chatter; errors are always printed. + +## Exit codes + +| Code | Meaning | +|:---:|---| +| `0` | Success (including the "no variable sites" case) | +| `1` | Error — bad input, unequal sequence lengths, unwritable output, etc. | diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ef51029 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,83 @@ +site_name: SNPick +site_description: Fast, memory-efficient extraction of variable sites from FASTA alignments +site_url: https://pathogenomics-lab.github.io/snpick/ +site_author: Paula Ruiz-Rodriguez +repo_url: https://github.com/PathoGenOmics-Lab/snpick +repo_name: PathoGenOmics-Lab/snpick +edit_uri: edit/main/docs/ +copyright: Copyright © Paula Ruiz-Rodriguez & Mireia Coscolla — I²SysBio (UV-CSIC) + +theme: + name: material + logo: assets/logo.svg + favicon: assets/logo.png + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: deep purple + accent: pink + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: deep purple + accent: pink + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - navigation.tabs + - navigation.sections + - navigation.top + - navigation.tracking + - navigation.instant + - toc.follow + - search.suggest + - search.highlight + - content.code.copy + - content.tabs.link + icon: + repo: fontawesome/brands/github + +nav: + - Home: index.md + - Installation: installation.md + - Usage: usage.md + - Output formats: output.md + - Benchmarks: benchmarks.md + - Architecture: architecture.md + - Contributing: contributing.md + - Changelog: changelog.md + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - tables + - footnotes + - toc: + permalink: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.details + - pymdownx.tabbed: + alternate_style: true + - pymdownx.snippets: + base_path: ["."] + check_paths: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + +plugins: + - search + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/PathoGenOmics-Lab + - icon: fontawesome/solid/flask + link: https://github.com/PathoGenOmics-Lab/snpick From c8345b4cc3a955a0026564acb32047cb4a94e7b1 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:31:45 +0200 Subject: [PATCH 18/62] docs: enrich the site with Material feature set Add navigation tabs/sections/instant + footer, light/dark/auto palette toggle with the brand colours, an announcement bar, content tabs on the install page, a Mermaid architecture diagram, code annotations, an abbreviations glossary with tooltips, tags, image lightbox, last-updated dates, HTML minification, and social cards (built in CI, where Cairo is installed). Update the Docs workflow with the native deps and full git history. --- .github/workflows/docs.yml | 14 ++++- .gitignore | 3 ++ docs/architecture.md | 21 +++++--- docs/benchmarks.md | 5 ++ docs/includes/abbreviations.md | 16 ++++++ docs/index.md | 62 +++++++++++++++------ docs/installation.md | 72 ++++++++++++++----------- docs/output.md | 7 +++ docs/requirements.txt | 5 +- docs/stylesheets/extra.css | 62 +++++++++++++++++++++ docs/tags.md | 10 ++++ docs/usage.md | 11 +++- mkdocs.yml | 99 +++++++++++++++++++++++++++++----- overrides/main.html | 10 ++++ 14 files changed, 329 insertions(+), 68 deletions(-) create mode 100644 docs/includes/abbreviations.md create mode 100644 docs/stylesheets/extra.css create mode 100644 docs/tags.md create mode 100644 overrides/main.html diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 963a54a..9479fcf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,6 +5,7 @@ on: branches: [ main ] paths: - 'docs/**' + - 'overrides/**' - 'mkdocs.yml' - 'CHANGELOG.md' - '.github/CONTRIBUTING.md' @@ -19,13 +20,24 @@ jobs: 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 + - 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/.gitignore b/.gitignore index 10adb89..f347511 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ test_*.vcf # MkDocs build output site/ + +# MkDocs Material social-cards / plugin cache +.cache/ diff --git a/docs/architecture.md b/docs/architecture.md index 84e632e..3a8b4c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,11 +1,20 @@ +--- +tags: + - internals +--- + # Architecture -```text -Input FASTA ──mmap──▶ Index records ──▶ Pass 1: bitmask scan ──▶ Analyze - │ (parallel) │ - │ ▼ - └──────────▶ Pass 2: extract sites ──▶ FASTA + VCF - (sparse random access) +```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 — **no copies of diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 84f02f7..8f35f3c 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,3 +1,8 @@ +--- +tags: + - performance +--- + # Benchmarks Benchmarks use simulated *M. tuberculosis*-like genomes (4.4 Mbp, ~65% GC, 3.6% variable sites). 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 index d675030..f2721cb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,20 @@ +--- +tags: + - overview +--- + # SNPick -

- SNPick logo +

+ ![SNPick logo](assets/logo.png){ width="600" }

**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. @@ -15,6 +24,38 @@ alignments ready for phylogenetic inference with ascertainment-bias correction ( 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, and gap/ambiguity handling. + +- :material-lightning-bolt:{ .lg .middle } __Built for scale__ + + --- + + Zero-copy mmap + parallel scan: **O(L)** memory, thousands of genomes in seconds. + +
+ ## SNPick vs snp-sites | | **SNPick** | **snp-sites** | @@ -22,10 +63,10 @@ alignments ready for phylogenetic inference with ascertainment-bias correction ( | 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 | -| ASC `fconst` output | :material-check: Built-in | :material-close: Not supported | -| VCF output | :material-check: Optional | :material-check: Default | -| Gap handling | :material-check: Optional (`-g`) | :material-check: Default | -| IUPAC ambiguous | :material-check: Tracked as ambiguous | :material-alert: Treated as variant | +| ASC `fconst` output | :material-check:{ .snpick-yes } Built-in | :material-close: Not supported | +| VCF output | :material-check:{ .snpick-yes } Optional | :material-check: Default | +| Gap handling | :material-check:{ .snpick-yes } Optional (`-g`) | :material-check: Default | +| IUPAC ambiguous | :material-check:{ .snpick-yes } Tracked as ambiguous | :material-alert: Treated as variant | ## Quick start @@ -40,15 +81,6 @@ snpick -f alignment.fasta -o snps.fasta snpick -f alignment.fasta -o snps.fasta --vcf -t 8 -q ``` -
- -- :material-download: **[Installation](installation.md)** — Bioconda, source, or a pre-built binary -- :material-console: **[Usage](usage.md)** — every flag, with examples -- :material-file-document: **[Output formats](output.md)** — reduced FASTA, VCF v4.2, ASC `fconst` -- :material-chart-line: **[Benchmarks](benchmarks.md)** — scaling by sequences and length - -
- ## Citation If you use SNPick in your research, please cite: diff --git a/docs/installation.md b/docs/installation.md index 8528889..f027e8a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,47 +1,55 @@ +--- +tags: + - install +--- + # Installation -## Bioconda (recommended) +=== ":simple-anaconda: Bioconda" -[![Bioconda version](https://anaconda.org/bioconda/snpick/badges/version.svg)](https://anaconda.org/bioconda/snpick) + [![Bioconda version](https://anaconda.org/bioconda/snpick/badges/version.svg)](https://anaconda.org/bioconda/snpick) -```bash -conda install -c bioconda snpick -# or -mamba install -c bioconda snpick -``` + The recommended way to install SNPick: -## Pre-built binary + ```bash + conda install -c bioconda snpick + # or + mamba install -c bioconda snpick + ``` -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: +=== ":material-download: Pre-built binary" -```bash -# choose one: -# 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 -``` + 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: -!!! tip "Verify the download" - Fetch `SHA256SUMS.txt` from the same release and compare the checksum of your binary - against the matching line. + ```bash + # choose one: + # 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 + ``` -## From source + !!! tip "Verify the download" + Fetch `SHA256SUMS.txt` from the same release and compare the checksum of your binary + against the matching line. -Requires a [Rust toolchain](https://rustup.rs/) (edition 2021). +=== ":material-language-rust: From source" -```bash -git clone https://github.com/PathoGenOmics-Lab/snpick.git -cd snpick -cargo build --release -# Binary at target/release/snpick -``` + Requires a [Rust toolchain](https://rustup.rs/) (edition 2021). + + ```bash + git clone https://github.com/PathoGenOmics-Lab/snpick.git + cd snpick + cargo build --release + # Binary at target/release/snpick + ``` -The release profile enables fat LTO and a single codegen unit for maximum throughput, so a -release build takes noticeably longer than a debug build — this is expected. + !!! note "Release builds are slow on purpose" + The release profile enables fat LTO and a single codegen unit for maximum throughput, so + a release build takes noticeably longer than a debug build. ## Check the install diff --git a/docs/output.md b/docs/output.md index eb802d6..467cc45 100644 --- a/docs/output.md +++ b/docs/output.md @@ -1,3 +1,10 @@ +--- +tags: + - vcf + - output + - phylogenetics +--- + # Output formats ## Reduced FASTA diff --git a/docs/requirements.txt b/docs/requirements.txt index 4df33c5..af752ae 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1,4 @@ -mkdocs-material>=9.5 +mkdocs-material[imaging]>=9.5 +mkdocs-git-revision-date-localized-plugin>=1.2 +mkdocs-glightbox>=0.4 +mkdocs-minify-plugin>=0.8 diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..16400c8 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,62 @@ +/* ------------------------------------------------------------------ * + * SNPick — brand palette & polish + * ------------------------------------------------------------------ */ + +:root { + --md-primary-fg-color: #5e35b1; + --md-primary-fg-color--light: #7e57c2; + --md-primary-fg-color--dark: #4527a0; + --md-accent-fg-color: #ff0077; + --snpick-alt: #6ee36a; +} + +[data-md-color-scheme="slate"] { + --md-accent-fg-color: #ff4d97; +} + +/* Brand gradient on the primary heading of the landing page */ +.md-typeset h1#snpick { + background: linear-gradient(90deg, var(--md-primary-fg-color--light), var(--snpick-alt)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + font-weight: 800; + letter-spacing: -0.02em; +} + +/* Cards: subtle lift on hover */ +.md-typeset .grid.cards > ul > li, +.md-typeset .grid > .card { + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} +.md-typeset .grid.cards > ul > li:hover { + border-color: var(--md-accent-fg-color); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.12); + transform: translateY(-2px); +} + +/* Centre the hero logo and give it breathing room */ +.md-typeset h1#snpick + p img, +.md-typeset p > img[alt="SNPick logo"] { + display: block; + margin: 1.2rem auto 0.6rem; +} + +/* Tighten the comparison / option tables */ +.md-typeset table:not([class]) th { + background: var(--md-primary-fg-color); + color: #fff; +} + +/* "Last updated" footer meta a touch quieter */ +.md-source-file { + opacity: 0.85; +} + +/* Green "supported" checks in comparison tables */ +.md-typeset .snpick-yes { + color: #2e9e2e; +} +[data-md-color-scheme="slate"] .md-typeset .snpick-yes { + color: var(--snpick-alt); +} diff --git a/docs/tags.md b/docs/tags.md new file mode 100644 index 0000000..c177011 --- /dev/null +++ b/docs/tags.md @@ -0,0 +1,10 @@ +--- +hide: + - toc +--- + +# Tags + +Browse the documentation by topic. + + diff --git a/docs/usage.md b/docs/usage.md index 6a12ddf..684b4e4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,3 +1,9 @@ +--- +tags: + - cli + - usage +--- + # Usage ``` @@ -74,9 +80,12 @@ By default the VCF `CHROM` and `##contig` are `1`. Match your reference so downs (bcftools, GATK, IGV) line up without post-processing: ```bash -snpick -f alignment.fasta -o snps.fasta --vcf --chrom NC_000962.3 +snpick -f alignment.fasta -o snps.fasta --vcf --chrom NC_000962.3 # (1)! ``` +1. `--chrom` sets **both** the `##contig` header ID and the per-row `CHROM` column, and is + rejected if it contains whitespace (which would break the tab-delimited columns). + ### Include gaps ```bash diff --git a/mkdocs.yml b/mkdocs.yml index ef51029..75a670b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,36 +9,60 @@ copyright: Copyright © Paula Ruiz-Rodriguez & Mireia Coscolla — I²Sy theme: name: material + custom_dir: overrides logo: assets/logo.svg favicon: assets/logo.png + language: en palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode - media: "(prefers-color-scheme: light)" scheme: default primary: deep purple accent: pink toggle: - icon: material/weather-night + icon: material/brightness-7 name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: slate primary: deep purple accent: pink toggle: - icon: material/weather-sunny - name: Switch to light mode + icon: material/brightness-4 + name: Switch to system preference + font: + text: Inter + code: JetBrains Mono features: + - announce.dismiss + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress - navigation.tabs + - navigation.tabs.sticky - navigation.sections + - navigation.indexes - navigation.top + - navigation.footer - navigation.tracking - - navigation.instant + - navigation.path - toc.follow - search.suggest - search.highlight + - search.share - content.code.copy + - content.code.annotate + - content.code.select - content.tabs.link + - content.tooltips + - content.action.edit + - content.action.view icon: repo: fontawesome/brands/github + edit: material/pencil + view: material/eye nav: - Home: index.md @@ -49,35 +73,86 @@ nav: - Architecture: architecture.md - Contributing: contributing.md - Changelog: changelog.md + - Tags: tags.md markdown_extensions: + - abbr - admonition - attr_list + - def_list + - footnotes - md_in_html - tables - - footnotes - toc: permalink: true + title: On this page + - pymdownx.betterem + - pymdownx.caret + - pymdownx.critic + - pymdownx.details - pymdownx.highlight: anchor_linenums: true + line_spans: __span + pygments_lang_class: true - pymdownx.inlinehilite - - pymdownx.superfences - - pymdownx.details - - pymdownx.tabbed: - alternate_style: true + - pymdownx.keys + - pymdownx.mark + - pymdownx.smartsymbols - pymdownx.snippets: - base_path: ["."] + base_path: [".", "docs"] + auto_append: ["includes/abbreviations.md"] check_paths: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg plugins: - search + - tags + - glightbox: + touchNavigation: true + loop: false + effect: zoom + draggable: true + - git-revision-date-localized: + type: timeago + enable_creation_date: true + enable_git_follow: false + fallback_to_build_date: true + - social: + # Social cards need Cairo/Pango; generate them only in CI (where the libs + # are installed), so local builds don't require the native dependencies. + enabled: !ENV [CI, false] + cards_layout_options: + background_color: "#5e35b1" + - minify: + minify_html: true extra: social: - icon: fontawesome/brands/github link: https://github.com/PathoGenOmics-Lab - - icon: fontawesome/solid/flask - link: https://github.com/PathoGenOmics-Lab/snpick + name: PathoGenOmics-Lab on GitHub + - icon: fontawesome/brands/python + link: https://anaconda.org/bioconda/snpick + name: SNPick on Bioconda + +extra_css: + - stylesheets/extra.css + +# The abbreviations glossary is auto-appended via snippets, not a standalone page. +exclude_docs: | + includes/ diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..98d292a --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block announce %} + + + {% include ".icons/material/party-popper.svg" %} + + SNPick 1.0.2 is here — see what changed in the changelog. + +{% endblock %} From d49d453753fb196e4e3566ce3b86aa0a2d85a3db Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:47:44 +0200 Subject: [PATCH 19/62] docs: enlarge the header logo --- docs/stylesheets/extra.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 16400c8..d14db35 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -60,3 +60,14 @@ [data-md-color-scheme="slate"] .md-typeset .snpick-yes { color: var(--snpick-alt); } + +/* Larger brand logo in the header */ +.md-header__button.md-logo { + padding: 0.1rem 0.2rem; +} +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + height: 2.1rem; + width: auto; +} + From d3bacb3c86263234fe47f363ac13f78828b371f4 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:47:52 +0200 Subject: [PATCH 20/62] docs: add an executable Jupyter tutorial A hands-on notebook rendered via mkdocs-jupyter (execute: false, so the saved outputs ship as-is and CI needs no snpick install): build a toy alignment, extract variable sites, read the reduced FASTA/VCF, use the ASC fconst counts, plot the SNP matrix, and see gap handling. --- docs/requirements.txt | 1 + docs/tutorials/tutorial.ipynb | 428 ++++++++++++++++++++++++++++++++++ mkdocs.yml | 5 + 3 files changed, 434 insertions(+) create mode 100644 docs/tutorials/tutorial.ipynb diff --git a/docs/requirements.txt b/docs/requirements.txt index af752ae..853e02c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,3 +2,4 @@ mkdocs-material[imaging]>=9.5 mkdocs-git-revision-date-localized-plugin>=1.2 mkdocs-glightbox>=0.4 mkdocs-minify-plugin>=0.8 +mkdocs-jupyter>=0.24 diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb new file mode 100644 index 0000000..7a07933 --- /dev/null +++ b/docs/tutorials/tutorial.ipynb @@ -0,0 +1,428 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "988158c2", + "metadata": {}, + "source": [ + "# Tutorial: from an alignment to a SNP matrix\n", + "\n", + "This hands-on tutorial walks through the full SNPick workflow on a tiny toy alignment: extracting variable sites, reading the reduced FASTA and VCF, using the ASC `fconst` counts, visualising the SNP matrix, and handling gaps.\n", + "\n", + "It assumes `snpick` is on your `PATH` (e.g. `conda install -c bioconda snpick`). Every cell below is real, executed output." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "01e857c7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-18T16:42:27.061441Z", + "iopub.status.busy": "2026-07-18T16:42:27.061315Z", + "iopub.status.idle": "2026-07-18T16:42:27.601688Z", + "shell.execute_reply": "2026-07-18T16:42:27.601051Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "snpick 1.0.2\r\n" + ] + } + ], + "source": [ + "!snpick --version" + ] + }, + { + "cell_type": "markdown", + "id": "4cecf9ff", + "metadata": {}, + "source": [ + "## 1. Build a small example alignment\n", + "\n", + "We write six short sequences of equal length (an alignment is required). Most columns are constant; a handful carry SNPs, plus one ambiguous base (`N`)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "7e55b187", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-18T16:42:27.603110Z", + "iopub.status.busy": "2026-07-18T16:42:27.603021Z", + "iopub.status.idle": "2026-07-18T16:42:27.605921Z", + "shell.execute_reply": "2026-07-18T16:42:27.605522Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + ">ref\n", + "ACGTACGTACGTACGTACGTACGTACGTAC\n", + ">sample1\n", + "ACGTTCGTACGCACGTACGTACGAACGTAC\n", + ">sample2\n", + "ACGAACGTACGCACGTAGGTACGTACGTAC\n", + ">sample3\n", + "ACGTTCGTACGTACGTACGTACGAACGTAC\n", + ">sample4\n", + "ACGTACGTNCGCACGTAGGTACGTACGTAC\n", + ">sample5\n", + "ACGAACGTACGCACGTACGTACGTACGTAC\n", + "\n" + ] + } + ], + "source": [ + "seqs = {\n", + " \"ref\": \"ACGTACGTACGTACGTACGTACGTACGTAC\",\n", + " \"sample1\": \"ACGTTCGTACGCACGTACGTACGAACGTAC\",\n", + " \"sample2\": \"ACGAACGTACGCACGTAGGTACGTACGTAC\",\n", + " \"sample3\": \"ACGTTCGTACGTACGTACGTACGAACGTAC\",\n", + " \"sample4\": \"ACGTACGTNCGCACGTAGGTACGTACGTAC\",\n", + " \"sample5\": \"ACGAACGTACGCACGTACGTACGTACGTAC\",\n", + "}\n", + "with open(\"example.fasta\", \"w\") as fh:\n", + " for name, seq in seqs.items():\n", + " fh.write(f\">{name}\\n{seq}\\n\")\n", + "\n", + "print(open(\"example.fasta\").read())" + ] + }, + { + "cell_type": "markdown", + "id": "01b73957", + "metadata": {}, + "source": [ + "## 2. Extract the variable sites\n", + "\n", + "The reduced FASTA keeps only the columns that vary across samples — a much smaller, phylogenetics-ready alignment. Progress (including the ASC `fconst` counts) is printed to **stderr**." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6ebde791", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-18T16:42:27.607322Z", + "iopub.status.busy": "2026-07-18T16:42:27.607227Z", + "iopub.status.idle": "2026-07-18T16:42:27.817427Z", + "shell.execute_reply": "2026-07-18T16:42:27.816853Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[snpick] Mapped 236 bytes. 6 sequences × 30 positions.\r\n", + "[snpick] 5 variable, 25 constant (A:7 C:7 G:7 T:4), 0 ambiguous-only, 30 total.\r\n", + "[snpick] ASC fconst: 7,7,7,4\r\n", + "[snpick] Pass 1 took 0.00s.\r\n", + "[snpick] Pass 2: Wrote 6 sequences to snps.fasta.\r\n", + "[snpick] Done in 0.00s. 5 vars from 6 seqs × 30 pos.\r\n" + ] + } + ], + "source": [ + "!snpick -f example.fasta -o snps.fasta 2>&1" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "2c5608da", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-18T16:42:27.819040Z", + "iopub.status.busy": "2026-07-18T16:42:27.818913Z", + "iopub.status.idle": "2026-07-18T16:42:27.821360Z", + "shell.execute_reply": "2026-07-18T16:42:27.820987Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + ">ref\n", + "TATCT\n", + ">sample1\n", + "TTCCA\n", + ">sample2\n", + "AACGT\n", + ">sample3\n", + "TTTCA\n", + ">sample4\n", + "TACGT\n", + ">sample5\n", + "AACCT\n", + "\n" + ] + } + ], + "source": [ + "print(open(\"snps.fasta\").read())" + ] + }, + { + "cell_type": "markdown", + "id": "e66b596e", + "metadata": {}, + "source": [ + "The line `ASC fconst: A,C,G,T` reports how many constant sites of each base were dropped. Feed it straight to IQ-TREE so branch lengths stay unbiased:\n", + "\n", + "```bash\n", + "iqtree2 -s snps.fasta -m GTR+ASC -fconst \n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "78a668f1", + "metadata": {}, + "source": [ + "## 3. Generate a VCF\n", + "\n", + "Add `--vcf` for a VCF v4.2 with per-sample genotypes. `--chrom` sets the contig name so it lines up with your reference." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "52b82c84", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-18T16:42:27.822650Z", + "iopub.status.busy": "2026-07-18T16:42:27.822550Z", + "iopub.status.idle": "2026-07-18T16:42:28.026198Z", + "shell.execute_reply": "2026-07-18T16:42:28.025443Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[snpick] Mapped 236 bytes. 6 sequences × 30 positions.\r\n", + "[snpick] 5 variable, 25 constant (A:7 C:7 G:7 T:4), 0 ambiguous-only, 30 total.\r\n", + "[snpick] ASC fconst: 7,7,7,4\r\n", + "[snpick] Pass 1 took 0.00s.\r\n", + "[snpick] Pass 2: Wrote 6 sequences to snps.fasta.\r\n", + "[snpick] VCF written to snps.vcf.\r\n", + "[snpick] Done in 0.00s. 5 vars from 6 seqs × 30 pos.\r\n" + ] + } + ], + "source": [ + "!snpick -f example.fasta -o snps.fasta --vcf --chrom NC_000962.3 2>&1" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "1d893854", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-18T16:42:28.028015Z", + "iopub.status.busy": "2026-07-18T16:42:28.027866Z", + "iopub.status.idle": "2026-07-18T16:42:28.030400Z", + "shell.execute_reply": "2026-07-18T16:42:28.030036Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tref\tsample1\tsample2\tsample3\tsample4\tsample5\n", + "NC_000962.3\t4\t.\tT\tA\t.\tPASS\tNS=6\tGT\t0\t0\t1\t0\t0\t1\n", + "NC_000962.3\t5\t.\tA\tT\t.\tPASS\tNS=6\tGT\t0\t1\t0\t1\t0\t0\n", + "NC_000962.3\t12\t.\tT\tC\t.\tPASS\tNS=6\tGT\t0\t1\t1\t0\t1\t1\n", + "NC_000962.3\t18\t.\tC\tG\t.\tPASS\tNS=6\tGT\t0\t0\t1\t0\t1\t0\n", + "NC_000962.3\t24\t.\tT\tA\t.\tPASS\tNS=6\tGT\t0\t1\t0\t1\t0\t0\n" + ] + } + ], + "source": [ + "vcf = open(\"snps.vcf\").read()\n", + "# Show the column header and the data rows (skip the ## metadata lines)\n", + "for line in vcf.splitlines():\n", + " if not line.startswith(\"##\"):\n", + " print(line)" + ] + }, + { + "cell_type": "markdown", + "id": "54439878", + "metadata": {}, + "source": [ + "Note how `sample4`'s `N` at a variable site becomes a missing genotype (`.`) and is excluded from `NS` (number of samples with data)." + ] + }, + { + "cell_type": "markdown", + "id": "378aaa91", + "metadata": {}, + "source": [ + "## 4. Visualise the SNP matrix\n", + "\n", + "The reduced FASTA is small enough to plot directly: samples on the y-axis, variable positions on the x-axis, coloured by allele." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "294d8c96", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-18T16:42:28.032260Z", + "iopub.status.busy": "2026-07-18T16:42:28.032114Z", + "iopub.status.idle": "2026-07-18T16:42:28.486735Z", + "shell.execute_reply": "2026-07-18T16:42:28.486257Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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Opxkz7WrVzFJrqjEAgAAwMfp06elX79+UrRoUQkJCZECBQpIixYtZP369RIoqMQi2Vk9Zpe/h4BYtGg+z99DAByj0Zhq/h4CorkZESmyeofcD3755RepWbOmZM6cWSZNmiTly5eXv/76S1avXi19+vSRH374QQIBIRYAAAAevXv3lqCgIPn6668lffr0nsfLli0r3bp1k0BBOwEAAADMxYsXJSwszCqu3gHWTauzgYIQCwAAAHP48GFxuVxSqlQpCXSEWAAAABgNsE5BiAUAAIApXry49cMGyuSt2yHEAgAAwGTNmlUaNWokb7/9tly9elWi++OPPyRQEGIBAADgoQH25s2b8uCDD8onn3wiP/30kxw8eFDefPNNqV69ugQKltgCAABIQoF+Fa2iRYvK7t275eWXX5Z//etfcurUKcmRI4dUqVJFZs6cKYGCEAsAAAAfefLkkbfeestugYp2AgAAADgOIRYAAACOQ4gFAACA4xBiAQAA4DiEWAAAADhOsgixXbp0kVatWvl7GAAAAEgiySLE3o3+/fvbemchISFSqVIlfw8HAAAACXDfhljVrVs3efLJJ/09DAAAANzrELtkyRIpX768pE2bVrJlyyb169e3a+vu2rVLGjRoINmzZ5dMmTJJ7dq17WoP3oKCgmTWrFnSvHlzSZcunZQuXVq2b98uhw8fljp16kj69OmlRo0acuTIEc8+Y8aMsUqp7legQAHbr127dnLp0qU4x3jr1i2ZOHGiFClSxMZZsWJFG7c3vXRanz597KoU8REZGSmXL1/2uQEAAMABV+zSy461b99eXnvtNWndurVcuXJFNm/eLC6Xy37u3LmzTJ8+3e5PmTJFmjZtatfbzZAhg+cY48ePl9dff91uQ4YMkQ4dOliQHDp0qBQsWNCqo3379pVVq1Z59tGQu3jxYlmxYoWFx+7du0vv3r3lgw8+iHWcGmDff/99eeedd6R48eKyadMm6dixo10yTcP13dBjjh079q72BQAAcLu8NXOSPVfGmn9IcpXgEBsVFSVt2rSRQoUK2WNalVV169b12Xb27NmSOXNm2bhxo1Ve3bp27WqVVKUhtnr16jJy5Ehp1KiRPTZgwADbxtv169dlwYIFki9fPruvQblZs2YWlHPnzh2jYjphwgRZt26dHVtpSN6yZYtVc+82xGrIHjRokOe+hmmtDAMA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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "from matplotlib.colors import ListedColormap\n", + "from matplotlib.patches import Patch\n", + "\n", + "# Read the reduced FASTA\n", + "names, rows = [], []\n", + "for block in open(\"snps.fasta\").read().split(\">\")[1:]:\n", + " head, seq = block.split(chr(10), 1)\n", + " names.append(head.strip())\n", + " rows.append(seq.replace(chr(10), \"\").strip())\n", + "\n", + "code = {\"A\": 0, \"C\": 1, \"G\": 2, \"T\": 3, \"N\": 4, \"-\": 4}\n", + "mat = [[code.get(b, 4) for b in r] for r in rows]\n", + "\n", + "colors = [\"#2ecc71\", \"#3498db\", \"#f1c40f\", \"#e74c3c\", \"#bdc3c7\"]\n", + "cmap = ListedColormap(colors)\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 3.2))\n", + "ax.imshow(mat, cmap=cmap, vmin=0, vmax=4, aspect='auto')\n", + "ax.set_yticks(range(len(names)), names)\n", + "ax.set_xticks(range(len(rows[0])), range(1, len(rows[0]) + 1))\n", + "ax.set_xlabel(\"variable site #\")\n", + "ax.set_title(\"SNPick reduced alignment\")\n", + "legend = [Patch(facecolor=c, label=b) for b, c in zip(\"ACGT\", colors)]\n", + "legend.append(Patch(facecolor=colors[4], label=\"N / -\"))\n", + "ax.legend(handles=legend, bbox_to_anchor=(1.01, 1), loc=\"upper left\", frameon=False)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "fc968876", + "metadata": {}, + "source": [ + "## 5. Include gaps\n", + "\n", + "By default gaps (`-`) are ignored and never make a column variable. With `-g` a gap becomes a 5th allele (rendered as `*` in the VCF)." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "4339ae5d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-18T16:42:28.487899Z", + "iopub.status.busy": "2026-07-18T16:42:28.487801Z", + "iopub.status.idle": "2026-07-18T16:42:28.893575Z", + "shell.execute_reply": "2026-07-18T16:42:28.892888Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Without -g:\n", + "[snpick] 0 variable, 8 constant (A:2 C:2 G:2 T:2), 0 ambiguous-only, 8 total.\r\n", + "[snpick] No variable positions — writing empty output.\r\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "With -g:\n", + "[snpick] 1 variable, 7 constant (A:2 C:2 G:1 T:2), 0 ambiguous-only, 8 total.\r\n" + ] + } + ], + "source": [ + "gapped = {\n", + " \"ref\": \"ACGTACGT\",\n", + " \"sample1\": \"AC-TACGT\",\n", + " \"sample2\": \"ACGTACGT\",\n", + "}\n", + "with open(\"gaps.fasta\", \"w\") as fh:\n", + " for name, seq in gapped.items():\n", + " fh.write(f\">{name}\\n{seq}\\n\")\n", + "\n", + "print(\"Without -g:\")\n", + "!snpick -f gaps.fasta -o nogap.fasta 2>&1 | grep variable\n", + "print(chr(10) + \"With -g:\")\n", + "!snpick -f gaps.fasta -o withgap.fasta -g 2>&1 | grep variable" + ] + }, + { + "cell_type": "markdown", + "id": "c86b864d", + "metadata": {}, + "source": [ + "## Next steps\n", + "\n", + "- [Usage reference](../usage.md) — every flag, with examples.\n", + "- [Output formats](../output.md) — the reduced FASTA, VCF v4.2 and `fconst` in detail.\n", + "- [Architecture](../architecture.md) — how the two-pass, memory-mapped scan works.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mkdocs.yml b/mkdocs.yml index 75a670b..e91f4c1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Home: index.md - Installation: installation.md - Usage: usage.md + - Tutorial: tutorials/tutorial.ipynb - Output formats: output.md - Benchmarks: benchmarks.md - Architecture: architecture.md @@ -122,6 +123,10 @@ markdown_extensions: plugins: - search - tags + - mkdocs-jupyter: + execute: false + include_source: true + ignore_h1_titles: true - glightbox: touchNavigation: true loop: false From 4dfe081cb633f17a7e22427dcb93fe02e6583e13 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:53:23 +0200 Subject: [PATCH 21/62] docs: fix tutorial cross-links to resolve on the built site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkdocs-jupyter does not rewrite .md links, so the Next-steps links (../usage.md etc.) resolved to /tutorials/usage.md — 404. Point them at the final page URLs (../../usage/ …). --- docs/tutorials/tutorial.ipynb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb index 7a07933..3562cc0 100644 --- a/docs/tutorials/tutorial.ipynb +++ b/docs/tutorials/tutorial.ipynb @@ -398,9 +398,9 @@ "source": [ "## Next steps\n", "\n", - "- [Usage reference](../usage.md) — every flag, with examples.\n", - "- [Output formats](../output.md) — the reduced FASTA, VCF v4.2 and `fconst` in detail.\n", - "- [Architecture](../architecture.md) — how the two-pass, memory-mapped scan works.\n" + "- [Usage reference](../../usage/) — every flag, with examples.\n", + "- [Output formats](../../output/) — the reduced FASTA, VCF v4.2 and `fconst` in detail.\n", + "- [Architecture](../../architecture/) — how the two-pass, memory-mapped scan works.\n" ] } ], From 92aaa3f26122d757dd22a468c95366ac0c53e45f Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:55:17 +0200 Subject: [PATCH 22/62] docs: link to the documentation site from the README Add a docs badge, a Documentation entry in the top nav, and a callout pointing to the GitHub Pages site (including the tutorial). --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 10ff64b..307bae0 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ [![Anaconda-Version Badge](https://anaconda.org/bioconda/snpick/badges/version.svg)](https://anaconda.org/bioconda/snpick) [![Anaconda-Downloads](https://img.shields.io/conda/dn/bioconda/snpick.svg?style=flat-square&label=downloads)](https://anaconda.org/bioconda/snpick) [![install with bioconda](https://img.shields.io/badge/install%20with-bioconda-brightgreen.svg?style=flat-square)](http://bioconda.github.io/recipes/snpick/README.html) +[![Documentation](https://img.shields.io/badge/docs-online-%23af64d1?style=flat-square)](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) · [Changelog](CHANGELOG.md) +**[📖 Documentation](https://pathogenomics-lab.github.io/snpick/)** · [Quick Start](#-quick-start) · [Features](#-features) · [Usage](#-usage) · [Benchmarks](#-benchmarks) · [Citation](#-citation) · [Changelog](CHANGELOG.md) @@ -30,6 +31,8 @@ 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** | From b4d5c8bb358ccd238d92f5d2a988b567efaf5973 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:22:22 +0200 Subject: [PATCH 23/62] chore: add package metadata, richer --help, and a CI test matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill in Cargo.toml (license, description, repository, keywords, MSRV 1.74). Add per-argument help text and a NOTES/EXAMPLES section to the CLI. Run the test suite on a Linux/macOS × x86_64/aarch64 matrix (the mmap offset arithmetic was previously never tested on aarch64) plus an MSRV build job. --- .github/workflows/rust.yml | 37 ++++++++++++++++++++++++++----------- Cargo.toml | 14 ++++++++++++++ src/main.rs | 22 +++++++++++++++++++++- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0140aef..0361062 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -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/Cargo.toml b/Cargo.toml index 80fe985..affec9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,20 @@ name = "snpick" 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/"] [dependencies] clap = { version = "4.5.21", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 8236d49..4659a90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,13 +34,33 @@ macro_rules! progress { name = "snpick", version = env!("CARGO_PKG_VERSION"), author = "Paula Ruiz-Rodriguez ", - about = "A fast, memory-efficient tool for extracting variable sites from FASTA alignments." + about = "Fast, memory-efficient extraction of variable sites from FASTA alignments.", + long_about = "snpick extracts variable (SNP) sites from a whole-genome FASTA alignment, \ +producing a reduced alignment ready for phylogenetic inference (optionally with a VCF and the \ +ASC fconst constant-site counts for IQ-TREE / RAxML). It uses a zero-copy, memory-mapped, \ +parallel two-pass scan that scales to thousands of genomes with minimal RAM.", + after_help = "NOTES:\n \ +- All input sequences must have the same length (an alignment). The first sequence is the\n \ +reference for REF/ALT polarity.\n \ +- fconst is printed as A,C,G,T (the order IQ-TREE's -fconst expects).\n \ +- In the VCF, POS is the 1-based ALIGNMENT column, not an ungapped reference coordinate.\n \ +- IUPAC ambiguous bases (N, R, Y, ...) are treated as missing data, never as alleles.\n \ +- Gaps ('-') are ignored unless -g is given, where they become a 5th allele ('*' in the VCF).\n\n\ +EXAMPLES:\n \ +snpick -f aln.fasta -o snps.fasta\n \ +snpick -f aln.fasta -o snps.fasta --vcf --chrom NC_000962.3\n \ +snpick -f aln.fasta -o snps.fasta -g -t 8 -q" )] struct Args { + /// Input FASTA alignment (all sequences must have equal length). #[arg(short, long)] fasta: String, + /// Output FASTA containing only the variable sites. #[arg(short, long)] output: String, + /// Treat gaps ('-') as a 5th character instead of ignoring them. #[arg(short = 'g', long)] include_gaps: bool, + /// Also write a VCF, named after the output (snps.fasta -> snps.vcf). #[arg(long)] vcf: bool, + /// Write the VCF to a custom path (implies --vcf). #[arg(long)] vcf_output: Option, /// Silence progress logs on stderr (errors are still reported). #[arg(short = 'q', long)] quiet: bool, From bcbdc967c52f35ef3b046b43c35c0607a02b48bc Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:25:41 +0200 Subject: [PATCH 24/62] feat: add --reference for polarity and guard against bad sequence IDs --reference picks which record is the REF/polarity sequence (for allele polarization and the VCF ##reference line); default stays the first record. Empty sequence IDs are now rejected, and duplicate IDs error with a clear message (they yield spec-invalid VCFs and duplicate tree taxa) unless --allow-dup-ids is passed. --- src/main.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++------ src/vcf.rs | 5 ++- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4659a90..f6e862b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,13 +6,14 @@ mod vcf; use clap::Parser; use memmap2::Mmap; +use std::collections::HashSet; use std::fs::File; use std::io::{self, BufWriter, Write}; use std::path::Path; use std::time::Instant; use crate::extract::{pass2_extract, ExtractParams}; -use crate::fasta::{get_ref_seq, index_fasta}; +use crate::fasta::{get_ref_seq, index_fasta, FastaRecord}; use crate::scan::{analyze, pass1_scan}; use crate::types::*; use crate::vcf::write_vcf; @@ -70,6 +71,10 @@ struct Args { /// CHROM / contig name written to the VCF (e.g. NC_000962.3). #[arg(long, default_value = "1")] chrom: String, + /// Sequence ID to use as the REF/polarity reference (default: first sequence). + #[arg(long)] reference: Option, + /// Permit duplicate sequence IDs instead of erroring. + #[arg(long)] allow_dup_ids: bool, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -113,6 +118,39 @@ fn check_paths_differ(a: &str, b: &str) -> io::Result<()> { Ok(()) } +/// Reject empty sequence IDs, and duplicate IDs unless `allow_dups`. +/// Duplicate sample names yield spec-invalid VCFs and trees downstream tools reject. +fn validate_ids(records: &[FastaRecord], allow_dups: bool) -> io::Result<()> { + let mut seen: HashSet<&[u8]> = HashSet::with_capacity(records.len()); + for (i, rec) in records.iter().enumerate() { + if rec.id.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, + format!("Record #{} has an empty sequence ID.", i + 1))); + } + if !allow_dups && !seen.insert(rec.id) { + let id = std::str::from_utf8(rec.id).unwrap_or("?"); + return Err(io::Error::new(io::ErrorKind::InvalidData, + format!("Duplicate sequence ID '{}' (record #{}). Use --allow-dup-ids to permit.", + id, i + 1))); + } + } + Ok(()) +} + +/// Index of the record to use as the REF/polarity reference. +fn reference_index(records: &[FastaRecord], reference: &Option) -> io::Result { + match reference { + Some(id) => { + let target = id.as_bytes(); + records.iter().position(|r| r.id == target).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, + format!("--reference '{}' not found among sequence IDs.", id)) + }) + } + None => Ok(0), + } +} + // ============================================================================= // Pipeline // ============================================================================= @@ -173,13 +211,17 @@ fn run() -> io::Result<()> { let (records, seq_length, layout) = index_fasta(data)?; let num_samples = records.len(); + validate_ids(&records, args.allow_dup_ids)?; + let ref_idx = reference_index(&records, &args.reference)?; + let ref_name = std::str::from_utf8(records[ref_idx].id).unwrap_or("reference").to_string(); + progress!(quiet, "[snpick] Mapped {} bytes. {} sequences × {} positions.{}", data.len(), num_samples, seq_length, if layout.single_line { "" } else { " (multi-line FASTA)" }); // Pass 1: bitmask scan let bitmask = pass1_scan(data, &records, seq_length, layout, &lookup); - let ref_seq = get_ref_seq(data, &records[0], seq_length, layout); + let ref_seq = get_ref_seq(data, &records[ref_idx], seq_length, layout); let t1 = start.elapsed().as_secs_f64(); let (mut var_positions, site_counts) = analyze(&bitmask, &ref_seq, &lookup, args.include_gaps); @@ -209,7 +251,7 @@ fn run() -> io::Result<()> { // Still honour a requested VCF: emit a valid header-only file so a // pipeline that declares the .vcf as an output doesn't break. if let Some(ref vp) = vcf_path { - write_vcf(&[], num_samples, &[], vp, &records, seq_length, &args.chrom)?; + write_vcf(&[], num_samples, &[], vp, &records, seq_length, &args.chrom, &ref_name)?; progress!(quiet, "[snpick] VCF written to {} (header only — no variable sites).", vp); } return Ok(()); @@ -236,7 +278,7 @@ fn run() -> io::Result<()> { // Write VCF if let (Some(ref geno), Some(ref vp)) = (&vcf_geno, &vcf_path) { - write_vcf(geno, num_samples, &var_positions, vp, &records, seq_length, &args.chrom)?; + write_vcf(geno, num_samples, &var_positions, vp, &records, seq_length, &args.chrom, &ref_name)?; progress!(quiet, "[snpick] VCF written to {}.", vp); } @@ -332,7 +374,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -353,7 +395,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -375,7 +417,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, true); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -399,7 +441,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, true); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -416,7 +458,7 @@ mod tests { let vo = "/tmp/snpick_t_hdr.vcf"; let m = setup(&p); let (recs, sl, _layout) = index_fasta(&m).unwrap(); - write_vcf(&[], recs.len(), &[], vo, &recs, sl, "1").unwrap(); + write_vcf(&[], recs.len(), &[], vo, &recs, sl, "1", "ref").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let data: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); assert_eq!(data.len(), 0); @@ -458,6 +500,45 @@ mod tests { std::fs::remove_file(&p).ok(); } + #[test] fn test_validate_ids() { + let p = tmp("ids", ">a\nAC\n>a\nAT\n>b\nAG\n"); + let m = setup(&p); + let (recs, _, _) = index_fasta(&m).unwrap(); + assert!(validate_ids(&recs, false).is_err()); // duplicate 'a' + assert!(validate_ids(&recs, true).is_ok()); // allowed + std::fs::remove_file(&p).ok(); + } + + #[test] fn test_reference_index() { + let p = tmp("refi", ">a\nAC\n>b\nAT\n>c\nAG\n"); + let m = setup(&p); + let (recs, _, _) = index_fasta(&m).unwrap(); + assert_eq!(reference_index(&recs, &None).unwrap(), 0); + assert_eq!(reference_index(&recs, &Some("b".to_string())).unwrap(), 1); + assert!(reference_index(&recs, &Some("zzz".to_string())).is_err()); + std::fs::remove_file(&p).ok(); + } + + #[test] fn test_reference_polarity() { + // Choosing a different reference flips which base is REF vs ALT. + let p = tmp("refp", ">a\nAT\n>b\nCT\n"); + let m = setup(&p); + let lk = build_lookup(false); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + // default reference = record 0 ('a') -> REF A + let rs0 = get_ref_seq(&m, &recs[0], sl, layout); + let (v0, _) = analyze(&bm, &rs0, &lk, false); + assert_eq!(v0[0].ref_base, b'A'); + assert_eq!(v0[0].alt_bases, vec![b'C']); + // reference = record 1 ('b') -> REF C + let rs1 = get_ref_seq(&m, &recs[1], sl, layout); + let (v1, _) = analyze(&bm, &rs1, &lk, false); + assert_eq!(v1[0].ref_base, b'C'); + assert_eq!(v1[0].alt_bases, vec![b'A']); + std::fs::remove_file(&p).ok(); + } + #[test] fn test_all_gap_column_counted() { // Under --include-gaps an all-gap column is tallied as ambiguous, so // variable + constant + ambiguous still equals seq_length. @@ -529,7 +610,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); let c = std::fs::read_to_string(fo).unwrap(); let l: Vec<&str> = c.lines().collect(); assert_eq!(l[1], "AG"); assert_eq!(l[3], "AC"); assert_eq!(l[5], "CG"); @@ -681,7 +762,7 @@ mod tests { assert_eq!(v[0].alt_bases, vec![b'C']); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); diff --git a/src/vcf.rs b/src/vcf.rs index 5ff1a33..2900808 100644 --- a/src/vcf.rs +++ b/src/vcf.rs @@ -10,9 +10,10 @@ use crate::fasta::FastaRecord; use crate::types::{VariablePosition, IO_BUF}; /// Write VCF output from genotype matrix and variable positions. +#[allow(clippy::too_many_arguments)] pub fn write_vcf( vcf_geno: &[u8], num_samples: usize, var_positions: &[VariablePosition], - vcf_path: &str, records: &[FastaRecord], seq_length: usize, chrom: &str, + vcf_path: &str, records: &[FastaRecord], seq_length: usize, chrom: &str, reference: &str, ) -> io::Result<()> { let out = File::create(vcf_path).map_err(|e| io::Error::new(e.kind(), format!("Cannot create VCF '{}': {}", vcf_path, e)))?; @@ -21,7 +22,7 @@ pub fn write_vcf( // Header writeln!(w, "##fileformat=VCFv4.2")?; writeln!(w, "##source=snpick v{}", env!("CARGO_PKG_VERSION"))?; - writeln!(w, "##reference=first_sequence")?; + writeln!(w, "##reference={}", reference)?; writeln!(w, "##contig=", chrom, seq_length)?; writeln!(w, "##INFO=")?; writeln!(w, "##FORMAT=")?; From e10da6d75d7e52dece75839366ceb51a96f11806 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:28:42 +0200 Subject: [PATCH 25/62] feat(cli): add --stats-json and --dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --stats-json (or '-' for stdout) writes a flat JSON run summary — sequences, alignment length, variable/constant/ambiguous counts, and the fconst vector as a typed array — so pipelines stop scraping stderr for ASC parameters. --dry-run reports the statistics and exits without writing any FASTA/VCF (making -o optional). --- src/main.rs | 99 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index f6e862b..f6fb375 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,8 +55,8 @@ snpick -f aln.fasta -o snps.fasta -g -t 8 -q" struct Args { /// Input FASTA alignment (all sequences must have equal length). #[arg(short, long)] fasta: String, - /// Output FASTA containing only the variable sites. - #[arg(short, long)] output: String, + /// Output FASTA containing only the variable sites (not needed with --dry-run). + #[arg(short, long, required_unless_present = "dry_run")] output: Option, /// Treat gaps ('-') as a 5th character instead of ignoring them. #[arg(short = 'g', long)] include_gaps: bool, /// Also write a VCF, named after the output (snps.fasta -> snps.vcf). @@ -75,6 +75,10 @@ struct Args { #[arg(long)] reference: Option, /// Permit duplicate sequence IDs instead of erroring. #[arg(long)] allow_dup_ids: bool, + /// Write a machine-readable JSON run summary to this path ('-' for stdout). + #[arg(long)] stats_json: Option, + /// Report site statistics without writing any FASTA/VCF output. + #[arg(long)] dry_run: bool, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -137,6 +141,33 @@ fn validate_ids(records: &[FastaRecord], allow_dups: bool) -> io::Result<()> { Ok(()) } +/// Emit a flat JSON run summary to `path` ('-' = stdout). Hand-written (no serde); +/// only the string fields need escaping. +#[allow(clippy::too_many_arguments)] +fn write_stats_json( + path: &str, input: &str, reference: &str, seq_length: usize, num_samples: usize, + sc: &SiteCounts, include_gaps: bool, threads: usize, +) -> io::Result<()> { + let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\""); + let c = &sc.constant; + let json = format!( + "{{\n \"snpick_version\": \"{}\",\n \"input\": \"{}\",\n \"reference\": \"{}\",\n \ +\"sequences\": {},\n \"alignment_length\": {},\n \"variable_sites\": {},\n \ +\"constant_sites\": {},\n \"constant_by_base\": {{ \"A\": {}, \"C\": {}, \"G\": {}, \"T\": {} }},\n \ +\"ambiguous_sites\": {},\n \"fconst\": [{}, {}, {}, {}],\n \ +\"include_gaps\": {},\n \"threads\": {}\n}}\n", + env!("CARGO_PKG_VERSION"), esc(input), esc(reference), + num_samples, seq_length, sc.variable, + c.total(), c.a, c.c, c.g, c.t, sc.ambiguous, c.a, c.c, c.g, c.t, + include_gaps, threads, + ); + if path == "-" { + io::stdout().write_all(json.as_bytes()) + } else { + std::fs::write(path, json) + } +} + /// Index of the record to use as the REF/polarity reference. fn reference_index(records: &[FastaRecord], reference: &Option) -> io::Result { match reference { @@ -171,7 +202,9 @@ fn run() -> io::Result<()> { let lookup = build_lookup(args.include_gaps); let upper = build_upper(); - let do_vcf = args.vcf || args.vcf_output.is_some(); + let dry_run = args.dry_run; + // --dry-run reports statistics only, so it writes no FASTA/VCF. + let do_vcf = (args.vcf || args.vcf_output.is_some()) && !dry_run; // A CHROM with whitespace would break the tab-delimited VCF columns. if do_vcf && (args.chrom.is_empty() || args.chrom.bytes().any(|b| b.is_ascii_whitespace())) { @@ -179,17 +212,23 @@ fn run() -> io::Result<()> { "--chrom must be non-empty and contain no whitespace.")); } - // Validate paths - check_paths_differ(&args.fasta, &args.output)?; + // Output path (clap guarantees it is present unless --dry-run). + let out_path: Option = if dry_run { None } else { args.output.clone() }; + + // Validate paths (skip when writing nothing). + if let Some(ref out) = out_path { + check_paths_differ(&args.fasta, out)?; + } let vcf_path = if do_vcf { - let vp = args.vcf_output.unwrap_or_else(|| { - let out = Path::new(&args.output); - let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); - let parent = out.parent().unwrap_or(Path::new(".")); + let out = out_path.as_deref().unwrap_or("output"); + let vp = args.vcf_output.clone().unwrap_or_else(|| { + let o = Path::new(out); + let stem = o.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); + let parent = o.parent().unwrap_or(Path::new(".")); parent.join(format!("{}.vcf", stem)).to_string_lossy().into_owned() }); check_paths_differ(&args.fasta, &vp)?; - check_paths_differ(&args.output, &vp)?; + check_paths_differ(out, &vp)?; Some(vp) } else { None }; @@ -236,11 +275,27 @@ fn run() -> io::Result<()> { progress!(quiet, "[snpick] ASC fconst: {}", site_counts.constant.fconst()); progress!(quiet, "[snpick] Pass 1 took {:.2}s.", t1); + // Machine-readable stats sidecar (also emitted for dry-run and zero-variant). + if let Some(ref sp) = args.stats_json { + write_stats_json(sp, &args.fasta, &ref_name, seq_length, num_samples, + &site_counts, args.include_gaps, rayon::current_num_threads())?; + progress!(quiet, "[snpick] Stats JSON written to {}.", + if sp == "-" { "stdout" } else { sp }); + } + + if dry_run { + progress!(quiet, "[snpick] Dry run — no output written."); + return Ok(()); + } + + // Past the dry-run gate, an output path is guaranteed (clap-enforced). + let out = out_path.as_deref().expect("output is required unless --dry-run"); + // Handle zero-variant case if num_var == 0 { progress!(quiet, "[snpick] No variable positions — writing empty output."); - let out = File::create(&args.output)?; - let mut w = BufWriter::new(out); + let outf = File::create(out)?; + let mut w = BufWriter::new(outf); for rec in &records { w.write_all(b">")?; w.write_all(rec.id)?; @@ -270,11 +325,11 @@ fn run() -> io::Result<()> { // Pass 2: extract variable sites let ep = ExtractParams { - records: &records, output: &args.output, + records: &records, output: out, collect_vcf: do_vcf, lookup: &lookup, upper: &upper, layout, }; let vcf_geno = pass2_extract(data, &mut var_positions, &ep)?; - progress!(quiet, "[snpick] Pass 2: Wrote {} sequences to {}.", num_samples, args.output); + progress!(quiet, "[snpick] Pass 2: Wrote {} sequences to {}.", num_samples, out); // Write VCF if let (Some(ref geno), Some(ref vp)) = (&vcf_geno, &vcf_path) { @@ -509,6 +564,22 @@ mod tests { std::fs::remove_file(&p).ok(); } + #[test] fn test_stats_json() { + let sc = SiteCounts { + constant: ConstantSiteCounts { a: 7, c: 7, g: 7, t: 4 }, + variable: 5, + ambiguous: 1, + }; + let p = "/tmp/snpick_t_stats.json"; + write_stats_json(p, "aln.fasta", "H37Rv", 30, 6, &sc, false, 8).unwrap(); + let c = std::fs::read_to_string(p).unwrap(); + assert!(c.contains("\"variable_sites\": 5")); + assert!(c.contains("\"fconst\": [7, 7, 7, 4]")); + assert!(c.contains("\"reference\": \"H37Rv\"")); + assert!(c.contains("\"sequences\": 6")); + std::fs::remove_file(p).ok(); + } + #[test] fn test_reference_index() { let p = tmp("refi", ">a\nAC\n>b\nAT\n>c\nAG\n"); let m = setup(&p); From 48f84fdbb16adf19d6c7ca86dee5cb1ce74ec279 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:31:01 +0200 Subject: [PATCH 26/62] feat: add --keep-samples / --exclude-samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter records before the scan (comma-separated IDs or @file), so site classification and fconst are computed for exactly the retained samples — a site variable only because of a dropped outlier correctly becomes constant and enters fconst, which a naive seqkit-grep + snp-sites gets wrong. Unknown IDs error; the two flags are mutually exclusive. --- src/main.rs | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index f6fb375..ffea787 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,10 @@ struct Args { #[arg(long)] stats_json: Option, /// Report site statistics without writing any FASTA/VCF output. #[arg(long)] dry_run: bool, + /// Keep only these sample IDs (comma-separated, or @file with one ID per line). + #[arg(long)] keep_samples: Option, + /// Drop these sample IDs (comma-separated, or @file). Excludes --keep-samples. + #[arg(long, conflicts_with = "keep_samples")] exclude_samples: Option, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -168,6 +172,55 @@ fn write_stats_json( } } +/// Parse a sample-ID selector: a comma-separated list, or `@path` to read one ID per line +/// (blank lines and `#` comments ignored). +fn parse_id_set(spec: &str) -> io::Result>> { + let mut set = HashSet::new(); + if let Some(path) = spec.strip_prefix('@') { + let content = std::fs::read_to_string(path).map_err(|e| io::Error::new(e.kind(), + format!("Cannot read sample list '{}': {}", path, e)))?; + for line in content.lines() { + let line = line.trim(); + if !line.is_empty() && !line.starts_with('#') { + set.insert(line.as_bytes().to_vec()); + } + } + } else { + for id in spec.split(',') { + let id = id.trim(); + if !id.is_empty() { set.insert(id.as_bytes().to_vec()); } + } + } + Ok(set) +} + +/// Apply --keep-samples / --exclude-samples in place. Filtering happens before the scan, so +/// site classification and fconst are computed for exactly the retained samples. Unknown IDs +/// are an error (catches typos). +fn apply_sample_filter( + records: &mut Vec, keep: &Option, exclude: &Option, +) -> io::Result<()> { + let (spec, keeping) = match (keep, exclude) { + (Some(s), _) => (s, true), + (_, Some(s)) => (s, false), + _ => return Ok(()), + }; + let set = parse_id_set(spec)?; + let present: HashSet<&[u8]> = records.iter().map(|r| r.id).collect(); + for id in &set { + if !present.contains(id.as_slice()) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, + format!("Sample '{}' not found in the alignment.", String::from_utf8_lossy(id)))); + } + } + records.retain(|r| set.contains(r.id) == keeping); + if records.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, + "No sequences remain after sample filtering.")); + } + Ok(()) +} + /// Index of the record to use as the REF/polarity reference. fn reference_index(records: &[FastaRecord], reference: &Option) -> io::Result { match reference { @@ -247,10 +300,12 @@ fn run() -> io::Result<()> { let data = &mmap[..]; // Index records - let (records, seq_length, layout) = index_fasta(data)?; - let num_samples = records.len(); + let (mut records, seq_length, layout) = index_fasta(data)?; validate_ids(&records, args.allow_dup_ids)?; + apply_sample_filter(&mut records, &args.keep_samples, &args.exclude_samples)?; + let num_samples = records.len(); + let ref_idx = reference_index(&records, &args.reference)?; let ref_name = std::str::from_utf8(records[ref_idx].id).unwrap_or("reference").to_string(); @@ -580,6 +635,41 @@ mod tests { std::fs::remove_file(p).ok(); } + #[test] fn test_exclude_samples_reclassifies() { + // A site variable only because of one sample becomes constant (and enters + // fconst) once that sample is excluded — the correctness snp-sites misses. + let p = tmp("excl", ">a\nAA\n>b\nAA\n>c\nAT\n"); + let m = setup(&p); + let lk = build_lookup(false); + let (mut recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (v, sc) = analyze(&bm, &rs, &lk, false); + assert_eq!(v.len(), 1); + assert_eq!(sc.constant.total(), 1); + apply_sample_filter(&mut recs, &None, &Some("c".to_string())).unwrap(); + assert_eq!(recs.len(), 2); + let bm2 = pass1_scan(&m, &recs, sl, layout, &lk); + let rs2 = get_ref_seq(&m, &recs[0], sl, layout); + let (v2, sc2) = analyze(&bm2, &rs2, &lk, false); + assert_eq!(v2.len(), 0); + assert_eq!(sc2.constant.total(), 2); + std::fs::remove_file(&p).ok(); + } + + #[test] fn test_keep_samples_and_unknown() { + let p = tmp("keep", ">a\nAT\n>b\nCT\n>c\nGT\n"); + let m = setup(&p); + let (mut recs, _, _) = index_fasta(&m).unwrap(); + apply_sample_filter(&mut recs, &Some("a,c".to_string()), &None).unwrap(); + assert_eq!(recs.len(), 2); + assert_eq!(recs[0].id, b"a"); + assert_eq!(recs[1].id, b"c"); + let (mut recs2, _, _) = index_fasta(&m).unwrap(); + assert!(apply_sample_filter(&mut recs2, &Some("a,zzz".to_string()), &None).is_err()); + std::fs::remove_file(&p).ok(); + } + #[test] fn test_reference_index() { let p = tmp("refi", ">a\nAC\n>b\nAT\n>c\nAG\n"); let m = setup(&p); From 9786a5c68db372e0624e62d72ac09047e5b52a91 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:36:05 +0200 Subject: [PATCH 27/62] feat: per-site filtering (--max-missing, --mac, --maf, --min-samples, --max-alleles) A sparse counting pass over the variable columns tallies per-site allele and missingness counts, then filters which variable sites reach the reduced FASTA/VCF. Filtered sites stay variable (never reclassified as constant), so fconst and ASC remain valid. --stats-json reports both the classified variable_sites and the written_sites. Counting runs only when a filter is requested. --- src/filter.rs | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 63 +++++++++++++++-- 2 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 src/filter.rs diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..f61e078 --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,186 @@ +//! Per-site filtering: recount alleles and missingness over the variable columns. +//! +//! Filtering removes *variable* sites from the output only — it never reclassifies a site as +//! constant, so the ASC `fconst` counts (constant sites) are untouched and stay valid. + +use crate::fasta::FastaRecord; +use crate::types::{SeqLayout, BIT_A, BIT_C, BIT_G, BIT_T, BIT_GAP}; + +/// Per-variable-site allele and missingness counts across all samples. +#[derive(Clone, Copy, Default)] +pub struct SiteStat { + /// Counts of A, C, G, T. + pub counts: [u32; 4], + /// Count of gaps ('-'). + pub gap: u32, + /// Count of ambiguous / uncalled bases. + pub missing: u32, +} + +/// Thresholds for per-site filtering. `None` = no constraint. +#[derive(Clone, Copy, Default)] +pub struct SiteFilters { + /// Max allowed fraction of missing genotypes (0.0–1.0). + pub max_missing: Option, + /// Min minor-allele count. + pub mac: Option, + /// Min minor-allele frequency (0.0–1.0). + pub maf: Option, + /// Min number of samples with data. + pub min_samples: Option, + /// Max number of distinct alleles (e.g. 2 = biallelic only). + pub max_alleles: Option, +} + +impl SiteFilters { + /// Whether any threshold is set (skip the counting pass entirely if not). + pub fn active(&self) -> bool { + self.max_missing.is_some() + || self.mac.is_some() + || self.maf.is_some() + || self.min_samples.is_some() + || self.max_alleles.is_some() + } +} + +impl SiteStat { + /// Number of samples with a called allele (gaps count only under `include_gaps`). + pub fn ns(&self, include_gaps: bool) -> u32 { + let acgt: u32 = self.counts.iter().sum(); + acgt + if include_gaps { self.gap } else { 0 } + } + + /// Counts of the alleles actually present (ACGT with count > 0, plus gap if `include_gaps`). + fn allele_counts(&self, include_gaps: bool) -> Vec { + let mut v: Vec = self.counts.iter().copied().filter(|&c| c > 0).collect(); + if include_gaps && self.gap > 0 { + v.push(self.gap); + } + v + } + + /// Whether this site passes every set threshold. + pub fn passes(&self, f: &SiteFilters, num_samples: u32, include_gaps: bool) -> bool { + let ns = self.ns(include_gaps); + let missing = num_samples - ns; // each sample lands in exactly one bucket, no underflow + if let Some(ms) = f.min_samples { + if ns < ms { + return false; + } + } + if let Some(mm) = f.max_missing { + if num_samples > 0 && (missing as f64 / num_samples as f64) > mm { + return false; + } + } + let alleles = self.allele_counts(include_gaps); + if let Some(ma) = f.max_alleles { + if alleles.len() as u32 > ma { + return false; + } + } + let mac = alleles.iter().copied().min().unwrap_or(0); + if let Some(m) = f.mac { + if mac < m { + return false; + } + } + if let Some(maf) = f.maf { + if ns == 0 || (mac as f64 / ns as f64) < maf { + return false; + } + } + true + } +} + +#[inline] +fn tally(s: &mut SiteStat, bits: u8) { + // `bits` comes from the lookup table, so it is a single flag (or 0 for ambiguous). + match bits { + BIT_A => s.counts[0] += 1, + BIT_C => s.counts[1] += 1, + BIT_G => s.counts[2] += 1, + BIT_T => s.counts[3] += 1, + BIT_GAP => s.gap += 1, + _ => s.missing += 1, + } +} + +/// Count alleles and missing calls at each variable position (sparse pass over just the +/// variable columns). Gap handling follows `lookup` (built with the same `include_gaps`). +pub fn count_sites( + data: &[u8], records: &[FastaRecord], pos_indices: &[usize], + layout: SeqLayout, lookup: &[u8; 256], +) -> Vec { + let num_var = pos_indices.len(); + let mut stats = vec![SiteStat::default(); num_var]; + for rec in records { + if layout.single_line { + let base = rec.seq_offset; + for (vi, &p) in pos_indices.iter().enumerate() { + tally(&mut stats[vi], lookup[data[base + p] as usize]); + } + } else { + let mut pos = rec.seq_offset; + let end = data.len(); + let mut base_idx = 0usize; + let mut var_idx = 0usize; + while var_idx < num_var && pos < end { + let b = data[pos]; + pos += 1; + if b == b'\n' || b == b'\r' { + continue; + } + if base_idx == pos_indices[var_idx] { + tally(&mut stats[var_idx], lookup[b as usize]); + var_idx += 1; + } + base_idx += 1; + } + } + } + stats +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stat(a: u32, c: u32, g: u32, t: u32, gap: u32, missing: u32) -> SiteStat { + SiteStat { counts: [a, c, g, t], gap, missing } + } + + #[test] + fn mac_drops_singletons() { + let f = SiteFilters { mac: Some(2), ..Default::default() }; + // A:9 T:1 -> minor-allele count 1, dropped by --mac 2 + assert!(!stat(9, 0, 0, 1, 0, 0).passes(&f, 10, false)); + // A:8 T:2 -> minor-allele count 2, kept + assert!(stat(8, 0, 0, 2, 0, 0).passes(&f, 10, false)); + } + + #[test] + fn max_missing_and_min_samples() { + // 3 of 10 missing = 0.3 + let s = stat(5, 2, 0, 0, 0, 3); + assert!(s.passes(&SiteFilters { max_missing: Some(0.3), ..Default::default() }, 10, false)); + assert!(!s.passes(&SiteFilters { max_missing: Some(0.2), ..Default::default() }, 10, false)); + assert!(s.passes(&SiteFilters { min_samples: Some(7), ..Default::default() }, 10, false)); + assert!(!s.passes(&SiteFilters { min_samples: Some(8), ..Default::default() }, 10, false)); + } + + #[test] + fn max_alleles_biallelic() { + let f = SiteFilters { max_alleles: Some(2), ..Default::default() }; + assert!(stat(5, 5, 0, 0, 0, 0).passes(&f, 10, false)); // 2 alleles + assert!(!stat(4, 3, 3, 0, 0, 0).passes(&f, 10, false)); // 3 alleles + } + + #[test] + fn maf_frequency() { + let f = SiteFilters { maf: Some(0.15), ..Default::default() }; + assert!(!stat(9, 0, 0, 1, 0, 0).passes(&f, 10, false)); // maf 0.1 + assert!(stat(8, 0, 0, 2, 0, 0).passes(&f, 10, false)); // maf 0.2 + } +} diff --git a/src/main.rs b/src/main.rs index ffea787..b1cd627 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod extract; mod fasta; +mod filter; mod scan; mod types; mod vcf; @@ -14,6 +15,7 @@ use std::time::Instant; use crate::extract::{pass2_extract, ExtractParams}; use crate::fasta::{get_ref_seq, index_fasta, FastaRecord}; +use crate::filter::{count_sites, SiteFilters}; use crate::scan::{analyze, pass1_scan}; use crate::types::*; use crate::vcf::write_vcf; @@ -83,6 +85,16 @@ struct Args { #[arg(long)] keep_samples: Option, /// Drop these sample IDs (comma-separated, or @file). Excludes --keep-samples. #[arg(long, conflicts_with = "keep_samples")] exclude_samples: Option, + /// Drop sites whose fraction of missing genotypes exceeds this (0.0-1.0). + #[arg(long)] max_missing: Option, + /// Drop sites whose minor-allele count is below this. + #[arg(long)] mac: Option, + /// Drop sites whose minor-allele frequency is below this (0.0-1.0). + #[arg(long)] maf: Option, + /// Drop sites with fewer than this many samples with data. + #[arg(long)] min_samples: Option, + /// Drop sites with more than this many distinct alleles (e.g. 2 = biallelic). + #[arg(long)] max_alleles: Option, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -150,18 +162,19 @@ fn validate_ids(records: &[FastaRecord], allow_dups: bool) -> io::Result<()> { #[allow(clippy::too_many_arguments)] fn write_stats_json( path: &str, input: &str, reference: &str, seq_length: usize, num_samples: usize, - sc: &SiteCounts, include_gaps: bool, threads: usize, + sc: &SiteCounts, written: usize, include_gaps: bool, threads: usize, ) -> io::Result<()> { let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\""); let c = &sc.constant; let json = format!( "{{\n \"snpick_version\": \"{}\",\n \"input\": \"{}\",\n \"reference\": \"{}\",\n \ \"sequences\": {},\n \"alignment_length\": {},\n \"variable_sites\": {},\n \ +\"written_sites\": {},\n \ \"constant_sites\": {},\n \"constant_by_base\": {{ \"A\": {}, \"C\": {}, \"G\": {}, \"T\": {} }},\n \ \"ambiguous_sites\": {},\n \"fconst\": [{}, {}, {}, {}],\n \ \"include_gaps\": {},\n \"threads\": {}\n}}\n", env!("CARGO_PKG_VERSION"), esc(input), esc(reference), - num_samples, seq_length, sc.variable, + num_samples, seq_length, sc.variable, written, c.total(), c.a, c.c, c.g, c.t, sc.ambiguous, c.a, c.c, c.g, c.t, include_gaps, threads, ); @@ -319,7 +332,6 @@ fn run() -> io::Result<()> { let t1 = start.elapsed().as_secs_f64(); let (mut var_positions, site_counts) = analyze(&bitmask, &ref_seq, &lookup, args.include_gaps); - let num_var = var_positions.len(); drop(bitmask); drop(ref_seq); @@ -330,10 +342,32 @@ fn run() -> io::Result<()> { progress!(quiet, "[snpick] ASC fconst: {}", site_counts.constant.fconst()); progress!(quiet, "[snpick] Pass 1 took {:.2}s.", t1); + // Per-site filtering (opt-in). Removes variable sites from the OUTPUT only; the fconst + // constant-site counts are unchanged, so ASC stays valid. + let filters = SiteFilters { + max_missing: args.max_missing, mac: args.mac, maf: args.maf, + min_samples: args.min_samples, max_alleles: args.max_alleles, + }; + if filters.active() && !var_positions.is_empty() { + let pos_indices: Vec = var_positions.iter().map(|v| v.index).collect(); + let stats = count_sites(data, &records, &pos_indices, layout, &lookup); + let ns_total = num_samples as u32; + let before = var_positions.len(); + let mut i = 0; + var_positions.retain(|_| { + let keep = stats[i].passes(&filters, ns_total, args.include_gaps); + i += 1; + keep + }); + progress!(quiet, "[snpick] Filtered {} of {} variable sites ({} remain).", + before - var_positions.len(), before, var_positions.len()); + } + let num_var = var_positions.len(); + // Machine-readable stats sidecar (also emitted for dry-run and zero-variant). if let Some(ref sp) = args.stats_json { write_stats_json(sp, &args.fasta, &ref_name, seq_length, num_samples, - &site_counts, args.include_gaps, rayon::current_num_threads())?; + &site_counts, num_var, args.include_gaps, rayon::current_num_threads())?; progress!(quiet, "[snpick] Stats JSON written to {}.", if sp == "-" { "stdout" } else { sp }); } @@ -626,15 +660,34 @@ mod tests { ambiguous: 1, }; let p = "/tmp/snpick_t_stats.json"; - write_stats_json(p, "aln.fasta", "H37Rv", 30, 6, &sc, false, 8).unwrap(); + write_stats_json(p, "aln.fasta", "H37Rv", 30, 6, &sc, 3, false, 8).unwrap(); let c = std::fs::read_to_string(p).unwrap(); assert!(c.contains("\"variable_sites\": 5")); + assert!(c.contains("\"written_sites\": 3")); assert!(c.contains("\"fconst\": [7, 7, 7, 4]")); assert!(c.contains("\"reference\": \"H37Rv\"")); assert!(c.contains("\"sequences\": 6")); std::fs::remove_file(p).ok(); } + #[test] fn test_count_sites() { + // pos0 singleton (A5 T1), pos1 common (A3 C3), pos2 triallelic (A2 C2 G2). + let p = tmp("cnt", ">s1\nAAA\n>s2\nAAA\n>s3\nAAC\n>s4\nACC\n>s5\nACG\n>s6\nTCG\n"); + let m = setup(&p); + let lk = build_lookup(false); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (v, _) = analyze(&bm, &rs, &lk, false); + let idx: Vec = v.iter().map(|x| x.index).collect(); + let stats = crate::filter::count_sites(&m, &recs, &idx, layout, &lk); + assert_eq!(stats.len(), 3); + assert_eq!(stats[0].counts, [5, 0, 0, 1]); + assert_eq!(stats[1].counts, [3, 3, 0, 0]); + assert_eq!(stats[2].counts, [2, 2, 2, 0]); + std::fs::remove_file(&p).ok(); + } + #[test] fn test_exclude_samples_reclassifies() { // A site variable only because of one sample becomes constant (and enters // fconst) once that sample is excluded — the correctness snp-sites misses. From 0ac202a61f219608040bef8302693174b91e2996 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:42:23 +0200 Subject: [PATCH 28/62] feat: add --mask to exclude BED regions from the analysis --mask zeroes the given alignment columns before classification, so masked regions (PE/PPE, mobile elements, resistance genes) leave the output and the fconst counts entirely. --mask-ref reads the BED in reference coordinates, mapped to alignment columns via the reference's non-gap prefix positions. --- src/coords.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 20 +++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/coords.rs diff --git a/src/coords.rs b/src/coords.rs new file mode 100644 index 0000000..8121ee8 --- /dev/null +++ b/src/coords.rs @@ -0,0 +1,107 @@ +//! Reference-anchored coordinates and BED region masking. + +use std::io; + +/// Ungapped reference position (1-based) for each alignment column. A column where the +/// reference base is a gap inherits the position of the preceding non-gap base. +pub fn ref_positions(ref_seq: &[u8]) -> Vec { + let mut out = Vec::with_capacity(ref_seq.len()); + let mut rp: u32 = 0; + for &b in ref_seq { + if b != b'-' { + rp += 1; + } + out.push(rp); + } + out +} + +/// Parse a BED file into (start, end) 0-based half-open intervals (the chrom column is ignored; +/// snpick works over a single alignment). +pub fn parse_bed(path: &str) -> io::Result> { + let content = std::fs::read_to_string(path).map_err(|e| { + io::Error::new(e.kind(), format!("Cannot read BED '{}': {}", path, e)) + })?; + let mut ivs = Vec::new(); + for (n, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() + || line.starts_with('#') + || line.starts_with("track") + || line.starts_with("browser") + { + continue; + } + let mut f = line.split('\t'); + let _chrom = f.next(); + let start = f.next().and_then(|s| s.trim().parse::().ok()); + let end = f.next().and_then(|s| s.trim().parse::().ok()); + match (start, end) { + (Some(s), Some(e)) if s <= e => ivs.push((s, e)), + _ => { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!( + "Malformed BED at line {}: expected 'chromstartend' with start <= end.", + n + 1 + ))) + } + } + } + Ok(ivs) +} + +/// Build a per-column boolean mask (true = masked). When `ref_coords` is `Some`, the intervals +/// are 0-based half-open reference positions; otherwise they are alignment columns. +pub fn build_mask( + intervals: &[(usize, usize)], seq_length: usize, ref_coords: Option<&[u32]>, +) -> Vec { + let mut mask = vec![false; seq_length]; + match ref_coords { + None => { + for &(s, e) in intervals { + let (s, e) = (s.min(seq_length), e.min(seq_length)); + for m in &mut mask[s..e] { + *m = true; + } + } + } + Some(rp) => { + for &(s, e) in intervals { + // 0-based half-open [s, e) over the reference == 1-based positions s+1 ..= e + let (lo, hi) = ((s + 1) as u32, e as u32); + for (col, &p) in rp.iter().enumerate() { + if p >= lo && p <= hi { + mask[col] = true; + } + } + } + } + } + mask +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ref_positions_gaps_inherit() { + // A - C G -> positions 1 1 2 3 + assert_eq!(ref_positions(b"A-CG"), vec![1, 1, 2, 3]); + assert_eq!(ref_positions(b"ACGT"), vec![1, 2, 3, 4]); + } + + #[test] + fn mask_alignment_columns() { + // columns [1,3) masked + let m = build_mask(&[(1, 3)], 5, None); + assert_eq!(m, vec![false, true, true, false, false]); + } + + #[test] + fn mask_reference_coordinates() { + // ref A-CG -> positions [1,1,2,3]; mask ref [1,2) = ref position 2 -> column 2 + let rp = ref_positions(b"A-CG"); + let m = build_mask(&[(1, 2)], 4, Some(&rp)); + assert_eq!(m, vec![false, false, true, false]); + } +} diff --git a/src/main.rs b/src/main.rs index b1cd627..effea60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod coords; mod extract; mod fasta; mod filter; @@ -95,6 +96,10 @@ struct Args { #[arg(long)] min_samples: Option, /// Drop sites with more than this many distinct alleles (e.g. 2 = biallelic). #[arg(long)] max_alleles: Option, + /// BED file of regions to mask out (excluded from output AND fconst). + #[arg(long)] mask: Option, + /// Interpret --mask coordinates as reference positions, not alignment columns. + #[arg(long, requires = "mask")] mask_ref: bool, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -327,10 +332,23 @@ fn run() -> io::Result<()> { if layout.single_line { "" } else { " (multi-line FASTA)" }); // Pass 1: bitmask scan - let bitmask = pass1_scan(data, &records, seq_length, layout, &lookup); + let mut bitmask = pass1_scan(data, &records, seq_length, layout, &lookup); let ref_seq = get_ref_seq(data, &records[ref_idx], seq_length, layout); let t1 = start.elapsed().as_secs_f64(); + // Mask out BED regions before classification: masked columns are zeroed, so they classify + // as ambiguous and never enter the output or the fconst constant counts. + if let Some(bed) = &args.mask { + let ivs = coords::parse_bed(bed)?; + let ref_pos = if args.mask_ref { Some(coords::ref_positions(&ref_seq)) } else { None }; + let mask = coords::build_mask(&ivs, seq_length, ref_pos.as_deref()); + let masked = mask.iter().filter(|&&m| m).count(); + for (col, &m) in mask.iter().enumerate() { + if m { bitmask[col] = 0; } + } + progress!(quiet, "[snpick] Masked {} columns from {} BED region(s).", masked, ivs.len()); + } + let (mut var_positions, site_counts) = analyze(&bitmask, &ref_seq, &lookup, args.include_gaps); drop(bitmask); From 3aad9a7507a61745e5b37390c4e7bdbddb3741d5 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:45:21 +0200 Subject: [PATCH 29/62] feat: reference-anchored coordinates (--ref-coords, --sites-output) --ref-coords sets VCF POS (and the contig length) to ungapped reference positions via a prefix sum over the reference's non-gap bases, instead of the raw alignment column. --sites-output writes a TSV mapping each variable site's alignment column to its reference position, REF and ALT. --- src/main.rs | 58 +++++++++++++++++++++++++++++++++++++++++++---------- src/vcf.rs | 14 +++++++++++-- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index effea60..a95b853 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,6 +100,10 @@ struct Args { #[arg(long)] mask: Option, /// Interpret --mask coordinates as reference positions, not alignment columns. #[arg(long, requires = "mask")] mask_ref: bool, + /// Use ungapped reference positions for VCF POS instead of the alignment column. + #[arg(long)] ref_coords: bool, + /// Write a TSV mapping each variable site (alignment_pos, ref_pos, ref, alt) to this path. + #[arg(long)] sites_output: Option, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -239,6 +243,23 @@ fn apply_sample_filter( Ok(()) } +/// Write a TSV mapping each variable site to its alignment column, reference position, +/// REF and ALT alleles (gaps rendered as '*'). +fn write_sites_tsv(path: &str, var: &[VariablePosition], ref_pos: Option<&[u32]>) -> io::Result<()> { + let mut w = BufWriter::new(File::create(path).map_err(|e| io::Error::new(e.kind(), + format!("Cannot create sites TSV '{}': {}", path, e)))?); + writeln!(w, "alignment_pos\tref_pos\tref\talt")?; + for vp in var { + let rp = ref_pos.map(|r| r[vp.index]).unwrap_or((vp.index + 1) as u32); + let refc = if vp.ref_base == b'-' { '*' } else { vp.ref_base as char }; + let alt: String = vp.alt_bases.iter() + .map(|&b| if b == b'-' { "*".to_string() } else { (b as char).to_string() }) + .collect::>().join(","); + writeln!(w, "{}\t{}\t{}\t{}", vp.index + 1, rp, refc, alt)?; + } + w.flush() +} + /// Index of the record to use as the REF/polarity reference. fn reference_index(records: &[FastaRecord], reference: &Option) -> io::Result { match reference { @@ -336,12 +357,20 @@ fn run() -> io::Result<()> { let ref_seq = get_ref_seq(data, &records[ref_idx], seq_length, layout); let t1 = start.elapsed().as_secs_f64(); + // Ungapped reference positions (only computed when a feature needs them). + let ref_pos: Option> = + if args.ref_coords || args.sites_output.is_some() || args.mask_ref { + Some(coords::ref_positions(&ref_seq)) + } else { + None + }; + // Mask out BED regions before classification: masked columns are zeroed, so they classify // as ambiguous and never enter the output or the fconst constant counts. if let Some(bed) = &args.mask { let ivs = coords::parse_bed(bed)?; - let ref_pos = if args.mask_ref { Some(coords::ref_positions(&ref_seq)) } else { None }; - let mask = coords::build_mask(&ivs, seq_length, ref_pos.as_deref()); + let rc = if args.mask_ref { ref_pos.as_deref() } else { None }; + let mask = coords::build_mask(&ivs, seq_length, rc); let masked = mask.iter().filter(|&&m| m).count(); for (col, &m) in mask.iter().enumerate() { if m { bitmask[col] = 0; } @@ -397,6 +426,13 @@ fn run() -> io::Result<()> { // Past the dry-run gate, an output path is guaranteed (clap-enforced). let out = out_path.as_deref().expect("output is required unless --dry-run"); + let pos_map = if args.ref_coords { ref_pos.as_deref() } else { None }; + + // Optional variable-site coordinate map (alignment column -> reference position). + if let Some(sp) = &args.sites_output { + write_sites_tsv(sp, &var_positions, ref_pos.as_deref())?; + progress!(quiet, "[snpick] Sites TSV written to {}.", sp); + } // Handle zero-variant case if num_var == 0 { @@ -413,7 +449,7 @@ fn run() -> io::Result<()> { // Still honour a requested VCF: emit a valid header-only file so a // pipeline that declares the .vcf as an output doesn't break. if let Some(ref vp) = vcf_path { - write_vcf(&[], num_samples, &[], vp, &records, seq_length, &args.chrom, &ref_name)?; + write_vcf(&[], num_samples, &[], vp, &records, seq_length, &args.chrom, &ref_name, pos_map)?; progress!(quiet, "[snpick] VCF written to {} (header only — no variable sites).", vp); } return Ok(()); @@ -440,7 +476,7 @@ fn run() -> io::Result<()> { // Write VCF if let (Some(ref geno), Some(ref vp)) = (&vcf_geno, &vcf_path) { - write_vcf(geno, num_samples, &var_positions, vp, &records, seq_length, &args.chrom, &ref_name)?; + write_vcf(geno, num_samples, &var_positions, vp, &records, seq_length, &args.chrom, &ref_name, pos_map)?; progress!(quiet, "[snpick] VCF written to {}.", vp); } @@ -536,7 +572,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -557,7 +593,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -579,7 +615,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, true); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -603,7 +639,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, true); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); @@ -620,7 +656,7 @@ mod tests { let vo = "/tmp/snpick_t_hdr.vcf"; let m = setup(&p); let (recs, sl, _layout) = index_fasta(&m).unwrap(); - write_vcf(&[], recs.len(), &[], vo, &recs, sl, "1", "ref").unwrap(); + write_vcf(&[], recs.len(), &[], vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let data: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); assert_eq!(data.len(), 0); @@ -842,7 +878,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(fo).unwrap(); let l: Vec<&str> = c.lines().collect(); assert_eq!(l[1], "AG"); assert_eq!(l[3], "AC"); assert_eq!(l[5], "CG"); @@ -994,7 +1030,7 @@ mod tests { assert_eq!(v[0].alt_bases, vec![b'C']); let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); - write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref").unwrap(); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); diff --git a/src/vcf.rs b/src/vcf.rs index 2900808..f578621 100644 --- a/src/vcf.rs +++ b/src/vcf.rs @@ -14,6 +14,7 @@ use crate::types::{VariablePosition, IO_BUF}; pub fn write_vcf( vcf_geno: &[u8], num_samples: usize, var_positions: &[VariablePosition], vcf_path: &str, records: &[FastaRecord], seq_length: usize, chrom: &str, reference: &str, + pos_map: Option<&[u32]>, ) -> io::Result<()> { let out = File::create(vcf_path).map_err(|e| io::Error::new(e.kind(), format!("Cannot create VCF '{}': {}", vcf_path, e)))?; @@ -23,7 +24,12 @@ pub fn write_vcf( writeln!(w, "##fileformat=VCFv4.2")?; writeln!(w, "##source=snpick v{}", env!("CARGO_PKG_VERSION"))?; writeln!(w, "##reference={}", reference)?; - writeln!(w, "##contig=", chrom, seq_length)?; + // With reference coordinates the contig length is the ungapped reference length. + let contig_len = match pos_map { + Some(rp) => *rp.last().unwrap_or(&(seq_length as u32)) as usize, + None => seq_length, + }; + writeln!(w, "##contig=", chrom, contig_len)?; writeln!(w, "##INFO=")?; writeln!(w, "##FORMAT=")?; write!(w, "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT")?; @@ -58,7 +64,11 @@ pub fn write_vcf( let ref_byte = if vp.ref_base == b'-' { b'*' } else { vp.ref_base }; row.clear(); - write!(row, "{}\t{}\t.\t", chrom, vp.index + 1)?; + let pos = match pos_map { + Some(rp) => rp[vp.index].max(1), + None => (vp.index + 1) as u32, + }; + write!(row, "{}\t{}\t.\t", chrom, pos)?; row.push(ref_byte); row.push(b'\t'); row.extend_from_slice(&alt); From f7c45897aaa816923c07826d32eb5f58d6703d82 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:48:06 +0200 Subject: [PATCH 30/62] feat: add --on-invalid and --check composition audit --on-invalid {ignore,warn,error} guards against out-of-alphabet input (a protein alignment or truncated download that still parses as equal-length would otherwise silently yield zero variable sites). Default 'ignore' skips the extra pass entirely. --check runs a read-only composition audit (A/C/G/T, N, gap, IUPAC, invalid fractions) and exits. --- src/audit.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 39 ++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/audit.rs diff --git a/src/audit.rs b/src/audit.rs new file mode 100644 index 0000000..16ea091 --- /dev/null +++ b/src/audit.rs @@ -0,0 +1,83 @@ +//! Alignment composition audit: a per-byte histogram over the sequence data, used by +//! `--on-invalid` and `--check`. Only run on demand (it is a full extra pass). + +use crate::fasta::FastaRecord; +use crate::types::SeqLayout; + +/// Count every sequence byte (newlines skipped) into a 256-entry histogram. +pub fn composition( + data: &[u8], records: &[FastaRecord], seq_length: usize, layout: SeqLayout, +) -> [u64; 256] { + let mut h = [0u64; 256]; + for rec in records { + if layout.single_line { + for &b in &data[rec.seq_offset..rec.seq_offset + seq_length] { + h[b as usize] += 1; + } + } else { + let mut pos = rec.seq_offset; + let end = data.len(); + let mut i = 0; + while i < seq_length && pos < end { + let b = data[pos]; + pos += 1; + if b == b'\n' || b == b'\r' { + continue; + } + h[b as usize] += 1; + i += 1; + } + } + } + h +} + +/// Whether a byte is part of the accepted nucleotide alphabet (ACGTU, N, IUPAC ambiguity +/// codes, and gap characters). Anything else (e.g. protein residues) is "invalid". +pub fn is_valid_nucleotide(b: u8) -> bool { + matches!( + b.to_ascii_uppercase(), + b'A' | b'C' | b'G' | b'T' | b'U' | b'N' + | b'R' | b'Y' | b'S' | b'W' | b'K' | b'M' | b'B' | b'D' | b'H' | b'V' + | b'-' | b'.' | b'?' + ) +} + +/// Total count of out-of-alphabet bytes. +pub fn invalid_count(h: &[u64; 256]) -> u64 { + (0..256u16).filter(|&i| !is_valid_nucleotide(i as u8)).map(|i| h[i as usize]).sum() +} + +/// A short human-readable composition breakdown (for `--check`). +pub fn summary(h: &[u64; 256]) -> String { + let sum = |bytes: &[u8]| -> u64 { bytes.iter().map(|&b| h[b as usize]).sum() }; + let total: u64 = h.iter().sum(); + let acgt = sum(b"ACGTacgt"); + let n = sum(b"Nn"); + let gap = sum(b"-."); + let iupac = sum(b"RYSWKMBDHVryswkmbdhvUu"); + let invalid = invalid_count(h); + let pct = |c: u64| if total == 0 { 0.0 } else { 100.0 * c as f64 / total as f64 }; + format!( + "total={} A/C/G/T={} ({:.2}%) N={} ({:.2}%) gap={} ({:.2}%) IUPAC={} ({:.2}%) invalid={} ({:.2}%)", + total, acgt, pct(acgt), n, pct(n), gap, pct(gap), iupac, pct(iupac), invalid, pct(invalid), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_detection() { + let mut h = [0u64; 256]; + h[b'A' as usize] = 10; + h[b'N' as usize] = 2; + h[b'-' as usize] = 1; + h[b'E' as usize] = 3; // protein residue -> invalid + assert_eq!(invalid_count(&h), 3); + assert!(is_valid_nucleotide(b'R')); + assert!(!is_valid_nucleotide(b'E')); + assert!(summary(&h).contains("invalid=3")); + } +} diff --git a/src/main.rs b/src/main.rs index a95b853..02e268f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod audit; mod coords; mod extract; mod fasta; @@ -33,6 +34,18 @@ macro_rules! progress { // CLI // ============================================================================= +/// Policy for out-of-alphabet bytes in the input. +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)] +enum OnInvalid { + /// Skip the check entirely (no cost). + #[default] + Ignore, + /// Warn about out-of-alphabet bytes but continue. + Warn, + /// Error out if any out-of-alphabet byte is present. + Error, +} + #[derive(Parser, Debug)] #[command( name = "snpick", @@ -58,8 +71,8 @@ snpick -f aln.fasta -o snps.fasta -g -t 8 -q" struct Args { /// Input FASTA alignment (all sequences must have equal length). #[arg(short, long)] fasta: String, - /// Output FASTA containing only the variable sites (not needed with --dry-run). - #[arg(short, long, required_unless_present = "dry_run")] output: Option, + /// Output FASTA containing only the variable sites (not needed with --dry-run/--check). + #[arg(short, long, required_unless_present_any = ["dry_run", "check"])] output: Option, /// Treat gaps ('-') as a 5th character instead of ignoring them. #[arg(short = 'g', long)] include_gaps: bool, /// Also write a VCF, named after the output (snps.fasta -> snps.vcf). @@ -104,6 +117,10 @@ struct Args { #[arg(long)] ref_coords: bool, /// Write a TSV mapping each variable site (alignment_pos, ref_pos, ref, alt) to this path. #[arg(long)] sites_output: Option, + /// What to do about out-of-alphabet bytes: ignore (default), warn, or error. + #[arg(long, value_enum, default_value_t = OnInvalid::Ignore)] on_invalid: OnInvalid, + /// Audit the alignment composition and exit without writing output. + #[arg(long)] check: bool, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -352,6 +369,24 @@ fn run() -> io::Result<()> { data.len(), num_samples, seq_length, if layout.single_line { "" } else { " (multi-line FASTA)" }); + // Composition audit for --on-invalid / --check (a full extra pass, only on demand). + if args.check || args.on_invalid != OnInvalid::Ignore { + let hist = audit::composition(data, &records, seq_length, layout); + let invalid = audit::invalid_count(&hist); + if args.on_invalid == OnInvalid::Error && invalid > 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!( + "{} out-of-alphabet byte(s) found — is this a nucleotide alignment? \ + (use --on-invalid ignore to allow).", invalid))); + } + if args.on_invalid == OnInvalid::Warn && invalid > 0 { + eprintln!("[snpick] Warning: {} out-of-alphabet byte(s) in the alignment.", invalid); + } + if args.check { + progress!(quiet, "[snpick] Composition: {}", audit::summary(&hist)); + return Ok(()); + } + } + // Pass 1: bitmask scan let mut bitmask = pass1_scan(data, &records, seq_length, layout, &lookup); let ref_seq = get_ref_seq(data, &records[ref_idx], seq_length, layout); From 1a53e381aa05b7b1b918b00507a6e1756b839cd0 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:50:08 +0200 Subject: [PATCH 31/62] feat: add --iupac-mode to optionally resolve ambiguity codes --iupac-mode resolve expands IUPAC codes (R=A|G, etc.) to their bases when classifying sites, so a column that is all-A plus one R becomes variable and leaves fconst. Resolution applies only to the pass-1 presence scan; NS counting and REF selection keep the strict A/C/G/T table, so an R is still excluded from NS and genotyped as missing. Default stays 'missing'. --- src/main.rs | 22 ++++++++++++++++++++-- src/types.rs | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 02e268f..37c227f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,16 @@ macro_rules! progress { // CLI // ============================================================================= +/// How to treat IUPAC ambiguity codes (R, Y, S, ...). +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)] +enum IupacMode { + /// Treat ambiguity codes as missing data (default). + #[default] + Missing, + /// Resolve ambiguity codes to their constituent bases when classifying sites. + Resolve, +} + /// Policy for out-of-alphabet bytes in the input. #[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)] enum OnInvalid { @@ -121,6 +131,8 @@ struct Args { #[arg(long, value_enum, default_value_t = OnInvalid::Ignore)] on_invalid: OnInvalid, /// Audit the alignment composition and exit without writing output. #[arg(long)] check: bool, + /// How to treat IUPAC ambiguity codes: missing (default) or resolve to bases. + #[arg(long, value_enum, default_value_t = IupacMode::Missing)] iupac_mode: IupacMode, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -387,8 +399,14 @@ fn run() -> io::Result<()> { } } - // Pass 1: bitmask scan - let mut bitmask = pass1_scan(data, &records, seq_length, layout, &lookup); + // Pass 1: bitmask scan. Under --iupac-mode resolve, ambiguity codes are expanded to their + // bases *only here* (for classification); NS counting and REF selection keep the strict table. + let scan_lookup = if args.iupac_mode == IupacMode::Resolve { + build_iupac_lookup(args.include_gaps) + } else { + lookup + }; + let mut bitmask = pass1_scan(data, &records, seq_length, layout, &scan_lookup); let ref_seq = get_ref_seq(data, &records[ref_idx], seq_length, layout); let t1 = start.elapsed().as_secs_f64(); diff --git a/src/types.rs b/src/types.rs index a8dab5a..a45a11f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -72,6 +72,29 @@ pub fn build_lookup(include_gaps: bool) -> [u8; 256] { t } +/// Build a nucleotide → bitmask lookup that also resolves IUPAC ambiguity codes to their +/// constituent bases (R = A|G, etc.). Used only for pass-1 classification under +/// `--iupac-mode resolve`; NS counting and REF selection keep the strict table. +pub fn build_iupac_lookup(include_gaps: bool) -> [u8; 256] { + let mut t = build_lookup(include_gaps); + let mut set = |c: u8, bits: u8| { + t[c as usize] = bits; + t[(c + 32) as usize] = bits; // lowercase + }; + set(b'R', BIT_A | BIT_G); + set(b'Y', BIT_C | BIT_T); + set(b'S', BIT_C | BIT_G); + set(b'W', BIT_A | BIT_T); + set(b'K', BIT_G | BIT_T); + set(b'M', BIT_A | BIT_C); + set(b'B', BIT_C | BIT_G | BIT_T); + set(b'D', BIT_A | BIT_G | BIT_T); + set(b'H', BIT_A | BIT_C | BIT_T); + set(b'V', BIT_A | BIT_C | BIT_G); + // N and other fully-ambiguous codes stay 0. + t +} + /// Build lowercase → uppercase lookup table. pub fn build_upper() -> [u8; 256] { let mut t = [0u8; 256]; From 6606b5ab7dcc515ac8f8bce05cb8aa08b0ddce81 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:54:29 +0200 Subject: [PATCH 32/62] feat: transparent gzip input and stdin/stdout streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect gzip/bgzip by magic bytes and decompress to a temp file that is then memory-mapped (removed on drop), preserving the two-pass random-access architecture — for both files and stdin. '-f -' reads the alignment from stdin; '-o -' writes the reduced FASTA to stdout for piping. --- Cargo.lock | 48 +++++++++++++++++++++++++++++++ Cargo.toml | 2 ++ src/extract.rs | 15 ++++++++-- src/input.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 25 ++++++++-------- 5 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 src/input.rs diff --git a/Cargo.lock b/Cargo.lock index de01fa0..48de8c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # 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 = "anstream" version = "0.6.15" @@ -51,6 +57,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.5.60" @@ -97,6 +109,15 @@ 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 = "crossbeam-deque" version = "0.8.6" @@ -128,6 +149,16 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[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 = "heck" version = "0.5.0" @@ -155,6 +186,16 @@ 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 = "proc-macro2" version = "1.0.87" @@ -193,11 +234,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "snpick" version = "1.0.2" dependencies = [ "clap", + "flate2", "memmap2", "rayon", ] diff --git a/Cargo.toml b/Cargo.toml index affec9e..40a3e58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ exclude = ["benchmarks/", "docs/", "logo/", "site/", ".github/"] clap = { version = "4.5.21", features = ["derive"] } memmap2 = "0.9.10" rayon = "1.11.0" +# Pure-Rust (miniz_oxide) backend keeps the build C-toolchain-free for Bioconda. +flate2 = "1.0" [profile.release] opt-level = 3 diff --git a/src/extract.rs b/src/extract.rs index f9e49c5..5a79f37 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -9,6 +9,17 @@ use std::io::{self, BufWriter, Write}; use crate::fasta::FastaRecord; use crate::types::*; +/// Open an output sink: a file, or stdout when the path is "-". +pub fn open_sink(path: &str) -> io::Result> { + if path == "-" { + Ok(Box::new(io::stdout().lock())) + } else { + let f = File::create(path).map_err(|e| io::Error::new(e.kind(), + format!("Cannot create output '{}': {}", path, e)))?; + Ok(Box::new(f)) + } +} + /// Parameters for variable site extraction (pass 2). pub struct ExtractParams<'a> { pub records: &'a [FastaRecord<'a>], @@ -35,9 +46,7 @@ pub fn pass2_extract( let num_samples = records.len(); let pos_indices: Vec = var_positions.iter().map(|v| v.index).collect(); - let out_file = File::create(output).map_err(|e| io::Error::new(e.kind(), - format!("Cannot create output '{}': {}", output, e)))?; - let mut writer = BufWriter::with_capacity(IO_BUF, out_file); + let mut writer = BufWriter::with_capacity(IO_BUF, open_sink(output)?); let mut vcf_geno: Vec = if collect_vcf { vec![0u8; num_var * num_samples] } else { Vec::new() }; let mut ns_counts: Vec = if collect_vcf { vec![0usize; num_var] } else { Vec::new() }; diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..e14813b --- /dev/null +++ b/src/input.rs @@ -0,0 +1,77 @@ +//! Input handling: transparent gzip/bgzip decompression and stdin, mapped to memory. +//! +//! The two-pass architecture needs random access, which a pipe or a compressed stream can't +//! provide, so compressed or piped input is spooled to a temp file that is then memory-mapped +//! and removed on drop. + +use std::fs::File; +use std::io::{self, BufReader, BufWriter, Read}; +use std::path::PathBuf; + +use flate2::read::MultiGzDecoder; +use memmap2::Mmap; + +/// A memory-mapped input, owning any temp file created for decompressed/piped data. +pub struct MappedInput { + pub mmap: Mmap, + temp: Option, +} + +impl Drop for MappedInput { + fn drop(&mut self) { + if let Some(p) = &self.temp { + let _ = std::fs::remove_file(p); + } + } +} + +fn temp_path() -> PathBuf { + std::env::temp_dir().join(format!("snpick-{}.tmp", std::process::id())) +} + +fn spool_to_temp(mut reader: impl Read) -> io::Result<(Mmap, PathBuf)> { + let tp = temp_path(); + { + let mut out = BufWriter::new(File::create(&tp)?); + io::copy(&mut reader, &mut out)?; + } + let f = File::open(&tp)?; + let mmap = unsafe { Mmap::map(&f)? }; + Ok((mmap, tp)) +} + +/// Map the input for reading. `path == "-"` reads stdin; gzip/bgzip input (detected by magic +/// bytes) is decompressed transparently. +pub fn map_input(path: &str) -> io::Result { + if path == "-" { + // Peek the first two bytes, then chain them back so gzip over stdin is detected. + let mut reader = BufReader::new(io::stdin().lock()); + let mut magic = [0u8; 2]; + let n = reader.read(&mut magic)?; + let head = std::io::Cursor::new(magic[..n].to_vec()); + let stream = head.chain(reader); + let (mmap, tp) = if n == 2 && magic == [0x1f, 0x8b] { + spool_to_temp(MultiGzDecoder::new(stream))? + } else { + spool_to_temp(stream)? + }; + return Ok(MappedInput { mmap, temp: Some(tp) }); + } + + let f = File::open(path) + .map_err(|e| io::Error::new(e.kind(), format!("Cannot open '{}': {}", path, e)))?; + + // Peek the first two bytes for the gzip magic (0x1f 0x8b); bgzip is a valid gzip stream. + let mut magic = [0u8; 2]; + let n = (&f).read(&mut magic)?; + let is_gzip = n == 2 && magic == [0x1f, 0x8b]; + + if is_gzip { + let inf = File::open(path)?; + let (mmap, tp) = spool_to_temp(MultiGzDecoder::new(BufReader::new(inf)))?; + Ok(MappedInput { mmap, temp: Some(tp) }) + } else { + let mmap = unsafe { Mmap::map(&f)? }; + Ok(MappedInput { mmap, temp: None }) + } +} diff --git a/src/main.rs b/src/main.rs index 37c227f..bcc2300 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,12 +3,12 @@ mod coords; mod extract; mod fasta; mod filter; +mod input; mod scan; mod types; mod vcf; use clap::Parser; -use memmap2::Mmap; use std::collections::HashSet; use std::fs::File; use std::io::{self, BufWriter, Write}; @@ -167,6 +167,9 @@ fn resolve_path(p: &str) -> io::Result { } fn check_paths_differ(a: &str, b: &str) -> io::Result<()> { + if a == "-" || b == "-" { + return Ok(()); // stdin/stdout can't collide with a file + } let pa = resolve_path(a)?; let pb = resolve_path(b)?; if pa == pb { @@ -353,19 +356,15 @@ fn run() -> io::Result<()> { Some(vp) } else { None }; - // Memory-map input - let file = File::open(&args.fasta).map_err(|e| io::Error::new(e.kind(), - format!("Cannot open '{}': {}", args.fasta, e)))?; - let file_len = file.metadata()?.len(); - if file_len == 0 { + // Map the input, transparently decompressing gzip/bgzip and reading stdin ("-") as needed. + let input = input::map_input(&args.fasta)?; + if input.mmap.is_empty() { return Err(io::Error::new(io::ErrorKind::InvalidData, - format!("Input file '{}' is empty (0 bytes).", args.fasta))); + format!("Input '{}' is empty (0 bytes).", args.fasta))); } - let mmap = unsafe { Mmap::map(&file).map_err(|e| io::Error::new(e.kind(), - format!("Cannot memory-map '{}': {}", args.fasta, e)))? }; // Hint: pass 1 reads sequentially; OS can prefetch and release pages eagerly - mmap.advise(memmap2::Advice::Sequential).ok(); - let data = &mmap[..]; + input.mmap.advise(memmap2::Advice::Sequential).ok(); + let data = &input.mmap[..]; // Index records let (mut records, seq_length, layout) = index_fasta(data)?; @@ -490,8 +489,7 @@ fn run() -> io::Result<()> { // Handle zero-variant case if num_var == 0 { progress!(quiet, "[snpick] No variable positions — writing empty output."); - let outf = File::create(out)?; - let mut w = BufWriter::new(outf); + let mut w = BufWriter::new(crate::extract::open_sink(out)?); for rec in &records { w.write_all(b">")?; w.write_all(rec.id)?; @@ -552,6 +550,7 @@ fn main() { #[cfg(test)] mod tests { use super::*; + use memmap2::Mmap; use crate::extract::ExtractParams; use crate::fasta::{get_ref_seq, index_fasta}; use crate::scan::{analyze, pass1_scan}; From 22c8db93f7a257823debdcaed3a5a7e2df22f08e Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:58:11 +0200 Subject: [PATCH 33/62] feat: add --format for PHYLIP and NEXUS output --format {fasta,phylip,nexus} writes the reduced alignment as relaxed PHYLIP (IQ-TREE/RAxML) or a NEXUS DATA block (MrBayes/PAUP*/SplitsTree), not just FASTA. Routing the zero-variant case through pass 2 unifies the writer so every format handles the empty-alignment case too. --- src/extract.rs | 55 +++++++++++++++++++++++++++++++------- src/main.rs | 71 +++++++++++++++++++++++++++----------------------- src/types.rs | 12 +++++++++ 3 files changed, 96 insertions(+), 42 deletions(-) diff --git a/src/extract.rs b/src/extract.rs index 5a79f37..2f81804 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -28,6 +28,7 @@ pub struct ExtractParams<'a> { pub lookup: &'a [u8; 256], pub upper: &'a [u8; 256], pub layout: SeqLayout, + pub format: OutputFormat, } /// Pass 2: extract variable sites from alignment and write output FASTA. @@ -39,15 +40,29 @@ pub struct ExtractParams<'a> { pub fn pass2_extract( data: &[u8], var_positions: &mut [VariablePosition], params: &ExtractParams<'_>, ) -> io::Result>> { - let ExtractParams { records, output, collect_vcf, lookup, upper, layout } = params; + let ExtractParams { records, output, collect_vcf, lookup, upper, layout, format } = params; let collect_vcf = *collect_vcf; let layout = *layout; + let format = *format; let num_var = var_positions.len(); let num_samples = records.len(); let pos_indices: Vec = var_positions.iter().map(|v| v.index).collect(); let mut writer = BufWriter::with_capacity(IO_BUF, open_sink(output)?); + // Format preamble. + match format { + OutputFormat::Fasta => {} + OutputFormat::Phylip => writeln!(writer, "{} {}", num_samples, num_var)?, + OutputFormat::Nexus => { + writeln!(writer, "#NEXUS")?; + writeln!(writer, "BEGIN DATA;")?; + writeln!(writer, " DIMENSIONS NTAX={} NCHAR={};", num_samples, num_var)?; + writeln!(writer, " FORMAT DATATYPE=DNA MISSING=N GAP=-;")?; + writeln!(writer, " MATRIX")?; + } + } + let mut vcf_geno: Vec = if collect_vcf { vec![0u8; num_var * num_samples] } else { Vec::new() }; let mut ns_counts: Vec = if collect_vcf { vec![0usize; num_var] } else { Vec::new() }; let mut var_buf = vec![0u8; num_var]; @@ -75,15 +90,32 @@ pub fn pass2_extract( } } - writer.write_all(b">")?; - writer.write_all(rec.id)?; - if !rec.desc.is_empty() { - writer.write_all(b" ")?; - writer.write_all(rec.desc)?; + match format { + OutputFormat::Fasta => { + writer.write_all(b">")?; + writer.write_all(rec.id)?; + if !rec.desc.is_empty() { + writer.write_all(b" ")?; + writer.write_all(rec.desc)?; + } + writer.write_all(b"\n")?; + writer.write_all(&var_buf)?; + writer.write_all(b"\n")?; + } + OutputFormat::Phylip => { + writer.write_all(rec.id)?; + writer.write_all(b" ")?; + writer.write_all(&var_buf)?; + writer.write_all(b"\n")?; + } + OutputFormat::Nexus => { + writer.write_all(b" ")?; + writer.write_all(rec.id)?; + writer.write_all(b" ")?; + writer.write_all(&var_buf)?; + writer.write_all(b"\n")?; + } } - writer.write_all(b"\n")?; - writer.write_all(&var_buf)?; - writer.write_all(b"\n")?; if collect_vcf { for (vi, &nuc) in var_buf.iter().enumerate() { @@ -95,6 +127,11 @@ pub fn pass2_extract( } } + if let OutputFormat::Nexus = format { + writeln!(writer, " ;")?; + writeln!(writer, "END;")?; + } + writer.flush()?; if collect_vcf { diff --git a/src/main.rs b/src/main.rs index bcc2300..8c5c3b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,8 @@ struct Args { #[arg(long)] check: bool, /// How to treat IUPAC ambiguity codes: missing (default) or resolve to bases. #[arg(long, value_enum, default_value_t = IupacMode::Missing)] iupac_mode: IupacMode, + /// Output format for the reduced alignment: fasta (default), phylip, or nexus. + #[arg(long, value_enum, default_value_t = OutputFormat::Fasta)] format: OutputFormat, } /// Parse a positive thread count (Rayon treats 0 as "use default", which would @@ -486,24 +488,8 @@ fn run() -> io::Result<()> { progress!(quiet, "[snpick] Sites TSV written to {}.", sp); } - // Handle zero-variant case if num_var == 0 { - progress!(quiet, "[snpick] No variable positions — writing empty output."); - let mut w = BufWriter::new(crate::extract::open_sink(out)?); - for rec in &records { - w.write_all(b">")?; - w.write_all(rec.id)?; - if !rec.desc.is_empty() { w.write_all(b" ")?; w.write_all(rec.desc)?; } - writeln!(w)?; writeln!(w)?; - } - w.flush()?; - // Still honour a requested VCF: emit a valid header-only file so a - // pipeline that declares the .vcf as an output doesn't break. - if let Some(ref vp) = vcf_path { - write_vcf(&[], num_samples, &[], vp, &records, seq_length, &args.chrom, &ref_name, pos_map)?; - progress!(quiet, "[snpick] VCF written to {} (header only — no variable sites).", vp); - } - return Ok(()); + progress!(quiet, "[snpick] No variable positions found — writing empty alignment."); } // VCF size guard @@ -520,7 +506,7 @@ fn run() -> io::Result<()> { // Pass 2: extract variable sites let ep = ExtractParams { records: &records, output: out, - collect_vcf: do_vcf, lookup: &lookup, upper: &upper, layout, + collect_vcf: do_vcf, lookup: &lookup, upper: &upper, layout, format: args.format, }; let vcf_geno = pass2_extract(data, &mut var_positions, &ep)?; progress!(quiet, "[snpick] Pass 2: Wrote {} sequences to {}.", num_samples, out); @@ -604,7 +590,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -622,7 +608,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -643,7 +629,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -665,7 +651,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, true); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -689,7 +675,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, true); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -726,7 +712,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); assert!(c.contains(">s1 some description")); @@ -776,6 +762,25 @@ mod tests { std::fs::remove_file(p).ok(); } + #[test] fn test_phylip_output() { + let p = tmp("phy", ">s1\nATGC\n>s2\nATGT\n"); + let o = "/tmp/snpick_t_phy_out.phy"; + let m = setup(&p); + let lk = build_lookup(false); + let up = build_upper(); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (mut v, _) = analyze(&bm, &rs, &lk, false); + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Phylip }; + pass2_extract(&m, &mut v, &ep).unwrap(); + let l: Vec = std::fs::read_to_string(o).unwrap().lines().map(|s| s.to_string()).collect(); + assert_eq!(l[0], "2 1"); + assert_eq!(l[1], "s1 C"); + assert_eq!(l[2], "s2 T"); + std::fs::remove_file(&p).ok(); std::fs::remove_file(o).ok(); + } + #[test] fn test_count_sites() { // pos0 singleton (A5 T1), pos1 common (A3 C3), pos2 triallelic (A2 C2 G2). let p = tmp("cnt", ">s1\nAAA\n>s2\nAAA\n>s3\nAAC\n>s4\nACC\n>s5\nACG\n>s6\nTCG\n"); @@ -910,7 +915,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -928,7 +933,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(fo).unwrap(); @@ -952,7 +957,7 @@ mod tests { let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); assert_eq!(v[0].index, 4); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -972,7 +977,7 @@ mod tests { let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); assert_eq!(v[0].index, 6); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -996,7 +1001,7 @@ mod tests { assert_eq!(v.len(), 1); assert_eq!(v[0].index, 2); let o = "/tmp/snpick_t_crlfml_out.fa"; - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -1020,7 +1025,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); assert_eq!(v[0].index, 3); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -1057,7 +1062,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); let o = "/tmp/snpick_t_noeof_out.fa"; - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -1080,7 +1085,7 @@ mod tests { assert_eq!(v.len(), 1); assert_eq!(v[0].ref_base, b'A'); assert_eq!(v[0].alt_bases, vec![b'C']); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -1109,7 +1114,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); assert_eq!(v[0].index, 3); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); diff --git a/src/types.rs b/src/types.rs index a45a11f..03306dc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -19,6 +19,18 @@ pub const MAX_VCF_GENO_BYTES: usize = 4_000_000_000; /// I/O buffer size for BufWriter (16 MB). pub const IO_BUF: usize = 16 * 1024 * 1024; +/// Output format for the reduced alignment. +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum OutputFormat { + /// FASTA (default). + #[default] + Fasta, + /// Relaxed PHYLIP (IQ-TREE / RAxML). + Phylip, + /// NEXUS DATA block (MrBayes / PAUP* / SplitsTree). + Nexus, +} + /// Whether all sequences are single-line (no embedded newlines). /// When true, `data[seq_offset + pos]` gives the base at `pos` directly. /// When false, we must scan skipping newlines for each record. From 4111d3355f6b61baf3645f11de19990f094d351e Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:02:17 +0200 Subject: [PATCH 34/62] perf: parallel prefault and an auto-vectorizable scan kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefault large mmaps in parallel instead of a single-core page-touch loop. Replace the lookup-table gather in the single-line scan with a branchless A/C/G/T(+gap) kernel that LLVM auto-vectorizes (SSE2/AVX2/NEON) — no unsafe, and a test asserts it is byte-identical to the lookup table for all 256 bytes. IUPAC-resolve tables fall back to the gather. --- src/scan.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/src/scan.rs b/src/scan.rs index 31675d8..3f6a0be 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -12,17 +12,26 @@ use crate::types::*; /// worth its overhead — roughly 50 seqs × 4M bp. const PARALLEL_MIN_WORK: usize = 200_000_000; -/// Prefault mmap pages by touching one byte per OS page. -/// Eliminates soft page faults during the scan loop (~0.5s on 1 GB files). +/// Prefault mmap pages by touching one byte per OS page. Eliminates soft page faults during +/// the scan loop. For large files this is parallelised so it is no longer a single-core stall +/// (~10 s on a 20 GB alignment) ahead of the parallel scan. #[inline(never)] fn prefault(data: &[u8]) { const PAGE: usize = 4096; - let mut sum = 0u8; - let mut off = 0; - while off < data.len() { - sum = sum.wrapping_add(data[off]); - off += PAGE; - } + let n = data.len(); + let num_pages = n.div_ceil(PAGE); + let sum: u8 = if n >= 256 * 1024 * 1024 { + (0..num_pages) + .into_par_iter() + .map(|p| data[p * PAGE]) + .reduce(|| 0u8, |a, b| a.wrapping_add(b)) + } else { + let mut s = 0u8; + for p in 0..num_pages { + s = s.wrapping_add(data[p * PAGE]); + } + s + }; std::hint::black_box(sum); } @@ -78,16 +87,39 @@ fn scan_parallel( bitmask } +/// Branchless nucleotide → bitmask for the standard A/C/G/T(+gap) alphabet. Unlike a table +/// gather, this auto-vectorises (SSE2/AVX2/NEON). `gap_mask` is 1 when gaps are counted, else 0. +/// Byte-identical to the strict lookup table for the ACGT(+gap) case. +#[inline(always)] +fn acgt_bits(b: u8, gap_mask: u8) -> u8 { + let u = b & !0x20; // fold letter case (A/a -> A) + (u == b'A') as u8 + | (((u == b'C') as u8) << 1) + | (((u == b'G') as u8) << 2) + | (((u == b'T') as u8) << 3) + | ((((b == b'-') as u8) & gap_mask) << 4) +} + /// Sequential scan of a set of records into a bitmask. fn scan_sequential( data: &[u8], records: &[FastaRecord], seq_length: usize, layout: SeqLayout, lookup: &[u8; 256], bitmask: &mut [u8], ) { + // The branchless kernel is only valid for the standard alphabet; IUPAC-resolve tables + // (where e.g. 'R' maps to a nonzero bitmask) fall back to the general gather. + let fast = lookup[b'R' as usize] == 0; + let gap_mask = if lookup[b'-' as usize] != 0 { 1u8 } else { 0u8 }; if layout.single_line { for rec in records { let seq = &data[rec.seq_offset..rec.seq_offset + seq_length]; - for (bm_byte, &seq_byte) in bitmask.iter_mut().zip(seq.iter()) { - *bm_byte |= lookup[seq_byte as usize]; + if fast { + for (bm_byte, &seq_byte) in bitmask.iter_mut().zip(seq.iter()) { + *bm_byte |= acgt_bits(seq_byte, gap_mask); + } + } else { + for (bm_byte, &seq_byte) in bitmask.iter_mut().zip(seq.iter()) { + *bm_byte |= lookup[seq_byte as usize]; + } } } } else { @@ -146,6 +178,18 @@ mod tests { use super::*; use crate::fasta::index_fasta; + #[test] + fn acgt_bits_matches_lookup() { + // The branchless kernel must be byte-identical to the strict lookup table. + for gap in [false, true] { + let lk = build_lookup(gap); + let gm = if gap { 1u8 } else { 0u8 }; + for b in 0u16..256 { + assert_eq!(acgt_bits(b as u8, gm), lk[b as usize], "byte {}", b); + } + } + } + #[test] fn parallel_matches_sequential() { // The parallel chunk/merge path is the production path for real From 1c4c2e55cf50480fa6b6da296fac5ad9a8235ad2 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:04:27 +0200 Subject: [PATCH 35/62] feat(cli): add -v/-vv verbosity and distinct exit codes -v prints thread count, layout and scan throughput; -vv adds an allele-class histogram. Level 0 stderr is unchanged. main() now exits 2 for bad input/data (InvalidData/InvalidInput) and 1 for I/O errors, so callers can distinguish user error from environment error. --- src/main.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 8c5c3b9..dba99a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,8 @@ struct Args { #[arg(long)] vcf_output: Option, /// Silence progress logs on stderr (errors are still reported). #[arg(short = 'q', long)] quiet: bool, + /// Increase detail: -v adds diagnostics, -vv adds an allele-class histogram. + #[arg(short, long, action = clap::ArgAction::Count)] verbose: u8, /// Number of threads for the parallel scan (default: all logical cores). #[arg(short = 't', long, value_parser = parse_threads)] threads: Option, @@ -465,6 +467,24 @@ fn run() -> io::Result<()> { } let num_var = var_positions.len(); + if args.verbose >= 1 && !quiet { + let mbps = data.len() as f64 / 1e6 / t1.max(1e-9); + eprintln!("[snpick] Threads: {}. Layout: {}. Scan throughput: {:.0} MB/s.", + rayon::current_num_threads(), + if layout.single_line { "single-line" } else { "multi-line" }, mbps); + } + if args.verbose >= 2 && !quiet { + let (mut bi, mut tri, mut tetra) = (0usize, 0usize, 0usize); + for vp in &var_positions { + match 1 + vp.alt_bases.len() { + 2 => bi += 1, + 3 => tri += 1, + _ => tetra += 1, + } + } + eprintln!("[snpick] Allele classes: biallelic={} triallelic={} 4+-allelic={}", bi, tri, tetra); + } + // Machine-readable stats sidecar (also emitted for dry-run and zero-variant). if let Some(ref sp) = args.stats_json { write_stats_json(sp, &args.fasta, &ref_name, seq_length, num_samples, @@ -525,7 +545,12 @@ fn run() -> io::Result<()> { fn main() { if let Err(e) = run() { eprintln!("[snpick] Error: {}", e); - std::process::exit(1); + // Exit codes: 2 for bad input/data, 1 for I/O and everything else. + let code = match e.kind() { + io::ErrorKind::InvalidData | io::ErrorKind::InvalidInput => 2, + _ => 1, + }; + std::process::exit(code); } } From 9f0ab0d1ed065ca1a305279f1706095a0bf763a9 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:05:47 +0200 Subject: [PATCH 36/62] feat(cli): show an extraction progress line on interactive terminals pass 2 prints an in-place '[snpick] Extracting sequence N/M' line, gated on a TTY stderr, non-quiet, a real file output, and >= 500 samples, so piped/redirected runs and small inputs are unaffected. --- src/extract.rs | 11 ++++++++++- src/main.rs | 38 ++++++++++++++++++++------------------ 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/extract.rs b/src/extract.rs index 2f81804..a8410a2 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -29,6 +29,7 @@ pub struct ExtractParams<'a> { pub upper: &'a [u8; 256], pub layout: SeqLayout, pub format: OutputFormat, + pub progress: bool, } /// Pass 2: extract variable sites from alignment and write output FASTA. @@ -40,10 +41,11 @@ pub struct ExtractParams<'a> { pub fn pass2_extract( data: &[u8], var_positions: &mut [VariablePosition], params: &ExtractParams<'_>, ) -> io::Result>> { - let ExtractParams { records, output, collect_vcf, lookup, upper, layout, format } = params; + let ExtractParams { records, output, collect_vcf, lookup, upper, layout, format, progress } = params; let collect_vcf = *collect_vcf; let layout = *layout; let format = *format; + let progress = *progress; let num_var = var_positions.len(); let num_samples = records.len(); let pos_indices: Vec = var_positions.iter().map(|v| v.index).collect(); @@ -125,6 +127,13 @@ pub fn pass2_extract( } } } + + if progress && (si % 256 == 0) { + eprint!("\r[snpick] Extracting sequence {}/{}", si + 1, num_samples); + } + } + if progress { + eprintln!("\r[snpick] Extracted {} sequences. ", num_samples); } if let OutputFormat::Nexus = format { diff --git a/src/main.rs b/src/main.rs index dba99a7..c2dab32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ mod vcf; use clap::Parser; use std::collections::HashSet; use std::fs::File; -use std::io::{self, BufWriter, Write}; +use std::io::{self, BufWriter, IsTerminal, Write}; use std::path::Path; use std::time::Instant; @@ -523,10 +523,12 @@ fn run() -> io::Result<()> { } } - // Pass 2: extract variable sites + // Pass 2: extract variable sites (show an in-place progress line on a TTY for big inputs). + let show_progress = !quiet && num_samples >= 500 && io::stderr().is_terminal() && out != "-"; let ep = ExtractParams { records: &records, output: out, collect_vcf: do_vcf, lookup: &lookup, upper: &upper, layout, format: args.format, + progress: show_progress, }; let vcf_geno = pass2_extract(data, &mut var_positions, &ep)?; progress!(quiet, "[snpick] Pass 2: Wrote {} sequences to {}.", num_samples, out); @@ -615,7 +617,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -633,7 +635,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -654,7 +656,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -676,7 +678,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, true); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -700,7 +702,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, true); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -737,7 +739,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); assert!(c.contains(">s1 some description")); @@ -797,7 +799,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Phylip }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Phylip, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let l: Vec = std::fs::read_to_string(o).unwrap().lines().map(|s| s.to_string()).collect(); assert_eq!(l[0], "2 1"); @@ -940,7 +942,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -958,7 +960,7 @@ mod tests { let bm = pass1_scan(&m, &recs, sl, layout, &lk); let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(fo).unwrap(); @@ -982,7 +984,7 @@ mod tests { let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); assert_eq!(v[0].index, 4); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -1002,7 +1004,7 @@ mod tests { let rs = get_ref_seq(&m, &recs[0], sl, layout); let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); assert_eq!(v[0].index, 6); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -1026,7 +1028,7 @@ mod tests { assert_eq!(v.len(), 1); assert_eq!(v[0].index, 2); let o = "/tmp/snpick_t_crlfml_out.fa"; - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -1050,7 +1052,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); assert_eq!(v[0].index, 3); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -1087,7 +1089,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); let o = "/tmp/snpick_t_noeof_out.fa"; - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); @@ -1110,7 +1112,7 @@ mod tests { assert_eq!(v.len(), 1); assert_eq!(v[0].ref_base, b'A'); assert_eq!(v[0].alt_bases, vec![b'C']); - let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", None).unwrap(); let c = std::fs::read_to_string(vo).unwrap(); @@ -1139,7 +1141,7 @@ mod tests { let (mut v, _) = analyze(&bm, &rs, &lk, false); assert_eq!(v.len(), 1); assert_eq!(v[0].index, 3); - let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta }; + let ep = ExtractParams { records: &recs, output: o, collect_vcf: false, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; pass2_extract(&m, &mut v, &ep).unwrap(); let c = std::fs::read_to_string(o).unwrap(); let l: Vec<&str> = c.lines().collect(); From 5a06d1bd69825116a736abeca6c6b1e308dc0351 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:08:55 +0200 Subject: [PATCH 37/62] refactor: expose the core as a library crate (lib.rs) Move the algorithm modules (fasta, scan, extract, vcf, filter, coords, audit, input, types) into a public library crate so they can be reused and documented on docs.rs; the binary now builds on top of it. Adds a crate-level doc example (run as a doctest). No behavior change. --- src/lib.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 32 ++++++++++++-------------------- 2 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 src/lib.rs diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..10aea0e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,42 @@ +//! # SNPick +//! +//! Fast, memory-efficient extraction of variable (SNP) sites from FASTA alignments. +//! +//! This library exposes the core of the `snpick` command-line tool as reusable modules: +//! +//! - [`fasta`] — zero-copy FASTA indexing over memory-mapped data. +//! - [`scan`] — pass 1: the parallel bitmask scan and site classification ([`scan::analyze`]). +//! - [`extract`] — pass 2: reduced FASTA / genotype-matrix extraction. +//! - [`vcf`] — VCF v4.2 writer. +//! - [`filter`] — per-site allele/missingness counting and filtering. +//! - [`coords`] — reference-anchored coordinates and BED masking. +//! - [`audit`] — alignment composition audit. +//! - [`input`] — transparent gzip/stdin input mapping. +//! - [`types`] — shared constants, lookup tables and data structures. +//! +//! # Example +//! +//! ```no_run +//! use snpick::fasta::index_fasta; +//! use snpick::scan::{analyze, pass1_scan}; +//! use snpick::types::build_lookup; +//! +//! let data: &[u8] = b">a\nACGT\n>b\nACGA\n"; +//! let lookup = build_lookup(false); +//! let (records, seq_len, layout) = index_fasta(data).unwrap(); +//! let bitmask = pass1_scan(data, &records, seq_len, layout, &lookup); +//! let ref_seq: Vec = data[3..7].to_vec(); +//! let (variable_sites, counts) = analyze(&bitmask, &ref_seq, &lookup, false); +//! assert_eq!(variable_sites.len(), 1); +//! assert_eq!(counts.constant.total(), 3); +//! ``` + +pub mod audit; +pub mod coords; +pub mod extract; +pub mod fasta; +pub mod filter; +pub mod input; +pub mod scan; +pub mod types; +pub mod vcf; diff --git a/src/main.rs b/src/main.rs index c2dab32..9b93606 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,3 @@ -mod audit; -mod coords; -mod extract; -mod fasta; -mod filter; -mod input; -mod scan; -mod types; -mod vcf; use clap::Parser; use std::collections::HashSet; @@ -15,12 +6,13 @@ use std::io::{self, BufWriter, IsTerminal, Write}; use std::path::Path; use std::time::Instant; -use crate::extract::{pass2_extract, ExtractParams}; -use crate::fasta::{get_ref_seq, index_fasta, FastaRecord}; -use crate::filter::{count_sites, SiteFilters}; -use crate::scan::{analyze, pass1_scan}; -use crate::types::*; -use crate::vcf::write_vcf; +use snpick::extract::{pass2_extract, ExtractParams}; +use snpick::fasta::{get_ref_seq, index_fasta, FastaRecord}; +use snpick::filter::{count_sites, SiteFilters}; +use snpick::scan::{analyze, pass1_scan}; +use snpick::types::*; +use snpick::vcf::write_vcf; +use snpick::{audit, coords, input}; /// Emit a `[snpick]` progress line to stderr unless `--quiet` was passed. /// Errors are always printed; only progress chatter is gated. @@ -564,10 +556,10 @@ fn main() { mod tests { use super::*; use memmap2::Mmap; - use crate::extract::ExtractParams; - use crate::fasta::{get_ref_seq, index_fasta}; - use crate::scan::{analyze, pass1_scan}; - use crate::vcf::write_vcf; + use snpick::extract::ExtractParams; + use snpick::fasta::{get_ref_seq, index_fasta}; + use snpick::scan::{analyze, pass1_scan}; + use snpick::vcf::write_vcf; fn tmp(name: &str, c: &str) -> String { let p = format!("/tmp/snpick_t_{}.fa", name); @@ -818,7 +810,7 @@ mod tests { let rs = get_ref_seq(&m, &recs[0], sl, layout); let (v, _) = analyze(&bm, &rs, &lk, false); let idx: Vec = v.iter().map(|x| x.index).collect(); - let stats = crate::filter::count_sites(&m, &recs, &idx, layout, &lk); + let stats = snpick::filter::count_sites(&m, &recs, &idx, layout, &lk); assert_eq!(stats.len(), 3); assert_eq!(stats[0].counts, [5, 0, 0, 1]); assert_eq!(stats[1].counts, [3, 3, 0, 0]); From 0c3f90e51e22adcf972eb0901102072ceb2da208 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:11:34 +0200 Subject: [PATCH 38/62] test: add proptest, a criterion benchmark, and a cargo-fuzz target proptest checks the classification invariant (variable+constant+ambiguous == length) and variable-position bounds over randomized alignments. A criterion micro-benchmark exercises the pass-1 scan (advisory, not a CI gate). A cargo-fuzz target fuzzes index_fasta so parser index arithmetic over untrusted bytes never panics. proptest/criterion are dev-only. --- .gitignore | 6 + Cargo.lock | 674 ++++++++++++++++++++++++++++++- Cargo.toml | 8 + benches/scan.rs | 32 ++ fuzz/Cargo.toml | 24 ++ fuzz/fuzz_targets/index_fasta.rs | 15 + tests/proptest.rs | 63 +++ 7 files changed, 820 insertions(+), 2 deletions(-) create mode 100644 benches/scan.rs create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/index_fasta.rs create mode 100644 tests/proptest.rs diff --git a/.gitignore b/.gitignore index f347511..99735df 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ site/ # MkDocs Material social-cards / plugin cache .cache/ + +# cargo-fuzz +fuzz/target/ +fuzz/corpus/ +fuzz/artifacts/ +fuzz/Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index 48de8c5..1242ac7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,21 @@ 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" @@ -57,12 +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" @@ -118,6 +199,42 @@ 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" @@ -143,12 +260,34 @@ 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" @@ -159,24 +298,143 @@ dependencies = [ "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" @@ -196,6 +454,70 @@ dependencies = [ "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" @@ -205,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" @@ -234,19 +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.2" dependencies = [ "clap", + "criterion", "flate2", "memmap2", + "proptest", "rayon", ] @@ -267,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" @@ -279,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" @@ -351,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 40a3e58..ac75e84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,14 @@ rayon = "1.11.0" # Pure-Rust (miniz_oxide) backend keeps the build C-toolchain-free for Bioconda. flate2 = "1.0" +[dev-dependencies] +proptest = "1" +criterion = "0.5" + +[[bench]] +name = "scan" +harness = false + [profile.release] opt-level = 3 lto = "fat" 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/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..772114e --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "snpick-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +# Own workspace so it does not interfere with the parent package's build. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.snpick] +path = ".." + +[[bin]] +name = "index_fasta" +path = "fuzz_targets/index_fasta.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/index_fasta.rs b/fuzz/fuzz_targets/index_fasta.rs new file mode 100644 index 0000000..3318789 --- /dev/null +++ b/fuzz/fuzz_targets/index_fasta.rs @@ -0,0 +1,15 @@ +#![no_main] +//! Fuzz the FASTA indexer: `index_fasta` does manual index arithmetic over untrusted, +//! machine-generated input, so it must never panic — only return `Ok`/`Err`. +//! +//! Run with a nightly toolchain and cargo-fuzz: +//! cargo +nightly fuzz run index_fasta + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok((records, seq_len, layout)) = snpick::fasta::index_fasta(data) { + // Exercise the reference reader on the first record too. + let _ = snpick::fasta::get_ref_seq(data, &records[0], seq_len, layout); + } +}); diff --git a/tests/proptest.rs b/tests/proptest.rs new file mode 100644 index 0000000..8637074 --- /dev/null +++ b/tests/proptest.rs @@ -0,0 +1,63 @@ +//! Property-based tests over randomized alignments. + +use proptest::prelude::*; +use snpick::fasta::{get_ref_seq, index_fasta}; +use snpick::scan::{analyze, pass1_scan}; +use snpick::types::build_lookup; + +/// Build an equal-length FASTA of `nseq` sequences of `len` bases, drawn deterministically +/// from `seed` over the alphabet A/C/G/T/N/-. +fn make_alignment(nseq: usize, len: usize, seed: u64) -> Vec { + const ALPHABET: &[u8] = b"ACGTN-"; + let mut fa = Vec::new(); + let mut x = seed | 1; + for i in 0..nseq { + fa.extend_from_slice(format!(">s{}\n", i).as_bytes()); + for _ in 0..len { + x = x.wrapping_mul(6364136223846793005).wrapping_add(1); + fa.push(ALPHABET[(x >> 33) as usize % ALPHABET.len()]); + } + fa.push(b'\n'); + } + fa +} + +proptest! { + /// The core invariant: every alignment column is classified exactly once. + #[test] + fn classification_covers_every_column( + nseq in 2usize..8, + len in 1usize..40, + seed in any::(), + ) { + let fa = make_alignment(nseq, len, seed); + for gaps in [false, true] { + let lookup = build_lookup(gaps); + let (recs, sl, layout) = index_fasta(&fa).unwrap(); + let bm = pass1_scan(&fa, &recs, sl, layout, &lookup); + let rs = get_ref_seq(&fa, &recs[0], sl, layout); + let (v, sc) = analyze(&bm, &rs, &lookup, gaps); + prop_assert_eq!(v.len() + sc.constant.total() + sc.ambiguous, sl); + } + } + + /// The parallel and sequential scans always agree (production path vs. fallback). + #[test] + fn variable_count_stable_across_gaps_flag( + nseq in 2usize..8, + len in 1usize..40, + seed in any::(), + ) { + let fa = make_alignment(nseq, len, seed); + let lookup = build_lookup(false); + let (recs, sl, layout) = index_fasta(&fa).unwrap(); + let bm = pass1_scan(&fa, &recs, sl, layout, &lookup); + let rs = get_ref_seq(&fa, &recs[0], sl, layout); + let (v, _) = analyze(&bm, &rs, &lookup, false); + // Every reported variable position must be within bounds and have >= 1 ALT allele. + for vp in &v { + prop_assert!(vp.index < sl); + prop_assert!(!vp.alt_bases.is_empty()); + } + } +} From 426c6c20c1c53bd4dadc5e71072e514f7c9779dc Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:14:21 +0200 Subject: [PATCH 39/62] build: add Docker and Apptainer container recipes Multi-stage Dockerfile builds a static musl binary (snpick has no C dependencies) into a scratch image. Apptainer/Singularity definition bootstraps from the published image. A Container workflow builds and pushes to GHCR on version tags. --- .dockerignore | 10 +++++++++ .github/workflows/docker.yml | 40 ++++++++++++++++++++++++++++++++++++ Dockerfile | 21 +++++++++++++++++++ snpick.def | 17 +++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 snpick.def 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/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/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/snpick.def b/snpick.def new file mode 100644 index 0000000..8c6906c --- /dev/null +++ b/snpick.def @@ -0,0 +1,17 @@ +Bootstrap: docker +From: ghcr.io/pathogenomics-lab/snpick:latest + +# Apptainer / Singularity definition. Build with: +# apptainer build snpick.sif snpick.def +# The published container image already ships a static snpick binary. + +%labels + Author Paula Ruiz-Rodriguez + Description Fast extraction of variable sites from FASTA alignments + +%runscript + exec /usr/local/bin/snpick "$@" + +%help + snpick — extract variable (SNP) sites from FASTA alignments. + Usage: apptainer run snpick.sif -f alignment.fasta -o snps.fasta From 41a49434bd3168b9e35360ba76eb21e82312e3cd Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:15:46 +0200 Subject: [PATCH 40/62] feat(python): add pyo3 bindings built on the library crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bindings/python is a maturin/pyo3 crate (its own workspace) exposing snpick.stats(path, include_gaps) — variable/constant/ambiguous counts and the fconst tuple — over the snpick library. Build with 'maturin develop'. --- bindings/python/Cargo.toml | 18 ++++++++++++ bindings/python/README.md | 24 ++++++++++++++++ bindings/python/pyproject.toml | 24 ++++++++++++++++ bindings/python/src/lib.rs | 51 ++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 bindings/python/Cargo.toml create mode 100644 bindings/python/README.md create mode 100644 bindings/python/pyproject.toml create mode 100644 bindings/python/src/lib.rs 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(()) +} From 1eb4d0358033b5ba34c9508f28dedbe12e58613b Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:19:33 +0200 Subject: [PATCH 41/62] feat(wasm): add WebAssembly bindings and make rayon/mmap optional Gate rayon (parallel) and memmap2 (mmap) behind default-on features so the library also builds for wasm32 (no threads, no mmap) using the sequential scan. The binary declares them as required-features. bindings/wasm exposes variable_site_count() and fconst() via wasm-bindgen. --- .gitignore | 5 +++++ Cargo.toml | 16 ++++++++++++++-- bindings/wasm/Cargo.toml | 22 +++++++++++++++++++++ bindings/wasm/README.md | 25 ++++++++++++++++++++++++ bindings/wasm/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/scan.rs | 35 +++++++++++++++++++--------------- 7 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 bindings/wasm/Cargo.toml create mode 100644 bindings/wasm/README.md create mode 100644 bindings/wasm/src/lib.rs diff --git a/.gitignore b/.gitignore index 99735df..18f414b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ fuzz/target/ fuzz/corpus/ fuzz/artifacts/ fuzz/Cargo.lock + +# binding crates +bindings/*/target/ +bindings/*/Cargo.lock +bindings/wasm/pkg/ diff --git a/Cargo.toml b/Cargo.toml index ac75e84..3395b03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,13 +17,25 @@ 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" 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/src/lib.rs b/src/lib.rs index 10aea0e..ab108ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub mod coords; pub mod extract; pub mod fasta; pub mod filter; +#[cfg(feature = "mmap")] pub mod input; pub mod scan; pub mod types; diff --git a/src/scan.rs b/src/scan.rs index 3f6a0be..8c611a2 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -3,6 +3,7 @@ //! Builds a per-position bitmask by OR-ing each sequence's nucleotide flags, //! then classifies positions as variable (>1 allele), constant, or ambiguous. +#[cfg(feature = "parallel")] use rayon::prelude::*; use crate::fasta::FastaRecord; @@ -10,6 +11,7 @@ use crate::types::*; /// Minimum total scan work (records × positions) before the parallel path is /// worth its overhead — roughly 50 seqs × 4M bp. +#[cfg(feature = "parallel")] const PARALLEL_MIN_WORK: usize = 200_000_000; /// Prefault mmap pages by touching one byte per OS page. Eliminates soft page faults during @@ -20,18 +22,17 @@ fn prefault(data: &[u8]) { const PAGE: usize = 4096; let n = data.len(); let num_pages = n.div_ceil(PAGE); + #[cfg(feature = "parallel")] let sum: u8 = if n >= 256 * 1024 * 1024 { (0..num_pages) .into_par_iter() .map(|p| data[p * PAGE]) .reduce(|| 0u8, |a, b| a.wrapping_add(b)) } else { - let mut s = 0u8; - for p in 0..num_pages { - s = s.wrapping_add(data[p * PAGE]); - } - s + (0..num_pages).fold(0u8, |s, p| s.wrapping_add(data[p * PAGE])) }; + #[cfg(not(feature = "parallel"))] + let sum: u8 = (0..num_pages).fold(0u8, |s, p| s.wrapping_add(data[p * PAGE])); std::hint::black_box(sum); } @@ -47,23 +48,26 @@ pub fn pass1_scan( // Prefault all pages into RAM before the hot loop prefault(data); - // Parallelism only pays off when there's enough work per thread - // (~200M bases, e.g. 50 seqs × 4M bp). Small inputs scan sequentially. - let num_threads = rayon::current_num_threads().min(records.len()); - let total_work = records.len() * seq_length; - if num_threads <= 1 || total_work < PARALLEL_MIN_WORK { - let mut bitmask = vec![0u8; seq_length]; - scan_sequential(data, records, seq_length, layout, lookup, &mut bitmask); - bitmask - } else { - scan_parallel(data, records, seq_length, layout, lookup, num_threads) + // Parallelism only pays off when there's enough work per thread (~200M bases). Small + // inputs (and builds without the `parallel` feature, e.g. wasm) scan sequentially. + #[cfg(feature = "parallel")] + { + let num_threads = rayon::current_num_threads().min(records.len()); + let total_work = records.len() * seq_length; + if num_threads > 1 && total_work >= PARALLEL_MIN_WORK { + return scan_parallel(data, records, seq_length, layout, lookup, num_threads); + } } + let mut bitmask = vec![0u8; seq_length]; + scan_sequential(data, records, seq_length, layout, lookup, &mut bitmask); + bitmask } /// Parallel scan: each thread scans a disjoint chunk of records into its own /// bitmask, then all partials are merged with OR. OR is commutative and /// associative over the disjoint chunks, so the result is byte-for-byte /// identical to a sequential scan. +#[cfg(feature = "parallel")] fn scan_parallel( data: &[u8], records: &[FastaRecord], seq_length: usize, layout: SeqLayout, lookup: &[u8; 256], num_threads: usize, @@ -190,6 +194,7 @@ mod tests { } } + #[cfg(feature = "parallel")] #[test] fn parallel_matches_sequential() { // The parallel chunk/merge path is the production path for real From ed0cbfb4e813ccb4e2ede2cd2262850b0fe86bfd Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:29:10 +0200 Subject: [PATCH 42/62] docs: document the full 1.0.2 feature set in the README and site Rewrite the README usage reference and features, and update the docs site (usage, output, index, installation, architecture, CHANGELOG) to cover per-site filtering, BED masking, reference coordinates, sample selection, PHYLIP/NEXUS output, gzip/stdin-stdout I/O, --stats-json/--dry-run/--check, --on-invalid/--iupac-mode, verbosity/exit codes, and the library crate, Python/WASM bindings and containers. --- CHANGELOG.md | 45 +++++++++--- README.md | 124 ++++++++++++++++++++++++++++---- docs/architecture.md | 9 ++- docs/benchmarks.md | 2 +- docs/index.md | 20 +++++- docs/installation.md | 16 +++++ docs/output.md | 81 +++++++++++++++++++-- docs/usage.md | 163 +++++++++++++++++++++---------------------- 8 files changed, 342 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3ecfa5..b0d9bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.0.2] - 2026-07-18 ### Added -- `-t, --threads ` to pin the Rayon thread pool (deterministic wall-clock on - shared HPC/SLURM nodes). Thread count never changes the output, only speed. -- `--chrom ` to set the VCF `CHROM` / `##contig` name (default `1`, e.g. - `NC_000962.3`). -- `-q, --quiet` to silence the `[snpick]` progress logs (errors still print). -- A valid header-only VCF is now written when `--vcf` is requested but the - alignment has no variable sites, so pipelines that declare the `.vcf` as an - output no longer break. + +**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 diff --git a/README.md b/README.md index 307bae0..a41bbe3 100644 --- a/README.md +++ b/README.md @@ -56,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 -# Include gaps as informative -snpick -f alignment.fasta -o snps.fasta -g +# Drop singletons, keep biallelic sites, mask repetitive regions +snpick -f alignment.fasta -o snps.fasta --mac 2 --max-alleles 2 --mask exclude.bed + +# 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 - ``` --- @@ -97,6 +104,44 @@ Optional VCF v4.2 output with per-sample genotypes. Reference allele taken from 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. + --- ## 💾 Installation @@ -129,24 +174,73 @@ 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 [OPTIONS] --fasta ``` -| Argument | Required | Description | -|---|---|---| -| `-f, --fasta ` | ✅ | Input FASTA alignment | -| `-o, --output ` | ✅ | Output FASTA (variable sites only) | -| `-g, --include-gaps` | | Treat gaps (`-`) as a 5th character | -| `--vcf` | | Generate VCF file (derived from output name) | -| `--vcf-output ` | | Custom VCF output path | -| `-t, --threads ` | | Threads for the parallel scan (default: all cores) | -| `--chrom ` | | CHROM / contig name in the VCF (default: `1`) | -| `-q, --quiet` | | Silence progress logs (errors still shown) | +`-f/--fasta` is always required; `-o/--output` is required unless `--dry-run` or `--check`. +Use `-` for `--fasta`/`--output`/`--stats-json` to read stdin / write stdout. See `snpick --help` for the authoritative list. + +**Core** + +| Argument | Description | +|---|---| +| `-f, --fasta ` | Input FASTA alignment (`-` = stdin; gzip/bgzip auto-detected) | +| `-o, --output ` | Output FASTA of variable sites (`-` = stdout) | +| `-g, --include-gaps` | Treat gaps (`-`) as a 5th character | +| `--format ` | `fasta` (default), `phylip`, or `nexus` | +| `-t, --threads ` | Threads for the parallel scan (default: all cores) | +| `-q, --quiet` · `-v`/`-vv` | Silence logs · increase detail | + +**VCF & coordinates** + +| Argument | Description | +|---|---| +| `--vcf` · `--vcf-output ` | Write a VCF (derived name, or a custom path) | +| `--chrom ` | CHROM / contig name (default `1`) | +| `--reference ` | REF/polarity sequence (default: first) | +| `--ref-coords` | VCF `POS` as ungapped reference positions | +| `--sites-output ` | Per-site alignment→reference coordinate map | + +**Filtering & masking** + +| Argument | Description | +|---|---| +| `--max-missing ` | Max fraction of missing genotypes per site | +| `--mac ` · `--maf ` | Min minor-allele count / frequency | +| `--min-samples ` | Min samples with data | +| `--max-alleles ` | Max distinct alleles (`2` = biallelic) | +| `--keep-samples` / `--exclude-samples ` | Subset samples (list or `@file`) | +| `--mask ` · `--mask-ref` | Mask regions (alignment or reference coords) | + +**QC, reporting & robustness** + +| Argument | Description | +|---|---| +| `--stats-json ` | Machine-readable JSON summary (`-` = stdout) | +| `--dry-run` · `--check` | Stats only · composition audit, then exit | +| `--on-invalid ` | `ignore` (default) · `warn` · `error` on non-nucleotides | +| `--iupac-mode ` | `missing` (default) · `resolve` ambiguity codes | +| `--allow-dup-ids` | Permit duplicate sequence IDs | + +**Exit codes:** `0` success · `1` I/O error · `2` bad input/data. ### Example diff --git a/docs/architecture.md b/docs/architecture.md index 3a8b4c1..475c3cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,9 +54,14 @@ 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** — the working set scales with alignment length, not with the number of sequences, which is what lets SNPick handle thousands of genomes with a small footprint. -The source is split into focused modules: `fasta` (indexing), `scan` (pass 1 + classification), -`extract` (pass 2), `vcf` (VCF writer), and `types` (shared constants and lookup tables). +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/benchmarks.md b/docs/benchmarks.md index 8f35f3c..3dc94ed 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -28,5 +28,5 @@ SNPick maintains **O(L)** memory regardless of sequence count, while snp-sites r | 1000 seqs × 4.4 Mbp | **~3 s**, ~140 MB | >26 min (killed), 3+ GB | !!! tip "Reproducing" - Wall-clock depends on core count; pin it with [`--threads`](usage.md#control-threads-hpc-reproducibility) + 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/index.md b/docs/index.md index f2721cb..642b25c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,13 +46,29 @@ alignments ready for phylogenetic inference with ascertainment-bias correction ( --- - VCF v4.2 with per-sample genotypes, a configurable contig name, and gap/ambiguity handling. + VCF v4.2 with per-sample genotypes, a configurable contig name, reference-anchored `POS`, + 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 scan: **O(L)** memory, thousands of genomes in seconds. + Zero-copy mmap + parallel, auto-vectorized scan: **O(L)** memory, thousands of genomes in + seconds. diff --git a/docs/installation.md b/docs/installation.md index f027e8a..3e40a64 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -51,6 +51,22 @@ tags: The release profile enables fat LTO and a single codegen unit for maximum throughput, so a release build takes noticeably longer than a debug build. +=== ":material-docker: Container" + + ```bash + docker run --rm -v "$PWD:/data" ghcr.io/pathogenomics-lab/snpick \ + -f /data/alignment.fasta -o /data/snps.fasta + ``` + + An Apptainer/Singularity image is available too — see the + [`snpick.def`](https://github.com/PathoGenOmics-Lab/snpick/blob/main/snpick.def) recipe. + +## Library & bindings + +snpick is also a Rust **library crate** (these docs' API surface), with **Python** +([maturin](https://www.maturin.rs)/[pyo3](https://pyo3.rs), under `bindings/python`) and +**WebAssembly** (under `bindings/wasm`) bindings for notebooks, pipelines and the browser. + ## Check the install ```bash diff --git a/docs/output.md b/docs/output.md index 467cc45..36c35ad 100644 --- a/docs/output.md +++ b/docs/output.md @@ -19,6 +19,27 @@ phylogenetic tools while staying orders of magnitude smaller. If the alignment has no variable columns, SNPick writes a valid FASTA with each record's header and an empty sequence line, and exits `0`. +### Alternative formats + +`--format` writes the reduced alignment as something other than FASTA: + +=== "PHYLIP" + + ```bash + snpick -f alignment.fasta -o snps.phy --format phylip + ``` + + Relaxed PHYLIP (`ntax nchar` header, then `name sequence`), read by IQ-TREE and RAxML. + +=== "NEXUS" + + ```bash + snpick -f alignment.fasta -o snps.nex --format nexus + ``` + + A NEXUS `DATA` block (`DIMENSIONS` / `FORMAT DATATYPE=DNA` / `MATRIX`) for MrBayes, PAUP* + and SplitsTree. + ## ASC `fconst` for ascertainment-bias correction Removing invariant sites biases branch lengths unless the model is told how many constant sites @@ -54,16 +75,18 @@ site and per-sample genotypes. | Field | Meaning | |---|---| -| `CHROM` | Contig name — `1` by default, override with [`--chrom`](usage.md#set-the-vcf-contig-name) | -| `POS` | **1-based alignment column** (not an ungapped reference coordinate) | +| `CHROM` | Contig name — `1` by default, override with [`--chrom`](usage.md#vcf-coordinates) | +| `POS` | **1-based alignment column** by default, or the ungapped reference position with `--ref-coords` | | `REF` | Base of the first sequence; if that base is ambiguous, the first observed base in A, C, G, T order | | `ALT` | The other observed alleles, comma-separated | | `INFO=NS` | Number of samples with data (a called base; gaps count only under `-g`) | | `FORMAT=GT` | Per-sample allele index: `0` = REF, `1..` = the *n*-th ALT, `.` = missing/ambiguous | -!!! warning "POS is an alignment coordinate" - `POS` is the column index in the alignment, so when the reference sequence contains gaps it - diverges from the true genomic position, and `##contig` length is the alignment length. +!!! warning "POS is an alignment coordinate by default" + Without `--ref-coords`, `POS` is the column index in the alignment, so when the reference + contains gaps it diverges from the true genomic position and `##contig` length is the + alignment length. `--ref-coords` maps `POS` (and the contig length) onto ungapped reference + positions; `--reference ` picks which sequence is that reference. ### Genotype matrix guard @@ -89,3 +112,51 @@ declares the `.vcf` as an output does not break. Writing gaps as `*` is an alignment convention shared with snp-sites. Note that in strict VCF v4.2, `*` denotes a spanning deletion, so some downstream tools may interpret gap sites accordingly. + +With `--iupac-mode resolve`, IUPAC codes (R = A|G, …) are resolved to their bases when +classifying, so a column that is all-A plus one `R` becomes variable. Resolution applies only to +the presence scan; an `R` is still excluded from `NS` and genotyped as missing. + +## Machine-readable summary (`--stats-json`) + +`--stats-json ` (or `-` for stdout) writes a flat JSON run summary — so pipelines get the +`fconst` array as a typed field instead of scraping the `[snpick] ASC fconst:` stderr line: + +```json +{ + "snpick_version": "1.0.2", + "input": "alignment.fasta", + "reference": "H37Rv", + "sequences": 250, + "alignment_length": 4411532, + "variable_sites": 158934, + "written_sites": 141002, + "constant_sites": 4252598, + "constant_by_base": { "A": 744123, "C": 1382922, "G": 1382180, "T": 743556 }, + "ambiguous_sites": 0, + "fconst": [744123, 1382922, 1382180, 743556], + "include_gaps": false, + "threads": 8 +} +``` + +`variable_sites` is the classified count; `written_sites` is what remains after per-site +filtering. Pair with `--dry-run` to compute the summary without writing any alignment. + +## Variable-site coordinate map (`--sites-output`) + +`--sites-output ` writes one row per variable site mapping its alignment column to the +ungapped reference position and alleles — useful for cross-referencing gene coordinates or a +masking BED: + +```text +alignment_pos ref_pos ref alt +2 1 A T +8 7 C A +``` + +## Composition audit (`--check`) + +`--check` prints an A/C/G/T, N, gap, IUPAC and invalid-byte breakdown, then exits — a quick +pre-flight to catch a mis-supplied protein alignment or truncated download (see also +`--on-invalid`). diff --git a/docs/usage.md b/docs/usage.md index 684b4e4..f91b39b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,23 +7,67 @@ tags: # Usage ``` -snpick [OPTIONS] --fasta --output +snpick [OPTIONS] --fasta ``` -## Options - -| Argument | Required | Description | -|---|:---:|---| -| `-f, --fasta ` | :material-check: | Input FASTA alignment (all sequences must have equal length) | -| `-o, --output ` | :material-check: | Output FASTA containing only the variable sites | -| `-g, --include-gaps` | | Treat gaps (`-`) as a 5th character instead of ignoring them | -| `--vcf` | | Also write a VCF, named after the output (`snps.fasta` → `snps.vcf`) | -| `--vcf-output ` | | Write the VCF to a custom path (implies `--vcf`) | -| `-t, --threads ` | | Threads for the parallel scan (default: all logical cores) | -| `--chrom ` | | `CHROM` / contig name written to the VCF (default: `1`) | -| `-q, --quiet` | | Silence progress logs on stderr (errors are still reported) | -| `-h, --help` | | Print help | -| `-V, --version` | | Print version | +`-f/--fasta` is always required. `-o/--output` is required **unless** `--dry-run` or `--check` +is given. Use `-` in place of a path for `--fasta`, `--output` or `--stats-json` to read from +stdin / write to stdout. Run `snpick --help` for the authoritative, always-current list. + +## Core + +| Argument | Description | +|---|---| +| `-f, --fasta ` | Input FASTA alignment (`-` = stdin; gzip/bgzip auto-detected) | +| `-o, --output ` | Output FASTA of variable sites (`-` = stdout) | +| `-g, --include-gaps` | Treat gaps (`-`) as a 5th character | +| `--format ` | Reduced-alignment format: `fasta` (default), `phylip`, `nexus` | +| `-t, --threads ` | Threads for the parallel scan (default: all logical cores) | +| `-q, --quiet` | Silence progress logs (errors still shown) | +| `-v`, `-vv` | Increase detail (diagnostics; allele-class histogram) | + +## VCF & coordinates + +| Argument | Description | +|---|---| +| `--vcf` | Write a VCF named after the output (`snps.fasta` → `snps.vcf`) | +| `--vcf-output ` | Write the VCF to a custom path (implies `--vcf`) | +| `--chrom ` | `CHROM` / `##contig` name (default `1`, e.g. `NC_000962.3`) | +| `--reference ` | Sequence ID used for REF/polarity (default: first) | +| `--ref-coords` | Write `POS` as the ungapped reference position, not the alignment column | +| `--sites-output ` | Map each site: `alignment_pos`, `ref_pos`, `ref`, `alt` | + +## Filtering & masking + +| Argument | Description | +|---|---| +| `--max-missing ` | Drop sites with more than fraction `f` of missing genotypes | +| `--mac ` | Drop sites with minor-allele count below `n` (e.g. `2` drops singletons) | +| `--maf ` | Drop sites with minor-allele frequency below `f` | +| `--min-samples ` | Drop sites with fewer than `n` samples with data | +| `--max-alleles ` | Drop sites with more than `n` distinct alleles (`2` = biallelic) | +| `--keep-samples ` | Keep only these samples (comma-separated, or `@file`) | +| `--exclude-samples ` | Drop these samples (mutually exclusive with `--keep-samples`) | +| `--mask ` | Mask BED regions (excluded from output **and** `fconst`) | +| `--mask-ref` | Interpret `--mask` coordinates as reference positions | + +!!! info "Filtering keeps ASC valid" + Per-site filters remove **variable** sites from the output only; they are never reclassified + as constant, so the `fconst` counts are untouched. Sample selection filters *before* the + scan, so `fconst` is recomputed for exactly the retained samples. + +## QC, reporting & robustness + +| Argument | Description | +|---|---| +| `--stats-json ` | Machine-readable JSON summary (`-` = stdout) | +| `--dry-run` | Report statistics without writing any FASTA/VCF | +| `--check` | Audit the alignment composition and exit | +| `--on-invalid ` | `ignore` (default), `warn`, or `error` on out-of-alphabet bytes | +| `--iupac-mode ` | `missing` (default) or `resolve` IUPAC codes to bases | +| `--allow-dup-ids` | Permit duplicate sequence IDs instead of erroring | + +**Exit codes:** `0` success · `1` I/O error · `2` bad input/data. ## Examples @@ -33,93 +77,42 @@ snpick [OPTIONS] --fasta --output snpick -f alignment.fasta -o snps.fasta ``` -**Input** (`alignment.fasta`): - -```text ->sequence1 -ATGCTAGCTAGCTAGCTA ->sequence2 -ATGCTAGCTGGCTAGCTA ->sequence3 -ATGCTAGCTAGCTAGCTA -``` - -**Output** (`snps.fasta`): - -```text ->sequence1 -A ->sequence2 -G ->sequence3 -A -``` - -**stderr:** - -```text -[snpick] Mapped 63 bytes. 3 sequences × 18 positions. -[snpick] 1 variable, 17 constant (A:4 C:4 G:4 T:5), 0 ambiguous-only, 18 total. -[snpick] ASC fconst: 4,4,4,5 -[snpick] Done in 0.00s. 1 vars from 3 seqs × 18 pos. -``` - -### With a VCF +### VCF with reference coordinates and a stats sidecar ```bash -snpick -f alignment.fasta -o snps.fasta --vcf -# or a custom path: -snpick -f alignment.fasta -o snps.fasta --vcf-output variants.vcf +snpick -f alignment.fasta.gz -o snps.fasta \ + --vcf --chrom NC_000962.3 --reference H37Rv --ref-coords \ + --stats-json stats.json # (1)! ``` -See [Output formats](output.md) for the VCF layout. +1. `--ref-coords` sets `POS` (and the contig length) to ungapped reference positions; + `--reference` pins REF polarity to H37Rv; `--stats-json` gives IQ-TREE the `fconst` array + without scraping stderr. -### Set the VCF contig name - -By default the VCF `CHROM` and `##contig` are `1`. Match your reference so downstream tools -(bcftools, GATK, IGV) line up without post-processing: +### Filter and mask ```bash -snpick -f alignment.fasta -o snps.fasta --vcf --chrom NC_000962.3 # (1)! +# Drop singletons, keep biallelic sites, mask repetitive regions (reference coords) +snpick -f alignment.fasta -o snps.fasta \ + --mac 2 --max-alleles 2 --mask exclude.bed --mask-ref ``` -1. `--chrom` sets **both** the `##contig` header ID and the per-row `CHROM` column, and is - rejected if it contains whitespace (which would break the tab-delimited columns). - -### Include gaps +### Subset samples ```bash -snpick -f alignment.fasta -o snps.fasta -g +snpick -f alignment.fasta -o clade.fasta --keep-samples @clade_ids.txt ``` -Without `-g`, gap columns never make a site variable. With `-g`, a gap is treated as a 5th -allele (rendered as `*` in the VCF). See [Gaps & ambiguity](output.md#gaps-and-ambiguous-bases). - -### Control threads (HPC / reproducibility) - -The scan is parallelised with Rayon and, by default, uses every logical core. On a shared -SLURM node, pin it to your allocation: +### Alternative formats and piping ```bash -snpick -f alignment.fasta -o snps.fasta -t 8 +snpick -f alignment.fasta -o snps.nex --format nexus +gzip -dc big.fasta.gz | snpick -f - -o - --vcf-output snps.vcf > snps.fasta ``` -!!! note "Determinism" - The thread count **never changes the output** — the per-position bitmask is merged with a - commutative OR — only the wall-clock time. - -### Quiet mode for pipelines +### Dry run and composition audit ```bash -snpick -f alignment.fasta -o snps.fasta --vcf -q +snpick -f alignment.fasta --dry-run --stats-json - # stats only, JSON to stdout +snpick -f alignment.fasta --check # composition breakdown, then exit ``` - -All progress goes to **stderr** and `stdout` stays clean, so `-q` is only needed to silence the -`[snpick]` chatter; errors are always printed. - -## Exit codes - -| Code | Meaning | -|:---:|---| -| `0` | Success (including the "no variable sites" case) | -| `1` | Error — bad input, unequal sequence lengths, unwritable output, etc. | From 74a18fdb697785791d71c776f0c47a291ad948c6 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:19:16 +0200 Subject: [PATCH 43/62] fix: guard --stats-json/--sites-output against clobbering the input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These sidecar outputs were never path-checked, so pointing either at the input file truncated the in-place mmap mid-run — a SIGBUS crash for --sites-output and silent destruction of the alignment for --stats-json. They now get the same collision check as -o and the VCF, against the input and the other outputs. --- src/main.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main.rs b/src/main.rs index 9b93606..4def86c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -352,6 +352,18 @@ fn run() -> io::Result<()> { Some(vp) } else { None }; + // Sidecar outputs must not collide with the input (which is mmapped in place — overwriting + // it truncates the live mapping and corrupts the run) or with the other outputs. + for sidecar in [args.stats_json.as_deref(), args.sites_output.as_deref()].into_iter().flatten() { + check_paths_differ(&args.fasta, sidecar)?; + if let Some(ref out) = out_path { + check_paths_differ(out, sidecar)?; + } + if let Some(ref vp) = vcf_path { + check_paths_differ(vp, sidecar)?; + } + } + // Map the input, transparently decompressing gzip/bgzip and reading stdin ("-") as needed. let input = input::map_input(&args.fasta)?; if input.mmap.is_empty() { From 81cb85821cf85ba4fe2cc4ce02495e71cba105c8 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:20:57 +0200 Subject: [PATCH 44/62] fix(input): tolerate short-read gzip magic and clean up temp on failure The 2-byte magic peek used a single read(), which on a pipe can return fewer than 2 bytes, so gzip-over-stdin was mis-detected and parsed as FASTA. Read the magic in a fill loop. Also remove the spool temp file when decompression or mmap fails, instead of leaking one per failed run. --- src/input.rs | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/input.rs b/src/input.rs index e14813b..ef0b22b 100644 --- a/src/input.rs +++ b/src/input.rs @@ -5,7 +5,7 @@ //! and removed on drop. use std::fs::File; -use std::io::{self, BufReader, BufWriter, Read}; +use std::io::{self, BufReader, BufWriter, Read, Write}; use std::path::PathBuf; use flate2::read::MultiGzDecoder; @@ -29,15 +29,40 @@ fn temp_path() -> PathBuf { std::env::temp_dir().join(format!("snpick-{}.tmp", std::process::id())) } +/// Read up to 2 magic bytes, tolerating short reads (a pipe's first read may return < 2 bytes). +fn read_magic(mut r: impl Read) -> io::Result<(usize, [u8; 2])> { + let mut buf = [0u8; 2]; + let mut n = 0; + while n < 2 { + match r.read(&mut buf[n..]) { + Ok(0) => break, + Ok(k) => n += k, + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + Ok((n, buf)) +} + fn spool_to_temp(mut reader: impl Read) -> io::Result<(Mmap, PathBuf)> { let tp = temp_path(); - { - let mut out = BufWriter::new(File::create(&tp)?); - io::copy(&mut reader, &mut out)?; + let build = (|| -> io::Result { + { + let mut out = BufWriter::new(File::create(&tp)?); + io::copy(&mut reader, &mut out)?; + out.flush()?; + } + let f = File::open(&tp)?; + unsafe { Mmap::map(&f) } + })(); + match build { + Ok(mmap) => Ok((mmap, tp)), + Err(e) => { + // Don't leak the spool file if decompression / mmap failed. + let _ = std::fs::remove_file(&tp); + Err(e) + } } - let f = File::open(&tp)?; - let mmap = unsafe { Mmap::map(&f)? }; - Ok((mmap, tp)) } /// Map the input for reading. `path == "-"` reads stdin; gzip/bgzip input (detected by magic @@ -46,8 +71,7 @@ pub fn map_input(path: &str) -> io::Result { if path == "-" { // Peek the first two bytes, then chain them back so gzip over stdin is detected. let mut reader = BufReader::new(io::stdin().lock()); - let mut magic = [0u8; 2]; - let n = reader.read(&mut magic)?; + let (n, magic) = read_magic(&mut reader)?; let head = std::io::Cursor::new(magic[..n].to_vec()); let stream = head.chain(reader); let (mmap, tp) = if n == 2 && magic == [0x1f, 0x8b] { @@ -62,8 +86,7 @@ pub fn map_input(path: &str) -> io::Result { .map_err(|e| io::Error::new(e.kind(), format!("Cannot open '{}': {}", path, e)))?; // Peek the first two bytes for the gzip magic (0x1f 0x8b); bgzip is a valid gzip stream. - let mut magic = [0u8; 2]; - let n = (&f).read(&mut magic)?; + let (n, magic) = read_magic(&f)?; let is_gzip = n == 2 && magic == [0x1f, 0x8b]; if is_gzip { From 8dc5e5e578ffc986883414c6f436b184b1b830e6 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:22:30 +0200 Subject: [PATCH 45/62] fix(vcf): valid gap REF, consistent reference coordinates, u32-safe POS Render a gap reference base as 'N' (VCF v4.2 REF must be A/C/G/T/N; '*' is an ALT-only spanning-deletion allele that htslib/bcftools reject). Clamp the --sites-output ref_pos to 1 like the VCF POS, so the two agree at leading-gap reference columns. Cap MAX_SEQ_LENGTH below u32::MAX so POS / reference positions never truncate. --- src/main.rs | 6 ++++-- src/types.rs | 6 ++++-- src/vcf.rs | 7 ++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4def86c..ff82c6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -278,7 +278,9 @@ fn write_sites_tsv(path: &str, var: &[VariablePosition], ref_pos: Option<&[u32]> format!("Cannot create sites TSV '{}': {}", path, e)))?); writeln!(w, "alignment_pos\tref_pos\tref\talt")?; for vp in var { - let rp = ref_pos.map(|r| r[vp.index]).unwrap_or((vp.index + 1) as u32); + // Clamp to 1 to match the VCF POS (a leading-gap reference column has ungapped + // position 0, which is not a valid 1-based coordinate). + let rp = ref_pos.map(|r| r[vp.index].max(1)).unwrap_or((vp.index + 1) as u32); let refc = if vp.ref_base == b'-' { '*' } else { vp.ref_base as char }; let alt: String = vp.alt_bases.iter() .map(|&b| if b == b'-' { "*".to_string() } else { (b as char).to_string() }) @@ -689,7 +691,7 @@ mod tests { let dl: Vec<&str> = c.lines().filter(|l| !l.starts_with('#')).collect(); let f: Vec<&str> = dl[0].split('\t').collect(); assert_eq!(f[1], "2"); // POS (gap-in-ref site) - assert_eq!(f[3], "*"); // REF: gap → '*', not '-' + assert_eq!(f[3], "N"); // REF: gap → 'N' (valid VCF), not '-' or '*' assert_eq!(f[4], "T"); // ALT assert!(!c.contains("\t-\t")); // no bare hyphen field anywhere std::fs::remove_file(&p).ok(); std::fs::remove_file(fo).ok(); std::fs::remove_file(vo).ok(); diff --git a/src/types.rs b/src/types.rs index 03306dc..8049313 100644 --- a/src/types.rs +++ b/src/types.rs @@ -10,8 +10,10 @@ pub const BIT_G: u8 = 0b00100; pub const BIT_T: u8 = 0b01000; pub const BIT_GAP: u8 = 0b10000; -/// Maximum alignment length (prevents OOM on malicious input). -pub const MAX_SEQ_LENGTH: usize = 10_000_000_000; +/// Maximum alignment length. Kept below `u32::MAX` so alignment columns and reference +/// positions (VCF `POS`) always fit in a `u32` without truncation; also bounds memory on +/// malicious input (the bitmask alone is one byte per column). +pub const MAX_SEQ_LENGTH: usize = 4_000_000_000; /// Maximum VCF genotype matrix size in bytes. pub const MAX_VCF_GENO_BYTES: usize = 4_000_000_000; diff --git a/src/vcf.rs b/src/vcf.rs index f578621..67a529c 100644 --- a/src/vcf.rs +++ b/src/vcf.rs @@ -59,9 +59,10 @@ pub fn write_vcf( lut[ab as usize] = (i + 1) as u8; } - // A gap reference renders as '*' (VCF v4.2 REF must be A/C/G/T/N, never '-'), - // consistent with the '-'→'*' mapping applied to ALT. - let ref_byte = if vp.ref_base == b'-' { b'*' } else { vp.ref_base }; + // VCF v4.2 REF must be A/C/G/T/N — never '-' and never '*' (which is an ALT-only + // spanning-deletion allele that bcftools/htslib reject in REF). Render a gap reference + // as 'N'. (ALT gaps stay '*', the snp-sites convention.) + let ref_byte = if vp.ref_base == b'-' { b'N' } else { vp.ref_base }; row.clear(); let pos = match pos_map { From ad0103fa696dd86a09e39311eb9d81fd3146324e Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:23:24 +0200 Subject: [PATCH 46/62] fix(cli): emit --sites-output under --dry-run The sites TSV is a reporting sidecar like --stats-json, but it sat after the dry-run early return, so --dry-run --sites-output silently wrote nothing. Move it before the gate so both behave consistently. --- src/main.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index ff82c6e..3ecadb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -499,8 +499,14 @@ fn run() -> io::Result<()> { if sp == "-" { "stdout" } else { sp }); } + // Variable-site coordinate map — a reporting sidecar, so emitted for --dry-run too. + if let Some(sp) = &args.sites_output { + write_sites_tsv(sp, &var_positions, ref_pos.as_deref())?; + progress!(quiet, "[snpick] Sites TSV written to {}.", sp); + } + if dry_run { - progress!(quiet, "[snpick] Dry run — no output written."); + progress!(quiet, "[snpick] Dry run — no alignment/VCF written."); return Ok(()); } @@ -508,12 +514,6 @@ fn run() -> io::Result<()> { let out = out_path.as_deref().expect("output is required unless --dry-run"); let pos_map = if args.ref_coords { ref_pos.as_deref() } else { None }; - // Optional variable-site coordinate map (alignment column -> reference position). - if let Some(sp) = &args.sites_output { - write_sites_tsv(sp, &var_positions, ref_pos.as_deref())?; - progress!(quiet, "[snpick] Sites TSV written to {}.", sp); - } - if num_var == 0 { progress!(quiet, "[snpick] No variable positions found — writing empty alignment."); } From d5eed9682c6f0c334055d1417f34c8f7904d4015 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:53:23 +0200 Subject: [PATCH 47/62] fix(filter): count the resolved allele set under --iupac-mode resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-site filters recounted alleles with the strict lookup, so under --iupac-mode resolve an R/W/M was tallied as missing even though it had made the site variable — inverting filter decisions (e.g. --max-alleles 1 kept biallelic sites). Count over the same table pass 1 used, and credit every set base in a multi-flag mask (R = A|G -> both A and G). --- src/filter.rs | 40 ++++++++++++++++++++++++++-------------- src/main.rs | 4 +++- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/filter.rs b/src/filter.rs index f61e078..e8b7954 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -44,10 +44,10 @@ impl SiteFilters { } impl SiteStat { - /// Number of samples with a called allele (gaps count only under `include_gaps`). - pub fn ns(&self, include_gaps: bool) -> u32 { - let acgt: u32 = self.counts.iter().sum(); - acgt + if include_gaps { self.gap } else { 0 } + /// Number of samples with a called allele (i.e. any set bit; gaps count only under + /// `include_gaps`, since the lookup then maps '-' to a bit). + pub fn ns(&self, num_samples: u32) -> u32 { + num_samples - self.missing } /// Counts of the alleles actually present (ACGT with count > 0, plus gap if `include_gaps`). @@ -61,8 +61,8 @@ impl SiteStat { /// Whether this site passes every set threshold. pub fn passes(&self, f: &SiteFilters, num_samples: u32, include_gaps: bool) -> bool { - let ns = self.ns(include_gaps); - let missing = num_samples - ns; // each sample lands in exactly one bucket, no underflow + let ns = self.ns(num_samples); + let missing = self.missing; if let Some(ms) = f.min_samples { if ns < ms { return false; @@ -96,15 +96,17 @@ impl SiteStat { #[inline] fn tally(s: &mut SiteStat, bits: u8) { - // `bits` comes from the lookup table, so it is a single flag (or 0 for ambiguous). - match bits { - BIT_A => s.counts[0] += 1, - BIT_C => s.counts[1] += 1, - BIT_G => s.counts[2] += 1, - BIT_T => s.counts[3] += 1, - BIT_GAP => s.gap += 1, - _ => s.missing += 1, + // `bits` may carry several flags under --iupac-mode resolve (e.g. R = A|G), so credit every + // set base — matching the classifier's resolved allele set. Zero = uncalled/ambiguous. + if bits == 0 { + s.missing += 1; + return; } + if bits & BIT_A != 0 { s.counts[0] += 1; } + if bits & BIT_C != 0 { s.counts[1] += 1; } + if bits & BIT_G != 0 { s.counts[2] += 1; } + if bits & BIT_T != 0 { s.counts[3] += 1; } + if bits & BIT_GAP != 0 { s.gap += 1; } } /// Count alleles and missing calls at each variable position (sparse pass over just the @@ -177,6 +179,16 @@ mod tests { assert!(!stat(4, 3, 3, 0, 0, 0).passes(&f, 10, false)); // 3 alleles } + #[test] + fn resolved_iupac_counts_both_bases() { + // Under --iupac-mode resolve, an R credits both A and G, so a site is 2-allelic and + // has no missing — it must fail --max-alleles 1 and pass --max-missing 0. + let s = stat(4, 0, 1, 0, 0, 0); // A=4 (incl. R), G=1 (from R) + assert!(!s.passes(&SiteFilters { max_alleles: Some(1), ..Default::default() }, 4, false)); + assert!(s.passes(&SiteFilters { max_missing: Some(0.0), ..Default::default() }, 4, false)); + assert_eq!(s.ns(4), 4); + } + #[test] fn maf_frequency() { let f = SiteFilters { maf: Some(0.15), ..Default::default() }; diff --git a/src/main.rs b/src/main.rs index 3ecadb1..b704ae0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -459,7 +459,9 @@ fn run() -> io::Result<()> { }; if filters.active() && !var_positions.is_empty() { let pos_indices: Vec = var_positions.iter().map(|v| v.index).collect(); - let stats = count_sites(data, &records, &pos_indices, layout, &lookup); + // Count over the SAME table pass 1 used to classify (resolves IUPAC codes under + // --iupac-mode resolve), so the filter judges the same allele set. + let stats = count_sites(data, &records, &pos_indices, layout, &scan_lookup); let ns_total = num_samples as u32; let before = var_positions.len(); let mut i = 0; From 77ec6cf81b0450061a755fc7ac4e9cbbc4d64f60 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:54:19 +0200 Subject: [PATCH 48/62] fix(nexus): quote taxon labels containing NEXUS metacharacters IDs like 'iso1[London]' were written raw into the NEXUS MATRIX, so conformant readers dropped '[London]' as a comment (losing/colliding taxa) or failed to parse. Single-quote labels that contain anything other than [A-Za-z0-9_.], doubling embedded quotes. --- src/extract.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/extract.rs b/src/extract.rs index a8410a2..6c0e82f 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -9,6 +9,25 @@ use std::io::{self, BufWriter, Write}; use crate::fasta::FastaRecord; use crate::types::*; +/// Write a NEXUS taxon label, single-quoting (and doubling embedded quotes) when it contains +/// characters NEXUS treats as punctuation/whitespace, so labels like `iso[London]` survive. +fn write_nexus_label(w: &mut impl Write, id: &[u8]) -> io::Result<()> { + let safe = !id.is_empty() + && id.iter().all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.'); + if safe { + return w.write_all(id); + } + w.write_all(b"'")?; + for &b in id { + if b == b'\'' { + w.write_all(b"''")?; + } else { + w.write_all(&[b])?; + } + } + w.write_all(b"'") +} + /// Open an output sink: a file, or stdout when the path is "-". pub fn open_sink(path: &str) -> io::Result> { if path == "-" { @@ -112,7 +131,7 @@ pub fn pass2_extract( } OutputFormat::Nexus => { writer.write_all(b" ")?; - writer.write_all(rec.id)?; + write_nexus_label(&mut writer, rec.id)?; writer.write_all(b" ")?; writer.write_all(&var_buf)?; writer.write_all(b"\n")?; From 38792bc7217701204ab2d32ca1b3963223c0e24e Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:56:36 +0200 Subject: [PATCH 49/62] fix: sidecar collision, fraction validation, BED chrom, sites stdout - Reject --stats-json and --sites-output pointing at the same path (the TSV silently clobbered the JSON). - Validate --maf/--max-missing as finite fractions in [0,1] (NaN/Inf and out-of-range values were silently accepted, disabling or zeroing output). - parse_bed only skips header lines whose first token is exactly 'track'/'browser', not a chrom that starts with those letters. - --sites-output '-' now writes to stdout instead of a file named '-'. --- src/coords.rs | 12 +++++++----- src/main.rs | 22 +++++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/coords.rs b/src/coords.rs index 8121ee8..4fcb43f 100644 --- a/src/coords.rs +++ b/src/coords.rs @@ -25,11 +25,13 @@ pub fn parse_bed(path: &str) -> io::Result> { let mut ivs = Vec::new(); for (n, line) in content.lines().enumerate() { let line = line.trim(); - if line.is_empty() - || line.starts_with('#') - || line.starts_with("track") - || line.starts_with("browser") - { + if line.is_empty() || line.starts_with('#') { + continue; + } + // Skip BED header lines, but only when the FIRST token is exactly `track`/`browser` + // (not a chrom that merely starts with those letters, e.g. `track_scaffold_1`). + let first = line.split_whitespace().next().unwrap_or(""); + if first == "track" || first == "browser" { continue; } let mut f = line.split('\t'); diff --git a/src/main.rs b/src/main.rs index b704ae0..30fa255 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ use clap::Parser; use std::collections::HashSet; -use std::fs::File; use std::io::{self, BufWriter, IsTerminal, Write}; use std::path::Path; use std::time::Instant; @@ -104,11 +103,11 @@ struct Args { /// Drop these sample IDs (comma-separated, or @file). Excludes --keep-samples. #[arg(long, conflicts_with = "keep_samples")] exclude_samples: Option, /// Drop sites whose fraction of missing genotypes exceeds this (0.0-1.0). - #[arg(long)] max_missing: Option, + #[arg(long, value_parser = parse_fraction)] max_missing: Option, /// Drop sites whose minor-allele count is below this. #[arg(long)] mac: Option, /// Drop sites whose minor-allele frequency is below this (0.0-1.0). - #[arg(long)] maf: Option, + #[arg(long, value_parser = parse_fraction)] maf: Option, /// Drop sites with fewer than this many samples with data. #[arg(long)] min_samples: Option, /// Drop sites with more than this many distinct alleles (e.g. 2 = biallelic). @@ -141,6 +140,15 @@ fn parse_threads(s: &str) -> Result { Ok(n) } +/// Parse a fraction in [0.0, 1.0] (rejects NaN, infinities and out-of-range values). +fn parse_fraction(s: &str) -> Result { + let v: f64 = s.parse().map_err(|_| format!("'{}' is not a number", s))?; + if !v.is_finite() || !(0.0..=1.0).contains(&v) { + return Err(format!("'{}' must be a fraction between 0.0 and 1.0", s)); + } + Ok(v) +} + // ============================================================================= // Path validation // ============================================================================= @@ -274,8 +282,7 @@ fn apply_sample_filter( /// Write a TSV mapping each variable site to its alignment column, reference position, /// REF and ALT alleles (gaps rendered as '*'). fn write_sites_tsv(path: &str, var: &[VariablePosition], ref_pos: Option<&[u32]>) -> io::Result<()> { - let mut w = BufWriter::new(File::create(path).map_err(|e| io::Error::new(e.kind(), - format!("Cannot create sites TSV '{}': {}", path, e)))?); + let mut w = BufWriter::new(snpick::extract::open_sink(path)?); writeln!(w, "alignment_pos\tref_pos\tref\talt")?; for vp in var { // Clamp to 1 to match the VCF POS (a leading-gap reference column has ungapped @@ -365,6 +372,10 @@ fn run() -> io::Result<()> { check_paths_differ(vp, sidecar)?; } } + // ...and the two sidecars must not clobber each other. + if let (Some(a), Some(b)) = (args.stats_json.as_deref(), args.sites_output.as_deref()) { + check_paths_differ(a, b)?; + } // Map the input, transparently decompressing gzip/bgzip and reading stdin ("-") as needed. let input = input::map_input(&args.fasta)?; @@ -572,6 +583,7 @@ fn main() { mod tests { use super::*; use memmap2::Mmap; + use std::fs::File; use snpick::extract::ExtractParams; use snpick::fasta::{get_ref_seq, index_fasta}; use snpick::scan::{analyze, pass1_scan}; From 92ffa24195b5bf049359f539c986944310adeb78 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:57:02 +0200 Subject: [PATCH 50/62] docs: note duplicate VCF POS for insertion columns under --ref-coords --- docs/output.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/output.md b/docs/output.md index 36c35ad..4ef6cf8 100644 --- a/docs/output.md +++ b/docs/output.md @@ -88,6 +88,12 @@ site and per-sample genotypes. alignment length. `--ref-coords` maps `POS` (and the contig length) onto ungapped reference positions; `--reference ` picks which sequence is that reference. + Under `--ref-coords`, columns where the reference has a **gap** (insertions relative to the + reference) have no reference coordinate of their own, so they take the position of the + preceding reference base. Several such variable columns therefore share one `POS` (and any + leading-gap column clamps to `POS 1`), producing duplicate-`POS` records — expected for + insertions, but some downstream tools may need `bcftools norm` or a sort. + ### Genotype matrix guard The genotype matrix is `variants × samples` bytes. To avoid accidental multi-gigabyte VCFs, From cc7f223095ce8729b65b49818c4937114f43ac86 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:59:04 +0200 Subject: [PATCH 51/62] fix(vcf): stream --vcf-output - to stdout like the other outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_vcf opened its destination with File::create unconditionally, so `--vcf-output -` created a file literally named "-" in the working directory instead of streaming the VCF to stdout — inconsistent with -o, --sites-output and --stats-json, which all treat "-" as stdout. Route the VCF through the shared open_sink helper so "-" means stdout there too. For the auto-derived case, `-o - --vcf` used to fabricate a file named "-.vcf" from the "-" stem; reject that combination with a clear message telling the user to pass --vcf-output explicitly. --- src/main.rs | 24 +++++++++++++------ src/vcf.rs | 6 ++--- tests/cli.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 tests/cli.rs diff --git a/src/main.rs b/src/main.rs index 30fa255..ce0348e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -350,12 +350,22 @@ fn run() -> io::Result<()> { } let vcf_path = if do_vcf { let out = out_path.as_deref().unwrap_or("output"); - let vp = args.vcf_output.clone().unwrap_or_else(|| { - let o = Path::new(out); - let stem = o.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); - let parent = o.parent().unwrap_or(Path::new(".")); - parent.join(format!("{}.vcf", stem)).to_string_lossy().into_owned() - }); + let vp = match args.vcf_output.clone() { + Some(vp) => vp, + None => { + // Can't derive `.vcf` when the alignment streams to stdout (`-o -`); + // that used to fabricate a file literally named "-.vcf". + if out == "-" { + return Err(io::Error::new(io::ErrorKind::InvalidInput, + "Cannot derive a VCF path when the alignment is written to stdout (-o -); \ + pass --vcf-output (or --vcf-output - to stream the VCF to stdout).")); + } + let o = Path::new(out); + let stem = o.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); + let parent = o.parent().unwrap_or(Path::new(".")); + parent.join(format!("{}.vcf", stem)).to_string_lossy().into_owned() + } + }; check_paths_differ(&args.fasta, &vp)?; check_paths_differ(out, &vp)?; Some(vp) @@ -555,7 +565,7 @@ fn run() -> io::Result<()> { // Write VCF if let (Some(ref geno), Some(ref vp)) = (&vcf_geno, &vcf_path) { write_vcf(geno, num_samples, &var_positions, vp, &records, seq_length, &args.chrom, &ref_name, pos_map)?; - progress!(quiet, "[snpick] VCF written to {}.", vp); + progress!(quiet, "[snpick] VCF written to {}.", if vp == "-" { "stdout" } else { vp }); } progress!(quiet, "[snpick] Done in {:.2}s. {} vars from {} seqs × {} pos.", diff --git a/src/vcf.rs b/src/vcf.rs index 67a529c..ef5633e 100644 --- a/src/vcf.rs +++ b/src/vcf.rs @@ -3,9 +3,9 @@ //! Generates VCF v4.2 from the genotype matrix built during pass 2. //! Uses a per-position lookup table for O(1) allele → index mapping. -use std::fs::File; use std::io::{self, BufWriter, Write}; +use crate::extract::open_sink; use crate::fasta::FastaRecord; use crate::types::{VariablePosition, IO_BUF}; @@ -16,8 +16,8 @@ pub fn write_vcf( vcf_path: &str, records: &[FastaRecord], seq_length: usize, chrom: &str, reference: &str, pos_map: Option<&[u32]>, ) -> io::Result<()> { - let out = File::create(vcf_path).map_err(|e| io::Error::new(e.kind(), - format!("Cannot create VCF '{}': {}", vcf_path, e)))?; + // `-` streams to stdout, like -o / --sites-output / --stats-json; otherwise a file. + let out = open_sink(vcf_path)?; let mut w = BufWriter::with_capacity(IO_BUF, out); // Header diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..cd730c1 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,68 @@ +//! End-to-end CLI tests that drive the built `snpick` binary. Cargo sets +//! `CARGO_BIN_EXE_snpick` for integration tests, so these exercise argument +//! parsing and output routing exactly as a user would. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_snpick"); + +/// A unique temp directory per test, removed on drop. +struct TempDir(PathBuf); +impl TempDir { + fn new(tag: &str) -> Self { + let p = std::env::temp_dir().join(format!("snpick-cli-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&p); + fs::create_dir_all(&p).unwrap(); + TempDir(p) + } + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + fn write(&self, name: &str, contents: &str) -> PathBuf { + let p = self.path(name); + fs::write(&p, contents).unwrap(); + p + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +const ALN: &str = ">ref\nATGCAT\n>s1\nATGTAT\n>s2\nACGCAT\n>s3\nATGCAG\n"; + +#[test] +fn vcf_output_dash_streams_to_stdout() { + let d = TempDir::new("vcfdash"); + let fa = d.write("aln.fa", ALN); + let out = d.path("out.fa"); + let o = Command::new(BIN) + .current_dir(&d.0) + .args(["-f", fa.to_str().unwrap(), "-o", out.to_str().unwrap(), "--vcf-output", "-", "-q"]) + .output() + .unwrap(); + assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + // The VCF must arrive on stdout, not in a file literally named "-". + let stdout = String::from_utf8_lossy(&o.stdout); + assert!(stdout.starts_with("##fileformat=VCFv4.2"), "stdout was: {:?}", stdout); + assert!(!d.path("-").exists(), "a file named '-' should NOT have been created"); + // The reduced FASTA still went to its file. + assert!(out.exists()); +} + +#[test] +fn vcf_derive_rejects_stdout_alignment() { + // `-o - --vcf` cannot derive a sensible VCF filename; it must error, not write "-.vcf". + let d = TempDir::new("vcfderive"); + let fa = d.write("aln.fa", ALN); + let o = Command::new(BIN) + .current_dir(&d.0) + .args(["-f", fa.to_str().unwrap(), "-o", "-", "--vcf", "-q"]) + .output() + .unwrap(); + assert_eq!(o.status.code(), Some(2), "stderr: {}", String::from_utf8_lossy(&o.stderr)); + assert!(!d.path("-.vcf").exists(), "no '-.vcf' file should be created"); +} From 41dcb5b626db6481fce3e5f7d55fa4539eccb1e6 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:00:27 +0200 Subject: [PATCH 52/62] fix(cli): skip output-path checks under --check and guard stdout collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related output-routing fixes: - --check audits the alignment and exits without writing anything, yet it still validated the -o path, so `--check -o input.fa` failed with a spurious "paths resolve to same file" while `--dry-run -o input.fa` succeeded. Gate all output-path derivation and validation on a single writes_align flag so --check (like --dry-run) does not reject paths it never touches. - check_paths_differ exempts "-" because a file cannot collide with stdout, but that let several sinks target stdout at once — e.g. `--stats-json - --sites-output -` concatenated JSON and TSV onto one unparseable stream. Reject more than one output routed to "-". --- src/main.rs | 52 +++++++++++++++++++++++++++++++++++----------------- tests/cli.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/main.rs b/src/main.rs index ce0348e..2879f6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -332,8 +332,10 @@ fn run() -> io::Result<()> { let upper = build_upper(); let dry_run = args.dry_run; - // --dry-run reports statistics only, so it writes no FASTA/VCF. - let do_vcf = (args.vcf || args.vcf_output.is_some()) && !dry_run; + // --dry-run reports statistics only; --check audits and exits. Neither writes the reduced + // alignment or VCF, and --check additionally writes no sidecars, so it needs no output paths. + let writes_align = !dry_run && !args.check; + let do_vcf = (args.vcf || args.vcf_output.is_some()) && writes_align; // A CHROM with whitespace would break the tab-delimited VCF columns. if do_vcf && (args.chrom.is_empty() || args.chrom.bytes().any(|b| b.is_ascii_whitespace())) { @@ -341,10 +343,11 @@ fn run() -> io::Result<()> { "--chrom must be non-empty and contain no whitespace.")); } - // Output path (clap guarantees it is present unless --dry-run). - let out_path: Option = if dry_run { None } else { args.output.clone() }; + // Output path (clap guarantees it is present unless --dry-run/--check). + let out_path: Option = if writes_align { args.output.clone() } else { None }; - // Validate paths (skip when writing nothing). + // Validate paths (skip when writing nothing — e.g. `--check -o input.fa` must not be + // rejected for a collision with a file it never touches). if let Some(ref out) = out_path { check_paths_differ(&args.fasta, out)?; } @@ -371,20 +374,35 @@ fn run() -> io::Result<()> { Some(vp) } else { None }; - // Sidecar outputs must not collide with the input (which is mmapped in place — overwriting - // it truncates the live mapping and corrupts the run) or with the other outputs. - for sidecar in [args.stats_json.as_deref(), args.sites_output.as_deref()].into_iter().flatten() { - check_paths_differ(&args.fasta, sidecar)?; - if let Some(ref out) = out_path { - check_paths_differ(out, sidecar)?; + // Sidecars are emitted for normal and --dry-run runs, but not --check (which returns before + // they are written), so only validate their paths when they will actually be produced. + if !args.check { + // Sidecar outputs must not collide with the input (which is mmapped in place — + // overwriting it truncates the live mapping and corrupts the run) or the other outputs. + for sidecar in [args.stats_json.as_deref(), args.sites_output.as_deref()].into_iter().flatten() { + check_paths_differ(&args.fasta, sidecar)?; + if let Some(ref out) = out_path { + check_paths_differ(out, sidecar)?; + } + if let Some(ref vp) = vcf_path { + check_paths_differ(vp, sidecar)?; + } } - if let Some(ref vp) = vcf_path { - check_paths_differ(vp, sidecar)?; + // ...and the two sidecars must not clobber each other. + if let (Some(a), Some(b)) = (args.stats_json.as_deref(), args.sites_output.as_deref()) { + check_paths_differ(a, b)?; + } + // At most one output may stream to stdout; check_paths_differ exempts "-" (a file can't + // collide with stdout), so several "-" sinks would otherwise interleave — e.g. the JSON + // summary and the sites TSV concatenated onto one unparseable stream. + let to_stdout = [ + out_path.as_deref(), vcf_path.as_deref(), + args.stats_json.as_deref(), args.sites_output.as_deref(), + ].into_iter().flatten().filter(|p| *p == "-").count(); + if to_stdout > 1 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, + "Multiple outputs are directed to stdout ('-'); at most one may use '-'.")); } - } - // ...and the two sidecars must not clobber each other. - if let (Some(a), Some(b)) = (args.stats_json.as_deref(), args.sites_output.as_deref()) { - check_paths_differ(a, b)?; } // Map the input, transparently decompressing gzip/bgzip and reading stdin ("-") as needed. diff --git a/tests/cli.rs b/tests/cli.rs index cd730c1..89052a7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -53,6 +53,44 @@ fn vcf_output_dash_streams_to_stdout() { assert!(out.exists()); } +#[test] +fn check_does_not_reject_output_path() { + // --check writes nothing, so pointing -o at the input must not trip the collision guard, + // matching --dry-run's behavior. + let d = TempDir::new("checkpath"); + let fa = d.write("aln.fa", ALN); + for mode in ["--check", "--dry-run"] { + let o = Command::new(BIN) + .args(["-f", fa.to_str().unwrap(), mode, "-o", fa.to_str().unwrap(), "-q"]) + .output() + .unwrap(); + assert!(o.status.success(), "{mode} -o should succeed; stderr: {}", + String::from_utf8_lossy(&o.stderr)); + } +} + +#[test] +fn multiple_stdout_outputs_rejected() { + // Two sidecars both aimed at stdout would concatenate JSON+TSV into one unparseable stream. + let d = TempDir::new("multistdout"); + let fa = d.write("aln.fa", ALN); + let out = d.path("out.fa"); + let o = Command::new(BIN) + .args(["-f", fa.to_str().unwrap(), "-o", out.to_str().unwrap(), + "--stats-json", "-", "--sites-output", "-", "-q"]) + .output() + .unwrap(); + assert_eq!(o.status.code(), Some(2), "stdout: {}", String::from_utf8_lossy(&o.stdout)); + assert!(String::from_utf8_lossy(&o.stderr).contains("stdout")); + // Exactly one sidecar to stdout is still fine. + let ok = Command::new(BIN) + .args(["-f", fa.to_str().unwrap(), "-o", out.to_str().unwrap(), "--stats-json", "-", "-q"]) + .output() + .unwrap(); + assert!(ok.status.success()); + assert!(String::from_utf8_lossy(&ok.stdout).starts_with('{')); +} + #[test] fn vcf_derive_rejects_stdout_alignment() { // `-o - --vcf` cannot derive a sensible VCF filename; it must error, not write "-.vcf". From d1d6266ef2ff11713e20e298ca3cb79400b5bd56 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:01:24 +0200 Subject: [PATCH 53/62] fix(samples): treat every @file line as a literal sample ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_id_set skipped lines beginning with "#" in an @file list as comments. Sample IDs come from FASTA headers and may legitimately start with "#" (e.g. ">#iso1"), so such a line was silently dropped: the ID never entered the set, was never validated against the alignment, and the run proceeded over the wrong sample set with exit 0 — while the comma-separated form kept the same ID. Remove the comment handling so both forms behave identically; the existing "sample not found" check still catches typos. --- src/main.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2879f6e..32925e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -231,7 +231,9 @@ fn write_stats_json( } /// Parse a sample-ID selector: a comma-separated list, or `@path` to read one ID per line -/// (blank lines and `#` comments ignored). +/// (blank lines ignored). Every non-blank line is a literal ID — sample IDs come from FASTA +/// headers and may legitimately start with '#', so no line is treated as a comment (that would +/// silently drop such IDs and keep the wrong sample set, unlike the comma-separated form). fn parse_id_set(spec: &str) -> io::Result>> { let mut set = HashSet::new(); if let Some(path) = spec.strip_prefix('@') { @@ -239,7 +241,7 @@ fn parse_id_set(spec: &str) -> io::Result>> { format!("Cannot read sample list '{}': {}", path, e)))?; for line in content.lines() { let line = line.trim(); - if !line.is_empty() && !line.starts_with('#') { + if !line.is_empty() { set.insert(line.as_bytes().to_vec()); } } @@ -633,6 +635,19 @@ mod tests { assert_eq!(build_lookup(true)[b'-' as usize], BIT_GAP); } + #[test] fn parse_id_set_at_file_keeps_hash_ids() { + // @file must treat every non-blank line as a literal ID — including IDs that start + // with '#' (valid FASTA headers) — and agree with the comma-separated form. + let p = format!("/tmp/snpick_idset_{}.txt", std::process::id()); + std::fs::write(&p, "#iso1\n\n normal \n").unwrap(); + let from_file = parse_id_set(&format!("@{}", p)).unwrap(); + assert!(from_file.contains(&b"#iso1"[..])); + assert!(from_file.contains(&b"normal"[..])); + assert_eq!(from_file.len(), 2); + assert_eq!(from_file, parse_id_set("#iso1,normal").unwrap()); + std::fs::remove_file(&p).ok(); + } + #[test] fn test_index() { let p = tmp("idx3", ">s1\nATGC\n>s2\nATCC\n"); let m = setup(&p); From 3a66e57fe1ac74b70a8e882ef2c6af8f7b48971d Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:02:51 +0200 Subject: [PATCH 54/62] fix(input): keep gzip detection rewind-safe for non-seekable paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To sniff the gzip magic, map_input read the first two bytes and then, on a hit, reopened the path to decode from the start. Reopening rewinds a regular file, but a non-seekable path — process substitution `<(gzip -c ...)` or a named FIFO — yields another handle to the same pipe whose magic bytes are already consumed, so the decoder started mid-stream and aborted with "invalid gzip header" (the identical stream over stdin decoded fine). Chain the peeked bytes back onto the same handle instead, mirroring the stdin path; this works for both seekable and non-seekable inputs. --- src/input.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/input.rs b/src/input.rs index ef0b22b..7f1ad25 100644 --- a/src/input.rs +++ b/src/input.rs @@ -90,11 +90,69 @@ pub fn map_input(path: &str) -> io::Result { let is_gzip = n == 2 && magic == [0x1f, 0x8b]; if is_gzip { - let inf = File::open(path)?; - let (mmap, tp) = spool_to_temp(MultiGzDecoder::new(BufReader::new(inf)))?; + // Chain the peeked magic back onto the same handle rather than reopening the path. + // Reopening rewinds a regular file but NOT a non-seekable path (process substitution + // `<(gzip -c ...)` or a FIFO), where the magic bytes are already consumed — the decoder + // would then start mid-stream and fail with "invalid gzip header". This mirrors the + // stdin branch and works for both seekable and non-seekable inputs. + let head = std::io::Cursor::new(magic[..n].to_vec()); + let stream = head.chain(BufReader::new(f)); + let (mmap, tp) = spool_to_temp(MultiGzDecoder::new(stream))?; Ok(MappedInput { mmap, temp: Some(tp) }) } else { + // Mmap maps from offset 0 regardless of the read position left by read_magic. let mmap = unsafe { Mmap::map(&f)? }; Ok(MappedInput { mmap, temp: None }) } } + +#[cfg(test)] +mod tests { + use super::map_input; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::fs::File; + use std::io::Write; + + fn gzip(bytes: &[u8]) -> Vec { + let mut e = GzEncoder::new(Vec::new(), Compression::fast()); + e.write_all(bytes).unwrap(); + e.finish().unwrap() + } + + #[test] + fn gzip_regular_file_decodes() { + let raw = b">s1\nATGC\n>s2\nATCC\n"; + let p = std::env::temp_dir().join(format!("snpick-gz-{}.fa.gz", std::process::id())); + std::fs::write(&p, gzip(raw)).unwrap(); + let m = map_input(p.to_str().unwrap()).unwrap(); + assert_eq!(&m.mmap[..], raw); + std::fs::remove_file(&p).ok(); + } + + // A gzip stream delivered through a non-seekable FIFO must still decode. On the old code the + // gzip branch reopened the path, which on a pipe yields a fresh handle whose magic bytes were + // already consumed, so decoding failed with "invalid gzip header". + #[cfg(unix)] + #[test] + fn gzip_over_fifo_is_rewind_safe() { + use std::process::Command; + let raw = b">s1\nATGC\n>s2\nATCC\n"; + let gz = gzip(raw); + let fifo = std::env::temp_dir().join(format!("snpick-fifo-{}.gz", std::process::id())); + let _ = std::fs::remove_file(&fifo); + assert!(Command::new("mkfifo").arg(&fifo).status().unwrap().success()); + + let wpath = fifo.clone(); + let writer = std::thread::spawn(move || { + // File::create on a FIFO blocks until the reader opens; then stream and close. + if let Ok(mut f) = File::create(&wpath) { + let _ = f.write_all(&gz); + } + }); + let m = map_input(fifo.to_str().unwrap()).unwrap(); + assert_eq!(&m.mmap[..], raw); + writer.join().unwrap(); + std::fs::remove_file(&fifo).ok(); + } +} From e4bad855e2b9e8d230d4809948d1cb5de6bc75a2 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:03:28 +0200 Subject: [PATCH 55/62] fix(coords): compare reference BED intervals without truncating to u32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_mask cast BED interval bounds to u32 in the --mask-ref branch. With overflow checks off in release, an out-of-range coordinate (>= 2^32) or a degenerate empty interval at usize::MAX wrapped/truncated into the valid range and masked the wrong columns — silently dropping SNPs and corrupting the ASC fconst counts — while the alignment-coordinate branch clamps and is unaffected. Compare in usize with a saturating +1, matching that branch, so a pathological interval is a no-op instead of masking real sites. --- src/coords.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/coords.rs b/src/coords.rs index 4fcb43f..40d7665 100644 --- a/src/coords.rs +++ b/src/coords.rs @@ -68,10 +68,15 @@ pub fn build_mask( } Some(rp) => { for &(s, e) in intervals { - // 0-based half-open [s, e) over the reference == 1-based positions s+1 ..= e - let (lo, hi) = ((s + 1) as u32, e as u32); + // 0-based half-open [s, e) over the reference == 1-based positions s+1 ..= e. + // Compare in usize (saturating on the +1) so an out-of-range or degenerate + // interval never wraps or truncates to u32 and masks the wrong columns — the + // None branch above is likewise robust via `.min(seq_length)`. Reference + // positions are always <= MAX_SEQ_LENGTH, so widening `p` loses nothing. + let lo = s.saturating_add(1); for (col, &p) in rp.iter().enumerate() { - if p >= lo && p <= hi { + let p = p as usize; + if p >= lo && p <= e { mask[col] = true; } } @@ -106,4 +111,17 @@ mod tests { let m = build_mask(&[(1, 2)], 4, Some(&rp)); assert_eq!(m, vec![false, false, true, false]); } + + #[test] + fn mask_reference_out_of_range_is_noop() { + // Reference positions are small; an interval far beyond them (or a degenerate empty one + // at usize::MAX) must mask nothing. The old `as u32` cast wrapped/truncated these into + // the valid range and masked the wrong columns (and panicked on the +1 in debug). + let rp = ref_positions(b"ACGT"); // positions [1,2,3,4] + let huge = 1usize << 40; // ≡ 0 mod 2^32 + assert_eq!(build_mask(&[(huge, huge + 4)], 4, Some(&rp)), vec![false; 4]); + assert_eq!(build_mask(&[(usize::MAX, usize::MAX)], 4, Some(&rp)), vec![false; 4]); + // An in-range interval still masks correctly. + assert_eq!(build_mask(&[(1, 3)], 4, Some(&rp)), vec![false, true, true, false]); + } } From 44c378a7663b73ec5d5ac8f5b75293cfcbefffed Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:04:55 +0200 Subject: [PATCH 56/62] fix(vcf): keep the contig length consistent with the clamped POS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under --ref-coords, POS is floored to 1, but the declared ##contig length came straight from the last ungapped reference position. An all-gap reference has ungapped length 0, so the header declared length=0 while the data rows sat at POS=1 — a self-contradictory VCF (POS > contig length) that htslib rejects, written silently with exit 0. Clamp the contig length to >= 1 to match the POS clamp. --- src/main.rs | 28 ++++++++++++++++++++++++++++ src/vcf.rs | 7 +++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 32925e6..ce08bd0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -777,6 +777,34 @@ mod tests { std::fs::remove_file(&p).ok(); std::fs::remove_file(fo).ok(); std::fs::remove_file(vo).ok(); } + #[test] fn test_vcf_allgap_ref_contig_len() { + // All-gap reference under --ref-coords: ungapped length is 0, but POS clamps to 1, so the + // declared contig length must also be >= 1 — an emitted POS must never exceed it. + let p = tmp("agref", ">ref\n--\n>s1\nAC\n>s2\nGT\n"); + let fo = "/tmp/snpick_t_agref_out.fa"; let vo = "/tmp/snpick_t_agref.vcf"; + let m = setup(&p); + let lk = build_lookup(false); + let up = build_upper(); + let (recs, sl, layout) = index_fasta(&m).unwrap(); + let bm = pass1_scan(&m, &recs, sl, layout, &lk); + let rs = get_ref_seq(&m, &recs[0], sl, layout); + let (mut v, _) = analyze(&bm, &rs, &lk, false); + let ep = ExtractParams { records: &recs, output: fo, collect_vcf: true, lookup: &lk, upper: &up, layout, format: OutputFormat::Fasta, progress: false }; + let g = pass2_extract(&m, &mut v, &ep).unwrap().unwrap(); + let rp = snpick::coords::ref_positions(&rs); + write_vcf(&g, recs.len(), &v, vo, &recs, sl, "1", "ref", Some(&rp)).unwrap(); + let c = std::fs::read_to_string(vo).unwrap(); + let contig_len: usize = c.lines() + .find_map(|l| l.strip_prefix("##contig=').parse().ok()).unwrap(); + assert!(contig_len >= 1, "contig length was {}", contig_len); + for l in c.lines().filter(|l| !l.starts_with('#')) { + let pos: usize = l.split('\t').nth(1).unwrap().parse().unwrap(); + assert!(pos <= contig_len, "POS {} exceeds contig length {}", pos, contig_len); + } + std::fs::remove_file(&p).ok(); std::fs::remove_file(fo).ok(); std::fs::remove_file(vo).ok(); + } + #[test] fn test_vcf_header_only() { // Zero variable sites but VCF requested: a valid header-only VCF that // still lists every sample, so downstream pipelines find the file. diff --git a/src/vcf.rs b/src/vcf.rs index ef5633e..2962004 100644 --- a/src/vcf.rs +++ b/src/vcf.rs @@ -24,9 +24,12 @@ pub fn write_vcf( writeln!(w, "##fileformat=VCFv4.2")?; writeln!(w, "##source=snpick v{}", env!("CARGO_PKG_VERSION"))?; writeln!(w, "##reference={}", reference)?; - // With reference coordinates the contig length is the ungapped reference length. + // With reference coordinates the contig length is the ungapped reference length. POS values + // are clamped to >= 1 below, so clamp the declared length the same way: an all-gap reference + // has ungapped length 0, which would otherwise declare `length=0` while emitting POS=1 + // records — a self-contradictory VCF that htslib rejects. let contig_len = match pos_map { - Some(rp) => *rp.last().unwrap_or(&(seq_length as u32)) as usize, + Some(rp) => (*rp.last().unwrap_or(&(seq_length as u32))).max(1) as usize, None => seq_length, }; writeln!(w, "##contig=", chrom, contig_len)?; From 730f70c207dac4fdbc455eb42f536f940aff0aa3 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:22:44 +0200 Subject: [PATCH 57/62] docs(vcf): correct POS default, ##reference header, and gap-REF encoding - POS is the 1-based alignment column by default (reference coordinates are opt-in via --ref-coords); the feature list wrongly implied POS was reference-anchored by default. - The VCF example header is ##reference=, not the literal "first_sequence"; note that ##reference carries the reference record ID. - A gap in REF renders as N (VCF v4.2 forbids -/* in REF); only ALT gaps render as *. The docs and CHANGELOG claimed a gap REF becomes *. --- CHANGELOG.md | 3 ++- docs/index.md | 4 ++-- docs/output.md | 17 +++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d9bd0..8999ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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`; - they are now rendered as `*`, matching the `ALT` encoding. + 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 diff --git a/docs/index.md b/docs/index.md index 642b25c..807ec38 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,8 +46,8 @@ alignments ready for phylogenetic inference with ascertainment-bias correction ( --- - VCF v4.2 with per-sample genotypes, a configurable contig name, reference-anchored `POS`, - and gap/ambiguity handling. + 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__ diff --git a/docs/output.md b/docs/output.md index 4ef6cf8..daee3e5 100644 --- a/docs/output.md +++ b/docs/output.md @@ -64,7 +64,7 @@ site and per-sample genotypes. ```text ##fileformat=VCFv4.2 ##source=snpick v1.0.2 -##reference=first_sequence +##reference=ref ##contig= ##INFO= ##FORMAT= @@ -77,11 +77,14 @@ site and per-sample genotypes. |---|---| | `CHROM` | Contig name — `1` by default, override with [`--chrom`](usage.md#vcf-coordinates) | | `POS` | **1-based alignment column** by default, or the ungapped reference position with `--ref-coords` | -| `REF` | Base of the first sequence; if that base is ambiguous, the first observed base in A, C, G, T order | +| `REF` | Base of the reference sequence (the first, or the one set by `--reference`); if that base is ambiguous, the first observed base in A, C, G, T order; if it is a gap (under `-g`), `N` | | `ALT` | The other observed alleles, comma-separated | | `INFO=NS` | Number of samples with data (a called base; gaps count only under `-g`) | | `FORMAT=GT` | Per-sample allele index: `0` = REF, `1..` = the *n*-th ALT, `.` = missing/ambiguous | +The `##reference` header carries the **ID** of the reference sequence (the first record, or the +one chosen with `--reference `), and `##contig` its length. + !!! warning "POS is an alignment coordinate by default" Without `--ref-coords`, `POS` is the column index in the alignment, so when the reference contains gaps it diverges from the true genomic position and `##contig` length is the @@ -111,13 +114,15 @@ declares the `.vcf` as an output does not break. - **Ambiguous bases** (N, R, Y, …) are treated as **missing data**, never as alleles. A column is variable only if it has ≥2 standard bases (A, C, G, T). Ambiguous genotypes are written as `.` in the VCF. -- **Gaps** (`-`) are **ignored by default**. With `-g` a gap becomes a 5th allele and, in the - VCF, is rendered as `*`. +- **Gaps** (`-`) are **ignored by default**. With `-g` a gap becomes a 5th allele. In the VCF a + gap in **ALT** is written as `*`, but a gap in **REF** (the reference sequence has a gap at a + variable column) is written as **`N`**, since VCF v4.2 forbids `-`/`*` in the REF field. The + sample whose base is that gap still gets genotype `0`, so `REF=N` there marks a gap, not an N. !!! note "The `*` gap encoding" - Writing gaps as `*` is an alignment convention shared with snp-sites. Note that in strict + Writing ALT gaps as `*` is an alignment convention shared with snp-sites. Note that in strict VCF v4.2, `*` denotes a spanning deletion, so some downstream tools may interpret gap sites - accordingly. + accordingly. A gap in REF is written as `N` instead (`*` is not a legal REF allele). With `--iupac-mode resolve`, IUPAC codes (R = A|G, …) are resolved to their bases when classifying, so a column that is all-A plus one `R` becomes variable. Resolution applies only to From 43dd5759370857f8a94d531a986217323f7b0c56 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:23:13 +0200 Subject: [PATCH 58/62] docs: document stdout ('-') support for every output sink `-` writes to stdout for --output, --vcf-output, --sites-output and --stats-json (and reads stdin for --fasta), with at most one stdout sink per run. The usage/README notes and the --vcf-output/--sites-output table rows only listed some of these, implying the VCF and sites TSV could not stream to stdout. --- README.md | 2 +- docs/usage.md | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a41bbe3..ab63c87 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ snpick [OPTIONS] --fasta ``` `-f/--fasta` is always required; `-o/--output` is required unless `--dry-run` or `--check`. -Use `-` for `--fasta`/`--output`/`--stats-json` to read stdin / write stdout. See `snpick --help` for the authoritative list. +Use `-` for `--fasta` (stdin) or for any one of `--output`, `--vcf-output`, `--sites-output` or `--stats-json` (stdout; at most one at a time). See `snpick --help` for the authoritative list. **Core** diff --git a/docs/usage.md b/docs/usage.md index f91b39b..d32f0ca 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -11,8 +11,10 @@ snpick [OPTIONS] --fasta ``` `-f/--fasta` is always required. `-o/--output` is required **unless** `--dry-run` or `--check` -is given. Use `-` in place of a path for `--fasta`, `--output` or `--stats-json` to read from -stdin / write to stdout. Run `snpick --help` for the authoritative, always-current list. +is given. Use `-` in place of a path for `--fasta` (read from stdin) or for `--output`, +`--vcf-output`, `--sites-output` and `--stats-json` (write to stdout). **At most one** output may +use `-` at a time — snpick errors (exit 2) if two or more `-` sinks are given. Run +`snpick --help` for the authoritative, always-current list. ## Core @@ -31,11 +33,11 @@ stdin / write to stdout. Run `snpick --help` for the authoritative, always-curre | Argument | Description | |---|---| | `--vcf` | Write a VCF named after the output (`snps.fasta` → `snps.vcf`) | -| `--vcf-output ` | Write the VCF to a custom path (implies `--vcf`) | +| `--vcf-output ` | Write the VCF to a custom path (implies `--vcf`; `-` = stdout) | | `--chrom ` | `CHROM` / `##contig` name (default `1`, e.g. `NC_000962.3`) | | `--reference ` | Sequence ID used for REF/polarity (default: first) | | `--ref-coords` | Write `POS` as the ungapped reference position, not the alignment column | -| `--sites-output ` | Map each site: `alignment_pos`, `ref_pos`, `ref`, `alt` | +| `--sites-output ` | Map each site: `alignment_pos`, `ref_pos`, `ref`, `alt` (`-` = stdout) | ## Filtering & masking From e719212f404a6105158887fe61dc2f6422cd1127 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:24:03 +0200 Subject: [PATCH 59/62] docs(benchmarks): reconcile the figures with benchmarks/results.tsv The published table (250 seqs 0.9 s / 1000 seqs ~3 s, ~140 MB; snp-sites 520 MB / 3+ GB) did not match the committed results.tsv (250 seqs 1.72 s / 105 MB, snp-sites 9.38 s / 213 MB; 1000 seqs 10.27 s / 217 MB, snp-sites killed). Use the recorded numbers, note their source and hardware/thread dependence, and replace the "O(L) memory regardless of sequence count" claim (contradicted by the measured 39->217 MB curve) with accurate sub-linear wording. Fixed in benchmarks.md, index.md and README. --- README.md | 6 +++--- docs/benchmarks.md | 12 ++++++++---- docs/index.md | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ab63c87..83263a9 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ SNPick extracts variable (SNP) sites from whole-genome FASTA alignments. It prod | | **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 | @@ -295,7 +295,7 @@ Simulated *M. tuberculosis*-like genomes (4.4 Mbp, ~65% GC, 3.6% variable sites) Benchmark: length scaling

-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/docs/benchmarks.md b/docs/benchmarks.md index 3dc94ed..f58183e 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -6,6 +6,9 @@ tags: # 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 @@ -19,13 +22,14 @@ Benchmarks use simulated *M. tuberculosis*-like genomes (4.4 Mbp, ~65% GC, 3.6% Benchmark: length scaling

-SNPick maintains **O(L)** memory regardless of sequence count, while snp-sites requires -**O(N × L)** — it holds the full matrix in memory and is eventually killed on large inputs. +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 | **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) | !!! tip "Reproducing" Wall-clock depends on core count; pin it with [`--threads`](usage.md#core) diff --git a/docs/index.md b/docs/index.md index 807ec38..4848eca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,8 +77,8 @@ alignments ready for phylogenetic inference with ascertainment-bias correction ( | | **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 | :material-check:{ .snpick-yes } Built-in | :material-close: Not supported | | VCF output | :material-check:{ .snpick-yes } Optional | :material-check: Default | | Gap handling | :material-check:{ .snpick-yes } Optional (`-g`) | :material-check: Default | From b1b7dbca026554df571046719f6f8cd65f5f11f9 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:24:24 +0200 Subject: [PATCH 60/62] docs(architecture): qualify the zero-copy and O(L)-memory claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "No copies of the sequence data are ever made" overstated it — compressed/ piped input is spooled to a temp file and the reference sequence is copied once (both O(L)). The accurate claim is that the full N×L matrix is never materialized. Likewise, "O(L) memory" holds only for the reduced-alignment path; --vcf allocates a variable_sites × samples genotype matrix (up to O(L×N), bounded by the 4 GB guard). --- docs/architecture.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 475c3cc..5732a3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,8 +17,10 @@ flowchart LR F --> H([VCF v4.2]) ``` -SNPick memory-maps the input once and shares it, read-only, across two passes — **no copies of -the sequence data are ever made**. +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 @@ -57,8 +59,11 @@ genotype). - **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** — the working set scales with alignment length, not with the number of - sequences, which is what lets SNPick handle thousands of genomes with a small footprint. +- **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` From 68a86a58d125293cbfde029b29cfcc7ac6bf90e7 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:24:55 +0200 Subject: [PATCH 61/62] docs: state MSRV 1.74, fix README byte count, date changelog releases - From-source install now names the minimum toolchain (Rust 1.74, matching Cargo.toml rust-version), not just "edition 2021". - README quick example printed "Mapped 63 bytes" for an input that is 90 bytes (the binary prints 90); every other line already matched. - Add release dates to the 1.0.1 (2026-03-31) and 1.0.0 (2024-11-16) changelog headers for Keep a Changelog consistency with 1.0.2. --- CHANGELOG.md | 4 ++-- README.md | 2 +- docs/installation.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8999ab9..7c78d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,13 +66,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). write per genotype field — a large speedup on many-sample inputs. Output is byte-identical. -## [1.0.1] +## [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] +## [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. diff --git a/README.md b/README.md index 83263a9..a19186e 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ A **stderr:** ``` -[snpick] Mapped 63 bytes. 3 sequences × 18 positions. +[snpick] Mapped 90 bytes. 3 sequences × 18 positions. [snpick] 1 variable, 17 constant (A:4 C:4 G:4 T:5), 0 ambiguous-only, 18 total. [snpick] ASC fconst: 4,4,4,5 [snpick] Done in 0.00s. 1 vars from 3 seqs × 18 pos. diff --git a/docs/installation.md b/docs/installation.md index 3e40a64..2ef9fa0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -38,7 +38,7 @@ tags: === ":material-language-rust: From source" - Requires a [Rust toolchain](https://rustup.rs/) (edition 2021). + Requires a [Rust toolchain](https://rustup.rs/) (edition 2021, Rust 1.74 or newer). ```bash git clone https://github.com/PathoGenOmics-Lab/snpick.git From 72f68d7638a203559875025ca110ce2e230160a9 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:26:10 +0200 Subject: [PATCH 62/62] docs(tutorial): note that a gap in REF is written N, not * The "Include gaps" cell said a gap is "rendered as * in the VCF" unconditionally; that holds only for ALT gaps. A gap in the reference is written N (* is not a legal REF allele). --- docs/tutorials/tutorial.ipynb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb index 3562cc0..514c130 100644 --- a/docs/tutorials/tutorial.ipynb +++ b/docs/tutorials/tutorial.ipynb @@ -337,11 +337,7 @@ "cell_type": "markdown", "id": "fc968876", "metadata": {}, - "source": [ - "## 5. Include gaps\n", - "\n", - "By default gaps (`-`) are ignored and never make a column variable. With `-g` a gap becomes a 5th allele (rendered as `*` in the VCF)." - ] + "source": "## 5. Include gaps\n\nBy default gaps (`-`) are ignored and never make a column variable. With `-g` a gap becomes a 5th allele — in the VCF an ALT gap is written `*`, while a gap in the reference is written `N` (`*` is not a legal REF allele)." }, { "cell_type": "code", @@ -425,4 +421,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file