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 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 a fixed bijective finalizer; 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. 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#[cfg(test)]
282impl HyperLogLog {
283 /// Counts how many registers are non-zero (test introspection only).
284 ///
285 /// Lets unit tests assert that a single `add` touches exactly one register and
286 /// that the idempotent maximum leaves the register population unchanged.
287 fn nonzero_register_count(&self) -> usize {
288 self.registers.iter().filter(|&&r| r != 0).count()
289 }
290}
291
292#[cfg(test)]
293#[path = "tests.rs"]
294mod tests;