Skip to main content

sdsforge_core/generation/
unresolved.rs

1use serde::{Deserialize, Serialize};
2
3use super::provenance::EvidenceLevel;
4
5/// Why a value couldn't be filled in.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum UnresolvedReason {
9    MissingInput,
10    ProductTestRequired,
11    AmbiguousChemicalIdentity,
12    ConflictingSources,
13    UnsupportedCalculation,
14    InsufficientMeasurementConditions,
15    MixtureCannotBeDerivedFromComponents,
16    RegulatoryJudgementRequired,
17    HumanReviewRequired,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SafetyImpact {
23    None,
24    Low,
25    Medium,
26    High,
27    Critical,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum RegulatoryImpact {
33    None,
34    Low,
35    Medium,
36    High,
37}
38
39/// One piece of evidence a human would need to supply to resolve a field.
40/// Kept small — enough to explain what's needed, not a full lab-data model.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RequiredInput {
43    pub name: String,
44    pub description: String,
45    pub unit: Option<String>,
46}
47
48impl RequiredInput {
49    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
50        RequiredInput {
51            name: name.into(),
52            description: description.into(),
53            unit: None,
54        }
55    }
56
57    pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
58        self.unit = Some(unit.into());
59        self
60    }
61}
62
63/// A field the generator could not populate, with enough detail for a human
64/// (or a downstream system) to know what's missing, why, how bad it is to
65/// ship without it, and what evidence would resolve it.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct UnresolvedField {
68    pub path: String,
69    pub title: String,
70    pub reason: UnresolvedReason,
71    pub required_inputs: Vec<RequiredInput>,
72    pub acceptable_evidence: Vec<EvidenceLevel>,
73    pub safety_impact: SafetyImpact,
74    pub regulatory_impact: RegulatoryImpact,
75    pub recommended_action: String,
76    pub blocks_release: bool,
77}
78
79/// Placeholder for a deliberate "this field does not apply to this product"
80/// determination. Not constructed anywhere in this commit's generation
81/// logic — see `docs/sdsforge-architecture.md`'s generation-architecture
82/// section: when applicability genuinely can't be determined (e.g. this
83/// commit's `ProductInput` carries no physical-state field), the correct
84/// reason is [`UnresolvedReason::HumanReviewRequired`], not a guessed
85/// `NotApplicable`. Defined now so [`super::FieldStatus`] compiles and so a
86/// future commit that *can* determine applicability has a type to use.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct NotApplicableReason {
89    pub explanation: String,
90}
91
92/// How a field's value was determined. `Calculated`/`Estimated`/`Literature`
93/// must never auto-promote to `Confirmed` — promotion requires new
94/// evidence, not a higher-confidence label on the same evidence.
95///
96/// Not currently stored on [`super::GenerationResult`] (which populates
97/// `SdsRoot` fields directly, per commit #9's design) — defined per the
98/// architecture spec's type system and used internally by evidence-summary
99/// computation (`super::result::evidence_level_bucket`) to categorize each
100/// [`super::provenance::FieldProvenance`] record.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum FieldStatus<T> {
104    Confirmed(T),
105    Supplied(T),
106    Literature(T),
107    Calculated(T),
108    Estimated(T),
109    Unresolved(UnresolvedField),
110    NotApplicable(NotApplicableReason),
111}
112
113/// What evidence is even eligible for a given field, and whether missing it
114/// blocks release. Data, not scattered conditionals — see
115/// `PRODUCT_LEVEL_POLICIES` below.
116#[derive(Debug, Clone)]
117pub struct FieldPolicy {
118    pub path: &'static str,
119    pub allowed_evidence: &'static [EvidenceLevel],
120    pub product_test_required: bool,
121    pub calculation_allowed: bool,
122    pub estimation_allowed: bool,
123    pub blocks_release_if_missing: bool,
124}
125
126/// Molecular formula as returned by [`crate::enrichment::lookup_cas`]: an
127/// identity candidate, reference-database evidence, no product test
128/// required. Included for completeness of the policy table — commit #9
129/// already populates this field when the resolver supplies it; this policy
130/// documents the evidence rule that governs it.
131pub const MOLECULAR_FORMULA_POLICY: FieldPolicy = FieldPolicy {
132    path: "Composition.CompositionAndConcentration[].MolecularFormula",
133    allowed_evidence: &[
134        EvidenceLevel::ReferenceDatabase,
135        EvidenceLevel::DeterministicCalculation,
136    ],
137    product_test_required: false,
138    calculation_allowed: true,
139    estimation_allowed: false,
140    blocks_release_if_missing: false,
141};
142
143/// Product-level physical/hazard properties that Commit #9's Section 1/3
144/// draft never populates — CAS identity and composition alone are not
145/// sufficient evidence for any of these (see
146/// `docs/sdsforge-architecture.md`'s "Properties that require product-level
147/// evidence" table). Every one of these becomes an [`UnresolvedField`] on
148/// every generation result, since `ProductInput` (commit #8) supplies none
149/// of the underlying test data.
150pub const PRODUCT_LEVEL_POLICIES: &[FieldPolicy] = &[
151    FieldPolicy {
152        path: "PhysicalChemicalProperties.FlashPoint",
153        allowed_evidence: &[
154            EvidenceLevel::ProductTestReport,
155            EvidenceLevel::EquivalentBatchTestReport,
156            EvidenceLevel::SupplierSpecification,
157        ],
158        product_test_required: true,
159        calculation_allowed: false,
160        estimation_allowed: false,
161        blocks_release_if_missing: false, // see build_product_level_unresolved: applicability is undetermined, not confirmed-blocking
162    },
163    FieldPolicy {
164        path: "PhysicalChemicalProperties.InitialBoilingPointAndBoilingRange",
165        allowed_evidence: &[
166            EvidenceLevel::ProductTestReport,
167            EvidenceLevel::EquivalentBatchTestReport,
168        ],
169        product_test_required: true,
170        calculation_allowed: false,
171        estimation_allowed: false,
172        blocks_release_if_missing: false,
173    },
174    FieldPolicy {
175        path: "PhysicalChemicalProperties.VapourPressure",
176        allowed_evidence: &[
177            EvidenceLevel::ProductTestReport,
178            EvidenceLevel::EquivalentBatchTestReport,
179        ],
180        product_test_required: true,
181        calculation_allowed: false,
182        estimation_allowed: false,
183        blocks_release_if_missing: false,
184    },
185    FieldPolicy {
186        path: "PhysicalChemicalProperties.ExplosiveLimits",
187        allowed_evidence: &[
188            EvidenceLevel::ProductTestReport,
189            EvidenceLevel::EquivalentBatchTestReport,
190        ],
191        product_test_required: true,
192        calculation_allowed: false,
193        estimation_allowed: false,
194        blocks_release_if_missing: false,
195    },
196    FieldPolicy {
197        path: "StabilityReactivity.SelfReactivity",
198        allowed_evidence: &[
199            EvidenceLevel::ProductTestReport,
200            EvidenceLevel::EquivalentBatchTestReport,
201        ],
202        product_test_required: true,
203        calculation_allowed: false,
204        estimation_allowed: false,
205        blocks_release_if_missing: false,
206    },
207    FieldPolicy {
208        path: "PhysicalChemicalProperties.OxidizingProperties",
209        allowed_evidence: &[
210            EvidenceLevel::ProductTestReport,
211            EvidenceLevel::EquivalentBatchTestReport,
212        ],
213        product_test_required: true,
214        calculation_allowed: false,
215        estimation_allowed: false,
216        blocks_release_if_missing: false,
217    },
218    FieldPolicy {
219        path: "HazardIdentification.Classification.PhysicochemicalEffect.CorrosiveToMetals",
220        allowed_evidence: &[
221            EvidenceLevel::ProductTestReport,
222            EvidenceLevel::EquivalentBatchTestReport,
223        ],
224        product_test_required: true,
225        calculation_allowed: false,
226        estimation_allowed: false,
227        blocks_release_if_missing: false,
228    },
229];
230
231/// Builds the always-present product-level [`UnresolvedField`]s.
232///
233/// Every one uses [`UnresolvedReason::HumanReviewRequired`], not
234/// `ProductTestRequired` — `ProductInput` carries no physical-state field,
235/// so the generator cannot determine whether e.g. flash point is even
236/// applicable to this product before a human looks at it. Pretending
237/// otherwise (guessing "liquid, therefore blocking" or "solid, therefore
238/// not applicable") would be exactly the kind of applicability-guessing
239/// this feature avoids; a human must resolve applicability first, and
240/// `required_inputs` names the test evidence needed once they do.
241pub fn build_product_level_unresolved() -> Vec<UnresolvedField> {
242    PRODUCT_LEVEL_POLICIES
243        .iter()
244        .map(|policy| {
245            let (title, required_inputs) = product_level_detail(policy.path);
246            UnresolvedField {
247                path: policy.path.to_string(),
248                title,
249                reason: UnresolvedReason::HumanReviewRequired,
250                required_inputs,
251                acceptable_evidence: policy.allowed_evidence.to_vec(),
252                safety_impact: SafetyImpact::Medium,
253                regulatory_impact: RegulatoryImpact::Medium,
254                recommended_action:
255                    "A human must first determine whether this property applies to \
256                     the product (ProductInput carries no physical-state/use information), then \
257                     supply product or equivalent-batch test evidence if it does."
258                        .to_string(),
259                blocks_release: policy.blocks_release_if_missing,
260            }
261        })
262        .collect()
263}
264
265pub(super) fn product_level_detail(path: &str) -> (String, Vec<RequiredInput>) {
266    match path {
267        "PhysicalChemicalProperties.FlashPoint" => (
268            "Flash point".into(),
269            vec![
270                RequiredInput::new("value", "Measured flash point").with_unit("°C"),
271                RequiredInput::new("method", "Open-cup or closed-cup test method"),
272                RequiredInput::new("sample_or_batch_id", "Sample or batch identity tested"),
273            ],
274        ),
275        "PhysicalChemicalProperties.InitialBoilingPointAndBoilingRange" => (
276            "Initial boiling point / boiling range".into(),
277            vec![
278                RequiredInput::new("value", "Boiling point or range").with_unit("°C"),
279                RequiredInput::new("pressure", "Pressure at which measured").with_unit("kPa"),
280                RequiredInput::new("method", "Test method"),
281                RequiredInput::new(
282                    "decomposes_before_boiling",
283                    "Whether the substance decomposes before boiling",
284                ),
285            ],
286        ),
287        "PhysicalChemicalProperties.VapourPressure" => (
288            "Vapour pressure".into(),
289            vec![
290                RequiredInput::new("value", "Vapour pressure").with_unit("kPa"),
291                RequiredInput::new(
292                    "measurement_temperature",
293                    "Temperature at which vapour pressure was measured — a vapour pressure value \
294                     without its measurement temperature is not usable",
295                )
296                .with_unit("°C"),
297                RequiredInput::new("basis", "Whether the value is measured or calculated"),
298            ],
299        ),
300        "PhysicalChemicalProperties.ExplosiveLimits" => (
301            "Explosive (flammability) limits".into(),
302            vec![
303                RequiredInput::new("lower_limit", "Lower explosive limit").with_unit("vol %"),
304                RequiredInput::new("upper_limit", "Upper explosive limit").with_unit("vol %"),
305                RequiredInput::new("atmosphere", "Test atmosphere / O2 percentage"),
306                RequiredInput::new("temperature", "Test temperature").with_unit("°C"),
307                RequiredInput::new("method", "Test method"),
308            ],
309        ),
310        "StabilityReactivity.SelfReactivity" => (
311            "Self-reactivity".into(),
312            vec![RequiredInput::new(
313                "test_result",
314                "UN test series A-H result, or self-accelerating decomposition temperature (SADT) — \
315                 DSC screening alone is not sufficient",
316            )],
317        ),
318        "PhysicalChemicalProperties.OxidizingProperties" => (
319            "Oxidizing properties".into(),
320            vec![RequiredInput::new(
321                "test_result",
322                "Physical-state-appropriate UN test result (O.1/O.2/O.3) — structure alone does not \
323                 resolve this field",
324            )],
325        ),
326        "HazardIdentification.Classification.PhysicochemicalEffect.CorrosiveToMetals" => (
327            "Corrosive to metals".into(),
328            vec![RequiredInput::new(
329                "corrosion_rate_test",
330                "Steel/aluminium corrosion-rate test at a specified temperature — pH alone is \
331                 insufficient",
332            )
333            .with_unit("mm/year")],
334        ),
335        _ => (path.to_string(), Vec::new()),
336    }
337}
338
339/// Builds an [`UnresolvedField`] for a component whose CAS enrichment
340/// lookup did not resolve (mirrors commit #9's `GEN-CAS-ENRICHMENT-MISSING`
341/// finding, which is retained alongside this — a `Finding` is a severity-
342/// bearing diagnostic event, this is a field-level statement of what's
343/// missing and how to fix it; they serve different purposes and neither
344/// replaces the other).
345///
346/// Always `UnresolvedReason::MissingInput`, never a more specific reason
347/// like `AmbiguousChemicalIdentity` — the pure mapping function this feeds
348/// from (`draft_sections_from_resolved_input`) only sees a `HashMap<String,
349/// CasInfo>` of *successful* lookups (commit #9's frozen signature), so it
350/// cannot distinguish "not found in PubChem" from "network/parse error" for
351/// a missing entry. This is a known, deliberate limitation — recorded here
352/// rather than redesigning `enrichment::lookup_cas`'s error type in this
353/// commit.
354///
355/// `blocks_release` is always `false`: the supplied name/CAS/concentration
356/// remain in the draft regardless (commit #9's guarantee), so the
357/// composition row is usable even without enrichment. Nothing in
358/// `ProductInput` gives this function a way to detect "the missing
359/// identity makes the composition materially unsafe or unusable" — that
360/// judgement call is left to human review, not guessed here.
361pub fn build_lookup_failure_unresolved(
362    component_index: usize,
363    cas: &str,
364    name: Option<&str>,
365) -> UnresolvedField {
366    let label = name
367        .map(|n| format!("'{n}' (CAS {cas})"))
368        .unwrap_or_else(|| format!("CAS {cas}"));
369    UnresolvedField {
370        path: super::provenance::path::composition_row(
371            component_index,
372            super::provenance::path::CAS_NO,
373        ),
374        title: format!("Chemical identity for component {label}"),
375        reason: UnresolvedReason::MissingInput,
376        required_inputs: vec![RequiredInput::new(
377            "authoritative_identity_source",
378            "An authoritative source confirming this CAS number's identity — automatic lookup did \
379             not return a match. This may mean the record wasn't found, or the lookup request \
380             itself failed; the two are not currently distinguished.",
381        )],
382        acceptable_evidence: vec![
383            EvidenceLevel::SupplierSpecification,
384            EvidenceLevel::SupplierSds,
385            EvidenceLevel::RegulatoryDatabase,
386            EvidenceLevel::ReferenceDatabase,
387        ],
388        safety_impact: SafetyImpact::Low,
389        regulatory_impact: RegulatoryImpact::Low,
390        recommended_action: "Verify the chemical identity and provide an authoritative source \
391            (e.g. supplier SDS, regulatory database entry)."
392            .into(),
393        blocks_release: false,
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn product_level_unresolved_covers_all_seven_properties() {
403        let unresolved = build_product_level_unresolved();
404        assert_eq!(unresolved.len(), 7);
405        assert!(unresolved
406            .iter()
407            .all(|f| f.reason == UnresolvedReason::HumanReviewRequired));
408        assert!(unresolved.iter().all(|f| !f.blocks_release));
409    }
410
411    #[test]
412    fn flash_point_required_inputs_are_specific() {
413        let unresolved = build_product_level_unresolved();
414        let flash_point = unresolved
415            .iter()
416            .find(|f| f.path == "PhysicalChemicalProperties.FlashPoint")
417            .unwrap();
418        assert!(flash_point
419            .required_inputs
420            .iter()
421            .any(|i| i.name == "method"));
422    }
423
424    #[test]
425    fn vapour_pressure_requires_measurement_temperature() {
426        let unresolved = build_product_level_unresolved();
427        let vp = unresolved
428            .iter()
429            .find(|f| f.path == "PhysicalChemicalProperties.VapourPressure")
430            .unwrap();
431        assert!(vp
432            .required_inputs
433            .iter()
434            .any(|i| i.name == "measurement_temperature"));
435    }
436
437    #[test]
438    fn metal_corrosivity_states_ph_alone_is_insufficient() {
439        let unresolved = build_product_level_unresolved();
440        let corrosivity = unresolved
441            .iter()
442            .find(|f| {
443                f.path
444                    == "HazardIdentification.Classification.PhysicochemicalEffect.CorrosiveToMetals"
445            })
446            .unwrap();
447        assert!(corrosivity.required_inputs[0]
448            .description
449            .contains("pH alone"));
450    }
451
452    #[test]
453    fn no_component_averaging_language_anywhere() {
454        // Structural check: nothing in this module computes an average —
455        // grep-style assertion that the self-reactivity/oxidizing/flash
456        // point policies require a direct test result, not a formula.
457        for policy in PRODUCT_LEVEL_POLICIES {
458            assert!(!policy.calculation_allowed || policy.path.contains("MolecularFormula"));
459        }
460    }
461
462    #[test]
463    fn lookup_failure_unresolved_never_blocks_release() {
464        let field = build_lookup_failure_unresolved(0, "7732-18-5", Some("Water"));
465        assert!(!field.blocks_release);
466        assert_eq!(field.reason, UnresolvedReason::MissingInput);
467    }
468
469    #[test]
470    fn unresolved_reason_serializes_snake_case() {
471        let json = serde_json::to_string(&UnresolvedReason::ProductTestRequired).unwrap();
472        assert_eq!(json, "\"product_test_required\"");
473    }
474}