Skip to main content

gammalooprs/observables/
mod.rs

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    /// Determines whether bins represent continuous ranges or discrete identifiers.
1988    pub kind: HistogramSnapshotKind,
1989    /// Human-readable histogram title used by JSON and HwU output.
1990    pub title: String,
1991    /// HwU `TYPE@` description retained with the histogram metadata.
1992    pub type_description: String,
1993    /// Real or imaginary component of the complex event weight accumulated in this histogram.
1994    pub phase: ObservablePhase,
1995    /// Transformation applied to observable coordinates before continuous binning.
1996    pub value_transform: ObservableValueTransform,
1997    /// Whether the observable supports paired fills that mitigate boundary misbinning.
1998    pub supports_misbinning_mitigation: bool,
1999    /// Lower bound of a continuous histogram, or `None` for a discrete histogram.
2000    pub x_min: Option<f64>,
2001    /// Upper bound of a continuous histogram, or `None` for a discrete histogram.
2002    pub x_max: Option<f64>,
2003    /// Number of statistically independent Monte Carlo samples represented by the snapshot.
2004    pub sample_count: usize,
2005    /// Whether compatible renderers should display the horizontal axis logarithmically.
2006    pub log_x_axis: bool,
2007    /// Whether compatible renderers should display the vertical axis logarithmically.
2008    pub log_y_axis: bool,
2009    /// Smallest discrete bin identifier, or `None` for a continuous histogram.
2010    pub discrete_min_bin_id: Option<isize>,
2011    /// Display ordering for discrete bins, or `None` for a continuous histogram.
2012    pub discrete_ordering: Option<DiscreteBinOrdering>,
2013    /// Raw statistics for every in-range bin.
2014    pub bins: Vec<HistogramBinSnapshot>,
2015    /// Raw statistics for entries below the configured histogram range.
2016    pub underflow_bin: HistogramBinSnapshot,
2017    /// Raw statistics for entries above the configured histogram range.
2018    pub overflow_bin: HistogramBinSnapshot,
2019    /// Histogram-wide entry, non-finite-value, and mitigation counters.
2020    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    /// Determines whether bins represent continuous ranges or discrete identifiers.
2273    pub kind: HistogramSnapshotKind,
2274    /// Human-readable histogram title used by snapshots and exported output.
2275    pub title: String,
2276    /// HwU `TYPE@` description retained in generated snapshots.
2277    pub type_description: String,
2278    /// Real or imaginary component of the complex event weight accumulated in this histogram.
2279    pub phase: ObservablePhase,
2280    /// Transformation applied to observable coordinates before continuous binning.
2281    pub value_transform: ObservableValueTransform,
2282    /// Whether the observable supports paired fills that mitigate boundary misbinning.
2283    pub supports_misbinning_mitigation: bool,
2284    /// Lower continuous bound, or the smallest discrete bin identifier as an `f64`.
2285    pub x_min: f64,
2286    /// Upper continuous bound, or one past the largest discrete bin identifier as an `f64`.
2287    pub x_max: f64,
2288    /// Number of committed statistically independent Monte Carlo samples.
2289    pub sample_count: usize,
2290    new_sample_count: usize,
2291    /// Whether compatible renderers should display the horizontal axis logarithmically.
2292    pub log_x_axis: bool,
2293    /// Whether compatible renderers should display the vertical axis logarithmically.
2294    pub log_y_axis: bool,
2295    /// Smallest discrete bin identifier, or `None` for a continuous histogram.
2296    pub discrete_min_bin_id: Option<isize>,
2297    /// Display ordering for discrete bins, or `None` for a continuous histogram.
2298    pub discrete_ordering: Option<DiscreteBinOrdering>,
2299    /// Optional labels indexed by bin offset, primarily used by discrete histograms.
2300    pub bin_labels: Vec<Option<String>>,
2301    /// Per-bin raw-stat accumulators in continuous or ascending discrete-bin order.
2302    pub bins: Vec<ObservableBinAccumulator>,
2303    /// Raw-stat accumulator for entries below the configured histogram range.
2304    pub underflow_bin: ObservableBinAccumulator,
2305    /// Raw-stat accumulator for entries above the configured histogram range.
2306    pub overflow_bin: ObservableBinAccumulator,
2307    /// Histogram-wide entry, non-finite-value, and mitigation counters.
2308    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}