diff --git a/.github/workflows/r-check.yml b/.github/workflows/r-check.yml new file mode 100644 index 0000000..a92e02d --- /dev/null +++ b/.github/workflows/r-check.yml @@ -0,0 +1,81 @@ +name: R CMD check + +on: + push: + branches: [main] + paths: + - "r-immunum/**" + - "src/**" + - "build.rs" + - "Cargo.toml" + - "Cargo.lock" + - "resources/**" + - ".github/workflows/r-check.yml" + pull_request: + paths: + - "r-immunum/**" + - "src/**" + - "build.rs" + - "Cargo.toml" + - "Cargo.lock" + - "resources/**" + - ".github/workflows/r-check.yml" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + r-check: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + r-version: release + - os: ubuntu-latest + r-version: devel + - os: ubuntu-latest + r-version: oldrel-1 + - os: macos-latest + r-version: release + - os: windows-latest + r-version: release + + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + prefix-key: r-immunum + workspaces: r-immunum/src/extendr + + - name: Set up R + uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.r-version }} + use-public-rspm: true + + - name: Install R dependencies + uses: r-lib/actions/setup-r-dependencies@v2 + with: + working-directory: r-immunum + + # R CMD check copies the package to a temp directory, breaking the + # path dependency to the parent Rust crate. Since we target r-universe + # (which clones the full repo), we install from the repo and run tests + # directly instead. + - name: Install package + shell: bash + run: R CMD INSTALL --install-tests r-immunum + + - name: Run tests + shell: bash + run: Rscript -e 'testthat::test_package("immunum", stop_on_failure = TRUE)' + env: + IMMUNUM_FIXTURES: ${{ github.workspace }}/fixtures/validation diff --git a/README.md b/README.md index 2c4c9a0..bca0570 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Immunum Logo](https://raw.githubusercontent.com/ENPICOM/immunum/master/docs/assets/immunum_logotype.svg) -Immunum is a high-performance antibody and TCR sequence numbering tool for Rust, Python, Polars and JS/TS. +Immunum is a high-performance antibody and TCR sequence numbering tool for Rust, Python, R, Polars and JS/TS. Try it in your browser: [interactive demo](https://immunum.enpicom.com/demo/). @@ -19,6 +19,7 @@ Available as: - **Rust crate** — core library and CLI - **Python package** — with a [Polars](https://pola.rs) plugin for vectorized batch processing +- **R package** — with rayon-parallel batch processing, distributed via r-universe - **npm package** — for Node.js and browsers ### Supported chains @@ -46,11 +47,15 @@ Chain type is automatically detected by aligning against all loaded chains and s - [Numbering](#numbering) - [Segmentation](#segmentation) - [Polars plugin](#polars-plugin) -- [JavaScript / npm](#javascript--npm) +- [R](#r) - [Installation](#installation-1) + - [Numbering](#numbering-1) + - [Segmentation](#segmentation-1) +- [JavaScript / npm](#javascript--npm) + - [Installation](#installation-2) - [Usage](#usage) - [Rust](#rust) - - [Installation](#installation-2) + - [Installation](#installation-3) - [Usage](#usage-1) - [CLI](#cli) - [Options](#options) @@ -130,6 +135,64 @@ result = df.with_columns( The `number` expression returns a struct with fields `chain`, `scheme`, `confidence`, and `numbering` (a struct of position→residue). The `segment` expression returns a struct with fields `fr1`, `cdr1`, `fr2`, `cdr2`, `fr3`, `cdr3`, `fr4`, `prefix`, `postfix`. +## R + +### Installation + +```r +# install.packages("remotes") +remotes::install_github("ENPICOM/immunum", subdir = "r-immunum", build = FALSE) +``` + +
+Building from source + +Building from source requires a [Rust toolchain](https://rustup.rs). On Windows you also need [Rtools](https://cran.r-project.org/bin/windows/Rtools/). + +Check your setup with: + +```r +# install.packages("rextendr") +rextendr::rust_sitrep() +``` + +Install Rust if needed: + +```bash +# macOS / Linux +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Windows: download and run rustup-init.exe from https://rustup.rs +``` + +
+ +### Numbering + +```r +library(immunum) + +ann <- Annotator$new(chains = c("H", "K", "L"), scheme = "imgt") + +sequence <- "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS" + +result <- ann$number(sequence) +result$chain # "H" +result$confidence # 0.78 +result$numbering # named character vector: c("1"="Q", "2"="V", ...) +result$error # NULL on success, error message on failure +``` + +### Segmentation + +```r +result <- ann$segment(sequence) +result$fr1 # "QVQLVQSGAEVKRPGSSVTVSCKAS" +result$cdr1 # "GGSFSTYA" +result$cdr3 # "AREGTTGKPIGAFAH" +result$fr4 # "WGQGTLVTVSS" +``` + ## JavaScript / npm ### Installation @@ -167,7 +230,7 @@ Add to `Cargo.toml`: ```toml [dependencies] -immunum = "0.9" +immunum = "1.1" ``` ### Usage @@ -286,6 +349,7 @@ task build-local PROFILE=release task test-rust # test only rust code task test-python # test only python code task test # test all code +task r:test # test R package ``` ### Linting @@ -309,6 +373,12 @@ $ task | grep benchmark * benchmark-speed-polars: Speed benchmark for immunum polars across all chain/scheme fixtures ``` +R vs Python scaling benchmark: + +```bash +task r:bench # R vs Python polars scaling benchmark with chart +``` + ## Project structure ``` @@ -341,6 +411,10 @@ immunum/ ├── _internal.pyi # python stub file for pyo3 ├── polars.py # polars extension module └── python.py # python module +r-immunum/ # R package (extendr bindings) +├── R/ # R source files (Annotator, polars wrappers, normalization) +├── src/extendr/ # Rust shim crate (path dep to parent immunum crate) +└── tests/testthat/ # testthat tests (annotator, polars, validation, cross-language) ``` ### Design decisions diff --git a/Taskfile.yml b/Taskfile.yml index 0c433bd..c580f7b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -250,6 +250,28 @@ tasks: desc: "Produce plots from benchmark results" cmd: uv run --script scripts/plots.py + r:build: + desc: "Install the R package locally" + dir: r-immunum + cmd: R CMD INSTALL . + + r:test: + desc: "Run R package tests" + dir: r-immunum + cmd: Rscript -e 'testthat::test_local()' + + r:check: + desc: "Run R CMD check on the R package" + cmds: + - R CMD build r-immunum + - R CMD check immunum_*.tar.gz --no-manual + - rm -f immunum_*.tar.gz + + r:bench: + desc: "Run R benchmarks (single-seq + batch throughput)" + dir: r-immunum + cmd: Rscript bench/bench-annotate.R + default: cmds: - task --list-all diff --git a/benches/bench_scaling.R b/benches/bench_scaling.R new file mode 100644 index 0000000..544ce06 --- /dev/null +++ b/benches/bench_scaling.R @@ -0,0 +1,84 @@ +# Scaling benchmark: R-immunum vs Python-immunum across batch sizes. +# Produces a comparison chart at docs/assets/benchmark_r_vs_python.svg + +library(immunum) +library(polars) +library(ggplot2) + +FIXTURES <- normalizePath(file.path("..", "fixtures", "validation"), mustWork = TRUE) +fixture <- file.path(FIXTURES, "ab_H_imgt.csv") +SIZES <- c(100L, 500L, 1000L, 5000L, 10000L, 50000L) +ROUNDS <- 3L +SEED <- 42L + +df_full <- pl$read_csv(fixture, infer_schema_length = 0L) + +rows <- list() + +for (size in SIZES) { + cat(sprintf("\n=== size = %d ===\n", size)) + df <- df_full$sample(n = size, with_replacement = TRUE, seed = SEED) + + # R polars batch + times_r <- numeric(ROUNDS) + for (r in seq_len(ROUNDS)) { + t0 <- proc.time()["elapsed"] + df$select( + polars_number(pl$col("sequence"), + chains = "IGH", scheme = "IMGT", + min_confidence = 0.0)$alias("numbered") + ) + times_r[r] <- proc.time()["elapsed"] - t0 + } + med_r <- median(times_r) + cat(sprintf(" R polars: %.3fs\n", med_r)) + rows <- c(rows, list(data.frame(size = size, tool = "R (polars batch)", time_s = med_r))) + + # Python polars batch + reticulate::py_run_string(sprintf(" +import polars +import immunum.polars as imp +import time + +df = polars.read_csv('%s', infer_schema=False).sample(n=%d, with_replacement=True, seed=%d) +times = [] +for _ in range(%d): + t0 = time.perf_counter() + df.select(imp.number(polars.col('sequence'), chains=['IGH'], scheme='IMGT', min_confidence=0.0).alias('numbered')) + times.append(time.perf_counter() - t0) + +median_s = sorted(times)[len(times) // 2] +", gsub("\\\\", "/", fixture), size, SEED, ROUNDS)) + med_py <- reticulate::py$median_s + cat(sprintf(" Python polars: %.3fs\n", med_py)) + rows <- c(rows, list(data.frame(size = size, tool = "Python (polars batch)", time_s = med_py))) +} + +results <- do.call(rbind, rows) +cat("\n=== Results ===\n") +print(results) + +# Write CSV +csv_path <- file.path("..", "resources", "benchmark_results", "results_r_vs_python.csv") +write.csv(results, csv_path, row.names = FALSE) +cat(sprintf("\nCSV saved to %s\n", csv_path)) + +# Produce chart +p <- ggplot(results, aes(x = size, y = time_s, color = tool)) + + geom_line(linewidth = 1) + + geom_point(size = 3) + + scale_x_log10(labels = scales::comma) + + scale_y_log10() + + labs( + title = "immunum: R vs Python polars batch numbering", + subtitle = "IGH / IMGT, median of 3 rounds", + x = "Batch size", + y = "Time (seconds, log scale)", + color = NULL + ) + + theme_minimal(base_size = 14) + + theme(legend.position = "top") + +svg_path <- file.path("..", "docs", "assets", "benchmark_r_vs_python.svg") +ggsave(svg_path, p, width = 8, height = 5) +cat(sprintf("Chart saved to %s\n", svg_path)) diff --git a/docs/assets/benchmark_r_vs_python.svg b/docs/assets/benchmark_r_vs_python.svg new file mode 100644 index 0000000..9dc624b --- /dev/null +++ b/docs/assets/benchmark_r_vs_python.svg @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Multi-threaded + + + + + + + + + +Single-threaded + + + 100 + 500 + 1,000 + 5,000 + 10,000 + 50,000 + 100,000 + 500,000 +1,000,000 + 100 + 500 + 1,000 + 5,000 + 10,000 + 50,000 + 100,000 +0.01 +0.10 +1 +10 +100 +0.01 +0.10 +1 +10 +100 +Batch size +Time (s) +Method + + + + +immunum (Python) +immunum (R) +R immunum vs Python immunum, 3 rounds, median +Scaling: time vs batch size (H-chain, IMGT) + + + diff --git a/r-immunum/.Rbuildignore b/r-immunum/.Rbuildignore new file mode 100644 index 0000000..2a36e75 --- /dev/null +++ b/r-immunum/.Rbuildignore @@ -0,0 +1,21 @@ +^.*\.Rproj$ +^\.Rproj\.user$ +^LICENSE\.md$ +^README\.Rmd$ +^_pkgdown\.yml$ +^docs$ +^pkgdown$ +^\.github$ +^tools$ +^src/extendr/target$ +^src/extendr/Cargo\.lock$ +# NOTE: src/extendr/cargo-overrides.toml MUST ship — it neutralizes the +# upstream repo's PyO3-only macOS rustflags (-undefined dynamic_lookup) so +# the R extension links correctly on darwin. Do not add it to the ignore +# list. The file lives at src/extendr/cargo-overrides.toml (non-hidden) and +# is loaded by Makevars/Makevars.win via cargo's `--config ` flag. +^bench$ +^cran-comments\.md$ +^CRAN-RELEASE$ +^revdep$ +^EXTENDR_ERROR_HANDLING\.md$ diff --git a/r-immunum/.gitignore b/r-immunum/.gitignore new file mode 100644 index 0000000..1745700 --- /dev/null +++ b/r-immunum/.gitignore @@ -0,0 +1,29 @@ +# R artifacts +.Rproj.user +.Rhistory +.RData +.Ruserdata +*.Rproj +.Rcheck/ +*.tar.gz + +# R package build artifacts +src/*.o +src/*.so +src/*.dll +src/symbols.rds +src/entrypoint.o + +# Cargo artifacts +src/extendr/target/ +src/.cargo/ + +# Internal dev notes +EXTENDR_ERROR_HANDLING.md + +# pkgdown +docs/ + +# OS +.DS_Store +Thumbs.db diff --git a/r-immunum/DESCRIPTION b/r-immunum/DESCRIPTION new file mode 100644 index 0000000..46acf01 --- /dev/null +++ b/r-immunum/DESCRIPTION @@ -0,0 +1,34 @@ +Package: immunum +Title: Fast Antibody and T-Cell Receptor Sequence Numbering +Version: 1.1.0 +Authors@R: c( + person("Eli", "Eydlin", role = "aut", email = "ilyabeydlin@gmail.com"), + person("Egor", "Marin", role = c("aut", "cre"), email = "e.marin@enpicom.com"), + person("ENPICOM", role = "cph", email = "dev@enpicom.com") + ) +Description: R bindings for the 'immunum' Rust library: high-performance + numbering of antibody and T-cell receptor variable domain sequences using + IMGT and Kabat schemes. Uses Needleman-Wunsch semi-global alignment against + position-specific scoring matrices built from consensus sequences with + BLOSUM62 substitution scores. Supports automatic chain detection (IGH, + IGK, IGL, TRA, TRB, TRG, TRD) and batch processing of large sequence sets + in parallel via Rayon. +License: MIT + file LICENSE +URL: https://github.com/ENPICOM/immunum, https://immunum.enpicom.com +BugReports: https://github.com/ENPICOM/immunum/issues +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.3 +Depends: + R (>= 4.1.0) +Imports: + R6 +Suggests: + knitr, + rmarkdown, + reticulate, + testthat (>= 3.0.0) +VignetteBuilder: knitr +Config/testthat/edition: 3 +Config/rextendr/version: 0.4.0 +SystemRequirements: Cargo (Rust's package manager), rustc diff --git a/r-immunum/LICENSE b/r-immunum/LICENSE new file mode 100644 index 0000000..d5fbfb5 --- /dev/null +++ b/r-immunum/LICENSE @@ -0,0 +1,2 @@ +YEAR: 2026 +COPYRIGHT HOLDER: ENPICOM diff --git a/r-immunum/LICENSE.md b/r-immunum/LICENSE.md new file mode 100644 index 0000000..b0ec817 --- /dev/null +++ b/r-immunum/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2026 ENPICOM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/r-immunum/NAMESPACE b/r-immunum/NAMESPACE new file mode 100644 index 0000000..c839dc4 --- /dev/null +++ b/r-immunum/NAMESPACE @@ -0,0 +1,8 @@ +# Generated by roxygen2: do not edit by hand + +export(Annotator) +export(benchmark_threshold) +export(immunum_version) +export(validation_fixtures) +importFrom(R6,R6Class) +useDynLib(immunum, .registration = TRUE) diff --git a/r-immunum/NEWS.md b/r-immunum/NEWS.md new file mode 100644 index 0000000..c1457a9 --- /dev/null +++ b/r-immunum/NEWS.md @@ -0,0 +1,13 @@ +# immunum 1.1.0 + +* R bindings for the `immunum` Rust crate via `extendr`. Depends on the + published `immunum` 1.1.0 release on crates.io — the package is + self-contained and does not require the parent repository at install + time. +* `immunum_version()` returns the version of the linked Rust core. +* `Annotator` R6 class with `$new()` / `$number()` / `$segment()` methods, + mirroring the Python wrapper one-to-one. +* Validation support: `validation_fixtures()` and `benchmark_threshold()` + provide the manifest and accuracy thresholds from `BENCHMARKS.toml`. + Validation fixtures live in the repo tree at `fixtures/validation/` + (same as the Python tests) and are not shipped inside the package. diff --git a/r-immunum/R/extendr-wrappers.R b/r-immunum/R/extendr-wrappers.R new file mode 100644 index 0000000..5241f02 --- /dev/null +++ b/r-immunum/R/extendr-wrappers.R @@ -0,0 +1,49 @@ +# Generated by extendr: do not edit by hand +# +# Hand-written to match symbols from src/extendr/src/lib.rs. +# Other wrap__* symbols are called directly via .Call() from R/immunum.R. + +#' Return the version of the underlying `immunum` Rust crate +#' +#' This is the version of the Rust core, not the R package wrapper. +#' Returned as a `character(1)` to mirror Python's +#' `immunum._internal.__version__`. +#' +#' @return A length-1 character vector with the crate version. +#' @export +#' @examples +#' immunum_version() +immunum_version <- function() .Call(wrap__immunum_version) + +# ── Internal batch wrappers (not exported) ──────────────────────────────────── +# Accessible via immunum:::numbering_batch() etc. +# Chains and scheme are normalised before the Rust call so these functions +# accept the same aliases as Annotator$new(). + +numbering_batch <- function(seqs, chains, scheme, min_confidence = NULL) { + chains <- normalize_chains(chains) + scheme <- normalize_scheme(scheme) + .Call(wrap__numbering_batch, seqs, chains, scheme, min_confidence) +} + +segmentation_batch <- function(seqs, chains, scheme, min_confidence = NULL) { + chains <- normalize_chains(chains) + scheme <- normalize_scheme(scheme) + .Call(wrap__segmentation_batch, seqs, chains, scheme, min_confidence) +} + +numbering_batch_with <- function(seqs, annotator) { + if (!inherits(annotator, "Annotator")) { + stop("`annotator` must be an Annotator R6 instance", call. = FALSE) + } + .Call(wrap__numbering_batch_with, seqs, + annotator$.__enclos_env__$private$.inner) +} + +segmentation_batch_with <- function(seqs, annotator) { + if (!inherits(annotator, "Annotator")) { + stop("`annotator` must be an Annotator R6 instance", call. = FALSE) + } + .Call(wrap__segmentation_batch_with, seqs, + annotator$.__enclos_env__$private$.inner) +} diff --git a/r-immunum/R/immunum.R b/r-immunum/R/immunum.R new file mode 100644 index 0000000..7445a0e --- /dev/null +++ b/r-immunum/R/immunum.R @@ -0,0 +1,198 @@ +# immunum -- mirrors immunum/__init__.py. +# +# Contains chain/scheme normalization and the Annotator R6 class. + +#' immunum: Fast Antibody and T-Cell Receptor Sequence Numbering +#' +#' R bindings for the `immunum` Rust crate. High-performance numbering +#' of antibody and T-cell receptor variable-domain sequences using IMGT +#' and Kabat schemes. +#' +#' @section Main entry points: +#' - [Annotator]: R6 class for numbering and segmenting sequences. +#' - [immunum_version()]: Version of the linked Rust core. +#' +#' @keywords internal +"_PACKAGE" + +## usethis namespace: start +#' @useDynLib immunum, .registration = TRUE +## usethis namespace: end +NULL + +# ── Chain / scheme alias normalization ────────────────────────────────────── + +.CHAIN_ALIASES <- c( + igh = "IGH", h = "IGH", heavy = "IGH", + igk = "IGK", k = "IGK", kappa = "IGK", + igl = "IGL", l = "IGL", lambda = "IGL", + tra = "TRA", a = "TRA", alpha = "TRA", + trb = "TRB", b = "TRB", beta = "TRB", + trg = "TRG", g = "TRG", gamma = "TRG", + trd = "TRD", d = "TRD", delta = "TRD" +) + +.SCHEME_ALIASES <- c( + imgt = "IMGT", + i = "IMGT", + kabat = "Kabat", + k = "Kabat" +) + +normalize_chains <- function(chains) { + if (!is.character(chains)) { + stop("`chains` must be a character vector", call. = FALSE) + } + if (length(chains) == 0L) { + stop("`chains` cannot be empty", call. = FALSE) + } + if (anyNA(chains)) { + stop("`chains` cannot contain NA", call. = FALSE) + } + + out <- .CHAIN_ALIASES[tolower(chains)] + bad <- is.na(out) + if (any(bad)) { + valid <- sort(unique(unname(.CHAIN_ALIASES))) + stop( + sprintf( + "Unknown chain(s): %s. Valid chains: %s", + paste(shQuote(chains[bad]), collapse = ", "), + paste(valid, collapse = ", ") + ), + call. = FALSE + ) + } + unname(out) +} + +normalize_scheme <- function(scheme) { + if (!is.character(scheme) || length(scheme) != 1L || is.na(scheme)) { + stop("`scheme` must be a single non-NA character string", call. = FALSE) + } + out <- .SCHEME_ALIASES[tolower(scheme)] + if (is.na(out)) { + valid <- sort(unique(unname(.SCHEME_ALIASES))) + stop( + sprintf( + "Unknown scheme: %s. Valid schemes: %s", + shQuote(scheme), + paste(valid, collapse = ", ") + ), + call. = FALSE + ) + } + unname(out) +} + +check_sequence <- function(sequence) { + if (!is.character(sequence) || length(sequence) != 1L || is.na(sequence)) { + stop("`sequence` must be a single non-NA character string", call. = FALSE) + } + invisible(TRUE) +} + +# ── Annotator R6 class ────────────────────────────────────────────────────── + +#' @rawNamespace export(Annotator) +#' @importFrom R6 R6Class +Annotator <- R6::R6Class( + "Annotator", + cloneable = FALSE, + public = list( + initialize = function(chains, scheme, min_confidence = NULL) { + canon_chains <- normalize_chains(chains) + canon_scheme <- normalize_scheme(scheme) + + tcr <- c("TRA", "TRB", "TRG", "TRD") + if (canon_scheme == "Kabat" && any(canon_chains %in% tcr)) { + stop( + "Kabat scheme only supported for antibody chains (IGH, IGK, IGL)", + call. = FALSE + ) + } + + if (!is.null(min_confidence)) { + if (!is.numeric(min_confidence) || length(min_confidence) != 1L || + is.na(min_confidence) || min_confidence < 0 || min_confidence > 1) { + stop( + sprintf( + "`min_confidence` must be a single numeric in [0, 1], got %s", + format(min_confidence) + ), + call. = FALSE + ) + } + min_confidence <- as.numeric(min_confidence) + } + + private$.inner <- .Call( + wrap__Annotator__new, + canon_chains, + canon_scheme, + min_confidence + ) + private$.chains <- canon_chains + private$.scheme <- canon_scheme + invisible(self) + }, + + number = function(sequence) { + check_sequence(sequence) + raw <- .Call(wrap__Annotator__number, private$.inner, sequence) + if (!is.null(raw$error)) { + return(list( + chain = NULL, + scheme = NULL, + confidence = NULL, + numbering = NULL, + query_start = NULL, + query_end = NULL, + error = raw$error + )) + } + list( + chain = raw$chain, + scheme = raw$scheme, + confidence = raw$confidence, + numbering = stats::setNames(raw$residues, raw$positions), + query_start = raw$query_start, + query_end = raw$query_end, + error = NULL + ) + }, + + segment = function(sequence) { + check_sequence(sequence) + raw <- .Call(wrap__Annotator__segment, private$.inner, sequence) + if (!is.null(raw$error)) { + return(list( + fr1 = NULL, + cdr1 = NULL, + fr2 = NULL, + cdr2 = NULL, + fr3 = NULL, + cdr3 = NULL, + fr4 = NULL, + prefix = NULL, + postfix = NULL, + error = raw$error + )) + } + raw$error <- NULL + raw + }, + + print = function(...) { + cat("\n") + cat(" chains: ", paste(private$.chains, collapse = ", "), "\n", sep = "") + cat(" scheme: ", private$.scheme, "\n", sep = "") + invisible(self) + } + ), + private = list( + .inner = NULL, + .chains = NULL, + .scheme = NULL + ) +) diff --git a/r-immunum/R/validation.R b/r-immunum/R/validation.R new file mode 100644 index 0000000..b85fc5b --- /dev/null +++ b/r-immunum/R/validation.R @@ -0,0 +1,94 @@ +# Validation fixture manifest and benchmark thresholds. +# +# Fixtures live at fixtures/validation/ in the repo root (not shipped +# in the installed package). Thresholds are baked in from BENCHMARKS.toml. + +.VALIDATION_FIXTURES <- data.frame( + stem = c( + "ab_H_imgt", "ab_K_imgt", "ab_L_imgt", + "ab_H_kabat", "ab_K_kabat", "ab_L_kabat", + "tcr_A_imgt", "tcr_B_imgt", "tcr_G_imgt", "tcr_D_imgt" + ), + scheme = c( + "IMGT", "IMGT", "IMGT", + "Kabat", "Kabat", "Kabat", + "IMGT", "IMGT", "IMGT", "IMGT" + ), + benchmark = c( + "imgt.H", "imgt.K", "imgt.L", + "kabat.H", "kabat.K", "kabat.L", + "imgt.A", "imgt.B", "imgt.G", "imgt.D" + ), + stringsAsFactors = FALSE +) +.VALIDATION_FIXTURES$chains <- list( + "IGH", "IGK", "IGL", + "IGH", "IGK", "IGL", + "TRA", "TRB", "TRG", "TRD" +) + +# Perfect_pct values from BENCHMARKS.toml. Update via tools/sync-benchmarks.R. +.BENCHMARK_THRESHOLDS <- c( + `imgt.A` = 88.21, + `imgt.B` = 97.54, + `imgt.D` = 100, + `imgt.G` = 96, + `imgt.H` = 99.88, + `imgt.K` = 99.73, + `imgt.L` = 99.46, + `kabat.H` = 99.88, + `kabat.K` = 99.66, + `kabat.L` = 99.46 +) + +#' List the available validation fixtures +#' +#' Returns the manifest of upstream validation fixtures known to this +#' release of `immunum`. The fixtures live at `fixtures/validation/` in +#' the source repository and are available when running tests from the +#' repo tree (they are not shipped inside the installed package). +#' +#' @return A `data.frame` with columns: +#' - `stem` -- fixture identifier without extension (e.g. `"ab_H_imgt"`) +#' - `chains` -- list-column of canonical chain codes (IGH, TRA, ...) +#' - `scheme` -- `"IMGT"` or `"Kabat"` +#' - `benchmark` -- key into the BENCHMARKS.toml table (e.g. `"imgt.H"`) +#' @examples +#' validation_fixtures() +#' @export +validation_fixtures <- function() { + .VALIDATION_FIXTURES +} + +#' Look up the benchmark perfect-percentage threshold +#' +#' Returns the `perfect_pct` from the repo-root `BENCHMARKS.toml` for +#' the requested benchmark key (the `benchmark` column of +#' [validation_fixtures()]). The values are baked into the package as +#' a static R constant; see `R/validation.R` for the source-of-truth +#' comment. +#' +#' @param benchmark_key Dotted key, e.g. `"imgt.H"` or `"kabat.K"`. +#' @return A single numeric in `[0, 100]`. +#' @examples +#' benchmark_threshold("imgt.H") +#' @export +benchmark_threshold <- function(benchmark_key) { + if (!is.character(benchmark_key) || length(benchmark_key) != 1L || + is.na(benchmark_key)) { + stop("`benchmark_key` must be a single non-NA character string", + call. = FALSE) + } + out <- .BENCHMARK_THRESHOLDS[benchmark_key] + if (is.na(out)) { + stop( + sprintf( + "Unknown benchmark key: %s. Known: %s", + shQuote(benchmark_key), + paste(names(.BENCHMARK_THRESHOLDS), collapse = ", ") + ), + call. = FALSE + ) + } + unname(out) +} diff --git a/r-immunum/R/zzz.R b/r-immunum/R/zzz.R new file mode 100644 index 0000000..402a5a2 --- /dev/null +++ b/r-immunum/R/zzz.R @@ -0,0 +1,10 @@ +.onLoad <- function(libname, pkgname) { + invisible(NULL) +} + +.onAttach <- function(libname, pkgname) { + ver <- tryCatch(immunum_version(), error = function(e) NA_character_) + if (!is.na(ver)) { + packageStartupMessage(sprintf("immunum: linked against Rust crate %s", ver)) + } +} diff --git a/r-immunum/README.md b/r-immunum/README.md new file mode 100644 index 0000000..343a2b3 --- /dev/null +++ b/r-immunum/README.md @@ -0,0 +1,48 @@ +# immunum (R) + +R bindings for the [`immunum`](https://github.com/ENPICOM/immunum) Rust crate: +fast numbering of antibody and T-cell receptor variable-domain sequences using +IMGT and Kabat schemes. + +## Installation + +```r +# install.packages("remotes") +remotes::install_github("ENPICOM/immunum", subdir = "r-immunum", build = FALSE) +``` + +## Build from source + +You need a working R installation, the [Rust toolchain](https://rustup.rs/), +and a C compiler. From the repository root: + +```bash +R CMD INSTALL r-immunum +``` + +Or via the project Taskfile: + +```bash +task r:build +``` + +## Quick start + +```r +library(immunum) + +ann <- Annotator$new(chains = c("H", "K", "L"), scheme = "imgt") + +result <- ann$number( + "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS" +) +result$chain # "H" +result$confidence # 0.78 +result$numbering # named character vector "1"="Q", "2"="V", ... +``` + +See `vignette("getting-started", package = "immunum")` for the full walkthrough. + +## License + +MIT, copyright 2026 ENPICOM. See `LICENSE`. diff --git a/r-immunum/bench/bench-annotate.R b/r-immunum/bench/bench-annotate.R new file mode 100644 index 0000000..f529c93 --- /dev/null +++ b/r-immunum/bench/bench-annotate.R @@ -0,0 +1,119 @@ +# Benchmark immunum R package throughput. +# Usage: +# Rscript bench/bench-annotate.R +# RAYON_NUM_THREADS=1 Rscript bench/bench-annotate.R # single-thread baseline +# No dependencies beyond the installed immunum package. + +library(immunum) + +# Time a closure over `reps` iterations, return median of `rounds` such blocks. +.bench_s <- function(f, reps = 10L, rounds = 5L, warmup = 3L) { + for (i in seq_len(warmup)) f() + times <- numeric(rounds) + for (r in seq_len(rounds)) { + times[[r]] <- system.time(for (i in seq_len(reps)) f())[["elapsed"]] + } + median(times) / reps +} + +# ── Setup ────────────────────────────────────────────────────────────────────── + +nthreads <- Sys.getenv("RAYON_NUM_THREADS", unset = "default") +cat(sprintf("immunum %s | R %s | RAYON_NUM_THREADS=%s | platform=%s\n", + immunum_version(), getRversion(), nthreads, .Platform$OS.type)) + +SEQS <- list( + IGH = "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS", + IGL = "SALTQPPAVSGTPGQRVTISCSGSDIGRRSVNWYQQFPGTAPKLLIYSNDQRPSVVPDRFSGSKSGTSASLAISGLQSEDEAEYYCAAWDDSLAVFGGGTQLTVGQPKA", + TRB = "GVTQTPKFQVLKTGQSMTLQCAQDMNHEYMSWYRQDPGMGLRLIHYSVGAGITDQGEVPNGYNVSRSTTEDFPLRLLSAAPSQTSVYFCASRPGLAGGRPEQYFGPGTRLTVTE" +) +CHAIN_SETS <- list( + IG = c("IGH", "IGK", "IGL"), + TCR = c("TRA", "TRB", "TRG", "TRD"), + ALL = c("IGH", "IGK", "IGL", "TRA", "TRB", "TRG", "TRD") +) +make_batch <- function(seq, n) rep(seq, length.out = n) +reps_for <- function(n) max(2L, min(30L, as.integer(6000L / n))) + +# ── Single sequence ──────────────────────────────────────────────────────────── + +cat("\n── Single sequence (Annotator$number) ──────────────────────────────────\n") +ann_all <- Annotator$new(chains = CHAIN_SETS$ALL, scheme = "IMGT") +for (nm in names(SEQS)) { + seq <- SEQS[[nm]] + t <- .bench_s(function() ann_all$number(seq), reps = 500L, rounds = 5L) + cat(sprintf(" %-8s %.3f ms/seq\n", nm, t * 1000)) +} + +# ── Batch numbering ──────────────────────────────────────────────────────────── + +cat("\n── Batch numbering (numbering_batch) ──────────────────────────────────\n") +cat(sprintf(" %-8s %6s %10s %12s\n", "chain_set", "n", "median_s", "seqs/s")) +for (cs in names(CHAIN_SETS)) { + chains <- CHAIN_SETS[[cs]] + for (n in c(100L, 1000L, 10000L)) { + seqs <- make_batch(SEQS$IGH, n) + t <- .bench_s(function() immunum:::numbering_batch(seqs, chains, "IMGT"), + reps = reps_for(n), rounds = 3L) + cat(sprintf(" %-8s %6d %10.4f %12.0f\n", cs, n, t, n / t)) + } +} + +# ── Batch segmentation ───────────────────────────────────────────────────────── + +cat("\n── Batch segmentation (segmentation_batch) ─────────────────────────────\n") +cat(sprintf(" %6s %10s %12s\n", "n", "median_s", "seqs/s")) +for (n in c(100L, 1000L, 10000L)) { + seqs <- make_batch(SEQS$IGH, n) + t <- .bench_s(function() immunum:::segmentation_batch(seqs, CHAIN_SETS$IG, "IMGT"), + reps = reps_for(n), rounds = 3L) + cat(sprintf(" %6d %10.4f %12.0f\n", n, t, n / t)) +} + +# ── Bottleneck breakdown ─────────────────────────────────────────────────────── +# +# Three potential costs inside numbering_batch: +# A) R-side: normalize_chains / normalize_scheme +# B) Rust: build InnerAnnotator (parse chains, load scoring matrices) +# C) Rust: parallel alignment (the actual work) +# +# numbering_batch = A + B + C +# numbering_batch_with = A(zero) + B(zero) + C (annotator pre-built) +# So: B = numbering_batch - numbering_batch_with at the same n + +cat("\n── Bottleneck breakdown ─────────────────────────────────────────────────\n") + +# A: pure R normalization (no Rust call at all) +t_r <- .bench_s(function() { + immunum:::normalize_chains(CHAIN_SETS$IG) + immunum:::normalize_scheme("IMGT") +}, reps = 10000L, rounds = 3L) +cat(sprintf(" A R normalization only: %.4f ms\n", t_r * 1000)) + +# B+C vs C: annotator construction cost +ann_ig <- Annotator$new(chains = CHAIN_SETS$IG, scheme = "IMGT") +seqs_1k <- make_batch(SEQS$IGH, 1000L) +t_fresh <- .bench_s(function() immunum:::numbering_batch(seqs_1k, CHAIN_SETS$IG, "IMGT"), + reps = 10L, rounds = 5L) +t_with <- .bench_s(function() immunum:::numbering_batch_with(seqs_1k, ann_ig), + reps = 10L, rounds = 5L) +cat(sprintf(" B Annotator construction (1k): %.4f ms (= fresh - with)\n", + (t_fresh - t_with) * 1000)) +cat(sprintf(" C Alignment work only (1k): %.4f ms (numbering_batch_with)\n", + t_with * 1000)) +cat(sprintf(" Per-sequence alignment time: %.4f µs\n", t_with / 1000 * 1e6)) + +# Throughput curve — shows where rayon parallelism saturates +cat("\n── Throughput curve (IG chains, IMGT, numbering_batch_with) ────────────\n") +cat(sprintf(" %8s %10s %12s\n", "n", "µs/seq", "seqs/s")) +ann_ig <- Annotator$new(chains = CHAIN_SETS$IG, scheme = "IMGT") +for (n in c(1L, 5L, 20L, 100L, 500L, 2000L, 10000L)) { + seqs <- make_batch(SEQS$IGH, n) + reps <- max(5L, min(200L, as.integer(10000L / n))) + t <- .bench_s(function() immunum:::numbering_batch_with(seqs, ann_ig), + reps = reps, rounds = 3L) + cat(sprintf(" %8d %10.2f %12.0f\n", n, t / n * 1e6, n / t)) +} +cat(" (plateau = alignment-bound; steep = thread/overhead-bound)\n") + +cat("\nDone.\n") diff --git a/r-immunum/man/Annotator.Rd b/r-immunum/man/Annotator.Rd new file mode 100644 index 0000000..c589c61 --- /dev/null +++ b/r-immunum/man/Annotator.Rd @@ -0,0 +1,139 @@ +% Hand-written: do NOT add the "Generated by roxygen2" header above — +% roxygen2 will overwrite this file on the next `roxygenise()` if it sees +% that header. See R/annotator.R for context. +\name{Annotator} +\alias{Annotator} +\title{Annotator: number antibody/TCR sequences with IMGT or Kabat positions} +\description{ +R6 class wrapping the upstream Rust \code{immunum::Annotator} via extendr. +Holds an external pointer to a Rust-side annotator and exposes +\verb{$number()} and \verb{$segment()} methods that mirror the Python +wrapper one-to-one (see \verb{immunum/__init__.py} upstream). +} +\details{ +Chain identifiers accept the same alias set as the Python constructor: +short codes (\code{"H"}, \code{"K"}), lowercase (\code{"igh"}), canonical +(\code{"IGH"}), or named (\code{"heavy"}, \code{"kappa"}). The chain set +bounds which receptor types the alignment considers; supply all seven to +enable full auto-detection. Scheme accepts \code{"IMGT"}/\code{"i"} or +\code{"Kabat"}/\code{"k"}, case-insensitive. Kabat is only supported for +antibody chains. + +\code{min_confidence} is the alignment-confidence cutoff in 0 to 1 +inclusive. Sequences scoring below it raise an error from \verb{$number()} +and \verb{$segment()}. Defaults to the Rust side's default (currently +0.5). Pass \code{0} to disable filtering. +} +\section{Methods}{ + +\subsection{Public methods}{ +\itemize{ +\item \href{#method-Annotator-new}{\code{Annotator$new()}} +\item \href{#method-Annotator-number}{\code{Annotator$number()}} +\item \href{#method-Annotator-segment}{\code{Annotator$segment()}} +\item \href{#method-Annotator-print}{\code{Annotator$print()}} +} +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\subsection{Method \code{new()}}{ +Create a new Annotator. See the class-level details for argument semantics. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{Annotator$new(chains, scheme, min_confidence = NULL)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{chains}}{Character vector of chain identifiers. Accepts the +same alias set as the Python wrapper: short codes (\code{"H"}, +\code{"K"}), lowercase (\code{"igh"}), canonical (\code{"IGH"}), or named +(\code{"heavy"}).} + +\item{\code{scheme}}{\code{"IMGT"} or \code{"Kabat"}, case-insensitive. +Aliases \code{"i"} and \code{"k"} are also accepted.} + +\item{\code{min_confidence}}{Optional numeric in 0 to 1 inclusive. +\code{NULL} means use the Rust default (currently 0.5).} +} +\if{html}{\out{
}} +} +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\subsection{Method \code{number()}}{ +Number a single amino-acid sequence. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{Annotator$number(sequence)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{sequence}}{A single character string of amino-acid codes.} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +A list with \code{chain}, \code{scheme}, \code{confidence}, and +\code{numbering} (named character vector). +} +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\subsection{Method \code{segment()}}{ +Split a single amino-acid sequence into FR/CDR regions. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{Annotator$segment(sequence)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{sequence}}{A single character string of amino-acid codes.} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +A list with \code{prefix}, \code{fr1}, \code{cdr1}, \code{fr2}, +\code{cdr2}, \code{fr3}, \code{cdr3}, \code{fr4}, \code{postfix}. +} +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\subsection{Method \code{print()}}{ +Pretty-print the annotator configuration. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{Annotator$print(...)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{...}}{Ignored; present so the method matches the +\code{print()} S3 generic signature.} +} +\if{html}{\out{
}} +} +} +} +\examples{ +ann <- Annotator$new(c("H", "K", "L"), "imgt") +seq <- paste0( + "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQ", + "GRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS" +) +result <- ann$number(seq) +result$chain # "H" +result$scheme # "IMGT" +result$confidence # numeric in [0, 1] +head(result$numbering) + +seg <- ann$segment(seq) +seg$fr1 +seg$cdr3 +} diff --git a/r-immunum/man/benchmark_threshold.Rd b/r-immunum/man/benchmark_threshold.Rd new file mode 100644 index 0000000..238f9dd --- /dev/null +++ b/r-immunum/man/benchmark_threshold.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/validation.R +\name{benchmark_threshold} +\alias{benchmark_threshold} +\title{Look up the benchmark perfect-percentage threshold} +\usage{ +benchmark_threshold(benchmark_key) +} +\arguments{ +\item{benchmark_key}{Dotted key, e.g. \code{"imgt.H"} or \code{"kabat.K"}.} +} +\value{ +A single numeric in \verb{[0, 100]}. +} +\description{ +Returns the \code{perfect_pct} from the repo-root \code{BENCHMARKS.toml} for +the requested benchmark key (the \code{benchmark} column of +\code{\link[=validation_fixtures]{validation_fixtures()}}). The values are baked into the package as +a static R constant; see \code{R/validation.R} for the source-of-truth +comment. +} +\examples{ +benchmark_threshold("imgt.H") +} diff --git a/r-immunum/man/figures/logo.svg b/r-immunum/man/figures/logo.svg new file mode 100644 index 0000000..24db674 --- /dev/null +++ b/r-immunum/man/figures/logo.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + immunum + diff --git a/r-immunum/man/immunum-package.Rd b/r-immunum/man/immunum-package.Rd new file mode 100644 index 0000000..0e90dd5 --- /dev/null +++ b/r-immunum/man/immunum-package.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/immunum.R +\docType{package} +\name{immunum-package} +\alias{immunum} +\alias{immunum-package} +\title{immunum: Fast Antibody and T-Cell Receptor Sequence Numbering} +\description{ +R bindings for the \code{immunum} Rust crate. High-performance numbering +of antibody and T-cell receptor variable-domain sequences using IMGT +and Kabat schemes. +} +\section{Main entry points}{ + +\itemize{ +\item \link{Annotator}: R6 class for numbering and segmenting sequences. +\item \code{\link[=immunum_version]{immunum_version()}}: Version of the linked Rust core. +} +} + +\seealso{ +Useful links: +\itemize{ + \item \url{https://github.com/ENPICOM/immunum} + \item \url{https://immunum.enpicom.com} + \item Report bugs at \url{https://github.com/ENPICOM/immunum/issues} +} + +} +\author{ +\strong{Maintainer}: Egor Marin \email{e.marin@enpicom.com} + +Authors: +\itemize{ + \item Eli Eydlin \email{ilyabeydlin@gmail.com} +} + +Other contributors: +\itemize{ + \item ENPICOM \email{dev@enpicom.com} [copyright holder] +} + +} +\keyword{internal} diff --git a/r-immunum/man/immunum_version.Rd b/r-immunum/man/immunum_version.Rd new file mode 100644 index 0000000..4a8bfe5 --- /dev/null +++ b/r-immunum/man/immunum_version.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/extendr-wrappers.R +\name{immunum_version} +\alias{immunum_version} +\title{Return the version of the underlying \code{immunum} Rust crate} +\usage{ +immunum_version() +} +\value{ +A length-1 character vector with the crate version. +} +\description{ +This is the version of the Rust core, not the R package wrapper. +Returned as a \code{character(1)} to mirror Python's +\code{immunum._internal.__version__}. +} +\examples{ +immunum_version() +} diff --git a/r-immunum/man/validation_fixtures.Rd b/r-immunum/man/validation_fixtures.Rd new file mode 100644 index 0000000..74db52c --- /dev/null +++ b/r-immunum/man/validation_fixtures.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/validation.R +\name{validation_fixtures} +\alias{validation_fixtures} +\title{List the available validation fixtures} +\usage{ +validation_fixtures() +} +\value{ +A \code{data.frame} with columns: +\itemize{ +\item \code{stem} -- fixture identifier without extension (e.g. \code{"ab_H_imgt"}) +\item \code{chains} -- list-column of canonical chain codes (IGH, TRA, ...) +\item \code{scheme} -- \code{"IMGT"} or \code{"Kabat"} +\item \code{benchmark} -- key into the BENCHMARKS.toml table (e.g. \code{"imgt.H"}) +} +} +\description{ +Returns the manifest of upstream validation fixtures known to this +release of \code{immunum}. The fixtures live at \verb{fixtures/validation/} in +the source repository and are available when running tests from the +repo tree (they are not shipped inside the installed package). +} +\examples{ +validation_fixtures() +} diff --git a/r-immunum/src/Makevars b/r-immunum/src/Makevars new file mode 100644 index 0000000..64a195f --- /dev/null +++ b/r-immunum/src/Makevars @@ -0,0 +1,33 @@ +TARGET_DIR = ./extendr/target +LIBDIR = $(TARGET_DIR)/release +STATLIB = $(LIBDIR)/libimmunumr.a +PKG_LIBS = -L$(LIBDIR) -limmunumr + +# Override path for cargo's `--config ` flag (stable since 1.63). +# Wins over the walked .cargo/config.toml at the repo root, which injects +# PyO3-only `-undefined dynamic_lookup` rustflags that break R linking. +CARGO_OVERRIDES = $(CURDIR)/extendr/cargo-overrides.toml + +all: C_clean + +$(SHLIB): $(STATLIB) + +CARGOTMP = $(CURDIR)/.cargo + +$(STATLIB): + export PATH="$${HOME}/.cargo/bin:$${PATH}" && \ + export CARGO_HOME=$(CARGOTMP) && \ + cd ./extendr && \ + cargo build --lib --release \ + --config $(CARGO_OVERRIDES) \ + --manifest-path Cargo.toml \ + --target-dir $(CURDIR)/extendr/target && \ + cd .. && \ + rm -Rf $(CARGOTMP) && \ + rm -Rf extendr/target/release/build extendr/target/release/deps/*.rlib + +C_clean: + rm -Rf $(SHLIB) $(OBJECTS) $(CARGOTMP) + +clean: + rm -Rf $(SHLIB) $(OBJECTS) $(CARGOTMP) ./extendr/target diff --git a/r-immunum/src/Makevars.win b/r-immunum/src/Makevars.win new file mode 100644 index 0000000..f13e559 --- /dev/null +++ b/r-immunum/src/Makevars.win @@ -0,0 +1,36 @@ +TARGET = $(subst 64,x86_64,$(subst 32,i686,$(WIN)))-pc-windows-gnu + +TARGET_DIR = ./extendr/target +LIBDIR = $(TARGET_DIR)/$(TARGET)/release +STATLIB = $(LIBDIR)/libimmunumr.a +PKG_LIBS = -L$(LIBDIR) -limmunumr -lws2_32 -ladvapi32 -luserenv -lbcrypt -lntdll + +CARGO_OVERRIDES = $(CURDIR)/extendr/cargo-overrides.toml + +all: C_clean + +$(SHLIB): $(STATLIB) + +CARGOTMP = $(CURDIR)/.cargo + +$(STATLIB): + mkdir -p $(TARGET_DIR)/libgcc_mock && \ + touch $(TARGET_DIR)/libgcc_mock/libgcc_eh.a && \ + export PATH="$${HOME}/.cargo/bin:$${PATH}" && \ + export CARGO_HOME=$(CARGOTMP) && \ + export RUSTUP_TOOLCHAIN=stable-x86_64-pc-windows-gnu && \ + export LIBRARY_PATH="$${LIBRARY_PATH};$(CURDIR)/$(TARGET_DIR)/libgcc_mock" && \ + cd ./extendr && \ + cargo build --target=$(TARGET) --lib --release \ + --config $(CARGO_OVERRIDES) \ + --manifest-path Cargo.toml \ + --target-dir $(CURDIR)/extendr/target && \ + cd .. && \ + rm -Rf $(CARGOTMP) && \ + rm -Rf $(LIBDIR)/build $(LIBDIR)/deps/*.rlib + +C_clean: + rm -Rf $(SHLIB) $(OBJECTS) $(CARGOTMP) + +clean: + rm -Rf $(SHLIB) $(OBJECTS) $(CARGOTMP) ./extendr/target diff --git a/r-immunum/src/entrypoint.c b/r-immunum/src/entrypoint.c new file mode 100644 index 0000000..6c7d4f2 --- /dev/null +++ b/r-immunum/src/entrypoint.c @@ -0,0 +1,11 @@ +// We need to forward routine registration from C to Rust to avoid the linker +// removing the static library if it does not see any object referenced from C. +// +// This file is autogenerated by `rextendr::use_extendr()` and should not be +// edited by hand. + +void R_init_immunum_extendr(void *dll); + +void R_init_immunum(void *dll) { + R_init_immunum_extendr(dll); +} diff --git a/r-immunum/src/extendr/Cargo.lock b/r-immunum/src/extendr/Cargo.lock new file mode 100644 index 0000000..0bb4f61 --- /dev/null +++ b/r-immunum/src/extendr/Cargo.lock @@ -0,0 +1,280 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "extendr-api" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67505d96c7faa49d20e749dba7ba2447db52c40a788fd88cc2b6bef02c02277a" +dependencies = [ + "extendr-macros", + "libR-sys", + "once_cell", + "paste", +] + +[[package]] +name = "extendr-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b58838056f294411d0b2c35ac1a2b24c507d6828b75f2c1e74f00ee9b99267" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "immunum" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f012490457999721b8fe789837aecd12f4d40872accfc9e78f9eee23601e42" +dependencies = [ + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror", +] + +[[package]] +name = "immunumr" +version = "1.1.0" +dependencies = [ + "extendr-api", + "immunum", + "rayon", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libR-sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ac9752bc1e83f5a354a62b9e81bd8db4468b1008e29f262441e7f0e91e6bb3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/r-immunum/src/extendr/Cargo.toml b/r-immunum/src/extendr/Cargo.toml new file mode 100644 index 0000000..8850f14 --- /dev/null +++ b/r-immunum/src/extendr/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "immunumr" +version = "1.1.0" +edition = "2021" +publish = false +license = "MIT" + +[lib] +crate-type = ["staticlib"] +name = "immunumr" + +[dependencies] +extendr-api = "0.7" +rayon = "1.10" + +# Depends on the published crates.io release. +# Bump this when the crate version advances. +# `default-features = false` disables the `cli` feature (clap/chrono/toml). +immunum = { version = "1.1.0", default-features = false } + +[profile.release] +codegen-units = 1 +lto = "fat" +opt-level = 3 +strip = "debuginfo" diff --git a/r-immunum/src/extendr/cargo-overrides.toml b/r-immunum/src/extendr/cargo-overrides.toml new file mode 100644 index 0000000..995f3e8 --- /dev/null +++ b/r-immunum/src/extendr/cargo-overrides.toml @@ -0,0 +1,25 @@ +# Per-package cargo config overrides for the R extension shim crate. +# +# Loaded into cargo via `cargo build --config ` from +# r-immunum/src/Makevars and Makevars.win. The `--config ` flag (stable +# since cargo 1.63) takes precedence over any walked .cargo/config.toml files. +# +# Why this exists: the top-level immunum crate ships .cargo/config.toml at the +# repo root that injects `-undefined dynamic_lookup` rustflags for both darwin +# targets. Those flags exist so PyO3's `extension-module` link mode works on +# macOS — Python's libpython symbols are looked up dynamically at runtime. +# That's correct for PyO3, wrong for R: R extensions must be self-contained +# at link time, and `-undefined dynamic_lookup` would let unresolved symbols +# slip through and explode at dyn.load time. +# +# This file overrides those rustflags with empty lists so the R build links +# normally on macOS. The file lives at src/rust/cargo-overrides.toml (no +# leading dot) on purpose: a hidden `.cargo/config.toml` directory triggers +# a NOTE in `R CMD check`, and shipping a hidden directory inside a tarball +# is awkward to explain to mirror admins. + +[target.x86_64-apple-darwin] +rustflags = [] + +[target.aarch64-apple-darwin] +rustflags = [] diff --git a/r-immunum/src/extendr/src/lib.rs b/r-immunum/src/extendr/src/lib.rs new file mode 100644 index 0000000..07eaf48 --- /dev/null +++ b/r-immunum/src/extendr/src/lib.rs @@ -0,0 +1,369 @@ +use extendr_api::prelude::*; +use rayon::prelude::*; +use std::str::FromStr; + +use immunum::numbering::segment as segment_positions; +use immunum::{Annotator as InnerAnnotator, Chain, Scheme}; + +// extendr 0.7 converts Result::Err via throw_r_error → Rf_error (longjmp). +// On Windows+GNU this longjmp across Rust frames is UB and segfaults. +// Annotator::new must return Result (extendr requirement); R-side +// validates inputs so the Err path is unreachable in normal operation. +// number()/segment() return a plain List: on error the list has an +// `error` field carrying the Rust error message and all other fields NULL. +// On success, `error` is absent. This mirrors the Python wrapper's +// error-returning (non-throwing) approach added in upstream v1.1.0. + +#[extendr] +fn immunum_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +struct Annotator { + inner: InnerAnnotator, +} + +#[extendr] +impl Annotator { + // extendr requires Result for struct constructors. + // R-side validates chains/scheme/min_confidence before calling, + // so the Err branch is unreachable in normal operation. + fn new(chains: Strings, scheme: String, min_confidence: Nullable) -> Result { + let parsed_chains: Vec = chains + .iter() + .map(|c| { + Chain::from_str(c.as_str()) + .map_err(|_| Error::Other(format!("invalid chain {:?}", c.as_str()))) + }) + .collect::>>()?; + + let parsed_scheme = Scheme::from_str(&scheme) + .map_err(|_| Error::Other(format!("invalid scheme {:?}", scheme)))?; + + let conf = match min_confidence { + Nullable::NotNull(v) => Some(v as f32), + Nullable::Null => None, + }; + + let inner = InnerAnnotator::new(&parsed_chains, parsed_scheme, conf) + .map_err(|e| Error::Other(format!("{}", e)))?; + + Ok(Self { inner }) + } + + fn number(&self, sequence: &str) -> List { + let result = match self.inner.number(sequence) { + Ok(r) => r, + Err(e) => return list!(error = e.to_string()), + }; + + let aligned = &sequence[result.query_start..=result.query_end]; + let positions: Vec = result.positions.iter().map(|p| p.to_string()).collect(); + let residues: Vec = aligned.chars().map(|c| c.to_string()).collect(); + + list!( + chain = result.chain.to_string(), + scheme = result.scheme.to_string(), + confidence = result.confidence as f64, + positions = positions, + residues = residues, + query_start = (result.query_start + 1) as i32, + query_end = (result.query_end + 1) as i32, + ) + } + + fn segment(&self, sequence: &str) -> List { + let result = match self.inner.segment(sequence) { + Ok(r) => r, + Err(e) => return list!(error = e.to_string()), + }; + + list!( + prefix = result.prefix, + fr1 = result.fr1, + cdr1 = result.cdr1, + fr2 = result.fr2, + cdr2 = result.cdr2, + fr3 = result.fr3, + cdr3 = result.cdr3, + fr4 = result.fr4, + postfix = result.postfix, + ) + } +} + +// --------------------------------------------------------------------------- +// Batch primitives +// --------------------------------------------------------------------------- + +fn parse_chains(chains: &Strings) -> Result> { + chains + .iter() + .map(|c| { + Chain::from_str(c.as_str()) + .map_err(|_| Error::Other(format!("invalid chain {:?}", c.as_str()))) + }) + .collect() +} + +fn parse_scheme(scheme: &str) -> Result { + Scheme::from_str(scheme) + .map_err(|_| Error::Other(format!("invalid scheme {:?}", scheme))) +} + +fn build_inner_annotator( + chains: Strings, + scheme: String, + min_confidence: Nullable, +) -> Result { + let parsed_chains = parse_chains(&chains)?; + let parsed_scheme = parse_scheme(&scheme)?; + let conf = match min_confidence { + Nullable::NotNull(v) => Some(v as f32), + Nullable::Null => None, + }; + InnerAnnotator::new(&parsed_chains, parsed_scheme, conf) + .map_err(|e| Error::Other(format!("{}", e))) +} + +fn materialize_inputs(seqs: Strings) -> Vec> { + seqs.iter() + .map(|s| { + if s.is_na() { + None + } else { + Some(s.as_str().to_string()) + } + }) + .collect() +} + +type NumRow = Option, Vec), String>>; + +fn process_number(ann: &InnerAnnotator, seq: &str) -> NumRow { + let result = match ann.number(seq) { + Ok(r) => r, + Err(e) => return Some(Err(e.to_string())), + }; + let (positions, residues): (Vec, Vec) = result + .positions + .iter() + .zip(seq.chars()) + .map(|(pos, ch)| (pos.to_string(), ch.to_string())) + .unzip(); + Some(Ok(( + result.chain.to_string(), + result.scheme.to_string(), + result.confidence as f64, + positions, + residues, + ))) +} + +type SegRow = Option>; + +fn process_segment(ann: &InnerAnnotator, seq: &str) -> SegRow { + let result = match ann.number(seq) { + Ok(r) => r, + Err(e) => return Some(Err(e.to_string())), + }; + let mut s = segment_positions(&result.positions, seq, result.scheme); + let mut take = |k: &str| s.remove(k).unwrap_or_default(); + Some(Ok([ + take("prefix"), + take("fr1"), + take("cdr1"), + take("fr2"), + take("cdr2"), + take("fr3"), + take("cdr3"), + take("fr4"), + take("postfix"), + ])) +} + +fn collect_number_columns(rows: Vec) -> List { + let n = rows.len(); + let mut chain: Vec> = Vec::with_capacity(n); + let mut scheme: Vec> = Vec::with_capacity(n); + let mut conf: Vec> = Vec::with_capacity(n); + let mut positions: Vec = Vec::with_capacity(n); + let mut residues: Vec = Vec::with_capacity(n); + let mut error: Vec> = Vec::with_capacity(n); + for row in rows { + match row { + None => { + chain.push(None); + scheme.push(None); + conf.push(None); + positions.push(Robj::from(())); + residues.push(Robj::from(())); + error.push(None); + } + Some(Err(e)) => { + chain.push(None); + scheme.push(None); + conf.push(None); + positions.push(Robj::from(())); + residues.push(Robj::from(())); + error.push(Some(e)); + } + Some(Ok((c, sc, cf, pos, res))) => { + chain.push(Some(c)); + scheme.push(Some(sc)); + conf.push(Some(cf)); + positions.push(Robj::from(pos)); + residues.push(Robj::from(res)); + error.push(None); + } + } + } + list!( + chain = chain, + scheme = scheme, + confidence = conf, + positions = List::from_values(positions), + residues = List::from_values(residues), + error = error, + ) +} + +fn collect_segment_columns(rows: Vec) -> List { + let n = rows.len(); + let mut prefix: Vec> = Vec::with_capacity(n); + let mut fr1: Vec> = Vec::with_capacity(n); + let mut cdr1: Vec> = Vec::with_capacity(n); + let mut fr2: Vec> = Vec::with_capacity(n); + let mut cdr2: Vec> = Vec::with_capacity(n); + let mut fr3: Vec> = Vec::with_capacity(n); + let mut cdr3: Vec> = Vec::with_capacity(n); + let mut fr4: Vec> = Vec::with_capacity(n); + let mut postfix: Vec> = Vec::with_capacity(n); + let mut error: Vec> = Vec::with_capacity(n); + for row in rows { + match row { + None => { + prefix.push(None); + fr1.push(None); + cdr1.push(None); + fr2.push(None); + cdr2.push(None); + fr3.push(None); + cdr3.push(None); + fr4.push(None); + postfix.push(None); + error.push(None); + } + Some(Err(e)) => { + prefix.push(None); + fr1.push(None); + cdr1.push(None); + fr2.push(None); + cdr2.push(None); + fr3.push(None); + cdr3.push(None); + fr4.push(None); + postfix.push(None); + error.push(Some(e)); + } + Some(Ok([p, f1, c1, f2, c2, f3, c3, f4, pf])) => { + prefix.push(Some(p)); + fr1.push(Some(f1)); + cdr1.push(Some(c1)); + fr2.push(Some(f2)); + cdr2.push(Some(c2)); + fr3.push(Some(f3)); + cdr3.push(Some(c3)); + fr4.push(Some(f4)); + postfix.push(Some(pf)); + error.push(None); + } + } + } + list!( + prefix = prefix, + fr1 = fr1, + cdr1 = cdr1, + fr2 = fr2, + cdr2 = cdr2, + fr3 = fr3, + cdr3 = cdr3, + fr4 = fr4, + postfix = postfix, + error = error, + ) +} + +// R-side validates chains/scheme before calling, so build_inner_annotator's +// Err path is unreachable in normal operation. +#[extendr] +fn numbering_batch( + seqs: Strings, + chains: Strings, + scheme: String, + min_confidence: Nullable, +) -> Result { + let annotator = build_inner_annotator(chains, scheme, min_confidence)?; + let inputs = materialize_inputs(seqs); + let rows: Vec = inputs + .par_iter() + .map_with(annotator, |ann, opt_seq| { + opt_seq.as_deref().and_then(|s| process_number(ann, s)) + }) + .collect(); + Ok(collect_number_columns(rows)) +} + +#[extendr] +fn numbering_batch_with(seqs: Strings, annotator: &Annotator) -> List { + let cloned = annotator.inner.clone(); + let inputs = materialize_inputs(seqs); + let rows: Vec = inputs + .par_iter() + .map_with(cloned, |ann, opt_seq| { + opt_seq.as_deref().and_then(|s| process_number(ann, s)) + }) + .collect(); + collect_number_columns(rows) +} + +#[extendr] +fn segmentation_batch( + seqs: Strings, + chains: Strings, + scheme: String, + min_confidence: Nullable, +) -> Result { + let annotator = build_inner_annotator(chains, scheme, min_confidence)?; + let inputs = materialize_inputs(seqs); + let rows: Vec = inputs + .par_iter() + .map_with(annotator, |ann, opt_seq| { + opt_seq.as_deref().and_then(|s| process_segment(ann, s)) + }) + .collect(); + Ok(collect_segment_columns(rows)) +} + +#[extendr] +fn segmentation_batch_with(seqs: Strings, annotator: &Annotator) -> List { + let cloned = annotator.inner.clone(); + let inputs = materialize_inputs(seqs); + let rows: Vec = inputs + .par_iter() + .map_with(cloned, |ann, opt_seq| { + opt_seq.as_deref().and_then(|s| process_segment(ann, s)) + }) + .collect(); + collect_segment_columns(rows) +} + +extendr_module! { + mod immunum; + fn immunum_version; + fn numbering_batch; + fn numbering_batch_with; + fn segmentation_batch; + fn segmentation_batch_with; + impl Annotator; +} diff --git a/r-immunum/tests/testthat.R b/r-immunum/tests/testthat.R new file mode 100644 index 0000000..1bb6f65 --- /dev/null +++ b/r-immunum/tests/testthat.R @@ -0,0 +1,12 @@ +# This file is part of the standard setup for testthat. +# It is recommended that you do not modify it. +# +# Where should you do additional test configuration? +# Learn more about the roles of various files in: +# * https://r-pkgs.org/testing-design.html#sec-tests-files-overview +# * https://testthat.r-lib.org/articles/special-files.html + +library(testthat) +library(immunum) + +test_check("immunum") diff --git a/r-immunum/tests/testthat/helper-fixtures.R b/r-immunum/tests/testthat/helper-fixtures.R new file mode 100644 index 0000000..87f68b4 --- /dev/null +++ b/r-immunum/tests/testthat/helper-fixtures.R @@ -0,0 +1,45 @@ +# Sequence and chain-set fixtures shared across test files. +# Mirrors the constants in tests/test_python.py. + +ALL_CHAINS_R <- c("IGH", "IGK", "IGL", "TRA", "TRB", "TRG", "TRD") +AB_CHAINS_R <- c("IGH", "IGK", "IGL") +TCR_CHAINS_R <- c("TRA", "TRB", "TRG", "TRD") + +IGL_SEQ <- "SALTQPPAVSGTPGQRVTISCSGSDIGRRSVNWYQQFPGTAPKLLIYSNDQRPSVVPDRFSGSKSGTSASLAISGLQSEDEAEYYCAAWDDSLAVFGGGTQLTVGQPKA" +IGH_SEQ <- "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS" +TRA_SEQ <- "DSVTQTEGQVALSEEDFLTIHCNYSASGYPALFWYVQYPGEGPQFLFRASRDKEKGSSRGFEATYNKEATSFHLQKASVQESDSAVYYCALSGGNNKLTFGAGTKLTIKP" +TRB_SEQ <- "GVTQTPKFQVLKTGQSMTLQCAQDMNHEYMSWYRQDPGMGLRLIHYSVGAGITDQGEVPNGYNVSRSTTEDFPLRLLSAAPSQTSVYFCASRPGLAGGRPEQYFGPGTRLTVTE" +TRG_SEQ <- "AGHLEQPQISSTKTLSKTARLECVVSGITISATSVYWYRERPGEVIQFLVSISYDGTVRKESGIPSGKFEVDRIPETSTSTLTIHNVEKQDIATYYCALWEAQQEGLKKIKVFGPGTKLIITD" +TRD_SEQ <- "QKVTQAQSSVSMPVRKAVTLNCLYETSWWSYYIFWYKQLPSKEMIFLIRQGSDEQNAKSGRYSVNFKKAAKSVALTISALQLEDSAKYFCALGDPGGNLTDKLIFGKGTRVTVEP" + +INIT_CASES <- list( + list(id = "single_IGH_imgt", chains = "IGH", scheme = "IMGT", seq = IGH_SEQ), + list(id = "single_IGK_imgt", chains = "IGK", scheme = "IMGT", seq = IGL_SEQ), + list(id = "single_IGL_imgt", chains = "IGL", scheme = "IMGT", seq = IGL_SEQ), + list(id = "single_TRA_imgt", chains = "TRA", scheme = "IMGT", seq = TRA_SEQ), + list(id = "single_TRB_imgt", chains = "TRB", scheme = "IMGT", seq = TRB_SEQ), + list(id = "single_TRG_imgt", chains = "TRG", scheme = "IMGT", seq = TRG_SEQ), + list(id = "single_TRD_imgt", chains = "TRD", scheme = "IMGT", seq = TRD_SEQ), + list(id = "all_ab_chains_imgt", chains = AB_CHAINS_R, scheme = "IMGT", seq = IGL_SEQ), + list(id = "all_tcr_chains_imgt", chains = TCR_CHAINS_R, scheme = "IMGT", seq = TRB_SEQ), + list(id = "all_chains_imgt", chains = ALL_CHAINS_R, scheme = "IMGT", seq = IGL_SEQ), + list(id = "single_IGH_kabat", chains = "IGH", scheme = "Kabat", seq = IGH_SEQ), + list(id = "all_ab_chains_kabat", chains = AB_CHAINS_R, scheme = "Kabat", seq = IGL_SEQ), + list(id = "lowercase_chain_scheme", chains = "igh", scheme = "imgt", seq = IGH_SEQ), + list(id = "short_chain_alias_H", chains = "H", scheme = "IMGT", seq = IGH_SEQ), + list(id = "short_chain_alias_K", chains = "K", scheme = "IMGT", seq = IGL_SEQ), + list(id = "short_chain_alias_L", chains = "L", scheme = "IMGT", seq = IGL_SEQ), + list(id = "named_chain_heavy", chains = "heavy", scheme = "IMGT", seq = IGH_SEQ), + list(id = "named_chain_kappa", chains = "kappa", scheme = "IMGT", seq = IGL_SEQ), + list(id = "named_chain_lambda", chains = "lambda", scheme = "IMGT", seq = IGL_SEQ), + list(id = "named_chain_alpha", chains = "alpha", scheme = "IMGT", seq = TRA_SEQ), + list(id = "named_chain_beta", chains = "beta", scheme = "IMGT", seq = TRB_SEQ), + list(id = "short_chain_alias_A", chains = "A", scheme = "IMGT", seq = TRA_SEQ), + list(id = "short_chain_alias_B", chains = "B", scheme = "IMGT", seq = TRB_SEQ), + list(id = "short_chain_alias_G", chains = "G", scheme = "IMGT", seq = TRG_SEQ), + list(id = "short_chain_alias_D", chains = "D", scheme = "IMGT", seq = TRD_SEQ), + list(id = "named_chain_gamma", chains = "gamma", scheme = "IMGT", seq = TRG_SEQ), + list(id = "named_chain_delta", chains = "delta", scheme = "IMGT", seq = TRD_SEQ), + list(id = "scheme_alias_i", chains = "IGH", scheme = "i", seq = IGH_SEQ), + list(id = "scheme_alias_k", chains = AB_CHAINS_R, scheme = "k", seq = IGL_SEQ) +) diff --git a/r-immunum/tests/testthat/test-annotator.R b/r-immunum/tests/testthat/test-annotator.R new file mode 100644 index 0000000..97b11f1 --- /dev/null +++ b/r-immunum/tests/testthat/test-annotator.R @@ -0,0 +1,187 @@ +skip_if_not_loaded <- function() { + testthat::skip_if_not( + requireNamespace("immunum", quietly = TRUE), + "immunum native library not loaded" + ) +} + +# ── Construction ──────────────────────────────────────────────────────────── + +test_that("Annotator constructs across the parametrized init matrix", { + skip_if_not_loaded() + for (case in INIT_CASES) { + ann <- Annotator$new(chains = case$chains, scheme = case$scheme) + expect_s3_class(ann, "Annotator") + } +}) + +test_that("Annotator can number the seed sequence for each init case", { + skip_if_not_loaded() + for (case in INIT_CASES) { + ann <- Annotator$new(chains = case$chains, scheme = case$scheme) + result <- ann$number(case$seq) + expect_type(result$chain, "character") + expect_type(result$scheme, "character") + expect_type(result$confidence, "double") + expect_true(result$confidence >= 0 && result$confidence <= 1) + } +}) + +test_that("Kabat scheme rejects TCR chains", { + skip_if_not_loaded() + bad_combos <- list( + list(chains = "TRA", scheme = "Kabat"), + list(chains = "TRB", scheme = "Kabat"), + list(chains = c("IGH", "TRA"), scheme = "Kabat"), + list(chains = ALL_CHAINS_R, scheme = "Kabat") + ) + for (combo in bad_combos) { + expect_error( + Annotator$new(chains = combo$chains, scheme = combo$scheme), + "Kabat" + ) + } +}) + +test_that("invalid construction args raise", { + skip_if_not_loaded() + expect_error(Annotator$new(chains = "INVALID", scheme = "IMGT")) + expect_error(Annotator$new(chains = "IGH", scheme = "INVALID")) + expect_error(Annotator$new(chains = character(), scheme = "IMGT")) +}) + +test_that("min_confidence is range-checked", { + skip_if_not_loaded() + expect_error( + Annotator$new(chains = "IGH", scheme = "IMGT", min_confidence = -0.1), + "must be a single numeric in" + ) + expect_error( + Annotator$new(chains = "IGH", scheme = "IMGT", min_confidence = 1.1), + "must be a single numeric in" + ) + expect_error( + Annotator$new(chains = "IGH", scheme = "IMGT", min_confidence = c(0.5, 0.6)), + "must be a single numeric in" + ) + expect_no_error(Annotator$new(chains = "IGH", scheme = "IMGT", min_confidence = NULL)) + expect_no_error(Annotator$new(chains = "IGH", scheme = "IMGT", min_confidence = 0)) + expect_no_error(Annotator$new(chains = "IGH", scheme = "IMGT", min_confidence = 1)) +}) + +# ── Numbering ─────────────────────────────────────────────────────────────── + +test_that("number detects an IGH sequence as the H chain", { + skip_if_not_loaded() + ann <- Annotator$new(chains = ALL_CHAINS_R, scheme = "IMGT") + result <- ann$number(IGH_SEQ) + expect_equal(result$chain, "H") + expect_equal(result$scheme, "IMGT") + expect_null(result$error) +}) + +test_that("number with a single-chain annotator returns that chain", { + skip_if_not_loaded() + ann <- Annotator$new(chains = "IGH", scheme = "IMGT") + result <- ann$number(IGH_SEQ) + expect_equal(result$chain, "H") +}) + +test_that("number on an empty sequence returns error", { + skip_if_not_loaded() + ann <- Annotator$new(chains = ALL_CHAINS_R, scheme = "IMGT") + result <- ann$number("") + expect_false(is.null(result$error)) + expect_null(result$chain) + expect_null(result$query_start) + expect_null(result$query_end) +}) + +test_that("number on an invalid sequence returns error", { + skip_if_not_loaded() + ann <- Annotator$new(chains = ALL_CHAINS_R, scheme = "IMGT") + result <- ann$number("AAAAAAAAAAAAAAAA") + expect_false(is.null(result$error)) + expect_null(result$chain) +}) + +test_that("query_start and query_end are returned on success", { + skip_if_not_loaded() + ann <- Annotator$new(chains = "IGH", scheme = "IMGT") + result <- ann$number(IGH_SEQ) + expect_type(result$query_start, "integer") + expect_type(result$query_end, "integer") + expect_true(result$query_start >= 1L) + expect_true(result$query_end >= result$query_start) + expect_true(result$query_end <= nchar(IGH_SEQ)) + expect_equal(result$query_end - result$query_start + 1L, length(result$numbering)) + expect_null(result$error) +}) + +test_that("number rejects bad sequence input shapes", { + skip_if_not_loaded() + ann <- Annotator$new(chains = ALL_CHAINS_R, scheme = "IMGT") + expect_error(ann$number(NA_character_), "single non-NA") + expect_error(ann$number(c("AAA", "BBB")), "single non-NA") + expect_error(ann$number(123), "single non-NA") +}) + +test_that("confidence is a numeric in [0, 1] for every init case", { + skip_if_not_loaded() + for (case in INIT_CASES) { + ann <- Annotator$new(chains = case$chains, scheme = case$scheme) + result <- ann$number(case$seq) + expect_type(result$confidence, "double") + expect_true(result$confidence >= 0 && result$confidence <= 1) + } +}) + +test_that("numbering is a named character vector with one entry per residue", { + skip_if_not_loaded() + ann <- Annotator$new(chains = "IGH", scheme = "IMGT") + result <- ann$number(IGH_SEQ) + expect_type(result$numbering, "character") + expect_true(length(result$numbering) > 0L) + expect_false(is.null(names(result$numbering))) + expect_true(all(nchar(result$numbering) == 1L)) + expect_true(all(nzchar(names(result$numbering)))) +}) + +# ── Segmentation ──────────────────────────────────────────────────────────── + +test_that("segmentation returns the nine expected fields with no error", { + skip_if_not_loaded() + expected_fields <- c("prefix", "fr1", "cdr1", "fr2", "cdr2", + "fr3", "cdr3", "fr4", "postfix") + for (case in INIT_CASES) { + ann <- Annotator$new(chains = case$chains, scheme = case$scheme) + seg <- ann$segment(case$seq) + expect_setequal(names(seg), expected_fields) + expect_null(seg$error) + for (field in expected_fields) { + expect_type(seg[[field]], "character") + expect_length(seg[[field]], 1L) + expect_false(is.na(seg[[field]])) + } + } +}) + +test_that("segmentation on an invalid sequence returns error", { + skip_if_not_loaded() + ann <- Annotator$new(chains = "IGH", scheme = "IMGT") + seg <- ann$segment("AAAAAAAAAAAAAAAA") + expect_false(is.null(seg$error)) + expect_null(seg$fr1) +}) + +test_that("known IGH sequence segments to canonical FR1/CDR1/CDR3/FR4", { + skip_if_not_loaded() + ann <- Annotator$new(chains = "IGH", scheme = "IMGT") + seg <- ann$segment(IGH_SEQ) + expect_equal(seg$fr1, "QVQLVQSGAEVKRPGSSVTVSCKAS") + expect_equal(seg$cdr1, "GGSFSTYA") + expect_equal(seg$cdr3, "AREGTTGKPIGAFAH") + expect_equal(seg$fr4, "WGQGTLVTVSS") + expect_equal(seg$prefix, "") + expect_equal(seg$postfix, "") +}) diff --git a/r-immunum/tests/testthat/test-batch.R b/r-immunum/tests/testthat/test-batch.R new file mode 100644 index 0000000..142f16c --- /dev/null +++ b/r-immunum/tests/testthat/test-batch.R @@ -0,0 +1,152 @@ +skip_if_not_loaded <- function() { + testthat::skip_if_not( + requireNamespace("immunum", quietly = TRUE), + "immunum native library not loaded" + ) +} + +BATCH_SEQS <- c(IGH_SEQ, IGL_SEQ, TRB_SEQ) + +# ── numbering_batch columns ────────────────────────────────────────────────── + +test_that("numbering_batch returns expected column names", { + skip_if_not_loaded() + out <- immunum:::numbering_batch(BATCH_SEQS, ALL_CHAINS_R, "IMGT") + expect_named(out, c("chain", "scheme", "confidence", "positions", "residues", "error"), + ignore.order = TRUE) +}) + +test_that("numbering_batch returns one row per input sequence", { + skip_if_not_loaded() + out <- immunum:::numbering_batch(BATCH_SEQS, ALL_CHAINS_R, "IMGT") + expect_length(out$chain, length(BATCH_SEQS)) + expect_length(out$confidence, length(BATCH_SEQS)) + expect_length(out$positions, length(BATCH_SEQS)) + expect_length(out$residues, length(BATCH_SEQS)) + expect_length(out$error, length(BATCH_SEQS)) +}) + +test_that("numbering_batch results match single-sequence Annotator$number()", { + skip_if_not_loaded() + seqs <- c(IGH_SEQ, IGL_SEQ) + ann <- Annotator$new(chains = ALL_CHAINS_R, scheme = "IMGT") + bat <- immunum:::numbering_batch(seqs, ALL_CHAINS_R, "IMGT") + + for (i in seq_along(seqs)) { + single <- ann$number(seqs[[i]]) + expect_equal(bat$chain[[i]], single$chain) + expect_equal(bat$confidence[[i]], single$confidence, tolerance = 1e-6) + expect_equal(bat$positions[[i]], names(single$numbering)) + expect_equal(bat$residues[[i]], unname(single$numbering)) + expect_true(is.na(bat$error[[i]])) + } +}) + +test_that("numbering_batch error field is set for bad sequences", { + skip_if_not_loaded() + seqs <- c(IGH_SEQ, "AAAAAAAAAAAAAAAA", IGL_SEQ) + out <- immunum:::numbering_batch(seqs, ALL_CHAINS_R, "IMGT") + expect_true(is.na(out$error[[1]])) + expect_false(is.na(out$error[[2]])) + expect_true(is.na(out$chain[[2]])) + expect_true(is.na(out$error[[3]])) +}) + +test_that("numbering_batch treats NA input as missing (no error field set)", { + skip_if_not_loaded() + seqs <- c(IGH_SEQ, NA_character_, IGL_SEQ) + out <- immunum:::numbering_batch(seqs, ALL_CHAINS_R, "IMGT") + expect_true(is.na(out$chain[[2]])) + expect_true(is.na(out$error[[2]])) +}) + +test_that("numbering_batch works with Kabat scheme", { + skip_if_not_loaded() + out <- immunum:::numbering_batch(c(IGH_SEQ, IGL_SEQ), AB_CHAINS_R, "Kabat") + expect_true(is.na(out$error[[1]])) + expect_true(is.na(out$error[[2]])) + expect_equal(out$scheme[[1]], "Kabat") +}) + +test_that("numbering_batch accepts chain/scheme aliases", { + skip_if_not_loaded() + out1 <- immunum:::numbering_batch(c(IGH_SEQ), c("IGH"), "IMGT") + out2 <- immunum:::numbering_batch(c(IGH_SEQ), c("H"), "imgt") + out3 <- immunum:::numbering_batch(c(IGH_SEQ), c("heavy"), "i") + expect_equal(out1$chain, out2$chain) + expect_equal(out1$chain, out3$chain) +}) + +# ── segmentation_batch columns ──────────────────────────────────────────────── + +test_that("segmentation_batch returns expected column names", { + skip_if_not_loaded() + out <- immunum:::segmentation_batch(BATCH_SEQS, ALL_CHAINS_R, "IMGT") + expect_named(out, c("prefix", "fr1", "cdr1", "fr2", "cdr2", + "fr3", "cdr3", "fr4", "postfix", "error"), + ignore.order = TRUE) +}) + +test_that("segmentation_batch results match single-sequence Annotator$segment()", { + skip_if_not_loaded() + seqs <- c(IGH_SEQ, IGL_SEQ) + ann <- Annotator$new(chains = ALL_CHAINS_R, scheme = "IMGT") + bat <- immunum:::segmentation_batch(seqs, ALL_CHAINS_R, "IMGT") + fields <- c("prefix", "fr1", "cdr1", "fr2", "cdr2", "fr3", "cdr3", "fr4", "postfix") + + for (i in seq_along(seqs)) { + single <- ann$segment(seqs[[i]]) + for (f in fields) { + expect_equal(bat[[f]][[i]], single[[f]], + info = sprintf("row %d field %s", i, f)) + } + expect_true(is.na(bat$error[[i]])) + } +}) + +test_that("segmentation_batch error field is set for bad sequences", { + skip_if_not_loaded() + seqs <- c(IGH_SEQ, "AAAAAAAAAAAAAAAA") + out <- immunum:::segmentation_batch(seqs, ALL_CHAINS_R, "IMGT") + expect_true(is.na(out$error[[1]])) + expect_false(is.na(out$error[[2]])) + expect_true(is.na(out$fr1[[2]])) +}) + +# ── _with variants ──────────────────────────────────────────────────────────── + +test_that("numbering_batch_with returns the same result as numbering_batch", { + skip_if_not_loaded() + ann <- Annotator$new(chains = ALL_CHAINS_R, scheme = "IMGT") + seqs <- c(IGH_SEQ, IGL_SEQ) + ref <- immunum:::numbering_batch(seqs, ALL_CHAINS_R, "IMGT") + got <- immunum:::numbering_batch_with(seqs, ann) + + expect_equal(got$chain, ref$chain) + expect_equal(got$scheme, ref$scheme) + expect_equal(got$confidence, ref$confidence, tolerance = 1e-6) + expect_equal(got$positions, ref$positions) + expect_equal(got$residues, ref$residues) +}) + +test_that("segmentation_batch_with returns the same result as segmentation_batch", { + skip_if_not_loaded() + ann <- Annotator$new(chains = ALL_CHAINS_R, scheme = "IMGT") + seqs <- c(IGH_SEQ, IGL_SEQ) + ref <- immunum:::segmentation_batch(seqs, ALL_CHAINS_R, "IMGT") + got <- immunum:::segmentation_batch_with(seqs, ann) + fields <- c("prefix", "fr1", "cdr1", "fr2", "cdr2", + "fr3", "cdr3", "fr4", "postfix", "error") + + for (f in fields) { + expect_equal(got[[f]], ref[[f]], info = paste("field:", f)) + } +}) + +test_that("_with variants reject non-Annotator inputs", { + skip_if_not_loaded() + expect_error(immunum:::numbering_batch_with(IGH_SEQ, "nope"), + "must be an Annotator R6 instance") + expect_error(immunum:::segmentation_batch_with(IGH_SEQ, list()), + "must be an Annotator R6 instance") +}) diff --git a/r-immunum/tests/testthat/test-cross-language.R b/r-immunum/tests/testthat/test-cross-language.R new file mode 100644 index 0000000..7515b87 --- /dev/null +++ b/r-immunum/tests/testthat/test-cross-language.R @@ -0,0 +1,74 @@ +skip_if_no_python_immunum <- function() { + testthat::skip_if_not_installed("reticulate") + testthat::skip_if( + !nzchar(Sys.which("python3")) && !nzchar(Sys.which("python")), + "No Python installation found" + ) + old <- Sys.getenv("RETICULATE_PYTHON_FALLBACK", NA) + Sys.setenv(RETICULATE_PYTHON_FALLBACK = "false") + on.exit({ + if (is.na(old)) Sys.unsetenv("RETICULATE_PYTHON_FALLBACK") + else Sys.setenv(RETICULATE_PYTHON_FALLBACK = old) + }) + tryCatch( + reticulate::import("immunum"), + error = function(e) { + testthat::skip("Python immunum not importable") + } + ) +} + +# ── Single-sequence parity ────────────────────────────────────────────────── + +test_that("R and Python Annotator$number() produce identical output", { + skip_if_no_python_immunum() + imp <- reticulate::import("immunum") + + py_ann <- imp$Annotator(chains = list("H", "K", "L"), scheme = "imgt") + r_ann <- Annotator$new(chains = c("H", "K", "L"), scheme = "imgt") + + for (seq in c(IGH_SEQ, IGL_SEQ)) { + py_res <- py_ann$number(seq) + r_res <- r_ann$number(seq) + + expect_identical(r_res$chain, py_res$chain, + info = sprintf("chain mismatch for %.20s...", seq)) + expect_identical(r_res$scheme, py_res$scheme, + info = sprintf("scheme mismatch for %.20s...", seq)) + expect_equal(r_res$confidence, py_res$confidence, tolerance = 1e-6, + info = sprintf("confidence mismatch for %.20s...", seq)) + + py_numb <- unlist(reticulate::py_to_r(py_res$numbering)) + r_numb <- r_res$numbering + expect_identical(sort(names(r_numb)), sort(names(py_numb)), + info = sprintf("position set mismatch for %.20s...", seq)) + expect_identical(unname(r_numb[sort(names(r_numb))]), + unname(py_numb[sort(names(py_numb))]), + info = sprintf("residue values mismatch for %.20s...", seq)) + } +}) + +test_that("R and Python Annotator$segment() produce identical output", { + skip_if_no_python_immunum() + imp <- reticulate::import("immunum") + + py_ann <- imp$Annotator(chains = list("H", "K", "L"), scheme = "imgt") + r_ann <- Annotator$new(chains = c("H", "K", "L"), scheme = "imgt") + + regions <- c("prefix", "fr1", "cdr1", "fr2", "cdr2", + "fr3", "cdr3", "fr4", "postfix") + + for (seq in c(IGH_SEQ, IGL_SEQ)) { + py_res <- py_ann$segment(seq) + r_res <- r_ann$segment(seq) + + for (region in regions) { + py_val <- py_res[[region]] + r_val <- r_res[[region]] + if (is.null(py_val) || identical(py_val, "")) py_val <- NA_character_ + if (is.null(r_val) || identical(r_val, "")) r_val <- NA_character_ + expect_identical(r_val, py_val, + info = sprintf("%s mismatch for %.20s...", region, seq)) + } + } +}) diff --git a/r-immunum/tests/testthat/test-normalize.R b/r-immunum/tests/testthat/test-normalize.R new file mode 100644 index 0000000..95c80ef --- /dev/null +++ b/r-immunum/tests/testthat/test-normalize.R @@ -0,0 +1,109 @@ +.normalize_chains <- function(...) immunum:::normalize_chains(...) +.normalize_scheme <- function(...) immunum:::normalize_scheme(...) + +test_that("short codes resolve to canonical chain codes", { + expect_equal(.normalize_chains("H"), "IGH") + expect_equal(.normalize_chains("K"), "IGK") + expect_equal(.normalize_chains("L"), "IGL") + expect_equal(.normalize_chains("A"), "TRA") + expect_equal(.normalize_chains("B"), "TRB") + expect_equal(.normalize_chains("G"), "TRG") + expect_equal(.normalize_chains("D"), "TRD") +}) + +test_that("named aliases resolve to canonical chain codes", { + expect_equal(.normalize_chains("heavy"), "IGH") + expect_equal(.normalize_chains("kappa"), "IGK") + expect_equal(.normalize_chains("lambda"), "IGL") + expect_equal(.normalize_chains("alpha"), "TRA") + expect_equal(.normalize_chains("beta"), "TRB") + expect_equal(.normalize_chains("gamma"), "TRG") + expect_equal(.normalize_chains("delta"), "TRD") +}) + +test_that("canonical codes pass through unchanged", { + expect_equal(.normalize_chains("IGH"), "IGH") + expect_equal(.normalize_chains(ALL_CHAINS_R), ALL_CHAINS_R) +}) + +test_that("normalization is case-insensitive", { + expect_equal(.normalize_chains("igh"), "IGH") + expect_equal(.normalize_chains("Heavy"), "IGH") + expect_equal(.normalize_chains("LAMBDA"), "IGL") + expect_equal(.normalize_chains(c("h", "K", "Lambda")), c("IGH", "IGK", "IGL")) +}) + +test_that("unknown chain raises informative error", { + expect_error(.normalize_chains("INVALID"), "Unknown chain") + expect_error(.normalize_chains("Z"), "Unknown chain") + expect_error(.normalize_chains("IGX"), "Unknown chain") + expect_error(.normalize_chains(c("H", "INVALID")), "Unknown chain") +}) + +test_that("invalid input shape raises", { + expect_error(.normalize_chains(character()), "cannot be empty") + expect_error(.normalize_chains(NA_character_), "cannot contain NA") + expect_error(.normalize_chains(123), "must be a character") +}) + +test_that("scheme aliases resolve to canonical case", { + expect_equal(.normalize_scheme("IMGT"), "IMGT") + expect_equal(.normalize_scheme("imgt"), "IMGT") + expect_equal(.normalize_scheme("i"), "IMGT") + expect_equal(.normalize_scheme("Kabat"), "Kabat") + expect_equal(.normalize_scheme("kabat"), "Kabat") + expect_equal(.normalize_scheme("k"), "Kabat") + expect_equal(.normalize_scheme("ImGt"), "IMGT") +}) + +test_that("unknown scheme raises informative error", { + expect_error(.normalize_scheme("INVALID"), "Unknown scheme") + expect_error(.normalize_scheme("xyz"), "Unknown scheme") +}) + +test_that("invalid scheme input shape raises", { + expect_error(.normalize_scheme(character()), "single non-NA character") + expect_error(.normalize_scheme(c("IMGT", "Kabat")), "single non-NA character") + expect_error(.normalize_scheme(NA_character_), "single non-NA character") +}) + +# ── End-to-end alias equivalence ───────────────────────────────────────────── +# Mirrors TestNormalization::test_alias_produces_identical_result from +# test_python.py: using an alias must produce byte-identical chain, scheme, +# and numbering output as the canonical form. + +test_that("alias annotators produce identical numbering to canonical", { + skip_if_not( + requireNamespace("immunum", quietly = TRUE), + "immunum native library not loaded" + ) + cases <- list( + list(alias = "H", canonical = "IGH", scheme_a = "IMGT", scheme_c = "IMGT", seq = IGH_SEQ), + list(alias = "K", canonical = "IGK", scheme_a = "IMGT", scheme_c = "IMGT", seq = IGL_SEQ), + list(alias = "L", canonical = "IGL", scheme_a = "IMGT", scheme_c = "IMGT", seq = IGL_SEQ), + list(alias = "A", canonical = "TRA", scheme_a = "IMGT", scheme_c = "IMGT", seq = TRA_SEQ), + list(alias = "B", canonical = "TRB", scheme_a = "IMGT", scheme_c = "IMGT", seq = TRB_SEQ), + list(alias = "G", canonical = "TRG", scheme_a = "IMGT", scheme_c = "IMGT", seq = TRG_SEQ), + list(alias = "D", canonical = "TRD", scheme_a = "IMGT", scheme_c = "IMGT", seq = TRD_SEQ), + list(alias = "heavy", canonical = "IGH", scheme_a = "IMGT", scheme_c = "IMGT", seq = IGH_SEQ), + list(alias = "kappa", canonical = "IGK", scheme_a = "IMGT", scheme_c = "IMGT", seq = IGL_SEQ), + list(alias = "lambda", canonical = "IGL", scheme_a = "IMGT", scheme_c = "IMGT", seq = IGL_SEQ), + list(alias = "alpha", canonical = "TRA", scheme_a = "IMGT", scheme_c = "IMGT", seq = TRA_SEQ), + list(alias = "beta", canonical = "TRB", scheme_a = "IMGT", scheme_c = "IMGT", seq = TRB_SEQ), + list(alias = "gamma", canonical = "TRG", scheme_a = "IMGT", scheme_c = "IMGT", seq = TRG_SEQ), + list(alias = "delta", canonical = "TRD", scheme_a = "IMGT", scheme_c = "IMGT", seq = TRD_SEQ), + list(alias = "igh", canonical = "IGH", scheme_a = "imgt", scheme_c = "IMGT", seq = IGH_SEQ), + list(alias = "IGH", canonical = "IGH", scheme_a = "i", scheme_c = "IMGT", seq = IGH_SEQ), + list(alias = AB_CHAINS_R, canonical = AB_CHAINS_R, scheme_a = "k", scheme_c = "Kabat", seq = IGL_SEQ) + ) + for (case in cases) { + alias_r <- Annotator$new(chains = case$alias, scheme = case$scheme_a)$number(case$seq) + canon_r <- Annotator$new(chains = case$canonical, scheme = case$scheme_c)$number(case$seq) + expect_equal(alias_r$chain, canon_r$chain, + info = paste("chain mismatch for alias", paste(case$alias, collapse = "+"))) + expect_equal(alias_r$scheme, canon_r$scheme, + info = paste("scheme mismatch for alias", paste(case$alias, collapse = "+"))) + expect_equal(alias_r$numbering, canon_r$numbering, + info = paste("numbering mismatch for alias", paste(case$alias, collapse = "+"))) + } +}) diff --git a/r-immunum/tests/testthat/test-validation.R b/r-immunum/tests/testthat/test-validation.R new file mode 100644 index 0000000..4c3b0d5 --- /dev/null +++ b/r-immunum/tests/testthat/test-validation.R @@ -0,0 +1,114 @@ +.fixtures_dir <- function() { + src <- file.path(testthat::test_path(), "..", "..", "..", "fixtures", "validation") + if (dir.exists(src)) return(normalizePath(src)) + env <- Sys.getenv("IMMUNUM_FIXTURES", "") + if (nzchar(env) && dir.exists(env)) return(normalizePath(env)) + normalizePath(src, mustWork = FALSE) +} + +skip_if_no_fixtures <- function() { + testthat::skip_if( + !dir.exists(.fixtures_dir()), + "validation fixtures not available (not in repo tree)" + ) +} + +.fixture_path <- function(stem) { + file.path(.fixtures_dir(), paste0(stem, ".csv")) +} + +# ── Manifest / threshold accessors ────────────────────────────────────────── + +test_that("validation_fixtures returns the manifest", { + fx <- validation_fixtures() + expect_s3_class(fx, "data.frame") + expect_true(all(c("stem", "scheme", "benchmark", "chains") %in% names(fx))) + expect_gte(nrow(fx), 10L) + expect_type(fx$chains, "list") + expect_type(fx$chains[[1]], "character") +}) + +test_that("benchmark_threshold returns numeric percentages", { + expect_equal(benchmark_threshold("imgt.H"), 99.88) + expect_equal(benchmark_threshold("kabat.K"), 99.66) + expect_equal(benchmark_threshold("imgt.D"), 100.0) +}) + +test_that("benchmark_threshold rejects unknown keys", { + expect_error(benchmark_threshold("nope.X"), "Unknown benchmark key") + expect_error(benchmark_threshold(NA_character_), "non-NA") +}) + +# ── Fixture files exist in repo ───────────────────────────────────────────── + +test_that("all manifest fixtures exist in the repo tree", { + skip_if_no_fixtures() + fx <- validation_fixtures() + for (stem in fx$stem) { + expect_true(file.exists(.fixture_path(stem)), + info = sprintf("missing fixture %s", stem)) + } +}) + +# ── Accuracy validation ──────────────────────────────────────────────────── + +.compare_fixture <- function(path, chains, scheme) { + df <- utils::read.csv(path, colClasses = "character", + na.strings = c("", "NA"), check.names = FALSE) + meta <- c("header", "sequence", "species") + pos_cols <- setdiff(names(df), meta) + + rows <- immunum:::numbering_batch(df$sequence, chains, scheme, + min_confidence = 0.0) + + n <- nrow(df) + mismatches <- 0L + for (i in seq_len(n)) { + # Expected: non-empty cells in position columns + mask <- !is.na(df[i, pos_cols]) & nzchar(df[i, pos_cols]) + exp_pos <- pos_cols[mask] + exp_res <- unlist(df[i, pos_cols[mask]], use.names = FALSE) + + got_pos <- rows$positions[[i]] + got_res <- rows$residues[[i]] + + if (is.null(got_pos) || length(got_pos) == 0L) { + mismatches <- mismatches + 1L + next + } + + ord <- sort(exp_pos) + got <- stats::setNames(got_res, got_pos) + + if (!identical(sort(got_pos), ord) || + !identical(unname(got[ord]), unname(exp_res[match(ord, exp_pos)]))) { + mismatches <- mismatches + 1L + } + } + + perfect <- n - mismatches + list( + mismatches = mismatches, + total = n, + perfect = perfect, + perfect_pct = if (n > 0L) 100 * perfect / n else 0 + ) +} + +test_that("fixtures match BENCHMARKS.toml thresholds", { + skip_if_no_fixtures() + fx <- validation_fixtures() + for (i in seq_len(nrow(fx))) { + row <- fx[i, ] + out <- .compare_fixture(.fixture_path(row$stem), row$chains[[1]], row$scheme) + threshold <- benchmark_threshold(row$benchmark) + expect_gte( + round(out$perfect_pct, 2), + threshold, + label = sprintf( + "%s (%d/%d perfect, %.2f%%, threshold %.2f%%)", + row$stem, out$perfect, out$total, out$perfect_pct, threshold + ) + ) + } +}) diff --git a/r-immunum/tests/testthat/test-version.R b/r-immunum/tests/testthat/test-version.R new file mode 100644 index 0000000..99196e5 --- /dev/null +++ b/r-immunum/tests/testthat/test-version.R @@ -0,0 +1,16 @@ +test_that("immunum_version returns the linked Rust crate version", { + v <- immunum_version() + expect_type(v, "character") + expect_length(v, 1L) + + # Must look like a SemVer (major.minor.patch with optional pre-release). + expect_match(v, "^[0-9]+\\.[0-9]+\\.[0-9]+(-[A-Za-z0-9.-]+)?$") +}) + +test_that("R package version matches the linked Rust crate version", { + # The R DESCRIPTION version and the Rust shim crate version are kept in + # lockstep. Both must match the upstream immunum crate version. + pkg_version <- as.character(utils::packageVersion("immunum")) + rust_version <- immunum_version() + expect_equal(pkg_version, rust_version) +}) diff --git a/r-immunum/tools/sync-benchmarks.R b/r-immunum/tools/sync-benchmarks.R new file mode 100644 index 0000000..59fdf83 --- /dev/null +++ b/r-immunum/tools/sync-benchmarks.R @@ -0,0 +1,76 @@ +# Regenerate the .BENCHMARK_THRESHOLDS constant in R/validation.R from +# the source-of-truth BENCHMARKS.toml at the repo root. +# +# The R package can't read BENCHMARKS.toml at runtime without pulling +# in a TOML parser as a hard dependency. Instead the perfect-pct +# values are baked in as a static R constant; this script keeps them +# fresh after upstream re-runs the benchmark suite. +# +# Run from the r-immunum/ directory after BENCHMARKS.toml changes: +# Rscript --vanilla tools/sync-benchmarks.R +# +# It rewrites the `.BENCHMARK_THRESHOLDS <- c(...)` block in +# R/validation.R in place. Diff afterwards before committing. + +stopifnot(file.exists("../BENCHMARKS.toml"), + file.exists("R/validation.R")) + +lines <- readLines("../BENCHMARKS.toml") + +# Tiny single-purpose parser for the section headers + perfect_pct +# lines we care about. BENCHMARKS.toml is a flat dict-of-dicts so we +# don't need a real TOML parser here. +section <- NULL +thresholds <- list() +for (line in lines) { + line <- trimws(line) + if (!nzchar(line) || startsWith(line, "#")) next + m <- regmatches(line, regexec("^\\[(.+)\\]$", line))[[1]] + if (length(m) == 2L) { + section <- m[[2]] + next + } + if (!is.null(section)) { + kv <- regmatches(line, regexec("^perfect_pct\\s*=\\s*([0-9.]+)$", line))[[1]] + if (length(kv) == 2L) { + thresholds[[section]] <- as.numeric(kv[[2]]) + } + } +} + +if (length(thresholds) == 0L) { + stop("No perfect_pct entries found in BENCHMARKS.toml -- aborting") +} + +ordered_keys <- sort(names(thresholds)) + +# Pretty-print the constant body so it diffs cleanly. Backtick-quote +# the dotted keys so they're valid R names. +body_lines <- vapply(seq_along(ordered_keys), function(i) { + k <- ordered_keys[[i]] + v <- thresholds[[k]] + comma <- if (i == length(ordered_keys)) "" else "," + sprintf(" `%s` = %s%s", k, format(v, nsmall = 0L), comma) +}, character(1)) + +new_block <- c(".BENCHMARK_THRESHOLDS <- c(", body_lines, ")") + +# Splice into R/validation.R between the existing markers. +src <- readLines("R/validation.R") +start <- grep("^\\.BENCHMARK_THRESHOLDS <- c\\($", src) +if (length(start) != 1L) { + stop("Could not find a unique `.BENCHMARK_THRESHOLDS <- c(` line in R/validation.R") +} +end <- start - 1L + which(startsWith(src[start:length(src)], ")"))[1] +if (is.na(end) || end <= start) { + stop("Could not find the closing `)` for .BENCHMARK_THRESHOLDS") +} + +updated <- c(src[seq_len(start - 1L)], new_block, src[(end + 1L):length(src)]) +writeLines(updated, "R/validation.R") + +cat("Updated R/validation.R with", + length(thresholds), "thresholds from BENCHMARKS.toml\n") +for (k in ordered_keys) { + cat(sprintf(" %-10s %s\n", k, format(thresholds[[k]]))) +} diff --git a/r-immunum/tools/validate-static.R b/r-immunum/tools/validate-static.R new file mode 100644 index 0000000..318b302 --- /dev/null +++ b/r-immunum/tools/validate-static.R @@ -0,0 +1,260 @@ +# Static validation: parse DESCRIPTION, NAMESPACE, R files, Rd files, +# the extendr shim crate manifest, and lib.rs source. Used in lieu of a +# full `R CMD check` when cargo isn't available locally. +# +# Run from the r-immunum/ directory: +# Rscript --vanilla tools/validate-static.R + +stopifnot(file.exists("DESCRIPTION"), file.exists("src/extendr/Cargo.toml")) + +ok <- TRUE +fail <- function(msg) { cat("FAIL:", msg, "\n"); ok <<- FALSE } + +# 1. DESCRIPTION +desc <- tryCatch(read.dcf("DESCRIPTION"), error = function(e) NULL) +if (is.null(desc)) { + fail("DESCRIPTION not parseable") +} else { + cat("OK: DESCRIPTION parses\n") + cat(" Package:", desc[, "Package"], "\n") + cat(" Version:", desc[, "Version"], "\n") + cat(" License:", desc[, "License"], "\n") +} + +# 2. R files parse +r_files <- list.files("R", pattern = "\\.R$", full.names = TRUE) +for (f in r_files) { + tryCatch({ + parse(f) + cat("OK: parse", f, "\n") + }, error = function(e) fail(sprintf("parse %s: %s", f, conditionMessage(e)))) +} + +# 3. Test files parse +test_files <- list.files("tests/testthat", pattern = "\\.R$", full.names = TRUE) +for (f in test_files) { + tryCatch({ + parse(f) + cat("OK: parse", f, "\n") + }, error = function(e) fail(sprintf("parse %s: %s", f, conditionMessage(e)))) +} + +# 4. NAMESPACE +ns_lines <- readLines("NAMESPACE") +exports <- grep("^export\\(", ns_lines, value = TRUE) +useDynLib <- grep("^useDynLib\\(", ns_lines, value = TRUE) +if (length(exports) == 0L) fail("NAMESPACE has no export() directives") +if (length(useDynLib) == 0L) fail("NAMESPACE missing useDynLib") +# Phase B: Annotator must be exported +if (!any(grepl("^export\\(Annotator\\)", ns_lines))) { + fail("NAMESPACE does not export Annotator") +} else { + cat("OK: NAMESPACE exports Annotator\n") +} +if (!any(grepl("^export\\(immunum_version\\)", ns_lines))) { + fail("NAMESPACE does not export immunum_version") +} else { + cat("OK: NAMESPACE exports immunum_version\n") +} +# Validation pipeline functions must be exported +validation_exports <- c( + "benchmark_threshold", + "validation_fixtures" +) +for (e in validation_exports) { + if (!any(grepl(sprintf("^export\\(%s\\)", e), ns_lines))) { + fail(sprintf("NAMESPACE does not export %s", e)) + } else { + cat("OK: NAMESPACE exports", e, "\n") + } +} +cat("OK: NAMESPACE has", length(exports), "export(s) and", + length(useDynLib), "useDynLib directive(s)\n") + +# 5. Cargo.toml at expected path +if (!file.exists("src/extendr/Cargo.toml")) fail("src/extendr/Cargo.toml missing") +if (!file.exists("src/extendr/src/lib.rs")) fail("src/extendr/src/lib.rs missing") +if (!file.exists("src/extendr/cargo-overrides.toml")) { + fail("src/extendr/cargo-overrides.toml missing (PyO3 macOS rustflags override)") +} + +cargo_toml <- readLines("src/extendr/Cargo.toml") +if (any(grepl('path\\s*=', cargo_toml))) { + fail("src/extendr/Cargo.toml has a path dep — should use crates.io for `immunum`") +} +if (!any(grepl('immunum\\s*=\\s*\\{\\s*version\\s*=', cargo_toml))) { + fail("src/extendr/Cargo.toml does not depend on `immunum` from crates.io") +} else { + cat("OK: src/extendr/Cargo.toml depends on `immunum` from crates.io\n") +} + +# 6. lib.rs has the Annotator impl + module registration +lib_rs <- readLines("src/extendr/src/lib.rs") +core_symbols <- c( + "fn immunum_version", + "struct Annotator", + "fn new\\(", + "fn number\\(", + "fn segment\\(", + "extendr_module!", + "impl Annotator;" +) +for (sym in core_symbols) { + if (!any(grepl(sym, lib_rs))) { + fail(sprintf("src/extendr/src/lib.rs missing symbol matching /%s/", sym)) + } else { + cat("OK: lib.rs has", sym, "\n") + } +} + +# lib.rs has the four rayon-parallel batch primitives + their +# extendr_module! lines, and the rust-side helpers they depend on. +batch_symbols <- c( + "fn numbering_batch\\(", + "fn numbering_batch_with\\(", + "fn segmentation_batch\\(", + "fn segmentation_batch_with\\(", + "fn numbering_batch;", + "fn numbering_batch_with;", + "fn segmentation_batch;", + "fn segmentation_batch_with;", + "use rayon::prelude::\\*;", + "use immunum::numbering::segment as segment_positions;" +) +for (sym in batch_symbols) { + if (!any(grepl(sym, lib_rs))) { + fail(sprintf("src/extendr/src/lib.rs missing symbol matching /%s/", sym)) + } else { + cat("OK: lib.rs has", sym, "\n") + } +} + +# 7. Rd files exist and check clean +rd_files <- list.files("man", pattern = "\\.Rd$", full.names = TRUE) +for (rd in rd_files) { + problems <- tryCatch(tools::checkRd(rd), error = function(e) { + fail(sprintf("checkRd crashed on %s: %s", rd, conditionMessage(e))) + NULL + }) + if (length(problems) == 0L) { + cat("OK: checkRd", rd, "\n") + } else { + fail(sprintf("checkRd %s reported %d issue(s)", rd, length(problems))) + for (p in problems) cat(" -", format(p), "\n") + } +} + +# 8. Annotator.Rd documents the four R6 methods (so R CMD check +# won't warn about undocumented arguments). The hand-written Rd lives at +# man/Annotator.Rd and intentionally lacks the `Generated by roxygen2` +# header -- see comment in R/annotator.R. +ann_rd <- readLines("man/Annotator.Rd") +if (any(grepl("^% Generated by roxygen2: do not edit by hand", ann_rd))) { + fail("man/Annotator.Rd carries roxygen2 generated header (will be clobbered)") +} +for (m in c("method-Annotator-new", "method-Annotator-number", + "method-Annotator-segment", "method-Annotator-print")) { + if (!any(grepl(m, ann_rd, fixed = TRUE))) { + fail(sprintf("man/Annotator.Rd missing %s anchor", m)) + } +} +cat("OK: man/Annotator.Rd documents all four R6 methods\n") + +# 9. helper-fixtures.R + INIT_CASES present so the parametrized +# test files can iterate. +fixtures <- readLines("tests/testthat/helper-fixtures.R") +for (k in c("INIT_CASES", "ALL_CHAINS_R", "IGH_SEQ", "TRA_SEQ")) { + if (!any(grepl(k, fixtures, fixed = TRUE))) { + fail(sprintf("tests/testthat/helper-fixtures.R missing %s", k)) + } +} +cat("OK: helper-fixtures.R defines INIT_CASES + chain constants\n") + +# 10. validation fixtures exist at ../fixtures/validation/ in +# the repo tree. Tests reference these directly (like the Python tests). +# We don't bundle them inside the package. +val_src <- readLines("R/validation.R") +stem_lines <- grep('"(ab|tcr)_', val_src, value = TRUE) +phase_d_stems <- unique(unlist(regmatches( + stem_lines, + gregexpr('"(ab|tcr)_[A-Z]_(imgt|kabat)"', stem_lines) +))) +phase_d_stems <- gsub('"', "", phase_d_stems) +if (length(phase_d_stems) == 0L) { + fail("R/validation.R does not list any validation fixture stems") +} +if (dir.exists("../fixtures/validation")) { + for (stem in phase_d_stems) { + p <- file.path("../fixtures/validation", paste0(stem, ".csv")) + if (!file.exists(p)) { + fail(sprintf("missing fixture %s in repo tree", p)) + } + } + cat("OK: repo fixtures/ has all", length(phase_d_stems), "validation CSV(s)\n") +} else { + cat("SKIP: ../fixtures/validation not found (not in repo tree)\n") +} + +# 11. BENCHMARKS.toml thresholds must match the baked-in +# .BENCHMARK_THRESHOLDS table -- catches drift after upstream rerun +# of the benchmark suite. Uses the same tiny TOML subset parser as +# tools/sync-benchmarks.R; both should stay in lockstep. +if (file.exists("../BENCHMARKS.toml")) { + toml <- readLines("../BENCHMARKS.toml") + section <- NULL + toml_thresholds <- list() + for (line in toml) { + line <- trimws(line) + if (!nzchar(line) || startsWith(line, "#")) next + m <- regmatches(line, regexec("^\\[(.+)\\]$", line))[[1]] + if (length(m) == 2L) { + section <- m[[2]] + next + } + if (!is.null(section)) { + kv <- regmatches(line, regexec("^perfect_pct\\s*=\\s*([0-9.]+)$", line))[[1]] + if (length(kv) == 2L) { + toml_thresholds[[section]] <- as.numeric(kv[[2]]) + } + } + } + baked <- list() + in_block <- FALSE + for (line in val_src) { + if (grepl("^\\.BENCHMARK_THRESHOLDS <- c\\(", line)) { + in_block <- TRUE + next + } + if (in_block) { + if (startsWith(trimws(line), ")")) break + kv <- regmatches( + line, + regexec("`([^`]+)`\\s*=\\s*([0-9.]+)", line) + )[[1]] + if (length(kv) == 3L) { + baked[[kv[[2]]]] <- as.numeric(kv[[3]]) + } + } + } + drift <- character() + for (k in union(names(toml_thresholds), names(baked))) { + a <- toml_thresholds[[k]] + b <- baked[[k]] + if (is.null(a) || is.null(b) || !isTRUE(all.equal(a, b))) { + drift <- c(drift, sprintf("%s (toml=%s, R=%s)", + k, format(a), format(b))) + } + } + if (length(drift) > 0L) { + fail(sprintf( + "R/validation.R thresholds drift from BENCHMARKS.toml: %s. Run tools/sync-benchmarks.R", + paste(drift, collapse = "; ") + )) + } else { + cat("OK: R/validation.R thresholds match BENCHMARKS.toml (", + length(baked), "keys)\n", sep = "") + } +} + +cat("\n", if (ok) "ALL CHECKS PASSED" else "SOME CHECKS FAILED", "\n", sep = "") +quit(status = if (ok) 0L else 1L) diff --git a/r-immunum/vignettes/getting-started.Rmd b/r-immunum/vignettes/getting-started.Rmd new file mode 100644 index 0000000..c62bd25 --- /dev/null +++ b/r-immunum/vignettes/getting-started.Rmd @@ -0,0 +1,106 @@ +--- +title: "Getting started with immunum" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Getting started with immunum} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>", + eval = FALSE +) +``` + +`immunum` is an R package for numbering antibody and T-cell receptor (TCR) +variable-domain sequences using IMGT and Kabat schemes. It wraps the +[immunum Rust crate](https://crates.io/crates/immunum) via +[extendr](https://extendr.github.io/) for high performance. + +## Installation + +Install from r-universe: + +```{r, eval = FALSE} +install.packages("immunum", + repos = c("https://enpicom.r-universe.dev", getOption("repos")) +) +``` + +## Numbering + +Create an `Annotator` and number a single sequence: + +```{r} +library(immunum) + +ann <- Annotator$new(chains = c("H", "K", "L"), scheme = "imgt") + +sequence <- paste0( + "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGG", + "VIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGT", + "TGKPIGAFAHWGQGTLVTVSS" +) + +result <- ann$number(sequence) +result$chain # "H" +result$confidence # 0.78 +result$numbering # named character: "1"="Q", "2"="V", ... +``` + +The `chains` argument controls which chain types are tried during alignment. +Chain aliases are case-insensitive — `"H"`, `"heavy"`, and `"IGH"` all +resolve to the same chain. + +### Supported chains + +| Antibody | TCR | +|---------------|--------------| +| IGH (heavy) | TRA (alpha) | +| IGK (kappa) | TRB (beta) | +| IGL (lambda) | TRD (delta) | +| | TRG (gamma) | + +### Numbering schemes + +- **IMGT** — all 7 chain types +- **Kabat** — antibody chains only (IGH, IGK, IGL) + +## Segmentation + +`segment()` splits the sequence into framework (FR) and +complementarity-determining (CDR) regions: + +```{r} +seg <- ann$segment(sequence) +seg$fr1 # "QVQLVQSGAEVKRPGSSVTVSCKAS" +seg$cdr1 # "GGSFSTYA" +seg$fr2 # "LSWVRQAPGRGLEWMGG" +seg$cdr2 # "VIPLLTIT" +seg$fr3 # "NYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYC" +seg$cdr3 # "AREGTTGKPIGAFAH" +seg$fr4 # "WGQGTLVTVSS" +``` + +Prefix and postfix residues that fall outside the numbered domain are +returned in `seg$prefix` and `seg$postfix`. + +## Validation + +immunum provides access to benchmark accuracy thresholds from the +upstream test suite: + +```{r} +# List all known validation fixtures +validation_fixtures() + +# Look up the benchmark-suite accuracy threshold +benchmark_threshold("imgt.H") # 99.88 +``` + +The validation fixture CSVs (reference numberings for ~10k sequences) +live at `fixtures/validation/` in the source repo and are available +when running tests from the repo tree -- same as the Python package.