Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/bencher.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
6 changes: 5 additions & 1 deletion rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -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"]
44 changes: 44 additions & 0 deletions src/annotator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"));
}
}
}
71 changes: 71 additions & 0 deletions src/numbering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,74 @@ pub fn number_with_rules(len: usize, rule: &NumberingRule) -> Vec<Position> {

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());
}
}
}
6 changes: 3 additions & 3 deletions src/numbering/kabat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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)),
Expand Down
Loading