Skip to content

Commit 2e86dac

Browse files
committed
fix: Summary quantiles collapsing for targeted quantiles with 2*epsilon >= 1-quantile
Fixes prometheus#2292. CKMSQuantiles returned values from far below the requested quantile for quantile configurations such as (0.9, 0.05) or (0.99, 0.005) - often the minimum of all observations, regardless of the input data. Interacting root causes, all stemming from the error function f() being of order n-r below a target quantile when 2*epsilon >= 1-quantile: 1. compress(): a single sample was allowed to span all ranks from r to n, so compress() merged away the samples that hold the information needed to answer the quantile query. With quantiles {(0.9, 0.05), (0.99, 0.005)} the sample list collapsed to 3 samples. 2. insertBefore(): freshly inserted samples get delta = f(r) - 1, so below a target their possible-rank intervals are centered near rank n regardless of the sample's actual position, making them indistinguishable from genuine samples near the target. 3. get(): the scan stopped at the first sample with r + g + delta > desiredRank + f(desiredRank)/2 and returned the value of the sample before it; a single wide sample (see 2., and get() flushes the buffer right before scanning, so such samples are always present) made the scan stop far before the target rank. The fix bounds sample widths by maxWidthNotCrossingTargets(r) in addition to f(r) at both places where widths are created - merging in compress() and delta assignment in insertBefore() - so that every target quantile keeps enough resolution around its accuracy window [quantile*n - epsilon*n, quantile*n + epsilon*n]. The bound is anchored at the window's start with a floor of 2*epsilon*n so that it does not degenerate for targets with quantile + epsilon >= 1 (window end == n), e.g. (0.99, 0.01) or (0.95, 0.05). get() returns the value of the sample whose possible rank interval is centered closest to the desired rank, which cannot be derailed by a single wide sample. Verified against exact percentiles on 3720 test cases (31 quantiles across 13 configurations x 6 distributions x 2 sizes x 10 seeds): worst rank error 1.75 * epsilon, no case above 2 * epsilon. Before the fix the worst rank error was 330 * epsilon. Also includes the deterministic regression case from the review of PR prometheus#2316 (values 1..10,000 shuffled with seed 2, single quantile (0.99, 0.005)), which this fix passes, plus regression tests for the quantile + epsilon >= 1 family and for descending input order. Signed-off-by: Oleg Kovalenko <okovalenko@evolution.com>
1 parent 92f8344 commit 2e86dac

2 files changed

Lines changed: 194 additions & 16 deletions

File tree

prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/CKMSQuantiles.java

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,12 @@ private void insertBefore(ListIterator<Sample> iterator, double value, int r) {
126126
samples.addFirst(new Sample(value, 0));
127127
} else {
128128
iterator.previous();
129-
iterator.add(new Sample(value, f(r) - 1));
129+
// delta is bounded by maxWidthNotCrossingTargets(r) in addition to the paper's f(r) - 1:
130+
// for a targeted quantile with 2*epsilon >= 1-quantile, f(r) below the target is of order
131+
// n-r, and a freshly inserted sample with such a delta has a possible-rank interval
132+
// centered near rank n regardless of its position — indistinguishable in get() from a
133+
// genuine sample near a target. See maxWidthNotCrossingTargets.
134+
iterator.add(new Sample(value, effectiveMaxWidth(r) - 1));
130135
iterator.next();
131136
}
132137
}
@@ -147,25 +152,37 @@ public double get(double q) {
147152
return samples.getLast().value;
148153
}
149154

155+
// Return the value of the sample whose possible rank range is centered closest to the
156+
// desired rank. The true rank of samples.get(i) is somewhere in
157+
// [r(i) , r(i) + delta(i)] with r(i) = g(0) + ... + g(i), so the best point estimate
158+
// of its rank is the center of that interval.
159+
//
160+
// Note that the previous implementation ("stop at the first sample with
161+
// r + g + delta > desiredRank + f(desiredRank)/2 and return the value of the sample
162+
// before it") is only correct if g + delta is small for all samples up to the target
163+
// rank. With targeted quantiles the error function f() allows g + delta to be large at
164+
// ranks far below a target quantile (for a target (q, epsilon) and rank r < q*n it
165+
// allows 2*epsilon*(n-r)/(1-q)), and freshly inserted samples used to get
166+
// delta = f(r) - 1 (and flush() above guarantees freshly inserted samples are present).
167+
// Such a sample tripped the old stop condition long before the target rank, so get()
168+
// returned a value from a far lower quantile than requested. For example, with
169+
// quantiles {(0.9, 0.05), (0.99, 0.005)} get(0.99) returned the minimum observation.
170+
// Sample widths are additionally bounded by maxWidthNotCrossingTargets at insert and
171+
// merge time, so near a target quantile the interval centers are tight estimates.
150172
int r = 0; // sum of g's left of the current sample
151173
int desiredRank = (int) Math.ceil(q * n);
152-
int upperBound = desiredRank + f(desiredRank) / 2;
153-
154-
ListIterator<Sample> iterator = samples.listIterator();
155-
while (iterator.hasNext()) {
156-
Sample sample = iterator.next();
157-
if (r + sample.g + sample.delta > upperBound) {
158-
iterator.previous(); // roll back the item.next() above
159-
if (iterator.hasPrevious()) {
160-
Sample result = iterator.previous();
161-
return result.value;
162-
} else {
163-
return sample.value;
164-
}
174+
double bestDistance = Double.MAX_VALUE;
175+
Sample bestSample = samples.getFirst();
176+
for (Sample sample : samples) {
177+
double rankEstimate = r + sample.g + sample.delta / 2.0;
178+
double distance = Math.abs(rankEstimate - desiredRank);
179+
if (distance < bestDistance) {
180+
bestDistance = distance;
181+
bestSample = sample;
165182
}
166183
r += sample.g;
167184
}
168-
return samples.getLast().value;
185+
return bestSample.value;
169186
}
170187

171188
/** Error function, as in definition 5 of the paper. */
@@ -192,6 +209,67 @@ int f(int r) {
192209
return Math.max(minResult, 1);
193210
}
194211

212+
/**
213+
* Maximum width (g + delta) of a sample whose predecessor has rank r such that the sample keeps
214+
* enough resolution around the accuracy window [quantile*n - epsilon*n, quantile*n + epsilon*n]
215+
* of every target quantile: below a window a sample may extend at most max(windowStart - r,
216+
* 2*epsilon*n) — it can intrude into the window but never reach the window's end — and any sample
217+
* overlapping a window has width at most the window's size 2*epsilon*n. So no single sample can
218+
* span a whole window, and resolution around each target stays at the window scale: the center of
219+
* a sample's possible-rank interval is within epsilon*n of any rank the sample covers inside the
220+
* window.
221+
*
222+
* <p>This is needed in addition to the error function f(): for a target (quantile, epsilon) and
223+
* rank r below the target, f() allows a width of 2*epsilon*(n-r)/(1-quantile). When 2*epsilon >=
224+
* (1-quantile) — e.g. (0.9, 0.05) or (0.99, 0.005) — this is >= (n-r), i.e. a single sample may
225+
* span all ranks from r to n. Two failure modes follow: compress() merges away all samples
226+
* between r and n, permanently destroying the information needed to answer the quantile query
227+
* (with quantiles {(0.9, 0.05), (0.99, 0.005)} the sample list collapsed to 3 samples regardless
228+
* of how many values were inserted, and get() returned the minimum observation for every
229+
* quantile), and insertBefore() assigns freshly inserted samples a delta of the same order, so
230+
* their possible-rank intervals are centered near rank n and get() cannot tell them apart from
231+
* genuine samples near a target. This bound is therefore applied both when merging in compress()
232+
* and when assigning delta in insertBefore(). For configurations with 2*epsilon < (1-quantile)
233+
* this bound is larger than f() near the target, so behavior is mostly unchanged.
234+
*
235+
* <p>The bound is anchored at the window's start rather than its end so that it does not
236+
* degenerate for targets with quantile + epsilon >= 1 (e.g. (0.95, 0.05) or (0.99, 0.01)), where
237+
* the window's end is rank n and "may not extend past the window's end" would be no constraint at
238+
* all.
239+
*
240+
* <p>This is intentionally not part of the per-sample invariant (g + delta <= f(r)): the bound
241+
* depends on n while a sample's delta is fixed at insert time, so it cannot be maintained as a
242+
* static invariant — but enforcing it at insert and merge time is what matters, because those are
243+
* the only operations that create sample widths.
244+
*/
245+
int maxWidthNotCrossingTargets(int r) {
246+
double min = Double.MAX_VALUE;
247+
for (Quantile q : quantiles) {
248+
if (q.quantile == 0 || q.quantile == 1) {
249+
continue;
250+
}
251+
double windowStart = q.quantile * n - q.epsilon * n;
252+
double windowEnd = q.quantile * n + q.epsilon * n;
253+
if (r < windowEnd) {
254+
min = Math.min(min, Math.max(windowStart - r, 2 * q.epsilon * n));
255+
}
256+
}
257+
if (min == Double.MAX_VALUE) {
258+
return Integer.MAX_VALUE;
259+
}
260+
return Math.max((int) (min + 0.00000000001), 1);
261+
}
262+
263+
/**
264+
* Effective maximum width (g + delta) of a sample whose predecessor has rank r: the error
265+
* function f() additionally bounded by {@link #maxWidthNotCrossingTargets(int)}. Both places that
266+
* create sample widths — merging in compress() and delta assignment in insertBefore() — must use
267+
* this combined bound.
268+
*/
269+
int effectiveMaxWidth(int r) {
270+
return Math.min(f(r), maxWidthNotCrossingTargets(r));
271+
}
272+
195273
/** Merge pairs of consecutive samples if this doesn't violate the error function. */
196274
void compress() {
197275
if (samples.size() < 3) {
@@ -212,7 +290,7 @@ void compress() {
212290
// The min sample must never be merged.
213291
break;
214292
}
215-
if (left.g + right.g + right.delta < f(r)) {
293+
if (left.g + right.g + right.delta < effectiveMaxWidth(r)) {
216294
right.g += left.g;
217295
descendingIterator.remove();
218296
left = right;

prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/CKMSQuantilesTest.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,106 @@ void testMaxEpsilon() {
224224
validateResults(ckms);
225225
}
226226

227+
/**
228+
* Reproducer for the quantile collapse bug: for a target quantile (q, epsilon) the error function
229+
* allows samples below rank q*n to have g + delta up to 2*epsilon*(n-r)/(1-q). When 2*epsilon >=
230+
* 1-q (as in (0.9, 0.05) or (0.99, 0.005) — both taken from real-world configurations) this is >=
231+
* n-r, so (a) compress() merged almost all samples away and (b) get() stopped at the first
232+
* freshly inserted sample (delta = f(r)-1) and returned the minimum observation for every
233+
* quantile: get(0.9) == get(0.99) == 1.0 regardless of the input data.
234+
*/
235+
@Test
236+
void testTargetedQuantilesDoNotCollapse() {
237+
Random random = new Random(42);
238+
CKMSQuantiles ckms = new CKMSQuantiles(new Quantile(0.9, 0.05), new Quantile(0.99, 0.005));
239+
for (double value : shuffledValues(100 * 1000, random)) {
240+
ckms.insert(value);
241+
}
242+
validateResults(ckms);
243+
}
244+
245+
/** Like {@link #testTargetedQuantilesDoNotCollapse()}, with a single targeted quantile. */
246+
@Test
247+
void testSingleTargetedQuantileDoesNotCollapse() {
248+
Random random = new Random(43);
249+
CKMSQuantiles ckms = new CKMSQuantiles(new Quantile(0.99, 0.005));
250+
for (double value : shuffledValues(100 * 1000, random)) {
251+
ckms.insert(value);
252+
}
253+
validateResults(ckms);
254+
}
255+
256+
/**
257+
* Adding a well-behaved quantile (0.5, 0.05) to the collapsing configuration bounds the error
258+
* function in the lower ranks, but before the fix get(0.99) still returned a value from around
259+
* the 85th percentile: samples between rank 0.8*n and 0.99*n may have g + delta up to n-r, and
260+
* the old stop condition in get() tripped on the first of them.
261+
*/
262+
@Test
263+
void testTargetedQuantilesWithMedian() {
264+
Random random = new Random(44);
265+
CKMSQuantiles ckms =
266+
new CKMSQuantiles(
267+
new Quantile(0.5, 0.05), new Quantile(0.9, 0.05), new Quantile(0.99, 0.005));
268+
for (double value : shuffledValues(100 * 1000, random)) {
269+
ckms.insert(value);
270+
}
271+
validateResults(ckms);
272+
}
273+
274+
/**
275+
* Deterministic small-n case from the review of an earlier fix attempt
276+
* (https://github.com/prometheus/client_java/pull/2316): with values 1..10,000 shuffled with seed
277+
* 2, selecting the sample whose possible-rank interval is centered nearest the desired rank
278+
* returned 9784 there, outside the accuracy window [9800, 10000]. The additional merge bound in
279+
* compress() keeps enough resolution around the target rank for this case to pass.
280+
*/
281+
@Test
282+
void testSingleTargetedQuantileSmallN() {
283+
Random random = new Random(2);
284+
CKMSQuantiles ckms = new CKMSQuantiles(new Quantile(0.99, 0.005));
285+
for (double value : shuffledValues(10 * 1000, random)) {
286+
ckms.insert(value);
287+
}
288+
validateResults(ckms);
289+
}
290+
291+
/**
292+
* Targets with quantile + epsilon >= 1 are the degenerate end of the collapsing family: the
293+
* accuracy window's end is rank n itself, so a bound phrased as "a sample may not extend past the
294+
* window's end" is no constraint at all, and freshly inserted samples with delta = f(r) - 1 have
295+
* possible-rank intervals centered near rank n regardless of their position. Both the merge bound
296+
* and the insert-time delta bound must be anchored at the window's start for these
297+
* configurations.
298+
*/
299+
@Test
300+
void testTargetedQuantileWindowReachingMaximum() {
301+
for (Quantile quantile : new Quantile[] {new Quantile(0.99, 0.01), new Quantile(0.95, 0.05)}) {
302+
for (int seed = 0; seed < 5; seed++) {
303+
Random random = new Random(seed);
304+
CKMSQuantiles ckms = new CKMSQuantiles(quantile);
305+
for (double value : shuffledValues(10 * 1000, random)) {
306+
ckms.insert(value);
307+
}
308+
validateResults(ckms);
309+
}
310+
}
311+
}
312+
313+
/**
314+
* Descending input is the worst case for the collapsing configurations: every insert happens at
315+
* the front of the sample list, where the error function is loosest. Before the insert-time delta
316+
* bound, get(0.9) was off by 2.9 * epsilon here.
317+
*/
318+
@Test
319+
void testTargetedQuantilesDescendingInput() {
320+
CKMSQuantiles ckms = new CKMSQuantiles(new Quantile(0.9, 0.05), new Quantile(0.99, 0.005));
321+
for (int value = 10 * 1000; value >= 1; value--) {
322+
ckms.insert(value);
323+
}
324+
validateResults(ckms);
325+
}
326+
227327
@Test
228328
void testGetGaussian() {
229329
RandomGenerator rand = new JDKRandomGenerator();

0 commit comments

Comments
 (0)