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}