Skip to content

Commit 3632989

Browse files
author
Christian
committed
Add auto-bucket sizing logic for interval histograms
- Modified `getIntervalHistogram` to support auto-calculating bucket sizes when `bucketSizeNs` is set to `-1` or omitted. - Implemented `autoBucketSizeNs` to calculate bucket sizes approximating 64 buckets, rounded to human-readable values (1/2/5 × 10^k). - Updated validation logic to allow `-1` as a valid input for bucket size. - Enhanced tests to cover auto-sizing and snapping behavior.
1 parent a254d83 commit 3632989

2 files changed

Lines changed: 94 additions & 10 deletions

File tree

src/main/java/com/ammann/entropy/resource/EventsResource.java

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -428,8 +428,9 @@ public Response getEventRate(
428428
description =
429429
"Returns a histogram of frequencies for intervals between decay events."
430430
+ " Requires at least 100 intervals for meaningful statistical analysis."
431-
+ " Default bucket size (100ns) is optimized for radioactive decay"
432-
+ " intervals in the 2-10µs range.")
431+
+ " When bucketSizeNs is omitted (or set to -1) the server auto-selects"
432+
+ " a bucket size that targets approximately 64 buckets across the"
433+
+ " observed interval range, rounded to a 1/2/5 × 10^k value.")
433434
@APIResponses({
434435
@APIResponse(
435436
responseCode = "200",
@@ -442,19 +443,21 @@ public Response getIntervalHistogram(
442443
@Parameter(description = "Start of time window (ISO-8601)") @QueryParam("from")
443444
String from,
444445
@Parameter(description = "End of time window (ISO-8601)") @QueryParam("to") String to,
445-
@Parameter(description = "Bucket size in nanoseconds (must be positive, default: 100)")
446+
@Parameter(
447+
description =
448+
"Bucket size in nanoseconds. Pass -1 (or omit) for"
449+
+ " auto-sizing based on the observed interval range.")
446450
@QueryParam("bucketSizeNs")
447-
@DefaultValue("100")
451+
@DefaultValue("-1")
448452
int bucketSizeNs) {
449453

450454
LOG.debugf(
451455
"Interval histogram request: from=%s, to=%s, bucketSize=%d",
452456
from, to, bucketSizeNs);
453457

454-
// Validate bucket size to prevent division by zero and negative values
455-
if (bucketSizeNs <= 0) {
458+
if (bucketSizeNs != -1 && bucketSizeNs <= 0) {
456459
throw ValidationException.invalidParameter(
457-
"bucketSizeNs", bucketSizeNs, "positive integer");
460+
"bucketSizeNs", bucketSizeNs, "positive integer or -1 for auto");
458461
}
459462

460463
TimeWindow window = parseTimeWindow(from, to);
@@ -464,11 +467,19 @@ public Response getIntervalHistogram(
464467
throw ValidationException.insufficientData("intervals", 100, intervals.size());
465468
}
466469

470+
int effectiveBucketSizeNs = bucketSizeNs;
471+
if (effectiveBucketSizeNs == -1) {
472+
effectiveBucketSizeNs = autoBucketSizeNs(intervals);
473+
LOG.debugf(
474+
"Auto-selected bucketSizeNs=%d for %d intervals",
475+
effectiveBucketSizeNs, intervals.size());
476+
}
477+
467478
Map<Long, Integer> histogram =
468-
entropyStatisticsService.createHistogram(intervals, bucketSizeNs);
479+
entropyStatisticsService.createHistogram(intervals, effectiveBucketSizeNs);
469480
IntervalHistogramDTO response =
470481
IntervalHistogramDTO.from(
471-
histogram, intervals, bucketSizeNs, window.start, window.end);
482+
histogram, intervals, effectiveBucketSizeNs, window.start, window.end);
472483

473484
LOG.infof(
474485
"Histogram: %d buckets from %d intervals (min=%dns, max=%dns)",
@@ -551,4 +562,45 @@ public Response getFilterOptions() {
551562

552563
return Response.ok(new EventFilterOptionsDTO(batchIds, channels)).build();
553564
}
565+
566+
/**
567+
* Picks a bucket size that yields roughly {@link #AUTO_HISTOGRAM_TARGET_BUCKETS} buckets
568+
* across the observed interval range, snapped to a 1/2/5 × 10^k mantissa so bucket edges
569+
* are human-readable. Floors at {@link #AUTO_HISTOGRAM_MIN_BUCKET_NS} so fine-grained
570+
* entropy sources do not collapse into a single bucket.
571+
*/
572+
static int autoBucketSizeNs(List<Long> intervals) {
573+
long min = Long.MAX_VALUE;
574+
long max = Long.MIN_VALUE;
575+
for (long v : intervals) {
576+
if (v < min) min = v;
577+
if (v > max) max = v;
578+
}
579+
long range = Math.max(1L, max - min);
580+
double ideal = (double) range / AUTO_HISTOGRAM_TARGET_BUCKETS;
581+
if (ideal < AUTO_HISTOGRAM_MIN_BUCKET_NS) {
582+
return AUTO_HISTOGRAM_MIN_BUCKET_NS;
583+
}
584+
585+
// Snap ``ideal`` up to the next value in the 1/2/5 × 10^k series.
586+
double exponent = Math.floor(Math.log10(ideal));
587+
double pow = Math.pow(10.0, exponent);
588+
double mantissa = ideal / pow;
589+
double snapped;
590+
if (mantissa <= 1.0) {
591+
snapped = 1.0 * pow;
592+
} else if (mantissa <= 2.0) {
593+
snapped = 2.0 * pow;
594+
} else if (mantissa <= 5.0) {
595+
snapped = 5.0 * pow;
596+
} else {
597+
snapped = 10.0 * pow;
598+
}
599+
600+
long bucket = Math.max(AUTO_HISTOGRAM_MIN_BUCKET_NS, (long) snapped);
601+
return (int) Math.min(bucket, Integer.MAX_VALUE);
602+
}
603+
604+
private static final int AUTO_HISTOGRAM_TARGET_BUCKETS = 64;
605+
private static final int AUTO_HISTOGRAM_MIN_BUCKET_NS = 100;
554606
}

src/test/java/com/ammann/entropy/resource/EventsResourceTest.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,10 +331,42 @@ void getIntervalHistogramUsesDefaultBucketSize() {
331331
start.toString(), start.plusSeconds(10).toString(), 100);
332332
IntervalHistogramDTO dto = (IntervalHistogramDTO) response.getEntity();
333333

334-
// Verify default bucket size is used when not specified
334+
// Verify explicit bucket size is honored when passed.
335335
assertThat(dto.bucketSizeNs()).isEqualTo(100);
336336
}
337337

338+
@Test
339+
@TestTransaction
340+
void getIntervalHistogramAutoSizesWhenBucketSizeIsMinusOne() {
341+
EntropyData.deleteAll();
342+
EventsResource resource = buildResource();
343+
344+
// TestDataFactory builds events with a fixed 1500 ns stride, so all intervals
345+
// collapse to a single value (range=0). The auto-sizer must degrade gracefully
346+
// and return the minimum floor of 100 ns rather than throwing or returning 0.
347+
Instant start = Instant.parse("2024-01-01T00:00:00Z");
348+
List<EntropyData> events = TestDataFactory.buildSequentialEvents(150, 1_000L, start);
349+
EntropyData.persist(events);
350+
351+
var response =
352+
resource.getIntervalHistogram(
353+
start.toString(), start.plusSeconds(10).toString(), -1);
354+
IntervalHistogramDTO dto = (IntervalHistogramDTO) response.getEntity();
355+
356+
assertThat(dto.bucketSizeNs()).isEqualTo(100);
357+
assertThat(dto.totalIntervals()).isEqualTo(149L);
358+
}
359+
360+
@Test
361+
void autoBucketSizeNsSnapsToNiceValues() {
362+
// Range 6_400_000 ns → ideal ~100_000 → snaps to 100_000
363+
assertThat(EventsResource.autoBucketSizeNs(List.of(0L, 6_400_000L))).isEqualTo(100_000);
364+
// Range 12_800 ns → ideal 200 → snaps to 200
365+
assertThat(EventsResource.autoBucketSizeNs(List.of(0L, 12_800L))).isEqualTo(200);
366+
// Tiny range clamps to minimum bucket (100 ns)
367+
assertThat(EventsResource.autoBucketSizeNs(List.of(0L, 10L))).isEqualTo(100);
368+
}
369+
338370
@Test
339371
@TestTransaction
340372
void getIntervalHistogramCustomBucketSize() {

0 commit comments

Comments
 (0)