use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfidenceLevel {
High,
Medium,
Low,
Unverified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceLevel {
ProductTestReport,
EquivalentBatchTestReport,
SupplierSpecification,
SupplierSds,
RegulatoryDatabase,
PeerReviewedLiterature,
ReferenceDatabase,
DeterministicCalculation,
ModelEstimate,
UnverifiedUserInput,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MeasurementConditions {
pub temperature_c: Option<f64>,
pub pressure_kpa: Option<f64>,
pub atmosphere: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldProvenance {
pub path: String,
pub source_type: EvidenceLevel,
pub source_reference: Option<String>,
pub source_value: Option<String>,
pub method: String,
pub sample_id: Option<String>,
pub batch_id: Option<String>,
pub test_method: Option<String>,
pub conditions: Option<MeasurementConditions>,
pub retrieved_at: Option<String>,
pub confidence: ConfidenceLevel,
pub warnings: Vec<String>,
}
impl FieldProvenance {
pub fn supplied(path: impl Into<String>, method: impl Into<String>) -> Self {
FieldProvenance {
path: path.into(),
source_type: EvidenceLevel::UnverifiedUserInput,
source_reference: None,
source_value: None,
method: method.into(),
sample_id: None,
batch_id: None,
test_method: None,
conditions: None,
retrieved_at: None,
confidence: ConfidenceLevel::Unverified,
warnings: Vec::new(),
}
}
pub fn from_cas_resolver(path: impl Into<String>, pubchem_cid: Option<u64>) -> Self {
FieldProvenance {
path: path.into(),
source_type: EvidenceLevel::ReferenceDatabase,
source_reference: pubchem_cid.map(|cid| format!("PubChem CID {cid}")),
source_value: None,
method: "CAS lookup through existing enrichment layer (PubChem)".into(),
sample_id: None,
batch_id: None,
test_method: None,
conditions: None,
retrieved_at: None,
confidence: ConfidenceLevel::Medium,
warnings: Vec::new(),
}
}
pub fn from_measured_evidence(
path: impl Into<String>,
source: &super::EvidenceSource,
method: Option<&str>,
sample_id: Option<&str>,
batch_id: Option<&str>,
conditions: &MeasurementConditions,
) -> Self {
FieldProvenance {
path: path.into(),
source_type: source.level,
source_reference: Some(source.reference.clone()),
source_value: None,
method: "resolved from supplied measured-property evidence".into(),
sample_id: sample_id.map(str::to_string),
batch_id: batch_id.map(str::to_string),
test_method: method.map(str::to_string),
conditions: Some(conditions.clone()),
retrieved_at: None,
confidence: ConfidenceLevel::High,
warnings: Vec::new(),
}
}
pub fn from_test_result_evidence(
path: impl Into<String>,
source: &super::EvidenceSource,
result: &super::TestResultEvidence,
) -> Self {
FieldProvenance {
path: path.into(),
source_type: source.level,
source_reference: Some(source.reference.clone()),
source_value: Some(result.result.clone()),
method: "resolved from supplied measured-property evidence".into(),
sample_id: result.sample_id.clone(),
batch_id: result.batch_id.clone(),
test_method: result.method.clone(),
conditions: None,
retrieved_at: None,
confidence: ConfidenceLevel::High,
warnings: Vec::new(),
}
}
pub fn source_smiles(pubchem_cid: Option<u64>, used_connectivity_fallback: bool) -> Self {
FieldProvenance {
path: path::SOURCE_SMILES.to_string(),
source_type: EvidenceLevel::ReferenceDatabase,
source_reference: pubchem_cid.map(|cid| format!("PubChem CID {cid}")),
source_value: None,
method: if used_connectivity_fallback {
"PubChem CAS lookup (ConnectivitySMILES fallback)".into()
} else {
"PubChem CAS lookup (SMILES)".into()
},
sample_id: None,
batch_id: None,
test_method: None,
conditions: None,
retrieved_at: None,
confidence: ConfidenceLevel::Medium,
warnings: if used_connectivity_fallback {
vec![
"connectivity-only SMILES used as fallback -- stereochemistry/isotope \
information may not be represented"
.to_string(),
]
} else {
Vec::new()
},
}
}
pub fn canonical_smiles(pubchem_cid: Option<u64>, warnings: Vec<String>) -> Self {
FieldProvenance {
path: path::CANONICAL_SMILES.to_string(),
source_type: EvidenceLevel::DeterministicCalculation,
source_reference: pubchem_cid.map(|cid| format!("PubChem CID {cid}")),
source_value: None,
method: "chematic 0.4.30 canonical_smiles".into(),
sample_id: None,
batch_id: None,
test_method: None,
conditions: None,
retrieved_at: None,
confidence: ConfidenceLevel::Medium,
warnings,
}
}
}
pub mod path {
pub const TRADE_NAME_JP: &str = "Identification.TradeProductIdentity.TradeNameJP";
pub const OTHER_NAME: &str = "Identification.TradeProductIdentity.OtherName";
pub const SUPPLIER_COMPANY_NAME: &str = "Identification.SupplierInformation.CompanyName";
pub const SUPPLIER_ADDRESS: &str = "Identification.SupplierInformation.Address";
pub const SUPPLIER_PHONE: &str = "Identification.SupplierInformation.Phone";
pub const SUPPLIER_EMAIL: &str = "Identification.SupplierInformation.Email";
pub fn composition_row(index: usize, field: &str) -> String {
format!("Composition.CompositionAndConcentration[{index}].{field}")
}
pub const GENERIC_NAME: &str = "SubstanceIdentifiers.SubstanceNames.GenericName";
pub const IUPAC_NAME: &str = "SubstanceIdentifiers.SubstanceNames.IupacName";
pub const CAS_NO: &str = "SubstanceIdentifiers.SubstanceIdentity.CASno";
pub const CONCENTRATION: &str = "Concentration";
pub const MOLECULAR_FORMULA: &str = "MolecularFormula";
pub const CANONICAL_SMILES: &str = "SMILES";
pub const SOURCE_SMILES: &str = "Structure.SourceSmiles (report-only, no schema field)";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_convention_is_stable() {
assert_eq!(
path::TRADE_NAME_JP,
"Identification.TradeProductIdentity.TradeNameJP"
);
assert_eq!(
path::composition_row(0, path::GENERIC_NAME),
"Composition.CompositionAndConcentration[0].SubstanceIdentifiers.SubstanceNames.GenericName"
);
assert_eq!(
path::composition_row(2, path::CAS_NO),
"Composition.CompositionAndConcentration[2].SubstanceIdentifiers.SubstanceIdentity.CASno"
);
}
#[test]
fn supplied_provenance_is_unverified() {
let p = FieldProvenance::supplied(path::TRADE_NAME_JP, "supplied in ProductInput");
assert_eq!(p.source_type, EvidenceLevel::UnverifiedUserInput);
assert_eq!(p.confidence, ConfidenceLevel::Unverified);
}
#[test]
fn cas_resolver_provenance_is_reference_database_not_confirmed() {
let p = FieldProvenance::from_cas_resolver(
path::composition_row(0, path::IUPAC_NAME),
Some(962),
);
assert_eq!(p.source_type, EvidenceLevel::ReferenceDatabase);
assert_eq!(p.source_reference.as_deref(), Some("PubChem CID 962"));
}
#[test]
fn source_smiles_full_form_has_no_fallback_warning() {
let p = FieldProvenance::source_smiles(Some(702), false);
assert_eq!(p.source_type, EvidenceLevel::ReferenceDatabase);
assert_eq!(p.method, "PubChem CAS lookup (SMILES)");
assert!(p.warnings.is_empty());
}
#[test]
fn source_smiles_connectivity_fallback_discloses_stereo_loss() {
let p = FieldProvenance::source_smiles(Some(702), true);
assert_eq!(p.source_type, EvidenceLevel::ReferenceDatabase);
assert_eq!(p.method, "PubChem CAS lookup (ConnectivitySMILES fallback)");
assert_eq!(p.warnings.len(), 1);
assert!(p.warnings[0].contains("stereochemistry/isotope"));
}
#[test]
fn evidence_level_serializes_snake_case() {
let json = serde_json::to_string(&EvidenceLevel::UnverifiedUserInput).unwrap();
assert_eq!(json, "\"unverified_user_input\"");
let json = serde_json::to_string(&EvidenceLevel::ReferenceDatabase).unwrap();
assert_eq!(json, "\"reference_database\"");
}
}