1#[cfg(test)]
4#[path = "amplification_tests.rs"]
5mod tests;
6use crate::digest::reverse_complement;
7use crate::primer_optimisation::{
8 DimerScreen, PrimerConstraints, annealing_tm, dimer_screen, hairpin_stem,
9};
10use crate::{SequenceRecord, Topology};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum DesignError {
16 #[error("invalid amplification request: {0}")]
17 Invalid(&'static str),
18 #[error(
19 "amplification search exceeds explicit implementation budget; narrow the window/length range or reduce templates (no partial design returned)"
20 )]
21 Budget,
22 #[error(
23 "no pair satisfies the declared constraints and supplied-template screening; no fallback returned"
24 )]
25 NoPairs,
26}
27#[derive(Debug, Clone, Deserialize, Serialize)]
28#[serde(deny_unknown_fields)]
29pub struct Request {
30 pub reference_input: usize,
31 pub window_start: usize,
32 pub window_length: usize,
33 pub junction_offset: Option<usize>,
35 pub junction_min_bases: usize,
36 pub positive_inputs: Vec<usize>,
37 pub negative_inputs: Vec<usize>,
38 pub min_product_length: usize,
39 pub max_product_length: usize,
40 pub screen_max_product_length: usize,
41 pub max_mismatches: usize,
42 pub exact_three_prime_bases: usize,
43 pub max_results: usize,
44 pub constraints: PrimerConstraints,
45}
46#[derive(Debug, Clone, Serialize)]
47pub struct Oligo {
48 pub sequence_5to3: String,
49 pub reference_start: usize,
50 pub length: usize,
51 pub reverse: bool,
52 pub tm_c: f64,
53 pub gc_fraction: f64,
54 pub hairpin_stem: usize,
55 pub self_dimer: DimerScreen,
56}
57#[derive(Debug, Clone, Serialize)]
58pub struct Site {
59 pub primer: usize,
60 pub start: usize,
61 pub length: usize,
62 pub reverse: bool,
64 pub mismatches: usize,
65}
66#[derive(Debug, Clone, Serialize)]
67pub struct Product {
68 pub start: usize,
69 pub length: usize,
70 pub plus_primer: usize,
71 pub minus_primer: usize,
72 pub mismatches: usize,
73 pub template_sequence_forward: String,
75}
76#[derive(Debug, Clone, Serialize)]
77pub struct Screen {
78 pub input: usize,
79 pub sites: Vec<Site>,
80 pub products: Vec<Product>,
81}
82#[derive(Debug, Serialize)]
83pub struct Pair {
84 pub forward: Oligo,
85 pub reverse: Oligo,
86 pub heterodimer: DimerScreen,
87 pub score: f64,
88 pub screens: Vec<Screen>,
89}
90#[derive(Debug, Serialize)]
91pub struct Design {
92 pub request: Request,
93 pub pairs: Vec<Pair>,
94 pub candidate_pairs_examined: usize,
95 pub feasible_pairs: usize,
96 pub model: &'static str,
97 pub limitations: Vec<&'static str>,
98}
99
100impl Request {
101 pub fn validate(&self, records: &[SequenceRecord]) -> Result<(), DesignError> {
102 let invalid =
103 || DesignError::Invalid("invalid bounds, solution, roles, reference or junction");
104 self.constraints.validate().map_err(|_| invalid())?;
105 if records.is_empty()
106 || records.len() > 8
107 || !(1..=records.len()).contains(&self.reference_input)
108 || self.window_length > 500
109 || self.window_length < 36
110 || self.min_product_length < 36
111 || self.max_product_length < self.min_product_length
112 || self.max_product_length > self.window_length
113 || self.screen_max_product_length < self.max_product_length
114 || self.screen_max_product_length > 5000
115 || self.max_mismatches > 2
116 || !(4..=self.constraints.min_length).contains(&self.exact_three_prime_bases)
117 || !(1..=20).contains(&self.max_results)
118 || !(1..=10).contains(&self.junction_min_bases)
119 || self.junction_offset.is_some_and(|j| {
120 j < self.junction_min_bases
121 || j > self.window_length.saturating_sub(self.junction_min_bases)
122 })
123 {
124 return Err(invalid());
125 }
126 let mut roles = self.positive_inputs.clone();
127 roles.extend(&self.negative_inputs);
128 roles.sort_unstable();
129 if roles != (1..=records.len()).collect::<Vec<_>>()
130 || !self.positive_inputs.contains(&self.reference_input)
131 {
132 return Err(invalid());
133 }
134 let reference = &records[self.reference_input - 1];
135 if self.window_start >= reference.sequence().len()
136 || self.window_length > reference.sequence().len()
137 || (reference.topology() == Topology::Linear
138 && self.window_start + self.window_length > reference.sequence().len())
139 {
140 return Err(invalid());
141 }
142 if records.iter().any(|r| {
143 r.sequence().len() > 100_000
144 || !r.sequence().as_str().bytes().all(|b| b"ACGT".contains(&b))
145 }) {
146 return Err(DesignError::Invalid(
147 "templates must be unambiguous ACGT, at most 100,000 bases each",
148 ));
149 }
150 Ok(())
151 }
152}
153fn interval(record: &SequenceRecord, start: usize, length: usize) -> String {
154 let seq = record.sequence().as_str().as_bytes();
155 (0..length)
156 .map(|i| char::from(seq[(start + i) % seq.len()]))
157 .collect()
158}
159fn gc(sequence: &str) -> f64 {
160 let n = u32::try_from(sequence.len()).expect("bounded primer");
161 let count = u32::try_from(
162 sequence
163 .bytes()
164 .filter(|b| matches!(b, b'G' | b'C'))
165 .count(),
166 )
167 .expect("bounded primer");
168 f64::from(count) / f64::from(n)
169}
170fn dimer_ok(screen: DimerScreen, c: &PrimerConstraints) -> bool {
171 screen.longest_run <= c.max_dimer_run && screen.longest_three_prime_run <= c.max_three_prime_run
172}
173struct Candidate {
174 oligo: Oligo,
175 offset: usize,
176 sites: Vec<Vec<Site>>,
177}
178
179fn sites(
182 record: &SequenceRecord,
183 oligo: &str,
184 request: &Request,
185 budget: &mut usize,
186) -> Result<Vec<Site>, DesignError> {
187 let n = record.sequence().len();
188 if oligo.len() > n {
189 return Ok(Vec::new());
190 }
191 let stops = if record.topology() == Topology::Circular {
192 n
193 } else {
194 n - oligo.len() + 1
195 };
196 let rc = reverse_complement(oligo);
197 let mut result = Vec::new();
198 for (reverse, motif) in [(false, oligo), (true, rc.as_str())] {
199 for start in 0..stops {
200 let mut mismatches = 0;
201 let mut valid = true;
202 for (i, base) in motif.bytes().enumerate() {
203 *budget += 1;
204 if *budget > 200_000_000 {
205 return Err(DesignError::Budget);
206 }
207 if base != record.sequence().as_str().as_bytes()[(start + i) % n] {
208 mismatches += 1;
209 let anchored = if reverse {
210 i < request.exact_three_prime_bases
211 } else {
212 i >= oligo.len() - request.exact_three_prime_bases
213 };
214 if anchored || mismatches > request.max_mismatches {
215 valid = false;
216 break;
217 }
218 }
219 }
220 if valid {
221 result.push(Site {
222 primer: 0,
223 start,
224 length: oligo.len(),
225 reverse,
226 mismatches,
227 });
228 }
229 if result.len() > 200 {
230 return Err(DesignError::Budget);
231 }
232 }
233 }
234 Ok(result)
235}
236fn candidates(
237 record: &SequenceRecord,
238 records: &[SequenceRecord],
239 r: &Request,
240 reverse: bool,
241 budget: &mut usize,
242) -> Result<Vec<Candidate>, DesignError> {
243 let mut result = Vec::new();
244 let c = &r.constraints;
245 for offset in 0..r.window_length {
246 for length in c.min_length..=c.max_length {
247 if offset + length > r.window_length {
248 continue;
249 }
250 let start = (r.window_start + offset) % record.sequence().len();
251 let mut sequence = interval(record, start, length);
252 if reverse {
253 sequence = reverse_complement(&sequence);
254 }
255 let fraction = gc(&sequence);
256 if !(c.min_gc_fraction..=c.max_gc_fraction).contains(&fraction) {
257 continue;
258 }
259 let Ok(tm) = annealing_tm(&sequence, &c.solution) else {
260 continue;
261 };
262 if !(c.min_tm_c..=c.max_tm_c).contains(&tm) {
263 continue;
264 }
265 let hairpin = hairpin_stem(&sequence);
266 let self_dimer = dimer_screen(&sequence, &sequence);
267 if hairpin > c.max_hairpin_stem || !dimer_ok(self_dimer, c) {
268 continue;
269 }
270 let found = records
271 .iter()
272 .map(|template| sites(template, &sequence, r, budget))
273 .collect::<Result<Vec<_>, _>>()?;
274 if r.positive_inputs
276 .iter()
277 .any(|i| !found[i - 1].iter().any(|s| s.mismatches == 0))
278 {
279 continue;
280 }
281 result.push(Candidate {
282 offset,
283 oligo: Oligo {
284 sequence_5to3: sequence,
285 reference_start: start,
286 length,
287 reverse,
288 tm_c: tm,
289 gc_fraction: fraction,
290 hairpin_stem: hairpin,
291 self_dimer,
292 },
293 sites: found,
294 });
295 }
296 }
297 Ok(result)
298}
299fn products(
300 record: &SequenceRecord,
301 sites: &[Site],
302 max_length: usize,
303 budget: &mut usize,
304) -> Result<Vec<Product>, DesignError> {
305 let n = record.sequence().len();
306 let mut result = Vec::new();
307 for plus in sites.iter().filter(|s| !s.reverse) {
308 for minus in sites.iter().filter(|s| s.reverse) {
309 *budget += 1;
310 if *budget > 20_000_000 {
311 return Err(DesignError::Budget);
312 }
313 let distance = if record.topology() == Topology::Circular {
314 (minus.start + n - plus.start) % n
315 } else if minus.start >= plus.start {
316 minus.start - plus.start
317 } else {
318 continue;
319 };
320 let length = distance + minus.length;
321 if distance < plus.length || length > n || length > max_length {
323 continue;
324 }
325 result.push(Product {
326 start: plus.start,
327 length,
328 plus_primer: plus.primer,
329 minus_primer: minus.primer,
330 mismatches: plus.mismatches + minus.mismatches,
331 template_sequence_forward: interval(record, plus.start, length),
332 });
333 }
334 }
335 Ok(result)
336}
337
338fn screen_pair(
339 records: &[SequenceRecord],
340 f: &Candidate,
341 r: &Candidate,
342 request: &Request,
343 budget: &mut usize,
344) -> Result<Option<Vec<Screen>>, DesignError> {
345 let mut screens = Vec::new();
346 for (index, record) in records.iter().enumerate() {
347 let mut binding = f.sites[index].clone();
348 for s in &mut binding {
349 s.primer = 1;
350 }
351 binding.extend(r.sites[index].iter().cloned().map(|mut s| {
352 s.primer = 2;
353 s
354 }));
355 let products = products(record, &binding, request.screen_max_product_length, budget)?;
356 let accepted = if request.positive_inputs.contains(&(index + 1)) {
357 products.len() == 1
358 && products[0].mismatches == 0
359 && products[0].plus_primer != products[0].minus_primer
360 && (request.min_product_length..=request.max_product_length)
361 .contains(&products[0].length)
362 } else {
363 products.is_empty()
364 };
365 if !accepted {
366 return Ok(None);
367 }
368 screens.push(Screen {
369 input: index + 1,
370 sites: binding,
371 products,
372 });
373 }
374 Ok(Some(screens))
375}
376
377pub fn design(records: &[SequenceRecord], request: &Request) -> Result<Design, DesignError> {
378 request.validate(records)?;
379 let reference = &records[request.reference_input - 1];
380 let mut budget = 0;
381 let forward = candidates(reference, records, request, false, &mut budget)?;
382 let reverse = candidates(reference, records, request, true, &mut budget)?;
383 if forward.len().saturating_mul(reverse.len()) > 1_000_000 {
384 return Err(DesignError::Budget);
385 }
386 let mut pairs = Vec::new();
387 let mut examined = 0;
388 let mut feasible = 0;
389 let mut product_budget = 0;
390 for f in &forward {
391 for r in &reverse {
392 examined += 1;
393 if r.offset < f.offset + f.oligo.length {
394 continue;
395 }
396 let length = r.offset + r.oligo.length - f.offset;
397 if !(request.min_product_length..=request.max_product_length).contains(&length) {
398 continue;
399 }
400 if let Some(j) = request.junction_offset {
401 let spans = |p: &Candidate| {
402 p.offset + request.junction_min_bases <= j
403 && j + request.junction_min_bases <= p.offset + p.oligo.length
404 };
405 if !spans(f) && !spans(r) {
406 continue;
407 }
408 }
409 if (f.oligo.tm_c - r.oligo.tm_c).abs() > request.constraints.max_pair_tm_difference_c {
410 continue;
411 }
412 let heterodimer = dimer_screen(&f.oligo.sequence_5to3, &r.oligo.sequence_5to3);
413 if !dimer_ok(heterodimer, &request.constraints) {
414 continue;
415 }
416 let Some(screens) = screen_pair(records, f, r, request, &mut product_budget)? else {
417 continue;
418 };
419 feasible += 1;
420 pairs.push(Pair {
421 forward: f.oligo.clone(),
422 reverse: r.oligo.clone(),
423 heterodimer,
424 score: (f.oligo.tm_c - request.constraints.target_tm_c).abs()
425 + (r.oligo.tm_c - request.constraints.target_tm_c).abs(),
426 screens,
427 });
428 pairs.sort_by(|a, b| {
429 a.score
430 .total_cmp(&b.score)
431 .then_with(|| {
432 (a.forward.length + a.reverse.length)
433 .cmp(&(b.forward.length + b.reverse.length))
434 })
435 .then_with(|| a.forward.reference_start.cmp(&b.forward.reference_start))
436 .then_with(|| a.reverse.reference_start.cmp(&b.reverse.reference_start))
437 .then_with(|| a.forward.length.cmp(&b.forward.length))
438 });
439 pairs.truncate(request.max_results);
440 }
441 }
442 if pairs.is_empty() {
443 return Err(DesignError::NoPairs);
444 }
445 Ok(Design {
446 request: request.clone(),
447 pairs,
448 candidate_pairs_examined: examined,
449 feasible_pairs: feasible,
450 model: "dnagent-nn-v1: Allawi/SantaLucia 1997; SantaLucia 1998 entropy salt correction; von Ahsen 2001 sodium-equivalent Mg/dNTP; primer in excess",
451 limitations: vec![
452 "Not Primer3: deterministic bounded search using DNAgent's existing NN model and contiguous-complement structure screens, not folding energies or experimental validation",
453 "Specificity covers only supplied templates, full-oligo Hamming mismatches with an exact 3-prime anchor; no indels, seed-only matches, BLAST or genome/transcriptome search",
454 "Predicted products require nonoverlapping inward-facing sites, at most one template traversal and length <= screen_max_product_length; no products is conditional on these bounds",
455 "Positive templates require exactly one exact heteroprimer product in the requested size range; any additional screened product rejects a pair. Negative templates require zero screened products",
456 "All candidate oligos originate within the declared reference window; shared-block discovery outside it is not performed",
457 "Junction constraint requires a primer to straddle the explicit boundary; this alone does not establish transcript or construct specificity",
458 "No annealing temperature, assay validity, primer ordering readiness, multiplex suitability or DNA/RNA origin is inferred",
459 ],
460 })
461}