43b1b6fc25
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>
201 lines
8.7 KiB
Rust
201 lines
8.7 KiB
Rust
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);
|
||
}
|
||
}
|
||
}
|
||
}
|