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
@@ -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
+ }
package/cordis.patch.yml CHANGED
@@ -21,7 +21,7 @@
21
21
  #
22
22
  # Keep the tool in the PRESET and only the anchor here. An earlier version of
23
23
  # this file inserted a host row for the tool itself (`name: dsh-molbio-tools`),
24
- # which both registered all 60 tools globally and collided with the preset's own
24
+ # which both registered every tool globally and collided with the preset's own
25
25
  # copy of that row:
26
26
  #
27
27
  # broken: "tool-molbio (...): never started"
@@ -47,7 +47,7 @@
47
47
  id: molbio-lab
48
48
  order: 5
49
49
  name: Molecular Biology Lab
50
- description: "分子生物学专属模式:标准编码能力 + 60 个 molbio_* 工具(序列分析、引物设计(含跨内含子 qPCR、盐/浓度旋钮与目标位置偏好)、TaqMan 水解探针(含 MGB 与订购 CSV)与多重 PCR 互扰/退火温度/浓度建议、克隆模拟与选酶(含 Golden Gate)、酶目录查询、甲基化敏感位点与双酶切 buffer 兼容检查、多序列比对(每个输入残基都会进比对结果)、保守性分析与比对后处理(共识/缺口修剪/同一性矩阵/逐列覆盖度,含 sequence logo 图)、CRISPR gRNA 设计(SpCas9/SaCas9/Cas12a/Cas13,家族特定 PAM/PFS/种子)与脱靶搜索、SnapGene/GenBank 解析与质粒图谱、Sanger 测序验证、蛋白性质/密码子优化/螺旋轮投影与疏水性图、qPCR 与绘图、虚拟凝胶、实验台分析(FASTQ 质控、密码子使用、系统发生树(长叶名自动折行)、in-silico PCR、CpG/GC 组成、批量文件分析/CSV)、静默酶切位点突变设计——生成的 SVG 图片自动用系统默认应用打开,文献/协议/实验记录)。"
50
+ description: "分子生物学专属模式:标准编码能力 + 64 个 molbio_* 工具(序列分析、引物设计(含跨内含子 qPCR、盐/浓度旋钮与目标位置偏好)、TaqMan 水解探针(含 MGB 与订购 CSV)与多重 PCR 互扰/退火温度/浓度建议、克隆模拟与选酶(含 Golden Gate)、酶目录查询、甲基化敏感位点与双酶切 buffer 兼容检查、多序列比对(每个输入残基都会进比对结果)、保守性分析与比对后处理(共识/缺口修剪/同一性矩阵/逐列覆盖度,含 sequence logo 图)、CRISPR gRNA 设计(SpCas9/SaCas9/Cas12a/Cas13,家族特定 PAM/PFS/种子)与脱靶搜索、SnapGene/GenBank 解析与质粒图谱、Sanger 测序验证、蛋白性质/电荷-pH 曲线/密码子优化/螺旋轮投影与疏水性图、qPCR 与绘图、虚拟凝胶、实验台分析(FASTQ 质控、密码子使用、系统发生树(长叶名自动折行)、in-silico PCR、CpG/GC 组成、序列 dotplot、载体/接头污染筛查、FASTQ 修剪/过滤、批量文件分析/CSV)、静默酶切位点突变设计——生成的 SVG 图片自动用系统默认应用打开,文献/协议/实验记录)。"
51
51
  plugins:
52
52
  - id: persona
53
53
  name: '@deepseek-ai/dsh-persona'
@@ -67,6 +67,11 @@
67
67
  disabled: !!js process.platform !== 'win32'
68
68
  - id: tool-fs
69
69
  name: '@deepseek-ai/dsh-tool-fs'
70
+ config:
71
+ # Set false to drop the apply_patch tool beside edit. (0.2.2-rc.1 makes this
72
+ # explicit in the shipped standard preset; the package default is false, and
73
+ # the row is kept verbatim so the two stay comparable.)
74
+ applyPatch: false
70
75
  - id: tool-fs-search
71
76
  name: '@deepseek-ai/dsh-tool-fs-search'
72
77
  config:
@@ -129,14 +134,13 @@
129
134
  config:
130
135
  - id: tool-subagent-control
131
136
  name: '@deepseek-ai/dsh-tool-subagent-control'
132
- - id: tool-subagent-list-agents
133
- name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
134
137
  - id: tool-subagent
135
138
  name: '@deepseek-ai/dsh-tool-subagent'
136
139
  config:
137
140
  provider: spawn
138
141
  toolName: subagent
139
- modelSelectionSettings: true
142
+ modelSelection: true
143
+ modelDiscovery: true
140
144
  - id: tool-subagent-fork
141
145
  name: '@deepseek-ai/dsh-tool-subagent'
142
146
  config:
@@ -168,7 +172,7 @@
168
172
  name: '@deepseek-ai/dsh-plugin-market/tools'
169
173
  disabled: true
170
174
 
171
- # dsh-molbio-tools: molecular-biology toolset (60 tools across sequence
175
+ # dsh-molbio-tools: molecular-biology toolset (64 tools across sequence
172
176
  # analysis, primers (incl. TaqMan probes and multiplex compatibility),
173
177
  # in-silico PCR, cloning (incl. Golden Gate), enzyme catalog, methylation-aware
174
178
  # and double digests, plasmids, Sanger verification, proteins (incl. helical
package/design.mjs CHANGED
@@ -36,17 +36,17 @@
36
36
  * from `anchor` and not from the RC-scan `start`. It is the REVERSE primer,
37
37
  * and the amplicon ends at `anchor + length - 1`.
38
38
  *
39
- * NOTE — `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
40
- * deliberately does NOT follow this convention and is unchanged in 0.16.0: it
41
- * reports both primers as sense substrings of the spliced transcript, which is
42
- * what makes its `exon`/`junction_left`/`junction_right` geometry, its
43
- * spliced-vs-genomic mismatch report and its 3'-tail mispriming check read
44
- * uniformly on one sequence. Both ends are therefore reported 5'->3' on the
45
- * sense strand, `forward` is downstream of `reverse`, and the molecule to order
46
- * as the reverse primer is the reverse complement of the reported `reverse`.
47
- * That is a real usability wart, not a silent one (nothing feeds this pair to
48
- * `molbio_pcr_simulate`), so it is recorded on the roadmap rather than changed
49
- * in the same release that moves `designPrimerPairs`.
39
+ * `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
40
+ * follows the SAME convention since 0.23.0. The junction is carried by the
41
+ * REVERSE primer (the assay sits 5' of the junction), so the reverse candidate
42
+ * is scanned on the reverse complement of the spliced transcript: every 3'-end
43
+ * constraint is then evaluated on the molecule that is ordered, and the hit is
44
+ * mapped back to sense coordinates through `anchor` (its 3' end, the LEFT edge
45
+ * of its site). `forward` is upstream of `reverse`, the spliced amplicon runs
46
+ * from the forward 5' end to the reverse 5' end, and the reported reverse
47
+ * sequence is what goes on the order form. Before 0.23.0 the tool reported both
48
+ * primers as sense substrings, so `forward` sat downstream of `reverse` and the
49
+ * reported reverse was the reverse complement of the molecule to order.
50
50
  */
51
51
 
52
52
  import {
@@ -738,37 +738,60 @@ export function designPrimers(rawTemplate, rawOptions) {
738
738
  // ── cross-intron primer design ──────────────────────────────────────────────
739
739
 
740
740
  /**
741
- * Mismatch report for cross-intron candidates. Both primer scans run on the
742
- * spliced transcript in the sense orientation (the reported reverse.sequence
743
- * follows the established sense-substring convention of this tool), so the
744
- * mapping is uniform: `template_base` is what a perfectly matching primer
745
- * carries at the position on the spliced transcript, and coordinates come back
746
- * both spliced and genomic.
741
+ * Mismatch report for cross-intron candidates. The forward primer is the
742
+ * sense-strand substring at its binding site; the reverse primer is the REVERSE
743
+ * COMPLEMENT of the sense-strand substring at its site (the molecule that is
744
+ * ordered). `anchorStart` is the 0-based sense-strand position of the primer's
745
+ * 3' end — for the reverse primer that is the LEFT edge of its site — and
746
+ * `isReverse` flips both the perfect-match base and how a primer offset maps
747
+ * onto the transcript.
747
748
  */
748
- function intronMismatchReport(candidate, spliced, splicedToGenomic) {
749
+ function intronMismatchReport(candidate, spliced, splicedToGenomic, anchorStart, isReverse) {
749
750
  const len = candidate.length;
750
- return candidate.mismatches.map(({ p, base }) => ({
751
- position: p + 1,
752
- template_base: spliced[candidate.start + p],
753
- primer_base: base,
754
- spliced_position: candidate.start + p + 1,
755
- genomic_position: splicedToGenomic[candidate.start + p] + 1,
756
- distance_from_3prime: len - 1 - p,
757
- }));
751
+ return candidate.mismatches.map(({ p, base }) => {
752
+ const offset = isReverse ? len - 1 - p : p;
753
+ const index = anchorStart + offset;
754
+ return {
755
+ position: p + 1,
756
+ template_base: isReverse ? complement(spliced[index]) : spliced[index],
757
+ primer_base: base,
758
+ spliced_position: index + 1,
759
+ genomic_position: splicedToGenomic[index] + 1,
760
+ distance_from_3prime: len - 1 - p,
761
+ };
762
+ });
758
763
  }
759
764
 
760
765
  /**
761
- * Design qPCR primer pairs where the forward primer spans an exon-exon
762
- * junction (>= min_junction_bases on each side) so genomic DNA cannot be
763
- * amplified, and the reverse primer sits in a different exon. Coordinates are
764
- * reported both on the spliced transcript and on the genomic sequence.
766
+ * The same weights as `mismatchPenalty`, over the snake_case report fields.
767
+ * The old intron path handed snake_case reports to the camelCase helper, so the
768
+ * 3'-zone surcharge never applied there; this keeps the two conventions apart on
769
+ * purpose instead of loosening the shared helper.
770
+ */
771
+ function intronMismatchPenalty(report, zone) {
772
+ return report.reduce((sum, mismatch) => sum + 8 + (mismatch.distance_from_3prime <= zone ? 4 : 0), 0);
773
+ }
774
+
775
+ /**
776
+ * Design qPCR primer pairs across an exon-exon junction so genomic DNA cannot
777
+ * be amplified: the REVERSE primer straddles the junction (>= minJunctionBases
778
+ * on each side), the FORWARD primer sits upstream inside a single exon, and the
779
+ * spliced amplicon crosses the junction inside the reverse primer's site.
780
+ *
781
+ * Coordinates are reported both on the spliced transcript and on the genomic
782
+ * sequence. Orientation follows `designPrimerPairs` (see the file header):
783
+ * `forward.sequence` is the sense-strand substring at its site and
784
+ * `reverse.sequence` is the reverse complement of the sense-strand substring at
785
+ * its site. The reverse candidate is scanned on the reverse complement of the
786
+ * spliced transcript, so every 3'-end constraint (GC clamp, end stability, end
787
+ * GC, self-end, hairpin) applies to the molecule that is ordered.
765
788
  */
766
789
  export function designIntronSpanningPrimers(genomic, exons, opts) {
767
790
  if (!Array.isArray(exons) || exons.length < 2) throw new MolbioInputError('exons must be an array of at least two {start, end} spans');
768
791
  const sorted = [...exons].sort((a, b) => a.start - b.start);
769
792
  for (const exon of sorted) {
770
793
  if (!Number.isInteger(exon.start) || !Number.isInteger(exon.end) || exon.start < 1 || exon.end > genomic.length || exon.start > exon.end) {
771
- throw new MolbioInputError(`exon ${JSON.stringify(exon)} is outside the genomic sequence (length ${genomic.length})`);
794
+ throw new MolbioInputError('exon ' + JSON.stringify(exon) + ' is outside the genomic sequence (length ' + genomic.length + ')');
772
795
  }
773
796
  }
774
797
  for (let i = 1; i < sorted.length; i++) {
@@ -791,33 +814,39 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
791
814
  }
792
815
  });
793
816
  if (spliced.length < 24) throw new MolbioInputError('the spliced transcript is too short for primer design');
817
+ const splicedLength = spliced.length;
818
+ const rcSpliced = reverseComplement(spliced);
794
819
 
795
820
  // v13 target position preference (coordinates on the spliced transcript).
796
821
  const targetPosition = opts.targetPosition;
797
- if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > spliced.length)) {
798
- throw new MolbioInputError(`target_position ${targetPosition} is outside the spliced transcript (length ${spliced.length})`);
822
+ if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > splicedLength)) {
823
+ throw new MolbioInputError('target_position ' + targetPosition + ' is outside the spliced transcript (length ' + splicedLength + ')');
799
824
  }
800
825
  const targetWeight = opts.targetPenalty ?? DESIGN_DEFAULTS.targetPenalty;
801
826
 
802
- // Forward candidates must span a junction with minSide bases on each side.
803
- const fwd = [];
804
- for (const candidate of scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)) {
827
+ // Forward candidates: entirely inside one exon on the sense strand.
828
+ const fwd = scanCandidates(spliced, 0, splicedLength - opts.lenMin, opts)
829
+ .filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
830
+ .map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
831
+ .sort((a, b) => a.start - b.start);
832
+
833
+ // Reverse candidates: scanned on the reverse complement (the ordered
834
+ // molecule), then mapped back to the sense-strand 3' end `anchor`.
835
+ const rev = [];
836
+ for (const candidate of scanCandidates(rcSpliced, 0, splicedLength - opts.lenMin, opts)) {
837
+ const anchor = splicedLength - (candidate.start + candidate.length);
838
+ const topEnd = anchor + candidate.length - 1;
805
839
  for (const junction of junctionPositions) {
806
- const left = junction.splicedPos - candidate.start;
807
- const right = candidate.start + candidate.length - junction.splicedPos;
840
+ const left = junction.splicedPos - anchor;
841
+ const right = topEnd + 1 - junction.splicedPos;
808
842
  if (left >= minSide && right >= minSide) {
809
- fwd.push({ ...candidate, junction, junction_left: left, junction_right: right });
843
+ rev.push({ ...candidate, anchor, junction, junction_left: left, junction_right: right });
810
844
  break;
811
845
  }
812
846
  }
813
847
  }
814
-
815
- // Reverse candidates sit entirely inside one exon.
816
- const rev = scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)
817
- .filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
818
- .map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
819
- .sort((a, b) => a.start - b.start);
820
- const revStarts = rev.map((candidate) => candidate.start);
848
+ rev.sort((a, b) => a.anchor - b.anchor);
849
+ const revAnchors = rev.map((candidate) => candidate.anchor);
821
850
 
822
851
  const tmCenter = (opts.tmMin + opts.tmMax) / 2;
823
852
  const misprimingIndex = opts.checkMispriming
@@ -826,48 +855,52 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
826
855
  const maxSites = opts.misprimingMaxSites ?? DESIGN_DEFAULTS.misprimingMaxSites;
827
856
  const pairs = [];
828
857
  for (const f of fwd) {
829
- const fwdEnd = f.start + f.length - 1;
830
- const aMin = fwdEnd - opts.ampliconMax + 1;
831
- const aMax = fwdEnd - opts.ampliconMin + 1;
858
+ // The reverse site must start after the forward 3' end, and the amplicon
859
+ // (forward 5' -> reverse 5') must fit the window. The bounds are widened by
860
+ // the primer-length range and the exact length is checked below, because a
861
+ // long reverse primer can start earlier and still close inside the window.
862
+ const aMin = Math.max(f.start + f.length, f.start + opts.ampliconMin - opts.lenMax);
863
+ const aMax = f.start + opts.ampliconMax - opts.lenMin;
864
+ if (aMax < aMin) continue;
832
865
  let lo = 0;
833
- let hi = revStarts.length;
866
+ let hi = revAnchors.length;
834
867
  while (lo < hi) {
835
868
  const mid = (lo + hi) >> 1;
836
- if (revStarts[mid] < aMin) lo = mid + 1;
869
+ if (revAnchors[mid] < aMin) lo = mid + 1;
837
870
  else hi = mid;
838
871
  }
839
872
  for (let i = lo; i < rev.length; i++) {
840
873
  const r = rev[i];
841
- if (r.start > aMax) break;
842
- if (r.exon === f.junction.down) continue; // reverse primer must sit in a different exon
843
- const gEnd = splicedToGenomic[fwdEnd];
844
- const gStart = splicedToGenomic[r.start];
874
+ if (r.anchor > aMax) break;
875
+ const ampliconStart = f.start;
876
+ const ampliconEnd = r.anchor + r.length - 1;
877
+ const ampliconLength = ampliconEnd - ampliconStart + 1;
878
+ if (ampliconLength < opts.ampliconMin || ampliconLength > opts.ampliconMax) continue;
879
+ const gStart = splicedToGenomic[ampliconStart];
880
+ const gEnd = splicedToGenomic[ampliconEnd];
845
881
  if (gEnd - gStart + 1 < minGenomicSpan) continue;
846
882
  if (Math.abs(f.tm - r.tm) > opts.maxTmDelta) continue;
847
883
  const dimer = dimerThermo(f.sequence, r.sequence, opts.ctMolar ?? DEFAULT_CT_MOLAR);
848
884
  if (dimer.any_tm > opts.maxDimerTm || dimer.end_tm > opts.maxDimerEndTm) continue;
885
+ const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
849
886
  let fMispriming = { count: 0, sites: [] };
850
887
  let rMispriming = { count: 0, sites: [] };
851
888
  if (misprimingIndex !== undefined) {
852
- const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
853
- fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start);
889
+ // Exclude each primer's own 3'-tail window (not its 5' end).
890
+ fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start + f.length - misK);
854
891
  if (fMispriming.count > maxSites) continue;
855
- // the reported reverse sequence follows the sense-substring convention:
856
- // the real oligo is its reverse complement, so its 3' tail is the RC
857
- // of the FIRST k bases and the intended site is r.start
858
- const rTail = reverseComplement(r.sequence.slice(0, misK));
859
- rMispriming = misprimingForCandidate(r, rTail, misprimingIndex, opts, r.start);
892
+ rMispriming = misprimingForCandidate(r, r.sequence.slice(-misK), misprimingIndex, opts, r.anchor);
860
893
  if (rMispriming.count > maxSites) continue;
861
894
  }
862
- const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic);
863
- const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic);
895
+ const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic, f.start, false);
896
+ const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic, r.anchor, true);
864
897
  const zone = opts.mismatch3PrimeZone ?? DESIGN_DEFAULTS.mismatch3PrimeZone;
865
898
  let fTargetDistance;
866
899
  let rTargetDistance;
867
900
  let targetDistance;
868
901
  if (targetPosition !== undefined) {
869
- fTargetDistance = Math.abs(f.start + f.length - targetPosition); // spliced 1-based 3' end
870
- rTargetDistance = Math.abs(r.start + 1 - targetPosition);
902
+ fTargetDistance = Math.abs(f.start + f.length - targetPosition); // forward 3' end (1-based)
903
+ rTargetDistance = Math.abs(r.anchor + 1 - targetPosition); // reverse 3' end (1-based)
871
904
  targetDistance = Math.min(fTargetDistance, rTargetDistance);
872
905
  }
873
906
  const penalty = 0.6 * Math.abs(f.tm - r.tm) + Math.abs(f.tm - tmCenter) + Math.abs(r.tm - tmCenter)
@@ -875,8 +908,8 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
875
908
  + 1.0 * Math.max(0, f.self_end - 1) + 1.0 * Math.max(0, r.self_end - 1)
876
909
  + 0.2 * Math.max(0, f.hairpin_tm - 40) + 0.2 * Math.max(0, r.hairpin_tm - 40)
877
910
  + 0.2 * Math.max(0, dimer.any_tm - 40) + 0.2 * Math.max(0, dimer.end_tm - 40)
878
- + mismatchPenalty(fMismatches, zone)
879
- + mismatchPenalty(rMismatches, zone)
911
+ + intronMismatchPenalty(fMismatches, zone)
912
+ + intronMismatchPenalty(rMismatches, zone)
880
913
  + 8 * fMispriming.count + 8 * rMispriming.count
881
914
  + (targetDistance !== undefined ? targetWeight * targetDistance : 0);
882
915
  pairs.push({
@@ -894,9 +927,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
894
927
  spliced_end: f.start + f.length,
895
928
  genomic_start: splicedToGenomic[f.start] + 1,
896
929
  genomic_end: splicedToGenomic[f.start + f.length - 1] + 1,
897
- exons: [String(f.junction.up + 1), String(f.junction.down + 1)],
898
- junction_left: f.junction_left,
899
- junction_right: f.junction_right,
930
+ exon: f.exon + 1,
900
931
  mismatch_count: fMismatches.length,
901
932
  mismatches: fMismatches,
902
933
  mispriming_count: fMispriming.count,
@@ -918,11 +949,13 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
918
949
  hairpin_tm: r.hairpin_tm,
919
950
  end_stability_kcal: r.end_stability_kcal,
920
951
  end_gc_count: r.end_gc_count,
921
- spliced_start: r.start + 1,
922
- spliced_end: r.start + r.length,
923
- genomic_start: splicedToGenomic[r.start] + 1,
924
- genomic_end: splicedToGenomic[r.start + r.length - 1] + 1,
925
- exon: r.exon + 1,
952
+ spliced_start: r.anchor + 1,
953
+ spliced_end: r.anchor + r.length,
954
+ genomic_start: splicedToGenomic[r.anchor] + 1,
955
+ genomic_end: splicedToGenomic[r.anchor + r.length - 1] + 1,
956
+ exons: [String(r.junction.up + 1), String(r.junction.down + 1)],
957
+ junction_left: r.junction_left,
958
+ junction_right: r.junction_right,
926
959
  mismatch_count: rMismatches.length,
927
960
  mismatches: rMismatches,
928
961
  mispriming_count: rMispriming.count,
@@ -934,7 +967,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
934
967
  })),
935
968
  ...(rTargetDistance !== undefined ? { target_distance: rTargetDistance } : {}),
936
969
  },
937
- spliced_amplicon: { start: r.start + 1, end: fwdEnd + 1, length: fwdEnd - r.start + 1 },
970
+ spliced_amplicon: { start: ampliconStart + 1, end: ampliconEnd + 1, length: ampliconLength },
938
971
  genomic_amplicon_length: gEnd - gStart + 1,
939
972
  ...(targetDistance !== undefined ? { target_distance: targetDistance } : {}),
940
973
  penalty: Math.round(penalty * 100) / 100,
@@ -945,7 +978,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
945
978
  const seen = new Set();
946
979
  const unique = [];
947
980
  for (const pair of pairs) {
948
- const key = `${pair.spliced_amplicon.start}:${pair.spliced_amplicon.end}`;
981
+ const key = pair.spliced_amplicon.start + ':' + pair.spliced_amplicon.end;
949
982
  if (seen.has(key)) continue;
950
983
  seen.add(key);
951
984
  unique.push(pair);
@@ -6,6 +6,11 @@ mainstream bioinformatics landscape offer that this toolset does **not**, and wh
6
6
  deterministic algorithms in a few hundred lines of dependency-free JS with **no external data files,
7
7
  binaries, network, or reference databases**?
8
8
 
9
+ > **Status note (0.24.0).** The baseline below is the 52-tool package this survey was written
10
+ > against. Since then rows 1–7, 11, 12, 23, 30, 34, 40 and the Cas-family half of row 20 have shipped
11
+ > (see [history.md](history.md) and the roadmap); the remaining rows are still candidates unless
12
+ > §3 names a blocker. The rankings are left as written so the original judgement can be audited.
13
+
9
14
  **Method.** Surveyed nine source families (commercial plasmid/cloning suites; bench web calculators;
10
15
  Biopython/BioPerl/SeqKit/EMBOSS command-line toolkits; standard sequence/statistics analyses;
11
16
  microbiome; population genetics; protein prediction; alignment post-processing; sequencing QC),