Skip to content

chore: swap build backend from setuptools to maturin - #975

Open
SkyeAv wants to merge 22 commits into
mainfrom
feat/migrate-to-maturin-build-backend
Open

chore: swap build backend from setuptools to maturin#975
SkyeAv wants to merge 22 commits into
mainfrom
feat/migrate-to-maturin-build-backend

Conversation

@SkyeAv

@SkyeAv SkyeAv commented Jul 27, 2026

Copy link
Copy Markdown
Member

Swaps the build backend from setuptools to maturin so Babel builds as a mixed Rust/Python
package, ahead of landing native (Rust) functionality. This is the backend swap only — the Rust
side is an inert placeholder module, the importable package stays src, and nothing under it
moves or renames.

Update (2026-08-10): The module is no longer an inert placeholder — it now ships its first real
accelerator: a Rust union-find that replaces babel_utils.glom(), the O(N²) clique-builder behind
the ~5.5 h single-core protein_compendia/chemical_compendia rules. It is Rust-only: the
BABEL_DISABLE_RUST env var and the Python fallback are removed, the Python glom is deleted, and the
A/B-testing language is stripped from the docs/tests. ABI_VERSION is now 2 (rust-version 1.80). All
~12 callers are unchanged (the Rust glom is re-exported from src.babel_utils). Offline benchmark:
~103× faster than the Python glom on a 6,000-merge chain (12,000 pairs) with identical cliques; the
speedup grows with N (9× → 100×), the signature of removing the O(N²). 449 unit tests pass; cargo
fmt/clippy/test, ruff, and rumdl clean. Full details in the review comment below.

Update (2026-07-30): #987 is merged into
this branch and supersedes some placeholder details below — the module is renamed rs_accel
(import via src/accel.py, with a Python fallback and a stale-ABI guard), Docker/CI use rustup
pinned to Rust 1.95.0 (not apt cargo / @stable), and the formatting lint runs uv run --no-project so it doesn't build. See #987 for those specifics.

Build backend

  • pyproject.toml: [build-system] is now requires = ["maturin>=1.0,<2.0"] /
    build-backend = "maturin"; dropped [tool.setuptools.packages.find].
  • [tool.maturin]: module-name = "src.rs" places the compiled extension at src/rs.* and
    tells maturin to bundle the src package (auto-resolved to the repo root — src-layout detection
    doesn't fire because there's no src/babel_pipeline/__init__.py); manifest-path = "rust/Cargo.toml" keeps Cargo off the Python src/ dir.
  • dev group: added maturin>=1.0,<2.0 so uv run maturin develop works locally (the
    [build-system].requires entry is build-isolation only).

Rust stub

  • rust/Cargo.toml + rust/src/lib.rs: empty #[pymodule] fn rs (pyo3, abi3-py311 +
    extension-module), importable as from src import rs. Real functionality lands in a follow-up.
  • Layout: Rust lives under rust/ so Cargo's default src/lib.rs never collides with the
    Python src/ package.

Build prerequisites (CI + Docker)

  • .github/workflows/test.yml: added dtolnay/rust-toolchain@stable before uv sync in both
    jobs — every uv sync now compiles via cargo, and ubuntu-latest ships no Rust.
  • Dockerfile: apt-get install -y cargo (system-wide, before USER nru) for the same reason.
  • Accepted caveat (softer than it first reads): a Rust toolchain is a build prerequisite, but uv
    bootstraps one itself (via puccinialin) when cargo is absent — a silent ~600 MB download into a
    platform cache, not a failure. The one place it genuinely fails is uv sync --frozen on the
    Hatteras login node (see slurm/README.md); CI and Docker install rustup explicitly.

Housekeeping

  • .gitignore: ignore rust/target/ and the maturin develop binaries
    (src/rs.*.so/.pyd/.dylib).
  • uv.lock + rust/Cargo.lock: regenerated and committed (CI runs uv sync --frozen).

Behavior change (worth knowing)

  • The wheel now ships the 19 .snakefiles. main's setuptools wheel shipped zero non-Python
    files, so an installed babel-pipeline previously could not run the pipeline; maturin bundles all
    of src/*, which includes the .snakefiles. An accidental fix, but a behavior change.

Design

  • Why maturin needs a stub: maturin is fundamentally a Rust build tool — every layout it
    supports requires a Cargo.toml, so a bare backend swap with no Rust won't build. The empty
    #[pymodule] is the smallest thing that makes maturin viable now.
  • Why module-name = "src.rs": the import package is literally named src (flat layout).
    Verified against maturin's resolver (project_layout.rs / module_writer/mod.rs): this bundles
    all of src/* and drops the extension at src/rs.<so> without moving or renaming anything.
  • Deferred: real Rust functionality; a manylinux/abi3 publishing matrix (the stub builds a
    local cp311-abi3 wheel).

Testing

  • uv sync — builds babel-pipeline via maturin → cargo; installs maturin==1.14.1.
  • uv run python -c "import src; from src import rs; from src.tools.clique_diff import cli" — ok.
  • uv run pytest -m unit -q402 passed, 105 deselected.
  • uv buildbabel_pipeline-1.17-cp311-abi3-linux_x86_64.whl; unzip -l shows src/__init__.py,
    src/node.py, src/tools/..., src/rs.abi3.so, and all 4 console scripts in entry_points.txt.
  • uv run babel-clique-diff --help — resolves.

Questions for the reviewer

  • Rust toolchain as a prerequisite. Every build invokes cargo, but uv self-bootstraps a toolchain
    (~600 MB) when it's missing, so the practical cost is a one-time download, not a hard blocker —
    except uv sync --frozen on Hatteras, which does fail without a toolchain. Fine for a stub-only
    PR, or would you rather gate the maturin switch behind the PR that adds real Rust?

Replace the setuptools build backend with maturin so the project builds as a
mixed Rust/Python package. This is the backend swap only — the Rust side is an
empty placeholder module (src.rs, importable as `from src import rs`) that real
functionality will fill in via a follow-up.

- pyproject.toml: build-system -> maturin; drop [tool.setuptools.packages.find];
  add [tool.maturin] (module-name = "src.rs", manifest-path = "rust/Cargo.toml");
  add maturin to the dev dependency group.
- rust/: new Cargo.toml + src/lib.rs stub (empty #[pymodule] `rs`, pyo3 abi3).
  Rust source lives under rust/ to avoid colliding with the Python src/ dir.
- .gitignore: ignore rust/target/ and the maturin-develop .so/.pyd/.dylib.
- CI/Docker: install a Rust toolchain (dtolnay/rust-toolchain in test.yml, apt
  cargo in Dockerfile) since every `uv sync` now compiles via cargo.
- uv.lock + rust/Cargo.lock regenerated.

The importable package stays `src`; nothing under src/ moves or renames, and all
console scripts and subpackages are preserved (verified: 402 unit tests pass;
wheel bundles src/* plus src/rs.abi3.so).
@SkyeAv SkyeAv added enhancement New feature or request Priority: High dependencies Pull requests that update a dependency file python:uv Pull requests that update python:uv code testing Related to the test suite or testing infrastructure github_actions Pull requests that update GitHub Actions code developer tooling Tooling & workflow that helps develop, debug, and validate Babel without changing pipeline outputs labels Jul 27, 2026
@SkyeAv

SkyeAv commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

This would allow me to expose rust code directly to python. This backend is the base commit I need before I can start refactoring in that direction

@SkyeAv SkyeAv self-assigned this Jul 28, 2026
@SkyeAv SkyeAv added the structural Changes to the structure of Babel in order to apply DRY and to make future development easier. label Jul 28, 2026
astral-sh/ruff-action@v3 floats to the latest ruff, now 0.16.0 (pyproject
pins only a floor, >=0.14.14). Ruff 0.16.0 formats Python code fences inside
*.md, which overlaps with the dedicated rumdl Markdown check and was failing
'Check Python formatting with ruff' on every open PR. Exclude *.md from ruff
format so ruff stays scoped to Python and Markdown formatting CI is
deterministic across ruff releases.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR switches Babel’s packaging/build backend from setuptools to maturin so the project can build as a mixed Python/Rust distribution, while keeping the importable Python package layout unchanged (src remains the top-level package). It adds a minimal Rust/PyO3 stub extension to satisfy maturin’s requirements and updates CI/Docker to include a Rust toolchain for builds.

Changes:

  • Swap PEP 517 build backend to maturin and configure it to bundle the existing src Python package while emitting an extension module at src/rs.*.
  • Add a placeholder Rust crate (rust/) with an empty PyO3 #[pymodule] importable as from src import rs.
  • Update CI and Docker build prerequisites to install Rust, and add ignores/locks for new build artifacts and dependencies.

Reviewed changes

Copilot reviewed 5 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pyproject.toml Switch build backend to maturin; configure [tool.maturin]; add dev dependency; adjust ruff formatter config.
uv.lock Record maturin in the dev dependency set and lock resolution.
rust/Cargo.toml Define the Rust crate and PyO3 dependency for the placeholder extension module.
rust/src/lib.rs Add the empty #[pymodule] fn rs stub importable from Python.
rust/Cargo.lock Commit the Rust dependency lockfile for reproducible cargo builds.
.github/workflows/test.yml Install a Rust toolchain in CI before uv sync so maturin/cargo builds can run.
Dockerfile Install cargo in the container image so uv sync can build the maturin extension.
.gitignore Ignore Rust build outputs and local maturin develop-produced extension binaries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Dockerfile Outdated
Comment thread .github/workflows/test.yml Outdated
gaurav and others added 3 commits July 29, 2026 02:39
Renames the module from `rs` to `_accel`: the name should say what it is (a
compiled accelerator) rather than what it is written in, and while the module
is empty this rename is as cheap as it will ever be.

Adds `src/accel.py` as the only thing that imports the compiled module, because
the two failure modes need opposite handling and neither is obvious at the call
site:

  - A *missing* extension falls back to Python and logs at INFO. Every
    snakefile does a top-level `import src.foo` at DAG-parse time, so raising
    would take down all 245 rules for a contributor without a Rust toolchain,
    for a reviewer, and for a fork's CI -- not just the rules that would have
    used Rust. AGENTS.md's "a log warning is not a control" is about wrong
    output; a slower path emitting identical bytes is not that.
  - A *stale* extension raises. The extension is installed editable, so the
    compiled artifact sits in the checkout at src/_accel.*.so and a `git pull`
    does not rebuild it. ABI_VERSION in rust/src/lib.rs is compared against
    _REQUIRED_ABI_VERSION at import -- i.e. at DAG-parse time -- so a stale
    build fails in the first second rather than partway through a 12h rule.
    The check is a separate function so it can be tested against a stub;
    building a genuinely stale extension inside a unit test would mean
    compiling Rust.

BABEL_DISABLE_RUST=1 forces the Python path, which is what makes an A/B
measurement possible in one checkout. An environment variable rather than a
config.yaml entry: which of two byte-identical implementations runs has no
user-facing meaning, and config.yaml is threaded into Snakemake params and
output paths where changing it could perturb a running DAG. Precedent is
BABEL_DUCKDB_TEMP_DIR.

Also: rust/Cargo.toml's version becomes 0.0.0 (maturin takes the distribution
version from pyproject.toml -- the wheel is still babel_pipeline-1.17-cp311-abi3
-- and nothing publishes this crate, so a second version number is only a thing
to forget to bump); rust-version declares pyo3 0.23's 1.63 floor;
rust-toolchain.toml pins the channel; and `[tool.maturin] exclude` stops the
four per-directory CLAUDE.md agent-instruction files shipping to anyone who
installs the wheel. Verified by rebuilding: the wheel now holds src/_accel.abi3.so,
src/_accel.pyi, 98 .py files and the 19 .snakefiles, and no CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since the maturin swap, `[tool.uv] package = true` means every `uv run` and
`uv sync` builds the project, and building the project invokes cargo. Three
places had not caught up:

  - check-formatting.yml's snakefmt job runs `uv run`, with no toolchain step.
    It would have passed by accident, because ubuntu-latest happens to ship
    Rust -- which is worse than failing, since it makes a lint job depend on
    the runner image. Fixed by not building at all: snakefmt does not need
    babel-pipeline installed, so `uv run --no-project --with snakefmt`.
  - The Dockerfile installed Debian bookworm's cargo, which is 1.63 -- exactly
    pyo3 0.23's minimum. It builds today and breaks on the next pyo3 bump with
    an error that reads as unrelated. Swapped for rustup, which also honours
    rust-toolchain.toml (apt's cargo ignores it).
  - Nothing in slurm/, kubernetes/ or docs/ mentioned Rust at all, while
    `uv sync --frozen` on the Hatteras login node now fails outright without a
    toolchain -- before Snakemake starts. slurm/README.md now says so, and
    notes that uv's own fallback downloads ~600 MB into a cache directory the
    UV_CACHE_DIR override in run-babel-on-slurm.sh does not cover.

Adds a cargo fmt/clippy job mirroring the one-linter-per-job structure of the
other three, so "all four linters checked in CI" does not quietly become four
of five languages, plus Swatinem/rust-cache so every PR does not rebuild pyo3
from scratch.

docs/Rust.md is the reference: why targets come from a run's benchmark: TSVs
rather than from reading code, why the FFI boundary is one call per file and
never one per row, the fallback and staleness contracts, how to build in each
environment, and what PR #588's 19 standalone binaries got wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Address Copilot review feedback on PR #975:

- Add a checked-in rust-toolchain.toml pinning channel = "1.95.0" (the
  toolchain used locally) so local builds, CI, and any rustup-based
  environment compile the maturin/PyO3 extension with the same compiler.
- Pin the CI action ref dtolnay/rust-toolchain@stable -> @1.95.0 in both
  test jobs so CI no longer tracks the moving stable channel; the ref and
  rust-toolchain.toml are cross-referenced to be bumped together.
- Dockerfile: uv sync -> uv sync --frozen so the image installs exactly the
  committed uv.lock (no re-resolution), matching CI and keeping builds
  reproducible now that the step compiles Rust via maturin/cargo.
@SkyeAv

SkyeAv commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Addressed both Copilot review points in cc0192d:

Reproducible Rust toolchain (.github/workflows/test.yml)

  • Added a checked-in rust-toolchain.toml pinning channel = "1.95.0" (the toolchain used locally), so local builds, CI, and any rustup-based environment all compile the maturin/PyO3 extension with the same compiler.
  • Pinned the CI action ref dtolnay/rust-toolchain@stable@1.95.0 in both test jobs, so CI no longer tracks the moving stable channel. The action ref and rust-toolchain.toml are cross-referenced (comment on each) to be bumped together.

Lockfile-reproducible Docker build (Dockerfile)

  • uv syncuv sync --frozen, so the image installs exactly the committed uv.lock with no re-resolution — matching CI's uv sync --frozen. This matters more now that the step also compiles Rust via maturin/cargo.

Verified locally: rustup show active-toolchain reports 1.95.0-x86_64-unknown-linux-gnu (overridden by rust-toolchain.toml), and both cargo check and a full uv run maturin rebuild of the extension succeed under the pin.

SkyeAv added 4 commits July 30, 2026 10:43
Combines the base branch's exact Rust 1.95.0 pin (cc0192d) with this
branch's toolchain-prerequisite work: keep channel=1.95.0 and
dtolnay/rust-toolchain@1.95.0, add Swatinem/rust-cache@v2, and in the
Dockerfile keep both the rustup install and 'uv sync --frozen'.
…se maturin build gaps

> **Targets #975's branch, not `main`.** @SkyeAv — this is offered *to*
you, to merge into #975 if you like it, or ignore. It does not modify
your commits. Written by Claude at @gaurav's request; @gaurav has
reviewed the outcome but not line-by-line.

Two things: hardening the extension's contract, and closing three gaps
where the maturin swap has consequences that had not been followed
through.

## Naming and the two failure modes

**`rs` → `_accel`.** The name should say what it is (a compiled
accelerator), not what it is written in. While the module is empty this
rename is as cheap as it will ever be — not worth a fight if you prefer
`rs`.

**`src/accel.py` is now the only thing that imports the compiled
module**, because the two failure modes need opposite handling and
neither is obvious at the call site:

- **A missing extension falls back to Python** and logs at INFO. Every
snakefile does a top-level `import src.foo` at DAG-parse time, so
raising would take down **all 243 rules** for a contributor without a
Rust toolchain, for a reviewer, and for a fork's CI — not just the rules
that would have used Rust. AGENTS.md's "a log warning is not a control"
is about *wrong output*; a slower path emitting identical bytes is not
that.
- **A stale extension raises.** The extension is installed editable, so
the compiled artifact sits in the checkout at `src/_accel.*.so` and a
`git pull` does **not** rebuild it. `ABI_VERSION` is compared against
`_REQUIRED_ABI_VERSION` at import — i.e. at DAG-parse time — so a stale
build fails in the first second rather than partway through a 12 h rule.
The check is a separate function so it can be tested against a stub;
building a genuinely stale extension inside a unit test would mean
compiling Rust.

`BABEL_DISABLE_RUST=1` forces the Python path, which is what makes an
A/B measurement possible in one checkout. An environment variable rather
than a `config.yaml` entry: which of two byte-identical implementations
runs has no user-facing meaning, and `config.yaml` is threaded into
Snakemake `params` and output paths where changing it could perturb a
running DAG. Precedent is `BABEL_DUCKDB_TEMP_DIR`.

All three paths verified by hand, not just by test.

## Three gaps the maturin swap opens

Since `[tool.uv] package = true`, every `uv run` and `uv sync` builds
the project, and building it invokes cargo.

- **`check-formatting.yml`'s snakefmt job runs `uv run` with no
toolchain step.** It would have passed *by accident*, because
`ubuntu-latest` ships Rust — which is worse than failing, since it makes
a lint job depend on the runner image. Fixed by not building at all: `uv
run --no-project --with snakefmt`.
- **The Dockerfile installed Debian bookworm's cargo, which is 1.63 —
exactly pyo3 0.23's minimum.** It builds today and breaks on the next
pyo3 bump with an error that reads as unrelated. Swapped for rustup,
which also honours `rust-toolchain.toml` (apt's cargo ignores it).
- **Nothing in `slurm/`, `kubernetes/` or `docs/` mentioned Rust**,
while `uv sync --frozen` on the Hatteras login node now **fails
outright** without a toolchain — before Snakemake starts.
`slurm/README.md` now says so, and notes that uv's own fallback
downloads ~600 MB into a cache directory the `UV_CACHE_DIR` override in
`run-babel-on-slurm.sh` does *not* cover.

## Housekeeping

- `cargo fmt`/`clippy` job mirroring the one-linter-per-job structure of
the other three, so "all four linters checked in CI" does not quietly
become four of five languages. Plus `Swatinem/rust-cache` so every PR
does not rebuild pyo3.
- `rust/Cargo.toml` version → `0.0.0`: maturin takes the distribution
version from `pyproject.toml` (the wheel is still
`babel_pipeline-1.17-cp311-abi3`) and nothing publishes this crate, so a
second version number is only a thing to forget to bump. `rust-version`
declares pyo3's 1.63 floor; `rust-toolchain.toml` pins the channel.
- **`[tool.maturin] exclude` stops the four per-directory `CLAUDE.md`
agent-instruction files shipping to anyone who installs the wheel.**
Verified by rebuilding: the wheel holds `src/_accel.abi3.so`,
`src/_accel.pyi`, 98 `.py` files and the 19 `.snakefile`s, and no
`CLAUDE.md`.

## One thing worth flagging about #975 itself

Comparing wheels: `main`'s ships **zero** non-Python files, while the
maturin wheel adds the 19 `.snakefile`s. That is an accidental **fix** —
an installed `babel-pipeline` previously could not run the pipeline —
but it is a behaviour change worth knowing about.

Also, the "Rust toolchain is now a build prerequisite" caveat in #975's
description is a little stronger than reality: **uv bootstraps a
toolchain itself** (via `puccinialin`) when cargo is missing. Verified
by building with no cargo on `$PATH`. The real cost is a silent ~600 MB
download, not a failure — except on Hatteras, where `--frozen` does
fail.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
The comments in rust/src/lib.rs and src/accel.py said a hard import
failure would 'take down all 245 rules'. The exact count is brittle
(verified 244 run: + 26 shell: on main) and adds nothing: a top-level
import failure at DAG-parse time takes down the whole parse, i.e. every
rule. Wording it as 'every rule' cannot go stale.
The maturin swap (this branch) bundles all of src/* into the wheel,
which includes the 19 .snakefile files; main's setuptools wheel shipped
zero non-Python files, so an installed babel-pipeline previously could
not run the pipeline. Record this accidental fix as a one-line general
release note so the next release-notes author picks it up.
SkyeAv added 2 commits July 30, 2026 10:58
The cargo fmt/clippy job used dtolnay/rust-toolchain@stable, which
installed rustfmt+clippy for 'stable'. But rust-toolchain.toml pins
channel=1.95.0, so when cargo ran it switched to 1.95.0 -- a toolchain
without those components -- and failed with "'cargo-fmt' is not
installed for the toolchain '1.95.0'". Pin the action to 1.95.0 (as
test.yml already is) so the components are installed for the toolchain
cargo actually uses.
@SkyeAv

SkyeAv commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Status on the two branches offered into this PR

#987 (Rust plumbing) — merged (b10b48a4). Hardens the extension contract: single import point
src/accel.py with a Python fallback + stale-ABI guard, BABEL_DISABLE_RUST, the three maturin
build-gap fixes (rustup in Docker, --no-project lint, slurm/docs notes), and cargo fmt/clippy +
rust-cache CI. Module renamed rs_accel. Conflicts resolved by keeping the Rust 1.95.0 pin and
adding Swatinem/rust-cache.

#988 (boundary-cost experiment) — closed unmerged. Its headline — "in-process can save at most
~4 min / 1.3%, a ceiling no pyo3 can beat" — bounds only the cost of a single Python↔Rust handoff,
and benchmarks a misuse of Rust (one function converted then reserialized, single-threaded). Verified
empirically: chaining heavy routines on native data skips the "unavoidable floor" (0.67 s vs 12.4 s,
−95%), and py.allow_threads gives near-linear multi-core scaling in-process (~10× at 16 threads vs
~1× GIL-held). Both patterns are already in production in Tablassert. Full pushback lives on #988.

Decision: in-process pyo3 (this PR) is the default mechanism — less code per accelerated routine
and no new file formats, shell: rules, or binaries; out-of-process (B/C) reserved for stages already
split at a file boundary.

Follow-ups harvested from #988 (no Rust needed): #996 (JSONL, not repr(set), for the clique
boundary), #997 (cache get_biolink_model_toolkit), #998 (read icRDF.tsv once). Two more Rule 0
fixes are already open: #984, #985.

CI is green (ruff, snakefmt, cargo fmt/clippy, rumdl, unit-tests).

Comment thread rust/src/lib.rs Outdated
///
// ponytail: a hand-bumped integer. Move to a build-time hash of this crate's sources if anyone
// forgets to bump it twice.
const ABI_VERSION: u32 = 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Do we really need this? I don't think recompiling the Rust code for every Babel build will be a huge imposition.
  2. Is there any value for storing this in config.yaml rather than here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. It's not about build cost — it's about the editable install. The compiled .so sits in the checkout (src/_accel.*.so, gitignored), and a git pull that brings new Rust source rebuilds nothing (uv sync only rebuilds when pyproject.toml/uv.lock change). Without the guard, a stale binary silently runs 12-hour rules; with it, the run fails at DAG-parse in the first second, with the fix command in the message. Cost is one integer comparison at import, bumped in the same commit as the change that stales the binary.
  2. No — the check compares what was baked into the .so at compile time against what the Python checkout expects; config.yaml is runtime data and can't detect a stale binary. The two halves (here and _REQUIRED_ABI_VERSION in src/accel.py) also need to move in one commit. Same reason BABEL_DISABLE_RUST isn't in config.yaml: config is threaded into Snakemake params/output paths, and an implementation detail shouldn't be able to perturb the DAG.

The prior commit dropped the "keep a byte-identical Python reference"
rule from rust/README.md but left it verbatim in AGENTS.md and
src/accel.py's docstring, and left dangling docs/Rust.md /
docs/README.md links after the rust/ rename. Also restores
BABEL_DISABLE_RUST documentation, reworded as the A/B tool for proving
a port before deleting its Python original -- the env var and its test
are still live in code, just no longer documented.
Comment thread tests/test_accel.py Outdated
the bump was forgotten; failing locally means the checkout needs
`uv sync --reinstall-package babel-pipeline`.
"""
if src.accel.accel is None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand -- shouldn't this be built on uv sync? I'm concerned that this test will start skipping at some point but we won't notice it -- a failure would be better IMO.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — [tool.uv] package = true means every uv sync builds the extension (uv even bootstraps a toolchain if cargo is missing), and CI always has it, so this test runs there today. But your concern is right: if the build ever silently regressed, the skip would hide it. The no-extension fallback is covered separately (test_a_missing_extension_falls_back_instead_of_raising fakes the absence), so I've removed the skip — the test now fails instead: 6ebd4e4.

Comment thread rust/README.md Outdated
return _read_something_python(path)
```

`accel` is the compiled module when it is present, importable and current, and `None` otherwise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any case where accel is not present?

@gaurav gaurav left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks so much for all of this work, Skye! Would it be possible to include an example of Rust usage in this file so I can better see how a Rust rule could be incorporated into Snakemake? If there isn't an obvious candidate to pull in from #588, I wondered if #1004 might be a good candidate for Rustification: there's a bunch of different places where the text validation could go, so even if it doesn't make sense to replace to *Factory classes with something better (either entirely in Python or with some Rust code), it might make sense to add a check_encoding(output.synonyms_file) every time we create a synonyms to check the format. What do you think? Feel free to pick another function to Rustify or we can discuss this in person if useful -- I wouldn't try rustifying glom() or write_compendia(), which are probably the top candidates for this, because they're crucial functions that don't have any (?) tests yet and so introducing bugs in them would be baaaad, or the PubMed import, which I'm writing a complete replacement for in https://github.com/TranslatorSRI/pubmed2db as per NCATSTranslator/Core-Components-Working-Group#15. Let me know what you think! If you think this first-Rust-function would make more sense to do in a separate PR, then I'm happy to merge this for now and we can figure that out there, but I think it would be useful to put this example next to the new Rust documentation and pyproject.toml/Cargo settings we are developing here.

gaurav and others added 3 commits August 5, 2026 04:08
Replaces the Python babel_utils.glom() with a union-find over interned CURIE
ids (path compression + union by size) exposed as _accel.glom and re-exported
from src.babel_utils, so every caller is unchanged. This removes the O(N^2) of
copying the whole merged clique on every merge; the unique_prefixes,
KEGG/PUBCHEM, and close checks are maintained incrementally per root instead.
Merged cliques grow the larger live Python set in place (reassigning only the
smaller side's dict entries), and a cross-call cache stops repeated glom calls
on the same dict from re-reading/re-interning the whole state.

Rust-only: removes BABEL_DISABLE_RUST and the missing-extension fallback,
deletes the Python glom, and strips the A/B language from the docs. Bumps
ABI_VERSION 1->2 and rust-version to 1.80 (LazyLock for the cache).

Offline benchmark vs the git-extracted Python glom: ~103x faster on a
6,000-merge chain (12,000 pairs) with identical cliques; the speedup grows
with N (9x -> 25x -> 71x -> 100x), the signature of removing the O(N^2).
449 unit tests pass; cargo fmt/clippy/test, ruff, and rumdl all clean.
…ckend' into feat/migrate-to-maturin-build-backend

# Conflicts:
#	rust/README.md
@SkyeAv

SkyeAv commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

First real _accel accelerator: glom() union-find (commit 53ed067)

This replaces the Python babel_utils.glom() clique-builder with a Rust union-find, and makes the
extension Rust-only. Details below.

Why

glom() is the union-find in every compendium build. The Python version is O(clique) per merge
it copies the whole merged clique (set().union(...)), re-scans it for the unique_prefixes /
KEGG / PUBCHEM / close checks, and reassigns every member — so a clique growing to N costs
O(N²). That's a large share of the ~5.5 h, single-core, 246–335 GB protein_compendia /
chemical_compendia rules.

What changed

  • New rust/src/glom.rs: disjoint-set forest over interned u32 CURIE ids — path compression +
    union by size; constraint checks (unique-prefix counts, garbage flag, close) maintained
    incrementally per root → O(#unique_prefixes), not O(clique).
  • Serialization minimizers: merged cliques grow the larger live Python set in place (only the
    smaller side's dict entries are reassigned → O(N log N) writes), and a cross-call cache keyed by the
    dict stops repeated calls from re-reading/re-interning the whole state.
  • Parallel where sound: the initial CURIE interning runs on rayon over a sharded map. The merge
    loop stays sequential because rejection is order-sensitive (reordering would change the output).
  • Rust-only (per direction): removed BABEL_DISABLE_RUST and the missing-extension fallback from
    src/accel.py; deleted the Python glom (−135 lines); stripped the A/B language from
    rust/README.md, AGENTS.md, src/accel.py, and lib.rs. Correctness is guarded by tests, not a
    fallback. ABI_VERSION 1→2; rust-version 1.63→1.80 (LazyLock).

The drop-in contract is preserved: it mutates conc_set in place, returns None, accepts 1–2 element
groups of str/LabeledID, and takes unique_prefixes as any iterable (a bare string is iterated
per-char, exactly as before — test_uberon relies on it). All ~12 callers are unchanged.

Correctness notes

  • Rejected groups don't add new members (faithful to Python). Callers pre-register identifiers
    first, so this only affects never-registered ids; both behaviours are pinned by tests.
  • A bug the cache exposed (fixed): the all-new-clique branch reset the keeper's up_count then
    re-added up_count[id] for the keeper itself (now zero), silently dropping its unique-prefix
    contribution and defeating rejection. The rebuild-per-call path masked it; the cache surfaced it via
    test_mp_included_in_unique_prefixes_blocks_same_prefix_merge. Fixed by computing each member's
    contribution from its prefix.

Benchmark (offline, synthetic)

data/benchmark_glom.py races the git-extracted Python glom vs the Rust one on identical inputs
(one long merge chain + noise, 8 batches on the same dict) and asserts the cliques match. Machine:
24-core AMD Ryzen AI 9 HX 370, debug-profile Rust build (conservative).

chain pairs python rust speedup match
1500 7500 0.416s 0.046s 9.1× True
3000 9000 1.667s 0.067s 24.8× True
4500 10500 4.013s 0.056s 71.3× True
6000 12000 6.921s 0.069s 99.9× True

Headline (chain=6000): Python 7.0s → Rust 0.07s (~103×), identical 3,001 cliques. The speedup
grows with N while Rust stays near-flat — the signature of removing the O(N²). At production scale
the quadratic term dominates more, so the real win should be larger; #990 (profile on real data) should
confirm where those rules are actually bound.

Verification (all offline — no BABEL run)

  • uv run pytest -m unit449 passed (was 437; +12 new glom semantics tests). The suite exercises
    glom through its real callers — the regression net now that there's no Python reference.
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings (0 warnings), cargo test — clean.
  • ruff check ., ruff format --check, rumdl (0.2.50, as pinned in CI) — clean.
  • git grep BABEL_DISABLE_RUST → none outside scratch notes.

Questions

  • The cross-call cache keys on the dict's address and rebuilds on entry-count/unique_prefixes
    mismatch; it assumes a dict is only mutated through glom (true today). OK with that invariant, or
    prefer the simpler rebuild-every-call (still correct, just re-reads state per call)?
  • Benchmark is a laptop-scale cost model. Want the coarse builder that also fuses file reading
    (read_concord_file / remove_overused_xrefs) for the hottest callers in this PR, or as a follow-up?

@SkyeAv
SkyeAv requested a review from gaurav August 10, 2026 21:37
@SkyeAv

SkyeAv commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

@gaurav LMK if you want any addtional changes. I drafted this with an LLM and had it benchmark the optimizations to glom and it seems pretty promising.

@SkyeAv

SkyeAv commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

@gaurav this ran successfully at /projects/babel/runs/goetzs/RUST-GLOM-1.18 on HT1. I don't know how much faster it really was of the top of my head but I was hoping you could let me know where to check the speed. I'll say it did seem MUCH faster running it though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file developer tooling Tooling & workflow that helps develop, debug, and validate Babel without changing pipeline outputs enhancement New feature or request github_actions Pull requests that update GitHub Actions code Priority: Critical python:uv Pull requests that update python:uv code structural Changes to the structure of Babel in order to apply DRY and to make future development easier. testing Related to the test suite or testing infrastructure

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

3 participants