Skip to main content

tensor4all_core/index_key/
mod.rs

1//! Bit-packed integer keys for multi-index maps.
2//!
3//! A [`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    /// The storage arm, for tests that pin which arm a width selects.
182    #[cfg(test)]
183    fn repr(&self) -> &Repr {
184        &self.repr
185    }
186
187    /// Builds a key from limbs already known to occupy exactly `width_bits`.
188    ///
189    /// The arm is selected from `width_bits` alone, which is what keeps a
190    /// composed key equal to the same value from [`FlatIndexer::encode`].
191    fn from_limbs(limbs: &dynamic::Limbs, width_bits: u64) -> Self {
192        fn digit(limbs: &[u64], position: usize) -> u64 {
193            limbs.get(position).copied().unwrap_or(0)
194        }
195        fn digits<const N: usize>(limbs: &[u64]) -> [u64; N] {
196            let mut out = [0u64; N];
197            for (position, slot) in out.iter_mut().enumerate() {
198                *slot = digit(limbs, position);
199            }
200            out
201        }
202        let repr = match width_bits {
203            0..=64 => Repr::U64(digit(limbs, 0)),
204            65..=128 => {
205                Repr::U128(u128::from(digit(limbs, 0)) | (u128::from(digit(limbs, 1)) << 64))
206            }
207            129..=256 => Repr::U256(Box::new(bnum::types::U256::from_digits(digits::<4>(limbs)))),
208            257..=512 => Repr::U512(Box::new(bnum::types::U512::from_digits(digits::<8>(limbs)))),
209            _ => {
210                let mut truncated = limbs.clone();
211                truncated.truncate(dynamic::limb_count(width_bits));
212                Repr::Limbs(truncated)
213            }
214        };
215        Self { width_bits, repr }
216    }
217}
218
219/// Encodes multi-indices over fixed local dimensions as [`IndexKey`] values.
220///
221/// Dimension `i` occupies `ceil(log2(d_i))` bits at a fixed offset, so distinct
222/// multi-indices always produce distinct keys.
223///
224/// # Examples
225///
226/// ```
227/// use tensor4all_core::index_key::FlatIndexer;
228/// let indexer = FlatIndexer::try_new(&[3, 4]).unwrap();
229/// assert_eq!(indexer.width_bits(), 4);
230/// assert_eq!(indexer.len(), 2);
231/// assert!(indexer.encode(&[2, 3]).is_ok());
232/// assert!(indexer.encode(&[3, 0]).is_err());
233/// ```
234#[derive(Debug, Clone)]
235pub struct FlatIndexer {
236    dims: Vec<usize>,
237    offsets: Vec<u64>,
238    width_bits: u64,
239}
240
241impl FlatIndexer {
242    /// Builds an indexer for `local_dims`.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`IndexKeyError::ZeroDimension`] when any dimension is zero and
247    /// [`IndexKeyError::WidthOverflow`] when the packed width exceeds what this
248    /// build can represent.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// use tensor4all_core::index_key::FlatIndexer;
254    /// assert!(FlatIndexer::try_new(&[2, 3, 4]).is_ok());
255    /// assert!(FlatIndexer::try_new(&[2, 0]).is_err());
256    /// ```
257    pub fn try_new(local_dims: &[usize]) -> Result<Self, IndexKeyError> {
258        let mut offsets = Vec::with_capacity(local_dims.len());
259        let mut width_bits = 0u64;
260        for (position, &dim) in local_dims.iter().enumerate() {
261            let bits =
262                dimension_bits(dim).map_err(|_| IndexKeyError::ZeroDimension { position })?;
263            offsets.push(width_bits);
264            width_bits =
265                width_bits
266                    .checked_add(u64::from(bits))
267                    .ok_or(IndexKeyError::WidthOverflow {
268                        requested_bits: u64::MAX,
269                    })?;
270        }
271        Ok(Self {
272            dims: local_dims.to_vec(),
273            offsets,
274            width_bits,
275        })
276    }
277
278    /// Total packed width in bits.
279    pub fn width_bits(&self) -> u64 {
280        self.width_bits
281    }
282
283    /// Number of local dimensions.
284    pub fn len(&self) -> usize {
285        self.dims.len()
286    }
287
288    /// Whether the indexer has no dimensions.
289    pub fn is_empty(&self) -> bool {
290        self.dims.is_empty()
291    }
292
293    /// Encodes a multi-index.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`IndexKeyError::LengthMismatch`] when `idx` has the wrong
298    /// length, and [`IndexKeyError::IndexOutOfRange`] when a component is not
299    /// below its dimension. No input produces a silently wrapped key.
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// use tensor4all_core::index_key::FlatIndexer;
305    /// let indexer = FlatIndexer::try_new(&[2, 2]).unwrap();
306    /// assert!(indexer.encode(&[1, 1]).is_ok());
307    /// assert!(indexer.encode(&[2, 0]).is_err());
308    /// assert!(indexer.encode(&[0]).is_err());
309    /// ```
310    pub fn encode(&self, idx: &[usize]) -> Result<IndexKey, IndexKeyError> {
311        self.check(idx)?;
312        macro_rules! pack {
313            ($zero:expr, $place:path, $arm:expr) => {{
314                let mut key = $zero;
315                for (&value, &offset) in idx.iter().zip(&self.offsets) {
316                    // Offsets are below the arm's width here, so this cannot
317                    // truncate; the conversion is checked rather than cast so
318                    // that a future width change cannot silently wrap.
319                    let offset =
320                        u32::try_from(offset).map_err(|_| IndexKeyError::WidthOverflow {
321                            requested_bits: self.width_bits,
322                        })?;
323                    key = $place(key, value, offset);
324                }
325                Ok(IndexKey {
326                    width_bits: self.width_bits,
327                    repr: $arm(key),
328                })
329            }};
330        }
331        match self.width_bits {
332            0..=64 => pack!(0u64, fixed::place_u64, Repr::U64),
333            65..=128 => pack!(0u128, fixed::place_u128, Repr::U128),
334            129..=256 => pack!(bnum::types::U256::ZERO, fixed::place_u256, |k| {
335                Repr::U256(Box::new(k))
336            }),
337            257..=512 => pack!(bnum::types::U512::ZERO, fixed::place_u512, |k| {
338                Repr::U512(Box::new(k))
339            }),
340            _ => {
341                let mut limbs = dynamic::zeroed(self.width_bits)?;
342                for (&value, &offset) in idx.iter().zip(&self.offsets) {
343                    dynamic::place(&mut limbs, value, offset);
344                }
345                Ok(IndexKey {
346                    width_bits: self.width_bits,
347                    repr: Repr::Limbs(limbs),
348                })
349            }
350        }
351    }
352
353    /// Validates length and per-dimension bounds before any packing happens.
354    fn check(&self, idx: &[usize]) -> Result<(), IndexKeyError> {
355        if idx.len() != self.dims.len() {
356            return Err(IndexKeyError::LengthMismatch {
357                expected: self.dims.len(),
358                actual: idx.len(),
359            });
360        }
361        for (position, (&value, &dim)) in idx.iter().zip(&self.dims).enumerate() {
362            if value >= dim {
363                return Err(IndexKeyError::IndexOutOfRange {
364                    position,
365                    value,
366                    dim,
367                });
368            }
369        }
370        Ok(())
371    }
372}
373
374/// Assembles one key by appending sub-keys at successive bit offsets.
375///
376/// This is the composition operation a tree needs: a node's key is its own
377/// local key followed by each child's key, so
378/// `key(node) = local ++ key(c1) ++ key(c2) ++ ...`. Appending at a known bit
379/// offset costs one pass over the pushed key's limbs, so composing a tree is
380/// linear in total width rather than quadratic as a multiply-based mixed-radix
381/// composition would be.
382///
383/// The result equals what [`FlatIndexer::encode`] produces for the
384/// concatenated multi-index, so a composed key and a directly encoded one are
385/// interchangeable as map keys.
386///
387/// # Examples
388///
389/// ```
390/// use tensor4all_core::index_key::{FlatIndexer, KeyBuilder};
391/// let local = FlatIndexer::try_new(&[3, 2]).unwrap();
392/// let child = FlatIndexer::try_new(&[4]).unwrap();
393/// let whole = FlatIndexer::try_new(&[3, 2, 4]).unwrap();
394///
395/// let mut builder = KeyBuilder::with_capacity_bits(whole.width_bits()).unwrap();
396/// builder.push(&local.encode(&[2, 1]).unwrap()).unwrap();
397/// builder.push(&child.encode(&[3]).unwrap()).unwrap();
398///
399/// assert_eq!(builder.finish(), whole.encode(&[2, 1, 3]).unwrap());
400/// ```
401#[derive(Debug, Clone)]
402pub struct KeyBuilder {
403    limbs: dynamic::Limbs,
404    offset: u64,
405    capacity_bits: u64,
406}
407
408impl KeyBuilder {
409    /// Creates a builder able to hold `width_bits` of appended sub-keys.
410    ///
411    /// # Errors
412    ///
413    /// Returns [`IndexKeyError::WidthOverflow`] when the width cannot be
414    /// allocated on this platform.
415    ///
416    /// # Examples
417    ///
418    /// ```
419    /// use tensor4all_core::index_key::KeyBuilder;
420    /// assert!(KeyBuilder::with_capacity_bits(0).is_ok());
421    /// assert!(KeyBuilder::with_capacity_bits(4096).is_ok());
422    /// ```
423    pub fn with_capacity_bits(width_bits: u64) -> Result<Self, IndexKeyError> {
424        Ok(Self {
425            limbs: dynamic::zeroed(width_bits)?,
426            offset: 0,
427            capacity_bits: width_bits,
428        })
429    }
430
431    /// Total bits appended so far.
432    pub fn width_bits(&self) -> u64 {
433        self.offset
434    }
435
436    /// Appends `key` at the current offset, advancing by the key's own width.
437    ///
438    /// The width comes from the key itself, so a sub-key can never be placed
439    /// under a width that disagrees with its contents -- which would overwrite
440    /// the following field and silently break injectivity.
441    ///
442    /// # Errors
443    ///
444    /// Returns [`IndexKeyError::WidthOverflow`] when the append would exceed
445    /// the declared capacity, rather than truncating the key.
446    ///
447    /// # Examples
448    ///
449    /// ```
450    /// use tensor4all_core::index_key::{FlatIndexer, KeyBuilder};
451    /// let indexer = FlatIndexer::try_new(&[2, 2]).unwrap();
452    /// let key = indexer.encode(&[1, 1]).unwrap();
453    /// let mut builder = KeyBuilder::with_capacity_bits(2).unwrap();
454    /// assert!(builder.push(&key).is_ok());
455    /// assert!(builder.push(&key).is_err());
456    /// ```
457    pub fn push(&mut self, key: &IndexKey) -> Result<(), IndexKeyError> {
458        let key_width_bits = key.width_bits;
459        let end = self
460            .offset
461            .checked_add(key_width_bits)
462            .ok_or(IndexKeyError::WidthOverflow {
463                requested_bits: u64::MAX,
464            })?;
465        if end > self.capacity_bits {
466            return Err(IndexKeyError::WidthOverflow {
467                requested_bits: end,
468            });
469        }
470        let offset = self.offset;
471        match &key.repr {
472            Repr::U64(value) => {
473                dynamic::place_limbs(&mut self.limbs, &[*value], key_width_bits, offset);
474            }
475            Repr::U128(value) => {
476                let source = [*value as u64, (*value >> 64) as u64];
477                dynamic::place_limbs(&mut self.limbs, &source, key_width_bits, offset);
478            }
479            Repr::U256(value) => {
480                dynamic::place_limbs(&mut self.limbs, value.digits(), key_width_bits, offset);
481            }
482            Repr::U512(value) => {
483                dynamic::place_limbs(&mut self.limbs, value.digits(), key_width_bits, offset);
484            }
485            Repr::Limbs(value) => {
486                dynamic::place_limbs(&mut self.limbs, value, key_width_bits, offset);
487            }
488        }
489        self.offset = end;
490        Ok(())
491    }
492
493    /// Produces the key for the bits actually appended.
494    ///
495    /// The arm is selected from the appended width, not from the declared
496    /// capacity, so a composed key equals the same value from
497    /// [`FlatIndexer::encode`] even when the builder was given more capacity
498    /// than it ended up using.
499    ///
500    /// # Examples
501    ///
502    /// ```
503    /// use tensor4all_core::index_key::{FlatIndexer, KeyBuilder};
504    /// let indexer = FlatIndexer::try_new(&[2, 2]).unwrap();
505    /// let key = indexer.encode(&[1, 0]).unwrap();
506    ///
507    /// // Over-declared capacity does not change the composed key.
508    /// let mut tight = KeyBuilder::with_capacity_bits(2).unwrap();
509    /// tight.push(&key).unwrap();
510    /// let mut loose = KeyBuilder::with_capacity_bits(600).unwrap();
511    /// loose.push(&key).unwrap();
512    /// let composed = tight.finish();
513    /// assert_eq!(composed, loose.finish());
514    /// assert_eq!(composed, key);
515    ///
516    /// let empty = KeyBuilder::with_capacity_bits(8).unwrap();
517    /// assert_eq!(empty.finish().width_bits(), 0);
518    /// ```
519    pub fn finish(self) -> IndexKey {
520        IndexKey::from_limbs(&self.limbs, self.offset)
521    }
522}
523
524#[cfg(test)]
525mod tests;