Skip to main content

sdsforge_core/generation/
result.rs

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/// Generation must never mark its own output approved — approval is a
20/// separate human act (approver, timestamp, target version), never a side
21/// effect of running the generator. `Draft`/`ReviewRequired`/`Blocked` are
22/// the only values [`compute_release_status`] can produce; `Approved`
23/// exists on the enum for a future explicit human-approval record to set,
24/// not for generation code to reach (see `tests::generation_never_approves`).
25#[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/// Deterministic tally over a [`GenerationResult`]'s provenance/unresolved
42/// records — never manually incremented elsewhere, always recomputed by
43/// [`compute_evidence_summary`] from the records themselves, so the counts
44/// can't drift from what's actually in the report.
45#[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/// The full generation report: the official SDS draft plus everything
59/// needed to understand its incompleteness. `sds` is the only part that
60/// belongs in `official_sds.json` — `findings`/`unresolved`/`provenance`/
61/// `evidence_summary`/`release_status` describe the draft, they are never
62/// written into it (see `tests::official_sds_json_has_no_report_keys`).
63///
64/// Uses `SdsRoot` directly, not an aspirational `DomainSds` type — the
65/// regulatory-profile/domain separation described in
66/// `docs/sdsforge-architecture.md` doesn't exist yet, and commit #9
67/// deliberately returned the current MHLW-backed `SdsRoot`. This is that
68/// same decision carried forward: `sds: SdsRoot` is today's `mhlw-v1`
69/// representation, not a placeholder for a schema refactor.
70#[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
80/// Pure, deterministic: wraps [`draft_sections_from_resolved_input`] (does
81/// not duplicate its mapping logic) and adds provenance/unresolved/
82/// evidence-summary/release-status on top. Same `resolved` shape as commit
83/// #9's function — a `HashMap` of successful CAS lookups only, so a
84/// missing entry cannot be distinguished as "not found" vs. "request
85/// failed" here either; see [`build_lookup_failure_unresolved`]'s doc
86/// comment for why that's a recorded limitation, not a redesign target for
87/// this commit.
88pub 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                    // Mirrors commit #9's own condition for emitting
162                    // GEN-CAS-ENRICHMENT-MISSING: only for well-formed CAS
163                    // numbers. A malformed one is already fully covered by
164                    // commit #8's GEN-CAS-FORMAT finding — adding an
165                    // unresolved-identity record on top would be redundant
166                    // noise about the same underlying problem.
167                    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        // ConcentrationRange is always present on ComponentInput (not
179        // Option), so this provenance record is unconditional.
180        provenance.push(FieldProvenance::supplied(
181            path::composition_row(i, path::CONCENTRATION),
182            "supplied and structurally validated",
183        ));
184    }
185
186    // Resolves the seven safety-sensitive properties against supplied
187    // measured-property evidence — writes into `sds` only on full policy
188    // satisfaction, otherwise adds a (more specific than commit #10 could
189    // give) UnresolvedField. See resolve.rs's module doc for the "no
190    // partial credit" rule this enforces.
191    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
209/// Orchestration: performs the CAS lookups (reusing
210/// [`crate::enrichment::lookup_cas`], no second PubChem client), then
211/// delegates everything else to [`generate_from_resolved_input`]. This is
212/// the one new orchestration path this commit adds — it does not call
213/// commit #9's `generate_section_1_and_3`, which would re-fetch the same
214/// CAS data over the network a second time for no benefit.
215pub 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
230/// Adds chematic-backed chemical-identity normalization on top of
231/// [`generate_from_resolved_input`] — reuses it rather than duplicating
232/// Section 1/3 mapping logic. Strategy: derive a plain `HashMap<String,
233/// CasInfo>` containing only genuinely `CasResolution::Resolved` (non-
234/// ambiguous) candidates and call the existing, unchanged
235/// `generate_from_resolved_input` — an ambiguous CAS is simply absent from
236/// that map, so the base pass treats it exactly like today's "lookup
237/// didn't resolve" case (commit #9's `GEN-CAS-ENRICHMENT-MISSING`/
238/// `MissingInput`, unchanged). This function then does one additive pass
239/// over the already-built result: replaces the generic lookup-failure
240/// unresolved entry with a specific `AmbiguousChemicalIdentity` one for
241/// ambiguous components, and for resolved components runs the normalizer
242/// and layers on canonical-SMILES writing / formula-consistency handling.
243///
244/// Never touches any of the seven product-level properties commit A
245/// (2ac2758/d4dd15d) governs — this function's writes are scoped entirely
246/// to `Composition.CompositionAndConcentration[i].{MolecularFormula,SMILES}`.
247pub 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                // Already handled by the base pass (GEN-CAS-ENRICHMENT-MISSING /
284                // MissingInput) — nothing additional to do here.
285            }
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
294/// Orchestration for the `sdsforge generate --enrich` CLI path: resolves
295/// every distinct CAS number in `input` through [`lookup_cas_detailed`]
296/// (deduplicated — one request per distinct CAS, not per component), then
297/// delegates everything else to [`generate_from_detailed_lookups`]. A
298/// network/HTTP/parse failure for one CAS never aborts the whole draft —
299/// it's recorded as a `Result::Err` and surfaces as a
300/// `GEN-CAS-LOOKUP-ERROR` finding, while every other component's lookup
301/// still proceeds normally.
302pub 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
326/// Pure core of [`generate_with_detailed_enrichment`] — no network access,
327/// so it's fully unit-testable with a fixture `lookups` map (see
328/// `tests::lookup_error_is_nonblocking_and_retains_supplied_data`).
329///
330/// Delegates Section 1/3 mapping, ambiguity handling, and normalization
331/// entirely to [`generate_from_normalized_input`] — every generation path
332/// converges on that one implementation, this function does not duplicate
333/// it. A CAS whose lookup failed (`Err`) is simply absent from the
334/// `CasResolution` map passed down, so the base pass already creates its
335/// usual "identity not resolved" [`UnresolvedField`] for it (same as a
336/// `NotFound`/never-looked-up CAS) — this function's only additional job
337/// is layering a `GEN-CAS-LOOKUP-ERROR` finding on top, which is the one
338/// thing that distinguishes "the request itself failed" from "nothing was
339/// ever tried" (see [`build_lookup_failure_unresolved`]'s doc comment).
340/// Never classified as `AmbiguousChemicalIdentity` — that reason is
341/// reserved for a genuine multi-candidate resolver response — and never
342/// promoted above MED severity, so a transient network failure alone
343/// cannot block a release the way ambiguity or a formula mismatch does.
344pub 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        // Mirrors generate_from_resolved_input's own guard: a malformed CAS
364        // already has its own GEN-CAS-FORMAT finding from input validation,
365        // so a lookup-error finding on top would be redundant noise about
366        // the same underlying problem.
367        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    // Replace the generic lookup-failure entry the base pass created (the
413    // CAS wasn't in the derived CasInfo map, so it looks like a plain
414    // "not found" to generate_from_resolved_input) with a more specific one.
415    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            // Not reachable via this code path today (ambiguity is handled
478            // at the CasResolution level before a normalizer ever runs),
479            // kept for completeness of the match.
480        }
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                // Don't expose two conflicting values in the official field
506                // — remove whatever the base pass already wrote.
507                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            // Canonical SMILES is written whenever normalization produced
523            // one at all -- including the multi-fragment/formula-mismatch
524            // review cases, since the structure itself parsed successfully
525            // and the canonical form is still the accurate representation
526            // of what was parsed.
527            if let Some(canonical) = &normalization.canonical_smiles {
528                set_smiles(result, component_index, canonical);
529                // PubChem's full `smiles` is preferred as the normalization
530                // input; `connectivity_smiles` (no stereochemistry/isotopes)
531                // is used only as a fallback -- flag it here so the report
532                // discloses when that weaker representation was the source.
533                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
577/// Maps an [`EvidenceLevel`] to the [`FieldStatus`] bucket it represents,
578/// for evidence-summary tallying only — `FieldStatus`'s payload is `()`
579/// here since the summary only needs counts, not the underlying values.
580fn 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
599/// Recomputes the summary from scratch every time — see
600/// [`EvidenceSummary`]'s doc comment for why this is deliberate.
601pub 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(_) => {} // unreachable: evidence_level_bucket never returns this variant
619        }
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
634/// `Blocked` if anything explicitly blocks release (an unresolved field
635/// marked `blocks_release`, or a CRIT/HIGH validation finding); otherwise
636/// `ReviewRequired` if anything at all remains unresolved or was merely
637/// flagged; `Draft` only when there's truly nothing to review. Given this
638/// feature only ever produces Section 1/3 and always adds the seven
639/// product-level unresolved fields, real results are `Blocked` or
640/// `ReviewRequired` — `Draft` is reachable but not the common case, exactly
641/// as intended: an incomplete draft should not look deceptively finished.
642///
643/// Never returns `Approved` — there is no branch that produces it.
644pub 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
661/// Aggregates a [`GenerationResult`] into the CRIT/HIGH findings and
662/// required actions that actually block release — deterministic, built
663/// from `result.unresolved`/`result.findings` only, never a second source
664/// of truth. `required_actions` is deduplicated by exact string match
665/// (sufficient for now — multiple unresolved fields commonly share the
666/// same recommended action, e.g. two properties both needing "a human
667/// must first determine whether this property applies").
668pub 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        // The product-level unresolved fields are all non-blocking, so a
870        // typical result is ReviewRequired, not Blocked or a falsely-clean Draft.
871        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        // Structural guarantee: compute_release_status has no branch that
895        // returns Approved. Exercised across several scenarios to confirm
896        // empirically as well.
897        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        // And the real generation path, end to end:
916        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        // First unresolved entry is the lookup-failure record (pushed
945        // before the always-present product-level ones).
946        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        // generate_from_resolved_input doesn't re-run validate_product_input
966        // (that's the caller's job, same as commit #9) — confirm its own
967        // findings (draft-mapping-time findings) coexist independently.
968        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        // One fewer unresolved entry (flash point moved out of the list).
1070        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        // Two disagreeing reports -> ConflictingSources -> Blocked.
1080        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        // Same product, single agreeing report -> resolves cleanly, no
1130        // longer blocked by that property (still ReviewRequired overall —
1131        // six other product-level properties remain unresolved).
1132        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        // Two properties left unresolved with the same generic
1166        // HumanReviewRequired recommended_action text should collapse to
1167        // one deduplicated required_actions entry; a CRIT finding should
1168        // land in blocking_findings.
1169        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        // All seven product-level HumanReviewRequired fields share
1173        // identical recommended_action text, none blocks_release, so with
1174        // no evidence supplied there should be zero required_actions here
1175        // (nothing in this baseline is blocks_release: true).
1176        assert!(gate.required_actions.is_empty());
1177
1178        // Force a duplicate blocking action via a conflicting-evidence
1179        // scenario on two properties sharing the same conflict message.
1180        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        // Both conflicts share identical recommended_action text -> deduplicated to one entry.
1249        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        // Supplied name/CAS/concentration are untouched -- a lookup failure
1388        // never causes fabricated or discarded composition data.
1389        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); // ambiguity alone blocks
1474    }
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            // No conflicting duplicate value exposed in the official field.
1540            assert!(row.molecular_formula.is_none());
1541
1542            // Both values retained in the report's provenance.
1543            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            // Structure kept intact -- both fragments present in the written SMILES.
1585            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}