1use std::{
32 fs::File,
33 io::{BufReader, BufWriter, Read, Write},
34 path::Path,
35};
36
37use crate::{
38 PlaidError,
39 Result,
40 codec::{ResidualCodec, packed_bytes_per_vector},
41 index::{Index, IndexParams, build_inverted_file_from_flat},
42};
43
44const MAGIC: &[u8; 8] = b"PLAIDIDX";
45const FORMAT_VERSION: u32 = 2;
51
52pub fn save(index: &Index, path: &Path) -> Result<()> {
65 let file = File::create(path)?;
66 let mut writer = BufWriter::new(file);
67 write_index(index, &mut writer)?;
68 writer.flush()?;
69 Ok(())
70}
71
72pub fn load(path: &Path) -> Result<Index> {
83 let file = File::open(path)?;
84 let mut reader = BufReader::new(file);
85 read_index(&mut reader)
86}
87
88fn write_index<W: Write>(index: &Index, w: &mut W) -> Result<()> {
89 w.write_all(MAGIC)?;
90 write_u32(w, FORMAT_VERSION)?;
91
92 let params = &index.params;
93 write_u32(w, params.dim as u32)?;
94 write_u32(w, params.nbits)?;
95 write_u32(w, params.k_centroids as u32)?;
96 write_u32(w, params.max_kmeans_iters as u32)?;
97 write_u64(w, index.doc_ids.len() as u64)?;
98
99 write_f32_slice(w, &index.codec.centroids)?;
100 write_f32_slice(w, &index.codec.bucket_cutoffs)?;
101 write_f32_slice(w, &index.codec.bucket_weights)?;
102
103 write_u64_slice(w, &index.doc_ids)?;
104
105 let token_counts: Vec<u32> = (0..index.num_documents())
106 .map(|i| index.doc_token_count(i) as u32)
107 .collect();
108 write_u32_slice(w, &token_counts)?;
109
110 let packed_bytes = packed_bytes_per_vector(params.dim, params.nbits);
111 if index.doc_residual_bytes.len() != index.num_tokens() * packed_bytes {
112 return Err(PlaidError::InvalidIndex(format!(
113 "doc_residual_bytes length {} is not tokens ({}) × packed_bytes ({packed_bytes})",
114 index.doc_residual_bytes.len(),
115 index.num_tokens(),
116 )));
117 }
118 for i in 0..index.num_tokens() {
119 write_u32(w, index.doc_centroid_ids[i])?;
120 w.write_all(
121 &index.doc_residual_bytes[i * packed_bytes..(i + 1) * packed_bytes],
122 )?;
123 }
124
125 Ok(())
126}
127
128fn read_index<R: Read>(r: &mut R) -> Result<Index> {
129 let mut magic = [0u8; 8];
130 r.read_exact(&mut magic)?;
131 if &magic != MAGIC {
132 return Err(PlaidError::InvalidIndex(
133 "not a docbert-plaid index (magic bytes mismatch)".into(),
134 ));
135 }
136
137 let version = read_u32(r)?;
138 if version != FORMAT_VERSION {
139 return Err(PlaidError::InvalidIndex(format!(
140 "unsupported plaid index version {version}, expected {FORMAT_VERSION}",
141 )));
142 }
143
144 let dim = read_u32(r)? as usize;
145 let nbits = read_u32(r)?;
146 let k_centroids = read_u32(r)? as usize;
147 let max_kmeans_iters = read_u32(r)? as usize;
148 let n_documents = read_u64(r)? as usize;
149
150 if dim == 0 || k_centroids == 0 || !matches!(nbits, 1 | 2 | 4 | 8) {
151 return Err(PlaidError::InvalidIndex(
152 "plaid index header has invalid dim/k_centroids/nbits".into(),
153 ));
154 }
155
156 let params = IndexParams {
157 dim,
158 nbits,
159 k_centroids,
160 max_kmeans_iters,
161 };
162
163 let centroids = read_f32_vec(r, k_centroids * dim)?;
164 let num_buckets = 1usize << nbits;
165 let bucket_cutoffs = read_f32_vec(r, num_buckets - 1)?;
166 let bucket_weights = read_f32_vec(r, num_buckets)?;
167
168 let doc_ids = read_u64_vec(r, n_documents)?;
169 let token_counts = read_u32_vec(r, n_documents)?;
170 let packed_bytes = packed_bytes_per_vector(dim, nbits);
171
172 let total_tokens: usize = token_counts.iter().map(|&c| c as usize).sum();
173 let mut doc_centroid_ids: Vec<u32> = Vec::with_capacity(total_tokens);
174 let mut doc_residual_bytes: Vec<u8> =
175 Vec::with_capacity(total_tokens * packed_bytes);
176 let mut doc_offsets: Vec<usize> = Vec::with_capacity(n_documents + 1);
177 doc_offsets.push(0);
178
179 for count in token_counts.iter() {
180 for _ in 0..*count {
181 let centroid_id = read_u32(r)?;
182 if (centroid_id as usize) >= k_centroids {
183 return Err(PlaidError::InvalidIndex(format!(
184 "centroid_id {centroid_id} out of range 0..{k_centroids}",
185 )));
186 }
187 doc_centroid_ids.push(centroid_id);
188 let start = doc_residual_bytes.len();
189 doc_residual_bytes.resize(start + packed_bytes, 0);
190 r.read_exact(&mut doc_residual_bytes[start..start + packed_bytes])?;
191 }
192 doc_offsets.push(doc_centroid_ids.len());
193 }
194 let ivf = build_inverted_file_from_flat(
195 &doc_centroid_ids,
196 &doc_offsets,
197 k_centroids,
198 );
199
200 let codec = ResidualCodec {
201 nbits,
202 dim,
203 centroids,
204 bucket_cutoffs,
205 bucket_weights,
206 };
207 codec.validate()?;
208
209 Ok(Index {
210 params,
211 codec,
212 doc_ids,
213 doc_centroid_ids,
214 doc_residual_bytes,
215 doc_offsets,
216 ivf,
217 })
218}
219
220fn write_u32<W: Write>(w: &mut W, v: u32) -> Result<()> {
221 w.write_all(&v.to_le_bytes())?;
222 Ok(())
223}
224
225fn write_u64<W: Write>(w: &mut W, v: u64) -> Result<()> {
226 w.write_all(&v.to_le_bytes())?;
227 Ok(())
228}
229
230fn write_f32_slice<W: Write>(w: &mut W, slice: &[f32]) -> Result<()> {
231 w.write_all(bytemuck::cast_slice(slice))?;
232 Ok(())
233}
234
235fn write_u32_slice<W: Write>(w: &mut W, slice: &[u32]) -> Result<()> {
236 w.write_all(bytemuck::cast_slice(slice))?;
237 Ok(())
238}
239
240fn write_u64_slice<W: Write>(w: &mut W, slice: &[u64]) -> Result<()> {
241 w.write_all(bytemuck::cast_slice(slice))?;
242 Ok(())
243}
244
245fn read_u32<R: Read>(r: &mut R) -> Result<u32> {
246 let mut buf = [0u8; 4];
247 r.read_exact(&mut buf)?;
248 Ok(u32::from_le_bytes(buf))
249}
250
251fn read_u64<R: Read>(r: &mut R) -> Result<u64> {
252 let mut buf = [0u8; 8];
253 r.read_exact(&mut buf)?;
254 Ok(u64::from_le_bytes(buf))
255}
256
257fn read_f32_vec<R: Read>(r: &mut R, n: usize) -> Result<Vec<f32>> {
258 let mut out = vec![0.0f32; n];
259 r.read_exact(bytemuck::cast_slice_mut(&mut out))?;
260 Ok(out)
261}
262
263fn read_u32_vec<R: Read>(r: &mut R, n: usize) -> Result<Vec<u32>> {
264 let mut out = vec![0u32; n];
265 r.read_exact(bytemuck::cast_slice_mut(&mut out))?;
266 Ok(out)
267}
268
269fn read_u64_vec<R: Read>(r: &mut R, n: usize) -> Result<Vec<u64>> {
270 let mut out = vec![0u64; n];
271 r.read_exact(bytemuck::cast_slice_mut(&mut out))?;
272 Ok(out)
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::index::{DocumentTokens, build_index};
279
280 fn small_corpus() -> Vec<DocumentTokens> {
281 vec![
282 DocumentTokens {
283 doc_id: 10,
284 tokens: vec![0.0, 0.0, 0.1, 0.2, -0.1, 0.1],
285 n_tokens: 3,
286 },
287 DocumentTokens {
288 doc_id: 20,
289 tokens: vec![10.0, 10.0, 10.2, 9.9, 9.8, 10.1],
290 n_tokens: 3,
291 },
292 DocumentTokens {
293 doc_id: 30,
294 tokens: vec![0.3, -0.2, 9.7, 10.2],
295 n_tokens: 2,
296 },
297 ]
298 }
299
300 fn default_params() -> IndexParams {
301 IndexParams {
302 dim: 2,
303 nbits: 2,
304 k_centroids: 2,
305 max_kmeans_iters: 50,
306 }
307 }
308
309 #[test]
310 fn round_trip_preserves_codec_parameters_and_doc_ids() {
311 let tmp = tempfile::tempdir().unwrap();
312 let path = tmp.path().join("index.plaid");
313 let index = build_index(&small_corpus(), default_params()).unwrap();
314
315 save(&index, &path).unwrap();
316 let loaded = load(&path).unwrap();
317
318 assert_eq!(loaded.params.dim, index.params.dim);
319 assert_eq!(loaded.params.nbits, index.params.nbits);
320 assert_eq!(loaded.params.k_centroids, index.params.k_centroids);
321 assert_eq!(
322 loaded.params.max_kmeans_iters,
323 index.params.max_kmeans_iters
324 );
325 assert_eq!(loaded.doc_ids, index.doc_ids);
326 }
327
328 #[test]
329 fn round_trip_preserves_codec_tables_byte_for_byte() {
330 let tmp = tempfile::tempdir().unwrap();
331 let path = tmp.path().join("index.plaid");
332 let index = build_index(&small_corpus(), default_params()).unwrap();
333
334 save(&index, &path).unwrap();
335 let loaded = load(&path).unwrap();
336
337 assert_eq!(loaded.codec.centroids, index.codec.centroids);
338 assert_eq!(loaded.codec.bucket_cutoffs, index.codec.bucket_cutoffs);
339 assert_eq!(loaded.codec.bucket_weights, index.codec.bucket_weights);
340 }
341
342 #[test]
343 fn round_trip_preserves_encoded_tokens() {
344 let tmp = tempfile::tempdir().unwrap();
345 let path = tmp.path().join("index.plaid");
346 let index = build_index(&small_corpus(), default_params()).unwrap();
347
348 save(&index, &path).unwrap();
349 let loaded = load(&path).unwrap();
350
351 assert_eq!(loaded.num_documents(), index.num_documents());
352 for i in 0..index.num_documents() {
353 assert_eq!(loaded.doc_tokens_vec(i), index.doc_tokens_vec(i));
354 }
355 }
356
357 #[test]
358 fn round_trip_rebuilds_the_inverted_file() {
359 let tmp = tempfile::tempdir().unwrap();
360 let path = tmp.path().join("index.plaid");
361 let index = build_index(&small_corpus(), default_params()).unwrap();
362
363 save(&index, &path).unwrap();
364 let loaded = load(&path).unwrap();
365
366 assert_eq!(loaded.ivf.num_centroids(), index.ivf.num_centroids());
367 assert_eq!(
368 loaded.ivf.total_doc_postings(),
369 index.ivf.total_doc_postings(),
370 );
371 for c in 0..index.ivf.num_centroids() {
372 let want = index.ivf.docs_for_centroid(c);
373 let got = loaded.ivf.docs_for_centroid(c);
374 assert_eq!(got, want, "IVF list for centroid {c} differs");
375 }
376 }
377
378 #[test]
379 fn round_trip_preserves_search_results_exactly() {
380 let tmp = tempfile::tempdir().unwrap();
381 let path = tmp.path().join("index.plaid");
382 let index = build_index(&small_corpus(), default_params()).unwrap();
383 save(&index, &path).unwrap();
384 let loaded = load(&path).unwrap();
385
386 let query = [0.05f32, 0.1, 9.9, 10.1];
387 let params = crate::search::SearchParams {
388 top_k: 3,
389 n_probe: 2,
390 n_candidate_docs: None,
391 centroid_score_threshold: None,
392 };
393
394 let a = crate::search::search(&index, &query, params).unwrap();
395 let b = crate::search::search(&loaded, &query, params).unwrap();
396 assert_eq!(a, b);
397 }
398
399 #[test]
400 fn round_trip_handles_empty_documents() {
401 let tmp = tempfile::tempdir().unwrap();
402 let path = tmp.path().join("index.plaid");
403 let mut docs = small_corpus();
404 docs.push(DocumentTokens {
405 doc_id: 999,
406 tokens: vec![],
407 n_tokens: 0,
408 });
409 let index = build_index(&docs, default_params()).unwrap();
410
411 save(&index, &path).unwrap();
412 let loaded = load(&path).unwrap();
413
414 assert_eq!(loaded.doc_ids, index.doc_ids);
415 assert_eq!(loaded.doc_token_count(loaded.num_documents() - 1), 0);
416 }
417
418 #[test]
419 fn load_rejects_files_with_wrong_magic() {
420 let tmp = tempfile::tempdir().unwrap();
421 let path = tmp.path().join("bogus.plaid");
422 std::fs::write(&path, b"NOTPLAID").unwrap();
423
424 let err = load(&path).unwrap_err();
425 assert!(
426 matches!(err, PlaidError::InvalidIndex(ref m) if m.contains("magic")),
427 "expected InvalidIndex with magic message, got {err:?}",
428 );
429 }
430
431 #[test]
432 fn load_rejects_unknown_format_version() {
433 let tmp = tempfile::tempdir().unwrap();
434 let path = tmp.path().join("future.plaid");
435 let mut buf = Vec::new();
436 buf.extend_from_slice(MAGIC);
437 buf.extend_from_slice(&999u32.to_le_bytes());
438 std::fs::write(&path, &buf).unwrap();
439
440 let err = load(&path).unwrap_err();
441 assert!(
442 matches!(err, PlaidError::InvalidIndex(ref m) if m.contains("version")),
443 "expected InvalidIndex with version message, got {err:?}",
444 );
445 }
446
447 #[test]
448 fn load_rejects_legacy_unpacked_format_version_one() {
449 let tmp = tempfile::tempdir().unwrap();
452 let path = tmp.path().join("legacy.plaid");
453 let mut buf = Vec::new();
454 buf.extend_from_slice(MAGIC);
455 buf.extend_from_slice(&1u32.to_le_bytes());
456 std::fs::write(&path, &buf).unwrap();
457
458 let err = load(&path).unwrap_err();
459 assert!(
460 matches!(err, PlaidError::InvalidIndex(ref m) if m.contains("version")),
461 "expected InvalidIndex with version message, got {err:?}",
462 );
463 }
464}