From 403351610b7b4964f6c739050fddc631c7a19334 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 03:43:47 +0000 Subject: [PATCH] feat: implement NIP-45 HyperLogLog for probabilistic event counting Add client-side HyperLogLog support as specified by NIP-45: - HyperLogLog object with merge, estimate, encode/decode, and computeOffset for deterministic filter-based offset calculation - Add hll ByteArray field to CountResult for relay HLL data - Update CountResultKSerializer to serialize/deserialize hll hex field - Add queryCountMergedHll extension for multi-relay HLL merging - 20 unit tests covering HLL operations and serialization round-trips https://claude.ai/code/session_01CQzygEkwckLCiXzSRPMpBa --- .../CountResultKSerializer.kt | 3 + .../client/accessories/NostrClientCountExt.kt | 48 +++++ .../relay/client/accessories/RelayLogger.kt | 2 +- .../relay/commands/toClient/CountMessage.kt | 1 + .../quartz/nip45Count/HyperLogLog.kt | 179 ++++++++++++++++++ .../CountResultHllSerializationTest.kt | 121 ++++++++++++ .../quartz/nip45Count/HyperLogLogTest.kt | 162 ++++++++++++++++ 7 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLog.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLogTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt index ed0ff95ae..27cc372c3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.kotlinSerialization import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip45Count.HyperLogLog import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor @@ -57,6 +58,7 @@ object CountResultKSerializer : KSerializer { put("count", value.count) // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate put("pubkey", value.approximate) + value.hll?.let { put("hll", HyperLogLog.encode(it)) } } override fun deserialize(decoder: Decoder): CountResult { @@ -68,5 +70,6 @@ object CountResultKSerializer : KSerializer { CountResult( count = jsonObject["count"]!!.jsonPrimitive.int, approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false, + hll = jsonObject["hll"]?.jsonPrimitive?.content?.let { HyperLogLog.decode(it) }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index 15dd206ff..2816642c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip45Count.HyperLogLog import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.withTimeoutOrNull @@ -137,3 +138,50 @@ suspend fun INostrClient.queryCountSuspend( return results } + +/** + * Queries multiple relays for a COUNT and merges the HyperLogLog + * registers from all responses to produce a single merged estimate. + * + * If any relay returns HLL data, the results are merged by taking + * the maximum register value across all relays, and the cardinality + * is re-estimated from the merged registers. + * + * If no relay returns HLL data, falls back to the maximum count + * reported by any relay. + * + * @param relays List of relays to query. + * @param filter The filter to count against. + * @param timeoutMs How long to wait for all responses (default 15 s). + * @return A merged [CountResult], or `null` if no relay responded. + */ +suspend fun INostrClient.queryCountMergedHll( + relays: List, + filter: Filter, + timeoutMs: Long = 15_000, +): CountResult? { + if (relays.isEmpty()) return null + + val results = + queryCountSuspend( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = timeoutMs, + ) + + if (results.isEmpty()) return null + + val hlls = results.values.mapNotNull { it.hll } + + return if (hlls.isNotEmpty()) { + val merged = HyperLogLog.merge(hlls) + val estimate = HyperLogLog.estimate(merged) + CountResult( + count = estimate.toInt(), + approximate = true, + hll = merged, + ) + } else { + // No HLL data - use the maximum count from any relay + results.values.maxByOrNull { it.count } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index ceb4b36de..303f8727f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -63,7 +63,7 @@ class RelayLogger( is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}") is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}") - is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate}") + is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate} hll: ${msg.result.hll != null}") is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt index f6661bc23..27915753c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt @@ -34,4 +34,5 @@ class CountMessage( class CountResult( val count: Int, val approximate: Boolean = false, + val hll: ByteArray? = null, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLog.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLog.kt new file mode 100644 index 000000000..0b8097019 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLog.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip45Count + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.math.ln +import kotlin.math.pow + +/** + * NIP-45 HyperLogLog implementation for probabilistic cardinality estimation. + * + * Uses 256 registers (8-bit precision, p=8) as specified by NIP-45. + * Each register is a single byte storing the maximum number of leading + * zero bits + 1 observed for events mapping to that register. + * + * HLL values are transmitted as 512-character hex strings (256 bytes). + */ +object HyperLogLog { + const val NUM_REGISTERS = 256 + + // Bias correction constant for m=256: α = 0.7213 / (1 + 1.079/m) + private val ALPHA_M: Double = 0.7213 / (1.0 + 1.079 / NUM_REGISTERS) + + /** + * Merges multiple HLL register arrays by taking the maximum value + * for each register position across all inputs. + * + * @param hlls List of 256-byte register arrays from different relays + * @return Merged 256-byte register array + */ + fun merge(hlls: List): ByteArray { + val merged = ByteArray(NUM_REGISTERS) + for (hll in hlls) { + for (i in 0 until NUM_REGISTERS) { + val value = hll[i].toInt() and 0xFF + val current = merged[i].toInt() and 0xFF + if (value > current) { + merged[i] = value.toByte() + } + } + } + return merged + } + + /** + * Estimates the cardinality from an HLL register array using the + * standard HyperLogLog algorithm with small/large range corrections. + * + * @param registers 256-byte HLL register array + * @return Estimated cardinality + */ + fun estimate(registers: ByteArray): Long { + var harmonicSum = 0.0 + var zeroRegisters = 0 + + for (i in 0 until NUM_REGISTERS) { + val value = registers[i].toInt() and 0xFF + harmonicSum += 2.0.pow(-value.toDouble()) + if (value == 0) zeroRegisters++ + } + + val m = NUM_REGISTERS.toDouble() + var estimate = ALPHA_M * m * m / harmonicSum + + // Small range correction: use linear counting when estimate is small + // and there are empty registers + if (estimate <= 2.5 * m && zeroRegisters > 0) { + estimate = m * ln(m / zeroRegisters.toDouble()) + } + + return estimate.toLong() + } + + /** + * Decodes a 512-character hex string into a 256-byte HLL register array. + * + * @param hex 512-character hex string + * @return 256-byte register array, or null if the hex string is invalid + */ + fun decode(hex: String): ByteArray? { + if (hex.length != NUM_REGISTERS * 2) return null + return try { + Hex.decode(hex) + } catch (_: Exception) { + null + } + } + + /** + * Encodes a 256-byte HLL register array into a 512-character hex string. + * + * @param registers 256-byte register array + * @return 512-character hex string + */ + fun encode(registers: ByteArray): String = Hex.encode(registers) + + /** + * Computes the deterministic offset for a given filter, as specified + * by NIP-45. The offset determines which byte of event pubkeys is + * used as the register index. + * + * Algorithm: + * 1. Extract the first item from the filter's first tag attribute + * 2. Convert to a 64-character hex string: + * - If already a 64-char hex (event ID or pubkey): use directly + * - If an address (kind:pubkey:dtag): extract the pubkey + * - Otherwise: SHA-256 hash the value + * 3. Take the hex character at position 32 + * 4. Parse as base-16 and add 8 + * + * @param filter The filter to compute offset for + * @return The offset (8-23), or null if the filter has no tag attributes + */ + fun computeOffset(filter: Filter): Int? { + val firstTagValue = extractFirstTagValue(filter) ?: return null + val hex64 = toHex64(firstTagValue) ?: return null + val charAtPos32 = hex64[32] + val hexValue = charAtPos32.digitToIntOrNull(16) ?: return null + return hexValue + 8 + } + + /** + * Extracts the first value from the first tag attribute in the filter. + */ + private fun extractFirstTagValue(filter: Filter): String? { + val tags = filter.tags ?: return null + for ((_, values) in tags) { + if (values.isNotEmpty()) { + return values[0] + } + } + return null + } + + /** + * Converts a tag value to a 64-character hex string. + * + * - If it's already a 64-char hex string: return as-is + * - If it's a Nostr address (kind:pubkey:dtag): extract pubkey + * - Otherwise: SHA-256 hash and hex-encode + */ + private fun toHex64(value: String): String? { + // Already a 64-char hex (event ID or pubkey) + if (value.length == 64 && Hex.isHex(value)) { + return value + } + + // Try to parse as address (kind:pubkey:dtag) + val address = Address.parse(value) + if (address != null) { + return address.pubKeyHex + } + + // Fall back to SHA-256 + val hash = sha256(value.encodeToByteArray()) + return Hex.encode(hash) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt new file mode 100644 index 000000000..d4eff30ad --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip45Count + +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.CountResultKSerializer +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CountResultHllSerializationTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun testSerializeWithHll() { + val hll = ByteArray(256) { (it % 16).toByte() } + val result = CountResult(count = 42, approximate = true, hll = hll) + val element = CountResultKSerializer.serializeToElement(result) + + assertEquals(42, element["count"]!!.jsonPrimitive.int) + assertNotNull(element["hll"]) + assertEquals(512, element["hll"]!!.jsonPrimitive.content.length) + } + + @Test + fun testSerializeWithoutHll() { + val result = CountResult(count = 10, approximate = false) + val element = CountResultKSerializer.serializeToElement(result) + + assertEquals(10, element["count"]!!.jsonPrimitive.int) + assertNull(element["hll"]) + } + + @Test + fun testDeserializeWithHll() { + val hll = ByteArray(256) { (it % 8).toByte() } + val hllHex = HyperLogLog.encode(hll) + + val jsonObject: JsonObject = + buildJsonObject { + put("count", 100) + put("approximate", true) + put("hll", hllHex) + } + + val result = CountResultKSerializer.deserializeFromElement(jsonObject) + + assertEquals(100, result.count) + assertTrue(result.approximate) + assertNotNull(result.hll) + assertTrue(hll.contentEquals(result.hll!!)) + } + + @Test + fun testDeserializeWithoutHll() { + val jsonObject: JsonObject = + buildJsonObject { + put("count", 50) + } + + val result = CountResultKSerializer.deserializeFromElement(jsonObject) + + assertEquals(50, result.count) + assertNull(result.hll) + } + + @Test + fun testRoundTripSerialization() { + val hll = ByteArray(256) { (it * 3 % 20).toByte() } + val original = CountResult(count = 75, approximate = true, hll = hll) + + val element = CountResultKSerializer.serializeToElement(original) + val deserialized = CountResultKSerializer.deserializeFromElement(element) + + assertEquals(original.count, deserialized.count) + assertNotNull(deserialized.hll) + assertTrue(hll.contentEquals(deserialized.hll!!)) + } + + @Test + fun testDeserializeFromRelayJsonWithHll() { + // Simulate a real relay response: ["COUNT", "sub1", {"count": 42, "hll": "00010203..."}] + val hll = ByteArray(256) { 5 } + val hllHex = HyperLogLog.encode(hll) + + val jsonStr = """{"count":42,"hll":"$hllHex"}""" + val jsonObject = json.decodeFromString(jsonStr) + + val result = CountResultKSerializer.deserializeFromElement(jsonObject) + assertEquals(42, result.count) + assertNotNull(result.hll) + assertEquals(256, result.hll!!.size) + assertEquals(5, result.hll!![0].toInt() and 0xFF) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLogTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLogTest.kt new file mode 100644 index 000000000..7bfdc5824 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLogTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip45Count + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HyperLogLogTest { + @Test + fun testEmptyRegistersEstimateZero() { + val registers = ByteArray(256) + val estimate = HyperLogLog.estimate(registers) + assertEquals(0L, estimate) + } + + @Test + fun testEncodeDecodeRoundTrip() { + val registers = ByteArray(256) { (it % 10).toByte() } + val hex = HyperLogLog.encode(registers) + assertEquals(512, hex.length) + + val decoded = HyperLogLog.decode(hex) + assertNotNull(decoded) + assertTrue(registers.contentEquals(decoded)) + } + + @Test + fun testDecodeInvalidHex() { + assertNull(HyperLogLog.decode("too_short")) + assertNull(HyperLogLog.decode("")) + } + + @Test + fun testMergeSelectsMaxPerRegister() { + val hll1 = ByteArray(256) { 1 } + val hll2 = ByteArray(256) { 2 } + val hll3 = ByteArray(256) { 0 } + + // Set some specific registers higher in hll1 + hll1[0] = 5 + hll1[100] = 10 + + val merged = HyperLogLog.merge(listOf(hll1, hll2, hll3)) + + // Register 0: max(5, 2, 0) = 5 + assertEquals(5, merged[0].toInt() and 0xFF) + // Register 1: max(1, 2, 0) = 2 + assertEquals(2, merged[1].toInt() and 0xFF) + // Register 100: max(10, 2, 0) = 10 + assertEquals(10, merged[100].toInt() and 0xFF) + } + + @Test + fun testMergeSingleHll() { + val hll = ByteArray(256) { (it % 5).toByte() } + val merged = HyperLogLog.merge(listOf(hll)) + assertTrue(hll.contentEquals(merged)) + } + + @Test + fun testMergeEmptyList() { + val merged = HyperLogLog.merge(emptyList()) + assertEquals(256, merged.size) + assertTrue(merged.all { it == 0.toByte() }) + } + + @Test + fun testEstimateNonZeroRegisters() { + // Set all registers to a uniform value > 0 + val registers = ByteArray(256) { 3 } + val estimate = HyperLogLog.estimate(registers) + // With all registers at 3, the estimate should be positive + assertTrue(estimate > 0) + } + + @Test + fun testEstimateIncreaseWithHigherValues() { + val lowRegisters = ByteArray(256) { 2 } + val highRegisters = ByteArray(256) { 5 } + + val lowEstimate = HyperLogLog.estimate(lowRegisters) + val highEstimate = HyperLogLog.estimate(highRegisters) + + assertTrue(highEstimate > lowEstimate) + } + + @Test + fun testComputeOffsetWithEventIdFilter() { + // Event ID hex: the char at position 32 determines the offset + val eventId = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + val filter = Filter(tags = mapOf("e" to listOf(eventId))) + val offset = HyperLogLog.computeOffset(filter) + + // char at position 32 is 'a' = 10, offset = 10 + 8 = 18 + assertNotNull(offset) + assertEquals(18, offset) + } + + @Test + fun testComputeOffsetWithPubkeyFilter() { + val pubkey = "0000000000000000000000000000000000000000000000000000000000000000" + val filter = Filter(tags = mapOf("p" to listOf(pubkey))) + val offset = HyperLogLog.computeOffset(filter) + + // char at position 32 is '0' = 0, offset = 0 + 8 = 8 + assertNotNull(offset) + assertEquals(8, offset) + } + + @Test + fun testComputeOffsetWithNoTags() { + val filter = Filter(kinds = listOf(1)) + val offset = HyperLogLog.computeOffset(filter) + assertNull(offset) + } + + @Test + fun testComputeOffsetWithEmptyTags() { + val filter = Filter(tags = mapOf("e" to emptyList())) + val offset = HyperLogLog.computeOffset(filter) + assertNull(offset) + } + + @Test + fun testComputeOffsetWithNonHexValue() { + // Non-hex value should be SHA-256 hashed + val filter = Filter(tags = mapOf("t" to listOf("nostr"))) + val offset = HyperLogLog.computeOffset(filter) + assertNotNull(offset) + assertTrue(offset in 8..23) + } + + @Test + fun testComputeOffsetDeterministic() { + val filter = Filter(tags = mapOf("e" to listOf("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"))) + val offset1 = HyperLogLog.computeOffset(filter) + val offset2 = HyperLogLog.computeOffset(filter) + assertEquals(offset1, offset2) + } +}