Ligase¶
Type-safe, reproducible computational biology pipelines. A Rust core with a Python surface.
The idea¶
Biological data types carry dimensions — organism, reference build, coordinate system, sample set — and the engine refuses to connect steps whose dimensions do not match, the way a physics engine refuses to add metres to seconds.
The worst comp bio bugs are silent. hg19 against hg38. 0-based against
1-based. chr1 against 1. Sample IDs that shifted by one somewhere in a
join. None of these crash; they produce plausible numbers that are wrong.
Workflow managers like Snakemake and Nextflow move files between steps very
well, but they do not understand what is in them. Ligase does, and it checks
before it computes.
pipe.check() # type-checks dimensions; no compute
pipe.run() # parallel, cached, per-sample fan-out
pipe.methods() # generated methods paragraph + reproducibility bundle
When a pipeline is wrong, you should find out in milliseconds rather than after an overnight run:
DimensionError in step `call`:
aln.reference = HXB2 (from BAM @SQ header)
ref.reference = hg38
These must match. Did you pass the wrong reference?
Status¶
Pre-alpha, at v0.1's last milestone. M0 (scaffold), M1 (types and dimensions), M2 (the graph), M3 (execution and cache), M4 (semantic hashing and early cutoff), M5 (provenance) and M6 (validation) are complete.
The reference pipeline now interprets as well as computes: its resistance calls reproduce published Stanford HIVdb results exactly at the 15% and 5% thresholds, with every remaining discrepancy explained.
You can write a pipeline of annotated steps, have the whole graph type-checked without running any of it, be told every mistake at once, and then run it — in parallel, cached, one branch per sample:
import ligase as lg
from ligase.types import Alignment, Reads, Reference, Variants
@lg.step(tool="minimap2")
def align(reads: Reads, ref: Reference) -> Alignment:
return lg.sh("minimap2 -ax sr {ref} {reads.r1} {reads.r2} | samtools sort -o {out}")
@lg.step(tool="bcftools")
def call(aln: Alignment, ref: Reference, min_freq: float = 0.01) -> Variants:
return lg.sh("bcftools mpileup -f {ref} {aln} | bcftools call -mv -Oz -o {out}")
hxb2 = lg.reference("data/refs/HXB2.fasta", organism="HIV-1", build="HXB2")
samples = lg.samples("data/runs/*.fastq.gz", paired=True)
pipe = lg.Pipeline(samples.map(lambda s: call(align(s, hxb2), hxb2)))
pipe.check() # type-checks every step. Nothing runs.
$ ligase check
checked 8 steps in pipeline.py
DimensionError in step `call` (sample SRR32217714):
aln.reference = HXB2 (from step `align`)
ref.reference = hg38 (from FASTA contigs: data/refs/hg38.fasta)
Contig `K03455.1` is not in hg38.
These must match. Did you pass the wrong reference?
The same problem affects 3 other samples: SRR32217715, SRR32217716, SRR32217726.
4 errors in 4 steps.
The step bodies never ran, aln.reference came out of a step that does not
exist yet, and a problem that hit the whole cohort is stated once with every
affected sample named.
Point it at the right reference and it runs:
$ ligase run
running 12 steps in pipeline.py
ran `align` (sample SRR8525904) 4.5s
ran `call` (sample SRR8525904) 513ms
ran `resistance` (sample SRR8525904) 3ms
...
12 steps: 12 ran.
Run it again and nothing is recomputed, because a step's key is built from its code, its parameters, its inputs' contents and its tools' versions — and none of those moved:
$ ligase run
cached `align` for SRR8525904, SRR8525908, SRR8525920, SRR8525942
cached `call` for SRR8525904, SRR8525908, SRR8525920, SRR8525942
cached `resistance` for SRR8525904, SRR8525908, SRR8525920, SRR8525942
12 steps: 12 from cache.
Change min_freq and only call and resistance come back. Add a sample
and only that sample is processed.
Run the aligner again on eight cores instead of one and it reruns — a thread count is a parameter, and nothing can know in advance that it will not change an answer — but the alignments come out the same, so nothing below it is recomputed:
$ ligase run
ran `align` (sample SRR8525904) 3.3s
ran `align` (sample SRR8525908) 6.9s
ran `align` (sample SRR8525920) 3.7s
ran `align` (sample SRR8525942) 6.0s
skipped `call` for SRR8525904, SRR8525908, SRR8525920, SRR8525942
skipped `resistance` for SRR8525904, SRR8525908, SRR8525920, SRR8525942
12 steps: 4 ran, 8 skipped.
"The same" is not byte equality — minimap2 writes -t 8 and the output
path into the BAM's @PG line — but what the file
means: its records sorted and the fields
recording when and how it was made dropped. And when you want the opposite
question answered, ligase audit <step> runs a step twice and says whether
it repeats.
And when you need to say how the results were produced, ligase methods
does — naming the version every tool actually ran at, every parameter as it
was hashed, the reference by accession and checksum, and the papers to cite:
$ ligase methods
`align` ran on 4 samples (SRR8525904, SRR8525908, SRR8525920, SRR8525942)
with minimap2 2.31-r1302 and samtools 1.24, as `minimap2 -t {threads} -ax sr
{ref} {reads.r1} {reads.r2} | samtools sort -@ {threads} -o {out}`, with
threads = 4, against the HXB2 reference (K03455.1, 9719 bp, MD5
c66838498aab02f786a6ec2f2d62cdc1).
It can say that about a run where nothing was recomputed, which is the
point: the command is recorded when a step runs, because since M4 it is not
in the output's hash and a cache hit can no longer prove it. --bundle
packages all of it so someone else can check the results, and --verify is
that check.
What remains is M6 (validation): whether the resistance calls agree with the published ones. See milestones for the order and the acceptance test for each step.
Where to go¶
| If you want to | Read |
|---|---|
| Install it and run the checks | Installation |
| Understand how the project is put together | Project layout |
| Know why the workspace is shaped this way | Workspace architecture |
| See the target API | Golden example |
| Understand why data carries dimensions | Types and dimensions |
| Declare dimensions and read the errors | Declaring dimensions |
| Write a pipeline and check it | Building a pipeline |
| Run one, and understand what was cached | Running a pipeline |
| Understand why steps are never run to build a graph | Pipelines and the graph |
| Know how the checker works inside | Type system |
| Know how the graph works inside | Graph |
| Understand why a step did or did not rerun | Running, and not running |
| Know why two different files can be the same result | What a file means |
| Check that a step repeats itself | Semantic hashing and ligase audit |
| Know how a run is scheduled | Execution |
| Know how the cache and its keys work | Cache |
| Know how a canonical form is built | Canonicalisation |
| Say how a result was produced, for a paper | How was this produced? |
Understand a noticed line in a run report |
When a tool names a sample after a file |
| Fix an output named after a file rather than a sample | Reading what the engine noticed |
| Write a methods paragraph and a bundle | Writing up a pipeline |
| Know how provenance is stored and checked | Provenance |
| Turn variant calls into drug-resistance calls | Resistance interpretation |
Write a resistance step, and pick a threshold |
Interpreting resistance |
| Know how the HIVdb rules are read and scored | Resistance interpretation |
| See what the calls were checked against | Validation |
| Know which dimensions exist and how they are inferred | Dimensions |
| Follow the build order | Milestones |
| Understand a design choice | Decisions |
Names¶
| Thing | Name |
|---|---|
| Rust core crate | ligase |
| PyPI distribution | ligase-bio |
| Python import | ligase |
Licence¶
Dual-licensed under either MIT or Apache-2.0, at your option.