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:
+201
-1
@@ -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 {
|
||||||
if let Some(m) = cur.as_mut() {
|
|
||||||
let s = txt.unescape().unwrap_or_default();
|
let s = txt.unescape().unwrap_or_default();
|
||||||
|
if let Some(m) = cur.as_mut() {
|
||||||
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user