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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/r-check.yml
Original file line number Diff line number Diff line change
@@ -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
82 changes: 78 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/).

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
```

<details>
<summary>Building from source</summary>

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
```

</details>

### 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
Expand Down Expand Up @@ -167,7 +230,7 @@ Add to `Cargo.toml`:

```toml
[dependencies]
immunum = "0.9"
immunum = "1.1"
```

### Usage
Expand Down Expand Up @@ -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
Expand All @@ -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

```
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions benches/bench_scaling.R
Original file line number Diff line number Diff line change
@@ -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))
Loading