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/// `[UTILMD_Strom 359/360]` — every `STS+E01` carries a code whose
160/// cluster under its referenced EBD matches `predicate`.
161///
162/// `Unknown` when no `STS+E01` is present (insufficient context);
163/// `True`/`False` when all segments agree / any segment disagrees.
164pub fn all_sts_e01_in_cluster(
165    ctx: &EvaluationContext,
166    predicate: impl Fn(&Cluster) -> bool,
167) -> ConditionResult {
168    let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
169    if sts_segs.is_empty() {
170        return ConditionResult::Unknown;
171    }
172    let all = sts_segs.iter().all(|s| {
173        let Some((code, ebd)) = sts_e01_code_ebd(s) else {
174            return false;
175        };
176        ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate)
177    });
178    ConditionResult::from(all)
179}
180
181/// `[UTILMD_Strom 366/368]` — every `STS+E01` references `required_ebd`
182/// and its code is Ablehnung and not in `excluded_codes`.
183pub fn ebd_ablehnung_except(
184    ctx: &EvaluationContext,
185    required_ebd: &str,
186    excluded_codes: &[&str],
187) -> ConditionResult {
188    let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
189    if sts_segs.is_empty() {
190        return ConditionResult::Unknown;
191    }
192    let all = sts_segs.iter().all(|s| {
193        let Some((code, ebd)) = sts_e01_code_ebd(s) else {
194            return false;
195        };
196        ebd == required_ebd
197            && !excluded_codes.contains(&code)
198            && ctx
199                .ebd_clusters
200                .cluster(ebd, code)
201                .is_some_and(Cluster::is_ablehnung)
202    });
203    ConditionResult::from(all)
204}
205
206/// `[UTILTS 61]` — any `STS+E01` carries an Ablehnung-cluster code.
207/// Existential rather than universal — matches the AHB wording
208/// "Wenn in einem STS+E01 ... ein Antwortcode aus dem Cluster Ablehnung
209/// vorhanden ist". Returns `False` rather than `Unknown` when no
210/// `STS+E01` is present (the premise is absent).
211pub fn any_sts_e01_in_cluster(
212    ctx: &EvaluationContext,
213    predicate: impl Fn(&Cluster) -> bool,
214) -> ConditionResult {
215    let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
216    let found = sts_segs.iter().any(|s| {
217        sts_e01_code_ebd(s)
218            .is_some_and(|(code, ebd)| ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate))
219    });
220    ConditionResult::from(found)
221}
222
223/// `[ORDRSP 17/18]` — resolved STS code is in the given cluster under
224/// the same segment's referenced EBD. Uses `ctx.resolved_value` +
225/// `ctx.resolved_segment` which are populated by the tree validator
226/// when visiting a specific field.
227///
228/// Returns `Unknown` when the resolved context is absent — so bulk
229/// `evaluate(17, ctx)` calls without a resolved field get a safe
230/// three-valued answer.
231pub fn resolved_sts_code_in_cluster(
232    ctx: &EvaluationContext,
233    predicate: impl Fn(&Cluster) -> bool,
234) -> ConditionResult {
235    let (Some(code), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) else {
236        return ConditionResult::Unknown;
237    };
238    if code.is_empty() {
239        return ConditionResult::Unknown;
240    }
241    let ebd = segment
242        .get(2)
243        .and_then(|e| e.get(1))
244        .map(|s| s.as_str())
245        .filter(|s| !s.is_empty());
246    let Some(ebd) = ebd else {
247        return ConditionResult::Unknown;
248    };
249    ctx.ebd_clusters
250        .cluster(ebd, code)
251        .map(|c| ConditionResult::from(predicate(c)))
252        .unwrap_or(ConditionResult::Unknown)
253}
254
255/// `[REMADV 14/15/16]` — resolved AJT DE4465 code is in the given
256/// cluster under the segment's referenced EBD (DE1082, element[1][0]).
257pub fn resolved_ajt_code_in_cluster(
258    ctx: &EvaluationContext,
259    predicate: impl Fn(&Cluster) -> bool,
260) -> ConditionResult {
261    let (Some(code), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) else {
262        return ConditionResult::Unknown;
263    };
264    if code.is_empty() {
265        return ConditionResult::Unknown;
266    }
267    let ebd = segment
268        .get(1)
269        .and_then(|e| e.first())
270        .map(|s| s.as_str())
271        .filter(|s| !s.is_empty());
272    let Some(ebd) = ebd else {
273        return ConditionResult::Unknown;
274    };
275    ctx.ebd_clusters
276        .cluster(ebd, code)
277        .map(|c| ConditionResult::from(predicate(c)))
278        .unwrap_or(ConditionResult::Unknown)
279}
280
281/// `[REMADV 517/518]` — any AJT in the current scope (falls back to
282/// message-wide when no scope is attached) carries a code whose
283/// cluster matches `predicate` and whose A-code is not in
284/// `excluded_codes`.
285pub fn any_scoped_ajt_in_cluster(
286    ctx: &EvaluationContext,
287    excluded_codes: &[&str],
288    predicate: impl Fn(&Cluster) -> bool,
289) -> ConditionResult {
290    let ajt_segs = ctx.scoped_find_segments("AJT");
291    let found = ajt_segs.iter().any(|s| {
292        ajt_code_ebd(s).is_some_and(|(code, ebd)| {
293            !excluded_codes.contains(&code)
294                && ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate)
295        })
296    });
297    ConditionResult::from(found)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn a36_in_e0624_is_zustimmung() {
306        let lookup = EbdClusterLookup::embedded();
307        assert_eq!(lookup.cluster("E_0624", "A36"), Some(&Cluster::Zustimmung));
308    }
309
310    #[test]
311    fn a30_in_e0624_is_ablehnung() {
312        let lookup = EbdClusterLookup::embedded();
313        assert_eq!(lookup.cluster("E_0624", "A30"), Some(&Cluster::Ablehnung));
314    }
315
316    #[test]
317    fn unknown_code_returns_none() {
318        let lookup = EbdClusterLookup::embedded();
319        assert_eq!(lookup.cluster("E_0624", "Z99"), None);
320    }
321
322    #[test]
323    fn unknown_ebd_returns_none() {
324        let lookup = EbdClusterLookup::embedded();
325        assert_eq!(lookup.cluster("E_9999", "A01"), None);
326    }
327
328    #[test]
329    fn same_code_can_differ_across_ebds() {
330        // A01 is a common outcome code across many EBDs; at minimum make
331        // sure the (ebd, code) key shape works — both should resolve.
332        let lookup = EbdClusterLookup::embedded();
333        assert!(lookup.cluster("E_0014", "A01").is_some());
334        assert!(lookup.cluster("E_0049", "A01").is_some());
335    }
336
337    #[test]
338    fn korrekturliste_wegen_ablehnung_is_preserved_as_other() {
339        let lookup = EbdClusterLookup::embedded();
340        // E_0014 A04 has hint "Cluster: Korrekturliste wegen Ablehnung ...".
341        // Not in the named enum variants, so it stays in Other with the
342        // full multi-word label.
343        match lookup.cluster("E_0014", "A04") {
344            Some(Cluster::Other(s)) => assert_eq!(s, "Korrekturliste wegen Ablehnung"),
345            other => {
346                panic!("expected Cluster::Other(\"Korrekturliste wegen Ablehnung\"), got {other:?}")
347            }
348        }
349    }
350
351    #[test]
352    fn sub_level_ablehnung_variants_still_count_as_ablehnung() {
353        assert!(Cluster::Ablehnung.is_ablehnung());
354        assert!(Cluster::AblehnungKopfebene.is_ablehnung());
355        assert!(Cluster::AblehnungPositionsebene.is_ablehnung());
356        assert!(Cluster::AblehnungSummenebene.is_ablehnung());
357        assert!(!Cluster::Zustimmung.is_ablehnung());
358        assert!(!Cluster::Other("Abweisung".into()).is_ablehnung());
359    }
360}