sdsforge_core/generation/
provenance.rs1use serde::{Deserialize, Serialize};
2
3#[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#[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#[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#[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 pub retrieved_at: Option<String>,
73 pub confidence: ConfidenceLevel,
74 pub warnings: Vec<String>,
75}
76
77impl FieldProvenance {
78 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 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 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 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 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 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
246pub 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 pub const CANONICAL_SMILES: &str = "SMILES";
269 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}