Files
alpha-id/docs/superpowers/plans/2026-05-18-alpha-id-icd-suggestion.md
T
Brummel 24a0db2359 Add implementation plan for alpha-id ICD suggestion PoC
19 TDD tasks across Phase 0 (scaffold), Phase C (lexical baseline
+ data layer + eval, no IONOS), Phase A (IONOS bge-m3 hybrid +
Qwen3-VL reranker + cost-guarded resumable embedding + degrade).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:03:37 +02:00

80 KiB
Raw Blame History

alpha-id ICD Code Suggestion — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A Rust core library + thin CLI that turns a marker-segmented dictation into one prioritized list of ≤10 ICD-10-GM codes, with tag filtering, validated by a transcription-error eval harness.

Architecture: Two modes behind one interface. Mode C = lexical baseline (tantivy BM25 over the ~90k Alpha-ID phrase corpus) + ClaML tag filter. Mode A = adds IONOS bge-m3 semantic retrieval (brute-force cosine over precomputed embeddings) fused with lexical via Reciprocal Rank Fusion, then IONOS Qwen3-VL-Reranker-8B cross-encoder rerank. Cross-segment fusion produces one global top-10. Embedding is one-time, hash-cached, resumable, cost-guarded. On IONOS failure mode A degrades to C.

Tech Stack: Rust 1.94, clap (CLI), tantivy (lexical), quick-xml (ClaML streaming parse), reqwest blocking (IONOS HTTP), serde/serde_json/toml, sha2 (cache keys), rand (seeded error injection). No ANN crate — brute-force exact cosine over 90k×1024 f32 is <50 ms and removes a native-build dependency (YAGNI).

Spec: docs/superpowers/specs/2026-05-18-alpha-id-icd-suggestion-design.md. Data lives in gitignored data/ and icd-claml/ (already present).


File Structure

Cargo.toml                       # single binary+lib crate
config/default.toml              # IONOS endpoint, model names, pool sizes, paths
src/lib.rs                       # library root, module decls, re-exports
src/model.rs                     # core types: AlphaIdEntry, IcdMeta, Tags, Candidate, Suggestion, Filter, Mode, Config, Diagnostics, AppError
src/corpus.rs                    # parse Alpha-ID-SE -> Vec<AlphaIdEntry> + helpers
src/claml.rs                     # stream-parse ClaML XML -> HashMap<String, IcdMeta>
src/normalize.rs                 # text normalization + abbreviation expansion
src/segment.rs                   # split dictation on blank lines -> Vec<Segment>
src/lexical.rs                   # tantivy index build/load/query over Alpha-ID texts
src/tags.rs                      # build Tags from IcdMeta+validity; Filter predicate
src/fusion.rs                    # RRF within segment; cross-segment dedupe(max)
src/ionos.rs                     # shared IONOS HTTP client: token, retry/backoff
src/embed.rs                     # bge-m3 client + sha256-keyed resumable disk cache + cost estimate
src/vector.rs                    # load embeddings; brute-force cosine top-k
src/rerank.rs                    # Qwen3-VL-Reranker-8B pair scoring client
src/pipeline.rs                  # orchestrate [1]-[7] for Mode C and Mode A (+ degrade)
src/eval.rs                      # error injector + gold-set generator + Recall@k
src/bin/alpha_id.rs              # CLI: index | suggest | eval
tests/corpus_tests.rs            # integration: real data/ file
tests/claml_tests.rs            # integration: real icd-claml/ file
tests/pipeline_mode_c_tests.rs   # integration: end-to-end Mode C

Each file = one responsibility. The library is CLI-agnostic so doctate can later embed it unchanged. IONOS-touching tests use a local mock HTTP server (tiny_http dev-dependency), never the real endpoint.


Phase 0 — Scaffold

Task 1: Cargo project skeleton

Files:

  • Create: Cargo.toml

  • Create: src/lib.rs

  • Create: src/bin/alpha_id.rs

  • Create: config/default.toml

  • Step 1: Create Cargo.toml

[package]
name = "alpha-id"
version = "0.1.0"
edition = "2021"

[lib]
name = "alpha_id"
path = "src/lib.rs"

[[bin]]
name = "alpha-id"
path = "src/bin/alpha_id.rs"

[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
quick-xml = "0.36"
tantivy = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
sha2 = "0.10"
rand = "0.8"
thiserror = "1"

[dev-dependencies]
tiny_http = "0.12"
tempfile = "3"
  • Step 2: Create src/lib.rs
pub mod model;
pub mod corpus;
pub mod claml;
pub mod normalize;
pub mod segment;
pub mod lexical;
pub mod tags;
pub mod fusion;
pub mod ionos;
pub mod embed;
pub mod vector;
pub mod rerank;
pub mod pipeline;
pub mod eval;
  • Step 3: Create config/default.toml
ionos_base_url = "https://openai.inference.de-txl.ionos.com/v1"
token_path = "~/.ionos_token"
embed_model = "BAAI/bge-m3"
rerank_model = "Qwen/Qwen3-VL-Reranker-8B"
alpha_id_path = "data/icd10gm2026_alphaidse_edvtxt_20250926.txt"
claml_path = "icd-claml/Klassifikationsdateien/icd10gm2026syst_claml_20250912.xml"
index_dir = "index"
pool_size = 60
top_k = 10
  • Step 4: Create placeholder src/bin/alpha_id.rs
fn main() {
    println!("alpha-id CLI — see `alpha-id --help`");
}
  • Step 5: Stub the remaining modules so lib.rs compiles

Create each of src/model.rs, src/corpus.rs, src/claml.rs, src/normalize.rs, src/segment.rs, src/lexical.rs, src/tags.rs, src/fusion.rs, src/ionos.rs, src/embed.rs, src/vector.rs, src/rerank.rs, src/pipeline.rs, src/eval.rs containing exactly one line:

// implemented in a later task
  • Step 6: Verify it builds

Run: cargo build Expected: compiles with warnings about empty modules, no errors.

  • Step 7: Commit
git add Cargo.toml Cargo.lock src/ config/
git commit -m "chore: scaffold alpha-id crate skeleton"

Phase C — Lexical baseline + data layer + eval (fully working, no IONOS)

Task 2: Core types

Files:

  • Modify: src/model.rs

  • Test: tests/model_tests.rs

  • Step 1: Write the failing testtests/model_tests.rs

use alpha_id::model::{Filter, Tags, Mode};

#[test]
fn filter_billable_only_rejects_non_primary() {
    let tags = Tags { billable: false, valid: true, chapter: "I".into(), exotic: false };
    let f = Filter { billable_only: true, valid_only: false, chapters: None, exclude_exotic: false };
    assert!(!f.accepts(&tags));
}

#[test]
fn filter_default_accepts_everything() {
    let tags = Tags { billable: false, valid: false, chapter: "II".into(), exotic: true };
    assert!(Filter::default().accepts(&tags));
}

#[test]
fn mode_parses_from_str() {
    assert_eq!("a".parse::<Mode>().unwrap(), Mode::A);
    assert_eq!("c".parse::<Mode>().unwrap(), Mode::C);
}
  • Step 2: Run test to verify it fails

Run: cargo test --test model_tests Expected: FAIL — model items not found.

  • Step 3: Write minimal implementationsrc/model.rs
use serde::{Deserialize, Serialize};
use std::str::FromStr;

#[derive(Debug, Clone)]
pub struct AlphaIdEntry {
    pub alpha_id: String,
    pub valid: bool,
    pub icd_primary: String,   // Field 3, normalized below
    pub icd_star: String,      // Field 4
    pub icd_addon: String,     // Field 5 ("!") — NOT ICD exclusion
    pub icd_primary2: String,  // Field 6
    pub orpha: String,         // Field 7
    pub text: String,          // Field 8
}

#[derive(Debug, Clone, Default)]
pub struct IcdMeta {
    pub code: String,          // normalized, no + * !
    pub description: String,
    pub chapter: String,
    pub group: String,
    pub para295: String,       // P/O/Z/V
    pub para301: String,
    pub exotic: bool,
    pub ifsg: bool,
    pub content: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct Tags {
    pub billable: bool,        // para295 == "P"
    pub valid: bool,           // Alpha-ID field 1
    pub chapter: String,
    pub exotic: bool,
}

#[derive(Debug, Clone)]
pub struct Filter {
    pub billable_only: bool,
    pub valid_only: bool,
    pub chapters: Option<Vec<String>>,
    pub exclude_exotic: bool,
}

impl Default for Filter {
    fn default() -> Self {
        Filter { billable_only: false, valid_only: false, chapters: None, exclude_exotic: false }
    }
}

impl Filter {
    pub fn accepts(&self, t: &Tags) -> bool {
        if self.billable_only && !t.billable { return false; }
        if self.valid_only && !t.valid { return false; }
        if self.exclude_exotic && t.exotic { return false; }
        if let Some(ch) = &self.chapters {
            if !ch.iter().any(|c| c == &t.chapter) { return false; }
        }
        true
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode { C, A }

impl FromStr for Mode {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, String> {
        match s.to_ascii_lowercase().as_str() {
            "c" => Ok(Mode::C),
            "a" => Ok(Mode::A),
            other => Err(format!("unknown mode: {other}")),
        }
    }
}

#[derive(Debug, Clone)]
pub struct Candidate {
    pub icd_code: String,
    pub alpha_text: String,
    pub segment_idx: usize,
    pub lexical: Option<f32>,
    pub semantic: Option<f32>,
    pub rerank: Option<f32>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Suggestion {
    pub icd_code: String,
    pub description: String,
    pub score: f32,
    pub matched_phrase: String,
    pub source_segments: Vec<usize>,
    pub tags: Tags,
}

#[derive(Debug, Clone, Serialize, Default)]
pub struct Diagnostics {
    pub mode: String,
    pub degraded: bool,
    pub segment_count: usize,
    pub ionos_calls: usize,
    pub millis: u128,
}

#[derive(Debug, Serialize)]
pub struct SuggestResult {
    pub suggestions: Vec<Suggestion>,
    pub diagnostics: Diagnostics,
}

#[derive(Debug, Clone, Deserialize)]
pub struct Config {
    pub ionos_base_url: String,
    pub token_path: String,
    pub embed_model: String,
    pub rerank_model: String,
    pub alpha_id_path: String,
    pub claml_path: String,
    pub index_dir: String,
    pub pool_size: usize,
    pub top_k: usize,
}

impl Config {
    pub fn load(path: &str) -> Result<Config, AppError> {
        let s = std::fs::read_to_string(path)
            .map_err(|e| AppError::Io(format!("config {path}: {e}")))?;
        toml::from_str(&s).map_err(|e| AppError::Parse(format!("config: {e}")))
    }
}

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("io error: {0}")]
    Io(String),
    #[error("parse error: {0}")]
    Parse(String),
    #[error("ionos error: {0}")]
    Ionos(String),
    #[error("config error: {0}")]
    Config(String),
}
  • Step 4: Run test to verify it passes

Run: cargo test --test model_tests Expected: PASS (3 tests).

  • Step 5: Commit
git add src/model.rs tests/model_tests.rs
git commit -m "feat: core domain types and Filter predicate"

Task 3: Alpha-ID corpus parser

Files:

  • Modify: src/corpus.rs

  • Test: tests/corpus_tests.rs

  • Step 1: Write the failing testtests/corpus_tests.rs

use alpha_id::corpus;

#[test]
fn parses_pipe_line_into_entry() {
    let line = "1|I69812|A01.0+|I39.8*|||99745|Typhus-Endokarditis";
    let e = corpus::parse_line(line).expect("should parse");
    assert_eq!(e.alpha_id, "I69812");
    assert!(e.valid);
    assert_eq!(e.icd_primary, "A01.0"); // normalized: no '+'
    assert_eq!(e.icd_star, "I39.8");    // normalized: no '*'
    assert_eq!(e.orpha, "99745");
    assert_eq!(e.text, "Typhus-Endokarditis");
}

#[test]
fn invalid_flag_is_false() {
    let line = "0|I97837|||U81!|||Bakterien mit Multiresistenz";
    let e = corpus::parse_line(line).unwrap();
    assert!(!e.valid);
    assert_eq!(e.icd_addon, "U81"); // field 5, '!' stripped
    assert_eq!(e.icd_primary, "");
}

#[test]
fn loads_full_real_corpus() {
    let entries = corpus::load("data/icd10gm2026_alphaidse_edvtxt_20250926.txt").unwrap();
    assert_eq!(entries.len(), 90_898);
    assert!(entries.iter().any(|e| e.text.contains("Diabetes mellitus Typ 2")));
}
  • Step 2: Run test to verify it fails

Run: cargo test --test corpus_tests Expected: FAIL — corpus::parse_line not found.

  • Step 3: Write minimal implementationsrc/corpus.rs
use crate::model::{AlphaIdEntry, AppError};

/// Normalize an ICD code field by removing classification marks.
pub fn normalize_code(raw: &str) -> String {
    raw.trim().trim_end_matches(['+', '*', '!']).trim().to_string()
}

pub fn parse_line(line: &str) -> Option<AlphaIdEntry> {
    let f: Vec<&str> = line.split('|').collect();
    if f.len() < 8 { return None; }
    Some(AlphaIdEntry {
        valid: f[0].trim() == "1",
        alpha_id: f[1].trim().to_string(),
        icd_primary: normalize_code(f[2]),
        icd_star: normalize_code(f[3]),
        icd_addon: normalize_code(f[4]),
        icd_primary2: normalize_code(f[5]),
        orpha: f[6].trim().to_string(),
        text: f[7].trim().to_string(),
    })
}

pub fn load(path: &str) -> Result<Vec<AlphaIdEntry>, AppError> {
    let raw = std::fs::read_to_string(path)
        .map_err(|e| AppError::Io(format!("{path}: {e}")))?;
    let entries: Vec<AlphaIdEntry> = raw
        .lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(parse_line)
        .collect();
    if entries.is_empty() {
        return Err(AppError::Parse(format!("no entries parsed from {path}")));
    }
    Ok(entries)
}

/// The primary ICD code a matched entry contributes (falls back to star/addon).
pub fn primary_code(e: &AlphaIdEntry) -> Option<&str> {
    for c in [&e.icd_primary, &e.icd_primary2, &e.icd_star, &e.icd_addon] {
        if !c.is_empty() { return Some(c); }
    }
    None
}
  • Step 4: Run test to verify it passes

Run: cargo test --test corpus_tests Expected: PASS (3 tests). The full-corpus test reads the real gitignored file.

  • Step 5: Commit
git add src/corpus.rs tests/corpus_tests.rs
git commit -m "feat: Alpha-ID-SE corpus parser with code normalization"

Task 4: ClaML metadata parser

Files:

  • Modify: src/claml.rs

  • Test: tests/claml_tests.rs

  • Step 1: Write the failing testtests/claml_tests.rs

use alpha_id::claml;

#[test]
fn parses_real_claml_meta_and_titles() {
    let map = claml::load("icd-claml/Klassifikationsdateien/icd10gm2026syst_claml_20250912.xml").unwrap();
    let a010 = map.get("A01.0").expect("A01.0 present");
    assert_eq!(a010.para295, "P");          // billable primary
    assert_eq!(a010.chapter, "01");
    assert!(a010.description.contains("Typhus abdominalis"));
    let a01 = map.get("A01").expect("A01 present");
    assert_eq!(a01.para295, "V");           // 3-digit non-codable
    assert!(map.len() > 10_000);
}
  • Step 2: Run test to verify it fails

Run: cargo test --test claml_tests Expected: FAIL — claml::load not found.

  • Step 3: Write minimal implementationsrc/claml.rs
use crate::model::{AppError, IcdMeta};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::collections::HashMap;

/// Stream-parse the ClaML XML. We only need <Class kind="category">:
/// its code attr, <Meta name/value> children, owning chapter, and the
/// preferred <Rubric><Label> text.
pub fn load(path: &str) -> Result<HashMap<String, IcdMeta>, AppError> {
    let mut reader = Reader::from_file(path)
        .map_err(|e| AppError::Io(format!("{path}: {e}")))?;
    reader.config_mut().trim_text(true);

    let mut map: HashMap<String, IcdMeta> = HashMap::new();
    let mut buf = Vec::new();

    let mut cur: Option<IcdMeta> = None;
    let mut cur_kind = String::new();
    let mut in_preferred = false;
    let mut rubric_kind = String::new();

    loop {
        match reader.read_event_into(&mut buf) {
            Err(e) => return Err(AppError::Parse(format!("claml: {e}"))),
            Ok(Event::Eof) => break,
            Ok(Event::Start(t)) | Ok(Event::Empty(t)) => {
                let name = t.name();
                match name.as_ref() {
                    b"Class" => {
                        let mut code = String::new();
                        cur_kind.clear();
                        for a in t.attributes().flatten() {
                            match a.key.as_ref() {
                                b"code" => code = String::from_utf8_lossy(&a.value).into_owned(),
                                b"kind" => cur_kind = String::from_utf8_lossy(&a.value).into_owned(),
                                _ => {}
                            }
                        }
                        if cur_kind == "category" {
                            let mut m = IcdMeta::default();
                            m.code = code;
                            cur = Some(m);
                        } else {
                            cur = None;
                        }
                    }
                    b"SuperClass" => {
                        if let Some(m) = cur.as_mut() {
                            for a in t.attributes().flatten() {
                                if a.key.as_ref() == b"code" {
                                    let sup = String::from_utf8_lossy(&a.value);
                                    if m.group.is_empty() { m.group = sup.into_owned(); }
                                }
                            }
                        }
                    }
                    b"Meta" => {
                        if let Some(m) = cur.as_mut() {
                            let mut k = String::new();
                            let mut v = String::new();
                            for a in t.attributes().flatten() {
                                match a.key.as_ref() {
                                    b"name" => k = String::from_utf8_lossy(&a.value).into_owned(),
                                    b"value" => v = String::from_utf8_lossy(&a.value).into_owned(),
                                    _ => {}
                                }
                            }
                            match k.as_str() {
                                "Para295" => m.para295 = v,
                                "Para301" => m.para301 = v,
                                "Exotic" => m.exotic = v == "J",
                                "Infectious" => m.ifsg = v == "J",
                                "Content" => m.content = v == "J",
                                "chapter" => m.chapter = v,
                                _ => {}
                            }
                        }
                    }
                    b"Rubric" => {
                        rubric_kind.clear();
                        for a in t.attributes().flatten() {
                            if a.key.as_ref() == b"kind" {
                                rubric_kind = String::from_utf8_lossy(&a.value).into_owned();
                            }
                        }
                        in_preferred = rubric_kind == "preferred";
                    }
                    _ => {}
                }
            }
            Ok(Event::Text(txt)) => {
                if in_preferred {
                    if let Some(m) = cur.as_mut() {
                        if m.description.is_empty() {
                            m.description = txt.unescape().unwrap_or_default().into_owned();
                        }
                    }
                }
            }
            Ok(Event::End(t)) => {
                match t.name().as_ref() {
                    b"Rubric" => in_preferred = false,
                    b"Class" => {
                        if let Some(m) = cur.take() {
                            if !m.code.is_empty() { map.insert(m.code.clone(), m); }
                        }
                    }
                    _ => {}
                }
            }
            _ => {}
        }
        buf.clear();
    }
    if map.is_empty() {
        return Err(AppError::Parse(format!("no ClaML categories in {path}")));
    }
    Ok(map)
}

Note: chapter may arrive via a chapter Meta or be derivable from the owning <Class kind="chapter">. If the real file lacks a chapter Meta on categories, derive it: keep a stack of the current chapter code while walking; assign m.chapter from the nearest enclosing kind="chapter" Class code. Implement the stack only if Step 4 shows empty chapters.

  • Step 2b (conditional): chapter-stack fallback

If cargo test --test claml_tests shows a010.chapter empty, add a chapter_stack: Vec<String> pushed on <Class kind="chapter"> start (store its code), popped on its End, and set m.chapter = chapter_stack.last().cloned().unwrap_or_default() when creating a category. Re-run.

  • Step 3: Run test to verify it passes

Run: cargo test --test claml_tests Expected: PASS. If chapter assertion fails, apply Step 2b then re-run.

  • Step 4: Commit
git add src/claml.rs tests/claml_tests.rs
git commit -m "feat: streaming ClaML parser for ICD metadata and titles"

Task 5: Text normalization

Files:

  • Modify: src/normalize.rs

  • Test: tests/normalize_tests.rs

  • Step 1: Write the failing testtests/normalize_tests.rs

use alpha_id::normalize::normalize;

#[test]
fn lowercases_and_collapses_whitespace() {
    assert_eq!(normalize("  Diabetes   Mellitus\tTyp 2 "), "diabetes mellitus typ 2");
}

#[test]
fn expands_common_medical_abbreviations() {
    assert_eq!(normalize("V.a. art. Hypertonie"), "verdacht auf arterielle hypertonie");
    assert_eq!(normalize("Z.n. OP"), "zustand nach operation");
}

#[test]
fn keeps_german_umlauts() {
    assert_eq!(normalize("Ödem"), "ödem");
}
  • Step 2: Run test to verify it fails

Run: cargo test --test normalize_tests Expected: FAIL — normalize not found.

  • Step 3: Write minimal implementationsrc/normalize.rs
/// (pattern, replacement) — applied as whole-token, case-insensitive.
const ABBREV: &[(&str, &str)] = &[
    ("v.a.", "verdacht auf"),
    ("z.n.", "zustand nach"),
    ("art.", "arterielle"),
    ("op", "operation"),
    ("dd", "differentialdiagnose"),
    ("re.", "rechts"),
    ("li.", "links"),
];

pub fn normalize(input: &str) -> String {
    let lower = input.to_lowercase();
    let mut out: Vec<String> = Vec::new();
    for tok in lower.split_whitespace() {
        let replaced = ABBREV.iter()
            .find(|(p, _)| *p == tok)
            .map(|(_, r)| r.to_string())
            .unwrap_or_else(|| tok.to_string());
        out.push(replaced);
    }
    out.join(" ")
}
  • Step 4: Run test to verify it passes

Run: cargo test --test normalize_tests Expected: PASS (3 tests).

  • Step 5: Commit
git add src/normalize.rs tests/normalize_tests.rs
git commit -m "feat: text normalization with medical abbreviation expansion"

Task 6: Dictation segmentation

Files:

  • Modify: src/segment.rs

  • Test: tests/segment_tests.rs

  • Step 1: Write the failing testtests/segment_tests.rs

use alpha_id::segment::split_segments;

#[test]
fn splits_on_blank_lines() {
    let d = "Befund eins\nmit Zeile zwei\n\nBefund zwei\n\n\nBefund drei\n";
    let segs = split_segments(d);
    assert_eq!(segs, vec![
        "Befund eins\nmit Zeile zwei".to_string(),
        "Befund zwei".to_string(),
        "Befund drei".to_string(),
    ]);
}

#[test]
fn single_segment_when_no_blank_line() {
    assert_eq!(split_segments("nur ein Befund"), vec!["nur ein Befund".to_string()]);
}

#[test]
fn empty_input_yields_no_segments() {
    assert!(split_segments("   \n\n").is_empty());
}
  • Step 2: Run test to verify it fails

Run: cargo test --test segment_tests Expected: FAIL — split_segments not found.

  • Step 3: Write minimal implementationsrc/segment.rs
/// Split a dictation into Befund segments on Markdown paragraph
/// breaks (one or more blank lines). Trims each segment; drops empties.
pub fn split_segments(dictation: &str) -> Vec<String> {
    let mut segments = Vec::new();
    let mut current: Vec<&str> = Vec::new();
    for line in dictation.lines() {
        if line.trim().is_empty() {
            if !current.is_empty() {
                segments.push(current.join("\n").trim().to_string());
                current.clear();
            }
        } else {
            current.push(line);
        }
    }
    if !current.is_empty() {
        segments.push(current.join("\n").trim().to_string());
    }
    segments.into_iter().filter(|s| !s.is_empty()).collect()
}
  • Step 4: Run test to verify it passes

Run: cargo test --test segment_tests Expected: PASS (3 tests).

  • Step 5: Commit
git add src/segment.rs tests/segment_tests.rs
git commit -m "feat: dictation segmentation on blank lines"

Task 7: Tags builder

Files:

  • Modify: src/tags.rs

  • Test: tests/tags_tests.rs

  • Step 1: Write the failing testtests/tags_tests.rs

use alpha_id::model::IcdMeta;
use alpha_id::tags::tags_for;

#[test]
fn billable_when_para295_is_p_and_valid() {
    let mut m = IcdMeta::default();
    m.para295 = "P".into();
    m.chapter = "01".into();
    let t = tags_for(&m, true);
    assert!(t.billable);
    assert!(t.valid);
    assert_eq!(t.chapter, "01");
}

#[test]
fn not_billable_when_para295_is_v() {
    let mut m = IcdMeta::default();
    m.para295 = "V".into();
    assert!(!tags_for(&m, true).billable);
}
  • Step 2: Run test to verify it fails

Run: cargo test --test tags_tests Expected: FAIL — tags_for not found.

  • Step 3: Write minimal implementationsrc/tags.rs
use crate::model::{IcdMeta, Tags};

/// Build display/filter Tags from ICD metadata and the Alpha-ID
/// validity flag of the matched entry.
pub fn tags_for(m: &IcdMeta, alpha_valid: bool) -> Tags {
    Tags {
        billable: m.para295 == "P",
        valid: alpha_valid,
        chapter: m.chapter.clone(),
        exotic: m.exotic,
    }
}
  • Step 4: Run test to verify it passes

Run: cargo test --test tags_tests Expected: PASS (2 tests).

  • Step 5: Commit
git add src/tags.rs tests/tags_tests.rs
git commit -m "feat: tag derivation from ClaML metadata + validity"

Task 8: Lexical index (tantivy)

Files:

  • Modify: src/lexical.rs

  • Test: tests/lexical_tests.rs

  • Step 1: Write the failing testtests/lexical_tests.rs

use alpha_id::lexical::LexicalIndex;
use alpha_id::model::AlphaIdEntry;

fn entry(id: &str, code: &str, text: &str) -> AlphaIdEntry {
    AlphaIdEntry {
        alpha_id: id.into(), valid: true,
        icd_primary: code.into(), icd_star: "".into(), icd_addon: "".into(),
        icd_primary2: "".into(), orpha: "".into(), text: text.into(),
    }
}

#[test]
fn finds_entry_by_fuzzy_query() {
    let entries = vec![
        entry("I1", "E11.9", "Diabetes mellitus Typ 2"),
        entry("I2", "I10.90", "Arterielle essentielle Hypertonie"),
    ];
    let idx = LexicalIndex::build_in_ram(&entries).unwrap();
    let hits = idx.search("diabetes typ 2", 5).unwrap();
    assert!(!hits.is_empty());
    assert_eq!(hits[0].0, 0); // entry index 0
    assert!(hits[0].1 > 0.0); // bm25 score
}
  • Step 2: Run test to verify it fails

Run: cargo test --test lexical_tests Expected: FAIL — LexicalIndex not found.

  • Step 3: Write minimal implementationsrc/lexical.rs
use crate::model::{AlphaIdEntry, AppError};
use crate::normalize::normalize;
use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
use tantivy::schema::{Schema, STORED, TEXT, Value};
use tantivy::{doc, Index, TantivyDocument};

pub struct LexicalIndex {
    index: Index,
    text_field: tantivy::schema::Field,
    idx_field: tantivy::schema::Field,
}

impl LexicalIndex {
    fn schema() -> (Schema, tantivy::schema::Field, tantivy::schema::Field) {
        let mut b = Schema::builder();
        let text_field = b.add_text_field("text", TEXT);
        let idx_field = b.add_u64_field("idx", STORED);
        (b.build(), text_field, idx_field)
    }

    pub fn build_in_ram(entries: &[AlphaIdEntry]) -> Result<Self, AppError> {
        let (schema, text_field, idx_field) = Self::schema();
        let index = Index::create_in_ram(schema);
        let mut w = index.writer(50_000_000)
            .map_err(|e| AppError::Io(format!("tantivy writer: {e}")))?;
        for (i, e) in entries.iter().enumerate() {
            w.add_document(doc!(text_field => normalize(&e.text), idx_field => i as u64))
                .map_err(|e| AppError::Io(format!("tantivy add: {e}")))?;
        }
        w.commit().map_err(|e| AppError::Io(format!("tantivy commit: {e}")))?;
        Ok(Self { index, text_field, idx_field })
    }

    /// Returns (entry_index, bm25_score) descending.
    pub fn search(&self, query: &str, k: usize) -> Result<Vec<(usize, f32)>, AppError> {
        let reader = self.index.reader()
            .map_err(|e| AppError::Io(format!("tantivy reader: {e}")))?;
        let searcher = reader.searcher();
        let mut qp = QueryParser::for_index(&self.index, vec![self.text_field]);
        qp.set_conjunction_by_default(); // all terms; falls back via OR below
        let norm = normalize(query);
        let parsed = QueryParser::for_index(&self.index, vec![self.text_field])
            .parse_query(&format!("{}", norm.replace(['(', ')', '"', ':'], " ")))
            .or_else(|_| qp.parse_query(&norm))
            .map_err(|e| AppError::Parse(format!("query: {e}")))?;
        let top = searcher.search(&parsed, &TopDocs::with_limit(k.max(1)))
            .map_err(|e| AppError::Io(format!("tantivy search: {e}")))?;
        let mut out = Vec::new();
        for (score, addr) in top {
            let d: TantivyDocument = searcher.doc(addr)
                .map_err(|e| AppError::Io(format!("tantivy doc: {e}")))?;
            if let Some(v) = d.get_first(self.idx_field).and_then(|v| v.as_u64()) {
                out.push((v as usize, score));
            }
        }
        Ok(out)
    }
}
  • Step 4: Run test to verify it passes

Run: cargo test --test lexical_tests Expected: PASS. If the query parser rejects the input, the .or_else fallback handles it; adjust the sanitizing replace set if a panic shows another special char.

  • Step 5: Commit
git add src/lexical.rs tests/lexical_tests.rs
git commit -m "feat: tantivy lexical index over Alpha-ID texts"

Task 9: Fusion (RRF + cross-segment)

Files:

  • Modify: src/fusion.rs

  • Test: tests/fusion_tests.rs

  • Step 1: Write the failing testtests/fusion_tests.rs

use alpha_id::fusion::{rrf_merge, cross_segment_dedupe};
use alpha_id::model::Candidate;

fn cand(code: &str, seg: usize, rerank: Option<f32>) -> Candidate {
    Candidate { icd_code: code.into(), alpha_text: code.into(),
        segment_idx: seg, lexical: None, semantic: None, rerank }
}

#[test]
fn rrf_merges_two_rankings_by_reciprocal_rank() {
    let lex = vec![0usize, 1, 2];   // entry indices ranked
    let sem = vec![2usize, 0, 3];
    let merged = rrf_merge(&lex, &sem, 60);
    // entry 0 appears rank0 in lex and rank1 in sem -> highest
    assert_eq!(merged[0], 0);
    assert!(merged.contains(&3));
}

#[test]
fn cross_segment_keeps_max_score_and_all_sources() {
    let cands = vec![
        cand("E11.9", 0, Some(0.4)),
        cand("E11.9", 2, Some(0.9)),
        cand("I10.90", 1, Some(0.5)),
    ];
    let mut merged = cross_segment_dedupe(cands);
    merged.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
    assert_eq!(merged[0].0.icd_code, "E11.9");
    assert_eq!(merged[0].1, 0.9);                       // max
    assert_eq!(merged[0].0.source_segments, vec![0, 2]); // all sources
}
  • Step 2: Run test to verify it fails

Run: cargo test --test fusion_tests Expected: FAIL — fusion items not found.

  • Step 3: Write minimal implementationsrc/fusion.rs
use crate::model::Candidate;
use std::collections::HashMap;

/// Reciprocal Rank Fusion of two ranked lists of entry indices.
/// Returns deduped entry indices ordered by fused score desc.
pub fn rrf_merge(a: &[usize], b: &[usize], k: usize) -> Vec<usize> {
    let mut score: HashMap<usize, f64> = HashMap::new();
    for (rank, &id) in a.iter().enumerate() {
        *score.entry(id).or_default() += 1.0 / (k as f64 + rank as f64 + 1.0);
    }
    for (rank, &id) in b.iter().enumerate() {
        *score.entry(id).or_default() += 1.0 / (k as f64 + rank as f64 + 1.0);
    }
    let mut v: Vec<(usize, f64)> = score.into_iter().collect();
    v.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap());
    v.into_iter().map(|(id, _)| id).collect()
}

/// Group candidates by normalized ICD code across all segments.
/// Score = max over occurrences; retain all source segments and
/// the matched phrase of the best-scoring occurrence.
pub fn cross_segment_dedupe(cands: Vec<Candidate>) -> Vec<(DedupCandidate, f32)> {
    let mut by_code: HashMap<String, DedupCandidate> = HashMap::new();
    for c in cands {
        let s = c.rerank.or(c.semantic).or(c.lexical).unwrap_or(0.0);
        let e = by_code.entry(c.icd_code.clone()).or_insert_with(|| DedupCandidate {
            icd_code: c.icd_code.clone(),
            best_phrase: c.alpha_text.clone(),
            best_score: f32::MIN,
            source_segments: Vec::new(),
        });
        if !e.source_segments.contains(&c.segment_idx) {
            e.source_segments.push(c.segment_idx);
        }
        if s > e.best_score {
            e.best_score = s;
            e.best_phrase = c.alpha_text.clone();
        }
    }
    let mut out: Vec<(DedupCandidate, f32)> = by_code
        .into_values()
        .map(|mut d| { d.source_segments.sort(); let s = d.best_score; (d, s) })
        .collect();
    out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
    out
}

#[derive(Debug, Clone)]
pub struct DedupCandidate {
    pub icd_code: String,
    pub best_phrase: String,
    pub best_score: f32,
    pub source_segments: Vec<usize>,
}
  • Step 4: Run test to verify it passes

Run: cargo test --test fusion_tests Expected: PASS (2 tests).

  • Step 5: Commit
git add src/fusion.rs tests/fusion_tests.rs
git commit -m "feat: RRF and cross-segment max-score dedupe"

Task 10: Pipeline — Mode C

Files:

  • Modify: src/pipeline.rs

  • Test: tests/pipeline_mode_c_tests.rs

  • Step 1: Write the failing testtests/pipeline_mode_c_tests.rs

use alpha_id::pipeline::Pipeline;
use alpha_id::model::{Config, Filter, Mode};

fn cfg() -> Config {
    Config::load("config/default.toml").unwrap()
}

#[test]
fn mode_c_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);
    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");
}

#[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);
    assert!(res.suggestions.iter().all(|s| s.tags.billable && s.tags.valid));
}
  • Step 2: Run test to verify it fails

Run: cargo test --test pipeline_mode_c_tests Expected: FAIL — Pipeline not found.

  • Step 3: Write minimal implementationsrc/pipeline.rs
use crate::claml;
use crate::corpus::{self};
use crate::fusion::cross_segment_dedupe;
use crate::lexical::LexicalIndex;
use crate::model::*;
use crate::segment::split_segments;
use crate::tags::tags_for;
use std::collections::HashMap;
use std::time::Instant;

pub struct Pipeline {
    pub entries: Vec<AlphaIdEntry>,
    pub meta: HashMap<String, IcdMeta>,
    pub lexical: LexicalIndex,
    pub pool_size: usize,
}

impl Pipeline {
    pub fn load(cfg: &Config) -> Result<Self, AppError> {
        let entries = corpus::load(&cfg.alpha_id_path)?;
        let meta = claml::load(&cfg.claml_path)?;
        let lexical = LexicalIndex::build_in_ram(&entries)?;
        Ok(Self { entries, meta, lexical, pool_size: cfg.pool_size })
    }

    fn to_suggestion(&self, code: &str, phrase: &str, score: f32,
                     segs: Vec<usize>, alpha_valid: bool) -> Option<Suggestion> {
        let m = self.meta.get(code)?;
        Some(Suggestion {
            icd_code: code.to_string(),
            description: m.description.clone(),
            score,
            matched_phrase: phrase.to_string(),
            source_segments: segs,
            tags: tags_for(m, alpha_valid),
        })
    }

    pub fn suggest(&self, dictation: &str, mode: Mode, filter: &Filter, top_k: usize)
        -> SuggestResult {
        let t0 = Instant::now();
        let segments = split_segments(dictation);
        let mut candidates: Vec<Candidate> = Vec::new();

        for (si, seg) in segments.iter().enumerate() {
            let lex = self.lexical.search(seg, self.pool_size).unwrap_or_default();
            for (entry_idx, score) in lex {
                let e = &self.entries[entry_idx];
                if let Some(code) = corpus::primary_code(e) {
                    candidates.push(Candidate {
                        icd_code: code.to_string(),
                        alpha_text: e.text.clone(),
                        segment_idx: si,
                        lexical: Some(score),
                        semantic: None,
                        rerank: None,
                    });
                }
            }
        }

        // validity lookup per code: any matched entry that is valid
        let valid_by_code: HashMap<String, bool> = {
            let mut m = HashMap::new();
            for c in &candidates {
                m.entry(c.icd_code.clone()).or_insert(false);
            }
            for e in &self.entries {
                if let Some(code) = corpus::primary_code(e) {
                    if let Some(v) = m.get_mut(code) { *v = *v || e.valid; }
                }
            }
            m
        };

        let merged = cross_segment_dedupe(candidates);
        let mut suggestions = Vec::new();
        for (d, score) in merged {
            let alpha_valid = *valid_by_code.get(&d.icd_code).unwrap_or(&false);
            if let Some(s) = self.to_suggestion(&d.icd_code, &d.best_phrase, score,
                                                d.source_segments.clone(), alpha_valid) {
                if filter.accepts(&s.tags) {
                    suggestions.push(s);
                }
            }
            if suggestions.len() >= top_k { break; }
        }

        SuggestResult {
            suggestions,
            diagnostics: Diagnostics {
                mode: format!("{:?}", mode),
                degraded: false,
                segment_count: segments.len(),
                ionos_calls: 0,
                millis: t0.elapsed().as_millis(),
            },
        }
    }
}
  • Step 4: Run test to verify it passes

Run: cargo test --test pipeline_mode_c_tests Expected: PASS. If mode string is "C" vs "C" mismatch, note format!("{:?}", Mode::C) yields "C" — matches the assertion.

  • Step 5: Commit
git add src/pipeline.rs tests/pipeline_mode_c_tests.rs
git commit -m "feat: Mode C pipeline end-to-end (lexical + tags + fusion)"

Task 11: Eval harness — error injection + Recall@k

Files:

  • Modify: src/eval.rs

  • Test: tests/eval_tests.rs

  • Step 1: Write the failing testtests/eval_tests.rs

use alpha_id::eval::{inject_errors, recall_at_k};

#[test]
fn injection_is_deterministic_for_seed() {
    let a = inject_errors("Diabetes mellitus Typ 2", 0.3, 42);
    let b = inject_errors("Diabetes mellitus Typ 2", 0.3, 42);
    assert_eq!(a, b);
    assert_ne!(a, "Diabetes mellitus Typ 2"); // some corruption at rate 0.3
}

#[test]
fn recall_counts_hit_within_k() {
    let predicted = vec!["E11.9".to_string(), "I10.90".to_string(), "J45.0".to_string()];
    assert_eq!(recall_at_k(&predicted, "I10.90", 5), 1.0);
    assert_eq!(recall_at_k(&predicted, "I10.90", 1), 0.0);
    assert_eq!(recall_at_k(&predicted, "Z99.9", 5), 0.0);
}
  • Step 2: Run test to verify it fails

Run: cargo test --test eval_tests Expected: FAIL — eval items not found.

  • Step 3: Write minimal implementationsrc/eval.rs
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};

/// Inject realistic transcription errors deterministically (seeded):
/// char drop, adjacent swap, umlaut flattening, digit transposition.
pub fn inject_errors(text: &str, rate: f64, seed: u64) -> String {
    let mut rng = StdRng::seed_from_u64(seed);
    let mut chars: Vec<char> = text.chars().collect();
    let n = chars.len();
    let mut i = 0;
    while i < chars.len() {
        if rng.gen::<f64>() < rate {
            match rng.gen_range(0..4) {
                0 => { chars.remove(i); continue; }                 // drop
                1 => { if i + 1 < chars.len() { chars.swap(i, i + 1); } } // swap
                2 => {                                               // flatten umlaut
                    chars[i] = match chars[i] {
                        'ä' => 'a', 'ö' => 'o', 'ü' => 'u', 'ß' => 's',
                        other => other,
                    };
                }
                _ => {                                               // digit transpose
                    if chars[i].is_ascii_digit() && i + 1 < chars.len()
                        && chars[i + 1].is_ascii_digit() {
                        chars.swap(i, i + 1);
                    }
                }
            }
        }
        i += 1;
    }
    let _ = n;
    chars.into_iter().collect()
}

/// 1.0 if `expected` appears in the first k predictions, else 0.0.
pub fn recall_at_k(predicted: &[String], expected: &str, k: usize) -> f64 {
    if predicted.iter().take(k).any(|p| p == expected) { 1.0 } else { 0.0 }
}
  • Step 4: Run test to verify it passes

Run: cargo test --test eval_tests Expected: PASS (2 tests). If assert_ne!(a, original) is flaky at low corruption, the test uses rate 0.3 over a long string — corruption is effectively certain; if it ever fails, raise rate to 0.5 in the test.

  • Step 5: Commit
git add src/eval.rs tests/eval_tests.rs
git commit -m "feat: seeded transcription-error injection + Recall@k"

Task 12: CLI — suggest, eval, index(build lexical)

Files:

  • Modify: src/bin/alpha_id.rs

  • Test: tests/cli_tests.rs

  • Step 1: Write the failing testtests/cli_tests.rs

use std::process::Command;

#[test]
fn cli_suggest_outputs_json_with_codes() {
    let out = Command::new(env!("CARGO_BIN_EXE_alpha-id"))
        .args(["suggest", "--mode", "c", "--json", "-"])
        .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 s = String::from_utf8_lossy(&out.stdout);
    assert!(s.contains("\"icd_code\""));
    assert!(s.contains("E11"));
}

(For test determinism the binary reads stdin; here we feed via the ALPHA_ID_STDIN env shim that the binary checks before real stdin.)

  • Step 2: Run test to verify it fails

Run: cargo test --test cli_tests Expected: FAIL — subcommand not implemented.

  • Step 3: Write minimal implementationsrc/bin/alpha_id.rs
use alpha_id::eval::{inject_errors, recall_at_k};
use alpha_id::model::{Config, Filter, Mode};
use alpha_id::pipeline::Pipeline;
use clap::{Parser, Subcommand};
use std::io::Read;

#[derive(Parser)]
#[command(name = "alpha-id")]
struct Cli {
    #[arg(long, default_value = "config/default.toml", global = true)]
    config: String,
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Build indexes (lexical now; embeddings added in Phase A)
    Index { #[arg(long)] sample: Option<usize>, #[arg(long)] full: bool, #[arg(long)] confirm: bool },
    /// Suggest ICD codes for a dictation (FILE or - for stdin)
    Suggest {
        input: String,
        #[arg(long, default_value = "c")] mode: String,
        #[arg(long, default_value_t = 10)] top: usize,
        #[arg(long)] billable_only: bool,
        #[arg(long)] valid_only: bool,
        #[arg(long)] exclude_exotic: bool,
        #[arg(long)] chapters: Option<String>,
        #[arg(long)] json: bool,
    },
    /// Run the eval harness
    Eval {
        #[arg(long, default_value = "c")] mode: String,
        #[arg(long, default_value_t = 200)] cases: usize,
        #[arg(long, default_value_t = 42)] seed: u64,
    },
}

fn read_input(arg: &str) -> String {
    if let Ok(v) = std::env::var("ALPHA_ID_STDIN") { return v; }
    if arg == "-" {
        let mut s = String::new();
        std::io::stdin().read_to_string(&mut s).ok();
        s
    } else {
        std::fs::read_to_string(arg).unwrap_or_default()
    }
}

fn main() {
    let cli = Cli::parse();
    let cfg = Config::load(&cli.config).expect("config");
    match cli.cmd {
        Cmd::Index { sample, full, confirm } => {
            // Phase A extends this. For now: build lexical to validate data load.
            let p = Pipeline::load(&cfg).expect("load");
            eprintln!("Loaded {} entries, {} ICD metas. (sample={:?} full={} confirm={})",
                p.entries.len(), p.meta.len(), sample, full, confirm);
        }
        Cmd::Suggest { input, mode, top, billable_only, valid_only, exclude_exotic, chapters, json } => {
            let p = Pipeline::load(&cfg).expect("load");
            let m: Mode = mode.parse().expect("mode");
            let filter = Filter {
                billable_only, valid_only, exclude_exotic,
                chapters: chapters.map(|c| c.split(',').map(|s| s.trim().to_string()).collect()),
            };
            let text = read_input(&input);
            let res = p.suggest(&text, m, &filter, top);
            if json {
                println!("{}", serde_json::to_string_pretty(&res).unwrap());
            } else {
                for s in &res.suggestions {
                    println!("{:<8} {:>5.3}  {}", s.icd_code, s.score, s.description);
                }
                eprintln!("[{} segments, mode {}, {} ms, degraded={}]",
                    res.diagnostics.segment_count, res.diagnostics.mode,
                    res.diagnostics.millis, res.diagnostics.degraded);
            }
        }
        Cmd::Eval { mode, cases, seed } => {
            let p = Pipeline::load(&cfg).expect("load");
            let m: Mode = mode.parse().expect("mode");
            let mut sum = 0.0;
            let mut billable_sum = 0.0;
            let mut billable_n = 0.0;
            let billable: Vec<_> = p.entries.iter().enumerate()
                .filter(|(_, e)| e.valid)
                .filter(|(_, e)| {
                    alpha_id::corpus::primary_code(e)
                        .and_then(|c| p.meta.get(c))
                        .map(|mt| mt.para295 == "P").unwrap_or(false)
                })
                .take(cases).collect();
            for (k, (_, e)) in billable.iter().enumerate() {
                let code = alpha_id::corpus::primary_code(e).unwrap().to_string();
                let noisy = inject_errors(&e.text, 0.15, seed.wrapping_add(k as u64));
                let res = p.suggest(&noisy, m, &Filter::default(), 10);
                let preds: Vec<String> = res.suggestions.iter().map(|s| s.icd_code.clone()).collect();
                sum += recall_at_k(&preds, &code, 10);
                billable_sum += recall_at_k(&preds, &code, 10);
                billable_n += 1.0;
            }
            let n = billable.len().max(1) as f64;
            println!("mode={} cases={} Recall@10={:.3} BillableRecall@10={:.3}",
                mode, billable.len(), sum / n, billable_sum / billable_n.max(1.0));
        }
    }
}
  • Step 4: Run test to verify it passes

Run: cargo test --test cli_tests Expected: PASS.

  • Step 5: Manual smoke

Run: printf 'Diabetes mellitus Typ 2\n\nArterielle Hypertonie\n' | cargo run -- suggest --mode c - Expected: a table with E11.* and I10.* rows.

Run: cargo run -- eval --mode c --cases 100 Expected: a line mode=c cases=100 Recall@10=… BillableRecall@10=… — this is the baseline number Mode A must beat.

  • Step 6: Commit
git add src/bin/alpha_id.rs tests/cli_tests.rs
git commit -m "feat: CLI suggest/eval/index; Mode C baseline runnable"

Milestone C: Mode C works end-to-end on real data; eval harness produces a baseline BillableRecall@10.


Phase A — IONOS hybrid retrieval + reranker

Task 13: Shared IONOS client

Files:

  • Modify: src/ionos.rs

  • Test: tests/ionos_tests.rs

  • Step 1: Write the failing testtests/ionos_tests.rs

use alpha_id::ionos::IonosClient;
use std::io::Write;

fn mock_server(body: &'static str) -> (String, std::thread::JoinHandle<()>) {
    let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
    let url = format!("http://{}", server.server_addr());
    let h = std::thread::spawn(move || {
        if let Ok(req) = server.recv() {
            let resp = tiny_http::Response::from_string(body);
            req.respond(resp).ok();
        }
    });
    (url, h)
}

#[test]
fn posts_json_and_parses_response() {
    let (url, h) = mock_server(r#"{"ok":true}"#);
    let mut tf = tempfile::NamedTempFile::new().unwrap();
    write!(tf, "test-token-123").unwrap();
    let c = IonosClient::new(&url, tf.path().to_str().unwrap()).unwrap();
    let v: serde_json::Value = c.post_json("/ping", &serde_json::json!({"x":1})).unwrap();
    assert_eq!(v["ok"], true);
    h.join().ok();
}
  • Step 2: Run test to verify it fails

Run: cargo test --test ionos_tests Expected: FAIL — IonosClient not found.

  • Step 3: Write minimal implementationsrc/ionos.rs
use crate::model::AppError;

pub struct IonosClient {
    base_url: String,
    token: String,
    http: reqwest::blocking::Client,
}

fn expand_tilde(p: &str) -> String {
    if let Some(rest) = p.strip_prefix("~/") {
        if let Some(home) = std::env::var_os("HOME") {
            return format!("{}/{}", home.to_string_lossy(), rest);
        }
    }
    p.to_string()
}

impl IonosClient {
    pub fn new(base_url: &str, token_path: &str) -> Result<Self, AppError> {
        let path = expand_tilde(token_path);
        let token = std::fs::read_to_string(&path)
            .map_err(|e| AppError::Config(format!("token {path}: {e}")))?
            .trim().to_string();
        if token.is_empty() {
            return Err(AppError::Config(format!("empty token at {path}")));
        }
        let http = reqwest::blocking::Client::builder()
            .timeout(std::time::Duration::from_secs(60))
            .build()
            .map_err(|e| AppError::Ionos(format!("client: {e}")))?;
        Ok(Self { base_url: base_url.trim_end_matches('/').to_string(), token, http })
    }

    /// POST with up to 3 retries on transport / 5xx, exponential backoff.
    pub fn post_json<T: serde::de::DeserializeOwned>(
        &self, path: &str, body: &serde_json::Value,
    ) -> Result<T, AppError> {
        let url = format!("{}{}", self.base_url, path);
        let mut last = String::new();
        for attempt in 0..3 {
            let r = self.http.post(&url)
                .bearer_auth(&self.token)
                .json(body)
                .send();
            match r {
                Ok(resp) if resp.status().is_success() => {
                    return resp.json::<T>()
                        .map_err(|e| AppError::Ionos(format!("decode: {e}")));
                }
                Ok(resp) => {
                    last = format!("HTTP {}", resp.status());
                    if !resp.status().is_server_error() {
                        return Err(AppError::Ionos(last));
                    }
                }
                Err(e) => last = format!("transport: {e}"),
            }
            std::thread::sleep(std::time::Duration::from_millis(300 * (1 << attempt)));
        }
        Err(AppError::Ionos(format!("giving up after retries: {last}")))
    }
}
  • Step 4: Run test to verify it passes

Run: cargo test --test ionos_tests Expected: PASS.

  • Step 5: Commit
git add src/ionos.rs tests/ionos_tests.rs
git commit -m "feat: shared IONOS HTTP client with retry/backoff"

Task 14: Embedding client + resumable sha256 disk cache + cost estimate

Files:

  • Modify: src/embed.rs

  • Test: tests/embed_tests.rs

  • Step 1: Write the failing testtests/embed_tests.rs

use alpha_id::embed::{EmbeddingStore, estimate};

#[test]
fn cache_key_is_stable_sha256() {
    let k1 = EmbeddingStore::key("BAAI/bge-m3", "diabetes");
    let k2 = EmbeddingStore::key("BAAI/bge-m3", "diabetes");
    assert_eq!(k1, k2);
    assert_ne!(k1, EmbeddingStore::key("BAAI/bge-m3", "hypertonie"));
    assert_eq!(k1.len(), 64); // hex sha256
}

#[test]
fn store_persists_and_reloads_vectors_resumably() {
    let dir = tempfile::tempdir().unwrap();
    let store = EmbeddingStore::open(dir.path().to_str().unwrap(), "BAAI/bge-m3").unwrap();
    assert!(store.get("diabetes").is_none());
    store.put("diabetes", &[0.1, 0.2, 0.3]).unwrap();
    let reopened = EmbeddingStore::open(dir.path().to_str().unwrap(), "BAAI/bge-m3").unwrap();
    assert_eq!(reopened.get("diabetes").unwrap(), vec![0.1, 0.2, 0.3]);
    assert!(reopened.has("diabetes"));            // resume: skip already-embedded
}

#[test]
fn estimate_reports_pending_count_and_chars() {
    let est = estimate(&["abc".to_string(), "de".to_string()]);
    assert_eq!(est.items, 2);
    assert_eq!(est.chars, 5);
}
  • Step 2: Run test to verify it fails

Run: cargo test --test embed_tests Expected: FAIL — embed items not found.

  • Step 3: Write minimal implementationsrc/embed.rs
use crate::ionos::IonosClient;
use crate::model::AppError;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;

pub struct EmbeddingStore {
    dir: PathBuf,    // <index_dir>/embeddings/<model-sanitized>/
    model: String,
}

pub struct Estimate { pub items: usize, pub chars: usize }

pub fn estimate(pending: &[String]) -> Estimate {
    Estimate { items: pending.len(), chars: pending.iter().map(|s| s.len()).sum() }
}

impl EmbeddingStore {
    pub fn key(model: &str, text: &str) -> String {
        let mut h = Sha256::new();
        h.update(model.as_bytes());
        h.update([0u8]);
        h.update(text.as_bytes());
        hex(&h.finalize())
    }

    pub fn open(index_dir: &str, model: &str) -> Result<Self, AppError> {
        let safe = model.replace('/', "_");
        let dir = PathBuf::from(index_dir).join("embeddings").join(safe);
        fs::create_dir_all(&dir)
            .map_err(|e| AppError::Io(format!("{}: {e}", dir.display())))?;
        Ok(Self { dir, model: model.to_string() })
    }

    fn path(&self, text: &str) -> PathBuf {
        self.dir.join(format!("{}.f32", Self::key(&self.model, text)))
    }

    pub fn has(&self, text: &str) -> bool { self.path(text).exists() }

    pub fn get(&self, text: &str) -> Option<Vec<f32>> {
        let bytes = fs::read(self.path(text)).ok()?;
        Some(bytes.chunks_exact(4)
            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
            .collect())
    }

    /// Atomic write (temp + rename) so an interrupted run never leaves
    /// a half-written vector — the next run simply re-embeds that key.
    pub fn put(&self, text: &str, v: &[f32]) -> Result<(), AppError> {
        let mut bytes = Vec::with_capacity(v.len() * 4);
        for f in v { bytes.extend_from_slice(&f.to_le_bytes()); }
        let final_path = self.path(text);
        let tmp = final_path.with_extension("tmp");
        fs::write(&tmp, &bytes).map_err(|e| AppError::Io(format!("{e}")))?;
        fs::rename(&tmp, &final_path).map_err(|e| AppError::Io(format!("{e}")))?;
        Ok(())
    }
}

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{:02x}", x)).collect()
}

/// Embed a batch via IONOS, persisting each result. Only `texts` not
/// already cached should be passed (caller filters via `has`).
pub fn embed_batch(
    client: &IonosClient, store: &EmbeddingStore, model: &str, texts: &[String],
) -> Result<usize, AppError> {
    if texts.is_empty() { return Ok(0); }
    let body = serde_json::json!({ "model": model, "input": texts });
    let resp: serde_json::Value = client.post_json("/embeddings", &body)?;
    let data = resp["data"].as_array()
        .ok_or_else(|| AppError::Ionos("embeddings: no data[]".into()))?;
    let mut n = 0;
    for (i, item) in data.iter().enumerate() {
        let v: Vec<f32> = item["embedding"].as_array()
            .ok_or_else(|| AppError::Ionos("embeddings: no embedding[]".into()))?
            .iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
        store.put(&texts[i], &v)?;
        n += 1;
    }
    Ok(n)
}
  • Step 4: Run test to verify it passes

Run: cargo test --test embed_tests Expected: PASS (3 tests). embed_batch is exercised in Task 16 against the mock.

  • Step 5: Commit
git add src/embed.rs tests/embed_tests.rs
git commit -m "feat: resumable sha256 embedding cache + cost estimate"

Task 15: Vector store — brute-force cosine top-k

Files:

  • Modify: src/vector.rs

  • Test: tests/vector_tests.rs

  • Step 1: Write the failing testtests/vector_tests.rs

use alpha_id::vector::VectorIndex;

#[test]
fn cosine_topk_ranks_nearest_first() {
    let vi = VectorIndex::from_vectors(vec![
        vec![1.0, 0.0],   // id 0
        vec![0.0, 1.0],   // id 1
        vec![0.9, 0.1],   // id 2
    ]);
    let hits = vi.top_k(&[1.0, 0.0], 2);
    assert_eq!(hits[0].0, 0);
    assert_eq!(hits[1].0, 2);
    assert!(hits[0].1 > hits[1].1);
}
  • Step 2: Run test to verify it fails

Run: cargo test --test vector_tests Expected: FAIL — VectorIndex not found.

  • Step 3: Write minimal implementationsrc/vector.rs
/// Exact cosine search. 90k×1024 f32 ≈ 370 MB held in RAM; a query
/// is 90k dot products, well under 50 ms. No ANN dependency (YAGNI).
pub struct VectorIndex {
    vectors: Vec<Vec<f32>>,
    norms: Vec<f32>,
}

impl VectorIndex {
    pub fn from_vectors(vectors: Vec<Vec<f32>>) -> Self {
        let norms = vectors.iter().map(|v| dot(v, v).sqrt().max(1e-8)).collect();
        Self { vectors, norms }
    }

    /// (entry_index, cosine_similarity) descending.
    pub fn top_k(&self, query: &[f32], k: usize) -> Vec<(usize, f32)> {
        let qn = dot(query, query).sqrt().max(1e-8);
        let mut scored: Vec<(usize, f32)> = self.vectors.iter().enumerate()
            .map(|(i, v)| (i, dot(v, query) / (self.norms[i] * qn)))
            .collect();
        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
        scored.truncate(k);
        scored
    }
}

fn dot(a: &[f32], b: &[f32]) -> f32 {
    a.iter().zip(b).map(|(x, y)| x * y).sum()
}
  • Step 4: Run test to verify it passes

Run: cargo test --test vector_tests Expected: PASS.

  • Step 5: Commit
git add src/vector.rs tests/vector_tests.rs
git commit -m "feat: brute-force cosine vector index"

Task 16: index build — sample smoke-test + cost-guarded full run

Files:

  • Modify: src/bin/alpha_id.rs (Index arm)

  • Modify: src/embed.rs (add build_corpus_embeddings)

  • Test: tests/index_build_tests.rs

  • Step 1: Write the failing testtests/index_build_tests.rs

use alpha_id::embed::{EmbeddingStore, build_corpus_embeddings};
use alpha_id::ionos::IonosClient;
use std::io::Write;

#[test]
fn build_embeds_only_missing_and_is_resumable() {
    // mock server returns 2 vectors regardless
    let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
    let url = format!("http://{}", server.server_addr());
    let h = std::thread::spawn(move || {
        for _ in 0..2 {
            if let Ok(req) = server.recv() {
                let body = r#"{"data":[{"embedding":[0.1,0.2]},{"embedding":[0.3,0.4]}]}"#;
                req.respond(tiny_http::Response::from_string(body)).ok();
            }
        }
    });
    let mut tf = tempfile::NamedTempFile::new().unwrap();
    write!(tf, "tok").unwrap();
    let client = IonosClient::new(&url, tf.path().to_str().unwrap()).unwrap();
    let dir = tempfile::tempdir().unwrap();
    let store = EmbeddingStore::open(dir.path().to_str().unwrap(), "BAAI/bge-m3").unwrap();
    let texts = vec!["a".to_string(), "b".to_string()];

    let n1 = build_corpus_embeddings(&client, &store, "BAAI/bge-m3", &texts, 8).unwrap();
    assert_eq!(n1, 2);
    // second run: all cached -> zero new embeds, no server call needed
    let n2 = build_corpus_embeddings(&client, &store, "BAAI/bge-m3", &texts, 8).unwrap();
    assert_eq!(n2, 0);
    h.join().ok();
}
  • Step 2: Run test to verify it fails

Run: cargo test --test index_build_tests Expected: FAIL — build_corpus_embeddings not found.

  • Step 3: Add build_corpus_embeddings to src/embed.rs
/// Embed all `texts` not yet cached, in batches. Resumable: a crash
/// leaves cached vectors intact; rerun continues from the gap.
pub fn build_corpus_embeddings(
    client: &IonosClient, store: &EmbeddingStore, model: &str,
    texts: &[String], batch: usize,
) -> Result<usize, AppError> {
    let pending: Vec<String> = texts.iter()
        .filter(|t| !store.has(t))
        .cloned()
        .collect();
    let mut done = 0;
    for chunk in pending.chunks(batch.max(1)) {
        done += embed_batch(client, store, model, chunk)?;
    }
    Ok(done)
}
  • Step 4: Rewrite the Cmd::Index arm in src/bin/alpha_id.rs
Cmd::Index { sample, full, confirm } => {
    use alpha_id::embed::{EmbeddingStore, build_corpus_embeddings, estimate};
    use alpha_id::ionos::IonosClient;
    let p = Pipeline::load(&cfg).expect("load");
    let texts: Vec<String> = p.entries.iter().map(|e| e.text.clone()).collect();
    let client = IonosClient::new(&cfg.ionos_base_url, &cfg.token_path)
        .expect("ionos client (token?)");
    let store = EmbeddingStore::open(&cfg.index_dir, &cfg.embed_model).expect("store");

    let subset: Vec<String> = match sample {
        Some(n) => texts.iter().take(n).cloned().collect(),
        None => texts.clone(),
    };

    if sample.is_some() {
        let est = estimate(&subset);
        eprintln!("SMOKE: embedding {} sample texts ({} chars)…", est.items, est.chars);
        let n = build_corpus_embeddings(&client, &store, &cfg.embed_model, &subset, 32)
            .expect("sample embed failed");
        // verify dimension + a sample retrieval
        let dim = store.get(&subset[0]).map(|v| v.len()).unwrap_or(0);
        eprintln!("SMOKE OK: embedded {n}, vector dim = {dim}. \
                   Inspect, then run `index --full --confirm`.");
        return;
    }

    if full {
        let pending: Vec<String> = texts.iter().filter(|t| !store.has(t)).cloned().collect();
        let est = estimate(&pending);
        eprintln!("FULL RUN: {} of {} texts not yet embedded ({} chars). \
                   This calls IONOS and costs money.", est.items, texts.len(), est.chars);
        if !confirm {
            eprintln!("Refusing without --confirm. Re-run: \
                       `alpha-id index --full --confirm`");
            std::process::exit(2);
        }
        let n = build_corpus_embeddings(&client, &store, &cfg.embed_model, &texts, 32)
            .expect("full embed failed");
        eprintln!("FULL RUN done: {n} newly embedded, {} total cached.",
            texts.iter().filter(|t| store.has(t)).count());
        return;
    }

    eprintln!("Specify --sample N (smoke test first) or --full --confirm.");
}
  • Step 5: Run test to verify it passes

Run: cargo test --test index_build_tests Expected: PASS (resumability: second call returns 0).

  • Step 6: Commit
git add src/embed.rs src/bin/alpha_id.rs tests/index_build_tests.rs
git commit -m "feat: cost-guarded resumable index build (sample smoke + full)"

Task 17: Reranker client

Files:

  • Modify: src/rerank.rs

  • Test: tests/rerank_tests.rs

  • Step 1: Write the failing testtests/rerank_tests.rs

use alpha_id::rerank::rerank;
use alpha_id::ionos::IonosClient;
use std::io::Write;

#[test]
fn rerank_returns_scores_aligned_to_documents() {
    let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
    let url = format!("http://{}", server.server_addr());
    let h = std::thread::spawn(move || {
        if let Ok(req) = server.recv() {
            let body = r#"{"results":[{"index":0,"relevance_score":0.2},
                                       {"index":1,"relevance_score":0.9}]}"#;
            req.respond(tiny_http::Response::from_string(body)).ok();
        }
    });
    let mut tf = tempfile::NamedTempFile::new().unwrap();
    write!(tf, "tok").unwrap();
    let c = IonosClient::new(&url, tf.path().to_str().unwrap()).unwrap();
    let scores = rerank(&c, "Qwen/Qwen3-VL-Reranker-8B", "diabetes",
                        &["herzinfarkt".to_string(), "diabetes typ 2".to_string()]).unwrap();
    assert_eq!(scores.len(), 2);
    assert!(scores[1] > scores[0]);
    h.join().ok();
}
  • Step 2: Run test to verify it fails

Run: cargo test --test rerank_tests Expected: FAIL — rerank not found.

  • Step 3: Write minimal implementationsrc/rerank.rs
use crate::ionos::IonosClient;
use crate::model::AppError;

/// Cross-encoder rerank. Returns a score per document, aligned to the
/// input `docs` order (results are re-sorted back by their index).
pub fn rerank(
    client: &IonosClient, model: &str, query: &str, docs: &[String],
) -> Result<Vec<f32>, AppError> {
    if docs.is_empty() { return Ok(vec![]); }
    let body = serde_json::json!({
        "model": model, "query": query, "documents": docs
    });
    let resp: serde_json::Value = client.post_json("/rerank", &body)?;
    let results = resp["results"].as_array()
        .ok_or_else(|| AppError::Ionos("rerank: no results[]".into()))?;
    let mut scores = vec![0.0f32; docs.len()];
    for r in results {
        let idx = r["index"].as_u64().ok_or_else(
            || AppError::Ionos("rerank: no index".into()))? as usize;
        let sc = r["relevance_score"].as_f64()
            .or_else(|| r["score"].as_f64())
            .ok_or_else(|| AppError::Ionos("rerank: no score".into()))? as f32;
        if idx < scores.len() { scores[idx] = sc; }
    }
    Ok(scores)
}

Note: exact field names (relevance_score vs score, /rerank path) are confirmed empirically in the Task 18 smoke step; the code already accepts both score keys.

  • Step 4: Run test to verify it passes

Run: cargo test --test rerank_tests Expected: PASS.

  • Step 5: Commit
git add src/rerank.rs tests/rerank_tests.rs
git commit -m "feat: Qwen3-VL reranker client"

Task 18: Pipeline — Mode A (hybrid + rerank + degrade)

Files:

  • Modify: src/pipeline.rs

  • Test: tests/pipeline_mode_a_tests.rs

  • Step 1: Write the failing testtests/pipeline_mode_a_tests.rs

use alpha_id::pipeline::Pipeline;
use alpha_id::model::{Config, Filter, Mode};

#[test]
fn mode_a_degrades_to_c_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);
    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")));
}
  • Step 2: Run test to verify it fails

Run: cargo test --test pipeline_mode_a_tests Expected: FAIL — Mode A path not implemented (currently identical to C, degraded=false).

  • Step 3: Extend src/pipeline.rs

Add fields + Mode A retrieval. Replace the Pipeline struct and suggest with:

use crate::embed::EmbeddingStore;
use crate::ionos::IonosClient;
use crate::rerank::rerank;
use crate::vector::VectorIndex;

pub struct Pipeline {
    pub entries: Vec<AlphaIdEntry>,
    pub meta: HashMap<String, IcdMeta>,
    pub lexical: LexicalIndex,
    pub pool_size: usize,
    cfg: Config,
    vector: Option<VectorIndex>, // present iff embeddings cached for all entries
}

impl Pipeline {
    pub fn load(cfg: &Config) -> Result<Self, AppError> {
        let entries = corpus::load(&cfg.alpha_id_path)?;
        let meta = claml::load(&cfg.claml_path)?;
        let lexical = LexicalIndex::build_in_ram(&entries)?;
        // Build vector index only if every entry has a cached embedding.
        let vector = match EmbeddingStore::open(&cfg.index_dir, &cfg.embed_model) {
            Ok(store) if entries.iter().all(|e| store.has(&e.text)) => {
                let vs: Vec<Vec<f32>> = entries.iter()
                    .map(|e| store.get(&e.text).unwrap()).collect();
                Some(VectorIndex::from_vectors(vs))
            }
            _ => None,
        };
        Ok(Self { entries, meta, lexical, pool_size: cfg.pool_size, cfg: cfg.clone(), vector })
    }

    fn semantic_pool(&self, seg: &str, calls: &mut usize)
        -> Result<Vec<(usize, f32)>, AppError> {
        let client = IonosClient::new(&self.cfg.ionos_base_url, &self.cfg.token_path)?;
        let body = serde_json::json!({ "model": self.cfg.embed_model, "input": [seg] });
        let resp: serde_json::Value = client.post_json("/embeddings", &body)?;
        *calls += 1;
        let q: Vec<f32> = resp["data"][0]["embedding"].as_array()
            .ok_or_else(|| AppError::Ionos("query embed: no vector".into()))?
            .iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
        let vi = self.vector.as_ref()
            .ok_or_else(|| AppError::Ionos("no vector index".into()))?;
        Ok(vi.top_k(&q, self.pool_size))
    }

    fn rerank_pool(&self, seg: &str, idxs: &[usize], calls: &mut usize)
        -> Result<Vec<(usize, f32)>, AppError> {
        let client = IonosClient::new(&self.cfg.ionos_base_url, &self.cfg.token_path)?;
        let docs: Vec<String> = idxs.iter().map(|&i| self.entries[i].text.clone()).collect();
        let scores = rerank(&client, &self.cfg.rerank_model, seg, &docs)?;
        *calls += 1;
        Ok(idxs.iter().cloned().zip(scores).collect())
    }
}

Now replace suggest so it tries Mode A, and on any IONOS error falls back to the Mode C path with degraded=true:

impl Pipeline {
    pub fn suggest(&self, dictation: &str, mode: Mode, filter: &Filter, top_k: usize)
        -> SuggestResult {
        let t0 = Instant::now();
        let segments = split_segments(dictation);
        let mut calls = 0usize;
        let mut degraded = false;

        let candidates: Vec<Candidate> = match mode {
            Mode::A => match self.collect_mode_a(&segments, &mut calls) {
                Ok(c) => c,
                Err(_) => { degraded = true; self.collect_mode_c(&segments) }
            },
            Mode::C => self.collect_mode_c(&segments),
        };

        let valid_by_code = self.valid_by_code(&candidates);
        let merged = cross_segment_dedupe(candidates);
        let mut suggestions = Vec::new();
        for (d, score) in merged {
            let alpha_valid = *valid_by_code.get(&d.icd_code).unwrap_or(&false);
            if let Some(s) = self.to_suggestion(&d.icd_code, &d.best_phrase, score,
                                                d.source_segments.clone(), alpha_valid) {
                if filter.accepts(&s.tags) { suggestions.push(s); }
            }
            if suggestions.len() >= top_k { break; }
        }
        SuggestResult {
            suggestions,
            diagnostics: Diagnostics {
                mode: format!("{:?}", mode),
                degraded,
                segment_count: segments.len(),
                ionos_calls: calls,
                millis: t0.elapsed().as_millis(),
            },
        }
    }

    fn collect_mode_c(&self, segments: &[String]) -> Vec<Candidate> {
        let mut out = Vec::new();
        for (si, seg) in segments.iter().enumerate() {
            for (entry_idx, score) in self.lexical.search(seg, self.pool_size).unwrap_or_default() {
                let e = &self.entries[entry_idx];
                if let Some(code) = corpus::primary_code(e) {
                    out.push(Candidate { icd_code: code.into(), alpha_text: e.text.clone(),
                        segment_idx: si, lexical: Some(score), semantic: None, rerank: None });
                }
            }
        }
        out
    }

    fn collect_mode_a(&self, segments: &[String], calls: &mut usize)
        -> Result<Vec<Candidate>, AppError> {
        let mut out = Vec::new();
        for (si, seg) in segments.iter().enumerate() {
            let lex = self.lexical.search(seg, self.pool_size).unwrap_or_default();
            let sem = self.semantic_pool(seg, calls)?;
            let lex_ids: Vec<usize> = lex.iter().map(|(i, _)| *i).collect();
            let sem_ids: Vec<usize> = sem.iter().map(|(i, _)| *i).collect();
            let pool = crate::fusion::rrf_merge(&lex_ids, &sem_ids, 60);
            let pool: Vec<usize> = pool.into_iter().take(self.pool_size).collect();
            let reranked = self.rerank_pool(seg, &pool, calls)?;
            for (entry_idx, rscore) in reranked {
                let e = &self.entries[entry_idx];
                if let Some(code) = corpus::primary_code(e) {
                    out.push(Candidate { icd_code: code.into(), alpha_text: e.text.clone(),
                        segment_idx: si, lexical: None, semantic: None, rerank: Some(rscore) });
                }
            }
        }
        Ok(out)
    }

    fn valid_by_code(&self, candidates: &[Candidate]) -> HashMap<String, bool> {
        let mut m: HashMap<String, bool> = HashMap::new();
        for c in candidates { m.entry(c.icd_code.clone()).or_insert(false); }
        for e in &self.entries {
            if let Some(code) = corpus::primary_code(e) {
                if let Some(v) = m.get_mut(code) { *v = *v || e.valid; }
            }
        }
        m
    }
}

Delete the old single suggest/to_suggestion duplicates from Task 10 — keep one to_suggestion (unchanged) and the new suggest.

  • Step 4: Run test to verify it passes

Run: cargo test --test pipeline_mode_a_tests Expected: PASS — IONOS refused → degraded=true, lexical still answers.

  • Step 5: Run the full suite (no regressions)

Run: cargo test Expected: all prior tests still PASS (Mode C unaffected).

  • Step 6: Commit
git add src/pipeline.rs tests/pipeline_mode_a_tests.rs
git commit -m "feat: Mode A hybrid pipeline with graceful degradation to C"

Task 19: Eval comparison C vs A + smoke against real IONOS

Files:

  • Modify: src/bin/alpha_id.rs (Eval arm prints both modes when --mode both)

  • Test: tests/eval_compare_tests.rs

  • Step 1: Write the failing testtests/eval_compare_tests.rs

use std::process::Command;

#[test]
fn eval_mode_c_runs_and_reports_billable_recall() {
    let out = Command::new(env!("CARGO_BIN_EXE_alpha-id"))
        .args(["eval", "--mode", "c", "--cases", "30"])
        .arg("--config").arg("config/default.toml")
        .output().unwrap();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    let s = String::from_utf8_lossy(&out.stdout);
    assert!(s.contains("BillableRecall@10="));
}
  • Step 2: Run test to verify it fails

If Task 12's eval already prints BillableRecall@10=, this test passes immediately — that is acceptable (regression guard). Otherwise adjust the Eval arm to print that exact token.

Run: cargo test --test eval_compare_tests Expected: PASS (guard) or FAIL then fix the print string.

  • Step 3: Add a both convenience to the Eval arm

In src/bin/alpha_id.rs, after parsing mode, allow the literal both:

Cmd::Eval { mode, cases, seed } => {
    let modes: Vec<Mode> = if mode == "both" {
        vec![Mode::C, Mode::A]
    } else {
        vec![mode.parse().expect("mode")]
    };
    let p = Pipeline::load(&cfg).expect("load");
    for m in modes {
        // (reuse the eval loop from Task 12, parameterized by `m`;
        //  extract it into `fn run_eval(&p, m, cases, seed) -> (f64,f64)`
        //  and print one line per mode)
        let (r10, br10) = run_eval(&p, m, cases, seed);
        println!("mode={:?} cases={} Recall@10={:.3} BillableRecall@10={:.3}",
                 m, cases, r10, br10);
    }
}

Extract the Task 12 eval body verbatim into a free function:

fn run_eval(p: &Pipeline, m: Mode, cases: usize, seed: u64) -> (f64, f64) {
    use alpha_id::eval::{inject_errors, recall_at_k};
    use alpha_id::model::Filter;
    let billable: Vec<_> = p.entries.iter().enumerate()
        .filter(|(_, e)| e.valid)
        .filter(|(_, e)| alpha_id::corpus::primary_code(e)
            .and_then(|c| p.meta.get(c))
            .map(|mt| mt.para295 == "P").unwrap_or(false))
        .take(cases).collect();
    let mut sum = 0.0;
    for (k, (_, e)) in billable.iter().enumerate() {
        let code = alpha_id::corpus::primary_code(e).unwrap().to_string();
        let noisy = inject_errors(&e.text, 0.15, seed.wrapping_add(k as u64));
        let res = p.suggest(&noisy, m, &Filter::default(), 10);
        let preds: Vec<String> = res.suggestions.iter().map(|s| s.icd_code.clone()).collect();
        sum += recall_at_k(&preds, &code, 10);
    }
    let n = billable.len().max(1) as f64;
    (sum / n, sum / n) // overall == billable here (sample is billable-only)
}
  • Step 4: Run test to verify it passes

Run: cargo test --test eval_compare_tests Expected: PASS.

  • Step 5: Real IONOS smoke (manual, gated)

Only after alpha-id index --sample 50 then alpha-id index --full --confirm have populated the cache:

Run: cargo run -- eval --mode both --cases 200 Expected: two lines; compare BillableRecall@10 for C vs A. This is the go/no-go number (spec §6) — present it to the user; do not decide adoption autonomously.

  • Step 6: Commit
git add src/bin/alpha_id.rs tests/eval_compare_tests.rs
git commit -m "feat: C-vs-A eval comparison; go/no-go metric"

Milestone A: Mode A end-to-end with graceful degradation; eval prints the C-vs-A BillableRecall@10 comparison that decides PoC adoption.


Self-Review

1. Spec coverage:

  • §1 I/O contract → Tasks 6 (segment), 10/18 (single global list), 2 (types). ✓
  • §2 Modes C/A, data flow, modules → Tasks 3,4,7,8,9,10 (C), 1318 (A). ✓
  • §3 data model, dedupe by normalized code, fusion → Tasks 2, 3 (normalize_code), 9. ✓
  • §4 one-time, hash-cached, resumable, cost-guarded, sample-first embedding → Tasks 14, 16. ✓
  • §5 degrade-not-fail, no hallucination (codes only from corpus) → Task 18 (degrade), 10/18 (codes sourced from entries only). ✓
  • §6 eval harness, synthetic errors, Recall@10 billable, go/no-go → Tasks 11, 12, 19. ✓
  • §7 CLI surface → Tasks 12, 16, 19. ✓
  • §8 out of scope (no generative LLM) → respected; only embeddings + reranker. ✓

2. Placeholder scan: No "TBD/TODO". Two conditional steps (claml chapter-stack 2b; eval guard 19/2) are explicit, with concrete code/criteria — not placeholders. The Task 19 run_eval extraction quotes the full function body rather than "similar to Task 12".

3. Type consistency: AlphaIdEntry, IcdMeta, Tags, Filter, Candidate, Suggestion, Diagnostics, SuggestResult, Config, AppError, Mode defined once in Task 2 and used unchanged. corpus::primary_code, normalize::normalize, LexicalIndex::search, fusion::rrf_merge, fusion::cross_segment_dedupe, EmbeddingStore, VectorIndex::top_k, rerank::rerank, IonosClient::post_json signatures are consistent across Tasks 319. Task 18 explicitly supersedes the Task 10 suggest (noted to delete the duplicate) — no clearLayers/clearFullLayers drift.

Known intentional simplification: run_eval returns (overall, billable) equal because the eval sample is billable-only by construction (spec §6 headline metric); documented inline.


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-18-alpha-id-icd-suggestion.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.

2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?