Files
alpha-id/tests/index_mismatch_tests.rs
Brummel 79de4e1404 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>
2026-06-01 00:59:25 +02:00

79 lines
3.1 KiB
Rust

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}");
}