Skip to main content

automapper_validation/eval/
ebd_cluster.rs

1//! EBD answer-code cluster lookup.
2//!
3//! Resolves UTILMD/ORDRSP/IFTSTA conditions that classify STS response
4//! codes by "Cluster Zustimmung" or "Cluster Ablehnung". The cluster is
5//! a per-EBD attribute — the same A-code can sit in a different cluster
6//! depending on which EBD (C556/1131 qualifier) it belongs to.
7//!
8//! Data source: extracted from `mako_prozesse` (YAML per EBD) via
9//! `scripts/extract_ebd_clusters.py`.
10
11use super::context::EvaluationContext;
12use super::evaluator::ConditionResult;
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::sync::OnceLock;
16
17const EMBEDDED_JSON: &str = include_str!("../../data/ebd_cluster_map.json");
18
19#[derive(Debug, Deserialize)]
20struct RawFile {
21    ebds: HashMap<String, HashMap<String, String>>,
22}
23
24/// Cluster classifier derived from the `Cluster:` hint in an EBD YAML.
25///
26/// Primary clusters (Zustimmung, Ablehnung) and the three Ablehnung
27/// sub-levels referenced by REMADV/INVOIC conditions have named
28/// variants; anything else (Änderung, Korrekturliste, Abweisung, ...)
29/// falls into `Other` preserving the raw label for future match-ups.
30///
31/// `is_ablehnung` returns true for both the plain `Ablehnung` variant
32/// and all three sub-level variants, so UTILMD_Strom conditions like
33/// `[359]` keep matching sub-level codes transparently.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Cluster {
36    Zustimmung,
37    Ablehnung,
38    AblehnungKopfebene,
39    AblehnungPositionsebene,
40    AblehnungSummenebene,
41    /// Preserves the raw cluster label (e.g. "Korrekturliste wegen Ablehnung",
42    /// "Änderung der Daten", "Abweisung") so future conditions can match
43    /// on it without regenerating the data file.
44    Other(String),
45}
46
47impl Cluster {
48    pub fn from_token(token: &str) -> Self {
49        match token {
50            "Zustimmung" => Cluster::Zustimmung,
51            "Ablehnung" => Cluster::Ablehnung,
52            "Ablehnung auf Kopfebene" => Cluster::AblehnungKopfebene,
53            "Ablehnung auf Positionsebene" => Cluster::AblehnungPositionsebene,
54            "Ablehnung auf Summenebene" => Cluster::AblehnungSummenebene,
55            _ => Cluster::Other(token.to_owned()),
56        }
57    }
58
59    pub fn is_zustimmung(&self) -> bool {
60        matches!(self, Cluster::Zustimmung)
61    }
62
63    /// Any Ablehnung variant — plain or Kopf/Positions/Summenebene.
64    pub fn is_ablehnung(&self) -> bool {
65        matches!(
66            self,
67            Cluster::Ablehnung
68                | Cluster::AblehnungKopfebene
69                | Cluster::AblehnungPositionsebene
70                | Cluster::AblehnungSummenebene
71        )
72    }
73
74    pub fn is_ablehnung_kopfebene(&self) -> bool {
75        matches!(self, Cluster::AblehnungKopfebene)
76    }
77
78    pub fn is_ablehnung_positionsebene(&self) -> bool {
79        matches!(self, Cluster::AblehnungPositionsebene)
80    }
81
82    pub fn is_ablehnung_summenebene(&self) -> bool {
83        matches!(self, Cluster::AblehnungSummenebene)
84    }
85}
86
87#[derive(Debug)]
88pub struct EbdClusterLookup {
89    clusters: HashMap<(String, String), Cluster>,
90}
91
92impl EbdClusterLookup {
93    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
94        let raw: RawFile = serde_json::from_str(json)?;
95        let clusters = raw
96            .ebds
97            .into_iter()
98            .flat_map(|(ebd, codes)| {
99                codes
100                    .into_iter()
101                    .map(move |(code, token)| ((ebd.clone(), code), Cluster::from_token(&token)))
102            })
103            .collect();
104        Ok(Self { clusters })
105    }
106
107    pub fn embedded() -> &'static Self {
108        static CELL: OnceLock<EbdClusterLookup> = OnceLock::new();
109        CELL.get_or_init(|| {
110            EbdClusterLookup::from_json(EMBEDDED_JSON)
111                .expect("embedded ebd_cluster_map.json is malformed")
112        })
113    }
114
115    /// Looks up the cluster for `code` within `ebd`. Returns `None` when
116    /// the EBD isn't in the data set or the code isn't an answer-code
117    /// leaf of that EBD.
118    pub fn cluster(&self, ebd: &str, code: &str) -> Option<&Cluster> {
119        self.clusters.get(&(ebd.to_owned(), code.to_owned()))
120    }
121}
122
123// -----------------------------------------------------------------------
124// Shared evaluator helpers for cluster conditions.
125//
126// These sit on the ctx + cluster lookup and are used by per-message
127// generated condition files (UTILMD_Strom, ORDRSP, UTILTS, REMADV).
128// Putting them here keeps the HAND-EDITED hot spots in one place so the
129// codegen prompt (Task B6) only needs to learn about this one module.
130
131/// STS+E01 layout helper: extract `(code, ebd)` from element[2].
132/// Returns None when either component is missing or empty.
133fn sts_e01_code_ebd(seg: &mig_types::segment::OwnedSegment) -> Option<(&str, &str)> {
134    let c556 = seg.elements.get(2)?;
135    let code = c556.first().filter(|v| !v.is_empty())?.as_str();
136    let ebd = c556.get(1).filter(|v| !v.is_empty())?.as_str();
137    Some((code, ebd))
138}
139
140/// AJT layout helper (REMADV): extract `(code, ebd)` from `AJT`.
141/// AJT has DE4465 at element[0][0] and DE1082 (EBD qualifier like
142/// `E_0403`) at element[1][0].
143fn ajt_code_ebd(seg: &mig_types::segment::OwnedSegment) -> Option<(&str, &str)> {
144    let code = seg
145        .elements
146        .first()?
147        .first()
148        .filter(|v| !v.is_empty())?
149        .as_str();
150    let ebd = seg
151        .elements
152        .get(1)?
153        .first()
154        .filter(|v| !v.is_empty())?
155        .as_str();
156    Some((code, ebd))
157}
158
159/// The STS answers a cluster condition judges: the answer the validator is
160/// visiting, when it is one of `qualifier` — or else every STS+`qualifier`
161/// of the enclosing SG4 (message-wide without a scope) that carries a code.
162/// `(code, ebd)`; the EBD is empty when the segment names none.
163fn sts_answers(ctx: &EvaluationContext, qualifier: &str) -> Vec<(String, String)> {
164    let answer = |elements: &[Vec<String>]| {
165        let c556 = elements.get(2)?;
166        let code = c556.first().filter(|v| !v.is_empty())?;
167        Some((code.clone(), c556.get(1).cloned().unwrap_or_default()))
168    };
169    if let (Some(value), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) {
170        let own = segment.first().and_then(|e| e.first()).map(String::as_str);
171        if own == Some(qualifier) && !value.is_empty() {
172            let ebd = segment
173                .get(2)
174                .and_then(|c| c.get(1))
175                .cloned()
176                .unwrap_or_default();
177            return vec![(value.to_string(), ebd)];
178        }
179    }
180    ctx.scoped_find_segments_in_with_qualifier("SG4", "STS", 0, qualifier)
181        .iter()
182        .filter_map(|s| answer(&s.elements))
183        .collect()
184}
185
186/// Whether every answer passes `judge`: `False` when one fails, `Unknown`
187/// when none fails but one cannot be told, `True` otherwise — no answer
188/// included. A missing answer is the business of the field's requirement, not
189/// of a condition on what the answer may be.
190fn every_answer(
191    answers: &[(String, String)],
192    judge: impl Fn(&str, &str) -> ConditionResult,
193) -> ConditionResult {
194    let mut result = ConditionResult::True;
195    for (code, ebd) in answers {
196        match judge(code, ebd) {
197            ConditionResult::False => return ConditionResult::False,
198            ConditionResult::Unknown => result = ConditionResult::Unknown,
199            ConditionResult::True => {}
200        }
201    }
202    result
203}
204
205/// `[UTILMD_Strom 359/360]` — every `STS+E01` answer is a code whose cluster
206/// under its referenced EBD matches `predicate` ("Es sind nur Antwortcodes aus
207/// dem Cluster Zustimmung erlaubt"). A code the EBD does not hold (`Q99`) is in
208/// no cluster and fails; an answer naming no EBD cannot be told.
209pub fn all_sts_e01_in_cluster(
210    ctx: &EvaluationContext,
211    predicate: impl Fn(&Cluster) -> bool,
212) -> ConditionResult {
213    every_answer(&sts_answers(ctx, "E01"), |code, ebd| {
214        if ebd.is_empty() {
215            return ConditionResult::Unknown;
216        }
217        ConditionResult::from(ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate))
218    })
219}
220
221/// `[UTILMD_Strom 366/368]` — every `STS+qualifier` answer is a code of
222/// `required_ebd` in the cluster Ablehnung, other than `excluded_codes` ("Bis
223/// auf den Code A30 sind alle Codes aus EBD E_0624 im Cluster Ablehnung
224/// erlaubt"). The conditions sit on STS+Z35, the third party's answer; the
225/// STS+E01 beside it names another EBD. An answer naming no EBD is read as
226/// naming `required_ebd`, the only one its AHB allows.
227pub fn ebd_ablehnung_except(
228    ctx: &EvaluationContext,
229    qualifier: &str,
230    required_ebd: &str,
231    excluded_codes: &[&str],
232) -> ConditionResult {
233    every_answer(&sts_answers(ctx, qualifier), |code, ebd| {
234        let ebd = if ebd.is_empty() { required_ebd } else { ebd };
235        ConditionResult::from(
236            ebd == required_ebd
237                && !excluded_codes.contains(&code)
238                && ctx
239                    .ebd_clusters
240                    .cluster(ebd, code)
241                    .is_some_and(Cluster::is_ablehnung),
242        )
243    })
244}
245
246/// `[UTILTS 61]` — any `STS+E01` carries an Ablehnung-cluster code.
247/// Existential rather than universal — matches the AHB wording
248/// "Wenn in einem STS+E01 ... ein Antwortcode aus dem Cluster Ablehnung
249/// vorhanden ist". Returns `False` rather than `Unknown` when no
250/// `STS+E01` is present (the premise is absent).
251pub fn any_sts_e01_in_cluster(
252    ctx: &EvaluationContext,
253    predicate: impl Fn(&Cluster) -> bool,
254) -> ConditionResult {
255    let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
256    let found = sts_segs.iter().any(|s| {
257        sts_e01_code_ebd(s)
258            .is_some_and(|(code, ebd)| ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate))
259    });
260    ConditionResult::from(found)
261}
262
263/// `[ORDRSP 17/18]` — resolved STS code is in the given cluster under
264/// the same segment's referenced EBD. Uses `ctx.resolved_value` +
265/// `ctx.resolved_segment` which are populated by the tree validator
266/// when visiting a specific field.
267///
268/// Returns `Unknown` when the resolved context is absent — so bulk
269/// `evaluate(17, ctx)` calls without a resolved field get a safe
270/// three-valued answer.
271pub fn resolved_sts_code_in_cluster(
272    ctx: &EvaluationContext,
273    predicate: impl Fn(&Cluster) -> bool,
274) -> ConditionResult {
275    let (Some(code), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) else {
276        return ConditionResult::Unknown;
277    };
278    if code.is_empty() {
279        return ConditionResult::Unknown;
280    }
281    let ebd = segment
282        .get(2)
283        .and_then(|e| e.get(1))
284        .map(|s| s.as_str())
285        .filter(|s| !s.is_empty());
286    let Some(ebd) = ebd else {
287        return ConditionResult::Unknown;
288    };
289    ctx.ebd_clusters
290        .cluster(ebd, code)
291        .map(|c| ConditionResult::from(predicate(c)))
292        .unwrap_or(ConditionResult::Unknown)
293}
294
295/// `[REMADV 14/15/16]` — resolved AJT DE4465 code is in the given
296/// cluster under the segment's referenced EBD (DE1082, element[1][0]).
297pub fn resolved_ajt_code_in_cluster(
298    ctx: &EvaluationContext,
299    predicate: impl Fn(&Cluster) -> bool,
300) -> ConditionResult {
301    let (Some(code), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) else {
302        return ConditionResult::Unknown;
303    };
304    if code.is_empty() {
305        return ConditionResult::Unknown;
306    }
307    let ebd = segment
308        .get(1)
309        .and_then(|e| e.first())
310        .map(|s| s.as_str())
311        .filter(|s| !s.is_empty());
312    let Some(ebd) = ebd else {
313        return ConditionResult::Unknown;
314    };
315    ctx.ebd_clusters
316        .cluster(ebd, code)
317        .map(|c| ConditionResult::from(predicate(c)))
318        .unwrap_or(ConditionResult::Unknown)
319}
320
321/// `[REMADV 517/518]` — any AJT "in dieser SG5" (the enclosing SG5 and its
322/// SG7s; message-wide when no scope is attached) carries a code whose
323/// cluster matches `predicate` and whose A-code is not in
324/// `excluded_codes`.
325pub fn any_scoped_ajt_in_cluster(
326    ctx: &EvaluationContext,
327    excluded_codes: &[&str],
328    predicate: impl Fn(&Cluster) -> bool,
329) -> ConditionResult {
330    let ajt_segs = ctx.scoped_find_segments_in("SG5", "AJT");
331    let found = ajt_segs.iter().any(|s| {
332        ajt_code_ebd(s).is_some_and(|(code, ebd)| {
333            !excluded_codes.contains(&code)
334                && ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate)
335        })
336    });
337    ConditionResult::from(found)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::eval::NoOpExternalProvider;
344    use mig_types::segment::OwnedSegment;
345
346    fn sts(qualifier: &str, code: &str, ebd: &str) -> OwnedSegment {
347        OwnedSegment {
348            id: "STS".into(),
349            elements: vec![
350                vec![qualifier.into()],
351                vec![],
352                vec![code.into(), ebd.into()],
353            ],
354            segment_number: 0,
355        }
356    }
357
358    fn judge(
359        segments: &[OwnedSegment],
360        f: impl Fn(&EvaluationContext) -> ConditionResult,
361    ) -> ConditionResult {
362        let external = NoOpExternalProvider;
363        let ctx = EvaluationContext::new("55003", &external, segments);
364        f(&ctx)
365    }
366
367    /// `[360]`: an Ablehnung code, or a code no list holds, is not a
368    /// Zustimmung (mako-twin: 55002 passed with A50 and with Q99).
369    #[test]
370    fn only_zustimmung_answers_hold_360() {
371        let zustimmung = |segs: &[OwnedSegment]| {
372            judge(segs, |ctx| {
373                all_sts_e01_in_cluster(ctx, Cluster::is_zustimmung)
374            })
375        };
376        assert_eq!(
377            zustimmung(&[sts("E01", "A51", "E_0623")]),
378            ConditionResult::True
379        );
380        assert_eq!(
381            zustimmung(&[sts("E01", "A50", "E_0623")]),
382            ConditionResult::False
383        );
384        assert_eq!(
385            zustimmung(&[sts("E01", "Q99", "E_0623")]),
386            ConditionResult::False
387        );
388        // Nothing answered: nothing breaks the rule; whether an answer is
389        // missing is the requirement's business.
390        assert_eq!(zustimmung(&[]), ConditionResult::True);
391    }
392
393    /// `[366]`/`[368]` judge the third party's answer, STS+Z35 against
394    /// E_0624 — not the STS+E01, which names another EBD.
395    #[test]
396    fn the_third_partys_answer_is_judged_by_366_and_368() {
397        let e01 = sts("E01", "A50", "E_0623");
398        let r366 = |z35: &str| {
399            judge(&[e01.clone(), sts("Z35", z35, "E_0624")], |ctx| {
400                ebd_ablehnung_except(ctx, "Z35", "E_0624", &["A30"])
401            })
402        };
403        assert_eq!(r366("A32"), ConditionResult::True, "A32 is an Ablehnung");
404        assert_eq!(r366("A30"), ConditionResult::False, "bis auf A30");
405        assert_eq!(r366("A31"), ConditionResult::False, "A31 is a Zustimmung");
406        let r368 = |z35: &str| {
407            judge(&[e01.clone(), sts("Z35", z35, "E_0624")], |ctx| {
408                ebd_ablehnung_except(ctx, "Z35", "E_0624", &["A41"])
409            })
410        };
411        assert_eq!(r368("A30"), ConditionResult::True);
412        assert_eq!(r368("A41"), ConditionResult::False);
413        // No STS+Z35: nothing breaks the rule.
414        assert_eq!(
415            judge(std::slice::from_ref(&e01), |ctx| ebd_ablehnung_except(
416                ctx,
417                "Z35",
418                "E_0624",
419                &["A41"]
420            )),
421            ConditionResult::True
422        );
423    }
424
425    /// The validator visiting the answer itself judges that answer, not
426    /// every STS of the message.
427    #[test]
428    fn a_resolved_answer_is_judged_alone() {
429        let segments = [sts("Z35", "A30", "E_0624"), sts("Z35", "A41", "E_0624")];
430        let external = NoOpExternalProvider;
431        let ctx = EvaluationContext::new("55080", &external, &segments);
432        let first = ctx.with_resolved(Some("A30"), Some(&segments[0].elements));
433        assert_eq!(
434            ebd_ablehnung_except(&first, "Z35", "E_0624", &["A41"]),
435            ConditionResult::True
436        );
437        let second = ctx.with_resolved(Some("A41"), Some(&segments[1].elements));
438        assert_eq!(
439            ebd_ablehnung_except(&second, "Z35", "E_0624", &["A41"]),
440            ConditionResult::False
441        );
442    }
443
444    #[test]
445    fn a36_in_e0624_is_zustimmung() {
446        let lookup = EbdClusterLookup::embedded();
447        assert_eq!(lookup.cluster("E_0624", "A36"), Some(&Cluster::Zustimmung));
448    }
449
450    #[test]
451    fn a30_in_e0624_is_ablehnung() {
452        let lookup = EbdClusterLookup::embedded();
453        assert_eq!(lookup.cluster("E_0624", "A30"), Some(&Cluster::Ablehnung));
454    }
455
456    #[test]
457    fn unknown_code_returns_none() {
458        let lookup = EbdClusterLookup::embedded();
459        assert_eq!(lookup.cluster("E_0624", "Z99"), None);
460    }
461
462    #[test]
463    fn unknown_ebd_returns_none() {
464        let lookup = EbdClusterLookup::embedded();
465        assert_eq!(lookup.cluster("E_9999", "A01"), None);
466    }
467
468    #[test]
469    fn same_code_can_differ_across_ebds() {
470        // A01 is a common outcome code across many EBDs; at minimum make
471        // sure the (ebd, code) key shape works — both should resolve.
472        let lookup = EbdClusterLookup::embedded();
473        assert!(lookup.cluster("E_0014", "A01").is_some());
474        assert!(lookup.cluster("E_0049", "A01").is_some());
475    }
476
477    #[test]
478    fn korrekturliste_wegen_ablehnung_is_preserved_as_other() {
479        let lookup = EbdClusterLookup::embedded();
480        // E_0014 A04 has hint "Cluster: Korrekturliste wegen Ablehnung ...".
481        // Not in the named enum variants, so it stays in Other with the
482        // full multi-word label.
483        match lookup.cluster("E_0014", "A04") {
484            Some(Cluster::Other(s)) => assert_eq!(s, "Korrekturliste wegen Ablehnung"),
485            other => {
486                panic!("expected Cluster::Other(\"Korrekturliste wegen Ablehnung\"), got {other:?}")
487            }
488        }
489    }
490
491    #[test]
492    fn sub_level_ablehnung_variants_still_count_as_ablehnung() {
493        assert!(Cluster::Ablehnung.is_ablehnung());
494        assert!(Cluster::AblehnungKopfebene.is_ablehnung());
495        assert!(Cluster::AblehnungPositionsebene.is_ablehnung());
496        assert!(Cluster::AblehnungSummenebene.is_ablehnung());
497        assert!(!Cluster::Zustimmung.is_ablehnung());
498        assert!(!Cluster::Other("Abweisung".into()).is_ablehnung());
499    }
500}