-
Notifications
You must be signed in to change notification settings - Fork 7
test: extend td_init coverage + guard td_free(NULL) (follow-up to #41) #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9c1b7f9
335d972
53f9e69
caadf02
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,16 @@ if (BUILD_TESTS) | |
| target_link_libraries(td_test tdigest m) | ||
| enable_testing() | ||
| add_test(td_test td_test) | ||
|
|
||
| # Capacity-boundary regression: includes tdigest.c to drive the allocation-free | ||
| # capacity_from_compression() helper at the exact accepted/rejected boundary without | ||
| # allocating the ~34 GB (~32 GiB) that the largest accepted compression would need. | ||
| # Use CMAKE_CURRENT_LIST_DIR so the src path stays correct when t-digest-c is consumed | ||
| # via add_subdirectory() (CMAKE_SOURCE_DIR would point at the outermost project). | ||
| add_executable(td_capacity_test unit/td_capacity_test.c) | ||
| target_include_directories(td_capacity_test PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../src) | ||
| target_link_libraries(td_capacity_test m) | ||
| add_test(td_capacity_test td_capacity_test) | ||
| endif() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 2fb6b5a — switched the include path to |
||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| /* | ||
| * Capacity-boundary regression for td_init (follow-up to #41 / #44). | ||
| * | ||
| * The accepted side of the capacity guard is the largest compression whose node capacity | ||
| * (cap = 6*compression + 10) still fits within every limit the helper enforces: INT_MAX and the | ||
| * per-array element counts SIZE_MAX/sizeof(double) and SIZE_MAX/sizeof(long long). On a 64-bit | ||
| * size_t target INT_MAX binds; on a 32-bit size_t target the SIZE_MAX/8 element limit is smaller | ||
| * and binds instead, so the boundary is derived from the minimum rather than assuming INT_MAX. | ||
| * Verifying it through td_new()/td_init() is impractical because at cap ~ INT_MAX the two 8-byte | ||
| * node arrays total ~34 GB (~32 GiB). This test instead includes the translation unit and drives | ||
| * the factored, allocation-free helper capacity_from_compression() directly, so the exact | ||
| * boundary (and one past it) is checked without committing tens of GiB. | ||
| */ | ||
| #include <limits.h> | ||
| #include <math.h> | ||
| #include <stdint.h> | ||
| #include <stdio.h> | ||
|
|
||
| #include "tdigest.c" /* brings in the static capacity_from_compression + cap_from_compression */ | ||
|
|
||
| static int failures = 0; | ||
|
|
||
| #define CHECK(cond, msg) \ | ||
| do { \ | ||
| if (!(cond)) { \ | ||
| fprintf(stderr, "FAIL: %s\n", (msg)); \ | ||
| failures++; \ | ||
| } \ | ||
| } while (0) | ||
|
|
||
| int main(void) { | ||
| /* The binding capacity limit is the minimum of every check the helper enforces: | ||
| * INT_MAX and the per-array element counts SIZE_MAX/sizeof(double|long long). On 64-bit | ||
| * size_t this is INT_MAX; on 32-bit size_t it is the (smaller) SIZE_MAX/8. */ | ||
| uint64_t max_capacity = (uint64_t)INT_MAX; | ||
| if (SIZE_MAX / sizeof(double) < max_capacity) { | ||
| max_capacity = (uint64_t)(SIZE_MAX / sizeof(double)); | ||
| } | ||
| if (SIZE_MAX / sizeof(long long) < max_capacity) { | ||
| max_capacity = (uint64_t)(SIZE_MAX / sizeof(long long)); | ||
| } | ||
|
|
||
| /* The largest integer compression whose capacity still fits within max_capacity. */ | ||
| const long long max_ok = (long long)((max_capacity - 10) / 6); | ||
|
|
||
| /* Sanity on the arithmetic: this capacity must be within the limit, the next one must not. */ | ||
| const uint64_t cap_ok = cap_from_compression((uint64_t)max_ok); | ||
| const uint64_t cap_over = cap_from_compression((uint64_t)(max_ok + 1)); | ||
| CHECK(cap_ok <= max_capacity, "boundary capacity should be within the binding limit"); | ||
| CHECK(cap_over > max_capacity, "boundary+1 capacity should exceed the binding limit"); | ||
|
|
||
| const size_t SENTINEL = (size_t)0xA5A5A5A5A5A5A5A5ULL; | ||
| size_t cap; | ||
|
|
||
| /* Accepted boundary: returns 0 and writes a capacity that matches the formula. */ | ||
| cap = SENTINEL; | ||
| CHECK(capacity_from_compression((double)max_ok, &cap) == 0, | ||
| "largest in-range compression must be accepted"); | ||
| CHECK(cap == (size_t)cap_ok, "accepted boundary capacity must match 6*c+10"); | ||
|
|
||
| /* One past the boundary: rejected, and *capacity is left untouched. */ | ||
| cap = SENTINEL; | ||
| CHECK(capacity_from_compression((double)(max_ok + 1), &cap) == 1, | ||
| "compression just past the boundary must be rejected"); | ||
| CHECK(cap == SENTINEL, "rejected input must leave *capacity untouched"); | ||
|
|
||
| /* Invalid inputs are rejected and never touch *capacity. */ | ||
| const double bad[] = {NAN, INFINITY, -INFINITY, 0.0, -1.0, (double)INT_MAX}; | ||
| for (unsigned i = 0; i < sizeof(bad) / sizeof(bad[0]); ++i) { | ||
| cap = SENTINEL; | ||
| CHECK(capacity_from_compression(bad[i], &cap) == 1, "invalid compression must be rejected"); | ||
| CHECK(cap == SENTINEL, "rejected input must leave *capacity untouched"); | ||
| } | ||
|
|
||
| /* A small valid compression still computes the documented capacity. */ | ||
| cap = SENTINEL; | ||
| CHECK(capacity_from_compression(100.0, &cap) == 0, "compression 100 must be accepted"); | ||
| CHECK(cap == 610, "cap(100) must be 6*100 + 10"); | ||
|
|
||
| if (failures == 0) { | ||
| printf("OK: capacity boundary max_ok=%lld cap=%llu\n", max_ok, (unsigned long long)cap_ok); | ||
| return 0; | ||
| } | ||
| fprintf(stderr, "%d capacity check(s) failed\n", failures); | ||
| return 1; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -521,8 +521,9 @@ | |
| mu_assert_long_eq(0, td_init(1000000, &t)); | ||
| td_free(t); | ||
|
|
||
| mu_assert_long_eq(0, td_init(100000000, &t)); | ||
| td_free(t); | ||
| // The exact accepted/rejected capacity boundary is covered by td_capacity_test, which | ||
| // exercises the no-alloc capacity helper directly instead of committing the ~34 GB (~32 GiB) | ||
| // the largest accepted compression would allocate here. | ||
| } | ||
|
|
||
| MU_TEST(test_quantiles) { | ||
|
|
@@ -594,9 +595,102 @@ | |
| td_free(t); | ||
| } | ||
|
|
||
| // td_free must tolerate NULL: this PR makes td_new() return NULL for invalid | ||
| // compression, so td_free(td_new(bad)) is a natural cleanup pattern. | ||
| MU_TEST(test_td_free_null) { | ||
| td_free(NULL); // must not crash | ||
| td_free(td_new(-1.0)); // td_new returns NULL for invalid compression | ||
| td_free(td_new(NAN)); | ||
| } | ||
|
|
||
| // The td_new() convenience wrapper must reject the same inputs as td_init(). | ||
| MU_TEST(test_td_new_rejects_bad_compression) { | ||
| mu_assert(td_new(NAN) == NULL, "td_new(NaN) must return NULL"); | ||
| mu_assert(td_new(INFINITY) == NULL, "td_new(INF) must return NULL"); | ||
| mu_assert(td_new(-1) == NULL, "td_new(-1) must return NULL"); | ||
| mu_assert(td_new(0) == NULL, "td_new(0) must return NULL"); | ||
| mu_assert(td_new((double)(((INT_MAX - 10) / 6) + 1)) == NULL, | ||
| "td_new above the capacity boundary must return NULL"); | ||
| } | ||
|
|
||
| // td_init must leave *result untouched on failure (stronger than "stays NULL": | ||
| // a sentinel proves td_init never writes the out-param on a rejected input). | ||
| MU_TEST(test_td_init_result_untouched_on_failure) { | ||
| td_histogram_t sentinel_obj; | ||
| td_histogram_t *const sentinel = &sentinel_obj; | ||
| td_histogram_t *t; | ||
| t = sentinel; | ||
| mu_assert_long_eq(1, td_init(NAN, &t)); | ||
| mu_assert(t == sentinel, "NaN: *result must be left untouched"); | ||
| t = sentinel; | ||
| mu_assert_long_eq(1, td_init(0, &t)); | ||
| mu_assert(t == sentinel, "zero: *result must be left untouched"); | ||
| t = sentinel; | ||
| mu_assert_long_eq(1, td_init((double)(((INT_MAX - 10) / 6) + 1), &t)); | ||
| mu_assert(t == sentinel, "overflow: *result must be left untouched"); | ||
| } | ||
|
|
||
| // Capacity formula (cap = 6*compression + 10) and determinism at safe sizes, | ||
| // plus the current behavior for sub-1 / fractional compression (accepted, floored). | ||
| MU_TEST(test_td_init_cap_and_determinism) { | ||
| td_histogram_t *a = NULL, *b = NULL; | ||
|
Check warning on line 636 in tests/unit/td_test.c
|
||
| mu_assert_long_eq(0, td_init(1, &a)); | ||
| mu_assert_long_eq(16, a->cap); // 6*1 + 10 | ||
| td_free(a); | ||
| a = NULL; | ||
| mu_assert_long_eq(0, td_init(2, &a)); | ||
| mu_assert_long_eq(22, a->cap); // 6*2 + 10 | ||
| td_free(a); | ||
| a = NULL; | ||
| // determinism: same compression -> same cap | ||
| mu_assert_long_eq(0, td_init(500, &a)); | ||
| mu_assert_long_eq(0, td_init(500, &b)); | ||
| mu_assert_long_eq(3010, a->cap); // 6*500 + 10 | ||
| mu_assert_long_eq(a->cap, b->cap); | ||
| td_free(a); | ||
| td_free(b); | ||
| // Sub-1 / fractional compression is currently ACCEPTED and floored to 0, | ||
| // yielding cap 10 (6*0 + 10); td_compression() then reports (int)0.5 == 0. | ||
| // Pins today's behavior (see PR discussion on whether to reject compression < 1). | ||
| a = NULL; | ||
| mu_assert_long_eq(0, td_init(0.5, &a)); | ||
| mu_assert_long_eq(10, a->cap); | ||
| mu_assert_int_eq(0, td_compression(a)); | ||
| td_free(a); | ||
| } | ||
|
|
||
| // A large but valid digest (compression 100000 -> cap 600010, ~9.6 MB) must not | ||
| // just allocate but actually work end to end. | ||
| MU_TEST(test_td_init_large_success_is_usable) { | ||
| td_histogram_t *t = NULL; | ||
| mu_assert_long_eq(0, td_init(100000, &t)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This adds a useful ~9.6 MB end-to-end case, but it does not complete the follow-up called out on #41: the exact accepted side,
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in f2beaa2. Factored the validation + capacity math out of |
||
| mu_assert(t != NULL, "large valid compression should allocate"); | ||
| mu_assert_long_eq(600010, t->cap); // 6*100000 + 10 | ||
| for (int i = 1; i <= 10000; ++i) { | ||
| mu_assert(td_add(t, (double)i, 1) == 0, "Insertion"); | ||
| } | ||
| mu_assert(td_compress(t) == 0, "compress large digest"); | ||
| mu_assert_double_eq(1.0, td_min(t)); | ||
| mu_assert_double_eq(10000.0, td_max(t)); | ||
| mu_assert_long_eq(10000, td_size(t)); | ||
| mu_assert(td_centroid_count(t) <= t->cap, "centroid count must stay within cap"); | ||
| // Store the result and assert it is finite first: mu_assert_double_eq_epsilon does NOT fail | ||
| // for NaN (fabs(expected - NaN) is NaN, and NaN > epsilon is false), so the epsilon check | ||
| // alone would pass even if td_quantile() returned NaN. | ||
| const double median = td_quantile(t, 0.5); | ||
| mu_assert(isfinite(median), "median must be finite"); | ||
| mu_assert_double_eq_epsilon(5000.5, median, 50.0); | ||
| td_free(t); | ||
| } | ||
|
|
||
| MU_TEST_SUITE(test_suite) { | ||
| MU_RUN_TEST(test_basic); | ||
| MU_RUN_TEST(test_td_init); | ||
| MU_RUN_TEST(test_td_free_null); | ||
| MU_RUN_TEST(test_td_new_rejects_bad_compression); | ||
| MU_RUN_TEST(test_td_init_result_untouched_on_failure); | ||
| MU_RUN_TEST(test_td_init_cap_and_determinism); | ||
| MU_RUN_TEST(test_td_init_large_success_is_usable); | ||
| MU_RUN_TEST(test_compress_small); | ||
| MU_RUN_TEST(test_compress_large); | ||
| MU_RUN_TEST(test_nans); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this makes NULL tolerance part of the public API, please update
tdigest.halongside it:td_new()can return NULL for invalid compression or allocation failure;td_init()can return 1 for either case and leaves*resultuntouched on rejection; andtd_free(NULL)is supported. Also, the nearby comment should not say the validation was added “in this PR”—that landed in #41.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in f2beaa2.
tdigest.hnow documents thattd_new()returns NULL for invalid compression or allocation failure,td_init()returns 1 and leaves*resultuntouched on rejection, andtd_free(NULL)is a no-op. Also corrected thetd_freecomment — it no longer claims the validation was added in this PR (it landed in #41).