#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Topology {
Circular,
Linear,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
SkewAndBoxes,
SkewOnly,
DnaaAnchored,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
Pass,
Review,
Weak,
}
impl Confidence {
pub fn as_str(self) -> &'static str {
match self {
Confidence::Pass => "pass",
Confidence::Review => "review",
Confidence::Weak => "weak",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flag {
Multimodal,
WeakSkew,
NoBoxes,
DnaaDisagree,
DegenerateBox,
Displaced,
NoHint,
}
impl Flag {
pub fn code(self) -> &'static str {
match self {
Flag::Multimodal => "multimodal",
Flag::WeakSkew => "weak-skew",
Flag::NoBoxes => "no-boxes",
Flag::DnaaDisagree => "dnaa-disagree",
Flag::DegenerateBox => "degenerate-box",
Flag::Displaced => "displaced",
Flag::NoHint => "no-hint",
}
}
pub fn is_warning(self) -> bool {
!matches!(self, Flag::Displaced | Flag::NoHint)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Options {
pub min_len: usize,
pub topology: Topology,
pub min_skew_sigma: f64,
pub min_antipodal: f64,
pub search_half_win: i64,
pub cluster_gap: i64,
pub box_max_mismatch: u32,
pub min_cluster_boxes: usize,
pub region_half_win: i64,
}
impl Default for Options {
fn default() -> Self {
Options {
min_len: 50_000,
topology: Topology::Circular,
min_skew_sigma: 6.0,
min_antipodal: 0.55,
search_half_win: 50_000,
cluster_gap: 1_000,
box_max_mismatch: 1,
min_cluster_boxes: 3,
region_half_win: 250,
}
}
}
const DNAA_BOX: &[u8; 9] = b"TTATCCACA";
const DNAA_BOX_RC: &[u8; 9] = b"TGTGGATAA";
const DNAA_BOX_HALF_WIN: i64 = 5_000;
const DNAA_BOX_MAX_DIST: i64 = 3_000;
const DNAA_ORIC_FAR: i64 = 20_000;
const DNAA_ORIC_MODERATE: i64 = 7_000;
const DUE_MIN_EXCESS: f64 = 0.03;
const DUE_MAX_GENOME_AT: f64 = 0.62;
const CONF_PASS: f32 = 0.60;
const CONF_WEAK: f32 = 0.30;
fn due_excess(seq: &[u8], center: i64, genome_at: f64, n: i64) -> f64 {
const WIN: i64 = 350;
let mut best = 0.0f64;
let mut w0 = center - 600;
while w0 <= center + 600 {
let mut at = 0usize;
for k in 0..WIN {
match seq[(w0 + k).rem_euclid(n) as usize].to_ascii_uppercase() {
b'A' | b'T' => at += 1,
_ => {}
}
}
best = best.max(at as f64 / WIN as f64);
w0 += 100;
}
best - genome_at
}
const DISPLACED_ORIC_MARGIN: usize = 4;
const DISPLACED_SKEW_SIGMA: f64 = 10.0;
const DISPLACED_SKEW_ANTIPODAL: f64 = 0.80;
const MULTIMODAL_SEP_FRAC: f64 = 0.1;
const MULTIMODAL_DEPTH_FRAC: f64 = 0.85;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneHint {
pub start: usize,
pub end: usize,
pub name: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OriC {
pub start: usize,
pub end: usize,
pub score: f32,
pub skew_min: usize,
pub dnaa_boxes: usize,
pub method: Method,
pub conf: Confidence,
pub notes: Vec<Flag>,
}
#[derive(Debug, Clone, Copy)]
struct SkewProfile {
min_pos: usize,
max_pos: usize,
range: i64,
n_valid: usize,
gc_count: usize,
multimodal: bool,
}
pub fn detect(seq: &[u8], genes: &[GeneHint]) -> Vec<OriC> {
detect_with(seq, genes, &Options::default())
}
pub fn detect_with(seq: &[u8], genes: &[GeneHint], opt: &Options) -> Vec<OriC> {
match detect_one(seq, genes, opt) {
Some(o) => vec![o],
None => Vec::new(),
}
}
fn detect_one(seq: &[u8], genes: &[GeneHint], opt: &Options) -> Option<OriC> {
let n = seq.len();
if n < opt.min_len {
return None;
}
let prof = skew_profile(seq);
if prof.n_valid < opt.min_len {
return None;
}
let dnaa_span = dnaa_gene(genes);
let dnaa_center = dnaa_span.map(|(s, e)| (s + e) / 2);
let sigma = prof.range as f64 / (prof.n_valid as f64).sqrt();
let strong_amplitude = sigma >= opt.min_skew_sigma;
let antipodal = match opt.topology {
Topology::Linear => 1.0,
Topology::Circular => {
let d = circular_dist(prof.min_pos as i64, prof.max_pos as i64, n as i64);
d as f64 / (n as f64 / 2.0)
}
};
let skew_usable = strong_amplitude && antipodal >= opt.min_antipodal;
let high_gc =
prof.n_valid > 0 && (prof.gc_count as f64 / prof.n_valid as f64) >= BOX_FALLBACK_MIN_GC;
let dnaa_hit = dnaa_center.and_then(|c| {
let skew_at_dnaa = circular_dist(prof.min_pos as i64, c, n as i64) <= DNAA_ORIC_FAR;
let allow_loose = high_gc && skew_at_dnaa;
best_cluster_near(seq, c, DNAA_BOX_HALF_WIN, n as i64, allow_loose, opt).filter(
|&(s, e, _, _)| {
circular_dist((s as i64 + e as i64) / 2, c, n as i64) <= DNAA_BOX_MAX_DIST
},
)
});
let dnaa_used_loose = dnaa_hit.map(|(_, _, _, loose)| loose).unwrap_or(false);
let dnaa_cluster = dnaa_hit.map(|(s, e, c, _)| (s, e, c));
let skew_cluster = if skew_usable {
let a = prof.min_pos as i64;
let hits = find_dnaa_boxes_around(seq, a, opt.search_half_win, opt.box_max_mismatch, opt);
best_cluster(&hits, a, n as i64, opt)
} else {
None
};
let genome_at = if prof.n_valid > 0 {
1.0 - prof.gc_count as f64 / prof.n_valid as f64
} else {
0.0
};
let skew_cluster_far = skew_cluster.filter(|&(s, e, _)| {
dnaa_center.is_none_or(|c| {
let center = (s as i64 + e as i64) / 2;
let d = circular_dist(center, c, n as i64);
d > DNAA_ORIC_FAR
|| (d > DNAA_ORIC_MODERATE
&& genome_at <= DUE_MAX_GENOME_AT
&& due_excess(seq, center, genome_at, n as i64) >= DUE_MIN_EXCESS)
})
});
let skew_clean = opt.topology == Topology::Circular
&& sigma >= DISPLACED_SKEW_SIGMA
&& antipodal >= DISPLACED_SKEW_ANTIPODAL;
let far_wins = |d: (usize, usize, usize), f: (usize, usize, usize)| {
(skew_clean && f.2 >= d.2) || f.2 >= d.2 + DISPLACED_ORIC_MARGIN
};
let picked = match (dnaa_cluster, skew_cluster_far) {
(Some(d), Some(f)) if far_wins(d, f) => Some((f, Method::SkewAndBoxes)),
(Some(d), _) => Some((d, Method::DnaaAnchored)),
(None, Some(f)) => Some((f, Method::SkewAndBoxes)),
(None, None) => None,
};
let (start0, end0, boxes, method) = if let Some(((s, e, count), m)) = picked {
(s, e, count, m)
} else if let Some((gs, ge)) = dnaa_span {
(gs as usize, ge as usize, 0, Method::DnaaAnchored)
} else if skew_usable {
match skew_cluster {
Some((s, e, count)) => (s, e, count, Method::SkewAndBoxes),
None => {
let a = prof.min_pos as i64;
let (s, e) = (a - opt.region_half_win, a + opt.region_half_win);
(
wrap(s, n as i64, opt) as usize,
wrap(e, n as i64, opt) as usize,
0,
Method::SkewOnly,
)
}
}
} else {
return None;
};
let region_center = (start0 as i64 + end0 as i64) / 2;
let dnaa_dist = dnaa_center.map(|c| circular_dist(c, region_center, n as i64));
let score = confidence(sigma, antipodal, boxes, dnaa_dist, prof.multimodal, opt);
let mut notes = Vec::new();
if prof.multimodal {
notes.push(Flag::Multimodal);
}
if method != Method::DnaaAnchored
&& (sigma < opt.min_skew_sigma * 1.5
|| (opt.topology == Topology::Circular && antipodal < opt.min_antipodal + 0.10))
{
notes.push(Flag::WeakSkew);
}
if boxes == 0 {
notes.push(Flag::NoBoxes);
}
if dnaa_used_loose && method == Method::DnaaAnchored {
notes.push(Flag::DegenerateBox);
}
match (dnaa_center, dnaa_dist) {
(None, _) => notes.push(Flag::NoHint),
(Some(c), d) => {
let resolved_displaced =
method == Method::SkewAndBoxes && d.is_some_and(|d| d > DNAA_ORIC_MODERATE);
if resolved_displaced {
notes.push(Flag::Displaced);
} else if circular_dist(prof.min_pos as i64, c, n as i64) > DNAA_ORIC_FAR && !skew_clean
{
notes.push(Flag::DnaaDisagree);
}
}
}
let conf = if score >= CONF_PASS {
Confidence::Pass
} else if score < CONF_WEAK {
Confidence::Weak
} else {
Confidence::Review
};
Some(OriC {
start: start0 + 1,
end: end0 + 1,
score,
skew_min: prof.min_pos + 1,
dnaa_boxes: boxes,
method,
conf,
notes,
})
}
fn skew_profile(seq: &[u8]) -> SkewProfile {
let mut skew: i64 = 0;
let mut min_val: i64 = 0;
let mut max_val: i64 = 0;
let mut min_pos: usize = 0;
let mut max_pos: usize = 0;
let mut n_valid: usize = 0;
let mut gc_count: usize = 0;
for (i, &b) in seq.iter().enumerate() {
match b.to_ascii_uppercase() {
b'G' => {
skew += 1;
n_valid += 1;
gc_count += 1;
}
b'C' => {
skew -= 1;
n_valid += 1;
gc_count += 1;
}
b'A' | b'T' => n_valid += 1,
_ => {}
}
if skew < min_val {
min_val = skew;
min_pos = i;
}
if skew > max_val {
max_val = skew;
max_pos = i;
}
}
let range = max_val - min_val;
let n = seq.len() as i64;
let sep = (seq.len() as f64 * MULTIMODAL_SEP_FRAC) as i64;
let mut skew2: i64 = 0;
let mut rival_min: i64 = max_val;
for (i, &b) in seq.iter().enumerate() {
match b.to_ascii_uppercase() {
b'G' => skew2 += 1,
b'C' => skew2 -= 1,
_ => {}
}
if skew2 < rival_min && circular_dist(i as i64, min_pos as i64, n) > sep {
rival_min = skew2;
}
}
let multimodal =
range > 0 && (rival_min - min_val) as f64 <= (1.0 - MULTIMODAL_DEPTH_FRAC) * range as f64;
SkewProfile {
min_pos,
max_pos,
range,
n_valid,
gc_count,
multimodal,
}
}
fn circular_dist(a: i64, b: i64, n: i64) -> i64 {
let d = (a - b).abs();
d.min(n - d)
}
fn wrap(x: i64, n: i64, opt: &Options) -> i64 {
match opt.topology {
Topology::Circular => x.rem_euclid(n),
Topology::Linear => x.clamp(0, n - 1),
}
}
fn find_dnaa_boxes_around(
seq: &[u8],
center: i64,
half: i64,
mism: u32,
opt: &Options,
) -> Vec<usize> {
let n = seq.len() as i64;
let (lo, hi) = match opt.topology {
Topology::Circular => (center - half, center + half),
Topology::Linear => ((center - half).max(0), (center + half).min(n)),
};
let span = (hi - lo).min(n);
let mut window = Vec::with_capacity(span as usize + 8);
for k in 0..(span + 8) {
let idx = match opt.topology {
Topology::Circular => (lo + k).rem_euclid(n),
Topology::Linear => {
if lo + k >= n {
break;
}
lo + k
}
};
window.push(seq[idx as usize]);
}
let mut hits = Vec::new();
if window.len() < 9 {
return hits;
}
for i in 0..=window.len() - 9 {
let kmer = &window[i..i + 9];
if matches_box(kmer, DNAA_BOX, mism) || matches_box(kmer, DNAA_BOX_RC, mism) {
let abs = match opt.topology {
Topology::Circular => (lo + i as i64).rem_euclid(n),
Topology::Linear => lo + i as i64,
};
hits.push(abs as usize);
}
}
hits.sort_unstable();
hits
}
fn matches_box(kmer: &[u8], pat: &[u8; 9], max_mismatch: u32) -> bool {
let mut mism = 0u32;
for j in 0..9 {
if kmer[j].to_ascii_uppercase() != pat[j] {
mism += 1;
if mism > max_mismatch {
return false;
}
}
}
true
}
const BOX_WORTH_BP: i64 = 3_000;
const BOX_FALLBACK_MISMATCH: u32 = 2;
const BOX_FALLBACK_MIN_GC: f64 = 0.60;
fn best_cluster_near(
seq: &[u8],
center: i64,
half: i64,
n: i64,
allow_loose: bool,
opt: &Options,
) -> Option<(usize, usize, usize, bool)> {
let strict = find_dnaa_boxes_around(seq, center, half, opt.box_max_mismatch, opt);
if let Some((s, e, c)) = best_cluster(&strict, center, n, opt) {
return Some((s, e, c, false));
}
if allow_loose && opt.box_max_mismatch < BOX_FALLBACK_MISMATCH {
let loose = find_dnaa_boxes_around(seq, center, half, BOX_FALLBACK_MISMATCH, opt);
return best_cluster(&loose, center, n, opt).map(|(s, e, c)| (s, e, c, true));
}
None
}
fn best_cluster(
hits: &[usize],
anchor: i64,
n: i64,
opt: &Options,
) -> Option<(usize, usize, usize)> {
if hits.is_empty() {
return None;
}
let mut best: Option<(usize, usize, usize, i64)> = None; let mut i = 0;
while i < hits.len() {
let mut j = i;
while j + 1 < hits.len() && (hits[j + 1] as i64 - hits[j] as i64) <= opt.cluster_gap {
j += 1;
}
let (start, end, count) = (hits[i], hits[j] + 8, j - i + 1);
i = j + 1;
if count < opt.min_cluster_boxes {
continue;
}
let center = (start as i64 + end as i64) / 2;
let dist = circular_dist(center, anchor, n);
let cost = dist - count as i64 * BOX_WORTH_BP;
let better = match &best {
None => true,
Some((_, _, _, bcost)) => cost < *bcost,
};
if better {
best = Some((start, end, count, cost));
}
}
best.map(|(s, e, c, _)| (s, e, c))
}
fn confidence(
sigma: f64,
antipodal: f64,
boxes: usize,
dnaa_dist: Option<i64>,
multimodal: bool,
opt: &Options,
) -> f32 {
let amp_q = norm(sigma, opt.min_skew_sigma, opt.min_skew_sigma * 4.0);
let anti_q = norm(antipodal, opt.min_antipodal, 1.0);
let box_q = boxes as f64 / (boxes as f64 + 3.0);
let dnaa_q = dnaa_dist.map(|d| 1.0 - norm(d as f64, 0.0, opt.search_half_win as f64));
let (mut sum, mut wsum) = (0.45 * amp_q + 0.20 * anti_q + 0.35 * box_q, 1.0);
if let Some(q) = dnaa_q {
sum += 0.30 * q;
wsum += 0.30;
}
let mut score = (sum / wsum) as f32;
if multimodal {
score *= 0.5;
}
score.clamp(0.0, 1.0)
}
fn norm(x: f64, lo: f64, hi: f64) -> f64 {
if hi <= lo {
return 1.0;
}
((x - lo) / (hi - lo)).clamp(0.0, 1.0)
}
fn dnaa_gene(genes: &[GeneHint]) -> Option<(i64, i64)> {
genes.iter().find(|g| contains_dnaa(&g.name)).map(|g| {
let s = (g.start as i64 - 1).max(0);
let e = (g.end as i64 - 1).max(s);
(s, e)
})
}
fn contains_dnaa(s: &str) -> bool {
s.to_ascii_lowercase().contains("dnaa")
}
#[cfg(test)]
pub(crate) mod tests_util {
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
Rng(seed ^ 0x9E37_79B9_7F4A_7C15 | 1)
}
pub fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
pub fn base(&mut self, gc: u8) -> u8 {
if (self.next() % 100) < gc as u64 {
if self.next() & 1 == 0 {
b'G'
} else {
b'C'
}
} else if self.next() & 1 == 0 {
b'A'
} else {
b'T'
}
}
}
pub struct Synth {
pub arm: usize,
pub skew_pct: u8,
pub gc_bg: u8,
pub boxes: usize,
pub rot: usize,
pub seed: u64,
}
impl Default for Synth {
fn default() -> Self {
Synth {
arm: 60_000,
skew_pct: 40,
gc_bg: 50,
boxes: 5,
rot: 0,
seed: 1,
}
}
}
impl Synth {
pub fn build(&self) -> (Vec<u8>, usize) {
let mut rng = Rng::new(self.seed);
let mut seq = Vec::with_capacity(2 * self.arm + 64);
for _ in 0..self.arm {
if (rng.next() % 100) < self.skew_pct as u64 {
seq.push(b'C');
} else {
seq.push(rng.base(self.gc_bg));
}
}
let ori = seq.len();
for _ in 0..self.boxes {
seq.extend_from_slice(b"TTATCCACA");
seq.extend_from_slice(b"AACGT"); }
for _ in 0..self.arm {
if (rng.next() % 100) < self.skew_pct as u64 {
seq.push(b'G');
} else {
seq.push(rng.base(self.gc_bg));
}
}
let n = seq.len();
let rot = self.rot % n;
seq.rotate_left(rot);
let ori = (ori + n - rot) % n;
(seq, ori)
}
}
pub fn clean() -> (Vec<u8>, usize) {
Synth::default().build()
}
}
#[cfg(test)]
mod tests {
use super::tests_util::*;
use super::*;
fn off(pred_1based: usize, ori_0based: usize, n: usize) -> usize {
let d = (pred_1based as i64 - 1 - ori_0based as i64).abs();
d.min(n as i64 - d) as usize
}
#[test]
fn dnaa_recognition_variants() {
assert!(contains_dnaa("dnaA"));
assert!(contains_dnaa("DnaA"));
assert!(contains_dnaa(
"Chromosomal replication initiator protein DnaA"
));
assert!(!contains_dnaa("gyrA"));
assert!(!contains_dnaa("DNA gyrase subunit A"));
}
#[test]
fn circular_dist_wraps() {
assert_eq!(circular_dist(10, 90, 100), 20);
assert_eq!(circular_dist(90, 10, 100), 20);
assert_eq!(circular_dist(10, 20, 100), 10);
}
#[test]
fn matches_box_tolerates_one_mismatch() {
assert!(matches_box(b"TTATCCACA", DNAA_BOX, 1));
assert!(matches_box(b"TTATCCACC", DNAA_BOX, 1)); assert!(!matches_box(b"TTATCCAGG", DNAA_BOX, 1)); assert!(!matches_box(b"TTATCCACC", DNAA_BOX, 0)); }
#[test]
fn best_cluster_prefers_proximity_over_size() {
let n = 1_000_000;
let anchor = 500_000;
let mut hits = vec![anchor as usize, anchor as usize + 30, anchor as usize + 60];
for k in 0..6 {
hits.push(100_000 + k * 30);
}
hits.sort_unstable();
let opt = Options::default();
let (s, e, _) = best_cluster(&hits, anchor, n, &opt).unwrap();
let center = ((s + e) / 2) as i64;
assert!(
circular_dist(center, anchor, n) < 5_000,
"cluster {center} should sit near anchor {anchor}, not at the larger distant group"
);
}
#[test]
fn best_cluster_skips_subthreshold_near_anchor() {
let n = 4_000_000;
let anchor = 3_600_000;
let mut hits = vec![anchor as usize]; for k in 0..3 {
hits.push(25_000 + k * 30); }
hits.sort_unstable();
let opt = Options::default();
let (s, e, count) =
best_cluster(&hits, anchor, n, &opt).expect("must find the real cluster");
assert_eq!(count, 3);
let center = ((s + e) / 2) as i64;
assert!(
circular_dist(center, 25_000, n) < 1_000,
"should report the 3-box cluster, not the lone box"
);
}
#[test]
fn find_boxes_wraps_around_origin() {
let mut seq = vec![b'A'; 200_000];
seq[199_995..].copy_from_slice(&b"TTATC"[..]);
seq[..4].copy_from_slice(&b"CACA"[..]); let opt = Options::default();
let hits = find_dnaa_boxes_around(&seq, 0, opt.search_half_win, opt.box_max_mismatch, &opt);
assert!(
hits.iter().any(|&h| h >= 199_990 || h <= 5),
"expected a wrapped box near the origin, got {hits:?}"
);
}
#[test]
fn detects_clean_oric_with_boxes() {
let (seq, ori) = clean();
let n = seq.len();
let out = detect(&seq, &[]);
assert_eq!(out.len(), 1, "expected exactly one oriC");
let o = &out[0];
assert_eq!(o.method, Method::SkewAndBoxes);
assert!(
o.dnaa_boxes >= 3,
"should anchor on the embedded box cluster"
);
assert!(
off(o.start, ori, n) < 2_000,
"oriC {} should be within 2 kb of the true origin {ori}",
o.start
);
assert!(
o.score > 0.6,
"clean signal should score high, got {}",
o.score
);
}
#[test]
fn rotation_invariant_when_deposited_at_origin() {
for rot in [0usize, 30_000, 60_000, 90_000, 120_000] {
let (seq, ori) = Synth {
rot,
..Default::default()
}
.build();
let n = seq.len();
let out = detect(&seq, &[]);
assert_eq!(out.len(), 1, "rot={rot}: expected one call");
assert!(
off(out[0].start, ori, n) < 2_000,
"rot={rot}: oriC {} not near origin {ori}",
out[0].start
);
}
}
#[test]
fn high_gc_genome_still_detected() {
let (seq, ori) = Synth {
gc_bg: 70,
..Default::default()
}
.build();
let n = seq.len();
let out = detect(&seq, &[]);
assert_eq!(out.len(), 1, "high-GC genome should still be called");
assert!(off(out[0].start, ori, n) < 3_000);
}
#[test]
fn noisy_skew_still_detected() {
let (seq, ori) = Synth {
skew_pct: 12,
boxes: 6,
seed: 42,
..Default::default()
}
.build();
let n = seq.len();
let out = detect(&seq, &[]);
assert_eq!(out.len(), 1, "noisy but real signal should be called");
assert!(
off(out[0].start, ori, n) < 5_000,
"oriC {} not within 5 kb of {ori} on noisy genome",
out[0].start
);
}
#[test]
fn skew_only_call_without_boxes() {
let (seq, _) = Synth {
boxes: 0,
..Default::default()
}
.build();
let out = detect(&seq, &[]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].method, Method::SkewOnly);
assert_eq!(out[0].dnaa_boxes, 0);
}
#[test]
fn flat_skew_declined() {
let mut rng = Rng::new(7);
let seq: Vec<u8> = (0..200_000).map(|_| rng.base(50)).collect();
assert!(
detect(&seq, &[]).is_empty(),
"a strand-balanced sequence must not yield an oriC"
);
}
#[test]
fn short_contig_declined() {
let seq = vec![b'A'; 10_000];
assert!(detect(&seq, &[]).is_empty());
}
#[test]
fn dnaa_gene_rescues_weak_skew() {
let mut rng = Rng::new(3);
let mut seq: Vec<u8> = (0..300_000).map(|_| rng.base(50)).collect();
let gene_at = 150_000usize;
for k in 0..5 {
let p = gene_at + k * 15;
seq[p..p + 9].copy_from_slice(DNAA_BOX);
}
let genes = [GeneHint {
start: gene_at + 1,
end: gene_at + 1200,
name: "dnaA".into(),
}];
let out = detect(&seq, &genes);
assert_eq!(out.len(), 1, "dnaA gene should rescue the call");
assert_eq!(out[0].method, Method::DnaaAnchored);
}
#[test]
fn weak_skew_no_gene_declined() {
let mut rng = Rng::new(3);
let seq: Vec<u8> = (0..300_000).map(|_| rng.base(50)).collect();
assert!(detect(&seq, &[]).is_empty());
}
#[test]
fn linear_prefers_dnaa_over_wrong_skew_min() {
let (mut seq, _) = Synth {
arm: 80_000,
rot: 40_000,
..Default::default()
}
.build();
let n = seq.len();
let gene_at = n / 2;
for k in 0..5 {
let p = gene_at + k * 15;
seq[p..p + 9].copy_from_slice(DNAA_BOX);
}
let genes = [GeneHint {
start: gene_at + 1,
end: gene_at + 1200,
name: "dnaA".into(),
}];
let opt = Options {
topology: Topology::Linear,
..Default::default()
};
let out = detect_with(&seq, &genes, &opt);
assert_eq!(out.len(), 1);
assert_eq!(out[0].method, Method::DnaaAnchored);
let d = (out[0].start as i64 - 1 - gene_at as i64).abs();
assert!(
d < 3_000,
"linear call {} should be near dnaA {gene_at}",
out[0].start
);
}
#[test]
fn detect_is_single_per_call() {
let (chrom, _) = clean();
let plasmid = vec![b'A'; 60_000]; assert_eq!(detect(&chrom, &[]).len(), 1);
assert_eq!(detect(&plasmid, &[]).len(), 0);
}
#[test]
fn confidence_increases_with_evidence() {
let opt = Options::default();
let weak = confidence(6.0, 0.55, 0, None, false, &opt);
let strong = confidence(30.0, 1.0, 8, Some(0), false, &opt);
assert!(strong > weak);
assert!((0.0..=1.0).contains(&strong));
assert!((0.0..=1.0).contains(&weak));
assert!(confidence(30.0, 1.0, 8, Some(0), true, &opt) < strong);
}
#[test]
fn dnaa_box_cluster_anchors_the_call() {
let (seq, ori) = clean();
let n = seq.len();
let genes = [GeneHint {
start: ori + 1,
end: ori + 1200,
name: "dnaA".into(),
}];
let out = detect(&seq, &genes);
assert_eq!(out.len(), 1);
assert_eq!(out[0].method, Method::DnaaAnchored);
assert!(out[0].dnaa_boxes >= 3);
assert!(off(out[0].start, ori, n) < 2_000);
}
#[test]
fn displaced_origin_overrides_dnaa_decoy() {
let (mut seq, ori) = Synth {
boxes: 9,
..Default::default()
}
.build();
let n = seq.len();
let gene = (ori + 80_000) % n;
for k in 0..3 {
let p = (gene + 40 + k * 15) % n;
seq[p..p + 9].copy_from_slice(DNAA_BOX);
}
let genes = [GeneHint {
start: gene + 1,
end: gene + 1200,
name: "dnaA".into(),
}];
let out = detect(&seq, &genes);
assert_eq!(out.len(), 1);
assert_eq!(out[0].method, Method::SkewAndBoxes);
assert!(
off(out[0].start, ori, n) < 2_000,
"should report the displaced origin at {ori}, got {}",
out[0].start
);
assert!(out[0].notes.contains(&Flag::Displaced));
assert!(!Flag::Displaced.is_warning());
}
#[test]
fn high_gc_two_mismatch_boxes_recovered() {
let (mut seq, ori) = Synth {
gc_bg: 75,
boxes: 0,
..Default::default()
}
.build();
let variant = b"TTAGCCATA"; assert!(!matches_box(variant, DNAA_BOX, 1)); for k in 0..5 {
let p = ori + k * 15;
seq[p..p + 9].copy_from_slice(variant);
}
let genes = [GeneHint {
start: ori + 1,
end: ori + 1200,
name: "dnaA".into(),
}];
let out = detect(&seq, &genes);
assert_eq!(out.len(), 1);
assert!(
out[0].dnaa_boxes >= 3,
"drifted boxes should be recovered at 2 mismatches in a high-GC genome"
);
assert!(off(out[0].start, ori, seq.len()) < 2_000);
}
#[test]
fn clean_skew_beats_dnaa_decoy_without_box_margin() {
let (mut seq, ori) = Synth {
boxes: 3,
..Default::default()
}
.build();
let n = seq.len();
let gene = (ori + 42_000) % n;
for k in 0..3 {
let p = (gene + 40 + k * 15) % n;
seq[p..p + 9].copy_from_slice(DNAA_BOX);
}
let genes = [GeneHint {
start: gene + 1,
end: gene + 1200,
name: "dnaA".into(),
}];
let out = detect(&seq, &genes);
assert_eq!(out.len(), 1);
assert_eq!(out[0].method, Method::SkewAndBoxes, "clean skew should win");
assert!(
off(out[0].start, ori, n) < 2_000,
"displaced origin {ori} not recovered, got {}",
out[0].start
);
}
#[test]
fn moderate_displacement_recovered_with_due() {
let (mut seq, ori) = Synth {
boxes: 5,
gc_bg: 50,
..Default::default()
}
.build();
let n = seq.len();
for k in 0..400 {
seq[(ori + n - 400 + k) % n] = if k & 1 == 0 { b'A' } else { b'T' };
}
let gene = (ori + 10_000) % n;
for k in 0..3 {
let p = (gene + 40 + k * 15) % n;
seq[p..p + 9].copy_from_slice(DNAA_BOX);
}
let genes = [GeneHint {
start: gene + 1,
end: gene + 1200,
name: "dnaA".into(),
}];
let out = detect(&seq, &genes);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].method,
Method::SkewAndBoxes,
"the DUE-bearing displaced cluster should override the dnaA decoy"
);
assert!(
off(out[0].start, ori, n) < 2_000,
"moderately-displaced origin {ori} not recovered, got {}",
out[0].start
);
}
#[test]
fn at_rich_genome_suppresses_moderate_due_override() {
let (mut seq, ori) = Synth {
boxes: 5,
skew_pct: 20,
gc_bg: 5,
..Default::default()
}
.build();
let n = seq.len();
for k in 0..400 {
seq[(ori + n - 400 + k) % n] = if k & 1 == 0 { b'A' } else { b'T' };
}
let gene = (ori + 10_000) % n;
for k in 0..3 {
let p = (gene + 40 + k * 15) % n;
seq[p..p + 9].copy_from_slice(DNAA_BOX);
}
let genes = [GeneHint {
start: gene + 1,
end: gene + 1200,
name: "dnaA".into(),
}];
let out = detect(&seq, &genes);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].method,
Method::DnaaAnchored,
"the moderate-band DUE override must be suppressed in AT-saturated genomes"
);
}
#[test]
fn clean_call_is_pass_with_no_warnings() {
let (seq, ori) = clean();
let genes = [GeneHint {
start: ori + 1,
end: ori + 1200,
name: "dnaA".into(),
}];
let out = detect(&seq, &genes);
assert_eq!(out[0].conf, Confidence::Pass);
assert!(out[0].score >= CONF_PASS);
assert!(
out[0].notes.iter().all(|f| !f.is_warning()),
"clean call should carry no warning notes, got {:?}",
out[0].notes
);
}
#[test]
fn no_hint_is_flagged() {
let (seq, _) = clean();
let out = detect(&seq, &[]);
assert!(out[0].notes.contains(&Flag::NoHint));
assert!(!Flag::NoHint.is_warning());
}
#[test]
fn missing_box_cluster_is_flagged() {
let (seq, _) = Synth {
boxes: 0,
..Default::default()
}
.build();
let out = detect(&seq, &[]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].method, Method::SkewOnly);
assert!(out[0].notes.contains(&Flag::NoBoxes));
assert!(Flag::NoBoxes.is_warning());
}
#[test]
fn confidence_tiers_track_score() {
let tier = |s: f32| {
if s >= CONF_PASS {
Confidence::Pass
} else if s < CONF_WEAK {
Confidence::Weak
} else {
Confidence::Review
}
};
assert_eq!(tier(0.95), Confidence::Pass);
assert_eq!(tier(CONF_PASS), Confidence::Pass);
assert_eq!(tier(0.45), Confidence::Review);
assert_eq!(tier(CONF_WEAK), Confidence::Review);
assert_eq!(tier(0.10), Confidence::Weak);
}
}