Skip to content

Commit f8018f0

Browse files
authored
Merge pull request #765 from tisonkun/codex/tdigest-numeric-validation
Fix TDigest extreme means and validate serialized state
2 parents 20678c4 + 654c26b commit f8018f0

3 files changed

Lines changed: 508 additions & 35 deletions

File tree

‎src/main/java/org/apache/datasketches/tdigest/TDigestDouble.java‎

Lines changed: 106 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,12 @@ public short getK() {
9898
* Update this TDigest with the given value.
9999
* NaN and infinity are ignored.
100100
* @param value to update the TDigest with
101+
* @throws ArithmeticException if the total weight would exceed Long.MAX_VALUE
101102
*/
102103
public void update(final double value) {
103104
if (!Double.isFinite(value)) { return; }
104-
if (numBuffered_ == (centroidsCapacity_ * BUFFER_MULTIPLIER)) { compress(); }
105+
Math.addExact(getTotalWeight(), 1);
106+
if (numBuffered_ >= (centroidsCapacity_ * BUFFER_MULTIPLIER)) { compress(); }
105107
bufferValues_[numBuffered_] = value;
106108
numBuffered_++;
107109
minValue_ = Math.min(minValue_, value);
@@ -111,9 +113,11 @@ public void update(final double value) {
111113
/**
112114
* Merge the given TDigest into this one
113115
* @param other TDigest to merge
116+
* @throws ArithmeticException if the combined total weight would exceed Long.MAX_VALUE
114117
*/
115118
public void merge(final TDigestDouble other) {
116119
if (other.isEmpty()) { return; }
120+
Math.addExact(getTotalWeight(), other.getTotalWeight());
117121
final int num = numCentroids_ + numBuffered_ + other.numCentroids_ + other.numBuffered_;
118122
final double[] values = new double[num];
119123
final long[] weights = new long[num];
@@ -256,6 +260,7 @@ public double getQuantile(final double rank) {
256260
}
257261
final double lastWeight = centroidWeights_[numCentroids_ - 1];
258262
if ((lastWeight > 1) && ((centroidsWeight_ - weight) <= (lastWeight / 2.0))) {
263+
if (lastWeight == 2) { return maxValue_; }
259264
return maxValue_ - (((centroidsWeight_ - weight - 1.0) / ((lastWeight / 2.0) - 1.0))
260265
* (maxValue_ - centroidMeans_[numCentroids_ - 1]));
261266
}
@@ -395,6 +400,7 @@ public static TDigestDouble heapify(final MemorySegment seg) {
395400
* @return an instance of TDigest
396401
*/
397402
public static TDigestDouble heapify(final MemorySegment seg, final boolean isFloat) {
403+
checkSerializedSize(seg, 8);
398404
final PositionalSegment posSeg = PositionalSegment.wrap(seg);
399405
final byte preambleLongs = posSeg.getByte();
400406
final byte serialVersion = posSeg.getByte();
@@ -411,6 +417,10 @@ public static TDigestDouble heapify(final MemorySegment seg, final boolean isFlo
411417
final byte flagsByte = posSeg.getByte();
412418
final boolean isEmpty = (flagsByte & (1 << Flags.IS_EMPTY.ordinal())) > 0;
413419
final boolean isSingleValue = (flagsByte & (1 << Flags.IS_SINGLE_VALUE.ordinal())) > 0;
420+
final int knownFlags = (1 << Flags.values().length) - 1;
421+
if (((flagsByte & ~knownFlags) != 0) || (isEmpty && isSingleValue)) {
422+
throw new SketchesArgumentException("Invalid TDigest flags: " + flagsByte);
423+
}
414424
final byte expectedPreambleLongs = isEmpty || isSingleValue ? PREAMBLE_LONGS_EMPTY_OR_SINGLE : PREAMBLE_LONGS_MULTIPLE;
415425
if (preambleLongs != expectedPreambleLongs) {
416426
throw new SketchesArgumentException("Preamble longs mismatch: expected " + expectedPreambleLongs + ", actual " + preambleLongs);
@@ -419,6 +429,7 @@ public static TDigestDouble heapify(final MemorySegment seg, final boolean isFlo
419429
if (isEmpty) { return new TDigestDouble(k); }
420430
final boolean reverseMerge = (flagsByte & (1 << Flags.REVERSE_MERGE.ordinal())) > 0;
421431
if (isSingleValue) {
432+
checkSerializedSize(seg, 8 + (isFloat ? Float.BYTES : Double.BYTES));
422433
final double value;
423434
if (isFloat) {
424435
value = posSeg.getFloat();
@@ -428,8 +439,12 @@ public static TDigestDouble heapify(final MemorySegment seg, final boolean isFlo
428439
checkDeserializedValue(value, "value");
429440
return new TDigestDouble(reverseMerge, k, value, value, new double[] {value}, new long[] {1}, 1, null);
430441
}
442+
final int valueBytes = isFloat ? Float.BYTES : Double.BYTES;
443+
checkSerializedSize(seg, 16 + (2L * valueBytes));
431444
final int numCentroids = posSeg.getInt();
432445
final int numBuffered = posSeg.getInt();
446+
checkDeserializedCounts(k, numCentroids, numBuffered);
447+
checkSerializedSize(seg, 16 + (2L * valueBytes) + (2L * valueBytes * numCentroids) + ((long) valueBytes * numBuffered));
433448
final double min;
434449
final double max;
435450
if (isFloat) {
@@ -439,22 +454,22 @@ public static TDigestDouble heapify(final MemorySegment seg, final boolean isFlo
439454
min = posSeg.getDouble();
440455
max = posSeg.getDouble();
441456
}
442-
checkDeserializedValue(min, "min");
443-
checkDeserializedValue(max, "max");
457+
checkDeserializedExtrema(min, max);
444458
final double[] means = new double[numCentroids];
445459
final long[] weights = new long[numCentroids];
446460
long totalWeight = 0;
447461
for (int i = 0; i < numCentroids; i++) {
448462
means[i] = isFloat ? posSeg.getFloat() : posSeg.getDouble();
449463
weights[i] = isFloat ? posSeg.getInt() : posSeg.getLong();
450-
checkDeserializedValue(means[i], "centroid mean");
464+
checkDeserializedRange(means[i], i == 0 ? min : means[i - 1], max, "centroid mean");
451465
checkDeserializedWeight(weights[i]);
452-
totalWeight += weights[i];
466+
totalWeight = addDeserializedWeight(totalWeight, weights[i]);
453467
}
468+
addDeserializedWeight(totalWeight, numBuffered);
454469
final double[] buffered = new double[numBuffered];
455470
for (int i = 0; i < numBuffered; i++) {
456471
buffered[i] = isFloat ? posSeg.getFloat() : posSeg.getDouble();
457-
checkDeserializedValue(buffered[i], "buffered value");
472+
checkDeserializedRange(buffered[i], min, max, "buffered value");
458473
}
459474
return new TDigestDouble(reverseMerge, k, min, max, means, weights, totalWeight, buffered);
460475
}
@@ -468,43 +483,47 @@ private static TDigestDouble heapifyCompat(final MemorySegment seg) {
468483
throw new SketchesArgumentException("unexpected compatibility type " + type);
469484
}
470485
if (type == COMPAT_DOUBLE) { // compatibility with asBytes()
486+
checkSerializedSize(seg, 32);
471487
final double min = seg.get(JAVA_DOUBLE_UNALIGNED_BIG_ENDIAN, offset); offset += Double.BYTES;
472488
final double max = seg.get(JAVA_DOUBLE_UNALIGNED_BIG_ENDIAN, offset); offset += Double.BYTES;
473489
final short k = (short) seg.get(JAVA_DOUBLE_UNALIGNED_BIG_ENDIAN, offset); offset += Double.BYTES;
474490
final int numCentroids = seg.get(JAVA_INT_UNALIGNED_BIG_ENDIAN, offset); offset += Integer.BYTES;
475-
checkDeserializedValue(min, "min");
476-
checkDeserializedValue(max, "max");
491+
checkDeserializedCounts(k, numCentroids, 0);
492+
checkSerializedSize(seg, offset + (16L * numCentroids));
493+
checkDeserializedExtrema(min, max);
477494
final double[] means = new double[numCentroids];
478495
final long[] weights = new long[numCentroids];
479496
long totalWeight = 0;
480497
for (int i = 0; i < numCentroids; i++) {
481-
weights[i] = (long) seg.get(JAVA_DOUBLE_UNALIGNED_BIG_ENDIAN, offset); offset += Double.BYTES;
498+
weights[i] = readCompatibilityWeight(seg.get(JAVA_DOUBLE_UNALIGNED_BIG_ENDIAN, offset)); offset += Double.BYTES;
482499
means[i] = seg.get(JAVA_DOUBLE_UNALIGNED_BIG_ENDIAN, offset); offset += Double.BYTES;
483-
checkDeserializedValue(means[i], "centroid mean");
484-
checkDeserializedWeight(weights[i]);
485-
totalWeight += weights[i];
500+
checkDeserializedRange(means[i], i == 0 ? min : means[i - 1], max, "centroid mean");
501+
totalWeight = addDeserializedWeight(totalWeight, weights[i]);
486502
}
487503
return new TDigestDouble(false, k, min, max, means, weights, totalWeight, null);
488504
}
489505
// COMPAT_FLOAT: compatibility with asSmallBytes(), reference implementation uses doubles for min and max
506+
checkSerializedSize(seg, 30);
490507
final double min = seg.get(JAVA_DOUBLE_UNALIGNED_BIG_ENDIAN, offset); offset += Double.BYTES;
491508
final double max = seg.get(JAVA_DOUBLE_UNALIGNED_BIG_ENDIAN, offset);offset += Double.BYTES;
492509
final short k = (short) seg.get(JAVA_FLOAT_UNALIGNED_BIG_ENDIAN, offset); offset += Float.BYTES;
493510
// reference implementation stores capacities of the array of centroids and the buffer as shorts
494511
// they can be derived from k in the constructor
495512
seg.get(JAVA_INT_UNALIGNED_BIG_ENDIAN, offset); offset += Integer.BYTES;
496513
final int numCentroids = seg.get(JAVA_SHORT_UNALIGNED_BIG_ENDIAN, offset); offset += Short.BYTES;
497-
checkDeserializedValue(min, "min");
498-
checkDeserializedValue(max, "max");
514+
checkDeserializedCounts(k, numCentroids, 0);
515+
checkSerializedSize(seg, offset + (8L * numCentroids));
516+
checkDeserializedExtrema(min, max);
499517
final double[] means = new double[numCentroids];
500518
final long[] weights = new long[numCentroids];
501519
long totalWeight = 0;
502520
for (int i = 0; i < numCentroids; i++) {
503-
weights[i] = (long) seg.get(JAVA_FLOAT_UNALIGNED_BIG_ENDIAN, offset); offset += Float.BYTES;
521+
weights[i] = readCompatibilityWeight(seg.get(JAVA_FLOAT_UNALIGNED_BIG_ENDIAN, offset)); offset += Float.BYTES;
504522
means[i] = seg.get(JAVA_FLOAT_UNALIGNED_BIG_ENDIAN, offset); offset += Float.BYTES;
505-
checkDeserializedValue(means[i], "centroid mean");
506-
checkDeserializedWeight(weights[i]);
507-
totalWeight += weights[i];
523+
// Float means can round just outside the double extrema in asSmallBytes().
524+
checkDeserializedRange(means[i], (float) (i == 0 ? min : means[i - 1]), (float) max, "centroid mean");
525+
means[i] = Math.max(min, Math.min(max, means[i]));
526+
totalWeight = addDeserializedWeight(totalWeight, weights[i]);
508527
}
509528
return new TDigestDouble(false, k, min, max, means, weights, totalWeight, null);
510529
}
@@ -521,6 +540,50 @@ private static void checkDeserializedWeight(final long weight) {
521540
}
522541
}
523542

543+
private static long readCompatibilityWeight(final double weight) {
544+
// Validate before narrowing: Java truncates fractions and saturates overflowing casts.
545+
if (!Double.isFinite(weight) || (weight < 1) || (weight >= 0x1p63) || (weight != Math.rint(weight))) {
546+
throw new SketchesArgumentException("Deserialized centroid weight must be a positive long, actual: " + weight);
547+
}
548+
return (long) weight;
549+
}
550+
551+
private static long addDeserializedWeight(final long total, final long weight) {
552+
if (weight > (Long.MAX_VALUE - total)) {
553+
throw new SketchesArgumentException("Deserialized total weight exceeds Long.MAX_VALUE");
554+
}
555+
return total + weight;
556+
}
557+
558+
private static void checkDeserializedExtrema(final double min, final double max) {
559+
checkDeserializedValue(min, "min");
560+
checkDeserializedValue(max, "max");
561+
if (min > max) { throw new SketchesArgumentException("Deserialized min must not exceed max"); }
562+
}
563+
564+
private static void checkDeserializedRange(final double value, final double lower, final double upper,
565+
final String description) {
566+
checkDeserializedValue(value, description);
567+
if ((value < lower) || (value > upper)) {
568+
throw new SketchesArgumentException("Deserialized " + description + " must be within [" + lower + ", " + upper + "]");
569+
}
570+
}
571+
572+
private static void checkDeserializedCounts(final short k, final int numCentroids, final int numBuffered) {
573+
checkK(k);
574+
if ((numCentroids < 0) || (numBuffered < 0) || (numCentroids > (Integer.MAX_VALUE - numBuffered))
575+
|| ((numCentroids + numBuffered) == 0)) {
576+
throw new SketchesArgumentException("Invalid TDigest counts: centroids=" + numCentroids + ", buffered=" + numBuffered);
577+
}
578+
}
579+
580+
private static void checkSerializedSize(final MemorySegment seg, final long requiredBytes) {
581+
if (seg.byteSize() < requiredBytes) {
582+
throw new SketchesArgumentException("Insufficient TDigest data: expected at least " + requiredBytes
583+
+ " bytes, actual: " + seg.byteSize());
584+
}
585+
}
586+
524587
/**
525588
* Human-readable summary of this TDigest as a string
526589
* @return summary of this TDigest
@@ -542,8 +605,8 @@ public String toString(final boolean printCentroids) {
542605
.append(" Compression: ").append(k_).append(LS)
543606
.append(" Centroids: ").append(numCentroids_).append(LS)
544607
.append(" Buffered: ").append(numBuffered_).append(LS)
545-
.append(" Centroids Capacity: ").append(centroidsCapacity_).append(LS)
546-
.append(" Buffer Capacity: ").append(centroidsCapacity_ * BUFFER_MULTIPLIER).append(LS)
608+
.append(" Centroids Capacity: ").append(centroidMeans_.length).append(LS)
609+
.append(" Buffer Capacity: ").append(bufferValues_.length).append(LS)
547610
.append("Centroids Weight: ").append(centroidsWeight_).append(LS)
548611
.append(" Total Weight: ").append(getTotalWeight()).append(LS)
549612
.append(" Reverse Merge: ").append(reverseMerge_).append(LS);
@@ -574,12 +637,15 @@ private TDigestDouble(final boolean reverseMerge, final short k, final double mi
574637
k_ = k;
575638
minValue_ = min;
576639
maxValue_ = max;
577-
if (k < 10) { throw new SketchesArgumentException("k must be at least 10"); }
640+
checkK(k);
578641
final int fudge = k < 30 ? 30 : 10;
579-
centroidsCapacity_ = (k_ * 2) + fudge;
580-
centroidMeans_ = new double[centroidsCapacity_];
581-
centroidWeights_ = new long[centroidsCapacity_];
582-
bufferValues_ = new double[centroidsCapacity_ * BUFFER_MULTIPLIER];
642+
centroidsCapacity_ = (k * 2) + fudge;
643+
// Compression thresholds are local sizing choices, not serialization limits.
644+
final int centroidSlots = Math.max(centroidsCapacity_, means == null ? 0 : means.length);
645+
final int bufferSlots = Math.max(centroidsCapacity_ * BUFFER_MULTIPLIER, buffer == null ? 0 : buffer.length);
646+
centroidMeans_ = new double[centroidSlots];
647+
centroidWeights_ = new long[centroidSlots];
648+
bufferValues_ = new double[bufferSlots];
583649
numCentroids_ = 0;
584650
numBuffered_ = 0;
585651
centroidsWeight_ = weight;
@@ -594,6 +660,10 @@ private TDigestDouble(final boolean reverseMerge, final short k, final double mi
594660
}
595661
}
596662

663+
private static void checkK(final short k) {
664+
if (k < 10) { throw new SketchesArgumentException("k must be at least 10"); }
665+
}
666+
597667
// assumes that there is enough room in the input arrays to add centroids from this TDigest
598668
private void merge(final double[] values, final long[] weights, final long weight, int num) {
599669
System.arraycopy(centroidMeans_, 0, values, num, numCentroids_);
@@ -670,25 +740,26 @@ static double z(final double compression, final double n) {
670740
}
671741

672742
/*
673-
* The weights are normalized before multiplying so that each term is bounded by the magnitude
674-
* of its input, otherwise the products can overflow to infinity for values of large magnitude.
743+
* Opposite-sign inputs need normalized weights to avoid overflowing their difference.
744+
* Same-sign inputs need interpolation to avoid overflowing the sum of rounded products.
675745
*/
676746
private static double weightedAverage(final double x1, final double w1, final double x2, final double w2) {
677-
final double weight = w1 + w2;
678-
return (x1 * (w1 / weight)) + (x2 * (w2 / weight));
747+
final double ratio = w2 / (w1 + w2);
748+
if (Math.copySign(1, x1) != Math.copySign(1, x2)) {
749+
return (x1 * (1 - ratio)) + (x2 * ratio);
750+
}
751+
return Math.fma(x2 - x1, ratio, x1);
679752
}
680753

681754
/*
682755
* Computes the mean of a centroid after merging in the given value with weight w,
683756
* where weight is the total weight of the centroid including w.
684-
* The intermediate (value - mean) or its product with w can overflow to infinity
685-
* even when both inputs are finite (e.g. means near opposite ends of the double range),
686-
* which eventually turns the stored mean into NaN. In that case fall back to
687-
* the overflow-safe weighted average, which stays finite.
757+
* Normalize w before multiplying to avoid overflowing the product. If the difference
758+
* itself overflows, the inputs have opposite signs and need the scaled weighted average.
688759
*/
689760
private static double mergedMean(final double mean, final double value, final long w, final long weight) {
690-
final double newMean = mean + (((value - mean) * w) / weight);
691-
if (Double.isFinite(newMean)) { return newMean; }
761+
final double delta = value - mean;
762+
if (Double.isFinite(delta)) { return Math.fma(delta, (double) w / weight, mean); }
692763
return weightedAverage(mean, weight - w, value, w);
693764
}
694765
}

0 commit comments

Comments
 (0)