1use std::{
2 collections::{BTreeMap, HashMap, HashSet},
3 sync::Arc,
4};
5
6use crate::{LadduDataError, LadduDataResult, Name};
7
8#[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
19#[derive(Clone, Debug, PartialEq)]
26pub struct ScalarBinding {
27 schema: Arc<Schema>,
28 index: usize,
29}
30
31impl ScalarBinding {
32 pub fn index(&self) -> usize {
34 self.index
35 }
36
37 pub(crate) fn matches(&self, schema: &Schema) -> bool {
38 self.schema.as_ref() == schema
39 }
40}
41
42#[derive(Clone, Debug, PartialEq)]
49pub struct P4Binding {
50 schema: Arc<Schema>,
51 index: usize,
52}
53
54impl P4Binding {
55 pub fn index(&self) -> usize {
57 self.index
58 }
59
60 pub(crate) fn matches(&self, schema: &Schema) -> bool {
61 self.schema.as_ref() == schema
62 }
63}
64
65impl PartialEq for Schema {
66 fn eq(&self, other: &Self) -> bool {
67 self.p4s == other.p4s
68 && self.scalars == other.scalars
69 && self.has_weight == other.has_weight
70 }
71}
72
73impl Schema {
74 pub fn new(
81 p4s: impl IntoIterator<Item = impl Into<Name>>,
82 scalars: impl IntoIterator<Item = impl Into<Name>>,
83 has_weight: bool,
84 ) -> LadduDataResult<Self> {
85 let p4s: Vec<Name> = p4s.into_iter().map(Into::into).collect();
86 let scalars: Vec<Name> = scalars.into_iter().map(Into::into).collect();
87 let p4_index = Arc::new(make_index(&p4s, "p4")?);
88 let scalar_index = Arc::new(make_index(&scalars, "scalar")?);
89 Ok(Self {
90 p4s,
91 scalars,
92 has_weight,
93 p4_index,
94 scalar_index,
95 })
96 }
97
98 pub fn p4_index(&self, name: &str) -> Option<usize> {
100 self.p4_index.get(name).copied()
101 }
102
103 pub fn bind_p4(&self, name: &str) -> Option<P4Binding> {
105 self.p4_index(name).map(|index| P4Binding {
106 schema: Arc::new(self.clone()),
107 index,
108 })
109 }
110
111 pub fn scalar_index(&self, name: &str) -> Option<usize> {
113 self.scalar_index.get(name).copied()
114 }
115
116 pub fn bind_scalar(&self, name: &str) -> Option<ScalarBinding> {
118 self.scalar_index(name).map(|index| ScalarBinding {
119 schema: Arc::new(self.clone()),
120 index,
121 })
122 }
123
124 pub fn p4s(&self) -> &[Name] {
126 &self.p4s
127 }
128
129 pub fn scalars(&self) -> &[Name] {
131 &self.scalars
132 }
133
134 pub fn has_weight(&self) -> bool {
136 self.has_weight
137 }
138
139 pub fn n_p4s(&self) -> usize {
141 self.p4s.len()
142 }
143
144 pub fn n_scalars(&self) -> usize {
146 self.scalars.len()
147 }
148
149 pub fn require_p4(&self, name: &str) -> LadduDataResult<usize> {
156 self.p4_index(name)
157 .ok_or_else(|| LadduDataError::MissingColumn(Name::from(name)))
158 }
159
160 pub fn require_scalar(&self, name: &str) -> LadduDataResult<usize> {
167 self.scalar_index(name)
168 .ok_or_else(|| LadduDataError::MissingColumn(Name::from(name)))
169 }
170}
171
172fn make_index(names: &[Name], kind: &'static str) -> LadduDataResult<HashMap<Name, usize>> {
173 let mut out = HashMap::with_capacity(names.len());
174 for (i, name) in names.iter().cloned().enumerate() {
175 if out.insert(name.clone(), i).is_some() {
176 return Err(LadduDataError::Schema(format!(
177 "duplicate {kind} column: {name}"
178 )));
179 }
180 }
181 Ok(out)
182}
183
184#[derive(Clone, Debug)]
186pub struct SchemaColumnNames {
187 pub weight_column: Name,
189 pub p4_suffixes: P4Suffixes,
191}
192
193impl Default for SchemaColumnNames {
194 fn default() -> Self {
195 Self {
196 weight_column: Name::from("weight"),
197 p4_suffixes: P4Suffixes::default(),
198 }
199 }
200}
201
202#[derive(Clone, Debug)]
204pub struct SchemaInferenceOptions {
205 pub column_names: SchemaColumnNames,
207 pub require_weight: bool,
209 pub incomplete_p4_components_are_scalars: bool,
211}
212
213impl Default for SchemaInferenceOptions {
214 fn default() -> Self {
215 Self {
216 column_names: SchemaColumnNames::default(),
217 require_weight: false,
218 incomplete_p4_components_are_scalars: true,
219 }
220 }
221}
222
223#[derive(Clone, Debug)]
225pub struct P4Suffixes {
226 pub e: &'static str,
228 pub px: &'static str,
230 pub py: &'static str,
232 pub pz: &'static str,
234}
235
236impl Default for P4Suffixes {
237 fn default() -> Self {
238 Self {
239 e: "_e",
240 px: "_px",
241 py: "_py",
242 pz: "_pz",
243 }
244 }
245}
246
247impl P4Suffixes {
248 pub fn component<'a>(&'a self, name: &'a str) -> Option<(&'a str, usize)> {
250 if let Some(prefix) = name.strip_suffix(self.e) {
251 Some((prefix, 0))
252 } else if let Some(prefix) = name.strip_suffix(self.px) {
253 Some((prefix, 1))
254 } else if let Some(prefix) = name.strip_suffix(self.py) {
255 Some((prefix, 2))
256 } else if let Some(prefix) = name.strip_suffix(self.pz) {
257 Some((prefix, 3))
258 } else {
259 None
260 }
261 }
262
263 pub fn physical_p4_names(&self, prefix: &str) -> [String; 4] {
265 [
266 format!("{prefix}{}", self.e),
267 format!("{prefix}{}", self.px),
268 format!("{prefix}{}", self.py),
269 format!("{prefix}{}", self.pz),
270 ]
271 }
272}
273
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub enum ColumnType {
277 F64,
279 F32,
281 Other,
283}
284
285impl ColumnType {
286 pub fn is_supported_float(self) -> bool {
288 matches!(self, Self::F64 | Self::F32)
289 }
290}
291
292#[derive(Clone, Copy, Debug)]
294pub struct ColumnInfo<'a> {
295 pub name: &'a str,
297 pub dtype: ColumnType,
299}
300
301impl Schema {
302 pub fn infer_from_columns<'a>(
309 columns: impl IntoIterator<Item = ColumnInfo<'a>>,
310 options: &SchemaInferenceOptions,
311 ) -> LadduDataResult<Self> {
312 let mut p4_candidates = BTreeMap::<String, [bool; 4]>::new();
313 let mut scalar_names = Vec::<Name>::new();
314 let mut has_weight = false;
315
316 for col in columns {
317 if !col.dtype.is_supported_float() {
318 continue;
319 }
320
321 if col.name == options.column_names.weight_column.as_ref() {
322 has_weight = true;
323 continue;
324 }
325
326 if let Some((prefix, component)) = options.column_names.p4_suffixes.component(col.name)
327 {
328 p4_candidates.entry(prefix.to_owned()).or_default()[component] = true;
329 } else {
330 scalar_names.push(Name::from(col.name));
331 }
332 }
333
334 let mut p4s = Vec::<Name>::new();
335
336 for (prefix, seen) in p4_candidates {
337 if seen == [true, true, true, true] {
338 p4s.push(Name::from(prefix));
339 } else if options.incomplete_p4_components_are_scalars {
340 let names = options.column_names.p4_suffixes.physical_p4_names(&prefix);
341
342 for (i, name) in names.into_iter().enumerate() {
343 if seen[i] {
344 scalar_names.push(Name::from(name));
345 }
346 }
347 }
348 }
349
350 if options.require_weight && !has_weight {
351 return Err(LadduDataError::MissingColumn(Arc::clone(
352 &options.column_names.weight_column,
353 )));
354 }
355
356 Schema::new(p4s, scalar_names, has_weight)
357 }
358
359 pub fn physical_columns(&self, column_names: &SchemaColumnNames) -> Vec<Name> {
361 PhysicalSchemaPlan::for_read(self, column_names)
362 .columns()
363 .iter()
364 .map(|column| Arc::clone(column.name()))
365 .collect()
366 }
367
368 pub fn validate_required_columns<'a>(
375 &self,
376 available: impl IntoIterator<Item = ColumnInfo<'a>>,
377 options: &SchemaInferenceOptions,
378 ) -> LadduDataResult<()> {
379 let available: HashSet<&str> = available
380 .into_iter()
381 .filter(|c| c.dtype.is_supported_float())
382 .map(|c| c.name)
383 .collect();
384
385 for required in PhysicalSchemaPlan::for_read(self, &options.column_names).columns() {
386 if !available.contains(required.name().as_ref()) {
387 return Err(LadduDataError::MissingColumn(Arc::clone(required.name())));
388 }
389 }
390
391 Ok(())
392 }
393}
394
395#[derive(Copy, Clone, Debug, Default)]
397pub enum Precision {
398 #[default]
400 F64,
401 F32,
403}
404
405#[derive(Clone, Copy, Debug, Default)]
407pub enum WriteWeightColumn {
408 #[default]
410 Always,
411 OnlyIfPresent,
413}
414
415#[derive(Clone, Debug, Default)]
417pub struct SchemaWriteOptions {
418 pub column_names: SchemaColumnNames,
420 pub precision: Precision,
422 pub write_weight_column: WriteWeightColumn,
424}
425
426#[derive(Clone, Copy, Debug, PartialEq, Eq)]
432pub(crate) enum PhysicalColumnRole {
433 P4 {
435 index: usize,
437 component: usize,
439 },
440 Scalar {
442 index: usize,
444 },
445 Weight,
447}
448
449#[derive(Clone, Debug)]
455pub(crate) struct PhysicalSchemaPlan {
456 columns: Vec<PhysicalColumn>,
457}
458
459#[derive(Clone, Debug)]
460pub(crate) struct PhysicalColumn {
461 name: Name,
462 role: PhysicalColumnRole,
463}
464
465impl PhysicalSchemaPlan {
466 pub(crate) fn for_read(schema: &Schema, column_names: &SchemaColumnNames) -> Self {
468 Self::build(schema, column_names, schema.has_weight())
469 }
470
471 pub(crate) fn for_write(
473 schema: &Schema,
474 options: &SchemaWriteOptions,
475 write_weight: WriteWeightColumn,
476 ) -> Self {
477 let should_write_weight =
478 matches!(write_weight, WriteWeightColumn::Always) || schema.has_weight();
479 Self::build(schema, &options.column_names, should_write_weight)
480 }
481
482 fn build(schema: &Schema, column_names: &SchemaColumnNames, include_weight: bool) -> Self {
483 let mut columns = Vec::with_capacity(
484 4 * schema.n_p4s() + schema.n_scalars() + usize::from(include_weight),
485 );
486
487 for (index, p4) in schema.p4s().iter().enumerate() {
488 for (component, name) in column_names
489 .p4_suffixes
490 .physical_p4_names(p4)
491 .into_iter()
492 .enumerate()
493 {
494 columns.push(PhysicalColumn {
495 name: Name::from(name),
496 role: PhysicalColumnRole::P4 { index, component },
497 });
498 }
499 }
500
501 for (index, name) in schema.scalars().iter().cloned().enumerate() {
502 columns.push(PhysicalColumn {
503 name,
504 role: PhysicalColumnRole::Scalar { index },
505 });
506 }
507
508 if include_weight {
509 columns.push(PhysicalColumn {
510 name: Arc::clone(&column_names.weight_column),
511 role: PhysicalColumnRole::Weight,
512 });
513 }
514
515 Self { columns }
516 }
517
518 pub(crate) fn columns(&self) -> &[PhysicalColumn] {
520 &self.columns
521 }
522}
523
524impl PhysicalColumn {
525 pub(crate) fn name(&self) -> &Name {
527 &self.name
528 }
529
530 pub(crate) fn role(&self) -> PhysicalColumnRole {
532 self.role
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 fn col(name: &'static str, dtype: ColumnType) -> ColumnInfo<'static> {
541 ColumnInfo { name, dtype }
542 }
543
544 #[test]
545 fn schema_new_rejects_duplicates_and_required_lookup_reports_missing_column() {
546 let duplicate_p4 = Schema::new(["p", "p"], ["mass"], false);
547 assert!(matches!(duplicate_p4, Err(LadduDataError::Schema(_))));
548
549 let duplicate_scalar = Schema::new(["p"], ["mass", "mass"], false);
550 assert!(matches!(duplicate_scalar, Err(LadduDataError::Schema(_))));
551
552 let schema = Schema::new(["beam", "recoil"], ["mass", "costheta"], true).unwrap();
553
554 assert_eq!(schema.require_p4("recoil").unwrap(), 1);
555 assert_eq!(schema.require_scalar("costheta").unwrap(), 1);
556
557 let err = schema.require_scalar("missing").unwrap_err();
558 assert!(matches!(err, LadduDataError::MissingColumn(name) if name.as_ref() == "missing"));
559 }
560
561 #[test]
562 fn schema_bindings_resolve_typed_indices_once() {
563 let schema = Schema::new(["beam", "recoil"], ["mass", "costheta"], true).unwrap();
564
565 assert_eq!(schema.bind_p4("recoil").unwrap().index(), 1);
566 assert_eq!(schema.bind_scalar("costheta").unwrap().index(), 1);
567 assert_eq!(schema.bind_p4("missing"), None);
568 assert_eq!(schema.bind_scalar("missing"), None);
569 }
570
571 #[test]
572 fn infer_from_columns_groups_complete_p4s_keeps_incomplete_components_as_scalars_and_ignores_nonfloats()
573 {
574 let options = SchemaInferenceOptions::default();
575
576 let schema = Schema::infer_from_columns(
577 [
578 col("gamma_px", ColumnType::F64),
579 col("gamma_py", ColumnType::F64),
580 col("gamma_pz", ColumnType::F32),
581 col("gamma_e", ColumnType::F64),
582 col("partial_px", ColumnType::F64),
583 col("partial_e", ColumnType::F64),
584 col("mass", ColumnType::F32),
585 col("ignored", ColumnType::Other),
586 col("weight", ColumnType::F64),
587 ],
588 &options,
589 )
590 .unwrap();
591
592 assert_eq!(
593 schema
594 .p4s()
595 .iter()
596 .map(|n| n.to_string())
597 .collect::<Vec<_>>(),
598 vec!["gamma"]
599 );
600
601 assert_eq!(
602 schema
603 .scalars()
604 .iter()
605 .map(|n| n.to_string())
606 .collect::<Vec<_>>(),
607 vec!["mass", "partial_e", "partial_px"]
608 );
609
610 assert!(schema.has_weight());
611 }
612
613 #[test]
614 fn infer_from_columns_can_discard_incomplete_p4_components_and_require_weight() {
615 let options = SchemaInferenceOptions {
616 incomplete_p4_components_are_scalars: false,
617 ..Default::default()
618 };
619
620 let schema = Schema::infer_from_columns(
621 [
622 col("partial_px", ColumnType::F64),
623 col("partial_e", ColumnType::F64),
624 col("mass", ColumnType::F64),
625 col("weight", ColumnType::F64),
626 ],
627 &options,
628 )
629 .unwrap();
630
631 assert!(schema.p4s().is_empty());
632 assert_eq!(
633 schema
634 .scalars()
635 .iter()
636 .map(|n| n.to_string())
637 .collect::<Vec<_>>(),
638 vec!["mass"]
639 );
640
641 let require_weight = SchemaInferenceOptions {
642 require_weight: true,
643 ..Default::default()
644 };
645
646 let err = Schema::infer_from_columns([col("mass", ColumnType::F64)], &require_weight)
647 .unwrap_err();
648
649 assert!(matches!(err, LadduDataError::MissingColumn(name) if name.as_ref() == "weight"));
650 }
651
652 #[test]
653 fn physical_columns_and_validation_respect_custom_names_and_float_types_only() {
654 let schema = Schema::new(["p"], ["mass"], true).unwrap();
655
656 let names = SchemaColumnNames {
657 weight_column: Name::from("event_weight"),
658 ..Default::default()
659 };
660
661 let physical = schema
662 .physical_columns(&names)
663 .into_iter()
664 .map(|n| n.to_string())
665 .collect::<Vec<_>>();
666
667 assert_eq!(
668 physical,
669 vec!["p_e", "p_px", "p_py", "p_pz", "mass", "event_weight"]
670 );
671
672 let options = SchemaInferenceOptions {
673 column_names: names,
674 ..Default::default()
675 };
676
677 let ok = schema.validate_required_columns(
678 [
679 col("p_e", ColumnType::F32),
680 col("p_px", ColumnType::F64),
681 col("p_py", ColumnType::F64),
682 col("p_pz", ColumnType::F32),
683 col("mass", ColumnType::F64),
684 col("event_weight", ColumnType::F64),
685 ],
686 &options,
687 );
688
689 assert!(ok.is_ok());
690
691 let missing_because_not_float = schema
692 .validate_required_columns(
693 [
694 col("p_e", ColumnType::F32),
695 col("p_px", ColumnType::F64),
696 col("p_py", ColumnType::Other),
697 col("p_pz", ColumnType::F32),
698 col("mass", ColumnType::F64),
699 col("event_weight", ColumnType::F64),
700 ],
701 &options,
702 )
703 .unwrap_err();
704
705 assert!(
706 matches!(missing_because_not_float, LadduDataError::MissingColumn(name) if name.as_ref() == "p_py")
707 );
708 }
709
710 #[test]
711 fn physical_schema_plan_preserves_order_roles_and_weight_policy() {
712 let schema = Schema::new(["p"], ["mass"], false).unwrap();
713 let options = SchemaWriteOptions {
714 column_names: SchemaColumnNames {
715 weight_column: Name::from("event_weight"),
716 ..Default::default()
717 },
718 ..Default::default()
719 };
720
721 let only_if_present =
722 PhysicalSchemaPlan::for_write(&schema, &options, WriteWeightColumn::OnlyIfPresent);
723 assert_eq!(
724 only_if_present
725 .columns()
726 .iter()
727 .map(|column| column.name().to_string())
728 .collect::<Vec<_>>(),
729 ["p_e", "p_px", "p_py", "p_pz", "mass"]
730 );
731 assert_eq!(
732 only_if_present
733 .columns()
734 .iter()
735 .map(PhysicalColumn::role)
736 .collect::<Vec<_>>(),
737 [
738 PhysicalColumnRole::P4 {
739 index: 0,
740 component: 0,
741 },
742 PhysicalColumnRole::P4 {
743 index: 0,
744 component: 1,
745 },
746 PhysicalColumnRole::P4 {
747 index: 0,
748 component: 2,
749 },
750 PhysicalColumnRole::P4 {
751 index: 0,
752 component: 3,
753 },
754 PhysicalColumnRole::Scalar { index: 0 },
755 ]
756 );
757
758 let always = PhysicalSchemaPlan::for_write(&schema, &options, WriteWeightColumn::Always);
759 assert_eq!(
760 always.columns().last().map(|column| column.name().as_ref()),
761 Some("event_weight")
762 );
763 assert_eq!(
764 always.columns().last().map(PhysicalColumn::role),
765 Some(PhysicalColumnRole::Weight)
766 );
767 }
768}