1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct ProposalRng {
16 state: u64,
17}
18
19impl ProposalRng {
20 pub fn new(seed: u64) -> Self {
22 Self { state: seed }
23 }
24
25 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 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)]
49pub struct NamedMomentum<'a> {
51 pub name: &'a str,
53 pub p4: RealVec4,
55}
56
57#[derive(Clone, Copy, Debug)]
58pub struct NamedMass<'a> {
60 pub name: &'a str,
62 pub mass: f64,
64}
65
66#[derive(Clone, Debug)]
67pub struct ProposalResult {
69 pub outgoing: Vec<RealVec4>,
71 pub weight: f64,
73}
74
75#[derive(Clone, Copy, Debug)]
76pub struct MassProposalResult {
78 pub mass: f64,
80 pub weight: f64,
82}
83
84#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum MassProposal {
88 Fixed {
90 mass: f64,
92 },
93 Uniform {
95 low: f64,
97 high: f64,
99 },
100}
101
102impl MassProposal {
103 pub fn fixed(mass: f64) -> Self {
105 Self::Fixed { mass }
106 }
107
108 pub fn uniform(low: f64, high: f64) -> Self {
110 Self::Uniform { low, high }
111 }
112
113 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 pub fn density(
157 &self,
158 minimum: f64,
159 maximum: f64,
160 mass: f64,
161 ) -> LadduPhysicsResult<Option<f64>> {
162 match *self {
163 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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
212#[serde(tag = "kind", rename_all = "snake_case")]
213pub enum VertexProposal {
214 #[default]
216 TwoBodyDecay,
217 TwoBodyScattering {
219 proposal: TwoBodyScattering,
221 },
222}
223
224impl VertexProposal {
225 pub fn isotropic_decay() -> Self {
227 Self::TwoBodyDecay
228 }
229
230 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 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#[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 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 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)]
385pub enum TComponent {
387 Uniform,
389 Exponential {
391 slope: f64,
393 },
394 Pole {
397 exchange_mass: f64,
399 power: f64,
401 },
402 Histogram {
404 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)]
559pub enum ScalarSource {
561 Constant(f64),
563 Uniform {
565 low: f64,
567 high: f64,
569 },
570 Histogram(Histogram),
572}
573
574#[derive(Clone, Copy, Debug)]
575pub struct ScalarProposalResult {
577 pub value: f64,
579 pub weight: f64,
581}
582
583impl ScalarSource {
584 pub fn constant(value: f64) -> Self {
586 Self::Constant(value)
587 }
588
589 pub fn uniform(low: f64, high: f64) -> Self {
591 Self::Uniform { low, high }
592 }
593
594 pub fn histogram(histogram: Histogram) -> Self {
596 Self::Histogram(histogram)
597 }
598
599 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 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#[derive(Clone, Debug, Serialize, Deserialize)]
704pub enum InitialMomentum {
705 P4(RealVec4),
707 Momentum(RealVec3),
709 EnergyDirection {
711 energy: ScalarSource,
713 direction: RealVec3,
715 },
716}
717
718#[derive(Clone, Copy, Debug)]
720pub struct InitialMomentumResult {
721 pub p4: RealVec4,
723 pub weight: f64,
725}
726
727impl InitialMomentum {
728 pub fn p4(p4: RealVec4) -> Self {
730 Self::P4(p4)
731 }
732
733 pub fn momentum(momentum: RealVec3) -> Self {
735 Self::Momentum(momentum)
736 }
737
738 pub fn energy_direction(energy: f64, direction: RealVec3) -> Self {
740 Self::EnergyDirection {
741 energy: ScalarSource::constant(energy),
742 direction,
743 }
744 }
745
746 pub fn energy_source_direction(energy: ScalarSource, direction: RealVec3) -> Self {
748 Self::EnergyDirection { energy, direction }
749 }
750
751 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 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 #[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)]
884pub 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 pub fn uniform() -> Self {
896 Self::mixture([(1.0, TComponent::Uniform)])
897 }
898
899 pub fn exponential(slope: f64) -> Self {
901 Self::mixture([(1.0, TComponent::Exponential { slope })])
902 }
903
904 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 pub fn histogram(histogram: Histogram) -> Self {
917 Self::mixture([(1.0, TComponent::Histogram { histogram })])
918 }
919
920 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 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)]
1046pub struct TwoBodyScattering {
1049 incoming_edge: String,
1050 outgoing_edge: String,
1051 distribution: TDistribution,
1052}
1053
1054impl TwoBodyScattering {
1055 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 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 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}