tensor4all_core/index_key/mod.rs
1//! Bit-packed integer keys for multi-index maps.
2//!
3//! A [`FlatIndexer`](crate::index_key::FlatIndexer) turns a multi-index over fixed local dimensions into a
4//! single integer key suitable for hashing. Dimension `i` occupies
5//! `ceil(log2(d_i))` bits at a fixed offset, so encoding is shift-and-OR with
6//! no multiplication, and two multi-indices collide only if they are equal.
7//!
8//! Widths up to 512 bits use fixed-width fast paths; wider index spaces fall
9//! back to a limb-backed representation with the same semantics.
10//!
11//! The ladder stops at 512 bits because that is where a fixed-width bignum
12//! stops paying. Measured per-dimension encode cost is 2.12 ns at `U256` and
13//! 2.86 ns at `U512` against 2.52–2.71 ns for limbs, while a `U1024` arm cost
14//! 6.02 ns — 2.3x the limb path at the same width. A generic shift over
15//! sixteen digits touches every digit for each component, whereas a limb write
16//! touches the one or two the component actually spans.
17//!
18//! These figures only hold with the placement helpers inlined; see `fixed` for
19//! why that attribute is load-bearing.
20
21use thiserror::Error;
22
23mod dynamic;
24mod fixed;
25
26/// Failures from index-key construction and encoding.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
28pub enum IndexKeyError {
29 /// A local dimension was zero, so the index space is empty.
30 #[error("local dimension at position {position} is zero")]
31 ZeroDimension {
32 /// Position of the offending dimension.
33 position: usize,
34 },
35 /// A multi-index had the wrong number of components.
36 #[error("expected a multi-index of length {expected}, got {actual}")]
37 LengthMismatch {
38 /// Number of local dimensions the indexer was built with.
39 expected: usize,
40 /// Number of components supplied.
41 actual: usize,
42 },
43 /// A component was not less than its local dimension.
44 #[error("index {value} at position {position} is not below dimension {dim}")]
45 IndexOutOfRange {
46 /// Position of the offending component.
47 position: usize,
48 /// The supplied value.
49 value: usize,
50 /// The local dimension at that position.
51 dim: usize,
52 },
53 /// The requested key width exceeds what this build can represent.
54 #[error("requested key width {requested_bits} bits is too large")]
55 WidthOverflow {
56 /// Total bits requested.
57 requested_bits: u64,
58 },
59}
60
61/// Number of bits needed to represent the values `0..dim`.
62///
63/// # Errors
64///
65/// Returns [`IndexKeyError::ZeroDimension`] when `dim` is zero, since an empty
66/// local space has no representable value.
67///
68/// # Examples
69///
70/// ```
71/// use tensor4all_core::index_key::dimension_bits;
72/// assert_eq!(dimension_bits(1).unwrap(), 0);
73/// assert_eq!(dimension_bits(4).unwrap(), 2);
74/// assert_eq!(dimension_bits(5).unwrap(), 3);
75/// assert!(dimension_bits(0).is_err());
76/// ```
77pub fn dimension_bits(dim: usize) -> Result<u32, IndexKeyError> {
78 match dim {
79 0 => Err(IndexKeyError::ZeroDimension { position: 0 }),
80 1 => Ok(0),
81 _ => Ok(usize::BITS - (dim - 1).leading_zeros()),
82 }
83}
84
85/// Total bit width of the bit-packed key for `local_dims`.
86///
87/// # Errors
88///
89/// Returns [`IndexKeyError::ZeroDimension`] naming the first zero dimension,
90/// and [`IndexKeyError::WidthOverflow`] if the widths do not fit a `u64`.
91///
92/// # Examples
93///
94/// ```
95/// use tensor4all_core::index_key::total_bits;
96/// assert_eq!(total_bits(&[2, 2, 2]).unwrap(), 3);
97/// assert_eq!(total_bits(&[4, 3, 1]).unwrap(), 4);
98/// assert_eq!(total_bits(&[]).unwrap(), 0);
99/// assert!(total_bits(&[2, 0]).is_err());
100/// ```
101pub fn total_bits(local_dims: &[usize]) -> Result<u64, IndexKeyError> {
102 let mut sum = 0u64;
103 for (position, &dim) in local_dims.iter().enumerate() {
104 let bits = dimension_bits(dim).map_err(|_| IndexKeyError::ZeroDimension { position })?;
105 sum = sum
106 .checked_add(u64::from(bits))
107 .ok_or(IndexKeyError::WidthOverflow {
108 requested_bits: u64::MAX,
109 })?;
110 }
111 Ok(sum)
112}
113
114/// The storage arm holding a key's bits.
115///
116/// Private: the arm is an implementation detail chosen from the key's width,
117/// and exposing it would let callers build a key whose declared width and
118/// contents disagree.
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120enum Repr {
121 /// Keys up to 64 bits.
122 U64(u64),
123 /// Keys up to 128 bits.
124 U128(u128),
125 /// Keys up to 256 bits.
126 U256(Box<bnum::types::U256>),
127 /// Keys up to 512 bits.
128 U512(Box<bnum::types::U512>),
129 /// Keys wider than 512 bits, as little-endian 64-bit limbs.
130 Limbs(dynamic::Limbs),
131}
132
133/// A bit-packed multi-index key.
134///
135/// A key carries the width it was built for, and the storage arm is a function
136/// of that width, so two keys are equal exactly when they were built for the
137/// same width and hold the same bits. Arms wider than 128 bits are boxed so a
138/// narrow key does not pay the footprint of the widest arm.
139///
140/// The contents are deliberately opaque. A key can only be produced by
141/// [`FlatIndexer::encode`] or [`KeyBuilder::finish`], both of which establish
142/// that its value occupies exactly `width_bits` bits. [`KeyBuilder::push`]
143/// relies on that invariant to place a sub-key without masking or truncation,
144/// and it is not one a caller could be asked to maintain by hand.
145///
146/// # Examples
147///
148/// ```
149/// use tensor4all_core::index_key::FlatIndexer;
150/// let indexer = FlatIndexer::try_new(&[2, 2]).unwrap();
151/// let a = indexer.encode(&[1, 0]).unwrap();
152/// let b = indexer.encode(&[0, 1]).unwrap();
153/// assert_ne!(a, b);
154/// assert_eq!(a, indexer.encode(&[1, 0]).unwrap());
155/// assert_eq!(a.width_bits(), 2);
156/// ```
157#[derive(Debug, Clone, PartialEq, Eq, Hash)]
158pub struct IndexKey {
159 width_bits: u64,
160 repr: Repr,
161}
162
163impl IndexKey {
164 /// The width this key was built for, in bits.
165 ///
166 /// This is what [`KeyBuilder::push`] consumes, so a sub-key can never be
167 /// appended under a width that disagrees with its contents.
168 ///
169 /// # Examples
170 ///
171 /// ```
172 /// use tensor4all_core::index_key::FlatIndexer;
173 /// let indexer = FlatIndexer::try_new(&[3, 4]).unwrap();
174 /// assert_eq!(indexer.width_bits(), 4);
175 /// assert_eq!(indexer.encode(&[2, 3]).unwrap().width_bits(), 4);
176 /// ```
177 pub fn width_bits(&self) -> u64 {
178 self.width_bits
179 }
180
181 /// Returns the heap payload bytes owned by this key, including spare limb capacity.
182 ///
183 /// Inline keys of at most 128 bits own no heap payload. Wider fixed-width
184 /// keys own their boxed storage; limb-backed keys include the allocation's
185 /// full capacity. This excludes the inline [`IndexKey`] value, allocator
186 /// headers, allocator rounding, and any temporary builder storage. Use it
187 /// when accounting for keys retained in a cache or collection.
188 ///
189 /// # Examples
190 ///
191 /// ```
192 /// use tensor4all_core::index_key::FlatIndexer;
193 /// let narrow = FlatIndexer::try_new(&[2, 2]).unwrap().encode(&[1, 0]).unwrap();
194 /// assert_eq!(narrow.owned_heap_bytes(), 0);
195 /// let wide = FlatIndexer::try_new(&[2; 130]).unwrap().encode(&[1; 130]).unwrap();
196 /// assert_eq!(wide.owned_heap_bytes(), 32);
197 /// ```
198 pub fn owned_heap_bytes(&self) -> usize {
199 match &self.repr {
200 Repr::U64(_) | Repr::U128(_) => 0,
201 Repr::U256(value) => std::mem::size_of_val(value.as_ref()),
202 Repr::U512(value) => std::mem::size_of_val(value.as_ref()),
203 Repr::Limbs(value) => dynamic::owned_heap_bytes(value),
204 }
205 }
206
207 /// The storage arm, for tests that pin which arm a width selects.
208 #[cfg(test)]
209 fn repr(&self) -> &Repr {
210 &self.repr
211 }
212
213 /// Builds a key from limbs already known to occupy exactly `width_bits`.
214 ///
215 /// The arm is selected from `width_bits` alone, which is what keeps a
216 /// composed key equal to the same value from [`FlatIndexer::encode`].
217 fn from_limbs(limbs: &dynamic::Limbs, width_bits: u64) -> Self {
218 fn digit(limbs: &[u64], position: usize) -> u64 {
219 limbs.get(position).copied().unwrap_or(0)
220 }
221 fn digits<const N: usize>(limbs: &[u64]) -> [u64; N] {
222 let mut out = [0u64; N];
223 for (position, slot) in out.iter_mut().enumerate() {
224 *slot = digit(limbs, position);
225 }
226 out
227 }
228 let repr = match width_bits {
229 0..=64 => Repr::U64(digit(limbs, 0)),
230 65..=128 => {
231 Repr::U128(u128::from(digit(limbs, 0)) | (u128::from(digit(limbs, 1)) << 64))
232 }
233 129..=256 => Repr::U256(Box::new(bnum::types::U256::from_digits(digits::<4>(limbs)))),
234 257..=512 => Repr::U512(Box::new(bnum::types::U512::from_digits(digits::<8>(limbs)))),
235 _ => {
236 let mut truncated = limbs.clone();
237 truncated.truncate(dynamic::limb_count(width_bits));
238 Repr::Limbs(truncated)
239 }
240 };
241 Self { width_bits, repr }
242 }
243}
244
245/// Encodes multi-indices over fixed local dimensions as [`IndexKey`] values.
246///
247/// Dimension `i` occupies `ceil(log2(d_i))` bits at a fixed offset, so distinct
248/// multi-indices always produce distinct keys.
249///
250/// # Examples
251///
252/// ```
253/// use tensor4all_core::index_key::FlatIndexer;
254/// let indexer = FlatIndexer::try_new(&[3, 4]).unwrap();
255/// assert_eq!(indexer.width_bits(), 4);
256/// assert_eq!(indexer.len(), 2);
257/// assert!(indexer.encode(&[2, 3]).is_ok());
258/// assert!(indexer.encode(&[3, 0]).is_err());
259/// ```
260#[derive(Debug, Clone)]
261pub struct FlatIndexer {
262 dims: Vec<usize>,
263 offsets: Vec<u64>,
264 width_bits: u64,
265}
266
267impl FlatIndexer {
268 /// Builds an indexer for `local_dims`.
269 ///
270 /// # Errors
271 ///
272 /// Returns [`IndexKeyError::ZeroDimension`] when any dimension is zero and
273 /// [`IndexKeyError::WidthOverflow`] when the packed width exceeds what this
274 /// build can represent.
275 ///
276 /// # Examples
277 ///
278 /// ```
279 /// use tensor4all_core::index_key::FlatIndexer;
280 /// assert!(FlatIndexer::try_new(&[2, 3, 4]).is_ok());
281 /// assert!(FlatIndexer::try_new(&[2, 0]).is_err());
282 /// ```
283 pub fn try_new(local_dims: &[usize]) -> Result<Self, IndexKeyError> {
284 let mut offsets = Vec::with_capacity(local_dims.len());
285 let mut width_bits = 0u64;
286 for (position, &dim) in local_dims.iter().enumerate() {
287 let bits =
288 dimension_bits(dim).map_err(|_| IndexKeyError::ZeroDimension { position })?;
289 offsets.push(width_bits);
290 width_bits =
291 width_bits
292 .checked_add(u64::from(bits))
293 .ok_or(IndexKeyError::WidthOverflow {
294 requested_bits: u64::MAX,
295 })?;
296 }
297 Ok(Self {
298 dims: local_dims.to_vec(),
299 offsets,
300 width_bits,
301 })
302 }
303
304 /// Total packed width in bits.
305 pub fn width_bits(&self) -> u64 {
306 self.width_bits
307 }
308
309 /// Number of local dimensions.
310 pub fn len(&self) -> usize {
311 self.dims.len()
312 }
313
314 /// Whether the indexer has no dimensions.
315 pub fn is_empty(&self) -> bool {
316 self.dims.is_empty()
317 }
318
319 /// Encodes a multi-index.
320 ///
321 /// # Errors
322 ///
323 /// Returns [`IndexKeyError::LengthMismatch`] when `idx` has the wrong
324 /// length, and [`IndexKeyError::IndexOutOfRange`] when a component is not
325 /// below its dimension. No input produces a silently wrapped key.
326 ///
327 /// # Examples
328 ///
329 /// ```
330 /// use tensor4all_core::index_key::FlatIndexer;
331 /// let indexer = FlatIndexer::try_new(&[2, 2]).unwrap();
332 /// assert!(indexer.encode(&[1, 1]).is_ok());
333 /// assert!(indexer.encode(&[2, 0]).is_err());
334 /// assert!(indexer.encode(&[0]).is_err());
335 /// ```
336 pub fn encode(&self, idx: &[usize]) -> Result<IndexKey, IndexKeyError> {
337 self.check(idx)?;
338 macro_rules! pack {
339 ($zero:expr, $place:path, $arm:expr) => {{
340 let mut key = $zero;
341 for (&value, &offset) in idx.iter().zip(&self.offsets) {
342 // Offsets are below the arm's width here, so this cannot
343 // truncate; the conversion is checked rather than cast so
344 // that a future width change cannot silently wrap.
345 let offset =
346 u32::try_from(offset).map_err(|_| IndexKeyError::WidthOverflow {
347 requested_bits: self.width_bits,
348 })?;
349 key = $place(key, value, offset);
350 }
351 Ok(IndexKey {
352 width_bits: self.width_bits,
353 repr: $arm(key),
354 })
355 }};
356 }
357 match self.width_bits {
358 0..=64 => pack!(0u64, fixed::place_u64, Repr::U64),
359 65..=128 => pack!(0u128, fixed::place_u128, Repr::U128),
360 129..=256 => pack!(bnum::types::U256::ZERO, fixed::place_u256, |k| {
361 Repr::U256(Box::new(k))
362 }),
363 257..=512 => pack!(bnum::types::U512::ZERO, fixed::place_u512, |k| {
364 Repr::U512(Box::new(k))
365 }),
366 _ => {
367 let mut limbs = dynamic::zeroed(self.width_bits)?;
368 for (&value, &offset) in idx.iter().zip(&self.offsets) {
369 dynamic::place(&mut limbs, value, offset);
370 }
371 Ok(IndexKey {
372 width_bits: self.width_bits,
373 repr: Repr::Limbs(limbs),
374 })
375 }
376 }
377 }
378
379 /// Validates length and per-dimension bounds before any packing happens.
380 fn check(&self, idx: &[usize]) -> Result<(), IndexKeyError> {
381 if idx.len() != self.dims.len() {
382 return Err(IndexKeyError::LengthMismatch {
383 expected: self.dims.len(),
384 actual: idx.len(),
385 });
386 }
387 for (position, (&value, &dim)) in idx.iter().zip(&self.dims).enumerate() {
388 if value >= dim {
389 return Err(IndexKeyError::IndexOutOfRange {
390 position,
391 value,
392 dim,
393 });
394 }
395 }
396 Ok(())
397 }
398}
399
400/// Assembles one key by appending sub-keys at successive bit offsets.
401///
402/// This is the composition operation a tree needs: a node's key is its own
403/// local key followed by each child's key, so
404/// `key(node) = local ++ key(c1) ++ key(c2) ++ ...`. Appending at a known bit
405/// offset costs one pass over the pushed key's limbs, so composing a tree is
406/// linear in total width rather than quadratic as a multiply-based mixed-radix
407/// composition would be.
408///
409/// The result equals what [`FlatIndexer::encode`] produces for the
410/// concatenated multi-index, so a composed key and a directly encoded one are
411/// interchangeable as map keys.
412///
413/// # Examples
414///
415/// ```
416/// use tensor4all_core::index_key::{FlatIndexer, KeyBuilder};
417/// let local = FlatIndexer::try_new(&[3, 2]).unwrap();
418/// let child = FlatIndexer::try_new(&[4]).unwrap();
419/// let whole = FlatIndexer::try_new(&[3, 2, 4]).unwrap();
420///
421/// let mut builder = KeyBuilder::with_capacity_bits(whole.width_bits()).unwrap();
422/// builder.push(&local.encode(&[2, 1]).unwrap()).unwrap();
423/// builder.push(&child.encode(&[3]).unwrap()).unwrap();
424///
425/// assert_eq!(builder.finish(), whole.encode(&[2, 1, 3]).unwrap());
426/// ```
427#[derive(Debug, Clone)]
428pub struct KeyBuilder {
429 limbs: dynamic::Limbs,
430 offset: u64,
431 capacity_bits: u64,
432}
433
434impl KeyBuilder {
435 /// Creates a builder able to hold `width_bits` of appended sub-keys.
436 ///
437 /// # Errors
438 ///
439 /// Returns [`IndexKeyError::WidthOverflow`] when the width cannot be
440 /// allocated on this platform.
441 ///
442 /// # Examples
443 ///
444 /// ```
445 /// use tensor4all_core::index_key::KeyBuilder;
446 /// assert!(KeyBuilder::with_capacity_bits(0).is_ok());
447 /// assert!(KeyBuilder::with_capacity_bits(4096).is_ok());
448 /// ```
449 pub fn with_capacity_bits(width_bits: u64) -> Result<Self, IndexKeyError> {
450 Ok(Self {
451 limbs: dynamic::zeroed(width_bits)?,
452 offset: 0,
453 capacity_bits: width_bits,
454 })
455 }
456
457 /// Total bits appended so far.
458 pub fn width_bits(&self) -> u64 {
459 self.offset
460 }
461
462 /// Appends `key` at the current offset, advancing by the key's own width.
463 ///
464 /// The width comes from the key itself, so a sub-key can never be placed
465 /// under a width that disagrees with its contents -- which would overwrite
466 /// the following field and silently break injectivity.
467 ///
468 /// # Errors
469 ///
470 /// Returns [`IndexKeyError::WidthOverflow`] when the append would exceed
471 /// the declared capacity, rather than truncating the key.
472 ///
473 /// # Examples
474 ///
475 /// ```
476 /// use tensor4all_core::index_key::{FlatIndexer, KeyBuilder};
477 /// let indexer = FlatIndexer::try_new(&[2, 2]).unwrap();
478 /// let key = indexer.encode(&[1, 1]).unwrap();
479 /// let mut builder = KeyBuilder::with_capacity_bits(2).unwrap();
480 /// assert!(builder.push(&key).is_ok());
481 /// assert!(builder.push(&key).is_err());
482 /// ```
483 pub fn push(&mut self, key: &IndexKey) -> Result<(), IndexKeyError> {
484 let key_width_bits = key.width_bits;
485 let end = self
486 .offset
487 .checked_add(key_width_bits)
488 .ok_or(IndexKeyError::WidthOverflow {
489 requested_bits: u64::MAX,
490 })?;
491 if end > self.capacity_bits {
492 return Err(IndexKeyError::WidthOverflow {
493 requested_bits: end,
494 });
495 }
496 let offset = self.offset;
497 match &key.repr {
498 Repr::U64(value) => {
499 dynamic::place_limbs(&mut self.limbs, &[*value], key_width_bits, offset);
500 }
501 Repr::U128(value) => {
502 let source = [*value as u64, (*value >> 64) as u64];
503 dynamic::place_limbs(&mut self.limbs, &source, key_width_bits, offset);
504 }
505 Repr::U256(value) => {
506 dynamic::place_limbs(&mut self.limbs, value.digits(), key_width_bits, offset);
507 }
508 Repr::U512(value) => {
509 dynamic::place_limbs(&mut self.limbs, value.digits(), key_width_bits, offset);
510 }
511 Repr::Limbs(value) => {
512 dynamic::place_limbs(&mut self.limbs, value, key_width_bits, offset);
513 }
514 }
515 self.offset = end;
516 Ok(())
517 }
518
519 /// Produces the key for the bits actually appended.
520 ///
521 /// The arm is selected from the appended width, not from the declared
522 /// capacity, so a composed key equals the same value from
523 /// [`FlatIndexer::encode`] even when the builder was given more capacity
524 /// than it ended up using.
525 ///
526 /// # Examples
527 ///
528 /// ```
529 /// use tensor4all_core::index_key::{FlatIndexer, KeyBuilder};
530 /// let indexer = FlatIndexer::try_new(&[2, 2]).unwrap();
531 /// let key = indexer.encode(&[1, 0]).unwrap();
532 ///
533 /// // Over-declared capacity does not change the composed key.
534 /// let mut tight = KeyBuilder::with_capacity_bits(2).unwrap();
535 /// tight.push(&key).unwrap();
536 /// let mut loose = KeyBuilder::with_capacity_bits(600).unwrap();
537 /// loose.push(&key).unwrap();
538 /// let composed = tight.finish();
539 /// assert_eq!(composed, loose.finish());
540 /// assert_eq!(composed, key);
541 ///
542 /// let empty = KeyBuilder::with_capacity_bits(8).unwrap();
543 /// assert_eq!(empty.finish().width_bits(), 0);
544 /// ```
545 pub fn finish(self) -> IndexKey {
546 IndexKey::from_limbs(&self.limbs, self.offset)
547 }
548}
549
550#[cfg(test)]
551mod tests;