diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f54c384
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ java-version: ["17", "21"]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up JDK ${{ matrix.java-version }}
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java-version }}
+ cache: maven
+ - name: Build and test
+ run: mvn -B --no-transfer-progress verify
diff --git a/.gitignore b/.gitignore
index 524f096..6895e49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,18 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
+
+# Maven
+target/
+
+# IDE
+.idea/
+*.iml
+.vscode/
+.classpath
+.project
+.settings/
+
+# Benchmarks
+benchmarks/dependency-reduced-pom.xml
+benchmarks/results.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a2f0e64
--- /dev/null
+++ b/README.md
@@ -0,0 +1,160 @@
+# h2histogram-java
+
+[](https://github.com/iopsystems/h2histogram-java/actions/workflows/ci.yml)
+[](LICENSE)
+
+A pure-Java implementation of the [iopsystems h2 histogram](https://github.com/iopsystems/histogram).
+
+`h2histogram` produces histograms with **byte-for-byte identical bucketing** to the
+Rust `histogram` crate, so histograms recorded here can be consumed by
+[Rezolus](https://github.com/iopsystems/rezolus) — and, conversely, you can read an
+h2histogram produced by Rezolus (or the [Python](https://github.com/iopsystems/h2histogram-py)
+and [Go](https://github.com/iopsystems/h2histogram-go) implementations) and analyze
+it on the JVM. Values are carried in Java `long`s with unsigned semantics
+(`Long.compareUnsigned` and friends), so the full `u64` value range is supported,
+exactly like the Rust crate.
+
+## What is an h2 histogram?
+
+An h2 histogram quantizes values into buckets using two parameters:
+
+- **`groupingPower`** — the number of buckets spanning each power of two. It sets
+ the relative error to `2^-groupingPower` (e.g. `groupingPower=7` → ~0.78% error).
+- **`maxValuePower`** — the largest representable value is `2^maxValuePower - 1`.
+
+Values below `2^(groupingPower+1)` are stored **exactly** (linear buckets of width 1);
+larger values fall into logarithmic buckets. This gives HDR-histogram-like guarantees
+with a simpler, faster bucket index computation. Rezolus records histograms with
+`groupingPower=3` and `maxValuePower=64`.
+
+## Install
+
+Maven:
+
+```xml
+
The two libraries are compared at matched relative-error levels; because + * both use a log-linear bucket layout, the matched settings also produce + * counter arrays of almost identical size, making the cache behaviour + * directly comparable: + * + *
The {@code spread} parameter controls how many distinct buckets the + * pre-generated value stream touches, moving the hot working set through the + * cache hierarchy: {@code few} (64 buckets, fits in L1 alongside the data), + * {@code quarter} (a quarter of all buckets), {@code all} (every bucket). + * Bucket indices are sampled uniformly — sampling uniform values + * would concentrate nearly all samples in the top power of two of a + * logarithmic layout. + * + *
Histograms use maxValuePower=63 so the identical value stream is in
+ * range for HdrHistogram, which only accepts values up to Long.MAX_VALUE.
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Fork(1)
+public class RecordBenchmark {
+
+ static final int N = 1 << 20;
+
+ @Param({"gp3", "gp7", "gp14"})
+ public String precision;
+
+ @Param({"few", "quarter", "all"})
+ public String spread;
+
+ long[] values;
+ Histogram h2;
+ org.HdrHistogram.Histogram hdr;
+
+ @Setup
+ public void setup() {
+ int groupingPower;
+ int hdrDigits;
+ switch (precision) {
+ case "gp3" -> {
+ groupingPower = 3;
+ hdrDigits = 1;
+ }
+ case "gp7" -> {
+ groupingPower = 7;
+ hdrDigits = 2;
+ }
+ case "gp14" -> {
+ groupingPower = 14;
+ hdrDigits = 4;
+ }
+ default -> throw new IllegalArgumentException(precision);
+ }
+
+ Config config = new Config(groupingPower, 63);
+ h2 = new Histogram(config);
+ hdr = new org.HdrHistogram.Histogram(Long.MAX_VALUE, hdrDigits);
+
+ int totalBuckets = config.totalBuckets();
+ int targetBuckets = switch (spread) {
+ case "few" -> Math.min(64, totalBuckets);
+ case "quarter" -> Math.max(1, totalBuckets / 4);
+ case "all" -> totalBuckets;
+ default -> throw new IllegalArgumentException(spread);
+ };
+
+ Random rng = new Random(42);
+ values = new long[N];
+ for (int i = 0; i < N; i++) {
+ values[i] = config.indexToLowerBound(rng.nextInt(targetBuckets));
+ }
+ }
+
+ @Benchmark
+ @OperationsPerInvocation(N)
+ public Histogram h2Record() {
+ Histogram h = h2;
+ for (long v : values) {
+ h.increment(v);
+ }
+ return h;
+ }
+
+ @Benchmark
+ @OperationsPerInvocation(N)
+ public org.HdrHistogram.Histogram hdrRecord() {
+ org.HdrHistogram.Histogram h = hdr;
+ for (long v : values) {
+ h.recordValue(v);
+ }
+ return h;
+ }
+}
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..fb1dcf4
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,87 @@
+
+
All three components are unsigned 64-bit values carried in Java + * {@code long}s; use {@link Long#toUnsignedString} and friends when a bound + * may exceed {@code Long.MAX_VALUE}. + * + * @param count the number of observations in the bucket (unsigned) + * @param start the inclusive lower bound of the bucket's value range (unsigned) + * @param end the inclusive upper bound of the bucket's value range (unsigned) + */ +public record Bucket(long count, long start, long end) { + /** + * Returns the arithmetic midpoint of the bucket range. It is a reasonable + * point estimate for values that fell into this bucket. + */ + public double midpoint() { + return (U64.toDouble(start) + U64.toDouble(end)) / 2.0; + } + + /** Returns the number of distinct integer values the bucket covers (unsigned). */ + public long width() { + return end - start + 1; + } + + @Override + public String toString() { + return "Bucket(count=" + Long.toUnsignedString(count) + + ", range=[" + Long.toUnsignedString(start) + + ", " + Long.toUnsignedString(end) + "])"; + } +} diff --git a/src/main/java/com/iopsystems/h2histogram/BucketWithQuantiles.java b/src/main/java/com/iopsystems/h2histogram/BucketWithQuantiles.java new file mode 100644 index 0000000..18b4acb --- /dev/null +++ b/src/main/java/com/iopsystems/h2histogram/BucketWithQuantiles.java @@ -0,0 +1,14 @@ +package com.iopsystems.h2histogram; + +/** + * Bundles a bucket with its quantile span. + * + *
{@code lowerQuantile} is the fraction of observations strictly before + * this bucket and {@code upperQuantile} the fraction at or before it, both in + * {@code [0.0, 1.0]}. + * + * @param bucket the bucket, with its individual (non-cumulative) count + * @param lowerQuantile the fraction of observations strictly before this bucket + * @param upperQuantile the fraction of observations at or before this bucket + */ +public record BucketWithQuantiles(Bucket bucket, double lowerQuantile, double upperQuantile) {} diff --git a/src/main/java/com/iopsystems/h2histogram/Config.java b/src/main/java/com/iopsystems/h2histogram/Config.java new file mode 100644 index 0000000..ef19272 --- /dev/null +++ b/src/main/java/com/iopsystems/h2histogram/Config.java @@ -0,0 +1,210 @@ +package com.iopsystems.h2histogram; + +/** + * An immutable bucketing configuration. + * + *
The bucketing strategy is fully determined by two parameters: + * + *
The layout has two regions: a linear region covering + * {@code 0 .. 2^(groupingPower+1)} where every bucket has width 1 (exact), and + * a logarithmic region above the cutoff subdivided into + * {@code 2^groupingPower} buckets per power of two. + * + *
This is a faithful port of the {@code Config} type from the Rust + * histogram crate and + * produces byte-for-byte identical bucketing. + * + *
Values are unsigned 64-bit integers carried in Java {@code long}s: a + * negative {@code long} represents a value above {@code Long.MAX_VALUE}, so + * the full {@code u64} range is supported. + */ +public final class Config { + private final int groupingPower; + private final int maxValuePower; + private final long max; // unsigned + private final int cutoffPower; + private final long cutoffValue; // unsigned + private final int lowerBinCount; + private final int upperBinDivisions; + private final int upperBinCount; + + /** + * Creates and validates a {@code Config}. + * + *
The constraints match the Rust crate: + * + *
Rezolus/metriken parquet files store dense histograms as a bare list + * of bucket counts without recording {@code groupingPower}. Given the + * number of buckets and the (conventionally fixed) {@code maxValuePower} + * the grouping power can be recovered uniquely. + * + * @throws IllegalArgumentException if no grouping power produces + * {@code totalBuckets} + */ + public static Config fromTotalBuckets(int totalBuckets, int maxValuePower) { + for (int groupingPower = 0; groupingPower < maxValuePower; groupingPower++) { + Config candidate; + try { + candidate = new Config(groupingPower, maxValuePower); + } catch (IllegalArgumentException e) { + continue; + } + if (candidate.totalBuckets() == totalBuckets) { + return candidate; + } + } + throw new IllegalArgumentException( + "no grouping_power with max_value_power=" + maxValuePower + + " yields " + totalBuckets + " buckets"); + } + + /** Returns the grouping power used to create this configuration. */ + public int groupingPower() { + return groupingPower; + } + + /** Returns the max value power used to create this configuration. */ + public int maxValuePower() { + return maxValuePower; + } + + /** + * Returns the largest value representable by this configuration, + * i.e. {@code 2^maxValuePower - 1}, as an unsigned {@code long}. + */ + public long max() { + return max; + } + + /** Returns the total number of buckets for this configuration. */ + public int totalBuckets() { + return lowerBinCount + upperBinCount; + } + + /** + * Returns the relative error (as a percentage) of the logarithmic buckets. + * Linear buckets have width 1 and no error. If the config has no + * logarithmic buckets the error is zero. + */ + public double error() { + if (groupingPower == maxValuePower - 1) { + return 0.0; + } + return 100.0 / (double) (1L << groupingPower); + } + + /** + * Returns the bucket index that {@code value} (unsigned) falls into. + * + * @throws IllegalArgumentException if the value is greater than the + * configured maximum + */ + public int valueToIndex(long value) { + if (Long.compareUnsigned(value, cutoffValue) < 0) { + return (int) value; + } + if (Long.compareUnsigned(value, max) > 0) { + throw new IllegalArgumentException( + "value " + Long.toUnsignedString(value) + + " is out of range for max " + Long.toUnsignedString(max)); + } + + // power = floor(log2(value)); equivalent to 63 - leading_zeros for u64. + int power = 63 - Long.numberOfLeadingZeros(value); + int logBin = power - cutoffPower; + long offset = (value - (1L << power)) >>> (power - groupingPower); + + return lowerBinCount + logBin * upperBinDivisions + (int) offset; + } + + /** Returns the inclusive (unsigned) lower bound of the bucket at {@code index}. */ + public long indexToLowerBound(int index) { + long g = ((long) index) >>> groupingPower; + long h = ((long) index) - g * (1L << groupingPower); + if (g < 1) { + return h; + } + return (1L << (groupingPower + g - 1)) + (1L << (g - 1)) * h; + } + + /** Returns the inclusive (unsigned) upper bound of the bucket at {@code index}. */ + public long indexToUpperBound(int index) { + if (index == totalBuckets() - 1) { + return max; + } + long g = ((long) index) >>> groupingPower; + long h = ((long) index) - g * (1L << groupingPower) + 1; + if (g < 1) { + return h - 1; + } + return (1L << (groupingPower + g - 1)) + (1L << (g - 1)) * h - 1; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Config other)) { + return false; + } + return groupingPower == other.groupingPower && maxValuePower == other.maxValuePower; + } + + @Override + public int hashCode() { + return 31 * groupingPower + maxValuePower; + } + + @Override + public String toString() { + return "Config(grouping_power=" + groupingPower + + ", max_value_power=" + maxValuePower + ")"; + } +} diff --git a/src/main/java/com/iopsystems/h2histogram/CumulativeHistogram.java b/src/main/java/com/iopsystems/h2histogram/CumulativeHistogram.java new file mode 100644 index 0000000..9835b38 --- /dev/null +++ b/src/main/java/com/iopsystems/h2histogram/CumulativeHistogram.java @@ -0,0 +1,320 @@ +package com.iopsystems.h2histogram; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; + +/** + * A read-only histogram with cumulative counts for fast quantile queries. + * + *
It corresponds to {@code CumulativeROHistogram} in the Rust histogram + * crate. It is a variant of {@link SparseHistogram} that stores only non-zero + * buckets in columnar form, but with cumulative counts: {@code count[i]} is + * the running prefix sum of individual bucket counts, so the last element + * equals the total observation count. + * + *
Because the counts are cumulative, percentile queries are answered with
+ * a binary search (O(log n) in the number of non-zero buckets) rather than a
+ * linear scan. The histogram is read-only. A midpoint-estimated mean is
+ * computed once at construction.
+ */
+public final class CumulativeHistogram {
+ private final Config config;
+ private final int[] index;
+ private final long[] count; // cumulative (prefix-sum) counts
+ private final double mean;
+ private final boolean hasMean;
+
+ private CumulativeHistogram(Config config, int[] index, long[] count) {
+ this.config = config;
+ this.index = index;
+ this.count = count;
+ double meanValue = 0.0;
+ boolean meanPresent = false;
+ if (count.length != 0 && count[count.length - 1] != 0) {
+ long total = count[count.length - 1];
+ double weighted = 0.0;
+ for (int i = 0; i < index.length; i++) {
+ long individual = individualCount(i);
+ double midpoint = (U64.toDouble(config.indexToLowerBound(index[i]))
+ + U64.toDouble(config.indexToUpperBound(index[i]))) / 2.0;
+ weighted += midpoint * U64.toDouble(individual);
+ }
+ meanValue = weighted / U64.toDouble(total);
+ meanPresent = true;
+ }
+ this.mean = meanValue;
+ this.hasMean = meanPresent;
+ }
+
+ /**
+ * Creates a {@code CumulativeHistogram} from raw parts. {@code count} must
+ * be cumulative (prefix sums).
+ *
+ * @throws IllegalArgumentException if the lengths differ, an index is out
+ * of range, the indices are not strictly ascending, the counts are not
+ * non-decreasing, or any count is zero
+ */
+ public static CumulativeHistogram fromParts(Config config, int[] index, long[] count) {
+ if (index.length != count.length) {
+ throw new IllegalArgumentException(
+ "index and count must have the same length ("
+ + index.length + " != " + count.length + ")");
+ }
+ int total = config.totalBuckets();
+ int prev = -1;
+ for (int i : index) {
+ if (i < 0 || i >= total) {
+ throw new IllegalArgumentException("index " + i + " out of range for config");
+ }
+ if (i <= prev) {
+ throw new IllegalArgumentException("indices must be strictly ascending");
+ }
+ prev = i;
+ }
+ long prevCount = 0;
+ for (long c : count) {
+ if (c == 0) {
+ throw new IllegalArgumentException("cumulative counts must be non-zero");
+ }
+ if (Long.compareUnsigned(c, prevCount) < 0) {
+ throw new IllegalArgumentException("cumulative counts must be non-decreasing");
+ }
+ prevCount = c;
+ }
+ return new CumulativeHistogram(config, index.clone(), count.clone());
+ }
+
+ /** Builds a {@code CumulativeHistogram} from a dense {@link Histogram}. */
+ public static CumulativeHistogram fromHistogram(Histogram histogram) {
+ long[] buckets = histogram.bucketsRef();
+ int nonzero = 0;
+ for (long c : buckets) {
+ if (c != 0) {
+ nonzero++;
+ }
+ }
+ int[] index = new int[nonzero];
+ long[] count = new long[nonzero];
+ int k = 0;
+ long running = 0;
+ for (int i = 0; i < buckets.length; i++) {
+ if (buckets[i] != 0) {
+ running += buckets[i];
+ index[k] = i;
+ count[k] = running;
+ k++;
+ }
+ }
+ return new CumulativeHistogram(histogram.config(), index, count);
+ }
+
+ /** Builds a {@code CumulativeHistogram} from a {@link SparseHistogram}. */
+ public static CumulativeHistogram fromSparse(SparseHistogram sparse) {
+ int[] index = sparse.indexRef().clone();
+ long[] sparseCount = sparse.countRef();
+ long[] cumulative = new long[sparseCount.length];
+ long running = 0;
+ for (int i = 0; i < sparseCount.length; i++) {
+ running += sparseCount[i];
+ cumulative[i] = running;
+ }
+ return new CumulativeHistogram(sparse.config(), index, cumulative);
+ }
+
+ private long individualCount(int position) {
+ if (position == 0) {
+ return count[0];
+ }
+ return count[position] - count[position - 1];
+ }
+
+ /** Returns the bucketing configuration. */
+ public Config config() {
+ return config;
+ }
+
+ /** Returns a copy of the non-zero bucket indices, ascending. */
+ public int[] index() {
+ return index.clone();
+ }
+
+ /** Returns a copy of the cumulative (prefix-sum) counts aligned with {@link #index()}. */
+ public long[] count() {
+ return count.clone();
+ }
+
+ /** Returns the number of stored (non-zero) buckets. */
+ public int size() {
+ return index.length;
+ }
+
+ /** Returns whether the histogram has no stored buckets. */
+ public boolean isEmpty() {
+ return index.length == 0;
+ }
+
+ /** Returns the total number of observations (unsigned). */
+ public long totalCount() {
+ if (count.length == 0) {
+ return 0;
+ }
+ return count[count.length - 1];
+ }
+
+ /**
+ * Returns the midpoint-estimated mean of all observations, or empty if the
+ * histogram is empty. It is computed once at construction.
+ */
+ public OptionalDouble mean() {
+ return hasMean ? OptionalDouble.of(mean) : OptionalDouble.empty();
+ }
+
+ /** Returns the first position where the cumulative count is {@code >= target}. */
+ private int findQuantilePosition(long target) {
+ int lo = 0;
+ int hi = count.length;
+ while (lo < hi) {
+ int mid = (lo + hi) >>> 1;
+ if (Long.compareUnsigned(count[mid], target) < 0) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ if (lo >= count.length) {
+ return count.length - 1;
+ }
+ return lo;
+ }
+
+ private Bucket bucketAt(int position) {
+ return new Bucket(individualCount(position),
+ config.indexToLowerBound(index[position]),
+ config.indexToUpperBound(index[position]));
+ }
+
+ /**
+ * Returns the bucket at {@code percentile} in {@code [0.0, 1.0]}, or empty
+ * if the histogram has no observations. The returned bucket carries the
+ * individual (non-cumulative) count.
+ *
+ * @throws IllegalArgumentException if the percentile is out of range
+ */
+ public Optional Values are quantized into buckets according to a {@link Config}
+ * determined by {@code groupingPower} and {@code maxValuePower}. This is the
+ * Java analogue of the Rust {@code Histogram} type and produces byte-for-byte
+ * identical bucketing, so histograms recorded here can be consumed by
+ * Rezolus (and vice versa).
+ *
+ * Values and counts are unsigned 64-bit integers carried in Java
+ * {@code long}s, so the full {@code u64} range is supported.
+ */
+public final class Histogram {
+ private final Config config;
+ private final long[] buckets;
+
+ /**
+ * Creates an empty histogram with the given {@code groupingPower} and
+ * {@code maxValuePower}.
+ *
+ * @throws IllegalArgumentException if the parameters are invalid (see
+ * {@link Config#Config(int, int)})
+ */
+ public Histogram(int groupingPower, int maxValuePower) {
+ this(new Config(groupingPower, maxValuePower));
+ }
+
+ /** Creates an empty histogram from an existing {@link Config}. */
+ public Histogram(Config config) {
+ this.config = config;
+ this.buckets = new long[config.totalBuckets()];
+ }
+
+ /**
+ * Creates a histogram from a full, dense array of bucket counts. The
+ * length of {@code buckets} must equal the config's
+ * {@link Config#totalBuckets()}.
+ *
+ * @throws IllegalArgumentException if the parameters are invalid or the
+ * length does not match
+ */
+ public static Histogram fromBuckets(int groupingPower, int maxValuePower, long[] buckets) {
+ Config config = new Config(groupingPower, maxValuePower);
+ if (buckets.length != config.totalBuckets()) {
+ throw new IllegalArgumentException(
+ "expected " + config.totalBuckets() + " buckets, got " + buckets.length);
+ }
+ Histogram h = new Histogram(config);
+ System.arraycopy(buckets, 0, h.buckets, 0, buckets.length);
+ return h;
+ }
+
+ /** Returns the bucketing configuration. */
+ public Config config() {
+ return config;
+ }
+
+ /** Returns a copy of the dense bucket counts (one entry per bucket). */
+ public long[] bucketCounts() {
+ return buckets.clone();
+ }
+
+ /** Returns the internal bucket array without copying. */
+ long[] bucketsRef() {
+ return buckets;
+ }
+
+ /** Returns the number of buckets. */
+ public int size() {
+ return buckets.length;
+ }
+
+ /** Returns the total number of observations recorded (unsigned). */
+ public long totalCount() {
+ long total = 0;
+ for (long c : buckets) {
+ total += c;
+ }
+ return total;
+ }
+
+ /**
+ * Adds one observation of {@code value}.
+ *
+ * @throws IllegalArgumentException if value is out of range for the histogram
+ */
+ public void increment(long value) {
+ record(value, 1);
+ }
+
+ /**
+ * Adds {@code count} observations of {@code value}.
+ *
+ * @throws IllegalArgumentException if value is out of range for the histogram
+ */
+ public void record(long value, long count) {
+ buckets[config.valueToIndex(value)] += count;
+ }
+
+ /**
+ * Records each value in {@code values} once.
+ *
+ * @throws IllegalArgumentException (stopping) if any value is out of range
+ */
+ public void recordMany(long[] values) {
+ for (long v : values) {
+ record(v, 1);
+ }
+ }
+
+ /**
+ * Records each value with the corresponding weight from {@code counts}.
+ *
+ * @throws IllegalArgumentException if the arrays differ in length or any
+ * value is out of range
+ */
+ public void recordMany(long[] values, long[] counts) {
+ if (values.length != counts.length) {
+ throw new IllegalArgumentException(
+ "values and counts must have the same length ("
+ + values.length + " != " + counts.length + ")");
+ }
+ for (int i = 0; i < values.length; i++) {
+ record(values[i], counts[i]);
+ }
+ }
+
+ // Bucket iteration ------------------------------------------------------
+
+ private Bucket bucketAt(int index) {
+ return new Bucket(buckets[index],
+ config.indexToLowerBound(index), config.indexToUpperBound(index));
+ }
+
+ /** Calls {@code action} for every bucket in ascending index order. */
+ public void forEachBucket(Consumer Only non-zero buckets are stored, as two parallel arrays {@code index}
+ * and {@code count} in ascending index order. This is the form Rezolus uses
+ * for its {@code :bucket_indices} / {@code :bucket_counts} parquet columns.
+ */
+public final class SparseHistogram {
+ private final Config config;
+ private final int[] index;
+ private final long[] count;
+
+ private SparseHistogram(Config config, int[] index, long[] count) {
+ this.config = config;
+ this.index = index;
+ this.count = count;
+ }
+
+ /** Builds a sparse histogram from a dense {@link Histogram}. */
+ public static SparseHistogram fromHistogram(Histogram histogram) {
+ long[] buckets = histogram.bucketsRef();
+ int nonzero = 0;
+ for (long c : buckets) {
+ if (c != 0) {
+ nonzero++;
+ }
+ }
+ int[] index = new int[nonzero];
+ long[] count = new long[nonzero];
+ int k = 0;
+ for (int i = 0; i < buckets.length; i++) {
+ if (buckets[i] != 0) {
+ index[k] = i;
+ count[k] = buckets[i];
+ k++;
+ }
+ }
+ return new SparseHistogram(histogram.config(), index, count);
+ }
+
+ /**
+ * Creates a sparse histogram from raw parts, validating invariants.
+ *
+ * @throws IllegalArgumentException if the lengths differ, an index is out
+ * of range, or the indices are not strictly ascending
+ */
+ public static SparseHistogram fromParts(Config config, int[] index, long[] count) {
+ if (index.length != count.length) {
+ throw new IllegalArgumentException(
+ "index and count must have the same length ("
+ + index.length + " != " + count.length + ")");
+ }
+ int total = config.totalBuckets();
+ int prev = -1;
+ for (int i : index) {
+ if (i < 0 || i >= total) {
+ throw new IllegalArgumentException("index " + i + " out of range for config");
+ }
+ if (i <= prev) {
+ throw new IllegalArgumentException("indices must be strictly ascending");
+ }
+ prev = i;
+ }
+ return new SparseHistogram(config, index.clone(), count.clone());
+ }
+
+ /** Returns the bucketing configuration. */
+ public Config config() {
+ return config;
+ }
+
+ /** Returns a copy of the non-zero bucket indices, ascending. */
+ public int[] index() {
+ return index.clone();
+ }
+
+ /** Returns a copy of the counts corresponding to {@link #index()}. */
+ public long[] count() {
+ return count.clone();
+ }
+
+ /** Returns the number of stored (non-zero) buckets. */
+ public int size() {
+ return index.length;
+ }
+
+ /** Returns whether the histogram has no stored buckets. */
+ public boolean isEmpty() {
+ return index.length == 0;
+ }
+
+ /** Returns the total number of observations (unsigned). */
+ public long totalCount() {
+ long total = 0;
+ for (long c : count) {
+ total += c;
+ }
+ return total;
+ }
+
+ /** Returns each stored bucket with its individual count. */
+ public List All values and counts in this library are {@code u64}, stored in Java
+ * {@code long}s with unsigned semantics ({@link Long#compareUnsigned},
+ * {@link Long#toUnsignedString}, ...). This gives the full {@code u64} value
+ * range, exactly like the Rust crate.
+ */
+final class U64 {
+ private U64() {}
+
+ private static final double TWO_POW_63 = 9.223372036854775808E18;
+
+ /** Converts an unsigned 64-bit value to the nearest {@code double}. */
+ static double toDouble(long value) {
+ if (value >= 0) {
+ return value;
+ }
+ return (double) (value >>> 1) * 2.0 + (value & 1L);
+ }
+
+ /** Converts a non-negative {@code double} to an unsigned 64-bit value. */
+ static long fromDouble(double value) {
+ if (value < TWO_POW_63) {
+ return (long) value;
+ }
+ return ((long) (value - TWO_POW_63)) + Long.MIN_VALUE;
+ }
+
+ /**
+ * Computes {@code max(1, ceil(p * total))} with {@code total} unsigned,
+ * matching the Rust crate's {@code max(1, (q * total).ceil())}.
+ */
+ static long ceilCount(double p, long total) {
+ double target = Math.ceil(p * toDouble(total));
+ if (target < 1.0) {
+ return 1;
+ }
+ return fromDouble(target);
+ }
+}
diff --git a/src/main/java/com/iopsystems/h2histogram/package-info.java b/src/main/java/com/iopsystems/h2histogram/package-info.java
new file mode 100644
index 0000000..0d9dc2e
--- /dev/null
+++ b/src/main/java/com/iopsystems/h2histogram/package-info.java
@@ -0,0 +1,29 @@
+/**
+ * A pure-Java implementation of the iopsystems h2 histogram.
+ *
+ * It produces histograms with byte-for-byte identical bucketing to the Rust
+ * histogram crate, so
+ * histograms recorded here can be consumed by (and interoperate with) Rezolus
+ * and the Python, Go, and JavaScript implementations.
+ *
+ * The bucketing strategy is fully determined by two parameters:
+ *
+ * The layout has two regions: a linear region covering
+ * {@code 0 .. 2^(groupingPower+1)} where every bucket has width 1 (exact), and
+ * a logarithmic region above the cutoff subdivided into
+ * {@code 2^groupingPower} buckets per power of two.
+ *
+ * Values and counts are unsigned 64-bit integers ({@code u64}) carried in
+ * Java {@code long}s with unsigned semantics, so the full {@code u64} range is
+ * supported, exactly like the Rust crate.
+ */
+package com.iopsystems.h2histogram;
diff --git a/src/test/java/com/iopsystems/h2histogram/ConfigTest.java b/src/test/java/com/iopsystems/h2histogram/ConfigTest.java
new file mode 100644
index 0000000..271b603
--- /dev/null
+++ b/src/test/java/com/iopsystems/h2histogram/ConfigTest.java
@@ -0,0 +1,111 @@
+package com.iopsystems.h2histogram;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Expected values here are copied verbatim from src/config.rs in
+ * https://github.com/iopsystems/histogram so we are guaranteed bit-identical
+ * bucketing.
+ */
+class ConfigTest {
+ private static final long U64_MAX = -1L; // 2^64 - 1, unsigned
+
+ @Test
+ void totalBuckets() {
+ assertEquals(252, new Config(2, 64).totalBuckets());
+ assertEquals(7424, new Config(7, 64).totalBuckets());
+ assertEquals(835_584, new Config(14, 64).totalBuckets());
+ assertEquals(12, new Config(2, 4).totalBuckets());
+ }
+
+ @Test
+ void valueToIndex() {
+ Config c = new Config(7, 64);
+ assertEquals(0, c.valueToIndex(0));
+ assertEquals(1, c.valueToIndex(1));
+ assertEquals(256, c.valueToIndex(256));
+ assertEquals(256, c.valueToIndex(257));
+ assertEquals(257, c.valueToIndex(258));
+ assertEquals(384, c.valueToIndex(512));
+ assertEquals(384, c.valueToIndex(515));
+ assertEquals(385, c.valueToIndex(516));
+ assertEquals(512, c.valueToIndex(1024));
+ assertEquals(512, c.valueToIndex(1031));
+ assertEquals(513, c.valueToIndex(1032));
+ assertEquals(7423, c.valueToIndex(U64_MAX - 1));
+ assertEquals(7423, c.valueToIndex(U64_MAX));
+ }
+
+ @Test
+ void indexToLowerBound() {
+ Config c = new Config(7, 64);
+ assertEquals(0, c.indexToLowerBound(0));
+ assertEquals(1, c.indexToLowerBound(1));
+ assertEquals(256, c.indexToLowerBound(256));
+ assertEquals(512, c.indexToLowerBound(384));
+ assertEquals(1024, c.indexToLowerBound(512));
+ assertEquals(Long.parseUnsignedLong("18374686479671623680"),
+ c.indexToLowerBound(7423));
+ }
+
+ @Test
+ void indexToUpperBound() {
+ Config c = new Config(7, 64);
+ assertEquals(0, c.indexToUpperBound(0));
+ assertEquals(1, c.indexToUpperBound(1));
+ assertEquals(257, c.indexToUpperBound(256));
+ assertEquals(515, c.indexToUpperBound(384));
+ assertEquals(1031, c.indexToUpperBound(512));
+ assertEquals(U64_MAX, c.indexToUpperBound(7423));
+ }
+
+ @Test
+ void roundtripValueIndexRange() {
+ Config c = new Config(7, 64);
+ long[] values = {0, 1, 5, 127, 128, 255, 256, 257, 999, 1_000_000, (1L << 40) + 7};
+ for (long v : values) {
+ int idx = c.valueToIndex(v);
+ long lo = c.indexToLowerBound(idx);
+ long hi = c.indexToUpperBound(idx);
+ assertTrue(Long.compareUnsigned(lo, v) <= 0 && Long.compareUnsigned(v, hi) <= 0,
+ "value " + v + " landed in bucket " + idx + " [" + lo + ", " + hi + "]");
+ }
+ }
+
+ @Test
+ void error() {
+ assertEquals(100.0 / 128, new Config(7, 64).error(), 1e-12);
+ // No logarithmic buckets -> zero error.
+ assertEquals(0.0, new Config(3, 4).error());
+ }
+
+ @Test
+ void invalidParams() {
+ assertThrows(IllegalArgumentException.class, () -> new Config(7, 65));
+ assertThrows(IllegalArgumentException.class, () -> new Config(64, 64));
+ assertThrows(IllegalArgumentException.class, () -> new Config(10, 5));
+ assertThrows(IllegalArgumentException.class, () -> new Config(-1, 4));
+ }
+
+ @Test
+ void fromTotalBuckets() {
+ Config c = Config.fromTotalBuckets(7424, 64);
+ assertEquals(7, c.groupingPower());
+ assertEquals(64, c.maxValuePower());
+ // Rezolus default.
+ Config c2 = Config.fromTotalBuckets(496, 64);
+ assertEquals(3, c2.groupingPower());
+ assertThrows(IllegalArgumentException.class, () -> Config.fromTotalBuckets(7425, 64));
+ }
+
+ @Test
+ void outOfRange() {
+ Config c = new Config(2, 4); // max = 15
+ assertEquals(c.totalBuckets() - 1, c.valueToIndex(15));
+ assertThrows(IllegalArgumentException.class, () -> c.valueToIndex(16));
+ }
+}
diff --git a/src/test/java/com/iopsystems/h2histogram/CumulativeHistogramTest.java b/src/test/java/com/iopsystems/h2histogram/CumulativeHistogramTest.java
new file mode 100644
index 0000000..3f1eff9
--- /dev/null
+++ b/src/test/java/com/iopsystems/h2histogram/CumulativeHistogramTest.java
@@ -0,0 +1,128 @@
+package com.iopsystems.h2histogram;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+import java.util.OptionalDouble;
+import org.junit.jupiter.api.Test;
+
+class CumulativeHistogramTest {
+
+ @Test
+ void fromHistogram() {
+ Histogram h = new Histogram(7, 64);
+ h.record(1, 2);
+ h.record(500, 3);
+ h.record(1_000_000, 5);
+
+ CumulativeHistogram c = h.toCumulative();
+ assertEquals(10, c.totalCount());
+ assertEquals(3, c.size());
+ // Cumulative counts are prefix sums; last equals total.
+ assertArrayEquals(new long[] {2, 5, 10}, c.count());
+ }
+
+ @Test
+ void percentileMatchesDense() {
+ Histogram h = new Histogram(7, 64);
+ for (long i = 1; i <= 1000; i++) {
+ h.increment(i);
+ }
+ CumulativeHistogram c = h.toCumulative();
+ for (double q : new double[] {0.0, 0.1, 0.5, 0.9, 0.99, 1.0}) {
+ Optional
+ *
+ *
+ *