diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a4dc7a59a..63feb062a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -60,10 +60,7 @@ While `dev` contains the most complete set of install dependencies, a number of
pip install -e ".[dev]"
~~~
-The `dev` extra includes QMCPy's PyPI-hosted MPMC dependencies. MPMC additionally
-requires a platform-specific `pyg_lib` wheel that is not available from PyPI.
-After installing `dev`, let the QMCPy installer select the wheel page matching
-the installed PyTorch build:
+The `dev` extra includes QMCPy's PyPI-hosted MPMC dependencies. MPMC additionally requires a platform-specific `pyg_lib` wheel that is not available from PyPI. After installing `dev`, let the QMCPy installer select the wheel page matching the installed PyTorch build:
~~~bash
qmcpy-install-mpmc
@@ -145,6 +142,10 @@ make tests
Please see the targets in the makefile for more granular control over tests.
+### Test file layout
+
+Unit tests live flat in `test/`, named `test__.py` where `` is a short code for the `qmcpy` subpackage under test (`tm` true_measure, `dd` discrete_distribution, `sc` stopping_criterion, `ig` integrand, ...) or a cross-cutting bucket (`ee`, `sr`). So `pytest test/ -k test_tm_` runs every true-measure test. New files should also be written as a `unittest.TestCase` subclass rather than bare `def test_*` functions. `make check_test_style` lists any file that breaks either convention (informational; also runs inside `make format`; `STRICT=--strict` makes it fail). The full area table is in [`test/README.md`](test/README.md#test-file-organization).
+
## Documentation
### Ensure `pyreverse` Is On Your PATH
diff --git a/docs/api/discrete_distributions.md b/docs/api/discrete_distributions.md
index daa782cb6..49b60fbb4 100644
--- a/docs/api/discrete_distributions.md
+++ b/docs/api/discrete_distributions.md
@@ -70,10 +70,7 @@ python -m pip install "qmcpy[mpmc]"
qmcpy-install-mpmc
```
-The second command selects the `pyg_lib` wheel page matching the installed
-PyTorch and accelerator builds. For GPU support or platform-specific wheels,
-see the [PyTorch installation guide](https://pytorch.org/get-started/locally/)
-and the [PyTorch Geometric installation guide](https://pytorch-geometric.readthedocs.io/en/latest/install/installation.html).
+The second command selects the `pyg_lib` wheel page matching the installed PyTorch and accelerator builds. For GPU support or platform-specific wheels, see the [PyTorch installation guide](https://pytorch.org/get-started/locally/) and the [PyTorch Geometric installation guide](https://pytorch-geometric.readthedocs.io/en/latest/install/installation.html).
::: qmcpy.discrete_distribution.mpmc.mpmc.MPMC
diff --git a/docs/mpmc-compatibility.md b/docs/mpmc-compatibility.md
index 92d6f1526..4e9bbf424 100644
--- a/docs/mpmc-compatibility.md
+++ b/docs/mpmc-compatibility.md
@@ -36,17 +36,14 @@ This gives one place to enforce modern MPMC compatibility without forcing the en
## Local Developer Commands
-Install the usual test and MPMC extras first, then add the platform-specific
-PyG runtime with QMCPy's installed helper command:
+Install the usual test and MPMC extras first, then add the platform-specific PyG runtime with QMCPy's installed helper command:
```bash
python -m pip install -e ".[test,test_torch,test_gpytorch,test_botorch,mpmc]"
qmcpy-install-mpmc
```
-The `mpmc` extra contains dependencies available from PyPI. The helper handles
-`pyg_lib` separately because its wheel page depends on the installed PyTorch
-version and accelerator build, which standard project metadata cannot select.
+The `mpmc` extra contains dependencies available from PyPI. The helper handles `pyg_lib` separately because its wheel page depends on the installed PyTorch version and accelerator build, which standard project metadata cannot select.
Then run the MPMC-specific checks:
diff --git a/docs/tests.md b/docs/tests.md
index 1120146dc..73fb226be 100644
--- a/docs/tests.md
+++ b/docs/tests.md
@@ -18,6 +18,48 @@ This document describes the available test targets in the Makefile for QMCSoftwa
| `make delcoverage` | Reset coverage tracking | Instant | Start fresh coverage analysis |
+## Test File Organization
+
+Unit tests live flat in `test/` (no subpackage subfolders). Every file is named:
+
+```
+test__.py
+```
+
+`` is a short code for the `qmcpy` subpackage under test, or a cross-cutting bucket:
+
+| area | scope |
+|------|-------|
+| `dd` | `qmcpy/discrete_distribution` |
+| `ft` | `qmcpy/fast_transform` |
+| `ig` | `qmcpy/integrand` |
+| `kn` | `qmcpy/kernel` |
+| `sc` | `qmcpy/stopping_criterion` |
+| `tm` | `qmcpy/true_measure` |
+| `ut` | `qmcpy/util` |
+| `ee` | end-to-end / cross-cutting pipeline (`integrate()`, worked problems such as Keister and pi) |
+| `sr` | `scripts/` tooling, packaging, and docs checks |
+
+This keeps related tests adjacent when the directory is sorted, and lets you run one area at a time:
+
+```bash
+python -m pytest test/ -k test_tm_ # every true_measure test
+make unittests PYTEST_EXTRA_ARGS="-k test_sc_"
+```
+
+Notebook tests are separate: they live in `test/booktests/` as `tb_*.py` and are generated from `demos/` (see `test/booktests/README.md`).
+
+### Conventions checked by `make check_test_style`
+
+1. **Area prefix** — the filename must start with a recognized `test__` prefix from the table above.
+2. **Object class** — write a test file as one or more `unittest.TestCase` subclasses rather than bare `def test_*` pytest functions. A class groups related assertions under a name (so `pytest -k TestCubMCG` selects them and a failure report names the group), shares construction through `setUp` / `setUpClass` / `self.addCleanup`, and runs identically under `pytest`, `python -m unittest`, and the coverage and booktest runners without depending on pytest fixtures. Most of the suite already follows this; a few legacy files still use bare functions and new files should not.
+
+`make check_test_style` lists any violation and is informational (exit 0). It also runs as part of `make format`. To make it fail instead — for a pre-commit hook or CI gate — pass `--strict`:
+
+```bash
+STRICT=--strict make check_test_style
+```
+
## Detailed Descriptions
## Scope
diff --git a/makefile b/makefile
index 57e4f66e6..924c0e9b5 100644
--- a/makefile
+++ b/makefile
@@ -40,6 +40,16 @@ clean_local_only_files:
clean_coverage:
rm -fr artifacts/coverage/ .coverage* test/booktests/.coverage*
+TEST_STYLE_PATH ?= test
+# Check test/test_*.py against two suite conventions: (1) written as a
+# unittest.TestCase subclass ("object class"), not bare pytest functions;
+# (2) named test__*.py where is the qmcpy subpackage under test
+# (dd ft ig kn sc tm ut) or a cross-cutting bucket (ee sr).
+# Informational by default; pass --strict to make it fail
+# (e.g. STRICT=--strict make check_test_style).
+check_test_style:
+ @$(PYTHON) scripts/check_test_style.py $(TEST_STYLE_PATH) $(STRICT)
+
##########################################################
# Doctests
##########################################################
@@ -406,8 +416,13 @@ MARKDOWN_UNWRAP_PATH ?= $(FORMAT_PATH)
format:
$(MAKE) flatten_qmcpy_imports
+ @echo ""
$(MAKE) markdown-unwrap MARKDOWN_UNWRAP_PATH="$(MARKDOWN_UNWRAP_PATH)"
+ @echo ""
$(MAKE) rm_trailing_whitespace FORMAT_PATH="$(FORMAT_PATH)"
+ @echo ""
+ $(MAKE) check_test_style
+ @echo ""
flatten_qmcpy_imports:
$(PYTHON) scripts/flatten_qmcpy_imports.py
diff --git a/qmcpy/accumulate_data/__init__.py b/qmcpy/accumulate_data/__init__.py
deleted file mode 100644
index 6f913ef81..000000000
--- a/qmcpy/accumulate_data/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Accumulate data module."""
diff --git a/scripts/check_test_style.py b/scripts/check_test_style.py
new file mode 100755
index 000000000..9fdda9fa0
--- /dev/null
+++ b/scripts/check_test_style.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""Check ``test/test_*.py`` files against two suite conventions.
+
+1. **Object class.** A test file should be written as a ``unittest.TestCase``
+ subclass, not as bare ``def test_*`` pytest functions. The numeric-correctness
+ backbone (``test_tm_true_measures.py``, ``test_sc_stopping_criteria.py``,
+ ``test_dd_discrete_distribs.py``, ...) already follows this; newer per-measure
+ and tooling files do not. The split is listed so it stays visible in review.
+
+2. **Area prefix.** A test file should be named ``test__.py`` where
+ ```` marks the ``qmcpy`` subpackage under test (or a cross-cutting
+ bucket). Recognized areas:
+
+ dd discrete_distribution tm true_measure
+ ft fast_transform ut util
+ ig integrand ee end-to-end / cross-cutting pipeline
+ kn kernel sr scripts/ tooling, packaging, docs checks
+ sc stopping_criterion
+
+Usage:
+ python scripts/check_test_style.py [TEST_DIR] [--strict] [--quiet]
+
+TEST_DIR defaults to ``test``. With ``--strict`` the exit code is non-zero when
+any file violates either convention (so it can gate CI); otherwise it is always
+0 and the output is informational.
+"""
+import ast
+import re
+import sys
+from pathlib import Path
+
+AREA_PREFIXES = {
+ "dd": "discrete_distribution",
+ "ft": "fast_transform",
+ "ig": "integrand",
+ "kn": "kernel",
+ "sc": "stopping_criterion",
+ "tm": "true_measure",
+ "ut": "util",
+ "ee": "end-to-end / cross-cutting pipeline",
+ "sr": "scripts/ tooling, packaging, docs checks",
+}
+AREA_RE = re.compile(r"^test_(?:" + "|".join(sorted(AREA_PREFIXES)) + r")_.+\.py$")
+
+
+def _area_ok(path):
+ """True if the filename starts with a recognized ``test__`` prefix."""
+ return bool(AREA_RE.match(path.name))
+
+
+def _subclasses_testcase(node):
+ """True if a ClassDef lists ``TestCase`` / ``unittest.TestCase`` as a base."""
+ for base in node.bases:
+ if isinstance(base, ast.Attribute) and base.attr == "TestCase":
+ return True
+ if isinstance(base, ast.Name) and base.id == "TestCase":
+ return True
+ return False
+
+
+def classify(path):
+ """Return (has_testcase_class, has_test_callables)."""
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ nodes = list(ast.walk(tree))
+ has_class = any(
+ isinstance(n, ast.ClassDef) and _subclasses_testcase(n) for n in nodes
+ )
+ has_tests = any(
+ isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
+ and n.name.startswith("test_")
+ for n in nodes
+ )
+ return has_class, has_tests
+
+
+def main(argv):
+ strict = "--strict" in argv
+ quiet = "--quiet" in argv
+ positional = [a for a in argv if not a.startswith("-")]
+ test_dir = Path(positional[0]) if positional else Path("test")
+
+ files = sorted(test_dir.glob("test_*.py"))
+ if not files:
+ print(f"no test_*.py files under {test_dir}/", file=sys.stderr)
+ return 1
+
+ class_based, function_based, no_tests = [], [], []
+ for f in files:
+ has_class, has_tests = classify(f)
+ if has_class:
+ class_based.append(f)
+ elif has_tests:
+ function_based.append(f)
+ else:
+ no_tests.append(f)
+
+ misnamed = [f for f in files if not _area_ok(f)]
+
+ if not quiet:
+ print(f"{len(class_based)}/{len(files)} file(s) use a unittest.TestCase class")
+ if function_based:
+ print(
+ f"{len(function_based)} file(s) use bare pytest functions "
+ f"(no unittest.TestCase class):"
+ )
+ for f in function_based:
+ print(f" {f.as_posix()}")
+ elif not quiet:
+ print(" no bare-function test files found")
+ if no_tests and not quiet:
+ print(f"{len(no_tests)} file(s) define no test_* callables:")
+ for f in no_tests:
+ print(f" {f.as_posix()}")
+
+ if not quiet:
+ print(
+ f"{len(files) - len(misnamed)}/{len(files)} file(s) use a "
+ f"test__ prefix ({', '.join(sorted(AREA_PREFIXES))})"
+ )
+ if misnamed:
+ print(f" {len(misnamed)} file(s) have no recognized test__ prefix:")
+ for f in misnamed:
+ print(f" {f.as_posix()}")
+ elif not quiet:
+ print(" no misnamed test files found")
+
+ return 1 if (strict and (function_based or misnamed)) else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/flatten_qmcpy_imports.py b/scripts/flatten_qmcpy_imports.py
index 033952768..f48a5cbd1 100644
--- a/scripts/flatten_qmcpy_imports.py
+++ b/scripts/flatten_qmcpy_imports.py
@@ -907,7 +907,7 @@ def main(argv: list[str] | None = None) -> int:
file=sys.stderr,
)
- changed_files = 0
+ changed = [] # list of (display_path, import_count)
changed_imports = 0
for path in targets:
original = path.read_bytes()
@@ -919,29 +919,26 @@ def main(argv: list[str] | None = None) -> int:
if not count:
continue
- changed_files += 1
+ changed.append((_display_path(path, repository_root), count))
changed_imports += count
if not args.check:
path.write_bytes(updated)
- action = "Would update" if args.check else "Updated"
- import_label = "import" if count == 1 else "imports"
- print(
- f"{action}: {_display_path(path, repository_root)} "
- f"({count} {import_label})"
- )
- if changed_files:
- action = "need updates" if args.check else "updated"
+ action = "would update" if args.check else "updated"
+ if changed:
+ file_label = "file" if len(changed) == 1 else "files"
import_label = "import" if changed_imports == 1 else "imports"
- file_label = "file" if changed_files == 1 else "files"
print(
- f"{changed_imports} {import_label} in "
- f"{changed_files} {file_label} {action}."
+ f"qmcpy imports {action}: {len(changed)} {file_label}, "
+ f"{changed_imports} {import_label}:"
)
+ for display_path, count in sorted(changed):
+ per = "import" if count == 1 else "imports"
+ print(f" {display_path} ({count} {per})")
else:
- print("All eligible QMCPy imports already use the top-level package.")
+ print(f" qmcpy imports {action}: 0 files")
- return int(args.check and changed_files > 0)
+ return int(args.check and bool(changed))
if __name__ == "__main__":
diff --git a/scripts/remove_trailing_whitespace.py b/scripts/remove_trailing_whitespace.py
index e8def8d17..bbdc65f58 100644
--- a/scripts/remove_trailing_whitespace.py
+++ b/scripts/remove_trailing_whitespace.py
@@ -125,11 +125,17 @@ def main() -> int:
parser.add_argument("paths", nargs="+", help="tracked files or directories to process")
args = parser.parse_args()
- changed = [
- path for path in iter_source_files(args.paths) if remove_trailing_whitespace(path, args.check)
- ]
+ changed = sorted(
+ path for path in iter_source_files(args.paths)
+ if remove_trailing_whitespace(path, args.check)
+ )
action = "would update" if args.check else "updated"
- print(f"trailing whitespace {action}: {len(changed)} file(s)")
+ if changed:
+ print(f"trailing whitespace {action}: {len(changed)} file(s):")
+ for path in changed:
+ print(f" {path}")
+ else:
+ print(f" trailing whitespace {action}: 0 file(s)")
return int(args.check and bool(changed))
diff --git a/scripts/unwrap_markdown.py b/scripts/unwrap_markdown.py
index 7841d8cc7..b02004c8f 100755
--- a/scripts/unwrap_markdown.py
+++ b/scripts/unwrap_markdown.py
@@ -280,23 +280,31 @@ def main() -> int:
print("error: no .md or .ipynb files found", file=sys.stderr)
return 2
- changed_files = 0
+ changed_paths = []
changed_cells = 0
for path in targets:
suffix = path.suffix.lower()
if suffix == ".md":
- changed = process_markdown_file(path, args.check)
- changed_files += int(changed)
+ if process_markdown_file(path, args.check):
+ changed_paths.append(path)
elif suffix == ".ipynb":
changed, cell_count = process_notebook(path, args.check)
- changed_files += int(changed)
+ if changed:
+ changed_paths.append(path)
changed_cells += cell_count
mode = "would update" if args.check else "updated"
- print(
- f"markdown unwrap {mode}: {changed_files} file(s), {changed_cells} markdown cell(s)",
+ summary = (
+ f"markdown unwrap {mode}: {len(changed_paths)} file(s), "
+ f"{changed_cells} markdown cell(s)"
)
- return 1 if args.check and changed_files else 0
+ if changed_paths:
+ print(summary + ":")
+ for path in sorted(changed_paths):
+ print(f" {path}")
+ else:
+ print(" " + summary)
+ return 1 if args.check and changed_paths else 0
if __name__ == "__main__":
diff --git a/test/README.md b/test/README.md
index 1120146dc..73fb226be 100644
--- a/test/README.md
+++ b/test/README.md
@@ -18,6 +18,48 @@ This document describes the available test targets in the Makefile for QMCSoftwa
| `make delcoverage` | Reset coverage tracking | Instant | Start fresh coverage analysis |
+## Test File Organization
+
+Unit tests live flat in `test/` (no subpackage subfolders). Every file is named:
+
+```
+test__.py
+```
+
+`` is a short code for the `qmcpy` subpackage under test, or a cross-cutting bucket:
+
+| area | scope |
+|------|-------|
+| `dd` | `qmcpy/discrete_distribution` |
+| `ft` | `qmcpy/fast_transform` |
+| `ig` | `qmcpy/integrand` |
+| `kn` | `qmcpy/kernel` |
+| `sc` | `qmcpy/stopping_criterion` |
+| `tm` | `qmcpy/true_measure` |
+| `ut` | `qmcpy/util` |
+| `ee` | end-to-end / cross-cutting pipeline (`integrate()`, worked problems such as Keister and pi) |
+| `sr` | `scripts/` tooling, packaging, and docs checks |
+
+This keeps related tests adjacent when the directory is sorted, and lets you run one area at a time:
+
+```bash
+python -m pytest test/ -k test_tm_ # every true_measure test
+make unittests PYTEST_EXTRA_ARGS="-k test_sc_"
+```
+
+Notebook tests are separate: they live in `test/booktests/` as `tb_*.py` and are generated from `demos/` (see `test/booktests/README.md`).
+
+### Conventions checked by `make check_test_style`
+
+1. **Area prefix** — the filename must start with a recognized `test__` prefix from the table above.
+2. **Object class** — write a test file as one or more `unittest.TestCase` subclasses rather than bare `def test_*` pytest functions. A class groups related assertions under a name (so `pytest -k TestCubMCG` selects them and a failure report names the group), shares construction through `setUp` / `setUpClass` / `self.addCleanup`, and runs identically under `pytest`, `python -m unittest`, and the coverage and booktest runners without depending on pytest fixtures. Most of the suite already follows this; a few legacy files still use bare functions and new files should not.
+
+`make check_test_style` lists any violation and is informational (exit 0). It also runs as part of `make format`. To make it fail instead — for a pre-commit hook or CI gate — pass `--strict`:
+
+```bash
+STRICT=--strict make check_test_style
+```
+
## Detailed Descriptions
## Scope
diff --git a/test/test_check_links.py b/test/test_check_links.py
deleted file mode 100644
index 6a2b3dc52..000000000
--- a/test/test_check_links.py
+++ /dev/null
@@ -1,178 +0,0 @@
-import ssl
-import sys
-import urllib.error
-from unittest.mock import patch
-
-from scripts import check_links
-
-
-def _http_error(url, code):
- return urllib.error.HTTPError(url, code, "test response", {}, None)
-
-
-def test_head_success_is_reachable():
- with patch.object(check_links.urllib.request, "urlopen", return_value=object()) as urlopen:
- assert check_links._check_one("https://example.test", timeout=1) is None
-
- assert urlopen.call_count == 1
- assert urlopen.call_args.args[0].get_method() == "HEAD"
-
-
-def test_get_success_after_head_failure_is_reachable():
- url = "https://example.test"
- with patch.object(
- check_links.urllib.request,
- "urlopen",
- side_effect=[_http_error(url, 405), object()],
- ) as urlopen:
- assert check_links._check_one(url, timeout=1) is None
-
- assert urlopen.call_count == 2
- assert urlopen.call_args_list[1].args[0].get_method() == "GET"
-
-
-def test_not_found_and_gone_gets_are_broken():
- for code in (404, 410):
- url = f"https://example.test/{code}"
- with patch.object(
- check_links.urllib.request,
- "urlopen",
- side_effect=[_http_error(url, code), _http_error(url, code)],
- ):
- assert check_links._check_one(url, timeout=1) == (
- "broken",
- f"{url} -- HTTP {code}",
- )
-
-
-def test_bot_block_and_rate_limit_are_warnings():
- for code in (403, 429):
- url = f"https://example.test/{code}"
- with patch.object(
- check_links.urllib.request,
- "urlopen",
- side_effect=[_http_error(url, code), _http_error(url, code)],
- ):
- severity, message = check_links._check_one(url, timeout=1)
-
- assert severity == "warning"
- assert f"HTTP {code}" in message
-
-
-def test_tls_and_timeout_failures_are_warnings():
- failures = (
- ssl.SSLCertVerificationError("certificate verify failed"),
- TimeoutError("timed out"),
- )
- for failure in failures:
- with patch.object(
- check_links.urllib.request,
- "urlopen",
- side_effect=[failure, failure],
- ):
- severity, message = check_links._check_one(
- "https://example.test", timeout=1
- )
-
- assert severity == "warning"
- assert str(failure) in message
-
-
-def test_external_results_are_separated_and_duplicate_urls_checked_once(tmp_path):
- (tmp_path / "page.html").write_text(
- 'missing'
- 'duplicate'
- 'blocked',
- encoding="utf-8",
- )
-
- def result_for(url, _timeout):
- if url.endswith("/missing"):
- return "broken", f"{url} -- HTTP 404"
- return "warning", f"{url} -- HTTP 403"
-
- with patch.object(check_links, "_check_one", side_effect=result_for) as check_one:
- broken, warnings = check_links.check_external(tmp_path, workers=1)
-
- assert check_one.call_count == 2
- assert broken == [
- "https://example.test/missing -- HTTP 404 (seen on page.html)"
- ]
- assert warnings == [
- "https://example.test/blocked -- HTTP 403 (seen on page.html)"
- ]
-
-
-def test_internal_links_strip_site_url_deployment_path(tmp_path):
- target = tmp_path / "target"
- target.mkdir()
- (target / "index.html").write_text(
- 'Target
', encoding="utf-8"
- )
- (tmp_path / "index.html").write_text(
- 'root-relative'
- 'absolute',
- encoding="utf-8",
- )
-
- assert (
- check_links.check_internal(
- tmp_path, site_url="https://qmcsoftware.github.io/QMCSoftware/"
- )
- == []
- )
-
-
-def test_external_check_skips_same_site_urls(tmp_path):
- (tmp_path / "page.html").write_text(
- 'same'
- 'external',
- encoding="utf-8",
- )
-
- with patch.object(check_links, "_check_one", return_value=None) as check_one:
- broken, warnings = check_links.check_external(
- tmp_path,
- workers=1,
- site_url="https://qmcsoftware.github.io/QMCSoftware/",
- )
-
- assert broken == []
- assert warnings == []
- assert check_one.call_count == 1
- assert check_one.call_args.args[0] == "https://example.test/target/"
-
-
-def test_external_warnings_do_not_make_main_fail(tmp_path, monkeypatch, capsys):
- monkeypatch.setattr(sys, "argv", ["check_links.py", str(tmp_path), "--external"])
- monkeypatch.setattr(
- check_links, "check_internal", lambda _site_dir, site_url=None: []
- )
- monkeypatch.setattr(
- check_links,
- "check_external",
- lambda _site_dir, site_url=None: (
- [],
- ["https://example.test -- HTTP 403"],
- ),
- )
-
- assert check_links.main() == 0
- assert "0 broken link(s), 1 warning(s)" in capsys.readouterr().out
-
-
-def test_confirmed_external_breakage_makes_main_fail(tmp_path, monkeypatch):
- monkeypatch.setattr(sys, "argv", ["check_links.py", str(tmp_path), "--external"])
- monkeypatch.setattr(
- check_links, "check_internal", lambda _site_dir, site_url=None: []
- )
- monkeypatch.setattr(
- check_links,
- "check_external",
- lambda _site_dir, site_url=None: (
- ["https://example.test -- HTTP 404"],
- [],
- ),
- )
-
- assert check_links.main() == 1
diff --git a/test/test_check_removed_urls.py b/test/test_check_removed_urls.py
deleted file mode 100644
index 9374e34ad..000000000
--- a/test/test_check_removed_urls.py
+++ /dev/null
@@ -1,134 +0,0 @@
-import sys
-import urllib.error
-from unittest.mock import patch
-
-from scripts import check_removed_urls as cru
-
-SITE = "https://qmcsoftware.github.io/QMCSoftware/"
-
-
-def _sitemap(*paths):
- locs = "".join(f"{SITE}{path}" for path in paths)
- return f'{locs}'
-
-
-def _config(redirect_maps=None):
- plugins = ["material/search", {"mkdocs-jupyter": {"execute": False}}]
- if redirect_maps is not None:
- plugins.append({"redirects": {"redirect_maps": redirect_maps}})
- return {"site_url": SITE, "plugins": plugins}
-
-
-def _run(tmp_path, monkeypatch, sitemap_paths, redirect_maps=None, extra_argv=()):
- """Run main() offline against a temp sitemap and a temp docs/ tree."""
- docs = tmp_path / "docs"
- docs.mkdir(parents=True)
- (docs / "README.md").write_text("home", encoding="utf-8")
- (docs / "good_practices.md").write_text("page", encoding="utf-8")
- sitemap = tmp_path / "sitemap.xml"
- sitemap.write_text(_sitemap(*sitemap_paths), encoding="utf-8")
-
- monkeypatch.setattr(cru, "read_config", lambda *a, **k: _config(redirect_maps))
- monkeypatch.setattr(sys, "argv", [
- "check_removed_urls.py", "--sitemap", str(sitemap), "--docs-dir", str(docs),
- *extra_argv,
- ])
- return cru.main()
-
-
-def test_url_path_and_source_round_trip(tmp_path):
- for source, url_path in [("blogs/scipywrapper/index.md", "blogs/scipywrapper/"),
- ("good_practices.md", "good_practices/"),
- ("demos/quickstart.ipynb", "demos/quickstart/"),
- ("index.md", ""), ("README.md", "")]:
- assert cru.url_path_for_source(source) == url_path
-
- for source in ("README.md", "good_practices.md", "demos/quickstart.ipynb",
- "api/index.md"):
- path = tmp_path / source
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text("page", encoding="utf-8")
- assert cru.source_exists(cru.url_path_for_source(source), tmp_path)
- assert not cru.source_exists("blogs/scipywrapper/", tmp_path)
-
-
-def test_redirect_maps_reads_the_plugin_and_tolerates_its_absence():
- entry = {"blogs/x/index.md": "https://qmcsoftware.org/blogs/x/"}
- assert cru.redirect_maps(_config(entry)) == entry
- assert cru.redirect_maps(_config()) == {}
- assert cru.redirect_maps({}) == {}
-
-
-def test_published_paths_separates_foreign_urls():
- sitemap = _sitemap("", "good_practices/").replace(
- "", "https://example.test/other/")
-
- assert cru.published_paths(sitemap, SITE) == (
- ["", "good_practices/"], ["https://example.test/other/"])
-
-
-def test_http_status_falls_back_to_get_when_head_is_unsupported():
- url = "https://example.test"
- error = urllib.error.HTTPError(url, 405, "test response", {}, None)
- response = type("Response", (), {"status": 200, "__enter__": lambda s: s,
- "__exit__": lambda s, *a: False})()
- with patch.object(cru.urllib.request, "urlopen",
- side_effect=[error, response]) as urlopen:
- assert cru.http_status(url, timeout=1) == "200"
-
- assert urlopen.call_count == 2
- assert urlopen.call_args_list[1].args[0].get_method() == "GET"
-
-
-def test_removed_page_without_redirect_is_flagged(tmp_path, monkeypatch, capsys):
- code = _run(tmp_path, monkeypatch, ["", "good_practices/", "blogs/scipywrapper/"])
- out = capsys.readouterr().out
-
- assert code == 1
- assert "1 removed with no redirect" in out
- assert f"[ORPHAN] {SITE}blogs/scipywrapper/" in out
- assert "blogs/scipywrapper/index.md: " in out
-
-
-def test_removed_page_covered_by_a_redirect_passes(tmp_path, monkeypatch, capsys):
- code = _run(
- tmp_path, monkeypatch, ["", "good_practices/", "blogs/scipywrapper/"],
- redirect_maps={
- "blogs/scipywrapper/index.md": "https://qmcsoftware.org/blogs/scipywrapper/"},
- )
- out = capsys.readouterr().out
-
- assert code == 0
- assert "0 removed with no redirect" in out
- assert "[redirect]" in out and "[ORPHAN]" not in out
-
-
-def test_intact_site_passes(tmp_path, monkeypatch, capsys):
- assert _run(tmp_path, monkeypatch, ["", "good_practices/"]) == 0
- assert "2 still have a page source" in capsys.readouterr().out
-
-
-def test_verify_redirects_follows_the_target_status(tmp_path, monkeypatch, capsys):
- redirects = {"blogs/x/index.md": "https://qmcsoftware.org/blogs/x/"}
- for status, expected_code in [("200", 0), ("404", 1)]:
- monkeypatch.setattr(cru, "http_status", lambda *a, **k: status)
- code = _run(tmp_path / status, monkeypatch, ["", "blogs/x/"],
- redirect_maps=redirects, extra_argv=("--verify-redirects",))
- out = capsys.readouterr().out
-
- assert code == expected_code
- assert status in out
- # The URL itself is covered, so a failure is the target, not an orphan.
- assert "[ORPHAN]" not in out
-
-
-def test_unreachable_sitemap_fails_unless_offline_is_allowed(tmp_path, monkeypatch, capsys):
- monkeypatch.setattr(cru, "read_config", lambda *a, **k: _config())
- argv = ["check_removed_urls.py", "--sitemap", str(tmp_path / "absent.xml")]
-
- monkeypatch.setattr(sys, "argv", argv)
- assert cru.main() == 1
-
- monkeypatch.setattr(sys, "argv", argv + ["--allow-offline"])
- assert cru.main() == 0
- assert "skipping the check" in capsys.readouterr().out
diff --git a/test/test_copulas.py b/test/test_copulas.py
deleted file mode 100644
index 2f4bd06ba..000000000
--- a/test/test_copulas.py
+++ /dev/null
@@ -1,1362 +0,0 @@
-import warnings
-
-import numpy as np
-import pytest
-import scipy.stats as stats
-
-from qmcpy import (
- AbstractCopula,
- ClaytonCopula,
- DigitalNetB2,
- FrankCopula,
- GaussianCopula,
- GumbelCopula,
- StudentTCopula,
-)
-
-from qmcpy.true_measure.copula import (
- AbstractCopula as ModuleAbstractCopula,
- _apply_marginal_ppfs,
- _build_marginal_range,
- _clip_unit_interval,
- _marginal_cdfs_and_logpdf,
- _validate_correlation_matrix,
- _validate_dimension,
- _validate_marginals,
-)
-
-from qmcpy.util import DimensionError, MethodImplementationError, ParameterError
-
-
-class PPFOnlyMarginal:
- def ppf(self, u):
- return np.asarray(u, dtype=float)
-
-
-class NonCallablePPFMarginal:
- ppf = 1.0
-
-
-class UnitPDFMarginal:
- def ppf(self, u):
- return np.asarray(u, dtype=float)
-
- def cdf(self, x):
- return np.asarray(x, dtype=float)
-
- def pdf(self, x):
- return np.ones_like(np.asarray(x, dtype=float))
-
-
-class CDFOnlyMarginal(PPFOnlyMarginal):
- def cdf(self, x):
- return np.asarray(x, dtype=float)
-
-
-class BadIntervalMarginal(PPFOnlyMarginal):
- def interval(self, confidence):
- raise ValueError("interval unavailable")
-
-
-class BadRangeMarginal:
- def ppf(self, u):
- raise ValueError("ppf unavailable")
-
-
-def _equicorrelation(d, rho):
- corr = np.full((d, d), rho, dtype=float)
- np.fill_diagonal(corr, 1.0)
- return corr
-
-
-def _make_copula(copula_cls, dimension=2, marginals=None, correlation=None, seed=7):
- if marginals is None:
- marginals = [stats.norm()] * dimension
- if correlation is None:
- correlation = np.eye(dimension)
-
- kwargs = {}
- if copula_cls is StudentTCopula:
- kwargs["df"] = 4
- if copula_cls is ClaytonCopula:
- kwargs["theta"] = 2.0
- if copula_cls is FrankCopula:
- kwargs["theta"] = 5.0
- if copula_cls is GumbelCopula:
- kwargs["theta"] = 2.0
-
- common = {
- "sampler": DigitalNetB2(dimension, seed=seed),
- "marginals": marginals,
- **kwargs,
- }
- if copula_cls in [ClaytonCopula, FrankCopula, GumbelCopula]:
- return copula_cls(**common)
- return copula_cls(correlation=correlation, **common)
-
-
-# Base AbstractCopula and helper tests
-
-
-def test_abstract_copula_is_importable_from_public_module_path():
- assert ModuleAbstractCopula is AbstractCopula
-
-
-def test_public_api_imports_and_normal_usage():
- for copula_cls in [
- GaussianCopula,
- StudentTCopula,
- ClaytonCopula,
- FrankCopula,
- GumbelCopula,
- ]:
- assert issubclass(copula_cls, AbstractCopula)
-
- tm = _make_copula(copula_cls)
- x = tm(8)
- x_gen = tm.gen_samples(8)
- v = tm.gen_copula_samples(8)
-
- assert x.shape == (8, 2)
- assert x_gen.shape == (8, 2)
- assert v.shape == (8, 2)
- assert np.all(np.isfinite(x))
- assert np.all(np.isfinite(x_gen))
- assert np.all((0 <= v) & (v <= 1))
-
-
-def test_abstract_copula_rejects_unimplemented_transform():
- tm = AbstractCopula(
- DigitalNetB2(2, seed=101),
- marginals=[stats.uniform(), stats.uniform()],
- )
-
- with pytest.raises(MethodImplementationError):
- tm.copula_transform(np.full((3, 2), 0.5))
-
-
-def test_abstract_copula_rejects_invalid_sampler():
- with pytest.raises(ParameterError, match="sampler"):
- AbstractCopula(object(), marginals=[stats.uniform()])
-
-
-def test_validate_marginals_error_branches():
- with pytest.raises(ParameterError, match="marginals"):
- _validate_marginals(None)
-
- with pytest.raises(ParameterError, match="at least one"):
- _validate_marginals([])
-
- with pytest.raises(ParameterError, match="ppf"):
- _validate_marginals([NonCallablePPFMarginal()])
-
-
-def test_validate_dimension_error_branches():
- with pytest.raises(DimensionError, match="integer dimension"):
- _validate_dimension(object(), [stats.uniform()])
-
- with pytest.raises(DimensionError, match="marginals"):
- _validate_dimension(3, [stats.uniform(), stats.uniform()])
-
-
-def test_apply_marginal_ppfs_clips_endpoints_and_checks_dimension():
- transformed = _apply_marginal_ppfs(
- np.array([[0.0, 1.0], [1.0, 0.0]]),
- [stats.norm(), stats.norm()],
- )
-
- assert transformed.shape == (2, 2)
- assert np.all(np.isfinite(transformed))
-
- with pytest.raises(DimensionError, match="marginals"):
- _apply_marginal_ppfs(np.full((2, 3), 0.5), [stats.uniform(), stats.uniform()])
-
-
-def test_marginal_range_falls_back_when_interval_or_ppf_fails():
- ranges = _build_marginal_range([BadIntervalMarginal(), BadRangeMarginal()])
-
- assert ranges.shape == (2, 2)
- assert np.all(np.isfinite(ranges[0]))
- np.testing.assert_allclose(ranges[1], [-np.inf, np.inf])
-
-
-def test_marginal_cdfs_and_logpdf_pdf_branch_and_errors():
- x = np.array([[0.25, 0.75], [0.4, 0.6]])
- u, log_density = _marginal_cdfs_and_logpdf(
- x,
- [UnitPDFMarginal(), UnitPDFMarginal()],
- )
-
- np.testing.assert_allclose(u, x)
- np.testing.assert_allclose(log_density, np.zeros(2))
-
- with pytest.raises(ParameterError, match="cdf"):
- _marginal_cdfs_and_logpdf(x, [PPFOnlyMarginal(), UnitPDFMarginal()])
-
- with pytest.raises(ParameterError, match="pdf"):
- _marginal_cdfs_and_logpdf(x, [CDFOnlyMarginal(), UnitPDFMarginal()])
-
-
-def test_validate_correlation_matrix_rejects_nonfinite_values():
- with pytest.raises(ValueError, match="finite"):
- _validate_correlation_matrix([[1.0, np.nan], [np.nan, 1.0]], 2)
-
-
-def test_clip_unit_interval_uses_machine_epsilon():
- clipped = _clip_unit_interval(np.array([0.0, 0.5, 1.0]))
- eps = np.finfo(float).eps
-
- np.testing.assert_allclose(clipped, [eps, 0.5, 1.0 - eps])
-
-
-@pytest.mark.parametrize(
- "copula_cls",
- [GaussianCopula, StudentTCopula, ClaytonCopula, GumbelCopula, FrankCopula],
-)
-def test_copula_transform_outputs_dependent_uniforms_in_unit_cube(copula_cls):
- tm = _make_copula(copula_cls, dimension=3)
- u = np.array(
- [
- [0.1, 0.3, 0.7],
- [0.5, 0.5, 0.5],
- [0.9, 0.8, 0.2],
- ]
- )
-
- v = tm.copula_transform(u)
-
- assert v.shape == u.shape
- assert np.all(np.isfinite(v))
- assert np.all((0.0 <= v) & (v <= 1.0))
-
-
-@pytest.mark.parametrize(
- "copula_cls,dimension",
- [
- (GaussianCopula, 3),
- (StudentTCopula, 3),
- (ClaytonCopula, 3),
- (FrankCopula, 3),
- (GumbelCopula, 3),
- ],
-)
-def test_copula_sample_shapes_are_preserved(copula_cls, dimension):
- tm = _make_copula(copula_cls, dimension=dimension, seed=9)
-
- one = tm(1)
- many = tm(8)
- batched_transform = tm._transform(np.full((2, 3, dimension), 0.5))
-
- assert one.shape == (1, dimension)
- assert many.shape == (8, dimension)
- assert batched_transform.shape == (2, 3, dimension)
- assert np.all(np.isfinite(one))
- assert np.all(np.isfinite(many))
- assert np.all(np.isfinite(batched_transform))
-
-
-# Elliptical copulas
-
-
-def test_output_shape_with_nonnormal_marginals():
- tm = GaussianCopula(
- sampler=DigitalNetB2(2, seed=7),
- marginals=[stats.beta(a=2, b=5), stats.gamma(a=3, scale=2)],
- correlation=[[1.0, 0.4], [0.4, 1.0]],
- )
-
- x = tm(16)
-
- assert x.shape == (16, 2)
-
-
-def test_finite_output_for_normal_marginals():
- tm = GaussianCopula(
- sampler=DigitalNetB2(2, seed=11),
- marginals=[stats.norm(), stats.norm(loc=1.0, scale=2.0)],
- correlation=[[1.0, -0.3], [-0.3, 1.0]],
- )
-
- x = tm(128)
-
- assert np.all(np.isfinite(x))
-
-
-def test_return_weights_shape_when_marginal_densities_available():
- tm = GaussianCopula(
- sampler=DigitalNetB2(2, seed=12),
- marginals=[stats.norm(), stats.gamma(a=2.0)],
- correlation=[[1.0, 0.25], [0.25, 1.0]],
- )
-
- x, weights = tm(32, return_weights=True)
-
- assert x.shape == (32, 2)
- assert weights.shape == (32,)
- assert np.all(np.isfinite(weights))
- assert np.all(weights > 0.0)
-
-
-def test_identity_correlation_matches_independent_marginal_transforms():
- marginals = [stats.norm(loc=-1.0, scale=2.0), stats.gamma(a=2.0, scale=3.0)]
- tm = GaussianCopula(
- sampler=DigitalNetB2(2, seed=13),
- marginals=marginals,
- correlation=np.eye(2),
- )
- u = np.array([[0.2, 0.7], [0.4, 0.8], [0.9, 0.1]])
-
- x = tm._transform(u)
- expected = np.column_stack(
- [marginals[j].ppf(u[:, j]) for j in range(len(marginals))]
- )
-
- np.testing.assert_allclose(x, expected, rtol=1e-12, atol=1e-12)
-
-
-def test_positive_correlation_produces_positive_dependence():
- rho = 0.75
- tm = GaussianCopula(
- sampler=DigitalNetB2(2, seed=17),
- marginals=[stats.norm(), stats.norm()],
- correlation=[[1.0, rho], [rho, 1.0]],
- )
-
- x = tm(4096)
- empirical_corr = np.corrcoef(x.T)[0, 1]
-
- assert empirical_corr > 0.5
- assert abs(empirical_corr - rho) < 0.2
-
-
-@pytest.mark.parametrize("copula_cls", [GaussianCopula, StudentTCopula])
-@pytest.mark.parametrize("dimension", [1, 3, 5])
-def test_elliptical_copulas_support_general_dimensions(copula_cls, dimension):
- correlation = _equicorrelation(dimension, 0.25)
- tm = _make_copula(
- copula_cls,
- dimension=dimension,
- marginals=[stats.norm()] * dimension,
- correlation=correlation,
- seed=19,
- )
-
- x = tm(16)
- one = tm(1)
-
- assert x.shape == (16, dimension)
- assert one.shape == (1, dimension)
- assert np.all(np.isfinite(x))
- assert np.all(np.isfinite(one))
-
-
-@pytest.mark.parametrize("copula_cls", [GaussianCopula, StudentTCopula])
-def test_elliptical_copulas_handle_valid_near_singular_correlation(copula_cls):
- dimension = 5
- tm = _make_copula(
- copula_cls,
- dimension=dimension,
- marginals=[stats.norm()] * dimension,
- correlation=_equicorrelation(dimension, 0.999),
- seed=20,
- )
-
- x = tm(32)
-
- assert x.shape == (32, dimension)
- assert np.all(np.isfinite(x))
-
-
-@pytest.mark.parametrize("copula_cls", [GaussianCopula, StudentTCopula])
-def test_elliptical_copulas_reject_singular_correlation(copula_cls):
- with pytest.raises(ValueError, match="positive definite"):
- _make_copula(
- copula_cls,
- dimension=3,
- marginals=[stats.norm(), stats.norm(), stats.norm()],
- correlation=np.ones((3, 3)),
- seed=22,
- )
-
-
-@pytest.mark.parametrize(
- "copula_cls",
- [GaussianCopula, StudentTCopula, ClaytonCopula, FrankCopula, GumbelCopula],
-)
-def test_distribution_dimension_matches_number_of_marginals(copula_cls):
- tm = _make_copula(
- copula_cls,
- dimension=5,
- marginals=[
- stats.norm(),
- stats.beta(a=2, b=5),
- stats.gamma(a=3),
- stats.expon(),
- stats.lognorm(s=0.5),
- ],
- correlation=np.eye(5),
- )
-
- x = tm(32)
-
- assert x.shape == (32, 5)
- assert np.all(np.isfinite(x))
-
-
-@pytest.mark.parametrize("copula_cls", [GaussianCopula, StudentTCopula])
-def test_invalid_dimension_mismatches_raise(copula_cls):
- with pytest.raises(DimensionError, match="marginals"):
- _make_copula(
- copula_cls,
- dimension=2,
- marginals=[stats.norm(), stats.norm(), stats.norm()],
- correlation=np.eye(2),
- )
-
- with pytest.raises(ValueError, match="shape"):
- _make_copula(
- copula_cls,
- dimension=2,
- marginals=[stats.norm(), stats.norm()],
- correlation=np.eye(3),
- )
-
- with pytest.raises(ValueError, match="square"):
- _make_copula(
- copula_cls,
- dimension=2,
- marginals=[stats.norm(), stats.norm()],
- correlation=[[1.0, 0.2, 0.3], [0.2, 1.0, 0.4]],
- )
-
-
-@pytest.mark.parametrize("copula_cls", [ClaytonCopula, FrankCopula, GumbelCopula])
-def test_archimedean_dimension_mismatch_raises_dimension_error(copula_cls):
- with pytest.raises(DimensionError, match="marginals"):
- _make_copula(
- copula_cls,
- dimension=2,
- marginals=[stats.norm(), stats.norm(), stats.norm()],
- )
-
-
-@pytest.mark.parametrize(
- "copula_cls",
- [GaussianCopula, StudentTCopula],
-)
-@pytest.mark.parametrize(
- "correlation",
- [
- [[1.0, 0.2], [0.3, 1.0]],
- [[1.0, 0.2], [0.2, 0.9]],
- [[1.0, 1.2], [1.2, 1.0]],
- ],
-)
-def test_invalid_correlation_matrices_raise_value_error(copula_cls, correlation):
- with pytest.raises(ValueError):
- _make_copula(
- copula_cls,
- dimension=2,
- marginals=[stats.norm(), stats.norm()],
- correlation=correlation,
- )
-
-
-def test_marginal_length_mismatch_raises_dimension_error():
- with pytest.raises(DimensionError, match="marginals"):
- GaussianCopula(
- sampler=DigitalNetB2(2, seed=21),
- marginals=[stats.norm()],
- correlation=np.eye(2),
- )
-
-
-def test_marginal_without_ppf_raises_clear_error():
- class NoPPF:
- pass
-
- with pytest.raises(ParameterError, match="ppf"):
- GaussianCopula(
- sampler=DigitalNetB2(1, seed=23),
- marginals=[NoPPF()],
- correlation=[[1.0]],
- )
-
-
-@pytest.mark.parametrize(
- "copula_cls",
- [GaussianCopula, StudentTCopula, ClaytonCopula, FrankCopula, GumbelCopula],
-)
-def test_common_scipy_frozen_marginals_work(copula_cls):
- tm = _make_copula(
- copula_cls,
- dimension=5,
- marginals=[
- stats.norm(),
- stats.beta(a=2, b=5),
- stats.gamma(a=3),
- stats.expon(),
- stats.lognorm(s=0.5),
- ],
- correlation=np.eye(5),
- seed=47,
- )
-
- x = tm(128)
-
- assert x.shape == (128, 5)
- assert np.all(np.isfinite(x))
-
-
-@pytest.mark.parametrize(
- "copula_cls",
- [GaussianCopula, StudentTCopula, ClaytonCopula, FrankCopula, GumbelCopula],
-)
-def test_endpoint_uniforms_are_clipped_to_finite_outputs(copula_cls):
- tm = _make_copula(
- copula_cls,
- dimension=5,
- marginals=[
- stats.norm(),
- stats.beta(a=2, b=5),
- stats.gamma(a=3),
- stats.expon(),
- stats.lognorm(s=0.5),
- ],
- correlation=np.eye(5),
- seed=53,
- )
- u = np.array(
- [
- [0.0, 1.0, 0.0, 1.0, 0.5],
- [1.0, 0.0, 1.0, 0.0, 0.5],
- ]
- )
-
- x = tm._transform(u)
-
- assert x.shape == (2, 5)
- assert np.all(np.isfinite(x))
-
-
-def test_student_t_copula_output_shape_and_finite_values():
- tm = StudentTCopula(
- sampler=DigitalNetB2(2, seed=29),
- marginals=[stats.norm(), stats.gamma(a=3.0, scale=2.0)],
- correlation=[[1.0, 0.5], [0.5, 1.0]],
- df=4,
- )
-
- x = tm(128)
-
- assert x.shape == (128, 2)
- assert np.all(np.isfinite(x))
-
-
-def test_student_t_copula_positive_correlation_produces_positive_dependence():
- tm = StudentTCopula(
- sampler=DigitalNetB2(2, seed=31),
- marginals=[stats.norm(), stats.norm()],
- correlation=[[1.0, 0.7], [0.7, 1.0]],
- df=5,
- )
-
- x = tm(4096)
- empirical_corr = np.corrcoef(x.T)[0, 1]
-
- assert empirical_corr > 0.45
-
-
-def test_student_t_copula_has_stronger_joint_tail_than_gaussian_copula():
- rho = 0.7
- df = 4
- n = 2**12
- marginals = [stats.norm(), stats.norm()]
- correlation = [[1.0, rho], [rho, 1.0]]
-
- gaussian = GaussianCopula(
- sampler=DigitalNetB2(2, seed=101),
- marginals=marginals,
- correlation=correlation,
- )
- student_t = StudentTCopula(
- sampler=DigitalNetB2(2, seed=101),
- marginals=marginals,
- correlation=correlation,
- df=df,
- )
-
- x_gaussian = gaussian(n)
- x_student_t = student_t(n)
- threshold = stats.norm.ppf(0.99)
-
- def joint_tail_rate(x):
- tail_0 = x[:, 0] > threshold
- return np.mean(x[tail_0, 1] > threshold)
-
- gaussian_tail = joint_tail_rate(x_gaussian)
- student_t_tail = joint_tail_rate(x_student_t)
-
- assert student_t_tail > gaussian_tail + 0.08
-
-
-def test_student_t_copula_return_weights_shape_when_density_available():
- tm = StudentTCopula(
- sampler=DigitalNetB2(2, seed=37),
- marginals=[stats.norm(), stats.gamma(a=2.0)],
- correlation=[[1.0, 0.3], [0.3, 1.0]],
- df=6,
- )
-
- x, weights = tm(32, return_weights=True)
-
- assert x.shape == (32, 2)
- assert weights.shape == (32,)
- assert np.all(np.isfinite(weights))
- assert np.all(weights > 0.0)
-
-
-@pytest.mark.parametrize("df", [1.0, 100.0])
-def test_student_t_copula_boundary_df_values_are_finite(df):
- dimension = 3
- tm = StudentTCopula(
- sampler=DigitalNetB2(dimension, seed=39),
- marginals=[stats.norm()] * dimension,
- correlation=_equicorrelation(dimension, 0.4),
- df=df,
- )
-
- x = tm(128)
-
- assert x.shape == (128, dimension)
- assert np.all(np.isfinite(x))
-
-
-def test_student_t_copula_large_df_is_close_to_gaussian_copula():
- rho = 0.6
- correlation = [[1.0, rho], [rho, 1.0]]
- marginals = [stats.norm(), stats.norm()]
- gaussian = GaussianCopula(
- sampler=DigitalNetB2(2, seed=40),
- marginals=marginals,
- correlation=correlation,
- )
- student_t = StudentTCopula(
- sampler=DigitalNetB2(2, seed=40),
- marginals=marginals,
- correlation=correlation,
- df=100,
- )
-
- x_gaussian = gaussian(4096)
- x_student_t = student_t(4096)
- corr_gaussian = np.corrcoef(x_gaussian.T)[0, 1]
- corr_student_t = np.corrcoef(x_student_t.T)[0, 1]
-
- assert abs(corr_student_t - corr_gaussian) < 0.02
-
-
-@pytest.mark.parametrize("df", [0, -1, np.inf, "not-a-number"])
-def test_student_t_copula_invalid_df_raises_parameter_error(df):
- with pytest.raises(ParameterError, match="df"):
- StudentTCopula(
- sampler=DigitalNetB2(2, seed=41),
- marginals=[stats.norm(), stats.norm()],
- correlation=np.eye(2),
- df=df,
- )
-
-
-def test_student_t_copula_marginal_without_ppf_raises_clear_error():
- class NoPPF:
- pass
-
- with pytest.raises(ParameterError, match="ppf"):
- StudentTCopula(
- sampler=DigitalNetB2(1, seed=43),
- marginals=[NoPPF()],
- correlation=[[1.0]],
- df=4,
- )
-
-
-# Archimedean copulas
-
-
-def test_clayton_copula_output_shape_and_finite_values():
- tm = ClaytonCopula(
- sampler=DigitalNetB2(2, seed=57),
- marginals=[stats.norm(), stats.gamma(a=3.0, scale=2.0)],
- theta=2.0,
- )
-
- x = tm(128)
-
- assert x.shape == (128, 2)
- assert np.all(np.isfinite(x))
-
-
-def test_clayton_copula_return_weights_shape_when_density_available():
- tm = ClaytonCopula(
- sampler=DigitalNetB2(3, seed=59),
- marginals=[stats.norm(), stats.gamma(a=2.0), stats.expon()],
- theta=1.5,
- )
-
- x, weights = tm(32, return_weights=True)
-
- assert x.shape == (32, 3)
- assert weights.shape == (32,)
- assert np.all(np.isfinite(weights))
- assert np.all(weights > 0.0)
-
-
-@pytest.mark.parametrize("theta", [0, -1, np.inf, "not-a-number"])
-def test_clayton_copula_invalid_theta_raises_parameter_error(theta):
- with pytest.raises(ParameterError, match="theta"):
- ClaytonCopula(
- sampler=DigitalNetB2(2, seed=61),
- marginals=[stats.norm(), stats.norm()],
- theta=theta,
- )
-
-
-@pytest.mark.parametrize("dimension", [2, 3, 5])
-def test_clayton_copula_supports_general_dimension(dimension):
- tm = ClaytonCopula(
- sampler=DigitalNetB2(dimension, seed=63),
- marginals=[stats.norm()] * dimension,
- theta=2.0,
- )
-
- x = tm(128)
-
- assert x.shape == (128, dimension)
- assert np.all(np.isfinite(x))
-
-
-def test_clayton_copula_marginal_without_ppf_raises_clear_error():
- class NoPPF:
- pass
-
- with pytest.raises(ParameterError, match="ppf"):
- ClaytonCopula(
- sampler=DigitalNetB2(2, seed=67),
- marginals=[stats.norm(), NoPPF()],
- theta=2.0,
- )
-
-
-@pytest.mark.parametrize(
- "marginals",
- [
- [stats.norm(), stats.beta(a=2, b=5)],
- [stats.gamma(a=3), stats.expon()],
- [stats.lognorm(s=0.5), stats.norm()],
- ],
-)
-def test_clayton_copula_common_scipy_frozen_marginals_work(marginals):
- tm = ClaytonCopula(
- sampler=DigitalNetB2(2, seed=69),
- marginals=marginals,
- theta=2.0,
- )
-
- x = tm(128)
-
- assert x.shape == (128, 2)
- assert np.all(np.isfinite(x))
-
-
-def test_clayton_copula_endpoint_uniforms_are_clipped_to_finite_outputs():
- tm = ClaytonCopula(
- sampler=DigitalNetB2(2, seed=70),
- marginals=[stats.norm(), stats.lognorm(s=0.5)],
- theta=2.0,
- )
- u = np.array([[0.0, 1.0], [1.0, 0.0]])
-
- x = tm._transform(u)
-
- assert x.shape == (2, 2)
- assert np.all(np.isfinite(x))
-
-
-@pytest.mark.parametrize("dimension", [2, 3, 5])
-def test_clayton_copula_tiny_theta_is_near_independent(dimension):
- marginals = [stats.uniform()] * dimension
- tm = ClaytonCopula(
- sampler=DigitalNetB2(dimension, seed=70),
- marginals=marginals,
- theta=1e-8,
- )
- u = np.array(
- [
- [0.2, 0.7, 0.4, 0.6, 0.8],
- [0.4, 0.8, 0.9, 0.3, 0.2],
- [0.9, 0.1, 0.3, 0.7, 0.5],
- ]
- )[:, :dimension]
-
- x = tm._transform(u)
-
- assert x.shape == (3, dimension)
- assert np.all(np.isfinite(x))
- np.testing.assert_allclose(x, u, atol=5e-6)
-
-
-@pytest.mark.parametrize("dimension", [2, 3, 5])
-@pytest.mark.parametrize("theta", [20.0, 50.0])
-def test_clayton_copula_large_theta_is_finite(theta, dimension):
- tm = ClaytonCopula(
- sampler=DigitalNetB2(dimension, seed=70),
- marginals=[stats.norm()] * dimension,
- theta=theta,
- )
-
- x = tm(128)
-
- assert x.shape == (128, dimension)
- assert np.all(np.isfinite(x))
-
-
-def test_clayton_copula_positive_dependence_behavior():
- tm = ClaytonCopula(
- sampler=DigitalNetB2(2, seed=71),
- marginals=[stats.uniform(), stats.uniform()],
- theta=2.0,
- )
-
- x = tm(4096)
- empirical_corr = np.corrcoef(x.T)[0, 1]
-
- assert empirical_corr > 0.45
-
-
-def test_clayton_copula_has_stronger_lower_tail_than_gaussian_copula():
- theta = 2.0
- n = 2**12
- marginals = [stats.uniform(), stats.uniform()]
- # Clayton Kendall tau is theta/(theta+2); convert to Gaussian rho.
- rho = np.sin(np.pi * (theta / (theta + 2.0)) / 2.0)
-
- clayton = ClaytonCopula(
- sampler=DigitalNetB2(2, seed=73),
- marginals=marginals,
- theta=theta,
- )
- gaussian = GaussianCopula(
- sampler=DigitalNetB2(2, seed=73),
- marginals=marginals,
- correlation=[[1.0, rho], [rho, 1.0]],
- )
-
- x_clayton = clayton(n)
- x_gaussian = gaussian(n)
- threshold = 0.05
-
- def lower_tail_rate(x):
- tail_0 = x[:, 0] < threshold
- return np.mean(x[tail_0, 1] < threshold)
-
- clayton_tail = lower_tail_rate(x_clayton)
- gaussian_tail = lower_tail_rate(x_gaussian)
-
- assert clayton_tail > gaussian_tail + 0.2
-
-
-def test_frank_copula_output_shape_for_two_dimensions():
- tm = FrankCopula(
- sampler=DigitalNetB2(2, seed=75),
- marginals=[stats.norm(), stats.gamma(a=3.0, scale=2.0)],
- theta=5.0,
- )
-
- x = tm(128)
-
- assert x.shape == (128, 2)
- assert np.all(np.isfinite(x))
-
-
-@pytest.mark.parametrize("dimension", [3, 5])
-def test_frank_copula_positive_theta_supports_higher_dimensions(dimension):
- tm = FrankCopula(
- sampler=DigitalNetB2(dimension, seed=76),
- marginals=[stats.norm()] * dimension,
- theta=5.0,
- )
-
- x = tm(128)
-
- assert x.shape == (128, dimension)
- assert np.all(np.isfinite(x))
-
-
-def test_frank_copula_return_weights_shape_when_density_available():
- tm = FrankCopula(
- sampler=DigitalNetB2(3, seed=77),
- marginals=[stats.norm(), stats.gamma(a=2.0), stats.expon()],
- theta=4.0,
- )
-
- x, weights = tm(32, return_weights=True)
-
- assert x.shape == (32, 3)
- assert weights.shape == (32,)
- assert np.all(np.isfinite(weights))
- assert np.all(weights > 0.0)
-
-
-@pytest.mark.parametrize("theta", [0, np.inf, -np.inf, "not-a-number"])
-def test_frank_copula_invalid_theta_raises_parameter_error(theta):
- with pytest.raises(ParameterError, match="theta"):
- FrankCopula(
- sampler=DigitalNetB2(2, seed=78),
- marginals=[stats.norm(), stats.norm()],
- theta=theta,
- )
-
-
-def test_frank_copula_negative_theta_rejected_above_two_dimensions():
- with pytest.raises(ParameterError, match="d=2"):
- FrankCopula(
- sampler=DigitalNetB2(3, seed=79),
- marginals=[stats.norm(), stats.norm(), stats.norm()],
- theta=-2.0,
- )
-
-
-def test_frank_copula_dimension_mismatch_raises_dimension_error():
- with pytest.raises(DimensionError, match="marginals"):
- FrankCopula(
- sampler=DigitalNetB2(2, seed=80),
- marginals=[stats.norm(), stats.norm(), stats.norm()],
- theta=5.0,
- )
-
-
-def test_frank_copula_marginal_without_ppf_raises_clear_error():
- class NoPPF:
- pass
-
- with pytest.raises(ParameterError, match="ppf"):
- FrankCopula(
- sampler=DigitalNetB2(2, seed=82),
- marginals=[stats.norm(), NoPPF()],
- theta=5.0,
- )
-
-
-def test_frank_copula_positive_dependence_behavior():
- tm = FrankCopula(
- sampler=DigitalNetB2(2, seed=84),
- marginals=[stats.uniform(), stats.uniform()],
- theta=6.0,
- )
-
- x = tm(4096)
- empirical_corr = np.corrcoef(x.T)[0, 1]
-
- assert empirical_corr > 0.45
-
-
-@pytest.mark.parametrize(
- "theta,dimension",
- [
- (1e-8, 3),
- (-1e-8, 2),
- ],
-)
-def test_frank_copula_tiny_theta_is_close_to_independence(theta, dimension):
- marginals = [stats.uniform()] * dimension
- tm = FrankCopula(
- sampler=DigitalNetB2(dimension, seed=86),
- marginals=marginals,
- theta=theta,
- )
- u = np.array(
- [
- [0.2, 0.7, 0.4, 0.6, 0.8],
- [0.4, 0.8, 0.9, 0.3, 0.2],
- [0.9, 0.1, 0.3, 0.7, 0.5],
- ]
- )[:, :dimension]
-
- x = tm._transform(u)
-
- assert x.shape == (3, dimension)
- assert np.all(np.isfinite(x))
- np.testing.assert_allclose(x, u, atol=5e-6)
-
-
-@pytest.mark.parametrize(
- "theta,dimension",
- [
- (50.0, 5),
- (-50.0, 2),
- ],
-)
-def test_frank_copula_large_theta_is_finite(theta, dimension):
- tm = FrankCopula(
- sampler=DigitalNetB2(dimension, seed=87),
- marginals=[stats.norm()] * dimension,
- theta=theta,
- )
-
- x = tm(128)
-
- assert x.shape == (128, dimension)
- assert np.all(np.isfinite(x))
-
-
-def test_frank_copula_negative_theta_produces_negative_dependence_in_2d():
- tm = FrankCopula(
- sampler=DigitalNetB2(2, seed=88),
- marginals=[stats.uniform(), stats.uniform()],
- theta=-6.0,
- )
-
- x = tm(4096)
- empirical_corr = np.corrcoef(x.T)[0, 1]
-
- assert empirical_corr < -0.35
-
-
-def test_gumbel_copula_output_shape_and_finite_values():
- tm = GumbelCopula(
- sampler=DigitalNetB2(2, seed=79),
- marginals=[stats.norm(), stats.gamma(a=3.0, scale=2.0)],
- theta=2.0,
- )
-
- x = tm(128)
-
- assert x.shape == (128, 2)
- assert np.all(np.isfinite(x))
-
-
-def test_gumbel_copula_return_weights_shape_when_density_available():
- tm = GumbelCopula(
- sampler=DigitalNetB2(3, seed=81),
- marginals=[stats.norm(), stats.gamma(a=2.0), stats.expon()],
- theta=1.5,
- )
-
- x, weights = tm(32, return_weights=True)
-
- assert x.shape == (32, 3)
- assert weights.shape == (32,)
- assert np.all(np.isfinite(weights))
- assert np.all(weights > 0.0)
-
-
-@pytest.mark.parametrize("theta", [0, 0.5, -1, np.inf, "not-a-number"])
-def test_gumbel_copula_invalid_theta_raises_parameter_error(theta):
- with pytest.raises(ParameterError, match="theta"):
- GumbelCopula(
- sampler=DigitalNetB2(2, seed=83),
- marginals=[stats.norm(), stats.norm()],
- theta=theta,
- )
-
-
-def test_gumbel_copula_theta_one_is_independent_marginal_transform():
- marginals = [stats.norm(loc=-1.0, scale=2.0), stats.gamma(a=2.0, scale=3.0)]
- tm = GumbelCopula(
- sampler=DigitalNetB2(2, seed=85),
- marginals=marginals,
- theta=1.0,
- )
- u = np.array([[0.2, 0.7], [0.4, 0.8], [0.9, 0.1]])
-
- x = tm._transform(u)
- expected = np.column_stack(
- [marginals[j].ppf(u[:, j]) for j in range(len(marginals))]
- )
-
- np.testing.assert_allclose(x, expected, rtol=1e-12, atol=1e-12)
-
-
-@pytest.mark.parametrize("dimension", [2, 3, 5])
-def test_gumbel_copula_theta_close_to_one_is_near_independent(dimension):
- marginals = [stats.uniform()] * dimension
- tm = GumbelCopula(
- sampler=DigitalNetB2(dimension, seed=85),
- marginals=marginals,
- theta=1.000001,
- )
- u = np.array(
- [
- [0.2, 0.7, 0.4, 0.6, 0.8],
- [0.4, 0.8, 0.9, 0.3, 0.2],
- [0.9, 0.1, 0.3, 0.7, 0.5],
- ]
- )[:, :dimension]
-
- x = tm._transform(u)
-
- assert x.shape == (3, dimension)
- assert np.all(np.isfinite(x))
- np.testing.assert_allclose(x, u, atol=5e-5)
-
-
-@pytest.mark.parametrize("dimension", [2, 3, 5])
-@pytest.mark.parametrize("theta", [20.0, 50.0])
-def test_gumbel_copula_large_theta_is_finite(theta, dimension):
- tm = GumbelCopula(
- sampler=DigitalNetB2(dimension, seed=86),
- marginals=[stats.norm()] * dimension,
- theta=theta,
- )
-
- x = tm(128)
-
- assert x.shape == (128, dimension)
- assert np.all(np.isfinite(x))
-
-
-@pytest.mark.parametrize("dimension", [2, 3, 5])
-def test_gumbel_copula_supports_general_dimension(dimension):
- tm = GumbelCopula(
- sampler=DigitalNetB2(dimension, seed=87),
- marginals=[stats.norm()] * dimension,
- theta=2.0,
- )
-
- x = tm(128)
-
- assert x.shape == (128, dimension)
- assert np.all(np.isfinite(x))
-
-
-def test_gumbel_copula_marginal_without_ppf_raises_clear_error():
- class NoPPF:
- pass
-
- with pytest.raises(ParameterError, match="ppf"):
- GumbelCopula(
- sampler=DigitalNetB2(2, seed=89),
- marginals=[stats.norm(), NoPPF()],
- theta=2.0,
- )
-
-
-@pytest.mark.parametrize(
- "marginals",
- [
- [stats.norm(), stats.beta(a=2, b=5)],
- [stats.gamma(a=3), stats.expon()],
- [stats.lognorm(s=0.5), stats.norm()],
- ],
-)
-def test_gumbel_copula_common_scipy_frozen_marginals_work(marginals):
- tm = GumbelCopula(
- sampler=DigitalNetB2(2, seed=91),
- marginals=marginals,
- theta=2.0,
- )
-
- x = tm(128)
-
- assert x.shape == (128, 2)
- assert np.all(np.isfinite(x))
-
-
-def test_gumbel_copula_endpoint_uniforms_are_clipped_to_finite_outputs():
- tm = GumbelCopula(
- sampler=DigitalNetB2(2, seed=93),
- marginals=[stats.norm(), stats.lognorm(s=0.5)],
- theta=2.0,
- )
- u = np.array([[0.0, 1.0], [1.0, 0.0]])
-
- x = tm._transform(u)
-
- assert x.shape == (2, 2)
- assert np.all(np.isfinite(x))
-
-
-def test_gumbel_copula_positive_dependence_behavior():
- tm = GumbelCopula(
- sampler=DigitalNetB2(2, seed=95),
- marginals=[stats.uniform(), stats.uniform()],
- theta=2.0,
- )
-
- x = tm(4096)
- empirical_corr = np.corrcoef(x.T)[0, 1]
-
- assert empirical_corr > 0.45
-
-
-def test_gumbel_copula_has_stronger_upper_tail_than_gaussian_copula():
- theta = 2.0
- n = 2**12
- marginals = [stats.uniform(), stats.uniform()]
- # Gumbel Kendall tau is 1 - 1/theta; convert to Gaussian rho.
- rho = np.sin(np.pi * (1.0 - 1.0 / theta) / 2.0)
-
- gumbel = GumbelCopula(
- sampler=DigitalNetB2(2, seed=97),
- marginals=marginals,
- theta=theta,
- )
- gaussian = GaussianCopula(
- sampler=DigitalNetB2(2, seed=97),
- marginals=marginals,
- correlation=[[1.0, rho], [rho, 1.0]],
- )
-
- x_gumbel = gumbel(n)
- x_gaussian = gaussian(n)
- threshold = 0.95
-
- def upper_tail_rate(x):
- tail_0 = x[:, 0] > threshold
- return np.mean(x[tail_0, 1] > threshold)
-
- gumbel_tail = upper_tail_rate(x_gumbel)
- gaussian_tail = upper_tail_rate(x_gaussian)
-
- assert gumbel_tail > gaussian_tail + 0.15
-
-
-# Weights, fallback behavior, spawn, and edge cases
-
-
-@pytest.mark.parametrize(
- "copula_cls",
- [GaussianCopula, StudentTCopula, ClaytonCopula, GumbelCopula, FrankCopula],
-)
-def test_copula_weight_fallback_warns_once_when_density_methods_are_missing(
- copula_cls,
-):
- tm = _make_copula(
- copula_cls,
- dimension=2,
- marginals=[PPFOnlyMarginal(), PPFOnlyMarginal()],
- )
- x = np.full((4, 2), 0.5)
- expected_message = getattr(
- tm,
- "_missing_weight_warning_message",
- f"{copula_cls.__name__} marginals must implement 'cdf' and "
- "'pdf' or 'logpdf' to compute density weights. "
- "Weights will be treated as 1.",
- )
-
- assert "_unit_weight_with_warning" not in copula_cls.__dict__
- assert (
- tm._unit_weight_with_warning.__func__
- is AbstractCopula._unit_weight_with_warning
- )
-
- with pytest.warns(UserWarning) as warning_info:
- weights = tm._weight(x)
-
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- second_weights = tm._weight(x)
-
- np.testing.assert_allclose(weights, np.ones(4))
- np.testing.assert_allclose(second_weights, np.ones(4))
- assert str(warning_info[0].message) == expected_message
- assert caught == []
-
-
-def test_student_t_weight_falls_back_when_multivariate_t_is_unavailable():
- tm = StudentTCopula(
- DigitalNetB2(2, seed=115),
- marginals=[stats.norm(), stats.norm()],
- correlation=np.eye(2),
- df=4,
- )
- tm._mvt_scipy = None
-
- with pytest.warns(UserWarning, match="Weights will be treated as 1"):
- weights = tm._weight(np.full((3, 2), 0.25))
-
- np.testing.assert_allclose(weights, np.ones(3))
-
-
-def test_gaussian_weight_uses_pdf_branch_when_logpdf_is_unavailable():
- tm = GaussianCopula(
- DigitalNetB2(2, seed=117),
- marginals=[UnitPDFMarginal(), UnitPDFMarginal()],
- correlation=[[1.0, 0.4], [0.4, 1.0]],
- )
-
- weights = tm._weight(np.array([[0.25, 0.5], [0.75, 0.5]]))
-
- assert weights.shape == (2,)
- assert np.all(np.isfinite(weights))
- assert np.all(weights > 0.0)
-
-
-def test_gumbel_theta_one_weight_is_independent_marginal_density():
- tm = GumbelCopula(
- DigitalNetB2(2, seed=119),
- marginals=[stats.gamma(a=2.0), stats.expon()],
- theta=1.0,
- )
- x = np.array([[1.0, 0.5], [2.0, 1.5]])
- expected = stats.gamma(a=2.0).pdf(x[:, 0]) * stats.expon().pdf(x[:, 1])
-
- weights = tm._weight(x)
-
- np.testing.assert_allclose(weights, expected)
-
-
-def test_gen_copula_samples_composed_transform_branch():
- inner = GaussianCopula(
- DigitalNetB2(2, seed=121),
- marginals=[stats.uniform(), stats.uniform()],
- correlation=[[1.0, 0.3], [0.3, 1.0]],
- )
- outer = ClaytonCopula(inner, marginals=[stats.uniform(), stats.uniform()], theta=1.5)
-
- v = outer.gen_copula_samples(n_min=4, n_max=8)
-
- assert v.shape == (4, 2)
- assert np.all(np.isfinite(v))
- assert np.all((0.0 <= v) & (v <= 1.0))
-
-
-@pytest.mark.parametrize(
- "copula_cls",
- [GaussianCopula, StudentTCopula, ClaytonCopula, GumbelCopula, FrankCopula],
-)
-def test_copula_spawn_same_dimension_and_reject_different_dimension(copula_cls):
- tm = _make_copula(copula_cls, dimension=2)
-
- spawned = tm.spawn(s=1, dimensions=[2])
- assert len(spawned) == 1
- assert isinstance(spawned[0], copula_cls)
- assert spawned[0](4).shape == (4, 2)
-
- with pytest.raises(DimensionError):
- tm._spawn(DigitalNetB2(3, seed=123), 3)
-
-
-def test_frank_one_dimensional_weight_covers_zero_order_eulerian_term():
- tm = FrankCopula(
- DigitalNetB2(1, seed=125),
- marginals=[UnitPDFMarginal()],
- theta=3.0,
- )
-
- weights = tm._weight(np.array([[0.25], [0.75]]))
-
- assert weights.shape == (2,)
- assert np.all(np.isfinite(weights))
- assert np.all(weights > 0.0)
-
-
-def test_frank_rejects_large_negative_theta_when_exponential_overflows():
- with np.errstate(over="ignore"):
- with pytest.raises(ParameterError, match="too close to 0 or too large"):
- FrankCopula(
- DigitalNetB2(2, seed=127),
- marginals=[stats.uniform(), stats.uniform()],
- theta=-1000.0,
- )
diff --git a/test/test_discrete_distribs.py b/test/test_dd_discrete_distribs.py
similarity index 100%
rename from test/test_discrete_distribs.py
rename to test/test_dd_discrete_distribs.py
diff --git a/test/test_dd_dummy_sampler.py b/test/test_dd_dummy_sampler.py
new file mode 100644
index 000000000..50d5005b8
--- /dev/null
+++ b/test/test_dd_dummy_sampler.py
@@ -0,0 +1,107 @@
+import unittest
+
+import numpy as np
+
+from qmcpy import DummySampler
+from qmcpy.util import ParameterError
+
+
+PLACEHOLDER_ERROR = "construction placeholder"
+
+
+class TestDummySampler(unittest.TestCase):
+
+ def test_dummy_sampler_constructs_dimension_one(self):
+ sampler = DummySampler(1)
+
+ self.assertEqual(sampler.d, 1)
+ self.assertEqual(sampler.replications, 1)
+ self.assertTrue(sampler.no_replications)
+ self.assertEqual(sampler.mimics, "StdUniform")
+ self.assertEqual(sampler.parameters, [])
+
+ def test_dummy_sampler_constructs_larger_dimensions(self):
+ sampler = DummySampler(3, seed=7)
+
+ self.assertEqual(sampler.d, 3)
+ self.assertEqual(sampler.replications, 1)
+ self.assertTrue(sampler.no_replications)
+ self.assertTrue(np.array_equal(sampler.dvec, np.arange(3)))
+
+ def test_dummy_sampler_constructs_larger_dimension_with_replications(self):
+ sampler = DummySampler(4, replications=3, seed=7)
+
+ self.assertEqual(sampler.d, 4)
+ self.assertEqual(sampler.replications, 3)
+ self.assertFalse(sampler.no_replications)
+ self.assertTrue(np.array_equal(sampler.dvec, np.arange(4)))
+
+ def test_dummy_sampler_direct_sampling_raises_placeholder_error(self):
+ sampler = DummySampler(2)
+
+ with self.assertRaisesRegex(ParameterError, PLACEHOLDER_ERROR):
+ sampler(8)
+
+ def test_dummy_sampler_replicated_direct_sampling_raises_placeholder_error(self):
+ sampler = DummySampler(2, replications=3)
+
+ with self.assertRaisesRegex(ParameterError, PLACEHOLDER_ERROR):
+ sampler(8)
+
+ def test_dummy_sampler_supported_calling_conventions_raise_placeholder_error(self):
+ sampler = DummySampler(2)
+
+ with self.assertRaisesRegex(ParameterError, PLACEHOLDER_ERROR):
+ sampler(n=4)
+ with self.assertRaisesRegex(ParameterError, PLACEHOLDER_ERROR):
+ sampler(n_min=2, n_max=6)
+ with self.assertRaisesRegex(ParameterError, PLACEHOLDER_ERROR):
+ sampler(n=2, n_min=6)
+
+ def test_dummy_sampler_nonzero_n_min_raises_placeholder_error(self):
+ sampler = DummySampler(2)
+
+ with self.assertRaisesRegex(ParameterError, PLACEHOLDER_ERROR):
+ sampler(n_min=5, n_max=9)
+
+ def test_dummy_sampler_rejects_return_binary(self):
+ sampler = DummySampler(2)
+
+ with self.assertRaisesRegex(ParameterError, PLACEHOLDER_ERROR):
+ sampler(4, return_binary=True)
+
+ def test_dummy_sampler_internal_gen_samples_raises_placeholder_error(self):
+ sampler = DummySampler(2)
+
+ with self.assertRaisesRegex(ParameterError, PLACEHOLDER_ERROR):
+ sampler._gen_samples(n_min=5, n_max=9, return_binary=False, warn=True)
+
+ def test_dummy_sampler_spawn_preserves_relevant_fields(self):
+ sampler = DummySampler(2, replications=3, seed=11)
+
+ spawned = sampler.spawn(s=2, dimensions=[1, 5])
+
+ self.assertEqual([spawn.d for spawn in spawned], [1, 5])
+ self.assertEqual([spawn.replications for spawn in spawned], [3, 3])
+ self.assertTrue(all(isinstance(spawn, DummySampler) for spawn in spawned))
+
+ def test_dummy_sampler_spawn_without_explicit_replications(self):
+ sampler = DummySampler(2, seed=11)
+
+ spawned = sampler.spawn(s=1, dimensions=4)[0]
+
+ self.assertEqual(spawned.d, 4)
+ self.assertEqual(spawned.replications, 1)
+ self.assertTrue(spawned.no_replications)
+
+ def test_dummy_sampler_limits_are_enforced(self):
+ with self.assertRaisesRegex(ParameterError, "dimension greater than dimension limit"):
+ DummySampler(10_002)
+
+ sampler = DummySampler(1)
+ with self.assertRaisesRegex(ParameterError, "n_limit"):
+ sampler(n_min=0, n_max=2**32 + 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_dummy_sampler.py b/test/test_dummy_sampler.py
deleted file mode 100644
index 24bec84eb..000000000
--- a/test/test_dummy_sampler.py
+++ /dev/null
@@ -1,111 +0,0 @@
-import numpy as np
-import pytest
-
-from qmcpy import DummySampler
-from qmcpy.util import ParameterError
-
-
-PLACEHOLDER_ERROR = "construction placeholder"
-
-
-def test_dummy_sampler_constructs_dimension_one():
- sampler = DummySampler(1)
-
- assert sampler.d == 1
- assert sampler.replications == 1
- assert sampler.no_replications
- assert sampler.mimics == "StdUniform"
- assert sampler.parameters == []
-
-
-def test_dummy_sampler_constructs_larger_dimensions():
- sampler = DummySampler(3, seed=7)
-
- assert sampler.d == 3
- assert sampler.replications == 1
- assert sampler.no_replications
- assert np.array_equal(sampler.dvec, np.arange(3))
-
-
-def test_dummy_sampler_constructs_larger_dimension_with_replications():
- sampler = DummySampler(4, replications=3, seed=7)
-
- assert sampler.d == 4
- assert sampler.replications == 3
- assert not sampler.no_replications
- assert np.array_equal(sampler.dvec, np.arange(4))
-
-
-def test_dummy_sampler_direct_sampling_raises_placeholder_error():
- sampler = DummySampler(2)
-
- with pytest.raises(ParameterError, match=PLACEHOLDER_ERROR):
- sampler(8)
-
-
-def test_dummy_sampler_replicated_direct_sampling_raises_placeholder_error():
- sampler = DummySampler(2, replications=3)
-
- with pytest.raises(ParameterError, match=PLACEHOLDER_ERROR):
- sampler(8)
-
-
-def test_dummy_sampler_supported_calling_conventions_raise_placeholder_error():
- sampler = DummySampler(2)
-
- with pytest.raises(ParameterError, match=PLACEHOLDER_ERROR):
- sampler(n=4)
- with pytest.raises(ParameterError, match=PLACEHOLDER_ERROR):
- sampler(n_min=2, n_max=6)
- with pytest.raises(ParameterError, match=PLACEHOLDER_ERROR):
- sampler(n=2, n_min=6)
-
-
-def test_dummy_sampler_nonzero_n_min_raises_placeholder_error():
- sampler = DummySampler(2)
-
- with pytest.raises(ParameterError, match=PLACEHOLDER_ERROR):
- sampler(n_min=5, n_max=9)
-
-
-def test_dummy_sampler_rejects_return_binary():
- sampler = DummySampler(2)
-
- with pytest.raises(ParameterError, match=PLACEHOLDER_ERROR):
- sampler(4, return_binary=True)
-
-
-def test_dummy_sampler_internal_gen_samples_raises_placeholder_error():
- sampler = DummySampler(2)
-
- with pytest.raises(ParameterError, match=PLACEHOLDER_ERROR):
- sampler._gen_samples(n_min=5, n_max=9, return_binary=False, warn=True)
-
-
-def test_dummy_sampler_spawn_preserves_relevant_fields():
- sampler = DummySampler(2, replications=3, seed=11)
-
- spawned = sampler.spawn(s=2, dimensions=[1, 5])
-
- assert [spawn.d for spawn in spawned] == [1, 5]
- assert [spawn.replications for spawn in spawned] == [3, 3]
- assert all(isinstance(spawn, DummySampler) for spawn in spawned)
-
-
-def test_dummy_sampler_spawn_without_explicit_replications():
- sampler = DummySampler(2, seed=11)
-
- spawned = sampler.spawn(s=1, dimensions=4)[0]
-
- assert spawned.d == 4
- assert spawned.replications == 1
- assert spawned.no_replications
-
-
-def test_dummy_sampler_limits_are_enforced():
- with pytest.raises(ParameterError, match="dimension greater than dimension limit"):
- DummySampler(10_002)
-
- sampler = DummySampler(1)
- with pytest.raises(ParameterError, match="n_limit"):
- sampler(n_min=0, n_max=2**32 + 1)
diff --git a/test/test_integrate.py b/test/test_ee_integrate.py
similarity index 100%
rename from test/test_integrate.py
rename to test/test_ee_integrate.py
diff --git a/test/test_keister.py b/test/test_ee_keister.py
similarity index 100%
rename from test/test_keister.py
rename to test/test_ee_keister.py
diff --git a/test/test_pi_problem.py b/test/test_ee_pi_problem.py
similarity index 100%
rename from test/test_pi_problem.py
rename to test/test_ee_pi_problem.py
diff --git a/test/test_fast_transform_fallbacks.py b/test/test_fast_transform_fallbacks.py
deleted file mode 100644
index 2dcbd2b7a..000000000
--- a/test/test_fast_transform_fallbacks.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import numpy as np
-import pytest
-
-from qmcpy import (
- fftbr,
- fftbr_torch,
- fwht,
- fwht_torch,
- ifftbr,
- ifftbr_torch,
- omega_fftbr,
- omega_fftbr_torch,
- omega_fwht,
- omega_fwht_torch,
-)
-
-
-def test_non_torch_transforms_basic():
- rng = np.random.default_rng(11)
- x = rng.random(8) + 1j * rng.random(8)
- y = fftbr(x)
- assert y.shape == x.shape
- xr = ifftbr(y)
- assert xr.shape == x.shape
-
- a = rng.random(8)
- b = fwht(a)
- assert b.shape == a.shape
-
- omega = omega_fftbr(3)
- assert omega.shape[0] == 2**3
- omega2 = omega_fwht(3)
- assert omega2.shape[0] == 2**3
-
-
-def test_torch_fallbacks_raise():
- with pytest.raises(Exception):
- fftbr_torch()
- with pytest.raises(Exception):
- ifftbr_torch()
- with pytest.raises(Exception):
- fwht_torch()
- with pytest.raises(Exception):
- omega_fftbr_torch()
- with pytest.raises(Exception):
- omega_fwht_torch()
diff --git a/test/test_financial_option_quick.py b/test/test_financial_option_quick.py
deleted file mode 100644
index bd9f3022b..000000000
--- a/test/test_financial_option_quick.py
+++ /dev/null
@@ -1,94 +0,0 @@
-import numpy as np
-
-from qmcpy import FinancialOption
-import qmcpy
-
-
-class SmallSampler(qmcpy.AbstractDiscreteDistribution):
- def __init__(self, d=3):
- super().__init__(
- dimension=d, replications=1, seed=123, d_limit=100, n_limit=1024
- )
-
- def _gen_samples(self, n_min, n_max, return_binary=False, warn=True):
- n = n_max - n_min
- # return shape (replications, n, d)
- arr = np.tile(np.linspace(0.1, 1.0, n)[:, None], (1, self.d))
- return arr.reshape(self.replications, n, self.d)
-
-
-def test_financial_option_payoffs_and_exact():
- sampler = SmallSampler(d=3)
- fo = FinancialOption(
- sampler,
- option="EUROPEAN",
- call_put="CALL",
- volatility=0.5,
- start_price=30,
- strike_price=25,
- interest_rate=0.01,
- t_final=1,
- )
- gbm = np.array([[30.0, 28.0, 35.0]])
- c = fo.payoff_european_call(gbm)
- p = fo.payoff_european_put(gbm)
- assert c.shape == (1,)
- assert p.shape == (1,)
-
- # Asian arithmetic trapezoidal
- fo_asian = FinancialOption(
- sampler,
- option="ASIAN",
- asian_mean="ARITHMETIC",
- asian_mean_quadrature_rule="TRAPEZOIDAL",
- )
- gbm2 = np.array([[30.0, 32.0, 34.0]])
- a_call = fo_asian.payoff_asian_arithmetic_trap_call(gbm2)
- assert a_call.shape == (1,)
-
- # geometric right call
- fo_geo = FinancialOption(
- sampler,
- option="ASIAN",
- asian_mean="GEOMETRIC",
- asian_mean_quadrature_rule="RIGHT",
- )
- g_call = fo_geo.payoff_asian_geometric_right_call(np.array([[30.0, 30.0, 30.0]]))
- assert g_call.shape == (1,)
-
- # barrier options: up and down behaviors
- fo_barrier_up = FinancialOption(
- sampler, option="BARRIER", barrier_in_out="IN", barrier_price=25, start_price=20
- )
- gbm_up = np.array([[20.0, 26.0, 27.0]])
- v = fo_barrier_up.payoff_barrier_in_up_call(gbm_up)
- assert v.shape == (1,)
-
- fo_barrier_out = FinancialOption(
- sampler,
- option="BARRIER",
- barrier_in_out="OUT",
- barrier_price=40,
- start_price=30,
- )
- gbm_out = np.array([[30.0, 32.0, 33.0]])
- v2 = fo_barrier_out.payoff_barrier_out_up_call(gbm_out)
- assert v2.shape == (1,)
-
- # lookback
- fo_lb = FinancialOption(sampler, option="LOOKBACK")
- lb = fo_lb.payoff_lookback_call(np.array([[10.0, 9.0, 12.0]]))
- assert lb.shape == (1,)
-
- # digital
- fo_dig = FinancialOption(sampler, option="DIGITAL", digital_payout=5)
- dig = fo_dig.payoff_digital_call(np.array([[10.0, 11.0, 12.0]]))
- assert dig.shape == (1,)
-
- # exact value for European should return a float
- val = fo.get_exact_value()
- assert np.isscalar(val)
-
- # exact value for Asian geometric right
- val2 = fo_geo.get_exact_value()
- assert np.isscalar(val2)
diff --git a/test/test_flatten_qmcpy_imports.py b/test/test_flatten_qmcpy_imports.py
deleted file mode 100644
index c7fc36fdb..000000000
--- a/test/test_flatten_qmcpy_imports.py
+++ /dev/null
@@ -1,330 +0,0 @@
-import json
-from pathlib import Path
-
-from scripts.flatten_qmcpy_imports import (
- _load_qmcpy_public_names,
- flatten_imports,
- main,
-)
-
-
-def _nested_import(module, imported):
- return f"from {'qmcpy.' + module} import {imported}"
-
-
-def test_flatten_imports_basic():
- source = (
- _nested_import("integrand", "Keister")
- + "\n"
- + _nested_import("discrete_distribution.lattice", "Lattice as LD")
- + "\nfrom qmcpy import DigitalNetB2\nimport qmcpy.util\n"
- ).encode()
-
- updated, count = flatten_imports(
- source, frozenset({"DigitalNetB2", "Keister", "Lattice"})
- )
-
- assert count == 3
- assert updated == (
- b"from qmcpy import DigitalNetB2, Keister, Lattice as LD\n"
- b"import qmcpy.util\n"
- )
-
-
-def test_flatten_preserves_private():
- source = (
- _nested_import("_internal._helpers", "PublicHelper")
- + "\n"
- + _nested_import(
- "true_measure.uniform_triangle",
- "UniformTriangle, _UniformTriangleAdapter",
- )
- + "\n"
- + _nested_import(
- "true_measure.copula",
- "(\n AbstractCopula,\n _validate_dimension,\n)",
- )
- + "\n"
- + _nested_import("integrand", "Keister")
- + "\n"
- ).encode()
-
- updated, count = flatten_imports(source, frozenset({"Keister"}))
-
- assert count == 1
- assert updated == source.replace(
- _nested_import("integrand", "Keister").encode(),
- b"from qmcpy import Keister",
- )
-
-
-def test_private_module_splits_groups():
- source = (
- b"from qmcpy import Zeta\n"
- b"from qmcpy._internal._helpers import PublicHelper\n"
- b"from qmcpy import Alpha\n"
- )
-
- updated, count = flatten_imports(source)
-
- assert count == 0
- assert updated == source
-
-
-def test_flatten_preserves_util_imports():
- source = (
- b"from qmcpy.util import ParameterError\n"
- b"from qmcpy.util.transforms import tf_exp\n"
- )
-
- updated, count = flatten_imports(source, frozenset({"ParameterError", "tf_exp"}))
-
- assert (updated, count) == (source, 0)
-
-
-def test_flatten_keeps_nonpublic_names():
- source = b"from qmcpy.stopping_criterion.pf_gp_ci import PFGPCIData\n"
-
- updated, count = flatten_imports(source, frozenset({"PFGPCI"}))
-
- assert (updated, count) == (source, 0)
-
-
-def test_flatten_no_public_api_noop():
- source = (_nested_import("integrand", "Keister") + "\n").encode()
-
- updated, count = flatten_imports(source)
-
- assert (updated, count) == (source, 0)
-
-
-def test_flatten_preserve_str_literals():
- source = b'text = """\nfrom qmcpy.integrand import Keister\n"""\n'
-
- updated, count = flatten_imports(source, frozenset({"Keister"}))
-
- assert count == 0
- assert updated == source
-
-
-def test_python_string_protection_applies_to_every_rewrite_stage():
- string_body = (
- b'text = """\n'
- b"from qmcpy.integrand import Keister\n"
- b"from qmcpy import Zeta,Beta\n"
- b"from qmcpy import Alpha\n"
- b"from qmcpy import *\n"
- b"from qmcpy import *\n"
- b'"""\n'
- )
- source = string_body + b"from qmcpy.integrand import Keister\n"
-
- updated, count = flatten_imports(source, frozenset({"Keister"}))
-
- assert count == 1
- assert updated == string_body + b"from qmcpy import Keister\n"
-
-
-def test_python_tokenize_failure_is_fail_closed():
- source = b'"""unterminated\nfrom qmcpy.integrand import Keister\n'
-
- assert flatten_imports(source, frozenset({"Keister"})) == (source, 0)
-
-
-def test_flatten_skip_star_expansion():
- source = (
- b"from qmcpy import *\n\n"
- b"def f(Lattice):\n"
- b" return Lattice\n\n"
- b"y = Keister(dimension=2)\n"
- b"x = Lattice(dimension=2)\n"
- )
-
- updated, count = flatten_imports(source, frozenset({"Keister", "Lattice"}))
-
- assert count == 0
- assert updated == source
-
-
-def test_notebook_star_dedup():
- notebook = {
- "cells": [
- {
- "cell_type": "code",
- "source": [
- _nested_import("integrand", "*") + "\n",
- _nested_import("true_measure", "*"),
- ],
- }
- ]
- }
- source = json.dumps(notebook, indent=1).encode()
-
- updated, count = flatten_imports(source, frozenset({"Keister"}))
-
- assert count == 3
- assert json.loads(updated)["cells"][0]["source"] == ["from qmcpy import *"]
-
-
-def test_named_imports_merge_sort():
- source = (
- b"from qmcpy import Zeta,Beta\n"
- b"from qmcpy import Alpha\n"
- b"\n"
- b"from qmcpy import Gamma\n"
- )
-
- updated, count = flatten_imports(source)
-
- assert count == 1
- assert updated == (
- b"from qmcpy import Alpha, Beta, Zeta\n"
- b"\n"
- b"from qmcpy import Gamma\n"
- )
- assert flatten_imports(updated) == (updated, 0)
-
-
-def test_merge_paren_and_single_line():
- source = b"""from qmcpy import (
- KernelDigShiftInvar,
- KernelDigShiftInvarAdaptiveAlpha,
- KernelDigShiftInvarCombined,
- KernelShiftInvar,
- KernelShiftInvarCombined,
-)
-from qmcpy import tf_exp_eps, tf_exp_eps_inv
-"""
-
- updated, count = flatten_imports(source)
-
- assert count == 1
- assert updated == b"""from qmcpy import (
- KernelDigShiftInvar,
- KernelDigShiftInvarAdaptiveAlpha,
- KernelDigShiftInvarCombined,
- KernelShiftInvar,
- KernelShiftInvarCombined,
- tf_exp_eps,
- tf_exp_eps_inv,
-)
-"""
- assert flatten_imports(updated) == (updated, 0)
-
-
-def test_merge_same_scope_only():
- source = (
- b"if enabled:\n"
- b" from qmcpy import Zeta\n"
- b" from qmcpy import Alpha as First\n"
- b"else:\n"
- b" from qmcpy import Beta\n"
- b"from qmcpy import _Private\n"
- b"from qmcpy import Gamma # keep this comment\n"
- )
-
- updated, count = flatten_imports(source)
-
- assert count == 1
- assert updated == (
- b"if enabled:\n"
- b" from qmcpy import Alpha as First, Zeta\n"
- b"else:\n"
- b" from qmcpy import Beta\n"
- b"from qmcpy import _Private\n"
- b"from qmcpy import Gamma # keep this comment\n"
- )
-
-
-def test_notebook_named_merge():
- notebook = {
- "cells": [
- {
- "cell_type": "code",
- "source": [
- "from qmcpy import Zeta\n",
- "from qmcpy import Alpha,Beta\n",
- "print(Alpha)\n",
- ],
- }
- ]
- }
- source = json.dumps(notebook, indent=1).encode()
-
- updated, count = flatten_imports(source)
-
- assert count == 1
- assert json.loads(updated)["cells"][0]["source"] == [
- "from qmcpy import Alpha, Beta, Zeta\n",
- "print(Alpha)\n",
- ]
- assert flatten_imports(updated) == (updated, 0)
-
-
-def test_notebook_flattens_nested_imports_only_in_code_cells():
- nested_import = _nested_import("integrand", "Keister") + "\n"
- metadata_import = _nested_import("true_measure", "Gaussian") + "\n"
- string_literal = f'text = "{nested_import.rstrip()}"\n'
- multiline_string = ['text = """\n', nested_import, '"""\n']
- notebook = {
- "metadata": {"source": [metadata_import]},
- "cells": [
- {"cell_type": "markdown", "source": [nested_import]},
- {"cell_type": "code", "source": [nested_import]},
- {"cell_type": "code", "source": [string_literal]},
- {"cell_type": "code", "source": multiline_string},
- ]
- }
- source = json.dumps(notebook, indent=1).encode()
-
- updated, count = flatten_imports(source, frozenset({"Keister"}))
-
- cells = json.loads(updated)["cells"]
- assert count == 1
- assert json.loads(updated)["metadata"]["source"] == [metadata_import]
- assert cells[0]["source"] == [nested_import]
- assert cells[1]["source"] == ["from qmcpy import Keister\n"]
- assert cells[2]["source"] == [string_literal]
- assert cells[3]["source"] == multiline_string
- assert flatten_imports(updated, frozenset({"Keister"})) == (updated, 0)
-
-
-def test_markdown_import_examples_are_flattened(tmp_path):
- path = tmp_path / "example.md"
- path.write_bytes(
- b'Example with unmatched prose delimiter: """\n\n'
- b"```python\n"
- b"from qmcpy.integrand import Keister\n"
- b"```\n"
- )
-
- assert main([str(path)]) == 0
- assert b"from qmcpy import Keister" in path.read_bytes()
-
-
-def test_check_mode_no_write(tmp_path):
- path = tmp_path / "example.py"
- original = (_nested_import("true_measure", "Gaussian") + "\n").encode()
- path.write_bytes(original)
-
- assert main(["--check", str(path)]) == 1
- assert path.read_bytes() == original
-
- assert main([str(path)]) == 0
- assert path.read_bytes() == b"from qmcpy import Gaussian\n"
- assert main(["--check", str(path)]) == 0
-
-
-def test_public_names_optional_free_stable():
- repository_root = Path(__file__).resolve().parent.parent
- names = _load_qmcpy_public_names(repository_root)
-
- assert names is not None
- assert "Gaussian" in names
- assert "Keister" in names
- # Optional dependencies are blocked in the probe context, so fallback
- # exports are part of the deterministic name set.
- assert "PFGPCI" in names
- # Helpers that are deliberately not part of the top-level API.
- assert "PFGPCIData" not in names
- assert "TriangularDistribution" not in names
\ No newline at end of file
diff --git a/test/test_ft_fast_transform_fallbacks.py b/test/test_ft_fast_transform_fallbacks.py
new file mode 100644
index 000000000..7bf9c3493
--- /dev/null
+++ b/test/test_ft_fast_transform_fallbacks.py
@@ -0,0 +1,68 @@
+import unittest
+
+import numpy as np
+
+try:
+ import torch
+except ImportError:
+ torch = None
+
+from qmcpy import (
+ fftbr,
+ fftbr_torch,
+ fwht,
+ fwht_torch,
+ ifftbr,
+ ifftbr_torch,
+ omega_fftbr,
+ omega_fftbr_torch,
+ omega_fwht,
+ omega_fwht_torch,
+)
+
+
+class TestFastTransformFallbacks(unittest.TestCase):
+
+ def test_non_torch_transforms_basic(self):
+ rng = np.random.default_rng(11)
+ x = rng.random(8) + 1j * rng.random(8)
+ y = fftbr(x)
+ self.assertEqual(y.shape, x.shape)
+ xr = ifftbr(y)
+ self.assertEqual(xr.shape, x.shape)
+
+ a = rng.random(8)
+ b = fwht(a)
+ self.assertEqual(b.shape, a.shape)
+
+ omega = omega_fftbr(3)
+ self.assertEqual(omega.shape[0], 2**3)
+ omega2 = omega_fwht(3)
+ self.assertEqual(omega2.shape[0], 2**3)
+
+ def test_torch_transforms_or_fallbacks(self):
+ if torch is None:
+ calls = (
+ (fftbr_torch, np.zeros(8, dtype=complex)),
+ (ifftbr_torch, np.zeros(8, dtype=complex)),
+ (fwht_torch, np.zeros(8)),
+ (omega_fftbr_torch, 3),
+ (omega_fwht_torch, 3),
+ )
+ for transform, argument in calls:
+ with self.subTest(transform=transform.__name__):
+ with self.assertRaisesRegex(ModuleNotFoundError, "requires torch"):
+ transform(argument)
+ return
+
+ complex_x = torch.zeros(8, dtype=torch.complex64)
+ real_x = torch.zeros(8)
+ self.assertEqual(fftbr_torch(complex_x).shape, complex_x.shape)
+ self.assertEqual(ifftbr_torch(complex_x).shape, complex_x.shape)
+ self.assertEqual(fwht_torch(real_x).shape, real_x.shape)
+ self.assertEqual(omega_fftbr_torch(3).shape[0], 2**3)
+ self.assertEqual(omega_fwht_torch(3).shape[0], 2**3)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_ig_financial_option_quick.py b/test/test_ig_financial_option_quick.py
new file mode 100644
index 000000000..b05b8708b
--- /dev/null
+++ b/test/test_ig_financial_option_quick.py
@@ -0,0 +1,101 @@
+import unittest
+
+import numpy as np
+
+from qmcpy import AbstractDiscreteDistribution, FinancialOption
+
+
+class SmallSampler(AbstractDiscreteDistribution):
+ def __init__(self, d=3):
+ super().__init__(
+ dimension=d, replications=1, seed=123, d_limit=100, n_limit=1024
+ )
+
+ def _gen_samples(self, n_min, n_max, return_binary=False, warn=True):
+ n = n_max - n_min
+ # return shape (replications, n, d)
+ arr = np.tile(np.linspace(0.1, 1.0, n)[:, None], (1, self.d))
+ return arr.reshape(self.replications, n, self.d)
+
+
+class TestFinancialOptionPayoffs(unittest.TestCase):
+
+ def test_financial_option_payoffs_and_exact(self):
+ sampler = SmallSampler(d=3)
+ fo = FinancialOption(
+ sampler,
+ option="EUROPEAN",
+ call_put="CALL",
+ volatility=0.5,
+ start_price=30,
+ strike_price=25,
+ interest_rate=0.01,
+ t_final=1,
+ )
+ gbm = np.array([[30.0, 28.0, 35.0]])
+ c = fo.payoff_european_call(gbm)
+ p = fo.payoff_european_put(gbm)
+ self.assertEqual(c.shape, (1,))
+ self.assertEqual(p.shape, (1,))
+
+ # Asian arithmetic trapezoidal
+ fo_asian = FinancialOption(
+ sampler,
+ option="ASIAN",
+ asian_mean="ARITHMETIC",
+ asian_mean_quadrature_rule="TRAPEZOIDAL",
+ )
+ gbm2 = np.array([[30.0, 32.0, 34.0]])
+ a_call = fo_asian.payoff_asian_arithmetic_trap_call(gbm2)
+ self.assertEqual(a_call.shape, (1,))
+
+ # geometric right call
+ fo_geo = FinancialOption(
+ sampler,
+ option="ASIAN",
+ asian_mean="GEOMETRIC",
+ asian_mean_quadrature_rule="RIGHT",
+ )
+ g_call = fo_geo.payoff_asian_geometric_right_call(np.array([[30.0, 30.0, 30.0]]))
+ self.assertEqual(g_call.shape, (1,))
+
+ # barrier options: up and down behaviors
+ fo_barrier_up = FinancialOption(
+ sampler, option="BARRIER", barrier_in_out="IN", barrier_price=25, start_price=20
+ )
+ gbm_up = np.array([[20.0, 26.0, 27.0]])
+ v = fo_barrier_up.payoff_barrier_in_up_call(gbm_up)
+ self.assertEqual(v.shape, (1,))
+
+ fo_barrier_out = FinancialOption(
+ sampler,
+ option="BARRIER",
+ barrier_in_out="OUT",
+ barrier_price=40,
+ start_price=30,
+ )
+ gbm_out = np.array([[30.0, 32.0, 33.0]])
+ v2 = fo_barrier_out.payoff_barrier_out_up_call(gbm_out)
+ self.assertEqual(v2.shape, (1,))
+
+ # lookback
+ fo_lb = FinancialOption(sampler, option="LOOKBACK")
+ lb = fo_lb.payoff_lookback_call(np.array([[10.0, 9.0, 12.0]]))
+ self.assertEqual(lb.shape, (1,))
+
+ # digital
+ fo_dig = FinancialOption(sampler, option="DIGITAL", digital_payout=5)
+ dig = fo_dig.payoff_digital_call(np.array([[10.0, 11.0, 12.0]]))
+ self.assertEqual(dig.shape, (1,))
+
+ # exact value for European should return a float
+ val = fo.get_exact_value()
+ self.assertTrue(np.isscalar(val))
+
+ # exact value for Asian geometric right
+ val2 = fo_geo.get_exact_value()
+ self.assertTrue(np.isscalar(val2))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_integrands.py b/test/test_ig_integrands.py
similarity index 100%
rename from test/test_integrands.py
rename to test/test_ig_integrands.py
diff --git a/test/test_option.py b/test/test_ig_option.py
similarity index 100%
rename from test/test_option.py
rename to test/test_ig_option.py
diff --git a/test/test_option_ml.py b/test/test_ig_option_ml.py
similarity index 100%
rename from test/test_option_ml.py
rename to test/test_ig_option_ml.py
diff --git a/test/test_install_mpmc_pyg.py b/test/test_install_mpmc_pyg.py
deleted file mode 100644
index 41b1b4e0b..000000000
--- a/test/test_install_mpmc_pyg.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""Tests for the platform-specific MPMC dependency installer."""
-
-import subprocess
-from types import SimpleNamespace
-
-import pytest
-
-from qmcpy.util import install_mpmc_pyg
-
-
-def _torch(version="2.12.1+cpu", cuda=None, hip=None):
- return SimpleNamespace(
- __version__=version,
- version=SimpleNamespace(cuda=cuda, hip=hip),
- )
-
-
-def test_torch_versions_include_baseline_fallback():
- """Wheel lookup tries an exact patch release, then its minor baseline."""
- assert install_mpmc_pyg.torch_versions("2.12.1+cpu") == ["2.12.1", "2.12.0"]
- assert install_mpmc_pyg.torch_versions("2.12.0") == ["2.12.0"]
-
- with pytest.raises(RuntimeError, match="Unable to parse torch version"):
- install_mpmc_pyg.torch_versions("development")
-
-
-@pytest.mark.parametrize(
- ("torch_module", "expected"),
- [
- (_torch(), "cpu"),
- (_torch(cuda="12.6"), "cu126"),
- (_torch(cuda="13.0.1"), "cu130"),
- ],
-)
-def test_accelerator_tag(torch_module, expected):
- """PyTorch build metadata maps to the expected PyG wheel tag."""
- assert install_mpmc_pyg.accelerator_tag(torch_module) == expected
-
-
-def test_accelerator_tag_rejects_rocm():
- """The installer directs unsupported ROCm users to upstream guidance."""
- with pytest.raises(RuntimeError, match="does not currently support ROCm"):
- install_mpmc_pyg.accelerator_tag(_torch(hip="6.3"))
-
-
-def test_main_retries_with_torch_minor_baseline(monkeypatch):
- """A missing exact wheel page falls back to the minor baseline page."""
- calls = []
-
- def fake_run(*args):
- calls.append(args)
- if args[-1].endswith("torch-2.12.1+cpu.html"):
- raise subprocess.CalledProcessError(1, args)
-
- monkeypatch.setattr(install_mpmc_pyg, "run", fake_run)
-
- install_mpmc_pyg.main(_torch())
-
- assert calls[0][-1] == "torch-geometric>=2.6.1"
- assert calls[1][-1] == "https://data.pyg.org/whl/torch-2.12.1+cpu.html"
- assert calls[2][-1] == "https://data.pyg.org/whl/torch-2.12.0+cpu.html"
- assert "--only-binary" in calls[1]
-
-
-def test_main_explains_that_torch_must_be_installed(monkeypatch):
- """Running the helper before installing the extra gives a useful error."""
- def missing_torch(_name):
- raise ModuleNotFoundError("No module named 'torch'", name="torch")
-
- monkeypatch.setattr(install_mpmc_pyg.importlib, "import_module", missing_torch)
-
- with pytest.raises(RuntimeError, match=r"install 'qmcpy\[mpmc\]'"):
- install_mpmc_pyg.main()
-
-
-def test_main_reports_missing_wheel(monkeypatch):
- """Exhausting candidate wheel pages reports the build that failed."""
- def fail_pyg_lib(*args):
- if "pyg_lib>=0.6.0" in args:
- raise subprocess.CalledProcessError(1, args)
-
- monkeypatch.setattr(install_mpmc_pyg, "run", fail_pyg_lib)
-
- with pytest.raises(RuntimeError, match=r"torch 2\.12\.1\+cpu \(cpu\)"):
- install_mpmc_pyg.main(_torch())
diff --git a/test/test_kernels.py b/test/test_kn_kernels.py
similarity index 100%
rename from test/test_kernels.py
rename to test/test_kn_kernels.py
diff --git a/test/test_mpmc_optional_imports.py b/test/test_mpmc_optional_imports.py
deleted file mode 100644
index 33b71b841..000000000
--- a/test/test_mpmc_optional_imports.py
+++ /dev/null
@@ -1,100 +0,0 @@
-import ast
-import builtins
-from pathlib import Path
-
-import pytest
-
-
-def _execute_optional_import(blocked_import):
- repository_root = Path(__file__).resolve().parent.parent
- init_path = repository_root / "qmcpy" / "__init__.py"
- init_tree = ast.parse(init_path.read_text())
- optional_import = next(
- node
- for node in init_tree.body
- if isinstance(node, ast.Try)
- and any(
- isinstance(statement, ast.ImportFrom)
- and statement.module == "discrete_distribution.mpmc"
- for statement in node.body
- )
- )
-
- import qmcpy
-
- real_import = builtins.__import__
-
- def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):
- missing_module = blocked_import(name, fromlist, level)
- if missing_module is not None:
- raise ModuleNotFoundError(
- "blocked optional dependency",
- name=missing_module,
- )
- return real_import(name, globals, locals, fromlist, level)
-
- test_builtins = vars(builtins).copy()
- test_builtins["__import__"] = guarded_import
- namespace = {"__builtins__": test_builtins, "__package__": "qmcpy"}
- module = ast.Module(body=[optional_import], type_ignores=[])
- exec(compile(module, str(init_path), "exec"), namespace)
- return namespace
-
-
-def test_mpmc_utils_remain_available_without_pyg():
- pytest.importorskip("torch")
-
- def block_pyg_models(name, fromlist, level):
- if level == 1 and name == "discrete_distribution.mpmc.models":
- return "torch_geometric"
- return None
-
- namespace = _execute_optional_import(block_pyg_models)
-
- import qmcpy
-
- assert namespace["mpmc_utils"] is qmcpy.mpmc_utils
- assert namespace["mpmc_utils"].__name__ == (
- "qmcpy.discrete_distribution.mpmc.utils"
- )
- assert "utils" not in namespace
-
- with pytest.raises(ModuleNotFoundError, match="MPMC_net.*torch_geometric") as error:
- namespace["MPMC_net"]()
- assert error.value.name == "torch_geometric"
-
-
-def test_mpmc_placeholders_report_missing_torch():
- def block_torch_utils(name, fromlist, level):
- if (
- level == 1
- and name == "discrete_distribution.mpmc"
- and "utils" in fromlist
- ):
- return "torch"
- return None
-
- namespace = _execute_optional_import(block_torch_utils)
-
- with pytest.raises(ModuleNotFoundError, match="mpmc_utils.*torch") as error:
- namespace["mpmc_utils"].L2star
- assert error.value.name == "torch"
-
- with pytest.raises(ModuleNotFoundError, match="MPMC_net.*torch") as error:
- namespace["MPMC_net"]()
- assert error.value.name == "torch"
-
-
-def test_mpmc_placeholder_missing_torch_scatter():
- pytest.importorskip("torch")
-
- def block_torch_scatter(name, fromlist, level):
- if level == 1 and name == "discrete_distribution.mpmc.models":
- return "torch_scatter"
- return None
-
- namespace = _execute_optional_import(block_torch_scatter)
-
- with pytest.raises(ModuleNotFoundError, match="MPMC_net.*torch_scatter") as error:
- namespace["MPMC_net"]()
- assert error.value.name == "torch_scatter"
diff --git a/test/test_plot_and_stop.py b/test/test_plot_and_stop.py
deleted file mode 100644
index dd564cd9d..000000000
--- a/test/test_plot_and_stop.py
+++ /dev/null
@@ -1,155 +0,0 @@
-import sys
-import types
-import numpy as np
-import builtins
-import pytest
-
-import qmcpy
-from qmcpy import plot_proj
-from qmcpy.util import stop_notebook
-
-
-class FakeAxes:
- def __init__(self):
- self.removed = False
- self.calls = []
-
- def remove(self):
- self.removed = True
-
- def set_xlim(self, *a, **k):
- self.calls.append(("set_xlim", a))
-
- def set_ylim(self, *a, **k):
- self.calls.append(("set_ylim", a))
-
- def set_xticks(self, *a, **k):
- self.calls.append(("set_xticks", a))
-
- def set_yticks(self, *a, **k):
- self.calls.append(("set_yticks", a))
-
- def set_aspect(self, *a, **k):
- self.calls.append(("set_aspect", a))
-
- def grid(self, *a, **k):
- self.calls.append(("grid", a))
-
- def tick_params(self, *a, **k):
- self.calls.append(("tick_params", a))
-
- def set_xlabel(self, *a, **k):
- self.calls.append(("set_xlabel", a))
-
- def set_ylabel(self, *a, **k):
- self.calls.append(("set_ylabel", a))
-
- def scatter(self, *a, **k):
- self.calls.append(("scatter", a))
-
-
-class FakeFig:
- def __init__(self):
- self.tl = False
-
- def tight_layout(self, *a, **k):
- self.tl = True
-
-
-def make_fake_matplotlib(nrows, ncols):
- plt = types.ModuleType("matplotlib.pyplot")
- plt.style = types.SimpleNamespace()
- plt.style.use = lambda *a, **k: None
- plt.rcParams = {
- "font.family": "sans-serif",
- "axes.prop_cycle": types.SimpleNamespace(
- by_key=lambda: {"color": ["k", "b", "r"]}
- ),
- }
-
- def subplots(nrows=1, ncols=1, figsize=None, squeeze=False):
- fig = FakeFig()
- ax = np.empty((nrows, ncols), dtype=object)
- for i in range(nrows):
- for j in range(ncols):
- ax[i, j] = FakeAxes()
- return fig, ax
-
- plt.subplots = subplots
- plt.suptitle = lambda *a, **k: None
- return plt
-
-
-class DummySampler(qmcpy.AbstractDiscreteDistribution):
- def __init__(self, d=2):
- super().__init__(dimension=d, replications=1, seed=1, d_limit=10, n_limit=100)
-
- def _gen_samples(self, n_min, n_max, return_binary=False, warn=True):
- n = n_max - n_min
- return np.tile(np.arange(n)[:, None] / max(1, n - 1), (1, 1, self.d)).reshape(
- self.replications, n, self.d
- )
-
- def __repr__(self):
- return "DummySampler"
-
-
-def test_plot_proj_with_fake_matplotlib_and_sampler(monkeypatch):
- # Inject fake matplotlib.pyplot
- fake_plt = make_fake_matplotlib(1, 1)
- # Create a proper matplotlib package module with colors submodule
- fake_matplotlib = types.ModuleType("matplotlib")
- fake_matplotlib.pyplot = fake_plt
- fake_matplotlib.colors = types.SimpleNamespace()
- monkeypatch.setitem(sys.modules, "matplotlib.pyplot", fake_plt)
- monkeypatch.setitem(sys.modules, "matplotlib", fake_matplotlib)
-
- sampler = DummySampler(d=3)
- fig, ax = plot_proj(
- sampler,
- n=4,
- d_horizontal=1,
- d_vertical=2,
- math_ind=True,
- marker_size=1,
- figfac=1,
- )
- assert isinstance(fig, FakeFig)
- assert isinstance(ax, np.ndarray)
- # At least one axes should have scatter calls or be removed
- found = False
- for a in ax.flatten():
- if getattr(a, "removed", False) or any(c[0] == "scatter" for c in a.calls):
- found = True
- break
- assert found
-
-
-def test_plot_proj_with_callable_sampler(monkeypatch):
- # sampler not instance of AbstractDiscreteDistribution -> uses t_i labels
- fake_plt = make_fake_matplotlib(1, 1)
- fake_matplotlib = types.ModuleType("matplotlib")
- fake_matplotlib.pyplot = fake_plt
- fake_matplotlib.colors = types.SimpleNamespace()
- monkeypatch.setitem(sys.modules, "matplotlib.pyplot", fake_plt)
- monkeypatch.setitem(sys.modules, "matplotlib", fake_matplotlib)
-
- def sampler_callable(n):
- return np.zeros((n, 1))
-
- fig, ax = plot_proj(
- sampler_callable, n=3, d_horizontal=0, d_vertical=0, math_ind=False
- )
- assert isinstance(fig, FakeFig)
-
-
-def test_stop_notebook_yes_and_no(monkeypatch):
- # When input is 'yes' nothing should happen
- monkeypatch.setattr(builtins, "input", lambda prompt="": "yes")
- # Should not raise
- stop_notebook("prompt")
-
- # When input is not 'yes' should exit
- monkeypatch.setattr(builtins, "input", lambda prompt="": "no")
- with pytest.raises(SystemExit):
- stop_notebook("prompt")
diff --git a/test/test_product_measure.py b/test/test_product_measure.py
deleted file mode 100644
index c91b313bd..000000000
--- a/test/test_product_measure.py
+++ /dev/null
@@ -1,277 +0,0 @@
-import numpy as np
-import pytest
-import scipy.stats as stats
-
-from qmcpy import (
- AcceptanceRejection,
- DigitalNetB2,
- DummySampler,
- Gaussian,
- GaussianCopula,
- ProductMeasure,
- SciPyWrapper,
- Uniform,
- ZeroInflatedExpUniform,
-)
-from qmcpy.util import DimensionError, ParameterError
-
-
-def test_product_measure_zero_inflated_with_scipy_uniform_shape():
- n = 32
- marginals = [
- ZeroInflatedExpUniform(DummySampler(1), p_zero=0.4, lam=1.5),
- SciPyWrapper(DummySampler(1), stats.uniform(loc=2.0, scale=3.0)),
- ]
- tm = ProductMeasure(sampler=DigitalNetB2(2, seed=23), marginals=marginals)
-
- x = tm(n)
-
- assert x.shape == (n, 2)
- assert np.any(x[:, 0] == 0.0)
- assert np.all((2.0 <= x[:, 1]) & (x[:, 1] <= 5.0))
-
-
-def test_product_measure_replication_shape():
- n = 16
- r = 3
- marginals = [
- ZeroInflatedExpUniform(DummySampler(1), p_zero=0.4, lam=1.5),
- Uniform(DummySampler(1), lower_bound=2.0, upper_bound=5.0),
- ]
- tm = ProductMeasure(
- sampler=DigitalNetB2(2, seed=23, replications=r),
- marginals=marginals,
- )
-
- x = tm(n)
-
- assert x.shape == (r, n, 2)
-
-
-def test_product_measure_marginals_with_different_dimensions():
- n = 32
- marginals = [
- Gaussian(
- DummySampler(2),
- mean=[1.0, -1.0],
- covariance=[[2.0, 0.25], [0.25, 1.0]],
- ),
- ZeroInflatedExpUniform(DummySampler(1), p_zero=0.4, lam=1.5),
- ]
- tm = ProductMeasure(sampler=DigitalNetB2(3, seed=31), marginals=marginals)
-
- x = tm(n)
-
- assert tm.d == 3
- assert np.array_equal(tm.marginal_dimensions, np.array([2, 1]))
- assert x.shape == (n, 3)
- assert np.all(np.isfinite(x[:, :2]))
- assert np.all(x[:, 2] >= 0.0)
-
-
-def test_product_measure_block_split_range_and_weight_product():
- n = 16
- marginals = [
- Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
- Uniform(
- DummySampler(2),
- lower_bound=[20.0, 30.0],
- upper_bound=[24.0, 36.0],
- ),
- ]
- tm = ProductMeasure(sampler=DigitalNetB2(3, seed=41), marginals=marginals)
-
- u = tm.discrete_distrib.gen_samples(n)
- x = tm._transform(u)
- x_call, jac = tm(n, return_weights=True)
- expected = np.concatenate(
- [
- marginals[0]._jacobian_transform_r(u[..., :1], return_weights=False),
- marginals[1]._jacobian_transform_r(u[..., 1:], return_weights=False),
- ],
- axis=-1,
- )
- expected_range = np.array([[10.0, 12.0], [20.0, 24.0], [30.0, 36.0]])
-
- assert x.shape == (n, 3)
- assert np.allclose(tm.range, expected_range)
- assert np.allclose(x, expected)
- assert np.all((10.0 <= x[:, 0]) & (x[:, 0] <= 12.0))
- assert np.all((20.0 <= x[:, 1]) & (x[:, 1] <= 24.0))
- assert np.all((30.0 <= x[:, 2]) & (x[:, 2] <= 36.0))
- assert np.allclose(tm._weight(x), 1.0 / (2.0 * 4.0 * 6.0))
- assert x_call.shape == (n, 3)
- assert np.allclose(jac, 2.0 * 4.0 * 6.0)
-
-
-def test_product_measure_invalid_inputs():
- with pytest.raises(ParameterError, match="nonempty list of marginals"):
- ProductMeasure(sampler=DigitalNetB2(1, seed=7), marginals=[])
-
- with pytest.raises(ParameterError, match="marginal"):
- ProductMeasure(sampler=DigitalNetB2(1, seed=7), marginals=[object()])
-
- with pytest.raises(ParameterError, match="AbstractDiscreteDistribution"):
- ProductMeasure(sampler=object(), marginals=[Uniform(DummySampler(1))])
-
- marginals = [Uniform(DummySampler(1))]
- with pytest.raises(DimensionError, match="sum of marginal dimensions"):
- ProductMeasure(sampler=DigitalNetB2(2, seed=7), marginals=marginals)
-
-
-def test_product_measure_rejects_non_dimension_preserving_marginal():
- marginal = AcceptanceRejection(
- DigitalNetB2(2, seed=7),
- lambda x: np.ones(len(x)),
- 1.0,
- 1.0,
- )
-
- with pytest.raises(DimensionError, match="dimension-preserving"):
- ProductMeasure(DigitalNetB2(2, seed=11), [marginal])
-
-
-def test_product_measure_spawn_preserves_marginal_blocks_and_replaces_outer_sampler():
- marginals = [
- Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
- Uniform(
- DummySampler(2),
- lower_bound=[20.0, 30.0],
- upper_bound=[24.0, 36.0],
- ),
- ]
- tm = ProductMeasure(sampler=DigitalNetB2(3, seed=41), marginals=marginals)
-
- spawn = tm.spawn(s=1)[0]
-
- assert isinstance(spawn, ProductMeasure)
- assert spawn.d == 3
- assert spawn.marginals == tm.marginals
- assert spawn.discrete_distrib is not tm.discrete_distrib
- assert np.array_equal(spawn.marginal_dimensions, np.array([1, 2]))
-
- with pytest.raises(DimensionError):
- tm.spawn(s=1, dimensions=4)
-
-
-def test_product_measure_does_not_use_marginal_dummy_sampler_values():
- marginals = [
- Uniform(DummySampler(1), lower_bound=0.0, upper_bound=2.0),
- Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
- ]
-
- with pytest.raises(ParameterError, match="construction placeholder"):
- marginals[0].discrete_distrib(4)
-
- tm = ProductMeasure(sampler=DigitalNetB2(2, seed=19), marginals=marginals)
- x = tm(8)
-
- assert x.shape == (8, 2)
- assert np.all((0.0 <= x[:, 0]) & (x[:, 0] <= 2.0))
- assert np.all((10.0 <= x[:, 1]) & (x[:, 1] <= 12.0))
-
-
-def test_product_measure_same_outer_seed_matches_different_outer_seed_changes():
- marginals = [
- Uniform(DummySampler(1), lower_bound=0.0, upper_bound=2.0),
- Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
- ]
-
- first = ProductMeasure(sampler=DigitalNetB2(2, seed=101), marginals=marginals)(16)
- same_outer = ProductMeasure(sampler=DigitalNetB2(2, seed=101), marginals=marginals)(16)
- different_outer = ProductMeasure(sampler=DigitalNetB2(2, seed=102), marginals=marginals)(16)
-
- assert np.array_equal(first, same_outer)
- assert not np.array_equal(first, different_outer)
-
-
-def test_product_measure_replication_means_close_to_uniform_targets():
- n = 1024
- r = 4
- marginals = [
- Uniform(DummySampler(1), lower_bound=0.0, upper_bound=2.0),
- Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
- ]
- tm = ProductMeasure(
- sampler=DigitalNetB2(2, seed=101, replications=r),
- marginals=marginals,
- )
-
- x = tm(n)
- replication_means = x.mean(axis=1)
-
- assert x.shape == (r, n, 2)
- assert np.allclose(replication_means[:, 0], 1.0, atol=0.03)
- assert np.allclose(replication_means[:, 1], 11.0, atol=0.03)
-
-
-def test_product_measure_with_scipywrapper_beta_marginal():
- n = 64
- marginals = [
- Uniform(DummySampler(1), lower_bound=-1.0, upper_bound=1.0),
- SciPyWrapper(DummySampler(1), stats.beta(a=2.0, b=5.0)),
- ]
- tm = ProductMeasure(sampler=DigitalNetB2(2, seed=71), marginals=marginals)
-
- x = tm(n)
-
- assert x.shape == (n, 2)
- assert np.all((-1.0 <= x[:, 0]) & (x[:, 0] <= 1.0))
- assert np.all((0.0 <= x[:, 1]) & (x[:, 1] <= 1.0))
-
-
-def test_product_measure_matches_equivalent_scipywrapper():
- n = 128
- seed = 55
- scipy_marginals = [stats.norm(loc=0.0, scale=1.0), stats.gamma(a=2.0, scale=1.0)]
- product_marginals = [
- SciPyWrapper(DummySampler(1), scipy_marginals[0]),
- SciPyWrapper(DummySampler(1), scipy_marginals[1]),
- ]
-
- product_samples = ProductMeasure(
- sampler=DigitalNetB2(2, seed=seed),
- marginals=product_marginals,
- )(n)
- scipy_samples = SciPyWrapper(DigitalNetB2(2, seed=seed), scipy_marginals)(n)
-
- assert np.array_equal(product_samples, scipy_samples)
-
-
-def test_product_measure_with_gaussian_copula_marginal():
- n = 64
- copula = GaussianCopula(
- DummySampler(2),
- marginals=[stats.beta(a=2.0, b=5.0), stats.gamma(a=3.0, scale=1.0)],
- correlation=[[1.0, 0.5], [0.5, 1.0]],
- )
- marginals = [copula, Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0)]
- tm = ProductMeasure(sampler=DigitalNetB2(3, seed=81), marginals=marginals)
-
- x = tm(n)
-
- assert x.shape == (n, 3)
- assert np.all((0.0 <= x[:, 0]) & (x[:, 0] <= 1.0))
- assert np.all(x[:, 1] >= 0.0)
- assert np.all((10.0 <= x[:, 2]) & (x[:, 2] <= 12.0))
-
-
-def test_product_measure_recursive_transform_sampling_supported_but_weights_restricted():
- recursive_marginal = Uniform(
- Uniform(DummySampler(1), lower_bound=0.0, upper_bound=1.0),
- lower_bound=2.0,
- upper_bound=4.0,
- )
- direct_marginal = Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0)
- tm = ProductMeasure(
- sampler=DigitalNetB2(2, seed=91),
- marginals=[recursive_marginal, direct_marginal],
- )
-
- x = tm(16)
-
- assert x.shape == (16, 2)
- assert np.all((2.0 <= x[:, 0]) & (x[:, 0] <= 4.0))
- assert np.all((10.0 <= x[:, 1]) & (x[:, 1] <= 12.0))
- with pytest.raises(ParameterError, match="direct marginal"):
- tm(16, return_weights=True)
diff --git a/test/test_accumulate_data.py b/test/test_sc_accumulate_data.py
similarity index 100%
rename from test/test_accumulate_data.py
rename to test/test_sc_accumulate_data.py
diff --git a/test/test_cubbayes_vec.py b/test/test_sc_cubbayes_vec.py
similarity index 100%
rename from test/test_cubbayes_vec.py
rename to test/test_sc_cubbayes_vec.py
diff --git a/test/test_stopping_criteria.py b/test/test_sc_stopping_criteria.py
similarity index 100%
rename from test/test_stopping_criteria.py
rename to test/test_sc_stopping_criteria.py
diff --git a/test/test_scipy_wrapper_custom.py b/test/test_scipy_wrapper_custom.py
deleted file mode 100644
index dd713e934..000000000
--- a/test/test_scipy_wrapper_custom.py
+++ /dev/null
@@ -1,302 +0,0 @@
-import warnings
-
-import pytest
-import numpy as np
-import scipy.stats as stats
-
-from qmcpy import DigitalNetB2, SciPyWrapper, StudentT, ZeroInflatedExpUniform
-
-from qmcpy.true_measure.triangular import TriangularDistribution
-from qmcpy.util import DimensionError, ParameterError
-
-
-MISSING_PDF_WARNING = "no 'pdf' or 'logpdf'"
-
-
-def _missing_pdf_warnings(caught):
- return [
- warning
- for warning in caught
- if issubclass(warning.category, UserWarning)
- and MISSING_PDF_WARNING in str(warning.message)
- ]
-
-
-def test_mvn_dependence_correlation_and_moment():
- """
- Check that passing a SciPy multivariate normal through SciPyWrapper
- preserves correlation and the mixed moment E[X1 X2].
- """
- sampler = DigitalNetB2(2, seed=5)
- rho_target = 0.7
- cov = [[1.0, rho_target], [rho_target, 1.0]]
- mvn = stats.multivariate_normal(mean=[0.0, 0.0], cov=cov)
- tm_mvn = SciPyWrapper(sampler, scipy_distribs=mvn)
-
- n = 4096
- x = tm_mvn(n)
-
- rho_hat = np.corrcoef(x.T)[0, 1]
- est_moment = np.mean(x[:, 0] * x[:, 1])
-
- assert np.isfinite(rho_hat)
- assert np.isfinite(est_moment)
-
- assert abs(rho_hat - rho_target) < 0.05
- assert abs(est_moment - rho_target) < 0.05
-
-
-def test_triangular_custom_marginal_range_and_shape():
- """
- Make sure our custom triangular marginal behaves sensibly:
- samples stay in the right interval and the empirical mean is close
- to the analytic mean.
- """
- tri = TriangularDistribution(c=0.3, loc=-1.0, scale=2.0)
- tm = SciPyWrapper(DigitalNetB2(1, seed=11), scipy_distribs=tri)
-
- n = 4096
- x = tm(n).ravel()
-
- assert x.min() >= -1.1
- assert x.max() <= 1.1
-
- a = -1.0
- b = 1.0
- m = -1.0 + 0.3 * 2.0
- true_mean = (a + b + m) / 3.0
- emp_mean = x.mean()
- assert abs(emp_mean - true_mean) < 0.05
-
-
-def test_zero_inflated_zero_rate():
- """
- Check that the zero-inflated exponential distribution preserves the
- specified probability mass at X = 0.
- """
- p_zero = 0.4
- sampler = DigitalNetB2(1, seed=17)
- tm = ZeroInflatedExpUniform(sampler, p_zero=p_zero, lam=1.5)
-
- n = 4096
- samples = tm(n)
- x = samples.ravel()
- zero_rate = np.mean(x == 0.0)
-
- assert samples.shape == (n, 1)
- assert abs(zero_rate - p_zero) < 0.05
-
-
-def test_zero_inflated_replications_shape():
- tm = ZeroInflatedExpUniform(
- DigitalNetB2(1, seed=17, replications=2),
- p_zero=0.4,
- lam=1.5,
- )
-
- x = tm(8)
-
- assert x.shape == (2, 8, 1)
- assert np.all(x >= 0.0)
-
-
-@pytest.mark.parametrize("p_zero", [0.0, 1.0, -0.1, 1.1])
-def test_zero_inflated_rejects_invalid_p_zero(p_zero):
- with pytest.raises(ParameterError, match="p_zero must be in"):
- ZeroInflatedExpUniform(
- DigitalNetB2(1, seed=17),
- p_zero=p_zero,
- lam=1.5,
- )
-
-
-@pytest.mark.parametrize("lam", [0.0, -1.0])
-def test_zero_inflated_rejects_nonpositive_lam(lam):
- with pytest.raises(ParameterError, match="lam must be positive"):
- ZeroInflatedExpUniform(
- DigitalNetB2(1, seed=17),
- p_zero=0.4,
- lam=lam,
- )
-
-
-def test_zero_inflated_requires_one_dimensional_sampler():
- with pytest.raises(
- DimensionError,
- match="requires a one-dimensional sampler",
- ):
- ZeroInflatedExpUniform(
- DigitalNetB2(2, seed=17),
- p_zero=0.4,
- lam=1.5,
- )
-
-
-def test_zero_inflated_inverse_transform_exact_values():
- tm = ZeroInflatedExpUniform(
- DigitalNetB2(1, seed=17),
- p_zero=0.4,
- lam=2.0,
- )
- u = np.array([[0.0], [0.2], [0.4], [0.7], [0.9]])
-
- x = tm._transform(u)
-
- assert x.shape == (5, 1)
- assert np.array_equal(x[:3], np.zeros((3, 1)))
- assert np.all(x[3:] > 0.0)
-
- u_positive = u[3:, 0]
- u_rescaled = (u_positive - 0.4) / 0.6
- expected = -np.log1p(-u_rescaled) / 2.0
- assert np.allclose(x[3:, 0], expected)
-
-
-def test_zero_inflated_inverse_transform_all_zero_branch():
- tm = ZeroInflatedExpUniform(
- DigitalNetB2(1, seed=17),
- p_zero=0.4,
- lam=2.0,
- )
- u = np.array([[0.0], [0.1], [0.4]])
-
- x = tm._transform(u)
-
- assert x.shape == (3, 1)
- assert np.array_equal(x, np.zeros((3, 1)))
-
-
-def test_zero_inflated_inverse_transform_clips_one():
- tm = ZeroInflatedExpUniform(
- DigitalNetB2(1, seed=17),
- p_zero=0.4,
- lam=2.0,
- )
- u = np.array([[1.0]])
-
- x = tm._transform(u)
-
- assert x.shape == (1, 1)
- assert np.isfinite(x).all()
- assert x[0, 0] > 0.0
-
-
-def test_zero_inflated_construction_does_not_warn_about_missing_pdf():
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- tm = ZeroInflatedExpUniform(
- DigitalNetB2(1, seed=17),
- p_zero=0.4,
- lam=1.5,
- )
-
- assert tm.d == 1
- assert _missing_pdf_warnings(caught) == []
-
-
-def test_zero_inflated_sampling_does_not_warn_about_missing_pdf():
- tm = ZeroInflatedExpUniform(DigitalNetB2(1, seed=17), p_zero=0.4, lam=1.5)
-
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- x = tm(8)
-
- assert x.shape == (8, 1)
- assert _missing_pdf_warnings(caught) == []
-
-
-def test_zero_inflated_return_weights_warns_once_for_missing_pdf():
- tm = ZeroInflatedExpUniform(DigitalNetB2(1, seed=17), p_zero=0.4, lam=1.5)
-
- with pytest.warns(UserWarning, match=MISSING_PDF_WARNING):
- x, jac = tm(8, return_weights=True)
-
- assert x.shape == (8, 1)
- assert jac.shape == (8,)
- assert np.allclose(jac, 1.0)
-
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- x_second, jac_second = tm(8, return_weights=True)
-
- assert x_second.shape == (8, 1)
- assert np.allclose(jac_second, 1.0)
- assert _missing_pdf_warnings(caught) == []
-
-
-def test_zero_inflated_y_split_warns_and_uses_one_dimensional_interface():
- with pytest.warns(DeprecationWarning, match="y_split"):
- tm = ZeroInflatedExpUniform(
- DigitalNetB2(1, seed=17),
- p_zero=0.4,
- lam=1.5,
- y_split=0.5,
- )
-
- x = tm(4)
-
- assert x.shape == (4, 1)
- assert np.all(x >= 0.0)
-
-
-def test_zero_inflated_y_split_preserves_deprecated_two_dimensional_usage():
- with pytest.warns(DeprecationWarning, match="2D zero-inflated"):
- tm = ZeroInflatedExpUniform(
- DigitalNetB2(2, seed=17),
- p_zero=0.4,
- lam=1.5,
- y_split=0.5,
- )
-
- x = tm(16)
-
- assert x.shape == (16, 2)
- assert np.all(x[:, 0] >= 0.0)
- assert np.all((0.0 <= x[:, 1]) & (x[:, 1] <= 1.0))
- assert np.all(x[x[:, 0] == 0.0, 1] <= 0.5)
- assert np.all(x[x[:, 0] > 0.0, 1] >= 0.5)
-
-
-def test_zero_inflated_y_split_preserves_replicated_two_dimensional_usage():
- with pytest.warns(DeprecationWarning, match="2D zero-inflated"):
- tm = ZeroInflatedExpUniform(
- DigitalNetB2(2, seed=17, replications=2),
- p_zero=0.4,
- lam=1.5,
- y_split=0.5,
- )
-
- x = tm(16)
-
- assert x.shape == (2, 16, 2)
- assert np.all(x[..., 0] >= 0.0)
- assert np.all((0.0 <= x[..., 1]) & (x[..., 1] <= 1.0))
- assert np.all(x[..., 1][x[..., 0] == 0.0] <= 0.5)
- assert np.all(x[..., 1][x[..., 0] > 0.0] >= 0.5)
-
-
-def test_student_t_marginals_shape():
- tm = SciPyWrapper(
- sampler=DigitalNetB2(2, seed=5),
- scipy_distribs=stats.t(df=5),
- )
- x = tm(8)
- assert x.shape == (8, 2)
-
-
-def test_multivariate_student_t_joint_corr_and_cov():
- if not hasattr(stats, "multivariate_t"):
- pytest.skip("scipy.stats.multivariate_t not available in this SciPy version")
-
- df = 5.0
- rho = 0.8
- loc = np.array([0.0, 0.0])
- shape = np.array([[1.0, rho], [rho, 1.0]])
-
- tm = StudentT(DigitalNetB2(2, seed=123), loc=loc, shape=shape, df=df)
-
- n = 4096
- x = tm(n)
- emp_corr = np.corrcoef(x.T)[0, 1]
-
- assert abs(emp_corr - rho) < 0.05
diff --git a/test/test_sr_check_links.py b/test/test_sr_check_links.py
new file mode 100644
index 000000000..49c148256
--- /dev/null
+++ b/test/test_sr_check_links.py
@@ -0,0 +1,198 @@
+import contextlib
+import io
+import shutil
+import ssl
+import sys
+import tempfile
+import unittest
+import urllib.error
+from pathlib import Path
+from unittest.mock import patch
+
+from scripts import check_links
+
+
+def _http_error(url, code):
+ return urllib.error.HTTPError(url, code, "test response", {}, None)
+
+
+class TestCheckLinks(unittest.TestCase):
+
+ def setUp(self):
+ self.tmp_path = Path(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.tmp_path, ignore_errors=True)
+
+ def _patch(self, target, name, value):
+ """monkeypatch.setattr equivalent: set now, auto-restore at test end."""
+ patcher = patch.object(target, name, value)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_head_success_is_reachable(self):
+ with patch.object(check_links.urllib.request, "urlopen", return_value=object()) as urlopen:
+ self.assertIsNone(check_links._check_one("https://example.test", timeout=1))
+
+ self.assertEqual(urlopen.call_count, 1)
+ self.assertEqual(urlopen.call_args.args[0].get_method(), "HEAD")
+
+ def test_get_success_after_head_failure_is_reachable(self):
+ url = "https://example.test"
+ with patch.object(
+ check_links.urllib.request,
+ "urlopen",
+ side_effect=[_http_error(url, 405), object()],
+ ) as urlopen:
+ self.assertIsNone(check_links._check_one(url, timeout=1))
+
+ self.assertEqual(urlopen.call_count, 2)
+ self.assertEqual(urlopen.call_args_list[1].args[0].get_method(), "GET")
+
+ def test_not_found_and_gone_gets_are_broken(self):
+ for code in (404, 410):
+ with self.subTest(code=code):
+ url = f"https://example.test/{code}"
+ with patch.object(
+ check_links.urllib.request,
+ "urlopen",
+ side_effect=[_http_error(url, code), _http_error(url, code)],
+ ):
+ self.assertEqual(
+ check_links._check_one(url, timeout=1),
+ ("broken", f"{url} -- HTTP {code}"),
+ )
+
+ def test_bot_block_and_rate_limit_are_warnings(self):
+ for code in (403, 429):
+ with self.subTest(code=code):
+ url = f"https://example.test/{code}"
+ with patch.object(
+ check_links.urllib.request,
+ "urlopen",
+ side_effect=[_http_error(url, code), _http_error(url, code)],
+ ):
+ severity, message = check_links._check_one(url, timeout=1)
+
+ self.assertEqual(severity, "warning")
+ self.assertIn(f"HTTP {code}", message)
+
+ def test_tls_and_timeout_failures_are_warnings(self):
+ failures = (
+ ssl.SSLCertVerificationError("certificate verify failed"),
+ TimeoutError("timed out"),
+ )
+ for failure in failures:
+ with self.subTest(failure=type(failure).__name__):
+ with patch.object(
+ check_links.urllib.request,
+ "urlopen",
+ side_effect=[failure, failure],
+ ):
+ severity, message = check_links._check_one(
+ "https://example.test", timeout=1
+ )
+
+ self.assertEqual(severity, "warning")
+ self.assertIn(str(failure), message)
+
+ def test_external_results_are_separated_and_duplicate_urls_checked_once(self):
+ (self.tmp_path / "page.html").write_text(
+ 'missing'
+ 'duplicate'
+ 'blocked',
+ encoding="utf-8",
+ )
+
+ def result_for(url, _timeout):
+ if url.endswith("/missing"):
+ return "broken", f"{url} -- HTTP 404"
+ return "warning", f"{url} -- HTTP 403"
+
+ with patch.object(check_links, "_check_one", side_effect=result_for) as check_one:
+ broken, warnings = check_links.check_external(self.tmp_path, workers=1)
+
+ self.assertEqual(check_one.call_count, 2)
+ self.assertEqual(
+ broken,
+ ["https://example.test/missing -- HTTP 404 (seen on page.html)"],
+ )
+ self.assertEqual(
+ warnings,
+ ["https://example.test/blocked -- HTTP 403 (seen on page.html)"],
+ )
+
+ def test_internal_links_strip_site_url_deployment_path(self):
+ target = self.tmp_path / "target"
+ target.mkdir()
+ (target / "index.html").write_text(
+ 'Target
', encoding="utf-8"
+ )
+ (self.tmp_path / "index.html").write_text(
+ 'root-relative'
+ 'absolute',
+ encoding="utf-8",
+ )
+
+ self.assertEqual(
+ check_links.check_internal(
+ self.tmp_path, site_url="https://qmcsoftware.github.io/QMCSoftware/"
+ ),
+ [],
+ )
+
+ def test_external_check_skips_same_site_urls(self):
+ (self.tmp_path / "page.html").write_text(
+ 'same'
+ 'external',
+ encoding="utf-8",
+ )
+
+ with patch.object(check_links, "_check_one", return_value=None) as check_one:
+ broken, warnings = check_links.check_external(
+ self.tmp_path,
+ workers=1,
+ site_url="https://qmcsoftware.github.io/QMCSoftware/",
+ )
+
+ self.assertEqual(broken, [])
+ self.assertEqual(warnings, [])
+ self.assertEqual(check_one.call_count, 1)
+ self.assertEqual(check_one.call_args.args[0], "https://example.test/target/")
+
+ def test_external_warnings_do_not_make_main_fail(self):
+ self._patch(sys, "argv", ["check_links.py", str(self.tmp_path), "--external"])
+ self._patch(
+ check_links, "check_internal", lambda _site_dir, site_url=None: []
+ )
+ self._patch(
+ check_links,
+ "check_external",
+ lambda _site_dir, site_url=None: (
+ [],
+ ["https://example.test -- HTTP 403"],
+ ),
+ )
+
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ self.assertEqual(check_links.main(), 0)
+ self.assertIn("0 broken link(s), 1 warning(s)", buf.getvalue())
+
+ def test_confirmed_external_breakage_makes_main_fail(self):
+ self._patch(sys, "argv", ["check_links.py", str(self.tmp_path), "--external"])
+ self._patch(
+ check_links, "check_internal", lambda _site_dir, site_url=None: []
+ )
+ self._patch(
+ check_links,
+ "check_external",
+ lambda _site_dir, site_url=None: (
+ ["https://example.test -- HTTP 404"],
+ [],
+ ),
+ )
+
+ self.assertEqual(check_links.main(), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_sr_check_removed_urls.py b/test/test_sr_check_removed_urls.py
new file mode 100644
index 000000000..57a30223d
--- /dev/null
+++ b/test/test_sr_check_removed_urls.py
@@ -0,0 +1,165 @@
+import contextlib
+import io
+import shutil
+import sys
+import tempfile
+import unittest
+import urllib.error
+from pathlib import Path
+from unittest.mock import patch
+
+from scripts import check_removed_urls as cru
+
+SITE = "https://qmcsoftware.github.io/QMCSoftware/"
+
+
+def _sitemap(*paths):
+ locs = "".join(f"{SITE}{path}" for path in paths)
+ return f'{locs}'
+
+
+def _config(redirect_maps=None):
+ plugins = ["material/search", {"mkdocs-jupyter": {"execute": False}}]
+ if redirect_maps is not None:
+ plugins.append({"redirects": {"redirect_maps": redirect_maps}})
+ return {"site_url": SITE, "plugins": plugins}
+
+
+class TestCheckRemovedUrls(unittest.TestCase):
+
+ def setUp(self):
+ self.tmp_path = Path(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.tmp_path, ignore_errors=True)
+ self._last_out = ""
+
+ def _patch(self, target, name, value):
+ """monkeypatch.setattr equivalent: set now, auto-restore at test end."""
+ patcher = patch.object(target, name, value)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def _run(self, sitemap_paths, redirect_maps=None, extra_argv=(), base=None):
+ """Run main() offline against a temp sitemap and a temp docs/ tree."""
+ base = self.tmp_path if base is None else base
+ docs = base / "docs"
+ docs.mkdir(parents=True)
+ (docs / "README.md").write_text("home", encoding="utf-8")
+ (docs / "good_practices.md").write_text("page", encoding="utf-8")
+ sitemap = base / "sitemap.xml"
+ sitemap.write_text(_sitemap(*sitemap_paths), encoding="utf-8")
+
+ self._patch(cru, "read_config", lambda *a, **k: _config(redirect_maps))
+ self._patch(sys, "argv", [
+ "check_removed_urls.py", "--sitemap", str(sitemap), "--docs-dir", str(docs),
+ *extra_argv,
+ ])
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ code = cru.main()
+ self._last_out = buf.getvalue()
+ return code
+
+ def test_url_path_and_source_round_trip(self):
+ for source, url_path in [("blogs/scipywrapper/index.md", "blogs/scipywrapper/"),
+ ("good_practices.md", "good_practices/"),
+ ("demos/quickstart.ipynb", "demos/quickstart/"),
+ ("index.md", ""), ("README.md", "")]:
+ self.assertEqual(cru.url_path_for_source(source), url_path)
+
+ for source in ("README.md", "good_practices.md", "demos/quickstart.ipynb",
+ "api/index.md"):
+ path = self.tmp_path / source
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("page", encoding="utf-8")
+ self.assertTrue(
+ cru.source_exists(cru.url_path_for_source(source), self.tmp_path)
+ )
+ self.assertFalse(cru.source_exists("blogs/scipywrapper/", self.tmp_path))
+
+ def test_redirect_maps_reads_the_plugin_and_tolerates_its_absence(self):
+ entry = {"blogs/x/index.md": "https://qmcsoftware.org/blogs/x/"}
+ self.assertEqual(cru.redirect_maps(_config(entry)), entry)
+ self.assertEqual(cru.redirect_maps(_config()), {})
+ self.assertEqual(cru.redirect_maps({}), {})
+
+ def test_published_paths_separates_foreign_urls(self):
+ sitemap = _sitemap("", "good_practices/").replace(
+ "", "https://example.test/other/")
+
+ self.assertEqual(
+ cru.published_paths(sitemap, SITE),
+ (["", "good_practices/"], ["https://example.test/other/"]),
+ )
+
+ def test_http_status_falls_back_to_get_when_head_is_unsupported(self):
+ url = "https://example.test"
+ error = urllib.error.HTTPError(url, 405, "test response", {}, None)
+ response = type("Response", (), {"status": 200, "__enter__": lambda s: s,
+ "__exit__": lambda s, *a: False})()
+ with patch.object(cru.urllib.request, "urlopen",
+ side_effect=[error, response]) as urlopen:
+ self.assertEqual(cru.http_status(url, timeout=1), "200")
+
+ self.assertEqual(urlopen.call_count, 2)
+ self.assertEqual(urlopen.call_args_list[1].args[0].get_method(), "GET")
+
+ def test_removed_page_without_redirect_is_flagged(self):
+ code = self._run(["", "good_practices/", "blogs/scipywrapper/"])
+ out = self._last_out
+
+ self.assertEqual(code, 1)
+ self.assertIn("1 removed with no redirect", out)
+ self.assertIn(f"[ORPHAN] {SITE}blogs/scipywrapper/", out)
+ self.assertIn("blogs/scipywrapper/index.md: ", out)
+
+ def test_removed_page_covered_by_a_redirect_passes(self):
+ code = self._run(
+ ["", "good_practices/", "blogs/scipywrapper/"],
+ redirect_maps={
+ "blogs/scipywrapper/index.md": "https://qmcsoftware.org/blogs/scipywrapper/"},
+ )
+ out = self._last_out
+
+ self.assertEqual(code, 0)
+ self.assertIn("0 removed with no redirect", out)
+ self.assertIn("[redirect]", out)
+ self.assertNotIn("[ORPHAN]", out)
+
+ def test_intact_site_passes(self):
+ self.assertEqual(self._run(["", "good_practices/"]), 0)
+ self.assertIn("2 still have a page source", self._last_out)
+
+ def test_verify_redirects_follows_the_target_status(self):
+ redirects = {"blogs/x/index.md": "https://qmcsoftware.org/blogs/x/"}
+ for status, expected_code in [("200", 0), ("404", 1)]:
+ with self.subTest(status=status):
+ self._patch(cru, "http_status", lambda *a, **k: status)
+ code = self._run(
+ ["", "blogs/x/"],
+ redirect_maps=redirects,
+ extra_argv=("--verify-redirects",),
+ base=self.tmp_path / status,
+ )
+ out = self._last_out
+
+ self.assertEqual(code, expected_code)
+ self.assertIn(status, out)
+ # The URL itself is covered, so a failure is the target, not an orphan.
+ self.assertNotIn("[ORPHAN]", out)
+
+ def test_unreachable_sitemap_fails_unless_offline_is_allowed(self):
+ self._patch(cru, "read_config", lambda *a, **k: _config())
+ argv = ["check_removed_urls.py", "--sitemap", str(self.tmp_path / "absent.xml")]
+
+ self._patch(sys, "argv", argv)
+ self.assertEqual(cru.main(), 1)
+
+ self._patch(sys, "argv", argv + ["--allow-offline"])
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ self.assertEqual(cru.main(), 0)
+ self.assertIn("skipping the check", buf.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_sr_flatten_qmcpy_imports.py b/test/test_sr_flatten_qmcpy_imports.py
new file mode 100644
index 000000000..5a295121a
--- /dev/null
+++ b/test/test_sr_flatten_qmcpy_imports.py
@@ -0,0 +1,351 @@
+import json
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+
+from scripts.flatten_qmcpy_imports import (
+ _load_qmcpy_public_names,
+ flatten_imports,
+ main,
+)
+
+
+def _nested_import(module, imported):
+ return f"from {'qmcpy.' + module} import {imported}"
+
+
+class TestFlattenQmcpyImports(unittest.TestCase):
+
+ def setUp(self):
+ self.tmp_path = Path(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.tmp_path, ignore_errors=True)
+
+ def test_flatten_imports_basic(self):
+ source = (
+ _nested_import("integrand", "Keister")
+ + "\n"
+ + _nested_import("discrete_distribution.lattice", "Lattice as LD")
+ + "\nfrom qmcpy import DigitalNetB2\nimport qmcpy.util\n"
+ ).encode()
+
+ updated, count = flatten_imports(
+ source, frozenset({"DigitalNetB2", "Keister", "Lattice"})
+ )
+
+ self.assertEqual(count, 3)
+ self.assertEqual(
+ updated,
+ (
+ b"from qmcpy import DigitalNetB2, Keister, Lattice as LD\n"
+ b"import qmcpy.util\n"
+ ),
+ )
+
+ def test_flatten_preserves_private(self):
+ source = (
+ _nested_import("_internal._helpers", "PublicHelper")
+ + "\n"
+ + _nested_import(
+ "true_measure.uniform_triangle",
+ "UniformTriangle, _UniformTriangleAdapter",
+ )
+ + "\n"
+ + _nested_import(
+ "true_measure.copula",
+ "(\n AbstractCopula,\n _validate_dimension,\n)",
+ )
+ + "\n"
+ + _nested_import("integrand", "Keister")
+ + "\n"
+ ).encode()
+
+ updated, count = flatten_imports(source, frozenset({"Keister"}))
+
+ self.assertEqual(count, 1)
+ self.assertEqual(
+ updated,
+ source.replace(
+ _nested_import("integrand", "Keister").encode(),
+ b"from qmcpy import Keister",
+ ),
+ )
+
+ def test_private_module_splits_groups(self):
+ source = (
+ b"from qmcpy import Zeta\n"
+ b"from qmcpy._internal._helpers import PublicHelper\n"
+ b"from qmcpy import Alpha\n"
+ )
+
+ updated, count = flatten_imports(source)
+
+ self.assertEqual(count, 0)
+ self.assertEqual(updated, source)
+
+ def test_flatten_preserves_util_imports(self):
+ source = (
+ b"from qmcpy.util import ParameterError\n"
+ b"from qmcpy.util.transforms import tf_exp\n"
+ )
+
+ updated, count = flatten_imports(source, frozenset({"ParameterError", "tf_exp"}))
+
+ self.assertEqual((updated, count), (source, 0))
+
+ def test_flatten_keeps_nonpublic_names(self):
+ source = b"from qmcpy.stopping_criterion.pf_gp_ci import PFGPCIData\n"
+
+ updated, count = flatten_imports(source, frozenset({"PFGPCI"}))
+
+ self.assertEqual((updated, count), (source, 0))
+
+ def test_flatten_no_public_api_noop(self):
+ source = (_nested_import("integrand", "Keister") + "\n").encode()
+
+ updated, count = flatten_imports(source)
+
+ self.assertEqual((updated, count), (source, 0))
+
+ def test_flatten_preserve_str_literals(self):
+ source = b'text = """\nfrom qmcpy.integrand import Keister\n"""\n'
+
+ updated, count = flatten_imports(source, frozenset({"Keister"}))
+
+ self.assertEqual(count, 0)
+ self.assertEqual(updated, source)
+
+ def test_python_string_protection_applies_to_every_rewrite_stage(self):
+ string_body = (
+ b'text = """\n'
+ b"from qmcpy.integrand import Keister\n"
+ b"from qmcpy import Zeta,Beta\n"
+ b"from qmcpy import Alpha\n"
+ b"from qmcpy import *\n"
+ b"from qmcpy import *\n"
+ b'"""\n'
+ )
+ source = string_body + b"from qmcpy.integrand import Keister\n"
+
+ updated, count = flatten_imports(source, frozenset({"Keister"}))
+
+ self.assertEqual(count, 1)
+ self.assertEqual(updated, string_body + b"from qmcpy import Keister\n")
+
+ def test_python_tokenize_failure_is_fail_closed(self):
+ source = b'"""unterminated\nfrom qmcpy.integrand import Keister\n'
+
+ self.assertEqual(
+ flatten_imports(source, frozenset({"Keister"})), (source, 0)
+ )
+
+ def test_flatten_skip_star_expansion(self):
+ source = (
+ b"from qmcpy import *\n\n"
+ b"def f(Lattice):\n"
+ b" return Lattice\n\n"
+ b"y = Keister(dimension=2)\n"
+ b"x = Lattice(dimension=2)\n"
+ )
+
+ updated, count = flatten_imports(source, frozenset({"Keister", "Lattice"}))
+
+ self.assertEqual(count, 0)
+ self.assertEqual(updated, source)
+
+ def test_notebook_star_dedup(self):
+ notebook = {
+ "cells": [
+ {
+ "cell_type": "code",
+ "source": [
+ _nested_import("integrand", "*") + "\n",
+ _nested_import("true_measure", "*"),
+ ],
+ }
+ ]
+ }
+ source = json.dumps(notebook, indent=1).encode()
+
+ updated, count = flatten_imports(source, frozenset({"Keister"}))
+
+ self.assertEqual(count, 3)
+ self.assertEqual(
+ json.loads(updated)["cells"][0]["source"], ["from qmcpy import *"]
+ )
+
+ def test_named_imports_merge_sort(self):
+ source = (
+ b"from qmcpy import Zeta,Beta\n"
+ b"from qmcpy import Alpha\n"
+ b"\n"
+ b"from qmcpy import Gamma\n"
+ )
+
+ updated, count = flatten_imports(source)
+
+ self.assertEqual(count, 1)
+ self.assertEqual(
+ updated,
+ (
+ b"from qmcpy import Alpha, Beta, Zeta\n"
+ b"\n"
+ b"from qmcpy import Gamma\n"
+ ),
+ )
+ self.assertEqual(flatten_imports(updated), (updated, 0))
+
+ def test_merge_paren_and_single_line(self):
+ source = b"""from qmcpy import (
+ KernelDigShiftInvar,
+ KernelDigShiftInvarAdaptiveAlpha,
+ KernelDigShiftInvarCombined,
+ KernelShiftInvar,
+ KernelShiftInvarCombined,
+)
+from qmcpy import tf_exp_eps, tf_exp_eps_inv
+"""
+
+ updated, count = flatten_imports(source)
+
+ self.assertEqual(count, 1)
+ self.assertEqual(
+ updated,
+ b"""from qmcpy import (
+ KernelDigShiftInvar,
+ KernelDigShiftInvarAdaptiveAlpha,
+ KernelDigShiftInvarCombined,
+ KernelShiftInvar,
+ KernelShiftInvarCombined,
+ tf_exp_eps,
+ tf_exp_eps_inv,
+)
+""",
+ )
+ self.assertEqual(flatten_imports(updated), (updated, 0))
+
+ def test_merge_same_scope_only(self):
+ source = (
+ b"if enabled:\n"
+ b" from qmcpy import Zeta\n"
+ b" from qmcpy import Alpha as First\n"
+ b"else:\n"
+ b" from qmcpy import Beta\n"
+ b"from qmcpy import _Private\n"
+ b"from qmcpy import Gamma # keep this comment\n"
+ )
+
+ updated, count = flatten_imports(source)
+
+ self.assertEqual(count, 1)
+ self.assertEqual(
+ updated,
+ (
+ b"if enabled:\n"
+ b" from qmcpy import Alpha as First, Zeta\n"
+ b"else:\n"
+ b" from qmcpy import Beta\n"
+ b"from qmcpy import _Private\n"
+ b"from qmcpy import Gamma # keep this comment\n"
+ ),
+ )
+
+ def test_notebook_named_merge(self):
+ notebook = {
+ "cells": [
+ {
+ "cell_type": "code",
+ "source": [
+ "from qmcpy import Zeta\n",
+ "from qmcpy import Alpha,Beta\n",
+ "print(Alpha)\n",
+ ],
+ }
+ ]
+ }
+ source = json.dumps(notebook, indent=1).encode()
+
+ updated, count = flatten_imports(source)
+
+ self.assertEqual(count, 1)
+ self.assertEqual(
+ json.loads(updated)["cells"][0]["source"],
+ [
+ "from qmcpy import Alpha, Beta, Zeta\n",
+ "print(Alpha)\n",
+ ],
+ )
+ self.assertEqual(flatten_imports(updated), (updated, 0))
+
+ def test_notebook_flattens_nested_imports_only_in_code_cells(self):
+ nested_import = _nested_import("integrand", "Keister") + "\n"
+ metadata_import = _nested_import("true_measure", "Gaussian") + "\n"
+ string_literal = f'text = "{nested_import.rstrip()}"\n'
+ multiline_string = ['text = """\n', nested_import, '"""\n']
+ notebook = {
+ "metadata": {"source": [metadata_import]},
+ "cells": [
+ {"cell_type": "markdown", "source": [nested_import]},
+ {"cell_type": "code", "source": [nested_import]},
+ {"cell_type": "code", "source": [string_literal]},
+ {"cell_type": "code", "source": multiline_string},
+ ]
+ }
+ source = json.dumps(notebook, indent=1).encode()
+
+ updated, count = flatten_imports(source, frozenset({"Keister"}))
+
+ cells = json.loads(updated)["cells"]
+ self.assertEqual(count, 1)
+ self.assertEqual(
+ json.loads(updated)["metadata"]["source"], [metadata_import]
+ )
+ self.assertEqual(cells[0]["source"], [nested_import])
+ self.assertEqual(cells[1]["source"], ["from qmcpy import Keister\n"])
+ self.assertEqual(cells[2]["source"], [string_literal])
+ self.assertEqual(cells[3]["source"], multiline_string)
+ self.assertEqual(
+ flatten_imports(updated, frozenset({"Keister"})), (updated, 0)
+ )
+
+ def test_markdown_import_examples_are_flattened(self):
+ path = self.tmp_path / "example.md"
+ path.write_bytes(
+ b'Example with unmatched prose delimiter: """\n\n'
+ b"```python\n"
+ b"from qmcpy.integrand import Keister\n"
+ b"```\n"
+ )
+
+ self.assertEqual(main([str(path)]), 0)
+ self.assertIn(b"from qmcpy import Keister", path.read_bytes())
+
+ def test_check_mode_no_write(self):
+ path = self.tmp_path / "example.py"
+ original = (_nested_import("true_measure", "Gaussian") + "\n").encode()
+ path.write_bytes(original)
+
+ self.assertEqual(main(["--check", str(path)]), 1)
+ self.assertEqual(path.read_bytes(), original)
+
+ self.assertEqual(main([str(path)]), 0)
+ self.assertEqual(path.read_bytes(), b"from qmcpy import Gaussian\n")
+ self.assertEqual(main(["--check", str(path)]), 0)
+
+ def test_public_names_optional_free_stable(self):
+ repository_root = Path(__file__).resolve().parent.parent
+ names = _load_qmcpy_public_names(repository_root)
+
+ self.assertIsNotNone(names)
+ self.assertIn("Gaussian", names)
+ self.assertIn("Keister", names)
+ # Optional dependencies are blocked in the probe context, so fallback
+ # exports are part of the deterministic name set.
+ self.assertIn("PFGPCI", names)
+ # Helpers that are deliberately not part of the top-level API.
+ self.assertNotIn("PFGPCIData", names)
+ self.assertNotIn("TriangularDistribution", names)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_sr_install_mpmc_pyg.py b/test/test_sr_install_mpmc_pyg.py
new file mode 100644
index 000000000..25e810ec1
--- /dev/null
+++ b/test/test_sr_install_mpmc_pyg.py
@@ -0,0 +1,92 @@
+"""Tests for the platform-specific MPMC dependency installer."""
+
+import subprocess
+import unittest
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from qmcpy.util import install_mpmc_pyg
+
+
+def _torch(version="2.12.1+cpu", cuda=None, hip=None):
+ return SimpleNamespace(
+ __version__=version,
+ version=SimpleNamespace(cuda=cuda, hip=hip),
+ )
+
+
+class TestInstallMPMCPyG(unittest.TestCase):
+
+ def test_torch_versions_include_baseline_fallback(self):
+ """Wheel lookup tries an exact patch release, then its minor baseline."""
+ self.assertEqual(
+ install_mpmc_pyg.torch_versions("2.12.1+cpu"), ["2.12.1", "2.12.0"]
+ )
+ self.assertEqual(install_mpmc_pyg.torch_versions("2.12.0"), ["2.12.0"])
+
+ with self.assertRaisesRegex(RuntimeError, "Unable to parse torch version"):
+ install_mpmc_pyg.torch_versions("development")
+
+ def test_accelerator_tag(self):
+ """PyTorch build metadata maps to the expected PyG wheel tag."""
+ cases = [
+ (_torch(), "cpu"),
+ (_torch(cuda="12.6"), "cu126"),
+ (_torch(cuda="13.0.1"), "cu130"),
+ ]
+ for torch_module, expected in cases:
+ with self.subTest(expected=expected):
+ self.assertEqual(
+ install_mpmc_pyg.accelerator_tag(torch_module), expected
+ )
+
+ def test_accelerator_tag_rejects_rocm(self):
+ """The installer directs unsupported ROCm users to upstream guidance."""
+ with self.assertRaisesRegex(RuntimeError, "does not currently support ROCm"):
+ install_mpmc_pyg.accelerator_tag(_torch(hip="6.3"))
+
+ def test_main_retries_with_torch_minor_baseline(self):
+ """A missing exact wheel page falls back to the minor baseline page."""
+ calls = []
+
+ def fake_run(*args):
+ calls.append(args)
+ if args[-1].endswith("torch-2.12.1+cpu.html"):
+ raise subprocess.CalledProcessError(1, args)
+
+ with patch.object(install_mpmc_pyg, "run", fake_run):
+ install_mpmc_pyg.main(_torch())
+
+ self.assertEqual(calls[0][-1], "torch-geometric>=2.6.1")
+ self.assertEqual(
+ calls[1][-1], "https://data.pyg.org/whl/torch-2.12.1+cpu.html"
+ )
+ self.assertEqual(
+ calls[2][-1], "https://data.pyg.org/whl/torch-2.12.0+cpu.html"
+ )
+ self.assertIn("--only-binary", calls[1])
+
+ def test_main_explains_that_torch_must_be_installed(self):
+ """Running the helper before installing the extra gives a useful error."""
+ def missing_torch(_name):
+ raise ModuleNotFoundError("No module named 'torch'", name="torch")
+
+ with patch.object(
+ install_mpmc_pyg.importlib, "import_module", missing_torch
+ ):
+ with self.assertRaisesRegex(RuntimeError, r"install 'qmcpy\[mpmc\]'"):
+ install_mpmc_pyg.main()
+
+ def test_main_reports_missing_wheel(self):
+ """Exhausting candidate wheel pages reports the build that failed."""
+ def fail_pyg_lib(*args):
+ if "pyg_lib>=0.6.0" in args:
+ raise subprocess.CalledProcessError(1, args)
+
+ with patch.object(install_mpmc_pyg, "run", fail_pyg_lib):
+ with self.assertRaisesRegex(RuntimeError, r"torch 2\.12\.1\+cpu \(cpu\)"):
+ install_mpmc_pyg.main(_torch())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_sr_mpmc_optional_imports.py b/test/test_sr_mpmc_optional_imports.py
new file mode 100644
index 000000000..4628f2c93
--- /dev/null
+++ b/test/test_sr_mpmc_optional_imports.py
@@ -0,0 +1,114 @@
+import ast
+import builtins
+import unittest
+from pathlib import Path
+
+
+def _execute_optional_import(blocked_import):
+ repository_root = Path(__file__).resolve().parent.parent
+ init_path = repository_root / "qmcpy" / "__init__.py"
+ init_tree = ast.parse(init_path.read_text())
+ optional_import = next(
+ node
+ for node in init_tree.body
+ if isinstance(node, ast.Try)
+ and any(
+ isinstance(statement, ast.ImportFrom)
+ and statement.module == "discrete_distribution.mpmc"
+ for statement in node.body
+ )
+ )
+
+ import qmcpy
+
+ real_import = builtins.__import__
+
+ def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):
+ missing_module = blocked_import(name, fromlist, level)
+ if missing_module is not None:
+ raise ModuleNotFoundError(
+ "blocked optional dependency",
+ name=missing_module,
+ )
+ return real_import(name, globals, locals, fromlist, level)
+
+ test_builtins = vars(builtins).copy()
+ test_builtins["__import__"] = guarded_import
+ namespace = {"__builtins__": test_builtins, "__package__": "qmcpy"}
+ module = ast.Module(body=[optional_import], type_ignores=[])
+ exec(compile(module, str(init_path), "exec"), namespace)
+ return namespace
+
+
+class TestMPMCOptionalImports(unittest.TestCase):
+
+ def test_mpmc_utils_remain_available_without_pyg(self):
+ try:
+ import torch # noqa: F401
+ except ImportError:
+ self.skipTest("torch not available")
+
+ def block_pyg_models(name, fromlist, level):
+ if level == 1 and name == "discrete_distribution.mpmc.models":
+ return "torch_geometric"
+ return None
+
+ namespace = _execute_optional_import(block_pyg_models)
+
+ import qmcpy
+
+ self.assertIs(namespace["mpmc_utils"], qmcpy.mpmc_utils)
+ self.assertEqual(
+ namespace["mpmc_utils"].__name__,
+ "qmcpy.discrete_distribution.mpmc.utils",
+ )
+ self.assertNotIn("utils", namespace)
+
+ with self.assertRaisesRegex(
+ ModuleNotFoundError, "MPMC_net.*torch_geometric"
+ ) as cm:
+ namespace["MPMC_net"]()
+ self.assertEqual(cm.exception.name, "torch_geometric")
+
+ def test_mpmc_placeholders_report_missing_torch(self):
+ def block_torch_utils(name, fromlist, level):
+ if (
+ level == 1
+ and name == "discrete_distribution.mpmc"
+ and "utils" in fromlist
+ ):
+ return "torch"
+ return None
+
+ namespace = _execute_optional_import(block_torch_utils)
+
+ with self.assertRaisesRegex(ModuleNotFoundError, "mpmc_utils.*torch") as cm:
+ namespace["mpmc_utils"].L2star
+ self.assertEqual(cm.exception.name, "torch")
+
+ with self.assertRaisesRegex(ModuleNotFoundError, "MPMC_net.*torch") as cm:
+ namespace["MPMC_net"]()
+ self.assertEqual(cm.exception.name, "torch")
+
+ def test_mpmc_placeholder_missing_torch_scatter(self):
+ try:
+ import torch # noqa: F401
+ except ImportError:
+ self.skipTest("torch not available")
+
+ def block_torch_scatter(name, fromlist, level):
+ if level == 1 and name == "discrete_distribution.mpmc.models":
+ return "torch_scatter"
+ return None
+
+ namespace = _execute_optional_import(block_torch_scatter)
+
+ with self.assertRaisesRegex(
+ ModuleNotFoundError, "MPMC_net.*torch_scatter"
+ ) as cm:
+ namespace["MPMC_net"]()
+ self.assertEqual(cm.exception.name, "torch_scatter")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_sr_unwrap_markdown.py b/test/test_sr_unwrap_markdown.py
new file mode 100644
index 000000000..4115d9ad2
--- /dev/null
+++ b/test/test_sr_unwrap_markdown.py
@@ -0,0 +1,99 @@
+import unittest
+
+from scripts.unwrap_markdown import unwrap_markdown_text
+
+
+class TestUnwrapMarkdown(unittest.TestCase):
+
+ def test_unwraps_list_item_continuations(self):
+ cases = [
+ (
+ "- unordered first\n unordered second\n",
+ "- unordered first unordered second\n",
+ ),
+ (
+ "- [ ] task first\n task second\n",
+ "- [ ] task first task second\n",
+ ),
+ (
+ "10. ordered first\n ordered second\n",
+ "10. ordered first ordered second\n",
+ ),
+ ]
+ for source, expected in cases:
+ with self.subTest(source=source):
+ updated = unwrap_markdown_text(source)
+
+ self.assertEqual(updated, expected)
+ self.assertEqual(unwrap_markdown_text(updated), updated)
+
+ def test_unwraps_adjacent_and_nested_list_items_separately(self):
+ source = (
+ "- parent first\n"
+ " parent second\n"
+ " - child first\n"
+ " child second\n"
+ "- sibling first\n"
+ " sibling second\n"
+ )
+
+ self.assertEqual(
+ unwrap_markdown_text(source),
+ (
+ "- parent first parent second\n"
+ " - child first child second\n"
+ "- sibling first sibling second\n"
+ ),
+ )
+
+ def test_preserves_list_item_blocks_and_explicit_hard_breaks(self):
+ source = (
+ "- first paragraph\n"
+ " continuation\n"
+ "\n"
+ " second paragraph\n"
+ " continuation\n"
+ "\n"
+ "- item before code\n"
+ " indented code\n"
+ "\n"
+ "- explicit hard break \n"
+ " remains separate\n"
+ )
+
+ self.assertEqual(
+ unwrap_markdown_text(source),
+ (
+ "- first paragraph continuation\n"
+ "\n"
+ " second paragraph continuation\n"
+ "\n"
+ "- item before code\n"
+ " indented code\n"
+ "\n"
+ "- explicit hard break \n"
+ " remains separate\n"
+ ),
+ )
+
+ def test_unwraps_ordinary_paragraphs(self):
+ self.assertEqual(
+ unwrap_markdown_text("first line\nsecond line\n"),
+ "first line second line\n",
+ )
+
+ def test_preserves_horizontal_rules(self):
+ for rule in ["- - -", "* * *", "_ _ _"]:
+ with self.subTest(rule=rule):
+ source = f"{rule}\nfollowing paragraph\n"
+
+ self.assertEqual(unwrap_markdown_text(source), source)
+
+ def test_preserves_indented_code_that_looks_like_a_list(self):
+ source = " - code first\n code second\n"
+
+ self.assertEqual(unwrap_markdown_text(source), source)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_tm_copulas.py b/test/test_tm_copulas.py
new file mode 100644
index 000000000..ed29969fb
--- /dev/null
+++ b/test/test_tm_copulas.py
@@ -0,0 +1,1271 @@
+import unittest
+import warnings
+
+import numpy as np
+import scipy.stats as stats
+
+from qmcpy import (
+ AbstractCopula,
+ ClaytonCopula,
+ DigitalNetB2,
+ FrankCopula,
+ GaussianCopula,
+ GumbelCopula,
+ StudentTCopula,
+)
+
+from qmcpy.true_measure.copula import (
+ AbstractCopula as ModuleAbstractCopula,
+ _apply_marginal_ppfs,
+ _build_marginal_range,
+ _clip_unit_interval,
+ _marginal_cdfs_and_logpdf,
+ _validate_correlation_matrix,
+ _validate_dimension,
+ _validate_marginals,
+)
+
+from qmcpy.util import DimensionError, MethodImplementationError, ParameterError
+
+
+class PPFOnlyMarginal:
+ def ppf(self, u):
+ return np.asarray(u, dtype=float)
+
+
+class NonCallablePPFMarginal:
+ ppf = 1.0
+
+
+class UnitPDFMarginal:
+ def ppf(self, u):
+ return np.asarray(u, dtype=float)
+
+ def cdf(self, x):
+ return np.asarray(x, dtype=float)
+
+ def pdf(self, x):
+ return np.ones_like(np.asarray(x, dtype=float))
+
+
+class CDFOnlyMarginal(PPFOnlyMarginal):
+ def cdf(self, x):
+ return np.asarray(x, dtype=float)
+
+
+class BadIntervalMarginal(PPFOnlyMarginal):
+ def interval(self, confidence):
+ raise ValueError("interval unavailable")
+
+
+class BadRangeMarginal:
+ def ppf(self, u):
+ raise ValueError("ppf unavailable")
+
+
+def _equicorrelation(d, rho):
+ corr = np.full((d, d), rho, dtype=float)
+ np.fill_diagonal(corr, 1.0)
+ return corr
+
+
+def _make_copula(copula_cls, dimension=2, marginals=None, correlation=None, seed=7):
+ if marginals is None:
+ marginals = [stats.norm()] * dimension
+ if correlation is None:
+ correlation = np.eye(dimension)
+
+ kwargs = {}
+ if copula_cls is StudentTCopula:
+ kwargs["df"] = 4
+ if copula_cls is ClaytonCopula:
+ kwargs["theta"] = 2.0
+ if copula_cls is FrankCopula:
+ kwargs["theta"] = 5.0
+ if copula_cls is GumbelCopula:
+ kwargs["theta"] = 2.0
+
+ common = {
+ "sampler": DigitalNetB2(dimension, seed=seed),
+ "marginals": marginals,
+ **kwargs,
+ }
+ if copula_cls in [ClaytonCopula, FrankCopula, GumbelCopula]:
+ return copula_cls(**common)
+ return copula_cls(correlation=correlation, **common)
+
+
+class TestAbstractCopulaAndHelpers(unittest.TestCase):
+
+ def test_abstract_copula_is_importable_from_public_module_path(self):
+ self.assertIs(ModuleAbstractCopula, AbstractCopula)
+
+ def test_public_api_imports_and_normal_usage(self):
+ for copula_cls in [
+ GaussianCopula,
+ StudentTCopula,
+ ClaytonCopula,
+ FrankCopula,
+ GumbelCopula,
+ ]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ self.assertTrue(issubclass(copula_cls, AbstractCopula))
+
+ tm = _make_copula(copula_cls)
+ x = tm(8)
+ x_gen = tm.gen_samples(8)
+ v = tm.gen_copula_samples(8)
+
+ self.assertEqual(x.shape, (8, 2))
+ self.assertEqual(x_gen.shape, (8, 2))
+ self.assertEqual(v.shape, (8, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+ self.assertTrue(np.all(np.isfinite(x_gen)))
+ self.assertTrue(np.all((0 <= v) & (v <= 1)))
+
+ def test_abstract_copula_rejects_unimplemented_transform(self):
+ tm = AbstractCopula(
+ DigitalNetB2(2, seed=101),
+ marginals=[stats.uniform(), stats.uniform()],
+ )
+
+ with self.assertRaises(MethodImplementationError):
+ tm.copula_transform(np.full((3, 2), 0.5))
+
+ def test_abstract_copula_rejects_invalid_sampler(self):
+ with self.assertRaisesRegex(ParameterError, "sampler"):
+ AbstractCopula(object(), marginals=[stats.uniform()])
+
+ def test_validate_marginals_error_branches(self):
+ with self.assertRaisesRegex(ParameterError, "marginals"):
+ _validate_marginals(None)
+
+ with self.assertRaisesRegex(ParameterError, "at least one"):
+ _validate_marginals([])
+
+ with self.assertRaisesRegex(ParameterError, "ppf"):
+ _validate_marginals([NonCallablePPFMarginal()])
+
+ def test_validate_dimension_error_branches(self):
+ with self.assertRaisesRegex(DimensionError, "integer dimension"):
+ _validate_dimension(object(), [stats.uniform()])
+
+ with self.assertRaisesRegex(DimensionError, "marginals"):
+ _validate_dimension(3, [stats.uniform(), stats.uniform()])
+
+ def test_apply_marginal_ppfs_clips_endpoints_and_checks_dimension(self):
+ transformed = _apply_marginal_ppfs(
+ np.array([[0.0, 1.0], [1.0, 0.0]]),
+ [stats.norm(), stats.norm()],
+ )
+
+ self.assertEqual(transformed.shape, (2, 2))
+ self.assertTrue(np.all(np.isfinite(transformed)))
+
+ with self.assertRaisesRegex(DimensionError, "marginals"):
+ _apply_marginal_ppfs(np.full((2, 3), 0.5), [stats.uniform(), stats.uniform()])
+
+ def test_marginal_range_falls_back_when_interval_or_ppf_fails(self):
+ ranges = _build_marginal_range([BadIntervalMarginal(), BadRangeMarginal()])
+
+ self.assertEqual(ranges.shape, (2, 2))
+ self.assertTrue(np.all(np.isfinite(ranges[0])))
+ np.testing.assert_allclose(ranges[1], [-np.inf, np.inf])
+
+ def test_marginal_cdfs_and_logpdf_pdf_branch_and_errors(self):
+ x = np.array([[0.25, 0.75], [0.4, 0.6]])
+ u, log_density = _marginal_cdfs_and_logpdf(
+ x,
+ [UnitPDFMarginal(), UnitPDFMarginal()],
+ )
+
+ np.testing.assert_allclose(u, x)
+ np.testing.assert_allclose(log_density, np.zeros(2))
+
+ with self.assertRaisesRegex(ParameterError, "cdf"):
+ _marginal_cdfs_and_logpdf(x, [PPFOnlyMarginal(), UnitPDFMarginal()])
+
+ with self.assertRaisesRegex(ParameterError, "pdf"):
+ _marginal_cdfs_and_logpdf(x, [CDFOnlyMarginal(), UnitPDFMarginal()])
+
+ def test_validate_correlation_matrix_rejects_nonfinite_values(self):
+ with self.assertRaisesRegex(ValueError, "finite"):
+ _validate_correlation_matrix([[1.0, np.nan], [np.nan, 1.0]], 2)
+
+ def test_clip_unit_interval_uses_machine_epsilon(self):
+ clipped = _clip_unit_interval(np.array([0.0, 0.5, 1.0]))
+ eps = np.finfo(float).eps
+
+ np.testing.assert_allclose(clipped, [eps, 0.5, 1.0 - eps])
+
+ def test_copula_transform_outputs_dependent_uniforms_in_unit_cube(self):
+ for copula_cls in [GaussianCopula, StudentTCopula, ClaytonCopula, GumbelCopula, FrankCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ tm = _make_copula(copula_cls, dimension=3)
+ u = np.array(
+ [
+ [0.1, 0.3, 0.7],
+ [0.5, 0.5, 0.5],
+ [0.9, 0.8, 0.2],
+ ]
+ )
+
+ v = tm.copula_transform(u)
+
+ self.assertEqual(v.shape, u.shape)
+ self.assertTrue(np.all(np.isfinite(v)))
+ self.assertTrue(np.all((0.0 <= v) & (v <= 1.0)))
+
+ def test_copula_sample_shapes_are_preserved(self):
+ for copula_cls, dimension in [
+ (GaussianCopula, 3),
+ (StudentTCopula, 3),
+ (ClaytonCopula, 3),
+ (FrankCopula, 3),
+ (GumbelCopula, 3),
+ ]:
+ with self.subTest(copula_cls=copula_cls.__name__, dimension=dimension):
+ tm = _make_copula(copula_cls, dimension=dimension, seed=9)
+
+ one = tm(1)
+ many = tm(8)
+ batched_transform = tm._transform(np.full((2, 3, dimension), 0.5))
+
+ self.assertEqual(one.shape, (1, dimension))
+ self.assertEqual(many.shape, (8, dimension))
+ self.assertEqual(batched_transform.shape, (2, 3, dimension))
+ self.assertTrue(np.all(np.isfinite(one)))
+ self.assertTrue(np.all(np.isfinite(many)))
+ self.assertTrue(np.all(np.isfinite(batched_transform)))
+
+
+class TestEllipticalCopulas(unittest.TestCase):
+
+ def test_output_shape_with_nonnormal_marginals(self):
+ tm = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=7),
+ marginals=[stats.beta(a=2, b=5), stats.gamma(a=3, scale=2)],
+ correlation=[[1.0, 0.4], [0.4, 1.0]],
+ )
+
+ x = tm(16)
+
+ self.assertEqual(x.shape, (16, 2))
+
+ def test_finite_output_for_normal_marginals(self):
+ tm = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=11),
+ marginals=[stats.norm(), stats.norm(loc=1.0, scale=2.0)],
+ correlation=[[1.0, -0.3], [-0.3, 1.0]],
+ )
+
+ x = tm(128)
+
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_return_weights_shape_when_marginal_densities_available(self):
+ tm = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=12),
+ marginals=[stats.norm(), stats.gamma(a=2.0)],
+ correlation=[[1.0, 0.25], [0.25, 1.0]],
+ )
+
+ x, weights = tm(32, return_weights=True)
+
+ self.assertEqual(x.shape, (32, 2))
+ self.assertEqual(weights.shape, (32,))
+ self.assertTrue(np.all(np.isfinite(weights)))
+ self.assertTrue(np.all(weights > 0.0))
+
+ def test_identity_correlation_matches_independent_marginal_transforms(self):
+ marginals = [stats.norm(loc=-1.0, scale=2.0), stats.gamma(a=2.0, scale=3.0)]
+ tm = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=13),
+ marginals=marginals,
+ correlation=np.eye(2),
+ )
+ u = np.array([[0.2, 0.7], [0.4, 0.8], [0.9, 0.1]])
+
+ x = tm._transform(u)
+ expected = np.column_stack(
+ [marginals[j].ppf(u[:, j]) for j in range(len(marginals))]
+ )
+
+ np.testing.assert_allclose(x, expected, rtol=1e-12, atol=1e-12)
+
+ def test_positive_correlation_produces_positive_dependence(self):
+ rho = 0.75
+ tm = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=17),
+ marginals=[stats.norm(), stats.norm()],
+ correlation=[[1.0, rho], [rho, 1.0]],
+ )
+
+ x = tm(4096)
+ empirical_corr = np.corrcoef(x.T)[0, 1]
+
+ self.assertGreater(empirical_corr, 0.5)
+ self.assertLess(abs(empirical_corr - rho), 0.2)
+
+ def test_elliptical_copulas_support_general_dimensions(self):
+ for copula_cls in [GaussianCopula, StudentTCopula]:
+ for dimension in [1, 3, 5]:
+ with self.subTest(copula_cls=copula_cls.__name__, dimension=dimension):
+ correlation = _equicorrelation(dimension, 0.25)
+ tm = _make_copula(
+ copula_cls,
+ dimension=dimension,
+ marginals=[stats.norm()] * dimension,
+ correlation=correlation,
+ seed=19,
+ )
+
+ x = tm(16)
+ one = tm(1)
+
+ self.assertEqual(x.shape, (16, dimension))
+ self.assertEqual(one.shape, (1, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+ self.assertTrue(np.all(np.isfinite(one)))
+
+ def test_elliptical_copulas_handle_valid_near_singular_correlation(self):
+ for copula_cls in [GaussianCopula, StudentTCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ dimension = 5
+ tm = _make_copula(
+ copula_cls,
+ dimension=dimension,
+ marginals=[stats.norm()] * dimension,
+ correlation=_equicorrelation(dimension, 0.999),
+ seed=20,
+ )
+
+ x = tm(32)
+
+ self.assertEqual(x.shape, (32, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_elliptical_copulas_reject_singular_correlation(self):
+ for copula_cls in [GaussianCopula, StudentTCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ with self.assertRaisesRegex(ValueError, "positive definite"):
+ _make_copula(
+ copula_cls,
+ dimension=3,
+ marginals=[stats.norm(), stats.norm(), stats.norm()],
+ correlation=np.ones((3, 3)),
+ seed=22,
+ )
+
+ def test_distribution_dimension_matches_number_of_marginals(self):
+ for copula_cls in [GaussianCopula, StudentTCopula, ClaytonCopula, FrankCopula, GumbelCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ tm = _make_copula(
+ copula_cls,
+ dimension=5,
+ marginals=[
+ stats.norm(),
+ stats.beta(a=2, b=5),
+ stats.gamma(a=3),
+ stats.expon(),
+ stats.lognorm(s=0.5),
+ ],
+ correlation=np.eye(5),
+ )
+
+ x = tm(32)
+
+ self.assertEqual(x.shape, (32, 5))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_invalid_dimension_mismatches_raise(self):
+ for copula_cls in [GaussianCopula, StudentTCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ with self.assertRaisesRegex(DimensionError, "marginals"):
+ _make_copula(
+ copula_cls,
+ dimension=2,
+ marginals=[stats.norm(), stats.norm(), stats.norm()],
+ correlation=np.eye(2),
+ )
+
+ with self.assertRaisesRegex(ValueError, "shape"):
+ _make_copula(
+ copula_cls,
+ dimension=2,
+ marginals=[stats.norm(), stats.norm()],
+ correlation=np.eye(3),
+ )
+
+ with self.assertRaisesRegex(ValueError, "square"):
+ _make_copula(
+ copula_cls,
+ dimension=2,
+ marginals=[stats.norm(), stats.norm()],
+ correlation=[[1.0, 0.2, 0.3], [0.2, 1.0, 0.4]],
+ )
+
+ def test_archimedean_dimension_mismatch_raises_dimension_error(self):
+ for copula_cls in [ClaytonCopula, FrankCopula, GumbelCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ with self.assertRaisesRegex(DimensionError, "marginals"):
+ _make_copula(
+ copula_cls,
+ dimension=2,
+ marginals=[stats.norm(), stats.norm(), stats.norm()],
+ )
+
+ def test_invalid_correlation_matrices_raise_value_error(self):
+ correlations = [
+ [[1.0, 0.2], [0.3, 1.0]],
+ [[1.0, 0.2], [0.2, 0.9]],
+ [[1.0, 1.2], [1.2, 1.0]],
+ ]
+ for copula_cls in [GaussianCopula, StudentTCopula]:
+ for correlation in correlations:
+ with self.subTest(copula_cls=copula_cls.__name__, correlation=correlation):
+ with self.assertRaises(ValueError):
+ _make_copula(
+ copula_cls,
+ dimension=2,
+ marginals=[stats.norm(), stats.norm()],
+ correlation=correlation,
+ )
+
+ def test_marginal_length_mismatch_raises_dimension_error(self):
+ with self.assertRaisesRegex(DimensionError, "marginals"):
+ GaussianCopula(
+ sampler=DigitalNetB2(2, seed=21),
+ marginals=[stats.norm()],
+ correlation=np.eye(2),
+ )
+
+ def test_marginal_without_ppf_raises_clear_error(self):
+ class NoPPF:
+ pass
+
+ with self.assertRaisesRegex(ParameterError, "ppf"):
+ GaussianCopula(
+ sampler=DigitalNetB2(1, seed=23),
+ marginals=[NoPPF()],
+ correlation=[[1.0]],
+ )
+
+ def test_common_scipy_frozen_marginals_work(self):
+ for copula_cls in [GaussianCopula, StudentTCopula, ClaytonCopula, FrankCopula, GumbelCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ tm = _make_copula(
+ copula_cls,
+ dimension=5,
+ marginals=[
+ stats.norm(),
+ stats.beta(a=2, b=5),
+ stats.gamma(a=3),
+ stats.expon(),
+ stats.lognorm(s=0.5),
+ ],
+ correlation=np.eye(5),
+ seed=47,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, 5))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_endpoint_uniforms_are_clipped_to_finite_outputs(self):
+ for copula_cls in [GaussianCopula, StudentTCopula, ClaytonCopula, FrankCopula, GumbelCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ tm = _make_copula(
+ copula_cls,
+ dimension=5,
+ marginals=[
+ stats.norm(),
+ stats.beta(a=2, b=5),
+ stats.gamma(a=3),
+ stats.expon(),
+ stats.lognorm(s=0.5),
+ ],
+ correlation=np.eye(5),
+ seed=53,
+ )
+ u = np.array(
+ [
+ [0.0, 1.0, 0.0, 1.0, 0.5],
+ [1.0, 0.0, 1.0, 0.0, 0.5],
+ ]
+ )
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (2, 5))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_student_t_copula_output_shape_and_finite_values(self):
+ tm = StudentTCopula(
+ sampler=DigitalNetB2(2, seed=29),
+ marginals=[stats.norm(), stats.gamma(a=3.0, scale=2.0)],
+ correlation=[[1.0, 0.5], [0.5, 1.0]],
+ df=4,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_student_t_copula_positive_correlation_produces_positive_dependence(self):
+ tm = StudentTCopula(
+ sampler=DigitalNetB2(2, seed=31),
+ marginals=[stats.norm(), stats.norm()],
+ correlation=[[1.0, 0.7], [0.7, 1.0]],
+ df=5,
+ )
+
+ x = tm(4096)
+ empirical_corr = np.corrcoef(x.T)[0, 1]
+
+ self.assertGreater(empirical_corr, 0.45)
+
+ def test_student_t_copula_has_stronger_joint_tail_than_gaussian_copula(self):
+ rho = 0.7
+ df = 4
+ n = 2**12
+ marginals = [stats.norm(), stats.norm()]
+ correlation = [[1.0, rho], [rho, 1.0]]
+
+ gaussian = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=101),
+ marginals=marginals,
+ correlation=correlation,
+ )
+ student_t = StudentTCopula(
+ sampler=DigitalNetB2(2, seed=101),
+ marginals=marginals,
+ correlation=correlation,
+ df=df,
+ )
+
+ x_gaussian = gaussian(n)
+ x_student_t = student_t(n)
+ threshold = stats.norm.ppf(0.99)
+
+ def joint_tail_rate(x):
+ tail_0 = x[:, 0] > threshold
+ return np.mean(x[tail_0, 1] > threshold)
+
+ gaussian_tail = joint_tail_rate(x_gaussian)
+ student_t_tail = joint_tail_rate(x_student_t)
+
+ self.assertGreater(student_t_tail, gaussian_tail + 0.08)
+
+ def test_student_t_copula_return_weights_shape_when_density_available(self):
+ tm = StudentTCopula(
+ sampler=DigitalNetB2(2, seed=37),
+ marginals=[stats.norm(), stats.gamma(a=2.0)],
+ correlation=[[1.0, 0.3], [0.3, 1.0]],
+ df=6,
+ )
+
+ x, weights = tm(32, return_weights=True)
+
+ self.assertEqual(x.shape, (32, 2))
+ self.assertEqual(weights.shape, (32,))
+ self.assertTrue(np.all(np.isfinite(weights)))
+ self.assertTrue(np.all(weights > 0.0))
+
+ def test_student_t_copula_boundary_df_values_are_finite(self):
+ for df in [1.0, 100.0]:
+ with self.subTest(df=df):
+ dimension = 3
+ tm = StudentTCopula(
+ sampler=DigitalNetB2(dimension, seed=39),
+ marginals=[stats.norm()] * dimension,
+ correlation=_equicorrelation(dimension, 0.4),
+ df=df,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_student_t_copula_large_df_is_close_to_gaussian_copula(self):
+ rho = 0.6
+ correlation = [[1.0, rho], [rho, 1.0]]
+ marginals = [stats.norm(), stats.norm()]
+ gaussian = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=40),
+ marginals=marginals,
+ correlation=correlation,
+ )
+ student_t = StudentTCopula(
+ sampler=DigitalNetB2(2, seed=40),
+ marginals=marginals,
+ correlation=correlation,
+ df=100,
+ )
+
+ x_gaussian = gaussian(4096)
+ x_student_t = student_t(4096)
+ corr_gaussian = np.corrcoef(x_gaussian.T)[0, 1]
+ corr_student_t = np.corrcoef(x_student_t.T)[0, 1]
+
+ self.assertLess(abs(corr_student_t - corr_gaussian), 0.02)
+
+ def test_student_t_copula_invalid_df_raises_parameter_error(self):
+ for df in [0, -1, np.inf, "not-a-number"]:
+ with self.subTest(df=df):
+ with self.assertRaisesRegex(ParameterError, "df"):
+ StudentTCopula(
+ sampler=DigitalNetB2(2, seed=41),
+ marginals=[stats.norm(), stats.norm()],
+ correlation=np.eye(2),
+ df=df,
+ )
+
+ def test_student_t_copula_marginal_without_ppf_raises_clear_error(self):
+ class NoPPF:
+ pass
+
+ with self.assertRaisesRegex(ParameterError, "ppf"):
+ StudentTCopula(
+ sampler=DigitalNetB2(1, seed=43),
+ marginals=[NoPPF()],
+ correlation=[[1.0]],
+ df=4,
+ )
+
+
+class TestArchimedeanCopulas(unittest.TestCase):
+
+ def test_clayton_copula_output_shape_and_finite_values(self):
+ tm = ClaytonCopula(
+ sampler=DigitalNetB2(2, seed=57),
+ marginals=[stats.norm(), stats.gamma(a=3.0, scale=2.0)],
+ theta=2.0,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_clayton_copula_return_weights_shape_when_density_available(self):
+ tm = ClaytonCopula(
+ sampler=DigitalNetB2(3, seed=59),
+ marginals=[stats.norm(), stats.gamma(a=2.0), stats.expon()],
+ theta=1.5,
+ )
+
+ x, weights = tm(32, return_weights=True)
+
+ self.assertEqual(x.shape, (32, 3))
+ self.assertEqual(weights.shape, (32,))
+ self.assertTrue(np.all(np.isfinite(weights)))
+ self.assertTrue(np.all(weights > 0.0))
+
+ def test_clayton_copula_invalid_theta_raises_parameter_error(self):
+ for theta in [0, -1, np.inf, "not-a-number"]:
+ with self.subTest(theta=theta):
+ with self.assertRaisesRegex(ParameterError, "theta"):
+ ClaytonCopula(
+ sampler=DigitalNetB2(2, seed=61),
+ marginals=[stats.norm(), stats.norm()],
+ theta=theta,
+ )
+
+ def test_clayton_copula_supports_general_dimension(self):
+ for dimension in [2, 3, 5]:
+ with self.subTest(dimension=dimension):
+ tm = ClaytonCopula(
+ sampler=DigitalNetB2(dimension, seed=63),
+ marginals=[stats.norm()] * dimension,
+ theta=2.0,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_clayton_copula_marginal_without_ppf_raises_clear_error(self):
+ class NoPPF:
+ pass
+
+ with self.assertRaisesRegex(ParameterError, "ppf"):
+ ClaytonCopula(
+ sampler=DigitalNetB2(2, seed=67),
+ marginals=[stats.norm(), NoPPF()],
+ theta=2.0,
+ )
+
+ def test_clayton_copula_common_scipy_frozen_marginals_work(self):
+ for marginals in [
+ [stats.norm(), stats.beta(a=2, b=5)],
+ [stats.gamma(a=3), stats.expon()],
+ [stats.lognorm(s=0.5), stats.norm()],
+ ]:
+ with self.subTest(marginals=[type(m.dist).__name__ for m in marginals]):
+ tm = ClaytonCopula(
+ sampler=DigitalNetB2(2, seed=69),
+ marginals=marginals,
+ theta=2.0,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_clayton_copula_endpoint_uniforms_are_clipped_to_finite_outputs(self):
+ tm = ClaytonCopula(
+ sampler=DigitalNetB2(2, seed=70),
+ marginals=[stats.norm(), stats.lognorm(s=0.5)],
+ theta=2.0,
+ )
+ u = np.array([[0.0, 1.0], [1.0, 0.0]])
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (2, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_clayton_copula_tiny_theta_is_near_independent(self):
+ for dimension in [2, 3, 5]:
+ with self.subTest(dimension=dimension):
+ marginals = [stats.uniform()] * dimension
+ tm = ClaytonCopula(
+ sampler=DigitalNetB2(dimension, seed=70),
+ marginals=marginals,
+ theta=1e-8,
+ )
+ u = np.array(
+ [
+ [0.2, 0.7, 0.4, 0.6, 0.8],
+ [0.4, 0.8, 0.9, 0.3, 0.2],
+ [0.9, 0.1, 0.3, 0.7, 0.5],
+ ]
+ )[:, :dimension]
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (3, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+ np.testing.assert_allclose(x, u, atol=5e-6)
+
+ def test_clayton_copula_large_theta_is_finite(self):
+ for dimension in [2, 3, 5]:
+ for theta in [20.0, 50.0]:
+ with self.subTest(dimension=dimension, theta=theta):
+ tm = ClaytonCopula(
+ sampler=DigitalNetB2(dimension, seed=70),
+ marginals=[stats.norm()] * dimension,
+ theta=theta,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_clayton_copula_positive_dependence_behavior(self):
+ tm = ClaytonCopula(
+ sampler=DigitalNetB2(2, seed=71),
+ marginals=[stats.uniform(), stats.uniform()],
+ theta=2.0,
+ )
+
+ x = tm(4096)
+ empirical_corr = np.corrcoef(x.T)[0, 1]
+
+ self.assertGreater(empirical_corr, 0.45)
+
+ def test_clayton_copula_has_stronger_lower_tail_than_gaussian_copula(self):
+ theta = 2.0
+ n = 2**12
+ marginals = [stats.uniform(), stats.uniform()]
+ # Clayton Kendall tau is theta/(theta+2); convert to Gaussian rho.
+ rho = np.sin(np.pi * (theta / (theta + 2.0)) / 2.0)
+
+ clayton = ClaytonCopula(
+ sampler=DigitalNetB2(2, seed=73),
+ marginals=marginals,
+ theta=theta,
+ )
+ gaussian = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=73),
+ marginals=marginals,
+ correlation=[[1.0, rho], [rho, 1.0]],
+ )
+
+ x_clayton = clayton(n)
+ x_gaussian = gaussian(n)
+ threshold = 0.05
+
+ def lower_tail_rate(x):
+ tail_0 = x[:, 0] < threshold
+ return np.mean(x[tail_0, 1] < threshold)
+
+ clayton_tail = lower_tail_rate(x_clayton)
+ gaussian_tail = lower_tail_rate(x_gaussian)
+
+ self.assertGreater(clayton_tail, gaussian_tail + 0.2)
+
+ def test_frank_copula_output_shape_for_two_dimensions(self):
+ tm = FrankCopula(
+ sampler=DigitalNetB2(2, seed=75),
+ marginals=[stats.norm(), stats.gamma(a=3.0, scale=2.0)],
+ theta=5.0,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_frank_copula_positive_theta_supports_higher_dimensions(self):
+ for dimension in [3, 5]:
+ with self.subTest(dimension=dimension):
+ tm = FrankCopula(
+ sampler=DigitalNetB2(dimension, seed=76),
+ marginals=[stats.norm()] * dimension,
+ theta=5.0,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_frank_copula_return_weights_shape_when_density_available(self):
+ tm = FrankCopula(
+ sampler=DigitalNetB2(3, seed=77),
+ marginals=[stats.norm(), stats.gamma(a=2.0), stats.expon()],
+ theta=4.0,
+ )
+
+ x, weights = tm(32, return_weights=True)
+
+ self.assertEqual(x.shape, (32, 3))
+ self.assertEqual(weights.shape, (32,))
+ self.assertTrue(np.all(np.isfinite(weights)))
+ self.assertTrue(np.all(weights > 0.0))
+
+ def test_frank_copula_invalid_theta_raises_parameter_error(self):
+ for theta in [0, np.inf, -np.inf, "not-a-number"]:
+ with self.subTest(theta=theta):
+ with self.assertRaisesRegex(ParameterError, "theta"):
+ FrankCopula(
+ sampler=DigitalNetB2(2, seed=78),
+ marginals=[stats.norm(), stats.norm()],
+ theta=theta,
+ )
+
+ def test_frank_copula_negative_theta_rejected_above_two_dimensions(self):
+ with self.assertRaisesRegex(ParameterError, "d=2"):
+ FrankCopula(
+ sampler=DigitalNetB2(3, seed=79),
+ marginals=[stats.norm(), stats.norm(), stats.norm()],
+ theta=-2.0,
+ )
+
+ def test_frank_copula_dimension_mismatch_raises_dimension_error(self):
+ with self.assertRaisesRegex(DimensionError, "marginals"):
+ FrankCopula(
+ sampler=DigitalNetB2(2, seed=80),
+ marginals=[stats.norm(), stats.norm(), stats.norm()],
+ theta=5.0,
+ )
+
+ def test_frank_copula_marginal_without_ppf_raises_clear_error(self):
+ class NoPPF:
+ pass
+
+ with self.assertRaisesRegex(ParameterError, "ppf"):
+ FrankCopula(
+ sampler=DigitalNetB2(2, seed=82),
+ marginals=[stats.norm(), NoPPF()],
+ theta=5.0,
+ )
+
+ def test_frank_copula_positive_dependence_behavior(self):
+ tm = FrankCopula(
+ sampler=DigitalNetB2(2, seed=84),
+ marginals=[stats.uniform(), stats.uniform()],
+ theta=6.0,
+ )
+
+ x = tm(4096)
+ empirical_corr = np.corrcoef(x.T)[0, 1]
+
+ self.assertGreater(empirical_corr, 0.45)
+
+ def test_frank_copula_tiny_theta_is_close_to_independence(self):
+ for theta, dimension in [(1e-8, 3), (-1e-8, 2)]:
+ with self.subTest(theta=theta, dimension=dimension):
+ marginals = [stats.uniform()] * dimension
+ tm = FrankCopula(
+ sampler=DigitalNetB2(dimension, seed=86),
+ marginals=marginals,
+ theta=theta,
+ )
+ u = np.array(
+ [
+ [0.2, 0.7, 0.4, 0.6, 0.8],
+ [0.4, 0.8, 0.9, 0.3, 0.2],
+ [0.9, 0.1, 0.3, 0.7, 0.5],
+ ]
+ )[:, :dimension]
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (3, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+ np.testing.assert_allclose(x, u, atol=5e-6)
+
+ def test_frank_copula_large_theta_is_finite(self):
+ for theta, dimension in [(50.0, 5), (-50.0, 2)]:
+ with self.subTest(theta=theta, dimension=dimension):
+ tm = FrankCopula(
+ sampler=DigitalNetB2(dimension, seed=87),
+ marginals=[stats.norm()] * dimension,
+ theta=theta,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_frank_copula_negative_theta_produces_negative_dependence_in_2d(self):
+ tm = FrankCopula(
+ sampler=DigitalNetB2(2, seed=88),
+ marginals=[stats.uniform(), stats.uniform()],
+ theta=-6.0,
+ )
+
+ x = tm(4096)
+ empirical_corr = np.corrcoef(x.T)[0, 1]
+
+ self.assertLess(empirical_corr, -0.35)
+
+ def test_gumbel_copula_output_shape_and_finite_values(self):
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(2, seed=79),
+ marginals=[stats.norm(), stats.gamma(a=3.0, scale=2.0)],
+ theta=2.0,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_gumbel_copula_return_weights_shape_when_density_available(self):
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(3, seed=81),
+ marginals=[stats.norm(), stats.gamma(a=2.0), stats.expon()],
+ theta=1.5,
+ )
+
+ x, weights = tm(32, return_weights=True)
+
+ self.assertEqual(x.shape, (32, 3))
+ self.assertEqual(weights.shape, (32,))
+ self.assertTrue(np.all(np.isfinite(weights)))
+ self.assertTrue(np.all(weights > 0.0))
+
+ def test_gumbel_copula_invalid_theta_raises_parameter_error(self):
+ for theta in [0, 0.5, -1, np.inf, "not-a-number"]:
+ with self.subTest(theta=theta):
+ with self.assertRaisesRegex(ParameterError, "theta"):
+ GumbelCopula(
+ sampler=DigitalNetB2(2, seed=83),
+ marginals=[stats.norm(), stats.norm()],
+ theta=theta,
+ )
+
+ def test_gumbel_copula_theta_one_is_independent_marginal_transform(self):
+ marginals = [stats.norm(loc=-1.0, scale=2.0), stats.gamma(a=2.0, scale=3.0)]
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(2, seed=85),
+ marginals=marginals,
+ theta=1.0,
+ )
+ u = np.array([[0.2, 0.7], [0.4, 0.8], [0.9, 0.1]])
+
+ x = tm._transform(u)
+ expected = np.column_stack(
+ [marginals[j].ppf(u[:, j]) for j in range(len(marginals))]
+ )
+
+ np.testing.assert_allclose(x, expected, rtol=1e-12, atol=1e-12)
+
+ def test_gumbel_copula_theta_close_to_one_is_near_independent(self):
+ for dimension in [2, 3, 5]:
+ with self.subTest(dimension=dimension):
+ marginals = [stats.uniform()] * dimension
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(dimension, seed=85),
+ marginals=marginals,
+ theta=1.000001,
+ )
+ u = np.array(
+ [
+ [0.2, 0.7, 0.4, 0.6, 0.8],
+ [0.4, 0.8, 0.9, 0.3, 0.2],
+ [0.9, 0.1, 0.3, 0.7, 0.5],
+ ]
+ )[:, :dimension]
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (3, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+ np.testing.assert_allclose(x, u, atol=5e-5)
+
+ def test_gumbel_copula_large_theta_is_finite(self):
+ for dimension in [2, 3, 5]:
+ for theta in [20.0, 50.0]:
+ with self.subTest(dimension=dimension, theta=theta):
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(dimension, seed=86),
+ marginals=[stats.norm()] * dimension,
+ theta=theta,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_gumbel_copula_supports_general_dimension(self):
+ for dimension in [2, 3, 5]:
+ with self.subTest(dimension=dimension):
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(dimension, seed=87),
+ marginals=[stats.norm()] * dimension,
+ theta=2.0,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, dimension))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_gumbel_copula_marginal_without_ppf_raises_clear_error(self):
+ class NoPPF:
+ pass
+
+ with self.assertRaisesRegex(ParameterError, "ppf"):
+ GumbelCopula(
+ sampler=DigitalNetB2(2, seed=89),
+ marginals=[stats.norm(), NoPPF()],
+ theta=2.0,
+ )
+
+ def test_gumbel_copula_common_scipy_frozen_marginals_work(self):
+ for marginals in [
+ [stats.norm(), stats.beta(a=2, b=5)],
+ [stats.gamma(a=3), stats.expon()],
+ [stats.lognorm(s=0.5), stats.norm()],
+ ]:
+ with self.subTest(marginals=[type(m.dist).__name__ for m in marginals]):
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(2, seed=91),
+ marginals=marginals,
+ theta=2.0,
+ )
+
+ x = tm(128)
+
+ self.assertEqual(x.shape, (128, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_gumbel_copula_endpoint_uniforms_are_clipped_to_finite_outputs(self):
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(2, seed=93),
+ marginals=[stats.norm(), stats.lognorm(s=0.5)],
+ theta=2.0,
+ )
+ u = np.array([[0.0, 1.0], [1.0, 0.0]])
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (2, 2))
+ self.assertTrue(np.all(np.isfinite(x)))
+
+ def test_gumbel_copula_positive_dependence_behavior(self):
+ tm = GumbelCopula(
+ sampler=DigitalNetB2(2, seed=95),
+ marginals=[stats.uniform(), stats.uniform()],
+ theta=2.0,
+ )
+
+ x = tm(4096)
+ empirical_corr = np.corrcoef(x.T)[0, 1]
+
+ self.assertGreater(empirical_corr, 0.45)
+
+ def test_gumbel_copula_has_stronger_upper_tail_than_gaussian_copula(self):
+ theta = 2.0
+ n = 2**12
+ marginals = [stats.uniform(), stats.uniform()]
+ # Gumbel Kendall tau is 1 - 1/theta; convert to Gaussian rho.
+ rho = np.sin(np.pi * (1.0 - 1.0 / theta) / 2.0)
+
+ gumbel = GumbelCopula(
+ sampler=DigitalNetB2(2, seed=97),
+ marginals=marginals,
+ theta=theta,
+ )
+ gaussian = GaussianCopula(
+ sampler=DigitalNetB2(2, seed=97),
+ marginals=marginals,
+ correlation=[[1.0, rho], [rho, 1.0]],
+ )
+
+ x_gumbel = gumbel(n)
+ x_gaussian = gaussian(n)
+ threshold = 0.95
+
+ def upper_tail_rate(x):
+ tail_0 = x[:, 0] > threshold
+ return np.mean(x[tail_0, 1] > threshold)
+
+ gumbel_tail = upper_tail_rate(x_gumbel)
+ gaussian_tail = upper_tail_rate(x_gaussian)
+
+ self.assertGreater(gumbel_tail, gaussian_tail + 0.15)
+
+
+class TestCopulaWeightsFallbackAndSpawn(unittest.TestCase):
+
+ def test_copula_weight_fallback_warns_once_when_density_methods_are_missing(self):
+ for copula_cls in [GaussianCopula, StudentTCopula, ClaytonCopula, GumbelCopula, FrankCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ tm = _make_copula(
+ copula_cls,
+ dimension=2,
+ marginals=[PPFOnlyMarginal(), PPFOnlyMarginal()],
+ )
+ x = np.full((4, 2), 0.5)
+ expected_message = getattr(
+ tm,
+ "_missing_weight_warning_message",
+ f"{copula_cls.__name__} marginals must implement 'cdf' and "
+ "'pdf' or 'logpdf' to compute density weights. "
+ "Weights will be treated as 1.",
+ )
+
+ self.assertNotIn("_unit_weight_with_warning", copula_cls.__dict__)
+ self.assertIs(
+ tm._unit_weight_with_warning.__func__,
+ AbstractCopula._unit_weight_with_warning,
+ )
+
+ with self.assertWarns(UserWarning) as wcm:
+ weights = tm._weight(x)
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ second_weights = tm._weight(x)
+
+ np.testing.assert_allclose(weights, np.ones(4))
+ np.testing.assert_allclose(second_weights, np.ones(4))
+ self.assertEqual(str(wcm.warning), expected_message)
+ self.assertEqual(caught, [])
+
+ def test_student_t_weight_falls_back_when_multivariate_t_is_unavailable(self):
+ tm = StudentTCopula(
+ DigitalNetB2(2, seed=115),
+ marginals=[stats.norm(), stats.norm()],
+ correlation=np.eye(2),
+ df=4,
+ )
+ tm._mvt_scipy = None
+
+ with self.assertWarnsRegex(UserWarning, "Weights will be treated as 1"):
+ weights = tm._weight(np.full((3, 2), 0.25))
+
+ np.testing.assert_allclose(weights, np.ones(3))
+
+ def test_gaussian_weight_uses_pdf_branch_when_logpdf_is_unavailable(self):
+ tm = GaussianCopula(
+ DigitalNetB2(2, seed=117),
+ marginals=[UnitPDFMarginal(), UnitPDFMarginal()],
+ correlation=[[1.0, 0.4], [0.4, 1.0]],
+ )
+
+ weights = tm._weight(np.array([[0.25, 0.5], [0.75, 0.5]]))
+
+ self.assertEqual(weights.shape, (2,))
+ self.assertTrue(np.all(np.isfinite(weights)))
+ self.assertTrue(np.all(weights > 0.0))
+
+ def test_gumbel_theta_one_weight_is_independent_marginal_density(self):
+ tm = GumbelCopula(
+ DigitalNetB2(2, seed=119),
+ marginals=[stats.gamma(a=2.0), stats.expon()],
+ theta=1.0,
+ )
+ x = np.array([[1.0, 0.5], [2.0, 1.5]])
+ expected = stats.gamma(a=2.0).pdf(x[:, 0]) * stats.expon().pdf(x[:, 1])
+
+ weights = tm._weight(x)
+
+ np.testing.assert_allclose(weights, expected)
+
+ def test_gen_copula_samples_composed_transform_branch(self):
+ inner = GaussianCopula(
+ DigitalNetB2(2, seed=121),
+ marginals=[stats.uniform(), stats.uniform()],
+ correlation=[[1.0, 0.3], [0.3, 1.0]],
+ )
+ outer = ClaytonCopula(inner, marginals=[stats.uniform(), stats.uniform()], theta=1.5)
+
+ v = outer.gen_copula_samples(n_min=4, n_max=8)
+
+ self.assertEqual(v.shape, (4, 2))
+ self.assertTrue(np.all(np.isfinite(v)))
+ self.assertTrue(np.all((0.0 <= v) & (v <= 1.0)))
+
+ def test_copula_spawn_same_dimension_and_reject_different_dimension(self):
+ for copula_cls in [GaussianCopula, StudentTCopula, ClaytonCopula, GumbelCopula, FrankCopula]:
+ with self.subTest(copula_cls=copula_cls.__name__):
+ tm = _make_copula(copula_cls, dimension=2)
+
+ spawned = tm.spawn(s=1, dimensions=[2])
+ self.assertEqual(len(spawned), 1)
+ self.assertIsInstance(spawned[0], copula_cls)
+ self.assertEqual(spawned[0](4).shape, (4, 2))
+
+ with self.assertRaises(DimensionError):
+ tm._spawn(DigitalNetB2(3, seed=123), 3)
+
+ def test_frank_one_dimensional_weight_covers_zero_order_eulerian_term(self):
+ tm = FrankCopula(
+ DigitalNetB2(1, seed=125),
+ marginals=[UnitPDFMarginal()],
+ theta=3.0,
+ )
+
+ weights = tm._weight(np.array([[0.25], [0.75]]))
+
+ self.assertEqual(weights.shape, (2,))
+ self.assertTrue(np.all(np.isfinite(weights)))
+ self.assertTrue(np.all(weights > 0.0))
+
+ def test_frank_rejects_large_negative_theta_when_exponential_overflows(self):
+ with np.errstate(over="ignore"):
+ with self.assertRaisesRegex(ParameterError, "too close to 0 or too large"):
+ FrankCopula(
+ DigitalNetB2(2, seed=127),
+ marginals=[stats.uniform(), stats.uniform()],
+ theta=-1000.0,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_tm_product_measure.py b/test/test_tm_product_measure.py
new file mode 100644
index 000000000..470356860
--- /dev/null
+++ b/test/test_tm_product_measure.py
@@ -0,0 +1,271 @@
+import unittest
+
+import numpy as np
+import scipy.stats as stats
+
+from qmcpy import (
+ AcceptanceRejection,
+ DigitalNetB2,
+ DummySampler,
+ Gaussian,
+ GaussianCopula,
+ ProductMeasure,
+ SciPyWrapper,
+ Uniform,
+ ZeroInflatedExpUniform,
+)
+from qmcpy.util import DimensionError, ParameterError
+
+
+class TestProductMeasure(unittest.TestCase):
+
+ def test_product_measure_zero_inflated_with_scipy_uniform_shape(self):
+ n = 32
+ marginals = [
+ ZeroInflatedExpUniform(DummySampler(1), p_zero=0.4, lam=1.5),
+ SciPyWrapper(DummySampler(1), stats.uniform(loc=2.0, scale=3.0)),
+ ]
+ tm = ProductMeasure(sampler=DigitalNetB2(2, seed=23), marginals=marginals)
+
+ x = tm(n)
+
+ self.assertEqual(x.shape, (n, 2))
+ self.assertTrue(np.any(x[:, 0] == 0.0))
+ self.assertTrue(np.all((2.0 <= x[:, 1]) & (x[:, 1] <= 5.0)))
+
+ def test_product_measure_replication_shape(self):
+ n = 16
+ r = 3
+ marginals = [
+ ZeroInflatedExpUniform(DummySampler(1), p_zero=0.4, lam=1.5),
+ Uniform(DummySampler(1), lower_bound=2.0, upper_bound=5.0),
+ ]
+ tm = ProductMeasure(
+ sampler=DigitalNetB2(2, seed=23, replications=r),
+ marginals=marginals,
+ )
+
+ x = tm(n)
+
+ self.assertEqual(x.shape, (r, n, 2))
+
+ def test_product_measure_marginals_with_different_dimensions(self):
+ n = 32
+ marginals = [
+ Gaussian(
+ DummySampler(2),
+ mean=[1.0, -1.0],
+ covariance=[[2.0, 0.25], [0.25, 1.0]],
+ ),
+ ZeroInflatedExpUniform(DummySampler(1), p_zero=0.4, lam=1.5),
+ ]
+ tm = ProductMeasure(sampler=DigitalNetB2(3, seed=31), marginals=marginals)
+
+ x = tm(n)
+
+ self.assertEqual(tm.d, 3)
+ self.assertTrue(np.array_equal(tm.marginal_dimensions, np.array([2, 1])))
+ self.assertEqual(x.shape, (n, 3))
+ self.assertTrue(np.all(np.isfinite(x[:, :2])))
+ self.assertTrue(np.all(x[:, 2] >= 0.0))
+
+ def test_product_measure_block_split_range_and_weight_product(self):
+ n = 16
+ marginals = [
+ Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
+ Uniform(
+ DummySampler(2),
+ lower_bound=[20.0, 30.0],
+ upper_bound=[24.0, 36.0],
+ ),
+ ]
+ tm = ProductMeasure(sampler=DigitalNetB2(3, seed=41), marginals=marginals)
+
+ u = tm.discrete_distrib.gen_samples(n)
+ x = tm._transform(u)
+ x_call, jac = tm(n, return_weights=True)
+ expected = np.concatenate(
+ [
+ marginals[0]._jacobian_transform_r(u[..., :1], return_weights=False),
+ marginals[1]._jacobian_transform_r(u[..., 1:], return_weights=False),
+ ],
+ axis=-1,
+ )
+ expected_range = np.array([[10.0, 12.0], [20.0, 24.0], [30.0, 36.0]])
+
+ self.assertEqual(x.shape, (n, 3))
+ self.assertTrue(np.allclose(tm.range, expected_range))
+ self.assertTrue(np.allclose(x, expected))
+ self.assertTrue(np.all((10.0 <= x[:, 0]) & (x[:, 0] <= 12.0)))
+ self.assertTrue(np.all((20.0 <= x[:, 1]) & (x[:, 1] <= 24.0)))
+ self.assertTrue(np.all((30.0 <= x[:, 2]) & (x[:, 2] <= 36.0)))
+ self.assertTrue(np.allclose(tm._weight(x), 1.0 / (2.0 * 4.0 * 6.0)))
+ self.assertEqual(x_call.shape, (n, 3))
+ self.assertTrue(np.allclose(jac, 2.0 * 4.0 * 6.0))
+
+ def test_product_measure_invalid_inputs(self):
+ with self.assertRaisesRegex(ParameterError, "nonempty list of marginals"):
+ ProductMeasure(sampler=DigitalNetB2(1, seed=7), marginals=[])
+
+ with self.assertRaisesRegex(ParameterError, "marginal"):
+ ProductMeasure(sampler=DigitalNetB2(1, seed=7), marginals=[object()])
+
+ with self.assertRaisesRegex(ParameterError, "AbstractDiscreteDistribution"):
+ ProductMeasure(sampler=object(), marginals=[Uniform(DummySampler(1))])
+
+ marginals = [Uniform(DummySampler(1))]
+ with self.assertRaisesRegex(DimensionError, "sum of marginal dimensions"):
+ ProductMeasure(sampler=DigitalNetB2(2, seed=7), marginals=marginals)
+
+ def test_product_measure_rejects_non_dimension_preserving_marginal(self):
+ marginal = AcceptanceRejection(
+ DigitalNetB2(2, seed=7),
+ lambda x: np.ones(len(x)),
+ 1.0,
+ 1.0,
+ )
+
+ with self.assertRaisesRegex(DimensionError, "dimension-preserving"):
+ ProductMeasure(DigitalNetB2(2, seed=11), [marginal])
+
+ def test_product_measure_spawn_preserves_marginal_blocks_and_replaces_outer_sampler(self):
+ marginals = [
+ Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
+ Uniform(
+ DummySampler(2),
+ lower_bound=[20.0, 30.0],
+ upper_bound=[24.0, 36.0],
+ ),
+ ]
+ tm = ProductMeasure(sampler=DigitalNetB2(3, seed=41), marginals=marginals)
+
+ spawn = tm.spawn(s=1)[0]
+
+ self.assertIsInstance(spawn, ProductMeasure)
+ self.assertEqual(spawn.d, 3)
+ self.assertEqual(spawn.marginals, tm.marginals)
+ self.assertIsNot(spawn.discrete_distrib, tm.discrete_distrib)
+ self.assertTrue(np.array_equal(spawn.marginal_dimensions, np.array([1, 2])))
+
+ with self.assertRaises(DimensionError):
+ tm.spawn(s=1, dimensions=4)
+
+ def test_product_measure_does_not_use_marginal_dummy_sampler_values(self):
+ marginals = [
+ Uniform(DummySampler(1), lower_bound=0.0, upper_bound=2.0),
+ Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
+ ]
+
+ with self.assertRaisesRegex(ParameterError, "construction placeholder"):
+ marginals[0].discrete_distrib(4)
+
+ tm = ProductMeasure(sampler=DigitalNetB2(2, seed=19), marginals=marginals)
+ x = tm(8)
+
+ self.assertEqual(x.shape, (8, 2))
+ self.assertTrue(np.all((0.0 <= x[:, 0]) & (x[:, 0] <= 2.0)))
+ self.assertTrue(np.all((10.0 <= x[:, 1]) & (x[:, 1] <= 12.0)))
+
+ def test_product_measure_same_outer_seed_matches_different_outer_seed_changes(self):
+ marginals = [
+ Uniform(DummySampler(1), lower_bound=0.0, upper_bound=2.0),
+ Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
+ ]
+
+ first = ProductMeasure(sampler=DigitalNetB2(2, seed=101), marginals=marginals)(16)
+ same_outer = ProductMeasure(sampler=DigitalNetB2(2, seed=101), marginals=marginals)(16)
+ different_outer = ProductMeasure(sampler=DigitalNetB2(2, seed=102), marginals=marginals)(16)
+
+ self.assertTrue(np.array_equal(first, same_outer))
+ self.assertFalse(np.array_equal(first, different_outer))
+
+ def test_product_measure_replication_means_close_to_uniform_targets(self):
+ n = 1024
+ r = 4
+ marginals = [
+ Uniform(DummySampler(1), lower_bound=0.0, upper_bound=2.0),
+ Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0),
+ ]
+ tm = ProductMeasure(
+ sampler=DigitalNetB2(2, seed=101, replications=r),
+ marginals=marginals,
+ )
+
+ x = tm(n)
+ replication_means = x.mean(axis=1)
+
+ self.assertEqual(x.shape, (r, n, 2))
+ self.assertTrue(np.allclose(replication_means[:, 0], 1.0, atol=0.03))
+ self.assertTrue(np.allclose(replication_means[:, 1], 11.0, atol=0.03))
+
+ def test_product_measure_with_scipywrapper_beta_marginal(self):
+ n = 64
+ marginals = [
+ Uniform(DummySampler(1), lower_bound=-1.0, upper_bound=1.0),
+ SciPyWrapper(DummySampler(1), stats.beta(a=2.0, b=5.0)),
+ ]
+ tm = ProductMeasure(sampler=DigitalNetB2(2, seed=71), marginals=marginals)
+
+ x = tm(n)
+
+ self.assertEqual(x.shape, (n, 2))
+ self.assertTrue(np.all((-1.0 <= x[:, 0]) & (x[:, 0] <= 1.0)))
+ self.assertTrue(np.all((0.0 <= x[:, 1]) & (x[:, 1] <= 1.0)))
+
+ def test_product_measure_matches_equivalent_scipywrapper(self):
+ n = 128
+ seed = 55
+ scipy_marginals = [stats.norm(loc=0.0, scale=1.0), stats.gamma(a=2.0, scale=1.0)]
+ product_marginals = [
+ SciPyWrapper(DummySampler(1), scipy_marginals[0]),
+ SciPyWrapper(DummySampler(1), scipy_marginals[1]),
+ ]
+
+ product_samples = ProductMeasure(
+ sampler=DigitalNetB2(2, seed=seed),
+ marginals=product_marginals,
+ )(n)
+ scipy_samples = SciPyWrapper(DigitalNetB2(2, seed=seed), scipy_marginals)(n)
+
+ self.assertTrue(np.array_equal(product_samples, scipy_samples))
+
+ def test_product_measure_with_gaussian_copula_marginal(self):
+ n = 64
+ copula = GaussianCopula(
+ DummySampler(2),
+ marginals=[stats.beta(a=2.0, b=5.0), stats.gamma(a=3.0, scale=1.0)],
+ correlation=[[1.0, 0.5], [0.5, 1.0]],
+ )
+ marginals = [copula, Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0)]
+ tm = ProductMeasure(sampler=DigitalNetB2(3, seed=81), marginals=marginals)
+
+ x = tm(n)
+
+ self.assertEqual(x.shape, (n, 3))
+ self.assertTrue(np.all((0.0 <= x[:, 0]) & (x[:, 0] <= 1.0)))
+ self.assertTrue(np.all(x[:, 1] >= 0.0))
+ self.assertTrue(np.all((10.0 <= x[:, 2]) & (x[:, 2] <= 12.0)))
+
+ def test_product_measure_recursive_transform_sampling_supported_but_weights_restricted(self):
+ recursive_marginal = Uniform(
+ Uniform(DummySampler(1), lower_bound=0.0, upper_bound=1.0),
+ lower_bound=2.0,
+ upper_bound=4.0,
+ )
+ direct_marginal = Uniform(DummySampler(1), lower_bound=10.0, upper_bound=12.0)
+ tm = ProductMeasure(
+ sampler=DigitalNetB2(2, seed=91),
+ marginals=[recursive_marginal, direct_marginal],
+ )
+
+ x = tm(16)
+
+ self.assertEqual(x.shape, (16, 2))
+ self.assertTrue(np.all((2.0 <= x[:, 0]) & (x[:, 0] <= 4.0)))
+ self.assertTrue(np.all((10.0 <= x[:, 1]) & (x[:, 1] <= 12.0)))
+ with self.assertRaisesRegex(ParameterError, "direct marginal"):
+ tm(16, return_weights=True)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_tm_scipy_wrapper_custom.py b/test/test_tm_scipy_wrapper_custom.py
new file mode 100644
index 000000000..105b984e5
--- /dev/null
+++ b/test/test_tm_scipy_wrapper_custom.py
@@ -0,0 +1,292 @@
+import unittest
+import warnings
+
+import numpy as np
+import scipy.stats as stats
+
+from qmcpy import DigitalNetB2, SciPyWrapper, StudentT, ZeroInflatedExpUniform
+
+from qmcpy.true_measure.triangular import TriangularDistribution
+from qmcpy.util import DimensionError, ParameterError
+
+
+MISSING_PDF_WARNING = "no 'pdf' or 'logpdf'"
+
+
+def _missing_pdf_warnings(caught):
+ return [
+ warning
+ for warning in caught
+ if issubclass(warning.category, UserWarning)
+ and MISSING_PDF_WARNING in str(warning.message)
+ ]
+
+
+class TestSciPyWrapperCustom(unittest.TestCase):
+
+ def test_mvn_dependence_correlation_and_moment(self):
+ """
+ Check that passing a SciPy multivariate normal through SciPyWrapper
+ preserves correlation and the mixed moment E[X1 X2].
+ """
+ sampler = DigitalNetB2(2, seed=5)
+ rho_target = 0.7
+ cov = [[1.0, rho_target], [rho_target, 1.0]]
+ mvn = stats.multivariate_normal(mean=[0.0, 0.0], cov=cov)
+ tm_mvn = SciPyWrapper(sampler, scipy_distribs=mvn)
+
+ n = 4096
+ x = tm_mvn(n)
+
+ rho_hat = np.corrcoef(x.T)[0, 1]
+ est_moment = np.mean(x[:, 0] * x[:, 1])
+
+ self.assertTrue(np.isfinite(rho_hat))
+ self.assertTrue(np.isfinite(est_moment))
+
+ self.assertLess(abs(rho_hat - rho_target), 0.05)
+ self.assertLess(abs(est_moment - rho_target), 0.05)
+
+ def test_triangular_custom_marginal_range_and_shape(self):
+ """
+ Make sure our custom triangular marginal behaves sensibly:
+ samples stay in the right interval and the empirical mean is close
+ to the analytic mean.
+ """
+ tri = TriangularDistribution(c=0.3, loc=-1.0, scale=2.0)
+ tm = SciPyWrapper(DigitalNetB2(1, seed=11), scipy_distribs=tri)
+
+ n = 4096
+ x = tm(n).ravel()
+
+ self.assertGreaterEqual(x.min(), -1.1)
+ self.assertLessEqual(x.max(), 1.1)
+
+ a = -1.0
+ b = 1.0
+ m = -1.0 + 0.3 * 2.0
+ true_mean = (a + b + m) / 3.0
+ emp_mean = x.mean()
+ self.assertLess(abs(emp_mean - true_mean), 0.05)
+
+ def test_zero_inflated_zero_rate(self):
+ """
+ Check that the zero-inflated exponential distribution preserves the
+ specified probability mass at X = 0.
+ """
+ p_zero = 0.4
+ sampler = DigitalNetB2(1, seed=17)
+ tm = ZeroInflatedExpUniform(sampler, p_zero=p_zero, lam=1.5)
+
+ n = 4096
+ samples = tm(n)
+ x = samples.ravel()
+ zero_rate = np.mean(x == 0.0)
+
+ self.assertEqual(samples.shape, (n, 1))
+ self.assertLess(abs(zero_rate - p_zero), 0.05)
+
+ def test_zero_inflated_replications_shape(self):
+ tm = ZeroInflatedExpUniform(
+ DigitalNetB2(1, seed=17, replications=2),
+ p_zero=0.4,
+ lam=1.5,
+ )
+
+ x = tm(8)
+
+ self.assertEqual(x.shape, (2, 8, 1))
+ self.assertTrue(np.all(x >= 0.0))
+
+ def test_zero_inflated_rejects_invalid_p_zero(self):
+ for p_zero in [0.0, 1.0, -0.1, 1.1]:
+ with self.subTest(p_zero=p_zero):
+ with self.assertRaisesRegex(ParameterError, "p_zero must be in"):
+ ZeroInflatedExpUniform(
+ DigitalNetB2(1, seed=17),
+ p_zero=p_zero,
+ lam=1.5,
+ )
+
+ def test_zero_inflated_rejects_nonpositive_lam(self):
+ for lam in [0.0, -1.0]:
+ with self.subTest(lam=lam):
+ with self.assertRaisesRegex(ParameterError, "lam must be positive"):
+ ZeroInflatedExpUniform(
+ DigitalNetB2(1, seed=17),
+ p_zero=0.4,
+ lam=lam,
+ )
+
+ def test_zero_inflated_requires_one_dimensional_sampler(self):
+ with self.assertRaisesRegex(
+ DimensionError, "requires a one-dimensional sampler"
+ ):
+ ZeroInflatedExpUniform(
+ DigitalNetB2(2, seed=17),
+ p_zero=0.4,
+ lam=1.5,
+ )
+
+ def test_zero_inflated_inverse_transform_exact_values(self):
+ tm = ZeroInflatedExpUniform(
+ DigitalNetB2(1, seed=17),
+ p_zero=0.4,
+ lam=2.0,
+ )
+ u = np.array([[0.0], [0.2], [0.4], [0.7], [0.9]])
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (5, 1))
+ self.assertTrue(np.array_equal(x[:3], np.zeros((3, 1))))
+ self.assertTrue(np.all(x[3:] > 0.0))
+
+ u_positive = u[3:, 0]
+ u_rescaled = (u_positive - 0.4) / 0.6
+ expected = -np.log1p(-u_rescaled) / 2.0
+ self.assertTrue(np.allclose(x[3:, 0], expected))
+
+ def test_zero_inflated_inverse_transform_all_zero_branch(self):
+ tm = ZeroInflatedExpUniform(
+ DigitalNetB2(1, seed=17),
+ p_zero=0.4,
+ lam=2.0,
+ )
+ u = np.array([[0.0], [0.1], [0.4]])
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (3, 1))
+ self.assertTrue(np.array_equal(x, np.zeros((3, 1))))
+
+ def test_zero_inflated_inverse_transform_clips_one(self):
+ tm = ZeroInflatedExpUniform(
+ DigitalNetB2(1, seed=17),
+ p_zero=0.4,
+ lam=2.0,
+ )
+ u = np.array([[1.0]])
+
+ x = tm._transform(u)
+
+ self.assertEqual(x.shape, (1, 1))
+ self.assertTrue(np.isfinite(x).all())
+ self.assertGreater(x[0, 0], 0.0)
+
+ def test_zero_inflated_construction_does_not_warn_about_missing_pdf(self):
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ tm = ZeroInflatedExpUniform(
+ DigitalNetB2(1, seed=17),
+ p_zero=0.4,
+ lam=1.5,
+ )
+
+ self.assertEqual(tm.d, 1)
+ self.assertEqual(_missing_pdf_warnings(caught), [])
+
+ def test_zero_inflated_sampling_does_not_warn_about_missing_pdf(self):
+ tm = ZeroInflatedExpUniform(DigitalNetB2(1, seed=17), p_zero=0.4, lam=1.5)
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ x = tm(8)
+
+ self.assertEqual(x.shape, (8, 1))
+ self.assertEqual(_missing_pdf_warnings(caught), [])
+
+ def test_zero_inflated_return_weights_warns_once_for_missing_pdf(self):
+ tm = ZeroInflatedExpUniform(DigitalNetB2(1, seed=17), p_zero=0.4, lam=1.5)
+
+ with self.assertWarnsRegex(UserWarning, MISSING_PDF_WARNING):
+ x, jac = tm(8, return_weights=True)
+
+ self.assertEqual(x.shape, (8, 1))
+ self.assertEqual(jac.shape, (8,))
+ self.assertTrue(np.allclose(jac, 1.0))
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ x_second, jac_second = tm(8, return_weights=True)
+
+ self.assertEqual(x_second.shape, (8, 1))
+ self.assertTrue(np.allclose(jac_second, 1.0))
+ self.assertEqual(_missing_pdf_warnings(caught), [])
+
+ def test_zero_inflated_y_split_warns_and_uses_one_dimensional_interface(self):
+ with self.assertWarnsRegex(DeprecationWarning, "y_split"):
+ tm = ZeroInflatedExpUniform(
+ DigitalNetB2(1, seed=17),
+ p_zero=0.4,
+ lam=1.5,
+ y_split=0.5,
+ )
+
+ x = tm(4)
+
+ self.assertEqual(x.shape, (4, 1))
+ self.assertTrue(np.all(x >= 0.0))
+
+ def test_zero_inflated_y_split_preserves_deprecated_two_dimensional_usage(self):
+ with self.assertWarnsRegex(DeprecationWarning, "2D zero-inflated"):
+ tm = ZeroInflatedExpUniform(
+ DigitalNetB2(2, seed=17),
+ p_zero=0.4,
+ lam=1.5,
+ y_split=0.5,
+ )
+
+ x = tm(16)
+
+ self.assertEqual(x.shape, (16, 2))
+ self.assertTrue(np.all(x[:, 0] >= 0.0))
+ self.assertTrue(np.all((0.0 <= x[:, 1]) & (x[:, 1] <= 1.0)))
+ self.assertTrue(np.all(x[x[:, 0] == 0.0, 1] <= 0.5))
+ self.assertTrue(np.all(x[x[:, 0] > 0.0, 1] >= 0.5))
+
+ def test_zero_inflated_y_split_preserves_replicated_two_dimensional_usage(self):
+ with self.assertWarnsRegex(DeprecationWarning, "2D zero-inflated"):
+ tm = ZeroInflatedExpUniform(
+ DigitalNetB2(2, seed=17, replications=2),
+ p_zero=0.4,
+ lam=1.5,
+ y_split=0.5,
+ )
+
+ x = tm(16)
+
+ self.assertEqual(x.shape, (2, 16, 2))
+ self.assertTrue(np.all(x[..., 0] >= 0.0))
+ self.assertTrue(np.all((0.0 <= x[..., 1]) & (x[..., 1] <= 1.0)))
+ self.assertTrue(np.all(x[..., 1][x[..., 0] == 0.0] <= 0.5))
+ self.assertTrue(np.all(x[..., 1][x[..., 0] > 0.0] >= 0.5))
+
+ def test_student_t_marginals_shape(self):
+ tm = SciPyWrapper(
+ sampler=DigitalNetB2(2, seed=5),
+ scipy_distribs=stats.t(df=5),
+ )
+ x = tm(8)
+ self.assertEqual(x.shape, (8, 2))
+
+ def test_multivariate_student_t_joint_corr_and_cov(self):
+ if not hasattr(stats, "multivariate_t"):
+ self.skipTest("scipy.stats.multivariate_t not available in this SciPy version")
+
+ df = 5.0
+ rho = 0.8
+ loc = np.array([0.0, 0.0])
+ shape = np.array([[1.0, rho], [rho, 1.0]])
+
+ tm = StudentT(DigitalNetB2(2, seed=123), loc=loc, shape=shape, df=df)
+
+ n = 4096
+ x = tm(n)
+ emp_corr = np.corrcoef(x.T)[0, 1]
+
+ self.assertLess(abs(emp_corr - rho), 0.05)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_true_measures.py b/test/test_tm_true_measures.py
similarity index 100%
rename from test/test_true_measures.py
rename to test/test_tm_true_measures.py
diff --git a/test/test_unwrap_markdown.py b/test/test_unwrap_markdown.py
deleted file mode 100644
index e227b1807..000000000
--- a/test/test_unwrap_markdown.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import pytest
-
-from scripts.unwrap_markdown import unwrap_markdown_text
-
-
-@pytest.mark.parametrize(
- ("source", "expected"),
- [
- (
- "- unordered first\n unordered second\n",
- "- unordered first unordered second\n",
- ),
- (
- "- [ ] task first\n task second\n",
- "- [ ] task first task second\n",
- ),
- (
- "10. ordered first\n ordered second\n",
- "10. ordered first ordered second\n",
- ),
- ],
-)
-def test_unwraps_list_item_continuations(source, expected):
- updated = unwrap_markdown_text(source)
-
- assert updated == expected
- assert unwrap_markdown_text(updated) == updated
-
-
-def test_unwraps_adjacent_and_nested_list_items_separately():
- source = (
- "- parent first\n"
- " parent second\n"
- " - child first\n"
- " child second\n"
- "- sibling first\n"
- " sibling second\n"
- )
-
- assert unwrap_markdown_text(source) == (
- "- parent first parent second\n"
- " - child first child second\n"
- "- sibling first sibling second\n"
- )
-
-
-def test_preserves_list_item_blocks_and_explicit_hard_breaks():
- source = (
- "- first paragraph\n"
- " continuation\n"
- "\n"
- " second paragraph\n"
- " continuation\n"
- "\n"
- "- item before code\n"
- " indented code\n"
- "\n"
- "- explicit hard break \n"
- " remains separate\n"
- )
-
- assert unwrap_markdown_text(source) == (
- "- first paragraph continuation\n"
- "\n"
- " second paragraph continuation\n"
- "\n"
- "- item before code\n"
- " indented code\n"
- "\n"
- "- explicit hard break \n"
- " remains separate\n"
- )
-
-
-def test_unwraps_ordinary_paragraphs():
- assert unwrap_markdown_text("first line\nsecond line\n") == "first line second line\n"
-
-
-@pytest.mark.parametrize("rule", ["- - -", "* * *", "_ _ _"])
-def test_preserves_horizontal_rules(rule):
- source = f"{rule}\nfollowing paragraph\n"
-
- assert unwrap_markdown_text(source) == source
-
-
-def test_preserves_indented_code_that_looks_like_a_list():
- source = " - code first\n code second\n"
-
- assert unwrap_markdown_text(source) == source
diff --git a/test/test_ut_plot_and_stop.py b/test/test_ut_plot_and_stop.py
new file mode 100644
index 000000000..4ce81545b
--- /dev/null
+++ b/test/test_ut_plot_and_stop.py
@@ -0,0 +1,166 @@
+import builtins
+import sys
+import types
+import unittest
+from unittest.mock import patch
+
+import numpy as np
+
+from qmcpy import AbstractDiscreteDistribution, plot_proj
+from qmcpy.util import stop_notebook
+
+
+class FakeAxes:
+ def __init__(self):
+ self.removed = False
+ self.calls = []
+
+ def remove(self):
+ self.removed = True
+
+ def set_xlim(self, *a, **k):
+ self.calls.append(("set_xlim", a))
+
+ def set_ylim(self, *a, **k):
+ self.calls.append(("set_ylim", a))
+
+ def set_xticks(self, *a, **k):
+ self.calls.append(("set_xticks", a))
+
+ def set_yticks(self, *a, **k):
+ self.calls.append(("set_yticks", a))
+
+ def set_aspect(self, *a, **k):
+ self.calls.append(("set_aspect", a))
+
+ def grid(self, *a, **k):
+ self.calls.append(("grid", a))
+
+ def tick_params(self, *a, **k):
+ self.calls.append(("tick_params", a))
+
+ def set_xlabel(self, *a, **k):
+ self.calls.append(("set_xlabel", a))
+
+ def set_ylabel(self, *a, **k):
+ self.calls.append(("set_ylabel", a))
+
+ def scatter(self, *a, **k):
+ self.calls.append(("scatter", a))
+
+
+class FakeFig:
+ def __init__(self):
+ self.tl = False
+
+ def tight_layout(self, *a, **k):
+ self.tl = True
+
+
+def make_fake_matplotlib(nrows, ncols):
+ plt = types.ModuleType("matplotlib.pyplot")
+ plt.style = types.SimpleNamespace()
+ plt.style.use = lambda *a, **k: None
+ plt.rcParams = {
+ "font.family": "sans-serif",
+ "axes.prop_cycle": types.SimpleNamespace(
+ by_key=lambda: {"color": ["k", "b", "r"]}
+ ),
+ }
+
+ def subplots(nrows=1, ncols=1, figsize=None, squeeze=False):
+ fig = FakeFig()
+ ax = np.empty((nrows, ncols), dtype=object)
+ for i in range(nrows):
+ for j in range(ncols):
+ ax[i, j] = FakeAxes()
+ return fig, ax
+
+ plt.subplots = subplots
+ plt.suptitle = lambda *a, **k: None
+ return plt
+
+
+class DummySampler(AbstractDiscreteDistribution):
+ def __init__(self, d=2):
+ super().__init__(dimension=d, replications=1, seed=1, d_limit=10, n_limit=100)
+
+ def _gen_samples(self, n_min, n_max, return_binary=False, warn=True):
+ n = n_max - n_min
+ return np.tile(np.arange(n)[:, None] / max(1, n - 1), (1, 1, self.d)).reshape(
+ self.replications, n, self.d
+ )
+
+ def __repr__(self):
+ return "DummySampler"
+
+
+class TestPlotProjAndStopNotebook(unittest.TestCase):
+
+ def test_plot_proj_with_fake_matplotlib_and_sampler(self):
+ # Inject fake matplotlib.pyplot
+ fake_plt = make_fake_matplotlib(1, 1)
+ # Create a proper matplotlib package module with colors submodule
+ fake_matplotlib = types.ModuleType("matplotlib")
+ fake_matplotlib.pyplot = fake_plt
+ fake_matplotlib.colors = types.SimpleNamespace()
+
+ with patch.dict(
+ sys.modules,
+ {"matplotlib.pyplot": fake_plt, "matplotlib": fake_matplotlib},
+ ):
+ sampler = DummySampler(d=3)
+ fig, ax = plot_proj(
+ sampler,
+ n=4,
+ d_horizontal=1,
+ d_vertical=2,
+ math_ind=True,
+ marker_size=1,
+ figfac=1,
+ )
+
+ self.assertIsInstance(fig, FakeFig)
+ self.assertIsInstance(ax, np.ndarray)
+ # At least one axes should have scatter calls or be removed
+ found = False
+ for a in ax.flatten():
+ if getattr(a, "removed", False) or any(c[0] == "scatter" for c in a.calls):
+ found = True
+ break
+ self.assertTrue(found)
+
+ def test_plot_proj_with_callable_sampler(self):
+ # sampler not instance of AbstractDiscreteDistribution -> uses t_i labels
+ fake_plt = make_fake_matplotlib(1, 1)
+ fake_matplotlib = types.ModuleType("matplotlib")
+ fake_matplotlib.pyplot = fake_plt
+ fake_matplotlib.colors = types.SimpleNamespace()
+
+ with patch.dict(
+ sys.modules,
+ {"matplotlib.pyplot": fake_plt, "matplotlib": fake_matplotlib},
+ ):
+ def sampler_callable(n):
+ return np.zeros((n, 1))
+
+ fig, ax = plot_proj(
+ sampler_callable, n=3, d_horizontal=0, d_vertical=0, math_ind=False
+ )
+
+ self.assertIsInstance(fig, FakeFig)
+
+ def test_stop_notebook_yes_and_no(self):
+ # When input is 'yes' nothing should happen
+ with patch.object(builtins, "input", lambda prompt="": "yes"):
+ # Should not raise
+ stop_notebook("prompt")
+
+ # When input is not 'yes' should exit
+ with patch.object(builtins, "input", lambda prompt="": "no"):
+ with self.assertRaises(SystemExit):
+ stop_notebook("prompt")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_util.py b/test/test_ut_util.py
similarity index 100%
rename from test/test_util.py
rename to test/test_ut_util.py