Skip to content

Commit bac3326

Browse files
luispedroclaude
andcommitted
NEW Collect per-position quality percentiles in FASTQ QC
The Rust QC accumulator dropped the per-position quality histograms that Haskell's `fqStatsC` builds, so `qualityPercentiles` (per-position mean, median, lower/upper quartile) could not be reproduced. `FastQStatsAcc` and `stats_from_reads` now build the same 256-wide per-position histograms from the already-decoded read qualities and expose `qual_percentiles` on `FastQStats`, a faithful port of `qualityPercentiles` (offset by minQualityValue = -5, ceil-based percentile ranks, integer-div mean). This is the data the HTML/JS run report displays; it is now ready for when the report writer lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent de9269f commit bac3326

3 files changed

Lines changed: 121 additions & 7 deletions

File tree

ChangeLog

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ Unreleased
66
* `write()` (and `--export-json`) now write their output files atomically
77
(to a temporary sibling that is renamed into place), so an interrupted or
88
failed run never leaves a half-written output file at the destination
9+
* FASTQ QC now collects per-position quality percentiles (mean/median/lower
10+
and upper quartile) during the statistics pass, matching earlier ngless
11+
versions (used by the run report)
912
* `collect()` under `--subsample` now appends a `.subsample` suffix to its
1013
output file, matching earlier ngless versions
1114
* `count(functional_map=...)` now checks that the requested features are

rust-migration.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Haskell parity target for `ngless "1.5"`+ scripts.
55

66
- **HTML/JS run report.** Haskell's `Output.hs::writeOutputJS` writes a report directory
77
(`output.js` + HTML) at end of run; `src/output.rs` has no report writer (only the console output
8-
layer).
8+
layer). The per-position quality percentiles the report displays *are* now computed by the QC
9+
accumulator (`fastq.rs`, see below), so the data is ready once the report writer lands.
910

1011
- **`Transform.hs` passes: only two edges remain.** Almost all passes are ported
1112
(`transform.rs`): `writeToMove`, `qcInPreprocess`, `ifLenDiscardSpecial`, `substrimReassign`,
@@ -29,9 +30,6 @@ Haskell parity target for `ngless "1.5"`+ scripts.
2930
unsupported (`moduleDirectReference`/`ExternalPackagedReference`; external modules carry no
3031
`references:` section in their YAML).
3132

32-
- **Per-position quality percentiles** (`qualityPercentiles` in `Data/FastQ.hs`) are simplified out
33-
of the Rust QC accumulator.
34-
3533
- **`ARGV` node kind differs (output-affecting).** In Haskell `ARGV` is a module constant
3634
(`BuiltinModules/Argv.hs`) so it parses to `Lookup (Variable "ARGV")`; in Rust it is in `tokens.rs`
3735
`CONSTANTS``BuiltinConstant(Variable("ARGV"))`. Where the idiom `fastq(ARGV[1])` feeds a

src/fastq.rs

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,58 @@ pub struct FastQStats {
7272
pub max_len: i64,
7373
/// Base counts: (A, C, G, T, other), each case-insensitive.
7474
pub bp: (i64, i64, i64, i64, i64),
75+
/// Per-position quality statistics `(mean, median, lower_quartile, upper_quartile)`, one
76+
/// tuple per base position (mirrors `qualityPercentiles` in `Data/FastQ.hs`).
77+
pub qual_percentiles: Vec<(i64, i64, i64, i64)>,
78+
}
79+
80+
/// Quality-value offset for the per-position quality histograms (mirrors Haskell's
81+
/// `minQualityValue`): quality `q` is counted at histogram index `q - MIN_QUALITY_VALUE`.
82+
const MIN_QUALITY_VALUE: i64 = -5;
83+
84+
/// Width of each per-position quality histogram (mirrors the 256-wide `VU.Vector Int`).
85+
const QUAL_HIST_WIDTH: usize = 256;
86+
87+
/// A per-position quality histogram: for each base position a 256-wide count vector indexed by
88+
/// `q - MIN_QUALITY_VALUE` (mirrors `qualCounts :: [VU.Vector Int]`).
89+
type QualCounts = Vec<[i64; QUAL_HIST_WIDTH]>;
90+
91+
/// Fold a read's decoded qualities into the per-position histograms, growing them as needed
92+
/// (mirrors the `update` inner loop of `fqStatsC`).
93+
fn accumulate_qualities(qual_counts: &mut QualCounts, qualities: &[i8]) {
94+
if qual_counts.len() < qualities.len() {
95+
qual_counts.resize(qualities.len(), [0; QUAL_HIST_WIDTH]);
96+
}
97+
for (i, &q) in qualities.iter().enumerate() {
98+
let idx = (q as i64 - MIN_QUALITY_VALUE) as usize;
99+
qual_counts[i][idx] += 1;
100+
}
101+
}
102+
103+
/// Per-position `(mean, median, lower_quartile, upper_quartile)` quality, a faithful port of
104+
/// `qualityPercentiles`. Each output is offset by `MIN_QUALITY_VALUE`, matching Haskell.
105+
fn quality_percentiles(qual_counts: &QualCounts) -> Vec<(i64, i64, i64, i64)> {
106+
qual_counts.iter().map(position_stats).collect()
107+
}
108+
109+
fn position_stats(qs: &[i64; QUAL_HIST_WIDTH]) -> (i64, i64, i64, i64) {
110+
let elem_total: i64 = qs.iter().sum();
111+
// bpSum = Σ i * qs[i]; mean uses Haskell's integer `div` (floor, both operands ≥ 0).
112+
let bp_sum: i64 = qs.iter().enumerate().map(|(i, &q)| i as i64 * q).sum();
113+
let mean = bp_sum / elem_total + MIN_QUALITY_VALUE;
114+
// The index of the first inclusive prefix-sum reaching `ceil(elemTotal * perc)`.
115+
let percentile = |perc: f64| -> i64 {
116+
let lim = (elem_total as f64 * perc).ceil() as i64;
117+
let mut acc = 0i64;
118+
for (i, &q) in qs.iter().enumerate() {
119+
acc += q;
120+
if acc >= lim {
121+
return i as i64 + MIN_QUALITY_VALUE;
122+
}
123+
}
124+
unreachable!("ERROR: Logical impossibility in calcPercentile function")
125+
};
126+
(mean, percentile(0.50), percentile(0.25), percentile(0.75))
75127
}
76128

77129
impl FastQStats {
@@ -99,6 +151,7 @@ pub fn stats_from_reads(reads: &[ShortRead]) -> FastQStats {
99151
let (mut a, mut c, mut g, mut t, mut o) = (0i64, 0i64, 0i64, 0i64, 0i64);
100152
let mut min_len = i64::MAX;
101153
let mut max_len = 0i64;
154+
let mut qual_counts: QualCounts = Vec::new();
102155
for r in reads {
103156
let len = r.sequence.len() as i64;
104157
min_len = min_len.min(len);
@@ -112,12 +165,14 @@ pub fn stats_from_reads(reads: &[ShortRead]) -> FastQStats {
112165
_ => o += 1,
113166
}
114167
}
168+
accumulate_qualities(&mut qual_counts, &r.qualities);
115169
}
116170
FastQStats {
117171
n_seq: reads.len() as i64,
118172
min_len,
119173
max_len,
120174
bp: (a, c, g, t, o),
175+
qual_percentiles: quality_percentiles(&qual_counts),
121176
}
122177
}
123178

@@ -309,9 +364,9 @@ impl<R: BufRead> Iterator for FastqReader<R> {
309364
}
310365

311366
/// Incremental version of [`stats_from_reads`]: fold reads one at a time so QC statistics can
312-
/// be collected during a single streaming pass. Uses only the sequence (never qualities), so it
313-
/// is independent of the quality encoding — which is what lets the QC pass be decoupled from
314-
/// encoding detection. `finish` on an empty accumulator yields `(min_len, max_len) =
367+
/// be collected during a single streaming pass. The reads are already decoded with the file's
368+
/// detected encoding, so the per-position quality histograms (`qual_counts`) match Haskell's
369+
/// `qualCounts`. `finish` on an empty accumulator yields `(min_len, max_len) =
315370
/// (i64::MAX, 0)`, exactly as `stats_from_reads(&[])`.
316371
#[derive(Clone, Debug)]
317372
pub struct FastQStatsAcc {
@@ -323,6 +378,7 @@ pub struct FastQStatsAcc {
323378
g: i64,
324379
t: i64,
325380
o: i64,
381+
qual_counts: QualCounts,
326382
}
327383

328384
impl Default for FastQStatsAcc {
@@ -342,6 +398,7 @@ impl FastQStatsAcc {
342398
g: 0,
343399
t: 0,
344400
o: 0,
401+
qual_counts: Vec::new(),
345402
}
346403
}
347404

@@ -364,6 +421,7 @@ impl FastQStatsAcc {
364421
_ => self.o += 1,
365422
}
366423
}
424+
accumulate_qualities(&mut self.qual_counts, &sr.qualities);
367425
}
368426

369427
/// Fold another accumulator into this one. Every field is a commutative/associative
@@ -379,6 +437,15 @@ impl FastQStatsAcc {
379437
self.g += other.g;
380438
self.t += other.t;
381439
self.o += other.o;
440+
if self.qual_counts.len() < other.qual_counts.len() {
441+
self.qual_counts
442+
.resize(other.qual_counts.len(), [0; QUAL_HIST_WIDTH]);
443+
}
444+
for (dst, src) in self.qual_counts.iter_mut().zip(other.qual_counts.iter()) {
445+
for i in 0..QUAL_HIST_WIDTH {
446+
dst[i] += src[i];
447+
}
448+
}
382449
}
383450

384451
pub fn finish(&self) -> FastQStats {
@@ -387,6 +454,7 @@ impl FastQStatsAcc {
387454
min_len: self.min_len,
388455
max_len: self.max_len,
389456
bp: (self.a, self.c, self.g, self.t, self.o),
457+
qual_percentiles: quality_percentiles(&self.qual_counts),
390458
}
391459
}
392460
}
@@ -922,4 +990,49 @@ mod tests {
922990
assert_eq!(trimmed.sequence, "TTT");
923991
assert_eq!(trimmed.qualities, vec![123, 122, 111]);
924992
}
993+
994+
#[test]
995+
fn quality_percentiles_matches_haskell() {
996+
// Two reads, hand-computed against `qualityPercentiles` (mean, median, lq, uq), each
997+
// offset by minQualityValue = -5.
998+
let reads = vec![
999+
ShortRead::new("a", "AC", vec![10, 20]),
1000+
ShortRead::new("b", "GT", vec![30, 40]),
1001+
];
1002+
// Position 0 qualities {10,30}: mean 20, median 10, lq 10, uq 30.
1003+
// Position 1 qualities {20,40}: mean 30, median 20, lq 20, uq 40.
1004+
let expected = vec![(20, 10, 10, 30), (30, 20, 20, 40)];
1005+
assert_eq!(stats_from_reads(&reads).qual_percentiles, expected);
1006+
1007+
// The streaming accumulator agrees with the batch computation, one read at a time.
1008+
let mut acc = FastQStatsAcc::new();
1009+
for r in &reads {
1010+
acc.update(r);
1011+
}
1012+
assert_eq!(acc.finish().qual_percentiles, expected);
1013+
}
1014+
1015+
#[test]
1016+
fn quality_percentiles_merge_and_uneven_lengths() {
1017+
// Reads of different lengths: only the longer read reaches position 2.
1018+
let left = vec![ShortRead::new("a", "ACG", vec![10, 20, 30])];
1019+
let right = vec![ShortRead::new("b", "AC", vec![14, 24])];
1020+
1021+
let serial = stats_from_reads(&[left[0].clone(), right[0].clone()]);
1022+
1023+
// Merging per-block accumulators in input order matches the single serial fold.
1024+
let mut a = FastQStatsAcc::new();
1025+
for r in &left {
1026+
a.update(r);
1027+
}
1028+
let mut b = FastQStatsAcc::new();
1029+
for r in &right {
1030+
b.update(r);
1031+
}
1032+
a.merge(&b);
1033+
assert_eq!(a.finish().qual_percentiles, serial.qual_percentiles);
1034+
1035+
// Empty input has no per-position statistics.
1036+
assert!(stats_from_reads(&[]).qual_percentiles.is_empty());
1037+
}
9251038
}

0 commit comments

Comments
 (0)