dsh-molbio-tools 0.19.0 → 0.20.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 (50) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +96 -33
  3. package/align.mjs +28 -0
  4. package/batch.mjs +185 -0
  5. package/benchmark/README.md +27 -8
  6. package/benchmark/sequences.mjs +7 -0
  7. package/benchmark/tasks/02-primers.json +132 -19
  8. package/benchmark/tasks/03-restriction.json +113 -29
  9. package/benchmark/tasks/04-cloning.json +167 -34
  10. package/benchmark/tasks/05-plasmids.json +172 -29
  11. package/benchmark/tasks/06-analysis.json +387 -100
  12. package/benchmark/tasks/09-formats.json +132 -23
  13. package/benchmark/tools.mjs +28 -2
  14. package/benchmark/verifications.mjs +17 -1
  15. package/build/release-preflight.mjs +2 -2
  16. package/crispr.mjs +392 -78
  17. package/docs/history.md +10 -0
  18. package/docs/maintainer.md +8 -6
  19. package/docs/roadmap.md +22 -8
  20. package/docs/rules.md +1 -1
  21. package/docs/workflow.md +7 -7
  22. package/fastq-qc.mjs +735 -735
  23. package/icon.svg +8 -0
  24. package/index.mjs +573 -36
  25. package/lib/client.js +665 -86
  26. package/multiplex.mjs +87 -0
  27. package/package.json +12 -5
  28. package/packages/molbio-panel/lib/client.js +665 -86
  29. package/packages/molbio-panel/package.json +1 -1
  30. package/preset/molbio-lab/agent.cordis.yml +4 -4
  31. package/preset/molbio-lab/preset.patch.yml +2 -2
  32. package/preset/molbio-lab/preset.yml +1 -1
  33. package/silent-site.mjs +286 -0
  34. package/snapgene.mjs +17 -1
  35. package/taqman.mjs +163 -7
  36. package/test/contract.mjs +14 -0
  37. package/test/release.mjs +23 -3
  38. package/test/smoke.mjs +377 -3
  39. package/benchmark/reports/20260923-142935-suite.json +0 -545
  40. package/benchmark/reports/20260923-142935-suite.md +0 -30
  41. package/benchmark/reports/20260923-190149-suite.json +0 -2024
  42. package/benchmark/reports/20260923-190149-suite.md +0 -235
  43. package/benchmark/reports/20260923-192913-suite.json +0 -1856
  44. package/benchmark/reports/20260923-192913-suite.md +0 -158
  45. package/benchmark/reports/20260923-200212-suite.json +0 -3587
  46. package/benchmark/reports/20260923-200212-suite.md +0 -163
  47. package/benchmark/reports/20260923-202830-suite.json +0 -3129
  48. package/benchmark/reports/20260923-202830-suite.md +0 -99
  49. package/benchmark/reports/20260923-203856-suite.json +0 -71
  50. package/benchmark/reports/20260923-203856-suite.md +0 -16
package/batch.mjs ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * dsh-molbio-tools/batch.mjs
3
+ *
4
+ * Batch sequence-file analysis: parse every matching file in a workspace
5
+ * directory, compute the same summary for each, and emit one CSV/TSV table.
6
+ * Pure parsing and computation; file discovery and writes live in index.mjs and
7
+ * go through ctx.fs with the session sandbox policy.
8
+ *
9
+ * The point is the TABLE, not a second implementation of each analysis: GC/N
10
+ * come from lib.mjs's baseCounts, ORFs from translateFrames, enzyme sites from
11
+ * digest, and the GenBank/SnapGene parsers are the same ones the single-file
12
+ * tools use. A file that cannot be parsed becomes an error row instead of
13
+ * aborting the whole batch — one bad .dna in a folder of 60 must not hide the
14
+ * other 59.
15
+ */
16
+
17
+ import { MolbioInputError, baseCounts, digest, normalizeSequence, translateFrames } from './lib.mjs';
18
+ import { parseGenBank } from './genbank.mjs';
19
+ import { parseSnapGeneBytes } from './snapgene.mjs';
20
+ import { entryStats, parseFasta, parseFastq } from './seqio.mjs';
21
+
22
+ export const BATCH_LIMITS = {
23
+ maxFiles: 500,
24
+ maxFileBytes: 50 * 1024 * 1024,
25
+ maxTotalBytes: 500 * 1024 * 1024,
26
+ maxOrfBases: 2_000_000,
27
+ maxEnzymes: 20,
28
+ };
29
+
30
+ const round2 = (value) => Math.round(value * 100) / 100;
31
+
32
+ /** Detect the format from the extension, then from the content. */
33
+ export function detectBatchFormat(path, text) {
34
+ const lower = String(path).toLowerCase();
35
+ if (lower.endsWith('.dna')) return 'snapgene';
36
+ if (/\.(gb|gbk|genbank)$/.test(lower)) return 'genbank';
37
+ if (/\.(fa|fasta|fna|fas|ffn)$/.test(lower)) return 'fasta';
38
+ if (/\.(fq|fastq)$/.test(lower)) return 'fastq';
39
+ const head = typeof text === 'string' ? text.trimStart() : '';
40
+ if (head.startsWith('>')) return 'fasta';
41
+ if (head.startsWith('@')) return 'fastq';
42
+ if (/^LOCUS\s/m.test(head)) return 'genbank';
43
+ return 'text';
44
+ }
45
+
46
+ /** Convert a simple basename glob (*, ?) to a RegExp. */
47
+ export function globToRegExp(pattern) {
48
+ const source = String(pattern)
49
+ .replace(/[.+^$()|[\]{}]/g, '\$&')
50
+ .replaceAll('*', '.*')
51
+ .replaceAll('?', '.');
52
+ return new RegExp('^' + source + '$', 'i');
53
+ }
54
+
55
+ /** Parse one sequence file into its name/topology/sequence/features. */
56
+ function parseOne({ path, format, text, bytes }) {
57
+ if (format === 'snapgene') {
58
+ if (bytes === undefined) throw new MolbioInputError('a .dna file needs binary bytes');
59
+ const parsed = parseSnapGeneBytes(bytes);
60
+ return {
61
+ name: parsed.name,
62
+ topology: parsed.topology === 'circular' ? 'circular' : 'linear',
63
+ sequence: parsed.sequence,
64
+ features: parsed.features.length,
65
+ };
66
+ }
67
+ if (format === 'genbank') {
68
+ const parsed = parseGenBank(String(text ?? ''));
69
+ return {
70
+ name: parsed.name ?? path,
71
+ topology: parsed.topology === 'circular' ? 'circular' : 'linear',
72
+ sequence: parsed.sequence,
73
+ features: parsed.features.length,
74
+ };
75
+ }
76
+ if (format === 'fasta') {
77
+ const entries = parseFasta(String(text ?? ''));
78
+ return {
79
+ name: entries[0]?.id ?? path,
80
+ topology: 'unknown',
81
+ sequence: entries.map((entry) => entry.sequence).join(''),
82
+ features: 0,
83
+ entries: entries.length,
84
+ fastaEntries: entries,
85
+ };
86
+ }
87
+ if (format === 'fastq') {
88
+ const entries = parseFastq(String(text ?? ''));
89
+ return {
90
+ name: entries[0]?.id ?? path,
91
+ topology: 'unknown',
92
+ sequence: entries.map((entry) => entry.sequence).join(''),
93
+ features: 0,
94
+ entries: entries.length,
95
+ fastqEntries: entries,
96
+ };
97
+ }
98
+ const sequence = normalizeSequence(String(text ?? ''), 'sequence');
99
+ return { name: path, topology: 'unknown', sequence, features: 0, entries: 1 };
100
+ }
101
+
102
+ /**
103
+ * Analyze one file.
104
+ *
105
+ * @param {{path: string, format?: string, text?: string, bytes?: Uint8Array,
106
+ * enzymes?: string[], orfMinAa?: number, circular?: boolean, codonTable?: string}} input
107
+ * @returns {object} one table row; throws only for an unrecognizable file.
108
+ */
109
+ export function analyzeSequenceFile(input) {
110
+ const { path, text, bytes } = input;
111
+ const format = input.format ?? detectBatchFormat(path, text);
112
+ const parsed = parseOne({ path, format, text, bytes });
113
+ const sequence = normalizeSequence(parsed.sequence, path + ' sequence');
114
+ const counts = baseCounts(sequence);
115
+ const totalBases = sequence.length;
116
+ const gcPercent = counts.gc + counts.at === 0 ? 0 : round2((counts.gc / (counts.gc + counts.at)) * 100);
117
+ const nPercent = totalBases === 0 ? 0 : round2((counts.n / totalBases) * 100);
118
+ const row = {
119
+ path,
120
+ format,
121
+ topology: parsed.topology,
122
+ entries: parsed.entries ?? 1,
123
+ total_bases: totalBases,
124
+ gc_percent: gcPercent,
125
+ n_percent: nPercent,
126
+ feature_count: parsed.features,
127
+ enzyme_sites: [],
128
+ error: '',
129
+ };
130
+ const enzymes = input.enzymes ?? [];
131
+ if (enzymes.length > 0) {
132
+ const circular = input.circular === true || parsed.topology === 'circular';
133
+ const result = digest(sequence, enzymes, circular);
134
+ row.enzyme_sites = result.map((entry, index) => ({
135
+ name: enzymes[index],
136
+ sites: entry.cut_positions.length,
137
+ }));
138
+ }
139
+ const orfMinAa = input.orfMinAa ?? 0;
140
+ if (orfMinAa > 0) {
141
+ if (totalBases > BATCH_LIMITS.maxOrfBases) {
142
+ row.orf_count = 0;
143
+ row.longest_orf_aa = 0;
144
+ row.orf_skipped = true;
145
+ } else {
146
+ const { orfs } = translateFrames(sequence, 'all', input.codonTable ?? 'standard', orfMinAa);
147
+ row.orf_count = orfs.length;
148
+ row.longest_orf_aa = orfs.length === 0 ? 0 : Math.max(...orfs.map((orf) => orf.length_aa));
149
+ row.orf_skipped = false;
150
+ }
151
+ }
152
+ if (format === 'fasta' || format === 'fastq') {
153
+ const stats = entryStats(parsed.fastaEntries ?? parsed.fastqEntries ?? [], format === 'fastq');
154
+ row.mean_length = stats.mean_length;
155
+ if (format === 'fastq' && stats.quality_mean !== undefined) row.quality_mean = stats.quality_mean;
156
+ }
157
+ return row;
158
+ }
159
+
160
+ /** Columns for the table, in a stable order. */
161
+ export function batchColumns({ orfs, enzymes }) {
162
+ const columns = ['path', 'format', 'topology', 'entries', 'total_bases', 'gc_percent', 'n_percent', 'feature_count', 'mean_length'];
163
+ if (orfs) columns.push('orf_count', 'longest_orf_aa', 'orf_skipped');
164
+ if (enzymes.length > 0) columns.push(...enzymes.map((name) => name + '_sites'));
165
+ columns.push('error');
166
+ return columns;
167
+ }
168
+
169
+ function csvField(value, delimiter) {
170
+ const text = value === undefined || value === null ? '' : String(value);
171
+ return text.includes(delimiter) || /["\n\r]/.test(text) ? '"' + text.replaceAll('"', '""') + '"' : text;
172
+ }
173
+
174
+ /** Render rows as CSV (delimiter = ",") or TSV (delimiter = "\t"). */
175
+ export function batchTable(rows, columns, delimiter = ',') {
176
+ const value = (row, column) => {
177
+ if (column.endsWith('_sites')) {
178
+ const name = column.slice(0, -'_sites'.length);
179
+ return row.enzyme_sites?.find((entry) => entry.name === name)?.sites;
180
+ }
181
+ return row[column];
182
+ };
183
+ const lines = rows.map((row) => columns.map((column) => csvField(value(row, column), delimiter)).join(delimiter));
184
+ return [columns.join(delimiter), ...lines].join('\n') + '\n';
185
+ }
@@ -76,11 +76,11 @@ Measured, and printed so the answer can be audited:
76
76
 
77
77
  ```
78
78
  $ touch crispr.mjs && node benchmark/run.mjs --list --changed
79
- 1 of 56 task(s) selected — --tools molbio_grna_design --changed
79
+ 1 of 61 task(s) selected — --tools molbio_grna_design --changed
80
80
  (from --changed; 2 module(s) affected: crispr.mjs, index.mjs)
81
81
 
82
82
  $ touch lib.mjs && node benchmark/run.mjs --list --changed
83
- 55 of 56 task(s) selected — --changed (27 module(s) affected: align.mjs, cloning.mjs, …)
83
+ 55 of 61 task(s) selected — --changed (27 module(s) affected: align.mjs, cloning.mjs, …)
84
84
  ```
85
85
 
86
86
  `lib.mjs` selecting nearly everything is the **correct** answer, not a failure to
@@ -102,7 +102,7 @@ you changed, so its task runs even if it is full-only.
102
102
  | `--tools a,b,c` / `primer_*` | the union | you changed a few |
103
103
  | `--changed --tools X` | the INTERSECTION | "of what I changed, test X" |
104
104
  | (default) | **core** — 14 tasks | a broad smoke of the catalog |
105
- | `--tier full` | all 56, 58/58 tools | before a release |
105
+ | `--tier full` | all 61, 60/60 tools | before a release |
106
106
 
107
107
  The two `--tools` operators are stated because confusing them is the difference
108
108
  between testing what you changed and testing **less** than that: alone it unions,
@@ -125,7 +125,7 @@ widens to the full suite, with the reason printed.
125
125
  | a module **only** imported by other modules (`svgio.mjs`) | partial — its importers' tasks |
126
126
  | docs, changelog, `test/`, `benchmark/`, config | **`none`: nothing to run** |
127
127
 
128
- `none` is deliberately NOT `full`. A documentation commit that triggers 56 model
128
+ `none` is deliberately NOT `full`. A documentation commit that triggers 61 model
129
129
  calls is how a `--changed` default gets switched off and the full suite goes back
130
130
  to running every time — the exact behaviour this option exists to prevent. It is
131
131
  only reachable with an explicit `--changed`, so everything else still fails safe,
@@ -134,7 +134,7 @@ and `test/benchmark-changed.mjs` asserts both directions.
134
134
  ### The part that still runs in full, on purpose
135
135
 
136
136
  A model run with `--changed` or `--tools` first executes the **whole** offline
137
- gate: all 56 tasks' `where: "tool"` expectations are recomputed against the shipped
137
+ gate: all 61 tasks' `where: "tool"` expectations are recomputed against the shipped
138
138
  tools (no model, a few hundred milliseconds). That is what makes partial runs safe
139
139
  — the expected values are re-proven for the tasks you skipped, so "skipped because
140
140
  unchanged" is a checked claim, not a remembered one.
@@ -168,7 +168,7 @@ asserting whatever the tool happened to emit.
168
168
 
169
169
  Two guards make "someone forgot" fail loudly instead of quietly:
170
170
 
171
- - `test/benchmark-coverage.mjs` asserts **58/58 registered tools are covered by
171
+ - `test/benchmark-coverage.mjs` asserts **60/60 registered tools are covered by
172
172
  at least one task**. Adding a tool without a task is a red build. It also
173
173
  asserts the fixtures' premises (the Golden Gate backbone is BsaI-free, the
174
174
  mutagenesis template still admits a primer pair), that a `covers` entry names a
@@ -209,7 +209,7 @@ recorded tool text, and it says so instead of silently scoring empty text).
209
209
 
210
210
  ## The headless profile (`benchmark/profile.mjs`)
211
211
 
212
- The model-facing way to get the 58 tools is the **Molecular Biology Lab** agent
212
+ The model-facing way to get the 60 tools is the **Molecular Biology Lab** agent
213
213
  preset. The benchmark cannot use it, and the reason is structural rather than
214
214
  configurational — `dsh-headless/lib/index.js` refuses an adoption whose session
215
215
  records a preset:
@@ -368,6 +368,25 @@ These are recorded rather than scored, because each one is a question about the
368
368
  plugin or the harness, not about the model. The first is a **measured plugin
369
369
  defect**.
370
370
 
371
+ ### `molbio_extract_region` advertised `sequence`, but the resolver only read `vector`
372
+
373
+ The 0.20.0 full run (61 tasks) failed `region-extract` on **arguments**: the model
374
+ called `molbio_extract_region` with the parameter the schema advertises
375
+ (`sequence`), and the tool answered "provide either vector (a sequence) or
376
+ vector_path". The schema/reality split was the defect: `resolveVector` read only
377
+ `args.vector`, so `sequence` was silently ignored. **Fixed in 0.20.0** by
378
+ accepting every declared inline alias (`vector`/`sequence`/`reference`), with a
379
+ smoke regression that calls the tool through the advertised `sequence` argument.
380
+
381
+ The same run also showed four **assertion** failures rather than product defects:
382
+ `plasmid-digest` (the model wrote `2,840`, the assertion `2840`),
383
+ `protein-props` (`3,935` vs `3935`), `codon-optimize` (the model said
384
+ `EcoRI-free`, the assertion demanded `no EcoRI`), and `chart-volcano` (the task
385
+ asked for `de.svg`, the assertion pinned `volcano.svg`). `--replay` turns all four
386
+ to PASS; the region-extract live rerun was blocked by the DSH sandbox approval
387
+ for headless subprocesses, so the last measured score is **60/61**, with the
388
+ underlying bug fixed and pinned by smoke.
389
+
371
390
  ### `molbio_design_primers` prints the forward/reverse roles reversed
372
391
 
373
392
  **Confirmed by direct inspection, not by reading a model's claim.**
@@ -548,7 +567,7 @@ implementation happens to return for one set of arguments.**
548
567
  and `args_ok` both true is a *reporting* finding, not a tool bug.
549
568
  - **Multimodal hand-off.** `attach_image` is exercised as an argument at most; no
550
569
  assertion inspects a decoded PNG (that is `svgpng.mjs` and `contract.mjs`).
551
- - **Most of the toolset is now covered, but shallowly.** 56 tasks touch all 58
570
+ - **Most of the toolset is now covered, but shallowly.** 61 tasks touch all 60
552
571
  tools; most tools appear in exactly one task, and several appearances are a
553
572
  single argument check rather than a full workflow. Breadth is asserted by
554
573
  `test/benchmark-coverage.mjs`; depth is not, and cannot be.
@@ -165,6 +165,13 @@ export const NAMED_SEQUENCES = {
165
165
  cds: FIXTURES.CDS,
166
166
  mutagenesis_template: FIXTURES.MUTAGENESIS_TEMPLATE,
167
167
  sanger_read: FIXTURES.SANGER_READ,
168
+ // v0.20.0: the family-geometry fixtures. The Cas12a target is a 5' TTTV PAM
169
+ // followed by a 23 nt protospacer; the Cas13a target is a 28 nt spacer with a
170
+ // 3' H PFS. Keeping them here means the instruction and the offline grader
171
+ // cannot diverge.
172
+ cas12a_target: 'AAAATTTAACGTACGTACGTACGTACGTACGCCCCTTTCGGGGGGGGGGG',
173
+ cas13_target: 'AAAAACGTACGTACGTACGTACGTACGTACGTACCCCCCCCCC',
174
+ silent_cds: 'ATGGCTGAATTCGGTTAAGCTTAA',
168
175
  /**
169
176
  * The three FASTQ reads the `fastq-qc` task pastes in.
170
177
  *
@@ -12,26 +12,54 @@
12
12
  "id": "primer-qc",
13
13
  "category": "primers",
14
14
  "tier": "full",
15
- "covers": ["molbio_primer_check", "molbio_primer_tm"],
15
+ "covers": [
16
+ "molbio_primer_check",
17
+ "molbio_primer_tm"
18
+ ],
16
19
  "instruction": "I have these two primers already ordered and I want to know whether they are any good together before I run them. Report the Tm of the forward primer, and flag anything structurally worrying about the pair.\n\nForward: ATTACTCCTGCTCTTCCCATAC\nReverse: CACTACTCCTCTGTACGCAC",
17
20
  "timeout_ms": 240000,
18
- "expect_tools": ["molbio_primer_check"],
21
+ "expect_tools": [
22
+ "molbio_primer_check"
23
+ ],
19
24
  "assertions": [
20
- { "where": "tool", "pattern": "primer1: 22 bp, GC 45\\.45%", "note": "the forward primer's own metrics" },
21
- { "where": "tool", "pattern": "primer2: 20 bp, GC 55%" },
22
- { "where": "tool", "pattern": "self-any 7 \\(≤8\\)", "note": "the reverse primer's self-complementarity, right at the Primer3 limit" },
23
- { "where": "tool", "pattern": "pair \\(dimer\\): score 14", "note": "the pair's dimer score" },
24
- { "where": "answer", "pattern": "53\\.9|54\\b", "note": "the forward primer's Tm, 53.97 °C" }
25
+ {
26
+ "where": "tool",
27
+ "pattern": "primer1: 22 bp, GC 45\\.45%",
28
+ "note": "the forward primer's own metrics"
29
+ },
30
+ {
31
+ "where": "tool",
32
+ "pattern": "primer2: 20 bp, GC 55%"
33
+ },
34
+ {
35
+ "where": "tool",
36
+ "pattern": "self-any 7 \\(≤8\\)",
37
+ "note": "the reverse primer's self-complementarity, right at the Primer3 limit"
38
+ },
39
+ {
40
+ "where": "tool",
41
+ "pattern": "pair \\(dimer\\): score 14",
42
+ "note": "the pair's dimer score"
43
+ },
44
+ {
45
+ "where": "answer",
46
+ "pattern": "53\\.9|54\\b",
47
+ "note": "the forward primer's Tm, 53.97 °C"
48
+ }
25
49
  ]
26
50
  },
27
51
  {
28
52
  "id": "taqman-assay",
29
53
  "category": "primers",
30
54
  "tier": "full",
31
- "covers": ["molbio_design_taqman"],
55
+ "covers": [
56
+ "molbio_design_taqman"
57
+ ],
32
58
  "instruction": "Design a TaqMan (5' nuclease) assay on this template. I want primers plus a hydrolysis probe between them, and I want the probe to sit at least a few degrees above the primers in Tm.\n\nTemplate:\n{template:400:7}",
33
59
  "timeout_ms": 300000,
34
- "expect_tools": ["molbio_design_taqman"],
60
+ "expect_tools": [
61
+ "molbio_design_taqman"
62
+ ],
35
63
  "assertions": [
36
64
  {
37
65
  "where": "tool",
@@ -48,21 +76,39 @@
48
76
  "pattern": "vs the hotter primer",
49
77
  "note": "the probe-vs-primer Tm margin, which the tool reports per candidate"
50
78
  },
51
- { "where": "answer", "pattern": "probe|Probe" }
79
+ {
80
+ "where": "answer",
81
+ "pattern": "probe|Probe"
82
+ }
52
83
  ]
53
84
  },
54
85
  {
55
86
  "id": "intron-qpcr",
56
87
  "category": "primers",
57
88
  "tier": "full",
58
- "covers": ["molbio_design_intron_primers"],
89
+ "covers": [
90
+ "molbio_design_intron_primers"
91
+ ],
59
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).",
60
93
  "timeout_ms": 300000,
61
- "expect_tools": ["molbio_design_intron_primers"],
94
+ "expect_tools": [
95
+ "molbio_design_intron_primers"
96
+ ],
62
97
  "assertions": [
63
- { "where": "tool", "pattern": "forward primer spans an exon-exon junction", "note": "the property that makes the assay genomic-DNA-proof" },
64
- { "where": "tool", "pattern": "spliced amplicon 320-406 \\(87 bp\\), genomic span 387 bp" },
65
- { "where": "tool", "pattern": "junction 12\\+6 bp", "note": "bases contributed on each side of the junction" },
98
+ {
99
+ "where": "tool",
100
+ "pattern": "forward primer spans an exon-exon junction",
101
+ "note": "the property that makes the assay genomic-DNA-proof"
102
+ },
103
+ {
104
+ "where": "tool",
105
+ "pattern": "spliced amplicon 320-406 \\(87 bp\\), genomic span 387 bp"
106
+ },
107
+ {
108
+ "where": "tool",
109
+ "pattern": "junction 12\\+6 bp",
110
+ "note": "bases contributed on each side of the junction"
111
+ },
66
112
  {
67
113
  "where": "answer",
68
114
  "pattern": "splic|junction|genomic|intron|87|387",
@@ -74,12 +120,19 @@
74
120
  "id": "multiplex-panel",
75
121
  "category": "primers",
76
122
  "tier": "full",
77
- "covers": ["molbio_multiplex_check"],
78
- "instruction": "I want to run these two primer pairs in the same multiplex tube. The first pair is on target A (template below), the second on target B. Check them against each other and tell me whether they interfere — dimers, unintended annealing, or bands I will not be able to tell apart.\n\nTarget A template:\n{template:400:7}\n\nPair A: forward ATTACTCCTGCTCTTCCCATAC (Tm 58.4), reverse CACTACTCCTCTGTACGCAC (Tm 58.9)\nPair B: forward GGGTTTCCCAAATTTGGGCCCA, reverse TTTGGGCCCAAATTTGGGAAAC\nTemplate B:\n{template:400:8}",
123
+ "covers": [
124
+ "molbio_multiplex_check"
125
+ ],
126
+ "instruction": "I want to run these two primer pairs in the same multiplex tube. The first pair is on target A (template below), the second on target B. Check them against each other and tell me whether they interfere — dimers, unintended annealing, or bands I will not be able to tell apart. Also give me the recommended annealing temperature for the panel and a starting primer concentration for each pair.\n\nTarget A template:\n{template:400:7}\n\nPair A: forward ATTACTCCTGCTCTTCCCATAC (Tm 58.4), reverse CACTACTCCTCTGTACGCAC (Tm 58.9)\nPair B: forward GGGTTTCCCAAATTTGGGCCCA, reverse TTTGGGCCCAAATTTGGGAAAC\nTemplate B:\n{template:400:8}",
79
127
  "timeout_ms": 300000,
80
- "expect_tools": ["molbio_multiplex_check"],
128
+ "expect_tools": [
129
+ "molbio_multiplex_check"
130
+ ],
81
131
  "assertions": [
82
- { "where": "tool", "pattern": "multiplex panel: 2 target\\(s\\) on 2 template\\(s\\), 4 primer\\(s\\)" },
132
+ {
133
+ "where": "tool",
134
+ "pattern": "multiplex panel: 2 target\\(s\\) on 2 template\\(s\\), 4 primer\\(s\\)"
135
+ },
83
136
  {
84
137
  "where": "tool",
85
138
  "pattern": "primer-primer interactions above the dimer threshold: [0-9]+",
@@ -94,6 +147,66 @@
94
147
  "where": "answer",
95
148
  "pattern": "dimer|interfere|conflict|anneal",
96
149
  "note": "the panel verdict. Note the measured behaviour this task must tolerate: a model that FIRST checks each pair on its own template often finds — correctly — that a pair as written does not amplify at all, and answers that instead. That is a better answer than 'no dimer conflict', not a worse one."
150
+ },
151
+ {
152
+ "where": "tool",
153
+ "pattern": "annealing temperature: [0-9.]+ °C \\(range [0-9.]+-[0-9.]+ °C\\)",
154
+ "note": "the panel-level annealing recommendation (lowest primer Tm minus the offset)"
155
+ },
156
+ {
157
+ "where": "tool",
158
+ "pattern": "primer concentration plan \\(nM\\):",
159
+ "note": "the starting primer-concentration plan"
160
+ },
161
+ {
162
+ "where": "answer",
163
+ "pattern": "anneal|temperature|concentration",
164
+ "note": "the model reports the new planning outputs, not only the dimer verdict"
165
+ }
166
+ ]
167
+ },
168
+ {
169
+ "id": "taqman-mgb",
170
+ "category": "primers",
171
+ "tier": "full",
172
+ "covers": [
173
+ "molbio_design_taqman"
174
+ ],
175
+ "instruction": "Design a TaqMan assay with an MGB probe on this template. Use an MGB probe, write the ordering sheet to mgb-order.csv, and tell me the probe length and Tm window the designer used, the top-ranked probe, and the 5'/3' modifications on its ordering row.\n\nTemplate:\n{template:400:7}",
176
+ "timeout_ms": 300000,
177
+ "expect_tools": [
178
+ "molbio_design_taqman"
179
+ ],
180
+ "assertions": [
181
+ {
182
+ "where": "tool",
183
+ "pattern": "Probe chemistry: TaqMan MGB",
184
+ "note": "the MGB chemistry is selected, not silently the standard one"
185
+ },
186
+ {
187
+ "where": "tool",
188
+ "pattern": "13-20 nt, Tm 48-60",
189
+ "note": "the MGB length/Tm window, on the unmodified NN scale"
190
+ },
191
+ {
192
+ "where": "tool",
193
+ "pattern": "P TTGCTACGCCTGT",
194
+ "note": "the top-ranked MGB probe"
195
+ },
196
+ {
197
+ "where": "tool",
198
+ "pattern": "ordering CSV \\([0-9]+ oligo row\\(s\\)\\) saved to",
199
+ "note": "the ordering sheet was written"
200
+ },
201
+ {
202
+ "where": "tool",
203
+ "pattern": "MGB probes: the reported probe Tm is the unmodified DNA NN estimate",
204
+ "note": "the tool refuses to fabricate a modified Tm"
205
+ },
206
+ {
207
+ "where": "answer",
208
+ "pattern": "MGB|unmodified|modif",
209
+ "note": "the model explains the MGB Tm caveat or the modifications"
97
210
  }
98
211
  ]
99
212
  }