Skip to main content

laddu_likelihood/
likelihood.rs

1use std::{
2    collections::HashSet,
3    fmt::Debug,
4    sync::{
5        Arc,
6        atomic::{AtomicU64, Ordering},
7    },
8};
9
10use crate::{LikelihoodError, LikelihoodResult};
11use laddu_compile::{CompiledModel, ReductionPlan};
12use laddu_data::data::Dataset;
13#[cfg(test)]
14use laddu_expr::parameters::ParamError;
15use laddu_expr::parameters::{ParamId, ParamLayout, ParamRegistry, ParamValues};
16#[cfg(test)]
17use laddu_runtime::NormalizationMode;
18use laddu_runtime::{
19    Execution, PreparedDataset, PreparedModel, PreparedNormalization,
20    PreparedNormalizationDiagnostics, RuntimeError,
21};
22
23/// Role of a prepared dataset within a likelihood term.
24#[derive(Copy, Clone, Debug, PartialEq, Eq)]
25pub enum DatasetRole {
26    /// Observed events entering the data contribution.
27    Observed,
28    /// Accepted Monte Carlo entering the normalization contribution.
29    AcceptedMc,
30}
31
32/// Preparation diagnostics for one likelihood dataset.
33#[derive(Clone, Debug, PartialEq)]
34pub struct DatasetDiagnostics {
35    term: String,
36    role: DatasetRole,
37    stats: laddu_runtime::PreparedDatasetStats,
38    quadratic_normalization: bool,
39    normalization: Option<PreparedNormalizationDiagnostics>,
40    source_traversals: u64,
41}
42
43impl DatasetDiagnostics {
44    /// Returns the owning likelihood-term name.
45    pub fn term(&self) -> &str {
46        &self.term
47    }
48
49    /// Returns the dataset's role within the term.
50    pub fn role(&self) -> DatasetRole {
51        self.role
52    }
53
54    /// Returns the runtime preparation statistics.
55    pub fn stats(&self) -> &laddu_runtime::PreparedDatasetStats {
56        &self.stats
57    }
58
59    /// Returns whether accepted normalization uses compiler-native sufficient statistics.
60    ///
61    /// The historical method name is retained for compatibility.
62    pub fn uses_quadratic_normalization(&self) -> bool {
63        self.quadratic_normalization
64    }
65
66    /// Returns compiler-native normalization preparation diagnostics.
67    pub fn normalization(&self) -> Option<&PreparedNormalizationDiagnostics> {
68        self.normalization.as_ref()
69    }
70
71    /// Returns the number of source traversals opened through this dataset view.
72    pub fn source_traversals(&self) -> u64 {
73        self.source_traversals
74    }
75}
76
77/// Snapshot of likelihood preparation and evaluation behavior.
78#[derive(Clone, Debug, PartialEq)]
79pub struct LikelihoodDiagnostics {
80    datasets: Vec<DatasetDiagnostics>,
81    objective_evaluations: u64,
82    gradient_evaluations: u64,
83    memory_decisions: Vec<laddu_runtime::MemoryDecision>,
84}
85
86impl LikelihoodDiagnostics {
87    /// Returns prepared-dataset records in term and role order.
88    pub fn datasets(&self) -> &[DatasetDiagnostics] {
89        &self.datasets
90    }
91
92    /// Returns the number of value-only objective requests.
93    pub fn objective_evaluations(&self) -> u64 {
94        self.objective_evaluations
95    }
96
97    /// Returns the number of value-and-gradient objective requests.
98    pub fn gradient_evaluations(&self) -> u64 {
99        self.gradient_evaluations
100    }
101
102    /// Returns memory-planning decisions recorded by the execution.
103    pub fn memory_decisions(&self) -> &[laddu_runtime::MemoryDecision] {
104        &self.memory_decisions
105    }
106}
107
108/// Stable, user-supplied name identifying a likelihood term.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct LikelihoodName(String);
111
112impl LikelihoodName {
113    /// Creates a likelihood name.
114    pub fn new(name: impl Into<String>) -> Self {
115        Self(name.into())
116    }
117
118    /// Returns the name as a string slice.
119    pub fn as_str(&self) -> &str {
120        &self.0
121    }
122}
123
124/// A scalar likelihood value and its free-parameter gradient.
125#[derive(Clone, Debug, PartialEq)]
126pub struct LikelihoodEvaluation {
127    value: f64,
128    gradient: Vec<f64>,
129}
130
131impl LikelihoodEvaluation {
132    /// Creates an evaluation from a value and gradient.
133    pub fn new(value: f64, gradient: Vec<f64>) -> Self {
134        Self { value, gradient }
135    }
136
137    /// Returns the objective value.
138    pub fn value(&self) -> f64 {
139        self.value
140    }
141
142    /// Returns derivatives in free-parameter order.
143    pub fn gradient(&self) -> &[f64] {
144        &self.gradient
145    }
146
147    /// Consumes the evaluation and returns its value and gradient.
148    pub fn into_parts(self) -> (f64, Vec<f64>) {
149        (self.value, self.gradient)
150    }
151}
152
153/// Backend-neutral differentiable objective over a stable free-parameter layout.
154///
155/// This object-safe interface is intended for downstream optimizers and
156/// samplers. It deliberately does not prescribe parameter transforms,
157/// minimization strategy, or result types.
158pub trait Objective: Debug + Send + Sync {
159    /// Returns the stable parameter layout used by objective vectors.
160    fn parameter_layout(&self) -> &ParamLayout;
161    /// Evaluates the objective at the supplied free-parameter vector.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`LikelihoodError`] when the parameter vector is invalid or
166    /// objective evaluation fails.
167    fn value(&self, free_parameters: &[f64]) -> LikelihoodResult<f64>;
168    /// Evaluates the objective and its gradient.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`LikelihoodError`] when the parameter vector is invalid or
173    /// objective or gradient evaluation fails.
174    fn value_gradient(&self, free_parameters: &[f64]) -> LikelihoodResult<LikelihoodEvaluation>;
175}
176
177/// An objective that can provide an unbiased stochastic value and gradient.
178///
179/// The `seed` identifies one deterministic batch. Implementations must use the
180/// same batch for the returned value and gradient.
181pub trait StochasticObjective: Objective {
182    /// Evaluates an unbiased stochastic objective and gradient on a deterministic batch.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`LikelihoodError`] when the parameter vector or batch fraction
187    /// is invalid, or stochastic evaluation fails.
188    fn stochastic_value_gradient(
189        &self,
190        free_parameters: &[f64],
191        fraction: f64,
192        seed: u64,
193    ) -> LikelihoodResult<LikelihoodEvaluation>;
194}
195
196/// A composable contribution to a negative log likelihood.
197pub trait LikelihoodTerm: Debug + Send + Sync {
198    /// Returns the unique term name.
199    fn name(&self) -> &str;
200
201    /// Appends preparation diagnostics owned by this term.
202    fn append_diagnostics(&self, _diagnostics: &mut Vec<DatasetDiagnostics>) {}
203
204    /// Returns whether [`Self::bootstrap_clone`] preserves resolved preparation state.
205    fn bootstrap_clone_is_prepared(&self) -> bool {
206        false
207    }
208
209    /// Clones this term while applying a deterministic Poisson bootstrap to
210    /// its observed dataset.
211    ///
212    /// Terms without observed event data should return an ordinary clone.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`LikelihoodError`] when the term cannot be cloned for a
217    /// bootstrap replica.
218    fn bootstrap_clone(&self, _seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
219        Err(LikelihoodError::Runtime(RuntimeError::InvalidShape {
220            index: 0,
221            message: format!("term `{}` does not support bootstrap cloning", self.name()),
222        }))
223    }
224
225    /// Registers parameters required by this term.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`LikelihoodError`] when parameter definitions are invalid or
230    /// conflict with previously registered definitions.
231    fn register_params(&self, _registry: &mut ParamRegistry) -> LikelihoodResult<()> {
232        Ok(())
233    }
234
235    /// Resolves the term against a global parameter layout and execution context.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`LikelihoodError`] when required parameters are missing or
240    /// incompatible, or runtime preparation fails.
241    fn resolve(
242        &mut self,
243        global_params: Arc<ParamLayout>,
244        execution: &Execution,
245    ) -> LikelihoodResult<()>;
246
247    /// Evaluates this term's negative-log-likelihood contribution.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`LikelihoodError`] when parameters are incompatible or term
252    /// evaluation fails.
253    fn nll(&self, params: &ParamValues, execution: &Execution) -> LikelihoodResult<f64>;
254
255    /// Adds this term's gradient to `gradient` and returns its objective contribution.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`LikelihoodError`] when parameters or gradient length are
260    /// incompatible, or value or derivative evaluation fails.
261    fn nll_with_gradient(
262        &self,
263        params: &ParamValues,
264        gradient: &mut [f64],
265        execution: &Execution,
266    ) -> LikelihoodResult<f64> {
267        let layout = params.layout();
268        if gradient.len() != layout.n_free() {
269            return Err(LikelihoodError::GradientLengthMismatch {
270                expected: layout.n_free(),
271                actual: gradient.len(),
272            });
273        }
274
275        let value = self.nll(params, execution)?;
276        for (free_index, id) in layout.free_params().iter().copied().enumerate() {
277            let parameter = layout.spec(id)?;
278            let free_id = layout
279                .free_id(id)?
280                .ok_or(LikelihoodError::ParameterLayoutMismatch)?;
281            let center = params.get(id)?;
282            let scale = center.abs().max(1.0);
283            let base_step = f64::EPSILON.cbrt() * scale;
284            let bounds = parameter.bounds_spec();
285            let left_room = bounds
286                .min
287                .map_or(f64::INFINITY, |min| (center - min).max(0.0));
288            let right_room = bounds
289                .max
290                .map_or(f64::INFINITY, |max| (max - center).max(0.0));
291
292            let derivative = if left_room > 0.0 && right_room > 0.0 {
293                let step = base_step.min(left_room).min(right_room);
294                let mut plus = params.clone();
295                let mut minus = params.clone();
296                plus.set_free(free_id, center + step)?;
297                minus.set_free(free_id, center - step)?;
298                (self.nll(&plus, execution)? - self.nll(&minus, execution)?) / (2.0 * step)
299            } else if right_room > 0.0 {
300                let step = base_step.min(right_room);
301                let mut plus = params.clone();
302                plus.set_free(free_id, center + step)?;
303                (self.nll(&plus, execution)? - value) / step
304            } else if left_room > 0.0 {
305                let step = base_step.min(left_room);
306                let mut minus = params.clone();
307                minus.set_free(free_id, center - step)?;
308                (value - self.nll(&minus, execution)?) / step
309            } else {
310                0.0
311            };
312            gradient[free_index] += derivative;
313        }
314        Ok(value)
315    }
316
317    /// Evaluate a stochastic term contribution. Non-data terms remain exact by
318    /// default; intensity terms override this to batch only observed events.
319    ///
320    /// # Errors
321    ///
322    /// Returns [`LikelihoodError`] when parameters, gradient length, or batch
323    /// fraction are invalid, or evaluation fails.
324    fn stochastic_nll_with_gradient(
325        &self,
326        params: &ParamValues,
327        gradient: &mut [f64],
328        execution: &Execution,
329        _fraction: f64,
330        _seed: u64,
331    ) -> LikelihoodResult<f64> {
332        self.nll_with_gradient(params, gradient, execution)
333    }
334
335    /// Returns this term as an intensity term when supported.
336    fn as_intensity(&self) -> Option<&NllTerm> {
337        None
338    }
339
340    /// Reports whether this term determines an absolute expected event rate.
341    ///
342    /// Shape-only intensity terms leave their overall scale unconstrained.
343    /// Extended intensity terms override this when their normalization is part
344    /// of the objective.
345    fn has_absolute_rate(&self) -> bool {
346        false
347    }
348
349    /// Boxes this term for use in a heterogeneous [`Likelihood`].
350    fn boxed(self) -> Box<dyn LikelihoodTerm>
351    where
352        Self: Sized + 'static,
353    {
354        Box::new(self)
355    }
356}
357
358/// Accepted parameter representations for likelihood evaluation.
359pub enum Parameters<'a> {
360    /// Free values in the likelihood's parameter order.
361    Slice(&'a [f64]),
362    /// Fully resolved parameter values.
363    ParamValues(&'a ParamValues),
364}
365
366impl<'a> From<&'a [f64]> for Parameters<'a> {
367    fn from(val: &'a [f64]) -> Self {
368        Self::Slice(val)
369    }
370}
371
372impl<'a, const N: usize> From<&'a [f64; N]> for Parameters<'a> {
373    fn from(val: &'a [f64; N]) -> Self {
374        Self::Slice(val.as_slice())
375    }
376}
377
378impl<'a> From<&'a Vec<f64>> for Parameters<'a> {
379    fn from(value: &'a Vec<f64>) -> Self {
380        Self::Slice(value.as_slice())
381    }
382}
383
384impl<'a> From<&'a ParamValues> for Parameters<'a> {
385    fn from(val: &'a ParamValues) -> Self {
386        Self::ParamValues(val)
387    }
388}
389
390/// A resolved collection of likelihood terms sharing one parameter layout.
391#[derive(Debug)]
392pub struct Likelihood {
393    params: Arc<ParamLayout>,
394    terms: Vec<Box<dyn LikelihoodTerm>>,
395    execution: Execution,
396    objective_evaluations: AtomicU64,
397    gradient_evaluations: AtomicU64,
398}
399
400impl Likelihood {
401    /// Construct a likelihood from terms of one concrete type.
402    ///
403    /// The terms are boxed internally, so the common case does not require a
404    /// manual [`LikelihoodTerm::boxed`] call.
405    ///
406    /// # Errors
407    ///
408    /// Returns [`LikelihoodError`] when term names or parameter definitions
409    /// conflict, or a term cannot be resolved.
410    pub fn new<T>(terms: impl IntoIterator<Item = T>) -> LikelihoodResult<Self>
411    where
412        T: LikelihoodTerm + 'static,
413    {
414        Self::with_execution(terms, &Execution::default())
415    }
416
417    /// Construct a likelihood containing heterogeneous, already boxed terms.
418    ///
419    /// # Errors
420    ///
421    /// Returns [`LikelihoodError`] when term names or parameter definitions
422    /// conflict, or a term cannot be resolved.
423    pub fn new_boxed(
424        terms: impl IntoIterator<Item = Box<dyn LikelihoodTerm>>,
425    ) -> LikelihoodResult<Self> {
426        Self::with_execution_boxed(terms, &Execution::default())
427    }
428
429    /// Constructs a likelihood with an explicit execution context.
430    ///
431    /// # Errors
432    ///
433    /// Returns [`LikelihoodError`] when term names or parameter definitions
434    /// conflict, or a term cannot be resolved for `execution`.
435    pub fn with_execution<T>(
436        terms: impl IntoIterator<Item = T>,
437        execution: &Execution,
438    ) -> LikelihoodResult<Self>
439    where
440        T: LikelihoodTerm + 'static,
441    {
442        Self::with_execution_boxed(
443            terms
444                .into_iter()
445                .map(|term| Box::new(term) as Box<dyn LikelihoodTerm>),
446            execution,
447        )
448    }
449
450    /// Construct a heterogeneous likelihood using a borrowed execution setup.
451    ///
452    /// # Errors
453    ///
454    /// Returns [`LikelihoodError`] when term names or parameter definitions
455    /// conflict, or a term cannot be resolved for `execution`.
456    pub fn with_execution_boxed(
457        terms: impl IntoIterator<Item = Box<dyn LikelihoodTerm>>,
458        execution: &Execution,
459    ) -> LikelihoodResult<Self> {
460        let mut terms: Vec<_> = terms.into_iter().collect();
461        let mut names = HashSet::new();
462        let mut registry = ParamRegistry::new();
463
464        for term in &terms {
465            if !names.insert(term.name().to_owned()) {
466                return Err(LikelihoodError::DuplicateTermName(term.name().to_owned()));
467            }
468            term.register_params(&mut registry)?;
469        }
470
471        let params = Arc::new(registry.layout()?);
472        for term in &mut terms {
473            term.resolve(Arc::clone(&params), execution)?;
474        }
475
476        Ok(Self {
477            params,
478            terms,
479            execution: execution.clone(),
480            objective_evaluations: AtomicU64::new(0),
481            gradient_evaluations: AtomicU64::new(0),
482        })
483    }
484
485    /// Returns the global parameter layout.
486    pub fn params(&self) -> &ParamLayout {
487        &self.params
488    }
489
490    /// Return deterministic initial values in the order expected by [`Self::nll`].
491    pub fn default_params(&self) -> Vec<f64> {
492        self.params.initial_free_values()
493    }
494
495    /// Generate one value for each free parameter in objective-vector order.
496    pub fn params_with(
497        &self,
498        value: impl FnMut(&laddu_expr::parameters::Parameter) -> f64,
499    ) -> Vec<f64> {
500        self.params.free_values_with(value)
501    }
502
503    /// Sample uniform initial ranges while preserving fixed and point-initialized parameters.
504    pub fn sample_initial(&self, seed: u64) -> Vec<f64> {
505        self.params.sample_initial(seed)
506    }
507
508    /// Rebuilds this likelihood with Poisson-bootstrapped observed datasets.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`LikelihoodError`] when a term cannot be bootstrap-cloned or
513    /// the rebuilt likelihood cannot be prepared.
514    pub fn bootstrap(&self, seed: u64) -> LikelihoodResult<Self> {
515        let clones_are_prepared = self
516            .terms
517            .iter()
518            .all(|term| term.bootstrap_clone_is_prepared());
519        let terms = self
520            .terms
521            .iter()
522            .enumerate()
523            .map(|(index, term)| {
524                term.bootstrap_clone(
525                    seed.wrapping_add((index as u64).wrapping_mul(0x9E3779B97F4A7C15)),
526                )
527            })
528            .collect::<LikelihoodResult<Vec<_>>>()?;
529        if clones_are_prepared {
530            Ok(Self {
531                params: Arc::clone(&self.params),
532                terms,
533                execution: self.execution.clone(),
534                objective_evaluations: AtomicU64::new(0),
535                gradient_evaluations: AtomicU64::new(0),
536            })
537        } else {
538            Self::with_execution_boxed(terms, &self.execution)
539        }
540    }
541
542    /// Returns the resolved likelihood terms.
543    pub fn terms(&self) -> &[Box<dyn LikelihoodTerm>] {
544        &self.terms
545    }
546
547    /// Returns the execution context used by the likelihood.
548    pub fn execution(&self) -> &Execution {
549        &self.execution
550    }
551
552    /// Returns a snapshot of preparation and objective-evaluation diagnostics.
553    pub fn diagnostics(&self) -> LikelihoodDiagnostics {
554        let mut datasets = Vec::new();
555        for term in &self.terms {
556            term.append_diagnostics(&mut datasets);
557        }
558        LikelihoodDiagnostics {
559            datasets,
560            objective_evaluations: self.objective_evaluations.load(Ordering::Relaxed),
561            gradient_evaluations: self.gradient_evaluations.load(Ordering::Relaxed),
562            memory_decisions: self.execution.memory_decisions(),
563        }
564    }
565
566    /// Evaluate the objective from free values in [`Self::params`] order.
567    ///
568    /// # Errors
569    ///
570    /// Returns [`LikelihoodError`] when parameters have the wrong layout or
571    /// length, violate bounds, or a term cannot be evaluated.
572    pub fn nll<'a>(&self, parameters: impl Into<Parameters<'a>>) -> LikelihoodResult<f64> {
573        let params = match parameters.into() {
574            Parameters::Slice(free) => &self.params.values(free)?,
575            Parameters::ParamValues(param_values) => param_values,
576        };
577        self.nll_values(params)
578    }
579
580    fn nll_values(&self, params: &ParamValues) -> LikelihoodResult<f64> {
581        self.objective_evaluations.fetch_add(1, Ordering::Relaxed);
582        check_params(&self.params, params)?;
583        self.terms.iter().try_fold(
584            0.0,
585            |sum, term| Ok(sum + term.nll(params, &self.execution)?),
586        )
587    }
588
589    /// Evaluates the objective and gradient from free or resolved parameter values.
590    ///
591    /// # Errors
592    ///
593    /// Returns [`LikelihoodError`] when parameters are invalid or a term's
594    /// value or gradient cannot be evaluated.
595    pub fn nll_with_gradient<'a>(
596        &self,
597        parameters: impl Into<Parameters<'a>>,
598    ) -> LikelihoodResult<LikelihoodEvaluation> {
599        let params = match parameters.into() {
600            Parameters::Slice(free) => &self.params.values(free)?,
601            Parameters::ParamValues(param_values) => param_values,
602        };
603        self.nll_with_gradient_values(params)
604    }
605
606    fn nll_with_gradient_values(
607        &self,
608        params: &ParamValues,
609    ) -> LikelihoodResult<LikelihoodEvaluation> {
610        self.gradient_evaluations.fetch_add(1, Ordering::Relaxed);
611        check_params(&self.params, params)?;
612        let mut gradient = vec![0.0; self.params.n_free()];
613        let value = self.terms.iter().try_fold(0.0, |sum, term| {
614            Ok::<_, LikelihoodError>(
615                sum + term.nll_with_gradient(params, &mut gradient, &self.execution)?,
616            )
617        })?;
618        Ok(LikelihoodEvaluation { value, gradient })
619    }
620
621    /// Evaluates an unbiased stochastic objective and gradient.
622    ///
623    /// # Errors
624    ///
625    /// Returns [`LikelihoodError`] when `fraction` is outside `(0, 1]`,
626    /// parameters are invalid, or stochastic term evaluation fails.
627    pub fn stochastic_nll_with_gradient(
628        &self,
629        free_parameters: &[f64],
630        fraction: f64,
631        seed: u64,
632    ) -> LikelihoodResult<LikelihoodEvaluation> {
633        self.gradient_evaluations.fetch_add(1, Ordering::Relaxed);
634        if !(fraction > 0.0 && fraction <= 1.0) {
635            return Err(LikelihoodError::InvalidBatchFraction(fraction));
636        }
637        let params = self.params.values(free_parameters)?;
638        let mut gradient = vec![0.0; self.params.n_free()];
639        let value = self
640            .terms
641            .iter()
642            .enumerate()
643            .try_fold(0.0, |sum, (term_index, term)| {
644                let term_seed =
645                    seed.wrapping_add((term_index as u64).wrapping_mul(0x9E3779B97F4A7C15));
646                Ok::<_, LikelihoodError>(
647                    sum + term.stochastic_nll_with_gradient(
648                        &params,
649                        &mut gradient,
650                        &self.execution,
651                        fraction,
652                        term_seed,
653                    )?,
654                )
655            })?;
656        Ok(LikelihoodEvaluation::new(value, gradient))
657    }
658
659    /// Prepares accepted and generated Monte Carlo integrals for an intensity term.
660    ///
661    /// # Errors
662    ///
663    /// Returns [`LikelihoodError`] when `term_name` is missing or not an
664    /// intensity term, or dataset preparation fails.
665    pub fn cross_section_integrals(
666        &self,
667        term_name: &str,
668        generated_mc: &Dataset,
669    ) -> LikelihoodResult<CrossSectionIntegrals> {
670        let Some(term) = self.terms.iter().find(|term| term.name() == term_name) else {
671            return Err(LikelihoodError::MissingTerm(term_name.to_owned()));
672        };
673        let has_absolute_rate = term.has_absolute_rate();
674        let Some(term) = term.as_intensity() else {
675            return Err(LikelihoodError::NotIntensityTerm(term_name.to_owned()));
676        };
677        term.cross_section_integrals(generated_mc, &self.execution, has_absolute_rate)
678    }
679
680    /// Prepares tag-narrowed accepted and generated Monte Carlo integrals.
681    ///
682    /// The selected tags define the numerator contribution. Cross sections
683    /// retain the full accepted-model normalization, matching
684    /// [`Self::projection`].
685    ///
686    /// # Errors
687    ///
688    /// Returns [`LikelihoodError`] when `term_name` is missing or not an
689    /// intensity term, graph projection fails, or dataset preparation fails.
690    pub fn cross_section_integrals_with_tags<'a>(
691        &self,
692        term_name: &str,
693        generated_mc: &Dataset,
694        tags: impl IntoIterator<Item = &'a str>,
695    ) -> LikelihoodResult<CrossSectionIntegrals> {
696        let Some(term) = self.terms.iter().find(|term| term.name() == term_name) else {
697            return Err(LikelihoodError::MissingTerm(term_name.to_owned()));
698        };
699        let has_absolute_rate = term.has_absolute_rate();
700        let Some(term) = term.as_intensity() else {
701            return Err(LikelihoodError::NotIntensityTerm(term_name.to_owned()));
702        };
703        term.cross_section_integrals_with_tags(
704            generated_mc,
705            tags,
706            &self.execution,
707            has_absolute_rate,
708        )
709    }
710
711    /// Returns the observed and accepted Monte Carlo sources for an intensity term.
712    ///
713    /// # Errors
714    ///
715    /// Returns [`LikelihoodError`] when `term_name` is missing or is not an
716    /// intensity term.
717    pub fn intensity_datasets(&self, term_name: &str) -> LikelihoodResult<(&Dataset, &Dataset)> {
718        let Some(term) = self.terms.iter().find(|term| term.name() == term_name) else {
719            return Err(LikelihoodError::MissingTerm(term_name.to_owned()));
720        };
721        let Some(term) = term.as_intensity() else {
722            return Err(LikelihoodError::NotIntensityTerm(term_name.to_owned()));
723        };
724        Ok((&term.data_source, &term.accepted_mc_source))
725    }
726
727    /// Projects an intensity term onto selected model tags over generated Monte Carlo.
728    ///
729    /// # Errors
730    ///
731    /// Returns [`LikelihoodError`] when `term_name` is missing or not an
732    /// intensity term, graph projection fails, or dataset preparation fails.
733    pub fn projection<'a>(
734        &self,
735        term_name: &str,
736        generated_mc: &Dataset,
737        tags: impl IntoIterator<Item = &'a str>,
738    ) -> LikelihoodResult<LikelihoodProjection> {
739        let Some(term) = self.terms.iter().find(|term| term.name() == term_name) else {
740            return Err(LikelihoodError::MissingTerm(term_name.to_owned()));
741        };
742        let has_absolute_rate = term.has_absolute_rate();
743        let Some(term) = term.as_intensity() else {
744            return Err(LikelihoodError::NotIntensityTerm(term_name.to_owned()));
745        };
746        term.projection(generated_mc, tags, &self.execution, has_absolute_rate)
747    }
748}
749
750impl Objective for Likelihood {
751    fn parameter_layout(&self) -> &ParamLayout {
752        self.params()
753    }
754
755    fn value(&self, free_parameters: &[f64]) -> LikelihoodResult<f64> {
756        self.nll(free_parameters)
757    }
758
759    fn value_gradient(&self, free_parameters: &[f64]) -> LikelihoodResult<LikelihoodEvaluation> {
760        self.nll_with_gradient(free_parameters)
761    }
762}
763
764impl StochasticObjective for Likelihood {
765    fn stochastic_value_gradient(
766        &self,
767        free_parameters: &[f64],
768        fraction: f64,
769        seed: u64,
770    ) -> LikelihoodResult<LikelihoodEvaluation> {
771        self.stochastic_nll_with_gradient(free_parameters, fraction, seed)
772    }
773}
774
775#[derive(Clone)]
776/// A shape-normalized unbinned negative-log-likelihood term.
777pub struct NllTerm {
778    name: LikelihoodName,
779    model: CompiledModel,
780    local_params: Arc<ParamLayout>,
781    data_source: Dataset,
782    accepted_mc_source: Dataset,
783    state: NllState,
784}
785
786#[derive(Clone)]
787enum NllState {
788    Unresolved,
789    Prepared(Box<PreparedNll>),
790}
791
792#[derive(Clone)]
793struct PreparedNll {
794    plan: PreparedModel,
795    projection: ParamProjection,
796    data: PreparedDataset,
797    normalization: NormalizationData,
798    data_weight_sum: f64,
799    execution: Execution,
800}
801
802#[derive(Clone)]
803enum NormalizationData {
804    PreparedDataset(Box<PreparedDataset>),
805    CompilerNative(Arc<PreparedNormalization>),
806}
807
808impl std::fmt::Debug for NllTerm {
809    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810        formatter
811            .debug_struct("NllTerm")
812            .field("name", &self.name)
813            .field("prepared", &matches!(self.state, NllState::Prepared(_)))
814            .finish_non_exhaustive()
815    }
816}
817
818impl NllTerm {
819    fn bootstrap_term(&self, seed: u64) -> LikelihoodResult<Self> {
820        let data_source = self.data_source.clone().bootstrap(seed);
821        let state = match &self.state {
822            NllState::Unresolved => NllState::Unresolved,
823            NllState::Prepared(prepared) => {
824                let data = prepared
825                    .plan
826                    .prepare_dataset(&prepared.execution, &data_source)?;
827                NllState::Prepared(Box::new(PreparedNll {
828                    plan: prepared.plan.clone(),
829                    projection: prepared.projection.clone(),
830                    data_weight_sum: data.stats().sum_weights(),
831                    data,
832                    normalization: prepared.normalization.clone(),
833                    execution: prepared.execution.clone(),
834                }))
835            }
836        };
837        Ok(Self {
838            name: self.name.clone(),
839            model: self.model.clone(),
840            local_params: Arc::clone(&self.local_params),
841            data_source,
842            accepted_mc_source: self.accepted_mc_source.clone(),
843            state,
844        })
845    }
846    fn projection<'a>(
847        &self,
848        generated_mc: &Dataset,
849        tags: impl IntoIterator<Item = &'a str>,
850        execution: &Execution,
851        has_absolute_rate: bool,
852    ) -> LikelihoodResult<LikelihoodProjection> {
853        let projected_model =
854            self.model
855                .project_tags(tags)
856                .map_err(|error| RuntimeError::InvalidShape {
857                    index: 0,
858                    message: error.to_string(),
859                })?;
860        let projected_plan = PreparedModel::prepare(&projected_model, execution)?;
861        let projected_normalization = PreparedNormalization::prepare(
862            &projected_model,
863            &projected_plan,
864            &self.accepted_mc_source,
865            execution,
866        )?;
867        let projected_params = ParamProjection::new(
868            Arc::clone(&self.resolved_projection()?.global_layout),
869            projected_model.params(),
870            self.name(),
871        )?;
872        Ok(LikelihoodProjection {
873            name: self.name.clone(),
874            full_plan: self.plan()?.clone(),
875            full_projection: self.resolved_projection()?.clone(),
876            full_accepted_mc: self.accepted_mc_for_analysis(execution)?,
877            full_normalization: self.compiler_native_normalization()?,
878            projected_accepted_mc: projected_plan
879                .prepare_dataset(execution, &self.accepted_mc_source)?,
880            projected_normalization,
881            projected_generated_mc: projected_plan.prepare_dataset(execution, generated_mc)?,
882            accepted_mc_source: self.accepted_mc_source.clone(),
883            generated_mc_source: generated_mc.clone(),
884            projected_plan,
885            projected_params,
886            data_weight_sum: self.data_weight_sum()?,
887            has_absolute_rate,
888            execution: execution.clone(),
889        })
890    }
891
892    /// Creates an unresolved intensity term from data and accepted Monte Carlo datasets.
893    ///
894    /// # Errors
895    ///
896    /// This constructor currently succeeds for all inputs. The result type is
897    /// retained for compatibility with other fallible likelihood constructors.
898    pub fn new(
899        name: impl Into<String>,
900        model: &CompiledModel,
901        data: &Dataset,
902        accepted_mc: &Dataset,
903    ) -> LikelihoodResult<Self> {
904        Ok(Self {
905            name: LikelihoodName::new(name),
906            model: model.clone(),
907            local_params: Arc::new(model.params().clone()),
908            data_source: data.clone(),
909            accepted_mc_source: accepted_mc.clone(),
910            state: NllState::Unresolved,
911        })
912    }
913
914    /// Returns the prepared observed dataset.
915    ///
916    /// # Errors
917    ///
918    /// Returns [`LikelihoodError::UnresolvedTerm`] when this term has not been
919    /// resolved as part of a [`Likelihood`].
920    pub fn data(&self) -> LikelihoodResult<&PreparedDataset> {
921        match &self.state {
922            NllState::Prepared(prepared) => Ok(&prepared.data),
923            NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
924        }
925    }
926
927    fn plan(&self) -> LikelihoodResult<&PreparedModel> {
928        match &self.state {
929            NllState::Prepared(prepared) => Ok(&prepared.plan),
930            NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
931        }
932    }
933
934    /// Returns the prepared accepted Monte Carlo dataset.
935    ///
936    /// # Errors
937    ///
938    /// Returns [`LikelihoodError::UnresolvedTerm`] when this term has not been
939    /// resolved as part of a [`Likelihood`].
940    pub fn accepted_mc(&self) -> LikelihoodResult<&PreparedDataset> {
941        match &self.state {
942            NllState::Prepared(prepared) => match &prepared.normalization {
943                NormalizationData::PreparedDataset(accepted_mc) => Ok(accepted_mc),
944                NormalizationData::CompilerNative(_) => {
945                    Err(LikelihoodError::UnresolvedTerm(self.name().to_owned()))
946                }
947            },
948            NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
949        }
950    }
951
952    fn accepted_mc_for_analysis(&self, execution: &Execution) -> LikelihoodResult<PreparedDataset> {
953        match &self.state {
954            NllState::Prepared(prepared) => match &prepared.normalization {
955                NormalizationData::PreparedDataset(accepted_mc) => Ok((**accepted_mc).clone()),
956                NormalizationData::CompilerNative(_) => Ok(prepared
957                    .plan
958                    .prepare_dataset(execution, &self.accepted_mc_source)?),
959            },
960            NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
961        }
962    }
963
964    /// Returns the observed dataset's total event weight.
965    ///
966    /// # Errors
967    ///
968    /// Returns [`LikelihoodError::UnresolvedTerm`] when this term has not been
969    /// resolved as part of a [`Likelihood`].
970    pub fn data_weight_sum(&self) -> LikelihoodResult<f64> {
971        match &self.state {
972            NllState::Prepared(prepared) => Ok(prepared.data_weight_sum),
973            NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
974        }
975    }
976
977    /// Returns the weighted log-intensity sum over observed data.
978    ///
979    /// # Errors
980    ///
981    /// Returns [`LikelihoodError`] when the term is unresolved, parameters are
982    /// invalid, runtime evaluation fails, or an intensity is not positive.
983    pub fn data_log_intensity_sum(&self, free: &[f64]) -> LikelihoodResult<f64> {
984        let params = self.global_values(free)?;
985        let local_params = self.local_values(&params)?;
986        self.reduce(
987            &local_params,
988            self.data()?,
989            ReductionPlan::weighted_log_positive_real(),
990            "data",
991        )
992    }
993
994    /// Returns the weighted intensity integral over accepted Monte Carlo.
995    ///
996    /// # Errors
997    ///
998    /// Returns [`LikelihoodError`] when the term is unresolved, parameters are
999    /// invalid, runtime evaluation fails, or an intensity is not positive.
1000    pub fn accepted_normalization(&self, free: &[f64]) -> LikelihoodResult<f64> {
1001        let params = self.global_values(free)?;
1002        let local_params = self.local_values(&params)?;
1003        self.normalization_value(&local_params, self.resolved_execution()?)
1004    }
1005
1006    fn cross_section_integrals(
1007        &self,
1008        generated_mc: &Dataset,
1009        execution: &Execution,
1010        has_absolute_rate: bool,
1011    ) -> LikelihoodResult<CrossSectionIntegrals> {
1012        let plan = self.plan()?.clone();
1013        let accepted_mc = self.accepted_mc_for_analysis(execution)?;
1014        Ok(CrossSectionIntegrals {
1015            name: self.name.clone(),
1016            full_plan: plan.clone(),
1017            full_projection: self.resolved_projection()?.clone(),
1018            full_accepted_mc: accepted_mc.clone(),
1019            full_normalization: self.compiler_native_normalization()?,
1020            accepted_mc_source: self.accepted_mc_source.clone(),
1021            generated_mc_source: generated_mc.clone(),
1022            plan: plan.clone(),
1023            projection: self.resolved_projection()?.clone(),
1024            accepted_mc,
1025            normalization: self.compiler_native_normalization()?,
1026            generated_mc: plan.prepare_dataset(execution, generated_mc)?,
1027            data_weight_sum: self.data_weight_sum()?,
1028            has_absolute_rate,
1029            execution: execution.clone(),
1030        })
1031    }
1032
1033    fn cross_section_integrals_with_tags<'a>(
1034        &self,
1035        generated_mc: &Dataset,
1036        tags: impl IntoIterator<Item = &'a str>,
1037        execution: &Execution,
1038        has_absolute_rate: bool,
1039    ) -> LikelihoodResult<CrossSectionIntegrals> {
1040        let projection = self.projection(generated_mc, tags, execution, has_absolute_rate)?;
1041        Ok(CrossSectionIntegrals {
1042            name: projection.name,
1043            full_plan: projection.full_plan,
1044            full_projection: projection.full_projection,
1045            full_accepted_mc: projection.full_accepted_mc,
1046            full_normalization: projection.full_normalization,
1047            accepted_mc_source: projection.accepted_mc_source,
1048            generated_mc_source: projection.generated_mc_source,
1049            plan: projection.projected_plan,
1050            projection: projection.projected_params,
1051            accepted_mc: projection.projected_accepted_mc,
1052            normalization: projection.projected_normalization,
1053            generated_mc: projection.projected_generated_mc,
1054            data_weight_sum: projection.data_weight_sum,
1055            has_absolute_rate: projection.has_absolute_rate,
1056            execution: projection.execution,
1057        })
1058    }
1059
1060    fn normalization_value(
1061        &self,
1062        params: &ParamValues,
1063        execution: &Execution,
1064    ) -> LikelihoodResult<f64> {
1065        match &self.state {
1066            NllState::Prepared(prepared) => {
1067                if let NormalizationData::CompilerNative(normalization) = &prepared.normalization {
1068                    return normalization
1069                        .value(params, execution)
1070                        .map_err(LikelihoodError::from);
1071                }
1072            }
1073            NllState::Unresolved => {
1074                return Err(LikelihoodError::UnresolvedTerm(self.name().to_owned()));
1075            }
1076        }
1077        self.plan()?
1078            .reduce(
1079                execution,
1080                params,
1081                self.accepted_mc()?,
1082                ReductionPlan::weighted_positive_real(),
1083            )
1084            .map_err(|error| map_reduction_error("accepted MC", error))
1085    }
1086
1087    fn normalization_with_gradient(
1088        &self,
1089        params: &ParamValues,
1090        execution: &Execution,
1091    ) -> LikelihoodResult<(f64, Vec<f64>)> {
1092        match &self.state {
1093            NllState::Prepared(prepared) => {
1094                if let NormalizationData::CompilerNative(normalization) = &prepared.normalization {
1095                    return normalization
1096                        .value_gradient(params, execution)
1097                        .map_err(LikelihoodError::from);
1098                }
1099            }
1100            NllState::Unresolved => {
1101                return Err(LikelihoodError::UnresolvedTerm(self.name().to_owned()));
1102            }
1103        }
1104        Ok(self
1105            .plan()?
1106            .reduce_with_gradient(
1107                execution,
1108                params,
1109                self.accepted_mc()?,
1110                ReductionPlan::weighted_positive_real(),
1111            )
1112            .map_err(|error| map_reduction_error("accepted MC", error))?
1113            .into_parts())
1114    }
1115
1116    fn reduce(
1117        &self,
1118        params: &ParamValues,
1119        dataset: &PreparedDataset,
1120        reduction: ReductionPlan,
1121        name: &'static str,
1122    ) -> LikelihoodResult<f64> {
1123        self.plan()?
1124            .reduce(self.resolved_execution()?, params, dataset, reduction)
1125            .map_err(|error| map_reduction_error(name, error))
1126    }
1127
1128    fn stochastic_data_evaluation(
1129        &self,
1130        params: &ParamValues,
1131        execution: &Execution,
1132        fraction: f64,
1133        seed: u64,
1134    ) -> LikelihoodResult<(f64, Vec<f64>)> {
1135        let selected = self.data_source.clone().subsample(fraction, seed)?;
1136        let prepared = self.plan()?.prepare_dataset(execution, &selected)?;
1137        let evaluation = self
1138            .plan()?
1139            .reduce_with_gradient(
1140                execution,
1141                params,
1142                &prepared,
1143                ReductionPlan::weighted_log_positive_real(),
1144            )
1145            .map_err(|error| map_reduction_error("data batch", error))?;
1146        let (value, gradient) = evaluation.into_parts();
1147        Ok((
1148            value / fraction,
1149            gradient.into_iter().map(|value| value / fraction).collect(),
1150        ))
1151    }
1152
1153    fn local_values(&self, params: &ParamValues) -> LikelihoodResult<ParamValues> {
1154        self.resolved_projection()?.project(params)
1155    }
1156
1157    fn global_values(&self, free: &[f64]) -> LikelihoodResult<ParamValues> {
1158        Ok(self.resolved_projection()?.global_layout.values(free)?)
1159    }
1160
1161    fn resolved_projection(&self) -> LikelihoodResult<&ParamProjection> {
1162        match &self.state {
1163            NllState::Prepared(prepared) => Ok(&prepared.projection),
1164            NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
1165        }
1166    }
1167
1168    fn resolved_execution(&self) -> LikelihoodResult<&Execution> {
1169        match &self.state {
1170            NllState::Prepared(prepared) => Ok(&prepared.execution),
1171            NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
1172        }
1173    }
1174
1175    fn compiler_native_normalization(
1176        &self,
1177    ) -> LikelihoodResult<Option<Arc<PreparedNormalization>>> {
1178        match &self.state {
1179            NllState::Prepared(prepared) => match &prepared.normalization {
1180                NormalizationData::CompilerNative(normalization) => {
1181                    Ok(Some(Arc::clone(normalization)))
1182                }
1183                NormalizationData::PreparedDataset(_) => Ok(None),
1184            },
1185            NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
1186        }
1187    }
1188}
1189
1190impl LikelihoodTerm for NllTerm {
1191    fn name(&self) -> &str {
1192        self.name.as_str()
1193    }
1194
1195    fn append_diagnostics(&self, diagnostics: &mut Vec<DatasetDiagnostics>) {
1196        let NllState::Prepared(prepared) = &self.state else {
1197            return;
1198        };
1199        diagnostics.push(DatasetDiagnostics {
1200            term: self.name.as_str().to_owned(),
1201            role: DatasetRole::Observed,
1202            stats: *prepared.data.stats(),
1203            quadratic_normalization: false,
1204            normalization: None,
1205            source_traversals: self.data_source.source_traversals(),
1206        });
1207        match &prepared.normalization {
1208            NormalizationData::CompilerNative(normalization) => {
1209                diagnostics.push(DatasetDiagnostics {
1210                    term: self.name.as_str().to_owned(),
1211                    role: DatasetRole::AcceptedMc,
1212                    stats: *normalization.stats(),
1213                    quadratic_normalization: true,
1214                    normalization: Some(normalization.diagnostics()),
1215                    source_traversals: self.accepted_mc_source.source_traversals(),
1216                });
1217            }
1218            NormalizationData::PreparedDataset(accepted_mc) => {
1219                diagnostics.push(DatasetDiagnostics {
1220                    term: self.name.as_str().to_owned(),
1221                    role: DatasetRole::AcceptedMc,
1222                    stats: *accepted_mc.stats(),
1223                    quadratic_normalization: false,
1224                    normalization: Some(PreparedNormalizationDiagnostics::general(
1225                        self.model.normalization_diagnostics().clone(),
1226                    )),
1227                    source_traversals: self.accepted_mc_source.source_traversals(),
1228                });
1229            }
1230        }
1231    }
1232
1233    fn bootstrap_clone_is_prepared(&self) -> bool {
1234        matches!(self.state, NllState::Prepared(_))
1235    }
1236
1237    fn bootstrap_clone(&self, seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
1238        Ok(Box::new(self.bootstrap_term(seed)?))
1239    }
1240
1241    fn register_params(&self, registry: &mut ParamRegistry) -> LikelihoodResult<()> {
1242        for spec in self.local_params.specs() {
1243            registry.register(spec.clone())?;
1244        }
1245        Ok(())
1246    }
1247
1248    fn resolve(
1249        &mut self,
1250        global_params: Arc<ParamLayout>,
1251        execution: &Execution,
1252    ) -> LikelihoodResult<()> {
1253        let projection = ParamProjection::new(global_params, &self.local_params, self.name())?;
1254        let plan = PreparedModel::prepare(&self.model, execution)?;
1255        let data = plan.prepare_dataset(execution, &self.data_source)?;
1256        let normalization = PreparedNormalization::prepare(
1257            &self.model,
1258            &plan,
1259            &self.accepted_mc_source,
1260            execution,
1261        )?;
1262        let normalization = if let Some(normalization) = normalization {
1263            NormalizationData::CompilerNative(normalization)
1264        } else {
1265            NormalizationData::PreparedDataset(Box::new(
1266                plan.prepare_dataset(execution, &self.accepted_mc_source)?,
1267            ))
1268        };
1269        let data_weight_sum = data.stats().sum_weights();
1270        self.state = NllState::Prepared(Box::new(PreparedNll {
1271            plan,
1272            projection,
1273            data,
1274            normalization,
1275            data_weight_sum,
1276            execution: execution.clone(),
1277        }));
1278        Ok(())
1279    }
1280
1281    fn nll(&self, params: &ParamValues, execution: &Execution) -> LikelihoodResult<f64> {
1282        let local_params = self.local_values(params)?;
1283        let normalization = positive_integral(
1284            "accepted MC",
1285            self.normalization_value(&local_params, execution)?,
1286        )?;
1287        let data_log_sum = self
1288            .plan()?
1289            .reduce(
1290                execution,
1291                &local_params,
1292                self.data()?,
1293                ReductionPlan::weighted_log_positive_real(),
1294            )
1295            .map_err(|error| map_reduction_error("data", error))?;
1296        Ok(self.data_weight_sum()? * normalization.ln() - data_log_sum)
1297    }
1298
1299    fn nll_with_gradient(
1300        &self,
1301        params: &ParamValues,
1302        gradient: &mut [f64],
1303        execution: &Execution,
1304    ) -> LikelihoodResult<f64> {
1305        let local_params = self.local_values(params)?;
1306        let (normalization, normalization_gradient) =
1307            self.normalization_with_gradient(&local_params, execution)?;
1308        let normalization = positive_integral("accepted MC", normalization)?;
1309        let data_evaluation = self
1310            .plan()?
1311            .reduce_with_gradient(
1312                execution,
1313                &local_params,
1314                self.data()?,
1315                ReductionPlan::weighted_log_positive_real(),
1316            )
1317            .map_err(|error| map_reduction_error("data", error))?;
1318        let (data_log_sum, data_log_gradient) = data_evaluation.into_parts();
1319        let data_weight_sum = self.data_weight_sum()?;
1320        let local_gradient = normalization_gradient
1321            .into_iter()
1322            .zip(data_log_gradient)
1323            .map(|(normalization_derivative, data_derivative)| {
1324                data_weight_sum * normalization_derivative / normalization - data_derivative
1325            })
1326            .collect::<Vec<_>>();
1327        self.resolved_projection()?
1328            .scatter_gradient(&local_gradient, gradient)?;
1329        Ok(data_weight_sum * normalization.ln() - data_log_sum)
1330    }
1331
1332    fn stochastic_nll_with_gradient(
1333        &self,
1334        params: &ParamValues,
1335        gradient: &mut [f64],
1336        execution: &Execution,
1337        fraction: f64,
1338        seed: u64,
1339    ) -> LikelihoodResult<f64> {
1340        let local_params = self.local_values(params)?;
1341        let (normalization, normalization_gradient) =
1342            self.normalization_with_gradient(&local_params, execution)?;
1343        let normalization = positive_integral("accepted MC", normalization)?;
1344        let (data_log_sum, data_log_gradient) =
1345            self.stochastic_data_evaluation(&local_params, execution, fraction, seed)?;
1346        let data_weight_sum = self.data_weight_sum()?;
1347        let local_gradient = normalization_gradient
1348            .into_iter()
1349            .zip(data_log_gradient)
1350            .map(|(normalization_derivative, data_derivative)| {
1351                data_weight_sum * normalization_derivative / normalization - data_derivative
1352            })
1353            .collect::<Vec<_>>();
1354        self.resolved_projection()?
1355            .scatter_gradient(&local_gradient, gradient)?;
1356        Ok(data_weight_sum * normalization.ln() - data_log_sum)
1357    }
1358
1359    fn as_intensity(&self) -> Option<&NllTerm> {
1360        Some(self)
1361    }
1362}
1363
1364/// An extended unbinned negative-log-likelihood term.
1365///
1366/// Unlike [`NllTerm`], the normalization is the expected event yield rather
1367/// than a shape-only normalization raised to the observed weighted yield.
1368#[derive(Clone)]
1369pub struct ExtendedNllTerm {
1370    inner: NllTerm,
1371}
1372
1373impl std::fmt::Debug for ExtendedNllTerm {
1374    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1375        formatter
1376            .debug_struct("ExtendedNllTerm")
1377            .field("name", &self.inner.name)
1378            .field("prepared", &self.inner.data().is_ok())
1379            .finish_non_exhaustive()
1380    }
1381}
1382
1383impl ExtendedNllTerm {
1384    /// Creates an unresolved extended intensity term.
1385    ///
1386    /// # Errors
1387    ///
1388    /// Returns [`LikelihoodError`] if construction of the underlying intensity
1389    /// term fails.
1390    pub fn new(
1391        name: impl Into<String>,
1392        model: &CompiledModel,
1393        data: &Dataset,
1394        accepted_mc: &Dataset,
1395    ) -> LikelihoodResult<Self> {
1396        Ok(Self {
1397            inner: NllTerm::new(name, model, data, accepted_mc)?,
1398        })
1399    }
1400
1401    /// Returns the prepared observed dataset.
1402    ///
1403    /// # Errors
1404    ///
1405    /// Returns [`LikelihoodError::UnresolvedTerm`] when this term has not been
1406    /// resolved.
1407    pub fn data(&self) -> LikelihoodResult<&PreparedDataset> {
1408        self.inner.data()
1409    }
1410
1411    /// Returns the prepared accepted Monte Carlo dataset.
1412    ///
1413    /// # Errors
1414    ///
1415    /// Returns [`LikelihoodError::UnresolvedTerm`] when this term has not been
1416    /// resolved.
1417    pub fn accepted_mc(&self) -> LikelihoodResult<&PreparedDataset> {
1418        self.inner.accepted_mc()
1419    }
1420
1421    /// Returns the observed dataset's total event weight.
1422    ///
1423    /// # Errors
1424    ///
1425    /// Returns [`LikelihoodError::UnresolvedTerm`] when this term has not been
1426    /// resolved.
1427    pub fn data_weight_sum(&self) -> LikelihoodResult<f64> {
1428        self.inner.data_weight_sum()
1429    }
1430
1431    /// Returns the weighted log-intensity sum over observed data.
1432    ///
1433    /// # Errors
1434    ///
1435    /// Returns [`LikelihoodError`] when the term is unresolved, parameters are
1436    /// invalid, runtime evaluation fails, or an intensity is not positive.
1437    pub fn data_log_intensity_sum(&self, free: &[f64]) -> LikelihoodResult<f64> {
1438        self.inner.data_log_intensity_sum(free)
1439    }
1440
1441    /// Returns the weighted intensity integral over accepted Monte Carlo.
1442    ///
1443    /// # Errors
1444    ///
1445    /// Returns [`LikelihoodError`] when the term is unresolved, parameters are
1446    /// invalid, runtime evaluation fails, or an intensity is not positive.
1447    pub fn accepted_normalization(&self, free: &[f64]) -> LikelihoodResult<f64> {
1448        self.inner.accepted_normalization(free)
1449    }
1450}
1451
1452impl LikelihoodTerm for ExtendedNllTerm {
1453    fn name(&self) -> &str {
1454        self.inner.name()
1455    }
1456
1457    fn append_diagnostics(&self, diagnostics: &mut Vec<DatasetDiagnostics>) {
1458        self.inner.append_diagnostics(diagnostics);
1459    }
1460
1461    fn bootstrap_clone_is_prepared(&self) -> bool {
1462        self.inner.bootstrap_clone_is_prepared()
1463    }
1464
1465    fn bootstrap_clone(&self, seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
1466        Ok(Box::new(Self {
1467            inner: self.inner.bootstrap_term(seed)?,
1468        }))
1469    }
1470
1471    fn register_params(&self, registry: &mut ParamRegistry) -> LikelihoodResult<()> {
1472        self.inner.register_params(registry)
1473    }
1474
1475    fn resolve(
1476        &mut self,
1477        global_params: Arc<ParamLayout>,
1478        execution: &Execution,
1479    ) -> LikelihoodResult<()> {
1480        self.inner.resolve(global_params, execution)
1481    }
1482
1483    fn nll(&self, params: &ParamValues, execution: &Execution) -> LikelihoodResult<f64> {
1484        let local_params = self.inner.local_values(params)?;
1485        let normalization = positive_integral(
1486            "accepted MC",
1487            self.inner.normalization_value(&local_params, execution)?,
1488        )?;
1489        let data_log_sum = self
1490            .inner
1491            .plan()?
1492            .reduce(
1493                execution,
1494                &local_params,
1495                self.inner.data()?,
1496                ReductionPlan::weighted_log_positive_real(),
1497            )
1498            .map_err(|error| map_reduction_error("data", error))?;
1499        Ok(normalization - data_log_sum)
1500    }
1501
1502    fn nll_with_gradient(
1503        &self,
1504        params: &ParamValues,
1505        gradient: &mut [f64],
1506        execution: &Execution,
1507    ) -> LikelihoodResult<f64> {
1508        let local_params = self.inner.local_values(params)?;
1509        let (normalization, normalization_gradient) = self
1510            .inner
1511            .normalization_with_gradient(&local_params, execution)?;
1512        let normalization = positive_integral("accepted MC", normalization)?;
1513        let data_evaluation = self
1514            .inner
1515            .plan()?
1516            .reduce_with_gradient(
1517                execution,
1518                &local_params,
1519                self.inner.data()?,
1520                ReductionPlan::weighted_log_positive_real(),
1521            )
1522            .map_err(|error| map_reduction_error("data", error))?;
1523        let (data_log_sum, data_log_gradient) = data_evaluation.into_parts();
1524        let local_gradient = normalization_gradient
1525            .into_iter()
1526            .zip(data_log_gradient)
1527            .map(|(normalization_derivative, data_derivative)| {
1528                normalization_derivative - data_derivative
1529            })
1530            .collect::<Vec<_>>();
1531        self.inner
1532            .resolved_projection()?
1533            .scatter_gradient(&local_gradient, gradient)?;
1534        Ok(normalization - data_log_sum)
1535    }
1536
1537    fn stochastic_nll_with_gradient(
1538        &self,
1539        params: &ParamValues,
1540        gradient: &mut [f64],
1541        execution: &Execution,
1542        fraction: f64,
1543        seed: u64,
1544    ) -> LikelihoodResult<f64> {
1545        let local_params = self.inner.local_values(params)?;
1546        let (normalization, normalization_gradient) = self
1547            .inner
1548            .normalization_with_gradient(&local_params, execution)?;
1549        let normalization = positive_integral("accepted MC", normalization)?;
1550        let (data_log_sum, data_log_gradient) =
1551            self.inner
1552                .stochastic_data_evaluation(&local_params, execution, fraction, seed)?;
1553        let local_gradient = normalization_gradient
1554            .into_iter()
1555            .zip(data_log_gradient)
1556            .map(|(normalization_derivative, data_derivative)| {
1557                normalization_derivative - data_derivative
1558            })
1559            .collect::<Vec<_>>();
1560        self.inner
1561            .resolved_projection()?
1562            .scatter_gradient(&local_gradient, gradient)?;
1563        Ok(normalization - data_log_sum)
1564    }
1565
1566    fn as_intensity(&self) -> Option<&NllTerm> {
1567        Some(&self.inner)
1568    }
1569
1570    fn has_absolute_rate(&self) -> bool {
1571        true
1572    }
1573}
1574
1575/// Quadratic regularization over selected parameters.
1576#[derive(Clone, Debug)]
1577pub struct RidgePenalty {
1578    inner: CpuParameterPenalty,
1579}
1580
1581impl RidgePenalty {
1582    /// Creates a ridge penalty with weight `lambda`.
1583    ///
1584    /// # Errors
1585    ///
1586    /// Returns [`LikelihoodError`] when the name or parameter list is empty or
1587    /// duplicated, or `lambda` is negative or non-finite.
1588    pub fn new(
1589        name: impl Into<String>,
1590        parameter_names: impl IntoIterator<Item = impl Into<String>>,
1591        lambda: f64,
1592    ) -> LikelihoodResult<Self> {
1593        Ok(Self {
1594            inner: CpuParameterPenalty::new(name, parameter_names, lambda, PenaltyKind::Ridge)?,
1595        })
1596    }
1597}
1598
1599impl LikelihoodTerm for RidgePenalty {
1600    fn name(&self) -> &str {
1601        self.inner.name()
1602    }
1603
1604    fn bootstrap_clone(&self, _seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
1605        Ok(Box::new(self.clone()))
1606    }
1607
1608    fn bootstrap_clone_is_prepared(&self) -> bool {
1609        self.inner.global_params.is_some()
1610    }
1611
1612    fn resolve(
1613        &mut self,
1614        global_params: Arc<ParamLayout>,
1615        _execution: &Execution,
1616    ) -> LikelihoodResult<()> {
1617        self.inner.resolve(global_params)
1618    }
1619
1620    fn nll(&self, params: &ParamValues, _execution: &Execution) -> LikelihoodResult<f64> {
1621        self.inner.nll(params)
1622    }
1623
1624    fn nll_with_gradient(
1625        &self,
1626        params: &ParamValues,
1627        gradient: &mut [f64],
1628        _execution: &Execution,
1629    ) -> LikelihoodResult<f64> {
1630        self.inner.nll_with_gradient(params, gradient)
1631    }
1632}
1633
1634/// Absolute-value regularization over selected parameters.
1635#[derive(Clone, Debug)]
1636pub struct LassoPenalty {
1637    inner: CpuParameterPenalty,
1638}
1639
1640impl LassoPenalty {
1641    /// Creates a lasso penalty with weight `lambda`.
1642    ///
1643    /// # Errors
1644    ///
1645    /// Returns [`LikelihoodError`] when the name or parameter list is empty or
1646    /// duplicated, or `lambda` is negative or non-finite.
1647    pub fn new(
1648        name: impl Into<String>,
1649        parameter_names: impl IntoIterator<Item = impl Into<String>>,
1650        lambda: f64,
1651    ) -> LikelihoodResult<Self> {
1652        Ok(Self {
1653            inner: CpuParameterPenalty::new(name, parameter_names, lambda, PenaltyKind::Lasso)?,
1654        })
1655    }
1656}
1657
1658impl LikelihoodTerm for LassoPenalty {
1659    fn name(&self) -> &str {
1660        self.inner.name()
1661    }
1662
1663    fn bootstrap_clone(&self, _seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
1664        Ok(Box::new(self.clone()))
1665    }
1666
1667    fn bootstrap_clone_is_prepared(&self) -> bool {
1668        self.inner.global_params.is_some()
1669    }
1670
1671    fn resolve(
1672        &mut self,
1673        global_params: Arc<ParamLayout>,
1674        _execution: &Execution,
1675    ) -> LikelihoodResult<()> {
1676        self.inner.resolve(global_params)
1677    }
1678
1679    fn nll(&self, params: &ParamValues, _execution: &Execution) -> LikelihoodResult<f64> {
1680        self.inner.nll(params)
1681    }
1682
1683    fn nll_with_gradient(
1684        &self,
1685        params: &ParamValues,
1686        gradient: &mut [f64],
1687        _execution: &Execution,
1688    ) -> LikelihoodResult<f64> {
1689        self.inner.nll_with_gradient(params, gradient)
1690    }
1691}
1692
1693#[derive(Clone, Debug)]
1694struct CpuParameterPenalty {
1695    name: LikelihoodName,
1696    parameter_names: Vec<String>,
1697    parameter_ids: Vec<ParamId>,
1698    global_params: Option<Arc<ParamLayout>>,
1699    lambda: f64,
1700    kind: PenaltyKind,
1701}
1702
1703impl CpuParameterPenalty {
1704    fn new(
1705        name: impl Into<String>,
1706        parameter_names: impl IntoIterator<Item = impl Into<String>>,
1707        lambda: f64,
1708        kind: PenaltyKind,
1709    ) -> LikelihoodResult<Self> {
1710        let name = LikelihoodName::new(name);
1711        if !lambda.is_finite() || lambda < 0.0 {
1712            return Err(LikelihoodError::InvalidPenaltyWeight {
1713                term: name.as_str().to_owned(),
1714                lambda,
1715            });
1716        }
1717        Ok(Self {
1718            name,
1719            parameter_names: parameter_names.into_iter().map(Into::into).collect(),
1720            parameter_ids: Vec::new(),
1721            global_params: None,
1722            lambda,
1723            kind,
1724        })
1725    }
1726
1727    fn name(&self) -> &str {
1728        self.name.as_str()
1729    }
1730
1731    fn resolve(&mut self, global_params: Arc<ParamLayout>) -> LikelihoodResult<()> {
1732        self.parameter_ids = self
1733            .parameter_names
1734            .iter()
1735            .map(|parameter| {
1736                global_params
1737                    .id(parameter)
1738                    .ok_or_else(|| LikelihoodError::MissingParameter {
1739                        term: self.name().to_owned(),
1740                        parameter: parameter.clone(),
1741                    })
1742            })
1743            .collect::<LikelihoodResult<_>>()?;
1744        self.global_params = Some(global_params);
1745        Ok(())
1746    }
1747
1748    fn nll(&self, params: &ParamValues) -> LikelihoodResult<f64> {
1749        let global_params = self
1750            .global_params
1751            .as_ref()
1752            .ok_or(LikelihoodError::ParameterLayoutMismatch)?;
1753        check_params(global_params, params)?;
1754        let mut sum = 0.0;
1755        for id in &self.parameter_ids {
1756            let value = params.get(*id)?;
1757            sum += match self.kind {
1758                PenaltyKind::Ridge => value * value,
1759                PenaltyKind::Lasso => value.abs(),
1760            };
1761        }
1762        Ok(self.lambda * sum)
1763    }
1764
1765    fn nll_with_gradient(
1766        &self,
1767        params: &ParamValues,
1768        gradient: &mut [f64],
1769    ) -> LikelihoodResult<f64> {
1770        let global_params = self
1771            .global_params
1772            .as_ref()
1773            .ok_or(LikelihoodError::ParameterLayoutMismatch)?;
1774        check_params(global_params, params)?;
1775        if gradient.len() != global_params.n_free() {
1776            return Err(LikelihoodError::GradientLengthMismatch {
1777                expected: global_params.n_free(),
1778                actual: gradient.len(),
1779            });
1780        }
1781        let mut sum = 0.0;
1782        for id in &self.parameter_ids {
1783            let value = params.get(*id)?;
1784            let (penalty, derivative) = match self.kind {
1785                PenaltyKind::Ridge => (value * value, 2.0 * value),
1786                PenaltyKind::Lasso => {
1787                    (value.abs(), if value == 0.0 { 0.0 } else { value.signum() })
1788                }
1789            };
1790            sum += penalty;
1791            if let Some(free) = global_params.free_id(*id)? {
1792                gradient[free.index()] += self.lambda * derivative;
1793            }
1794        }
1795        Ok(self.lambda * sum)
1796    }
1797}
1798
1799#[derive(Copy, Clone, Debug)]
1800enum PenaltyKind {
1801    Ridge,
1802    Lasso,
1803}
1804
1805/// Prepared accepted and generated Monte Carlo integrals for an intensity model.
1806#[derive(Clone)]
1807pub struct CrossSectionIntegrals {
1808    name: LikelihoodName,
1809    full_plan: PreparedModel,
1810    full_projection: ParamProjection,
1811    full_accepted_mc: PreparedDataset,
1812    full_normalization: Option<Arc<PreparedNormalization>>,
1813    accepted_mc_source: Dataset,
1814    generated_mc_source: Dataset,
1815    plan: PreparedModel,
1816    projection: ParamProjection,
1817    accepted_mc: PreparedDataset,
1818    normalization: Option<Arc<PreparedNormalization>>,
1819    generated_mc: PreparedDataset,
1820    data_weight_sum: f64,
1821    has_absolute_rate: bool,
1822    execution: Execution,
1823}
1824
1825impl std::fmt::Debug for CrossSectionIntegrals {
1826    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1827        formatter
1828            .debug_struct("CrossSectionIntegrals")
1829            .field("name", &self.name)
1830            .field("data_weight_sum", &self.data_weight_sum)
1831            .finish_non_exhaustive()
1832    }
1833}
1834
1835/// A tag-projected intensity model evaluated over generated Monte Carlo.
1836#[derive(Clone)]
1837pub struct LikelihoodProjection {
1838    name: LikelihoodName,
1839    full_plan: PreparedModel,
1840    full_projection: ParamProjection,
1841    full_accepted_mc: PreparedDataset,
1842    full_normalization: Option<Arc<PreparedNormalization>>,
1843    projected_plan: PreparedModel,
1844    projected_params: ParamProjection,
1845    projected_accepted_mc: PreparedDataset,
1846    projected_normalization: Option<Arc<PreparedNormalization>>,
1847    projected_generated_mc: PreparedDataset,
1848    accepted_mc_source: Dataset,
1849    generated_mc_source: Dataset,
1850    data_weight_sum: f64,
1851    has_absolute_rate: bool,
1852    execution: Execution,
1853}
1854
1855impl LikelihoodProjection {
1856    /// Returns the source likelihood term name.
1857    pub fn name(&self) -> &str {
1858        self.name.as_str()
1859    }
1860
1861    /// Returns the projected intensity integral over accepted Monte Carlo.
1862    ///
1863    /// # Errors
1864    ///
1865    /// Returns [`LikelihoodError`] when parameters are invalid, runtime
1866    /// evaluation fails, or an intensity is not positive.
1867    pub fn accepted_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
1868        if let Some(normalization) = &self.projected_normalization {
1869            let global = self.projected_params.global_layout.values(free)?;
1870            let local = self.projected_params.project(&global)?;
1871            return normalization
1872                .value(&local, &self.execution)
1873                .map_err(LikelihoodError::from);
1874        }
1875        self.projected_integral(free, &self.projected_accepted_mc, "accepted MC")
1876    }
1877
1878    /// Returns the projected intensity integral over generated Monte Carlo.
1879    ///
1880    /// # Errors
1881    ///
1882    /// Returns [`LikelihoodError`] when parameters are invalid, runtime
1883    /// evaluation fails, or an intensity is not positive.
1884    pub fn generated_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
1885        self.projected_integral(free, &self.projected_generated_mc, "generated MC")
1886    }
1887
1888    /// Returns the projected acceptance ratio.
1889    ///
1890    /// # Errors
1891    ///
1892    /// Returns [`LikelihoodError`] when either accepted or generated integral
1893    /// cannot be evaluated or is not positive.
1894    pub fn acceptance(&self, free: &[f64]) -> LikelihoodResult<f64> {
1895        let generated = positive_integral("generated MC", self.generated_integral(free)?)?;
1896        let accepted = positive_integral("accepted MC", self.accepted_integral(free)?)?;
1897        Ok(accepted / generated)
1898    }
1899
1900    /// Returns the unprojected accepted Monte Carlo integral.
1901    ///
1902    /// # Errors
1903    ///
1904    /// Returns [`LikelihoodError`] when parameters are invalid, runtime
1905    /// evaluation fails, or an intensity is not positive.
1906    pub fn full_accepted_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
1907        let global = self.full_projection.global_layout.values(free)?;
1908        let local = self.full_projection.project(&global)?;
1909        if let Some(normalization) = &self.full_normalization {
1910            return normalization
1911                .value(&local, &self.execution)
1912                .map_err(LikelihoodError::from);
1913        }
1914        self.full_plan
1915            .reduce(
1916                &self.execution,
1917                &local,
1918                &self.full_accepted_mc,
1919                ReductionPlan::weighted_positive_real(),
1920            )
1921            .map_err(|error| map_reduction_error("accepted MC", error))
1922    }
1923
1924    /// Returns the projected, acceptance-corrected event yield.
1925    ///
1926    /// # Errors
1927    ///
1928    /// Returns [`LikelihoodError`] when accepted or generated integrals cannot
1929    /// be evaluated or the accepted integral is not positive.
1930    pub fn acceptance_corrected_yield(&self, free: &[f64]) -> LikelihoodResult<f64> {
1931        let accepted = positive_integral("accepted MC", self.full_accepted_integral(free)?)?;
1932        Ok(self.data_weight_sum * self.generated_integral(free)? / accepted)
1933    }
1934
1935    /// Returns the observed-yield-normalized projected cross section.
1936    ///
1937    /// # Errors
1938    ///
1939    /// Returns [`LikelihoodError`] when `luminosity` is not positive or the
1940    /// acceptance-corrected yield cannot be evaluated.
1941    pub fn observed_cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
1942        if !luminosity.is_finite() || luminosity <= 0.0 {
1943            return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
1944        }
1945        Ok(self.acceptance_corrected_yield(free)? / luminosity)
1946    }
1947
1948    /// Returns the fitted projected cross section from an absolute-rate term.
1949    ///
1950    /// # Errors
1951    ///
1952    /// Returns [`LikelihoodError::AbsoluteRateUnavailable`] for shape-only
1953    /// terms, or [`LikelihoodError`] when luminosity or integral evaluation
1954    /// fails.
1955    pub fn fitted_cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
1956        if !self.has_absolute_rate {
1957            return Err(LikelihoodError::AbsoluteRateUnavailable(
1958                self.name.as_str().to_owned(),
1959            ));
1960        }
1961        if !luminosity.is_finite() || luminosity <= 0.0 {
1962            return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
1963        }
1964        Ok(self.generated_integral(free)? / luminosity)
1965    }
1966
1967    /// Alias for [`Self::observed_cross_section`].
1968    ///
1969    /// # Errors
1970    ///
1971    /// Returns [`LikelihoodError`] when luminosity or integral evaluation fails.
1972    pub fn cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
1973        self.observed_cross_section(free, luminosity)
1974    }
1975
1976    /// Returns per-event projected weights over generated Monte Carlo.
1977    ///
1978    /// # Errors
1979    ///
1980    /// Returns [`LikelihoodError`] when parameters or integrals are invalid,
1981    /// generated data cannot be read, or runtime evaluation fails.
1982    pub fn weights(&self, free: &[f64], acceptance_corrected: bool) -> LikelihoodResult<Vec<f64>> {
1983        let scale = if acceptance_corrected {
1984            self.data_weight_sum
1985                / positive_integral("accepted MC", self.full_accepted_integral(free)?)?
1986        } else {
1987            1.0
1988        };
1989        let intensities = self.intensities(free)?;
1990        let mut output = Vec::with_capacity(intensities.len());
1991        let mut offset = 0;
1992        for batch in self
1993            .generated_mc_source
1994            .batches()
1995            .map_err(|e| LikelihoodError::Runtime(RuntimeError::Data(e.to_string())))?
1996        {
1997            let batch =
1998                batch.map_err(|e| LikelihoodError::Runtime(RuntimeError::Data(e.to_string())))?;
1999            output.extend(
2000                (0..batch.len())
2001                    .map(|row| batch.weights_at(row) * intensities[offset + row] * scale),
2002            );
2003            offset += batch.len();
2004        }
2005        Ok(output)
2006    }
2007
2008    /// Returns projected intensities over generated Monte Carlo.
2009    ///
2010    /// # Errors
2011    ///
2012    /// Returns [`LikelihoodError`] when parameters are invalid, generated data
2013    /// cannot be read, or runtime evaluation fails.
2014    pub fn intensities(&self, free: &[f64]) -> LikelihoodResult<Vec<f64>> {
2015        let global = self.projected_params.global_layout.values(free)?;
2016        let local = self.projected_params.project(&global)?;
2017        let mut output = Vec::new();
2018        for batch in self
2019            .generated_mc_source
2020            .batches()
2021            .map_err(|e| LikelihoodError::Runtime(RuntimeError::Data(e.to_string())))?
2022        {
2023            let batch =
2024                batch.map_err(|e| LikelihoodError::Runtime(RuntimeError::Data(e.to_string())))?;
2025            output.extend(
2026                self.projected_plan
2027                    .evaluate_batch(&local, &batch)?
2028                    .into_iter()
2029                    .map(|value| value.re),
2030            );
2031        }
2032        Ok(output)
2033    }
2034
2035    fn projected_integral(
2036        &self,
2037        free: &[f64],
2038        dataset: &PreparedDataset,
2039        name: &'static str,
2040    ) -> LikelihoodResult<f64> {
2041        let global = self.projected_params.global_layout.values(free)?;
2042        let local = self.projected_params.project(&global)?;
2043        self.projected_plan
2044            .reduce(
2045                &self.execution,
2046                &local,
2047                dataset,
2048                ReductionPlan::weighted_positive_real(),
2049            )
2050            .map_err(|error| map_reduction_error(name, error))
2051    }
2052}
2053
2054impl CrossSectionIntegrals {
2055    /// Returns retained prepared-dataset bytes used by these integrals.
2056    pub fn resident_bytes(&self) -> usize {
2057        let dataset_bytes = self
2058            .full_accepted_mc
2059            .stats()
2060            .resident_bytes()
2061            .saturating_add(self.accepted_mc.stats().resident_bytes())
2062            .saturating_add(self.generated_mc.stats().resident_bytes());
2063        let full_statistics = self
2064            .full_normalization
2065            .as_ref()
2066            .map_or(0, |normalization| normalization.resident_bytes());
2067        let projected_statistics = self.normalization.as_ref().map_or(0, |normalization| {
2068            if self
2069                .full_normalization
2070                .as_ref()
2071                .is_some_and(|full| Arc::ptr_eq(full, normalization))
2072            {
2073                0
2074            } else {
2075                normalization.resident_bytes()
2076            }
2077        });
2078        dataset_bytes
2079            .saturating_add(full_statistics)
2080            .saturating_add(projected_statistics)
2081    }
2082
2083    /// Returns the source likelihood term name.
2084    pub fn name(&self) -> &str {
2085        self.name.as_str()
2086    }
2087
2088    /// Returns the prepared accepted Monte Carlo dataset.
2089    pub fn accepted_mc(&self) -> &PreparedDataset {
2090        &self.accepted_mc
2091    }
2092
2093    /// Returns the prepared generated Monte Carlo dataset.
2094    pub fn generated_mc(&self) -> &PreparedDataset {
2095        &self.generated_mc
2096    }
2097
2098    /// Returns the accepted Monte Carlo source dataset.
2099    pub fn accepted_mc_source(&self) -> &Dataset {
2100        &self.accepted_mc_source
2101    }
2102
2103    /// Returns the generated Monte Carlo source dataset.
2104    pub fn generated_mc_source(&self) -> &Dataset {
2105        &self.generated_mc_source
2106    }
2107
2108    /// Returns the observed dataset's total event weight.
2109    pub fn data_weight_sum(&self) -> f64 {
2110        self.data_weight_sum
2111    }
2112
2113    /// Returns the intensity integral over accepted Monte Carlo.
2114    ///
2115    /// # Errors
2116    ///
2117    /// Returns [`LikelihoodError`] when parameters are invalid, runtime
2118    /// evaluation fails, or an intensity is not positive.
2119    pub fn accepted_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
2120        let params = self.projection.global_layout.values(free)?;
2121        let local_params = self.projection.project(&params)?;
2122        if let Some(normalization) = &self.normalization {
2123            return normalization
2124                .value(&local_params, &self.execution)
2125                .map_err(LikelihoodError::from);
2126        }
2127        self.weighted_intensity_sum(&local_params, &self.accepted_mc, "accepted MC")
2128    }
2129
2130    /// Returns the intensity integral over generated Monte Carlo.
2131    ///
2132    /// # Errors
2133    ///
2134    /// Returns [`LikelihoodError`] when parameters are invalid, runtime
2135    /// evaluation fails, or an intensity is not positive.
2136    pub fn generated_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
2137        let params = self.projection.global_layout.values(free)?;
2138        let local_params = self.projection.project(&params)?;
2139        self.weighted_intensity_sum(&local_params, &self.generated_mc, "generated MC")
2140    }
2141
2142    /// Returns selected intensities over accepted Monte Carlo.
2143    ///
2144    /// # Errors
2145    ///
2146    /// Returns [`LikelihoodError`] when parameters or dataset evaluation fail.
2147    pub fn accepted_intensities(&self, free: &[f64]) -> LikelihoodResult<Vec<f64>> {
2148        self.intensities(free, &self.accepted_mc_source)
2149    }
2150
2151    pub(crate) fn visit_accepted_prepared_intensities_many<F>(
2152        &self,
2153        free: &[&[f64]],
2154        parameter_contexts: &[String],
2155        consume: F,
2156    ) -> LikelihoodResult<Vec<f64>>
2157    where
2158        F: FnMut(usize, usize, &[f64]) + Send,
2159    {
2160        self.visit_prepared_intensities_many(
2161            free,
2162            parameter_contexts,
2163            &self.accepted_mc,
2164            &self.accepted_mc_source,
2165            Some(ReductionPlan::weighted_positive_real()),
2166            consume,
2167        )
2168    }
2169
2170    /// Returns selected intensities over generated Monte Carlo.
2171    ///
2172    /// # Errors
2173    ///
2174    /// Returns [`LikelihoodError`] when parameters or dataset evaluation fail.
2175    pub fn generated_intensities(&self, free: &[f64]) -> LikelihoodResult<Vec<f64>> {
2176        self.intensities(free, &self.generated_mc_source)
2177    }
2178
2179    pub(crate) fn visit_generated_prepared_intensities_many<F>(
2180        &self,
2181        free: &[&[f64]],
2182        parameter_contexts: &[String],
2183        consume: F,
2184    ) -> LikelihoodResult<()>
2185    where
2186        F: FnMut(usize, usize, &[f64]) + Send,
2187    {
2188        self.visit_prepared_intensities_many(
2189            free,
2190            parameter_contexts,
2191            &self.generated_mc,
2192            &self.generated_mc_source,
2193            None,
2194            consume,
2195        )?;
2196        Ok(())
2197    }
2198
2199    /// Returns the accepted-to-generated integral ratio.
2200    ///
2201    /// # Errors
2202    ///
2203    /// Returns [`LikelihoodError`] when either integral cannot be evaluated or
2204    /// is not positive.
2205    pub fn acceptance(&self, free: &[f64]) -> LikelihoodResult<f64> {
2206        let generated = positive_integral("generated MC", self.generated_integral(free)?)?;
2207        let accepted = positive_integral("accepted MC", self.accepted_integral(free)?)?;
2208        Ok(accepted / generated)
2209    }
2210
2211    /// Corrects an accepted yield for finite acceptance.
2212    ///
2213    /// # Errors
2214    ///
2215    /// Returns [`LikelihoodError`] when an integral cannot be evaluated or the
2216    /// accepted integral is not positive.
2217    pub fn acceptance_corrected_yield(
2218        &self,
2219        free: &[f64],
2220        accepted_yield: f64,
2221    ) -> LikelihoodResult<f64> {
2222        let accepted = self.accepted_integral(free)?;
2223        if accepted <= 0.0 {
2224            return Err(LikelihoodError::NonPositiveAcceptedIntegral(accepted));
2225        }
2226        Ok(accepted_yield * self.generated_integral(free)? / accepted)
2227    }
2228
2229    /// Returns the observed-yield-normalized cross section.
2230    ///
2231    /// # Errors
2232    ///
2233    /// Returns [`LikelihoodError`] when `luminosity` is not positive or the
2234    /// acceptance-corrected yield cannot be evaluated.
2235    pub fn observed_cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
2236        if !luminosity.is_finite() || luminosity <= 0.0 {
2237            return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
2238        }
2239        let full_accepted = positive_integral("accepted MC", self.full_accepted_integral(free)?)?;
2240        Ok(self.data_weight_sum * self.generated_integral(free)? / full_accepted / luminosity)
2241    }
2242
2243    /// Returns the fitted cross section from an absolute-rate term.
2244    ///
2245    /// For a tagged evaluator this uses the selected generated intensity
2246    /// directly, without rescaling it to the observed yield.
2247    ///
2248    /// # Errors
2249    ///
2250    /// Returns [`LikelihoodError::AbsoluteRateUnavailable`] for shape-only
2251    /// terms, or [`LikelihoodError`] when luminosity or integral evaluation
2252    /// fails.
2253    pub fn fitted_cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
2254        if !self.has_absolute_rate {
2255            return Err(LikelihoodError::AbsoluteRateUnavailable(
2256                self.name.as_str().to_owned(),
2257            ));
2258        }
2259        if !luminosity.is_finite() || luminosity <= 0.0 {
2260            return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
2261        }
2262        Ok(self.generated_integral(free)? / luminosity)
2263    }
2264
2265    /// Alias for [`Self::observed_cross_section`].
2266    ///
2267    /// # Errors
2268    ///
2269    /// Returns [`LikelihoodError`] when luminosity or integral evaluation fails.
2270    pub fn cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
2271        self.observed_cross_section(free, luminosity)
2272    }
2273
2274    /// Returns the full-model accepted Monte Carlo integral.
2275    ///
2276    /// This differs from [`Self::accepted_integral`] only when the evaluator
2277    /// was narrowed by tags.
2278    ///
2279    /// # Errors
2280    ///
2281    /// Returns [`LikelihoodError`] when parameters are invalid, runtime
2282    /// evaluation fails, or an intensity is not positive.
2283    pub fn full_accepted_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
2284        let params = self.full_projection.global_layout.values(free)?;
2285        let local_params = self.full_projection.project(&params)?;
2286        if let Some(normalization) = &self.full_normalization {
2287            return normalization
2288                .value(&local_params, &self.execution)
2289                .map_err(LikelihoodError::from);
2290        }
2291        self.full_plan
2292            .reduce(
2293                &self.execution,
2294                &local_params,
2295                &self.full_accepted_mc,
2296                ReductionPlan::weighted_positive_real(),
2297            )
2298            .map_err(|error| map_reduction_error("accepted MC", error))
2299    }
2300
2301    fn weighted_intensity_sum(
2302        &self,
2303        params: &ParamValues,
2304        dataset: &PreparedDataset,
2305        name: &'static str,
2306    ) -> LikelihoodResult<f64> {
2307        self.plan
2308            .reduce(
2309                &self.execution,
2310                params,
2311                dataset,
2312                ReductionPlan::weighted_positive_real(),
2313            )
2314            .map_err(|error| map_reduction_error(name, error))
2315    }
2316
2317    fn intensities(&self, free: &[f64], dataset: &Dataset) -> LikelihoodResult<Vec<f64>> {
2318        let global = self.projection.global_layout.values(free)?;
2319        let local = self.projection.project(&global)?;
2320        let mut output = Vec::new();
2321        for batch in dataset
2322            .batches()
2323            .map_err(|error| LikelihoodError::Runtime(RuntimeError::Data(error.to_string())))?
2324        {
2325            let batch = batch
2326                .map_err(|error| LikelihoodError::Runtime(RuntimeError::Data(error.to_string())))?;
2327            output.extend(
2328                self.plan
2329                    .evaluate_batch(&local, &batch)?
2330                    .into_iter()
2331                    .map(|value| value.re),
2332            );
2333        }
2334        Ok(output)
2335    }
2336
2337    fn visit_prepared_intensities_many<F>(
2338        &self,
2339        free: &[&[f64]],
2340        parameter_contexts: &[String],
2341        dataset: &PreparedDataset,
2342        source: &Dataset,
2343        reduction: Option<ReductionPlan>,
2344        mut consume: F,
2345    ) -> LikelihoodResult<Vec<f64>>
2346    where
2347        F: FnMut(usize, usize, &[f64]) + Send,
2348    {
2349        let local = self.project_many(free)?;
2350        let parameter_sets = local
2351            .iter()
2352            .zip(parameter_contexts)
2353            .map(|(parameters, context)| (parameters, context.as_str()))
2354            .collect::<Vec<_>>();
2355        if parameter_sets.len() != local.len() {
2356            return Err(LikelihoodError::Runtime(RuntimeError::InvalidShape {
2357                index: 0,
2358                message: "parameter values and evaluation contexts have different lengths".into(),
2359            }));
2360        }
2361        self.plan
2362            .visit_prepared_many_parallel(
2363                &self.execution,
2364                &parameter_sets,
2365                dataset,
2366                source,
2367                reduction,
2368                |offset, parameter_index, values| {
2369                    let real = values.iter().map(|value| value.re).collect::<Vec<f64>>();
2370                    consume(offset, parameter_index, &real);
2371                    Ok(())
2372                },
2373            )
2374            .map_err(LikelihoodError::from)
2375    }
2376
2377    fn project_many(&self, free: &[&[f64]]) -> LikelihoodResult<Vec<ParamValues>> {
2378        free.iter()
2379            .map(|free| {
2380                let global = self.projection.global_layout.values(free)?;
2381                self.projection.project(&global)
2382            })
2383            .collect()
2384    }
2385}
2386
2387#[derive(Clone, Debug)]
2388struct ParamProjection {
2389    global_layout: Arc<ParamLayout>,
2390    local_layout: Arc<ParamLayout>,
2391    global_ids: Vec<ParamId>,
2392    local_free_to_global_free: Vec<usize>,
2393}
2394
2395impl ParamProjection {
2396    fn new(
2397        global_layout: Arc<ParamLayout>,
2398        local_layout: &ParamLayout,
2399        term: &str,
2400    ) -> LikelihoodResult<Self> {
2401        let global_ids = local_layout
2402            .specs()
2403            .iter()
2404            .map(|spec| {
2405                global_layout
2406                    .id(spec.name())
2407                    .ok_or_else(|| LikelihoodError::MissingParameter {
2408                        term: term.to_owned(),
2409                        parameter: spec.name().to_owned(),
2410                    })
2411            })
2412            .collect::<LikelihoodResult<_>>()?;
2413        let local_free_to_global_free = local_layout
2414            .free_params()
2415            .iter()
2416            .map(|local_id| {
2417                let name = local_layout.name(*local_id)?;
2418                let global_id =
2419                    global_layout
2420                        .id(name)
2421                        .ok_or_else(|| LikelihoodError::MissingParameter {
2422                            term: term.to_owned(),
2423                            parameter: name.to_owned(),
2424                        })?;
2425                global_layout
2426                    .free_id(global_id)?
2427                    .map(|id| id.index())
2428                    .ok_or(LikelihoodError::ParameterLayoutMismatch)
2429            })
2430            .collect::<LikelihoodResult<Vec<_>>>()?;
2431        Ok(Self {
2432            global_layout,
2433            local_layout: Arc::new(local_layout.clone()),
2434            global_ids,
2435            local_free_to_global_free,
2436        })
2437    }
2438
2439    fn project(&self, params: &ParamValues) -> LikelihoodResult<ParamValues> {
2440        check_params(&self.global_layout, params)?;
2441        let free = self
2442            .local_layout
2443            .free_params()
2444            .iter()
2445            .map(|local_id| params.get(self.global_ids[local_id.index()]))
2446            .collect::<Result<Vec<_>, _>>()?;
2447        Ok(self.local_layout.values(&free)?)
2448    }
2449
2450    fn scatter_gradient(&self, local: &[f64], global: &mut [f64]) -> LikelihoodResult<()> {
2451        if local.len() != self.local_free_to_global_free.len() {
2452            return Err(LikelihoodError::GradientLengthMismatch {
2453                expected: self.local_free_to_global_free.len(),
2454                actual: local.len(),
2455            });
2456        }
2457        if global.len() != self.global_layout.n_free() {
2458            return Err(LikelihoodError::GradientLengthMismatch {
2459                expected: self.global_layout.n_free(),
2460                actual: global.len(),
2461            });
2462        }
2463        for (derivative, target) in local.iter().zip(&self.local_free_to_global_free) {
2464            global[*target] += derivative;
2465        }
2466        Ok(())
2467    }
2468}
2469
2470fn check_params(layout: &ParamLayout, params: &ParamValues) -> LikelihoodResult<()> {
2471    if params.layout().specs() == layout.specs() {
2472        Ok(())
2473    } else {
2474        Err(LikelihoodError::ParameterLayoutMismatch)
2475    }
2476}
2477
2478fn map_reduction_error(
2479    dataset: &'static str,
2480    error: laddu_runtime::RuntimeError,
2481) -> LikelihoodError {
2482    match error {
2483        laddu_runtime::RuntimeError::Reduction(
2484            laddu_compile::ReductionError::NonPositiveValue { value, .. },
2485        ) => LikelihoodError::NonPositiveIntensity { dataset, value },
2486        error => error.into(),
2487    }
2488}
2489
2490fn positive_integral(dataset: &'static str, value: f64) -> LikelihoodResult<f64> {
2491    if value > 0.0 {
2492        Ok(value)
2493    } else {
2494        Err(LikelihoodError::NonPositiveIntensity { dataset, value })
2495    }
2496}
2497
2498#[cfg(test)]
2499mod tests {
2500    use std::sync::Arc;
2501
2502    use approx::assert_relative_eq;
2503    #[cfg(feature = "wgpu")]
2504    use laddu_compile::CompileOptions;
2505    use laddu_compile::CompiledModel;
2506    use laddu_data::{
2507        data::{CacheStorage, Dataset, EventBatch, OwnedEvent},
2508        schema::Schema,
2509    };
2510    use laddu_expr::{
2511        Expr, complex, event_scalar, matrix, parameter, parameters::Parameter, solve, vector,
2512    };
2513    #[cfg(feature = "wgpu")]
2514    use laddu_expr::{dot, matvec};
2515    use laddu_runtime::{CpuOptions, Device, ExecutionOptions, JitPolicy, Precision, ThreadPolicy};
2516    #[cfg(feature = "wgpu")]
2517    use laddu_runtime::{GpuBackend, GpuOptions, MemoryBudget, MemoryPlan};
2518
2519    use super::*;
2520
2521    fn weighted_dataset(values: &[(f64, f64)]) -> Dataset {
2522        let schema = Arc::new(Schema::new(std::iter::empty::<&str>(), ["x"], true).unwrap());
2523        let batch = EventBatch::from_events(
2524            schema,
2525            values
2526                .iter()
2527                .map(|(x, weight)| OwnedEvent::weighted(vec![], vec![*x], *weight)),
2528        )
2529        .unwrap();
2530        Dataset::from_batches(vec![batch]).unwrap()
2531    }
2532
2533    fn weighted_dataset_batches(values: &[(f64, f64)], ends: &[usize]) -> Dataset {
2534        let schema = Arc::new(Schema::new(std::iter::empty::<&str>(), ["x"], true).unwrap());
2535        let mut start = 0;
2536        let batches = ends
2537            .iter()
2538            .map(|&end| {
2539                let batch = EventBatch::from_events(
2540                    Arc::clone(&schema),
2541                    values[start..end]
2542                        .iter()
2543                        .map(|(x, weight)| OwnedEvent::weighted(vec![], vec![*x], *weight)),
2544                )
2545                .unwrap();
2546                start = end;
2547                batch
2548            })
2549            .collect::<Vec<_>>();
2550        assert_eq!(start, values.len());
2551        Dataset::from_batches(batches)
2552            .unwrap()
2553            .chunked(values.len())
2554            .unwrap()
2555    }
2556
2557    fn single_term_likelihood(
2558        name: &str,
2559        model: &CompiledModel,
2560        data: &Dataset,
2561        accepted_mc: &Dataset,
2562    ) -> Likelihood {
2563        Likelihood::new([NllTerm::new(name, model, data, accepted_mc).unwrap()]).unwrap()
2564    }
2565
2566    fn single_term_likelihood_with_execution(
2567        name: &str,
2568        model: &CompiledModel,
2569        data: &Dataset,
2570        accepted_mc: &Dataset,
2571        execution: Execution,
2572    ) -> Likelihood {
2573        Likelihood::with_execution(
2574            [NllTerm::new(name, model, data, accepted_mc).unwrap()],
2575            &execution,
2576        )
2577        .unwrap()
2578    }
2579
2580    fn cpu_execution(precision: Precision, threads: ThreadPolicy, jit: JitPolicy) -> Execution {
2581        Execution::local(ExecutionOptions {
2582            device: Device::Cpu(CpuOptions { threads, jit }),
2583            precision,
2584            ..ExecutionOptions::default()
2585        })
2586        .unwrap()
2587    }
2588
2589    #[cfg(feature = "wgpu")]
2590    fn wgpu_execution(memory_budget: Option<usize>) -> Execution {
2591        Execution::local(ExecutionOptions {
2592            device: Device::Gpu(GpuOptions {
2593                backend: GpuBackend::Wgpu,
2594                ..GpuOptions::default()
2595            }),
2596            memory: MemoryPlan {
2597                host: MemoryBudget::Auto,
2598                device: memory_budget.map(|bytes| MemoryBudget::Bytes(bytes as u64)),
2599            },
2600            precision: Precision::F32,
2601            ..ExecutionOptions::default()
2602        })
2603        .unwrap()
2604    }
2605
2606    fn assert_evaluation_close(
2607        actual: &LikelihoodEvaluation,
2608        expected: &LikelihoodEvaluation,
2609        epsilon: f64,
2610    ) {
2611        assert_relative_eq!(actual.value(), expected.value(), epsilon = epsilon);
2612        assert_eq!(actual.gradient().len(), expected.gradient().len());
2613        for (actual, expected) in actual.gradient().iter().zip(expected.gradient()) {
2614            assert_relative_eq!(actual, expected, epsilon = epsilon);
2615        }
2616    }
2617
2618    fn finite_difference_nll(
2619        likelihood: &Likelihood,
2620        params: &[f64],
2621        free_parameter: usize,
2622    ) -> f64 {
2623        let center = params[free_parameter];
2624        let h = 1.0e-6;
2625        let mut plus = params.to_vec();
2626        let mut minus = params.to_vec();
2627        plus[free_parameter] = center + h;
2628        minus[free_parameter] = center - h;
2629        (likelihood.nll(&plus).unwrap() - likelihood.nll(&minus).unwrap()) / (2.0 * h)
2630    }
2631
2632    #[test]
2633    fn nll_uses_data_and_accepted_mc_reductions() {
2634        let expr = event_scalar("x") * parameter!("scale", initial: 0.5);
2635        let model = CompiledModel::from_expr(&expr).unwrap();
2636        let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
2637        let accepted_mc = weighted_dataset(&[(4.0, 1.0)]);
2638        let likelihood = single_term_likelihood("data", &model, &data, &accepted_mc);
2639        let params = likelihood.default_params();
2640
2641        let expected = 2.0 * 2.0_f64.ln() - 1.0_f64.ln() - 1.5_f64.ln();
2642        assert_relative_eq!(likelihood.nll(&params).unwrap(), expected);
2643    }
2644
2645    #[test]
2646    fn extended_nll_uses_expected_yield_and_has_an_analytic_gradient() {
2647        let expr = event_scalar("x") * parameter!("scale", initial: 0.5);
2648        let model = CompiledModel::from_expr(&expr).unwrap();
2649        let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
2650        let accepted_mc = weighted_dataset(&[(4.0, 1.0)]);
2651        let likelihood =
2652            Likelihood::new([
2653                ExtendedNllTerm::new("extended", &model, &data, &accepted_mc).unwrap(),
2654            ])
2655            .unwrap();
2656        let params = likelihood.default_params();
2657        let evaluation = likelihood.nll_with_gradient(&params).unwrap();
2658        let expected = 2.0 - 1.0_f64.ln() - 1.5_f64.ln();
2659
2660        assert_relative_eq!(evaluation.value(), expected);
2661        assert_relative_eq!(
2662            evaluation.gradient()[0],
2663            finite_difference_nll(&likelihood, &params, 0),
2664            epsilon = 1.0e-8
2665        );
2666    }
2667
2668    #[test]
2669    fn extended_nll_exposes_observed_and_fitted_cross_sections() {
2670        let model =
2671            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.25)))
2672                .unwrap();
2673        let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
2674        let accepted_mc = weighted_dataset(&[(4.0, 1.0)]);
2675        let generated_mc = weighted_dataset(&[(6.0, 1.0)]);
2676        let likelihood =
2677            Likelihood::new([
2678                ExtendedNllTerm::new("extended", &model, &data, &accepted_mc).unwrap(),
2679            ])
2680            .unwrap();
2681        let params = likelihood.default_params();
2682        let integrals = likelihood
2683            .cross_section_integrals("extended", &generated_mc)
2684            .unwrap();
2685
2686        assert_relative_eq!(
2687            integrals.observed_cross_section(&params, 10.0).unwrap(),
2688            0.3
2689        );
2690        assert_relative_eq!(integrals.fitted_cross_section(&params, 10.0).unwrap(), 0.15);
2691        assert_relative_eq!(
2692            integrals.cross_section(&params, 10.0).unwrap(),
2693            integrals.observed_cross_section(&params, 10.0).unwrap()
2694        );
2695        assert_relative_eq!(
2696            likelihood
2697                .intensity_datasets("extended")
2698                .unwrap()
2699                .0
2700                .sum_weights()
2701                .unwrap(),
2702            data.sum_weights().unwrap()
2703        );
2704    }
2705
2706    #[test]
2707    fn fitted_cross_section_rejects_shape_only_nll() {
2708        let model = CompiledModel::from_expr(&event_scalar("x")).unwrap();
2709        let data = weighted_dataset(&[(2.0, 1.0)]);
2710        let accepted_mc = weighted_dataset(&[(4.0, 1.0)]);
2711        let generated_mc = weighted_dataset(&[(6.0, 1.0)]);
2712        let likelihood = single_term_likelihood("shape", &model, &data, &accepted_mc);
2713        let integrals = likelihood
2714            .cross_section_integrals("shape", &generated_mc)
2715            .unwrap();
2716
2717        assert!(matches!(
2718            integrals.fitted_cross_section(&[], 10.0),
2719            Err(LikelihoodError::AbsoluteRateUnavailable(name)) if name == "shape"
2720        ));
2721    }
2722
2723    #[test]
2724    fn likelihood_accepts_free_slices_and_generates_free_parameters() {
2725        let scale = laddu_expr::Expr::from(Parameter::free("scale").with_initial((0.25, 0.75)));
2726        let offset = laddu_expr::Expr::from(Parameter::fixed("offset", 1.0));
2727        let model = CompiledModel::from_expr(&(event_scalar("x") * scale + offset)).unwrap();
2728        let data = weighted_dataset(&[(1.0, 1.0), (2.0, 1.0)]);
2729        let accepted = weighted_dataset(&[(1.5, 1.0), (2.5, 1.0)]);
2730        let likelihood = single_term_likelihood("slice", &model, &data, &accepted);
2731
2732        assert_eq!(likelihood.default_params(), vec![0.5]);
2733        assert_eq!(likelihood.sample_initial(0), vec![0.5513138035955086]);
2734        assert_eq!(
2735            likelihood.params_with(|parameter| parameter.name().len() as f64),
2736            vec![5.0]
2737        );
2738        assert!(likelihood.nll(&[0.5f64]).unwrap().is_finite());
2739        assert!(matches!(
2740            likelihood.nll(&[]),
2741            Err(LikelihoodError::Params(ParamError::FreeLengthMismatch {
2742                expected: 1,
2743                actual: 0
2744            }))
2745        ));
2746    }
2747
2748    #[test]
2749    fn nll_gradient_matches_finite_difference() {
2750        let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.7));
2751        let expr = (event_scalar("x") + scale).powi(2);
2752        let model = CompiledModel::from_expr(&expr).unwrap();
2753        let data = weighted_dataset(&[(0.3, 1.0), (1.1, 2.0)]);
2754        let accepted_mc = weighted_dataset(&[(0.5, 1.5), (1.7, 0.8)]);
2755        let likelihood = single_term_likelihood("data", &model, &data, &accepted_mc);
2756        let params = likelihood.default_params();
2757        let evaluation = likelihood.nll_with_gradient(&params).unwrap();
2758
2759        assert_relative_eq!(evaluation.value(), likelihood.nll(&params).unwrap());
2760        assert_relative_eq!(
2761            evaluation.gradient()[0],
2762            finite_difference_nll(&likelihood, &params, 0),
2763            epsilon = 1.0e-8
2764        );
2765    }
2766
2767    #[test]
2768    fn full_fraction_stochastic_evaluation_matches_exact_likelihood() {
2769        let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.7));
2770        let model = CompiledModel::from_expr(&(event_scalar("x") + scale).powi(2)).unwrap();
2771        let data = weighted_dataset(&[(0.3, 1.0), (1.1, 2.0), (1.8, 0.5)]);
2772        let accepted_mc = weighted_dataset(&[(0.5, 1.5), (1.7, 0.8)]);
2773        let likelihood = single_term_likelihood("data", &model, &data, &accepted_mc);
2774        let params = likelihood.default_params();
2775        let exact = likelihood.nll_with_gradient(&params).unwrap();
2776        let stochastic = likelihood
2777            .stochastic_nll_with_gradient(&params, 1.0, 42)
2778            .unwrap();
2779
2780        assert_evaluation_close(&stochastic, &exact, 1.0e-12);
2781        assert!(matches!(
2782            likelihood.stochastic_nll_with_gradient(&params, 0.0, 42),
2783            Err(LikelihoodError::InvalidBatchFraction(0.0))
2784        ));
2785    }
2786
2787    #[test]
2788    fn likelihood_is_invariant_under_dataset_batching() {
2789        let x = event_scalar("x");
2790        let coupling = laddu_expr::Expr::from(parameter!("coupling", initial: 0.35));
2791        let matrix = matrix([
2792            [x.clone() + 2.0, complex(coupling.clone(), 0.15)],
2793            [complex(-0.2, coupling), 3.5.into()],
2794        ]);
2795        let amplitude = solve(matrix, vector([x.sin() + 1.0, complex(x.cos(), 0.5)])).component(1);
2796        let model = CompiledModel::from_expr(&(amplitude.norm_sqr() + 0.25)).unwrap();
2797        let values = [
2798            (0.15, 0.7),
2799            (0.35, 1.2),
2800            (0.65, 0.5),
2801            (0.95, 1.8),
2802            (1.25, 0.9),
2803            (1.55, 1.1),
2804            (1.85, 0.6),
2805        ];
2806        let one_batch = weighted_dataset_batches(&values, &[values.len()]);
2807        let two_batches = weighted_dataset_batches(&values, &[3, values.len()]);
2808        let uneven_batches = weighted_dataset_batches(&values, &[1, 2, 6, values.len()]);
2809        let streaming = weighted_dataset_batches(&values, &[2, 5, values.len()]).streaming();
2810
2811        let reference = single_term_likelihood("reference", &model, &one_batch, &one_batch);
2812        let two = single_term_likelihood("two", &model, &two_batches, &two_batches);
2813        let uneven = single_term_likelihood("uneven", &model, &uneven_batches, &uneven_batches);
2814        let serial = single_term_likelihood_with_execution(
2815            "serial",
2816            &model,
2817            &streaming,
2818            &streaming,
2819            Execution::local(ExecutionOptions {
2820                device: Device::Cpu(CpuOptions {
2821                    threads: ThreadPolicy::Serial,
2822                    ..CpuOptions::default()
2823                }),
2824                ..ExecutionOptions::default()
2825            })
2826            .unwrap(),
2827        );
2828        let fixed = single_term_likelihood_with_execution(
2829            "fixed",
2830            &model,
2831            &two_batches,
2832            &streaming,
2833            Execution::local(ExecutionOptions {
2834                device: Device::Cpu(CpuOptions {
2835                    threads: ThreadPolicy::Fixed(2),
2836                    ..CpuOptions::default()
2837                }),
2838                ..ExecutionOptions::default()
2839            })
2840            .unwrap(),
2841        );
2842        let expected = reference
2843            .nll_with_gradient(&reference.default_params())
2844            .unwrap();
2845
2846        for actual in [
2847            two.nll_with_gradient(&two.default_params()).unwrap(),
2848            uneven.nll_with_gradient(&uneven.default_params()).unwrap(),
2849            serial.nll_with_gradient(&serial.default_params()).unwrap(),
2850            fixed.nll_with_gradient(&fixed.default_params()).unwrap(),
2851        ] {
2852            assert_relative_eq!(actual.value(), expected.value(), epsilon = 1.0e-12);
2853            assert_eq!(actual.gradient().len(), expected.gradient().len());
2854            for (actual, expected) in actual.gradient().iter().zip(expected.gradient()) {
2855                assert_relative_eq!(actual, expected, epsilon = 1.0e-11);
2856            }
2857        }
2858        assert_eq!(
2859            reference.terms()[0]
2860                .as_intensity()
2861                .unwrap()
2862                .data()
2863                .unwrap()
2864                .stats()
2865                .storage(),
2866            CacheStorage::Resident
2867        );
2868        let streaming_stats = serial.terms()[0]
2869            .as_intensity()
2870            .unwrap()
2871            .data()
2872            .unwrap()
2873            .stats();
2874        assert_eq!(streaming_stats.storage(), CacheStorage::Streaming);
2875        assert_eq!(streaming_stats.resident_bytes(), 0);
2876        assert_eq!(streaming_stats.local_batches(), 1);
2877    }
2878
2879    #[test]
2880    fn f32_cpu_likelihood_matches_across_resident_streaming_and_batches() {
2881        let x = event_scalar("x");
2882        let coupling = laddu_expr::Expr::from(parameter!("coupling", initial: 0.35));
2883        let matrix = matrix([
2884            [x.clone() + 2.0, complex(coupling.clone(), 0.15)],
2885            [complex(-0.2, coupling), 3.5.into()],
2886        ]);
2887        let amplitude = solve(matrix, vector([x.sin() + 1.0, complex(x.cos(), 0.5)])).component(1);
2888        let model = CompiledModel::from_expr(&(amplitude.norm_sqr() + 0.25)).unwrap();
2889        let values = [
2890            (0.15, 0.7),
2891            (0.35, 1.2),
2892            (0.65, 0.5),
2893            (0.95, 1.8),
2894            (1.25, 0.9),
2895            (1.55, 1.1),
2896            (1.85, 0.6),
2897        ];
2898        let one_batch = weighted_dataset_batches(&values, &[values.len()]);
2899        let two_batches = weighted_dataset_batches(&values, &[3, values.len()]);
2900        let streaming = weighted_dataset_batches(&values, &[2, 5, values.len()]).streaming();
2901        let interpreter = cpu_execution(Precision::F32, ThreadPolicy::Serial, JitPolicy::Disabled);
2902        let threaded = cpu_execution(Precision::F32, ThreadPolicy::Fixed(2), JitPolicy::Disabled);
2903
2904        let reference = single_term_likelihood_with_execution(
2905            "reference",
2906            &model,
2907            &one_batch,
2908            &one_batch,
2909            interpreter.clone(),
2910        );
2911        let expected = reference
2912            .nll_with_gradient(&reference.default_params())
2913            .unwrap();
2914
2915        let cases = [
2916            single_term_likelihood_with_execution(
2917                "resident",
2918                &model,
2919                &two_batches,
2920                &two_batches,
2921                interpreter.clone(),
2922            ),
2923            single_term_likelihood_with_execution(
2924                "streaming",
2925                &model,
2926                &streaming,
2927                &streaming,
2928                interpreter.clone(),
2929            ),
2930            single_term_likelihood_with_execution(
2931                "mixed",
2932                &model,
2933                &two_batches,
2934                &streaming,
2935                threaded,
2936            ),
2937        ];
2938
2939        for likelihood in cases {
2940            let actual = likelihood
2941                .nll_with_gradient(&likelihood.default_params())
2942                .unwrap();
2943            assert_evaluation_close(&actual, &expected, 5.0e-5);
2944        }
2945
2946        #[cfg(feature = "jit")]
2947        {
2948            let jit = cpu_execution(Precision::F32, ThreadPolicy::Fixed(2), JitPolicy::Enabled);
2949            for likelihood in [
2950                single_term_likelihood_with_execution(
2951                    "jit-resident",
2952                    &model,
2953                    &two_batches,
2954                    &two_batches,
2955                    jit.clone(),
2956                ),
2957                single_term_likelihood_with_execution(
2958                    "jit-streaming",
2959                    &model,
2960                    &streaming,
2961                    &streaming,
2962                    jit.clone(),
2963                ),
2964                single_term_likelihood_with_execution(
2965                    "jit-mixed",
2966                    &model,
2967                    &two_batches,
2968                    &streaming,
2969                    jit,
2970                ),
2971            ] {
2972                let actual = likelihood
2973                    .nll_with_gradient(&likelihood.default_params())
2974                    .unwrap();
2975                assert_evaluation_close(&actual, &expected, 5.0e-5);
2976            }
2977        }
2978
2979        let streaming_likelihood = single_term_likelihood_with_execution(
2980            "streaming-stats",
2981            &model,
2982            &streaming,
2983            &streaming,
2984            interpreter,
2985        );
2986        let streaming_stats = streaming_likelihood.terms()[0]
2987            .as_intensity()
2988            .unwrap()
2989            .data()
2990            .unwrap()
2991            .stats();
2992        assert_eq!(streaming_stats.storage(), CacheStorage::Streaming);
2993        assert_eq!(streaming_stats.resident_bytes(), 0);
2994        assert_eq!(streaming_stats.local_batches(), 1);
2995    }
2996
2997    #[test]
2998    fn likelihood_nll_uses_configured_f32_scalar_execution() {
2999        let dataset = weighted_dataset(&[(1.0, 1.0), (2.0, 1.0)]);
3000        let scale = laddu_expr::Expr::from(parameter!("scale", initial: 1.0));
3001        let model = CompiledModel::from_expr(&(event_scalar("x") + scale)).unwrap();
3002        let likelihood = single_term_likelihood_with_execution(
3003            "f32",
3004            &model,
3005            &dataset,
3006            &dataset,
3007            cpu_execution(Precision::F32, ThreadPolicy::Serial, JitPolicy::Auto),
3008        );
3009
3010        let expected = 2.0 * 5.0_f64.ln() - 2.0_f32.ln() as f64 - 3.0_f32.ln() as f64;
3011        assert_eq!(
3012            likelihood.nll(&likelihood.default_params()).unwrap(),
3013            expected
3014        );
3015    }
3016
3017    #[cfg(feature = "mpi")]
3018    #[mpi_test::mpi_test(np = [2, 3, 4])]
3019    fn mpi_likelihood_matches_local_reference_without_multiplying_penalties() {
3020        use mpi::traits::Communicator;
3021
3022        let universe = mpi::initialize().unwrap();
3023        let world = universe.world();
3024        let values = [(0.4, 1.5), (1.2, 0.75)];
3025        let resident = weighted_dataset_batches(&values, &[1, values.len()]);
3026        let streaming = weighted_dataset_batches(&values, &[1, values.len()]).streaming();
3027        let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.6));
3028        let model = CompiledModel::from_expr(&(event_scalar("x") + scale).powi(2)).unwrap();
3029
3030        let reference = Likelihood::new_boxed([
3031            NllTerm::new("data", &model, &resident, &streaming)
3032                .unwrap()
3033                .boxed(),
3034            RidgePenalty::new("ridge", ["scale"], 0.3).unwrap().boxed(),
3035        ])
3036        .unwrap();
3037        let distributed = Likelihood::with_execution_boxed(
3038            [
3039                NllTerm::new("data", &model, &resident, &streaming)
3040                    .unwrap()
3041                    .boxed(),
3042                RidgePenalty::new("ridge", ["scale"], 0.3).unwrap().boxed(),
3043            ],
3044            &Execution::distributed(
3045                ExecutionOptions {
3046                    device: Device::Cpu(CpuOptions {
3047                        threads: ThreadPolicy::Serial,
3048                        ..CpuOptions::default()
3049                    }),
3050                    partitioning: laddu_data::io::Partitioning::Contiguous,
3051                    ..ExecutionOptions::default()
3052                },
3053                &world,
3054            )
3055            .unwrap(),
3056        )
3057        .unwrap();
3058
3059        let expected = reference
3060            .nll_with_gradient(&reference.default_params())
3061            .unwrap();
3062        let actual = distributed
3063            .nll_with_gradient(&distributed.default_params())
3064            .unwrap();
3065        assert_relative_eq!(actual.value(), expected.value(), epsilon = 1.0e-12);
3066        assert_relative_eq!(
3067            actual.gradient()[0],
3068            expected.gradient()[0],
3069            epsilon = 1.0e-11
3070        );
3071        let expected_term = reference.terms()[0].as_intensity().unwrap();
3072        let actual_term = distributed.terms()[0].as_intensity().unwrap();
3073        assert_relative_eq!(
3074            actual_term
3075                .accepted_normalization(&distributed.default_params())
3076                .unwrap(),
3077            expected_term
3078                .accepted_normalization(&reference.default_params())
3079                .unwrap(),
3080            epsilon = 1.0e-12
3081        );
3082        assert_relative_eq!(
3083            actual_term
3084                .data_log_intensity_sum(&distributed.default_params())
3085                .unwrap(),
3086            expected_term
3087                .data_log_intensity_sum(&reference.default_params())
3088                .unwrap(),
3089            epsilon = 1.0e-12
3090        );
3091
3092        let stats = distributed.terms()[0]
3093            .as_intensity()
3094            .unwrap()
3095            .data()
3096            .unwrap()
3097            .stats();
3098        assert_eq!(stats.global_events(), values.len());
3099        assert!(stats.local_events() <= 1 || world.size() <= 2);
3100    }
3101
3102    #[cfg(all(feature = "mpi", feature = "wgpu"))]
3103    #[mpi_test::mpi_test(np = [2, 3])]
3104    fn mpi_wgpu_likelihood_matches_local_wgpu_reference_across_storage_modes() {
3105        use mpi::traits::Communicator;
3106
3107        let universe = mpi::initialize().unwrap();
3108        let world = universe.world();
3109        let values = [
3110            (0.15, 0.7),
3111            (0.35, 1.2),
3112            (0.65, 0.5),
3113            (0.95, 1.8),
3114            (1.25, 0.9),
3115            (1.55, 1.1),
3116            (1.85, 0.6),
3117        ];
3118        let accepted_values = [
3119            (0.25, 0.5),
3120            (0.55, 1.0),
3121            (0.85, 1.5),
3122            (1.15, 0.75),
3123            (1.45, 1.25),
3124        ];
3125        let data = weighted_dataset_batches(&values, &[2, 5, values.len()]);
3126        let accepted = weighted_dataset_batches(&accepted_values, &[1, 3, accepted_values.len()]);
3127        let streaming_data = data.clone().streaming();
3128        let streaming_accepted = accepted.clone().streaming();
3129        let x = event_scalar("x");
3130        let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.5));
3131        let offset = laddu_expr::Expr::from(parameter!("offset", initial: 1.25));
3132        let model = CompiledModel::from_expr(&((x * scale + offset + 2.0).powi(2) + 0.1)).unwrap();
3133
3134        let local = single_term_likelihood_with_execution(
3135            "local",
3136            &model,
3137            &data,
3138            &accepted,
3139            wgpu_execution(Some(256)),
3140        );
3141        let expected = local.nll_with_gradient(&local.default_params()).unwrap();
3142
3143        let make_distributed = |name, data: &Dataset, accepted: &Dataset| {
3144            single_term_likelihood_with_execution(
3145                name,
3146                &model,
3147                data,
3148                accepted,
3149                Execution::distributed(
3150                    ExecutionOptions {
3151                        device: Device::Gpu(GpuOptions {
3152                            backend: GpuBackend::Wgpu,
3153                            ..GpuOptions::default()
3154                        }),
3155                        memory: MemoryPlan::host_device(
3156                            MemoryBudget::Auto,
3157                            MemoryBudget::Bytes(256),
3158                        ),
3159                        precision: Precision::F32,
3160                        partitioning: laddu_data::io::Partitioning::Contiguous,
3161                        ..ExecutionOptions::default()
3162                    },
3163                    &world,
3164                )
3165                .unwrap(),
3166            )
3167        };
3168        let resident = make_distributed("resident", &data, &accepted);
3169        let streaming = make_distributed("streaming", &streaming_data, &streaming_accepted);
3170        let mixed = make_distributed("mixed", &data, &streaming_accepted);
3171
3172        for likelihood in [&resident, &streaming, &mixed] {
3173            let actual = likelihood
3174                .nll_with_gradient(&likelihood.default_params())
3175                .unwrap();
3176            assert_evaluation_close(&actual, &expected, 5.0e-4);
3177        }
3178
3179        let stats = resident.terms()[0]
3180            .as_intensity()
3181            .unwrap()
3182            .data()
3183            .unwrap()
3184            .stats();
3185        assert_eq!(stats.global_events(), values.len());
3186        assert!(stats.local_events() <= values.len().div_ceil(world.size() as usize));
3187        assert_eq!(stats.storage(), CacheStorage::Resident);
3188
3189        let streaming_stats = streaming.terms()[0]
3190            .as_intensity()
3191            .unwrap()
3192            .data()
3193            .unwrap()
3194            .stats();
3195        assert_eq!(streaming_stats.global_events(), values.len());
3196        assert_eq!(streaming_stats.storage(), CacheStorage::Streaming);
3197        assert_eq!(streaming_stats.resident_bytes(), 0);
3198    }
3199
3200    #[cfg(feature = "mpi")]
3201    #[mpi_test::mpi_test(np = [2, 3])]
3202    fn mpi_likelihood_propagates_a_rank_local_error_without_deadlocking() {
3203        let universe = mpi::initialize().unwrap();
3204        let world = universe.world();
3205        let data = weighted_dataset_batches(&[(-1.0, 1.0), (2.0, 1.0)], &[2]);
3206        let accepted_mc = weighted_dataset_batches(&[(1.0, 1.0), (2.0, 1.0)], &[2]);
3207        let model = CompiledModel::from_expr(&event_scalar("x")).unwrap();
3208        let likelihood = Likelihood::with_execution(
3209            [NllTerm::new("data", &model, &data, &accepted_mc).unwrap()],
3210            &Execution::distributed(ExecutionOptions::default(), &world).unwrap(),
3211        )
3212        .unwrap();
3213
3214        assert!(likelihood.nll(&likelihood.default_params()).is_err());
3215    }
3216
3217    #[test]
3218    fn shared_parameters_are_merged_across_independent_models() {
3219        let model_a =
3220            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3221                .unwrap();
3222        let model_b =
3223            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3224                .unwrap();
3225        let data_a = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
3226        let accepted_a = weighted_dataset(&[(4.0, 1.0)]);
3227        let data_b = weighted_dataset(&[(5.0, 2.0)]);
3228        let accepted_b = weighted_dataset(&[(6.0, 3.0)]);
3229        let likelihood = Likelihood::new([
3230            NllTerm::new("KsKs", &model_a, &data_a, &accepted_a).unwrap(),
3231            NllTerm::new("eta_pi", &model_b, &data_b, &accepted_b).unwrap(),
3232        ])
3233        .unwrap();
3234
3235        assert_eq!(likelihood.params().len(), 1);
3236        assert_eq!(likelihood.params().specs()[0].name(), "scale");
3237
3238        let params = likelihood.default_params();
3239        let term_a = 2.0 * 2.0_f64.ln() - 1.0_f64.ln() - 1.5_f64.ln();
3240        let term_b = 2.0 * 9.0_f64.ln() - 2.0 * 2.5_f64.ln();
3241        assert_relative_eq!(likelihood.nll(&params).unwrap(), term_a + term_b);
3242    }
3243
3244    #[test]
3245    fn changing_shared_parameter_affects_all_terms() {
3246        let model_a =
3247            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3248                .unwrap();
3249        let model_b =
3250            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3251                .unwrap();
3252        let data = weighted_dataset(&[(2.0, 1.0)]);
3253        let accepted = weighted_dataset(&[(4.0, 1.0)]);
3254        let likelihood = Likelihood::new([
3255            NllTerm::new("a", &model_a, &data, &accepted).unwrap(),
3256            NllTerm::new("b", &model_b, &data, &accepted).unwrap(),
3257        ])
3258        .unwrap();
3259        let mut params = likelihood.default_params();
3260        params[0] = 1.0;
3261
3262        let expected_term = 1.0 * 4.0_f64.ln() - 2.0_f64.ln();
3263        assert_relative_eq!(likelihood.nll(&params).unwrap(), 2.0 * expected_term);
3264    }
3265
3266    #[test]
3267    fn shared_and_channel_specific_gradients_scatter_into_global_layout() {
3268        let shared = laddu_expr::Expr::from(parameter!("shared", initial: 0.4));
3269        let model_a = CompiledModel::from_expr(
3270            &(event_scalar("x")
3271                + shared.clone()
3272                + laddu_expr::Expr::from(parameter!("only_a", initial: 0.2)))
3273            .powi(2),
3274        )
3275        .unwrap();
3276        let model_b = CompiledModel::from_expr(
3277            &(event_scalar("x")
3278                + shared
3279                + laddu_expr::Expr::from(parameter!("only_b", initial: -0.1)))
3280            .powi(2),
3281        )
3282        .unwrap();
3283        let data = weighted_dataset(&[(0.5, 1.0), (1.2, 0.7)]);
3284        let accepted = weighted_dataset(&[(0.8, 1.3), (1.5, 0.9)]);
3285        let likelihood = Likelihood::new([
3286            NllTerm::new("a", &model_a, &data, &accepted).unwrap(),
3287            NllTerm::new("b", &model_b, &data, &accepted).unwrap(),
3288        ])
3289        .unwrap();
3290        let params = likelihood.default_params();
3291        let evaluation = likelihood.nll_with_gradient(&params).unwrap();
3292
3293        for (parameter, derivative) in evaluation.gradient().iter().enumerate() {
3294            assert_relative_eq!(
3295                *derivative,
3296                finite_difference_nll(&likelihood, &params, parameter),
3297                epsilon = 1.0e-8
3298            );
3299        }
3300    }
3301
3302    #[test]
3303    fn incompatible_shared_parameter_specs_are_rejected() {
3304        let model_a =
3305            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3306                .unwrap();
3307        let model_b =
3308            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 1.0)))
3309                .unwrap();
3310        let data = weighted_dataset(&[(2.0, 1.0)]);
3311        let accepted = weighted_dataset(&[(4.0, 1.0)]);
3312        let err = Likelihood::new([
3313            NllTerm::new("a", &model_a, &data, &accepted).unwrap(),
3314            NllTerm::new("b", &model_b, &data, &accepted).unwrap(),
3315        ])
3316        .unwrap_err();
3317
3318        assert!(matches!(
3319            err,
3320            LikelihoodError::Params(ParamError::ParameterConflict { ref name, .. })
3321                if name == "scale"
3322        ));
3323    }
3324
3325    #[test]
3326    fn unique_channel_parameters_remain_separate() {
3327        let model_a =
3328            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale_ksks", initial: 0.5)))
3329                .unwrap();
3330        let model_b = CompiledModel::from_expr(
3331            &(event_scalar("x") * parameter!("scale_eta_pi", initial: 0.5)),
3332        )
3333        .unwrap();
3334        let data = weighted_dataset(&[(2.0, 1.0)]);
3335        let accepted = weighted_dataset(&[(4.0, 1.0)]);
3336        let likelihood = Likelihood::new([
3337            NllTerm::new("KsKs", &model_a, &data, &accepted).unwrap(),
3338            NllTerm::new("eta_pi", &model_b, &data, &accepted).unwrap(),
3339        ])
3340        .unwrap();
3341
3342        assert_eq!(likelihood.params().len(), 2);
3343        assert!(likelihood.params().id("scale_ksks").is_some());
3344        assert!(likelihood.params().id("scale_eta_pi").is_some());
3345    }
3346
3347    #[test]
3348    fn ridge_and_lasso_terms_add_penalties() {
3349        let model =
3350            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3351                .unwrap();
3352        let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
3353        let accepted = weighted_dataset(&[(4.0, 1.0)]);
3354        let likelihood = Likelihood::new_boxed([
3355            NllTerm::new("data", &model, &data, &accepted)
3356                .unwrap()
3357                .boxed(),
3358            RidgePenalty::new("ridge", ["scale"], 2.0).unwrap().boxed(),
3359            LassoPenalty::new("lasso", ["scale"], 3.0).unwrap().boxed(),
3360        ])
3361        .unwrap();
3362        let params = likelihood.default_params();
3363
3364        let nll = 2.0 * 2.0_f64.ln() - 1.0_f64.ln() - 1.5_f64.ln();
3365        let penalty = 2.0 * 0.5_f64.powi(2) + 3.0 * 0.5_f64.abs();
3366        let result = likelihood.nll_with_gradient(&params).unwrap();
3367        assert_relative_eq!(result.value(), nll + penalty);
3368        assert_relative_eq!(result.gradient()[0], 5.0);
3369    }
3370
3371    #[test]
3372    fn penalty_terms_reject_missing_parameters() {
3373        let err =
3374            Likelihood::new([RidgePenalty::new("ridge", ["missing"], 1.0).unwrap()]).unwrap_err();
3375
3376        assert!(matches!(
3377            err,
3378            LikelihoodError::MissingParameter { ref term, ref parameter }
3379                if term == "ridge" && parameter == "missing"
3380        ));
3381    }
3382
3383    #[derive(Debug)]
3384    struct ConstantTerm {
3385        name: String,
3386        value: f64,
3387    }
3388
3389    #[derive(Debug)]
3390    struct BoundedQuadraticTerm {
3391        parameter: Parameter,
3392        id: Option<ParamId>,
3393    }
3394
3395    impl LikelihoodTerm for BoundedQuadraticTerm {
3396        fn name(&self) -> &str {
3397            "bounded-quadratic"
3398        }
3399
3400        fn register_params(&self, registry: &mut ParamRegistry) -> LikelihoodResult<()> {
3401            registry.register(self.parameter.clone())?;
3402            Ok(())
3403        }
3404
3405        fn resolve(
3406            &mut self,
3407            global_params: Arc<ParamLayout>,
3408            _execution: &Execution,
3409        ) -> LikelihoodResult<()> {
3410            self.id = global_params.id(self.parameter.name());
3411            Ok(())
3412        }
3413
3414        fn nll(&self, params: &ParamValues, _execution: &Execution) -> LikelihoodResult<f64> {
3415            let value = params.get(self.id.ok_or(LikelihoodError::ParameterLayoutMismatch)?)?;
3416            Ok((value - 2.0).powi(2))
3417        }
3418    }
3419
3420    impl LikelihoodTerm for ConstantTerm {
3421        fn name(&self) -> &str {
3422            &self.name
3423        }
3424
3425        fn resolve(
3426            &mut self,
3427            _global_params: Arc<ParamLayout>,
3428            _execution: &Execution,
3429        ) -> LikelihoodResult<()> {
3430            Ok(())
3431        }
3432
3433        fn nll(&self, _params: &ParamValues, _execution: &Execution) -> LikelihoodResult<f64> {
3434            Ok(self.value)
3435        }
3436    }
3437
3438    #[test]
3439    fn custom_likelihood_term_can_be_user_defined() {
3440        let model =
3441            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3442                .unwrap();
3443        let data = weighted_dataset(&[(2.0, 1.0)]);
3444        let accepted = weighted_dataset(&[(4.0, 1.0)]);
3445        let likelihood = Likelihood::new_boxed([
3446            NllTerm::new("data", &model, &data, &accepted)
3447                .unwrap()
3448                .boxed(),
3449            ConstantTerm {
3450                name: "constant".into(),
3451                value: 12.5,
3452            }
3453            .boxed(),
3454        ])
3455        .unwrap();
3456        let params = likelihood.default_params();
3457
3458        let expected = 1.0 * 2.0_f64.ln() - 1.0_f64.ln() + 12.5;
3459        assert_relative_eq!(likelihood.nll(&params).unwrap(), expected);
3460    }
3461
3462    #[test]
3463    fn custom_term_gradient_uses_bounded_finite_difference_fallback() {
3464        let likelihood = Likelihood::new([BoundedQuadraticTerm {
3465            parameter: Parameter::free("x")
3466                .with_initial(0.0)
3467                .with_bounds(Some(0.0), None),
3468            id: None,
3469        }])
3470        .unwrap();
3471        let params = likelihood.default_params();
3472        let evaluation = likelihood.nll_with_gradient(&params).unwrap();
3473
3474        assert_relative_eq!(evaluation.value(), 4.0);
3475        assert_relative_eq!(evaluation.gradient()[0], -4.0, epsilon = 1.0e-5);
3476    }
3477
3478    #[test]
3479    fn cross_section_integrals_use_named_intensity_term_and_global_params() {
3480        let model =
3481            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3482                .unwrap();
3483        let data = weighted_dataset(&[(9.0, 4.0)]);
3484        let accepted_mc = weighted_dataset(&[(1.0, 2.0), (2.0, 3.0)]);
3485        let generated_mc = weighted_dataset(&[(4.0, 5.0), (5.0, 7.0)]);
3486        let likelihood = single_term_likelihood("KsKs", &model, &data, &accepted_mc);
3487        let params = likelihood.default_params();
3488        let integrals = likelihood
3489            .cross_section_integrals("KsKs", &generated_mc)
3490            .unwrap();
3491
3492        let accepted = 2.0 * 2.0 + 3.0 * 4.0;
3493        let generated = 5.0 * 8.0 + 7.0 * 10.0;
3494        assert_eq!(integrals.name(), "KsKs");
3495        assert_relative_eq!(integrals.accepted_integral(&params).unwrap(), accepted);
3496        assert_relative_eq!(integrals.generated_integral(&params).unwrap(), generated);
3497        assert_relative_eq!(integrals.acceptance(&params).unwrap(), accepted / generated);
3498        assert_relative_eq!(
3499            integrals.acceptance_corrected_yield(&params, 20.0).unwrap(),
3500            20.0 * generated / accepted
3501        );
3502        assert_relative_eq!(
3503            integrals.cross_section(&params, 5.0).unwrap(),
3504            data.sum_weights().unwrap() * generated / accepted / 5.0
3505        );
3506        assert_eq!(integrals.accepted_intensities(&params).unwrap().len(), 2);
3507        assert_eq!(integrals.generated_intensities(&params).unwrap().len(), 2);
3508    }
3509
3510    #[test]
3511    fn bootstrap_rebuilds_likelihood_with_deterministic_poisson_data_weights() {
3512        let model =
3513            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3514                .unwrap();
3515        let data = weighted_dataset(&[(1.0, 1.0), (2.0, 1.0), (3.0, 1.0)]);
3516        let accepted = weighted_dataset(&[(1.0, 1.0)]);
3517        let likelihood = single_term_likelihood("signal", &model, &data, &accepted);
3518        let first = likelihood.bootstrap(42).unwrap();
3519        let second = likelihood.bootstrap(42).unwrap();
3520        let first_sum = first
3521            .intensity_datasets("signal")
3522            .unwrap()
3523            .0
3524            .sum_weights()
3525            .unwrap();
3526        let second_sum = second
3527            .intensity_datasets("signal")
3528            .unwrap()
3529            .0
3530            .sum_weights()
3531            .unwrap();
3532
3533        assert_eq!(first_sum, second_sum);
3534        assert_eq!(first.params().n_free(), likelihood.params().n_free());
3535    }
3536
3537    #[test]
3538    fn unresolved_nll_accessors_report_unresolved_state() {
3539        let model =
3540            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3541                .unwrap();
3542        let data = weighted_dataset(&[(1.0, 1.0)]);
3543        let term = NllTerm::new("signal", &model, &data, &data).unwrap();
3544
3545        assert!(
3546            matches!(term.data(), Err(LikelihoodError::UnresolvedTerm(name)) if name == "signal")
3547        );
3548        assert!(
3549            matches!(term.accepted_mc(), Err(LikelihoodError::UnresolvedTerm(name)) if name == "signal")
3550        );
3551        assert!(
3552            matches!(term.data_weight_sum(), Err(LikelihoodError::UnresolvedTerm(name)) if name == "signal")
3553        );
3554    }
3555
3556    #[test]
3557    fn failed_nll_resolution_does_not_leave_partial_preparation() {
3558        let model =
3559            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3560                .unwrap();
3561        let data = weighted_dataset(&[(1.0, 1.0)]);
3562        let mut term = NllTerm::new("signal", &model, &data, &data).unwrap();
3563        let empty_layout = Arc::new(ParamRegistry::new().layout().unwrap());
3564
3565        assert!(matches!(
3566            term.resolve(empty_layout, &Execution::default()),
3567            Err(LikelihoodError::MissingParameter { .. })
3568        ));
3569        assert!(
3570            matches!(term.data(), Err(LikelihoodError::UnresolvedTerm(name)) if name == "signal")
3571        );
3572
3573        term.resolve(Arc::new(model.params().clone()), &Execution::default())
3574            .unwrap();
3575        assert!(term.data().is_ok());
3576        assert!(term.data_weight_sum().is_ok());
3577    }
3578
3579    #[test]
3580    fn failed_nll_reresolution_preserves_prepared_state() {
3581        let model =
3582            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3583                .unwrap();
3584        let data = weighted_dataset(&[(1.0, 1.0), (2.0, 2.0)]);
3585        let mut term = NllTerm::new("signal", &model, &data, &data).unwrap();
3586        let execution = Execution::default();
3587        term.resolve(Arc::new(model.params().clone()), &execution)
3588            .unwrap();
3589        let params = model.params().default_values();
3590        let value = term.nll(&params, &execution).unwrap();
3591        let stats = *term.data().unwrap().stats();
3592        let mut diagnostics = Vec::new();
3593        term.append_diagnostics(&mut diagnostics);
3594
3595        assert!(matches!(
3596            term.resolve(Arc::new(ParamRegistry::new().layout().unwrap()), &execution),
3597            Err(LikelihoodError::MissingParameter { .. })
3598        ));
3599        assert_eq!(term.nll(&params, &execution).unwrap(), value);
3600        assert_eq!(*term.data().unwrap().stats(), stats);
3601        let mut after_diagnostics = Vec::new();
3602        term.append_diagnostics(&mut after_diagnostics);
3603        assert_eq!(after_diagnostics, diagnostics);
3604    }
3605
3606    #[test]
3607    fn prepared_bootstrap_preserves_normalization_and_dataset_diagnostics() {
3608        let model =
3609            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3610                .unwrap();
3611        let data = weighted_dataset(&[(1.0, 1.0), (2.0, 1.0), (3.0, 1.0)]);
3612        let accepted = weighted_dataset(&[(1.0, 1.0), (2.0, 2.0)]);
3613        let executions = [
3614            Execution::default(),
3615            Execution::local(ExecutionOptions {
3616                normalization: NormalizationMode::General,
3617                ..ExecutionOptions::default()
3618            })
3619            .unwrap(),
3620        ];
3621
3622        for execution in executions {
3623            let likelihood = single_term_likelihood_with_execution(
3624                "signal", &model, &data, &accepted, execution,
3625            );
3626            let baseline = likelihood
3627                .diagnostics()
3628                .datasets()
3629                .iter()
3630                .find(|dataset| dataset.role() == DatasetRole::AcceptedMc)
3631                .unwrap()
3632                .clone();
3633            let first = likelihood.bootstrap(41).unwrap();
3634            let second = first.bootstrap(42).unwrap();
3635
3636            for replica in [&first, &second] {
3637                let diagnostics = replica.diagnostics();
3638                let accepted_diagnostics = diagnostics
3639                    .datasets()
3640                    .iter()
3641                    .find(|dataset| dataset.role() == DatasetRole::AcceptedMc)
3642                    .unwrap();
3643                assert_eq!(accepted_diagnostics, &baseline);
3644                assert_eq!(
3645                    accepted_diagnostics.source_traversals(),
3646                    baseline.source_traversals()
3647                );
3648                let term = replica.terms()[0].as_intensity().unwrap();
3649                if baseline.uses_quadratic_normalization() {
3650                    assert!(term.accepted_mc().is_err());
3651                } else {
3652                    assert_eq!(term.accepted_mc().unwrap().stats(), baseline.stats());
3653                }
3654            }
3655        }
3656    }
3657
3658    #[test]
3659    fn coherent_quadratic_normalization_matches_general_event_reduction() {
3660        let coefficient = complex(
3661            parameter!("coefficient_re", initial: 0.7),
3662            parameter!("coefficient_im", initial: -0.2),
3663        );
3664        let basis = complex(event_scalar("x"), 0.5);
3665        let model = CompiledModel::from_expr(&(coefficient * basis).norm_sqr()).unwrap();
3666        let sample = weighted_dataset(&[(0.5, 1.0), (1.5, 2.0), (2.5, 0.75)]);
3667        let likelihood = single_term_likelihood("quadratic", &model, &sample, &sample);
3668        let term = likelihood.terms()[0].as_intensity().unwrap();
3669        let free = vec![0.4, -0.6];
3670        let global = term.global_values(&free).unwrap();
3671        let local = term.local_values(&global).unwrap();
3672
3673        let optimized = term
3674            .normalization_with_gradient(&local, likelihood.execution())
3675            .unwrap();
3676        let general_dataset = term
3677            .plan()
3678            .unwrap()
3679            .prepare_dataset(likelihood.execution(), &term.accepted_mc_source)
3680            .unwrap();
3681        let general = term
3682            .plan()
3683            .unwrap()
3684            .reduce_with_gradient(
3685                likelihood.execution(),
3686                &local,
3687                &general_dataset,
3688                ReductionPlan::weighted_positive_real(),
3689            )
3690            .unwrap()
3691            .into_parts();
3692        assert!((optimized.0 - general.0).abs() < 1.0e-12);
3693        for (optimized, general) in optimized.1.iter().zip(general.1) {
3694            assert!((*optimized - general).abs() < 1.0e-12);
3695        }
3696        assert!(
3697            likelihood
3698                .diagnostics()
3699                .datasets()
3700                .iter()
3701                .any(DatasetDiagnostics::uses_quadratic_normalization)
3702        );
3703    }
3704
3705    #[test]
3706    fn packed_hermitian_normalization_matches_general_interference() {
3707        let first = complex(
3708            parameter!("first_re", initial: 0.7),
3709            parameter!("first_im", initial: -0.2),
3710        ) * complex(event_scalar("x"), 0.5);
3711        let second = complex(
3712            parameter!("second_re", initial: -0.3),
3713            parameter!("second_im", initial: 0.4),
3714        ) * complex(event_scalar("x").powi(2), -0.25);
3715        let model = CompiledModel::from_expr(&(first + second).norm_sqr()).unwrap();
3716        assert_eq!(
3717            model.normalization_diagnostics().strategy(),
3718            laddu_compile::NormalizationStrategy::Hermitian
3719        );
3720        assert_eq!(model.normalization_diagnostics().basis_count(), 3);
3721        let sample = weighted_dataset(&[(0.5, 1.0), (1.5, -0.2), (2.5, 0.75)]);
3722        let likelihood = single_term_likelihood("hermitian", &model, &sample, &sample);
3723        let term = likelihood.terms()[0].as_intensity().unwrap();
3724        let free = vec![0.4, -0.6, 0.2, 0.8];
3725        let global = term.global_values(&free).unwrap();
3726        let local = term.local_values(&global).unwrap();
3727        let optimized = term
3728            .normalization_with_gradient(&local, likelihood.execution())
3729            .unwrap();
3730        let general_dataset = term
3731            .plan()
3732            .unwrap()
3733            .prepare_dataset(likelihood.execution(), &term.accepted_mc_source)
3734            .unwrap();
3735        let general = term
3736            .plan()
3737            .unwrap()
3738            .reduce_with_gradient(
3739                likelihood.execution(),
3740                &local,
3741                &general_dataset,
3742                ReductionPlan::weighted_positive_real(),
3743            )
3744            .unwrap()
3745            .into_parts();
3746        assert_relative_eq!(optimized.0, general.0, epsilon = 1.0e-11);
3747        for (optimized, general) in optimized.1.iter().zip(general.1) {
3748            assert_relative_eq!(optimized, &general, epsilon = 1.0e-10);
3749        }
3750    }
3751
3752    #[test]
3753    fn normalization_mode_general_forces_event_reduction() {
3754        let scale = parameter!("scale", initial: 0.7);
3755        let model = CompiledModel::from_expr(&(scale * event_scalar("x")).powi(2)).unwrap();
3756        let sample = weighted_dataset(&[(0.5, 1.0), (1.5, 2.0)]);
3757        let execution = Execution::local(ExecutionOptions {
3758            normalization: NormalizationMode::General,
3759            ..ExecutionOptions::default()
3760        })
3761        .unwrap();
3762        let likelihood =
3763            single_term_likelihood_with_execution("general", &model, &sample, &sample, execution);
3764        let diagnostics = likelihood.diagnostics();
3765        let accepted = diagnostics
3766            .datasets()
3767            .iter()
3768            .find(|dataset| dataset.role() == DatasetRole::AcceptedMc)
3769            .unwrap();
3770        assert!(!accepted.uses_quadratic_normalization());
3771        assert_eq!(
3772            accepted.normalization().unwrap().strategy(),
3773            laddu_compile::NormalizationStrategy::General
3774        );
3775    }
3776
3777    #[test]
3778    fn verify_mode_checks_hybrid_values_and_gradients() {
3779        let scale = parameter!("scale", initial: 0.2);
3780        let mixed = (scale.clone() * event_scalar("x")).sin();
3781        let separable = scale * (event_scalar("x") + 2.0);
3782        let model = CompiledModel::from_expr(&(separable + mixed + 3.0)).unwrap();
3783        let sample = weighted_dataset(&[(0.2, 1.0), (0.8, -0.25), (1.4, 2.0)]);
3784        let execution = Execution::local(ExecutionOptions {
3785            normalization: NormalizationMode::Verify,
3786            ..ExecutionOptions::default()
3787        })
3788        .unwrap();
3789        let likelihood =
3790            single_term_likelihood_with_execution("hybrid", &model, &sample, &sample, execution);
3791        let evaluation = likelihood
3792            .nll_with_gradient(&likelihood.default_params())
3793            .unwrap();
3794        assert!(evaluation.value().is_finite());
3795        let diagnostics = likelihood.diagnostics();
3796        let normalization = diagnostics
3797            .datasets()
3798            .iter()
3799            .find_map(DatasetDiagnostics::normalization)
3800            .unwrap();
3801        assert_eq!(
3802            normalization.compiler().strategy(),
3803            laddu_compile::NormalizationStrategy::Hybrid
3804        );
3805        assert!(normalization.compiler().has_residual());
3806    }
3807
3808    #[test]
3809    fn normalization_preparation_cache_reuses_dataset_statistics() {
3810        let scale = parameter!("scale", initial: 0.4);
3811        let model = CompiledModel::from_expr(&(scale * event_scalar("x")).powi(2)).unwrap();
3812        let first_data = weighted_dataset(&[(0.5, 1.0)]);
3813        let second_data = weighted_dataset(&[(0.7, 1.0)]);
3814        let accepted = weighted_dataset(&[(0.5, 1.0), (1.5, 2.0), (2.5, 0.75)]);
3815        let execution = Execution::default();
3816        let likelihood = Likelihood::with_execution(
3817            [
3818                NllTerm::new("first", &model, &first_data, &accepted).unwrap(),
3819                NllTerm::new("second", &model, &second_data, &accepted).unwrap(),
3820            ],
3821            &execution,
3822        )
3823        .unwrap();
3824
3825        let diagnostics = likelihood.diagnostics();
3826        let accepted_diagnostics = diagnostics
3827            .datasets()
3828            .iter()
3829            .filter(|dataset| dataset.role() == DatasetRole::AcceptedMc)
3830            .collect::<Vec<_>>();
3831        assert_eq!(accepted_diagnostics.len(), 2);
3832        assert_eq!(accepted.source_traversals(), 1);
3833        assert!(accepted_diagnostics.iter().all(|dataset| {
3834            dataset
3835                .normalization()
3836                .is_some_and(PreparedNormalizationDiagnostics::cache_hit)
3837        }));
3838    }
3839
3840    #[test]
3841    fn cross_section_integrals_reject_non_intensity_terms() {
3842        let model =
3843            CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3844                .unwrap();
3845        let data = weighted_dataset(&[(2.0, 1.0)]);
3846        let accepted = weighted_dataset(&[(4.0, 1.0)]);
3847        let generated = weighted_dataset(&[(5.0, 1.0)]);
3848        let likelihood = Likelihood::new_boxed([
3849            NllTerm::new("data", &model, &data, &accepted)
3850                .unwrap()
3851                .boxed(),
3852            RidgePenalty::new("ridge", ["scale"], 1.0).unwrap().boxed(),
3853        ])
3854        .unwrap();
3855        let err = likelihood
3856            .cross_section_integrals("ridge", &generated)
3857            .unwrap_err();
3858
3859        assert!(matches!(err, LikelihoodError::NotIntensityTerm(ref name) if name == "ridge"));
3860    }
3861
3862    #[cfg(feature = "wgpu")]
3863    #[test]
3864    fn wgpu_scalar_likelihood_matches_cpu_across_storage_modes() {
3865        let x = event_scalar("x");
3866        let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.5));
3867        let offset = laddu_expr::Expr::from(parameter!("offset", initial: 1.25));
3868        let model = CompiledModel::from_expr(&(x * scale + offset + 2.0)).unwrap();
3869        let data = weighted_dataset_batches(
3870            &(0..70)
3871                .map(|index| (index as f64 * 0.01, 1.0 + index as f64 * 0.001))
3872                .collect::<Vec<_>>(),
3873            &[31, 70],
3874        );
3875        let accepted = weighted_dataset_batches(&[(0.25, 0.5), (0.75, 1.5), (1.25, 2.0)], &[1, 3]);
3876        let streaming_data = data.clone().streaming();
3877        let streaming_accepted = accepted.clone().streaming();
3878        let cpu = single_term_likelihood_with_execution(
3879            "scalar",
3880            &model,
3881            &data,
3882            &accepted,
3883            cpu_execution(Precision::F32, ThreadPolicy::Auto, JitPolicy::Disabled),
3884        );
3885        let params = cpu.default_params();
3886        let expected = cpu.nll_with_gradient(&params).unwrap();
3887        let (first, second) = {
3888            let gpu = single_term_likelihood_with_execution(
3889                "resident",
3890                &model,
3891                &data,
3892                &accepted,
3893                wgpu_execution(Some(256)),
3894            );
3895            (
3896                gpu.nll_with_gradient(&params).unwrap(),
3897                gpu.nll_with_gradient(&params).unwrap(),
3898            )
3899        };
3900
3901        assert_evaluation_close(&first, &expected, 5.0e-4);
3902        assert_eq!(second, first);
3903
3904        let streaming_gradient = {
3905            let streaming_gpu = single_term_likelihood_with_execution(
3906                "streaming",
3907                &model,
3908                &streaming_data,
3909                &streaming_accepted,
3910                wgpu_execution(Some(256)),
3911            );
3912            let streaming_gradient = streaming_gpu.nll_with_gradient(&params).unwrap();
3913            let streaming_term = streaming_gpu.terms()[0].as_intensity().unwrap();
3914            let streaming_data_stats = streaming_term.data().unwrap().stats();
3915            let streaming_accepted_stats = streaming_term.accepted_mc().unwrap().stats();
3916            assert_eq!(streaming_data_stats.storage(), CacheStorage::Streaming);
3917            assert_eq!(streaming_data_stats.resident_bytes(), 0);
3918            assert_eq!(streaming_data_stats.local_batches(), 2);
3919            assert_eq!(streaming_accepted_stats.storage(), CacheStorage::Streaming);
3920            assert_eq!(streaming_accepted_stats.resident_bytes(), 0);
3921            assert_eq!(streaming_accepted_stats.local_batches(), 2);
3922            streaming_gradient
3923        };
3924
3925        assert_evaluation_close(&streaming_gradient, &expected, 5.0e-4);
3926
3927        let mixed_gradient = {
3928            let mixed_gpu = single_term_likelihood_with_execution(
3929                "mixed",
3930                &model,
3931                &data,
3932                &streaming_accepted,
3933                wgpu_execution(Some(256)),
3934            );
3935            mixed_gpu.nll_with_gradient(&params).unwrap()
3936        };
3937        assert_evaluation_close(&mixed_gradient, &expected, 5.0e-4);
3938        assert_evaluation_close(&streaming_gradient, &first, 2.0e-5);
3939        assert_evaluation_close(&mixed_gradient, &first, 2.0e-5);
3940
3941        let cpu_f64 = single_term_likelihood_with_execution(
3942            "scalar",
3943            &model,
3944            &data,
3945            &accepted,
3946            cpu_execution(Precision::F64, ThreadPolicy::Auto, JitPolicy::Disabled),
3947        );
3948        let expected_gradient = cpu_f64
3949            .nll_with_gradient(&cpu_f64.default_params())
3950            .unwrap();
3951        assert_evaluation_close(&first, &expected_gradient, 5.0e-4);
3952    }
3953
3954    #[cfg(feature = "wgpu")]
3955    #[test]
3956    #[ignore = "requires a WGPU-compatible hardware adapter"]
3957    fn wgpu_aggregate_likelihood_matches_cpu() {
3958        let expression = dot(
3959            matvec(
3960                matrix([[event_scalar("x"), event_scalar("x") + 1.0]]),
3961                vector([
3962                    parameter!("a", initial: 0.5),
3963                    parameter!("b", initial: 1.25),
3964                ]),
3965            ),
3966            vector([1.0]),
3967        )
3968        .norm_sqr();
3969        let model = CompiledModel::from_expr_with_options(
3970            &expression,
3971            &CompileOptions::without_optimizations(),
3972        )
3973        .unwrap();
3974        let data = weighted_dataset(&[(0.25, 0.5), (0.75, 1.5), (1.25, 2.0)]);
3975        let cpu = single_term_likelihood_with_execution(
3976            "aggregate",
3977            &model,
3978            &data,
3979            &data,
3980            Execution::local(ExecutionOptions {
3981                device: Device::Cpu(CpuOptions::default()),
3982                precision: Precision::F64,
3983                ..ExecutionOptions::default()
3984            })
3985            .unwrap(),
3986        );
3987        let gpu = single_term_likelihood_with_execution(
3988            "aggregate",
3989            &model,
3990            &data,
3991            &data,
3992            Execution::local(ExecutionOptions {
3993                device: Device::Gpu(GpuOptions {
3994                    backend: GpuBackend::Wgpu,
3995                    ..GpuOptions::default()
3996                }),
3997                memory: MemoryPlan::host_device(MemoryBudget::Auto, MemoryBudget::Bytes(256)),
3998                precision: Precision::F32,
3999                ..ExecutionOptions::default()
4000            })
4001            .unwrap(),
4002        );
4003        let params = cpu.default_params();
4004
4005        assert_relative_eq!(
4006            gpu.nll(&params).unwrap(),
4007            cpu.nll(&params).unwrap(),
4008            epsilon = 2.0e-5
4009        );
4010    }
4011
4012    #[test]
4013    fn tagged_projection_produces_partial_weights_and_cross_sections() {
4014        let x = event_scalar("x");
4015        let selected = (Expr::from(parameter!("a", initial: 2.0)) * x.clone()).tagged("selected");
4016        let removed = Expr::from(parameter!("b", initial: 1.0)).tagged("removed");
4017        let model = CompiledModel::from_expr(&(selected + removed).norm_sqr()).unwrap();
4018        let data = weighted_dataset(&[(1.0, 3.0)]);
4019        let accepted = weighted_dataset(&[(1.0, 1.0)]);
4020        let generated = weighted_dataset(&[(2.0, 1.0)]);
4021        let likelihood =
4022            Likelihood::new([NllTerm::new("waves", &model, &data, &accepted).unwrap()]).unwrap();
4023        let params = likelihood.default_params();
4024        let projection = likelihood
4025            .projection("waves", &generated, ["selected"])
4026            .unwrap();
4027        let integrals = likelihood
4028            .cross_section_integrals_with_tags("waves", &generated, ["selected"])
4029            .unwrap();
4030
4031        assert_relative_eq!(projection.full_accepted_integral(&params).unwrap(), 9.0);
4032        assert_relative_eq!(projection.generated_integral(&params).unwrap(), 16.0);
4033        assert_relative_eq!(projection.acceptance(&params).unwrap(), 0.25);
4034        assert_relative_eq!(projection.intensities(&params).unwrap()[0], 16.0);
4035        assert_relative_eq!(
4036            projection.acceptance_corrected_yield(&params).unwrap(),
4037            16.0 / 3.0
4038        );
4039        assert_relative_eq!(projection.weights(&params, false).unwrap()[0], 16.0);
4040        assert_relative_eq!(projection.weights(&params, true).unwrap()[0], 16.0 / 3.0);
4041        assert_relative_eq!(integrals.accepted_integral(&params).unwrap(), 4.0);
4042        assert_relative_eq!(integrals.generated_integral(&params).unwrap(), 16.0);
4043        assert_relative_eq!(integrals.acceptance(&params).unwrap(), 0.25);
4044        assert_relative_eq!(integrals.full_accepted_integral(&params).unwrap(), 9.0);
4045        assert_relative_eq!(
4046            integrals.acceptance_corrected_yield(&params, 12.0).unwrap(),
4047            48.0
4048        );
4049        assert_relative_eq!(integrals.cross_section(&params, 2.0).unwrap(), 8.0 / 3.0);
4050    }
4051
4052    #[cfg(feature = "wgpu")]
4053    #[test]
4054    #[ignore = "requires a WGPU-compatible hardware adapter"]
4055    fn explicit_wgpu_solve_likelihood_value_and_gradient_match_cpu() {
4056        let x = event_scalar("x");
4057        let amplitude = laddu_amplitudes::f_vector(
4058            x.clone() + 4.0,
4059            vector([1.0]),
4060            matrix([[x.clone() + 0.5, 0.25.into()], [0.1.into(), x + 1.0]]),
4061            vector([Expr::from(parameter!("scale", initial: 1.25)), 2.0.into()]),
4062            matrix([
4063                [complex(0.0, -0.2), 0.0.into()],
4064                [0.0.into(), complex(0.0, -0.1)],
4065            ]),
4066        )
4067        .unwrap()
4068        .component(0)
4069        .norm_sqr();
4070        let model = CompiledModel::from_expr(&amplitude).unwrap();
4071        let data = weighted_dataset(&[(0.5, 1.0), (1.0, 0.75), (1.5, 1.25)]);
4072        let accepted = weighted_dataset(&[(0.25, 0.5), (0.75, 1.0), (1.25, 1.5)]);
4073        let make = |device, precision| {
4074            single_term_likelihood_with_execution(
4075                "solve",
4076                &model,
4077                &data,
4078                &accepted,
4079                Execution::local(ExecutionOptions {
4080                    device,
4081                    precision,
4082                    ..ExecutionOptions::default()
4083                })
4084                .unwrap(),
4085            )
4086        };
4087        let cpu = make(Device::Cpu(CpuOptions::default()), Precision::F64);
4088        let gpu = make(
4089            Device::Gpu(GpuOptions {
4090                backend: GpuBackend::Wgpu,
4091                ..GpuOptions::default()
4092            }),
4093            Precision::F32,
4094        );
4095        let params = cpu.default_params();
4096        let expected = cpu.nll_with_gradient(&params).unwrap();
4097        let actual = gpu.nll_with_gradient(&params).unwrap();
4098        assert_relative_eq!(actual.value(), expected.value(), epsilon = 2.0e-4);
4099        assert_relative_eq!(
4100            actual.gradient()[0],
4101            expected.gradient()[0],
4102            epsilon = 5.0e-4
4103        );
4104    }
4105}