Skip to main content

laddu_generation/
topology.rs

1use std::{
2    collections::{HashMap, HashSet},
3    sync::Arc,
4};
5
6use fastrand::Rng;
7use laddu_core::{
8    math::{q_m, Histogram, Sheet},
9    Dataset, DatasetMetadata, Expression, LadduError, LadduResult, Particle, Reaction, Vec3, Vec4,
10    PI,
11};
12use serde::{Deserialize, Serialize};
13
14use crate::distributions::{
15    Distribution, HistogramSampler, LadduGenRngExt, MandelstamTDistribution, SimpleDistribution,
16};
17
18/// Selects which generated particle four-momenta are written into generated datasets.
19///
20/// The generated reaction layout always retains the full generated graph. This policy only controls
21/// which generated particle IDs become p4 columns in generated [`Dataset`] values and which
22/// particles have a p4 label in [`GeneratedEventLayout`].
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum GeneratedStorage {
25    /// Store every generated particle p4.
26    All,
27    /// Store only the listed generated particle IDs, preserving reaction p4-label order.
28    Only(Vec<String>),
29}
30
31impl GeneratedStorage {
32    /// Store every generated particle p4.
33    pub fn all() -> Self {
34        Self::All
35    }
36
37    /// Store only the listed generated particle IDs.
38    pub fn only<I, S>(ids: I) -> Self
39    where
40        I: IntoIterator<Item = S>,
41        S: Into<String>,
42    {
43        Self::Only(ids.into_iter().map(Into::into).collect())
44    }
45
46    /// Return true if `id` is selected for dataset storage.
47    pub fn stores(&self, id: &str) -> bool {
48        match self {
49            Self::All => true,
50            Self::Only(ids) => ids.iter().any(|stored_id| stored_id == id),
51        }
52    }
53
54    fn validate(&self, available_ids: &[String]) -> LadduResult<()> {
55        let available = available_ids
56            .iter()
57            .map(String::as_str)
58            .collect::<HashSet<_>>();
59        let Self::Only(ids) = self else {
60            return Ok(());
61        };
62        let mut seen = HashSet::new();
63        for id in ids {
64            if !seen.insert(id.as_str()) {
65                return Err(LadduError::Custom(format!(
66                    "generated storage contains duplicate particle ID '{id}'"
67                )));
68            }
69            if !available.contains(id.as_str()) {
70                return Err(LadduError::Custom(format!(
71                    "generated storage references unknown particle ID '{id}'"
72                )));
73            }
74        }
75        Ok(())
76    }
77
78    fn stored_labels(&self, all_labels: &[String]) -> Vec<String> {
79        all_labels
80            .iter()
81            .filter(|label| self.stores(label))
82            .cloned()
83            .collect()
84    }
85}
86
87/// Experiment-neutral metadata describing a generated particle species.
88///
89/// Species metadata is intentionally separate from generated particle IDs and reconstructed
90/// reaction particles. It is meant for generator/export layers that need an external particle code
91/// or label without forcing laddu to adopt an experiment-specific particle table.
92#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
93pub enum ParticleSpecies {
94    /// A numeric species code with an optional namespace.
95    Code {
96        /// Numeric species identifier.
97        id: i64,
98        /// Optional namespace, such as `"pdg"`.
99        namespace: Option<String>,
100    },
101    /// A free-form species label.
102    Label(String),
103}
104
105impl ParticleSpecies {
106    /// Construct a species from a numeric code with no namespace.
107    pub fn code(id: i64) -> Self {
108        Self::Code {
109            id,
110            namespace: None,
111        }
112    }
113
114    /// Construct a species from a numeric code in an explicit namespace.
115    pub fn with_namespace(namespace: impl Into<String>, id: i64) -> Self {
116        Self::Code {
117            id,
118            namespace: Some(namespace.into()),
119        }
120    }
121
122    /// Construct a species from a free-form label.
123    pub fn label(label: impl Into<String>) -> Self {
124        Self::Label(label.into())
125    }
126}
127
128fn basis(z: Vec3) -> (Vec3, Vec3, Vec3) {
129    let z = z.unit();
130    let ref_axis = if z.z.abs() < 0.9 {
131        Vec3::z()
132    } else {
133        Vec3::y()
134    };
135    let x = ref_axis.cross(&z).unit();
136    let y = z.cross(&x);
137    (x, y, z)
138}
139
140/// Generator settings for an initial-state particle.
141#[derive(Clone, Debug)]
142pub struct InitialGenerator {
143    mass: f64,
144    energy_distribution: SimpleDistribution,
145}
146
147impl InitialGenerator {
148    /// Construct a beam with fixed energy.
149    pub fn beam_with_fixed_energy(mass: f64, energy: f64) -> Self {
150        debug_assert!(mass >= 0.0, "Mass cannot be negative!\nMass: {}", mass);
151        debug_assert!(energy > 0.0, "Energy must be positive!\nEnergy: {}", energy);
152        Self {
153            mass,
154            energy_distribution: SimpleDistribution::Fixed(energy),
155        }
156    }
157
158    /// Construct a beam with uniformly sampled energy.
159    pub fn beam(mass: f64, min_energy: f64, max_energy: f64) -> Self {
160        debug_assert!(mass >= 0.0, "Mass cannot be negative!\nMass: {}", mass);
161        debug_assert!(
162            min_energy > 0.0,
163            "Minimum energy must be positive!\nMinimum Energy: {}",
164            min_energy
165        );
166        debug_assert!(
167            max_energy > min_energy,
168            "Maximum energy must be greater than minimum energy!"
169        );
170        Self {
171            mass,
172            energy_distribution: SimpleDistribution::Uniform {
173                min: min_energy,
174                max: max_energy,
175            },
176        }
177    }
178
179    /// Construct a beam with histogram-sampled energy.
180    pub fn beam_with_energy_histogram(mass: f64, energy: Histogram) -> LadduResult<Self> {
181        debug_assert!(
182            mass >= 0.0,
183            "Mass must be positive and greater than zero!\nMass: {}",
184            mass
185        );
186        let sampler = HistogramSampler::new(energy)?;
187        debug_assert!(
188            sampler.hist.bin_edges()[0] >= mass,
189            "Mass cannot be greater than the minimum allowed energy!\nMass: {}\nMinimum Energy: {}",
190            mass,
191            sampler.hist.bin_edges()[0]
192        );
193        Ok(Self {
194            mass,
195            energy_distribution: SimpleDistribution::Histogram(sampler),
196        })
197    }
198
199    /// Construct a target at rest.
200    pub fn target(mass: f64) -> Self {
201        Self {
202            mass,
203            energy_distribution: SimpleDistribution::Fixed(mass),
204        }
205    }
206}
207
208/// Generator settings for a generated composite particle.
209#[derive(Clone, Debug)]
210pub struct CompositeGenerator {
211    mass_distribution: SimpleDistribution,
212}
213
214impl CompositeGenerator {
215    /// Construct a composite mass generator with a uniform mass range.
216    pub fn new(min_mass: f64, max_mass: f64) -> Self {
217        Self {
218            mass_distribution: SimpleDistribution::Uniform {
219                min: min_mass,
220                max: max_mass,
221            },
222        }
223    }
224
225    fn sample_mass(&self, rng: &mut Rng) -> f64 {
226        self.mass_distribution.sample(rng)
227    }
228}
229
230/// Generator settings for a stable generated particle.
231#[derive(Clone, Debug)]
232pub struct StableGenerator {
233    mass_distribution: SimpleDistribution,
234}
235
236impl StableGenerator {
237    /// Construct a fixed-mass stable-particle generator.
238    pub fn new(mass: f64) -> Self {
239        debug_assert!(mass >= 0.0, "Mass cannot be negative!\nMass: {}", mass);
240        Self {
241            mass_distribution: SimpleDistribution::Fixed(mass),
242        }
243    }
244
245    fn sample_mass(&self, rng: &mut Rng) -> f64 {
246        self.mass_distribution.sample(rng)
247    }
248}
249
250/// Reconstruction interpretation for a generated particle.
251#[derive(Clone, Debug, PartialEq)]
252pub enum Reconstruction {
253    /// The particle p4 is stored in the analysis dataset under the generated particle ID.
254    Stored,
255    /// The particle p4 is fixed in the reconstructed reaction.
256    Fixed(Vec4),
257    /// The particle p4 is inferred from reaction-level constraints.
258    Missing,
259    /// The particle p4 is reconstructed as a composite of its two generated daughters.
260    Composite,
261}
262
263/// A generated particle with generation and reconstruction metadata.
264#[derive(Clone, Debug)]
265pub enum GeneratedParticle {
266    /// An initial-state generated particle.
267    Initial {
268        id: String,
269        generator: InitialGenerator,
270        reconstruction: Reconstruction,
271        species: Option<ParticleSpecies>,
272    },
273    /// A stable generated particle.
274    Stable {
275        id: String,
276        generator: StableGenerator,
277        reconstruction: Reconstruction,
278        species: Option<ParticleSpecies>,
279    },
280    /// A generated composite particle with exactly two generated daughters.
281    Composite {
282        id: String,
283        generator: CompositeGenerator,
284        daughters: (Box<GeneratedParticle>, Box<GeneratedParticle>),
285        reconstruction: Reconstruction,
286        species: Option<ParticleSpecies>,
287    },
288}
289
290impl GeneratedParticle {
291    /// Construct a generated initial-state particle.
292    pub fn initial(
293        id: impl Into<String>,
294        generator: InitialGenerator,
295        reconstruction: Reconstruction,
296    ) -> Self {
297        Self::Initial {
298            id: id.into(),
299            generator,
300            reconstruction,
301            species: None,
302        }
303    }
304
305    /// Construct a generated stable particle.
306    pub fn stable(
307        id: impl Into<String>,
308        generator: StableGenerator,
309        reconstruction: Reconstruction,
310    ) -> Self {
311        Self::Stable {
312            id: id.into(),
313            generator,
314            reconstruction,
315            species: None,
316        }
317    }
318
319    /// Construct a generated composite particle from exactly two ordered daughters.
320    pub fn composite(
321        id: impl Into<String>,
322        generator: CompositeGenerator,
323        daughters: (&GeneratedParticle, &GeneratedParticle),
324        reconstruction: Reconstruction,
325    ) -> Self {
326        Self::Composite {
327            id: id.into(),
328            generator,
329            daughters: (Box::new(daughters.0.clone()), Box::new(daughters.1.clone())),
330            reconstruction,
331            species: None,
332        }
333    }
334
335    /// Return a copy of this generated particle with species metadata attached.
336    pub fn with_species(mut self, species: ParticleSpecies) -> Self {
337        match &mut self {
338            Self::Initial {
339                species: particle_species,
340                ..
341            }
342            | Self::Stable {
343                species: particle_species,
344                ..
345            }
346            | Self::Composite {
347                species: particle_species,
348                ..
349            } => *particle_species = Some(species),
350        }
351        self
352    }
353
354    /// Return the generated particle ID.
355    pub fn id(&self) -> &str {
356        match self {
357            Self::Initial { id, .. } | Self::Stable { id, .. } | Self::Composite { id, .. } => id,
358        }
359    }
360
361    /// Return optional species metadata for this generated particle.
362    pub fn species(&self) -> Option<&ParticleSpecies> {
363        match self {
364            Self::Initial { species, .. }
365            | Self::Stable { species, .. }
366            | Self::Composite { species, .. } => species.as_ref(),
367        }
368    }
369
370    /// Return this particle's reconstruction interpretation.
371    pub fn reconstruction(&self) -> &Reconstruction {
372        match self {
373            Self::Initial { reconstruction, .. }
374            | Self::Stable { reconstruction, .. }
375            | Self::Composite { reconstruction, .. } => reconstruction,
376        }
377    }
378
379    fn p4_labels(&self) -> Vec<String> {
380        let mut labels = vec![self.id().to_string()];
381        if let Self::Composite { daughters, .. } = self {
382            labels.append(&mut daughters.0.p4_labels());
383            labels.append(&mut daughters.1.p4_labels());
384        }
385        labels
386    }
387
388    fn append_decay_layout(
389        &self,
390        parent_id: Option<usize>,
391        produced_vertex_id: Option<usize>,
392        storage: &GeneratedStorage,
393        particles: &mut Vec<GeneratedParticleLayout>,
394        vertices: &mut Vec<GeneratedVertexLayout>,
395    ) -> usize {
396        let product_id = particles.len();
397        particles.push(GeneratedParticleLayout {
398            id: self.id().to_string(),
399            product_id,
400            parent_id,
401            species: self.species().cloned(),
402            p4_label: storage.stores(self.id()).then(|| self.id().to_string()),
403            produced_vertex_id,
404            decay_vertex_id: None,
405        });
406        if let Self::Composite { daughters, .. } = self {
407            let vertex_id = vertices.len();
408            particles[product_id].decay_vertex_id = Some(vertex_id);
409            vertices.push(GeneratedVertexLayout {
410                vertex_id,
411                kind: GeneratedVertexKind::Decay,
412                incoming_product_ids: vec![product_id],
413                outgoing_product_ids: Vec::new(),
414            });
415            let daughter_1_id = daughters.0.append_decay_layout(
416                Some(product_id),
417                Some(vertex_id),
418                storage,
419                particles,
420                vertices,
421            );
422            let daughter_2_id = daughters.1.append_decay_layout(
423                Some(product_id),
424                Some(vertex_id),
425                storage,
426                particles,
427                vertices,
428            );
429            vertices[vertex_id].outgoing_product_ids = vec![daughter_1_id, daughter_2_id];
430        }
431        product_id
432    }
433
434    fn sample_mass(&self, rng: &mut Rng) -> f64 {
435        match self {
436            Self::Initial { generator, .. } => generator.mass,
437            Self::Stable { generator, .. } => generator.sample_mass(rng),
438            Self::Composite { generator, .. } => generator.sample_mass(rng),
439        }
440    }
441
442    fn generated_particle(&self) -> LadduResult<Particle> {
443        match self.reconstruction() {
444            Reconstruction::Stored => Ok(Particle::stored(self.id())),
445            Reconstruction::Fixed(p4) => Ok(Particle::fixed(self.id(), *p4)),
446            Reconstruction::Missing => Ok(Particle::missing(self.id())),
447            Reconstruction::Composite => {
448                let Self::Composite { daughters, .. } = self else {
449                    return Err(LadduError::Custom(format!(
450                        "particle '{}' cannot use composite reconstruction without daughters",
451                        self.id()
452                    )));
453                };
454                let daughter_1 = daughters.0.generated_particle()?;
455                let daughter_2 = daughters.1.generated_particle()?;
456                Particle::composite(self.id(), (&daughter_1, &daughter_2))
457            }
458        }
459    }
460
461    fn validate_reconstruction(&self) -> LadduResult<()> {
462        match (self, self.reconstruction()) {
463            (Self::Composite { daughters, .. }, Reconstruction::Composite) => {
464                daughters.0.validate_reconstruction()?;
465                daughters.1.validate_reconstruction()?;
466                Ok(())
467            }
468            (Self::Composite { .. }, _) => Ok(()),
469            (_, Reconstruction::Composite) => Err(LadduError::Custom(format!(
470                "particle '{}' cannot use composite reconstruction without daughters",
471                self.id()
472            ))),
473            _ => Ok(()),
474        }
475    }
476
477    fn collect_ids<'a>(&'a self, seen: &mut HashSet<&'a str>) -> LadduResult<()> {
478        if !seen.insert(self.id()) {
479            return Err(LadduError::Custom(format!(
480                "duplicate generated particle identifier '{}'",
481                self.id()
482            )));
483        }
484        if let Self::Composite { daughters, .. } = self {
485            daughters.0.collect_ids(seen)?;
486            daughters.1.collect_ids(seen)?;
487        }
488        Ok(())
489    }
490
491    fn generate_decay(
492        &self,
493        rng: &mut Rng,
494        p4_cm: Vec4,
495        cm_to_lab_boost: &Vec3,
496        p4_storage: &mut HashMap<String, Vec<Vec4>>,
497    ) {
498        let p4_lab = p4_cm.boost(cm_to_lab_boost);
499        if let Some(storage) = p4_storage.get_mut(self.id()) {
500            storage.push(p4_lab);
501        }
502
503        let Self::Composite { daughters, .. } = self else {
504            return;
505        };
506        let d1 = &daughters.0;
507        let d2 = &daughters.1;
508        let parent_mass = p4_cm.m();
509        let m1 = d1.sample_mass(rng);
510        let m2 = d2.sample_mass(rng);
511        let q = q_m(parent_mass, m1, m2, Sheet::Physical).re;
512        let parent_msq = parent_mass * parent_mass;
513        let msq1 = m1 * m1;
514        let msq2 = m2 * m2;
515        let e1 = (parent_msq + msq1 - msq2) / (2.0 * parent_mass);
516        let e2 = (parent_msq + msq2 - msq1) / (2.0 * parent_mass);
517
518        let cos_theta = rng.uniform(-1.0, 1.0);
519        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
520        let phi = rng.uniform(0.0, 2.0 * PI);
521        let (sin_phi, cos_phi) = phi.sin_cos();
522
523        let dir = Vec3::new(sin_theta * cos_phi, sin_theta * sin_phi, cos_theta);
524        let p1_p4_rest = (dir * q).with_energy(e1);
525        let p2_p4_rest = (-dir * q).with_energy(e2);
526        let parent_to_cm_boost = p4_cm.beta();
527        let p1_p4_cm = p1_p4_rest.boost(&parent_to_cm_boost);
528        let p2_p4_cm = p2_p4_rest.boost(&parent_to_cm_boost);
529        d1.generate_decay(rng, p1_p4_cm, cm_to_lab_boost, p4_storage);
530        d2.generate_decay(rng, p2_p4_cm, cm_to_lab_boost, p4_storage);
531    }
532}
533
534/// A generated two-to-two reaction preserving `p1 + p2 -> p3 + p4` role semantics.
535#[derive(Clone, Debug)]
536pub struct GeneratedTwoToTwoReaction {
537    p1: GeneratedParticle,
538    p2: GeneratedParticle,
539    p3: GeneratedParticle,
540    p4: GeneratedParticle,
541    tdist: MandelstamTDistribution,
542    p1_p3_lab_dir: Vec3,
543    p2_p3_lab_dir: Vec3,
544}
545
546impl GeneratedTwoToTwoReaction {
547    /// Construct a generated two-to-two reaction.
548    pub fn new(
549        p1: GeneratedParticle,
550        p2: GeneratedParticle,
551        p3: GeneratedParticle,
552        p4: GeneratedParticle,
553        tdist: MandelstamTDistribution,
554    ) -> LadduResult<Self> {
555        validate_initial_role(&p1, "p1")?;
556        validate_initial_role(&p2, "p2")?;
557        validate_final_role(&p3, "p3")?;
558        validate_final_role(&p4, "p4")?;
559        let reaction = Self {
560            p1,
561            p2,
562            p3,
563            p4,
564            tdist,
565            p1_p3_lab_dir: Vec3::z(),
566            p2_p3_lab_dir: -Vec3::z(),
567        };
568        reaction.validate()?;
569        Ok(reaction)
570    }
571
572    fn validate(&self) -> LadduResult<()> {
573        let mut seen = HashSet::new();
574        for particle in [&self.p1, &self.p2, &self.p3, &self.p4] {
575            particle.collect_ids(&mut seen)?;
576            particle.validate_reconstruction()?;
577        }
578        Ok(())
579    }
580
581    fn p4_labels(&self) -> Vec<String> {
582        let mut labels = Vec::new();
583        for particle in [&self.p1, &self.p2, &self.p3, &self.p4] {
584            labels.append(&mut particle.p4_labels());
585        }
586        labels
587    }
588
589    fn layout_components(
590        &self,
591        storage: &GeneratedStorage,
592    ) -> (Vec<GeneratedParticleLayout>, Vec<GeneratedVertexLayout>) {
593        let mut particles = Vec::new();
594        let mut vertices = vec![GeneratedVertexLayout {
595            vertex_id: 0,
596            kind: GeneratedVertexKind::Production,
597            incoming_product_ids: Vec::new(),
598            outgoing_product_ids: Vec::new(),
599        }];
600        let p1_id = self
601            .p1
602            .append_decay_layout(None, None, storage, &mut particles, &mut vertices);
603        let p2_id = self
604            .p2
605            .append_decay_layout(None, None, storage, &mut particles, &mut vertices);
606        let p3_id =
607            self.p3
608                .append_decay_layout(None, Some(0), storage, &mut particles, &mut vertices);
609        let p4_id =
610            self.p4
611                .append_decay_layout(None, Some(0), storage, &mut particles, &mut vertices);
612        vertices[0].incoming_product_ids = vec![p1_id, p2_id];
613        vertices[0].outgoing_product_ids = vec![p3_id, p4_id];
614        (particles, vertices)
615    }
616
617    fn particle_layouts(&self) -> Vec<GeneratedParticleLayout> {
618        self.particle_layouts_with_storage(&GeneratedStorage::All)
619    }
620
621    fn particle_layouts_with_storage(
622        &self,
623        storage: &GeneratedStorage,
624    ) -> Vec<GeneratedParticleLayout> {
625        self.layout_components(storage).0
626    }
627
628    fn vertex_layouts(&self) -> Vec<GeneratedVertexLayout> {
629        self.layout_components(&GeneratedStorage::All).1
630    }
631
632    fn reconstructed_reaction(&self) -> LadduResult<Reaction> {
633        Reaction::two_to_two(
634            &self.p1.generated_particle()?,
635            &self.p2.generated_particle()?,
636            &self.p3.generated_particle()?,
637            &self.p4.generated_particle()?,
638        )
639    }
640
641    fn generate_event(&self, rng: &mut Rng, p4_storage: &mut HashMap<String, Vec<Vec4>>) {
642        let GeneratedParticle::Initial {
643            id: p1_id,
644            generator: p1_generator,
645            ..
646        } = &self.p1
647        else {
648            unreachable!("validated generated two-to-two p1 role")
649        };
650        let GeneratedParticle::Initial {
651            id: p2_id,
652            generator: p2_generator,
653            ..
654        } = &self.p2
655        else {
656            unreachable!("validated generated two-to-two p2 role")
657        };
658
659        let p1_e = p1_generator.energy_distribution.sample(rng);
660        let p1_m = p1_generator.mass;
661        let p1_msq = p1_m * p1_m;
662        let p1_p4_lab = rng.p4(p1_m, p1_e, self.p1_p3_lab_dir);
663        if let Some(storage) = p4_storage.get_mut(p1_id) {
664            storage.push(p1_p4_lab)
665        }
666
667        let p2_e = p2_generator.energy_distribution.sample(rng);
668        let p2_m = p2_generator.mass;
669        let p2_msq = p2_m * p2_m;
670        let p2_p4_lab = rng.p4(p2_m, p2_e, self.p2_p3_lab_dir);
671        if let Some(storage) = p4_storage.get_mut(p2_id) {
672            storage.push(p2_p4_lab)
673        }
674
675        let cm = p1_p4_lab + p2_p4_lab;
676        let cm_boost = -cm.beta();
677        let s = cm.mag2();
678        let sqrt_s = s.sqrt();
679
680        let p1_p4_cm = p1_p4_lab.boost(&cm_boost);
681        let p3_m = self.p3.sample_mass(rng);
682        let p3_msq = p3_m * p3_m;
683        let p4_m = self.p4.sample_mass(rng);
684        let p4_msq = p4_m * p4_m;
685        let p_in_mag = q_m(sqrt_s, p1_m, p2_m, Sheet::Physical).re;
686        let p_out_mag = q_m(sqrt_s, p3_m, p4_m, Sheet::Physical).re;
687        let p1_e_cm = (s + p1_msq - p2_msq) / (2.0 * sqrt_s);
688        let p3_e_cm = (s + p3_msq - p4_msq) / (2.0 * sqrt_s);
689        let p4_e_cm = (s + p4_msq - p3_msq) / (2.0 * sqrt_s);
690        let a = p1_msq + p3_msq - 2.0 * p1_e_cm * p3_e_cm;
691        let b_angle = 2.0 * p_in_mag * p_out_mag;
692        let t_lo = a - b_angle;
693        let t_hi = a + b_angle;
694        let t = self.tdist.sample(rng, Some((t_lo, t_hi)));
695        let costheta = (t - a) / b_angle;
696        let sintheta = (1.0 - costheta * costheta).sqrt();
697        let phi = rng.uniform(0.0, 2.0 * PI);
698        let (sin_phi, cos_phi) = phi.sin_cos();
699        let (x, y, z) = basis(p1_p4_cm.vec3());
700        let p3_dir_cm = x * (sintheta * cos_phi) + y * (sintheta * sin_phi) + z * costheta;
701
702        let p3_p4_cm = (p3_dir_cm * p_out_mag).with_energy(p3_e_cm);
703        self.p3
704            .generate_decay(rng, p3_p4_cm, &-cm_boost, p4_storage);
705        let p4_p4_cm = (-p3_dir_cm * p_out_mag).with_energy(p4_e_cm);
706        self.p4
707            .generate_decay(rng, p4_p4_cm, &-cm_boost, p4_storage);
708    }
709}
710
711fn validate_initial_role(particle: &GeneratedParticle, role: &str) -> LadduResult<()> {
712    if matches!(particle, GeneratedParticle::Initial { .. }) {
713        Ok(())
714    } else {
715        Err(LadduError::Custom(format!(
716            "generated two-to-two role '{role}' requires an initial particle"
717        )))
718    }
719}
720
721fn validate_final_role(particle: &GeneratedParticle, role: &str) -> LadduResult<()> {
722    if matches!(
723        particle,
724        GeneratedParticle::Stable { .. } | GeneratedParticle::Composite { .. }
725    ) {
726        Ok(())
727    } else {
728        Err(LadduError::Custom(format!(
729            "generated two-to-two role '{role}' requires an outgoing particle"
730        )))
731    }
732}
733
734/// A generated reaction topology.
735#[derive(Clone, Debug)]
736pub enum GeneratedReactionTopology {
737    /// A generated two-to-two topology.
738    TwoToTwo(GeneratedTwoToTwoReaction),
739}
740
741impl GeneratedReactionTopology {
742    fn p4_labels(&self) -> Vec<String> {
743        match self {
744            Self::TwoToTwo(reaction) => reaction.p4_labels(),
745        }
746    }
747
748    fn particle_layouts(&self) -> Vec<GeneratedParticleLayout> {
749        match self {
750            Self::TwoToTwo(reaction) => reaction.particle_layouts(),
751        }
752    }
753
754    fn particle_layouts_with_storage(
755        &self,
756        storage: &GeneratedStorage,
757    ) -> Vec<GeneratedParticleLayout> {
758        match self {
759            Self::TwoToTwo(reaction) => reaction.particle_layouts_with_storage(storage),
760        }
761    }
762
763    fn vertex_layouts(&self) -> Vec<GeneratedVertexLayout> {
764        match self {
765            Self::TwoToTwo(reaction) => reaction.vertex_layouts(),
766        }
767    }
768
769    fn reconstructed_reaction(&self) -> LadduResult<Reaction> {
770        match self {
771            Self::TwoToTwo(reaction) => reaction.reconstructed_reaction(),
772        }
773    }
774
775    fn generate_event(&self, rng: &mut Rng, p4_storage: &mut HashMap<String, Vec<Vec4>>) {
776        match self {
777            Self::TwoToTwo(reaction) => reaction.generate_event(rng, p4_storage),
778        }
779    }
780}
781
782/// A generated reaction layout.
783#[derive(Clone, Debug)]
784pub struct GeneratedReaction {
785    topology: GeneratedReactionTopology,
786}
787
788impl GeneratedReaction {
789    /// Construct a generated two-to-two reaction.
790    pub fn two_to_two(
791        p1: GeneratedParticle,
792        p2: GeneratedParticle,
793        p3: GeneratedParticle,
794        p4: GeneratedParticle,
795        tdist: MandelstamTDistribution,
796    ) -> LadduResult<Self> {
797        Ok(Self {
798            topology: GeneratedReactionTopology::TwoToTwo(GeneratedTwoToTwoReaction::new(
799                p1, p2, p3, p4, tdist,
800            )?),
801        })
802    }
803
804    /// Return generated p4 labels.
805    pub fn p4_labels(&self) -> Vec<String> {
806        self.topology.p4_labels()
807    }
808
809    /// Return generated particle layout entries in stable product-ID order.
810    pub fn particle_layouts(&self) -> Vec<GeneratedParticleLayout> {
811        self.topology.particle_layouts()
812    }
813
814    /// Return generated particle layout entries for a dataset storage policy.
815    pub fn particle_layouts_with_storage(
816        &self,
817        storage: &GeneratedStorage,
818    ) -> Vec<GeneratedParticleLayout> {
819        self.topology.particle_layouts_with_storage(storage)
820    }
821
822    /// Return generated vertex layout entries in stable vertex-ID order.
823    pub fn vertex_layouts(&self) -> Vec<GeneratedVertexLayout> {
824        self.topology.vertex_layouts()
825    }
826
827    /// Build the reconstructed reaction corresponding to this generated layout.
828    pub fn reconstructed_reaction(&self) -> LadduResult<Reaction> {
829        self.topology.reconstructed_reaction()
830    }
831
832    fn generate(
833        &self,
834        rng: &mut Rng,
835        p4_storage: &mut HashMap<String, Vec<Vec4>>,
836        n_events: usize,
837    ) {
838        for _ in 0..n_events {
839            self.topology.generate_event(rng, p4_storage);
840        }
841    }
842}
843
844/// Metadata for one generated particle in a generated event layout.
845#[derive(Clone, Debug, PartialEq, Eq)]
846pub struct GeneratedParticleLayout {
847    id: String,
848    product_id: usize,
849    parent_id: Option<usize>,
850    species: Option<ParticleSpecies>,
851    p4_label: Option<String>,
852    produced_vertex_id: Option<usize>,
853    decay_vertex_id: Option<usize>,
854}
855
856impl GeneratedParticleLayout {
857    /// Return the generated particle identifier.
858    pub fn id(&self) -> &str {
859        &self.id
860    }
861
862    /// Return the zero-based stable product ID in generated-layout order.
863    pub fn product_id(&self) -> usize {
864        self.product_id
865    }
866
867    /// Return the decay-parent product ID, if this particle is a decay daughter.
868    pub fn parent_id(&self) -> Option<usize> {
869        self.parent_id
870    }
871
872    /// Return optional species metadata associated with this generated particle.
873    pub fn species(&self) -> Option<&ParticleSpecies> {
874        self.species.as_ref()
875    }
876
877    /// Return the dataset p4 label associated with this particle, if stored in the batch.
878    pub fn p4_label(&self) -> Option<&str> {
879        self.p4_label.as_deref()
880    }
881
882    /// Return the vertex ID where this particle was produced, if any.
883    pub fn produced_vertex_id(&self) -> Option<usize> {
884        self.produced_vertex_id
885    }
886
887    /// Return the vertex ID where this particle decays, if it is a generated parent.
888    pub fn decay_vertex_id(&self) -> Option<usize> {
889        self.decay_vertex_id
890    }
891}
892
893/// The semantic kind of a generated vertex.
894#[derive(Clone, Copy, Debug, PartialEq, Eq)]
895pub enum GeneratedVertexKind {
896    /// A production vertex connecting initial-state particles to outgoing products.
897    Production,
898    /// A decay vertex connecting one generated parent to generated daughters.
899    Decay,
900}
901
902/// Metadata for one generated vertex in a generated event layout.
903#[derive(Clone, Debug, PartialEq, Eq)]
904pub struct GeneratedVertexLayout {
905    vertex_id: usize,
906    kind: GeneratedVertexKind,
907    incoming_product_ids: Vec<usize>,
908    outgoing_product_ids: Vec<usize>,
909}
910
911impl GeneratedVertexLayout {
912    /// Return the zero-based stable vertex ID in generated-layout order.
913    pub fn vertex_id(&self) -> usize {
914        self.vertex_id
915    }
916
917    /// Return the semantic vertex kind.
918    pub fn kind(&self) -> GeneratedVertexKind {
919        self.kind
920    }
921
922    /// Return product IDs entering this vertex.
923    pub fn incoming_product_ids(&self) -> &[usize] {
924        &self.incoming_product_ids
925    }
926
927    /// Return product IDs leaving this vertex.
928    pub fn outgoing_product_ids(&self) -> &[usize] {
929        &self.outgoing_product_ids
930    }
931}
932
933/// Metadata describing the columns and generated particles in a generated event batch.
934#[derive(Clone, Debug, PartialEq, Eq)]
935pub struct GeneratedEventLayout {
936    p4_labels: Vec<String>,
937    aux_labels: Vec<String>,
938    particles: Vec<GeneratedParticleLayout>,
939    vertices: Vec<GeneratedVertexLayout>,
940}
941
942impl GeneratedEventLayout {
943    /// Construct generated event layout metadata from p4 and auxiliary labels.
944    pub fn new(
945        p4_labels: Vec<String>,
946        aux_labels: Vec<String>,
947        particles: Vec<GeneratedParticleLayout>,
948        vertices: Vec<GeneratedVertexLayout>,
949    ) -> Self {
950        Self {
951            p4_labels,
952            aux_labels,
953            particles,
954            vertices,
955        }
956    }
957
958    /// Return generated p4 column labels in dataset order.
959    pub fn p4_labels(&self) -> &[String] {
960        &self.p4_labels
961    }
962
963    /// Return generated auxiliary column labels in dataset order.
964    pub fn aux_labels(&self) -> &[String] {
965        &self.aux_labels
966    }
967
968    /// Return generated particle layout entries in stable product-ID order.
969    pub fn particles(&self) -> &[GeneratedParticleLayout] {
970        &self.particles
971    }
972
973    /// Return the generated particle layout for a generated particle ID.
974    pub fn particle(&self, id: &str) -> Option<&GeneratedParticleLayout> {
975        self.particles.iter().find(|particle| particle.id() == id)
976    }
977
978    /// Return the generated particle layout for a stable product ID.
979    pub fn product(&self, product_id: usize) -> Option<&GeneratedParticleLayout> {
980        self.particles
981            .iter()
982            .find(|particle| particle.product_id() == product_id)
983    }
984
985    /// Return generated vertex layout entries in stable vertex-ID order.
986    pub fn vertices(&self) -> &[GeneratedVertexLayout] {
987        &self.vertices
988    }
989
990    /// Return the generated vertex layout for a stable vertex ID.
991    pub fn vertex(&self, vertex_id: usize) -> Option<&GeneratedVertexLayout> {
992        self.vertices
993            .iter()
994            .find(|vertex| vertex.vertex_id() == vertex_id)
995    }
996
997    /// Return the production vertex layout, if the generated layout has one.
998    pub fn production_vertex(&self) -> Option<&GeneratedVertexLayout> {
999        self.vertices
1000            .iter()
1001            .find(|vertex| vertex.kind() == GeneratedVertexKind::Production)
1002    }
1003
1004    /// Return the generated decay daughters of a parent product ID.
1005    pub fn decay_products(&self, parent_product_id: usize) -> Vec<&GeneratedParticleLayout> {
1006        self.particles
1007            .iter()
1008            .filter(|particle| particle.parent_id() == Some(parent_product_id))
1009            .collect()
1010    }
1011
1012    /// Return production-level incoming particle layouts.
1013    pub fn production_incoming(&self) -> Vec<&GeneratedParticleLayout> {
1014        self.production_vertex_products(GeneratedVertexLayout::incoming_product_ids)
1015    }
1016
1017    /// Return production-level outgoing particle layouts.
1018    pub fn production_outgoing(&self) -> Vec<&GeneratedParticleLayout> {
1019        self.production_vertex_products(GeneratedVertexLayout::outgoing_product_ids)
1020    }
1021
1022    fn production_vertex_products(
1023        &self,
1024        ids: impl FnOnce(&GeneratedVertexLayout) -> &[usize],
1025    ) -> Vec<&GeneratedParticleLayout> {
1026        self.production_vertex()
1027            .map(|vertex| {
1028                ids(vertex)
1029                    .iter()
1030                    .filter_map(|product_id| self.product(*product_id))
1031                    .collect()
1032            })
1033            .unwrap_or_default()
1034    }
1035}
1036
1037/// A generated dataset batch plus the metadata needed to interpret it.
1038#[derive(Clone, Debug)]
1039pub struct GeneratedBatch {
1040    dataset: Dataset,
1041    reaction: GeneratedReaction,
1042    layout: GeneratedEventLayout,
1043}
1044
1045impl GeneratedBatch {
1046    /// Construct a generated batch.
1047    pub fn new(
1048        dataset: Dataset,
1049        reaction: GeneratedReaction,
1050        layout: GeneratedEventLayout,
1051    ) -> Self {
1052        Self {
1053            dataset,
1054            reaction,
1055            layout,
1056        }
1057    }
1058
1059    /// Borrow the generated dataset.
1060    pub fn dataset(&self) -> &Dataset {
1061        &self.dataset
1062    }
1063
1064    /// Consume this batch and return the generated dataset.
1065    pub fn into_dataset(self) -> Dataset {
1066        self.dataset
1067    }
1068
1069    /// Borrow the generated reaction metadata.
1070    pub fn reaction(&self) -> &GeneratedReaction {
1071        &self.reaction
1072    }
1073
1074    /// Borrow the generated event layout metadata.
1075    pub fn layout(&self) -> &GeneratedEventLayout {
1076        &self.layout
1077    }
1078}
1079
1080/// Event generator for generated reactions.
1081#[derive(Clone, Debug)]
1082pub struct EventGenerator {
1083    reaction: GeneratedReaction,
1084    aux_generators: HashMap<String, Distribution>,
1085    storage: GeneratedStorage,
1086    seed: u64,
1087}
1088
1089/// Finite iterator over generated dataset batches.
1090#[derive(Clone, Debug)]
1091pub struct GeneratedBatchIter {
1092    generator: EventGenerator,
1093    remaining_events: usize,
1094    batch_size: usize,
1095    rng: Rng,
1096}
1097
1098impl Iterator for GeneratedBatchIter {
1099    type Item = LadduResult<GeneratedBatch>;
1100
1101    fn next(&mut self) -> Option<Self::Item> {
1102        if self.remaining_events == 0 {
1103            return None;
1104        }
1105        let n_events = self.batch_size.min(self.remaining_events);
1106        self.remaining_events -= n_events;
1107        Some(
1108            self.generator
1109                .generate_batch_with_rng(n_events, &mut self.rng),
1110        )
1111    }
1112}
1113
1114/// Evaluates unnormalized intensities for generated batches.
1115pub trait BatchIntensity {
1116    /// Return one real-valued intensity for each event in `batch`.
1117    fn evaluate(&self, batch: &GeneratedBatch) -> LadduResult<Vec<f64>>;
1118
1119    /// Evaluate and validate one finite nonnegative intensity for each event in `batch`.
1120    fn evaluate_checked(&self, batch: &GeneratedBatch) -> LadduResult<Vec<f64>> {
1121        let intensities = self.evaluate(batch)?;
1122        if intensities.len() != batch.dataset().n_events() {
1123            return Err(LadduError::Custom(format!(
1124                "intensity length mismatch: expected {}, got {}",
1125                batch.dataset().n_events(),
1126                intensities.len()
1127            )));
1128        }
1129        for (index, weight) in intensities.iter().enumerate() {
1130            if !weight.is_finite() || *weight < 0.0 {
1131                return Err(LadduError::Custom(format!(
1132                    "intensity at event {index} must be finite and nonnegative, got {weight}"
1133                )));
1134            }
1135        }
1136        Ok(intensities)
1137    }
1138}
1139
1140impl<F> BatchIntensity for F
1141where
1142    F: Fn(&GeneratedBatch) -> LadduResult<Vec<f64>>,
1143{
1144    fn evaluate(&self, batch: &GeneratedBatch) -> LadduResult<Vec<f64>> {
1145        self(batch)
1146    }
1147}
1148
1149/// Evaluates a real-valued [`Expression`] as a generated-batch intensity.
1150#[derive(Clone, Debug)]
1151pub struct ExpressionIntensity {
1152    expression: Expression,
1153    parameters: Vec<f64>,
1154}
1155
1156impl ExpressionIntensity {
1157    /// Construct an expression-backed generated-batch intensity.
1158    pub fn new(expression: Expression, parameters: Vec<f64>) -> Self {
1159        Self {
1160            expression,
1161            parameters,
1162        }
1163    }
1164}
1165
1166impl BatchIntensity for ExpressionIntensity {
1167    fn evaluate(&self, batch: &GeneratedBatch) -> LadduResult<Vec<f64>> {
1168        let dataset = Arc::new(batch.dataset().clone());
1169        let evaluator = self.expression.load(&dataset)?;
1170        evaluator
1171            .evaluate(&self.parameters)?
1172            .into_iter()
1173            .enumerate()
1174            .map(|(index, value)| {
1175                if !value.im.is_finite() || value.im.abs() > f64::EPSILON {
1176                    return Err(LadduError::Custom(format!(
1177                        "expression intensity at event {index} must be real-valued, got {value}"
1178                    )));
1179                }
1180                Ok(value.re)
1181            })
1182            .collect()
1183    }
1184}
1185
1186/// Envelope strategy used by rejection sampling.
1187#[derive(Clone, Debug)]
1188pub enum RejectionEnvelope {
1189    /// Use a fixed maximum event weight.
1190    Fixed {
1191        /// Maximum event weight used as the rejection envelope.
1192        max_weight: f64,
1193    },
1194    /// Estimate the maximum event weight from a pilot sample and inflate it.
1195    Pilot {
1196        /// Number of pilot events used to estimate the envelope.
1197        pilot_events: usize,
1198        /// Optional raw generation batch size used for pilot evaluation.
1199        batch_size: Option<usize>,
1200        /// Multiplicative safety factor applied to the observed maximum.
1201        safety_factor: f64,
1202    },
1203}
1204
1205/// Options for rejection sampling generated events.
1206#[derive(Clone, Debug)]
1207pub struct RejectionSamplingOptions {
1208    /// Number of accepted events to produce.
1209    pub target_accepted: usize,
1210    /// Number of raw events to generate per source batch.
1211    pub generation_batch_size: usize,
1212    /// Target number of accepted events emitted per output batch.
1213    pub output_batch_size: usize,
1214    /// Envelope used by the rejection sampler.
1215    pub envelope: RejectionEnvelope,
1216    /// Random seed used for accept/reject decisions.
1217    pub seed: u64,
1218}
1219
1220/// Rejection-sampling diagnostics accumulated while sampling.
1221#[derive(Clone, Debug, Default)]
1222pub struct RejectionSamplingDiagnostics {
1223    /// Number of generated events inspected.
1224    pub generated_events: usize,
1225    /// Number of events accepted.
1226    pub accepted_events: usize,
1227    /// Number of events rejected.
1228    pub rejected_events: usize,
1229    /// Maximum observed event intensity.
1230    pub max_observed_weight: f64,
1231    /// Envelope maximum used for rejection sampling.
1232    pub envelope_max_weight: f64,
1233    /// Number of fixed-envelope violations observed.
1234    pub envelope_violations: usize,
1235}
1236
1237impl RejectionSamplingDiagnostics {
1238    /// Fraction of generated events accepted.
1239    pub fn acceptance_efficiency(&self) -> f64 {
1240        if self.generated_events == 0 {
1241            0.0
1242        } else {
1243            self.accepted_events as f64 / self.generated_events as f64
1244        }
1245    }
1246}
1247
1248/// Rejection sampler over generated batches.
1249#[derive(Clone, Debug)]
1250pub struct RejectionSampler<I> {
1251    generator: EventGenerator,
1252    intensity: I,
1253    max_weight: f64,
1254    options: RejectionSamplingOptions,
1255}
1256
1257impl<I> RejectionSampler<I>
1258where
1259    I: BatchIntensity,
1260{
1261    /// Construct a rejection sampler.
1262    pub fn new(
1263        generator: EventGenerator,
1264        intensity: I,
1265        options: RejectionSamplingOptions,
1266    ) -> LadduResult<Self> {
1267        if options.generation_batch_size == 0 {
1268            return Err(LadduError::Custom(
1269                "generation_batch_size must be greater than zero".to_string(),
1270            ));
1271        }
1272        if options.output_batch_size == 0 {
1273            return Err(LadduError::Custom(
1274                "output_batch_size must be greater than zero".to_string(),
1275            ));
1276        }
1277        let max_weight = match options.envelope {
1278            RejectionEnvelope::Fixed { max_weight } => max_weight,
1279            RejectionEnvelope::Pilot {
1280                pilot_events,
1281                batch_size,
1282                safety_factor,
1283            } => estimate_rejection_envelope(
1284                &generator,
1285                &intensity,
1286                pilot_events,
1287                batch_size.unwrap_or(options.generation_batch_size),
1288                safety_factor,
1289            )?,
1290        };
1291        if !max_weight.is_finite() || max_weight <= 0.0 {
1292            return Err(LadduError::Custom(
1293                "rejection envelope max_weight must be finite and positive".to_string(),
1294            ));
1295        }
1296        Ok(Self {
1297            generator,
1298            intensity,
1299            max_weight,
1300            options,
1301        })
1302    }
1303
1304    /// Consume this sampler and return an iterator over accepted generated batches.
1305    pub fn accepted_batches(self) -> RejectionSampleIter<I> {
1306        RejectionSampleIter {
1307            generation_rng: Rng::with_seed(self.generator.seed),
1308            rejection_rng: Rng::with_seed(self.options.seed),
1309            diagnostics: RejectionSamplingDiagnostics {
1310                envelope_max_weight: self.max_weight,
1311                ..Default::default()
1312            },
1313            sampler: self,
1314            current_batch: None,
1315            current_intensities: Vec::new(),
1316            current_index: 0,
1317        }
1318    }
1319}
1320
1321fn estimate_rejection_envelope<I>(
1322    generator: &EventGenerator,
1323    intensity: &I,
1324    pilot_events: usize,
1325    batch_size: usize,
1326    safety_factor: f64,
1327) -> LadduResult<f64>
1328where
1329    I: BatchIntensity,
1330{
1331    if pilot_events == 0 {
1332        return Err(LadduError::Custom(
1333            "pilot_events must be greater than zero".to_string(),
1334        ));
1335    }
1336    if batch_size == 0 {
1337        return Err(LadduError::Custom(
1338            "pilot batch_size must be greater than zero".to_string(),
1339        ));
1340    }
1341    if !safety_factor.is_finite() || safety_factor <= 0.0 {
1342        return Err(LadduError::Custom(
1343            "pilot safety_factor must be finite and positive".to_string(),
1344        ));
1345    }
1346
1347    let mut max_observed = 0.0_f64;
1348    let mut rng = Rng::with_seed(generator.seed);
1349    let mut remaining = pilot_events;
1350    while remaining > 0 {
1351        let n_events = remaining.min(batch_size);
1352        let batch = generator.generate_batch_with_rng(n_events, &mut rng)?;
1353        let weights = intensity.evaluate_checked(&batch)?;
1354        for weight in weights {
1355            max_observed = max_observed.max(weight);
1356        }
1357        remaining -= n_events;
1358    }
1359
1360    let max_weight = max_observed * safety_factor;
1361    if !max_weight.is_finite() || max_weight <= 0.0 {
1362        return Err(LadduError::Custom(format!(
1363            "pilot envelope produced invalid max_weight {max_weight}; observed maximum was {max_observed}"
1364        )));
1365    }
1366    Ok(max_weight)
1367}
1368
1369/// Iterator over accepted generated batches.
1370#[derive(Clone, Debug)]
1371pub struct RejectionSampleIter<I> {
1372    sampler: RejectionSampler<I>,
1373    generation_rng: Rng,
1374    rejection_rng: Rng,
1375    diagnostics: RejectionSamplingDiagnostics,
1376    current_batch: Option<GeneratedBatch>,
1377    current_intensities: Vec<f64>,
1378    current_index: usize,
1379}
1380
1381impl<I> RejectionSampleIter<I> {
1382    /// Borrow rejection-sampling diagnostics accumulated so far.
1383    pub fn diagnostics(&self) -> &RejectionSamplingDiagnostics {
1384        &self.diagnostics
1385    }
1386}
1387
1388impl<I> RejectionSampleIter<I>
1389where
1390    I: BatchIntensity,
1391{
1392    fn load_next_source_batch(&mut self) -> LadduResult<()> {
1393        let batch = self.sampler.generator.generate_batch_with_rng(
1394            self.sampler.options.generation_batch_size,
1395            &mut self.generation_rng,
1396        )?;
1397        let intensities = self.sampler.intensity.evaluate_checked(&batch)?;
1398        self.diagnostics.generated_events += batch.dataset().n_events();
1399        self.current_batch = Some(batch);
1400        self.current_intensities = intensities;
1401        self.current_index = 0;
1402        Ok(())
1403    }
1404
1405    fn empty_output_batch(source: &GeneratedBatch) -> GeneratedBatch {
1406        GeneratedBatch::new(
1407            Dataset::empty_local(source.dataset().metadata().clone()),
1408            source.reaction().clone(),
1409            source.layout().clone(),
1410        )
1411    }
1412}
1413
1414impl<I> Iterator for RejectionSampleIter<I>
1415where
1416    I: BatchIntensity,
1417{
1418    type Item = LadduResult<GeneratedBatch>;
1419
1420    fn next(&mut self) -> Option<Self::Item> {
1421        if self.diagnostics.accepted_events >= self.sampler.options.target_accepted {
1422            return None;
1423        }
1424
1425        let mut output: Option<GeneratedBatch> = None;
1426        while self.diagnostics.accepted_events < self.sampler.options.target_accepted {
1427            let needs_batch = self
1428                .current_batch
1429                .as_ref()
1430                .map(|batch| self.current_index >= batch.dataset().n_events())
1431                .unwrap_or(true);
1432            if needs_batch {
1433                if let Err(err) = self.load_next_source_batch() {
1434                    return Some(Err(err));
1435                }
1436            }
1437
1438            let source = self
1439                .current_batch
1440                .as_ref()
1441                .expect("source batch should be loaded");
1442            if output.is_none() {
1443                output = Some(Self::empty_output_batch(source));
1444            }
1445
1446            let weight = self.current_intensities[self.current_index];
1447            self.diagnostics.max_observed_weight = self.diagnostics.max_observed_weight.max(weight);
1448            let envelope_max = self.sampler.max_weight;
1449            if weight > envelope_max {
1450                self.diagnostics.envelope_violations += 1;
1451                return Some(Err(LadduError::Custom(format!(
1452                    "rejection envelope violation: observed weight {weight} exceeds max_weight {envelope_max}"
1453                ))));
1454            }
1455
1456            let accepted = self.rejection_rng.f64() * envelope_max < weight;
1457            if accepted {
1458                let event = match source.dataset().event_global(self.current_index) {
1459                    Ok(event) => event,
1460                    Err(err) => return Some(Err(err)),
1461                };
1462                if let Err(err) = output.as_mut().unwrap().dataset.push_event_local(
1463                    event.p4s.clone(),
1464                    event.aux.clone(),
1465                    event.weight,
1466                ) {
1467                    return Some(Err(err));
1468                }
1469                self.diagnostics.accepted_events += 1;
1470            } else {
1471                self.diagnostics.rejected_events += 1;
1472            }
1473            self.current_index += 1;
1474
1475            if output.as_ref().unwrap().dataset().n_events()
1476                >= self.sampler.options.output_batch_size
1477                || self.diagnostics.accepted_events >= self.sampler.options.target_accepted
1478            {
1479                break;
1480            }
1481        }
1482
1483        output
1484            .filter(|batch| batch.dataset().n_events() > 0)
1485            .map(Ok)
1486    }
1487}
1488
1489impl EventGenerator {
1490    /// Construct an event generator.
1491    pub fn new(
1492        reaction: GeneratedReaction,
1493        aux_generators: HashMap<String, Distribution>,
1494        seed: Option<u64>,
1495    ) -> Self {
1496        Self {
1497            reaction,
1498            aux_generators,
1499            storage: GeneratedStorage::All,
1500            seed: seed.unwrap_or_else(|| fastrand::u64(..)),
1501        }
1502    }
1503
1504    /// Return the generated p4 storage policy.
1505    pub fn storage(&self) -> &GeneratedStorage {
1506        &self.storage
1507    }
1508
1509    /// Return a copy of this generator with a generated p4 storage policy.
1510    pub fn with_storage(mut self, storage: GeneratedStorage) -> LadduResult<Self> {
1511        storage.validate(&self.reaction.p4_labels())?;
1512        self.storage = storage;
1513        Ok(self)
1514    }
1515
1516    fn aux_entries(&self) -> Vec<(&String, &Distribution)> {
1517        let mut aux_entries = self.aux_generators.iter().collect::<Vec<_>>();
1518        aux_entries.sort_by_key(|(label, _)| *label);
1519        aux_entries
1520    }
1521
1522    fn generate_batch_with_rng(
1523        &self,
1524        n_events: usize,
1525        rng: &mut Rng,
1526    ) -> LadduResult<GeneratedBatch> {
1527        let all_p4_labels = self.reaction.p4_labels();
1528        self.storage.validate(&all_p4_labels)?;
1529        let p4_labels = self.storage.stored_labels(&all_p4_labels);
1530        let aux_entries = self.aux_entries();
1531        let aux_labels = aux_entries
1532            .iter()
1533            .map(|(label, _)| (*label).clone())
1534            .collect::<Vec<_>>();
1535        let mut p4_data: HashMap<String, Vec<Vec4>> = p4_labels
1536            .iter()
1537            .map(|label| (label.clone(), Vec::with_capacity(n_events)))
1538            .collect();
1539        let metadata = DatasetMetadata::new(p4_labels.clone(), aux_labels.clone())?;
1540        let mut aux: Vec<Vec<f64>> = aux_entries
1541            .iter()
1542            .map(|_| Vec::with_capacity(n_events))
1543            .collect();
1544        let weights = vec![1.0; n_events];
1545        for _ in 0..n_events {
1546            for ((_, distribution), column) in aux_entries.iter().zip(aux.iter_mut()) {
1547                column.push(distribution.sample(rng));
1548            }
1549            self.reaction.generate(rng, &mut p4_data, 1);
1550        }
1551        let p4 = p4_labels
1552            .iter()
1553            .filter_map(|label| p4_data.remove(label))
1554            .collect();
1555        let dataset = Dataset::from_columns_local(metadata, p4, aux, weights)?;
1556        Ok(GeneratedBatch::new(
1557            dataset,
1558            self.reaction.clone(),
1559            GeneratedEventLayout::new(
1560                p4_labels,
1561                aux_labels,
1562                self.reaction.particle_layouts_with_storage(&self.storage),
1563                self.reaction.vertex_layouts(),
1564            ),
1565        ))
1566    }
1567
1568    /// Generate one dataset batch with generated layout metadata.
1569    pub fn generate_batch(&self, n_events: usize) -> LadduResult<GeneratedBatch> {
1570        let mut rng = Rng::with_seed(self.seed);
1571        self.generate_batch_with_rng(n_events, &mut rng)
1572    }
1573
1574    /// Generate a finite iterator over batches.
1575    ///
1576    /// The iterator advances one RNG stream, so concatenating all yielded batches is
1577    /// deterministic and matches [`EventGenerator::generate_dataset`] for the same total count.
1578    pub fn generate_batches(
1579        &self,
1580        total_events: usize,
1581        batch_size: usize,
1582    ) -> LadduResult<GeneratedBatchIter> {
1583        if batch_size == 0 {
1584            return Err(LadduError::Custom(
1585                "batch_size must be greater than zero".to_string(),
1586            ));
1587        }
1588        Ok(GeneratedBatchIter {
1589            generator: self.clone(),
1590            remaining_events: total_events,
1591            batch_size,
1592            rng: Rng::with_seed(self.seed),
1593        })
1594    }
1595
1596    /// Generate a dataset.
1597    pub fn generate_dataset(&self, n_events: usize) -> LadduResult<Dataset> {
1598        Ok(self.generate_batch(n_events)?.into_dataset())
1599    }
1600
1601    /// Construct a rejection sampler using a real-valued expression intensity.
1602    pub fn rejection_sampler_with_expression(
1603        &self,
1604        expression: Expression,
1605        parameters: Vec<f64>,
1606        options: RejectionSamplingOptions,
1607    ) -> LadduResult<RejectionSampler<ExpressionIntensity>> {
1608        RejectionSampler::new(
1609            self.clone(),
1610            ExpressionIntensity::new(expression, parameters),
1611            options,
1612        )
1613    }
1614}
1615
1616#[cfg(test)]
1617mod tests {
1618    use approx::assert_relative_eq;
1619    use laddu_core::{traits::Variable, Channel, Expression, Frame};
1620
1621    use super::*;
1622
1623    fn demo_reaction() -> GeneratedReaction {
1624        let beam = GeneratedParticle::initial(
1625            "beam",
1626            InitialGenerator::beam_with_fixed_energy(0.0, 8.0),
1627            Reconstruction::Stored,
1628        );
1629        let target = GeneratedParticle::initial(
1630            "target",
1631            InitialGenerator::target(0.938272),
1632            Reconstruction::Missing,
1633        );
1634        let ks1 = GeneratedParticle::stable(
1635            "kshort1",
1636            StableGenerator::new(0.497611),
1637            Reconstruction::Stored,
1638        );
1639        let ks2 = GeneratedParticle::stable(
1640            "kshort2",
1641            StableGenerator::new(0.497611),
1642            Reconstruction::Stored,
1643        );
1644        let kk = GeneratedParticle::composite(
1645            "kk",
1646            CompositeGenerator::new(1.1, 1.6),
1647            (&ks1, &ks2),
1648            Reconstruction::Composite,
1649        );
1650        let recoil = GeneratedParticle::stable(
1651            "recoil",
1652            StableGenerator::new(0.938272),
1653            Reconstruction::Stored,
1654        );
1655        let tdist = MandelstamTDistribution::Exponential { slope: 0.1 };
1656        GeneratedReaction::two_to_two(beam, target, kk, recoil, tdist).unwrap()
1657    }
1658
1659    #[test]
1660    fn test_generation() {
1661        let reaction = demo_reaction();
1662        let generator = EventGenerator::new(reaction, HashMap::new(), Some(12345));
1663        let n_events = 1_000;
1664        let dataset = generator.generate_dataset(n_events).unwrap();
1665        assert_eq!(dataset.n_events(), n_events);
1666        let metadata = dataset.metadata();
1667        assert!(metadata.p4_index("beam").is_some());
1668        assert!(metadata.p4_index("target").is_some());
1669        assert!(metadata.p4_index("kk").is_some());
1670        assert!(metadata.p4_index("kshort1").is_some());
1671        assert!(metadata.p4_index("kshort2").is_some());
1672        assert!(metadata.p4_index("recoil").is_some());
1673
1674        for event in dataset.events_global() {
1675            let beam_p4 = event.p4("beam").unwrap();
1676            let target_p4 = event.p4("target").unwrap();
1677            let kk_p4 = event.p4("kk").unwrap();
1678            let kshort1_p4 = event.p4("kshort1").unwrap();
1679            let kshort2_p4 = event.p4("kshort2").unwrap();
1680            let recoil_p4 = event.p4("recoil").unwrap();
1681
1682            assert!(beam_p4.e().is_finite());
1683            assert!(target_p4.e().is_finite());
1684            assert!(kk_p4.e().is_finite());
1685            assert!(kshort1_p4.e().is_finite());
1686            assert!(kshort2_p4.e().is_finite());
1687            assert!(recoil_p4.e().is_finite());
1688
1689            assert_relative_eq!(kk_p4, kshort1_p4 + kshort2_p4, epsilon = 1e-10);
1690            assert_relative_eq!(beam_p4 + target_p4, kk_p4 + recoil_p4, epsilon = 1e-10);
1691            assert_relative_eq!(kshort1_p4.m(), 0.497611, epsilon = 1e-10);
1692            assert_relative_eq!(kshort2_p4.m(), 0.497611, epsilon = 1e-10);
1693            assert_relative_eq!(recoil_p4.m(), 0.938272, epsilon = 1e-10);
1694        }
1695    }
1696
1697    #[test]
1698    fn test_reconstructed_reaction() {
1699        let generated = demo_reaction();
1700        let reaction = generated.reconstructed_reaction().unwrap();
1701        let dataset = EventGenerator::new(generated, HashMap::new(), Some(12345))
1702            .generate_dataset(4)
1703            .unwrap();
1704        let mass = reaction.mass("kk").value_on(&dataset).unwrap();
1705        let angles = reaction
1706            .decay("kk")
1707            .unwrap()
1708            .angles("kshort1", Frame::Helicity)
1709            .unwrap();
1710        let mandelstam = reaction
1711            .mandelstam(Channel::S)
1712            .unwrap()
1713            .value_on(&dataset)
1714            .unwrap();
1715
1716        assert_eq!(mass.len(), 4);
1717        assert_eq!(
1718            angles.costheta.to_string(),
1719            "CosTheta(parent=kk, daughter=kshort1, frame=Helicity)"
1720        );
1721        assert_eq!(mandelstam.len(), 4);
1722    }
1723
1724    #[test]
1725    fn test_generated_batch_metadata() {
1726        let generated = demo_reaction();
1727        let generator = EventGenerator::new(
1728            generated,
1729            HashMap::from([("pol_angle".to_string(), Distribution::Fixed(0.25))]),
1730            Some(12345),
1731        );
1732        let batch = generator.generate_batch(4).unwrap();
1733
1734        assert_eq!(batch.dataset().n_events(), 4);
1735        assert_eq!(
1736            batch.layout().p4_labels(),
1737            &["beam", "target", "kk", "kshort1", "kshort2", "recoil"]
1738        );
1739        assert_eq!(batch.layout().aux_labels(), &["pol_angle"]);
1740        assert_eq!(
1741            batch.reaction().p4_labels(),
1742            vec!["beam", "target", "kk", "kshort1", "kshort2", "recoil"]
1743        );
1744        assert_eq!(batch.dataset().p4_names(), batch.layout().p4_labels());
1745        assert_eq!(batch.dataset().aux_names(), batch.layout().aux_labels());
1746        let particles = batch.layout().particles();
1747        assert_eq!(particles.len(), 6);
1748        assert_eq!(particles[0].id(), "beam");
1749        assert_eq!(particles[0].product_id(), 0);
1750        assert_eq!(particles[0].parent_id(), None);
1751        assert_eq!(particles[0].produced_vertex_id(), None);
1752        assert_eq!(particles[0].decay_vertex_id(), None);
1753        assert_eq!(particles[1].id(), "target");
1754        assert_eq!(particles[1].parent_id(), None);
1755        assert_eq!(particles[1].produced_vertex_id(), None);
1756        assert_eq!(particles[1].decay_vertex_id(), None);
1757        assert_eq!(particles[2].id(), "kk");
1758        assert_eq!(particles[2].product_id(), 2);
1759        assert_eq!(particles[2].parent_id(), None);
1760        assert_eq!(particles[2].produced_vertex_id(), Some(0));
1761        assert_eq!(particles[2].decay_vertex_id(), Some(1));
1762        assert_eq!(particles[3].id(), "kshort1");
1763        assert_eq!(particles[3].parent_id(), Some(2));
1764        assert_eq!(particles[3].produced_vertex_id(), Some(1));
1765        assert_eq!(particles[3].decay_vertex_id(), None);
1766        assert_eq!(particles[4].id(), "kshort2");
1767        assert_eq!(particles[4].parent_id(), Some(2));
1768        assert_eq!(particles[4].produced_vertex_id(), Some(1));
1769        assert_eq!(particles[4].decay_vertex_id(), None);
1770        assert_eq!(particles[5].id(), "recoil");
1771        assert_eq!(particles[5].parent_id(), None);
1772        assert_eq!(particles[5].produced_vertex_id(), Some(0));
1773        assert_eq!(particles[5].decay_vertex_id(), None);
1774        for particle in particles {
1775            assert_eq!(particle.p4_label(), Some(particle.id()));
1776        }
1777        let vertices = batch.layout().vertices();
1778        assert_eq!(vertices.len(), 2);
1779        assert_eq!(vertices[0].vertex_id(), 0);
1780        assert_eq!(vertices[0].kind(), GeneratedVertexKind::Production);
1781        assert_eq!(vertices[0].incoming_product_ids(), &[0, 1]);
1782        assert_eq!(vertices[0].outgoing_product_ids(), &[2, 5]);
1783        assert_eq!(vertices[1].vertex_id(), 1);
1784        assert_eq!(vertices[1].kind(), GeneratedVertexKind::Decay);
1785        assert_eq!(vertices[1].incoming_product_ids(), &[2]);
1786        assert_eq!(vertices[1].outgoing_product_ids(), &[3, 4]);
1787
1788        assert_eq!(batch.layout().particle("kk"), Some(&particles[2]));
1789        assert_eq!(batch.layout().particle("missing_id"), None);
1790        assert_eq!(batch.layout().product(5), Some(&particles[5]));
1791        assert_eq!(batch.layout().product(6), None);
1792        assert_eq!(batch.layout().vertex(1), Some(&vertices[1]));
1793        assert_eq!(batch.layout().vertex(2), None);
1794        assert_eq!(batch.layout().production_vertex(), Some(&vertices[0]));
1795        assert_eq!(
1796            batch
1797                .layout()
1798                .production_incoming()
1799                .iter()
1800                .map(|particle| particle.id())
1801                .collect::<Vec<_>>(),
1802            vec!["beam", "target"]
1803        );
1804        assert_eq!(
1805            batch
1806                .layout()
1807                .production_outgoing()
1808                .iter()
1809                .map(|particle| particle.id())
1810                .collect::<Vec<_>>(),
1811            vec!["kk", "recoil"]
1812        );
1813        assert_eq!(
1814            batch
1815                .layout()
1816                .decay_products(2)
1817                .iter()
1818                .map(|particle| particle.id())
1819                .collect::<Vec<_>>(),
1820            vec!["kshort1", "kshort2"]
1821        );
1822        assert!(batch.layout().decay_products(5).is_empty());
1823    }
1824
1825    #[test]
1826    fn generated_storage_only_projects_dataset_columns() {
1827        let generated = demo_reaction();
1828        let generator = EventGenerator::new(generated, HashMap::new(), Some(12345))
1829            .with_storage(GeneratedStorage::only([
1830                "beam", "target", "kshort1", "kshort2", "recoil",
1831            ]))
1832            .unwrap();
1833        let batch = generator.generate_batch(4).unwrap();
1834
1835        assert_eq!(
1836            batch.reaction().p4_labels(),
1837            vec!["beam", "target", "kk", "kshort1", "kshort2", "recoil"]
1838        );
1839        assert_eq!(
1840            batch.layout().p4_labels(),
1841            &["beam", "target", "kshort1", "kshort2", "recoil"]
1842        );
1843        assert_eq!(batch.dataset().p4_names(), batch.layout().p4_labels());
1844        assert!(batch.dataset().metadata().p4_index("kk").is_none());
1845
1846        let particles = batch.layout().particles();
1847        assert_eq!(particles.len(), 6);
1848        assert_eq!(particles[2].id(), "kk");
1849        assert_eq!(particles[2].p4_label(), None);
1850        assert_eq!(particles[3].p4_label(), Some("kshort1"));
1851        assert_eq!(particles[4].p4_label(), Some("kshort2"));
1852
1853        for index in 0..batch.dataset().n_events() {
1854            let event = batch.dataset().event_global(index).unwrap();
1855            assert_relative_eq!(
1856                event.p4("beam").unwrap() + event.p4("target").unwrap(),
1857                event.p4("kshort1").unwrap()
1858                    + event.p4("kshort2").unwrap()
1859                    + event.p4("recoil").unwrap(),
1860                epsilon = 1e-10
1861            );
1862        }
1863    }
1864
1865    #[test]
1866    fn generated_storage_rejects_unknown_and_duplicate_ids() {
1867        assert!(
1868            EventGenerator::new(demo_reaction(), HashMap::new(), Some(12345))
1869                .with_storage(GeneratedStorage::only(["beam", "does_not_exist"]))
1870                .is_err()
1871        );
1872        assert!(
1873            EventGenerator::new(demo_reaction(), HashMap::new(), Some(12345))
1874                .with_storage(GeneratedStorage::only(["beam", "beam"]))
1875                .is_err()
1876        );
1877    }
1878
1879    #[test]
1880    fn generated_species_metadata_propagates_to_layout() {
1881        let beam = GeneratedParticle::initial(
1882            "beam",
1883            InitialGenerator::beam_with_fixed_energy(0.0, 8.0),
1884            Reconstruction::Stored,
1885        )
1886        .with_species(ParticleSpecies::code(22));
1887        let target = GeneratedParticle::initial(
1888            "target",
1889            InitialGenerator::target(0.938272),
1890            Reconstruction::Missing,
1891        )
1892        .with_species(ParticleSpecies::with_namespace("pdg", 2212));
1893        let kshort1 = GeneratedParticle::stable(
1894            "kshort1",
1895            StableGenerator::new(0.497611),
1896            Reconstruction::Stored,
1897        )
1898        .with_species(ParticleSpecies::label("KShort"));
1899        let kshort2 = GeneratedParticle::stable(
1900            "kshort2",
1901            StableGenerator::new(0.497611),
1902            Reconstruction::Stored,
1903        )
1904        .with_species(ParticleSpecies::label("KShort"));
1905        let kk = GeneratedParticle::composite(
1906            "kk",
1907            CompositeGenerator::new(1.1, 1.6),
1908            (&kshort1, &kshort2),
1909            Reconstruction::Composite,
1910        )
1911        .with_species(ParticleSpecies::label("KK"));
1912        let recoil = GeneratedParticle::stable(
1913            "recoil",
1914            StableGenerator::new(0.938272),
1915            Reconstruction::Stored,
1916        )
1917        .with_species(ParticleSpecies::code(2212));
1918        let reaction = GeneratedReaction::two_to_two(
1919            beam,
1920            target,
1921            kk,
1922            recoil,
1923            MandelstamTDistribution::Exponential { slope: 0.1 },
1924        )
1925        .unwrap();
1926        let particles = reaction.particle_layouts();
1927
1928        assert_eq!(particles[0].species(), Some(&ParticleSpecies::code(22)));
1929        assert_eq!(
1930            particles[1].species(),
1931            Some(&ParticleSpecies::with_namespace("pdg", 2212))
1932        );
1933        assert_eq!(particles[2].species(), Some(&ParticleSpecies::label("KK")));
1934        assert_eq!(
1935            particles[3].species(),
1936            Some(&ParticleSpecies::label("KShort"))
1937        );
1938        assert_eq!(
1939            particles[4].species(),
1940            Some(&ParticleSpecies::label("KShort"))
1941        );
1942        assert_eq!(particles[5].species(), Some(&ParticleSpecies::code(2212)));
1943    }
1944
1945    #[test]
1946    fn generated_batches_match_one_shot_generation() {
1947        let generated = demo_reaction();
1948        let generator = EventGenerator::new(
1949            generated,
1950            HashMap::from([(
1951                "pol_angle".to_string(),
1952                Distribution::Uniform { min: 0.0, max: 1.0 },
1953            )]),
1954            Some(12345),
1955        );
1956        let one_shot = generator.generate_dataset(7).unwrap();
1957        let batches = generator
1958            .generate_batches(7, 3)
1959            .unwrap()
1960            .collect::<LadduResult<Vec<_>>>()
1961            .unwrap();
1962        let batch_sizes = batches
1963            .iter()
1964            .map(|batch| batch.dataset().n_events())
1965            .collect::<Vec<_>>();
1966        assert_eq!(batch_sizes, vec![3, 3, 1]);
1967
1968        let mut offset = 0;
1969        for batch in batches {
1970            for local_index in 0..batch.dataset().n_events() {
1971                let expected = one_shot.event_global(offset + local_index).unwrap();
1972                let actual = batch.dataset().event_global(local_index).unwrap();
1973                for name in one_shot.p4_names() {
1974                    assert_relative_eq!(
1975                        actual.p4(name).unwrap(),
1976                        expected.p4(name).unwrap(),
1977                        epsilon = 1e-10
1978                    );
1979                }
1980                for aux_index in 0..one_shot.aux_names().len() {
1981                    assert_relative_eq!(actual.aux[aux_index], expected.aux[aux_index]);
1982                }
1983                assert_relative_eq!(actual.weight(), expected.weight());
1984            }
1985            offset += batch.dataset().n_events();
1986        }
1987        assert_eq!(offset, one_shot.n_events());
1988        assert!(generator.generate_batches(1, 0).is_err());
1989    }
1990
1991    #[test]
1992    fn fixed_envelope_rejection_sampler_streams_accepted_batches() {
1993        let generator = EventGenerator::new(demo_reaction(), HashMap::new(), Some(12345));
1994        let sampler = RejectionSampler::new(
1995            generator,
1996            |batch: &GeneratedBatch| Ok(vec![1.0; batch.dataset().n_events()]),
1997            RejectionSamplingOptions {
1998                target_accepted: 5,
1999                generation_batch_size: 4,
2000                output_batch_size: 2,
2001                envelope: RejectionEnvelope::Fixed { max_weight: 1.0 },
2002                seed: 67890,
2003            },
2004        )
2005        .unwrap();
2006
2007        let mut iter = sampler.accepted_batches();
2008        let mut accepted_batches = Vec::new();
2009        for batch in iter.by_ref() {
2010            accepted_batches.push(batch.unwrap());
2011        }
2012        assert_eq!(
2013            accepted_batches
2014                .iter()
2015                .map(|batch| batch.dataset().n_events())
2016                .collect::<Vec<_>>(),
2017            vec![2, 2, 1]
2018        );
2019        assert_eq!(iter.diagnostics().generated_events, 8);
2020        assert_eq!(iter.diagnostics().accepted_events, 5);
2021        assert_eq!(iter.diagnostics().rejected_events, 0);
2022        assert_relative_eq!(iter.diagnostics().acceptance_efficiency(), 5.0 / 8.0);
2023        for batch in accepted_batches {
2024            assert_eq!(
2025                batch.layout().p4_labels(),
2026                &["beam", "target", "kk", "kshort1", "kshort2", "recoil"]
2027            );
2028        }
2029    }
2030
2031    #[test]
2032    fn fixed_envelope_rejection_sampler_rejects_violations() {
2033        let generator = EventGenerator::new(demo_reaction(), HashMap::new(), Some(12345));
2034        let sampler = RejectionSampler::new(
2035            generator,
2036            |batch: &GeneratedBatch| Ok(vec![2.0; batch.dataset().n_events()]),
2037            RejectionSamplingOptions {
2038                target_accepted: 1,
2039                generation_batch_size: 1,
2040                output_batch_size: 1,
2041                envelope: RejectionEnvelope::Fixed { max_weight: 1.0 },
2042                seed: 67890,
2043            },
2044        )
2045        .unwrap();
2046
2047        let mut iter = sampler.accepted_batches();
2048        let err = iter.next().expect("sampler should produce an error");
2049        assert!(err.is_err());
2050        assert_eq!(iter.diagnostics().envelope_violations, 1);
2051        assert_relative_eq!(iter.diagnostics().max_observed_weight, 2.0);
2052    }
2053
2054    #[test]
2055    fn rejection_sampler_validates_custom_batch_intensities() {
2056        let generator = EventGenerator::new(demo_reaction(), HashMap::new(), Some(12345));
2057        let sampler = RejectionSampler::new(
2058            generator,
2059            |batch: &GeneratedBatch| Ok(vec![f64::NAN; batch.dataset().n_events()]),
2060            RejectionSamplingOptions {
2061                target_accepted: 1,
2062                generation_batch_size: 1,
2063                output_batch_size: 1,
2064                envelope: RejectionEnvelope::Fixed { max_weight: 1.0 },
2065                seed: 67890,
2066            },
2067        )
2068        .unwrap();
2069
2070        let err = sampler
2071            .accepted_batches()
2072            .next()
2073            .expect("sampler should produce an error");
2074        assert!(err.is_err());
2075    }
2076
2077    #[test]
2078    fn expression_rejection_sampler_streams_exact_target_count() {
2079        let generator = EventGenerator::new(demo_reaction(), HashMap::new(), Some(12345));
2080        let sampler = generator
2081            .rejection_sampler_with_expression(
2082                Expression::from(1.0),
2083                vec![],
2084                RejectionSamplingOptions {
2085                    target_accepted: 5,
2086                    generation_batch_size: 4,
2087                    output_batch_size: 2,
2088                    envelope: RejectionEnvelope::Fixed { max_weight: 1.0 },
2089                    seed: 67890,
2090                },
2091            )
2092            .unwrap();
2093
2094        let batches = sampler
2095            .accepted_batches()
2096            .collect::<LadduResult<Vec<_>>>()
2097            .unwrap();
2098        assert_eq!(
2099            batches
2100                .iter()
2101                .map(|batch| batch.dataset().n_events())
2102                .collect::<Vec<_>>(),
2103            vec![2, 2, 1]
2104        );
2105        assert_eq!(
2106            batches
2107                .iter()
2108                .map(|batch| batch.dataset().n_events())
2109                .sum::<usize>(),
2110            5
2111        );
2112    }
2113
2114    #[test]
2115    fn expression_rejection_sampler_supports_pilot_envelope() {
2116        let generator = EventGenerator::new(demo_reaction(), HashMap::new(), Some(12345));
2117        let sampler = generator
2118            .rejection_sampler_with_expression(
2119                Expression::from(1.0),
2120                vec![],
2121                RejectionSamplingOptions {
2122                    target_accepted: 3,
2123                    generation_batch_size: 2,
2124                    output_batch_size: 2,
2125                    envelope: RejectionEnvelope::Pilot {
2126                        pilot_events: 4,
2127                        batch_size: Some(2),
2128                        safety_factor: 1.25,
2129                    },
2130                    seed: 67890,
2131                },
2132            )
2133            .unwrap();
2134        let mut iter = sampler.accepted_batches();
2135        let batches = iter.by_ref().collect::<LadduResult<Vec<_>>>().unwrap();
2136        assert_eq!(
2137            batches
2138                .iter()
2139                .map(|batch| batch.dataset().n_events())
2140                .sum::<usize>(),
2141            3
2142        );
2143        assert_relative_eq!(iter.diagnostics().envelope_max_weight, 1.25);
2144    }
2145
2146    #[test]
2147    fn expression_rejection_sampler_rejects_invalid_intensities() {
2148        let generator = EventGenerator::new(demo_reaction(), HashMap::new(), Some(12345));
2149        let err = generator
2150            .rejection_sampler_with_expression(
2151                Expression::from(-1.0),
2152                vec![],
2153                RejectionSamplingOptions {
2154                    target_accepted: 1,
2155                    generation_batch_size: 1,
2156                    output_batch_size: 1,
2157                    envelope: RejectionEnvelope::Fixed { max_weight: 1.0 },
2158                    seed: 67890,
2159                },
2160            )
2161            .unwrap()
2162            .accepted_batches()
2163            .next()
2164            .expect("sampler should emit an error");
2165        assert!(err.is_err());
2166    }
2167
2168    #[test]
2169    fn duplicate_generated_particle_ids_are_rejected() {
2170        let beam = GeneratedParticle::initial(
2171            "beam",
2172            InitialGenerator::beam_with_fixed_energy(0.0, 8.0),
2173            Reconstruction::Stored,
2174        );
2175        let target = GeneratedParticle::initial(
2176            "target",
2177            InitialGenerator::target(0.938272),
2178            Reconstruction::Missing,
2179        );
2180        let duplicate = GeneratedParticle::stable(
2181            "beam",
2182            StableGenerator::new(0.497611),
2183            Reconstruction::Stored,
2184        );
2185        let recoil = GeneratedParticle::stable(
2186            "recoil",
2187            StableGenerator::new(0.938272),
2188            Reconstruction::Stored,
2189        );
2190
2191        assert!(GeneratedReaction::two_to_two(
2192            beam,
2193            target,
2194            duplicate,
2195            recoil,
2196            MandelstamTDistribution::Exponential { slope: 0.1 },
2197        )
2198        .is_err());
2199    }
2200}