1#[derive(Clone, Debug, Serialize, Deserialize)]
4pub enum TComponent {
6 Uniform,
8 Exponential {
10 slope: f64,
12 },
13 Pole {
16 exchange_mass: f64,
18 power: f64,
20 },
21 Histogram {
23 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 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)]
332pub 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 pub fn uniform() -> Self {
344 Self::mixture([(1.0, TComponent::Uniform)])
345 }
346
347 pub fn exponential(slope: f64) -> Self {
349 Self::mixture([(1.0, TComponent::Exponential { slope })])
350 }
351
352 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 pub fn histogram(histogram: Histogram) -> Self {
365 Self::mixture([(1.0, TComponent::Histogram { histogram })])
366 }
367
368 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 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 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)]
576pub struct TwoBodyScattering {
579 incoming_edge: String,
580 outgoing_edge: String,
581 distribution: TDistribution,
582}
583
584impl TwoBodyScattering {
585 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 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 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 #[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 #[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 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}