Skip to main content

laddu_data/
schema.rs

1use std::{
2    collections::{BTreeMap, HashMap, HashSet},
3    sync::Arc,
4};
5
6use crate::{LadduDataError, LadduDataResult, Name};
7
8/// Logical names and lookup tables for four-momentum, scalar, and weight columns.
9#[derive(Clone, Debug)]
10pub struct Schema {
11    p4s: Vec<Name>,
12    scalars: Vec<Name>,
13    has_weight: bool,
14
15    p4_index: Arc<HashMap<Name, usize>>,
16    scalar_index: Arc<HashMap<Name, usize>>,
17}
18
19impl PartialEq for Schema {
20    fn eq(&self, other: &Self) -> bool {
21        self.p4s == other.p4s
22            && self.scalars == other.scalars
23            && self.has_weight == other.has_weight
24    }
25}
26
27impl Schema {
28    /// Validates and constructs a logical schema.
29    ///
30    /// # Errors
31    ///
32    /// Returns [`LadduDataError`] when four-momentum or scalar column names are
33    /// duplicated.
34    pub fn new(
35        p4s: impl IntoIterator<Item = impl Into<Name>>,
36        scalars: impl IntoIterator<Item = impl Into<Name>>,
37        has_weight: bool,
38    ) -> LadduDataResult<Self> {
39        let p4s: Vec<Name> = p4s.into_iter().map(Into::into).collect();
40        let scalars: Vec<Name> = scalars.into_iter().map(Into::into).collect();
41        let p4_index = Arc::new(make_index(&p4s, "p4")?);
42        let scalar_index = Arc::new(make_index(&scalars, "scalar")?);
43        Ok(Self {
44            p4s,
45            scalars,
46            has_weight,
47            p4_index,
48            scalar_index,
49        })
50    }
51
52    /// Returns the four-momentum column index for `name`.
53    pub fn p4_index(&self, name: &str) -> Option<usize> {
54        self.p4_index.get(name).copied()
55    }
56
57    /// Returns the scalar column index for `name`.
58    pub fn scalar_index(&self, name: &str) -> Option<usize> {
59        self.scalar_index.get(name).copied()
60    }
61
62    /// Returns four-momentum names in column order.
63    pub fn p4s(&self) -> &[Name] {
64        &self.p4s
65    }
66
67    /// Returns scalar names in column order.
68    pub fn scalars(&self) -> &[Name] {
69        &self.scalars
70    }
71
72    /// Returns whether events carry explicit weights.
73    pub fn has_weight(&self) -> bool {
74        self.has_weight
75    }
76
77    /// Returns the number of four-momentum columns.
78    pub fn n_p4s(&self) -> usize {
79        self.p4s.len()
80    }
81
82    /// Returns the number of scalar columns.
83    pub fn n_scalars(&self) -> usize {
84        self.scalars.len()
85    }
86
87    /// Requires and returns a four-momentum column index.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`LadduDataError::MissingColumn`] when `name` is not a
92    /// four-momentum column.
93    pub fn require_p4(&self, name: &str) -> LadduDataResult<usize> {
94        self.p4_index(name)
95            .ok_or_else(|| LadduDataError::MissingColumn(Name::from(name)))
96    }
97
98    /// Requires and returns a scalar column index.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`LadduDataError::MissingColumn`] when `name` is not a scalar
103    /// column.
104    pub fn require_scalar(&self, name: &str) -> LadduDataResult<usize> {
105        self.scalar_index(name)
106            .ok_or_else(|| LadduDataError::MissingColumn(Name::from(name)))
107    }
108}
109
110fn make_index(names: &[Name], kind: &'static str) -> LadduDataResult<HashMap<Name, usize>> {
111    let mut out = HashMap::with_capacity(names.len());
112    for (i, name) in names.iter().cloned().enumerate() {
113        if out.insert(name.clone(), i).is_some() {
114            return Err(LadduDataError::Schema(format!(
115                "duplicate {kind} column: {name}"
116            )));
117        }
118    }
119    Ok(out)
120}
121
122/// Physical naming conventions used to map a logical schema to storage columns.
123#[derive(Clone, Debug)]
124pub struct SchemaColumnNames {
125    /// Physical weight-column name.
126    pub weight_column: Name,
127    /// Suffixes for four-momentum components.
128    pub p4_suffixes: P4Suffixes,
129}
130
131impl Default for SchemaColumnNames {
132    fn default() -> Self {
133        Self {
134            weight_column: Name::from("weight"),
135            p4_suffixes: P4Suffixes::default(),
136        }
137    }
138}
139
140/// Options controlling logical schema inference from physical columns.
141#[derive(Clone, Debug)]
142pub struct SchemaInferenceOptions {
143    /// Physical naming conventions.
144    pub column_names: SchemaColumnNames,
145    /// Whether inference fails if the weight column is absent.
146    pub require_weight: bool,
147    /// Whether incomplete four-momenta become independent scalar columns.
148    pub incomplete_p4_components_are_scalars: bool,
149}
150
151impl Default for SchemaInferenceOptions {
152    fn default() -> Self {
153        Self {
154            column_names: SchemaColumnNames::default(),
155            require_weight: false,
156            incomplete_p4_components_are_scalars: true,
157        }
158    }
159}
160
161/// Physical suffixes for `(E, px, py, pz)` columns.
162#[derive(Clone, Debug)]
163pub struct P4Suffixes {
164    /// Energy suffix.
165    pub e: &'static str,
166    /// X-momentum suffix.
167    pub px: &'static str,
168    /// Y-momentum suffix.
169    pub py: &'static str,
170    /// Z-momentum suffix.
171    pub pz: &'static str,
172}
173
174impl Default for P4Suffixes {
175    fn default() -> Self {
176        Self {
177            e: "_e",
178            px: "_px",
179            py: "_py",
180            pz: "_pz",
181        }
182    }
183}
184
185impl P4Suffixes {
186    /// Splits a matching physical name into logical prefix and component index.
187    pub fn component<'a>(&'a self, name: &'a str) -> Option<(&'a str, usize)> {
188        if let Some(prefix) = name.strip_suffix(self.e) {
189            Some((prefix, 0))
190        } else if let Some(prefix) = name.strip_suffix(self.px) {
191            Some((prefix, 1))
192        } else if let Some(prefix) = name.strip_suffix(self.py) {
193            Some((prefix, 2))
194        } else if let Some(prefix) = name.strip_suffix(self.pz) {
195            Some((prefix, 3))
196        } else {
197            None
198        }
199    }
200
201    /// Produces the four physical names for a logical four-momentum prefix.
202    pub fn physical_p4_names(&self, prefix: &str) -> [String; 4] {
203        [
204            format!("{prefix}{}", self.e),
205            format!("{prefix}{}", self.px),
206            format!("{prefix}{}", self.py),
207            format!("{prefix}{}", self.pz),
208        ]
209    }
210}
211
212/// Physical storage type relevant to schema inference.
213#[derive(Clone, Copy, Debug, PartialEq, Eq)]
214pub enum ColumnType {
215    /// 64-bit floating point.
216    F64,
217    /// 32-bit floating point.
218    F32,
219    /// Any unsupported type.
220    Other,
221}
222
223impl ColumnType {
224    /// Returns whether this type can populate an event-data column.
225    pub fn is_supported_float(self) -> bool {
226        matches!(self, Self::F64 | Self::F32)
227    }
228}
229
230/// Name and physical type of one available storage column.
231#[derive(Clone, Copy, Debug)]
232pub struct ColumnInfo<'a> {
233    /// Physical column name.
234    pub name: &'a str,
235    /// Physical column type.
236    pub dtype: ColumnType,
237}
238
239impl Schema {
240    /// Infers a logical schema from available physical columns.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`LadduDataError`] when required momentum components or weights
245    /// are missing, names are ambiguous, or inferred logical names conflict.
246    pub fn infer_from_columns<'a>(
247        columns: impl IntoIterator<Item = ColumnInfo<'a>>,
248        options: &SchemaInferenceOptions,
249    ) -> LadduDataResult<Self> {
250        let mut p4_candidates = BTreeMap::<String, [bool; 4]>::new();
251        let mut scalar_names = Vec::<Name>::new();
252        let mut has_weight = false;
253
254        for col in columns {
255            if !col.dtype.is_supported_float() {
256                continue;
257            }
258
259            if col.name == options.column_names.weight_column.as_ref() {
260                has_weight = true;
261                continue;
262            }
263
264            if let Some((prefix, component)) = options.column_names.p4_suffixes.component(col.name)
265            {
266                p4_candidates.entry(prefix.to_owned()).or_default()[component] = true;
267            } else {
268                scalar_names.push(Name::from(col.name));
269            }
270        }
271
272        let mut p4s = Vec::<Name>::new();
273
274        for (prefix, seen) in p4_candidates {
275            if seen == [true, true, true, true] {
276                p4s.push(Name::from(prefix));
277            } else if options.incomplete_p4_components_are_scalars {
278                let names = options.column_names.p4_suffixes.physical_p4_names(&prefix);
279
280                for (i, name) in names.into_iter().enumerate() {
281                    if seen[i] {
282                        scalar_names.push(Name::from(name));
283                    }
284                }
285            }
286        }
287
288        if options.require_weight && !has_weight {
289            return Err(LadduDataError::MissingColumn(Arc::clone(
290                &options.column_names.weight_column,
291            )));
292        }
293
294        Schema::new(p4s, scalar_names, has_weight)
295    }
296
297    /// Returns all physical columns required to store this schema.
298    pub fn physical_columns(&self, column_names: &SchemaColumnNames) -> Vec<Name> {
299        PhysicalSchemaPlan::for_read(self, column_names)
300            .columns()
301            .iter()
302            .map(|column| Arc::clone(column.name()))
303            .collect()
304    }
305
306    /// Validates that all physical columns required by this schema are available.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`LadduDataError::MissingColumn`] when a required physical
311    /// column is absent or has an unsupported type.
312    pub fn validate_required_columns<'a>(
313        &self,
314        available: impl IntoIterator<Item = ColumnInfo<'a>>,
315        options: &SchemaInferenceOptions,
316    ) -> LadduDataResult<()> {
317        let available: HashSet<&str> = available
318            .into_iter()
319            .filter(|c| c.dtype.is_supported_float())
320            .map(|c| c.name)
321            .collect();
322
323        for required in PhysicalSchemaPlan::for_read(self, &options.column_names).columns() {
324            if !available.contains(required.name().as_ref()) {
325                return Err(LadduDataError::MissingColumn(Arc::clone(required.name())));
326            }
327        }
328
329        Ok(())
330    }
331}
332
333/// Floating-point precision used when writing physical columns.
334#[derive(Copy, Clone, Debug, Default)]
335pub enum Precision {
336    /// Write 64-bit floating-point values.
337    #[default]
338    F64,
339    /// Write 32-bit floating-point values.
340    F32,
341}
342
343/// Policy controlling whether sinks emit a weight column.
344#[derive(Clone, Copy, Debug, Default)]
345pub enum WriteWeightColumn {
346    /// Always write weights, using unit weights when the schema has none.
347    #[default]
348    Always,
349    /// Write weights only when the logical schema contains them.
350    OnlyIfPresent,
351}
352
353/// Physical naming, precision, and weight policy for event sinks.
354#[derive(Clone, Debug, Default)]
355pub struct SchemaWriteOptions {
356    /// Physical column naming conventions.
357    pub column_names: SchemaColumnNames,
358    /// Floating-point output precision.
359    pub precision: Precision,
360    /// Weight-column emission policy.
361    pub write_weight_column: WriteWeightColumn,
362}
363
364/// The semantic role of one physical storage column.
365///
366/// This is deliberately crate-private: callers work with logical [`Schema`]
367/// values while the file-format adapters use this plan to keep physical
368/// column ordering and binding identical across backends.
369#[derive(Clone, Copy, Debug, PartialEq, Eq)]
370pub(crate) enum PhysicalColumnRole {
371    /// One component of a logical four-momentum column.
372    P4 {
373        /// Logical four-momentum column index.
374        index: usize,
375        /// Component index in `(E, px, py, pz)` order.
376        component: usize,
377    },
378    /// One logical scalar column.
379    Scalar {
380        /// Logical scalar column index.
381        index: usize,
382    },
383    /// The physical event-weight column.
384    Weight,
385}
386
387/// Ordered physical representation of a logical event schema.
388///
389/// A plan is built once at each storage boundary and then consumed by schema
390/// creation, projection, encoding, and decoding.  Keeping the role alongside
391/// the name avoids each backend reimplementing the canonical ordering.
392#[derive(Clone, Debug)]
393pub(crate) struct PhysicalSchemaPlan {
394    columns: Vec<PhysicalColumn>,
395}
396
397#[derive(Clone, Debug)]
398pub(crate) struct PhysicalColumn {
399    name: Name,
400    role: PhysicalColumnRole,
401}
402
403impl PhysicalSchemaPlan {
404    /// Builds the physical columns used when reading or validating a schema.
405    pub(crate) fn for_read(schema: &Schema, column_names: &SchemaColumnNames) -> Self {
406        Self::build(schema, column_names, schema.has_weight())
407    }
408
409    /// Builds the physical columns emitted by a sink.
410    pub(crate) fn for_write(
411        schema: &Schema,
412        options: &SchemaWriteOptions,
413        write_weight: WriteWeightColumn,
414    ) -> Self {
415        let should_write_weight =
416            matches!(write_weight, WriteWeightColumn::Always) || schema.has_weight();
417        Self::build(schema, &options.column_names, should_write_weight)
418    }
419
420    fn build(schema: &Schema, column_names: &SchemaColumnNames, include_weight: bool) -> Self {
421        let mut columns = Vec::with_capacity(
422            4 * schema.n_p4s() + schema.n_scalars() + usize::from(include_weight),
423        );
424
425        for (index, p4) in schema.p4s().iter().enumerate() {
426            for (component, name) in column_names
427                .p4_suffixes
428                .physical_p4_names(p4)
429                .into_iter()
430                .enumerate()
431            {
432                columns.push(PhysicalColumn {
433                    name: Name::from(name),
434                    role: PhysicalColumnRole::P4 { index, component },
435                });
436            }
437        }
438
439        for (index, name) in schema.scalars().iter().cloned().enumerate() {
440            columns.push(PhysicalColumn {
441                name,
442                role: PhysicalColumnRole::Scalar { index },
443            });
444        }
445
446        if include_weight {
447            columns.push(PhysicalColumn {
448                name: Arc::clone(&column_names.weight_column),
449                role: PhysicalColumnRole::Weight,
450            });
451        }
452
453        Self { columns }
454    }
455
456    /// Returns physical columns in canonical storage order.
457    pub(crate) fn columns(&self) -> &[PhysicalColumn] {
458        &self.columns
459    }
460}
461
462impl PhysicalColumn {
463    /// Returns the physical storage name.
464    pub(crate) fn name(&self) -> &Name {
465        &self.name
466    }
467
468    /// Returns the logical role of this physical column.
469    pub(crate) fn role(&self) -> PhysicalColumnRole {
470        self.role
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    fn col(name: &'static str, dtype: ColumnType) -> ColumnInfo<'static> {
479        ColumnInfo { name, dtype }
480    }
481
482    #[test]
483    fn schema_new_rejects_duplicates_and_required_lookup_reports_missing_column() {
484        let duplicate_p4 = Schema::new(["p", "p"], ["mass"], false);
485        assert!(matches!(duplicate_p4, Err(LadduDataError::Schema(_))));
486
487        let duplicate_scalar = Schema::new(["p"], ["mass", "mass"], false);
488        assert!(matches!(duplicate_scalar, Err(LadduDataError::Schema(_))));
489
490        let schema = Schema::new(["beam", "recoil"], ["mass", "costheta"], true).unwrap();
491
492        assert_eq!(schema.require_p4("recoil").unwrap(), 1);
493        assert_eq!(schema.require_scalar("costheta").unwrap(), 1);
494
495        let err = schema.require_scalar("missing").unwrap_err();
496        assert!(matches!(err, LadduDataError::MissingColumn(name) if name.as_ref() == "missing"));
497    }
498
499    #[test]
500    fn infer_from_columns_groups_complete_p4s_keeps_incomplete_components_as_scalars_and_ignores_nonfloats()
501     {
502        let options = SchemaInferenceOptions::default();
503
504        let schema = Schema::infer_from_columns(
505            [
506                col("gamma_px", ColumnType::F64),
507                col("gamma_py", ColumnType::F64),
508                col("gamma_pz", ColumnType::F32),
509                col("gamma_e", ColumnType::F64),
510                col("partial_px", ColumnType::F64),
511                col("partial_e", ColumnType::F64),
512                col("mass", ColumnType::F32),
513                col("ignored", ColumnType::Other),
514                col("weight", ColumnType::F64),
515            ],
516            &options,
517        )
518        .unwrap();
519
520        assert_eq!(
521            schema
522                .p4s()
523                .iter()
524                .map(|n| n.to_string())
525                .collect::<Vec<_>>(),
526            vec!["gamma"]
527        );
528
529        assert_eq!(
530            schema
531                .scalars()
532                .iter()
533                .map(|n| n.to_string())
534                .collect::<Vec<_>>(),
535            vec!["mass", "partial_e", "partial_px"]
536        );
537
538        assert!(schema.has_weight());
539    }
540
541    #[test]
542    fn infer_from_columns_can_discard_incomplete_p4_components_and_require_weight() {
543        let options = SchemaInferenceOptions {
544            incomplete_p4_components_are_scalars: false,
545            ..Default::default()
546        };
547
548        let schema = Schema::infer_from_columns(
549            [
550                col("partial_px", ColumnType::F64),
551                col("partial_e", ColumnType::F64),
552                col("mass", ColumnType::F64),
553                col("weight", ColumnType::F64),
554            ],
555            &options,
556        )
557        .unwrap();
558
559        assert!(schema.p4s().is_empty());
560        assert_eq!(
561            schema
562                .scalars()
563                .iter()
564                .map(|n| n.to_string())
565                .collect::<Vec<_>>(),
566            vec!["mass"]
567        );
568
569        let require_weight = SchemaInferenceOptions {
570            require_weight: true,
571            ..Default::default()
572        };
573
574        let err = Schema::infer_from_columns([col("mass", ColumnType::F64)], &require_weight)
575            .unwrap_err();
576
577        assert!(matches!(err, LadduDataError::MissingColumn(name) if name.as_ref() == "weight"));
578    }
579
580    #[test]
581    fn physical_columns_and_validation_respect_custom_names_and_float_types_only() {
582        let schema = Schema::new(["p"], ["mass"], true).unwrap();
583
584        let names = SchemaColumnNames {
585            weight_column: Name::from("event_weight"),
586            ..Default::default()
587        };
588
589        let physical = schema
590            .physical_columns(&names)
591            .into_iter()
592            .map(|n| n.to_string())
593            .collect::<Vec<_>>();
594
595        assert_eq!(
596            physical,
597            vec!["p_e", "p_px", "p_py", "p_pz", "mass", "event_weight"]
598        );
599
600        let options = SchemaInferenceOptions {
601            column_names: names,
602            ..Default::default()
603        };
604
605        let ok = schema.validate_required_columns(
606            [
607                col("p_e", ColumnType::F32),
608                col("p_px", ColumnType::F64),
609                col("p_py", ColumnType::F64),
610                col("p_pz", ColumnType::F32),
611                col("mass", ColumnType::F64),
612                col("event_weight", ColumnType::F64),
613            ],
614            &options,
615        );
616
617        assert!(ok.is_ok());
618
619        let missing_because_not_float = schema
620            .validate_required_columns(
621                [
622                    col("p_e", ColumnType::F32),
623                    col("p_px", ColumnType::F64),
624                    col("p_py", ColumnType::Other),
625                    col("p_pz", ColumnType::F32),
626                    col("mass", ColumnType::F64),
627                    col("event_weight", ColumnType::F64),
628                ],
629                &options,
630            )
631            .unwrap_err();
632
633        assert!(
634            matches!(missing_because_not_float, LadduDataError::MissingColumn(name) if name.as_ref() == "p_py")
635        );
636    }
637
638    #[test]
639    fn physical_schema_plan_preserves_order_roles_and_weight_policy() {
640        let schema = Schema::new(["p"], ["mass"], false).unwrap();
641        let options = SchemaWriteOptions {
642            column_names: SchemaColumnNames {
643                weight_column: Name::from("event_weight"),
644                ..Default::default()
645            },
646            ..Default::default()
647        };
648
649        let only_if_present =
650            PhysicalSchemaPlan::for_write(&schema, &options, WriteWeightColumn::OnlyIfPresent);
651        assert_eq!(
652            only_if_present
653                .columns()
654                .iter()
655                .map(|column| column.name().to_string())
656                .collect::<Vec<_>>(),
657            ["p_e", "p_px", "p_py", "p_pz", "mass"]
658        );
659        assert_eq!(
660            only_if_present
661                .columns()
662                .iter()
663                .map(PhysicalColumn::role)
664                .collect::<Vec<_>>(),
665            [
666                PhysicalColumnRole::P4 {
667                    index: 0,
668                    component: 0,
669                },
670                PhysicalColumnRole::P4 {
671                    index: 0,
672                    component: 1,
673                },
674                PhysicalColumnRole::P4 {
675                    index: 0,
676                    component: 2,
677                },
678                PhysicalColumnRole::P4 {
679                    index: 0,
680                    component: 3,
681                },
682                PhysicalColumnRole::Scalar { index: 0 },
683            ]
684        );
685
686        let always = PhysicalSchemaPlan::for_write(&schema, &options, WriteWeightColumn::Always);
687        assert_eq!(
688            always.columns().last().map(|column| column.name().as_ref()),
689            Some("event_weight")
690        );
691        assert_eq!(
692            always.columns().last().map(PhysicalColumn::role),
693            Some(PhysicalColumnRole::Weight)
694        );
695    }
696}