Skip to Content

Provenance

crates/ligase-core/src/prov/. No pyo3; exposed through ligase-py and reached from Python as pipe.methods() and ligase methods.

Three pieces, each answering a different question:

ModuleQuestion
prov/mod.rsWhat was recorded about how a step ran?
prov/methods.rsWhat would I write in a paper?
prov/claim.rsWhat is behind a number I already wrote?
prov/bundle.rsWould someone else get the same thing?
prov/cite.rsWho do I credit for this tool?

The third runs the opposite way from the others, which is the point: the rest of this module answers how was this result produced, and claim.rs answers what produced this number.

The store

A claim is read at write-up time and stored nowhere, so it adds no table to the store below and no column to the ones that are there.

provenance.sqlite, in the cache directory and not in cache.sqlite. One row per step key in produced, with four child tables — produced_parameters, produced_inputs, produced_references, produced_tools — rather than JSON blobs in one row, so that a question about a paper can be answered with sqlite3 and no parser:

$ sqlite3 .ligase/provenance.sqlite 'select tool, version from produced_tools' minimap2|2.31-r1302 samtools|samtools 1.24...

That is the same bet the cache makes (D6), and it is why the engine needs no JSON library.

Why a separate file

Cache::open drops its tables when KEY_VERSION or CANON_VERSION moves, because an entry written under another format is a claim about ingredients that were combined differently and must never be read (D19). None of that reasoning applies to a record of what a command did. A separate file in the same directory survives every such bump for free, and PROV_VERSION tracks this schema on its own (D23).

What is keyed, and what is not

A record is filed under the step key, which is already the identity of the computation: the same code, parameters, input contents and tool versions are the same step, and a second run of it has nothing new to say.

Two things are deliberately absent.

The scope. The sample label a report puts beside a step’s name depends on the pipeline the step was reached from, not on the step. Graph::scopes can work it out whenever it is needed, and storing it would be a fact about one run masquerading as a fact about the computation.

Citations. A citation cannot change a result, so it is not a key ingredient — and storing one in a record would go stale the moment it was edited, since editing it does not rerun the step. prov::methods takes them from the pipeline in front of it, which is always current (D24).

The write, and where it happens

In exec.rs, inside Runner::attempt, after the output is stored:

let produced = produce(&task.recipe, &inputs, &task.parameters, &staging, self.host)?; // ... self.remember(&Made { spec, task, key, inputs: &inputs, command: produced.command, /* … */ })?;

It has to be there. exec::produce now returns an Execution carrying the command it ran, because fill_template is the only thing that ever renders it and the rendered command exists nowhere else — not in the cache, and since M4 not in the output either (D22).

A step served from the cache writes nothing. That is not an omission: the record from the run that did the work is still there, and that is the whole point of keeping it outside the cache.

Stores { cache, prov } is passed to exec::run and audit::audit so that “a run records provenance” is visible in the type rather than hidden in a constructor. A provenance write that fails fails the step, exactly as a cache metadata write already does: a result the engine cannot account for is not one it should serve.

Cost

The hashes a record needs are the ones the key was just built from, so remember makes cache lookups rather than reads — the files prefilter and the memoised canonical table both answer from SQLite. One extra content_hash per input file is taken alongside the canonical hash, because a record names the file by its bytes as well as by its meaning.

Where that hashing happens matters more than what it costs. It runs inside the step’s own worker, and BLAKE3 hashes a large file across whatever thread pool it is called from — so writing a record, like building a key, queues work on the executor’s own pool. That is safe only because no worker in that pool is ever blocked waiting for scheduler work (D27, the scheduler). A ligase methods run on the demo is what found this out, by hanging.

Pinning a reference

prov::pin reads ReferenceId, which load::reference already filled:

  • accession — the contig name, where it looks like one. The check is is_accession: one to six uppercase letters, an optional underscore, five to ten digits, an optional .version. K03455.1 and NC_001802.1 pass; chr1 and 1 do not.
  • checksum — the per-contig MD5 the loader computes over the uppercased sequence with whitespace removed, which is the digest the SAM specification prescribes for @SQ M5 (D11). It is given for a single-sequence reference; a multi-sequence one has no single checksum to name.
  • fingerprint — always present, so a reference with no accession is still told apart from any other.

Nothing here is declared, and nothing is guessed. That is the whole reason the accession requirement cost no new API: both halves of “accession plus checksum” were already being read off the file in M1.

The paragraph

prov::methods takes a graph, its roots, the tasks, a run report and the store. The run report is where the keys come from, which is why pipe.methods() runs the pipeline first.

Grouping

One written step becomes one node per sample, and four identical sentences with a different accession in each would be unreadable. So nodes are grouped by a signature of everything that could make their sentences differ — step name, command template, parameters, tool versions, reference fingerprints — and each group gets one sentence saying how many nodes it covers.

Where two nodes of a step genuinely were run differently they fall into different groups and get a sentence each. That is not a special case; it is the same rule, and it puts the difference in front of the reader.

Wording

Fixed sentence forms, assembled in Group::sentence and wrapped at 76 columns. Two details worth knowing:

  • describe_tool does not say a tool’s name twice. samtools --version prints samtools 1.24, while minimap2 --version prints 2.31-r1302 and nothing else, so the name is prepended only when the version does not already start with it. Only the first line reaches a sentence; the rest — the htslib a samtools was built against — is in the bundle.
  • The template is printed, not the filled command. A filled command names a staging file inside the cache, which is true and of no use in a paper. The filled one is in the record, and commands.tsv prints it.

Gaps

A node whose key has no record becomes a Gap, reported by name with ligase run --force as the remedy. Nothing can reconstruct such a command, which is exactly the trade D22 made.

Claims

prov/claim.rs, reached through Methods::bind.

Why the engine owes this and not more

The feature was specified in three sentences and the first design question was whether it belonged at all. The obvious test to apply is D35, and applying its letter here would be a category error — and a convenient one. D35 asks a kind to bring a dimension. Claim binding is not a kind and brings no dimension; neither does ligase audit, the bundle, early cutoff or the citation table, and each of those is load-bearing.

So what it was held to is the reasoning D35 rests on: does it catch a documented silent failure that passes unremarked today, and is the engine already holding what it needs to catch it?

It is. D24 names an uncited tool, D26 names a field that cannot be dropped, and a Gap names a step nothing is known about — three instances of one promise, that the record names what it cannot account for. The number in the paper was the one thing the record could not account for and did not mention. This is the fourth instance of an existing promise rather than a new capability, which is also why it cost no new store, no new dimension and no new dependency.

Methods::bind, and not an argument to methods

Every check a claim needs is against the Methods value itself: the steps it accounts for, the samples they ran on, the parameters and tools their records hold, and the keys in Methods::produced. Nothing about building a methods section depends on the claims, so binding is an operation over the finished write-up — which is why prov::methods’ eleven existing call sites are untouched and why a pipeline declaring no claims produces byte-identical text.

Methods::produced

A Group is a set of nodes run the same way and keeps one representative Record, discarding the other nodes’ keys. A claim binds to a computation, so the keys are exactly what it needs — and that is the field. It is not a duplicate of groups; it is the thing grouping was throwing away.

An unscoped claim over a four-sample step therefore has four Bound rows, one per key. That is the honest answer to what is behind this number, where binding it to the group’s representative key would have named one sample’s computation and implied it covered four.

The five checks and the three verdicts

In order, each failing to Unbacked with its own reason: the step is one the write-up accounts for; the sample, where one is named, is one that step ran on; something is known about how it was produced (a step that is only a Gap stops here); the parameter or tool it restates is one the record holds; and the value agrees.

The last one fails to Contradicted rather than Unbacked, because it is the stronger statement: the claim is bound, to a computation that contradicts the paper. Both numbers are printed and the write-up does not say which is wrong, since it cannot know whether the manuscript or the pipeline is the thing that should move.

Unbacked has five variants rather than one flag. A renamed step, a dropped sample, a renamed parameter and a swapped tool are four ordinary edits with four different one-line fixes, and a complaint that merely said unbacked would send a reader looking for all four. Each names what there was, for the reason the check and run reports name their samples (D15).

One case the tests pinned and the design had not foreseen: a step with a single node has no scope at all, because Graph::scopes gives one only where something tells siblings apart. So a claim naming a sample of such a step is told there is nothing there to name, and advised to drop the sample rather than to correct it.

A sum type in the core, flat at the surface

Restated is Parameter { name, value } | Tool { name, version }, so a claim that restates both cannot be constructed. Python’s Claim is flat — parameter=, tool=, value= — because that is how a person writes one, and __post_init__ is the single place an incoherent combination is refused: both names, a value with neither, or either with no value. Each of those is a declaration with nothing behind it, and M13’s rule is that a declaration nothing can check is a comment.

Comparing a tool version

version_states is textual and never numeric, and three tools this project runs each break equality in a different way:

Tool--version printsWhy equality fails
samtoolssamtools 1.24its own name is in the string
python3Python 3.15.0a name that is not the invoked name
minimap22.31-r1287a build suffix a paper would omit

So the stated version is tried against the whole first line and against each way of trimming a leading name off it — the tool’s own name, or everything before the first digit — and one match is enough. That generosity is safe only because of the rule that does the work: a prefix must end at a component boundary, so 2.4 does not claim 2.41. A version line with no digit in it is left whole, which is why a claim against bwa’s [main] unrecognized command '--version' banner — the string D42 found the engine recording as a version — is wrong rather than accidentally right.

The python3 case was found by a test, not by design: the first implementation stripped only the tool’s own name and called a correct claim contradicted.

MethodsGap keeps its shape

§5.6 of the v0.2 spec says claim binding “extends Methods and MethodsGap”. The first it does. The second it deliberately does not, and the reason is worth recording because the literal reading was available and cheaper.

A MethodsGap is (step, scopes, nodes). A claim has no nodes — it is one sentence, not a count of graph nodes — one scope rather than a list, and five distinguishable reasons where a step’s gap has one. Forcing a claim through that shape would mean a nodes column that is always 1 and means nothing, a scopes list always of length 0 or 1, and a reason with nowhere to live: a record whose columns are not true, which is the one thing a provenance store may not be.

So what extends is the gap-reporting that MethodsGap stands for — same section, same consequence, same promise — in a type honest about what a claim is. Every existing use of MethodsGap is untouched.

An unbacked claim is a finding; an uncited tool is not

ligase methods exits non-zero on a gap and not on an uncited tool, and the distinction is sound: an uncited tool is a stated absence with nothing wrong behind it, and a gap is a hole in the record. An unbacked claim is worse than a gap — a number in a paper the analysis does not support — and a contradicted one worse again. Both raise the exit code; a bound one does not. CI is the only place a drifting manuscript is actually caught, so this is what makes a claim enforceable.

For the same reason Methods.accounted_for widened from not gaps to not gaps and not unbacked. A property with that name returning True for a write-up that states an unsupported number would be making the engine’s own promise falsely.

What claims deliberately do not do

No number is read out of an output. The engine does not open a VCF and count its rows. It could do so for the formats it knows and would have to skip the rest, which is the lenient-canonicalisation failure canon’s own module comment warns about: some numbers verified, the others silently waved through. A claim with a value is compared; a claim without one is bound to a key, a hash and a date, and the pages say which is which.

A claim’s text is never checked. It cannot be. If min_freq moves and the claim states its value, check 5 catches it; if the claim states only a sentence, the output digest in the write-up and in claims.tsv is what a reviewer compares.

A located claim is the only thing in a write-up that reads an artifact. Every other check — the step, the sample, the parameter, the tool version — is against the record, which is why a write-up works for a run whose outputs were pruned. Restated::Value is not: Located::read opens the output and reads a cell, so Produced and Bound carry the output’s path for it. That is the one dependency on an artifact still being on disk, and it is confined to the claims that asked for it: a claim with no at never touches the store. An output that cannot be read comes back as Unbacked::NoSuchCell, phrased where it was discovered, because what was there instead — the columns the file does have, how many rows it holds, the text that is not a number — is known only at the point of reading (D61).

A claim binds to a key and is cited by a digest. Two identifiers, two jobs. Bound carries both; claim_sentence prints only the digest, because the key is the identity of a computation and so moves when the step’s code, parameters, inputs or tool versions move — cite= among them, which changes no number and reruns nothing. The canonical digest is what survived that edit in the reported case, and it is what audit and verify already compare, so it is the one a manuscript can quote (D59).

No claim verdict in verify. A claim names a digest, and whether that result still reproduces elsewhere is what results.tsv’s verdict already says. A parallel verdict path would duplicate that comparison in a second place that could disagree with the first.

A note behind a bound claim is not surfaced on the claim. D26’s notes are already reported in their own section of the same write-up, and linking the two would be a fourth verdict for a connection a reader makes anyway.

No claim reaches a cache key, and no claim can fail check(). Both halves of D45. A claim in a key would mean editing a manuscript reruns the aligner; check() is about dimensions, and a write-up naming a renamed step is not a pipeline that must refuse to run.

The bundle

prov::bundle::write takes a Request and produces a directory. Seven flat TSV tables, the source of every step, whatever files the caller asked to carry along (the CLI passes the pipeline file), and a MANIFEST hashing all of it with BLAKE3.

An eighth, claims.tsv, where the pipeline declared any — one row per computation, with the whole key rather than the sixteen characters a sentence prints, since this is the table a key is looked up in. It is written only where there are claims: verify reads tables by position, so a file that appeared for every bundle would be one more thing a later version has to keep meaning the same, and an empty table is a promise nobody made. It belongs here for the reason this module’s own decision is titled — a bundle states a claim and can be checked (D25) — with the bundle’s standing claim being about reproducibility and a Claim being the paper’s.

Fields are escaped — \\, \t, \n, \r — so a command containing a tab cannot shift a column, and unescape reads them back.

What “a clean machine” means

A bundle cannot carry the reference pipeline’s inputs: they are gigabytes, and fetching them is out of scope (D8). So the claim is narrower and checkable: a run with these inputs, on a machine with these tools, lands on these canonical hashes. To this engine a clean machine is an empty cache — nothing computed, nothing remembered, every tool probed again — and that is what the acceptance test uses (D25).

The four verdicts

verify matches the bundle’s rows to a fresh run’s results by (step, scope) rather than by key, because the key is the thing that may legitimately have moved:

VerdictKeyResult
Identicalsamesame
Reproduceddifferentsame
Differseitherdifferent
Missing—this pipeline produced nothing for it

Reproduced is the verdict that justifies the whole mechanism. Keys are content-addressed, so a bundle checked on a machine with the same data and the same tool versions comes out Identical — and one checked where the aligner is a different build comes out Reproduced, which is a stronger statement than the cache could ever make.

Inputs are checked by content as well, and reported only when something moved: a run of four hundred samples should not print four hundred lines to say nothing changed.

The citation table

Four entries’ worth of promise: minimap2 (Li 2018) and samtools / bcftools (Danecek et al. 2021, one paper for both). It is short on purpose — every entry is a commitment to keep a reference correct, and a long list of half-checked citations is worth less than a short list of checked ones plus an honest gap. Anything else comes from cite= on the step, and a tool neither covers is named as uncited.

ligase is the exception, and it is not in the table. cite::engine builds its reference from the engine versions the run recorded, Methods::citations adds it once for the whole run, and the engine is never reported as uncited. The table could not hold it: every row there is a constant, this reference has to name the version that ran, and a distributed run may have more than one (D55). What forced the change is that ligase is the tool of the built-in conversions, which take no decorator — so naming it as uncited printed a gap whose stated remedy did not exist, and a gap that cannot be closed teaches a reader to skip the ones that can be (D58).

And it recognises its own work when somebody else cites it

That change had a cost nobody saw until a consumer upgraded. 0.3.0’s message had told them to cite Ligase by hand; they did, on their one decoratable step; and the self-citation then rendered the same work twice — same version, same URL, two entries in a reference list. There was no spelling of the pipeline that was correct on both versions, so they kept theirs with a REMOVE this when the floor moves comment.

cite::is_engine_reference is the answer: a declared reference naming Ligase’s own repository is the same work, and Methods::citations keeps the engine’s own rather than the consumer’s, because the engine’s names the build that ran. The match is on cite::REPOSITORY — one constant, rendered by cite::engine and recognised here, so the two cannot drift — and only where the repository name ends there, which is what keeps a sibling repository under the same owner a different work (D63).

The URL is the identity rather than the title because that is how this kind of software is cited at all: a reference-list entry is free text and a title can be written twenty ways, while the repository a reader is pointed at is the thing being named. It is the same reasoning that gave RDKit its website in the table above.

What is not here

Validation. Whether the resistance calls agree with the published ones is M6. Provenance says how a number was produced; it says nothing about whether it is right.

Fetching pinned data. A bundle names its inputs by content hash and its references by accession and checksum, and checks the ones it finds. Going and getting them is out of scope for v0.1 (D8).

A history of runs. One row per step key, replaced if the same key is produced again. The key is the computation, so the second run has nothing new to say about it except the machine and the moment, where the later answer is the better one. A pipeline wanting an append-only log of every invocation would want a different table, not a different key.

A repair for a tool that names a sample after a file. bcftools names a VCF’s sample column after its input BAM’s path when the BAM carries no @RG SM, and a Ligase run hands it a path inside the content-addressed store. The canonical form of a VCF includes its sample names — correctly, because a sample set is a dimension — so such a VCF’s canonical hash depends on where the cache is, and a bundle written from one cache cannot verify against another. Bundle verification is what found it.

The engine now notices this and says so, in the run report, in the record, in notes.tsv and in the methods section (D26, canonicalisation). What it does not do is fix it, and will not: refusing the file would make a format problem into an engine failure in the layer D21 forbids it, and rewriting the identifier would be a silent repair that does not even work — a file in the store is named after the hash of its bytes, which move with the cache root too. The fix is one line in the pipeline, and align in the reference pipeline has it.

Provenance for a step that failed. pipe.methods() refuses a pipeline with a failure rather than describing the steps that worked, because a paragraph covering most of a pipeline reads exactly like a paragraph covering all of it.

Last updated on