Files
alpha-id/src/pipeline.rs
T
Brummel d83b10f939 refactor: rename Mode variants C/A to Lexical/Hybrid (closes #2)
The retrieval-mode enum used opaque single letters `Mode { C, A }`;
neither the identifier nor the operator-facing `--mode a` token
conveyed what the mode does. Rename to self-describing names that map
directly onto the glossary definitions:

  Mode::C -> Mode::Lexical  (BM25 + tag filter, offline, no IONOS)
  Mode::A -> Mode::Hybrid   (lexical u semantic -> RRF -> rerank)

Decisions taken (the issue's open questions, resolved):
- CLI canonical tokens are now `lexical` / `hybrid`; the default is
  `lexical`. The legacy `c` / `a` tokens stay accepted as
  case-insensitive input aliases (zero cost; protects hand-typed
  invocations). A unit test pins the alias contract.
- The diagnostics `mode` string (Debug-derived) now emits
  "Lexical"/"Hybrid". Verified safe: no downstream consumer parses it.
  doctate is the future integrator and embeds the library (the `Mode`
  enum), not the JSON string (design spec §103).
- The reserved, out-of-scope generative tier (spec's "Mode B") keeps
  conceptual room: a future `Mode::Generative` does not collide with
  the retrieval-strategy names Lexical/Hybrid.

Surface: enum + FromStr, CLI defaults and `eval --mode both`
expansion, the private `collect_mode_c`/`collect_mode_a` methods (now
`collect_lexical`/`collect_hybrid`) and their comments, the two
pipeline mode test files (renamed), the eval CLI test, the glossary
(old letter forms demoted to Avoid lists), and the spec CLI synopsis.

closes #2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:15:08 +02:00

239 lines
9.6 KiB
Rust

use crate::claml;
use crate::corpus::{self};
use crate::embed::EmbeddingStore;
use crate::fusion::{cross_segment_dedupe, rrf_merge};
use crate::ionos::IonosClient;
use crate::lexical::LexicalIndex;
use crate::model::*;
use crate::rerank;
use crate::segment::split_segments;
use crate::tags::tags_for;
use crate::vector::VectorIndex;
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,
pub valid_by_code: HashMap<String, bool>,
cfg: Config,
vector: Option<VectorIndex>,
}
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)?;
let valid_by_code = {
let mut m: HashMap<String, bool> = HashMap::new();
for e in &entries {
if let Some(code) = corpus::primary_code(e) {
let v = m.entry(code.to_string()).or_insert(false);
*v = *v || e.valid;
}
}
m
};
// Build the semantic index only if EVERY corpus entry already has a
// 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<Vec<Vec<f32>>> =
entries.iter().map(|e| store.get(&e.text)).collect();
vecs.map(VectorIndex::from_vectors)
}
_ => None,
};
Ok(Self {
entries,
meta,
lexical,
pool_size: cfg.pool_size,
valid_by_code,
cfg: cfg.clone(),
vector,
})
}
/// Look up ICD metadata: exact code first, then — only for 6-char dotted codes
/// of the form `Lxx.yz` — the 5-char parent `Lxx.y`.
///
/// The ClaML map (populated by `claml::load`) now contains synthesized
/// 5th-digit entries (e.g. `E11.90`, `I10.91`) with correct `Para295 = "P"`,
/// so the exact hit succeeds for the vast majority of corpus codes. The
/// 5-char-parent fallback covers any residual gap where a 6-char code is not
/// explicitly present but its 5-char parent is.
///
/// No 3-char-root fallback is applied: that level caused ~1 193 codes to
/// inherit the parent's `Para295 = "V"` and be wrongly dropped under
/// `--billable-only` (Goal 1 regression). Codes absent from ClaML entirely
/// yield `None` (accepted, documented out-of-scope gap).
fn meta_lookup<'a>(&'a self, code: &str) -> Option<&'a IcdMeta> {
if let Some(m) = self.meta.get(code) {
return Some(m);
}
// 5-char-parent fallback only for 6-char dotted codes: Lxx.yz → Lxx.y
// Pattern: one letter, two digits, dot, two digits (total 6 chars).
if code.len() == 6 {
let bytes = code.as_bytes();
if bytes[0].is_ascii_alphabetic()
&& bytes[1].is_ascii_digit()
&& bytes[2].is_ascii_digit()
&& bytes[3] == b'.'
&& bytes[4].is_ascii_digit()
&& bytes[5].is_ascii_digit()
{
if let Some(m) = self.meta.get(&code[..5]) {
return Some(m);
}
}
}
None
}
fn to_suggestion(&self, code: &str, phrase: &str, score: f32,
segs: Vec<usize>, alpha_valid: bool) -> Option<Suggestion> {
let m = self.meta_lookup(code)?; // None ≈ 31 codes absent from ClaML entirely; accepted gap (see meta_lookup doc)
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),
})
}
/// 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<Candidate> {
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,
});
}
}
}
candidates
}
/// 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_hybrid(&self, segments: &[String], calls: &mut usize)
-> Result<Vec<Candidate>, AppError> {
let client = IonosClient::new(&self.cfg.ionos_base_url, &self.cfg.token_path)?;
let vector = self.vector.as_ref()
.ok_or_else(|| AppError::Ionos("no vector index".into()))?;
let mut candidates: Vec<Candidate> = Vec::new();
for (si, seg) in segments.iter().enumerate() {
// Lexical pool (entry indices).
let lex = self.lexical.search(seg, self.pool_size).unwrap_or_default();
let lex_ids: Vec<usize> = lex.iter().map(|(i, _)| *i).collect();
// Semantic pool: embed the segment, then exact cosine search.
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("embeddings: no data[0].embedding".into()))?
.iter()
.map(|x| x.as_f64()
.ok_or_else(|| AppError::Ionos("embeddings: non-numeric component".into())))
.collect::<Result<Vec<f64>, _>>()?
.into_iter().map(|f| f as f32).collect();
let sem = vector.top_k(&q, self.pool_size);
let sem_ids: Vec<usize> = sem.iter().map(|(i, _)| *i).collect();
// Fuse the two ranked index lists; cap at pool_size.
let mut fused = rrf_merge(&lex_ids, &sem_ids, 60);
fused.truncate(self.pool_size);
// Cross-encoder rerank the fused pool.
let docs: Vec<String> = fused.iter()
.map(|&i| self.entries[i].text.clone())
.collect();
let scores = rerank::rerank(&client, &self.cfg.rerank_model, seg, &docs)?;
*calls += 1;
for (rank, &entry_idx) in fused.iter().enumerate() {
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: None,
semantic: None,
rerank: scores.get(rank).copied(),
});
}
}
}
Ok(candidates)
}
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 ionos_calls = 0usize;
let mut degraded = false;
let candidates: Vec<Candidate> = match mode {
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 Lexical fallback,
// request still answered, never panics.
degraded = true;
self.collect_lexical(&segments)
}
},
};
let merged = cross_segment_dedupe(candidates);
let mut suggestions = Vec::new();
for (d, score) in merged {
let alpha_valid = self.valid_by_code.get(&d.icd_code).copied().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,
millis: t0.elapsed().as_millis(),
},
}
}
}