Workspace architecture
How the M0 scaffold is wired, and what each choice costs.
Shape
Cargo.toml workspace root: shared version, lints, deps
├── crates/ligase-core/ [package] name = "ligase" -- the engine
└── crates/ligase-py/ [lib] name = "_core" -- cdylib bindings
pyproject.toml maturin backend, dist "ligase-bio"
python/ligase/ python-source rootOne version number lives in [workspace.package] and everything inherits it
with version.workspace = true. The Python distribution takes it too, via
dynamic = ["version"], which maturin fills from the crate it builds. There
is exactly one place to bump a release, and no way for the Rust and Python
halves to disagree.
That last part is tested rather than assumed:
def test_version_matches_workspace_manifest() -> None:
import ligase
assert ligase.__version__ == workspace_version() # parsed from Cargo.toml
def test_core_version_comes_from_rust() -> None:
import ligase
assert ligase._core.version() == ligase.__version__The second test is the one that matters. It would still pass if
__version__ were hardcoded to the right string — so it is paired with the
first, which reads the manifest. Together they fail if the Python package
reports a version the engine was not built from.
The release, and the build
One number is not enough, because the manifest’s number is bumped by a
release commit. For every commit between one release and the next, a
source build reads the number of the release it followed — and that string
is not only printed. It is the engine’s half of a hivdb step’s tool
version, so it reaches the cache key, and a branch build that answers
differently from the published release shared a key with it.
So there are two questions and two answers:
| Answers | Used by | |
|---|---|---|
ligase::version() | 0.4.0 | wheel metadata, lg.__version__, a dependency floor |
ligase::build() | 0.4.0+13.g0c5980e | the rule fingerprint, tool="ligase", the provenance record, the self-citation |
build.rs asks git describe --tags --always --dirty=.dirty and passes the
raw answer through as LIGASE_GIT_DESCRIBE; build_version in the library
composes it, and is a pure function so that every case — at the tag, past
the tag, dirty, no tag in reach, no repository at all — is tested without a
repository to arrange. The marker is a local version part, after a single
+, so the string stays a version a methods paragraph can print.
Three things it cannot know, and they are in build.rs rather than only
here: a build from an sdist or from crates.io has no repository to ask and
falls back to the manifest version (LIGASE_GIT_DESCRIBE is how a packager
says otherwise); Cargo reruns a build script only when a file it was told to
watch changes, so a marker can sit a commit behind until the crate is
rebuilt for another reason; and this is a description of the source, not a
hash of the binary, so two builds of one commit with different features
report the same string.
The build
maturin is the PEP 517 backend. The relevant configuration:
[tool.maturin]
manifest-path = "crates/ligase-py/Cargo.toml"
module-name = "ligase._core"
python-source = "python"This is maturin’s mixed layout: a hand-written Python package with a
compiled module dropped inside it. python-source = "python" tells maturin
the package root is python/, and module-name places the shared object at
python/ligase/_core.abi3.so. The result is that pip install -e . produces
one importable ligase whose __init__.py you can edit without rebuilding,
containing a _core you cannot.
abi3
The bindings build against abi3-py311 — Python’s stable ABI, from 3.11
onwards. One compiled artifact works on 3.11, 3.12, 3.13, 3.14 and later.
The cost is real: abi3 closes off parts of the CPython C API, so some PyO3
features are unavailable, and a #[pyclass] under abi3 is marginally slower
than a version-specific build. We accept that. The alternative is a build
matrix with one wheel per Python version per platform, which multiplies CI
time and release surface by four or five. For a project whose hot path will
be hashing files and shelling out to aligners, the per-call overhead of a
stable-ABI class is not where the time goes.
The floor is 3.11 rather than 3.10 for two reasons: 3.10 reaches end of life
in October 2026, and tomllib is in the standard library from 3.11, which the
version test uses to read Cargo.toml without a dependency.
Why the engine has no pyo3 dependency
ligase-core does not depend on pyo3 at all; ligase-py depends on both.
This is enforced by the manifests rather than by discipline.
The payoff is that the engine’s test suite is plain cargo test with no
Python interpreter in the loop, which keeps the inner development loop fast
and means a type error in the engine surfaces in milliseconds. It also keeps
the engine usable from Rust, which matters for the conversion graph in v2.
The cost is a translation layer that must be written by hand, and the
temptation to let types leak across it for convenience. The leading
underscore on _core is the reminder that the layer is allowed to change.
Lints as rules
CLAUDE.md says “no unwrap outside tests”. Rather than leaving that to code
review, the workspace makes it a build error:
[workspace.lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
[workspace.lints.rust]
missing_docs = "warn"missing_docs is a warning locally and an error in CI, where clippy runs with
-D warnings. So an undocumented public item fails the build — the
documentation rule, applied to code.
Test files opt out explicitly, at the top of the file:
#![allow(clippy::unwrap_used, clippy::expect_used)]That is the point of the design. An unwrap in a test is fine and an
unwrap in the engine is not, and the opt-out is visible in the diff when
someone adds a file that needs it.
Trade-offs taken
One repository, not three. The engine, the bindings and the Python package
version and release together. A scientist installing Ligase should never have
to reason about which engine version their Python package wants. The cost is
that the Rust crate’s releases are coupled to the Python package’s, which
would matter if other Rust programs depended on ligase — none do yet.
Source distribution only, for now. Installing needs a Rust toolchain,
which is a real barrier for the target user. Pre-built wheels via
maturin-action are the fix, and belong with the first release rather than
with the scaffold; publishing wheels for a package that cannot yet check a
pipeline would be premature.
ruff does not reformat the docs. While the design docs were Markdown
under docs/, ruff wanted to reformat
the golden example — it formats Python inside
fenced Markdown blocks — adding blank lines between decorated functions and
de-aligning its comments. That document is the specification the engine is
built to satisfy and its shape is deliberate, so an extend-exclude kept the
formatter out of it. The pages are MDX now, which ruff does not read at all,
so the exclusion is gone rather than retargeted. The trade is unchanged for
that page: its example code is not machine-checked. Where it matters the fix
was execution rather than formatting — the quickstart’s commands and the
notebooks are run on every docs build, and a page whose output no longer
matches fails it.
Four CI jobs, eight job-runs. The Python gate runs on 3.11 and 3.14 across Linux and macOS. abi3 means the compiled artifact is not version-specific, so the matrix is really testing that the build works across interpreters and platforms, not the extension’s behaviour on each. Two versions at the ends of the supported range catch that; testing all four would buy very little.
What M0 deliberately leaves out
No dimension types, no graph, no cache, no execution. The only behaviour the engine has is reporting its own version.
Dependencies were kept to the minimum that a scaffold needs: pyo3 and
nothing else. blake3, rusqlite and rayon arrive with the
milestones that need them, and the BAM and VCF
header parsing that M1 requires will need a format
library — noodles being the obvious candidate. Adding them now would mean
guessing at an interface before the thing that uses it exists.
Related
- Project layout — the three layers and why
- Installation — the developer loop
- Decisions — D1 on the Rust/Python split, D4 on canonicalisation