Repository navigation
Make subsampling contamination-aware - #5
Merged
Merged
Conversation
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.
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Subsampling runs before any alignment, and
calculateMaxReads()was a pure function ofassayTypeandgenomeSize. So the invariant enforced was "N raw reads" while whatdownstream 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_REFERENCEsketches a user-supplied target-organism FASTA once per run andmeasures genome size.
MEASURE_SAMPLEdraws a pilot of reads per sample, sketches it, and runssourmash gatherto 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 → collectFiletail that existed in bothmain.nfandretrieve_from_sra.nfis extracted into a sharedPREPARE_SAMPLESsubworkflow.The
fromSrafork now decides only how reads are obtained.Interface changes
--referenceFasta(required),--targetCoverage,--minOnTargetFraction,--minPlausibleFraction,--pilotSize--genomeSize— now measured from the reference FASTAsample_metrics.csvsidecarsamplesheet.csv's contract is unchanged atsample,fastq_1,fastq_2,var1— verifiedbyte-for-byte. Metrics go to a separate sidecar specifically so downstream consumers are
unaffected.
Behavior changes to be aware of
--referenceFastais required. Runs fail immediately without it, before any download.fasterq-dumpemits 3 files now fails instead of succeeding. Default--split-3emits R1, R2 and an orphan file when some reads lack a mate; the vendoredmodule's own
.diffrenames that orphan so it reachedCONCATENATE_FASTQ, which treated3 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.
--assayTypenow fails rather than silently defaulting to DNASeq.Bugs fixed along the way
A/C/G/T/Nare all validamino-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 gatherneeds--threshold-bp 0. The default 50kbp threshold writes no resultrow 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.
|| trueon gather swallowed real crashes. An OOM or corrupt sketch reported 0.0on-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.
Division by zeroin a shared function. Now failsnaming the sample.
-stub-runwas completely broken —concatenate_fastq.nf's stub referenced anundefined bare
hasPairedReads. Never caught because nothing here had ever run a stub.lane1/s.fastq.gz,lane2/s.fastq.gz) failed with an input-collision error in the very module meant toconcatenate them. Fixed with
stageAs: "?/*".Now symlinked — concat is the pipeline's peak-disk step.
Test plan
nf-test was not installed and had never run here (
nextflow testin CLAUDE.md is not a realcommand). Bootstrapped, plus 36 tests across 5 files:
depth_policy(21) — clamps, floors, RNASeq path, flagging, and loud failure on bad inputsketch_reference(5) — genome measurement, protein rejection, headers-only, gzippedmeasure_sample(3) — known 10% mixture (0.115 vs 0.10 mixing ratio), zero-overlap, reservoir pathconcatenate_fastq(4) — byte-identity passthrough, multi-file concat, mate-collision fall-throughsubsample_fastq(3) — cut, passthrough, paired-end pairing integrityKnown gaps
MIN_TARGET_READSfloormeans any fixture-scale run has
rawReadscapped attotalReads, so inflation neverchanges 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.
total_readscounts pairs while coverage credits oneread length per pair. Pre-existing, deliberately deferred, now documented in README.md.
README.orgis stale — still documents--genomeSize/--maxReads. It predatesREADME.mdand appears to be an abandoned duplicate. Left untouched pending a decision todelete or maintain it.
🤖 Generated with Claude Code