Skip to main content

znippy_plugin_git/
oid_index.rs

1//! `__gunnar_oid__` — the reserved oid index.
2//!
3//! A raw (non-Arrow) section holding an **`stree`** keyspace over the first eight
4//! bytes of every object id, plus, per entry, the full oid, the first lookup row
5//! of that object's chunk run, and its object ordinal.
6//!
7//! ## Why stree and not the stock fst trie
8//!
9//! Git oids are fixed-width and uniformly random, so they share no prefixes: an
10//! fst gets no prefix compression, still walks its automaton byte by byte, and
11//! has **no batch path**. `stree` (`znippy-zoomies/src/stree.rs`) is built for
12//! sorted fixed-width keys — one cache-line node, branchless AVX2 compare, and a
13//! software-pipelined batch traversal. Serving one git pack is thousands of
14//! lookups, so the batch path is the whole point.
15//!
16//! ## The 8-byte prefix is NOT a key — it is a filter
17//!
18//! Two distinct oids can share their first eight bytes. It is vanishingly
19//! unlikely and it is **not impossible**, so the key is treated as what it is: a
20//! filter that narrows to a short candidate run, after which the **full oid is
21//! compared**. `lookup` is only ever correct because of that comparison;
22//! [`GitOidIndex::candidate_run`] exposes the unverified run precisely so a test
23//! can prove the verify step is load-bearing rather than decoration.
24//!
25//! ## Section layout (little-endian)
26//!
27//! ```text
28//!   0  magic  b"ZNPYGOID"                8
29//!   8  u32 version                       4
30//!  12  u8  hash code (1=sha1, 2=sha256)  1
31//!  13  u8  oid_len (20 or 32)            1
32//!  14  u16 header_len (24 or 64)         2   ← was `reserved`, see OidLayout
33//!  16  u64 count                         8
34//!  24  u8  pad [header_len - 24]             ← zero, only when header_len > 24
35//!  HL  i64 keys   [count]                8*count   ← sorted ascending, the stree keyspace
36//!      u64 rows   [count]                8*count   ← first lookup row of that oid
37//!      u32 ords   [count]                4*count   ← object ordinal (oid-lexicographic)
38//!      u8  oids   [count * oid_len]                ← full oid, for the verify step
39//! ```
40//!
41//! ## Where the keyspace sits against the cache line — [`OidLayout`]
42//!
43//! `header_len` exists because the header offset decides the *cache-line phase*
44//! of the key array, and the key array is what `stree`'s leaf scan reads. See
45//! [`OidLayout`] for what each value means, what it costs, and — importantly —
46//! what it cannot reach.
47//!
48//! ### Measured: alignment buys 5% fewer cache misses and no time at all
49//!
50//! oden, 32-core Threadripper PRO 3975WX, 2026-08-07,
51//! `--release --no-default-features`, sha1 oids, `examples/oid_align_bench.rs`,
52//! three arms, arm order rotated so each is timed first exactly once,
53//! `/proc/loadavg` 1-min 0.58–1.30 throughout.
54//!
55//! **The hypothesis this tested was half right, and the half that was right does
56//! not pay.** The prediction was that a 24-byte header makes every `stree` node
57//! straddle two lines, "two misses per level instead of one". Two corrections
58//! fell out of reading the source before measuring:
59//!
60//! 1. **The internal nodes are not in this section.** `STree64Mmap` builds them
61//!    into its own `Vec<[i64; 8]>`; the section holds only the *leaf* layer. So
62//!    the header can move **one** access per lookup, not one per level.
63//! 2. **A 64-byte header alone does nothing.** `Vec<u8>` is align-1 by type.
64//!    Measured `Compact` phases at 4e6 objects were 40 and 56 on different runs
65//!    — glibc's 16-mod-64 chunk base plus the header. The allocation has to move
66//!    too, which is why [`OidLayout::Compact64Alloc`] exists as the control.
67//!
68//! Counters attributable to the `ordinals_batch` loop alone (a `dry` run with
69//! only the lookup call removed is subtracted, so query construction and chunk
70//! iteration cancel), 4e6 objects, 6e6 lookups per run, median of four passes,
71//! per 1e6 lookups:
72//!
73//! | counter | compact24 | compact24+align | aligned64 | aligned/compact |
74//! |---|---:|---:|---:|---:|
75//! | instructions | 838 617 005 | 838 610 396 | 838 561 628 | 1.000 |
76//! | cache-references | 31 853 436 | 32 191 979 | 31 976 234 | 1.004 |
77//! | **cache-misses** | **14 981 126** | 14 752 419 | **14 206 088** | **0.948** |
78//! | dTLB-load-misses | 4 624 378 | 4 620 465 | 4 615 620 | 0.998 |
79//! | cycles | 1 390 271 493 | 1 286 488 175 | 1 280 146 067 | 0.921 |
80//!
81//! The identical `instructions` count is the guard that the three arms really
82//! are one code path. The **5.2% cache-miss reduction reproduced in all four
83//! passes** (0.944 / 0.947 / 0.953 / 0.956) and the three arms order themselves
84//! by phase — 40 → 24 → 0 — exactly as the mechanism predicts. `dTLB` does not
85//! move, which it should not: alignment changes lines, not pages. `cycles`
86//! points the same way but its per-pass ratios scatter 0.849–0.998, so it is
87//! reported and not claimed.
88//!
89//! **And none of it is visible in time.** 24 cells (4 sizes × 2 hit mixes ×
90//! 3 batch sizes), 100 000 queries, 5 runs, 3 rotations: **noise band median
91//! 8.2%, p90 30.2%**, and `aligned/compact` geomean **0.998**, range
92//! 0.855–1.109 — **0 of 24 cells clear their own band**. Re-run at 4e6 with
93//! 1 000 000 queries and 9 runs to tighten the band to **median 5.0%, p90 8.5%**:
94//! ratios 0.999–1.052, still 0 of 6 clearing, and the direction is now
95//! consistently *against* the aligned arm by ~1%.
96//!
97//! The 0.77-fewer-misses-per-lookup is real and is worth about 60 ns if it were
98//! ever exposed. It is not exposed, and the reason is the thing `stree` was
99//! chosen for: `lookup_batch_pipeline` keeps eight queries in flight and
100//! prefetches the next level, so the leaf touch overlaps with seven others. A
101//! miss that the machine was already hiding does not become time when you
102//! remove it.
103//!
104//! **So the default stays [`OidLayout::Compact`]** — the 40 extra bytes per
105//! section and the parse-time copy into an aligned buffer buy a counter, not a
106//! latency. The apparatus stays because it is cheap, guarded, and the answer
107//! would otherwise have to be re-derived. What this does settle is that the
108//! 72–91% of a full-row lookup that [`crate::index_layout`] attributes to the
109//! oid step is **not** the header offset, and the next attempt on that step has
110//! to look elsewhere — `STree64Mmap`'s own internal-node `Vec<[i64; 8]>` is at
111//! glibc's mercy exactly the way this section was, and that one *is* a node per
112//! level rather than one leaf touch. It lives in `znippy-zoomies` and is not
113//! answerable from this crate.
114//!
115//! ## The key is order-preserving, and the sign bit is why
116//!
117//! `i64::from_be_bytes(oid[..8])` — the literal reading of "the first eight bytes
118//! as an i64" — is **not** order-preserving over oid bytes: an oid whose first
119//! byte is `0x80` or higher goes negative and sorts before every oid starting
120//! `0x00..0x7f`, which is roughly half the keyspace on the wrong side. The key
121//! here therefore flips the top bit, `(u64::from_be_bytes(first8) ^ (1 << 63)) as
122//! i64`, which maps unsigned order onto signed order exactly. Key rank is then
123//! oid-lexicographic rank.
124//!
125//! ## …and the parallel arrays are still not redundant
126//!
127//! With an order-preserving key it is tempting to drop both parallel arrays and
128//! read them off the rank. Only one of the two can go:
129//!
130//! * `ords` equals the rank for every index [`crate::sections::GitIndexBuilder`]
131//!   builds, because that is where the ordinal is defined and it numbers the same
132//!   oid-lexicographic sequence. It is still stored, because [`build_section`] is
133//!   the lower-level API and its contract does **not** require the caller's
134//!   ordinal to be a rank — the ordinal is the `__gunnar_reach__` bitmap space,
135//!   and a caller indexing a subset of a larger archive has ordinals from the
136//!   larger space. Dropping the array is 4 bytes per object and a narrower
137//!   contract; it is not free, and it is not done here.
138//! * `rows` is **not** the rank and cannot become it. A lookup row is a *chunk*
139//!   row: an object above `file_split_block_size` occupies several consecutive
140//!   rows, and the lookup covers **every** path in the archive, not only git
141//!   objects — an archive holding anything besides the object store has git rows
142//!   that are not contiguous at all. `rows` is monotonic in rank and equal to it
143//!   only in the special case of a single-chunk, git-only archive.
144
145use std::alloc::{Layout, alloc, dealloc};
146use std::ops::Deref;
147use std::path::Path;
148use std::ptr::NonNull;
149
150use anyhow::{Result, bail, ensure};
151use znippy_common::read_reserved_section_bytes;
152use znippy_common::GUNNAR_OID_MODULE;
153use znippy_zoomies::stree::STree64Mmap;
154
155use crate::object::GitHashKind;
156
157pub const GIT_OID_MAGIC: [u8; 8] = *b"ZNPYGOID";
158/// Bumped 1 → 2 when the key became order-preserving. A v1 section holds the same
159/// bytes in the same places but sorted on a different key, so a v2 reader walking
160/// it would return wrong rows rather than fail — which is why the reader below
161/// requires an **exact** match instead of `<=`.
162///
163/// Bumped 2 → 3 when byte 14 stopped being `reserved` and became `header_len`.
164/// A v2 section has `0` there, which a v3 reader would read as a zero-length
165/// header and then walk the magic as keys, so again: exact match, not `<=`.
166pub const GIT_OID_VERSION: u32 = 3;
167
168/// The fixed part of the header — magic through `count`. A section's real header
169/// is `header_len` bytes and is never shorter than this.
170const HEADER_FIXED: usize = 24;
171
172/// Where the key array starts, and therefore how the `stree` keyspace sits
173/// against the 64-byte cache line.
174///
175/// ## What this actually controls, and what it does not
176///
177/// `stree`'s internal B-tree nodes are 8 × `i64` = one cache line each, but they
178/// do **not** live in this section: `STree64Mmap` builds them into its own
179/// `Vec<[i64; 8]>`. Nothing in this header can move them. What the section owns
180/// is the **leaf layer** — the sorted key array that `find_exact` and
181/// `lookup_batch_pipeline` linear-scan (up to `B + 1` = 9 keys) once the tree has
182/// routed them to a block. So this enum moves exactly one memory access per
183/// lookup: the leaf touch, which is also the coldest one.
184///
185/// A leaf block is `8 * 8` = 64 bytes. Its phase against the line is
186/// `keyspace_base mod 64`, and `keyspace_base` is
187/// `allocation_base + header_len` — which is why one arm is not enough to
188/// separate the two terms:
189///
190/// | variant | header | allocation | keyspace phase | leaf lines touched |
191/// |---|---:|---|---:|---:|
192/// | [`Compact`](OidLayout::Compact) | 24 | `Vec<u8>`, align-1 by type | unknown, not 0 | 2, sometimes 3 |
193/// | [`Compact64Alloc`](OidLayout::Compact64Alloc) | 24 | 64-aligned | 24 | 2, sometimes 3 |
194/// | [`Aligned64`](OidLayout::Aligned64) | 64 | 64-aligned | **0** | 1, plus the overflow key |
195///
196/// `Compact64Alloc` is the control that makes the experiment readable: it holds
197/// the allocator constant and moves only the offset, so a difference between it
198/// and `Compact` is the allocator and a difference between it and `Aligned64` is
199/// the phase.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
201pub enum OidLayout {
202    /// 24-byte header, plain `Vec<u8>`. What every section written before this
203    /// enum existed looks like, and the arm every other arm is measured against.
204    ///
205    /// `Vec<u8>` is `align_of::<u8>() == 1` *by type*. Large allocations happen
206    /// to come back 16-byte aligned from glibc, but that is allocator behaviour
207    /// and not a guarantee — which is exactly why the aligned arms below cannot
208    /// be built by hoping.
209    #[default]
210    Compact,
211    /// 24-byte header, 64-aligned allocation. Keyspace at phase 24.
212    Compact64Alloc,
213    /// 64-byte header, 64-aligned allocation. Keyspace at phase 0 — every
214    /// `stree` leaf block is exactly one cache line.
215    Aligned64,
216}
217
218impl OidLayout {
219    pub const fn header_len(self) -> usize {
220        match self {
221            OidLayout::Compact | OidLayout::Compact64Alloc => 24,
222            OidLayout::Aligned64 => 64,
223        }
224    }
225
226    /// Whether [`GitOidIndex::parse`] must re-home the bytes into a 64-aligned
227    /// allocation. Not recorded in the section: it is a property of the reader's
228    /// heap, not of the bytes, and a section written by any arm parses correctly
229    /// under any of them.
230    const fn wants_aligned_alloc(self) -> bool {
231        !matches!(self, OidLayout::Compact)
232    }
233
234    pub const fn name(self) -> &'static str {
235        match self {
236            OidLayout::Compact => "compact24",
237            OidLayout::Compact64Alloc => "compact24+align",
238            OidLayout::Aligned64 => "aligned64",
239        }
240    }
241
242    pub const ALL: [OidLayout; 3] =
243        [OidLayout::Compact, OidLayout::Compact64Alloc, OidLayout::Aligned64];
244}
245
246/// A heap buffer whose base address is a multiple of 64.
247///
248/// `Vec<u8>` cannot promise this — its type alignment is 1 — so an arm that
249/// wants a cache-line-phased keyspace has to own its allocation. One `alloc`
250/// with an explicit 64-byte `Layout`, one `memcpy`, one `dealloc`.
251struct Aligned64Bytes {
252    ptr: NonNull<u8>,
253    len: usize,
254}
255
256// The buffer is immutable after construction and owned solely by the index.
257unsafe impl Send for Aligned64Bytes {}
258unsafe impl Sync for Aligned64Bytes {}
259
260impl Aligned64Bytes {
261    /// Zero-length input still allocates one 64-byte-aligned byte, so the
262    /// pointer is never dangling and `% 64 == 0` holds unconditionally.
263    fn copy_of(src: &[u8]) -> Self {
264        let len = src.len();
265        let layout = Layout::from_size_align(len.max(1), 64).expect("64-aligned layout");
266        // SAFETY: `layout` has non-zero size; the null return is checked.
267        let raw = unsafe { alloc(layout) };
268        let Some(ptr) = NonNull::new(raw) else {
269            std::alloc::handle_alloc_error(layout);
270        };
271        // SAFETY: `raw` owns `len.max(1)` bytes and `src` is a distinct slice.
272        unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), raw, len) };
273        Self { ptr, len }
274    }
275}
276
277impl Deref for Aligned64Bytes {
278    type Target = [u8];
279    fn deref(&self) -> &[u8] {
280        // SAFETY: `ptr` owns at least `len` initialised bytes for our lifetime.
281        unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
282    }
283}
284
285impl Drop for Aligned64Bytes {
286    fn drop(&mut self) {
287        let layout = Layout::from_size_align(self.len.max(1), 64).expect("64-aligned layout");
288        // SAFETY: same layout the allocation was made with.
289        unsafe { dealloc(self.ptr.as_ptr(), layout) };
290    }
291}
292
293/// The section's bytes, however the reader chose to hold them.
294enum SectionBytes {
295    Plain(Vec<u8>),
296    Aligned(Aligned64Bytes),
297}
298
299impl Deref for SectionBytes {
300    type Target = [u8];
301    fn deref(&self) -> &[u8] {
302        match self {
303            SectionBytes::Plain(v) => v,
304            SectionBytes::Aligned(a) => a,
305        }
306    }
307}
308
309/// Number of queries the batch walk keeps in flight. Swept 2026-08-10 on t14s
310/// (znippy-zoomies `examples/stree_lab.rs`, 8e6 keys × 200k queries): the
311/// fused walk runs 58.2 ns/q at P=8 → 21.9 ns/q at P=64, flat to P=128. The
312/// old value 8 came from the historical sweep of the *call* batch size
313/// (1/100/1000/10000, saturating at 100) — that sweep never varied this
314/// const-generic, and the two had been conflated.
315const BATCH_P: usize = 64;
316
317/// One object as the index records it.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct OidEntry {
320    /// Raw object id.
321    pub oid: Vec<u8>,
322    /// First row of this object's contiguous chunk run in the sorted lookup
323    /// sub-index.
324    pub lookup_row: u64,
325    /// Position of this object in the archive's oid-lexicographic ordering —
326    /// the ordinal space `__gunnar_reach__` bitmaps address.
327    pub ordinal: u32,
328}
329
330/// The key an oid maps into: its first eight bytes as a big-endian unsigned
331/// integer, with the top bit flipped so that unsigned order becomes signed order.
332///
333/// The flip is the whole point — `stree` compares `i64`, and without it every oid
334/// starting `0x80` or higher sorts before every oid starting `0x00..0x7f`. See the
335/// module docs.
336///
337/// Oids shorter than eight bytes cannot occur (sha1 is 20), but the function is
338/// total anyway: it zero-pads rather than panicking.
339pub fn key_for_oid(oid: &[u8]) -> i64 {
340    let mut b = [0u8; 8];
341    let n = oid.len().min(8);
342    b[..n].copy_from_slice(&oid[..n]);
343    (u64::from_be_bytes(b) ^ (1u64 << 63)) as i64
344}
345
346/// Serialize the `__gunnar_oid__` section in the default layout. `entries` may
347/// be in any order; they are sorted by key here, which is what `stree` requires.
348pub fn build_section(entries: &[OidEntry], hash: GitHashKind) -> Result<Vec<u8>> {
349    build_section_with_layout(entries, hash, OidLayout::default())
350}
351
352/// [`build_section`] with the header offset chosen explicitly. The only thing
353/// `layout` changes is `header_len` and the zero padding after it — the key,
354/// row, ordinal and oid arrays are byte-identical in every layout, which is what
355/// makes the arms comparable and what
356/// `aligned_and_compact_are_the_same_index_byte_for_byte` asserts.
357pub fn build_section_with_layout(
358    entries: &[OidEntry],
359    hash: GitHashKind,
360    layout: OidLayout,
361) -> Result<Vec<u8>> {
362    let header_len = layout.header_len();
363    let oid_len = hash.oid_len();
364    for e in entries {
365        ensure!(
366            e.oid.len() == oid_len,
367            "oid length {} does not match hash kind {:?}",
368            e.oid.len(),
369            hash
370        );
371    }
372    let mut order: Vec<usize> = (0..entries.len()).collect();
373    // Sort by (key, full oid) so a duplicate-key run has a deterministic layout.
374    order.sort_by(|&a, &b| {
375        key_for_oid(&entries[a].oid)
376            .cmp(&key_for_oid(&entries[b].oid))
377            .then_with(|| entries[a].oid.cmp(&entries[b].oid))
378    });
379
380    let n = entries.len();
381    let mut out = Vec::with_capacity(header_len + n * (8 + 8 + 4 + oid_len));
382    out.extend_from_slice(&GIT_OID_MAGIC);
383    out.extend_from_slice(&GIT_OID_VERSION.to_le_bytes());
384    out.push(hash.code());
385    out.push(oid_len as u8);
386    out.extend_from_slice(&(header_len as u16).to_le_bytes());
387    out.extend_from_slice(&(n as u64).to_le_bytes());
388    debug_assert_eq!(out.len(), HEADER_FIXED);
389    out.resize(header_len, 0);
390    for &i in &order {
391        out.extend_from_slice(&key_for_oid(&entries[i].oid).to_le_bytes());
392    }
393    for &i in &order {
394        out.extend_from_slice(&entries[i].lookup_row.to_le_bytes());
395    }
396    for &i in &order {
397        out.extend_from_slice(&entries[i].ordinal.to_le_bytes());
398    }
399    for &i in &order {
400        out.extend_from_slice(&entries[i].oid);
401    }
402    Ok(out)
403}
404
405/// What a successful lookup resolved to.
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub struct OidHit {
408    /// Index of the entry within the index (its position in key order).
409    pub entry: usize,
410    /// First lookup row of the object's chunk run.
411    pub lookup_row: u64,
412    /// Object ordinal (the `__gunnar_reach__` bitmap space).
413    pub ordinal: u32,
414}
415
416/// Reader over a `__gunnar_oid__` section.
417pub struct GitOidIndex {
418    bytes: SectionBytes,
419    header_len: usize,
420    count: usize,
421    oid_len: usize,
422    hash: GitHashKind,
423    /// `None` for an empty index — `STree64Mmap` requires `count > 0`.
424    tree: Option<STree64Mmap>,
425}
426
427impl GitOidIndex {
428    /// Parse a section produced by [`build_section`].
429    ///
430    /// The header width comes out of the section's own `header_len`. Whether the
431    /// bytes are re-homed into a 64-aligned allocation does **not** — that is a
432    /// property of this reader's heap, not of the bytes, and it is not
433    /// recorded anywhere. `parse` infers it: a 64-byte header exists only to put
434    /// the keyspace on a line boundary, which an arbitrary base would undo, so a
435    /// `header_len` of 64 implies the aligned allocation. See
436    /// [`parse_as`](Self::parse_as) for the third arm, whose whole point is that
437    /// the two are separable.
438    pub fn parse(bytes: Vec<u8>) -> Result<Self> {
439        Self::parse_inner(bytes, None)
440    }
441
442    /// [`parse`](Self::parse) with the reader's allocation choice forced to
443    /// `layout`'s, and the section's own header width checked against it.
444    ///
445    /// This exists for [`OidLayout::Compact64Alloc`], which is a 24-byte header
446    /// over a 64-aligned base — indistinguishable on disk from
447    /// [`OidLayout::Compact`], because the difference is in the heap. It is the
448    /// control arm of the alignment experiment: it holds the allocator fixed and
449    /// moves only the header offset.
450    pub fn parse_as(bytes: Vec<u8>, layout: OidLayout) -> Result<Self> {
451        Self::parse_inner(bytes, Some(layout))
452    }
453
454    fn parse_inner(bytes: Vec<u8>, want: Option<OidLayout>) -> Result<Self> {
455        ensure!(bytes.len() >= HEADER_FIXED, "__gunnar_oid__ section truncated");
456        ensure!(bytes[..8] == GIT_OID_MAGIC, "__gunnar_oid__ bad magic");
457        let version = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
458        ensure!(
459            version == GIT_OID_VERSION,
460            "__gunnar_oid__ is version {version}, this reader speaks {GIT_OID_VERSION} \
461             only — v1 sorted its keys on a non-order-preserving key and v2 had no \
462             header_len, so reading one here would return wrong rows instead of failing"
463        );
464        let Some(hash) = GitHashKind::from_code(bytes[12]) else {
465            bail!("__gunnar_oid__ unknown hash code {}", bytes[12]);
466        };
467        let oid_len = bytes[13] as usize;
468        ensure!(
469            oid_len == hash.oid_len(),
470            "__gunnar_oid__ oid_len {oid_len} disagrees with hash {hash:?}"
471        );
472        let header_len = u16::from_le_bytes(bytes[14..16].try_into().unwrap()) as usize;
473        // Only the widths an `OidLayout` can produce. An arbitrary value here
474        // would silently shift the whole keyspace, which reads as wrong rows
475        // rather than as an error.
476        let Some(inferred) = OidLayout::ALL.into_iter().find(|l| l.header_len() == header_len)
477        else {
478            bail!(
479                "__gunnar_oid__ header_len {header_len} is not a layout this reader knows \
480                 (24 or 64)"
481            );
482        };
483        let layout = match want {
484            Some(w) => {
485                ensure!(
486                    w.header_len() == header_len,
487                    "__gunnar_oid__ was written with a {header_len}-byte header, cannot be read \
488                     as {} ({} bytes)",
489                    w.name(),
490                    w.header_len()
491                );
492                w
493            }
494            None => inferred,
495        };
496        let count = u64::from_le_bytes(bytes[16..24].try_into().unwrap()) as usize;
497        let need = header_len
498            .checked_add(count.checked_mul(8 + 8 + 4 + oid_len).unwrap_or(usize::MAX))
499            .unwrap_or(usize::MAX);
500        ensure!(
501            bytes.len() >= need,
502            "__gunnar_oid__ declares {count} entries but section is {} bytes (needs {need})",
503            bytes.len()
504        );
505
506        // A 64-byte header only pays off if the allocation under it is
507        // 64-aligned too; `Vec<u8>` cannot promise that, so the aligned arms
508        // re-home the bytes once, at parse.
509        let bytes = if layout.wants_aligned_alloc() {
510            SectionBytes::Aligned(Aligned64Bytes::copy_of(&bytes))
511        } else {
512            SectionBytes::Plain(bytes)
513        };
514
515        let tree = if count == 0 {
516            None
517        } else {
518            let keys = &bytes[header_len..header_len + count * 8];
519            Some(STree64Mmap::new_with_stride(keys, count, 8))
520        };
521        Ok(Self { bytes, header_len, count, oid_len, hash, tree })
522    }
523
524    /// The layout this section was written in.
525    pub fn layout(&self) -> OidLayout {
526        OidLayout::ALL
527            .into_iter()
528            .find(|l| l.header_len() == self.header_len && l.wants_aligned_alloc() == self.is_aligned_alloc())
529            .unwrap_or(OidLayout::Compact)
530    }
531
532    fn is_aligned_alloc(&self) -> bool {
533        matches!(self.bytes, SectionBytes::Aligned(_))
534    }
535
536    /// Base address of the `stree` keyspace, modulo the cache line.
537    ///
538    /// This is the whole variable of the alignment experiment, exposed so a
539    /// guard can assert the arm it thinks it built is the arm it got — a
540    /// timing difference between two arms that turned out to share a phase
541    /// would be noise wearing a conclusion's clothes.
542    pub fn keyspace_phase(&self) -> usize {
543        self.keys().as_ptr() as usize % 64
544    }
545
546    /// Read the section out of a sealed archive. `Ok(None)` when the archive
547    /// carries no oid index (i.e. it is not a `git`-format archive).
548    pub fn open(archive: &Path) -> Result<Option<Self>> {
549        match read_reserved_section_bytes(archive, GUNNAR_OID_MODULE)? {
550            Some(b) => Ok(Some(Self::parse(b)?)),
551            None => Ok(None),
552        }
553    }
554
555    pub fn len(&self) -> usize {
556        self.count
557    }
558
559    pub fn is_empty(&self) -> bool {
560        self.count == 0
561    }
562
563    pub fn hash_kind(&self) -> GitHashKind {
564        self.hash
565    }
566
567    fn keys(&self) -> &[u8] {
568        &self.bytes[self.header_len..self.header_len + self.count * 8]
569    }
570
571    /// The key of entry `i`.
572    pub fn key_at(&self, i: usize) -> i64 {
573        let off = self.header_len + i * 8;
574        i64::from_le_bytes(self.bytes[off..off + 8].try_into().unwrap())
575    }
576
577    /// The full oid of entry `i`.
578    pub fn oid_at(&self, i: usize) -> &[u8] {
579        let base = self.header_len + self.count * (8 + 8 + 4) + i * self.oid_len;
580        &self.bytes[base..base + self.oid_len]
581    }
582
583    fn row_at(&self, i: usize) -> u64 {
584        let off = self.header_len + self.count * 8 + i * 8;
585        u64::from_le_bytes(self.bytes[off..off + 8].try_into().unwrap())
586    }
587
588    fn ordinal_at(&self, i: usize) -> u32 {
589        let off = self.header_len + self.count * 16 + i * 4;
590        u32::from_le_bytes(self.bytes[off..off + 4].try_into().unwrap())
591    }
592
593    /// The **unverified** candidate run for a key: every entry sharing that
594    /// 8-byte prefix, as `start..end`. Normally length 1; length > 1 is a real
595    /// prefix collision.
596    ///
597    /// Exposed so a test can assert that a collision actually produces a run of
598    /// two and that the verify step is what tells the two oids apart. A caller
599    /// resolving an oid should use [`lookup`](Self::lookup), never this.
600    pub fn candidate_run(&self, key: i64) -> std::ops::Range<usize> {
601        let Some(tree) = self.tree.as_ref() else { return 0..0 };
602        let Some(pos) = tree.find_exact(key, self.keys()) else { return 0..0 };
603        self.expand_run(pos, key)
604    }
605
606    /// Widen a hit to the whole run of equal keys. `stree` routes to *a* member
607    /// of the run; which member is an implementation detail, so both directions
608    /// are walked rather than assumed.
609    fn expand_run(&self, pos: usize, key: i64) -> std::ops::Range<usize> {
610        let mut lo = pos;
611        while lo > 0 && self.key_at(lo - 1) == key {
612            lo -= 1;
613        }
614        let mut hi = pos + 1;
615        while hi < self.count && self.key_at(hi) == key {
616            hi += 1;
617        }
618        lo..hi
619    }
620
621    /// Resolve a raw oid. `None` when absent.
622    ///
623    /// stree narrows to a candidate run; the full oid is then compared against
624    /// every candidate. Skipping that comparison would return a *different*
625    /// object's row whenever two oids share their first eight bytes.
626    pub fn lookup(&self, oid: &[u8]) -> Option<OidHit> {
627        if oid.len() != self.oid_len {
628            return None;
629        }
630        let tree = self.tree.as_ref()?;
631        let key = key_for_oid(oid);
632        let pos = tree.find_exact(key, self.keys())?;
633        self.verify(pos, key, oid)
634    }
635
636    /// Resolve a hex oid.
637    pub fn lookup_hex(&self, hex_oid: &str) -> Option<OidHit> {
638        if hex_oid.len() != self.oid_len * 2 {
639            return None;
640        }
641        let raw = hex::decode(hex_oid).ok()?;
642        self.lookup(&raw)
643    }
644
645    fn verify(&self, pos: usize, key: i64, oid: &[u8]) -> Option<OidHit> {
646        for i in self.expand_run(pos, key) {
647            if self.oid_at(i) == oid {
648                return Some(OidHit {
649                    entry: i,
650                    lookup_row: self.row_at(i),
651                    ordinal: self.ordinal_at(i),
652                });
653            }
654        }
655        None
656    }
657
658    /// Resolve many oids at once through stree's software-pipelined batch
659    /// traversal. This is the path that matters: serving one pack is hundreds to
660    /// thousands of lookups, and the pipelined walk overlaps their memory
661    /// latency instead of paying it serially.
662    ///
663    /// Results are positional — `out[i]` corresponds to `oids[i]`. Every hit is
664    /// full-oid verified, exactly as in [`lookup`](Self::lookup).
665    pub fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<OidHit>> {
666        let Some(tree) = self.tree.as_ref() else { return vec![None; oids.len()] };
667        let keys: Vec<i64> = oids.iter().map(|o| key_for_oid(o)).collect();
668        // `lookup_batch_fused`, not `lookup_batch_pipeline`: the pipeline's
669        // route-sort-scan shape exists for a cold 144 GB OSM mmap, where the
670        // sort converts random page faults into sequential readahead. This
671        // keyspace is an in-RAM section, where the sort was measured as a
672        // ~40 ns/query tax and the leaf misses sat outside the pipeline
673        // (2026-08-10, stree_lab: pipeline::<8> ~100 ns/q vs fused::<64>
674        // 21.9 ns/q at 8e6 keys).
675        let raw = tree.lookup_batch_fused::<BATCH_P>(&keys, self.keys());
676        // The verify loop below needs no software prefetch — tried and
677        // reverted 2026-08-10: a 32-query prefetch lag over `oid_at`/`row_at`/
678        // `ordinal_at` measured 110.0 → 108.5 ns on git_oid_lookup_8m, inside
679        // noise. The iterations are independent, so the out-of-order window
680        // already overlaps their three loads across ~10 queries; the misses
681        // this loop pays were never serial.
682        raw.into_iter()
683            .zip(oids.iter())
684            .enumerate()
685            .map(|(i, (pos, oid))| {
686                if oid.len() != self.oid_len {
687                    return None;
688                }
689                self.verify(pos?, keys[i], oid)
690            })
691            .collect()
692    }
693
694    /// The **baseline** the stree keyspace has to beat: `std::binary_search` over
695    /// the very same sorted key array, followed by the very same full-oid verify.
696    ///
697    /// It exists only under `bench-kernels`, and it exists so the choice of stree
698    /// is a measurement rather than an argument. Anything cheaper than this would
699    /// not be the same question: it derives the key the same way, expands the
700    /// equal-key run the same way, and compares the same 20 or 32 bytes — the
701    /// only difference is how it finds the run.
702    #[cfg(feature = "bench-kernels")]
703    pub fn lookup_binary_search(&self, oid: &[u8]) -> Option<OidHit> {
704        if oid.len() != self.oid_len || self.count == 0 {
705            return None;
706        }
707        let key = key_for_oid(oid);
708        // The keys live little-endian in `bytes`; read them through `key_at` so
709        // there is one decoder, not two.
710        let mut lo = 0usize;
711        let mut hi = self.count;
712        while lo < hi {
713            let mid = lo + (hi - lo) / 2;
714            if self.key_at(mid) < key { lo = mid + 1 } else { hi = mid }
715        }
716        if lo >= self.count || self.key_at(lo) != key {
717            return None;
718        }
719        self.verify(lo, key, oid)
720    }
721
722    /// Hex convenience over [`lookup_batch`](Self::lookup_batch).
723    pub fn lookup_batch_hex(&self, hex_oids: &[&str]) -> Vec<Option<OidHit>> {
724        let raw: Vec<Vec<u8>> = hex_oids.iter().map(|h| hex::decode(h).unwrap_or_default()).collect();
725        let refs: Vec<&[u8]> = raw.iter().map(|v| v.as_slice()).collect();
726        self.lookup_batch(&refs)
727    }
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    fn oid(bytes: &[u8], len: usize) -> Vec<u8> {
735        let mut v = bytes.to_vec();
736        v.resize(len, 0);
737        v
738    }
739
740    fn idx(entries: Vec<OidEntry>, hash: GitHashKind) -> GitOidIndex {
741        GitOidIndex::parse(build_section(&entries, hash).unwrap()).unwrap()
742    }
743
744    #[test]
745    fn resolves_every_entry_it_was_built_from() {
746        // 300 entries → tall enough that stree has real internal layers.
747        let n = 300usize;
748        let entries: Vec<OidEntry> = (0..n)
749            .map(|i| {
750                let mut o = [0u8; 32];
751                o[..8].copy_from_slice(&(i as u64).wrapping_mul(0x0123_4567_89ab_cdef).to_be_bytes());
752                o[8] = (i % 251) as u8;
753                OidEntry { oid: o.to_vec(), lookup_row: (i * 3) as u64, ordinal: i as u32 }
754            })
755            .collect();
756        let index = idx(entries.clone(), GitHashKind::Sha256);
757        assert_eq!(index.len(), n);
758        for e in &entries {
759            let hit = index.lookup(&e.oid).unwrap_or_else(|| panic!("miss for {}", hex::encode(&e.oid)));
760            assert_eq!(hit.lookup_row, e.lookup_row);
761            assert_eq!(hit.ordinal, e.ordinal);
762        }
763        // And an oid that is NOT in the index must miss.
764        let mut absent = entries[0].oid.clone();
765        absent[31] ^= 0xff;
766        assert!(index.lookup(&absent).is_none());
767    }
768
769    /// LAW 2 — the collision case, constructed rather than hoped for.
770    ///
771    /// Two oids that agree on their first eight bytes and differ after. They
772    /// share one stree key, so the tree alone cannot tell them apart; only the
773    /// full-oid comparison can. The assertions below are on the *applied
774    /// output* (the two rows resolved), so an implementation that dropped the
775    /// verify and returned the first candidate would return the same row twice
776    /// and fail here.
777    #[test]
778    fn eight_byte_prefix_collision_is_resolved_by_the_full_oid() {
779        let prefix = [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04];
780        let mut a = oid(&prefix, 32);
781        let mut b = oid(&prefix, 32);
782        a[8] = 0xaa;
783        b[8] = 0xbb;
784        assert_eq!(key_for_oid(&a), key_for_oid(&b), "test premise: keys must collide");
785        assert_ne!(a, b);
786
787        // Some filler so the tree is not a single leaf block.
788        let mut entries = vec![
789            OidEntry { oid: a.clone(), lookup_row: 100, ordinal: 7 },
790            OidEntry { oid: b.clone(), lookup_row: 200, ordinal: 9 },
791        ];
792        for i in 0..64u64 {
793            let mut o = [0u8; 32];
794            o[..8].copy_from_slice(&i.wrapping_mul(0x1111_1111_1111_1111).to_be_bytes());
795            o[9] = 1;
796            entries.push(OidEntry { oid: o.to_vec(), lookup_row: 900 + i, ordinal: 100 + i as u32 });
797        }
798        let index = idx(entries, GitHashKind::Sha256);
799
800        // The collision is real in the built index: one key, two candidates.
801        let run = index.candidate_run(key_for_oid(&a));
802        assert_eq!(run.len(), 2, "expected a 2-entry candidate run, got {run:?}");
803        assert_eq!(index.key_at(run.start), index.key_at(run.start + 1));
804
805        // Applied output: the two oids resolve to their OWN rows.
806        let ha = index.lookup(&a).expect("a must resolve");
807        let hb = index.lookup(&b).expect("b must resolve");
808        assert_eq!(ha.lookup_row, 100);
809        assert_eq!(hb.lookup_row, 200);
810        assert_eq!(ha.ordinal, 7);
811        assert_eq!(hb.ordinal, 9);
812        assert_ne!(ha.lookup_row, hb.lookup_row);
813
814        // A third oid on the same prefix that was never inserted must MISS —
815        // a verify-less lookup would happily hand back a candidate's row.
816        let mut c = oid(&prefix, 32);
817        c[8] = 0xcc;
818        assert!(index.lookup(&c).is_none(), "unstored oid on a colliding prefix must miss");
819    }
820
821    #[test]
822    fn batch_path_agrees_with_the_serial_path_including_on_a_collision() {
823        let prefix = [0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
824        let mut a = oid(&prefix, 20);
825        let mut b = oid(&prefix, 20);
826        a[8] = 1;
827        b[8] = 2;
828        let mut entries = vec![
829            OidEntry { oid: a.clone(), lookup_row: 11, ordinal: 1 },
830            OidEntry { oid: b.clone(), lookup_row: 22, ordinal: 2 },
831        ];
832        for i in 0..200u64 {
833            let mut o = [0u8; 20];
834            o[..8].copy_from_slice(&(i.wrapping_mul(0x9e37_79b9_7f4a_7c15)).to_be_bytes());
835            o[10] = (i % 97) as u8;
836            entries.push(OidEntry { oid: o.to_vec(), lookup_row: 1000 + i, ordinal: 500 + i as u32 });
837        }
838        let index = idx(entries.clone(), GitHashKind::Sha1);
839
840        let mut queries: Vec<&[u8]> = entries.iter().map(|e| e.oid.as_slice()).collect();
841        let absent = oid(&[0xab, 0xcd, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44], 20);
842        queries.push(&absent);
843
844        let batched = index.lookup_batch(&queries);
845        assert_eq!(batched.len(), queries.len());
846        for (i, q) in queries.iter().enumerate() {
847            assert_eq!(batched[i], index.lookup(q), "batch/serial disagree at {i}");
848        }
849        assert!(batched.last().unwrap().is_none(), "absent oid must miss in the batch path too");
850        assert_eq!(batched[0].unwrap().lookup_row, 11);
851        assert_eq!(batched[1].unwrap().lookup_row, 22);
852    }
853
854    #[test]
855    fn empty_index_is_a_clean_miss_not_a_panic() {
856        let index = idx(Vec::new(), GitHashKind::Sha256);
857        assert!(index.is_empty());
858        assert!(index.lookup(&oid(&[1], 32)).is_none());
859        assert_eq!(index.lookup_batch(&[&oid(&[1], 32)[..]]), vec![None]);
860    }
861
862    #[test]
863    fn truncated_or_mislabelled_sections_are_rejected() {
864        let entries = vec![OidEntry { oid: oid(&[9], 20), lookup_row: 0, ordinal: 0 }];
865        let good = build_section(&entries, GitHashKind::Sha1).unwrap();
866        assert!(GitOidIndex::parse(good.clone()).is_ok());
867
868        let mut bad_magic = good.clone();
869        bad_magic[0] = b'X';
870        assert!(GitOidIndex::parse(bad_magic).is_err());
871
872        let mut newer = good.clone();
873        newer[8..12].copy_from_slice(&(GIT_OID_VERSION + 1).to_le_bytes());
874        assert!(GitOidIndex::parse(newer).is_err());
875
876        assert!(GitOidIndex::parse(good[..HEADER_FIXED + 4].to_vec()).is_err());
877        assert!(GitOidIndex::parse(Vec::new()).is_err());
878
879        // A header_len no layout can produce would shift the entire keyspace and
880        // return wrong rows rather than fail, so it is refused by name.
881        //
882        // Seen RED by relaxing the reader's `header_len() == header_len` to
883        // `>=`, which accepts 40 as "close enough to 64" and then reads the
884        // keyspace 24 bytes past where it was written: the panic below,
885        // "a header_len no layout can produce must be refused".
886        let mut bad_header = good.clone();
887        bad_header[14..16].copy_from_slice(&40u16.to_le_bytes());
888        let err = match GitOidIndex::parse(bad_header) {
889            Ok(_) => panic!("a header_len no layout can produce must be refused"),
890            Err(e) => e.to_string(),
891        };
892        assert!(err.contains("header_len 40"), "error must name the width: {err}");
893    }
894
895    /// LAW 2 — the sign-bit trap, asserted on applied output.
896    ///
897    /// Half of all oids start `0x80..0xff`. Under the literal
898    /// `i64::from_be_bytes` key those sort *before* every oid starting
899    /// `0x00..0x7f`, so entry order is not oid order. This asserts entry `i` holds
900    /// the `i`-th oid lexicographically — which is exactly what fails if the top-
901    /// bit flip in [`key_for_oid`] is removed, and which a test built only from
902    /// low-byte oids could never see.
903    #[test]
904    fn entry_order_is_oid_lexicographic_across_the_sign_boundary() {
905        let firsts: [u8; 8] = [0x00, 0x7f, 0x80, 0xff, 0x01, 0xfe, 0x81, 0x7e];
906        let entries: Vec<OidEntry> = firsts
907            .iter()
908            .enumerate()
909            .map(|(i, &f)| {
910                let mut o = [0u8; 32];
911                o[0] = f;
912                o[1] = i as u8;
913                OidEntry { oid: o.to_vec(), lookup_row: i as u64, ordinal: i as u32 }
914            })
915            .collect();
916        let index = idx(entries.clone(), GitHashKind::Sha256);
917
918        let mut want: Vec<Vec<u8>> = entries.iter().map(|e| e.oid.clone()).collect();
919        want.sort();
920        for (i, w) in want.iter().enumerate() {
921            assert_eq!(
922                index.oid_at(i),
923                w.as_slice(),
924                "entry {i} is {} but the {i}-th oid lexicographically is {}",
925                hex::encode(index.oid_at(i)),
926                hex::encode(w)
927            );
928        }
929        // And the keys themselves must be ascending — stree requires it, and an
930        // unsorted keyspace is the failure that would otherwise surface as an
931        // occasional wrong row rather than an error.
932        for i in 1..index.len() {
933            assert!(
934                index.key_at(i - 1) < index.key_at(i),
935                "keys not ascending at {i}: {} then {}",
936                index.key_at(i - 1),
937                index.key_at(i)
938            );
939        }
940        // Every oid still resolves to its own row, sign bit or not.
941        for e in &entries {
942            assert_eq!(index.lookup(&e.oid).unwrap().lookup_row, e.lookup_row);
943        }
944    }
945
946    /// A v1 section must be refused, not silently misread: the layout is
947    /// identical and only the key ordering changed, so a `<=` version check would
948    /// hand back wrong rows without erroring.
949    #[test]
950    fn a_v1_section_is_refused_rather_than_misread() {
951        let entries = vec![OidEntry { oid: oid(&[0x80], 20), lookup_row: 3, ordinal: 0 }];
952        let mut v1 = build_section(&entries, GitHashKind::Sha1).unwrap();
953        v1[8..12].copy_from_slice(&1u32.to_le_bytes());
954        let err = match GitOidIndex::parse(v1) {
955            Ok(_) => panic!("a v1 section must be refused"),
956            Err(e) => e.to_string(),
957        };
958        assert!(err.contains("version 1"), "error must name the version: {err}");
959    }
960
961    /// `n` deterministic sha1-width entries with uniformly-spread oids — enough
962    /// of them that the `stree` has several internal layers and the leaf scan is
963    /// a real random touch rather than the whole index sitting in one line.
964    fn spread_entries(n: usize) -> Vec<OidEntry> {
965        (0..n)
966            .map(|i| {
967                let mut o = [0u8; 20];
968                let mut z = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
969                z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
970                z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
971                o[..8].copy_from_slice(&(z ^ (z >> 31)).to_be_bytes());
972                o[8..12].copy_from_slice(&(i as u32).to_be_bytes());
973                OidEntry { oid: o.to_vec(), lookup_row: (i as u64) * 7 + 1, ordinal: i as u32 }
974            })
975            .collect()
976    }
977
978    /// LAW 2 — the arm is the arm it says it is, asserted on the **address the
979    /// hardware sees**, not on the header field that was supposed to produce it.
980    ///
981    /// A `header_len` of 64 over a `Vec<u8>` is not an aligned keyspace; it is a
982    /// 64-byte header over whatever the allocator felt like. Asserting
983    /// `header_len == 64` would pass in that world and the whole experiment
984    /// would be two arms sharing a cache-line phase.
985    ///
986    /// Seen RED by making `OidLayout::wants_aligned_alloc` return `false` (so
987    /// every arm falls back to `Vec<u8>`): "compact24+align keyspace must sit at
988    /// phase 24 (64-aligned base + 24-byte header), got 40" — 40 being glibc's
989    /// 16-mod-64 chunk base plus the 24-byte header, which is also what the
990    /// shipping `Compact` arm gets and precisely the straddle under test.
991    #[test]
992    fn each_layout_puts_the_keyspace_where_it_claims() {
993        let entries = spread_entries(5_000);
994        let mut phases = Vec::new();
995        for layout in OidLayout::ALL {
996            let section = build_section_with_layout(&entries, GitHashKind::Sha1, layout).unwrap();
997            let index = GitOidIndex::parse_as(section, layout).unwrap();
998            let phase = index.keyspace_phase();
999            match layout {
1000                OidLayout::Aligned64 => assert_eq!(
1001                    phase, 0,
1002                    "aligned64 keyspace must sit at phase 0, got {phase}"
1003                ),
1004                OidLayout::Compact64Alloc => assert_eq!(
1005                    phase, 24,
1006                    "compact24+align keyspace must sit at phase 24 (64-aligned base + 24-byte \
1007                     header), got {phase}"
1008                ),
1009                // `Compact` is at the allocator's mercy by construction — the
1010                // only thing that can be asserted is that it is not the aligned
1011                // arm, which the pairwise check below does.
1012                OidLayout::Compact => {}
1013            }
1014            assert_eq!(index.layout(), layout);
1015            phases.push(phase);
1016        }
1017        assert_ne!(
1018            phases[0], phases[2],
1019            "compact and aligned64 landed on the same cache-line phase ({}), so there is no \
1020             experiment left to run",
1021            phases[0]
1022        );
1023        assert_ne!(phases[1], phases[2]);
1024    }
1025
1026    /// LAW 2 — the identity guard the alignment experiment stands on.
1027    ///
1028    /// Byte-identical payload arrays, and byte-identical **applied output**: the
1029    /// full `(entry, lookup_row, ordinal)` triple for a workload of hits *and*
1030    /// misses, serial and batched, in all three layouts. A faster arm that
1031    /// answered differently would not be a faster arm.
1032    ///
1033    /// Seen RED twice, once per half:
1034    ///
1035    /// * **the byte-identity half**, by deleting `out.resize(header_len, 0)` from
1036    ///   the writer (a header that declares 64 but pads to 24 — the obvious way
1037    ///   to get this wrong): "aligned64 moved a payload byte; it is supposed to
1038    ///   move only the header".
1039    /// * **the applied-output half**, by building the `stree` from
1040    ///   `&bytes[HEADER_FIXED..]` instead of `&bytes[header_len..]` — a leftover
1041    ///   constant, which leaves every section byte identical and only misroutes
1042    ///   the tree: "aligned64 disagrees with compact24 on the serial path at 8",
1043    ///   `None` against `Some(OidHit { entry: 19421, lookup_row: 29, ordinal: 4 })`.
1044    #[test]
1045    fn aligned_and_compact_are_the_same_index_byte_for_byte() {
1046        let entries = spread_entries(20_000);
1047        let hash = GitHashKind::Sha1;
1048
1049        let sections: Vec<Vec<u8>> = OidLayout::ALL
1050            .iter()
1051            .map(|&l| build_section_with_layout(&entries, hash, l).unwrap())
1052            .collect();
1053        // The payload after the header is the same bytes in the same order —
1054        // only the header width differs.
1055        for (i, l) in OidLayout::ALL.iter().enumerate() {
1056            assert_eq!(
1057                &sections[i][l.header_len()..],
1058                &sections[0][OidLayout::Compact.header_len()..],
1059                "{} moved a payload byte; it is supposed to move only the header",
1060                l.name()
1061            );
1062        }
1063
1064        // A workload that is half misses, so the miss path through the tree is
1065        // covered too — a `have` negotiation is mostly misses.
1066        let mut queries: Vec<Vec<u8>> = Vec::new();
1067        for (i, e) in entries.iter().enumerate() {
1068            queries.push(e.oid.clone());
1069            let mut absent = e.oid.clone();
1070            absent[19] ^= 0x5a;
1071            absent[0] ^= if i % 2 == 0 { 0x80 } else { 0x00 };
1072            queries.push(absent);
1073        }
1074        let refs: Vec<&[u8]> = queries.iter().map(|q| q.as_slice()).collect();
1075
1076        let indices: Vec<GitOidIndex> = sections
1077            .into_iter()
1078            .zip(OidLayout::ALL)
1079            .map(|(s, l)| GitOidIndex::parse_as(s, l).unwrap())
1080            .collect();
1081        let base_serial: Vec<Option<OidHit>> = refs.iter().map(|o| indices[0].lookup(o)).collect();
1082        let base_batch = indices[0].lookup_batch(&refs);
1083        assert_eq!(base_serial, base_batch);
1084        let hits = base_serial.iter().filter(|h| h.is_some()).count();
1085        assert_eq!(hits, entries.len(), "premise: every present oid must resolve");
1086        assert!(base_serial.iter().any(|h| h.is_none()), "premise: some queries must miss");
1087
1088        for (i, l) in OidLayout::ALL.iter().enumerate().skip(1) {
1089            for (q, want) in base_serial.iter().enumerate() {
1090                assert_eq!(
1091                    &indices[i].lookup(refs[q]),
1092                    want,
1093                    "{} disagrees with compact24 on the serial path at {q}",
1094                    l.name()
1095                );
1096            }
1097            assert_eq!(
1098                indices[i].lookup_batch(&refs),
1099                base_batch,
1100                "{} disagrees with compact24 on the batch path",
1101                l.name()
1102            );
1103            for e in 0..entries.len() {
1104                assert_eq!(indices[i].key_at(e), indices[0].key_at(e));
1105                assert_eq!(indices[i].oid_at(e), indices[0].oid_at(e));
1106            }
1107        }
1108    }
1109
1110    #[test]
1111    fn build_rejects_an_oid_of_the_wrong_width() {
1112        let entries = vec![OidEntry { oid: oid(&[1], 20), lookup_row: 0, ordinal: 0 }];
1113        assert!(build_section(&entries, GitHashKind::Sha256).is_err());
1114    }
1115}