Skip to main content

laddu_likelihood/
cross_section.rs

1//! High-level cross-section analyses and uncertainty propagation.
2
3use std::{
4    collections::HashMap,
5    sync::{
6        Arc, Mutex,
7        atomic::{AtomicU64, Ordering},
8    },
9};
10
11use auto_ops::impl_op_ex;
12use laddu_data::data::Dataset;
13use laddu_expr::{Expr, ExprNodeStructuralKey};
14use laddu_runtime::{DatasetExprExt, Execution};
15use rayon::prelude::*;
16
17use crate::{CrossSectionIntegrals, Likelihood, LikelihoodError, LikelihoodResult};
18
19static NEXT_SOURCE_ID: AtomicU64 = AtomicU64::new(1);
20
21/// Returns a process-local identifier for an independent uncertainty source.
22pub fn next_uncertainty_source_id() -> u64 {
23    NEXT_SOURCE_ID.fetch_add(1, Ordering::Relaxed)
24}
25
26fn invalid(message: impl Into<String>) -> LikelihoodError {
27    LikelihoodError::InvalidCrossSection(message.into())
28}
29
30#[cfg(test)]
31thread_local! {
32    static SELECTION_INTENSITY_EVALUATIONS: std::cell::Cell<usize> = const {
33        std::cell::Cell::new(0)
34    };
35    static PREPARED_INTENSITY_EVALUATIONS: std::cell::Cell<usize> = const {
36        std::cell::Cell::new(0)
37    };
38    static BIN_ASSIGNMENT_EVALUATIONS: std::cell::Cell<usize> = const {
39        std::cell::Cell::new(0)
40    };
41}
42
43fn record_selection_intensity_evaluation() {
44    #[cfg(test)]
45    SELECTION_INTENSITY_EVALUATIONS.with(|count| count.set(count.get() + 1));
46}
47
48fn record_prepared_intensity_evaluation() {
49    #[cfg(test)]
50    PREPARED_INTENSITY_EVALUATIONS.with(|count| count.set(count.get() + 1));
51}
52
53fn record_bin_assignment_evaluation() {
54    #[cfg(test)]
55    BIN_ASSIGNMENT_EVALUATIONS.with(|count| count.set(count.get() + 1));
56}
57
58#[cfg(test)]
59fn reset_selection_intensity_evaluation_count() {
60    SELECTION_INTENSITY_EVALUATIONS.with(|count| count.set(0));
61}
62
63#[cfg(test)]
64fn selection_intensity_evaluation_count() -> usize {
65    SELECTION_INTENSITY_EVALUATIONS.with(std::cell::Cell::get)
66}
67
68#[cfg(test)]
69fn reset_projection_evaluation_counts() {
70    PREPARED_INTENSITY_EVALUATIONS.with(|count| count.set(0));
71    BIN_ASSIGNMENT_EVALUATIONS.with(|count| count.set(0));
72}
73
74#[cfg(test)]
75fn projection_evaluation_counts() -> (usize, usize) {
76    let intensities = PREPARED_INTENSITY_EVALUATIONS.with(std::cell::Cell::get);
77    let assignments = BIN_ASSIGNMENT_EVALUATIONS.with(std::cell::Cell::get);
78    (intensities, assignments)
79}
80
81/// Named parameter draws with optional paired bootstrap likelihood replicas.
82#[derive(Clone)]
83pub struct Ensemble {
84    parameter_names: Vec<String>,
85    draws: Vec<Vec<f64>>,
86    source_id: u64,
87    replicas: Vec<Arc<Likelihood>>,
88    replicas_share_event_rows: bool,
89}
90
91/// Failure while constructing a paired bootstrap-fit ensemble.
92#[derive(Debug, thiserror::Error)]
93pub enum BootstrapFitError<E> {
94    /// A likelihood replica or final ensemble could not be prepared.
95    #[error(transparent)]
96    Likelihood(#[from] LikelihoodError),
97    /// The user-supplied fit operation failed for one replica.
98    #[error("bootstrap fit {index} failed: {source}")]
99    Fit {
100        /// Zero-based bootstrap replica index.
101        index: usize,
102        /// Fit error returned by the supplied operation.
103        #[source]
104        source: E,
105    },
106}
107
108impl std::fmt::Debug for Ensemble {
109    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        formatter
111            .debug_struct("Ensemble")
112            .field("parameter_names", &self.parameter_names)
113            .field("draws", &self.draws)
114            .field("source_id", &self.source_id)
115            .field("replicas", &self.replicas.len())
116            .field("replicas_share_event_rows", &self.replicas_share_event_rows)
117            .finish()
118    }
119}
120
121impl Ensemble {
122    /// Constructs an ensemble from rows of free-parameter values.
123    ///
124    /// # Errors
125    /// Returns an error for empty, non-finite, or incorrectly sized draws.
126    pub fn new(parameter_names: Vec<String>, draws: Vec<Vec<f64>>) -> LikelihoodResult<Self> {
127        Self::with_source_id(parameter_names, draws, next_uncertainty_source_id())
128    }
129
130    /// Constructs an ensemble with an explicit correlation/provenance ID.
131    ///
132    /// # Errors
133    /// Returns an error for empty, non-finite, or incorrectly sized draws.
134    pub fn with_source_id(
135        parameter_names: Vec<String>,
136        draws: Vec<Vec<f64>>,
137        source_id: u64,
138    ) -> LikelihoodResult<Self> {
139        if draws.is_empty() {
140            return Err(invalid("an ensemble must contain at least one draw"));
141        }
142        if draws
143            .iter()
144            .any(|draw| draw.len() != parameter_names.len() || draw.iter().any(|v| !v.is_finite()))
145        {
146            return Err(invalid(
147                "every ensemble draw must be finite and match the parameter-name count",
148            ));
149        }
150        Ok(Self {
151            parameter_names,
152            draws,
153            source_id,
154            replicas: Vec::new(),
155            replicas_share_event_rows: false,
156        })
157    }
158
159    /// Constructs a paired bootstrap ensemble.
160    ///
161    /// # Errors
162    /// Returns an error when draws are invalid or replica counts differ.
163    pub fn with_replicas(
164        parameter_names: Vec<String>,
165        draws: Vec<Vec<f64>>,
166        replicas: Vec<Arc<Likelihood>>,
167    ) -> LikelihoodResult<Self> {
168        let mut ensemble = Self::new(parameter_names, draws)?;
169        if replicas.len() != ensemble.draws.len() {
170            return Err(invalid(
171                "bootstrap replica count must match the parameter draw count",
172            ));
173        }
174        ensemble.replicas = replicas;
175        Ok(ensemble)
176    }
177
178    /// Flattens a `(walkers, steps, parameters)` chain after burn-in and thinning.
179    ///
180    /// # Errors
181    /// Returns an error for invalid thinning, discard, or draw shapes.
182    pub fn from_chain(
183        parameter_names: Vec<String>,
184        chain: &[Vec<Vec<f64>>],
185        discard: usize,
186        thin: usize,
187    ) -> LikelihoodResult<Self> {
188        if thin == 0 {
189            return Err(invalid("MCMC thinning must be positive"));
190        }
191        if chain.is_empty()
192            || chain
193                .iter()
194                .any(|walker| discard >= walker.len() || walker.is_empty())
195        {
196            return Err(invalid(
197                "MCMC discard must leave at least one step in every walker",
198            ));
199        }
200        let draws = chain
201            .iter()
202            .flat_map(|walker| {
203                (discard..walker.len())
204                    .step_by(thin)
205                    .map(|step| walker[step].clone())
206            })
207            .collect();
208        Self::new(parameter_names, draws)
209    }
210
211    /// Poisson-bootstraps a likelihood and fits every paired replica.
212    ///
213    /// The callback receives the prepared replica and its zero-based index.
214    /// Its returned free-parameter vector is retained beside that exact
215    /// likelihood, ensuring later cross-section evaluations use the matching
216    /// resampled dataset.
217    ///
218    /// # Errors
219    /// Returns an error when replica preparation, fitting, or validation fails.
220    pub fn bootstrap_fit<E>(
221        likelihood: &Arc<Likelihood>,
222        samples: usize,
223        seed: u64,
224        mut fit: impl FnMut(&Arc<Likelihood>, usize) -> Result<Vec<f64>, E>,
225    ) -> Result<Self, BootstrapFitError<E>> {
226        if samples == 0 {
227            return Err(BootstrapFitError::Likelihood(invalid(
228                "bootstrap sample count must be positive",
229            )));
230        }
231        let parameter_names = likelihood
232            .params()
233            .free_params()
234            .iter()
235            .map(|id| likelihood.params().name(*id).map(str::to_owned))
236            .collect::<Result<Vec<_>, _>>()
237            .map_err(LikelihoodError::from)?;
238        let mut draws = Vec::with_capacity(samples);
239        let mut replicas = Vec::with_capacity(samples);
240        for index in 0..samples {
241            let replica = Arc::new(likelihood.bootstrap(seed.wrapping_add(index as u64))?);
242            let draw =
243                fit(&replica, index).map_err(|source| BootstrapFitError::Fit { index, source })?;
244            draws.push(draw);
245            replicas.push(replica);
246        }
247        let mut ensemble = Self::with_replicas(parameter_names, draws, replicas)?;
248        ensemble.replicas_share_event_rows = true;
249        Ok(ensemble)
250    }
251
252    /// Parameter names in draw-column order.
253    pub fn parameter_names(&self) -> &[String] {
254        &self.parameter_names
255    }
256
257    /// Parameter draw rows.
258    pub fn draws(&self) -> &[Vec<f64>] {
259        &self.draws
260    }
261
262    /// Correlation/provenance identifier.
263    pub fn source_id(&self) -> u64 {
264        self.source_id
265    }
266
267    /// Paired bootstrap likelihood replicas, if present.
268    pub fn replicas(&self) -> &[Arc<Likelihood>] {
269        &self.replicas
270    }
271
272    fn replica_bin_assignments(
273        &self,
274        dataset: Option<&Dataset>,
275        axes: &[Axis],
276        execution: &Execution,
277    ) -> LikelihoodResult<Option<BinAssignments>> {
278        if self.replicas_share_event_rows {
279            Ok(None)
280        } else {
281            dataset
282                .map(|dataset| evaluate_bin_assignments(dataset, axes, execution))
283                .transpose()
284        }
285    }
286
287    /// Number of draws.
288    pub fn len(&self) -> usize {
289        self.draws.len()
290    }
291
292    /// Whether no draws are present.
293    pub fn is_empty(&self) -> bool {
294        self.draws.is_empty()
295    }
296}
297
298/// A central scalar estimate with optional uncertainty draws.
299#[derive(Clone, Debug, PartialEq)]
300pub struct Estimate {
301    central: f64,
302    draws: Vec<f64>,
303    source_id: Option<u64>,
304}
305
306impl Estimate {
307    /// Constructs a central-only estimate.
308    ///
309    /// # Errors
310    /// Returns an error when the central value is not finite.
311    pub fn central(central: f64) -> LikelihoodResult<Self> {
312        Self::with_source_id(central, Vec::new(), None)
313    }
314
315    /// Constructs an estimate with an independent uncertainty source.
316    ///
317    /// # Errors
318    /// Returns an error when the central value or a draw is not finite.
319    pub fn new(central: f64, draws: Vec<f64>) -> LikelihoodResult<Self> {
320        let source_id = (!draws.is_empty()).then(next_uncertainty_source_id);
321        Self::with_source_id(central, draws, source_id)
322    }
323
324    /// Constructs an estimate with an explicit correlation/provenance ID.
325    ///
326    /// # Errors
327    /// Returns an error when the central value or a draw is not finite.
328    pub fn with_source_id(
329        central: f64,
330        draws: Vec<f64>,
331        source_id: Option<u64>,
332    ) -> LikelihoodResult<Self> {
333        if !central.is_finite() || draws.iter().any(|value| !value.is_finite()) {
334            return Err(invalid("estimate central value and draws must be finite"));
335        }
336        Ok(Self {
337            central,
338            draws,
339            source_id,
340        })
341    }
342
343    fn from_evaluation(central: f64, draws: Vec<f64>, source_id: Option<u64>) -> Self {
344        Self {
345            central,
346            draws,
347            source_id,
348        }
349    }
350
351    /// Central estimate.
352    pub fn value(&self) -> f64 {
353        self.central
354    }
355
356    /// Uncertainty draws.
357    pub fn draws(&self) -> &[f64] {
358        &self.draws
359    }
360
361    /// Correlation/provenance identifier.
362    pub fn source_id(&self) -> Option<u64> {
363        self.source_id
364    }
365
366    /// Draw mean.
367    ///
368    /// # Errors
369    /// Returns an error when there are no uncertainty draws.
370    pub fn mean(&self) -> LikelihoodResult<f64> {
371        if self.draws.is_empty() {
372            return Err(invalid("estimate has no uncertainty draws"));
373        }
374        Ok(self.draws.iter().sum::<f64>() / self.draws.len() as f64)
375    }
376
377    /// Draw standard deviation using Bessel's correction.
378    ///
379    /// # Errors
380    /// Returns an error when fewer than two draws are available.
381    pub fn std(&self) -> LikelihoodResult<f64> {
382        if self.draws.len() < 2 {
383            return Err(invalid("estimate needs at least two uncertainty draws"));
384        }
385        let mean = self.mean()?;
386        Ok((self
387            .draws
388            .iter()
389            .map(|value| (value - mean).powi(2))
390            .sum::<f64>()
391            / (self.draws.len() - 1) as f64)
392            .sqrt())
393    }
394
395    /// Linearly interpolated draw quantile.
396    ///
397    /// # Errors
398    /// Returns an error for an invalid probability or absent draws.
399    pub fn quantile(&self, probability: f64) -> LikelihoodResult<f64> {
400        if !(0.0..=1.0).contains(&probability) {
401            return Err(invalid("quantile probability must lie in [0, 1]"));
402        }
403        if self.draws.is_empty() {
404            return Err(invalid("estimate has no uncertainty draws"));
405        }
406        let mut values = self.draws.clone();
407        values.sort_by(f64::total_cmp);
408        let position = probability * (values.len() - 1) as f64;
409        let lower = position.floor() as usize;
410        let upper = position.ceil() as usize;
411        let fraction = position - lower as f64;
412        Ok(values[lower] * (1.0 - fraction) + values[upper] * fraction)
413    }
414
415    /// Median draw.
416    ///
417    /// # Errors
418    /// Returns an error when there are no uncertainty draws.
419    pub fn median(&self) -> LikelihoodResult<f64> {
420        self.quantile(0.5)
421    }
422
423    /// Equal-tailed uncertainty interval.
424    ///
425    /// # Errors
426    /// Returns an error for an invalid level or absent draws.
427    pub fn interval(&self, level: f64) -> LikelihoodResult<(f64, f64)> {
428        if !(0.0 < level && level < 1.0) {
429            return Err(invalid("interval level must lie in (0, 1)"));
430        }
431        let tail = (1.0 - level) * 0.5;
432        Ok((self.quantile(tail)?, self.quantile(1.0 - tail)?))
433    }
434
435    fn binary(&self, other: &Self, op: impl Fn(f64, f64) -> f64) -> Self {
436        let count = match (self.draws.len(), other.draws.len()) {
437            (0, 0) => 0,
438            (0, right) => right,
439            (left, 0) => left,
440            (left, right) => left.min(right),
441        };
442        let draws = (0..count)
443            .map(|index| {
444                let left = self.draws.get(index).copied().unwrap_or(self.central);
445                let right_index = if self.source_id == other.source_id {
446                    index
447                } else {
448                    (index.wrapping_mul(6364136223846793005usize).wrapping_add(1))
449                        % other.draws.len().max(1)
450                };
451                let right = other
452                    .draws
453                    .get(right_index)
454                    .copied()
455                    .unwrap_or(other.central);
456                op(left, right)
457            })
458            .collect();
459        let source_id = match (self.draws.is_empty(), other.draws.is_empty()) {
460            (false, true) => self.source_id,
461            (true, false) => other.source_id,
462            (false, false) if self.source_id == other.source_id => self.source_id,
463            (false, false) => Some(next_uncertainty_source_id()),
464            (true, true) => None,
465        };
466        Self::from_evaluation(op(self.central, other.central), draws, source_id)
467    }
468}
469
470impl_op_ex!(+ |left: &Estimate, right: &Estimate| -> Estimate {
471    left.binary(right, |a, b| a + b)
472});
473impl_op_ex!(-|left: &Estimate, right: &Estimate| -> Estimate { left.binary(right, |a, b| a - b) });
474impl_op_ex!(*|left: &Estimate, right: &Estimate| -> Estimate { left.binary(right, |a, b| a * b) });
475impl_op_ex!(/ |left: &Estimate, right: &Estimate| -> Estimate {
476    left.binary(right, |a, b| a / b)
477});
478
479impl_op_ex!(+ |left: &Estimate, right: &f64| -> Estimate {
480    left.binary(
481        &Estimate::from_evaluation(*right, Vec::new(), None),
482        |a, b| a + b,
483    )
484});
485impl_op_ex!(-|left: &Estimate, right: &f64| -> Estimate {
486    left.binary(
487        &Estimate::from_evaluation(*right, Vec::new(), None),
488        |a, b| a - b,
489    )
490});
491impl_op_ex!(*|left: &Estimate, right: &f64| -> Estimate {
492    left.binary(
493        &Estimate::from_evaluation(*right, Vec::new(), None),
494        |a, b| a * b,
495    )
496});
497impl_op_ex!(/ |left: &Estimate, right: &f64| -> Estimate {
498    left.binary(
499        &Estimate::from_evaluation(*right, Vec::new(), None),
500        |a, b| a / b,
501    )
502});
503
504/// An expression and monotonically increasing bin edges.
505#[derive(Clone, Debug)]
506pub struct Axis {
507    expression: Expr,
508    edges: Vec<f64>,
509}
510
511impl Axis {
512    /// Constructs a differential axis.
513    ///
514    /// # Errors
515    /// Returns an error unless edges are finite and strictly increasing.
516    pub fn new(expression: Expr, edges: Vec<f64>) -> LikelihoodResult<Self> {
517        if edges.len() < 2
518            || edges.iter().any(|value| !value.is_finite())
519            || edges.windows(2).any(|pair| pair[0] >= pair[1])
520        {
521            return Err(invalid(
522                "axis edges must contain at least two finite increasing values",
523            ));
524        }
525        Ok(Self { expression, edges })
526    }
527
528    /// Axis expression.
529    pub fn expression(&self) -> &Expr {
530        &self.expression
531    }
532
533    /// Bin edges.
534    pub fn edges(&self) -> &[f64] {
535        &self.edges
536    }
537
538    /// Number of bins.
539    pub fn bins(&self) -> usize {
540        self.edges.len() - 1
541    }
542}
543
544/// Central bin values with optional uncertainty draws.
545#[derive(Clone, Debug, PartialEq)]
546pub struct BinnedEstimate {
547    central: Vec<f64>,
548    draws: Vec<Vec<f64>>,
549}
550
551impl BinnedEstimate {
552    fn new(central: Vec<f64>, draws: Vec<Vec<f64>>) -> Self {
553        Self { central, draws }
554    }
555
556    /// Central flattened bin values.
557    pub fn values(&self) -> &[f64] {
558        &self.central
559    }
560
561    /// Flattened bin values for every uncertainty draw.
562    pub fn draws(&self) -> &[Vec<f64>] {
563        &self.draws
564    }
565
566    /// Equal-tailed interval for every bin.
567    ///
568    /// # Errors
569    /// Returns an error for an invalid level or absent draws.
570    pub fn interval(&self, level: f64) -> LikelihoodResult<(Vec<f64>, Vec<f64>)> {
571        if self.draws.is_empty() {
572            return Err(invalid("binned estimate has no uncertainty draws"));
573        }
574        let mut lower = Vec::with_capacity(self.central.len());
575        let mut upper = Vec::with_capacity(self.central.len());
576        for bin in 0..self.central.len() {
577            let estimate = Estimate::from_evaluation(
578                self.central[bin],
579                self.draws.iter().map(|draw| draw[bin]).collect(),
580                None,
581            );
582            let interval = estimate.interval(level)?;
583            lower.push(interval.0);
584            upper.push(interval.1);
585        }
586        Ok((lower, upper))
587    }
588
589    /// Sample covariance matrix between flattened bins.
590    ///
591    /// # Errors
592    /// Returns an error when fewer than two draws are available.
593    pub fn covariance(&self) -> LikelihoodResult<Vec<Vec<f64>>> {
594        if self.draws.len() < 2 {
595            return Err(invalid(
596                "binned estimate needs at least two uncertainty draws",
597            ));
598        }
599        let count = self.draws.len() as f64;
600        let means: Vec<_> = (0..self.central.len())
601            .map(|bin| self.draws.iter().map(|draw| draw[bin]).sum::<f64>() / count)
602            .collect();
603        Ok((0..self.central.len())
604            .map(|left| {
605                (0..self.central.len())
606                    .map(|right| {
607                        self.draws
608                            .iter()
609                            .map(|draw| (draw[left] - means[left]) * (draw[right] - means[right]))
610                            .sum::<f64>()
611                            / (count - 1.0)
612                    })
613                    .collect()
614            })
615            .collect())
616    }
617}
618
619/// Data, coherent-model, and tagged-component differential cross sections.
620#[derive(Clone, Debug)]
621pub struct DifferentialCrossSection {
622    axes: Vec<Vec<f64>>,
623    shape: Vec<usize>,
624    data: BinnedEstimate,
625    model: BinnedEstimate,
626    components: HashMap<String, BinnedEstimate>,
627}
628
629type DifferentialValues = (Vec<f64>, Vec<f64>, HashMap<String, Vec<f64>>);
630
631impl DifferentialCrossSection {
632    /// Edge arrays for every differential axis.
633    pub fn axes(&self) -> &[Vec<f64>] {
634        &self.axes
635    }
636
637    /// Multidimensional bin shape; values are flattened in row-major order.
638    pub fn shape(&self) -> &[usize] {
639        &self.shape
640    }
641
642    /// Acceptance-corrected observed distribution.
643    pub fn data(&self) -> &BinnedEstimate {
644        &self.data
645    }
646
647    /// Coherent fitted-model distribution.
648    pub fn model(&self) -> &BinnedEstimate {
649        &self.model
650    }
651
652    /// Tagged, separately evaluated component distributions.
653    pub fn components(&self) -> &HashMap<String, BinnedEstimate> {
654        &self.components
655    }
656}
657
658/// One named differential cross section within a projection-set request.
659#[derive(Clone, Debug)]
660pub struct Projection {
661    name: String,
662    axes: Vec<Axis>,
663}
664
665impl Projection {
666    /// Constructs a named projection specification.
667    ///
668    /// # Errors
669    /// Returns an error when the name or axis group is empty.
670    pub fn new(name: impl Into<String>, axes: Vec<Axis>) -> LikelihoodResult<Self> {
671        let name = name.into();
672        if name.is_empty() {
673            return Err(invalid("projection names must not be empty"));
674        }
675        if axes.is_empty() {
676            return Err(invalid("each projection must contain at least one axis"));
677        }
678        Ok(Self { name, axes })
679    }
680
681    /// Public projection name.
682    pub fn name(&self) -> &str {
683        &self.name
684    }
685
686    /// Axes forming this entry's joint differential cross section.
687    pub fn axes(&self) -> &[Axis] {
688        &self.axes
689    }
690}
691
692/// Ordered results from a multi-projection cross-section request.
693#[derive(Clone, Debug)]
694pub struct ProjectionSet {
695    entries: Vec<(String, DifferentialCrossSection)>,
696}
697
698impl ProjectionSet {
699    /// Number of named projection results.
700    pub fn len(&self) -> usize {
701        self.entries.len()
702    }
703
704    /// Whether the result contains no projections.
705    pub fn is_empty(&self) -> bool {
706        self.entries.is_empty()
707    }
708
709    /// Looks up a projection result by its public name.
710    pub fn get(&self, name: &str) -> Option<&DifferentialCrossSection> {
711        self.entries
712            .iter()
713            .find_map(|(candidate, result)| (candidate == name).then_some(result))
714    }
715
716    /// Iterates over projection names and results in request order.
717    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &DifferentialCrossSection)> {
718        self.entries
719            .iter()
720            .map(|(name, result)| (name.as_str(), result))
721    }
722}
723
724/// A prepared total, tagged, differential, and combinable cross-section analysis.
725#[derive(Clone, Debug, Eq, Hash, PartialEq)]
726struct CanonicalTags(Vec<String>);
727
728impl CanonicalTags {
729    fn new(tags: &[String]) -> Self {
730        let mut canonical = tags.to_vec();
731        canonical.sort();
732        canonical.dedup();
733        Self(canonical)
734    }
735
736    fn as_slice(&self) -> &[String] {
737        &self.0
738    }
739}
740
741type IntegralCacheKey = (usize, Option<CanonicalTags>);
742type IntegralCache = Arc<Mutex<HashMap<IntegralCacheKey, CrossSectionIntegrals>>>;
743
744/// A prepared total, tagged, differential, and combinable cross-section analysis.
745#[derive(Clone)]
746pub struct CrossSection {
747    likelihood: Arc<Likelihood>,
748    term_name: String,
749    generated_mc: Dataset,
750    full_integrals: CrossSectionIntegrals,
751    luminosity: f64,
752    parameters: Vec<f64>,
753    ensemble: Option<Ensemble>,
754    members: Option<Arc<Vec<(CrossSection, Estimate)>>>,
755    integral_cache: IntegralCache,
756    cache_hits: Arc<AtomicU64>,
757    cache_misses: Arc<AtomicU64>,
758}
759
760/// Integral-preparation cache statistics for a cross-section analysis.
761#[derive(Copy, Clone, Debug, PartialEq, Eq)]
762pub struct CrossSectionDiagnostics {
763    cache_hits: u64,
764    cache_misses: u64,
765    cached_integrals: usize,
766    prepared_bytes: usize,
767}
768
769impl CrossSectionDiagnostics {
770    /// Returns successful integral-cache lookups.
771    pub fn cache_hits(&self) -> u64 {
772        self.cache_hits
773    }
774    /// Returns integral preparations caused by cache misses.
775    pub fn cache_misses(&self) -> u64 {
776        self.cache_misses
777    }
778    /// Returns the number of unique likelihood/tag integral records retained.
779    pub fn cached_integrals(&self) -> usize {
780        self.cached_integrals
781    }
782    /// Returns the summed prepared bytes reported by cached integral records.
783    pub fn prepared_bytes(&self) -> usize {
784        self.prepared_bytes
785    }
786}
787
788#[derive(Clone)]
789struct BinnedMeasurement {
790    yields: Vec<f64>,
791    exposures: Vec<f64>,
792}
793
794struct CombinedMemberValues {
795    data: BinnedMeasurement,
796    model: BinnedMeasurement,
797    components: HashMap<String, BinnedMeasurement>,
798}
799
800struct CanonicalComponents {
801    aliases: HashMap<String, CanonicalTags>,
802    integrals: HashMap<CanonicalTags, CrossSectionIntegrals>,
803}
804
805struct CombinedMemberWorkspace {
806    luminosity: f64,
807    full: CrossSectionIntegrals,
808    components: CanonicalComponents,
809    projections: Vec<CombinedPreparedProjection>,
810    data_weights: Vec<f64>,
811    accepted_weights: Vec<f64>,
812    generated_weights: Vec<f64>,
813}
814
815struct CombinedPreparedProjection {
816    name: String,
817    request_axes: Vec<Axis>,
818    data_bins: BinAssignments,
819    accepted_bins: BinAssignments,
820    generated_bins: BinAssignments,
821}
822
823impl CanonicalComponents {
824    fn prepare(
825        member: &CrossSection,
826        likelihood: &Likelihood,
827        components: &HashMap<String, Vec<String>>,
828    ) -> LikelihoodResult<Self> {
829        let aliases = components
830            .iter()
831            .map(|(name, tags)| (name.clone(), CanonicalTags::new(tags)))
832            .collect::<HashMap<_, _>>();
833        let mut integrals = HashMap::new();
834        for tags in aliases.values() {
835            if !integrals.contains_key(tags) {
836                integrals.insert(
837                    tags.clone(),
838                    member.integrals_for(likelihood, Some(tags.as_slice()))?,
839                );
840            }
841        }
842        Ok(Self { aliases, integrals })
843    }
844}
845
846struct BinAssignments {
847    indices: Vec<Option<usize>>,
848    count: usize,
849}
850
851#[derive(Clone, Debug, PartialEq, Eq, Hash)]
852struct ProjectionKey(Vec<AxisKey>);
853
854#[derive(Clone, Debug, PartialEq, Eq, Hash)]
855struct AxisKey {
856    root: usize,
857    nodes: Vec<ExprNodeStructuralKey>,
858    edges: Vec<u64>,
859}
860
861impl ProjectionKey {
862    fn new(axes: &[Axis]) -> Self {
863        Self(
864            axes.iter()
865                .map(|axis| {
866                    let graph = axis.expression.to_graph();
867                    AxisKey {
868                        root: graph.root().index(),
869                        nodes: graph
870                            .nodes()
871                            .iter()
872                            .map(|node| node.structural_key())
873                            .collect(),
874                        edges: axis.edges.iter().map(|edge| edge.to_bits()).collect(),
875                    }
876                })
877                .collect(),
878        )
879    }
880}
881
882fn deduplicate_projections(projections: &[Projection]) -> (Vec<&Projection>, Vec<usize>) {
883    let mut unique_indexes = HashMap::with_capacity(projections.len());
884    let mut unique = Vec::with_capacity(projections.len());
885    let indexes = projections
886        .iter()
887        .map(|projection| {
888            let key = ProjectionKey::new(projection.axes());
889            if let Some(index) = unique_indexes.get(&key) {
890                return *index;
891            }
892            let index = unique.len();
893            unique.push(projection);
894            unique_indexes.insert(key, index);
895            index
896        })
897        .collect();
898    (unique, indexes)
899}
900
901struct PreparedProjection {
902    name: String,
903    request_axes: Vec<Axis>,
904    axes: Vec<Vec<f64>>,
905    shape: Vec<usize>,
906    volumes: Vec<f64>,
907    data_bins: BinAssignments,
908    accepted_bins: BinAssignments,
909    generated_bins: BinAssignments,
910}
911
912struct ProjectionReplica {
913    bins: Vec<Option<BinAssignments>>,
914    weights: Option<Vec<f64>>,
915    total_data: f64,
916}
917
918impl BinAssignments {
919    fn new(values: &[Vec<f64>], axes: &[Axis]) -> Self {
920        let event_count = values.first().map_or(0, Vec::len);
921        debug_assert!(
922            values
923                .iter()
924                .all(|coordinates| coordinates.len() == event_count)
925        );
926        let indices = (0..event_count)
927            .map(|event| {
928                axes.iter()
929                    .zip(values)
930                    .try_fold(0, |flat, (axis, coordinates)| {
931                        bin_index(coordinates[event], &axis.edges)
932                            .map(|index| flat * axis.bins() + index)
933                    })
934            })
935            .collect();
936        Self {
937            indices,
938            count: axes.iter().map(Axis::bins).product(),
939        }
940    }
941
942    fn accumulate_weighted_block(
943        &self,
944        offset: usize,
945        weights: &[f64],
946        intensities: &[f64],
947        bins: &mut [f64],
948    ) {
949        debug_assert!(offset + intensities.len() <= self.indices.len());
950        debug_assert_eq!(self.indices.len(), weights.len());
951        debug_assert_eq!(self.count, bins.len());
952        let worker_count = rayon::current_num_threads().min(intensities.len());
953        if rayon::current_thread_index().is_none() || worker_count < 2 {
954            for (row, &intensity) in intensities.iter().enumerate() {
955                let event = offset + row;
956                if let Some(index) = self.indices[event] {
957                    bins[index] += weights[event] * intensity;
958                }
959            }
960            return;
961        }
962        let chunk_size = intensities.len().div_ceil(worker_count);
963        let chunk_count = intensities.len().div_ceil(chunk_size);
964        let partials = (0..chunk_count)
965            .into_par_iter()
966            .map(|chunk_index| {
967                let start = chunk_index * chunk_size;
968                let end = (start + chunk_size).min(intensities.len());
969                let mut partial = vec![0.0; self.count];
970                for (row, &intensity) in intensities[start..end].iter().enumerate() {
971                    let event = offset + start + row;
972                    if let Some(index) = self.indices[event] {
973                        partial[index] += weights[event] * intensity;
974                    }
975                }
976                partial
977            })
978            .collect::<Vec<_>>();
979        for partial in partials {
980            for (bin, value) in bins.iter_mut().zip(partial) {
981                *bin += value;
982            }
983        }
984    }
985
986    fn accumulate_products(&self, weights: &[f64], intensities: Option<&[f64]>) -> Vec<f64> {
987        debug_assert_eq!(self.indices.len(), weights.len());
988        debug_assert!(intensities.is_none_or(|values| values.len() == weights.len()));
989        let mut bins = vec![0.0; self.count];
990        for (event, (&index, &weight)) in self.indices.iter().zip(weights).enumerate() {
991            if let Some(index) = index {
992                let intensity = intensities.map_or(1.0, |values| values[event]);
993                bins[index] += weight * intensity;
994            }
995        }
996        bins
997    }
998}
999
1000impl CombinedMemberWorkspace {
1001    fn prepare(
1002        member: &CrossSection,
1003        projections: &[&Projection],
1004        components: &HashMap<String, Vec<String>>,
1005    ) -> LikelihoodResult<Self> {
1006        let execution = member.likelihood.execution();
1007        let (data, _) = member.likelihood.intensity_datasets(&member.term_name)?;
1008        let full = member.integrals_for(&member.likelihood, None)?;
1009        let component_integrals =
1010            CanonicalComponents::prepare(member, &member.likelihood, components)?;
1011        let prepared_projections = projections
1012            .iter()
1013            .map(|projection| {
1014                Ok(CombinedPreparedProjection {
1015                    name: projection.name().to_owned(),
1016                    request_axes: projection.axes().to_vec(),
1017                    data_bins: evaluate_bin_assignments(data, projection.axes(), execution)
1018                        .map_err(|error| {
1019                            invalid(format!(
1020                                "projection `{}` data bin preparation failed: {error}",
1021                                projection.name()
1022                            ))
1023                        })?,
1024                    accepted_bins: evaluate_bin_assignments(
1025                        full.accepted_mc_source(),
1026                        projection.axes(),
1027                        execution,
1028                    )
1029                    .map_err(|error| {
1030                        invalid(format!(
1031                            "projection `{}` accepted MC bin preparation failed: {error}",
1032                            projection.name()
1033                        ))
1034                    })?,
1035                    generated_bins: evaluate_bin_assignments(
1036                        full.generated_mc_source(),
1037                        projection.axes(),
1038                        execution,
1039                    )
1040                    .map_err(|error| {
1041                        invalid(format!(
1042                            "projection `{}` generated MC bin preparation failed: {error}",
1043                            projection.name()
1044                        ))
1045                    })?,
1046                })
1047            })
1048            .collect::<LikelihoodResult<Vec<_>>>()?;
1049        Ok(Self {
1050            luminosity: member.luminosity,
1051            projections: prepared_projections,
1052            data_weights: dataset_weights(data)
1053                .map_err(|error| invalid(format!("data weights: {error}")))?,
1054            accepted_weights: dataset_weights(full.accepted_mc_source())
1055                .map_err(|error| invalid(format!("accepted MC weights: {error}")))?,
1056            generated_weights: dataset_weights(full.generated_mc_source())
1057                .map_err(|error| invalid(format!("generated MC weights: {error}")))?,
1058            full,
1059            components: component_integrals,
1060        })
1061    }
1062
1063    fn evaluate_projections_with_draws(
1064        &self,
1065        member: &CrossSection,
1066        factor: &Estimate,
1067        draw_count: usize,
1068        reference_source: Option<u64>,
1069        position: usize,
1070    ) -> LikelihoodResult<Vec<Vec<CombinedMemberValues>>> {
1071        let ensemble = member.ensemble.as_ref();
1072        let draw_indexes = (0..draw_count)
1073            .map(|index| {
1074                ensemble.map(|ensemble| {
1075                    paired_draw_index(
1076                        index,
1077                        position,
1078                        ensemble.len(),
1079                        Some(ensemble.source_id),
1080                        reference_source,
1081                    )
1082                })
1083            })
1084            .collect::<Vec<_>>();
1085        let parameter_sets = std::iter::once(member.parameters.as_slice())
1086            .chain(draw_indexes.iter().map(|draw_index| {
1087                draw_index
1088                    .and_then(|draw_index| ensemble.and_then(|value| value.draws.get(draw_index)))
1089                    .map(Vec::as_slice)
1090                    .unwrap_or(&member.parameters)
1091            }))
1092            .collect::<Vec<_>>();
1093        let parameter_contexts = std::iter::once("central value".to_owned())
1094            .chain((0..draw_count).map(|index| format!("ensemble draw {index}")))
1095            .collect::<Vec<_>>();
1096
1097        let mut accepted_histograms = self
1098            .projections
1099            .iter()
1100            .map(|projection| vec![vec![0.0; projection.accepted_bins.count]; parameter_sets.len()])
1101            .collect::<Vec<_>>();
1102        record_prepared_intensity_evaluation();
1103        let full_accepted = self
1104            .full
1105            .visit_accepted_prepared_intensities_many(
1106                &parameter_sets,
1107                &parameter_contexts,
1108                |offset, parameter_index, intensities| {
1109                    for (projection, histograms) in
1110                        self.projections.iter().zip(&mut accepted_histograms)
1111                    {
1112                        projection.accepted_bins.accumulate_weighted_block(
1113                            offset,
1114                            &self.accepted_weights,
1115                            intensities,
1116                            &mut histograms[parameter_index],
1117                        );
1118                    }
1119                },
1120            )
1121            .map_err(|error| invalid(format!("accepted MC intensity evaluation: {error}")))?;
1122        let mut generated_histograms = self
1123            .projections
1124            .iter()
1125            .map(|projection| {
1126                vec![vec![0.0; projection.generated_bins.count]; parameter_sets.len()]
1127            })
1128            .collect::<Vec<_>>();
1129        record_prepared_intensity_evaluation();
1130        self.full
1131            .visit_generated_prepared_intensities_many(
1132                &parameter_sets,
1133                &parameter_contexts,
1134                |offset, parameter_index, intensities| {
1135                    for (projection, histograms) in
1136                        self.projections.iter().zip(&mut generated_histograms)
1137                    {
1138                        projection.generated_bins.accumulate_weighted_block(
1139                            offset,
1140                            &self.generated_weights,
1141                            intensities,
1142                            &mut histograms[parameter_index],
1143                        );
1144                    }
1145                },
1146            )
1147            .map_err(|error| invalid(format!("generated MC intensity evaluation: {error}")))?;
1148
1149        let mut component_histograms = HashMap::new();
1150        for (canonical_tags, selected) in &self.components.integrals {
1151            let mut accepted = self
1152                .projections
1153                .iter()
1154                .map(|projection| {
1155                    vec![vec![0.0; projection.accepted_bins.count]; parameter_sets.len()]
1156                })
1157                .collect::<Vec<_>>();
1158            record_selection_intensity_evaluation();
1159            record_prepared_intensity_evaluation();
1160            selected
1161                .visit_accepted_prepared_intensities_many(
1162                    &parameter_sets,
1163                    &parameter_contexts,
1164                    |offset, parameter_index, intensities| {
1165                        for (projection, histograms) in self.projections.iter().zip(&mut accepted) {
1166                            projection.accepted_bins.accumulate_weighted_block(
1167                                offset,
1168                                &self.accepted_weights,
1169                                intensities,
1170                                &mut histograms[parameter_index],
1171                            );
1172                        }
1173                    },
1174                )
1175                .map_err(|error| {
1176                    invalid(format!(
1177                        "accepted MC component {:?} intensity evaluation: {error}",
1178                        canonical_tags.as_slice()
1179                    ))
1180                })?;
1181            let mut generated = self
1182                .projections
1183                .iter()
1184                .map(|projection| {
1185                    vec![vec![0.0; projection.generated_bins.count]; parameter_sets.len()]
1186                })
1187                .collect::<Vec<_>>();
1188            record_selection_intensity_evaluation();
1189            record_prepared_intensity_evaluation();
1190            selected
1191                .visit_generated_prepared_intensities_many(
1192                    &parameter_sets,
1193                    &parameter_contexts,
1194                    |offset, parameter_index, intensities| {
1195                        for (projection, histograms) in self.projections.iter().zip(&mut generated)
1196                        {
1197                            projection.generated_bins.accumulate_weighted_block(
1198                                offset,
1199                                &self.generated_weights,
1200                                intensities,
1201                                &mut histograms[parameter_index],
1202                            );
1203                        }
1204                    },
1205                )
1206                .map_err(|error| {
1207                    invalid(format!(
1208                        "generated MC component {:?} intensity evaluation: {error}",
1209                        canonical_tags.as_slice()
1210                    ))
1211                })?;
1212            component_histograms.insert(canonical_tags.clone(), (accepted, generated));
1213        }
1214
1215        let draw_data = draw_indexes
1216            .iter()
1217            .enumerate()
1218            .map(|(index, draw_index)| {
1219                let prepare = || {
1220                    let replica_data = draw_index
1221                        .and_then(|draw_index| {
1222                            ensemble.and_then(|value| value.replicas.get(draw_index))
1223                        })
1224                        .map(|likelihood| likelihood.intensity_datasets(&member.term_name))
1225                        .transpose()
1226                        .map_err(|error| invalid(format!("replica data lookup: {error}")))?
1227                        .map(|(data, _)| data);
1228                    let weights = replica_data
1229                        .map(dataset_weights)
1230                        .transpose()
1231                        .map_err(|error| invalid(format!("replica data weights: {error}")))?;
1232                    let histograms = self
1233                        .projections
1234                        .iter()
1235                        .map(|projection| {
1236                            let bins = match ensemble {
1237                                Some(ensemble) => ensemble.replica_bin_assignments(
1238                                    replica_data,
1239                                    &projection.request_axes,
1240                                    member.likelihood.execution(),
1241                                ),
1242                                None => Ok(None),
1243                            }
1244                            .map_err(|error| {
1245                                invalid(format!(
1246                                    "projection `{}` replica data bin preparation: {error}",
1247                                    projection.name
1248                                ))
1249                            })?;
1250                            Ok(bins
1251                                .as_ref()
1252                                .unwrap_or(&projection.data_bins)
1253                                .accumulate_products(
1254                                    weights.as_deref().unwrap_or(&self.data_weights),
1255                                    None,
1256                                ))
1257                        })
1258                        .collect::<LikelihoodResult<Vec<_>>>()?;
1259                    Ok((
1260                        histograms,
1261                        weights
1262                            .as_ref()
1263                            .map(|values| values.iter().sum())
1264                            .unwrap_or_else(|| self.full.data_weight_sum()),
1265                    ))
1266                };
1267                prepare().map_err(|error: LikelihoodError| {
1268                    invalid(format!(
1269                        "member `{}` draw {index}: {error}",
1270                        member.term_name
1271                    ))
1272                })
1273            })
1274            .collect::<LikelihoodResult<Vec<_>>>()?;
1275        let factors = std::iter::once(factor.central)
1276            .chain((0..draw_count).map(|index| {
1277                if factor.draws.is_empty() {
1278                    factor.central
1279                } else {
1280                    let factor_index = paired_draw_index(
1281                        index,
1282                        position,
1283                        factor.draws.len(),
1284                        factor.source_id,
1285                        reference_source,
1286                    );
1287                    factor.draws[factor_index]
1288                }
1289            }))
1290            .collect::<Vec<_>>();
1291
1292        Ok((0..parameter_sets.len())
1293            .map(|parameter_index| {
1294                self.projections
1295                    .iter()
1296                    .enumerate()
1297                    .map(|(projection_index, projection)| {
1298                        let (data_histogram, total_data) = if parameter_index == 0 {
1299                            (
1300                                projection
1301                                    .data_bins
1302                                    .accumulate_products(&self.data_weights, None),
1303                                self.full.data_weight_sum(),
1304                            )
1305                        } else {
1306                            let (histograms, total_data) = &draw_data[parameter_index - 1];
1307                            (histograms[projection_index].clone(), *total_data)
1308                        };
1309                        let accepted = &accepted_histograms[projection_index][parameter_index];
1310                        let generated = &generated_histograms[projection_index][parameter_index];
1311                        let exposures = binned_exposures(
1312                            self.luminosity * factors[parameter_index],
1313                            accepted,
1314                            generated,
1315                        );
1316                        let canonical_values = self
1317                            .components
1318                            .integrals
1319                            .keys()
1320                            .map(|tags| {
1321                                let (accepted_histograms, generated_histograms) =
1322                                    &component_histograms[tags];
1323                                let selected_accepted =
1324                                    &accepted_histograms[projection_index][parameter_index];
1325                                let selected_generated =
1326                                    &generated_histograms[projection_index][parameter_index];
1327                                (
1328                                    tags.clone(),
1329                                    BinnedMeasurement {
1330                                        yields: selected_accepted
1331                                            .iter()
1332                                            .map(|value| {
1333                                                total_data * value / full_accepted[parameter_index]
1334                                            })
1335                                            .collect(),
1336                                        exposures: binned_exposures(
1337                                            self.luminosity * factors[parameter_index],
1338                                            selected_accepted,
1339                                            selected_generated,
1340                                        ),
1341                                    },
1342                                )
1343                            })
1344                            .collect::<HashMap<_, _>>();
1345                        CombinedMemberValues {
1346                            data: BinnedMeasurement {
1347                                yields: data_histogram,
1348                                exposures: exposures.clone(),
1349                            },
1350                            model: BinnedMeasurement {
1351                                yields: accepted
1352                                    .iter()
1353                                    .map(|value| {
1354                                        total_data * value / full_accepted[parameter_index]
1355                                    })
1356                                    .collect(),
1357                                exposures,
1358                            },
1359                            components: self
1360                                .components
1361                                .aliases
1362                                .iter()
1363                                .map(|(name, tags)| (name.clone(), canonical_values[tags].clone()))
1364                                .collect(),
1365                        }
1366                    })
1367                    .collect()
1368            })
1369            .collect())
1370    }
1371}
1372
1373impl std::fmt::Debug for CrossSection {
1374    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1375        formatter
1376            .debug_struct("CrossSection")
1377            .field("term_name", &self.term_name)
1378            .field("luminosity", &self.luminosity)
1379            .field("parameters", &self.parameters)
1380            .field("ensemble", &self.ensemble)
1381            .field(
1382                "members",
1383                &self.members.as_ref().map(|members| members.len()),
1384            )
1385            .finish_non_exhaustive()
1386    }
1387}
1388
1389impl CrossSection {
1390    /// Constructs a central-value cross-section analysis.
1391    ///
1392    /// # Errors
1393    /// Returns an error for invalid inputs or likelihood preparation failure.
1394    pub fn new(
1395        likelihood: Arc<Likelihood>,
1396        term_name: impl Into<String>,
1397        generated_mc: Dataset,
1398        luminosity: f64,
1399        parameters: Vec<f64>,
1400    ) -> LikelihoodResult<Self> {
1401        Self::with_ensemble(
1402            likelihood,
1403            term_name,
1404            generated_mc,
1405            luminosity,
1406            parameters,
1407            None,
1408        )
1409    }
1410
1411    /// Constructs an analysis with optional uncertainty draws.
1412    ///
1413    /// # Errors
1414    /// Returns an error for invalid inputs, mismatched parameters, or preparation failure.
1415    pub fn with_ensemble(
1416        likelihood: Arc<Likelihood>,
1417        term_name: impl Into<String>,
1418        generated_mc: Dataset,
1419        luminosity: f64,
1420        parameters: Vec<f64>,
1421        ensemble: Option<Ensemble>,
1422    ) -> LikelihoodResult<Self> {
1423        if !luminosity.is_finite() || luminosity <= 0.0 {
1424            return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
1425        }
1426        likelihood.params().validate_free_values(&parameters)?;
1427        let term_name = term_name.into();
1428        if let Some(ensemble) = &ensemble {
1429            let names = likelihood
1430                .params()
1431                .free_params()
1432                .iter()
1433                .map(|id| likelihood.params().name(*id).map(str::to_owned))
1434                .collect::<Result<Vec<_>, _>>()?;
1435            if names != ensemble.parameter_names {
1436                return Err(invalid(
1437                    "ensemble parameter names do not match the likelihood",
1438                ));
1439            }
1440        }
1441        let full_integrals = likelihood.cross_section_integrals(&term_name, &generated_mc)?;
1442        let likelihood_key = Arc::as_ptr(&likelihood) as usize;
1443        let mut integral_cache = HashMap::new();
1444        integral_cache.insert((likelihood_key, None), full_integrals.clone());
1445        Ok(Self {
1446            likelihood,
1447            term_name,
1448            generated_mc,
1449            full_integrals,
1450            luminosity,
1451            parameters,
1452            ensemble,
1453            members: None,
1454            integral_cache: Arc::new(Mutex::new(integral_cache)),
1455            cache_hits: Default::default(),
1456            cache_misses: Arc::new(AtomicU64::new(1)),
1457        })
1458    }
1459
1460    /// Exposure-pools measurements of the same underlying cross section.
1461    ///
1462    /// # Errors
1463    /// Returns an error when no measurements are supplied.
1464    pub fn combine(members: Vec<CrossSection>) -> LikelihoodResult<Self> {
1465        let factors = (0..members.len())
1466            .map(|_| Estimate::central(1.0))
1467            .collect::<LikelihoodResult<Vec<_>>>()?;
1468        Self::combine_with_factors(members, factors)
1469    }
1470
1471    /// Exposure-pools measurements with branching or other exposure factors.
1472    ///
1473    /// # Errors
1474    /// Returns an error for missing measurements or invalid factors.
1475    pub fn combine_with_factors(
1476        members: Vec<CrossSection>,
1477        factors: Vec<Estimate>,
1478    ) -> LikelihoodResult<Self> {
1479        if members.is_empty() {
1480            return Err(invalid("at least one CrossSection is required"));
1481        }
1482        if factors.len() != members.len()
1483            || factors.iter().any(|factor| {
1484                factor.central <= 0.0 || factor.draws.iter().any(|value| *value <= 0.0)
1485            })
1486        {
1487            return Err(invalid(
1488                "factors must contain one positive estimate per member",
1489            ));
1490        }
1491        let template = members[0].clone();
1492        Ok(Self {
1493            likelihood: Arc::clone(&template.likelihood),
1494            term_name: template.term_name,
1495            generated_mc: template.generated_mc,
1496            full_integrals: template.full_integrals,
1497            luminosity: template.luminosity,
1498            parameters: template.parameters,
1499            ensemble: None,
1500            members: Some(Arc::new(members.into_iter().zip(factors).collect())),
1501            integral_cache: template.integral_cache,
1502            cache_hits: template.cache_hits,
1503            cache_misses: template.cache_misses,
1504        })
1505    }
1506
1507    /// Full-model observed-yield-normalized cross section.
1508    ///
1509    /// # Errors
1510    /// Returns an error when model integrals or ensemble evaluation fail.
1511    pub fn observed_total(&self) -> LikelihoodResult<Estimate> {
1512        self.observed_total_selected(None)
1513    }
1514
1515    /// Returns integral-cache hit, miss, count, and retained-byte diagnostics.
1516    pub fn diagnostics(&self) -> CrossSectionDiagnostics {
1517        let cache = self
1518            .integral_cache
1519            .lock()
1520            .unwrap_or_else(|error| error.into_inner());
1521        CrossSectionDiagnostics {
1522            cache_hits: self.cache_hits.load(Ordering::Relaxed),
1523            cache_misses: self.cache_misses.load(Ordering::Relaxed),
1524            cached_integrals: cache.len(),
1525            prepared_bytes: cache
1526                .values()
1527                .map(CrossSectionIntegrals::resident_bytes)
1528                .sum(),
1529        }
1530    }
1531
1532    /// Tag-narrowed observed-yield-normalized cross section.
1533    ///
1534    /// # Errors
1535    /// Returns an error when tag projection, integrals, or ensemble evaluation fail.
1536    pub fn observed_total_with_tags(&self, tags: &[String]) -> LikelihoodResult<Estimate> {
1537        self.observed_total_selected(Some(tags))
1538    }
1539
1540    fn observed_total_selected(&self, tags: Option<&[String]>) -> LikelihoodResult<Estimate> {
1541        if self.members.is_some() {
1542            return self.combined_total(tags);
1543        }
1544        self.evaluate_estimate(tags, |integrals, parameters| {
1545            integrals.observed_cross_section(parameters, self.luminosity)
1546        })
1547    }
1548
1549    /// Full-model fitted cross section from an absolute-rate likelihood term.
1550    ///
1551    /// # Errors
1552    /// Returns an error for shape-only terms, combined analyses, invalid
1553    /// luminosity, or failed integral or ensemble evaluation.
1554    pub fn fitted_total(&self) -> LikelihoodResult<Estimate> {
1555        self.fitted_total_selected(None)
1556    }
1557
1558    /// Tag-narrowed fitted cross section from an absolute-rate likelihood term.
1559    ///
1560    /// # Errors
1561    /// Returns an error for shape-only terms, combined analyses, invalid
1562    /// luminosity, or failed tag projection, integral, or ensemble evaluation.
1563    pub fn fitted_total_with_tags(&self, tags: &[String]) -> LikelihoodResult<Estimate> {
1564        self.fitted_total_selected(Some(tags))
1565    }
1566
1567    fn fitted_total_selected(&self, tags: Option<&[String]>) -> LikelihoodResult<Estimate> {
1568        self.evaluate_estimate(tags, |integrals, parameters| {
1569            integrals.fitted_cross_section(parameters, self.luminosity)
1570        })
1571    }
1572
1573    /// Alias for [`Self::observed_total`].
1574    ///
1575    /// # Errors
1576    /// Returns an error when model integrals or ensemble evaluation fail.
1577    pub fn total(&self) -> LikelihoodResult<Estimate> {
1578        self.observed_total()
1579    }
1580
1581    /// Alias for [`Self::observed_total_with_tags`].
1582    ///
1583    /// # Errors
1584    /// Returns an error when tag projection, integrals, or ensemble evaluation fail.
1585    pub fn total_with_tags(&self, tags: &[String]) -> LikelihoodResult<Estimate> {
1586        self.observed_total_with_tags(tags)
1587    }
1588
1589    /// Full-model acceptance.
1590    ///
1591    /// # Errors
1592    /// Returns an error when model integrals or ensemble evaluation fail.
1593    pub fn acceptance(&self) -> LikelihoodResult<Estimate> {
1594        self.acceptance_selected(None)
1595    }
1596
1597    /// Tag-narrowed model-weighted acceptance.
1598    ///
1599    /// # Errors
1600    /// Returns an error when tag projection, integrals, or ensemble evaluation fail.
1601    pub fn acceptance_with_tags(&self, tags: &[String]) -> LikelihoodResult<Estimate> {
1602        self.acceptance_selected(Some(tags))
1603    }
1604
1605    fn acceptance_selected(&self, tags: Option<&[String]>) -> LikelihoodResult<Estimate> {
1606        self.evaluate_estimate(tags, CrossSectionIntegrals::acceptance)
1607    }
1608
1609    /// Full-model acceptance-corrected yield.
1610    ///
1611    /// # Errors
1612    /// Returns an error when model integrals or ensemble evaluation fail.
1613    pub fn corrected_yield(&self) -> LikelihoodResult<Estimate> {
1614        self.corrected_yield_selected(None)
1615    }
1616
1617    /// Tag-narrowed acceptance-corrected yield.
1618    ///
1619    /// # Errors
1620    /// Returns an error when tag projection, integrals, or ensemble evaluation fail.
1621    pub fn corrected_yield_with_tags(&self, tags: &[String]) -> LikelihoodResult<Estimate> {
1622        self.corrected_yield_selected(Some(tags))
1623    }
1624
1625    fn corrected_yield_selected(&self, tags: Option<&[String]>) -> LikelihoodResult<Estimate> {
1626        self.evaluate_estimate(tags, |integrals, parameters| {
1627            let accepted_yield = if tags.is_some() {
1628                integrals.data_weight_sum() * integrals.accepted_integral(parameters)?
1629                    / integrals.full_accepted_integral(parameters)?
1630            } else {
1631                integrals.data_weight_sum()
1632            };
1633            integrals.acceptance_corrected_yield(parameters, accepted_yield)
1634        })
1635    }
1636
1637    /// Computes an arbitrary-dimensional differential cross section.
1638    ///
1639    /// # Errors
1640    /// Returns an error for absent axes or failed expression/model evaluation.
1641    pub fn differential(
1642        &self,
1643        axes: &[Axis],
1644        components: &HashMap<String, Vec<String>>,
1645    ) -> LikelihoodResult<DifferentialCrossSection> {
1646        if axes.is_empty() {
1647            return Err(invalid("at least one differential axis is required"));
1648        }
1649        let projection = Projection {
1650            name: "differential".to_owned(),
1651            axes: axes.to_vec(),
1652        };
1653        let mut entries = self
1654            .projection_set(std::slice::from_ref(&projection), components)?
1655            .entries;
1656        Ok(entries.remove(0).1)
1657    }
1658
1659    /// Computes an ordered set of independent named differential cross sections.
1660    ///
1661    /// # Errors
1662    /// Returns an error for an empty request, duplicate names, or failed
1663    /// expression/model evaluation.
1664    pub fn projection_set(
1665        &self,
1666        projections: &[Projection],
1667        components: &HashMap<String, Vec<String>>,
1668    ) -> LikelihoodResult<ProjectionSet> {
1669        if projections.is_empty() {
1670            return Err(invalid("at least one projection is required"));
1671        }
1672        let mut names = std::collections::HashSet::with_capacity(projections.len());
1673        for projection in projections {
1674            if !names.insert(projection.name()) {
1675                return Err(invalid(format!(
1676                    "duplicate projection name: {}",
1677                    projection.name()
1678                )));
1679            }
1680        }
1681        if self.members.is_some() {
1682            return self.combined_projection_set(projections, components);
1683        }
1684        self.single_projection_set(projections, components)
1685    }
1686
1687    fn integrals_for(
1688        &self,
1689        likelihood: &Likelihood,
1690        tags: Option<&[String]>,
1691    ) -> LikelihoodResult<CrossSectionIntegrals> {
1692        let key_tags = tags.map(CanonicalTags::new);
1693        let key = (likelihood as *const Likelihood as usize, key_tags.clone());
1694        if let Some(integrals) = self
1695            .integral_cache
1696            .lock()
1697            .unwrap_or_else(|error| error.into_inner())
1698            .get(&key)
1699            .cloned()
1700        {
1701            self.cache_hits.fetch_add(1, Ordering::Relaxed);
1702            return Ok(integrals);
1703        }
1704        self.cache_misses.fetch_add(1, Ordering::Relaxed);
1705        let integrals = match key_tags.as_ref() {
1706            Some(tags) => likelihood.cross_section_integrals_with_tags(
1707                &self.term_name,
1708                &self.generated_mc,
1709                tags.as_slice().iter().map(String::as_str),
1710            ),
1711            None => likelihood.cross_section_integrals(&self.term_name, &self.generated_mc),
1712        }?;
1713        self.integral_cache
1714            .lock()
1715            .unwrap_or_else(|error| error.into_inner())
1716            .insert(key, integrals.clone());
1717        Ok(integrals)
1718    }
1719
1720    fn evaluate_estimate(
1721        &self,
1722        tags: Option<&[String]>,
1723        function: impl Fn(&CrossSectionIntegrals, &[f64]) -> LikelihoodResult<f64>,
1724    ) -> LikelihoodResult<Estimate> {
1725        if self.members.is_some() {
1726            return Err(invalid(
1727                "operation is not defined directly for a combined CrossSection",
1728            ));
1729        }
1730        let integrals = self.integrals_for(&self.likelihood, tags)?;
1731        let central = function(&integrals, &self.parameters)?;
1732        let draws = self
1733            .ensemble
1734            .as_ref()
1735            .map(|ensemble| {
1736                ensemble
1737                    .draws
1738                    .iter()
1739                    .enumerate()
1740                    .map(|(index, draw)| {
1741                        let replica_integrals = ensemble
1742                            .replicas
1743                            .get(index)
1744                            .map(|likelihood| self.integrals_for(likelihood, tags))
1745                            .transpose()?;
1746                        function(replica_integrals.as_ref().unwrap_or(&integrals), draw)
1747                    })
1748                    .collect::<LikelihoodResult<Vec<_>>>()
1749            })
1750            .transpose()?
1751            .unwrap_or_default();
1752        Ok(Estimate::from_evaluation(
1753            central,
1754            draws,
1755            self.ensemble.as_ref().map(Ensemble::source_id),
1756        ))
1757    }
1758
1759    fn selected_measurement_for(
1760        &self,
1761        likelihood: &Likelihood,
1762        parameters: &[f64],
1763        tags: Option<&[String]>,
1764        factor: f64,
1765    ) -> LikelihoodResult<(f64, f64)> {
1766        let full = self.integrals_for(likelihood, None)?;
1767        let selected = self.integrals_for(likelihood, tags)?;
1768        let full_accepted = full.full_accepted_integral(parameters)?;
1769        let accepted = selected.accepted_integral(parameters)?;
1770        let generated = selected.generated_integral(parameters)?;
1771        if full_accepted <= 0.0 || accepted <= 0.0 || generated <= 0.0 {
1772            return Err(invalid(
1773                "cross-section combination requires positive integrals",
1774            ));
1775        }
1776        Ok((
1777            selected.data_weight_sum() * accepted / full_accepted,
1778            self.luminosity * factor * accepted / generated,
1779        ))
1780    }
1781
1782    fn combined_total(&self, tags: Option<&[String]>) -> LikelihoodResult<Estimate> {
1783        let members = self
1784            .members
1785            .as_ref()
1786            .ok_or_else(|| invalid("CrossSection is not combined"))?;
1787        let central = members.iter().try_fold(
1788            (0.0, 0.0),
1789            |(yield_sum, exposure_sum), (member, factor)| {
1790                let (yield_value, exposure) = member.selected_measurement_for(
1791                    &member.likelihood,
1792                    &member.parameters,
1793                    tags,
1794                    factor.central,
1795                )?;
1796                Ok::<_, LikelihoodError>((yield_sum + yield_value, exposure_sum + exposure))
1797            },
1798        )?;
1799        let draw_count = member_draw_count(members);
1800        let reference_source = member_reference_source(members);
1801        let mut draws = Vec::with_capacity(draw_count);
1802        for index in 0..draw_count {
1803            let mut yield_sum = 0.0;
1804            let mut exposure_sum = 0.0;
1805            for (position, (member, factor)) in members.iter().enumerate() {
1806                let draw_index = member.ensemble.as_ref().map(|ensemble| {
1807                    paired_draw_index(
1808                        index,
1809                        position,
1810                        ensemble.len(),
1811                        Some(ensemble.source_id),
1812                        reference_source,
1813                    )
1814                });
1815                let parameters = draw_index
1816                    .and_then(|draw_index| {
1817                        member
1818                            .ensemble
1819                            .as_ref()
1820                            .and_then(|ensemble| ensemble.draws.get(draw_index))
1821                    })
1822                    .map(Vec::as_slice)
1823                    .unwrap_or(&member.parameters);
1824                let likelihood = draw_index
1825                    .and_then(|draw_index| {
1826                        member
1827                            .ensemble
1828                            .as_ref()
1829                            .and_then(|ensemble| ensemble.replicas.get(draw_index))
1830                    })
1831                    .map(Arc::as_ref)
1832                    .unwrap_or(&member.likelihood);
1833                let factor_index = (!factor.draws.is_empty()).then(|| {
1834                    paired_draw_index(
1835                        index,
1836                        position,
1837                        factor.draws.len(),
1838                        factor.source_id,
1839                        reference_source,
1840                    )
1841                });
1842                let factor = factor_index
1843                    .and_then(|draw_index| factor.draws.get(draw_index))
1844                    .copied()
1845                    .unwrap_or(factor.central);
1846                let (yield_value, exposure) =
1847                    member.selected_measurement_for(likelihood, parameters, tags, factor)?;
1848                yield_sum += yield_value;
1849                exposure_sum += exposure;
1850            }
1851            draws.push(yield_sum / exposure_sum);
1852        }
1853        Ok(Estimate::from_evaluation(
1854            central.0 / central.1,
1855            draws,
1856            Some(next_uncertainty_source_id()),
1857        ))
1858    }
1859
1860    fn single_projection_set(
1861        &self,
1862        projections: &[Projection],
1863        components: &HashMap<String, Vec<String>>,
1864    ) -> LikelihoodResult<ProjectionSet> {
1865        let request_context = format!(
1866            "member `{}` projections [{}]",
1867            self.term_name,
1868            projections
1869                .iter()
1870                .map(|projection| projection.name())
1871                .collect::<Vec<_>>()
1872                .join(", ")
1873        );
1874        let execution = self.likelihood.execution();
1875        let (data, _) = self.likelihood.intensity_datasets(&self.term_name)?;
1876        let full = self.integrals_for(&self.likelihood, None)?;
1877        let data_weights = dataset_weights(data)?;
1878        let accepted_weights = dataset_weights(full.accepted_mc_source())?;
1879        let generated_weights = dataset_weights(full.generated_mc_source())?;
1880        let component_integrals = CanonicalComponents::prepare(self, &self.likelihood, components)?;
1881        let (unique_projections, projection_plans) = deduplicate_projections(projections);
1882        let plans = unique_projections
1883            .iter()
1884            .map(|projection| {
1885                let prepare = || {
1886                    Ok(PreparedProjection {
1887                        name: projection.name().to_owned(),
1888                        request_axes: projection.axes().to_vec(),
1889                        axes: projection
1890                            .axes()
1891                            .iter()
1892                            .map(|axis| axis.edges.clone())
1893                            .collect(),
1894                        shape: projection.axes().iter().map(Axis::bins).collect(),
1895                        volumes: bin_volumes(projection.axes()),
1896                        data_bins: evaluate_bin_assignments(data, projection.axes(), execution)?,
1897                        accepted_bins: evaluate_bin_assignments(
1898                            full.accepted_mc_source(),
1899                            projection.axes(),
1900                            execution,
1901                        )?,
1902                        generated_bins: evaluate_bin_assignments(
1903                            full.generated_mc_source(),
1904                            projection.axes(),
1905                            execution,
1906                        )?,
1907                    })
1908                };
1909                prepare().map_err(|error: LikelihoodError| {
1910                    invalid(format!(
1911                        "projection `{}` preparation failed: {error}",
1912                        projection.name()
1913                    ))
1914                })
1915            })
1916            .collect::<LikelihoodResult<Vec<_>>>()?;
1917        let parameter_sets = std::iter::once(self.parameters.as_slice())
1918            .chain(
1919                self.ensemble
1920                    .iter()
1921                    .flat_map(|ensemble| ensemble.draws.iter().map(Vec::as_slice)),
1922            )
1923            .collect::<Vec<_>>();
1924        let parameter_contexts = std::iter::once("central value".to_owned())
1925            .chain(
1926                (0..parameter_sets.len().saturating_sub(1))
1927                    .map(|index| format!("ensemble draw {index}")),
1928            )
1929            .collect::<Vec<_>>();
1930        let mut accepted_histograms = plans
1931            .iter()
1932            .map(|plan| vec![vec![0.0; plan.accepted_bins.count]; parameter_sets.len()])
1933            .collect::<Vec<_>>();
1934        record_prepared_intensity_evaluation();
1935        let full_accepted_integrals = full
1936            .visit_accepted_prepared_intensities_many(
1937                &parameter_sets,
1938                &parameter_contexts,
1939                |offset, parameter_index, intensities| {
1940                    for (plan, histograms) in plans.iter().zip(&mut accepted_histograms) {
1941                        plan.accepted_bins.accumulate_weighted_block(
1942                            offset,
1943                            &accepted_weights,
1944                            intensities,
1945                            &mut histograms[parameter_index],
1946                        );
1947                    }
1948                },
1949            )
1950            .map_err(|error| {
1951                invalid(format!(
1952                    "projection set {request_context} accepted MC intensity evaluation failed: {error}"
1953                ))
1954            })?;
1955        let mut generated_histograms = plans
1956            .iter()
1957            .map(|plan| vec![vec![0.0; plan.generated_bins.count]; parameter_sets.len()])
1958            .collect::<Vec<_>>();
1959        record_prepared_intensity_evaluation();
1960        full.visit_generated_prepared_intensities_many(
1961            &parameter_sets,
1962            &parameter_contexts,
1963            |offset, parameter_index, intensities| {
1964                for (plan, histograms) in plans.iter().zip(&mut generated_histograms) {
1965                    plan.generated_bins.accumulate_weighted_block(
1966                        offset,
1967                        &generated_weights,
1968                        intensities,
1969                        &mut histograms[parameter_index],
1970                    );
1971                }
1972            },
1973        )
1974        .map_err(|error| {
1975            invalid(format!(
1976                "projection set {request_context} generated MC intensity evaluation failed: {error}"
1977            ))
1978        })?;
1979        let mut component_histograms = HashMap::new();
1980        for (canonical_tags, selected) in &component_integrals.integrals {
1981            let mut histograms = plans
1982                .iter()
1983                .map(|plan| vec![vec![0.0; plan.generated_bins.count]; parameter_sets.len()])
1984                .collect::<Vec<_>>();
1985            record_selection_intensity_evaluation();
1986            record_prepared_intensity_evaluation();
1987            selected
1988                .visit_generated_prepared_intensities_many(
1989                    &parameter_sets,
1990                    &parameter_contexts,
1991                    |offset, parameter_index, intensities| {
1992                        for (plan, histograms) in plans.iter().zip(&mut histograms) {
1993                            plan.generated_bins.accumulate_weighted_block(
1994                                offset,
1995                                &generated_weights,
1996                                intensities,
1997                                &mut histograms[parameter_index],
1998                            );
1999                        }
2000                    },
2001                )
2002                .map_err(|error| {
2003                    invalid(format!(
2004                        "projection set {request_context} generated MC component {:?} intensity evaluation failed: {error}",
2005                        canonical_tags.as_slice()
2006                    ))
2007                })?;
2008            component_histograms.insert(canonical_tags.clone(), histograms);
2009        }
2010        let replicas = self
2011            .ensemble
2012            .as_ref()
2013            .map(|ensemble| {
2014                ensemble
2015                    .draws
2016                    .iter()
2017                    .enumerate()
2018                    .map(|(index, _)| {
2019                let replica_data = ensemble
2020                    .replicas
2021                    .get(index)
2022                    .map(|likelihood| likelihood.intensity_datasets(&self.term_name))
2023                    .transpose()?
2024                    .map(|(data, _)| data);
2025                let replica_weights = replica_data.map(dataset_weights).transpose()?;
2026                let total_data = replica_weights
2027                    .as_ref()
2028                    .map(|weights| weights.iter().sum())
2029                    .unwrap_or_else(|| full.data_weight_sum());
2030                        let bins = plans
2031                            .iter()
2032                            .map(|plan| {
2033                                ensemble
2034                                    .replica_bin_assignments(
2035                                        replica_data,
2036                                        &plan.request_axes,
2037                                        execution,
2038                                    )
2039                                    .map_err(|error| {
2040                                        invalid(format!(
2041                                            "projection `{}` draw {index} bin preparation failed: {error}",
2042                                            plan.name
2043                                        ))
2044                                    })
2045                            })
2046                            .collect::<LikelihoodResult<Vec<_>>>()?;
2047                        Ok(ProjectionReplica {
2048                            bins,
2049                            weights: replica_weights,
2050                            total_data,
2051                        })
2052                    })
2053                    .collect::<LikelihoodResult<Vec<_>>>()
2054            })
2055            .transpose()?
2056            .unwrap_or_default();
2057        let unique_results = plans
2058            .iter()
2059            .enumerate()
2060            .map(|(plan_index, plan)| {
2061                let evaluate = |draw_index: usize,
2062                                draw_data_bins: &BinAssignments,
2063                                draw_data_weights: &[f64],
2064                                total_data: f64|
2065                 -> DifferentialValues {
2066                    let data_histogram =
2067                        draw_data_bins.accumulate_products(draw_data_weights, None);
2068                    let accepted_histogram = &accepted_histograms[plan_index][draw_index];
2069                    let generated_histogram = &generated_histograms[plan_index][draw_index];
2070                    let full_accepted = full_accepted_integrals[draw_index];
2071                    let data_cross_section = data_histogram
2072                        .iter()
2073                        .zip(accepted_histogram)
2074                        .zip(generated_histogram)
2075                        .zip(&plan.volumes)
2076                        .map(|(((data, accepted), generated), volume)| {
2077                            if *accepted > 0.0 {
2078                                data * generated / accepted / self.luminosity / volume
2079                            } else {
2080                                f64::NAN
2081                            }
2082                        })
2083                        .collect();
2084                    let model = generated_histogram
2085                        .iter()
2086                        .zip(&plan.volumes)
2087                        .map(|(generated, volume)| {
2088                            total_data * generated / full_accepted / self.luminosity / volume
2089                        })
2090                        .collect();
2091                    let component_values = component_integrals
2092                        .aliases
2093                        .iter()
2094                        .map(|(name, canonical_tags)| {
2095                            let bins =
2096                                &component_histograms[canonical_tags][plan_index][draw_index];
2097                            (
2098                                name.clone(),
2099                                bins.iter()
2100                                    .zip(&plan.volumes)
2101                                    .map(|(generated, volume)| {
2102                                        total_data * generated
2103                                            / full_accepted
2104                                            / self.luminosity
2105                                            / volume
2106                                    })
2107                                    .collect(),
2108                            )
2109                        })
2110                        .collect();
2111                    (data_cross_section, model, component_values)
2112                };
2113                let (data_cross_section, model, component_values) =
2114                    evaluate(0, &plan.data_bins, &data_weights, full.data_weight_sum());
2115                let mut data_draws = Vec::with_capacity(replicas.len());
2116                let mut model_draws = Vec::with_capacity(replicas.len());
2117                let mut component_draws: HashMap<String, Vec<Vec<f64>>> = components
2118                    .keys()
2119                    .map(|name| (name.clone(), Vec::with_capacity(replicas.len())))
2120                    .collect();
2121                for (index, replica) in replicas.iter().enumerate() {
2122                    let draw_data_bins =
2123                        replica.bins[plan_index].as_ref().unwrap_or(&plan.data_bins);
2124                    let draw_data_weights = replica.weights.as_deref().unwrap_or(&data_weights);
2125                    let (data, model, values) = evaluate(
2126                        index + 1,
2127                        draw_data_bins,
2128                        draw_data_weights,
2129                        replica.total_data,
2130                    );
2131                    data_draws.push(data);
2132                    model_draws.push(model);
2133                    for (name, values) in values {
2134                        component_draws.entry(name).or_default().push(values);
2135                    }
2136                }
2137                Ok(DifferentialCrossSection {
2138                    axes: plan.axes.clone(),
2139                    shape: plan.shape.clone(),
2140                    data: BinnedEstimate::new(data_cross_section, data_draws),
2141                    model: BinnedEstimate::new(model, model_draws),
2142                    components: component_values
2143                        .into_iter()
2144                        .map(|(name, central)| {
2145                            let draws = component_draws.remove(&name).unwrap_or_default();
2146                            (name, BinnedEstimate::new(central, draws))
2147                        })
2148                        .collect(),
2149                })
2150            })
2151            .collect::<LikelihoodResult<Vec<_>>>()?;
2152        Ok(ProjectionSet {
2153            entries: projections
2154                .iter()
2155                .zip(projection_plans)
2156                .map(|(projection, plan)| (projection.name.clone(), unique_results[plan].clone()))
2157                .collect(),
2158        })
2159    }
2160
2161    fn combined_projection_set(
2162        &self,
2163        projections: &[Projection],
2164        components: &HashMap<String, Vec<String>>,
2165    ) -> LikelihoodResult<ProjectionSet> {
2166        let members = self
2167            .members
2168            .as_ref()
2169            .ok_or_else(|| invalid("CrossSection is not combined"))?;
2170        let (unique_projections, projection_indexes) = deduplicate_projections(projections);
2171        let projection_names = projections
2172            .iter()
2173            .map(Projection::name)
2174            .collect::<Vec<_>>()
2175            .join(", ");
2176        let workspaces = members
2177            .iter()
2178            .map(|(member, _)| {
2179                CombinedMemberWorkspace::prepare(member, &unique_projections, components)
2180                    .map_err(|error| {
2181                        invalid(format!(
2182                            "projection set member `{}` projections [{projection_names}] preparation failed: {error}",
2183                            member.term_name
2184                        ))
2185                    })
2186            })
2187            .collect::<LikelihoodResult<Vec<_>>>()?;
2188        let draw_count = member_draw_count(members);
2189        let reference_source = member_reference_source(members);
2190        let member_values = members
2191            .iter()
2192            .zip(&workspaces)
2193            .enumerate()
2194            .map(|(position, ((member, factor), workspace))| {
2195                workspace
2196                    .evaluate_projections_with_draws(
2197                        member,
2198                        factor,
2199                        draw_count,
2200                        reference_source,
2201                        position,
2202                    )
2203                    .map_err(|error| {
2204                        invalid(format!(
2205                            "projection set member `{}` projections [{projection_names}] evaluation failed: {error}",
2206                            member.term_name
2207                        ))
2208                    })
2209            })
2210            .collect::<LikelihoodResult<Vec<_>>>()?;
2211        let unique_results = unique_projections
2212            .iter()
2213            .enumerate()
2214            .map(|(projection_index, projection)| {
2215                let volumes = bin_volumes(projection.axes());
2216                let central = member_values
2217                    .iter()
2218                    .map(|values| &values[0][projection_index])
2219                    .collect::<Vec<_>>();
2220                let data = pool_binned(central.iter().map(|values| &values.data), &volumes);
2221                let model = pool_binned(central.iter().map(|values| &values.model), &volumes);
2222                let component_central = components
2223                    .keys()
2224                    .map(|name| {
2225                        (
2226                            name.clone(),
2227                            pool_binned(
2228                                central.iter().map(|values| &values.components[name]),
2229                                &volumes,
2230                            ),
2231                        )
2232                    })
2233                    .collect::<HashMap<_, _>>();
2234                let mut data_draws = Vec::with_capacity(draw_count);
2235                let mut model_draws = Vec::with_capacity(draw_count);
2236                let mut component_draws = components
2237                    .keys()
2238                    .map(|name| (name.clone(), Vec::with_capacity(draw_count)))
2239                    .collect::<HashMap<_, _>>();
2240                for draw_index in 0..draw_count {
2241                    let draw = member_values
2242                        .iter()
2243                        .map(|values| &values[draw_index + 1][projection_index])
2244                        .collect::<Vec<_>>();
2245                    data_draws.push(pool_binned(
2246                        draw.iter().map(|values| &values.data),
2247                        &volumes,
2248                    ));
2249                    model_draws.push(pool_binned(
2250                        draw.iter().map(|values| &values.model),
2251                        &volumes,
2252                    ));
2253                    for name in components.keys() {
2254                        component_draws.get_mut(name).unwrap().push(pool_binned(
2255                            draw.iter().map(|values| &values.components[name]),
2256                            &volumes,
2257                        ));
2258                    }
2259                }
2260                DifferentialCrossSection {
2261                    axes: projection
2262                        .axes()
2263                        .iter()
2264                        .map(|axis| axis.edges.clone())
2265                        .collect(),
2266                    shape: projection.axes().iter().map(Axis::bins).collect(),
2267                    data: BinnedEstimate::new(data, data_draws),
2268                    model: BinnedEstimate::new(model, model_draws),
2269                    components: component_central
2270                        .into_iter()
2271                        .map(|(name, central)| {
2272                            let draws = component_draws.remove(&name).unwrap_or_default();
2273                            (name, BinnedEstimate::new(central, draws))
2274                        })
2275                        .collect(),
2276                }
2277            })
2278            .collect::<Vec<_>>();
2279        Ok(ProjectionSet {
2280            entries: projections
2281                .iter()
2282                .zip(projection_indexes)
2283                .map(|(projection, index)| (projection.name.clone(), unique_results[index].clone()))
2284                .collect(),
2285        })
2286    }
2287}
2288
2289impl Likelihood {
2290    /// Prepares a central-value cross-section analysis from a shared likelihood.
2291    ///
2292    /// # Errors
2293    /// Returns an error for invalid inputs or likelihood preparation failure.
2294    pub fn cross_section(
2295        self: &Arc<Self>,
2296        term_name: impl Into<String>,
2297        generated_mc: Dataset,
2298        luminosity: f64,
2299        parameters: Vec<f64>,
2300    ) -> LikelihoodResult<CrossSection> {
2301        CrossSection::new(
2302            Arc::clone(self),
2303            term_name,
2304            generated_mc,
2305            luminosity,
2306            parameters,
2307        )
2308    }
2309
2310    /// Prepares an ensemble-backed cross-section analysis.
2311    ///
2312    /// # Errors
2313    /// Returns an error for invalid inputs, mismatched draws, or preparation failure.
2314    pub fn cross_section_with_ensemble(
2315        self: &Arc<Self>,
2316        term_name: impl Into<String>,
2317        generated_mc: Dataset,
2318        luminosity: f64,
2319        parameters: Vec<f64>,
2320        ensemble: Ensemble,
2321    ) -> LikelihoodResult<CrossSection> {
2322        CrossSection::with_ensemble(
2323            Arc::clone(self),
2324            term_name,
2325            generated_mc,
2326            luminosity,
2327            parameters,
2328            Some(ensemble),
2329        )
2330    }
2331}
2332
2333fn member_draw_count(members: &[(CrossSection, Estimate)]) -> usize {
2334    members
2335        .iter()
2336        .flat_map(|(member, factor)| {
2337            [
2338                member.ensemble.as_ref().map(Ensemble::len),
2339                (!factor.draws.is_empty()).then_some(factor.draws.len()),
2340            ]
2341        })
2342        .flatten()
2343        .min()
2344        .unwrap_or(0)
2345}
2346
2347fn member_reference_source(members: &[(CrossSection, Estimate)]) -> Option<u64> {
2348    members.iter().find_map(|(member, factor)| {
2349        member
2350            .ensemble
2351            .as_ref()
2352            .map(Ensemble::source_id)
2353            .or(factor.source_id)
2354    })
2355}
2356
2357fn paired_draw_index(
2358    index: usize,
2359    position: usize,
2360    draw_count: usize,
2361    source_id: Option<u64>,
2362    reference_source: Option<u64>,
2363) -> usize {
2364    if source_id == reference_source {
2365        index % draw_count
2366    } else {
2367        (index.wrapping_mul(2 * position + 1) + position) % draw_count
2368    }
2369}
2370
2371fn binned_exposures(luminosity: f64, accepted: &[f64], generated: &[f64]) -> Vec<f64> {
2372    accepted
2373        .iter()
2374        .zip(generated)
2375        .map(|(accepted, generated)| {
2376            if *generated > 0.0 {
2377                luminosity * accepted / generated
2378            } else {
2379                0.0
2380            }
2381        })
2382        .collect()
2383}
2384
2385fn pool_binned<'a>(
2386    measurements: impl IntoIterator<Item = &'a BinnedMeasurement>,
2387    volumes: &[f64],
2388) -> Vec<f64> {
2389    let mut yields = vec![0.0; volumes.len()];
2390    let mut exposures = vec![0.0; volumes.len()];
2391    for measurement in measurements {
2392        for index in 0..volumes.len() {
2393            yields[index] += measurement.yields[index];
2394            exposures[index] += measurement.exposures[index];
2395        }
2396    }
2397    (0..volumes.len())
2398        .map(|index| {
2399            if exposures[index] > 0.0 {
2400                yields[index] / exposures[index] / volumes[index]
2401            } else {
2402                f64::NAN
2403            }
2404        })
2405        .collect()
2406}
2407
2408fn evaluate_coordinates(
2409    dataset: &Dataset,
2410    axes: &[Axis],
2411    execution: &Execution,
2412) -> LikelihoodResult<Vec<Vec<f64>>> {
2413    axes.iter()
2414        .map(|axis| {
2415            dataset
2416                .evaluate_real(&axis.expression, execution)
2417                .map_err(Into::into)
2418        })
2419        .collect()
2420}
2421
2422fn evaluate_bin_assignments(
2423    dataset: &Dataset,
2424    axes: &[Axis],
2425    execution: &Execution,
2426) -> LikelihoodResult<BinAssignments> {
2427    record_bin_assignment_evaluation();
2428    Ok(BinAssignments::new(
2429        &evaluate_coordinates(dataset, axes, execution)?,
2430        axes,
2431    ))
2432}
2433
2434fn dataset_weights(dataset: &Dataset) -> LikelihoodResult<Vec<f64>> {
2435    dataset
2436        .try_fold_events(Vec::new(), |mut weights, event| {
2437            weights.push(event.weight());
2438            Ok(weights)
2439        })
2440        .map_err(Into::into)
2441}
2442
2443fn bin_volumes(axes: &[Axis]) -> Vec<f64> {
2444    axes.iter().fold(vec![1.0], |volumes, axis| {
2445        volumes
2446            .into_iter()
2447            .flat_map(|volume| {
2448                axis.edges
2449                    .windows(2)
2450                    .map(move |pair| volume * (pair[1] - pair[0]))
2451            })
2452            .collect()
2453    })
2454}
2455
2456fn bin_index(value: f64, edges: &[f64]) -> Option<usize> {
2457    if !value.is_finite() || value < edges[0] || value >= *edges.last()? {
2458        return None;
2459    }
2460    edges.windows(2).position(|pair| value < pair[1])
2461}
2462
2463#[cfg(test)]
2464mod tests {
2465    use std::collections::HashSet;
2466
2467    use approx::assert_relative_eq;
2468    use laddu_compile::CompiledModel;
2469    use laddu_data::{
2470        data::{EventBatch, OwnedEvent},
2471        schema::Schema,
2472    };
2473    use laddu_expr::{Expr, event_scalar, parameter};
2474
2475    use super::*;
2476
2477    fn weighted_dataset(values: &[(f64, f64)]) -> Dataset {
2478        let schema = Arc::new(Schema::new(std::iter::empty::<&str>(), ["x"], true).unwrap());
2479        let batch = EventBatch::from_events(
2480            schema,
2481            values
2482                .iter()
2483                .map(|(x, weight)| OwnedEvent::weighted(vec![], vec![*x], *weight)),
2484        )
2485        .unwrap();
2486        Dataset::from_batches(vec![batch]).unwrap()
2487    }
2488
2489    fn weighted_dataset_2d(values: &[(f64, f64, f64)]) -> Dataset {
2490        let schema = Arc::new(Schema::new(std::iter::empty::<&str>(), ["x", "y"], true).unwrap());
2491        let batch = EventBatch::from_events(
2492            schema,
2493            values
2494                .iter()
2495                .map(|(x, y, weight)| OwnedEvent::weighted(vec![], vec![*x, *y], *weight)),
2496        )
2497        .unwrap();
2498        Dataset::from_batches(vec![batch]).unwrap()
2499    }
2500
2501    struct CanonicalSelectionFixture {
2502        likelihood: Arc<Likelihood>,
2503        generated: Dataset,
2504        axis: Axis,
2505        components: HashMap<String, Vec<String>>,
2506    }
2507
2508    fn canonical_selection_fixture() -> CanonicalSelectionFixture {
2509        let x = event_scalar("x");
2510        let signal = (Expr::from(parameter!("a", initial: 1.5)) * x.clone()).tagged("signal");
2511        let background = Expr::from(parameter!("b", initial: 0.75)).tagged("background");
2512        let model = CompiledModel::from_expr(&(signal + background).norm_sqr()).unwrap();
2513        let data = weighted_dataset(&[(0.25, 1.0), (0.75, 2.0), (1.25, 1.0)]);
2514        let accepted = weighted_dataset(&[(0.25, 1.0), (0.75, 1.0), (1.25, 1.0)]);
2515        let generated = weighted_dataset(&[(0.25, 1.0), (0.75, 1.0), (1.25, 1.0), (1.75, 1.0)]);
2516        let likelihood = Arc::new(
2517            Likelihood::new([crate::NllTerm::new("signal", &model, &data, &accepted).unwrap()])
2518                .unwrap(),
2519        );
2520        CanonicalSelectionFixture {
2521            likelihood,
2522            generated,
2523            axis: Axis::new(x, vec![0.0, 1.0, 2.0]).unwrap(),
2524            components: HashMap::from([
2525                ("ordered".into(), vec!["background".into(), "signal".into()]),
2526                (
2527                    "reordered".into(),
2528                    vec!["signal".into(), "background".into()],
2529                ),
2530                (
2531                    "repeated".into(),
2532                    vec!["signal".into(), "background".into(), "signal".into()],
2533                ),
2534            ]),
2535        }
2536    }
2537
2538    fn assert_projection_close(
2539        actual: &DifferentialCrossSection,
2540        expected: &DifferentialCrossSection,
2541    ) {
2542        fn assert_estimate_close(actual: &BinnedEstimate, expected: &BinnedEstimate) {
2543            let rows = std::iter::once((actual.values(), expected.values())).chain(
2544                actual
2545                    .draws()
2546                    .iter()
2547                    .zip(expected.draws())
2548                    .map(|(actual, expected)| (actual.as_slice(), expected.as_slice())),
2549            );
2550            assert_eq!(actual.draws().len(), expected.draws().len());
2551            for (actual, expected) in rows {
2552                assert_eq!(actual.len(), expected.len());
2553                for (actual, expected) in actual.iter().zip(expected) {
2554                    if expected.is_nan() {
2555                        assert!(actual.is_nan());
2556                    } else {
2557                        assert_relative_eq!(
2558                            actual,
2559                            expected,
2560                            epsilon = 1e-10,
2561                            max_relative = 1e-10
2562                        );
2563                    }
2564                }
2565            }
2566        }
2567
2568        assert_eq!(actual.axes(), expected.axes());
2569        assert_eq!(actual.shape(), expected.shape());
2570        assert_estimate_close(actual.data(), expected.data());
2571        assert_estimate_close(actual.model(), expected.model());
2572        assert_eq!(
2573            actual.components().keys().collect::<HashSet<_>>(),
2574            expected.components().keys().collect::<HashSet<_>>()
2575        );
2576        for (name, expected) in expected.components() {
2577            assert_estimate_close(&actual.components()[name], expected);
2578        }
2579    }
2580
2581    #[test]
2582    fn estimate_arithmetic_preserves_scalar_provenance() {
2583        let estimate = Estimate::with_source_id(2.0, vec![1.0, 3.0], Some(17)).unwrap();
2584        let scaled = &estimate * 4.0;
2585        assert_eq!(scaled.value(), 8.0);
2586        assert_eq!(scaled.draws(), &[4.0, 12.0]);
2587        assert_eq!(scaled.source_id(), Some(17));
2588    }
2589
2590    #[test]
2591    fn chain_adapter_discards_and_thins_each_walker() {
2592        let chain = vec![
2593            vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0]],
2594            vec![vec![4.0], vec![5.0], vec![6.0], vec![7.0]],
2595        ];
2596        let ensemble = Ensemble::from_chain(vec!["x".into()], &chain, 1, 2).unwrap();
2597        assert_eq!(
2598            ensemble.draws(),
2599            &[vec![1.0], vec![3.0], vec![5.0], vec![7.0]]
2600        );
2601    }
2602
2603    #[test]
2604    fn bin_lookup_uses_half_open_intervals() {
2605        assert_eq!(bin_index(0.0, &[0.0, 1.0, 2.0]), Some(0));
2606        assert_eq!(bin_index(1.0, &[0.0, 1.0, 2.0]), Some(1));
2607        assert_eq!(bin_index(2.0, &[0.0, 1.0, 2.0]), None);
2608    }
2609
2610    #[test]
2611    fn joint_differential_preserves_bin_and_weight_semantics() {
2612        let model = CompiledModel::from_expr(&(event_scalar("x") + 1.0)).unwrap();
2613        let data = weighted_dataset_2d(&[
2614            (0.0, 0.0, 1.0),
2615            (0.0, 1.0, 2.0),
2616            (1.0, 0.0, -3.0),
2617            (1.0, 1.0, 4.0),
2618            (2.0, 0.5, 8.0),
2619            (f64::NAN, 0.5, 16.0),
2620            (0.5, f64::INFINITY, 32.0),
2621            (0.5, f64::NEG_INFINITY, 64.0),
2622            (-0.5, 0.5, 128.0),
2623        ]);
2624        let accepted = weighted_dataset_2d(&[
2625            (0.25, 0.25, 1.0),
2626            (0.25, 1.25, 1.0),
2627            (1.25, 0.25, 1.0),
2628            (1.25, 1.25, -1.0),
2629        ]);
2630        let generated = weighted_dataset_2d(&[
2631            (0.25, 0.25, 1.0),
2632            (0.25, 1.25, 1.0),
2633            (1.25, 0.25, 1.0),
2634            (1.25, 1.25, 1.0),
2635        ]);
2636        let likelihood = Arc::new(
2637            Likelihood::new([crate::NllTerm::new("signal", &model, &data, &accepted).unwrap()])
2638                .unwrap(),
2639        );
2640        let axes = [
2641            Axis::new(event_scalar("x"), vec![0.0, 1.0, 2.0]).unwrap(),
2642            Axis::new(event_scalar("y"), vec![0.0, 1.0, 2.0]).unwrap(),
2643        ];
2644        let differential = likelihood
2645            .cross_section("signal", generated, 1.0, Vec::new())
2646            .unwrap()
2647            .differential(&axes, &HashMap::new())
2648            .unwrap();
2649
2650        assert_eq!(differential.shape(), &[2, 2]);
2651        assert_eq!(&differential.data().values()[..3], &[1.0, 2.0, -3.0]);
2652        assert!(differential.data().values()[3].is_nan());
2653    }
2654
2655    #[test]
2656    fn rust_cross_section_api_covers_totals_differentials_and_bootstrap_pairing() {
2657        let model = CompiledModel::from_expr(&(event_scalar("x") + 1.0)).unwrap();
2658        let data = weighted_dataset(&[(0.25, 1.0), (1.25, 2.0)]);
2659        let accepted = weighted_dataset(&[(0.25, 1.0), (1.25, 1.0)]);
2660        let generated = weighted_dataset(&[(0.25, 1.0), (1.25, 1.0), (1.75, 1.0)]);
2661        let likelihood = Arc::new(
2662            Likelihood::new([crate::NllTerm::new("signal", &model, &data, &accepted).unwrap()])
2663                .unwrap(),
2664        );
2665        let cross_section = likelihood
2666            .cross_section("signal", generated, 10.0, Vec::new())
2667            .unwrap();
2668        assert!(cross_section.total().unwrap().value().is_finite());
2669        let before = cross_section.diagnostics();
2670        assert!(cross_section.total().unwrap().value().is_finite());
2671        let after = cross_section.diagnostics();
2672        assert_eq!(after.cache_hits(), before.cache_hits() + 1);
2673        assert_eq!(after.cache_misses(), 1);
2674        assert_eq!(after.cached_integrals(), 1);
2675        assert!(after.prepared_bytes() > 0);
2676
2677        let axis = Axis::new(event_scalar("x"), vec![0.0, 1.0, 2.0]).unwrap();
2678        let differential = cross_section
2679            .differential(&[axis], &HashMap::new())
2680            .unwrap();
2681        assert_eq!(differential.shape(), &[2]);
2682        assert_eq!(differential.data().values().len(), 2);
2683        assert_eq!(differential.model().values().len(), 2);
2684
2685        let ensemble = Ensemble::bootstrap_fit(&likelihood, 3, 42, |replica, _| {
2686            Ok::<_, std::convert::Infallible>(replica.default_params())
2687        })
2688        .unwrap();
2689        assert_eq!(ensemble.len(), 3);
2690        assert_eq!(ensemble.replicas().len(), 3);
2691    }
2692
2693    #[test]
2694    fn rust_projection_set_preserves_order_lookup_and_differential_results() {
2695        let fixture = canonical_selection_fixture();
2696        let cross_section = fixture
2697            .likelihood
2698            .cross_section("signal", fixture.generated, 2.0, vec![1.5, 0.75])
2699            .unwrap();
2700        let wide_axis = Axis::new(event_scalar("x"), vec![0.0, 2.0]).unwrap();
2701        let projections = vec![
2702            Projection::new("fine", vec![fixture.axis.clone()]).unwrap(),
2703            Projection::new("wide", vec![wide_axis.clone()]).unwrap(),
2704        ];
2705
2706        let expected_fine = cross_section
2707            .differential(std::slice::from_ref(&fixture.axis), &fixture.components)
2708            .unwrap();
2709        let expected_wide = cross_section
2710            .differential(std::slice::from_ref(&wide_axis), &fixture.components)
2711            .unwrap();
2712        let actual = cross_section
2713            .projection_set(&projections, &fixture.components)
2714            .unwrap();
2715
2716        assert_eq!(actual.len(), 2);
2717        assert_eq!(
2718            actual.iter().map(|(name, _)| name).collect::<Vec<_>>(),
2719            vec!["fine", "wide"]
2720        );
2721        assert_projection_close(actual.get("fine").unwrap(), &expected_fine);
2722        assert_projection_close(actual.get("wide").unwrap(), &expected_wide);
2723        assert!(actual.get("missing").is_none());
2724    }
2725
2726    #[test]
2727    fn projection_set_shares_intensities_and_identical_bin_assignments() {
2728        let fixture = canonical_selection_fixture();
2729        let cross_section = fixture
2730            .likelihood
2731            .cross_section("signal", fixture.generated, 2.0, vec![1.5, 0.75])
2732            .unwrap();
2733        let wide_axis = Axis::new(event_scalar("x"), vec![0.0, 2.0]).unwrap();
2734        let projections = vec![
2735            Projection::new("first", vec![fixture.axis.clone()]).unwrap(),
2736            Projection::new("alias", vec![fixture.axis]).unwrap(),
2737            Projection::new("wide", vec![wide_axis]).unwrap(),
2738        ];
2739
2740        reset_projection_evaluation_counts();
2741        let result = cross_section
2742            .projection_set(&projections, &fixture.components)
2743            .unwrap();
2744
2745        assert_eq!(result.len(), 3);
2746        assert_eq!(projection_evaluation_counts(), (3, 6));
2747        assert_projection_close(result.get("first").unwrap(), result.get("alias").unwrap());
2748    }
2749
2750    #[test]
2751    fn projection_set_rejects_invalid_requests_before_evaluation() {
2752        let fixture = canonical_selection_fixture();
2753        let cross_section = fixture
2754            .likelihood
2755            .cross_section("signal", fixture.generated, 2.0, vec![1.5, 0.75])
2756            .unwrap();
2757
2758        assert!(Projection::new("", vec![fixture.axis.clone()]).is_err());
2759        assert!(Projection::new("empty", Vec::new()).is_err());
2760        assert!(
2761            cross_section
2762                .projection_set(&[], &fixture.components)
2763                .is_err()
2764        );
2765        let duplicate = vec![
2766            Projection::new("same", vec![fixture.axis.clone()]).unwrap(),
2767            Projection::new("same", vec![fixture.axis]).unwrap(),
2768        ];
2769        reset_projection_evaluation_counts();
2770        let error = cross_section
2771            .projection_set(&duplicate, &fixture.components)
2772            .expect_err("duplicate names must fail");
2773
2774        assert!(
2775            error
2776                .to_string()
2777                .contains("duplicate projection name: same")
2778        );
2779        assert_eq!(projection_evaluation_counts(), (0, 0));
2780    }
2781
2782    #[test]
2783    fn projection_set_execution_errors_report_dataset_and_draw_context() {
2784        let expression: Expr = parameter!("scale", initial: 1.0).into();
2785        let model = CompiledModel::from_expr(&expression).unwrap();
2786        let data = weighted_dataset(&[(0.25, 1.0)]);
2787        let accepted = weighted_dataset(&[(0.25, 1.0)]);
2788        let generated = weighted_dataset(&[(0.25, 1.0)]);
2789        let likelihood = Arc::new(
2790            Likelihood::new([crate::NllTerm::new("signal", &model, &data, &accepted).unwrap()])
2791                .unwrap(),
2792        );
2793        let ensemble = Ensemble::new(vec!["scale".into()], vec![vec![-1.0]]).unwrap();
2794        let cross_section = likelihood
2795            .cross_section_with_ensemble("signal", generated, 1.0, vec![1.0], ensemble)
2796            .unwrap();
2797        let projections = [Projection::new(
2798            "x",
2799            vec![Axis::new(event_scalar("x"), vec![0.0, 1.0]).unwrap()],
2800        )
2801        .unwrap()];
2802
2803        let error = cross_section
2804            .projection_set(&projections, &HashMap::new())
2805            .expect_err("a negative draw intensity must fail");
2806        let message = error.to_string();
2807
2808        assert!(message.contains("accepted MC"), "{message}");
2809        assert!(message.contains("ensemble draw 0"), "{message}");
2810        assert!(message.contains("member `signal`"), "{message}");
2811        assert!(message.contains("projections [x]"), "{message}");
2812
2813        let combined = CrossSection::combine(vec![cross_section.clone(), cross_section]).unwrap();
2814        let error = combined
2815            .projection_set(&projections, &HashMap::new())
2816            .expect_err("a combined negative draw intensity must fail");
2817        let message = error.to_string();
2818        assert!(message.contains("accepted MC"), "{message}");
2819        assert!(message.contains("ensemble draw 0"), "{message}");
2820        assert!(message.contains("member `signal`"), "{message}");
2821        assert!(message.contains("projections [x]"), "{message}");
2822    }
2823
2824    #[test]
2825    fn extended_nll_cross_section_distinguishes_observed_and_fitted_totals() {
2826        let model =
2827            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.25)))
2828                .unwrap();
2829        let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
2830        let accepted = weighted_dataset(&[(4.0, 1.0)]);
2831        let generated = weighted_dataset(&[(6.0, 1.0)]);
2832        let likelihood = Arc::new(
2833            Likelihood::new([
2834                crate::ExtendedNllTerm::new("signal", &model, &data, &accepted).unwrap(),
2835            ])
2836            .unwrap(),
2837        );
2838        let cross_section = likelihood
2839            .cross_section("signal", generated, 10.0, likelihood.default_params())
2840            .unwrap();
2841
2842        assert_relative_eq!(cross_section.observed_total().unwrap().value(), 0.3);
2843        assert_relative_eq!(cross_section.fitted_total().unwrap().value(), 0.15);
2844        assert_relative_eq!(
2845            cross_section.total().unwrap().value(),
2846            cross_section.observed_total().unwrap().value()
2847        );
2848    }
2849
2850    #[test]
2851    fn differential_aliases_share_canonical_selection_evaluations() {
2852        let fixture = canonical_selection_fixture();
2853        let ensemble = Ensemble::new(
2854            vec!["a".into(), "b".into()],
2855            vec![vec![1.6, 0.7], vec![1.4, 0.8]],
2856        )
2857        .unwrap();
2858        let cross_section = fixture
2859            .likelihood
2860            .cross_section_with_ensemble(
2861                "signal",
2862                fixture.generated,
2863                10.0,
2864                fixture.likelihood.default_params(),
2865                ensemble,
2866            )
2867            .unwrap();
2868
2869        reset_selection_intensity_evaluation_count();
2870        let differential = cross_section
2871            .differential(std::slice::from_ref(&fixture.axis), &fixture.components)
2872            .unwrap();
2873
2874        assert_eq!(selection_intensity_evaluation_count(), 1);
2875        assert_eq!(differential.components().len(), 3);
2876        assert_eq!(
2877            differential.components()["ordered"].values(),
2878            differential.components()["reordered"].values()
2879        );
2880        assert_eq!(
2881            differential.components()["ordered"].values(),
2882            differential.components()["repeated"].values()
2883        );
2884        assert_eq!(
2885            differential.components()["ordered"].draws(),
2886            differential.components()["reordered"].draws()
2887        );
2888        assert_eq!(
2889            differential.components()["ordered"].draws(),
2890            differential.components()["repeated"].draws()
2891        );
2892    }
2893
2894    #[test]
2895    fn combined_differential_deduplicates_selections_per_member() {
2896        let fixture = canonical_selection_fixture();
2897        let members = [10.0, 15.0]
2898            .into_iter()
2899            .map(|luminosity| {
2900                fixture
2901                    .likelihood
2902                    .cross_section(
2903                        "signal",
2904                        fixture.generated.clone(),
2905                        luminosity,
2906                        fixture.likelihood.default_params(),
2907                    )
2908                    .unwrap()
2909            })
2910            .collect();
2911        let cross_section = CrossSection::combine(members).unwrap();
2912
2913        reset_selection_intensity_evaluation_count();
2914        let differential = cross_section
2915            .differential(std::slice::from_ref(&fixture.axis), &fixture.components)
2916            .unwrap();
2917
2918        assert_eq!(selection_intensity_evaluation_count(), 4);
2919        assert_eq!(differential.components().len(), 3);
2920        assert_eq!(
2921            differential.components()["ordered"].values(),
2922            differential.components()["reordered"].values()
2923        );
2924        assert_eq!(
2925            differential.components()["ordered"].values(),
2926            differential.components()["repeated"].values()
2927        );
2928    }
2929
2930    #[test]
2931    fn combined_projection_sets_match_independent_combined_differentials() {
2932        let fixture = canonical_selection_fixture();
2933        let members = [10.0, 15.0]
2934            .into_iter()
2935            .map(|luminosity| {
2936                fixture
2937                    .likelihood
2938                    .cross_section(
2939                        "signal",
2940                        fixture.generated.clone(),
2941                        luminosity,
2942                        fixture.likelihood.default_params(),
2943                    )
2944                    .unwrap()
2945            })
2946            .collect();
2947        let cross_section = CrossSection::combine(members).unwrap();
2948        let wide_axis = Axis::new(event_scalar("x"), vec![0.0, 2.0]).unwrap();
2949        let projections = vec![
2950            Projection::new("fine", vec![fixture.axis.clone()]).unwrap(),
2951            Projection::new("fine_alias", vec![fixture.axis.clone()]).unwrap(),
2952            Projection::new("wide", vec![wide_axis.clone()]).unwrap(),
2953        ];
2954
2955        reset_projection_evaluation_counts();
2956        let actual = cross_section
2957            .projection_set(&projections, &fixture.components)
2958            .unwrap();
2959        assert_eq!(projection_evaluation_counts(), (8, 12));
2960        assert_projection_close(
2961            actual.get("fine").unwrap(),
2962            actual.get("fine_alias").unwrap(),
2963        );
2964
2965        for (name, axes) in [("fine", vec![fixture.axis]), ("wide", vec![wide_axis])] {
2966            let expected = cross_section
2967                .differential(&axes, &fixture.components)
2968                .unwrap();
2969            assert_projection_close(actual.get(name).unwrap(), &expected);
2970        }
2971    }
2972
2973    #[test]
2974    fn combined_projection_sets_pair_distinct_ensemble_and_factor_sources() {
2975        let fixture = canonical_selection_fixture();
2976        let ensemble_a = Ensemble::new(
2977            vec!["a".into(), "b".into()],
2978            vec![vec![1.6, 0.7], vec![1.4, 0.8]],
2979        )
2980        .unwrap();
2981        let ensemble_b = Ensemble::new(
2982            vec!["a".into(), "b".into()],
2983            vec![vec![1.7, 0.6], vec![1.3, 0.9]],
2984        )
2985        .unwrap();
2986        let source_a = ensemble_a.source_id();
2987        let source_b = ensemble_b.source_id();
2988        let members = [ensemble_a.clone(), ensemble_b.clone()]
2989            .into_iter()
2990            .enumerate()
2991            .map(|(index, ensemble)| {
2992                fixture
2993                    .likelihood
2994                    .cross_section_with_ensemble(
2995                        "signal",
2996                        fixture.generated.clone(),
2997                        10.0 + index as f64 * 5.0,
2998                        fixture.likelihood.default_params(),
2999                        ensemble,
3000                    )
3001                    .unwrap()
3002            })
3003            .collect::<Vec<_>>();
3004        let factors = vec![
3005            Estimate::with_source_id(1.0, vec![1.1, 1.2], Some(source_a)).unwrap(),
3006            Estimate::with_source_id(2.0, vec![2.1, 2.2], Some(source_b)).unwrap(),
3007        ];
3008        let combined = CrossSection::combine_with_factors(members, factors.clone()).unwrap();
3009        let projection = Projection::new("x", vec![fixture.axis.clone()]).unwrap();
3010        let actual = combined
3011            .projection_set(std::slice::from_ref(&projection), &fixture.components)
3012            .unwrap();
3013
3014        for index in 0..2 {
3015            let paired_b = paired_draw_index(index, 1, 2, Some(source_b), Some(source_a));
3016            let explicit_members = [
3017                fixture
3018                    .likelihood
3019                    .cross_section(
3020                        "signal",
3021                        fixture.generated.clone(),
3022                        10.0,
3023                        ensemble_a.draws()[index].clone(),
3024                    )
3025                    .unwrap(),
3026                fixture
3027                    .likelihood
3028                    .cross_section(
3029                        "signal",
3030                        fixture.generated.clone(),
3031                        15.0,
3032                        ensemble_b.draws()[paired_b].clone(),
3033                    )
3034                    .unwrap(),
3035            ];
3036            let expected = CrossSection::combine_with_factors(
3037                explicit_members.into(),
3038                vec![
3039                    Estimate::central(factors[0].draws()[index]).unwrap(),
3040                    Estimate::central(factors[1].draws()[paired_b]).unwrap(),
3041                ],
3042            )
3043            .unwrap()
3044            .differential(projection.axes(), &fixture.components)
3045            .unwrap();
3046            let actual = actual.get("x").unwrap();
3047            assert_eq!(actual.data().draws()[index], expected.data().values());
3048            assert_eq!(actual.model().draws()[index], expected.model().values());
3049            for name in fixture.components.keys() {
3050                assert_eq!(
3051                    actual.components()[name].draws()[index],
3052                    expected.components()[name].values()
3053                );
3054            }
3055        }
3056    }
3057
3058    #[test]
3059    fn combined_projection_sets_use_arbitrary_replica_event_rows() {
3060        let model = CompiledModel::from_expr(&(event_scalar("x") + 1.0)).unwrap();
3061        let accepted = weighted_dataset(&[(0.25, 1.0), (1.25, 1.0)]);
3062        let generated = weighted_dataset(&[(0.25, 1.0), (1.25, 1.0)]);
3063        let make_likelihood = |data: Dataset| {
3064            Arc::new(
3065                Likelihood::new([crate::NllTerm::new("signal", &model, &data, &accepted).unwrap()])
3066                    .unwrap(),
3067            )
3068        };
3069        let likelihood = make_likelihood(weighted_dataset(&[(0.25, 1.0), (1.25, 1.0)]));
3070        let replicas = vec![
3071            make_likelihood(weighted_dataset(&[(0.25, 2.0)])),
3072            make_likelihood(weighted_dataset(&[(1.25, 3.0)])),
3073        ];
3074        let ensemble =
3075            Ensemble::with_replicas(Vec::new(), vec![Vec::new(), Vec::new()], replicas.clone())
3076                .unwrap();
3077        let members = [1.0, 2.0]
3078            .into_iter()
3079            .map(|luminosity| {
3080                likelihood
3081                    .cross_section_with_ensemble(
3082                        "signal",
3083                        generated.clone(),
3084                        luminosity,
3085                        Vec::new(),
3086                        ensemble.clone(),
3087                    )
3088                    .unwrap()
3089            })
3090            .collect();
3091        let axis = Axis::new(event_scalar("x"), vec![0.0, 1.0, 2.0]).unwrap();
3092        let projection = Projection::new("x", vec![axis.clone()]).unwrap();
3093        let actual = CrossSection::combine(members)
3094            .unwrap()
3095            .projection_set(std::slice::from_ref(&projection), &HashMap::new())
3096            .unwrap();
3097
3098        for (index, replica) in replicas.iter().enumerate() {
3099            let explicit_members = [1.0, 2.0]
3100                .into_iter()
3101                .map(|luminosity| {
3102                    replica
3103                        .cross_section("signal", generated.clone(), luminosity, Vec::new())
3104                        .unwrap()
3105                })
3106                .collect();
3107            let expected = CrossSection::combine(explicit_members)
3108                .unwrap()
3109                .differential(std::slice::from_ref(&axis), &HashMap::new())
3110                .unwrap();
3111            assert_eq!(
3112                actual.get("x").unwrap().data().draws()[index],
3113                expected.data().values()
3114            );
3115        }
3116    }
3117
3118    #[test]
3119    fn optimized_bootstrap_differential_matches_individual_replica_evaluations() {
3120        let x = event_scalar("x");
3121        let selected = (Expr::from(parameter!("a", initial: 1.5)) * x.clone()).tagged("selected");
3122        let remainder = Expr::from(parameter!("b", initial: 0.75)).tagged("remainder");
3123        let model = CompiledModel::from_expr(&(selected + remainder).norm_sqr()).unwrap();
3124        let data = weighted_dataset(&[(0.25, 1.0), (0.75, 2.0), (1.25, 1.0)]);
3125        let accepted = weighted_dataset(&[(0.25, 1.0), (0.75, 1.0), (1.25, 1.0)]);
3126        let generated = weighted_dataset(&[(0.25, 1.0), (0.75, 1.0), (1.25, 1.0), (1.75, 1.0)]);
3127        let likelihood = Arc::new(
3128            Likelihood::new([crate::NllTerm::new("signal", &model, &data, &accepted).unwrap()])
3129                .unwrap(),
3130        );
3131        let ensemble = Ensemble::bootstrap_fit(&likelihood, 3, 73, |replica, index| {
3132            let mut parameters = replica.default_params();
3133            parameters[0] += index as f64 * 0.1;
3134            parameters[1] -= index as f64 * 0.05;
3135            Ok::<_, std::convert::Infallible>(parameters)
3136        })
3137        .unwrap();
3138        let axis = Axis::new(x, vec![0.0, 1.0, 2.0]).unwrap();
3139        let components = HashMap::from([("selected".into(), vec!["selected".into()])]);
3140        let propagated = likelihood
3141            .cross_section_with_ensemble(
3142                "signal",
3143                generated.clone(),
3144                10.0,
3145                likelihood.default_params(),
3146                ensemble.clone(),
3147            )
3148            .unwrap()
3149            .differential(std::slice::from_ref(&axis), &components)
3150            .unwrap();
3151
3152        for (index, (replica, parameters)) in
3153            ensemble.replicas().iter().zip(ensemble.draws()).enumerate()
3154        {
3155            let individual = replica
3156                .cross_section("signal", generated.clone(), 10.0, parameters.clone())
3157                .unwrap()
3158                .differential(std::slice::from_ref(&axis), &components)
3159                .unwrap();
3160            assert_eq!(propagated.data().draws()[index], individual.data().values());
3161            assert_eq!(
3162                propagated.model().draws()[index],
3163                individual.model().values()
3164            );
3165            assert_eq!(
3166                propagated.components()["selected"].draws()[index],
3167                individual.components()["selected"].values()
3168            );
3169        }
3170    }
3171
3172    #[test]
3173    fn arbitrary_replica_differentials_use_each_replicas_event_rows() {
3174        let model = CompiledModel::from_expr(&(event_scalar("x") + 1.0)).unwrap();
3175        let accepted = weighted_dataset(&[(0.25, 1.0), (1.25, 1.0)]);
3176        let generated = weighted_dataset(&[(0.25, 1.0), (1.25, 1.0)]);
3177        let make_likelihood = |data: Dataset| {
3178            Arc::new(
3179                Likelihood::new([crate::NllTerm::new("signal", &model, &data, &accepted).unwrap()])
3180                    .unwrap(),
3181            )
3182        };
3183        let likelihood = make_likelihood(weighted_dataset(&[(0.25, 1.0), (1.25, 1.0)]));
3184        let replicas = vec![
3185            make_likelihood(weighted_dataset(&[(0.25, 2.0)])),
3186            make_likelihood(weighted_dataset(&[(1.25, 3.0)])),
3187        ];
3188        let ensemble =
3189            Ensemble::with_replicas(Vec::new(), vec![Vec::new(), Vec::new()], replicas.clone())
3190                .unwrap();
3191        let axis = Axis::new(event_scalar("x"), vec![0.0, 1.0, 2.0]).unwrap();
3192        let propagated = likelihood
3193            .cross_section_with_ensemble("signal", generated.clone(), 1.0, Vec::new(), ensemble)
3194            .unwrap()
3195            .differential(std::slice::from_ref(&axis), &HashMap::new())
3196            .unwrap();
3197
3198        for (index, replica) in replicas.iter().enumerate() {
3199            let individual = replica
3200                .cross_section("signal", generated.clone(), 1.0, Vec::new())
3201                .unwrap()
3202                .differential(std::slice::from_ref(&axis), &HashMap::new())
3203                .unwrap();
3204            assert_eq!(propagated.data().draws()[index], individual.data().values());
3205        }
3206    }
3207
3208    #[test]
3209    fn optimized_combined_differential_matches_explicit_draw_combinations() {
3210        let x = event_scalar("x");
3211        let selected = (Expr::from(parameter!("a", initial: 1.5)) * x.clone()).tagged("selected");
3212        let remainder = Expr::from(parameter!("b", initial: 0.75)).tagged("remainder");
3213        let model = CompiledModel::from_expr(&(selected + remainder).norm_sqr()).unwrap();
3214        let data = weighted_dataset(&[(0.25, 1.0), (0.75, 2.0), (1.25, 1.0)]);
3215        let accepted = weighted_dataset(&[(0.25, 1.0), (0.75, 1.0), (1.25, 1.0)]);
3216        let generated = weighted_dataset(&[(0.25, 1.0), (0.75, 1.0), (1.25, 1.0), (1.75, 1.0)]);
3217        let data_b = weighted_dataset(&[(0.25, 2.0), (0.75, 1.0), (1.75, 2.0)]);
3218        let accepted_b = weighted_dataset(&[(0.25, 1.0), (1.25, 1.0), (1.75, 1.0)]);
3219        let generated_b = weighted_dataset(&[(0.25, 1.0), (0.75, 1.0), (1.25, 2.0), (1.75, 1.0)]);
3220        let likelihood = Arc::new(
3221            Likelihood::new([
3222                crate::NllTerm::new("period_a", &model, &data, &accepted).unwrap(),
3223                crate::NllTerm::new("period_b", &model, &data_b, &accepted_b).unwrap(),
3224            ])
3225            .unwrap(),
3226        );
3227        let ensemble = Ensemble::bootstrap_fit(&likelihood, 3, 91, |replica, index| {
3228            let mut parameters = replica.default_params();
3229            parameters[0] += index as f64 * 0.1;
3230            parameters[1] -= index as f64 * 0.05;
3231            Ok::<_, std::convert::Infallible>(parameters)
3232        })
3233        .unwrap();
3234        let member_inputs = [
3235            ("period_a", generated.clone(), 10.0),
3236            ("period_b", generated_b.clone(), 15.0),
3237        ];
3238        let members = member_inputs
3239            .iter()
3240            .map(|(name, generated, luminosity)| {
3241                likelihood
3242                    .cross_section_with_ensemble(
3243                        *name,
3244                        generated.clone(),
3245                        *luminosity,
3246                        likelihood.default_params(),
3247                        ensemble.clone(),
3248                    )
3249                    .unwrap()
3250            })
3251            .collect();
3252        let axis = Axis::new(x, vec![0.0, 1.0, 2.0]).unwrap();
3253        let components = HashMap::from([("selected".into(), vec!["selected".into()])]);
3254        let propagated = CrossSection::combine(members)
3255            .unwrap()
3256            .differential(std::slice::from_ref(&axis), &components)
3257            .unwrap();
3258
3259        for (index, (replica, parameters)) in
3260            ensemble.replicas().iter().zip(ensemble.draws()).enumerate()
3261        {
3262            let explicit_members = member_inputs
3263                .iter()
3264                .map(|(name, generated, luminosity)| {
3265                    replica
3266                        .cross_section(*name, generated.clone(), *luminosity, parameters.clone())
3267                        .unwrap()
3268                })
3269                .collect();
3270            let explicit = CrossSection::combine(explicit_members)
3271                .unwrap()
3272                .differential(std::slice::from_ref(&axis), &components)
3273                .unwrap();
3274            assert_eq!(propagated.data().draws()[index], explicit.data().values());
3275            assert_eq!(propagated.model().draws()[index], explicit.model().values());
3276            assert_eq!(
3277                propagated.components()["selected"].draws()[index],
3278                explicit.components()["selected"].values()
3279            );
3280        }
3281    }
3282}