Skip to main content

yo_vector/
image.rs

1//! Writing a collection down, and reading it back without rebuilding it.
2//!
3//! `yo_format::image` is the layout and this is the part that fills it in. The
4//! split is the same one the rest of the build keeps: the format crate knows
5//! where a byte goes and nothing else, and the crate that owns the structure
6//! knows what the byte means.
7//!
8//! # Why an index is written down at all
9//!
10//! The records give back the vectors. They do not give back the shape. A
11//! collection of a million vectors is a few thousand partitions that arrived at
12//! their centroids through a long sequence of splits, merges and sweeps, and
13//! rebuilding that on open is a million quantisations and a lot of two means:
14//! minutes, on a machine that is meant to be answering. Every other index in
15//! this family has the same problem and most of them solve it by not solving it,
16//! which is what "the index warms up" means when a vendor says it.
17//!
18//! So a checkpoint writes the index down and an open reads it back. Nothing is
19//! requantised on the way in, because requantising is the rebuild.
20//!
21//! # The two halves of a load
22//!
23//! An image holds the codes and does not hold the vectors, and that is on
24//! purpose: the vectors are records of kind 3 already, at addresses the log
25//! resolves, and G8's budget is 96 bytes of index for a 768 dimensional vector
26//! with the raw copy in the log. An image that carried them too would write
27//! every vector twice to save a walk.
28//!
29//! So the caller brings them. [`Stored`] is that: something that can produce the
30//! vector a key was stored under, which for the engine is the log and for a test
31//! is a map. A key the store cannot produce is dropped rather than refused,
32//! which is the same answer [`Vectors`](crate::Vectors) gives for an id the log
33//! forgot: an index that heals is worth more than an index that is right about
34//! being unable to open.
35//!
36//! What comes back out of the store has to be what went in, which for a cosine
37//! collection is the unit vector rather than whatever the client sent, because
38//! that is what the collection stored and what its codes were measured against.
39//! [`Collection::get`] returns the same thing, so a store built out of one
40//! collection reloads another exactly.
41//!
42//! # The order things are written in
43//!
44//! Sections first, root last. A chain writes its chunks before its directory, so
45//! a directory that is readable has readable chunks, and this is the same rule
46//! one level up: a root that is readable points at sections that are all there.
47//! A crash between the two leaves chunks nobody points at, which is what
48//! compaction is for.
49
50use yo_common::{Addr, Code, Error, Result};
51use yo_format::image::{
52    Chain, ImageHeader, Keys, PostingHeader, get_floats, image_kind, image_len, key_entry_len,
53    metric, posting_len, put_floats, put_key, put_partition,
54};
55use yo_format::{get_f32, get_u64, put_u64};
56use yo_kv::cold::{self, Blocks, Scratch};
57use yo_shape::Metric;
58
59use crate::collection::{Collection, check_metric};
60use crate::partition::{Partitions, Tuning};
61use crate::rabitq::{Bits, Coded};
62
63/// Where the full precision vectors come back from when an image is loaded.
64///
65/// The engine answers this out of the record log. A test answers it out of a
66/// map. Either way it is asked once per key in the image and never again, and
67/// what it gives back has to be the stored form: see the note at the top of this
68/// module about cosine.
69pub trait Stored {
70    /// Write the vector stored under `key` into `into` and say so, or say that
71    /// the key is gone.
72    fn get(&self, key: &[u8], into: &mut [f32]) -> bool;
73}
74
75/// What came back from an image.
76#[derive(Debug)]
77pub struct Restored {
78    /// The collection.
79    pub collection: Collection,
80    /// How many keys the image named that the store could not produce.
81    ///
82    /// Zero on any pair of an image and a log that were written together. A
83    /// number here is a log that was compacted or truncated past the checkpoint
84    /// the image belongs to, and it is worth reporting rather than swallowing,
85    /// because it is the difference between a collection that is smaller than it
86    /// was and a collection that is smaller than it should be.
87    pub missing: usize,
88}
89
90impl Collection {
91    /// Write the collection down and say where the root went.
92    ///
93    /// The root's address and length are what a checkpoint entry records, so
94    /// this returns the pair rather than putting it anywhere: which checkpoint
95    /// this belongs to is the shard's business.
96    ///
97    /// # Errors
98    ///
99    /// [`Code::Full`] if a section is longer than a chain holds, which for the
100    /// centroids means a collection with more partitions than 512 MiB of them,
101    /// and whatever the store returns while it is being written to.
102    pub fn save<B: Blocks>(&self, blocks: &mut B, scratch: &mut Scratch) -> Result<Chain> {
103        let index = self.index();
104        let dim = index.dim();
105        let width = index.quantizer().code_bytes();
106        let count = index.partitions();
107
108        let mut buf = Vec::new();
109        let mut root = vec![0u8; image_len(as_u32(count)?)?];
110
111        // Every partition first, each its own chain, because a partition is the
112        // unit that can be brought back on its own and `10` section 2 says so.
113        for p in 0..count {
114            let (ids, tags, codes, meta, stuck) = index.posting_parts(p);
115            let head = PostingHeader {
116                count: as_u32(ids.len())?,
117                code_bytes: as_u32(width)?,
118                stuck: as_u32(stuck)?,
119            };
120            buf.clear();
121            buf.resize(posting_len(head.count, head.code_bytes)?, 0);
122            head.encode(&mut buf)?;
123            for (i, &id) in ids.iter().enumerate() {
124                put_u64(&mut buf, head.ids_at() + i * 8, id);
125            }
126            for (i, &tag) in tags.iter().enumerate() {
127                put_u64(&mut buf, head.tags_at() + i * 8, tag);
128            }
129            let at = head.codes_at();
130            buf[at..at + codes.len()].copy_from_slice(codes);
131            for (i, m) in meta.iter().enumerate() {
132                let at = head.meta_at() + i * 16;
133                put_floats(&mut buf[at..], &[m.norm, m.scale, m.lo, m.delta])?;
134            }
135            put_partition(&mut root, as_u32(p)?, write(blocks, &buf, scratch)?)?;
136        }
137
138        // The centroids, one run of floats, and the key table, one run of
139        // entries. Both are read whole at open, so neither is cut up further.
140        buf.clear();
141        buf.resize(index.all_centroids().len() * 4, 0);
142        put_floats(&mut buf, index.all_centroids())?;
143        let centroids = write(blocks, &buf, scratch)?;
144
145        buf.clear();
146        for (key, &id) in self.id_table().iter() {
147            let at = buf.len();
148            buf.resize(at + key_entry_len(key.len())?, 0);
149            put_key(&mut buf[at..], id, key)?;
150        }
151        let keys = write(blocks, &buf, scratch)?;
152
153        let tuning = index.tuning();
154        let head = ImageHeader {
155            kind: image_kind::VECTOR,
156            bits: as_u32(index.quantizer().bits().count())? as u8,
157            metric: metric_byte(self.metric()),
158            dim: as_u32(dim)?,
159            partitions: as_u32(count)?,
160            seed: index.quantizer().seed(),
161            members: self.len() as u64,
162            slots: as_u32(self.slots())?,
163            posting: as_u32(tuning.posting)?,
164            probe: as_u32(tuning.probe)?,
165            rerank: as_u32(tuning.rerank)?,
166            sweep: as_u32(tuning.sweep)?,
167            widen: as_u32(tuning.widen)?,
168            spill: as_u32(tuning.spill)?,
169            slack: tuning.slack,
170            patience: as_u32(tuning.patience)?,
171            centroids,
172            keys,
173        };
174        head.encode(&mut root)?;
175        write(blocks, &root, scratch)
176    }
177
178    /// Read a collection back out of an image, taking the vectors from `stored`.
179    ///
180    /// # Errors
181    ///
182    /// [`Code::Corrupt`] for an image that does not describe a collection this
183    /// build can hold: a kind or a metric it does not know, sections that
184    /// disagree with the header that named them, an id in two partitions, or an
185    /// id in a partition that the key table does not have.
186    pub fn load<B: Blocks>(blocks: &mut B, at: Chain, stored: &impl Stored) -> Result<Restored> {
187        let mut buf = Vec::new();
188        read(blocks, at, &mut buf)?;
189        let head = ImageHeader::decode(&buf)?;
190        if head.kind != image_kind::VECTOR {
191            return Err(
192                Error::new(Code::Corrupt, "that image is not a vector index")
193                    .with_detail(format!("kind={}", head.kind)),
194            );
195        }
196        let bits = match head.bits {
197            1 => Bits::One,
198            _ => Bits::Four,
199        };
200        let metric = metric_of(head.metric)?;
201        check_metric(metric)?;
202        let dim = head.dim as usize;
203        let root = std::mem::take(&mut buf);
204
205        let mut index = Partitions::new(
206            dim,
207            bits,
208            head.seed,
209            Tuning {
210                posting: head.posting as usize,
211                probe: head.probe as usize,
212                rerank: head.rerank as usize,
213                sweep: head.sweep as usize,
214                widen: head.widen as usize,
215                spill: head.spill as usize,
216                slack: head.slack,
217                patience: head.patience as usize,
218            },
219        );
220        let width = index.quantizer().code_bytes();
221
222        blocks.release();
223        read(blocks, head.centroids, &mut buf)?;
224        let mut centroids = vec![0f32; head.partitions as usize * dim];
225        get_floats(&buf, &mut centroids)?;
226
227        let mut members = Vec::new();
228        for p in 0..head.partitions {
229            blocks.release();
230            read(blocks, yo_format::image::get_partition(&root, p)?, &mut buf)?;
231            let post = PostingHeader::decode(&buf)?;
232            if post.code_bytes as usize != width {
233                return Err(
234                    Error::new(Code::Corrupt, "a partition's codes are the wrong width")
235                        .with_detail(format!("code_bytes={} want={width}", post.code_bytes)),
236                );
237            }
238            let n = post.count as usize;
239            let mut ids = Vec::with_capacity(n);
240            let mut tags = Vec::with_capacity(n);
241            let mut meta = Vec::with_capacity(n);
242            for i in 0..n {
243                let id = get_u64(&buf, post.ids_at() + i * 8);
244                if id >= u64::from(head.slots) {
245                    return Err(Error::new(Code::Corrupt, "a member's id is past the table")
246                        .with_detail(format!("id={id} slots={}", head.slots)));
247                }
248                ids.push(id);
249                tags.push(get_u64(&buf, post.tags_at() + i * 8));
250                let at = post.meta_at() + i * 16;
251                meta.push(Coded {
252                    norm: get_f32(&buf, at),
253                    scale: get_f32(&buf, at + 4),
254                    lo: get_f32(&buf, at + 8),
255                    delta: get_f32(&buf, at + 12),
256                });
257            }
258            let codes = buf[post.codes_at()..post.meta_at()].to_vec();
259            let at = p as usize * dim;
260            index.absorb(
261                &centroids[at..at + dim],
262                ids,
263                tags,
264                codes,
265                meta,
266                post.stuck as usize,
267            )?;
268            members.push(n);
269        }
270        index.finish_image();
271
272        blocks.release();
273        read(blocks, head.keys, &mut buf)?;
274        let mut collection = Collection::from_image(index, metric, head.slots as usize);
275        let mut vector = vec![0f32; dim];
276        let mut missing = 0;
277        let mut named = 0u64;
278        let mut walk = Keys::new(&buf);
279        for (id, key) in walk.by_ref() {
280            named += 1;
281            if !collection.holds(id) {
282                return Err(Error::new(
283                    Code::Corrupt,
284                    "the key table names an id no partition has",
285                )
286                .with_detail(format!("id={id}")));
287            }
288            if stored.get(key, &mut vector) {
289                collection.restore(key, id, &vector)?;
290            } else {
291                collection.forget(id);
292                missing += 1;
293            }
294        }
295        if !walk.done() {
296            return Err(Error::new(Code::Corrupt, "the key table ends mid entry"));
297        }
298        if named != head.members {
299            return Err(Error::new(
300                Code::Corrupt,
301                "the key table is not the length the header says",
302            )
303            .with_detail(format!("keys={named} members={}", head.members)));
304        }
305        collection.seal();
306        Ok(Restored {
307            collection,
308            missing,
309        })
310    }
311}
312
313/// One section, through the chunk chain, in the format's terms.
314fn write<B: Blocks>(blocks: &mut B, bytes: &[u8], scratch: &mut Scratch) -> Result<Chain> {
315    let chain = cold::write(blocks, bytes, scratch)?;
316    Ok(Chain {
317        at: chain.at.to_bits(),
318        len: chain.len,
319    })
320}
321
322/// The same section back, whole.
323fn read<B: Blocks>(blocks: &B, at: Chain, out: &mut Vec<u8>) -> Result<()> {
324    out.clear();
325    let reader = cold::Reader::open(
326        blocks,
327        cold::Chain {
328            at: Addr::from_bits(at.at),
329            len: at.len,
330        },
331    )?;
332    for piece in reader.range(0, at.len) {
333        out.extend_from_slice(piece?);
334    }
335    Ok(())
336}
337
338/// The byte an image writes a metric as.
339fn metric_byte(m: Metric) -> u8 {
340    match m {
341        Metric::L2 => metric::L2,
342        Metric::Cosine => metric::COSINE,
343        Metric::Ip => metric::IP,
344        Metric::Hamming => metric::HAMMING,
345    }
346}
347
348/// And back, for the four this version has a name for.
349fn metric_of(b: u8) -> Result<Metric> {
350    match b {
351        metric::L2 => Ok(Metric::L2),
352        metric::COSINE => Ok(Metric::Cosine),
353        metric::IP => Ok(Metric::Ip),
354        metric::HAMMING => Ok(Metric::Hamming),
355        _ => Err(Error::new(Code::Corrupt, "unknown metric in an image")
356            .with_detail(format!("metric={b}"))),
357    }
358}
359
360/// A count the format writes as a `u32`, refused rather than truncated.
361fn as_u32(n: usize) -> Result<u32> {
362    u32::try_from(n).map_err(|_| {
363        Error::new(Code::Full, "that count does not fit in an image").with_detail(format!("n={n}"))
364    })
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use std::collections::HashMap;
371
372    /// A store that keeps blobs in memory and hands back the index as the
373    /// address, which is enough to exercise every path here without a file.
374    struct Mem {
375        blobs: Vec<Vec<u8>>,
376    }
377
378    impl Mem {
379        fn new() -> Mem {
380            Mem { blobs: Vec::new() }
381        }
382    }
383
384    impl Blocks for Mem {
385        fn put(&mut self, bytes: &[u8]) -> Result<Addr> {
386            self.blobs.push(bytes.to_vec());
387            Ok(Addr::new(
388                yo_common::Space::Log,
389                (self.blobs.len() - 1) as u64,
390            ))
391        }
392
393        fn get(&self, at: Addr) -> Result<&[u8]> {
394            self.blobs
395                .get(at.offset() as usize)
396                .map(Vec::as_slice)
397                .ok_or_else(|| Error::new(Code::NotFound, "no such block"))
398        }
399
400        fn bytes(&self) -> u64 {
401            self.blobs.iter().map(|b| b.len() as u64).sum()
402        }
403    }
404
405    /// Everything the collection holds, keyed the way an image names it.
406    struct Table(HashMap<Vec<u8>, Vec<f32>>);
407
408    impl Table {
409        fn of(c: &Collection) -> Table {
410            let mut m = HashMap::new();
411            for key in c.keys() {
412                m.insert(
413                    key.to_vec(),
414                    c.get(key).expect("a key it just named").to_vec(),
415                );
416            }
417            Table(m)
418        }
419
420        fn without(mut self, key: &[u8]) -> Table {
421            self.0.remove(key);
422            self
423        }
424    }
425
426    impl Stored for Table {
427        fn get(&self, key: &[u8], into: &mut [f32]) -> bool {
428            let Some(v) = self.0.get(key) else {
429                return false;
430            };
431            into.copy_from_slice(v);
432            true
433        }
434    }
435
436    /// A deterministic spread of vectors, so that a corpus is the same on every
437    /// machine and a recall number means something when it is compared.
438    fn corpus(dim: usize, n: usize, seed: u64) -> Vec<(Vec<u8>, Vec<f32>)> {
439        let mut state = seed | 1;
440        let mut next = move || {
441            state ^= state << 13;
442            state ^= state >> 7;
443            state ^= state << 17;
444            (state >> 11) as f32 / (1u64 << 53) as f32 - 0.5
445        };
446        (0..n)
447            .map(|i| {
448                let key = format!("k{i}").into_bytes();
449                let v: Vec<f32> = (0..dim).map(|_| next()).collect();
450                (key, v)
451            })
452            .collect()
453    }
454
455    /// The corpus above gathered into a handful of clusters, which is what a
456    /// boundary needs in order to exist at all. Uniform points in a cube put
457    /// every centroid near the middle of it, so any two centroids are closer to
458    /// each other than either is to a vector, and no vector is ever near enough
459    /// to a second partition to be copied into it.
460    fn clustered(dim: usize, n: usize, clusters: usize, seed: u64) -> Vec<(Vec<u8>, Vec<f32>)> {
461        let centres = corpus(dim, clusters, seed);
462        corpus(dim, n, seed ^ 0x9e37)
463            .into_iter()
464            .enumerate()
465            .map(|(i, (key, off))| {
466                let centre = &centres[i % clusters].1;
467                let v = centre.iter().zip(&off).map(|(c, o)| c + o * 0.6).collect();
468                (key, v)
469            })
470            .collect()
471    }
472
473    fn built(dim: usize, n: usize, metric: Metric) -> Collection {
474        built_from(dim, metric, Tuning::default(), corpus(dim, n, 42))
475    }
476
477    fn built_from(
478        dim: usize,
479        metric: Metric,
480        tuning: Tuning,
481        vectors: Vec<(Vec<u8>, Vec<f32>)>,
482    ) -> Collection {
483        let mut c = Collection::new(dim, metric).expect("a collection");
484        c.retune(tuning);
485        for (i, (key, v)) in vectors.into_iter().enumerate() {
486            c.put_tagged(&key, &v, 1 << (i % 8)).expect("put");
487        }
488        c
489    }
490
491    /// A width, a corpus size and a posting size, shrunk together for Miri.
492    ///
493    /// Everything in this module is a round trip, and a round trip is the kind
494    /// of claim that holds at any size: the shape that went in is the shape that
495    /// comes back, the codes were not recomputed, a copy is still a copy. None
496    /// of it is a number about how well the index answers. So the three tests
497    /// that were too slow to run interpreted are made small rather than skipped,
498    /// which is worth doing here more than anywhere else in the crate, because
499    /// saving and loading is where the unaligned reads and the raw offsets live
500    /// and those are the only thing Miri is actually here for.
501    ///
502    /// The three move together for the reason the same helper in `partition`
503    /// gives. The corpus and the posting set the partition count between them,
504    /// and cutting the corpus on its own gives a collection with one partition
505    /// in it, which passes every assertion here without having saved anything
506    /// worth loading. The width is separate and is the one that costs the most:
507    /// a rotation is `dim` squared multiplications, so a collection 8 wide is a
508    /// sixteenth of the arithmetic of the same collection 32 wide, for a claim
509    /// that never mentions geometry.
510    fn shrunk(dim: usize, n: usize, posting: usize) -> (usize, usize, usize) {
511        if cfg!(miri) {
512            (dim.min(8), (n / 4).max(200), (posting / 8).max(24))
513        } else {
514            (dim, n, posting)
515        }
516    }
517
518    fn round_trip(c: &Collection, stored: &impl Stored) -> Restored {
519        let mut mem = Mem::new();
520        let mut scratch = Scratch::new();
521        let at = c.save(&mut mem, &mut scratch).expect("saved");
522        Collection::load(&mut mem, at, stored).expect("loaded")
523    }
524
525    #[test]
526    fn a_collection_comes_back_answering_the_same_questions() {
527        let (dim, n, posting) = shrunk(32, 900, Tuning::default().posting);
528        let tuning = Tuning {
529            posting,
530            ..Tuning::default()
531        };
532        let c = built_from(dim, Metric::L2, tuning, corpus(dim, n, 42));
533        let back = round_trip(&c, &Table::of(&c)).collection;
534
535        assert_eq!(back.len(), c.len());
536        assert_eq!(back.dim(), c.dim());
537        assert_eq!(back.metric(), c.metric());
538        assert!(
539            c.partitions() > 1,
540            "a collection that never split proves nothing"
541        );
542        assert_eq!(
543            back.partitions(),
544            c.partitions(),
545            "the shape is the thing an image exists to keep"
546        );
547        assert_eq!(back.tuning(), c.tuning());
548
549        // Identical answers rather than close ones: the codes were not
550        // recomputed, so the candidates are the same candidates and the rerank
551        // measures the same vectors. Queries the collection has never seen, so
552        // that this is a search and not a lookup.
553        for (_, q) in corpus(dim, if cfg!(miri) { 8 } else { 50 }, 7) {
554            assert_eq!(
555                back.search(&q, 10, None).expect("search"),
556                c.search(&q, 10, None).expect("search"),
557                "a query came back differently after a round trip"
558            );
559            assert_eq!(
560                back.search_where(&q, 10, None, &crate::Signature::from_bits(1 << 3))
561                    .expect("search"),
562                c.search_where(&q, 10, None, &crate::Signature::from_bits(1 << 3))
563                    .expect("search"),
564                "a filtered query came back differently, so a tag moved"
565            );
566        }
567    }
568
569    /// A collection with boundary copies in it is the one shape the loader used
570    /// to refuse outright, because an id in two partitions was the definition of
571    /// a corrupt image. The copies have to come back, and they have to come back
572    /// as copies rather than as two members.
573    ///
574    /// Clustered and slacker than the rest of the tests here on purpose, for the
575    /// reason [`clustered`] gives: the plain corpus makes no copies at all, so
576    /// the test would pass without ever exercising what it is named after.
577    #[test]
578    fn the_boundary_copies_survive_a_round_trip() {
579        let (dim, n, posting) = shrunk(32, 3000, Tuning::default().posting);
580        let tuning = Tuning {
581            slack: 0.25,
582            posting,
583            ..Tuning::default()
584        };
585        let c = built_from(dim, Metric::L2, tuning, clustered(dim, n, 12, 42));
586        assert!(
587            c.entries() > c.len(),
588            "a collection with no copies in it proves nothing here"
589        );
590        let back = round_trip(&c, &Table::of(&c)).collection;
591        assert_eq!(back.entries(), c.entries(), "a copy was lost");
592        assert_eq!(back.len(), c.len(), "a copy came back as a member");
593        assert_eq!(back.partitions(), c.partitions());
594    }
595
596    #[test]
597    fn every_vector_and_every_tag_survives() {
598        let c = built(16, 400, Metric::Cosine);
599        let back = round_trip(&c, &Table::of(&c)).collection;
600        for key in c.keys() {
601            assert_eq!(
602                back.get(key).map(<[f32]>::to_vec),
603                c.get(key).map(<[f32]>::to_vec),
604                "the vector under a key changed, bit for bit"
605            );
606            assert_eq!(back.tag(key), c.tag(key), "a tag was lost");
607        }
608    }
609
610    #[test]
611    fn a_reloaded_collection_takes_writes_where_it_left_off() {
612        let mut c = built(16, 300, Metric::L2);
613        let mut back = round_trip(&c, &Table::of(&c)).collection;
614
615        // The free list is the part of a load that is derived rather than
616        // stored, and the way to find out it is wrong is to allocate from it.
617        assert!(c.remove(b"k7"));
618        assert!(back.remove(b"k7"));
619        for (key, v) in corpus(16, 40, 99) {
620            let key = [b"new-".as_slice(), &key].concat();
621            c.put(&key, &v).expect("put");
622            back.put(&key, &v).expect("put");
623        }
624        assert_eq!(back.len(), c.len());
625        for key in c.keys() {
626            assert!(back.contains(key), "a key written after a load is missing");
627        }
628        let q = c.get(b"k1").expect("a vector").to_vec();
629        assert_eq!(
630            back.search(&q, 5, None).expect("search"),
631            c.search(&q, 5, None).expect("search")
632        );
633    }
634
635    /// `stuck` is the one thing an image carries that could have been derived
636    /// from the vectors and cannot be derived from the codes: it is the size at
637    /// which a split was tried and there was no cut to make. A thousand copies
638    /// of one vector is the case that produces it, and an image that dropped it
639    /// would have the first write after every open try that split again.
640    #[test]
641    fn a_partition_that_gave_up_splitting_does_not_try_again_after_a_load() {
642        let mut c = Collection::new(8, Metric::L2).expect("a collection");
643        for i in 0..1000 {
644            c.put(format!("same{i}").as_bytes(), &[0.5; 8])
645                .expect("put");
646        }
647        assert_eq!(c.maintain(1 << 20), 0, "it has already given up");
648
649        let mut back = round_trip(&c, &Table::of(&c)).collection;
650        assert_eq!(
651            back.maintain(1 << 20),
652            0,
653            "the load forgot that the split was hopeless and went looking again"
654        );
655        assert_eq!(back.partitions(), c.partitions());
656    }
657
658    #[test]
659    fn an_empty_collection_is_an_image_too() {
660        let c = Collection::new(8, Metric::L2).expect("a collection");
661        let back = round_trip(&c, &Table::of(&c)).collection;
662        assert!(back.is_empty());
663        assert_eq!(back.partitions(), 0);
664        assert!(back.search(&[0.0; 8], 4, None).expect("search").is_empty());
665    }
666
667    /// A section longer than a chunk is a directory and a run of chunks rather
668    /// than one record, and the key table is the section that gets there first:
669    /// five thousand short keys is already past 64 KiB. Nothing above this
670    /// module knows the difference, which is the thing being checked.
671    ///
672    /// What has to be past 64 KiB is the table, not the number of rows in it, so
673    /// under Miri it gets there on four hundred long keys instead of five
674    /// thousand short ones. That is the same section over the same chunk
675    /// boundary for an eighth of the writes, and the assertion below that some
676    /// section really was cut up is what says the substitution worked.
677    #[test]
678    fn a_section_longer_than_a_chunk_is_still_one_section() {
679        let (rows, pad) = if cfg!(miri) { (400, 160) } else { (5000, 0) };
680        let mut c = Collection::new(8, Metric::L2).expect("a collection");
681        for (i, (_, v)) in corpus(8, rows, 11).into_iter().enumerate() {
682            let key = format!("key{i}{}", "x".repeat(pad));
683            c.put(key.as_bytes(), &v).expect("put");
684        }
685
686        let mut mem = Mem::new();
687        let mut scratch = Scratch::new();
688        let at = c.save(&mut mem, &mut scratch).expect("saved");
689        assert!(
690            mem.blobs.len() > c.partitions() + 3,
691            "no section was cut up, so this proves nothing about chains"
692        );
693
694        let back = Collection::load(&mut mem, at, &Table::of(&c))
695            .expect("loaded")
696            .collection;
697        assert_eq!(back.len(), c.len());
698        for key in c.keys() {
699            assert!(
700                back.contains(key),
701                "a key on the far side of a chunk is gone"
702            );
703        }
704    }
705
706    #[test]
707    fn a_key_the_store_cannot_produce_is_dropped_and_counted() {
708        let c = built(16, 200, Metric::L2);
709        let restored = round_trip(&c, &Table::of(&c).without(b"k5"));
710        assert_eq!(restored.missing, 1);
711        let back = restored.collection;
712        assert_eq!(back.len(), c.len() - 1);
713        assert!(!back.contains(b"k5"));
714        // And the collection is whole afterwards rather than merely smaller: the
715        // slot is free, so the next write takes it.
716        let mut back = back;
717        back.put(b"k5", &[1.0; 16]).expect("put");
718        assert_eq!(back.len(), c.len());
719        assert_eq!(back.get(b"k5"), Some([1.0f32; 16].as_slice()));
720    }
721
722    #[test]
723    fn an_image_that_says_something_impossible_is_refused() {
724        let c = built(8, 120, Metric::L2);
725        let mut mem = Mem::new();
726        let mut scratch = Scratch::new();
727        let at = c.save(&mut mem, &mut scratch).expect("saved");
728        assert!(Collection::load(&mut mem, at, &Table::of(&c)).is_ok());
729
730        // The root is the last thing written, so it is the last blob, and every
731        // field in it is one a corrupt file could disagree about.
732        let root = mem.blobs.len() - 1;
733        for (at_byte, to) in [(4usize, 9u8), (5, 2), (6, 9), (7, 1)] {
734            let mut broken = Mem {
735                blobs: mem.blobs.clone(),
736            };
737            broken.blobs[root][at_byte] = to;
738            assert!(
739                Collection::load(&mut broken, at, &Table::of(&c)).is_err(),
740                "byte {at_byte} of the root was believed"
741            );
742        }
743
744        // A member count that does not match the key table, which is what a
745        // half written image would look like if the root went down first.
746        let mut lying = Mem {
747            blobs: mem.blobs.clone(),
748        };
749        put_u64(&mut lying.blobs[root], 24, 3);
750        assert!(Collection::load(&mut lying, at, &Table::of(&c)).is_err());
751    }
752}