This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-rng.git


The following commit(s) were added to refs/heads/master by this push:
     new 05b4f366 RNG-203: Add a zeta distribution sampler
05b4f366 is described below

commit 05b4f3661349b0de1559a79d822cefef460d3252
Author: Alex Herbert <[email protected]>
AuthorDate: Fri Sep 18 12:51:49 2026 +0100

    RNG-203: Add a zeta distribution sampler
---
 .../rng/sampling/distribution/ZetaSampler.java     | 222 +++++++++++++++++++++
 .../DiscreteSamplerParametricTest.java             |  23 ++-
 .../distribution/DiscreteSamplerTestData.java      |  55 ++++-
 .../distribution/DiscreteSamplersList.java         |  72 ++++++-
 .../rng/sampling/distribution/ZetaSamplerTest.java | 180 +++++++++++++++++
 src/changes/changes.xml                            |   3 +
 6 files changed, 537 insertions(+), 18 deletions(-)

diff --git 
a/commons-rng-sampling/src/main/java/org/apache/commons/rng/sampling/distribution/ZetaSampler.java
 
b/commons-rng-sampling/src/main/java/org/apache/commons/rng/sampling/distribution/ZetaSampler.java
new file mode 100644
index 00000000..8e30c8cc
--- /dev/null
+++ 
b/commons-rng-sampling/src/main/java/org/apache/commons/rng/sampling/distribution/ZetaSampler.java
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.rng.sampling.distribution;
+
+import java.util.function.LongToDoubleFunction;
+import org.apache.commons.rng.UniformRandomProvider;
+
+/**
+ * Sampling from a zeta distribution.
+ *
+ * <p>Note that the zeta distribution has an upper limit of positive infinity. 
This
+ * implementation is clipped to {@link Integer#MAX_VALUE}. As the exponent \( 
s \to 1 \)
+ * sampling will become biased to 2<sup>31</sup> - 1 as the distribution 
density is
+ * truncated by the {@code int} limit. When the exponent is large the density
+ * of the zeta distribution is concentrated at 1. In {@code double} precision 
this
+ * limits the rejection method to {@code s <= 54}; larger {@code s} will 
always sample 1.
+ *
+ * <p>Rejection sampling method for a zeta distribution adapted from:
+ * <blockquote>
+ *   Luc Devroye (1986)
+ *   <i>"Non-uniform random variate generation",</i><br>
+ *   <strong>Springer New York, NY</strong> pp 550-552.
+ * </blockquote>
+ *
+ * <p>Sampling uses {@link UniformRandomProvider#nextLong()}.</p>
+ *
+ * @see <a href="https://en.wikipedia.org/wiki/Zeta_distribution";>Zeta 
distribution (Wikipedia)</a>
+ * @since 1.4
+ */
+public final class ZetaSampler {
+    /** The minimum exponent. The exponent must be above 1. */
+    private static final double MIN_EXPONENT = Math.nextUp(1.0);
+    /**
+     * The maximum exponent for the rejection sampler.
+     * Note that if the sampler uses u in (0, 1] to bias towards 1 then the 
sample
+     * x = floor ( U^{-1/(a-1)} ) is always 1 when a is large.
+     * The threshold for a is 54 if u=2^-53.
+     * Note the zeta distribution CDF(x=1; s=54) = 1.0 in {@code double} 
precision.
+     */
+    private static final double REJECTION_EXPONENT = 54;
+
+    /**
+     * Sample from the zeta distribution using a rejection method.
+     * Package-private for testing.
+     */
+    static final class RejectionZetaSampler implements 
SharedStateDiscreteSampler {
+        /**
+         * The threshold to bias the extreme sample to 1 or infinity. Change 
the
+         * extreme sample of the zeta distribution using the midpoint of the 
support
+         * domain, i.e. x = 2^31 / 2; cdf(x; a) = sf(x; a) ~ 0.5.
+         */
+        private static final double THRESHOLD = 1.0324376395045163;
+        /** ln(2). */
+        private static final double LN2 = Math.log(2.0);
+
+        /** Source of randomness. */
+        private final UniformRandomProvider rng;
+        /** a - 1. */
+        private final double am1;
+        /** Reciprocal of (a - 1) = 1 / (a - 1). */
+        private final double ram1;
+        /** 2^(a-1) / (2^(a-1) - 1). */
+        private final double bObm1;
+        /** Function to compute u in [0, 1]. */
+        private final LongToDoubleFunction nextU;
+
+        /**
+         * Create an instance.
+         *
+         * @param rng Source of randomness.
+         * @param a Exponent of the zeta distribution ({@code a > 1}).
+         */
+        RejectionZetaSampler(UniformRandomProvider rng, double a) {
+            this.rng = rng;
+            am1 = a - 1;
+            ram1 = 1 / am1;
+            // b = 2^(a-1)
+            // This will not overflow within the usable range of the algorithm.
+            // We never expect infinity / infinity = NaN.
+            final double b = Math.exp(LN2 * am1);
+            final double bm1 = Math.expm1(LN2 * am1);
+            bObm1 = b / bm1;
+            // Note:
+            // u in [0, 1]
+            // u == 0 : x == inf
+            // u == 1 : x == 1
+            // When a -> 1 then bias to infinity; otherwise bias to 1.
+            nextU = a <= THRESHOLD ?
+                // u in [0, 1)
+                InternalUtils::makeDouble :
+                // u in (0, 1]
+                InternalUtils::makeNonZeroDouble;
+        }
+
+        /**
+         * Copy constructor.
+         *
+         * @param rng Source of randomness.
+         * @param source Source to copy.
+         */
+        private RejectionZetaSampler(UniformRandomProvider rng, 
RejectionZetaSampler source) {
+            this.rng = rng;
+            am1 = source.am1;
+            ram1 = source.ram1;
+            bObm1 = source.bObm1;
+            nextU = source.nextU;
+        }
+
+        @Override
+        public int sample() {
+            double u;
+            double v;
+            double x;
+            double t;
+            double tm1;
+            for (;;) {
+                // Generate iid uniform [0, 1] random variate U, V.
+                // Sampling only uses nextLong.
+                u = nextU.applyAsDouble(rng.nextLong());
+                v = InternalUtils.makeDouble(rng.nextLong());
+
+                // X = floor ( U^{-1/(a-1)} ) , X in [1, inf]
+                x = Math.floor(Math.pow(u, -ram1));
+
+                // T = (1 + 1/x)^(a-1)
+                // This can create T ~ 1 for large X so
+                // avoid precision loss using exp(log(1 + 1/x) * (a-1)).
+                t = Math.log1p(1 / x) * am1;
+                tm1 = Math.expm1(t);
+                t = Math.exp(t);
+
+                // Until:
+                //    T-1    T
+                // VX --- <= -
+                //    b-1    b
+
+                // Note: If X==infinity then T==1.
+                // Avoid infinity * (T - 1) == NaN by rearrangement:
+                //     b      T
+                // VX --- <= ---
+                //    b-1    T-1
+
+                if (v * x * bObm1 <= t / tm1) {
+                    // Truncates x >= 2^31 to integer max
+                    return (int) x;
+                }
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String toString() {
+            return "Zeta deviate [" + rng.toString() + "]";
+        }
+
+        @Override
+        public SharedStateDiscreteSampler 
withUniformRandomProvider(UniformRandomProvider rng) {
+            return new RejectionZetaSampler(rng, this);
+        }
+    }
+
+    /**
+     * Sample from the zeta distribution when the density is entirely 
concentrated at x=1.
+     */
+    private static final class LargeExponentZetaSampler implements 
SharedStateDiscreteSampler {
+        /** The single instance. */
+        static final LargeExponentZetaSampler INSTANCE = new 
LargeExponentZetaSampler();
+
+        @Override
+        public int sample() {
+            return 1;
+        }
+
+        @Override
+        public String toString() {
+            return "Zeta(x=1) deviate";
+        }
+
+        @Override
+        public SharedStateDiscreteSampler 
withUniformRandomProvider(UniformRandomProvider rng) {
+            // No requirement for a new instance
+            return this;
+        }
+    }
+
+    /** Class contains only static methods. */
+    private ZetaSampler() {}
+
+    /**
+     * Creates a new zeta distribution sampler.
+     *
+     * <p>When {@code s > 54} the sample will always be 1. See the {@linkplain 
ZetaSampler
+     * class-level} documentation for details.
+     *
+     * @param rng Generator of uniformly distributed random numbers.
+     * @param exponent Exponent.
+     * @return the sampler
+     * @throws IllegalArgumentException if {@code exponent <= 1} or is 
non-finite.
+     */
+    public static SharedStateDiscreteSampler of(UniformRandomProvider rng,
+                                                double exponent) {
+        InternalUtils.requireRangeClosed(MIN_EXPONENT, 
Double.POSITIVE_INFINITY, exponent, "exponent");
+        return exponent <= REJECTION_EXPONENT ?
+            new RejectionZetaSampler(rng, exponent) :
+            LargeExponentZetaSampler.INSTANCE;
+    }
+}
diff --git 
a/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplerParametricTest.java
 
b/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplerParametricTest.java
index a972464a..0cbc488e 100644
--- 
a/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplerParametricTest.java
+++ 
b/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplerParametricTest.java
@@ -28,6 +28,13 @@ import org.junit.jupiter.params.provider.MethodSource;
  * Tests for random deviates generators.
  */
 class DiscreteSamplerParametricTest {
+
+    /** Method to test two integer values. */
+    @FunctionalInterface
+    interface IntIntBiPredicate {
+        boolean test(int a, int b);
+    }
+
     private static Iterable<DiscreteSamplerTestData> getSamplerTestData() {
         return DiscreteSamplersList.list();
     }
@@ -40,7 +47,8 @@ class DiscreteSamplerParametricTest {
         check(sampleSize,
               data.getSampler(),
               data.getPoints(),
-              data.getProbabilities());
+              data.getProbabilities(),
+              data.isRange());
     }
 
     /**
@@ -53,11 +61,13 @@ class DiscreteSamplerParametricTest {
      * @param sampleSize Number of random values to generate.
      * @param points Outcomes.
      * @param expected Expected counts of the given outcomes.
+     * @param range True if the probabilities are for a range.
      */
     private static void check(long sampleSize,
                               DiscreteSampler sampler,
                               int[] points,
-                              double[] expected) {
+                              double[] expected,
+                              boolean range) {
         final ChiSquareTest chiSquareTest = new ChiSquareTest();
         final int numTests = 50;
 
@@ -67,6 +77,13 @@ class DiscreteSamplerParametricTest {
         final int numBins = points.length;
         final long[] observed = new long[numBins];
 
+        // Support testing a probability mass function PMF(x)
+        // or a range p(x_i < X < x_i+1). If points are sorted
+        // we only require comparing using <= points[i].
+        final IntIntBiPredicate test = range ?
+            (x, y) -> x <= y :
+            (x, y) -> x == y;
+
         // For storing chi2 larger than the critical value.
         final List<Double> failedStat = new ArrayList<>();
         try {
@@ -76,7 +93,7 @@ class DiscreteSamplerParametricTest {
                     final int value = sampler.sample();
 
                     for (int k = 0; k < numBins; k++) {
-                        if (value == points[k]) {
+                        if (test.test(value, points[k])) {
                             ++observed[k];
                             continue SAMPLE;
                         }
diff --git 
a/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplerTestData.java
 
b/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplerTestData.java
index 3a7ef85c..11bfbf54 100644
--- 
a/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplerTestData.java
+++ 
b/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplerTestData.java
@@ -16,22 +16,42 @@
  */
 package org.apache.commons.rng.sampling.distribution;
 
-import java.util.Arrays;
+import org.junit.jupiter.api.Assertions;
 
 /**
  * Data store for {@link DiscreteSamplerParametricTest}.
+ *
+ * <p>By default the probabilities are for {@code p_i(x_i)} where {@code x_i} 
is the point.
+ * The data should contain probabilities for the entire range of {@code x} 
expected to
+ * be produced by the sampler.
+ *
+ * <p>The data can be used to represent a cumulative probability distribution. 
Points
+ * must be sorted and the probabilities correspond to:
+ * <pre>{@code
+ *  P_0(X <= x_0)
+ *  P_i+1(x_i < X <= x_i+1) , i in [0, n)
+ * }</pre>
  */
 class DiscreteSamplerTestData {
     private final DiscreteSampler sampler;
     private final int[] points;
     private final double[] probabilities;
+    private final boolean range;
 
     DiscreteSamplerTestData(DiscreteSampler sampler,
                             int[] points,
-                            double[] probabilities) {
+                            double[] probabilities,
+                            boolean range) {
         this.sampler = sampler;
         this.points = points.clone();
         this.probabilities = probabilities.clone();
+        this.range = range;
+        if (range) {
+            for (int k = 1; k < points.length; k++) {
+                Assertions.assertTrue(points[k - 1] < points[k],
+                    "Points must be sorted for range probabilities");
+            }
+        }
     }
 
     public DiscreteSampler getSampler() {
@@ -46,13 +66,36 @@ class DiscreteSamplerTestData {
         return probabilities.clone();
     }
 
+    /**
+     * Check if the probabilities are for a range. Points must be sorted.
+     *
+     * @return true if the probabilities are for a range
+     * @since 1.8
+     */
+    public boolean isRange() {
+        return range;
+    }
+
     @Override
     public String toString() {
+        final StringBuilder sb = new StringBuilder(2048)
+            .append(sampler.toString()).append(':');
         final int len = points.length;
-        final String[] p = new String[len];
-        for (int i = 0; i < len; i++) {
-            p[i] = "p(" + points[i] + ")=" + probabilities[i];
+        if (range) {
+            sb.append(" p(<=").append(points[0]).append(")=")
+                .append(probabilities[0]);
+            for (int i = 1; i < len; i++) {
+                // Use a half-open interval, e.g. p((4,5])=
+                sb.append(" p((").append(points[i - 1]).append(',')
+                    .append(points[i]).append("])=")
+                    .append(probabilities[i]);
+            }
+        } else {
+            for (int i = 0; i < len; i++) {
+                sb.append(" p(").append(points[i]).append(")=")
+                    .append(probabilities[i]);
+            }
         }
-        return sampler.toString() + ": " + Arrays.toString(p);
+        return sb.toString();
     }
 }
diff --git 
a/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplersList.java
 
b/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplersList.java
index 0db0e500..1128520d 100644
--- 
a/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplersList.java
+++ 
b/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/DiscreteSamplersList.java
@@ -122,7 +122,7 @@ public final class DiscreteSamplersList {
                 MathArrays.sequence(5, 1, 1),
                 RejectionInversionZipfSampler.of(RandomAssert.createRNG(), 
numElementsZipf, exponentCloseToOneZipf));
             // Zipf (exponent = 0).
-            add(LIST, MathArrays.sequence(5, 1, 1), new double[] {0.2, 0.2, 
0.2, 0.2, 0.2},
+            add(LIST, MathArrays.sequence(5, 1, 1), new double[] {0.2, 0.2, 
0.2, 0.2, 0.2}, false,
                 RejectionInversionZipfSampler.of(RandomAssert.createRNG(), 
numElementsZipf, 0.0));
 
             // Poisson ("inverse method").
@@ -180,16 +180,66 @@ public final class DiscreteSamplersList {
             final int[] discretePoints = {0, 1, 2, 3, 4};
             final double[] discreteProbabilities = {0.1, 0.2, 0.3, 0.4, 0.5};
             final long[] discreteFrequencies = {1, 2, 3, 4, 5};
-            add(LIST, discretePoints, discreteProbabilities,
+            add(LIST, discretePoints, discreteProbabilities, false,
                 
MarsagliaTsangWangDiscreteSampler.Enumerated.of(RandomAssert.createRNG(), 
discreteProbabilities));
-            add(LIST, discretePoints, discreteProbabilities,
+            add(LIST, discretePoints, discreteProbabilities, false,
                 GuideTableDiscreteSampler.of(RandomAssert.createRNG(), 
discreteProbabilities));
-            add(LIST, discretePoints, discreteProbabilities,
+            add(LIST, discretePoints, discreteProbabilities, false,
                 AliasMethodDiscreteSampler.of(RandomAssert.createRNG(), 
discreteProbabilities));
-            add(LIST, discretePoints, discreteProbabilities,
+            add(LIST, discretePoints, discreteProbabilities, false,
                 
FastLoadedDiceRollerDiscreteSampler.of(RandomAssert.createRNG(), 
discreteFrequencies));
-            add(LIST, discretePoints, discreteProbabilities,
+            add(LIST, discretePoints, discreteProbabilities, false,
                 
FastLoadedDiceRollerDiscreteSampler.of(RandomAssert.createRNG(), 
discreteProbabilities));
+
+            // Zeta distribution: x in [1, infinity].
+            // Range points generated with scipy.stats (1.18.1) zipf(s).
+            // from scipy.stats import zipf
+            // import numpy as np; np.set_printoptions(precision=17)
+            // x = np.unique(zipf(s).ppf([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 
0.8, 0.9])).astype(int)
+            // p = np.append(zipf(s).cdf(x), [1])
+            // Convert cumulative to range probabilities:
+            // p[1:] -= p[:-1]
+
+            // s = 1.15
+            add(LIST,
+                new int[] {1, 3, 6, 17, 58, 256, 1742, 26005, 2641987, 
Integer.MAX_VALUE},
+                new double[] {
+                    0.13784177793759045, 0.10108148525222727, 
0.06720522534524914,
+                    0.09570277096807939, 0.09903685404346751, 
0.09925485973116599,
+                    0.09988225352737812, 0.09999485138279085, 
0.09999992334440055,
+                    0.09999999846765073},
+                true,
+                ZetaSampler.of(RandomAssert.createRNG(), 1.15));
+            // s = 1.35. Skewed to 1
+            add(LIST,
+                new int[] {1, 2, 4, 8, 18, 58, 417, Integer.MAX_VALUE},
+                new double[] {
+                    0.28908106624157415, 0.11340420378808813, 
0.11008788688054949,
+                    0.09700240404611993, 0.09297268629333943, 
0.09863885643999604,
+                    0.09887693680911303, 0.0999359595012198},
+                true,
+                ZetaSampler.of(RandomAssert.createRNG(), 1.35));
+            // s = 2.35. Very skewed to 1
+            add(LIST,
+                new int[] {1, 2, 3, Integer.MAX_VALUE},
+                new double[] {
+                    0.7107946035897981, 0.13941953571184507, 
0.0537661789726942,
+                    0.09601968172566266},
+                true,
+                ZetaSampler.of(RandomAssert.createRNG(), 2.35));
+            // s = 1.02. Skewed to infinity. Tests sampler with truncation of 
the distribution.
+            // scipy zipf is too slow to explore small s as it sums the pmf 
for the cdf.
+            // Computed using CDF=(zeta(s, x+1) - zeta(s)) / zeta(s) using the
+            // the zeta function from mpmath (1.14.1) with x=[2**3, 2**7, 
2**11, 2**15, 2**20, 2**25]
+            // and 50 digits of precision.
+            add(LIST,
+                new int[] {8, 128, 2048, 32768, 1048576, 33554432, 
Integer.MAX_VALUE},
+                new double[] {0.05287102382096685, 0.0500627338177098, 
0.04832778811226474,
+                    0.04577927420430394, 0.05377155271772868, 
0.05017084772170899,
+                    // Most of the density is above 2^31
+                    0.699016779605317},
+                true,
+                ZetaSampler.of(RandomAssert.createRNG(), 1.02));
         } catch (Exception e) {
             // CHECKSTYLE: stop Regexp
             System.err.println("Unexpected exception while creating the list 
of samplers: " + e);
@@ -228,7 +278,8 @@ public final class DiscreteSamplersList {
                 });
         list.add(new DiscreteSamplerTestData(inverseMethodSampler,
                                              points,
-                                             getProbabilities(dist, points)));
+                                             getProbabilities(dist, points),
+                                             false));
     }
 
     /**
@@ -243,7 +294,8 @@ public final class DiscreteSamplersList {
                             final DiscreteSampler sampler) {
         list.add(new DiscreteSamplerTestData(sampler,
                                              points,
-                                             getProbabilities(dist, points)));
+                                             getProbabilities(dist, points),
+                                             false));
     }
 
     /**
@@ -255,10 +307,12 @@ public final class DiscreteSamplersList {
     private static void add(List<DiscreteSamplerTestData> list,
                             int[] points,
                             final double[] probabilities,
+                            boolean range,
                             final DiscreteSampler sampler) {
         list.add(new DiscreteSamplerTestData(sampler,
                                              points,
-                                             probabilities));
+                                             probabilities,
+                                             range));
     }
 
     /**
diff --git 
a/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/ZetaSamplerTest.java
 
b/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/ZetaSamplerTest.java
new file mode 100644
index 00000000..3c91a3a9
--- /dev/null
+++ 
b/commons-rng-sampling/src/test/java/org/apache/commons/rng/sampling/distribution/ZetaSamplerTest.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.rng.sampling.distribution;
+
+import java.time.Duration;
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.sampling.RandomAssert;
+import 
org.apache.commons.rng.sampling.distribution.ZetaSampler.RejectionZetaSampler;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Test for the {@link ZetaSampler}. The tests hit edge cases for the sampler.
+ */
+class ZetaSamplerTest {
+    /**
+     * Test the constructor with an {@code exponent <= 1}.
+     */
+    @ParameterizedTest
+    @ValueSource(doubles = {-1, 0, 1, Double.NaN})
+    void testConstructorThrowsWithBadExponent(double s) {
+        final UniformRandomProvider rng = RandomAssert.seededRNG();
+        Assertions.assertThrows(IllegalArgumentException.class,
+            () -> ZetaSampler.of(rng, s));
+    }
+
+    /**
+     * Test the SharedStateSampler implementation with exponents that are 
above and below
+     * the threshold to switch the sampler bias from 1 to infinity.
+     */
+    @ParameterizedTest
+    @ValueSource(doubles = {1.01, 1.1, 1.3, 2, 100})
+    void testSharedStateSampler(double s) {
+        final UniformRandomProvider rng1 = RandomAssert.seededRNG();
+        final UniformRandomProvider rng2 = RandomAssert.seededRNG();
+        final SharedStateDiscreteSampler sampler1 = ZetaSampler.of(rng1, s);
+        final SharedStateDiscreteSampler sampler2 = 
sampler1.withUniformRandomProvider(rng2);
+        RandomAssert.assertProduceSameSequence(sampler1, sampler2);
+    }
+
+    /**
+     * Test a large exponent biases towards 1.
+     *
+     * <p>Note: This uses a package-private constructor for the rejection 
sampler.
+     * The test validates that an exponent above 54 does not sample values 
other
+     * than 1. If the exponent is too large (e.g. above 1025) then the sampler 
will
+     * overflow to infinity when computing 2^(a-1). This will result in inf / 
inf
+     * division producing a NaN and infinite recursion. This limits the 
exponent used
+     * by this test.
+     *
+     * <p>In practice the factory method knows the rejection method will not 
work
+     * and returns a sampler that always returns 1.
+     */
+    @ParameterizedTest
+    @ValueSource(doubles = {54.1, 100})
+    void testLargeExponent(double exponent) {
+        // x = floor ( U^{-1/(a-1)} )
+        // Bias towards 1 has U in (0, 1] to avoid U=0 where x=infinity
+        // Threshold at 1.9999... = pow(2^-53, -(1 / (a - 1))
+        // a = 1 - Math.log(0x1p-53) / Math.log(Math.nextDown(2.0)) = 
54.00000000000001
+        // The sample should always be 1
+        final int expected = 1;
+        UniformRandomProvider rng;
+        DiscreteSampler s;
+        // u from first long : Can be [0, 1) or (0, 1]
+        // v from second long should control rejection
+        rng = createRNG(0, -1, 0, -1, 0, -1);
+        s = new RejectionZetaSampler(rng, exponent);
+        for (int i = 0; i < 3; i++) {
+            Assertions.assertEquals(expected, s.sample());
+        }
+        rng = createRNG(-1, -1, -1, -1, -1, -1);
+        s = new RejectionZetaSampler(rng, exponent);
+        for (int i = 0; i < 3; i++) {
+            Assertions.assertEquals(expected, s.sample());
+        }
+        // Any RNG
+        s = new RejectionZetaSampler(RandomAssert.createRNG(), exponent);
+        for (int i = 0; i < 100; i++) {
+            Assertions.assertEquals(expected, s.sample());
+        }
+    }
+
+    /**
+     * Test a tiny exponent cannot avoid small samples even though it should
+     * bias towards infinity.
+     */
+    @Test
+    void testTinyExponent() {
+        final double exponent = Math.nextUp(1.0);
+        // x = floor ( U^{-1/(a-1)} )
+        // Bias towards infinity has U in [0, 1) to avoid U=1
+        // But this cannot avoid small samples when U is close to 1.
+        // Math.pow(1.0, -(1/0x1p-52))  = 1
+        // Math.pow(Math.nextDown(1.0), -(1/0x1p-52)) = 1.6487212707001282
+        // Math.pow(Math.nextDown(Math.nextDown(1.0)), -(1/0x1p-52)) = 
2.7182818284590455
+        // Math.pow(Math.nextDown(Math.nextDown(Math.nextDown(1.0))), 
-(1/0x1p-52)) = 4.481689070338066
+        final UniformRandomProvider rng = createRNG(
+            0, -1,   // u=0.0; v~1.0
+            -1, -1,  // u=1.0 - 2^-53; v~1.0
+
+            // Note: These lead to rejection in the current implementation 
when v is high.
+            // So here we lower v to allow the rejection test to pass.
+            -2L << 11, -1L >>> 1,  // u=1.0 - 2 * 2^-53; v~0.5
+            -3L << 11, -1L >>> 1   // u=1.0 - 3 * 2^-53; v~0.5
+        );
+        final DiscreteSampler s = ZetaSampler.of(rng, exponent);
+        Assertions.assertEquals(Integer.MAX_VALUE, s.sample());
+        // Expected samples from values above: 1.645; 2.718; 4.481.
+        Assertions.assertEquals(1, s.sample());
+        Assertions.assertEquals(2, s.sample());
+        Assertions.assertEquals(4, s.sample());
+
+        // Test the sampler can output samples (i.e. do not reject forever 
with other u in [0, 1).
+        final int[][] sample = new int[1][];
+        UniformRandomProvider rng2 = RandomSource.L128_X256_MIX.create();
+        Assertions.assertTimeoutPreemptively(Duration.ofMillis(100), () ->
+            sample[0] = ZetaSampler.of(rng2, exponent).samples(1000).toArray()
+        );
+        // The survival function of the zeta distribution with tiny s:
+        // sf(x=2147483647; s=1 + 2^-52) = 0.9999999999999951
+        // We expect the sample to be at the upper bound.
+        // Use of a fresh RNG for repeat invocation if the test fails should 
ensure
+        // this passes. Otherwise consider a fixed seed.
+        for (final int x : sample[0]) {
+            Assertions.assertEquals(Integer.MAX_VALUE, x, "Consider using a 
fixed RNG seed");
+        }
+    }
+
+    /**
+     * Test the toString method for cases not hit in the rest of the test 
suite.
+     * This test asserts the toString method always contains the string 'zeta'.
+     */
+    @ParameterizedTest
+    @ValueSource(doubles = {1.23, 100})
+    void testToString(double exponent) {
+        final UniformRandomProvider rng = RandomAssert.seededRNG();
+        final String s = ZetaSampler.of(rng, 
exponent).toString().toLowerCase();
+        Assertions.assertTrue(s.contains("zeta"));
+    }
+
+    /**
+     * Creates the RNG to return the given values from the nextLong() method.
+     *
+     * @param values Long values
+     * @return the RNG
+     */
+    private static UniformRandomProvider createRNG(long... values) {
+        return new UniformRandomProvider() {
+            private int i;
+
+            @Override
+            public long nextLong() {
+                return values[i++];
+            }
+
+            @Override
+            public double nextDouble() {
+                throw new IllegalStateException("nextDouble cannot be trusted 
to be in [0, 1) and should be ignored");
+            }
+        };
+    }
+}
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 591e7e54..606b6f4b 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -56,6 +56,9 @@ If the output is not quite correct, check for invisible 
trailing spaces!
     <release version="1.8" date="TBD" description="
 New features, updates and bug fixes (requires Java 8).
 ">
+      <action dev="aherbert" type="add" due-to="Alex Herbert" issue="RNG-203">
+        "ZetaSampler": Add a sampler for the zeta distribution.
+      </action>
       <action dev="aherbert" type="update" due-to="Security scan, Alex 
Herbert" issue="RNG-202">
         "AliasMethodDiscreteSampler" and "GuideTableDiscreteSampler": Improve
         documentation of the effects of the scaling factor on the internal 
table

Reply via email to