diff --git a/docs/glossary.md b/docs/glossary.md index 68af861..fd29f15 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -20,13 +20,13 @@ The BfArM Alpha-ID dataset mapping free-text diagnosis phrases to ICD-10-GM code **Avoid:** segment, unit, Einheit, Formulierung One caller-extracted diagnostic phrase (one input line) — the unit that is matched and ranked. In source code this concept is carried by the identifier `segment` (e.g. `split_segments`, `source_segments`), which is retained as a code term only. -### Mode C -**Avoid:** Modus C -The lexical-baseline retrieval mode: BM25 over the corpus plus tag filtering, with no IONOS calls. Also the degrade target for Mode A. +### Mode Lexical +**Avoid:** Mode C, Modus C, mode c +The lexical-baseline retrieval mode: BM25 over the corpus plus tag filtering, with no IONOS calls. Also the degrade target for Mode Hybrid. -### Mode A -**Avoid:** Modus A, semantic mode -The hybrid retrieval mode: lexical and semantic (IONOS embedding) pools fused via RRF, then cross-encoder reranked. Degrades to Mode C on any IONOS failure. +### Mode Hybrid +**Avoid:** Mode A, Modus A, mode a, semantic mode +The hybrid retrieval mode: lexical and semantic (IONOS embedding) pools fused via RRF, then cross-encoder reranked. Degrades to Mode Lexical on any IONOS failure. ### billable **Avoid:** abrechenbar, Abrechenbarkeit @@ -42,11 +42,11 @@ The ClaML process of generating terminal sub-digit codes (e.g. `E11.90`) by appl ### RRF **Avoid:** — -Reciprocal Rank Fusion: the rank-based method that merges the lexical and semantic candidate lists into one fused pool in Mode A. +Reciprocal Rank Fusion: the rank-based method that merges the lexical and semantic candidate lists into one fused pool in Mode Hybrid. ### rerank **Avoid:** — -The Mode A cross-encoder step that rescores the fused candidate pool against the query phrase, using the IONOS reranker model. +The Mode Hybrid cross-encoder step that rescores the fused candidate pool against the query phrase, using the IONOS reranker model. ### corpus **Avoid:** Korpus, match corpus @@ -58,11 +58,11 @@ The per-phrase, content-hash-keyed persistent store of corpus embeddings under ` ### vector index **Avoid:** ANN index -The in-RAM matrix of corpus embeddings searched by exact cosine similarity for the semantic pool. Built only when every corpus entry has a cached embedding; otherwise Mode A degrades. +The in-RAM matrix of corpus embeddings searched by exact cosine similarity for the semantic pool. Built only when every corpus entry has a cached embedding; otherwise Mode Hybrid degrades. ### degrade **Avoid:** — -Mode A falling back to Mode C when any IONOS step fails (embed, rerank, or a missing vector index); the request is still answered and `degraded` is set in diagnostics. Distinct from the 5-char-parent lookup fallback in `meta_lookup`. +Mode Hybrid falling back to Mode Lexical when any IONOS step fails (embed, rerank, or a missing vector index); the request is still answered and `degraded` is set in diagnostics. Distinct from the 5-char-parent lookup fallback in `meta_lookup`. ### tag filter **Avoid:** — diff --git a/docs/specs/2026-05-18-alpha-id-icd-suggestion-design.md b/docs/specs/2026-05-18-alpha-id-icd-suggestion-design.md index 33ec46a..b974664 100644 --- a/docs/specs/2026-05-18-alpha-id-icd-suggestion-design.md +++ b/docs/specs/2026-05-18-alpha-id-icd-suggestion-design.md @@ -197,9 +197,9 @@ alpha-id index build [--sample N | --full] [--confirm] # Korpus+ClaML parsen, bge-m3-Embeddings, # Lexik-+Vektorindex persistieren (resumierbar) alpha-id suggest [FILE|-] # Formulierungs-Liste (eine pro Zeile) → Top-10 - --mode c|a --top 10 --billable-only --valid-only + --mode lexical|hybrid --top 10 --billable-only --valid-only --chapters I,J --exclude-exotic --json|--table -alpha-id eval --mode c|a --cases N --seed S [--gold PATH] +alpha-id eval --mode lexical|hybrid --cases N --seed S [--gold PATH] ``` - Config-TOML: IONOS-Endpoint/Modellnamen/Pool-Größen/Pfad-Defaults, per Flags überschreibbar. diff --git a/src/bin/alpha_id.rs b/src/bin/alpha_id.rs index e7d771b..961de10 100644 --- a/src/bin/alpha_id.rs +++ b/src/bin/alpha_id.rs @@ -19,7 +19,7 @@ enum Cmd { /// Suggest ICD codes for a dictation (FILE or - for stdin) Suggest { input: String, - #[arg(long, default_value = "c")] mode: String, + #[arg(long, default_value = "lexical")] mode: String, #[arg(long, default_value_t = 10)] top: usize, #[arg(long)] billable_only: bool, #[arg(long)] valid_only: bool, @@ -29,7 +29,7 @@ enum Cmd { }, /// Run the eval harness Eval { - #[arg(long, default_value = "c")] mode: String, + #[arg(long, default_value = "lexical")] mode: String, #[arg(long, default_value_t = 200)] cases: usize, #[arg(long, default_value_t = 42)] seed: u64, }, @@ -152,7 +152,7 @@ fn main() { } Cmd::Eval { mode, cases, seed } => { let modes: Vec = if mode == "both" { - vec![Mode::C, Mode::A] + vec![Mode::Lexical, Mode::Hybrid] } else { vec![mode.parse().expect("mode")] }; diff --git a/src/model.rs b/src/model.rs index 4c48fe2..79cb065 100644 --- a/src/model.rs +++ b/src/model.rs @@ -78,14 +78,14 @@ impl Filter { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Mode { C, A } +pub enum Mode { Lexical, Hybrid } impl FromStr for Mode { type Err = String; fn from_str(s: &str) -> Result { match s.to_ascii_lowercase().as_str() { - "c" => Ok(Mode::C), - "a" => Ok(Mode::A), + "lexical" | "c" => Ok(Mode::Lexical), + "hybrid" | "a" => Ok(Mode::Hybrid), other => Err(format!("unknown mode: {other}")), } } diff --git a/src/pipeline.rs b/src/pipeline.rs index 42cf1bd..3274aa3 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -38,8 +38,8 @@ impl Pipeline { m }; // Build the semantic index only if EVERY corpus entry already has a - // cached embedding; otherwise leave it `None` so Mode A degrades to C - // rather than mispairing partial vectors. + // cached embedding; otherwise leave it `None` so Mode Hybrid degrades to + // Lexical rather than mispairing partial vectors. let vector = match EmbeddingStore::open(&cfg.index_dir, &cfg.embed_model) { Ok(store) if entries.iter().all(|e| store.has(&e.text)) => { let vecs: Option>> = @@ -108,9 +108,9 @@ impl Pipeline { }) } - /// Mode C candidate collection: lexical pool per segment. This is the - /// exact original behavior; it is also the degrade fallback for Mode A. - fn collect_mode_c(&self, segments: &[String]) -> Vec { + /// Mode Lexical candidate collection: lexical pool per segment. This is the + /// exact original behavior; it is also the degrade fallback for Mode Hybrid. + fn collect_lexical(&self, segments: &[String]) -> Vec { let mut candidates: Vec = Vec::new(); for (si, seg) in segments.iter().enumerate() { let lex = self.lexical.search(seg, self.pool_size).unwrap_or_default(); @@ -131,11 +131,11 @@ impl Pipeline { candidates } - /// Mode A candidate collection: per segment, fuse a lexical pool with a + /// Mode Hybrid candidate collection: per segment, fuse a lexical pool with a /// semantic pool (IONOS embedding + in-RAM vector search) via RRF, then /// cross-encoder rerank the fused pool. ANY error (IONOS unreachable, no /// vector index, embed/rerank failure) propagates so `suggest` degrades. - fn collect_mode_a(&self, segments: &[String], calls: &mut usize) + fn collect_hybrid(&self, segments: &[String], calls: &mut usize) -> Result, AppError> { let client = IonosClient::new(&self.cfg.ionos_base_url, &self.cfg.token_path)?; let vector = self.vector.as_ref() @@ -199,14 +199,14 @@ impl Pipeline { let mut degraded = false; let candidates: Vec = match mode { - Mode::C => self.collect_mode_c(&segments), - Mode::A => match self.collect_mode_a(&segments, &mut ionos_calls) { + Mode::Lexical => self.collect_lexical(&segments), + Mode::Hybrid => match self.collect_hybrid(&segments, &mut ionos_calls) { Ok(c) => c, Err(_) => { - // Total degradation: any failure → Mode C fallback, + // Total degradation: any failure → Mode Lexical fallback, // request still answered, never panics. degraded = true; - self.collect_mode_c(&segments) + self.collect_lexical(&segments) } }, }; diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index f479763..7f04672 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -12,3 +12,21 @@ fn cli_suggest_outputs_json_with_codes() { assert!(s.contains("\"icd_code\"")); assert!(s.contains("E11")); } + +/// The renamed canonical CLI token `--mode lexical` is accepted and the +/// diagnostics line reports the new variant name `Lexical` (not the legacy `C`), +/// confirming the enum rename reaches observable CLI output. +#[test] +fn cli_suggest_accepts_canonical_lexical_token() { + let out = Command::new(env!("CARGO_BIN_EXE_alpha-id")) + .args(["suggest", "--mode", "lexical", "-"]) + .arg("--config").arg("config/default.toml") + .env("ALPHA_ID_STDIN", "Diabetes mellitus Typ 2") + .output().unwrap(); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("E11")); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("mode Lexical"), + "diagnostics should report canonical mode name; stderr: {stderr}"); +} diff --git a/tests/eval_compare_tests.rs b/tests/eval_compare_tests.rs index 02977f1..da3d3cc 100644 --- a/tests/eval_compare_tests.rs +++ b/tests/eval_compare_tests.rs @@ -1,9 +1,9 @@ use std::process::Command; #[test] -fn eval_mode_c_runs_and_reports_billable_recall() { +fn eval_lexical_runs_and_reports_billable_recall() { let out = Command::new(env!("CARGO_BIN_EXE_alpha-id")) - .args(["eval", "--mode", "c", "--cases", "30"]) + .args(["eval", "--mode", "lexical", "--cases", "30"]) .arg("--config").arg("config/default.toml") .output().unwrap(); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); @@ -15,7 +15,7 @@ fn eval_mode_c_runs_and_reports_billable_recall() { fn eval_is_deterministic_same_seed() { let run = || { let out = Command::new(env!("CARGO_BIN_EXE_alpha-id")) - .args(["eval", "--mode", "c", "--cases", "40", "--seed", "42"]) + .args(["eval", "--mode", "lexical", "--cases", "40", "--seed", "42"]) .arg("--config").arg("config/default.toml") .output().unwrap(); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); diff --git a/tests/model_tests.rs b/tests/model_tests.rs index 0c89587..0e0fac7 100644 --- a/tests/model_tests.rs +++ b/tests/model_tests.rs @@ -15,6 +15,15 @@ fn filter_default_accepts_everything() { #[test] fn mode_parses_from_str() { - assert_eq!("a".parse::().unwrap(), Mode::A); - assert_eq!("c".parse::().unwrap(), Mode::C); + assert_eq!("lexical".parse::().unwrap(), Mode::Lexical); + assert_eq!("hybrid".parse::().unwrap(), Mode::Hybrid); +} + +#[test] +fn mode_letter_aliases_still_parse_to_new_variants() { + // The legacy `c`/`a` CLI tokens remain accepted aliases (case-insensitive) + // for the renamed canonical `lexical`/`hybrid` modes. + assert_eq!("c".parse::().unwrap(), Mode::Lexical); + assert_eq!("a".parse::().unwrap(), Mode::Hybrid); + assert_eq!("A".parse::().unwrap(), Mode::Hybrid); } diff --git a/tests/pipeline_mode_a_tests.rs b/tests/pipeline_hybrid_tests.rs similarity index 76% rename from tests/pipeline_mode_a_tests.rs rename to tests/pipeline_hybrid_tests.rs index a2559da..0393449 100644 --- a/tests/pipeline_mode_a_tests.rs +++ b/tests/pipeline_hybrid_tests.rs @@ -2,11 +2,11 @@ use alpha_id::pipeline::Pipeline; use alpha_id::model::{Config, Filter, Mode}; #[test] -fn mode_a_degrades_to_c_when_ionos_unreachable() { +fn hybrid_degrades_to_lexical_when_ionos_unreachable() { let mut cfg = Config::load("config/default.toml").unwrap(); cfg.ionos_base_url = "http://127.0.0.1:1".into(); // refused let p = Pipeline::load(&cfg).unwrap(); - let res = p.suggest("Diabetes mellitus Typ 2", Mode::A, &Filter::default(), 10); + let res = p.suggest("Diabetes mellitus Typ 2", Mode::Hybrid, &Filter::default(), 10); assert!(res.diagnostics.degraded); // fell back assert!(!res.suggestions.is_empty()); // still answered assert!(res.suggestions.iter().any(|s| s.icd_code.starts_with("E11"))); diff --git a/tests/pipeline_mode_c_tests.rs b/tests/pipeline_lexical_tests.rs similarity index 81% rename from tests/pipeline_mode_c_tests.rs rename to tests/pipeline_lexical_tests.rs index c2cb601..d7f4146 100644 --- a/tests/pipeline_mode_c_tests.rs +++ b/tests/pipeline_lexical_tests.rs @@ -6,23 +6,23 @@ fn cfg() -> Config { } #[test] -fn mode_c_end_to_end_real_data() { +fn lexical_end_to_end_real_data() { let p = Pipeline::load(&cfg()).unwrap(); let dictation = "Patient mit Diabetes mellitus Typ 2\n\nArterielle Hypertonie"; - let res = p.suggest(dictation, Mode::C, &Filter::default(), 10); + let res = p.suggest(dictation, Mode::Lexical, &Filter::default(), 10); assert!(!res.suggestions.is_empty()); assert!(res.suggestions.len() <= 10); assert!(res.suggestions.iter().any(|s| s.icd_code.starts_with("E11"))); assert!(res.suggestions.iter().any(|s| s.icd_code.starts_with("I10"))); assert_eq!(res.diagnostics.segment_count, 2); - assert_eq!(res.diagnostics.mode, "C"); + assert_eq!(res.diagnostics.mode, "Lexical"); } #[test] fn billable_only_filter_excludes_three_digit_v_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("Cholera", Mode::C, &f, 10); + let res = p.suggest("Cholera", Mode::Lexical, &f, 10); assert!(res.suggestions.iter().all(|s| s.tags.billable && s.tags.valid)); } @@ -30,7 +30,7 @@ fn billable_only_filter_excludes_three_digit_v_codes() { 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); + let res = p.suggest("Diabetes mellitus Typ 2", Mode::Lexical, &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::>());