From 4ee705e57db2ef552c2f1f608ba70dd66d335565 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 18 May 2026 17:32:33 +0200 Subject: [PATCH] feat: expand ClaML Modifier/ModifierClass into terminal 5th-digit codes ICD-10-GM models the 4th/5th sub-digits of many groups (incl. all E10-E14 diabetes) via / referenced by ordered on the parent , not as nested elements. load() previously returned nothing for codes like E11.90/E11.72/I10.91, so ~1.7k corpus codes (incl. ~1.2k billable) fell back to the non-billable 3-digit root and were silently dropped under --billable-only. Collect per-Class ModifiedBy refs and all ModifierClass defs during the existing single stream, then expand: cartesian product of the referenced modifiers' ModifierClass codes in ModifiedBy order, concatenated verbatim onto the parent code (E11 + .9 + 0 = E11.90), matching the Alpha-ID corpus format exactly. Terminal leaves are billable (Para295/Para301=P); description = parent label + each ModifierClass label joined by ' - '; chapter/group/exotic/ifsg/content inherited from parent. Purely additive: explicit entries always win. Generic over every modifier group. Map size 12334 -> 17249 (+4915 synthesized). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/claml.rs | 202 +++++++++++++++++++++++++++++++++- tests/claml_modifier_tests.rs | 16 +++ 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 tests/claml_modifier_tests.rs diff --git a/src/claml.rs b/src/claml.rs index db26a68..027eab1 100644 --- a/src/claml.rs +++ b/src/claml.rs @@ -17,12 +17,40 @@ fn roman_to_padded(roman: &str) -> Option { Some(format!("{n:02}")) } +/// A `` definition: its `code` (appended verbatim to the parent +/// ICD code, e.g. `.9` for a 4th digit or `0` for a 5th digit) and its +/// preferred-rubric label (used to compose the synthesized description). +#[derive(Default, Clone)] +struct ModifierClassDef { + code: String, + label: String, +} + +/// Records, for one ``, the ordered list of +/// `` references so the modifier chain can be expanded +/// after the whole document has been parsed. +struct ModifiedByRefs { + parent_code: String, + refs: Vec, +} + /// Stream-parse the ClaML XML into a map keyed by normalised ICD code. /// /// The file is flat: `` elements close before their /// associated `` siblings begin. We therefore track /// the most-recently-seen chapter and assign it to every category that /// follows, until the next chapter appears. +/// +/// In addition to the explicit `` entries, ICD-10-GM +/// models many 4th/5th sub-digits (e.g. all of `E10`-`E14`) not as nested +/// `` elements but via `` / `` referenced by +/// one or more ordered `` on the parent ``. After +/// the streaming pass we expand those: for every category Class carrying +/// `` refs we take the cartesian product of the referenced +/// modifiers' `` codes (in `` order) and synthesize +/// a terminal billable entry per leaf (`Para295`/`Para301` = `"P"`). This is +/// purely additive: a key already produced by an explicit `` always +/// wins. pub fn load(path: &str) -> Result, AppError> { let mut reader = Reader::from_file(path) .map_err(|e| AppError::Io(format!("{path}: {e}")))?; @@ -40,6 +68,19 @@ pub fn load(path: &str) -> Result, AppError> { let mut in_preferred = false; + // ---- Modifier-expansion bookkeeping ------------------------------------- + // For each modifier code => ordered list of its ModifierClass definitions. + let mut modifier_classes: HashMap> = HashMap::new(); + // ModifiedBy refs collected per category Class (in document order). + let mut modified_by: Vec = Vec::new(); + // Refs accumulating for the currently-open category Class. + let mut cur_refs: Vec = Vec::new(); + let mut cur_code = String::new(); + // Currently-open , if any (these are NOT category Classes, + // so `cur` stays None and the normal description path is bypassed). + let mut cur_mc: Option = None; + let mut cur_mc_modifier = String::new(); + loop { match reader.read_event_into(&mut buf) { Err(e) => return Err(AppError::Parse(format!("claml: {e}"))), @@ -65,6 +106,8 @@ pub fn load(path: &str) -> Result, AppError> { } cur = None; } else if cur_kind == "category" { + cur_code = code.clone(); + cur_refs.clear(); let m = IcdMeta { code, chapter: current_chapter.clone(), @@ -75,6 +118,34 @@ pub fn load(path: &str) -> Result, AppError> { cur = None; } } + b"ModifierClass" => { + let mut mc = ModifierClassDef::default(); + cur_mc_modifier.clear(); + for a in t.attributes().flatten() { + match a.key.as_ref() { + b"code" => mc.code = String::from_utf8_lossy(&a.value).into_owned(), + b"modifier" => { + cur_mc_modifier = + String::from_utf8_lossy(&a.value).into_owned() + } + _ => {} + } + } + cur_mc = Some(mc); + cur = None; + } + b"ModifiedBy" => { + // Only meaningful while a category Class is open. + if cur.is_some() { + for a in t.attributes().flatten() { + if a.key.as_ref() == b"code" { + cur_refs.push( + String::from_utf8_lossy(&a.value).into_owned(), + ); + } + } + } + } b"SuperClass" => { if let Some(m) = cur.as_mut() { for a in t.attributes().flatten() { @@ -125,9 +196,11 @@ pub fn load(path: &str) -> Result, AppError> { Ok(Event::Text(txt)) => { if in_preferred { + let s = txt.unescape().unwrap_or_default(); if let Some(m) = cur.as_mut() { - let s = txt.unescape().unwrap_or_default(); m.description.push_str(&s); + } else if let Some(mc) = cur_mc.as_mut() { + mc.label.push_str(&s); } } } @@ -137,9 +210,26 @@ pub fn load(path: &str) -> Result, AppError> { b"Class" => { if let Some(m) = cur.take() { if !m.code.is_empty() { + if !cur_refs.is_empty() { + modified_by.push(ModifiedByRefs { + parent_code: cur_code.clone(), + refs: std::mem::take(&mut cur_refs), + }); + } map.insert(m.code.clone(), m); } } + cur_refs.clear(); + } + b"ModifierClass" => { + if let Some(mc) = cur_mc.take() { + if !cur_mc_modifier.is_empty() { + modifier_classes + .entry(std::mem::take(&mut cur_mc_modifier)) + .or_default() + .push(mc); + } + } } _ => {} }, @@ -152,5 +242,115 @@ pub fn load(path: &str) -> Result, AppError> { if map.is_empty() { return Err(AppError::Parse(format!("no ClaML categories in {path}"))); } + + expand_modifiers(&mut map, &modified_by, &modifier_classes); + Ok(map) } + +/// Expand every category `` that carries `` references into +/// its terminal sub-digit leaves. +/// +/// The chain is the Class's `` codes in document order (4th digit +/// first, then 5th, …); each ref resolves to that modifier's ordered list of +/// `` definitions. We take the cartesian product across the +/// chain, concatenating each `` `code` verbatim onto the parent +/// ICD code (e.g. `E11` + `.9` + `0` = `E11.90`, `I10.0` + `1` = `I10.01`), +/// which exactly matches the Alpha-ID corpus code format. +/// +/// Only the terminal leaf (every modifier in the chain applied) is emitted as +/// a billable code (`Para295`/`Para301` = `"P"`); intermediate prefixes such as +/// the bare 3-steller keep whatever the explicit `` said (typically +/// `"V"`). The description is the parent's preferred label followed by each +/// `` label, joined by " - " (empty labels skipped). +/// +/// Purely additive: a key already present (produced by an explicit ``) +/// is never overwritten — explicit always wins. +fn expand_modifiers( + map: &mut HashMap, + modified_by: &[ModifiedByRefs], + modifier_classes: &HashMap>, +) { + for mb in modified_by { + // Resolve the chain: each ModifiedBy ref => that modifier's ordered + // ModifierClass list. Skip the whole Class if any ref is unknown. + let mut chain: Vec<&Vec> = Vec::with_capacity(mb.refs.len()); + let mut resolvable = true; + for r in &mb.refs { + match modifier_classes.get(r) { + Some(list) if !list.is_empty() => chain.push(list), + _ => { + resolvable = false; + break; + } + } + } + if !resolvable || chain.is_empty() { + continue; + } + + // Inherit fields from the parent's explicit entry. + let parent = match map.get(&mb.parent_code) { + Some(p) => p, + None => continue, + }; + let parent_label = parent.description.clone(); + let parent_chapter = parent.chapter.clone(); + let parent_group = parent.group.clone(); + let parent_exotic = parent.exotic; + let parent_ifsg = parent.ifsg; + let parent_content = parent.content; + + // Cartesian product over the chain, in ModifiedBy (digit) order. + // `acc` holds (concatenated-code-suffix, composed-label-tail) pairs. + let mut acc: Vec<(String, String)> = vec![(String::new(), String::new())]; + for level in &chain { + let mut next: Vec<(String, String)> = Vec::with_capacity(acc.len() * level.len()); + for (code_acc, label_acc) in &acc { + for mc in level.iter() { + let code = format!("{code_acc}{}", mc.code); + let label = if mc.label.trim().is_empty() { + label_acc.clone() + } else if label_acc.is_empty() { + mc.label.trim().to_string() + } else { + format!("{label_acc} - {}", mc.label.trim()) + }; + next.push((code, label)); + } + } + acc = next; + } + + for (suffix, label_tail) in acc { + let key = format!("{}{suffix}", mb.parent_code); + // Additive only: never overwrite an explicit Class entry. + if map.contains_key(&key) { + continue; + } + let mut description = parent_label.clone(); + if !label_tail.is_empty() { + if description.is_empty() { + description = label_tail; + } else { + description.push_str(" - "); + description.push_str(&label_tail); + } + } + map.insert( + key.clone(), + IcdMeta { + code: key, + description, + chapter: parent_chapter.clone(), + group: parent_group.clone(), + para295: "P".to_string(), + para301: "P".to_string(), + exotic: parent_exotic, + ifsg: parent_ifsg, + content: parent_content, + }, + ); + } + } +} diff --git a/tests/claml_modifier_tests.rs b/tests/claml_modifier_tests.rs new file mode 100644 index 0000000..f43f4a8 --- /dev/null +++ b/tests/claml_modifier_tests.rs @@ -0,0 +1,16 @@ +use alpha_id::claml; + +#[test] +fn expands_diabetes_5th_digit_codes_as_billable() { + let map = claml::load("icd-claml/Klassifikationsdateien/icd10gm2026syst_claml_20250912.xml").unwrap(); + let e1190 = map.get("E11.90").expect("E11.90 synthesized from ModifierClass"); + assert_eq!(e1190.para295, "P", "terminal 5th-digit diabetes code must be billable"); + assert!(e1190.description.to_lowercase().contains("diabetes"), + "composed description from parent + modifier labels: {:?}", e1190.description); + let e1172 = map.get("E11.72").expect("E11.72 synthesized"); + assert_eq!(e1172.para295, "P"); + let a010 = map.get("A01.0").expect("A01.0 explicit"); + assert_eq!(a010.para295, "P"); + assert!(a010.description.contains("Typhus abdominalis")); + assert_eq!(map.get("E11").expect("E11 root").para295, "V"); +}