Skip to main content

laddu_data/
io.rs

1use serde::{Deserialize, Serialize};
2use std::{
3    fs,
4    path::{Path, PathBuf},
5    sync::Arc,
6};
7
8use crate::{LadduDataError, LadduDataResult, data::EventBatch, schema::Schema};
9
10/// In-memory event sources and sinks.
11pub mod memory;
12/// Parquet event sources and sinks.
13pub mod parquet;
14/// ROOT event sources.
15pub mod root;
16
17#[cfg(feature = "mpi")]
18/// Distribution of event I/O across MPI ranks.
19#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
20pub enum Distribution {
21    /// Single-process I/O.
22    #[default]
23    Serial,
24    /// MPI-distributed I/O with explicit rank metadata.
25    Mpi {
26        /// Zero-based rank.
27        rank: usize,
28        /// Number of ranks.
29        nranks: usize,
30        /// Work-partitioning strategy.
31        partitioning: Partitioning,
32    },
33}
34
35#[cfg(feature = "mpi")]
36impl Distribution {
37    /// Creates serial distribution.
38    pub fn serial() -> Self {
39        Self::Serial
40    }
41
42    /// Creates MPI distribution from a communicator.
43    pub fn from_world<C>(world: &C) -> Self
44    where
45        C: mpi::topology::Communicator,
46    {
47        Self::Mpi {
48            rank: world.rank() as usize,
49            nranks: world.size() as usize,
50            partitioning: Partitioning::default(),
51        }
52    }
53
54    /// Returns the current rank.
55    pub fn rank(self) -> usize {
56        match self {
57            Self::Serial => 0,
58            Self::Mpi { rank, .. } => rank,
59        }
60    }
61
62    /// Returns the number of ranks.
63    pub fn nranks(self) -> usize {
64        match self {
65            Self::Serial => 1,
66            Self::Mpi { nranks, .. } => nranks,
67        }
68    }
69
70    /// Returns the partitioning strategy.
71    pub fn partitioning(self) -> Partitioning {
72        match self {
73            Self::Serial => Partitioning::Contiguous,
74            Self::Mpi { partitioning, .. } => partitioning,
75        }
76    }
77
78    /// Returns this distribution with a new partitioning strategy.
79    pub fn with_partitioning(self, partitioning: Partitioning) -> Self {
80        match self {
81            Self::Serial => Self::Serial,
82            Self::Mpi { rank, nranks, .. } => Self::Mpi {
83                rank,
84                nranks,
85                partitioning,
86            },
87        }
88    }
89}
90
91/// Strategy for partitioning input rows across ranks.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
93pub enum Partitioning {
94    /// Each rank reads a contiguous global row range.
95    #[default]
96    Contiguous,
97
98    /// Each rank reads whole source fragments, such as files or row groups, round-robin.
99    FileGroups,
100
101    /// Rank r keeps rows where global_row % nranks == r.
102    /// Deterministic, but usually slower.
103    Rows,
104}
105
106/// Options controlling event-source reads.
107#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
108pub struct ReadPlan {
109    /// Optional maximum output batch size.
110    pub chunk_size: Option<usize>,
111
112    #[cfg(feature = "mpi")]
113    /// MPI distribution.
114    pub distribution: Distribution,
115}
116
117impl ReadPlan {
118    /// Creates a serial read plan.
119    pub fn serial() -> Self {
120        Self::default()
121    }
122
123    /// Returns the current rank.
124    pub fn rank(&self) -> usize {
125        #[cfg(feature = "mpi")]
126        {
127            self.distribution.rank()
128        }
129
130        #[cfg(not(feature = "mpi"))]
131        {
132            0
133        }
134    }
135
136    /// Returns the number of ranks.
137    pub fn nranks(&self) -> usize {
138        #[cfg(feature = "mpi")]
139        {
140            self.distribution.nranks()
141        }
142
143        #[cfg(not(feature = "mpi"))]
144        {
145            1
146        }
147    }
148
149    /// Returns whether reads are distributed.
150    pub fn is_distributed(&self) -> bool {
151        self.nranks() > 1
152    }
153
154    /// Returns the low-level fragment-partitioning strategy.
155    pub fn fragment_partitioning(&self) -> FragmentPartitioning {
156        #[cfg(feature = "mpi")]
157        {
158            match self.distribution.partitioning() {
159                Partitioning::Contiguous => FragmentPartitioning::Contiguous,
160                Partitioning::FileGroups => FragmentPartitioning::RoundRobinFragments,
161                Partitioning::Rows => FragmentPartitioning::StridedRows,
162            }
163        }
164
165        #[cfg(not(feature = "mpi"))]
166        {
167            FragmentPartitioning::Contiguous
168        }
169    }
170}
171
172/// Options controlling event-sink writes.
173#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
174pub struct WritePlan {
175    #[cfg(feature = "mpi")]
176    /// MPI distribution.
177    pub distribution: Distribution,
178}
179
180impl From<ReadPlan> for WritePlan {
181    #[cfg_attr(not(feature = "mpi"), allow(unused_variables))]
182    fn from(plan: ReadPlan) -> Self {
183        Self {
184            #[cfg(feature = "mpi")]
185            distribution: plan.distribution,
186        }
187    }
188}
189
190impl WritePlan {
191    /// Returns the current rank.
192    pub fn rank(&self) -> usize {
193        #[cfg(feature = "mpi")]
194        {
195            self.distribution.rank()
196        }
197
198        #[cfg(not(feature = "mpi"))]
199        {
200            0
201        }
202    }
203
204    /// Returns the number of ranks.
205    pub fn nranks(&self) -> usize {
206        #[cfg(feature = "mpi")]
207        {
208            self.distribution.nranks()
209        }
210
211        #[cfg(not(feature = "mpi"))]
212        {
213            1
214        }
215    }
216
217    /// Returns whether writes are distributed.
218    pub fn is_distributed(&self) -> bool {
219        self.nranks() > 1
220    }
221}
222
223/// Low-level assignment of source fragments or rows.
224#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
225pub enum FragmentPartitioning {
226    /// Contiguous global row ranges.
227    Contiguous,
228    /// Whole fragments assigned round-robin.
229    RoundRobinFragments,
230    /// Individual rows assigned by global index modulo rank count.
231    StridedRows,
232}
233
234/// Optional performance and planning capabilities of an [`EventSource`].
235#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
236pub struct SourceCapabilities {
237    /// Exact event count is available cheaply.
238    pub exact_len: bool,
239    /// Exact weighted total is available cheaply.
240    pub exact_weighted_total: bool,
241    /// Arbitrary row ranges can be read.
242    pub random_access: bool,
243    /// Distributed row assignment is deterministic.
244    pub deterministic_partitioning: bool,
245    /// Filters can be pushed into the source.
246    pub predicate_pushdown: bool,
247    /// Column projection can be pushed into the source.
248    pub projection_pushdown: bool,
249    /// Batches can be streamed without full materialization.
250    pub streaming: bool,
251}
252
253/// Sendable iterator of fallible event batches.
254pub type EventBatchIter = Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>;
255
256/// Thread-safe producer of schema-compatible event batches.
257pub trait EventSource: Send + Sync {
258    /// Returns the source schema.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`LadduDataError`] when source metadata cannot be read or
263    /// interpreted as a logical schema.
264    fn schema(&self) -> LadduDataResult<Arc<Schema>>;
265
266    /// Returns optional source capabilities.
267    fn capabilities(&self) -> SourceCapabilities {
268        SourceCapabilities::default()
269    }
270
271    /// Returns the exact event count when cheaply available.
272    ///
273    /// # Errors
274    ///
275    /// Returns [`LadduDataError`] when the source cannot read the metadata
276    /// needed to determine its event count.
277    fn num_events(&self) -> LadduDataResult<Option<u64>> {
278        Ok(None)
279    }
280
281    /// Returns the exact sum of event weights when cheaply available.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`LadduDataError`] when source weights or their metadata cannot
286    /// be read.
287    fn weighted_total(&self) -> LadduDataResult<Option<f64>> {
288        Ok(None)
289    }
290
291    /// Opens a batch iterator using `plan`.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`LadduDataError`] when `plan` is invalid or the source cannot
296    /// initialize the requested read.
297    fn batches(&self, plan: ReadPlan) -> LadduDataResult<EventBatchIter>;
298}
299
300/// Consumer of schema-compatible event batches.
301pub trait EventSink: Send {
302    /// Returns whether written batches remain resident in memory.
303    fn retains_batches(&self) -> bool {
304        false
305    }
306
307    /// Begins a write operation.
308    ///
309    /// # Errors
310    ///
311    /// Returns [`LadduDataError`] when the plan or schema is unsupported or
312    /// output initialization fails.
313    fn begin(&mut self, schema: Arc<Schema>, plan: WritePlan) -> LadduDataResult<()>;
314
315    /// Writes one batch.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`LadduDataError`] when the batch schema is incompatible or the
320    /// output cannot be written.
321    fn write_batch(&mut self, batch: &EventBatch) -> LadduDataResult<()>;
322
323    /// Finishes and flushes the write operation.
324    ///
325    /// # Errors
326    ///
327    /// Returns [`LadduDataError`] when buffered output cannot be finalized.
328    fn finish(&mut self) -> LadduDataResult<()>;
329}
330
331/// Metadata describing one addressable source fragment.
332#[derive(Clone, Debug)]
333pub struct DataFragment<K> {
334    /// Source-specific fragment key.
335    pub key: K,
336    /// Global row offset.
337    pub global_start: u64,
338    /// Number of rows.
339    pub rows: u64,
340}
341
342/// Planned read of one source fragment.
343#[derive(Clone, Debug)]
344pub struct FragmentRead<K> {
345    /// Source-specific fragment key.
346    pub key: K,
347    /// Rows selected from the fragment.
348    pub selection: FragmentSelection,
349}
350
351/// Row selection within one source fragment.
352#[derive(Clone, Copy, Debug)]
353pub enum FragmentSelection {
354    /// Contiguous local row range.
355    Range {
356        /// First local row.
357        local_start: usize,
358        /// Number of local rows.
359        local_len: usize,
360    },
361    /// Rows assigned by global index modulo rank count.
362    StridedRows {
363        /// Global offset of the fragment.
364        global_start: u64,
365        /// Number of rows in the fragment.
366        rows: usize,
367        /// Current rank.
368        rank: usize,
369        /// Number of ranks.
370        nranks: usize,
371    },
372}
373
374/// Event source composed of independently addressable fragments.
375pub trait FragmentedSource: Send + Sync {
376    /// Source-specific fragment key.
377    type Key: Clone + Send + Sync + 'static;
378
379    /// Lists all fragments in global row order.
380    ///
381    /// # Errors
382    ///
383    /// Returns [`LadduDataError`] when fragment metadata cannot be read.
384    fn fragments(&self) -> LadduDataResult<Vec<DataFragment<Self::Key>>>;
385
386    /// Reads a contiguous range within one fragment.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`LadduDataError`] when the key, range, or chunk size is invalid
391    /// or fragment data cannot be read.
392    fn read_fragment_range(
393        &self,
394        key: &Self::Key,
395        local_start: usize,
396        local_len: usize,
397        chunk_size: Option<usize>,
398    ) -> LadduDataResult<EventBatchIter>;
399}
400
401/// Creates a planned batch iterator for a fragmented source.
402///
403/// # Errors
404///
405/// Returns [`LadduDataError`] when the read plan is invalid, fragment metadata
406/// cannot be loaded, or the iterator cannot be initialized.
407pub fn fragmented_batches<S>(source: Arc<S>, plan: ReadPlan) -> LadduDataResult<EventBatchIter>
408where
409    S: FragmentedSource + 'static,
410{
411    let iter = FragmentBatchIter::new(source, plan)?;
412
413    if plan.chunk_size.is_none() {
414        Ok(Box::new(CoalescedBatchIter::new(iter)))
415    } else {
416        Ok(Box::new(iter))
417    }
418}
419
420/// Assigns source fragments or rows according to a read plan.
421///
422/// # Errors
423///
424/// Returns [`LadduDataError`] when rank settings are invalid or fragment sizes
425/// cannot be represented on this platform.
426pub fn plan_fragments<K: Clone>(
427    fragments: &[DataFragment<K>],
428    plan: ReadPlan,
429) -> LadduDataResult<Vec<FragmentRead<K>>> {
430    let total_rows: u64 = fragments.iter().map(|f| f.rows).sum();
431    let rank = plan.rank();
432    let nranks = plan.nranks();
433
434    if nranks == 1 {
435        return fragments
436            .iter()
437            .map(|f| {
438                Ok(FragmentRead {
439                    key: f.key.clone(),
440                    selection: FragmentSelection::Range {
441                        local_start: 0,
442                        local_len: usize_from_u64(f.rows)?,
443                    },
444                })
445            })
446            .collect();
447    }
448
449    match plan.fragment_partitioning() {
450        FragmentPartitioning::Contiguous => contiguous_plan(fragments, total_rows, rank, nranks),
451        FragmentPartitioning::RoundRobinFragments => {
452            round_robin_fragment_plan(fragments, rank, nranks)
453        }
454        FragmentPartitioning::StridedRows => strided_row_plan(fragments, rank, nranks),
455    }
456}
457
458fn contiguous_plan<K: Clone>(
459    fragments: &[DataFragment<K>],
460    total_rows: u64,
461    rank: usize,
462    nranks: usize,
463) -> LadduDataResult<Vec<FragmentRead<K>>> {
464    let rank_start = total_rows * rank as u64 / nranks as u64;
465    let rank_end = total_rows * (rank as u64 + 1) / nranks as u64;
466
467    let mut out = Vec::new();
468
469    for f in fragments {
470        let frag_start = f.global_start;
471        let frag_end = f.global_start + f.rows;
472
473        let start = rank_start.max(frag_start);
474        let end = rank_end.min(frag_end);
475
476        if start < end {
477            out.push(FragmentRead {
478                key: f.key.clone(),
479                selection: FragmentSelection::Range {
480                    local_start: usize_from_u64(start - frag_start)?,
481                    local_len: usize_from_u64(end - start)?,
482                },
483            });
484        }
485    }
486
487    Ok(out)
488}
489
490fn round_robin_fragment_plan<K: Clone>(
491    fragments: &[DataFragment<K>],
492    rank: usize,
493    nranks: usize,
494) -> LadduDataResult<Vec<FragmentRead<K>>> {
495    let mut out = Vec::new();
496
497    for (i, f) in fragments.iter().enumerate() {
498        if i % nranks == rank {
499            out.push(FragmentRead {
500                key: f.key.clone(),
501                selection: FragmentSelection::Range {
502                    local_start: 0,
503                    local_len: usize_from_u64(f.rows)?,
504                },
505            });
506        }
507    }
508
509    Ok(out)
510}
511
512fn strided_row_plan<K: Clone>(
513    fragments: &[DataFragment<K>],
514    rank: usize,
515    nranks: usize,
516) -> LadduDataResult<Vec<FragmentRead<K>>> {
517    fragments
518        .iter()
519        .map(|f| {
520            Ok(FragmentRead {
521                key: f.key.clone(),
522                selection: FragmentSelection::StridedRows {
523                    global_start: f.global_start,
524                    rows: usize_from_u64(f.rows)?,
525                    rank,
526                    nranks,
527                },
528            })
529        })
530        .collect()
531}
532
533fn usize_from_u64(value: u64) -> LadduDataResult<usize> {
534    usize::try_from(value).map_err(|_| LadduDataError::InvalidArgument("row count exceeds usize"))
535}
536
537pub(crate) struct FragmentBatchIter<S>
538where
539    S: FragmentedSource,
540{
541    source: Arc<S>,
542    reads: Vec<FragmentRead<S::Key>>,
543    read_index: usize,
544    current: Option<EventBatchIter>,
545    chunk_size: Option<usize>,
546}
547
548impl<S> FragmentBatchIter<S>
549where
550    S: FragmentedSource,
551{
552    pub(crate) fn new(source: Arc<S>, plan: ReadPlan) -> LadduDataResult<Self> {
553        let fragments = source.fragments()?;
554        let reads = plan_fragments(&fragments, plan)?;
555
556        Ok(Self {
557            source,
558            reads,
559            read_index: 0,
560            current: None,
561            chunk_size: plan.chunk_size,
562        })
563    }
564}
565
566impl<S> Iterator for FragmentBatchIter<S>
567where
568    S: FragmentedSource,
569{
570    type Item = LadduDataResult<EventBatch>;
571
572    fn next(&mut self) -> Option<Self::Item> {
573        loop {
574            if let Some(iter) = self.current.as_mut() {
575                match iter.next() {
576                    Some(batch) => return Some(batch),
577                    None => self.current = None,
578                }
579            }
580
581            let read = self.reads.get(self.read_index)?.clone();
582            self.read_index += 1;
583
584            let next_iter = match read.selection {
585                FragmentSelection::Range {
586                    local_start,
587                    local_len,
588                } => self.source.read_fragment_range(
589                    &read.key,
590                    local_start,
591                    local_len,
592                    self.chunk_size,
593                ),
594
595                FragmentSelection::StridedRows {
596                    global_start,
597                    rows,
598                    rank,
599                    nranks,
600                } => {
601                    let inner =
602                        self.source
603                            .read_fragment_range(&read.key, 0, rows, self.chunk_size);
604
605                    inner.and_then(|iter| {
606                        let iter = StridedRowsBatchIter::new(iter, global_start, rank, nranks)?;
607                        Ok(Box::new(iter) as EventBatchIter)
608                    })
609                }
610            };
611
612            match next_iter {
613                Ok(iter) => self.current = Some(iter),
614                Err(err) => return Some(Err(err)),
615            }
616        }
617    }
618}
619
620pub(crate) struct SliceBatchIter<I> {
621    inner: I,
622    start: usize,
623    end: usize,
624    consumed: usize,
625}
626
627impl<I> SliceBatchIter<I> {
628    pub(crate) fn new(inner: I, start: usize, len: usize) -> LadduDataResult<Self> {
629        let end = start
630            .checked_add(len)
631            .ok_or(LadduDataError::InvalidArgument(
632                "slice range overflows usize",
633            ))?;
634        Ok(Self {
635            inner,
636            start,
637            end,
638            consumed: 0,
639        })
640    }
641}
642
643impl<I> Iterator for SliceBatchIter<I>
644where
645    I: Iterator<Item = LadduDataResult<EventBatch>>,
646{
647    type Item = LadduDataResult<EventBatch>;
648
649    fn next(&mut self) -> Option<Self::Item> {
650        while self.consumed < self.end {
651            let batch = match self.inner.next()? {
652                Ok(batch) => batch,
653                Err(err) => return Some(Err(err)),
654            };
655
656            let batch_start = self.consumed;
657            let batch_end = batch_start + batch.len();
658            self.consumed = batch_end;
659
660            let lo = self.start.max(batch_start);
661            let hi = self.end.min(batch_end);
662
663            if lo >= hi {
664                continue;
665            }
666
667            let local_lo = lo - batch_start;
668            let local_hi = hi - batch_start;
669
670            return Some(Ok(batch.slice(local_lo, local_hi)));
671        }
672
673        None
674    }
675}
676
677pub(crate) struct StridedRowsBatchIter<I> {
678    inner: I,
679    global_start: u64,
680    consumed: u64,
681    rank: usize,
682    nranks: usize,
683}
684
685impl<I> StridedRowsBatchIter<I> {
686    pub(crate) fn new(
687        inner: I,
688        global_start: u64,
689        rank: usize,
690        nranks: usize,
691    ) -> LadduDataResult<Self> {
692        if nranks == 0 {
693            return Err(LadduDataError::InvalidArgument("nranks must be nonzero"));
694        }
695        if rank >= nranks {
696            return Err(LadduDataError::InvalidArgument(
697                "rank must be less than nranks",
698            ));
699        }
700        Ok(Self {
701            inner,
702            global_start,
703            consumed: 0,
704            rank,
705            nranks,
706        })
707    }
708}
709
710impl<I> Iterator for StridedRowsBatchIter<I>
711where
712    I: Iterator<Item = LadduDataResult<EventBatch>>,
713{
714    type Item = LadduDataResult<EventBatch>;
715
716    fn next(&mut self) -> Option<Self::Item> {
717        loop {
718            let batch = match self.inner.next()? {
719                Ok(batch) => batch,
720                Err(err) => return Some(Err(err)),
721            };
722
723            let batch_global_start = self.global_start + self.consumed;
724            self.consumed = self.consumed.saturating_add(batch.len() as u64);
725
726            let rows: Vec<usize> = (0..batch.len())
727                .filter(|&i| {
728                    ((batch_global_start + i as u64) % self.nranks as u64) == self.rank as u64
729                })
730                .collect();
731
732            if rows.is_empty() {
733                continue;
734            }
735
736            return Some(Ok(batch.select(&rows)));
737        }
738    }
739}
740
741pub(crate) struct CoalescedBatchIter<I> {
742    inner: Option<I>,
743    emitted: bool,
744}
745
746impl<I> CoalescedBatchIter<I> {
747    pub(crate) fn new(inner: I) -> Self {
748        Self {
749            inner: Some(inner),
750            emitted: false,
751        }
752    }
753}
754
755impl<I> Iterator for CoalescedBatchIter<I>
756where
757    I: Iterator<Item = LadduDataResult<EventBatch>>,
758{
759    type Item = LadduDataResult<EventBatch>;
760
761    fn next(&mut self) -> Option<Self::Item> {
762        if self.emitted {
763            return None;
764        }
765
766        self.emitted = true;
767
768        let inner = self.inner.as_mut()?;
769        let mut batches = Vec::new();
770
771        for batch in inner {
772            match batch {
773                Ok(batch) => batches.push(batch),
774                Err(err) => return Some(Err(err)),
775            }
776        }
777
778        if batches.is_empty() {
779            None
780        } else {
781            Some(EventBatch::concat(&batches))
782        }
783    }
784}
785
786/// Resolves a base output path for serial or distributed writes.
787#[derive(Clone, Debug)]
788pub struct OutputPath {
789    base: PathBuf,
790    mode: OutputMode,
791}
792
793/// Policy for resolving a concrete output path.
794#[derive(Clone, Copy, Debug, Default)]
795pub enum OutputMode {
796    /// Select single-file or per-rank output from the write plan.
797    #[default]
798    Auto,
799    /// Write exactly one file; invalid for distributed writes.
800    SingleFile,
801    /// Write a rank-specific file.
802    PerRankFiles,
803}
804
805impl OutputPath {
806    /// Creates an automatically resolved output path.
807    pub fn new(path: impl Into<PathBuf>) -> Self {
808        Self {
809            base: path.into(),
810            mode: OutputMode::Auto,
811        }
812    }
813
814    /// Returns this path with an explicit output mode.
815    pub fn with_mode(mut self, mode: OutputMode) -> Self {
816        self.mode = mode;
817        self
818    }
819
820    /// Returns the unresolved base path.
821    pub fn base(&self) -> &Path {
822        &self.base
823    }
824
825    /// Returns the output mode.
826    pub fn mode(&self) -> OutputMode {
827        self.mode
828    }
829
830    /// Resolves the concrete path for a write plan.
831    ///
832    /// # Errors
833    ///
834    /// Returns [`LadduDataError`] when single-file output is requested for a
835    /// distributed plan.
836    pub fn resolve(&self, plan: WritePlan, default_extension: &str) -> LadduDataResult<PathBuf> {
837        let mode = match self.mode {
838            OutputMode::Auto if plan.is_distributed() => OutputMode::PerRankFiles,
839            OutputMode::Auto => OutputMode::SingleFile,
840            mode => mode,
841        };
842
843        match mode {
844            OutputMode::SingleFile => {
845                if plan.is_distributed() {
846                    return Err(LadduDataError::Sink(
847                        "single-file output is unsafe with multiple MPI ranks; use per-rank output"
848                            .into(),
849                    ));
850                }
851
852                Ok(self.base.clone())
853            }
854
855            OutputMode::PerRankFiles => Ok(per_rank_path(
856                &self.base,
857                plan.rank(),
858                plan.nranks(),
859                default_extension,
860            )),
861
862            OutputMode::Auto => unreachable!(),
863        }
864    }
865
866    /// Creates a file's parent directories when absent.
867    ///
868    /// # Errors
869    ///
870    /// Returns [`LadduDataError`] when a required directory cannot be created.
871    pub fn create_parent_dirs(path: &Path) -> LadduDataResult<()> {
872        if let Some(parent) = path.parent()
873            && !parent.as_os_str().is_empty()
874        {
875            fs::create_dir_all(parent).map_err(|e| LadduDataError::Sink(e.to_string()))?;
876        }
877
878        Ok(())
879    }
880}
881
882fn per_rank_path(base: &Path, rank: usize, nranks: usize, default_extension: &str) -> PathBuf {
883    if base.extension().is_none() {
884        let ext = default_extension.trim_start_matches('.');
885        return base.join(format!("part-rank{rank:05}-of{nranks:05}.{ext}"));
886    }
887
888    let parent = base.parent().unwrap_or_else(|| Path::new(""));
889    let stem = base.file_stem().unwrap_or_default().to_string_lossy();
890    let ext = base.extension().unwrap_or_default().to_string_lossy();
891
892    parent.join(format!("{stem}.rank{rank:05}-of{nranks:05}.{ext}"))
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898    use crate::{
899        data::{EventBatch, EventBatchBuilder},
900        schema::Schema,
901    };
902
903    fn v(x: f64) -> RealVec4 {
904        RealVec4 {
905            e: x,
906            px: x,
907            py: x,
908            pz: x,
909        }
910    }
911
912    fn schema() -> Arc<Schema> {
913        Arc::new(Schema::new(["p"], ["id"], true).unwrap())
914    }
915
916    fn batch(start: usize, len: usize) -> EventBatch {
917        let schema = schema();
918        let mut builder = EventBatchBuilder::with_capacity(schema, len);
919
920        for i in start..start + len {
921            builder
922                .push_weighted([v(i as f64)], [i as f64], 100.0 + i as f64)
923                .unwrap();
924        }
925
926        builder.finish().unwrap()
927    }
928
929    fn concat_values(batches: Vec<EventBatch>) -> Vec<f64> {
930        EventBatch::concat(&batches)
931            .unwrap()
932            .scalar_column(0)
933            .to_vec()
934    }
935
936    #[test]
937    fn slice_batch_iter_slices_across_batch_boundaries_without_losing_alignment() {
938        let inner = vec![Ok(batch(0, 3)), Ok(batch(3, 2)), Ok(batch(5, 4))].into_iter();
939
940        let out: Vec<EventBatch> = SliceBatchIter::new(inner, 2, 5)
941            .unwrap()
942            .map(Result::unwrap)
943            .collect();
944
945        let values = concat_values(out);
946        assert_eq!(values, vec![2.0, 3.0, 4.0, 5.0, 6.0]);
947    }
948
949    #[test]
950    fn strided_rows_batch_iter_uses_global_row_numbers_across_batches() {
951        let inner = vec![Ok(batch(0, 4)), Ok(batch(4, 5))].into_iter();
952
953        let out: Vec<EventBatch> = StridedRowsBatchIter::new(inner, 1, 1, 3)
954            .unwrap()
955            .map(Result::unwrap)
956            .collect();
957
958        // Global rows are 1..=9 because global_start = 1.
959        // Rank 1 of 3 keeps global rows 1, 4, 7.
960        // Those correspond to local scalar ids 0, 3, 6.
961        assert_eq!(concat_values(out), vec![0.0, 3.0, 6.0]);
962    }
963
964    #[test]
965    fn coalesced_batch_iter_concatenates_successes_and_propagates_first_error() {
966        let success_inner = vec![Ok(batch(0, 2)), Ok(batch(2, 3))].into_iter();
967        let mut success = CoalescedBatchIter::new(success_inner);
968
969        let merged = success.next().unwrap().unwrap();
970        assert_eq!(merged.scalar_column(0), &[0.0, 1.0, 2.0, 3.0, 4.0]);
971        assert!(success.next().is_none());
972
973        let error_inner = vec![
974            Ok(batch(0, 1)),
975            Err(LadduDataError::Source("boom".into())),
976            Ok(batch(1, 1)),
977        ]
978        .into_iter();
979
980        let err = CoalescedBatchIter::new(error_inner)
981            .next()
982            .unwrap()
983            .unwrap_err();
984
985        assert!(matches!(err, LadduDataError::Source(msg) if msg == "boom"));
986    }
987
988    #[test]
989    fn output_path_resolves_single_file_and_per_rank_names() {
990        let plan = WritePlan::default();
991
992        let single = OutputPath::new(PathBuf::from("events.parquet"))
993            .resolve(plan, "parquet")
994            .unwrap();
995
996        assert_eq!(single, PathBuf::from("events.parquet"));
997
998        let per_rank_with_extension = OutputPath::new(PathBuf::from("events.parquet"))
999            .with_mode(OutputMode::PerRankFiles)
1000            .resolve(plan, "parquet")
1001            .unwrap();
1002
1003        assert_eq!(
1004            per_rank_with_extension,
1005            PathBuf::from("events.rank00000-of00001.parquet")
1006        );
1007
1008        let per_rank_without_extension = OutputPath::new(PathBuf::from("events"))
1009            .with_mode(OutputMode::PerRankFiles)
1010            .resolve(plan, "root")
1011            .unwrap();
1012
1013        assert_eq!(
1014            per_rank_without_extension,
1015            PathBuf::from("events").join("part-rank00000-of00001.root")
1016        );
1017    }
1018
1019    #[test]
1020    fn plan_fragments_serial_mode_keeps_all_fragments_in_order() {
1021        let fragments = vec![
1022            DataFragment {
1023                key: "a",
1024                global_start: 0,
1025                rows: 2,
1026            },
1027            DataFragment {
1028                key: "b",
1029                global_start: 2,
1030                rows: 3,
1031            },
1032        ];
1033
1034        let reads = plan_fragments(&fragments, ReadPlan::default()).unwrap();
1035
1036        assert_eq!(reads.len(), 2);
1037
1038        match &reads[0].selection {
1039            FragmentSelection::Range {
1040                local_start,
1041                local_len,
1042            } => {
1043                assert_eq!((*local_start, *local_len), (0, 2));
1044            }
1045            _ => panic!("expected range read"),
1046        }
1047
1048        match &reads[1].selection {
1049            FragmentSelection::Range {
1050                local_start,
1051                local_len,
1052            } => {
1053                assert_eq!((*local_start, *local_len), (0, 3));
1054            }
1055            _ => panic!("expected range read"),
1056        }
1057    }
1058
1059    use laddu_physics::vectors::RealVec4;
1060    #[cfg(feature = "mpi")]
1061    use mpi::traits::*;
1062    #[cfg(feature = "mpi")]
1063    use mpi_test::mpi_test;
1064
1065    #[cfg(feature = "mpi")]
1066    fn distributed_plan(
1067        partitioning: Partitioning,
1068        world: &impl mpi::topology::Communicator,
1069    ) -> ReadPlan {
1070        ReadPlan {
1071            chunk_size: None,
1072            distribution: Distribution::from_world(world).with_partitioning(partitioning),
1073        }
1074    }
1075
1076    #[cfg(feature = "mpi")]
1077    fn expected_contiguous_global_range(total_rows: u64, rank: usize, nranks: usize) -> (u64, u64) {
1078        let start = total_rows * rank as u64 / nranks as u64;
1079        let end = total_rows * (rank as u64 + 1) / nranks as u64;
1080        (start, end)
1081    }
1082
1083    #[cfg(feature = "mpi")]
1084    #[mpi_test(np = [2, 3, 4])]
1085    fn mpi_contiguous_plan_assigns_disjoint_ranges_covering_all_rows() {
1086        let universe = mpi::initialize().unwrap();
1087        let world = universe.world();
1088
1089        let rank = world.rank() as usize;
1090        let nranks = world.size() as usize;
1091
1092        let fragments = vec![
1093            DataFragment {
1094                key: "a",
1095                global_start: 0,
1096                rows: 4,
1097            },
1098            DataFragment {
1099                key: "b",
1100                global_start: 4,
1101                rows: 5,
1102            },
1103            DataFragment {
1104                key: "c",
1105                global_start: 9,
1106                rows: 3,
1107            },
1108        ];
1109
1110        let total_rows = fragments.iter().map(|f| f.rows).sum::<u64>();
1111        let plan = distributed_plan(Partitioning::Contiguous, &world);
1112        let reads = plan_fragments(&fragments, plan).unwrap();
1113
1114        let assigned_rows: u64 = reads
1115            .iter()
1116            .map(|read| match read.selection {
1117                FragmentSelection::Range { local_len, .. } => local_len as u64,
1118                FragmentSelection::StridedRows { .. } => panic!("expected range selection"),
1119            })
1120            .sum();
1121
1122        let (expected_start, expected_end) =
1123            expected_contiguous_global_range(total_rows, rank, nranks);
1124
1125        assert_eq!(assigned_rows, expected_end - expected_start);
1126
1127        for read in reads {
1128            let fragment = fragments
1129                .iter()
1130                .find(|fragment| fragment.key == read.key)
1131                .unwrap();
1132
1133            match read.selection {
1134                FragmentSelection::Range {
1135                    local_start,
1136                    local_len,
1137                } => {
1138                    let global_start = fragment.global_start + local_start as u64;
1139                    let global_end = global_start + local_len as u64;
1140
1141                    assert!(expected_start <= global_start);
1142                    assert!(global_end <= expected_end);
1143                    assert!(fragment.global_start <= global_start);
1144                    assert!(global_end <= fragment.global_start + fragment.rows);
1145                }
1146                FragmentSelection::StridedRows { .. } => panic!("expected range selection"),
1147            }
1148        }
1149    }
1150
1151    #[cfg(feature = "mpi")]
1152    #[mpi_test(np = [2, 3])]
1153    fn mpi_file_group_plan_assigns_fragment_by_rank_round_robin() {
1154        let universe = mpi::initialize().unwrap();
1155        let world = universe.world();
1156
1157        let rank = world.rank() as usize;
1158        let nranks = world.size() as usize;
1159
1160        let fragments = (0..8)
1161            .map(|i| DataFragment {
1162                key: i,
1163                global_start: 10 * i as u64,
1164                rows: 10,
1165            })
1166            .collect::<Vec<_>>();
1167
1168        let plan = distributed_plan(Partitioning::FileGroups, &world);
1169        let reads = plan_fragments(&fragments, plan).unwrap();
1170
1171        let keys = reads.iter().map(|read| read.key).collect::<Vec<_>>();
1172        let expected = (0..8).filter(|i| i % nranks == rank).collect::<Vec<_>>();
1173
1174        assert_eq!(keys, expected);
1175
1176        for read in reads {
1177            match read.selection {
1178                FragmentSelection::Range {
1179                    local_start,
1180                    local_len,
1181                } => {
1182                    assert_eq!(local_start, 0);
1183                    assert_eq!(local_len, 10);
1184                }
1185                FragmentSelection::StridedRows { .. } => panic!("expected range selection"),
1186            }
1187        }
1188    }
1189
1190    #[cfg(feature = "mpi")]
1191    #[mpi_test(np = [2, 3, 4])]
1192    fn mpi_rows_plan_assigns_strided_row_selection_with_world_rank() {
1193        let universe = mpi::initialize().unwrap();
1194        let world = universe.world();
1195
1196        let rank = world.rank() as usize;
1197        let nranks = world.size() as usize;
1198
1199        let fragments = vec![
1200            DataFragment {
1201                key: "a",
1202                global_start: 0,
1203                rows: 4,
1204            },
1205            DataFragment {
1206                key: "b",
1207                global_start: 4,
1208                rows: 5,
1209            },
1210        ];
1211
1212        let plan = distributed_plan(Partitioning::Rows, &world);
1213        let reads = plan_fragments(&fragments, plan).unwrap();
1214
1215        assert_eq!(reads.len(), fragments.len());
1216
1217        for (read, fragment) in reads.iter().zip(fragments.iter()) {
1218            assert_eq!(read.key, fragment.key);
1219
1220            match read.selection {
1221                FragmentSelection::StridedRows {
1222                    global_start,
1223                    rows,
1224                    rank: selected_rank,
1225                    nranks: selected_nranks,
1226                } => {
1227                    assert_eq!(global_start, fragment.global_start);
1228                    assert_eq!(rows, fragment.rows as usize);
1229                    assert_eq!(selected_rank, rank);
1230                    assert_eq!(selected_nranks, nranks);
1231                }
1232                FragmentSelection::Range { .. } => panic!("expected strided selection"),
1233            }
1234        }
1235    }
1236
1237    #[cfg(feature = "mpi")]
1238    #[mpi_test(np = [2, 3])]
1239    fn mpi_read_plan_and_write_plan_reflect_world_distribution() {
1240        let universe = mpi::initialize().unwrap();
1241        let world = universe.world();
1242
1243        let read_plan = ReadPlan {
1244            chunk_size: Some(7),
1245            distribution: Distribution::from_world(&world).with_partitioning(Partitioning::Rows),
1246        };
1247
1248        assert!(read_plan.is_distributed());
1249        assert_eq!(read_plan.rank(), world.rank() as usize);
1250        assert_eq!(read_plan.nranks(), world.size() as usize);
1251
1252        match read_plan.fragment_partitioning() {
1253            FragmentPartitioning::StridedRows => {}
1254            _ => panic!("expected strided row partitioning"),
1255        }
1256
1257        let write_plan = WritePlan::from(read_plan);
1258
1259        assert!(write_plan.is_distributed());
1260        assert_eq!(write_plan.rank(), world.rank() as usize);
1261        assert_eq!(write_plan.nranks(), world.size() as usize);
1262    }
1263}