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