Skip to main content

rust_ise/
lib.rs

1// rust-ise — Rust-native ISEScan-equivalent IS-element scanner.
2// Pipeline: rustygal META gene-finding (LINKED LIBRARY, == pyrodigal) + strand-aware edge recovery
3//   -> mmseqs2 profile search (system `mmseqs` on PATH; same union DB/params as the Python reference pv2)
4//   -> 4-tier family call -> native affine Smith-Waterman-Gotoh TIR -> c/p -> dedup -> TSV/GFF/.sum.
5// Coordinates 1-based inclusive. mmseqs2 is the one external engine (call system binary); the IS DB is
6// external data resolved via --db / $ISSCAN_DB (not bundled in the crate).
7use rayon::prelude::*;
8use std::cell::RefCell;
9use std::collections::HashMap;
10use std::fs;
11use std::io::{BufRead, BufReader, Write, BufWriter};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15// Compile-time perfect-hash map `REP_THRESHOLDS: phf::Map<&str, f32>` (rep -> min pident to accept a
16// hit to that profile), GTDB-calibrated, generated by build.rs from data/rep_thresholds.tsv.
17include!(concat!(env!("OUT_DIR"), "/rep_thresholds.rs"));
18
19// ---- family-call thresholds (tuned, == v2) ----
20// SIG_EVALUE: the primary significance gate — an ORF's best profile hit must be at least this
21// significant to be called an IS at all (family_call). This is the dominant code-side precision
22// lever. Empirically calibrated on E. coli MG1655 vs RefSeq (50 mobile_genetic_element truth
23// features): loosening to 1e-10 admits ~5 low-significance false positives; tightening past ~1e-40
24// starts discarding real IS calls. 1e-30 is the knee — it removes those FPs while retaining every
25// true IS element (MG1655: precision 0.72 -> 0.76, recall unchanged at 0.98 / 49-of-50 truth).
26// PRECISION/RECALL TRADEOFF: smaller value => fewer FPs but risks dropping divergent IS transposases.
27const SIG_EVALUE: f64 = 1e-30;
28// SIG_QCOV: minimum query (ORF) coverage of the winning profile hit. A real IS transposase ORF
29// usually aligns across most of the profile, so a very partial hit is a weak-evidence FP candidate.
30// IMPORTANT / HONEST CAVEAT: on MG1655 query coverage is a WEAK discriminator — false-positive qcov
31// (median ~0.88) overlaps true-positive qcov (median ~0.89, min 0.13), so a strict floor (e.g. 0.7)
32// would destroy recall (0.98 -> 0.78) for almost no precision gain. This floor is therefore set LOW
33// as a conservative safety net that trims only the most extreme fragmentary hits without harming
34// recall; the E-value floor above does the real work. Raise only with fresh empirical validation.
35const SIG_QCOV: f64 = 0.35;
36const LIKE_EVALUE: f64 = 1e-30;
37const LIKE_QCOV: f64 = 0.7;
38const LIKE_SPAN_OVERLAP: f64 = 0.5;
39const NOVEL_PIDENT: f64 = 30.0;
40// ---- TIR / flank params (== constants_isscan.py) ----
41const MAX_DIST: i64 = 500;
42const MIN_DIST_ABS: i64 = 150; // abs(minDist4ter2orf = -150)
43const SW_MATCH: i32 = 2;
44const SW_MISMATCH: i32 = -6;
45// affine gap (Gotoh, parasail convention: a length-g gap costs OPEN + EXTEND*(g-1)).
46// constants_isscan filters4ssw4trial = (match2, mismatch6, gap_open2, gap_extend2).
47const SW_OPEN: i32 = 2;
48const SW_EXTEND: i32 = 2;
49
50// ---------------------------------------------------------- sequence utils
51fn revcomp(s: &[u8]) -> Vec<u8> {
52    s.iter().rev().map(|&c| match c {
53        b'A' => b'T', b'T' => b'A', b'G' => b'C', b'C' => b'G',
54        b'a' => b't', b't' => b'a', b'g' => b'c', b'c' => b'g',
55        b'N' => b'N', b'n' => b'n', _ => b'N',
56    }).collect()
57}
58
59fn norm_fam(f: &str) -> String {
60    f.replace('/', "").replace('_', "").to_uppercase()
61}
62
63// standard genetic code (== v2 _CODON), index by 3 uppercase bases
64fn translate(s: &[u8]) -> Vec<u8> {
65    const TBL: &[u8] = b"FFLLSSSSYY**CC*WLLLLPPPPHHQQRRRRIIIMTTTTNNKKSSRRVVVVAAAADDEEGGGG";
66    let code = |c: u8| -> i32 { match c { b'T'=>0, b'C'=>1, b'A'=>2, b'G'=>3, _=>-1 } };
67    let mut out = Vec::with_capacity(s.len()/3);
68    let mut i = 0;
69    while i + 3 <= s.len() {
70        let (a,b,c) = (code(s[i]), code(s[i+1]), code(s[i+2]));
71        if a<0 || b<0 || c<0 { out.push(b'X'); }
72        else { out.push(TBL[(a*16+b*4+c) as usize]); }
73        i += 3;
74    }
75    out
76}
77
78fn read_fasta(path: &str) -> Vec<(String, Vec<u8>)> {
79    let f = BufReader::new(fs::File::open(path).expect("open fasta"));
80    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
81    let mut name: Option<String> = None;
82    let mut buf: Vec<u8> = Vec::new();
83    for line in f.lines() {
84        let line = line.unwrap();
85        if let Some(rest) = line.strip_prefix('>') {
86            if let Some(n) = name.take() { out.push((n, std::mem::take(&mut buf))); }
87            name = Some(rest.split_whitespace().next().unwrap_or("").to_string());
88        } else {
89            buf.extend(line.trim().bytes().map(|c| c.to_ascii_uppercase()));
90        }
91    }
92    if let Some(n) = name.take() { out.push((n, buf)); }
93    out
94}
95
96// ---------------------------------------------------------- 1. ORFs (rustygal + edge)
97struct Orf { begin: i64, end: i64, strand: char, prot: Vec<u8>, edge: bool }
98
99// Parse rustygal -a FASTA bytes: header ">{contig}_{geneidx} # b # e # strand # ID=..." , seq = protein.
100// (rv2 gets these bytes from the rustygal LIBRARY `run_meta`, not a file — same format as the binary's -a.)
101// Returns contig -> Vec<Orf> (pyrodigal-equivalent genes only; edge added later).
102fn parse_rustygal_faa(data: &[u8]) -> HashMap<String, Vec<Orf>> {
103    let mut map: HashMap<String, Vec<Orf>> = HashMap::new();
104    let mut cur: Option<(String, i64, i64, char)> = None;
105    let mut seq: Vec<u8> = Vec::new();
106    let flush = |map: &mut HashMap<String, Vec<Orf>>,
107                 cur: &Option<(String,i64,i64,char)>, seq: &mut Vec<u8>| {
108        if let Some((ctg, b, e, st)) = cur {
109            // strip trailing '*' to match v2 g.translate().rstrip("*")
110            while seq.last() == Some(&b'*') { seq.pop(); }
111            map.entry(ctg.clone()).or_default().push(Orf {
112                begin: *b, end: *e, strand: *st, prot: std::mem::take(seq), edge: false });
113        } else { seq.clear(); }
114    };
115    for raw in data.split(|&c| c == b'\n') {
116        let line = String::from_utf8_lossy(raw);
117        let line = line.trim_end_matches('\r');
118        if let Some(rest) = line.strip_prefix('>') {
119            flush(&mut map, &cur, &mut seq);
120            // rest: "{contig}_{geneidx} # b # e # strand # ID=..."
121            let first = rest.split_whitespace().next().unwrap();
122            let parts: Vec<&str> = rest.split('#').collect();
123            let b: i64 = parts[1].trim().parse().unwrap();
124            let e: i64 = parts[2].trim().parse().unwrap();
125            let strand = if parts[3].trim() == "1" { '+' } else { '-' };
126            // contig = first token minus the trailing "_{geneidx}"
127            let contig = match first.rfind('_') { Some(i) => &first[..i], None => first };
128            cur = Some((contig.to_string(), b, e, strand));
129        } else {
130            seq.extend(line.trim().bytes());
131        }
132    }
133    flush(&mut map, &cur, &mut seq);
134    map
135}
136
137// strand-aware edge recovery for one contig (== v2 _orf_one edge part).
138fn edge_orfs(seq: &[u8], pyr: &[Orf], minaa: usize, edge: i64) -> Vec<Orf> {
139    let l = seq.len() as i64;
140    let mut out = Vec::new();
141    for &strand in &['+', '-'] {
142        let s = if strand == '+' { seq.to_vec() } else { revcomp(seq) };
143        for frame in 0..3usize {
144            let prot = translate(&s[frame.min(s.len())..]);
145            // maximal non-stop stretches [a0,a1) (a1 exclusive)
146            let mut start = 0usize;
147            let mut stretches: Vec<(usize, usize)> = Vec::new();
148            for (k, &aa) in prot.iter().enumerate() {
149                if aa == b'*' { if k > start { stretches.push((start, k)); } start = k + 1; }
150            }
151            if start < prot.len() { stretches.push((start, prot.len())); }
152            for (a0, a1) in stretches {
153                if a1 - a0 < minaa { continue; }
154                let ns = frame as i64 + a0 as i64 * 3;
155                let ne = frame as i64 + a1 as i64 * 3;
156                if !(ns <= edge || ne >= l - edge) { continue; }
157                let (cb, ce) = if strand == '+' { (ns + 1, ne) } else { (l - ne + 1, l - ns) };
158                let suppressed = pyr.iter().any(|p| p.strand == strand
159                    && (ce.min(p.end) - cb.max(p.begin)) as f64 >= 0.5 * (ce - cb) as f64);
160                if suppressed { continue; }
161                out.push(Orf { begin: cb, end: ce, strand, prot: prot[a0..a1].to_vec(), edge: true });
162            }
163        }
164    }
165    out
166}
167
168// ---------------------------------------------------------- 2. mmseqs search
169#[derive(Clone)]
170struct Hit { fam: String, evalue: f64, qcov: f64, pident: f64, tstart: i64, tend: i64 }
171
172fn mmseqs_search(proteome: &Path, db_dir: &Path, tmp: &Path, nthread: usize, gpu: bool)
173    -> HashMap<String, Vec<Hit>> {
174    let profile_db = db_dir.join("mmdb_union").join("profileDb");
175    let manifest = db_dir.join("manifest_union.tsv");
176    let run = |args: &[&str]| {
177        let st = Command::new("mmseqs").args(args)
178            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
179            .status().expect("spawn mmseqs");
180        assert!(st.success(), "mmseqs {:?} failed", &args[0]);
181    };
182    let qdb = tmp.join("qdb");
183    run(&["createdb", proteome.to_str().unwrap(), qdb.to_str().unwrap(), "-v", "0"]);
184    // cid -> family and cid -> rep (rep is the stable key for the embedded per-profile threshold)
185    let mut cid2fam: HashMap<String, String> = HashMap::new();
186    let mut cid2rep: HashMap<String, String> = HashMap::new();
187    for line in BufReader::new(fs::File::open(&manifest).unwrap()).lines() {
188        let line = line.unwrap();
189        let p: Vec<&str> = line.split('\t').collect();
190        if p.len() >= 2 && p[0] != "cid" { cid2fam.insert(p[0].to_string(), p[1].to_string()); }
191        if p.len() >= 4 && p[0] != "cid" { cid2rep.insert(p[0].to_string(), p[3].to_string()); }
192    }
193    let res = tmp.join("pres");
194    let aln = tmp.join("pres.tsv");
195    let tp = tmp.join("tp");
196    let nt = nthread.to_string();
197    let mut search_args: Vec<&str> = vec!["search", profile_db.to_str().unwrap(),
198        qdb.to_str().unwrap(), res.to_str().unwrap(), tp.to_str().unwrap(),
199        "-s", "7.5", "-e", "10", "--threads", &nt, "-v", "0"];
200    if gpu { search_args.push("--gpu"); search_args.push("1"); }
201    run(&search_args);
202    run(&["convertalis", profile_db.to_str().unwrap(), qdb.to_str().unwrap(),
203        res.to_str().unwrap(), aln.to_str().unwrap(),
204        "--format-output", "query,target,evalue,qcov,pident,tstart,tend", "-v", "0"]);
205    let mut hits: HashMap<String, Vec<Hit>> = HashMap::new();
206    for line in BufReader::new(fs::File::open(&aln).unwrap()).lines() {
207        let line = line.unwrap();
208        let p: Vec<&str> = line.split('\t').collect();
209        if p.len() < 7 { continue; }
210        let pident: f64 = p[4].parse().unwrap_or(0.0);
211        // per-profile pident gate (GTDB-calibrated, rep-keyed): drop hits to a contaminant /
212        // promiscuous profile when their identity is below the profile's threshold. Absent rep =>
213        // threshold 0 => accept. This runs before family_call so a gated hit cannot win the ORF.
214        if let Some(rep) = cid2rep.get(p[0]) {
215            let thr = REP_THRESHOLDS.get(rep.as_str()).copied().unwrap_or(0.0) as f64;
216            if pident < thr { continue; }
217        }
218        if let Some(fam) = cid2fam.get(p[0]) {
219            hits.entry(p[1].to_string()).or_default().push(Hit {
220                fam: fam.clone(),
221                evalue: p[2].parse().unwrap_or(1.0),
222                qcov: p[3].parse().unwrap_or(0.0),
223                pident,
224                tstart: p[5].parse().unwrap_or(0),
225                tend: p[6].parse().unwrap_or(0),
226            });
227        }
228    }
229    hits
230}
231
232// ---------------------------------------------------------- 3. family call (4-tier)
233// Total order on hits => representative selection is deterministic regardless of the
234// (run-to-run varying) mmseqs hit order. evalue asc, then pident/qcov desc, then
235// tstart/tend asc, then family — fully canonical so equal-E ties never flip the output.
236fn cmp_hit(a: &Hit, b: &Hit) -> std::cmp::Ordering {
237    a.evalue.partial_cmp(&b.evalue).unwrap()
238        .then(b.pident.partial_cmp(&a.pident).unwrap())
239        .then(b.qcov.partial_cmp(&a.qcov).unwrap())
240        .then(a.tstart.cmp(&b.tstart))
241        .then(a.tend.cmp(&b.tend))
242        .then_with(|| a.fam.cmp(&b.fam))
243}
244
245// returns (label, tier, best_hit) ; tier in {plain,like,chimera,novel}
246fn family_call(orfhits: &[Hit]) -> Option<(String, String, Hit)> {
247    if orfhits.is_empty() { return None; }
248    let mut perfam: HashMap<String, Hit> = HashMap::new();
249    for h in orfhits {
250        let nf = norm_fam(&h.fam);
251        match perfam.get(&nf) {
252            Some(cur) if cmp_hit(cur, h) != std::cmp::Ordering::Greater => {}
253            _ => { perfam.insert(nf, h.clone()); }
254        }
255    }
256    let mut ranked: Vec<Hit> = perfam.into_values().collect();
257    ranked.sort_by(cmp_hit);
258    let top = ranked[0].clone();
259    // primary significance gate (dominant precision lever): drop weakly-supported ORFs.
260    if top.evalue > SIG_EVALUE { return None; }
261    // query-coverage floor: reject extreme fragmentary hits (conservative — see SIG_QCOV doc).
262    if top.qcov < SIG_QCOV { return None; }
263    if top.pident < NOVEL_PIDENT { return Some(("novel".to_string(), "novel".to_string(), top)); }
264    // competitor: best DIFFERENT family, strong + good coverage
265    let comp = ranked[1..].iter().find(|x|
266        norm_fam(&x.fam) != norm_fam(&top.fam) && x.evalue <= LIKE_EVALUE && x.qcov >= LIKE_QCOV);
267    match comp {
268        None => Some((top.fam.clone(), "plain".to_string(), top)),
269        Some(c) => {
270            let ov = top.tend.min(c.tend) - top.tstart.max(c.tstart);
271            let span = ((top.tend - top.tstart).min(c.tend - c.tstart)).max(1);
272            if ov > 0 && ov as f64 >= LIKE_SPAN_OVERLAP * span as f64 {
273                Some((format!("{}-like", top.fam), "like".to_string(), top))
274            } else {
275                Some((format!("{}-chimera", top.fam), "chimera".to_string(), top))
276            }
277        }
278    }
279}
280
281// ---------------------------------------------------------- 4. TIR (native SW) + c/p
282#[derive(Clone)]
283pub struct Tir { pub irid: i64, pub irlen: i64, pub start1: i64, pub end1: i64, pub start2: i64, pub end2: i64,
284             pub seq1: Vec<u8>, pub seq2: Vec<u8> }
285
286#[derive(Default)]
287struct SwBuf {
288    ph: Vec<i32>, ch: Vec<i32>,   // H rows (prev / cur)
289    pf: Vec<i32>, cf: Vec<i32>,   // F rows (prev / cur) — vertical-gap matrix
290    hp: Vec<u8>, ep: Vec<u8>, fp: Vec<u8>,  // traceback pointer matrices
291}
292thread_local! {
293    // Reused across every TIR alignment on this worker thread → no per-call heap allocation.
294    // Buffers only grow to the largest flank seen so far.
295    static SW_SCRATCH: RefCell<SwBuf> = RefCell::new(SwBuf::default());
296}
297
298// Local Smith-Waterman-Gotoh with AFFINE gaps (parasail convention: a length-g gap costs
299// SW_OPEN + SW_EXTEND*(g-1)). Two gap matrices: E = horizontal (gap in query / consume ref),
300// F = vertical (gap in ref / consume query). When SW_OPEN==SW_EXTEND this is numerically
301// identical to the linear case. Returns (score, beg_q, end_q, beg_r, end_r) 0-based inclusive.
302fn sw_local(q: &[u8], r: &[u8]) -> Option<(i32, usize, usize, usize, usize)> {
303    let (n, m) = (q.len(), r.len());
304    if n == 0 || m == 0 { return None; }
305    const NEG: i32 = i32::MIN / 4;   // -inf for gap-matrix init (no overflow on -EXTEND)
306    SW_SCRATCH.with(|cell| {
307        let mut s = cell.borrow_mut();
308        let b = &mut *s;             // deref RefMut once so field borrows are provably disjoint
309        let stride = m + 1;
310        if b.ph.len() < stride {
311            b.ph.resize(stride, 0); b.ch.resize(stride, 0);
312            b.pf.resize(stride, 0); b.cf.resize(stride, 0);
313        }
314        let need = (n + 1) * stride;
315        if b.hp.len() < need { b.hp.resize(need, 0); b.ep.resize(need, 0); b.fp.resize(need, 0); }
316        let (ph, ch, pf, cf) = (&mut b.ph, &mut b.ch, &mut b.pf, &mut b.cf);
317        let (hp, ep, fp) = (&mut b.hp, &mut b.ep, &mut b.fp);
318        // row 0: H=0, F=-inf
319        for x in ph[..stride].iter_mut() { *x = 0; }
320        for x in pf[..stride].iter_mut() { *x = NEG; }
321        let (mut best, mut bi, mut bj) = (0i32, 0usize, 0usize);
322        for i in 1..=n {
323            ch[0] = 0; cf[0] = NEG;
324            let mut e_prev = NEG;        // E[i][0] = -inf
325            let qi = q[i-1];
326            let row = i * stride;
327            for j in 1..=m {
328                let sc = if qi == r[j-1] && matches!(qi, b'A'|b'C'|b'G'|b'T')
329                        { SW_MATCH } else { SW_MISMATCH };
330                // E[i][j] (horizontal): open from H[i][j-1] or extend from E[i][j-1]
331                let e_open = ch[j-1] - SW_OPEN;
332                let e_ext  = e_prev - SW_EXTEND;
333                let (e_val, e_code) = if e_ext > e_open { (e_ext, 1u8) } else { (e_open, 0u8) };
334                // F[i][j] (vertical): open from H[i-1][j] or extend from F[i-1][j]
335                let f_open = ph[j] - SW_OPEN;
336                let f_ext  = pf[j] - SW_EXTEND;
337                let (f_val, f_code) = if f_ext > f_open { (f_ext, 1u8) } else { (f_open, 0u8) };
338                // H[i][j]; tie-break order diag > F(up) > E(left) (matches the old linear order)
339                let diag = ph[j-1] + sc;
340                let mut h = 0i32; let mut hc = 0u8;
341                if diag > h { h = diag; hc = 1; }
342                if f_val > h { h = f_val; hc = 3; }
343                if e_val > h { h = e_val; hc = 2; }
344                ch[j] = h; cf[j] = f_val; e_prev = e_val;
345                hp[row + j] = hc; ep[row + j] = e_code; fp[row + j] = f_code;
346                // tie-break: keep LAST max cell (>=) — closer to parasail's striped traceback
347                if h >= best && h > 0 { best = h; bi = i; bj = j; }
348            }
349            std::mem::swap(ph, ch);
350            std::mem::swap(pf, cf);
351        }
352        if best <= 0 { return None; }
353        // 3-state traceback to the origin (where H came from 0). state: 0=H, 1=E, 2=F.
354        let (mut i, mut j, mut state) = (bi, bj, 0u8);
355        loop {
356            match state {
357                0 => match hp[i*stride + j] {
358                    0 => break,                       // origin
359                    1 => { i -= 1; j -= 1; }          // diagonal
360                    2 => state = 1,                   // enter horizontal gap (E)
361                    _ => state = 2,                   // enter vertical gap (F)
362                },
363                1 => { let ext = ep[i*stride + j] == 1; j -= 1; if !ext { state = 0; } }
364                _ => { let ext = fp[i*stride + j] == 1; i -= 1; if !ext { state = 0; } }
365            }
366            if i == 0 || j == 0 { break; }
367        }
368        Some((best, i, bi - 1, j, bj - 1))
369    })
370}
371
372fn find_tir(contig: &[u8], orf_b: i64, orf_e: i64, family: &str,
373            tir_tbl: &HashMap<String,(i64,i64,i64,i64)>) -> Option<Tir> {
374    let fam = family.split('-').next().unwrap();
375    let info = lookup(tir_tbl, fam);
376    if let Some(t) = info { if t.3 == 0 { return None; } }
377    let min_ir = info.map(|t| t.0).unwrap_or(4);
378    if min_ir > 1000 { return None; }
379    let l = contig.len() as i64;
380    let lb = (orf_b - MAX_DIST).max(1);
381    let le = (orf_b + MIN_DIST_ABS).min(l);
382    let rb = (orf_e - MIN_DIST_ABS).max(1);
383    let re = (orf_e + MAX_DIST).min(l);
384    if le < lb || re < rb { return None; }
385    let left = &contig[(lb-1) as usize..le as usize];
386    let right = &contig[(rb-1) as usize..re as usize];
387    if (left.len() as i64) < min_ir || (right.len() as i64) < min_ir { return None; }
388    let rrc = revcomp(right);
389    let (score, qs, qe, ts, te) = sw_local(left, &rrc)?;
390    if score < (2 * min_ir) as i32 { return None; }
391    let ir_len = qe as i64 - qs as i64 + 1;
392    if ir_len < min_ir { return None; }
393    let seq1 = left[qs..=qe].to_vec();
394    let r_local_end = right.len() - 1 - ts;
395    let r_local_beg = right.len() - 1 - te;
396    let start1 = lb + qs as i64; let end1 = lb + qe as i64;
397    let start2 = rb + r_local_beg as i64; let end2 = rb + r_local_end as i64;
398    let seq2 = right[r_local_beg..=r_local_end].to_vec();
399    let seq2rc = revcomp(&seq2);
400    let ir_id = seq1.iter().zip(seq2rc.iter()).filter(|(a, b)| a == b).count() as i64;
401    if ir_len == 0 || (ir_id as f64) / (ir_len as f64) < 0.5 { return None; }
402    Some(Tir { irid: ir_id, irlen: ir_len, start1, end1, start2, end2, seq1, seq2 })
403}
404
405// O(1): tables are built with normalized keys, so a direct get on norm_fam(fam) suffices.
406fn lookup<'a>(tbl: &'a HashMap<String,(i64,i64,i64,i64)>, fam: &str) -> Option<&'a (i64,i64,i64,i64)> {
407    tbl.get(&norm_fam(fam))
408}
409
410// returns (isBegin, isEnd, type c/p)
411fn classify_is(label: &str, b: i64, e: i64, tir: &Option<Tir>,
412               tir_tbl: &HashMap<String,(i64,i64,i64,i64)>,
413               tpase_tbl: &HashMap<String,(i64,i64,i64)>) -> (i64, i64, char) {
414    let fam = label.split('-').next().unwrap();
415    if let Some(t) = tir {
416        return (t.start1.min(t.start2), t.end1.max(t.end2), 'c');
417    }
418    let info = lookup(tir_tbl, fam);
419    let has_tir = info.map(|t| t.3).unwrap_or(-1);
420    let lenb = tpase_tbl.get(&norm_fam(fam)).copied();
421    let orflen = e - b + 1;
422    if has_tir == 0 {
423        if let Some((lo, hi, _)) = lenb {
424            if lo <= orflen && orflen <= hi { return (b, e, 'c'); }
425        }
426    }
427    (b, e, 'p')
428}
429
430// ---------------------------------------------------------- 5. assembly
431#[derive(Clone)]
432pub struct Call {
433    pub seqid: String, pub family: String, pub tier: String, pub is_begin: i64, pub is_end: i64, pub is_len: i64,
434    pub orf_begin: i64, pub orf_end: i64, pub strand: char, pub evalue: f64, pub qcov: f64, pub pident: f64,
435    pub typ: char, pub tir: Option<Tir>, pub ncopy: i64,
436    // (C) nt neg-pos FP-control verdict (which lineage the call nt resembles): IS | shared | host | unresolved
437    pub fp_flag: String,
438}
439
440fn dedup(mut calls: Vec<Call>, min_overlap: f64) -> Vec<Call> {
441    let mut by_contig: HashMap<String, Vec<Call>> = HashMap::new();
442    for c in calls.drain(..) { by_contig.entry(c.seqid.clone()).or_default().push(c); }
443    let mut kept: Vec<Call> = Vec::new();
444    for (_, mut cs) in by_contig {
445        // total order: best-E first, then canonical (orfBegin,orfEnd,strand) so the greedy
446        // keep is deterministic even when two overlapping ORFs tie on E-value.
447        cs.sort_by(|a, b| a.evalue.partial_cmp(&b.evalue).unwrap()
448            .then(a.orf_begin.cmp(&b.orf_begin))
449            .then(a.orf_end.cmp(&b.orf_end))
450            .then(a.strand.cmp(&b.strand)));
451        let mut acc: Vec<Call> = Vec::new();
452        for c in cs {
453            let (cb, ce) = (c.orf_begin, c.orf_end);
454            let clash = acc.iter().any(|a| {
455                let ov = ce.min(a.orf_end) - cb.max(a.orf_begin);
456                ov > 0 && ov as f64 >= min_overlap * ((ce - cb).min(a.orf_end - a.orf_begin)) as f64
457            });
458            if !clash { acc.push(c); }
459        }
460        kept.extend(acc);
461    }
462    kept
463}
464
465// Merge adjacent co-oriented same-family ORF calls into ONE IS element. Two-ORF IS families
466// (IS3/IS1/IS21/IS66 ...) encode their transposase as orfA + orfB on the same strand, joined by a
467// programmed -1 frameshift; the two ORFs are immediately consecutive (small gap or slight overlap).
468// dedup() only collapses ORFs that OVERLAP by >=min_overlap, so these adjacent pairs survive as two
469// calls => the IS is double-counted. ISEScan reports such a pair as a single element. We chain
470// consecutive calls of the same normalized family + same strand whose gap <= `gap` bp, then re-derive
471// the IS boundary (TIR re-searched over the merged span) and c/p from the merged span; family/E-value/
472// identity are taken from the strongest hit in the chain. Singletons pass through unchanged.
473fn merge_adjacent(calls: Vec<Call>, gap: i64, contig_map: &HashMap<&str, &Vec<u8>>,
474                  tir_tbl: &HashMap<String,(i64,i64,i64,i64)>,
475                  tpase_tbl: &HashMap<String,(i64,i64,i64)>) -> Vec<Call> {
476    let mut by_contig: HashMap<String, Vec<Call>> = HashMap::new();
477    for c in calls { by_contig.entry(c.seqid.clone()).or_default().push(c); }
478    let mut out: Vec<Call> = Vec::new();
479    for (_, cs) in by_contig {
480        let mut cs = cs;
481        // total order on genomic position so chaining is deterministic
482        cs.sort_by(|a, b| a.orf_begin.cmp(&b.orf_begin)
483            .then(a.orf_end.cmp(&b.orf_end))
484            .then(a.strand.cmp(&b.strand)));
485        let n = cs.len();
486        let mut slots: Vec<Option<Call>> = cs.into_iter().map(Some).collect();
487        let mut i = 0;
488        while i < n {
489            let fam = norm_fam(&slots[i].as_ref().unwrap().family);
490            let strand = slots[i].as_ref().unwrap().strand;
491            let mut chain_end = slots[i].as_ref().unwrap().orf_end;
492            let mut j = i + 1;
493            while j < n {
494                let cj = slots[j].as_ref().unwrap();
495                if norm_fam(&cj.family) == fam && cj.strand == strand
496                    && cj.orf_begin - chain_end <= gap {
497                    chain_end = chain_end.max(cj.orf_end);
498                    j += 1;
499                } else { break; }
500            }
501            if j - i == 1 {
502                out.push(slots[i].take().unwrap());
503            } else {
504                let chain: Vec<Call> = (i..j).map(|k| slots[k].take().unwrap()).collect();
505                let mb = chain.iter().map(|c| c.orf_begin).min().unwrap();
506                let me = chain.iter().map(|c| c.orf_end).max().unwrap();
507                // strongest hit = base for family/tier/evalue/qcov/pident (total-order tiebreak)
508                let bi = (0..chain.len()).min_by(|&a, &b|
509                    chain[a].evalue.partial_cmp(&chain[b].evalue).unwrap()
510                        .then(chain[a].orf_begin.cmp(&chain[b].orf_begin))
511                        .then(chain[a].orf_end.cmp(&chain[b].orf_end))
512                        .then(chain[a].strand.cmp(&chain[b].strand))).unwrap();
513                let base = &chain[bi];
514                let label = base.family.clone();
515                let tir = contig_map.get(base.seqid.as_str())
516                    .and_then(|seq| find_tir(seq, mb, me, &label, tir_tbl));
517                let (is_b, is_e, typ) = classify_is(&label, mb, me, &tir, tir_tbl, tpase_tbl);
518                out.push(Call {
519                    seqid: base.seqid.clone(), family: label, tier: base.tier.clone(),
520                    is_begin: is_b, is_end: is_e, is_len: is_e - is_b + 1,
521                    orf_begin: mb, orf_end: me, strand: base.strand,
522                    evalue: base.evalue, qcov: base.qcov, pident: base.pident,
523                    typ, tir, ncopy: 1,
524                    fp_flag: "IS".into(),
525                });
526            }
527            i = j;
528        }
529    }
530    out
531}
532
533// Target-site duplication (TSD): upon insertion an IS duplicates a short host target, leaving an
534// identical DIRECT repeat immediately flanking each end (...[TSD][element][TSD]...). A TSD is thus a
535// homology-INDEPENDENT positive signal of transposition, complementary to the TIR. Family TSD lengths
536// run ~3-14 bp, so we look for a direct repeat of length >= MIN_TSD whose left copy abuts is_begin and
537// right copy abuts is_end, allowing a small shift because the called boundary can be a few bp off.
538// Conservative (flush-ish, exact, >= MIN_TSD) to keep the chance-match rate low.
539const MIN_TSD: usize = 4;
540const MAX_TSD: usize = 14;
541fn has_tsd(seq: &[u8], is_begin: i64, is_end: i64) -> bool {
542    const W: i64 = 12;      // flank window scanned on each side
543    const SHIFT: usize = 2; // boundary-imprecision tolerance
544    let n = seq.len() as i64;
545    let (lb, le) = ((is_begin - 1 - W).max(0), (is_begin - 1).max(0)); // upstream flank [lb,le)
546    let (rb, re) = (is_end.min(n), (is_end + W).min(n));               // downstream flank [rb,re)
547    if le <= lb || re <= rb { return false; }
548    let left = &seq[lb as usize..le as usize];   // ends at the left boundary
549    let right = &seq[rb as usize..re as usize];  // starts at the right boundary
550    for l in (MIN_TSD..=MAX_TSD).rev() {
551        for ls in 0..=SHIFT {
552            if left.len() < l + ls { break; }
553            let lc = &left[left.len() - l - ls..left.len() - ls]; // l bp ending ls before boundary
554            for rs in 0..=SHIFT {
555                if right.len() < l + rs { break; }
556                let rc = &right[rs..rs + l];                       // l bp starting rs after boundary
557                if lc == rc { return true; }
558            }
559        }
560    }
561    false
562}
563
564// ---------------------------------------------------------- (C) nt neg-pos FP-control module
565// The HTH/DDE fold is shared between IS transposases and host proteins (regulators, nucleases,
566// recombinases) at the protein level -> the aa profile search cannot separate them (marA/soxS
567// called IS4). But at NUCLEOTIDE level, IS lineages and host lineages diverge, so the call's nt
568// discriminates. For each call, compare its nt to a positive IS-nt reference (db_dir/fpc/pos) and
569// a negative host-nt reference (db_dir/fpc/neg): pos-lineage match confirms IS; host-lineage match
570// with no IS support flags a likely FP. Two homology-INDEPENDENT structural signals (TIR and TSD)
571// add positive support that survives when nt homology is silent (divergent/novel IS): a call with
572// no nt evidence either way but a TIR/TSD is "putative" (structure suggests IS), not "unresolved";
573// and structure protects a host-leaning call from the droppable "host" verdict (never silently drop
574// a structurally-supported call -- the neg DB is contamination-fragile, TIR/TSD are IS-specific).
575// Validated on MG1655 (marA/soxS/fimB flagged, 49 curated IS retained) and 1500 GTDB strains
576// (85% regulator-FP rejection, robust to genus-level holdout => not overfit).
577fn fp_control_module(calls: &mut [Call], contig_map: &HashMap<&str, &Vec<u8>>,
578                     db_dir: &Path, tmp: &Path, nthread: usize) {
579    let fpc = db_dir.join("fpc");
580    if !fpc.join("refset.dbtype").exists() { return; } // no FP-control DB -> skip (module optional)
581    // write each call's element nt as >c{index}
582    let qfa = tmp.join("fpcq.fna");
583    let mut nq = 0usize;
584    {
585        let mut o = BufWriter::new(fs::File::create(&qfa).unwrap());
586        for (i, c) in calls.iter().enumerate() {
587            if let Some(seq) = contig_map.get(c.seqid.as_str()) {
588                let b = c.is_begin.max(1) as usize; let e = c.is_end as usize;
589                if e <= seq.len() && e >= b {
590                    let mut sub = seq[b-1..e].to_vec();
591                    if c.strand == '-' { sub = revcomp(&sub); }
592                    if sub.len() >= 60 {
593                        writeln!(o, ">c{}", i).unwrap();
594                        o.write_all(&sub).unwrap(); o.write_all(b"\n").unwrap();
595                        nq += 1;
596                    }
597                }
598            }
599        }
600    }
601    if nq == 0 { return; } // no extractable query nt (0 calls / all too short) -> mmseqs createdb would fail; all calls keep default "IS"
602    let run = |args: &[&str]| {
603        let st = Command::new("mmseqs").args(args)
604            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
605            .status().expect("spawn mmseqs");
606        assert!(st.success(), "mmseqs {:?} failed", &args[0]);
607    };
608    let nt = nthread.to_string();
609    let qdb = tmp.join("fpcqdb");
610    run(&["createdb", qfa.to_str().unwrap(), qdb.to_str().unwrap(), "--dbtype", "2", "-v", "0"]);
611    // Best bits per call against the IS(pos) and host(neg) references, in ONE search over the combined
612    // refset (targets prefixed "P|" = IS-lineage, "N|" = host-lineage) -> ~40% faster than two searches.
613    let mut posb: HashMap<usize,f64> = HashMap::new();
614    let mut negb: HashMap<usize,f64> = HashMap::new();
615    let (res, aln, tp) = (tmp.join("fpcres"), tmp.join("fpcaln.tsv"), tmp.join("fpctp"));
616    run(&["search", qdb.to_str().unwrap(), fpc.join("refset").to_str().unwrap(), res.to_str().unwrap(),
617          tp.to_str().unwrap(), "--search-type", "3", "-s", "7", "--max-seqs", "50",
618          "-e", "1e-3", "--threads", &nt, "-v", "0"]);
619    run(&["convertalis", qdb.to_str().unwrap(), fpc.join("refset").to_str().unwrap(), res.to_str().unwrap(),
620          aln.to_str().unwrap(), "--format-output", "query,target,bits", "-v", "0"]);
621    for line in BufReader::new(fs::File::open(&aln).unwrap()).lines() {
622        let line = line.unwrap(); let p: Vec<&str> = line.split('\t').collect();
623        if p.len() < 3 { continue; }
624        let idx = match p[0].strip_prefix('c').and_then(|s| s.parse::<usize>().ok()) { Some(i)=>i, None=>continue };
625        let bits: f64 = p[2].parse().unwrap_or(0.0);
626        let tbl = if let Some(_) = p[1].strip_prefix("P|") { &mut posb }
627                  else if let Some(_) = p[1].strip_prefix("N|") { &mut negb } else { continue };
628        let ent = tbl.entry(idx).or_insert(0.0);
629        if bits > *ent { *ent = bits; }
630    }
631    for (i, c) in calls.iter_mut().enumerate() {
632        let pb = *posb.get(&i).unwrap_or(&0.0);
633        let nb = *negb.get(&i).unwrap_or(&0.0);
634        // homology-independent IS-architecture support (TIR present, or a flanking TSD direct repeat)
635        let structural = c.tir.is_some()
636            || contig_map.get(c.seqid.as_str()).map(|s| has_tsd(s, c.is_begin, c.is_end)).unwrap_or(false);
637        c.fp_flag = fp_verdict(pb, nb, structural).into();
638    }
639}
640
641// The 5-tier fpFlag decision, isolated as a pure fn so it is unit-testable and the semantics are locked.
642// pb/nb = best bits vs the IS(pos)/host(neg) nt references; structural = TIR or TSD present.
643fn fp_verdict(pb: f64, nb: f64, structural: bool) -> &'static str {
644    if pb > 0.0 && pb >= nb {                       // nt matches IS lineage (homology-confirmed)
645        "IS"
646    } else if nb > pb {                             // nt leans host
647        if !structural && pb == 0.0 { "host" }      // host-lineage only, no IS support of any kind -> droppable
648        else { "shared" }                           // matches both / structure-vs-nt conflict -> keep
649    } else {                                        // pb == 0 && nb == 0: no nt evidence either way
650        if structural { "putative" }                // structure (TIR/TSD) suggests IS
651        else { "unresolved" }                       // truly undecided
652    }
653}
654
655// Format like Python's "%.1e" (signed exponent, zero-padded to >=2 digits) so the
656// E-value column is byte-identical to pv2's. Rust's {:.1e} drops the sign and padding
657// (e.g. 0.0 -> "0.0e0"); Python gives "0.0e+00".
658fn py_e(x: f64) -> String {
659    let s = format!("{:.1e}", x);
660    let (m, e) = s.split_once('e').unwrap();
661    let exp: i32 = e.parse().unwrap_or(0);
662    format!("{}e{}{:02}", m, if exp < 0 { "-" } else { "+" }, exp.abs())
663}
664
665pub fn write_tsv(calls: &[Call], path: &Path) {
666    let mut o = BufWriter::new(fs::File::create(path).unwrap());
667    writeln!(o, "seqID\tfamily\ttier\tisBegin\tisEnd\tisLen\tncopy4is\torfBegin\torfEnd\tstrand\tE-value\tqcov\tpident\ttype\tirLen\tirId\tstart2\tend2\ttir\tfpFlag").unwrap();
668    let mut idx: Vec<usize> = (0..calls.len()).collect();
669    idx.sort_by(|&a, &b| (calls[a].seqid.as_str(), calls[a].is_begin)
670        .cmp(&(calls[b].seqid.as_str(), calls[b].is_begin)));
671    for &i in &idx {
672        let c = &calls[i];
673        let (irlen, irid, s2, e2, tirstr) = match &c.tir {
674            Some(t) => (t.irlen, t.irid, t.start2, t.end2,
675                        format!("{}:{}", String::from_utf8_lossy(&t.seq1), String::from_utf8_lossy(&t.seq2))),
676            None => (0, 0, 0, 0, "-".to_string()),
677        };
678        writeln!(o, "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.2}\t{:.0}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
679            c.seqid, c.family, c.tier, c.is_begin, c.is_end, c.is_len, c.ncopy,
680            c.orf_begin, c.orf_end, c.strand, py_e(c.evalue), c.qcov, c.pident, c.typ,
681            irlen, irid, s2, e2, tirstr, c.fp_flag).unwrap();
682    }
683}
684
685pub fn write_gff(calls: &[Call], path: &Path) {
686    let mut o = BufWriter::new(fs::File::create(path).unwrap());
687    writeln!(o, "##gff-version 3").unwrap();
688    let mut idx: Vec<usize> = (0..calls.len()).collect();
689    idx.sort_by(|&a, &b| (calls[a].seqid.as_str(), calls[a].is_begin)
690        .cmp(&(calls[b].seqid.as_str(), calls[b].is_begin)));
691    for (n, &i) in idx.iter().enumerate() {
692        let c = &calls[i];
693        let id = n + 1;
694        writeln!(o, "{}\tisscan\tinsertion_sequence\t{}\t{}\t.\t{}\t.\tID=IS_{};family={};type={};tier={};ncopy={};evalue={};orf={}..{}",
695            c.seqid, c.is_begin, c.is_end, c.strand, id, c.family, c.typ, c.tier, c.ncopy, py_e(c.evalue), c.orf_begin, c.orf_end).unwrap();
696        if c.fp_flag != "IS" {
697            writeln!(o, "# IS_{} fpFlag={}", id, c.fp_flag).unwrap();
698        }
699        if let Some(t) = &c.tir {
700            writeln!(o, "{}\tisscan\tterminal_inverted_repeat\t{}\t{}\t.\t{}\t.\tParent=IS_{};irLen={};irId={}",
701                c.seqid, t.start1, t.end1, c.strand, id, t.irlen, t.irid).unwrap();
702            writeln!(o, "{}\tisscan\tterminal_inverted_repeat\t{}\t{}\t.\t{}\t.\tParent=IS_{};irLen={};irId={}",
703                c.seqid, t.start2, t.end2, c.strand, id, t.irlen, t.irid).unwrap();
704        }
705    }
706}
707
708pub fn write_sum(calls: &[Call], path: &Path) {
709    let mut o = BufWriter::new(fs::File::create(path).unwrap());
710    let mut byfam: HashMap<String, Vec<usize>> = HashMap::new();
711    for (i, c) in calls.iter().enumerate() {
712        byfam.entry(c.family.split('-').next().unwrap().to_string()).or_default().push(i);
713    }
714    let mut fams: Vec<&String> = byfam.keys().collect();
715    // total order: nIS desc, then family name asc — deterministic on count ties.
716    fams.sort_by(|a, b| byfam[*b].len().cmp(&byfam[*a].len()).then_with(|| a.cmp(b)));
717    writeln!(o, "family\tnIS\tcomplete\tpartial\tmeanIsLen").unwrap();
718    let (mut tot, mut nc, mut np) = (0i64, 0i64, 0i64);
719    for f in fams {
720        let cs = &byfam[f];
721        let comp = cs.iter().filter(|&&i| calls[i].typ == 'c').count() as i64;
722        let part = cs.len() as i64 - comp;
723        let mean: i64 = cs.iter().map(|&i| calls[i].is_len).sum::<i64>() / cs.len() as i64;
724        writeln!(o, "{}\t{}\t{}\t{}\t{}", f, cs.len(), comp, part, mean).unwrap();
725        tot += cs.len() as i64; nc += comp; np += part;
726    }
727    writeln!(o, "TOTAL\t{}\t{}\t{}\t-", tot, nc, np).unwrap();
728    // FP-control summary: how many calls the nt neg-pos discriminator supports vs flags
729    if calls.iter().any(|c| c.fp_flag != "IS") {
730        let cnt = |f: &str| calls.iter().filter(|c| c.fp_flag == f).count();
731        writeln!(o, "#fpControl\tIS\tputative\tshared\thost\tunresolved").unwrap();
732        writeln!(o, "#fpControl\t{}\t{}\t{}\t{}\t{}",
733            cnt("IS"), cnt("putative"), cnt("shared"), cnt("host"), cnt("unresolved")).unwrap();
734    }
735}
736
737// ---------------------------------------------------------- tables (== constants_isscan.py)
738fn tir_table() -> HashMap<String,(i64,i64,i64,i64)> {
739    [("IS1",(8,67,14,1)),("IS110",(2,31,14,-1)),("IS1182",(8,44,10,1)),("IS1380",(7,39,10,1)),
740     ("IS1595",(10,43,15,1)),("IS1634",(11,32,12,1)),("IS200/IS605",(10000,0,10000,0)),
741     ("IS21",(8,76,10,1)),("IS256",(8,48,15,1)),("IS3",(7,54,10,-1)),("IS30",(11,50,12,1)),
742     ("IS4",(8,67,12,1)),("IS481",(5,52,10,1)),("IS5",(7,45,14,1)),("IS6",(12,36,14,1)),
743     ("IS607",(12,46,12,-1)),("IS630",(3,92,11,1)),("IS66",(11,144,11,1)),("IS701",(12,38,12,1)),
744     ("IS91",(11,21,11,-1)),("IS982",(11,35,11,1)),("ISAS1",(12,34,12,1)),("ISAZO13",(18,48,18,1)),
745     ("ISH3",(11,31,15,1)),("ISKRA4",(15,40,18,1)),("ISL3",(6,50,11,1)),("ISNCY",(4,52,13,-1)),
746     ("new",(10,50,20,-1))]
747    .iter().map(|(k,v)| (norm_fam(k), *v)).collect()   // normalized keys -> O(1) lookup
748}
749fn tpase_table() -> HashMap<String,(i64,i64,i64)> {
750    [("IS1",(666,1119,252)),("IS110",(603,1380,156)),("IS1182",(822,1731,570)),("IS1380",(1158,1554,1158)),
751     ("IS1595",(576,1158,426)),("IS1634",(1314,1875,1314)),("IS200/IS605",(366,1482,147)),("IS21",(882,1758,231)),
752     ("IS256",(990,1389,990)),("IS3",(441,1581,120)),("IS30",(540,1419,189)),("IS4",(570,1629,219)),
753     ("IS481",(447,1794,447)),("IS5",(360,1908,75)),("IS6",(528,1062,246)),("IS607",(768,1653,453)),
754     ("IS630",(510,1194,318)),("IS66",(354,1695,165)),("IS701",(921,1410,921)),("IS91",(648,1548,648)),
755     ("IS982",(627,981,429)),("ISAS1",(594,1329,189)),("ISAZO13",(1203,2094,513)),("ISH3",(573,1206,549)),
756     ("ISKRA4",(1047,1719,114)),("ISL3",(414,1716,408)),("ISNCY",(573,1815,123)),("new",(300,2100,50))]
757    .iter().map(|(k,v)| (norm_fam(k), *v)).collect()   // normalized keys -> O(1) lookup
758}
759
760// ---------------------------------------------------------- main
761/// Configuration for [`run`].
762pub struct IseConfig {
763    /// mmseqs2 thread count.
764    pub threads: usize,
765    /// Use MMseqs2 GPU acceleration.
766    pub gpu: bool,
767    /// Precision mode: drop `fpFlag == "host"` (host-lineage FP) calls.
768    pub strict: bool,
769    /// IS database directory (must contain `mmdb_union/profileDb*` + `manifest_union.tsv`).
770    pub db_dir: PathBuf,
771}
772
773/// Run the IS-element detection pipeline on a genome FASTA, returning the calls.
774///
775/// `work_dir` is where the intermediate files (`proteome.faa`, mmseqs
776/// `isscan_tmp/`) are written; the caller owns it and any cleanup. Requires the
777/// `mmseqs` binary on PATH and a valid IS DB at `config.db_dir`. This is the exact
778/// pipeline the `rust-ise` binary runs (the binary just parses args and writes the
779/// TSV/GFF/.sum from the returned calls).
780pub fn run(seqfile: &Path, work_dir: &Path, config: &IseConfig) -> Result<Vec<Call>, String> {
781    if !config
782        .db_dir
783        .join("mmdb_union")
784        .join("profileDb.dbtype")
785        .exists()
786    {
787        return Err(format!(
788            "'{}' is not a valid isscan DB (missing mmdb_union/profileDb)",
789            config.db_dir.display()
790        ));
791    }
792    fs::create_dir_all(work_dir).map_err(|e| format!("create {}: {e}", work_dir.display()))?;
793    let tmp = work_dir.join("isscan_tmp");
794    fs::create_dir_all(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?;
795
796    let seqfile = seqfile.to_str().ok_or("non-UTF8 seqfile path")?;
797    let contigs = read_fasta(seqfile);
798    let contig_map: HashMap<&str, &Vec<u8>> =
799        contigs.iter().map(|(k, v)| (k.as_str(), v)).collect();
800
801    // 1. ORFs: rustygal META gene-finding (rust-pure, no subprocess) + native edge recovery.
802    let meta = rustygal::meta_api::meta_bins();
803    let per_contig: Vec<Vec<u8>> = contigs
804        .par_iter()
805        .enumerate()
806        .map(|(i, (hdr, dna))| rustygal::meta_api::run_meta(i as i32 + 1, hdr, dna, &meta).trans_faa)
807        .collect();
808    let faa: Vec<u8> = per_contig.concat();
809    let mut orfs = parse_rustygal_faa(&faa);
810    let edges: Vec<(String, Vec<Orf>)> = contigs
811        .par_iter()
812        .map(|(cid, seq)| {
813            let empty = Vec::new();
814            let pyr = orfs.get(cid).unwrap_or(&empty);
815            (cid.clone(), edge_orfs(seq, pyr, 30, 3))
816        })
817        .collect();
818    for (cid, mut e) in edges {
819        orfs.entry(cid).or_default().append(&mut e);
820    }
821    // proteome.faa headers {cid}_{b}_{e}_{strand}[_edge], contigs in FASTA order
822    // (deterministic byte-order == pv2; mmseqs prefilter is order-sensitive).
823    let proteome = work_dir.join("proteome.faa");
824    {
825        let mut o = BufWriter::new(fs::File::create(&proteome).map_err(|e| e.to_string())?);
826        for (cid, _) in &contigs {
827            if let Some(list) = orfs.get(cid) {
828                for orf in list {
829                    let suffix = if orf.edge { "_edge" } else { "" };
830                    writeln!(o, ">{}_{}_{}_{}{}", cid, orf.begin, orf.end, orf.strand, suffix)
831                        .map_err(|e| e.to_string())?;
832                    o.write_all(&orf.prot).map_err(|e| e.to_string())?;
833                    o.write_all(b"\n").map_err(|e| e.to_string())?;
834                }
835            }
836        }
837    }
838
839    // 2. search
840    let hits = mmseqs_search(&proteome, &config.db_dir, &tmp, config.threads, config.gpu);
841
842    // 3. family call + TIR + classify (parallel over ORFs)
843    let tir_tbl = tir_table();
844    let tpase_tbl = tpase_table();
845    let hit_vec: Vec<(&String, &Vec<Hit>)> = hits.iter().collect();
846    let raw_calls: Vec<Call> = hit_vec
847        .par_iter()
848        .filter_map(|(orf, oh)| {
849            let (label, tier, best) = family_call(oh)?;
850            if tier == "novel" {
851                return None;
852            }
853            // parse orf id: contig_begin_end_strand[_edge]
854            let core: &str = if orf.ends_with("_edge") {
855                &orf[..orf.len() - 5]
856            } else {
857                orf
858            };
859            let mut it = core.rsplitn(4, '_');
860            let strand = it.next()?;
861            let e: i64 = it.next()?.parse().ok()?;
862            let b: i64 = it.next()?.parse().ok()?;
863            let contig = it.next()?;
864            let strand_c = strand.chars().next().unwrap_or('+');
865            let tir = contig_map
866                .get(contig)
867                .and_then(|seq| find_tir(seq, b, e, &label, &tir_tbl));
868            let (is_b, is_e, typ) = classify_is(&label, b, e, &tir, &tir_tbl, &tpase_tbl);
869            Some(Call {
870                seqid: contig.to_string(),
871                family: label,
872                tier,
873                is_begin: is_b,
874                is_end: is_e,
875                is_len: is_e - is_b + 1,
876                orf_begin: b,
877                orf_end: e,
878                strand: strand_c,
879                evalue: best.evalue,
880                qcov: best.qcov,
881                pident: best.pident,
882                typ,
883                tir,
884                ncopy: 1,
885                fp_flag: "IS".into(),
886            })
887        })
888        .collect();
889    let calls = dedup(raw_calls, 0.5);
890    // collapse two-ORF (orfA+orfB) IS into one element (see merge_adjacent)
891    let mut calls = merge_adjacent(calls, 50, &contig_map, &tir_tbl, &tpase_tbl);
892
893    // 3b. nt neg-pos FP-control: flag host-lineage calls
894    fp_control_module(&mut calls, &contig_map, &config.db_dir, &tmp, config.threads);
895    if config.strict {
896        calls.retain(|c| c.fp_flag != "host"); // precision mode: drop host-lineage FPs
897    }
898    Ok(calls)
899}
900
901// ---------------------------------------------------------- unit tests (golden: lock the logic)
902#[cfg(test)]
903mod tests {
904    use super::*;
905
906    #[test]
907    fn fp_verdict_five_tiers() {
908        // IS: nt matches IS lineage (pb>0 & pb>=nb), incl. the tie pb==nb>0
909        assert_eq!(fp_verdict(100.0, 0.0, false), "IS");
910        assert_eq!(fp_verdict(100.0, 100.0, false), "IS");
911        // shared: nt leans host but pb>0 (matches both, host closer)
912        assert_eq!(fp_verdict(50.0, 100.0, false), "shared");
913        // host: host-lineage only, pb==0, no structure -> the only droppable tier
914        assert_eq!(fp_verdict(0.0, 100.0, false), "host");
915        // structure PROTECTS a host-leaning call from "host" -> "shared" (never silently dropped)
916        assert_eq!(fp_verdict(0.0, 100.0, true), "shared");
917        // no nt evidence either way: structure -> putative, else unresolved
918        assert_eq!(fp_verdict(0.0, 0.0, true), "putative");
919        assert_eq!(fp_verdict(0.0, 0.0, false), "unresolved");
920    }
921
922    #[test]
923    fn tsd_detected_and_rejected() {
924        // ...[GGGGGG][ACGTAC]<TTTTTTTTTT>[ACGTAC][CCCCCC]... : 6bp TSD flanking element at 1-based [13,22]
925        let s = b"GGGGGGACGTACTTTTTTTTTTACGTACCCCCC";
926        assert!(has_tsd(s, 13, 22), "flanking 6bp direct repeat should be found");
927        // no flanking direct repeat -> false
928        let s2 = b"GGGGGGGGGGGGTTTTTTTTTTCCCCCCCCCCCC";
929        assert!(!has_tsd(s2, 13, 22), "distinct flanks have no TSD");
930        // 3bp repeat is below MIN_TSD(4) -> not counted
931        let s3 = b"GGGGGGGGGACGTTTTTTTTTTACGCCCCCCCCC";
932        assert!(!has_tsd(s3, 13, 22), "3bp repeat must not count as TSD");
933    }
934
935    #[test]
936    fn revcomp_translate_norm() {
937        assert_eq!(revcomp(b"ACGT"), b"ACGT");           // palindrome
938        assert_eq!(revcomp(b"AAAA"), b"TTTT");
939        assert_eq!(revcomp(b"ATGC"), b"GCAT");
940        assert_eq!(translate(b"ATGAAA"), b"MK");         // ATG=Met, AAA=Lys
941        assert_eq!(norm_fam("IS200/IS605"), "IS200IS605");
942        assert_eq!(norm_fam("is_3"), "IS3");
943    }
944
945    #[test]
946    fn py_e_matches_python_format() {
947        assert_eq!(py_e(0.0), "0.0e+00");
948        assert_eq!(py_e(1e-5), "1.0e-05");
949    }
950
951    // helper: a plausible IS transposase hit, tweak the fields under test
952    fn hit(evalue: f64, qcov: f64, pident: f64) -> Hit {
953        Hit { fam: "IS3".into(), evalue, qcov, pident, tstart: 1, tend: 300 }
954    }
955
956    #[test]
957    fn family_call_sig_evalue_floor() {
958        // a hit right at the floor is accepted; a hit just weaker than the floor is rejected.
959        assert!(family_call(&[hit(SIG_EVALUE, 0.9, 90.0)]).is_some(),
960            "E-value == SIG_EVALUE must pass");
961        assert!(family_call(&[hit(SIG_EVALUE * 10.0, 0.9, 90.0)]).is_none(),
962            "E-value weaker than SIG_EVALUE must be rejected");
963        // sanity: the pre-tightening 1e-10 regime is now rejected (that was the FP-admitting setting).
964        assert!(family_call(&[hit(1e-10, 0.9, 90.0)]).is_none(),
965            "1e-10 hits must no longer qualify after tightening to 1e-30");
966    }
967
968    #[test]
969    fn family_call_qcov_floor() {
970        // strongly significant but below the coverage floor -> rejected as a fragmentary hit.
971        assert!(family_call(&[hit(1e-50, SIG_QCOV - 0.01, 90.0)]).is_none(),
972            "qcov below SIG_QCOV must be rejected even when E-value is strong");
973        // at/above the floor with a strong E-value -> accepted.
974        assert!(family_call(&[hit(1e-50, SIG_QCOV, 90.0)]).is_some(),
975            "qcov == SIG_QCOV must pass");
976        // the floor is deliberately low so mid-coverage true IS calls survive (recall protection).
977        assert!(family_call(&[hit(1e-50, 0.55, 90.0)]).is_some(),
978            "mid-coverage (0.55) real IS hits must be retained");
979    }
980}