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:
| Module | Question |
|---|---|
prov/mod.rs | What was recorded about how a step ran? |
prov/methods.rs | What would I write in a paper? |
prov/claim.rs | What is behind a number I already wrote? |
prov/bundle.rs | Would someone else get the same thing? |
prov/cite.rs | Who 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.1andNC_001802.1pass;chr1and1do 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_tooldoes not say a tool’s name twice.samtools --versionprintssamtools 1.24, whileminimap2 --versionprints2.31-r1302and 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.tsvprints 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 prints | Why equality fails |
|---|---|---|
samtools | samtools 1.24 | its own name is in the string |
python3 | Python 3.15.0 | a name that is not the invoked name |
minimap2 | 2.31-r1287 | a 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:
| Verdict | Key | Result |
|---|---|---|
Identical | same | same |
Reproduced | different | same |
Differs | either | different |
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.