Skip to content

Make subsampling contamination-aware - #5

Merged
jbrestel merged 30 commits into
mainfrom
contamination-aware-subsampling
Sep 9, 2026
Merged

jbrestel merged 30 commits into
mainfrom
contamination-aware-subsampling

Conversation

@jbrestel

@jbrestel jbrestel commented Sep 4, 2026

Copy link
Copy Markdown
Member

The problem

Subsampling runs before any alignment, and calculateMaxReads() was a pure function of
assayType and genomeSize. So the invariant enforced was "N raw reads" while what
downstream workflows actually need is "N on-target reads."

Those coincide only for a pure sample. With substantial host contamination in a mixed sample,
cutting to N raw reads delivers far fewer than N on-target reads, and every child workflow
gets less coverage than it asked for. The pipeline was asserting a coverage guarantee it had
no information to make, because it has never seen an alignment.

The approach

Measure the on-target fraction here, so the cut is made against a number that means something.

  • SKETCH_REFERENCE sketches a user-supplied target-organism FASTA once per run and
    measures genome size.
  • MEASURE_SAMPLE draws a pilot of reads per sample, sketches it, and runs sourmash gather
    to get f_unique_weighted — the on-target fraction.
  • depthPlan (a pure, unit-tested function) inflates the retained read count:
    rawReads = targetOnTargetReads / onTargetFraction, capped at reads available.

A sample that is 12% target retains ~8.3x more raw reads than a clean one, and both land at
the requested coverage after mapping. Verified arithmetic: a 100M-read run at 12% target
against a 23Mb genome at 60x retains 76,666,667 reads and yields exactly 60.0x on-target.

The duplicated CONCATENATE → SUBSAMPLE → FORMAT → collectFile tail that existed in both
main.nf and retrieve_from_sra.nf is extracted into a shared PREPARE_SAMPLES subworkflow.
The fromSra fork now decides only how reads are obtained.

Interface changes

Added --referenceFasta (required), --targetCoverage, --minOnTargetFraction, --minPlausibleFraction, --pilotSize
Removed --genomeSize — now measured from the reference FASTA
New output sample_metrics.csv sidecar

samplesheet.csv's contract is unchanged at sample,fastq_1,fastq_2,var1 — verified
byte-for-byte. Metrics go to a separate sidecar specifically so downstream consumers are
unaffected.

Behavior changes to be aware of

  1. --referenceFasta is required. Runs fail immediately without it, before any download.
  2. An SRA run where fasterq-dump emits 3 files now fails instead of succeeding. Default
    --split-3 emits R1, R2 and an orphan file when some reads lack a mate; the vendored
    module's own .diff renames that orphan so it reached CONCATENATE_FASTQ, which treated
    3 files as single-end and concatenated R1+R2+orphans into one blob — silently destroying
    mate pairing. This predates this branch. Anyone who has processed such a run will now see
    a loud failure where they previously got quietly corrupted output.
  3. An unrecognized --assayType now fails rather than silently defaulting to DNASeq.

Bugs fixed along the way

  • Protein FASTA passed the "is this a nucleotide FASTA" gate. A/C/G/T/N are all valid
    amino-acid codes, so a proteome cleared the old absolute base-count floor and produced a
    fabricated genome size. Now gated on strict-ACGTN as a ratio (>=90%). IUPAC ambiguity
    codes are deliberately excluded from that ratio — they are also amino-acid codes, and
    including them lets protein score ~70%.
  • sourmash gather needs --threshold-bp 0. The default 50kbp threshold writes no result
    row at low overlap, so a 0.2%-target sample parsed as 0.0 — indistinguishable from a wrong
    reference genome. Measured: 0.2% reads as nothing by default, 0.0018 with the flag.
  • || true on gather swallowed real crashes. An OOM or corrupt sketch reported 0.0
    on-target, which the policy reads as maximal contamination and inflates to the cap. Verified
    gather exits 0 on genuine no-match, so the guard was unnecessary and is gone.
  • Zero-read samples crashed with a bare Division by zero in a shared function. Now fails
    naming the sample.
  • -stub-run was completely broken — concatenate_fastq.nf's stub referenced an
    undefined bare hasPairedReads. Never caught because nothing here had ever run a stub.
  • Same-basename inputs collided. A multi-lane samplesheet (lane1/s.fastq.gz,
    lane2/s.fastq.gz) failed with an input-collision error in the very module meant to
    concatenate them. Fixed with stageAs: "?/*".
  • Single-run samples were decompressed and re-gzipped to produce byte-identical output.
    Now symlinked — concat is the pipeline's peak-disk step.

Test plan

nf-test was not installed and had never run here (nextflow test in CLAUDE.md is not a real
command). Bootstrapped, plus 36 tests across 5 files:

  • depth_policy (21) — clamps, floors, RNASeq path, flagging, and loud failure on bad input
  • sketch_reference (5) — genome measurement, protein rejection, headers-only, gzipped
  • measure_sample (3) — known 10% mixture (0.115 vs 0.10 mixing ratio), zero-overlap, reservoir path
  • concatenate_fastq (4) — byte-identity passthrough, multi-file concat, mate-collision fall-through
  • subsample_fastq (3) — cut, passthrough, paired-end pairing integrity
  • Two-sample end-to-end run; published FASTQs verified readable through the symlink chain
  • Stub runs, single-end and paired
  • Missing-reference error fires before any process submission
  • All-flagged run triggers the wrong-genome diagnostic
  • Protein FASTA as reference is rejected

Known gaps

  • No end-to-end test demonstrates inflation. The 1,000,000-read MIN_TARGET_READS floor
    means any fixture-scale run has rawReads capped at totalReads, so inflation never
    changes the outcome. The arithmetic is unit-tested and the wiring is integration-tested;
    nothing is untested, but no single test exercises the core behavior. Closing it would need
    either gigabyte fixtures or a configurable floor.
  • Paired-end coverage runs ~2x high. total_reads counts pairs while coverage credits one
    read length per pair. Pre-existing, deliberately deferred, now documented in README.md.
  • README.org is stale — still documents --genomeSize/--maxReads. It predates
    README.md and appears to be an abandoned duplicate. Left untouched pending a decision to
    delete or maintain it.

🤖 Generated with Claude Code

jbrestel and others added 25 commits September 4, 2026 12:32
Subsampling currently enforces "N raw reads" while consumers need
"N on-target reads". In mixed samples with host contamination those
diverge badly and downstream workflows lose coverage.

Design measures on-target fraction here via sourmash k-mer containment
against a user-supplied reference FASTA, then inflates the raw read
target by that fraction. Also extracts the duplicated
concatenate/subsample/format tail into a shared PREPARE_SAMPLES
subworkflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Default threshold-bp 50000 makes gather write no result row at low
overlap, so a 0.2% target sample parses as 0.0 and is indistinguishable
from a wrong reference FASTA. Verified against synthetic mixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 tasks, TDD throughout. Verified the sourmash mechanics against
synthetic mixtures before planning against them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Installed nf-test 0.9.5 to ~/bin (not committed; a build tool, not repo
content). Dropped the profile "docker" line from nf-test.config's
original spec: the repo has no named 'docker' profile (docker is
enabled unconditionally via conf/docker.config), and nf-test failed
with 'Unknown configuration profile: docker' when it was present.
Verified via --dryRun and a real nf-test run against the vendored
sratools/prefetch stub test, which now gets past config resolution
and fails only on the known out-of-scope missing testdata fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing/comments

- assayType is now validated against DNASeq/RNASeq/ChipSeq and throws on
  an unrecognized value instead of silently falling through to the
  DNASeq coverage path (bug: a typo like RNAseq would previously
  misroute a sample)
- ChipSeq now explicitly uses the coverage-based path like DNASeq
- Named the read-count bounds and RNASeq fixed target as @field
  constants with a short comment on why the bounds exist
- Renamed 'resolved' to 'policyWithReadLength' for clarity
- Documented the deliberate truncation in the coverage-based target
  calculation
- Added 7 tests covering boundary conditions and the assayType fixes
…nly coverage

- Reject non-nucleotide FASTA (e.g. proteomes) by checking strict ACGTN
  composition ratio (>=90%), not just an absolute character floor. A
  protein FASTA can clear the old floor by chance since A/C/G/T/N are
  also valid amino acid codes.
- Guard the sequence-line grep so a headers-only FASTA fails with a
  clear diagnostic instead of an unguarded grep exit under set -e.
- Support gzipped reference FASTA (.fa.gz/.fasta.gz) via a POSIX case
  picking zcat vs cat; confirmed sourmash sketch reads gzip natively.
- Widen the genome-size character count (separate from the composition
  check) to include IUPAC ambiguity codes.
- Comment the k=31/scaled=1000 sourmash parameters and the stub's
  placeholder genomeSize.
- Add protein.fasta, headers_only.fasta, and ref.fasta.gz fixtures
  (deterministic, generated by make_fixtures.py) plus three new
  nf-test cases; existing two tests unchanged and still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also adds stageAs numbered-subdir staging to CONCATENATE_FASTQ's input
to avoid a Nextflow same-basename staging collision, surfaced by the
new multi-file test using one fixture file twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Remove `|| true` on sourmash gather: the real container exits 0 for
  both a match and a genuine no-match (writing no CSV in the latter
  case), so a nonzero exit is a real failure that must not be silently
  reported as 0.0 on-target.
- Collapse three zcat passes into one: a single-pass reservoir sample
  (Algorithm R) counts total reads, builds the pilot, and accumulates
  read-length stats simultaneously, without needing total_reads known
  in advance.
- readLength is now the mean sequence length over the pilot records,
  rounded to a whole number, instead of the first read's length.
- Note stub metrics are wiring-test placeholders, not real data.
- Note the unvalidated meta.id assumption in the --name quoting.
- Add norelation.fastq.gz fixture and a test pinning genuine
  zero-overlap behavior (process succeeds, onTargetFraction == 0.0).

Verified the mixture test's onTargetFraction is bit-for-bit unchanged
(0.11502938706968933) since pilotSize exceeds totalReads there, so the
full population is used under both the old stride and new reservoir
sampling. All pre-existing fixtures are byte-identical after
regenerating tests/fixtures/make_fixtures.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
If a single filename matched both R1 and R2 patterns (e.g.
sample_R1_R2.fastq.gz), r1 and r2 resolved to the same file and both
_concat_1 and _concat_2 were symlinked to it, silently dropping the
other mate. Require r1 != r2 before taking the short-circuit path so
this case falls through to the original concatenation path, which
correctly exit 1s.

Added a test proving the fall-through fails rather than silently
producing a duplicated mate.
Existing tests use fixtures smaller than pilotSize, so total <=
pilot_size is always true and the rand()-based reservoir branch never
executes. Add a test with pilotSize=1000 against the 10000-read mix10
fixture so the reservoir path (9000 of 10000 records competing for
1000 slots) actually runs, asserting totalReads, pilotReads, readLength,
and a widened onTargetFraction band appropriate to a 1000-read subsample.

Confirmed empirically (two runs) that srand(42) makes the sampler
reproducible: onTargetFraction and pilotReads were bit-identical
(0.104, 1000) both times.
… reads

Read counting moved upstream into MEASURE_SAMPLE, so SUBSAMPLE_FASTQ is
now told target_reads/total_reads directly instead of re-deriving
total_reads itself via zcat|wc -l. Input tuple grows from
(meta, reads) + max_reads to (meta, reads, target_reads, total_reads).

Verified read_list[0]/read_list[1] mate ordering: Nextflow sorts
glob-matched path() outputs lexicographically regardless of file
creation order (confirmed empirically), so positional indexing on
CONCATENATE_FASTQ's _1/_2 outputs is safe; no fix needed.

Added main.nf.test with three cases: cuts to target, passes through
untouched when target exceeds total, and paired-end mate-count parity.
Adds workflows/prepare_samples.nf, tying together CONCATENATE_FASTQ,
MEASURE_SAMPLE, depthPlan and SUBSAMPLE_FASTQ into one subworkflow
that both the local-files and fromSra paths now share. Rewrites
retrieve_from_sra.nf to delegate into PREPARE_SAMPLES instead of
duplicating the concatenate/subsample/format steps, and rewrites
main.nf to sketch the reference genome up front, build the depth
policy from measured genome size, and surface a clear error when
--referenceFasta is missing or every sample is flagged as implausible.

nextflow.config: genomeSize is dropped (now measured via
SKETCH_REFERENCE) in favor of referenceFasta, targetCoverage,
minOnTargetFraction, minPlausibleFraction and pilotSize.

Verified end-to-end with a two-sample local run: both samples flow
through CONCATENATE_FASTQ -> MEASURE_SAMPLE -> SUBSAMPLE_FASTQ ->
FORMAT_INPUT_FROM_SRA without channel splitting, the samplesheet.csv
header contract is unchanged, and published subsampled FASTQs contain
real read data. Confirmed depthPlan resolves correctly through
`include` (Field constants and the internal targetOnTargetReads call
both work), and the all-flagged workflow.onComplete diagnostic fires
correctly with a plain `def sampleFlags`.

Found and left untouched: modules/local/concatenate_fastq.nf's stub
block references a bare `hasPairedReads` instead of
`meta.hasPairedReads`, breaking -stub-run for the local-files path.
Pre-existing bug, out of scope per task instructions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stub block referenced a bare `hasPairedReads` instead of
`meta.hasPairedReads`, so any -stub-run crashed with "No such variable:
hasPairedReads". This predates the current work and was never caught
because nothing in this repo had exercised -stub-run successfully.

While fixing it, also corrected the stub's output filenames. The real
script emits `${meta.id}_concat[...].fastq.gz`, but the stub emitted
`${meta.id}[...].fastq.gz`. The output glob `${meta.id}*.fastq.gz`
happened to match both, so this wasn't breaking anything today, but a
stub that names its files differently from the real path is a latent
trap for anyone using -stub-run to sanity-check wiring. Renamed the
stub outputs to match the real `_concat` naming.

Audited every other stub: block under modules/local/ for the same
class of defect (bare variables, filenames inconsistent with the real
script, missing required outputs). sketch_reference.nf, subsample_fastq.nf,
measure_sample.nf, and format_input_from_sra.nf were all already
correct - no further fixes needed.

Also added the untracked artifacts every real/stub run produces
(.nextflow/, .nextflow.log*, ngs-samples-work/, ngs-samples-output/)
to .gitignore, since they were one careless `git add -A` away from
being committed.
SKETCH_REFERENCE runs on a Channel.value() input, so its .sig and
.stats outputs are already value channels - .first() on them was a
no-op that produced two spurious WARN lines on every run.

Verified with a real two-sample run that reference_sig and policy
still broadcast to every consumer (MEASURE_SAMPLE, and the
.combine(policy) in prepare_samples.nf): both samples completed every
stage (CONCATENATE_FASTQ, MEASURE_SAMPLE, SUBSAMPLE_FASTQ,
FORMAT_INPUT_FROM_SRA all show 2 of 2), and both appear in the
resulting samplesheet.csv and sample_metrics.csv. No .first() warnings
in the output.
Both docs still described the pre-contamination-aware pipeline:
`nextflow test <path>` (not a real Nextflow command), `--genomeSize`
and `--maxReads` (both removed), 30x/50x coverage targets (actual
code: 60x DNASeq/ChipSeq, fixed 20M reads for RNASeq), and a process
flow that predates PREPARE_SAMPLES, MEASURE_SAMPLE, and depth_policy.

CLAUDE.md changes beyond the three blocks specified:
- Overview: mention the contamination-aware step both modes share
- Pipeline Structure: added prepare_samples.nf, sketch_reference.nf,
  measure_sample.nf, depth_policy.nf
- Key Parameters: added referenceFasta/maxDownloadSize, dropped the
  stale genomeSize/maxReads entries, fixed input/fromSra defaults
- Multi-file concatenation section: fixed output filenames to the
  real `_concat`/`_concat_1`/`_concat_2` naming
- Container Management: added sourmash and seqtk images
- Error Handling: added SKETCH_REFERENCE's composition checks and the
  onComplete all-flagged error
- File Locations: listed the new modules
- New Features: replaced "Read Subsampling" with "Contamination-Aware
  Subsampling" describing the on-target-fraction approach and correct
  coverage numbers

README.md changes beyond the specified Outputs/Contamination-aware
sections:
- Usage examples: added the now-required --referenceFasta
- Key parameters table: dropped --genomeSize, added --referenceFasta,
  --targetCoverage, --minOnTargetFraction, --minPlausibleFraction,
  --pilotSize
- Folded the old single-line subsampling description into the new
  "Contamination-aware subsampling" section
- Noted that estimated_coverage can legitimately read low for a
  small sample (raw_reads_used is capped at reads available), not
  just for a failed sequencing run

README.org was left untouched. It is an unmaintained duplicate from
initial development (last touched in the original "wip; initial dev"
commit, before README.md was added later as "clean pipeline
documentation" in 747d701) - it predates and was superseded by
README.md rather than being a currently maintained doc, so it was not
updated to avoid maintaining two copies of the same content going
forward. Flagging this for a human decision on whether to delete it.
Fix 1: zero-read samples previously crashed depthPlan with an opaque
division-by-zero stack trace pointing at depth_policy.nf. Guard in both
places (defence in depth, each guards a different consumer):
- measure_sample.nf now fails loudly, naming the sample, before
  attempting to sketch an empty pilot.
- depth_policy.nf's depthPlan now rejects null/<=0 readLength and
  totalReads with a clear IllegalArgumentException instead of dividing
  by zero.

Fix 2: depthPlan now validates onTargetFraction is within [0.0, 1.0]
and minOnTargetFraction is > 0.0. An out-of-range onTargetFraction
(corrupt gather.csv, sourmash version change, hand-edited metrics.json)
previously silently under-shot coverage; a minOnTargetFraction of 0
would silently truncate to 0 rather than raise.

Fix 3: document in the README that paired-end samples retain roughly
2x the coverage implied by --targetCoverage (totalReads counts pairs,
coverage credits one read length per pair), and clarify that the
1M/100M bounds clamp the on-target read target, not the number of
reads written out.

Adds depth_policy_tests cases for zero readLength, zero totalReads,
onTargetFraction out of range in both directions, and
minOnTargetFraction of 0.0. No existing tests modified.
fasterq-dump's default splitting (no --split-files override is set
anywhere in this repo's config, confirmed via modules.config and the
vendored module + its .diff) emits a third orphan file when some reads
in an SRA run lack a mate. The vendored fasterqdump module even renames
that orphan file to the sample prefix so it survives into the
*.fastq.gz glob as a genuine third file — this is reachable here, not
theoretical.

Previously `meta.hasPairedReads = reads.size() == 2` treated a 3-file
result as single-end, and CONCATENATE_FASTQ's single-end path silently
concatenated R1 + R2 + orphans into one blob, destroying mate pairing
with no error anywhere.

Now grouped_reads throws IllegalStateException naming the sample and
file count when fasterq-dump produces anything other than 1 or 2 files.

BEHAVIOR CHANGE: a run that previously "succeeded" with silently
corrupted pairing on such a sample will now fail instead. This
condition predates this branch; failing loudly on it is intentional
and should be called out in the PR.
jbrestel and others added 2 commits September 8, 2026 13:18
MEASURE_SAMPLE counts R1 records only, so totalReads is a fragment count.
depth_policy multiplied it by a single readLength, halving reported coverage
on paired libraries and doubling the read target used for subsampling.

Pairedness now enters via metrics.mateCount (from meta.hasPairedReads) as
basesPerFragment = readLength * mateCount. mateCount is required and
validated: a silent single-end default would reinstate the same bug.

RNASeq is unchanged - its 20M target is already quoted in fragments.

sample_metrics.csv columns renamed to match (total_fragments,
raw_fragments_used) with a new mate_count column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jbrestel and others added 3 commits September 9, 2026 14:43
Host contamination is expected across much of this data - parasite RNA/DNA
from infected host tissue is routinely a few percent on-target. Treating
"every sample flagged" as a wrong-reference error made the common case look
like a misconfiguration.

The per-sample warning already carries the wrong-reference hint, so the
onComplete aggregate only duplicated it at a louder severity. Removing it
also retires the sampleFlags mutable list and the flags emit that existed
solely to feed it; sample_metrics.csv keeps the durable flagged column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChIP-seq depth accrues under peaks, not across the genome, so deriving it
from genomeSize * targetCoverage scaled it on the wrong axis: a larger
genome does not need proportionally more depth for the same peak set.
ChipSeq now uses a flat 20M on-target fragment target and reports no
estimated_coverage, matching the RNASeq path. targetCoverage applies to
DNASeq alone.

The two fixed-target assays now share one FIXED_FRAGMENT_TARGETS table so
adding another is a single line rather than a new branch.

Also corrects README text that had gone stale: the sample_metrics.csv column
list, and a "paired-end coverage runs 2x high" section that documented the
fragment-unit defect as intended behaviour. Floor-threshold examples are
recomputed per fragment, and the wrong-reference wording now leads with host
contamination as the expected cause of a low on-target fraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Most of this ChIP-seq is histone modification, which skews broad: domain
marks need more depth than a point-source factor to separate enrichment
from background. 20M was the narrow/TF figure.

30M rather than the conventional 45M because that figure assumes a ~3Gb
genome; these targets are ~23Mb, where 45M buys depth the peak set cannot
use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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