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
This commit is contained in:
Claude
2026-03-25 03:43:47 +00:00
parent f42881a48e
commit 403351610b
7 changed files with 515 additions and 1 deletions
@@ -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<CountResult> {
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> {
CountResult(
count = jsonObject["count"]!!.jsonPrimitive.int,
approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false,
hll = jsonObject["hll"]?.jsonPrimitive?.content?.let { HyperLogLog.decode(it) },
)
}
@@ -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<NormalizedRelayUrl>,
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 }
}
}
@@ -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}")
}
}
@@ -34,4 +34,5 @@ class CountMessage(
class CountResult(
val count: Int,
val approximate: Boolean = false,
val hll: ByteArray? = null,
)
@@ -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>): 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)
}
}
@@ -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<JsonObject>(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)
}
}
@@ -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)
}
}