1#![cfg_attr(docsrs, doc(cfg(feature = "nip45")))]
21
22use sha2::{Digest, Sha256};
23use thiserror::Error;
24
25use crate::filter::Filter;
26use crate::message::SubscriptionId;
27use crate::util::hex;
28
29const HLL_REGISTERS: usize = 256;
30const HLL_HEX_LEN: usize = HLL_REGISTERS * 2;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CountRequest {
35 pub subscription_id: SubscriptionId,
37 pub filters: Vec<Filter>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CountResponse {
45 pub subscription_id: SubscriptionId,
47 pub count: u64,
49 pub approximate: bool,
51 pub hll: Option<HyperLogLog>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct HyperLogLog {
58 registers: [u8; HLL_REGISTERS],
59}
60
61#[expect(
62 clippy::cast_possible_truncation,
63 clippy::cast_sign_loss,
64 reason = "saturating cast is intentional and bounded by the branches above"
65)]
66fn f64_to_u64_saturating(value: f64) -> u64 {
67 if value.is_nan() || value <= 0.0 {
68 0
69 } else if value >= u64::MAX as f64 {
70 u64::MAX
71 } else {
72 value as u64
73 }
74}
75
76#[derive(Debug, Error)]
78#[non_exhaustive]
79pub enum CountError {
80 #[error("hll must be {HLL_HEX_LEN} hex characters, got {0}")]
82 HllLength(usize),
83 #[error("hll hex decode failure: {0}")]
85 HllHex(String),
86}
87
88impl HyperLogLog {
89 #[must_use]
91 pub const fn new() -> Self {
92 Self {
93 registers: [0u8; HLL_REGISTERS],
94 }
95 }
96
97 #[must_use]
99 pub const fn as_bytes(&self) -> &[u8; HLL_REGISTERS] {
100 &self.registers
101 }
102
103 #[must_use]
105 pub const fn from_bytes(registers: &[u8; HLL_REGISTERS]) -> Self {
106 Self {
107 registers: *registers,
108 }
109 }
110
111 #[must_use]
113 pub fn to_hex(self) -> String {
114 hex::encode(self.registers)
115 }
116
117 pub fn from_hex(hex_str: &str) -> Result<Self, CountError> {
124 if hex_str.len() != HLL_HEX_LEN {
125 return Err(CountError::HllLength(hex_str.len()));
126 }
127 let bytes = hex::decode(hex_str).map_err(|e| CountError::HllHex(e.to_string()))?;
128 let registers: [u8; HLL_REGISTERS] = bytes
129 .try_into()
130 .map_err(|v: Vec<u8>| CountError::HllLength(v.len() * 2))?;
131 Ok(Self { registers })
132 }
133
134 pub fn observe(&mut self, offset: usize, hash: &[u8; 32]) -> bool {
140 if !(8..=23).contains(&offset) {
141 return false;
142 }
143 let Some(®ister_byte) = hash.get(offset) else {
144 return false;
145 };
146 let register_index = register_byte as usize;
147 let tail = hash.get(offset + 1..).unwrap_or(&[]);
148 let mut zeros: u8 = 0;
151 for byte in tail {
152 if *byte == 0 {
153 zeros = zeros.saturating_add(8);
154 } else {
155 let leading = u8::try_from(byte.leading_zeros()).unwrap_or(u8::MAX);
156 zeros = zeros.saturating_add(leading);
157 break;
158 }
159 }
160 let value = zeros.saturating_add(1);
161 let Some(slot) = self.registers.get_mut(register_index) else {
162 return false;
163 };
164 if value > *slot {
165 *slot = value;
166 }
167 true
168 }
169
170 pub fn merge(&mut self, other: &Self) {
173 for (a, b) in self.registers.iter_mut().zip(other.registers.iter()) {
174 if *b > *a {
175 *a = *b;
176 }
177 }
178 }
179
180 #[must_use]
184 pub fn estimate(&self) -> u64 {
185 const M_F64: f64 = HLL_REGISTERS as f64;
186 let alpha_m = 0.7213_f64 / 1.079_f64.mul_add(1.0 / M_F64, 1.0);
187 let mut sum = 0.0_f64;
188 let mut zero_registers = 0_u32;
189 for &r in &self.registers {
190 if r == 0 {
191 zero_registers += 1;
192 }
193 sum += 2.0_f64.powi(-i32::from(r));
194 }
195 let raw = alpha_m * M_F64 * M_F64 / sum;
196 let estimate = if raw <= 2.5 * M_F64 && zero_registers > 0 {
197 M_F64 * (M_F64 / f64::from(zero_registers)).ln()
198 } else {
199 raw
200 };
201 f64_to_u64_saturating(estimate.round())
202 }
203}
204
205impl Default for HyperLogLog {
206 fn default() -> Self {
207 Self::new()
208 }
209}
210
211#[must_use]
222pub fn hll_offset_for_value(value: &str) -> usize {
223 let hex_str = canonical_hex_for_value(value);
224 let nibble = hex_str
225 .as_bytes()
226 .get(32)
227 .copied()
228 .map_or(0u8, hex_digit_value);
229 8 + nibble as usize
230}
231
232fn canonical_hex_for_value(value: &str) -> String {
233 if is_64_hex(value) {
234 return value.to_ascii_lowercase();
235 }
236 if let Some(pubkey) = coordinate_pubkey_part(value)
237 && is_64_hex(pubkey)
238 {
239 return pubkey.to_ascii_lowercase();
240 }
241 let mut hasher = Sha256::new();
242 hasher.update(value.as_bytes());
243 let digest = hasher.finalize();
244 hex::encode(digest)
245}
246
247fn is_64_hex(s: &str) -> bool {
248 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
249}
250
251fn coordinate_pubkey_part(s: &str) -> Option<&str> {
252 let mut parts = s.splitn(3, ':');
253 let _kind = parts.next()?;
254 let pubkey = parts.next()?;
255 parts.next()?;
256 Some(pubkey)
257}
258
259const fn hex_digit_value(byte: u8) -> u8 {
260 match byte {
261 b'0'..=b'9' => byte - b'0',
262 b'a'..=b'f' => byte - b'a' + 10,
263 b'A'..=b'F' => byte - b'A' + 10,
264 _ => 0,
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn hll_round_trip_hex() {
274 let mut hll = HyperLogLog::new();
275 hll.registers[0] = 5;
276 hll.registers[255] = 8;
277 let hex_str = hll.to_hex();
278 assert_eq!(hex_str.len(), HLL_HEX_LEN);
279 let parsed = HyperLogLog::from_hex(&hex_str).unwrap();
280 assert_eq!(parsed, hll);
281 }
282
283 #[test]
284 fn hll_merge_keeps_max() {
285 let mut a = HyperLogLog::new();
286 a.registers[0] = 3;
287 let mut b = HyperLogLog::new();
288 b.registers[0] = 5;
289 a.merge(&b);
290 assert_eq!(a.registers[0], 5);
291 }
292
293 #[test]
294 fn hll_offset_for_event_id() {
295 let event_id = "0".repeat(32) + "f" + &"0".repeat(31);
296 assert_eq!(hll_offset_for_value(&event_id), 23);
298 }
299
300 #[test]
301 fn hll_offset_for_arbitrary_string_uses_sha256() {
302 let v = hll_offset_for_value("hello world");
305 assert_eq!(v, 20);
306 }
307
308 #[test]
309 fn hll_observe_records_leading_zeros() {
310 let mut hll = HyperLogLog::new();
311 let mut hash = [0u8; 32];
315 hash[8] = 0xff;
316 assert!(hll.observe(8, &hash));
317 assert_eq!(
318 hll.registers[0xff],
319 8u8.saturating_mul(23).saturating_add(1)
320 );
321 }
322
323 #[test]
324 fn hll_observe_records_high_first_bit() {
325 let mut hll = HyperLogLog::new();
326 let mut hash = [0u8; 32];
327 hash[8] = 0xff;
328 hash[9] = 0x80;
329 assert!(hll.observe(8, &hash));
330 assert_eq!(hll.registers[0xff], 1);
333 }
334}