Skip to main content

sdsforge_core/normalize/
mod.rs

1//! Chemical structure normalization — a peer to [`crate::enrichment`], not
2//! nested inside it or inside [`crate::generation`]. Mirrors the boundary
3//! `docs/sdsforge-architecture.md`'s "chematic integration boundary" section
4//! describes:
5//!
6//! ```text
7//! enrichment  — resolves CAS -> candidate record(s) (PubChem)
8//! normalize   — parses/canonicalizes/flags-inconsistency on a candidate
9//!               enrichment already resolved (this module)
10//! generation  — decides whether the result is usable, records provenance
11//!               and uncertainty, maps verified data into the SDS draft
12//! ```
13//!
14//! A normalizer never resolves CAS numbers, never predicts finished-product
15//! properties (flash point, vapor pressure, GHS classification, ...), and
16//! never becomes evidence for anything [`crate::generation`]'s
17//! `PRODUCT_LEVEL_POLICIES` already governs — those stay exactly as commits
18//! 2ac2758/d4dd15d ("A") left them, untouched by this module.
19
20#[cfg(feature = "chematic-normalization")]
21mod chematic_impl;
22#[cfg(feature = "chematic-normalization")]
23pub use chematic_impl::ChematicNormalizer;
24
25use crate::enrichment::ChemicalIdentityCandidate;
26
27/// The normalization-input SMILES to use for a candidate: PubChem's full
28/// `smiles` (stereochemistry/isotopes included where represented) is always
29/// preferred; `connectivity_smiles` (connectivity only) is used only as a
30/// fallback when the full form is unavailable, since it represents strictly
31/// less of the actual structure. Shared by every [`ChemicalNormalizer`]
32/// implementation so they agree on what counts as "structure present".
33pub(crate) fn preferred_smiles(candidate: &ChemicalIdentityCandidate) -> Option<&str> {
34    candidate
35        .smiles
36        .as_deref()
37        .or(candidate.connectivity_smiles.as_deref())
38}
39
40/// A local, deterministic parse/canonicalize/consistency-check step over one
41/// already-resolved [`ChemicalIdentityCandidate`]. Implementations must not
42/// perform network I/O (normalization is local by design) and must not
43/// silently replace the candidate's identity (e.g. by stripping a salt) —
44/// see [`ChemicalNormalizationResult`]'s doc comment.
45pub trait ChemicalNormalizer {
46    fn normalize(&self, candidate: &ChemicalIdentityCandidate) -> ChemicalNormalizationResult;
47}
48
49/// Explicit behavior when chematic support isn't compiled in — distinct from
50/// "no SMILES was supplied" ([`NormalizationStatus::MissingStructure`]) so a
51/// caller can tell the two situations apart.
52pub struct UnavailableNormalizer;
53
54impl ChemicalNormalizer for UnavailableNormalizer {
55    fn normalize(&self, candidate: &ChemicalIdentityCandidate) -> ChemicalNormalizationResult {
56        match preferred_smiles(candidate) {
57            None => ChemicalNormalizationResult {
58                original_smiles: None,
59                canonical_smiles: None,
60                status: NormalizationStatus::MissingStructure,
61                issues: vec![],
62                calculated: CalculatedIdentityProperties::default(),
63                screening_alerts: vec![],
64            },
65            Some(smiles) => ChemicalNormalizationResult {
66                original_smiles: Some(smiles.to_string()),
67                canonical_smiles: None,
68                status: NormalizationStatus::ReviewRequired,
69                issues: vec![],
70                calculated: CalculatedIdentityProperties::default(),
71                screening_alerts: vec![],
72            },
73        }
74    }
75}
76
77/// Result of normalizing one candidate. `canonical_smiles` is a
78/// **deterministic transformation of the input for this chematic version**,
79/// not a confirmation of chemical identity — a candidate a normalizer
80/// cannot process at all (missing/invalid structure, or the
81/// `chematic-normalization` feature disabled) still returns supplied
82/// CAS/name/concentration data untouched elsewhere in the generation
83/// pipeline; this result only ever adds information, it never causes
84/// already-resolved fields to be discarded.
85#[derive(Debug, Clone)]
86pub struct ChemicalNormalizationResult {
87    /// The candidate's own SMILES, unchanged, kept separate from anything
88    /// derived from it.
89    pub original_smiles: Option<String>,
90    pub canonical_smiles: Option<String>,
91    pub status: NormalizationStatus,
92    pub issues: Vec<NormalizationIssue>,
93    pub calculated: CalculatedIdentityProperties,
94    /// PAINS/Brenk substructure alert names — screening-only, never a GHS
95    /// classification or confirmed hazard, never product-level evidence,
96    /// never blocks release by itself.
97    pub screening_alerts: Vec<String>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum NormalizationStatus {
102    Normalized,
103    MissingStructure,
104    InvalidStructure,
105    Ambiguous,
106    ReviewRequired,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum NormalizationIssue {
111    InvalidSmiles,
112    MultipleResolverCandidates,
113    /// A dot-disconnected structure (e.g. a salt or solvate) — reported,
114    /// never silently reduced to its largest fragment. See
115    /// `docs/sdsforge-architecture.md`: a salt/solvate/charged form may be
116    /// the *correct* identity for the CAS number; picking the largest
117    /// fragment could silently turn it into a different substance.
118    MultiFragmentStructure,
119    FormulaMismatch,
120    ChargeOrSaltPresent,
121    UnsupportedPolymerOrMixture,
122    StereochemistryNotFullyPreserved,
123}
124
125/// Deterministic calculations over the parsed structure — never an
126/// invented value. Every field is `None` when the underlying chematic
127/// operation wasn't run (e.g. `UnavailableNormalizer`, or parsing failed).
128#[derive(Debug, Clone, Default)]
129pub struct CalculatedIdentityProperties {
130    pub molecular_formula: Option<String>,
131    pub molecular_weight: Option<f64>,
132    pub formal_charge_sum: Option<i32>,
133    /// Whether the structure has more than one disconnected fragment.
134    /// Deliberately not a `fragment_count: usize` — chematic's
135    /// `largest_fragment` only gives a yes/no signal (does the largest
136    /// fragment have fewer atoms than the whole structure?), not an exact
137    /// count; claiming a precise count would invent a value the underlying
138    /// operation doesn't actually provide.
139    pub has_multiple_fragments: Option<bool>,
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn candidate(smiles: Option<&str>) -> ChemicalIdentityCandidate {
147        ChemicalIdentityCandidate {
148            cas: "7732-18-5".into(),
149            pubchem_cid: Some(962),
150            iupac_name: Some("oxidane".into()),
151            molecular_formula: Some("H2O".into()),
152            smiles: smiles.map(str::to_string),
153            connectivity_smiles: None,
154            inchi_key: None,
155        }
156    }
157
158    #[test]
159    fn unavailable_normalizer_reports_missing_structure_when_no_smiles() {
160        let result = UnavailableNormalizer.normalize(&candidate(None));
161        assert_eq!(result.status, NormalizationStatus::MissingStructure);
162    }
163
164    #[test]
165    fn unavailable_normalizer_reports_review_required_when_smiles_present() {
166        // Distinct from "no SMILES supplied" — chematic just isn't compiled in.
167        let result = UnavailableNormalizer.normalize(&candidate(Some("O")));
168        assert_eq!(result.status, NormalizationStatus::ReviewRequired);
169        assert!(result.canonical_smiles.is_none());
170    }
171
172    #[test]
173    fn full_smiles_is_preferred_over_connectivity_smiles() {
174        let mut c = candidate(Some("CCO"));
175        c.connectivity_smiles = Some("CO".into());
176        assert_eq!(preferred_smiles(&c), Some("CCO"));
177    }
178
179    #[test]
180    fn connectivity_smiles_is_used_only_as_fallback() {
181        let mut c = candidate(None);
182        c.connectivity_smiles = Some("CCO".into());
183        assert_eq!(preferred_smiles(&c), Some("CCO"));
184
185        let result = UnavailableNormalizer.normalize(&c);
186        assert_eq!(result.status, NormalizationStatus::ReviewRequired);
187        assert_eq!(result.original_smiles.as_deref(), Some("CCO"));
188    }
189
190    /// Feature-disabled sanity check: this whole crate, including this
191    /// test, must compile and pass without `chematic-normalization`.
192    #[cfg(not(feature = "chematic-normalization"))]
193    #[test]
194    fn crate_compiles_without_chematic_normalization_feature() {
195        // Reaching this assertion at all is the test.
196    }
197}