Skip to content

Commit 1fbff34

Browse files
author
Paul Meng
committed
docs: clarify digest contracts accuracy and memory costs
1 parent 8812732 commit 1fbff34

4 files changed

Lines changed: 308 additions & 60 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Fixed
8+
9+
- Preserve the requested compression size and sort centroids in `TDigest::new`; reject zero compression sizes.
10+
- Keep compression within `max_size` even when fractional centroid weights and the stored count differ by roundoff.
11+
- Make quantile interpolation monotonic across uneven centroid gaps, with finite results for extreme values and subnormal weights.
12+
- Interpolate ranks for single-centroid digests that represent varying values and avoid overflow across extreme endpoints.
13+
- Keep centroid means and trimmed means finite when weighted sums overflow; compute `mean()` from retained data if its sum overflows.
14+
- Preserve centroids when adding zero sum and zero weight, including empty accumulators.
15+
- Make centroid equality consistent with its total ordering, including weights and signed zeros.
16+
- Reject invalid serde states before they can cause query or merge panics, preserving the existing field layout for valid digests.
17+
- Restrict debug-assertion tests to debug builds so the release test suite can run.
18+
19+
### Performance
20+
21+
- Allocate the ingestion buffer on first use and reuse it across flushes.
22+
- Bound compression-vector reservations by the available input size.
23+
- Avoid building cumulative weights for empty or single-element bulk quantile queries.
24+
25+
### Documentation and validation
26+
27+
- Clarify memory usage, flush requirements, serialization limits, query semantics, and distribution-dependent accuracy.
28+
- Add constructor, numeric, and serde regression tests; extend fuzzing to buffered ingestion and interior quantiles.
29+
- Cover optimized tests and Rust 1.62 `no_std` configurations in CI.
30+
531
## [1.0.0] - 2026-07-12
632

733
### Added

‎CLAUDE.md‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,45 @@ This is `tdigest`, a Rust implementation of the t-digest data structure for accu
1010
cargo build # Build with default features
1111
cargo build --all-features # Build with serde support
1212
cargo test --all-features # Run all tests including serde round-trip
13+
cargo test --release --all-features # Test optimized builds without debug assertions
1314
cargo test --features serde # Test the preferred serde feature
1415
cargo test --features use_serde # Test the compatibility alias
1516
cargo fmt --all -- --check # Check formatting
1617
cargo check --all-features # Type-check only
1718
cargo check --no-default-features # Verify no_std + alloc
19+
cargo check --no-default-features --features serde # Verify serde with alloc
20+
cargo clippy --all-features --all-targets -- -D warnings
21+
RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps
22+
cargo bench # Criterion performance benchmarks
23+
cargo run --release --example accuracy # Deterministic accuracy comparison
1824
```
1925

2026
## Project structure
2127

22-
- `src/lib.rs` -- entire library: `Centroid`, `TDigest`, all methods, and all tests
28+
- `src/lib.rs` -- entire library: `Centroid`, `TDigest`, all methods, unit tests, and property tests
29+
- `tests/` -- integration regression tests for public API behavior
2330
- `Cargo.toml` -- package metadata, MSRV 1.62, default `std`, optional `serde`, and compatibility `use_serde` features
2431
- `docs/architecture.md` -- algorithm and architecture documentation
32+
- `benches/tdigest.rs` -- deterministic Criterion ingestion, merge, and query benchmarks
33+
- `examples/accuracy.rs` -- exact versus estimated quantiles across six distributions
34+
- `fuzz/` -- standalone cargo-fuzz package; see `fuzz/README.md` for nightly commands
2535
- `rustfmt.toml` -- max_width = 120
2636

2737
## Code conventions
2838

29-
- Single-file library (`src/lib.rs`), tests are inline `#[cfg(test)]` at the bottom
30-
- Immutable API: data methods (`merge_sorted`, `merge_unsorted`) take `&self` and return a new `TDigest`
31-
- `Option<f64>` for fallible queries (`min`, `max`, `mean`, `estimate_quantile`) -- never NaN sentinels
39+
- Single-file library (`src/lib.rs`), with inline `#[cfg(test)]` tests and public API regression tests in `tests/`
40+
- Batch API: `merge_sorted` and `merge_unsorted` take `&self` and return a new `TDigest`; the sorted method requires ascending input
41+
- Mutable API: `push`, `extend_values`, and `Extend<f64>` buffer data; summary statistics update immediately
42+
- Call `flush` before centroid queries, immutable merges, or serialization; `FromIterator<f64>` flushes before returning
43+
- `Option<f64>` for queries that require samples (`min`, `max`, `mean`, `estimate_quantile`, `estimate_rank`, `trimmed_mean`); `quantiles` returns a vector of optional estimates
3244
- `#[must_use]` on methods returning new values
3345
- `#[non_exhaustive]` on public structs
34-
- `debug_assert` for invariant checks (NaN rejection, min/max presence)
46+
- Require a positive compression size in constructors; `max_size` is a compression target, not an exact byte budget
47+
- `debug_assert` for ingestion contracts (finite values) and state invariants; these checks are absent in release builds
48+
- Serde skips the pending buffer; never assume serialization flushes or rejects pending data
49+
- Deserialization validates centroid order, weights, extrema, and count consistency; nonempty sums may be non-finite after valid ingestion
50+
- Compression's final bucket absorbs remaining weight so count roundoff cannot exceed `max_size`
51+
- Keep library code compatible with Rust 1.62 without `std`; verify this configuration separately from all-features builds
3552
- No external dependencies by default; serde is behind the `serde` feature, with `use_serde` as a deprecated alias
3653
- The library is `no_std` and uses `alloc`; the default `std` feature preserves existing behavior
3754
- Formatting: rustfmt with max_width=120

‎README.md‎

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ This implementation follows Facebook's [folly TDigest](https://github.com/facebo
1212

1313
## Features
1414

15-
- **Accurate quantile estimation** with bounded error, especially at the tails (p99, p999)
15+
- **Approximate quantiles** with finer resolution at the tails (p99, p99.9)
1616
- **Mergeable** -- combine digests computed on different machines or threads
17-
- **Compact** -- fixed memory footprint regardless of input size
17+
- **Compact** -- retained state scales with the compression factor, rather than the number of samples
1818
- **No dependencies** by default (optional `serde` support behind a feature flag)
1919
- **`no_std` + `alloc` support**, including serde without its `std` feature
2020

@@ -88,6 +88,10 @@ let unsorted = vec![5.0, 3.0, 1.0, 4.0, 2.0];
8888
let t = t.merge_unsorted(unsorted);
8989
```
9090

91+
`merge_sorted` requires ascending input. Use `merge_unsorted` when the order is
92+
unknown. Both methods borrow the existing digest and return a new one; keep the
93+
returned value to retain the inserted data.
94+
9195
For streaming ingestion, use the buffered mutable API and flush before a
9296
centroid-based query:
9397

@@ -102,6 +106,22 @@ t.flush();
102106
assert_eq!(t.estimate_quantile(0.5), Some(3.0));
103107
```
104108

109+
`push`, `extend_values`, and the standard `Extend<f64>` implementation update
110+
`count`, `sum`, `mean`, `min`, `max`, and `is_empty` immediately. Call `flush`
111+
before `estimate_quantile`, `quantiles`, `estimate_rank`, `trimmed_mean`,
112+
`centroids`, immutable merges, or serialization. Automatic compression happens
113+
when the buffer fills, so a partially filled buffer still needs an explicit flush.
114+
115+
Collecting an iterator creates a digest with the default compression factor and
116+
flushes it before returning:
117+
118+
```rust
119+
use tdigest::TDigest;
120+
121+
let t: TDigest = [5.0, 1.0, 3.0].into_iter().collect();
122+
assert_eq!(t.estimate_quantile(0.5), Some(3.0));
123+
```
124+
105125
### Querying
106126

107127
```rust
@@ -117,18 +137,32 @@ let max = t.max(); // Some(5.0)
117137
let mean = t.mean(); // Some(3.0)
118138
let count = t.count(); // 5.0
119139
let sum = t.sum(); // 15.0
140+
141+
// Estimate several quantiles in one call; input order is preserved
142+
let percentiles = t.quantiles(&[0.5, 0.9, 0.99]);
143+
144+
// Approximate CDF and the mean of the middle 80% of the distribution
145+
let rank = t.estimate_rank(3.0);
146+
let middle_mean = t.trimmed_mean(0.1, 0.9);
120147
```
121148

149+
Quantiles at or below 0 return the minimum; quantiles at or above 1 return the
150+
maximum. `trimmed_mean` clamps its bounds to `[0, 1]` and returns `None` for an
151+
empty interval or NaN bound. Rank and trimmed-mean queries also return `None`
152+
for empty digests. These queries interpolate compressed data and need not match
153+
an exact sample percentile or empirical CDF.
154+
122155
### Merging digests (distributed / parallel use)
123156

124157
```rust
125158
use tdigest::TDigest;
126159

127160
// Build digests independently (e.g. on different threads)
161+
let data: Vec<f64> = (1..=10_000).map(f64::from).collect();
128162
let mut digests = Vec::new();
129163
for chunk in data.chunks(1000) {
130164
let t = TDigest::new_with_size(100);
131-
let t = t.merge_sorted(chunk.to_vec());
165+
let t = t.merge_unsorted(chunk.to_vec());
132166
digests.push(t);
133167
}
134168

@@ -137,6 +171,11 @@ let combined = TDigest::merge_digests(digests);
137171
let p99 = combined.estimate_quantile(0.99);
138172
```
139173

174+
Flush any digests built with mutable ingestion before merging. The result uses
175+
the largest input `max_size`; an empty input vector produces an empty digest
176+
with the default size of 100. Repeated merging can change estimates, so measure
177+
accuracy with the merge pattern used by your application.
178+
140179
### Serialization (serde)
141180

142181
Enable the `serde` feature, then use any serde-compatible format. The deprecated
@@ -152,18 +191,55 @@ let json = serde_json::to_string(&t).unwrap();
152191
let restored: TDigest = serde_json::from_str(&json).unwrap();
153192
```
154193

155-
## Compression factor
194+
To run this JSON example with exact finite-float round trips, add
195+
`serde_json = { version = "1.0", features = ["float_roundtrip"] }` to your
196+
dependencies. Without that feature, JSON parsing can introduce small rounding
197+
differences in centroid values.
156198

157-
The `max_size` parameter controls the trade-off between accuracy and memory:
199+
The pending ingestion buffer is omitted from serialization. Always call `flush`
200+
after mutable ingestion: serializing without it succeeds but loses pending
201+
samples while retaining their summary statistics. Deserialization validates
202+
centroid ordering, weights, extrema, and count consistency, and rejects
203+
inconsistent payloads such as an omitted buffer that leaves a count mismatch.
204+
The 1.0 format uses `Option<f64>` for `min` and `max`; data written by older
205+
versions may need migration, depending on the serialization format.
158206

159-
| `max_size` | Memory (approx.) | Accuracy |
160-
|------------|-------------------|----------|
161-
| 50 | ~1.2 KB | Good for rough estimates |
162-
| 100 | ~2.4 KB | Good default |
163-
| 200 | ~4.8 KB | High accuracy |
164-
| 500 | ~12 KB | Very high accuracy |
207+
## Compression factor
165208

166-
Larger values produce more centroids, giving better accuracy at the cost of more memory and slightly slower merges. A value of 100 is sufficient for most use cases.
209+
The positive `max_size` parameter controls the trade-off between accuracy and
210+
memory; zero is rejected. The default is 100.
211+
212+
| `max_size` | Centroid payload (approx.) | Full ingestion buffer |
213+
|------------|----------------------------|-----------------------|
214+
| 50 | 0.8 KB | 2 KB |
215+
| 100 | 1.6 KB | 4 KB |
216+
| 200 | 3.2 KB | 8 KB |
217+
| 500 | 8 KB | 20 KB |
218+
219+
Each centroid holds two `f64` values (16 bytes); the streaming buffer holds up to
220+
`5 * max_size` values (8 bytes each), allocated on the first mutable insertion
221+
and reused after flushing. Batch-only digests do not allocate this buffer.
222+
These are payload estimates in decimal KB, excluding the digest struct, spare
223+
vector capacity, allocator overhead, input vectors, and temporary storage during
224+
merges. `max_size` is a compression target, not an exact byte budget.
225+
226+
Larger values generally improve accuracy at the cost of more memory and merge
227+
work. Start with the default of 100, then measure on your own distribution.
228+
229+
## Input and numeric limits
230+
231+
Inserted values must be finite. NaN and positive or negative infinity violate
232+
the ingestion contract: debug builds reject them, while release-build estimates
233+
are unspecified. NaN also violates the contract for `estimate_quantile`,
234+
`quantiles`, and `estimate_rank`; `trimmed_mean` instead returns `None` for a NaN
235+
bound.
236+
237+
Counts and sums use `f64`. Integer counts are exact only through `2^53`, and a
238+
sum of finite values can overflow to infinity or NaN. `mean` normally computes
239+
`sum / count`; when the sum is non-finite, it falls back to an approximate
240+
weighted mean of the centroids and pending values. This fallback takes linear
241+
time in the retained state. `sum` remains non-finite: formats such as JSON cannot
242+
round-trip that value. Scale inputs when you need a finite sum or JSON storage.
167243

168244
## Minimum Supported Rust Version (MSRV)
169245

@@ -187,6 +263,10 @@ quantiles across deterministic uniform, normal, lognormal, exponential, bimodal,
187263
and adversarial distributions. See [architecture and internals](docs/architecture.md#measured-accuracy)
188264
for a summary of the measured error profile.
189265

266+
The crate does not provide a distribution-independent error bound. Tail
267+
resolution improves with compression size, but skew, repeated values, gaps in
268+
the distribution, batch boundaries, and merge order can affect estimates.
269+
190270
## Benchmarks
191271

192272
Run `cargo bench` to measure sorted and unsorted batch ingestion, incremental

0 commit comments

Comments
 (0)