Skip to main content

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