1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::converter::validator::{validate_cas_format, Finding};
6use crate::enrichment::{lookup_cas, lookup_cas_detailed, CasInfo, CasResolution};
7use crate::normalize::{ChemicalNormalizer, NormalizationIssue, NormalizationStatus};
8use crate::schema::SdsRoot;
9
10use super::draft::{draft_sections_from_resolved_input, SectionDraftResult};
11use super::input::ProductInput;
12use super::provenance::{path, EvidenceLevel, FieldProvenance};
13use super::resolve;
14use super::unresolved::{
15 build_lookup_failure_unresolved, FieldStatus, NotApplicableReason, RegulatoryImpact,
16 RequiredInput, SafetyImpact, UnresolvedField, UnresolvedReason,
17};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ReleaseStatus {
28 Draft,
29 ReviewRequired,
30 Blocked,
31 Approved,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ReleaseGateResult {
36 pub status: ReleaseStatus,
37 pub blocking_findings: Vec<Finding>,
38 pub required_actions: Vec<String>,
39}
40
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct EvidenceSummary {
47 pub confirmed: usize,
48 pub supplied: usize,
49 pub literature: usize,
50 pub calculated: usize,
51 pub estimated: usize,
52 pub unresolved: usize,
53 pub not_applicable: usize,
54 pub product_test_evidence: usize,
55 pub unverified_user_input: usize,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct GenerationResult {
72 pub sds: SdsRoot,
73 pub findings: Vec<Finding>,
74 pub unresolved: Vec<UnresolvedField>,
75 pub provenance: Vec<FieldProvenance>,
76 pub evidence_summary: EvidenceSummary,
77 pub release_status: ReleaseStatus,
78}
79
80pub fn generate_from_resolved_input(
89 input: &ProductInput,
90 resolved: &HashMap<String, CasInfo>,
91) -> GenerationResult {
92 let SectionDraftResult { mut sds, findings } =
93 draft_sections_from_resolved_input(input, resolved);
94
95 let mut provenance = Vec::new();
96 let mut unresolved = Vec::new();
97
98 provenance.push(FieldProvenance::supplied(
99 path::TRADE_NAME_JP,
100 "supplied in ProductInput",
101 ));
102 if !input.other_names.is_empty() {
103 provenance.push(FieldProvenance::supplied(
104 path::OTHER_NAME,
105 "supplied in ProductInput",
106 ));
107 }
108 provenance.push(FieldProvenance::supplied(
109 path::SUPPLIER_COMPANY_NAME,
110 "supplied in ProductInput",
111 ));
112 if input.supplier.address.is_some() {
113 provenance.push(FieldProvenance::supplied(
114 path::SUPPLIER_ADDRESS,
115 "supplied in ProductInput",
116 ));
117 }
118 if input.supplier.phone.is_some() {
119 provenance.push(FieldProvenance::supplied(
120 path::SUPPLIER_PHONE,
121 "supplied in ProductInput",
122 ));
123 }
124 if input.supplier.email.is_some() {
125 provenance.push(FieldProvenance::supplied(
126 path::SUPPLIER_EMAIL,
127 "supplied in ProductInput",
128 ));
129 }
130
131 for (i, component) in input.components.iter().enumerate() {
132 if component.name.is_some() {
133 provenance.push(FieldProvenance::supplied(
134 path::composition_row(i, path::GENERIC_NAME),
135 "supplied in ComponentInput",
136 ));
137 }
138
139 if let Some(cas) = &component.cas_number {
140 provenance.push(FieldProvenance::supplied(
141 path::composition_row(i, path::CAS_NO),
142 "supplied and check-digit validated",
143 ));
144
145 match resolved.get(cas) {
146 Some(info) => {
147 if info.iupac_name.is_some() {
148 provenance.push(FieldProvenance::from_cas_resolver(
149 path::composition_row(i, path::IUPAC_NAME),
150 info.pubchem_cid,
151 ));
152 }
153 if info.molecular_formula.is_some() {
154 provenance.push(FieldProvenance::from_cas_resolver(
155 path::composition_row(i, path::MOLECULAR_FORMULA),
156 info.pubchem_cid,
157 ));
158 }
159 }
160 None => {
161 if validate_cas_format(cas) {
168 unresolved.push(build_lookup_failure_unresolved(
169 i,
170 cas,
171 component.name.as_deref(),
172 ));
173 }
174 }
175 }
176 }
177
178 provenance.push(FieldProvenance::supplied(
181 path::composition_row(i, path::CONCENTRATION),
182 "supplied and structurally validated",
183 ));
184 }
185
186 let (property_unresolved, property_provenance) =
192 resolve::resolve_measured_properties(input, &mut sds);
193 unresolved.extend(property_unresolved);
194 provenance.extend(property_provenance);
195
196 let evidence_summary = compute_evidence_summary(&provenance, &unresolved);
197 let release_status = compute_release_status(&unresolved, &findings);
198
199 GenerationResult {
200 sds,
201 findings,
202 unresolved,
203 provenance,
204 evidence_summary,
205 release_status,
206 }
207}
208
209pub async fn generate_with_enrichment(
216 input: &ProductInput,
217 client: &reqwest::Client,
218) -> GenerationResult {
219 let mut resolved = HashMap::new();
220 for component in &input.components {
221 if let Some(cas) = &component.cas_number {
222 if let Ok(Some(info)) = lookup_cas(cas, client).await {
223 resolved.insert(cas.clone(), info);
224 }
225 }
226 }
227 generate_from_resolved_input(input, &resolved)
228}
229
230pub fn generate_from_normalized_input<N: ChemicalNormalizer>(
248 input: &ProductInput,
249 resolved: &HashMap<String, CasResolution>,
250 normalizer: &N,
251) -> GenerationResult {
252 let basic: HashMap<String, CasInfo> = resolved
253 .iter()
254 .filter_map(|(cas, res)| match res {
255 CasResolution::Resolved(c) => Some((
256 cas.clone(),
257 CasInfo {
258 cas: c.cas.clone(),
259 iupac_name: c.iupac_name.clone(),
260 molecular_formula: c.molecular_formula.clone(),
261 pubchem_cid: c.pubchem_cid,
262 },
263 )),
264 _ => None,
265 })
266 .collect();
267
268 let mut result = generate_from_resolved_input(input, &basic);
269
270 for (i, component) in input.components.iter().enumerate() {
271 let Some(cas) = &component.cas_number else {
272 continue;
273 };
274 match resolved.get(cas) {
275 Some(CasResolution::Ambiguous(candidates)) => {
276 apply_ambiguous_identity(&mut result, i, cas, candidates);
277 }
278 Some(CasResolution::Resolved(candidate)) => {
279 let normalization = normalizer.normalize(candidate);
280 apply_normalization(&mut result, i, candidate, &normalization);
281 }
282 Some(CasResolution::NotFound) | None => {
283 }
286 }
287 }
288
289 result.evidence_summary = compute_evidence_summary(&result.provenance, &result.unresolved);
290 result.release_status = compute_release_status(&result.unresolved, &result.findings);
291 result
292}
293
294pub async fn generate_with_detailed_enrichment<N: ChemicalNormalizer>(
303 input: &ProductInput,
304 client: &reqwest::Client,
305 normalizer: &N,
306) -> GenerationResult {
307 let mut cas_numbers: Vec<String> = input
308 .components
309 .iter()
310 .filter_map(|c| c.cas_number.clone())
311 .collect();
312 cas_numbers.sort();
313 cas_numbers.dedup();
314
315 let mut lookups: HashMap<String, Result<CasResolution, String>> = HashMap::new();
316 for cas in cas_numbers {
317 let outcome = lookup_cas_detailed(&cas, client)
318 .await
319 .map_err(|e| e.to_string());
320 lookups.insert(cas, outcome);
321 }
322
323 generate_from_detailed_lookups(input, &lookups, normalizer)
324}
325
326pub fn generate_from_detailed_lookups<N: ChemicalNormalizer>(
345 input: &ProductInput,
346 lookups: &HashMap<String, Result<CasResolution, String>>,
347 normalizer: &N,
348) -> GenerationResult {
349 let resolved: HashMap<String, CasResolution> = lookups
350 .iter()
351 .filter_map(|(cas, outcome)| match outcome {
352 Ok(resolution) => Some((cas.clone(), resolution.clone())),
353 Err(_) => None,
354 })
355 .collect();
356
357 let mut result = generate_from_normalized_input(input, &resolved, normalizer);
358
359 for (i, component) in input.components.iter().enumerate() {
360 let Some(cas) = &component.cas_number else {
361 continue;
362 };
363 if let Some(Err(message)) = lookups.get(cas) {
368 if validate_cas_format(cas) {
369 result.findings.push(Finding {
370 level: "MED".into(),
371 rule: "GEN-CAS-LOOKUP-ERROR".into(),
372 message: format!(
373 "CAS lookup failed for component {} (CAS {cas}): {message}. Supplied \
374 identity and composition data was retained; provide an authoritative \
375 identity source or retry enrichment.",
376 i + 1
377 ),
378 });
379 }
380 }
381 }
382
383 result.evidence_summary = compute_evidence_summary(&result.provenance, &result.unresolved);
384 result.release_status = compute_release_status(&result.unresolved, &result.findings);
385 result
386}
387
388fn apply_ambiguous_identity(
389 result: &mut GenerationResult,
390 component_index: usize,
391 cas: &str,
392 candidates: &[crate::enrichment::ChemicalIdentityCandidate],
393) {
394 let cas_path = path::composition_row(component_index, path::CAS_NO);
395 let cids: Vec<String> = candidates
396 .iter()
397 .filter_map(|c| c.pubchem_cid)
398 .map(|cid| cid.to_string())
399 .collect();
400
401 result.findings.push(Finding {
402 level: "HIGH".into(),
403 rule: "GEN-CAS-AMBIGUOUS".into(),
404 message: format!(
405 "CAS '{cas}': PubChem returned {} distinct candidates (CIDs: {}) — a material identity \
406 ambiguity, not resolved automatically.",
407 candidates.len(),
408 if cids.is_empty() { "unknown".to_string() } else { cids.join(", ") }
409 ),
410 });
411
412 result.unresolved.retain(|f| f.path != cas_path);
416 result.unresolved.push(UnresolvedField {
417 path: cas_path,
418 title: format!("Ambiguous chemical identity for CAS '{cas}'"),
419 reason: UnresolvedReason::AmbiguousChemicalIdentity,
420 required_inputs: vec![RequiredInput::new(
421 "authoritative_identity_source",
422 format!(
423 "An authoritative supplier or regulatory source selecting one specific candidate \
424 identity from {} PubChem matches (CIDs: {}).",
425 candidates.len(),
426 if cids.is_empty() {
427 "unknown".to_string()
428 } else {
429 cids.join(", ")
430 }
431 ),
432 )],
433 acceptable_evidence: vec![
434 EvidenceLevel::SupplierSpecification,
435 EvidenceLevel::SupplierSds,
436 EvidenceLevel::RegulatoryDatabase,
437 ],
438 safety_impact: SafetyImpact::High,
439 regulatory_impact: RegulatoryImpact::High,
440 recommended_action:
441 "Do not select a candidate by name similarity, CID order, or structure \
442 size — obtain authoritative confirmation of which candidate matches this CAS number."
443 .into(),
444 blocks_release: true,
445 });
446}
447
448fn apply_normalization(
449 result: &mut GenerationResult,
450 component_index: usize,
451 candidate: &crate::enrichment::ChemicalIdentityCandidate,
452 normalization: &crate::normalize::ChemicalNormalizationResult,
453) {
454 match normalization.status {
455 NormalizationStatus::MissingStructure => {
456 result.findings.push(Finding {
457 level: "LOW".into(),
458 rule: "GEN-STRUCTURE-MISSING".into(),
459 message: format!(
460 "CAS '{}': resolver returned no SMILES structure — CAS/name/concentration remain usable.",
461 candidate.cas
462 ),
463 });
464 }
465 NormalizationStatus::InvalidStructure => {
466 result.findings.push(Finding {
467 level: "MED".into(),
468 rule: "GEN-STRUCTURE-INVALID".into(),
469 message: format!(
470 "CAS '{}': resolver-supplied SMILES could not be parsed — identity remains \
471 independently verifiable via CID/IUPAC name.",
472 candidate.cas
473 ),
474 });
475 }
476 NormalizationStatus::Ambiguous => {
477 }
481 NormalizationStatus::Normalized | NormalizationStatus::ReviewRequired => {
482 let has_mismatch = normalization
483 .issues
484 .contains(&NormalizationIssue::FormulaMismatch);
485 let has_multi_fragment = normalization
486 .issues
487 .contains(&NormalizationIssue::MultiFragmentStructure);
488
489 if has_mismatch {
490 let calculated = normalization
491 .calculated
492 .molecular_formula
493 .as_deref()
494 .unwrap_or("?");
495 let resolver_formula = candidate.molecular_formula.as_deref().unwrap_or("?");
496 result.findings.push(Finding {
497 level: "HIGH".into(),
498 rule: "GEN-STRUCTURE-FORMULA-MISMATCH".into(),
499 message: format!(
500 "CAS '{}': resolver formula '{resolver_formula}' does not match chematic-calculated \
501 formula '{calculated}' from the resolved structure — not reconciled automatically.",
502 candidate.cas
503 ),
504 });
505 remove_molecular_formula(result, component_index);
508 }
509 if has_multi_fragment {
510 result.findings.push(Finding {
511 level: "MED".into(),
512 rule: "GEN-STRUCTURE-MULTIFRAGMENT".into(),
513 message: format!(
514 "CAS '{}': structure has more than one disconnected fragment (e.g. a salt or \
515 solvate) — reported for review, not automatically rejected or reduced to its \
516 largest fragment.",
517 candidate.cas
518 ),
519 });
520 }
521
522 if let Some(canonical) = &normalization.canonical_smiles {
528 set_smiles(result, component_index, canonical);
529 let used_connectivity_fallback =
534 candidate.smiles.is_none() && candidate.connectivity_smiles.is_some();
535 result.provenance.push(FieldProvenance::source_smiles(
536 candidate.pubchem_cid,
537 used_connectivity_fallback,
538 ));
539 result.provenance.push(FieldProvenance::canonical_smiles(
540 candidate.pubchem_cid,
541 normalization
542 .issues
543 .iter()
544 .map(|i| format!("{i:?}"))
545 .collect(),
546 ));
547 }
548 }
549 }
550}
551
552fn composition_row_mut(
553 result: &mut GenerationResult,
554 index: usize,
555) -> Option<&mut crate::schema::CompositionCompositionAndConcentration> {
556 result
557 .sds
558 .composition
559 .as_mut()?
560 .composition_and_concentration
561 .as_mut()?
562 .get_mut(index)
563}
564
565fn set_smiles(result: &mut GenerationResult, index: usize, canonical: &str) {
566 if let Some(row) = composition_row_mut(result, index) {
567 row.smiles = Some(canonical.to_string());
568 }
569}
570
571fn remove_molecular_formula(result: &mut GenerationResult, index: usize) {
572 if let Some(row) = composition_row_mut(result, index) {
573 row.molecular_formula = None;
574 }
575}
576
577fn evidence_level_bucket(level: &EvidenceLevel) -> FieldStatus<()> {
581 match level {
582 EvidenceLevel::ProductTestReport | EvidenceLevel::EquivalentBatchTestReport => {
583 FieldStatus::Confirmed(())
584 }
585 EvidenceLevel::SupplierSpecification
586 | EvidenceLevel::SupplierSds
587 | EvidenceLevel::UnverifiedUserInput => FieldStatus::Supplied(()),
588 EvidenceLevel::RegulatoryDatabase
589 | EvidenceLevel::PeerReviewedLiterature
590 | EvidenceLevel::ReferenceDatabase => FieldStatus::Literature(()),
591 EvidenceLevel::DeterministicCalculation => FieldStatus::Calculated(()),
592 EvidenceLevel::ModelEstimate => FieldStatus::Estimated(()),
593 EvidenceLevel::None => FieldStatus::NotApplicable(NotApplicableReason {
594 explanation: "no evidence source recorded".into(),
595 }),
596 }
597}
598
599pub fn compute_evidence_summary(
602 provenance: &[FieldProvenance],
603 unresolved: &[UnresolvedField],
604) -> EvidenceSummary {
605 let mut summary = EvidenceSummary {
606 unresolved: unresolved.len(),
607 ..Default::default()
608 };
609
610 for p in provenance {
611 match evidence_level_bucket(&p.source_type) {
612 FieldStatus::Confirmed(()) => summary.confirmed += 1,
613 FieldStatus::Supplied(()) => summary.supplied += 1,
614 FieldStatus::Literature(()) => summary.literature += 1,
615 FieldStatus::Calculated(()) => summary.calculated += 1,
616 FieldStatus::Estimated(()) => summary.estimated += 1,
617 FieldStatus::NotApplicable(_) => summary.not_applicable += 1,
618 FieldStatus::Unresolved(_) => {} }
620 if matches!(
621 p.source_type,
622 EvidenceLevel::ProductTestReport | EvidenceLevel::EquivalentBatchTestReport
623 ) {
624 summary.product_test_evidence += 1;
625 }
626 if p.source_type == EvidenceLevel::UnverifiedUserInput {
627 summary.unverified_user_input += 1;
628 }
629 }
630
631 summary
632}
633
634pub fn compute_release_status(
645 unresolved: &[UnresolvedField],
646 findings: &[Finding],
647) -> ReleaseStatus {
648 let blocked_by_unresolved = unresolved.iter().any(|f| f.blocks_release);
649 let blocked_by_findings = findings
650 .iter()
651 .any(|f| f.level == "CRIT" || f.level == "HIGH");
652 if blocked_by_unresolved || blocked_by_findings {
653 return ReleaseStatus::Blocked;
654 }
655 if !unresolved.is_empty() || !findings.is_empty() {
656 return ReleaseStatus::ReviewRequired;
657 }
658 ReleaseStatus::Draft
659}
660
661pub fn evaluate_release_gate(result: &GenerationResult) -> ReleaseGateResult {
669 let blocking_findings: Vec<Finding> = result
670 .findings
671 .iter()
672 .filter(|f| f.level == "CRIT" || f.level == "HIGH")
673 .cloned()
674 .collect();
675
676 let mut required_actions = Vec::new();
677 for field in result.unresolved.iter().filter(|f| f.blocks_release) {
678 if !required_actions.contains(&field.recommended_action) {
679 required_actions.push(field.recommended_action.clone());
680 }
681 }
682
683 ReleaseGateResult {
684 status: result.release_status,
685 blocking_findings,
686 required_actions,
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::generation::input::{ComponentInput, ConcentrationRange, SupplierInput};
694 use crate::generation::unresolved::build_product_level_unresolved;
695 use crate::generation::{
696 EvidenceApplicability, EvidenceSource, MeasuredValueEvidence, MeasurementConditions,
697 };
698
699 fn supplier() -> SupplierInput {
700 SupplierInput {
701 company_name: "Example Chemical Co.".into(),
702 address: Some("1-1 Example, Tokyo".into()),
703 phone: Some("03-1234-5678".into()),
704 email: Some("safety@example.com".into()),
705 }
706 }
707
708 fn exact_component(cas: &str, name: &str, value: f64) -> ComponentInput {
709 ComponentInput {
710 cas_number: Some(cas.into()),
711 name: Some(name.into()),
712 concentration: ConcentrationRange {
713 exact: Some(value),
714 lower: None,
715 upper: None,
716 unit: "%".into(),
717 },
718 }
719 }
720
721 fn product() -> ProductInput {
722 ProductInput {
723 trade_name: "Test Solvent".into(),
724 other_names: vec![],
725 supplier: supplier(),
726 components: vec![exact_component("7732-18-5", "Water", 100.0)],
727 measured_properties: Default::default(),
728 evidence: vec![],
729 }
730 }
731
732 #[test]
733 fn every_populated_section1_field_has_provenance() {
734 let result = generate_from_resolved_input(&product(), &HashMap::new());
735 let paths: Vec<&str> = result.provenance.iter().map(|p| p.path.as_str()).collect();
736 assert!(paths.contains(&path::TRADE_NAME_JP));
737 assert!(paths.contains(&path::SUPPLIER_COMPANY_NAME));
738 assert!(paths.contains(&path::SUPPLIER_ADDRESS));
739 assert!(paths.contains(&path::SUPPLIER_PHONE));
740 assert!(paths.contains(&path::SUPPLIER_EMAIL));
741 }
742
743 #[test]
744 fn every_populated_section3_field_has_provenance() {
745 let result = generate_from_resolved_input(&product(), &HashMap::new());
746 let paths: Vec<&str> = result.provenance.iter().map(|p| p.path.as_str()).collect();
747 assert!(paths.contains(&path::composition_row(0, path::GENERIC_NAME).as_str()));
748 assert!(paths.contains(&path::composition_row(0, path::CAS_NO).as_str()));
749 assert!(paths.contains(&path::composition_row(0, path::CONCENTRATION).as_str()));
750 }
751
752 #[test]
753 fn user_input_provenance_is_never_confirmed() {
754 let result = generate_from_resolved_input(&product(), &HashMap::new());
755 assert!(result
756 .provenance
757 .iter()
758 .filter(|p| p.method.contains("supplied"))
759 .all(|p| p.source_type == EvidenceLevel::UnverifiedUserInput));
760 }
761
762 #[test]
763 fn resolver_output_is_reference_database_not_confirmed() {
764 let mut resolved = HashMap::new();
765 resolved.insert(
766 "7732-18-5".to_string(),
767 CasInfo {
768 cas: "7732-18-5".into(),
769 iupac_name: Some("oxidane".into()),
770 molecular_formula: Some("H2O".into()),
771 pubchem_cid: Some(962),
772 },
773 );
774 let result = generate_from_resolved_input(&product(), &resolved);
775 let cas_provenance: Vec<_> = result
776 .provenance
777 .iter()
778 .filter(|p| p.path.contains("IupacName") || p.path.contains("MolecularFormula"))
779 .collect();
780 assert_eq!(cas_provenance.len(), 2);
781 assert!(cas_provenance
782 .iter()
783 .all(|p| p.source_type == EvidenceLevel::ReferenceDatabase));
784 }
785
786 #[test]
787 fn lookup_failure_creates_finding_and_unresolved_identity() {
788 let result = generate_from_resolved_input(&product(), &HashMap::new());
789 assert!(result
790 .findings
791 .iter()
792 .any(|f| f.rule == "GEN-CAS-ENRICHMENT-MISSING"));
793 assert!(result
794 .unresolved
795 .iter()
796 .any(|u| u.path == path::composition_row(0, path::CAS_NO)));
797 }
798
799 #[test]
800 fn valid_supplied_composition_remains_after_lookup_failure() {
801 let result = generate_from_resolved_input(&product(), &HashMap::new());
802 let row = &result
803 .sds
804 .composition
805 .as_ref()
806 .unwrap()
807 .composition_and_concentration
808 .as_ref()
809 .unwrap()[0];
810 assert_eq!(
811 row.substance_identifiers
812 .as_ref()
813 .unwrap()
814 .substance_names
815 .as_ref()
816 .unwrap()
817 .generic_name
818 .as_deref(),
819 Some("Water")
820 );
821 }
822
823 #[test]
824 fn evidence_summary_matches_underlying_records() {
825 let result = generate_from_resolved_input(&product(), &HashMap::new());
826 let expected_unverified = result
827 .provenance
828 .iter()
829 .filter(|p| p.source_type == EvidenceLevel::UnverifiedUserInput)
830 .count();
831 assert_eq!(
832 result.evidence_summary.unverified_user_input,
833 expected_unverified
834 );
835 assert_eq!(result.evidence_summary.unresolved, result.unresolved.len());
836 assert_eq!(
837 result.evidence_summary.supplied
838 + result.evidence_summary.confirmed
839 + result.evidence_summary.literature
840 + result.evidence_summary.calculated
841 + result.evidence_summary.estimated
842 + result.evidence_summary.not_applicable,
843 result.provenance.len()
844 );
845 }
846
847 #[test]
848 fn blocking_unresolved_produces_blocked() {
849 let unresolved = vec![UnresolvedField {
850 path: "x".into(),
851 title: "x".into(),
852 reason: super::super::unresolved::UnresolvedReason::MissingInput,
853 required_inputs: vec![],
854 acceptable_evidence: vec![],
855 safety_impact: super::super::unresolved::SafetyImpact::High,
856 regulatory_impact: super::super::unresolved::RegulatoryImpact::High,
857 recommended_action: "x".into(),
858 blocks_release: true,
859 }];
860 assert_eq!(
861 compute_release_status(&unresolved, &[]),
862 ReleaseStatus::Blocked
863 );
864 }
865
866 #[test]
867 fn nonblocking_unresolved_produces_review_required() {
868 let result = generate_from_resolved_input(&product(), &HashMap::new());
869 assert_eq!(result.release_status, ReleaseStatus::ReviewRequired);
872 }
873
874 #[test]
875 fn high_severity_finding_produces_blocked() {
876 let finding = Finding {
877 level: "HIGH".into(),
878 rule: "X".into(),
879 message: "x".into(),
880 };
881 assert_eq!(
882 compute_release_status(&[], std::slice::from_ref(&finding)),
883 ReleaseStatus::Blocked
884 );
885 }
886
887 #[test]
888 fn empty_input_with_no_issues_produces_draft() {
889 assert_eq!(compute_release_status(&[], &[]), ReleaseStatus::Draft);
890 }
891
892 #[test]
893 fn generation_never_approves() {
894 let scenarios: Vec<(Vec<UnresolvedField>, Vec<Finding>)> = vec![
898 (vec![], vec![]),
899 (build_product_level_unresolved(), vec![]),
900 (
901 vec![],
902 vec![Finding {
903 level: "CRIT".into(),
904 rule: "X".into(),
905 message: "x".into(),
906 }],
907 ),
908 ];
909 for (unresolved, findings) in scenarios {
910 assert_ne!(
911 compute_release_status(&unresolved, &findings),
912 ReleaseStatus::Approved
913 );
914 }
915 assert_ne!(
917 generate_from_resolved_input(&product(), &HashMap::new()).release_status,
918 ReleaseStatus::Approved
919 );
920 }
921
922 #[test]
923 fn official_sds_json_has_no_report_keys() {
924 let result = generate_from_resolved_input(&product(), &HashMap::new());
925 let sds_json = serde_json::to_value(&result.sds).unwrap();
926 let as_object = sds_json.as_object().unwrap();
927 for key in [
928 "findings",
929 "provenance",
930 "unresolved",
931 "evidence_summary",
932 "release_status",
933 ] {
934 assert!(!as_object.contains_key(key));
935 }
936 }
937
938 #[test]
939 fn report_json_has_stable_snake_case_enum_values() {
940 let result = generate_from_resolved_input(&product(), &HashMap::new());
941 let report_json = serde_json::to_value(&result).unwrap();
942 let release_status = report_json["release_status"].as_str().unwrap();
943 assert_eq!(release_status, "review_required");
944 let first_unresolved_reason = report_json["unresolved"][0]["reason"].as_str().unwrap();
947 assert_eq!(first_unresolved_reason, "missing_input");
948 let reasons: Vec<&str> = report_json["unresolved"]
949 .as_array()
950 .unwrap()
951 .iter()
952 .map(|u| u["reason"].as_str().unwrap())
953 .collect();
954 assert!(reasons.contains(&"human_review_required"));
955 }
956
957 #[test]
958 fn commit8_findings_are_retained_alongside_commit9_findings() {
959 use crate::generation::validate_product_input;
960 let mut p = product();
961 p.components
962 .push(exact_component("7732-18-5", "Water again", 0.0));
963 let input_findings = validate_product_input(&p);
964 assert!(input_findings.iter().any(|f| f.rule == "GEN-CAS-DUPLICATE"));
965 let result = generate_from_resolved_input(&p, &HashMap::new());
969 assert_eq!(
970 result
971 .sds
972 .composition
973 .as_ref()
974 .unwrap()
975 .composition_and_concentration
976 .as_ref()
977 .unwrap()
978 .len(),
979 2
980 );
981 }
982
983 #[test]
984 fn component_order_and_mapping_unchanged_from_commit9() {
985 let mut p = product();
986 p.components
987 .push(exact_component("64-17-5", "Ethanol", 0.0));
988 let result = generate_from_resolved_input(&p, &HashMap::new());
989 let rows = result
990 .sds
991 .composition
992 .as_ref()
993 .unwrap()
994 .composition_and_concentration
995 .as_ref()
996 .unwrap();
997 assert_eq!(
998 rows[0]
999 .substance_identifiers
1000 .as_ref()
1001 .unwrap()
1002 .substance_names
1003 .as_ref()
1004 .unwrap()
1005 .generic_name
1006 .as_deref(),
1007 Some("Water")
1008 );
1009 assert_eq!(
1010 rows[1]
1011 .substance_identifiers
1012 .as_ref()
1013 .unwrap()
1014 .substance_names
1015 .as_ref()
1016 .unwrap()
1017 .generic_name
1018 .as_deref(),
1019 Some("Ethanol")
1020 );
1021 }
1022
1023 #[test]
1024 fn repeated_generation_is_byte_equivalent() {
1025 let p = product();
1026 let a = generate_from_resolved_input(&p, &HashMap::new());
1027 let b = generate_from_resolved_input(&p, &HashMap::new());
1028 let json_a = serde_json::to_string(&a).unwrap();
1029 let json_b = serde_json::to_string(&b).unwrap();
1030 assert_eq!(json_a, json_b);
1031 }
1032
1033 fn product_with_confirmed_flash_point() -> ProductInput {
1034 let mut p = product();
1035 p.evidence.push(EvidenceSource {
1036 id: "ev1".into(),
1037 level: EvidenceLevel::ProductTestReport,
1038 reference: "Lab Report 2026-014".into(),
1039 issuer: None,
1040 document_date: None,
1041 applies_to: EvidenceApplicability::FinishedProduct,
1042 });
1043 p.measured_properties
1044 .flash_point
1045 .push(MeasuredValueEvidence {
1046 value: 61.0,
1047 unit: "°C".into(),
1048 method: Some("Closed Cup (ASTM D93)".into()),
1049 conditions: MeasurementConditions {
1050 temperature_c: None,
1051 pressure_kpa: None,
1052 atmosphere: None,
1053 },
1054 sample_id: None,
1055 batch_id: None,
1056 evidence_id: "ev1".into(),
1057 });
1058 p
1059 }
1060
1061 #[test]
1062 fn evidence_summary_counts_update_when_a_property_resolves() {
1063 let baseline = generate_from_resolved_input(&product(), &HashMap::new());
1064 let resolved =
1065 generate_from_resolved_input(&product_with_confirmed_flash_point(), &HashMap::new());
1066
1067 assert_eq!(baseline.evidence_summary.confirmed, 0);
1068 assert_eq!(resolved.evidence_summary.confirmed, 1);
1069 assert_eq!(resolved.unresolved.len(), baseline.unresolved.len() - 1);
1071 assert_eq!(
1072 resolved.evidence_summary.unresolved,
1073 resolved.unresolved.len()
1074 );
1075 }
1076
1077 #[test]
1078 fn resolving_a_property_updates_release_status_deterministically() {
1079 let mut blocked = product();
1081 blocked.evidence.push(EvidenceSource {
1082 id: "ev1".into(),
1083 level: EvidenceLevel::ProductTestReport,
1084 reference: "Report A".into(),
1085 issuer: None,
1086 document_date: None,
1087 applies_to: EvidenceApplicability::FinishedProduct,
1088 });
1089 blocked.evidence.push(EvidenceSource {
1090 id: "ev2".into(),
1091 level: EvidenceLevel::ProductTestReport,
1092 reference: "Report B".into(),
1093 issuer: None,
1094 document_date: None,
1095 applies_to: EvidenceApplicability::FinishedProduct,
1096 });
1097 let conds = MeasurementConditions {
1098 temperature_c: None,
1099 pressure_kpa: None,
1100 atmosphere: None,
1101 };
1102 blocked
1103 .measured_properties
1104 .flash_point
1105 .push(MeasuredValueEvidence {
1106 value: 61.0,
1107 unit: "°C".into(),
1108 method: Some("Closed Cup".into()),
1109 conditions: conds.clone(),
1110 sample_id: None,
1111 batch_id: None,
1112 evidence_id: "ev1".into(),
1113 });
1114 blocked
1115 .measured_properties
1116 .flash_point
1117 .push(MeasuredValueEvidence {
1118 value: 65.0,
1119 unit: "°C".into(),
1120 method: Some("Closed Cup".into()),
1121 conditions: conds,
1122 sample_id: None,
1123 batch_id: None,
1124 evidence_id: "ev2".into(),
1125 });
1126 let blocked_result = generate_from_resolved_input(&blocked, &HashMap::new());
1127 assert_eq!(blocked_result.release_status, ReleaseStatus::Blocked);
1128
1129 let resolved_result =
1133 generate_from_resolved_input(&product_with_confirmed_flash_point(), &HashMap::new());
1134 assert_eq!(
1135 resolved_result.release_status,
1136 ReleaseStatus::ReviewRequired
1137 );
1138 assert!(!resolved_result
1139 .unresolved
1140 .iter()
1141 .any(|f| f.path.contains("FlashPoint") && f.blocks_release));
1142 }
1143
1144 #[test]
1145 fn generation_with_resolved_evidence_still_never_approves() {
1146 let result =
1147 generate_from_resolved_input(&product_with_confirmed_flash_point(), &HashMap::new());
1148 assert_ne!(result.release_status, ReleaseStatus::Approved);
1149 }
1150
1151 #[test]
1152 fn repeated_generation_with_evidence_is_byte_equivalent() {
1153 let p = product_with_confirmed_flash_point();
1154 let a = generate_from_resolved_input(&p, &HashMap::new());
1155 let b = generate_from_resolved_input(&p, &HashMap::new());
1156 assert_eq!(
1157 serde_json::to_string(&a).unwrap(),
1158 serde_json::to_string(&b).unwrap()
1159 );
1160 }
1161
1162 #[test]
1163 fn evaluate_release_gate_aggregates_blocking_findings_and_dedupes_actions() {
1164 let mut p = product();
1165 let result = generate_from_resolved_input(&p, &HashMap::new());
1170 let gate = evaluate_release_gate(&result);
1171 assert_eq!(gate.status, result.release_status);
1172 assert!(gate.required_actions.is_empty());
1177
1178 p.evidence.push(EvidenceSource {
1181 id: "ev1".into(),
1182 level: EvidenceLevel::ProductTestReport,
1183 reference: "A".into(),
1184 issuer: None,
1185 document_date: None,
1186 applies_to: EvidenceApplicability::FinishedProduct,
1187 });
1188 p.evidence.push(EvidenceSource {
1189 id: "ev2".into(),
1190 level: EvidenceLevel::ProductTestReport,
1191 reference: "B".into(),
1192 issuer: None,
1193 document_date: None,
1194 applies_to: EvidenceApplicability::FinishedProduct,
1195 });
1196 let conds = MeasurementConditions {
1197 temperature_c: None,
1198 pressure_kpa: None,
1199 atmosphere: None,
1200 };
1201 p.measured_properties
1202 .flash_point
1203 .push(MeasuredValueEvidence {
1204 value: 61.0,
1205 unit: "°C".into(),
1206 method: Some("Closed Cup".into()),
1207 conditions: conds.clone(),
1208 sample_id: None,
1209 batch_id: None,
1210 evidence_id: "ev1".into(),
1211 });
1212 p.measured_properties
1213 .flash_point
1214 .push(MeasuredValueEvidence {
1215 value: 65.0,
1216 unit: "°C".into(),
1217 method: Some("Closed Cup".into()),
1218 conditions: conds.clone(),
1219 sample_id: None,
1220 batch_id: None,
1221 evidence_id: "ev2".into(),
1222 });
1223 p.measured_properties
1224 .boiling_point
1225 .push(MeasuredValueEvidence {
1226 value: 100.0,
1227 unit: "°C".into(),
1228 method: Some("ASTM D1120".into()),
1229 conditions: conds.clone(),
1230 sample_id: None,
1231 batch_id: None,
1232 evidence_id: "ev1".into(),
1233 });
1234 p.measured_properties
1235 .boiling_point
1236 .push(MeasuredValueEvidence {
1237 value: 105.0,
1238 unit: "°C".into(),
1239 method: Some("ASTM D1120".into()),
1240 conditions: conds,
1241 sample_id: None,
1242 batch_id: None,
1243 evidence_id: "ev2".into(),
1244 });
1245 let result = generate_from_resolved_input(&p, &HashMap::new());
1246 let gate = evaluate_release_gate(&result);
1247 assert_eq!(gate.status, ReleaseStatus::Blocked);
1248 assert_eq!(gate.required_actions.len(), 1);
1250 }
1251
1252 use crate::enrichment::ChemicalIdentityCandidate;
1253 use crate::normalize::UnavailableNormalizer;
1254
1255 fn identity_candidate(
1256 cas: &str,
1257 cid: u64,
1258 smiles: Option<&str>,
1259 formula: Option<&str>,
1260 ) -> ChemicalIdentityCandidate {
1261 ChemicalIdentityCandidate {
1262 cas: cas.into(),
1263 pubchem_cid: Some(cid),
1264 iupac_name: Some("test compound".into()),
1265 molecular_formula: formula.map(str::to_string),
1266 smiles: smiles.map(str::to_string),
1267 connectivity_smiles: None,
1268 inchi_key: None,
1269 }
1270 }
1271
1272 #[test]
1273 fn multiple_candidates_are_not_silently_reduced_to_first() {
1274 let mut resolved = HashMap::new();
1275 resolved.insert(
1276 "7732-18-5".to_string(),
1277 CasResolution::Ambiguous(vec![
1278 identity_candidate("7732-18-5", 1, Some("O"), Some("H2O")),
1279 identity_candidate("7732-18-5", 2, Some("[OH2]"), Some("H2O")),
1280 ]),
1281 );
1282 let result = generate_from_normalized_input(&product(), &resolved, &UnavailableNormalizer);
1283
1284 assert!(result
1285 .findings
1286 .iter()
1287 .any(|f| f.rule == "GEN-CAS-AMBIGUOUS"));
1288 let unresolved = result
1289 .unresolved
1290 .iter()
1291 .find(|f| f.path == path::composition_row(0, path::CAS_NO))
1292 .unwrap();
1293 assert_eq!(
1294 unresolved.reason,
1295 UnresolvedReason::AmbiguousChemicalIdentity
1296 );
1297 assert!(unresolved.blocks_release);
1298 }
1299
1300 #[test]
1301 fn ambiguous_identity_blocks_release() {
1302 let mut resolved = HashMap::new();
1303 resolved.insert(
1304 "7732-18-5".to_string(),
1305 CasResolution::Ambiguous(vec![
1306 identity_candidate("7732-18-5", 1, None, None),
1307 identity_candidate("7732-18-5", 2, None, None),
1308 ]),
1309 );
1310 let result = generate_from_normalized_input(&product(), &resolved, &UnavailableNormalizer);
1311 assert_eq!(result.release_status, ReleaseStatus::Blocked);
1312 }
1313
1314 #[test]
1315 fn resolved_candidate_with_no_smiles_via_unavailable_normalizer_writes_nothing() {
1316 let mut resolved = HashMap::new();
1317 resolved.insert(
1318 "7732-18-5".to_string(),
1319 CasResolution::Resolved(identity_candidate("7732-18-5", 962, None, Some("H2O"))),
1320 );
1321 let result = generate_from_normalized_input(&product(), &resolved, &UnavailableNormalizer);
1322 let row = &result
1323 .sds
1324 .composition
1325 .as_ref()
1326 .unwrap()
1327 .composition_and_concentration
1328 .as_ref()
1329 .unwrap()[0];
1330 assert!(row.smiles.is_none());
1331 assert!(result
1332 .findings
1333 .iter()
1334 .any(|f| f.rule == "GEN-STRUCTURE-MISSING"));
1335 }
1336
1337 #[test]
1338 fn no_chematic_result_ever_populates_a_product_level_property() {
1339 let mut resolved = HashMap::new();
1340 resolved.insert(
1341 "7732-18-5".to_string(),
1342 CasResolution::Resolved(identity_candidate("7732-18-5", 962, Some("O"), Some("H2O"))),
1343 );
1344 let result = generate_from_normalized_input(&product(), &resolved, &UnavailableNormalizer);
1345 assert!(result.sds.physical_chemical_properties.is_none());
1346 assert!(result.sds.stability_reactivity.is_none());
1347 assert!(result.sds.hazard_identification.is_none());
1348 }
1349
1350 #[test]
1351 fn official_sds_json_has_no_normalization_report_keys() {
1352 let mut resolved = HashMap::new();
1353 resolved.insert(
1354 "7732-18-5".to_string(),
1355 CasResolution::Resolved(identity_candidate("7732-18-5", 962, Some("O"), Some("H2O"))),
1356 );
1357 let result = generate_from_normalized_input(&product(), &resolved, &UnavailableNormalizer);
1358 let json = serde_json::to_string(&result.sds).unwrap();
1359 for leak in [
1360 "status",
1361 "issues",
1362 "screening_alerts",
1363 "NormalizationStatus",
1364 "confidence",
1365 ] {
1366 assert!(
1367 !json.contains(leak),
1368 "official SDS JSON must not contain '{leak}'"
1369 );
1370 }
1371 }
1372
1373 #[test]
1374 fn lookup_error_is_nonblocking_and_retains_supplied_data() {
1375 let mut lookups = HashMap::new();
1376 lookups.insert("7732-18-5".to_string(), Err("network timeout".to_string()));
1377 let result = generate_from_detailed_lookups(&product(), &lookups, &UnavailableNormalizer);
1378
1379 let finding = result
1380 .findings
1381 .iter()
1382 .find(|f| f.rule == "GEN-CAS-LOOKUP-ERROR")
1383 .expect("expected a GEN-CAS-LOOKUP-ERROR finding");
1384 assert_eq!(finding.level, "MED");
1385 assert_ne!(result.release_status, ReleaseStatus::Blocked);
1386
1387 let row = &result
1390 .sds
1391 .composition
1392 .as_ref()
1393 .unwrap()
1394 .composition_and_concentration
1395 .as_ref()
1396 .unwrap()[0];
1397 assert_eq!(
1398 row.substance_identifiers
1399 .as_ref()
1400 .unwrap()
1401 .substance_identity
1402 .as_ref()
1403 .unwrap()
1404 .ca_sno
1405 .as_ref()
1406 .unwrap()
1407 .full_text
1408 .as_deref(),
1409 Some(["7732-18-5".to_string()].as_slice())
1410 );
1411 }
1412
1413 #[test]
1414 fn lookup_error_is_distinct_from_ambiguous_identity() {
1415 let mut lookups = HashMap::new();
1416 lookups.insert("7732-18-5".to_string(), Err("HTTP 503".to_string()));
1417 let result = generate_from_detailed_lookups(&product(), &lookups, &UnavailableNormalizer);
1418
1419 assert!(!result
1420 .findings
1421 .iter()
1422 .any(|f| f.rule == "GEN-CAS-AMBIGUOUS"));
1423 let unresolved = result
1424 .unresolved
1425 .iter()
1426 .find(|f| f.path == path::composition_row(0, path::CAS_NO))
1427 .unwrap();
1428 assert_ne!(
1429 unresolved.reason,
1430 UnresolvedReason::AmbiguousChemicalIdentity
1431 );
1432 }
1433
1434 #[test]
1435 fn malformed_cas_lookup_error_does_not_duplicate_format_finding() {
1436 let mut input = product();
1437 input.components = vec![exact_component("not-a-cas", "Mystery", 100.0)];
1438 let mut lookups = HashMap::new();
1439 lookups.insert("not-a-cas".to_string(), Err("invalid format".to_string()));
1440 let result = generate_from_detailed_lookups(&input, &lookups, &UnavailableNormalizer);
1441 assert!(!result
1442 .findings
1443 .iter()
1444 .any(|f| f.rule == "GEN-CAS-LOOKUP-ERROR"));
1445 }
1446
1447 #[test]
1448 fn mixed_lookup_outcomes_are_handled_independently_per_component() {
1449 let mut input = product();
1450 input.components = vec![
1451 exact_component("7732-18-5", "Water", 90.0),
1452 exact_component("64-17-5", "Ethanol", 10.0),
1453 ];
1454 let mut lookups = HashMap::new();
1455 lookups.insert(
1456 "7732-18-5".to_string(),
1457 Ok(CasResolution::Ambiguous(vec![
1458 identity_candidate("7732-18-5", 1, None, None),
1459 identity_candidate("7732-18-5", 2, None, None),
1460 ])),
1461 );
1462 lookups.insert("64-17-5".to_string(), Err("connection reset".to_string()));
1463
1464 let result = generate_from_detailed_lookups(&input, &lookups, &UnavailableNormalizer);
1465 assert!(result
1466 .findings
1467 .iter()
1468 .any(|f| f.rule == "GEN-CAS-AMBIGUOUS"));
1469 assert!(result
1470 .findings
1471 .iter()
1472 .any(|f| f.rule == "GEN-CAS-LOOKUP-ERROR"));
1473 assert_eq!(result.release_status, ReleaseStatus::Blocked); }
1475
1476 #[cfg(feature = "chematic-normalization")]
1477 mod chematic_integration {
1478 use super::*;
1479 use crate::normalize::ChematicNormalizer;
1480
1481 #[test]
1482 fn matching_formula_populates_smiles_and_keeps_formula() {
1483 let mut resolved = HashMap::new();
1484 resolved.insert(
1485 "7732-18-5".to_string(),
1486 CasResolution::Resolved(identity_candidate(
1487 "7732-18-5",
1488 962,
1489 Some("O"),
1490 Some("H2O"),
1491 )),
1492 );
1493 let result = generate_from_normalized_input(&product(), &resolved, &ChematicNormalizer);
1494 let row = &result
1495 .sds
1496 .composition
1497 .as_ref()
1498 .unwrap()
1499 .composition_and_concentration
1500 .as_ref()
1501 .unwrap()[0];
1502 assert!(row.smiles.is_some());
1503 assert_eq!(row.molecular_formula.as_deref(), Some("H2O"));
1504 assert!(!result
1505 .findings
1506 .iter()
1507 .any(|f| f.rule == "GEN-STRUCTURE-FORMULA-MISMATCH"));
1508 }
1509
1510 #[test]
1511 fn formula_mismatch_blocks_release_and_preserves_both_values_in_provenance() {
1512 let mut resolved = HashMap::new();
1513 resolved.insert(
1514 "64-17-5".to_string(),
1515 CasResolution::Resolved(identity_candidate(
1516 "64-17-5",
1517 702,
1518 Some("CCO"),
1519 Some("C6H12O6"),
1520 )),
1521 );
1522 let result =
1523 generate_from_normalized_input(&product_ethanol(), &resolved, &ChematicNormalizer);
1524
1525 assert!(result
1526 .findings
1527 .iter()
1528 .any(|f| f.rule == "GEN-STRUCTURE-FORMULA-MISMATCH" && f.level == "HIGH"));
1529 assert_eq!(result.release_status, ReleaseStatus::Blocked);
1530
1531 let row = &result
1532 .sds
1533 .composition
1534 .as_ref()
1535 .unwrap()
1536 .composition_and_concentration
1537 .as_ref()
1538 .unwrap()[0];
1539 assert!(row.molecular_formula.is_none());
1541
1542 let calculated_prov = result
1544 .provenance
1545 .iter()
1546 .find(|p| p.path == path::CANONICAL_SMILES)
1547 .unwrap();
1548 assert!(calculated_prov
1549 .warnings
1550 .iter()
1551 .any(|w| w.contains("FormulaMismatch")));
1552 }
1553
1554 #[test]
1555 fn multi_fragment_produces_review_required_not_blocked() {
1556 let mut resolved = HashMap::new();
1557 resolved.insert(
1558 "7647-14-5".to_string(),
1559 CasResolution::Resolved(identity_candidate(
1560 "7647-14-5",
1561 5234,
1562 Some("[Na+].[Cl-]"),
1563 None,
1564 )),
1565 );
1566 let result = generate_from_normalized_input(
1567 &product_sodium_chloride(),
1568 &resolved,
1569 &ChematicNormalizer,
1570 );
1571 assert!(result
1572 .findings
1573 .iter()
1574 .any(|f| f.rule == "GEN-STRUCTURE-MULTIFRAGMENT" && f.level == "MED"));
1575 assert_ne!(result.release_status, ReleaseStatus::Blocked);
1576 let row = &result
1577 .sds
1578 .composition
1579 .as_ref()
1580 .unwrap()
1581 .composition_and_concentration
1582 .as_ref()
1583 .unwrap()[0];
1584 assert!(row.smiles.as_deref().unwrap().contains('.'));
1586 }
1587
1588 #[test]
1589 fn source_and_canonical_smiles_provenance_never_confirmed() {
1590 let mut resolved = HashMap::new();
1591 resolved.insert(
1592 "7732-18-5".to_string(),
1593 CasResolution::Resolved(identity_candidate(
1594 "7732-18-5",
1595 962,
1596 Some("O"),
1597 Some("H2O"),
1598 )),
1599 );
1600 let result = generate_from_normalized_input(&product(), &resolved, &ChematicNormalizer);
1601 let source = result
1602 .provenance
1603 .iter()
1604 .find(|p| p.path == path::SOURCE_SMILES)
1605 .unwrap();
1606 let canonical = result
1607 .provenance
1608 .iter()
1609 .find(|p| p.path == path::CANONICAL_SMILES)
1610 .unwrap();
1611 assert_eq!(source.source_type, EvidenceLevel::ReferenceDatabase);
1612 assert_eq!(
1613 canonical.source_type,
1614 EvidenceLevel::DeterministicCalculation
1615 );
1616 }
1617
1618 #[test]
1619 fn connectivity_smiles_fallback_reaches_provenance_as_a_warning() {
1620 let mut candidate = identity_candidate("7732-18-5", 962, None, Some("H2O"));
1621 candidate.connectivity_smiles = Some("O".into());
1622 let mut resolved = HashMap::new();
1623 resolved.insert("7732-18-5".to_string(), CasResolution::Resolved(candidate));
1624 let result = generate_from_normalized_input(&product(), &resolved, &ChematicNormalizer);
1625 let source = result
1626 .provenance
1627 .iter()
1628 .find(|p| p.path == path::SOURCE_SMILES)
1629 .unwrap();
1630 assert_eq!(
1631 source.method,
1632 "PubChem CAS lookup (ConnectivitySMILES fallback)"
1633 );
1634 assert!(source
1635 .warnings
1636 .iter()
1637 .any(|w| w.contains("stereochemistry/isotope")));
1638 }
1639
1640 #[test]
1641 fn generation_never_approves_with_normalization() {
1642 let mut resolved = HashMap::new();
1643 resolved.insert(
1644 "7732-18-5".to_string(),
1645 CasResolution::Resolved(identity_candidate(
1646 "7732-18-5",
1647 962,
1648 Some("O"),
1649 Some("H2O"),
1650 )),
1651 );
1652 let result = generate_from_normalized_input(&product(), &resolved, &ChematicNormalizer);
1653 assert_ne!(result.release_status, ReleaseStatus::Approved);
1654 }
1655
1656 #[test]
1657 fn repeated_generation_with_normalization_is_byte_equivalent() {
1658 let mut resolved = HashMap::new();
1659 resolved.insert(
1660 "7732-18-5".to_string(),
1661 CasResolution::Resolved(identity_candidate(
1662 "7732-18-5",
1663 962,
1664 Some("O"),
1665 Some("H2O"),
1666 )),
1667 );
1668 let a = generate_from_normalized_input(&product(), &resolved, &ChematicNormalizer);
1669 let b = generate_from_normalized_input(&product(), &resolved, &ChematicNormalizer);
1670 assert_eq!(
1671 serde_json::to_string(&a).unwrap(),
1672 serde_json::to_string(&b).unwrap()
1673 );
1674 }
1675
1676 fn product_ethanol() -> ProductInput {
1677 let mut p = product();
1678 p.components[0].cas_number = Some("64-17-5".into());
1679 p.components[0].name = Some("Ethanol".into());
1680 p
1681 }
1682
1683 fn product_sodium_chloride() -> ProductInput {
1684 let mut p = product();
1685 p.components[0].cas_number = Some("7647-14-5".into());
1686 p.components[0].name = Some("Sodium chloride".into());
1687 p
1688 }
1689 }
1690}