\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.