refactor: move the index --pack writer into the packed module (closes #5)

The `alpha-id index --pack` writer logic — store-completeness check, row
collection in corpus order, header derivation (dim/corpus_name/corpus_sha256/
n_rows), and the write — lived inline in main, leaving the header construction
outside library test reach (covered only end-to-end via the pack CLI tests).
This extracts it into packed::pack_from_store, returning
PackOutcome { Packed(PackedHeader), Incomplete { cached, total } }. main's pack
branch now just loads entries, calls the fn, and matches the outcome onto the
same stderr messages and exit code 2 as before.

Behaviour is unchanged: the stderr lines ("store incomplete: N of M entries
embedded; run `index --full --confirm` first" and "packed N rows × D dims →
path") and the exit-2 refusal are byte-identical, and the four existing pack
CLI tests (pack_refuses_incomplete_store_exit_2, pack_writes_file_from_complete_store,
cli_packed_file_loads_back_as_ok_index, cli_packed_rows_are_in_corpus_order)
stay green unchanged as the behaviour-parity guard. Two new unit tests now
exercise the header construction directly without spawning the binary: a
complete store yields PackOutcome::Packed with the expected n_rows/dim/
embed_model/corpus_sha256 and writes the file; an incomplete store yields
PackOutcome::Incomplete { cached, total } and writes nothing.

Minor improvement folded in: the old inline `store.get(...).expect("cached
vector")` is now a propagated AppError inside pack_from_store rather than a
panic (the branch stays unreachable — the complete-store precondition is
checked first — so parity holds).

This is the debt tidy the cycle-close audit deferred (architect [low] finding).
87 tests green.

closes #5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 01:35:06 +02:00
parent dabb3d2c79
commit 7b9d84be87
3 changed files with 147 additions and 27 deletions
+9 -19
View File
@@ -85,34 +85,24 @@ fn main() {
use alpha_id::ionos::IonosClient; use alpha_id::ionos::IonosClient;
if pack { if pack {
use alpha_id::packed::{corpus_sha256, packed_path, write_packed, PackedHeader}; use alpha_id::packed::{pack_from_store, packed_path, PackOutcome};
let entries = alpha_id::corpus::load(&cfg.alpha_id_path).expect("corpus"); let entries = alpha_id::corpus::load(&cfg.alpha_id_path).expect("corpus");
let store = EmbeddingStore::open(&cfg.index_dir, &cfg.embed_model).expect("store"); match pack_from_store(&cfg.index_dir, &cfg.embed_model, &cfg.alpha_id_path, &entries)
let cached = entries.iter().filter(|e| store.has(&e.text)).count(); .expect("pack")
if cached != entries.len() { {
PackOutcome::Incomplete { cached, total } => {
eprintln!( eprintln!(
"error: store incomplete: {} of {} entries embedded; \ "error: store incomplete: {} of {} entries embedded; \
run `index --full --confirm` first", run `index --full --confirm` first",
cached, entries.len() cached, total
); );
std::process::exit(2); std::process::exit(2);
} }
let rows: Vec<Vec<f32>> = PackOutcome::Packed(header) => {
entries.iter().map(|e| store.get(&e.text).expect("cached vector")).collect();
let dim = rows.first().map(|r| r.len()).unwrap_or(0) as u32;
let corpus_name = std::path::Path::new(&cfg.alpha_id_path)
.file_name().and_then(|n| n.to_str())
.unwrap_or(&cfg.alpha_id_path).to_string();
let header = PackedHeader {
n_rows: entries.len() as u32,
dim,
embed_model: cfg.embed_model.clone(),
corpus_sha256: corpus_sha256(&cfg.alpha_id_path).expect("corpus hash"),
corpus_name,
};
let path = packed_path(&cfg.index_dir, &cfg.embed_model); let path = packed_path(&cfg.index_dir, &cfg.embed_model);
write_packed(&path, &header, &rows).expect("write packed");
eprintln!("packed {} rows × {} dims → {}", header.n_rows, header.dim, path.display()); eprintln!("packed {} rows × {} dims → {}", header.n_rows, header.dim, path.display());
}
}
return; return;
} }
+51 -1
View File
@@ -1,4 +1,5 @@
use crate::model::AppError; use crate::embed::EmbeddingStore;
use crate::model::{AlphaIdEntry, AppError};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fs; use std::fs;
@@ -35,6 +36,55 @@ pub fn corpus_sha256(path: &str) -> Result<String, AppError> {
Ok(h.finalize().iter().map(|x| format!("{:02x}", x)).collect()) Ok(h.finalize().iter().map(|x| format!("{:02x}", x)).collect())
} }
/// Result of a pack attempt: either the header that was written, or a report
/// that the per-file store is missing some corpus entry's embedding (in which
/// case nothing is written, and the caller refuses loudly).
pub enum PackOutcome {
Packed(PackedHeader),
Incomplete { cached: usize, total: usize },
}
/// Pack a complete per-file store into the single packed matrix file at
/// `packed_path(index_dir, embed_model)`, returning the header written. Rows
/// are collected in `entries` order (row `i` = `entries[i]`), so the packed
/// matrix is corpus-aligned. If any entry's embedding is not cached, returns
/// `Incomplete { cached, total }` and writes nothing.
pub fn pack_from_store(
index_dir: &str,
embed_model: &str,
alpha_id_path: &str,
entries: &[AlphaIdEntry],
) -> Result<PackOutcome, AppError> {
let store = EmbeddingStore::open(index_dir, embed_model)?;
let cached = entries.iter().filter(|e| store.has(&e.text)).count();
if cached != entries.len() {
return Ok(PackOutcome::Incomplete { cached, total: entries.len() });
}
let rows: Vec<Vec<f32>> = entries
.iter()
.map(|e| {
store
.get(&e.text)
.ok_or_else(|| AppError::Io("cached vector vanished during pack".to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
let dim = rows.first().map(|r| r.len()).unwrap_or(0) as u32;
let corpus_name = Path::new(alpha_id_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(alpha_id_path)
.to_string();
let header = PackedHeader {
n_rows: entries.len() as u32,
dim,
embed_model: embed_model.to_string(),
corpus_sha256: corpus_sha256(alpha_id_path)?,
corpus_name,
};
write_packed(&packed_path(index_dir, embed_model), &header, &rows)?;
Ok(PackOutcome::Packed(header))
}
/// Write the packed matrix: magic + format_version + header_len + JSON header /// Write the packed matrix: magic + format_version + header_len + JSON header
/// + pad-to-4 + row-major little-endian f32 payload. Atomic via temp+rename. /// + pad-to-4 + row-major little-endian f32 payload. Atomic via temp+rename.
pub fn write_packed(path: &Path, header: &PackedHeader, rows: &[Vec<f32>]) -> Result<(), AppError> { pub fn write_packed(path: &Path, header: &PackedHeader, rows: &[Vec<f32>]) -> Result<(), AppError> {
+80
View File
@@ -1,6 +1,10 @@
use alpha_id::packed::{corpus_sha256, read_packed, write_packed, PackedHeader}; use alpha_id::packed::{corpus_sha256, read_packed, write_packed, PackedHeader};
use std::io::Write; use std::io::Write;
use alpha_id::embed::EmbeddingStore;
use alpha_id::model::AlphaIdEntry;
use alpha_id::packed::{pack_from_store, packed_path, PackOutcome};
fn header(n_rows: u32, dim: u32) -> PackedHeader { fn header(n_rows: u32, dim: u32) -> PackedHeader {
PackedHeader { PackedHeader {
n_rows, n_rows,
@@ -44,3 +48,79 @@ fn read_packed_rejects_bad_magic() {
f.write_all(b"XXXX____________").unwrap(); f.write_all(b"XXXX____________").unwrap();
assert!(read_packed(&path).is_err()); assert!(read_packed(&path).is_err());
} }
fn mk_entry(text: &str) -> AlphaIdEntry {
AlphaIdEntry {
alpha_id: "A0001".to_string(),
valid: true,
icd_primary: "A00".to_string(),
icd_star: String::new(),
icd_addon: String::new(),
icd_primary2: String::new(),
orpha: String::new(),
text: text.to_string(),
}
}
#[test]
fn pack_from_store_complete_builds_header_and_writes_file() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("index");
std::fs::create_dir_all(&index_dir).unwrap();
let corpus = dir.path().join("corpus.txt");
std::fs::write(&corpus, b"corpus bytes").unwrap();
let store = EmbeddingStore::open(index_dir.to_str().unwrap(), "test/model").unwrap();
store.put("alpha", &[1.0f32, 0.0, 0.0]).unwrap();
store.put("beta", &[0.0f32, 1.0, 0.0]).unwrap();
let entries = vec![mk_entry("alpha"), mk_entry("beta")];
let outcome = pack_from_store(
index_dir.to_str().unwrap(),
"test/model",
corpus.to_str().unwrap(),
&entries,
)
.unwrap();
match outcome {
PackOutcome::Packed(h) => {
assert_eq!(h.n_rows, 2);
assert_eq!(h.dim, 3);
assert_eq!(h.embed_model, "test/model");
assert_eq!(
h.corpus_sha256,
alpha_id::packed::corpus_sha256(corpus.to_str().unwrap()).unwrap()
);
}
PackOutcome::Incomplete { .. } => panic!("a complete store must pack"),
}
assert!(packed_path(index_dir.to_str().unwrap(), "test/model").exists());
}
#[test]
fn pack_from_store_incomplete_reports_counts_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("index");
std::fs::create_dir_all(&index_dir).unwrap();
let corpus = dir.path().join("corpus.txt");
std::fs::write(&corpus, b"corpus bytes").unwrap();
let store = EmbeddingStore::open(index_dir.to_str().unwrap(), "test/model").unwrap();
store.put("alpha", &[1.0f32, 0.0]).unwrap(); // only 1 of 2 entries cached
let entries = vec![mk_entry("alpha"), mk_entry("beta")];
let outcome = pack_from_store(
index_dir.to_str().unwrap(),
"test/model",
corpus.to_str().unwrap(),
&entries,
)
.unwrap();
assert!(matches!(outcome, PackOutcome::Incomplete { cached: 1, total: 2 }));
assert!(
!packed_path(index_dir.to_str().unwrap(), "test/model").exists(),
"an incomplete store must not write a packed file"
);
}