From e40d8df4d0f0e353548b60a03a1278c78ec1cc3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 18:21:21 +0000 Subject: [PATCH] feat: add fast hex-to-Long slicing to Hex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019CU1wR6NvQmdmNNsPe9GuN --- .../com/vitorpamplona/quartz/utils/Hex.kt | 62 +++++++++++++++++++ .../quartz/utils/HexEncodingTest.kt | 49 +++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 4c834b2e8c..49137ac8aa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexEncodingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexEncodingTest.kt index 949dd0c426..46a3811c94 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexEncodingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexEncodingTest.kt @@ -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()) + } + } }