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
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "$comment": [
3
- "Protein analysis, bench analysis and plotting.",
3
+ "Protein analysis, bench analysis and plotting, plus the embedded-signature",
4
+ "contamination screen (short signatures, both strands, negative != clean).",
4
5
  "",
5
6
  "`reading-frames` is the one task whose point is a TOOL CHOICE rather than a",
6
7
  "value: the model must call the translator in all-frames mode with an ORF floor,",
@@ -164,6 +165,115 @@
164
165
  }
165
166
  ]
166
167
  },
168
+ {
169
+ "id": "protein-charge",
170
+ "category": "protein",
171
+ "tier": "full",
172
+ "covers": [
173
+ "molbio_protein_charge"
174
+ ],
175
+ "instruction": "I need to know how this peptide's charge changes with pH. Plot the net charge from pH 0 to 14, report the isoelectric point and the net charge at pH 7.4, and tell me which pK set the numbers come from. Write the figure to charge.svg.\n\nMKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQ",
176
+ "timeout_ms": 240000,
177
+ "expect_tools": [
178
+ "molbio_protein_charge"
179
+ ],
180
+ "assertions": [
181
+ {
182
+ "where": "tool",
183
+ "pattern": "33 aa · Bjellqvist 1993 pK set · pI 10\\.63 · charge at pH 7\\.4 2\\.9835 e \\(net positive\\)"
184
+ },
185
+ {
186
+ "where": "tool",
187
+ "pattern": "Curve over pH 0-14 \\(29 points; range -4\\.9682 \\.\\. 7\\.9996 e\\)"
188
+ },
189
+ {
190
+ "where": "answer",
191
+ "pattern": "10\\.6"
192
+ },
193
+ {
194
+ "where": "answer",
195
+ "pattern": "2\\.98|3\\.0|net positive"
196
+ },
197
+ {
198
+ "where": "answer",
199
+ "pattern": "Bjellqvist|pK"
200
+ }
201
+ ]
202
+ },
203
+ {
204
+ "id": "dotplot",
205
+ "category": "bench-analysis",
206
+ "tier": "full",
207
+ "covers": [
208
+ "molbio_dotplot"
209
+ ],
210
+ "instruction": "I want to see the repeat structure of this 46 bp sequence. Draw a self dotplot with a 5-mer word, covering both direct and inverted repeats, and tell me how many direct and how many inverted matches it finds. Write the figure to dot.svg.\n\nTTAGGCACGTAGCTAGGCTAACGTTAGGCACGTAGCTAGGCTAACG",
211
+ "timeout_ms": 240000,
212
+ "expect_tools": [
213
+ "molbio_dotplot"
214
+ ],
215
+ "assertions": [
216
+ {
217
+ "where": "tool",
218
+ "pattern": "46 bp · word 5 · 88 direct / 18 inverted match\\(es\\); 88 \\+ 18 occupied cell\\(s\\)"
219
+ },
220
+ {
221
+ "where": "tool",
222
+ "pattern": "Exact k-mer view \\(black = direct, red = inverted\\)"
223
+ },
224
+ {
225
+ "where": "answer",
226
+ "pattern": "88"
227
+ },
228
+ {
229
+ "where": "answer",
230
+ "pattern": "18"
231
+ },
232
+ {
233
+ "where": "answer",
234
+ "pattern": "direct|inverted|repeat"
235
+ }
236
+ ]
237
+ },
238
+ {
239
+ "id": "contamination-screen",
240
+ "category": "bench-analysis",
241
+ "tier": "full",
242
+ "covers": [
243
+ "molbio_contamination_screen"
244
+ ],
245
+ "instruction": "I have this 122 bp sequence from a miniprep and I am worried it is contaminated with vector backbone or Illumina/Nextera adapter. Screen it against common vector and adapter signatures, tell me which signatures hit and their positions, and say whether this is proof of contamination.\n\n{seq:contamination}",
246
+ "timeout_ms": 240000,
247
+ "expect_tools": [
248
+ "molbio_contamination_screen"
249
+ ],
250
+ "assertions": [
251
+ {
252
+ "where": "tool",
253
+ "pattern": "sequence input: 1 of 1 entry, 122 bp; 2 signature\\(s\\) hit, 1 entry with a hit\\."
254
+ },
255
+ {
256
+ "where": "tool",
257
+ "pattern": "Illumina Universal \\[adapter/adapter\\] 1/1 \\(100%\\), best 0 mismatch\\(es\\); e\\.g\\. input 111-122 \\(\\+\\)"
258
+ },
259
+ {
260
+ "where": "tool",
261
+ "pattern": "pUC/pBR322 origin \\[vector/origin\\] 1/1 \\(100%\\), best 0 mismatch\\(es\\); e\\.g\\. input 1-30 \\(\\+\\)"
262
+ },
263
+ {
264
+ "where": "answer",
265
+ "pattern": "pUC|pBR322|vector"
266
+ },
267
+ {
268
+ "where": "answer",
269
+ "pattern": "Illumina|adapter"
270
+ },
271
+ {
272
+ "where": "answer",
273
+ "pattern": "not proof|short signature|does not prove|suggest|consistent"
274
+ }
275
+ ]
276
+ },
167
277
  {
168
278
  "id": "codon-optimize",
169
279
  "category": "bench-analysis",
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "$comment": [
3
- "Codon usage, phylogeny, and the two file-parsing tools.",
3
+ "Codon usage, phylogeny, FASTQ trimming, and the two file-parsing tools.",
4
+ "",
5
+ "`fastq-trim` belongs here rather than under visualisation: it reads a FASTQ,",
6
+ "transforms every record, and writes a FASTQ/FASTA back. The task pins the",
7
+ "before/after counts and the per-stage trim reasons, not just 'it ran'.",
4
8
  "",
5
9
  "The parsing tools appear here as a CHAIN rather than alone: a model that",
6
10
  "answers a question about a GenBank record without parsing it is doing",
@@ -128,6 +132,45 @@
128
132
  }
129
133
  ]
130
134
  },
135
+ {
136
+ "id": "fastq-trim",
137
+ "category": "sequence-io",
138
+ "tier": "full",
139
+ "covers": [
140
+ "molbio_fastq_trim"
141
+ ],
142
+ "instruction": "These four reads are from an old Illumina run: r1 has a low-quality 3' tail, r2 carries the Illumina Universal adapter, r3 has a poly-G tail, and r4 is very short. Trim the low-quality tail with a 3-base window at mean Q30, remove the adapter and everything after it, trim poly-G runs of 8 or more, drop reads shorter than 20 bp, and write the survivors to trimmed.fq. Tell me how many reads and bases went in and out, how many were trimmed or dropped, and why.\n",
143
+ "timeout_ms": 240000,
144
+ "expect_tools": [
145
+ "molbio_fastq_trim"
146
+ ],
147
+ "assertions": [
148
+ {
149
+ "where": "tool",
150
+ "pattern": "FASTQ trim: 4 of 4 read\\(s\\) scanned \\(127 bases\\) -> 3 read\\(s\\) \\(80 bases\\); 3 trimmed, 1 dropped\\."
151
+ },
152
+ {
153
+ "where": "tool",
154
+ "pattern": "quality 1 · adapter 1 · poly-G 1 · poly-A 0 · primer 0; dropped by length 1, by N 0"
155
+ },
156
+ {
157
+ "where": "tool",
158
+ "pattern": "Saved FASTQ to .*trimmed\\.fq\\."
159
+ },
160
+ {
161
+ "where": "answer",
162
+ "pattern": "3 .{0,20}(read|kept|surviv)|(kept|surviv).{0,20}3"
163
+ },
164
+ {
165
+ "where": "answer",
166
+ "pattern": "80"
167
+ },
168
+ {
169
+ "where": "answer",
170
+ "pattern": "adapter|poly-G|quality|drop"
171
+ }
172
+ ]
173
+ },
131
174
  {
132
175
  "id": "batch-analysis",
133
176
  "category": "formats",
@@ -74,6 +74,7 @@ const FILE_SETUPS = {
74
74
  'chart-volcano': { 'de.csv': FIXTURES.DE_CSV },
75
75
  'chart-heatmap': { 'expression.tsv': FIXTURES.HEATMAP_TSV },
76
76
  'batch-analysis': { 'pUC118.gb': GENBANK_TEXT },
77
+ 'fastq-trim': { 'reads.fq': FIXTURES.TRIM_FASTQ_TEXT },
77
78
  };
78
79
 
79
80
  /**
@@ -252,6 +253,18 @@ export function invocationsFor(task) {
252
253
  'peptide-digest': () => [{ tool: 'molbio_peptide_digest', args: { sequence: FIXTURES.DIGEST_PEPTIDE, enzyme: 'trypsin', missed: 0 } }],
253
254
  'helical-wheel': () => [{ tool: 'molbio_helical_wheel', args: { sequence: FIXTURES.AMPHIPATHIC_PEPTIDE, title: 'amphipathic test' } }],
254
255
  hydropathy: () => [{ tool: 'molbio_hydropathy_plot', args: { sequence: FIXTURES.DIGEST_PEPTIDE } }],
256
+ 'protein-charge': () => [
257
+ { tool: 'molbio_protein_charge', args: { sequence: FIXTURES.DIGEST_PEPTIDE, output_path: at('charge.svg') } },
258
+ ],
259
+ dotplot: () => [
260
+ { tool: 'molbio_dotplot', args: { sequence: FIXTURES.DOTPLOT_SEQUENCE, word: 5, output_path: at('dot.svg') } },
261
+ ],
262
+ 'contamination-screen': () => [
263
+ { tool: 'molbio_contamination_screen', args: { sequence: FIXTURES.CONTAMINATION_SEQUENCE } },
264
+ ],
265
+ 'fastq-trim': () => [
266
+ { tool: 'molbio_fastq_trim', args: { path: at('reads.fq'), output_path: at('trimmed.fq'), min_quality: 30, quality_window: 3, poly_g: true, adapter: 'AGATCGGAAGAG', min_length: 20 } },
267
+ ],
255
268
 
256
269
  // bench analysis
257
270
  'codon-optimize': () => [{ tool: 'molbio_codon_optimize', args: { sequence: FIXTURES.CDS, host: 'e_coli' } }],
@@ -36,6 +36,8 @@ import {
36
36
  childPath,
37
37
  classifyEntry,
38
38
  credentialInfo,
39
+ figureCardSummary,
40
+ figureCardView,
39
41
  isMolbioDetailSubject,
40
42
  remoteFailure,
41
43
  decodeWorkspaceBytes,
@@ -294,8 +296,10 @@ function papersTitle() {
294
296
  return 'Papers';
295
297
  }
296
298
 
297
- /** The tool names whose transcript card this package draws. */
299
+ /** The tool names whose transcript card this package draws (plasmid maps). */
298
300
  const MAP_TOOL_KEYS = ['molbio_plasmid_map', 'molbio_plasmid_map_file'];
301
+ /** Figure tools: a drawn logo / gRNA map plus (for gRNA) the ranked list. */
302
+ const FIGURE_TOOL_KEYS = ['molbio_sequence_logo', 'molbio_grna_design'];
299
303
 
300
304
  /**
301
305
  * The transcript card for the map tools (`tool.call.toolview`, keyed by tool
@@ -364,6 +368,72 @@ function MolbioMapCard({ block, inspect }) {
364
368
  return h('div', { style: styles.card }, ...content);
365
369
  }
366
370
 
371
+ /**
372
+ * The transcript card for the sequence-logo and gRNA tools. The logo card draws
373
+ * the logo; the gRNA card draws the map when `map_path` was requested AND lists
374
+ * the top-ranked guides (a gRNA call without a map still gets the table). Like
375
+ * the map card it is a pure function of the block, and everything it reads is
376
+ * validated and bounded by `figureCardView`.
377
+ */
378
+ function MolbioFigureCard({ block, inspect }) {
379
+ const settled = block !== null && typeof block === 'object' && block.kind === 'tool-result';
380
+ const view = figureCardView(settled ? block.meta : undefined);
381
+ const host = useRef(null);
382
+ const svg = view.kind === 'figure' ? view.svg : '';
383
+ const [drawError, setDrawError] = useState(null);
384
+
385
+ useEffect(() => {
386
+ const node = host.current;
387
+ if (node === null) return;
388
+ node.replaceChildren();
389
+ if (svg === '') return;
390
+ const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml');
391
+ if (parsed.querySelector('parsererror') !== null) {
392
+ setDrawError('the figure markup could not be parsed as SVG');
393
+ return;
394
+ }
395
+ setDrawError(null);
396
+ const element = parsed.documentElement;
397
+ element.setAttribute('style', 'width:100%;height:auto;max-width:640px');
398
+ node.append(document.importNode(element, true));
399
+ }, [svg]);
400
+
401
+ const content = [h('div', { key: 'head', style: styles.cardHead }, settled ? figureCardSummary(view) : 'Drawing the figure…')];
402
+ if (view.kind === 'figure') {
403
+ if (drawError !== null) content.push(h('div', { key: 'drawerr', style: styles.cardNote }, drawError));
404
+ content.push(h('div', { key: 'svg', ref: host, style: styles.cardSvg }));
405
+ } else {
406
+ content.push(h('div', { key: 'note', style: styles.cardNote }, view.message));
407
+ }
408
+ if (view.guides.length > 0) {
409
+ const head = ['#', 'Guide', 'PAM', 'Position', 'Score', 'Off-targets'];
410
+ const rows = view.guides.map((guide) => h('tr', { key: guide.rank },
411
+ h('td', { style: styles.td }, String(guide.rank)),
412
+ h('td', { style: styles.td }, guide.sequence),
413
+ h('td', { style: styles.td }, guide.pam === '' ? '—' : guide.pam),
414
+ h('td', { style: styles.td }, `${guide.strand === 'reverse' ? '−' : '+'}${guide.start}-${guide.end}`),
415
+ h('td', { style: styles.td }, String(guide.score)),
416
+ h('td', { style: styles.td }, String(guide.offTargets)),
417
+ ));
418
+ content.push(h('table', { key: 'guides', style: styles.table },
419
+ h('thead', null, h('tr', null, ...head.map((label) => h('th', { key: label, style: styles.th }, label)))),
420
+ h('tbody', null, ...rows)));
421
+ }
422
+ if (view.svgPath !== '') content.push(h('div', { key: 'path', style: styles.cardPath }, view.svgPath));
423
+ if (view.csvPath !== '') content.push(h('div', { key: 'csv', style: styles.cardPath }, view.csvPath));
424
+ if (settled && block.isError === true) {
425
+ const text = (Array.isArray(block.content) ? block.content : [])
426
+ .map((item) => (item !== null && typeof item === 'object' && item.type === 'text' ? String(item.text ?? '') : ''))
427
+ .join('\n')
428
+ .trim();
429
+ content.push(h('div', { key: 'err', style: styles.error }, text === '' ? 'The call failed.' : text));
430
+ if (typeof inspect === 'function') {
431
+ content.push(h('button', { key: 'inspect', style: styles.cardButton, onClick: () => inspect() }, 'Inspect'));
432
+ }
433
+ }
434
+ return h('div', { style: styles.card }, ...content);
435
+ }
436
+
367
437
  /**
368
438
  * The literature panel: the workspace `papers.json` the molbio_paper_* tools
369
439
  * maintain, rendered as a searchable reading list with a detail pane.
@@ -712,6 +782,15 @@ function apply(ctx) {
712
782
  key: toolName,
713
783
  }, MolbioMapCard)), `molbio panel: ${toolName} card`);
714
784
  }
785
+ // Figure cards: the sequence-logo and gRNA tools declare a `molbio-figure`
786
+ // projection; the same component draws a logo or a gRNA map and, for gRNA,
787
+ // lists the top-ranked guides even when no map was requested.
788
+ for (const toolName of FIGURE_TOOL_KEYS) {
789
+ ctx.effect(() => ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
790
+ name: 'tool.call.toolview',
791
+ key: toolName,
792
+ }, MolbioFigureCard)), `molbio panel: ${toolName} card`);
793
+ }
715
794
  // Plugins -> the installed plugin's detail page: the one place a user can
716
795
  // store the NCBI key without editing a YAML file or exporting a variable.
717
796
  //
@@ -268,6 +268,91 @@ export function mapCardSummary(view) {
268
268
  return parts.join(' · ');
269
269
  }
270
270
 
271
+ // ── figure cards (the same `tool.call.toolview` seat) ───────────────────────
272
+
273
+ /** The meta kind the sequence-logo and gRNA tools attach to their results. */
274
+ export const FIGURE_CARD_KIND = 'molbio-figure';
275
+
276
+ const CARD_SUMMARY_MAX = 300;
277
+ const CARD_PATH_MAX = 500;
278
+ const CARD_GUIDES_MAX = 5;
279
+
280
+ /** A bounded string, or '' when the value is not a string. */
281
+ function cardText(value, max) {
282
+ return typeof value === 'string' ? value.slice(0, max) : '';
283
+ }
284
+
285
+ /** One guide row, validated field by field; malformed rows are dropped. */
286
+ function cardGuide(row) {
287
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return undefined;
288
+ const sequence = cardText(row.sequence, 60);
289
+ if (sequence === '') return undefined;
290
+ return {
291
+ rank: Number.isInteger(row.rank) ? row.rank : 0,
292
+ sequence,
293
+ pam: cardText(row.pam, 12),
294
+ strand: row.strand === 'reverse' ? 'reverse' : 'forward',
295
+ start: Number.isInteger(row.start) ? row.start : 0,
296
+ end: Number.isInteger(row.end) ? row.end : 0,
297
+ score: typeof row.score === 'number' && Number.isFinite(row.score) ? row.score : 0,
298
+ offTargets: Number.isInteger(row.off_target_count) ? row.off_target_count : 0,
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Read a tool-result block's meta into what the figure card draws.
304
+ *
305
+ * Like `mapCardView`, the value is a projection this package produced itself,
306
+ * but the card renders inside the conversation, so every field is checked and
307
+ * bounded rather than trusted: a block from an older version, another tool, a
308
+ * running call or a failed run must degrade to a notice, never throw.
309
+ *
310
+ * @param {unknown} meta the block's meta, as the client received it.
311
+ * @returns {{kind: 'notice', message: string, ...} | {kind: 'figure', figure:
312
+ * 'logo'|'grna', summary: string, svg: string, svgPath: string,
313
+ * csvPath: string, mapPath: string, guides: Array}}
314
+ */
315
+ export function figureCardView(meta) {
316
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
317
+ return { kind: 'notice', message: 'No figure data was attached to this call.' };
318
+ }
319
+ if (meta.kind !== FIGURE_CARD_KIND) {
320
+ return { kind: 'notice', message: 'This call carries no figure.' };
321
+ }
322
+ const figure = meta.figure === 'grna' ? 'grna' : meta.figure === 'logo' ? 'logo' : '';
323
+ if (figure === '') {
324
+ return { kind: 'notice', message: 'This call carries an unknown figure type.' };
325
+ }
326
+ const base = {
327
+ figure,
328
+ summary: cardText(meta.summary, CARD_SUMMARY_MAX),
329
+ svgPath: cardText(meta.svg_path, CARD_PATH_MAX),
330
+ csvPath: cardText(meta.csv_path, CARD_PATH_MAX),
331
+ mapPath: cardText(meta.map_path, CARD_PATH_MAX),
332
+ guides: Array.isArray(meta.guides)
333
+ // Bound the work before validating, then keep at most five VALID rows:
334
+ // malformed entries must not consume the budget and hide good ones.
335
+ ? meta.guides.slice(0, 50).map(cardGuide).filter((guide) => guide !== undefined).slice(0, CARD_GUIDES_MAX)
336
+ : [],
337
+ svg: '',
338
+ };
339
+ if (!isSvgMarkup(meta.svg)) {
340
+ const message = meta.svg_missing === true
341
+ ? 'No picture was requested for this call; the numbers are in the tool result.'
342
+ : meta.svg_omitted === true
343
+ ? `The figure is too large to draw inline (${Math.round((Number.isInteger(meta.svg_bytes) ? meta.svg_bytes : 0) / 1024)} KB); the written file is named below.`
344
+ : 'The figure markup did not travel with this call.';
345
+ return { kind: 'notice', message, ...base };
346
+ }
347
+ return { kind: 'figure', ...base, svg: meta.svg };
348
+ }
349
+
350
+ /** The header line for a figure card, with a stable fallback. */
351
+ export function figureCardSummary(view) {
352
+ if (view.summary !== '') return view.summary;
353
+ return view.figure === 'logo' ? 'Sequence logo' : 'gRNA design';
354
+ }
355
+
271
356
  // ── literature library (papers.json) ────────────────────────────────────────
272
357
 
273
358
  /** The library file the molbio_paper_* tools write, relative to the workspace. */
@@ -41,7 +41,7 @@ import { gunzipSync } from 'node:zlib';
41
41
  * The two npm entries this repository publishes, in publish order.
42
42
  *
43
43
  * THEY DO NOT SHARE A VERSION LINE, and that is deliberate rather than an
44
- * oversight to be "fixed" here. The root package carries the 60 tools and moves
44
+ * oversight to be "fixed" here. The root package carries the 62 tools and moves
45
45
  * with every toolset release; `dsh-molbio-panel` carries only the browser panel
46
46
  * and moves when the panel's own artifact moves. The tag names the ROOT version
47
47
  * (`v0.19.0`), and each entry is checked against its own declared version —
@@ -0,0 +1,229 @@
1
+ /**
2
+ * dsh-molbio-tools/contamination.mjs
3
+ *
4
+ * A small, EMBEDDED set of common vector-backbone/promoter/origin and
5
+ * Illumina/Nextera adapter signatures, plus a bounded both-strand screen.
6
+ *
7
+ * This is deliberately a short-signature screen, not a database search:
8
+ * a hit is evidence worth following up, and a miss is NOT evidence that a
9
+ * sample is clean. The table lives in code because the package has no external
10
+ * data files; every entry names the feature it represents so a report can be
11
+ * read without guessing which 20-mer matched.
12
+ *
13
+ * The adapters are shared with the FASTQ QC adapter-content panel. They are DNA
14
+ * here: the Small RNA 5' entry is publicly documented as RNA (GUUCAG...), but a
15
+ * FASTQ read is cDNA, so keeping the U in the table made the forward molecule
16
+ * unmatchable. Normalizing U→T at the source fixes the screen for both the
17
+ * standalone tool and fastq-qc's adapter panel.
18
+ */
19
+ import { MolbioInputError, normalizeSequence, reverseComplement } from './lib.mjs';
20
+
21
+ /** One signature in the embedded table. */
22
+ export const ADAPTERS = [
23
+ { name: 'Illumina Universal', kind: 'adapter', category: 'adapter', sequence: 'AGATCGGAAGAG' },
24
+ { name: "Illumina Small RNA 3'", kind: 'adapter', category: 'adapter', sequence: 'TGGAATTCTCGG' },
25
+ { name: "Illumina Small RNA 5'", kind: 'adapter', category: 'adapter', sequence: 'GTTCAGAGTTCTACAGTCCGACGATC' },
26
+ { name: 'Nextera Transposase', kind: 'adapter', category: 'adapter', sequence: 'CTGTCTCTTATA' },
27
+ { name: 'PolyA', kind: 'adapter', category: 'homopolymer', sequence: 'AAAAAAAAAAAA' },
28
+ { name: 'PolyG', kind: 'adapter', category: 'homopolymer', sequence: 'GGGGGGGGGGGG' },
29
+ ];
30
+
31
+ /**
32
+ * Common vector signatures. These are SHORT, deliberately: a 20-35 bp signature
33
+ * is enough to flag a backbone/promoter/origin in an assembly or read set, while
34
+ * a full vector sequence would turn this table into a reference database (and
35
+ * raise licensing questions). Names say which element matched.
36
+ */
37
+ export const VECTORS = [
38
+ { name: 'pUC/pBR322 origin', kind: 'vector', category: 'origin', sequence: 'GTTTGCCGGATCAAGAGCTACCAACTCTTT' },
39
+ { name: 'pUC19 lacZ/M13 region', kind: 'vector', category: 'backbone', sequence: 'GTTTTCCCAGTCACGACGTTGTAAAACGACGGCCAGT' },
40
+ { name: 'pUC/pBR322 bla (AmpR) start', kind: 'vector', category: 'selection', sequence: 'ATGAGTATTCAACATTTCCGTGTCGCCCTT' },
41
+ { name: 'pET T7 promoter', kind: 'vector', category: 'promoter', sequence: 'TAATACGACTCACTATAGGG' },
42
+ { name: 'pET T7 terminator', kind: 'vector', category: 'terminator', sequence: 'GCTAGTTATTGCTCAGCGG' },
43
+ { name: 'pcDNA CMV promoter', kind: 'vector', category: 'promoter', sequence: 'CGCAAATGGGCGGTAGGCGTG' },
44
+ { name: 'pcDNA BGH polyA', kind: 'vector', category: 'terminator', sequence: 'CTAGAAGGCACAGTCGAGG' },
45
+ { name: 'Gateway attB1', kind: 'vector', category: 'recombination', sequence: 'ACAAGTTTGTACAAAAAAGCAGGCT' },
46
+ { name: 'Gateway attB2', kind: 'vector', category: 'recombination', sequence: 'ACCCAGCTTTCTTGTACAAAGTGGT' },
47
+ ];
48
+
49
+ /** Every embedded signature, adapters first (the table order is stable). */
50
+ export const SIGNATURES = [...ADAPTERS, ...VECTORS];
51
+
52
+ /** Defaults and hard bounds shared by the tool, tests and docs. */
53
+ export const CONTAMINATION_DEFAULTS = {
54
+ maxMismatches: 0,
55
+ maxMismatchesHard: 2,
56
+ maxReads: 0,
57
+ maxReadsHard: 200000,
58
+ maxHits: 100,
59
+ };
60
+
61
+ const round = (value, digits = 1) => Math.round(value * 10 ** digits) / 10 ** digits;
62
+
63
+ /** Hamming distance over equal-length strings (non-ACGT positions count as mismatches). */
64
+ function hamming(a, b) {
65
+ let distance = 0;
66
+ for (let index = 0; index < a.length; index++) {
67
+ if (a[index] !== b[index]) distance++;
68
+ }
69
+ return distance;
70
+ }
71
+
72
+ /** The seed chunks used by the mismatch path; one chunk when maxMismatches is 0. */
73
+ function seedChunks(signature, maxMismatches) {
74
+ const count = maxMismatches + 1;
75
+ const base = Math.floor(signature.length / count);
76
+ const chunks = [];
77
+ let offset = 0;
78
+ for (let index = 0; index < count; index++) {
79
+ const length = index === count - 1 ? signature.length - offset : base;
80
+ chunks.push({ needle: signature.slice(offset, offset + length), offset });
81
+ offset += length;
82
+ }
83
+ return chunks;
84
+ }
85
+
86
+ /**
87
+ * Best occurrence of one needle on one strand, or undefined.
88
+ *
89
+ * Exact mode is a plain indexOf. The mismatch path uses maxMismatches+1 seed
90
+ * chunks: at most maxMismatches chunks can be altered, so at least one chunk
91
+ * must occur verbatim; every seed occurrence is verified with a Hamming count.
92
+ * This keeps a 30-mer search linear in the number of seed hits instead of
93
+ * scanning every window of every read.
94
+ */
95
+ function bestOnStrand(sequence, needle, maxMismatches, strand) {
96
+ let best;
97
+ const consider = (start, mismatches) => {
98
+ if (start < 0 || start + needle.length > sequence.length) return;
99
+ if (best === undefined
100
+ || mismatches < best.mismatches
101
+ || (mismatches === best.mismatches && start < best.start)) {
102
+ best = { start, end: start + needle.length, strand, mismatches };
103
+ }
104
+ };
105
+ if (maxMismatches === 0) {
106
+ let from = 0;
107
+ while (from <= sequence.length - needle.length) {
108
+ const at = sequence.indexOf(needle, from);
109
+ if (at === -1) break;
110
+ consider(at, 0);
111
+ from = at + 1;
112
+ }
113
+ return best;
114
+ }
115
+ for (const chunk of seedChunks(needle, maxMismatches)) {
116
+ if (chunk.needle.length === 0) continue;
117
+ let from = 0;
118
+ while (from <= sequence.length - chunk.needle.length) {
119
+ const at = sequence.indexOf(chunk.needle, from);
120
+ if (at === -1) break;
121
+ const start = at - chunk.offset;
122
+ if (start >= 0 && start + needle.length <= sequence.length) {
123
+ const mismatches = hamming(sequence.slice(start, start + needle.length), needle);
124
+ if (mismatches <= maxMismatches) consider(start, mismatches);
125
+ }
126
+ from = at + 1;
127
+ }
128
+ }
129
+ return best;
130
+ }
131
+
132
+ /** Best hit of a signature against a sequence on either strand. */
133
+ export function findSignatureHit(sequence, signature, maxMismatches = 0) {
134
+ const forward = bestOnStrand(sequence, signature, maxMismatches, '+');
135
+ const reverse = bestOnStrand(sequence, reverseComplement(signature), maxMismatches, '-');
136
+ if (forward === undefined) return reverse;
137
+ if (reverse === undefined) return forward;
138
+ if (reverse.mismatches < forward.mismatches) return reverse;
139
+ if (reverse.mismatches === forward.mismatches && reverse.start < forward.start) return reverse;
140
+ return forward;
141
+ }
142
+
143
+ /**
144
+ * Screen one or more sequences against the embedded signatures.
145
+ *
146
+ * @param {Array<{id: string, sequence: string}>} entries
147
+ * @param {{maxMismatches?: number, kinds?: 'both'|'adapter'|'vector', maxReads?: number}} [options]
148
+ * @returns {object} aggregate hits plus the criteria that produced them.
149
+ */
150
+ export function screenContamination(entries, options = {}) {
151
+ if (!Array.isArray(entries) || entries.length === 0) throw new MolbioInputError('contamination screening needs at least one sequence');
152
+ const maxMismatches = options.maxMismatches ?? CONTAMINATION_DEFAULTS.maxMismatches;
153
+ if (!Number.isInteger(maxMismatches) || maxMismatches < 0 || maxMismatches > CONTAMINATION_DEFAULTS.maxMismatchesHard) {
154
+ throw new MolbioInputError('max_mismatches must be an integer between 0 and ' + CONTAMINATION_DEFAULTS.maxMismatchesHard);
155
+ }
156
+ const kinds = options.kinds ?? 'both';
157
+ if (!['both', 'adapter', 'vector'].includes(kinds)) throw new MolbioInputError('kinds must be "both", "adapter" or "vector"');
158
+ const maxReads = options.maxReads ?? CONTAMINATION_DEFAULTS.maxReads;
159
+ if (!Number.isInteger(maxReads) || maxReads < 0 || maxReads > CONTAMINATION_DEFAULTS.maxReadsHard) {
160
+ throw new MolbioInputError('max_reads must be an integer between 0 and ' + CONTAMINATION_DEFAULTS.maxReadsHard);
161
+ }
162
+ const limit = maxReads === 0 ? CONTAMINATION_DEFAULTS.maxReadsHard : maxReads;
163
+ const normalized = entries.map((entry, index) => ({
164
+ id: typeof entry?.id === 'string' && entry.id !== '' ? entry.id : 'entry' + (index + 1),
165
+ sequence: normalizeSequence(entry?.sequence ?? '', 'sequence'),
166
+ }));
167
+ const scanned = normalized.slice(0, limit);
168
+ const truncated = normalized.length > scanned.length;
169
+ const signatures = SIGNATURES.filter((signature) => kinds === 'both' || signature.kind === kinds);
170
+ let ambiguousBases = 0;
171
+ let bases = 0;
172
+ for (const entry of scanned) {
173
+ bases += entry.sequence.length;
174
+ for (const base of entry.sequence) if (!'ACGT'.includes(base)) ambiguousBases++;
175
+ }
176
+ const hits = [];
177
+ const withHit = new Set();
178
+ for (const signature of signatures) {
179
+ let reads = 0;
180
+ let best;
181
+ for (let index = 0; index < scanned.length; index++) {
182
+ const entry = scanned[index];
183
+ const hit = findSignatureHit(entry.sequence, signature.sequence, maxMismatches);
184
+ if (hit === undefined) continue;
185
+ reads++;
186
+ withHit.add(index);
187
+ if (best === undefined
188
+ || hit.mismatches < best.mismatches
189
+ || (hit.mismatches === best.mismatches && hit.start < best.start)) {
190
+ best = { ...hit, id: entry.id };
191
+ }
192
+ }
193
+ if (reads === 0) continue;
194
+ hits.push({
195
+ name: signature.name,
196
+ kind: signature.kind,
197
+ category: signature.category,
198
+ signature: signature.sequence,
199
+ reads,
200
+ percent: round((reads / scanned.length) * 100),
201
+ best_mismatches: best.mismatches,
202
+ example: {
203
+ id: best.id,
204
+ start: best.start + 1,
205
+ end: best.end,
206
+ strand: best.strand,
207
+ mismatches: best.mismatches,
208
+ },
209
+ });
210
+ }
211
+ hits.sort((a, b) => b.reads - a.reads || a.name.localeCompare(b.name));
212
+ return {
213
+ total_entries: normalized.length,
214
+ scanned_entries: scanned.length,
215
+ bases,
216
+ ambiguous_bases: ambiguousBases,
217
+ hits: hits.slice(0, CONTAMINATION_DEFAULTS.maxHits),
218
+ hit_count: Math.min(hits.length, CONTAMINATION_DEFAULTS.maxHits),
219
+ reads_with_hit: withHit.size,
220
+ truncated,
221
+ criteria: {
222
+ max_mismatches: maxMismatches,
223
+ kinds,
224
+ signature_count: signatures.length,
225
+ max_reads: maxReads,
226
+ match_basis: 'embedded short signatures, both strands, substitutions only (no gaps); a negative result does not prove the sample is clean',
227
+ },
228
+ };
229
+ }