Skip to content

fix(flagstat): split counts by QC pass/fail and follow samtools' flagstat_loop - #175

Draft
NicoDeVeaux wants to merge 2 commits into
mainfrom
ndv/fix-flagstat-qcfail-proper-dup
Draft

NicoDeVeaux wants to merge 2 commits into
mainfrom
ndv/fix-flagstat-qcfail-proper-dup

Conversation

@NicoDeVeaux

Copy link
Copy Markdown

fix(flagstat): split counts by QC pass/fail and follow samtools' flagstat_loop

What

samtools/<sample>.flagstat now matches samtools flagstat 1.22.1 (and 1.24) on every 12-bit flag, except the SECONDARY|SUPPLEMENTARY line, which is deliberately unchanged (#125, below). Three divergences are fixed:

  1. QC-failed reads (0x200) go in the second column. flagstat.rs printed <n> + 0 and N/A for every line, folding QC-failed reads into the QC-passed column and changing its percentages.
  2. "properly paired" requires the read itself to be mapped, as samtools does (bam_stat.c:61: (flag & BAM_FPROPER_PAIR) && !(flag & BAM_FUNMAP)). A PAIRED|PROPER_PAIR record that is unmapped (e.g. 0x4F) was counted.
  3. "duplicates" counts every 0x400 record. It reused RSeQC bam_stat's cascade counter, where a QC-failed duplicate (0x600) returns at the QC-fail step, so it was in neither duplicates column while "primary duplicates" counted it, and primary duplicates could exceed duplicates.

The flagstat counters move to a FlagstatCounts struct of [QC-passed, QC-failed] pairs, counted like samtools' flagstat_loop and merged across parallel workers. The six counters that only fed flagstat (mapped, read1_flagstat, read2_flagstat, both_mapped, singletons, mate_diff_chr) are removed from BamStatAccum/BamStatResult; the ones samtools stats output reads are untouched, so stats and RSeQC bam_stat output do not change.

Why

Each case is two plain mapped reads (chr1:1001, chr1:1201) plus one record at chr1:1001:

Record Line samtools 1.22.1 RustQC main this branch
0x200 in total / primary / mapped 2 + 1, mapped (100.00% : 100.00%) 3 + 0, (100.00% : N/A) as samtools
0x4F (mate =) properly paired 0 + 0 (0.00% : N/A) 1 + 0 (100.00% : N/A) as samtools
0x600 duplicates / primary duplicates 0 + 1 / 0 + 1 0 + 0 / 1 + 0 as samtools

The same record without the trigger bit (0x0, 0x4B, 0x400) gives identical output in both tools at main.

How tested

  • Unit tests (src/rna/rseqc/flagstat.rs): a transcription of samtools 1.22.1 flagstat_loop and out_fmt_default (bam_stat.c:48-79, 124-143) is the oracle. test_flagstat_matches_samtools_flagstat_loop_per_flag checks each of the 4,096 flags alone in three placements (mate on the same contig; mate on another contig with MAPQ 60; with MAPQ 3); ..._merged checks all 12,288 records split across two merged accumulators. At main: 6,336 of 12,288 cases differ.
  • Integration tests run rustqc rna and compare the whole flagstat file with samtools 1.22.1 output: the three cases above (test_flagstat_qc_failed_column, test_flagstat_properly_paired_requires_mapped_read, test_flagstat_qc_failed_duplicate_counts_as_duplicate), and test_flagstat_matches_samtools_on_every_flag, every flag in three placements across chr1/chr2 at --threads 1 and 2, against tests/expected/samtools/all_flags.flagstat (generated once with samtools 1.22.1; command in the README beside it; 1.24 prints the same). All four fail at main and pass here.
  • cargo test --release --locked --offline: 202 unit, 12 bin, 23 integration, 2 doc tests pass. cargo fmt --check passes. cargo clippy -- -D warnings reports the same three needless_range_loop errors as unmodified main with the local toolchain (clippy 0.1.90); with that lint allowed, --all-targets is clean.
  • Formal-review harness against the built binary: flagstat_exhaustive.py (each flag plus two background reads, vs samtools 1.22.1) goes from 2,624 of 4,096 flags differing at main to 1,024, all of them the samtools flagstat: reads with both SECONDARY and SUPPLEMENTARY flags are double-counted in the supplementary total #125 line; counterexamples.py shows Q1, Q3 and Q4 no longer differ and Q2 (samtools flagstat: reads with both SECONDARY and SUPPLEMENTARY flags are double-counted in the supplementary total #125) still does. Both scripts now exit 1 because they assert the old divergences.

Not changed, and why

🤖 Generated with Claude Code

NicoDeVeaux and others added 2 commits October 23, 2026 11:13
…plicate counts

Failing tests for three divergences from samtools flagstat 1.22.1:

- QC-failed reads (0x200) are counted in the QC-passed column; the second
  column is always 0.
- "properly paired" counts a PAIRED|PROPER_PAIR record that is itself
  unmapped (samtools requires !UNMAP, bam_stat.c:61).
- A QC-failed duplicate (0x600) is in neither "duplicates" column, while
  "primary duplicates" counts it.

The unit tests check every 12-bit flag against a transcription of samtools'
flagstat_loop; the integration tests compare `rustqc rna` output with
golden output from samtools 1.22.1. The SECONDARY|SUPPLEMENTARY line
(#125) is kept at its current behaviour and pinned as such.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stat_loop

flagstat now keeps its own `[QC-passed, QC-failed]` counters
(`FlagstatCounts`), counted like samtools' `flagstat_loop` (bam_stat.c):

- QC-failed reads (0x200) go in the second column, with their own
  percentages, instead of being folded into the first.
- "properly paired" requires the read itself to be mapped.
- "duplicates" counts every 0x400 record, not the RSeQC bam_stat cascade
  counter that drops QC-failed duplicates.

The SECONDARY|SUPPLEMENTARY case (#125) is unchanged: such a record still
counts on both lines. The counters that only fed flagstat are removed from
BamStatAccum/BamStatResult; those samtools stats reads are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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