Skip to main content

cuttlefish_rs/
partition.rs

1//! Weak-super-k-mer partitioning and bucket emission.
2//!
3//! The production partitioner dynamically schedules size-sorted inputs, scans
4//! packed rolling windows, and drains worker-local 128-subgraph atlas chunks to
5//! [`crate::buckets`]. Graph IDs are stable across colored and uncolored paths.
6
7use crate::buckets::{BucketEmitStats, SharedBucketEmitter, SharedBucketSink};
8use crate::dna::{ascii_base_bits, valid_ascii_base_bits};
9use crate::hash::{hash_u64, wyhash_u64};
10use crate::input::{
11    BorrowedSequenceFragment, InputError, expand_input_paths, parse_fragments,
12    parse_fragments_borrowed, parse_fragments_borrowed_with,
13};
14use crate::params::{BuildParams, ParamError};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicUsize, Ordering};
17use std::sync::{Arc, mpsc};
18use std::time::{Duration, Instant};
19
20const PARTITION_FRAGMENT_BATCH: usize = 16 * 1024;
21const PARTITION_BATCH_BASES: usize = 8 * 1024 * 1024;
22
23/// Fragment-batch geometry for the streamed partitioner.
24///
25/// Batches are deliberately much smaller than [`PARTITION_BATCH_BASES`] so that
26/// a bounded pool keeps every worker fed without holding gigabytes in flight.
27const STREAM_BATCH_BASES: usize = 1024 * 1024;
28const STREAM_BATCH_FRAGMENTS: usize = 8 * 1024;
29/// Upper bound on the bytes held by the streamed partitioner's batch pool.
30const STREAM_POOL_BYTES: usize = 256 * 1024 * 1024;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33/// A weak super-k-mer assigned to one local subgraph.
34///
35/// `offset` and `len` address a fragment supplied to [`partition_fragment`].
36pub struct WeakSuperKmer {
37    pub graph_id: usize,
38    pub offset: usize,
39    pub len: usize,
40    pub source_id: Option<u32>,
41    pub left_discontinuous: bool,
42    pub right_discontinuous: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46/// Aggregate parser and weak-super-k-mer partitioning counts.
47pub struct PartitionStats {
48    pub input_files: usize,
49    pub records: u64,
50    pub fragments: u64,
51    pub fragment_bases: u64,
52    pub weak_superkmers: u64,
53    pub weak_superkmer_bases: u64,
54    pub graph_histogram: Vec<u64>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58/// Partition counts, external bucket manifest, and phase timings.
59pub struct PartitionEmissionStats {
60    pub partition: PartitionStats,
61    pub buckets: BucketEmitStats,
62    pub parse_elapsed: Duration,
63    pub worker_elapsed: Duration,
64    pub bucket_flush_elapsed: Duration,
65    pub bucket_flushes: u64,
66    pub bucket_finish_elapsed: Duration,
67}
68
69struct PartitionWorkerOutput {
70    stats: PartitionStats,
71    elapsed: Duration,
72}
73
74struct PartitionFragmentBatch {
75    fragments: Vec<PartitionBatchFragment>,
76    bases: Vec<u8>,
77}
78
79struct PartitionBatchFragment {
80    source_id: u32,
81    offset: usize,
82    len: usize,
83}
84
85impl PartitionStats {
86    pub fn new(graph_count: usize) -> Self {
87        Self {
88            input_files: 0,
89            records: 0,
90            fragments: 0,
91            fragment_bases: 0,
92            weak_superkmers: 0,
93            weak_superkmer_bases: 0,
94            graph_histogram: vec![0; graph_count],
95        }
96    }
97
98    #[inline]
99    pub fn non_empty_graphs(&self) -> usize {
100        self.graph_histogram
101            .iter()
102            .filter(|&&count| count > 0)
103            .count()
104    }
105
106    #[inline]
107    pub fn max_graph_superkmers(&self) -> u64 {
108        self.graph_histogram.iter().copied().max().unwrap_or(0)
109    }
110
111    fn merge_from(&mut self, other: &Self) {
112        self.input_files += other.input_files;
113        self.records += other.records;
114        self.fragments += other.fragments;
115        self.fragment_bases += other.fragment_bases;
116        self.weak_superkmers += other.weak_superkmers;
117        self.weak_superkmer_bases += other.weak_superkmer_bases;
118        for (dst, src) in self
119            .graph_histogram
120            .iter_mut()
121            .zip(other.graph_histogram.iter())
122        {
123            *dst += *src;
124        }
125    }
126}
127
128pub fn emit_weak_superkmer_buckets<const K: usize>(
129    params: &BuildParams,
130    graph_count: usize,
131) -> Result<PartitionEmissionStats, PartitionRunError> {
132    params.validate()?;
133
134    let paths = expand_input_paths(params)?;
135    if params.color {
136        // Colored vertices retain their previous source in a packed 21-bit
137        // field. Reject an oversized source set here, with the input count in
138        // hand, rather than letting a contraction worker trip an assertion.
139        if paths.len() > crate::state::VertexState::MAX_SOURCE_ID as usize {
140            return Err(PartitionRunError::TooManySources);
141        }
142        return emit_colored_weak_superkmer_buckets::<K>(params, graph_count, &paths);
143    }
144    if std::env::var_os("CF3_RS_LEGACY_UNCOLORED_PARTITION").is_none() {
145        return emit_uncolored_windowed_weak_superkmer_buckets::<K>(params, graph_count, &paths);
146    }
147    let mut stats = PartitionStats::new(graph_count);
148    let workers = params.threads.max(1);
149    let mut batch_txs = Vec::with_capacity(workers);
150    let mut batch_rxs = Vec::with_capacity(workers);
151    for _ in 0..workers {
152        let (tx, rx) = mpsc::sync_channel::<PartitionFragmentBatch>(4);
153        batch_txs.push(tx);
154        batch_rxs.push(rx);
155    }
156    let sink = SharedBucketSink::create(params, graph_count)?;
157    let mut worker_elapsed = Duration::ZERO;
158    let parse_started = Instant::now();
159    let mut parse_elapsed = Duration::ZERO;
160
161    std::thread::scope(|scope| {
162        let mut handles = Vec::new();
163        for rx in batch_rxs {
164            let sink = Arc::clone(&sink);
165            handles.push(scope.spawn(move || {
166                let mut worker_stats = PartitionStats::new(graph_count);
167                let mut worker_elapsed = Duration::ZERO;
168                let mut buckets = sink.emitter();
169                loop {
170                    let batch = rx.recv();
171                    let Ok(batch) = batch else {
172                        break;
173                    };
174                    for fragment in &batch.fragments {
175                        let seq = &batch.bases[fragment.offset..fragment.offset + fragment.len];
176                        let fragment_started = Instant::now();
177                        emit_fragment_seq_weak_superkmer_buckets::<K, PartitionRunError>(
178                            params,
179                            graph_count,
180                            fragment.source_id,
181                            seq,
182                            &mut buckets,
183                            &mut worker_stats,
184                        )?;
185                        worker_elapsed += fragment_started.elapsed();
186                    }
187                }
188                buckets.finish()?;
189                Ok::<_, PartitionRunError>(PartitionWorkerOutput {
190                    stats: worker_stats,
191                    elapsed: worker_elapsed,
192                })
193            }));
194        }
195
196        let mut producer_handles = Vec::new();
197        for (path_idx, path) in paths.iter().enumerate() {
198            let source_id =
199                u32::try_from(path_idx + 1).map_err(|_| PartitionRunError::TooManySources)?;
200            let txs = batch_txs.clone();
201            producer_handles.push(scope.spawn(move || {
202                let mut batch = PartitionFragmentBatch::new();
203                let mut next_worker = path_idx % txs.len();
204                let records = parse_fragments_borrowed(path, source_id, K + 1, |fragment| {
205                    batch.push(fragment);
206                    if batch.fragments.len() >= PARTITION_FRAGMENT_BATCH
207                        || batch.bases.len() >= PARTITION_BATCH_BASES
208                    {
209                        txs[next_worker]
210                            .send(std::mem::take(&mut batch))
211                            .map_err(|_| {
212                                InputError::Partition(PartitionError::WorkerDisconnected)
213                            })?;
214                        next_worker = (next_worker + 1) % txs.len();
215                    }
216                    Ok(())
217                })?;
218                if !batch.fragments.is_empty() {
219                    txs[next_worker]
220                        .send(batch)
221                        .map_err(|_| InputError::Partition(PartitionError::WorkerDisconnected))?;
222                }
223                Ok::<_, InputError>(records)
224            }));
225        }
226        drop(batch_txs);
227        for handle in producer_handles {
228            let records = handle
229                .join()
230                .map_err(|_| PartitionRunError::WorkerPanic)??;
231            stats.input_files += 1;
232            stats.records += records;
233        }
234        parse_elapsed = parse_started.elapsed();
235
236        for handle in handles {
237            let worker_stats = handle
238                .join()
239                .map_err(|_| PartitionRunError::WorkerPanic)??;
240            worker_elapsed += worker_stats.elapsed;
241            stats.merge_from(&worker_stats.stats);
242        }
243        Ok::<(), PartitionRunError>(())
244    })?;
245
246    let (bucket_flushes, bucket_flush_elapsed) = sink.flush_stats();
247    let bucket_finish_started = Instant::now();
248    let bucket_stats = sink.finish()?;
249    let bucket_finish_elapsed = bucket_finish_started.elapsed();
250    populate_emitted_graph_histogram(&mut stats, &bucket_stats)?;
251
252    Ok(PartitionEmissionStats {
253        partition: stats,
254        buckets: bucket_stats,
255        parse_elapsed,
256        worker_elapsed,
257        bucket_flush_elapsed,
258        bucket_flushes,
259        bucket_finish_elapsed,
260    })
261}
262
263/// Returns the reader count for the streamed partitioner, or `None` when
264/// file-level parallelism already fills the requested worker count.
265///
266/// Reference workloads present thousands of sources, so one whole file per
267/// worker saturates the machine with no batch copy at all. Read workloads
268/// present a handful of very large files, where that mapping leaves nearly
269/// every core idle; those inputs are streamed instead.
270fn streamed_reader_count(params: &BuildParams, files: usize) -> Option<usize> {
271    if std::env::var_os("CF3_RS_DIRECT_PARTITION").is_some() {
272        return None;
273    }
274    // `usize::MAX` asks for the memory bound only, dropping the input-file clamp
275    // that the direct path needs because it maps one whole file per worker.
276    let workers = params.partition_workers(usize::MAX);
277    if files == 0 || files >= workers {
278        return None;
279    }
280    Some(files)
281}
282
283fn emit_uncolored_windowed_weak_superkmer_buckets<const K: usize>(
284    params: &BuildParams,
285    graph_count: usize,
286    paths: &[PathBuf],
287) -> Result<PartitionEmissionStats, PartitionRunError> {
288    if let Some(readers) = streamed_reader_count(params, paths.len()) {
289        return emit_uncolored_streamed_weak_superkmer_buckets::<K>(
290            params,
291            graph_count,
292            paths,
293            readers,
294        );
295    }
296    emit_uncolored_direct_weak_superkmer_buckets::<K>(params, graph_count, paths)
297}
298
299/// Streams fragment batches from per-file readers to a full pool of workers.
300///
301/// Decompression and record assembly stay on the reader threads while every
302/// requested worker runs the minimizer scan and bucket packing, mirroring the
303/// C++ partitioner's chunk queue. Uncolored emission has no source ordering
304/// requirement, so batches may be consumed in any order.
305fn emit_uncolored_streamed_weak_superkmer_buckets<const K: usize>(
306    params: &BuildParams,
307    graph_count: usize,
308    paths: &[PathBuf],
309    readers: usize,
310) -> Result<PartitionEmissionStats, PartitionRunError> {
311    let sink = SharedBucketSink::create(params, graph_count)?;
312    let mut stats = PartitionStats::new(graph_count);
313    let mut worker_elapsed = Duration::ZERO;
314    let workers = params.partition_workers(usize::MAX);
315    let buffers = (2 * (workers + readers)).min(STREAM_POOL_BYTES / STREAM_BATCH_BASES);
316    eprintln!(
317        "cuttlefish: uncolored partition streaming {readers} reader(s) into {workers} worker(s), {buffers} batch buffer(s)"
318    );
319
320    // Readers decompress; only block-structured input can use more than one
321    // thread for it, so the budget is shared evenly and costs nothing otherwise.
322    let inflate_workers = (workers / readers.max(1)).max(1);
323    let pool = BatchPool::new(buffers, readers);
324    let next_source = AtomicUsize::new(0);
325    let mut work_order = (0..paths.len()).collect::<Vec<_>>();
326    work_order.sort_unstable_by_key(|&offset| {
327        std::cmp::Reverse(paths[offset].metadata().map_or(0, |meta| meta.len()))
328    });
329
330    let started = Instant::now();
331    let mut emitters = Vec::with_capacity(workers);
332    std::thread::scope(|scope| {
333        let mut worker_handles = Vec::with_capacity(workers);
334        for _ in 0..workers {
335            let sink = Arc::clone(&sink);
336            let pool = &pool;
337            worker_handles.push(scope.spawn(move || {
338                let mut worker_stats = PartitionStats::new(graph_count);
339                let mut elapsed = Duration::ZERO;
340                let mut buckets = sink.deferred_uncolored_emitter();
341                let result = (|| -> Result<(), PartitionRunError> {
342                    while let Some(batch) = pool.take_ready() {
343                        let fragment_started = Instant::now();
344                        let outcome = (|| -> Result<(), PartitionRunError> {
345                            for fragment in &batch.fragments {
346                                let seq =
347                                    &batch.bases[fragment.offset..fragment.offset + fragment.len];
348                                emit_fragment_seq_weak_superkmer_buckets::<K, PartitionRunError>(
349                                    params,
350                                    graph_count,
351                                    fragment.source_id,
352                                    seq,
353                                    &mut buckets,
354                                    &mut worker_stats,
355                                )?;
356                            }
357                            Ok(())
358                        })();
359                        elapsed += fragment_started.elapsed();
360                        pool.release(batch);
361                        outcome?;
362                    }
363                    Ok(())
364                })();
365                if result.is_err() {
366                    pool.fail();
367                }
368                result.map(|()| {
369                    (
370                        PartitionWorkerOutput {
371                            stats: worker_stats,
372                            elapsed,
373                        },
374                        buckets,
375                    )
376                })
377            }));
378        }
379
380        let mut reader_handles = Vec::with_capacity(readers);
381        for _ in 0..readers {
382            let pool = &pool;
383            let next_source = &next_source;
384            let work_order = &work_order;
385            reader_handles.push(scope.spawn(move || {
386                let result = (|| -> Result<(u64, usize), PartitionRunError> {
387                    let mut records = 0u64;
388                    let mut input_files = 0usize;
389                    loop {
390                        let order_idx = next_source.fetch_add(1, Ordering::Relaxed);
391                        let Some(&offset) = work_order.get(order_idx) else {
392                            break;
393                        };
394                        let source_id = u32::try_from(offset + 1)
395                            .map_err(|_| PartitionRunError::TooManySources)?;
396                        let mut batch = pool.take_free().ok_or(PartitionRunError::Partition(
397                            PartitionError::WorkerDisconnected,
398                        ))?;
399                        let parsed = parse_fragments_borrowed_with(
400                            &paths[offset],
401                            source_id,
402                            K + 1,
403                            inflate_workers,
404                            |fragment| {
405                                batch.push(fragment);
406                                if batch.is_full() {
407                                    let replacement = pool.take_free().ok_or(
408                                        InputError::Partition(PartitionError::WorkerDisconnected),
409                                    )?;
410                                    pool.publish(std::mem::replace(&mut batch, replacement));
411                                }
412                                Ok(())
413                            },
414                        );
415                        // Publish whatever this source produced before deciding
416                        // how to treat a failure, so no buffer is leaked.
417                        if batch.fragments.is_empty() {
418                            pool.release(batch);
419                        } else {
420                            pool.publish(batch);
421                        }
422                        match parsed {
423                            Ok(parsed) => {
424                                records += parsed;
425                                input_files += 1;
426                            }
427                            Err(error) => {
428                                handle_source_failure(params, &paths[offset], error)?;
429                            }
430                        }
431                    }
432                    Ok((records, input_files))
433                })();
434                pool.reader_done();
435                if result.is_err() {
436                    pool.fail();
437                }
438                result
439            }));
440        }
441
442        for handle in reader_handles {
443            let (records, input_files) = handle
444                .join()
445                .map_err(|_| PartitionRunError::WorkerPanic)??;
446            stats.records += records;
447            stats.input_files += input_files;
448        }
449        for handle in worker_handles {
450            let (output, buckets) = handle
451                .join()
452                .map_err(|_| PartitionRunError::WorkerPanic)??;
453            worker_elapsed += output.elapsed;
454            stats.merge_from(&output.stats);
455            emitters.push(buckets);
456        }
457        Ok::<(), PartitionRunError>(())
458    })?;
459    let parse_elapsed = started.elapsed();
460    sink.flush_uncolored_emitters(emitters)?;
461
462    let (bucket_flushes, bucket_flush_elapsed) = sink.flush_stats();
463    let bucket_finish_started = Instant::now();
464    let bucket_stats = sink.finish()?;
465    let bucket_finish_elapsed = bucket_finish_started.elapsed();
466    populate_emitted_graph_histogram(&mut stats, &bucket_stats)?;
467    Ok(PartitionEmissionStats {
468        partition: stats,
469        buckets: bucket_stats,
470        parse_elapsed,
471        worker_elapsed,
472        bucket_flush_elapsed,
473        bucket_flushes,
474        bucket_finish_elapsed,
475    })
476}
477
478fn emit_uncolored_direct_weak_superkmer_buckets<const K: usize>(
479    params: &BuildParams,
480    graph_count: usize,
481    paths: &[PathBuf],
482) -> Result<PartitionEmissionStats, PartitionRunError> {
483    let sink = SharedBucketSink::create(params, graph_count)?;
484    let mut stats = PartitionStats::new(graph_count);
485    let mut worker_elapsed = Duration::ZERO;
486    let mut parse_elapsed = Duration::ZERO;
487
488    {
489        let (source_start, window) = (0, paths);
490        let started = Instant::now();
491        let mut work_order = (0..window.len()).collect::<Vec<_>>();
492        work_order.sort_unstable_by_key(|&offset| {
493            std::cmp::Reverse(window[offset].metadata().map_or(0, |meta| meta.len()))
494        });
495        let next_source = AtomicUsize::new(0);
496        let workers = params.partition_workers(window.len());
497        eprintln!("cuttlefish: uncolored partition using {workers} worker(s)");
498        let mut emitters = Vec::with_capacity(workers);
499
500        std::thread::scope(|scope| {
501            let mut handles = Vec::with_capacity(workers);
502            for _ in 0..workers {
503                let sink = Arc::clone(&sink);
504                let next_source = &next_source;
505                let work_order = &work_order;
506                handles.push(scope.spawn(move || {
507                    let mut worker_stats = PartitionStats::new(graph_count);
508                    let mut elapsed = Duration::ZERO;
509                    let mut records = 0u64;
510                    let mut input_files = 0usize;
511                    let mut buckets = sink.deferred_uncolored_emitter();
512                    loop {
513                        let order_idx = next_source.fetch_add(1, Ordering::Relaxed);
514                        let Some(&offset) = work_order.get(order_idx) else {
515                            break;
516                        };
517                        let source_id = u32::try_from(source_start + offset + 1)
518                            .map_err(|_| PartitionRunError::TooManySources)?;
519                        match parse_fragments_borrowed(
520                            &window[offset],
521                            source_id,
522                            K + 1,
523                            |fragment| {
524                                let fragment_started = Instant::now();
525                                emit_fragment_seq_weak_superkmer_buckets::<K, InputError>(
526                                    params,
527                                    graph_count,
528                                    fragment.source_id,
529                                    fragment.seq,
530                                    &mut buckets,
531                                    &mut worker_stats,
532                                )?;
533                                elapsed += fragment_started.elapsed();
534                                Ok(())
535                            },
536                        ) {
537                            Ok(parsed) => {
538                                records += parsed;
539                                input_files += 1;
540                            }
541                            Err(error) => {
542                                handle_source_failure(params, &window[offset], error)?;
543                            }
544                        }
545                    }
546                    Ok::<_, PartitionRunError>((
547                        records,
548                        input_files,
549                        PartitionWorkerOutput {
550                            stats: worker_stats,
551                            elapsed,
552                        },
553                        buckets,
554                    ))
555                }));
556            }
557            for handle in handles {
558                let (records, input_files, output, buckets) = handle
559                    .join()
560                    .map_err(|_| PartitionRunError::WorkerPanic)??;
561                stats.records += records;
562                stats.input_files += input_files;
563                worker_elapsed += output.elapsed;
564                stats.merge_from(&output.stats);
565                emitters.push(buckets);
566            }
567            Ok::<_, PartitionRunError>(())
568        })?;
569        parse_elapsed += started.elapsed();
570        sink.flush_uncolored_emitters(emitters)?;
571    }
572
573    let (bucket_flushes, bucket_flush_elapsed) = sink.flush_stats();
574    let bucket_finish_started = Instant::now();
575    let bucket_stats = sink.finish()?;
576    let bucket_finish_elapsed = bucket_finish_started.elapsed();
577    populate_emitted_graph_histogram(&mut stats, &bucket_stats)?;
578    Ok(PartitionEmissionStats {
579        partition: stats,
580        buckets: bucket_stats,
581        parse_elapsed,
582        worker_elapsed,
583        bucket_flush_elapsed,
584        bucket_flushes,
585        bucket_finish_elapsed,
586    })
587}
588
589fn emit_colored_weak_superkmer_buckets<const K: usize>(
590    params: &BuildParams,
591    graph_count: usize,
592    paths: &[PathBuf],
593) -> Result<PartitionEmissionStats, PartitionRunError> {
594    let sink = SharedBucketSink::create(params, graph_count)?;
595    let mut stats = PartitionStats::new(graph_count);
596    let mut worker_elapsed = Duration::ZERO;
597    let mut parse_elapsed = Duration::ZERO;
598
599    // The reference partitioner exposes the complete size-sorted source set to
600    // its dynamic scheduler and drains each worker after every source.
601    {
602        let (source_start, window) = (0, paths);
603        let parse_started = Instant::now();
604        let mut work_order = (0..window.len()).collect::<Vec<_>>();
605        work_order.sort_unstable_by_key(|&offset| {
606            std::cmp::Reverse(window[offset].metadata().map_or(0, |meta| meta.len()))
607        });
608        let next_source = AtomicUsize::new(0);
609        let workers = params.partition_workers(window.len());
610        eprintln!("cuttlefish: colored partition using {workers} worker(s)");
611        let mut emitters = Vec::with_capacity(workers);
612        std::thread::scope(|scope| {
613            let mut handles = Vec::new();
614            for _ in 0..workers {
615                let sink = Arc::clone(&sink);
616                let next_source = &next_source;
617                let work_order = &work_order;
618                handles.push(scope.spawn(move || {
619                    let mut worker_stats = PartitionStats::new(graph_count);
620                    let mut elapsed = Duration::ZERO;
621                    let mut input_files = 0usize;
622                    let mut records = 0u64;
623                    let mut buckets = sink.emitter();
624                    loop {
625                        let order_idx = next_source.fetch_add(1, Ordering::Relaxed);
626                        let Some(&offset) = work_order.get(order_idx) else {
627                            break;
628                        };
629                        let source_id = u32::try_from(source_start + offset + 1)
630                            .map_err(|_| PartitionRunError::TooManySources)?;
631                        match parse_fragments_borrowed(
632                            &window[offset],
633                            source_id,
634                            K + 1,
635                            |fragment| {
636                                let started = Instant::now();
637                                emit_fragment_seq_weak_superkmer_buckets::<K, InputError>(
638                                    params,
639                                    graph_count,
640                                    fragment.source_id,
641                                    fragment.seq,
642                                    &mut buckets,
643                                    &mut worker_stats,
644                                )?;
645                                elapsed += started.elapsed();
646                                Ok(())
647                            },
648                        ) {
649                            Ok(parsed) => {
650                                records += parsed;
651                                input_files += 1;
652                            }
653                            Err(error) => {
654                                handle_source_failure(params, &window[offset], error)?;
655                            }
656                        }
657                        buckets.flush_colored_worker_if_required()?;
658                    }
659                    Ok::<_, PartitionRunError>((
660                        records,
661                        input_files,
662                        PartitionWorkerOutput {
663                            stats: worker_stats,
664                            elapsed,
665                        },
666                        buckets,
667                    ))
668                }));
669            }
670            for handle in handles {
671                let (records, input_files, output, buckets) = handle
672                    .join()
673                    .map_err(|_| PartitionRunError::WorkerPanic)??;
674                stats.records += records;
675                stats.input_files += input_files;
676                worker_elapsed += output.elapsed;
677                stats.merge_from(&output.stats);
678                emitters.push(buckets);
679            }
680            Ok::<_, PartitionRunError>(())
681        })?;
682        parse_elapsed += parse_started.elapsed();
683
684        sink.flush_colored_emitters(emitters)?;
685    }
686
687    let (bucket_flushes, bucket_flush_elapsed) = sink.flush_stats();
688    let bucket_finish_started = Instant::now();
689    let bucket_stats = sink.finish()?;
690    let bucket_finish_elapsed = bucket_finish_started.elapsed();
691    populate_emitted_graph_histogram(&mut stats, &bucket_stats)?;
692    Ok(PartitionEmissionStats {
693        partition: stats,
694        buckets: bucket_stats,
695        parse_elapsed,
696        worker_elapsed,
697        bucket_flush_elapsed,
698        bucket_flushes,
699        bucket_finish_elapsed,
700    })
701}
702
703impl PartitionFragmentBatch {
704    fn new() -> Self {
705        Self {
706            fragments: Vec::with_capacity(PARTITION_FRAGMENT_BATCH),
707            bases: Vec::with_capacity(PARTITION_BATCH_BASES.min(PARTITION_FRAGMENT_BATCH * 256)),
708        }
709    }
710
711    fn with_capacity(fragments: usize, bases: usize) -> Self {
712        Self {
713            fragments: Vec::with_capacity(fragments),
714            bases: Vec::with_capacity(bases),
715        }
716    }
717
718    fn push(&mut self, fragment: BorrowedSequenceFragment<'_>) {
719        let offset = self.bases.len();
720        self.bases.extend_from_slice(fragment.seq);
721        self.fragments.push(PartitionBatchFragment {
722            source_id: fragment.source_id,
723            offset,
724            len: fragment.seq.len(),
725        });
726    }
727
728    #[inline]
729    fn is_full(&self) -> bool {
730        self.fragments.len() >= STREAM_BATCH_FRAGMENTS || self.bases.len() >= STREAM_BATCH_BASES
731    }
732
733    fn clear(&mut self) {
734        self.fragments.clear();
735        self.bases.clear();
736    }
737}
738
739/// A bounded pool of recyclable fragment batches shared by readers and workers.
740///
741/// The pool is the backpressure mechanism for the streamed partitioner: readers
742/// block when every buffer is in flight, and workers block until a reader
743/// publishes one. Buffers are recycled rather than reallocated, so the steady
744/// state performs no allocation.
745struct BatchPool {
746    inner: std::sync::Mutex<BatchPoolInner>,
747    ready_available: std::sync::Condvar,
748    free_available: std::sync::Condvar,
749}
750
751struct BatchPoolInner {
752    ready: std::collections::VecDeque<PartitionFragmentBatch>,
753    free: Vec<PartitionFragmentBatch>,
754    readers: usize,
755    failed: bool,
756}
757
758impl BatchPool {
759    fn new(buffers: usize, readers: usize) -> Self {
760        let buffers = buffers.max(1);
761        let mut free = Vec::with_capacity(buffers);
762        for _ in 0..buffers {
763            free.push(PartitionFragmentBatch::with_capacity(
764                STREAM_BATCH_FRAGMENTS,
765                STREAM_BATCH_BASES,
766            ));
767        }
768        Self {
769            inner: std::sync::Mutex::new(BatchPoolInner {
770                ready: std::collections::VecDeque::with_capacity(buffers),
771                free,
772                readers,
773                failed: false,
774            }),
775            ready_available: std::sync::Condvar::new(),
776            free_available: std::sync::Condvar::new(),
777        }
778    }
779
780    /// Claims an empty buffer, blocking until one is recycled.
781    ///
782    /// Returns `None` once a worker has failed, so readers stop early instead of
783    /// blocking forever on a pool nobody is draining.
784    fn take_free(&self) -> Option<PartitionFragmentBatch> {
785        let mut inner = self.inner.lock().ok()?;
786        loop {
787            if inner.failed {
788                return None;
789            }
790            if let Some(batch) = inner.free.pop() {
791                return Some(batch);
792            }
793            inner = self.free_available.wait(inner).ok()?;
794        }
795    }
796
797    fn publish(&self, batch: PartitionFragmentBatch) {
798        if let Ok(mut inner) = self.inner.lock() {
799            inner.ready.push_back(batch);
800            self.ready_available.notify_one();
801        }
802    }
803
804    /// Claims a filled buffer, blocking until one is published.
805    ///
806    /// Returns `None` when every reader has finished and the queue has drained.
807    fn take_ready(&self) -> Option<PartitionFragmentBatch> {
808        let mut inner = self.inner.lock().ok()?;
809        loop {
810            if let Some(batch) = inner.ready.pop_front() {
811                return Some(batch);
812            }
813            if inner.readers == 0 || inner.failed {
814                return None;
815            }
816            inner = self.ready_available.wait(inner).ok()?;
817        }
818    }
819
820    fn release(&self, mut batch: PartitionFragmentBatch) {
821        batch.clear();
822        if let Ok(mut inner) = self.inner.lock() {
823            inner.free.push(batch);
824            self.free_available.notify_one();
825        }
826    }
827
828    fn reader_done(&self) {
829        if let Ok(mut inner) = self.inner.lock() {
830            inner.readers -= 1;
831            if inner.readers == 0 {
832                self.ready_available.notify_all();
833            }
834        }
835    }
836
837    /// Aborts the pool so both sides unblock after a worker or reader error.
838    fn fail(&self) {
839        if let Ok(mut inner) = self.inner.lock() {
840            inner.failed = true;
841        }
842        self.ready_available.notify_all();
843        self.free_available.notify_all();
844    }
845}
846
847impl Default for PartitionFragmentBatch {
848    fn default() -> Self {
849        Self::new()
850    }
851}
852
853/// Reports a source that failed to parse, or propagates the failure.
854///
855/// With `--skip-unreadable` a corrupt input in a large corpus costs a warning
856/// rather than the whole build. Records already emitted from the failed source
857/// are retained, and its position in the input list is preserved so colored
858/// source assignments do not shift.
859fn handle_source_failure(
860    params: &BuildParams,
861    path: &Path,
862    error: InputError,
863) -> Result<(), PartitionRunError> {
864    if !params.skip_unreadable {
865        return Err(error.into());
866    }
867    eprintln!(
868        "cuttlefish: skipping unreadable input {}: {error}",
869        path.display()
870    );
871    Ok(())
872}
873
874/// Whether to skip the minimizer scan entirely (diagnostic only).
875fn parse_only_diagnostic() -> bool {
876    static PARSE_ONLY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
877    *PARSE_ONLY.get_or_init(|| std::env::var_os("CF3_RS_PARSE_ONLY").is_some())
878}
879
880/// Whether to skip record packing and bucket writes (diagnostic only).
881fn scan_only_diagnostic() -> bool {
882    static SCAN_ONLY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
883    *SCAN_ONLY.get_or_init(|| std::env::var_os("CF3_RS_SCAN_ONLY").is_some())
884}
885
886fn emit_fragment_seq_weak_superkmer_buckets<const K: usize, E>(
887    params: &BuildParams,
888    graph_count: usize,
889    source_id: u32,
890    seq: &[u8],
891    buckets: &mut SharedBucketEmitter,
892    stats: &mut PartitionStats,
893) -> Result<(), E>
894where
895    E: From<PartitionError> + From<crate::buckets::BucketError>,
896{
897    stats.fragments += 1;
898    stats.fragment_bases += seq.len() as u64;
899    // Diagnostic: `CF3_RS_SCAN_ONLY` performs the minimizer scan without packing
900    // or writing records, isolating scan cost from emission cost.
901    let scan_only = scan_only_diagnostic();
902    // Diagnostic: `CF3_RS_PARSE_ONLY` also skips the minimizer scan, leaving only
903    // line assembly and fragment splitting, so the two can be costed apart.
904    if parse_only_diagnostic() {
905        return Ok(());
906    }
907    for_each_valid_weak_superkmer::<K, E, _>(
908        seq,
909        params.minimizer_len as usize,
910        graph_count,
911        params.color.then_some(source_id),
912        |sk| {
913            if !scan_only {
914                buckets.add_valid(&sk, sk.sequence(seq))?;
915            }
916            stats.weak_superkmers += 1;
917            stats.weak_superkmer_bases += sk.len as u64;
918            Ok(())
919        },
920    )?;
921    Ok(())
922}
923
924fn populate_emitted_graph_histogram(
925    stats: &mut PartitionStats,
926    buckets: &BucketEmitStats,
927) -> Result<(), PartitionRunError> {
928    stats.graph_histogram.fill(0);
929    let (_, entries) = crate::buckets::BucketStore::open_dir(&buckets.bucket_dir)?;
930    for entry in entries {
931        stats.graph_histogram[entry.graph_id] = entry.records;
932    }
933    Ok(())
934}
935
936pub fn partition_inputs<const K: usize>(
937    params: &BuildParams,
938    graph_count: usize,
939) -> Result<PartitionStats, PartitionRunError> {
940    params.validate()?;
941
942    let paths = expand_input_paths(params)?;
943    let mut stats = PartitionStats::new(graph_count);
944    let workers = params.partition_workers(paths.len());
945    let chunk_len = paths.len().div_ceil(workers);
946
947    std::thread::scope(|scope| {
948        let mut handles = Vec::new();
949        for (chunk_idx, chunk) in paths.chunks(chunk_len).enumerate() {
950            let start_idx = chunk_idx * chunk_len;
951            handles.push(scope.spawn(move || {
952                let mut worker_stats = PartitionStats::new(graph_count);
953                for (offset, path) in chunk.iter().enumerate() {
954                    let source_id = u32::try_from(start_idx + offset + 1)
955                        .map_err(|_| PartitionRunError::TooManySources)?;
956                    let path_stats = partition_path::<K>(params, graph_count, source_id, path)?;
957                    worker_stats.merge_from(&path_stats);
958                }
959                Ok::<PartitionStats, PartitionRunError>(worker_stats)
960            }));
961        }
962
963        for handle in handles {
964            let worker_stats = handle
965                .join()
966                .map_err(|_| PartitionRunError::WorkerPanic)??;
967            stats.merge_from(&worker_stats);
968        }
969        Ok::<(), PartitionRunError>(())
970    })?;
971
972    Ok(stats)
973}
974
975fn partition_path<const K: usize>(
976    params: &BuildParams,
977    graph_count: usize,
978    source_id: u32,
979    path: &Path,
980) -> Result<PartitionStats, PartitionRunError> {
981    let mut stats = PartitionStats::new(graph_count);
982    stats.input_files = 1;
983
984    let records = parse_fragments(path, source_id, K + 1, |fragment| {
985        stats.fragments += 1;
986        stats.fragment_bases += fragment.seq.len() as u64;
987        for_each_valid_weak_superkmer::<K, InputError, _>(
988            &fragment.seq,
989            params.minimizer_len as usize,
990            graph_count,
991            params.color.then_some(fragment.source_id),
992            |sk| {
993                stats.weak_superkmers += 1;
994                stats.weak_superkmer_bases += sk.len as u64;
995                stats.graph_histogram[sk.graph_id] += 1;
996                Ok(())
997            },
998        )?;
999        Ok(())
1000    })?;
1001    stats.records = records;
1002
1003    Ok(stats)
1004}
1005
1006impl WeakSuperKmer {
1007    #[inline]
1008    pub fn sequence<'a>(&self, fragment: &'a [u8]) -> &'a [u8] {
1009        &fragment[self.offset..self.offset + self.len]
1010    }
1011}
1012
1013pub fn partition_fragment<const K: usize>(
1014    fragment: &[u8],
1015    minimizer_len: usize,
1016    graph_count: usize,
1017    source_id: Option<u32>,
1018) -> Result<Vec<WeakSuperKmer>, PartitionError> {
1019    let mut out = Vec::new();
1020    for_each_weak_superkmer::<K, PartitionError, _>(
1021        fragment,
1022        minimizer_len,
1023        graph_count,
1024        source_id,
1025        |sk| {
1026            out.push(sk);
1027            Ok(())
1028        },
1029    )?;
1030    Ok(out)
1031}
1032
1033fn for_each_weak_superkmer<const K: usize, E, F>(
1034    fragment: &[u8],
1035    minimizer_len: usize,
1036    graph_count: usize,
1037    source_id: Option<u32>,
1038    mut visit: F,
1039) -> Result<(), E>
1040where
1041    E: From<PartitionError>,
1042    F: FnMut(WeakSuperKmer) -> Result<(), E>,
1043{
1044    for_each_weak_superkmer_impl::<K, E, _, true>(
1045        fragment,
1046        minimizer_len,
1047        graph_count,
1048        source_id,
1049        &mut visit,
1050    )
1051}
1052
1053fn for_each_valid_weak_superkmer<const K: usize, E, F>(
1054    fragment: &[u8],
1055    minimizer_len: usize,
1056    graph_count: usize,
1057    source_id: Option<u32>,
1058    mut visit: F,
1059) -> Result<(), E>
1060where
1061    E: From<PartitionError>,
1062    F: FnMut(WeakSuperKmer) -> Result<(), E>,
1063{
1064    for_each_weak_superkmer_impl::<K, E, _, false>(
1065        fragment,
1066        minimizer_len,
1067        graph_count,
1068        source_id,
1069        &mut visit,
1070    )
1071}
1072
1073/// Scans a fragment and reports each weak super-k-mer.
1074///
1075/// The rolling minimizer window and the super-k-mer boundary state share one
1076/// loop, as they do in C++. Splitting them across a per-base visitor closure
1077/// kept the boundary state in a closure environment, so every base reloaded and
1078/// stored it through memory and paid a call. `visit` now runs once per weak
1079/// super-k-mer rather than once per base.
1080#[inline]
1081fn for_each_weak_superkmer_impl<const K: usize, E, F, const CHECK_BASES: bool>(
1082    fragment: &[u8],
1083    minimizer_len: usize,
1084    graph_count: usize,
1085    source_id: Option<u32>,
1086    visit: &mut F,
1087) -> Result<(), E>
1088where
1089    E: From<PartitionError>,
1090    F: FnMut(WeakSuperKmer) -> Result<(), E>,
1091{
1092    if K < 3 || K % 2 == 0 || K > 63 {
1093        return Err(PartitionError::InvalidK(K).into());
1094    }
1095    if minimizer_len == 0 || minimizer_len >= K || minimizer_len > 32 {
1096        return Err(PartitionError::InvalidMinimizerLen(minimizer_len).into());
1097    }
1098    if !graph_count.is_power_of_two() {
1099        return Err(PartitionError::GraphCountNotPowerOfTwo(graph_count).into());
1100    }
1101    if fragment.len() < K + 1 {
1102        return Ok(());
1103    }
1104
1105    let window_k = K - 1;
1106    let max_super_km1_len = 2 * (K - 1) - minimizer_len;
1107    let lmers_per_window = window_k - minimizer_len + 1;
1108    let ring_size = lmers_per_window - 1;
1109    let mut hashes = [0u64; K];
1110    let mut prefix_min = [0u64; K];
1111
1112    let mut fwd = 0u64;
1113    let mut rev = 0u64;
1114    // Indexed rather than iterated on purpose. Clippy prefers
1115    // `fragment.iter().enumerate().take(minimizer_len)` here and
1116    // `&fragment[minimizer_len..window_k]` below, and both measured slower:
1117    // over six interleaved pairs of partition-only runs on 10,000 genomes the
1118    // iterator forms cost about 0.13 s of an 8.05 s phase and were slower in
1119    // five of six. This is the fused minimizer scan, the hottest loop in the
1120    // build; the clearer spelling is not worth 1.6% of it.
1121    #[allow(clippy::needless_range_loop)]
1122    for idx in 0..minimizer_len {
1123        let base_bits = partition_base_bits::<CHECK_BASES, E>(fragment[idx])?;
1124        fwd = (fwd << 2) | base_bits as u64;
1125        rev |= ((base_bits ^ 0b11) as u64) << (2 * idx);
1126    }
1127    let mask = if minimizer_len == 32 {
1128        u64::MAX
1129    } else {
1130        (1u64 << (2 * minimizer_len)) - 1
1131    };
1132    let rev_high_shift = 2 * (minimizer_len - 1);
1133
1134    let mut pivot = ring_size;
1135    hashes[pivot] = canonical_lmer_hash(fwd, rev);
1136    #[allow(clippy::needless_range_loop)]
1137    for idx in minimizer_len..window_k {
1138        let next_bits = partition_base_bits::<CHECK_BASES, E>(fragment[idx])?;
1139        fwd = ((fwd << 2) | next_bits as u64) & mask;
1140        rev = (rev >> 2) | (((next_bits ^ 0b11) as u64) << rev_high_shift);
1141        pivot -= 1;
1142        hashes[pivot] = canonical_lmer_hash(fwd, rev);
1143    }
1144
1145    reset_min_window(&hashes, &mut prefix_min, ring_size, &mut pivot);
1146    let mut suffix_min = u64::MAX;
1147
1148    // Boundary state in locals, so the loop needs no closure environment.
1149    let mut cur_off = 0usize;
1150    let mut km1_idx = 0usize;
1151    let mut cur_g = graph_id(prefix_min[pivot].min(suffix_min), graph_count);
1152    let mut prev_g = graph_count;
1153
1154    for &base in &fragment[window_k..] {
1155        let next_bits = partition_base_bits::<CHECK_BASES, E>(base)?;
1156        fwd = ((fwd << 2) | next_bits as u64) & mask;
1157        rev = (rev >> 2) | (((next_bits ^ 0b11) as u64) << rev_high_shift);
1158
1159        hashes[pivot] = canonical_lmer_hash(fwd, rev);
1160        suffix_min = suffix_min.min(hashes[pivot]);
1161        if pivot > 0 {
1162            pivot -= 1;
1163        } else {
1164            reset_min_window(&hashes, &mut prefix_min, ring_size, &mut pivot);
1165            suffix_min = u64::MAX;
1166        }
1167
1168        let len = km1_idx + window_k;
1169        let next_g = graph_id(prefix_min[pivot].min(suffix_min), graph_count);
1170        km1_idx += 1;
1171        if next_g != cur_g || len == max_super_km1_len {
1172            let next_off = cur_off + km1_idx;
1173            let left_joined = cur_off > 0;
1174            visit(WeakSuperKmer {
1175                graph_id: cur_g,
1176                offset: cur_off - usize::from(left_joined),
1177                len: usize::from(left_joined) + len + 1,
1178                source_id,
1179                left_discontinuous: left_joined && prev_g != cur_g,
1180                right_discontinuous: next_g != cur_g,
1181            })?;
1182
1183            cur_off = next_off;
1184            prev_g = cur_g;
1185            cur_g = next_g;
1186            km1_idx = 0;
1187        }
1188    }
1189
1190    let len = fragment.len() - cur_off;
1191    let left_joined = cur_off > 0;
1192    visit(WeakSuperKmer {
1193        graph_id: cur_g,
1194        offset: cur_off - usize::from(left_joined),
1195        len: usize::from(left_joined) + len,
1196        source_id,
1197        left_discontinuous: left_joined && prev_g != cur_g,
1198        right_discontinuous: false,
1199    })?;
1200
1201    Ok(())
1202}
1203
1204fn reset_min_window(hashes: &[u64], prefix_min: &mut [u64], ring_size: usize, pivot: &mut usize) {
1205    prefix_min[0] = hashes[0];
1206    for idx in 1..=ring_size {
1207        prefix_min[idx] = prefix_min[idx - 1].min(hashes[idx]);
1208    }
1209    *pivot = ring_size;
1210}
1211
1212#[inline(always)]
1213fn canonical_lmer_hash(fwd: u64, rev: u64) -> u64 {
1214    wyhash_u64(fwd, 0).min(wyhash_u64(rev, 0))
1215}
1216
1217#[inline(always)]
1218fn partition_base_bits<const CHECK_BASES: bool, E>(base: u8) -> Result<u8, E>
1219where
1220    E: From<PartitionError>,
1221{
1222    if CHECK_BASES {
1223        ascii_base_bits(base)
1224            .ok_or(PartitionError::InvalidBase(base))
1225            .map_err(E::from)
1226    } else {
1227        debug_assert!(ascii_base_bits(base).is_some());
1228        Ok(valid_ascii_base_bits(base))
1229    }
1230}
1231
1232#[inline]
1233fn graph_id(hash: u64, graph_count: usize) -> usize {
1234    hash as usize & (graph_count - 1)
1235}
1236
1237#[inline]
1238pub fn source_hash(source_id: u32) -> u64 {
1239    hash_u64(source_id as u64, 0)
1240}
1241
1242#[derive(Debug)]
1243pub enum PartitionError {
1244    InvalidK(usize),
1245    InvalidMinimizerLen(usize),
1246    GraphCountNotPowerOfTwo(usize),
1247    InvalidBase(u8),
1248    Kmer(crate::kmer::KmerError),
1249    WorkerDisconnected,
1250}
1251
1252impl From<crate::kmer::KmerError> for PartitionError {
1253    fn from(value: crate::kmer::KmerError) -> Self {
1254        Self::Kmer(value)
1255    }
1256}
1257
1258impl std::fmt::Display for PartitionError {
1259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1260        match self {
1261            Self::InvalidK(k) => write!(f, "invalid k for partitioning: {k}"),
1262            Self::InvalidMinimizerLen(l) => write!(f, "invalid minimizer length: {l}"),
1263            Self::GraphCountNotPowerOfTwo(c) => {
1264                write!(f, "graph count must be a power of two: {c}")
1265            }
1266            Self::InvalidBase(b) => {
1267                write!(f, "invalid base in partition fragment: '{}'", *b as char)
1268            }
1269            Self::Kmer(err) => write!(f, "{err}"),
1270            Self::WorkerDisconnected => write!(f, "partition worker disconnected"),
1271        }
1272    }
1273}
1274
1275impl std::error::Error for PartitionError {}
1276
1277impl From<PartitionError> for InputError {
1278    fn from(value: PartitionError) -> Self {
1279        Self::Partition(value)
1280    }
1281}
1282
1283#[derive(Debug)]
1284pub enum PartitionRunError {
1285    Params(ParamError),
1286    Input(InputError),
1287    Partition(PartitionError),
1288    Bucket(crate::buckets::BucketError),
1289    TooManySources,
1290    WorkerPanic,
1291}
1292
1293impl From<ParamError> for PartitionRunError {
1294    fn from(value: ParamError) -> Self {
1295        Self::Params(value)
1296    }
1297}
1298
1299impl From<InputError> for PartitionRunError {
1300    fn from(value: InputError) -> Self {
1301        Self::Input(value)
1302    }
1303}
1304
1305impl From<PartitionError> for PartitionRunError {
1306    fn from(value: PartitionError) -> Self {
1307        Self::Partition(value)
1308    }
1309}
1310
1311impl From<crate::buckets::BucketError> for PartitionRunError {
1312    fn from(value: crate::buckets::BucketError) -> Self {
1313        Self::Bucket(value)
1314    }
1315}
1316
1317impl std::fmt::Display for PartitionRunError {
1318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1319        match self {
1320            Self::Params(err) => write!(f, "{err}"),
1321            Self::Input(err) => write!(f, "{err}"),
1322            Self::Partition(err) => write!(f, "{err}"),
1323            Self::Bucket(err) => write!(f, "{err}"),
1324            Self::TooManySources => write!(f, "too many input sources for 32-bit source IDs"),
1325            Self::WorkerPanic => write!(f, "partition worker thread panicked"),
1326        }
1327    }
1328}
1329
1330impl std::error::Error for PartitionRunError {}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335
1336    #[test]
1337    fn emits_covering_weak_superkmers() {
1338        let seq = b"ACGTACGTACGTACGTACGTACGT";
1339        let parts = partition_fragment::<7>(seq, 3, 128, Some(1)).unwrap();
1340        assert!(!parts.is_empty());
1341        assert!(parts.iter().all(|p| p.len >= 7));
1342        assert_eq!(parts[0].offset, 0);
1343        assert_eq!(
1344            parts.last().unwrap().offset + parts.last().unwrap().len,
1345            seq.len()
1346        );
1347        assert!(parts.iter().all(|p| p.source_id == Some(1)));
1348    }
1349
1350    #[test]
1351    fn rejects_non_power_of_two_graph_count() {
1352        let err = partition_fragment::<7>(b"ACGTACGT", 3, 127, None).unwrap_err();
1353        assert!(matches!(err, PartitionError::GraphCountNotPowerOfTwo(127)));
1354    }
1355
1356    #[test]
1357    fn unreadable_input_aborts_unless_skipping_is_requested() {
1358        let base = std::env::temp_dir().join(format!("cf3rs-skip-{}", std::process::id()));
1359        let _ = std::fs::remove_dir_all(&base);
1360        std::fs::create_dir_all(&base).unwrap();
1361        let good = base.join("good.fa");
1362        std::fs::write(&good, b">r1\nACGTACGTACGTACGTACGTACGT\n").unwrap();
1363        // A zero-byte `.gz` is the exact corruption seen in the wild: the file
1364        // exists and is listed, but no gzip member can be read from it.
1365        let bad = base.join("bad.fa.gz");
1366        std::fs::write(&bad, b"").unwrap();
1367
1368        let build = |skip: bool, threads: usize| {
1369            let work = base.join(format!("work-{skip}-{threads}"));
1370            let _ = std::fs::remove_dir_all(&work);
1371            std::fs::create_dir_all(&work).unwrap();
1372            let mut params = BuildParams::new(crate::GraphInput::References, "unused".to_string());
1373            params.seqs.push(good.to_string_lossy().into_owned());
1374            params.seqs.push(bad.to_string_lossy().into_owned());
1375            params.k = 7;
1376            params.minimizer_len = 3;
1377            params.threads = threads;
1378            params.skip_unreadable = skip;
1379            params.work_dir = work.to_string_lossy().into_owned();
1380            emit_weak_superkmer_buckets::<7>(&params, 128)
1381        };
1382
1383        // `threads = 1` takes the direct path, `threads = 8` the streamed one,
1384        // because the streamed path engages only when files < workers.
1385        for threads in [1usize, 8] {
1386            assert!(
1387                build(false, threads).is_err(),
1388                "unreadable input must abort by default at {threads} thread(s)"
1389            );
1390            let stats = build(true, threads)
1391                .unwrap_or_else(|e| panic!("skipping should succeed at {threads} thread(s): {e}"));
1392            assert_eq!(
1393                stats.partition.input_files, 1,
1394                "only the good source counts"
1395            );
1396            assert!(stats.partition.weak_superkmers > 0);
1397        }
1398
1399        let _ = std::fs::remove_dir_all(&base);
1400    }
1401
1402    #[test]
1403    fn streamed_partition_matches_direct_partition() {
1404        // One input file with four workers selects the streamed path; the direct
1405        // path is the same partitioner mapping that file onto a single worker.
1406        let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1407        let input = manifest.join("../../data/refs2.fa");
1408        assert!(input.exists(), "missing fixture {}", input.display());
1409
1410        let run = |dir: &std::path::Path| {
1411            let _ = std::fs::remove_dir_all(dir);
1412            std::fs::create_dir_all(dir).unwrap();
1413            let mut params = BuildParams::new(crate::GraphInput::References, "unused".to_string());
1414            params.seqs.push(input.to_string_lossy().into_owned());
1415            params.k = 7;
1416            params.minimizer_len = 3;
1417            params.threads = 4;
1418            params.work_dir = dir.to_string_lossy().into_owned();
1419            let paths = expand_input_paths(&params).unwrap();
1420            (params, paths)
1421        };
1422
1423        let base = std::env::temp_dir().join(format!("cf3rs-stream-{}", std::process::id()));
1424        let streamed_dir = base.join("streamed");
1425        let direct_dir = base.join("direct");
1426
1427        let (params, paths) = run(&streamed_dir);
1428        assert_eq!(streamed_reader_count(&params, paths.len()), Some(1));
1429        let streamed =
1430            emit_uncolored_streamed_weak_superkmer_buckets::<7>(&params, 128, &paths, 1).unwrap();
1431
1432        let (params, paths) = run(&direct_dir);
1433        let direct =
1434            emit_uncolored_direct_weak_superkmer_buckets::<7>(&params, 128, &paths).unwrap();
1435
1436        assert_eq!(streamed.partition.records, direct.partition.records);
1437        assert_eq!(streamed.partition.fragments, direct.partition.fragments);
1438        assert_eq!(
1439            streamed.partition.fragment_bases,
1440            direct.partition.fragment_bases
1441        );
1442        assert_eq!(
1443            streamed.partition.weak_superkmers,
1444            direct.partition.weak_superkmers
1445        );
1446        assert_eq!(
1447            streamed.partition.weak_superkmer_bases,
1448            direct.partition.weak_superkmer_bases
1449        );
1450        // Per-bucket record counts prove every weak super-k-mer reached the same
1451        // subgraph regardless of which worker packed it.
1452        assert_eq!(
1453            streamed.partition.graph_histogram,
1454            direct.partition.graph_histogram
1455        );
1456        assert_eq!(streamed.buckets.bucket_files, direct.buckets.bucket_files);
1457        assert_eq!(streamed.buckets.bytes_written, direct.buckets.bytes_written);
1458
1459        let _ = std::fs::remove_dir_all(&base);
1460    }
1461}