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>
47 lines
1.5 KiB
Rust
47 lines
1.5 KiB
Rust
use alpha_id::packed::{corpus_sha256, read_packed, write_packed, PackedHeader};
|
|
use std::io::Write;
|
|
|
|
fn header(n_rows: u32, dim: u32) -> PackedHeader {
|
|
PackedHeader {
|
|
n_rows,
|
|
dim,
|
|
embed_model: "test/model".to_string(),
|
|
corpus_sha256: "deadbeef".to_string(),
|
|
corpus_name: "corpus.txt".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn write_read_roundtrip_preserves_header_and_rows() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("packed.bin");
|
|
let rows = vec![vec![0.0f32, 1.5, -2.25], vec![3.0, 4.0, 5.0]];
|
|
let h = header(2, 3);
|
|
write_packed(&path, &h, &rows).unwrap();
|
|
let (got_h, got_rows) = read_packed(&path).unwrap();
|
|
assert_eq!(got_h, h);
|
|
assert_eq!(got_rows, rows);
|
|
}
|
|
|
|
#[test]
|
|
fn corpus_sha256_stable_and_sensitive() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let p = dir.path().join("c.txt");
|
|
std::fs::write(&p, b"alpha|beta").unwrap();
|
|
let a = corpus_sha256(p.to_str().unwrap()).unwrap();
|
|
let b = corpus_sha256(p.to_str().unwrap()).unwrap();
|
|
assert_eq!(a, b, "same bytes hash identically");
|
|
std::fs::write(&p, b"alpha|betb").unwrap();
|
|
let c = corpus_sha256(p.to_str().unwrap()).unwrap();
|
|
assert_ne!(a, c, "one changed byte changes the hash");
|
|
}
|
|
|
|
#[test]
|
|
fn read_packed_rejects_bad_magic() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("bad.bin");
|
|
let mut f = std::fs::File::create(&path).unwrap();
|
|
f.write_all(b"XXXX____________").unwrap();
|
|
assert!(read_packed(&path).is_err());
|
|
}
|