feat: add fast hex-to-Long slicing to Hex

Add Hex.toLong64/toLong128/toLong256 (plus the shared readLong helper) to
pack the first 64, 128 or 256 bits of a hex string into a single Long, two
Longs or four Longs. Big-endian, allocation-light, branch-free — 16 table
lookups and shifts per word. Useful as cheap map/set keys or bucket hashes
for 32-byte event ids and pubkeys without decoding to a ByteArray.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CU1wR6NvQmdmNNsPe9GuN
This commit is contained in:
Claude
2026-07-06 18:21:21 +00:00
parent c509014cf6
commit e40d8df4d0
2 changed files with 111 additions and 0 deletions
@@ -200,6 +200,68 @@ object Hex {
return out.concatToString()
}
/**
* Packs 16 hex chars starting at [offset] into a single [Long] (big-endian:
* the first char becomes the most-significant nibble). No allocations, no
* branches — just 16 table lookups and shifts.
*
* Assumes [hex] has at least `offset + 16` valid hex chars; it does not
* validate. Guard untrusted input with [isHex64] first, otherwise an invalid
* char (table value `-1`) corrupts the result.
*/
fun readLong(
hex: String,
offset: Int,
): Long =
(hexToByte[hex[offset].code].toLong() shl 60) or
(hexToByte[hex[offset + 1].code].toLong() shl 56) or
(hexToByte[hex[offset + 2].code].toLong() shl 52) or
(hexToByte[hex[offset + 3].code].toLong() shl 48) or
(hexToByte[hex[offset + 4].code].toLong() shl 44) or
(hexToByte[hex[offset + 5].code].toLong() shl 40) or
(hexToByte[hex[offset + 6].code].toLong() shl 36) or
(hexToByte[hex[offset + 7].code].toLong() shl 32) or
(hexToByte[hex[offset + 8].code].toLong() shl 28) or
(hexToByte[hex[offset + 9].code].toLong() shl 24) or
(hexToByte[hex[offset + 10].code].toLong() shl 20) or
(hexToByte[hex[offset + 11].code].toLong() shl 16) or
(hexToByte[hex[offset + 12].code].toLong() shl 12) or
(hexToByte[hex[offset + 13].code].toLong() shl 8) or
(hexToByte[hex[offset + 14].code].toLong() shl 4) or
hexToByte[hex[offset + 15].code].toLong()
/**
* Reads the first 64 bits (16 hex chars) of [hex] as a single [Long].
* Ideal as a cheap, collision-resistant map/set key or bucket hash for a
* 32-byte event id or pubkey. Assumes [hex] is at least 16 valid hex chars —
* see [readLong].
*/
fun toLong64(hex: String): Long = readLong(hex, 0)
/**
* Reads the first 128 bits (32 hex chars) of [hex] as two [Long]s, most
* significant first. Assumes [hex] is at least 32 valid hex chars — see
* [readLong].
*/
fun toLong128(hex: String): LongArray =
longArrayOf(
readLong(hex, 0),
readLong(hex, 16),
)
/**
* Reads a full 256-bit (64 hex char) id/pubkey/signature-half as four
* [Long]s, most significant first. Assumes [hex] is at least 64 valid hex
* chars — see [readLong].
*/
fun toLong256(hex: String): LongArray =
longArrayOf(
readLong(hex, 0),
readLong(hex, 16),
readLong(hex, 32),
readLong(hex, 48),
)
/**
* True when the hex string [id] encodes exactly the bytes [ourId], compared
* without allocating a decode buffer. Handy for matching an incoming hex id
@@ -77,4 +77,53 @@ class HexEncodingTest {
)
}
}
@Test
fun testToLong64() {
assertEquals(0x48a72b485d383386uL.toLong(), Hex.toLong64(testHex))
assertEquals(0L, Hex.toLong64("0000000000000000"))
assertEquals(-1L, Hex.toLong64("ffffffffffffffff"))
// uppercase reads the same
assertEquals(0x48a72b485d383386uL.toLong(), Hex.toLong64(testHex.uppercase()))
}
@Test
fun testToLong128() {
val longs = Hex.toLong128(testHex)
assertEquals(2, longs.size)
assertEquals(0x48a72b485d383386uL.toLong(), longs[0])
assertEquals(0x27ec9d427583551fuL.toLong(), longs[1])
}
@Test
fun testToLong256() {
val longs = Hex.toLong256(testHex)
assertEquals(4, longs.size)
assertEquals(0x48a72b485d383386uL.toLong(), longs[0])
assertEquals(0x27ec9d427583551fuL.toLong(), longs[1])
assertEquals(0x9af4f016c739b8ecuL.toLong(), longs[2])
assertEquals(0x0d6313540a8b12cfuL.toLong(), longs[3])
}
@OptIn(ExperimentalStdlibApi::class)
@Test
fun testToLongMatchesDecodedBytes() {
for (i in 0..1000) {
val bytes = Random.nextBytes(32)
val hex = bytes.toHexString(HexFormat.Default)
// the four longs must reconstruct exactly the 32 decoded bytes, big-endian
val longs = Hex.toLong256(hex)
for (word in 0 until 4) {
for (b in 0 until 8) {
val expected = bytes[word * 8 + b].toLong() and 0xFF
val actual = (longs[word] ushr ((7 - b) * 8)) and 0xFF
assertEquals(expected, actual, hex)
}
}
assertEquals(longs[0], Hex.toLong64(hex))
assertEquals(longs.take(2), Hex.toLong128(hex).toList())
}
}
}