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
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 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 pub fn p4_index(&self, name: &str) -> Option<usize> {
54 self.p4_index.get(name).copied()
55 }
56
57 pub fn scalar_index(&self, name: &str) -> Option<usize> {
59 self.scalar_index.get(name).copied()
60 }
61
62 pub fn p4s(&self) -> &[Name] {
64 &self.p4s
65 }
66
67 pub fn scalars(&self) -> &[Name] {
69 &self.scalars
70 }
71
72 pub fn has_weight(&self) -> bool {
74 self.has_weight
75 }
76
77 pub fn n_p4s(&self) -> usize {
79 self.p4s.len()
80 }
81
82 pub fn n_scalars(&self) -> usize {
84 self.scalars.len()
85 }
86
87 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 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#[derive(Clone, Debug)]
124pub struct SchemaColumnNames {
125 pub weight_column: Name,
127 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#[derive(Clone, Debug)]
142pub struct SchemaInferenceOptions {
143 pub column_names: SchemaColumnNames,
145 pub require_weight: bool,
147 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#[derive(Clone, Debug)]
163pub struct P4Suffixes {
164 pub e: &'static str,
166 pub px: &'static str,
168 pub py: &'static str,
170 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
214pub enum ColumnType {
215 F64,
217 F32,
219 Other,
221}
222
223impl ColumnType {
224 pub fn is_supported_float(self) -> bool {
226 matches!(self, Self::F64 | Self::F32)
227 }
228}
229
230#[derive(Clone, Copy, Debug)]
232pub struct ColumnInfo<'a> {
233 pub name: &'a str,
235 pub dtype: ColumnType,
237}
238
239impl Schema {
240 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 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 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#[derive(Copy, Clone, Debug, Default)]
335pub enum Precision {
336 #[default]
338 F64,
339 F32,
341}
342
343#[derive(Clone, Copy, Debug, Default)]
345pub enum WriteWeightColumn {
346 #[default]
348 Always,
349 OnlyIfPresent,
351}
352
353#[derive(Clone, Debug, Default)]
355pub struct SchemaWriteOptions {
356 pub column_names: SchemaColumnNames,
358 pub precision: Precision,
360 pub write_weight_column: WriteWeightColumn,
362}
363
364#[derive(Clone, Copy, Debug, PartialEq, Eq)]
370pub(crate) enum PhysicalColumnRole {
371 P4 {
373 index: usize,
375 component: usize,
377 },
378 Scalar {
380 index: usize,
382 },
383 Weight,
385}
386
387#[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 pub(crate) fn for_read(schema: &Schema, column_names: &SchemaColumnNames) -> Self {
406 Self::build(schema, column_names, schema.has_weight())
407 }
408
409 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 pub(crate) fn columns(&self) -> &[PhysicalColumn] {
458 &self.columns
459 }
460}
461
462impl PhysicalColumn {
463 pub(crate) fn name(&self) -> &Name {
465 &self.name
466 }
467
468 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}