From 79de4e140491ddc512c9e66764c6f3d1c8312e2e Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 1 Jun 2026 00:59:25 +0200 Subject: [PATCH] feat: enforce + surface packed index binding (closes #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 1 shipped the packed format with the corpus_sha256 + embed_model manifest in its header but only validated structure at load. A corpus release or an embed_model change could still ship an index that loads "successfully" yet belongs to a different corpus/model, and the only signal was the anonymous degraded:true — indistinguishable from a transient IONOS outage. This closes that gap. load_packed_or_store now, after the structural checks pass, compares the header's embed_model against cfg.embed_model and its corpus_sha256 against a fresh hash of cfg.alpha_id_path. On either disagreement it returns IndexStatus::Mismatch("embed model" | "corpus sha256") with no vector index, so Mode Hybrid degrades to Lexical rather than serving a matrix that does not belong to the deployed corpus/model. The check is ordered model-then-corpus so a model mismatch never reads the corpus file. The mismatch is now observable two ways, not just via degraded: - Pipeline::load emits a prominent stderr warning naming the packed path and the reason when the index is a Mismatch. - IndexStatus gains a Display (ok / absent / mismatch: ) and the human diagnostics line carries an index= token, e.g. "[3 segments, mode Lexical, 12 ms, degraded=false, index=mismatch: corpus sha256]". The index_status also rides in the JSON diagnostics (it was already a serialized Diagnostics field from iteration 1). index_status is orthogonal to degraded: a transient IONOS degrade leaves index_status=Ok, a structural binding problem sets Mismatch. Tests (67 green, +6 net): hermetic unit tests for the model- and corpus-hash-mismatch rejections and a matching-pair acceptance, a Display render test, a lib test that a Mode Hybrid suggest under a mismatch is degraded with index_status=Mismatch (degrading before any IONOS post, so no network), an end-to-end CLI test asserting the load warning and the index=mismatch token reach stderr, and the existing transient-degrade test extended with the index_status=Ok foil. The iteration-1 packed_beats_store_builds_without_store test was repaired (hermetic temp corpus + a real header hash) since the new semantic enforcement necessarily rejects its previous structural-only-era placeholder hash; its intent (packed beats store) is unchanged. No IONOS calls are made by any test. The per-file store, embedding build, and the VectorIndex are untouched. mmap remains rejected (spec § Out of scope). Spec: docs/specs/2026-05-31-packed-versioned-index-bundle.md Plan: docs/plans/2026-06-01-packed-versioned-bundle-iter2.md closes #4 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bin/alpha_id.rs | 5 ++- src/model.rs | 10 +++++ src/pipeline.rs | 29 +++++++++--- tests/index_mismatch_tests.rs | 78 ++++++++++++++++++++++++++++++++ tests/packed_load_tests.rs | 81 +++++++++++++++++++++++++++++++++- tests/pipeline_hybrid_tests.rs | 5 ++- 6 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 tests/index_mismatch_tests.rs 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); }