Skip to main content

laddu_core/quantum/
rules.rs

1use crate::{
2    quantum::{PartialWave, ParticleProperties},
3    AllowedPartialWave, AngularMomentum, OrbitalAngularMomentum, Parity, Statistics,
4};
5
6/// A collection of optional selection rules for testing whether a two-body
7/// decay channel is allowed.
8///
9/// Each boolean enables one conservation or symmetry check. Disabled checks are
10/// ignored. Enabled checks are treated permissively with respect to missing
11/// quantum numbers: if the required values are not known, that check does not
12/// reject the channel.
13///
14/// The purely angular-momentum constraints are not represented here. Those are
15/// handled separately when constructing candidate partial waves.
16#[derive(Clone, Debug, Eq, Hash, PartialEq, Default)]
17pub struct RuleSet {
18    /// Enforce intrinsic parity conservation.
19    ///
20    /// For a two-body final state this checks
21    /// $`P_\text{parent} = P_a P_b (-1)^L`$.
22    pub parity: bool,
23
24    /// Enforce total isospin coupling.
25    ///
26    /// This checks whether the two daughter isospins can couple to the parent
27    /// isospin:
28    /// $`I_\text{parent} \in |I_a - I_b|, \ldots, I_a + I_b`$.
29    pub isospin: bool,
30
31    /// Enforce conservation of the isospin projection $`I_3`$.
32    ///
33    /// This checks $`I_{3,\text{parent}} = I_{3,a} + I_{3,b}`$.
34    pub isospin_projection: bool,
35
36    /// Enforce charge-conjugation parity conservation when applicable.
37    ///
38    /// This is only meaningful for states with a defined $`C`$ eigenvalue and
39    /// final states that can be interpreted as $`C`$ eigenstates, such as
40    /// suitable particle-antiparticle combinations.
41    pub c_parity: bool,
42
43    /// Enforce G-parity conservation when applicable.
44    ///
45    /// This is mainly useful for light-quark isospin multiplets where
46    /// $`G`$-parity is defined. It should not be enabled blindly for arbitrary
47    /// hadrons.
48    pub g_parity: bool,
49
50    /// Enforce electric charge conservation.
51    ///
52    /// This checks $`Q_\text{parent} = Q_a + Q_b`$.
53    pub charge: bool,
54
55    /// Enforce strangeness conservation.
56    ///
57    /// This checks $`S_\text{parent} = S_a + S_b`$.
58    ///
59    /// Strong and electromagnetic interactions conserve strangeness; weak
60    /// interactions generally do not.
61    pub strangeness: bool,
62
63    /// Enforce charm conservation.
64    ///
65    /// This checks $`C_\text{parent} = C_a + C_b`$, where $`C`$ here denotes
66    /// charm quantum number, not charge conjugation.
67    pub charm: bool,
68
69    /// Enforce bottomness conservation.
70    ///
71    /// This checks $`B'_\text{parent} = B'_a + B'_b`$, where $`B'`$ denotes
72    /// bottomness, not baryon number.
73    pub bottomness: bool,
74
75    /// Enforce topness conservation.
76    ///
77    /// This checks $`T_\text{parent} = T_a + T_b`$.
78    pub topness: bool,
79
80    /// Enforce baryon-number conservation.
81    ///
82    /// This checks $`B_\text{parent} = B_a + B_b`$.
83    pub baryon_number: bool,
84
85    /// Enforce electron-family lepton-number conservation.
86    ///
87    /// This checks $`L_e(\text{parent}) = L_e(a) + L_e(b)`$.
88    pub electron_lepton_number: bool,
89
90    /// Enforce muon-family lepton-number conservation.
91    ///
92    /// This checks $`L_\mu(\text{parent}) = L_\mu(a) + L_\mu(b)`$.
93    pub muon_lepton_number: bool,
94
95    /// Enforce tau-family lepton-number conservation.
96    ///
97    /// This checks $`L_\tau(\text{parent}) = L_\tau(a) + L_\tau(b)`$.
98    pub tau_lepton_number: bool,
99
100    /// Enforce total lepton-number conservation.
101    ///
102    /// This checks $`L_\text{parent} = L_a + L_b`$, where
103    /// $`L = L_e + L_\mu + L_\tau`$.
104    ///
105    /// This is independent of the individual lepton-family checks. If both this
106    /// and the family-specific checks are enabled, all enabled checks must pass.
107    pub lepton_number: bool,
108
109    /// Enforce exchange-symmetry constraints for identical final-state
110    /// particles when enough information is available.
111    ///
112    /// At minimum, this is useful for cases such as identical spin-zero bosons,
113    /// where only even $`L`$ is allowed.
114    pub identical_particle_symmetry: bool,
115}
116impl RuleSet {
117    /// Construct a rule set with no non-angular selection rules enabled.
118    ///
119    /// This is useful when only the angular-momentum coupling constraints should
120    /// be applied:
121    /// $`S \in |j_a - j_b|, \ldots, j_a + j_b`$
122    /// and
123    /// $`J \in |L - S|, \ldots, L + S`$.
124    pub fn angular() -> Self {
125        Self::default()
126    }
127
128    /// Construct a rule set appropriate for ordinary strong two-body decays.
129    ///
130    /// This enables parity, isospin, isospin projection, electric charge,
131    /// flavor quantum numbers, baryon number, and identical-particle exchange
132    /// symmetry.
133    ///
134    /// Charge-conjugation parity and G-parity are left disabled because they
135    /// are only meaningful for certain channels and should be enabled
136    /// explicitly when applicable.
137    pub fn strong() -> Self {
138        Self {
139            parity: true,
140            isospin: true,
141            isospin_projection: true,
142            charge: true,
143            strangeness: true,
144            charm: true,
145            bottomness: true,
146            topness: true,
147            baryon_number: true,
148            identical_particle_symmetry: true,
149            ..Default::default()
150        }
151    }
152
153    /// Construct a rule set appropriate for electromagnetic two-body decays.
154    ///
155    /// This enables parity, electric charge, flavor quantum numbers, baryon
156    /// number, isospin-projection conservation, and identical-particle exchange
157    /// symmetry.
158    ///
159    /// Total isospin is not enabled because electromagnetic interactions break
160    /// isospin symmetry.
161    pub fn electromagnetic() -> Self {
162        Self {
163            parity: true,
164            isospin_projection: true,
165            charge: true,
166            strangeness: true,
167            charm: true,
168            bottomness: true,
169            topness: true,
170            baryon_number: true,
171            identical_particle_symmetry: true,
172            ..Default::default()
173        }
174    }
175
176    /// Construct a rule set appropriate for weak two-body decays.
177    ///
178    /// This enables electric charge, baryon number, individual lepton-family
179    /// numbers, total lepton number, and identical-particle exchange symmetry.
180    ///
181    /// Parity, isospin, strangeness, charm, bottomness, and topness are not
182    /// enabled because weak interactions can violate or change them.
183    pub fn weak() -> Self {
184        Self {
185            charge: true,
186            baryon_number: true,
187            electron_lepton_number: true,
188            muon_lepton_number: true,
189            tau_lepton_number: true,
190            lepton_number: true,
191            identical_particle_symmetry: true,
192            ..Default::default()
193        }
194    }
195
196    /// Check whether a candidate two-body partial wave satisfies this rule set.
197    ///
198    /// `parent` is the decaying particle, `daughters` are the two final-state
199    /// particles, `l` is their relative orbital angular momentum, and `s` is
200    /// their coupled spin.
201    ///
202    /// Returns `false` if any enabled rule is definitely violated. Returns
203    /// `true` if all enabled rules pass or if some enabled rules cannot be
204    /// evaluated because the required quantum numbers are unknown.
205    ///
206    /// The angular-momentum coupling itself should be checked before or during
207    /// candidate partial-wave construction; this method only applies the
208    /// selected conservation and symmetry rules.
209    pub fn check(
210        &self,
211        parent: &ParticleProperties,
212        daughters: (&ParticleProperties, &ParticleProperties),
213        l: OrbitalAngularMomentum,
214        s: AngularMomentum,
215    ) -> bool {
216        (!self.parity || Self::check_parity(parent, daughters, l).unwrap_or(true))
217            && (!self.isospin || Self::check_isospin(parent, daughters).unwrap_or(true))
218            && (!self.isospin_projection
219                || Self::check_isospin_projection(parent, daughters).unwrap_or(true))
220            && (!self.c_parity || Self::check_c_parity(parent, daughters, l, s).unwrap_or(true))
221            && (!self.g_parity || Self::check_g_parity(parent, daughters).unwrap_or(true))
222            && self.check_additives(parent, daughters).unwrap_or(true)
223            && (!self.identical_particle_symmetry
224                || Self::check_identical_particle_symmetry(daughters, l).unwrap_or(true))
225    }
226    fn check_parity(
227        parent: &ParticleProperties,
228        daughters: (&ParticleProperties, &ParticleProperties),
229        l: OrbitalAngularMomentum,
230    ) -> Option<bool> {
231        let p_parent = parent.parity?;
232        let p_a = daughters.0.parity?;
233        let p_b = daughters.1.parity?;
234        let sign = if l.value() & 1 == 0 { 1 } else { -1 };
235        Some(p_parent.value() == p_a.value() * p_b.value() * sign)
236    }
237    fn check_isospin(
238        parent: &ParticleProperties,
239        daughters: (&ParticleProperties, &ParticleProperties),
240    ) -> Option<bool> {
241        let i_parent = parent.isospin?;
242        let i_a = daughters.0.isospin?;
243        let i_b = daughters.1.isospin?;
244        Some(
245            i_parent
246                .isospin()
247                .can_couple_to(i_a.isospin(), i_b.isospin()),
248        )
249    }
250    fn check_isospin_projection(
251        parent: &ParticleProperties,
252        daughters: (&ParticleProperties, &ParticleProperties),
253    ) -> Option<bool> {
254        let i_parent = parent.isospin?;
255        let i_a = daughters.0.isospin?;
256        let i_b = daughters.1.isospin?;
257        let i3_parent = i_parent.projection?;
258        let i3_a = i_a.projection?;
259        let i3_b = i_b.projection?;
260        Some(i3_parent.value() == i3_a.value() + i3_b.value())
261    }
262    fn check_c_parity(
263        parent: &ParticleProperties,
264        daughters: (&ParticleProperties, &ParticleProperties),
265        l: OrbitalAngularMomentum,
266        s: AngularMomentum,
267    ) -> Option<bool> {
268        let c_parent = parent.c_parity?;
269        if !daughters.0.is_antiparticle_of(daughters.1) {
270            return None;
271        }
272        let exp_twice = 2 * l.value() + s.value();
273        if !exp_twice.is_multiple_of(2) {
274            return Some(false);
275        }
276        let c_final = if (exp_twice / 2).is_multiple_of(2) {
277            Parity::Positive
278        } else {
279            Parity::Negative
280        };
281        Some(c_parent == c_final)
282    }
283    fn check_g_parity(
284        parent: &ParticleProperties,
285        daughters: (&ParticleProperties, &ParticleProperties),
286    ) -> Option<bool> {
287        let g_parent = parent.g_parity?;
288        let g_a = daughters.0.g_parity?;
289        let g_b = daughters.1.g_parity?;
290        Some(g_parent.value() == g_a.value() * g_b.value())
291    }
292    fn check_additives(
293        &self,
294        parent: &ParticleProperties,
295        daughters: (&ParticleProperties, &ParticleProperties),
296    ) -> Option<bool> {
297        let mut unknown = false;
298
299        macro_rules! check_conserved {
300            ($enabled:expr, $parent:expr, $a:expr, $b:expr) => {
301                if $enabled {
302                    match ($parent, $a, $b) {
303                        (Some(parent), Some(a), Some(b)) => {
304                            if parent != a + b {
305                                return Some(false);
306                            }
307                        }
308
309                        _ => {
310                            unknown = true;
311                        }
312                    }
313                }
314            };
315        }
316
317        check_conserved!(
318            self.charge,
319            parent.charge.map(|q| q.value()),
320            daughters.0.charge.map(|q| q.value()),
321            daughters.1.charge.map(|q| q.value())
322        );
323
324        check_conserved!(
325            self.strangeness,
326            parent.strangeness,
327            daughters.0.strangeness,
328            daughters.1.strangeness
329        );
330
331        check_conserved!(
332            self.charm,
333            parent.charm,
334            daughters.0.charm,
335            daughters.1.charm
336        );
337
338        check_conserved!(
339            self.bottomness,
340            parent.bottomness,
341            daughters.0.bottomness,
342            daughters.1.bottomness
343        );
344
345        check_conserved!(
346            self.topness,
347            parent.topness,
348            daughters.0.topness,
349            daughters.1.topness
350        );
351
352        check_conserved!(
353            self.baryon_number,
354            parent.baryon_number,
355            daughters.0.baryon_number,
356            daughters.1.baryon_number
357        );
358
359        check_conserved!(
360            self.electron_lepton_number,
361            parent.electron_lepton_number,
362            daughters.0.electron_lepton_number,
363            daughters.1.electron_lepton_number
364        );
365
366        check_conserved!(
367            self.muon_lepton_number,
368            parent.muon_lepton_number,
369            daughters.0.muon_lepton_number,
370            daughters.1.muon_lepton_number
371        );
372
373        check_conserved!(
374            self.tau_lepton_number,
375            parent.tau_lepton_number,
376            daughters.0.tau_lepton_number,
377            daughters.1.tau_lepton_number
378        );
379
380        if self.lepton_number {
381            match (
382                parent.electron_lepton_number,
383                parent.muon_lepton_number,
384                parent.tau_lepton_number,
385                daughters.0.electron_lepton_number,
386                daughters.0.muon_lepton_number,
387                daughters.0.tau_lepton_number,
388                daughters.1.electron_lepton_number,
389                daughters.1.muon_lepton_number,
390                daughters.1.tau_lepton_number,
391            ) {
392                (
393                    Some(parent_e),
394                    Some(parent_mu),
395                    Some(parent_tau),
396                    Some(a_e),
397                    Some(a_mu),
398                    Some(a_tau),
399                    Some(b_e),
400                    Some(b_mu),
401                    Some(b_tau),
402                ) => {
403                    let parent_total = parent_e + parent_mu + parent_tau;
404
405                    let daughter_total = a_e + a_mu + a_tau + b_e + b_mu + b_tau;
406
407                    if parent_total != daughter_total {
408                        return Some(false);
409                    }
410                }
411
412                _ => {
413                    unknown = true;
414                }
415            }
416        }
417
418        if unknown {
419            None
420        } else {
421            Some(true)
422        }
423    }
424    fn check_identical_particle_symmetry(
425        daughters: (&ParticleProperties, &ParticleProperties),
426        l: OrbitalAngularMomentum,
427    ) -> Option<bool> {
428        let sp_a = daughters.0.species.as_ref()?;
429        let sp_b = daughters.1.species.as_ref()?;
430        if sp_a != sp_b {
431            return Some(true);
432        }
433        let stats_a = daughters.0.statistics?;
434        let stats_b = daughters.0.statistics?;
435        if stats_a != stats_b {
436            return Some(false);
437        }
438        if stats_a == Statistics::Boson
439            && daughters.0.spin.map(|x| x.value()) == Some(0)
440            && daughters.1.spin.map(|x| x.value()) == Some(0)
441        {
442            if l.value().is_multiple_of(2) {
443                return Some(true);
444            }
445            return Some(false);
446        }
447        None
448    }
449}
450
451/// Configuration for generating and filtering allowed two-body partial waves.
452///
453/// `SelectionRules` combines a maximum orbital angular momentum with a
454/// [`RuleSet`]. Candidate waves are generated from angular-momentum coupling
455/// and are then filtered by the enabled rules.
456///
457/// The generated waves satisfy
458/// $`S \in |j_a - j_b|, \ldots, j_a + j_b`$
459/// and
460/// $`J \in |L - S|, \ldots, L + S`$,
461/// with $`0 \le L \le L_\text{max}`$.
462#[derive(Clone, Debug, Eq, Hash, PartialEq)]
463pub struct SelectionRules {
464    /// Maximum orbital angular momentum $`L_\text{max}`$ considered when
465    /// generating candidate partial waves.
466    ///
467    /// The solver scans all integer values
468    /// $`L = 0, 1, \ldots, L_\text{max}`$.
469    pub max_l: OrbitalAngularMomentum,
470
471    /// Conservation and symmetry rules used to filter candidate waves.
472    ///
473    /// Angular-momentum compatibility is handled by
474    /// [`SelectionRules::allowed_partial_waves`]. The [`RuleSet`] applies
475    /// additional checks such as parity, charge, isospin, flavor quantum
476    /// numbers, $`C`$-parity, $`G`$-parity, and identical-particle symmetry.
477    pub rules: RuleSet,
478}
479impl Default for SelectionRules {
480    fn default() -> Self {
481        Self {
482            max_l: OrbitalAngularMomentum::integer(6),
483            rules: RuleSet::strong(),
484        }
485    }
486}
487impl SelectionRules {
488    /// Return all possible coupled total spins from two daughter spins.
489    ///
490    /// Given daughter spins $`j_a`$ and $`j_b`$, this returns
491    /// $`S = |j_a - j_b|, |j_a - j_b| + 1, \ldots, j_a + j_b`$.
492    ///
493    /// Internally angular momenta are stored as doubled values, so the returned
494    /// sequence advances by two in the doubled representation.
495    pub fn coupled_spins(a: AngularMomentum, b: AngularMomentum) -> Vec<AngularMomentum> {
496        let min = a.value().abs_diff(b.value());
497        let max = a.value() + b.value();
498        (min..=max)
499            .step_by(2)
500            .map(AngularMomentum::half_integer)
501            .collect()
502    }
503    /// Generate all allowed two-body partial waves for a parent and two
504    /// daughters.
505    ///
506    /// The parent spin is interpreted as the total angular momentum $`J`$ of
507    /// the resonance. The daughter spins are coupled to possible total-spin
508    /// values $`S`$, and each $`S`$ is combined with orbital angular momenta
509    /// $`L = 0, 1, \ldots, L_\text{max}`$.
510    ///
511    /// A candidate wave is kept when:
512    ///
513    /// 1. $`L`$ and $`S`$ can couple to the parent $`J`$.
514    /// 2. The enabled [`RuleSet`] checks do not reject it.
515    ///
516    /// Returns an empty vector if the parent spin or either daughter spin is
517    /// unknown.
518    ///
519    /// The returned [`AllowedPartialWave`] includes the underlying
520    /// [`PartialWave`] together with channel-dependent inferred quantum numbers,
521    /// such as final-state parity and, when meaningful, $`C`$-parity.
522    pub fn allowed_partial_waves(
523        &self,
524        parent: &ParticleProperties,
525        daughters: (&ParticleProperties, &ParticleProperties),
526    ) -> Vec<AllowedPartialWave> {
527        let Some(parent_j) = parent.spin else {
528            return vec![];
529        };
530        let Some(ja) = daughters.0.spin else {
531            return vec![];
532        };
533        let Some(jb) = daughters.1.spin else {
534            return vec![];
535        };
536        let mut out = Vec::new();
537        for s in Self::coupled_spins(ja, jb) {
538            for l_raw in 0..=self.max_l.value() {
539                let l = OrbitalAngularMomentum::integer(l_raw);
540                let wave = PartialWave::new(parent_j, l, s);
541                if let Ok(wave) = wave {
542                    // TODO: replace with let-chain in 2024 Rust
543                    if self.rules.check(parent, daughters, l, s) {
544                        out.push(AllowedPartialWave::new(wave, daughters));
545                    }
546                }
547            }
548        }
549        out
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::{Charge, Isospin, Projection};
557
558    fn j(twice: u32) -> AngularMomentum {
559        AngularMomentum::half_integer(twice)
560    }
561
562    fn l(value: u32) -> OrbitalAngularMomentum {
563        OrbitalAngularMomentum::integer(value)
564    }
565
566    fn q(thirds: i32) -> Charge {
567        Charge::third_integer(thirds)
568    }
569
570    fn labels(waves: &[AllowedPartialWave]) -> Vec<String> {
571        waves.iter().map(|w| w.wave.label.clone()).collect()
572    }
573
574    #[test]
575    fn coupled_spins_include_all_allowed_values() {
576        assert_eq!(SelectionRules::coupled_spins(j(1), j(1)), vec![j(0), j(2)]);
577        assert_eq!(SelectionRules::coupled_spins(j(1), j(2)), vec![j(1), j(3)]);
578        assert_eq!(
579            SelectionRules::coupled_spins(j(2), j(2)),
580            vec![j(0), j(2), j(4)]
581        );
582    }
583
584    #[test]
585    fn parity_check_uses_both_daughter_parities() {
586        let parent = ParticleProperties::jp(j(0), Parity::Positive);
587        let a = ParticleProperties::jp(j(0), Parity::Positive);
588        let b = ParticleProperties::jp(j(0), Parity::Negative);
589        assert_eq!(RuleSet::check_parity(&parent, (&a, &b), l(0)), Some(false));
590        assert_eq!(RuleSet::check_parity(&parent, (&a, &b), l(1)), Some(true));
591    }
592
593    #[test]
594    fn parity_check_returns_none_when_required_values_are_unknown() {
595        let parent = ParticleProperties::unknown();
596        let a = ParticleProperties::jp(j(0), Parity::Positive);
597        let b = ParticleProperties::jp(j(0), Parity::Negative);
598        assert_eq!(RuleSet::check_parity(&parent, (&a, &b), l(0)), None);
599    }
600
601    #[test]
602    fn additive_checks_reject_any_known_violation() {
603        let rules = RuleSet {
604            charge: true,
605            strangeness: true,
606            baryon_number: true,
607            ..RuleSet::default()
608        };
609        let parent = ParticleProperties::unknown()
610            .with_charge(q(0))
611            .with_strangeness(0)
612            .with_baryon_number(0);
613        let a = ParticleProperties::unknown()
614            .with_charge(q(3))
615            .with_strangeness(0)
616            .with_baryon_number(0);
617        let b = ParticleProperties::unknown()
618            .with_charge(q(0))
619            .with_strangeness(0)
620            .with_baryon_number(0);
621        assert_eq!(rules.check_additives(&parent, (&a, &b)), Some(false));
622    }
623
624    #[test]
625    fn additive_checks_return_none_for_unknowns_only_when_no_violation_is_known() {
626        let rules = RuleSet {
627            charge: true,
628            strangeness: true,
629            ..RuleSet::default()
630        };
631        let parent = ParticleProperties::unknown().with_charge(q(0));
632        let a = ParticleProperties::unknown().with_charge(q(3));
633        let b = ParticleProperties::unknown().with_charge(q(-3));
634        assert_eq!(rules.check_additives(&parent, (&a, &b)), None);
635    }
636
637    #[test]
638    fn additive_checks_return_some_true_when_all_enabled_checks_pass() {
639        let rules = RuleSet {
640            charge: true,
641            strangeness: true,
642            baryon_number: true,
643            ..RuleSet::default()
644        };
645        let parent = ParticleProperties::unknown()
646            .with_charge(q(0))
647            .with_strangeness(0)
648            .with_baryon_number(0);
649        let a = ParticleProperties::unknown()
650            .with_charge(q(3))
651            .with_strangeness(1)
652            .with_baryon_number(0);
653        let b = ParticleProperties::unknown()
654            .with_charge(q(-3))
655            .with_strangeness(-1)
656            .with_baryon_number(0);
657        assert_eq!(rules.check_additives(&parent, (&a, &b)), Some(true));
658    }
659
660    #[test]
661
662    fn total_lepton_number_can_pass_when_individual_flavors_change() {
663        let rules = RuleSet {
664            lepton_number: true,
665            electron_lepton_number: false,
666            muon_lepton_number: false,
667            tau_lepton_number: false,
668            ..RuleSet::default()
669        };
670        let parent = ParticleProperties::unknown()
671            .with_electron_lepton_number(1)
672            .with_muon_lepton_number(0)
673            .with_tau_lepton_number(0);
674        let a = ParticleProperties::unknown()
675            .with_electron_lepton_number(0)
676            .with_muon_lepton_number(1)
677            .with_tau_lepton_number(0);
678        let b = ParticleProperties::unknown()
679            .with_electron_lepton_number(0)
680            .with_muon_lepton_number(0)
681            .with_tau_lepton_number(0);
682        assert_eq!(rules.check_additives(&parent, (&a, &b)), Some(true));
683    }
684
685    #[test]
686    fn individual_lepton_number_can_reject_flavor_change() {
687        let rules = RuleSet {
688            lepton_number: true,
689            electron_lepton_number: true,
690            muon_lepton_number: true,
691            tau_lepton_number: true,
692            ..RuleSet::default()
693        };
694        let parent = ParticleProperties::unknown()
695            .with_electron_lepton_number(1)
696            .with_muon_lepton_number(0)
697            .with_tau_lepton_number(0);
698        let a = ParticleProperties::unknown()
699            .with_electron_lepton_number(0)
700            .with_muon_lepton_number(1)
701            .with_tau_lepton_number(0);
702        let b = ParticleProperties::unknown()
703            .with_electron_lepton_number(0)
704            .with_muon_lepton_number(0)
705            .with_tau_lepton_number(0);
706        assert_eq!(rules.check_additives(&parent, (&a, &b)), Some(false));
707    }
708
709    #[test]
710    fn isospin_coupling_accepts_allowed_parent_isospin() {
711        let parent = ParticleProperties::unknown().with_isospin(Isospin::new(j(2), None).unwrap()); // I = 1
712        let a = ParticleProperties::unknown().with_isospin(Isospin::new(j(1), None).unwrap()); // I = 1/2
713        let b = ParticleProperties::unknown().with_isospin(Isospin::new(j(1), None).unwrap()); // I = 1/2
714        assert_eq!(RuleSet::check_isospin(&parent, (&a, &b)), Some(true));
715    }
716
717    #[test]
718    fn isospin_coupling_rejects_disallowed_parent_isospin() {
719        let parent = ParticleProperties::unknown().with_isospin(Isospin::new(j(4), None).unwrap()); // I = 2
720        let a = ParticleProperties::unknown().with_isospin(Isospin::new(j(1), None).unwrap()); // I = 1/2
721        let b = ParticleProperties::unknown().with_isospin(Isospin::new(j(1), None).unwrap()); // I = 1/2
722        assert_eq!(RuleSet::check_isospin(&parent, (&a, &b)), Some(false));
723    }
724
725    #[test]
726    fn isospin_projection_checks_i3_conservation() {
727        let parent = ParticleProperties::unknown()
728            .with_isospin(Isospin::new(j(2), Some(Projection::integer(0))).unwrap());
729        let a = ParticleProperties::unknown()
730            .with_isospin(Isospin::new(j(1), Some(Projection::half_integer(1))).unwrap());
731        let b = ParticleProperties::unknown()
732            .with_isospin(Isospin::new(j(1), Some(Projection::half_integer(-1))).unwrap());
733        assert_eq!(
734            RuleSet::check_isospin_projection(&parent, (&a, &b)),
735            Some(true)
736        );
737    }
738
739    #[test]
740    fn c_parity_uses_l_plus_s_for_particle_antiparticle_pair() {
741        let parent = ParticleProperties::jpc(j(2), Parity::Negative, Parity::Negative);
742        let a = ParticleProperties::jp(j(0), Parity::Negative)
743            .with_species("pi+")
744            .with_antiparticle_species("pi-");
745        let b = ParticleProperties::jp(j(0), Parity::Negative)
746            .with_species("pi-")
747            .with_antiparticle_species("pi+");
748        // L = 1, S = 0 -> C = (-1)^(1 + 0) = -
749        assert_eq!(
750            RuleSet::check_c_parity(&parent, (&a, &b), l(1), j(0)),
751            Some(true)
752        );
753        // L = 0, S = 0 -> C = +
754        assert_eq!(
755            RuleSet::check_c_parity(&parent, (&a, &b), l(0), j(0)),
756            Some(false)
757        );
758    }
759
760    #[test]
761    fn g_parity_checks_product_of_daughter_g_parities() {
762        let parent = ParticleProperties::unknown().with_g_parity(Parity::Positive);
763        let a = ParticleProperties::unknown().with_g_parity(Parity::Negative);
764        let b = ParticleProperties::unknown().with_g_parity(Parity::Negative);
765        assert_eq!(RuleSet::check_g_parity(&parent, (&a, &b)), Some(true));
766    }
767
768    #[test]
769    fn identical_spin_zero_bosons_require_even_l() {
770        let a = ParticleProperties::jp(j(0), Parity::Negative)
771            .with_species("pi0")
772            .with_statistics(Statistics::Boson)
773            .unwrap();
774        let b = ParticleProperties::jp(j(0), Parity::Negative)
775            .with_species("pi0")
776            .with_statistics(Statistics::Boson)
777            .unwrap();
778        assert_eq!(
779            RuleSet::check_identical_particle_symmetry((&a, &b), l(0)),
780            Some(true)
781        );
782        assert_eq!(
783            RuleSet::check_identical_particle_symmetry((&a, &b), l(1)),
784            Some(false)
785        );
786    }
787
788    #[test]
789    fn selection_rules_find_delta_to_n_pi_p_wave() {
790        let parent = ParticleProperties::jp(j(3), Parity::Positive)
791            .with_charge(q(3))
792            .with_baryon_number(1);
793        let nucleon = ParticleProperties::jp(j(1), Parity::Positive)
794            .with_charge(q(3))
795            .with_baryon_number(1);
796        let pion = ParticleProperties::jp(j(0), Parity::Negative)
797            .with_charge(q(0))
798            .with_baryon_number(0);
799        let rules = SelectionRules {
800            max_l: l(4),
801            rules: RuleSet {
802                parity: true,
803                charge: true,
804                baryon_number: true,
805                ..RuleSet::angular()
806            },
807        };
808        let waves = rules.allowed_partial_waves(&parent, (&nucleon, &pion));
809        assert_eq!(labels(&waves), vec!["2P3/2"]);
810    }
811
812    #[test]
813    fn angular_only_selection_rules_include_all_l_s_couplings() {
814        let parent = ParticleProperties::jp(j(2), Parity::Positive); // J = 1
815        let a = ParticleProperties::jp(j(1), Parity::Positive); // spin 1/2
816        let b = ParticleProperties::jp(j(1), Parity::Negative); // spin 1/2
817        let rules = SelectionRules {
818            max_l: l(2),
819            rules: RuleSet::angular(),
820        };
821        let waves = rules.allowed_partial_waves(&parent, (&a, &b));
822        let got = labels(&waves);
823        assert_eq!(got, vec!["1P1", "3S1", "3P1", "3D1"]);
824    }
825
826    #[test]
827    fn strong_parity_filter_removes_wrong_l_values() {
828        let parent = ParticleProperties::jp(j(2), Parity::Positive); // J^P = 1+
829        let a = ParticleProperties::jp(j(1), Parity::Positive);
830        let b = ParticleProperties::jp(j(1), Parity::Negative);
831        let rules = SelectionRules {
832            max_l: l(2),
833            rules: RuleSet {
834                parity: true,
835                ..RuleSet::angular()
836            },
837        };
838        let waves = rules.allowed_partial_waves(&parent, (&a, &b));
839        let got = labels(&waves);
840        // P_parent = +, P_a P_b = -, so L must be odd.
841        assert_eq!(got, vec!["1P1", "3P1"]);
842    }
843
844    #[test]
845    fn allowed_partial_waves_returns_empty_when_spin_information_is_missing() {
846        let parent = ParticleProperties::unknown();
847        let a = ParticleProperties::jp(j(0), Parity::Negative);
848        let b = ParticleProperties::jp(j(0), Parity::Negative);
849        let rules = SelectionRules::default();
850        assert!(rules.allowed_partial_waves(&parent, (&a, &b)).is_empty());
851    }
852}