Skip to main content

hs_predict/rules/
matcher.rs

1//! Rule matching logic — selects the best [`HsRule`] for a given
2//! CAS number, physical form, and optional purity.
3
4use crate::rules::static_table::HS_RULES;
5use crate::rules::types::{HsRule, ShapePattern};
6use crate::types::PhysicalForm;
7
8/// Find the best matching [`HsRule`] for the given inputs.
9///
10/// When multiple rules match (e.g. both a specific-concentration rule and a
11/// catch-all rule), the one with the highest *specificity score* wins.
12/// Ties are broken by rule order in the static table.
13///
14/// Returns `None` if no rule is registered for this CAS number or if no
15/// rule's shape/purity conditions are satisfied.
16pub fn find_best_rule(
17    cas: &str,
18    physical_form: Option<&PhysicalForm>,
19    purity_pct: Option<f64>,
20) -> Option<&'static HsRule> {
21    let form = physical_form.unwrap_or(&PhysicalForm::Unknown);
22
23    let mut best: Option<(&'static HsRule, u8)> = None;
24
25    for rule in HS_RULES {
26        if rule.cas != cas {
27            continue;
28        }
29
30        // Check shape match
31        if !matches_shape(form, &rule.shape) {
32            continue;
33        }
34
35        // Check purity match
36        let purity_ok = match (&rule.purity_range, purity_pct) {
37            (None, _) => true,
38            (Some(range), Some(p)) => range.contains(&p),
39            (Some(_), None) => false, // rule requires purity but none provided
40        };
41        if !purity_ok {
42            continue;
43        }
44
45        let specificity = shape_specificity(&rule.shape)
46            + if rule.purity_range.is_some() { 2 } else { 0 };
47
48        if best.is_none() || specificity > best.unwrap().1 {
49            best = Some((rule, specificity));
50        }
51    }
52
53    best.map(|(rule, _)| rule)
54}
55
56/// Check whether a concrete [`PhysicalForm`] satisfies a [`ShapePattern`].
57pub fn matches_shape(form: &PhysicalForm, pattern: &ShapePattern) -> bool {
58    match (form, pattern) {
59        (_, ShapePattern::Any) => true,
60        (PhysicalForm::Solid, ShapePattern::Solid) => true,
61        (PhysicalForm::Powder { .. }, ShapePattern::Powder) => true,
62        (PhysicalForm::Granules, ShapePattern::Granules) => true,
63        (PhysicalForm::Granules, ShapePattern::Powder) => true, // granules ≈ powder for most rules
64        (PhysicalForm::Liquid, ShapePattern::Liquid) => true,
65        (PhysicalForm::Gas, ShapePattern::Gas) => true,
66        (PhysicalForm::Ingot, ShapePattern::Ingot) => true,
67        (PhysicalForm::Foil { .. }, ShapePattern::Foil) => true,
68        (PhysicalForm::Unknown, _) => false,
69        (
70            PhysicalForm::Solution { concentration_pct_ww, .. },
71            ShapePattern::Solution { concentration_range_pct },
72        ) => match (concentration_pct_ww, concentration_range_pct) {
73            (_, None) => true,
74            (None, Some(_)) => false,
75            (Some(c), Some(range)) => range.contains(c),
76        },
77        _ => false,
78    }
79}
80
81/// Specificity score for a [`ShapePattern`] (higher = more specific).
82fn shape_specificity(pattern: &ShapePattern) -> u8 {
83    match pattern {
84        ShapePattern::Any => 0,
85        ShapePattern::Solution { concentration_range_pct: None } => 1,
86        ShapePattern::Solution { concentration_range_pct: Some(_) } => 3,
87        _ => 2,
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn naoh_solid_matches_281511() {
97        let rule = find_best_rule("1310-73-2", Some(&PhysicalForm::Solid), None).unwrap();
98        assert_eq!(rule.hs_code, "281511");
99    }
100
101    #[test]
102    fn naoh_solution_matches_281512() {
103        let rule = find_best_rule(
104            "1310-73-2",
105            Some(&PhysicalForm::Solution { solvent: None, concentration_pct_ww: Some(50.0) }),
106            None,
107        )
108        .unwrap();
109        assert_eq!(rule.hs_code, "281512");
110    }
111
112    #[test]
113    fn hno3_fuming_matches_specific_code() {
114        let rule = find_best_rule(
115            "7697-37-2",
116            Some(&PhysicalForm::Solution { solvent: None, concentration_pct_ww: Some(99.0) }),
117            None,
118        )
119        .unwrap();
120        // Fuming nitric acid (≥98%) should get the more specific code
121        assert!(rule.confidence >= 0.85);
122    }
123
124    #[test]
125    fn unknown_cas_returns_none() {
126        let rule = find_best_rule("0000-00-0", Some(&PhysicalForm::Solid), None);
127        assert!(rule.is_none());
128    }
129}