Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: New Crowdin translations by GitHub Action New Crowdin translations by GitHub Action feat: implement NIP-45 HyperLogLog for probabilistic event counting
This commit is contained in:
@@ -71,7 +71,7 @@
|
|||||||
<string name="following">" অনুসরণ"</string>
|
<string name="following">" অনুসরণ"</string>
|
||||||
<string name="followers">" অনুসারী"</string>
|
<string name="followers">" অনুসারী"</string>
|
||||||
<string name="number_following">"%1$s অনুসরণ"</string>
|
<string name="number_following">"%1$s অনুসরণ"</string>
|
||||||
<string name="number_followers">""</string>
|
<string name="number_followers">"%1$s অনুসারী"</string>
|
||||||
<string name="profile">প্রোফাইল</string>
|
<string name="profile">প্রোফাইল</string>
|
||||||
<string name="security_filters">নিরাপত্তা-ফিল্টার</string>
|
<string name="security_filters">নিরাপত্তা-ফিল্টার</string>
|
||||||
<string name="log_out">লগ আউট</string>
|
<string name="log_out">লগ আউট</string>
|
||||||
|
|||||||
@@ -448,6 +448,8 @@
|
|||||||
<string name="poll_zap_value_max">打闪最高金额</string>
|
<string name="poll_zap_value_max">打闪最高金额</string>
|
||||||
<string name="poll_consensus_threshold">共识</string>
|
<string name="poll_consensus_threshold">共识</string>
|
||||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||||
|
<string name="poll_single_choice">单选</string>
|
||||||
|
<string name="poll_multiple_choice">多选</string>
|
||||||
<string name="poll_closing_date_time">投票结束日期 & 时间</string>
|
<string name="poll_closing_date_time">投票结束日期 & 时间</string>
|
||||||
<string name="poll_closing_in">投票结束于 %1$s</string>
|
<string name="poll_closing_in">投票结束于 %1$s</string>
|
||||||
<string name="poll_closing_time">后关闭</string>
|
<string name="poll_closing_time">后关闭</string>
|
||||||
@@ -487,6 +489,9 @@
|
|||||||
<string name="zap_type_anonymous_explainer">接收方和公众不知道谁发送了付款</string>
|
<string name="zap_type_anonymous_explainer">接收方和公众不知道谁发送了付款</string>
|
||||||
<string name="zap_type_nonzap">非打闪</string>
|
<string name="zap_type_nonzap">非打闪</string>
|
||||||
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
|
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
|
||||||
|
<string name="post_anonymously">匿名</string>
|
||||||
|
<string name="post_anonymously_explainer">使用新的一次性身份发布。您的帐户将不会被链接到这个回复。</string>
|
||||||
|
<string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string>
|
||||||
<string name="file_server">文件服务器</string>
|
<string name="file_server">文件服务器</string>
|
||||||
<string name="file_server_description">选择上传文件时使用的服务器</string>
|
<string name="file_server_description">选择上传文件时使用的服务器</string>
|
||||||
<string name="zap_forward_lnAddress">闪电地址或 @User</string>
|
<string name="zap_forward_lnAddress">闪电地址或 @User</string>
|
||||||
@@ -786,7 +791,7 @@
|
|||||||
<string name="preferences">偏好设置</string>
|
<string name="preferences">偏好设置</string>
|
||||||
<string name="user_preferences">用户首选项</string>
|
<string name="user_preferences">用户首选项</string>
|
||||||
<string name="translations">翻译</string>
|
<string name="translations">翻译</string>
|
||||||
<string name="reactions">反应</string>
|
<string name="reactions">回应</string>
|
||||||
<string name="settings">设置</string>
|
<string name="settings">设置</string>
|
||||||
<string name="account_settings">账户设置</string>
|
<string name="account_settings">账户设置</string>
|
||||||
<string name="app_settings">应用程序设置</string>
|
<string name="app_settings">应用程序设置</string>
|
||||||
@@ -1119,8 +1124,8 @@
|
|||||||
<string name="like_description">点赞</string>
|
<string name="like_description">点赞</string>
|
||||||
<string name="zap_description">打闪</string>
|
<string name="zap_description">打闪</string>
|
||||||
<string name="change_reaction">修改快速回应</string>
|
<string name="change_reaction">修改快速回应</string>
|
||||||
<string name="reactions_settings">反应设置</string>
|
<string name="reactions_settings">回应设置</string>
|
||||||
<string name="reactions_settings_description">配置显示的反应按钮、它们的顺序以及是否显示计数器。</string>
|
<string name="reactions_settings_description">配置显示的回应按钮、按钮顺序及是否显示回应计数。</string>
|
||||||
<string name="reactions_settings_enabled">已启用</string>
|
<string name="reactions_settings_enabled">已启用</string>
|
||||||
<string name="reactions_settings_show_counter">显示计数</string>
|
<string name="reactions_settings_show_counter">显示计数</string>
|
||||||
<string name="reactions_settings_reorder">调整顺序</string>
|
<string name="reactions_settings_reorder">调整顺序</string>
|
||||||
@@ -1129,7 +1134,7 @@
|
|||||||
<string name="reactions_settings_boost">Boost</string>
|
<string name="reactions_settings_boost">Boost</string>
|
||||||
<string name="reactions_settings_boost_description">转发或引用此笔记</string>
|
<string name="reactions_settings_boost_description">转发或引用此笔记</string>
|
||||||
<string name="reactions_settings_like">点赞</string>
|
<string name="reactions_settings_like">点赞</string>
|
||||||
<string name="reactions_settings_like_description">用表情符号对此笔记进行反应</string>
|
<string name="reactions_settings_like_description">使用表情符号回应笔记</string>
|
||||||
<string name="reactions_settings_zap">打闪</string>
|
<string name="reactions_settings_zap">打闪</string>
|
||||||
<string name="reactions_settings_zap_description">给作者发送 Lightning 网络付款</string>
|
<string name="reactions_settings_zap_description">给作者发送 Lightning 网络付款</string>
|
||||||
<string name="reactions_settings_share">分享</string>
|
<string name="reactions_settings_share">分享</string>
|
||||||
@@ -1541,7 +1546,7 @@
|
|||||||
<string name="kind_private_relays">私密中继</string>
|
<string name="kind_private_relays">私密中继</string>
|
||||||
<string name="kind_proxy_relays">代理中继</string>
|
<string name="kind_proxy_relays">代理中继</string>
|
||||||
<string name="kind_public_message">公开消息</string>
|
<string name="kind_public_message">公开消息</string>
|
||||||
<string name="kind_reactions">反应</string>
|
<string name="kind_reactions">回应</string>
|
||||||
<string name="kind_contact_card">名片</string>
|
<string name="kind_contact_card">名片</string>
|
||||||
<string name="kind_relay_auth">中继认证</string>
|
<string name="kind_relay_auth">中继认证</string>
|
||||||
<string name="kind_relay_discovery">中继发现</string>
|
<string name="kind_relay_discovery">中继发现</string>
|
||||||
|
|||||||
+3
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.quartz.nip01Core.kotlinSerialization
|
package com.vitorpamplona.quartz.nip01Core.kotlinSerialization
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult
|
||||||
|
import com.vitorpamplona.quartz.nip45Count.HyperLogLog
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
@@ -57,6 +58,7 @@ object CountResultKSerializer : KSerializer<CountResult> {
|
|||||||
put("count", value.count)
|
put("count", value.count)
|
||||||
// Matches Jackson's CountResultSerializer which writes "pubkey" for approximate
|
// Matches Jackson's CountResultSerializer which writes "pubkey" for approximate
|
||||||
put("pubkey", value.approximate)
|
put("pubkey", value.approximate)
|
||||||
|
value.hll?.let { put("hll", HyperLogLog.encode(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): CountResult {
|
override fun deserialize(decoder: Decoder): CountResult {
|
||||||
@@ -68,5 +70,6 @@ object CountResultKSerializer : KSerializer<CountResult> {
|
|||||||
CountResult(
|
CountResult(
|
||||||
count = jsonObject["count"]!!.jsonPrimitive.int,
|
count = jsonObject["count"]!!.jsonPrimitive.int,
|
||||||
approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false,
|
approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false,
|
||||||
|
hll = jsonObject["hll"]?.jsonPrimitive?.content?.let { HyperLogLog.decode(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+48
@@ -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.commands.toClient.Message
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip45Count.HyperLogLog
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -137,3 +138,50 @@ suspend fun INostrClient.queryCountSuspend(
|
|||||||
|
|
||||||
return results
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ class RelayLogger(
|
|||||||
is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}")
|
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 AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}")
|
||||||
is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}")
|
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}")
|
is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -34,4 +34,5 @@ class CountMessage(
|
|||||||
class CountResult(
|
class CountResult(
|
||||||
val count: Int,
|
val count: Int,
|
||||||
val approximate: Boolean = false,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user