Skip to main content

sample_app/
sample_app.rs

1//! Sample app: distinct-count telemetry for a two-venue trading gateway.
2//!
3//! One deterministic tape of order events drives the whole thing. The gateway
4//! counts distinct sessions per window, risk keeps a per-symbol counterparty
5//! sketch, and each venue ships its account sketch to a collector that merges
6//! them into a firm-wide reach number without ever seeing an account id.
7//!
8//! Run the base with `cargo run --example sample_app`; add `--all-features`
9//! (or a subset like `--features sparse`) to light up the later stages.
10
11use subms_hyperloglog::HyperLogLog;
12
13// The per-symbol section is a `sparse` tour, so its inputs are inert on a
14// default-feature build.
15#[cfg_attr(not(feature = "sparse"), allow(dead_code))]
16const SYMBOLS: [&str; 8] = [
17    "AAPL", "MSFT", "NVDA", "TSLA", "AMZN", "META", "GOOG", "NFLX",
18];
19const EVENTS: usize = 200_000;
20
21/// Distinct counterparties trading each symbol. Two liquid names and a long
22/// tail, which is what makes a per-symbol dense array wasteful.
23const COUNTERPARTY_POOL: [u64; 8] = [30_000, 30_000, 900, 700, 60, 40, 15, 9];
24
25#[cfg_attr(not(feature = "sparse"), allow(dead_code))]
26struct Event {
27    venue: u8,
28    symbol: usize,
29    account: u64,
30    counterparty: u64,
31    session: u64,
32}
33
34/// Seeded so the printed report is the same on every run and on both ports.
35struct Lcg(u64);
36
37impl Lcg {
38    fn next(&mut self) -> u64 {
39        self.0 = self
40            .0
41            .wrapping_mul(6364136223846793005)
42            .wrapping_add(1442695040888963407);
43        self.0 >> 11
44    }
45}
46
47/// The tape. Venue 0 and venue 1 draw accounts from overlapping ranges, and
48/// symbol popularity follows a sharp head-and-tail split - the two facts every
49/// stage below is trying to measure.
50fn tape() -> Vec<Event> {
51    let mut rng = Lcg(0x5eed);
52    (0..EVENTS)
53        .map(|_| {
54            let venue = (rng.next() % 2) as u8;
55            // Two thirds of the flow lands on the first two symbols.
56            let r = rng.next() % 100;
57            let symbol = if r < 66 {
58                (r % 2) as usize
59            } else {
60                2 + (rng.next() % 6) as usize
61            };
62            // Venue 0 accounts 0..30k, venue 1 accounts 20k..50k: a 10k overlap.
63            let account = if venue == 0 {
64                rng.next() % 30_000
65            } else {
66                20_000 + rng.next() % 30_000
67            };
68            Event {
69                venue,
70                symbol,
71                account,
72                counterparty: (symbol as u64) << 32 | (rng.next() % COUNTERPARTY_POOL[symbol]),
73                session: rng.next() % 40_000,
74            }
75        })
76        .collect()
77}
78
79fn main() {
80    let tape = tape();
81    println!(
82        "tape: {} order events across 2 venues, 8 symbols\n",
83        tape.len()
84    );
85
86    gateway_sessions(&tape);
87    size_from_an_error_budget();
88
89    #[cfg(feature = "sparse")]
90    per_symbol_counterparties(&tape);
91
92    #[cfg(feature = "union-intersect")]
93    cross_venue_overlap(&tape);
94
95    collector_fan_in(&tape);
96}
97
98/// The hot path. Every message on the gateway records its session id, and the
99/// answer costs 16 KB whether the window held 40 thousand sessions or 40
100/// million. `add_u64` skips rendering the id to a string first.
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}