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 <Modifier>/<ModifierClass> referenced by ordered <ModifiedBy>
on the parent <Class>, not as nested <Class> 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 <Class> entries always win. Generic over every modifier group.

Map size 12334 -> 17249 (+4915 synthesized).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 17:32:33 +02:00
parent 6647a66bd0
commit 4ee705e57d
2 changed files with 217 additions and 1 deletions
+201 -1
View File
@@ -17,12 +17,40 @@ fn roman_to_padded(roman: &str) -> Option<String> {
Some(format!("{n:02}")) Some(format!("{n:02}"))
} }
/// A `<ModifierClass>` 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 `<Class kind="category">`, the ordered list of
/// `<ModifiedBy code=…>` references so the modifier chain can be expanded
/// after the whole document has been parsed.
struct ModifiedByRefs {
parent_code: String,
refs: Vec<String>,
}
/// Stream-parse the ClaML XML into a map keyed by normalised ICD code. /// Stream-parse the ClaML XML into a map keyed by normalised ICD code.
/// ///
/// The file is flat: `<Class kind="chapter">` elements close before their /// The file is flat: `<Class kind="chapter">` elements close before their
/// associated `<Class kind="category">` siblings begin. We therefore track /// associated `<Class kind="category">` siblings begin. We therefore track
/// the most-recently-seen chapter and assign it to every category that /// the most-recently-seen chapter and assign it to every category that
/// follows, until the next chapter appears. /// follows, until the next chapter appears.
///
/// In addition to the explicit `<Class kind="category">` entries, ICD-10-GM
/// models many 4th/5th sub-digits (e.g. all of `E10`-`E14`) not as nested
/// `<Class>` elements but via `<Modifier>` / `<ModifierClass>` referenced by
/// one or more ordered `<ModifiedBy code=…>` on the parent `<Class>`. After
/// the streaming pass we expand those: for every category Class carrying
/// `<ModifiedBy>` refs we take the cartesian product of the referenced
/// modifiers' `<ModifierClass>` codes (in `<ModifiedBy>` order) and synthesize
/// a terminal billable entry per leaf (`Para295`/`Para301` = `"P"`). This is
/// purely additive: a key already produced by an explicit `<Class>` always
/// wins.
pub fn load(path: &str) -> Result<HashMap<String, IcdMeta>, AppError> { pub fn load(path: &str) -> Result<HashMap<String, IcdMeta>, AppError> {
let mut reader = Reader::from_file(path) let mut reader = Reader::from_file(path)
.map_err(|e| AppError::Io(format!("{path}: {e}")))?; .map_err(|e| AppError::Io(format!("{path}: {e}")))?;
@@ -40,6 +68,19 @@ pub fn load(path: &str) -> Result<HashMap<String, IcdMeta>, AppError> {
let mut in_preferred = false; let mut in_preferred = false;
// ---- Modifier-expansion bookkeeping -------------------------------------
// For each modifier code => ordered list of its ModifierClass definitions.
let mut modifier_classes: HashMap<String, Vec<ModifierClassDef>> = HashMap::new();
// ModifiedBy refs collected per category Class (in document order).
let mut modified_by: Vec<ModifiedByRefs> = Vec::new();
// Refs accumulating for the currently-open category Class.
let mut cur_refs: Vec<String> = Vec::new();
let mut cur_code = String::new();
// Currently-open <ModifierClass>, if any (these are NOT category Classes,
// so `cur` stays None and the normal description path is bypassed).
let mut cur_mc: Option<ModifierClassDef> = None;
let mut cur_mc_modifier = String::new();
loop { loop {
match reader.read_event_into(&mut buf) { match reader.read_event_into(&mut buf) {
Err(e) => return Err(AppError::Parse(format!("claml: {e}"))), Err(e) => return Err(AppError::Parse(format!("claml: {e}"))),
@@ -65,6 +106,8 @@ pub fn load(path: &str) -> Result<HashMap<String, IcdMeta>, AppError> {
} }
cur = None; cur = None;
} else if cur_kind == "category" { } else if cur_kind == "category" {
cur_code = code.clone();
cur_refs.clear();
let m = IcdMeta { let m = IcdMeta {
code, code,
chapter: current_chapter.clone(), chapter: current_chapter.clone(),
@@ -75,6 +118,34 @@ pub fn load(path: &str) -> Result<HashMap<String, IcdMeta>, AppError> {
cur = None; 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" => { b"SuperClass" => {
if let Some(m) = cur.as_mut() { if let Some(m) = cur.as_mut() {
for a in t.attributes().flatten() { for a in t.attributes().flatten() {
@@ -125,9 +196,11 @@ pub fn load(path: &str) -> Result<HashMap<String, IcdMeta>, AppError> {
Ok(Event::Text(txt)) => { Ok(Event::Text(txt)) => {
if in_preferred { if in_preferred {
let s = txt.unescape().unwrap_or_default();
if let Some(m) = cur.as_mut() { if let Some(m) = cur.as_mut() {
let s = txt.unescape().unwrap_or_default();
m.description.push_str(&s); 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<HashMap<String, IcdMeta>, AppError> {
b"Class" => { b"Class" => {
if let Some(m) = cur.take() { if let Some(m) = cur.take() {
if !m.code.is_empty() { 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); 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<HashMap<String, IcdMeta>, AppError> {
if map.is_empty() { if map.is_empty() {
return Err(AppError::Parse(format!("no ClaML categories in {path}"))); return Err(AppError::Parse(format!("no ClaML categories in {path}")));
} }
expand_modifiers(&mut map, &modified_by, &modifier_classes);
Ok(map) Ok(map)
} }
/// Expand every category `<Class>` that carries `<ModifiedBy>` references into
/// its terminal sub-digit leaves.
///
/// The chain is the Class's `<ModifiedBy>` codes in document order (4th digit
/// first, then 5th, …); each ref resolves to that modifier's ordered list of
/// `<ModifierClass>` definitions. We take the cartesian product across the
/// chain, concatenating each `<ModifierClass>` `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 `<Class>` said (typically
/// `"V"`). The description is the parent's preferred label followed by each
/// `<ModifierClass>` label, joined by " - " (empty labels skipped).
///
/// Purely additive: a key already present (produced by an explicit `<Class>`)
/// is never overwritten — explicit always wins.
fn expand_modifiers(
map: &mut HashMap<String, IcdMeta>,
modified_by: &[ModifiedByRefs],
modifier_classes: &HashMap<String, Vec<ModifierClassDef>>,
) {
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<ModifierClassDef>> = 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,
},
);
}
}
}
+16
View File
@@ -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");
}