Skip to main content

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;