@@ -451,3 +451,159 @@ TEST(Aggregation, Base2ExponentialHistogramAggregationMerge)
451451 expected_scale, expected_max_buckets);
452452 }
453453}
454+
455+ // Test that verifies the count invariant is maintained during merge operations that require
456+ // downscaling. The invariant is: count == zero_count + sum(positive_bucket_counts) +
457+ // sum(negative_bucket_counts)
458+ //
459+ TEST (Aggregation, Base2ExponentialHistogramAggregationMergeCountInvariant)
460+ {
461+ // Helper to sum all bucket counts
462+ auto sum_bucket_counts = [](const Base2ExponentialHistogramPointData &point) -> uint64_t {
463+ uint64_t total = 0 ;
464+ if (point.positive_buckets_ && !point.positive_buckets_ ->Empty ())
465+ {
466+ for (int32_t i = point.positive_buckets_ ->StartIndex ();
467+ i <= point.positive_buckets_ ->EndIndex (); ++i)
468+ {
469+ total += point.positive_buckets_ ->Get (i);
470+ }
471+ }
472+ if (point.negative_buckets_ && !point.negative_buckets_ ->Empty ())
473+ {
474+ for (int32_t i = point.negative_buckets_ ->StartIndex ();
475+ i <= point.negative_buckets_ ->EndIndex (); ++i)
476+ {
477+ total += point.negative_buckets_ ->Get (i);
478+ }
479+ }
480+ return total;
481+ };
482+
483+ // Helper to verify the count invariant
484+ auto verify_invariant = [&sum_bucket_counts](const Base2ExponentialHistogramPointData &point,
485+ uint64_t expected_count, const std::string &phase) {
486+ uint64_t bucket_sum = sum_bucket_counts (point);
487+ uint64_t calculated_count = point.zero_count_ + bucket_sum;
488+
489+ EXPECT_EQ (point.count_ , expected_count) << " Count mismatch at " << phase << " : expected "
490+ << expected_count << " , got " << point.count_ ;
491+ EXPECT_EQ (point.count_ , calculated_count)
492+ << " Invariant violation at " << phase << " : count(" << point.count_ << " ) != zero_count("
493+ << point.zero_count_ << " ) + bucket_sum(" << bucket_sum << " ) = " << calculated_count;
494+ };
495+
496+ // Use scale 0 for easy bucket index reasoning: value 2^N -> index N-1
497+ // Use max_buckets=5 so we can trigger the bug with small powers of 2
498+ Base2ExponentialHistogramAggregationConfig config;
499+ config.max_scale_ = 0 ;
500+ config.max_buckets_ = 5 ;
501+
502+ std::unique_ptr<Aggregation> cumulative =
503+ std::make_unique<Base2ExponentialHistogramAggregation>(&config);
504+
505+ uint64_t expected_count = 0 ;
506+
507+ // === Cycle 1: values 2,4,8,16,32 -> indices 0,1,2,3,4 (5 buckets, fits in max_buckets=5) ===
508+ {
509+ Base2ExponentialHistogramAggregation delta (&config);
510+ delta.Aggregate (2.0 , {}); // 2^1 -> index 0
511+ delta.Aggregate (4.0 , {}); // 2^2 -> index 1
512+ delta.Aggregate (8.0 , {}); // 2^3 -> index 2
513+ delta.Aggregate (16.0 , {}); // 2^4 -> index 3
514+ delta.Aggregate (32.0 , {}); // 2^5 -> index 4
515+ expected_count += 5 ;
516+
517+ cumulative = cumulative->Merge (delta);
518+
519+ auto point = nostd::get<Base2ExponentialHistogramPointData>(cumulative->ToPoint ());
520+ verify_invariant (point, expected_count, " Cycle 1: indices 0-4" );
521+
522+ // Verify bucket positions
523+ EXPECT_EQ (point.positive_buckets_ ->StartIndex (), 0 );
524+ EXPECT_EQ (point.positive_buckets_ ->EndIndex (), 4 );
525+ }
526+
527+ // === Cycle 2: values 4,8,16,32,64 -> indices 1,2,3,4,5 (5 buckets, fits individually) ===
528+ // But combined with Cycle 1: indices 0-5 = 6 buckets = max_buckets + 1
529+ // This triggers the off-by-one bug as of commit
530+ // https://github.com/open-telemetry/opentelemetry-cpp/commit/5fc4707a8b7820f6bdbc782ccdffac7ccafbe80d.
531+ {
532+ Base2ExponentialHistogramAggregation delta (&config);
533+ delta.Aggregate (4.0 , {}); // 2^2 -> index 1
534+ delta.Aggregate (8.0 , {}); // 2^3 -> index 2
535+ delta.Aggregate (16.0 , {}); // 2^4 -> index 3
536+ delta.Aggregate (32.0 , {}); // 2^5 -> index 4
537+ delta.Aggregate (64.0 , {}); // 2^6 -> index 5
538+ expected_count += 5 ;
539+
540+ cumulative = cumulative->Merge (delta);
541+
542+ auto point = nostd::get<Base2ExponentialHistogramPointData>(cumulative->ToPoint ());
543+ verify_invariant (point, expected_count, " Cycle 2: combined indices 0-5" );
544+ }
545+
546+ // === Cycle 3: values 8,16,32,64,128 -> indices 2,3,4,5,6 ===
547+ {
548+ Base2ExponentialHistogramAggregation delta (&config);
549+ delta.Aggregate (8.0 , {}); // 2^3 -> index 2
550+ delta.Aggregate (16.0 , {}); // 2^4 -> index 3
551+ delta.Aggregate (32.0 , {}); // 2^5 -> index 4
552+ delta.Aggregate (64.0 , {}); // 2^6 -> index 5
553+ delta.Aggregate (128.0 , {}); // 2^7 -> index 6
554+ expected_count += 5 ;
555+
556+ cumulative = cumulative->Merge (delta);
557+
558+ auto point = nostd::get<Base2ExponentialHistogramPointData>(cumulative->ToPoint ());
559+ verify_invariant (point, expected_count, " Cycle 3: indices 2-6" );
560+ }
561+
562+ // === Cycle 4: values 16,32,64,128,256 -> indices 3,4,5,6,7 ===
563+ {
564+ Base2ExponentialHistogramAggregation delta (&config);
565+ delta.Aggregate (16.0 , {}); // 2^4 -> index 3
566+ delta.Aggregate (32.0 , {}); // 2^5 -> index 4
567+ delta.Aggregate (64.0 , {}); // 2^6 -> index 5
568+ delta.Aggregate (128.0 , {}); // 2^7 -> index 6
569+ delta.Aggregate (256.0 , {}); // 2^8 -> index 7
570+ expected_count += 5 ;
571+
572+ cumulative = cumulative->Merge (delta);
573+
574+ auto point = nostd::get<Base2ExponentialHistogramPointData>(cumulative->ToPoint ());
575+ verify_invariant (point, expected_count, " Cycle 4: indices 3-7" );
576+ }
577+
578+ // === Cycle 5: values 32,64,128,256,512 -> indices 4,5,6,7,8 ===
579+ {
580+ Base2ExponentialHistogramAggregation delta (&config);
581+ delta.Aggregate (32.0 , {}); // 2^5 -> index 4
582+ delta.Aggregate (64.0 , {}); // 2^6 -> index 5
583+ delta.Aggregate (128.0 , {}); // 2^7 -> index 6
584+ delta.Aggregate (256.0 , {}); // 2^8 -> index 7
585+ delta.Aggregate (512.0 , {}); // 2^9 -> index 8
586+ expected_count += 5 ;
587+
588+ cumulative = cumulative->Merge (delta);
589+
590+ auto point = nostd::get<Base2ExponentialHistogramPointData>(cumulative->ToPoint ());
591+ verify_invariant (point, expected_count, " Cycle 5: indices 4-8" );
592+ }
593+
594+ // === Cycle 6: values 64,128,256,512,1024 -> indices 5,6,7,8,9 ===
595+ {
596+ Base2ExponentialHistogramAggregation delta (&config);
597+ delta.Aggregate (64.0 , {}); // 2^6 -> index 5
598+ delta.Aggregate (128.0 , {}); // 2^7 -> index 6
599+ delta.Aggregate (256.0 , {}); // 2^8 -> index 7
600+ delta.Aggregate (512.0 , {}); // 2^9 -> index 8
601+ delta.Aggregate (1024.0 , {}); // 2^10 -> index 9
602+ expected_count += 5 ;
603+
604+ cumulative = cumulative->Merge (delta);
605+
606+ auto point = nostd::get<Base2ExponentialHistogramPointData>(cumulative->ToPoint ());
607+ verify_invariant (point, expected_count, " Cycle 6: indices 5-9" );
608+ }
609+ }
0 commit comments