stats_claw/algorithms/cardinality/mod.rs
1//! `HyperLogLog` distinct-count (cardinality) estimation.
2//!
3//! `HyperLogLog` (Flajolet, Fusy, Gandouet & Meunier, 2007) estimates the number of
4//! **distinct** elements in a stream using a fixed, tiny amount of memory — `m`
5//! one-byte registers — rather than the `O(distinct)` memory an exact `HashSet`
6//! count needs. Each element is hashed to a 64-bit word; the leading `p` bits pick
7//! one of `m = 2^p` registers, and the register keeps the maximum, over all
8//! elements routed to it, of `1 + (leading zeros of the remaining bits)`. Many
9//! distinct elements push some register's run of leading zeros high, and the
10//! harmonic mean of `2^register` across the registers estimates the cardinality.
11//!
12//! The estimator implemented here is the original 2007 form with its three regimes:
13//!
14//! * the **raw** estimate `E = α_m · m² / Σ_j 2^(−register_j)`;
15//! * a **small-range** correction — when `E ≤ 2.5·m` and some registers are still
16//! zero, linear counting `m · ln(m / zeros)` is more accurate;
17//! * a **large-range** correction near the 32-bit hash ceiling. This crate hashes
18//! to a full 64-bit word, so that ceiling is astronomically far away and the
19//! correction never fires in practice; it is implemented for completeness and
20//! documented as inert at 64-bit width.
21//!
22//! ## Accuracy
23//!
24//! `HyperLogLog` is an *approximate* counter: its relative standard error is
25//! `≈ 1.04 / √m`, so precision `p` trades memory (`m = 2^p` bytes) for accuracy.
26//! At `p = 14` (16 KiB) the standard error is about `0.81 %`. There is **no**
27//! canonical library bit-match to assert against — the equivalence suite instead
28//! pins the estimate against the **exact** distinct count (a ground truth computed
29//! with a `HashSet`) and checks it lands inside a small multiple of HLL's
30//! theoretical standard error.
31//!
32//! ## Determinism
33//!
34//! For a given precision and input multiset the estimate is fully deterministic:
35//! the hash (`hash`) is a fixed bijective finalizer, register updates are
36//! order-independent maxima, and the estimator is a pure function of the registers.
37//!
38//! This base block backs the `CardinalityEstimation` computational method.
39
40mod estimate;
41mod hash;
42
43use estimate::estimate_from_registers;
44use hash::hash64;
45
46/// Number of bits in the hash word the register index and run length are read from.
47const HASH_BITS: u32 = 64;
48
49/// Smallest supported `HyperLogLog` precision (register-index bit width).
50///
51/// Below `p = 4` the bias-correction constant `α_m` and the small-range regime are
52/// not well defined, so the original paper treats `m = 16` as the floor.
53const MIN_PRECISION: u8 = 4;
54/// Largest supported `HyperLogLog` precision.
55///
56/// `p = 18` is `m = 262_144` registers (256 KiB); beyond this the memory cost
57/// outweighs the accuracy gain for this crate's use, so it is the supported ceiling.
58const MAX_PRECISION: u8 = 18;
59
60/// Errors that prevent constructing or updating a `HyperLogLog` estimator.
61///
62/// Returned (never panicked) so callers stay clear of the crate's `unwrap`/`panic`
63/// lint gate and can surface a clean diagnostic.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum CardinalityError {
66 /// The requested precision was outside the supported `[4, 18]` range, so the
67 /// register count `2^p` and the `α_m` correction would be ill-defined.
68 InvalidPrecision,
69}
70
71impl std::fmt::Display for CardinalityError {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 Self::InvalidPrecision => {
75 write!(
76 f,
77 "precision must lie in [{MIN_PRECISION}, {MAX_PRECISION}]"
78 )
79 }
80 }
81 }
82}
83
84impl std::error::Error for CardinalityError {}
85
86/// A `HyperLogLog` distinct-count estimator over `m = 2^precision` byte registers.
87///
88/// Construct with [`HyperLogLog::new`], feed elements with [`HyperLogLog::add`]
89/// (or build directly with [`HyperLogLog::from_u64_iter`]), then read the estimated
90/// cardinality with [`HyperLogLog::estimate`]. The struct holds only the precision
91/// and the register array, so it is cheap to clone and fully deterministic.
92#[derive(Debug, Clone)]
93pub struct HyperLogLog {
94 /// Register-index bit width `p`; the register count is `2^p`.
95 precision: u8,
96 /// The `2^precision` registers, each holding the running maximum `1 + leading
97 /// zeros` for the hashes routed to it. One byte each (max run length `< 64`).
98 registers: Vec<u8>,
99}
100
101impl HyperLogLog {
102 /// Creates an empty estimator with `2^precision` zeroed registers.
103 ///
104 /// # Arguments
105 ///
106 /// * `precision` — the register-index bit width `p`; must lie in
107 /// `[4, 18]`. The register count is `m = 2^p` and the relative standard error
108 /// is `≈ 1.04 / √m`.
109 ///
110 /// # Returns
111 ///
112 /// An estimator whose estimate is `0` until elements are added.
113 ///
114 /// # Errors
115 ///
116 /// Returns [`CardinalityError::InvalidPrecision`] if `precision` is outside
117 /// `[4, 18]`.
118 ///
119 /// # Examples
120 ///
121 /// ```
122 /// use stats_claw::algorithms::cardinality::HyperLogLog;
123 ///
124 /// let mut hll = HyperLogLog::new(14)?;
125 /// for x in 0_u64..1000 {
126 /// hll.add(x);
127 /// }
128 /// // 1000 distinct elements, estimated within HLL's ~0.8% standard error at p=14.
129 /// let est = hll.estimate();
130 /// assert!((est - 1000.0).abs() / 1000.0 < 0.05, "estimate was {est}");
131 /// # Ok::<(), stats_claw::algorithms::cardinality::CardinalityError>(())
132 /// ```
133 pub fn new(precision: u8) -> Result<Self, CardinalityError> {
134 if !(MIN_PRECISION..=MAX_PRECISION).contains(&precision) {
135 return Err(CardinalityError::InvalidPrecision);
136 }
137 let m = 1_usize << precision;
138 Ok(Self {
139 precision,
140 registers: vec![0_u8; m],
141 })
142 }
143
144 /// Builds an estimator at `precision` from an iterator of `u64` elements.
145 ///
146 /// Equivalent to [`HyperLogLog::new`] followed by [`HyperLogLog::add`] for each
147 /// element, but reads as a single expression for the common "estimate the
148 /// distinct count of this stream" call. Because the register update is an
149 /// order-independent maximum, the result is independent of the iteration order.
150 ///
151 /// # Arguments
152 ///
153 /// * `precision` — the register-index bit width `p`; must lie in `[4, 18]`.
154 /// * `values` — the elements to record; duplicates count once.
155 ///
156 /// # Returns
157 ///
158 /// An estimator populated with every element of `values`.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`CardinalityError::InvalidPrecision`] if `precision` is outside
163 /// `[4, 18]`.
164 ///
165 /// # Examples
166 ///
167 /// ```
168 /// use stats_claw::algorithms::cardinality::HyperLogLog;
169 ///
170 /// // 200 distinct values, each seen twice — the distinct count is 200.
171 /// let stream = (0_u64..200).chain(0_u64..200);
172 /// let hll = HyperLogLog::from_u64_iter(12, stream)?;
173 /// let est = hll.estimate();
174 /// assert!((est - 200.0).abs() / 200.0 < 0.1, "estimate was {est}");
175 /// # Ok::<(), stats_claw::algorithms::cardinality::CardinalityError>(())
176 /// ```
177 pub fn from_u64_iter<I>(precision: u8, values: I) -> Result<Self, CardinalityError>
178 where
179 I: IntoIterator<Item = u64>,
180 {
181 let mut hll = Self::new(precision)?;
182 for value in values {
183 hll.add(value);
184 }
185 Ok(hll)
186 }
187
188 /// The precision `p` (register-index bit width) this estimator was built with.
189 #[must_use]
190 pub const fn precision(&self) -> u8 {
191 self.precision
192 }
193
194 /// The register count `m = 2^precision`.
195 #[must_use]
196 pub const fn register_count(&self) -> usize {
197 self.registers.len()
198 }
199
200 /// Adds a 64-bit element to the estimator, updating one register.
201 ///
202 /// The element is scattered by the fixed finalizer (`hash::hash64`); the
203 /// leading `precision` bits of the hash select the register, and the register is
204 /// raised to `max(current, 1 + leading_zeros(remaining bits))`. The update is a
205 /// maximum, so it is idempotent in the element (re-adding the same value never
206 /// changes any register) and order-independent across distinct elements — which
207 /// is what makes the estimate deterministic for a given input multiset.
208 ///
209 /// Non-`u64` elements are added by first reducing them to a `u64` key; integer
210 /// streams pass their value directly.
211 ///
212 /// # Arguments
213 ///
214 /// * `value` — the element to record. Equal values are treated as one distinct
215 /// element; the count is over the *set* of values seen.
216 pub fn add(&mut self, value: u64) {
217 let hash = hash64(value);
218 let p = u32::from(self.precision);
219 // The leading `p` bits address the register: shift the rest away.
220 let index = usize::try_from(hash >> (HASH_BITS - p)).unwrap_or(0);
221 // The remaining `64 - p` bits carry the run-length signal. Shift the index
222 // bits out (filling with zeros from the left); the rank is `1 + leading
223 // zeros` of that tail, capped so a hash whose tail is all zeros still gives
224 // a defined rank of `64 - p + 1`.
225 let tail = hash << p;
226 let rank = leading_zero_rank(tail, p);
227 if let Some(slot) = self.registers.get_mut(index)
228 && rank > *slot
229 {
230 *slot = rank;
231 }
232 }
233
234 /// Estimates the number of distinct elements added so far.
235 ///
236 /// A pure function of the register array: the harmonic-mean raw estimate with
237 /// the small-range (linear-counting) and large-range corrections applied as the
238 /// register state warrants (see `estimate`). An estimator with nothing added
239 /// returns `0` exactly.
240 ///
241 /// The result is an *approximation* with relative standard error `≈ 1.04 / √m`
242 /// (`m = 2^precision`); it is not the exact count, and callers needing the true
243 /// distinct count must use an exact structure instead.
244 ///
245 /// # Returns
246 ///
247 /// The estimated distinct-element count, `≥ 0`.
248 #[must_use]
249 pub fn estimate(&self) -> f64 {
250 estimate_from_registers(&self.registers)
251 }
252}
253
254/// Computes the `HyperLogLog` register rank `1 + leading_zeros` for a hash tail.
255///
256/// `tail` is the hash with its `p` index bits already shifted out to the left, so
257/// its own leading zeros count the run of zeros in the original `64 - p` tail bits.
258/// When `tail` is entirely zero (`leading_zeros == 64`) the run spans the whole
259/// `64 - p`-bit tail, giving the maximal rank `64 - p + 1`; the `min` clamps to that
260/// so a register never exceeds the representable maximum.
261///
262/// # Arguments
263///
264/// * `tail` — the hash left-shifted by `p` (index bits removed).
265/// * `p` — the precision; the tail carries `64 - p` meaningful bits.
266///
267/// # Returns
268///
269/// The register rank, in `1..=(64 - p + 1)`, as a `u8` (always `< 64`).
270fn leading_zero_rank(tail: u64, p: u32) -> u8 {
271 let tail_bits = HASH_BITS - p;
272 // `leading_zeros` counts from the full 64-bit word; the tail's own run of zeros
273 // is the same value because the index bits were shifted out as zeros only when
274 // the tail itself led with zeros. Cap at the tail width so an all-zero tail maps
275 // to the maximal rank rather than counting the shifted-in index zeros.
276 let zeros = tail.leading_zeros().min(tail_bits);
277 // rank = 1 + run length; `zeros + 1 <= 64 - p + 1 <= 61`, fits a u8.
278 u8::try_from(zeros + 1).unwrap_or(u8::MAX)
279}
280
281/// Kani proof harnesses for the `HyperLogLog` register-addressing arithmetic.
282///
283/// Compiled only under `cargo kani` (behind `#[cfg(kani)]`); invisible to normal
284/// build/test/clippy. They prove the two integer derivations `add` performs — the
285/// register index and the leading-zero rank — stay in bounds for *every* symbolic
286/// hash and supported precision, which is what makes the `registers.get_mut(index)`
287/// access and the one-byte rank storage provably safe.
288#[cfg(kani)]
289mod verification {
290 use super::{HASH_BITS, MAX_PRECISION, MIN_PRECISION, leading_zero_rank};
291
292 /// Proves [`leading_zero_rank`] returns a rank in `1..=(64 − p + 1)` — hence a
293 /// value that always fits the one-byte register — and never panics, for every
294 /// symbolic hash tail and every supported precision `p ∈ [4, 18]`.
295 ///
296 /// The `min(tail_bits)` clamp plus `+ 1` keep the rank below `64 − p + 2 ≤ 61`,
297 /// so the `u8::try_from` can never saturate; this discharges the register-width
298 /// safety argument symbolically rather than by sampled hashes.
299 #[kani::proof]
300 fn card_leading_zero_rank_in_range() {
301 let tail: u64 = kani::any();
302 let p: u32 = kani::any();
303 kani::assume(p >= u32::from(MIN_PRECISION));
304 kani::assume(p <= u32::from(MAX_PRECISION));
305 let rank = leading_zero_rank(tail, p);
306 assert!(rank >= 1, "rank underflowed below 1");
307 let max_rank = HASH_BITS - p + 1;
308 assert!(
309 u32::from(rank) <= max_rank,
310 "rank exceeded the tail-width ceiling"
311 );
312 }
313
314 /// Proves the register index `hash >> (64 − p)` that [`HyperLogLog::add`] derives
315 /// is strictly below the register count `2^p` for every symbolic hash and every
316 /// supported precision `p ∈ [4, 18]`.
317 ///
318 /// This is the formal guarantee that `add`'s `registers.get_mut(index)` addresses
319 /// an existing register (the array has exactly `2^p` entries): shifting a 64-bit
320 /// word right by `64 − p` leaves at most `p` significant bits, so the result is
321 /// `< 2^p`. The `try_from` mirrors `add`; the shift width `64 − p ∈ [46, 60]` is
322 /// always valid.
323 #[kani::proof]
324 fn card_register_index_in_bounds() {
325 let hash: u64 = kani::any();
326 let p: u32 = kani::any();
327 kani::assume(p >= u32::from(MIN_PRECISION));
328 kani::assume(p <= u32::from(MAX_PRECISION));
329 let index = usize::try_from(hash >> (HASH_BITS - p)).unwrap_or(0);
330 let register_count = 1_usize << p;
331 assert!(
332 index < register_count,
333 "register index escaped the register array"
334 );
335 }
336}
337
338#[cfg(test)]
339impl HyperLogLog {
340 /// Counts how many registers are non-zero (test introspection only).
341 ///
342 /// Lets unit tests assert that a single `add` touches exactly one register and
343 /// that the idempotent maximum leaves the register population unchanged.
344 fn nonzero_register_count(&self) -> usize {
345 self.registers.iter().filter(|&&r| r != 0).count()
346 }
347}
348
349#[cfg(test)]
350#[path = "tests.rs"]
351mod tests;