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

pub fn estimate(&self) -> f64

Estimate distinct count.

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

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.