Files
alpha-id/src/bin/alpha_id.rs
T
Brummel 43b1b6fc25 feat: packed single-file embedding index (closes #3)
The semantic index was ~90 896 per-entry files (index/embeddings/<safe-model>/
<sha256>.f32), a deployment liability: the file count — not the 356 MB — is
hostile to container layers, object storage, and Git-LFS, and Pipeline::load
opened every one individually. This packs a complete per-file store into one
self-describing matrix file loaded in a single read.

New src/packed.rs owns the format: 4-byte magic "AIPK", u32 LE format_version,
u32 LE header length, a JSON PackedHeader {n_rows, dim, embed_model,
corpus_sha256, corpus_name}, pad to a 4-byte boundary, then row-major
little-endian f32 payload in corpus order (row i = corpus entry i). write_packed
is atomic (temp+rename); read_packed rejects a wrong magic, a newer-than-
supported version, a malformed header, or a payload length inconsistent with
n_rows×dim. corpus_sha256 hashes the raw corpus file bytes.

Pipeline::load now calls a free fn load_packed_or_store(cfg, entries) that
prefers the packed file and falls back to today's all-or-nothing per-file store,
reporting which path it took via a new IndexStatus {Ok, Absent, Mismatch}
carried into Diagnostics. `alpha-id index --pack` writes the file from a
complete store (refusing an incomplete one with exit 2), recording corpus_sha256
+ embed_model in the header as the artifact-binding data.

Iteration boundary (deliberate): this is iteration 1 of the packed-versioned-
bundle cycle. load_packed_or_store validates the packed file STRUCTURALLY only
(magic/version/payload length via read_packed, plus n_rows == entries.len());
it does NOT yet compare the header's corpus_sha256/embed_model against the live
config, and IndexStatus::Mismatch fires only on structural corruption. The
load-time mismatch warning, the index= token on the diagnostics line, and the
Hybrid-degrade-names-reason wiring are iteration 2 (issue #4), which consumes
the binding data this commit writes. The per-file store is untouched — it stays
the build/resume path; the packed file is an additive serve-path artifact.

mmap was considered and rejected: the brute-force full-scan cosine search
touches every row per query, so memory-mapping yields no runtime benefit over a
single read and would add an unsafe dependency (spec § Out of scope).

Verification: cargo test green (61 tests; 9 new — 3 format round-trip/hash/bad-
magic, 4 load-path incl. packed-beats-store and structural n_rows mismatch, 2
--pack CLI incl. exit-2 refusal). An end-to-end seam test writes the packed
file via the CLI, removes the per-file store, and asserts the pipeline builds
its index from the packed file alone as IndexStatus::Ok (#3 acceptance b).

Spec: docs/specs/2026-05-31-packed-versioned-index-bundle.md
Plan: docs/plans/2026-06-01-packed-versioned-bundle-iter1.md

closes #3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:42:08 +02:00

201 lines
8.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use alpha_id::model::{normalize_chapters, Config, Filter, Mode};
use alpha_id::pipeline::Pipeline;
use clap::{Parser, Subcommand};
use std::io::Read;
#[derive(Parser)]
#[command(name = "alpha-id")]
struct Cli {
#[arg(long, default_value = "config/default.toml", global = true)]
config: String,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Build indexes (lexical now; embeddings added in Phase A)
Index { #[arg(long)] sample: Option<usize>, #[arg(long)] full: bool, #[arg(long)] confirm: bool, #[arg(long)] pack: bool },
/// Suggest ICD codes for a dictation (FILE or - for stdin)
Suggest {
input: String,
#[arg(long, default_value = "lexical")] mode: String,
#[arg(long, default_value_t = 10)] top: usize,
#[arg(long)] billable_only: bool,
#[arg(long)] valid_only: bool,
#[arg(long)] exclude_exotic: bool,
#[arg(long)] chapters: Option<String>,
#[arg(long)] json: bool,
},
/// Run the eval harness
Eval {
#[arg(long, default_value = "lexical")] mode: String,
#[arg(long, default_value_t = 200)] cases: usize,
#[arg(long, default_value_t = 42)] seed: u64,
},
}
fn run_eval(p: &Pipeline, m: Mode, cases: usize, seed: u64) -> (f64, f64) {
use alpha_id::eval::{inject_errors, recall_at_k};
use rand::SeedableRng;
use rand::seq::SliceRandom;
let all_billable: Vec<_> = p.entries.iter().enumerate()
.filter(|(_, e)| e.valid)
.filter(|(_, e)| alpha_id::corpus::primary_code(e)
.and_then(|c| p.meta.get(c))
.map(|mt| mt.para295 == "P").unwrap_or(false))
.collect();
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let n = cases.min(all_billable.len());
let billable: Vec<_> = all_billable
.choose_multiple(&mut rng, n)
.cloned()
.collect();
let mut sum = 0.0;
for (k, (_, e)) in billable.iter().enumerate() {
let code = alpha_id::corpus::primary_code(e).unwrap().to_string();
let noisy = inject_errors(&e.text, 0.15, seed.wrapping_add(k as u64));
let res = p.suggest(&noisy, m, &Filter::default(), 10);
let preds: Vec<String> = res.suggestions.iter().map(|s| s.icd_code.clone()).collect();
sum += recall_at_k(&preds, &code, 10);
}
let n = billable.len().max(1) as f64;
// Both metrics are equal by construction: the loop runs only over
// billable cases, so Recall@10 == BillableRecall@10 here.
(sum / n, sum / n)
}
fn read_input(arg: &str) -> String {
if let Ok(v) = std::env::var("ALPHA_ID_STDIN") { return v; }
if arg == "-" {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s).ok();
s
} else {
std::fs::read_to_string(arg).unwrap_or_default()
}
}
fn main() {
let cli = Cli::parse();
let cfg = Config::load(&cli.config).expect("config");
match cli.cmd {
Cmd::Index { sample, full, confirm, pack } => {
use alpha_id::embed::{EmbeddingStore, build_corpus_embeddings, estimate};
use alpha_id::ionos::IonosClient;
if pack {
use alpha_id::packed::{corpus_sha256, packed_path, write_packed, PackedHeader};
let entries = alpha_id::corpus::load(&cfg.alpha_id_path).expect("corpus");
let store = EmbeddingStore::open(&cfg.index_dir, &cfg.embed_model).expect("store");
let cached = entries.iter().filter(|e| store.has(&e.text)).count();
if cached != entries.len() {
eprintln!(
"error: store incomplete: {} of {} entries embedded; \
run `index --full --confirm` first",
cached, entries.len()
);
std::process::exit(2);
}
let rows: Vec<Vec<f32>> =
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);
write_packed(&path, &header, &rows).expect("write packed");
eprintln!("packed {} rows × {} dims → {}", header.n_rows, header.dim, path.display());
return;
}
let p = Pipeline::load(&cfg).expect("load");
let texts: Vec<String> = p.entries.iter().map(|e| e.text.clone()).collect();
let client = IonosClient::new(&cfg.ionos_base_url, &cfg.token_path)
.expect("ionos client (token?)");
let store = EmbeddingStore::open(&cfg.index_dir, &cfg.embed_model).expect("store");
if let Some(n) = sample {
let subset: Vec<String> = texts.iter().take(n).cloned().collect();
let est = estimate(&subset);
eprintln!("SMOKE: embedding {} sample texts ({} chars)…", est.items, est.chars);
let n_done = build_corpus_embeddings(&client, &store, &cfg.embed_model, &subset, 32)
.expect("sample embed failed");
let dim = subset.first()
.and_then(|t| store.get(t))
.map(|v| v.len())
.unwrap_or(0);
eprintln!("SMOKE OK: embedded {n_done}, vector dim = {dim}. \
Inspect, then run `index --full --confirm`.");
return;
}
if full {
let pending: Vec<String> = texts.iter().filter(|t| !store.has(t)).cloned().collect();
let est = estimate(&pending);
eprintln!("FULL RUN: {} of {} texts not yet embedded ({} chars). \
This calls IONOS and costs money.", est.items, texts.len(), est.chars);
if !confirm {
eprintln!("Refusing without --confirm. Re-run: \
`alpha-id index --full --confirm`");
std::process::exit(2);
}
let n = build_corpus_embeddings(&client, &store, &cfg.embed_model, &texts, 32)
.expect("full embed failed");
eprintln!("FULL RUN done: {n} newly embedded, {} total cached.",
texts.iter().filter(|t| store.has(t)).count());
return;
}
eprintln!("Specify --sample N (smoke test first) or --full --confirm.");
}
Cmd::Suggest { input, mode, top, billable_only, valid_only, exclude_exotic, chapters, json } => {
let p = Pipeline::load(&cfg).expect("load");
let m: Mode = mode.parse().expect("mode");
let chapters = chapters.map(|c| {
let (padded, unknown) = normalize_chapters(&c);
for tok in &unknown {
eprintln!("warning: --chapters token '{tok}' matches no known chapter (01..22); ignored");
}
padded
});
let filter = Filter {
billable_only, valid_only, exclude_exotic,
chapters,
};
let text = read_input(&input);
let res = p.suggest(&text, m, &filter, top);
if json {
println!("{}", serde_json::to_string_pretty(&res).unwrap());
} else {
for s in &res.suggestions {
println!("{:<8} {:>5.3} {}", s.icd_code, s.score, s.description);
}
eprintln!("[{} segments, mode {}, {} ms, degraded={}]",
res.diagnostics.segment_count, res.diagnostics.mode,
res.diagnostics.millis, res.diagnostics.degraded);
}
}
Cmd::Eval { mode, cases, seed } => {
let modes: Vec<Mode> = if mode == "both" {
vec![Mode::Lexical, Mode::Hybrid]
} else {
vec![mode.parse().expect("mode")]
};
let p = Pipeline::load(&cfg).expect("load");
for m in modes {
let (r10, br10) = run_eval(&p, m, cases, seed);
println!("mode={:?} cases={} Recall@10={:.3} BillableRecall@10={:.3}",
m, cases, r10, br10);
}
}
}
}