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