Skip to main content

cuttlefish_rs/
uncolored.rs

1//! End-to-end uncolored graph construction.
2//!
3//! The production entry point streams local contraction into the external
4//! discontinuity pipeline. A global in-memory contractor remains available
5//! only as a debug/reference implementation.
6
7use crate::Side;
8use crate::buckets::{BucketError, BucketStore};
9use crate::discontinuity::{
10    SerialUncoloredCollator, emit_uncolored_external_discontinuity_inputs_with_threads_in_dir,
11    report_process_memory, spawn_background_dir_removal, trim_process_allocations,
12};
13use crate::dna::{Base, complement_ascii};
14use crate::hash::FastBuildHasher;
15use crate::kmer::{Kmer, KmerError};
16use crate::params::BuildParams;
17use crate::state::VertexState;
18use crate::subgraph::LocalSubgraphError;
19use std::collections::{BTreeSet, HashMap};
20use std::fs::File;
21use std::io::{BufWriter, Write};
22use std::path::{Path, PathBuf};
23use std::time::Instant;
24
25type FastHashMap<K, V> = HashMap<K, V, FastBuildHasher>;
26
27/// Summary of a completed uncolored graph build.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct UncoloredBuildStats {
30    pub input_buckets: usize,
31    pub bucket_records: u64,
32    pub observed_edges: u64,
33    pub retained_edges: u64,
34    pub unitigs: u64,
35    pub unitig_bases: u64,
36    pub output_path: PathBuf,
37}
38
39/// Builds an uncolored compacted de Bruijn graph from partition buckets.
40///
41/// The external-memory path is used unless the debug contractor environment
42/// switch is explicitly enabled.
43pub fn build_uncolored_from_buckets<const K: usize>(
44    params: &BuildParams,
45    bucket_dir: impl AsRef<Path>,
46) -> Result<UncoloredBuildStats, UncoloredBuildError> {
47    if std::env::var_os("CF3_RS_DEBUG_GLOBAL_CONTRACTOR").is_some() {
48        build_uncolored_with_debug_global_contractor::<K>(params, bucket_dir)
49    } else {
50        build_uncolored_with_serial_discontinuity_pipeline::<K>(params, bucket_dir)
51    }
52}
53
54pub fn build_uncolored_with_debug_global_contractor<const K: usize>(
55    params: &BuildParams,
56    bucket_dir: impl AsRef<Path>,
57) -> Result<UncoloredBuildStats, UncoloredBuildError> {
58    if params.color {
59        return Err(UncoloredBuildError::ColoredUnsupported);
60    }
61
62    let bucket_dir = bucket_dir.as_ref();
63    let cutoff = params.cutoff();
64    let mut bucket_records = 0u64;
65    let mut observed_edges = 0u64;
66    let mut graph = DebugGlobalCanonicalGraph::<K>::new(cutoff);
67
68    let (store, entries) = BucketStore::open_dir(bucket_dir)?;
69    for entry in &entries {
70        let mut reader = store.reader(entry)?;
71        let header = reader.header();
72        if header.k != params.k || header.minimizer_len != params.minimizer_len || header.colored {
73            return Err(UncoloredBuildError::BucketParamsMismatch {
74                path: bucket_dir.to_path_buf(),
75            });
76        }
77
78        while let Some(record) = reader.next_record()? {
79            bucket_records += 1;
80            observed_edges += graph.add_label(&record.label)?;
81        }
82    }
83
84    let retained_edges = graph.unique_edges.len() as u64;
85    let mut unitigs = graph.contract()?;
86    normalize_unitigs(&mut unitigs);
87
88    let output_path = PathBuf::from(format!("{}.fa", params.output_prefix));
89    write_fasta(&output_path, &unitigs)?;
90
91    Ok(UncoloredBuildStats {
92        input_buckets: entries.len(),
93        bucket_records,
94        observed_edges,
95        retained_edges,
96        unitigs: unitigs.len() as u64,
97        unitig_bases: unitigs.iter().map(|u| u.len() as u64).sum(),
98        output_path,
99    })
100}
101
102pub fn build_uncolored_with_serial_discontinuity_pipeline<const K: usize>(
103    params: &BuildParams,
104    bucket_dir: impl AsRef<Path>,
105) -> Result<UncoloredBuildStats, UncoloredBuildError> {
106    if params.color {
107        return Err(UncoloredBuildError::ColoredUnsupported);
108    }
109
110    let local_start = Instant::now();
111    report_process_memory("before local contraction");
112    let output_name = Path::new(&params.output_prefix)
113        .file_name()
114        .and_then(|s| s.to_str())
115        .filter(|s| !s.is_empty())
116        .unwrap_or("cuttlefish3");
117    let label_path =
118        PathBuf::from(&params.work_dir).join(format!("{output_name}.cf3rs.lmtig-labels"));
119    let bucket_dir = bucket_dir.as_ref().to_path_buf();
120    let local_threads = params.local_workers();
121    eprintln!("cuttlefish: local contraction using {local_threads} worker(s)");
122    let output_path = PathBuf::from(format!("{}.fa", params.output_prefix));
123    let mut inputs = emit_uncolored_external_discontinuity_inputs_with_threads_in_dir::<K>(
124        &bucket_dir,
125        params.cutoff(),
126        local_threads,
127        &label_path,
128        Some(&output_path),
129    )?;
130    let local_elapsed = local_start.elapsed();
131    report_process_memory("after local contraction before trim");
132    eprintln!(
133        "cuttlefish: local contraction emitted {} unitig(s), {} discontinuity exit(s)",
134        inputs.stats.local_unitigs, inputs.stats.discontinuity_exits
135    );
136    eprintln!(
137        "cuttlefish: local contraction phase completed in {:.3}s",
138        local_elapsed.as_secs_f64()
139    );
140    // With buckets in containers this is a bulk delete of 129 files holding
141    // hundreds of gigabytes, not the single leftover manifest the per-file
142    // layout left behind -- local contraction unlinked those as it consumed
143    // them. So it goes to the background unlinkers, like the edge-matrix and
144    // expansion directories, and is joined before the build returns.
145    let bucket_reclaim = (std::env::var_os("CF3_RS_KEEP_INTERMEDIATES").is_none())
146        .then(|| spawn_background_dir_removal(bucket_dir.clone()));
147    trim_process_allocations();
148    report_process_memory("after local contraction trim");
149    eprintln!("cuttlefish: collating final unitigs");
150    let collation_start = Instant::now();
151    report_process_memory("before collation");
152    let coord_dir =
153        PathBuf::from(&params.work_dir).join(format!("{output_name}.cf3rs.stitch-coords"));
154    let final_dir =
155        PathBuf::from(&params.work_dir).join(format!("{output_name}.cf3rs.final-unitigs"));
156    eprintln!("cuttlefish: writing FASTA to {}", output_path.display());
157    let post_local_threads = params.post_local_workers();
158    eprintln!("cuttlefish: collation using {post_local_threads} worker(s)");
159    let stats = SerialUncoloredCollator::collate_external_stitched_to_fasta_with_threads_in_dir(
160        &mut inputs,
161        post_local_threads,
162        &coord_dir,
163        &final_dir,
164        &output_path,
165    )?;
166    let collation_elapsed = collation_start.elapsed();
167    report_process_memory("after collation");
168    eprintln!(
169        "cuttlefish: collation and FASTA write completed in {:.3}s",
170        collation_elapsed.as_secs_f64()
171    );
172
173    if let Some(handle) = bucket_reclaim {
174        let _ = handle.join();
175    }
176
177    Ok(UncoloredBuildStats {
178        input_buckets: inputs.stats.input_buckets,
179        bucket_records: inputs.stats.weak_superkmers,
180        observed_edges: inputs.stats.discontinuity_exits,
181        retained_edges: inputs.stats.discontinuity_exits,
182        unitigs: stats.emitted_unitigs,
183        unitig_bases: stats.emitted_bases,
184        output_path,
185    })
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
189struct CanonicalEdge<const K: usize> {
190    from: Kmer<K>,
191    to: Kmer<K>,
192}
193
194struct DebugGlobalCanonicalGraph<const K: usize> {
195    cutoff: u32,
196    vertices: FastHashMap<Kmer<K>, VertexState>,
197    unique_edges: BTreeSet<CanonicalEdge<K>>,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201struct DirectedKmer<const K: usize> {
202    observed: Kmer<K>,
203}
204
205impl<const K: usize> DirectedKmer<K> {
206    fn new(observed: Kmer<K>) -> Self {
207        Self { observed }
208    }
209
210    #[inline]
211    fn canonical(self) -> Kmer<K> {
212        self.observed.canonical()
213    }
214
215    #[inline]
216    fn in_canonical_form(self) -> bool {
217        self.observed.is_canonical()
218    }
219
220    #[inline]
221    fn entrance_side(self) -> Side {
222        if self.in_canonical_form() {
223            Side::Front
224        } else {
225            Side::Back
226        }
227    }
228
229    #[inline]
230    fn roll_forward(self, base: Base) -> Self {
231        Self::new(self.observed.roll_forward(base))
232    }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236struct UnitigWalk<const K: usize> {
237    label: Vec<u8>,
238    anchor: Kmer<K>,
239}
240
241impl<const K: usize> UnitigWalk<K> {
242    fn init(v: DirectedKmer<K>) -> Self {
243        Self {
244            label: v.observed.to_ascii_string().into_bytes(),
245            anchor: v.canonical(),
246        }
247    }
248
249    fn extend(&mut self, v: DirectedKmer<K>, base: Base) -> bool {
250        if v.canonical() == self.anchor {
251            return false;
252        }
253
254        self.label.push(base.to_ascii());
255        true
256    }
257}
258
259impl<const K: usize> DebugGlobalCanonicalGraph<K> {
260    fn new(cutoff: u32) -> Self {
261        Self {
262            cutoff,
263            vertices: FastHashMap::default(),
264            unique_edges: BTreeSet::new(),
265        }
266    }
267
268    fn add_label(&mut self, label: &[u8]) -> Result<u64, KmerError> {
269        if label.len() < K {
270            return Ok(0);
271        }
272
273        let last_vertex_offset = label.len() - K;
274        let mut prev = None;
275        let mut observed_edges = 0u64;
276        for offset in 0..=last_vertex_offset {
277            let directed = DirectedKmer::new(Kmer::<K>::from_ascii(&label[offset..offset + K])?);
278            let canonical = directed.canonical();
279            let pred_base = if offset == 0 {
280                Base::E
281            } else {
282                Base::from_ascii(label[offset - 1])
283            };
284            let succ_base = if offset == last_vertex_offset {
285                Base::E
286            } else {
287                Base::from_ascii(label[offset + K])
288            };
289            let mut front = if directed.in_canonical_form() {
290                pred_base
291            } else {
292                succ_base.complement()
293            };
294            let mut back = if directed.in_canonical_form() {
295                succ_base
296            } else {
297                pred_base.complement()
298            };
299
300            if offset > 0 && Some(canonical) == prev {
301                if directed.in_canonical_form() {
302                    front = Base::E;
303                } else {
304                    back = Base::E;
305                }
306            }
307
308            self.vertices
309                .entry(canonical)
310                .or_default()
311                .update_edges(front, back);
312
313            if let Some(from) = prev {
314                self.unique_edges.insert(CanonicalEdge {
315                    from,
316                    to: canonical,
317                });
318                observed_edges += 1;
319            }
320            prev = Some(canonical);
321        }
322
323        Ok(observed_edges)
324    }
325
326    fn contract(&mut self) -> Result<Vec<Vec<u8>>, UncoloredBuildError> {
327        let mut unitigs = Vec::new();
328        let mut vertices = self.vertices.keys().copied().collect::<Vec<_>>();
329        vertices.sort_unstable();
330
331        for v_hat in vertices {
332            let Some(state) = self.vertices.get(&v_hat).copied() else {
333                continue;
334            };
335            if state.is_visited() || state.is_isolated(self.cutoff) {
336                continue;
337            }
338
339            unitigs.push(self.extract_maximal_unitig(v_hat)?);
340        }
341
342        Ok(unitigs)
343    }
344
345    fn extract_maximal_unitig(&mut self, v_hat: Kmer<K>) -> Result<Vec<u8>, UncoloredBuildError> {
346        let (back_walk, back_is_cycle) = self.walk_unitig(v_hat, Side::Back)?;
347        if back_is_cycle {
348            return Ok(canonical_label(back_walk.label));
349        }
350
351        let (front_walk, _) = self.walk_unitig(v_hat, Side::Front)?;
352        let mut label = reverse_complement_label(&front_walk.label);
353        label.extend_from_slice(&back_walk.label[K..]);
354        Ok(canonical_label(label))
355    }
356
357    fn walk_unitig(
358        &mut self,
359        v_hat: Kmer<K>,
360        start_side: Side,
361    ) -> Result<(UnitigWalk<K>, bool), UncoloredBuildError> {
362        let icc_return_side = start_side.inverse();
363        let mut v = if start_side == Side::Back {
364            DirectedKmer::new(v_hat)
365        } else {
366            DirectedKmer::new(v_hat.reverse_complement())
367        };
368        let mut side = start_side;
369        let mut walk = UnitigWalk::init(v);
370
371        loop {
372            let canonical = v.canonical();
373            let state = *self
374                .vertices
375                .get(&canonical)
376                .ok_or(UncoloredBuildError::MissingVertex)?;
377            self.vertices
378                .get_mut(&canonical)
379                .ok_or(UncoloredBuildError::MissingVertex)?
380                .mark_visited();
381
382            let mut edge = state.edge_at(side, self.cutoff);
383            if edge == Base::N || edge == Base::E {
384                return Ok((walk, false));
385            }
386
387            if side == Side::Front {
388                edge = edge.complement();
389            }
390            v = v.roll_forward(edge);
391
392            let next_state = *self
393                .vertices
394                .get(&v.canonical())
395                .ok_or(UncoloredBuildError::MissingVertex)?;
396            side = v.entrance_side();
397            if next_state.is_branching_side(side, self.cutoff) {
398                return Ok((walk, false));
399            }
400            if next_state.is_visited() {
401                return Ok((walk, v.canonical() == v_hat && side == icc_return_side));
402            }
403
404            if !walk.extend(v, edge) {
405                return Ok((walk, false));
406            }
407            side = side.inverse();
408        }
409    }
410}
411
412fn normalize_unitigs(unitigs: &mut Vec<Vec<u8>>) {
413    for label in unitigs.iter_mut() {
414        let rc = reverse_complement_label(label);
415        if rc < *label {
416            *label = rc;
417        }
418    }
419
420    unitigs.sort_unstable();
421    unitigs.dedup();
422}
423
424fn reverse_complement_label(label: &[u8]) -> Vec<u8> {
425    label
426        .iter()
427        .rev()
428        .map(|&base| complement_ascii(base))
429        .collect()
430}
431
432fn canonical_label(label: Vec<u8>) -> Vec<u8> {
433    let rc = reverse_complement_label(&label);
434    if rc < label { rc } else { label }
435}
436
437fn write_fasta(path: &Path, unitigs: &[Vec<u8>]) -> Result<(), UncoloredBuildError> {
438    let file = File::create(path).map_err(|source| UncoloredBuildError::Io {
439        path: path.to_path_buf(),
440        source,
441    })?;
442    let mut out = BufWriter::new(file);
443
444    for label in unitigs {
445        writeln!(out, ">0").map_err(|source| UncoloredBuildError::Io {
446            path: path.to_path_buf(),
447            source,
448        })?;
449        out.write_all(label)
450            .and_then(|_| out.write_all(b"\n"))
451            .map_err(|source| UncoloredBuildError::Io {
452                path: path.to_path_buf(),
453                source,
454            })?;
455    }
456
457    out.flush().map_err(|source| UncoloredBuildError::Io {
458        path: path.to_path_buf(),
459        source,
460    })
461}
462
463#[derive(Debug)]
464pub enum UncoloredBuildError {
465    Bucket(BucketError),
466    DiscontinuityInput(crate::discontinuity::DiscontinuityInputError),
467    SerialCollation(crate::discontinuity::SerialCollationError),
468    SerialEdgeMatrix(crate::discontinuity::SerialEdgeMatrixError),
469    LocalSubgraph(LocalSubgraphError),
470    Kmer(KmerError),
471    Io {
472        path: PathBuf,
473        source: std::io::Error,
474    },
475    ColoredUnsupported,
476    BucketParamsMismatch {
477        path: PathBuf,
478    },
479    MissingVertex,
480}
481
482impl From<BucketError> for UncoloredBuildError {
483    fn from(value: BucketError) -> Self {
484        Self::Bucket(value)
485    }
486}
487
488impl From<crate::discontinuity::DiscontinuityInputError> for UncoloredBuildError {
489    fn from(value: crate::discontinuity::DiscontinuityInputError) -> Self {
490        Self::DiscontinuityInput(value)
491    }
492}
493
494impl From<crate::discontinuity::SerialCollationError> for UncoloredBuildError {
495    fn from(value: crate::discontinuity::SerialCollationError) -> Self {
496        Self::SerialCollation(value)
497    }
498}
499
500impl From<crate::discontinuity::SerialEdgeMatrixError> for UncoloredBuildError {
501    fn from(value: crate::discontinuity::SerialEdgeMatrixError) -> Self {
502        Self::SerialEdgeMatrix(value)
503    }
504}
505
506impl From<LocalSubgraphError> for UncoloredBuildError {
507    fn from(value: LocalSubgraphError) -> Self {
508        Self::LocalSubgraph(value)
509    }
510}
511
512impl From<KmerError> for UncoloredBuildError {
513    fn from(value: KmerError) -> Self {
514        Self::Kmer(value)
515    }
516}
517
518impl std::fmt::Display for UncoloredBuildError {
519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520        match self {
521            Self::Bucket(err) => write!(f, "{err}"),
522            Self::DiscontinuityInput(err) => write!(f, "{err}"),
523            Self::SerialCollation(err) => write!(f, "{err}"),
524            Self::SerialEdgeMatrix(err) => write!(f, "{err}"),
525            Self::LocalSubgraph(err) => write!(f, "{err}"),
526            Self::Kmer(err) => write!(f, "{err}"),
527            Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
528            Self::ColoredUnsupported => {
529                write!(
530                    f,
531                    "colored graph output is not implemented in the Rust path yet"
532                )
533            }
534            Self::BucketParamsMismatch { path } => write!(
535                f,
536                "weak-superkmer bucket parameters do not match the build request: {}",
537                path.display()
538            ),
539            Self::MissingVertex => write!(f, "canonical graph edge references a missing vertex"),
540        }
541    }
542}
543
544impl std::error::Error for UncoloredBuildError {}