probabilistic_machines 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ //! `ProbabilisticMachines::HeavyHitters`: which keys dominate a stream.
2
+
3
+ use std::cell::RefCell;
4
+ use std::collections::{HashMap, HashSet};
5
+
6
+ use bytes::Bytes;
7
+ use magnus::scan_args::scan_args;
8
+ use magnus::typed_data::Obj;
9
+ use magnus::{Error, RArray, RHash, RModule, RString, Ruby, Value, function, method, prelude::*};
10
+ use probabilistic_machines::{CodecError, HeavyHitters, hash64};
11
+
12
+ use crate::data::hitter;
13
+ use crate::{
14
+ Whole, codec_error, mismatch, optional_kwarg, required_kwarg, unsupported, unsupported_blob,
15
+ };
16
+
17
+ /// Counter counts [`Table`] is monomorphised for.
18
+ const COUNTERS: [usize; 3] = [16, 64, 256];
19
+
20
+ #[derive(Clone)]
21
+ enum Table {
22
+ K16(Box<HeavyHitters<u64, 16>>),
23
+ K64(Box<HeavyHitters<u64, 64>>),
24
+ K256(Box<HeavyHitters<u64, 256>>),
25
+ }
26
+
27
+ /// Runs `body` against whichever variant is held.
28
+ macro_rules! table {
29
+ ($table:expr, |$inner:ident| $body:expr) => {
30
+ match $table {
31
+ Table::K16($inner) => $body,
32
+ Table::K64($inner) => $body,
33
+ Table::K256($inner) => $body,
34
+ }
35
+ };
36
+ }
37
+
38
+ /// Space-Saving needs `Copy + Eq` keys, so the table counts 64-bit key hashes
39
+ /// and `names` holds the string each tracked hash came from. A key the table
40
+ /// evicted leaves its name behind, so `names` is pruned back to the tracked
41
+ /// hashes once it holds more than twice the counters.
42
+ #[magnus::wrap(class = "ProbabilisticMachines::HeavyHitters", free_immediately, size)]
43
+ struct Wrapper {
44
+ table: RefCell<Table>,
45
+ names: RefCell<HashMap<u64, String>>,
46
+ }
47
+
48
+ const fn counters_of<const K: usize>(_: &HeavyHitters<u64, K>) -> usize {
49
+ K
50
+ }
51
+
52
+ const fn frame_len_of<const K: usize>(_: &HeavyHitters<u64, K>) -> usize {
53
+ HeavyHitters::<u64, K>::ENCODED_LEN
54
+ }
55
+
56
+ /// Each shape is constructed behind its own call frame. Inlined, a debug build
57
+ /// sizes one frame for every arm of the dispatch match at once.
58
+ #[inline(never)]
59
+ fn boxed_new<const K: usize>() -> Box<HeavyHitters<u64, K>> {
60
+ Box::new(HeavyHitters::new())
61
+ }
62
+
63
+ #[inline(never)]
64
+ fn boxed_decode<const K: usize>(
65
+ ruby: &Ruby,
66
+ frame: &[u8],
67
+ ) -> Result<Box<HeavyHitters<u64, K>>, Error> {
68
+ let mut table = boxed_new::<K>();
69
+ table
70
+ .decode_into(frame)
71
+ .map_err(|error| codec_error(ruby, error))?;
72
+ Ok(table)
73
+ }
74
+
75
+ /// A `usize` as the key table's 64-bit little-endian length field.
76
+ fn length_field(length: usize) -> [u8; 8] {
77
+ let mut field = [0_u8; 8];
78
+ for (slot, byte) in field.iter_mut().zip(length.to_le_bytes()) {
79
+ *slot = byte;
80
+ }
81
+ field
82
+ }
83
+
84
+ fn bad_key_table(ruby: &Ruby, reason: &str) -> Error {
85
+ Error::new(
86
+ ruby.exception_arg_error(),
87
+ format!("the blob's key table {reason}"),
88
+ )
89
+ }
90
+
91
+ /// Reads the name records that follow the core frame back into the
92
+ /// hash-to-name map.
93
+ fn key_table(ruby: &Ruby, mut records: &[u8]) -> Result<HashMap<u64, String>, Error> {
94
+ let mut names = HashMap::new();
95
+ while !records.is_empty() {
96
+ let Some((field, tail)) = records.split_first_chunk::<8>() else {
97
+ return Err(bad_key_table(ruby, "ends inside a record header"));
98
+ };
99
+ let Ok(length) = usize::try_from(u64::from_le_bytes(*field)) else {
100
+ return Err(bad_key_table(ruby, "names a key this platform cannot hold"));
101
+ };
102
+ let Some((name, tail)) = tail.split_at_checked(length) else {
103
+ return Err(bad_key_table(ruby, "ends inside a key"));
104
+ };
105
+ let Ok(name) = str::from_utf8(name) else {
106
+ return Err(bad_key_table(ruby, "holds a key that is not UTF-8"));
107
+ };
108
+ names.insert(hash64(name.as_bytes()), name.to_owned());
109
+ records = tail;
110
+ }
111
+ Ok(names)
112
+ }
113
+
114
+ impl Wrapper {
115
+ fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
116
+ let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
117
+ let Whole(counters): Whole<usize> = required_kwarg(args.keywords, "k")?;
118
+ let table = match counters {
119
+ 16 => Table::K16(boxed_new::<16>()),
120
+ 64 => Table::K64(boxed_new::<64>()),
121
+ 256 => Table::K256(boxed_new::<256>()),
122
+ _ => return Err(unsupported(ruby, "k", counters, &COUNTERS)),
123
+ };
124
+ Ok(Self {
125
+ table: RefCell::new(table),
126
+ names: RefCell::new(HashMap::new()),
127
+ })
128
+ }
129
+
130
+ /// Reads a blob written by [`dump`](Self::dump).
131
+ fn load(ruby: &Ruby, blob: Bytes) -> Result<Self, Error> {
132
+ let Some(shape) = blob.get(1..3) else {
133
+ return Err(codec_error(ruby, CodecError::Truncated));
134
+ };
135
+ let counters = u16::from_le_bytes([shape[0], shape[1]]);
136
+ let table = match counters {
137
+ 16 => Table::K16(boxed_decode::<16>(ruby, &blob)?),
138
+ 64 => Table::K64(boxed_decode::<64>(ruby, &blob)?),
139
+ 256 => Table::K256(boxed_decode::<256>(ruby, &blob)?),
140
+ _ => return Err(unsupported_blob(ruby, "counter count", &COUNTERS)),
141
+ };
142
+
143
+ let frame_len = table!(&table, |inner| frame_len_of(&**inner));
144
+ let names = key_table(ruby, &blob[frame_len..])?;
145
+ let unnamed = table!(&table, |inner| inner
146
+ .hitters()
147
+ .any(|entry| !names.contains_key(&entry.key)));
148
+ if unnamed {
149
+ return Err(bad_key_table(ruby, "does not name every tracked key"));
150
+ }
151
+
152
+ Ok(Self {
153
+ table: RefCell::new(table),
154
+ names: RefCell::new(names),
155
+ })
156
+ }
157
+
158
+ fn counters(&self) -> usize {
159
+ table!(&*self.table.borrow(), |table| counters_of(&**table))
160
+ }
161
+
162
+ fn add(rb_self: &Self, args: &[Value]) -> Result<(), Error> {
163
+ let args = scan_args::<(String,), (), (), (), RHash, ()>(args)?;
164
+ let (key,) = args.required;
165
+ let count =
166
+ optional_kwarg::<Whole<u64>>(args.keywords, "count")?.map_or(1, |Whole(count)| count);
167
+ let hash = hash64(key.as_bytes());
168
+ table!(&mut *rb_self.table.borrow_mut(), |table| table
169
+ .add_count(hash, count));
170
+ rb_self.names.borrow_mut().insert(hash, key);
171
+ rb_self.prune();
172
+ Ok(())
173
+ }
174
+
175
+ fn count(&self, key: String) -> u64 {
176
+ let hash = hash64(key.as_bytes());
177
+ table!(&*self.table.borrow(), |table| table.count(&hash))
178
+ }
179
+
180
+ fn observed(&self) -> u64 {
181
+ table!(&*self.table.borrow(), |table| table.observed())
182
+ }
183
+
184
+ fn size(&self) -> usize {
185
+ table!(&*self.table.borrow(), |table| table.len())
186
+ }
187
+
188
+ fn is_empty(&self) -> bool {
189
+ table!(&*self.table.borrow(), |table| table.is_empty())
190
+ }
191
+
192
+ fn ranked(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
193
+ rb_self.take(ruby, usize::MAX)
194
+ }
195
+
196
+ fn top(ruby: &Ruby, rb_self: &Self, Whole(count): Whole<usize>) -> Result<RArray, Error> {
197
+ rb_self.take(ruby, count)
198
+ }
199
+
200
+ fn merge(ruby: &Ruby, rb_self: Obj<Self>, other: &Self) -> Result<Obj<Self>, Error> {
201
+ let source = other.table.borrow().clone();
202
+ let names = other.names.borrow().clone();
203
+ match (&mut *rb_self.table.borrow_mut(), &source) {
204
+ (Table::K16(target), Table::K16(source)) => target.merge(source),
205
+ (Table::K64(target), Table::K64(source)) => target.merge(source),
206
+ (Table::K256(target), Table::K256(source)) => target.merge(source),
207
+ _ => return Err(mismatch(ruby, "table")),
208
+ }
209
+ rb_self.names.borrow_mut().extend(names);
210
+ rb_self.prune();
211
+ Ok(rb_self)
212
+ }
213
+
214
+ /// The core frame followed by the names of the keys it tracks.
215
+ ///
216
+ /// ```text
217
+ /// core frame HeavyHitters::<u64, k>::ENCODED_LEN bytes, tag 0xB1
218
+ /// name record u64 little-endian byte length, then that many UTF-8 bytes
219
+ /// ```
220
+ ///
221
+ /// One record per tracked key, in the core frame's order, filling the rest
222
+ /// of the blob. The table counts key hashes, so [`load`](Self::load)
223
+ /// rehashes each name to rebuild the hash-to-name map `top` and `ranked`
224
+ /// read.
225
+ fn dump(ruby: &Ruby, rb_self: &Self) -> RString {
226
+ let (frame, keys) = table!(&*rb_self.table.borrow(), |table| (
227
+ table.encoded(),
228
+ table.hitters().map(|entry| entry.key).collect::<Vec<u64>>()
229
+ ));
230
+ let names = rb_self.names.borrow();
231
+ let mut blob = Vec::from(frame);
232
+ for name in keys.iter().filter_map(|key| names.get(key)) {
233
+ blob.extend_from_slice(&length_field(name.len()));
234
+ blob.extend_from_slice(name.as_bytes());
235
+ }
236
+ ruby.str_from_slice(&blob)
237
+ }
238
+
239
+ fn inspect(&self) -> String {
240
+ format!(
241
+ "#<ProbabilisticMachines::HeavyHitters k={} tracked={} observed={}>",
242
+ self.counters(),
243
+ self.size(),
244
+ self.observed()
245
+ )
246
+ }
247
+
248
+ /// The `count` most frequent tracked keys, most frequent first.
249
+ fn take(&self, ruby: &Ruby, count: usize) -> Result<RArray, Error> {
250
+ let ranked = table!(&*self.table.borrow(), |table| table.ranked());
251
+ let names = self.names.borrow();
252
+ let hitters = ruby.ary_new_capa(ranked.len().min(count));
253
+ for entry in ranked.into_iter().take(count) {
254
+ let key = names.get(&entry.key).ok_or_else(|| {
255
+ Error::new(
256
+ ruby.exception_runtime_error(),
257
+ "a tracked key lost its name",
258
+ )
259
+ })?;
260
+ hitters.push(hitter(
261
+ ruby,
262
+ ruby.str_new(key).as_value(),
263
+ entry.count,
264
+ entry.overestimate,
265
+ )?)?;
266
+ }
267
+ Ok(hitters)
268
+ }
269
+
270
+ fn prune(&self) {
271
+ let mut names = self.names.borrow_mut();
272
+ if names.len() <= 2 * self.counters() {
273
+ return;
274
+ }
275
+ let tracked: HashSet<u64> = table!(&*self.table.borrow(), |table| table
276
+ .hitters()
277
+ .map(|entry| entry.key)
278
+ .collect());
279
+ names.retain(|hash, _| tracked.contains(hash));
280
+ }
281
+ }
282
+
283
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
284
+ let class = module.define_class("HeavyHitters", ruby.class_object())?;
285
+ class.define_singleton_method("new", function!(Wrapper::new, -1))?;
286
+ class.define_singleton_method("load", function!(Wrapper::load, 1))?;
287
+ class.define_method("k", method!(Wrapper::counters, 0))?;
288
+ class.define_method("add", method!(Wrapper::add, -1))?;
289
+ class.define_method("count", method!(Wrapper::count, 1))?;
290
+ class.define_method("observed", method!(Wrapper::observed, 0))?;
291
+ class.define_method("size", method!(Wrapper::size, 0))?;
292
+ class.define_method("empty?", method!(Wrapper::is_empty, 0))?;
293
+ class.define_method("top", method!(Wrapper::top, 1))?;
294
+ class.define_method("ranked", method!(Wrapper::ranked, 0))?;
295
+ class.define_method("merge", method!(Wrapper::merge, 1))?;
296
+ class.define_method("dump", method!(Wrapper::dump, 0))?;
297
+ class.define_method("inspect", method!(Wrapper::inspect, 0))?;
298
+ Ok(())
299
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProbabilisticMachines
4
+ VERSION = '0.1.1'
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'probabilistic_machines/version'
4
+
5
+ # Every class, the two `Data` result objects and both error classes are defined
6
+ # by the native extension; there is no pure-Ruby implementation to fall back to.
7
+ require 'probabilistic_machines_native/probabilistic_machines_native'
@@ -0,0 +1,182 @@
1
+ # Sketches that answer a counting question in memory fixed at construction
2
+ # time. Every class here is defined by the native extension.
3
+ module ProbabilisticMachines
4
+ VERSION: String
5
+
6
+ class Error < StandardError
7
+ end
8
+
9
+ # Raised when a cuckoo filter has nowhere left to put a fingerprint.
10
+ class Full < Error
11
+ end
12
+
13
+ # A tracked key and its counter. The true count lies in
14
+ # `count - overestimate` through `count`.
15
+ class Hitter < ::Data
16
+ attr_reader key: String
17
+ attr_reader count: Integer
18
+ attr_reader overestimate: Integer
19
+ end
20
+
21
+ # One cluster of a digest: the mean of the values that collapsed into it.
22
+ class Centroid < ::Data
23
+ attr_reader mean: Float
24
+ attr_reader weight: Float
25
+ end
26
+
27
+ # Distinct counts in a fixed register array.
28
+ class HyperLogLog
29
+ # `registers` is 1024, 4096, 16384 or 65536.
30
+ def self.new: (registers: Integer) -> instance
31
+
32
+ def self.load: (String blob) -> instance
33
+
34
+ def registers: () -> Integer
35
+ def add: (String item) -> nil
36
+
37
+ # For streams that ship 64-bit hashes instead of keys.
38
+ def add_hash: (Integer hash) -> nil
39
+
40
+ def estimate: () -> Float
41
+
42
+ # Relative standard error of the estimate, `1.04 / sqrt(registers)`.
43
+ def standard_error: () -> Float
44
+
45
+ def empty?: () -> bool
46
+
47
+ # Unions `other` in. Raises `ArgumentError` on a different register count.
48
+ def merge: (HyperLogLog other) -> self
49
+
50
+ def dump: () -> String
51
+ def inspect: () -> String
52
+ end
53
+
54
+ # How often each item was seen, in `width * depth` counters.
55
+ class CountMinSketch
56
+ # `width` is 1024 or 4096; `depth` is 4 or 8.
57
+ def self.new: (width: Integer, depth: Integer) -> instance
58
+
59
+ def self.load: (String blob) -> instance
60
+
61
+ def width: () -> Integer
62
+ def depth: () -> Integer
63
+ def add: (String item, ?count: Integer) -> nil
64
+
65
+ # Never below the true count, and over it by at most
66
+ # `e * total / width` with probability `1 - e ** -depth`.
67
+ def count: (String item) -> Integer
68
+
69
+ # Sum of every count added.
70
+ def total: () -> Integer
71
+ def saturated?: () -> bool
72
+
73
+ # Sums `other` in. Raises `ArgumentError` on a different shape.
74
+ def merge: (CountMinSketch other) -> self
75
+
76
+ def dump: () -> String
77
+ def inspect: () -> String
78
+ end
79
+
80
+ # Quantiles of a stream, in clusters instead of samples.
81
+ class TDigest
82
+ def self.new: (compression: Integer) -> instance
83
+
84
+ def self.load: (String blob) -> instance
85
+
86
+ def compression: () -> Integer
87
+ def add: (Float value, ?weight: Float) -> nil
88
+
89
+ # Value at quantile `q`, `Float::NAN` when empty.
90
+ def quantile: (Float q) -> Float
91
+
92
+ # Fraction of the stream at or below `value`, `Float::NAN` when empty.
93
+ def cdf: (Float value) -> Float
94
+
95
+ # Total weight added.
96
+ def count: () -> Float
97
+
98
+ # Smallest value added, `Float::INFINITY` when empty.
99
+ def minimum: () -> Float
100
+
101
+ # Largest value added, `-Float::INFINITY` when empty.
102
+ def maximum: () -> Float
103
+
104
+ # Clusters currently held.
105
+ def size: () -> Integer
106
+
107
+ def empty?: () -> bool
108
+ def centroids: () -> Array[Centroid]
109
+ def merge: (TDigest other) -> self
110
+ def dump: () -> String
111
+ def inspect: () -> String
112
+ end
113
+
114
+ # Membership that can forget, in `buckets * 4` fingerprints.
115
+ class CuckooFilter
116
+ # `buckets` is 1024, 4096, 16384 or 65536.
117
+ def self.new: (buckets: Integer) -> instance
118
+
119
+ def self.load: (String blob) -> instance
120
+
121
+ def buckets: () -> Integer
122
+
123
+ # Fingerprint slots in the table, `buckets * 4`.
124
+ def capacity: () -> Integer
125
+
126
+ # Raises `Full` when the item has nowhere to go.
127
+ def insert: (String item) -> bool
128
+
129
+ # Never false for an item that was inserted; true for one that was not
130
+ # with probability about 3%.
131
+ def include?: (String item) -> bool
132
+
133
+ alias contains? include?
134
+
135
+ # Only for items that were inserted: removing an item that merely collides
136
+ # with one takes the other item's fingerprint with it.
137
+ def remove: (String item) -> bool
138
+
139
+ def size: () -> Integer
140
+ def empty?: () -> bool
141
+ def dump: () -> String
142
+ def inspect: () -> String
143
+ end
144
+
145
+ # Which keys dominate a stream, in `k` counters instead of one per key.
146
+ class HeavyHitters
147
+ # `k` is 16, 64 or 256.
148
+ def self.new: (k: Integer) -> instance
149
+
150
+ def self.load: (String blob) -> instance
151
+
152
+ def k: () -> Integer
153
+
154
+ # Keys are UTF-8 strings, so `top` and `ranked` can hand them back.
155
+ def add: (String key, ?count: Integer) -> nil
156
+
157
+ # Upper bound on how often `key` occurred, 0 when it is not tracked.
158
+ def count: (String key) -> Integer
159
+
160
+ # Observations fed in.
161
+ def observed: () -> Integer
162
+
163
+ # Keys currently tracked, at most `k`.
164
+ def size: () -> Integer
165
+
166
+ def empty?: () -> bool
167
+
168
+ # The `n` most frequent tracked keys, most frequent first.
169
+ def top: (Integer n) -> Array[Hitter]
170
+
171
+ # Every tracked key, most frequent first.
172
+ def ranked: () -> Array[Hitter]
173
+
174
+ # Folds `other` in. Raises `ArgumentError` on a different `k`.
175
+ def merge: (HeavyHitters other) -> self
176
+
177
+ # The crate's frame followed by the key table that carries the strings.
178
+ def dump: () -> String
179
+
180
+ def inspect: () -> String
181
+ end
182
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: probabilistic_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Abdelkader Boudih
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ description: HyperLogLog, Count-Min Sketch, t-digest, cuckoo filter and Space-Saving
27
+ top-k, bound to Ruby from the no_std probabilistic-machines crate. Each sketch answers
28
+ a counting question in memory fixed at construction time, and serialises to a byte
29
+ string other languages reading the same crate can decode.
30
+ email:
31
+ - oss@seuros.com
32
+ executables: []
33
+ extensions:
34
+ - ext/probabilistic_machines_native/extconf.rb
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - Cargo.lock
39
+ - Cargo.toml
40
+ - LICENSE.txt
41
+ - README.md
42
+ - ext/probabilistic_machines_native/core/Cargo.toml
43
+ - ext/probabilistic_machines_native/core/src/countmin.rs
44
+ - ext/probabilistic_machines_native/core/src/cuckoo.rs
45
+ - ext/probabilistic_machines_native/core/src/hash.rs
46
+ - ext/probabilistic_machines_native/core/src/hyperloglog.rs
47
+ - ext/probabilistic_machines_native/core/src/lib.rs
48
+ - ext/probabilistic_machines_native/core/src/tdigest.rs
49
+ - ext/probabilistic_machines_native/core/src/topk.rs
50
+ - ext/probabilistic_machines_native/extconf.rb
51
+ - ext/probabilistic_machines_native/ffi/Cargo.toml
52
+ - ext/probabilistic_machines_native/ffi/extconf.rb
53
+ - ext/probabilistic_machines_native/ffi/src/countmin.rs
54
+ - ext/probabilistic_machines_native/ffi/src/cuckoo.rs
55
+ - ext/probabilistic_machines_native/ffi/src/data.rs
56
+ - ext/probabilistic_machines_native/ffi/src/hyperloglog.rs
57
+ - ext/probabilistic_machines_native/ffi/src/lib.rs
58
+ - ext/probabilistic_machines_native/ffi/src/tdigest.rs
59
+ - ext/probabilistic_machines_native/ffi/src/topk.rs
60
+ - lib/probabilistic_machines.rb
61
+ - lib/probabilistic_machines/version.rb
62
+ - sig/probabilistic_machines.rbs
63
+ homepage: https://github.com/seuros/probabilistic_machines
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ source_code_uri: https://github.com/seuros/probabilistic_machines
68
+ changelog_uri: https://github.com/seuros/probabilistic_machines/blob/main/CHANGELOG.md
69
+ bug_tracker_uri: https://github.com/seuros/probabilistic_machines/issues
70
+ rubygems_mfa_required: 'true'
71
+ cargo_crate_name: probabilistic_machines_native
72
+ cargo_manifest_path: ext/probabilistic_machines_native/ffi/Cargo.toml
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 4.0.0
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 4.0.20
88
+ specification_version: 4
89
+ summary: Distinct counts, quantiles and frequencies in bounded memory
90
+ test_files: []