1use crate::compatibility::{CompatibilityError, EndCompatibility, EndSide, compare_ends};
3use crate::digest::{DigestFragment, FragmentEnd, reverse_complement};
4use crate::fragment_annotations::{AnnotatedDigest, AnnotationError, annotated_digest};
5use crate::restriction::OverhangPolarity;
6use crate::{SequenceRecord, Strand, Topology};
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use thiserror::Error;
10
11pub const MAX_INPUTS: usize = 16;
12pub const MAX_COMPONENTS: usize = 128;
13pub const MAX_STRAND_BASES: usize = 10_000_000;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Orientation {
18 Forward,
19 Reverse,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct FragmentSelection {
25 pub input: usize,
27 pub fragment_id: String,
28 pub orientation: Orientation,
29}
30
31#[derive(Debug, Error)]
32pub enum LigationError {
33 #[error("invalid ligation plan: {0}")]
34 InvalidPlan(&'static str),
35 #[error(transparent)]
36 Annotation(#[from] AnnotationError),
37 #[error(transparent)]
38 Compatibility(#[from] CompatibilityError),
39 #[error(
40 "incompatible junction after component {after} and before component {before}: {reason:?}"
41 )]
42 Incompatible {
43 after: usize,
44 before: usize,
45 reason: crate::compatibility::CompatibilityReason,
46 },
47 #[error("ligation duplex conservation check failed: {0}")]
48 Conservation(&'static str),
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct ProductSpan {
53 pub start: usize,
55 pub length: usize,
56 pub source_strand: Strand,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct ComponentPlacement {
62 pub selection: FragmentSelection,
63 pub top: ProductSpan,
64 pub bottom: ProductSpan,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct Junction {
69 pub after_component: usize,
70 pub before_component: usize,
71 pub closure: bool,
72 pub top_boundary: usize,
74 pub bottom_boundary: usize,
75 pub assessment: EndCompatibility,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct UnusedFragment {
80 pub input: usize,
81 pub fragment_id: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct LigationProduct {
86 pub topology: Topology,
87 pub top_sequence_5to3: String,
88 pub bottom_sequence_5to3: String,
89 pub bottom_forward_start: i128,
92 pub paired_length: usize,
93 pub left_end: Option<FragmentEnd>,
94 pub right_end: Option<FragmentEnd>,
95 pub components: Vec<ComponentPlacement>,
96 pub junctions: Vec<Junction>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100pub struct LigationReport {
101 pub inputs: Vec<AnnotatedDigest>,
103 pub product: LigationProduct,
104 pub unused_fragments: Vec<UnusedFragment>,
105 pub assumptions: Vec<&'static str>,
106}
107
108pub fn validate_request(
110 input_count: usize,
111 selected: &[FragmentSelection],
112) -> Result<(), LigationError> {
113 if !(1..=MAX_INPUTS).contains(&input_count) {
114 return Err(LigationError::InvalidPlan(
115 "expected 1..=16 input digest instances",
116 ));
117 }
118 if selected.is_empty() || selected.len() > MAX_COMPONENTS {
119 return Err(LigationError::InvalidPlan(
120 "expected 1..=128 selected components",
121 ));
122 }
123 let mut used = HashSet::new();
124 for selection in selected {
125 if selection.input == 0 || selection.input > input_count || selection.fragment_id.is_empty()
126 {
127 return Err(LigationError::InvalidPlan(
128 "invalid input number or empty fragment ID",
129 ));
130 }
131 if !used.insert((selection.input, &selection.fragment_id)) {
132 return Err(LigationError::InvalidPlan(
133 "fragment reused; declare a separate input digest instance for another copy",
134 ));
135 }
136 }
137 Ok(())
138}
139
140pub fn simulate_ligation(
141 inputs: &[(&SequenceRecord, &[String])],
142 selected: &[FragmentSelection],
143 topology: Topology,
144) -> Result<LigationReport, LigationError> {
145 validate_request(inputs.len(), selected)?;
146 let inputs = inputs
148 .iter()
149 .map(|(r, names)| annotated_digest(r, names))
150 .collect::<Result<Vec<_>, _>>()?;
151 let components = selected
152 .iter()
153 .map(|selection| {
154 let fragment = inputs[selection.input - 1]
155 .digest
156 .fragments
157 .iter()
158 .find(|f| f.id == selection.fragment_id)
159 .ok_or(LigationError::InvalidPlan(
160 "selected fragment does not exist",
161 ))?;
162 orient(fragment, selection.orientation)
163 })
164 .collect::<Result<Vec<_>, _>>()?;
165 let product = assemble(&components, selected, topology)?;
166 let unused_fragments = inputs
167 .iter()
168 .enumerate()
169 .flat_map(|(index, input)| {
170 input
171 .digest
172 .fragments
173 .iter()
174 .filter(move |f| {
175 !selected
176 .iter()
177 .any(|s| s.input == index + 1 && s.fragment_id == f.id)
178 })
179 .map(move |f| UnusedFragment {
180 input: index + 1,
181 fragment_id: f.id.clone(),
182 })
183 })
184 .collect();
185 Ok(LigationReport {
186 inputs,
187 product,
188 unused_fragments,
189 assumptions: vec![
190 "Explicit digest instances and component order; no implicit fragment copies or endpoint reuse",
191 "Assumes selected sequence-compatible junctions seal completely; phosphorylation, nicks, reaction conditions and experimental yield are not predicted",
192 "Reverse orientation exchanges whole top/bottom 5-prime-to-3-prime sequences and ends; exposed physical oligos are not reverse-complemented",
193 "Both selected strands are conserved without trimming, fill-in or overlap deduplication; unused fragments are reported separately",
194 "Component-local source annotations remain in inputs and map through product spans; gene repair, fusion, joined feature reconstruction and functional integrity are not inferred",
195 "For circular products bottom_forward_start records the phase between strand coordinate origins; bottom sequence is not silently rotated",
196 "Linear single-component plans are pass-through products with no ligation junctions",
197 ],
198 })
199}
200
201struct Oriented {
202 top: String,
203 bottom: String,
204 left: FragmentEnd,
205 right: FragmentEnd,
206}
207
208fn flip(strand: Strand) -> Strand {
209 match strand {
210 Strand::Forward => Strand::Reverse,
211 Strand::Reverse => Strand::Forward,
212 Strand::Unknown => Strand::Unknown,
213 }
214}
215
216fn orient(fragment: &DigestFragment, orientation: Orientation) -> Result<Oriented, LigationError> {
217 let (Some(left), Some(right)) = (&fragment.left_end, &fragment.right_end) else {
218 return Err(LigationError::InvalidPlan(
219 "uncut circular fragments have no free ends",
220 ));
221 };
222 if orientation == Orientation::Forward {
223 return Ok(Oriented {
224 top: fragment.top.sequence_5to3.clone(),
225 bottom: fragment.bottom.sequence_5to3.clone(),
226 left: left.clone(),
227 right: right.clone(),
228 });
229 }
230 let mut left = right.clone();
231 let mut right = fragment
232 .left_end
233 .clone()
234 .ok_or(LigationError::InvalidPlan("missing end"))?;
235 left.protruding_strand = left.protruding_strand.map(flip);
236 right.protruding_strand = right.protruding_strand.map(flip);
237 Ok(Oriented {
238 top: fragment.bottom.sequence_5to3.clone(),
239 bottom: fragment.top.sequence_5to3.clone(),
240 left,
241 right,
242 })
243}
244
245fn total_length(components: &[Oriented], bottom: bool) -> Result<usize, LigationError> {
246 components.iter().try_fold(0usize, |total, component| {
247 total
248 .checked_add(if bottom {
249 component.bottom.len()
250 } else {
251 component.top.len()
252 })
253 .filter(|n| *n <= MAX_STRAND_BASES)
254 .ok_or(LigationError::InvalidPlan(
255 "product exceeds 10,000,000 bases per strand",
256 ))
257 })
258}
259
260fn assemble(
261 oriented: &[Oriented],
262 selected: &[FragmentSelection],
263 topology: Topology,
264) -> Result<LigationProduct, LigationError> {
265 let nt = total_length(oriented, false)?;
266 let nb = total_length(oriented, true)?;
267 let mut components = Vec::new();
268 let mut junctions = Vec::new();
269 let (mut top, mut bottom) = (0, 0);
270 for (index, (component, selection)) in oriented.iter().zip(selected).enumerate() {
271 let source_strand = if selection.orientation == Orientation::Forward {
272 Strand::Forward
273 } else {
274 Strand::Reverse
275 };
276 components.push(ComponentPlacement {
277 selection: selection.clone(),
278 top: ProductSpan {
279 start: top,
280 length: component.top.len(),
281 source_strand,
282 },
283 bottom: ProductSpan {
284 start: nb - bottom - component.bottom.len(),
285 length: component.bottom.len(),
286 source_strand: flip(source_strand),
287 },
288 });
289 top += component.top.len();
290 bottom += component.bottom.len();
291 let closure = index + 1 == oriented.len();
292 if !closure || topology == Topology::Circular {
293 let next = (index + 1) % oriented.len();
294 let assessment = compare_ends(
295 &component.right,
296 EndSide::Right,
297 &oriented[next].left,
298 EndSide::Left,
299 )?;
300 if !assessment.compatible {
301 return Err(LigationError::Incompatible {
302 after: index + 1,
303 before: next + 1,
304 reason: assessment.reason,
305 });
306 }
307 junctions.push(Junction {
308 after_component: index + 1,
309 before_component: next + 1,
310 closure,
311 top_boundary: if closure { 0 } else { top },
312 bottom_boundary: nb - bottom,
313 assessment,
314 });
315 }
316 }
317 let first = &oriented[0];
318 let last = &oriented[oriented.len() - 1];
319 let top_sequence_5to3: String = oriented.iter().map(|c| c.top.as_str()).collect();
320 let bottom_sequence_5to3: String = oriented.iter().rev().map(|c| c.bottom.as_str()).collect();
321 if top_sequence_5to3.len() != nt || bottom_sequence_5to3.len() != nb {
322 return Err(LigationError::Conservation(
323 "selected strand lengths changed",
324 ));
325 }
326 let delta = stagger(&first.left);
327 let (bottom_forward_start, paired_length) = verify_duplex(
328 &top_sequence_5to3,
329 &bottom_sequence_5to3,
330 delta,
331 stagger(&last.right),
332 topology,
333 )?;
334 Ok(LigationProduct {
335 topology,
336 top_sequence_5to3,
337 bottom_sequence_5to3,
338 bottom_forward_start,
339 paired_length,
340 left_end: (topology == Topology::Linear).then(|| first.left.clone()),
341 right_end: (topology == Topology::Linear).then(|| last.right.clone()),
342 components,
343 junctions,
344 })
345}
346
347fn stagger(end: &FragmentEnd) -> i128 {
348 let n = end.overhang_sequence.len() as i128;
349 match end.polarity {
350 OverhangPolarity::Blunt => 0,
351 OverhangPolarity::FivePrime => n,
352 OverhangPolarity::ThreePrime => -n,
353 }
354}
355
356fn verify_duplex(
357 top: &str,
358 bottom: &str,
359 delta: i128,
360 right_delta: i128,
361 topology: Topology,
362) -> Result<(i128, usize), LigationError> {
363 let bottom_forward = reverse_complement(bottom);
364 if topology == Topology::Circular {
365 if top.len() != bottom.len() {
366 return Err(LigationError::Conservation(
367 "unequal circular strand lengths",
368 ));
369 }
370 let offset = delta.rem_euclid(top.len() as i128) as usize;
371 if bottom_forward != format!("{}{}", &top[offset..], &top[..offset]) {
372 return Err(LigationError::Conservation(
373 "circular strand phase mismatch",
374 ));
375 }
376 return Ok((offset as i128, top.len()));
377 }
378 let end = delta + bottom.len() as i128;
379 if end - top.len() as i128 != right_delta {
380 return Err(LigationError::Conservation("terminal stagger mismatch"));
381 }
382 let lo = delta.max(0);
383 let hi = end.min(top.len() as i128);
384 if lo >= hi
385 || top[lo as usize..hi as usize]
386 != bottom_forward[(lo - delta) as usize..(hi - delta) as usize]
387 {
388 return Err(LigationError::Conservation("linear paired-core mismatch"));
389 }
390 Ok((delta, (hi - lo) as usize))
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::DnaSeq;
397 fn record(seq: &str, topology: Topology) -> SequenceRecord {
398 SequenceRecord::new(
399 "synthetic",
400 DnaSeq::new(seq).unwrap(),
401 topology,
402 vec![],
403 vec![],
404 )
405 .unwrap()
406 }
407 fn selection(input: usize, number: usize, orientation: Orientation) -> FragmentSelection {
408 FragmentSelection {
409 input,
410 fragment_id: format!("fragment-{number:04}"),
411 orientation,
412 }
413 }
414 #[test]
415 fn rejoining_linear_digest_conserves_both_strands() {
416 for (seq, enzyme) in [
417 ("AAAGAATTCTTT", "EcoRI"),
418 ("AAAGGTACCTTT", "KpnI"),
419 ("AAAGATATCTTT", "EcoRV"),
420 ] {
421 let r = record(seq, Topology::Linear);
422 let names = vec![enzyme.into()];
423 let selected = [
424 selection(1, 1, Orientation::Forward),
425 selection(1, 2, Orientation::Forward),
426 ];
427 let report = simulate_ligation(&[(&r, &names)], &selected, Topology::Linear).unwrap();
428 assert_eq!(report.product.top_sequence_5to3, seq);
429 assert_eq!(report.product.bottom_sequence_5to3, reverse_complement(seq));
430 assert_eq!(report.product.junctions.len(), 1);
431 assert_eq!(report.product.paired_length, seq.len());
432 assert_eq!(report.unused_fragments, []);
433 }
434 }
435 #[test]
436 fn circle_closure_keeps_explicit_bottom_phase() {
437 for (seq, enzyme, phase) in [
438 ("AAAGAATTCTTT", "EcoRI", 4),
439 ("AAAGGTACCTTT", "KpnI", 8),
440 ("AAAGATATCTTT", "EcoRV", 0),
441 ] {
442 let r = record(seq, Topology::Circular);
443 let names = vec![enzyme.into()];
444 for orientation in [Orientation::Forward, Orientation::Reverse] {
445 let report = simulate_ligation(
446 &[(&r, &names)],
447 &[selection(1, 1, orientation)],
448 Topology::Circular,
449 )
450 .unwrap();
451 assert_eq!(report.product.bottom_forward_start, phase);
452 assert!(report.product.left_end.is_none());
453 assert!(report.product.junctions[0].closure);
454 assert_eq!(report.product.paired_length, 12);
455 }
456 }
457 }
458 #[test]
459 fn reverse_order_and_orientation_reverse_the_whole_duplex() {
460 let r = record("AAAGAATTCTGC", Topology::Linear);
461 let names = vec!["EcoRI".into()];
462 let report = simulate_ligation(
463 &[(&r, &names)],
464 &[
465 selection(1, 2, Orientation::Reverse),
466 selection(1, 1, Orientation::Reverse),
467 ],
468 Topology::Linear,
469 )
470 .unwrap();
471 assert_eq!(report.product.top_sequence_5to3, "GCAGAATTCTTT");
472 assert_eq!(report.product.bottom_sequence_5to3, r.sequence().as_str());
473 assert_eq!(
474 report.product.components[0].top.source_strand,
475 Strand::Reverse
476 );
477 }
478 #[test]
479 fn pass_through_preserves_sticky_termini_and_paired_core() {
480 for (seq, enzyme) in [("AAAGAATTCTGC", "EcoRI"), ("AAAGGTACCTGC", "KpnI")] {
481 let r = record(seq, Topology::Linear);
482 let names = vec![enzyme.into()];
483 for number in [1, 2] {
484 for orientation in [Orientation::Forward, Orientation::Reverse] {
485 let report = simulate_ligation(
486 &[(&r, &names)],
487 &[selection(1, number, orientation)],
488 Topology::Linear,
489 )
490 .unwrap();
491 let source = &report.inputs[0].digest.fragments[number - 1];
492 assert_eq!(report.product.paired_length, source.paired_length);
493 assert_eq!(report.product.junctions, []);
494 assert_eq!(report.unused_fragments.len(), 1);
495 }
496 }
497 }
498 }
499
500 #[test]
501 fn resource_limits_are_checked_at_the_boundary() {
502 let s = selection(1, 1, Orientation::Forward);
503 assert!(validate_request(1, &vec![s; MAX_COMPONENTS + 1]).is_err());
504 let base = record("AAAA", Topology::Linear);
506 let names = vec!["EcoRI".into()];
507 let fragment = annotated_digest(&base, &names)
508 .unwrap()
509 .digest
510 .fragments
511 .remove(0);
512 let mut component = orient(&fragment, Orientation::Forward).unwrap();
513 component.top = "A".repeat(MAX_STRAND_BASES);
514 assert_eq!(
515 total_length(std::slice::from_ref(&component), false).unwrap(),
516 MAX_STRAND_BASES
517 );
518 component.top.push('A');
519 assert!(total_length(&[component], false).is_err());
520 }
521
522 #[test]
523 fn refuses_reuse_missing_fragments_and_uncut_circles() {
524 let r = record("AAAGAATTCTTT", Topology::Circular);
525 let names = vec!["BamHI".into()];
526 let s = selection(1, 1, Orientation::Forward);
527 assert!(
528 simulate_ligation(
529 &[(&r, &names)],
530 std::slice::from_ref(&s),
531 Topology::Circular
532 )
533 .is_err()
534 );
535 assert!(validate_request(1, &[s.clone(), s]).is_err());
536 assert!(
537 simulate_ligation(
538 &[(&r, &names)],
539 &[selection(1, 99, Orientation::Forward)],
540 Topology::Linear
541 )
542 .is_err()
543 );
544 assert!(validate_request(17, &[selection(1, 1, Orientation::Forward)]).is_err());
545 }
546 #[test]
547 fn incompatible_junctions_fail_and_distinct_instances_allow_explicit_copies() {
548 let a = record("AAAGAATTCTTT", Topology::Linear);
549 let b = record("AAAGGTACCTTT", Topology::Linear);
550 let ea = vec!["EcoRI".into()];
551 let eb = vec!["KpnI".into()];
552 let selected = [
553 selection(1, 1, Orientation::Forward),
554 selection(2, 2, Orientation::Forward),
555 ];
556 assert!(matches!(
557 simulate_ligation(&[(&a, &ea), (&b, &eb)], &selected, Topology::Linear),
558 Err(LigationError::Incompatible { .. })
559 ));
560 let report =
561 simulate_ligation(&[(&a, &ea), (&a, &ea)], &selected, Topology::Linear).unwrap();
562 assert_eq!(report.unused_fragments.len(), 2);
563 assert_eq!(report.product.top_sequence_5to3, a.sequence().as_str());
564 }
565}