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 §ions[i][l.header_len()..],
1058 §ions[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}