diff --git a/.github/workflows/bencher.yml b/.github/workflows/bencher.yml index 5ea8417..94bcc8e 100644 --- a/.github/workflows/bencher.yml +++ b/.github/workflows/bencher.yml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: bencherdev/bencher@main + - uses: bencherdev/bencher@v0.6.11 - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -57,7 +57,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: bencherdev/bencher@main + - uses: bencherdev/bencher@v0.6.11 - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b90d6a3..a354ee8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -239,8 +239,22 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable + - name: Read toolchain version + id: toolchain + # Read from rust-toolchain.toml so the pin lives in one place. + run: | + CHANNEL=$(grep '^channel = ' rust-toolchain.toml | head -1 | sed 's/channel = "\(.*\)"/\1/') + if [ -z "$CHANNEL" ]; then + echo "::error::Could not read channel from rust-toolchain.toml" + exit 1 + fi + echo "channel=$CHANNEL" >> $GITHUB_OUTPUT + echo "Using Rust toolchain: $CHANNEL" + + - name: Install Rust + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ steps.toolchain.outputs.channel }} - name: Publish to crates.io env: diff --git a/CHANGELOG.md b/CHANGELOG.md index e9bb2a5..b6c686c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. 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). +## [Unreleased] + +### Fixed +- Kabat numbering no longer panics when a CDR aligns to a single residue. Heavy CDR1, heavy CDR3 and light CDR2 each had a `deletion_order` shorter than `base_len - 1`, so deleting every base position but one indexed past the end of the slice. The orders now follow ANARCI, which leaves position 23 for heavy CDR1 and 94 for heavy CDR3. Affected roughly 1 in 20 000 truncated but productive VHH reads, all of which numbered correctly under IMGT. + ## [1.1.2] - 2026-05-21 ### Added diff --git a/Cargo.lock b/Cargo.lock index 43624e5..4274139 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -724,9 +724,9 @@ dependencies = [ [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "event-listener" diff --git a/pyproject.toml b/pyproject.toml index 920d652..810c147 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["maturin>=1.10.2,<2.0", "polars>1.30.0"] +requires = ["maturin>=1.10.2,<2.0", "polars>1.30.0,<2.0"] build-backend = "maturin" [project] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 292fe49..68badd1 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,6 @@ [toolchain] -channel = "stable" +# Pinned rather than "stable": CI floating onto each new release turned a latent unsoundness in a +# transitive dependency into a hard build error with no change on our side. Minor precision, so +# patch-level fixes are still picked up. Bump deliberately. +channel = "1.97" +components = ["rustfmt", "clippy"] diff --git a/src/annotator.rs b/src/annotator.rs index 645ca2e..7913a82 100644 --- a/src/annotator.rs +++ b/src/annotator.rs @@ -339,4 +339,48 @@ mod tests { assert_eq!(result.query_end, prefix.len() + FULL_IGH.len() - 1); assert_eq!(result.positions.len(), FULL_IGH.len()); } + + /// Truncated but productive camel VHH reads from the Observed Antibody Space (Li et al. 2017, + /// bactrian camel, run SRR3544217). Each aligns such that a Kabat heavy CDR1 or CDR3 collapses to + /// a single residue, which asks `number_with_rules` to delete every base position but one. Three + /// Kabat `deletion_order` tables were one entry short of that and sliced out of bounds, so these + /// panicked at `numbering.rs:175` under Kabat while numbering fine under IMGT. + const TRUNCATED_VHH_READS: &[(&str, &str)] = &[ + ("CDR1", "GWFRQAPGKEREGGAYIYTSDGIARYSDSVKGRFTISVDGVKKILFLQMNELKAEDTATYYCASTGRSNDCGPAQKLLLHSARGGRDFGIWGQGTQVTVS"), + ("CDR3", "QLVESGGGLVQPGGSLRLSCAATGFTFSNNWMHWVRQAPGKGLEWVASISRSGGNTDYADSVKGRFTISRDNAKNTLYLHLNSLKPEDTAMYYCTNWGQGTQVTVS"), + ("CDR1", "GWFRQAPGKEREGVAFISSEGAPTYADSVQGRFTISRNVLPERLSLQMTRLKAEDTAMYYCALDPSWDGRRIVLHGTFAAWECPREERQAFGVWGLGTQVTVS"), + ("CDR3", "QLVESGGGLVQPGGSLRLSCAASGLTFSSHAMSWVRQAPGKGLEWVSGITGGGTSYYADPVKGRFTISRDNAKNSVYLQLNSLKAEDSAMYYCAKWGQGTQVTVS"), + ("CDR1", "TWVRQAPGKGLEWVSTINSGGDSTYYADSVKGRFTISQDSAKNILYLQMRSLKPEDTAMYYCAARSVGWCPLFEHWLGKRAYTPGGYFANWGQGTQVTVS"), + ("CDR1", "GWFRQAPGKEREGVAVIHKNIYVASNTPGAVFYADSVKGRFTISRDSAKNTLYLQMNSLKPEDAAMYSCAADSRYASCGWLLDRFRDFAYRGQGTQVTVS"), + ]; + + #[test] + fn numbers_truncated_reads_under_kabat() { + let annotator = Annotator::new(&[Chain::IGH], Scheme::Kabat, None).unwrap(); + + for (region, sequence) in TRUNCATED_VHH_READS { + let result = annotator + .number(sequence) + .unwrap_or_else(|err| panic!("{region} read failed to number: {err}")); + + assert_eq!(result.scheme, Scheme::Kabat); + assert_eq!( + result.positions.len(), + result.query_end - result.query_start + 1, + "{region} read got {} positions for {} aligned residues", + result.positions.len(), + result.query_end - result.query_start + 1, + ); + } + } + + #[test] + fn numbers_truncated_reads_under_imgt_too() { + let annotator = Annotator::new(&[Chain::IGH], Scheme::IMGT, None).unwrap(); + for (region, sequence) in TRUNCATED_VHH_READS { + annotator + .number(sequence) + .unwrap_or_else(|err| panic!("{region} read failed to number under IMGT: {err}")); + } + } } diff --git a/src/numbering.rs b/src/numbering.rs index 0c8ed92..ddca267 100644 --- a/src/numbering.rs +++ b/src/numbering.rs @@ -219,3 +219,74 @@ pub fn number_with_rules(len: usize, rule: &NumberingRule) -> Vec { result } + +#[cfg(test)] +mod tests { + use super::*; + use crate::numbering::imgt::IMGT_RULES; + use crate::numbering::kabat::{KABAT_HEAVY_RULES, KABAT_LIGHT_RULES}; + + /// Only rules with an insertion strategy reach `number_with_rules`; `number_by_rules` sends the + /// framework rules down the offset path instead, where `deletion_order` is never consulted. + fn variable_rules() -> Vec<(&'static str, &'static NumberingRule)> { + [ + ("KABAT_HEAVY_RULES", KABAT_HEAVY_RULES), + ("KABAT_LIGHT_RULES", KABAT_LIGHT_RULES), + ("IMGT_RULES", IMGT_RULES), + ] + .into_iter() + .flat_map(|(name, rules)| { + rules + .iter() + .filter(|rule| !matches!(rule.insertion, Insertion::None)) + .map(move |rule| (name, rule)) + }) + .collect() + } + + /// A region one residue long asks to delete every base position but one, so `deletion_order` + /// needs `base_len - 1` entries. Three Kabat rules were short of that and sliced out of bounds + /// (`&rule.deletion_order[..to_remove]`), which surfaced as a panic on truncated reads. + #[test] + fn deletion_order_covers_the_shortest_possible_region() { + for (table, rule) in variable_rules() { + let base_len = (rule.num_end - rule.num_start + 1) as usize; + assert!( + rule.deletion_order.len() >= base_len - 1, + "{table} rule {}-{} has {} deletion positions but needs {} to represent a \ + single-residue region", + rule.num_start, + rule.num_end, + rule.deletion_order.len(), + base_len - 1, + ); + } + } + + /// The contract of `number_with_rules`: one position per residue, for every length a region can + /// take. Exercises the deletion path (below base length) and the insertion path (above it). + #[test] + fn every_region_length_yields_one_position_per_residue() { + for (table, rule) in variable_rules() { + let base_len = (rule.num_end - rule.num_start + 1) as usize; + for len in 1..=base_len + 4 { + let positions = number_with_rules(len, rule); + assert_eq!( + positions.len(), + len, + "{table} rule {}-{} returned {} positions for a region of {len} residues", + rule.num_start, + rule.num_end, + positions.len(), + ); + } + } + } + + #[test] + fn empty_region_yields_no_positions() { + for (_, rule) in variable_rules() { + assert!(number_with_rules(0, rule).is_empty()); + } + } +} diff --git a/src/numbering/kabat.rs b/src/numbering/kabat.rs index 4b6bc35..abc5c9b 100644 --- a/src/numbering/kabat.rs +++ b/src/numbering/kabat.rs @@ -20,7 +20,7 @@ pub const KABAT_HEAVY_RULES: &[NumberingRule] = &[ 40, 23, 35, - &[35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25], + &[35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23], Insertion::Sequential(35), ), NumberingRule::offset(41, 53, -5), @@ -41,7 +41,7 @@ pub const KABAT_HEAVY_RULES: &[NumberingRule] = &[ 117, 94, 102, - &[100, 99, 98, 97, 96, 95, 94], + &[100, 99, 98, 97, 96, 95, 101, 102, 94], Insertion::Sequential(100), ), NumberingRule::offset(118, 128, -15), @@ -63,7 +63,7 @@ pub const KABAT_LIGHT_RULES: &[NumberingRule] = &[ Insertion::Sequential(27), ), NumberingRule::offset(39, 55, -6), - NumberingRule::variable(56, 65, 50, 52, &[52], Insertion::Sequential(52)), + NumberingRule::variable(56, 65, 50, 52, &[52, 51, 50], Insertion::Sequential(52)), NumberingRule::offset(66, 72, -13), NumberingRule::offset(74, 79, -14), NumberingRule::variable(80, 82, 66, 66, &[], Insertion::Sequential(66)),