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;