Skip to main content

laddu_physics/
generation.rs

1//! Kinematic proposal primitives used by Monte Carlo generators.
2
3use serde::{Deserialize, Serialize};
4use std::{f64::consts::PI, sync::Arc};
5
6use crate::{
7    LadduPhysicsError, LadduPhysicsResult,
8    histogram::Histogram,
9    quantum::ParticleProperties,
10    vectors::{RealVec3, RealVec4},
11};
12
13/// Deterministic, portable random-number stream for generation proposals.
14#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct ProposalRng {
16    state: u64,
17}
18
19impl ProposalRng {
20    /// Construct a proposal stream from a reproducible seed.
21    pub fn new(seed: u64) -> Self {
22        Self { state: seed }
23    }
24
25    /// Draw the next uniformly distributed 64-bit integer.
26    pub fn next_u64(&mut self) -> u64 {
27        self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
28        let mut z = self.state;
29        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
30        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
31        z ^ (z >> 31)
32    }
33
34    /// Draw a floating-point value strictly between zero and one.
35    pub fn uniform(&mut self) -> f64 {
36        const SCALE: f64 = 1.0 / ((1_u64 << 53) as f64);
37        ((self.next_u64() >> 11) as f64 + 0.5) * SCALE
38    }
39
40    fn isotropic_direction(&mut self) -> RealVec3 {
41        let cos_theta = 2.0 * self.uniform() - 1.0;
42        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
43        let phi = 2.0 * PI * self.uniform();
44        RealVec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta)
45    }
46}
47
48#[derive(Clone, Copy, Debug)]
49/// A named generated edge and its four-momentum.
50pub struct NamedMomentum<'a> {
51    /// Edge name.
52    pub name: &'a str,
53    /// Four-momentum in `(E, px, py, pz)` order.
54    pub p4: RealVec4,
55}
56
57#[derive(Clone, Copy, Debug)]
58/// A named generated edge and its proposed invariant mass.
59pub struct NamedMass<'a> {
60    /// Edge name.
61    pub name: &'a str,
62    /// Invariant mass.
63    pub mass: f64,
64}
65
66#[derive(Clone, Debug)]
67/// Kinematics and importance weight produced by a vertex proposal.
68pub struct ProposalResult {
69    /// Proposed outgoing four-momenta in edge order.
70    pub outgoing: Vec<RealVec4>,
71    /// The proposal correction, conventionally `dPhi / q`.
72    pub weight: f64,
73}
74
75#[derive(Clone, Copy, Debug)]
76/// Mass and importance weight drawn from a [`MassProposal`].
77pub struct MassProposalResult {
78    /// Proposed invariant mass.
79    pub mass: f64,
80    /// Inverse proposal-density correction.
81    pub weight: f64,
82}
83
84/// Invariant-mass proposal for a generated edge.
85#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum MassProposal {
88    /// Always use one invariant mass.
89    Fixed {
90        /// Fixed invariant mass.
91        mass: f64,
92    },
93    /// Sample uniformly between the given bounds, clipped to kinematic support.
94    Uniform {
95        /// Lower proposal bound.
96        low: f64,
97        /// Upper proposal bound.
98        high: f64,
99    },
100}
101
102impl MassProposal {
103    /// Construct a fixed-mass proposal.
104    pub fn fixed(mass: f64) -> Self {
105        Self::Fixed { mass }
106    }
107
108    /// Construct a uniform mass proposal.
109    pub fn uniform(low: f64, high: f64) -> Self {
110        Self::Uniform { low, high }
111    }
112
113    /// Draw a mass within the supplied kinematic interval.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`LadduPhysicsError`] when the kinematic interval or proposal
118    /// bounds are non-finite, empty, or exclude a fixed mass.
119    pub fn propose(
120        &self,
121        minimum: f64,
122        maximum: f64,
123        rng: &mut ProposalRng,
124    ) -> LadduPhysicsResult<MassProposalResult> {
125        match *self {
126            Self::Fixed { mass } => {
127                if !mass.is_finite() || mass < minimum || mass > maximum {
128                    return Err(LadduPhysicsError::invalid_value(
129                        "fixed mass",
130                        format!("a finite value in [{minimum}, {maximum}]"),
131                        mass,
132                    ));
133                }
134                Ok(MassProposalResult { mass, weight: 1.0 })
135            }
136            Self::Uniform { low, high } => {
137                let (low, high) = uniform_mass_support(low, high, minimum, maximum)?;
138                let width = high - low;
139                Ok(MassProposalResult {
140                    mass: low + rng.uniform() * width,
141                    weight: width,
142                })
143            }
144        }
145    }
146
147    /// Evaluate the proposal density when it is available.
148    ///
149    /// Custom proposals may retain the default `None`; density-aware generators
150    /// use this hook only for optional importance adaptation.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`LadduPhysicsError`] when the kinematic interval and uniform
155    /// proposal have no valid finite overlap.
156    pub fn density(
157        &self,
158        minimum: f64,
159        maximum: f64,
160        mass: f64,
161    ) -> LadduPhysicsResult<Option<f64>> {
162        match *self {
163            // A point mass has no ordinary continuous density.
164            Self::Fixed { .. } => Ok(None),
165            Self::Uniform { low, high } => {
166                let (low, high) = uniform_mass_support(low, high, minimum, maximum)?;
167                Ok(Some(if mass >= low && mass <= high {
168                    (high - low).recip()
169                } else {
170                    0.0
171                }))
172            }
173        }
174    }
175}
176
177impl From<f64> for MassProposal {
178    fn from(mass: f64) -> Self {
179        Self::fixed(mass)
180    }
181}
182
183impl From<std::ops::Range<f64>> for MassProposal {
184    fn from(range: std::ops::Range<f64>) -> Self {
185        Self::uniform(range.start, range.end)
186    }
187}
188
189fn uniform_mass_support(
190    proposal_low: f64,
191    proposal_high: f64,
192    minimum: f64,
193    maximum: f64,
194) -> LadduPhysicsResult<(f64, f64)> {
195    if !proposal_low.is_finite() || !proposal_high.is_finite() || proposal_high <= proposal_low {
196        return Err(LadduPhysicsError::invalid_relation(format!(
197            "uniform mass proposal requires finite low < high, got [{proposal_low}, {proposal_high}]"
198        )));
199    }
200    let low = proposal_low.max(minimum);
201    let high = proposal_high.min(maximum);
202    if high <= low {
203        return Err(LadduPhysicsError::invalid_relation(format!(
204            "uniform mass support [{proposal_low}, {proposal_high}] does not overlap the allowed interval [{minimum}, {maximum}]"
205        )));
206    }
207    Ok((low, high))
208}
209
210/// Kinematic proposal attached to a channel vertex.
211#[derive(Clone, Debug, Default, Serialize, Deserialize)]
212#[serde(tag = "kind", rename_all = "snake_case")]
213pub enum VertexProposal {
214    /// Generate an isotropic one-to-two decay.
215    #[default]
216    TwoBodyDecay,
217    /// Generate two-to-two scattering from a transfer distribution.
218    TwoBodyScattering {
219        /// Scattering proposal configuration.
220        proposal: TwoBodyScattering,
221    },
222}
223
224impl VertexProposal {
225    /// Construct an isotropic two-body decay proposal.
226    pub fn isotropic_decay() -> Self {
227        Self::TwoBodyDecay
228    }
229
230    /// Construct a two-body scattering proposal distributed in momentum transfer.
231    pub fn t_exchange(
232        pairing: (impl Into<String>, impl Into<String>),
233        distribution: TDistribution,
234    ) -> Self {
235        Self::TwoBodyScattering {
236            proposal: TwoBodyScattering::t_exchange(pairing, distribution),
237        }
238    }
239
240    /// Propose outgoing kinematics for a vertex.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`LadduPhysicsError`] when the incoming or outgoing topology,
245    /// masses, or kinematics are invalid for the selected proposal.
246    pub fn propose(
247        &self,
248        incoming: &[NamedMomentum<'_>],
249        outgoing: &[NamedMass<'_>],
250        rng: &mut ProposalRng,
251    ) -> LadduPhysicsResult<ProposalResult> {
252        match self {
253            Self::TwoBodyDecay => propose_two_body_decay(incoming, outgoing, rng),
254            Self::TwoBodyScattering { proposal } => proposal.propose(incoming, outgoing, rng),
255        }
256    }
257}
258
259fn propose_two_body_decay(
260    incoming: &[NamedMomentum<'_>],
261    outgoing: &[NamedMass<'_>],
262    rng: &mut ProposalRng,
263) -> LadduPhysicsResult<ProposalResult> {
264    if incoming.len() != 1 || outgoing.len() != 2 {
265        return Err(LadduPhysicsError::invalid_relation(format!(
266            "isotropic decay requires one incoming and two outgoing edges, got {} incoming and {} outgoing",
267            incoming.len(),
268            outgoing.len()
269        )));
270    }
271    let parent = incoming[0].p4;
272    let mass = parent.m()?;
273    let p = two_body_momentum(mass, outgoing[0].mass, outgoing[1].mass)?;
274    let direction = rng.isotropic_direction();
275    let first = on_shell(direction, p, outgoing[0].mass);
276    let second = on_shell(-direction, p, outgoing[1].mass);
277    let beta = parent.beta()?;
278    Ok(ProposalResult {
279        outgoing: vec![first.boost(&beta), second.boost(&beta)],
280        weight: p / (4.0 * PI * mass),
281    })
282}
283
284/// Density-adapted two-body decay used by the channel generator after a pilot run.
285#[derive(Clone, Debug)]
286#[doc(hidden)]
287pub struct AdaptiveTwoBodyDecay {
288    counts: Arc<[f64]>,
289    total: f64,
290    defensive_fraction: f64,
291}
292
293impl AdaptiveTwoBodyDecay {
294    /// Construct an angular proposal from nonnegative pilot-bin counts.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`LadduPhysicsError`] when the counts are empty, negative, or
299    /// non-finite, their total is not positive and finite, or
300    /// `defensive_fraction` is outside `[0, 1]`.
301    pub fn new(counts: Arc<[f64]>, defensive_fraction: f64) -> LadduPhysicsResult<Self> {
302        let total: f64 = counts.iter().sum();
303        if counts.is_empty()
304            || counts
305                .iter()
306                .any(|count| !count.is_finite() || *count < 0.0)
307            || !total.is_finite()
308            || total <= 0.0
309            || !defensive_fraction.is_finite()
310            || !(0.0..=1.0).contains(&defensive_fraction)
311        {
312            return Err(LadduPhysicsError::invalid_relation(
313                "adaptive decay requires nonnegative finite counts with positive total and a defensive fraction in [0, 1]",
314            ));
315        }
316        Ok(Self {
317            counts,
318            total,
319            defensive_fraction,
320        })
321    }
322
323    fn sample_costheta(&self, rng: &mut ProposalRng) -> (f64, f64) {
324        let width = 2.0 / self.counts.len() as f64;
325        let costheta = if rng.uniform() < self.defensive_fraction {
326            2.0 * rng.uniform() - 1.0
327        } else {
328            let mut threshold = rng.uniform() * self.total;
329            let mut selected = self.counts.len() - 1;
330            for (bin, count) in self.counts.iter().enumerate() {
331                if threshold <= *count {
332                    selected = bin;
333                    break;
334                }
335                threshold -= count;
336            }
337            -1.0 + (selected as f64 + rng.uniform()) * width
338        };
339        let bin = (((costheta + 1.0) / width) as usize).min(self.counts.len() - 1);
340        let learned_density = self.counts[bin] / (self.total * width);
341        let density =
342            self.defensive_fraction * 0.5 + (1.0 - self.defensive_fraction) * learned_density;
343        (costheta, density)
344    }
345}
346
347impl AdaptiveTwoBodyDecay {
348    /// Propose a two-body decay from the adapted angular density.
349    ///
350    /// # Errors
351    ///
352    /// Returns [`LadduPhysicsError`] when the vertex is not a one-to-two decay
353    /// or its masses and momenta are not physically valid.
354    pub fn propose(
355        &self,
356        incoming: &[NamedMomentum<'_>],
357        outgoing: &[NamedMass<'_>],
358        rng: &mut ProposalRng,
359    ) -> LadduPhysicsResult<ProposalResult> {
360        if incoming.len() != 1 || outgoing.len() != 2 {
361            return Err(LadduPhysicsError::invalid_relation(format!(
362                "adaptive decay requires one incoming and two outgoing edges, got {} incoming and {} outgoing",
363                incoming.len(),
364                outgoing.len()
365            )));
366        }
367        let parent = incoming[0].p4;
368        let mass = parent.m()?;
369        let p = two_body_momentum(mass, outgoing[0].mass, outgoing[1].mass)?;
370        let (cos_theta, density) = self.sample_costheta(rng);
371        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
372        let phi = 2.0 * PI * rng.uniform();
373        let direction = RealVec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta);
374        let first = on_shell(direction, p, outgoing[0].mass);
375        let second = on_shell(-direction, p, outgoing[1].mass);
376        let beta = parent.beta()?;
377        Ok(ProposalResult {
378            outgoing: vec![first.boost(&beta), second.boost(&beta)],
379            weight: p / (4.0 * PI * mass) * 0.5 / density,
380        })
381    }
382}
383
384#[derive(Clone, Debug, Serialize, Deserialize)]
385/// A normalized component of a momentum-transfer proposal.
386pub enum TComponent {
387    /// Uniform density in `t`.
388    Uniform,
389    /// Density proportional to `exp(slope * t)`.
390    Exponential {
391        /// Exponential slope.
392        slope: f64,
393    },
394    /// Pole-like density proportional to
395    /// $`(\mathit{exchange\_mass}^2 - t)^{-\mathit{power}}`$.
396    Pole {
397        /// Mass of the exchanged pole.
398        exchange_mass: f64,
399        /// Power of the pole denominator.
400        power: f64,
401    },
402    /// Piecewise-constant density supplied by a histogram.
403    Histogram {
404        /// Histogram defining the piecewise density.
405        histogram: Histogram,
406    },
407}
408
409impl TComponent {
410    fn sample(&self, low: f64, high: f64, u: f64) -> LadduPhysicsResult<f64> {
411        match *self {
412            Self::Uniform => Ok(low + u * (high - low)),
413            Self::Exponential { slope } => {
414                if !slope.is_finite() {
415                    return Err(LadduPhysicsError::invalid_value(
416                        "exponential t slope",
417                        "finite",
418                        slope,
419                    ));
420                }
421                if slope.abs() < 1e-10 {
422                    return Ok(low + u * (high - low));
423                }
424                let width = high - low;
425                Ok(low + (1.0 + u * (slope * width).exp_m1()).ln() / slope)
426            }
427            Self::Pole {
428                exchange_mass,
429                power,
430            } => {
431                if !exchange_mass.is_finite()
432                    || exchange_mass < 0.0
433                    || !power.is_finite()
434                    || power <= 0.0
435                {
436                    return Err(LadduPhysicsError::invalid_relation(format!(
437                        "pole mass and power must be finite, with nonnegative mass and positive power; got exchange_mass={exchange_mass}, power={power}"
438                    )));
439                }
440                let a = exchange_mass * exchange_mass - high;
441                let b = exchange_mass * exchange_mass - low;
442                if a <= 0.0 {
443                    return Err(LadduPhysicsError::invalid_relation(format!(
444                        "pole singularity at {} lies in the physical t interval [{low}, {high}]",
445                        exchange_mass * exchange_mass
446                    )));
447                }
448                let x = if (power - 1.0).abs() < 1e-10 {
449                    a * (b / a).powf(u)
450                } else {
451                    let k = 1.0 - power;
452                    (a.powf(k) + u * (b.powf(k) - a.powf(k))).powf(1.0 / k)
453                };
454                Ok(exchange_mass * exchange_mass - x)
455            }
456            Self::Histogram { ref histogram } => {
457                let segments = Self::histogram_segments(histogram, low, high)?;
458                let total: f64 = segments.iter().map(|(_, _, weight)| weight).sum();
459                let mut threshold = u * total;
460                for (segment_low, segment_high, weight) in &segments {
461                    if threshold <= *weight {
462                        return Ok(segment_low + threshold / weight * (segment_high - segment_low));
463                    }
464                    threshold -= weight;
465                }
466                Ok(segments.last().expect("segments are nonempty").1)
467            }
468        }
469    }
470
471    fn density(&self, low: f64, high: f64, t: f64) -> LadduPhysicsResult<f64> {
472        match *self {
473            Self::Uniform => Ok(1.0 / (high - low)),
474            Self::Exponential { slope } => {
475                if !slope.is_finite() {
476                    return Err(LadduPhysicsError::invalid_value(
477                        "exponential t slope",
478                        "finite",
479                        slope,
480                    ));
481                }
482                if slope.abs() < 1e-10 {
483                    return Ok(1.0 / (high - low));
484                }
485                Ok(slope * (slope * (t - low)).exp() / (slope * (high - low)).exp_m1())
486            }
487            Self::Pole {
488                exchange_mass,
489                power,
490            } => {
491                let a = exchange_mass * exchange_mass - high;
492                let b = exchange_mass * exchange_mass - low;
493                let x = exchange_mass * exchange_mass - t;
494                if a <= 0.0 || power <= 0.0 {
495                    return Err(LadduPhysicsError::invalid_relation(format!(
496                        "invalid pole component for t interval [{low}, {high}]: exchange_mass={exchange_mass}, power={power}"
497                    )));
498                }
499                let norm = if (power - 1.0).abs() < 1e-10 {
500                    (b / a).ln()
501                } else {
502                    (b.powf(1.0 - power) - a.powf(1.0 - power)) / (1.0 - power)
503                };
504                Ok(x.powf(-power) / norm)
505            }
506            Self::Histogram { ref histogram } => {
507                let segments = Self::histogram_segments(histogram, low, high)?;
508                let total: f64 = segments.iter().map(|(_, _, weight)| weight).sum();
509                let Some((segment_low, segment_high, weight)) = segments
510                    .iter()
511                    .find(|(segment_low, segment_high, _)| t >= *segment_low && t <= *segment_high)
512                else {
513                    return Ok(0.0);
514                };
515                Ok(weight / ((segment_high - segment_low) * total))
516            }
517        }
518    }
519
520    fn histogram_segments(
521        histogram: &Histogram,
522        low: f64,
523        high: f64,
524    ) -> LadduPhysicsResult<Vec<(f64, f64, f64)>> {
525        if histogram
526            .counts()
527            .iter()
528            .any(|count| !count.is_finite() || *count < 0.0)
529            || !histogram.total_weight().is_finite()
530            || histogram.total_weight() <= 0.0
531        {
532            return Err(LadduPhysicsError::invalid_value(
533                "histogram t-proposal counts",
534                "finite and nonnegative with positive finite total weight",
535                format!("{:?}", histogram.counts()),
536            ));
537        }
538        let mut segments = Vec::new();
539        for (index, &count) in histogram.counts().iter().enumerate() {
540            let bin_low = histogram.bin_edges()[index];
541            let bin_high = histogram.bin_edges()[index + 1];
542            let segment_low = low.max(bin_low);
543            let segment_high = high.min(bin_high);
544            if segment_high > segment_low && count > 0.0 {
545                let overlap_fraction = (segment_high - segment_low) / (bin_high - bin_low);
546                segments.push((segment_low, segment_high, count * overlap_fraction));
547            }
548        }
549        if segments.is_empty() {
550            return Err(LadduPhysicsError::invalid_relation(format!(
551                "histogram support does not overlap the physical t interval [{low}, {high}]"
552            )));
553        }
554        Ok(segments)
555    }
556}
557
558#[derive(Clone, Debug, Serialize, Deserialize)]
559/// Source distribution for a generated scalar value.
560pub enum ScalarSource {
561    /// A deterministic value.
562    Constant(f64),
563    /// A uniform distribution on `[low, high)`.
564    Uniform {
565        /// Lower source bound.
566        low: f64,
567        /// Upper source bound.
568        high: f64,
569    },
570    /// A piecewise-constant histogram distribution.
571    Histogram(Histogram),
572}
573
574#[derive(Clone, Copy, Debug)]
575/// Scalar draw and its inverse proposal-density correction.
576pub struct ScalarProposalResult {
577    /// Sampled scalar value.
578    pub value: f64,
579    /// The proposal correction `1 / q(value)`; constants use one.
580    pub weight: f64,
581}
582
583impl ScalarSource {
584    /// Construct a constant scalar source.
585    pub fn constant(value: f64) -> Self {
586        Self::Constant(value)
587    }
588
589    /// Construct a uniform scalar source.
590    pub fn uniform(low: f64, high: f64) -> Self {
591        Self::Uniform { low, high }
592    }
593
594    /// Construct a histogram-backed scalar source.
595    pub fn histogram(histogram: Histogram) -> Self {
596        Self::Histogram(histogram)
597    }
598
599    /// Validate the source and return the smallest and largest values in its support.
600    ///
601    /// # Errors
602    ///
603    /// Returns [`LadduPhysicsError`] when a constant or bound is non-finite, a
604    /// uniform interval is empty, or histogram weights are invalid.
605    ///
606    /// # Panics
607    ///
608    /// Panics only if a histogram reports positive total weight without
609    /// containing any positive-weight bin.
610    pub fn support(&self) -> LadduPhysicsResult<(f64, f64)> {
611        match self {
612            Self::Constant(value) if value.is_finite() => Ok((*value, *value)),
613            Self::Constant(value) => Err(LadduPhysicsError::invalid_value(
614                "constant scalar source",
615                "finite",
616                value,
617            )),
618            Self::Uniform { low, high } if low.is_finite() && high.is_finite() && high > low => {
619                Ok((*low, *high))
620            }
621            Self::Uniform { low, high } => Err(LadduPhysicsError::invalid_relation(format!(
622                "uniform scalar source requires finite low < high, got [{low}, {high}]"
623            ))),
624            Self::Histogram(histogram) => {
625                if histogram
626                    .counts()
627                    .iter()
628                    .any(|count| !count.is_finite() || *count < 0.0)
629                    || !histogram.total_weight().is_finite()
630                    || histogram.total_weight() <= 0.0
631                {
632                    return Err(LadduPhysicsError::invalid_value(
633                        "histogram scalar-source counts",
634                        "finite and nonnegative with positive finite total weight",
635                        format!("{:?}", histogram.counts()),
636                    ));
637                }
638                let first = histogram
639                    .counts()
640                    .iter()
641                    .position(|count| *count > 0.0)
642                    .expect("positive total weight implies a positive bin");
643                let last = histogram
644                    .counts()
645                    .iter()
646                    .rposition(|count| *count > 0.0)
647                    .expect("positive total weight implies a positive bin");
648                Ok((
649                    histogram.bin_edges()[first],
650                    histogram.bin_edges()[last + 1],
651                ))
652            }
653        }
654    }
655
656    /// Draw a value and inverse-density weight from the source.
657    ///
658    /// # Errors
659    ///
660    /// Returns [`LadduPhysicsError`] when the source parameters or histogram
661    /// weights are invalid, or a histogram sample cannot be assigned to a bin.
662    pub fn sample(&self, rng: &mut ProposalRng) -> LadduPhysicsResult<ScalarProposalResult> {
663        match self {
664            Self::Constant(value) if value.is_finite() => Ok(ScalarProposalResult {
665                value: *value,
666                weight: 1.0,
667            }),
668            Self::Constant(value) => Err(LadduPhysicsError::invalid_value(
669                "constant scalar source",
670                "finite",
671                value,
672            )),
673            Self::Uniform { low, high } if low.is_finite() && high.is_finite() && high > low => {
674                Ok(ScalarProposalResult {
675                    value: low + rng.uniform() * (high - low),
676                    weight: high - low,
677                })
678            }
679            Self::Uniform { low, high } => Err(LadduPhysicsError::invalid_relation(format!(
680                "uniform scalar source requires finite low < high, got [{low}, {high}]"
681            ))),
682            Self::Histogram(histogram) => {
683                let mut histogram_rng = fastrand::Rng::with_seed(rng.next_u64());
684                let value = histogram.sample(&mut histogram_rng)?;
685                let index = histogram.bin_index(value).ok_or_else(|| {
686                    LadduPhysicsError::invalid_relation(
687                        "sampled histogram value does not belong to an in-range bin",
688                    )
689                })?;
690                let width = histogram.bin_edges()[index + 1] - histogram.bin_edges()[index];
691                let probability_density =
692                    histogram.counts()[index] / (histogram.total_weight() * width);
693                Ok(ScalarProposalResult {
694                    value,
695                    weight: probability_density.recip(),
696                })
697            }
698        }
699    }
700}
701
702/// A four-momentum source attached to an initial channel edge.
703#[derive(Clone, Debug, Serialize, Deserialize)]
704pub enum InitialMomentum {
705    /// Use a fixed four-momentum directly.
706    P4(RealVec4),
707    /// Use a fixed three-momentum and derive the energy from the particle mass.
708    Momentum(RealVec3),
709    /// Sample the energy and orient the momentum along a fixed direction.
710    EnergyDirection {
711        /// Energy source.
712        energy: ScalarSource,
713        /// Fixed direction of the initial momentum.
714        direction: RealVec3,
715    },
716}
717
718/// A sampled initial four-momentum and its inverse proposal density.
719#[derive(Clone, Copy, Debug)]
720pub struct InitialMomentumResult {
721    /// Sampled on-shell four-momentum in `(E, px, py, pz)` order.
722    pub p4: RealVec4,
723    /// Inverse proposal-density correction.
724    pub weight: f64,
725}
726
727impl InitialMomentum {
728    /// Construct a fixed four-momentum source.
729    pub fn p4(p4: RealVec4) -> Self {
730        Self::P4(p4)
731    }
732
733    /// Construct a source from a fixed three-momentum and particle mass.
734    pub fn momentum(momentum: RealVec3) -> Self {
735        Self::Momentum(momentum)
736    }
737
738    /// Construct a fixed-energy source along a direction.
739    pub fn energy_direction(energy: f64, direction: RealVec3) -> Self {
740        Self::EnergyDirection {
741            energy: ScalarSource::constant(energy),
742            direction,
743        }
744    }
745
746    /// Construct a sampled-energy source along a direction.
747    pub fn energy_source_direction(energy: ScalarSource, direction: RealVec3) -> Self {
748        Self::EnergyDirection { energy, direction }
749    }
750
751    /// Validate this source against an edge name and particle definition.
752    ///
753    /// # Errors
754    ///
755    /// Returns [`LadduPhysicsError`] when particle mass metadata is missing,
756    /// momentum components are invalid or off shell, energy support is below
757    /// threshold, or the direction cannot be normalized.
758    pub fn validate(
759        &self,
760        edge: &str,
761        properties: Option<&ParticleProperties>,
762    ) -> LadduPhysicsResult<()> {
763        match self {
764            Self::P4(p4) => {
765                let mass = particle_mass(edge, properties)?;
766                if ![p4.px(), p4.py(), p4.pz(), p4.e()]
767                    .into_iter()
768                    .all(f64::is_finite)
769                    || p4.e() < 0.0
770                {
771                    return Err(LadduPhysicsError::invalid_value(
772                        format!("initial four-momentum for edge `{edge}`"),
773                        "finite components and nonnegative energy",
774                        p4,
775                    ));
776                }
777                let tolerance = 1e-9 * (1.0 + mass * mass + p4.e() * p4.e());
778                if (p4.m2() - mass * mass).abs() > tolerance {
779                    return Err(LadduPhysicsError::invalid_relation(format!(
780                        "initial edge `{edge}` is off shell: p²={} but mass²={}",
781                        p4.m2(),
782                        mass * mass
783                    )));
784                }
785            }
786            Self::Momentum(momentum) => {
787                particle_mass(edge, properties)?;
788                if ![momentum.px(), momentum.py(), momentum.pz()]
789                    .into_iter()
790                    .all(f64::is_finite)
791                {
792                    return Err(LadduPhysicsError::invalid_value(
793                        format!("initial momentum for edge `{edge}`"),
794                        "finite components",
795                        momentum,
796                    ));
797                }
798            }
799            Self::EnergyDirection { energy, direction } => {
800                let mass = particle_mass(edge, properties)?;
801                let (minimum, _) = energy.support()?;
802                if minimum < mass {
803                    return Err(LadduPhysicsError::invalid_value(
804                        format!("energy support for initial edge `{edge}`"),
805                        format!("entirely at or above its particle mass {mass}"),
806                        minimum,
807                    ));
808                }
809                direction.unit()?;
810            }
811        }
812        Ok(())
813    }
814
815    /// Draw an initial four-momentum after validating its particle definition.
816    ///
817    /// # Errors
818    ///
819    /// Returns [`LadduPhysicsError`] when source validation fails or a sampled
820    /// value cannot produce a physical on-shell momentum.
821    pub fn sample(
822        &self,
823        edge: &str,
824        properties: Option<&ParticleProperties>,
825        rng: &mut ProposalRng,
826    ) -> LadduPhysicsResult<InitialMomentumResult> {
827        self.validate(edge, properties)?;
828        self.sample_prevalidated(particle_mass(edge, properties)?, rng)
829    }
830
831    /// Sample after channel validation has already established the source and
832    /// particle-mass invariants.
833    ///
834    /// # Errors
835    ///
836    /// Returns [`LadduPhysicsError`] when a scalar source cannot be sampled or
837    /// the supplied mass and sampled energy do not define a physical momentum.
838    #[doc(hidden)]
839    pub fn sample_prevalidated(
840        &self,
841        mass: f64,
842        rng: &mut ProposalRng,
843    ) -> LadduPhysicsResult<InitialMomentumResult> {
844        match self {
845            Self::P4(p4) => Ok(InitialMomentumResult {
846                p4: *p4,
847                weight: 1.0,
848            }),
849            Self::Momentum(momentum) => Ok(InitialMomentumResult {
850                p4: momentum.with_mass(mass),
851                weight: 1.0,
852            }),
853            Self::EnergyDirection { energy, direction } => {
854                let sampled = energy.sample(rng)?;
855                if sampled.value < mass {
856                    return Err(LadduPhysicsError::invalid_value(
857                        "sampled initial-state energy",
858                        format!("at or above the particle mass {mass}"),
859                        sampled.value,
860                    ));
861                }
862                let momentum =
863                    direction.unit()? * (sampled.value * sampled.value - mass * mass).sqrt();
864                Ok(InitialMomentumResult {
865                    p4: momentum.with_energy(sampled.value),
866                    weight: sampled.weight,
867                })
868            }
869        }
870    }
871}
872
873fn particle_mass(edge: &str, properties: Option<&ParticleProperties>) -> LadduPhysicsResult<f64> {
874    properties
875        .ok_or_else(|| {
876            LadduPhysicsError::invalid_relation(format!(
877                "initial edge `{edge}` has no particle properties"
878            ))
879        })?
880        .mass()
881}
882
883#[derive(Clone, Debug, Serialize, Deserialize)]
884/// Mixture distribution for Mandelstam `t`.
885pub struct TDistribution {
886    components: Vec<(f64, TComponent)>,
887    #[serde(default)]
888    t_min: Option<f64>,
889    #[serde(default)]
890    t_max: Option<f64>,
891}
892
893impl TDistribution {
894    /// Construct a uniform distribution in `t`.
895    pub fn uniform() -> Self {
896        Self::mixture([(1.0, TComponent::Uniform)])
897    }
898
899    /// Construct an exponential distribution in `t`.
900    pub fn exponential(slope: f64) -> Self {
901        Self::mixture([(1.0, TComponent::Exponential { slope })])
902    }
903
904    /// Construct a pole-like distribution in `t`.
905    pub fn pole(exchange_mass: f64, power: f64) -> Self {
906        Self::mixture([(
907            1.0,
908            TComponent::Pole {
909                exchange_mass,
910                power,
911            },
912        )])
913    }
914
915    /// Construct a histogram-backed distribution in `t`.
916    pub fn histogram(histogram: Histogram) -> Self {
917        Self::mixture([(1.0, TComponent::Histogram { histogram })])
918    }
919
920    /// Construct a weighted mixture of transfer-density components.
921    pub fn mixture(components: impl IntoIterator<Item = (f64, TComponent)>) -> Self {
922        Self {
923            components: components.into_iter().collect(),
924            t_min: None,
925            t_max: None,
926        }
927    }
928
929    /// Restrict this proposal to the intersection of these limits and the
930    /// event-by-event physical t interval.
931    ///
932    /// # Errors
933    ///
934    /// Returns [`LadduPhysicsError`] when a specified limit is non-finite or
935    /// `t_min` is not less than `t_max`.
936    ///
937    /// # Panics
938    ///
939    /// Panics only if an option tested as present unexpectedly contains no
940    /// value.
941    pub fn with_limits(
942        mut self,
943        t_min: Option<f64>,
944        t_max: Option<f64>,
945    ) -> LadduPhysicsResult<Self> {
946        if t_min.is_some_and(|value| !value.is_finite()) {
947            return Err(LadduPhysicsError::invalid_value(
948                "t_min",
949                "finite when specified",
950                t_min.unwrap(),
951            ));
952        }
953        if t_max.is_some_and(|value| !value.is_finite()) {
954            return Err(LadduPhysicsError::invalid_value(
955                "t_max",
956                "finite when specified",
957                t_max.unwrap(),
958            ));
959        }
960        if let (Some(t_min), Some(t_max)) = (t_min, t_max)
961            && t_max <= t_min
962        {
963            return Err(LadduPhysicsError::invalid_relation(format!(
964                "t limits require t_min < t_max, got [{t_min}, {t_max}]"
965            )));
966        }
967        self.t_min = t_min;
968        self.t_max = t_max;
969        Ok(self)
970    }
971
972    fn normalization(&self) -> LadduPhysicsResult<f64> {
973        if self.components.is_empty() {
974            return Err(LadduPhysicsError::invalid_length(
975                "t-distribution components",
976                "at least one",
977                0,
978            ));
979        }
980        if self
981            .components
982            .iter()
983            .any(|(weight, _)| !weight.is_finite() || *weight <= 0.0)
984        {
985            return Err(LadduPhysicsError::invalid_value(
986                "t-distribution mixture weights",
987                "finite and positive",
988                format!(
989                    "{:?}",
990                    self.components
991                        .iter()
992                        .map(|(weight, _)| weight)
993                        .collect::<Vec<_>>()
994                ),
995            ));
996        }
997        let sum: f64 = self.components.iter().map(|(weight, _)| weight).sum();
998        Ok(sum)
999    }
1000
1001    fn sample(&self, low: f64, high: f64, rng: &mut ProposalRng) -> LadduPhysicsResult<(f64, f64)> {
1002        if !low.is_finite() || !high.is_finite() || high <= low {
1003            return Err(LadduPhysicsError::invalid_relation(format!(
1004                "physical t interval must have finite bounds with low < high, got [{low}, {high}]"
1005            )));
1006        }
1007        let physical_low = low;
1008        let physical_high = high;
1009        let low = self.t_min.map_or(low, |t_min| low.max(t_min));
1010        let high = self.t_max.map_or(high, |t_max| high.min(t_max));
1011        if high <= low {
1012            return Err(LadduPhysicsError::invalid_relation(format!(
1013                "configured t limits do not overlap the physical interval [{physical_low}, {physical_high}]"
1014            )));
1015        }
1016        let normalization = self.normalization()?;
1017        let choice = rng.uniform();
1018        let mut cumulative = 0.0;
1019        let mut selected = self.components.len() - 1;
1020        for (index, (weight, _)) in self.components.iter().enumerate() {
1021            cumulative += weight / normalization;
1022            if choice < cumulative {
1023                selected = index;
1024                break;
1025            }
1026        }
1027        let t = self.components[selected]
1028            .1
1029            .sample(low, high, rng.uniform())?;
1030        let mut density = 0.0;
1031        for (weight, component) in &self.components {
1032            density += weight / normalization * component.density(low, high, t)?;
1033        }
1034        if !density.is_finite() || density <= 0.0 {
1035            return Err(LadduPhysicsError::invalid_value(
1036                "t-proposal density",
1037                "finite and positive",
1038                density,
1039            ));
1040        }
1041        Ok((t, density))
1042    }
1043}
1044
1045#[derive(Clone, Debug, Serialize, Deserialize)]
1046/// Two-to-two scattering proposal based on a selected incoming/outgoing
1047/// momentum-transfer pairing.
1048pub struct TwoBodyScattering {
1049    incoming_edge: String,
1050    outgoing_edge: String,
1051    distribution: TDistribution,
1052}
1053
1054impl TwoBodyScattering {
1055    /// Construct a `t`-exchange proposal for the named edge pairing.
1056    pub fn t_exchange(
1057        pairing: (impl Into<String>, impl Into<String>),
1058        distribution: TDistribution,
1059    ) -> Self {
1060        Self {
1061            incoming_edge: pairing.0.into(),
1062            outgoing_edge: pairing.1.into(),
1063            distribution,
1064        }
1065    }
1066}
1067
1068impl From<TwoBodyScattering> for VertexProposal {
1069    fn from(proposal: TwoBodyScattering) -> Self {
1070        Self::TwoBodyScattering { proposal }
1071    }
1072}
1073
1074impl TwoBodyScattering {
1075    /// Propose outgoing two-body scattering kinematics.
1076    ///
1077    /// # Errors
1078    ///
1079    /// Returns [`LadduPhysicsError`] when the topology or configured edge
1080    /// pairing is invalid, the event is outside physical phase space, or the
1081    /// transfer distribution cannot be sampled.
1082    pub fn propose(
1083        &self,
1084        incoming: &[NamedMomentum<'_>],
1085        outgoing: &[NamedMass<'_>],
1086        rng: &mut ProposalRng,
1087    ) -> LadduPhysicsResult<ProposalResult> {
1088        if incoming.len() != 2 || outgoing.len() != 2 {
1089            return Err(LadduPhysicsError::invalid_relation(format!(
1090                "two-body scattering requires two incoming and two outgoing edges, got {} incoming and {} outgoing",
1091                incoming.len(),
1092                outgoing.len()
1093            )));
1094        }
1095        let paired_in = incoming
1096            .iter()
1097            .position(|edge| edge.name == self.incoming_edge)
1098            .ok_or_else(|| {
1099                LadduPhysicsError::invalid_relation(format!(
1100                    "unknown incoming t-pairing edge `{}`",
1101                    self.incoming_edge
1102                ))
1103            })?;
1104        let paired_out = outgoing
1105            .iter()
1106            .position(|edge| edge.name == self.outgoing_edge)
1107            .ok_or_else(|| {
1108                LadduPhysicsError::invalid_relation(format!(
1109                    "unknown outgoing t-pairing edge `{}`",
1110                    self.outgoing_edge
1111                ))
1112            })?;
1113        let total = incoming[0].p4 + incoming[1].p4;
1114        let root_s = total.m()?;
1115        let beta = total.beta()?;
1116        let incoming_com = incoming[paired_in].p4.boost(&(-beta));
1117        // Invariant masses are best evaluated before the boost. In particular,
1118        // boosting a massless four-vector can leave a tiny negative m^2 from
1119        // floating-point cancellation.
1120        let m1 = incoming[paired_in].p4.m()?;
1121        let m2 = incoming[1 - paired_in].p4.m()?;
1122        let m3 = outgoing[paired_out].mass;
1123        let m4 = outgoing[1 - paired_out].mass;
1124        let p_in = two_body_momentum(root_s, m1, m2)?;
1125        let p_out = two_body_momentum(root_s, m3, m4)?;
1126        if p_in <= 0.0 {
1127            return Err(LadduPhysicsError::invalid_relation(
1128                "t exchange is undefined at the incoming threshold",
1129            ));
1130        }
1131        let e1 = (m1 * m1 + p_in * p_in).sqrt();
1132        let e3 = (m3 * m3 + p_out * p_out).sqrt();
1133        let center = m1 * m1 + m3 * m3 - 2.0 * e1 * e3;
1134        let span = 2.0 * p_in * p_out;
1135        let (t, q_t) = self
1136            .distribution
1137            .sample(center - span, center + span, rng)?;
1138        let cos_theta = ((t - center) / span).clamp(-1.0, 1.0);
1139        let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
1140        let phi = 2.0 * PI * rng.uniform();
1141        let z = incoming_com.vec3().unit()?;
1142        let seed = if z.z.abs() < 0.9 {
1143            RealVec3::new(0.0, 0.0, 1.0)
1144        } else {
1145            RealVec3::new(1.0, 0.0, 0.0)
1146        };
1147        let x = seed.cross(&z).unit()?;
1148        let y = z.cross(&x);
1149        let direction = z * cos_theta + x * (sin_theta * phi.cos()) + y * (sin_theta * phi.sin());
1150        let paired = on_shell(direction, p_out, m3).boost(&beta);
1151        let other = on_shell(-direction, p_out, m4).boost(&beta);
1152        let mut result = vec![RealVec4::new(0.0, 0.0, 0.0, 0.0); 2];
1153        result[paired_out] = paired;
1154        result[1 - paired_out] = other;
1155        Ok(ProposalResult {
1156            outgoing: result,
1157            weight: 1.0 / (16.0 * PI * root_s * p_in * q_t),
1158        })
1159    }
1160}
1161
1162fn two_body_momentum(parent: f64, first: f64, second: f64) -> LadduPhysicsResult<f64> {
1163    if !parent.is_finite()
1164        || !first.is_finite()
1165        || !second.is_finite()
1166        || parent <= 0.0
1167        || first < 0.0
1168        || second < 0.0
1169    {
1170        return Err(LadduPhysicsError::invalid_relation(format!(
1171            "two-body masses must be finite, with a positive parent and nonnegative daughters; got parent={parent}, first={first}, second={second}"
1172        )));
1173    }
1174    if parent < first + second {
1175        return Err(LadduPhysicsError::invalid_relation(format!(
1176            "two-body threshold is closed: {parent} < {}",
1177            first + second
1178        )));
1179    }
1180    let lambda =
1181        (parent * parent - (first + second).powi(2)) * (parent * parent - (first - second).powi(2));
1182    Ok(lambda.max(0.0).sqrt() / (2.0 * parent))
1183}
1184
1185fn on_shell(direction: RealVec3, momentum: f64, mass: f64) -> RealVec4 {
1186    (direction * momentum).with_mass(mass)
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192
1193    #[test]
1194    fn isotropic_decay_conserves_momentum_and_mass() {
1195        let proposal = VertexProposal::isotropic_decay();
1196        let incoming = [NamedMomentum {
1197            name: "x",
1198            p4: RealVec4::new(2.0, 0.3, -0.2, 1.0),
1199        }];
1200        let outgoing = [
1201            NamedMass {
1202                name: "a",
1203                mass: 0.2,
1204            },
1205            NamedMass {
1206                name: "b",
1207                mass: 0.4,
1208            },
1209        ];
1210        let result = proposal
1211            .propose(&incoming, &outgoing, &mut ProposalRng::new(7))
1212            .unwrap();
1213        let sum = result.outgoing[0] + result.outgoing[1];
1214        for (a, b) in [sum.e, sum.px, sum.py, sum.pz]
1215            .into_iter()
1216            .zip([2.0, 0.3, -0.2, 1.0])
1217        {
1218            assert!((a - b).abs() < 1e-12);
1219        }
1220        assert!((result.outgoing[0].m().unwrap() - 0.2).abs() < 1e-12);
1221        assert!((result.outgoing[1].m().unwrap() - 0.4).abs() < 1e-12);
1222        assert!(result.weight > 0.0);
1223    }
1224
1225    #[test]
1226    fn t_mixture_samples_inside_physical_range() {
1227        let distribution = TDistribution::mixture([
1228            (1.0, TComponent::Uniform),
1229            (2.0, TComponent::Exponential { slope: 3.0 }),
1230            (
1231                1.0,
1232                TComponent::Pole {
1233                    exchange_mass: 1.0,
1234                    power: 2.0,
1235                },
1236            ),
1237        ]);
1238        let mut rng = ProposalRng::new(11);
1239        for _ in 0..100 {
1240            let (t, density) = distribution.sample(-2.0, -0.1, &mut rng).unwrap();
1241            assert!((-2.0..=-0.1).contains(&t));
1242            assert!(density.is_finite() && density > 0.0);
1243        }
1244    }
1245
1246    #[test]
1247    fn t_distribution_limits_truncate_the_physical_interval() {
1248        let distribution = TDistribution::uniform()
1249            .with_limits(Some(-1.25), Some(-0.5))
1250            .unwrap();
1251        let mut rng = ProposalRng::new(13);
1252        for _ in 0..100 {
1253            let (t, density) = distribution.sample(-2.0, -0.1, &mut rng).unwrap();
1254            assert!((-1.25..=-0.5).contains(&t));
1255            assert!((density - 1.0 / 0.75).abs() < 1e-12);
1256        }
1257        assert!(
1258            TDistribution::uniform()
1259                .with_limits(Some(-0.5), Some(-1.0))
1260                .is_err()
1261        );
1262        assert!(
1263            distribution
1264                .sample(-3.0, -2.0, &mut ProposalRng::new(17))
1265                .is_err()
1266        );
1267    }
1268
1269    #[test]
1270    fn t_exchange_conserves_momentum_and_is_on_shell() {
1271        let proposal =
1272            TwoBodyScattering::t_exchange(("beam", "x"), TDistribution::exponential(2.0));
1273        let incoming = [
1274            NamedMomentum {
1275                name: "beam",
1276                p4: RealVec4::new(1.5, 0.0, 0.0, 1.0),
1277            },
1278            NamedMomentum {
1279                name: "target",
1280                p4: RealVec4::new(1.5, 0.0, 0.0, -1.0),
1281            },
1282        ];
1283        let outgoing = [
1284            NamedMass {
1285                name: "x",
1286                mass: 0.5,
1287            },
1288            NamedMass {
1289                name: "r",
1290                mass: 0.7,
1291            },
1292        ];
1293        let result = proposal
1294            .propose(&incoming, &outgoing, &mut ProposalRng::new(19))
1295            .unwrap();
1296        let before = incoming[0].p4 + incoming[1].p4;
1297        let after = result.outgoing[0] + result.outgoing[1];
1298        assert!((before.e - after.e).abs() < 1e-12);
1299        assert!((before.px - after.px).abs() < 1e-12);
1300        assert!((before.py - after.py).abs() < 1e-12);
1301        assert!((before.pz - after.pz).abs() < 1e-12);
1302        assert!((result.outgoing[0].m().unwrap() - 0.5).abs() < 1e-12);
1303        assert!((result.outgoing[1].m().unwrap() - 0.7).abs() < 1e-12);
1304    }
1305
1306    #[test]
1307    fn adaptive_decay_preserves_the_phase_space_integral() {
1308        let incoming = [NamedMomentum {
1309            name: "parent",
1310            p4: RealVec4::new(2.0, 0.0, 0.0, 0.0),
1311        }];
1312        let outgoing = [
1313            NamedMass {
1314                name: "a",
1315                mass: 0.2,
1316            },
1317            NamedMass {
1318                name: "b",
1319                mass: 0.4,
1320            },
1321        ];
1322        let adaptive =
1323            AdaptiveTwoBodyDecay::new(Arc::from([1.0, 2.0, 8.0, 20.0, 8.0, 2.0, 1.0]), 0.2)
1324                .unwrap();
1325        let baseline = VertexProposal::isotropic_decay()
1326            .propose(&incoming, &outgoing, &mut ProposalRng::new(1))
1327            .unwrap()
1328            .weight;
1329        let mut rng = ProposalRng::new(2);
1330        let samples = 100_000;
1331        let mean = (0..samples)
1332            .map(|_| {
1333                adaptive
1334                    .propose(&incoming, &outgoing, &mut rng)
1335                    .unwrap()
1336                    .weight
1337            })
1338            .sum::<f64>()
1339            / samples as f64;
1340        assert!((mean / baseline - 1.0).abs() < 0.01);
1341    }
1342
1343    #[test]
1344    fn proposal_failures_use_structured_physics_errors() {
1345        let empty = TDistribution::mixture([]);
1346        assert!(matches!(
1347            empty.normalization(),
1348            Err(LadduPhysicsError::InvalidLength { .. })
1349        ));
1350
1351        assert!(matches!(
1352            MassProposal::fixed(2.0).propose(0.0, 1.0, &mut ProposalRng::new(0)),
1353            Err(LadduPhysicsError::InvalidValue { .. })
1354        ));
1355
1356        assert!(matches!(
1357            VertexProposal::isotropic_decay().propose(&[], &[], &mut ProposalRng::new(0)),
1358            Err(LadduPhysicsError::InvalidRelation { .. })
1359        ));
1360    }
1361
1362    #[test]
1363    fn histogram_t_component_truncates_to_the_physical_interval() {
1364        let histogram = Histogram::new(vec![1.0, 3.0], vec![-2.0, -1.0, 0.0]).unwrap();
1365        let distribution = TDistribution::histogram(histogram);
1366        let mut rng = ProposalRng::new(31);
1367        for _ in 0..100 {
1368            let (t, density) = distribution.sample(-1.5, -0.5, &mut rng).unwrap();
1369            assert!((-1.5..=-0.5).contains(&t));
1370            assert!(density.is_finite() && density > 0.0);
1371        }
1372    }
1373
1374    #[test]
1375    fn scalar_sources_return_values_and_proposal_corrections() {
1376        let mut rng = ProposalRng::new(37);
1377        let constant = ScalarSource::constant(3.0).sample(&mut rng).unwrap();
1378        assert_eq!(constant.value, 3.0);
1379        assert_eq!(constant.weight, 1.0);
1380
1381        let uniform = ScalarSource::uniform(-2.0, 4.0).sample(&mut rng).unwrap();
1382        assert!((-2.0..4.0).contains(&uniform.value));
1383        assert_eq!(uniform.weight, 6.0);
1384
1385        let histogram = Histogram::new(vec![1.0, 2.0], vec![0.0, 1.0, 3.0]).unwrap();
1386        let sampled = ScalarSource::histogram(histogram).sample(&mut rng).unwrap();
1387        assert!((0.0..3.0).contains(&sampled.value));
1388        assert!(sampled.weight.is_finite() && sampled.weight > 0.0);
1389    }
1390
1391    #[test]
1392    fn uniform_mass_truncates_to_the_allowed_interval() {
1393        let proposal = MassProposal::uniform(1.0, 2.0);
1394        let mut rng = ProposalRng::new(41);
1395        for _ in 0..100 {
1396            let result = proposal.propose(1.25, 1.75, &mut rng).unwrap();
1397            assert!((1.25..1.75).contains(&result.mass));
1398            assert_eq!(result.weight, 0.5);
1399        }
1400    }
1401}