diff --git a/src/bin/alpha_id.rs b/src/bin/alpha_id.rs index 178e8b0..b83be50 100644 --- a/src/bin/alpha_id.rs +++ b/src/bin/alpha_id.rs @@ -178,9 +178,10 @@ fn main() { for s in &res.suggestions { println!("{:<8} {:>5.3} {}", s.icd_code, s.score, s.description); } - eprintln!("[{} segments, mode {}, {} ms, degraded={}]", + eprintln!("[{} segments, mode {}, {} ms, degraded={}, index={}]", res.diagnostics.segment_count, res.diagnostics.mode, - res.diagnostics.millis, res.diagnostics.degraded); + res.diagnostics.millis, res.diagnostics.degraded, + res.diagnostics.index_status); } } Cmd::Eval { mode, cases, seed } => { diff --git a/src/model.rs b/src/model.rs index 217642c..cde2811 100644 --- a/src/model.rs +++ b/src/model.rs @@ -88,6 +88,16 @@ pub enum IndexStatus { Mismatch(String), // packed file present but structurally invalid; reason carried } +impl std::fmt::Display for IndexStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IndexStatus::Ok => write!(f, "ok"), + IndexStatus::Absent => write!(f, "absent"), + IndexStatus::Mismatch(reason) => write!(f, "mismatch: {reason}"), + } + } +} + impl FromStr for Mode { type Err = String; fn from_str(s: &str) -> Result { diff --git a/src/pipeline.rs b/src/pipeline.rs index d634623..cfa1449 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -28,11 +28,12 @@ pub struct Pipeline { /// back to the per-file store. Returns the index (`None` when none is usable) /// and an `IndexStatus` describing the path taken. /// -/// Iteration-1 scope: the packed file is validated *structurally* only -/// (`read_packed` checks magic/version/payload length; here we additionally -/// require `n_rows == entries.len()`). The header's `corpus_sha256` and -/// `embed_model` are NOT yet compared against the config — that enforcement -/// is iteration 2. +/// Validation: structural first (`read_packed` checks magic/version/payload +/// length; here we additionally require `n_rows == entries.len()`), then +/// semantic — the header's `embed_model` and `corpus_sha256` must match the +/// live config, else the packed file is refused as a `Mismatch`. A `Mismatch` +/// yields no index, so Mode Hybrid degrades to Lexical rather than serving a +/// matrix that does not belong to the deployed corpus/model. pub fn load_packed_or_store(cfg: &Config, entries: &[AlphaIdEntry]) -> (Option, IndexStatus) { let packed = packed::packed_path(&cfg.index_dir, &cfg.embed_model); if packed.exists() { @@ -48,7 +49,16 @@ pub fn load_packed_or_store(cfg: &Config, entries: &[AlphaIdEntry]) -> (Option { + return (Some(VectorIndex::from_vectors(rows)), IndexStatus::Ok); + } + Ok(_) => return (None, IndexStatus::Mismatch("corpus sha256".to_string())), + Err(e) => return (None, IndexStatus::Mismatch(format!("corpus hash: {e}"))), + } } Err(e) => return (None, IndexStatus::Mismatch(format!("{e}"))), } @@ -82,6 +92,13 @@ impl Pipeline { m }; let (vector, index_status) = load_packed_or_store(cfg, &entries); + if let IndexStatus::Mismatch(reason) = &index_status { + eprintln!( + "warning: packed index {} does not match the current corpus/model \ + ({reason}); semantic index disabled, Mode Hybrid will degrade to Lexical", + packed::packed_path(&cfg.index_dir, &cfg.embed_model).display() + ); + } Ok(Self { entries, meta, diff --git a/tests/index_mismatch_tests.rs b/tests/index_mismatch_tests.rs new file mode 100644 index 0000000..c2c6bcf --- /dev/null +++ b/tests/index_mismatch_tests.rs @@ -0,0 +1,78 @@ +use alpha_id::model::{Config, Filter, IndexStatus, Mode}; +use alpha_id::packed::{packed_path, write_packed, PackedHeader}; +use alpha_id::pipeline::Pipeline; +use std::process::Command; + +// A structurally-mismatched packed file at the config's derived packed path. +// n_rows=1 will not equal the real corpus entry count, so load reports a +// Mismatch — enough to exercise the warning and the diagnostics token. Uses +// the real corpus/claml from config/default.toml with index_dir redirected to +// a temp dir, so the real index is never touched. +fn write_mismatched_packed(cfg: &Config) { + let header = PackedHeader { + n_rows: 1, + dim: 1, + embed_model: cfg.embed_model.clone(), + corpus_sha256: "x".to_string(), + corpus_name: "x".to_string(), + }; + write_packed(&packed_path(&cfg.index_dir, &cfg.embed_model), &header, &[vec![0.0f32]]).unwrap(); +} + +#[test] +fn hybrid_suggest_under_mismatch_is_degraded_and_index_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::load("config/default.toml").unwrap(); + cfg.index_dir = dir.path().to_str().unwrap().to_string(); + cfg.token_path = "/nonexistent".to_string(); + write_mismatched_packed(&cfg); + + let p = Pipeline::load(&cfg).unwrap(); + let res = p.suggest("knee pain", Mode::Hybrid, &Filter::default(), 5); + assert!(res.diagnostics.degraded, "hybrid degrades when the index is mismatched"); + assert!( + matches!(res.diagnostics.index_status, IndexStatus::Mismatch(_)), + "index_status names the structural problem, not an anonymous degrade" + ); +} + +#[test] +fn cli_suggest_under_mismatch_warns_and_shows_index_token() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::load("config/default.toml").unwrap(); + cfg.index_dir = dir.path().to_str().unwrap().to_string(); + write_mismatched_packed(&cfg); + + // Build a temp config TOML reusing the real data paths but the temp index dir. + let toml = format!( + r#"ionos_base_url = "http://127.0.0.1:1" +token_path = "/nonexistent" +embed_model = "{embed_model}" +rerank_model = "{rerank_model}" +alpha_id_path = "{alpha_id_path}" +claml_path = "{claml_path}" +index_dir = "{index_dir}" +pool_size = {pool_size} +top_k = {top_k} +"#, + embed_model = cfg.embed_model, + rerank_model = cfg.rerank_model, + alpha_id_path = cfg.alpha_id_path, + claml_path = cfg.claml_path, + index_dir = cfg.index_dir, + pool_size = cfg.pool_size, + top_k = cfg.top_k, + ); + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, toml).unwrap(); + + // Mode Lexical needs no IONOS; the warning + token come from load, not the mode. + let out = Command::new(env!("CARGO_BIN_EXE_alpha-id")) + .args(["--config", config_path.to_str().unwrap(), "suggest", "-", "--mode", "lexical"]) + .env("ALPHA_ID_STDIN", "knee pain") + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("does not match"), "load warning missing; stderr: {stderr}"); + assert!(stderr.contains("index=mismatch"), "diagnostics token missing; stderr: {stderr}"); +} diff --git a/tests/packed_load_tests.rs b/tests/packed_load_tests.rs index b66e0fb..22e833a 100644 --- a/tests/packed_load_tests.rs +++ b/tests/packed_load_tests.rs @@ -1,5 +1,6 @@ use alpha_id::model::{AlphaIdEntry, Config, IndexStatus}; use alpha_id::packed::{packed_path, write_packed, PackedHeader}; +use alpha_id::packed::corpus_sha256; use alpha_id::pipeline::load_packed_or_store; fn entry(text: &str) -> AlphaIdEntry { @@ -36,14 +37,19 @@ fn index_status_serializes_externally_tagged() { #[test] fn packed_beats_store_builds_without_store() { let dir = tempfile::tempdir().unwrap(); - let cfg = cfg_in(dir.path()); + let mut cfg = cfg_in(dir.path()); + // Hermetic corpus so the header's corpus_sha256 can match the live config + // under iteration-2 semantic enforcement. + let corpus = dir.path().join("corpus.txt"); + std::fs::write(&corpus, b"some corpus bytes").unwrap(); + cfg.alpha_id_path = corpus.to_str().unwrap().to_string(); let entries = vec![entry("a"), entry("b")]; let rows = vec![vec![1.0f32, 0.0], vec![0.0f32, 1.0]]; let h = PackedHeader { n_rows: 2, dim: 2, embed_model: cfg.embed_model.clone(), - corpus_sha256: "irrelevant-in-iter1".to_string(), + corpus_sha256: corpus_sha256(&cfg.alpha_id_path).unwrap(), corpus_name: "c.txt".to_string(), }; write_packed(&packed_path(&cfg.index_dir, &cfg.embed_model), &h, &rows).unwrap(); @@ -71,3 +77,74 @@ fn n_rows_mismatch_yields_mismatch_none() { assert!(vector.is_none()); assert!(matches!(status, IndexStatus::Mismatch(_))); } + +fn header_for(cfg: &Config, n_rows: u32, model: &str, corpus_hash: &str) -> PackedHeader { + let _ = cfg; + PackedHeader { + n_rows, + dim: 2, + embed_model: model.to_string(), + corpus_sha256: corpus_hash.to_string(), + corpus_name: "c.txt".to_string(), + } +} + +#[test] +fn index_status_display_renders_spec_tokens() { + assert_eq!(format!("{}", IndexStatus::Ok), "ok"); + assert_eq!(format!("{}", IndexStatus::Absent), "absent"); + assert_eq!( + format!("{}", IndexStatus::Mismatch("corpus sha256".to_string())), + "mismatch: corpus sha256" + ); +} + +#[test] +fn model_mismatch_yields_mismatch_none() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); // cfg.embed_model == "test/model" + let entries = vec![entry("a"), entry("b")]; + let rows = vec![vec![1.0f32, 0.0], vec![0.0f32, 1.0]]; + // n_rows matches (2) so the structural check passes; the header declares a + // different model, so the semantic check must reject it. + let h = header_for(&cfg, 2, "other/model", "irrelevant"); + write_packed(&packed_path(&cfg.index_dir, &cfg.embed_model), &h, &rows).unwrap(); + let (vector, status) = load_packed_or_store(&cfg, &entries); + assert!(vector.is_none()); + assert!(matches!(status, IndexStatus::Mismatch(_))); +} + +#[test] +fn corpus_hash_mismatch_yields_mismatch_none() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = cfg_in(dir.path()); + // Point the corpus at a tiny temp file so the hash is hermetic. + let corpus = dir.path().join("corpus.txt"); + std::fs::write(&corpus, b"some corpus bytes").unwrap(); + cfg.alpha_id_path = corpus.to_str().unwrap().to_string(); + let entries = vec![entry("a"), entry("b")]; + let rows = vec![vec![1.0f32, 0.0], vec![0.0f32, 1.0]]; + // Model matches, but the header's corpus hash does not match the file's. + let h = header_for(&cfg, 2, &cfg.embed_model, "deadbeef-not-the-real-hash"); + write_packed(&packed_path(&cfg.index_dir, &cfg.embed_model), &h, &rows).unwrap(); + let (vector, status) = load_packed_or_store(&cfg, &entries); + assert!(vector.is_none()); + assert!(matches!(status, IndexStatus::Mismatch(_))); +} + +#[test] +fn matching_model_and_corpus_yields_ok() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = cfg_in(dir.path()); + let corpus = dir.path().join("corpus.txt"); + std::fs::write(&corpus, b"some corpus bytes").unwrap(); + cfg.alpha_id_path = corpus.to_str().unwrap().to_string(); + let entries = vec![entry("a"), entry("b")]; + let rows = vec![vec![1.0f32, 0.0], vec![0.0f32, 1.0]]; + let real_hash = corpus_sha256(&cfg.alpha_id_path).unwrap(); + let h = header_for(&cfg, 2, &cfg.embed_model, &real_hash); + write_packed(&packed_path(&cfg.index_dir, &cfg.embed_model), &h, &rows).unwrap(); + let (vector, status) = load_packed_or_store(&cfg, &entries); + assert!(vector.is_some()); + assert_eq!(status, IndexStatus::Ok); +} diff --git a/tests/pipeline_hybrid_tests.rs b/tests/pipeline_hybrid_tests.rs index 0393449..69591d2 100644 --- a/tests/pipeline_hybrid_tests.rs +++ b/tests/pipeline_hybrid_tests.rs @@ -1,5 +1,5 @@ use alpha_id::pipeline::Pipeline; -use alpha_id::model::{Config, Filter, Mode}; +use alpha_id::model::{Config, Filter, IndexStatus, Mode}; #[test] fn hybrid_degrades_to_lexical_when_ionos_unreachable() { @@ -10,4 +10,7 @@ fn hybrid_degrades_to_lexical_when_ionos_unreachable() { 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"))); + // Foil: a transient IONOS degrade leaves the index structurally Ok — + // distinct from a corpus/model mismatch, which would be IndexStatus::Mismatch. + assert_eq!(res.diagnostics.index_status, IndexStatus::Ok); }