Skip to main content

laddu_data/io/
root.rs

1use std::{
2    path::{Path, PathBuf},
3    sync::{
4        Arc,
5        mpsc::{self, Receiver, Sender, SyncSender},
6    },
7    thread::{self, JoinHandle},
8};
9
10use laddu_physics::vectors::RealVec4;
11use oxyroot::{Branch, ReaderTree, RootFile, WriterTree};
12
13use crate::{
14    LadduDataError, LadduDataResult, Name,
15    data::EventBatch,
16    io::{
17        DataFragment, EventSink, EventSource, FragmentedSource, OutputMode, OutputPath, ReadPlan,
18        SourceCapabilities, WritePlan, fragmented_batches,
19    },
20    schema::{
21        ColumnInfo, ColumnType, Precision, Schema, SchemaColumnNames, SchemaInferenceOptions,
22        SchemaWriteOptions, WriteWeightColumn,
23    },
24};
25
26/// Event source backed by one or more ROOT TTrees.
27#[derive(Clone, Debug)]
28pub struct RootSource {
29    files: Arc<[Arc<PathBuf>]>,
30    tree_name: Name,
31    schema: Arc<Schema>,
32    options: RootReadOptions,
33}
34
35/// Schema, validation, glob, and tree-selection options for ROOT reads.
36#[derive(Clone, Debug)]
37pub struct RootReadOptions {
38    /// Infer a logical schema when none is supplied.
39    pub infer_schema: bool,
40    /// Validate required columns in every matched file.
41    pub validate_all_files: bool,
42    /// Sort glob results for deterministic global row order.
43    pub sort_glob: bool,
44    /// TTree selection policy.
45    pub tree: RootTreeSelection,
46    /// Logical schema inference options.
47    pub schema_inference: SchemaInferenceOptions,
48}
49
50impl Default for RootReadOptions {
51    fn default() -> Self {
52        Self {
53            infer_schema: true,
54            validate_all_files: true,
55            sort_glob: true,
56            tree: RootTreeSelection::First,
57            schema_inference: SchemaInferenceOptions::default(),
58        }
59    }
60}
61
62/// Policy for selecting a TTree from each ROOT file.
63#[derive(Clone, Debug, Default)]
64pub enum RootTreeSelection {
65    /// Select the first TTree.
66    #[default]
67    First,
68    /// Select a TTree by name.
69    Named(Name),
70}
71
72/// Key identifying one TTree within a ROOT file.
73#[derive(Clone, Debug)]
74pub struct RootFragmentKey {
75    /// Input file path.
76    pub file: Arc<PathBuf>,
77    /// TTree name.
78    pub tree_name: Name,
79}
80
81/// Introspection metadata for one ROOT branch.
82#[derive(Clone, Debug)]
83pub struct RootColumnInfo {
84    /// Branch name.
85    pub name: Name,
86    /// Rust item type reported by the reader.
87    pub item_type_name: String,
88    /// ROOT interpretation string.
89    pub interpretation: String,
90    /// Number of branch entries.
91    pub entries: i64,
92}
93
94impl RootSource {
95    /// Opens files matching a glob with default options.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`LadduDataError`] when the glob is invalid or empty, a ROOT
100    /// file or tree cannot be read, or schemas are incompatible.
101    pub fn open(pattern: impl AsRef<str>) -> LadduDataResult<Self> {
102        Self::builder(pattern).build()
103    }
104
105    /// Creates a configurable source builder for a file glob.
106    pub fn builder(pattern: impl AsRef<str>) -> RootSourceBuilder {
107        RootSourceBuilder {
108            pattern: pattern.as_ref().to_owned(),
109            schema: None,
110            options: RootReadOptions::default(),
111        }
112    }
113
114    /// Returns matched files in global row order.
115    pub fn files(&self) -> &[Arc<PathBuf>] {
116        &self.files
117    }
118
119    /// Returns the selected TTree name.
120    pub fn tree_name(&self) -> &str {
121        self.tree_name.as_ref()
122    }
123
124    /// Lists TTrees in one ROOT file.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`LadduDataError`] when the ROOT file cannot be opened or read.
129    pub fn tree_names(path: impl AsRef<Path>) -> LadduDataResult<Vec<Name>> {
130        let mut file = RootFile::open(path.as_ref()).map_err(root_source_error)?;
131        let key_names: Vec<String> = file.keys_name().map(str::to_owned).collect();
132
133        let mut out = Vec::new();
134
135        for name in key_names {
136            if file.get_tree(&name).is_ok() {
137                out.push(Name::from(name));
138            }
139        }
140
141        Ok(out)
142    }
143
144    /// Lists branch metadata for a selected or first TTree.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`LadduDataError`] when the file or tree cannot be read, no tree
149    /// exists, or branch metadata is invalid.
150    pub fn columns(
151        path: impl AsRef<Path>,
152        tree: Option<&str>,
153    ) -> LadduDataResult<Vec<RootColumnInfo>> {
154        let mut file = RootFile::open(path.as_ref()).map_err(root_source_error)?;
155
156        let tree_name = match tree {
157            Some(name) => Name::from(name),
158            None => first_tree_name(&mut file)?,
159        };
160
161        let tree = file
162            .get_tree(tree_name.as_ref())
163            .map_err(root_source_error)?;
164
165        Ok(tree
166            .branches_r()
167            .into_iter()
168            .map(|branch| RootColumnInfo {
169                name: Name::from(branch.name()),
170                item_type_name: branch.item_type_name(),
171                interpretation: branch.interpretation(),
172                entries: branch.entries(),
173            })
174            .collect())
175    }
176}
177
178/// Builder for a [`RootSource`].
179pub struct RootSourceBuilder {
180    pattern: String,
181    schema: Option<Arc<Schema>>,
182    options: RootReadOptions,
183}
184
185impl RootSourceBuilder {
186    /// Supplies an explicit logical schema and disables inference.
187    pub fn schema(mut self, schema: Arc<Schema>) -> Self {
188        self.schema = Some(schema);
189        self.options.infer_schema = false;
190        self
191    }
192
193    /// Enables or disables logical schema inference.
194    pub fn infer_schema(mut self, value: bool) -> Self {
195        self.options.infer_schema = value;
196        self
197    }
198
199    /// Selects a TTree by name.
200    pub fn tree(mut self, name: impl Into<Name>) -> Self {
201        self.options.tree = RootTreeSelection::Named(name.into());
202        self
203    }
204
205    /// Selects the first TTree.
206    pub fn first_tree(mut self) -> Self {
207        self.options.tree = RootTreeSelection::First;
208        self
209    }
210
211    /// Requires a physical weight column during inference.
212    pub fn require_weight(mut self, value: bool) -> Self {
213        self.options.schema_inference.require_weight = value;
214        self
215    }
216
217    /// Chooses whether every matched file is schema-validated eagerly.
218    pub fn validate_all_files(mut self, value: bool) -> Self {
219        self.options.validate_all_files = value;
220        self
221    }
222
223    /// Chooses whether matched paths are sorted.
224    pub fn sort_glob(mut self, value: bool) -> Self {
225        self.options.sort_glob = value;
226        self
227    }
228
229    /// Replaces logical schema inference options.
230    pub fn schema_inference(mut self, options: SchemaInferenceOptions) -> Self {
231        self.options.schema_inference = options;
232        self
233    }
234
235    /// Resolves files and tree, validates schema, and builds the source.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`LadduDataError`] when the glob is invalid or empty, files or
240    /// trees cannot be read, schema inference fails, or files disagree.
241    pub fn build(self) -> LadduDataResult<RootSource> {
242        let RootSourceBuilder {
243            pattern,
244            schema,
245            options,
246        } = self;
247
248        let mut files: Vec<PathBuf> = glob::glob(&pattern)
249            .map_err(|e| LadduDataError::Source(e.to_string()))?
250            .collect::<std::result::Result<_, _>>()
251            .map_err(|e| LadduDataError::Source(e.to_string()))?;
252
253        if options.sort_glob {
254            files.sort();
255        }
256
257        if files.is_empty() {
258            return Err(LadduDataError::Source("no ROOT files matched glob".into()));
259        }
260
261        let files: Arc<[Arc<PathBuf>]> = files.into_iter().map(Arc::new).collect();
262
263        let tree_name = {
264            let path: &Path = files[0].as_ref();
265            resolve_tree_name(path, &options.tree)?
266        };
267
268        let schema = match schema {
269            Some(schema) => schema,
270            None if options.infer_schema => {
271                let path: &Path = files[0].as_ref();
272                let columns = root_columns(path, tree_name.as_ref())?;
273
274                Arc::new(Schema::infer_from_columns(
275                    columns.iter().map(OwnedColumnInfo::as_column_info),
276                    &options.schema_inference,
277                )?)
278            }
279            None => return Err(LadduDataError::InvalidArgument("schema required")),
280        };
281
282        if options.validate_all_files {
283            for file in files.iter() {
284                let path: &Path = file.as_ref();
285                validate_root_file(path, tree_name.as_ref(), &schema, &options.schema_inference)?;
286            }
287        }
288
289        Ok(RootSource {
290            files,
291            tree_name,
292            schema,
293            options,
294        })
295    }
296}
297
298impl EventSource for RootSource {
299    fn schema(&self) -> LadduDataResult<Arc<Schema>> {
300        Ok(Arc::clone(&self.schema))
301    }
302
303    fn capabilities(&self) -> SourceCapabilities {
304        SourceCapabilities {
305            exact_len: true,
306            exact_weighted_total: false,
307            random_access: false,
308            deterministic_partitioning: true,
309            predicate_pushdown: false,
310            projection_pushdown: true,
311            streaming: true,
312        }
313    }
314
315    fn num_events(&self) -> LadduDataResult<Option<u64>> {
316        Ok(Some(self.fragments()?.iter().map(|f| f.rows).sum()))
317    }
318
319    fn batches(
320        &self,
321        plan: ReadPlan,
322    ) -> LadduDataResult<Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>> {
323        fragmented_batches(Arc::new(self.clone()), plan)
324    }
325}
326
327impl FragmentedSource for RootSource {
328    type Key = RootFragmentKey;
329
330    fn fragments(&self) -> LadduDataResult<Vec<DataFragment<Self::Key>>> {
331        let mut fragments = Vec::new();
332        let mut global_start = 0_u64;
333
334        for path in self.files.iter() {
335            let mut file = RootFile::open(path.as_ref()).map_err(root_source_error)?;
336            let tree = file
337                .get_tree(self.tree_name.as_ref())
338                .map_err(root_source_error)?;
339
340            let rows = usize_from_i64(tree.entries(), "negative TTree entry count")? as u64;
341
342            fragments.push(DataFragment {
343                key: RootFragmentKey {
344                    file: Arc::clone(path),
345                    tree_name: self.tree_name.clone(),
346                },
347                global_start,
348                rows,
349            });
350
351            global_start += rows;
352        }
353
354        Ok(fragments)
355    }
356
357    fn read_fragment_range(
358        &self,
359        key: &Self::Key,
360        local_start: usize,
361        local_len: usize,
362        chunk_size: Option<usize>,
363    ) -> LadduDataResult<Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>> {
364        if matches!(chunk_size, Some(0)) {
365            return Err(LadduDataError::InvalidArgument(
366                "chunk_size must be nonzero",
367            ));
368        }
369
370        Ok(Box::new(RootBatchIter::spawn(
371            Arc::clone(&self.schema),
372            self.options.clone(),
373            key.clone(),
374            local_start,
375            local_len,
376            chunk_size,
377        )))
378    }
379}
380
381struct RootBatchIter {
382    rx: Receiver<LadduDataResult<EventBatch>>,
383    handle: Option<JoinHandle<()>>,
384    joined: bool,
385}
386
387impl RootBatchIter {
388    fn spawn(
389        schema: Arc<Schema>,
390        options: RootReadOptions,
391        key: RootFragmentKey,
392        local_start: usize,
393        local_len: usize,
394        chunk_size: Option<usize>,
395    ) -> Self {
396        // Keep at most one decoded batch ahead of the consumer so ROOT I/O
397        // cannot silently exceed the dataset's memory-derived chunk budget.
398        let (tx, rx) = mpsc::sync_channel(1);
399
400        let handle = thread::spawn(move || {
401            if let Err(err) = read_root_range_and_send_batches(
402                schema,
403                options,
404                key,
405                local_start,
406                local_len,
407                chunk_size,
408                tx.clone(),
409            ) {
410                let _ = tx.send(Err(err));
411            }
412        });
413
414        Self {
415            rx,
416            handle: Some(handle),
417            joined: false,
418        }
419    }
420
421    fn join_if_needed(&mut self) -> Option<LadduDataResult<EventBatch>> {
422        if self.joined {
423            return None;
424        }
425
426        self.joined = true;
427
428        if let Some(handle) = self.handle.take()
429            && handle.join().is_err()
430        {
431            return Some(Err(LadduDataError::Source(
432                "ROOT reader thread panicked".into(),
433            )));
434        }
435
436        None
437    }
438}
439
440impl Iterator for RootBatchIter {
441    type Item = LadduDataResult<EventBatch>;
442
443    fn next(&mut self) -> Option<Self::Item> {
444        match self.rx.recv() {
445            Ok(item) => Some(item),
446            Err(_) => self.join_if_needed(),
447        }
448    }
449}
450
451fn read_root_range_and_send_batches(
452    schema: Arc<Schema>,
453    options: RootReadOptions,
454    key: RootFragmentKey,
455    local_start: usize,
456    local_len: usize,
457    chunk_size: Option<usize>,
458    tx: SyncSender<LadduDataResult<EventBatch>>,
459) -> LadduDataResult<()> {
460    let mut file = RootFile::open(key.file.as_ref()).map_err(root_source_error)?;
461    let tree = file
462        .get_tree(key.tree_name.as_ref())
463        .map_err(root_source_error)?;
464
465    let mut readers =
466        RootColumnReaders::new(&tree, &schema, &options.schema_inference.column_names)?;
467
468    for _ in 0..local_start {
469        readers.skip_one()?;
470    }
471
472    let mut remaining = local_len;
473    let batch_size = chunk_size.unwrap_or(local_len.max(1));
474
475    while remaining > 0 {
476        let take = remaining.min(batch_size);
477        let batch = readers.read_batch(Arc::clone(&schema), take)?;
478
479        tx.send(Ok(batch))
480            .map_err(|e| LadduDataError::Source(e.to_string()))?;
481
482        remaining -= take;
483    }
484
485    Ok(())
486}
487
488struct RootColumnReaders<'a> {
489    p4s: Vec<[RootFloatIter<'a>; 4]>,
490    scalars: Vec<RootFloatIter<'a>>,
491    weights: Option<RootFloatIter<'a>>,
492}
493
494impl<'a> RootColumnReaders<'a> {
495    fn new(
496        tree: &'a ReaderTree,
497        schema: &Schema,
498        column_names: &SchemaColumnNames,
499    ) -> LadduDataResult<Self> {
500        let mut p4s = Vec::with_capacity(schema.n_p4s());
501        let mut scalars = Vec::with_capacity(schema.n_scalars());
502
503        for name in schema.p4s() {
504            let [e, px, py, pz] = column_names.p4_suffixes.physical_p4_names(name);
505
506            p4s.push([
507                open_float_reader(tree, e.as_ref())?,
508                open_float_reader(tree, px.as_ref())?,
509                open_float_reader(tree, py.as_ref())?,
510                open_float_reader(tree, pz.as_ref())?,
511            ]);
512        }
513
514        for name in schema.scalars() {
515            scalars.push(open_float_reader(tree, name.as_ref())?);
516        }
517
518        let weights = if schema.has_weight() {
519            Some(open_float_reader(
520                tree,
521                column_names.weight_column.as_ref(),
522            )?)
523        } else {
524            None
525        };
526
527        Ok(Self {
528            p4s,
529            scalars,
530            weights,
531        })
532    }
533
534    fn skip_one(&mut self) -> LadduDataResult<()> {
535        for [e, px, py, pz] in self.p4s.iter_mut() {
536            e.next_f64()?;
537            px.next_f64()?;
538            py.next_f64()?;
539            pz.next_f64()?;
540        }
541
542        for scalar in self.scalars.iter_mut() {
543            scalar.next_f64()?;
544        }
545
546        if let Some(weights) = self.weights.as_mut() {
547            weights.next_f64()?;
548        }
549
550        Ok(())
551    }
552
553    fn read_batch(&mut self, schema: Arc<Schema>, len: usize) -> LadduDataResult<EventBatch> {
554        let mut p4s = Vec::with_capacity(schema.n_p4s());
555        let mut scalars = Vec::with_capacity(schema.n_scalars());
556
557        for [e, px, py, pz] in self.p4s.iter_mut() {
558            let mut col = Vec::with_capacity(len);
559
560            for _ in 0..len {
561                col.push(RealVec4 {
562                    e: e.next_f64()?,
563                    px: px.next_f64()?,
564                    py: py.next_f64()?,
565                    pz: pz.next_f64()?,
566                });
567            }
568
569            p4s.push(Arc::from(col));
570        }
571
572        for reader in self.scalars.iter_mut() {
573            let mut col = Vec::with_capacity(len);
574
575            for _ in 0..len {
576                col.push(reader.next_f64()?);
577            }
578
579            scalars.push(Arc::from(col));
580        }
581
582        let weights = if let Some(reader) = self.weights.as_mut() {
583            let mut col = Vec::with_capacity(len);
584
585            for _ in 0..len {
586                col.push(reader.next_f64()?);
587            }
588
589            Some(Arc::from(col))
590        } else {
591            None
592        };
593
594        EventBatch::new(schema, p4s, scalars, weights)
595    }
596}
597
598enum RootFloatIter<'a> {
599    F64(Box<dyn Iterator<Item = f64> + 'a>),
600    F32(Box<dyn Iterator<Item = f32> + 'a>),
601}
602
603impl<'a> RootFloatIter<'a> {
604    fn next_f64(&mut self) -> LadduDataResult<f64> {
605        match self {
606            Self::F64(iter) => iter
607                .next()
608                .ok_or_else(|| LadduDataError::Source("ROOT branch ended early".into())),
609            Self::F32(iter) => iter
610                .next()
611                .map(f64::from)
612                .ok_or_else(|| LadduDataError::Source("ROOT branch ended early".into())),
613        }
614    }
615}
616
617fn open_float_reader<'a>(tree: &'a ReaderTree, name: &str) -> LadduDataResult<RootFloatIter<'a>> {
618    let branch =
619        find_branch(tree, name).ok_or_else(|| LadduDataError::MissingColumn(Name::from(name)))?;
620
621    match root_column_type(branch) {
622        ColumnType::F64 => Ok(RootFloatIter::F64(Box::new(
623            branch.as_iter::<f64>().map_err(root_source_error)?,
624        ))),
625        ColumnType::F32 => Ok(RootFloatIter::F32(Box::new(
626            branch.as_iter::<f32>().map_err(root_source_error)?,
627        ))),
628        ColumnType::Other => Err(LadduDataError::Source(format!(
629            "column {name} has unsupported ROOT type {} interpreted as {}",
630            branch.item_type_name(),
631            branch.interpretation()
632        ))),
633    }
634}
635
636fn find_branch<'a>(tree: &'a ReaderTree, name: &str) -> Option<&'a Branch> {
637    tree.branch(name).or_else(|| {
638        tree.branches_r()
639            .into_iter()
640            .find(|branch| branch.name() == name)
641    })
642}
643
644#[derive(Clone, Debug)]
645struct OwnedColumnInfo {
646    name: Name,
647    dtype: ColumnType,
648}
649
650impl OwnedColumnInfo {
651    fn as_column_info(&self) -> ColumnInfo<'_> {
652        ColumnInfo {
653            name: self.name.as_ref(),
654            dtype: self.dtype,
655        }
656    }
657}
658
659fn root_columns(path: &Path, tree_name: &str) -> LadduDataResult<Vec<OwnedColumnInfo>> {
660    let mut file = RootFile::open(path).map_err(root_source_error)?;
661    let tree = file.get_tree(tree_name).map_err(root_source_error)?;
662
663    Ok(tree
664        .branches_r()
665        .into_iter()
666        .map(|branch| OwnedColumnInfo {
667            name: Name::from(branch.name()),
668            dtype: root_column_type(branch),
669        })
670        .collect())
671}
672
673fn validate_root_file(
674    path: &Path,
675    tree_name: &str,
676    schema: &Schema,
677    options: &SchemaInferenceOptions,
678) -> LadduDataResult<()> {
679    let columns = root_columns(path, tree_name)?;
680
681    schema.validate_required_columns(columns.iter().map(OwnedColumnInfo::as_column_info), options)
682}
683
684fn root_column_type(branch: &Branch) -> ColumnType {
685    match branch.interpretation().as_str() {
686        "f64" => ColumnType::F64,
687        "f32" => ColumnType::F32,
688        _ => match branch.item_type_name().as_str() {
689            "double" | "Double_t" | "ROOT::Double_t" => ColumnType::F64,
690            "float" | "Float_t" | "ROOT::Float_t" => ColumnType::F32,
691            _ => ColumnType::Other,
692        },
693    }
694}
695
696fn resolve_tree_name(path: &Path, selection: &RootTreeSelection) -> LadduDataResult<Name> {
697    let mut file = RootFile::open(path).map_err(root_source_error)?;
698
699    match selection {
700        RootTreeSelection::Named(name) => {
701            file.get_tree(name.as_ref()).map_err(root_source_error)?;
702            Ok(name.clone())
703        }
704        RootTreeSelection::First => first_tree_name(&mut file),
705    }
706}
707
708fn first_tree_name(file: &mut RootFile) -> LadduDataResult<Name> {
709    let key_names: Vec<String> = file.keys_name().map(str::to_owned).collect();
710
711    for name in key_names {
712        if file.get_tree(&name).is_ok() {
713            return Ok(Name::from(name));
714        }
715    }
716
717    Err(LadduDataError::Source("no TTree found in ROOT file".into()))
718}
719
720fn usize_from_i64(value: i64, message: &'static str) -> LadduDataResult<usize> {
721    if value < 0 {
722        return Err(LadduDataError::Source(message.into()));
723    }
724
725    usize::try_from(value).map_err(|_| LadduDataError::Source("entry count overflows usize".into()))
726}
727
728fn root_source_error(e: impl std::fmt::Display) -> LadduDataError {
729    LadduDataError::Source(e.to_string())
730}
731
732fn root_sink_error(e: impl std::fmt::Display) -> LadduDataError {
733    LadduDataError::Sink(e.to_string())
734}
735
736/// Event sink that writes a ROOT TTree on a background thread.
737pub struct RootSink {
738    output: OutputPath,
739    options: RootWriteOptions,
740    resolved_path: Option<PathBuf>,
741    event_schema: Option<Arc<Schema>>,
742    senders: Option<RootColumnSenders>,
743    writer_thread: Option<JoinHandle<LadduDataResult<()>>>,
744}
745
746/// ROOT tree and physical schema write options.
747#[derive(Clone, Debug)]
748pub struct RootWriteOptions {
749    /// Output TTree name.
750    pub tree_name: Name,
751    /// Physical schema write options.
752    pub schema_write: SchemaWriteOptions,
753}
754
755impl Default for RootWriteOptions {
756    fn default() -> Self {
757        Self {
758            tree_name: Name::from("tree"),
759            schema_write: SchemaWriteOptions::default(),
760        }
761    }
762}
763
764impl RootSink {
765    /// Creates a sink with default options.
766    pub fn create(path: impl Into<PathBuf>) -> Self {
767        Self::builder(path).build()
768    }
769
770    /// Creates a configurable sink builder.
771    pub fn builder(path: impl Into<PathBuf>) -> RootSinkBuilder {
772        RootSinkBuilder {
773            output: OutputPath::new(path),
774            options: RootWriteOptions::default(),
775        }
776    }
777
778    /// Returns the concrete path after writing has begun.
779    pub fn resolved_path(&self) -> Option<&Path> {
780        self.resolved_path.as_deref()
781    }
782}
783
784/// Builder for a [`RootSink`].
785pub struct RootSinkBuilder {
786    output: OutputPath,
787    options: RootWriteOptions,
788}
789
790impl RootSinkBuilder {
791    /// Sets the output path mode.
792    pub fn output_mode(mut self, mode: OutputMode) -> Self {
793        self.output = self.output.with_mode(mode);
794        self
795    }
796
797    /// Selects single-file output.
798    pub fn single_file(self) -> Self {
799        self.output_mode(OutputMode::SingleFile)
800    }
801
802    /// Selects one output file per rank.
803    pub fn per_rank_files(self) -> Self {
804        self.output_mode(OutputMode::PerRankFiles)
805    }
806
807    /// Selects output mode from the write plan.
808    pub fn auto_output(self) -> Self {
809        self.output_mode(OutputMode::Auto)
810    }
811
812    /// Sets the output TTree name.
813    pub fn tree(mut self, name: impl Into<Name>) -> Self {
814        self.options.tree_name = name.into();
815        self
816    }
817
818    /// Replaces physical schema write options.
819    pub fn schema_write(mut self, options: SchemaWriteOptions) -> Self {
820        self.options.schema_write = options;
821        self
822    }
823
824    /// Sets physical column naming conventions.
825    pub fn column_names(mut self, column_names: SchemaColumnNames) -> Self {
826        self.options.schema_write.column_names = column_names;
827        self
828    }
829
830    /// Sets floating-point output precision.
831    pub fn precision(mut self, precision: Precision) -> Self {
832        self.options.schema_write.precision = precision;
833        self
834    }
835
836    /// Sets the weight-column emission policy.
837    pub fn write_weight_column(mut self, value: WriteWeightColumn) -> Self {
838        self.options.schema_write.write_weight_column = value;
839        self
840    }
841
842    /// Builds the sink.
843    pub fn build(self) -> RootSink {
844        RootSink {
845            output: self.output,
846            options: self.options,
847            resolved_path: None,
848            event_schema: None,
849            senders: None,
850            writer_thread: None,
851        }
852    }
853}
854
855impl EventSink for RootSink {
856    fn begin(&mut self, schema: Arc<Schema>, plan: WritePlan) -> LadduDataResult<()> {
857        if self.writer_thread.is_some() {
858            return Err(LadduDataError::Sink("ROOT sink already initialized".into()));
859        }
860
861        let path = self.output.resolve(plan, "root")?;
862        OutputPath::create_parent_dirs(&path)?;
863
864        let columns = root_output_columns(
865            &schema,
866            self.options.schema_write.write_weight_column,
867            &self.options.schema_write,
868        );
869
870        let (senders, receivers) = root_channels(&columns, self.options.schema_write.precision);
871
872        let writer_path = path.clone();
873        let tree_name = self.options.tree_name.clone();
874
875        let handle = thread::spawn(move || write_root_tree(writer_path, tree_name, receivers));
876
877        self.resolved_path = Some(path);
878        self.event_schema = Some(schema);
879        self.senders = Some(senders);
880        self.writer_thread = Some(handle);
881
882        Ok(())
883    }
884
885    fn write_batch(&mut self, batch: &EventBatch) -> LadduDataResult<()> {
886        let event_schema = self
887            .event_schema
888            .as_ref()
889            .ok_or_else(|| LadduDataError::Sink("ROOT sink not initialized".into()))?;
890
891        if event_schema.as_ref() != batch.schema().as_ref() {
892            return Err(LadduDataError::Sink(
893                "batch schema does not match ROOT sink schema".into(),
894            ));
895        }
896
897        let senders = self
898            .senders
899            .as_ref()
900            .ok_or_else(|| LadduDataError::Sink("ROOT sink not initialized".into()))?;
901
902        let should_write_weight = matches!(
903            self.options.schema_write.write_weight_column,
904            WriteWeightColumn::Always
905        ) || batch.schema().has_weight();
906
907        for row in 0..batch.len() {
908            let mut index = 0;
909
910            for col in 0..batch.schema().n_p4s() {
911                let p = batch.p4_at(col, row);
912
913                senders.send(index, p.e)?;
914                index += 1;
915
916                senders.send(index, p.px)?;
917                index += 1;
918
919                senders.send(index, p.py)?;
920                index += 1;
921
922                senders.send(index, p.pz)?;
923                index += 1;
924            }
925
926            for col in 0..batch.schema().n_scalars() {
927                senders.send(index, batch.scalar_at(col, row))?;
928                index += 1;
929            }
930
931            if should_write_weight {
932                senders.send(index, batch.weights_at(row))?;
933            }
934        }
935
936        Ok(())
937    }
938
939    fn finish(&mut self) -> LadduDataResult<()> {
940        self.senders.take();
941
942        if let Some(handle) = self.writer_thread.take() {
943            match handle.join() {
944                Ok(result) => result?,
945                Err(_) => return Err(LadduDataError::Sink("ROOT writer thread panicked".into())),
946            }
947        }
948
949        Ok(())
950    }
951}
952
953impl Drop for RootSink {
954    fn drop(&mut self) {
955        let _ = self.finish();
956    }
957}
958
959enum RootColumnSenders {
960    F64(Vec<Sender<f64>>),
961    F32(Vec<Sender<f32>>),
962}
963
964impl RootColumnSenders {
965    fn send(&self, index: usize, value: f64) -> LadduDataResult<()> {
966        match self {
967            Self::F64(senders) => senders[index]
968                .send(value)
969                .map_err(|e| LadduDataError::Sink(e.to_string())),
970            Self::F32(senders) => senders[index]
971                .send(value as f32)
972                .map_err(|e| LadduDataError::Sink(e.to_string())),
973        }
974    }
975}
976
977enum RootColumnReceivers {
978    F64(Vec<(Name, Receiver<f64>)>),
979    F32(Vec<(Name, Receiver<f32>)>),
980}
981
982fn root_channels(
983    columns: &[Name],
984    precision: Precision,
985) -> (RootColumnSenders, RootColumnReceivers) {
986    match precision {
987        Precision::F64 => {
988            let mut senders = Vec::with_capacity(columns.len());
989            let mut receivers = Vec::with_capacity(columns.len());
990
991            for name in columns {
992                let (tx, rx) = mpsc::channel();
993                senders.push(tx);
994                receivers.push((name.clone(), rx));
995            }
996
997            (
998                RootColumnSenders::F64(senders),
999                RootColumnReceivers::F64(receivers),
1000            )
1001        }
1002        Precision::F32 => {
1003            let mut senders = Vec::with_capacity(columns.len());
1004            let mut receivers = Vec::with_capacity(columns.len());
1005
1006            for name in columns {
1007                let (tx, rx) = mpsc::channel();
1008                senders.push(tx);
1009                receivers.push((name.clone(), rx));
1010            }
1011
1012            (
1013                RootColumnSenders::F32(senders),
1014                RootColumnReceivers::F32(receivers),
1015            )
1016        }
1017    }
1018}
1019
1020fn write_root_tree(
1021    path: PathBuf,
1022    tree_name: Name,
1023    receivers: RootColumnReceivers,
1024) -> LadduDataResult<()> {
1025    let mut file = RootFile::create(&path).map_err(root_sink_error)?;
1026    let mut tree = WriterTree::new(tree_name.as_ref());
1027
1028    match receivers {
1029        RootColumnReceivers::F64(receivers) => {
1030            for (name, rx) in receivers {
1031                tree.new_branch(name.as_ref(), rx.into_iter());
1032            }
1033        }
1034        RootColumnReceivers::F32(receivers) => {
1035            for (name, rx) in receivers {
1036                tree.new_branch(name.as_ref(), rx.into_iter());
1037            }
1038        }
1039    }
1040
1041    tree.write(&mut file).map_err(root_sink_error)?;
1042    file.close().map_err(root_sink_error)?;
1043
1044    Ok(())
1045}
1046
1047fn root_output_columns(
1048    schema: &Schema,
1049    write_weight: WriteWeightColumn,
1050    options: &SchemaWriteOptions,
1051) -> Vec<Name> {
1052    let should_write_weight =
1053        matches!(write_weight, WriteWeightColumn::Always) || schema.has_weight();
1054
1055    let mut columns = Vec::with_capacity(
1056        4 * schema.n_p4s() + schema.n_scalars() + usize::from(should_write_weight),
1057    );
1058
1059    for name in schema.p4s() {
1060        let [e, px, py, pz] = options.column_names.p4_suffixes.physical_p4_names(name);
1061        columns.push(e.into());
1062        columns.push(px.into());
1063        columns.push(py.into());
1064        columns.push(pz.into());
1065    }
1066
1067    for name in schema.scalars() {
1068        columns.push(name.clone());
1069    }
1070
1071    if should_write_weight {
1072        columns.push(options.column_names.weight_column.clone());
1073    }
1074
1075    columns
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use std::sync::atomic::{AtomicU64, Ordering};
1081
1082    use super::*;
1083    use crate::data::{Dataset, EventBatchBuilder};
1084
1085    fn temp_path(ext: &str) -> PathBuf {
1086        static NEXT_TEMP_FILE_ID: AtomicU64 = AtomicU64::new(0);
1087
1088        let nanos = std::time::SystemTime::now()
1089            .duration_since(std::time::UNIX_EPOCH)
1090            .unwrap()
1091            .as_nanos();
1092        let id = NEXT_TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed);
1093
1094        std::env::temp_dir().join(format!(
1095            "laddu-root-test-{}-{nanos}-{id}.{ext}",
1096            std::process::id()
1097        ))
1098    }
1099
1100    fn v(x: f64) -> RealVec4 {
1101        RealVec4 {
1102            e: x + 0.3,
1103            px: x,
1104            py: x + 0.1,
1105            pz: x + 0.2,
1106        }
1107    }
1108
1109    fn schema() -> Arc<Schema> {
1110        Arc::new(Schema::new(["p"], ["mass"], true).unwrap())
1111    }
1112
1113    fn batch() -> EventBatch {
1114        let schema = schema();
1115        let mut builder = EventBatchBuilder::new(schema);
1116
1117        for i in 0..4 {
1118            builder
1119                .push_weighted([v(i as f64)], [100.0 + i as f64], 10.0 + i as f64)
1120                .unwrap();
1121        }
1122
1123        builder.finish().unwrap()
1124    }
1125
1126    #[test]
1127    fn root_sink_and_source_roundtrip_named_tree_with_f32_precision() {
1128        let path = temp_path("root");
1129        let batch = batch();
1130
1131        let mut sink = RootSink::builder(path.clone())
1132            .tree("events")
1133            .precision(Precision::F32)
1134            .build();
1135
1136        sink.begin(Arc::clone(batch.schema()), WritePlan::default())
1137            .unwrap();
1138        sink.write_batch(&batch).unwrap();
1139        sink.finish().unwrap();
1140
1141        let tree_names = RootSource::tree_names(&path).unwrap();
1142        assert!(tree_names.iter().any(|name| name.as_ref() == "events"));
1143
1144        let columns = RootSource::columns(&path, Some("events")).unwrap();
1145        let names = columns
1146            .iter()
1147            .map(|col| col.name.to_string())
1148            .collect::<Vec<_>>();
1149
1150        for expected in ["p_e", "p_px", "p_py", "p_pz", "mass", "weight"] {
1151            assert!(
1152                names.iter().any(|name| name == expected),
1153                "missing {expected}"
1154            );
1155        }
1156
1157        let source = RootSource::builder(path.to_str().unwrap())
1158            .tree("events")
1159            .build()
1160            .unwrap();
1161
1162        assert_eq!(source.tree_name(), "events");
1163        assert_eq!(source.num_events().unwrap(), Some(4));
1164
1165        let read_batches: Vec<EventBatch> = source
1166            .batches(ReadPlan {
1167                chunk_size: Some(2),
1168                #[cfg(feature = "mpi")]
1169                distribution: Default::default(),
1170            })
1171            .unwrap()
1172            .map(Result::unwrap)
1173            .collect();
1174
1175        assert_eq!(
1176            read_batches.iter().map(EventBatch::len).collect::<Vec<_>>(),
1177            vec![2, 2]
1178        );
1179
1180        let read = EventBatch::concat(&read_batches).unwrap();
1181
1182        assert_eq!(read.scalar_column(0), &[100.0, 101.0, 102.0, 103.0]);
1183        assert_eq!(read.weights_column().unwrap(), &[10.0, 11.0, 12.0, 13.0]);
1184        assert!((read.p4_at(0, 2).e - 2.3).abs() < 1.0e-6);
1185
1186        let _ = std::fs::remove_file(path);
1187    }
1188
1189    #[test]
1190    fn root_source_infers_first_tree_when_no_tree_is_named() {
1191        let path = temp_path("root");
1192        let batch = batch();
1193
1194        let mut sink = RootSink::builder(path.clone()).tree("first_tree").build();
1195
1196        sink.begin(Arc::clone(batch.schema()), WritePlan::default())
1197            .unwrap();
1198        sink.write_batch(&batch).unwrap();
1199        sink.finish().unwrap();
1200
1201        let source = RootSource::builder(path.to_str().unwrap())
1202            .first_tree()
1203            .build()
1204            .unwrap();
1205
1206        assert_eq!(source.tree_name(), "first_tree");
1207
1208        let read = EventBatch::concat(
1209            &source
1210                .batches(ReadPlan::default())
1211                .unwrap()
1212                .map(Result::unwrap)
1213                .collect::<Vec<_>>(),
1214        )
1215        .unwrap();
1216
1217        assert_eq!(read.scalar_column(0), &[100.0, 101.0, 102.0, 103.0]);
1218
1219        let _ = std::fs::remove_file(path);
1220    }
1221
1222    #[test]
1223    fn root_source_named_missing_tree_fails() {
1224        let path = temp_path("root");
1225        let batch = batch();
1226
1227        let mut sink = RootSink::builder(path.clone()).tree("events").build();
1228
1229        sink.begin(Arc::clone(batch.schema()), WritePlan::default())
1230            .unwrap();
1231        sink.write_batch(&batch).unwrap();
1232        sink.finish().unwrap();
1233
1234        let err = RootSource::builder(path.to_str().unwrap())
1235            .tree("missing")
1236            .build()
1237            .unwrap_err();
1238
1239        assert!(matches!(err, LadduDataError::Source(_)));
1240
1241        let _ = std::fs::remove_file(path);
1242    }
1243
1244    #[test]
1245    fn root_sink_rejects_batches_with_different_schema() {
1246        let path = temp_path("root");
1247        let batch = batch();
1248
1249        let mut sink = RootSink::builder(path.clone()).tree("events").build();
1250
1251        sink.begin(Arc::clone(batch.schema()), WritePlan::default())
1252            .unwrap();
1253
1254        let other_schema = Arc::new(Schema::new(["q"], ["mass"], true).unwrap());
1255        let mut builder = EventBatchBuilder::new(other_schema);
1256        builder.push_weighted([v(1.0)], [1.0], 1.0).unwrap();
1257        let other = builder.finish().unwrap();
1258
1259        let err = sink.write_batch(&other).unwrap_err();
1260        assert!(matches!(err, LadduDataError::Sink(msg) if msg.contains("schema")));
1261
1262        sink.finish().unwrap();
1263        let _ = std::fs::remove_file(path);
1264    }
1265
1266    #[test]
1267    fn dataset_write_to_root_applies_dataset_transformations_before_writing() {
1268        let path = temp_path("root");
1269
1270        let dataset = Dataset::from_batch(batch()).filter(|ev| ev.scalar(0) >= 102.0);
1271
1272        let mut sink = RootSink::builder(path.clone()).tree("events").build();
1273
1274        dataset.write_to(&mut sink).unwrap();
1275
1276        let source = RootSource::builder(path.to_str().unwrap())
1277            .tree("events")
1278            .build()
1279            .unwrap();
1280
1281        let read = EventBatch::concat(
1282            &source
1283                .batches(ReadPlan::default())
1284                .unwrap()
1285                .map(Result::unwrap)
1286                .collect::<Vec<_>>(),
1287        )
1288        .unwrap();
1289
1290        assert_eq!(read.scalar_column(0), &[102.0, 103.0]);
1291        assert_eq!(read.weights_column().unwrap(), &[12.0, 13.0]);
1292
1293        let _ = std::fs::remove_file(path);
1294    }
1295}