From a40e90c4e97fb18ec6c20cf9fdb42f7d62194c75 Mon Sep 17 00:00:00 2001
From: algo-hawk <135367680+algo-hawk@users.noreply.github.com>
Date: Tue, 29 Sep 2026 21:04:06 +0000
Subject: [PATCH 1/2] Add Kronecker discrepancy regression tests (#612)
Pins the shift-dependence/NaN bug in Kronecker.periodic_discrepancy
with expectedFailure tests. Includes broader unit-test coverage for
the Kronecker sequence construction, generating vectors, randomization,
and spawning.
---
test/test_kronecker.py | 303 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 303 insertions(+)
create mode 100644 test/test_kronecker.py
diff --git a/test/test_kronecker.py b/test/test_kronecker.py
new file mode 100644
index 000000000..86232fab9
--- /dev/null
+++ b/test/test_kronecker.py
@@ -0,0 +1,303 @@
+from qmcpy import Kronecker
+from qmcpy.util import ParameterError
+
+import unittest
+import warnings
+
+import numpy as np
+import numpy.testing as npt
+
+
+class TestKroneckerConstruction(unittest.TestCase):
+ """Unit tests for the additive recurrence defining the Kronecker sequence."""
+
+ def test_matches_additive_recurrence_definition(self):
+ gen_vec = 2 ** (np.arange(1, 4) / 4)
+ dd = Kronecker(3, generating_vector=gen_vec, randomize=False)
+ x = dd.gen_samples(6)
+ expected = (np.arange(6)[:, None] * gen_vec[None, :]) % 1
+ npt.assert_allclose(x, expected)
+
+ def test_matches_definition_with_replications(self):
+ dd = Kronecker(3, generating_vector="SUZUKI", replications=2, seed=7)
+ x = dd.gen_samples(5)
+ expected = (
+ np.arange(5)[:, None] * dd.gen_vec[:, None, :] + dd.shift[:, None, :]
+ ) % 1
+ self.assertEqual(x.shape, (2, 5, 3))
+ npt.assert_allclose(x, expected)
+
+ def test_points_lie_in_unit_cube(self):
+ for gen_vec_source in ["CBC", "RICHTMYER", "SUZUKI"]:
+ x = Kronecker(4, generating_vector=gen_vec_source, seed=7).gen_samples(32)
+ self.assertTrue((x >= 0).all() and (x < 1).all())
+
+ def test_first_point_equals_shift(self):
+ shift = [0.1, 0.2, 0.3]
+ dd = Kronecker(3, generating_vector="SUZUKI", shift=shift)
+ npt.assert_allclose(dd.gen_samples(1)[0], shift)
+
+ def test_unrandomized_sequence_starts_at_origin(self):
+ x = Kronecker(3, generating_vector="SUZUKI", randomize=False).gen_samples(1)
+ npt.assert_allclose(x, np.zeros((1, 3)))
+
+ def test_n_min_n_max_slices_the_sequence(self):
+ dd = Kronecker(3, seed=7)
+ npt.assert_allclose(dd.gen_samples(n_min=2, n_max=5), dd.gen_samples(5)[2:])
+
+ def test_subset_dimensions_select_generating_vector_components(self):
+ gen_vec = 2 ** (np.arange(1, 4) / 4)
+ full = Kronecker(3, generating_vector=gen_vec, randomize=False).gen_samples(4)
+ subset = Kronecker(
+ [0, 2], generating_vector=gen_vec, randomize=False
+ ).gen_samples(4)
+ self.assertEqual(subset.shape, (4, 2))
+ npt.assert_allclose(subset, full[:, [0, 2]])
+
+
+class TestKroneckerGeneratingVector(unittest.TestCase):
+ """Unit tests for generating vector selection and its reported source."""
+
+ def test_gen_vec_source_labels(self):
+ self.assertEqual(Kronecker(3, generating_vector="CBC").gen_vec_source, "CBC")
+ self.assertEqual(
+ Kronecker(3, generating_vector="RICHTMYER").gen_vec_source, "RICHTMYER"
+ )
+ self.assertEqual(
+ Kronecker(3, generating_vector="SUZUKI").gen_vec_source, "SUZUKI"
+ )
+ self.assertEqual(
+ Kronecker(3, generating_vector=np.array([0.1, 0.2, 0.3])).gen_vec_source,
+ "CUSTOM",
+ )
+
+ def test_generating_vector_name_is_case_insensitive(self):
+ self.assertEqual(Kronecker(3, generating_vector="cbc").gen_vec_source, "CBC")
+ self.assertEqual(
+ Kronecker(3, generating_vector="richtmyer").gen_vec_source, "RICHTMYER"
+ )
+ self.assertEqual(
+ Kronecker(3, generating_vector="suzuki").gen_vec_source, "SUZUKI"
+ )
+
+ def test_suzuki_generating_vector_formula(self):
+ d = 5
+ dd = Kronecker(d, generating_vector="SUZUKI", randomize=False)
+ npt.assert_allclose(dd.gen_vec[0], 2 ** (np.arange(1, d + 1) / (d + 1)))
+
+ def test_richtmyer_generating_vector_formula(self):
+ primes = np.array([2, 3, 5, 7, 11])
+ dd = Kronecker(5, generating_vector="RICHTMYER", randomize=False)
+ npt.assert_allclose(dd.gen_vec[0], np.sqrt(primes) % 1)
+
+ def test_cbc_falls_back_to_richtmyer_beyond_supported_dimension(self):
+ with self.assertWarns(RuntimeWarning):
+ dd = Kronecker(15, generating_vector="CBC", seed=7)
+ self.assertEqual(dd.gen_vec_source, "RICHTMYER")
+ self.assertEqual(dd.gen_samples(4).shape, (4, 15))
+
+ def test_cbc_fallback_warning_suppressed_by_warn_false(self):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error")
+ dd = Kronecker(15, generating_vector="CBC", seed=7, warn=False)
+ self.assertEqual(dd.gen_vec_source, "RICHTMYER")
+
+ def test_generating_vector_above_two_dimensions_raises(self):
+ with self.assertRaises(ParameterError):
+ Kronecker(3, generating_vector=np.ones((2, 2, 3)))
+
+
+class TestKroneckerRandomization(unittest.TestCase):
+ """Unit tests for randomization aliases and shift validation."""
+
+ def test_randomize_aliases_resolve_to_canonical_values(self):
+ for alias in ["TRUE", "true", "SHIFT"]:
+ self.assertEqual(Kronecker(2, randomize=alias, seed=7).randomize, "SHIFT")
+ for alias in ["FALSE", "NONE", "NO", False]:
+ self.assertEqual(Kronecker(2, randomize=alias, seed=7).randomize, "FALSE")
+
+ def test_unrandomized_shift_is_zero(self):
+ dd = Kronecker(3, randomize=False)
+ npt.assert_allclose(dd.shift, np.zeros((1, 3)))
+
+ def test_invalid_randomize_raises(self):
+ with self.assertRaises(AssertionError):
+ Kronecker(2, randomize="OWEN")
+
+ def test_shift_requires_randomize_shift(self):
+ with self.assertRaises(AssertionError):
+ Kronecker(3, randomize=False, shift=[0.1, 0.2, 0.3])
+
+ def test_same_seed_reproduces_shift_and_points(self):
+ npt.assert_allclose(
+ Kronecker(3, seed=7).gen_samples(8), Kronecker(3, seed=7).gen_samples(8)
+ )
+
+ def test_different_seeds_give_different_shifts(self):
+ self.assertFalse(
+ np.allclose(Kronecker(3, seed=7).shift, Kronecker(3, seed=8).shift)
+ )
+
+ def test_replications_use_independent_shifts(self):
+ dd = Kronecker(3, replications=2, seed=7)
+ self.assertEqual(dd.shift.shape, (2, 3))
+ self.assertFalse(np.allclose(dd.shift[0], dd.shift[1]))
+
+ def test_per_replication_shift_is_respected(self):
+ shift = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]])
+ dd = Kronecker(3, generating_vector="SUZUKI", replications=2, shift=shift)
+ npt.assert_allclose(dd.gen_samples(1)[:, 0, :], shift)
+
+
+class TestKroneckerUnsupportedOptions(unittest.TestCase):
+ """Unit tests for options the Kronecker sequence does not support."""
+
+ def test_return_binary_raises(self):
+ with self.assertRaises(ParameterError):
+ Kronecker(3, seed=7).gen_samples(4, return_binary=True)
+
+
+class TestKroneckerSpawn(unittest.TestCase):
+ """Unit tests for spawning independent Kronecker samplers."""
+
+ def test_spawn_preserves_configuration(self):
+ dd = Kronecker(3, generating_vector="SUZUKI", seed=7)
+ spawns = dd.spawn(s=2, dimensions=[2, 2])
+ self.assertEqual(len(spawns), 2)
+ for spawn in spawns:
+ self.assertIsInstance(spawn, Kronecker)
+ self.assertEqual(spawn.gen_vec_source, "SUZUKI")
+ self.assertEqual(spawn.randomize, "SHIFT")
+ self.assertEqual(spawn.gen_samples(4).shape, (4, 2))
+
+ def test_spawns_are_independently_randomized(self):
+ spawns = Kronecker(3, seed=7).spawn(s=2, dimensions=[3, 3])
+ self.assertFalse(np.allclose(spawns[0].shift, spawns[1].shift))
+
+ def test_spawn_requires_unspecified_shift(self):
+ dd = Kronecker(3, generating_vector="SUZUKI", shift=[0.1, 0.2, 0.3])
+ with self.assertRaises(AssertionError):
+ dd.spawn(s=1, dimensions=[3])
+
+
+class TestKroneckerDiscrepancy(unittest.TestCase):
+ """Unit tests for the periodic discrepancy helpers."""
+
+ @staticmethod
+ def _default_k_tilde(x, gamma):
+ return np.prod(1 + (x * (x - 1) + 1 / 6) * gamma, axis=-1)
+
+ def _pairwise_definition(self, dd, n):
+ """Squared periodic discrepancy from its definition over pairwise differences."""
+ x = dd.gen_samples(n)
+ gamma = np.ones(dd.d)
+ k = self._default_k_tilde((x[:, None, :] - x[None, :, :]) % 1, gamma)
+ return np.array([k[:m, :m].sum() / m**2 - 1 for m in range(1, n + 1)])
+
+ def test_unshifted_squared_discrepancy_matches_pairwise_definition(self):
+ n = 32
+ for d in [1, 2, 3, 5]:
+ dd = Kronecker(d, seed=7, randomize=False)
+ npt.assert_allclose(
+ dd._square_periodic_discrepancies(
+ n, (self._default_k_tilde, 1), np.ones(dd.d)
+ ),
+ self._pairwise_definition(dd, n),
+ atol=1e-12,
+ )
+
+ @unittest.expectedFailure
+ def test_shifted_squared_discrepancy_matches_pairwise_definition(self):
+ # Known bug: the implementation keeps the random shift, the definition does not.
+ n, d = 32, 3
+ dd = Kronecker(d, seed=7)
+ npt.assert_allclose(
+ dd._square_periodic_discrepancies(
+ n, (self._default_k_tilde, 1), np.ones(dd.d)
+ ),
+ self._pairwise_definition(dd, n),
+ atol=1e-12,
+ )
+
+ def test_periodic_discrepancy_is_root_of_squared_discrepancy(self):
+ dd = Kronecker(2, seed=7)
+ n = 8
+ gamma = np.ones(dd.d)
+ k_tilde = (self._default_k_tilde, 1)
+ squared = dd._square_periodic_discrepancies(n, k_tilde, gamma)
+ npt.assert_allclose(dd.periodic_discrepancy(n), np.sqrt(squared))
+
+ def test_periodic_discrepancy_shape(self):
+ n = 16
+ discrep = Kronecker(3, seed=7, randomize=False).periodic_discrepancy(n)
+ self.assertEqual(discrep.shape[-1], n)
+
+ def test_unshifted_squared_discrepancy_is_nonnegative(self):
+ for d in [1, 2, 3, 5]:
+ dd = Kronecker(d, seed=7, randomize=False)
+ squared = dd._square_periodic_discrepancies(
+ 32, (self._default_k_tilde, 1), np.ones(dd.d)
+ )
+ self.assertTrue((squared >= 0).all())
+
+ @unittest.expectedFailure
+ def test_squared_discrepancy_is_nonnegative_when_shifted(self):
+ # Known bug: the kernel is evaluated at x_{|a-b|}, which retains the random
+ # shift, so the quadratic form loses positive semi-definiteness.
+ for d in [1, 3, 5]:
+ dd = Kronecker(d, seed=7)
+ squared = dd._square_periodic_discrepancies(
+ 32, (self._default_k_tilde, 1), np.ones(dd.d)
+ )
+ self.assertTrue((squared >= 0).all())
+
+ @unittest.expectedFailure
+ def test_squared_discrepancy_is_invariant_to_the_random_shift(self):
+ # Known bug: x_a - x_b = (a-b)*alpha mod 1 cancels the shift, so the
+ # discrepancy must not depend on the seed, but it currently does.
+ n, d = 32, 3
+ gamma = np.ones(d)
+ k_tilde = (self._default_k_tilde, 1)
+ unshifted = Kronecker(d, seed=7, randomize=False)
+ expected = unshifted._square_periodic_discrepancies(n, k_tilde, gamma)
+ for seed in [1, 7, 42]:
+ shifted = Kronecker(d, seed=seed)
+ npt.assert_allclose(
+ shifted._square_periodic_discrepancies(n, k_tilde, gamma), expected
+ )
+
+ @unittest.expectedFailure
+ def test_periodic_discrepancy_is_finite_when_shifted(self):
+ # Known bug: negative squared discrepancies make the square root NaN.
+ discrep = Kronecker(3, seed=7).periodic_discrepancy(32)
+ self.assertTrue(np.isfinite(discrep).all())
+
+ def test_wssd_discrepancy_is_weighted_sum_of_squared_discrepancies(self):
+ dd = Kronecker(2, seed=7)
+ n = 8
+ weights = np.linspace(0.5, 2.0, n)
+ npt.assert_allclose(
+ dd.wssd_discrepancy(n, weights),
+ np.sum(weights * dd.periodic_discrepancy(n) ** 2, axis=-1),
+ )
+
+ def test_squared_discrepancy_matches_direct_double_sum(self):
+ # Refactor guard only: this expectation is an algebraic restatement of the
+ # implementation's cumulative sums, so it cannot detect a wrong formula.
+ # Note: passes today because it bakes in the buggy shifted-kernel formula
+ # from #612; it will need rewriting once that fix lands (see PR #556/#633).
+ dd = Kronecker(2, seed=7)
+ n = 8
+ gamma = np.ones(dd.d)
+ terms = self._default_k_tilde(dd.gen_samples(n), gamma)
+ expected = np.array(
+ [
+ sum(terms[abs(a - b)] for a in range(m) for b in range(m)) / m**2 - 1
+ for m in range(1, n + 1)
+ ]
+ )
+ npt.assert_allclose(dd._square_periodic_discrepancies(n, (self._default_k_tilde, 1), gamma), expected)
+
+
+if __name__ == "__main__":
+ unittest.main()
From ce49b3332704206115ab38f0dcb9a4783803dc8e Mon Sep 17 00:00:00 2001
From: algo-hawk <135367680+algo-hawk@users.noreply.github.com>
Date: Tue, 29 Sep 2026 21:38:38 +0000
Subject: [PATCH 2/2] Rename Kronecker test file to satisfy test__ naming
convention
CI's check_test_style requires test__.py; Kronecker is a
discrete distribution, so dd is the correct area prefix.
---
test/{test_kronecker.py => test_dd_kronecker.py} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename test/{test_kronecker.py => test_dd_kronecker.py} (100%)
diff --git a/test/test_kronecker.py b/test/test_dd_kronecker.py
similarity index 100%
rename from test/test_kronecker.py
rename to test/test_dd_kronecker.py