Skip to main content

laddu_physics/quantum/
rules.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::quantum::{L, ParticleProperties, S};
6
7mod evaluators;
8
9#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
10/// A conservation, symmetry, or classification rule that can be applied to a
11/// two-body decay.
12pub enum RuleKind {
13    /// Enforce intrinsic parity conservation.
14    ///
15    /// For a two-body final state this checks
16    /// $`P_\text{parent} = P_a P_b (-1)^L`$.
17    Parity,
18
19    /// Enforce total isospin coupling.
20    ///
21    /// This checks whether the two daughter isospins can couple to the parent
22    /// isospin:
23    /// $`I_\text{parent} \in |I_a - I_b|, \ldots, I_a + I_b`$.
24    Isospin,
25    /// Enforce conservation of the isospin projection $`I_3`$.
26    ///
27    /// This checks $`I_{3,\text{parent}} = I_{3,a} + I_{3,b}`$.
28    IsospinProjection,
29    /// Enforce charge-conjugation parity conservation when applicable.
30    ///
31    /// This is only meaningful for states with a defined $`C`$ eigenvalue and
32    /// final states that can be interpreted as $`C`$ eigenstates, such as
33    /// suitable particle-antiparticle combinations.
34    CParity,
35    /// Enforce G-parity conservation when applicable.
36    ///
37    /// This is mainly useful for light-quark isospin multiplets where
38    /// $`G`$-parity is defined. It should not be enabled blindly for arbitrary
39    /// hadrons.
40    GParity,
41    /// Enforce electric charge conservation.
42    ///
43    /// This checks $`Q_\text{parent} = Q_a + Q_b`$.
44    Charge,
45    /// Enforce strangeness conservation.
46    ///
47    /// This checks $`S_\text{parent} = S_a + S_b`$.
48    ///
49    /// Strong and electromagnetic interactions conserve strangeness; weak
50    /// interactions generally do not.
51    Strangeness,
52    /// Enforce charm conservation.
53    ///
54    /// This checks $`C_\text{parent} = C_a + C_b`$, where $`C`$ here denotes
55    /// charm quantum number, not charge conjugation.
56    Charm,
57    /// Enforce bottomness conservation.
58    ///
59    /// This checks $`B'_\text{parent} = B'_a + B'_b`$, where $`B'`$ denotes
60    /// bottomness, not baryon number.
61    Bottomness,
62    /// Enforce topness conservation.
63    ///
64    /// This checks $`T_\text{parent} = T_a + T_b`$.
65    Topness,
66    /// Enforce baryon-number conservation.
67    ///
68    /// This checks $`B_\text{parent} = B_a + B_b`$.
69    BaryonNumber,
70    /// Enforce electron-family lepton-number conservation.
71    ///
72    /// This checks $`L_e(\text{parent}) = L_e(a) + L_e(b)`$.
73    ElectronLeptonNumber,
74    /// Enforce muon-family lepton-number conservation.
75    ///
76    /// This checks $`L_\mu(\text{parent}) = L_\mu(a) + L_\mu(b)`$.
77    MuonLeptonNumber,
78    /// Enforce tau-family lepton-number conservation.
79    ///
80    /// This checks $`L_\tau(\text{parent}) = L_\tau(a) + L_\tau(b)`$.
81    TauLeptonNumber,
82    /// Enforce total lepton-number conservation.
83    ///
84    /// This checks $`L_\text{parent} = L_a + L_b`$, where
85    /// $`L = L_e + L_\mu + L_\tau`$.
86    ///
87    /// This is independent of the individual lepton-family checks. If both this
88    /// and the family-specific checks are enabled, all enabled checks must pass.
89    LeptonNumber,
90    /// Enforce exchange-symmetry constraints for identical final-state
91    /// particles when enough information is available.
92    ///
93    /// At minimum, this is useful for cases such as identical spin-zero bosons,
94    /// where only even $`L`$ is allowed.
95    IdenticalParticleSymmetry,
96
97    /// Optional diagnostic/classification rule, not part of strong-decay conservation.
98    ConventionalMesonJpc,
99}
100
101#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
102/// Controls whether and how a rule contributes to the acceptance decision.
103pub enum RuleMode {
104    /// Reject candidates for which the rule fails.
105    Enforce,
106    /// Skip the rule, optionally recording why it was disabled.
107    Ignore {
108        /// Optional explanation for ignoring the rule.
109        reason: Option<String>,
110    },
111    /// Evaluate and report the rule without rejecting the candidate.
112    DiagnoseOnly {
113        /// Optional explanation for retaining the rule as a diagnostic.
114        reason: Option<String>,
115    },
116}
117
118#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
119/// Determines how an enforced rule treats missing particle properties.
120pub enum UnknownPolicy {
121    /// Current behavior: missing information does not reject the channel.
122    Allow,
123    /// Missing information makes the rule fail.
124    Reject,
125    /// Missing information does not reject, but appears in the report.
126    Warn,
127}
128
129#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
130/// The evaluation mode and missing-input policy associated with one rule.
131pub struct RulePolicy {
132    /// Whether the rule is enforced, ignored, or diagnostic.
133    pub mode: RuleMode,
134    /// How missing inputs are handled when the rule is enforced.
135    pub unknown: UnknownPolicy,
136}
137
138impl RulePolicy {
139    /// Create an enforced policy which allows unknown inputs.
140    pub fn enforce() -> Self {
141        Self {
142            mode: RuleMode::Enforce,
143            unknown: UnknownPolicy::Allow,
144        }
145    }
146
147    /// Create an enforced policy which rejects unknown inputs.
148    pub fn enforce_strict() -> Self {
149        Self {
150            mode: RuleMode::Enforce,
151            unknown: UnknownPolicy::Reject,
152        }
153    }
154
155    /// Create an enforced policy with an explicit missing-input policy.
156    pub fn enforce_with_unknown_policy(unknown: UnknownPolicy) -> Self {
157        Self {
158            mode: RuleMode::Enforce,
159            unknown,
160        }
161    }
162
163    /// Create an ignored policy and record a reason.
164    pub fn ignore(reason: impl Into<String>) -> Self {
165        Self {
166            mode: RuleMode::Ignore {
167                reason: Some(reason.into()),
168            },
169            unknown: UnknownPolicy::Allow,
170        }
171    }
172
173    /// Create an ignored policy without recording a reason.
174    pub fn ignore_without_reason() -> Self {
175        Self {
176            mode: RuleMode::Ignore { reason: None },
177            unknown: UnknownPolicy::Allow,
178        }
179    }
180
181    /// Create a diagnostic-only policy and record a reason.
182    pub fn diagnose_only(reason: impl Into<String>) -> Self {
183        Self {
184            mode: RuleMode::DiagnoseOnly {
185                reason: Some(reason.into()),
186            },
187            unknown: UnknownPolicy::Warn,
188        }
189    }
190
191    /// Create a diagnostic-only policy without recording a reason.
192    pub fn diagnose_only_without_reason() -> Self {
193        Self {
194            mode: RuleMode::DiagnoseOnly { reason: None },
195            unknown: UnknownPolicy::Warn,
196        }
197    }
198}
199
200#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
201/// The result of applying one configured rule to a candidate partial wave.
202pub enum RuleOutcome {
203    /// The rule was evaluated and satisfied.
204    Pass {
205        /// Human-readable explanation of the successful check.
206        message: String,
207    },
208    /// The rule was evaluated and rejected the candidate.
209    Fail {
210        /// Human-readable explanation of the failure.
211        message: String,
212    },
213    /// Required inputs were absent, and the policy allowed the candidate.
214    UnknownAllowed {
215        /// Names of the particle properties that were unavailable.
216        missing: Vec<String>,
217        /// Human-readable explanation of the incomplete check.
218        message: String,
219    },
220    /// Required inputs were absent and the policy requested a warning.
221    Warning {
222        /// Names of the particle properties that were unavailable.
223        missing: Vec<String>,
224        /// Human-readable explanation of the incomplete check.
225        message: String,
226    },
227    /// The rule was intentionally not evaluated.
228    Ignored {
229        /// Optional explanation supplied when the rule was ignored.
230        reason: Option<String>,
231    },
232    /// The rule was evaluated for information but did not affect acceptance.
233    Diagnostic {
234        /// Whether the check passed, or `None` when inputs were unavailable.
235        passed: Option<bool>,
236        /// Optional explanation supplied when diagnostic mode was selected.
237        reason: Option<String>,
238        /// Human-readable result of the diagnostic check.
239        message: String,
240    },
241}
242
243#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
244/// The outcome of one named rule in a [`RuleReport`].
245pub struct RuleCheck {
246    /// Rule that was evaluated.
247    pub rule: RuleKind,
248    /// Result produced under the rule's configured policy.
249    pub outcome: RuleOutcome,
250}
251
252impl RuleCheck {
253    /// Return whether this check rejects the candidate.
254    pub fn is_failure(&self) -> bool {
255        matches!(self.outcome, RuleOutcome::Fail { .. })
256    }
257
258    /// Return whether this check produced a missing-input warning.
259    pub fn is_warning(&self) -> bool {
260        matches!(self.outcome, RuleOutcome::Warning { .. })
261    }
262
263    /// Return whether missing inputs were accepted silently.
264    pub fn is_unknown_allowed(&self) -> bool {
265        matches!(self.outcome, RuleOutcome::UnknownAllowed { .. })
266    }
267
268    /// Return whether this rule was ignored.
269    pub fn is_ignored(&self) -> bool {
270        matches!(self.outcome, RuleOutcome::Ignored { .. })
271    }
272}
273
274#[derive(Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize)]
275/// Detailed results from evaluating a [`RuleSet`] against one candidate.
276pub struct RuleReport {
277    /// Individual checks, ordered by [`RuleKind`].
278    pub checks: Vec<RuleCheck>,
279}
280
281impl RuleReport {
282    /// Return whether no enforced rule rejected the candidate.
283    pub fn is_allowed(&self) -> bool {
284        self.checks.iter().all(|check| !check.is_failure())
285    }
286
287    /// Iterate over checks that rejected the candidate.
288    pub fn failures(&self) -> impl Iterator<Item = &RuleCheck> {
289        self.checks.iter().filter(|check| check.is_failure())
290    }
291
292    /// Iterate over missing-input warnings.
293    pub fn warnings(&self) -> impl Iterator<Item = &RuleCheck> {
294        self.checks.iter().filter(|check| check.is_warning())
295    }
296
297    /// Iterate over checks whose missing inputs were allowed.
298    pub fn unknowns(&self) -> impl Iterator<Item = &RuleCheck> {
299        self.checks
300            .iter()
301            .filter(|check| check.is_unknown_allowed())
302    }
303
304    /// Iterate over rules which were intentionally ignored.
305    pub fn ignored(&self) -> impl Iterator<Item = &RuleCheck> {
306        self.checks.iter().filter(|check| check.is_ignored())
307    }
308
309    /// Retrieve the outcome for a particular rule, if it was configured.
310    pub fn outcome(&self, rule: RuleKind) -> Option<&RuleOutcome> {
311        self.checks
312            .iter()
313            .find(|check| check.rule == rule)
314            .map(|check| &check.outcome)
315    }
316
317    /// Return whether at least one check rejected the candidate.
318    pub fn has_failures(&self) -> bool {
319        self.failures().next().is_some()
320    }
321
322    /// Return whether at least one check allowed unknown inputs.
323    pub fn has_unknowns(&self) -> bool {
324        self.unknowns().next().is_some()
325    }
326
327    /// Return whether at least one configured rule was ignored.
328    pub fn has_ignored(&self) -> bool {
329        self.ignored().next().is_some()
330    }
331
332    /// Return the number of configured rules in the report.
333    pub fn len(&self) -> usize {
334        self.checks.len()
335    }
336
337    /// Return whether the report contains no checks.
338    pub fn is_empty(&self) -> bool {
339        self.checks.is_empty()
340    }
341}
342
343/// A collection of selection rules for testing whether a two-body
344/// decay channel is allowed.
345///
346/// Each rule enables one conservation or symmetry check. Each enabled rule is associated with a
347/// [`RulePolicy`] which dictates how permissively it should be applied to the given particles.
348///
349/// # Notes
350/// The default angular policy doesn't actually enforce any rules, as angular momentum conservation
351/// and coupling rules are handled by other methods.
352///
353/// All constructors assume a permissive enforcement policy, i.e. if a property is unknown for one
354/// or more particles involved, that check is skipped.
355#[derive(Clone, Debug, Eq, Hash, PartialEq, Default, Serialize, Deserialize)]
356pub struct RuleSet {
357    policies: BTreeMap<RuleKind, RulePolicy>,
358}
359impl RuleSet {
360    /// Construct a rule set with no non-angular selection rules enabled.
361    ///
362    /// This is useful when only the angular-momentum coupling constraints should
363    /// be applied:
364    /// $`S \in |j_a - j_b|, \ldots, j_a + j_b`$
365    /// and
366    /// $`J \in |L - S|, \ldots, L + S`$.
367    pub fn angular() -> Self {
368        Self::default()
369    }
370
371    /// Construct a rule set appropriate for ordinary strong two-body decays.
372    ///
373    /// This enables parity, isospin, isospin projection, electric charge,
374    /// flavor quantum numbers, baryon number, and identical-particle exchange
375    /// symmetry.
376    ///
377    /// Charge-conjugation parity and G-parity are left disabled because they
378    /// are only meaningful for certain channels and should be enabled
379    /// explicitly when applicable.
380    pub fn strong() -> Self {
381        Self::angular()
382            .enforce(RuleKind::Parity)
383            .enforce(RuleKind::Isospin)
384            .enforce(RuleKind::IsospinProjection)
385            .enforce(RuleKind::Charge)
386            .enforce(RuleKind::Strangeness)
387            .enforce(RuleKind::Charm)
388            .enforce(RuleKind::Bottomness)
389            .enforce(RuleKind::Topness)
390            .enforce(RuleKind::BaryonNumber)
391            .enforce(RuleKind::IdenticalParticleSymmetry)
392    }
393
394    /// Construct a rule set appropriate for electromagnetic two-body decays.
395    ///
396    /// This enables parity, electric charge, flavor quantum numbers, baryon
397    /// number, isospin-projection conservation, and identical-particle exchange
398    /// symmetry.
399    ///
400    /// Total isospin is not enabled because electromagnetic interactions break
401    /// isospin symmetry.
402    pub fn electromagnetic() -> Self {
403        Self::angular()
404            .enforce(RuleKind::Parity)
405            .enforce(RuleKind::IsospinProjection)
406            .enforce(RuleKind::Charge)
407            .enforce(RuleKind::Strangeness)
408            .enforce(RuleKind::Charm)
409            .enforce(RuleKind::Bottomness)
410            .enforce(RuleKind::Topness)
411            .enforce(RuleKind::BaryonNumber)
412            .enforce(RuleKind::IdenticalParticleSymmetry)
413    }
414
415    /// Construct a rule set appropriate for weak two-body decays.
416    ///
417    /// This enables electric charge, baryon number, individual lepton-family
418    /// numbers, total lepton number, and identical-particle exchange symmetry.
419    ///
420    /// Parity, isospin, strangeness, charm, bottomness, and topness are not
421    /// enabled because weak interactions can violate or change them.
422    pub fn weak() -> Self {
423        Self::angular()
424            .enforce(RuleKind::Charge)
425            .enforce(RuleKind::BaryonNumber)
426            .enforce(RuleKind::ElectronLeptonNumber)
427            .enforce(RuleKind::MuonLeptonNumber)
428            .enforce(RuleKind::TauLeptonNumber)
429            .enforce(RuleKind::LeptonNumber)
430            .enforce(RuleKind::IdenticalParticleSymmetry)
431    }
432
433    /// Enable a rule in place using permissive missing-input handling.
434    pub fn enforce_mut(&mut self, rule: RuleKind) -> &mut Self {
435        self.set_policy_mut(rule, RulePolicy::enforce())
436    }
437
438    /// Enable a rule in place and reject candidates with missing inputs.
439    pub fn enforce_strict_mut(&mut self, rule: RuleKind) -> &mut Self {
440        self.set_policy_mut(rule, RulePolicy::enforce_strict())
441    }
442
443    /// Assign an explicit policy to a rule in place.
444    pub fn set_policy_mut(&mut self, rule: RuleKind, policy: RulePolicy) -> &mut Self {
445        self.policies.insert(rule, policy);
446        self
447    }
448
449    /// Ignore a rule in place and record the supplied reason.
450    pub fn ignore_mut(&mut self, rule: RuleKind, reason: impl Into<String>) -> &mut Self {
451        self.set_policy_mut(rule, RulePolicy::ignore(reason))
452    }
453
454    /// Ignore a rule in place without recording a reason.
455    pub fn ignore_without_reason_mut(&mut self, rule: RuleKind) -> &mut Self {
456        self.set_policy_mut(rule, RulePolicy::ignore_without_reason())
457    }
458
459    /// Make a rule diagnostic-only in place and record the supplied reason.
460    pub fn diagnose_only_mut(&mut self, rule: RuleKind, reason: impl Into<String>) -> &mut Self {
461        self.set_policy_mut(rule, RulePolicy::diagnose_only(reason))
462    }
463
464    /// Make a rule diagnostic-only in place without recording a reason.
465    pub fn diagnose_only_without_reason_mut(&mut self, rule: RuleKind) -> &mut Self {
466        self.set_policy_mut(rule, RulePolicy::diagnose_only_without_reason())
467    }
468
469    /// Remove a rule from this set in place.
470    pub fn disable_mut(&mut self, rule: RuleKind) -> &mut Self {
471        self.policies.remove(&rule);
472        self
473    }
474
475    /// Change a rule's missing-input policy in place.
476    ///
477    /// The rule is enabled with [`RulePolicy::enforce`] if it was not already
478    /// configured.
479    pub fn with_unknown_policy_mut(&mut self, rule: RuleKind, unknown: UnknownPolicy) -> &mut Self {
480        self.policies
481            .entry(rule)
482            .or_insert_with(RulePolicy::enforce)
483            .unknown = unknown;
484        self
485    }
486
487    /// Return a copy with a permissively enforced rule.
488    pub fn enforce(mut self, rule: RuleKind) -> Self {
489        self.enforce_mut(rule);
490        self
491    }
492
493    /// Return a copy with a strictly enforced rule.
494    pub fn enforce_strict(mut self, rule: RuleKind) -> Self {
495        self.enforce_strict_mut(rule);
496        self
497    }
498
499    /// Return a copy with an explicit policy assigned to a rule.
500    pub fn set_policy(mut self, rule: RuleKind, policy: RulePolicy) -> Self {
501        self.set_policy_mut(rule, policy);
502        self
503    }
504
505    /// Return a copy which ignores a rule for the supplied reason.
506    pub fn ignore(mut self, rule: RuleKind, reason: impl Into<String>) -> Self {
507        self.ignore_mut(rule, reason);
508        self
509    }
510
511    /// Return a copy which ignores a rule without recording a reason.
512    pub fn ignore_without_reason(mut self, rule: RuleKind) -> Self {
513        self.ignore_without_reason_mut(rule);
514        self
515    }
516
517    /// Return a copy which evaluates a rule only for diagnostics.
518    pub fn diagnose_only(mut self, rule: RuleKind, reason: impl Into<String>) -> Self {
519        self.diagnose_only_mut(rule, reason);
520        self
521    }
522
523    /// Return a copy which evaluates a rule only for diagnostics, without a reason.
524    pub fn diagnose_only_without_reason(mut self, rule: RuleKind) -> Self {
525        self.diagnose_only_without_reason_mut(rule);
526        self
527    }
528
529    /// Return a copy with a rule removed.
530    pub fn disable(mut self, rule: RuleKind) -> Self {
531        self.disable_mut(rule);
532        self
533    }
534
535    /// Return a copy with the selected missing-input policy.
536    pub fn with_unknown_policy(mut self, rule: RuleKind, unknown: UnknownPolicy) -> Self {
537        self.with_unknown_policy_mut(rule, unknown);
538        self
539    }
540
541    /// Retrieve the configured policy for a rule.
542    pub fn policy(&self, rule: RuleKind) -> Option<&RulePolicy> {
543        self.policies.get(&rule)
544    }
545
546    /// Iterate over the configured rules in stable [`RuleKind`] order.
547    pub fn enabled_rules(&self) -> impl Iterator<Item = RuleKind> + '_ {
548        self.policies.keys().copied()
549    }
550
551    /// Return whether a two-body partial-wave candidate satisfies this rule set.
552    pub fn check(
553        &self,
554        parent: &ParticleProperties,
555        daughters: (&ParticleProperties, &ParticleProperties),
556        l: L,
557        s: S,
558    ) -> bool {
559        self.evaluate(parent, daughters, l, s).is_allowed()
560    }
561
562    /// Evaluate every configured rule and return a detailed report.
563    pub fn evaluate(
564        &self,
565        parent: &ParticleProperties,
566        daughters: (&ParticleProperties, &ParticleProperties),
567        l: L,
568        s: S,
569    ) -> RuleReport {
570        let input = evaluators::RuleInput {
571            parent,
572            daughters,
573            l,
574            s,
575        };
576        let checks = self
577            .policies
578            .iter()
579            .map(|(&rule, policy)| apply_policy(rule, policy, evaluators::evaluate(rule, input)))
580            .collect();
581        RuleReport { checks }
582    }
583}
584
585#[derive(Clone, Debug, Eq, PartialEq)]
586enum RawRuleOutcome {
587    Pass {
588        message: String,
589    },
590    Fail {
591        message: String,
592    },
593    Unknown {
594        missing: Vec<String>,
595        message: String,
596    },
597}
598
599impl RawRuleOutcome {
600    fn pass(message: impl Into<String>) -> Self {
601        Self::Pass {
602            message: message.into(),
603        }
604    }
605
606    fn fail(message: impl Into<String>) -> Self {
607        Self::Fail {
608            message: message.into(),
609        }
610    }
611
612    fn unknown(missing: impl Into<Vec<String>>, message: impl Into<String>) -> Self {
613        Self::Unknown {
614            missing: missing.into(),
615            message: message.into(),
616        }
617    }
618
619    fn message(&self) -> String {
620        match self {
621            Self::Pass { message } => message.clone(),
622            Self::Fail { message } => message.clone(),
623            Self::Unknown { message, .. } => message.clone(),
624        }
625    }
626
627    fn passed(&self) -> Option<bool> {
628        match self {
629            Self::Pass { .. } => Some(true),
630            Self::Fail { .. } => Some(false),
631            Self::Unknown { .. } => None,
632        }
633    }
634}
635
636fn missing(fields: &[&'static str]) -> Vec<String> {
637    fields.iter().map(|field| (*field).to_string()).collect()
638}
639
640fn apply_policy(rule: RuleKind, policy: &RulePolicy, raw: RawRuleOutcome) -> RuleCheck {
641    let outcome = match &policy.mode {
642        RuleMode::Ignore { reason } => RuleOutcome::Ignored {
643            reason: reason.clone(),
644        },
645
646        RuleMode::DiagnoseOnly { reason } => RuleOutcome::Diagnostic {
647            passed: raw.passed(),
648            reason: reason.clone(),
649            message: raw.message(),
650        },
651
652        RuleMode::Enforce => match raw {
653            RawRuleOutcome::Pass { message } => RuleOutcome::Pass { message },
654
655            RawRuleOutcome::Fail { message } => RuleOutcome::Fail { message },
656
657            RawRuleOutcome::Unknown { missing, message } => match policy.unknown {
658                UnknownPolicy::Allow => RuleOutcome::UnknownAllowed { missing, message },
659                UnknownPolicy::Warn => RuleOutcome::Warning { missing, message },
660                UnknownPolicy::Reject => RuleOutcome::Fail {
661                    message: format!("{message}; unknown inputs are rejected by policy"),
662                },
663            },
664        },
665    };
666
667    RuleCheck { rule, outcome }
668}
669
670#[cfg(test)]
671mod tests {
672    use std::collections::BTreeSet;
673
674    use super::*;
675    use crate::{
676        j, l, m,
677        quantum::{
678            AllowedPartialWave, Isospin, J, M, Parity, PartialWave, PartialWaveCandidate,
679            SelectionRules, Statistics,
680        },
681    };
682
683    fn labels(waves: &[AllowedPartialWave]) -> Vec<String> {
684        waves.iter().map(|w| w.wave.label()).collect()
685    }
686
687    fn allowed_labels<'a>(waves: impl Iterator<Item = &'a AllowedPartialWave>) -> Vec<String> {
688        waves.map(|w| w.wave.label()).collect()
689    }
690
691    fn candidate_labels<'a>(
692        candidates: impl Iterator<Item = &'a PartialWaveCandidate>,
693    ) -> Vec<String> {
694        candidates.map(|candidate| candidate.wave.label()).collect()
695    }
696
697    fn outcome(report: &RuleReport, rule: RuleKind) -> &RuleOutcome {
698        report
699            .outcome(rule)
700            .unwrap_or_else(|| panic!("missing outcome for {rule:?}; report was {report:#?}"))
701    }
702
703    fn assert_pass(report: &RuleReport, rule: RuleKind) {
704        assert!(
705            matches!(outcome(report, rule), RuleOutcome::Pass { .. }),
706            "expected {rule:?} to pass; got {:#?}",
707            outcome(report, rule)
708        );
709    }
710
711    fn assert_fail(report: &RuleReport, rule: RuleKind) {
712        assert!(
713            matches!(outcome(report, rule), RuleOutcome::Fail { .. }),
714            "expected {rule:?} to fail; got {:#?}",
715            outcome(report, rule)
716        );
717    }
718
719    fn assert_unknown_allowed(report: &RuleReport, rule: RuleKind, expected_missing: &[&str]) {
720        match outcome(report, rule) {
721            RuleOutcome::UnknownAllowed { missing, .. } => {
722                for field in expected_missing {
723                    assert!(
724                        missing.iter().any(|missing| missing == field),
725                        "expected missing field {field:?}; got {missing:?}"
726                    );
727                }
728            }
729            other => panic!("expected {rule:?} to be UnknownAllowed; got {other:#?}"),
730        }
731    }
732
733    fn assert_warning(report: &RuleReport, rule: RuleKind, expected_missing: &[&str]) {
734        match outcome(report, rule) {
735            RuleOutcome::Warning { missing, .. } => {
736                for field in expected_missing {
737                    assert!(
738                        missing.iter().any(|missing| missing == field),
739                        "expected missing field {field:?}; got {missing:?}"
740                    );
741                }
742            }
743            other => panic!("expected {rule:?} to be Warning; got {other:#?}"),
744        }
745    }
746
747    fn assert_ignored(report: &RuleReport, rule: RuleKind, expected_reason: Option<&str>) {
748        match outcome(report, rule) {
749            RuleOutcome::Ignored { reason } => {
750                assert_eq!(reason.as_deref(), expected_reason);
751            }
752            other => panic!("expected {rule:?} to be Ignored; got {other:#?}"),
753        }
754    }
755
756    fn assert_diagnostic(
757        report: &RuleReport,
758        rule: RuleKind,
759        expected_passed: Option<bool>,
760        expected_reason: Option<&str>,
761    ) {
762        match outcome(report, rule) {
763            RuleOutcome::Diagnostic { passed, reason, .. } => {
764                assert_eq!(*passed, expected_passed);
765                assert_eq!(reason.as_deref(), expected_reason);
766            }
767            other => panic!("expected {rule:?} to be Diagnostic; got {other:#?}"),
768        }
769    }
770
771    #[allow(clippy::too_many_arguments)]
772    fn add_additives(
773        particle: ParticleProperties,
774        charge: i32,
775        strangeness: i32,
776        charm: i32,
777        bottomness: i32,
778        topness: i32,
779        baryon_number: i32,
780        electron_lepton_number: i32,
781        muon_lepton_number: i32,
782        tau_lepton_number: i32,
783    ) -> ParticleProperties {
784        particle
785            .with_charge(charge)
786            .with_strangeness(strangeness)
787            .unwrap()
788            .with_charm(charm)
789            .unwrap()
790            .with_bottomness(bottomness)
791            .unwrap()
792            .with_topness(topness)
793            .unwrap()
794            .with_baryon_number(baryon_number)
795            .unwrap()
796            .with_electron_lepton_number(electron_lepton_number)
797            .unwrap()
798            .with_muon_lepton_number(muon_lepton_number)
799            .unwrap()
800            .with_tau_lepton_number(tau_lepton_number)
801            .unwrap()
802    }
803
804    fn pion_like(name: &str, anti_name: &str, charge: i32, i3: i32) -> ParticleProperties {
805        ParticleProperties::meson()
806            .with_zero_flavor()
807            .with_name(name)
808            .with_species_names(name, anti_name)
809            .unwrap()
810            .with_spin(j!(0))
811            .with_parity(Parity::Negative)
812            .with_charge(charge)
813            .with_isospin(Isospin::new(j!(1), Some(M::int(i3))).unwrap())
814            .with_g_parity(Parity::Negative)
815            .with_statistics(Statistics::Boson)
816            .unwrap()
817    }
818
819    fn rho_like() -> ParticleProperties {
820        ParticleProperties::meson()
821            .with_zero_flavor()
822            .with_name("rho0")
823            .with_self_conjugate_species("rho0")
824            .unwrap()
825            .with_spin(j!(1))
826            .with_parity(Parity::Negative)
827            .with_c_parity(Parity::Negative)
828            .unwrap()
829            .with_charge(0)
830            .with_isospin(Isospin::new(j!(1), Some(m!(0))).unwrap())
831            .with_g_parity(Parity::Positive)
832            .with_statistics(Statistics::Boson)
833            .unwrap()
834    }
835
836    fn exotic_one_minus_plus() -> ParticleProperties {
837        ParticleProperties::meson()
838            .with_zero_flavor()
839            .with_name("pi1_exotic")
840            .with_self_conjugate_species("pi1_exotic")
841            .unwrap()
842            .with_spin(j!(1))
843            .with_parity(Parity::Negative)
844            .with_c_parity(Parity::Positive)
845            .unwrap()
846            .with_charge(0)
847            .with_isospin(Isospin::new(j!(1), Some(m!(0))).unwrap())
848            .with_statistics(Statistics::Boson)
849            .unwrap()
850    }
851
852    fn identical_boson(spin: J, species: &str) -> ParticleProperties {
853        ParticleProperties::unknown()
854            .with_spin(spin)
855            .with_species(species)
856            .unwrap()
857            .with_statistics(Statistics::Boson)
858            .unwrap()
859    }
860
861    fn identical_fermion(spin: J, species: &str) -> ParticleProperties {
862        ParticleProperties::unknown()
863            .with_spin(spin)
864            .with_species(species)
865            .unwrap()
866            .with_statistics(Statistics::Fermion)
867            .unwrap()
868    }
869
870    const ALL_RULES: [RuleKind; 17] = [
871        RuleKind::Parity,
872        RuleKind::Isospin,
873        RuleKind::IsospinProjection,
874        RuleKind::CParity,
875        RuleKind::GParity,
876        RuleKind::Charge,
877        RuleKind::Strangeness,
878        RuleKind::Charm,
879        RuleKind::Bottomness,
880        RuleKind::Topness,
881        RuleKind::BaryonNumber,
882        RuleKind::ElectronLeptonNumber,
883        RuleKind::MuonLeptonNumber,
884        RuleKind::TauLeptonNumber,
885        RuleKind::LeptonNumber,
886        RuleKind::IdenticalParticleSymmetry,
887        RuleKind::ConventionalMesonJpc,
888    ];
889
890    #[test]
891    fn rule_registry_is_complete_unique_and_named() {
892        let registered: Vec<_> = evaluators::registered_rules().collect();
893        assert_eq!(
894            registered.iter().map(|(rule, _)| *rule).collect::<Vec<_>>(),
895            ALL_RULES,
896        );
897        assert!(registered.iter().all(|(_, name)| !name.is_empty()));
898        assert_eq!(
899            registered
900                .iter()
901                .map(|(rule, _)| *rule)
902                .collect::<BTreeSet<_>>()
903                .len(),
904            ALL_RULES.len(),
905        );
906    }
907
908    #[test]
909    fn every_rule_outcome_obeys_every_policy_state() {
910        let raw_outcomes = [
911            RawRuleOutcome::pass("passed"),
912            RawRuleOutcome::fail("failed"),
913            RawRuleOutcome::unknown(vec!["missing.field".to_string()], "unknown"),
914        ];
915        let modes = [
916            RuleMode::Enforce,
917            RuleMode::Ignore { reason: None },
918            RuleMode::DiagnoseOnly { reason: None },
919        ];
920        let unknown_policies = [
921            UnknownPolicy::Allow,
922            UnknownPolicy::Warn,
923            UnknownPolicy::Reject,
924        ];
925
926        for rule in ALL_RULES {
927            for mode in &modes {
928                for unknown in unknown_policies {
929                    for raw in &raw_outcomes {
930                        let check = apply_policy(
931                            rule,
932                            &RulePolicy {
933                                mode: mode.clone(),
934                                unknown,
935                            },
936                            raw.clone(),
937                        );
938                        assert_eq!(check.rule, rule);
939                        match (mode, raw, unknown, check.outcome) {
940                            (RuleMode::Ignore { .. }, _, _, RuleOutcome::Ignored { .. })
941                            | (
942                                RuleMode::DiagnoseOnly { .. },
943                                _,
944                                _,
945                                RuleOutcome::Diagnostic { .. },
946                            )
947                            | (
948                                RuleMode::Enforce,
949                                RawRuleOutcome::Pass { .. },
950                                _,
951                                RuleOutcome::Pass { .. },
952                            )
953                            | (
954                                RuleMode::Enforce,
955                                RawRuleOutcome::Fail { .. },
956                                _,
957                                RuleOutcome::Fail { .. },
958                            )
959                            | (
960                                RuleMode::Enforce,
961                                RawRuleOutcome::Unknown { .. },
962                                UnknownPolicy::Allow,
963                                RuleOutcome::UnknownAllowed { .. },
964                            )
965                            | (
966                                RuleMode::Enforce,
967                                RawRuleOutcome::Unknown { .. },
968                                UnknownPolicy::Warn,
969                                RuleOutcome::Warning { .. },
970                            )
971                            | (
972                                RuleMode::Enforce,
973                                RawRuleOutcome::Unknown { .. },
974                                UnknownPolicy::Reject,
975                                RuleOutcome::Fail { .. },
976                            ) => {}
977                            combination => panic!("unexpected policy result: {combination:?}"),
978                        }
979                    }
980                }
981            }
982        }
983    }
984
985    #[test]
986    fn rule_presets_preserve_exact_membership_and_order() {
987        let assert_rules = |actual: RuleSet, expected: &[RuleKind]| {
988            assert_eq!(actual.enabled_rules().collect::<Vec<_>>(), expected);
989        };
990        assert_rules(
991            RuleSet::strong(),
992            &[
993                RuleKind::Parity,
994                RuleKind::Isospin,
995                RuleKind::IsospinProjection,
996                RuleKind::Charge,
997                RuleKind::Strangeness,
998                RuleKind::Charm,
999                RuleKind::Bottomness,
1000                RuleKind::Topness,
1001                RuleKind::BaryonNumber,
1002                RuleKind::IdenticalParticleSymmetry,
1003            ],
1004        );
1005        assert_rules(
1006            RuleSet::electromagnetic(),
1007            &[
1008                RuleKind::Parity,
1009                RuleKind::IsospinProjection,
1010                RuleKind::Charge,
1011                RuleKind::Strangeness,
1012                RuleKind::Charm,
1013                RuleKind::Bottomness,
1014                RuleKind::Topness,
1015                RuleKind::BaryonNumber,
1016                RuleKind::IdenticalParticleSymmetry,
1017            ],
1018        );
1019        assert_rules(
1020            RuleSet::weak(),
1021            &[
1022                RuleKind::Charge,
1023                RuleKind::BaryonNumber,
1024                RuleKind::ElectronLeptonNumber,
1025                RuleKind::MuonLeptonNumber,
1026                RuleKind::TauLeptonNumber,
1027                RuleKind::LeptonNumber,
1028                RuleKind::IdenticalParticleSymmetry,
1029            ],
1030        );
1031    }
1032
1033    #[test]
1034    fn representative_rule_policy_and_report_serde_stay_stable() {
1035        let policy = RulePolicy {
1036            mode: RuleMode::DiagnoseOnly {
1037                reason: Some("classification only".to_string()),
1038            },
1039            unknown: UnknownPolicy::Warn,
1040        };
1041        let policy_json = serde_json::to_string(&policy).unwrap();
1042        assert_eq!(
1043            policy_json,
1044            r#"{"mode":{"DiagnoseOnly":{"reason":"classification only"}},"unknown":"Warn"}"#,
1045        );
1046        assert_eq!(
1047            serde_json::from_str::<RulePolicy>(&policy_json).unwrap(),
1048            policy
1049        );
1050
1051        let report = RuleReport {
1052            checks: vec![RuleCheck {
1053                rule: RuleKind::Charge,
1054                outcome: RuleOutcome::Warning {
1055                    missing: vec!["daughter_b.charge".to_string()],
1056                    message: "charge cannot be checked".to_string(),
1057                },
1058            }],
1059        };
1060        let report_json = serde_json::to_string(&report).unwrap();
1061        assert_eq!(
1062            report_json,
1063            r#"{"checks":[{"rule":"Charge","outcome":{"Warning":{"missing":["daughter_b.charge"],"message":"charge cannot be checked"}}}]}"#,
1064        );
1065        assert_eq!(
1066            serde_json::from_str::<RuleReport>(&report_json).unwrap(),
1067            report
1068        );
1069    }
1070
1071    #[test]
1072    fn rule_set_constructors_build_expected_default_policies() {
1073        let angular = RuleSet::angular();
1074        assert_eq!(angular.enabled_rules().count(), 0);
1075
1076        let strong = RuleSet::strong();
1077        for rule in [
1078            RuleKind::Parity,
1079            RuleKind::Isospin,
1080            RuleKind::IsospinProjection,
1081            RuleKind::Charge,
1082            RuleKind::Strangeness,
1083            RuleKind::Charm,
1084            RuleKind::Bottomness,
1085            RuleKind::Topness,
1086            RuleKind::BaryonNumber,
1087            RuleKind::IdenticalParticleSymmetry,
1088        ] {
1089            assert!(
1090                matches!(strong.policy(rule).unwrap().mode, RuleMode::Enforce),
1091                "strong rules should enforce {rule:?}"
1092            );
1093        }
1094        assert!(strong.policy(RuleKind::CParity).is_none());
1095        assert!(strong.policy(RuleKind::GParity).is_none());
1096        assert!(strong.policy(RuleKind::ConventionalMesonJpc).is_none());
1097
1098        let electromagnetic = RuleSet::electromagnetic();
1099        assert!(electromagnetic.policy(RuleKind::Parity).is_some());
1100        assert!(electromagnetic.policy(RuleKind::Charge).is_some());
1101        assert!(
1102            electromagnetic
1103                .policy(RuleKind::IsospinProjection)
1104                .is_some()
1105        );
1106        assert!(electromagnetic.policy(RuleKind::Isospin).is_none());
1107
1108        let weak = RuleSet::weak();
1109        assert!(weak.policy(RuleKind::Charge).is_some());
1110        assert!(weak.policy(RuleKind::BaryonNumber).is_some());
1111        assert!(weak.policy(RuleKind::ElectronLeptonNumber).is_some());
1112        assert!(weak.policy(RuleKind::MuonLeptonNumber).is_some());
1113        assert!(weak.policy(RuleKind::TauLeptonNumber).is_some());
1114        assert!(weak.policy(RuleKind::LeptonNumber).is_some());
1115        assert!(weak.policy(RuleKind::Parity).is_none());
1116        assert!(weak.policy(RuleKind::Strangeness).is_none());
1117    }
1118
1119    #[test]
1120    fn rule_set_builder_and_mut_methods_configure_the_same_policies() {
1121        let built = RuleSet::angular()
1122            .enforce(RuleKind::Parity)
1123            .enforce_strict(RuleKind::Charge)
1124            .set_policy(
1125                RuleKind::Strangeness,
1126                RulePolicy::enforce_with_unknown_policy(UnknownPolicy::Warn),
1127            )
1128            .ignore(RuleKind::Isospin, "intentional isospin violation")
1129            .ignore_without_reason(RuleKind::GParity)
1130            .diagnose_only(
1131                RuleKind::ConventionalMesonJpc,
1132                "classify exotics without rejecting them",
1133            )
1134            .diagnose_only_without_reason(RuleKind::CParity)
1135            .with_unknown_policy(RuleKind::Bottomness, UnknownPolicy::Reject)
1136            .disable(RuleKind::CParity);
1137
1138        let mut mutated = RuleSet::angular();
1139        mutated
1140            .enforce_mut(RuleKind::Parity)
1141            .enforce_strict_mut(RuleKind::Charge)
1142            .set_policy_mut(
1143                RuleKind::Strangeness,
1144                RulePolicy::enforce_with_unknown_policy(UnknownPolicy::Warn),
1145            )
1146            .ignore_mut(RuleKind::Isospin, "intentional isospin violation")
1147            .ignore_without_reason_mut(RuleKind::GParity)
1148            .diagnose_only_mut(
1149                RuleKind::ConventionalMesonJpc,
1150                "classify exotics without rejecting them",
1151            )
1152            .diagnose_only_without_reason_mut(RuleKind::CParity)
1153            .with_unknown_policy_mut(RuleKind::Bottomness, UnknownPolicy::Reject)
1154            .disable_mut(RuleKind::CParity);
1155
1156        assert_eq!(built, mutated);
1157
1158        assert!(matches!(
1159            built.policy(RuleKind::Parity).unwrap().mode,
1160            RuleMode::Enforce
1161        ));
1162        assert_eq!(
1163            built.policy(RuleKind::Charge).unwrap().unknown,
1164            UnknownPolicy::Reject
1165        );
1166        assert_eq!(
1167            built.policy(RuleKind::Strangeness).unwrap().unknown,
1168            UnknownPolicy::Warn
1169        );
1170        assert!(matches!(
1171            built.policy(RuleKind::Isospin).unwrap().mode,
1172            RuleMode::Ignore { reason: Some(_) }
1173        ));
1174        assert!(matches!(
1175            built.policy(RuleKind::GParity).unwrap().mode,
1176            RuleMode::Ignore { reason: None }
1177        ));
1178        assert!(matches!(
1179            built.policy(RuleKind::ConventionalMesonJpc).unwrap().mode,
1180            RuleMode::DiagnoseOnly { reason: Some(_) }
1181        ));
1182        assert_eq!(
1183            built.policy(RuleKind::Bottomness).unwrap().unknown,
1184            UnknownPolicy::Reject
1185        );
1186        assert!(built.policy(RuleKind::CParity).is_none());
1187    }
1188
1189    #[test]
1190    fn policy_application_distinguishes_unknown_allowed_warning_reject_ignore_and_diagnostic() {
1191        let parent = ParticleProperties::unknown().with_spin(j!(0));
1192        let a = ParticleProperties::unknown().with_spin(j!(0));
1193        let b = ParticleProperties::unknown().with_spin(j!(0));
1194
1195        let rules = RuleSet::angular()
1196            .enforce(RuleKind::Parity)
1197            .set_policy(
1198                RuleKind::Charge,
1199                RulePolicy::enforce_with_unknown_policy(UnknownPolicy::Warn),
1200            )
1201            .enforce_strict(RuleKind::Strangeness)
1202            .ignore(RuleKind::Isospin, "not relevant for this model")
1203            .diagnose_only(
1204                RuleKind::ConventionalMesonJpc,
1205                "only classify the parent assignment",
1206            );
1207
1208        let report = rules.evaluate(&parent, (&a, &b), l!(0), j!(0));
1209
1210        assert!(!report.is_allowed());
1211        assert_eq!(report.len(), 5);
1212        assert!(report.has_failures());
1213        assert!(report.has_unknowns());
1214        assert!(report.has_ignored());
1215        assert_eq!(report.failures().count(), 1);
1216        assert_eq!(report.warnings().count(), 1);
1217        assert_eq!(report.unknowns().count(), 1);
1218        assert_eq!(report.ignored().count(), 1);
1219
1220        assert_unknown_allowed(&report, RuleKind::Parity, &["parent.parity"]);
1221        assert_warning(
1222            &report,
1223            RuleKind::Charge,
1224            &["parent.charge", "daughter_a.charge", "daughter_b.charge"],
1225        );
1226        assert_fail(&report, RuleKind::Strangeness);
1227        assert_ignored(
1228            &report,
1229            RuleKind::Isospin,
1230            Some("not relevant for this model"),
1231        );
1232        assert_diagnostic(
1233            &report,
1234            RuleKind::ConventionalMesonJpc,
1235            None,
1236            Some("only classify the parent assignment"),
1237        );
1238    }
1239
1240    #[test]
1241    fn angular_momentum_helpers_partial_wave_validation_and_inference_work_together() {
1242        assert_eq!(
1243            SelectionRules::coupled_spins(j!(1 / 2), j!(1 / 2)),
1244            vec![j!(0), j!(1)]
1245        );
1246        assert_eq!(
1247            SelectionRules::coupled_spins(j!(1 / 2), j!(1)),
1248            vec![j!(1 / 2), j!(3 / 2)]
1249        );
1250        assert_eq!(
1251            SelectionRules::coupled_spins(j!(1), j!(1)),
1252            vec![j!(0), j!(1), j!(2)]
1253        );
1254
1255        let wave = PartialWave::new(j!(1), l!(1), j!(0)).unwrap();
1256        assert_eq!(wave.label(), "1P1");
1257        assert_eq!(wave.to_string(), "1P1");
1258
1259        assert!(PartialWave::new(j!(1), l!(0), j!(0)).is_err());
1260
1261        let pi_plus = pion_like("pi+", "pi-", 1, 1);
1262        let pi_minus = pion_like("pi-", "pi+", -1, -1);
1263        let allowed = AllowedPartialWave::new(wave, (&pi_plus, &pi_minus));
1264
1265        assert_eq!(allowed.parity, Some(Parity::Negative));
1266        assert_eq!(allowed.c_parity, Some(Parity::Negative));
1267
1268        let non_c_pair = AllowedPartialWave::new(
1269            PartialWave::new(j!(0), l!(0), j!(0)).unwrap(),
1270            (&pi_plus, &pion_like("pi0", "pi0", 0, 0)),
1271        );
1272        assert_eq!(non_c_pair.parity, Some(Parity::Positive));
1273        assert_eq!(non_c_pair.c_parity, None);
1274    }
1275
1276    #[test]
1277    fn complete_strong_plus_c_and_g_rules_pass_for_rho_like_to_charged_pions() {
1278        let parent = rho_like();
1279        let pi_plus = pion_like("pi+", "pi-", 1, 1);
1280        let pi_minus = pion_like("pi-", "pi+", -1, -1);
1281
1282        let rules = RuleSet::strong()
1283            .enforce(RuleKind::CParity)
1284            .enforce(RuleKind::GParity)
1285            .enforce(RuleKind::ElectronLeptonNumber)
1286            .enforce(RuleKind::MuonLeptonNumber)
1287            .enforce(RuleKind::TauLeptonNumber)
1288            .enforce(RuleKind::LeptonNumber);
1289
1290        let report = rules.evaluate(&parent, (&pi_plus, &pi_minus), l!(1), j!(0));
1291
1292        assert!(report.is_allowed());
1293        assert_eq!(report.len(), rules.enabled_rules().count());
1294        assert!(
1295            report
1296                .checks
1297                .iter()
1298                .all(|check| { matches!(check.outcome, RuleOutcome::Pass { .. }) })
1299        );
1300
1301        for rule in rules.enabled_rules() {
1302            assert_pass(&report, rule);
1303        }
1304    }
1305
1306    #[test]
1307    fn nontrivial_quantum_number_rules_report_failures() {
1308        let parent = ParticleProperties::meson()
1309            .with_zero_flavor()
1310            .with_name("bad_parent")
1311            .with_self_conjugate_species("bad_parent")
1312            .unwrap()
1313            .with_spin(j!(1))
1314            .with_parity(Parity::Positive)
1315            .with_c_parity(Parity::Positive)
1316            .unwrap()
1317            .with_charge(0)
1318            .with_isospin(Isospin::new(j!(3), Some(m!(1))).unwrap())
1319            .with_g_parity(Parity::Negative)
1320            .with_statistics(Statistics::Boson)
1321            .unwrap();
1322
1323        let pi_plus = pion_like("pi+", "pi-", 1, 1);
1324        let pi_minus = pion_like("pi-", "pi+", -1, -1);
1325
1326        let rules = RuleSet::angular()
1327            .enforce(RuleKind::Parity)
1328            .enforce(RuleKind::Isospin)
1329            .enforce(RuleKind::IsospinProjection)
1330            .enforce(RuleKind::CParity)
1331            .enforce(RuleKind::GParity);
1332
1333        let report = rules.evaluate(&parent, (&pi_plus, &pi_minus), l!(1), j!(0));
1334
1335        assert!(!report.is_allowed());
1336        assert_eq!(report.failures().count(), 5);
1337
1338        assert_fail(&report, RuleKind::Parity);
1339        assert_fail(&report, RuleKind::Isospin);
1340        assert_fail(&report, RuleKind::IsospinProjection);
1341        assert_fail(&report, RuleKind::CParity);
1342        assert_fail(&report, RuleKind::GParity);
1343    }
1344
1345    #[test]
1346    fn additive_and_lepton_rules_report_passes_failures_and_missing_fields() {
1347        let parent = add_additives(
1348            ParticleProperties::unknown(),
1349            1, // charge violation against 0 + 0
1350            1, // strangeness violation
1351            1, // charm violation
1352            1, // bottomness violation
1353            1, // topness violation
1354            1, // baryon-number violation
1355            1, // electron-family lepton-number violation
1356            0,
1357            0,
1358        );
1359
1360        let daughter_a = add_additives(ParticleProperties::unknown(), 0, 0, 0, 0, 0, 0, 0, 1, 0);
1361        let daughter_b = add_additives(ParticleProperties::unknown(), 0, 0, 0, 0, 0, 0, 0, 0, 0);
1362
1363        let rules = RuleSet::angular()
1364            .enforce(RuleKind::Charge)
1365            .enforce(RuleKind::Strangeness)
1366            .enforce(RuleKind::Charm)
1367            .enforce(RuleKind::Bottomness)
1368            .enforce(RuleKind::Topness)
1369            .enforce(RuleKind::BaryonNumber)
1370            .enforce(RuleKind::ElectronLeptonNumber)
1371            .enforce(RuleKind::MuonLeptonNumber)
1372            .enforce(RuleKind::TauLeptonNumber)
1373            .enforce(RuleKind::LeptonNumber);
1374
1375        let report = rules.evaluate(&parent, (&daughter_a, &daughter_b), l!(0), j!(0));
1376
1377        assert!(!report.is_allowed());
1378
1379        for rule in [
1380            RuleKind::Charge,
1381            RuleKind::Strangeness,
1382            RuleKind::Charm,
1383            RuleKind::Bottomness,
1384            RuleKind::Topness,
1385            RuleKind::BaryonNumber,
1386            RuleKind::ElectronLeptonNumber,
1387            RuleKind::MuonLeptonNumber,
1388        ] {
1389            assert_fail(&report, rule);
1390        }
1391
1392        assert_pass(&report, RuleKind::TauLeptonNumber);
1393        assert_pass(&report, RuleKind::LeptonNumber);
1394
1395        let parent_missing = ParticleProperties::unknown().with_charge(0);
1396        let a_missing = ParticleProperties::unknown().with_charge(0);
1397        let b_missing = ParticleProperties::unknown();
1398
1399        let unknown_report = RuleSet::angular()
1400            .enforce(RuleKind::Charge)
1401            .enforce(RuleKind::LeptonNumber)
1402            .evaluate(&parent_missing, (&a_missing, &b_missing), l!(0), j!(0));
1403
1404        assert!(unknown_report.is_allowed());
1405        assert_unknown_allowed(&unknown_report, RuleKind::Charge, &["daughter_b.charge"]);
1406        assert_unknown_allowed(
1407            &unknown_report,
1408            RuleKind::LeptonNumber,
1409            &[
1410                "parent.electron_lepton_number",
1411                "daughter_a.electron_lepton_number",
1412                "daughter_b.electron_lepton_number",
1413            ],
1414        );
1415    }
1416
1417    #[test]
1418    fn identical_particle_symmetry_handles_bosons_and_fermions_with_spin_dependence() {
1419        let scalar_a = identical_boson(j!(0), "scalar");
1420        let scalar_b = identical_boson(j!(0), "scalar");
1421
1422        let vector_a = identical_boson(j!(1), "vector");
1423        let vector_b = identical_boson(j!(1), "vector");
1424
1425        let fermion_a = identical_fermion(j!(1 / 2), "fermion");
1426        let fermion_b = identical_fermion(j!(1 / 2), "fermion");
1427
1428        let rules = RuleSet::angular().enforce(RuleKind::IdenticalParticleSymmetry);
1429
1430        let scalar_even_l = rules.evaluate(
1431            &ParticleProperties::unknown(),
1432            (&scalar_a, &scalar_b),
1433            l!(0),
1434            j!(0),
1435        );
1436        assert_pass(&scalar_even_l, RuleKind::IdenticalParticleSymmetry);
1437
1438        let scalar_odd_l = rules.evaluate(
1439            &ParticleProperties::unknown(),
1440            (&scalar_a, &scalar_b),
1441            l!(1),
1442            j!(0),
1443        );
1444        assert_fail(&scalar_odd_l, RuleKind::IdenticalParticleSymmetry);
1445
1446        let vector_s0 = rules.evaluate(
1447            &ParticleProperties::unknown(),
1448            (&vector_a, &vector_b),
1449            l!(0),
1450            j!(0),
1451        );
1452        assert_pass(&vector_s0, RuleKind::IdenticalParticleSymmetry);
1453
1454        let vector_s1 = rules.evaluate(
1455            &ParticleProperties::unknown(),
1456            (&vector_a, &vector_b),
1457            l!(0),
1458            j!(1),
1459        );
1460        assert_fail(&vector_s1, RuleKind::IdenticalParticleSymmetry);
1461
1462        let fermion_s0 = rules.evaluate(
1463            &ParticleProperties::unknown(),
1464            (&fermion_a, &fermion_b),
1465            l!(0),
1466            j!(0),
1467        );
1468        assert_pass(&fermion_s0, RuleKind::IdenticalParticleSymmetry);
1469
1470        let fermion_s1 = rules.evaluate(
1471            &ParticleProperties::unknown(),
1472            (&fermion_a, &fermion_b),
1473            l!(0),
1474            j!(1),
1475        );
1476        assert_fail(&fermion_s1, RuleKind::IdenticalParticleSymmetry);
1477
1478        let different_species = rules.evaluate(
1479            &ParticleProperties::unknown(),
1480            (&identical_boson(j!(0), "a"), &identical_boson(j!(0), "b")),
1481            l!(1),
1482            j!(0),
1483        );
1484        assert_pass(&different_species, RuleKind::IdenticalParticleSymmetry);
1485    }
1486
1487    #[test]
1488    fn c_parity_rule_distinguishes_inferred_c_from_non_inferable_final_states() {
1489        let parent = rho_like();
1490        let pi_plus = pion_like("pi+", "pi-", 1, 1);
1491        let pi_minus = pion_like("pi-", "pi+", -1, -1);
1492        let pi_zero = pion_like("pi0", "pi0", 0, 0);
1493
1494        let rules = RuleSet::angular().enforce(RuleKind::CParity);
1495
1496        let p_wave_report = rules.evaluate(&parent, (&pi_plus, &pi_minus), l!(1), j!(0));
1497        assert_pass(&p_wave_report, RuleKind::CParity);
1498
1499        let s_wave_report = rules.evaluate(&parent, (&pi_plus, &pi_minus), l!(0), j!(0));
1500        assert_fail(&s_wave_report, RuleKind::CParity);
1501
1502        let unknown_report = rules.evaluate(&parent, (&pi_plus, &pi_zero), l!(1), j!(0));
1503        assert!(unknown_report.is_allowed());
1504        assert_unknown_allowed(
1505            &unknown_report,
1506            RuleKind::CParity,
1507            &[
1508                "daughter_a.species",
1509                "daughter_a.antiparticle_species",
1510                "daughter_b.species",
1511                "daughter_b.antiparticle_species",
1512            ],
1513        );
1514    }
1515
1516    #[test]
1517    fn conventional_meson_jpc_can_be_enforced_or_used_as_non_rejecting_diagnostic() {
1518        let conventional = rho_like();
1519        let exotic = exotic_one_minus_plus();
1520
1521        let diagnostic_rules = RuleSet::angular().diagnose_only(
1522            RuleKind::ConventionalMesonJpc,
1523            "flag exotic JPC without rejecting hybrid candidates",
1524        );
1525
1526        let conventional_report =
1527            diagnostic_rules.evaluate(&conventional, (&conventional, &conventional), l!(0), j!(0));
1528        assert!(conventional_report.is_allowed());
1529        assert_diagnostic(
1530            &conventional_report,
1531            RuleKind::ConventionalMesonJpc,
1532            Some(true),
1533            Some("flag exotic JPC without rejecting hybrid candidates"),
1534        );
1535
1536        let exotic_report = diagnostic_rules.evaluate(&exotic, (&exotic, &exotic), l!(0), j!(0));
1537        assert!(exotic_report.is_allowed());
1538        assert_diagnostic(
1539            &exotic_report,
1540            RuleKind::ConventionalMesonJpc,
1541            Some(false),
1542            Some("flag exotic JPC without rejecting hybrid candidates"),
1543        );
1544
1545        let enforced_report = RuleSet::angular()
1546            .enforce(RuleKind::ConventionalMesonJpc)
1547            .evaluate(&exotic, (&exotic, &exotic), l!(0), j!(0));
1548        assert!(!enforced_report.is_allowed());
1549        assert_fail(&enforced_report, RuleKind::ConventionalMesonJpc);
1550
1551        let unknown_report = RuleSet::angular()
1552            .diagnose_only_without_reason(RuleKind::ConventionalMesonJpc)
1553            .evaluate(
1554                &ParticleProperties::unknown(),
1555                (
1556                    &ParticleProperties::unknown(),
1557                    &ParticleProperties::unknown(),
1558                ),
1559                l!(0),
1560                j!(0),
1561            );
1562        assert_diagnostic(&unknown_report, RuleKind::ConventionalMesonJpc, None, None);
1563    }
1564
1565    #[test]
1566    fn selection_rules_scan_partial_waves_keeps_rejected_candidates_for_diagnostics() {
1567        let parent = ParticleProperties::jp(j!(1), Parity::Positive);
1568        let a = ParticleProperties::jp(j!(1 / 2), Parity::Positive);
1569        let b = ParticleProperties::jp(j!(1 / 2), Parity::Negative);
1570
1571        let angular_scan = SelectionRules::angular(l!(2)).scan_partial_waves(&parent, (&a, &b));
1572
1573        assert!(angular_scan.missing_inputs.is_empty());
1574        assert_eq!(
1575            candidate_labels(angular_scan.candidates.iter()),
1576            vec!["1P1", "3S1", "3P1", "3D1"]
1577        );
1578        assert_eq!(
1579            allowed_labels(angular_scan.allowed()),
1580            vec!["1P1", "3S1", "3P1", "3D1"]
1581        );
1582        assert_eq!(angular_scan.rejected().count(), 0);
1583
1584        let parity_rules = SelectionRules::new(RuleSet::angular().enforce(RuleKind::Parity), l!(2));
1585        let parity_scan = parity_rules.scan_partial_waves(&parent, (&a, &b));
1586
1587        assert_eq!(
1588            candidate_labels(parity_scan.candidates.iter()),
1589            vec!["1P1", "3S1", "3P1", "3D1"]
1590        );
1591        assert_eq!(allowed_labels(parity_scan.allowed()), vec!["1P1", "3P1"]);
1592        assert_eq!(candidate_labels(parity_scan.rejected()), vec!["3S1", "3D1"]);
1593
1594        let allowed = parity_rules.allowed_partial_waves(&parent, (&a, &b));
1595        assert_eq!(labels(&allowed), vec!["1P1", "3P1"]);
1596    }
1597
1598    #[test]
1599    fn selection_rules_report_missing_spin_inputs_and_default_to_strong_l6() {
1600        assert_eq!(SelectionRules::default(), SelectionRules::strong(l!(6)));
1601        assert_eq!(
1602            SelectionRules::electromagnetic(l!(2)),
1603            SelectionRules::new(RuleSet::electromagnetic(), l!(2))
1604        );
1605        assert_eq!(
1606            SelectionRules::weak(l!(3)),
1607            SelectionRules::new(RuleSet::weak(), l!(3))
1608        );
1609
1610        let parent_missing = ParticleProperties::unknown();
1611        let a = ParticleProperties::jp(j!(0), Parity::Negative);
1612        let b = ParticleProperties::jp(j!(0), Parity::Negative);
1613
1614        let scan = SelectionRules::default().scan_partial_waves(&parent_missing, (&a, &b));
1615
1616        assert!(scan.candidates.is_empty());
1617        assert_eq!(scan.missing_inputs, vec!["parent.spin"]);
1618
1619        let parent = ParticleProperties::jp(j!(0), Parity::Positive);
1620        let a_missing = ParticleProperties::unknown();
1621
1622        let scan = SelectionRules::default().scan_partial_waves(&parent, (&a_missing, &b));
1623
1624        assert!(scan.candidates.is_empty());
1625        assert_eq!(scan.missing_inputs, vec!["daughter_a.spin"]);
1626
1627        let b_missing = ParticleProperties::unknown();
1628
1629        let scan = SelectionRules::default().scan_partial_waves(&parent, (&a, &b_missing));
1630
1631        assert!(scan.candidates.is_empty());
1632        assert_eq!(scan.missing_inputs, vec!["daughter_b.spin"]);
1633    }
1634
1635    #[test]
1636    fn strong_rules_find_delta_like_to_nucleon_pion_p_wave() {
1637        let parent = add_additives(
1638            ParticleProperties::jp(j!(3 / 2), Parity::Positive),
1639            1,
1640            0,
1641            0,
1642            0,
1643            0,
1644            1,
1645            0,
1646            0,
1647            0,
1648        );
1649
1650        let nucleon = add_additives(
1651            ParticleProperties::jp(j!(1 / 2), Parity::Positive),
1652            1,
1653            0,
1654            0,
1655            0,
1656            0,
1657            1,
1658            0,
1659            0,
1660            0,
1661        );
1662
1663        let pion = add_additives(
1664            ParticleProperties::jp(j!(0), Parity::Negative),
1665            0,
1666            0,
1667            0,
1668            0,
1669            0,
1670            0,
1671            0,
1672            0,
1673            0,
1674        );
1675
1676        let rules = SelectionRules::new(
1677            RuleSet::angular()
1678                .enforce(RuleKind::Parity)
1679                .enforce(RuleKind::Charge)
1680                .enforce(RuleKind::BaryonNumber),
1681            l!(4),
1682        );
1683
1684        let waves = rules.allowed_partial_waves(&parent, (&nucleon, &pion));
1685
1686        assert_eq!(labels(&waves), vec!["2P3/2"]);
1687        assert_eq!(waves[0].parity, Some(Parity::Positive));
1688        assert_eq!(waves[0].c_parity, None);
1689    }
1690}