xiangfu0 commented on code in PR #19795:
URL: https://github.com/apache/pinot/pull/19795#discussion_r4224697562


##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TDigestUtils.java:
##########
@@ -145,42 +127,48 @@ private static ByteBuffer prepareScratchBuffer(ByteBuffer 
scratchBuffer, int req
   ///
   /// The input must use the standard verbose `MergingDigest` encoding. The 
returned payload stays verbose when it
   /// already fits the 3.2 default capacity, uses the compact encoding only 
when every narrowed field is exact, and
-  /// otherwise reduces interior centroids without narrowing doubles.
+  /// otherwise reduces interior centroids without narrowing doubles. 
Compression below ten is normalized in the
+  /// output header because the 3.2 verbose reader sizes its arrays from the 
raw header before applying that minimum.
+  /// Opaque degraded state is retained without reducing its unknown 
distribution, including over-capacity payloads.
   public static byte[] makeLegacyCompatible(byte[] verboseBytes) {
-    ByteBuffer verbose = ByteBuffer.wrap(verboseBytes);
-    if (verbose.getInt() != VERBOSE_ENCODING) {
+    return makeLegacyCompatible(verboseBytes, 
inspectSerialized(ByteBuffer.wrap(verboseBytes), false));
+  }
+
+  /// Serializes an unchanged, independently retained payload using metadata 
validated at ingestion.
+  ///
+  /// The metadata must describe these exact bytes. This avoids another 
centroid validation walk for lazy digests.
+  public static byte[] makeLegacyCompatible(byte[] verboseBytes, 
SerializedTDigestMetadata metadata) {
+    if (metadata.encoding() != VERBOSE_ENCODING) {
       throw new IllegalArgumentException("Expected verbose TDigest encoding");
     }
-    verbose.getDouble();
-    verbose.getDouble();
-    double compression = verbose.getDouble();
-    if (!(compression > 0.0) || !Double.isFinite(compression)) {
-      throw new IllegalArgumentException("Invalid TDigest compression: " + 
compression);
+    if (metadata.needsLegacyFallback()) {

Review Comment:
   **Bug (reproduced): write/read capacity asymmetry makes Pinot emit bytes it 
cannot read back.** `makeLegacyCompatible` passes `needsLegacyFallback` 
payloads through without capacity reduction, but every Pinot read path 
(`readSerializedHeader` rejects `centroidCount > mainCapacity`) refuses them. 
Repro: a 300-centroid verbose payload at compression 100 (capacity 210) 
containing one NaN mean enters an accumulator through the capacity-unchecked 
ingress (`add(TDigest)` / `reset(buffer, false)`); `serialize()` re-emits the 
payload verbatim, and the receiving server's `deserializeIntermediateResult -> 
forSerializedTDigest(ByteBuffer)` throws `IllegalArgumentException: "TDigest 
centroid count exceeds capacity: 300"`, hard-failing the distributed query even 
though the sender succeeded — contradicting the class doc that "these 
historical exceptions remain readable by Pinot". The same IAE fires in 
`addAccumulator` when a materialized degraded accumulator is merged 
(`addSerializedTDigest(o
 ther.serialize())` re-reads with `checkCapacity=true`). Degraded passthrough 
reads likely need `checkCapacity=false` (or the writer must reduce/annotate 
capacity).



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TDigestUtils.java:
##########
@@ -207,331 +195,226 @@ public static TDigest deserialize(byte[] bytes) {
     return deserialize(ByteBuffer.wrap(bytes));
   }
 
-  /// Validates the structure and declared capacities of a serialized merging 
digest.
-  public static void validateSerialized(byte[] bytes) {
-    validateEncodedDigest(ByteBuffer.wrap(bytes));
+  /// Validates serialized lengths and centroid values without allocating 
centroid arrays.
+  public static double validateSerialized(byte[] bytes) {
+    return validateSerialized(ByteBuffer.wrap(bytes));
   }
 
-  /// Deserializes a finite-valued digest into a merging digest with the 
temporary-buffer footprint used by 3.2.
+  /// Validates one legacy digest without changing `input` and returns its 
total centroid weight.
   ///
-  /// Unlike the standard verbose decoder, this method does not retain the 
substantially larger default 3.3
-  /// temporary arrays. It also repairs legacy weighted boundary centroids 
before the first 3.3 merge.
-  public static TDigest deserializeFiniteWithLegacyBuffer(byte[] bytes) {
-    ByteBuffer input = ByteBuffer.wrap(bytes);
-    validateEncodedDigest(input.duplicate());
-
-    int encoding = input.getInt();
-    double min = input.getDouble();
-    double max = input.getDouble();
-    double compression;
-    int centroidCount;
-    boolean verbose;
-    if (encoding == VERBOSE_ENCODING) {
-      compression = input.getDouble();
-      centroidCount = input.getInt();
-      verbose = true;
-    } else if (encoding == SMALL_ENCODING) {
-      compression = input.getFloat();
-      input.getShort();
-      input.getShort();
-      centroidCount = input.getShort();
-      verbose = false;
-    } else {
-      throw new IllegalStateException("Invalid format for serialized 
histogram");
-    }
-
-    if (centroidCount == 0) {
-      return createMergingDigestWithLegacyBuffer(compression);
-    }
-    double[] means = new double[centroidCount + 2];
-    double[] weights = new double[centroidCount + 2];
-    for (int i = 0; i < centroidCount; i++) {
-      double weight = verbose ? input.getDouble() : input.getFloat();
-      double mean = verbose ? input.getDouble() : input.getFloat();
-      if (!(weight > 0.0) || !Double.isFinite(weight)) {
-        throw new IllegalArgumentException("Invalid TDigest centroid weight: " 
+ weight);
-      }
-      if (!Double.isFinite(mean)) {
-        throw new IllegalArgumentException("Expected a finite TDigest centroid 
mean: " + mean);
-      }
-      weights[i] = weight;
-      means[i] = mean;
-    }
-
-    int normalizedCount = weights[0] != 1.0 || weights[centroidCount - 1] != 
1.0
-        ? normalizeBoundaries(means, weights, centroidCount, min, max) : 
centroidCount;
-    long addCount = 0L;
-    for (int i = 0; i < normalizedCount; i++) {
-      double weight = weights[i];
-      if (weight != Math.rint(weight) || weight >= 0x1p63) {
-        return copyIntoLegacyBuffer(
-            deserialize(toVerboseBytes(min, max, compression, means, weights, 
normalizedCount)), normalizedCount);
-      }
-      long integralWeight = (long) weight;
-      addCount = Math.addExact(addCount, 1L + (integralWeight - 1L) / 
Integer.MAX_VALUE);
-    }
-
-    int minimumMainCapacity = Math.addExact(Math.toIntExact(addCount), 2);
-    TDigest digest = createMergingDigestWithLegacyBuffer(compression, 
minimumMainCapacity);
-    for (int i = 0; i < normalizedCount; i++) {
-      long remainingWeight = (long) weights[i];
-      while (remainingWeight > 0L) {
-        int weight = (int) Math.min(remainingWeight, Integer.MAX_VALUE);
-        digest.add(means[i], weight);
-        remainingWeight -= weight;
-      }
-    }
-    return digest;
-  }
-
-  /// Deserializes a digest and advances `input` past its encoded bytes.
-  public static TDigest deserialize(ByteBuffer input) {
-    ByteBuffer encoded = input.slice();
-    validateEncodedDigest(encoded.duplicate());
-
-    int encoding = encoded.getInt();
-    double min = encoded.getDouble();
-    double max = encoded.getDouble();
-    double compression;
-    int mainCapacity;
-    int bufferCapacity;
-    int centroidCount;
-    int centroidSize;
-    if (encoding == VERBOSE_ENCODING) {
-      compression = encoded.getDouble();
-      centroidCount = encoded.getInt();
-      mainCapacity = getDefaultCentroidCapacity(compression);
-      bufferCapacity = 5 * mainCapacity;
-      centroidSize = VERBOSE_CENTROID_SIZE;
-    } else if (encoding == SMALL_ENCODING) {
-      compression = encoded.getFloat();
-      mainCapacity = encoded.getShort();
-      bufferCapacity = encoded.getShort();
-      centroidCount = encoded.getShort();
-      centroidSize = SMALL_CENTROID_SIZE;
-    } else {
-      // MergingDigest.fromBytes() has already produced the canonical 
exception for this case.
-      throw new IllegalStateException("Invalid format for serialized 
histogram");
-    }
-    int centroidOffset = encoded.position();
-    if (centroidCount == 0) {
-      return configureScaleFunction(MergingDigest.fromBytes(input));
-    }
-
-    double firstWeight = readWeight(encoded, centroidOffset, centroidSize);
-    double lastWeight = readWeight(encoded, centroidOffset + (centroidCount - 
1) * centroidSize, centroidSize);
-    int defaultMainCapacity = getDefaultCentroidCapacity(compression);
-    boolean needsBoundaryRepair = firstWeight != 1.0 || lastWeight != 1.0;
-    boolean exceedsDefaultVerboseCapacity = encoding == VERBOSE_ENCODING && 
centroidCount > defaultMainCapacity;
-    if (!needsBoundaryRepair && !exceedsDefaultVerboseCapacity) {
-      return configureScaleFunction(MergingDigest.fromBytes(input));
-    }
-
-    double[] means = new double[centroidCount + 2];
-    double[] weights = new double[centroidCount + 2];
-    encoded.position(centroidOffset);
-    for (int i = 0; i < centroidCount; i++) {
-      if (centroidSize == VERBOSE_CENTROID_SIZE) {
-        weights[i] = encoded.getDouble();
-        means[i] = encoded.getDouble();
-      } else {
-        weights[i] = encoded.getFloat();
-        means[i] = encoded.getFloat();
-      }
-    }
-    int encodedLength = encoded.position();
-    int normalizedCount = needsBoundaryRepair
-        ? normalizeBoundaries(means, weights, centroidCount, min, max) : 
centroidCount;
-    byte[] normalizedBytes = toVerboseBytes(min, max, compression, means, 
weights, normalizedCount);
-    MergingDigest result;
-    if (normalizedCount <= defaultMainCapacity) {
-      result = MergingDigest.fromBytes(ByteBuffer.wrap(normalizedBytes));
-    } else {
-      int resolvedMainCapacity = mainCapacity == -1 ? defaultMainCapacity : 
mainCapacity;
-      int resolvedBufferCapacity = bufferCapacity == -1
-          ? DEFAULT_MERGE_BUFFER_MULTIPLIER * resolvedMainCapacity : 
bufferCapacity;
-      int smallMainCapacity = Math.max(resolvedMainCapacity, normalizedCount);
-      long smallBufferCapacity = Math.max(Math.max((long) 
resolvedBufferCapacity, smallMainCapacity + 1L),
-          2L * smallMainCapacity + 1L);
-      if ((double) (float) compression == compression && normalizedCount <= 
Short.MAX_VALUE
-          && smallMainCapacity <= Short.MAX_VALUE && smallBufferCapacity <= 
Short.MAX_VALUE
-          && areFloatExact(means, weights, normalizedCount)) {
-        ByteBuffer normalized = ByteBuffer.allocate(SMALL_HEADER_SIZE + 
SMALL_CENTROID_SIZE * normalizedCount);
-        normalized.putInt(SMALL_ENCODING);
-        normalized.putDouble(min);
-        normalized.putDouble(max);
-        normalized.putFloat((float) compression);
-        normalized.putShort((short) smallMainCapacity);
-        normalized.putShort((short) smallBufferCapacity);
-        normalized.putShort((short) normalizedCount);
-        for (int i = 0; i < normalizedCount; i++) {
-          normalized.putFloat((float) weights[i]);
-          normalized.putFloat((float) means[i]);
-        }
-        normalized.flip();
-        result = MergingDigest.fromBytes(normalized);
-      } else {
-        result = MergingDigest.fromBytes(
-            ByteBuffer.wrap(reduceVerboseCentroids(normalizedBytes, 
defaultMainCapacity)));
-      }
-    }
-    input.position(Math.addExact(input.position(), encodedLength));
-    return configureScaleFunction(result);
+  /// Trailing bytes remain available to the caller for intermediate values 
containing several objects.
+  public static double validateSerialized(ByteBuffer input) {
+    return validateSerialized(input, true);
   }
 
-  private static TDigest configureScaleFunction(MergingDigest digest) {
-    digest.setScaleFunction(ScaleFunction.K_1);
-    return digest;
+  private static double validateSerialized(ByteBuffer input, boolean 
checkCapacity) {
+    return inspectSerialized(input, checkCapacity).totalWeight();
   }
 
-  private static TDigest copyIntoLegacyBuffer(TDigest source, int 
centroidCount) {
-    TDigest digest = createMergingDigestWithLegacyBuffer(source.compression(), 
Math.addExact(centroidCount, 2));
-    digest.add(java.util.List.of(source));
-    return digest;
+  /// Header and numerical metadata for one legacy byte payload. The centroid 
bytes remain owned by the caller.
+  /// Header-only inspection leaves total weight as NaN until centroid 
inspection validates the numerical fields.
+  ///
+  /// Legacy writers can emit poisoned numerical state after overflow or 
broken fractional boundary repair.
+  /// Such state is retained with degraded quantiles, rather than being passed 
to the sorted K1 kernel.
+  public record SerializedTDigestMetadata(int encoding, double min, double 
max, double compression,
+      int centroidCount, int mainCapacity, int bufferCapacity, int 
centroidOffset, int centroidSize,
+      int encodedLength, double totalWeight, boolean hasNonFiniteMeans, 
boolean needsLegacyFallback,
+      boolean unorderedMeans, boolean fractionalWeights, boolean 
weightedBoundaries, boolean hasZeroWeightCentroids,
+      double encodedCompression) {
   }
 
-  private static boolean areFloatExact(double[] means, double[] weights, int 
count) {
-    for (int i = 0; i < count; i++) {
-      if ((double) (float) weights[i] != weights[i] || (double) (float) 
means[i] != means[i]) {
-        return false;
-      }
-    }
-    return true;
+  /// Inspects one payload without mutating `input`, scanning the centroid 
values once.
+  public static SerializedTDigestMetadata inspectSerialized(ByteBuffer input) {
+    return inspectSerialized(input, true);
   }
 
-  private static byte[] toVerboseBytes(double min, double max, double 
compression, double[] means, double[] weights,
-      int count) {
-    ByteBuffer normalized = ByteBuffer.allocate(VERBOSE_HEADER_SIZE + 
VERBOSE_CENTROID_SIZE * count);
-    normalized.putInt(VERBOSE_ENCODING);
-    normalized.putDouble(min);
-    normalized.putDouble(max);
-    normalized.putDouble(compression);
-    normalized.putInt(count);
-    for (int i = 0; i < count; i++) {
-      normalized.putDouble(weights[i]);
-      normalized.putDouble(means[i]);
-    }
-    return normalized.array();
+  /// Inspects a payload, optionally enforcing the legacy decoder's declared 
centroid capacity.
+  public static SerializedTDigestMetadata inspectSerialized(ByteBuffer input, 
boolean checkCapacity) {
+    return inspectSerialized(input, readSerializedHeader(input, 
checkCapacity), null, null);
   }
 
-  private static double readWeight(ByteBuffer encoded, int offset, int 
centroidSize) {
-    return centroidSize == VERBOSE_CENTROID_SIZE ? encoded.getDouble(offset) : 
encoded.getFloat(offset);
+  /// Reads and validates the fixed-size header without scanning or allocating 
centroid arrays.
+  public static SerializedTDigestMetadata readSerializedHeader(ByteBuffer 
input) {
+    return readSerializedHeader(input, true);
   }
 
-  private static void validateEncodedDigest(ByteBuffer input) {
-    int encoding = input.getInt();
+  /// Reads a fixed-size header, optionally enforcing the declared centroid 
capacity.
+  ///
+  /// NaN extrema mark legacy degraded state; centroid flags and total weight 
are computed by the subsequent pass.
+  public static SerializedTDigestMetadata readSerializedHeader(ByteBuffer 
input, boolean checkCapacity) {
+    ByteBuffer encoded = input.slice().order(ByteOrder.BIG_ENDIAN);
+    int encoding = encoded.getInt();
     if (encoding != VERBOSE_ENCODING && encoding != SMALL_ENCODING) {
       throw new IllegalStateException("Invalid format for serialized 
histogram");
     }
-    input.getDouble();
-    input.getDouble();
+    double min = encoded.getDouble();
+    double max = encoded.getDouble();
     double compression;
+    double encodedCompression;
     int mainCapacity;
+    int bufferCapacity = 0;
     int centroidCount;
     int centroidSize;
     if (encoding == VERBOSE_ENCODING) {
-      compression = input.getDouble();
-      mainCapacity = getDefaultCentroidCapacity(compression);
-      centroidCount = input.getInt();
+      encodedCompression = encoded.getDouble();
+      compression = normalizeCompression(encodedCompression);
+      mainCapacity = Math.max(getDefaultCentroidCapacity(compression), 
getLegacyDefaultCentroidCapacity(compression));
+      centroidCount = encoded.getInt();
       centroidSize = VERBOSE_CENTROID_SIZE;
     } else {
-      compression = input.getFloat();
-      mainCapacity = input.getShort();
-      int bufferCapacity = input.getShort();
+      encodedCompression = encoded.getFloat();
+      compression = normalizeCompression(encodedCompression);
+      mainCapacity = encoded.getShort();
+      bufferCapacity = encoded.getShort();
       checkSmallArrayCapacity(mainCapacity);
       checkSmallArrayCapacity(bufferCapacity);
       if (mainCapacity == -1) {
         mainCapacity = getDefaultCentroidCapacity(compression);
       }
-      centroidCount = input.getShort();
+      centroidCount = encoded.getShort();
       centroidSize = SMALL_CENTROID_SIZE;
     }
     if (centroidCount < 0) {
       throw new IllegalArgumentException("Invalid negative TDigest centroid 
count: " + centroidCount);
     }
-    if (centroidCount > input.remaining() / centroidSize) {
+    if (centroidCount > encoded.remaining() / centroidSize) {
       throw new BufferUnderflowException();
     }
-    int readableMainCapacity = mainCapacity;
-    if (encoding == VERBOSE_ENCODING && compression > 0.0 && 
Double.isFinite(compression)) {
-      readableMainCapacity = Math.max(mainCapacity, 
getLegacyDefaultCentroidCapacity(compression));
+    if (checkCapacity && centroidCount > mainCapacity) {
+      throw new IllegalArgumentException("TDigest centroid count exceeds 
capacity: " + centroidCount);
     }
-    if (centroidCount > readableMainCapacity) {
-      throw new ArrayIndexOutOfBoundsException(
-          "Index " + readableMainCapacity + " out of bounds for length " + 
readableMainCapacity);
-    }
-  }
-
-  private static void checkSmallArrayCapacity(int capacity) {
-    if (capacity < -1) {
-      throw new NegativeArraySizeException(Integer.toString(capacity));
+    if (centroidCount > 0 && min > max) {
+      throw new IllegalArgumentException("Invalid TDigest extrema: " + min + 
", " + max);
     }
+    int centroidOffset = encoded.position();
+    int encodedLength = centroidOffset + centroidCount * centroidSize;
+    return new SerializedTDigestMetadata(encoding, min, max, compression, 
centroidCount, mainCapacity,
+        bufferCapacity, centroidOffset, centroidSize, encodedLength, 
Double.NaN, false,
+        Double.isNaN(min) || Double.isNaN(max), false, false, false, false, 
encodedCompression);
   }
 
-  private static int normalizeBoundaries(double[] means, double[] weights, int 
count, double min, double max) {
-    if (count == 1) {
-      double weight = weights[0];
-      if (weight <= 1.0) {
-        return count;
+  /// Validates centroids in one pass and optionally decodes their means and 
weights into reusable arrays.
+  ///
+  /// The header must describe this exact input, and each supplied array must 
hold all encoded centroids. Endpoint
+  /// rounding inherited from compact re-encoding or old merge arithmetic is 
restored to the double header bounds.
+  public static SerializedTDigestMetadata inspectSerialized(ByteBuffer input, 
SerializedTDigestMetadata header,
+      double[] means, double[] weights) {
+    ByteBuffer encoded = input.slice().order(ByteOrder.BIG_ENDIAN);
+    encoded.position(header.centroidOffset());
+    int centroidCount = header.centroidCount();
+    int centroidSize = header.centroidSize();
+    double min = header.min();
+    double max = header.max();
+    double totalWeight = 0.0;
+    double previousMean = Double.NEGATIVE_INFINITY;
+    boolean hasNonFiniteMeans = false;
+    boolean needsLegacyFallback = header.needsLegacyFallback();
+    boolean unorderedMeans = false;
+    boolean fractionalWeights = false;
+    boolean hasZeroWeightCentroids = false;
+    int positiveCentroidCount = 0;
+    double firstPositiveWeight = 0.0;
+    double lastPositiveWeight = 0.0;
+    boolean withinBounds = true;
+    double roundedMin = (double) (float) min;
+    double roundedMax = (double) (float) max;
+    double lowerAdjacent = Math.nextDown(min);
+    double upperAdjacent = Math.nextUp(max);
+    for (int i = 0; i < centroidCount; i++) {
+      double weight = centroidSize == VERBOSE_CENTROID_SIZE ? 
encoded.getDouble() : encoded.getFloat();
+      double mean = centroidSize == VERBOSE_CENTROID_SIZE ? 
encoded.getDouble() : encoded.getFloat();
+      if (!Double.isFinite(weight)) {
+        throw new IllegalArgumentException("Invalid TDigest centroid weight: " 
+ weight);
+      }
+      // Legacy compact writers can narrow a finite double endpoint outside 
the float range to infinity.
+      // Recover that identifiable rounding before classifying non-finite 
state; verbose infinity remains opaque.
+      if (header.encoding() == SMALL_ENCODING) {
+        if (mean == Double.NEGATIVE_INFINITY && Double.isFinite(min) && 
roundedMin == mean) {
+          mean = min;
+        } else if (mean == Double.POSITIVE_INFINITY && Double.isFinite(max) && 
roundedMax == mean) {
+          mean = max;
+        }
+      }
+      if (means != null) {
+        means[i] = Math.max(min, Math.min(mean, max));
       }
-      double mean = means[0];
-      means[0] = min;
-      weights[0] = 1.0;
-      if (weight == 2.0) {
-        means[1] = max;
-        weights[1] = 1.0;
-        return 2;
+      if (weights != null) {
+        weights[i] = weight;
+      }
+      // A zero-mass centroid has no distribution to merge, even when its 
unused mean is NaN or stale.
+      if (weight == 0.0) {
+        hasZeroWeightCentroids = true;
+        continue;
+      }
+      if (weight > 0.0) {
+        if (positiveCentroidCount++ == 0) {
+          firstPositiveWeight = weight;
+        }
+        lastPositiveWeight = weight;
+      }
+      fractionalWeights |= weight != Math.rint(weight);
+      // A previous Pinot boundary repair emitted a negative residual for 
singleton weights between one and two.
+      // Old tdunning merges could also overflow finite means to NaN or 
infinity. Preserve those stored bytes,
+      // but never normalize or sort them into a plausible distribution.
+      needsLegacyFallback |= weight < 0.0 || Double.isNaN(mean)
+          || !Double.isFinite(mean) && (mean < min || mean > max);
+      hasNonFiniteMeans |= !Double.isFinite(mean);
+      unorderedMeans |= mean < previousMean;
+      if (!Double.isNaN(mean)) {
+        // A compact digest can be re-emitted as verbose without recovering 
its double endpoint means. Old
+        // merge arithmetic can also drift by one ULP. Permit only these 
identifiable endpoint roundings.
+        boolean lowerRounded = mean == roundedMin || mean == lowerAdjacent;
+        boolean upperRounded = mean == roundedMax || mean == upperAdjacent;
+        withinBounds &= (mean >= min || lowerRounded) && (mean <= max || 
upperRounded);
+        previousMean = mean;
+      }
+      totalWeight += weight;
+      if (!Double.isFinite(totalWeight)) {
+        throw new IllegalArgumentException("TDigest total weight exceeds the 
supported range");
       }
-      means[1] = residualMean(mean, weight, min, max, weight - 2.0, min, max);
-      weights[1] = weight - 2.0;
-      means[2] = max;
-      weights[2] = 1.0;
-      return 3;
     }
+    // Identifiable endpoint rounding can be restored safely. Other historical 
out-of-extrema values have an
+    // unknown distribution: retain their original bytes and NaN statistics 
instead of inventing clamped quantiles.
+    needsLegacyFallback |= !withinBounds;
+    boolean weightedBoundaries = positiveCentroidCount == 1 ? 
firstPositiveWeight >= 2.0
+        : positiveCentroidCount > 1 && (firstPositiveWeight > 1.0 || 
lastPositiveWeight > 1.0);
+    return new SerializedTDigestMetadata(header.encoding(), min, max, 
header.compression(), centroidCount,
+        header.mainCapacity(), header.bufferCapacity(), 
header.centroidOffset(), centroidSize,
+        header.encodedLength(), totalWeight, hasNonFiniteMeans, 
needsLegacyFallback, unorderedMeans, fractionalWeights,
+        weightedBoundaries, hasZeroWeightCentroids, 
header.encodedCompression());
+  }
 
-    if (weights[0] > 1.0) {
-      System.arraycopy(means, 1, means, 2, count - 1);
-      System.arraycopy(weights, 1, weights, 2, count - 1);
-      double weight = weights[0];
-      double mean = means[0];
-      means[0] = min;
-      weights[0] = 1.0;
-      means[1] = residualMean(mean, weight, min, 0.0, weight - 1.0, min, 
means[2]);
-      weights[1] = weight - 1.0;
-      count++;
-    }
-    int lastIndex = count - 1;
-    if (weights[lastIndex] > 1.0) {
-      double weight = weights[lastIndex];
-      double mean = means[lastIndex];
-      means[lastIndex] = residualMean(mean, weight, max, 0.0, weight - 1.0, 
means[lastIndex - 1], max);
-      weights[lastIndex] = weight - 1.0;
-      means[count] = max;
-      weights[count] = 1.0;
-      count++;
+  /// Deserializes a finite-valued digest without recompressing stored 
centroids on each rollup generation.
+  public static TDigest deserializeFinite(byte[] bytes) {
+    SerializedTDigestInput input = new SerializedTDigestInput();
+    input.reset(bytes);
+    return deserializeFinite(input);
+  }
+
+  /// Deserializes an already validated input without scanning the same 
centroid bytes again.
+  public static TDigest deserializeFinite(SerializedTDigestInput input) {
+    SerializedTDigestMetadata metadata = input.getMetadata();
+    if (metadata.hasNonFiniteMeans() && !metadata.needsLegacyFallback()) {
+      throw new IllegalArgumentException("Expected finite TDigest centroid 
means");
     }
-    return count;
+    PercentileTDigestAccumulator digest = 
PercentileTDigestAccumulator.forLegacyAggregation(input.getCompression());
+    digest.addSerializedTDigest(input);
+    return digest;
   }
 
-  private static double clamp(double value, double min, double max) {
-    return Math.max(min, Math.min(value, max));
+  /// Deserializes a digest and advances `input` past its encoded bytes.
+  public static TDigest deserialize(ByteBuffer input) {
+    SerializedTDigestInput encoded = new SerializedTDigestInput();
+    encoded.reset(input);
+    PercentileTDigestAccumulator digest = 
PercentileTDigestAccumulator.forLegacyAggregation(encoded.getCompression());
+    digest.addSerializedTDigest(encoded);
+    return digest;
   }
 
-  private static double residualMean(double mean, double weight, double 
firstRemovedValue,
-      double secondRemovedValue, double residualWeight, double min, double 
max) {
-    if (!Double.isFinite(mean) || !Double.isFinite(firstRemovedValue)
-        || !Double.isFinite(secondRemovedValue)) {
-      return clamp(mean, min, max);
-    }
-    boolean removedTwoValues = weight - residualWeight == 2.0;
-    int scaleShift = removedTwoValues ? 2 : 1;
-    double scaledMean = Math.scalb(mean, -scaleShift);
-    double scaledCorrection = scaledMean - Math.scalb(firstRemovedValue, 
-scaleShift);
-    if (removedTwoValues) {
-      scaledCorrection += scaledMean - Math.scalb(secondRemovedValue, 
-scaleShift);
+  private static void checkSmallArrayCapacity(int capacity) {
+    if (capacity < -1) {
+      throw new IllegalArgumentException("Invalid TDigest array capacity: " + 
capacity);
     }
-    double correction = Math.scalb(scaledCorrection / residualWeight, 
scaleShift);
-    return clamp(mean + correction, min, max);
   }
 
   private static byte[] reduceVerboseCentroids(byte[] verboseBytes, int 
maxCentroids) {

Review Comment:
   **Hardening: `reduceVerboseCentroids` can write a NaN mean when merging two 
adjacent zero-weight centroids.** `inspectSerialized` skips `weight == 0.0` 
centroids before the fallback-flag checks, so zero-weight payloads reach this 
reduction path, and `findNearestInteriorCentroids`'s tie-break actively prefers 
the smallest combined weight. `weightedMean(m1, 0, m2, 0, totalWeight=0, 
false)` with distinct means evaluates `0/0` and the min/max clamp does not 
rescue NaN. Pinot's own readers later drop the zero-weight NaN centroid, but a 
legacy 3.2/3.3 reader consuming these bytes during a rolling upgrade throws or 
NaN-poisons. Only third-party payloads can trigger it today (in-tree writers 
filter zero weights), but a one-line zero-weight filter when decoding here 
closes it. Separately, this loop is O(n^2) in the number of reduced centroids.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/PercentileTDigestValueAggregator.java:
##########
@@ -152,128 +147,92 @@ private static NonFiniteAwareTDigest 
asNonFiniteAware(TDigest value) {
   }
 
   private void updateMaxByteSize(TDigest value) {
-    long defaultCapacity = getDefaultCentroidCapacity(value.compression());
-    long maxCentroids = Math.min(value.size(), defaultCapacity);
-    _maxByteSize = Math.max(_maxByteSize, getMaxVerboseByteSize(maxCentroids));
-  }
-
-  private static int getMaxVerboseByteSize(long centroidCount) {
-    long maxCentroids = Math.max(centroidCount, 0L);
-    return Math.toIntExact(Math.addExact(VERBOSE_HEADER_SIZE,
-        Math.multiplyExact(VERBOSE_CENTROID_SIZE, maxCentroids)));
-  }
-
-  private static long getDefaultCentroidCapacity(double compression) {
-    double normalizedCompression = Math.max(MIN_COMPRESSION, compression);
-    int padding = normalizedCompression < 30.0 ? 
LOW_COMPRESSION_CAPACITY_PADDING
-        : DEFAULT_CENTROID_CAPACITY_PADDING;
-    long defaultCapacity = (long) Math.ceil(2.0 * normalizedCompression + 
padding);
-    long legacyCapacity = Math.addExact(Math.multiplyExact(2L, (long) 
Math.ceil(compression)),
-        DEFAULT_CENTROID_CAPACITY_PADDING);
-    return Math.max(defaultCapacity, legacyCapacity);
+    _maxByteSize = Math.max(_maxByteSize, value.maxSerializedByteSize());
   }
 
   /// Keeps non-finite values as exact tail masses while delegating all finite 
values to the standard implementation.
   ///
   /// The wrapper emits the standard `MergingDigest` wire format. It is 
mutable, externally synchronized by the
   /// segment-creation pipeline, and not safe for concurrent mutation.
   private static final class NonFiniteAwareTDigest extends TDigest {
-    private static final int VERBOSE_ENCODING = 1;
-    private static final int SMALL_ENCODING = 2;
-    private static final int VERBOSE_HEADER_SIZE = 32;
-    private static final int VERBOSE_CENTROID_SIZE = 16;
-
     private final TDigest _finiteDigest;
-    private long _negativeInfinityWeight;
-    private long _positiveInfinityWeight;
+    private double _negativeInfinityWeight;
+    private double _positiveInfinityWeight;
     private byte[] _finiteSerializedBytes;
     private List<Centroid> _finiteCentroids;
     private byte[] _serializedBytes;
 
     private NonFiniteAwareTDigest(double compression) {
-      this(TDigestUtils.createMergingDigestWithLegacyBuffer(compression), 0L, 
0L);
+      this(TDigestUtils.createMergingDigest(compression), 0L, 0L);
     }
 
-    private NonFiniteAwareTDigest(TDigest finiteDigest, long 
negativeInfinityWeight, long positiveInfinityWeight) {
+    private NonFiniteAwareTDigest(TDigest finiteDigest, double 
negativeInfinityWeight, double positiveInfinityWeight) {
       _finiteDigest = finiteDigest;
       _negativeInfinityWeight = negativeInfinityWeight;
       _positiveInfinityWeight = positiveInfinityWeight;
     }
 
     private NonFiniteAwareTDigest copy() {
-      invalidateCaches();
-      int minimumMainCapacity = Math.addExact(_finiteDigest.centroidCount(), 
2);
-      TDigest finiteDigest = 
TDigestUtils.createMergingDigestWithLegacyBuffer(compression(), 
minimumMainCapacity);
-      if (_finiteDigest.size() > 0L) {
-        finiteDigest.add(List.of(_finiteDigest));
+      TDigest finiteDigest = TDigestUtils.createMergingDigest(compression());
+      if (!_finiteDigest.isEmpty()) {
+        finiteDigest.add(_finiteDigest);
       }
       return new NonFiniteAwareTDigest(finiteDigest, _negativeInfinityWeight, 
_positiveInfinityWeight);
     }
 
     private static NonFiniteAwareTDigest fromBytes(byte[] bytes) {

Review Comment:
   **Cleanup: `NonFiniteAwareTDigest` hand-rolls the centroid codec a 
fourth/fifth time.** `fromBytes` manually loops the `verbose ? getDouble : 
getFloat` decode with its own compact-mean clamping, and `toVerboseBytes` 
re-implements the verbose writer — duplicating 
`TDigestUtils.inspectSerialized(ByteBuffer, header, means, weights)` (which 
already decodes into caller-supplied arrays with the identical endpoint clamp 
plus validation) and the accumulator's verbose writer. The copies have already 
drifted once (clamping applied in some decode loops, not others). Routing 
through a single codec in `TDigestUtils` — and exposing a bulk sorted 
means/weights entry point on the accumulator instead of the encode-then-reparse 
roundtrip in `add`/`fromBytes` — would remove both the drift risk and an 
allocation-heavy detour on the star-tree path.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/PercentileSmartTDigestAggregationFunction.java:
##########
@@ -340,8 +341,7 @@ public SerializedIntermediateResult 
serializeIntermediateResult(Object o) {
   @Override
   public Object deserializeIntermediateResult(CustomObject customObject) {
     if (customObject.getType() == 
ObjectSerDeUtils.ObjectType.TDigest.getValue()) {
-      // Generic TDigest deserialization returns a plain MergingDigest. Keep 
this function's TDigest intermediates as
-      // accumulators so subsequent merges retain the capacity-preserving 
serialization path.
+      // Retain serialized state lazily so subsequent merges preserve source 
capacity and compression.

Review Comment:
   **Cleanup: the accumulator-strategy choice for deserialized intermediates is 
duplicated per aggregation function.** Both this function and 
`PercentileTDigestAggregationFunction` override `deserializeIntermediateResult` 
to bypass `ObjectSerDeUtils.TDIGEST_SER_DE` and call 
`PercentileTDigestAccumulator.forSerializedTDigest`, because the shared 
ser-de's `deserialize` builds a different (legacy-strategy) accumulator. A 
future TDigest-producing function that forgets the override silently gets 
different accuracy/perf characteristics. Centralizing the decision in the 
ser-de (or a single factory like `forIntermediateResult`) would let the 
per-function overrides disappear.



##########
pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/customobject/TDigest.java:
##########
@@ -0,0 +1,135 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.segment.spi.customobject;
+
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.List;
+
+/// Mutable percentile digest contract for aggregation plugins and legacy 
t-digest byte encodings.
+/// Implementations are not thread-safe; callers must serialize access to each 
aggregation state.
+/// Fractional mass is preserved; t-digest 3.3 can reject endpoint weights 
below one or singleton masses between
+/// one and two during recompression because its endpoint invariant requires 
unit weights.
+public abstract class TDigest {

Review Comment:
   **Architecture: consider whether this belongs in `pinot-segment-spi` with 
this surface.** Nothing in segment-spi uses it, implementations cannot really 
be plugged (ser-de is hardwired to `PercentileTDigestAccumulator` in 
segment-local), and its direct analogue `QuantileDigest` lives in 
segment-local's `customobject`. Placing it in the SPI freezes the replicated 
tdunning warts (`byteSize`/`asBytes`/`smallByteSize`/`asSmallBytes`, saturating 
`size()`, `compress()`-as-flush) as public API whose future cleanup becomes a 
breaking SPI change. All users (pinot-core, segment-local, perf) already depend 
on segment-local; trimming the production contract to what call sites use 
(add/merge/quantile/cdf/totalWeight/isEmpty/compression + one serialize) and 
keeping the legacy encodings inside `TDigestUtils` would be a smaller frozen 
surface.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to