Skip to main content

laddu_physics/generation/
transfer.rs

1// Momentum-transfer proposal families.
2
3#[derive(Clone, Debug, Serialize, Deserialize)]
4/// A normalized component of a momentum-transfer proposal.
5pub enum TComponent {
6    /// Uniform density in `t`.
7    Uniform,
8    /// Density proportional to `exp(slope * t)`.
9    Exponential {
10        /// Exponential slope.
11        slope: f64,
12    },
13    /// Pole-like density proportional to
14    /// $`(\mathit{exchange\_mass}^2 - t)^{-\mathit{power}}`$.
15    Pole {
16        /// Mass of the exchanged pole.
17        exchange_mass: f64,
18        /// Power of the pole denominator.
19        power: f64,
20    },
21    /// Piecewise-constant density supplied by a histogram.
22    Histogram {
23        /// Histogram defining the piecewise density.
24        histogram: Histogram,
25    },
26}
27
28impl TComponent {
29    fn sample(&self, low: f64, high: f64, u: f64) -> LadduPhysicsResult<f64> {
30        match *self {
31            Self::Uniform => Ok(low + u * (high - low)),
32            Self::Exponential { slope } => {
33                if !slope.is_finite() {
34                    return Err(LadduPhysicsError::invalid_value(
35                        "exponential t slope",
36                        "finite",
37                        slope,
38                    ));
39                }
40                if slope.abs() < 1e-10 {
41                    return Ok(low + u * (high - low));
42                }
43                let width = high - low;
44                Ok(low + (1.0 + u * (slope * width).exp_m1()).ln() / slope)
45            }
46            Self::Pole {
47                exchange_mass,
48                power,
49            } => {
50                if !exchange_mass.is_finite()
51                    || exchange_mass < 0.0
52                    || !power.is_finite()
53                    || power <= 0.0
54                {
55                    return Err(LadduPhysicsError::invalid_relation(format!(
56                        "pole mass and power must be finite, with nonnegative mass and positive power; got exchange_mass={exchange_mass}, power={power}"
57                    )));
58                }
59                let a = exchange_mass * exchange_mass - high;
60                let b = exchange_mass * exchange_mass - low;
61                if a <= 0.0 {
62                    return Err(LadduPhysicsError::invalid_relation(format!(
63                        "pole singularity at {} lies in the physical t interval [{low}, {high}]",
64                        exchange_mass * exchange_mass
65                    )));
66                }
67                let x = if (power - 1.0).abs() < 1e-10 {
68                    a * (b / a).powf(u)
69                } else {
70                    let k = 1.0 - power;
71                    (a.powf(k) + u * (b.powf(k) - a.powf(k))).powf(1.0 / k)
72                };
73                Ok(exchange_mass * exchange_mass - x)
74            }
75            Self::Histogram { ref histogram } => {
76                let density = Self::histogram_density(histogram)?;
77                density.sample_with_unit(low, high, u).ok_or_else(|| {
78                    LadduPhysicsError::invalid_relation(format!(
79                        "histogram support does not overlap the physical t interval [{low}, {high}]"
80                    ))
81                })
82            }
83        }
84    }
85
86    fn density(&self, low: f64, high: f64, t: f64) -> LadduPhysicsResult<f64> {
87        match *self {
88            Self::Uniform => Ok(1.0 / (high - low)),
89            Self::Exponential { slope } => {
90                if !slope.is_finite() {
91                    return Err(LadduPhysicsError::invalid_value(
92                        "exponential t slope",
93                        "finite",
94                        slope,
95                    ));
96                }
97                if slope.abs() < 1e-10 {
98                    return Ok(1.0 / (high - low));
99                }
100                Ok(slope * (slope * (t - low)).exp() / (slope * (high - low)).exp_m1())
101            }
102            Self::Pole {
103                exchange_mass,
104                power,
105            } => {
106                let a = exchange_mass * exchange_mass - high;
107                let b = exchange_mass * exchange_mass - low;
108                let x = exchange_mass * exchange_mass - t;
109                if a <= 0.0 || power <= 0.0 {
110                    return Err(LadduPhysicsError::invalid_relation(format!(
111                        "invalid pole component for t interval [{low}, {high}]: exchange_mass={exchange_mass}, power={power}"
112                    )));
113                }
114                let norm = if (power - 1.0).abs() < 1e-10 {
115                    (b / a).ln()
116                } else {
117                    (b.powf(1.0 - power) - a.powf(1.0 - power)) / (1.0 - power)
118                };
119                Ok(x.powf(-power) / norm)
120            }
121            Self::Histogram { ref histogram } => {
122                let density = Self::histogram_density(histogram)?;
123                if density.truncated_total(low, high) <= 0.0 {
124                    return Err(LadduPhysicsError::invalid_relation(format!(
125                        "histogram support does not overlap the physical t interval [{low}, {high}]"
126                    )));
127                }
128                Ok(density.density_inclusive(low, high, t))
129            }
130        }
131    }
132
133    #[allow(dead_code)]
134    fn proven_density_floor(&self, maximum_width: f64, maximum_t: f64) -> LadduPhysicsResult<f64> {
135        if !maximum_width.is_finite() || maximum_width <= 0.0 {
136            return Err(LadduPhysicsError::invalid_relation(
137                "proven t-density bound requires a finite positive support width",
138            ));
139        }
140        match self {
141            Self::Uniform => Ok((Interval::ONE / maximum_width).inf()),
142            Self::Exponential { slope } => {
143                if !slope.is_finite() {
144                    return Err(LadduPhysicsError::invalid_value(
145                        "exponential t slope",
146                        "finite",
147                        slope,
148                    ));
149                }
150                let magnitude = slope.abs();
151                if magnitude < 1e-10 {
152                    Ok((Interval::ONE / maximum_width).inf())
153                } else {
154                    let magnitude = Interval::from(magnitude);
155                    let denominator = (magnitude * maximum_width).exp() - 1.0;
156                    Ok((magnitude / denominator).inf())
157                }
158            }
159            Self::Pole {
160                exchange_mass,
161                power,
162            } => {
163                if !exchange_mass.is_finite()
164                    || *exchange_mass < 0.0
165                    || !power.is_finite()
166                    || *power <= 0.0
167                {
168                    return Err(LadduPhysicsError::invalid_relation(
169                        "pole mass and power must be finite, with nonnegative mass and positive power",
170                    ));
171                }
172                // For x = m_ex^2 - t > 0, q(t) is proportional to x^-p.
173                // Over any interval of width W, min(q) >= (a / b)^p / W,
174                // where a and b are the smallest and largest possible x.
175                let a = Interval::from(*exchange_mass).sqr() - maximum_t;
176                if !a.inf().is_finite() || a.inf() <= 0.0 {
177                    return Ok(0.0);
178                }
179                let ratio = a / (a + maximum_width);
180                Ok((ratio.pow(Interval::from(*power)) / maximum_width).inf())
181            }
182            Self::Histogram { histogram } => {
183                let total = histogram
184                    .counts()
185                    .iter()
186                    .fold(Interval::ZERO, |sum, count| sum + *count);
187                let minimum_height = histogram
188                    .counts()
189                    .iter()
190                    .zip(histogram.bin_edges().windows(2))
191                    .filter(|(count, _)| **count > 0.0)
192                    .map(|(count, edges)| {
193                        Interval::from(*count)
194                            / (Interval::from(edges[1]) - Interval::from(edges[0]))
195                    })
196                    .reduce(IntervalOps::min)
197                    .unwrap_or(Interval::EMPTY);
198                let floor = minimum_height / total;
199                if !floor.inf().is_finite() || floor.inf() <= 0.0 {
200                    return Err(LadduPhysicsError::invalid_relation(
201                        "histogram t density has no positive finite support",
202                    ));
203                }
204                Ok(floor.inf())
205            }
206        }
207    }
208
209    fn proven_density_floor_on_interval(
210        &self,
211        support_low: f64,
212        support_high: f64,
213        local_low: f64,
214        local_high: f64,
215    ) -> LadduPhysicsResult<f64> {
216        if !support_low.is_finite()
217            || !support_high.is_finite()
218            || !local_low.is_finite()
219            || !local_high.is_finite()
220            || support_high <= support_low
221            || local_high < local_low
222        {
223            return Err(LadduPhysicsError::invalid_relation(
224                "local t-density bound requires finite ordered support intervals",
225            ));
226        }
227        let width = support_high - support_low;
228        match *self {
229            Self::Uniform => Ok((Interval::ONE / width).inf()),
230            Self::Exponential { slope } => {
231                if !slope.is_finite() {
232                    return Err(LadduPhysicsError::invalid_value(
233                        "exponential t slope",
234                        "finite",
235                        slope,
236                    ));
237                }
238                if slope.abs() < 1e-10 {
239                    return Ok((Interval::ONE / width).inf());
240                }
241                let slope = Interval::from(slope);
242                let denominator = (slope * width).exp() - 1.0;
243                let endpoint = if slope.inf() >= 0.0 {
244                    local_low
245                } else {
246                    local_high
247                };
248                let density = slope * (slope * (endpoint - support_low)).exp() / denominator;
249                Ok(density.inf())
250            }
251            Self::Pole {
252                exchange_mass,
253                power,
254            } => {
255                if !exchange_mass.is_finite()
256                    || exchange_mass < 0.0
257                    || !power.is_finite()
258                    || power <= 0.0
259                {
260                    return Err(LadduPhysicsError::invalid_relation(
261                        "pole mass and power must be finite, with nonnegative mass and positive power",
262                    ));
263                }
264                let pole = Interval::from(exchange_mass).sqr();
265                let a = pole - support_high;
266                let b = pole - support_low;
267                if a.inf() <= 0.0 {
268                    return Ok(0.0);
269                }
270                let norm = if (power - 1.0).abs() < 1e-10 {
271                    (b / a).log()
272                } else {
273                    (b.pow(Interval::from(1.0 - power))
274                        - a.pow(Interval::from(1.0 - power)))
275                        / (1.0 - power)
276                };
277                let x = pole - local_low;
278                Ok((x.pow(Interval::from(-power)) / norm).inf())
279            }
280            Self::Histogram { ref histogram } => {
281                let total = histogram
282                    .counts()
283                    .iter()
284                    .fold(Interval::ZERO, |sum, count| sum + *count);
285                let minimum_height = histogram
286                    .counts()
287                    .iter()
288                    .zip(histogram.bin_edges().windows(2))
289                    .filter(|(count, edges)| {
290                        **count > 0.0 && edges[1] >= local_low && edges[0] <= local_high
291                    })
292                    .map(|(count, edges)| {
293                        Interval::from(*count)
294                            / (Interval::from(edges[1]) - Interval::from(edges[0]))
295                    })
296                    .reduce(IntervalOps::min)
297                    .unwrap_or(Interval::EMPTY);
298                let floor = minimum_height / total;
299                if !floor.inf().is_finite() || floor.inf() <= 0.0 {
300                    return Ok(0.0);
301                }
302                Ok(floor.inf())
303            }
304        }
305    }
306
307    fn histogram_density(histogram: &Histogram) -> LadduPhysicsResult<PiecewiseDensity> {
308        if histogram
309            .counts()
310            .iter()
311            .any(|count| !count.is_finite() || *count < 0.0)
312            || !histogram.total_weight().is_finite()
313            || histogram.total_weight() <= 0.0
314        {
315            return Err(LadduPhysicsError::invalid_value(
316                "histogram t-proposal counts",
317                "finite and nonnegative with positive finite total weight",
318                format!("{:?}", histogram.counts()),
319            ));
320        }
321        PiecewiseDensity::from_histogram(histogram).map_err(|_| {
322            LadduPhysicsError::invalid_value(
323                "histogram t-proposal counts",
324                "finite and nonnegative with positive finite total weight",
325                format!("{:?}", histogram.counts()),
326            )
327        })
328    }
329}
330
331#[derive(Clone, Debug, Serialize, Deserialize)]
332/// Mixture distribution for Mandelstam `t`.
333pub struct TDistribution {
334    components: Vec<(f64, TComponent)>,
335    #[serde(default)]
336    t_min: Option<f64>,
337    #[serde(default)]
338    t_max: Option<f64>,
339}
340
341impl TDistribution {
342    /// Construct a uniform distribution in `t`.
343    pub fn uniform() -> Self {
344        Self::mixture([(1.0, TComponent::Uniform)])
345    }
346
347    /// Construct an exponential distribution in `t`.
348    pub fn exponential(slope: f64) -> Self {
349        Self::mixture([(1.0, TComponent::Exponential { slope })])
350    }
351
352    /// Construct a pole-like distribution in `t`.
353    pub fn pole(exchange_mass: f64, power: f64) -> Self {
354        Self::mixture([(
355            1.0,
356            TComponent::Pole {
357                exchange_mass,
358                power,
359            },
360        )])
361    }
362
363    /// Construct a histogram-backed distribution in `t`.
364    pub fn histogram(histogram: Histogram) -> Self {
365        Self::mixture([(1.0, TComponent::Histogram { histogram })])
366    }
367
368    /// Construct a weighted mixture of transfer-density components.
369    pub fn mixture(components: impl IntoIterator<Item = (f64, TComponent)>) -> Self {
370        Self {
371            components: components.into_iter().collect(),
372            t_min: None,
373            t_max: None,
374        }
375    }
376
377    /// Restrict this proposal to the intersection of these limits and the
378    /// event-by-event physical t interval.
379    ///
380    /// # Errors
381    ///
382    /// Returns [`LadduPhysicsError`] when a specified limit is non-finite or
383    /// `t_min` is not less than `t_max`.
384    ///
385    /// # Panics
386    ///
387    /// Panics only if an option tested as present unexpectedly contains no
388    /// value.
389    pub fn with_limits(
390        mut self,
391        t_min: Option<f64>,
392        t_max: Option<f64>,
393    ) -> LadduPhysicsResult<Self> {
394        if t_min.is_some_and(|value| !value.is_finite()) {
395            return Err(LadduPhysicsError::invalid_value(
396                "t_min",
397                "finite when specified",
398                t_min.unwrap(),
399            ));
400        }
401        if t_max.is_some_and(|value| !value.is_finite()) {
402            return Err(LadduPhysicsError::invalid_value(
403                "t_max",
404                "finite when specified",
405                t_max.unwrap(),
406            ));
407        }
408        if let (Some(t_min), Some(t_max)) = (t_min, t_max)
409            && t_max <= t_min
410        {
411            return Err(LadduPhysicsError::invalid_relation(format!(
412                "t limits require t_min < t_max, got [{t_min}, {t_max}]"
413            )));
414        }
415        self.t_min = t_min;
416        self.t_max = t_max;
417        Ok(self)
418    }
419
420    fn normalization(&self) -> LadduPhysicsResult<f64> {
421        if self.components.is_empty() {
422            return Err(LadduPhysicsError::invalid_length(
423                "t-distribution components",
424                "at least one",
425                0,
426            ));
427        }
428        if self
429            .components
430            .iter()
431            .any(|(weight, _)| !weight.is_finite() || *weight <= 0.0)
432        {
433            return Err(LadduPhysicsError::invalid_value(
434                "t-distribution mixture weights",
435                "finite and positive",
436                format!(
437                    "{:?}",
438                    self.components
439                        .iter()
440                        .map(|(weight, _)| weight)
441                        .collect::<Vec<_>>()
442                ),
443            ));
444        }
445        let sum: f64 = self.components.iter().map(|(weight, _)| weight).sum();
446        Ok(sum)
447    }
448
449    fn sample(&self, low: f64, high: f64, rng: &mut ProposalRng) -> LadduPhysicsResult<(f64, f64)> {
450        if !low.is_finite() || !high.is_finite() || high <= low {
451            return Err(LadduPhysicsError::invalid_relation(format!(
452                "physical t interval must have finite bounds with low < high, got [{low}, {high}]"
453            )));
454        }
455        let physical_low = low;
456        let physical_high = high;
457        let low = self.t_min.map_or(low, |t_min| low.max(t_min));
458        let high = self.t_max.map_or(high, |t_max| high.min(t_max));
459        if high <= low {
460            return Err(LadduPhysicsError::invalid_relation(format!(
461                "configured t limits do not overlap the physical interval [{physical_low}, {physical_high}]"
462            )));
463        }
464        let normalization = self.normalization()?;
465        let choice = rng.uniform();
466        let mut cumulative = 0.0;
467        let mut selected = self.components.len() - 1;
468        for (index, (weight, _)) in self.components.iter().enumerate() {
469            cumulative += weight / normalization;
470            if choice < cumulative {
471                selected = index;
472                break;
473            }
474        }
475        let t = self.components[selected]
476            .1
477            .sample(low, high, rng.uniform())?;
478        let mut density = 0.0;
479        for (weight, component) in &self.components {
480            density += weight / normalization * component.density(low, high, t)?;
481        }
482        if !density.is_finite() || density <= 0.0 {
483            return Err(LadduPhysicsError::invalid_value(
484                "t-proposal density",
485                "finite and positive",
486                density,
487            ));
488        }
489        Ok((t, density))
490    }
491
492    #[allow(dead_code)]
493    fn proven_density_floor(&self, maximum_width: f64, maximum_t: f64) -> LadduPhysicsResult<f64> {
494        let normalization = self.normalization()?;
495        let mut everywhere_floor = Interval::ZERO;
496        let mut selected_floor = f64::INFINITY;
497        for (weight, component) in &self.components {
498            let weighted_floor = (Interval::from(*weight / normalization)
499                * component.proven_density_floor(maximum_width, maximum_t)?)
500            .inf();
501            if matches!(component, TComponent::Histogram { .. }) {
502                selected_floor = selected_floor.min(weighted_floor);
503            } else {
504                everywhere_floor += weighted_floor;
505            }
506        }
507        if everywhere_floor.inf() > 0.0 {
508            Ok(everywhere_floor.inf())
509        } else {
510            Ok(selected_floor)
511        }
512    }
513
514    fn proven_density_floor_on_interval(
515        &self,
516        support_low: Interval,
517        support_high: Interval,
518        local_low: Interval,
519        local_high: Interval,
520    ) -> LadduPhysicsResult<f64> {
521        let normalization = self.normalization()?;
522        let mut everywhere_floor = Interval::ZERO;
523        let mut selected_floor = f64::INFINITY;
524        for (weight, component) in &self.components {
525            let component_floor = component.proven_density_floor_on_interval(
526                support_low.inf(),
527                support_high.sup(),
528                local_low.inf(),
529                local_high.sup(),
530            )?;
531            let weighted_floor = (Interval::from(*weight / normalization) * component_floor).inf();
532            if matches!(component, TComponent::Histogram { .. }) {
533                // Histogram-only mixtures are supported on the union of
534                // their positive bins. A component with no support in this
535                // local box must not erase another component's valid region.
536                if weighted_floor > 0.0 {
537                    selected_floor = selected_floor.min(weighted_floor);
538                }
539            } else {
540                everywhere_floor += weighted_floor;
541            }
542        }
543        if everywhere_floor.inf() > 0.0 {
544            Ok(everywhere_floor.inf())
545        } else if selected_floor.is_finite() {
546            Ok(selected_floor)
547        } else {
548            Ok(0.0)
549        }
550    }
551
552    fn proven_piecewise_regions(&self) -> usize {
553        self.components
554            .iter()
555            .map(|(_, component)| match component {
556                TComponent::Histogram { histogram } => histogram
557                    .counts()
558                    .iter()
559                    .filter(|count| **count > 0.0)
560                    .count(),
561                _ => 1,
562            })
563            .sum::<usize>()
564            .max(1)
565    }
566
567    fn has_pole_singularity_on(&self, support_high: Interval) -> bool {
568        self.components.iter().any(|(_, component)| {
569            matches!(component, TComponent::Pole { exchange_mass, .. }
570                if exchange_mass * exchange_mass <= support_high.sup())
571        })
572    }
573}
574
575#[derive(Clone, Debug, Serialize, Deserialize)]
576/// Two-to-two scattering proposal based on a selected incoming/outgoing
577/// momentum-transfer pairing.
578pub struct TwoBodyScattering {
579    incoming_edge: String,
580    outgoing_edge: String,
581    distribution: TDistribution,
582}
583
584impl TwoBodyScattering {
585    /// Construct a `t`-exchange proposal for the named edge pairing.
586    pub fn t_exchange(
587        pairing: (impl Into<String>, impl Into<String>),
588        distribution: TDistribution,
589    ) -> Self {
590        Self {
591            incoming_edge: pairing.0.into(),
592            outgoing_edge: pairing.1.into(),
593            distribution,
594        }
595    }
596}
597
598impl From<TwoBodyScattering> for VertexProposal {
599    fn from(proposal: TwoBodyScattering) -> Self {
600        Self::TwoBodyScattering { proposal }
601    }
602}
603
604impl TwoBodyScattering {
605    /// Propose outgoing two-body scattering kinematics.
606    ///
607    /// # Errors
608    ///
609    /// Returns [`LadduPhysicsError`] when the topology or configured edge
610    /// pairing is invalid, the event is outside physical phase space, or the
611    /// transfer distribution cannot be sampled.
612    pub fn propose(
613        &self,
614        incoming: &[NamedMomentum<'_>],
615        outgoing: &[NamedMass<'_>],
616        rng: &mut ProposalRng,
617    ) -> LadduPhysicsResult<ProposalResult> {
618        if incoming.len() != 2 || outgoing.len() != 2 {
619            return Err(LadduPhysicsError::invalid_relation(format!(
620                "two-body scattering requires two incoming and two outgoing edges, got {} incoming and {} outgoing",
621                incoming.len(),
622                outgoing.len()
623            )));
624        }
625        let paired_in = incoming
626            .iter()
627            .position(|edge| edge.name == self.incoming_edge)
628            .ok_or_else(|| {
629                LadduPhysicsError::invalid_relation(format!(
630                    "unknown incoming t-pairing edge `{}`",
631                    self.incoming_edge
632                ))
633            })?;
634        let paired_out = outgoing
635            .iter()
636            .position(|edge| edge.name == self.outgoing_edge)
637            .ok_or_else(|| {
638                LadduPhysicsError::invalid_relation(format!(
639                    "unknown outgoing t-pairing edge `{}`",
640                    self.outgoing_edge
641                ))
642            })?;
643        let total = incoming[0].p4 + incoming[1].p4;
644        let root_s = total.m()?;
645        let beta = total.beta()?;
646        let incoming_com = incoming[paired_in].p4.boost(&(-beta));
647        // Invariant masses are best evaluated before the boost. In particular,
648        // boosting a massless four-vector can leave a tiny negative m^2 from
649        // floating-point cancellation.
650        let m1 = incoming[paired_in].p4.m()?;
651        let m2 = incoming[1 - paired_in].p4.m()?;
652        let m3 = outgoing[paired_out].mass;
653        let m4 = outgoing[1 - paired_out].mass;
654        let p_in = two_body_momentum(root_s, m1, m2)?;
655        let p_out = two_body_momentum(root_s, m3, m4)?;
656        if p_in <= 0.0 {
657            return Err(LadduPhysicsError::invalid_relation(
658                "t exchange is undefined at the incoming threshold",
659            ));
660        }
661        let e1 = (m1 * m1 + p_in * p_in).sqrt();
662        let e3 = (m3 * m3 + p_out * p_out).sqrt();
663        let center = m1 * m1 + m3 * m3 - 2.0 * e1 * e3;
664        let span = 2.0 * p_in * p_out;
665        let (t, q_t) = self
666            .distribution
667            .sample(center - span, center + span, rng)?;
668        let cos_theta = ((t - center) / span).clamp(-1.0, 1.0);
669        let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
670        let phi = 2.0 * PI * rng.uniform();
671        let z = incoming_com.vec3().unit()?;
672        let seed = if z.z.abs() < 0.9 {
673            RealVec3::new(0.0, 0.0, 1.0)
674        } else {
675            RealVec3::new(1.0, 0.0, 0.0)
676        };
677        let x = seed.cross(&z).unit()?;
678        let y = z.cross(&x);
679        let direction = z * cos_theta + x * (sin_theta * phi.cos()) + y * (sin_theta * phi.sin());
680        let paired = on_shell(direction, p_out, m3).boost(&beta);
681        let other = on_shell(-direction, p_out, m4).boost(&beta);
682        let mut result = vec![RealVec4::new(0.0, 0.0, 0.0, 0.0); 2];
683        result[paired_out] = paired;
684        result[1 - paired_out] = other;
685        Ok(ProposalResult {
686            outgoing: result,
687            weight: 1.0 / (16.0 * PI * root_s * p_in * q_t),
688        })
689    }
690
691    /// Enclose the proposal correction for every two-body scattering point in
692    /// the supplied mass box.
693    #[doc(hidden)]
694    pub fn proven_weight_bound(
695        &self,
696        root_s: Interval,
697        incoming: [(&str, Interval); 2],
698        outgoing: [(&str, Interval); 2],
699    ) -> LadduPhysicsResult<Interval> {
700        self.proven_weight_bound_for_transfer(
701            root_s,
702            incoming,
703            outgoing,
704            Interval::new(0.0, 1.0),
705        )
706    }
707
708    /// Enclose the proposal correction over a normalized transfer subdomain.
709    ///
710    /// `transfer` is expressed as a fraction of the configured physical
711    /// transfer support, so `[0, 1]` reproduces the global bound. Restricting
712    /// this fraction lets a branch-and-bound caller refine the transfer
713    /// variable while retaining the exact angular enclosure.
714    #[doc(hidden)]
715    pub fn proven_weight_bound_for_transfer(
716        &self,
717        root_s: Interval,
718        incoming: [(&str, Interval); 2],
719        outgoing: [(&str, Interval); 2],
720        transfer: Interval,
721    ) -> LadduPhysicsResult<Interval> {
722        let paired_in = incoming
723            .iter()
724            .position(|(name, _)| *name == self.incoming_edge)
725            .ok_or_else(|| {
726                LadduPhysicsError::invalid_relation(format!(
727                    "unknown incoming t-pairing edge `{}`",
728                    self.incoming_edge
729                ))
730            })?;
731        let paired_out = outgoing
732            .iter()
733            .position(|(name, _)| *name == self.outgoing_edge)
734            .ok_or_else(|| {
735                LadduPhysicsError::invalid_relation(format!(
736                    "unknown outgoing t-pairing edge `{}`",
737                    self.outgoing_edge
738                ))
739            })?;
740        let incoming_masses = [incoming[0].1, incoming[1].1];
741        let outgoing_masses = [outgoing[0].1, outgoing[1].1];
742        let p_in = proven_two_body_momentum(root_s, incoming_masses[0], incoming_masses[1]);
743        let p_out = proven_two_body_momentum(root_s, outgoing_masses[0], outgoing_masses[1]);
744        let m1 = incoming_masses[paired_in];
745        let m3 = outgoing_masses[paired_out];
746        let e1 = (m1.sqr() + p_in.sqr()).sqrt();
747        let e3 = (m3.sqr() + p_out.sqr()).sqrt();
748        let center = m1.sqr() + m3.sqr() - 2.0 * e1 * e3;
749        let span = 2.0 * p_in * p_out;
750        let physical_low = center - span;
751        let physical_high = center + span;
752        let support_low = self
753            .distribution
754            .t_min
755            .map_or(physical_low, |t_min| physical_low.max(t_min.into()));
756        let support_high = self
757            .distribution
758            .t_max
759            .map_or(physical_high, |t_max| physical_high.min(t_max.into()));
760        let support_width = support_high - support_low;
761        if support_width.is_empty() || support_width.sup() <= 0.0 {
762            return Ok(Interval::EMPTY);
763        }
764        let transfer_t = support_low + transfer * support_width;
765        let density_floor = self
766            .distribution
767            .proven_density_floor_on_interval(
768                support_low,
769                support_high,
770                transfer_t,
771                transfer_t,
772            )?;
773        if !density_floor.is_finite() || density_floor <= 0.0 {
774            if !self.distribution.has_pole_singularity_on(support_high) {
775                // A histogram subdomain can lie entirely in a zero-density
776                // gap. Such a box contributes no valid proposals and is
777                // safely discarded by the branch-and-bound evaluator.
778                return Ok(Interval::EMPTY);
779            }
780            return Err(LadduPhysicsError::invalid_relation(
781                "momentum-transfer proposal has no finite positive local density floor",
782            ));
783        }
784        let result = Interval::ONE / (16.0 * PI * root_s * p_in * density_floor);
785        Ok(Interval::new(0.0, result.sup()))
786    }
787
788    #[doc(hidden)]
789    pub fn proven_domain_metadata(&self) -> (usize, usize) {
790        (2, self.distribution.proven_piecewise_regions())
791    }
792}
793
794fn proven_two_body_momentum(parent: Interval, first: Interval, second: Interval) -> Interval {
795    let parent_squared = parent.sqr();
796    let radicand =
797        (parent_squared - (first + second).sqr()) * (parent_squared - (first - second).sqr());
798    radicand.sqrt() / (2.0 * parent)
799}
800
801#[cfg(test)]
802mod tests {
803    use std::sync::Arc;
804
805    use super::*;
806    use crate::generation::{AdaptiveTwoBodyDecay, MassProposal, ScalarSource};
807
808    #[test]
809    fn proposal_rng_sequence_is_stable() {
810        let mut rng = ProposalRng::new(7);
811        assert_eq!(
812            (0..5).map(|_| rng.next_u64()).collect::<Vec<_>>(),
813            [
814                7_191_089_600_892_374_487,
815                309_689_372_594_955_804,
816                16_616_101_746_815_609_346,
817                10_753_165_928_301_472_203,
818                8_346_079_845_500_723_674,
819            ]
820        );
821    }
822
823    #[test]
824    fn isotropic_decay_conserves_momentum_and_mass() {
825        let proposal = VertexProposal::isotropic_decay();
826        let incoming = [NamedMomentum {
827            name: "x",
828            p4: RealVec4::new(2.0, 0.3, -0.2, 1.0),
829        }];
830        let outgoing = [
831            NamedMass {
832                name: "a",
833                mass: 0.2,
834            },
835            NamedMass {
836                name: "b",
837                mass: 0.4,
838            },
839        ];
840        let result = proposal
841            .propose(&incoming, &outgoing, &mut ProposalRng::new(7))
842            .unwrap();
843        let sum = result.outgoing[0] + result.outgoing[1];
844        for (a, b) in [sum.e, sum.px, sum.py, sum.pz]
845            .into_iter()
846            .zip([2.0, 0.3, -0.2, 1.0])
847        {
848            assert!((a - b).abs() < 1e-12);
849        }
850        assert!((result.outgoing[0].m().unwrap() - 0.2).abs() < 1e-12);
851        assert!((result.outgoing[1].m().unwrap() - 0.4).abs() < 1e-12);
852        assert!(result.weight > 0.0);
853    }
854
855    #[test]
856    fn t_mixture_samples_inside_physical_range() {
857        let distribution = TDistribution::mixture([
858            (1.0, TComponent::Uniform),
859            (2.0, TComponent::Exponential { slope: 3.0 }),
860            (
861                1.0,
862                TComponent::Pole {
863                    exchange_mass: 1.0,
864                    power: 2.0,
865                },
866            ),
867        ]);
868        let mut rng = ProposalRng::new(11);
869        for _ in 0..100 {
870            let (t, density) = distribution.sample(-2.0, -0.1, &mut rng).unwrap();
871            assert!((-2.0..=-0.1).contains(&t));
872            assert!(density.is_finite() && density > 0.0);
873        }
874    }
875
876    #[test]
877    fn t_distribution_limits_truncate_the_physical_interval() {
878        let distribution = TDistribution::uniform()
879            .with_limits(Some(-1.25), Some(-0.5))
880            .unwrap();
881        let mut rng = ProposalRng::new(13);
882        for _ in 0..100 {
883            let (t, density) = distribution.sample(-2.0, -0.1, &mut rng).unwrap();
884            assert!((-1.25..=-0.5).contains(&t));
885            assert!((density - 1.0 / 0.75).abs() < 1e-12);
886        }
887        assert!(
888            TDistribution::uniform()
889                .with_limits(Some(-0.5), Some(-1.0))
890                .is_err()
891        );
892        assert!(
893            distribution
894                .sample(-3.0, -2.0, &mut ProposalRng::new(17))
895                .is_err()
896        );
897    }
898
899    #[test]
900    fn t_exchange_conserves_momentum_and_is_on_shell() {
901        let proposal =
902            TwoBodyScattering::t_exchange(("beam", "x"), TDistribution::exponential(2.0));
903        let incoming = [
904            NamedMomentum {
905                name: "beam",
906                p4: RealVec4::new(1.5, 0.0, 0.0, 1.0),
907            },
908            NamedMomentum {
909                name: "target",
910                p4: RealVec4::new(1.5, 0.0, 0.0, -1.0),
911            },
912        ];
913        let outgoing = [
914            NamedMass {
915                name: "x",
916                mass: 0.5,
917            },
918            NamedMass {
919                name: "r",
920                mass: 0.7,
921            },
922        ];
923        let result = proposal
924            .propose(&incoming, &outgoing, &mut ProposalRng::new(19))
925            .unwrap();
926        let before = incoming[0].p4 + incoming[1].p4;
927        let after = result.outgoing[0] + result.outgoing[1];
928        assert!((before.e - after.e).abs() < 1e-12);
929        assert!((before.px - after.px).abs() < 1e-12);
930        assert!((before.py - after.py).abs() < 1e-12);
931        assert!((before.pz - after.pz).abs() < 1e-12);
932        assert!((result.outgoing[0].m().unwrap() - 0.5).abs() < 1e-12);
933        assert!((result.outgoing[1].m().unwrap() - 0.7).abs() < 1e-12);
934    }
935
936    #[test]
937    fn proven_scattering_bounds_cover_every_builtin_transfer_family() {
938        let histogram =
939            Histogram::new(vec![1.0, 0.0, 3.0, 2.0], vec![-8.0, -4.0, -2.0, -0.5, 0.0]).unwrap();
940        let distributions = [
941            TDistribution::uniform(),
942            TDistribution::exponential(3.0),
943            TDistribution::pole(1.0, 2.0),
944            TDistribution::histogram(histogram.clone()),
945            TDistribution::mixture([
946                (0.2, TComponent::Uniform),
947                (0.3, TComponent::Exponential { slope: 3.0 }),
948                (
949                    0.2,
950                    TComponent::Pole {
951                        exchange_mass: 1.0,
952                        power: 2.0,
953                    },
954                ),
955                (0.3, TComponent::Histogram { histogram }),
956            ]),
957        ];
958        let incoming = [
959            NamedMomentum {
960                name: "beam",
961                p4: RealVec4::new(1.5, 0.0, 0.0, 1.5),
962            },
963            NamedMomentum {
964                name: "target",
965                p4: RealVec4::new(1.5, 0.0, 0.0, -1.5),
966            },
967        ];
968        let outgoing = [
969            NamedMass {
970                name: "x",
971                mass: 0.5,
972            },
973            NamedMass {
974                name: "r",
975                mass: 0.7,
976            },
977        ];
978        for (index, distribution) in distributions.into_iter().enumerate() {
979            let proposal = TwoBodyScattering::t_exchange(("beam", "x"), distribution);
980            let bound = proposal
981                .proven_weight_bound(
982                    Interval::from(3.0),
983                    [
984                        ("beam", Interval::from(0.0)),
985                        ("target", Interval::from(0.0)),
986                    ],
987                    [("x", Interval::from(0.5)), ("r", Interval::from(0.7))],
988                )
989                .unwrap();
990            let mut rng = ProposalRng::new(100 + index as u64);
991            for _ in 0..2_000 {
992                let sampled = proposal.propose(&incoming, &outgoing, &mut rng).unwrap();
993                assert!(
994                    bound.contains(sampled.weight),
995                    "{bound} missed {}",
996                    sampled.weight
997                );
998            }
999        }
1000    }
1001
1002    #[test]
1003    fn local_exponential_transfer_bound_tightens_on_high_t_branch() {
1004        let proposal = TwoBodyScattering::t_exchange(
1005            ("beam", "x"),
1006            TDistribution::exponential(3.0),
1007        );
1008        let incoming = [("beam", Interval::from(0.0)), ("target", Interval::from(0.0))];
1009        let outgoing = [("x", Interval::from(0.5)), ("r", Interval::from(0.7))];
1010        let root_s = Interval::from(3.0);
1011        let global = proposal
1012            .proven_weight_bound_for_transfer(root_s, incoming, outgoing, Interval::new(0.0, 1.0))
1013            .unwrap();
1014        let high_t = proposal
1015            .proven_weight_bound_for_transfer(root_s, incoming, outgoing, Interval::new(0.5, 1.0))
1016            .unwrap();
1017        assert!(high_t.sup() < global.sup(), "{high_t} was not tighter than {global}");
1018    }
1019
1020    #[test]
1021    fn local_histogram_density_distinguishes_gaps_and_positive_bins() {
1022        let histogram = Histogram::new(
1023            vec![1.0, 0.0, 3.0, 2.0],
1024            vec![-8.0, -4.0, -2.0, -0.5, 0.0],
1025        )
1026        .unwrap();
1027        let component = TComponent::Histogram {
1028            histogram: histogram.clone(),
1029        };
1030        let gap = component
1031            .proven_density_floor_on_interval(-8.0, 0.0, -3.9, -2.1)
1032            .unwrap();
1033        let positive = component
1034            .proven_density_floor_on_interval(-8.0, 0.0, -1.9, -0.6)
1035            .unwrap();
1036        assert_eq!(gap, 0.0);
1037        assert!(positive.is_finite() && positive > 0.0);
1038
1039        let mixture = TDistribution::mixture([
1040            (0.5, TComponent::Uniform),
1041            (0.5, TComponent::Histogram { histogram }),
1042        ]);
1043        let mixture_gap = mixture
1044            .proven_density_floor_on_interval(
1045                Interval::from(-8.0),
1046                Interval::from(0.0),
1047                Interval::from(-3.9),
1048                Interval::from(-2.1),
1049            )
1050            .unwrap();
1051        assert!(mixture_gap.is_finite() && mixture_gap > 0.0);
1052
1053        let left = Histogram::new(vec![1.0, 0.0], vec![-4.0, -2.0, 0.0]).unwrap();
1054        let right = Histogram::new(vec![0.0, 1.0], vec![-4.0, -2.0, 0.0]).unwrap();
1055        let complementary = TDistribution::mixture([
1056            (0.5, TComponent::Histogram { histogram: left }),
1057            (0.5, TComponent::Histogram { histogram: right }),
1058        ]);
1059        let left_only = complementary
1060            .proven_density_floor_on_interval(
1061                Interval::from(-4.0),
1062                Interval::from(0.0),
1063                Interval::from(-3.9),
1064                Interval::from(-2.1),
1065            )
1066            .unwrap();
1067        assert!(left_only.is_finite() && left_only > 0.0);
1068    }
1069
1070    #[test]
1071    fn proven_massless_pole_rejects_a_domain_touching_the_singularity() {
1072        let proposal = TwoBodyScattering::t_exchange(("beam", "x"), TDistribution::pole(0.0, 1.0));
1073        assert!(
1074            proposal
1075                .proven_weight_bound(
1076                    Interval::from(3.0),
1077                    [
1078                        ("beam", Interval::from(0.0)),
1079                        ("target", Interval::from(0.0)),
1080                    ],
1081                    [("x", Interval::from(0.0)), ("r", Interval::from(0.0)),],
1082                )
1083                .is_err()
1084        );
1085    }
1086
1087    #[test]
1088    fn adaptive_decay_preserves_the_phase_space_integral() {
1089        let incoming = [NamedMomentum {
1090            name: "parent",
1091            p4: RealVec4::new(2.0, 0.0, 0.0, 0.0),
1092        }];
1093        let outgoing = [
1094            NamedMass {
1095                name: "a",
1096                mass: 0.2,
1097            },
1098            NamedMass {
1099                name: "b",
1100                mass: 0.4,
1101            },
1102        ];
1103        let adaptive =
1104            AdaptiveTwoBodyDecay::new(Arc::from([1.0, 2.0, 8.0, 20.0, 8.0, 2.0, 1.0]), 0.2)
1105                .unwrap();
1106        let baseline = VertexProposal::isotropic_decay()
1107            .propose(&incoming, &outgoing, &mut ProposalRng::new(1))
1108            .unwrap()
1109            .weight;
1110        let mut rng = ProposalRng::new(2);
1111        let samples = 100_000;
1112        let mean = (0..samples)
1113            .map(|_| {
1114                adaptive
1115                    .propose(&incoming, &outgoing, &mut rng)
1116                    .unwrap()
1117                    .weight
1118            })
1119            .sum::<f64>()
1120            / samples as f64;
1121        assert!((mean / baseline - 1.0).abs() < 0.01);
1122    }
1123
1124    #[test]
1125    fn proposal_failures_use_structured_physics_errors() {
1126        let empty = TDistribution::mixture([]);
1127        assert!(matches!(
1128            empty.normalization(),
1129            Err(LadduPhysicsError::InvalidLength { .. })
1130        ));
1131
1132        assert!(matches!(
1133            MassProposal::fixed(2.0).propose(0.0, 1.0, &mut ProposalRng::new(0)),
1134            Err(LadduPhysicsError::InvalidValue { .. })
1135        ));
1136
1137        assert!(matches!(
1138            VertexProposal::isotropic_decay().propose(&[], &[], &mut ProposalRng::new(0)),
1139            Err(LadduPhysicsError::InvalidRelation { .. })
1140        ));
1141    }
1142
1143    #[test]
1144    fn histogram_t_component_truncates_to_the_physical_interval() {
1145        let histogram = Histogram::new(vec![1.0, 3.0], vec![-2.0, -1.0, 0.0]).unwrap();
1146        let distribution = TDistribution::histogram(histogram);
1147        let mut rng = ProposalRng::new(31);
1148        for _ in 0..100 {
1149            let (t, density) = distribution.sample(-1.5, -0.5, &mut rng).unwrap();
1150            assert!((-1.5..=-0.5).contains(&t));
1151            assert!(density.is_finite() && density > 0.0);
1152        }
1153    }
1154
1155    #[test]
1156    fn scalar_sources_return_values_and_proposal_corrections() {
1157        let mut rng = ProposalRng::new(37);
1158        let constant = ScalarSource::constant(3.0).sample(&mut rng).unwrap();
1159        assert_eq!(constant.value, 3.0);
1160        assert_eq!(constant.weight, 1.0);
1161
1162        let uniform = ScalarSource::uniform(-2.0, 4.0).sample(&mut rng).unwrap();
1163        assert!((-2.0..4.0).contains(&uniform.value));
1164        assert_eq!(uniform.weight, 6.0);
1165
1166        let histogram = Histogram::new(vec![1.0, 2.0], vec![0.0, 1.0, 3.0]).unwrap();
1167        let sampled = ScalarSource::histogram(histogram).sample(&mut rng).unwrap();
1168        assert!((0.0..3.0).contains(&sampled.value));
1169        assert!(sampled.weight.is_finite() && sampled.weight > 0.0);
1170    }
1171
1172    #[test]
1173    fn uniform_mass_truncates_to_the_allowed_interval() {
1174        let proposal = MassProposal::uniform(1.0, 2.0);
1175        let mut rng = ProposalRng::new(41);
1176        for _ in 0..100 {
1177            let result = proposal.propose(1.25, 1.75, &mut rng).unwrap();
1178            assert!((1.25..1.75).contains(&result.mass));
1179            assert_eq!(result.weight, 0.5);
1180        }
1181    }
1182
1183    #[test]
1184    fn continuous_proposals_return_reciprocal_density_weights() {
1185        let mass = MassProposal::uniform(-1.0, 5.0);
1186        let mut rng = ProposalRng::new(43);
1187        for _ in 0..100 {
1188            let sampled = mass.propose(1.0, 3.0, &mut rng).unwrap();
1189            let density = mass.density(1.0, 3.0, sampled.mass).unwrap().unwrap();
1190            assert!((sampled.weight * density - 1.0).abs() < 1e-12);
1191        }
1192
1193        let histogram = Histogram::new(vec![1.0, 3.0], vec![0.0, 1.0, 3.0]).unwrap();
1194        let source = ScalarSource::histogram(histogram.clone());
1195        for _ in 0..100 {
1196            let sampled = source.sample(&mut rng).unwrap();
1197            let density = PiecewiseDensity::from_histogram(&histogram)
1198                .unwrap()
1199                .density(0.0, 3.0, sampled.value);
1200            assert!((sampled.weight * density - 1.0).abs() < 1e-12);
1201        }
1202    }
1203}