dsh-molbio-tools 0.22.2 → 0.24.1

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 (57) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/README.md +51 -19
  3. package/batch.mjs +5 -1
  4. package/benchmark/README.md +7 -7
  5. package/benchmark/changed.mjs +4 -3
  6. package/benchmark/fixtures.mjs +32 -4
  7. package/benchmark/profile.mjs +1 -1
  8. package/benchmark/run.mjs +1 -0
  9. package/benchmark/sequences.mjs +1 -0
  10. package/benchmark/tasks/02-primers.json +3 -3
  11. package/benchmark/tasks/06-analysis.json +111 -1
  12. package/benchmark/tasks/09-formats.json +44 -1
  13. package/benchmark/verifications.mjs +13 -0
  14. package/build/client-bundle.mjs +1 -1
  15. package/build/client-entry.mjs +80 -1
  16. package/build/panel-core.mjs +85 -0
  17. package/build/preset-patch.mjs +1 -1
  18. package/build/release-preflight.mjs +1 -1
  19. package/charts.mjs +35 -16
  20. package/contamination.mjs +229 -0
  21. package/cordis.patch.yml +10 -6
  22. package/design.mjs +111 -78
  23. package/docs/capability-gap-survey.md +5 -0
  24. package/docs/client-panel.md +10 -8
  25. package/docs/history.md +86 -0
  26. package/docs/maintainer.md +8 -5
  27. package/docs/roadmap.md +37 -15
  28. package/docs/rules.md +5 -2
  29. package/docs/store-evidence.md +30 -23
  30. package/docs/store-preset-exemption-issue.md +1 -1
  31. package/docs/store-preset-exemption.md +1 -1
  32. package/docs/v24-plan.md +178 -0
  33. package/docs/workflow.md +11 -10
  34. package/dotplot.mjs +257 -0
  35. package/fastq-qc.mjs +31 -16
  36. package/fastq-trim.mjs +238 -0
  37. package/host.mjs +2 -2
  38. package/index.mjs +521 -34
  39. package/lib/client.js +376 -109
  40. package/package.json +8 -3
  41. package/papers.mjs +10 -0
  42. package/plot.mjs +24 -8
  43. package/preset/install.mjs +8 -0
  44. package/preset/molbio-lab/agent.cordis.yml +19 -13
  45. package/preset/molbio-lab/preset.yml +1 -1
  46. package/protein-charge.mjs +158 -0
  47. package/protein-structure.mjs +10 -2
  48. package/protein.mjs +36 -14
  49. package/records.mjs +17 -2
  50. package/seqio.mjs +49 -14
  51. package/test/charts.mjs +11 -0
  52. package/test/client.mjs +12 -5
  53. package/test/contract.mjs +44 -12
  54. package/test/drift-probe.mjs +15 -1
  55. package/test/map-card.mjs +139 -1
  56. package/test/preset-health.mjs +39 -0
  57. package/test/smoke.mjs +378 -11
@@ -89,7 +89,7 @@
89
89
  "covers": [
90
90
  "molbio_design_intron_primers"
91
91
  ],
92
- "instruction": "I need a qPCR assay that will NOT amplify contaminating genomic DNA. Here is the genomic sequence and the two exon coordinates — design a pair where the forward primer spans the exon-exon junction. Tell me the spliced amplicon size and how far apart the primers sit on the genome.\n\n{seq:intron_genomic}\n\nExons: 1-400 and 701-1100 (1-based).",
92
+ "instruction": "I need a qPCR assay that will NOT amplify contaminating genomic DNA. Here is the genomic sequence and the two exon coordinates — design a pair where one primer spans the exon-exon junction (report which one it is). Tell me the spliced amplicon size and how far apart the primers sit on the genome.\n\n{seq:intron_genomic}\n\nExons: 1-400 and 701-1100 (1-based).",
93
93
  "timeout_ms": 300000,
94
94
  "expect_tools": [
95
95
  "molbio_design_intron_primers"
@@ -97,8 +97,8 @@
97
97
  "assertions": [
98
98
  {
99
99
  "where": "tool",
100
- "pattern": "forward primer spans an exon-exon junction",
101
- "note": "the property that makes the assay genomic-DNA-proof"
100
+ "pattern": "REVERSE primer spans an exon-exon junction",
101
+ "note": "the property that makes the assay genomic-DNA-proof; since 0.23.0 the junction is carried by the reverse primer (the assay sits 5' of the junction), and the forward primer is upstream"
102
102
  },
103
103
  {
104
104
  "where": "tool",
@@ -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' } }],
@@ -40,7 +40,7 @@ const checkOnly = process.argv.slice(2).includes('--check');
40
40
  /**
41
41
  * The bundles to emit. The SAME browser half ships through two channels:
42
42
  *
43
- * - `dsh-molbio-tools` — the plugin package: one install gives the 57 tools
43
+ * - `dsh-molbio-tools` — the plugin package: one install gives the 64 tools
44
44
  * plus the panel (its host half also registers the tools).
45
45
  * - `dsh-molbio-panel` — a panel-only package: installing it adds the tab to a
46
46
  * profile WITHOUT dragging the tools into every session of that profile,
@@ -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. */
@@ -101,7 +101,7 @@ const ANCHOR = [
101
101
  '#',
102
102
  '# Keep the tool in the PRESET and only the anchor here. An earlier version of',
103
103
  "# this file inserted a host row for the tool itself (`name: dsh-molbio-tools`),",
104
- "# which both registered all 60 tools globally and collided with the preset's own",
104
+ "# which both registered every tool globally and collided with the preset's own",
105
105
  '# copy of that row:',
106
106
  '#',
107
107
  '# broken: "tool-molbio (...): never started"',
@@ -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 64 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 —
package/charts.mjs CHANGED
@@ -60,6 +60,25 @@ import {
60
60
  textRun,
61
61
  } from './svgio.mjs';
62
62
 
63
+ // ── data-sized min/max ──────────────────────────────────────────────────────
64
+ //
65
+ // A table this module accepts can hold MAX_ROWS (200 000) values. Spreading an
66
+ // array that large into Math.min/Math.max overflows the engine's argument limit
67
+ // with a RangeError, so the data-sized extrema are computed with loops. Fixed-
68
+ // size arrays (bins, colour stops, density grids) still use the spread form.
69
+
70
+ function minData(values) {
71
+ let min = Infinity;
72
+ for (const value of values) if (value < min) min = value;
73
+ return min;
74
+ }
75
+
76
+ function maxData(values) {
77
+ let max = -Infinity;
78
+ for (const value of values) if (value > max) max = value;
79
+ return max;
80
+ }
81
+
63
82
  // ── canvas ──────────────────────────────────────────────────────────────────
64
83
 
65
84
  export const CHART_WIDTH = 720;
@@ -130,8 +149,8 @@ export function histogramBins(values, requestedBins) {
130
149
  const finite = values.filter((value) => Number.isFinite(value));
131
150
  if (finite.length === 0) throw new MolbioInputError('a histogram needs at least one finite value');
132
151
  const n = finite.length;
133
- const min = Math.min(...finite);
134
- const max = Math.max(...finite);
152
+ const min = minData(finite);
153
+ const max = maxData(finite);
135
154
  let binCount;
136
155
  let rule;
137
156
  if (requestedBins !== undefined) {
@@ -634,9 +653,9 @@ export function renderBoxPlot({ title, x_label, y_label, series }) {
634
653
  const stats = boxStats(validateFinite(entry.values, `series ${index + 1} values`));
635
654
  return { label, stats, color: entry.color ?? SERIES_COLORS[index % SERIES_COLORS.length] };
636
655
  });
637
- const lows = prepared.map((entry) => Math.min(entry.stats.whisker_low, ...entry.stats.outliers, entry.stats.min));
638
- const highs = prepared.map((entry) => Math.max(entry.stats.whisker_high, ...entry.stats.outliers, entry.stats.max));
639
- const [domainMin, domainMax] = padDomain(Math.min(...lows), Math.max(...highs));
656
+ const lows = prepared.map((entry) => Math.min(entry.stats.whisker_low, minData(entry.stats.outliers), entry.stats.min));
657
+ const highs = prepared.map((entry) => Math.max(entry.stats.whisker_high, maxData(entry.stats.outliers), entry.stats.max));
658
+ const [domainMin, domainMax] = padDomain(minData(lows), maxData(highs));
640
659
  const { parts, plot } = frame(title);
641
660
  const y = yAxis(parts, plot, domainMin, domainMax, y_label);
642
661
  const slot = plot.width / prepared.length;
@@ -695,8 +714,8 @@ export function renderLineChart({ title, x_label, y_label, series, markers = tru
695
714
  });
696
715
  const allX = prepared.flatMap((entry) => entry.xs);
697
716
  const allY = prepared.flatMap((entry) => entry.ys);
698
- const [xMin, xMax] = padDomain(Math.min(...allX), Math.max(...allX));
699
- const [yMin, yMax] = padDomain(Math.min(...allY), Math.max(...allY));
717
+ const [xMin, xMax] = padDomain(minData(allX), maxData(allX));
718
+ const [yMin, yMax] = padDomain(minData(allY), maxData(allY));
700
719
  const { parts, plot } = frame(title);
701
720
  const y = yAxis(parts, plot, yMin, yMax, y_label);
702
721
  const x = linearScale([xMin, xMax], [plot.x, plot.x + plot.width]);
@@ -719,11 +738,11 @@ export function renderLineChart({ title, x_label, y_label, series, markers = tru
719
738
  return {
720
739
  svg,
721
740
  stats: {
722
- series: prepared.map((entry) => ({ label: entry.label, points: entry.xs.length, x_min: Math.min(...entry.xs), x_max: Math.max(...entry.xs), y_min: Math.min(...entry.ys), y_max: Math.max(...entry.ys) })),
741
+ series: prepared.map((entry) => ({ label: entry.label, points: entry.xs.length, x_min: minData(entry.xs), x_max: maxData(entry.xs), y_min: minData(entry.ys), y_max: maxData(entry.ys) })),
723
742
  series_count: prepared.length,
724
743
  point_count: prepared.reduce((sum, entry) => sum + entry.xs.length, 0),
725
- x_range: [Math.min(...allX), Math.max(...allX)],
726
- y_range: [Math.min(...allY), Math.max(...allY)],
744
+ x_range: [minData(allX), maxData(allX)],
745
+ y_range: [minData(allY), maxData(allY)],
727
746
  },
728
747
  marks: prepared.reduce((sum, entry) => sum + entry.xs.length, 0),
729
748
  plot,
@@ -815,7 +834,7 @@ export function renderViolinPlot({ title, x_label, y_label, series, bandwidth, s
815
834
  });
816
835
  const lows = prepared.map((entry) => entry.stats.min);
817
836
  const highs = prepared.map((entry) => entry.stats.max);
818
- const [domainMin, domainMax] = padDomain(Math.min(...lows), Math.max(...highs));
837
+ const [domainMin, domainMax] = padDomain(minData(lows), maxData(highs));
819
838
  const { parts, plot } = frame(title);
820
839
  const y = yAxis(parts, plot, domainMin, domainMax, y_label);
821
840
  const slot = plot.width / prepared.length;
@@ -890,8 +909,8 @@ export function renderVolcanoPlot({ title, x_label, y_label, points, fold_change
890
909
  const significant = (point) => Math.abs(point.fold) >= fold_change_threshold && point.p <= p_threshold;
891
910
  const xs = prepared.map((point) => point.fold);
892
911
  const ys = prepared.map((point) => point.significance);
893
- const [xMin, xMax] = padDomain(Math.min(0, ...xs), Math.max(0, ...xs));
894
- const [yMin, yMax] = padDomain(0, Math.max(...ys, -Math.log10(p_threshold)));
912
+ const [xMin, xMax] = padDomain(Math.min(0, minData(xs)), Math.max(0, maxData(xs)));
913
+ const [yMin, yMax] = padDomain(0, Math.max(maxData(ys), -Math.log10(p_threshold)));
895
914
  const { parts, plot } = frame(title);
896
915
  const y = yAxis(parts, plot, yMin, yMax, y_label ?? '-log10 p');
897
916
  const x = linearScale([xMin, xMax], [plot.x, plot.x + plot.width]);
@@ -936,7 +955,7 @@ export function renderVolcanoPlot({ title, x_label, y_label, points, fold_change
936
955
  down_count: prepared.filter((point) => significant(point) && point.fold < 0).length,
937
956
  fold_change_threshold,
938
957
  p_threshold,
939
- max_significance: Math.max(...ys),
958
+ max_significance: maxData(ys),
940
959
  labelled: [...labelled],
941
960
  },
942
961
  marks: prepared.length,
@@ -1000,8 +1019,8 @@ export function renderHeatmap({ title, x_label, y_label, rows, columns, matrix,
1000
1019
  }
1001
1020
  const flat = values.flat().filter((value) => value !== null);
1002
1021
  if (flat.length === 0) throw new MolbioInputError('the matrix has no finite values to draw');
1003
- const min = Math.min(...flat);
1004
- const max = Math.max(...flat);
1022
+ const min = minData(flat);
1023
+ const max = maxData(flat);
1005
1024
  const ramp = color_scale ?? (min < 0 ? 'divergingRedBlue' : 'viridis');
1006
1025
  if (COLOR_RAMPS[ramp] === undefined) {
1007
1026
  throw new MolbioInputError(`unknown color_scale ${JSON.stringify(color_scale)}; use ${Object.keys(COLOR_RAMPS).join(', ')}`);