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