1use crate::model::Model;
2use crate::momentum::FourMomentum;
3use crate::settings::RuntimeSettings;
4use crate::utils::serde_utils::{
5 IsDefault, is_false, is_float, is_true, is_usize, show_defaults_helper,
6};
7use crate::utils::{F, FloatLike};
8use bincode_trait_derive::{Decode, Encode};
9use eyre::{Result, eyre};
10use schemars::JsonSchema;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use smallvec::{SmallVec, smallvec};
13use spenso::algebra::complex::Complex;
14use std::cmp::Ordering;
15use std::collections::BTreeMap;
16use std::fmt;
17use std::fs::File;
18use std::io::{BufReader, BufWriter, Write};
19use std::path::Path;
20
21pub mod clustering;
22pub mod events;
23
24pub use clustering::{ClusteringResult, Jet, JetAlgorithm, JetClustering};
25pub use events::{
26 AdditionalWeightKey, CutInfo, Event, EventGroup, EventGroupList, GenericAdditionalWeightInfo,
27 GenericEvent, GenericEventGroup, GenericEventGroupList,
28};
29
30pub type QuantitiesSettings = BTreeMap<String, QuantitySettings>;
31pub type ObservablesSettings = BTreeMap<String, ObservableSettings>;
32pub type SelectorsSettings = BTreeMap<String, SelectorSettings>;
33
34#[derive(Debug, Clone, Default)]
35pub struct HistogramProcessInfo {
36 pub graph_names: Vec<String>,
37 pub graph_to_group_id: Vec<usize>,
38 pub graph_group_master_names: Vec<String>,
39 pub orientation_labels_by_group: Vec<Vec<String>>,
40 pub lmb_channel_labels_by_group: Vec<Vec<String>>,
41}
42
43#[derive(
44 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
45)]
46#[serde(rename_all = "snake_case")]
47pub enum EntrySelection {
48 #[default]
49 All,
50 LeadingOnly,
51 NthOnly,
52}
53
54#[derive(
55 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
56)]
57#[serde(rename_all = "snake_case")]
58pub enum SelectorReduction {
59 #[default]
60 AnyInRange,
61 AllInRange,
62}
63
64#[derive(
65 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
66)]
67#[serde(rename_all = "snake_case")]
68pub enum ObservableValueTransform {
69 #[default]
70 Identity,
71 Log10,
72}
73
74#[derive(
75 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
76)]
77#[serde(rename_all = "snake_case")]
78pub enum ObservablePhase {
79 #[default]
80 Real,
81 Imag,
82}
83
84#[derive(
85 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
86)]
87#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
88pub enum ObservableFileFormat {
89 #[serde(rename = "none")]
90 None,
91 #[serde(rename = "hwu", alias = "HwU")]
92 Hwu,
93 #[default]
94 #[serde(rename = "json")]
95 Json,
96}
97
98#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
99pub enum FilterQuantity {
100 #[serde(rename = "E")]
101 Energy,
102 #[serde(rename = "CosTheta")]
103 CosThetaP,
104 #[serde(rename = "PT")]
105 PT,
106 #[serde(rename = "y")]
107 Rapidity,
108 #[serde(rename = "eta")]
109 PseudoRapidity,
110 #[serde(rename = "Px")]
111 Px,
112 #[serde(rename = "Py")]
113 Py,
114 #[serde(rename = "Pz")]
115 Pz,
116 #[serde(rename = "Mass")]
117 Mass,
118}
119
120impl FilterQuantity {
121 pub(crate) fn setting_name(&self) -> &'static str {
122 match self {
123 FilterQuantity::Energy => "E",
124 FilterQuantity::CosThetaP => "CosTheta",
125 FilterQuantity::PT => "PT",
126 FilterQuantity::Rapidity => "y",
127 FilterQuantity::PseudoRapidity => "eta",
128 FilterQuantity::Px => "Px",
129 FilterQuantity::Py => "Py",
130 FilterQuantity::Pz => "Pz",
131 FilterQuantity::Mass => "Mass",
132 }
133 }
134
135 fn project_momentum<T: FloatLike>(
136 &self,
137 momentum: &FourMomentum<F<T>>,
138 incoming_beam: Option<&FourMomentum<F<T>>>,
139 ) -> Option<F<T>> {
140 match self {
141 FilterQuantity::Energy => Some(momentum.temporal.value.clone()),
142 FilterQuantity::CosThetaP => incoming_beam.map(|beam| {
143 let beam_spatial = beam.spatial.clone();
144 let momentum_spatial = momentum.spatial.clone();
145 beam_spatial.clone() * momentum_spatial.clone()
146 / (beam_spatial.norm() * momentum_spatial.norm())
147 }),
148 FilterQuantity::PT => Some(momentum.pt()),
149 FilterQuantity::Rapidity => Some(momentum.rapidity()),
150 FilterQuantity::PseudoRapidity => Some(momentum.spatial.pseudo_rap()),
151 FilterQuantity::Px => Some(momentum.spatial.px.clone()),
152 FilterQuantity::Py => Some(momentum.spatial.py.clone()),
153 FilterQuantity::Pz => Some(momentum.spatial.pz.clone()),
154 FilterQuantity::Mass => Some(momentum.square().abs().sqrt()),
155 }
156 }
157}
158
159impl fmt::Display for FilterQuantity {
160 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
161 write!(f, "{}", self.setting_name())
162 }
163}
164
165#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
166pub enum QuantityOrdering {
167 #[serde(rename = "PT")]
168 PT,
169 #[serde(rename = "Energy")]
170 Energy,
171 #[serde(rename = "AbsRapidity")]
172 AbsRapidity,
173 #[serde(rename = "Quantity")]
174 Quantity,
175}
176
177impl QuantityOrdering {
178 pub(crate) fn setting_name(&self) -> &'static str {
179 match self {
180 QuantityOrdering::PT => "PT",
181 QuantityOrdering::Energy => "Energy",
182 QuantityOrdering::AbsRapidity => "AbsRapidity",
183 QuantityOrdering::Quantity => "Quantity",
184 }
185 }
186
187 fn scalar_sort_key<T: FloatLike>(
188 &self,
189 momentum: &FourMomentum<F<T>>,
190 quantity_value: &F<T>,
191 ) -> F<T> {
192 match self {
193 QuantityOrdering::PT => momentum.pt(),
194 QuantityOrdering::Energy => momentum.temporal.value.clone(),
195 QuantityOrdering::AbsRapidity => momentum.rapidity().abs(),
196 QuantityOrdering::Quantity => quantity_value.clone(),
197 }
198 }
199}
200
201impl fmt::Display for QuantityOrdering {
202 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
203 write!(f, "{}", self.setting_name())
204 }
205}
206
207#[derive(
208 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
209)]
210pub enum QuantityOrder {
211 #[serde(rename = "Ascending")]
212 Ascending,
213 #[default]
214 #[serde(rename = "Descending")]
215 Descending,
216}
217
218impl QuantityOrder {
219 pub(crate) fn setting_name(&self) -> &'static str {
220 match self {
221 QuantityOrder::Ascending => "Ascending",
222 QuantityOrder::Descending => "Descending",
223 }
224 }
225}
226
227impl fmt::Display for QuantityOrder {
228 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
229 write!(f, "{}", self.setting_name())
230 }
231}
232
233#[derive(
234 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
235)]
236#[serde(rename_all = "snake_case")]
237pub enum QuantityComputation {
238 #[default]
239 Scalar,
240 Count,
241 Pair,
242}
243
244#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Encode, Decode, JsonSchema)]
245pub enum PairQuantity {
246 #[serde(rename = "DeltaR")]
247 DeltaR,
248}
249
250impl PairQuantity {
251 pub(crate) fn setting_name(&self) -> &'static str {
252 match self {
253 PairQuantity::DeltaR => "DeltaR",
254 }
255 }
256
257 fn project_momenta<T: FloatLike>(
258 &self,
259 left: &FourMomentum<F<T>>,
260 right: &FourMomentum<F<T>>,
261 ) -> F<T> {
262 match self {
263 PairQuantity::DeltaR => left.delta_r(right),
264 }
265 }
266}
267
268impl fmt::Display for PairQuantity {
269 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
270 write!(f, "{}", self.setting_name())
271 }
272}
273
274#[derive(
275 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
276)]
277#[serde(rename_all = "snake_case")]
278pub enum PairingMode {
279 #[default]
280 AllPairs,
281}
282
283impl PairingMode {
284 pub(crate) fn setting_name(&self) -> &'static str {
285 match self {
286 PairingMode::AllPairs => "all_pairs",
287 }
288 }
289}
290
291impl fmt::Display for PairingMode {
292 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
293 write!(f, "{}", self.setting_name())
294 }
295}
296
297fn filter_quantity_completion_example() -> FilterQuantity {
298 FilterQuantity::PT
299}
300
301fn pair_quantity_completion_example() -> PairQuantity {
302 PairQuantity::DeltaR
303}
304
305fn pairing_mode_completion_example() -> PairingMode {
306 PairingMode::AllPairs
307}
308
309fn quantity_ordering_completion_example() -> QuantityOrdering {
310 QuantityOrdering::Quantity
311}
312
313fn quantity_order_completion_example() -> QuantityOrder {
314 QuantityOrder::Descending
315}
316
317#[derive(Debug, Clone, Copy)]
318enum QuantitySourceKind {
319 Particle,
320 Jet,
321}
322
323impl QuantitySourceKind {
324 fn default_scalar_ordering(self) -> QuantityOrdering {
325 match self {
326 QuantitySourceKind::Particle => QuantityOrdering::Quantity,
327 QuantitySourceKind::Jet => QuantityOrdering::PT,
328 }
329 }
330}
331
332#[derive(Debug, Clone, Default, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
333#[allow(non_snake_case)]
334#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
335#[serde(default, deny_unknown_fields)]
336pub struct QuantityComputationSettings {
337 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
338 #[schemars(
339 description = "Quantity computation mode: scalar projection per object, exact object count, or pairwise quantity."
340 )]
341 pub computation: QuantityComputation,
342 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
343 #[schemars(
344 description = "Per-object scalar quantity used when computation = \"scalar\".",
345 example = filter_quantity_completion_example()
346 )]
347 pub quantity: Option<FilterQuantity>,
348 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
349 #[schemars(
350 description = "Pairwise quantity used when computation = \"pair\".",
351 example = pair_quantity_completion_example()
352 )]
353 pub pair_quantity: Option<PairQuantity>,
354 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
355 #[schemars(
356 description = "Pairing strategy used when computation = \"pair\".",
357 example = pairing_mode_completion_example()
358 )]
359 pub pairing: Option<PairingMode>,
360 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
361 #[schemars(
362 description = "Ordering key used before entry_selection. Particle scalar quantities default to Quantity, jet scalar quantities default to PT, and pair quantities default to Quantity.",
363 example = quantity_ordering_completion_example()
364 )]
365 pub ordering: Option<QuantityOrdering>,
366 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
367 #[schemars(
368 description = "Sorting direction applied together with ordering.",
369 example = quantity_order_completion_example()
370 )]
371 pub order: QuantityOrder,
372}
373
374impl QuantityComputationSettings {
375 pub fn scalar(quantity: FilterQuantity) -> Self {
376 Self {
377 computation: QuantityComputation::Scalar,
378 quantity: Some(quantity),
379 pair_quantity: None,
380 pairing: None,
381 ordering: None,
382 order: QuantityOrder::Descending,
383 }
384 }
385
386 fn try_normalized_for_source(&self, source: QuantitySourceKind) -> Result<Self> {
387 match self.computation {
388 QuantityComputation::Scalar => Ok(Self {
389 computation: QuantityComputation::Scalar,
390 quantity: Some(self.quantity.unwrap_or(FilterQuantity::PT)),
391 pair_quantity: None,
392 pairing: None,
393 ordering: Some(self.ordering.unwrap_or(source.default_scalar_ordering())),
394 order: self.order,
395 }),
396 QuantityComputation::Count => Ok(Self {
397 computation: QuantityComputation::Count,
398 quantity: None,
399 pair_quantity: None,
400 pairing: None,
401 ordering: None,
402 order: QuantityOrder::Descending,
403 }),
404 QuantityComputation::Pair => {
405 let ordering = self.ordering.unwrap_or(QuantityOrdering::Quantity);
406 if ordering != QuantityOrdering::Quantity {
407 return Err(eyre!(
408 "Pair quantities only support ordering=Quantity, got ordering={ordering}"
409 ));
410 }
411
412 Ok(Self {
413 computation: QuantityComputation::Pair,
414 quantity: None,
415 pair_quantity: Some(self.pair_quantity.unwrap_or(PairQuantity::DeltaR)),
416 pairing: Some(self.pairing.unwrap_or_default()),
417 ordering: Some(QuantityOrdering::Quantity),
418 order: self.order,
419 })
420 }
421 }
422 }
423
424 fn resolve_for_source(
425 &self,
426 source: QuantitySourceKind,
427 ) -> Result<ResolvedQuantityComputation> {
428 let normalized = self.try_normalized_for_source(source)?;
429 match normalized.computation {
430 QuantityComputation::Scalar => Ok(ResolvedQuantityComputation::Scalar {
431 quantity: normalized.quantity.unwrap(),
432 ordering: normalized.ordering.unwrap(),
433 order: normalized.order,
434 }),
435 QuantityComputation::Count => Ok(ResolvedQuantityComputation::Count),
436 QuantityComputation::Pair => Ok(ResolvedQuantityComputation::Pair {
437 quantity: normalized.pair_quantity.unwrap(),
438 pairing: normalized.pairing.unwrap(),
439 order: normalized.order,
440 }),
441 }
442 }
443}
444
445#[derive(Debug, Clone, Default, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
446#[allow(non_snake_case)]
447#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
448#[serde(default, deny_unknown_fields)]
449pub struct JetClusteringSettings {
450 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
451 pub algorithm: JetAlgorithm,
452 #[serde(skip_serializing_if = "is_float::<0>")]
453 pub dR: f64,
454 #[serde(skip_serializing_if = "is_float::<0>")]
455 pub min_jpt: f64,
456 #[serde(skip_serializing_if = "IsDefault::is_default")]
457 #[schemars(
458 description = "Optional list of PDG IDs allowed to be clustered. Use null to derive the default from the model, or a compact list like [-1,1,21,82] in CLI key-value assignments.",
459 example = clustered_pdgs_completion_example()
460 )]
461 pub clustered_pdgs: Option<Vec<isize>>,
462}
463
464fn clustered_pdgs_completion_example() -> Option<Vec<isize>> {
465 Some(vec![-1, 1, 21, 82])
466}
467
468#[derive(Debug, Clone, PartialEq)]
469struct ResolvedJetClusteringSettings {
470 algorithm: JetAlgorithm,
471 d_r: f64,
472 min_jpt: f64,
473 clustered_pdgs: Vec<isize>,
474}
475
476impl JetClusteringSettings {
477 fn resolve(&self, model: Option<&Model>) -> Result<ResolvedJetClusteringSettings> {
478 Ok(ResolvedJetClusteringSettings {
479 algorithm: self.algorithm,
480 d_r: self.dR,
481 min_jpt: self.min_jpt,
482 clustered_pdgs: self.resolve_clustered_pdgs(model)?,
483 })
484 }
485
486 fn resolve_clustered_pdgs(&self, model: Option<&Model>) -> Result<Vec<isize>> {
487 let clustered_pdgs = match &self.clustered_pdgs {
488 Some(clustered_pdgs) => clustered_pdgs.clone(),
489 None => Self::default_clustered_pdgs(model.ok_or_else(|| {
490 eyre!(
491 "Cannot resolve default clustered_pdgs without a model. Provide a model-aware event-processing runtime or set clustered_pdgs explicitly."
492 )
493 })?)?,
494 };
495 Ok(Self::normalize_clustered_pdgs(clustered_pdgs))
496 }
497
498 fn default_clustered_pdgs(model: &Model) -> Result<Vec<isize>> {
499 Ok(model
500 .particles
501 .iter()
502 .filter(|particle| particle.is_qcd_charged())
503 .map(|particle| {
504 particle
505 .has_zero_resolved_mass(model)
506 .map(|has_zero_mass| has_zero_mass.then_some(particle.pdg_code))
507 })
508 .collect::<Result<Vec<_>>>()?
509 .into_iter()
510 .flatten()
511 .collect())
512 }
513
514 fn normalize_clustered_pdgs(mut clustered_pdgs: Vec<isize>) -> Vec<isize> {
515 clustered_pdgs.sort_unstable();
516 clustered_pdgs.dedup();
517 clustered_pdgs
518 }
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
522#[allow(non_snake_case)]
523#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
524#[serde(deny_unknown_fields)]
525pub struct ParticleQuantitySettings {
526 #[serde(skip_serializing_if = "IsDefault::is_default")]
527 pub pdgs: Vec<isize>,
528 #[serde(flatten)]
529 pub computation: QuantityComputationSettings,
530}
531
532impl ParticleQuantitySettings {
533 fn try_normalized(&self) -> Result<Self> {
534 Ok(Self {
535 pdgs: self.pdgs.clone(),
536 computation: self
537 .computation
538 .try_normalized_for_source(QuantitySourceKind::Particle)?,
539 })
540 }
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
544#[allow(non_snake_case)]
545#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
546#[serde(deny_unknown_fields)]
547pub struct JetQuantitySettings {
548 #[serde(flatten)]
549 pub clustering: JetClusteringSettings,
550 #[serde(flatten)]
551 pub computation: QuantityComputationSettings,
552}
553
554impl JetQuantitySettings {
555 fn try_normalized(&self) -> Result<Self> {
556 Ok(Self {
557 clustering: self.clustering.clone(),
558 computation: self
559 .computation
560 .try_normalized_for_source(QuantitySourceKind::Jet)?,
561 })
562 }
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
566#[allow(non_snake_case)]
567#[serde(tag = "type", rename_all = "snake_case")]
568#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
569pub enum QuantitySettings {
570 Particle(ParticleQuantitySettings),
571 Jet(JetQuantitySettings),
572 AFB {},
573 Integral {},
574 GraphId {},
575 GraphGroupId {},
576 OrientationId {},
577 LmbChannelId {},
578}
579
580impl QuantitySettings {
581 pub fn try_normalized(&self) -> Result<Self> {
582 match self {
583 QuantitySettings::Particle(settings) => {
584 Ok(QuantitySettings::Particle(settings.try_normalized()?))
585 }
586 QuantitySettings::Jet(settings) => {
587 Ok(QuantitySettings::Jet(settings.try_normalized()?))
588 }
589 QuantitySettings::AFB {} => Ok(QuantitySettings::AFB {}),
590 QuantitySettings::Integral {} => Ok(QuantitySettings::Integral {}),
591 QuantitySettings::GraphId {} => Ok(QuantitySettings::GraphId {}),
592 QuantitySettings::GraphGroupId {} => Ok(QuantitySettings::GraphGroupId {}),
593 QuantitySettings::OrientationId {} => Ok(QuantitySettings::OrientationId {}),
594 QuantitySettings::LmbChannelId {} => Ok(QuantitySettings::LmbChannelId {}),
595 }
596 }
597
598 pub fn normalized(&self) -> Self {
599 self.try_normalized()
600 .expect("quantity settings should normalize successfully")
601 }
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
605#[allow(non_snake_case)]
606#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
607#[serde(deny_unknown_fields)]
608pub struct ValueRangeSelectorSettings {
609 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
610 #[schemars(description = "Optional lower bound. Use null to disable the lower cut.")]
611 pub min: Option<f64>,
612 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
613 #[schemars(description = "Optional upper bound. Use null to disable the upper cut.")]
614 pub max: Option<f64>,
615 #[serde(default, skip_serializing_if = "is_default_selector_reduction")]
616 pub reduction: SelectorReduction,
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
620#[allow(non_snake_case)]
621#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
622#[serde(deny_unknown_fields)]
623pub struct DiscreteRangeSelectorSettings {
624 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
625 #[schemars(description = "Optional inclusive lower bound. Use null to disable.")]
626 pub min: Option<isize>,
627 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
628 #[schemars(description = "Optional inclusive upper bound. Use null to disable.")]
629 pub max: Option<isize>,
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
633#[allow(non_snake_case)]
634#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
635#[serde(deny_unknown_fields)]
636pub struct CountRangeSelectorSettings {
637 pub min_count: usize,
638 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
639 pub max_count: Option<usize>,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
643#[allow(non_snake_case)]
644#[serde(tag = "selector", rename_all = "snake_case")]
645#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
646pub enum SelectorDefinitionSettings {
647 ValueRange(ValueRangeSelectorSettings),
648 DiscreteRange(DiscreteRangeSelectorSettings),
649 CountRange(CountRangeSelectorSettings),
650}
651
652#[derive(Debug, Clone, Encode, Decode, PartialEq)]
653#[allow(non_snake_case)]
654#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
655pub struct SelectorSettings {
656 pub quantity: String,
657 pub active: bool,
658 pub entry_selection: EntrySelection,
659 pub entry_index: usize,
660 pub selector: SelectorDefinitionSettings,
661}
662
663#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
664#[serde(rename_all = "snake_case")]
665enum ValueRangeSelectorSerdeTag {
666 ValueRange,
667}
668
669#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
670#[serde(rename_all = "snake_case")]
671enum DiscreteRangeSelectorSerdeTag {
672 DiscreteRange,
673}
674
675#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
676#[serde(rename_all = "snake_case")]
677enum CountRangeSelectorSerdeTag {
678 CountRange,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
682#[serde(deny_unknown_fields)]
683struct ValueRangeSelectorSettingsSerde {
684 quantity: String,
685 #[serde(default = "default_true", skip_serializing_if = "is_true")]
686 active: bool,
687 #[serde(default, skip_serializing_if = "is_default_entry_selection")]
688 entry_selection: EntrySelection,
689 #[serde(default, skip_serializing_if = "is_default_entry_index")]
690 entry_index: usize,
691 selector: ValueRangeSelectorSerdeTag,
692 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
693 min: Option<f64>,
694 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
695 max: Option<f64>,
696 #[serde(default, skip_serializing_if = "is_default_selector_reduction")]
697 reduction: SelectorReduction,
698}
699
700#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
701#[serde(deny_unknown_fields)]
702struct DiscreteRangeSelectorSettingsSerde {
703 quantity: String,
704 #[serde(default = "default_true", skip_serializing_if = "is_true")]
705 active: bool,
706 #[serde(default, skip_serializing_if = "is_default_entry_selection")]
707 entry_selection: EntrySelection,
708 #[serde(default, skip_serializing_if = "is_default_entry_index")]
709 entry_index: usize,
710 selector: DiscreteRangeSelectorSerdeTag,
711 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
712 min: Option<isize>,
713 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
714 max: Option<isize>,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
718#[serde(deny_unknown_fields)]
719struct CountRangeSelectorSettingsSerde {
720 quantity: String,
721 #[serde(default = "default_true", skip_serializing_if = "is_true")]
722 active: bool,
723 #[serde(default, skip_serializing_if = "is_default_entry_selection")]
724 entry_selection: EntrySelection,
725 #[serde(default, skip_serializing_if = "is_default_entry_index")]
726 entry_index: usize,
727 selector: CountRangeSelectorSerdeTag,
728 min_count: usize,
729 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
730 max_count: Option<usize>,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
734#[allow(clippy::enum_variant_names)]
735#[serde(untagged)]
736enum SelectorSettingsSerde {
737 ValueRange(ValueRangeSelectorSettingsSerde),
738 DiscreteRange(DiscreteRangeSelectorSettingsSerde),
739 CountRange(CountRangeSelectorSettingsSerde),
740}
741
742impl JsonSchema for SelectorSettings {
743 fn schema_name() -> std::borrow::Cow<'static, str> {
744 "SelectorSettings".into()
745 }
746
747 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
748 <SelectorSettingsSerde as JsonSchema>::json_schema(generator)
749 }
750}
751
752impl Serialize for SelectorSettings {
753 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
754 where
755 S: Serializer,
756 {
757 match &self.selector {
758 SelectorDefinitionSettings::ValueRange(selector) => ValueRangeSelectorSettingsSerde {
759 quantity: self.quantity.clone(),
760 active: self.active,
761 entry_selection: self.entry_selection,
762 entry_index: self.entry_index,
763 selector: ValueRangeSelectorSerdeTag::ValueRange,
764 min: selector.min,
765 max: selector.max,
766 reduction: selector.reduction,
767 }
768 .serialize(serializer),
769 SelectorDefinitionSettings::DiscreteRange(selector) => {
770 DiscreteRangeSelectorSettingsSerde {
771 quantity: self.quantity.clone(),
772 active: self.active,
773 entry_selection: self.entry_selection,
774 entry_index: self.entry_index,
775 selector: DiscreteRangeSelectorSerdeTag::DiscreteRange,
776 min: selector.min,
777 max: selector.max,
778 }
779 .serialize(serializer)
780 }
781 SelectorDefinitionSettings::CountRange(selector) => CountRangeSelectorSettingsSerde {
782 quantity: self.quantity.clone(),
783 active: self.active,
784 entry_selection: self.entry_selection,
785 entry_index: self.entry_index,
786 selector: CountRangeSelectorSerdeTag::CountRange,
787 min_count: selector.min_count,
788 max_count: selector.max_count,
789 }
790 .serialize(serializer),
791 }
792 }
793}
794
795impl<'de> Deserialize<'de> for SelectorSettings {
796 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
797 where
798 D: Deserializer<'de>,
799 {
800 let settings = SelectorSettingsSerde::deserialize(deserializer)?;
801 Ok(match settings {
802 SelectorSettingsSerde::ValueRange(settings) => SelectorSettings {
803 quantity: settings.quantity,
804 active: settings.active,
805 entry_selection: settings.entry_selection,
806 entry_index: settings.entry_index,
807 selector: SelectorDefinitionSettings::ValueRange(ValueRangeSelectorSettings {
808 min: settings.min,
809 max: settings.max,
810 reduction: settings.reduction,
811 }),
812 },
813 SelectorSettingsSerde::DiscreteRange(settings) => SelectorSettings {
814 quantity: settings.quantity,
815 active: settings.active,
816 entry_selection: settings.entry_selection,
817 entry_index: settings.entry_index,
818 selector: SelectorDefinitionSettings::DiscreteRange(
819 DiscreteRangeSelectorSettings {
820 min: settings.min,
821 max: settings.max,
822 },
823 ),
824 },
825 SelectorSettingsSerde::CountRange(settings) => SelectorSettings {
826 quantity: settings.quantity,
827 active: settings.active,
828 entry_selection: settings.entry_selection,
829 entry_index: settings.entry_index,
830 selector: SelectorDefinitionSettings::CountRange(CountRangeSelectorSettings {
831 min_count: settings.min_count,
832 max_count: settings.max_count,
833 }),
834 },
835 })
836 }
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
840#[allow(non_snake_case)]
841#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
842#[serde(default, deny_unknown_fields)]
843pub struct ContinuousHistogramSettings {
844 #[serde(skip_serializing_if = "is_float::<0>")]
845 pub x_min: f64,
846 #[serde(skip_serializing_if = "is_float::<0>")]
847 pub x_max: f64,
848 #[serde(skip_serializing_if = "is_usize::<0>")]
849 pub n_bins: usize,
850 #[serde(skip_serializing_if = "is_false")]
851 pub log_x_axis: bool,
852 #[serde(default = "default_true", skip_serializing_if = "is_true")]
853 pub log_y_axis: bool,
854 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
855 #[schemars(
856 description = "Optional histogram title. Defaults to the observable name when omitted."
857 )]
858 pub title: Option<String>,
859 #[serde(
860 default = "default_histogram_type_description",
861 skip_serializing_if = "is_default_histogram_type_description"
862 )]
863 #[schemars(description = "HwU TYPE description written after TYPE@ in the histogram header.")]
864 pub type_description: String,
865}
866
867impl Default for ContinuousHistogramSettings {
868 fn default() -> Self {
869 Self {
870 x_min: 0.0,
871 x_max: 0.0,
872 n_bins: 0,
873 log_x_axis: false,
874 log_y_axis: true,
875 title: None,
876 type_description: default_histogram_type_description(),
877 }
878 }
879}
880
881#[derive(
882 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
883)]
884#[serde(rename_all = "snake_case")]
885#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
886pub enum DiscreteBinOrdering {
887 #[default]
888 AscendingBinId,
889 ValueDescending,
890 AbsValueDescending,
891}
892
893impl DiscreteBinOrdering {
894 pub fn as_str(self) -> &'static str {
895 match self {
896 Self::AscendingBinId => "ascending_bin_id",
897 Self::ValueDescending => "value_descending",
898 Self::AbsValueDescending => "abs_value_descending",
899 }
900 }
901}
902
903#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
904#[allow(non_snake_case)]
905#[serde(tag = "type", rename_all = "snake_case")]
906pub enum DiscreteBinDomainSettings {
907 ExplicitRange { min: isize, max: isize },
908 SingleBin,
909 GraphIds,
910 GraphGroupIds,
911 OrientationIds,
912 LmbChannelIds,
913}
914
915#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
916#[allow(non_snake_case)]
917#[serde(tag = "type", rename_all = "snake_case")]
918pub enum DiscreteBinLabelsSettings {
919 Custom { labels: Vec<String> },
920 BinId,
921 GraphName,
922 GraphGroupMasterName,
923 Orientation,
924 LmbChannelEdgeIds,
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
928#[allow(non_snake_case)]
929#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
930#[serde(default, deny_unknown_fields)]
931pub struct DiscreteHistogramSettings {
932 pub domain: DiscreteBinDomainSettings,
933 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
934 pub ordering: DiscreteBinOrdering,
935 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
936 pub labels: Option<DiscreteBinLabelsSettings>,
937 #[serde(default = "default_true", skip_serializing_if = "is_true")]
938 pub log_y_axis: bool,
939 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
940 #[schemars(
941 description = "Optional histogram title. Defaults to the observable name when omitted."
942 )]
943 pub title: Option<String>,
944 #[serde(
945 default = "default_histogram_type_description",
946 skip_serializing_if = "is_default_histogram_type_description"
947 )]
948 #[schemars(description = "HwU TYPE description written after TYPE@ in the histogram header.")]
949 pub type_description: String,
950}
951
952impl Default for DiscreteHistogramSettings {
953 fn default() -> Self {
954 Self {
955 domain: DiscreteBinDomainSettings::SingleBin,
956 ordering: DiscreteBinOrdering::AscendingBinId,
957 labels: None,
958 log_y_axis: true,
959 title: None,
960 type_description: default_histogram_type_description(),
961 }
962 }
963}
964
965#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, JsonSchema)]
966#[allow(non_snake_case)]
967#[serde(tag = "kind", rename_all = "snake_case")]
968#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
969pub enum HistogramSettings {
970 Continuous(ContinuousHistogramSettings),
971 Discrete(DiscreteHistogramSettings),
972}
973
974impl Default for HistogramSettings {
975 fn default() -> Self {
976 Self::Continuous(ContinuousHistogramSettings::default())
977 }
978}
979
980#[derive(Debug, Clone, Encode, Decode, PartialEq)]
981#[allow(non_snake_case)]
982#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
983pub struct ObservableSettings {
984 pub quantity: String,
985 pub selections: Vec<String>,
986 pub entry_selection: EntrySelection,
987 pub entry_index: usize,
988 pub value_transform: ObservableValueTransform,
989 pub phase: ObservablePhase,
990 pub misbinning_max_normalized_distance: Option<f64>,
991 pub histogram: HistogramSettings,
992}
993
994#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
995#[serde(rename_all = "snake_case")]
996enum HistogramKindSerde {
997 #[default]
998 Continuous,
999 Discrete,
1000}
1001
1002#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1003#[serde(deny_unknown_fields)]
1004struct ObservableSettingsSerde {
1005 quantity: String,
1006 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1007 selections: Vec<String>,
1008 #[serde(default, skip_serializing_if = "is_default_entry_selection")]
1009 entry_selection: EntrySelection,
1010 #[serde(default, skip_serializing_if = "is_default_entry_index")]
1011 entry_index: usize,
1012 #[serde(default, skip_serializing_if = "is_default_value_transform")]
1013 value_transform: ObservableValueTransform,
1014 #[serde(default, skip_serializing_if = "is_default_observable_phase")]
1015 phase: ObservablePhase,
1016 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1017 misbinning_max_normalized_distance: Option<f64>,
1018 #[serde(default)]
1019 kind: Option<HistogramKindSerde>,
1020 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1021 x_min: Option<f64>,
1022 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1023 x_max: Option<f64>,
1024 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1025 n_bins: Option<usize>,
1026 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1027 log_x_axis: Option<bool>,
1028 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1029 log_y_axis: Option<bool>,
1030 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1031 title: Option<String>,
1032 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1033 type_description: Option<String>,
1034 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1035 domain: Option<DiscreteBinDomainSettings>,
1036 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1037 ordering: Option<DiscreteBinOrdering>,
1038 #[serde(default, skip_serializing_if = "IsDefault::is_default")]
1039 labels: Option<DiscreteBinLabelsSettings>,
1040}
1041
1042impl JsonSchema for ObservableSettings {
1043 fn schema_name() -> std::borrow::Cow<'static, str> {
1044 "ObservableSettings".into()
1045 }
1046
1047 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1048 <ObservableSettingsSerde as JsonSchema>::json_schema(generator)
1049 }
1050}
1051
1052impl Serialize for ObservableSettings {
1053 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1054 where
1055 S: Serializer,
1056 {
1057 let mut raw = ObservableSettingsSerde {
1058 quantity: self.quantity.clone(),
1059 selections: self.selections.clone(),
1060 entry_selection: self.entry_selection,
1061 entry_index: self.entry_index,
1062 value_transform: self.value_transform,
1063 phase: self.phase,
1064 misbinning_max_normalized_distance: self.misbinning_max_normalized_distance,
1065 kind: None,
1066 x_min: None,
1067 x_max: None,
1068 n_bins: None,
1069 log_x_axis: None,
1070 log_y_axis: None,
1071 title: None,
1072 type_description: None,
1073 domain: None,
1074 ordering: None,
1075 labels: None,
1076 };
1077 match &self.histogram {
1078 HistogramSettings::Continuous(histogram) => {
1079 raw.kind = Some(HistogramKindSerde::Continuous);
1080 raw.x_min = Some(histogram.x_min);
1081 raw.x_max = Some(histogram.x_max);
1082 raw.n_bins = Some(histogram.n_bins);
1083 raw.log_x_axis = Some(histogram.log_x_axis);
1084 raw.log_y_axis = Some(histogram.log_y_axis);
1085 raw.title = histogram.title.clone();
1086 raw.type_description = Some(histogram.type_description.clone());
1087 }
1088 HistogramSettings::Discrete(histogram) => {
1089 raw.kind = Some(HistogramKindSerde::Discrete);
1090 raw.log_y_axis = Some(histogram.log_y_axis);
1091 raw.title = histogram.title.clone();
1092 raw.type_description = Some(histogram.type_description.clone());
1093 raw.domain = Some(histogram.domain.clone());
1094 raw.ordering = Some(histogram.ordering);
1095 raw.labels = histogram.labels.clone();
1096 }
1097 }
1098 raw.serialize(serializer)
1099 }
1100}
1101
1102impl<'de> Deserialize<'de> for ObservableSettings {
1103 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1104 where
1105 D: Deserializer<'de>,
1106 {
1107 let raw = ObservableSettingsSerde::deserialize(deserializer)?;
1108 let inferred_kind = raw.kind.unwrap_or_else(|| {
1109 if raw.domain.is_some() || raw.ordering.is_some() || raw.labels.is_some() {
1110 HistogramKindSerde::Discrete
1111 } else {
1112 HistogramKindSerde::Continuous
1113 }
1114 });
1115 let histogram = match inferred_kind {
1116 HistogramKindSerde::Continuous => {
1117 if raw.domain.is_some() || raw.ordering.is_some() || raw.labels.is_some() {
1118 return Err(serde::de::Error::custom(
1119 "continuous observables cannot specify discrete histogram fields",
1120 ));
1121 }
1122 HistogramSettings::Continuous(ContinuousHistogramSettings {
1123 x_min: raw.x_min.unwrap_or_default(),
1124 x_max: raw.x_max.unwrap_or_default(),
1125 n_bins: raw.n_bins.unwrap_or_default(),
1126 log_x_axis: raw.log_x_axis.unwrap_or(false),
1127 log_y_axis: raw.log_y_axis.unwrap_or(true),
1128 title: raw.title,
1129 type_description: raw
1130 .type_description
1131 .unwrap_or_else(default_histogram_type_description),
1132 })
1133 }
1134 HistogramKindSerde::Discrete => {
1135 if raw.x_min.is_some()
1136 || raw.x_max.is_some()
1137 || raw.n_bins.is_some()
1138 || raw.log_x_axis.is_some()
1139 {
1140 return Err(serde::de::Error::custom(
1141 "discrete observables cannot specify continuous histogram fields",
1142 ));
1143 }
1144 HistogramSettings::Discrete(DiscreteHistogramSettings {
1145 domain: raw.domain.unwrap_or(DiscreteBinDomainSettings::SingleBin),
1146 ordering: raw.ordering.unwrap_or_default(),
1147 labels: raw.labels,
1148 log_y_axis: raw.log_y_axis.unwrap_or(true),
1149 title: raw.title,
1150 type_description: raw
1151 .type_description
1152 .unwrap_or_else(default_histogram_type_description),
1153 })
1154 }
1155 };
1156 Ok(ObservableSettings {
1157 quantity: raw.quantity,
1158 selections: raw.selections,
1159 entry_selection: raw.entry_selection,
1160 entry_index: raw.entry_index,
1161 value_transform: raw.value_transform,
1162 phase: raw.phase,
1163 misbinning_max_normalized_distance: raw.misbinning_max_normalized_distance,
1164 histogram,
1165 })
1166 }
1167}
1168
1169fn default_true() -> bool {
1170 true
1171}
1172
1173fn default_histogram_type_description() -> String {
1174 "AL".to_string()
1175}
1176
1177fn is_default_histogram_type_description(value: &String) -> bool {
1178 show_defaults_helper(value == &default_histogram_type_description())
1179}
1180
1181fn is_default_entry_selection(selection: &EntrySelection) -> bool {
1182 show_defaults_helper(selection == &EntrySelection::All)
1183}
1184
1185fn is_default_entry_index(index: &usize) -> bool {
1186 show_defaults_helper(*index == 0)
1187}
1188
1189fn is_default_selector_reduction(reduction: &SelectorReduction) -> bool {
1190 show_defaults_helper(reduction == &SelectorReduction::AnyInRange)
1191}
1192
1193fn is_default_value_transform(transform: &ObservableValueTransform) -> bool {
1194 show_defaults_helper(transform == &ObservableValueTransform::Identity)
1195}
1196
1197fn is_default_observable_phase(phase: &ObservablePhase) -> bool {
1198 show_defaults_helper(phase == &ObservablePhase::Real)
1199}
1200
1201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1202enum ObservableCoordinateKind {
1203 Continuous,
1204 Discrete,
1205}
1206
1207#[derive(Debug, Clone)]
1208pub enum ObservableCoordinate<T: FloatLike> {
1209 Continuous(F<T>),
1210 Discrete(isize),
1211}
1212
1213#[derive(Debug, Clone)]
1214pub struct ObservableEntry<T: FloatLike> {
1215 pub coordinate: ObservableCoordinate<T>,
1216 pub weight_modifier: Complex<F<T>>,
1217}
1218
1219impl<T: FloatLike> ObservableEntry<T> {
1220 fn unit_continuous(value: F<T>) -> Self {
1221 Self {
1222 weight_modifier: unit_complex(&value),
1223 coordinate: ObservableCoordinate::Continuous(value),
1224 }
1225 }
1226
1227 fn unit_discrete(bin_id: isize, reference: &F<T>) -> Self {
1228 Self {
1229 weight_modifier: unit_complex(reference),
1230 coordinate: ObservableCoordinate::Discrete(bin_id),
1231 }
1232 }
1233}
1234
1235type ObservableEntries<T> = SmallVec<[ObservableEntry<T>; 8]>;
1236
1237type ClusteringHandle = usize;
1238
1239#[derive(Debug, Clone)]
1240struct CompiledClustering {
1241 settings: ResolvedJetClusteringSettings,
1242 clustering: JetClustering,
1243}
1244
1245#[derive(Debug, Clone, Default)]
1246pub(crate) struct CompiledClusteringRegistry {
1247 entries: Vec<CompiledClustering>,
1248}
1249
1250impl CompiledClusteringRegistry {
1251 fn register(
1252 &mut self,
1253 settings: &JetClusteringSettings,
1254 model: Option<&Model>,
1255 ) -> Result<ClusteringHandle> {
1256 let resolved_settings = settings.resolve(model)?;
1257 if let Some(index) = self
1258 .entries
1259 .iter()
1260 .position(|entry| entry.settings == resolved_settings)
1261 {
1262 Ok(index)
1263 } else {
1264 let index = self.entries.len();
1265 self.entries.push(CompiledClustering {
1266 clustering: JetClustering::new(
1267 resolved_settings.algorithm,
1268 resolved_settings.d_r,
1269 resolved_settings.min_jpt,
1270 resolved_settings.clustered_pdgs.clone(),
1271 ),
1272 settings: resolved_settings,
1273 });
1274 Ok(index)
1275 }
1276 }
1277
1278 fn get(&self, handle: ClusteringHandle) -> &JetClustering {
1279 &self.entries[handle].clustering
1280 }
1281
1282 fn len(&self) -> usize {
1283 self.entries.len()
1284 }
1285}
1286
1287#[derive(Debug, Clone)]
1288#[allow(clippy::enum_variant_names)]
1289enum SelectorCriterion {
1290 ValueRange {
1291 selection: EntrySelection,
1292 entry_index: usize,
1293 reduction: SelectorReduction,
1294 min: Option<f64>,
1295 max: Option<f64>,
1296 },
1297 DiscreteRange {
1298 selection: EntrySelection,
1299 entry_index: usize,
1300 min: Option<isize>,
1301 max: Option<isize>,
1302 },
1303 CountRange {
1304 selection: EntrySelection,
1305 entry_index: usize,
1306 min: usize,
1307 max: Option<usize>,
1308 },
1309}
1310
1311impl SelectorCriterion {
1312 fn passes<T: FloatLike>(&self, entries: &[ObservableEntry<T>]) -> bool {
1313 match self {
1314 SelectorCriterion::ValueRange {
1315 selection,
1316 entry_index,
1317 reduction,
1318 min,
1319 max,
1320 } => {
1321 let selected = apply_entry_selection(entries, *selection, *entry_index);
1322 if selected.is_empty() {
1323 return false;
1324 }
1325 match reduction {
1326 SelectorReduction::AnyInRange => selected.iter().any(|entry| {
1327 matches!(
1328 &entry.coordinate,
1329 ObservableCoordinate::Continuous(value)
1330 if value_in_range(value.into_ff64().0, *min, *max)
1331 )
1332 }),
1333 SelectorReduction::AllInRange => selected.iter().all(|entry| {
1334 matches!(
1335 &entry.coordinate,
1336 ObservableCoordinate::Continuous(value)
1337 if value_in_range(value.into_ff64().0, *min, *max)
1338 )
1339 }),
1340 }
1341 }
1342 SelectorCriterion::DiscreteRange {
1343 selection,
1344 entry_index,
1345 min,
1346 max,
1347 } => {
1348 let selected = apply_entry_selection(entries, *selection, *entry_index);
1349 if selected.is_empty() {
1350 return false;
1351 }
1352 selected.iter().all(|entry| {
1353 matches!(
1354 &entry.coordinate,
1355 ObservableCoordinate::Discrete(value)
1356 if discrete_value_in_range(*value, *min, *max)
1357 )
1358 })
1359 }
1360 SelectorCriterion::CountRange {
1361 selection,
1362 entry_index,
1363 min,
1364 max,
1365 } => {
1366 let count = apply_entry_selection(entries, *selection, *entry_index).len();
1367 count >= *min && max.is_none_or(|max| count <= max)
1368 }
1369 }
1370 }
1371}
1372
1373#[derive(Debug, Clone, Default)]
1374struct IntegralDefinition;
1375
1376impl IntegralDefinition {
1377 fn process_event<T: FloatLike>(&self, event: &GenericEvent<T>) -> ObservableEntries<T> {
1378 let reference = event.weight.re.clone();
1379 smallvec![ObservableEntry::unit_discrete(0, &reference)]
1380 }
1381}
1382
1383#[derive(Debug, Clone)]
1384enum ResolvedQuantityComputation {
1385 Scalar {
1386 quantity: FilterQuantity,
1387 ordering: QuantityOrdering,
1388 order: QuantityOrder,
1389 },
1390 Count,
1391 Pair {
1392 quantity: PairQuantity,
1393 pairing: PairingMode,
1394 order: QuantityOrder,
1395 },
1396}
1397
1398impl ResolvedQuantityComputation {
1399 fn coordinate_kind(&self) -> ObservableCoordinateKind {
1400 match self {
1401 ResolvedQuantityComputation::Scalar { .. }
1402 | ResolvedQuantityComputation::Pair { .. } => ObservableCoordinateKind::Continuous,
1403 ResolvedQuantityComputation::Count => ObservableCoordinateKind::Discrete,
1404 }
1405 }
1406
1407 fn process_momenta<T: FloatLike>(
1408 &self,
1409 momenta: &[&FourMomentum<F<T>>],
1410 incoming_beam: Option<&FourMomentum<F<T>>>,
1411 event: &GenericEvent<T>,
1412 ) -> ObservableEntries<T> {
1413 match self {
1414 ResolvedQuantityComputation::Scalar {
1415 quantity,
1416 ordering,
1417 order,
1418 } => {
1419 let mut entries = momenta
1420 .iter()
1421 .filter_map(|momentum| {
1422 quantity
1423 .project_momentum(momentum, incoming_beam)
1424 .map(|value| SortableObservableEntry {
1425 sort_key: ordering.scalar_sort_key(momentum, &value),
1426 entry: ObservableEntry::unit_continuous(value),
1427 })
1428 })
1429 .collect::<SmallVec<[SortableObservableEntry<T>; 8]>>();
1430 sort_observable_entries(&mut entries, *order);
1431 entries.into_iter().map(|entry| entry.entry).collect()
1432 }
1433 ResolvedQuantityComputation::Count => {
1434 let reference = event_representative_one(event);
1435 smallvec![ObservableEntry::unit_discrete(
1436 momenta.len() as isize,
1437 &reference,
1438 )]
1439 }
1440 ResolvedQuantityComputation::Pair {
1441 quantity,
1442 pairing,
1443 order,
1444 } => match pairing {
1445 PairingMode::AllPairs => {
1446 let mut entries = SmallVec::<[SortableObservableEntry<T>; 8]>::new();
1447 for left_index in 0..momenta.len() {
1448 for right_index in left_index + 1..momenta.len() {
1449 let value =
1450 quantity.project_momenta(momenta[left_index], momenta[right_index]);
1451 entries.push(SortableObservableEntry {
1452 sort_key: value.clone(),
1453 entry: ObservableEntry::unit_continuous(value),
1454 });
1455 }
1456 }
1457 sort_observable_entries(&mut entries, *order);
1458 entries.into_iter().map(|entry| entry.entry).collect()
1459 }
1460 },
1461 }
1462 }
1463
1464 fn supports_misbinning_mitigation(&self) -> bool {
1465 !matches!(self, ResolvedQuantityComputation::Count)
1466 }
1467}
1468
1469#[derive(Debug, Clone)]
1470enum QuantitySourceDefinition {
1471 Particle { pdgs: Vec<isize> },
1472 Jet { clustering_handle: ClusteringHandle },
1473}
1474
1475impl QuantitySourceDefinition {
1476 fn required_clustering_handle(&self) -> Option<ClusteringHandle> {
1477 match self {
1478 QuantitySourceDefinition::Particle { .. } => None,
1479 QuantitySourceDefinition::Jet { clustering_handle } => Some(*clustering_handle),
1480 }
1481 }
1482
1483 fn collect_momenta<'a, T: FloatLike>(
1484 &self,
1485 event: &'a GenericEvent<T>,
1486 ) -> SmallVec<[&'a FourMomentum<F<T>>; 8]> {
1487 match self {
1488 QuantitySourceDefinition::Particle { pdgs } => event
1489 .cut_info
1490 .particle_pdgs
1491 .1
1492 .iter()
1493 .copied()
1494 .zip(event.kinematic_configuration.1.iter())
1495 .filter(|(pdg, _)| pdgs.contains(pdg))
1496 .map(|(_, momentum)| momentum)
1497 .collect(),
1498 QuantitySourceDefinition::Jet { clustering_handle } => event
1499 .cached_clustering(*clustering_handle)
1500 .expect("jet quantity requires precomputed clustering")
1501 .jets
1502 .iter()
1503 .map(|jet| &jet.momentum)
1504 .collect(),
1505 }
1506 }
1507}
1508
1509#[derive(Debug, Clone)]
1510struct ObjectQuantityDefinition {
1511 source: QuantitySourceDefinition,
1512 computation: ResolvedQuantityComputation,
1513}
1514
1515impl ObjectQuantityDefinition {
1516 fn particle(settings: &ParticleQuantitySettings) -> Result<Self> {
1517 Ok(Self {
1518 source: QuantitySourceDefinition::Particle {
1519 pdgs: settings.pdgs.clone(),
1520 },
1521 computation: settings
1522 .computation
1523 .resolve_for_source(QuantitySourceKind::Particle)?,
1524 })
1525 }
1526
1527 fn jet(
1528 settings: &JetQuantitySettings,
1529 clustering_registry: &mut CompiledClusteringRegistry,
1530 model: Option<&Model>,
1531 ) -> Result<Self> {
1532 Ok(Self {
1533 source: QuantitySourceDefinition::Jet {
1534 clustering_handle: clustering_registry.register(&settings.clustering, model)?,
1535 },
1536 computation: settings
1537 .computation
1538 .resolve_for_source(QuantitySourceKind::Jet)?,
1539 })
1540 }
1541
1542 fn process_event<T: FloatLike>(&self, event: &GenericEvent<T>) -> ObservableEntries<T> {
1543 let incoming_beam = event.kinematic_configuration.0.get(1);
1544 let momenta = self.source.collect_momenta(event);
1545 self.computation
1546 .process_momenta(&momenta, incoming_beam, event)
1547 }
1548
1549 fn required_clustering_handle(&self) -> Option<ClusteringHandle> {
1550 self.source.required_clustering_handle()
1551 }
1552
1553 fn supports_misbinning_mitigation(&self) -> bool {
1554 self.computation.supports_misbinning_mitigation()
1555 }
1556
1557 fn coordinate_kind(&self) -> ObservableCoordinateKind {
1558 self.computation.coordinate_kind()
1559 }
1560}
1561
1562#[derive(Debug, Clone, Copy)]
1563#[allow(clippy::enum_variant_names)]
1564enum MetadataQuantityKind {
1565 GraphId,
1566 GraphGroupId,
1567 OrientationId,
1568 LmbChannelId,
1569}
1570
1571#[derive(Debug, Clone, Copy)]
1572struct MetadataQuantityDefinition {
1573 kind: MetadataQuantityKind,
1574}
1575
1576impl MetadataQuantityDefinition {
1577 fn process_event<T: FloatLike>(&self, event: &GenericEvent<T>) -> ObservableEntries<T> {
1578 let value = match self.kind {
1579 MetadataQuantityKind::GraphId => Some(event.cut_info.graph_id as isize),
1580 MetadataQuantityKind::GraphGroupId => {
1581 event.cut_info.graph_group_id.map(|id| id as isize)
1582 }
1583 MetadataQuantityKind::OrientationId => {
1584 event.cut_info.orientation_id.map(|id| id as isize)
1585 }
1586 MetadataQuantityKind::LmbChannelId => {
1587 event.cut_info.lmb_channel_id.map(|id| id as isize)
1588 }
1589 };
1590 let Some(bin_id) = value else {
1591 return ObservableEntries::new();
1592 };
1593 let reference = event_representative_one(event);
1594 smallvec![ObservableEntry::unit_discrete(bin_id, &reference)]
1595 }
1596}
1597
1598#[derive(Debug, Clone, Default)]
1599struct ForwardBackwardDefinition;
1600
1601impl ForwardBackwardDefinition {
1602 fn process_event<T: FloatLike>(&self, event: &GenericEvent<T>) -> ObservableEntries<T> {
1603 let Some(beam) = event.kinematic_configuration.0.get(1).cloned() else {
1604 return ObservableEntries::new();
1605 };
1606
1607 let Some(outgoing) = event
1608 .cut_info
1609 .particle_pdgs
1610 .1
1611 .iter()
1612 .copied()
1613 .zip(event.kinematic_configuration.1.iter().cloned())
1614 .find(|(pdg, _)| (1..=6).contains(pdg))
1615 .map(|(_, momentum)| momentum)
1616 else {
1617 return ObservableEntries::new();
1618 };
1619
1620 let beam_spatial = beam.spatial.clone();
1621 let outgoing_spatial = outgoing.spatial.clone();
1622 smallvec![ObservableEntry::unit_continuous(
1623 beam_spatial.clone() * outgoing_spatial.clone()
1624 / (beam_spatial.norm() * outgoing_spatial.norm()),
1625 )]
1626 }
1627}
1628
1629#[derive(Debug, Clone)]
1630enum ObservableDefinition {
1631 Integral(IntegralDefinition),
1632 ObjectQuantity(ObjectQuantityDefinition),
1633 Metadata(MetadataQuantityDefinition),
1634 ForwardBackward(ForwardBackwardDefinition),
1635}
1636
1637impl ObservableDefinition {
1638 fn from_settings(
1639 settings: &QuantitySettings,
1640 clustering_registry: &mut CompiledClusteringRegistry,
1641 model: Option<&Model>,
1642 ) -> Result<Self> {
1643 Ok(match settings {
1644 QuantitySettings::Particle(settings) => {
1645 ObservableDefinition::ObjectQuantity(ObjectQuantityDefinition::particle(settings)?)
1646 }
1647 QuantitySettings::Jet(settings) => ObservableDefinition::ObjectQuantity(
1648 ObjectQuantityDefinition::jet(settings, clustering_registry, model)?,
1649 ),
1650 QuantitySettings::AFB {} => {
1651 ObservableDefinition::ForwardBackward(ForwardBackwardDefinition)
1652 }
1653 QuantitySettings::Integral {} => ObservableDefinition::Integral(IntegralDefinition),
1654 QuantitySettings::GraphId {} => {
1655 ObservableDefinition::Metadata(MetadataQuantityDefinition {
1656 kind: MetadataQuantityKind::GraphId,
1657 })
1658 }
1659 QuantitySettings::GraphGroupId {} => {
1660 ObservableDefinition::Metadata(MetadataQuantityDefinition {
1661 kind: MetadataQuantityKind::GraphGroupId,
1662 })
1663 }
1664 QuantitySettings::OrientationId {} => {
1665 ObservableDefinition::Metadata(MetadataQuantityDefinition {
1666 kind: MetadataQuantityKind::OrientationId,
1667 })
1668 }
1669 QuantitySettings::LmbChannelId {} => {
1670 ObservableDefinition::Metadata(MetadataQuantityDefinition {
1671 kind: MetadataQuantityKind::LmbChannelId,
1672 })
1673 }
1674 })
1675 }
1676
1677 fn process_event<T: FloatLike>(&self, event: &GenericEvent<T>) -> ObservableEntries<T> {
1678 match self {
1679 ObservableDefinition::Integral(definition) => definition.process_event(event),
1680 ObservableDefinition::ObjectQuantity(definition) => definition.process_event(event),
1681 ObservableDefinition::Metadata(definition) => definition.process_event(event),
1682 ObservableDefinition::ForwardBackward(definition) => definition.process_event(event),
1683 }
1684 }
1685
1686 fn required_clustering_handle(&self) -> Option<ClusteringHandle> {
1687 match self {
1688 ObservableDefinition::ObjectQuantity(definition) => {
1689 definition.required_clustering_handle()
1690 }
1691 _ => None,
1692 }
1693 }
1694
1695 fn supports_misbinning_mitigation(&self) -> bool {
1696 match self {
1697 ObservableDefinition::ObjectQuantity(definition) => {
1698 definition.supports_misbinning_mitigation()
1699 }
1700 ObservableDefinition::Integral(_) | ObservableDefinition::Metadata(_) => false,
1701 ObservableDefinition::ForwardBackward(_) => true,
1702 }
1703 }
1704
1705 fn coordinate_kind(&self) -> ObservableCoordinateKind {
1706 match self {
1707 ObservableDefinition::Integral(_) | ObservableDefinition::Metadata(_) => {
1708 ObservableCoordinateKind::Discrete
1709 }
1710 ObservableDefinition::ObjectQuantity(definition) => definition.coordinate_kind(),
1711 ObservableDefinition::ForwardBackward(_) => ObservableCoordinateKind::Continuous,
1712 }
1713 }
1714}
1715
1716fn event_representative_one<T: FloatLike>(event: &GenericEvent<T>) -> F<T> {
1717 event
1718 .kinematic_configuration
1719 .0
1720 .first()
1721 .map(|momentum| momentum.temporal.value.one())
1722 .or_else(|| {
1723 event
1724 .kinematic_configuration
1725 .1
1726 .first()
1727 .map(|momentum| momentum.temporal.value.one())
1728 })
1729 .unwrap_or_else(|| event.weight.re.one())
1730}
1731
1732#[derive(Debug, Clone)]
1733struct SortableObservableEntry<T: FloatLike> {
1734 sort_key: F<T>,
1735 entry: ObservableEntry<T>,
1736}
1737
1738fn sort_observable_entries<T: FloatLike>(
1739 entries: &mut SmallVec<[SortableObservableEntry<T>; 8]>,
1740 order: QuantityOrder,
1741) {
1742 entries.sort_by(|lhs, rhs| compare_sort_keys(&lhs.sort_key, &rhs.sort_key, order));
1743}
1744
1745fn compare_sort_keys<T: FloatLike>(lhs: &F<T>, rhs: &F<T>, order: QuantityOrder) -> Ordering {
1746 let base = lhs.partial_cmp(rhs).unwrap_or(Ordering::Equal);
1747 match order {
1748 QuantityOrder::Ascending => base,
1749 QuantityOrder::Descending => base.reverse(),
1750 }
1751}
1752
1753fn ensure_event_clustering<T: FloatLike>(
1754 event: &mut GenericEvent<T>,
1755 clustering_handle: ClusteringHandle,
1756 clustering_registry: &CompiledClusteringRegistry,
1757) {
1758 event.ensure_clustering_slots(clustering_registry.len());
1759 if event.cached_clustering(clustering_handle).is_some() {
1760 return;
1761 }
1762
1763 let clustering = clustering_registry.get(clustering_handle).clone();
1764 let clustering_result = clustering.process_event(&*event);
1765 event.derived_observable_data.clustered_jets[clustering_handle] = Some(clustering_result);
1766}
1767
1768#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1769pub struct ObservableBinAccumulator {
1770 pub sum_weights: f64,
1771 new_sum_weights: f64,
1772 pub sum_weights_squared: f64,
1773 new_sum_weights_squared: f64,
1774 pub entry_count: usize,
1775 new_entry_count: usize,
1776 pub mitigated_fill_count: usize,
1777 new_mitigated_fill_count: usize,
1778}
1779
1780impl ObservableBinAccumulator {
1781 fn add_sample(&mut self, sample: f64, entry_count: usize, mitigated_fill_count: usize) {
1782 self.new_sum_weights += sample;
1783 self.new_sum_weights_squared += sample * sample;
1784 self.new_entry_count += entry_count;
1785 self.new_mitigated_fill_count += mitigated_fill_count;
1786 }
1787
1788 fn merge_samples(&mut self, other: &mut ObservableBinAccumulator) {
1789 self.new_sum_weights += other.new_sum_weights;
1790 self.new_sum_weights_squared += other.new_sum_weights_squared;
1791 self.new_entry_count += other.new_entry_count;
1792 self.new_mitigated_fill_count += other.new_mitigated_fill_count;
1793
1794 other.new_sum_weights = 0.0;
1795 other.new_sum_weights_squared = 0.0;
1796 other.new_entry_count = 0;
1797 other.new_mitigated_fill_count = 0;
1798 }
1799
1800 fn total_sum_weights(&self) -> f64 {
1801 self.sum_weights + self.new_sum_weights
1802 }
1803
1804 fn total_sum_weights_squared(&self) -> f64 {
1805 self.sum_weights_squared + self.new_sum_weights_squared
1806 }
1807
1808 fn total_entry_count(&self) -> usize {
1809 self.entry_count + self.new_entry_count
1810 }
1811
1812 fn total_mitigated_fill_count(&self) -> usize {
1813 self.mitigated_fill_count + self.new_mitigated_fill_count
1814 }
1815
1816 fn average(&self, sample_count: usize) -> f64 {
1817 if sample_count == 0 {
1818 0.0
1819 } else {
1820 self.total_sum_weights() / sample_count as f64
1821 }
1822 }
1823
1824 fn error(&self, sample_count: usize) -> f64 {
1825 if sample_count <= 1 {
1826 return 0.0;
1827 }
1828
1829 let n = sample_count as f64;
1830 let sum = self.total_sum_weights();
1831 let sum_sq = self.total_sum_weights_squared();
1832 let variance_numerator = sum_sq - (sum * sum) / n;
1833 if !variance_numerator.is_finite() || variance_numerator <= 0.0 {
1834 0.0
1835 } else {
1836 (variance_numerator / (n * (n - 1.0))).sqrt()
1837 }
1838 }
1839
1840 fn update_iter(&mut self) {
1841 self.sum_weights += self.new_sum_weights;
1842 self.sum_weights_squared += self.new_sum_weights_squared;
1843 self.entry_count += self.new_entry_count;
1844 self.mitigated_fill_count += self.new_mitigated_fill_count;
1845
1846 self.new_sum_weights = 0.0;
1847 self.new_sum_weights_squared = 0.0;
1848 self.new_entry_count = 0;
1849 self.new_mitigated_fill_count = 0;
1850 }
1851
1852 fn rescale(&mut self, factor: f64) {
1853 self.sum_weights *= factor;
1854 self.new_sum_weights *= factor;
1855 self.sum_weights_squared *= factor * factor;
1856 self.new_sum_weights_squared *= factor * factor;
1857 }
1858
1859 fn from_snapshot(snapshot: &HistogramBinSnapshot) -> Self {
1860 Self {
1861 sum_weights: snapshot.sum_weights,
1862 new_sum_weights: 0.0,
1863 sum_weights_squared: snapshot.sum_weights_squared,
1864 new_sum_weights_squared: 0.0,
1865 entry_count: snapshot.entry_count,
1866 new_entry_count: 0,
1867 mitigated_fill_count: snapshot.mitigated_fill_count,
1868 new_mitigated_fill_count: 0,
1869 }
1870 }
1871}
1872
1873#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1874pub struct ObservableHistogramStatistics {
1875 pub in_range_entry_count: usize,
1876 pub nan_value_count: usize,
1877 pub mitigated_pair_count: usize,
1878 new_in_range_entry_count: usize,
1879 new_nan_value_count: usize,
1880 new_mitigated_pair_count: usize,
1881}
1882
1883impl ObservableHistogramStatistics {
1884 fn register_in_range_entry(&mut self) {
1885 self.new_in_range_entry_count += 1;
1886 }
1887
1888 fn register_nan(&mut self) {
1889 self.new_nan_value_count += 1;
1890 }
1891
1892 fn register_mitigated_pair(&mut self) {
1893 self.new_mitigated_pair_count += 1;
1894 }
1895
1896 fn merge_samples(&mut self, other: &mut ObservableHistogramStatistics) {
1897 self.new_in_range_entry_count += other.new_in_range_entry_count;
1898 self.new_nan_value_count += other.new_nan_value_count;
1899 self.new_mitigated_pair_count += other.new_mitigated_pair_count;
1900
1901 other.new_in_range_entry_count = 0;
1902 other.new_nan_value_count = 0;
1903 other.new_mitigated_pair_count = 0;
1904 }
1905
1906 fn update_iter(&mut self) {
1907 self.in_range_entry_count += self.new_in_range_entry_count;
1908 self.nan_value_count += self.new_nan_value_count;
1909 self.mitigated_pair_count += self.new_mitigated_pair_count;
1910
1911 self.new_in_range_entry_count = 0;
1912 self.new_nan_value_count = 0;
1913 self.new_mitigated_pair_count = 0;
1914 }
1915}
1916
1917#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1918pub struct HistogramBinSnapshot {
1919 pub x_min: Option<f64>,
1920 pub x_max: Option<f64>,
1921 pub bin_id: Option<isize>,
1922 pub label: Option<String>,
1923 pub entry_count: usize,
1924 pub sum_weights: f64,
1925 pub sum_weights_squared: f64,
1926 pub mitigated_fill_count: usize,
1927}
1928
1929impl HistogramBinSnapshot {
1930 pub fn average(&self, sample_count: usize) -> f64 {
1931 if sample_count == 0 {
1932 0.0
1933 } else {
1934 self.sum_weights / sample_count as f64
1935 }
1936 }
1937
1938 pub fn error(&self, sample_count: usize) -> f64 {
1939 if sample_count <= 1 {
1940 return 0.0;
1941 }
1942
1943 let n = sample_count as f64;
1944 let variance_numerator =
1945 self.sum_weights_squared - (self.sum_weights * self.sum_weights) / n;
1946 if !variance_numerator.is_finite() || variance_numerator <= 0.0 {
1947 0.0
1948 } else {
1949 (variance_numerator / (n * (n - 1.0))).sqrt()
1950 }
1951 }
1952
1953 fn merge_in_place(&mut self, other: &HistogramBinSnapshot) {
1954 self.entry_count += other.entry_count;
1955 self.sum_weights += other.sum_weights;
1956 self.sum_weights_squared += other.sum_weights_squared;
1957 self.mitigated_fill_count += other.mitigated_fill_count;
1958 }
1959
1960 fn rescale_in_place(&mut self, factor: f64) {
1961 self.sum_weights *= factor;
1962 self.sum_weights_squared *= factor * factor;
1963 }
1964}
1965
1966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1967pub struct HistogramStatisticsSnapshot {
1968 pub in_range_entry_count: usize,
1969 pub nan_value_count: usize,
1970 pub mitigated_pair_count: usize,
1971}
1972
1973#[derive(
1974 Debug, Clone, Copy, Default, Serialize, Deserialize, Encode, Decode, PartialEq, Eq, JsonSchema,
1975)]
1976#[serde(rename_all = "snake_case")]
1977pub enum HistogramSnapshotKind {
1978 #[default]
1979 Continuous,
1980 Discrete,
1981}
1982
1983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1984pub struct HistogramSnapshot {
1985 pub kind: HistogramSnapshotKind,
1987 pub title: String,
1989 pub type_description: String,
1991 pub phase: ObservablePhase,
1993 pub value_transform: ObservableValueTransform,
1995 pub supports_misbinning_mitigation: bool,
1997 pub x_min: Option<f64>,
1999 pub x_max: Option<f64>,
2001 pub sample_count: usize,
2003 pub log_x_axis: bool,
2005 pub log_y_axis: bool,
2007 pub discrete_min_bin_id: Option<isize>,
2009 pub discrete_ordering: Option<DiscreteBinOrdering>,
2011 pub bins: Vec<HistogramBinSnapshot>,
2013 pub underflow_bin: HistogramBinSnapshot,
2015 pub overflow_bin: HistogramBinSnapshot,
2017 pub statistics: HistogramStatisticsSnapshot,
2019}
2020
2021impl HistogramSnapshot {
2022 pub fn to_json_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
2023 let writer = BufWriter::new(File::create(path.as_ref())?);
2024 serde_json::to_writer_pretty(writer, self)?;
2025 Ok(())
2026 }
2027
2028 pub fn from_json_file<P: AsRef<Path>>(path: P) -> Result<Self> {
2029 let reader = BufReader::new(File::open(path.as_ref())?);
2030 Ok(serde_json::from_reader(reader)?)
2031 }
2032
2033 fn write_hwu_block<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
2034 let x_axis_mode = if self.log_x_axis { "LOG" } else { "LIN" };
2035 let y_axis_mode = if self.log_y_axis { "LOG" } else { "LIN" };
2036
2037 writeln!(writer, "##& xmin & xmax & central value & dy &\n")?;
2038 writeln!(
2039 writer,
2040 "<histogram> {} \"{} |X_AXIS@{} |Y_AXIS@{} |TYPE@{}\"",
2041 self.bins.len(),
2042 self.title,
2043 x_axis_mode,
2044 y_axis_mode,
2045 self.type_description,
2046 )?;
2047
2048 for bin in &self.bins {
2049 let (x_min, x_max) = match self.kind {
2050 HistogramSnapshotKind::Continuous => (
2051 bin.x_min.unwrap_or(self.x_min.unwrap_or_default()),
2052 bin.x_max.unwrap_or(self.x_max.unwrap_or_default()),
2053 ),
2054 HistogramSnapshotKind::Discrete => {
2055 let bin_id = bin.bin_id.expect("discrete histogram bin missing bin id");
2056 (bin_id as f64, (bin_id + 1) as f64)
2057 }
2058 };
2059 writeln!(
2060 writer,
2061 " {:.8e} {:.8e} {:.8e} {:.8e}",
2062 x_min,
2063 x_max,
2064 bin.average(self.sample_count),
2065 bin.error(self.sample_count),
2066 )?;
2067 }
2068
2069 writeln!(writer, "<\\histogram>")?;
2070 Ok(())
2071 }
2072
2073 pub fn merge(&self, other: &HistogramSnapshot) -> Result<Self> {
2074 let mut merged = self.clone();
2075 merged.merge_in_place(other)?;
2076 Ok(merged)
2077 }
2078
2079 pub fn merged(&self, other: &HistogramSnapshot) -> Result<Self> {
2080 self.merge(other)
2081 }
2082
2083 pub fn merge_in_place(&mut self, other: &HistogramSnapshot) -> Result<()> {
2084 if self.title != other.title
2085 || self.type_description != other.type_description
2086 || self.phase != other.phase
2087 || self.value_transform != other.value_transform
2088 || self.supports_misbinning_mitigation != other.supports_misbinning_mitigation
2089 || self.kind != other.kind
2090 || self.x_min != other.x_min
2091 || self.x_max != other.x_max
2092 || self.log_x_axis != other.log_x_axis
2093 || self.log_y_axis != other.log_y_axis
2094 || self.discrete_min_bin_id != other.discrete_min_bin_id
2095 || self.bins.len() != other.bins.len()
2096 {
2097 return Err(eyre!(
2098 "Cannot merge incompatible histogram snapshots '{}'",
2099 self.title
2100 ));
2101 }
2102
2103 self.sample_count += other.sample_count;
2104 if self.kind == HistogramSnapshotKind::Continuous {
2105 for (bin, other_bin) in self.bins.iter_mut().zip(other.bins.iter()) {
2106 bin.merge_in_place(other_bin);
2107 }
2108 } else {
2109 let other_by_id = other
2110 .bins
2111 .iter()
2112 .filter_map(|bin| bin.bin_id.map(|bin_id| (bin_id, bin)))
2113 .collect::<BTreeMap<_, _>>();
2114 for bin in &mut self.bins {
2115 let bin_id = bin.bin_id.ok_or_else(|| {
2116 eyre!(
2117 "Discrete histogram snapshot '{}' contains a bin without bin_id",
2118 self.title
2119 )
2120 })?;
2121 let other_bin = other_by_id.get(&bin_id).ok_or_else(|| {
2122 eyre!(
2123 "Cannot merge discrete histogram snapshot '{}' because bin_id {} is missing",
2124 self.title,
2125 bin_id
2126 )
2127 })?;
2128 bin.merge_in_place(other_bin);
2129 }
2130 }
2131 self.underflow_bin.merge_in_place(&other.underflow_bin);
2132 self.overflow_bin.merge_in_place(&other.overflow_bin);
2133 self.statistics.in_range_entry_count += other.statistics.in_range_entry_count;
2134 self.statistics.nan_value_count += other.statistics.nan_value_count;
2135 self.statistics.mitigated_pair_count += other.statistics.mitigated_pair_count;
2136 Ok(())
2137 }
2138
2139 pub fn rescale(&mut self, factor: f64) {
2140 for bin in &mut self.bins {
2141 bin.rescale_in_place(factor);
2142 }
2143 self.underflow_bin.rescale_in_place(factor);
2144 self.overflow_bin.rescale_in_place(factor);
2145 }
2146
2147 pub fn rescaled(&self, factor: f64) -> Self {
2148 let mut scaled = self.clone();
2149 scaled.rescale(factor);
2150 scaled
2151 }
2152
2153 pub fn change_bin_ordering(&mut self, ordering: DiscreteBinOrdering) -> Result<()> {
2154 if self.kind != HistogramSnapshotKind::Discrete {
2155 return Err(eyre!(
2156 "Cannot change bin ordering on continuous histogram '{}'",
2157 self.title
2158 ));
2159 }
2160 self.discrete_ordering = Some(ordering);
2161 sort_discrete_snapshot_bins(&mut self.bins, ordering, self.sample_count);
2162 Ok(())
2163 }
2164
2165 pub fn changed_bin_ordering(&self, ordering: DiscreteBinOrdering) -> Result<Self> {
2166 let mut reordered = self.clone();
2167 reordered.change_bin_ordering(ordering)?;
2168 Ok(reordered)
2169 }
2170
2171 pub fn rebin(&self, contiguous_bins: usize) -> Result<Self> {
2172 if self.kind == HistogramSnapshotKind::Discrete {
2173 return Ok(self.clone());
2174 }
2175 if contiguous_bins == 0 {
2176 return Err(eyre!("Rebinning factor must be strictly positive."));
2177 }
2178 if self.bins.is_empty() || !self.bins.len().is_multiple_of(contiguous_bins) {
2179 return Err(eyre!(
2180 "Rebinning factor {} does not divide the {} histogram bins exactly.",
2181 contiguous_bins,
2182 self.bins.len()
2183 ));
2184 }
2185
2186 let bins = self
2187 .bins
2188 .chunks(contiguous_bins)
2189 .map(|chunk| {
2190 let mut rebinned = chunk[0].clone();
2191 rebinned.x_min = chunk.first().and_then(|bin| bin.x_min);
2192 rebinned.x_max = chunk.last().and_then(|bin| bin.x_max);
2193 for bin in &chunk[1..] {
2194 rebinned.merge_in_place(bin);
2195 }
2196 rebinned
2197 })
2198 .collect();
2199
2200 Ok(Self {
2201 title: self.title.clone(),
2202 type_description: self.type_description.clone(),
2203 phase: self.phase,
2204 value_transform: self.value_transform,
2205 supports_misbinning_mitigation: self.supports_misbinning_mitigation,
2206 kind: self.kind,
2207 x_min: self.x_min,
2208 x_max: self.x_max,
2209 sample_count: self.sample_count,
2210 log_x_axis: self.log_x_axis,
2211 log_y_axis: self.log_y_axis,
2212 discrete_min_bin_id: self.discrete_min_bin_id,
2213 discrete_ordering: self.discrete_ordering,
2214 bins,
2215 underflow_bin: self.underflow_bin.clone(),
2216 overflow_bin: self.overflow_bin.clone(),
2217 statistics: self.statistics.clone(),
2218 })
2219 }
2220
2221 pub fn rebinned(&self, contiguous_bins: usize) -> Result<Self> {
2222 self.rebin(contiguous_bins)
2223 }
2224
2225 pub fn into_accumulator_state(self) -> HistogramAccumulatorState {
2226 HistogramAccumulatorState::from_snapshot(&self)
2227 }
2228}
2229
2230#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2231pub struct HistogramAccumulatorState {
2232 pub kind: HistogramSnapshotKind,
2234 pub title: String,
2236 pub type_description: String,
2238 pub phase: ObservablePhase,
2240 pub value_transform: ObservableValueTransform,
2242 pub supports_misbinning_mitigation: bool,
2244 pub x_min: f64,
2246 pub x_max: f64,
2248 pub sample_count: usize,
2250 new_sample_count: usize,
2251 pub log_x_axis: bool,
2253 pub log_y_axis: bool,
2255 pub discrete_min_bin_id: Option<isize>,
2257 pub discrete_ordering: Option<DiscreteBinOrdering>,
2259 pub bin_labels: Vec<Option<String>>,
2261 pub bins: Vec<ObservableBinAccumulator>,
2263 pub underflow_bin: ObservableBinAccumulator,
2265 pub overflow_bin: ObservableBinAccumulator,
2267 pub statistics: ObservableHistogramStatistics,
2269}
2270
2271impl HistogramAccumulatorState {
2272 #[allow(clippy::too_many_arguments)]
2273 pub fn continuous(
2274 title: String,
2275 type_description: String,
2276 phase: ObservablePhase,
2277 value_transform: ObservableValueTransform,
2278 x_min: f64,
2279 x_max: f64,
2280 log_x_axis: bool,
2281 log_y_axis: bool,
2282 n_bins: usize,
2283 ) -> Self {
2284 Self::new_continuous(
2285 title,
2286 type_description,
2287 phase,
2288 value_transform,
2289 true,
2290 x_min,
2291 x_max,
2292 log_x_axis,
2293 log_y_axis,
2294 n_bins,
2295 )
2296 }
2297
2298 #[allow(clippy::too_many_arguments)]
2299 pub fn discrete(
2300 title: String,
2301 type_description: String,
2302 phase: ObservablePhase,
2303 min_bin_id: isize,
2304 max_bin_id: isize,
2305 ordering: DiscreteBinOrdering,
2306 log_y_axis: bool,
2307 bin_labels: Vec<Option<String>>,
2308 ) -> Result<Self> {
2309 Self::new_discrete(
2310 title,
2311 type_description,
2312 phase,
2313 ObservableValueTransform::Identity,
2314 min_bin_id,
2315 max_bin_id,
2316 ordering,
2317 log_y_axis,
2318 bin_labels,
2319 )
2320 }
2321
2322 #[allow(clippy::too_many_arguments)]
2323 fn new_continuous(
2324 title: String,
2325 type_description: String,
2326 phase: ObservablePhase,
2327 value_transform: ObservableValueTransform,
2328 supports_misbinning_mitigation: bool,
2329 x_min: f64,
2330 x_max: f64,
2331 log_x_axis: bool,
2332 log_y_axis: bool,
2333 n_bins: usize,
2334 ) -> Self {
2335 Self {
2336 kind: HistogramSnapshotKind::Continuous,
2337 title,
2338 type_description,
2339 phase,
2340 value_transform,
2341 supports_misbinning_mitigation,
2342 x_min,
2343 x_max,
2344 sample_count: 0,
2345 new_sample_count: 0,
2346 log_x_axis,
2347 log_y_axis,
2348 discrete_min_bin_id: None,
2349 discrete_ordering: None,
2350 bin_labels: Vec::new(),
2351 bins: vec![ObservableBinAccumulator::default(); n_bins],
2352 underflow_bin: ObservableBinAccumulator::default(),
2353 overflow_bin: ObservableBinAccumulator::default(),
2354 statistics: ObservableHistogramStatistics::default(),
2355 }
2356 }
2357
2358 #[allow(clippy::too_many_arguments)]
2359 fn new_discrete(
2360 title: String,
2361 type_description: String,
2362 phase: ObservablePhase,
2363 value_transform: ObservableValueTransform,
2364 min_bin_id: isize,
2365 max_bin_id: isize,
2366 ordering: DiscreteBinOrdering,
2367 log_y_axis: bool,
2368 bin_labels: Vec<Option<String>>,
2369 ) -> Result<Self> {
2370 if max_bin_id < min_bin_id {
2371 return Err(eyre!(
2372 "invalid discrete histogram range: max_bin_id ({max_bin_id}) must be >= min_bin_id ({min_bin_id})"
2373 ));
2374 }
2375 let n_bins = max_bin_id.saturating_sub(min_bin_id) as usize + 1;
2376 Ok(Self {
2377 kind: HistogramSnapshotKind::Discrete,
2378 title,
2379 type_description,
2380 phase,
2381 value_transform,
2382 supports_misbinning_mitigation: false,
2383 x_min: min_bin_id as f64,
2384 x_max: (max_bin_id + 1) as f64,
2385 sample_count: 0,
2386 new_sample_count: 0,
2387 log_x_axis: false,
2388 log_y_axis,
2389 discrete_min_bin_id: Some(min_bin_id),
2390 discrete_ordering: Some(ordering),
2391 bin_labels,
2392 bins: vec![ObservableBinAccumulator::default(); n_bins],
2393 underflow_bin: ObservableBinAccumulator::default(),
2394 overflow_bin: ObservableBinAccumulator::default(),
2395 statistics: ObservableHistogramStatistics::default(),
2396 })
2397 }
2398
2399 fn cleared_clone(&self) -> Self {
2400 match self.kind {
2401 HistogramSnapshotKind::Continuous => Self::new_continuous(
2402 self.title.clone(),
2403 self.type_description.clone(),
2404 self.phase,
2405 self.value_transform,
2406 self.supports_misbinning_mitigation,
2407 self.x_min,
2408 self.x_max,
2409 self.log_x_axis,
2410 self.log_y_axis,
2411 self.bins.len(),
2412 ),
2413 HistogramSnapshotKind::Discrete => Self::new_discrete(
2414 self.title.clone(),
2415 self.type_description.clone(),
2416 self.phase,
2417 self.value_transform,
2418 self.discrete_min_bin_id
2419 .expect("discrete histogram missing min bin id"),
2420 self.discrete_min_bin_id
2421 .expect("discrete histogram missing min bin id")
2422 + self.bins.len() as isize
2423 - 1,
2424 self.discrete_ordering
2425 .expect("discrete histogram missing ordering"),
2426 self.log_y_axis,
2427 self.bin_labels.clone(),
2428 )
2429 .expect("cleared_clone should preserve a valid discrete histogram range"),
2430 }
2431 }
2432
2433 fn discrete_bin_id(&self, index: usize) -> isize {
2434 self.discrete_min_bin_id
2435 .expect("discrete histogram missing min bin id")
2436 + index as isize
2437 }
2438
2439 fn discrete_display_indices(&self) -> Vec<usize> {
2440 let mut indices: Vec<_> = (0..self.bins.len()).collect();
2441 let ordering = self
2442 .discrete_ordering
2443 .expect("discrete histogram missing ordering");
2444 let sample_count = self.sample_count + self.new_sample_count;
2445 indices.sort_by(|lhs, rhs| {
2446 let lhs_bin = &self.bins[*lhs];
2447 let rhs_bin = &self.bins[*rhs];
2448 let base = match ordering {
2449 DiscreteBinOrdering::AscendingBinId => Ordering::Equal,
2450 DiscreteBinOrdering::ValueDescending => rhs_bin
2451 .average(sample_count)
2452 .partial_cmp(&lhs_bin.average(sample_count))
2453 .unwrap_or(Ordering::Equal),
2454 DiscreteBinOrdering::AbsValueDescending => rhs_bin
2455 .average(sample_count)
2456 .abs()
2457 .partial_cmp(&lhs_bin.average(sample_count).abs())
2458 .unwrap_or(Ordering::Equal),
2459 };
2460 if base == Ordering::Equal {
2461 self.discrete_bin_id(*lhs).cmp(&self.discrete_bin_id(*rhs))
2462 } else {
2463 base
2464 }
2465 });
2466 indices
2467 }
2468
2469 pub fn snapshot(&self) -> HistogramSnapshot {
2470 let bins = match self.kind {
2471 HistogramSnapshotKind::Continuous => self
2472 .bins
2473 .iter()
2474 .enumerate()
2475 .map(|(index, bin)| {
2476 let x_min = (self.x_max - self.x_min) * index as f64 / self.bins.len() as f64
2477 + self.x_min;
2478 let x_max = (self.x_max - self.x_min) * (index + 1) as f64
2479 / self.bins.len() as f64
2480 + self.x_min;
2481 HistogramBinSnapshot {
2482 x_min: Some(x_min),
2483 x_max: Some(x_max),
2484 bin_id: None,
2485 label: None,
2486 entry_count: bin.total_entry_count(),
2487 sum_weights: bin.total_sum_weights(),
2488 sum_weights_squared: bin.total_sum_weights_squared(),
2489 mitigated_fill_count: bin.total_mitigated_fill_count(),
2490 }
2491 })
2492 .collect(),
2493 HistogramSnapshotKind::Discrete => self
2494 .discrete_display_indices()
2495 .into_iter()
2496 .map(|index| {
2497 let bin = &self.bins[index];
2498 HistogramBinSnapshot {
2499 x_min: None,
2500 x_max: None,
2501 bin_id: Some(self.discrete_bin_id(index)),
2502 label: self.bin_labels.get(index).cloned().flatten(),
2503 entry_count: bin.total_entry_count(),
2504 sum_weights: bin.total_sum_weights(),
2505 sum_weights_squared: bin.total_sum_weights_squared(),
2506 mitigated_fill_count: bin.total_mitigated_fill_count(),
2507 }
2508 })
2509 .collect(),
2510 };
2511
2512 HistogramSnapshot {
2513 kind: self.kind,
2514 title: self.title.clone(),
2515 type_description: self.type_description.clone(),
2516 phase: self.phase,
2517 value_transform: self.value_transform,
2518 supports_misbinning_mitigation: self.supports_misbinning_mitigation,
2519 x_min: (self.kind == HistogramSnapshotKind::Continuous).then_some(self.x_min),
2520 x_max: (self.kind == HistogramSnapshotKind::Continuous).then_some(self.x_max),
2521 sample_count: self.sample_count + self.new_sample_count,
2522 log_x_axis: self.log_x_axis,
2523 log_y_axis: self.log_y_axis,
2524 discrete_min_bin_id: self.discrete_min_bin_id,
2525 discrete_ordering: self.discrete_ordering,
2526 bins,
2527 underflow_bin: HistogramBinSnapshot {
2528 x_min: None,
2529 x_max: (self.kind == HistogramSnapshotKind::Continuous).then_some(self.x_min),
2530 bin_id: None,
2531 label: None,
2532 entry_count: self.underflow_bin.total_entry_count(),
2533 sum_weights: self.underflow_bin.total_sum_weights(),
2534 sum_weights_squared: self.underflow_bin.total_sum_weights_squared(),
2535 mitigated_fill_count: self.underflow_bin.total_mitigated_fill_count(),
2536 },
2537 overflow_bin: HistogramBinSnapshot {
2538 x_min: (self.kind == HistogramSnapshotKind::Continuous).then_some(self.x_max),
2539 x_max: None,
2540 bin_id: None,
2541 label: None,
2542 entry_count: self.overflow_bin.total_entry_count(),
2543 sum_weights: self.overflow_bin.total_sum_weights(),
2544 sum_weights_squared: self.overflow_bin.total_sum_weights_squared(),
2545 mitigated_fill_count: self.overflow_bin.total_mitigated_fill_count(),
2546 },
2547 statistics: HistogramStatisticsSnapshot {
2548 in_range_entry_count: self.statistics.in_range_entry_count
2549 + self.statistics.new_in_range_entry_count,
2550 nan_value_count: self.statistics.nan_value_count
2551 + self.statistics.new_nan_value_count,
2552 mitigated_pair_count: self.statistics.mitigated_pair_count
2553 + self.statistics.new_mitigated_pair_count,
2554 },
2555 }
2556 }
2557
2558 fn merge_samples(&mut self, other: &mut HistogramAccumulatorState) -> Result<()> {
2559 self.ensure_compatible(other)?;
2560 self.new_sample_count += other.new_sample_count;
2561 other.new_sample_count = 0;
2562 for (bin, other_bin) in self.bins.iter_mut().zip(other.bins.iter_mut()) {
2563 bin.merge_samples(other_bin);
2564 }
2565 self.underflow_bin.merge_samples(&mut other.underflow_bin);
2566 self.overflow_bin.merge_samples(&mut other.overflow_bin);
2567 self.statistics.merge_samples(&mut other.statistics);
2568 Ok(())
2569 }
2570
2571 pub fn merge_in_place(&mut self, other: &mut HistogramAccumulatorState) -> Result<()> {
2572 self.merge_samples(other)
2573 }
2574
2575 pub fn merged_with(&self, other: &HistogramAccumulatorState) -> Result<Self> {
2576 let mut merged = self.clone();
2577 let mut other_clone = other.clone();
2578 merged.merge_in_place(&mut other_clone)?;
2579 Ok(merged)
2580 }
2581
2582 fn update_result(&mut self) {
2583 self.sample_count += self.new_sample_count;
2584 self.new_sample_count = 0;
2585 for bin in &mut self.bins {
2586 bin.update_iter();
2587 }
2588 self.underflow_bin.update_iter();
2589 self.overflow_bin.update_iter();
2590 self.statistics.update_iter();
2591 }
2592
2593 pub fn update_results(&mut self) {
2594 self.update_result();
2595 }
2596
2597 pub fn fill_continuous_sample(&mut self, entries: &[(f64, f64)]) -> Result<()> {
2598 if self.kind != HistogramSnapshotKind::Continuous {
2599 return Err(eyre!(
2600 "Cannot fill continuous entries into discrete histogram '{}'",
2601 self.title
2602 ));
2603 }
2604 self.new_sample_count += 1;
2605 for (value, projected_weight) in entries {
2606 if !value.is_finite() {
2607 self.statistics.register_nan();
2608 continue;
2609 }
2610 let Some(bin_position) =
2611 histogram_bin_position(*value, self.x_min, self.x_max, self.bins.len())
2612 else {
2613 self.statistics.register_nan();
2614 continue;
2615 };
2616 match bin_position {
2617 HistogramBinPosition::Underflow => {
2618 self.underflow_bin.add_sample(*projected_weight, 1, 0);
2619 }
2620 HistogramBinPosition::Overflow => {
2621 self.overflow_bin.add_sample(*projected_weight, 1, 0);
2622 }
2623 HistogramBinPosition::InRange(bin_index) => {
2624 self.statistics.register_in_range_entry();
2625 self.bins[bin_index].add_sample(*projected_weight, 1, 0);
2626 }
2627 }
2628 }
2629 Ok(())
2630 }
2631
2632 pub fn fill_discrete_sample(&mut self, entries: &[(isize, f64)]) -> Result<()> {
2633 if self.kind != HistogramSnapshotKind::Discrete {
2634 return Err(eyre!(
2635 "Cannot fill discrete entries into continuous histogram '{}'",
2636 self.title
2637 ));
2638 }
2639 self.new_sample_count += 1;
2640 let min_bin_id = self
2641 .discrete_min_bin_id
2642 .expect("discrete histogram missing min bin id");
2643 for (bin_id, projected_weight) in entries {
2644 match histogram_bin_position_discrete(*bin_id, min_bin_id, self.bins.len()) {
2645 HistogramBinPosition::Underflow => {
2646 self.underflow_bin.add_sample(*projected_weight, 1, 0);
2647 }
2648 HistogramBinPosition::Overflow => {
2649 self.overflow_bin.add_sample(*projected_weight, 1, 0);
2650 }
2651 HistogramBinPosition::InRange(bin_index) => {
2652 self.statistics.register_in_range_entry();
2653 self.bins[bin_index].add_sample(*projected_weight, 1, 0);
2654 }
2655 }
2656 }
2657 Ok(())
2658 }
2659
2660 fn write_hwu_block<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
2661 let x_axis_mode = if self.log_x_axis { "LOG" } else { "LIN" };
2662 let y_axis_mode = if self.log_y_axis { "LOG" } else { "LIN" };
2663
2664 writeln!(writer, "##& xmin & xmax & central value & dy &\n")?;
2665 writeln!(
2666 writer,
2667 "<histogram> {} \"{} |X_AXIS@{} |Y_AXIS@{} |TYPE@{}\"",
2668 self.bins.len(),
2669 self.title,
2670 x_axis_mode,
2671 y_axis_mode,
2672 self.type_description,
2673 )?;
2674
2675 let display_indices = if self.kind == HistogramSnapshotKind::Discrete {
2676 self.discrete_display_indices()
2677 } else {
2678 (0..self.bins.len()).collect()
2679 };
2680
2681 for index in display_indices {
2682 let bin = &self.bins[index];
2683 let (c1, c2) = if self.kind == HistogramSnapshotKind::Discrete {
2684 let bin_id = self.discrete_bin_id(index);
2685 (bin_id as f64, (bin_id + 1) as f64)
2686 } else {
2687 let c1 =
2688 (self.x_max - self.x_min) * index as f64 / self.bins.len() as f64 + self.x_min;
2689 let c2 = (self.x_max - self.x_min) * (index + 1) as f64 / self.bins.len() as f64
2690 + self.x_min;
2691 (c1, c2)
2692 };
2693 writeln!(
2694 writer,
2695 " {:.8e} {:.8e} {:.8e} {:.8e}",
2696 c1,
2697 c2,
2698 bin.average(self.sample_count + self.new_sample_count),
2699 bin.error(self.sample_count + self.new_sample_count),
2700 )?;
2701 }
2702
2703 writeln!(writer, "<\\histogram>")?;
2704 Ok(())
2705 }
2706
2707 fn from_snapshot(snapshot: &HistogramSnapshot) -> Self {
2708 let mut bins = snapshot.bins.clone();
2709 if snapshot.kind == HistogramSnapshotKind::Discrete {
2710 bins.sort_by_key(|bin| bin.bin_id.unwrap_or_default());
2711 }
2712 let discrete_min_bin_id = if snapshot.kind == HistogramSnapshotKind::Discrete {
2713 Some(
2714 bins.first()
2715 .and_then(|bin| bin.bin_id)
2716 .or(snapshot.discrete_min_bin_id)
2717 .unwrap_or(0),
2718 )
2719 } else {
2720 None
2721 };
2722 let x_min = snapshot
2723 .x_min
2724 .unwrap_or_else(|| discrete_min_bin_id.unwrap_or_default() as f64);
2725 let x_max = snapshot.x_max.unwrap_or_else(|| {
2726 let min = discrete_min_bin_id.unwrap_or_default();
2727 (min + bins.len() as isize) as f64
2728 });
2729 Self {
2730 kind: snapshot.kind,
2731 title: snapshot.title.clone(),
2732 type_description: snapshot.type_description.clone(),
2733 phase: snapshot.phase,
2734 value_transform: snapshot.value_transform,
2735 supports_misbinning_mitigation: snapshot.supports_misbinning_mitigation,
2736 x_min,
2737 x_max,
2738 sample_count: snapshot.sample_count,
2739 new_sample_count: 0,
2740 log_x_axis: snapshot.log_x_axis,
2741 log_y_axis: snapshot.log_y_axis,
2742 discrete_min_bin_id,
2743 discrete_ordering: snapshot.discrete_ordering,
2744 bin_labels: bins.iter().map(|bin| bin.label.clone()).collect(),
2745 bins: bins
2746 .iter()
2747 .map(ObservableBinAccumulator::from_snapshot)
2748 .collect(),
2749 underflow_bin: ObservableBinAccumulator::from_snapshot(&snapshot.underflow_bin),
2750 overflow_bin: ObservableBinAccumulator::from_snapshot(&snapshot.overflow_bin),
2751 statistics: ObservableHistogramStatistics {
2752 in_range_entry_count: snapshot.statistics.in_range_entry_count,
2753 nan_value_count: snapshot.statistics.nan_value_count,
2754 mitigated_pair_count: snapshot.statistics.mitigated_pair_count,
2755 new_in_range_entry_count: 0,
2756 new_nan_value_count: 0,
2757 new_mitigated_pair_count: 0,
2758 },
2759 }
2760 }
2761
2762 pub fn rebin(&self, contiguous_bins: usize) -> Result<Self> {
2763 self.snapshot()
2764 .rebin(contiguous_bins)
2765 .map(|s| s.into_accumulator_state())
2766 }
2767
2768 pub fn rescale(&mut self, factor: f64) {
2769 for bin in &mut self.bins {
2770 bin.rescale(factor);
2771 }
2772 self.underflow_bin.rescale(factor);
2773 self.overflow_bin.rescale(factor);
2774 }
2775
2776 pub fn rescaled(&self, factor: f64) -> Self {
2777 let mut scaled = self.clone();
2778 scaled.rescale(factor);
2779 scaled
2780 }
2781
2782 pub fn change_bin_ordering(&mut self, ordering: DiscreteBinOrdering) -> Result<()> {
2783 if self.kind != HistogramSnapshotKind::Discrete {
2784 return Err(eyre!(
2785 "Cannot change bin ordering on continuous histogram '{}'",
2786 self.title
2787 ));
2788 }
2789 self.discrete_ordering = Some(ordering);
2790 Ok(())
2791 }
2792
2793 pub fn changed_bin_ordering(&self, ordering: DiscreteBinOrdering) -> Result<Self> {
2794 let mut reordered = self.clone();
2795 reordered.change_bin_ordering(ordering)?;
2796 Ok(reordered)
2797 }
2798
2799 fn ensure_compatible(&self, other: &HistogramAccumulatorState) -> Result<()> {
2800 if self.title != other.title
2801 || self.type_description != other.type_description
2802 || self.phase != other.phase
2803 || self.value_transform != other.value_transform
2804 || self.supports_misbinning_mitigation != other.supports_misbinning_mitigation
2805 || self.kind != other.kind
2806 || self.x_min != other.x_min
2807 || self.x_max != other.x_max
2808 || self.log_x_axis != other.log_x_axis
2809 || self.log_y_axis != other.log_y_axis
2810 || self.discrete_min_bin_id != other.discrete_min_bin_id
2811 || self.bin_labels != other.bin_labels
2812 || self.bins.len() != other.bins.len()
2813 {
2814 return Err(eyre!(
2815 "Cannot merge incompatible histogram accumulators '{}'",
2816 self.title
2817 ));
2818 }
2819 Ok(())
2820 }
2821}
2822
2823#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2824pub struct ObservableAccumulatorBundle {
2825 pub histograms: BTreeMap<String, HistogramAccumulatorState>,
2826}
2827
2828impl ObservableAccumulatorBundle {
2829 pub fn merge_samples(&mut self, other: &mut ObservableAccumulatorBundle) -> Result<()> {
2830 for (name, other_histogram) in other.histograms.iter_mut() {
2831 let histogram = self
2832 .histograms
2833 .get_mut(name)
2834 .ok_or_else(|| eyre!("Cannot merge unknown observable accumulator '{}'", name))?;
2835 histogram.merge_samples(other_histogram)?;
2836 }
2837 Ok(())
2838 }
2839
2840 pub fn update_results(&mut self) {
2841 for histogram in self.histograms.values_mut() {
2842 histogram.update_result();
2843 }
2844 }
2845
2846 pub fn snapshot_bundle(&self) -> ObservableSnapshotBundle {
2847 ObservableSnapshotBundle {
2848 histograms: self
2849 .histograms
2850 .iter()
2851 .map(|(name, histogram)| (name.clone(), histogram.snapshot()))
2852 .collect(),
2853 }
2854 }
2855}
2856
2857#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
2858pub struct ObservableSnapshotBundle {
2859 pub histograms: BTreeMap<String, HistogramSnapshot>,
2860}
2861
2862impl ObservableSnapshotBundle {
2863 pub fn to_json_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
2864 let writer = BufWriter::new(File::create(path.as_ref())?);
2865 serde_json::to_writer_pretty(writer, self)?;
2866 Ok(())
2867 }
2868
2869 pub fn from_json_file<P: AsRef<Path>>(path: P) -> Result<Self> {
2870 let reader = BufReader::new(File::open(path.as_ref())?);
2871 Ok(serde_json::from_reader(reader)?)
2872 }
2873
2874 pub fn write_hwu_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
2875 let mut writer = BufWriter::new(File::create(path.as_ref())?);
2876 for histogram in self.histograms.values() {
2877 histogram.write_hwu_block(&mut writer)?;
2878 }
2879 Ok(())
2880 }
2881
2882 pub fn into_accumulator_bundle(self) -> ObservableAccumulatorBundle {
2883 ObservableAccumulatorBundle {
2884 histograms: self
2885 .histograms
2886 .into_iter()
2887 .map(|(name, histogram)| (name, histogram.into_accumulator_state()))
2888 .collect(),
2889 }
2890 }
2891
2892 pub fn merge_in_place(&mut self, other: &ObservableSnapshotBundle) -> Result<()> {
2893 for (name, other_histogram) in other.histograms.iter() {
2894 let histogram = self
2895 .histograms
2896 .get_mut(name)
2897 .ok_or_else(|| eyre!("Cannot merge unknown histogram snapshot '{}'", name))?;
2898 histogram.merge_in_place(other_histogram)?;
2899 }
2900 Ok(())
2901 }
2902}
2903
2904trait EventSelector {
2905 fn process_event<T: FloatLike>(
2906 &mut self,
2907 event: &mut GenericEvent<T>,
2908 clustering_registry: &CompiledClusteringRegistry,
2909 ) -> bool;
2910}
2911
2912#[derive(Debug, Clone)]
2913struct ConfiguredSelector {
2914 definition: ObservableDefinition,
2915 criteria: Vec<SelectorCriterion>,
2916}
2917
2918impl ConfiguredSelector {
2919 pub(crate) fn new(
2920 settings: &SelectorSettings,
2921 quantities: &QuantitiesSettings,
2922 clustering_registry: &mut CompiledClusteringRegistry,
2923 model: Option<&Model>,
2924 ) -> Result<Self> {
2925 let quantity = quantities.get(&settings.quantity).ok_or_else(|| {
2926 eyre!(
2927 "Selector references unknown quantity '{}'",
2928 settings.quantity
2929 )
2930 })?;
2931
2932 let definition = ObservableDefinition::from_settings(quantity, clustering_registry, model)?;
2933 let coordinate_kind = definition.coordinate_kind();
2934
2935 let criterion = match &settings.selector {
2936 SelectorDefinitionSettings::ValueRange(selector) => {
2937 if coordinate_kind != ObservableCoordinateKind::Continuous {
2938 return Err(eyre!(
2939 "Selector '{}' uses value_range with a discrete quantity '{}'",
2940 settings.quantity,
2941 settings.quantity
2942 ));
2943 }
2944 SelectorCriterion::ValueRange {
2945 selection: settings.entry_selection,
2946 entry_index: settings.entry_index,
2947 reduction: selector.reduction,
2948 min: selector.min,
2949 max: selector.max,
2950 }
2951 }
2952 SelectorDefinitionSettings::DiscreteRange(selector) => {
2953 if coordinate_kind != ObservableCoordinateKind::Discrete {
2954 return Err(eyre!(
2955 "Selector '{}' uses discrete_range with a continuous quantity '{}'",
2956 settings.quantity,
2957 settings.quantity
2958 ));
2959 }
2960 SelectorCriterion::DiscreteRange {
2961 selection: settings.entry_selection,
2962 entry_index: settings.entry_index,
2963 min: selector.min,
2964 max: selector.max,
2965 }
2966 }
2967 SelectorDefinitionSettings::CountRange(selector) => SelectorCriterion::CountRange {
2968 selection: settings.entry_selection,
2969 entry_index: settings.entry_index,
2970 min: selector.min_count,
2971 max: selector.max_count,
2972 },
2973 };
2974
2975 Ok(Self {
2976 definition,
2977 criteria: vec![criterion],
2978 })
2979 }
2980
2981 fn required_clustering_handle(&self) -> Option<ClusteringHandle> {
2982 self.definition.required_clustering_handle()
2983 }
2984
2985 fn passes_event<T: FloatLike>(
2986 &self,
2987 event: &mut GenericEvent<T>,
2988 clustering_registry: &CompiledClusteringRegistry,
2989 ) -> bool {
2990 if let Some(clustering_handle) = self.definition.required_clustering_handle() {
2991 ensure_event_clustering(event, clustering_handle, clustering_registry);
2992 }
2993 let entries = self.definition.process_event(event);
2994 self.criteria
2995 .iter()
2996 .all(|criterion| criterion.passes(&entries))
2997 }
2998
2999 fn passes_prepared_event<T: FloatLike>(&self, event: &GenericEvent<T>) -> bool {
3000 let entries = self.definition.process_event(event);
3001 self.criteria
3002 .iter()
3003 .all(|criterion| criterion.passes(&entries))
3004 }
3005}
3006
3007impl EventSelector for ConfiguredSelector {
3008 fn process_event<T: FloatLike>(
3009 &mut self,
3010 event: &mut GenericEvent<T>,
3011 clustering_registry: &CompiledClusteringRegistry,
3012 ) -> bool {
3013 self.passes_event(event, clustering_registry)
3014 }
3015}
3016
3017#[derive(Clone)]
3018enum Selectors {
3019 Configured(ConfiguredSelector),
3020}
3021
3022impl Selectors {
3023 fn process_event<T: FloatLike>(
3024 &mut self,
3025 event: &mut GenericEvent<T>,
3026 clustering_registry: &CompiledClusteringRegistry,
3027 ) -> bool {
3028 match self {
3029 Selectors::Configured(selector) => selector.process_event(event, clustering_registry),
3030 }
3031 }
3032}
3033
3034#[derive(Clone, Default)]
3035pub struct EventProcessingRuntime {
3036 clustering_registry: CompiledClusteringRegistry,
3037 observable_clustering_handles: Vec<ClusteringHandle>,
3038 selectors: Vec<Selectors>,
3039 observables: BTreeMap<String, Observables>,
3040}
3041
3042impl EventProcessingRuntime {
3043 pub fn from_settings(settings: &RuntimeSettings) -> Result<Self> {
3044 Self::from_settings_inner(settings, None, None)
3045 }
3046
3047 pub fn from_settings_with_model(settings: &RuntimeSettings, model: &Model) -> Result<Self> {
3048 Self::from_settings_inner(settings, Some(model), None)
3049 }
3050
3051 pub fn from_settings_with_model_and_process_info(
3052 settings: &RuntimeSettings,
3053 model: &Model,
3054 process_info: &HistogramProcessInfo,
3055 ) -> Result<Self> {
3056 Self::from_settings_inner(settings, Some(model), Some(process_info))
3057 }
3058
3059 fn from_settings_inner(
3060 settings: &RuntimeSettings,
3061 model: Option<&Model>,
3062 process_info: Option<&HistogramProcessInfo>,
3063 ) -> Result<Self> {
3064 let mut clustering_registry = CompiledClusteringRegistry::default();
3065 let selector_registry = settings
3066 .selectors
3067 .iter()
3068 .map(|(name, selector)| {
3069 ConfiguredSelector::new(
3070 selector,
3071 &settings.quantities,
3072 &mut clustering_registry,
3073 model,
3074 )
3075 .map(|configured| (name.clone(), configured))
3076 })
3077 .collect::<Result<BTreeMap<_, _>>>()?;
3078 let selectors = settings
3079 .selectors
3080 .iter()
3081 .filter(|(_, selector)| selector.active)
3082 .map(|(name, _selector)| Ok(Selectors::Configured(selector_registry[name].clone())))
3083 .collect::<Result<Vec<_>>>()?;
3084
3085 let mut observable_clustering_handles = Vec::new();
3086 let observables = settings
3087 .observables
3088 .iter()
3089 .map(|(name, observable)| {
3090 Observables::from_settings(
3091 name,
3092 observable,
3093 &settings.quantities,
3094 &settings.selectors,
3095 &selector_registry,
3096 process_info,
3097 &mut clustering_registry,
3098 model,
3099 )
3100 .map(|observable| {
3101 for handle in observable.required_clustering_handles() {
3102 if !observable_clustering_handles.contains(&handle) {
3103 observable_clustering_handles.push(handle);
3104 }
3105 }
3106 (name.clone(), observable)
3107 })
3108 })
3109 .collect::<Result<BTreeMap<_, _>>>()?;
3110
3111 Ok(Self {
3112 clustering_registry,
3113 observable_clustering_handles,
3114 selectors,
3115 observables,
3116 })
3117 }
3118
3119 pub fn has_selectors(&self) -> bool {
3120 !self.selectors.is_empty()
3121 }
3122
3123 pub fn has_observables(&self) -> bool {
3124 !self.observables.is_empty()
3125 }
3126
3127 fn process_event_internal<T: FloatLike>(
3128 &mut self,
3129 event: &mut GenericEvent<T>,
3130 prepare_observables: bool,
3131 ) -> bool {
3132 for selector in self.selectors.iter_mut() {
3133 if !selector.process_event(event, &self.clustering_registry) {
3134 return false;
3135 }
3136 }
3137
3138 if prepare_observables && self.has_observables() {
3139 for &handle in &self.observable_clustering_handles {
3140 ensure_event_clustering(event, handle, &self.clustering_registry);
3141 }
3142 }
3143 true
3144 }
3145
3146 pub fn process_event<T: FloatLike>(&mut self, event: &mut GenericEvent<T>) -> bool {
3147 self.process_event_internal(event, true)
3148 }
3149
3150 pub(crate) fn process_event_for_selectors<T: FloatLike>(
3151 &mut self,
3152 event: &mut GenericEvent<T>,
3153 ) -> bool {
3154 self.process_event_internal(event, false)
3155 }
3156
3157 pub fn process_event_groups<T: FloatLike>(&mut self, event_groups: &GenericEventGroupList<T>) {
3158 for observable in self.observables.values_mut() {
3159 observable.process_event_groups(event_groups, &self.clustering_registry);
3160 }
3161 }
3162
3163 pub fn merge_samples(&mut self, other: &mut EventProcessingRuntime) -> Result<()> {
3164 for (name, other_observable) in other.observables.iter_mut() {
3165 let observable = self
3166 .observables
3167 .get_mut(name)
3168 .ok_or_else(|| eyre!("Cannot merge unknown observable '{}'", name))?;
3169 observable.merge_samples(other_observable)?;
3170 }
3171 Ok(())
3172 }
3173
3174 pub fn update_results(&mut self, iter: usize) {
3175 for observable in self.observables.values_mut() {
3176 observable.update_result(iter);
3177 }
3178 }
3179
3180 pub fn cleared_observable_clone(&self) -> Self {
3181 Self {
3182 clustering_registry: self.clustering_registry.clone(),
3183 observable_clustering_handles: self.observable_clustering_handles.clone(),
3184 selectors: self.selectors.clone(),
3185 observables: self
3186 .observables
3187 .iter()
3188 .map(|(name, observable)| (name.clone(), observable.cleared_clone()))
3189 .collect(),
3190 }
3191 }
3192
3193 pub fn snapshot_bundle(&self) -> ObservableSnapshotBundle {
3194 ObservableSnapshotBundle {
3195 histograms: self
3196 .observables
3197 .iter()
3198 .map(|(name, observable)| (name.clone(), observable.snapshot()))
3199 .collect(),
3200 }
3201 }
3202
3203 pub fn restore_snapshot_bundle(&mut self, bundle: &ObservableSnapshotBundle) -> Result<()> {
3204 if self.observables.len() != bundle.histograms.len() {
3205 return Err(eyre!(
3206 "Cannot restore observables from a snapshot with a different histogram count"
3207 ));
3208 }
3209
3210 for (name, observable) in self.observables.iter_mut() {
3211 let snapshot = bundle
3212 .histograms
3213 .get(name)
3214 .ok_or_else(|| eyre!("Cannot restore unknown histogram snapshot '{}'", name))?;
3215 observable.restore_snapshot(snapshot)?;
3216 }
3217
3218 Ok(())
3219 }
3220
3221 pub fn accumulator_bundle(&self) -> ObservableAccumulatorBundle {
3222 ObservableAccumulatorBundle {
3223 histograms: self
3224 .observables
3225 .iter()
3226 .map(|(name, observable)| (name.clone(), observable.accumulator_state()))
3227 .collect(),
3228 }
3229 }
3230
3231 pub fn merge_accumulator_bundle(
3232 &mut self,
3233 other: &mut ObservableAccumulatorBundle,
3234 ) -> Result<()> {
3235 for (name, other_histogram) in other.histograms.iter_mut() {
3236 let observable = self
3237 .observables
3238 .get_mut(name)
3239 .ok_or_else(|| eyre!("Cannot merge unknown observable '{}'", name))?;
3240 observable.merge_accumulator_state(other_histogram)?;
3241 }
3242 Ok(())
3243 }
3244}
3245
3246pub(crate) trait Observable {
3247 fn process_event_groups<T: FloatLike>(
3248 &mut self,
3249 event_groups: &GenericEventGroupList<T>,
3250 clustering_registry: &CompiledClusteringRegistry,
3251 );
3252
3253 fn merge_samples(&mut self, other: &mut Self) -> Result<()>
3254 where
3255 Self: Sized;
3256
3257 fn update_result(&mut self, iter: usize);
3258}
3259
3260#[derive(Debug, Clone)]
3261struct PendingBinContribution {
3262 value: f64,
3263 bin_index: usize,
3264 projected_weight: f64,
3265 mitigated: bool,
3266}
3267
3268#[derive(Debug, Clone, Default)]
3269struct GroupBinContribution {
3270 projected_weight: f64,
3271 entry_count: usize,
3272 mitigated_fill_count: usize,
3273}
3274
3275#[derive(Debug, Clone)]
3276pub struct HistogramObservable {
3277 definition: ObservableDefinition,
3278 selections: Vec<ConfiguredSelector>,
3279 entry_selection: EntrySelection,
3280 entry_index: usize,
3281 misbinning_max_normalized_distance: Option<f64>,
3282 state: HistogramAccumulatorState,
3283 pending_contributions: Vec<PendingBinContribution>,
3284 candidate_pairs: Vec<(f64, usize, usize)>,
3285 used_contributions: Vec<bool>,
3286 grouped_contributions: Vec<GroupBinContribution>,
3287 grouped_underflow: GroupBinContribution,
3288 grouped_overflow: GroupBinContribution,
3289 touched_bins: Vec<usize>,
3290}
3291
3292impl HistogramObservable {
3293 fn new(config: HistogramObservableConfig<'_>) -> Result<Self> {
3294 let HistogramObservableConfig {
3295 definition,
3296 selections,
3297 quantities,
3298 quantity_settings,
3299 observable_name,
3300 settings,
3301 selector_settings,
3302 process_info,
3303 } = config;
3304 let state = match (&settings.histogram, definition.coordinate_kind()) {
3305 (HistogramSettings::Continuous(histogram), ObservableCoordinateKind::Continuous) => {
3306 let supports_misbinning_mitigation = definition.supports_misbinning_mitigation();
3307 if settings.misbinning_max_normalized_distance.is_some()
3308 && !supports_misbinning_mitigation
3309 {
3310 return Err(eyre!(
3311 "Observable '{}' does not support misbinning mitigation.",
3312 observable_name
3313 ));
3314 }
3315 HistogramAccumulatorState::new_continuous(
3316 histogram
3317 .title
3318 .clone()
3319 .unwrap_or_else(|| observable_name.to_string()),
3320 histogram.type_description.clone(),
3321 settings.phase,
3322 settings.value_transform,
3323 supports_misbinning_mitigation,
3324 histogram.x_min,
3325 histogram.x_max,
3326 histogram.log_x_axis,
3327 histogram.log_y_axis,
3328 histogram.n_bins,
3329 )
3330 }
3331 (HistogramSettings::Discrete(histogram), ObservableCoordinateKind::Discrete) => {
3332 if settings.misbinning_max_normalized_distance.is_some() {
3333 return Err(eyre!(
3334 "Observable '{}' uses a discrete histogram, so misbinning mitigation is not supported.",
3335 observable_name
3336 ));
3337 }
3338 if settings.value_transform != ObservableValueTransform::Identity {
3339 return Err(eyre!(
3340 "Observable '{}' uses a discrete histogram, so value_transform must stay identity.",
3341 observable_name
3342 ));
3343 }
3344 let (min_bin_id, max_bin_id, bin_labels) = resolve_discrete_histogram_layout(
3345 quantities,
3346 quantity_settings,
3347 observable_name,
3348 histogram,
3349 &settings.selections,
3350 selector_settings,
3351 process_info,
3352 )?;
3353 HistogramAccumulatorState::new_discrete(
3354 histogram
3355 .title
3356 .clone()
3357 .unwrap_or_else(|| observable_name.to_string()),
3358 histogram.type_description.clone(),
3359 settings.phase,
3360 settings.value_transform,
3361 min_bin_id,
3362 max_bin_id,
3363 histogram.ordering,
3364 histogram.log_y_axis,
3365 bin_labels,
3366 )?
3367 }
3368 (HistogramSettings::Continuous(_), ObservableCoordinateKind::Discrete) => {
3369 return Err(eyre!(
3370 "Observable '{}' uses a discrete quantity and therefore requires a discrete histogram definition.",
3371 observable_name
3372 ));
3373 }
3374 (HistogramSettings::Discrete(_), ObservableCoordinateKind::Continuous) => {
3375 return Err(eyre!(
3376 "Observable '{}' uses a continuous quantity and therefore requires a continuous histogram definition.",
3377 observable_name
3378 ));
3379 }
3380 };
3381 let n_bins = state.bins.len();
3382
3383 Ok(Self {
3384 definition,
3385 selections,
3386 entry_selection: settings.entry_selection,
3387 entry_index: settings.entry_index,
3388 misbinning_max_normalized_distance: settings.misbinning_max_normalized_distance,
3389 state,
3390 pending_contributions: Vec::with_capacity(16),
3391 candidate_pairs: Vec::with_capacity(16),
3392 used_contributions: Vec::with_capacity(16),
3393 grouped_contributions: vec![GroupBinContribution::default(); n_bins],
3394 grouped_underflow: GroupBinContribution::default(),
3395 grouped_overflow: GroupBinContribution::default(),
3396 touched_bins: Vec::with_capacity(n_bins.min(16)),
3397 })
3398 }
3399
3400 fn build_group_contributions<T: FloatLike>(
3401 &mut self,
3402 event_group: &GenericEventGroup<T>,
3403 _clustering_registry: &CompiledClusteringRegistry,
3404 ) {
3405 self.pending_contributions.clear();
3406 self.pending_contributions
3407 .reserve(event_group.len().saturating_mul(4));
3408
3409 for event in event_group.iter() {
3410 if self
3411 .selections
3412 .iter()
3413 .any(|selector| !selector.passes_prepared_event(event))
3414 {
3415 continue;
3416 }
3417 let entries = self.definition.process_event(event);
3418 for entry in apply_entry_selection(&entries, self.entry_selection, self.entry_index) {
3419 let (value, bin_position) = match &entry.coordinate {
3420 ObservableCoordinate::Continuous(entry_value) => {
3421 let transformed_value =
3422 transform_value(entry_value, self.state.value_transform);
3423 let value = transformed_value.into_ff64().0;
3424 if !value.is_finite() {
3425 self.state.statistics.register_nan();
3426 continue;
3427 }
3428
3429 let Some(bin_position) = histogram_bin_position(
3430 value,
3431 self.state.x_min,
3432 self.state.x_max,
3433 self.state.bins.len(),
3434 ) else {
3435 self.state.statistics.register_nan();
3436 continue;
3437 };
3438 (value, bin_position)
3439 }
3440 ObservableCoordinate::Discrete(bin_id) => (
3441 *bin_id as f64,
3442 histogram_bin_position_discrete(
3443 *bin_id,
3444 self.state
3445 .discrete_min_bin_id
3446 .expect("discrete histogram missing min bin id"),
3447 self.state.bins.len(),
3448 ),
3449 ),
3450 };
3451
3452 let projected_weight = self
3453 .state
3454 .phase
3455 .project(&combined_entry_weight(event, &entry))
3456 .into_ff64()
3457 .0;
3458 if !projected_weight.is_finite() {
3459 self.state.statistics.register_nan();
3460 continue;
3461 }
3462
3463 match bin_position {
3464 HistogramBinPosition::Underflow => {
3465 self.grouped_underflow.projected_weight += projected_weight;
3466 self.grouped_underflow.entry_count += 1;
3467 }
3468 HistogramBinPosition::Overflow => {
3469 self.grouped_overflow.projected_weight += projected_weight;
3470 self.grouped_overflow.entry_count += 1;
3471 }
3472 HistogramBinPosition::InRange(bin_index) => {
3473 self.state.statistics.register_in_range_entry();
3474 self.pending_contributions.push(PendingBinContribution {
3475 value,
3476 bin_index,
3477 projected_weight,
3478 mitigated: false,
3479 });
3480 }
3481 }
3482 }
3483 }
3484 }
3485
3486 fn mitigate_group_misbinning(&mut self) {
3487 if self.state.kind == HistogramSnapshotKind::Discrete {
3488 return;
3489 }
3490 let Some(max_distance) = self.misbinning_max_normalized_distance else {
3491 return;
3492 };
3493
3494 if self.pending_contributions.len() < 2 || self.state.bins.len() < 2 || max_distance <= 0.0
3495 {
3496 return;
3497 }
3498
3499 let bin_width = (self.state.x_max - self.state.x_min) / self.state.bins.len() as f64;
3500 if !bin_width.is_finite() || bin_width <= 0.0 {
3501 return;
3502 }
3503
3504 self.candidate_pairs.clear();
3505 for i in 0..self.pending_contributions.len() {
3506 for j in (i + 1)..self.pending_contributions.len() {
3507 if self.pending_contributions[i].projected_weight == 0.0
3508 || self.pending_contributions[j].projected_weight == 0.0
3509 {
3510 continue;
3511 }
3512 if self.pending_contributions[i].projected_weight.signum()
3513 == self.pending_contributions[j].projected_weight.signum()
3514 {
3515 continue;
3516 }
3517 if self.pending_contributions[i]
3518 .bin_index
3519 .abs_diff(self.pending_contributions[j].bin_index)
3520 != 1
3521 {
3522 continue;
3523 }
3524
3525 let normalized_distance = (self.pending_contributions[i].value
3526 - self.pending_contributions[j].value)
3527 .abs()
3528 / bin_width;
3529 if normalized_distance <= max_distance {
3530 self.candidate_pairs.push((normalized_distance, i, j));
3531 }
3532 }
3533 }
3534
3535 self.candidate_pairs
3536 .sort_by(|lhs, rhs| match lhs.0.partial_cmp(&rhs.0) {
3537 Some(ordering) => ordering,
3538 None => Ordering::Greater,
3539 });
3540
3541 self.used_contributions.clear();
3542 self.used_contributions
3543 .resize(self.pending_contributions.len(), false);
3544 for (_, i, j) in self.candidate_pairs.iter().copied() {
3545 if self.used_contributions[i] || self.used_contributions[j] {
3546 continue;
3547 }
3548
3549 let averaged_weight = 0.5
3550 * (self.pending_contributions[i].projected_weight
3551 + self.pending_contributions[j].projected_weight);
3552 self.pending_contributions[i].projected_weight = averaged_weight;
3553 self.pending_contributions[j].projected_weight = averaged_weight;
3554 self.pending_contributions[i].mitigated = true;
3555 self.pending_contributions[j].mitigated = true;
3556 self.used_contributions[i] = true;
3557 self.used_contributions[j] = true;
3558 self.state.statistics.register_mitigated_pair();
3559 }
3560 }
3561
3562 pub fn snapshot(&self) -> HistogramSnapshot {
3563 self.state.snapshot()
3564 }
3565
3566 pub fn write_to_file<P: AsRef<Path>>(
3567 &self,
3568 path: P,
3569 format: ObservableFileFormat,
3570 ) -> Result<()> {
3571 match format {
3572 ObservableFileFormat::None => Ok(()),
3573 ObservableFileFormat::Hwu => {
3574 self.write_hwu_file(path)?;
3575 Ok(())
3576 }
3577 ObservableFileFormat::Json => self.snapshot().to_json_file(path),
3578 }
3579 }
3580
3581 fn write_hwu_file<P: AsRef<Path>>(&self, path: P) -> std::io::Result<()> {
3582 let mut writer = BufWriter::new(File::create(path.as_ref())?);
3583 self.state.write_hwu_block(&mut writer)
3584 }
3585
3586 fn flush_sample_contributions(&mut self) {
3587 if self.touched_bins.is_empty()
3588 && self.grouped_underflow.projected_weight == 0.0
3589 && self.grouped_underflow.entry_count == 0
3590 && self.grouped_underflow.mitigated_fill_count == 0
3591 && self.grouped_overflow.projected_weight == 0.0
3592 && self.grouped_overflow.entry_count == 0
3593 && self.grouped_overflow.mitigated_fill_count == 0
3594 {
3595 return;
3596 }
3597
3598 for &bin_index in &self.touched_bins {
3599 let grouped = &self.grouped_contributions[bin_index];
3600 let bin_accumulator = &mut self.state.bins[bin_index];
3601 bin_accumulator.add_sample(
3602 grouped.projected_weight,
3603 grouped.entry_count,
3604 grouped.mitigated_fill_count,
3605 );
3606 }
3607 if self.grouped_underflow.projected_weight != 0.0
3608 || self.grouped_underflow.entry_count != 0
3609 || self.grouped_underflow.mitigated_fill_count != 0
3610 {
3611 self.state.underflow_bin.add_sample(
3612 self.grouped_underflow.projected_weight,
3613 self.grouped_underflow.entry_count,
3614 self.grouped_underflow.mitigated_fill_count,
3615 );
3616 }
3617 if self.grouped_overflow.projected_weight != 0.0
3618 || self.grouped_overflow.entry_count != 0
3619 || self.grouped_overflow.mitigated_fill_count != 0
3620 {
3621 self.state.overflow_bin.add_sample(
3622 self.grouped_overflow.projected_weight,
3623 self.grouped_overflow.entry_count,
3624 self.grouped_overflow.mitigated_fill_count,
3625 );
3626 }
3627 }
3628
3629 fn clear_sample_contributions(&mut self) {
3630 self.grouped_underflow = GroupBinContribution::default();
3631 self.grouped_overflow = GroupBinContribution::default();
3632 for bin_index in self.touched_bins.drain(..) {
3633 self.grouped_contributions[bin_index] = GroupBinContribution::default();
3634 }
3635 }
3636
3637 fn required_clustering_handles(&self) -> Vec<ClusteringHandle> {
3638 let mut handles = Vec::new();
3639 if let Some(handle) = self.definition.required_clustering_handle() {
3640 handles.push(handle);
3641 }
3642 for selector in &self.selections {
3643 if let Some(handle) = selector.required_clustering_handle()
3644 && !handles.contains(&handle)
3645 {
3646 handles.push(handle);
3647 }
3648 }
3649 handles
3650 }
3651}
3652
3653struct HistogramObservableConfig<'a> {
3654 definition: ObservableDefinition,
3655 selections: Vec<ConfiguredSelector>,
3656 quantities: &'a QuantitiesSettings,
3657 quantity_settings: &'a QuantitySettings,
3658 observable_name: &'a str,
3659 settings: &'a ObservableSettings,
3660 selector_settings: &'a SelectorsSettings,
3661 process_info: Option<&'a HistogramProcessInfo>,
3662}
3663
3664fn resolve_discrete_histogram_layout(
3665 quantities: &QuantitiesSettings,
3666 quantity_settings: &QuantitySettings,
3667 observable_name: &str,
3668 histogram: &DiscreteHistogramSettings,
3669 selection_names: &[String],
3670 selector_settings: &SelectorsSettings,
3671 process_info: Option<&HistogramProcessInfo>,
3672) -> Result<(isize, isize, Vec<Option<String>>)> {
3673 let graph_group_context = resolve_graph_group_context(
3674 observable_name,
3675 selection_names,
3676 selector_settings,
3677 quantities,
3678 process_info,
3679 )?;
3680 let (min_bin_id, max_bin_id) = match histogram.domain {
3681 DiscreteBinDomainSettings::ExplicitRange { min, max } => (min, max),
3682 DiscreteBinDomainSettings::SingleBin => (0, 0),
3683 DiscreteBinDomainSettings::GraphIds => {
3684 let info = process_info.ok_or_else(|| {
3685 eyre!(
3686 "Observable '{}' uses GraphIds but no process information is available.",
3687 observable_name
3688 )
3689 })?;
3690 if info.graph_names.is_empty() {
3691 return Err(eyre!(
3692 "Observable '{}' uses GraphIds but the process exposes no graphs.",
3693 observable_name
3694 ));
3695 }
3696 (0, info.graph_names.len() as isize - 1)
3697 }
3698 DiscreteBinDomainSettings::GraphGroupIds => {
3699 let info = process_info.ok_or_else(|| {
3700 eyre!(
3701 "Observable '{}' uses GraphGroupIds but no process information is available.",
3702 observable_name
3703 )
3704 })?;
3705 if info.graph_group_master_names.is_empty() {
3706 return Err(eyre!(
3707 "Observable '{}' uses GraphGroupIds but the process exposes no graph groups.",
3708 observable_name
3709 ));
3710 }
3711 (0, info.graph_group_master_names.len() as isize - 1)
3712 }
3713 DiscreteBinDomainSettings::OrientationIds => {
3714 let info = process_info.ok_or_else(|| {
3715 eyre!(
3716 "Observable '{}' uses OrientationIds but no process information is available.",
3717 observable_name
3718 )
3719 })?;
3720 let group_id = graph_group_context.ok_or_else(|| {
3721 eyre!(
3722 "Observable '{}' uses OrientationIds but no singleton graph-group context could be resolved from its selections.",
3723 observable_name
3724 )
3725 })?;
3726 let labels = info.orientation_labels_by_group.get(group_id).ok_or_else(|| {
3727 eyre!(
3728 "Observable '{}' resolved graph-group {} for OrientationIds, but no orientation metadata is available.",
3729 observable_name,
3730 group_id
3731 )
3732 })?;
3733 (0, labels.len() as isize - 1)
3734 }
3735 DiscreteBinDomainSettings::LmbChannelIds => {
3736 let info = process_info.ok_or_else(|| {
3737 eyre!(
3738 "Observable '{}' uses LmbChannelIds but no process information is available.",
3739 observable_name
3740 )
3741 })?;
3742 let group_id = graph_group_context.ok_or_else(|| {
3743 eyre!(
3744 "Observable '{}' uses LmbChannelIds but no singleton graph-group context could be resolved from its selections.",
3745 observable_name
3746 )
3747 })?;
3748 let labels = info.lmb_channel_labels_by_group.get(group_id).ok_or_else(|| {
3749 eyre!(
3750 "Observable '{}' resolved graph-group {} for LmbChannelIds, but no LMB-channel metadata is available.",
3751 observable_name,
3752 group_id
3753 )
3754 })?;
3755 (0, labels.len() as isize - 1)
3756 }
3757 };
3758 if max_bin_id < min_bin_id {
3759 return Err(eyre!(
3760 "Observable '{}' has an invalid discrete histogram range [{}, {}].",
3761 observable_name,
3762 min_bin_id,
3763 max_bin_id
3764 ));
3765 }
3766 let n_bins = max_bin_id.saturating_sub(min_bin_id) as usize + 1;
3767 let bin_labels = match histogram.labels.as_ref() {
3768 None => {
3769 if matches!(quantity_settings, QuantitySettings::Integral {}) && n_bins == 1 {
3770 vec![Some("total integral".to_string())]
3771 } else {
3772 vec![None; n_bins]
3773 }
3774 }
3775 Some(DiscreteBinLabelsSettings::Custom { labels }) => {
3776 if labels.len() != n_bins {
3777 return Err(eyre!(
3778 "Observable '{}' defines {} discrete labels for {} bins.",
3779 observable_name,
3780 labels.len(),
3781 n_bins
3782 ));
3783 }
3784 labels.iter().cloned().map(Some).collect()
3785 }
3786 Some(DiscreteBinLabelsSettings::BinId) => (0..n_bins)
3787 .map(|offset| Some(format!("#{}", min_bin_id + offset as isize)))
3788 .collect(),
3789 Some(DiscreteBinLabelsSettings::GraphName) => {
3790 if !matches!(quantity_settings, QuantitySettings::GraphId {}) {
3791 return Err(eyre!(
3792 "Observable '{}' uses GraphName labels, but its quantity is not graph_id.",
3793 observable_name
3794 ));
3795 }
3796 let info = process_info.ok_or_else(|| {
3797 eyre!(
3798 "Observable '{}' uses GraphName labels but no process information is available.",
3799 observable_name
3800 )
3801 })?;
3802 info.graph_names.iter().cloned().map(Some).collect()
3803 }
3804 Some(DiscreteBinLabelsSettings::GraphGroupMasterName) => {
3805 if !matches!(quantity_settings, QuantitySettings::GraphGroupId {}) {
3806 return Err(eyre!(
3807 "Observable '{}' uses GraphGroupMasterName labels, but its quantity is not graph_group_id.",
3808 observable_name
3809 ));
3810 }
3811 let info = process_info.ok_or_else(|| {
3812 eyre!(
3813 "Observable '{}' uses GraphGroupMasterName labels but no process information is available.",
3814 observable_name
3815 )
3816 })?;
3817 info.graph_group_master_names
3818 .iter()
3819 .cloned()
3820 .map(Some)
3821 .collect()
3822 }
3823 Some(DiscreteBinLabelsSettings::Orientation) => {
3824 if !matches!(quantity_settings, QuantitySettings::OrientationId {}) {
3825 return Err(eyre!(
3826 "Observable '{}' uses Orientation labels, but its quantity is not orientation_id.",
3827 observable_name
3828 ));
3829 }
3830 let info = process_info.ok_or_else(|| {
3831 eyre!(
3832 "Observable '{}' uses Orientation labels but no process information is available.",
3833 observable_name
3834 )
3835 })?;
3836 let group_id = graph_group_context.ok_or_else(|| {
3837 eyre!(
3838 "Observable '{}' uses Orientation labels but no singleton graph-group context could be resolved from its selections.",
3839 observable_name
3840 )
3841 })?;
3842 info.orientation_labels_by_group
3843 .get(group_id)
3844 .ok_or_else(|| {
3845 eyre!(
3846 "Observable '{}' resolved graph group {} for Orientation labels, but the process only exposes {} graph groups.",
3847 observable_name,
3848 group_id,
3849 info.orientation_labels_by_group.len()
3850 )
3851 })?
3852 .iter()
3853 .cloned()
3854 .map(Some)
3855 .collect()
3856 }
3857 Some(DiscreteBinLabelsSettings::LmbChannelEdgeIds) => {
3858 if !matches!(quantity_settings, QuantitySettings::LmbChannelId {}) {
3859 return Err(eyre!(
3860 "Observable '{}' uses LmbChannelEdgeIds labels, but its quantity is not lmb_channel_id.",
3861 observable_name
3862 ));
3863 }
3864 let info = process_info.ok_or_else(|| {
3865 eyre!(
3866 "Observable '{}' uses LmbChannelEdgeIds labels but no process information is available.",
3867 observable_name
3868 )
3869 })?;
3870 let group_id = graph_group_context.ok_or_else(|| {
3871 eyre!(
3872 "Observable '{}' uses LmbChannelEdgeIds labels but no singleton graph-group context could be resolved from its selections.",
3873 observable_name
3874 )
3875 })?;
3876 info.lmb_channel_labels_by_group
3877 .get(group_id)
3878 .ok_or_else(|| {
3879 eyre!(
3880 "Observable '{}' resolved graph group {} for LmbChannelEdgeIds labels, but the process only exposes {} graph groups.",
3881 observable_name,
3882 group_id,
3883 info.lmb_channel_labels_by_group.len()
3884 )
3885 })?
3886 .iter()
3887 .cloned()
3888 .map(Some)
3889 .collect()
3890 }
3891 };
3892 if bin_labels.len() != n_bins {
3893 return Err(eyre!(
3894 "Observable '{}' resolved {} labels for {} discrete bins.",
3895 observable_name,
3896 bin_labels.len(),
3897 n_bins
3898 ));
3899 }
3900 Ok((min_bin_id, max_bin_id, bin_labels))
3901}
3902
3903fn resolve_graph_group_context(
3904 observable_name: &str,
3905 selection_names: &[String],
3906 selector_settings: &SelectorsSettings,
3907 quantities: &QuantitiesSettings,
3908 process_info: Option<&HistogramProcessInfo>,
3909) -> Result<Option<usize>> {
3910 let mut resolved = None;
3911 for selection_name in selection_names {
3912 let Some(selector) = selector_settings.get(selection_name) else {
3913 return Err(eyre!(
3914 "Observable '{}' references unknown selector '{}'",
3915 observable_name,
3916 selection_name
3917 ));
3918 };
3919 let quantity = quantities.get(&selector.quantity).ok_or_else(|| {
3920 eyre!(
3921 "Observable '{}' selection '{}' references unknown quantity '{}'",
3922 observable_name,
3923 selection_name,
3924 selector.quantity
3925 )
3926 })?;
3927 let candidate = graph_group_context_candidate(
3928 observable_name,
3929 selection_name,
3930 selector,
3931 quantity,
3932 process_info,
3933 )?;
3934 if let Some(candidate) = candidate {
3935 if let Some(existing) = resolved
3936 && existing != candidate
3937 {
3938 return Err(eyre!(
3939 "Observable '{}' resolves conflicting graph-group contexts {} and {} from its selections.",
3940 observable_name,
3941 existing,
3942 candidate
3943 ));
3944 }
3945 resolved = Some(candidate);
3946 }
3947 }
3948
3949 if resolved.is_none()
3950 && let Some(info) = process_info
3951 && info.graph_group_master_names.len() == 1
3952 {
3953 resolved = Some(0);
3954 }
3955
3956 Ok(resolved)
3957}
3958
3959fn graph_group_context_candidate(
3960 observable_name: &str,
3961 selection_name: &str,
3962 selector: &SelectorSettings,
3963 quantity: &QuantitySettings,
3964 process_info: Option<&HistogramProcessInfo>,
3965) -> Result<Option<usize>> {
3966 let SelectorDefinitionSettings::DiscreteRange(DiscreteRangeSelectorSettings {
3967 min: Some(min),
3968 max: Some(max),
3969 }) = &selector.selector
3970 else {
3971 return Ok(None);
3972 };
3973 if min != max {
3974 return Ok(None);
3975 }
3976
3977 match quantity {
3978 QuantitySettings::GraphGroupId {} => {
3979 let group_id = singleton_selector_index(
3980 observable_name,
3981 selection_name,
3982 "graph_group_id",
3983 *min,
3984 process_info.map(|info| info.graph_group_master_names.len()),
3985 )?;
3986 Ok(Some(group_id))
3987 }
3988 QuantitySettings::GraphId {} => {
3989 let info = process_info.ok_or_else(|| {
3990 eyre!(
3991 "Observable '{}' needs process information to map selector '{}' graph_id={} to a graph group.",
3992 observable_name,
3993 selection_name,
3994 min
3995 )
3996 })?;
3997 let graph_id = singleton_selector_index(
3998 observable_name,
3999 selection_name,
4000 "graph_id",
4001 *min,
4002 Some(info.graph_to_group_id.len()),
4003 )?;
4004 Ok(info.graph_to_group_id.get(graph_id).copied())
4005 }
4006 _ => Ok(None),
4007 }
4008}
4009
4010fn singleton_selector_index(
4011 observable_name: &str,
4012 selection_name: &str,
4013 quantity_label: &str,
4014 value: isize,
4015 upper_bound: Option<usize>,
4016) -> Result<usize> {
4017 let index = usize::try_from(value).map_err(|_| {
4018 eyre!(
4019 "Observable '{}' resolves selector '{}' {}={} as graph-group context, but the value must be non-negative.",
4020 observable_name,
4021 selection_name,
4022 quantity_label,
4023 value
4024 )
4025 })?;
4026 if let Some(upper_bound) = upper_bound
4027 && index >= upper_bound
4028 {
4029 return Err(eyre!(
4030 "Observable '{}' resolves selector '{}' {}={} as graph-group context, but the process only exposes {} valid values.",
4031 observable_name,
4032 selection_name,
4033 quantity_label,
4034 value,
4035 upper_bound
4036 ));
4037 }
4038 Ok(index)
4039}
4040
4041impl Observable for HistogramObservable {
4042 fn process_event_groups<T: FloatLike>(
4043 &mut self,
4044 event_groups: &GenericEventGroupList<T>,
4045 clustering_registry: &CompiledClusteringRegistry,
4046 ) {
4047 self.state.new_sample_count += 1;
4048 self.touched_bins.clear();
4049
4050 for event_group in event_groups.iter() {
4051 self.build_group_contributions(event_group, clustering_registry);
4052 self.mitigate_group_misbinning();
4053
4054 for contribution in &self.pending_contributions {
4055 let grouped = &mut self.grouped_contributions[contribution.bin_index];
4056 if grouped.projected_weight == 0.0
4057 && grouped.entry_count == 0
4058 && grouped.mitigated_fill_count == 0
4059 {
4060 self.touched_bins.push(contribution.bin_index);
4061 }
4062 grouped.projected_weight += contribution.projected_weight;
4063 grouped.entry_count += 1;
4064 if contribution.mitigated {
4065 grouped.mitigated_fill_count += 1;
4066 }
4067 }
4068 }
4069
4070 self.flush_sample_contributions();
4071 self.clear_sample_contributions();
4072 }
4073
4074 fn merge_samples(&mut self, other: &mut HistogramObservable) -> Result<()> {
4075 self.state.merge_samples(&mut other.state)
4076 }
4077
4078 fn update_result(&mut self, _iter: usize) {
4079 self.state.update_result();
4080 }
4081}
4082
4083#[derive(Debug, Clone)]
4084pub enum Observables {
4085 Histogram(HistogramObservable),
4086}
4087
4088impl Observables {
4089 #[allow(clippy::too_many_arguments)]
4090 fn from_settings(
4091 name: &str,
4092 settings: &ObservableSettings,
4093 quantities: &QuantitiesSettings,
4094 selector_settings: &SelectorsSettings,
4095 selector_registry: &BTreeMap<String, ConfiguredSelector>,
4096 process_info: Option<&HistogramProcessInfo>,
4097 clustering_registry: &mut CompiledClusteringRegistry,
4098 model: Option<&Model>,
4099 ) -> Result<Self> {
4100 let quantity = quantities.get(&settings.quantity).ok_or_else(|| {
4101 eyre!(
4102 "Observable '{}' references unknown quantity '{}'",
4103 name,
4104 settings.quantity
4105 )
4106 })?;
4107 let selections = settings
4108 .selections
4109 .iter()
4110 .map(|selector_name| {
4111 selector_registry
4112 .get(selector_name)
4113 .cloned()
4114 .ok_or_else(|| {
4115 eyre!(
4116 "Observable '{}' references unknown selector '{}'",
4117 name,
4118 selector_name
4119 )
4120 })
4121 })
4122 .collect::<Result<Vec<_>>>()?;
4123
4124 Ok(Observables::Histogram(HistogramObservable::new(
4125 HistogramObservableConfig {
4126 definition: ObservableDefinition::from_settings(
4127 quantity,
4128 clustering_registry,
4129 model,
4130 )?,
4131 selections,
4132 quantities,
4133 quantity_settings: quantity,
4134 observable_name: name,
4135 settings,
4136 selector_settings,
4137 process_info,
4138 },
4139 )?))
4140 }
4141
4142 pub(crate) fn process_event_groups<T: FloatLike>(
4143 &mut self,
4144 event_groups: &GenericEventGroupList<T>,
4145 clustering_registry: &CompiledClusteringRegistry,
4146 ) {
4147 match self {
4148 Observables::Histogram(observable) => {
4149 observable.process_event_groups(event_groups, clustering_registry)
4150 }
4151 }
4152 }
4153
4154 pub(crate) fn merge_samples(&mut self, other: &mut Observables) -> Result<()> {
4155 match (self, other) {
4156 (Observables::Histogram(lhs), Observables::Histogram(rhs)) => lhs.merge_samples(rhs),
4157 }
4158 }
4159
4160 pub(crate) fn required_clustering_handles(&self) -> Vec<ClusteringHandle> {
4161 match self {
4162 Observables::Histogram(observable) => observable.required_clustering_handles(),
4163 }
4164 }
4165
4166 pub(crate) fn update_result(&mut self, iter: usize) {
4167 match self {
4168 Observables::Histogram(observable) => observable.update_result(iter),
4169 }
4170 }
4171
4172 pub(crate) fn restore_snapshot(&mut self, snapshot: &HistogramSnapshot) -> Result<()> {
4173 match self {
4174 Observables::Histogram(observable) => observable.restore_snapshot(snapshot),
4175 }
4176 }
4177
4178 pub(crate) fn cleared_clone(&self) -> Self {
4179 match self {
4180 Observables::Histogram(observable) => Observables::Histogram(HistogramObservable {
4181 definition: observable.definition.clone(),
4182 selections: observable.selections.clone(),
4183 entry_selection: observable.entry_selection,
4184 entry_index: observable.entry_index,
4185 misbinning_max_normalized_distance: observable.misbinning_max_normalized_distance,
4186 state: observable.state.cleared_clone(),
4187 pending_contributions: Vec::with_capacity(
4188 observable.pending_contributions.capacity(),
4189 ),
4190 candidate_pairs: Vec::with_capacity(observable.candidate_pairs.capacity()),
4191 used_contributions: Vec::with_capacity(observable.used_contributions.capacity()),
4192 grouped_contributions: vec![
4193 GroupBinContribution::default();
4194 observable.grouped_contributions.len()
4195 ],
4196 grouped_underflow: GroupBinContribution::default(),
4197 grouped_overflow: GroupBinContribution::default(),
4198 touched_bins: Vec::with_capacity(observable.touched_bins.capacity()),
4199 }),
4200 }
4201 }
4202
4203 pub(crate) fn snapshot(&self) -> HistogramSnapshot {
4204 match self {
4205 Observables::Histogram(observable) => observable.snapshot(),
4206 }
4207 }
4208
4209 pub(crate) fn accumulator_state(&self) -> HistogramAccumulatorState {
4210 match self {
4211 Observables::Histogram(observable) => observable.state.clone(),
4212 }
4213 }
4214
4215 pub(crate) fn merge_accumulator_state(
4216 &mut self,
4217 other: &mut HistogramAccumulatorState,
4218 ) -> Result<()> {
4219 match self {
4220 Observables::Histogram(observable) => observable.state.merge_samples(other),
4221 }
4222 }
4223}
4224
4225impl HistogramObservable {
4226 fn restore_snapshot(&mut self, snapshot: &HistogramSnapshot) -> Result<()> {
4227 let restored_state = snapshot.clone().into_accumulator_state();
4228 self.state.ensure_compatible(&restored_state)?;
4229 self.state = restored_state;
4230 self.pending_contributions.clear();
4231 self.candidate_pairs.clear();
4232 self.used_contributions.clear();
4233 self.grouped_contributions
4234 .fill(GroupBinContribution::default());
4235 self.grouped_underflow = GroupBinContribution::default();
4236 self.grouped_overflow = GroupBinContribution::default();
4237 self.touched_bins.clear();
4238 Ok(())
4239 }
4240}
4241
4242#[derive(Debug, Clone, Copy)]
4243enum HistogramBinPosition {
4244 Underflow,
4245 Overflow,
4246 InRange(usize),
4247}
4248
4249fn histogram_bin_position(
4250 value: f64,
4251 x_min: f64,
4252 x_max: f64,
4253 num_bins: usize,
4254) -> Option<HistogramBinPosition> {
4255 if num_bins == 0 || x_max <= x_min || !value.is_finite() {
4256 return None;
4257 }
4258
4259 if value < x_min {
4260 return Some(HistogramBinPosition::Underflow);
4261 }
4262 if value >= x_max {
4263 return Some(HistogramBinPosition::Overflow);
4264 }
4265
4266 let index = ((value - x_min) / (x_max - x_min) * num_bins as f64) as usize;
4267 if index < num_bins {
4268 Some(HistogramBinPosition::InRange(index))
4269 } else {
4270 Some(HistogramBinPosition::Overflow)
4271 }
4272}
4273
4274fn histogram_bin_position_discrete(
4275 value: isize,
4276 min_bin_id: isize,
4277 num_bins: usize,
4278) -> HistogramBinPosition {
4279 let max_bin_id = min_bin_id + num_bins as isize - 1;
4280 if value < min_bin_id {
4281 HistogramBinPosition::Underflow
4282 } else if value > max_bin_id {
4283 HistogramBinPosition::Overflow
4284 } else {
4285 HistogramBinPosition::InRange((value - min_bin_id) as usize)
4286 }
4287}
4288
4289fn sort_discrete_snapshot_bins(
4290 bins: &mut [HistogramBinSnapshot],
4291 ordering: DiscreteBinOrdering,
4292 sample_count: usize,
4293) {
4294 bins.sort_by(|lhs, rhs| {
4295 let base = match ordering {
4296 DiscreteBinOrdering::AscendingBinId => Ordering::Equal,
4297 DiscreteBinOrdering::ValueDescending => rhs
4298 .average(sample_count)
4299 .partial_cmp(&lhs.average(sample_count))
4300 .unwrap_or(Ordering::Equal),
4301 DiscreteBinOrdering::AbsValueDescending => rhs
4302 .average(sample_count)
4303 .abs()
4304 .partial_cmp(&lhs.average(sample_count).abs())
4305 .unwrap_or(Ordering::Equal),
4306 };
4307 if base == Ordering::Equal {
4308 lhs.bin_id
4309 .unwrap_or_default()
4310 .cmp(&rhs.bin_id.unwrap_or_default())
4311 } else {
4312 base
4313 }
4314 });
4315}
4316
4317fn unit_complex<T: FloatLike>(reference: &F<T>) -> Complex<F<T>> {
4318 Complex::new(reference.one(), reference.zero())
4319}
4320
4321fn apply_entry_selection<T: FloatLike>(
4322 entries: &[ObservableEntry<T>],
4323 selection: EntrySelection,
4324 entry_index: usize,
4325) -> ObservableEntries<T> {
4326 match selection {
4327 EntrySelection::All => entries.iter().cloned().collect(),
4328 EntrySelection::LeadingOnly => entries.first().into_iter().cloned().collect(),
4329 EntrySelection::NthOnly => entries.get(entry_index).into_iter().cloned().collect(),
4330 }
4331}
4332
4333fn transform_value<T: FloatLike>(value: &F<T>, value_transform: ObservableValueTransform) -> F<T> {
4334 match value_transform {
4335 ObservableValueTransform::Identity => value.clone(),
4336 ObservableValueTransform::Log10 => value.log10(),
4337 }
4338}
4339
4340fn value_in_range(value: f64, min: Option<f64>, max: Option<f64>) -> bool {
4341 if let Some(min) = min
4342 && value < min
4343 {
4344 return false;
4345 }
4346
4347 if let Some(max) = max {
4348 value <= max
4349 } else {
4350 true
4351 }
4352}
4353
4354fn discrete_value_in_range(value: isize, min: Option<isize>, max: Option<isize>) -> bool {
4355 if let Some(min) = min
4356 && value < min
4357 {
4358 return false;
4359 }
4360
4361 if let Some(max) = max {
4362 value <= max
4363 } else {
4364 true
4365 }
4366}
4367
4368fn combined_entry_weight<T: FloatLike>(
4369 event: &GenericEvent<T>,
4370 entry: &ObservableEntry<T>,
4371) -> Complex<F<T>> {
4372 event.weight.clone() * entry.weight_modifier.clone()
4373}
4374
4375impl ObservablePhase {
4376 fn project<T: FloatLike>(&self, weight: &Complex<F<T>>) -> F<T> {
4377 match self {
4378 ObservablePhase::Real => weight.re.clone(),
4379 ObservablePhase::Imag => weight.im.clone(),
4380 }
4381 }
4382}
4383
4384#[cfg(test)]
4385mod tests {
4386 use super::{
4387 ContinuousHistogramSettings, DiscreteBinDomainSettings, DiscreteBinOrdering,
4388 DiscreteRangeSelectorSettings, EntrySelection, HistogramAccumulatorState,
4389 HistogramBinSnapshot, HistogramProcessInfo, HistogramSettings, HistogramSnapshot,
4390 HistogramSnapshotKind, HistogramStatisticsSnapshot, JetClusteringSettings, ObservablePhase,
4391 ObservableSettings, ObservableValueTransform, QuantitySettings, SelectorDefinitionSettings,
4392 SelectorSettings, resolve_graph_group_context,
4393 };
4394 use schemars::schema_for;
4395 use serde_json::Value as JsonValue;
4396 use std::collections::BTreeMap;
4397 use std::fs;
4398
4399 fn sample_histogram_snapshot() -> HistogramSnapshot {
4400 HistogramSnapshot {
4401 kind: HistogramSnapshotKind::Continuous,
4402 title: "top_pt".to_string(),
4403 type_description: "AL".to_string(),
4404 phase: ObservablePhase::Real,
4405 value_transform: ObservableValueTransform::Identity,
4406 supports_misbinning_mitigation: true,
4407 x_min: Some(0.0),
4408 x_max: Some(10.0),
4409 sample_count: 3,
4410 log_x_axis: false,
4411 log_y_axis: true,
4412 discrete_min_bin_id: None,
4413 discrete_ordering: None,
4414 bins: vec![HistogramBinSnapshot {
4415 x_min: Some(0.0),
4416 x_max: Some(10.0),
4417 bin_id: None,
4418 label: None,
4419 entry_count: 3,
4420 sum_weights: 3.75,
4421 sum_weights_squared: 5.625,
4422 mitigated_fill_count: 2,
4423 }],
4424 underflow_bin: HistogramBinSnapshot {
4425 x_min: None,
4426 x_max: Some(0.0),
4427 bin_id: None,
4428 label: None,
4429 entry_count: 1,
4430 sum_weights: 1.5,
4431 sum_weights_squared: 2.25,
4432 mitigated_fill_count: 0,
4433 },
4434 overflow_bin: HistogramBinSnapshot {
4435 x_min: Some(10.0),
4436 x_max: None,
4437 bin_id: None,
4438 label: None,
4439 entry_count: 2,
4440 sum_weights: 0.5,
4441 sum_weights_squared: 0.25,
4442 mitigated_fill_count: 1,
4443 },
4444 statistics: HistogramStatisticsSnapshot {
4445 in_range_entry_count: 3,
4446 nan_value_count: 3,
4447 mitigated_pair_count: 4,
4448 },
4449 }
4450 }
4451
4452 #[test]
4453 fn jet_clustering_settings_normalize_explicit_clustered_pdgs() {
4454 let settings = JetClusteringSettings {
4455 clustered_pdgs: Some(vec![21, -1, 21, 1, -1]),
4456 ..JetClusteringSettings::default()
4457 };
4458 let resolved = settings
4459 .resolve(None)
4460 .expect("explicit clustered_pdgs should not require a model");
4461 assert_eq!(resolved.clustered_pdgs, vec![-1, 1, 21]);
4462 }
4463
4464 #[test]
4465 fn jet_clustering_settings_require_model_for_default_clustered_pdgs() {
4466 let error = JetClusteringSettings::default()
4467 .resolve(None)
4468 .expect_err("default clustered_pdgs should require model context");
4469 assert!(
4470 error
4471 .to_string()
4472 .contains("Cannot resolve default clustered_pdgs without a model")
4473 );
4474 }
4475
4476 #[test]
4477 fn histogram_snapshot_json_round_trip() {
4478 let snapshot = sample_histogram_snapshot();
4479
4480 let file_path = std::env::temp_dir().join(format!(
4481 "gammaloop_histogram_snapshot_{}_{}.json",
4482 std::process::id(),
4483 std::time::SystemTime::now()
4484 .duration_since(std::time::UNIX_EPOCH)
4485 .unwrap()
4486 .as_nanos()
4487 ));
4488
4489 snapshot.to_json_file(&file_path).unwrap();
4490 let loaded = HistogramSnapshot::from_json_file(&file_path).unwrap();
4491 let _ = fs::remove_file(&file_path);
4492
4493 assert_eq!(loaded, snapshot);
4494 }
4495
4496 #[test]
4497 fn histogram_snapshot_rescale_scales_weight_sums_only() {
4498 let snapshot = sample_histogram_snapshot();
4499 let scaled = snapshot.rescaled(2.0);
4500
4501 assert_eq!(scaled.sample_count, snapshot.sample_count);
4502 assert_eq!(scaled.statistics, snapshot.statistics);
4503 assert_eq!(scaled.bins[0].entry_count, snapshot.bins[0].entry_count);
4504 assert_eq!(
4505 scaled.bins[0].mitigated_fill_count,
4506 snapshot.bins[0].mitigated_fill_count
4507 );
4508 assert_eq!(scaled.bins[0].sum_weights, 7.5);
4509 assert_eq!(scaled.bins[0].sum_weights_squared, 22.5);
4510 assert_eq!(scaled.underflow_bin.sum_weights, 3.0);
4511 assert_eq!(scaled.underflow_bin.sum_weights_squared, 9.0);
4512 assert_eq!(scaled.overflow_bin.sum_weights, 1.0);
4513 assert_eq!(scaled.overflow_bin.sum_weights_squared, 1.0);
4514 assert_eq!(
4515 scaled.bins[0].average(scaled.sample_count),
4516 snapshot.bins[0].average(snapshot.sample_count) * 2.0
4517 );
4518 assert_eq!(
4519 scaled.bins[0].error(scaled.sample_count),
4520 snapshot.bins[0].error(snapshot.sample_count) * 2.0
4521 );
4522 }
4523
4524 #[test]
4525 fn histogram_snapshot_hwu_header_uses_type_description() {
4526 let mut snapshot = sample_histogram_snapshot();
4527 snapshot.type_description = "SB".to_string();
4528
4529 let mut output = Vec::new();
4530 snapshot.write_hwu_block(&mut output).unwrap();
4531 let output = String::from_utf8(output).unwrap();
4532
4533 assert!(output.contains("|TYPE@SB\""), "{output}");
4534 }
4535
4536 #[test]
4537 fn observable_settings_infer_discrete_histogram_from_domain() {
4538 let settings: ObservableSettings = toml::from_str(
4539 r#"
4540quantity = "integral"
4541domain = { type = "single_bin" }
4542"#,
4543 )
4544 .unwrap();
4545
4546 let HistogramSettings::Discrete(histogram) = settings.histogram else {
4547 panic!("expected discrete histogram");
4548 };
4549 assert_eq!(histogram.domain, DiscreteBinDomainSettings::SingleBin);
4550 }
4551
4552 #[test]
4553 fn observable_settings_serialize_flat_histogram_fields() {
4554 let settings = ObservableSettings {
4555 quantity: "integral".to_string(),
4556 selections: vec!["graph_cut".to_string()],
4557 entry_selection: super::EntrySelection::All,
4558 entry_index: 0,
4559 value_transform: ObservableValueTransform::Identity,
4560 phase: ObservablePhase::Real,
4561 misbinning_max_normalized_distance: None,
4562 histogram: HistogramSettings::Continuous(ContinuousHistogramSettings {
4563 x_min: 0.0,
4564 x_max: 1.0,
4565 n_bins: 1,
4566 log_x_axis: false,
4567 log_y_axis: true,
4568 title: Some("xs".to_string()),
4569 type_description: "AL".to_string(),
4570 }),
4571 };
4572
4573 let serialized = toml::to_string(&settings).unwrap();
4574 assert!(serialized.contains("kind = \"continuous\""));
4575 assert!(serialized.contains("quantity = \"integral\""));
4576 assert!(serialized.contains("n_bins = 1"));
4577 assert!(!serialized.contains("histogram"));
4578 }
4579
4580 #[test]
4581 fn observable_settings_schema_is_flat() {
4582 let schema = serde_json::to_value(schema_for!(ObservableSettings)).unwrap();
4583 let properties = schema
4584 .get("properties")
4585 .and_then(JsonValue::as_object)
4586 .expect("observable schema properties");
4587 assert!(properties.contains_key("kind"));
4588 assert!(properties.contains_key("quantity"));
4589 assert!(properties.contains_key("domain"));
4590 assert!(!properties.contains_key("histogram"));
4591 }
4592
4593 #[test]
4594 fn selector_settings_schema_is_flat() {
4595 let schema = serde_json::to_string(&schema_for!(SelectorSettings)).unwrap();
4596 assert!(schema.contains("\"selector\""));
4597 assert!(schema.contains("\"quantity\""));
4598 assert!(!schema.contains("\"histogram\""));
4599 }
4600
4601 #[test]
4602 fn selector_settings_serialize_flat_discrete_range() {
4603 let settings = SelectorSettings {
4604 quantity: "graph_id".to_string(),
4605 active: false,
4606 entry_selection: super::EntrySelection::All,
4607 entry_index: 0,
4608 selector: SelectorDefinitionSettings::DiscreteRange(DiscreteRangeSelectorSettings {
4609 min: Some(3),
4610 max: Some(3),
4611 }),
4612 };
4613
4614 let serialized = toml::to_string(&settings).unwrap();
4615 assert!(serialized.contains("selector = \"discrete_range\""));
4616 assert!(serialized.contains("active = false"));
4617 assert!(serialized.contains("min = 3"));
4618 assert!(!serialized.contains("selector = {"));
4619 }
4620
4621 #[test]
4622 fn selector_settings_deserialize_flat_discrete_range() {
4623 let settings: SelectorSettings = toml::from_str(
4624 r#"
4625quantity = "graph_id"
4626active = false
4627selector = "discrete_range"
4628min = 0
4629max = 0
4630"#,
4631 )
4632 .unwrap();
4633
4634 assert_eq!(settings.quantity, "graph_id");
4635 assert!(!settings.active);
4636 match settings.selector {
4637 SelectorDefinitionSettings::DiscreteRange(selector) => {
4638 assert_eq!(selector.min, Some(0));
4639 assert_eq!(selector.max, Some(0));
4640 }
4641 other => panic!("expected discrete-range selector, got {other:?}"),
4642 }
4643 }
4644
4645 #[test]
4646 fn discrete_histogram_ordering_changes_presentation_only() {
4647 let mut histogram = HistogramAccumulatorState::discrete(
4648 "jet_count".to_string(),
4649 "AL".to_string(),
4650 ObservablePhase::Real,
4651 0,
4652 1,
4653 DiscreteBinOrdering::AscendingBinId,
4654 true,
4655 vec![Some("zero".to_string()), Some("one".to_string())],
4656 )
4657 .unwrap();
4658 histogram
4659 .fill_discrete_sample(&[(0, 1.0), (1, 3.0)])
4660 .unwrap();
4661 histogram.update_results();
4662
4663 let ascending = histogram.snapshot();
4664 assert_eq!(ascending.bins[0].bin_id, Some(0));
4665 assert_eq!(ascending.bins[1].bin_id, Some(1));
4666 assert_eq!(ascending.discrete_min_bin_id, Some(0));
4667
4668 histogram
4669 .change_bin_ordering(DiscreteBinOrdering::ValueDescending)
4670 .unwrap();
4671 let reordered = histogram.snapshot();
4672 assert_eq!(reordered.bins[0].bin_id, Some(1));
4673 assert_eq!(reordered.bins[1].bin_id, Some(0));
4674 assert_eq!(reordered.bins[0].label.as_deref(), Some("one"));
4675 assert_eq!(reordered.bins[1].label.as_deref(), Some("zero"));
4676 assert_eq!(reordered.rebin(99).unwrap(), reordered);
4677 }
4678
4679 #[test]
4680 fn discrete_histogram_constructor_rejects_invalid_range() {
4681 let err = HistogramAccumulatorState::discrete(
4682 "bad_range".to_string(),
4683 "AL".to_string(),
4684 ObservablePhase::Real,
4685 3,
4686 1,
4687 DiscreteBinOrdering::AscendingBinId,
4688 true,
4689 Vec::new(),
4690 )
4691 .unwrap_err();
4692
4693 assert!(err.to_string().contains("invalid discrete histogram range"));
4694 }
4695
4696 #[test]
4697 fn resolve_graph_group_context_uses_selector_quantity_type() {
4698 let quantities = BTreeMap::from([("my_graph".to_string(), QuantitySettings::GraphId {})]);
4699 let selectors = BTreeMap::from([(
4700 "graph_only".to_string(),
4701 SelectorSettings {
4702 quantity: "my_graph".to_string(),
4703 active: false,
4704 entry_selection: EntrySelection::All,
4705 entry_index: 0,
4706 selector: SelectorDefinitionSettings::DiscreteRange(
4707 DiscreteRangeSelectorSettings {
4708 min: Some(1),
4709 max: Some(1),
4710 },
4711 ),
4712 },
4713 )]);
4714 let process_info = HistogramProcessInfo {
4715 graph_names: vec!["g0".to_string(), "g1".to_string()],
4716 graph_to_group_id: vec![0, 2],
4717 graph_group_master_names: vec![
4718 "group0".to_string(),
4719 "group1".to_string(),
4720 "group2".to_string(),
4721 ],
4722 orientation_labels_by_group: vec![Vec::new(), Vec::new(), Vec::new()],
4723 lmb_channel_labels_by_group: vec![Vec::new(), Vec::new(), Vec::new()],
4724 };
4725
4726 let resolved = resolve_graph_group_context(
4727 "obs",
4728 &["graph_only".to_string()],
4729 &selectors,
4730 &quantities,
4731 Some(&process_info),
4732 )
4733 .unwrap();
4734
4735 assert_eq!(resolved, Some(2));
4736 }
4737
4738 #[test]
4739 fn resolve_graph_group_context_rejects_negative_singleton_values() {
4740 let quantities =
4741 BTreeMap::from([("my_group".to_string(), QuantitySettings::GraphGroupId {})]);
4742 let selectors = BTreeMap::from([(
4743 "negative_group".to_string(),
4744 SelectorSettings {
4745 quantity: "my_group".to_string(),
4746 active: false,
4747 entry_selection: EntrySelection::All,
4748 entry_index: 0,
4749 selector: SelectorDefinitionSettings::DiscreteRange(
4750 DiscreteRangeSelectorSettings {
4751 min: Some(-1),
4752 max: Some(-1),
4753 },
4754 ),
4755 },
4756 )]);
4757 let process_info = HistogramProcessInfo {
4758 graph_names: Vec::new(),
4759 graph_to_group_id: Vec::new(),
4760 graph_group_master_names: vec!["group0".to_string()],
4761 orientation_labels_by_group: vec![Vec::new()],
4762 lmb_channel_labels_by_group: vec![Vec::new()],
4763 };
4764
4765 let err = resolve_graph_group_context(
4766 "obs",
4767 &["negative_group".to_string()],
4768 &selectors,
4769 &quantities,
4770 Some(&process_info),
4771 )
4772 .unwrap_err();
4773
4774 assert!(err.to_string().contains("must be non-negative"));
4775 }
4776}