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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/Cargo.lock +294 -0
- data/Cargo.toml +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +203 -0
- data/ext/probabilistic_machines_native/core/Cargo.toml +24 -0
- data/ext/probabilistic_machines_native/core/src/countmin.rs +240 -0
- data/ext/probabilistic_machines_native/core/src/cuckoo.rs +283 -0
- data/ext/probabilistic_machines_native/core/src/hash.rs +88 -0
- data/ext/probabilistic_machines_native/core/src/hyperloglog.rs +254 -0
- data/ext/probabilistic_machines_native/core/src/lib.rs +65 -0
- data/ext/probabilistic_machines_native/core/src/tdigest.rs +445 -0
- data/ext/probabilistic_machines_native/core/src/topk.rs +325 -0
- data/ext/probabilistic_machines_native/extconf.rb +3 -0
- data/ext/probabilistic_machines_native/ffi/Cargo.toml +20 -0
- data/ext/probabilistic_machines_native/ffi/extconf.rb +21 -0
- data/ext/probabilistic_machines_native/ffi/src/countmin.rs +188 -0
- data/ext/probabilistic_machines_native/ffi/src/cuckoo.rs +149 -0
- data/ext/probabilistic_machines_native/ffi/src/data.rs +69 -0
- data/ext/probabilistic_machines_native/ffi/src/hyperloglog.rs +155 -0
- data/ext/probabilistic_machines_native/ffi/src/lib.rs +140 -0
- data/ext/probabilistic_machines_native/ffi/src/tdigest.rs +166 -0
- data/ext/probabilistic_machines_native/ffi/src/topk.rs +299 -0
- data/lib/probabilistic_machines/version.rb +5 -0
- data/lib/probabilistic_machines.rb +7 -0
- data/sig/probabilistic_machines.rbs +182 -0
- metadata +90 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
//! Quantile estimation over a stream, in clusters instead of samples.
|
|
2
|
+
|
|
3
|
+
use alloc::vec::Vec;
|
|
4
|
+
|
|
5
|
+
use crate::{CodecError, TAG_TDIGEST, read8};
|
|
6
|
+
|
|
7
|
+
const HEADER_LEN: usize = 31;
|
|
8
|
+
const CENTROID_LEN: usize = 16;
|
|
9
|
+
|
|
10
|
+
/// Clusters the digest may hold per unit of compression.
|
|
11
|
+
const CLUSTERS_PER_COMPRESSION: usize = 10;
|
|
12
|
+
|
|
13
|
+
/// How far a total summed one point at a time may sit from the sum of the
|
|
14
|
+
/// clusters those points were folded into, relative to the total itself. Both
|
|
15
|
+
/// sums round once per addition, so a stream of fractional weights long enough
|
|
16
|
+
/// to exhaust this bound is one no `f64` total describes anyway.
|
|
17
|
+
const WEIGHT_DRIFT: f64 = 1e-6;
|
|
18
|
+
|
|
19
|
+
/// A weighted point: the mean of the values that collapsed into it.
|
|
20
|
+
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
21
|
+
pub struct Centroid {
|
|
22
|
+
pub mean: f64,
|
|
23
|
+
pub weight: f64,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Estimates quantiles from clusters whose weight is capped by how close they
|
|
27
|
+
/// sit to the tails.
|
|
28
|
+
///
|
|
29
|
+
/// A cluster covering the quantile range around `q` holds at most
|
|
30
|
+
/// `4 * n * q * (1 - q) / compression` of the `n` points seen, so a quantile is
|
|
31
|
+
/// off by at most the width of one cluster there: about `1 / compression` of
|
|
32
|
+
/// the distribution in the middle, shrinking towards the extremes, where
|
|
33
|
+
/// clusters are single points and `quantile(0.0)` and `quantile(1.0)` are
|
|
34
|
+
/// exact.
|
|
35
|
+
///
|
|
36
|
+
/// The cluster count grows with `compression * ln(n)`, not with `n`:
|
|
37
|
+
/// compression 100 holds 266 clusters at a thousand points and 809 at a
|
|
38
|
+
/// million. Below `10 * compression` points nothing is collapsed and the
|
|
39
|
+
/// quantiles are exact.
|
|
40
|
+
///
|
|
41
|
+
/// ```
|
|
42
|
+
/// use probabilistic_machines::TDigest;
|
|
43
|
+
///
|
|
44
|
+
/// let mut latency = TDigest::new(100);
|
|
45
|
+
/// for ms in 1..=10_000 {
|
|
46
|
+
/// latency.add(f64::from(ms));
|
|
47
|
+
/// }
|
|
48
|
+
///
|
|
49
|
+
/// assert!((latency.quantile(0.5) - 5_000.0).abs() < 100.0);
|
|
50
|
+
/// assert!((latency.quantile(0.99) - 9_900.0).abs() < 50.0);
|
|
51
|
+
/// assert_eq!(latency.quantile(0.0), 1.0);
|
|
52
|
+
/// assert_eq!(latency.quantile(1.0), 10_000.0);
|
|
53
|
+
/// ```
|
|
54
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
55
|
+
pub struct TDigest {
|
|
56
|
+
compression: u16,
|
|
57
|
+
centroids: Vec<Centroid>,
|
|
58
|
+
count: f64,
|
|
59
|
+
minimum: f64,
|
|
60
|
+
maximum: f64,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
impl TDigest {
|
|
64
|
+
#[must_use]
|
|
65
|
+
pub const fn new(compression: u16) -> Self {
|
|
66
|
+
Self {
|
|
67
|
+
compression,
|
|
68
|
+
centroids: Vec::new(),
|
|
69
|
+
count: 0.0,
|
|
70
|
+
minimum: f64::INFINITY,
|
|
71
|
+
maximum: f64::NEG_INFINITY,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[must_use]
|
|
76
|
+
pub const fn compression(&self) -> u16 {
|
|
77
|
+
self.compression
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Total weight added, which for unweighted points is the item count.
|
|
81
|
+
#[must_use]
|
|
82
|
+
pub const fn count(&self) -> f64 {
|
|
83
|
+
self.count
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Smallest value seen, [`f64::INFINITY`] when empty.
|
|
87
|
+
#[must_use]
|
|
88
|
+
pub const fn minimum(&self) -> f64 {
|
|
89
|
+
self.minimum
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Largest value seen, [`f64::NEG_INFINITY`] when empty.
|
|
93
|
+
#[must_use]
|
|
94
|
+
pub const fn maximum(&self) -> f64 {
|
|
95
|
+
self.maximum
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Clusters currently held, ordered by mean.
|
|
99
|
+
#[must_use]
|
|
100
|
+
pub fn centroids(&self) -> &[Centroid] {
|
|
101
|
+
&self.centroids
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[must_use]
|
|
105
|
+
pub const fn len(&self) -> usize {
|
|
106
|
+
self.centroids.len()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#[must_use]
|
|
110
|
+
pub const fn is_empty(&self) -> bool {
|
|
111
|
+
self.centroids.is_empty()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Adds one observation of `value`, under the same precondition as
|
|
115
|
+
/// [`add_weighted`](Self::add_weighted).
|
|
116
|
+
pub fn add(&mut self, value: f64) {
|
|
117
|
+
self.add_weighted(value, 1.0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Adds a value standing for `weight` observations.
|
|
121
|
+
///
|
|
122
|
+
/// `value` must be finite, and `weight` finite and positive: the quantiles
|
|
123
|
+
/// of a stream carrying anything else mean nothing, and the clusters it
|
|
124
|
+
/// leaves are not ones [`decode`](Self::decode) reads back.
|
|
125
|
+
pub fn add_weighted(&mut self, value: f64, weight: f64) {
|
|
126
|
+
let at = self
|
|
127
|
+
.centroids
|
|
128
|
+
.partition_point(|centroid| centroid.mean < value);
|
|
129
|
+
self.centroids.insert(
|
|
130
|
+
at,
|
|
131
|
+
Centroid {
|
|
132
|
+
mean: value,
|
|
133
|
+
weight,
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
self.count += weight;
|
|
137
|
+
if value < self.minimum {
|
|
138
|
+
self.minimum = value;
|
|
139
|
+
}
|
|
140
|
+
if value > self.maximum {
|
|
141
|
+
self.maximum = value;
|
|
142
|
+
}
|
|
143
|
+
if self.centroids.len() > self.capacity() {
|
|
144
|
+
self.compress();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Value at quantile `q`, [`f64::NAN`] when empty.
|
|
149
|
+
#[must_use]
|
|
150
|
+
pub fn quantile(&self, q: f64) -> f64 {
|
|
151
|
+
if self.centroids.is_empty() {
|
|
152
|
+
return f64::NAN;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let target = q * self.count;
|
|
156
|
+
let mut processed = 0.0;
|
|
157
|
+
let mut left_rank = 0.0;
|
|
158
|
+
let mut left_mean = self.minimum;
|
|
159
|
+
|
|
160
|
+
for centroid in &self.centroids {
|
|
161
|
+
let half = centroid.weight * 0.5;
|
|
162
|
+
let rank = processed + half;
|
|
163
|
+
if target < rank {
|
|
164
|
+
return lerp(target, left_rank, left_mean, rank, centroid.mean);
|
|
165
|
+
}
|
|
166
|
+
left_rank = rank;
|
|
167
|
+
left_mean = centroid.mean;
|
|
168
|
+
processed += centroid.weight;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lerp(target, left_rank, left_mean, self.count, self.maximum)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Fraction of the stream at or below `value`, [`f64::NAN`] when empty.
|
|
175
|
+
#[must_use]
|
|
176
|
+
pub fn cdf(&self, value: f64) -> f64 {
|
|
177
|
+
if self.centroids.is_empty() {
|
|
178
|
+
return f64::NAN;
|
|
179
|
+
}
|
|
180
|
+
if value < self.minimum {
|
|
181
|
+
return 0.0;
|
|
182
|
+
}
|
|
183
|
+
if value > self.maximum {
|
|
184
|
+
return 1.0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let mut processed = 0.0;
|
|
188
|
+
let mut left_rank = 0.0;
|
|
189
|
+
let mut left_mean = self.minimum;
|
|
190
|
+
|
|
191
|
+
for centroid in &self.centroids {
|
|
192
|
+
let half = centroid.weight * 0.5;
|
|
193
|
+
let rank = processed + half;
|
|
194
|
+
if value < centroid.mean {
|
|
195
|
+
return lerp(value, left_mean, left_rank, centroid.mean, rank) / self.count;
|
|
196
|
+
}
|
|
197
|
+
left_rank = rank;
|
|
198
|
+
left_mean = centroid.mean;
|
|
199
|
+
processed += centroid.weight;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
lerp(value, left_mean, left_rank, self.maximum, self.count) / self.count
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Folds another digest in. The result is the same digest you would get by
|
|
206
|
+
/// feeding one instance both streams, up to the order clusters formed in.
|
|
207
|
+
pub fn merge(&mut self, other: &Self) {
|
|
208
|
+
self.centroids.extend_from_slice(&other.centroids);
|
|
209
|
+
self.centroids
|
|
210
|
+
.sort_unstable_by(|left, right| left.mean.total_cmp(&right.mean));
|
|
211
|
+
self.count += other.count;
|
|
212
|
+
if other.minimum < self.minimum {
|
|
213
|
+
self.minimum = other.minimum;
|
|
214
|
+
}
|
|
215
|
+
if other.maximum > self.maximum {
|
|
216
|
+
self.maximum = other.maximum;
|
|
217
|
+
}
|
|
218
|
+
self.compress();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// Collapses adjacent clusters up to the weight their quantile allows.
|
|
222
|
+
pub fn compress(&mut self) {
|
|
223
|
+
let factor = 4.0 / f64::from(self.compression) * self.count;
|
|
224
|
+
let mut merged = Vec::with_capacity(self.centroids.len());
|
|
225
|
+
let mut processed = 0.0;
|
|
226
|
+
let mut open: Option<Centroid> = None;
|
|
227
|
+
|
|
228
|
+
for &next in &self.centroids {
|
|
229
|
+
let Some(current) = open else {
|
|
230
|
+
open = Some(next);
|
|
231
|
+
continue;
|
|
232
|
+
};
|
|
233
|
+
let combined = current.weight + next.weight;
|
|
234
|
+
let half = combined * 0.5;
|
|
235
|
+
let rank = (processed + half) / self.count;
|
|
236
|
+
if combined <= factor * rank * (1.0 - rank) {
|
|
237
|
+
open = Some(fold(current, next));
|
|
238
|
+
} else {
|
|
239
|
+
processed += current.weight;
|
|
240
|
+
merged.push(current);
|
|
241
|
+
open = Some(next);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
merged.extend(open);
|
|
246
|
+
self.centroids = merged;
|
|
247
|
+
|
|
248
|
+
// A cluster holding nearly all the weight pins every later rank at 1.0,
|
|
249
|
+
// where the scale function above can merge nothing.
|
|
250
|
+
while self.centroids.len() > self.capacity() {
|
|
251
|
+
self.centroids = self
|
|
252
|
+
.centroids
|
|
253
|
+
.chunks(2)
|
|
254
|
+
.map(|pair| match pair {
|
|
255
|
+
[left, right] => fold(*left, *right),
|
|
256
|
+
_ => pair[0],
|
|
257
|
+
})
|
|
258
|
+
.collect();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// Bytes [`encode`](Self::encode) needs, which grows with the cluster count.
|
|
263
|
+
#[must_use]
|
|
264
|
+
pub const fn encoded_len(&self) -> usize {
|
|
265
|
+
HEADER_LEN + self.centroids.len() * CENTROID_LEN
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Writes the digest into `out`, returning the number of bytes written.
|
|
269
|
+
///
|
|
270
|
+
/// The frame is a header of the tag, the compression, the cluster count,
|
|
271
|
+
/// the total weight and the two extremes, then a record of mean and weight
|
|
272
|
+
/// per cluster, every scalar little-endian.
|
|
273
|
+
///
|
|
274
|
+
/// # Errors
|
|
275
|
+
///
|
|
276
|
+
/// [`CodecError::Truncated`] when `out` is shorter than
|
|
277
|
+
/// [`encoded_len`](Self::encoded_len).
|
|
278
|
+
pub fn encode(&self, out: &mut [u8]) -> Result<usize, CodecError> {
|
|
279
|
+
let length = self.encoded_len();
|
|
280
|
+
let Some(frame) = out.get_mut(..length) else {
|
|
281
|
+
return Err(CodecError::Truncated);
|
|
282
|
+
};
|
|
283
|
+
self.write_frame(frame);
|
|
284
|
+
Ok(length)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// The digest's frame, exactly [`encoded_len`](Self::encoded_len) bytes.
|
|
288
|
+
#[must_use]
|
|
289
|
+
pub fn encoded(&self) -> alloc::boxed::Box<[u8]> {
|
|
290
|
+
let mut frame = alloc::vec![0_u8; self.encoded_len()].into_boxed_slice();
|
|
291
|
+
self.write_frame(&mut frame);
|
|
292
|
+
frame
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/// Fills a frame of exactly [`encoded_len`](Self::encoded_len) bytes.
|
|
296
|
+
fn write_frame(&self, frame: &mut [u8]) {
|
|
297
|
+
#[expect(
|
|
298
|
+
clippy::cast_possible_truncation,
|
|
299
|
+
reason = "capacity caps the cluster count at compression * CLUSTERS_PER_COMPRESSION"
|
|
300
|
+
)]
|
|
301
|
+
let clusters = self.centroids.len() as u32;
|
|
302
|
+
frame[0] = TAG_TDIGEST;
|
|
303
|
+
frame[1..3].copy_from_slice(&self.compression.to_le_bytes());
|
|
304
|
+
frame[3..7].copy_from_slice(&clusters.to_le_bytes());
|
|
305
|
+
frame[7..15].copy_from_slice(&self.count.to_le_bytes());
|
|
306
|
+
frame[15..23].copy_from_slice(&self.minimum.to_le_bytes());
|
|
307
|
+
frame[23..31].copy_from_slice(&self.maximum.to_le_bytes());
|
|
308
|
+
|
|
309
|
+
let (slots, _) = frame[HEADER_LEN..].as_chunks_mut::<CENTROID_LEN>();
|
|
310
|
+
for (slot, centroid) in slots.iter_mut().zip(&self.centroids) {
|
|
311
|
+
slot[..8].copy_from_slice(¢roid.mean.to_le_bytes());
|
|
312
|
+
slot[8..].copy_from_slice(¢roid.weight.to_le_bytes());
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/// Reads a digest written by [`encode`](Self::encode).
|
|
317
|
+
///
|
|
318
|
+
/// # Errors
|
|
319
|
+
///
|
|
320
|
+
/// [`CodecError::Truncated`] when `bytes` is shorter than a header or than
|
|
321
|
+
/// the clusters that header counts,
|
|
322
|
+
/// [`CodecError::Tag`] when it holds another structure,
|
|
323
|
+
/// [`CodecError::Parameters`] when the clusters, the total weight and the
|
|
324
|
+
/// extremes contradict each other.
|
|
325
|
+
pub fn decode(bytes: &[u8]) -> Result<Self, CodecError> {
|
|
326
|
+
let Some(header) = bytes.get(..HEADER_LEN) else {
|
|
327
|
+
return Err(CodecError::Truncated);
|
|
328
|
+
};
|
|
329
|
+
if header[0] != TAG_TDIGEST {
|
|
330
|
+
return Err(CodecError::Tag);
|
|
331
|
+
}
|
|
332
|
+
let counted = u32::from_le_bytes([header[3], header[4], header[5], header[6]]);
|
|
333
|
+
let Ok(clusters) = usize::try_from(counted) else {
|
|
334
|
+
return Err(CodecError::Truncated);
|
|
335
|
+
};
|
|
336
|
+
if (bytes.len() - HEADER_LEN) / CENTROID_LEN < clusters {
|
|
337
|
+
return Err(CodecError::Truncated);
|
|
338
|
+
}
|
|
339
|
+
let body = &bytes[HEADER_LEN..HEADER_LEN + clusters * CENTROID_LEN];
|
|
340
|
+
|
|
341
|
+
let mut digest = Self::new(u16::from_le_bytes([header[1], header[2]]));
|
|
342
|
+
digest.count = f64::from_le_bytes(read8(&header[7..15]));
|
|
343
|
+
digest.minimum = f64::from_le_bytes(read8(&header[15..23]));
|
|
344
|
+
digest.maximum = f64::from_le_bytes(read8(&header[23..31]));
|
|
345
|
+
let (slots, _) = body.as_chunks::<CENTROID_LEN>();
|
|
346
|
+
digest.centroids = slots
|
|
347
|
+
.iter()
|
|
348
|
+
.map(|slot| Centroid {
|
|
349
|
+
mean: f64::from_le_bytes(read8(&slot[..8])),
|
|
350
|
+
weight: f64::from_le_bytes(read8(&slot[8..])),
|
|
351
|
+
})
|
|
352
|
+
.collect();
|
|
353
|
+
if digest.is_coherent() {
|
|
354
|
+
Ok(digest)
|
|
355
|
+
} else {
|
|
356
|
+
Err(CodecError::Parameters)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/// Whether the clusters carry the total weight and reach the extremes the
|
|
361
|
+
/// header claims, as every digest built by adding points does.
|
|
362
|
+
fn is_coherent(&self) -> bool {
|
|
363
|
+
if self.centroids.len() > self.capacity() {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
let (Some(first), Some(last)) = (self.centroids.first(), self.centroids.last()) else {
|
|
367
|
+
return self.count == 0.0
|
|
368
|
+
&& self.minimum == f64::INFINITY
|
|
369
|
+
&& self.maximum == f64::NEG_INFINITY;
|
|
370
|
+
};
|
|
371
|
+
if !self.count.is_finite() || self.count <= 0.0 {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
let weight: f64 = self.centroids.iter().map(|centroid| centroid.weight).sum();
|
|
375
|
+
weight.is_finite()
|
|
376
|
+
&& self.centroids.iter().all(|centroid| {
|
|
377
|
+
centroid.mean.is_finite() && centroid.weight.is_finite() && centroid.weight > 0.0
|
|
378
|
+
})
|
|
379
|
+
&& self
|
|
380
|
+
.centroids
|
|
381
|
+
.is_sorted_by(|left, right| left.mean <= right.mean)
|
|
382
|
+
&& (weight - self.count).abs() <= self.count * WEIGHT_DRIFT
|
|
383
|
+
&& self.minimum.is_finite()
|
|
384
|
+
&& self.maximum.is_finite()
|
|
385
|
+
&& self.minimum <= first.mean
|
|
386
|
+
&& last.mean <= self.maximum
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Clusters held before compression runs, and never below the one cluster
|
|
390
|
+
/// folding cannot reduce further.
|
|
391
|
+
fn capacity(&self) -> usize {
|
|
392
|
+
(usize::from(self.compression) * CLUSTERS_PER_COMPRESSION).max(1)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// The weighted mean of two adjacent clusters.
|
|
397
|
+
///
|
|
398
|
+
/// Interpolating at half scale keeps the span of two opposite-sign extremes
|
|
399
|
+
/// inside the finite range, and doubling a mean that lies between two finite
|
|
400
|
+
/// means cannot leave it.
|
|
401
|
+
// Clippy only suggests `mul_add` when `std` provides it; `core` has no
|
|
402
|
+
// `f64::mul_add`, so the suggestion would break the `no_std` build.
|
|
403
|
+
#[cfg_attr(
|
|
404
|
+
feature = "std",
|
|
405
|
+
expect(clippy::suboptimal_flops, reason = "mul_add is std-only")
|
|
406
|
+
)]
|
|
407
|
+
fn fold(left: Centroid, right: Centroid) -> Centroid {
|
|
408
|
+
let weight = left.weight + right.weight;
|
|
409
|
+
let low = left.mean * 0.5;
|
|
410
|
+
let high = right.mean * 0.5;
|
|
411
|
+
Centroid {
|
|
412
|
+
mean: ((high - low) * (right.weight / weight) + low) * 2.0,
|
|
413
|
+
weight,
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/// Where `at` falls between two points, read off the line through them.
|
|
418
|
+
///
|
|
419
|
+
/// A span of two opposite-sign extremes is wider than [`f64::MAX`], and an
|
|
420
|
+
/// infinite span made every finite `at` land on the low end. Such a span is
|
|
421
|
+
/// halved the way [`fold`] averages, which costs nothing while the halves stay
|
|
422
|
+
/// normal but rounds a subnormal span to zero, so the span is only halved when
|
|
423
|
+
/// it does not fit.
|
|
424
|
+
// Clippy only suggests `mul_add` when `std` provides it; `core` has no
|
|
425
|
+
// `f64::mul_add`, so the suggestion would break the `no_std` build.
|
|
426
|
+
#[cfg_attr(
|
|
427
|
+
feature = "std",
|
|
428
|
+
expect(clippy::suboptimal_flops, reason = "mul_add is std-only")
|
|
429
|
+
)]
|
|
430
|
+
fn lerp(at: f64, low_x: f64, low_y: f64, high_x: f64, high_y: f64) -> f64 {
|
|
431
|
+
if high_x <= low_x {
|
|
432
|
+
return high_y;
|
|
433
|
+
}
|
|
434
|
+
let span = high_x - low_x;
|
|
435
|
+
let fraction = if span.is_finite() {
|
|
436
|
+
(at - low_x) / span
|
|
437
|
+
} else {
|
|
438
|
+
let low = low_x * 0.5;
|
|
439
|
+
(at * 0.5 - low) / (high_x * 0.5 - low)
|
|
440
|
+
};
|
|
441
|
+
low_y * (1.0 - fraction) + high_y * fraction
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#[cfg(test)]
|
|
445
|
+
mod tests;
|