dsh-molbio-tools 0.22.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +51 -19
  3. package/benchmark/README.md +7 -7
  4. package/benchmark/fixtures.mjs +32 -4
  5. package/benchmark/run.mjs +1 -0
  6. package/benchmark/sequences.mjs +1 -0
  7. package/benchmark/tasks/02-primers.json +3 -3
  8. package/benchmark/tasks/06-analysis.json +111 -1
  9. package/benchmark/tasks/09-formats.json +44 -1
  10. package/benchmark/verifications.mjs +13 -0
  11. package/build/client-entry.mjs +80 -1
  12. package/build/panel-core.mjs +85 -0
  13. package/build/release-preflight.mjs +1 -1
  14. package/contamination.mjs +229 -0
  15. package/cordis.patch.yml +9 -5
  16. package/design.mjs +111 -78
  17. package/docs/capability-gap-survey.md +5 -0
  18. package/docs/client-panel.md +9 -7
  19. package/docs/history.md +62 -0
  20. package/docs/maintainer.md +8 -5
  21. package/docs/roadmap.md +33 -15
  22. package/docs/rules.md +5 -2
  23. package/docs/store-evidence.md +30 -23
  24. package/docs/store-preset-exemption-issue.md +1 -1
  25. package/docs/store-preset-exemption.md +1 -1
  26. package/docs/v24-plan.md +178 -0
  27. package/docs/workflow.md +11 -10
  28. package/dotplot.mjs +257 -0
  29. package/fastq-qc.mjs +6 -9
  30. package/fastq-trim.mjs +238 -0
  31. package/index.mjs +470 -24
  32. package/lib/client.js +327 -93
  33. package/package.json +8 -3
  34. package/preset/molbio-lab/agent.cordis.yml +19 -13
  35. package/preset/molbio-lab/preset.yml +1 -1
  36. package/protein-charge.mjs +158 -0
  37. package/protein.mjs +36 -14
  38. package/seqio.mjs +10 -0
  39. package/test/client.mjs +12 -5
  40. package/test/contract.mjs +44 -12
  41. package/test/drift-probe.mjs +15 -1
  42. package/test/map-card.mjs +139 -1
  43. package/test/preset-health.mjs +39 -0
  44. package/test/smoke.mjs +288 -11
package/dotplot.mjs ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * dsh-molbio-tools/dotplot.mjs
3
+ *
4
+ * Self-comparison dotplot by exact word matching: a dot at (i, j) means the
5
+ * k-mer starting at i equals the k-mer starting at j (direct repeat), and a red
6
+ * dot means it equals the reverse complement of the k-mer at j (inverted
7
+ * repeat). Pure computation; the SVG is binned into a fixed pixel grid so a
8
+ * repetitive sequence cannot produce a million-element document.
9
+ *
10
+ * Deliberately NOT a window/identity dotplot (EMBOSS dotmatcher-style): this is
11
+ * the k-mer view, which is exact, deterministic and needs no scoring matrix.
12
+ */
13
+ import { MolbioInputError, normalizeSequence } from './lib.mjs';
14
+ import { axis, linearScale, niceTicks, panel, rect, svgDocument, textRun } from './svgio.mjs';
15
+
16
+ /** Accepted ranges and work caps. */
17
+ export const DOTPLOT_LIMITS = {
18
+ minWord: 4,
19
+ maxWord: 12,
20
+ maxLength: 20_000,
21
+ maxPairs: 200_000,
22
+ maxCells: 60_000,
23
+ grid: 400,
24
+ };
25
+
26
+ const round = (value, digits = 3) => Math.round(value * 10 ** digits) / 10 ** digits;
27
+
28
+ /** Reverse complement of one canonical k-mer (A/C/G/T only). */
29
+ function rcWord(word) {
30
+ let out = '';
31
+ for (let index = word.length - 1; index >= 0; index--) {
32
+ const base = word[index];
33
+ out += base === 'A' ? 'T' : base === 'C' ? 'G' : base === 'G' ? 'C' : 'A';
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * Compute the self-comparison dotplot.
40
+ *
41
+ * @param {string} raw DNA sequence (IUPAC; ambiguous k-mers are skipped).
42
+ * @param {{word?: number, mode?: 'direct'|'inverted'|'both', grid?: number}} [options]
43
+ * @returns {{length: number, word: number, mode: string, direct_matches: number,
44
+ * inverted_matches: number, direct_cells: number, inverted_cells: number,
45
+ * ambiguous_bases: number, truncated: boolean, plot_truncated: boolean,
46
+ * grid: number, directGrid: Set<number>, invertedGrid: Set<number>}}
47
+ */
48
+ export function dotplot(raw, options = {}) {
49
+ const sequence = normalizeSequence(raw, 'sequence');
50
+ if (sequence.length > DOTPLOT_LIMITS.maxLength) {
51
+ throw new MolbioInputError('the sequence is ' + sequence.length + ' bp; the dotplot limit is ' + DOTPLOT_LIMITS.maxLength + ' bp');
52
+ }
53
+ const word = options.word ?? 8;
54
+ if (!Number.isInteger(word) || word < DOTPLOT_LIMITS.minWord || word > DOTPLOT_LIMITS.maxWord) {
55
+ throw new MolbioInputError('word must be an integer between ' + DOTPLOT_LIMITS.minWord + ' and ' + DOTPLOT_LIMITS.maxWord);
56
+ }
57
+ if (sequence.length < word * 2) {
58
+ throw new MolbioInputError('the sequence is ' + sequence.length + ' bp; at word ' + word + ' it needs at least ' + word * 2 + ' bp to show a repeat');
59
+ }
60
+ const mode = options.mode ?? 'both';
61
+ if (!['direct', 'inverted', 'both'].includes(mode)) {
62
+ throw new MolbioInputError('mode must be "direct", "inverted" or "both"');
63
+ }
64
+ const grid = options.grid ?? DOTPLOT_LIMITS.grid;
65
+ if (!Number.isInteger(grid) || grid < 50 || grid > 1000) {
66
+ throw new MolbioInputError('grid must be an integer between 50 and 1000');
67
+ }
68
+
69
+ const index = new Map();
70
+ let ambiguousBases = 0;
71
+ for (let start = 0; start + word <= sequence.length; start++) {
72
+ const wordText = sequence.slice(start, start + word);
73
+ let canonical = true;
74
+ for (const base of wordText) {
75
+ if (base !== 'A' && base !== 'C' && base !== 'G' && base !== 'T') {
76
+ canonical = false;
77
+ break;
78
+ }
79
+ }
80
+ if (!canonical) {
81
+ ambiguousBases++;
82
+ continue;
83
+ }
84
+ const list = index.get(wordText);
85
+ if (list === undefined) index.set(wordText, [start]);
86
+ else list.push(start);
87
+ }
88
+
89
+ const length = sequence.length;
90
+ const cellOf = (position) => Math.min(grid - 1, Math.floor((position / length) * grid));
91
+ const directGrid = new Set();
92
+ const invertedGrid = new Set();
93
+ let directMatches = 0;
94
+ let invertedMatches = 0;
95
+ let truncated = false;
96
+ let plotTruncated = false;
97
+ let work = 0;
98
+
99
+ const addCell = (set, x, y) => {
100
+ const key = x * 4096 + y;
101
+ if (set.has(key)) return;
102
+ // Bound the RENDERED cells, not just the match count: a dense plot must not
103
+ // produce a document with one <rect> per match.
104
+ if (directGrid.size + invertedGrid.size >= DOTPLOT_LIMITS.maxCells) {
105
+ plotTruncated = true;
106
+ return;
107
+ }
108
+ set.add(key);
109
+ };
110
+
111
+ outer: for (const [wordText, positions] of index) {
112
+ if (mode === 'direct' || mode === 'both') {
113
+ for (let a = 0; a < positions.length; a++) {
114
+ for (let b = 0; b < positions.length; b++) {
115
+ if (work >= DOTPLOT_LIMITS.maxPairs) {
116
+ truncated = true;
117
+ break outer;
118
+ }
119
+ work++;
120
+ directMatches++;
121
+ addCell(directGrid, cellOf(positions[a]), cellOf(positions[b]));
122
+ }
123
+ }
124
+ }
125
+ if (mode === 'inverted' || mode === 'both') {
126
+ const rc = rcWord(wordText);
127
+ if (rc === wordText) continue; // a palindromic word would double-count the same pairs
128
+ const rcPositions = index.get(rc);
129
+ if (rcPositions === undefined) continue;
130
+ for (const from of positions) {
131
+ for (const to of rcPositions) {
132
+ if (work >= DOTPLOT_LIMITS.maxPairs) {
133
+ truncated = true;
134
+ break outer;
135
+ }
136
+ work++;
137
+ invertedMatches++;
138
+ addCell(invertedGrid, cellOf(from), cellOf(to));
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ return {
145
+ length,
146
+ word,
147
+ mode,
148
+ direct_matches: directMatches,
149
+ inverted_matches: invertedMatches,
150
+ direct_cells: directGrid.size,
151
+ inverted_cells: invertedGrid.size,
152
+ ambiguous_bases: ambiguousBases,
153
+ truncated,
154
+ plot_truncated: plotTruncated,
155
+ grid,
156
+ directGrid,
157
+ invertedGrid,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Render a computed dotplot as an SVG document. The grid is binned: one
163
+ * `<rect>` per occupied cell, so the document size is bounded by the grid, not
164
+ * by the number of matches.
165
+ *
166
+ * @param {ReturnType<typeof dotplot>} data
167
+ * @param {{title?: string}} [options]
168
+ * @returns {string} SVG text.
169
+ */
170
+ export function renderDotplot(data, options = {}) {
171
+ const width = 720;
172
+ // The x axis needs a title line BELOW its tick labels, and the legend needs a
173
+ // home INSIDE the panel. At 720 px the plot was 628 px square and the title /
174
+ // legend fell on (or outside) the bottom border; 760 px keeps the plot square
175
+ // (same 628 px) while giving both a row of their own. `bottom: 66` is what
176
+ // preserves that square: 728 (frame) - 34 (top) - 66 = 628.
177
+ const height = 760;
178
+ const frame = panel({
179
+ x: 16,
180
+ y: 16,
181
+ width: width - 32,
182
+ height: height - 32,
183
+ inset: { top: 34, right: 14, bottom: 66, left: 46 },
184
+ title: options.title ?? 'Sequence dotplot',
185
+ subtitle: data.length + ' bp · word ' + data.word + ' · ' + data.mode + ' · ' + data.direct_matches + ' direct / ' + data.inverted_matches + ' inverted match(es)'
186
+ + (data.truncated ? ' · scan capped' : '') + (data.ambiguous_bases > 0 ? ' · ' + data.ambiguous_bases + ' ambiguous k-mer(s) skipped' : ''),
187
+ });
188
+ const plot = frame.plot;
189
+ const x = linearScale([1, data.length], [plot.x, plot.x + plot.width]);
190
+ const y = linearScale([1, data.length], [plot.y + plot.height, plot.y]);
191
+ const cellWidth = plot.width / data.grid;
192
+ const cellHeight = plot.height / data.grid;
193
+ const parts = [frame.markup];
194
+ const drawCells = (set, fill, opacity) => {
195
+ const cells = [];
196
+ for (const key of set) {
197
+ const cx = Math.floor(key / 4096);
198
+ const cy = key - cx * 4096;
199
+ cells.push(rect({
200
+ x: round(plot.x + cx * cellWidth, 2),
201
+ y: round(plot.y + cy * cellHeight, 2),
202
+ width: Math.max(1, round(cellWidth, 2)),
203
+ height: Math.max(1, round(cellHeight, 2)),
204
+ fill,
205
+ fillOpacity: opacity,
206
+ }));
207
+ }
208
+ return cells.join('\n');
209
+ };
210
+ if (data.invertedGrid.size > 0) parts.push(drawCells(data.invertedGrid, '#cf222e', 0.55));
211
+ if (data.directGrid.size > 0) parts.push(drawCells(data.directGrid, '#24292f', 0.7));
212
+ parts.push(axis({
213
+ from: plot.x,
214
+ to: plot.x + plot.width,
215
+ at: plot.y + plot.height,
216
+ ticks: niceTicks([1, data.length], 6).map((tick) => x.of(tick)),
217
+ orientation: 'x',
218
+ tickLabel: (pixel) => String(Math.round(x.invert(pixel))),
219
+ title: 'position (bp)',
220
+ }));
221
+ parts.push(axis({
222
+ from: plot.y,
223
+ to: plot.y + plot.height,
224
+ at: plot.x,
225
+ ticks: niceTicks([1, data.length], 6).map((tick) => y.of(tick)),
226
+ orientation: 'y',
227
+ tickLabel: (pixel) => String(Math.round(y.invert(pixel))),
228
+ title: 'position (bp)',
229
+ }));
230
+ parts.push(legend(data));
231
+ return svgDocument({
232
+ width,
233
+ height,
234
+ title: options.title ?? 'Sequence dotplot',
235
+ description: 'Exact ' + data.word + '-mer self-comparison of a ' + data.length + ' bp sequence; black cells are direct repeats, red cells are inverted repeats.',
236
+ body: parts.join('\n'),
237
+ });
238
+ }
239
+
240
+ /** A compact legend; omitted when the corresponding mode produced no cells. */
241
+ function legend(data) {
242
+ const entries = [];
243
+ // Only label a class that was actually computed AND has dots: in
244
+ // inverted-only mode a black "direct repeat" key would claim a measurement
245
+ // that never ran.
246
+ if (data.directGrid.size > 0) entries.push(['#24292f', 'direct repeat']);
247
+ if (data.invertedGrid.size > 0) entries.push(['#cf222e', 'inverted repeat']);
248
+ let x = 28;
249
+ const y = 730;
250
+ const parts = [];
251
+ for (const [color, label] of entries) {
252
+ parts.push(rect({ x, y: y - 8, width: 9, height: 9, fill: color }));
253
+ parts.push(textRun({ x: x + 13, y, text: label, size: 9, fill: '#57606a' }));
254
+ x += 130;
255
+ }
256
+ return parts.join('\n');
257
+ }
package/fastq-qc.mjs CHANGED
@@ -28,6 +28,11 @@
28
28
  */
29
29
 
30
30
  import { MolbioInputError } from './lib.mjs';
31
+ import { ADAPTERS } from './contamination.mjs';
32
+
33
+ // Re-exported for callers that used this module's adapter table directly. The
34
+ // table now lives in contamination.mjs and is DNA-normalized there.
35
+ export { ADAPTERS } from './contamination.mjs';
31
36
  import {
32
37
  axis,
33
38
  escapeXml,
@@ -63,15 +68,7 @@ export const FASTQ_QC_DEFAULTS = {
63
68
  duplication_bins: 10,
64
69
  };
65
70
 
66
- /** Public adapter/primer fragments used for the adapter-content screen. */
67
- export const ADAPTERS = [
68
- { name: 'Illumina Universal', sequence: 'AGATCGGAAGAG' },
69
- { name: 'Illumina Small RNA 3\'', sequence: 'TGGAATTCTCGG' },
70
- { name: 'Illumina Small RNA 5\'', sequence: 'GUUCAGAGUUCUACAGUCCGACGAUC' },
71
- { name: 'Nextera Transposase', sequence: 'CTGTCTCTTATA' },
72
- { name: 'PolyA', sequence: 'AAAAAAAAAAAA' },
73
- { name: 'PolyG', sequence: 'GGGGGGGGGGGG' },
74
- ];
71
+ /** Public adapter/primer fragments: see contamination.mjs (DNA-normalized). */
75
72
 
76
73
  /** Phred+33 decoding, with the offending read id in the message. */
77
74
  function phredOf(character) {
package/fastq-trim.mjs ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * dsh-molbio-tools/fastq-trim.mjs
3
+ *
4
+ * Pure FASTQ trimming/filtering: 5' primer removal, 3' adapter removal, 3'
5
+ * sliding-window quality trimming, poly-G/poly-A tail trimming, and length/N
6
+ * filters. No file I/O and no quality re-encoding — the tool layer reads and
7
+ * writes through ctx.fs, while this module only transforms entry objects.
8
+ *
9
+ * The deliberately narrow scope is stated in the tool description: this is not
10
+ * a paired-end merger, not cutadapt's semiglobal aligner, and not a UMI
11
+ * pipeline. The algorithms here are the ones a bench user can reason about.
12
+ */
13
+ import { MolbioInputError, normalizeSequence } from './lib.mjs';
14
+
15
+ /** Defaults and hard bounds shared by the tool, tests and docs. */
16
+ export const FASTQ_TRIM_DEFAULTS = {
17
+ minQuality: 20,
18
+ qualityWindow: 4,
19
+ polyMin: 8,
20
+ minLength: 0,
21
+ maxNPercent: 100,
22
+ phred: 33,
23
+ maxReads: 0,
24
+ maxReadsHard: 200000,
25
+ };
26
+
27
+ const PHRED_OFFSETS = [33, 64];
28
+
29
+ function integerIn(value, name, min, max) {
30
+ if (!Number.isInteger(value) || value < min || value > max) {
31
+ throw new MolbioInputError(name + ' must be an integer between ' + min + ' and ' + max);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function numberIn(value, name, min, max) {
37
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
38
+ throw new MolbioInputError(name + ' must be a number between ' + min + ' and ' + max);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ /** Validate and normalize the options once per call. */
44
+ export function resolveTrimOptions(options = {}) {
45
+ const minQuality = integerIn(options.minQuality ?? FASTQ_TRIM_DEFAULTS.minQuality, 'min_quality', 0, 60);
46
+ const qualityWindow = integerIn(options.qualityWindow ?? FASTQ_TRIM_DEFAULTS.qualityWindow, 'quality_window', 1, 100);
47
+ const polyMin = integerIn(options.polyMin ?? FASTQ_TRIM_DEFAULTS.polyMin, 'poly_min', 2, 100);
48
+ const minLength = integerIn(options.minLength ?? FASTQ_TRIM_DEFAULTS.minLength, 'min_length', 0, 1_000_000);
49
+ const maxNPercent = numberIn(options.maxNPercent ?? FASTQ_TRIM_DEFAULTS.maxNPercent, 'max_n_percent', 0, 100);
50
+ const phred = options.phred ?? FASTQ_TRIM_DEFAULTS.phred;
51
+ if (!PHRED_OFFSETS.includes(phred)) throw new MolbioInputError('phred must be 33 or 64');
52
+ const maxReads = integerIn(options.maxReads ?? FASTQ_TRIM_DEFAULTS.maxReads, 'max_reads', 0, FASTQ_TRIM_DEFAULTS.maxReadsHard);
53
+ const adapter = options.adapter === undefined || options.adapter === ''
54
+ ? ''
55
+ : normalizeSequence(options.adapter, 'adapter');
56
+ const primer = options.primer === undefined || options.primer === ''
57
+ ? ''
58
+ : normalizeSequence(options.primer, 'primer');
59
+ return {
60
+ minQuality,
61
+ qualityWindow,
62
+ polyMin,
63
+ minLength,
64
+ maxNPercent,
65
+ phred,
66
+ maxReads,
67
+ adapter,
68
+ primer,
69
+ trimPolyG: options.polyG === true,
70
+ trimPolyA: options.polyA === true,
71
+ };
72
+ }
73
+
74
+ /** 0-based exclusive end after 3' sliding-window quality trimming. */
75
+ function qualityTrimEnd(sequence, quality, options) {
76
+ const length = sequence.length;
77
+ if (length === 0) return 0;
78
+ const window = Math.min(options.qualityWindow, length);
79
+ let end = length;
80
+ while (end >= window) {
81
+ let sum = 0;
82
+ for (let index = end - window; index < end; index++) {
83
+ const q = quality.charCodeAt(index) - options.phred;
84
+ if (q < 0 || q > 93) throw new MolbioInputError('FASTQ quality character outside Phred+' + options.phred + ' range at read position ' + (index + 1));
85
+ sum += q;
86
+ }
87
+ if (sum / window >= options.minQuality) break;
88
+ end--;
89
+ }
90
+ return end < window ? 0 : end;
91
+ }
92
+
93
+ /** Longest trailing run of one base, or 0. */
94
+ function trailingRun(sequence, base) {
95
+ let length = 0;
96
+ for (let index = sequence.length - 1; index >= 0 && sequence[index] === base; index--) length++;
97
+ return length;
98
+ }
99
+
100
+ /** Count Ns (and any other non-ACGT base) in a sequence. */
101
+ function ambiguousCount(sequence) {
102
+ let count = 0;
103
+ for (const base of sequence) if (!'ACGT'.includes(base)) count++;
104
+ return count;
105
+ }
106
+
107
+ /**
108
+ * Trim/filter one parsed FASTQ entry.
109
+ *
110
+ * @param {{id: string, description: string, sequence: string, quality: string}} entry
111
+ * @param {ReturnType<typeof resolveTrimOptions>} options
112
+ * @returns {{entry?: object, dropped?: 'length'|'n', trims: {quality:boolean, adapter:boolean, poly_g:boolean, poly_a:boolean, primer:boolean}}}
113
+ */
114
+ export function trimRead(entry, options) {
115
+ if (typeof entry?.sequence !== 'string' || typeof entry?.quality !== 'string') throw new MolbioInputError('each read needs a sequence and a quality string');
116
+ if (entry.sequence.length !== entry.quality.length) throw new MolbioInputError('read ' + entry.id + ': sequence and quality lengths differ');
117
+ let sequence = entry.sequence;
118
+ let quality = entry.quality;
119
+ const trims = { quality: false, adapter: false, poly_g: false, poly_a: false, primer: false };
120
+
121
+ if (options.primer !== '' && sequence.startsWith(options.primer)) {
122
+ sequence = sequence.slice(options.primer.length);
123
+ quality = quality.slice(options.primer.length);
124
+ trims.primer = true;
125
+ }
126
+ if (options.adapter !== '') {
127
+ const at = sequence.indexOf(options.adapter);
128
+ if (at !== -1) {
129
+ sequence = sequence.slice(0, at);
130
+ quality = quality.slice(0, at);
131
+ trims.adapter = true;
132
+ }
133
+ }
134
+
135
+ const qualityEnd = qualityTrimEnd(sequence, quality, options);
136
+ if (qualityEnd < sequence.length) {
137
+ sequence = sequence.slice(0, qualityEnd);
138
+ quality = quality.slice(0, qualityEnd);
139
+ trims.quality = true;
140
+ }
141
+
142
+ if (options.trimPolyG) {
143
+ const run = trailingRun(sequence, 'G');
144
+ if (run >= options.polyMin) {
145
+ sequence = sequence.slice(0, sequence.length - run);
146
+ quality = quality.slice(0, quality.length - run);
147
+ trims.poly_g = true;
148
+ }
149
+ }
150
+ if (options.trimPolyA) {
151
+ const run = trailingRun(sequence, 'A');
152
+ if (run >= options.polyMin) {
153
+ sequence = sequence.slice(0, sequence.length - run);
154
+ quality = quality.slice(0, quality.length - run);
155
+ trims.poly_a = true;
156
+ }
157
+ }
158
+
159
+ if (sequence.length === 0) return { dropped: 'length', trims };
160
+ if (sequence.length < options.minLength) return { dropped: 'length', trims };
161
+ const nPercent = (ambiguousCount(sequence) / sequence.length) * 100;
162
+ if (nPercent > options.maxNPercent) return { dropped: 'n', trims };
163
+ return {
164
+ entry: {
165
+ id: entry.id,
166
+ description: entry.description ?? '',
167
+ sequence,
168
+ quality,
169
+ },
170
+ trims,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Trim/filter a set of parsed FASTQ entries.
176
+ *
177
+ * @param {Array<object>} entries
178
+ * @param {object} [options]
179
+ * @returns {{entries: Array<object>, stats: object}}
180
+ */
181
+ export function trimFastq(entries, options = {}) {
182
+ if (!Array.isArray(entries) || entries.length === 0) throw new MolbioInputError('FASTQ trimming needs at least one read');
183
+ const resolved = resolveTrimOptions(options);
184
+ const limit = resolved.maxReads === 0 ? FASTQ_TRIM_DEFAULTS.maxReadsHard : resolved.maxReads;
185
+ const scanned = entries.slice(0, limit);
186
+ const kept = [];
187
+ const trimCounts = { quality: 0, adapter: 0, poly_g: 0, poly_a: 0, primer: 0 };
188
+ const dropCounts = { length: 0, n: 0 };
189
+ let basesIn = 0;
190
+ let basesOut = 0;
191
+ let trimmedReads = 0;
192
+ for (const entry of scanned) {
193
+ basesIn += entry.sequence.length;
194
+ const result = trimRead(entry, resolved);
195
+ if (result.entry === undefined) {
196
+ dropCounts[result.dropped]++;
197
+ continue;
198
+ }
199
+ kept.push(result.entry);
200
+ basesOut += result.entry.sequence.length;
201
+ let trimmed = false;
202
+ for (const [key, value] of Object.entries(result.trims)) {
203
+ if (value) {
204
+ trimCounts[key]++;
205
+ trimmed = true;
206
+ }
207
+ }
208
+ if (trimmed) trimmedReads++;
209
+ }
210
+ return {
211
+ entries: kept,
212
+ stats: {
213
+ total_reads: entries.length,
214
+ reads_in: scanned.length,
215
+ reads_out: kept.length,
216
+ bases_in: basesIn,
217
+ bases_out: basesOut,
218
+ trimmed_reads: trimmedReads,
219
+ dropped_reads: scanned.length - kept.length,
220
+ trim_counts: trimCounts,
221
+ drop_counts: dropCounts,
222
+ truncated: entries.length > scanned.length,
223
+ criteria: {
224
+ min_quality: resolved.minQuality,
225
+ quality_window: resolved.qualityWindow,
226
+ poly_min: resolved.polyMin,
227
+ min_length: resolved.minLength,
228
+ max_n_percent: resolved.maxNPercent,
229
+ phred: resolved.phred,
230
+ max_reads: resolved.maxReads,
231
+ adapter: resolved.adapter,
232
+ primer: resolved.primer,
233
+ poly_g: resolved.trimPolyG,
234
+ poly_a: resolved.trimPolyA,
235
+ },
236
+ },
237
+ };
238
+ }