Skip to main content

laddu_physics/quantum/
rules.rs

1use std::{collections::BTreeMap, fmt::Display};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    LadduPhysicsError, LadduPhysicsResult,
7    quantum::{J, L, Parity, ParticleProperties, S, Statistics},
8};
9
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
11/// A conservation, symmetry, or classification rule that can be applied to a
12/// two-body decay.
13pub enum RuleKind {
14    /// Enforce intrinsic parity conservation.
15    ///
16    /// For a two-body final state this checks
17    /// $`P_\text{parent} = P_a P_b (-1)^L`$.
18    Parity,
19
20    /// Enforce total isospin coupling.
21    ///
22    /// This checks whether the two daughter isospins can couple to the parent
23    /// isospin:
24    /// $`I_\text{parent} \in |I_a - I_b|, \ldots, I_a + I_b`$.
25    Isospin,
26    /// Enforce conservation of the isospin projection $`I_3`$.
27    ///
28    /// This checks $`I_{3,\text{parent}} = I_{3,a} + I_{3,b}`$.
29    IsospinProjection,
30    /// Enforce charge-conjugation parity conservation when applicable.
31    ///
32    /// This is only meaningful for states with a defined $`C`$ eigenvalue and
33    /// final states that can be interpreted as $`C`$ eigenstates, such as
34    /// suitable particle-antiparticle combinations.
35    CParity,
36    /// Enforce G-parity conservation when applicable.
37    ///
38    /// This is mainly useful for light-quark isospin multiplets where
39    /// $`G`$-parity is defined. It should not be enabled blindly for arbitrary
40    /// hadrons.
41    GParity,
42    /// Enforce electric charge conservation.
43    ///
44    /// This checks $`Q_\text{parent} = Q_a + Q_b`$.
45    Charge,
46    /// Enforce strangeness conservation.
47    ///
48    /// This checks $`S_\text{parent} = S_a + S_b`$.
49    ///
50    /// Strong and electromagnetic interactions conserve strangeness; weak
51    /// interactions generally do not.
52    Strangeness,
53    /// Enforce charm conservation.
54    ///
55    /// This checks $`C_\text{parent} = C_a + C_b`$, where $`C`$ here denotes
56    /// charm quantum number, not charge conjugation.
57    Charm,
58    /// Enforce bottomness conservation.
59    ///
60    /// This checks $`B'_\text{parent} = B'_a + B'_b`$, where $`B'`$ denotes
61    /// bottomness, not baryon number.
62    Bottomness,
63    /// Enforce topness conservation.
64    ///
65    /// This checks $`T_\text{parent} = T_a + T_b`$.
66    Topness,
67    /// Enforce baryon-number conservation.
68    ///
69    /// This checks $`B_\text{parent} = B_a + B_b`$.
70    BaryonNumber,
71    /// Enforce electron-family lepton-number conservation.
72    ///
73    /// This checks $`L_e(\text{parent}) = L_e(a) + L_e(b)`$.
74    ElectronLeptonNumber,
75    /// Enforce muon-family lepton-number conservation.
76    ///
77    /// This checks $`L_\mu(\text{parent}) = L_\mu(a) + L_\mu(b)`$.
78    MuonLeptonNumber,
79    /// Enforce tau-family lepton-number conservation.
80    ///
81    /// This checks $`L_\tau(\text{parent}) = L_\tau(a) + L_\tau(b)`$.
82    TauLeptonNumber,
83    /// Enforce total lepton-number conservation.
84    ///
85    /// This checks $`L_\text{parent} = L_a + L_b`$, where
86    /// $`L = L_e + L_\mu + L_\tau`$.
87    ///
88    /// This is independent of the individual lepton-family checks. If both this
89    /// and the family-specific checks are enabled, all enabled checks must pass.
90    LeptonNumber,
91    /// Enforce exchange-symmetry constraints for identical final-state
92    /// particles when enough information is available.
93    ///
94    /// At minimum, this is useful for cases such as identical spin-zero bosons,
95    /// where only even $`L`$ is allowed.
96    IdenticalParticleSymmetry,
97
98    /// Optional diagnostic/classification rule, not part of strong-decay conservation.
99    ConventionalMesonJpc,
100}
101
102#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
103/// Controls whether and how a rule contributes to the acceptance decision.
104pub enum RuleMode {
105    /// Reject candidates for which the rule fails.
106    Enforce,
107    /// Skip the rule, optionally recording why it was disabled.
108    Ignore {
109        /// Optional explanation for ignoring the rule.
110        reason: Option<String>,
111    },
112    /// Evaluate and report the rule without rejecting the candidate.
113    DiagnoseOnly {
114        /// Optional explanation for retaining the rule as a diagnostic.
115        reason: Option<String>,
116    },
117}
118
119#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
120/// Determines how an enforced rule treats missing particle properties.
121pub enum UnknownPolicy {
122    /// Current behavior: missing information does not reject the channel.
123    Allow,
124    /// Missing information makes the rule fail.
125    Reject,
126    /// Missing information does not reject, but appears in the report.
127    Warn,
128}
129
130#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
131/// The evaluation mode and missing-input policy associated with one rule.
132pub struct RulePolicy {
133    /// Whether the rule is enforced, ignored, or diagnostic.
134    pub mode: RuleMode,
135    /// How missing inputs are handled when the rule is enforced.
136    pub unknown: UnknownPolicy,
137}
138
139impl RulePolicy {
140    /// Create an enforced policy which allows unknown inputs.
141    pub fn enforce() -> Self {
142        Self {
143            mode: RuleMode::Enforce,
144            unknown: UnknownPolicy::Allow,
145        }
146    }
147
148    /// Create an enforced policy which rejects unknown inputs.
149    pub fn enforce_strict() -> Self {
150        Self {
151            mode: RuleMode::Enforce,
152            unknown: UnknownPolicy::Reject,
153        }
154    }
155
156    /// Create an enforced policy with an explicit missing-input policy.
157    pub fn enforce_with_unknown_policy(unknown: UnknownPolicy) -> Self {
158        Self {
159            mode: RuleMode::Enforce,
160            unknown,
161        }
162    }
163
164    /// Create an ignored policy and record a reason.
165    pub fn ignore(reason: impl Into<String>) -> Self {
166        Self {
167            mode: RuleMode::Ignore {
168                reason: Some(reason.into()),
169            },
170            unknown: UnknownPolicy::Allow,
171        }
172    }
173
174    /// Create an ignored policy without recording a reason.
175    pub fn ignore_without_reason() -> Self {
176        Self {
177            mode: RuleMode::Ignore { reason: None },
178            unknown: UnknownPolicy::Allow,
179        }
180    }
181
182    /// Create a diagnostic-only policy and record a reason.
183    pub fn diagnose_only(reason: impl Into<String>) -> Self {
184        Self {
185            mode: RuleMode::DiagnoseOnly {
186                reason: Some(reason.into()),
187            },
188            unknown: UnknownPolicy::Warn,
189        }
190    }
191
192    /// Create a diagnostic-only policy without recording a reason.
193    pub fn diagnose_only_without_reason() -> Self {
194        Self {
195            mode: RuleMode::DiagnoseOnly { reason: None },
196            unknown: UnknownPolicy::Warn,
197        }
198    }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
202/// The result of applying one configured rule to a candidate partial wave.
203pub enum RuleOutcome {
204    /// The rule was evaluated and satisfied.
205    Pass {
206        /// Human-readable explanation of the successful check.
207        message: String,
208    },
209    /// The rule was evaluated and rejected the candidate.
210    Fail {
211        /// Human-readable explanation of the failure.
212        message: String,
213    },
214    /// Required inputs were absent, and the policy allowed the candidate.
215    UnknownAllowed {
216        /// Names of the particle properties that were unavailable.
217        missing: Vec<String>,
218        /// Human-readable explanation of the incomplete check.
219        message: String,
220    },
221    /// Required inputs were absent and the policy requested a warning.
222    Warning {
223        /// Names of the particle properties that were unavailable.
224        missing: Vec<String>,
225        /// Human-readable explanation of the incomplete check.
226        message: String,
227    },
228    /// The rule was intentionally not evaluated.
229    Ignored {
230        /// Optional explanation supplied when the rule was ignored.
231        reason: Option<String>,
232    },
233    /// The rule was evaluated for information but did not affect acceptance.
234    Diagnostic {
235        /// Whether the check passed, or `None` when inputs were unavailable.
236        passed: Option<bool>,
237        /// Optional explanation supplied when diagnostic mode was selected.
238        reason: Option<String>,
239        /// Human-readable result of the diagnostic check.
240        message: String,
241    },
242}
243
244#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
245/// The outcome of one named rule in a [`RuleReport`].
246pub struct RuleCheck {
247    /// Rule that was evaluated.
248    pub rule: RuleKind,
249    /// Result produced under the rule's configured policy.
250    pub outcome: RuleOutcome,
251}
252
253impl RuleCheck {
254    /// Return whether this check rejects the candidate.
255    pub fn is_failure(&self) -> bool {
256        matches!(self.outcome, RuleOutcome::Fail { .. })
257    }
258
259    /// Return whether this check produced a missing-input warning.
260    pub fn is_warning(&self) -> bool {
261        matches!(self.outcome, RuleOutcome::Warning { .. })
262    }
263
264    /// Return whether missing inputs were accepted silently.
265    pub fn is_unknown_allowed(&self) -> bool {
266        matches!(self.outcome, RuleOutcome::UnknownAllowed { .. })
267    }
268
269    /// Return whether this rule was ignored.
270    pub fn is_ignored(&self) -> bool {
271        matches!(self.outcome, RuleOutcome::Ignored { .. })
272    }
273}
274
275#[derive(Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize)]
276/// Detailed results from evaluating a [`RuleSet`] against one candidate.
277pub struct RuleReport {
278    /// Individual checks, ordered by [`RuleKind`].
279    pub checks: Vec<RuleCheck>,
280}
281
282impl RuleReport {
283    /// Return whether no enforced rule rejected the candidate.
284    pub fn is_allowed(&self) -> bool {
285        self.checks.iter().all(|check| !check.is_failure())
286    }
287
288    /// Iterate over checks that rejected the candidate.
289    pub fn failures(&self) -> impl Iterator<Item = &RuleCheck> {
290        self.checks.iter().filter(|check| check.is_failure())
291    }
292
293    /// Iterate over missing-input warnings.
294    pub fn warnings(&self) -> impl Iterator<Item = &RuleCheck> {
295        self.checks.iter().filter(|check| check.is_warning())
296    }
297
298    /// Iterate over checks whose missing inputs were allowed.
299    pub fn unknowns(&self) -> impl Iterator<Item = &RuleCheck> {
300        self.checks
301            .iter()
302            .filter(|check| check.is_unknown_allowed())
303    }
304
305    /// Iterate over rules which were intentionally ignored.
306    pub fn ignored(&self) -> impl Iterator<Item = &RuleCheck> {
307        self.checks.iter().filter(|check| check.is_ignored())
308    }
309
310    /// Retrieve the outcome for a particular rule, if it was configured.
311    pub fn outcome(&self, rule: RuleKind) -> Option<&RuleOutcome> {
312        self.checks
313            .iter()
314            .find(|check| check.rule == rule)
315            .map(|check| &check.outcome)
316    }
317
318    /// Return whether at least one check rejected the candidate.
319    pub fn has_failures(&self) -> bool {
320        self.failures().next().is_some()
321    }
322
323    /// Return whether at least one check allowed unknown inputs.
324    pub fn has_unknowns(&self) -> bool {
325        self.unknowns().next().is_some()
326    }
327
328    /// Return whether at least one configured rule was ignored.
329    pub fn has_ignored(&self) -> bool {
330        self.ignored().next().is_some()
331    }
332
333    /// Return the number of configured rules in the report.
334    pub fn len(&self) -> usize {
335        self.checks.len()
336    }
337
338    /// Return whether the report contains no checks.
339    pub fn is_empty(&self) -> bool {
340        self.checks.is_empty()
341    }
342}
343
344/// A collection of selection rules for testing whether a two-body
345/// decay channel is allowed.
346///
347/// Each rule enables one conservation or symmetry check. Each enabled rule is associated with a
348/// [`RulePolicy`] which dictates how permissively it should be applied to the given particles.
349///
350/// # Notes
351/// The default angular policy doesn't actually enforce any rules, as angular momentum conservation
352/// and coupling rules are handled by other methods.
353///
354/// All constructors assume a permissive enforcement policy, i.e. if a property is unknown for one
355/// or more particles involved, that check is skipped.
356#[derive(Clone, Debug, Eq, Hash, PartialEq, Default, Serialize, Deserialize)]
357pub struct RuleSet {
358    policies: BTreeMap<RuleKind, RulePolicy>,
359}
360impl RuleSet {
361    /// Construct a rule set with no non-angular selection rules enabled.
362    ///
363    /// This is useful when only the angular-momentum coupling constraints should
364    /// be applied:
365    /// $`S \in |j_a - j_b|, \ldots, j_a + j_b`$
366    /// and
367    /// $`J \in |L - S|, \ldots, L + S`$.
368    pub fn angular() -> Self {
369        Self::default()
370    }
371
372    /// Construct a rule set appropriate for ordinary strong two-body decays.
373    ///
374    /// This enables parity, isospin, isospin projection, electric charge,
375    /// flavor quantum numbers, baryon number, and identical-particle exchange
376    /// symmetry.
377    ///
378    /// Charge-conjugation parity and G-parity are left disabled because they
379    /// are only meaningful for certain channels and should be enabled
380    /// explicitly when applicable.
381    pub fn strong() -> Self {
382        Self::angular()
383            .enforce(RuleKind::Parity)
384            .enforce(RuleKind::Isospin)
385            .enforce(RuleKind::IsospinProjection)
386            .enforce(RuleKind::Charge)
387            .enforce(RuleKind::Strangeness)
388            .enforce(RuleKind::Charm)
389            .enforce(RuleKind::Bottomness)
390            .enforce(RuleKind::Topness)
391            .enforce(RuleKind::BaryonNumber)
392            .enforce(RuleKind::IdenticalParticleSymmetry)
393    }
394
395    /// Construct a rule set appropriate for electromagnetic two-body decays.
396    ///
397    /// This enables parity, electric charge, flavor quantum numbers, baryon
398    /// number, isospin-projection conservation, and identical-particle exchange
399    /// symmetry.
400    ///
401    /// Total isospin is not enabled because electromagnetic interactions break
402    /// isospin symmetry.
403    pub fn electromagnetic() -> Self {
404        Self::angular()
405            .enforce(RuleKind::Parity)
406            .enforce(RuleKind::IsospinProjection)
407            .enforce(RuleKind::Charge)
408            .enforce(RuleKind::Strangeness)
409            .enforce(RuleKind::Charm)
410            .enforce(RuleKind::Bottomness)
411            .enforce(RuleKind::Topness)
412            .enforce(RuleKind::BaryonNumber)
413            .enforce(RuleKind::IdenticalParticleSymmetry)
414    }
415
416    /// Construct a rule set appropriate for weak two-body decays.
417    ///
418    /// This enables electric charge, baryon number, individual lepton-family
419    /// numbers, total lepton number, and identical-particle exchange symmetry.
420    ///
421    /// Parity, isospin, strangeness, charm, bottomness, and topness are not
422    /// enabled because weak interactions can violate or change them.
423    pub fn weak() -> Self {
424        Self::angular()
425            .enforce(RuleKind::Charge)
426            .enforce(RuleKind::BaryonNumber)
427            .enforce(RuleKind::ElectronLeptonNumber)
428            .enforce(RuleKind::MuonLeptonNumber)
429            .enforce(RuleKind::TauLeptonNumber)
430            .enforce(RuleKind::LeptonNumber)
431            .enforce(RuleKind::IdenticalParticleSymmetry)
432    }
433
434    /// Enable a rule in place using permissive missing-input handling.
435    pub fn enforce_mut(&mut self, rule: RuleKind) -> &mut Self {
436        self.policies.insert(rule, RulePolicy::enforce());
437        self
438    }
439
440    /// Enable a rule in place and reject candidates with missing inputs.
441    pub fn enforce_strict_mut(&mut self, rule: RuleKind) -> &mut Self {
442        self.policies.insert(rule, RulePolicy::enforce_strict());
443        self
444    }
445
446    /// Assign an explicit policy to a rule in place.
447    pub fn set_policy_mut(&mut self, rule: RuleKind, policy: RulePolicy) -> &mut Self {
448        self.policies.insert(rule, policy);
449        self
450    }
451
452    /// Ignore a rule in place and record the supplied reason.
453    pub fn ignore_mut(&mut self, rule: RuleKind, reason: impl Into<String>) -> &mut Self {
454        self.policies.insert(rule, RulePolicy::ignore(reason));
455        self
456    }
457
458    /// Ignore a rule in place without recording a reason.
459    pub fn ignore_without_reason_mut(&mut self, rule: RuleKind) -> &mut Self {
460        self.policies
461            .insert(rule, RulePolicy::ignore_without_reason());
462        self
463    }
464
465    /// Make a rule diagnostic-only in place and record the supplied reason.
466    pub fn diagnose_only_mut(&mut self, rule: RuleKind, reason: impl Into<String>) -> &mut Self {
467        self.policies
468            .insert(rule, RulePolicy::diagnose_only(reason));
469        self
470    }
471
472    /// Make a rule diagnostic-only in place without recording a reason.
473    pub fn diagnose_only_without_reason_mut(&mut self, rule: RuleKind) -> &mut Self {
474        self.policies
475            .insert(rule, RulePolicy::diagnose_only_without_reason());
476        self
477    }
478
479    /// Remove a rule from this set in place.
480    pub fn disable_mut(&mut self, rule: RuleKind) -> &mut Self {
481        self.policies.remove(&rule);
482        self
483    }
484
485    /// Change a rule's missing-input policy in place.
486    ///
487    /// The rule is enabled with [`RulePolicy::enforce`] if it was not already
488    /// configured.
489    pub fn with_unknown_policy_mut(&mut self, rule: RuleKind, unknown: UnknownPolicy) -> &mut Self {
490        self.policies
491            .entry(rule)
492            .or_insert_with(RulePolicy::enforce)
493            .unknown = unknown;
494        self
495    }
496
497    /// Return a copy with a permissively enforced rule.
498    pub fn enforce(mut self, rule: RuleKind) -> Self {
499        self.enforce_mut(rule);
500        self
501    }
502
503    /// Return a copy with a strictly enforced rule.
504    pub fn enforce_strict(mut self, rule: RuleKind) -> Self {
505        self.enforce_strict_mut(rule);
506        self
507    }
508
509    /// Return a copy with an explicit policy assigned to a rule.
510    pub fn set_policy(mut self, rule: RuleKind, policy: RulePolicy) -> Self {
511        self.set_policy_mut(rule, policy);
512        self
513    }
514
515    /// Return a copy which ignores a rule for the supplied reason.
516    pub fn ignore(mut self, rule: RuleKind, reason: impl Into<String>) -> Self {
517        self.ignore_mut(rule, reason);
518        self
519    }
520
521    /// Return a copy which ignores a rule without recording a reason.
522    pub fn ignore_without_reason(mut self, rule: RuleKind) -> Self {
523        self.ignore_without_reason_mut(rule);
524        self
525    }
526
527    /// Return a copy which evaluates a rule only for diagnostics.
528    pub fn diagnose_only(mut self, rule: RuleKind, reason: impl Into<String>) -> Self {
529        self.diagnose_only_mut(rule, reason);
530        self
531    }
532
533    /// Return a copy which evaluates a rule only for diagnostics, without a reason.
534    pub fn diagnose_only_without_reason(mut self, rule: RuleKind) -> Self {
535        self.diagnose_only_without_reason_mut(rule);
536        self
537    }
538
539    /// Return a copy with a rule removed.
540    pub fn disable(mut self, rule: RuleKind) -> Self {
541        self.disable_mut(rule);
542        self
543    }
544
545    /// Return a copy with the selected missing-input policy.
546    pub fn with_unknown_policy(mut self, rule: RuleKind, unknown: UnknownPolicy) -> Self {
547        self.with_unknown_policy_mut(rule, unknown);
548        self
549    }
550
551    /// Retrieve the configured policy for a rule.
552    pub fn policy(&self, rule: RuleKind) -> Option<&RulePolicy> {
553        self.policies.get(&rule)
554    }
555
556    /// Iterate over the configured rules in stable [`RuleKind`] order.
557    pub fn enabled_rules(&self) -> impl Iterator<Item = RuleKind> + '_ {
558        self.policies.keys().copied()
559    }
560
561    /// Return whether a two-body partial-wave candidate satisfies this rule set.
562    pub fn check(
563        &self,
564        parent: &ParticleProperties,
565        daughters: (&ParticleProperties, &ParticleProperties),
566        l: L,
567        s: S,
568    ) -> bool {
569        self.evaluate(parent, daughters, l, s).is_allowed()
570    }
571
572    /// Evaluate every configured rule and return a detailed report.
573    pub fn evaluate(
574        &self,
575        parent: &ParticleProperties,
576        daughters: (&ParticleProperties, &ParticleProperties),
577        l: L,
578        s: S,
579    ) -> RuleReport {
580        let mut checks = Vec::new();
581
582        for (&rule, policy) in &self.policies {
583            let raw = match rule {
584                RuleKind::Parity => check_parity_raw(parent, daughters, l),
585                RuleKind::Isospin => check_isospin_raw(parent, daughters),
586                RuleKind::IsospinProjection => check_isospin_projection_raw(parent, daughters),
587                RuleKind::CParity => check_c_parity_raw(parent, daughters, l, s),
588                RuleKind::GParity => check_g_parity_raw(parent, daughters),
589                RuleKind::Charge => check_additive_raw(
590                    "charge",
591                    "charge",
592                    "Q",
593                    parent.charge,
594                    daughters.0.charge,
595                    daughters.1.charge,
596                ),
597                RuleKind::Strangeness => check_additive_raw(
598                    "strangeness",
599                    "strangeness",
600                    "S",
601                    parent.strangeness,
602                    daughters.0.strangeness,
603                    daughters.1.strangeness,
604                ),
605                RuleKind::Charm => check_additive_raw(
606                    "charm",
607                    "charm",
608                    "C",
609                    parent.charm,
610                    daughters.0.charm,
611                    daughters.1.charm,
612                ),
613                RuleKind::Bottomness => check_additive_raw(
614                    "bottomness",
615                    "bottomness",
616                    "B'",
617                    parent.bottomness,
618                    daughters.0.bottomness,
619                    daughters.1.bottomness,
620                ),
621                RuleKind::Topness => check_additive_raw(
622                    "topness",
623                    "topness",
624                    "T",
625                    parent.topness,
626                    daughters.0.topness,
627                    daughters.1.topness,
628                ),
629                RuleKind::BaryonNumber => check_additive_raw(
630                    "baryon_number",
631                    "baryon number",
632                    "B",
633                    parent.baryon_number,
634                    daughters.0.baryon_number,
635                    daughters.1.baryon_number,
636                ),
637                RuleKind::ElectronLeptonNumber => check_additive_raw(
638                    "electron_lepton_number",
639                    "electron-family lepton number",
640                    "L_e",
641                    parent.electron_lepton_number,
642                    daughters.0.electron_lepton_number,
643                    daughters.1.electron_lepton_number,
644                ),
645                RuleKind::MuonLeptonNumber => check_additive_raw(
646                    "muon_lepton_number",
647                    "muon-family lepton number",
648                    "L_mu",
649                    parent.muon_lepton_number,
650                    daughters.0.muon_lepton_number,
651                    daughters.1.muon_lepton_number,
652                ),
653                RuleKind::TauLeptonNumber => check_additive_raw(
654                    "tau_lepton_number",
655                    "tau-family lepton number",
656                    "L_tau",
657                    parent.tau_lepton_number,
658                    daughters.0.tau_lepton_number,
659                    daughters.1.tau_lepton_number,
660                ),
661                RuleKind::LeptonNumber => check_total_lepton_number_raw(parent, daughters),
662                RuleKind::IdenticalParticleSymmetry => {
663                    check_identical_particle_symmetry_raw(daughters, l, s)
664                }
665                RuleKind::ConventionalMesonJpc => check_conventional_meson_jpc_raw(parent),
666            };
667
668            checks.push(apply_policy(rule, policy, raw));
669        }
670
671        RuleReport { checks }
672    }
673}
674
675#[derive(Clone, Debug, Eq, PartialEq)]
676enum RawRuleOutcome {
677    Pass {
678        message: String,
679    },
680    Fail {
681        message: String,
682    },
683    Unknown {
684        missing: Vec<String>,
685        message: String,
686    },
687}
688
689impl RawRuleOutcome {
690    fn pass(message: impl Into<String>) -> Self {
691        Self::Pass {
692            message: message.into(),
693        }
694    }
695
696    fn fail(message: impl Into<String>) -> Self {
697        Self::Fail {
698            message: message.into(),
699        }
700    }
701
702    fn unknown(missing: impl Into<Vec<String>>, message: impl Into<String>) -> Self {
703        Self::Unknown {
704            missing: missing.into(),
705            message: message.into(),
706        }
707    }
708
709    fn message(&self) -> String {
710        match self {
711            Self::Pass { message } => message.clone(),
712            Self::Fail { message } => message.clone(),
713            Self::Unknown { message, .. } => message.clone(),
714        }
715    }
716
717    fn passed(&self) -> Option<bool> {
718        match self {
719            Self::Pass { .. } => Some(true),
720            Self::Fail { .. } => Some(false),
721            Self::Unknown { .. } => None,
722        }
723    }
724}
725
726fn missing(fields: &[&'static str]) -> Vec<String> {
727    fields.iter().map(|field| (*field).to_string()).collect()
728}
729
730fn apply_policy(rule: RuleKind, policy: &RulePolicy, raw: RawRuleOutcome) -> RuleCheck {
731    let outcome = match &policy.mode {
732        RuleMode::Ignore { reason } => RuleOutcome::Ignored {
733            reason: reason.clone(),
734        },
735
736        RuleMode::DiagnoseOnly { reason } => RuleOutcome::Diagnostic {
737            passed: raw.passed(),
738            reason: reason.clone(),
739            message: raw.message(),
740        },
741
742        RuleMode::Enforce => match raw {
743            RawRuleOutcome::Pass { message } => RuleOutcome::Pass { message },
744
745            RawRuleOutcome::Fail { message } => RuleOutcome::Fail { message },
746
747            RawRuleOutcome::Unknown { missing, message } => match policy.unknown {
748                UnknownPolicy::Allow => RuleOutcome::UnknownAllowed { missing, message },
749                UnknownPolicy::Warn => RuleOutcome::Warning { missing, message },
750                UnknownPolicy::Reject => RuleOutcome::Fail {
751                    message: format!("{message}; unknown inputs are rejected by policy"),
752                },
753            },
754        },
755    };
756
757    RuleCheck { rule, outcome }
758}
759
760fn check_parity_raw(
761    parent: &ParticleProperties,
762    daughters: (&ParticleProperties, &ParticleProperties),
763    l: L,
764) -> RawRuleOutcome {
765    let Some(p_parent) = parent.parity else {
766        return RawRuleOutcome::unknown(missing(&["parent.parity"]), "parent parity is unknown");
767    };
768
769    let mut missing_fields = Vec::new();
770
771    if daughters.0.parity.is_none() {
772        missing_fields.push("daughter_a.parity".to_string());
773    }
774
775    if daughters.1.parity.is_none() {
776        missing_fields.push("daughter_b.parity".to_string());
777    }
778
779    if !missing_fields.is_empty() {
780        return RawRuleOutcome::unknown(
781            missing_fields,
782            "final-state parity cannot be inferred because one or both daughter parities are unknown",
783        );
784    }
785
786    let p_final = infer_parity(daughters, l).expect("daughter parities were checked above");
787
788    if p_parent == p_final {
789        RawRuleOutcome::pass(format!(
790            "parity is conserved for L = {} with final parity {:?}",
791            l.value(),
792            p_final,
793        ))
794    } else {
795        RawRuleOutcome::fail(format!(
796            "parity is not conserved for L = {}: parent parity is {:?}, final parity is {:?}",
797            l.value(),
798            p_parent,
799            p_final,
800        ))
801    }
802}
803
804fn check_isospin_raw(
805    parent: &ParticleProperties,
806    daughters: (&ParticleProperties, &ParticleProperties),
807) -> RawRuleOutcome {
808    let Some(i_parent) = parent.isospin else {
809        return RawRuleOutcome::unknown(missing(&["parent.isospin"]), "parent isospin is unknown");
810    };
811
812    let Some(i_a) = daughters.0.isospin else {
813        return RawRuleOutcome::unknown(
814            missing(&["daughter_a.isospin"]),
815            "first daughter isospin is unknown",
816        );
817    };
818
819    let Some(i_b) = daughters.1.isospin else {
820        return RawRuleOutcome::unknown(
821            missing(&["daughter_b.isospin"]),
822            "second daughter isospin is unknown",
823        );
824    };
825
826    if i_parent
827        .isospin()
828        .can_couple_to(i_a.isospin(), i_b.isospin())
829    {
830        RawRuleOutcome::pass("daughter isospins can couple to parent isospin")
831    } else {
832        RawRuleOutcome::fail("daughter isospins cannot couple to parent isospin")
833    }
834}
835
836fn check_isospin_projection_raw(
837    parent: &ParticleProperties,
838    daughters: (&ParticleProperties, &ParticleProperties),
839) -> RawRuleOutcome {
840    let Some(i_parent) = parent.isospin else {
841        return RawRuleOutcome::unknown(missing(&["parent.isospin"]), "parent isospin is unknown");
842    };
843
844    let Some(i_a) = daughters.0.isospin else {
845        return RawRuleOutcome::unknown(
846            missing(&["daughter_a.isospin"]),
847            "first daughter isospin is unknown",
848        );
849    };
850
851    let Some(i_b) = daughters.1.isospin else {
852        return RawRuleOutcome::unknown(
853            missing(&["daughter_b.isospin"]),
854            "second daughter isospin is unknown",
855        );
856    };
857
858    let Some(i3_parent) = i_parent.projection else {
859        return RawRuleOutcome::unknown(
860            missing(&["parent.isospin.projection"]),
861            "parent isospin projection is unknown",
862        );
863    };
864
865    let Some(i3_a) = i_a.projection else {
866        return RawRuleOutcome::unknown(
867            missing(&["daughter_a.isospin.projection"]),
868            "first daughter isospin projection is unknown",
869        );
870    };
871
872    let Some(i3_b) = i_b.projection else {
873        return RawRuleOutcome::unknown(
874            missing(&["daughter_b.isospin.projection"]),
875            "second daughter isospin projection is unknown",
876        );
877    };
878
879    if i3_parent.doubled() == i3_a.doubled() + i3_b.doubled() {
880        RawRuleOutcome::pass("isospin projection is conserved")
881    } else {
882        RawRuleOutcome::fail("isospin projection is not conserved")
883    }
884}
885
886fn check_c_parity_raw(
887    parent: &ParticleProperties,
888    daughters: (&ParticleProperties, &ParticleProperties),
889    l: L,
890    s: S,
891) -> RawRuleOutcome {
892    let Some(c_parent) = parent.c_parity else {
893        return RawRuleOutcome::unknown(
894            missing(&["parent.c_parity"]),
895            "parent C-parity is unknown or not applicable",
896        );
897    };
898
899    let Some(c_final) = infer_c_parity(daughters, l, s) else {
900        return RawRuleOutcome::unknown(
901            missing(&[
902                "daughter_a.species",
903                "daughter_a.antiparticle_species",
904                "daughter_b.species",
905                "daughter_b.antiparticle_species",
906            ]),
907            "final-state C-parity cannot be inferred; this check currently assumes a C-eigenstate particle-antiparticle combination",
908        );
909    };
910
911    if c_parent == c_final {
912        RawRuleOutcome::pass(format!(
913            "C-parity is conserved with inferred C_final = {:?}; assumes a C-eigenstate particle-antiparticle combination",
914            c_final,
915        ))
916    } else {
917        RawRuleOutcome::fail(format!(
918            "C-parity is not conserved: parent C = {:?}, inferred final C = {:?}; assumes a C-eigenstate particle-antiparticle combination",
919            c_parent, c_final,
920        ))
921    }
922}
923
924fn check_g_parity_raw(
925    parent: &ParticleProperties,
926    daughters: (&ParticleProperties, &ParticleProperties),
927) -> RawRuleOutcome {
928    let Some(g_parent) = parent.g_parity else {
929        return RawRuleOutcome::unknown(
930            missing(&["parent.g_parity"]),
931            "parent G-parity is unknown or not applicable",
932        );
933    };
934
935    let Some(g_a) = daughters.0.g_parity else {
936        return RawRuleOutcome::unknown(
937            missing(&["daughter_a.g_parity"]),
938            "first daughter G-parity is unknown or not applicable",
939        );
940    };
941
942    let Some(g_b) = daughters.1.g_parity else {
943        return RawRuleOutcome::unknown(
944            missing(&["daughter_b.g_parity"]),
945            "second daughter G-parity is unknown or not applicable",
946        );
947    };
948
949    let g_final = g_a.value() * g_b.value();
950
951    if g_parent.value() == g_final {
952        RawRuleOutcome::pass("G-parity product check passes")
953    } else {
954        RawRuleOutcome::fail(format!(
955            "G-parity product check fails: parent G = {:?}, daughter product = {}",
956            g_parent, g_final
957        ))
958    }
959}
960
961fn check_additive_raw(
962    field: &'static str,
963    display_name: &'static str,
964    symbol: &'static str,
965    parent: Option<i32>,
966    a: Option<i32>,
967    b: Option<i32>,
968) -> RawRuleOutcome {
969    match (parent, a, b) {
970        (Some(parent), Some(a), Some(b)) => {
971            let final_value = a + b;
972
973            if parent == final_value {
974                RawRuleOutcome::pass(format!("{display_name} is conserved"))
975            } else {
976                RawRuleOutcome::fail(format!(
977                    "{display_name} is not conserved: parent {symbol} = {parent}, final {symbol} = {final_value}",
978                ))
979            }
980        }
981
982        _ => {
983            let mut missing_fields = Vec::new();
984
985            if parent.is_none() {
986                missing_fields.push(format!("parent.{field}"));
987            }
988            if a.is_none() {
989                missing_fields.push(format!("daughter_a.{field}"));
990            }
991            if b.is_none() {
992                missing_fields.push(format!("daughter_b.{field}"));
993            }
994
995            RawRuleOutcome::unknown(
996                missing_fields,
997                format!("{display_name} cannot be checked because required values are unknown"),
998            )
999        }
1000    }
1001}
1002
1003fn check_total_lepton_number_raw(
1004    parent: &ParticleProperties,
1005    daughters: (&ParticleProperties, &ParticleProperties),
1006) -> RawRuleOutcome {
1007    let values = [
1008        (
1009            "parent.electron_lepton_number",
1010            parent.electron_lepton_number,
1011        ),
1012        ("parent.muon_lepton_number", parent.muon_lepton_number),
1013        ("parent.tau_lepton_number", parent.tau_lepton_number),
1014        (
1015            "daughter_a.electron_lepton_number",
1016            daughters.0.electron_lepton_number,
1017        ),
1018        (
1019            "daughter_a.muon_lepton_number",
1020            daughters.0.muon_lepton_number,
1021        ),
1022        (
1023            "daughter_a.tau_lepton_number",
1024            daughters.0.tau_lepton_number,
1025        ),
1026        (
1027            "daughter_b.electron_lepton_number",
1028            daughters.1.electron_lepton_number,
1029        ),
1030        (
1031            "daughter_b.muon_lepton_number",
1032            daughters.1.muon_lepton_number,
1033        ),
1034        (
1035            "daughter_b.tau_lepton_number",
1036            daughters.1.tau_lepton_number,
1037        ),
1038    ];
1039
1040    let missing_fields: Vec<String> = values
1041        .iter()
1042        .filter_map(|(name, value)| {
1043            if value.is_none() {
1044                Some((*name).to_string())
1045            } else {
1046                None
1047            }
1048        })
1049        .collect();
1050
1051    if !missing_fields.is_empty() {
1052        return RawRuleOutcome::unknown(
1053            missing_fields,
1054            "total lepton number cannot be checked because required values are unknown",
1055        );
1056    }
1057
1058    let parent_total = parent.electron_lepton_number.unwrap()
1059        + parent.muon_lepton_number.unwrap()
1060        + parent.tau_lepton_number.unwrap();
1061
1062    let daughter_total = daughters.0.electron_lepton_number.unwrap()
1063        + daughters.0.muon_lepton_number.unwrap()
1064        + daughters.0.tau_lepton_number.unwrap()
1065        + daughters.1.electron_lepton_number.unwrap()
1066        + daughters.1.muon_lepton_number.unwrap()
1067        + daughters.1.tau_lepton_number.unwrap();
1068
1069    if parent_total == daughter_total {
1070        RawRuleOutcome::pass("total lepton number is conserved")
1071    } else {
1072        RawRuleOutcome::fail(format!(
1073            "total lepton number is not conserved: parent L = {parent_total}, final L = {daughter_total}",
1074        ))
1075    }
1076}
1077
1078fn check_identical_particle_symmetry_raw(
1079    daughters: (&ParticleProperties, &ParticleProperties),
1080    l: L,
1081    s: S,
1082) -> RawRuleOutcome {
1083    let Some(species_a) = daughters.0.species.as_ref() else {
1084        return RawRuleOutcome::unknown(
1085            missing(&["daughter_a.species"]),
1086            "first daughter species is unknown",
1087        );
1088    };
1089
1090    let Some(species_b) = daughters.1.species.as_ref() else {
1091        return RawRuleOutcome::unknown(
1092            missing(&["daughter_b.species"]),
1093            "second daughter species is unknown",
1094        );
1095    };
1096
1097    if species_a != species_b {
1098        return RawRuleOutcome::pass("daughters are not identical particles");
1099    }
1100
1101    let Some(stats_a) = daughters.0.statistics else {
1102        return RawRuleOutcome::unknown(
1103            missing(&["daughter_a.statistics"]),
1104            "first daughter statistics are unknown",
1105        );
1106    };
1107
1108    let Some(stats_b) = daughters.1.statistics else {
1109        return RawRuleOutcome::unknown(
1110            missing(&["daughter_b.statistics"]),
1111            "second daughter statistics are unknown",
1112        );
1113    };
1114
1115    if stats_a != stats_b {
1116        return RawRuleOutcome::fail(
1117            "identical particles have inconsistent statistics assignments",
1118        );
1119    }
1120
1121    let Some(ja) = daughters.0.spin else {
1122        return RawRuleOutcome::unknown(
1123            missing(&["daughter_a.spin"]),
1124            "first daughter spin is unknown",
1125        );
1126    };
1127
1128    let Some(jb) = daughters.1.spin else {
1129        return RawRuleOutcome::unknown(
1130            missing(&["daughter_b.spin"]),
1131            "second daughter spin is unknown",
1132        );
1133    };
1134
1135    if ja != jb {
1136        return RawRuleOutcome::fail("identical particles have inconsistent spin assignments");
1137    }
1138
1139    if !s.doubled().is_multiple_of(2) {
1140        return RawRuleOutcome::fail(
1141            "two identical particles cannot couple to half-integer total spin",
1142        );
1143    }
1144
1145    let s_integer = s.doubled() / 2;
1146
1147    if s_integer > ja.doubled() {
1148        return RawRuleOutcome::fail(
1149            "coupled spin is incompatible with two identical daughter spins",
1150        );
1151    }
1152
1153    // For two identical spin-j particles, the spin-coupled state has exchange
1154    // phase (-1)^(2j - S). The spatial wave contributes (-1)^L.
1155    //
1156    // Total exchange phase must be +1 for bosons and -1 for fermions.
1157    let exchange_exponent = l.value() + ja.doubled() - s_integer;
1158    let exchange_is_symmetric = exchange_exponent.is_multiple_of(2);
1159
1160    let allowed = match stats_a {
1161        Statistics::Boson => exchange_is_symmetric,
1162        Statistics::Fermion => !exchange_is_symmetric,
1163    };
1164
1165    if allowed {
1166        RawRuleOutcome::pass("identical-particle exchange symmetry is satisfied")
1167    } else {
1168        RawRuleOutcome::fail("identical-particle exchange symmetry is violated")
1169    }
1170}
1171
1172fn check_conventional_meson_jpc_raw(parent: &ParticleProperties) -> RawRuleOutcome {
1173    let Some(j) = parent.spin else {
1174        return RawRuleOutcome::unknown(missing(&["parent.spin"]), "parent spin is unknown");
1175    };
1176
1177    let Some(p) = parent.parity else {
1178        return RawRuleOutcome::unknown(missing(&["parent.parity"]), "parent parity is unknown");
1179    };
1180
1181    let Some(c) = parent.c_parity else {
1182        return RawRuleOutcome::unknown(
1183            missing(&["parent.c_parity"]),
1184            "parent C-parity is unknown or not applicable",
1185        );
1186    };
1187
1188    if !j.doubled().is_multiple_of(2) {
1189        return RawRuleOutcome::fail(
1190            "half-integer J is not compatible with a conventional meson assignment",
1191        );
1192    }
1193
1194    let target_j = j.doubled();
1195
1196    // Conventional q qbar mesons have quark spin S = 0 or S = 1.
1197    //
1198    // P = (-1)^(L + 1)
1199    // C = (-1)^(L + S)
1200    //
1201    // For a fixed J and S:
1202    // - S = 0 implies J = L.
1203    // - S = 1 implies J in {|L - 1|, ..., L + 1}.
1204    //
1205    // Searching up to J + 1 is enough for S = 1.
1206    let max_l = target_j / 2 + 1;
1207
1208    for l_raw in 0..=max_l {
1209        for s_raw in [0u32, 1u32] {
1210            let l_doubled = 2 * l_raw;
1211            let s_doubled = 2 * s_raw;
1212
1213            let min_j = l_doubled.abs_diff(s_doubled);
1214            let max_j = l_doubled + s_doubled;
1215
1216            let angular_ok = target_j >= min_j && target_j <= max_j;
1217            let parity_ok = p == L::int(l_raw + 1).orbital_parity();
1218            let c_ok = c == L::int(l_raw + s_raw).orbital_parity();
1219
1220            if angular_ok && parity_ok && c_ok {
1221                return RawRuleOutcome::pass(
1222                    "J^PC is compatible with a conventional q qbar meson assignment",
1223                );
1224            }
1225        }
1226    }
1227
1228    RawRuleOutcome::fail(format!(
1229        "J^PC = {}{:?}{:?} is exotic for a conventional q qbar meson assignment",
1230        j, p, c,
1231    ))
1232}
1233
1234/// A partial wave defined by a total angular momentum, `J`, an orbital angular momentum, `L`, and
1235/// and intrinsic spin, `S`.
1236#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
1237pub struct PartialWave {
1238    /// The total angular momentum of the wave
1239    pub j: J,
1240    /// The orbital angular momentum of the wave
1241    pub l: L,
1242    /// The spin of the wave
1243    pub s: S,
1244}
1245impl PartialWave {
1246    /// Construct a new partial wave from the given angular momentum quantum numbers.
1247    ///
1248    /// # Errors
1249    ///
1250    /// Returns [`LadduPhysicsError`] when `j`, `l`, and `s` violate angular
1251    /// momentum coupling rules.
1252    pub fn new(j: J, l: L, s: S) -> LadduPhysicsResult<Self> {
1253        PartialWave::validate_coupling(j, l, s)?;
1254        Ok(Self { j, l, s })
1255    }
1256    /// Get the spectroscopic label for the wave in the form {2s+1}{l}{j} where l is represented by
1257    /// its spectroscopic letter equivalent (`S` for `0`, `P` for `1`, etc.).
1258    pub fn label(&self) -> String {
1259        let multiplicity = self.s.doubled() + 1;
1260        format!("{}{}{}", multiplicity, self.l, self.j)
1261    }
1262    /// Validate the set of angular momentum quantum numbers which define a partial wave.
1263    ///
1264    /// # Errors
1265    ///
1266    /// Returns [`LadduPhysicsError`] when `j` lies outside the range permitted
1267    /// by `l` and `s` or has incompatible integer/half-integer parity.
1268    pub fn validate_coupling(j: J, l: L, s: S) -> LadduPhysicsResult<()> {
1269        let l_twice = 2 * l.value();
1270        let s_twice = s.doubled();
1271        let j_twice = j.doubled();
1272        let min = l_twice.abs_diff(s_twice);
1273        let max = l_twice + s_twice;
1274        if j_twice >= min && j_twice <= max && (j_twice - min).is_multiple_of(2) {
1275            Ok(())
1276        } else {
1277            Err(LadduPhysicsError::invalid_relation(
1278                "j, l, and s must be compatible",
1279            ))
1280        }
1281    }
1282}
1283
1284impl Display for PartialWave {
1285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1286        write!(f, "{}", self.label())
1287    }
1288}
1289
1290/// A partial wave together with allowed parity and C-parity, if applicable.
1291#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
1292pub struct AllowedPartialWave {
1293    /// The angular quantum numbers of the wave
1294    pub wave: PartialWave,
1295    /// The allowed parity, if applicable
1296    pub parity: Option<Parity>,
1297    /// The allowed C-parity, if applicable
1298    pub c_parity: Option<Parity>,
1299}
1300
1301impl AllowedPartialWave {
1302    /// Take an existing [`PartialWave`] and infer parity and C-parity from its decay products.
1303    pub fn new(wave: PartialWave, daughters: (&ParticleProperties, &ParticleProperties)) -> Self {
1304        Self {
1305            parity: infer_parity(daughters, wave.l),
1306            c_parity: infer_c_parity(daughters, wave.l, wave.s),
1307            wave,
1308        }
1309    }
1310}
1311
1312fn infer_parity(daughters: (&ParticleProperties, &ParticleProperties), l: L) -> Option<Parity> {
1313    Some(daughters.0.parity? * daughters.1.parity? * l.orbital_parity())
1314}
1315
1316fn infer_c_parity(
1317    daughters: (&ParticleProperties, &ParticleProperties),
1318    l: L,
1319    s: S,
1320) -> Option<Parity> {
1321    daughters.0.is_antiparticle_of(daughters.1).then_some(())?;
1322    let s_doubled = s.doubled();
1323    if !s_doubled.is_multiple_of(2) {
1324        return None;
1325    }
1326    Some(L::int(l.value() + (s_doubled / 2)).orbital_parity())
1327}
1328
1329#[derive(Clone, Debug, Eq, PartialEq)]
1330/// A generated partial-wave candidate together with its inferred properties and
1331/// selection-rule report.
1332pub struct PartialWaveCandidate {
1333    /// Angular quantum numbers of the candidate.
1334    pub wave: PartialWave,
1335    /// Candidate wave plus its channel-dependent inferred parity values.
1336    pub inferred: AllowedPartialWave,
1337    /// Detailed outcomes from the configured rules.
1338    pub report: RuleReport,
1339}
1340
1341impl PartialWaveCandidate {
1342    /// Return whether the candidate passed every enforced rule.
1343    pub fn is_allowed(&self) -> bool {
1344        self.report.is_allowed()
1345    }
1346}
1347
1348#[derive(Clone, Debug, Eq, PartialEq, Default)]
1349/// Complete result of scanning a two-body channel for partial waves.
1350pub struct PartialWaveScan {
1351    /// All generated candidates, including rejected waves.
1352    pub candidates: Vec<PartialWaveCandidate>,
1353    /// Required properties which prevented candidate generation.
1354    pub missing_inputs: Vec<String>,
1355}
1356
1357impl PartialWaveScan {
1358    /// Iterate over the inferred properties of accepted waves.
1359    pub fn allowed(&self) -> impl Iterator<Item = &AllowedPartialWave> {
1360        self.candidates
1361            .iter()
1362            .filter(|candidate| candidate.is_allowed())
1363            .map(|candidate| &candidate.inferred)
1364    }
1365
1366    /// Iterate over candidates rejected by at least one enforced rule.
1367    pub fn rejected(&self) -> impl Iterator<Item = &PartialWaveCandidate> {
1368        self.candidates
1369            .iter()
1370            .filter(|candidate| !candidate.is_allowed())
1371    }
1372
1373    /// Consume the scan and collect its accepted waves.
1374    pub fn into_allowed(self) -> Vec<AllowedPartialWave> {
1375        self.candidates
1376            .into_iter()
1377            .filter_map(|candidate| {
1378                if candidate.is_allowed() {
1379                    Some(candidate.inferred)
1380                } else {
1381                    None
1382                }
1383            })
1384            .collect()
1385    }
1386}
1387
1388/// Configuration for generating and filtering allowed two-body partial waves.
1389///
1390/// `SelectionRules` combines a maximum orbital angular momentum with a
1391/// [`RuleSet`]. Candidate waves are generated from angular-momentum coupling
1392/// and are then filtered by the enabled rules.
1393///
1394/// The generated waves satisfy
1395/// $`S \in |j_a - j_b|, \ldots, j_a + j_b`$
1396/// and
1397/// $`J \in |L - S|, \ldots, L + S`$,
1398/// with $`0 \le L \le L_\text{max}`$.
1399#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1400pub struct SelectionRules {
1401    /// Conservation and symmetry rules used to filter candidate waves.
1402    ///
1403    /// Angular-momentum compatibility is handled by
1404    /// [`SelectionRules::allowed_partial_waves`]. The [`RuleSet`] applies
1405    /// additional checks such as parity, charge, isospin, flavor quantum
1406    /// numbers, $`C`$-parity, $`G`$-parity, and identical-particle symmetry.
1407    pub rules: RuleSet,
1408    /// Maximum orbital angular momentum $`L_\text{max}`$ considered when
1409    /// generating candidate partial waves.
1410    ///
1411    /// The solver scans all integer values
1412    /// $`L = 0, 1, \ldots, L_\text{max}`$.
1413    pub max_l: L,
1414}
1415
1416impl Default for SelectionRules {
1417    fn default() -> Self {
1418        Self::strong(L::int(6))
1419    }
1420}
1421
1422impl SelectionRules {
1423    /// Construct a partial-wave scanner from a rule set and maximum orbital
1424    /// angular momentum.
1425    pub fn new(rules: RuleSet, max_l: L) -> Self {
1426        Self { rules, max_l }
1427    }
1428
1429    /// Construct a scanner which applies only angular-momentum coupling.
1430    pub fn angular(max_l: L) -> Self {
1431        Self::new(RuleSet::angular(), max_l)
1432    }
1433
1434    /// Construct a scanner configured for electromagnetic decays.
1435    pub fn electromagnetic(max_l: L) -> Self {
1436        Self::new(RuleSet::electromagnetic(), max_l)
1437    }
1438
1439    /// Construct a scanner configured for weak decays.
1440    pub fn weak(max_l: L) -> Self {
1441        Self::new(RuleSet::weak(), max_l)
1442    }
1443
1444    /// Construct a scanner configured for strong decays.
1445    pub fn strong(max_l: L) -> Self {
1446        Self::new(RuleSet::strong(), max_l)
1447    }
1448    /// Return all possible coupled total spins from two daughter spins.
1449    ///
1450    /// Given daughter spins $`j_a`$ and $`j_b`$, this returns
1451    /// $`S = |j_a - j_b|, |j_a - j_b| + 1, \ldots, j_a + j_b`$.
1452    ///
1453    /// Internally angular momenta are stored as doubled values, so the returned
1454    /// sequence advances by two in the doubled representation.
1455    pub fn coupled_spins(a: J, b: J) -> Vec<S> {
1456        a.coupled_with(b)
1457    }
1458
1459    /// Generate all candidates and retain detailed reports for accepted and
1460    /// rejected waves.
1461    pub fn scan_partial_waves(
1462        &self,
1463        parent: &ParticleProperties,
1464        daughters: (&ParticleProperties, &ParticleProperties),
1465    ) -> PartialWaveScan {
1466        let mut missing_inputs = Vec::new();
1467
1468        let Some(parent_j) = parent.spin else {
1469            missing_inputs.push("parent.spin".to_string());
1470            return PartialWaveScan {
1471                candidates: Vec::new(),
1472                missing_inputs,
1473            };
1474        };
1475
1476        let Some(ja) = daughters.0.spin else {
1477            missing_inputs.push("daughter_a.spin".to_string());
1478            return PartialWaveScan {
1479                candidates: Vec::new(),
1480                missing_inputs,
1481            };
1482        };
1483
1484        let Some(jb) = daughters.1.spin else {
1485            missing_inputs.push("daughter_b.spin".to_string());
1486            return PartialWaveScan {
1487                candidates: Vec::new(),
1488                missing_inputs,
1489            };
1490        };
1491
1492        let mut candidates = Vec::new();
1493
1494        for s in Self::coupled_spins(ja, jb) {
1495            for l_raw in 0..=self.max_l.value() {
1496                let l = L::int(l_raw);
1497
1498                let Ok(wave) = PartialWave::new(parent_j, l, s) else {
1499                    continue;
1500                };
1501
1502                let report = self.rules.evaluate(parent, daughters, l, s);
1503                let inferred = AllowedPartialWave::new(wave, daughters);
1504
1505                candidates.push(PartialWaveCandidate {
1506                    wave,
1507                    inferred,
1508                    report,
1509                });
1510            }
1511        }
1512
1513        PartialWaveScan {
1514            candidates,
1515            missing_inputs,
1516        }
1517    }
1518
1519    /// Generate all allowed two-body partial waves for a parent and two
1520    /// daughters.
1521    ///
1522    /// The parent spin is interpreted as the total angular momentum $`J`$ of
1523    /// the resonance. The daughter spins are coupled to possible total-spin
1524    /// values $`S`$, and each $`S`$ is combined with orbital angular momenta
1525    /// $`L = 0, 1, \ldots, L_\text{max}`$.
1526    ///
1527    /// A candidate wave is kept when:
1528    ///
1529    /// 1. $`L`$ and $`S`$ can couple to the parent $`J`$.
1530    /// 2. The enabled [`RuleSet`] checks do not reject it.
1531    ///
1532    /// Returns an empty vector if the parent spin or either daughter spin is
1533    /// unknown.
1534    ///
1535    /// The returned [`AllowedPartialWave`] includes the underlying
1536    /// [`PartialWave`] together with channel-dependent inferred quantum numbers,
1537    /// such as final-state parity and, when meaningful, $`C`$-parity.
1538    pub fn allowed_partial_waves(
1539        &self,
1540        parent: &ParticleProperties,
1541        daughters: (&ParticleProperties, &ParticleProperties),
1542    ) -> Vec<AllowedPartialWave> {
1543        self.scan_partial_waves(parent, daughters).into_allowed()
1544    }
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549    use super::*;
1550    use crate::{
1551        j, l, m,
1552        quantum::{Isospin, M},
1553    };
1554
1555    fn labels(waves: &[AllowedPartialWave]) -> Vec<String> {
1556        waves.iter().map(|w| w.wave.label()).collect()
1557    }
1558
1559    fn allowed_labels<'a>(waves: impl Iterator<Item = &'a AllowedPartialWave>) -> Vec<String> {
1560        waves.map(|w| w.wave.label()).collect()
1561    }
1562
1563    fn candidate_labels<'a>(
1564        candidates: impl Iterator<Item = &'a PartialWaveCandidate>,
1565    ) -> Vec<String> {
1566        candidates.map(|candidate| candidate.wave.label()).collect()
1567    }
1568
1569    fn outcome(report: &RuleReport, rule: RuleKind) -> &RuleOutcome {
1570        report
1571            .outcome(rule)
1572            .unwrap_or_else(|| panic!("missing outcome for {rule:?}; report was {report:#?}"))
1573    }
1574
1575    fn assert_pass(report: &RuleReport, rule: RuleKind) {
1576        assert!(
1577            matches!(outcome(report, rule), RuleOutcome::Pass { .. }),
1578            "expected {rule:?} to pass; got {:#?}",
1579            outcome(report, rule)
1580        );
1581    }
1582
1583    fn assert_fail(report: &RuleReport, rule: RuleKind) {
1584        assert!(
1585            matches!(outcome(report, rule), RuleOutcome::Fail { .. }),
1586            "expected {rule:?} to fail; got {:#?}",
1587            outcome(report, rule)
1588        );
1589    }
1590
1591    fn assert_unknown_allowed(report: &RuleReport, rule: RuleKind, expected_missing: &[&str]) {
1592        match outcome(report, rule) {
1593            RuleOutcome::UnknownAllowed { missing, .. } => {
1594                for field in expected_missing {
1595                    assert!(
1596                        missing.iter().any(|missing| missing == field),
1597                        "expected missing field {field:?}; got {missing:?}"
1598                    );
1599                }
1600            }
1601            other => panic!("expected {rule:?} to be UnknownAllowed; got {other:#?}"),
1602        }
1603    }
1604
1605    fn assert_warning(report: &RuleReport, rule: RuleKind, expected_missing: &[&str]) {
1606        match outcome(report, rule) {
1607            RuleOutcome::Warning { missing, .. } => {
1608                for field in expected_missing {
1609                    assert!(
1610                        missing.iter().any(|missing| missing == field),
1611                        "expected missing field {field:?}; got {missing:?}"
1612                    );
1613                }
1614            }
1615            other => panic!("expected {rule:?} to be Warning; got {other:#?}"),
1616        }
1617    }
1618
1619    fn assert_ignored(report: &RuleReport, rule: RuleKind, expected_reason: Option<&str>) {
1620        match outcome(report, rule) {
1621            RuleOutcome::Ignored { reason } => {
1622                assert_eq!(reason.as_deref(), expected_reason);
1623            }
1624            other => panic!("expected {rule:?} to be Ignored; got {other:#?}"),
1625        }
1626    }
1627
1628    fn assert_diagnostic(
1629        report: &RuleReport,
1630        rule: RuleKind,
1631        expected_passed: Option<bool>,
1632        expected_reason: Option<&str>,
1633    ) {
1634        match outcome(report, rule) {
1635            RuleOutcome::Diagnostic { passed, reason, .. } => {
1636                assert_eq!(*passed, expected_passed);
1637                assert_eq!(reason.as_deref(), expected_reason);
1638            }
1639            other => panic!("expected {rule:?} to be Diagnostic; got {other:#?}"),
1640        }
1641    }
1642
1643    #[allow(clippy::too_many_arguments)]
1644    fn add_additives(
1645        particle: ParticleProperties,
1646        charge: i32,
1647        strangeness: i32,
1648        charm: i32,
1649        bottomness: i32,
1650        topness: i32,
1651        baryon_number: i32,
1652        electron_lepton_number: i32,
1653        muon_lepton_number: i32,
1654        tau_lepton_number: i32,
1655    ) -> ParticleProperties {
1656        particle
1657            .with_charge(charge)
1658            .with_strangeness(strangeness)
1659            .unwrap()
1660            .with_charm(charm)
1661            .unwrap()
1662            .with_bottomness(bottomness)
1663            .unwrap()
1664            .with_topness(topness)
1665            .unwrap()
1666            .with_baryon_number(baryon_number)
1667            .unwrap()
1668            .with_electron_lepton_number(electron_lepton_number)
1669            .unwrap()
1670            .with_muon_lepton_number(muon_lepton_number)
1671            .unwrap()
1672            .with_tau_lepton_number(tau_lepton_number)
1673            .unwrap()
1674    }
1675
1676    fn pion_like(name: &str, anti_name: &str, charge: i32, i3: i32) -> ParticleProperties {
1677        ParticleProperties::meson()
1678            .with_zero_flavor()
1679            .with_name(name)
1680            .with_species_names(name, anti_name)
1681            .unwrap()
1682            .with_spin(j!(0))
1683            .with_parity(Parity::Negative)
1684            .with_charge(charge)
1685            .with_isospin(Isospin::new(j!(1), Some(M::int(i3))).unwrap())
1686            .with_g_parity(Parity::Negative)
1687            .with_statistics(Statistics::Boson)
1688            .unwrap()
1689    }
1690
1691    fn rho_like() -> ParticleProperties {
1692        ParticleProperties::meson()
1693            .with_zero_flavor()
1694            .with_name("rho0")
1695            .with_self_conjugate_species("rho0")
1696            .unwrap()
1697            .with_spin(j!(1))
1698            .with_parity(Parity::Negative)
1699            .with_c_parity(Parity::Negative)
1700            .unwrap()
1701            .with_charge(0)
1702            .with_isospin(Isospin::new(j!(1), Some(m!(0))).unwrap())
1703            .with_g_parity(Parity::Positive)
1704            .with_statistics(Statistics::Boson)
1705            .unwrap()
1706    }
1707
1708    fn exotic_one_minus_plus() -> ParticleProperties {
1709        ParticleProperties::meson()
1710            .with_zero_flavor()
1711            .with_name("pi1_exotic")
1712            .with_self_conjugate_species("pi1_exotic")
1713            .unwrap()
1714            .with_spin(j!(1))
1715            .with_parity(Parity::Negative)
1716            .with_c_parity(Parity::Positive)
1717            .unwrap()
1718            .with_charge(0)
1719            .with_isospin(Isospin::new(j!(1), Some(m!(0))).unwrap())
1720            .with_statistics(Statistics::Boson)
1721            .unwrap()
1722    }
1723
1724    fn identical_boson(spin: J, species: &str) -> ParticleProperties {
1725        ParticleProperties::unknown()
1726            .with_spin(spin)
1727            .with_species(species)
1728            .unwrap()
1729            .with_statistics(Statistics::Boson)
1730            .unwrap()
1731    }
1732
1733    fn identical_fermion(spin: J, species: &str) -> ParticleProperties {
1734        ParticleProperties::unknown()
1735            .with_spin(spin)
1736            .with_species(species)
1737            .unwrap()
1738            .with_statistics(Statistics::Fermion)
1739            .unwrap()
1740    }
1741
1742    #[test]
1743    fn rule_set_constructors_build_expected_default_policies() {
1744        let angular = RuleSet::angular();
1745        assert_eq!(angular.enabled_rules().count(), 0);
1746
1747        let strong = RuleSet::strong();
1748        for rule in [
1749            RuleKind::Parity,
1750            RuleKind::Isospin,
1751            RuleKind::IsospinProjection,
1752            RuleKind::Charge,
1753            RuleKind::Strangeness,
1754            RuleKind::Charm,
1755            RuleKind::Bottomness,
1756            RuleKind::Topness,
1757            RuleKind::BaryonNumber,
1758            RuleKind::IdenticalParticleSymmetry,
1759        ] {
1760            assert!(
1761                matches!(strong.policy(rule).unwrap().mode, RuleMode::Enforce),
1762                "strong rules should enforce {rule:?}"
1763            );
1764        }
1765        assert!(strong.policy(RuleKind::CParity).is_none());
1766        assert!(strong.policy(RuleKind::GParity).is_none());
1767        assert!(strong.policy(RuleKind::ConventionalMesonJpc).is_none());
1768
1769        let electromagnetic = RuleSet::electromagnetic();
1770        assert!(electromagnetic.policy(RuleKind::Parity).is_some());
1771        assert!(electromagnetic.policy(RuleKind::Charge).is_some());
1772        assert!(
1773            electromagnetic
1774                .policy(RuleKind::IsospinProjection)
1775                .is_some()
1776        );
1777        assert!(electromagnetic.policy(RuleKind::Isospin).is_none());
1778
1779        let weak = RuleSet::weak();
1780        assert!(weak.policy(RuleKind::Charge).is_some());
1781        assert!(weak.policy(RuleKind::BaryonNumber).is_some());
1782        assert!(weak.policy(RuleKind::ElectronLeptonNumber).is_some());
1783        assert!(weak.policy(RuleKind::MuonLeptonNumber).is_some());
1784        assert!(weak.policy(RuleKind::TauLeptonNumber).is_some());
1785        assert!(weak.policy(RuleKind::LeptonNumber).is_some());
1786        assert!(weak.policy(RuleKind::Parity).is_none());
1787        assert!(weak.policy(RuleKind::Strangeness).is_none());
1788    }
1789
1790    #[test]
1791    fn rule_set_builder_and_mut_methods_configure_the_same_policies() {
1792        let built = RuleSet::angular()
1793            .enforce(RuleKind::Parity)
1794            .enforce_strict(RuleKind::Charge)
1795            .set_policy(
1796                RuleKind::Strangeness,
1797                RulePolicy::enforce_with_unknown_policy(UnknownPolicy::Warn),
1798            )
1799            .ignore(RuleKind::Isospin, "intentional isospin violation")
1800            .ignore_without_reason(RuleKind::GParity)
1801            .diagnose_only(
1802                RuleKind::ConventionalMesonJpc,
1803                "classify exotics without rejecting them",
1804            )
1805            .diagnose_only_without_reason(RuleKind::CParity)
1806            .with_unknown_policy(RuleKind::Bottomness, UnknownPolicy::Reject)
1807            .disable(RuleKind::CParity);
1808
1809        let mut mutated = RuleSet::angular();
1810        mutated
1811            .enforce_mut(RuleKind::Parity)
1812            .enforce_strict_mut(RuleKind::Charge)
1813            .set_policy_mut(
1814                RuleKind::Strangeness,
1815                RulePolicy::enforce_with_unknown_policy(UnknownPolicy::Warn),
1816            )
1817            .ignore_mut(RuleKind::Isospin, "intentional isospin violation")
1818            .ignore_without_reason_mut(RuleKind::GParity)
1819            .diagnose_only_mut(
1820                RuleKind::ConventionalMesonJpc,
1821                "classify exotics without rejecting them",
1822            )
1823            .diagnose_only_without_reason_mut(RuleKind::CParity)
1824            .with_unknown_policy_mut(RuleKind::Bottomness, UnknownPolicy::Reject)
1825            .disable_mut(RuleKind::CParity);
1826
1827        assert_eq!(built, mutated);
1828
1829        assert!(matches!(
1830            built.policy(RuleKind::Parity).unwrap().mode,
1831            RuleMode::Enforce
1832        ));
1833        assert_eq!(
1834            built.policy(RuleKind::Charge).unwrap().unknown,
1835            UnknownPolicy::Reject
1836        );
1837        assert_eq!(
1838            built.policy(RuleKind::Strangeness).unwrap().unknown,
1839            UnknownPolicy::Warn
1840        );
1841        assert!(matches!(
1842            built.policy(RuleKind::Isospin).unwrap().mode,
1843            RuleMode::Ignore { reason: Some(_) }
1844        ));
1845        assert!(matches!(
1846            built.policy(RuleKind::GParity).unwrap().mode,
1847            RuleMode::Ignore { reason: None }
1848        ));
1849        assert!(matches!(
1850            built.policy(RuleKind::ConventionalMesonJpc).unwrap().mode,
1851            RuleMode::DiagnoseOnly { reason: Some(_) }
1852        ));
1853        assert_eq!(
1854            built.policy(RuleKind::Bottomness).unwrap().unknown,
1855            UnknownPolicy::Reject
1856        );
1857        assert!(built.policy(RuleKind::CParity).is_none());
1858    }
1859
1860    #[test]
1861    fn policy_application_distinguishes_unknown_allowed_warning_reject_ignore_and_diagnostic() {
1862        let parent = ParticleProperties::unknown().with_spin(j!(0));
1863        let a = ParticleProperties::unknown().with_spin(j!(0));
1864        let b = ParticleProperties::unknown().with_spin(j!(0));
1865
1866        let rules = RuleSet::angular()
1867            .enforce(RuleKind::Parity)
1868            .set_policy(
1869                RuleKind::Charge,
1870                RulePolicy::enforce_with_unknown_policy(UnknownPolicy::Warn),
1871            )
1872            .enforce_strict(RuleKind::Strangeness)
1873            .ignore(RuleKind::Isospin, "not relevant for this model")
1874            .diagnose_only(
1875                RuleKind::ConventionalMesonJpc,
1876                "only classify the parent assignment",
1877            );
1878
1879        let report = rules.evaluate(&parent, (&a, &b), l!(0), j!(0));
1880
1881        assert!(!report.is_allowed());
1882        assert_eq!(report.len(), 5);
1883        assert!(report.has_failures());
1884        assert!(report.has_unknowns());
1885        assert!(report.has_ignored());
1886        assert_eq!(report.failures().count(), 1);
1887        assert_eq!(report.warnings().count(), 1);
1888        assert_eq!(report.unknowns().count(), 1);
1889        assert_eq!(report.ignored().count(), 1);
1890
1891        assert_unknown_allowed(&report, RuleKind::Parity, &["parent.parity"]);
1892        assert_warning(
1893            &report,
1894            RuleKind::Charge,
1895            &["parent.charge", "daughter_a.charge", "daughter_b.charge"],
1896        );
1897        assert_fail(&report, RuleKind::Strangeness);
1898        assert_ignored(
1899            &report,
1900            RuleKind::Isospin,
1901            Some("not relevant for this model"),
1902        );
1903        assert_diagnostic(
1904            &report,
1905            RuleKind::ConventionalMesonJpc,
1906            None,
1907            Some("only classify the parent assignment"),
1908        );
1909    }
1910
1911    #[test]
1912    fn angular_momentum_helpers_partial_wave_validation_and_inference_work_together() {
1913        assert_eq!(
1914            SelectionRules::coupled_spins(j!(1 / 2), j!(1 / 2)),
1915            vec![j!(0), j!(1)]
1916        );
1917        assert_eq!(
1918            SelectionRules::coupled_spins(j!(1 / 2), j!(1)),
1919            vec![j!(1 / 2), j!(3 / 2)]
1920        );
1921        assert_eq!(
1922            SelectionRules::coupled_spins(j!(1), j!(1)),
1923            vec![j!(0), j!(1), j!(2)]
1924        );
1925
1926        let wave = PartialWave::new(j!(1), l!(1), j!(0)).unwrap();
1927        assert_eq!(wave.label(), "1P1");
1928        assert_eq!(wave.to_string(), "1P1");
1929
1930        assert!(PartialWave::new(j!(1), l!(0), j!(0)).is_err());
1931
1932        let pi_plus = pion_like("pi+", "pi-", 1, 1);
1933        let pi_minus = pion_like("pi-", "pi+", -1, -1);
1934        let allowed = AllowedPartialWave::new(wave, (&pi_plus, &pi_minus));
1935
1936        assert_eq!(allowed.parity, Some(Parity::Negative));
1937        assert_eq!(allowed.c_parity, Some(Parity::Negative));
1938
1939        let non_c_pair = AllowedPartialWave::new(
1940            PartialWave::new(j!(0), l!(0), j!(0)).unwrap(),
1941            (&pi_plus, &pion_like("pi0", "pi0", 0, 0)),
1942        );
1943        assert_eq!(non_c_pair.parity, Some(Parity::Positive));
1944        assert_eq!(non_c_pair.c_parity, None);
1945    }
1946
1947    #[test]
1948    fn complete_strong_plus_c_and_g_rules_pass_for_rho_like_to_charged_pions() {
1949        let parent = rho_like();
1950        let pi_plus = pion_like("pi+", "pi-", 1, 1);
1951        let pi_minus = pion_like("pi-", "pi+", -1, -1);
1952
1953        let rules = RuleSet::strong()
1954            .enforce(RuleKind::CParity)
1955            .enforce(RuleKind::GParity)
1956            .enforce(RuleKind::ElectronLeptonNumber)
1957            .enforce(RuleKind::MuonLeptonNumber)
1958            .enforce(RuleKind::TauLeptonNumber)
1959            .enforce(RuleKind::LeptonNumber);
1960
1961        let report = rules.evaluate(&parent, (&pi_plus, &pi_minus), l!(1), j!(0));
1962
1963        assert!(report.is_allowed());
1964        assert_eq!(report.len(), rules.enabled_rules().count());
1965        assert!(
1966            report
1967                .checks
1968                .iter()
1969                .all(|check| { matches!(check.outcome, RuleOutcome::Pass { .. }) })
1970        );
1971
1972        for rule in rules.enabled_rules() {
1973            assert_pass(&report, rule);
1974        }
1975    }
1976
1977    #[test]
1978    fn nontrivial_quantum_number_rules_report_failures() {
1979        let parent = ParticleProperties::meson()
1980            .with_zero_flavor()
1981            .with_name("bad_parent")
1982            .with_self_conjugate_species("bad_parent")
1983            .unwrap()
1984            .with_spin(j!(1))
1985            .with_parity(Parity::Positive)
1986            .with_c_parity(Parity::Positive)
1987            .unwrap()
1988            .with_charge(0)
1989            .with_isospin(Isospin::new(j!(3), Some(m!(1))).unwrap())
1990            .with_g_parity(Parity::Negative)
1991            .with_statistics(Statistics::Boson)
1992            .unwrap();
1993
1994        let pi_plus = pion_like("pi+", "pi-", 1, 1);
1995        let pi_minus = pion_like("pi-", "pi+", -1, -1);
1996
1997        let rules = RuleSet::angular()
1998            .enforce(RuleKind::Parity)
1999            .enforce(RuleKind::Isospin)
2000            .enforce(RuleKind::IsospinProjection)
2001            .enforce(RuleKind::CParity)
2002            .enforce(RuleKind::GParity);
2003
2004        let report = rules.evaluate(&parent, (&pi_plus, &pi_minus), l!(1), j!(0));
2005
2006        assert!(!report.is_allowed());
2007        assert_eq!(report.failures().count(), 5);
2008
2009        assert_fail(&report, RuleKind::Parity);
2010        assert_fail(&report, RuleKind::Isospin);
2011        assert_fail(&report, RuleKind::IsospinProjection);
2012        assert_fail(&report, RuleKind::CParity);
2013        assert_fail(&report, RuleKind::GParity);
2014    }
2015
2016    #[test]
2017    fn additive_and_lepton_rules_report_passes_failures_and_missing_fields() {
2018        let parent = add_additives(
2019            ParticleProperties::unknown(),
2020            1, // charge violation against 0 + 0
2021            1, // strangeness violation
2022            1, // charm violation
2023            1, // bottomness violation
2024            1, // topness violation
2025            1, // baryon-number violation
2026            1, // electron-family lepton-number violation
2027            0,
2028            0,
2029        );
2030
2031        let daughter_a = add_additives(ParticleProperties::unknown(), 0, 0, 0, 0, 0, 0, 0, 1, 0);
2032        let daughter_b = add_additives(ParticleProperties::unknown(), 0, 0, 0, 0, 0, 0, 0, 0, 0);
2033
2034        let rules = RuleSet::angular()
2035            .enforce(RuleKind::Charge)
2036            .enforce(RuleKind::Strangeness)
2037            .enforce(RuleKind::Charm)
2038            .enforce(RuleKind::Bottomness)
2039            .enforce(RuleKind::Topness)
2040            .enforce(RuleKind::BaryonNumber)
2041            .enforce(RuleKind::ElectronLeptonNumber)
2042            .enforce(RuleKind::MuonLeptonNumber)
2043            .enforce(RuleKind::TauLeptonNumber)
2044            .enforce(RuleKind::LeptonNumber);
2045
2046        let report = rules.evaluate(&parent, (&daughter_a, &daughter_b), l!(0), j!(0));
2047
2048        assert!(!report.is_allowed());
2049
2050        for rule in [
2051            RuleKind::Charge,
2052            RuleKind::Strangeness,
2053            RuleKind::Charm,
2054            RuleKind::Bottomness,
2055            RuleKind::Topness,
2056            RuleKind::BaryonNumber,
2057            RuleKind::ElectronLeptonNumber,
2058            RuleKind::MuonLeptonNumber,
2059        ] {
2060            assert_fail(&report, rule);
2061        }
2062
2063        assert_pass(&report, RuleKind::TauLeptonNumber);
2064        assert_pass(&report, RuleKind::LeptonNumber);
2065
2066        let parent_missing = ParticleProperties::unknown().with_charge(0);
2067        let a_missing = ParticleProperties::unknown().with_charge(0);
2068        let b_missing = ParticleProperties::unknown();
2069
2070        let unknown_report = RuleSet::angular()
2071            .enforce(RuleKind::Charge)
2072            .enforce(RuleKind::LeptonNumber)
2073            .evaluate(&parent_missing, (&a_missing, &b_missing), l!(0), j!(0));
2074
2075        assert!(unknown_report.is_allowed());
2076        assert_unknown_allowed(&unknown_report, RuleKind::Charge, &["daughter_b.charge"]);
2077        assert_unknown_allowed(
2078            &unknown_report,
2079            RuleKind::LeptonNumber,
2080            &[
2081                "parent.electron_lepton_number",
2082                "daughter_a.electron_lepton_number",
2083                "daughter_b.electron_lepton_number",
2084            ],
2085        );
2086    }
2087
2088    #[test]
2089    fn identical_particle_symmetry_handles_bosons_and_fermions_with_spin_dependence() {
2090        let scalar_a = identical_boson(j!(0), "scalar");
2091        let scalar_b = identical_boson(j!(0), "scalar");
2092
2093        let vector_a = identical_boson(j!(1), "vector");
2094        let vector_b = identical_boson(j!(1), "vector");
2095
2096        let fermion_a = identical_fermion(j!(1 / 2), "fermion");
2097        let fermion_b = identical_fermion(j!(1 / 2), "fermion");
2098
2099        let rules = RuleSet::angular().enforce(RuleKind::IdenticalParticleSymmetry);
2100
2101        let scalar_even_l = rules.evaluate(
2102            &ParticleProperties::unknown(),
2103            (&scalar_a, &scalar_b),
2104            l!(0),
2105            j!(0),
2106        );
2107        assert_pass(&scalar_even_l, RuleKind::IdenticalParticleSymmetry);
2108
2109        let scalar_odd_l = rules.evaluate(
2110            &ParticleProperties::unknown(),
2111            (&scalar_a, &scalar_b),
2112            l!(1),
2113            j!(0),
2114        );
2115        assert_fail(&scalar_odd_l, RuleKind::IdenticalParticleSymmetry);
2116
2117        let vector_s0 = rules.evaluate(
2118            &ParticleProperties::unknown(),
2119            (&vector_a, &vector_b),
2120            l!(0),
2121            j!(0),
2122        );
2123        assert_pass(&vector_s0, RuleKind::IdenticalParticleSymmetry);
2124
2125        let vector_s1 = rules.evaluate(
2126            &ParticleProperties::unknown(),
2127            (&vector_a, &vector_b),
2128            l!(0),
2129            j!(1),
2130        );
2131        assert_fail(&vector_s1, RuleKind::IdenticalParticleSymmetry);
2132
2133        let fermion_s0 = rules.evaluate(
2134            &ParticleProperties::unknown(),
2135            (&fermion_a, &fermion_b),
2136            l!(0),
2137            j!(0),
2138        );
2139        assert_pass(&fermion_s0, RuleKind::IdenticalParticleSymmetry);
2140
2141        let fermion_s1 = rules.evaluate(
2142            &ParticleProperties::unknown(),
2143            (&fermion_a, &fermion_b),
2144            l!(0),
2145            j!(1),
2146        );
2147        assert_fail(&fermion_s1, RuleKind::IdenticalParticleSymmetry);
2148
2149        let different_species = rules.evaluate(
2150            &ParticleProperties::unknown(),
2151            (&identical_boson(j!(0), "a"), &identical_boson(j!(0), "b")),
2152            l!(1),
2153            j!(0),
2154        );
2155        assert_pass(&different_species, RuleKind::IdenticalParticleSymmetry);
2156    }
2157
2158    #[test]
2159    fn c_parity_rule_distinguishes_inferred_c_from_non_inferable_final_states() {
2160        let parent = rho_like();
2161        let pi_plus = pion_like("pi+", "pi-", 1, 1);
2162        let pi_minus = pion_like("pi-", "pi+", -1, -1);
2163        let pi_zero = pion_like("pi0", "pi0", 0, 0);
2164
2165        let rules = RuleSet::angular().enforce(RuleKind::CParity);
2166
2167        let p_wave_report = rules.evaluate(&parent, (&pi_plus, &pi_minus), l!(1), j!(0));
2168        assert_pass(&p_wave_report, RuleKind::CParity);
2169
2170        let s_wave_report = rules.evaluate(&parent, (&pi_plus, &pi_minus), l!(0), j!(0));
2171        assert_fail(&s_wave_report, RuleKind::CParity);
2172
2173        let unknown_report = rules.evaluate(&parent, (&pi_plus, &pi_zero), l!(1), j!(0));
2174        assert!(unknown_report.is_allowed());
2175        assert_unknown_allowed(
2176            &unknown_report,
2177            RuleKind::CParity,
2178            &[
2179                "daughter_a.species",
2180                "daughter_a.antiparticle_species",
2181                "daughter_b.species",
2182                "daughter_b.antiparticle_species",
2183            ],
2184        );
2185    }
2186
2187    #[test]
2188    fn conventional_meson_jpc_can_be_enforced_or_used_as_non_rejecting_diagnostic() {
2189        let conventional = rho_like();
2190        let exotic = exotic_one_minus_plus();
2191
2192        let diagnostic_rules = RuleSet::angular().diagnose_only(
2193            RuleKind::ConventionalMesonJpc,
2194            "flag exotic JPC without rejecting hybrid candidates",
2195        );
2196
2197        let conventional_report =
2198            diagnostic_rules.evaluate(&conventional, (&conventional, &conventional), l!(0), j!(0));
2199        assert!(conventional_report.is_allowed());
2200        assert_diagnostic(
2201            &conventional_report,
2202            RuleKind::ConventionalMesonJpc,
2203            Some(true),
2204            Some("flag exotic JPC without rejecting hybrid candidates"),
2205        );
2206
2207        let exotic_report = diagnostic_rules.evaluate(&exotic, (&exotic, &exotic), l!(0), j!(0));
2208        assert!(exotic_report.is_allowed());
2209        assert_diagnostic(
2210            &exotic_report,
2211            RuleKind::ConventionalMesonJpc,
2212            Some(false),
2213            Some("flag exotic JPC without rejecting hybrid candidates"),
2214        );
2215
2216        let enforced_report = RuleSet::angular()
2217            .enforce(RuleKind::ConventionalMesonJpc)
2218            .evaluate(&exotic, (&exotic, &exotic), l!(0), j!(0));
2219        assert!(!enforced_report.is_allowed());
2220        assert_fail(&enforced_report, RuleKind::ConventionalMesonJpc);
2221
2222        let unknown_report = RuleSet::angular()
2223            .diagnose_only_without_reason(RuleKind::ConventionalMesonJpc)
2224            .evaluate(
2225                &ParticleProperties::unknown(),
2226                (
2227                    &ParticleProperties::unknown(),
2228                    &ParticleProperties::unknown(),
2229                ),
2230                l!(0),
2231                j!(0),
2232            );
2233        assert_diagnostic(&unknown_report, RuleKind::ConventionalMesonJpc, None, None);
2234    }
2235
2236    #[test]
2237    fn selection_rules_scan_partial_waves_keeps_rejected_candidates_for_diagnostics() {
2238        let parent = ParticleProperties::jp(j!(1), Parity::Positive);
2239        let a = ParticleProperties::jp(j!(1 / 2), Parity::Positive);
2240        let b = ParticleProperties::jp(j!(1 / 2), Parity::Negative);
2241
2242        let angular_scan = SelectionRules::angular(l!(2)).scan_partial_waves(&parent, (&a, &b));
2243
2244        assert!(angular_scan.missing_inputs.is_empty());
2245        assert_eq!(
2246            candidate_labels(angular_scan.candidates.iter()),
2247            vec!["1P1", "3S1", "3P1", "3D1"]
2248        );
2249        assert_eq!(
2250            allowed_labels(angular_scan.allowed()),
2251            vec!["1P1", "3S1", "3P1", "3D1"]
2252        );
2253        assert_eq!(angular_scan.rejected().count(), 0);
2254
2255        let parity_rules = SelectionRules::new(RuleSet::angular().enforce(RuleKind::Parity), l!(2));
2256        let parity_scan = parity_rules.scan_partial_waves(&parent, (&a, &b));
2257
2258        assert_eq!(
2259            candidate_labels(parity_scan.candidates.iter()),
2260            vec!["1P1", "3S1", "3P1", "3D1"]
2261        );
2262        assert_eq!(allowed_labels(parity_scan.allowed()), vec!["1P1", "3P1"]);
2263        assert_eq!(candidate_labels(parity_scan.rejected()), vec!["3S1", "3D1"]);
2264
2265        let allowed = parity_rules.allowed_partial_waves(&parent, (&a, &b));
2266        assert_eq!(labels(&allowed), vec!["1P1", "3P1"]);
2267    }
2268
2269    #[test]
2270    fn selection_rules_report_missing_spin_inputs_and_default_to_strong_l6() {
2271        assert_eq!(SelectionRules::default(), SelectionRules::strong(l!(6)));
2272        assert_eq!(
2273            SelectionRules::electromagnetic(l!(2)),
2274            SelectionRules::new(RuleSet::electromagnetic(), l!(2))
2275        );
2276        assert_eq!(
2277            SelectionRules::weak(l!(3)),
2278            SelectionRules::new(RuleSet::weak(), l!(3))
2279        );
2280
2281        let parent_missing = ParticleProperties::unknown();
2282        let a = ParticleProperties::jp(j!(0), Parity::Negative);
2283        let b = ParticleProperties::jp(j!(0), Parity::Negative);
2284
2285        let scan = SelectionRules::default().scan_partial_waves(&parent_missing, (&a, &b));
2286
2287        assert!(scan.candidates.is_empty());
2288        assert_eq!(scan.missing_inputs, vec!["parent.spin"]);
2289
2290        let parent = ParticleProperties::jp(j!(0), Parity::Positive);
2291        let a_missing = ParticleProperties::unknown();
2292
2293        let scan = SelectionRules::default().scan_partial_waves(&parent, (&a_missing, &b));
2294
2295        assert!(scan.candidates.is_empty());
2296        assert_eq!(scan.missing_inputs, vec!["daughter_a.spin"]);
2297
2298        let b_missing = ParticleProperties::unknown();
2299
2300        let scan = SelectionRules::default().scan_partial_waves(&parent, (&a, &b_missing));
2301
2302        assert!(scan.candidates.is_empty());
2303        assert_eq!(scan.missing_inputs, vec!["daughter_b.spin"]);
2304    }
2305
2306    #[test]
2307    fn strong_rules_find_delta_like_to_nucleon_pion_p_wave() {
2308        let parent = add_additives(
2309            ParticleProperties::jp(j!(3 / 2), Parity::Positive),
2310            1,
2311            0,
2312            0,
2313            0,
2314            0,
2315            1,
2316            0,
2317            0,
2318            0,
2319        );
2320
2321        let nucleon = add_additives(
2322            ParticleProperties::jp(j!(1 / 2), Parity::Positive),
2323            1,
2324            0,
2325            0,
2326            0,
2327            0,
2328            1,
2329            0,
2330            0,
2331            0,
2332        );
2333
2334        let pion = add_additives(
2335            ParticleProperties::jp(j!(0), Parity::Negative),
2336            0,
2337            0,
2338            0,
2339            0,
2340            0,
2341            0,
2342            0,
2343            0,
2344            0,
2345        );
2346
2347        let rules = SelectionRules::new(
2348            RuleSet::angular()
2349                .enforce(RuleKind::Parity)
2350                .enforce(RuleKind::Charge)
2351                .enforce(RuleKind::BaryonNumber),
2352            l!(4),
2353        );
2354
2355        let waves = rules.allowed_partial_waves(&parent, (&nucleon, &pion));
2356
2357        assert_eq!(labels(&waves), vec!["2P3/2"]);
2358        assert_eq!(waves[0].parity, Some(Parity::Positive));
2359        assert_eq!(waves[0].c_parity, None);
2360    }
2361}