Skip to content

Call gene conversion only from reads that can carry it - #6

Merged
Paururo merged 31 commits into
mainfrom
feat/gene-conversion
Sep 23, 2026
Merged

Paururo merged 31 commits into
mainfrom
feat/gene-conversion

Conversation

@Paururo

@Paururo Paururo commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

What changed and why

Gene conversion was called from reads that could not carry it, and the cohort step multiplied those calls. On the 185-sample cohort it was checked against, 1,520 of the 1,579 calls were rows the samples themselves had left ambiguous, promoted because another sample had called the same stretch outright. The outright calls came from cultures at 0.2x to 2x that the QC fails, read by one to four molecules. With this branch the calls drop from 38 events to 4, two of them in the mixed cultures the QC also fails.

  • A tract most of whose sites are under --gconv_min_depth, or whose reads carry the donor's bases below 10%, is now ambiguous, with a reason that quotes the reads, and it takes no part in corroboration. The cohort step applies the same check, reciprocal exchanges included, so re-running only that step corrects an existing run.
  • Recurrence is measured against the samples mapped to the same reference. Divided by the whole run, an artefact of one of two references could never reach the 90% that marks a reference_artifact (113 of 185 samples is 61%). The pooled locus statistics are keyed on the contig as well as the pair number.
  • divergent_sample is judged per sample and reference, over every contig of that reference. A sample diverged from one of two references lost its tracts on the other as well, and five tracts on a plasmid made a sample with a clean chromosome divergent.
  • The report's summary counts the events that only failed samples carry apart from the rest.

This also brings to main what the branch already holds:

  • the redesigned QC report (Open the QC report on what the run found #5, reviewed into this branch);
  • LINEAGE_MISMATCH and the divergent_sample verdict, two guards against a sample mapped to a genome it does not belong to;
  • the Garnatxa work: a launcher that submits the driver, one QoS for every task with the queue as a parameter, walltimes, Kraken's memory a parameter instead of a fixed 56 GB, the dedup pipe fitted in its allocation, and submission four times faster;
  • the resistance catalogue v1.0.2 and the image that carries it;
  • a GFF attribute spelled nan is no longer read as a gene name.

Each has its CHANGELOG.md entry.

Outputs and defaults

  • <samplesheet>_gene_conversion.tsv: many rows that read gene_conversion are now ambiguous, with a reason that quotes the reads.
  • qc_flags.tsv can carry LINEAGE_MISMATCH (WARN).
  • New parameters: --kraken_memory (24 GB), --qos and --qos_heavy (short), --extra_binds. The container digest moves to the image with catalogue v1.0.2.

Two PRs are stacked on this one: #7 (the SNP matrix's depth), then #8 (the cohort analyses).

How it was verified

tests/run_tests.sh unit   # 2,045 passed, 39 skipped
tests/run_tests.sh js     # 96 passed
tests/run_tests.sh lint   # clean
mkdocs build --strict     # passes

Each commit was also tested on its own. CI passes on the branch head, every job green, the stub run included on Nextflow 24.04.2 and the latest stable release: run 35875974618.

Checklist

  • tests/run_tests.sh passes (unit, js and lint locally; the pipeline leg in CI)
  • ruff check . is clean, and I did not restyle code the change does not touch
  • New or changed behaviour has a test, and tests pin what the code does today
  • A new process in modules/ has a stub: block (not applicable: no new process)
  • tests/data/ was not edited by hand (fixtures come from make_test_data.py)
  • User-visible changes have a CHANGELOG.md entry and, where relevant, a docs update

freebayes-parallel ships inside the freebayes package, and its dependencies do
not. Read out of the published image, it pipes GNU parallel into
vcffirstheader, vcfstreamsort and vcfuniq, and none of those four are in the
image: --freebayes_parallel is a documented option, so turning it on failed
inside the container with a command not found.

Found by listing the image's own bin/ against every binary the pipeline
invokes. It is off by default, so no run has hit it, and the image needs a
rebuild before the fix is real.
The first rebuild in two months failed, and neither cause was in this file's
own logic. Debian bullseye left LTS on 2026-08-31: its security pool is being
pruned while the index still advertises what was in it, so apt resolved curl,
git and glibc to versions whose .deb files return 404. Base moved to bookworm,
where all nine packages this image installs are present.

The second would have failed on the next line. micromamba.snakepit.net is an
alias served the *.mamba.pm wildcard certificate, which does not cover it, and
the one it gets expired on 2026-09-15. Micromamba now comes from the project's
own release channel, which takes a third-party CDN out of the build.
The first image in two months, and the first that contains what the pipeline
actually invokes: its bin/ was listed against every binary the modules call,
including the four that freebayes-parallel runs and that the previous image
shipped without. 752 binaries against the old 607.
The digest is repeated in three files and grep found the third only after the
first two were changed, which is the argument for a test that checks they
agree rather than a habit of remembering.
The pinned digest is written down in six files and only one of them does
anything: nextflow.config is what every run pulls, the rest are documentation.
A stale copy there is worse than no mention, because it tells a reader they
are running an image they are not.

Updating them by grep found the last three only after the first three had been
changed, which is the argument for a test rather than for remembering. The
test compares every digest written anywhere in the docs against the one
nextflow.config pins, accepting the abbreviated forms the prose uses, and a
second test guards the guard: one that finds nothing to compare passes for the
wrong reason.
The pinned reference carries the `docker://` scheme. Singularity requires it,
Docker refuses it, and the documented laptop command `-profile local,docker`
died with `invalid reference format` before pulling anything. Found while
running the fixture cohort through the rebuilt image, which needed the scheme
stripped by hand to run at all.

The default keeps the scheme, because that is the path every cluster run
takes, and the docker profile strips it. Dropping it from the default would
fix a laptop and break every cluster, which is the more expensive direction.

Verified by running the fixture cohort end to end under -profile local,docker
with no override: 67 tasks, none failed. The test asserts both halves and that
the two forms name the same image, since a profile that quietly resolved a
different tag would satisfy either half alone.
Garnatxa's own documentation submits the Nextflow driver with sbatch rather
than leaving it on the login node, where a long run is both against the usage
rules and liable to be killed. This is that script for this pipeline, next to
the site config it pairs with.

The --time is explicit and that is the point: every QoS on that cluster
defaults to six hours, and a driver killed at its limit orphans whatever it
was waiting on. medium allows seven days.

Per-task resources stay in conf/garnatxa.config. The script only carries what
a launch decides: the samplesheet, the output directory, the revision, and
whether gene conversion runs. The Kraken2 database needs no flag, because the
profile already points at the shared copy; it needs a taxId column instead.
/storage/pgo is where the group's sequencing data is, and the Garnatxa profile
did not bind it. That does not fail at submission. It fails inside the task,
as a file plainly present on the login node and absent to the tool opening it,
which is a slow way to be told about a mount.

--extra_binds covers the next path nobody anticipated without editing a
tracked config. It is declared in nextflow.config rather than beside the
profile that consumes it, because main.nf validates flags against that block
and rejected it as unrecognised when it was declared next to its use. Whether
a command-line param reaches a GString in a profile was checked with a
standalone reproducer rather than assumed: it does.
nucmer records the absolute paths of its two inputs in the delta's first line,
and show-snps opens them to report the bases around each difference. Under a
scheduler that gives each task its own scratch, that path belonged to another
task on another node and is gone, so PARALOG_MAP died on a delta that was
perfectly intact:

  show-snps failed (1): ERROR: Could not open file /scr/.../nxf.XXXX/reference.fa

Found on a real cluster run and reproduced here by deleting the directory the
alignment ran in. It had never been exercised: the chain tests stand MUMmer in
with committed text, so no test ever handed show-snps a delta it had to open a
file for.

paralog_map.py now rewrites that one line to a file that is present, and the
reference travels with the delta for that purpose. Everything after the header
is offsets and is copied untouched. On the reference that hit this, the stage
then finds 264 paralogous pairs and 6610 diagnostic sites.
The three Zenodo files live inside the container image, so declaring them as `path` inputs told
Nextflow to stage them from the host and it added a bind for /opt/pathotypr, which Singularity
refused because that directory does not exist on the cluster. As values they are interpolated into
the script and resolved inside the container, where they do exist.

A static test now derives the in-image parameters from the config and follows each call site to the
input it lands on, so the same mistake on a future bundled asset fails before a run does.
… used

Catalogue v1.0.0 gave each variant a single drug inherited from its gene and took the grade from the
first catalogue row it found, so amikacin never appeared at all and 15,969 rows carried a grade
belonging to another drug. v1.0.2 grades per variant-drug pair; the ancestor coordinate frame is the
one bundled, because all of its REF alleles match the reference beside it and the H37Rv file
mismatches 609 of them at the same coordinates.

The report now expands a composite label into every drug it names and marks a detected-but-ungraded
call instead of rendering it as a clean cell, the build verifies the downloads by checksum with
--strict, and DUMP_VERSIONS records the catalogue version and its SHA-256 so the question this
started from is answerable from the results next time.
The rebuilt image was verified by running it: /opt/pathotypr/VERSION names v1.0.2, the marker file
hashes to the ancestor-frame file published on Zenodo, amikacin is present, the composite labels are
there and no row carries the ungraded value any more.

Six places write the digest down and four of them abbreviate it, which the consistency test caught
after the two full copies were already updated.
FastP is run with --merge, so an overlapping pair leaves as one merged fragment in the orphan
stream. MAP_READS has taken all three read streams from the start and LINEAGE_TYPING took two, so
pathotypr typed whatever stayed unmerged: 8-19 percent of the reads in the cohort that surfaced
this, and zero on a fully overlapping library, which reports Unclassified exactly as a genuinely
unclassifiable sample does.

The streams are concatenated and typed as one sample, since pathotypr rejects three -i arguments
either way. That multi-member gzip is read whole, and that --paired only groups files, were both
measured against the pinned binary. The join fails loudly on a missing partner, because the failure
it replaces was silent.
Two sequential gzip calls closed the task, and gzip is single-threaded: 72 seconds per sample
compressing 480 MB per mate while eleven of twelve reserved cores idled. bgzip with the task's
threads does it in 2.8 seconds, produces valid gzip with identical bytes, and fastp reads the
result whole, which is the check BGZF needs since it is a multi-member file.

The 12-CPU and 80 GB requests came down to 8 and 56 GB, measured from a run that averaged 102
percent CPU and peaked at 39.7 GB, and Garnatxa tasks now declare a walltime instead of inheriting
the six-hour QoS default that kept every job out of SLURM's backfill.
Three processes asked for a longer QoS. Two of them were measured at about twenty seconds each and
never needed it, and the third only did because Kraken was paging a 133 GB database. Both queues
now default to short and the long steps ask for one hour instead of two.

The parameters live in the root config, not in the profile: main.nf validates command-line
parameters against that block, so one declared only in a profile is refused. Verified by running
a reproducer that prints task.clusterOptions, because the session config and what SLURM actually
receives are resolved separately.
It was the last fixed memory directive in the pipeline, and on a 185-sample cohort it left 61 jobs
queued behind QOSMaxMemoryPerUser while the cluster had capacity. With --memory-mapping the
resident set follows the database: 34.7-39.7 GB measured against a 133 GB index, under 20 GB
against the capped 16 GB one that answers the same question.

--kraken_memory now sets it and it doubles on each retry, matching the eight other memory
directives here, so too small a value costs a retry and not the run.
…M from inside it

samtools sort -m is per thread and MERGE_AND_MARKDUP runs two sorts at once, each sized at 80
percent of the task's memory: 13.1 GB asked of an 8 GB request. A single run's BAM never fills
those buffers, so it hid until the first merged sample streamed 2.8 GB through it.

The kill was also unreadable from outside. A stage dies, its stdout closes mid-BGZF block, the
next stage reports a short read and exits 1, and the OOM retry never fires. PIPESTATUS is now
inspected and a signal death re-raised as 137, verified against a pipe whose middle stage kills
itself, with a genuine non-zero exit still failing fast.
submitRateLimit binds whenever the tasks are shorter than the interval between submissions, and a
185-sample cohort is mostly short tasks: 740 of its ~4,300 are annotation steps of two or three
seconds. At 50 a minute those spent a quarter of an hour on sbatch calls alone.

Raised to 200 a minute. It is a courtesy limit toward the controller, not a correctness one, so
the comment says to lower it again if submissions are refused.
A GFF built from a table writes pandas' NaN as four ordinary characters, and the attribute
cascade only asked whether a key was present. On a real reference 3,001 CDS lines carry Name=nan
next to a usable ID=Rv0001_1-1524 and none carry locus_tag, so Name won and every tract in a
185-sample cohort was labelled nan, with amino-acid changes reported as nan:P1443A.

A placeholder value now counts as absent and the cascade falls through to the ID. The first
guess at the cause was wrong: str() on a float NaN in the cohort writer would have shown up in
every column, and only this one was affected, which is what pointed at the parser instead.
22 samples of a 185-sample cohort were annotated as one lineage, were really another, and were
routed to the wrong reference. They carried 2,000 SNPs where their line mates carried 5 and
produced 507 of the run's 522 gene-conversion tracts, because a diverged genotype carries the
donor base at every paralogous locus by inheritance. Nothing failed; the metrics were correct
about the wrong comparison.

LINEAGE_MISMATCH flags a sample whose k-mer lineage disagrees with the others sharing its
reference, which is independent evidence since k-mer typing ignores the reference.
divergent_sample refuses to read tracts as conversion when one sample calls them in more than
--max-locus-frac of its loci, measured at 0.20-0.39 for the mislabelled samples and at most 0.08
for every correct one. Both floors are absolute as well as proportional: a ratio over three loci
says nothing, and firing there broke 61 existing tests before the floors went in.
… can fire

The flag shipped working and never fired. qc_report computed the per-reference majority from
summ[sid]["reference"], a key the summary has never had, so the majority was always empty; on the
cohort it was written for it flagged nothing while 18 samples sat on the wrong reference.

The reference now comes from the samplesheet's refId. The unit tests passed the majority in by
hand and so never exercised the lookup; the new ones read it the way qc_report does, and one
fails if the majority goes back to depending on the summary.
…t in

The report took each Kraken2 report as a sample of its own, so a cohort of
185 samples sequenced over several runs gave 225 rows, and it took the top
species as a sample's primary taxon. Kraken2 leaves most M. tuberculosis
reads on the complex node, where k-mers cannot tell its members apart, so
the species held about 5% of the reads and every clean culture fell under
the 90% line: the report called 225 of 185 samples possibly contaminated,
and the cultures that really were another organism looked no different.

The reports of one sample are now summed by their reads. Each sample is
placed in its dominant clade, found by following the most abundant child
from the root while it holds at least half of its parent's reads: that
stops at the complex for a TB culture and never goes below a species. The
cohort's target is the clade most samples are dominated by, and target_pct,
the share of a sample's classified reads inside it, is what contamination
is now judged on.
…ed to

The summary's date column is DAT_OUT, stamped with the day the pipeline
processed the sample. The report read it as a collection date, so a whole
cohort was sampled in the year it was run and the temporal panel reported
a span of zero years. The date now comes only from a samplesheet column
that names one (collection_date, sampling_date, date, year...), and a
cohort without such a column has no temporal panel.

Each sample now also carries its refId and the lineage the other samples on
that reference agree on. That is what LINEAGE_MISMATCH compares, so the
report can say what disagreed instead of only naming the flag.
On a 185-sample cohort the report was one scroll of 27 panels, about
150,000 pixels long. The SNP dynamics drew 800 trajectory cards on their
own, the resistance panel listed all 4,986 calls, the table of all samples
had 48 columns with a bar in every cell, and every flag was a code. Worse,
several read-outs said things the data did not: 176 "mixed-lineage"
samples above a table that counted none, "reference-bias suspects" that
were passing samples mapped to their own ancestor, lineage markers counted
as resistance, and Mapped % against Unmapped % as the headline correlation.

The report now reads as seven pages, listed in the sidebar in the order a
reader needs them, with a badge where a page needs attention:

- Summary: what the run shows, in sentences with their numbers. Which
  samples to exclude and why, which are another organism or look mapped
  to the wrong reference, the resistance mutations beyond each lineage's
  own, the alleles that swept, the gene conversion called. Each finding
  links to the page that holds its evidence and says what it cannot prove.
- Sample QC: the verdicts, the live thresholds, the flagged samples with
  the value behind each flag (Low depth 2.2x, needs 10x), the exclusion
  list, the table of all samples, lineages, contamination, distributions.
- Genome & genes, Variants over time, Resistance, Gene conversion, and
  Diagnostics for the views that never flag a sample on their own.

The read-outs are fixed at the source: mixed samples are counted from the
MIXED flag; flags the page cannot recompute, such as LINEAGE_MISMATCH, are
kept when it re-flags instead of disappearing; the reference-bias screen
switches itself off when the cohort sits on its reference; a mutation that
at least 90% of a lineage carries is listed as that lineage's marker; pairs
that are one quantity measured twice never headline a correlation; and
divergent_sample, the most common gene-conversion verdict on the cohort
that introduced it, has a chip instead of reading "verdict not recognised".

The table of all samples opens on the 14 metrics a verdict is made of and
colours only the cells that tripped a flag. Resistance is grouped by
mutation and opens on grades 1-2, contamination is one row per sample with
the flagged ones first, and the dynamics draw 24 cards at a time. The
scatter no longer draws its top values above the axis, the genome landscape
key matches its track, and a printed report lays out every page without
the mobile sidebar's backdrop over it.
The QC report page described one dashboard of panels. It now walks the
seven pages, what each answers, and how the results read: flags in words
with the codes kept in every export, pipeline flags that survive
re-flagging, contamination measured against the cohort's target clade,
lineage markers set apart from the resistance a cohort acquired, and the
big panels opening small. The samplesheet's collection-date column gets a
row of its own, LINEAGE_MISMATCH joins the flag codes, the exclusion basket
is the exclusion list everywhere, and the demo report is re-rendered from
its own payload so the documentation shows what a run now produces.
Open the QC report on what the run found
On a 185-sample cohort 1,520 of the 1,579 gene-conversion calls were
rows the samples themselves had left ambiguous, promoted because another
sample called the same stretch outright. They carried the donor's bases
in 2 to 5% of the reads and were fitted at the model's 20% floor, and
the calls vouching for them came from samples at 0.2x to 2x depth whose
tracts were read by one to four molecules.

A tract most of whose sites are under the depth floor, or whose reads
carry the donor's bases below half the thinnest tract the model fits
(10%), is now ambiguous, with a reason that quotes the reads. The cohort
pass applies the same check, so neither kind vouches for another sample
or is promoted by one, and re-running only that step corrects the
per-sample files of an earlier run. A corroborated row says what it fell
short of on its own, the Bayes factor, the read fraction or both,
instead of always quoting the Bayes factor, and the report says its
"Carried by" column cannot read below 20%.
The ubiquity rule divided by every sample in the run, so on a cohort
mapped against two references an artefact of one could never reach the
90% that marks a reference artifact: 113 of 185 samples is 61%. The
fraction is now taken over the samples mapped to the same reference,
which the per-locus files say, leaving out the samples diverged from it.
Those carry the donor's base at every paralogous locus by inheritance,
so they would join every event on their reference and inflate it.

The pooled locus statistics were keyed on the pair number alone, which
mixed pair 61 of one reference with pair 61 of the other; they are keyed
on the contig as well. The share of loci with a call that marks a sample
divergent is counted in distinct stretches of its own reference rather
than in pairs, because a gene family reports one stretch through a pair
per relative and one conversion seen through eleven relatives put a
clean sample on the threshold.
The summary card and the page badge counted every called event alike.
An event found only in samples the QC fails, mixed or contaminated
cultures where the donor's bases have a simpler explanation, is now
counted apart and those samples are named, and the headline and badge
count the events of the samples the QC keeps. A WARN sample counts with
them: a sample to review is not a sample to exclude.

The breakpoint support is counted in events like the headline. Counted
in calls, one event carried by two samples read as more backed events
than there were events.
divergent_sample was keyed on the sample alone, so a sample mapped
against two references and diverged from one lost its tracts on the
other as well. The share of loci was taken over only the contigs the
sample had tracts on: five tracts on a plasmid made a sample with a
clean chromosome divergent. The rows of each per-sample file now carry
the sample and reference the file was written for, the share is taken
over every contig of that reference, and the cohort count leaves a
sample out only on the reference it is diverged from.

The cohort pass demoted a gene_conversion or ambiguous row resting on
too few reads, but not a reciprocal_exchange one, which the per-sample
caller does demote. It now does, and keeps the file's own reason, with
its copy-number and breakpoint notes, when that reason already says as
much. The summary listed, among the events only failed samples carry,
a failed sample whose event a sample the QC keeps also carries.
@Paururo
Paururo merged commit e81e0a5 into main Sep 23, 2026
7 checks passed
@Paururo
Paururo deleted the feat/gene-conversion branch September 23, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant