1use serde::{Deserialize, Serialize};
2use std::fmt::Display;
3use std::sync::Arc;
4
5use crate::{LadduDataError, LadduDataResult, data::EventBatch, schema::Schema};
6
7pub mod memory;
9mod output;
10pub mod parquet;
12pub mod root;
14
15mod source;
16
17pub use output::{OutputMode, OutputPath};
18
19pub(crate) use source::{SourceBuild, SourceBuildOptions, build_source};
20
21pub(crate) fn source_error(
23 operation: impl AsRef<str>,
24 resource: impl Display,
25 cause: impl Display,
26) -> LadduDataError {
27 LadduDataError::Source(format!("{} `{resource}`: {cause}", operation.as_ref()))
28}
29
30pub(crate) fn sink_error(
32 operation: impl AsRef<str>,
33 resource: impl Display,
34 cause: impl Display,
35) -> LadduDataError {
36 LadduDataError::Sink(format!("{} `{resource}`: {cause}", operation.as_ref()))
37}
38
39#[cfg(test)]
40mod context_tests {
41 use super::*;
42
43 #[test]
44 fn source_and_sink_context_have_stable_operation_resource_format() {
45 assert!(matches!(
46 source_error("read ROOT tree", "events.root::events", "branch failed"),
47 LadduDataError::Source(message)
48 if message == "read ROOT tree `events.root::events`: branch failed"
49 ));
50 assert!(matches!(
51 sink_error("write Parquet file", "events.parquet", "disk full"),
52 LadduDataError::Sink(message)
53 if message == "write Parquet file `events.parquet`: disk full"
54 ));
55 }
56}
57
58#[cfg(test)]
59mod contract_tests;
60
61#[cfg(feature = "mpi")]
62#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
64pub enum Distribution {
65 #[default]
67 Serial,
68 Mpi {
70 rank: usize,
72 nranks: usize,
74 partitioning: Partitioning,
76 },
77}
78
79#[cfg(feature = "mpi")]
80impl Distribution {
81 pub fn serial() -> Self {
83 Self::Serial
84 }
85
86 pub fn from_world<C>(world: &C) -> Self
88 where
89 C: mpi::topology::Communicator,
90 {
91 Self::Mpi {
92 rank: world.rank() as usize,
93 nranks: world.size() as usize,
94 partitioning: Partitioning::default(),
95 }
96 }
97
98 pub fn rank(self) -> usize {
100 match self {
101 Self::Serial => 0,
102 Self::Mpi { rank, .. } => rank,
103 }
104 }
105
106 pub fn nranks(self) -> usize {
108 match self {
109 Self::Serial => 1,
110 Self::Mpi { nranks, .. } => nranks,
111 }
112 }
113
114 pub fn partitioning(self) -> Partitioning {
116 match self {
117 Self::Serial => Partitioning::Contiguous,
118 Self::Mpi { partitioning, .. } => partitioning,
119 }
120 }
121
122 pub fn with_partitioning(self, partitioning: Partitioning) -> Self {
124 match self {
125 Self::Serial => Self::Serial,
126 Self::Mpi { rank, nranks, .. } => Self::Mpi {
127 rank,
128 nranks,
129 partitioning,
130 },
131 }
132 }
133}
134
135#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub enum Partitioning {
138 #[default]
140 Contiguous,
141
142 FileGroups,
144
145 Rows,
148}
149
150#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
152pub struct ReadPlan {
153 pub chunk_size: Option<usize>,
155
156 #[cfg(feature = "mpi")]
157 pub distribution: Distribution,
159}
160
161impl ReadPlan {
162 pub fn serial() -> Self {
164 Self::default()
165 }
166
167 pub fn rank(&self) -> usize {
169 #[cfg(feature = "mpi")]
170 {
171 self.distribution.rank()
172 }
173
174 #[cfg(not(feature = "mpi"))]
175 {
176 0
177 }
178 }
179
180 pub fn nranks(&self) -> usize {
182 #[cfg(feature = "mpi")]
183 {
184 self.distribution.nranks()
185 }
186
187 #[cfg(not(feature = "mpi"))]
188 {
189 1
190 }
191 }
192
193 pub fn is_distributed(&self) -> bool {
195 self.nranks() > 1
196 }
197
198 pub fn fragment_partitioning(&self) -> FragmentPartitioning {
200 #[cfg(feature = "mpi")]
201 {
202 match self.distribution.partitioning() {
203 Partitioning::Contiguous => FragmentPartitioning::Contiguous,
204 Partitioning::FileGroups => FragmentPartitioning::RoundRobinFragments,
205 Partitioning::Rows => FragmentPartitioning::StridedRows,
206 }
207 }
208
209 #[cfg(not(feature = "mpi"))]
210 {
211 FragmentPartitioning::Contiguous
212 }
213 }
214}
215
216#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
218pub struct WritePlan {
219 #[cfg(feature = "mpi")]
220 pub distribution: Distribution,
222}
223
224impl From<ReadPlan> for WritePlan {
225 #[cfg_attr(not(feature = "mpi"), allow(unused_variables))]
226 fn from(plan: ReadPlan) -> Self {
227 Self {
228 #[cfg(feature = "mpi")]
229 distribution: plan.distribution,
230 }
231 }
232}
233
234impl WritePlan {
235 pub fn rank(&self) -> usize {
237 #[cfg(feature = "mpi")]
238 {
239 self.distribution.rank()
240 }
241
242 #[cfg(not(feature = "mpi"))]
243 {
244 0
245 }
246 }
247
248 pub fn nranks(&self) -> usize {
250 #[cfg(feature = "mpi")]
251 {
252 self.distribution.nranks()
253 }
254
255 #[cfg(not(feature = "mpi"))]
256 {
257 1
258 }
259 }
260
261 pub fn is_distributed(&self) -> bool {
263 self.nranks() > 1
264 }
265}
266
267#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
269pub enum FragmentPartitioning {
270 Contiguous,
272 RoundRobinFragments,
274 StridedRows,
276}
277
278#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
280pub struct SourceCapabilities {
281 pub exact_len: bool,
283 pub exact_weighted_total: bool,
285 pub random_access: bool,
287 pub deterministic_partitioning: bool,
289 pub predicate_pushdown: bool,
291 pub projection_pushdown: bool,
293 pub streaming: bool,
295}
296
297pub type EventBatchIter = Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>;
299
300pub trait EventSource: Send + Sync {
302 fn schema(&self) -> LadduDataResult<Arc<Schema>>;
309
310 fn capabilities(&self) -> SourceCapabilities {
312 SourceCapabilities::default()
313 }
314
315 fn num_events(&self) -> LadduDataResult<Option<u64>> {
322 Ok(None)
323 }
324
325 fn weighted_total(&self) -> LadduDataResult<Option<f64>> {
332 Ok(None)
333 }
334
335 fn batches(&self, plan: ReadPlan) -> LadduDataResult<EventBatchIter>;
342}
343
344pub trait EventSink: Send {
346 fn retains_batches(&self) -> bool {
348 false
349 }
350
351 fn begin(&mut self, schema: Arc<Schema>, plan: WritePlan) -> LadduDataResult<()>;
358
359 fn write_batch(&mut self, batch: &EventBatch) -> LadduDataResult<()>;
366
367 fn finish(&mut self) -> LadduDataResult<()>;
373
374 fn abort(&mut self) -> LadduDataResult<()> {
384 Ok(())
385 }
386}
387
388#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
390pub(crate) enum SinkState {
391 #[default]
392 Idle,
393 Writing,
394 Failed,
395}
396
397#[derive(Clone, Debug)]
399pub struct DataFragment<K> {
400 pub key: K,
402 pub global_start: u64,
404 pub rows: u64,
406}
407
408#[derive(Clone, Debug)]
410pub struct FragmentRead<K> {
411 pub key: K,
413 pub selection: FragmentSelection,
415}
416
417#[derive(Clone, Copy, Debug)]
419pub enum FragmentSelection {
420 Range {
422 local_start: usize,
424 local_len: usize,
426 },
427 StridedRows {
429 global_start: u64,
431 rows: usize,
433 rank: usize,
435 nranks: usize,
437 },
438}
439
440pub trait FragmentedSource: Send + Sync {
442 type Key: Clone + Send + Sync + 'static;
444
445 fn fragments(&self) -> LadduDataResult<Vec<DataFragment<Self::Key>>>;
451
452 fn read_fragment_range(
459 &self,
460 key: &Self::Key,
461 local_start: usize,
462 local_len: usize,
463 chunk_size: Option<usize>,
464 ) -> LadduDataResult<EventBatchIter>;
465}
466
467pub fn fragmented_batches<S>(source: Arc<S>, plan: ReadPlan) -> LadduDataResult<EventBatchIter>
474where
475 S: FragmentedSource + 'static,
476{
477 let iter = FragmentBatchIter::new(source, plan)?;
478
479 if plan.chunk_size.is_none() {
480 Ok(Box::new(CoalescedBatchIter::new(iter)))
481 } else {
482 Ok(Box::new(iter))
483 }
484}
485
486pub fn plan_fragments<K: Clone>(
493 fragments: &[DataFragment<K>],
494 plan: ReadPlan,
495) -> LadduDataResult<Vec<FragmentRead<K>>> {
496 let total_rows: u64 = fragments.iter().map(|f| f.rows).sum();
497 let rank = plan.rank();
498 let nranks = plan.nranks();
499
500 if nranks == 1 {
501 return fragments
502 .iter()
503 .map(|f| {
504 Ok(FragmentRead {
505 key: f.key.clone(),
506 selection: FragmentSelection::Range {
507 local_start: 0,
508 local_len: usize_from_u64(f.rows)?,
509 },
510 })
511 })
512 .collect();
513 }
514
515 match plan.fragment_partitioning() {
516 FragmentPartitioning::Contiguous => contiguous_plan(fragments, total_rows, rank, nranks),
517 FragmentPartitioning::RoundRobinFragments => {
518 round_robin_fragment_plan(fragments, rank, nranks)
519 }
520 FragmentPartitioning::StridedRows => strided_row_plan(fragments, rank, nranks),
521 }
522}
523
524fn contiguous_plan<K: Clone>(
525 fragments: &[DataFragment<K>],
526 total_rows: u64,
527 rank: usize,
528 nranks: usize,
529) -> LadduDataResult<Vec<FragmentRead<K>>> {
530 let rank_start = total_rows * rank as u64 / nranks as u64;
531 let rank_end = total_rows * (rank as u64 + 1) / nranks as u64;
532
533 let mut out = Vec::new();
534
535 for f in fragments {
536 let frag_start = f.global_start;
537 let frag_end = f.global_start + f.rows;
538
539 let start = rank_start.max(frag_start);
540 let end = rank_end.min(frag_end);
541
542 if start < end {
543 out.push(FragmentRead {
544 key: f.key.clone(),
545 selection: FragmentSelection::Range {
546 local_start: usize_from_u64(start - frag_start)?,
547 local_len: usize_from_u64(end - start)?,
548 },
549 });
550 }
551 }
552
553 Ok(out)
554}
555
556fn round_robin_fragment_plan<K: Clone>(
557 fragments: &[DataFragment<K>],
558 rank: usize,
559 nranks: usize,
560) -> LadduDataResult<Vec<FragmentRead<K>>> {
561 let mut out = Vec::new();
562
563 for (i, f) in fragments.iter().enumerate() {
564 if i % nranks == rank {
565 out.push(FragmentRead {
566 key: f.key.clone(),
567 selection: FragmentSelection::Range {
568 local_start: 0,
569 local_len: usize_from_u64(f.rows)?,
570 },
571 });
572 }
573 }
574
575 Ok(out)
576}
577
578fn strided_row_plan<K: Clone>(
579 fragments: &[DataFragment<K>],
580 rank: usize,
581 nranks: usize,
582) -> LadduDataResult<Vec<FragmentRead<K>>> {
583 fragments
584 .iter()
585 .map(|f| {
586 Ok(FragmentRead {
587 key: f.key.clone(),
588 selection: FragmentSelection::StridedRows {
589 global_start: f.global_start,
590 rows: usize_from_u64(f.rows)?,
591 rank,
592 nranks,
593 },
594 })
595 })
596 .collect()
597}
598
599fn usize_from_u64(value: u64) -> LadduDataResult<usize> {
600 usize::try_from(value).map_err(|_| LadduDataError::InvalidArgument("row count exceeds usize"))
601}
602
603pub(crate) struct FragmentBatchIter<S>
604where
605 S: FragmentedSource,
606{
607 source: Arc<S>,
608 reads: Vec<FragmentRead<S::Key>>,
609 state: FragmentBatchState,
610 chunk_size: Option<usize>,
611}
612
613enum FragmentBatchState {
614 NeedFragment {
615 index: usize,
616 },
617 Reading {
618 iter: EventBatchIter,
619 next_index: usize,
620 },
621 Done,
622}
623
624impl<S> FragmentBatchIter<S>
625where
626 S: FragmentedSource,
627{
628 pub(crate) fn new(source: Arc<S>, plan: ReadPlan) -> LadduDataResult<Self> {
629 let fragments = source.fragments()?;
630 let reads = plan_fragments(&fragments, plan)?;
631
632 Ok(Self {
633 source,
634 reads,
635 state: FragmentBatchState::NeedFragment { index: 0 },
636 chunk_size: plan.chunk_size,
637 })
638 }
639
640 fn open_fragment(&self, read: FragmentRead<S::Key>) -> LadduDataResult<EventBatchIter> {
641 match read.selection {
642 FragmentSelection::Range {
643 local_start,
644 local_len,
645 } => {
646 self.source
647 .read_fragment_range(&read.key, local_start, local_len, self.chunk_size)
648 }
649
650 FragmentSelection::StridedRows {
651 global_start,
652 rows,
653 rank,
654 nranks,
655 } => {
656 let inner = self
657 .source
658 .read_fragment_range(&read.key, 0, rows, self.chunk_size);
659
660 inner.and_then(|iter| {
661 let iter = StridedRowsBatchIter::new(iter, global_start, rank, nranks)?;
662 Ok(Box::new(iter) as EventBatchIter)
663 })
664 }
665 }
666 }
667}
668
669impl<S> Iterator for FragmentBatchIter<S>
670where
671 S: FragmentedSource,
672{
673 type Item = LadduDataResult<EventBatch>;
674
675 fn next(&mut self) -> Option<Self::Item> {
676 loop {
677 let state = std::mem::replace(&mut self.state, FragmentBatchState::Done);
678 match state {
679 FragmentBatchState::Done => return None,
680 FragmentBatchState::Reading {
681 mut iter,
682 next_index,
683 } => match iter.next() {
684 Some(batch) => {
685 self.state = FragmentBatchState::Reading { iter, next_index };
686 return Some(batch);
687 }
688 None => {
689 self.state = FragmentBatchState::NeedFragment { index: next_index };
690 }
691 },
692 FragmentBatchState::NeedFragment { index } => {
693 let Some(read) = self.reads.get(index).cloned() else {
694 self.state = FragmentBatchState::Done;
695 return None;
696 };
697
698 let next_index = index.saturating_add(1);
699 match self.open_fragment(read) {
700 Ok(iter) => {
701 self.state = FragmentBatchState::Reading { iter, next_index };
702 }
703 Err(err) => {
704 self.state = FragmentBatchState::NeedFragment { index: next_index };
707 return Some(Err(err));
708 }
709 }
710 }
711 }
712 }
713 }
714}
715
716pub(crate) struct SliceBatchIter<I> {
717 inner: I,
718 start: usize,
719 end: usize,
720 state: SliceBatchState,
721}
722
723#[derive(Clone, Copy)]
724enum SliceBatchState {
725 Reading { consumed: usize },
726 Done,
727}
728
729impl<I> SliceBatchIter<I> {
730 pub(crate) fn new(inner: I, start: usize, len: usize) -> LadduDataResult<Self> {
731 let end = start
732 .checked_add(len)
733 .ok_or(LadduDataError::InvalidArgument(
734 "slice range overflows usize",
735 ))?;
736 Ok(Self {
737 inner,
738 start,
739 end,
740 state: SliceBatchState::Reading { consumed: 0 },
741 })
742 }
743}
744
745impl<I> Iterator for SliceBatchIter<I>
746where
747 I: Iterator<Item = LadduDataResult<EventBatch>>,
748{
749 type Item = LadduDataResult<EventBatch>;
750
751 fn next(&mut self) -> Option<Self::Item> {
752 loop {
753 let SliceBatchState::Reading { consumed } = self.state else {
754 return None;
755 };
756
757 if consumed >= self.end {
758 self.state = SliceBatchState::Done;
759 return None;
760 }
761
762 let Some(item) = self.inner.next() else {
763 self.state = SliceBatchState::Done;
764 return None;
765 };
766 let batch = match item {
767 Ok(batch) => batch,
768 Err(err) => return Some(Err(err)),
769 };
770
771 let batch_start = consumed;
772 let batch_end = batch_start + batch.len();
773 self.state = SliceBatchState::Reading {
774 consumed: batch_end,
775 };
776
777 let lo = self.start.max(batch_start);
778 let hi = self.end.min(batch_end);
779
780 if lo >= hi {
781 continue;
782 }
783
784 let local_lo = lo - batch_start;
785 let local_hi = hi - batch_start;
786
787 return Some(Ok(batch.slice(local_lo, local_hi)));
788 }
789 }
790}
791
792pub(crate) struct StridedRowsBatchIter<I> {
793 inner: I,
794 global_start: u64,
795 rank: usize,
796 nranks: usize,
797 state: StridedRowsBatchState,
798}
799
800#[derive(Clone, Copy)]
801enum StridedRowsBatchState {
802 Reading { consumed: u64 },
803 Done,
804}
805
806impl<I> StridedRowsBatchIter<I> {
807 pub(crate) fn new(
808 inner: I,
809 global_start: u64,
810 rank: usize,
811 nranks: usize,
812 ) -> LadduDataResult<Self> {
813 if nranks == 0 {
814 return Err(LadduDataError::InvalidArgument("nranks must be nonzero"));
815 }
816 if rank >= nranks {
817 return Err(LadduDataError::InvalidArgument(
818 "rank must be less than nranks",
819 ));
820 }
821 Ok(Self {
822 inner,
823 global_start,
824 rank,
825 nranks,
826 state: StridedRowsBatchState::Reading { consumed: 0 },
827 })
828 }
829}
830
831impl<I> Iterator for StridedRowsBatchIter<I>
832where
833 I: Iterator<Item = LadduDataResult<EventBatch>>,
834{
835 type Item = LadduDataResult<EventBatch>;
836
837 fn next(&mut self) -> Option<Self::Item> {
838 loop {
839 let StridedRowsBatchState::Reading { consumed } = self.state else {
840 return None;
841 };
842
843 let Some(item) = self.inner.next() else {
844 self.state = StridedRowsBatchState::Done;
845 return None;
846 };
847 let batch = match item {
848 Ok(batch) => batch,
849 Err(err) => return Some(Err(err)),
850 };
851
852 let batch_global_start = self.global_start + consumed;
853 self.state = StridedRowsBatchState::Reading {
854 consumed: consumed.saturating_add(batch.len() as u64),
855 };
856
857 let rows: Vec<usize> = (0..batch.len())
858 .filter(|&i| {
859 ((batch_global_start + i as u64) % self.nranks as u64) == self.rank as u64
860 })
861 .collect();
862
863 if rows.is_empty() {
864 continue;
865 }
866
867 return Some(Ok(batch.select(&rows)));
868 }
869 }
870}
871
872pub(crate) struct CoalescedBatchIter<I> {
873 state: CoalescedBatchState<I>,
874}
875
876enum CoalescedBatchState<I> {
877 Reading(I),
878 Done,
879}
880
881impl<I> CoalescedBatchIter<I> {
882 pub(crate) fn new(inner: I) -> Self {
883 Self {
884 state: CoalescedBatchState::Reading(inner),
885 }
886 }
887}
888
889impl<I> Iterator for CoalescedBatchIter<I>
890where
891 I: Iterator<Item = LadduDataResult<EventBatch>>,
892{
893 type Item = LadduDataResult<EventBatch>;
894
895 fn next(&mut self) -> Option<Self::Item> {
896 let CoalescedBatchState::Reading(mut inner) =
897 std::mem::replace(&mut self.state, CoalescedBatchState::Done)
898 else {
899 return None;
900 };
901 let mut batches = Vec::new();
902
903 for batch in &mut inner {
904 match batch {
905 Ok(batch) => batches.push(batch),
906 Err(err) => return Some(Err(err)),
907 }
908 }
909
910 if batches.is_empty() {
911 None
912 } else {
913 Some(EventBatch::concat(&batches))
914 }
915 }
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921 use crate::{
922 data::{EventBatch, EventBatchBuilder},
923 schema::Schema,
924 };
925 use std::path::PathBuf;
926
927 fn v(x: f64) -> RealVec4 {
928 RealVec4 {
929 e: x,
930 px: x,
931 py: x,
932 pz: x,
933 }
934 }
935
936 fn schema() -> Arc<Schema> {
937 Arc::new(Schema::new(["p"], ["id"], true).unwrap())
938 }
939
940 fn batch(start: usize, len: usize) -> EventBatch {
941 let schema = schema();
942 let mut builder = EventBatchBuilder::with_capacity(schema, len);
943
944 for i in start..start + len {
945 builder
946 .push_weighted([v(i as f64)], [i as f64], 100.0 + i as f64)
947 .unwrap();
948 }
949
950 builder.finish().unwrap()
951 }
952
953 fn concat_values(batches: Vec<EventBatch>) -> Vec<f64> {
954 EventBatch::concat(&batches)
955 .unwrap()
956 .scalar_column(0)
957 .to_vec()
958 }
959
960 struct FragmentOpenFailureSource;
961
962 impl FragmentedSource for FragmentOpenFailureSource {
963 type Key = usize;
964
965 fn fragments(&self) -> LadduDataResult<Vec<DataFragment<Self::Key>>> {
966 Ok(vec![
967 DataFragment {
968 key: 0,
969 global_start: 0,
970 rows: 1,
971 },
972 DataFragment {
973 key: 1,
974 global_start: 1,
975 rows: 1,
976 },
977 ])
978 }
979
980 fn read_fragment_range(
981 &self,
982 key: &Self::Key,
983 _local_start: usize,
984 _local_len: usize,
985 _chunk_size: Option<usize>,
986 ) -> LadduDataResult<EventBatchIter> {
987 if *key == 0 {
988 return Err(LadduDataError::Source(
989 "first fragment failed to open".into(),
990 ));
991 }
992
993 Ok(Box::new(vec![Ok(batch(10, 1))].into_iter()))
994 }
995 }
996
997 #[test]
998 fn fragment_open_error_is_an_item_and_later_fragments_remain_readable() {
999 let mut iter = FragmentBatchIter::new(
1000 Arc::new(FragmentOpenFailureSource),
1001 ReadPlan {
1002 chunk_size: Some(1),
1003 #[cfg(feature = "mpi")]
1004 distribution: Default::default(),
1005 },
1006 )
1007 .unwrap();
1008
1009 assert!(matches!(
1010 iter.next(),
1011 Some(Err(LadduDataError::Source(message))) if message == "first fragment failed to open"
1012 ));
1013 assert_eq!(iter.next().unwrap().unwrap().scalar_column(0), &[10.0]);
1014 assert!(iter.next().is_none());
1015 assert!(iter.next().is_none());
1016 }
1017
1018 #[test]
1019 fn slice_batch_iter_slices_across_batch_boundaries_without_losing_alignment() {
1020 let inner = vec![Ok(batch(0, 3)), Ok(batch(3, 2)), Ok(batch(5, 4))].into_iter();
1021
1022 let out: Vec<EventBatch> = SliceBatchIter::new(inner, 2, 5)
1023 .unwrap()
1024 .map(Result::unwrap)
1025 .collect();
1026
1027 let values = concat_values(out);
1028 assert_eq!(values, vec![2.0, 3.0, 4.0, 5.0, 6.0]);
1029 }
1030
1031 #[test]
1032 fn slice_batch_iter_skips_empty_batches_and_has_a_repeated_terminal_none() {
1033 let inner = vec![Ok(batch(0, 0)), Ok(batch(0, 2))].into_iter();
1034 let mut iter = SliceBatchIter::new(inner, 0, 2).unwrap();
1035
1036 assert_eq!(iter.next().unwrap().unwrap().len(), 2);
1037 assert!(iter.next().is_none());
1038 assert!(iter.next().is_none());
1039 }
1040
1041 #[test]
1042 fn strided_rows_batch_iter_uses_global_row_numbers_across_batches() {
1043 let inner = vec![Ok(batch(0, 4)), Ok(batch(4, 5))].into_iter();
1044
1045 let out: Vec<EventBatch> = StridedRowsBatchIter::new(inner, 1, 1, 3)
1046 .unwrap()
1047 .map(Result::unwrap)
1048 .collect();
1049
1050 assert_eq!(concat_values(out), vec![0.0, 3.0, 6.0]);
1054 }
1055
1056 #[test]
1057 fn coalesced_batch_iter_concatenates_successes_and_propagates_first_error() {
1058 let success_inner = vec![Ok(batch(0, 2)), Ok(batch(2, 3))].into_iter();
1059 let mut success = CoalescedBatchIter::new(success_inner);
1060
1061 let merged = success.next().unwrap().unwrap();
1062 assert_eq!(merged.scalar_column(0), &[0.0, 1.0, 2.0, 3.0, 4.0]);
1063 assert!(success.next().is_none());
1064
1065 let error_inner = vec![
1066 Ok(batch(0, 1)),
1067 Err(LadduDataError::Source("boom".into())),
1068 Ok(batch(1, 1)),
1069 ]
1070 .into_iter();
1071
1072 let mut error_iter = CoalescedBatchIter::new(error_inner);
1073 let err = error_iter.next().unwrap().unwrap_err();
1074
1075 assert!(matches!(err, LadduDataError::Source(msg) if msg == "boom"));
1076 assert!(error_iter.next().is_none());
1077 assert!(error_iter.next().is_none());
1078
1079 let mut empty =
1080 CoalescedBatchIter::new(Vec::<LadduDataResult<EventBatch>>::new().into_iter());
1081 assert!(empty.next().is_none());
1082 assert!(empty.next().is_none());
1083 }
1084
1085 #[test]
1086 fn output_path_resolves_single_file_and_per_rank_names() {
1087 let plan = WritePlan::default();
1088
1089 let single = OutputPath::new(PathBuf::from("events.parquet"))
1090 .resolve(plan, "parquet")
1091 .unwrap();
1092
1093 assert_eq!(single, PathBuf::from("events.parquet"));
1094
1095 let per_rank_with_extension = OutputPath::new(PathBuf::from("events.parquet"))
1096 .with_mode(OutputMode::PerRankFiles)
1097 .resolve(plan, "parquet")
1098 .unwrap();
1099
1100 assert_eq!(
1101 per_rank_with_extension,
1102 PathBuf::from("events.rank00000-of00001.parquet")
1103 );
1104
1105 let per_rank_without_extension = OutputPath::new(PathBuf::from("events"))
1106 .with_mode(OutputMode::PerRankFiles)
1107 .resolve(plan, "root")
1108 .unwrap();
1109
1110 assert_eq!(
1111 per_rank_without_extension,
1112 PathBuf::from("events").join("part-rank00000-of00001.root")
1113 );
1114 }
1115
1116 #[test]
1117 fn plan_fragments_serial_mode_keeps_all_fragments_in_order() {
1118 let fragments = vec![
1119 DataFragment {
1120 key: "a",
1121 global_start: 0,
1122 rows: 2,
1123 },
1124 DataFragment {
1125 key: "b",
1126 global_start: 2,
1127 rows: 3,
1128 },
1129 ];
1130
1131 let reads = plan_fragments(&fragments, ReadPlan::default()).unwrap();
1132
1133 assert_eq!(reads.len(), 2);
1134
1135 match &reads[0].selection {
1136 FragmentSelection::Range {
1137 local_start,
1138 local_len,
1139 } => {
1140 assert_eq!((*local_start, *local_len), (0, 2));
1141 }
1142 _ => panic!("expected range read"),
1143 }
1144
1145 match &reads[1].selection {
1146 FragmentSelection::Range {
1147 local_start,
1148 local_len,
1149 } => {
1150 assert_eq!((*local_start, *local_len), (0, 3));
1151 }
1152 _ => panic!("expected range read"),
1153 }
1154 }
1155
1156 use laddu_physics::vectors::RealVec4;
1157 #[cfg(feature = "mpi")]
1158 use mpi::traits::*;
1159 #[cfg(feature = "mpi")]
1160 use mpi_test::mpi_test;
1161
1162 #[cfg(feature = "mpi")]
1163 fn distributed_plan(
1164 partitioning: Partitioning,
1165 world: &impl mpi::topology::Communicator,
1166 ) -> ReadPlan {
1167 ReadPlan {
1168 chunk_size: None,
1169 distribution: Distribution::from_world(world).with_partitioning(partitioning),
1170 }
1171 }
1172
1173 #[cfg(feature = "mpi")]
1174 fn expected_contiguous_global_range(total_rows: u64, rank: usize, nranks: usize) -> (u64, u64) {
1175 let start = total_rows * rank as u64 / nranks as u64;
1176 let end = total_rows * (rank as u64 + 1) / nranks as u64;
1177 (start, end)
1178 }
1179
1180 #[cfg(feature = "mpi")]
1181 #[mpi_test(np = [2, 3, 4])]
1182 fn mpi_contiguous_plan_assigns_disjoint_ranges_covering_all_rows() {
1183 let universe = mpi::initialize().unwrap();
1184 let world = universe.world();
1185
1186 let rank = world.rank() as usize;
1187 let nranks = world.size() as usize;
1188
1189 let fragments = vec![
1190 DataFragment {
1191 key: "a",
1192 global_start: 0,
1193 rows: 4,
1194 },
1195 DataFragment {
1196 key: "b",
1197 global_start: 4,
1198 rows: 5,
1199 },
1200 DataFragment {
1201 key: "c",
1202 global_start: 9,
1203 rows: 3,
1204 },
1205 ];
1206
1207 let total_rows = fragments.iter().map(|f| f.rows).sum::<u64>();
1208 let plan = distributed_plan(Partitioning::Contiguous, &world);
1209 let reads = plan_fragments(&fragments, plan).unwrap();
1210
1211 let assigned_rows: u64 = reads
1212 .iter()
1213 .map(|read| match read.selection {
1214 FragmentSelection::Range { local_len, .. } => local_len as u64,
1215 FragmentSelection::StridedRows { .. } => panic!("expected range selection"),
1216 })
1217 .sum();
1218
1219 let (expected_start, expected_end) =
1220 expected_contiguous_global_range(total_rows, rank, nranks);
1221
1222 assert_eq!(assigned_rows, expected_end - expected_start);
1223
1224 for read in reads {
1225 let fragment = fragments
1226 .iter()
1227 .find(|fragment| fragment.key == read.key)
1228 .unwrap();
1229
1230 match read.selection {
1231 FragmentSelection::Range {
1232 local_start,
1233 local_len,
1234 } => {
1235 let global_start = fragment.global_start + local_start as u64;
1236 let global_end = global_start + local_len as u64;
1237
1238 assert!(expected_start <= global_start);
1239 assert!(global_end <= expected_end);
1240 assert!(fragment.global_start <= global_start);
1241 assert!(global_end <= fragment.global_start + fragment.rows);
1242 }
1243 FragmentSelection::StridedRows { .. } => panic!("expected range selection"),
1244 }
1245 }
1246 }
1247
1248 #[cfg(feature = "mpi")]
1249 #[mpi_test(np = [2, 3])]
1250 fn mpi_file_group_plan_assigns_fragment_by_rank_round_robin() {
1251 let universe = mpi::initialize().unwrap();
1252 let world = universe.world();
1253
1254 let rank = world.rank() as usize;
1255 let nranks = world.size() as usize;
1256
1257 let fragments = (0..8)
1258 .map(|i| DataFragment {
1259 key: i,
1260 global_start: 10 * i as u64,
1261 rows: 10,
1262 })
1263 .collect::<Vec<_>>();
1264
1265 let plan = distributed_plan(Partitioning::FileGroups, &world);
1266 let reads = plan_fragments(&fragments, plan).unwrap();
1267
1268 let keys = reads.iter().map(|read| read.key).collect::<Vec<_>>();
1269 let expected = (0..8).filter(|i| i % nranks == rank).collect::<Vec<_>>();
1270
1271 assert_eq!(keys, expected);
1272
1273 for read in reads {
1274 match read.selection {
1275 FragmentSelection::Range {
1276 local_start,
1277 local_len,
1278 } => {
1279 assert_eq!(local_start, 0);
1280 assert_eq!(local_len, 10);
1281 }
1282 FragmentSelection::StridedRows { .. } => panic!("expected range selection"),
1283 }
1284 }
1285 }
1286
1287 #[cfg(feature = "mpi")]
1288 #[mpi_test(np = [2, 3, 4])]
1289 fn mpi_rows_plan_assigns_strided_row_selection_with_world_rank() {
1290 let universe = mpi::initialize().unwrap();
1291 let world = universe.world();
1292
1293 let rank = world.rank() as usize;
1294 let nranks = world.size() as usize;
1295
1296 let fragments = vec![
1297 DataFragment {
1298 key: "a",
1299 global_start: 0,
1300 rows: 4,
1301 },
1302 DataFragment {
1303 key: "b",
1304 global_start: 4,
1305 rows: 5,
1306 },
1307 ];
1308
1309 let plan = distributed_plan(Partitioning::Rows, &world);
1310 let reads = plan_fragments(&fragments, plan).unwrap();
1311
1312 assert_eq!(reads.len(), fragments.len());
1313
1314 for (read, fragment) in reads.iter().zip(fragments.iter()) {
1315 assert_eq!(read.key, fragment.key);
1316
1317 match read.selection {
1318 FragmentSelection::StridedRows {
1319 global_start,
1320 rows,
1321 rank: selected_rank,
1322 nranks: selected_nranks,
1323 } => {
1324 assert_eq!(global_start, fragment.global_start);
1325 assert_eq!(rows, fragment.rows as usize);
1326 assert_eq!(selected_rank, rank);
1327 assert_eq!(selected_nranks, nranks);
1328 }
1329 FragmentSelection::Range { .. } => panic!("expected strided selection"),
1330 }
1331 }
1332 }
1333
1334 #[cfg(feature = "mpi")]
1335 #[mpi_test(np = [2, 3])]
1336 fn mpi_read_plan_and_write_plan_reflect_world_distribution() {
1337 let universe = mpi::initialize().unwrap();
1338 let world = universe.world();
1339
1340 let read_plan = ReadPlan {
1341 chunk_size: Some(7),
1342 distribution: Distribution::from_world(&world).with_partitioning(Partitioning::Rows),
1343 };
1344
1345 assert!(read_plan.is_distributed());
1346 assert_eq!(read_plan.rank(), world.rank() as usize);
1347 assert_eq!(read_plan.nranks(), world.size() as usize);
1348
1349 match read_plan.fragment_partitioning() {
1350 FragmentPartitioning::StridedRows => {}
1351 _ => panic!("expected strided row partitioning"),
1352 }
1353
1354 let write_plan = WritePlan::from(read_plan);
1355
1356 assert!(write_plan.is_distributed());
1357 assert_eq!(write_plan.rank(), world.rank() as usize);
1358 assert_eq!(write_plan.nranks(), world.size() as usize);
1359 }
1360}