1use serde::{Deserialize, Serialize};
2
3use super::provenance::EvidenceLevel;
4
5#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct NotApplicableReason {
89 pub explanation: String,
90}
91
92#[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#[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
126pub 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
143pub 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, },
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
231pub 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
339pub 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 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}