feat: enforce + surface packed index binding (closes #4)
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: <reason>) and the human
diagnostics line carries an index=<status> 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) <noreply@anthropic.com>
This commit is contained in:
+3
-2
@@ -178,9 +178,10 @@ fn main() {
|
|||||||
for s in &res.suggestions {
|
for s in &res.suggestions {
|
||||||
println!("{:<8} {:>5.3} {}", s.icd_code, s.score, s.description);
|
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.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 } => {
|
Cmd::Eval { mode, cases, seed } => {
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ pub enum IndexStatus {
|
|||||||
Mismatch(String), // packed file present but structurally invalid; reason carried
|
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 {
|
impl FromStr for Mode {
|
||||||
type Err = String;
|
type Err = String;
|
||||||
fn from_str(s: &str) -> Result<Self, String> {
|
fn from_str(s: &str) -> Result<Self, String> {
|
||||||
|
|||||||
+22
-5
@@ -28,11 +28,12 @@ pub struct Pipeline {
|
|||||||
/// back to the per-file store. Returns the index (`None` when none is usable)
|
/// back to the per-file store. Returns the index (`None` when none is usable)
|
||||||
/// and an `IndexStatus` describing the path taken.
|
/// and an `IndexStatus` describing the path taken.
|
||||||
///
|
///
|
||||||
/// Iteration-1 scope: the packed file is validated *structurally* only
|
/// Validation: structural first (`read_packed` checks magic/version/payload
|
||||||
/// (`read_packed` checks magic/version/payload length; here we additionally
|
/// length; here we additionally require `n_rows == entries.len()`), then
|
||||||
/// require `n_rows == entries.len()`). The header's `corpus_sha256` and
|
/// semantic — the header's `embed_model` and `corpus_sha256` must match the
|
||||||
/// `embed_model` are NOT yet compared against the config — that enforcement
|
/// live config, else the packed file is refused as a `Mismatch`. A `Mismatch`
|
||||||
/// is iteration 2.
|
/// 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<VectorIndex>, IndexStatus) {
|
pub fn load_packed_or_store(cfg: &Config, entries: &[AlphaIdEntry]) -> (Option<VectorIndex>, IndexStatus) {
|
||||||
let packed = packed::packed_path(&cfg.index_dir, &cfg.embed_model);
|
let packed = packed::packed_path(&cfg.index_dir, &cfg.embed_model);
|
||||||
if packed.exists() {
|
if packed.exists() {
|
||||||
@@ -48,8 +49,17 @@ pub fn load_packed_or_store(cfg: &Config, entries: &[AlphaIdEntry]) -> (Option<V
|
|||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if header.embed_model != cfg.embed_model {
|
||||||
|
return (None, IndexStatus::Mismatch("embed model".to_string()));
|
||||||
|
}
|
||||||
|
match packed::corpus_sha256(&cfg.alpha_id_path) {
|
||||||
|
Ok(h) if h == header.corpus_sha256 => {
|
||||||
return (Some(VectorIndex::from_vectors(rows)), IndexStatus::Ok);
|
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}"))),
|
Err(e) => return (None, IndexStatus::Mismatch(format!("{e}"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +92,13 @@ impl Pipeline {
|
|||||||
m
|
m
|
||||||
};
|
};
|
||||||
let (vector, index_status) = load_packed_or_store(cfg, &entries);
|
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 {
|
Ok(Self {
|
||||||
entries,
|
entries,
|
||||||
meta,
|
meta,
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use alpha_id::model::{AlphaIdEntry, Config, IndexStatus};
|
use alpha_id::model::{AlphaIdEntry, Config, IndexStatus};
|
||||||
use alpha_id::packed::{packed_path, write_packed, PackedHeader};
|
use alpha_id::packed::{packed_path, write_packed, PackedHeader};
|
||||||
|
use alpha_id::packed::corpus_sha256;
|
||||||
use alpha_id::pipeline::load_packed_or_store;
|
use alpha_id::pipeline::load_packed_or_store;
|
||||||
|
|
||||||
fn entry(text: &str) -> AlphaIdEntry {
|
fn entry(text: &str) -> AlphaIdEntry {
|
||||||
@@ -36,14 +37,19 @@ fn index_status_serializes_externally_tagged() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn packed_beats_store_builds_without_store() {
|
fn packed_beats_store_builds_without_store() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
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 entries = vec![entry("a"), entry("b")];
|
||||||
let rows = vec![vec![1.0f32, 0.0], vec![0.0f32, 1.0]];
|
let rows = vec![vec![1.0f32, 0.0], vec![0.0f32, 1.0]];
|
||||||
let h = PackedHeader {
|
let h = PackedHeader {
|
||||||
n_rows: 2,
|
n_rows: 2,
|
||||||
dim: 2,
|
dim: 2,
|
||||||
embed_model: cfg.embed_model.clone(),
|
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(),
|
corpus_name: "c.txt".to_string(),
|
||||||
};
|
};
|
||||||
write_packed(&packed_path(&cfg.index_dir, &cfg.embed_model), &h, &rows).unwrap();
|
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!(vector.is_none());
|
||||||
assert!(matches!(status, IndexStatus::Mismatch(_)));
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use alpha_id::pipeline::Pipeline;
|
use alpha_id::pipeline::Pipeline;
|
||||||
use alpha_id::model::{Config, Filter, Mode};
|
use alpha_id::model::{Config, Filter, IndexStatus, Mode};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hybrid_degrades_to_lexical_when_ionos_unreachable() {
|
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.diagnostics.degraded); // fell back
|
||||||
assert!(!res.suggestions.is_empty()); // still answered
|
assert!(!res.suggestions.is_empty()); // still answered
|
||||||
assert!(res.suggestions.iter().any(|s| s.icd_code.starts_with("E11")));
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user