From 5305fb251e44ce0ebecc1d2bb801a3e101834a6d Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 18 May 2026 18:15:21 +0200 Subject: [PATCH] fix: drop 3-char-root meta fallback; rely on expanded ClaML map (Goal 1) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pipeline.rs | 45 ++++++++++++++++++++-------------- tests/pipeline_mode_c_tests.rs | 10 ++++++++ 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/pipeline.rs b/src/pipeline.rs index ae9c1b7..b72ec93 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -23,32 +23,39 @@ impl Pipeline { Ok(Self { entries, meta, lexical, pool_size: cfg.pool_size }) } - /// Look up ICD metadata with fallback: exact code → strip trailing '0' after - /// the decimal (e.g. "E11.90" → "E11.9") → 3-char root (e.g. "E11"). - /// The corpus uses 5- and 6-char codes; ClaML uses 3-, 5-, and 6-char codes. + /// Look up ICD metadata: exact code first, then — only for 6-char dotted codes + /// of the form `Lxx.yz` — the 5-char parent `Lxx.y`. + /// + /// The ClaML map (populated by `claml::load`) now contains synthesized + /// 5th-digit entries (e.g. `E11.90`, `I10.91`) with correct `Para295 = "P"`, + /// so the exact hit succeeds for the vast majority of corpus codes. The + /// 5-char-parent fallback covers any residual gap where a 6-char code is not + /// explicitly present but its 5-char parent is. + /// + /// No 3-char-root fallback is applied: that level caused ~1 193 codes to + /// inherit the parent's `Para295 = "V"` and be wrongly dropped under + /// `--billable-only` (Goal 1 regression). Codes absent from ClaML entirely + /// yield `None` (accepted, documented out-of-scope gap). fn meta_lookup<'a>(&'a self, code: &str) -> Option<&'a IcdMeta> { if let Some(m) = self.meta.get(code) { return Some(m); } - // Try stripping a trailing '0' from the last position after the dot - // e.g. "E11.90" → "E11.9", "I10.90" → "I10.9", "G40.00" → "G40.0" - if code.len() >= 5 { - if let Some(dot_pos) = code.find('.') { - let after_dot = &code[dot_pos + 1..]; - if after_dot.ends_with('0') && after_dot.len() > 1 { - let trimmed = &code[..code.len() - 1]; - if let Some(m) = self.meta.get(trimmed) { - return Some(m); - } + // 5-char-parent fallback only for 6-char dotted codes: Lxx.yz → Lxx.y + // Pattern: one letter, two digits, dot, two digits (total 6 chars). + if code.len() == 6 { + let bytes = code.as_bytes(); + if bytes[0].is_ascii_alphabetic() + && bytes[1].is_ascii_digit() + && bytes[2].is_ascii_digit() + && bytes[3] == b'.' + && bytes[4].is_ascii_alphanumeric() + && bytes[5].is_ascii_alphanumeric() + { + if let Some(m) = self.meta.get(&code[..5]) { + return Some(m); } } } - // Fallback to 3-char root (e.g. "E11") - if code.len() > 3 { - if let Some(m) = self.meta.get(&code[..3]) { - return Some(m); - } - } None } diff --git a/tests/pipeline_mode_c_tests.rs b/tests/pipeline_mode_c_tests.rs index 3fd3219..c2cb601 100644 --- a/tests/pipeline_mode_c_tests.rs +++ b/tests/pipeline_mode_c_tests.rs @@ -25,3 +25,13 @@ fn billable_only_filter_excludes_three_digit_v_codes() { let res = p.suggest("Cholera", Mode::C, &f, 10); assert!(res.suggestions.iter().all(|s| s.tags.billable && s.tags.valid)); } + +#[test] +fn billable_only_keeps_diabetes_5th_digit_codes() { + let p = Pipeline::load(&cfg()).unwrap(); + let f = Filter { billable_only: true, valid_only: true, chapters: None, exclude_exotic: false }; + let res = p.suggest("Diabetes mellitus Typ 2", Mode::C, &f, 10); + assert!(res.suggestions.iter().any(|s| s.icd_code.starts_with("E11") && s.tags.billable), + "billable E11.x must survive --billable-only; got {:?}", + res.suggestions.iter().map(|s| (&s.icd_code, s.tags.billable)).collect::>()); +}