Skip to main content

sdsforge_core/generation/
evidence.rs

1use serde::{Deserialize, Serialize};
2
3use super::provenance::{EvidenceLevel, MeasurementConditions};
4
5/// What the evidence actually was measured on — a mixture property can
6/// legitimately be confirmed by finished-product or same/equivalent-batch
7/// evidence, but never by component-level or reference-substance data (see
8/// `super::result`'s evidence-resolution logic).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum EvidenceApplicability {
12    FinishedProduct,
13    SameBatch,
14    EquivalentBatch,
15    Component,
16    ReferenceSubstance,
17    Unknown,
18}
19
20/// A reference to a piece of supporting evidence — a test report, a
21/// supplier specification, a database entry. Stores identifying metadata
22/// only (a reference string, issuer, date); never the document itself or
23/// arbitrary binary data.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct EvidenceSource {
27    /// Referenced by `evidence_id` on the measured-value types below.
28    pub id: String,
29    pub level: EvidenceLevel,
30    pub reference: String,
31    pub issuer: Option<String>,
32    pub document_date: Option<String>,
33    pub applies_to: EvidenceApplicability,
34}
35
36/// A measured value for one of the four properties whose MHLW schema field
37/// has a real numeric+unit shape (flash point, boiling point, vapor
38/// pressure). `evidence_id` must match an [`EvidenceSource::id`] in
39/// [`super::ProductInput::evidence`] — a value with no resolvable evidence
40/// reference can never become `Confirmed`, only `Supplied` at best (see
41/// resolution logic in `super::result`).
42/// `conditions` deliberately has no `#[serde(default)]`: it must remain a
43/// required key so an omitted measurement condition is a parse error, not a
44/// silently-accepted empty condition set. (`MeasurementConditions`' own
45/// three fields are individually `Option`, so `conditions: {}` is valid —
46/// the acceptance policies in `super::resolve` are what reject an
47/// insufficiently-specified condition set, not the parser.)
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct MeasuredValueEvidence {
51    pub value: f64,
52    pub unit: String,
53    pub method: Option<String>,
54    pub conditions: MeasurementConditions,
55    pub sample_id: Option<String>,
56    pub batch_id: Option<String>,
57    pub evidence_id: String,
58}
59
60/// Explosive (flammability) limits need two bounds; otherwise the same
61/// shape as [`MeasuredValueEvidence`], including the required `conditions`
62/// key (see that struct's doc comment).
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ExplosiveLimitsEvidence {
66    pub lower: Option<f64>,
67    pub upper: Option<f64>,
68    pub unit: String,
69    pub method: Option<String>,
70    pub conditions: MeasurementConditions,
71    pub sample_id: Option<String>,
72    pub batch_id: Option<String>,
73    pub evidence_id: String,
74}
75
76/// For the three properties whose MHLW schema field is free text with no
77/// structured numeric value at all (self-reactivity, oxidizing properties,
78/// metal corrosivity) — `result` describes the test outcome directly
79/// rather than a value+unit pair, since that's the actual shape of the
80/// target field.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct TestResultEvidence {
84    pub result: String,
85    pub method: Option<String>,
86    pub sample_id: Option<String>,
87    pub batch_id: Option<String>,
88    pub evidence_id: String,
89}
90
91/// All measured-property evidence a caller supplies for the seven
92/// properties [`super::unresolved::PRODUCT_LEVEL_POLICIES`] covers. Each
93/// field is a `Vec`, not an `Option` — zero entries means nothing was
94/// supplied (stays `Unresolved`/`HumanReviewRequired`, unchanged from
95/// commit #10); more than one entry makes disagreement between reports
96/// representable, which a single `Option` slot could never express.
97///
98/// Container-level `#[serde(default)]`: any subset of the seven fields may
99/// be supplied, and the rest default to empty — so
100/// `measured_properties: { flash_point: [...] }` is valid without
101/// spelling out the other six as `[]`. Combined with `deny_unknown_fields`,
102/// a misspelled property name (e.g. `flash_points`) is still a parse
103/// error, not a silently-ignored key.
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105#[serde(default, deny_unknown_fields)]
106pub struct MeasuredPropertiesInput {
107    pub flash_point: Vec<MeasuredValueEvidence>,
108    pub boiling_point: Vec<MeasuredValueEvidence>,
109    pub vapor_pressure: Vec<MeasuredValueEvidence>,
110    pub explosive_limits: Vec<ExplosiveLimitsEvidence>,
111    pub self_reactivity: Vec<TestResultEvidence>,
112    pub oxidizing_properties: Vec<TestResultEvidence>,
113    pub metal_corrosivity: Vec<TestResultEvidence>,
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn measured_properties_input_defaults_to_all_empty() {
122        let input = MeasuredPropertiesInput::default();
123        assert!(input.flash_point.is_empty());
124        assert!(input.boiling_point.is_empty());
125        assert!(input.vapor_pressure.is_empty());
126        assert!(input.explosive_limits.is_empty());
127        assert!(input.self_reactivity.is_empty());
128        assert!(input.oxidizing_properties.is_empty());
129        assert!(input.metal_corrosivity.is_empty());
130    }
131
132    #[test]
133    fn evidence_applicability_serializes_snake_case() {
134        let json = serde_json::to_string(&EvidenceApplicability::EquivalentBatch).unwrap();
135        assert_eq!(json, "\"equivalent_batch\"");
136    }
137
138    #[test]
139    fn missing_measured_properties_key_becomes_all_empty_collections() {
140        let input: MeasuredPropertiesInput = serde_json::from_str("{}").unwrap();
141        assert!(input.flash_point.is_empty());
142        assert!(input.boiling_point.is_empty());
143        assert!(input.vapor_pressure.is_empty());
144        assert!(input.explosive_limits.is_empty());
145        assert!(input.self_reactivity.is_empty());
146        assert!(input.oxidizing_properties.is_empty());
147        assert!(input.metal_corrosivity.is_empty());
148    }
149
150    #[test]
151    fn partial_measured_properties_object_may_contain_only_flash_point() {
152        let json = r#"{
153            "flash_point": [
154                {"value": 12.0, "unit": "degC", "method": null,
155                 "conditions": {"temperature_c": 20.0, "pressure_kpa": null, "atmosphere": null},
156                 "sample_id": null, "batch_id": "BATCH-001", "evidence_id": "flash-point-report"}
157            ]
158        }"#;
159        let input: MeasuredPropertiesInput = serde_json::from_str(json).unwrap();
160        assert_eq!(input.flash_point.len(), 1);
161        assert!(input.boiling_point.is_empty());
162        assert!(input.vapor_pressure.is_empty());
163        assert!(input.explosive_limits.is_empty());
164        assert!(input.self_reactivity.is_empty());
165        assert!(input.oxidizing_properties.is_empty());
166        assert!(input.metal_corrosivity.is_empty());
167    }
168
169    #[test]
170    fn unknown_measured_property_name_fails() {
171        // "flash_points" (extra trailing s) instead of "flash_point".
172        let json = r#"{"flash_points": []}"#;
173        assert!(serde_json::from_str::<MeasuredPropertiesInput>(json).is_err());
174    }
175
176    #[test]
177    fn missing_evidence_id_fails() {
178        let json = r#"{"level":"product_test_report","reference":"ref",
179            "issuer":null,"document_date":null,"applies_to":"finished_product"}"#;
180        assert!(serde_json::from_str::<EvidenceSource>(json).is_err());
181    }
182
183    #[test]
184    fn missing_evidence_reference_fails() {
185        let json = r#"{"id":"ev1","level":"product_test_report",
186            "issuer":null,"document_date":null,"applies_to":"finished_product"}"#;
187        assert!(serde_json::from_str::<EvidenceSource>(json).is_err());
188    }
189
190    #[test]
191    fn missing_measured_value_evidence_id_fails() {
192        let json = r#"{"value":1.0,"unit":"degC","method":null,
193            "conditions":{"temperature_c":20.0,"pressure_kpa":null,"atmosphere":null},
194            "sample_id":null,"batch_id":null}"#;
195        assert!(serde_json::from_str::<MeasuredValueEvidence>(json).is_err());
196    }
197
198    #[test]
199    fn missing_required_conditions_still_fails() {
200        // `conditions` itself is omitted entirely -- not the same as an
201        // empty `conditions: {}` object, which would still be valid since
202        // every MeasurementConditions field is individually Option.
203        let json = r#"{"value":1.0,"unit":"degC","method":null,
204            "sample_id":null,"batch_id":null,"evidence_id":"ev1"}"#;
205        assert!(serde_json::from_str::<MeasuredValueEvidence>(json).is_err());
206    }
207}