dsh-molbio-panel 0.1.2 → 0.1.4

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 (2) hide show
  1. package/lib/client.js +665 -86
  2. package/package.json +12 -1
package/lib/client.js CHANGED
@@ -1813,13 +1813,29 @@ window.__ModuleLoader__.load({
1813
1813
  const { MolbioInputError } = __molbio_require("lib.mjs");
1814
1814
  // ── minimal XML scanner ─────────────────────────────────────────────────────
1815
1815
 
1816
+ /**
1817
+ * Whether a numeric entity names a character this decoder may emit.
1818
+ *
1819
+ * `String.fromCodePoint` THROWS a RangeError above U+10FFFF, and a RangeError
1820
+ * raised from inside a `replace` callback escapes the whole parser as an error
1821
+ * type no caller handles — a crafted `�` in a SnapGene feature
1822
+ * qualifier (or in a PubMed response) turned `molbio_parse_snapgene` into a
1823
+ * crash instead of a MolbioInputError. Surrogates are rejected too: they are not
1824
+ * characters, and emitting a lone one would corrupt the value rather than
1825
+ * reject the reference. An unreadable entity is left as literal text, which is
1826
+ * what the named-entity branch already does.
1827
+ */
1828
+ function isXmlCodePoint(code) {
1829
+ return Number.isInteger(code) && code >= 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff);
1830
+ }
1831
+
1816
1832
  function decodeEntities(value) {
1817
1833
  return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
1818
1834
  if (entity[0] === '#') {
1819
1835
  const code = entity[1] === 'x' || entity[1] === 'X'
1820
1836
  ? parseInt(entity.slice(2), 16)
1821
1837
  : parseInt(entity.slice(1), 10);
1822
- return Number.isFinite(code) ? String.fromCodePoint(code) : match;
1838
+ return isXmlCodePoint(code) ? String.fromCodePoint(code) : match;
1823
1839
  }
1824
1840
  const named = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'", nbsp: ' ' };
1825
1841
  return named[entity] ?? match;
@@ -3985,6 +4001,138 @@ window.__ModuleLoader__.load({
3985
4001
  /** Default SpCas9 PAM. */
3986
4002
  const DEFAULT_PAM = 'NGG';
3987
4003
 
4004
+ /**
4005
+ * Cas-family geometry, in ONE table.
4006
+ *
4007
+ * The original design hard-coded SpCas9's shape everywhere: a 3' PAM, a 20 nt
4008
+ * protospacer, and a seed at the guide's 3' end. That is correct for SpCas9 and
4009
+ * SaCas9, but it is wrong for Cas12a (5' TTTV PAM, 23 nt crRNA, PAM-proximal 5'
4010
+ * seed) and meaningless for Cas13 (an RNA target with a PFS preference, not a
4011
+ * PAM). The table below is the fact each family is scored against; the geometry
4012
+ * and scoring code read it instead of assuming SpCas9.
4013
+ *
4014
+ * Sources: SpCas9 NGG / 20 nt; SaCas9 NNGRRT / 21 nt; Cas12a (LbCpf1, AsCpf1)
4015
+ * 5'-TTTV / 23 nt; LwaCas13a 3' H PFS / 28 nt; PspCas13b 5' D + 3' NAN PFS /
4016
+ * 30 nt; RfxCas13d no PFS / 22 nt. PFS preferences are NOT hard PAMs: a guide
4017
+ * without the preferred PFS can still work, so it is penalised and reported
4018
+ * rather than silently discarded unless the caller sets require_pfs.
4019
+ */
4020
+ const CAS_FAMILIES = {
4021
+ spcas9: {
4022
+ id: 'spcas9',
4023
+ label: 'SpCas9',
4024
+ pam: 'NGG',
4025
+ pamSide: '3prime',
4026
+ guideLength: 20,
4027
+ guideLengthMin: 15,
4028
+ guideLengthMax: 30,
4029
+ seedSide: '3prime',
4030
+ seedLength: 12,
4031
+ seedMismatchWindow: 2,
4032
+ target: 'DNA',
4033
+ tmModel: 'dna',
4034
+ positionBonus: { index: 20, base: 'G', points: 4 },
4035
+ gcClampNote: true,
4036
+ pfs: null,
4037
+ },
4038
+ sacas9: {
4039
+ id: 'sacas9',
4040
+ label: 'SaCas9',
4041
+ pam: 'NNGRRT',
4042
+ pamSide: '3prime',
4043
+ guideLength: 21,
4044
+ guideLengthMin: 15,
4045
+ guideLengthMax: 30,
4046
+ seedSide: '3prime',
4047
+ seedLength: 12,
4048
+ seedMismatchWindow: 2,
4049
+ target: 'DNA',
4050
+ tmModel: 'dna',
4051
+ positionBonus: null,
4052
+ gcClampNote: true,
4053
+ pfs: null,
4054
+ },
4055
+ cas12a: {
4056
+ id: 'cas12a',
4057
+ label: 'Cas12a (Cpf1)',
4058
+ pam: 'TTTV',
4059
+ pamSide: '5prime',
4060
+ guideLength: 23,
4061
+ guideLengthMin: 15,
4062
+ guideLengthMax: 30,
4063
+ seedSide: '5prime',
4064
+ seedLength: 8,
4065
+ seedMismatchWindow: 2,
4066
+ target: 'DNA',
4067
+ tmModel: 'dna',
4068
+ positionBonus: null,
4069
+ gcClampNote: false,
4070
+ pfs: null,
4071
+ },
4072
+ cas13a: {
4073
+ id: 'cas13a',
4074
+ label: 'Cas13a (LwaCas13a)',
4075
+ pam: null,
4076
+ pamSide: 'none',
4077
+ guideLength: 28,
4078
+ guideLengthMin: 15,
4079
+ guideLengthMax: 30,
4080
+ seedSide: null,
4081
+ seedLength: 0,
4082
+ seedMismatchWindow: 0,
4083
+ target: 'RNA',
4084
+ tmModel: 'none',
4085
+ positionBonus: null,
4086
+ gcClampNote: false,
4087
+ pfs: { fivePrime: null, threePrime: 'H', label: "3' H (A/C/U) — a preference, not a PAM" },
4088
+ },
4089
+ cas13b: {
4090
+ id: 'cas13b',
4091
+ label: 'Cas13b (PspCas13b)',
4092
+ pam: null,
4093
+ pamSide: 'none',
4094
+ guideLength: 30,
4095
+ guideLengthMin: 15,
4096
+ guideLengthMax: 30,
4097
+ seedSide: null,
4098
+ seedLength: 0,
4099
+ seedMismatchWindow: 0,
4100
+ target: 'RNA',
4101
+ tmModel: 'none',
4102
+ positionBonus: null,
4103
+ gcClampNote: false,
4104
+ pfs: { fivePrime: 'D', threePrime: 'NAN', label: "5' D (A/G/U) and 3' NAN — preferences, not a PAM" },
4105
+ },
4106
+ cas13d: {
4107
+ id: 'cas13d',
4108
+ label: 'Cas13d (RfxCas13d)',
4109
+ pam: null,
4110
+ pamSide: 'none',
4111
+ guideLength: 22,
4112
+ guideLengthMin: 15,
4113
+ guideLengthMax: 30,
4114
+ seedSide: null,
4115
+ seedLength: 0,
4116
+ seedMismatchWindow: 0,
4117
+ target: 'RNA',
4118
+ tmModel: 'none',
4119
+ positionBonus: null,
4120
+ gcClampNote: false,
4121
+ pfs: null,
4122
+ },
4123
+ };
4124
+
4125
+ const CAS_FAMILY_NAMES = Object.keys(CAS_FAMILIES);
4126
+
4127
+ /** Look up a family, with a useful error instead of a silent SpCas9 fallback. */
4128
+ function resolveCasFamily(raw = 'spcas9') {
4129
+ const family = CAS_FAMILIES[raw];
4130
+ if (family === undefined) {
4131
+ throw new MolbioInputError(`unknown cas_family ${JSON.stringify(raw)}; known: ${CAS_FAMILY_NAMES.join(', ')}`);
4132
+ }
4133
+ return family;
4134
+ }
4135
+
3988
4136
  /** Hard caps, so one call cannot enumerate an unbounded search space. */
3989
4137
  const CRISPR_LIMITS = {
3990
4138
  maxSequenceLength: 200_000,
@@ -3992,37 +4140,116 @@ window.__ModuleLoader__.load({
3992
4140
  maxMismatches: 4,
3993
4141
  maxVariantsPerGuide: 400_000,
3994
4142
  maxReportedSites: 20,
4143
+ // A Cas13 scan has no PAM to thin the candidate set: every window of the
4144
+ // guide length is a candidate. 200 kb would be ~200,000 windows and each
4145
+ // evaluation computes two folding scores, so cap it and ask for a region.
4146
+ maxCas13Candidates: 50_000,
3995
4147
  };
3996
4148
 
4149
+ /** Whether one IUPAC symbol stands for more than one base. */
4150
+ function isDegenerateSymbol(symbol) {
4151
+ const mask = IUPAC_MASKS[symbol];
4152
+ if (mask === undefined) return false;
4153
+ let count = 0;
4154
+ for (let bits = mask; bits !== 0; bits &= bits - 1) count++;
4155
+ return count > 1;
4156
+ }
4157
+
3997
4158
  /**
3998
- * Validate a PAM pattern: IUPAC DNA, 2-6 nt, with the 5'-most position
3999
- * degenerate (`N`) — the invariant that makes a PAM a PAM rather than a fixed
4000
- * site, and the reason a protospacer can always be found upstream of a hit.
4159
+ * Validate a PAM pattern: IUPAC DNA, 2-6 nt, with the PAM position ADJACENT to
4160
+ * the protospacer degenerate.
4161
+ *
4162
+ * For a 3' PAM (SpCas9 NGG, SaCas9 NNGRRT) that is the 5'-most position, as
4163
+ * before. For a 5' PAM (Cas12a TTTV) it is the 3'-most position: the V is the
4164
+ * base next to the protospacer. Requiring "N first" for Cas12a would reject the
4165
+ * real PAM, so the side is an explicit argument rather than an assumption.
4001
4166
  */
4002
- function assertPamPattern(raw) {
4167
+ function assertPamPattern(raw, side = '3prime') {
4003
4168
  if (typeof raw !== 'string') throw new MolbioInputError('pam must be a string');
4004
4169
  const pam = normalizeSequence(raw, 'pam');
4005
4170
  if (pam.length < 2 || pam.length > 6) {
4006
4171
  throw new MolbioInputError(`pam must be 2-6 nt (got ${pam.length})`);
4007
4172
  }
4008
- if (pam[0] !== 'N') {
4173
+ if (side === '5prime') {
4174
+ const anchor = pam[pam.length - 1];
4175
+ if (!isDegenerateSymbol(anchor)) {
4176
+ throw new MolbioInputError(`a 5' PAM must end with a degenerate position (got ${JSON.stringify(pam)}) — the base next to the protospacer cannot be fixed`);
4177
+ }
4178
+ return pam;
4179
+ }
4180
+ if (!isDegenerateSymbol(pam[0])) {
4009
4181
  throw new MolbioInputError(`pam must start with the degenerate position N (got ${JSON.stringify(pam)}) — a fixed first position cannot anchor a protospacer`);
4010
4182
  }
4011
4183
  return pam;
4012
4184
  }
4013
4185
 
4014
4186
  /**
4015
- * Every PAM-anchored protospacer in `seq`, both strands.
4187
+ * The PFS observed around one top-strand protospacer window, as read on the
4188
+ * guide's own strand.
4189
+ *
4190
+ * Cas13 PFS elements are preferences, not PAMs, and PspCas13b has one on each
4191
+ * side. The flanks are therefore part of the candidate object (reported and
4192
+ * optionally filtered), not a reason to drop a window before the caller sees it.
4193
+ */
4194
+ function observedPfs(seq, start, guideLength, strand, pfs) {
4195
+ if (pfs === null) return { five: '', three: '', sequence: '', ok: true };
4196
+ // For the reverse strand the guide is the reverse complement of the top
4197
+ // strand, so its 5' flank is the top-strand base AFTER the protospacer and its
4198
+ // 3' flank is the top-strand base BEFORE it. Reading both through the same
4199
+ // reverse-complement helper is what keeps the two ends from being swapped.
4200
+ const fiveTop = strand === 'forward' ? start - 1 : start + guideLength;
4201
+ const threeTop = strand === 'forward' ? start + guideLength : start - 1;
4202
+ const read = (from, length) => {
4203
+ if (from < 0 || from + length > seq.length) return undefined;
4204
+ const top = seq.slice(from, from + length);
4205
+ return strand === 'forward' ? top : reverseComplement(top);
4206
+ };
4207
+ const five = pfs.fivePrime === null ? '' : read(fiveTop, pfs.fivePrime.length) ?? '';
4208
+ const three = pfs.threePrime === null ? '' : read(threeTop, pfs.threePrime.length) ?? '';
4209
+ const okFive = pfs.fivePrime === null || (five !== '' && pamMatches(five, pfs.fivePrime));
4210
+ const okThree = pfs.threePrime === null || (three !== '' && pamMatches(three, pfs.threePrime));
4211
+ return { five, three, sequence: [five, three].filter((value) => value !== '').join('/'), ok: okFive && okThree };
4212
+ }
4213
+
4214
+ /**
4215
+ * Every PAM-anchored (or, for Cas13, PFS-flanked) protospacer in `seq`.
4216
+ *
4016
4217
  * @param {string} seq normalized (ACGT-only) sequence.
4017
- * @param {{pam?: string, guideLength?: number}} [options]
4218
+ * @param {{pam?: string, pamSide?: '3prime'|'5prime'|'none', guideLength?: number,
4219
+ * family?: object, targetStrand?: 'forward'|'reverse'|'both'}} [options]
4018
4220
  * @returns {Array<{strand: 'forward'|'reverse', start: number, end: number,
4019
- * sequence: string, pam: string, pam_start: number}>} `start`/`end` are
4020
- * 1-based inclusive top-strand positions; `pam_start` is 1-based too.
4021
- * Ordered by top-strand position, forward before reverse at a tie.
4221
+ * sequence: string, pam: string, pam_start: number, pfs?: string, pfs_ok?: boolean}>}
4222
+ * `start`/`end` are 1-based inclusive top-strand positions; `pam_start` is
4223
+ * 1-based too. Ordered by top-strand position, forward before reverse at a tie.
4022
4224
  */
4023
4225
  function findProtospacers(seq, options = {}) {
4024
- const pam = assertPamPattern(options.pam ?? DEFAULT_PAM);
4025
- const guideLength = options.guideLength ?? 20;
4226
+ const pamSide = options.pamSide ?? '3prime';
4227
+ const guideLength = options.guideLength ?? options.family?.guideLength ?? 20;
4228
+ if (pamSide === 'none') {
4229
+ const family = options.family ?? CAS_FAMILIES.cas13a;
4230
+ const targetStrand = options.targetStrand ?? 'forward';
4231
+ const strands = targetStrand === 'both' ? ['forward', 'reverse'] : [targetStrand];
4232
+ const hits = [];
4233
+ for (const strand of strands) {
4234
+ for (let start = 0; start + guideLength <= seq.length; start++) {
4235
+ const pfs = observedPfs(seq, start, guideLength, strand, family.pfs);
4236
+ hits.push({
4237
+ strand,
4238
+ start: start + 1,
4239
+ end: start + guideLength,
4240
+ sequence: strand === 'forward' ? seq.slice(start, start + guideLength) : reverseComplement(seq.slice(start, start + guideLength)),
4241
+ pam: '',
4242
+ pam_start: 0,
4243
+ pfs: pfs.sequence,
4244
+ pfs_ok: pfs.ok,
4245
+ });
4246
+ }
4247
+ }
4248
+ hits.sort((a, b) => a.start - b.start || a.end - b.end || a.strand.localeCompare(b.strand));
4249
+ return hits;
4250
+ }
4251
+ const pam = assertPamPattern(options.pam ?? options.family?.pam ?? DEFAULT_PAM, pamSide);
4252
+ const pamLength = pam.length;
4026
4253
  const rcPam = reverseComplement(pam);
4027
4254
  const hits = [];
4028
4255
  const scan = (pattern, strand) => {
@@ -4038,39 +4265,51 @@ window.__ModuleLoader__.load({
4038
4265
  }
4039
4266
  }
4040
4267
  if (!matched) continue;
4268
+ const pamStart = start; // 0-based top-strand PAM start, both strands
4269
+ let protospacerStart;
4270
+ let protospacerEnd; // 0-based inclusive
4271
+ let pamTop;
4041
4272
  if (strand === 'forward') {
4042
- const protospacerStart = start - guideLength;
4043
- if (protospacerStart < 0) continue; // PAM too close to the 5' end
4044
- hits.push({
4045
- strand: 'forward',
4046
- start: protospacerStart + 1,
4047
- end: start,
4048
- sequence: seq.slice(protospacerStart, start),
4049
- pam: seq.slice(start, start + pattern.length),
4050
- pam_start: start + 1,
4051
- });
4273
+ pamTop = seq.slice(pamStart, pamStart + pamLength);
4274
+ if (pamSide === '3prime') {
4275
+ // PAM is downstream of the protospacer on the top strand.
4276
+ protospacerStart = pamStart - guideLength;
4277
+ protospacerEnd = pamStart - 1;
4278
+ } else {
4279
+ // Cas12a: PAM is upstream of the protospacer on the top strand.
4280
+ protospacerStart = pamStart + pamLength;
4281
+ protospacerEnd = protospacerStart + guideLength - 1;
4282
+ }
4052
4283
  } else {
4053
- // Reverse strand: the PAM triple occupies `start` .. `start + L - 1`
4054
- // on the TOP strand, and the protospacer is the guideLength bases
4055
- // DOWNSTREAM of it — NOT including the PAM itself. (Slicing PAM +
4056
- // guideLength and reverse-complementing the lot was the first version
4057
- // of this function and produced 23 nt "guides".)
4058
- const pamEnd = start + pattern.length;
4059
- const protospacerEnd = pamEnd + guideLength;
4060
- if (protospacerEnd > seq.length) continue; // PAM too close to the 3' end
4061
- hits.push({
4062
- strand: 'reverse',
4063
- start: pamEnd + 1,
4064
- end: protospacerEnd,
4065
- sequence: reverseComplement(seq.slice(pamEnd, protospacerEnd)),
4066
- pam: reverseComplement(seq.slice(start, pamEnd)),
4067
- pam_start: start + 1,
4068
- });
4284
+ pamTop = reverseComplement(seq.slice(pamStart, pamStart + pamLength));
4285
+ if (pamSide === '3prime') {
4286
+ // On the reverse strand the top-strand PAM is UPSTREAM of the
4287
+ // top-strand protospacer.
4288
+ protospacerStart = pamStart + pamLength;
4289
+ protospacerEnd = protospacerStart + guideLength - 1;
4290
+ } else {
4291
+ // Cas12a on the reverse strand: the top-strand PAM is DOWNSTREAM.
4292
+ protospacerEnd = pamStart - 1;
4293
+ protospacerStart = protospacerEnd - guideLength + 1;
4294
+ }
4069
4295
  }
4296
+ if (protospacerStart < 0 || protospacerEnd >= seq.length) continue;
4297
+ const topProto = seq.slice(protospacerStart, protospacerEnd + 1);
4298
+ hits.push({
4299
+ strand,
4300
+ start: protospacerStart + 1,
4301
+ end: protospacerEnd + 1,
4302
+ sequence: strand === 'forward' ? topProto : reverseComplement(topProto),
4303
+ pam: pamTop,
4304
+ pam_start: pamStart + 1,
4305
+ });
4070
4306
  }
4071
4307
  };
4072
4308
  scan(pam, 'forward');
4073
- if (rcPam !== pam) scan(rcPam, 'reverse');
4309
+ // Palindromic PAM patterns are legitimate (e.g. an NNNN test pattern), and
4310
+ // the reverse-strand geometry is a different site even when the text matches,
4311
+ // so both scans run unconditionally.
4312
+ scan(rcPam, 'reverse');
4074
4313
  hits.sort((a, b) => a.start - b.start || a.end - b.end || a.strand.localeCompare(b.strand));
4075
4314
  return hits;
4076
4315
  }
@@ -4104,25 +4343,32 @@ window.__ModuleLoader__.load({
4104
4343
  * reject_reasons: string[]}}
4105
4344
  */
4106
4345
  function evaluateGuide(guide, options = {}) {
4346
+ const family = options.family ?? CAS_FAMILIES.spcas9;
4107
4347
  const notes = [];
4108
4348
  const rejectReasons = [];
4109
4349
  const gc = gcFraction(guide);
4110
4350
  const gcPercent = Math.round(gc * 1000) / 10;
4111
4351
  let tm = null;
4112
- try {
4113
- tm = primerTm(guide, {
4114
- naMm: options.naMm ?? 50,
4115
- mgMm: options.mgMm ?? 1.5,
4116
- dntpMm: options.dntpMm ?? 0.8,
4117
- primerNm: options.primerNm ?? 200,
4118
- }).tm_celsius;
4119
- } catch {
4120
- tm = null; // outside the physical regime of the NN model
4121
- notes.push('Tm could not be estimated with the NN model for this sequence');
4352
+ if (family.tmModel === 'dna') {
4353
+ try {
4354
+ tm = primerTm(guide, {
4355
+ naMm: options.naMm ?? 50,
4356
+ mgMm: options.mgMm ?? 1.5,
4357
+ dntpMm: options.dntpMm ?? 0.8,
4358
+ primerNm: options.primerNm ?? 200,
4359
+ }).tm_celsius;
4360
+ } catch {
4361
+ tm = null; // outside the physical regime of the NN model
4362
+ notes.push('Tm could not be estimated with the NN model for this sequence');
4363
+ }
4364
+ } else {
4365
+ notes.push('Cas13 targets RNA; the DNA nearest-neighbour Tm is not reported for this guide (it would be a different molecule)');
4122
4366
  }
4123
4367
  const selfAny = round2(selfAnyScore(guide));
4124
4368
  const selfEnd = round2(selfEndScoreSafe(guide));
4125
- const seedAny = round2(selfAnyScore(guide.slice(-12)));
4369
+ const seedAny = family.seedSide === null
4370
+ ? 0
4371
+ : round2(selfAnyScore(family.seedSide === '5prime' ? guide.slice(0, family.seedLength) : guide.slice(-family.seedLength)));
4126
4372
  let hairpinTm = null;
4127
4373
  try {
4128
4374
  const hairpins = hairpinThermo(guide);
@@ -4144,11 +4390,17 @@ window.__ModuleLoader__.load({
4144
4390
  if (gcPercent < gcMin) rejectReasons.push(`GC ${gcPercent}% below the minimum ${gcMin}%`);
4145
4391
  if (gcPercent > gcMax) rejectReasons.push(`GC ${gcPercent}% above the maximum ${gcMax}%`);
4146
4392
  if (tRun > (options.maxTRun ?? 4)) rejectReasons.push(`poly-T run of ${tRun} (limit ${options.maxTRun ?? 4}) — a U6 terminator signal`);
4147
- if (options.avoidGcClamp !== false && (guide[guide.length - 1] === 'G' || guide[guide.length - 1] === 'C') && (guide[guide.length - 2] === 'G' || guide[guide.length - 2] === 'C')) {
4393
+ if (family.gcClampNote && options.avoidGcClamp !== false && (guide[guide.length - 1] === 'G' || guide[guide.length - 1] === 'C') && (guide[guide.length - 2] === 'G' || guide[guide.length - 2] === 'C')) {
4148
4394
  notes.push('3\' end is a GG/GC/CC/CG clamp, which can favour seed-independent extension');
4149
4395
  }
4150
4396
  if (selfEnd >= (options.maxSelfEnd ?? 3)) rejectReasons.push(`3' self-complementarity score ${selfEnd} at or above ${options.maxSelfEnd ?? 3}`);
4151
4397
  if (options.avoidGcRun !== false && /G{4,}|C{4,}/.test(guide)) rejectReasons.push('homopolymer run of 4+ G/C');
4398
+ const pfsOk = options.pfsOk ?? true;
4399
+ if (family.pfs !== null && options.requirePfs === true && !pfsOk) {
4400
+ rejectReasons.push(`the preferred ${family.pfs.label} PFS is absent; a PFS is a preference, so set require_pfs=false to keep this guide`);
4401
+ } else if (family.pfs !== null && options.requirePfs !== true && !pfsOk) {
4402
+ notes.push(`the preferred ${family.pfs.label} PFS is absent (a preference, not a PAM; it costs ranking points)`);
4403
+ }
4152
4404
 
4153
4405
  const scored = scoreGuide({
4154
4406
  gcPercent,
@@ -4157,8 +4409,10 @@ window.__ModuleLoader__.load({
4157
4409
  longestHomopolymer,
4158
4410
  selfAny,
4159
4411
  selfEnd,
4412
+ seedAny,
4160
4413
  hairpinTm,
4161
4414
  guide,
4415
+ pfsOk,
4162
4416
  offTargetCount: options.offTargetCount ?? 0,
4163
4417
  }, options);
4164
4418
  return {
@@ -4206,28 +4460,37 @@ window.__ModuleLoader__.load({
4206
4460
  * NOT fitted parameters.
4207
4461
  */
4208
4462
  function scoreGuide(metrics, options) {
4463
+ const family = options.family ?? CAS_FAMILIES.spcas9;
4209
4464
  const cap = (value, max) => Math.min(value, max);
4210
- const penalties = { gc: 0, tm: 0, poly_t: 0, homopolymer: 0, self: 0, seed: 0, hairpin: 0, off_target: 0 };
4465
+ const penalties = { gc: 0, tm: 0, poly_t: 0, homopolymer: 0, self: 0, seed: 0, hairpin: 0, pfs: 0, off_target: 0 };
4211
4466
  const gcMin = options.gcMin ?? 40;
4212
4467
  const gcMax = options.gcMax ?? 70;
4213
4468
  const tmMin = options.tmMin ?? 50;
4214
4469
  const tmMax = options.tmMax ?? 70;
4215
4470
  if (metrics.gcPercent < gcMin) penalties.gc = cap((gcMin - metrics.gcPercent) * 1.0, 20);
4216
4471
  if (metrics.gcPercent > gcMax) penalties.gc = cap((metrics.gcPercent - gcMax) * 1.0, 20);
4217
- if (metrics.tm === null) penalties.tm = 12;
4218
- else if (metrics.tm < tmMin) penalties.tm = cap((tmMin - metrics.tm) * 0.5, 20);
4219
- else if (metrics.tm > tmMax) penalties.tm = cap((metrics.tm - tmMax) * 0.5, 20);
4472
+ if (family.tmModel === 'dna') {
4473
+ if (metrics.tm === null) penalties.tm = 12;
4474
+ else if (metrics.tm < tmMin) penalties.tm = cap((tmMin - metrics.tm) * 0.5, 20);
4475
+ else if (metrics.tm > tmMax) penalties.tm = cap((metrics.tm - tmMax) * 0.5, 20);
4476
+ }
4220
4477
  if (metrics.tRun > 3) penalties.poly_t = cap((metrics.tRun - 3) * 4, 12);
4221
4478
  if (metrics.longestHomopolymer > 4) penalties.homopolymer = cap((metrics.longestHomopolymer - 4) * 4, 12);
4222
4479
  penalties.self = cap(2 * Math.max(0, metrics.selfAny - 8) + 4 * metrics.selfEnd, 16);
4223
- const seedAny = selfAnyScore(metrics.guide.slice(-12));
4224
- if (seedAny >= 6) penalties.seed = cap(4 + 0.5 * (seedAny - 6), 12);
4480
+ const seedAny = metrics.seedAny ?? 0;
4481
+ if (family.seedSide !== null && seedAny >= 6) penalties.seed = cap(4 + 0.5 * (seedAny - 6), 12);
4225
4482
  if (metrics.hairpinTm !== null && metrics.hairpinTm >= (options.maxHairpinTm ?? 47)) {
4226
4483
  penalties.hairpin = cap(2 + (metrics.hairpinTm - (options.maxHairpinTm ?? 47)) * 0.5, 12);
4227
4484
  }
4485
+ // A missing preferred PFS is a ranking penalty, never a hard rejection unless
4486
+ // require_pfs was set. 10 points puts it near the other strong sequence
4487
+ // signals but below an actual off-target site.
4488
+ if (family.pfs !== null && metrics.pfsOk === false) penalties.pfs = 10;
4228
4489
  penalties.off_target = 8 * (metrics.offTargetCount ?? 0);
4229
4490
  const total = Object.values(penalties).reduce((sum, value) => sum + value, 0);
4230
- const bonus = metrics.guide[metrics.guide.length - 1] === 'G' ? 4 : 0;
4491
+ const bonus = family.positionBonus !== null && metrics.guide[family.positionBonus.index - 1] === family.positionBonus.base
4492
+ ? family.positionBonus.points
4493
+ : 0;
4231
4494
  const score = Math.round(Math.max(0, Math.min(100, 100 - total + bonus)) * 10) / 10;
4232
4495
  return { score, penalties };
4233
4496
  }
@@ -4284,12 +4547,21 @@ window.__ModuleLoader__.load({
4284
4547
  */
4285
4548
  function offTargetSites(guide, index, options = {}) {
4286
4549
  const maxMismatches = options.maxMismatches ?? 2;
4287
- const requirePam = options.requirePam !== false;
4550
+ const pamSide = options.pamSide ?? '3prime';
4551
+ const requirePam = pamSide !== 'none' && options.requirePam !== false;
4552
+ const seedSide = options.seedSide ?? (pamSide === 'none' ? null : '3prime');
4553
+ const seedWindow = options.seedMismatchWindow ?? (pamSide === 'none' ? 0 : 2);
4288
4554
  const kmers = mismatchVariants(guide, maxMismatches);
4289
4555
  const seen = new Map();
4290
4556
  for (const [key, distance] of kmers) {
4291
- // Seed constraint: positions length-2 and length-1 must match exactly.
4292
- if (key.slice(-2) !== guide.slice(-2)) continue;
4557
+ // The seed is the PAM-proximal end. SpCas9/SaCas9 protect the last two
4558
+ // bases; Cas12a protects the first two; Cas13 has no PAM and therefore no
4559
+ // hard seed window in this model.
4560
+ if (seedWindow > 0 && seedSide !== null) {
4561
+ const seed = seedSide === '5prime' ? guide.slice(0, seedWindow) : guide.slice(-seedWindow);
4562
+ const candidate = seedSide === '5prime' ? key.slice(0, seedWindow) : key.slice(-seedWindow);
4563
+ if (candidate !== seed) continue;
4564
+ }
4293
4565
  for (const [table, strand] of [[index.forward, 'forward'], [index.reverse, 'reverse']]) {
4294
4566
  const positions = table.get(key);
4295
4567
  if (positions === undefined) continue;
@@ -4306,22 +4578,43 @@ window.__ModuleLoader__.load({
4306
4578
  function finalizeSites(entries, options, requirePam, guideSequence) {
4307
4579
  const reference = options.reference;
4308
4580
  const guideLength = options.guideLength ?? 20;
4581
+ const pamSide = options.pamSide ?? '3prime';
4582
+ const pam = options.pam ?? DEFAULT_PAM;
4583
+ const pamLength = pamSide === 'none' ? 0 : pam.length;
4309
4584
  const ownStart = options.ownStart;
4310
4585
  const ownStrand = options.ownStrand;
4311
4586
  const sites = [];
4312
4587
  let droppedPam = 0;
4313
4588
  let intended = 0;
4314
4589
  for (const entry of entries) {
4315
- // Protospacer top-strand window: 0-based [start, start+20).
4316
- const start = entry.strand === 'forward' ? entry.position : entry.position - (guideLength - 1);
4590
+ // The index stores every k-mer at its TOP-STRAND start, for both the
4591
+ // forward and the reverse table. The old reverse formula subtracted
4592
+ // guideLength-1 here, which shifted every reverse-strand off-target by
4593
+ // 19 nt and dropped most of them as out of bounds — a silent
4594
+ // under-count that the forward-only smoke fixture never exposed.
4595
+ const start = entry.position;
4317
4596
  if (start < 0 || start + guideLength > reference.length) continue;
4318
- const topSlice = reference.slice(start + guideLength - 1, start + guideLength + 2);
4319
- if (topSlice.length < 3) continue;
4320
- const pamTop = entry.strand === 'forward' ? reference.slice(start + guideLength, start + guideLength + 3) : reverseComplement(topSlice);
4321
- const pamIntact = pamMatches(pamTop, options.pam ?? DEFAULT_PAM);
4322
- if (requirePam && !pamIntact) {
4323
- droppedPam++;
4324
- continue;
4597
+ let pamTop = '';
4598
+ let pamIntact = true;
4599
+ if (pamSide !== 'none') {
4600
+ // The PAM as read on the GUIDE's strand, and its top-strand window.
4601
+ // 3' PAM: forward -> downstream; reverse -> upstream.
4602
+ // 5' PAM (Cas12a): forward -> upstream; reverse -> downstream.
4603
+ const topPamStart = entry.strand === 'forward'
4604
+ ? (pamSide === '3prime' ? start + guideLength : start - pamLength)
4605
+ : (pamSide === '3prime' ? start - pamLength : start + guideLength);
4606
+ const topPamEnd = topPamStart + pamLength;
4607
+ if (topPamStart < 0 || topPamEnd > reference.length) {
4608
+ droppedPam++;
4609
+ continue;
4610
+ }
4611
+ const topPam = reference.slice(topPamStart, topPamEnd);
4612
+ pamTop = entry.strand === 'forward' ? topPam : reverseComplement(topPam);
4613
+ pamIntact = pamMatches(pamTop, pam);
4614
+ if (requirePam && !pamIntact) {
4615
+ droppedPam++;
4616
+ continue;
4617
+ }
4325
4618
  }
4326
4619
  // The guide's own intended site: same top-strand window and strand, and an
4327
4620
  // exact match. Counted (not listed) so the caller can subtract it.
@@ -4330,7 +4623,7 @@ window.__ModuleLoader__.load({
4330
4623
  intended++;
4331
4624
  continue;
4332
4625
  }
4333
- sites.push({
4626
+ const site = {
4334
4627
  strand: entry.strand,
4335
4628
  start: start + 1,
4336
4629
  end: start + guideLength,
@@ -4341,7 +4634,13 @@ window.__ModuleLoader__.load({
4341
4634
  entry.strand === 'forward' ? reference.slice(start, start + guideLength) : reverseComplement(reference.slice(start, start + guideLength)),
4342
4635
  guideSequence ?? '',
4343
4636
  ),
4344
- });
4637
+ };
4638
+ if (options.pfsSpec !== undefined && options.pfsSpec !== null) {
4639
+ const pfs = observedPfs(reference, start, guideLength, entry.strand, options.pfsSpec);
4640
+ site.pfs = pfs.sequence;
4641
+ site.pfs_ok = pfs.ok;
4642
+ }
4643
+ sites.push(site);
4345
4644
  }
4346
4645
  sites.sort((a, b) => a.mismatches - b.mismatches || a.start - b.start || a.strand.localeCompare(b.strand));
4347
4646
  return {
@@ -4372,20 +4671,37 @@ window.__ModuleLoader__.load({
4372
4671
  * off-target penalty is already part of every scanned guide's score.
4373
4672
  */
4374
4673
  function designGrnas(sequence, options = {}) {
4375
- const pam = assertPamPattern(options.pam ?? DEFAULT_PAM);
4376
- const guideLength = options.guideLength ?? 20;
4377
- const candidates = findProtospacers(sequence, { pam, guideLength });
4674
+ const family = resolveCasFamily(options.casFamily ?? 'spcas9');
4675
+ const pam = family.pam === null ? '' : assertPamPattern(options.pam ?? family.pam, family.pamSide);
4676
+ const guideLength = options.guideLength ?? family.guideLength;
4677
+ if (guideLength < family.guideLengthMin || guideLength > family.guideLengthMax) {
4678
+ throw new MolbioInputError(`guide_length for ${family.id} must be between ${family.guideLengthMin} and ${family.guideLengthMax} nt (got ${guideLength})`);
4679
+ }
4680
+ const targetStrand = options.targetStrand ?? (family.target === 'RNA' ? 'forward' : 'both');
4681
+ const candidates = findProtospacers(sequence, {
4682
+ ...family.pam === null ? {} : { pam },
4683
+ pamSide: family.pamSide,
4684
+ guideLength,
4685
+ family,
4686
+ targetStrand,
4687
+ });
4378
4688
  const regionStart = options.regionStart ?? 1;
4379
4689
  const regionEnd = options.regionEnd ?? sequence.length;
4380
4690
  const inRegion = candidates.filter((hit) => hit.start >= regionStart && hit.end <= regionEnd);
4691
+ if (family.pamSide === 'none' && inRegion.length > CRISPR_LIMITS.maxCas13Candidates) {
4692
+ throw new MolbioInputError(`the Cas13 scan found ${inRegion.length} candidate windows in the requested region (limit ${CRISPR_LIMITS.maxCas13Candidates}); tighten region_start/region_end`);
4693
+ }
4694
+ const requirePfs = options.requirePfs === true;
4381
4695
  const evaluated = inRegion.map((hit) => {
4382
- const metrics = evaluateGuide(hit.sequence, options);
4696
+ const metrics = evaluateGuide(hit.sequence, { ...options, family, pfsOk: hit.pfs_ok ?? true, requirePfs });
4383
4697
  return {
4384
4698
  sequence: hit.sequence,
4385
4699
  pam: hit.pam,
4386
4700
  strand: hit.strand,
4387
4701
  start: hit.start,
4388
4702
  end: hit.end,
4703
+ ...family.target === 'RNA' ? { rna_sequence: hit.sequence.replaceAll('T', 'U') } : {},
4704
+ ...hit.pfs === undefined || hit.pfs === '' ? {} : { pfs: hit.pfs.replaceAll('T', 'U'), pfs_ok: hit.pfs_ok ?? true },
4389
4705
  gc_percent: metrics.gc_percent,
4390
4706
  tm_celsius: metrics.tm_celsius,
4391
4707
  self_any: metrics.self_any,
@@ -4418,6 +4734,10 @@ window.__ModuleLoader__.load({
4418
4734
  reference: sequence,
4419
4735
  guideLength,
4420
4736
  pam,
4737
+ pamSide: family.pamSide,
4738
+ seedSide: family.seedSide,
4739
+ seedMismatchWindow: family.seedMismatchWindow,
4740
+ pfsSpec: family.pfs,
4421
4741
  guideSequence: guide.sequence,
4422
4742
  ownStart: guide.start,
4423
4743
  ownStrand: guide.strand,
@@ -4429,7 +4749,8 @@ window.__ModuleLoader__.load({
4429
4749
  guide.off_target_sites = result.sites;
4430
4750
  guide.pam_disrupted_sites = result.dropped_pam_disrupted;
4431
4751
  if (guide.off_target_count > 0) {
4432
- guide.notes.push(`${guide.off_target_count} off-target site(s) with <= ${options.maxMismatches ?? 2} mismatch(es) and an intact PAM in the supplied reference`);
4752
+ const requirement = family.pamSide === 'none' ? 'with no PAM requirement (Cas13)' : 'and an intact PAM';
4753
+ guide.notes.push(`${guide.off_target_count} off-target site(s) with <= ${options.maxMismatches ?? 2} mismatch(es) ${requirement} in the supplied reference`);
4433
4754
  }
4434
4755
  // Re-score with the off-target penalty included (the only penalty that
4435
4756
  // depends on the search, and the one weighted above all the others).
@@ -4440,10 +4761,12 @@ window.__ModuleLoader__.load({
4440
4761
  longestHomopolymer: longestRun(guide.sequence),
4441
4762
  selfAny: guide.self_any,
4442
4763
  selfEnd: guide.self_end,
4764
+ seedAny: guide.seed_self_any,
4443
4765
  hairpinTm: guide.hairpin_tm,
4444
4766
  guide: guide.sequence,
4767
+ pfsOk: guide.pfs_ok ?? true,
4445
4768
  offTargetCount: guide.off_target_count,
4446
- }, options);
4769
+ }, { ...options, family });
4447
4770
  guide.score = rescored.score;
4448
4771
  guide.penalties = rescored.penalties;
4449
4772
  }
@@ -4458,7 +4781,14 @@ window.__ModuleLoader__.load({
4458
4781
  rejected_count: evaluated.length - accepted.length,
4459
4782
  accepted_count: accepted.length,
4460
4783
  off_target_scanned: scanned,
4784
+ cas_family: family.id,
4785
+ cas_label: family.label,
4461
4786
  pam,
4787
+ pam_side: family.pamSide,
4788
+ pfs: family.pfs === null ? '' : family.pfs.label,
4789
+ target: family.target,
4790
+ target_strand: targetStrand,
4791
+ require_pfs: requirePfs,
4462
4792
  guide_length: guideLength,
4463
4793
  };
4464
4794
  }
@@ -4531,6 +4861,9 @@ window.__ModuleLoader__.load({
4531
4861
  }
4532
4862
 
4533
4863
  exports.DEFAULT_PAM = DEFAULT_PAM;
4864
+ exports.CAS_FAMILIES = CAS_FAMILIES;
4865
+ exports.CAS_FAMILY_NAMES = CAS_FAMILY_NAMES;
4866
+ exports.resolveCasFamily = resolveCasFamily;
4534
4867
  exports.CRISPR_LIMITS = CRISPR_LIMITS;
4535
4868
  exports.assertPamPattern = assertPamPattern;
4536
4869
  exports.findProtospacers = findProtospacers;
@@ -5686,6 +6019,40 @@ window.__ModuleLoader__.load({
5686
6019
  maxAmplicons: 5,
5687
6020
  };
5688
6021
 
6022
+ /**
6023
+ * Probe chemistries the designer knows about.
6024
+ *
6025
+ * A standard TaqMan probe is dual-labelled (5' reporter, 3' quencher) and is
6026
+ * designed at 18-27 nt with a Tm 5 °C above the primers. An MGB probe carries a
6027
+ * minor-groove binder that raises the Tm substantially, so the SAME unmodified
6028
+ * NN estimate must be compared with a lower window and the shorter length
6029
+ * window. The modification's Tm contribution is NOT modelled: the reported Tm
6030
+ * is the unmodified DNA estimate, and the result says so. Claiming a modified
6031
+ * Tm from the NN model would be a fabricated number.
6032
+ */
6033
+ const PROBE_CHEMISTRY = {
6034
+ taqman: {
6035
+ label: 'TaqMan (standard dual-labelled)',
6036
+ probeLenMin: 18,
6037
+ probeLenMax: 27,
6038
+ probeTmMin: 58,
6039
+ probeTmMax: 72,
6040
+ minTmDelta: 5,
6041
+ defaultQuencher: 'BHQ1',
6042
+ tmModel: 'unmodified DNA nearest-neighbour estimate',
6043
+ },
6044
+ mgb: {
6045
+ label: 'TaqMan MGB (minor-groove binder)',
6046
+ probeLenMin: 13,
6047
+ probeLenMax: 20,
6048
+ probeTmMin: 48,
6049
+ probeTmMax: 60,
6050
+ minTmDelta: 0,
6051
+ defaultQuencher: 'NFQ-MGB',
6052
+ tmModel: 'unmodified DNA nearest-neighbour estimate; the MGB modification raises Tm by an amount this model does not predict — verify with the supplier',
6053
+ },
6054
+ };
6055
+
5689
6056
  const round2 = (value) => Math.round(value * 100) / 100;
5690
6057
 
5691
6058
  function pairTm(pair) {
@@ -5739,7 +6106,8 @@ window.__ModuleLoader__.load({
5739
6106
  * Evaluate one probe candidate: thermodynamics, structure, and the heuristic
5740
6107
  * flags a designer must know before ordering it.
5741
6108
  */
5742
- function evaluateProbe(seq, { tmOpts, ctMolar, primerTmMax, maxRun, maxSelfAny }) {
6109
+ function evaluateProbe(seq, { tmOpts, ctMolar, primerTmMax, maxRun, maxSelfAny, chemistry = 'taqman' }) {
6110
+ const chemistrySpec = PROBE_CHEMISTRY[chemistry] ?? PROBE_CHEMISTRY.taqman;
5743
6111
  const lower = seq.toUpperCase();
5744
6112
  const tm = primerTm(lower, tmOpts).tm_celsius;
5745
6113
  const gc = gcPercent(lower);
@@ -5749,6 +6117,7 @@ window.__ModuleLoader__.load({
5749
6117
  const selfAny = safeSelfAny(lower);
5750
6118
  const selfEnd = safeSelfEnd(lower);
5751
6119
  const notes = [];
6120
+ if (chemistry === 'mgb') notes.push(`MGB probe: Tm ${round2(tm)} °C is the unmodified DNA NN estimate; the MGB modification raises it, so verify the modified Tm with the supplier`);
5752
6121
  if (lower[0] === 'G') notes.push('5\' terminal G: the guanine quenches the reporter even before cleavage — move the probe or pick the reverse strand');
5753
6122
  if (lower[lower.length - 1] === 'G') notes.push('3\' terminal G: a short probe is extended by the polymerase without cleavage — prefer a different 3\' base');
5754
6123
  if (runs.length > 0) notes.push(`mononucleotide run >= ${maxRun + 1} bases (${runs.map((run) => `${run.base}${run.count}`).join(', ')})`);
@@ -5770,9 +6139,12 @@ window.__ModuleLoader__.load({
5770
6139
  return {
5771
6140
  sequence: lower,
5772
6141
  length: lower.length,
6142
+ chemistry,
6143
+ chemistry_label: chemistrySpec.label,
6144
+ tm_model: chemistrySpec.tmModel,
5773
6145
  tm: round2(tm),
5774
6146
  gc_percent: gc,
5775
- tm_delta_vs_primer: tmDelta,
6147
+ ...tmDelta === undefined ? {} : { tm_delta_vs_primer: tmDelta },
5776
6148
  hairpin_tm: round2(hairpin),
5777
6149
  self_any: selfAny,
5778
6150
  self_end: selfEnd,
@@ -5830,6 +6202,11 @@ window.__ModuleLoader__.load({
5830
6202
  // outward from, and the 3' end the reported distance is measured against.
5831
6203
  const stem = forwardUpstream ? forwardInterval : reverseInterval;
5832
6204
  const work = reverse ? reverseComplement(template) : template;
6205
+ // An MGB probe's unmodified Tm is lower than its primers' by design; the
6206
+ // minor-groove binder supplies the missing stability. Comparing the raw NN Tm
6207
+ // with the primer Tm would penalise every MGB candidate, so the margin is not
6208
+ // applied to that chemistry (the MGB Tm window is the constraint instead).
6209
+ const tmReference = opts.chemistry === 'mgb' ? undefined : primerTmMax;
5833
6210
  // On `work`, a top-strand interval [s, e] corresponds to [n-1-e, n-1-s].
5834
6211
  const windowStart = reverse ? n - 1 - gap[1] : gap[0] + minGap;
5835
6212
  const windowEnd = reverse ? n - 1 - gap[0] - minGap : gap[1] - minGap;
@@ -5840,9 +6217,10 @@ window.__ModuleLoader__.load({
5840
6217
  const evaluated = evaluateProbe(sequence, {
5841
6218
  tmOpts: opts.tmOpts,
5842
6219
  ctMolar: opts.ctMolar,
5843
- primerTmMax,
6220
+ primerTmMax: tmReference,
5844
6221
  maxRun: opts.maxRun,
5845
6222
  maxSelfAny: opts.maxSelfAny,
6223
+ chemistry: opts.chemistry,
5846
6224
  });
5847
6225
  if (evaluated.gc_percent < opts.probeGcMin || evaluated.gc_percent > opts.probeGcMax) continue;
5848
6226
  if (evaluated.tm < opts.probeTmMin || evaluated.tm > opts.probeTmMax) continue;
@@ -5892,9 +6270,22 @@ window.__ModuleLoader__.load({
5892
6270
  */
5893
6271
  function designTaqmanProbes(rawTemplate, rawOptions = {}) {
5894
6272
  const template = normalizeSequence(rawTemplate);
5895
- const opts = { ...PROBE_DEFAULTS };
6273
+ const chemistry = rawOptions.probeChemistry ?? 'taqman';
6274
+ const chemistrySpec = PROBE_CHEMISTRY[chemistry];
6275
+ if (chemistrySpec === undefined) {
6276
+ throw new MolbioInputError(`unknown probe_chemistry ${JSON.stringify(chemistry)}; known: ${Object.keys(PROBE_CHEMISTRY).join(', ')}`);
6277
+ }
6278
+ const opts = {
6279
+ ...PROBE_DEFAULTS,
6280
+ probeLenMin: chemistrySpec.probeLenMin,
6281
+ probeLenMax: chemistrySpec.probeLenMax,
6282
+ probeTmMin: chemistrySpec.probeTmMin,
6283
+ probeTmMax: chemistrySpec.probeTmMax,
6284
+ minTmDelta: chemistrySpec.minTmDelta,
6285
+ chemistry,
6286
+ };
5896
6287
  for (const [key, value] of Object.entries(rawOptions)) {
5897
- if (value !== undefined && value !== null) opts[key] = value;
6288
+ if (value !== undefined && value !== null && key !== 'probeChemistry') opts[key] = value;
5898
6289
  }
5899
6290
  // A hydrolysis-probe assay is deliberately short (the amplicon is the stretch
5900
6291
  // the polymerase must traverse before it reaches and cleaves the probe), so
@@ -6000,7 +6391,11 @@ window.__ModuleLoader__.load({
6000
6391
  tm: [primerOpts.tmMin, primerOpts.tmMax],
6001
6392
  gc: [primerOpts.gcMin, primerOpts.gcMax],
6002
6393
  },
6394
+ probe_chemistry: chemistry,
6003
6395
  probe_options: {
6396
+ chemistry,
6397
+ chemistry_label: chemistrySpec.label,
6398
+ tm_model: chemistrySpec.tmModel,
6004
6399
  length: [opts.probeLenMin, opts.probeLenMax],
6005
6400
  tm: [opts.probeTmMin, opts.probeTmMax],
6006
6401
  gc: [opts.probeGcMin, opts.probeGcMax],
@@ -6075,9 +6470,22 @@ window.__ModuleLoader__.load({
6075
6470
  * that will run them.
6076
6471
  */
6077
6472
  function resolveProbeOptions(raw = {}) {
6078
- const opts = { ...PROBE_DEFAULTS };
6473
+ const chemistry = raw.probeChemistry ?? 'taqman';
6474
+ const chemistrySpec = PROBE_CHEMISTRY[chemistry];
6475
+ if (chemistrySpec === undefined) {
6476
+ throw new MolbioInputError(`unknown probe_chemistry ${JSON.stringify(chemistry)}; known: ${Object.keys(PROBE_CHEMISTRY).join(', ')}`);
6477
+ }
6478
+ const opts = {
6479
+ ...PROBE_DEFAULTS,
6480
+ probeLenMin: chemistrySpec.probeLenMin,
6481
+ probeLenMax: chemistrySpec.probeLenMax,
6482
+ probeTmMin: chemistrySpec.probeTmMin,
6483
+ probeTmMax: chemistrySpec.probeTmMax,
6484
+ minTmDelta: chemistrySpec.minTmDelta,
6485
+ chemistry,
6486
+ };
6079
6487
  for (const [key, value] of Object.entries(raw)) {
6080
- if (value !== undefined && value !== null && key !== 'primer_options') opts[key] = value;
6488
+ if (value !== undefined && value !== null && key !== 'primer_options' && key !== 'probeChemistry') opts[key] = value;
6081
6489
  }
6082
6490
  if (!Number.isInteger(opts.probeLenMin) || opts.probeLenMin < 12) throw new MolbioInputError('probe_len_min must be an integer >= 12');
6083
6491
  if (!Number.isInteger(opts.probeLenMax) || opts.probeLenMax > 40 || opts.probeLenMax < opts.probeLenMin) throw new MolbioInputError('probe_len_max must be an integer between probe_len_min and 40');
@@ -6094,12 +6502,96 @@ window.__ModuleLoader__.load({
6094
6502
  return opts;
6095
6503
  }
6096
6504
 
6505
+ /** Quote one CSV field; ordering sheets contain commas in probe notes. */
6506
+ function csvField(value) {
6507
+ const text = value === undefined || value === null ? '' : String(value);
6508
+ return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
6509
+ }
6510
+
6511
+ /**
6512
+ * Build the oligo rows a synthesis order needs: one row per primer and probe.
6513
+ *
6514
+ * The modification column is deliberately explicit rather than implied by the
6515
+ * chemistry name: a lab ordering sheet has separate 5' and 3' modification
6516
+ * fields, and an MGB probe without an NFQ-MGB quencher is a different molecule.
6517
+ */
6518
+ function taqmanOrderRows(assays, options = {}) {
6519
+ const chemistry = options.chemistry ?? 'taqman';
6520
+ const spec = PROBE_CHEMISTRY[chemistry] ?? PROBE_CHEMISTRY.taqman;
6521
+ const reporter = options.reporter ?? 'FAM';
6522
+ const quencher = options.quencher ?? spec.defaultQuencher;
6523
+ const primerScale = options.primerScale ?? '25nm';
6524
+ const probeScale = options.probeScale ?? '25nm';
6525
+ const primerPurification = options.primerPurification ?? 'standard';
6526
+ const probePurification = options.probePurification ?? 'hplc';
6527
+ const rows = [];
6528
+ for (const [index, assay] of assays.entries()) {
6529
+ const id = `assay${index + 1}`;
6530
+ for (const [role, primer] of [['forward', assay.forward], ['reverse', assay.reverse]]) {
6531
+ rows.push({
6532
+ assay: index + 1,
6533
+ role,
6534
+ name: `${id}_${role === 'forward' ? 'F' : 'R'}`,
6535
+ sequence: primer.sequence,
6536
+ length: primer.length,
6537
+ tm_celsius: primer.tm,
6538
+ gc_percent: primer.gc_percent,
6539
+ modification: '',
6540
+ scale: primerScale,
6541
+ purification: primerPurification,
6542
+ notes: '',
6543
+ });
6544
+ }
6545
+ const probe = assay.probe;
6546
+ const noteParts = [spec.label, probe.tm_model, ...(probe.notes ?? [])];
6547
+ if (chemistry === 'mgb' && quencher !== 'NFQ-MGB') {
6548
+ noteParts.push('an MGB probe is normally ordered with an NFQ-MGB quencher; a different quencher is a different molecule');
6549
+ }
6550
+ rows.push({
6551
+ assay: index + 1,
6552
+ role: 'probe',
6553
+ name: `${id}_P`,
6554
+ sequence: probe.sequence,
6555
+ length: probe.length,
6556
+ tm_celsius: probe.tm,
6557
+ gc_percent: probe.gc_percent,
6558
+ modification: `5' ${reporter} / 3' ${quencher}`,
6559
+ scale: probeScale,
6560
+ purification: probePurification,
6561
+ notes: noteParts.filter((part) => part !== undefined && part !== '').join('; '),
6562
+ });
6563
+ }
6564
+ return rows;
6565
+ }
6566
+
6567
+ /** Render the ordering rows as CSV text. */
6568
+ function taqmanOrderCsv(rows) {
6569
+ const header = 'assay,role,name,sequence,length,tm_celsius,gc_percent,modification,scale,purification,notes';
6570
+ const lines = rows.map((row) => [
6571
+ row.assay,
6572
+ row.role,
6573
+ row.name,
6574
+ row.sequence,
6575
+ row.length,
6576
+ row.tm_celsius,
6577
+ row.gc_percent,
6578
+ row.modification,
6579
+ row.scale,
6580
+ row.purification,
6581
+ row.notes,
6582
+ ].map(csvField).join(','));
6583
+ return `${[header, ...lines].join('\n')}\n`;
6584
+ }
6585
+
6097
6586
  exports.PROBE_DEFAULTS = PROBE_DEFAULTS;
6587
+ exports.PROBE_CHEMISTRY = PROBE_CHEMISTRY;
6098
6588
  exports.evaluateProbe = evaluateProbe;
6099
6589
  exports.probeCandidates = probeCandidates;
6100
6590
  exports.designTaqmanProbes = designTaqmanProbes;
6101
6591
  exports.primerEngineOptions = primerEngineOptions;
6102
6592
  exports.resolveProbeOptions = resolveProbeOptions;
6593
+ exports.taqmanOrderRows = taqmanOrderRows;
6594
+ exports.taqmanOrderCsv = taqmanOrderCsv;
6103
6595
  return exports;
6104
6596
  };
6105
6597
  __molbio_modules["multiplex.mjs"] = () => {
@@ -6139,6 +6631,14 @@ window.__ModuleLoader__.load({
6139
6631
  maxSitesReported: 6,
6140
6632
  /** Concentration used for folding Tm (nM primer -> molar). */
6141
6633
  primerNm: 200,
6634
+ /** Annealing-temperature recommendation: lowest primer Tm minus this offset. */
6635
+ annealingOffsetC: 3,
6636
+ /** Half-width of the recommended annealing window, °C. */
6637
+ annealingWindowC: 1,
6638
+ /** A primer-Tm spread above this makes one annealing temperature a compromise. */
6639
+ tmSpreadWarnC: 5,
6640
+ /** Starting primer concentration for every target, nM. */
6641
+ primerConcentrationNm: 200,
6142
6642
  };
6143
6643
 
6144
6644
  const round2 = (value) => Math.round(value * 100) / 100;
@@ -6271,6 +6771,18 @@ window.__ModuleLoader__.load({
6271
6771
  if (rawTargets.length > opts.maxTargets) throw new MolbioInputError(`targets has ${rawTargets.length} entries; the limit is ${opts.maxTargets} per call`);
6272
6772
  if (!(opts.dimerTmThreshold > 0)) throw new MolbioInputError('dimer_tm_threshold must be a positive temperature in °C');
6273
6773
  if (!Number.isInteger(opts.minSizeSeparationBp) || opts.minSizeSeparationBp < 0) throw new MolbioInputError('min_size_separation_bp must be a non-negative integer');
6774
+ if (!(typeof opts.annealingOffsetC === 'number' && Number.isFinite(opts.annealingOffsetC) && opts.annealingOffsetC >= 0 && opts.annealingOffsetC <= 15)) {
6775
+ throw new MolbioInputError('annealing_offset_c must be a number between 0 and 15 (°C below the lowest primer Tm)');
6776
+ }
6777
+ if (!(typeof opts.annealingWindowC === 'number' && Number.isFinite(opts.annealingWindowC) && opts.annealingWindowC >= 0 && opts.annealingWindowC <= 5)) {
6778
+ throw new MolbioInputError('annealing_window_c must be a number between 0 and 5 (°C on each side of the recommendation)');
6779
+ }
6780
+ if (!(typeof opts.tmSpreadWarnC === 'number' && Number.isFinite(opts.tmSpreadWarnC) && opts.tmSpreadWarnC >= 0 && opts.tmSpreadWarnC <= 20)) {
6781
+ throw new MolbioInputError('tm_spread_warn_c must be a number between 0 and 20');
6782
+ }
6783
+ if (!(typeof opts.primerConcentrationNm === 'number' && Number.isFinite(opts.primerConcentrationNm) && opts.primerConcentrationNm >= 20 && opts.primerConcentrationNm <= 1000)) {
6784
+ throw new MolbioInputError('primer_concentration_nm must be a number between 20 and 1000');
6785
+ }
6274
6786
 
6275
6787
  const ctMolar = opts.primerNm * 1e-9;
6276
6788
  const targets = rawTargets.map((target, index) => {
@@ -6318,6 +6830,31 @@ window.__ModuleLoader__.load({
6318
6830
  }
6319
6831
  }
6320
6832
 
6833
+ // Annealing temperature: one tube, one temperature. The defensible starting
6834
+ // point is the LOWEST primer Tm minus a small offset, because a primer that
6835
+ // melts below the set temperature is the one that drops out first. This is a
6836
+ // rule of thumb, not a thermal-cycling optimisation — the note says so.
6837
+ const annealing = primers.length === 0 ? null : (() => {
6838
+ const tms = primers.map((primer) => primer.tm);
6839
+ const minTm = Math.min(...tms);
6840
+ const maxTm = Math.max(...tms);
6841
+ const spread = round2(maxTm - minTm);
6842
+ const limiting = primers.reduce((best, primer) => (primer.tm < best.tm ? primer : best));
6843
+ const recommended = round2(minTm - opts.annealingOffsetC);
6844
+ return {
6845
+ min_primer_tm: round2(minTm),
6846
+ max_primer_tm: round2(maxTm),
6847
+ spread_c: spread,
6848
+ recommended_c: recommended,
6849
+ range_c: [round2(recommended - opts.annealingWindowC), round2(recommended + opts.annealingWindowC)],
6850
+ limiting_primer: `${limiting.target}/${limiting.role}`,
6851
+ offset_c: opts.annealingOffsetC,
6852
+ window_c: opts.annealingWindowC,
6853
+ tm_spread_ok: spread <= opts.tmSpreadWarnC,
6854
+ note: 'Starting point: the lowest primer Tm minus the configured offset; optimise on the bench with a temperature gradient.',
6855
+ };
6856
+ })();
6857
+
6321
6858
  // Cross interactions: every primer pair that is not the same primer. Within a
6322
6859
  // target, forward+reverse is the intended pair (its dimer was already checked
6323
6860
  // by the designer), so the conflicts are the CROSS-target ones plus each
@@ -6435,6 +6972,36 @@ window.__ModuleLoader__.load({
6435
6972
  }
6436
6973
  sizeConflicts.sort((x, y) => x.difference_bp - y.difference_bp);
6437
6974
 
6975
+ // Primer concentrations: a multiplex is a competition, and a much shorter
6976
+ // amplicon can out-compete a longer one. The plan is deliberately conservative
6977
+ // — it starts every pair at the same concentration and only shifts the
6978
+ // extremes when the size ratio is at least 2x. It is a starting point, not a
6979
+ // measured optimum; the rationale field says which case each target is in.
6980
+ const knownSizes = sizes.filter((entry) => entry.length !== undefined).map((entry) => entry.length);
6981
+ const minSize = knownSizes.length === 0 ? undefined : Math.min(...knownSizes);
6982
+ const maxSize = knownSizes.length === 0 ? undefined : Math.max(...knownSizes);
6983
+ const sizeRatio = minSize === undefined || minSize === 0 || maxSize === undefined ? 1 : maxSize / minSize;
6984
+ const primerConcentrations = targets.map((target) => {
6985
+ const size = sizes.find((entry) => entry.name === target.name)?.length;
6986
+ let recommended = opts.primerConcentrationNm;
6987
+ let rationale = 'panel default; no size-based adjustment applies';
6988
+ if (knownSizes.length >= 2 && sizeRatio >= 2 && size !== undefined) {
6989
+ if (size === minSize) {
6990
+ recommended = Math.round(opts.primerConcentrationNm * 0.75);
6991
+ rationale = 'shortest amplicon in the panel; a lower share limits its competitive advantage';
6992
+ } else if (size === maxSize) {
6993
+ recommended = Math.round(opts.primerConcentrationNm * 1.5);
6994
+ rationale = 'longest amplicon in the panel; a higher share compensates for lower amplification efficiency';
6995
+ } else {
6996
+ rationale = 'panel default; between the shortest and longest amplicon';
6997
+ }
6998
+ }
6999
+ return { target: target.name, recommended_nm: recommended, rationale };
7000
+ });
7001
+ const concentrationNote = knownSizes.length >= 2 && sizeRatio >= 2
7002
+ ? `amplicon sizes span ${minSize}-${maxSize} bp (${round2(sizeRatio)}x); the plan lowers the shortest and raises the longest pair — a starting point, not a measured optimum`
7003
+ : '';
7004
+
6438
7005
  const blockingInteractions = interactions.filter((entry) => entry.conflict && entry.cross_target);
6439
7006
  const blockingMispriming = mispriming.filter((entry) => entry.scope === 'other_templates');
6440
7007
  const blockingSizes = sizeConflicts.filter((entry) => entry.severity === 'indistinguishable');
@@ -6450,6 +7017,13 @@ window.__ModuleLoader__.load({
6450
7017
  for (const entry of blockingSizes.slice(0, 5)) {
6451
7018
  advice.push(`${entry.a} (${entry.a_length} bp) and ${entry.b} (${entry.b_length} bp) differ by only ${entry.difference_bp} bp — not resolvable on a standard gel`);
6452
7019
  }
7020
+ if (annealing !== null) {
7021
+ advice.push(`set the annealing temperature around ${annealing.recommended_c} °C (range ${annealing.range_c[0]}-${annealing.range_c[1]} °C): the lowest-Tm primer is ${annealing.limiting_primer} at ${annealing.min_primer_tm} °C`);
7022
+ if (!annealing.tm_spread_ok) {
7023
+ advice.push(`primer Tm spread is ${annealing.spread_c} °C (${annealing.min_primer_tm}-${annealing.max_primer_tm} °C) — one annealing temperature cannot be optimal for all; redesign the outlier primer(s) or use a two-step protocol`);
7024
+ }
7025
+ }
7026
+ if (concentrationNote !== '') advice.push(concentrationNote);
6453
7027
 
6454
7028
  return {
6455
7029
  compatible: blockingInteractions.length === 0 && blockingMispriming.length === 0 && blockingSizes.length === 0,
@@ -6466,10 +7040,15 @@ window.__ModuleLoader__.load({
6466
7040
  cross_template_mispriming: blockingMispriming.length,
6467
7041
  amplicons: sizes,
6468
7042
  size_conflicts: sizeConflicts,
7043
+ annealing,
7044
+ primer_concentrations: primerConcentrations,
7045
+ concentration_note: concentrationNote,
6469
7046
  advice,
6470
7047
  notes: [
6471
7048
  'Dimer/hairpin Tms are the same SantaLucia-1998 nearest-neighbour estimates the primer designer uses; they rank risk, they do not predict a multiplex outcome.',
6472
7049
  'Cross-template annealing is searched with a perfect 3\' tail only; own-template mispriming allows the configured mismatch tolerance.',
7050
+ 'The annealing temperature is a rule of thumb (lowest primer Tm minus the configured offset), not a thermal-cycling optimisation; run a gradient.',
7051
+ 'Primer concentrations are starting points chosen from amplicon-size balance, not measured optima; rebalance empirically.',
6473
7052
  'A panel that passes this check can still fail on the bench: template competition, unequal primer efficiencies and amplicon secondary structure are not modelled here.',
6474
7053
  ],
6475
7054
  };
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "dsh-molbio-panel",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Browser panel for the DeepSeek Harness Web GUI: a Molbio tab in the right sidebar that draws plasmid maps and sequence logos from workspace sequence files, using the same computation modules as the dsh-molbio-tools plugin.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
7
+ "engines": {
8
+ "node": ">=26",
9
+ "dsh": ">=0.2.1-rc.2 <0.3.0-0"
10
+ },
7
11
  "exports": {
8
12
  ".": "./index.mjs",
9
13
  "./client": {
@@ -17,6 +21,13 @@
17
21
  "README.md"
18
22
  ],
19
23
  "dsh": {
24
+ "compatibility": {
25
+ "dshReleases": {
26
+ "0.2.1-rc.1": "unknown",
27
+ "0.2.1-rc.2": "compatible",
28
+ "0.2.1-rc.3": "unknown"
29
+ }
30
+ },
20
31
  "bundle": {
21
32
  "patch": "./cordis.patch.yml"
22
33
  },