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 let mut names = Vec::with_capacity(4 * self.n_p4s() + self.n_scalars() + 1);
300
301 for p4 in self.p4s() {
302 for name in column_names.p4_suffixes.physical_p4_names(p4) {
303 names.push(Name::from(name));
304 }
305 }
306
307 names.extend(self.scalars().iter().cloned());
308
309 if self.has_weight() {
310 names.push(Arc::clone(&column_names.weight_column));
311 }
312
313 names
314 }
315
316 pub fn validate_required_columns<'a>(
323 &self,
324 available: impl IntoIterator<Item = ColumnInfo<'a>>,
325 options: &SchemaInferenceOptions,
326 ) -> LadduDataResult<()> {
327 let available: HashSet<&str> = available
328 .into_iter()
329 .filter(|c| c.dtype.is_supported_float())
330 .map(|c| c.name)
331 .collect();
332
333 for required in self.physical_columns(&options.column_names) {
334 if !available.contains(required.as_ref()) {
335 return Err(LadduDataError::MissingColumn(required));
336 }
337 }
338
339 Ok(())
340 }
341}
342
343#[derive(Copy, Clone, Debug, Default)]
345pub enum Precision {
346 #[default]
348 F64,
349 F32,
351}
352
353#[derive(Clone, Copy, Debug, Default)]
355pub enum WriteWeightColumn {
356 #[default]
358 Always,
359 OnlyIfPresent,
361}
362
363#[derive(Clone, Debug, Default)]
365pub struct SchemaWriteOptions {
366 pub column_names: SchemaColumnNames,
368 pub precision: Precision,
370 pub write_weight_column: WriteWeightColumn,
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn col(name: &'static str, dtype: ColumnType) -> ColumnInfo<'static> {
379 ColumnInfo { name, dtype }
380 }
381
382 #[test]
383 fn schema_new_rejects_duplicates_and_required_lookup_reports_missing_column() {
384 let duplicate_p4 = Schema::new(["p", "p"], ["mass"], false);
385 assert!(matches!(duplicate_p4, Err(LadduDataError::Schema(_))));
386
387 let duplicate_scalar = Schema::new(["p"], ["mass", "mass"], false);
388 assert!(matches!(duplicate_scalar, Err(LadduDataError::Schema(_))));
389
390 let schema = Schema::new(["beam", "recoil"], ["mass", "costheta"], true).unwrap();
391
392 assert_eq!(schema.require_p4("recoil").unwrap(), 1);
393 assert_eq!(schema.require_scalar("costheta").unwrap(), 1);
394
395 let err = schema.require_scalar("missing").unwrap_err();
396 assert!(matches!(err, LadduDataError::MissingColumn(name) if name.as_ref() == "missing"));
397 }
398
399 #[test]
400 fn infer_from_columns_groups_complete_p4s_keeps_incomplete_components_as_scalars_and_ignores_nonfloats()
401 {
402 let options = SchemaInferenceOptions::default();
403
404 let schema = Schema::infer_from_columns(
405 [
406 col("gamma_px", ColumnType::F64),
407 col("gamma_py", ColumnType::F64),
408 col("gamma_pz", ColumnType::F32),
409 col("gamma_e", ColumnType::F64),
410 col("partial_px", ColumnType::F64),
411 col("partial_e", ColumnType::F64),
412 col("mass", ColumnType::F32),
413 col("ignored", ColumnType::Other),
414 col("weight", ColumnType::F64),
415 ],
416 &options,
417 )
418 .unwrap();
419
420 assert_eq!(
421 schema
422 .p4s()
423 .iter()
424 .map(|n| n.to_string())
425 .collect::<Vec<_>>(),
426 vec!["gamma"]
427 );
428
429 assert_eq!(
430 schema
431 .scalars()
432 .iter()
433 .map(|n| n.to_string())
434 .collect::<Vec<_>>(),
435 vec!["mass", "partial_e", "partial_px"]
436 );
437
438 assert!(schema.has_weight());
439 }
440
441 #[test]
442 fn infer_from_columns_can_discard_incomplete_p4_components_and_require_weight() {
443 let options = SchemaInferenceOptions {
444 incomplete_p4_components_are_scalars: false,
445 ..Default::default()
446 };
447
448 let schema = Schema::infer_from_columns(
449 [
450 col("partial_px", ColumnType::F64),
451 col("partial_e", ColumnType::F64),
452 col("mass", ColumnType::F64),
453 col("weight", ColumnType::F64),
454 ],
455 &options,
456 )
457 .unwrap();
458
459 assert!(schema.p4s().is_empty());
460 assert_eq!(
461 schema
462 .scalars()
463 .iter()
464 .map(|n| n.to_string())
465 .collect::<Vec<_>>(),
466 vec!["mass"]
467 );
468
469 let require_weight = SchemaInferenceOptions {
470 require_weight: true,
471 ..Default::default()
472 };
473
474 let err = Schema::infer_from_columns([col("mass", ColumnType::F64)], &require_weight)
475 .unwrap_err();
476
477 assert!(matches!(err, LadduDataError::MissingColumn(name) if name.as_ref() == "weight"));
478 }
479
480 #[test]
481 fn physical_columns_and_validation_respect_custom_names_and_float_types_only() {
482 let schema = Schema::new(["p"], ["mass"], true).unwrap();
483
484 let names = SchemaColumnNames {
485 weight_column: Name::from("event_weight"),
486 ..Default::default()
487 };
488
489 let physical = schema
490 .physical_columns(&names)
491 .into_iter()
492 .map(|n| n.to_string())
493 .collect::<Vec<_>>();
494
495 assert_eq!(
496 physical,
497 vec!["p_e", "p_px", "p_py", "p_pz", "mass", "event_weight"]
498 );
499
500 let options = SchemaInferenceOptions {
501 column_names: names,
502 ..Default::default()
503 };
504
505 let ok = schema.validate_required_columns(
506 [
507 col("p_e", ColumnType::F32),
508 col("p_px", ColumnType::F64),
509 col("p_py", ColumnType::F64),
510 col("p_pz", ColumnType::F32),
511 col("mass", ColumnType::F64),
512 col("event_weight", ColumnType::F64),
513 ],
514 &options,
515 );
516
517 assert!(ok.is_ok());
518
519 let missing_because_not_float = schema
520 .validate_required_columns(
521 [
522 col("p_e", ColumnType::F32),
523 col("p_px", ColumnType::F64),
524 col("p_py", ColumnType::Other),
525 col("p_pz", ColumnType::F32),
526 col("mass", ColumnType::F64),
527 col("event_weight", ColumnType::F64),
528 ],
529 &options,
530 )
531 .unwrap_err();
532
533 assert!(
534 matches!(missing_because_not_float, LadduDataError::MissingColumn(name) if name.as_ref() == "p_py")
535 );
536 }
537}