This is an automated email from the ASF dual-hosted git repository.
LindaSummer pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git
The following commit(s) were added to refs/heads/unstable by this push:
new d3965fcc5 feat(tdigest): implement `tdigest.cdf` command (#3551)
d3965fcc5 is described below
commit d3965fcc5066229be4e0b7725623b741a1f18e64
Author: Edward Xu <[email protected]>
AuthorDate: Tue Aug 18 10:43:58 2026 +0800
feat(tdigest): implement `tdigest.cdf` command (#3551)
# Issue
Close #2807
# Proposed Changes
- Implement `tdigest.cdf` command
- Add unit tests and integration tests
---------
Co-authored-by: Anirudh Lakhanpal <[email protected]>
---
src/commands/cmd_tdigest.cc | 44 +++-
src/types/redis_tdigest.cc | 37 +++
src/types/redis_tdigest.h | 7 +
src/types/tdigest.h | 191 ++++++++++++++-
tests/cppunit/types/tdigest_test.cc | 306 ++++++++++++++++++++++++-
tests/gocase/unit/type/tdigest/tdigest_test.go | 164 +++++++++++++
6 files changed, 745 insertions(+), 4 deletions(-)
diff --git a/src/commands/cmd_tdigest.cc b/src/commands/cmd_tdigest.cc
index c0baf8ca1..149239859 100644
--- a/src/commands/cmd_tdigest.cc
+++ b/src/commands/cmd_tdigest.cc
@@ -556,6 +556,47 @@ class CommandTDigestTrimmedMean : public Commander {
double high_cut_quantile_;
};
+class CommandTDigestCDF : public Commander {
+ Status Parse(const std::vector<std::string> &args) override {
+ if (args.size() == 2) return {Status::RedisParseErr,
errWrongNumOfArguments};
+ key_name_ = args[1];
+ inputs_.reserve(args.size() - 2);
+ for (size_t i = 2; i < args.size(); i++) {
+ auto value = ParseFloat(args[i]);
+ if (!value) {
+ return {Status::RedisParseErr, errValueIsNotFloat};
+ }
+ if (std::isnan(*value)) {
+ return {Status::RedisParseErr, errValueIsNotFloat};
+ }
+ inputs_.push_back(*value);
+ }
+ return Status::OK();
+ }
+
+ Status Execute(engine::Context &ctx, Server *srv, Connection *conn,
std::string *output) override {
+ TDigest tdigest(srv->storage, conn->GetNamespace());
+ TDigestCDFResult result;
+ auto s = tdigest.CDF(ctx, key_name_, inputs_, &result);
+ if (!s.ok()) {
+ if (s.IsNotFound()) {
+ return {Status::RedisExecErr, errKeyNotFound};
+ }
+ return {Status::RedisExecErr, s.ToString()};
+ }
+
+ output->append(redis::MultiLen(result.cdf_values.size()));
+ for (auto const value : result.cdf_values) {
+ output->append(conn->Double(value));
+ }
+ return Status::OK();
+ }
+
+ private:
+ std::string key_name_;
+ std::vector<double> inputs_;
+};
+
std::vector<CommandKeyRange> GetMergeKeyRange(const std::vector<std::string>
&args) {
auto numkeys = ParseInt<int>(args[2], 10).ValueOr(0);
return {{1, 1, 1}, {3, 2 + numkeys, 1}};
@@ -573,5 +614,6 @@ REDIS_REGISTER_COMMANDS(TDigest,
MakeCmdAttr<CommandTDigestCreate>("tdigest.crea
MakeCmdAttr<CommandTDigestQuantile>("tdigest.quantile", -3, "read-only", 1, 1,
1),
MakeCmdAttr<CommandTDigestTrimmedMean>("tdigest.trimmed_mean", 4, "read-only",
1, 1, 1),
MakeCmdAttr<CommandTDigestReset>("tdigest.reset", 2,
"write", 1, 1, 1),
- MakeCmdAttr<CommandTDigestMerge>("tdigest.merge", -4,
"write", GetMergeKeyRange));
+ MakeCmdAttr<CommandTDigestMerge>("tdigest.merge", -4,
"write", GetMergeKeyRange),
+ MakeCmdAttr<CommandTDigestCDF>("tdigest.cdf", -3,
"read-only", 1, 1, 1));
} // namespace redis
diff --git a/src/types/redis_tdigest.cc b/src/types/redis_tdigest.cc
index 4d0325f3d..38c836dad 100644
--- a/src/types/redis_tdigest.cc
+++ b/src/types/redis_tdigest.cc
@@ -28,12 +28,14 @@
#include <rocksdb/status.h>
#include <algorithm>
+#include <cstdint>
#include <iterator>
#include <limits>
#include <memory>
#include <range/v3/algorithm/minmax.hpp>
#include <range/v3/range/conversion.hpp>
#include <range/v3/view/join.hpp>
+#include <range/v3/view/map.hpp>
#include <range/v3/view/transform.hpp>
#include <vector>
@@ -570,6 +572,41 @@ rocksdb::Status TDigest::Merge(engine::Context& ctx, const
Slice& dest_digest,
return storage_->Write(ctx, storage_->DefaultWriteOptions(),
batch->GetWriteBatch());
}
+rocksdb::Status TDigest::CDF(engine::Context& ctx, const Slice& digest_name,
const std::vector<double>& inputs,
+ TDigestCDFResult* result) {
+ auto ns_key = AppendNamespacePrefix(digest_name);
+ TDigestMetadata metadata;
+ {
+ LockGuard guard(storage_->GetLockManager(), ns_key);
+
+ if (auto status = getMetaDataByNsKey(ctx, ns_key, &metadata);
!status.ok()) {
+ return status;
+ }
+
+ if (metadata.total_observations == 0) {
+ result->cdf_values = std::vector<double>(inputs.size(),
std::numeric_limits<double>::quiet_NaN());
+ return rocksdb::Status::OK();
+ }
+
+ if (auto status = mergeNodes(ctx, ns_key, &metadata); !status.ok()) {
+ return status;
+ }
+ }
+
+ std::vector<Centroid> centroids;
+ if (auto status = dumpCentroids(ctx, ns_key, metadata, ¢roids);
!status.ok()) {
+ return status;
+ }
+
+ auto dump_centroids = DummyCentroids<false>(metadata, centroids);
+ if (auto status = TDigestCDF(centroids, dump_centroids.Min(),
dump_centroids.Max(), dump_centroids.TotalWeight(),
+ inputs, &result->cdf_values);
+ !status.IsOK()) {
+ return rocksdb::Status::InvalidArgument(status.Msg());
+ }
+ return rocksdb::Status::OK();
+}
+
rocksdb::Status TDigest::GetMetaData(engine::Context& context, const Slice&
digest_name, TDigestMetadata* metadata) {
auto ns_key = AppendNamespacePrefix(digest_name);
return Database::GetMetadata(context, {kRedisTDigest}, ns_key, metadata);
diff --git a/src/types/redis_tdigest.h b/src/types/redis_tdigest.h
index 8cf4562d2..445445c0f 100644
--- a/src/types/redis_tdigest.h
+++ b/src/types/redis_tdigest.h
@@ -49,6 +49,10 @@ struct TDigestMergeOptions {
bool override_flag = false;
};
+struct TDigestCDFResult {
+ std::vector<double> cdf_values;
+};
+
struct TDigestQuantitleResult {
std::optional<std::vector<double>> quantiles;
};
@@ -93,6 +97,9 @@ class TDigest : public SubKeyScanner {
double high_cut_quantile,
TDigestTrimmedMeanResult* result);
rocksdb::Status GetMetaData(engine::Context& context, const Slice&
digest_name, TDigestMetadata* metadata);
+ rocksdb::Status CDF(engine::Context& ctx, const Slice& digest_name, const
std::vector<double>& inputs,
+ TDigestCDFResult* result);
+
private:
enum class SegmentType : uint8_t { kBuffer = 0, kCentroids = 1, kGuardFlag =
0xFF };
diff --git a/src/types/tdigest.h b/src/types/tdigest.h
index 531f693ed..66b2991bb 100644
--- a/src/types/tdigest.h
+++ b/src/types/tdigest.h
@@ -22,10 +22,11 @@
#include <fmt/format.h>
+#include <algorithm>
+#include <cmath>
+#include <iterator>
#include <limits>
#include <map>
-#include <numeric>
-#include <variant>
#include <vector>
#include "common/status.h"
@@ -172,6 +173,192 @@ struct DoubleComparator {
bool operator()(const double& a, const double& b) const { return
DoubleCompare(a, b) == -1; }
};
+// Match RedisBloom t-digest-c CDF behavior: if min/max is outside the
first/last centroid mean, the exact
+// boundary sample is treated as a singleton with weight 1. Its center rank is
0.5 at min and
+// total_weight - 0.5 at max; interpolation toward an inner centroid starts
after the singleton, at rank 1 or
+// total_weight - 1.
+// refer to implementation:
+//
https://github.com/RedisBloom/t-digest-c/blob/50edef336eb27ed5b19e7f9be05494683ca58515/src/tdigest.c#L223
+inline Status TDigestCDF(const std::vector<Centroid>& centroids, double
centroids_min, double centroids_max,
+ double total_weight, const std::vector<double>&
inputs, std::vector<double>* result) {
+ if (centroids.empty() || total_weight <= 0) {
+ return Status{Status::InvalidArgument, "invalid or empty tdigest"};
+ }
+
+ std::map<double, std::vector<size_t>> sorted_unique_input_idx_map;
+ for (size_t i = 0; i < inputs.size(); ++i) {
+ sorted_unique_input_idx_map[inputs[i]].push_back(i);
+ }
+
+ std::vector<double> sorted_unique_inputs;
+ sorted_unique_inputs.reserve(sorted_unique_input_idx_map.size());
+ std::transform(sorted_unique_input_idx_map.cbegin(),
sorted_unique_input_idx_map.cend(),
+ std::back_inserter(sorted_unique_inputs), [](const auto&
pair) { return pair.first; });
+
+ constexpr double kSingletonBoundaryWeight = 1.0;
+ constexpr double kHalfSingletonBoundaryWeight = kSingletonBoundaryWeight / 2;
+
+ std::vector<double> sorted_result_weights;
+ sorted_result_weights.reserve(sorted_unique_inputs.size());
+ if (centroids.size() == 1) {
+ // only one centroid, min should equal max, and all inputs should be
either less than, equal to,
+ // or greater than the centroid mean
+ const double width = centroids_max - centroids_min;
+ for (const auto input : sorted_unique_inputs) {
+ if (input < centroids_min) {
+ sorted_result_weights.push_back(0.0);
+ continue;
+ }
+
+ if (input > centroids_max) {
+ sorted_result_weights.push_back(total_weight);
+ continue;
+ }
+
+ if (input - centroids_min <= width) {
+ // min and max are too close to do any viable interpolation, treat the
centroid as a singleton
+ sorted_result_weights.push_back(total_weight / 2);
+ } else {
+ // interpolate if somehow we have weight > 0 and max != min, which
should not happen in a valid tdigest
+ sorted_result_weights.push_back((input - centroids_min) / width *
total_weight);
+ }
+ }
+ } else {
+ auto first_valid_input_it = std::find_if(sorted_unique_inputs.cbegin(),
sorted_unique_inputs.cend(),
+ [centroids_min](double input) {
return input >= centroids_min; });
+ auto last_valid_input_it = std::find_if(sorted_unique_inputs.crbegin(),
sorted_unique_inputs.crend(),
+ [centroids_max](double input) {
return input <= centroids_max; });
+ auto input_idx = (first_valid_input_it == sorted_unique_inputs.cend())
+ ? sorted_unique_inputs.size()
+ : std::distance(sorted_unique_inputs.cbegin(),
first_valid_input_it);
+ auto last_valid_input_idx = (last_valid_input_it ==
sorted_unique_inputs.crend())
+ ? 0
+ :
std::distance(sorted_unique_inputs.cbegin(), last_valid_input_it.base());
+
+ // fill in 0 for inputs less than the min boundary
+ for (auto i = 0; i < input_idx; ++i) {
+ sorted_result_weights.push_back(0.);
+ }
+
+ size_t centroid_idx = 0;
+
+ // greater than the min boundary, but less than the first centroid mean
+ while (centroid_idx == 0 && input_idx < last_valid_input_idx &&
+ sorted_unique_inputs[input_idx] < centroids[centroid_idx].mean) {
+ auto cdf_input = sorted_unique_inputs[input_idx];
+ auto current_centroid = centroids[centroid_idx];
+ const auto width = current_centroid.mean - centroids_min;
+ double interpolated_weight = std::numeric_limits<double>::quiet_NaN();
+ if (width > 0) {
+ if (cdf_input == centroids_min) {
+ interpolated_weight = kHalfSingletonBoundaryWeight;
+ } else {
+ // there must be a singleton at the min boundary, so the
interpolation starts after it, at rank 1
+ interpolated_weight =
+ Lerp(kHalfSingletonBoundaryWeight, current_centroid.weight / 2,
(cdf_input - centroids_min) / width);
+ }
+ } else {
+ // this should be redundant of the check cdf_input < centroids_min,
but for clarity
+ interpolated_weight = 0.;
+ }
+ sorted_result_weights.push_back(interpolated_weight);
+ ++input_idx;
+ }
+
+ double weight_so_far = 0.;
+ while (centroid_idx < centroids.size() - 1 && input_idx <
last_valid_input_idx) {
+ auto cdf_input = sorted_unique_inputs[input_idx];
+ auto current_centroid = centroids[centroid_idx];
+ auto next_centroid = centroids[centroid_idx + 1];
+
+ if (cdf_input == current_centroid.mean) {
+ double dw = 0.;
+ auto same_mean_idx = centroid_idx;
+ while (same_mean_idx < centroids.size() &&
centroids[same_mean_idx].mean == current_centroid.mean) {
+ dw += centroids[same_mean_idx].weight;
+ ++same_mean_idx;
+ }
+ sorted_result_weights.push_back(weight_so_far + dw / 2);
+ ++input_idx;
+ continue;
+ }
+
+ if (current_centroid.mean < cdf_input && cdf_input < next_centroid.mean)
{
+ if (next_centroid.mean - current_centroid.mean > 0) {
+ double left_exclude_weight = 0;
+ double right_exclude_weight = 0;
+ if (current_centroid.weight == kSingletonBoundaryWeight) {
+ if (next_centroid.weight == kSingletonBoundaryWeight) {
+ // both adjacent centroids are singletons, include the left
exact sample and exclude the right one.
+ sorted_result_weights.push_back(weight_so_far +
kSingletonBoundaryWeight);
+ // weight_so_far += current_centroid.weight;
+ ++input_idx;
+ continue;
+ } else {
+ left_exclude_weight = kHalfSingletonBoundaryWeight;
+ }
+ } else if (next_centroid.weight == kSingletonBoundaryWeight) {
+ right_exclude_weight = kHalfSingletonBoundaryWeight;
+ }
+
+ double dw = (current_centroid.weight + next_centroid.weight) / 2;
+ double dw_no_singleton = dw - left_exclude_weight -
right_exclude_weight;
+ double base_weight = weight_so_far + current_centroid.weight / 2 +
left_exclude_weight;
+ auto interpolated_weight =
+ Lerp(base_weight, base_weight + dw_no_singleton,
+ (cdf_input - current_centroid.mean) / (next_centroid.mean -
current_centroid.mean));
+ sorted_result_weights.push_back(interpolated_weight);
+ ++input_idx;
+ }
+ continue;
+ }
+
+ ++centroid_idx;
+ weight_so_far += current_centroid.weight;
+ }
+
+ while (centroid_idx == centroids.size() - 1 && input_idx <
last_valid_input_idx &&
+ sorted_unique_inputs[input_idx] < centroids[centroid_idx].mean) {
+ auto cdf_input = sorted_unique_inputs[input_idx];
+ auto current_centroid = centroids[centroid_idx];
+ const auto width = current_centroid.mean - centroids_min;
+ double interpolated_weight = std::numeric_limits<double>::quiet_NaN();
+ if (width > 0) {
+ if (cdf_input == centroids_min) {
+ interpolated_weight = kHalfSingletonBoundaryWeight;
+ } else {
+ // there must be a singleton at the min boundary, so the
interpolation starts after it, at rank
+ // kHalfSingletonBoundaryWeight
+ interpolated_weight =
+ Lerp(kHalfSingletonBoundaryWeight, current_centroid.weight / 2,
(cdf_input - centroids_min) / width);
+ }
+ } else {
+ // this should be redundant of the check cdf_input < centroids_min,
but for clarity
+ interpolated_weight = 0;
+ }
+ sorted_result_weights.push_back(interpolated_weight);
+ ++input_idx;
+ }
+
+ // fill in 1 for inputs greater than the max boundary
+ while (input_idx < sorted_unique_inputs.size()) {
+ // handle remaining inputs
+ sorted_result_weights.push_back(total_weight);
+ ++input_idx;
+ }
+ }
+
+ result->clear();
+ result->resize(inputs.size(), std::numeric_limits<double>::quiet_NaN());
+ for (size_t i = 0; i < sorted_unique_inputs.size(); ++i) {
+ for (auto idx : sorted_unique_input_idx_map[sorted_unique_inputs[i]]) {
+ (*result)[idx] = std::clamp(sorted_result_weights[i] / total_weight,
0.0, 1.0);
+ }
+ }
+
+ return Status::OK();
+}
+
template <bool Reverse, typename TD>
inline Status TDigestByRank(TD&& td, const std::vector<int>& inputs,
std::vector<double>* result) {
result->clear();
diff --git a/tests/cppunit/types/tdigest_test.cc
b/tests/cppunit/types/tdigest_test.cc
index f82519d2f..410fd3404 100644
--- a/tests/cppunit/types/tdigest_test.cc
+++ b/tests/cppunit/types/tdigest_test.cc
@@ -30,13 +30,14 @@
#include <range/v3/algorithm/shuffle.hpp>
#include <range/v3/range.hpp>
#include <range/v3/view/chunk.hpp>
+#include <range/v3/view/concat.hpp>
#include <range/v3/view/iota.hpp>
#include <range/v3/view/join.hpp>
+#include <range/v3/view/repeat.hpp>
#include <range/v3/view/transform.hpp>
#include <string>
#include <vector>
-#include "logging.h"
#include "storage/redis_metadata.h"
#include "test_base.h"
#include "time_util.h"
@@ -948,3 +949,306 @@ TEST_F(RedisTDigestTest,
MergeWithUserSpecifiedCompression) {
// Verify total observations: dest(1) + src(1) = 2
EXPECT_EQ(metadata.total_observations, 2);
}
+
+TEST_F(RedisTDigestTest, CDFTest) {
+ std::string cdf_tdigest_name = "test_cdf_digest" +
std::to_string(util::GetTimeStampMS());
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, cdf_tdigest_name, {100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> samples = {1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 5};
+ status = tdigest_->Add(*ctx_, cdf_tdigest_name, samples);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {0, 1, 2, 3, 4, 5, 6};
+ redis::TDigestCDFResult result;
+
+ status = tdigest_->CDF(*ctx_, cdf_tdigest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+
+ std::vector<double> expected = {0.00, 0.03, 0.13, 0.29, 0.53, 0.83, 1.00};
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+
+ for (size_t i = 0; i < cdf_vals.size(); i++) {
+ EXPECT_NEAR(result.cdf_values[i], expected[i], 0.015) <<
fmt::format("Mismatch at index {}", i);
+ }
+}
+
+TEST_F(RedisTDigestTest, CDFReturnsNaNOnEmptyTDigest) {
+ std::string test_digest_name = "test_digest_cdf_nan" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> values = {0.0, 1.0, 2.0, 3.0};
+ redis::TDigestCDFResult result;
+
+ status = tdigest_->CDF(*ctx_, test_digest_name, values, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+ ASSERT_EQ(result.cdf_values.size(), values.size());
+ for (const auto cdf : result.cdf_values) {
+ EXPECT_TRUE(std::isnan(cdf));
+ }
+}
+
+TEST_F(RedisTDigestTest, CDFDuplicateValues) {
+ std::string test_digest_name = "test_cdf_duplicates" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ status = tdigest_->Add(*ctx_, test_digest_name, {10, 10, 10, 20, 20});
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {5, 10, 20, 25};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+
+ std::vector<double> expected = {0, 0.3, 0.8, 1};
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+ for (size_t i = 0; i < cdf_vals.size(); i++) {
+ EXPECT_NEAR(result.cdf_values[i], expected[i], 0.001) <<
fmt::format("Mismatch at index {}", i);
+ }
+}
+
+TEST_F(RedisTDigestTest, CDFSignedZeroQueries) {
+ std::string test_digest_name = "test_cdf_signed_zero" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ status = tdigest_->Add(*ctx_, test_digest_name, {-1, 0, 1});
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {-0.0, 0.0};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+ EXPECT_NEAR(result.cdf_values[0], 0.5, 0.001);
+ EXPECT_NEAR(result.cdf_values[1], 0.5, 0.001);
+}
+
+TEST_F(RedisTDigestTest, CDFUniformDistribution) {
+ std::string test_digest_name = "test_cdf_uniform" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {200}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> samples = ranges::views::iota(1, 101) |
+ ranges::views::transform([](int i) { return
(double)i; }) |
+ ranges::to<std::vector<double>>();
+ status = tdigest_->Add(*ctx_, test_digest_name, samples);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {1, 25, 50, 75, 100};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+
+ std::vector<double> expected = {0.01, 0.25, 0.50, 0.75, 1.00};
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+
+ for (size_t i = 0; i < cdf_vals.size(); i++) {
+ EXPECT_NEAR(result.cdf_values[i], expected[i], 0.02) <<
fmt::format("Mismatch at index {}, val={}", i, cdf_vals[i]);
+ }
+}
+
+TEST_F(RedisTDigestTest, CDFInterpolatesCompressedCentroids) {
+ std::string test_digest_name = "test_cdf_compressed" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {.compression = 10},
&exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> samples = ranges::views::iota(0, 100) |
+ ranges::views::transform([](int i) { return
static_cast<double>(i); }) |
+ ranges::to<std::vector<double>>();
+ status = tdigest_->Add(*ctx_, test_digest_name, samples);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {20, 40, 50, 60, 80};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+
+ std::vector<double> expected = {0.205, 0.405, 0.505, 0.605, 0.805};
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+ for (size_t i = 0; i < cdf_vals.size(); i++) {
+ EXPECT_NEAR(result.cdf_values[i], expected[i], 0.001)
+ << fmt::format("Mismatch at index {}, val={}", i, cdf_vals[i]);
+ }
+}
+
+TEST_F(RedisTDigestTest, CDFDoesNotInterpolateBetweenSingletonCentroids) {
+ std::string test_digest_name = "test_cdf_singletons" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {.compression =
100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ status = tdigest_->Add(*ctx_, test_digest_name, {0, 10, 20});
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {11};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+ EXPECT_NEAR(result.cdf_values[0], 2.0 / 3, 0.001);
+}
+
+TEST_F(RedisTDigestTest, CDFMultipleAdds) {
+ std::string test_digest_name = "test_cdf_multiadd" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> samples1 = {1, 2, 3, 4, 5};
+ std::vector<double> samples2 = {6, 7, 8, 9, 10};
+ status = tdigest_->Add(*ctx_, test_digest_name, samples1);
+ ASSERT_TRUE(status.ok());
+ status = tdigest_->Add(*ctx_, test_digest_name, samples2);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {1, 5, 7, 10};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> expected = {0.10, 0.50, 0.70, 1.00};
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+
+ for (size_t i = 0; i < cdf_vals.size(); i++) {
+ EXPECT_NEAR((result.cdf_values)[i], expected[i], 0.06)
+ << fmt::format("Mismatch at index {}, val={}", i, cdf_vals[i]);
+ }
+}
+
+TEST_F(RedisTDigestTest, CDFSkewedDistribution) {
+ std::string test_digest_name = "test_cdf_skewed" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {200}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> samples =
+ ranges::views::concat(
+ ranges::views::repeat(0.0) | ranges::views::take(100),
+ ranges::views::iota(1, 11) | ranges::views::transform([](int i) {
return static_cast<double>(i); })) |
+ ranges::to<std::vector<double>>();
+
+ status = tdigest_->Add(*ctx_, test_digest_name, samples);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {0, 1, 5, 10};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> expected = {0.4545, 0.91, 0.95, 1.00};
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+
+ for (size_t i = 0; i < cdf_vals.size(); i++) {
+ EXPECT_NEAR((result.cdf_values)[i], expected[i], 0.03)
+ << fmt::format("Mismatch at index {}, val={}", i, cdf_vals[i]);
+ }
+}
+
+TEST_F(RedisTDigestTest, CDFRepeatedCentroids) {
+ std::string test_digest_name = "test_cdf_repeated_centroids" +
std::to_string(util::GetTimeStampMS());
+
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {200}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> samples = {-40, -36, -27, -13, -12, 7, 7, 25, 47, 50};
+
+ status = tdigest_->Add(*ctx_, test_digest_name, samples);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {0, 6.9, 7, 7.1, 10};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> expected = {0.5, 0.5, 0.6, 0.7, 0.7};
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+
+ for (size_t i = 0; i < cdf_vals.size(); i++) {
+ EXPECT_NEAR((result.cdf_values)[i], expected[i], 0.03)
+ << fmt::format("Mismatch at index {}, val={}", i, cdf_vals[i]);
+ }
+}
+
+TEST_F(RedisTDigestTest, CDFSingleWeightedCentroid) {
+ std::string test_digest_name = "test_cdf_single_weighted_centroid" +
std::to_string(util::GetTimeStampMS());
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ status = tdigest_->Add(*ctx_, test_digest_name, {5, 5, 5, 5, 5, 5, 5, 5, 5,
5});
+ ASSERT_TRUE(status.ok());
+
+ std::vector<double> cdf_vals = {4, 5, 6};
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, cdf_vals, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+ ASSERT_EQ(result.cdf_values.size(), cdf_vals.size());
+ EXPECT_NEAR(result.cdf_values[0], 0.0, 0.001);
+ EXPECT_NEAR(result.cdf_values[1], 0.5, 0.001);
+ EXPECT_NEAR(result.cdf_values[2], 1.0, 0.001);
+}
+
+TEST_F(RedisTDigestTest, CDFAllInputsBelowMinimum) {
+ std::string test_digest_name = "test_cdf_all_below_minimum" +
std::to_string(util::GetTimeStampMS());
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ status = tdigest_->Add(*ctx_, test_digest_name, {1, 2});
+ ASSERT_TRUE(status.ok());
+
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, {-2, -1, 0}, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+
+ EXPECT_EQ(result.cdf_values, std::vector<double>({0, 0, 0}));
+}
+
+TEST_F(RedisTDigestTest, CDFAllInputsAboveMaximum) {
+ std::string test_digest_name = "test_cdf_all_above_maximum" +
std::to_string(util::GetTimeStampMS());
+ bool exists = false;
+ auto status = tdigest_->Create(*ctx_, test_digest_name, {100}, &exists);
+ ASSERT_FALSE(exists);
+ ASSERT_TRUE(status.ok());
+
+ status = tdigest_->Add(*ctx_, test_digest_name, {1, 2});
+ ASSERT_TRUE(status.ok());
+
+ redis::TDigestCDFResult result;
+ status = tdigest_->CDF(*ctx_, test_digest_name, {3, 4, 5}, &result);
+ ASSERT_TRUE(status.ok()) << status.ToString();
+
+ EXPECT_EQ(result.cdf_values, std::vector<double>({1, 1, 1}));
+}
diff --git a/tests/gocase/unit/type/tdigest/tdigest_test.go
b/tests/gocase/unit/type/tdigest/tdigest_test.go
index e4daa3e18..1cfa6bf0a 100644
--- a/tests/gocase/unit/type/tdigest/tdigest_test.go
+++ b/tests/gocase/unit/type/tdigest/tdigest_test.go
@@ -45,6 +45,7 @@ const (
errMsgLowCutQuantileRange = "low_cut_percentile and
high_cut_percentile should be in [0,1]"
errMsgHighCutQuantileRange = "low_cut_percentile and
high_cut_percentile should be in [0,1]"
errMsgLowCutQuantileLess = "low_cut_percentile should be
lower than high_cut_percentile"
+ errValueIsNotFloat = "value is not a valid float"
)
type tdigestInfo struct {
@@ -1309,4 +1310,167 @@ func tdigestTestsByRankAndByRevRank(t *testing.T,
configs util.KvrocksServerConf
}
}
})
+
+ t.Run("tdigest.cdf with different arguments", func(t *testing.T) {
+ keyPrefix := "tdigest_cdf_"
+ isRESP3 := configs["resp3-enabled"] == "yes"
+
+ require.ErrorContains(t, rdb.Do(ctx, "TDIGEST.CDF").Err(),
errMsgWrongNumberArg)
+ require.ErrorContains(t, rdb.Do(ctx, "TDIGEST.CDF",
keyPrefix+"key1").Err(), errMsgWrongNumberArg)
+
+ // non-existent key
+ require.ErrorContains(t, rdb.Do(ctx, "TDIGEST.CDF",
keyPrefix+"nonexistent", "1.0").Err(), errMsgKeyNotExist)
+
+ // invalid float value
+ require.ErrorContains(t, rdb.Do(ctx, "TDIGEST.CDF",
keyPrefix+"key2", "invalid").Err(), errValueIsNotFloat)
+
+ // create a tdigest and add some data
+ tdigestKey := keyPrefix + "source"
+ require.NoError(t, rdb.Do(ctx, "TDIGEST.CREATE",
tdigestKey).Err())
+ require.NoError(t, rdb.Do(ctx, "TDIGEST.ADD", tdigestKey,
"1.0", "2.0", "3.0", "4.0", "5.0").Err())
+
+ // single-value CDF query
+ rsp := rdb.Do(ctx, "TDIGEST.CDF", tdigestKey, "3.0")
+ require.NoError(t, rsp.Err())
+ vals, err := rsp.Slice()
+ require.NoError(t, err)
+ require.Len(t, vals, 1)
+ require.NotEqual(t, "nan", vals[0])
+
+ // multi-value CDF query
+ rsp = rdb.Do(ctx, "TDIGEST.CDF", tdigestKey, "0.0", "2.5",
"5.0", "10.0")
+ require.NoError(t, rsp.Err())
+ vals, err = rsp.Slice()
+ require.NoError(t, err)
+ require.Len(t, vals, 4)
+
+ // empty tdigest should return NaN
+ emptyKey := keyPrefix + "empty"
+ require.NoError(t, rdb.Do(ctx, "TDIGEST.CREATE",
emptyKey).Err())
+ rsp = rdb.Do(ctx, "TDIGEST.CDF", emptyKey, "1.0")
+ require.NoError(t, rsp.Err())
+ vals, err = rsp.Slice()
+ require.NoError(t, err)
+ require.Len(t, vals, 1)
+ if isRESP3 {
+ cdf, ok := vals[0].(float64)
+ require.True(t, ok, "expected float64 but got %T",
vals[0])
+ require.True(t, math.IsNaN(cdf), "expected NaN but got
%v", cdf)
+ } else {
+ require.Equal(t, "nan", vals[0])
+ }
+
+ // Test with an empty digest and multi-valued CDF.
+ rsp = rdb.Do(ctx, "TDIGEST.CDF", emptyKey, "0.5", "1.0", "1.5",
"2.2")
+ require.NoError(t, rsp.Err())
+ vals, err = rsp.Slice()
+ require.NoError(t, err)
+ require.Len(t, vals, 4)
+ if isRESP3 {
+ for i, v := range vals {
+ cdf, ok := v.(float64)
+ require.True(t, ok, "expected float64 but got
%T at index %d", v, i)
+ require.True(t, math.IsNaN(cdf), "expected NaN
but got %v at index %d", cdf, i)
+ }
+ } else {
+ require.Equal(t, []interface{}{"nan", "nan", "nan",
"nan"}, vals)
+ }
+
+ {
+ // test with samples, these data are generated from
redis tdigest.cdf command
+ var samplesTestKey = keyPrefix + "samples"
+ samples := []string{"42.210704046603865",
+ "35.2057544168016",
+ "12.444722535953744",
+ "-75.17683888127605",
+ "-27.367122500244108",
+ "77.34406489041416",
+ "-48.75891720991832",
+ "-42.93819033339253",
+ "44.855673646883474",
+ "50.891754534273815",
+ "-7.886891965257249",
+ "-89.26236837061113",
+ "48.50939634261886",
+ "59.88989586866117",
+ "32.46873341607849",
+ "-11.729446399857835",
+ "-100.32834587901394",
+ "-10.854053691728382",
+ "31.32150940192227",
+ "-19.568850657626257",
+ "-99.55268951300809",
+ "46.23219381980718",
+ "4.557500453188453",
+ "-52.306747310528394",
+ "50.02350257515229",
+ "27.897077091194205",
+ "-36.101222754212685",
+ "-34.17897261387189",
+ "-91.53560443207508",
+ "46.348114674645984",
+ }
+
+ cdfArgs := []string{
+ "60.01887553433912",
+ "-37.776384417214345",
+ "-82.915002521946",
+ "-87.44554390843862",
+ "31.95001239955863",
+ "-82.89948255423627",
+ "-73.80710164584889",
+ "-78.59695062757828",
+ "-30.343669845908025",
+ "-49.798720052628795",
+ }
+
+ expectedCdfs := []float64{
+ 0.9666666666666667,
+ 0.26666666666666666,
+ 0.13333333333333333,
+ 0.13333333333333333,
+ 0.6333333333333333,
+ 0.13333333333333333,
+ 0.16666666666666666,
+ 0.13333333333333333,
+ 0.3333333333333333,
+ 0.2,
+ }
+
+ require.NoError(t, rdb.Do(ctx, "TDIGEST.CREATE",
samplesTestKey, "compression", "100").Err())
+
+ var addReqArgs = make([]interface{}, 0, len(samples)+2)
+ addReqArgs = append(addReqArgs, "TDIGEST.ADD",
samplesTestKey)
+ for _, sample := range samples {
+ addReqArgs = append(addReqArgs, sample)
+ }
+ require.NoError(t, rdb.Do(ctx, addReqArgs...).Err())
+
+ var cdfReqArgs = make([]interface{}, 0, len(cdfArgs)+2)
+ cdfReqArgs = append(cdfReqArgs, "TDIGEST.CDF",
samplesTestKey)
+ for _, arg := range cdfArgs {
+ cdfReqArgs = append(cdfReqArgs, arg)
+ }
+
+ rsp = rdb.Do(ctx, cdfReqArgs...)
+ require.NoError(t, rsp.Err())
+ vals, err = rsp.Slice()
+ require.NoError(t, err)
+ require.Len(t, vals, len(cdfArgs))
+ for i, v := range vals {
+ var cdf float64
+ if isRESP3 {
+ var ok bool
+ cdf, ok = v.(float64)
+ require.True(t, ok, "expected float64
but got %T at index %d", v, i)
+ } else {
+ str, ok := v.(string)
+ require.True(t, ok, "expected string
but got %T at index %d", v, i)
+ cdf, err = strconv.ParseFloat(str, 64)
+ require.NoError(t, err)
+ }
+ require.InDelta(t, expectedCdfs[i], cdf, 0.01,
"CDF mismatch at index %d", i)
+ }
+ }
+ })
}