1use 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#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Cluster {
36 Zustimmung,
37 Ablehnung,
38 AblehnungKopfebene,
39 AblehnungPositionsebene,
40 AblehnungSummenebene,
41 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 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 pub fn cluster(&self, ebd: &str, code: &str) -> Option<&Cluster> {
119 self.clusters.get(&(ebd.to_owned(), code.to_owned()))
120 }
121}
122
123fn 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
140fn 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
159fn 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
186fn 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
205pub 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
221pub 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
246pub 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
263pub 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
295pub 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
321pub 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 #[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 assert_eq!(zustimmung(&[]), ConditionResult::True);
391 }
392
393 #[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 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 #[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 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 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}