Skip to main content

sdsforge_core/generation/
provenance.rs

1use serde::{Deserialize, Serialize};
2
3/// How confident the generator is in a value, independent of where the
4/// value came from. A `SupplierSpecification` source and a
5/// `ProductTestReport` source can both be `High` confidence; an
6/// `UnverifiedUserInput` source is `Unverified` by construction — nothing
7/// about passing structural validation (CAS check-digit, concentration
8/// bounds) upgrades it, since syntactic validity isn't evidence that a
9/// company name, address, or product identity is factually correct.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ConfidenceLevel {
13    High,
14    Medium,
15    Low,
16    Unverified,
17}
18
19/// Priority-ordered evidence source. Not every level is valid for every
20/// field — see [`super::unresolved::FieldPolicy::allowed_evidence`].
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum EvidenceLevel {
24    ProductTestReport,
25    EquivalentBatchTestReport,
26    SupplierSpecification,
27    SupplierSds,
28    RegulatoryDatabase,
29    PeerReviewedLiterature,
30    ReferenceDatabase,
31    DeterministicCalculation,
32    ModelEstimate,
33    UnverifiedUserInput,
34    None,
35}
36
37/// Measurement conditions for a physical/chemical property. Kept small —
38/// this only needs enough structure to say what evidence the user must
39/// provide, not to model a general laboratory information system.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct MeasurementConditions {
43    pub temperature_c: Option<f64>,
44    pub pressure_kpa: Option<f64>,
45    pub atmosphere: Option<String>,
46}
47
48/// A single field's origin: where its value came from, how, and how
49/// confident the generator is in it. One `FieldProvenance` per populated
50/// field.
51///
52/// `path` uses dot-separated MHLW JSON key names (the same names
53/// `#[serde(rename = "...")]` puts on the wire in `official_sds.json`), with
54/// `[i]` for array indices — e.g.
55/// `"Composition.CompositionAndConcentration[0].SubstanceIdentifiers.SubstanceNames.GenericName"`.
56/// This is the one canonical path convention for this feature; see
57/// `path_helpers` below and `tests::path_convention_is_stable`.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct FieldProvenance {
60    pub path: String,
61    pub source_type: EvidenceLevel,
62    pub source_reference: Option<String>,
63    pub source_value: Option<String>,
64    pub method: String,
65    pub sample_id: Option<String>,
66    pub batch_id: Option<String>,
67    pub test_method: Option<String>,
68    pub conditions: Option<MeasurementConditions>,
69    /// Deliberately left `None` by every code path in this commit — no
70    /// live timestamp is generated, so repeated generation from the same
71    /// input produces byte-identical report JSON.
72    pub retrieved_at: Option<String>,
73    pub confidence: ConfidenceLevel,
74    pub warnings: Vec<String>,
75}
76
77impl FieldProvenance {
78    /// A value the caller typed into `ProductInput` directly — trade name,
79    /// supplier contact fields, component name/CAS/concentration. Always
80    /// `UnverifiedUserInput` / `Unverified` confidence: structural
81    /// validation (commit #8) confirms the *shape* is well-formed, not that
82    /// the content is factually correct.
83    pub fn supplied(path: impl Into<String>, method: impl Into<String>) -> Self {
84        FieldProvenance {
85            path: path.into(),
86            source_type: EvidenceLevel::UnverifiedUserInput,
87            source_reference: None,
88            source_value: None,
89            method: method.into(),
90            sample_id: None,
91            batch_id: None,
92            test_method: None,
93            conditions: None,
94            retrieved_at: None,
95            confidence: ConfidenceLevel::Unverified,
96            warnings: Vec::new(),
97        }
98    }
99
100    /// A value returned by the existing CAS enrichment resolver
101    /// ([`crate::enrichment::lookup_cas`]). `ReferenceDatabase`, not
102    /// `Confirmed`/`DeterministicCalculation` — a CAS lookup result is
103    /// evidence about a chemical identity *candidate*, not proof that the
104    /// supplied product or batch actually contains that substance at the
105    /// declared concentration, and this commit records the resolver's
106    /// returned value rather than recalculating it from structure.
107    pub fn from_cas_resolver(path: impl Into<String>, pubchem_cid: Option<u64>) -> Self {
108        FieldProvenance {
109            path: path.into(),
110            source_type: EvidenceLevel::ReferenceDatabase,
111            source_reference: pubchem_cid.map(|cid| format!("PubChem CID {cid}")),
112            source_value: None,
113            method: "CAS lookup through existing enrichment layer (PubChem)".into(),
114            sample_id: None,
115            batch_id: None,
116            test_method: None,
117            conditions: None,
118            retrieved_at: None,
119            confidence: ConfidenceLevel::Medium,
120            warnings: Vec::new(),
121        }
122    }
123
124    /// A property resolved from caller-supplied measured-value evidence
125    /// (flash point, boiling point, vapor pressure, explosive limits) that
126    /// fully satisfied its `FieldPolicy` — see
127    /// `super::resolve::resolve_measured_properties`. `confidence: High`
128    /// and `source_type` taken directly from the [`super::EvidenceSource`]
129    /// that resolved it (`ProductTestReport`/`EquivalentBatchTestReport`/
130    /// `SupplierSpecification`, whichever this property's policy accepted)
131    /// — never upgraded to a status the evidence itself doesn't support.
132    pub fn from_measured_evidence(
133        path: impl Into<String>,
134        source: &super::EvidenceSource,
135        method: Option<&str>,
136        sample_id: Option<&str>,
137        batch_id: Option<&str>,
138        conditions: &MeasurementConditions,
139    ) -> Self {
140        FieldProvenance {
141            path: path.into(),
142            source_type: source.level,
143            source_reference: Some(source.reference.clone()),
144            source_value: None,
145            method: "resolved from supplied measured-property evidence".into(),
146            sample_id: sample_id.map(str::to_string),
147            batch_id: batch_id.map(str::to_string),
148            test_method: method.map(str::to_string),
149            conditions: Some(conditions.clone()),
150            retrieved_at: None,
151            confidence: ConfidenceLevel::High,
152            warnings: Vec::new(),
153        }
154    }
155
156    /// Same as [`Self::from_measured_evidence`] for the three free-text-only
157    /// properties (self-reactivity, oxidizing properties, metal
158    /// corrosivity), whose evidence is a [`super::TestResultEvidence`]
159    /// rather than a numeric value.
160    pub fn from_test_result_evidence(
161        path: impl Into<String>,
162        source: &super::EvidenceSource,
163        result: &super::TestResultEvidence,
164    ) -> Self {
165        FieldProvenance {
166            path: path.into(),
167            source_type: source.level,
168            source_reference: Some(source.reference.clone()),
169            source_value: Some(result.result.clone()),
170            method: "resolved from supplied measured-property evidence".into(),
171            sample_id: result.sample_id.clone(),
172            batch_id: result.batch_id.clone(),
173            test_method: result.method.clone(),
174            conditions: None,
175            retrieved_at: None,
176            confidence: ConfidenceLevel::High,
177            warnings: Vec::new(),
178        }
179    }
180
181    /// The candidate's own structure as PubChem returned it, before any
182    /// normalization — `ReferenceDatabase`, same evidence level as
183    /// [`Self::from_cas_resolver`], never `Confirmed`. Applies equally to
184    /// either PubChem representation actually used as the normalization
185    /// input: the full `SMILES` property (stereochemistry/isotopes where
186    /// represented) or, only as a fallback, `ConnectivitySMILES`
187    /// (connectivity only) — neither is chematic's separately-provenanced,
188    /// `DeterministicCalculation`-level canonical form (see
189    /// [`Self::canonical_smiles`]). `used_connectivity_fallback` records
190    /// which one so the report can disclose when stereochemistry/isotope
191    /// information may not be represented.
192    pub fn source_smiles(pubchem_cid: Option<u64>, used_connectivity_fallback: bool) -> Self {
193        FieldProvenance {
194            path: path::SOURCE_SMILES.to_string(),
195            source_type: EvidenceLevel::ReferenceDatabase,
196            source_reference: pubchem_cid.map(|cid| format!("PubChem CID {cid}")),
197            source_value: None,
198            method: if used_connectivity_fallback {
199                "PubChem CAS lookup (ConnectivitySMILES fallback)".into()
200            } else {
201                "PubChem CAS lookup (SMILES)".into()
202            },
203            sample_id: None,
204            batch_id: None,
205            test_method: None,
206            conditions: None,
207            retrieved_at: None,
208            confidence: ConfidenceLevel::Medium,
209            warnings: if used_connectivity_fallback {
210                vec![
211                    "connectivity-only SMILES used as fallback -- stereochemistry/isotope \
212                     information may not be represented"
213                        .to_string(),
214                ]
215            } else {
216                Vec::new()
217            },
218        }
219    }
220
221    /// The chematic-canonicalized SMILES actually written into
222    /// `official_sds.json`'s `SMILES` field. `DeterministicCalculation` —
223    /// a canonical form is a deterministic transformation for this
224    /// chematic version and input, not proof the underlying chemical
225    /// identity is confirmed. Never `Confirmed`/`ProductTestReport`/
226    /// `EquivalentBatchTestReport` — the evidence level is hardcoded here,
227    /// not something a caller can override.
228    pub fn canonical_smiles(pubchem_cid: Option<u64>, warnings: Vec<String>) -> Self {
229        FieldProvenance {
230            path: path::CANONICAL_SMILES.to_string(),
231            source_type: EvidenceLevel::DeterministicCalculation,
232            source_reference: pubchem_cid.map(|cid| format!("PubChem CID {cid}")),
233            source_value: None,
234            method: "chematic 0.4.30 canonical_smiles".into(),
235            sample_id: None,
236            batch_id: None,
237            test_method: None,
238            conditions: None,
239            retrieved_at: None,
240            confidence: ConfidenceLevel::Medium,
241            warnings,
242        }
243    }
244}
245
246/// Canonical MHLW JSON path fragments, so every call site builds paths the
247/// same way instead of hand-formatting strings.
248pub mod path {
249    pub const TRADE_NAME_JP: &str = "Identification.TradeProductIdentity.TradeNameJP";
250    pub const OTHER_NAME: &str = "Identification.TradeProductIdentity.OtherName";
251    pub const SUPPLIER_COMPANY_NAME: &str = "Identification.SupplierInformation.CompanyName";
252    pub const SUPPLIER_ADDRESS: &str = "Identification.SupplierInformation.Address";
253    pub const SUPPLIER_PHONE: &str = "Identification.SupplierInformation.Phone";
254    pub const SUPPLIER_EMAIL: &str = "Identification.SupplierInformation.Email";
255
256    pub fn composition_row(index: usize, field: &str) -> String {
257        format!("Composition.CompositionAndConcentration[{index}].{field}")
258    }
259
260    pub const GENERIC_NAME: &str = "SubstanceIdentifiers.SubstanceNames.GenericName";
261    pub const IUPAC_NAME: &str = "SubstanceIdentifiers.SubstanceNames.IupacName";
262    pub const CAS_NO: &str = "SubstanceIdentifiers.SubstanceIdentity.CASno";
263    pub const CONCENTRATION: &str = "Concentration";
264    pub const MOLECULAR_FORMULA: &str = "MolecularFormula";
265    /// The one real schema field (`CompositionCompositionAndConcentration.smiles`,
266    /// `#[serde(rename = "SMILES")]`) — only the chematic-canonicalized value
267    /// is ever written here.
268    pub const CANONICAL_SMILES: &str = "SMILES";
269    /// Report-only path — the schema has no separate "pre-normalization
270    /// SMILES" field, so the resolver's own value (before chematic
271    /// canonicalization) only ever appears in `generation_report`
272    /// provenance, never in `official_sds.json`.
273    pub const SOURCE_SMILES: &str = "Structure.SourceSmiles (report-only, no schema field)";
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn path_convention_is_stable() {
282        assert_eq!(
283            path::TRADE_NAME_JP,
284            "Identification.TradeProductIdentity.TradeNameJP"
285        );
286        assert_eq!(
287            path::composition_row(0, path::GENERIC_NAME),
288            "Composition.CompositionAndConcentration[0].SubstanceIdentifiers.SubstanceNames.GenericName"
289        );
290        assert_eq!(
291            path::composition_row(2, path::CAS_NO),
292            "Composition.CompositionAndConcentration[2].SubstanceIdentifiers.SubstanceIdentity.CASno"
293        );
294    }
295
296    #[test]
297    fn supplied_provenance_is_unverified() {
298        let p = FieldProvenance::supplied(path::TRADE_NAME_JP, "supplied in ProductInput");
299        assert_eq!(p.source_type, EvidenceLevel::UnverifiedUserInput);
300        assert_eq!(p.confidence, ConfidenceLevel::Unverified);
301    }
302
303    #[test]
304    fn cas_resolver_provenance_is_reference_database_not_confirmed() {
305        let p = FieldProvenance::from_cas_resolver(
306            path::composition_row(0, path::IUPAC_NAME),
307            Some(962),
308        );
309        assert_eq!(p.source_type, EvidenceLevel::ReferenceDatabase);
310        assert_eq!(p.source_reference.as_deref(), Some("PubChem CID 962"));
311    }
312
313    #[test]
314    fn source_smiles_full_form_has_no_fallback_warning() {
315        let p = FieldProvenance::source_smiles(Some(702), false);
316        assert_eq!(p.source_type, EvidenceLevel::ReferenceDatabase);
317        assert_eq!(p.method, "PubChem CAS lookup (SMILES)");
318        assert!(p.warnings.is_empty());
319    }
320
321    #[test]
322    fn source_smiles_connectivity_fallback_discloses_stereo_loss() {
323        let p = FieldProvenance::source_smiles(Some(702), true);
324        assert_eq!(p.source_type, EvidenceLevel::ReferenceDatabase);
325        assert_eq!(p.method, "PubChem CAS lookup (ConnectivitySMILES fallback)");
326        assert_eq!(p.warnings.len(), 1);
327        assert!(p.warnings[0].contains("stereochemistry/isotope"));
328    }
329
330    #[test]
331    fn evidence_level_serializes_snake_case() {
332        let json = serde_json::to_string(&EvidenceLevel::UnverifiedUserInput).unwrap();
333        assert_eq!(json, "\"unverified_user_input\"");
334        let json = serde_json::to_string(&EvidenceLevel::ReferenceDatabase).unwrap();
335        assert_eq!(json, "\"reference_database\"");
336    }
337}