Skip to main content

dnagent_domain/
amplification.rs

1//! Bounded offline amplification design. Reference coordinates are zero-based;
2//! input identities are one-based. No filesystem, external tools or inferred assays.
3#[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    /// Boundary relative to the unrolled window. A primer must straddle it.
34    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    /// Reverse means extension towards decreasing forward-reference coordinates.
63    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    /// Template interval, not an inferred mismatch-corrected PCR product.
74    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
179/// Exhaustive full-oligo Hamming search, with an exact 3′ anchor. Not a seed-only
180/// alignment search: bulges, indels and mismatches within the anchor are excluded.
181fn 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            // Necessary condition for an exact product on every positive template.
275            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            // Nonoverlapping sites, one traversal at most. Includes self-primer products.
322            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}