Skip to main content

HyperLogLog

Struct HyperLogLog 

Source
pub struct HyperLogLog { /* private fields */ }

Implementations§

Source§

impl HyperLogLog

Source

pub fn to_bytes(&self) -> Vec<u8>

Serialise to the canonical dense form: an 8-byte header then the raw register array. Length is always 8 + 2^p, so a reader can size the allocation from the header alone.

Examples found in repository?
examples/sample_app.rs (line 216)
209fn collector_fan_in(tape: &[Event]) {
210    println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212    let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213    for e in tape {
214        per_venue[e.venue as usize].add_u64(e.account);
215    }
216    let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217    let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219    let mut firm = HyperLogLog::new(14);
220    for bytes in &shipped {
221        let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222        firm.merge(&decoded).expect("same precision");
223    }
224    println!(
225        "  {} sketches on the wire, {} bytes total",
226        shipped.len(),
227        on_wire
228    );
229    println!(
230        "  raw ids would have been ~{} bytes",
231        tape.len() * core::mem::size_of::<u64>()
232    );
233    println!("  firm-wide reach: {:.0} (true 50000)", firm.estimate());
234    assert!(
235        (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236        "merged reach within 5%"
237    );
238}
Source

pub fn from_bytes(bytes: &[u8]) -> Result<Self, HllError>

Parse a dense buffer. A sparse buffer is rejected with UnsupportedEncoding rather than silently densified - use SparseHyperLogLog::from_bytes, which reads both.

Examples found in repository?
examples/sample_app.rs (line 221)
209fn collector_fan_in(tape: &[Event]) {
210    println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212    let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213    for e in tape {
214        per_venue[e.venue as usize].add_u64(e.account);
215    }
216    let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217    let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219    let mut firm = HyperLogLog::new(14);
220    for bytes in &shipped {
221        let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222        firm.merge(&decoded).expect("same precision");
223    }
224    println!(
225        "  {} sketches on the wire, {} bytes total",
226        shipped.len(),
227        on_wire
228    );
229    println!(
230        "  raw ids would have been ~{} bytes",
231        tape.len() * core::mem::size_of::<u64>()
232    );
233    println!("  firm-wide reach: {:.0} (true 50000)", firm.estimate());
234    assert!(
235        (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236        "merged reach within 5%"
237    );
238}
Source§

impl HyperLogLog

Source

pub fn new(precision: u32) -> Self

New empty HLL at the given precision. precision is clamped to [4, 18]; 14 gives ~16k registers / ~16 KB / ~1% std error. Use HyperLogLog::try_new when a caller-supplied precision should be rejected rather than silently pulled into range.

Examples found in repository?
examples/sample_app.rs (line 103)
101fn gateway_sessions(tape: &[Event]) {
102    println!("== gateway: distinct sessions this window ==");
103    let mut hll = HyperLogLog::new(14);
104    let mut first_sightings = 0u64;
105    for e in tape {
106        if hll.add_u64(e.session) {
107            first_sightings += 1;
108        }
109    }
110    let est = hll.estimate();
111    println!("  {} messages -> {:.0} distinct sessions", tape.len(), est);
112    println!(
113        "  {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114        first_sightings,
115        hll.state_bytes(),
116        hll.standard_error() * 100.0
117    );
118    assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125    println!("\n== sizing: error budget -> byte budget ==");
126    for budget in [0.05, 0.02, 0.01, 0.005] {
127        let p = HyperLogLog::precision_for_standard_error(budget);
128        let hll = HyperLogLog::new(p);
129        println!(
130            "  budget {:>5.1}%  ->  p={:<2} {:>6} bytes, actual {:.5}%",
131            budget * 100.0,
132            p,
133            hll.state_bytes(),
134            hll.standard_error() * 100.0
135        );
136    }
137}
138
139/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
140/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
141/// across a real universe. The sparse encoding pays only for registers actually
142/// touched, and promotes the two busy names once they earn it.
143#[cfg(feature = "sparse")]
144fn per_symbol_counterparties(tape: &[Event]) {
145    use subms_hyperloglog::SparseHyperLogLog;
146    println!("\n== risk: distinct counterparties per symbol ==");
147
148    let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
149        .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
150        .collect();
151    for e in tape {
152        books[e.symbol].add_u64(e.counterparty);
153    }
154
155    let mut sparse_bytes = 0usize;
156    for (i, b) in books.iter().enumerate() {
157        println!(
158            "  {:<5} {:>7.0} counterparties  {:>6} bytes  {}",
159            SYMBOLS[i],
160            b.estimate(),
161            b.state_bytes(),
162            if b.is_sparse() { "sparse" } else { "dense" }
163        );
164        sparse_bytes += b.state_bytes();
165    }
166    let dense_bytes = SYMBOLS.len() * 16_384;
167    println!("  total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
168    assert!(
169        sparse_bytes < dense_bytes,
170        "sparse must win on the long tail"
171    );
172}
173
174/// `union-intersect`: how many accounts trade on both venues? Inclusion-
175/// exclusion answers it from two sketches. The error bound is printed next to
176/// the answer because it scales with |A| + |B| rather than with the overlap,
177/// and an overlap smaller than its own bound is not a number to act on.
178#[cfg(feature = "union-intersect")]
179fn cross_venue_overlap(tape: &[Event]) {
180    use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
181    println!("\n== venues: account reach and overlap ==");
182
183    let mut a = HyperLogLog::new(14);
184    let mut b = HyperLogLog::new(14);
185    for e in tape {
186        if e.venue == 0 {
187            a.add_u64(e.account);
188        } else {
189            b.add_u64(e.account);
190        }
191    }
192    let union = estimate_union(&a, &b).expect("same precision");
193    let inter = estimate_intersect(&a, &b).expect("same precision");
194    let bound = intersect_error_bound(&a, &b).expect("same precision");
195    println!("  venue 0: {:>7.0} accounts", a.estimate());
196    println!("  venue 1: {:>7.0} accounts", b.estimate());
197    println!("  reach:   {union:>7.0} (true 50000)");
198    println!("  both:    {inter:>7.0} (true 10000) +/- {bound:.0}");
199    assert!(
200        (union - 50_000.0).abs() / 50_000.0 < 0.05,
201        "reach within 5%, got {union}"
202    );
203    assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
204}
205
206/// `serialize`: each venue ships its sketch, not its account list. The
207/// collector decodes and merges, and the firm-wide number falls out of 16 KB
208/// per venue instead of a million ids on the wire.
209fn collector_fan_in(tape: &[Event]) {
210    println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212    let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213    for e in tape {
214        per_venue[e.venue as usize].add_u64(e.account);
215    }
216    let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217    let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219    let mut firm = HyperLogLog::new(14);
220    for bytes in &shipped {
221        let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222        firm.merge(&decoded).expect("same precision");
223    }
224    println!(
225        "  {} sketches on the wire, {} bytes total",
226        shipped.len(),
227        on_wire
228    );
229    println!(
230        "  raw ids would have been ~{} bytes",
231        tape.len() * core::mem::size_of::<u64>()
232    );
233    println!("  firm-wide reach: {:.0} (true 50000)", firm.estimate());
234    assert!(
235        (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236        "merged reach within 5%"
237    );
238}
More examples
Hide additional examples
examples/perf_features.rs (line 135)
116fn main() -> io::Result<()> {
117    let ks = keys();
118
119    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
120        .join("..")
121        .join(".subms")
122        .join("features")
123        .join("rust.json");
124    let existing = std::fs::read_to_string(&path).unwrap_or_default();
125    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
126    // Stamp the box these numbers came from. The bench runs wherever it is
127    // invoked, so an unstamped manifest is indistinguishable from a fleet
128    // capture; the renderer will not publish one it cannot attribute.
129    let (source, instance) = SubMsP99Source::from_env();
130    manifest.set_p99_source(source, instance.as_deref());
131
132    // The baseline is base `add`, the per-op path. NOT base `estimate`: that
133    // folds all 2^p registers, so classifying a per-key feature against it would
134    // let anything look free.
135    let mut base = HyperLogLog::new(CANON_P);
136    let base_p50 = keyed_p50(&mut base, &ks[..OPS], |h, k| {
137        h.add(k);
138    });
139    eprintln!("base add p50: {base_p50}ns");
140
141    // ---------- sparse: a linear entry list until it earns the dense array ----------
142    #[cfg(feature = "sparse")]
143    {
144        use subms_hyperloglog::SparseHyperLogLog;
145        // Swept over SPARSE LIST LENGTH, not over precision. `add` linear-probes
146        // the list, so length is the cost driver; precision only sets it
147        // indirectly through the `m/4` promotion threshold, and swept that way
148        // the curve is a step rather than a slope. At p=12 and p=15 the
149        // structure promotes early, so BOTH low points measure the dense floor
150        // (100ns) rather than a small sparse probe, and at p=18 the list is
151        // capped by the key count instead of by the threshold. The resulting
152        // ratio landed either side of the classifier's guard - 40x in Rust,
153        // 23x in Java - which is a measurement artefact, not a real disagreement.
154        //
155        // `with_threshold` exists for exactly this: pin promotion out of reach
156        // and add n keys, and the swept axis IS the list length.
157        //
158        // The list is built to length n OUTSIDE the timed region, and the timed
159        // ops are re-adds of keys already in it - a fixed OPS of them at every
160        // size, so the op count is constant and the scan length is the only
161        // thing varying. Re-adding rather than adding fresh keys keeps the list
162        // from growing under measurement.
163        let sw = sweep_sizes("sparse/add(list-len)", &LIST_LENS, |n| {
164            let mut s = SparseHyperLogLog::with_threshold(CANON_P, n + 1);
165            for k in &ks[..n] {
166                s.add(k);
167            }
168            let mut h = SubMsPerfHarness::new("hll-feature", "rust");
169            let st = h.stage("op", OPS);
170            for i in 0..OPS {
171                let k = &ks[(i * 7919) % n];
172                st.time(|| s.add(k));
173            }
174            stat(&h, true)
175        });
176        // PINNED structural when the ratio test cannot carry it. `add`
177        // linear-probes the sparse list, so it is O(entries) from the source and
178        // the sweep above is monotonic and strongly rising. What it is not is
179        // 32x: a long scan runs ~0.34 ns/element against ~0.93 for a short one,
180        // so a true O(n) op measures ~23x over a 64x span and falls under the
181        // classifier's 0.5 guard. Publishing that as hot-path would tell a
182        // reader the probe is free at high precision. It is not, and the pin
183        // says a human decided rather than dressing the decision as measured.
184        let (cat, reason) = classify_feature(
185            &sw,
186            Some(base_p50),
187            Some(subms::SubMsFeatureCategory::Structural),
188        );
189
190        let mut s = SparseHyperLogLog::new(CANON_P);
191        let mut p99 = BTreeMap::new();
192        p99.insert(
193            "add".to_string(),
194            keyed_p99(&mut s, &ks[..OPS], |x, k| {
195                x.add(k);
196            }),
197        );
198        p99.insert(
199            "estimate".to_string(),
200            bulk(
201                || {
202                    let mut x = SparseHyperLogLog::new(CANON_P);
203                    for k in &ks[..OPS] {
204                        x.add(k);
205                    }
206                    x
207                },
208                |x| _ = x.estimate(),
209                false,
210            ),
211        );
212        manifest.set_feature("sparse", cat, &p99, &reason);
213    }
214
215    // ---------- union-intersect: pairwise folds over both register arrays ----------
216    #[cfg(feature = "union-intersect")]
217    {
218        use subms_hyperloglog::{estimate_intersect, estimate_union};
219        // Both HLLs are built by `setup`, outside the timed region. A union is a
220        // pure read of two register arrays, so repeating it does identical work.
221        // Filled with `m` keys, not a fixed count. OCCUPANCY has to be held
222        // constant or it, not size, is what the sweep measures: `estimate` costs
223        // `2f64.powi(-r)` per register and `powi(0)` takes a fast path, so a
224        // fixed key set against a growing array leaves 92% of registers zero at
225        // p=18 against 0% at p=12. That reads as a per-register cost falling
226        // with size, and it compressed a triple-O(m) op to 26x over 64x.
227        let build = |p: u32| {
228            let n = regs(p);
229            let mut a = HyperLogLog::new(p);
230            let mut b = HyperLogLog::new(p);
231            for (i, k) in ks[..n].iter().enumerate() {
232                a.add(k);
233                if i % 2 == 0 {
234                    b.add(k);
235                }
236            }
237            (a, b)
238        };
239        let sw = sweep("union-intersect/estimate_union", |p| {
240            bulk(
241                || build(p),
242                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
243                true,
244            )
245        });
246        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
247
248        let mut p99 = BTreeMap::new();
249        p99.insert(
250            "union".to_string(),
251            bulk(
252                || build(CANON_P),
253                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
254                false,
255            ),
256        );
257        p99.insert(
258            "intersect".to_string(),
259            bulk(
260                || build(CANON_P),
261                |(a, b)| _ = estimate_intersect(a, b).expect("same precision"),
262                false,
263            ),
264        );
265        manifest.set_feature("union-intersect", cat, &p99, &reason);
266    }
267
268    std::fs::create_dir_all(path.parent().unwrap())?;
269    std::fs::write(&path, manifest.to_json())?;
270    io::stdout().write_all(manifest.to_json().as_bytes())?;
271    Ok(())
272}
Source

pub fn try_new(precision: u32) -> Result<Self, HllError>

New empty HLL, rejecting a precision outside [4, 18] instead of clamping it. Reach for this when the precision comes from config or a wire message and a typo should fail loudly.

Source

pub fn precision(&self) -> u32

Source

pub fn register_count(&self) -> u32

Source

pub fn standard_error(&self) -> f64

Analytic relative standard error, 1.04 / sqrt(m). This is the error the structure carries by construction, not a measurement of the current contents: at p=14 it is 0.813%, so a 1,000,000 estimate is one standard deviation away from anything in [992k, 1008k].

Examples found in repository?
examples/sample_app.rs (line 116)
101fn gateway_sessions(tape: &[Event]) {
102    println!("== gateway: distinct sessions this window ==");
103    let mut hll = HyperLogLog::new(14);
104    let mut first_sightings = 0u64;
105    for e in tape {
106        if hll.add_u64(e.session) {
107            first_sightings += 1;
108        }
109    }
110    let est = hll.estimate();
111    println!("  {} messages -> {:.0} distinct sessions", tape.len(), est);
112    println!(
113        "  {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114        first_sightings,
115        hll.state_bytes(),
116        hll.standard_error() * 100.0
117    );
118    assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125    println!("\n== sizing: error budget -> byte budget ==");
126    for budget in [0.05, 0.02, 0.01, 0.005] {
127        let p = HyperLogLog::precision_for_standard_error(budget);
128        let hll = HyperLogLog::new(p);
129        println!(
130            "  budget {:>5.1}%  ->  p={:<2} {:>6} bytes, actual {:.5}%",
131            budget * 100.0,
132            p,
133            hll.state_bytes(),
134            hll.standard_error() * 100.0
135        );
136    }
137}
Source

pub fn precision_for_standard_error(target: f64) -> u32

Smallest precision whose standard error is at or below target (expressed as a fraction, so 0.01 for 1%). Clamped to [4, 18], so a target finer than 0.26% returns 18 and the caller gets the best this recipe allocates for rather than an error.

Examples found in repository?
examples/sample_app.rs (line 127)
124fn size_from_an_error_budget() {
125    println!("\n== sizing: error budget -> byte budget ==");
126    for budget in [0.05, 0.02, 0.01, 0.005] {
127        let p = HyperLogLog::precision_for_standard_error(budget);
128        let hll = HyperLogLog::new(p);
129        println!(
130            "  budget {:>5.1}%  ->  p={:<2} {:>6} bytes, actual {:.5}%",
131            budget * 100.0,
132            p,
133            hll.state_bytes(),
134            hll.standard_error() * 100.0
135        );
136    }
137}
Source

pub fn state_bytes(&self) -> usize

Bytes of register state this sketch holds. Fixed at construction and independent of how many items it has seen.

Examples found in repository?
examples/sample_app.rs (line 115)
101fn gateway_sessions(tape: &[Event]) {
102    println!("== gateway: distinct sessions this window ==");
103    let mut hll = HyperLogLog::new(14);
104    let mut first_sightings = 0u64;
105    for e in tape {
106        if hll.add_u64(e.session) {
107            first_sightings += 1;
108        }
109    }
110    let est = hll.estimate();
111    println!("  {} messages -> {:.0} distinct sessions", tape.len(), est);
112    println!(
113        "  {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114        first_sightings,
115        hll.state_bytes(),
116        hll.standard_error() * 100.0
117    );
118    assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125    println!("\n== sizing: error budget -> byte budget ==");
126    for budget in [0.05, 0.02, 0.01, 0.005] {
127        let p = HyperLogLog::precision_for_standard_error(budget);
128        let hll = HyperLogLog::new(p);
129        println!(
130            "  budget {:>5.1}%  ->  p={:<2} {:>6} bytes, actual {:.5}%",
131            budget * 100.0,
132            p,
133            hll.state_bytes(),
134            hll.standard_error() * 100.0
135        );
136    }
137}
Source

pub fn is_empty(&self) -> bool

True while every register is still zero.

Source

pub fn clear(&mut self)

Zero every register, keeping the allocation. Reuse across windows without re-allocating the array.

Source

pub fn add(&mut self, key: &str) -> bool

Record a key. Returns true when the sketch changed - a register moved up, so this key was the first of its kind to land that deep. Matching PFADD’s return, and cheap enough to ignore when you do not want it.

Examples found in repository?
examples/perf_features.rs (line 137)
116fn main() -> io::Result<()> {
117    let ks = keys();
118
119    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
120        .join("..")
121        .join(".subms")
122        .join("features")
123        .join("rust.json");
124    let existing = std::fs::read_to_string(&path).unwrap_or_default();
125    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
126    // Stamp the box these numbers came from. The bench runs wherever it is
127    // invoked, so an unstamped manifest is indistinguishable from a fleet
128    // capture; the renderer will not publish one it cannot attribute.
129    let (source, instance) = SubMsP99Source::from_env();
130    manifest.set_p99_source(source, instance.as_deref());
131
132    // The baseline is base `add`, the per-op path. NOT base `estimate`: that
133    // folds all 2^p registers, so classifying a per-key feature against it would
134    // let anything look free.
135    let mut base = HyperLogLog::new(CANON_P);
136    let base_p50 = keyed_p50(&mut base, &ks[..OPS], |h, k| {
137        h.add(k);
138    });
139    eprintln!("base add p50: {base_p50}ns");
140
141    // ---------- sparse: a linear entry list until it earns the dense array ----------
142    #[cfg(feature = "sparse")]
143    {
144        use subms_hyperloglog::SparseHyperLogLog;
145        // Swept over SPARSE LIST LENGTH, not over precision. `add` linear-probes
146        // the list, so length is the cost driver; precision only sets it
147        // indirectly through the `m/4` promotion threshold, and swept that way
148        // the curve is a step rather than a slope. At p=12 and p=15 the
149        // structure promotes early, so BOTH low points measure the dense floor
150        // (100ns) rather than a small sparse probe, and at p=18 the list is
151        // capped by the key count instead of by the threshold. The resulting
152        // ratio landed either side of the classifier's guard - 40x in Rust,
153        // 23x in Java - which is a measurement artefact, not a real disagreement.
154        //
155        // `with_threshold` exists for exactly this: pin promotion out of reach
156        // and add n keys, and the swept axis IS the list length.
157        //
158        // The list is built to length n OUTSIDE the timed region, and the timed
159        // ops are re-adds of keys already in it - a fixed OPS of them at every
160        // size, so the op count is constant and the scan length is the only
161        // thing varying. Re-adding rather than adding fresh keys keeps the list
162        // from growing under measurement.
163        let sw = sweep_sizes("sparse/add(list-len)", &LIST_LENS, |n| {
164            let mut s = SparseHyperLogLog::with_threshold(CANON_P, n + 1);
165            for k in &ks[..n] {
166                s.add(k);
167            }
168            let mut h = SubMsPerfHarness::new("hll-feature", "rust");
169            let st = h.stage("op", OPS);
170            for i in 0..OPS {
171                let k = &ks[(i * 7919) % n];
172                st.time(|| s.add(k));
173            }
174            stat(&h, true)
175        });
176        // PINNED structural when the ratio test cannot carry it. `add`
177        // linear-probes the sparse list, so it is O(entries) from the source and
178        // the sweep above is monotonic and strongly rising. What it is not is
179        // 32x: a long scan runs ~0.34 ns/element against ~0.93 for a short one,
180        // so a true O(n) op measures ~23x over a 64x span and falls under the
181        // classifier's 0.5 guard. Publishing that as hot-path would tell a
182        // reader the probe is free at high precision. It is not, and the pin
183        // says a human decided rather than dressing the decision as measured.
184        let (cat, reason) = classify_feature(
185            &sw,
186            Some(base_p50),
187            Some(subms::SubMsFeatureCategory::Structural),
188        );
189
190        let mut s = SparseHyperLogLog::new(CANON_P);
191        let mut p99 = BTreeMap::new();
192        p99.insert(
193            "add".to_string(),
194            keyed_p99(&mut s, &ks[..OPS], |x, k| {
195                x.add(k);
196            }),
197        );
198        p99.insert(
199            "estimate".to_string(),
200            bulk(
201                || {
202                    let mut x = SparseHyperLogLog::new(CANON_P);
203                    for k in &ks[..OPS] {
204                        x.add(k);
205                    }
206                    x
207                },
208                |x| _ = x.estimate(),
209                false,
210            ),
211        );
212        manifest.set_feature("sparse", cat, &p99, &reason);
213    }
214
215    // ---------- union-intersect: pairwise folds over both register arrays ----------
216    #[cfg(feature = "union-intersect")]
217    {
218        use subms_hyperloglog::{estimate_intersect, estimate_union};
219        // Both HLLs are built by `setup`, outside the timed region. A union is a
220        // pure read of two register arrays, so repeating it does identical work.
221        // Filled with `m` keys, not a fixed count. OCCUPANCY has to be held
222        // constant or it, not size, is what the sweep measures: `estimate` costs
223        // `2f64.powi(-r)` per register and `powi(0)` takes a fast path, so a
224        // fixed key set against a growing array leaves 92% of registers zero at
225        // p=18 against 0% at p=12. That reads as a per-register cost falling
226        // with size, and it compressed a triple-O(m) op to 26x over 64x.
227        let build = |p: u32| {
228            let n = regs(p);
229            let mut a = HyperLogLog::new(p);
230            let mut b = HyperLogLog::new(p);
231            for (i, k) in ks[..n].iter().enumerate() {
232                a.add(k);
233                if i % 2 == 0 {
234                    b.add(k);
235                }
236            }
237            (a, b)
238        };
239        let sw = sweep("union-intersect/estimate_union", |p| {
240            bulk(
241                || build(p),
242                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
243                true,
244            )
245        });
246        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
247
248        let mut p99 = BTreeMap::new();
249        p99.insert(
250            "union".to_string(),
251            bulk(
252                || build(CANON_P),
253                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
254                false,
255            ),
256        );
257        p99.insert(
258            "intersect".to_string(),
259            bulk(
260                || build(CANON_P),
261                |(a, b)| _ = estimate_intersect(a, b).expect("same precision"),
262                false,
263            ),
264        );
265        manifest.set_feature("union-intersect", cat, &p99, &reason);
266    }
267
268    std::fs::create_dir_all(path.parent().unwrap())?;
269    std::fs::write(&path, manifest.to_json())?;
270    io::stdout().write_all(manifest.to_json().as_bytes())?;
271    Ok(())
272}
Source

pub fn add_bytes(&mut self, key: &[u8]) -> bool

Record raw bytes. The string path funnels through here, so add("AAPL") and add_bytes(b"AAPL") land in the same register.

Source

pub fn add_u64(&mut self, key: u64) -> bool

Record a 64-bit id without rendering it to a string first. Hashes the big-endian bytes, so the Rust and Java ports agree register for register on the same id.

Examples found in repository?
examples/sample_app.rs (line 106)
101fn gateway_sessions(tape: &[Event]) {
102    println!("== gateway: distinct sessions this window ==");
103    let mut hll = HyperLogLog::new(14);
104    let mut first_sightings = 0u64;
105    for e in tape {
106        if hll.add_u64(e.session) {
107            first_sightings += 1;
108        }
109    }
110    let est = hll.estimate();
111    println!("  {} messages -> {:.0} distinct sessions", tape.len(), est);
112    println!(
113        "  {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114        first_sightings,
115        hll.state_bytes(),
116        hll.standard_error() * 100.0
117    );
118    assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125    println!("\n== sizing: error budget -> byte budget ==");
126    for budget in [0.05, 0.02, 0.01, 0.005] {
127        let p = HyperLogLog::precision_for_standard_error(budget);
128        let hll = HyperLogLog::new(p);
129        println!(
130            "  budget {:>5.1}%  ->  p={:<2} {:>6} bytes, actual {:.5}%",
131            budget * 100.0,
132            p,
133            hll.state_bytes(),
134            hll.standard_error() * 100.0
135        );
136    }
137}
138
139/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
140/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
141/// across a real universe. The sparse encoding pays only for registers actually
142/// touched, and promotes the two busy names once they earn it.
143#[cfg(feature = "sparse")]
144fn per_symbol_counterparties(tape: &[Event]) {
145    use subms_hyperloglog::SparseHyperLogLog;
146    println!("\n== risk: distinct counterparties per symbol ==");
147
148    let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
149        .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
150        .collect();
151    for e in tape {
152        books[e.symbol].add_u64(e.counterparty);
153    }
154
155    let mut sparse_bytes = 0usize;
156    for (i, b) in books.iter().enumerate() {
157        println!(
158            "  {:<5} {:>7.0} counterparties  {:>6} bytes  {}",
159            SYMBOLS[i],
160            b.estimate(),
161            b.state_bytes(),
162            if b.is_sparse() { "sparse" } else { "dense" }
163        );
164        sparse_bytes += b.state_bytes();
165    }
166    let dense_bytes = SYMBOLS.len() * 16_384;
167    println!("  total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
168    assert!(
169        sparse_bytes < dense_bytes,
170        "sparse must win on the long tail"
171    );
172}
173
174/// `union-intersect`: how many accounts trade on both venues? Inclusion-
175/// exclusion answers it from two sketches. The error bound is printed next to
176/// the answer because it scales with |A| + |B| rather than with the overlap,
177/// and an overlap smaller than its own bound is not a number to act on.
178#[cfg(feature = "union-intersect")]
179fn cross_venue_overlap(tape: &[Event]) {
180    use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
181    println!("\n== venues: account reach and overlap ==");
182
183    let mut a = HyperLogLog::new(14);
184    let mut b = HyperLogLog::new(14);
185    for e in tape {
186        if e.venue == 0 {
187            a.add_u64(e.account);
188        } else {
189            b.add_u64(e.account);
190        }
191    }
192    let union = estimate_union(&a, &b).expect("same precision");
193    let inter = estimate_intersect(&a, &b).expect("same precision");
194    let bound = intersect_error_bound(&a, &b).expect("same precision");
195    println!("  venue 0: {:>7.0} accounts", a.estimate());
196    println!("  venue 1: {:>7.0} accounts", b.estimate());
197    println!("  reach:   {union:>7.0} (true 50000)");
198    println!("  both:    {inter:>7.0} (true 10000) +/- {bound:.0}");
199    assert!(
200        (union - 50_000.0).abs() / 50_000.0 < 0.05,
201        "reach within 5%, got {union}"
202    );
203    assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
204}
205
206/// `serialize`: each venue ships its sketch, not its account list. The
207/// collector decodes and merges, and the firm-wide number falls out of 16 KB
208/// per venue instead of a million ids on the wire.
209fn collector_fan_in(tape: &[Event]) {
210    println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212    let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213    for e in tape {
214        per_venue[e.venue as usize].add_u64(e.account);
215    }
216    let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217    let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219    let mut firm = HyperLogLog::new(14);
220    for bytes in &shipped {
221        let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222        firm.merge(&decoded).expect("same precision");
223    }
224    println!(
225        "  {} sketches on the wire, {} bytes total",
226        shipped.len(),
227        on_wire
228    );
229    println!(
230        "  raw ids would have been ~{} bytes",
231        tape.len() * core::mem::size_of::<u64>()
232    );
233    println!("  firm-wide reach: {:.0} (true 50000)", firm.estimate());
234    assert!(
235        (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236        "merged reach within 5%"
237    );
238}
Source

pub fn estimate(&self) -> f64

Estimate distinct count.

Examples found in repository?
examples/sample_app.rs (line 110)
101fn gateway_sessions(tape: &[Event]) {
102    println!("== gateway: distinct sessions this window ==");
103    let mut hll = HyperLogLog::new(14);
104    let mut first_sightings = 0u64;
105    for e in tape {
106        if hll.add_u64(e.session) {
107            first_sightings += 1;
108        }
109    }
110    let est = hll.estimate();
111    println!("  {} messages -> {:.0} distinct sessions", tape.len(), est);
112    println!(
113        "  {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114        first_sightings,
115        hll.state_bytes(),
116        hll.standard_error() * 100.0
117    );
118    assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125    println!("\n== sizing: error budget -> byte budget ==");
126    for budget in [0.05, 0.02, 0.01, 0.005] {
127        let p = HyperLogLog::precision_for_standard_error(budget);
128        let hll = HyperLogLog::new(p);
129        println!(
130            "  budget {:>5.1}%  ->  p={:<2} {:>6} bytes, actual {:.5}%",
131            budget * 100.0,
132            p,
133            hll.state_bytes(),
134            hll.standard_error() * 100.0
135        );
136    }
137}
138
139/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
140/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
141/// across a real universe. The sparse encoding pays only for registers actually
142/// touched, and promotes the two busy names once they earn it.
143#[cfg(feature = "sparse")]
144fn per_symbol_counterparties(tape: &[Event]) {
145    use subms_hyperloglog::SparseHyperLogLog;
146    println!("\n== risk: distinct counterparties per symbol ==");
147
148    let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
149        .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
150        .collect();
151    for e in tape {
152        books[e.symbol].add_u64(e.counterparty);
153    }
154
155    let mut sparse_bytes = 0usize;
156    for (i, b) in books.iter().enumerate() {
157        println!(
158            "  {:<5} {:>7.0} counterparties  {:>6} bytes  {}",
159            SYMBOLS[i],
160            b.estimate(),
161            b.state_bytes(),
162            if b.is_sparse() { "sparse" } else { "dense" }
163        );
164        sparse_bytes += b.state_bytes();
165    }
166    let dense_bytes = SYMBOLS.len() * 16_384;
167    println!("  total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
168    assert!(
169        sparse_bytes < dense_bytes,
170        "sparse must win on the long tail"
171    );
172}
173
174/// `union-intersect`: how many accounts trade on both venues? Inclusion-
175/// exclusion answers it from two sketches. The error bound is printed next to
176/// the answer because it scales with |A| + |B| rather than with the overlap,
177/// and an overlap smaller than its own bound is not a number to act on.
178#[cfg(feature = "union-intersect")]
179fn cross_venue_overlap(tape: &[Event]) {
180    use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
181    println!("\n== venues: account reach and overlap ==");
182
183    let mut a = HyperLogLog::new(14);
184    let mut b = HyperLogLog::new(14);
185    for e in tape {
186        if e.venue == 0 {
187            a.add_u64(e.account);
188        } else {
189            b.add_u64(e.account);
190        }
191    }
192    let union = estimate_union(&a, &b).expect("same precision");
193    let inter = estimate_intersect(&a, &b).expect("same precision");
194    let bound = intersect_error_bound(&a, &b).expect("same precision");
195    println!("  venue 0: {:>7.0} accounts", a.estimate());
196    println!("  venue 1: {:>7.0} accounts", b.estimate());
197    println!("  reach:   {union:>7.0} (true 50000)");
198    println!("  both:    {inter:>7.0} (true 10000) +/- {bound:.0}");
199    assert!(
200        (union - 50_000.0).abs() / 50_000.0 < 0.05,
201        "reach within 5%, got {union}"
202    );
203    assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
204}
205
206/// `serialize`: each venue ships its sketch, not its account list. The
207/// collector decodes and merges, and the firm-wide number falls out of 16 KB
208/// per venue instead of a million ids on the wire.
209fn collector_fan_in(tape: &[Event]) {
210    println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212    let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213    for e in tape {
214        per_venue[e.venue as usize].add_u64(e.account);
215    }
216    let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217    let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219    let mut firm = HyperLogLog::new(14);
220    for bytes in &shipped {
221        let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222        firm.merge(&decoded).expect("same precision");
223    }
224    println!(
225        "  {} sketches on the wire, {} bytes total",
226        shipped.len(),
227        on_wire
228    );
229    println!(
230        "  raw ids would have been ~{} bytes",
231        tape.len() * core::mem::size_of::<u64>()
232    );
233    println!("  firm-wide reach: {:.0} (true 50000)", firm.estimate());
234    assert!(
235        (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236        "merged reach within 5%"
237    );
238}
Source

pub fn merge(&mut self, other: &Self) -> Result<(), HllError>

Merge another HLL of the same precision. Element-wise max over registers.

Examples found in repository?
examples/sample_app.rs (line 222)
209fn collector_fan_in(tape: &[Event]) {
210    println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212    let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213    for e in tape {
214        per_venue[e.venue as usize].add_u64(e.account);
215    }
216    let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217    let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219    let mut firm = HyperLogLog::new(14);
220    for bytes in &shipped {
221        let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222        firm.merge(&decoded).expect("same precision");
223    }
224    println!(
225        "  {} sketches on the wire, {} bytes total",
226        shipped.len(),
227        on_wire
228    );
229    println!(
230        "  raw ids would have been ~{} bytes",
231        tape.len() * core::mem::size_of::<u64>()
232    );
233    println!("  firm-wide reach: {:.0} (true 50000)", firm.estimate());
234    assert!(
235        (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236        "merged reach within 5%"
237    );
238}

Trait Implementations§

Source§

impl Clone for HyperLogLog

Source§

fn clone(&self) -> HyperLogLog

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for HyperLogLog

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Deliberately does not dump the register array - at p=14 that is 16384 bytes into whatever log caught the assertion.

Source§

impl<'de> Deserialize<'de> for HyperLogLog

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl PartialEq for HyperLogLog

Source§

fn eq(&self, other: &HyperLogLog) -> bool

Equality operator ==. Read more
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Inequality operator !=. Read more
Source§

impl Serialize for HyperLogLog

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl StructuralPartialEq for HyperLogLog

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> DeserializeOwned for T
where T: for<'de> Deserialize<'de>,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.