Skip to main content

sdsconv_core/converter/
validator.rs

1use crate::country::SourceCountry;
2use crate::ghs_codes;
3use crate::schema::{HazardIdentificationClassification, SdsRoot};
4
5// ── Phase 1 helpers ──────────────────────────────────────────────────────────
6
7/// Returns true if `s` looks like a date (contains a 4-digit year or a Japanese era year).
8fn looks_like_date(s: &str) -> bool {
9    let s = s.trim();
10    // Western year: 4-digit (YYYY) or 8-digit (YYYYMMDD) sequence starting with 1 or 2.
11    if s.split(|c: char| !c.is_ascii_digit())
12        .any(|tok| {
13            (tok.len() == 4 || tok.len() == 8)
14                && tok.starts_with(|c: char| c == '1' || c == '2')
15        })
16    {
17        return true;
18    }
19    // Japanese era date: contains "年" (year kanji) and at least one digit.
20    if s.contains('年') && s.chars().any(|c| c.is_ascii_digit()) {
21        return true;
22    }
23    false
24}
25
26/// Placeholder / generic values that should not appear as product names.
27const PLACEHOLDER_NAMES: &[&str] = &[
28    "n/a", "na", "unknown", "不明", "未定", "化学物質", "chemical substance",
29    "substance", "物質名", "product", "製品名",
30];
31
32fn is_placeholder(s: &str) -> bool {
33    let lower = s.trim().to_lowercase();
34    PLACEHOLDER_NAMES.contains(&lower.as_str()) || lower.is_empty()
35}
36
37/// "非危険物" を意味する分類値。これらだけが存在する場合は実質的な危険分類なし。
38const NOT_CLASSIFIED_PATTERNS: &[&str] = &[
39    "not classified", "not applicable", "n/a", "na",
40    "分類できない", "分類対象外", "区分外", "該当なし", "データなし",
41    "不适用", "不分类", "无资料",
42    // 「分類不明 / 分类不明」= data unavailable, treat same as not-classified
43    "分類不明", "分类不明",
44];
45
46fn is_not_classified(s: &str) -> bool {
47    let lower = s.trim().to_lowercase();
48    NOT_CLASSIFIED_PATTERNS.iter().any(|&p| lower.contains(p)) || lower.is_empty()
49}
50
51/// Returns true if Classification contains at least one real hazard category
52/// (i.e., at least one field that is not empty / "not classified" / "not applicable").
53fn classification_has_real_hazard(c: &HazardIdentificationClassification) -> bool {
54    let Ok(v) = serde_json::to_value(c) else { return false };
55    let mut strings: Vec<String> = Vec::new();
56    collect_strings(&v, &mut strings);
57    strings.iter().any(|s| !is_not_classified(s))
58}
59
60/// Collect every string leaf in a serde_json::Value tree into `out`.
61fn collect_strings(v: &serde_json::Value, out: &mut Vec<String>) {
62    match v {
63        serde_json::Value::String(s) => out.push(s.clone()),
64        serde_json::Value::Object(map) => map.values().for_each(|v| collect_strings(v, out)),
65        serde_json::Value::Array(arr) => arr.iter().for_each(|v| collect_strings(v, out)),
66        _ => {}
67    }
68}
69
70/// Performs post-deserialization structural checks on all 16 SDS sections.
71/// Does not hard-fail so partial results are still usable.
72pub fn validate(sds: &SdsRoot) -> Vec<String> {
73    let mut w: Vec<String> = Vec::new();
74
75    macro_rules! missing {
76        ($section:literal) => {
77            w.push(format!(
78                "{}: section not extracted — check source document.",
79                $section
80            ))
81        };
82    }
83
84    // ── Section 1: Identification ─────────────────────────────────────────────
85    match &sds.identification {
86        None => missing!("Section 1 (Identification)"),
87        Some(id) => {
88            let has_name = id
89                .trade_product_identity
90                .as_ref()
91                .map(|t| t.trade_name_jp.is_some() || t.trade_name_en.is_some())
92                .unwrap_or(false);
93            if !has_name {
94                w.push("Section 1 (Identification): no product name (TradeNameJP/TradeNameEN).".into());
95            }
96            if id.supplier_information.is_none() {
97                w.push("Section 1 (Identification): SupplierInformation is missing.".into());
98            }
99        }
100    }
101
102    // ── Section 2: HazardIdentification ──────────────────────────────────────
103    match &sds.hazard_identification {
104        None => missing!("Section 2 (HazardIdentification)"),
105        Some(hz) => {
106            if hz.classification.is_none() && hz.hazard_labelling.is_none() {
107                w.push("Section 2 (HazardIdentification): neither Classification nor HazardLabelling extracted.".into());
108            }
109            if let Some(hl) = &hz.hazard_labelling {
110                // GHS H-code validation
111                if let Some(stmts) = &hl.hazard_statement {
112                    for s in stmts {
113                        if let Some(code) = &s.hazard_statement_code {
114                            let upper = code.to_uppercase();
115                            if !ghs_codes::is_valid_h_code(&upper) {
116                                w.push(format!(
117                                    "Section 2 (HazardStatement): unknown H-code '{code}'"
118                                ));
119                            }
120                        }
121                    }
122                }
123                // GHS P-code validation
124                if let Some(ps) = &hl.precautionary_statements {
125                    let all_codes: Vec<Option<&String>> = ps
126                        .prevention
127                        .iter()
128                        .flatten()
129                        .map(|s| s.precautionary_statement_code.as_ref())
130                        .chain(
131                            ps.response
132                                .iter()
133                                .flatten()
134                                .map(|s| s.precautionary_statement_code.as_ref()),
135                        )
136                        .chain(
137                            ps.storage
138                                .iter()
139                                .flatten()
140                                .map(|s| s.precautionary_statement_code.as_ref()),
141                        )
142                        .chain(
143                            ps.disposal
144                                .iter()
145                                .flatten()
146                                .map(|s| s.precautionary_statement_code.as_ref()),
147                        )
148                        .collect();
149                    for code in all_codes.into_iter().flatten() {
150                        let upper = code.to_uppercase();
151                        if !ghs_codes::is_valid_p_code(&upper) {
152                            w.push(format!(
153                                "Section 2 (PrecautionaryStatement): unknown P-code '{code}'"
154                            ));
155                        }
156                    }
157                }
158            }
159        }
160    }
161
162    // ── Section 3: Composition ────────────────────────────────────────────────
163    match &sds.composition {
164        None => missing!("Section 3 (Composition)"),
165        Some(comp) => {
166            let items = comp.composition_and_concentration.as_deref().unwrap_or(&[]);
167            if items.is_empty() {
168                w.push("Section 3 (Composition): CompositionAndConcentration is empty.".into());
169            }
170            // CAS number format and check-digit validation
171            for (i, item) in items.iter().enumerate() {
172                if let Some(ids) = &item.substance_identifiers {
173                    if let Some(identity) = &ids.substance_identity {
174                        if let Some(cas_node) = &identity.ca_sno {
175                            for cas in cas_node.full_text.iter().flatten() {
176                                match validate_cas(cas) {
177                                    CasValidation::Ok => {}
178                                    CasValidation::InvalidFormat => {
179                                        w.push(format!(
180                                            "Section 3 (Composition[{i}]): invalid CAS format '{cas}' \
181                                             (expected \\d{{2,7}}-\\d{{2}}-\\d)"
182                                        ));
183                                    }
184                                    CasValidation::InvalidCheckDigit { expected } => {
185                                        w.push(format!(
186                                            "Section 3 (Composition[{i}]): CAS check digit mismatch \
187                                             '{cas}' (expected check digit {expected}, source has {})",
188                                            cas.chars().last().unwrap_or('?')
189                                        ));
190                                    }
191                                }
192                            }
193                        }
194                    }
195                }
196            }
197        }
198    }
199
200    // ── Section 4: FirstAidMeasures ───────────────────────────────────────────
201    if sds.first_aid_measures.is_none() {
202        missing!("Section 4 (FirstAidMeasures)");
203    }
204
205    // ── Section 5: FireFightingMeasures ───────────────────────────────────────
206    if sds.fire_fighting_measures.is_none() {
207        missing!("Section 5 (FireFightingMeasures)");
208    }
209
210    // ── Section 6: AccidentalReleaseMeasures ─────────────────────────────────
211    if sds.accidental_release_measures.is_none() {
212        missing!("Section 6 (AccidentalReleaseMeasures)");
213    }
214
215    // ── Section 7: HandlingAndStorage ────────────────────────────────────────
216    if sds.handling_and_storage.is_none() {
217        missing!("Section 7 (HandlingAndStorage)");
218    }
219
220    // ── Section 8: ExposureControlPersonalProtection ─────────────────────────
221    if sds.exposure_control_personal_protection.is_none() {
222        missing!("Section 8 (ExposureControlPersonalProtection)");
223    }
224
225    // ── Section 9: PhysicalChemicalProperties ────────────────────────────────
226    if sds.physical_chemical_properties.is_none() {
227        missing!("Section 9 (PhysicalChemicalProperties)");
228    }
229
230    // ── Section 10: StabilityReactivity ──────────────────────────────────────
231    match &sds.stability_reactivity {
232        None => missing!("Section 10 (StabilityReactivity)"),
233        Some(sr) => {
234            if sr.stability_description.is_none() && sr.reactivity_description.is_none() {
235                w.push("Section 10 (StabilityReactivity): neither StabilityDescription nor ReactivityDescription extracted.".into());
236            }
237        }
238    }
239
240    // ── Section 11: ToxicologicalInformation ─────────────────────────────────
241    match &sds.toxicological_information {
242        None => missing!("Section 11 (ToxicologicalInformation)"),
243        Some(list) if list.is_empty() => {
244            w.push("Section 11 (ToxicologicalInformation): array is present but empty.".into());
245        }
246        _ => {}
247    }
248
249    // ── Section 12: EcologicalInformation ────────────────────────────────────
250    match &sds.ecological_information {
251        None => missing!("Section 12 (EcologicalInformation)"),
252        Some(list) if list.is_empty() => {
253            w.push("Section 12 (EcologicalInformation): array is present but empty.".into());
254        }
255        _ => {}
256    }
257
258    // ── Section 13: DisposalConsiderations ───────────────────────────────────
259    if sds.disposal_considerations.is_none() {
260        missing!("Section 13 (DisposalConsiderations)");
261    }
262
263    // ── Section 14: TransportInformation ─────────────────────────────────────
264    if sds.transport_information.is_none() {
265        missing!("Section 14 (TransportInformation)");
266    }
267
268    // ── Section 15: RegulatoryInformation ────────────────────────────────────
269    if sds.regulatory_information.is_none() {
270        missing!("Section 15 (RegulatoryInformation)");
271    }
272
273    // ── Section 16: OtherInformation ─────────────────────────────────────────
274    if sds.other_information.is_none() {
275        missing!("Section 16 (OtherInformation)");
276    }
277
278    // ── Phase 1: cross-section consistency & value sanity ────────────────────
279
280    // Date format: IssueDate / RevisionDate
281    if let Some(ds) = &sds.datasheet {
282        if let Some(d) = &ds.issue_date {
283            if !looks_like_date(d) {
284                w.push(format!("Datasheet.IssueDate '{d}' does not look like a valid date."));
285            }
286        }
287        for d in ds.revision_date.iter().flatten() {
288            if !looks_like_date(d) {
289                w.push(format!("Datasheet.RevisionDate '{d}' does not look like a valid date."));
290            }
291        }
292    }
293
294    // ProductName quality
295    if let Some(id) = &sds.identification {
296        if let Some(tpi) = &id.trade_product_identity {
297            for (field, val) in [
298                ("TradeNameJP", tpi.trade_name_jp.as_deref()),
299                ("TradeNameEN", tpi.trade_name_en.as_deref()),
300            ] {
301                if let Some(name) = val {
302                    if is_placeholder(name) {
303                        w.push(format!(
304                            "Section 1 (Identification.{field}): value '{name}' looks like a placeholder."
305                        ));
306                    }
307                }
308            }
309        }
310    }
311
312    // Concentration range: numeric % values should be 0-100
313    if let Some(comp) = &sds.composition {
314        for (i, item) in comp
315            .composition_and_concentration
316            .iter()
317            .flatten()
318            .enumerate()
319        {
320            if let Some(conc) = &item.concentration {
321                if let Some(nr) = &conc.numeric_range_with_unit_and_qualifier {
322                    let is_percent = nr
323                        .unit
324                        .as_deref()
325                        .map(|u| u.contains('%'))
326                        .unwrap_or(false);
327                    if is_percent {
328                        let values: Vec<f64> = [
329                            nr.exact_value.as_ref().and_then(|v| v.value),
330                            nr.lower_value.as_ref().and_then(|v| v.value),
331                            nr.upper_value.as_ref().and_then(|v| v.value),
332                        ]
333                        .into_iter()
334                        .flatten()
335                        .collect();
336                        for v in values {
337                            if !(0.0..=100.0).contains(&v) {
338                                w.push(format!(
339                                    "Section 3 (Composition[{i}]): concentration {v}% is outside the \
340                                     valid range 0-100."
341                                ));
342                            }
343                        }
344                    }
345                }
346            }
347        }
348    }
349
350    // Cross-consistency: real hazard Classification present but HazardStatement absent.
351    // Skip non-hazardous substances (all categories "not classified" / "not applicable").
352    if let Some(hz) = &sds.hazard_identification {
353        let has_real_classification = hz
354            .classification
355            .as_ref()
356            .map(classification_has_real_hazard)
357            .unwrap_or(false);
358        let has_hazard_statement = hz
359            .hazard_labelling
360            .as_ref()
361            .and_then(|hl| hl.hazard_statement.as_ref())
362            .map(|s| !s.is_empty())
363            .unwrap_or(false);
364        if has_real_classification && !has_hazard_statement {
365            w.push(
366                "Section 2 (HazardIdentification): Classification is present but \
367                 HazardStatement is absent — verify labelling completeness."
368                    .into(),
369            );
370        }
371    }
372
373    w
374}
375
376// ---------------------------------------------------------------------------
377// Typed findings for the correction pass
378// ---------------------------------------------------------------------------
379
380/// Which sub-list a P-code belongs to within PrecautionaryStatements.
381#[derive(Debug, Clone, PartialEq)]
382pub enum PCodeCategory {
383    Prevention,
384    Response,
385    Storage,
386    Disposal,
387}
388
389impl std::fmt::Display for PCodeCategory {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        match self {
392            Self::Prevention => write!(f, "Prevention"),
393            Self::Response   => write!(f, "Response"),
394            Self::Storage    => write!(f, "Storage"),
395            Self::Disposal   => write!(f, "Disposal"),
396        }
397    }
398}
399
400/// An actionable validation finding with enough context to apply a correction.
401///
402/// Unlike the string-based [`validate()`] warnings, these carry the structured
403/// field path and the pre-computed correct value so [`crate::converter::corrector`]
404/// can apply fixes without re-parsing warning messages.
405#[derive(Debug, Clone)]
406pub enum ValidationFinding {
407    /// An H-code in `HazardLabelling.HazardStatement` is not in the GHS H-code list.
408    UnknownHCode {
409        /// The invalid code as extracted (e.g. `"H999"`).
410        code: String,
411        /// Full text of the hazard statement (for LLM context).
412        full_text: Option<String>,
413        /// Zero-based index into `HazardStatement[]`.
414        statement_index: usize,
415    },
416    /// A P-code in `PrecautionaryStatements` is not in the GHS P-code list.
417    UnknownPCode {
418        /// The invalid code as extracted (e.g. `"P286"`).
419        code: String,
420        /// Full text of the precautionary statement (for LLM context).
421        full_text: Option<String>,
422        /// Which sub-list the entry belongs to.
423        category: PCodeCategory,
424        /// Zero-based index within that sub-list.
425        statement_index: usize,
426    },
427    /// A CAS number whose check digit does not match; `expected_digit` is already computed.
428    CasCheckDigit {
429        /// The CAS string with the wrong check digit (e.g. `"238016-30-4"`).
430        cas: String,
431        /// Zero-based index into `CompositionAndConcentration[]`.
432        composition_index: usize,
433        /// The digit the weighted-sum algorithm requires.
434        expected_digit: u32,
435    },
436}
437
438/// Collect actionable [`ValidationFinding`]s for Sections 2 (GHS codes) and 3 (CAS).
439///
440/// These are the findings that [`crate::converter::corrector::apply_correction_pass`]
441/// can act on.  Structural presence-check warnings (missing sections, missing supplier
442/// info, etc.) are **not** included here because they cannot be corrected without
443/// regenerating the entire section.
444///
445/// This function is independent of [`validate()`] and can be called before or after
446/// it without affecting the existing warning pipeline.
447pub fn collect_findings(sds: &SdsRoot) -> Vec<ValidationFinding> {
448    let mut findings: Vec<ValidationFinding> = Vec::new();
449
450    // ── Section 2: H-codes ────────────────────────────────────────────────────
451    if let Some(hz) = &sds.hazard_identification {
452        if let Some(hl) = &hz.hazard_labelling {
453            // H-codes
454            if let Some(stmts) = &hl.hazard_statement {
455                for (i, s) in stmts.iter().enumerate() {
456                    if let Some(code) = &s.hazard_statement_code {
457                        if !ghs_codes::is_valid_h_code(&code.to_uppercase()) {
458                            findings.push(ValidationFinding::UnknownHCode {
459                                code: code.clone(),
460                                full_text: s.full_text.clone(),
461                                statement_index: i,
462                            });
463                        }
464                    }
465                }
466            }
467            // P-codes — each sub-list has a distinct concrete type, so we use a
468            // macro to avoid repeating identical logic four times.
469            if let Some(ps) = &hl.precautionary_statements {
470                macro_rules! collect_p {
471                    ($cat:expr, $list:expr) => {
472                        if let Some(list) = $list {
473                            for (i, s) in list.iter().enumerate() {
474                                if let Some(code) = &s.precautionary_statement_code {
475                                    if !ghs_codes::is_valid_p_code(&code.to_uppercase()) {
476                                        findings.push(ValidationFinding::UnknownPCode {
477                                            code: code.clone(),
478                                            full_text: s.full_text.clone(),
479                                            category: $cat,
480                                            statement_index: i,
481                                        });
482                                    }
483                                }
484                            }
485                        }
486                    };
487                }
488                collect_p!(PCodeCategory::Prevention, ps.prevention.as_deref());
489                collect_p!(PCodeCategory::Response,   ps.response.as_deref());
490                collect_p!(PCodeCategory::Storage,    ps.storage.as_deref());
491                collect_p!(PCodeCategory::Disposal,   ps.disposal.as_deref());
492            }
493        }
494    }
495
496    // ── Section 3: CAS check-digit mismatches ─────────────────────────────────
497    if let Some(comp) = &sds.composition {
498        let items = comp.composition_and_concentration.as_deref().unwrap_or(&[]);
499        for (i, item) in items.iter().enumerate() {
500            if let Some(ids) = &item.substance_identifiers {
501                if let Some(identity) = &ids.substance_identity {
502                    if let Some(cas_node) = &identity.ca_sno {
503                        for cas in cas_node.full_text.iter().flatten() {
504                            if let CasValidation::InvalidCheckDigit { expected } = validate_cas(cas) {
505                                findings.push(ValidationFinding::CasCheckDigit {
506                                    cas: cas.clone(),
507                                    composition_index: i,
508                                    expected_digit: expected,
509                                });
510                            }
511                        }
512                    }
513                }
514            }
515        }
516    }
517
518    findings
519}
520
521/// Detailed result of CAS Registry Number validation.
522#[derive(Debug, PartialEq)]
523pub(crate) enum CasValidation {
524    /// The CAS number passes both format and check-digit validation.
525    Ok,
526    /// The string does not match the expected format (`\d{2,7}-\d{2}-\d`).
527    InvalidFormat,
528    /// The format is correct but the check digit is wrong.
529    InvalidCheckDigit {
530        /// The check digit value that the weighted-sum algorithm requires.
531        expected: u32,
532    },
533}
534
535/// Validate a CAS Registry Number, returning a detailed [`CasValidation`] result.
536///
537/// Format: `^\d{2,7}-\d{2}-\d$`
538/// Check digit: weighted sum of all non-check digits (right-to-left, weight starts at 1)
539/// modulo 10 must equal the supplied check digit.
540pub(crate) fn validate_cas(cas: &str) -> CasValidation {
541    let parts: Vec<&str> = cas.split('-').collect();
542    if parts.len() != 3 {
543        return CasValidation::InvalidFormat;
544    }
545    let (a, b, c) = (parts[0], parts[1], parts[2]);
546    // Format check
547    if a.len() < 2
548        || a.len() > 7
549        || b.len() != 2
550        || c.len() != 1
551        || !a.chars().all(|ch| ch.is_ascii_digit())
552        || !b.chars().all(|ch| ch.is_ascii_digit())
553        || !c.chars().all(|ch| ch.is_ascii_digit())
554    {
555        return CasValidation::InvalidFormat;
556    }
557    // Both unwraps are guaranteed by the format check above.
558    let check_digit: u32 = c
559        .chars()
560        .next()
561        .expect("c has length 1 after format check")
562        .to_digit(10)
563        .expect("c is an ASCII digit after format check");
564    let digits: Vec<u32> = a
565        .chars()
566        .chain(b.chars())
567        .filter_map(|ch| ch.to_digit(10))
568        .collect();
569    let sum: u32 = digits
570        .iter()
571        .rev()
572        .enumerate()
573        .map(|(i, &d)| d * (i as u32 + 1))
574        .sum();
575    let expected = sum % 10;
576    if expected == check_digit {
577        CasValidation::Ok
578    } else {
579        CasValidation::InvalidCheckDigit { expected }
580    }
581}
582
583/// Convenience wrapper — returns `true` only when both format and check digit are valid.
584///
585/// Kept for use by [`crate::enrichment`] which only needs a boolean result.
586pub(crate) fn validate_cas_format(cas: &str) -> bool {
587    validate_cas(cas) == CasValidation::Ok
588}
589
590// ---------------------------------------------------------------------------
591// Phase 2: source-text verification (zero LLM calls)
592// ---------------------------------------------------------------------------
593
594/// Check whether key extracted values can be found verbatim in the source text.
595///
596/// This is a pure, deterministic pass — no API calls.  Values that are too
597/// short (< 3 chars), match a placeholder pattern, or are otherwise
598/// un-verifiable are silently skipped.
599///
600/// Returns human-readable warnings for each value that could not be located in
601/// `source_text`.  These warnings should be appended to the conversion report
602/// alongside the `validate()` warnings.
603/// Country-specific validation checks run after the universal `validate()` pass.
604///
605/// Returns a list of warning strings prefixed with `[China]`, `[Korea]`, etc.
606/// so they are distinguishable in the conversion report.
607pub fn validate_country(sds: &SdsRoot, country: SourceCountry) -> Vec<String> {
608    let mut w: Vec<String> = Vec::new();
609    match country {
610        SourceCountry::China => {
611            if !has_emergency_contact(sds) {
612                w.push(
613                    "WARN: [China] Section 1: 24-hour emergency contact (紧急电话) is \
614                     mandatory under GB/T 16483 but was not found."
615                        .into(),
616                );
617            }
618            if !regulatory_mentions_keyword(sds, &["危险化学品", "GB 13690", "GB13690", "GB 30000"]) {
619                w.push(
620                    "WARN: [China] Section 15: GB/T 16483 requires reference to \
621                     危险化学品目录 / GB 13690 in RegulatoryInformation, but none found."
622                        .into(),
623                );
624            }
625        }
626        SourceCountry::Korea => {
627            if !has_emergency_contact(sds) {
628                w.push(
629                    "WARN: [Korea] Section 1: emergency contact (e.g. 1588-9119) is \
630                     required by K-GHS but was not found."
631                        .into(),
632                );
633            }
634        }
635        SourceCountry::Taiwan => {
636            if !has_emergency_contact(sds) {
637                w.push(
638                    "WARN: [Taiwan] Section 1: emergency contact information is \
639                     required by CNS 15030 but was not found."
640                        .into(),
641                );
642            }
643        }
644        SourceCountry::Japan => {}
645    }
646    w
647}
648
649/// Returns true if any EmergencyContact entry with a non-empty Phone field is present.
650fn has_emergency_contact(sds: &SdsRoot) -> bool {
651    let id = match sds.identification.as_ref() { Some(v) => v, None => return false };
652    // Check DomesticManufacturerInformation.EmergencyContact
653    if let Some(dmi) = &id.domestic_manufacturer_information {
654        if let Some(contacts) = &dmi.emergency_contact {
655            if contacts.iter().any(|c| c.phone.as_ref().map_or(false, |p| !p.is_empty())) {
656                return true;
657            }
658        }
659    }
660    // Check SupplierInformation.EmergencyContact
661    if let Some(si) = &id.supplier_information {
662        if let Some(contacts) = &si.emergency_contact {
663            if contacts.iter().any(|c| c.phone.as_ref().map_or(false, |p| !p.is_empty())) {
664                return true;
665            }
666        }
667    }
668    false
669}
670
671/// Returns true if RegulatoryInformation contains any of the given keywords.
672fn regulatory_mentions_keyword(sds: &SdsRoot, keywords: &[&str]) -> bool {
673    let ri = match sds.regulatory_information.as_ref() { Some(v) => v, None => return false };
674    // Serialize to JSON and do a string search — simpler than traversing the deeply nested schema.
675    let Ok(v) = serde_json::to_string(ri) else { return false };
676    keywords.iter().any(|kw| v.contains(kw))
677}
678
679pub fn verify_against_source(sds: &SdsRoot, source_text: &str, skip_cas: &[String]) -> Vec<String> {
680    let mut w: Vec<String> = Vec::new();
681
682    // ── ProductName ──────────────────────────────────────────────────────────
683    let source_has_kana = has_kana(source_text);
684    if let Some(id) = &sds.identification {
685        if let Some(tpi) = &id.trade_product_identity {
686            for (field, val) in [
687                ("TradeNameJP", tpi.trade_name_jp.as_deref()),
688                ("TradeNameEN", tpi.trade_name_en.as_deref()),
689            ] {
690                if let Some(name) = val {
691                    if !is_placeholder(name) && !source_contains_name(source_text, name) {
692                        // Extra hint when TradeNameJP is absent from a kana-free source:
693                        // the LLM likely invented a Japanese name (kana or kanji) that
694                        // does not appear anywhere in the source document.
695                        let suffix = if field == "TradeNameJP" && !source_has_kana {
696                            " (source has no Japanese — likely a language hallucination)"
697                        } else {
698                            " — possible hallucination"
699                        };
700                        w.push(format!(
701                            "[SourceVerify] Section 1 ({field}): '{}' not found verbatim in \
702                             source text{suffix}.",
703                            truncate(name, 60)
704                        ));
705                    }
706                }
707            }
708        }
709    }
710
711    // ── CAS numbers ─────────────────────────────────────────────────────────
712    if let Some(comp) = &sds.composition {
713        for (i, item) in comp
714            .composition_and_concentration
715            .iter()
716            .flatten()
717            .enumerate()
718        {
719            if let Some(ids) = &item.substance_identifiers {
720                if let Some(identity) = &ids.substance_identity {
721                    if let Some(cas_node) = &identity.ca_sno {
722                        for cas in cas_node.full_text.iter().flatten() {
723                            // Only check format-valid CAS numbers; malformed ones
724                            // (e.g. "无资料") are already reported by validate().
725                            // Skip CAS values that were deterministically corrected by the
726                            // corrector — the source PDF has the wrong digit, so a match
727                            // against the corrected value would always fail.
728                            if validate_cas(cas) == CasValidation::Ok
729                                && !skip_cas.contains(cas)
730                                && !source_contains_cas(source_text, cas)
731                            {
732                                w.push(format!(
733                                    "[SourceVerify] Section 3 (Composition[{i}] CAS): \
734                                     '{cas}' not found in source text — possible hallucination."
735                                ));
736                            }
737                        }
738                    }
739                }
740            }
741        }
742    }
743
744    // ── Substance names ──────────────────────────────────────────────────────
745    if let Some(comp) = &sds.composition {
746        for (i, item) in comp
747            .composition_and_concentration
748            .iter()
749            .flatten()
750            .enumerate()
751        {
752            if let Some(ids) = &item.substance_identifiers {
753                if let Some(names) = &ids.substance_names {
754                    for (field, val) in [
755                        ("IupacName", names.iupac_name.as_deref()),
756                        ("GenericName", names.generic_name.as_deref()),
757                    ] {
758                        if let Some(name) = val {
759                            if name.trim().len() >= 3
760                                && !is_placeholder(name)
761                                && !source_contains_name(source_text, name)
762                            {
763                                w.push(format!(
764                                    "[SourceVerify] Section 3 (Composition[{i}].{field}): \
765                                     '{}' not found verbatim in source text.",
766                                    truncate(name, 60)
767                                ));
768                            }
769                        }
770                    }
771                }
772            }
773        }
774    }
775
776    // ── Language-mismatch: Japanese kana in non-Japanese source ──────────────
777    // If any name field contains hiragana/katakana but the source document has
778    // none, the LLM has fabricated a Japanese transliteration that isn't in the
779    // source — a hallucination pattern distinct from "not found verbatim".
780    if !source_has_kana {
781        // TradeNameJP with kana
782        if let Some(id) = &sds.identification {
783            if let Some(tpi) = &id.trade_product_identity {
784                if let Some(name) = &tpi.trade_name_jp {
785                    if has_kana(name) {
786                        w.push(format!(
787                            "[SourceVerify] Section 1 (TradeNameJP): '{}' contains Japanese \
788                             kana but none appears in the source document — likely a \
789                             transliteration hallucination.",
790                            truncate(name, 60)
791                        ));
792                    }
793                }
794            }
795        }
796        // IupacName / GenericName with kana
797        if let Some(comp) = &sds.composition {
798            for (i, item) in comp
799                .composition_and_concentration
800                .iter()
801                .flatten()
802                .enumerate()
803            {
804                if let Some(ids) = &item.substance_identifiers {
805                    if let Some(names) = &ids.substance_names {
806                        for (field, val) in [
807                            ("IupacName",   names.iupac_name.as_deref()),
808                            ("GenericName", names.generic_name.as_deref()),
809                        ] {
810                            if let Some(name) = val {
811                                if has_kana(name) {
812                                    w.push(format!(
813                                        "[SourceVerify] Section 3 (Composition[{i}].{field}): \
814                                         '{}' contains Japanese kana but source has none — \
815                                         possible language hallucination.",
816                                        truncate(name, 60)
817                                    ));
818                                }
819                            }
820                        }
821                    }
822                }
823            }
824        }
825        // SupplierInformation.CompanyName with kana
826        if let Some(id) = &sds.identification {
827            if let Some(si) = &id.supplier_information {
828                if let Some(name) = &si.company_name {
829                    if has_kana(name) {
830                        w.push(format!(
831                            "[SourceVerify] Section 1 (SupplierInformation.CompanyName): \
832                             '{}' contains Japanese kana but source has none — \
833                             possible language hallucination.",
834                            truncate(name, 60)
835                        ));
836                    }
837                }
838            }
839        }
840    }
841
842    w
843}
844
845// ---------------------------------------------------------------------------
846// Source-text search helpers
847// ---------------------------------------------------------------------------
848
849/// Product-name-specific source check.
850///
851/// For short names (≤ 40 chars) the full value must appear. For long names — e.g.
852/// UN transport names that span multiple PDF lines with intervening labels — only
853/// the first 30 characters need to be found. This avoids false positives where a
854/// PDF inserts a section label ("英文名称:") in the middle of a long name.
855fn source_contains_name(source: &str, name: &str) -> bool {
856    if source_contains(source, name) {
857        return true;
858    }
859    let chars: Vec<char> = name.chars().collect();
860    if chars.len() > 40 {
861        // For long names (UN names, IUPAC names that span PDF lines), verify only
862        // the "base" segment — the text up to the first delimiter that commonly
863        // introduces sub-clauses in long names: '(', ',', ';', '('.
864        // Falls back to the first 25 chars if no delimiter appears early enough.
865        let delimiters = ['(', ',', ';', '('];
866        let cut = chars[..chars.len().min(35)]
867            .iter()
868            .position(|c| delimiters.contains(c))
869            .unwrap_or(25)
870            .max(8); // always verify at least 8 chars
871        let prefix: String = chars[..cut].iter().collect();
872        if source_contains(source, prefix.trim()) {
873            return true;
874        }
875    }
876    // Strip leading positional-number prefix (e.g. "1,1,2,3,4,4-" in IUPAC names like
877    // "1,1,2,3,4,4-六氯-1,3-丁二烯") and verify the core name in the source.
878    // Sources sometimes print only the short form without position descriptors.
879    let name_norm = normalize_for_search(name);
880    let src_norm = normalize_for_search(source);
881    let core = name_norm.trim_start_matches(|c: char| c.is_ascii_digit() || c == ',' || c == '-');
882    if core.len() >= 8 && src_norm.contains(core) {
883        return true;
884    }
885    // Strip optical-rotation / stereodescriptor markers that CID-font PDFs often
886    // split across lines, causing the marker to disappear from extracted text.
887    // e.g. "イソプロピルβD(-)チオガラクトピラノシド" → "イソプロピルβDチオガラクトピラノシド"
888    let stripped_stereo = name_norm
889        .replace("(-)", "")
890        .replace("(+)", "")
891        .replace("(±)", "")
892        .replace("(R)", "")
893        .replace("(S)", "")
894        .replace("(d)", "")
895        .replace("(l)", "");
896    if stripped_stereo.len() >= 8 && src_norm.contains(stripped_stereo.as_str()) {
897        return true;
898    }
899    false
900}
901
902/// Check if `value` appears as a substring of `source` (after whitespace normalization).
903///
904/// Skips values shorter than 3 characters to avoid trivial false-positives.
905/// Falls back to a space-collapsed comparison to handle CID-font PDFs that insert
906/// stray spaces inside words (e.g. "エ チル" vs "エチル").
907fn source_contains(source: &str, value: &str) -> bool {
908    let v = value.trim();
909    if v.len() < 3 {
910        return true; // too short to verify reliably
911    }
912    if source.contains(v) {
913        return true;
914    }
915    // Collapse whitespace + normalize fullwidth punctuation, then retry.
916    // Handles CID-font artifacts (stray spaces, fullwidth commas/hyphens).
917    let v_norm = normalize_for_search(v);
918    let src_norm = normalize_for_search(source);
919    if v_norm.len() >= 3 {
920        src_norm.contains(&v_norm)
921    } else {
922        false
923    }
924}
925
926/// Normalize a string for fuzzy source-text matching:
927/// - Remove all whitespace
928/// - Convert fullwidth ASCII punctuation (,、。.-) to their halfwidth equivalents
929/// - Map common traditional Chinese characters to their simplified equivalents so that
930///   LLM output using trad. chars still matches simplified-Chinese source PDFs (and vice versa).
931fn normalize_for_search(s: &str) -> String {
932    s.chars()
933        .filter(|c| !c.is_whitespace())
934        .map(|c| match c {
935            // Fullwidth / Japanese punctuation → ASCII
936            ',' => ',',
937            '、' => ',',  // Japanese ideographic comma (U+3001)
938            '.' => '.',
939            '。' => '.',
940            '-' => '-',
941            '(' => '(',
942            ')' => ')',
943            // Traditional → Simplified (common in chemical/SDS names)
944            '異' => '异',  // iso- prefix (异丙基, 异庚烷, …)
945            '環' => '环',  // ring/cycle (环氧, 环己烷, …)
946            '鹼' => '碱',  // alkali
947            '鹽' => '盐',  // salt
948            '鋰' => '锂',  // Li
949            '鈉' => '钠',  // Na
950            '鉀' => '钾',  // K
951            '鐵' => '铁',  // Fe
952            '銅' => '铜',  // Cu
953            '鋁' => '铝',  // Al
954            '銀' => '银',  // Ag
955            '鋅' => '锌',  // Zn
956            '氫' => '氢',  // hydrogen
957            '氯' => '氯',  // chlorine (same)
958            '無' => '无',  // none/not
959            '劑' => '剂',  // agent/reagent suffix
960            '酸' => '酸',  // acid (same)
961            '烴' => '烃',  // hydrocarbon
962            _ => c,
963        })
964        .collect()
965}
966
967/// Returns true if `s` contains any hiragana (U+3040–U+309F) or katakana (U+30A0–U+30FF).
968fn has_kana(s: &str) -> bool {
969    s.chars().any(|c| matches!(c, '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}'))
970}
971
972/// CAS-aware source search: also tries full-width hyphen variants and space-collapsed
973/// forms because Japanese PDFs sometimes render hyphens as U+FF0D (-) or insert
974/// stray spaces inside CAS numbers.
975fn source_contains_cas(source: &str, cas: &str) -> bool {
976    if source.contains(cas) {
977        return true;
978    }
979    // Full-width hyphens (U+FF0D).
980    let fullwidth = cas.replace('-', "-");
981    if source.contains(&fullwidth) {
982        return true;
983    }
984    // Normalized fallback: collapse spaces + fullwidth punctuation.
985    let cas_norm = normalize_for_search(cas);
986    let src_norm = normalize_for_search(source);
987    src_norm.contains(&cas_norm)
988}
989
990/// Truncate a string to `max` chars for warning messages.
991fn truncate(s: &str, max: usize) -> &str {
992    let mut idx = max;
993    while !s.is_char_boundary(idx) && idx > 0 {
994        idx -= 1;
995    }
996    if idx < s.len() { &s[..idx] } else { s }
997}
998
999// ---------------------------------------------------------------------------
1000// Unit tests
1001// ---------------------------------------------------------------------------
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006
1007    #[test]
1008    fn test_looks_like_date_valid() {
1009        assert!(looks_like_date("2024-03-15"));
1010        assert!(looks_like_date("2024/03/15"));
1011        assert!(looks_like_date("令和6年3月15日"));
1012        assert!(looks_like_date("20240315"));
1013    }
1014
1015    #[test]
1016    fn test_looks_like_date_invalid() {
1017        assert!(!looks_like_date("不明"));
1018        assert!(!looks_like_date("N/A"));
1019        assert!(!looks_like_date("改訂"));
1020    }
1021
1022    #[test]
1023    fn test_source_contains_cas_ascii_hyphen() {
1024        let source = "成分: エタノール CAS No. 64-17-5 含有量 99%";
1025        assert!(source_contains_cas(source, "64-17-5"));
1026    }
1027
1028    #[test]
1029    fn test_source_contains_cas_fullwidth_hyphen() {
1030        let source = "CAS No. 64-17-5";
1031        assert!(source_contains_cas(source, "64-17-5"));
1032    }
1033
1034    #[test]
1035    fn test_source_contains_cas_not_present() {
1036        let source = "CAS No. 7732-18-5";
1037        assert!(!source_contains_cas(source, "64-17-5"));
1038    }
1039
1040    #[test]
1041    fn test_source_contains_short_value_always_passes() {
1042        let source = "no matches here";
1043        assert!(source_contains(source, "AB")); // len < 3 → skip
1044    }
1045
1046    #[test]
1047    fn test_truncate_ascii() {
1048        assert_eq!(truncate("hello world", 5), "hello");
1049    }
1050
1051    #[test]
1052    fn test_truncate_multibyte() {
1053        let s = "エタノール"; // 5 chars, 15 bytes
1054        let t = truncate(s, 6); // 6 bytes → "エタ" (each is 3 bytes)
1055        assert!(s.starts_with(t));
1056    }
1057}