diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03e82bc..97c684f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Sync submodules to tracked branches + run: | + git submodule sync --recursive + git submodule update --init --remote --recursive + - name: Setup Python id: setup-python uses: actions/setup-python@v5 @@ -34,6 +39,35 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true + - name: Clean up PATH on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $clean = ($env:PATH -split ';' | Where-Object { + $_ -notlike '*Git\usr\bin*' -and + $_ -notlike '*Strawberry*' + }) -join ';' + echo "PATH=$clean" >> $env:GITHUB_ENV + + - name: Set up MSVC dev cmd + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + with: + toolset: '14.29' + arch: x64 + + - name: Remove Git link.exe from PATH + if: runner.os == 'Windows' + shell: pwsh + run: | + $gitLink = "C:\Program Files\Git\usr\bin\link.exe" + if (Test-Path $gitLink) { + Rename-Item $gitLink "link_gnu_backup.exe" + Write-Host "Renamed Git link.exe -> link_gnu_backup.exe" + } else { + Write-Host "Git link.exe not found, nothing to do" + } + - name: Cache pip wheels uses: actions/cache@v4 with: diff --git a/.github/workflows/docs_deploy.yml b/.github/workflows/docs_deploy.yml index 284da57..3cbd864 100644 --- a/.github/workflows/docs_deploy.yml +++ b/.github/workflows/docs_deploy.yml @@ -23,6 +23,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sync submodules to tracked branches + run: | + git submodule sync --recursive + git submodule update --init --remote --recursive + - uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/.github/workflows/docs_preview.yml b/.github/workflows/docs_preview.yml index d0bbc68..49a75fe 100644 --- a/.github/workflows/docs_preview.yml +++ b/.github/workflows/docs_preview.yml @@ -13,6 +13,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sync submodules to tracked branches + run: | + git submodule sync --recursive + git submodule update --init --remote --recursive + - uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/.gitignore b/.gitignore index d5a9a64..a75017c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ # C extensions *.so +*.o # Distribution / packaging .Python @@ -127,7 +128,12 @@ poetry.lock .cursorindexingignore # Docs - docs/build/* docs/source/api/* docs/source/examples/* + +# Log files +*.log + +# Unuran build artifacts +src/pysatl_core/sampling/unuran/bindings/_unuran_cffi.c diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a149c2c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "vendor/unuran-pysatl"] + path = vendor/unuran-pysatl + url = https://github.com/PySATL/unuran.git + branch = wrdxwrdxwrdx/lib_build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 29174ac..efbaeb2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,7 @@ exclude: > | LICENSE$ | .*\.md$ | .*\.jpg$ + | vendor/.* )$ repos: @@ -68,3 +69,4 @@ repos: - scipy>=1.13 - pytest>=8 - scipy-stubs>=1.13 + - types-setuptools>=75 diff --git a/README.md b/README.md index 2b47590..130a057 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ The library is designed as a **foundational kernel** rather than a ready-to-use - Python **3.12+** (the project relies on **PEP 695** syntax) - NumPy **2.x** - SciPy **1.13+** +- A C toolchain for compiling the UNURAN bindings: + - **Linux/macOS:** GCC (or Clang) plus standard build utilities. + - **Windows:** Microsoft Visual C++ Build Tools (MSVC) from Visual Studio or the standalone Build Tools installer. - Poetry (recommended for development) --- @@ -50,6 +53,7 @@ Clone the repository: ```bash git clone https://github.com/PySATL/pysatl-core.git cd pysatl-core +git submodule update --init --remote --recursive ``` ### Using Poetry (recommended) diff --git a/examples/example_sampling_methods.ipynb b/examples/example_sampling_methods.ipynb new file mode 100644 index 0000000..6c7bc20 --- /dev/null +++ b/examples/example_sampling_methods.ipynb @@ -0,0 +1,501 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1f72d4a2", + "metadata": {}, + "source": [ + "# Sampling strategy walkthrough" + ] + }, + { + "cell_type": "markdown", + "id": "ca9fb58f", + "metadata": {}, + "source": [ + "## Environment preparation\n", + "\n", + "Run the next cell to define helper classes and functions that are shared by all examples." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "5d131883", + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "import math\n", + "from collections.abc import Iterable, Mapping\n", + "from dataclasses import dataclass\n", + "from typing import Any\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from pysatl_core.distributions import AnalyticalComputation, DefaultComputationStrategy\n", + "from pysatl_core.distributions.distribution import Distribution\n", + "from pysatl_core.distributions.support import ExplicitTableDiscreteSupport, Support\n", + "from pysatl_core.sampling import (\n", + " DefaultUnuranSamplingStrategy,\n", + " UnuranMethod,\n", + " UnuranMethodConfig,\n", + ")\n", + "from pysatl_core.types import CharacteristicName, EuclideanDistributionType, Kind\n", + "\n", + "\n", + "@dataclass(slots=True)\n", + "class CustomDistribution(Distribution):\n", + " \"\"\"Minimal Distribution wrapper used solely for the UNU.RAN examples.\"\"\"\n", + "\n", + " name: str\n", + " kind: Kind\n", + " parameters: dict[str, Any]\n", + " _analytical: dict[str, AnalyticalComputation[Any, Any]]\n", + " _sampling_strategy: DefaultUnuranSamplingStrategy\n", + " _distribution_type: EuclideanDistributionType\n", + " _computation_strategy: DefaultComputationStrategy\n", + " _support: Support | None\n", + "\n", + " def __init__(\n", + " self,\n", + " name: str,\n", + " kind: Kind,\n", + " analytical_computations: Iterable[AnalyticalComputation[Any, Any]]\n", + " | Mapping[str, AnalyticalComputation[Any, Any]],\n", + " sampling_strategy: DefaultUnuranSamplingStrategy,\n", + " support: Support | None = None,\n", + " *,\n", + " parameters: Mapping[str, Any] | None = None,\n", + " ) -> None:\n", + " self.name = name\n", + " self.kind = kind\n", + " self.parameters = dict(parameters or {})\n", + " self._distribution_type = EuclideanDistributionType(kind=kind, dimension=1)\n", + " if isinstance(analytical_computations, Mapping):\n", + " self._analytical = dict(analytical_computations)\n", + " else:\n", + " self._analytical = {ac.target: ac for ac in analytical_computations}\n", + " self._sampling_strategy = sampling_strategy\n", + " self._computation_strategy = DefaultComputationStrategy()\n", + " self._support = support\n", + "\n", + " @property\n", + " def distribution_type(self) -> EuclideanDistributionType:\n", + " return self._distribution_type\n", + "\n", + " @property\n", + " def analytical_computations(self) -> Mapping[str, AnalyticalComputation[Any, Any]]:\n", + " return self._analytical\n", + "\n", + " @property\n", + " def sampling_strategy(self) -> DefaultUnuranSamplingStrategy:\n", + " return self._sampling_strategy\n", + "\n", + " def set_sampling_strategy(self, sampling_strategy: DefaultUnuranSamplingStrategy) -> None:\n", + " self._sampling_strategy = sampling_strategy\n", + "\n", + " @property\n", + " def computation_strategy(self) -> DefaultComputationStrategy:\n", + " return self._computation_strategy\n", + "\n", + " @property\n", + " def support(self) -> Support | None: # type: ignore[override]\n", + " return self._support\n", + "\n", + "\n", + "def _save_and_show(fig: plt.Figure, filename: str) -> None:\n", + " display(fig)\n", + " plt.close(fig)\n", + "\n", + "\n", + "def _plot_histogram(\n", + " title: str, xs: np.ndarray, reference: np.ndarray, samples: np.ndarray, filename: str\n", + ") -> None:\n", + " fig, ax = plt.subplots(figsize=(6, 4))\n", + " ax.set_title(title)\n", + " ax.plot(xs, reference, label=\"Reference\", color=\"tab:red\")\n", + " ax.hist(samples, bins=80, density=True, alpha=0.55, label=\"Sampled\", color=\"tab:blue\")\n", + " ax.set_xlabel(\"x\")\n", + " ax.set_ylabel(\"Density\")\n", + " ax.legend()\n", + " ax.grid(True, alpha=0.3)\n", + " _save_and_show(fig, filename)\n", + "\n", + "\n", + "def _plot_cdf(\n", + " title: str, xs: np.ndarray, cdf: np.ndarray, samples: np.ndarray, filename: str\n", + ") -> None:\n", + " fig, ax = plt.subplots(figsize=(6, 4))\n", + " ax.set_title(title)\n", + " ax.plot(xs, cdf, label=\"Analytical CDF\", color=\"tab:red\")\n", + " sorted_samples = np.sort(samples)\n", + " ecdf = np.arange(1, len(sorted_samples) + 1) / len(sorted_samples)\n", + " ax.plot(sorted_samples, ecdf, label=\"Empirical CDF\", linestyle=\"--\", color=\"tab:blue\")\n", + " ax.set_xlabel(\"x\")\n", + "\n", + " ax.set_ylabel(\"Probability\")\n", + " ax.legend()\n", + " ax.grid(True, alpha=0.3)\n", + " _save_and_show(fig, filename)\n", + "\n", + "\n", + "def _plot_discrete_pmf(\n", + " title: str, pmf: Mapping[int, float], samples: np.ndarray, filename: str\n", + ") -> None:\n", + " fig, ax = plt.subplots(figsize=(6, 4))\n", + " ax.set_title(title)\n", + " xs = np.array(sorted(pmf.keys()))\n", + " ax.bar(xs - 0.15, [pmf[int(x)] for x in xs], width=0.3, label=\"Ideal\", color=\"tab:red\")\n", + " unique, counts = np.unique(samples.astype(int), return_counts=True)\n", + " freqs = counts / counts.sum()\n", + " ax.bar(unique + 0.15, freqs, width=0.3, label=\"Sampled\", color=\"tab:blue\", alpha=0.7)\n", + " ax.set_xlabel(\"value\")\n", + " ax.set_ylabel(\"Probability\")\n", + " ax.set_ylim(0, 1)\n", + " ax.legend()\n", + " ax.grid(True, axis=\"y\", alpha=0.3)\n", + " _save_and_show(fig, filename)" + ] + }, + { + "cell_type": "markdown", + "id": "c0cf853f", + "metadata": {}, + "source": [ + "## Experiment 1 — Exponential distribution\n", + "AUTO mode with an analytical PDF. Demonstrates the basic setup and validates mean/std." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "71be939b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "=== Example 1: Exponential PDF sampled with AUTO ===\n", + "Mean ≈ 0.4996 (expected 0.5000)\n", + "Std ≈ 0.4984 (expected 0.5000)\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def example_exponential_auto() -> None:\n", + " print(\"=== Example 1: Exponential PDF sampled with AUTO ===\")\n", + " lam = 2.0\n", + "\n", + " def pdf_func(x: float, **_: Any) -> float:\n", + " return lam * math.exp(-lam * x) if x >= 0 else 0.0\n", + "\n", + " distr = CustomDistribution(\n", + " name=\"Exp(λ=2)\",\n", + " kind=Kind.CONTINUOUS,\n", + " analytical_computations=[\n", + " AnalyticalComputation(target=CharacteristicName.PDF, func=pdf_func),\n", + " ],\n", + " sampling_strategy=DefaultUnuranSamplingStrategy(),\n", + " )\n", + "\n", + " data = distr.sample(30_000).flatten()\n", + " print(f\"Mean ≈ {data.mean():.4f} (expected {1/lam:.4f})\")\n", + " print(f\"Std ≈ {data.std():.4f} (expected {1/lam:.4f})\")\n", + "\n", + " xs = np.linspace(0, 4 / lam, 400)\n", + " pdf_vals = np.array([pdf_func(x) for x in xs])\n", + " _plot_histogram(\"Exponential distribution (AUTO)\", xs, pdf_vals, data, \"exp_auto_pdf.png\")\n", + "\n", + "\n", + "example_exponential_auto()" + ] + }, + { + "cell_type": "markdown", + "id": "1c57375f", + "metadata": {}, + "source": [ + "## Experiment 2 — Weibull with the NINV method\n", + "We rely on an analytical CDF for a heavy tail. Validate empirical moments and compare the analytical CDF with the ECDF." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "39eb6b91", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Example 2: Weibull CDF sampled with NINV ===\n", + "Mean ≈ 0.7511 (expected 0.7525)\n", + "Std ≈ 0.6333 (expected 0.6298)\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAhgAAAGJCAYAAADIVkprAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAYRdJREFUeJzt3Xd4FOXaBvB7dje76b2TQELvARJKQpdoKIIoCgJqCIqioGCOR4wiRUQUhIMFQZCqIvB5aAoiEHqvQWqoIUBCCul9szPfHzF7WLOBlE0m5f5dV4SdfWfm3ieRffJOWUGSJAlEREREJqSQOwARERHVPWwwiIiIyOTYYBAREZHJscEgIiIik2ODQURERCbHBoOIiIhMjg0GERERmRwbDCIiIjI5NhhERERkcmwwiExkzJgx8PHx0T+OiYmBIAj48ssvTbofQRAwY8YM/eNVq1ZBEATExMRUaHv/zG1sH1Vl3759EAQB+/bt0y/r06cP2rZtW+X7Bv73PVq1alW17M+YgQMHYty4cbLtvyIePHgAKysrbN++Xe4oVIOxwaA6bcOGDRAEAZs2bSrxnJ+fHwRBwN69e0s817BhQwQFBVVHxDpj7dq1WLhwodwxjKqp2Q4fPoydO3diypQp+mXFTZcgCDh9+nSJdcaMGQNra2uDZcaaMh8fHwiCgLfffrvENor38euvvwIAhgwZAktLS2RmZpaadfTo0VCr1Xjw4AGcnJzw2muv4eOPPy7X66X6hQ0G1Wk9evQAABw6dMhgeUZGBi5cuACVSoXDhw8bPHfnzh3cuXNHv25ZLVu2DNHR0ZULXEPk5uZi6tSp5VqnIm/ivXr1Qm5uLnr16lWu9cqrtGyNGjVCbm4uXn755Srdf2nmzZuHfv36oWnTpkafN8Us0rJlyxAXF/fIMaNHj0Zubq7RRhwAcnJysGXLFvTv3x9OTk4AgPHjx+PMmTPYs2dPpTNS3cQGg+o0T09P+Pr6lmgwjh49CkmS8MILL5R4rvhxeRsMMzMzaDSaygWuIczNzaFSqaps+3l5eRBFEQqFAubm5lAo5PmnSBAEmJubQ6lUVvu+ExMTsW3bNgwfPtzo8x06dMDvv/+OM2fOVHgfbdq0gU6nw+eff/7IcUOGDIGNjQ3Wrl1r9PktW7YgOzsbo0eP1i9r1aoV2rZtK+vhJarZ2GBQndejRw+cPXsWubm5+mWHDx9GmzZtMGDAABw7dgyiKBo8JwgCunfvrl/2008/wd/fHxYWFnB0dMSLL76IO3fuGOzH2LkMxf7zn/+gUaNGsLCwQO/evXHhwgWD5/v06YM+ffqUWO9R26yIzZs3o23btjA3N0fbtm1L/Y31n+dgZGZmYvLkyfDx8YFGo4GrqyuefPJJ/Ztfnz59sG3bNty+fVs/vV+cu3g6ft26dZg6dSoaNGgAS0tLZGRkGD0Ho9jp06cRFBQECwsL+Pr6YsmSJQbPl3buyT+3+ahspZ2DsWfPHvTs2RNWVlawt7fHM888g8uXLxuMmTFjBgRBwPXr1zFmzBjY29vDzs4OYWFhyMnJKf2b8Ldt27ahsLAQwcHBRp9/++234eDgUKlZDB8fH7zyyiuPncWwsLDAc889h8jISCQmJpZ4fu3atbCxscGQIUMMlj/55JP47bffwA/lJmPYYFCd16NHD2i1Whw/fly/7PDhwwgKCkJQUBDS09MN3vAPHz6Mli1b6qeCZ8+ejVdeeQXNmjXDggULMHnyZERGRqJXr15IS0t77P7XrFmDr7/+GhMmTEBERAQuXLiAJ554AgkJCSZ/rY+yc+dODBs2DIIgYM6cORg6dCjCwsJw6tSpx647fvx4LF68GMOGDcN3332H9957DxYWFvo33Y8++ggdOnSAs7MzfvzxR/z4448lDknMmjUL27Ztw3vvvYfPPvsMarW61P2lpqZi4MCB8Pf3x9y5c+Hl5YU333wTK1asKPfrLku2h+3evRshISFITEzEjBkzEB4ejiNHjqB79+5GT6QdPnw4MjMzMWfOHAwfPhyrVq3CzJkzH5vryJEjcHJyQqNGjYw+b2tri3fffRe//fZbpWYxPvroIxQWFj52FmP06NEoLCzEhg0bDJanpKTgzz//xLPPPgsLCwuD5/z9/ZGWloaLFy9WOB/VYRJRHXfx4kUJgDRr1ixJkiRJq9VKVlZW0urVqyVJkiQ3Nzdp0aJFkiRJUkZGhqRUKqVx48ZJkiRJMTExklKplGbPnm2wzfPnz0sqlcpgeWhoqNSoUSP941u3bkkAJAsLC+nu3bv65cePH5cASO+++65+We/evaXevXuXyP7PbUqSJAGQpk+frn+8cuVKCYB069atR9ahQ4cOkoeHh5SWlqZftnPnTgnAY/dhZ2cnTZgw4ZHbHzRoUIntSJIk7d27VwIgNW7cWMrJyTH63N69e/XLevfuLQGQ5s+fr1+Wn58vdejQQXJ1dZUKCgoe+bqNbbO0bMXfo5UrV+qXFe/nwYMH+mXnzp2TFAqF9Morr+iXTZ8+XQIgjR071mCbzz77rOTk5FRiX//Uo0cPyd/fv8Ty4vz/93//J6WlpUkODg7SkCFD9M+HhoZKVlZWBuv07t1batOmjcGyRo0aSYMGDZIkSZLCwsIkc3NzKS4ursQ+ihUWFkoeHh5SYGCgwXaWLFkiAZD+/PPPElmPHDkiAZDWr1//2NdL9Q9nMKjOa9WqFZycnPTnVpw7dw7Z2dn6q0SCgoL0J3oePXoUOp1Of/7Fxo0bIYoihg8fjuTkZP2Xu7s7mjVrZvQKlH8aOnQoGjRooH/cpUsXdO3atVov8YuPj0dUVBRCQ0NhZ2enX/7kk0+idevWj13f3t4ex48ff+zJgo8SGhpa4jfg0qhUKrzxxhv6x2q1Gm+88QYSExONXllhKsV1GjNmDBwdHfXL27dvjyeffNLo92z8+PEGj3v27IkHDx4gIyPjkft68OABHBwcHjnGzs4OkydPxtatW3H27NlyvBJDU6dOfewshlKpxIsvvoijR48azNSsXbsWbm5u6NevX4l1ivMnJydXOBvVXWwwqM4TBAFBQUH6cy0OHz4MV1dX/Zn7DzcYxX8WNxjXrl2DJElo1qwZXFxcDL4uX75s9Hj1PzVr1qzEsubNm1f4vhUVcfv27VKztGjR4rHrz507FxcuXIC3tze6dOmCGTNm4ObNm+XK4OvrW+axnp6esLKyMljWvHlzAKjSuhXXyVhNWrVqheTkZGRnZxssb9iwocHj4jfd1NTUx+5PKsO5C5MmTYK9vX2lzsVo3LgxXn75ZSxduhTx8fGljis+ibP4ZM+7d+/i4MGDePHFF42eCFucXxCECmejuosNBtULPXr0QHp6Os6fP68//6JYUFAQbt++jXv37uHQoUPw9PRE48aNAQCiKEIQBOzYsQO7du0q8fX999+bJF9p/0DrdDqTbL+yhg8fjps3b+Kbb76Bp6cn5s2bhzZt2uCPP/4o8zbKOntRVjWlZqVdgfK45sHJyalMTYipZjGKz8X44osvSh3j7++Pli1b4pdffgEA/PLLL5AkyeDqkYcV53d2dq5wLqq72GBQvfDw/TAOHz5scIWIv78/NBoN9u3bh+PHjxs816RJE0iSBF9fXwQHB5f46tat22P3fe3atRLLrl69anB1iIODg9ETRot/o66s4hMJjWUp6707PDw88NZbb2Hz5s24desWnJycMHv2bP3zpvwtNi4ursRMwdWrVwFAX7fimYJ/1s1YzcqarbhOxmpy5coVODs7l5hZqaiWLVvi1q1bZRo7efJk2Nvbl+nk0dI0adIEL730Er7//vvHzmJcuHABf/31F9auXYtmzZqhc+fORscW52/VqlWFc1HdxQaD6oWAgACYm5vj559/xr179wxmMDQaDTp16oRFixYhOzvb4P4Xzz33HJRKJWbOnFniN1JJkvDgwYPH7nvz5s24d++e/vGJEydw/PhxDBgwQL+sSZMmuHLlCpKSkvTLzp07V+ImYBXl4eGBDh06YPXq1UhPT9cv37VrFy5duvTIdXU6ncE6AODq6gpPT0/k5+frl1lZWZUYV1GFhYUGs0MFBQX4/vvv4eLiAn9/fwBFNQOAAwcOGGRdunRpie2VNdvDdXq4cblw4QJ27tyJgQMHVvQllRAYGIjU1NQyHWoqnsXYsmULoqKiKrzPqVOnQqvVYu7cuaWOKZ6tmDZtGqKiokqdvQCKLiW2s7NDmzZtKpyJ6q6qu5MOUQ2iVqvRuXNnHDx4EBqNRv8mVSwoKAjz588HYHiDrSZNmuDTTz9FREQEYmJiMHToUNjY2ODWrVvYtGkTXn/9dbz33nuP3HfTpk3Ro0cPvPnmm8jPz8fChQvh5OSE999/Xz9m7NixWLBgAUJCQvDqq68iMTERS5YsQZs2bR57smBZzZkzB4MGDUKPHj0wduxYpKSk4JtvvkGbNm2QlZVV6nqZmZnw8vLC888/Dz8/P1hbW2P37t04efKkvmZA0UzQ+vXrER4ejs6dO8Pa2hqDBw+uUFZPT0988cUXiImJQfPmzbF+/XpERUVh6dKlMDMzA1B0E6lu3bohIiICKSkpcHR0xLp161BYWFhie+XJNm/ePAwYMACBgYF49dVXkZubi2+++QZ2dnYm/XyWQYMGQaVSYffu3Xj99dcfO37SpEn4z3/+g3PnzlV4FqV4FmP16tWljvH19UVQUBC2bNkCAI9sMHbt2oXBgwfzHAwyTr4LWIiqV0REhARACgoKKvHcxo0bJQCSjY2NVFhYWOL5//73v1KPHj0kKysrycrKSmrZsqU0YcIEKTo6Wj+mtMtU582bJ82fP1/y9vaWNBqN1LNnT+ncuXMl9vHTTz9JjRs3ltRqtdShQwfpzz//NOllqsWvo1WrVpJGo5Fat24tbdy48bH7yM/Pl/79739Lfn5+ko2NjWRlZSX5+flJ3333ncE6WVlZ0qhRoyR7e3uDS1+NXRJZrLTLVNu0aSOdOnVKCgwMlMzNzaVGjRpJ3377bYn1b9y4IQUHB0sajUZyc3OTPvzwQ2nXrl0ltllaNmOXqUqSJO3evVvq3r27ZGFhIdna2kqDBw+WLl26ZDCm+DLVpKQkg+Xl+X4MGTJE6tevn9GaGKtX8T7Le5nqw65duyYplcpS9yFJkrRo0SIJgNSlS5dSs1++fFkCIO3evbvUMVS/CZLEW7AREcnh4MGD6NOnD65cuWL0Cp+abPLkyThw4ABOnz7NGQwyig0GEZGMBgwYAC8vLyxbtkzuKGX24MEDNGrUCBs2bDDpeSlUt7DBICIiIpPjVSRERERkcmwwiIiIyOTYYBAREZHJscEgIiIik6t3N9oSRRFxcXGwsbHhpVVERETlIEkSMjMz4enpCYXi0XMU9a7BiIuLg7e3t9wxiIiIaq07d+7Ay8vrkWPqXYNhY2MDoKg4tra2JtmmKIpISkqCi4vLYzu6+oR1MY51MY51KYk1MY51Ma466pKRkQFvb2/9e+mj1LsGo/iwiK2trUkbjLy8PNja2vKH/SGsi3Gsi3GsS0msiXGsi3HVWZeynGLA7wwRERGZHBsMIiIiMjk2GERERGRybDCIiIjI5NhgEBERkcmxwSAiIiKTY4NBREREJidrg3HgwAEMHjwYnp6eEAQBmzdvfuw6+/btQ6dOnaDRaNC0aVOsWrWqynMSERFR+cjaYGRnZ8PPzw+LFi0q0/hbt25h0KBB6Nu3L6KiojB58mS89tpr+PPPP6s4KREREZWHrHfyHDBgAAYMGFDm8UuWLIGvry/mz58PAGjVqhUOHTqE//znPwgJCamqmERE1UaSJEAUAUmCVFgIbV4+crNyIAAQdSJ0oghJlCDqRIiSCHtzFfD3Og+yC5BboIMkipAk6e8/AUkUAUhoaK0q3gkSsgqQlV8IUZT+Hmv4ZzM7FZQKAZAk3MsuRGquDtLf+Yr+LPqPBKC1vRnUyqI7O8ZmaZGQozMYJ0lS8W7R0ckM5sqiGLezCnEnS9Rn+vsv+lp0cFDB2qwoQ2y2Drez/66LJCE7KwtWVtYovp9kOwcV7MyK/n4vW4cbWeJD2wSk4u1KQFs7BRzVAiAB8bkirhVnMKK1rQLOmqK9JORJuJxRPFYqMbaljQLu5kVjk/MlnE//53b/t05zawUaWBSNfVAgISqt9AxNrQQ0tCwam6aVcDq15L6LX6MrcuE68hkAQHquFiqFACuNPG/1tepW4UePHkVwcLDBspCQEEyePLnUdfLz85Gfn69/nJGRAaDolqqiWPo3tDzEv/9nNtX26grWxTjWxThT1UUqLISYlwcpL0//Z9Hf8yHl5+F+ZgFy87XILyhEfkEh8rRFf+ZrddCIheisygK0WkhaLTZm2+BBoQIFOgn5IvRfBZIAe10e3sk8B+h0kHQ6zLUPQKzKFiIAnSRABwE6oehPW20u/nN5PSCKkHQ6TG05DJdsG0AUBEgQIEKAJBT9aVmYh/V/zNC/ng+DXsdZ1+ZGX6tKLMRvWz/QP/6k6xgc9Whbam22bpkCM0kHAPjCfxT2eXcqdeyGbR/DRpsLAPiqw/PY4dOt1LE/7vgEznlF/7YuaTcEW5r0KnXssl2fwys7GQCwulV/rG8RXOrYb/YuQNP0OADAuuZPYHXrgQ89qwCQo3/05YFv0SYlBgCwuXEPfN9+aKnb/fTwUvgnXQUA/NGoK77u+EKpY6ceX4Xu8RcAAHu9OmJuwOhSx/771M944u5ZAMBR9zb4pFtYqWPfPvt/GHj7OADgjEszfNT9jVLHvn5+C569cRAAcMmxEf7V6+1Sx75y7QC6jRgMALibkg0LtRI+Tlalji+v8vz/WasajPv378PNzc1gmZubGzIyMpCbmwsLC4sS68yZMwczZ84ssTwpKQl5eXkmySWKItLT0yFJEu+L/xDWxTjW5X8knQ5Sdjak7ByI2VnISkxEgSBAyM3FlZQC5OQUICdPi7z8AuTk65BboEOuVoRTfgaefHAZUn4ekF+AT30HIF1lgTxBhTylGfJUauQp1ShQmqFp2j3MO/Sdfp+jQz5GioUdAAGA2d9fRXzT4/Dd3m/1j3/q9z7u2bgWPRAAKP/+AuCenYzXjhzRj73SpyduWrsafZ0OyEBhXJz+cXYLBbLMSv57BQCFotLgsSAZ/20VAEQYfh6EStTBTKeFAECABEhFfxaNkiBZWUGQdIBCAUuFCBtt0cxI8T706wFQurhAoSv65czGTIBzfsbfz5fcttLNDQqtNQDAUaOAV26KwbaKxwkSoHZ3hUJrCQBwslShSU6S8RcnAOYeblA4aiAIApxszNE0N/F/T0oS8NDnYVg1cIfSyQwQAEdbazTLLWW7AGy83KB0Lvp/z8HaAS1yk4BSPlrDztMVSofWAAB7S+eisQ9lfJi9hwuUdm0AALbmbmilz4sS6zi6O0JpUzTWRuOM1sbG/s3JxR5Ki6LG0Vptjza5CaWOdbY3R2JiIhQKBXIy86BTKpCoyy51fHllZmaWeawgSY/46a1GgiBg06ZNGDp0aKljmjdvjrCwMEREROiXbd++HYMGDUJOTo7RBsPYDIa3tzdSU1P5aapVjHUxrq7WRRJF6NLSoEtOhjYpCWkP0lGQlg7brFTo0tKhTUvDr3mOSCsQkV4IpItKZAhqZKotkam2RKuUGLx/+hf99oY+/RnyVWqj+2qbfNOgaRjZfzrSzI1/umPTzHgsvvgzBHNzKDQahDYZjhSVBdSSCDWKvjSCCDUkeCvyMd3sFgQzMwhmZvi+wANpghk0SgEapQLmKgFqlQIalQL2ZgIGuQkQlEpAocSxDAHZogIqlQIqhQJKpRIqlQIKpRLmZgr4uVhAUCkBhQJ3cyVoJQFKlRJKlRIKZdFX0d8VcLM1BwQBoiQh9n4yHJ2cip5TKKBUKSAICigVAhQKBRQqJSAIZfrwqbqirv4/VFnV9WmqDg4OSE9Pf+x7aK2awXB3d0dCgmHnlpCQAFtbW6PNBQBoNBpoNJoSyxUKhUm/AYIgmHybdQHrYlxtq4suIwPauDjk34tDdnwCzFKSUJiUjLykZKzWeSJJK+AB1EhVWyPF3Aap5jYoVKgQGH8X046vAlB09Hnx4DnQWpsZ3YdzYTYUDRrAzM4OSmtreAu50EpaWCgAS5UAS5UACzMlrNRKNG7cBF4vLYbC3BwKc3NMiy8EzMxgaamBlaU5rG0sYWllDgszFSw1Sjhbv6bfz8FyvO7p5RjbvxxjjR/wKEkURdgUFMDBxaHW/KxUl9r2/1B1qeq6lGe7tarBCAwMxPbt2w2W7dq1C4GBgTIlIqobxNxcFMTGouBWDHLv3sPhO1m4m5aLhCwtErVAgpkNEi0dkGxuh64JMZh6Yg2Aoqbh58FzoFUabxq0rh6wHz4cSnt7KO3tMThVAZVGCQcbCzjYWcLR0QaOjrZwsLGAs1VvaAqfhaurKxQKBXaXI/9zHSpdAiIyMVkbjKysLFy/fl3/+NatW4iKioKjoyMaNmyIiIgI3Lt3D2vWFP1jNn78eHz77bd4//33MXbsWOzZswcbNmzAtm3b5HoJRLWGJEkojItD1tVriLl2B1fupOBWSi7u5EhwTonDqOiit3QRAt4fPAeFFirAyMRgiltD2I98ESpnZ6hcXPByigXMbazg5mIHd3dHuDlYwdVGA0crNczNDM8nWPCIfKIoIjExy4SvmIjkJGuDcerUKfTt21f/ODw8HAAQGhqKVatWIT4+HrGxsfrnfX19sW3bNrz77rv46quv4OXlhR9++IGXqBL9gy4rG/nXriI/+iryr0YjN/oqZpq1xQ0rV9yzcoaocATgCNgAsAGaqR3w0v2TUPs0gtrLG0EWuRDMzdHAwRLuLnbw8nKBl5s9PO3N4WpjDrXqf9Ok02R7lURUk8naYPTp0wePOsfU2F06+/Tpg7Nnz1ZhKqLaRSwowP1zF3HhdDQu3UzA1ZR83FDYwLIwD58dWaofd71fP9z9+4oIc0kHHzMtGtup0cjdFq2a9kOLwPH6sWuq/VUQUV1Tq87BICKgMDUVOSdPIufkKcy8Z4mTZq5ItrADYAMobADnonGWhfmw6NkTli2aQ9OiBSI0DWDVwB0tvBzgbmter646IKLqxwaDqIZLuJuAyD1ncehqEu5k5GPuzvn65x50G4tkdzsAgIcuG82tBbRs4IA2bRqhTVMPNHJ+Vt9IDJIlPRHVV2wwiGqY/Hwtju07gz0nr+FIsohr5k5/P+MIWAL3LR3RqIETLDt3xjvNW0PVuAlat2sMWwvj94wgIpIDGwyiGkDMzkbWgQPI3LMXc+MtsMWrCwAHwLzo+aa5yehmq0PP1p7oEr4FVq5Fx0Hc5YtMRPRIbDCIZJKRko7I3w7jj4vxGH5kA7xT7gIA/NzbYL9ra3RVZqJnMxf0eyoAHj4NZE5LRFQ+bDCIqlFmZg62bDyALefu4azKGYUKFaDyhIVXACbZKGD71FN4oVcvhPp1gFJt/OZVRES1ARsMoiomSRLunz2PWRvPITLfBvlKM0BddHDDIz8dTziKGPrOCDTp/hmv7CCiOoMNBlEV0WZmIev335D6yzpkX7uOY099hHwLM3jmpuIpmxy8MDgQrbu0ZVNBRHUSGwwiEzt94jJW/3YSZ9NELN49D0pJhJlGg/dUt9EwKBDd+j+J5JQUuLq6srkgojqLDQaRCWTna/F/Gw9h7ek4XFXZA3ACrICLbYLw5JBesBv6DFr+/dHGoijKmpWIqDqwwSCqhFtJmVi6/hC2xOYhR6EGVPZQ6QrRt/A+XurTAj1nL4FCqXz8hoiI6hg2GEQVIIkiMrb/gfOrN+KXJs8BCjU8s5PxnH0+XgoLhnuLZ+SOSEQkKzYYROWQkJ6Lw9sPocO6RciPjkYzAKMUTujWsSn6v/cc1M7OckckIqoR2GAQlUGeVodv1h7EDxfTAVHEipg4ONvYwGlsGGa99BKUNjZyRyQiqlHYYBA9RuThS5i+5QLuKqwAhQotM+5AM+olNH3jFSjt7eWOR0RUI7HBICrFvfgH+HjJLuzJtwEUVnDMy8C7NkkYMXME1G5ucscjIqrR2GAQGZHw527035mGTDMbKEQdhuXewJTX+8O5XSu5oxER1QpsMIgeok1MRMKns5G5cycGteqP856tMDPYB/5DJ/OmWERE5cAGgwhAcmYe3vv2Twza8xPa3rkAKJV4u0dDuL35MlSWFnLHIyKqddhgUL237XA0Pt5yASkKc6R7B2GBHeDx6SyYt2wpdzQiolqLDQbVW3laHaYt2YkN90RAYY5GmQmY0tkZPq+vg8C7bxIRVQobDKqXkpNSETr/T1xE0f0rXnxwDh/+63nYtmwuczIiorqBDQbVO/fOR+OFFacRp7GDdUEOZrs8wJBP3oOgVssdjYiozlDIHYCoOmX88QfSQ0fDIy0ebnnp+HlgAzwTMZ7NBRGRiXEGg+qFtPRsPPj2GxT8uBoCgKm4Cq/3RsPFy13uaEREdRIbDKrzbtyMx5hF+9AyLg/hAJxefx0u77wNQcUffyKiqsJ/YalOO3LkIt7872Wkm9ki37UZ7L76Fq4h/eSORURU57HBoDrr/349gA+Pp0JrZoHm2fex/LUgePrxVt9ERNWBDQbVOZIk4ctvt2LRPRWgVKFndiwWfTgMth6uckcjIqo32GBQnRPx+f9hXboVAGCkLhafzA2DmYW5zKmIiOoXXqZKdYYkSUicPx/tt/8EtU6LKdb38dnc8WwuiIhkwBkMqhMknQ73Z36CtA0b0BHA1iYpaDn+VbljERHVW5zBoFovPSMHYyNWI+qPA4AgwP2TmWg5fqzcsYiI6jXOYFCtlpKRg+GfbsV1hRuud3kZW5/2gMOAAXLHIiKq99hgUK2Vm1eAMbM347rCDvb5mZj3ZCM4DOA9LoiIagI2GFQr5RVoETpjA/4SHGClzcXK3o7oOJTNBRFRTcFzMKjW0RXqMGn6WpyAAzSFBfi2sxU6Dn1K7lhERPQQNhhU63w/ZxX+lJyhFHVY6KdG3xd5zgURUU3DBoNqlZQ1P6Lr+m/QJvkmPm4sYsDLg+SORERERvAcDKo1Mv74Awlz5sBSkrA8QAP38UPljkRERKVgg0G1wtr/O4AbP23DMEmC46hRcHtjnNyRiIjoEdhgUI23eecZTD2VDrHVADRu7IFRH30AQRDkjkVERI/ABoNqtOPnb+Pfu+9AVKgwMPsGRsz9FwSlUu5YRET0GGwwqMa6EZ+GcT+ehlahQffU6/jPZy9DxQ8uIyKqFXgVCdVIKdkFeOnrPchQaNAi/S4WTe4PjZOj3LGIiKiMZG8wFi1aBB8fH5ibm6Nr1644ceLEI8cvXLgQLVq0gIWFBby9vfHuu+8iLy+vmtJSdfnw2z8QL2ngmZWE74c0hX2rFnJHIiKicpC1wVi/fj3Cw8Mxffp0nDlzBn5+fggJCUFiYqLR8WvXrsUHH3yA6dOn4/Lly1i+fDnWr1+PDz/8sJqTU1XKOX0afnt+hVLU4fNmOvjw80WIiGodWRuMBQsWYNy4cQgLC0Pr1q2xZMkSWFpaYsWKFUbHHzlyBN27d8eoUaPg4+ODp556CiNHjnzsrAfVHoXJybg7eTL63D6FHwuPo+c7/Nh1IqLaSLaTPAsKCnD69GlERETolykUCgQHB+Po0aNG1wkKCsJPP/2EEydOoEuXLrh58ya2b9+Ol19+udT95OfnIz8/X/84IyMDACCKIkRRNMlrEUURkiSZbHt1RXnrcjs5C6kfToNFUjLUTZqgyycRkCQJkiRVcdLqxZ8X41iXklgT41gX46qjLuXZtmwNRnJyMnQ6Hdzc3AyWu7m54cqVK0bXGTVqFJKTk9GjRw9IkoTCwkKMHz/+kYdI5syZg5kzZ5ZYnpSUZLJzN0RRRHp6OiRJgkIh+2ktNUZ56lKok/Dm4sNIs+iG2Q7RaPnxVCRnZQFZWdWUtvrw58U41qUk1sQ41sW46qhLZmZmmcfWqstU9+3bh88++wzfffcdunbtiuvXr2PSpEmYNWsWPv74Y6PrREREIDw8XP84IyMD3t7ecHFxga2trUlyiaIIQRDg4uLCH/aHlKcuX6zehyuFFrA2k+D59lvw6NKlmlJWP/68GMe6lMSaGMe6GFcddTE3L/utAmRrMJydnaFUKpGQkGCwPCEhAe7u7kbX+fjjj/Hyyy/jtddeAwC0a9cO2dnZeP311/HRRx8ZLahGo4FGoymxXKFQmPQbIAiCybdZF5SlLkfP3cKyK9mAoMAH6ttoNeq9akwoD/68GMe6lMSaGMe6GFfVdSnPdmX7zqjVavj7+yMyMlK/TBRFREZGIjAw0Og6OTk5JV6c8u+7Ota14/T1RXpOASb/dAqioEBIyhWMmvaW3JGIiMgEZD1EEh4ejtDQUAQEBKBLly5YuHAhsrOzERYWBgB45ZVX0KBBA8yZMwcAMHjwYCxYsAAdO3bUHyL5+OOPMXjwYH2jQbWHJEn491fbkKC0hEf2A3w26WkoLC3ljkVERCYga4MxYsQIJCUlYdq0abh//z46dOiAHTt26E/8jI2NNZixmDp1KgRBwNSpU3Hv3j24uLhg8ODBmD17tlwvgSrhv7vOYWe6GgpRh8/bmsGpDW+mRURUVwhSPTu2kJGRATs7O6Snp5v0JM/ExES4urryeOBDHlUXqbAQZ19+DfPUrdDGBvjo639DqCe148+LcaxLSayJcayLcdVRl/K8h9aqq0io7niwfAUszh7HdOuL8N26pd40F0RE9QUbDKp2t0+dR8633wIA3Kd+BI2np8yJiIjI1NhgULWKTUhH//XX0dlvBKY6JMHumWfkjkRERFWADQZVG0mS8O53u5GrNEeyjRN8ZoyHIAhyxyIioirAA99UbX6L/Aun882hKSzAvH7eUDs5yR2JiIiqCBsMqhbJmXmY8ec1AMCIgltoPWygzImIiKgqscGgajF9yU6kKC3QMDMB74U/z0MjRER1HBsMqnK7Tt7AtgdKKCQRs1qrYOvTUO5IRERUxdhgUJWT/u8XOOekYVjqRfR6Y5TccYiIqBrwKhKqUtpTp9Ho91+wSGONZqt+gKDijxwRUX3AGQyqMpJWi5xvvwEANHphKBw7+smciIiIqgsbDKoyby/Yhi1CA8DBAS5vT5Q7DhERVSPOV1OViDx1E9vTNVC1G4KQVk9CaWcndyQiIqpGnMEgk9PqRHzy3zMAgKEPLqDtS8NkTkRERNWNDQaZ3M+/ncRtyQJ2+VkY+6w/BKVS7khERFTN2GCQSaXnFuA/h+8CAEIV9+AY0EHeQEREJAs2GGRS362ORLrSHF5ZSXhj0gtyxyEiIpmwwSCTyc7OxU/XcwEAE71FWHg1kDkRERHJhQ0GmUzBb1sw78A3ePbuCTz/1nC54xARkYzYYJBJiDk5SFq0CL4Z9/FxSDOobKzljkRERDLifTDIJK6t/BliUjLMvLzgMIKzF0RE9R1nMKjSzlyKxcDbbvjG7zk4v/MOBLVa7khERCQzNhhUaXPXHoFOoYTO0Rl2Tw+SOw4REdUAbDCoUg6diMaxQhsoRR0mP9MRgoI/UkRExAaDKkGSJHy+seiW4E/n3ELLkN4yJyIiopqCDQZV2JET0bgAW2gKCxA+qjsEQZA7EhER1RBsMKjCvt18CgDwpDYOjYICZE5DREQ1CRsMqpD4azGIKrCAQtRh4rDOcschIqIahvfBoIr5aRVW79yK6O4D0LLXbLnTEBFRDcMGg8pNGxeHtI0bYaPVYthrz8odh4iIaiAeIqFyO7n0J0haLSy7doVlAM+9ICKikjiDQeUSfSkGo3NboVXPCfhpLM+9ICIi4ziDQeXy1dqDkAQFHCzN4BjYVe44RERUQ7HBoDK7cf0edhTYAQDe7t9G5jRERFSTscGgMpv/80GICiW65NxD1/7d5Y5DREQ1GBsMKpP4hFTszLYEAEzu0ZB37SQiokdig0Fl8s2aPShUKNE26x4Chz0pdxwiIqrh2GDQY+kKtDgRmwEAeKu9PQSlUuZERERU0/EyVXqs7N27sHDXPEQ18UfI9KVyxyEiolqAMxj0SJIk4cHyFVBCQsiAblBaWMgdiYiIagHOYNAjnf7zMMwuR0Njbg6H0aPkjkNERLUEGwwqVZ5Wh9f3JABPRmCRwz2oHBzkjkRERLUED5FQqdb+cQYpCnOoJBHtx4yQOw4REdUiFWow9u7da+ocVMMU6kT8cPQOAGC0Mh5WPg1lTkRERLVJhRqM/v37o0mTJvj0009x584dU2eiGmDL8ZuIkzSwzc/C6GE95Y5DRES1TIUajHv37mHixIn49ddf0bhxY4SEhGDDhg0oKCgo97YWLVoEHx8fmJubo2vXrjhx4sQjx6elpWHChAnw8PCARqNB8+bNsX379oq8DCqFTpTw9Y6LAIAX0i7CqTs/1IyIiMqnQg2Gs7Mz3n33XURFReH48eNo3rw53nrrLXh6euKdd97BuXPnyrSd9evXIzw8HNOnT8eZM2fg5+eHkJAQJCYmGh1fUFCAJ598EjExMfj1118RHR2NZcuWoUGDBhV5GVSKrVF3cbtABZuCbIQFt+ZtwYmIqNwqfZJnp06dEBERgYkTJyIrKwsrVqyAv78/evbsiYsXLz5y3QULFmDcuHEICwtD69atsWTJElhaWmLFihVGx69YsQIpKSnYvHkzunfvDh8fH/Tu3Rt+fn6VfRn0kAunrgAAhsUeg+fQwTKnISKi2qjCl6lqtVps2bIFK1aswK5duxAQEIBvv/0WI0eORFJSEqZOnYoXXngBly5dMrp+QUEBTp8+jYiICP0yhUKB4OBgHD161Og6W7duRWBgICZMmIAtW7bAxcUFo0aNwpQpU6As5fbV+fn5yM/P1z/OyCi65bUoihBFsaIv34AoipAkyWTbk1voyf9Du/MxaDU4GLCwqPDrqmt1MRXWxTjWpSTWxDjWxbjqqEt5tl2hBuPtt9/GL7/8AkmS8PLLL2Pu3Llo27at/nkrKyt8+eWX8PT0LHUbycnJ0Ol0cHNzM1ju5uaGK1euGF3n5s2b2LNnD0aPHo3t27fj+vXreOutt6DVajF9+nSj68yZMwczZ84ssTwpKQl5eXllebmPJYoi0tPTIUkSFIrafeWv7u5dZB88iBaCAOv+/Uo9XFUWdakupsS6GMe6lMSaGMe6GFcddcnMzCzz2Ao1GJcuXcI333yD5557DhqNxugYZ2dnk1/OKooiXF1dsXTpUiiVSvj7++PevXuYN29eqQ1GREQEwsPD9Y8zMjLg7e0NFxcX2NramiyXIAhwcXGp1T/smXla3N+xCgBg1asXPDp2rNT26kpdTI11MY51KYk1MY51Ma466mJubl7msRVqMKZPn46goCCoVIarFxYW4siRI+jVqxdUKhV69+5d6jacnZ2hVCqRkJBgsDwhIQHu7u5G1/Hw8ICZmZnB4ZBWrVrh/v37KCgogFqtLrGORqMx2gQpFAqTfgMEQTD5NqvbL4dvYl5GazzbehBmvDTUJK+lLtSlKrAuxrEuJbEmxrEuxlV1Xcqz3Qol6Nu3L1JSUkosT09PR9++fcu0DbVaDX9/f0RGRuqXiaKIyMhIBAYGGl2ne/fuuH79usExoKtXr8LDw8Noc0Fll1ugww/7r0OnUMJHo4NV9yC5IxERUS1WoQZDkiSjly4+ePAAVlZWZd5OeHg4li1bhtWrV+Py5ct48803kZ2djbCwMADAK6+8YnAS6JtvvomUlBRMmjQJV69exbZt2/DZZ59hwoQJFXkZ9JDNUfeQXKiAa04KhvVrD4G/FRARUSWU6xDJc889B6BoCmbMmDEGhx50Oh3++usvBAWV/TffESNGICkpCdOmTcP9+/fRoUMH7NixQ3/iZ2xsrMF0jLe3N/7880+8++67aN++PRo0aIBJkyZhypQp5XkZZMTGw9cAAINvH4fLzFkypyEiotquXA2GnZ0dgKIZDBsbG1hYWOifU6vV6NatG8aNG1euABMnTsTEiRONPrdv374SywIDA3Hs2LFy7YMe7WpCJk4m5EEhiRjYxIafmkpERJVWrgZj5cqVAAAfHx+899575TocQjXXT4duAAC6xV9Ey7eekTkNERHVBRU60D59+nQ2F3WETpSw7exdAMAz2ddh2aWLzImIiKguKPMMRqdOnRAZGQkHBwd07NjxkZ9PcebMGZOEo6qnVAhYdWMDIh8AfYf15MmdRERkEmVuMJ555hn9SZ1Dhw6tqjxUzfKuXoXq7EmEqFRwfG6+3HGIiKiOKHOD8fCdMku7aybVLpIkIe3/fgUA2PTtC5WLi8yJiIioruB8eD22Yv81hN11wiHPdrAfPlzuOEREVIeUeQbDwcHhkeddPMzYXT6p5vnt8FVctvNCmps379xJREQmVeYGY+HChVUYg6pbTHI2ojIFCJKIQYHNeHInERGZVJkbjNDQ0KrMQdVs/f7LAAD/xKto+vaLMqchIqK6pswNRkZGhv7jzTMyMh451lQfg05VQydK+O+ZOABKDFY9gNqrgdyRiIiojinXORjx8fFwdXWFvb290fMxij8ETafTmTQkmdaBq4lI1Clhm5+NAU8FyB2HiIjqoDI3GHv27IGjoyMAYO/evVUWiKre+j0XAQB948/BaUDEY0YTERGVX5kbjN69exv9O9U+T9w/j9T4fAz1VkNpbS13HCIiqoPK9WFnD0tNTcXy5ctx+XLRyYKtW7dGWFiYfpaDaiaxoAB+f/6Cdunp8P7hB7njEBFRHVWhaxMPHDgAHx8ffP3110hNTUVqaiq+/vpr+Pr64sCBA6bOSCaUtW8fxPR0qNzcYBXYTe44RERUR1VoBmPChAkYMWIEFi9eDKVSCQDQ6XR46623MGHCBJw/f96kIck0HmTlY8n2C+hm5YR2QwZD+Pt7R0REZGoVmsG4fv06/vWvf+mbCwBQKpUIDw/H9evXTRaOTGvb8RtYZt4Cnwe8BLtnnpE7DhER1WEVajA6deqkP/fiYZcvX4afn1+lQ1HV2Hr4KgDgCd19aJo2lTkNERHVZWU+RPLXX3/p//7OO+9g0qRJuH79Orp1KzqOf+zYMSxatAiff/656VNSpSVk5OF0tgoQgMGBzeWOQ0REdVyZG4wOHTpAEARIkqRf9v7775cYN2rUKIwYMcI06chktu6/CEkQ0ColBi2f4feHiIiqVpkbjFu3blVlDqpiv526DUCDJ83SoXJ2ljsOERHVcWVuMBo1alSVOagK3U3NwV/5GgiSiKd7tJQ7DhER1QMVvtEWAFy6dAmxsbEoKCgwWD5kyJBKhSLTOn/6Ciy0eWiWcQ9NBo2ROw4REdUDFWowbt68iWeffRbnz583OC+j+APQ+GFnNYv/+QP45Y/lyH+iP5T8pFsiIqoGFbpMddKkSfD19UViYiIsLS1x8eJFHDhwAAEBAdi3b5+JI1JlSJKEjO3boREL0WpgX7njEBFRPVGhGYyjR49iz549cHZ2hkKhgEKhQI8ePTBnzhy88847OHv2rKlzUgXdPxmFgjt3oLCwgHWfPnLHISKieqJCMxg6nQ42NjYAAGdnZ8TFxQEoOhE0OjradOmo0l7afANvPPFvxD8xBApLS7njEBFRPVGhGYy2bdvi3Llz8PX1RdeuXTF37lyo1WosXboUjRs3NnVGqqDouDTcEC2gsjZD834+cschIqJ6pEINxtSpU5GdnQ0A+OSTT/D000+jZ8+ecHJywvr1600akCpu059nAAABD27Ao+9EmdMQEVF9UqEGIyQkRP/3pk2b4sqVK0hJSYGDg4P+ShKSlyRJ2BadCsAc/d0UUKjVckciIqJ6pFL3wQCAO3fuAAC8vb0rHYZM5+KdVNyBOdQ6LQYO7Cx3HCIiqmcqdJJnYWEhPv74Y9jZ2cHHxwc+Pj6ws7PD1KlTodVqTZ2RKmDjzqIrebqmXIdLUFeZ0xARUX1ToRmMt99+Gxs3bsTcuXMRGBgIoOjS1RkzZuDBgwdYvHixSUNS+UiShO3X0wFo0N9dCUFV6YkqIiKicqnQO8/atWuxbt06DBgwQL+sffv28Pb2xsiRI9lgyEws1GHixS04YN0IT70RLHccIiKqhyrUYGg0Gvj4+JRY7uvrCzVPJpRdftRZ+F87gc62V+AcNFXuOEREVA9V6ByMiRMnYtasWcjPz9cvy8/Px+zZszFxIi+HlFvGzl0AAJu+fSGw4SMiIhmUeQbjueeeM3i8e/dueHl5wc/PDwBw7tw5FBQUoF+/fqZNSOXy151UrL2SiyC7Buj70OXERERE1anMDYadnZ3B42HDhhk85mWqNcN/d5/DugbdkCSY4+nuQXLHISKieqrMDcbKlSurMgeZgCRJ2HUtFYAa/VyVUGg0ckciIqJ6qlLXLyYlJek/3KxFixZwcXExSSiqmMvxGYgT1VDrtHjiiY5yxyEionqsQid5ZmdnY+zYsfDw8ECvXr3Qq1cveHp64tVXX0VOTo6pM1IZbd9/AQDQ6cF1uPbtJXMaIiKqzyrUYISHh2P//v347bffkJaWhrS0NGzZsgX79+/Hv/71L1NnpDLadfE+AKCvnQ4KCwuZ0xARUX1WoUMk//3vf/Hrr7+iT58++mUDBw6EhYUFhg8fzhttyeBOSg6iC82hkEQ81aut3HGIiKieq9AMRk5ODtzc3Eosd3V15SESmVw/fw22+VlokxKDhk/2ljsOERHVcxVqMAIDAzF9+nTk5eXpl+Xm5mLmzJn6zyah6tXuynGs/WMmZiquQmltLXccIiKq5yrUYCxcuBCHDx+Gl5cX+vXrh379+sHb2xtHjhzBV199Ve7tLVq0CD4+PjA3N0fXrl1x4sSJMq23bt06CIKAoUOHlnufdU3mnkgoIcG3bw+5oxAREVXsHIx27drh2rVr+Pnnn3HlyhUAwMiRIzF69GhYlPPkwvXr1yM8PBxLlixB165dsXDhQoSEhCA6Ohqurq6lrhcTE4P33nsPPXv2rMhLqFNS7sQj99xfEABY9+0rdxwiIqLyNxharRYtW7bE77//jnHjxlU6wIIFCzBu3DiEhYUBAJYsWYJt27ZhxYoV+OCDD4yuo9PpMHr0aMycORMHDx5EWlpapXPUZu/9fALnQqbiX+mn0cqt9KaMiIioupS7wTAzMzM496IyCgoKcPr0aUREROiXKRQKBAcH4+jRo6Wu98knn8DV1RWvvvoqDh48+Mh95OfnG3woW0ZGBgBAFEWIoljJVwD9tiRJMtn2yiNfq8ORVCDPwh5evq1lyVAaOetSk7EuxrEuJbEmxrEuxlVHXcqz7QodIpkwYQK++OIL/PDDD1CpKn4z0OTkZOh0uhJXpLi5uekPvfzToUOHsHz5ckRFRZVpH3PmzMHMmTNLLE9KSjJZoySKItLT0yFJEhSKCp3WUmFHriQiT1DBKTcdjTu3RmJiYrXu/1HkrEtNxroYx7qUxJoYx7oYVx11yczMLPPYCnUHJ0+eRGRkJHbu3Il27drBysrK4PmNGzdWZLOPlZmZiZdffhnLli2Ds7NzmdaJiIhAeHi4/nFGRga8vb3h4uICW1tbk+QSRRGCIMDFxaXaf9iPbTgDAAjMug2PbiMgCEK17v9R5KxLTca6GMe6lMSaGMe6GFcddTE3Ny/z2Ao1GPb29iU+TbUinJ2doVQqkZCQYLA8ISEB7u7uJcbfuHEDMTExGDx4sH5Z8XSNSqVCdHQ0mjRpYrCORqOBxsiHfikUCpN+AwRBMPk2H0eSJOy/lwsI5ujbyBZKpbLa9l1WctSlNmBdjGNdSmJNjGNdjKvqupRnu+VqMERRxLx583D16lUUFBTgiSeewIwZM8p95UgxtVoNf39/REZG6i81FUURkZGRmDhxYonxLVu2xPnz5w2WTZ06FZmZmfjqq6/q3UfGX7qbigTBHJrCAvQJDpA7DhERkV65GozZs2djxowZCA4OhoWFBb7++mskJSVhxYoVFQ4QHh6O0NBQBAQEoEuXLli4cCGys7P1V5W88soraNCgAebMmQNzc3O0bWt4G2x7e3sAKLG8Pti25xwAoFPqLTh2GfyY0URERNWnXA3GmjVr8N133+GNN94AAOzevRuDBg3CDz/8UOHpmBEjRiApKQnTpk3D/fv30aFDB+zYsUN/4mdsbCynwErR+c55PHv9Lro2cYZQiZNtiYiITK1c70qxsbEYOHCg/nFwcDAEQUBcXBy8vLwqHGLixIlGD4kAwL59+x657qpVqyq839pMkiR4H9iG12Nj0WBc+e+eSkREVJXKNTVQWFhY4gxSMzMzaLVak4aixyu4cQPa2FgIZmaw6t5d7jhEREQGyjWDIUkSxowZY3BVRl5eHsaPH29wqWpVXaZK/7N+63EoXJqja0sPKK2tHr8CERFRNSpXgxEaGlpi2UsvvWSyMFQ2OlHCl/EWyOj+Olb4ZqKZ3IGIiIj+oVwNxsqVK6sqB5XDqUt3kKHUwFKbi24DeHiEiIhqHl6eUQv9N7LoXiA9sm7DsmH9uvcHERHVDmwwahlJkrA/rugzVEK8LWVOQ0REZBwbjFrm0t00/d07e/ftJHccIiIio9hg1DJ/Hig6PNIx9RacurDBICKimokNRi1z7mo8AKCXvQ6CmZnMaYiIiIzj/aVrmWnn1yP6bira/HuS3FGIiIhKxQajFtEmJKLg0mX4CgIaPNFT7jhERESl4iGSWiRz/34AgHm7dlA5OcmchoiIqHScwagltDoRg04BzQJGY0ZXNhdERFSzscGoJc7cTEK8whLZLs3g1qeN3HGIiIgeiYdIaom9B/++PDUjFpZtWsuchoiI6NHYYNQSh2+kAACCXNUQFPy2ERFRzcZ3qlogI0+Li4UWAIA+XZvLnIaIiOjx2GDUAodOXYcoKNAgKwlN+wbJHYeIiOix2GDUAvuORwMAOospUNrZyZyGiIjo8XgVSS3gce8GmmVboUdTe7mjEBERlQkbjBpOKizEwIPr0T8zEz5v/iJ3HCIiojLhIZIaLvev8xAzM6Gws4N5u3ZyxyEiIioTNhg13Ok9x5Gj0sAqKBCCUil3HCIiojLhIZIaTBQlTExwQdbAT/BzOx285A5ERERURpzBqMHOX72LdKU51Dot2vfrJnccIiKiMmODUYPt3fcXAMAvLwGWnh4ypyEiIio7Nhg12OGYVABAkJtG5iRERETlwwajhsotKESUaAMA6NOthcxpiIiIyocNRg115NBf0CpUcM5LR5veneWOQ0REVC5sMGqo/SeuAQA6C+lQaniIhIiIahdeplpDDbh6ANbxeej4zJNyRyEiIio3Nhg1kJidDfuTBzFYq0XjkKlyxyEiIio3HiKpgbJPnAC0Wph5eUHt4yN3HCIionLjDEYNtGbPFeT6BKJ/18YQBEHuOEREROXGBqOGkSQJq7IdkNRhGNq2NkNbuQMRERFVAA+R1DA3LscgSW0Dla4QPZ7sInccIiKiCmGDUcMcPHAOANBCmwJrR3t5wxAREVUQG4wa5tDNFABAN2cevSIiotqLDUYNoivU4VRh0e3Be/s3ljkNERFRxbHBqEGijl9AutoSloV56NbHX+44REREFcYGowb568QlKCQRHcQ0qC3M5Y5DRERUYTzQX4P0u7gH7Y6dguadd+WOQkREVClsMGoIsaAAOadOwaowD769u8kdh4iIqFJ4iKSGyDlzFlJeHpTOztA0byZ3HCIiokrhDEYNsXj3Fezu9TZG2WejOW8PTkREtVyNmMFYtGgRfHx8YG5ujq5du+LEiROljl22bBl69uwJBwcHODg4IDg4+JHja4uDSYWIdmyE/MYt5I5CRERUabI3GOvXr0d4eDimT5+OM2fOwM/PDyEhIUhMTDQ6ft++fRg5ciT27t2Lo0ePwtvbG0899RTu3btXzclNJy3xAS5qXAAA/fr6yZyGiIio8mRvMBYsWIBx48YhLCwMrVu3xpIlS2BpaYkVK1YYHf/zzz/jrbfeQocOHdCyZUv88MMPEEURkZGR1ZzcdPbvOgmdQokG+Wlo0qKR3HGIiIgqTdZzMAoKCnD69GlERETolykUCgQHB+Po0aNl2kZOTg60Wi0cHR2NPp+fn4/8/Hz944yMDACAKIoQRbES6f9HFEVIklTh7e2/FAfADV2ttCbLVBNUti51FetiHOtSEmtiHOtiXHXUpTzblrXBSE5Ohk6ng5ubm8FyNzc3XLlypUzbmDJlCjw9PREcHGz0+Tlz5mDmzJklliclJSEvL6/8oY0QRRHp6emQJAkKRfknhY5lqwFzIMDHrtRDQ7VRZetSV7EuxrEuJbEmxrEuxlVHXTIzM8s8tlZfRfL5559j3bp12LdvH8zNjd/5MiIiAuHh4frHGRkZ8Pb2houLC2xtbU2SQxRFCIIAFxeXcn9Tb166iThzByhFHfoP6QV7ZweTZKoJKlOXuox1MY51KYk1MY51Ma466lLae60xsjYYzs7OUCqVSEhIMFiekJAAd3f3R6775Zdf4vPPP8fu3bvRvn37UsdpNBpoNJoSyxUKhUm/AYIgVGibGWfPoce9aEgOjnB0dTJZnpqionWp61gX41iXklgT41gX46q6LuXZrqzfGbVaDX9/f4MTNItP2AwMDCx1vblz52LWrFnYsWMHAgICqiNqlXE7exgfnfwRC5tq5Y5CRERkMrIfIgkPD0doaCgCAgLQpUsXLFy4ENnZ2QgLCwMAvPLKK2jQoAHmzJkDAPjiiy8wbdo0rF27Fj4+Prh//z4AwNraGtbW1rK9joqQRBHZR48BAKy6B8mchoiIyHRkbzBGjBiBpKQkTJs2Dffv30eHDh2wY8cO/YmfsbGxBlMyixcvRkFBAZ5//nmD7UyfPh0zZsyozuiVFnvmIm5rVfC2tITFIw7zEBER1TayNxgAMHHiREycONHoc/v27TN4HBMTU/WBqsnPey5hafAUDM2LwUIzM7njEBERmQzPjpHR4YSi+3O083WWOQkREZFpscGQSVpaFi4r7QEAfXu2kzcMERGRibHBkMmhvWcgKpTwyn0A346t5I5DRERkUmwwZHLor1gAQCfzAgj8eHYiIqpj2GDIQJIkHMhQAgD6tHCROQ0REZHpscGQwbWb8Ygzs4WZTosnnuwidxwiIiKTqxGXqdY39tHnMfPociQ3agZ776FyxyEiIjI5Nhgy0B0/ii4Jl+HwVO2+zTkREVFpeIhEBtlHjwIArIJK/7wVIiKi2owzGNXs7Jmr+MW6Dbq6qNC8M8+/IKLK0+l00GrrzwcmiqIIrVaLvLw8fprqQ0xVF7VabZK6ssGoZtsOXMSG5v3woEETDLO2kjsOEdVikiTh/v37SEtLkztKtZIkCaIoIjMzk5f5P8RUdVEoFPD19YVara5UHjYY1exwXC6gUKO7V+365FciqnmKmwtXV1dYWlrWmzdbSZJQWFgIlUpVb15zWZiiLqIoIi4uDvHx8WjYsGGl6ssGoxqlZeUhWrABAPTt3kbmNERUm+l0On1z4eTkJHecasUGwzhT1cXFxQVxcXEoLCyEWSU+iJMHr6rR/v3nIAoKNMxKgk+3DnLHIaJarPicC0tLS5mTUF1TfGhEp9NVajtsMKrRwXO3AQBd1NkQVJw8IqLK42/wZGqm+plig1FNJEnCoZSivwc25e3BiYiobmODUU2SH2QgRwSUog5P9POXOw4RUZ3n4+ODhQsXVmob+/btgyAIJrtSJyYmBoIgICoqyiTbq8nYYFQTi8vn8cv2GVj51wrYN28sdxwiIlkdPXoUSqUSgwYNkjuKXp8+fTB58mSDZUFBQYiPj4ednV21Zrl+/TrCwsLg5eUFjUYDX19fjBw5EqdOndKPEQRB/2VlZYXmzZvj1VdfxenTpw22Vdwk/fNr6tSpVfoa2GBUk+yjR6GAhKb+bXjMlIjqveXLl+Ptt9/GgQMHEBcXJ3ecUqnVari7u1frv9unTp2Cv78/rl69iu+//x6XLl3Cpk2b0LJlS/zrX/8yGLty5UrEx8fj4sWL+Pbbb5GVlYVu3bphzZo1JbYbHR2N+Ph4/dcHH3xQpa+DDUY1yTpyBABgFRgkcxIiqqskSYKYk1PtX5IklStnVlYW1q9fjzfffBODBg3CqlWrDJ4v/o07MjISAQEBsLS0RFBQEKKjo/Vjbty4gaFDh8LNzQ3W1tbo3Lkzdu/eXeo+x44di6efftpgmVarhaurK5YvX44xY8Zg//79+Oqrr/S/4cfExBg9RHL48GH06dMHlpaWcHBwQEhICFJTUwEAO3bsQI8ePWBvbw8nJyc8/fTTuHHjRplrI0kSxowZg2bNmuHgwYMYNGgQmjRpgg4dOmD69OnYsmWLwXh7e3u4u7vDx8cHTz31FNavX4/Ro0dj4sSJ+kzFXF1d4e7urv+ytq7a+zHxUoZqcPP6XbzYcBjaW97A4m5d5Y5DRHWUlJuL6E7Vf45XizOnIZTjctkNGzagZcuWaNGiBV566SVMnjwZERERJWYJPvroI8yfPx8uLi4YP348xo4di8OHDwMoalIGDBiA2bNnQ6PRYM2aNRg8eDCio6PRsGHDEvt87bXX0KtXL8THx8PDwwMA8PvvvyMnJwcjRozA888/j6tXr6Jt27b45JNPABTdDyImJsZgO1FRUejXrx/Gjh2Lr776CiqVCnv37tVf0pmdnY3w8HC0b98eWVlZmDZtGp599llERUWV6fbbUVFRuHjxItauXWt0vL29/WO3MXnyZKxZswa7du3C8OHDHzu+qrDBqAb790YhydIBic5eUDs7yx2HiEhWy5cvx0svvQQA6N+/P9LT07F//3706dPHYNzs2bPRu3dvAMAHH3yAQYMGIS8vDxqNBn5+fvD399c3JbNmzcKmTZuwdetWTJw4scQ+g4KC0KJFC/z44494//33ARQdXnjhhRf0v8mr1WpYWlrC3d291Oxz585FQEAAvvvuO/2yNm3+d+PEYcOGGYxfsWIFXFxccOnSJbRt2/axtbl27RoAoGXLlo8dW5ridf/ZHHl5eRk8vn37dpXepI0NRjU4diMJgCv8nZRyRyGiOkywsECLM6cfP7AK9ltW0dHROHHiBDZt2gQAUKlUGDFiBJYvX16iwWjfvr3+78WzDomJifD29kZWVhY+/fRTbN++HfHx8SgsLERubi5iY2NL3fdrr72GpUuX4v3330dCQgL++OMP7NmzpxyvtGiG4YUXXij1+WvXrmHatGk4fvw4kpOTIYoiACA2NrZMDUZ5Dzc9ahv/nBE6ePAgbGxs9I8dHBwqva9HYYNRxQp1Io5prQEzoJefj9xxiKgOEwShXIcq5LB8+XIUFhbC09NTv0ySJGg0Gnz77bcGV2s8fJvq4jfL4jfsKVOmIDIyEl9++SWaNm0KCwsLPP/88ygoKCh136+88go++OADHD16FEeOHIGvry969uxZrvwWj2mmBg8ejEaNGmHZsmXw9PSEKIpo27btI3M9rHnz5gCAK1euoGPHjuXKVuzy5csAAF9fX4Plvr6+ZTrEYio8ybOKnT15GelmlrDS5qL7k/x4diKqvwoLC7FmzRrMnz8fUVFR+q9z587B09MTv/zyS5m3deTIEYSGhuLZZ59Fu3bt4O7uXuKQwD85OTlh6NChWLlyJVatWoWwsDCD59Vq9WNvj92+fXtERkYafe7BgweIjo7G1KlT0a9fP7Rq1arEiZaP06FDB7Ru3Rrz58/XN1MPK8v9OL766ivY2toiODi4XPs2Nc5gVLG9Ry4DsEAnXQo0VjX7Nwsioqr0+++/IzU1Fa+++mqJ+0oMGzYMy5cvx/jx48u0raZNm2LTpk0YMmQIBEHAxx9/bPQN+Z9ee+01PP3009DpdAgNDTV4zsfHB8ePH0dMTAysra3h6OhYYv2IiAi0a9cOb731FsaPHw+1Wo29e/fihRdegKOjI5ycnLB06VJ4eHggNja23JeCCoKAlStXIjg4GD179sRHH32Eli1bIisrC7/99ht27tyJ/fv368enpaXh/v37yM/PR3R0NJYsWYKtW7dizZo11TpbYQxnMKrYkbgcAEB3LyuZkxARyWv58uUIDg42etOqYcOG4dSpU/jrr7/KtK158+bBwcEBQUFBGDx4MEJCQtCpU6fHrhccHAwPDw+EhIQYHKYBgPfeew9KpRKtW7eGi4uL0fM5mjdvjp07d+LcuXPo0qULAgMDsWXLFqhUKigUCqxbtw6nT59G27Zt8e6772LevHllej0P69KlC06dOoWmTZti3LhxaNWqFYYMGYKLFy+WuDNpWFgYPDw80LJlS7z11luwtrbG8ePHMWrUqHLv19QEyRRnlNQiGRkZsLOzQ3p6OmxtbU2yTVEUkZiYCFdXV4PLiiStFh+O/gjHHZviu9AuaBnYwST7qy1Kq0t9x7oYx7qU9Kia5OXl4datW/D19YW5ublMCeVRmY8lz8rKQoMGDbBy5Uo899xzVZRQHqb6uPZH/WyV5z2Uh0iqUO7583jlr98QZm+PZt+9JnccIqJ6SxRFJCcnY/78+bC3t8eQIUPkjlTnscGoQlkHDwIArIICIfA3MiIi2cTGxsLX1xdeXl5YtWoVVCq+/VU1VrgKHTkRjUZKNTx69ZI7ChFRvebj42OSe0xQ2bHBqCKxN+5isvcgqD2fwumugXLHISIiqlact68iByOL7qbXWJsGGw83mdMQERFVLzYYVeTgtUQAQICT2WNGEhER1T1sMKpAYYEWB8WiG7SEdG0qcxoiIqLqxwajCpzcewrZZuawKsxDYN/q/+hkIiIiubHBqAL7TlwFAHRWZkJlxvNoiYio/mGDUQWOJmkBAEFNnGROQkRExcaMGYOhQ4c+dpwgCNi8ebPJ9uvj41PiFt/1ARsME9MmJOCdw6vx+vkt6B/SWe44REQ1ypgxY4o+Vv4fX/3796/yfX/11VdYtWrVY8fFx8djwIABVZ7nYRkZGfoPNjM3N4e7uzuCg4OxceNG/f07+vTpo6+XRqNBgwYNMHjwYGzcuLHE9ozVuEePHtX6mjh/b2JZBw6gQXYyRlqno2Ejd7njEBHVOP3798fKlSsNlmk0mirfr7EPWXtYQUEB1Go13N2r99/utLQ09OjRA+np6fj000/RuXNnqFQq7N+/H++//z6eeOIJ/Sejjhs3Dp988gkKCwtx9+5dbNq0CS+++CLGjBmD77//3mC7K1euNGjc1Gp1db4szmCYWubu3QAAmz595A1CRPVWTkFhqV95Wp1Jx1aERqOBu7u7wZeDg4P+eUEQ8P333+Ppp5+GpaUlWrVqhaNHj+L69evo06cPrK2t0atXL9y4cUO/zowZM9ChQwd8//338Pb2hqWlJYYPH4709HT9mH8eIunTpw8mTpyIyZMnw9nZGSEhIfr9P3yI5O7duxg5ciQcHR1hZWWFgIAAHD9+HABw48YNPPPMM3Bzc4O1tTU6d+6M3X+/D5TVhx9+iJiYGBw/fhyhoaFo3bo1mjdvjnHjxiEqKgrW1tb6sZaWlnB3d4eXlxe6deuGL774At9//z2WLVtWYr/29vYGNTb28fNViTMYJpSflo6IPF8EeWUh9MkQueMQUT3VetqfpT7Xt4ULVoZ10T/2n7Ubuf9oJIp19XXE+jf+dyfiHl/sRUp2gcGYmM8HVTKtcbNmzcKCBQuwYMECTJkyBaNGjULjxo0REREBb29vjB07Fm+//Tb++OMP/TrXr1/Hhg0b8NtvvyEjIwOvvvoq3nrrLfz888+l7mf16tV48803cfjwYaPPZ2VloXfv3mjQoAG2bt0Kd3d3nDlzBqIo6p8fOHAgZs+eDY1GgzVr1mDw4MGIjo5Gw4YNH/s6RVHEunXrMHr06BIfHw/AoLkoTWhoKP71r39h48aN6FODfrllg2FCh37bj8Me7XDepRne8PWROw4RUY30+++/l3jj/PDDD/Hhhx/qH4eFhWH48OEAgClTpiAwMBAff/wxQkJCIEkSJk6ciHHjxhlsIy8vD2vWrEGDBg0AAN988w0GDRqE+fPnl3rYo1mzZpg7d26pWdeuXYukpCScPHlSPwPQtOn/7m/k5+cHPz8//eNZs2Zh06ZN2Lp1KyZOnPjYWiQnJyM1NRUtW7Z87NjSKBQKNG/eHLdv3zZYPnLkSCiVSv3jn376qUwnuZoKGwwT2hZ1D9D4oJetFioljz4RkTwufVL6DKpCEAwen/44uMxjD03pW7lgf+vbty8WL15ssOyf0/ft27fX/93NrejjFtq1a2ewLC8vDxkZGbC1tQUANGzYUN9cAEBgYCBEUUR0dHSpDYa//6PvVRQVFYWOHTuWenghKysLM2bMwLZt2xAfH4/CwkLk5uYiNjb2kdstZqoPYJMkCcI/vl//+c9/EBz8v++vh4eHSfZVVmwwTKQgPRN7BBcAwNCeFe9EiYgqy1Jd9n/aq2rso1hZWRnMAhhjZva/j1kofuM0tqz4UEVlsjyKhYXFI59/7733sGvXLnz55Zdo2rQpLCws8Pzzz6OgoOCR6xVzcXGBvb09rly5UubM/6TT6XDt2jUEBAQYLHd3d39snatSjfg1e9GiRfDx8YG5uTm6du2KEydOPHL8//3f/+kv5WnXrh22b99eTUlLd2j3aWSoreCozUafXu0fvwIREZlUbGws4uLi9I+PHTsGhUKBFi1aVHib7du3R1RUFFJSUow+f/jwYYwZMwbPPvss2rVrB3d3d8TExJR5+wqFAi+++CJ+/vlng+zFsrKyUFj46JNpV69ejdTUVAwbNqzM+60OsjcY69evR3h4OKZPn44zZ87Az88PISEhSExMNDr+yJEjGDlyJF599VWcPXsWQ4cOxdChQ3HhwoVqTm5oU3QGAKC/PQ+PEBE9Sn5+Pu7fv2/wlZycXOntmpubIzQ0FOfOncPBgwfxzjvvYPjw4ZW67HTkyJFwd3fH0KFDcfjwYdy8eRP//e9/cfToUQBF53Bs3LgRUVFROHfuHEaNGlXuWZXZs2fD29sbXbt2xZo1a3Dp0iVcu3YNK1asQMeOHZGVlaUfm5OTg/v37+Pu3bs4duwYpkyZgvHjx+PNN99E376mOYRlKrK/Ey5YsADjxo1DWFgYWrdujSVLlsDS0hIrVqwwOv6rr75C//798e9//xutWrXCrFmz0KlTJ3z77bfVnPx/zh05h5OWDaCQRIx9Pki2HEREtcGOHTvg4eFh8GWKm0A1bdoUzz33HAYOHIinnnoK7du3x3fffVepbarVauzcuROurq4YOHAg2rVrh88//1x/8uSCBQvg4OCAoKAgDB48GCEhIejUqVO59uHo6Ihjx47hpZdewqeffoqOHTuiZ8+e+OWXXzBv3jyD+3csW7YMHh4eaNKkCZ577jlcunQJ69evr/TrrAqCZKozTCqgoKAAlpaW+PXXXw3ObA0NDUVaWhq2bNlSYp2GDRsiPDwckydP1i+bPn06Nm/ejHPnzpUYn5+fj/z8fP3jjIwMeHt7IzU1VX9iUGXt/WA2FqbYwdnBGsu/GGOSbdYFoigiKSkJLi4uUChk72VrDNbFONalpEfVJC8vDzExMfD19YW5ublMCeWj1WoNzsmYMWMGtmzZgrNnz8qYSn7/rEtF5OXl4datW/pTFx6WkZEBBwcHpKenP/Y9VNaTPJOTk6HT6fRnCBdzc3Mr9YSX+/fvGx1///59o+PnzJmDmTNnllielJSEvLy8CiY31NjLHnMPr4dq+sxSD+3UR6IoIj09HZIk8Q3jIayLcaxLSY+qiVarhSiKKCwsfOwx+rpGkiTodEX37nj4ZE9JkupdLR5mrC4VUVhYCFEU8eDBgxLNSmZmZpm3U+evIomIiEB4eLj+cfEMhouLi8lmMMS33kTic8/C1d2d/zA+RBRFCILA30j/gXUxjnUp6VE1ycvLQ2ZmJlQqFVSqOv9PuVEPv/kpFAoIglBva/Gwys5gqFQqKBQKODk5lZjBKM9smazfCWdnZyiVSiQkJBgsT0hIKPWkHHd393KN12g0Ru9xr1AoTPqPmOLvbwj/YTQkCALrYgTrYhzrUlJpNSl+Qy3+qk8evudD8Z8zZ840OltdnxirS0UU/0yV9nNXVrL+X6xWq+Hv74/IyEj9MlEUERkZicDAQKPrBAYGGowHgF27dpU6noiIiKqf7HNJ4eHhCA0NRUBAALp06YKFCxciOzsbYWFhAIBXXnkFDRo0wJw5cwAAkyZNQu/evTF//nwMGjQI69atw6lTp7B06VI5XwYRkSxkPE+f6ihT/UzJ3mCMGDECSUlJmDZtGu7fv48OHTpgx44d+hM5Y2NjDaZkgoKCsHbtWkydOhUffvghmjVrhs2bN6Nt27ZyvQQiompXfJw9JyfnsXebJCqP4ruQPvw5JhUh62WqcsjIyICdnV2ZLrEpK1EUkZiYCFdXVx47fgjrYhzrYhzrUtLjahIfH4+0tDS4urrC0tKy3pyLUXy1iEqlqjevuSxMURdRFBEXFwczMzM0bNiwxHbK8x4q+wwGERFVTPHJ7fXt8nhJkiCKov5EVypiqrooFAqjzUV5scEgIqqlBEGAh4cHXF1dodVq5Y5TbYrv0eDk5MTZroeYqi5qtdokdWWDQURUyymVykofL69NRFGEmZkZzM3N2WA8pKbVRf4EREREVOewwSAiIiKTY4NBREREJlfvzsEovio3IyPDZNsURRGZmZk15rhXTcG6GMe6GMe6lMSaGMe6GFcddSl+7yzLHS7qXYNR/Elw3t7eMichIiKqnTIzM2FnZ/fIMfXuRlvFNxGxsbEx2fXTxZ/QeufOHZPdvKsuYF2MY12MY11KYk2MY12Mq466SJKEzMxMeHp6PnaWpN7NYCgUCnh5eVXJtm1tbfnDbgTrYhzrYhzrUhJrYhzrYlxV1+VxMxfFePCKiIiITI4NBhEREZkcGwwT0Gg0mD59OjQajdxRahTWxTjWxTjWpSTWxDjWxbiaVpd6d5InERERVT3OYBAREZHJscEgIiIik2ODQURERCbHBoOIiIhMjg2GCSxatAg+Pj4wNzdH165dceLECbkjyerAgQMYPHgwPD09IQgCNm/eLHekGmHOnDno3LkzbGxs4OrqiqFDhyI6OlruWLJavHgx2rdvr78xUGBgIP744w+5Y9U4n3/+OQRBwOTJk+WOIqsZM2ZAEASDr5YtW8odS3b37t3DSy+9BCcnJ1hYWKBdu3Y4deqU3LHYYFTW+vXrER4ejunTp+PMmTPw8/NDSEgIEhMT5Y4mm+zsbPj5+WHRokVyR6lR9u/fjwkTJuDYsWPYtWsXtFotnnrqKWRnZ8sdTTZeXl74/PPPcfr0aZw6dQpPPPEEnnnmGVy8eFHuaDXGyZMn8f3336N9+/ZyR6kR2rRpg/j4eP3XoUOH5I4kq9TUVHTv3h1mZmb4448/cOnSJcyfPx8ODg5yRwMkqpQuXbpIEyZM0D/W6XSSp6enNGfOHBlT1RwApE2bNskdo0ZKTEyUAEj79++XO0qN4uDgIP3www9yx6gRMjMzpWbNmkm7du2SevfuLU2aNEnuSLKaPn265OfnJ3eMGmXKlClSjx495I5hFGcwKqGgoACnT59GcHCwfplCoUBwcDCOHj0qYzKqDdLT0wEAjo6OMiepGXQ6HdatW4fs7GwEBgbKHadGmDBhAgYNGmTwb0x9d+3aNXh6eqJx48YYPXo0YmNj5Y4kq61btyIgIAAvvPACXF1d0bFjRyxbtkzuWAB4iKRSkpOTodPp4ObmZrDczc0N9+/flykV1QaiKGLy5Mno3r072rZtK3ccWZ0/fx7W1tbQaDQYP348Nm3ahNatW8sdS3br1q3DmTNnMGfOHLmj1Bhdu3bFqlWrsGPHDixevBi3bt1Cz549kZmZKXc02dy8eROLFy9Gs2bN8Oeff+LNN9/EO++8g9WrV8sdrf59mipRTTBhwgRcuHCh3h8/BoAWLVogKioK6enp+PXXXxEaGor9+/fX6ybjzp07mDRpEnbt2gVzc3O549QYAwYM0P+9ffv26Nq1Kxo1aoQNGzbg1VdflTGZfERRREBAAD777DMAQMeOHXHhwgUsWbIEoaGhsmbjDEYlODs7Q6lUIiEhwWB5QkIC3N3dZUpFNd3EiRPx+++/Y+/evfDy8pI7juzUajWaNm0Kf39/zJkzB35+fvjqq6/kjiWr06dPIzExEZ06dYJKpYJKpcL+/fvx9ddfQ6VSQafTyR2xRrC3t0fz5s1x/fp1uaPIxsPDo0Qz3qpVqxpx6IgNRiWo1Wr4+/sjMjJSv0wURURGRvIYMpUgSRImTpyITZs2Yc+ePfD19ZU7Uo0kiiLy8/PljiGrfv364fz584iKitJ/BQQEYPTo0YiKioJSqZQ7Yo2QlZWFGzduwMPDQ+4osunevXuJy92vXr2KRo0ayZTof3iIpJLCw8MRGhqKgIAAdOnSBQsXLkR2djbCwsLkjiabrKwsg98obt26haioKDg6OqJhw4YyJpPXhAkTsHbtWmzZsgU2Njb683Ts7OxgYWEhczp5REREYMCAAWjYsCEyMzOxdu1a7Nu3D3/++afc0WRlY2NT4twcKysrODk51etzdt577z0MHjwYjRo1QlxcHKZPnw6lUomRI0fKHU027777LoKCgvDZZ59h+PDhOHHiBJYuXYqlS5fKHY2XqZrCN998IzVs2FBSq9VSly5dpGPHjskdSVZ79+6VAJT4Cg0NlTuarIzVBIC0cuVKuaPJZuzYsVKjRo0ktVotubi4SP369ZN27twpd6waiZepStKIESMkDw8PSa1WSw0aNJBGjBghXb9+Xe5Ysvvtt9+ktm3bShqNRmrZsqW0dOlSuSNJkiRJ/Lh2IiIiMjmeg0FEREQmxwaDiIiITI4NBhEREZkcGwwiIiIyOTYYREREZHJsMIiIiMjk2GAQERGRybHBICIiIpNjg0FEREQmxwaDiIiITI4NBhEREZkcGwwikl1SUhLc3d3x2Wef6ZcdOXIEarUakZGRMiYjoorih50RUY2wfft2DB06FEeOHEGLFi3QoUMHPPPMM1iwYIHc0YioAthgEFGNMWHCBOzevRsBAQE4f/48Tp48CY1GI3csIqoANhhEVGPk5uaibdu2uHPnDk6fPo127drJHYmIKojnYBBRjXHjxg3ExcVBFEXExMTIHYeIKoEzGERUIxQUFKBLly7o0KEDWrRogYULF+L8+fNwdXWVOxoRVQAbDCKqEf7973/j119/xblz52BtbY3evXvDzs4Ov//+u9zRiKgCeIiEiGS3b98+LFy4ED/++CNsbW2hUCjw448/4uDBg1i8eLHc8YioAjiDQURERCbHGQwiIiIyOTYYREREZHJsMIiIiMjk2GAQERGRybHBICIiIpNjg0FEREQmxwaDiIiITI4NBhEREZkcGwwiIiIyOTYYREREZHJsMIiIiMjk/h975V7xmsyyyAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def example_cdf_ninv() -> None:\n", + " print(\"\\n=== Example 2: Weibull CDF sampled with NINV ===\")\n", + " k = 1.2\n", + " lam = 0.8\n", + "\n", + " def cdf_func(x: float, **_: Any) -> float:\n", + " if x <= 0:\n", + " return 0.0\n", + " return 1 - math.exp(-((x / lam) ** k))\n", + "\n", + " expected_mean = lam * math.gamma(1 + 1 / k)\n", + " expected_std = lam * math.sqrt(math.gamma(1 + 2 / k) - math.gamma(1 + 1 / k) ** 2)\n", + "\n", + " distr = CustomDistribution(\n", + " name=\"Weibull(λ=0.8, k=1.2)\",\n", + " kind=Kind.CONTINUOUS,\n", + " analytical_computations=[\n", + " AnalyticalComputation(target=CharacteristicName.CDF, func=cdf_func),\n", + " ],\n", + " sampling_strategy=DefaultUnuranSamplingStrategy(\n", + " config=UnuranMethodConfig(\n", + " method=UnuranMethod.NINV,\n", + " use_cdf=True,\n", + " use_pdf=False,\n", + " )\n", + " ),\n", + " )\n", + "\n", + " data = distr.sample(35_000).flatten()\n", + " print(f\"Mean ≈ {data.mean():.4f} (expected {expected_mean:.4f})\")\n", + " print(f\"Std ≈ {data.std():.4f} (expected {expected_std:.4f})\")\n", + " xs = np.linspace(0, 6, 400)\n", + " cdf_vals = np.array([cdf_func(x) for x in xs])\n", + " _plot_cdf(\"Weibull distribution (NINV)\", xs, cdf_vals, data, \"weibull_cdf_ninv.png\")\n", + "\n", + "\n", + "example_cdf_ninv()" + ] + }, + { + "cell_type": "markdown", + "id": "2ff90524", + "metadata": {}, + "source": [ + "## Experiment 3 — discrete DGT\n", + "Shows how a bounded support and a hand-written PMF allow using DGT without relying on registry characteristics." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "51c022e0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Example 3: Discrete PMF sampled with DGT ===\n", + "Observed frequencies:\n", + " value 0 → 0.150 (expected 0.150)\n", + " value 1 → 0.502 (expected 0.500)\n", + " value 2 → 0.252 (expected 0.250)\n", + " value 3 → 0.096 (expected 0.100)\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAhgAAAGJCAYAAADIVkprAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAO+tJREFUeJzt3Xt8z/X///H7e7ODGRvNxmbMIadyljWRc/NJIpTwdUo6oLBUW2FJGYmkxKeDQ8qHUujzUUOzUSjlEGmSUzowG3Yw2WZ7/f7ot3fetrG9vba3N7fr5fK+1Pv5fr5e78dzr72973u9nq/Xy2IYhiEAAAATuTi6AAAAcP0hYAAAANMRMAAAgOkIGAAAwHQEDAAAYDoCBgAAMB0BAwAAmI6AAQAATEfAAAAApiNgANeIF154QRaLxdFl4BK//fabPD09tWXLFkeXUqjY2Fh5e3srOTnZ0aUANggYQClYvHixLBaL9eHp6anAwECFh4dr7ty5ysjIcHSJJfLTTz/phRde0NGjR01db0JCgs3Pyc3NTXXq1NGQIUN0+PBha7+jR49a+7z00kuFrmvQoEGyWCzy9va2ae/YsaPNe1z82L9//xVrfPHFFxUaGqo77rjD2jZs2DCb9Xh7e6tOnTrq16+fPvnkE+Xl5RW5vv/+97/q2bOnAgIC5O7uripVqujOO+/UrFmzlJ6ebjPWKz2OHj2q7t27q169eoqJibniWICyZOFeJID5Fi9erOHDh+vFF19U7dq1lZOToxMnTighIUEbNmxQzZo19dlnn6lp06bWZS5cuKALFy7I09PTgZUXbuXKlbr//vsVHx+vjh07mrbehIQEderUSU8++aRuu+025eTkaOfOnXr77bfl7e2tvXv3KjAwUEePHlXt2rXl6empOnXqaN++fTbryczMVEBAgHJzc+Xq6qqzZ89aX+vYsaMOHTpU6Bfwvffeq0qVKhVZX3JysoKCgrRkyRINGDDA2j5s2DAtX75c7777riTpr7/+0q+//qr//ve/2rNnjzp27Kg1a9bYrDsvL08jRozQ4sWL1aRJE/Xt21fBwcHKyMjQtm3btGbNGrVt21afffaZVq1aZVPHrFmz9Pvvv+u1116zab/vvvtUoUIFzZ8/XxMmTNCJEydUsWLFYvzkgTJgADDdokWLDEnGd999V+C1uLg4o3z58katWrWMc+fOOaA6w8jJyTGysrKK3f/jjz82JBnx8fGm1hEfH29IMj7++GOb9rlz5xqSjGnTphmGYRhHjhwxJBl9+vQxJBm7d++26f/hhx8abm5uRs+ePY0KFSrYvNahQwfjlltusau+2bNnG+XLlzcyMjJs2ocOHVrgffLFxMQYkowHHnig0Pbx48cbeXl5BZb7888/jenTpxe6zh49ehi1atUqss6kpCTD1dXVeO+9964wIqDscIgEKGOdO3fWpEmT9Ouvv+qDDz6wthc2B2PDhg1q166dfH195e3trQYNGui5556z6XP+/Hm98MILql+/vjw9PVW9enX16dNHhw4dkvTP4YVXX31Vc+bMUd26deXh4aGffvpJkrR//37169dPVapUkaenp1q3bq3PPvvMuv7Fixfr/vvvlyR16tTJuns+ISHB2ueLL75Q+/btVaFCBVWsWFE9evQosJehpD8jSTpy5IhNe1hYmGrXrq1ly5bZtH/44Yfq3r27qlSpYvd7Fmb16tUKDQ0tcNjlciIjI3XXXXfp448/1oEDByRJ586d04wZM3TLLbdo5syZhc61qV69up599lm76vT391fTpk21Zs0au5YHSgMBA3CAwYMHS5LWr19fZJ99+/bpnnvuUVZWll588UXNmjVL9957r81kw9zcXN1zzz2aMmWKWrVqpVmzZmns2LFKS0vTjz/+aLO+RYsW6Y033tAjjzyiWbNmqUqVKtq3b59uv/12JSYmKjIyUrNmzVKFChXUu3dv6276O++8U08++aQk6bnnntPSpUu1dOlSNWrUSJK0dOlS9ejRQ97e3poxY4YmTZqkn376Se3atbN7zkZ+OLrpppsKvDZgwAAtX75cxv8/upuSkqL169dr4MCBRa4vNzdXKSkpNo+LD6MUJicnR999951atmxZ4voHDx4swzC0YcMGSdLXX3+t1NRUDRgwQK6uriVeX3G0atVKW7duLZV1A/Yo5+gCgBtRjRo15OPjY/0iLcyGDRuUnZ2tL774Qn5+foX2ef/99xUXF6fZs2dr/Pjx1vbIyEjrF3C+33//XQcPHlTVqlWtbV27dlXNmjX13XffycPDQ5I0atQotWvXTs8++6zuu+8+1alTR+3bt9fcuXPVrVs3mzkYZ8+e1ZNPPqmHH35Yb7/9trV96NChatCggaZNm2bTXpSMjAylpKQoJydHu3bt0tix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dOhQm/4fffSRUb9+fcPd3d245ZZbjLVr15ZxxfYpyTjHjRtn7RsQEGDcfffdxs6dOx1Qdcnln4556SN/fEOHDjU6dOhQYJnmzZsb7u7uRp06dYxFixaVed0lVdJxzpgxw6hbt67h6elpVKlSxejYsaOxceNGxxRfQoWNU5LNdroePqv2jNNZP6sPPfSQUatWLcPd3d2oWrWq0aVLF2u4MIzrY3uaidu1AwAA0zEHAwAAmI6AAQAATEfAAAAApiNgAAAA0xEwAACA6QgYAADAdAQMAABgOgIGAAAwHQEDwDUjJCREc+bMcXQZAExAwAAAAKYjYAAAANMRMACY4u2331ZgYKDy8vJs2nv16qWHHnpIhw4dUq9evRQQECBvb2/ddttt+vLLL4tc39GjR2WxWLR7925rW2pqqiwWixISEqxtP/74o/71r3/J29tbAQEBGjx4sFJSUsweHoASImAAMMX999+vU6dOKT4+3tp2+vRpxcbGatCgQTp79qzuvvtuxcXFadeuXerevbt69uypY8eO2f2eqamp6ty5s1q0aKHvv/9esbGxSkpK0gMPPGDGkABchXKOLgDA9aFy5cr617/+pWXLlqlLly6SpJUrV8rPz0+dOnWSi4uLmjVrZu0/depUrVq1Sp999pnGjBlj13u++eabatGihaZNm2ZtW7hwoYKDg3XgwAHVr1//6gYFwG7swQBgmkGDBumTTz5RVlaWJOnDDz/Ugw8+KBcXF509e1YTJkxQo0aN5OvrK29vbyUmJl7VHowffvhB8fHx8vb2tj4aNmwoSTp06JApYwJgH/ZgADBNz549ZRiG1q5dq9tuu01fffWVXnvtNUnShAkTtGHDBr366quqV6+eypcvr379+ik7O7vQdbm4/P33j2EY1racnBybPmfPnlXPnj01Y8aMAstXr17drGEBsAMBA4BpPD091adPH3344Yc6ePCgGjRooJYtW0qStmzZomHDhum+++6T9Hc4OHr0aJHrqlq1qiTp+PHjatGihSTZTPiUpJYtW+qTTz5RSEiIypXjnzPgWsIhEgCmGjRokNauXauFCxdq0KBB1vabb75Zn376qXbv3q0ffvhBAwcOLHDGycXKly+v22+/XdOnT1diYqI2bdqkiRMn2vQZPXq0Tp8+rQEDBui7777ToUOHtG7dOg0fPly5ubmlNkYAV0bAAGCqzp07q0qVKvr55581cOBAa/vs2bNVuXJltW3bVj179lR4eLh170ZRFi5cqAsXLqhVq1YaN26cXnrpJZvXAwMDtWXLFuXm5uquu+5SkyZNNG7cOPn6+loPsQBwDItx8QFOAAAAExDxAQCA6QgYAADAdAQMAABgOgIGAAAwHQEDAACYjoABAABMR8AAAACmI2AAAADTETAAAIDpCBgAAMB0BAwAAGC6/wcvrxG1I879dwAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def example_discrete_dgt() -> None:\n", + " print(\"\\n=== Example 3: Discrete PMF sampled with DGT ===\")\n", + " pmf = {0: 0.15, 1: 0.5, 2: 0.25, 3: 0.10}\n", + " support = ExplicitTableDiscreteSupport(list(pmf.keys()))\n", + "\n", + " distr = CustomDistribution(\n", + " name=\"Custom discrete\",\n", + " kind=Kind.DISCRETE,\n", + " analytical_computations={\n", + " CharacteristicName.PMF: AnalyticalComputation(\n", + " target=CharacteristicName.PMF,\n", + " func=lambda x, **_: pmf.get(int(x), 0.0),\n", + " )\n", + " },\n", + " sampling_strategy=DefaultUnuranSamplingStrategy(\n", + " config=UnuranMethodConfig(\n", + " method=UnuranMethod.DGT,\n", + " use_registry_characteristics=False,\n", + " )\n", + " ),\n", + " support=support,\n", + " )\n", + "\n", + " data = distr.sample(25_000).flatten()\n", + " unique, counts = np.unique(data, return_counts=True)\n", + " print(\"Observed frequencies:\")\n", + " for value, freq in zip(unique, counts / counts.sum(), strict=False):\n", + " print(f\" value {int(value)} → {freq:.3f} (expected {pmf[int(value)]:.3f})\")\n", + "\n", + " _plot_discrete_pmf(\"Discrete PMF (DGT)\", pmf, data, \"discrete_dgt.png\")\n", + "\n", + "\n", + "example_discrete_dgt()" + ] + }, + { + "cell_type": "markdown", + "id": "0856c6c9", + "metadata": {}, + "source": [ + "## Experiment 4 — normal distribution (AUTO vs PINV)\n", + "Compare the two methods on the same PDF: AUTO selects an optimal strategy automatically, while PINV is enforced manually. We illustrate the differences via histograms." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "d1f910b2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Example 4: Normal PDF sampled with AUTO vs PINV ===\n", + "Expected mean ≈ 0.000, std ≈ 1.000\n", + "AUTO: mean=-0.004, std=1.006\n", + "PINV: mean=0.009, std=1.004\n" + ] + }, + { + "data": { + "image/png": "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Luqr4cOHk5uby9KlS2nWrBmJiYlER0dz9epVraPVKZqfCB85ciSzZs1iypQpBAcHs2/fPqKioswDS8+fP098fHy52nzttdf4xz/+wbPPPku3bt3IyMggKipKbqIkhAbyEhLo9fn72Galc71xEH9OeL/UAiTyt6N8u/M8kb8d5YM1R4odtnfs0YMmX36J3s2NnIMHufDsc5hk/h9RxVJSUti0aRMzZsygX79+NG3alO7duzNp0iQeeughoGB6iQ4dOuDo6Ii/vz8vvPACGRkZ5jaWLFmCm5sbq1evplWrVjg4ODBixAiysrJYunQpAQEBuLu789JLL5mvcgEICAjgvffeY/To0Tg6OtKoUSPmzZt327wXLlzg0Ucfxc3NjQYNGjBkyBDOnj1rft5oNBIREYGbmxsNGzbktddes5jLpjUvQgAmTJjAuXPnMBgMbN++ndDQUPNzMTExLFmypNRtlyxZUuyclqIovPvuuyQkJJCTk8Mff/xBy5Ytqym9EKI0xowMLjzzLA6pV0nzasSWZ98m18m1Um3atWpJk0X/RefiQvbevcRPmWIxv1BF3eDk5ISTkxOrVq0qMofUzXQ6HR9//DGHDx9m6dKlrF+/ntdee63IOllZWXz88cesWLGCqKgoYmJiePjhh/ntt9/47bffWLZsGZ999hnff/99ke3+9a9/0alTJ/bu3csbb7zByy+/zLp160rMkZeXR3h4OM7OzmzatIktW7bg5OTEgAEDyM3NBWD27NksWbKERYsWsXnzZq5du8ZPP/1UBd+pyrOIIkQIUfeoqkr8m29hOHmSbBd3tjzzFrmOVXPqxK5tWxp//G/Q60n75VeuLVpcJe0KAQUXOixZsoSlS5fi5uZGr169mDx5MgcOHDCvM3HiRPr160dAQAD9+/fn/fff59tvvy3STl5eHvPnz6dz587cc889jBgxwjzNRNu2bRk8eDD9+vVjw4YNRbbr1asXb7zxBi1btuQf//gHI0aM4KOPPiox68qVKzGZTHzxxRd06NCBNm3asHjxYs6fP09MTAwAc+fOZdKkSQwbNow2bdqwYMECXF0r98dAVZEiRAhRZQpPoXyw5ggrJs8mfe1aTHorto17rVxjQMrCsUcPvG9MdJg0ezZZu3dXafuifhs+fDiXL1/ml19+YcCAAcTExNClSxfzkfk//viDe++9l0aNGuHs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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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se3atYsvv/zSVlY+5uL48eO2pOFSubm5eHp6oigKJSUlDBo0iH//+99VPQV2dQMV2nX9+vV4eXnZ7vv5+V2xrrrQNAlxcXGhT58+JCYm2k6fUhSFxMREJk+eXO16FEWxG1h6qdTUVM6dO2fLhoUQ1dNu61oAirq10zaQeuY7uAvZv+4hf/sxLPnFOHnV7AuyqdLpdOiq2SUCZV9UeosFfT0kITXl4eFh+7G5YMECevXqxSeffMLEiRMpKCgAYMWKFbZBp+UqO6pdrqCggL/85S+VDm69+Pvh4jEYNdnu0rEROp3O1jXidoUkrKCggEceeYS///3vFZa1bdu2yu28vLzYvn07er2e0NDQKz5Ouf379wMQGWk/8WBkZGSFMSENSfPumPj4eMaNG0ffvn3p378/c+bMobCw0Ha2zNixY2ndurXtMFhCQgJ9+/YlKioKk8nETz/9xOeff87cuXOBsoacMWMGd955JyEhIRw9epRnn32WDh062J09I4S4PO8zJ/A9cxxVr6O4Sxutw6lXru0CcW0XSElKJnmbDuF/Qy+tQxKXodfrmTp1KvHx8dx777107doVo9HIyZMnGTJkSLXrufrqq/nuu++IiIio1riHum53sZ49e5KamsqhQ4cqPRpy9dVXs2/fPlviVV0X9wzUxJw5c/D29iY2NrbG29YnzTs9R48ezaxZs5g2bRrR0dHs2LGDlStX2garnjx5krNnz9rWLyws5LHHHqNbt25ce+21fPfdd3zxxRc89NBDQNkAnF27dnHrrbfSqVMnJk6cSJ8+fVi/fv1lM2QhhL2229YBUBIViuLW/N47PtdeBUBu0iGNIxHV8de//hWDwcAHH3yAl5cXzzzzDE899RSffvopR48eZfv27bz//vt8+umnVdbx+OOPc/78ecaMGcPWrVs5evQoq1atYsKECbazQ+pzu4sNGTKE6667jjvvvJNffvmF48eP8/PPP9tOpX3uuefYuHEjkydPZseOHRw+fJj//ve/NeoVqEpOTg5paWmkpKTwyy+/cNddd7F48WLmzp3bqEc9KqP5kRCAyZMnV/lEr1271u7+a6+9xmuvvVZlXW5ubhUmLhNC1IzOaqXt9t8AKOrevLpiynn370j6kt8pOZGJ6Ww2xtCG7fsWdePk5MTkyZN58803efTRR3n11VcJDAwkISGBY8eO4evry9VX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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def example_normal_overrides() -> None:\n", + " print(\"\\n=== Example 4: Normal PDF sampled with AUTO vs PINV ===\")\n", + "\n", + " def pdf_func(x: float, **_: Any) -> float:\n", + " return (1 / math.sqrt(2 * math.pi)) * math.exp(-0.5 * x * x)\n", + "\n", + " distr = CustomDistribution(\n", + " name=\"Normal N(0,1)\",\n", + " kind=Kind.CONTINUOUS,\n", + " analytical_computations=[\n", + " AnalyticalComputation(target=CharacteristicName.PDF, func=pdf_func),\n", + " ],\n", + " sampling_strategy=DefaultUnuranSamplingStrategy(),\n", + " )\n", + "\n", + " strategy = DefaultUnuranSamplingStrategy(config=UnuranMethodConfig(method=UnuranMethod.PINV))\n", + "\n", + " distr_pinv = CustomDistribution(\n", + " name=\"Normal N(0,1)\",\n", + " kind=Kind.CONTINUOUS,\n", + " analytical_computations=[\n", + " AnalyticalComputation(target=CharacteristicName.PDF, func=pdf_func),\n", + " ],\n", + " sampling_strategy=strategy,\n", + " )\n", + "\n", + " data_auto = distr.sample(15_000).flatten()\n", + " data_pinv = distr_pinv.sample(15_000).flatten()\n", + " print(\"Expected mean ≈ 0.000, std ≈ 1.000\")\n", + " print(f\"AUTO: mean={data_auto.mean():.3f}, std={data_auto.std():.3f}\")\n", + " print(f\"PINV: mean={data_pinv.mean():.3f}, std={data_pinv.std():.3f}\")\n", + "\n", + " xs = np.linspace(-4, 4, 400)\n", + " pdf_vals = np.array([pdf_func(x) for x in xs])\n", + " _plot_histogram(\n", + " \"Normal distribution (AUTO vs PINV)\", xs, pdf_vals, data_auto, \"normal_auto_hist.png\"\n", + " )\n", + "\n", + " fig, ax = plt.subplots(figsize=(6, 4))\n", + " ax.set_title(\"Normal distribution – method comparison\")\n", + " ax.hist(data_auto, bins=60, density=True, alpha=0.55, label=\"AUTO\", color=\"tab:blue\")\n", + " ax.hist(data_pinv, bins=60, density=True, alpha=0.45, label=\"PINV\", color=\"tab:orange\")\n", + " ax.plot(xs, pdf_vals, label=\"Reference PDF\", color=\"tab:red\")\n", + " ax.set_xlabel(\"x\")\n", + " ax.set_ylabel(\"Density\")\n", + " ax.legend()\n", + " ax.grid(True, alpha=0.3)\n", + " _save_and_show(fig, \"normal_auto_vs_pinv.png\")\n", + "\n", + "\n", + "example_normal_overrides()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index d4bc847..0795d19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,13 @@ [build-system] build-backend = "poetry.core.masonry.api" -requires = [ "poetry-core>=1.9" ] +requires = [ + "cffi>=1.16", + "meson>=0.64", + "ninja>=1.10", + "poetry-core>=1.9", + "setuptools>=69", +] [tool.poetry] name = "pysatl-core" @@ -41,10 +47,15 @@ homepage = "https://github.com/PySATL/pysatl-core" repository = "https://github.com/PySATL/pysatl-core" urls.Issues = "https://github.com/PySATL/pysatl-core/issues" +[tool.poetry.build] +script = "src/pysatl_core/sampling/unuran/bindings/_cffi_build.py" + [tool.poetry.dependencies] python = ">=3.12" numpy = "^2.0.0" scipy = ">=1.13" +cffi = "^1.16.0" +mypy_extensions = ">=1.0.0" [tool.poetry.group.dev.dependencies] ruff = ">=0.6" @@ -55,6 +66,7 @@ coverage = { version = ">=7.5", extras = [ "toml" ] } pre-commit = ">=3.6" types-setuptools = "*" scipy-stubs = ">=1.13.0.0" +types-cffi = ">=1.17.0.0" [tool.poetry.group.docs.dependencies] jupyter = "^1.1.1" @@ -70,6 +82,7 @@ linkify-it-py = "^2.0.3" [tool.ruff] target-version = "py312" line-length = 100 +exclude = [ "vendor" ] format.line-ending = "lf" @@ -84,6 +97,9 @@ lint.isort.force-single-line = false lint.isort.known-first-party = [ "pysatl_core" ] lint.isort.order-by-type = true +[tool.codespell] +skip = "vendor/**" + [tool.pytest.ini_options] addopts = "-q --cov=pysatl_core --cov-report=term-missing" testpaths = [ "tests" ] diff --git a/src/pysatl_core/__init__.py b/src/pysatl_core/__init__.py index 751fd03..da0efa9 100644 --- a/src/pysatl_core/__init__.py +++ b/src/pysatl_core/__init__.py @@ -14,6 +14,8 @@ from .distributions import __all__ as _distr_all from .families import * from .families import __all__ as _family_all +from .sampling import * +from .sampling import __all__ as _sampling_all from .types import * from .types import __all__ as _types_all @@ -23,8 +25,10 @@ *_distr_all, *_family_all, *_types_all, + *_sampling_all, ] del _distr_all del _family_all del _types_all +del _sampling_all diff --git a/src/pysatl_core/distributions/distribution.py b/src/pysatl_core/distributions/distribution.py index 280b5d3..cf480bf 100644 --- a/src/pysatl_core/distributions/distribution.py +++ b/src/pysatl_core/distributions/distribution.py @@ -22,13 +22,13 @@ from pysatl_core.distributions.computation import AnalyticalComputation from pysatl_core.distributions.strategies import ( ComputationStrategy, - Method, SamplingStrategy, ) from pysatl_core.distributions.support import Support from pysatl_core.types import ( DistributionType, GenericCharacteristicName, + Method, ) diff --git a/src/pysatl_core/distributions/strategies.py b/src/pysatl_core/distributions/strategies.py index 19e5001..be56bb3 100644 --- a/src/pysatl_core/distributions/strategies.py +++ b/src/pysatl_core/distributions/strategies.py @@ -16,17 +16,15 @@ import numpy as np from pysatl_core.distributions.registry import characteristic_registry -from pysatl_core.types import CharacteristicName, NumericArray +from pysatl_core.types import CharacteristicName, Method, NumericArray if TYPE_CHECKING: from typing import Any - from pysatl_core.distributions.computation import AnalyticalComputation, FittedComputationMethod + from pysatl_core.distributions.computation import FittedComputationMethod from pysatl_core.distributions.distribution import Distribution from pysatl_core.types import GenericCharacteristicName -type Method[In, Out] = AnalyticalComputation[In, Out] | FittedComputationMethod[In, Out] - class ComputationStrategy[In, Out](Protocol): """ diff --git a/src/pysatl_core/sampling/__init__.py b/src/pysatl_core/sampling/__init__.py new file mode 100644 index 0000000..cd49e81 --- /dev/null +++ b/src/pysatl_core/sampling/__init__.py @@ -0,0 +1,26 @@ +""" +Public sampling interface re-exporting UNURAN-based defaults. +""" + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from .unuran import ( + DefaultUnuranSampler, + DefaultUnuranSamplingStrategy, + UnuranMethod, + UnuranMethodConfig, +) + +SamplingMethod = UnuranMethod +SamplingMethodConfig = UnuranMethodConfig +DefaultSampler = DefaultUnuranSampler +DefaultSamplingStrategy = DefaultUnuranSamplingStrategy + +__all__ = [ + "SamplingMethod", + "SamplingMethodConfig", + "DefaultSampler", + "DefaultSamplingStrategy", +] diff --git a/src/pysatl_core/sampling/unuran/__init__.py b/src/pysatl_core/sampling/unuran/__init__.py new file mode 100644 index 0000000..e805898 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/__init__.py @@ -0,0 +1,26 @@ +""" +Expose the UNU.RAN sampling API interfaces alongside their default +implementations backed by our C bindings. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from .api import ( + UnuranMethod, + UnuranMethodConfig, +) +from .core import ( + DefaultUnuranSampler, + DefaultUnuranSamplingStrategy, +) + +__all__ = [ + "UnuranMethod", + "UnuranMethodConfig", + "DefaultUnuranSampler", + "DefaultUnuranSamplingStrategy", +] diff --git a/src/pysatl_core/sampling/unuran/api.py b/src/pysatl_core/sampling/unuran/api.py new file mode 100644 index 0000000..2886495 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/api.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING, Any, Protocol + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from pysatl_core.distributions.distribution import Distribution + from pysatl_core.types import NumericArray + + +class UnuranMethod(StrEnum): + """ + UNU.RAN sampling methods. + + Methods are categorized by their approach: + - Inversion methods: use inverse CDF + - Rejection methods: accept/reject based on envelope + - Transformation methods: transform from simpler distributions + - Numerical inversion: approximate inverse CDF + - Specialized methods: optimized for specific distribution types + """ + + # Automatic method selection + AUTO = "auto" + + # Inversion methods + PINV = "pinv" # Polynomial interpolation inversion + TDR = "tdr" # Transformed density rejection + + # Rejection methods + AROU = "arou" # Automatic ratio-of-uniforms + HINV = "hinv" # Hermite interpolation inversion + NINV = "ninv" # Numerical inversion + + # Discrete methods + DGT = "dgt" # Discrete guide table method + + +@dataclass(frozen=True, slots=True) +class UnuranMethodConfig: + """ + Configuration for a UNU.RAN sampling method. + + Parameters + ---------- + method : UnuranMethod + The sampling method to use. If ``UnuranMethod.AUTO``, UNU.RAN will + automatically select the best method based on available distribution + characteristics. + method_params : dict[str, Any], optional + Method-specific parameters. The exact parameters depend on the chosen + method. Common parameters include: + - ``accuracy``: target accuracy for numerical methods + - ``max_iterations``: maximum iterations for iterative methods + - ``grid_size``: grid size for interpolation methods + - ``smooth``: smoothing parameter for kernel methods + use_ppf : bool, default False + If ``True``, prefer using PPF (inverse CDF) when available. This is + typically the fastest method for univariate distributions. + use_pdf : bool, default True + If ``True``, allow using PDF for rejection-based methods. + use_cdf : bool, default False + If ``True``, allow using CDF for inversion-based methods. + use_registry_characteristics : bool, default True + If ``True``, allow using distribution characteristics from the registry + in addition to those directly available in the Distribution object. + If ``False``, use only characteristics that are directly available + in the Distribution object, without querying the registry. + use_fallback_sampler : bool, default True + If ``True``, fall back to ``DefaultSamplingUnivariateStrategy`` when + UNU.RAN initialization fails (e.g. non-integer discrete support). + If ``False``, the original ``RuntimeError`` is re-raised instead. + + Notes + ----- + - Method-specific parameters are validated when the sampler is created + - Some methods require specific characteristics (e.g., rejection methods + typically need PDF) + - The ``use_*`` flags control which distribution characteristics can be + used, but do not guarantee their use + - When ``use_registry_characteristics`` is ``True``, characteristics may + be retrieved from the distribution registry if not directly available + in the Distribution object + """ + + method: UnuranMethod = UnuranMethod.AUTO + method_params: dict[str, Any] | None = None + use_ppf: bool = True + use_pdf: bool = True + use_cdf: bool = True + use_registry_characteristics: bool = True + use_fallback_sampler: bool = True + + def __post_init__(self) -> None: + """Validate configuration parameters.""" + if self.method_params is None: + object.__setattr__(self, "method_params", {}) + + +class IUnuranSampler(Protocol): + """ + Protocol for UNU.RAN sampler instances. + + A sampler is created for a specific distribution and can generate + random variates efficiently. The sampler encapsulates the UNU.RAN + generator object and provides a Python interface for sampling. + + Notes + ----- + - Samplers are stateful and maintain internal random number generator state + - Multiple calls to ``sample()`` produce independent variates + - The sampler is bound to the distribution characteristics used at creation + """ + + def __init__( + self, + distr: Distribution, + config: UnuranMethodConfig | None = None, + **override_options: Any, + ) -> None: + """Initialize the sampler for ``distr`` with optional configuration overrides.""" + ... + + def sample(self, n: int) -> npt.NDArray[np.float64]: + """ + Generate ``n`` random variates from the distribution. + + Parameters + ---------- + n : int + Number of variates to generate. + + Returns + ------- + numpy.ndarray + 1D array of shape ``(n,)`` containing the random variates. + + Raises + ------ + RuntimeError + If sampling fails (e.g., method configuration is invalid, + distribution characteristics are insufficient). + """ + ... + + @property + def method(self) -> UnuranMethod: + """The sampling method used by this sampler.""" + ... + + +class IUnuranSamplingStrategy(Protocol): + """ + Protocol for UNU.RAN-based sampling strategies. + + This protocol extends the standard :class:`SamplingStrategy` protocol + with UNU.RAN-specific functionality. It integrates with the distribution + system by implementing the standard ``sample()`` method. + + Notes + ----- + - The strategy may create and cache samplers per distribution + - The strategy handles the conversion from UNU.RAN output to + :class:`~pysatl_core.distributions.sampling.Sample` format + """ + + def sample(self, n: int, distr: Distribution, **options: Any) -> NumericArray: + """ + Generate a sample from the distribution using UNU.RAN. + + Parameters + ---------- + n : int + Number of observations to draw. + distr : Distribution + The distribution to sample from. + **options : Any + Additional options that may override the default configuration: + - ``method``: override the sampling method + - Other method-specific parameters + + Returns + ------- + Sample + A 2D sample of shape ``(n, 1)`` for univariate distributions. + + Raises + ------ + RuntimeError + If the distribution type is not supported, or if UNU.RAN + cannot create a sampler with the available characteristics. + ValueError + If the configuration is invalid. + """ + ... + + @property + def config(self) -> UnuranMethodConfig: + """Method configuration.""" + ... diff --git a/src/pysatl_core/sampling/unuran/bindings/__init__.py b/src/pysatl_core/sampling/unuran/bindings/__init__.py new file mode 100644 index 0000000..8964dc0 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/bindings/__init__.py @@ -0,0 +1,37 @@ +""" +PySATL Core — UNU.RAN CFFI Bindings +===================================== + +Lazy loader for the compiled CFFI extension module ``_unuran_cffi``. +Tries the fully-qualified package path first, then falls back to a +bare top-level import so the extension can be found regardless of +how it was installed. Exposes ``_unuran_cffi`` (``None`` when the +binary extension is not available). +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from importlib import import_module +from types import ModuleType + +_unuran_cffi_module = None + +for name in ( + "pysatl_core.sampling.unuran.bindings._unuran_cffi", + "_unuran_cffi", +): + try: + _unuran_cffi_module = import_module(name) + break + except ModuleNotFoundError: # pragma: no cover - optional binary module + pass + +_unuran_cffi: ModuleType | None = _unuran_cffi_module + +__all__ = [ + "_unuran_cffi", +] diff --git a/src/pysatl_core/sampling/unuran/bindings/_cffi_build.py b/src/pysatl_core/sampling/unuran/bindings/_cffi_build.py new file mode 100644 index 0000000..040d033 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/bindings/_cffi_build.py @@ -0,0 +1,252 @@ +""" +CFFI build script that links against an already installed UNU.RAN library. + +Uses the helper from find.py to locate the headers and shared library. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +import argparse +import logging +import os +import subprocess +import sys +from pathlib import Path +from typing import Final + +from cffi import FFI # type: ignore[import-untyped] + +MODULE_NAME = "pysatl_core.sampling.unuran.bindings._unuran_cffi" +UNURAN_DIR_NAME = "unuran-pysatl" +_CDEF_FILE = Path(__file__).with_name("cffi_unuran.h") +UNURAN_CDEF: Final = _CDEF_FILE.read_text() + +LOGGER = logging.getLogger(__name__) + +ffi = FFI() + +ffi.cdef(UNURAN_CDEF) + + +def _configure_logging(verbose: bool, log_file: str | None = None) -> None: + """Initialize logging with INFO when verbose and WARNING otherwise. + + Parameters + ---------- + verbose : bool + Enable INFO-level logging; WARNING level is used otherwise. + log_file : str | None + Path to a file where log output will be written. If ``None``, logs + are written to stderr only. + """ + level = logging.INFO if verbose else logging.WARNING + handlers: list[logging.Handler] = [logging.StreamHandler()] + if log_file is not None: + handlers.append(logging.FileHandler(log_file)) + logging.basicConfig(level=level, format="%(levelname)s: %(message)s", handlers=handlers) + + +def _get_project_root() -> Path: + """Ascend parent directories to locate the repository containing pyproject.toml.""" + current_file = Path(__file__).resolve() + current_dir = current_file.parent + + while current_dir != current_dir.parent: + if (current_dir / "pyproject.toml").exists(): + return current_dir + current_dir = current_dir.parent + + raise RuntimeError( + f"Could not find project root (pyproject.toml) starting from {current_file.parent}" + ) + + +def _extract_library_name(library_path: Path) -> str: + """Return the bare library name stripping common prefixes/suffixes.""" + name = library_path.name + if name.startswith("lib"): + name = name[3:] + + for marker in (".so", ".dylib", ".a", ".dll"): + if marker in name: + name = name.split(marker, 1)[0] + break + + return name or library_path.stem + + +def _configure_from_paths(include_file: Path, library_file: Path) -> None: + """Configure the CFFI module depending on whether the target is static or shared.""" + include_dir = include_file.parent + + if library_file.suffix == ".a": + LOGGER.info("Linking against static library %s", library_file) + ffi.set_source( + MODULE_NAME, + '#include "unuran.h"', + include_dirs=[str(include_dir)], + extra_objects=[str(library_file)], + extra_compile_args=["-std=c99", "-O2"], + ) + return + + library_name = _extract_library_name(library_file) + library_dir = library_file.parent + + LOGGER.info("Linking against shared library %s", library_file) + ffi.set_source( + MODULE_NAME, + '#include "unuran.h"', + include_dirs=[str(include_dir)], + libraries=[library_name], + library_dirs=[str(library_dir)], + extra_compile_args=["-std=c99"], + ) + + +def find_unuran(unuran_dir: Path, raise_on_error: bool = True) -> dict[str, Path | None]: + """ + Locate the UNU.RAN library and its header file within the vendor tree. + + Parameters + ---------- + unuran_dir : Path + Root of the vendored UNU.RAN source tree. + raise_on_error : bool, default True + When ``True``, raise :exc:`ImportError` if either the header or the + library file cannot be found. When ``False``, return ``None`` values + for missing paths without raising. + + Returns + ------- + dict[str, Path | None] + Dictionary with keys ``"include_path"`` and ``"library_path"``, + each holding a resolved :class:`Path` or ``None`` if not found. + + Raises + ------ + ImportError + If ``raise_on_error`` is ``True`` and the library or header is missing. + """ + results: dict[str, Path | None] = { + "include_path": None, + "library_path": None, + } + + possible_lib_paths = [ + unuran_dir / "out" / "libunuran.a", # Windows + Linux + unuran_dir / "out" / "libunuran.so", # Linux + unuran_dir / "out" / "libunuran.dylib", # macOS + ] + + for path in possible_lib_paths: + if path and path.exists(): + results["library_path"] = path.resolve() + break + + include_path = unuran_dir / "unuran" / "src" / "unuran.h" + + if include_path.exists(): + results["include_path"] = include_path + + if not results["library_path"] and raise_on_error: + raise ImportError("libunuran.so not found") + + if not results["include_path"] and raise_on_error: + raise ImportError("Header unuran.h not found") + + return results + + +def build_unuran(unuran_dir: Path) -> None: + """ + Build the UNU.RAN C library from source if it has not been built yet. + + Skips the build when both the header and library file are already present. + Otherwise invokes the vendored ``build_unuran.py`` script via the current + Python interpreter. + + Parameters + ---------- + unuran_dir : Path + Root of the vendored UNU.RAN source tree (must contain ``build_unuran.py``). + + Raises + ------ + RuntimeError + If ``build_unuran.py`` does not exist in ``unuran_dir``. + subprocess.CalledProcessError + If the build script exits with a non-zero status. + """ + if all(find_unuran(unuran_dir, False).values()): + return + + build_script = unuran_dir / "build_unuran.py" + if not build_script.exists(): + raise RuntimeError(f"Build script {build_script} not found") + + subprocess.run([sys.executable, str(build_script)], check=True) + + +def main() -> None: + """ + Entry point for the CFFI build: locate UNU.RAN, configure the extension, + and compile the ``_unuran_cffi`` module into ``src/``. + + Raises + ------ + RuntimeError + If UNU.RAN cannot be found after attempting the build. + """ + project_root = _get_project_root() + unuran_dir = project_root / "vendor" / UNURAN_DIR_NAME + build_unuran(unuran_dir) + paths = find_unuran(unuran_dir) + + if not (paths["include_path"] and paths["library_path"]): + raise RuntimeError("UNU.RAN not found") + + include_file = Path(paths["include_path"]).expanduser().resolve() + library_file = Path(paths["library_path"]).expanduser().resolve() + + LOGGER.info("Found UNU.RAN include at %s", include_file) + LOGGER.info("Found UNU.RAN library at %s", library_file) + + _configure_from_paths(include_file, library_file) + + build_output_dir = project_root / "src" + previous_cwd = Path.cwd() + + try: + os.chdir(build_output_dir) + ffi.compile(verbose=True) + finally: + os.chdir(previous_cwd) + + LOGGER.info("CFFI bindings compiled successfully.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Build CFFI bindings for the vendored UNU.RAN library." + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + default=False, + help="Enable INFO-level logging during the build.", + ) + parser.add_argument( + "--log-file", + metavar="PATH", + default=None, + help="Write log output to PATH in addition to stderr.", + ) + args = parser.parse_args() + _configure_logging(args.verbose, args.log_file) + main() diff --git a/src/pysatl_core/sampling/unuran/bindings/_unuran_cffi.pyi b/src/pysatl_core/sampling/unuran/bindings/_unuran_cffi.pyi new file mode 100644 index 0000000..8d1a323 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/bindings/_unuran_cffi.pyi @@ -0,0 +1,6 @@ +from typing import Any + +# TODO problems with 'from cffi import FFI' (mypy) +ffi: Any +lib: Any +__all__ = ["ffi", "lib"] diff --git a/src/pysatl_core/sampling/unuran/bindings/cffi_unuran.h b/src/pysatl_core/sampling/unuran/bindings/cffi_unuran.h new file mode 100644 index 0000000..d280996 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/bindings/cffi_unuran.h @@ -0,0 +1,82 @@ +/* + * cffi_unuran.h — UNU.RAN CFFI interface declarations + * + * Minimal C declarations consumed by the CFFI build script + * (_cffi_build.py) to generate the Python bindings for the UNU.RAN + * random-variate generation library. + * + * Covers: + * - Opaque handle typedefs (UNUR_DISTR, UNUR_GEN, UNUR_PAR, UNUR_URNG) + * - Distribution constructors and setter functions (continuous & discrete) + * - Method (algorithm) constructors: AROU, TDR, HINV, PINV, NINV, DGT + * - Generator lifecycle: init, sample, free + * - Custom URNG registration + * - Error query helpers + * + * Author: Artem Romanyuk + * Copyright (c) 2025 PySATL project + * SPDX-License-Identifier: MIT + */ + +struct unur_distr; +struct unur_gen; +struct unur_par; +struct unur_urng; + +typedef struct unur_distr* UNUR_DISTR; +typedef struct unur_gen* UNUR_GEN; +typedef struct unur_par* UNUR_PAR; +typedef struct unur_urng* UNUR_URNG; + +UNUR_DISTR unur_distr_cont_new(void); +UNUR_DISTR unur_distr_discr_new(void); + +int unur_distr_cont_set_pdf(UNUR_DISTR distribution, + double (*pdf)(double, const struct unur_distr*)); +int unur_distr_cont_set_dpdf(UNUR_DISTR distribution, + double (*dpdf)(double, const struct unur_distr*)); +int unur_distr_cont_set_cdf(UNUR_DISTR distribution, + double (*cdf)(double, const struct unur_distr*)); +int unur_distr_cont_set_invcdf(UNUR_DISTR distribution, + double (*invcdf)(double, const struct unur_distr*)); +int unur_distr_cont_set_domain(UNUR_DISTR distribution, double left, double right); +int unur_distr_cont_set_mode(UNUR_DISTR distribution, double mode); +int unur_distr_cont_set_pdfparams(UNUR_DISTR distribution, const double* params, int n_params); + +int unur_distr_discr_set_pmf(UNUR_DISTR distribution, + double (*pmf)(int, const struct unur_distr*)); +int unur_distr_discr_set_cdf(UNUR_DISTR distribution, + double (*cdf)(int, const struct unur_distr*)); +int unur_distr_discr_set_pv(UNUR_DISTR distribution, const double* pv, int n_pv); +int unur_distr_discr_set_pmfparams(UNUR_DISTR distribution, const double* params, int n_params); +int unur_distr_discr_set_domain(UNUR_DISTR distribution, int left, int right); +int unur_distr_discr_set_pmfsum(UNUR_DISTR distribution, double sum); +int unur_distr_discr_make_pv(UNUR_DISTR distribution); + +UNUR_PAR unur_arou_new(const UNUR_DISTR distribution); +UNUR_PAR unur_tdr_new(const UNUR_DISTR distribution); +UNUR_PAR unur_hinv_new(const UNUR_DISTR distribution); +UNUR_PAR unur_pinv_new(const UNUR_DISTR distribution); +UNUR_PAR unur_ninv_new(const UNUR_DISTR distribution); +UNUR_PAR unur_dgt_new(const UNUR_DISTR distribution); + +UNUR_GEN unur_init(UNUR_PAR parameters); + +UNUR_URNG unur_urng_new(double (*sampleunif)(void *state), void *state); +UNUR_URNG unur_set_default_urng(UNUR_URNG urng_new); +UNUR_URNG unur_set_default_urng_aux(UNUR_URNG urng_new); +void unur_urng_free(UNUR_URNG urng); + +double unur_sample_cont(UNUR_GEN generator); +int unur_sample_discr(UNUR_GEN generator); +int unur_sample_vec(UNUR_GEN generator, double* vector); + +double unur_quantile(UNUR_GEN generator, double U); + +void unur_free(UNUR_GEN generator); +void unur_distr_free(UNUR_DISTR distribution); +void unur_par_free(UNUR_PAR par); + +const char* unur_get_strerror(const int errnocode); +int unur_get_errno(void); +const char* unur_gen_info(UNUR_GEN generator, int help); diff --git a/src/pysatl_core/sampling/unuran/core/__init__.py b/src/pysatl_core/sampling/unuran/core/__init__.py new file mode 100644 index 0000000..0564a13 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/__init__.py @@ -0,0 +1,20 @@ +"""Core UNU.RAN sampler and sampling strategy implementations. + +Exposes :class:`DefaultUnuranSampler`, which wraps the UNU.RAN C library via +CFFI and automatically selects a sampling method (PINV, NINV, DGT, …) based on +the available distribution characteristics, and +:class:`DefaultUnuranSamplingStrategy`, the high-level strategy that integrates +the sampler with the PySATL distribution protocol. +""" + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from .unuran_sampler import DefaultUnuranSampler +from .unuran_sampling_strategy import DefaultUnuranSamplingStrategy + +__all__ = [ + "DefaultUnuranSampler", + "DefaultUnuranSamplingStrategy", +] diff --git a/src/pysatl_core/sampling/unuran/core/_unuran_sampler/__init__.py b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/__init__.py new file mode 100644 index 0000000..041ea31 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/__init__.py @@ -0,0 +1,28 @@ +""" +PySATL Core — UNU.RAN Sampler Internals +========================================= + +Internal sub-package that wires together the two building blocks of the +UNU.RAN sampler: + +- ``UnuranSamplerInitializer`` — creates the UNU.RAN distribution object, + registers distribution callbacks (PDF, CDF, PMF, …) and initialises + the generator for a pre-selected sampling method (PINV, NINV, DGT, …). +- ``ensure_default_urng`` — registers a NumPy-backed uniform RNG as the + UNU.RAN default URNG so that seeding and reproducibility work through + the standard NumPy interface. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from .initialization import UnuranSamplerInitializer +from .urng import ensure_default_urng + +__all__ = [ + "UnuranSamplerInitializer", + "ensure_default_urng", +] diff --git a/src/pysatl_core/sampling/unuran/core/_unuran_sampler/callbacks.py b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/callbacks.py new file mode 100644 index 0000000..aec3b70 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/callbacks.py @@ -0,0 +1,189 @@ +""" +Callback creation utilities for UNU.RAN sampler bindings. + +Provides callback function creation for PDF/PMF evaluation needed during +UNU.RAN distribution setup. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from pysatl_core.types import CharacteristicName, Kind + +if TYPE_CHECKING: + from pysatl_core.types import Method + +_CONT_SIG = "double(double, const struct unur_distr*)" +_DISCR_SIG = "double(int, const struct unur_distr*)" + + +class UnuranCallback: + """ + Factory and registry for CFFI callbacks wired into a UNU.RAN distribution object. + + Creates closures over distribution characteristic functions (PDF, dPDF, CDF, + PPF, PMF) and registers them with the corresponding UNU.RAN setter. All live + callback objects are kept in an internal list so the garbage collector cannot + reclaim them while the UNU.RAN generator is alive. + """ + + def __init__( + self, + unuran_distr: Any, + kind: Kind, + lib: Any, + ffi: Any, + characteristics: Mapping[CharacteristicName, Method[Any, Any]], + ) -> None: + """ + Parameters + ---------- + unuran_distr : Any + CFFI pointer to the UNU.RAN distribution object. + kind : Kind + Whether the distribution is continuous or discrete. + lib : Any + CFFI library handle. + ffi : Any + CFFI FFI instance used to create callbacks. + characteristics : Mapping[CharacteristicName, Method[Any, Any]] + Map of available characteristic names to their callables. + """ + self._unuran_distr = unuran_distr + self._kind = kind + self._lib = lib + self._ffi = ffi + self._characteristics = dict(characteristics) + self._callbacks: list[Any] = [] + + @property + def callbacks(self) -> list[Any]: + """Live CFFI callback objects that must remain referenced for UNU.RAN.""" + return self._callbacks + + def _create_callback(self, char_name: CharacteristicName, signature: str) -> Any | None: + """ + Create a CFFI callback for the given characteristic and UNU.RAN signature. + + The wrapper always converts both input and output to ``float``, which is + safe for both ``double`` (no-op) and ``int`` (widens) argument types. + + Parameters + ---------- + char_name: + Characteristic to look up in the distribution. + signature: + CFFI function type string, e.g. ``"double(double, const struct unur_distr*)"``. + + Returns + ------- + CFFI callback or None + None if the characteristic is not available. + """ + func = self._characteristics.get(char_name) + if func is None: + return None + + def cb(x: Any, _: Any) -> float: + return float(func(float(x))) + + return self._ffi.callback(signature, cb) + + def setup_callback(self, func: Any | None, cffi_func: Any, error_text: str) -> None: + """ + Register a single CFFI callback with its UNU.RAN setter. + + Parameters + ---------- + func : Any or None + CFFI callback to register. Skipped silently when ``None``. + cffi_func : Any + UNU.RAN setter function (e.g. ``unur_distr_cont_set_pdf``). + error_text : str + Message template passed to :class:`RuntimeError` on failure; must + contain one ``{}`` placeholder for the error code. + + Raises + ------ + RuntimeError + If the setter returns a non-zero error code. + """ + if func: + self._callbacks.append(func) + result = cffi_func(self._unuran_distr, func) + if result != 0: + raise RuntimeError(error_text.format(result)) + + def setup_continuous_callbacks(self) -> None: + """ + Set up callbacks for continuous distributions. + + Configures PDF, dPDF (if available), CDF, and PPF callbacks for the UNURAN + continuous distribution object. + + Raises + ------ + RuntimeError + If setting any callback fails (non-zero return code). + + Notes + ----- + All created callbacks are appended to ``_callbacks`` list to prevent + garbage collection. Only available callbacks are set (missing + characteristics are skipped). + """ + self.setup_callback( + self._create_callback(CharacteristicName.PDF, _CONT_SIG), + self._lib.unur_distr_cont_set_pdf, + "Failed to set PDF callback (error code: {})", + ) + self.setup_callback( + self._create_callback(CharacteristicName.DPDF, _CONT_SIG), + self._lib.unur_distr_cont_set_dpdf, + "Failed to set dPDF callback (error code: {})", + ) + self.setup_callback( + self._create_callback(CharacteristicName.CDF, _CONT_SIG), + self._lib.unur_distr_cont_set_cdf, + "Failed to set CDF callback (error code: {})", + ) + self.setup_callback( + self._create_callback(CharacteristicName.PPF, _CONT_SIG), + self._lib.unur_distr_cont_set_invcdf, + "Failed to set PPF callback (error code: {})", + ) + + def setup_discrete_callbacks(self) -> None: + """ + Set up callbacks for discrete distributions. + + Configures PMF and CDF callbacks for the UNURAN discrete distribution + object. + + Raises + ------ + RuntimeError + If setting any callback fails (non-zero return code). + + Notes + ----- + All created callbacks are appended to ``_callbacks`` list to prevent + garbage collection. Only available callbacks are set (missing + characteristics are skipped). + """ + self.setup_callback( + self._create_callback(CharacteristicName.PMF, _DISCR_SIG), + self._lib.unur_distr_discr_set_pmf, + "Failed to set PMF callback (error code: {})", + ) + self.setup_callback( + self._create_callback(CharacteristicName.CDF, _DISCR_SIG), + self._lib.unur_distr_discr_set_cdf, + "Failed to set CDF callback (error code: {})", + ) diff --git a/src/pysatl_core/sampling/unuran/core/_unuran_sampler/dgt.py b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/dgt.py new file mode 100644 index 0000000..46c3446 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/dgt.py @@ -0,0 +1,193 @@ +""" +DGT (Discrete Guide Table) setup utilities for UNU.RAN bindings. + +Encapsulates domain inference, PMF normalization, and probability-vector +construction needed when configuring the UNU.RAN DGT method. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, cast + +import numpy as np + +from pysatl_core.distributions.support import ( + ExplicitTableDiscreteSupport, + IntegerLatticeDiscreteSupport, +) +from pysatl_core.sampling.unuran.core._unuran_sampler.utils import get_unuran_error_message +from pysatl_core.types import CharacteristicName + +if TYPE_CHECKING: + from pysatl_core.distributions.distribution import Distribution + from pysatl_core.sampling.unuran.core._unuran_sampler.domain import UnuranDomain + + +class DGTSetup: + """ + Helper that orchestrates UNU.RAN DGT (Discrete Generation Table) preparation: + stores CFFI handles, verifies required callbacks, and derives domain/probability + vectors for discrete distributions before sampler construction. + """ + + def __init__( + self, + lib: Any, + ffi: Any, + domain: UnuranDomain, + unuran_distr: Any, + distr: Distribution, + ) -> None: + self._lib = lib + self._ffi = ffi + self._domain = domain + self._unuran_distr = unuran_distr + self._distr = distr + + def _require_attr(self, attr_name: str) -> None: + """ + Assert that a required UNU.RAN CFFI function is present on the library handle. + + Parameters + ---------- + attr_name : str + Name of the required function on ``self._lib``. + + Raises + ------ + RuntimeError + If the attribute is absent, indicating the CFFI module needs recompilation. + """ + if not hasattr(self._lib, attr_name): + raise RuntimeError( + f"{attr_name} is not available. Please recompile CFFI module: poetry build" + ) + + def _domain_points(self, domain_left: int, domain_right: int) -> Iterable[int]: + """ + Return an iterable of the support points within [domain_left, domain_right]. + + Avoids evaluating the PMF at integers that lie outside the actual support: + + - ``ExplicitTableDiscreteSupport``: binary-search to slice the stored + sorted point array, skipping any gaps entirely. + - ``IntegerLatticeDiscreteSupport`` with modulus > 1: align the start + to the first lattice point ≥ domain_left and step by the modulus. + - All other cases: dense integer range (step = 1). + """ + support = self._distr.support + + if isinstance(support, ExplicitTableDiscreteSupport): + pts = support.points + lo = int(np.searchsorted(pts, domain_left, side="left")) + hi = int(np.searchsorted(pts, domain_right, side="right")) + return (int(p) for p in pts[lo:hi]) + + if isinstance(support, IntegerLatticeDiscreteSupport) and support.modulus > 1: + step = support.modulus + offset = (domain_left - support.residue) % step + start = domain_left if offset == 0 else domain_left + (step - offset) + return range(start, domain_right + 1, step) + + return range(domain_left, domain_right + 1) + + def _calculate_pmf_sum(self, domain_left: int, domain_right: int) -> float: + """ + Calculate the sum of PMF over the specified domain. + + Parameters + ---------- + domain_left : int + Left boundary of domain. + domain_right : int + Right boundary of domain. + + Returns + ------- + float + Sum of PMF values over the domain. + """ + try: + pmf_func = self._distr.query_method(CharacteristicName.PMF) + except RuntimeError as exc: + raise RuntimeError("PMF is unavailable but _calculate_pmf_sum was called") from exc + + # TODO: use NumPy vectorised evaluation instead of scalar loop + total = 0.0 + for k in self._domain_points(domain_left, domain_right): + try: + p = float(pmf_func(float(k))) + if p >= 0 and not (np.isnan(p) or np.isinf(p)): + total += p + except (ValueError, TypeError): + continue + + return total + + def setup_dgt_method(self) -> None: + """ + Set up DGT method specific requirements (domain and probability vector). + + The DGT (Discrete Generation Table) method requires: + 1. A domain to be set for the discrete distribution + 2. A probability vector (PV) to be created from the PMF + + Raises + ------ + RuntimeError + If required CFFI functions are not available, if domain setting fails, + or if PV creation fails. + + Notes + ----- + - Domain is determined from distribution support if available + - If support is unbounded or unavailable, domain is determined by + evaluating PMF until cumulative probability exceeds threshold (0.9999) + - PMF sum is calculated from the domain if possible, otherwise defaults to 1.0 + - This method should only be called for discrete distributions using + the DGT method + """ + self._require_attr("unur_distr_discr_set_domain") + self._require_attr("unur_distr_discr_make_pv") + + domain_info = self._domain.determine_discrete_domain() + + if domain_info is None or domain_info[0] is None or domain_info[1] is None: + raise RuntimeError( + "Failed to determine domain for discrete distribution. " + "Ensure the distribution has a bounded integer-valued support." + ) + + domain_left, domain_right = domain_info + domain_right = cast(int, domain_right) + + result = self._lib.unur_distr_discr_set_domain( + self._unuran_distr, domain_left, domain_right + ) + if result != 0: + raise RuntimeError( + f"Failed to set domain for discrete distribution (error code: {result}). " + f"Tried domain [{domain_left}, {domain_right}]" + ) + + pmf_sum = self._calculate_pmf_sum(domain_left, domain_right) + if hasattr(self._lib, "unur_distr_discr_set_pmfsum"): + self._lib.unur_distr_discr_set_pmfsum(self._unuran_distr, pmf_sum) + + pv_length = self._lib.unur_distr_discr_make_pv(self._unuran_distr) + if pv_length <= 0: + error_msg = ( + f"Failed to create PV from PMF (returned length: {pv_length}). " + "DGT method requires PV. " + "The PMF might not be normalized or domain is too large. " + f"Domain was set to [{domain_left}, {domain_right}], " + f"PMF sum calculated as {pmf_sum:.6f}. " + "Try setting a smaller domain or providing PV directly." + ) + full_error_msg = get_unuran_error_message(self._lib, self._ffi, error_msg) + raise RuntimeError(full_error_msg) diff --git a/src/pysatl_core/sampling/unuran/core/_unuran_sampler/domain.py b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/domain.py new file mode 100644 index 0000000..896d727 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/domain.py @@ -0,0 +1,105 @@ +""" +Domain inference helpers for UNU.RAN distributions. + +Provides utilities to derive discrete domains and continuous bounds +from distribution metadata needed during UNU.RAN setup. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from typing import TYPE_CHECKING + +import numpy as np + +from pysatl_core.distributions.support import ( + ExplicitTableDiscreteSupport, + IntegerLatticeDiscreteSupport, +) + +if TYPE_CHECKING: + from pysatl_core.distributions.distribution import Distribution + + +class UnuranDomain: + """ + Derives domain boundaries from a distribution's support for UNU.RAN setup. + + Wraps the distribution's support object and exposes two helpers: + one for discrete domains (integer bounds) and one for continuous domains + (float bounds). + """ + + def __init__(self, distr: Distribution) -> None: + """ + Parameters + ---------- + distr : Distribution + Distribution whose ``support`` attribute will be inspected. + """ + self._distr = distr + + def determine_discrete_domain(self) -> tuple[int, int | None] | None: + """ + Determine domain boundaries from distribution support if available. + + Returns + ------- + tuple[int, int | None] or None + Domain as (left, right) if support is bounded, or (left, None) if only + left boundary is known, or None if support is unavailable/unbounded. + """ + support = self._distr.support + if support is None: + return None + + if isinstance(support, ExplicitTableDiscreteSupport): + points = support.points + if points.size == 0: + return None + + if not np.issubdtype(points.dtype, np.integer): + return None + + left = int(points[0]) + right = int(points[-1]) + return left, right + + if isinstance(support, IntegerLatticeDiscreteSupport): + first = support.first() + last = support.last() + + if first is not None and last is not None: + return first, last + + if first is not None: + return first, None + + return None + + return None + + def determine_continuous_domain(self) -> tuple[float, float] | None: + """ + Determine continuous-domain bounds if the distribution exposes them. + + Returns + ------- + tuple[float, float] or None + Returns (left, right) bounds when both edges are numeric, otherwise + None when support is missing or incomplete. + """ + support = getattr(self._distr, "support", None) + if support is None: + return None + + left = getattr(support, "left", None) + right = getattr(support, "right", None) + + if isinstance(left, int | float) and isinstance(right, int | float): + return float(left), float(right) + + return None diff --git a/src/pysatl_core/sampling/unuran/core/_unuran_sampler/initialization.py b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/initialization.py new file mode 100644 index 0000000..c4a00a4 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/initialization.py @@ -0,0 +1,272 @@ +""" +Initialization helpers for UNU.RAN sampler bindings. + +Provides the orchestration logic that builds UNURAN distribution objects, +sets up callbacks/domains, and initializes the generator according to the +selected method for a given distribution. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +import contextlib +import math +from typing import TYPE_CHECKING, Any + +from pysatl_core.distributions.distribution import Distribution +from pysatl_core.sampling.unuran.api import UnuranMethod +from pysatl_core.sampling.unuran.core._unuran_sampler.callbacks import UnuranCallback +from pysatl_core.sampling.unuran.core._unuran_sampler.dgt import DGTSetup +from pysatl_core.sampling.unuran.core._unuran_sampler.domain import UnuranDomain +from pysatl_core.sampling.unuran.core._unuran_sampler.utils import get_unuran_error_message +from pysatl_core.sampling.unuran.core.method_requirements import METHOD_CHARACTERISTIC_REQUIREMENTS +from pysatl_core.types import CharacteristicName, EuclideanDistributionType, Kind + +if TYPE_CHECKING: + from pysatl_core.types import Method + + +class UnuranSamplerInitializer: + def __init__(self, distr: Distribution, method: UnuranMethod, lib: Any, ffi: Any): + self._method = method + self._distr = distr + self._lib = lib + self._ffi = ffi + self._unuran_gen: Any | None = None + self._unuran_distr: Any | None = None + self._unuran_par: Any | None = None + self._domain = UnuranDomain(self._distr) + + distr_type = distr.distribution_type + if not isinstance(distr_type, EuclideanDistributionType): + raise RuntimeError( + f"Unsupported distribution type: {distr_type}. " + "Only Euclidean distribution types are supported." + ) + self._kind = distr_type.kind + + if self._kind not in (Kind.CONTINUOUS, Kind.DISCRETE): + raise ValueError(f"Unsupported distribution kind: {self._kind}") + + def _create_unuran_distribution(self) -> None: + """ + Create UNURAN distribution object. + + Raises + ------ + RuntimeError + If the distribution object creation fails (returns NULL pointer). + + Notes + ----- + Creates either a continuous or discrete UNURAN distribution object + based on ``_kind``. The created object is stored in + ``_unuran_distr`` attribute. + """ + match self._kind: + case Kind.CONTINUOUS: + self._unuran_distr = self._lib.unur_distr_cont_new() + case Kind.DISCRETE: + self._unuran_distr = self._lib.unur_distr_discr_new() + case _: + raise RuntimeError(f"Unsupported distribution kind: {self._kind}") + + if self._unuran_distr == self._ffi.NULL: + raise RuntimeError("Failed to create UNURAN distribution object") + + def _create_parameter_object(self) -> Any: + """ + Create UNURAN parameter object based on selected method. + + Returns + ------- + CFFI pointer + Pointer to the created UNURAN parameter object, or NULL pointer + if creation fails. + + Raises + ------ + ValueError + If the method is SROU (not available in CFFI bindings) or if the + method is unsupported. + + Notes + ----- + Maps UNURAN methods to their corresponding CFFI creation functions: + - AROU: ``unur_arou_new()`` + - TDR: ``unur_tdr_new()`` + - HINV: ``unur_hinv_new()`` + - PINV: ``unur_pinv_new()`` + - NINV: ``unur_ninv_new()`` + - DGT: ``unur_dgt_new()`` + + Note that AROU and TDR require dPDF, which may not be available. + """ + method = self._method + lib = self._lib + match method: + case UnuranMethod.AROU: + return lib.unur_arou_new(self._unuran_distr) + case UnuranMethod.TDR: + return lib.unur_tdr_new(self._unuran_distr) + case UnuranMethod.HINV: + return lib.unur_hinv_new(self._unuran_distr) + case UnuranMethod.PINV: + return lib.unur_pinv_new(self._unuran_distr) + case UnuranMethod.NINV: + return lib.unur_ninv_new(self._unuran_distr) + case UnuranMethod.DGT: + return lib.unur_dgt_new(self._unuran_distr) + case _: + raise ValueError(f"Unsupported UNURAN method: {method}") + + def _create_and_init_generator(self) -> Any: + """ + Create UNURAN parameter object and initialize the generator. + + Raises + ------ + RuntimeError + If parameter object creation fails or generator initialization fails. + + Returns + ------- + Any + Pointer to the initialized UNURAN generator. + + Notes + ----- + The parameter object is created based on the selected method (PINV, HINV, + DGT, etc.). After successful initialization, the parameter object is + automatically destroyed by UNURAN, so it should not be freed manually. + + The initialized generator is stored in ``_unuran_gen`` attribute. + """ + self._unuran_par = self._create_parameter_object() + if self._unuran_par == self._ffi.NULL: + error_msg = get_unuran_error_message( + self._lib, self._ffi, "Failed to create UNURAN parameter object" + ) + raise RuntimeError(error_msg) + + unuran_gen = self._lib.unur_init(self._unuran_par) + if unuran_gen == self._ffi.NULL: + error_msg = get_unuran_error_message( + self._lib, self._ffi, "Failed to initialize UNURAN generator" + ) + raise RuntimeError(error_msg) + self._unuran_gen = unuran_gen + return unuran_gen + + def _requires_finite_support(self) -> bool: + """Return whether the selected UNURAN method mandates bounded support.""" + requirements = METHOD_CHARACTERISTIC_REQUIREMENTS.get(self._method) + return bool(requirements and requirements.requires_support) + + def _apply_continuous_domain_constraints(self) -> None: + """Validate and register finite continuous bounds when the method requires them.""" + if self._kind != Kind.CONTINUOUS or not self._requires_finite_support(): + return + + bounds = self._domain.determine_continuous_domain() + if bounds is None: + raise RuntimeError( + f"UNURAN method '{self._method.value}' requires finite support bounds, " + "but distribution.support is missing or incomplete." + ) + + left, right = bounds + if not (math.isfinite(left) and math.isfinite(right)): + raise RuntimeError( + f"UNURAN method '{self._method.value}' requires finite support bounds, " + f"got left={left}, right={right}." + ) + + if left >= right: + raise RuntimeError( + f"Invalid support bounds for method '{self._method.value}': " + f"left={left}, right={right}" + ) + + result = self._lib.unur_distr_cont_set_domain(self._unuran_distr, left, right) + if result != 0: + raise RuntimeError( + f"Failed to set continuous domain [{left}, {right}] " + f"for method '{self._method.value}' (error code: {result})." + ) + + def cleanup(self) -> None: + """Release UNURAN generator, parameter, and distribution handles in order.""" + gen_freed = False + if self._unuran_gen and self._unuran_gen is not None: + if self._unuran_gen != self._ffi.NULL: + with contextlib.suppress(Exception): + self._lib.unur_free(self._unuran_gen) + gen_freed = True + self._unuran_gen = None + + if self._unuran_par and self._unuran_par is not None: + if not gen_freed and self._unuran_par != self._ffi.NULL: + with contextlib.suppress(Exception): + self._lib.unur_par_free(self._unuran_par) + self._unuran_par = None + + if self._unuran_distr and self._unuran_distr is not None: + if self._unuran_distr != self._ffi.NULL: + with contextlib.suppress(Exception): + self._lib.unur_distr_free(self._unuran_distr) + self._unuran_distr = None + + def initialize_unuran_components( + self, available_chars: set[CharacteristicName] + ) -> tuple[Any | None, Any | None, Any | None, list[Any]]: + """ + Initialize all UNURAN components for the sampler. + + Parameters + ---------- + available_chars : set[CharacteristicName] + Characteristics resolved for the distribution (analytical + graph-derived). + + Returns + ------- + tuple[Any | None, Any | None, Any | None, list[Any]] + ``(unuran_distr, unuran_par, unuran_gen, callbacks)`` where callbacks keep + the CFFI handles alive for UNURAN. + """ + # TODO seed support + self._create_unuran_distribution() + + distr_characteristic: dict[CharacteristicName, Method[Any, Any]] = { + characteristic: self._distr.query_method(characteristic) + for characteristic in available_chars + } + + callbacks = UnuranCallback( + self._unuran_distr, self._kind, self._lib, self._ffi, distr_characteristic + ) + self._apply_continuous_domain_constraints() + + try: + match self._kind: + case Kind.CONTINUOUS: + callbacks.setup_continuous_callbacks() + case Kind.DISCRETE: + callbacks.setup_discrete_callbacks() + if self._method == UnuranMethod.DGT: + dgt_setup = DGTSetup( + self._lib, self._ffi, self._domain, self._unuran_distr, self._distr + ) + dgt_setup.setup_dgt_method() + case _: + raise RuntimeError(f"Unsupported distribution kind: {self._kind}") + + generator = self._create_and_init_generator() + except Exception: + self.cleanup() + raise + + return self._unuran_distr, self._unuran_par, generator, callbacks.callbacks diff --git a/src/pysatl_core/sampling/unuran/core/_unuran_sampler/urng.py b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/urng.py new file mode 100644 index 0000000..39ed6f1 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/urng.py @@ -0,0 +1,87 @@ +""" +Helper utilities that bridge UNU.RAN's URNG interface with NumPy's RNG. + +UNU.RAN expects a default uniform RNG to be configured globally. The vendored +library aborts if ``unur_get_default_urng()`` is called before a generator is +registered. This module exposes :func:`ensure_default_urng` that lazily creates +and registers a URNG backed by ``numpy.random.default_rng``. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +__all__ = ["ensure_default_urng"] + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, cast + +import numpy as np + +from pysatl_core.sampling.unuran.bindings import _unuran_cffi + +if TYPE_CHECKING: + from numpy.random import Generator + +Callback = Callable[[Any], float] + +_DEFAULT_URNG: Any | None = None +_DEFAULT_CALLBACK: Callback | None = None +_DEFAULT_STATE: Any | None = None + + +def ensure_default_urng() -> None: + """ + Lazily configure the global UNU.RAN URNG backed by NumPy's default RNG. + + Creates a CFFI callback that wraps ``numpy.random.default_rng().random()`` + and registers it as both the primary and auxiliary UNU.RAN URNG via + ``unur_set_default_urng`` and ``unur_set_default_urng_aux``. Subsequent + calls are no-ops once the URNG has been registered. + + Raises + ------ + RuntimeError + If the CFFI bindings are unavailable or if UNU.RAN fails to create + the URNG object. + """ + + global _DEFAULT_URNG, _DEFAULT_CALLBACK, _DEFAULT_STATE + + if _DEFAULT_URNG is not None: + return + + if _unuran_cffi is None: + raise RuntimeError( + "UNURAN CFFI bindings are not available. " + "Please build them via `python -m pysatl_core.sampling.unuran.bindings._cffi_build`." + ) + + ffi = _unuran_cffi.ffi + lib = _unuran_cffi.lib + + rng = np.random.default_rng() + state_handle = ffi.new_handle(rng) + + callback_decorator = cast( + Callable[[Callback], Callback], + ffi.callback("double(void *)"), + ) + + @callback_decorator + def _sample_uniform(state: Any) -> float: + generator = cast("Generator", ffi.from_handle(state)) + return float(generator.random()) + + urng = lib.unur_urng_new(_sample_uniform, state_handle) + if urng == ffi.NULL: + raise RuntimeError("Failed to create UNU.RAN default URNG") + + lib.unur_set_default_urng(urng) + lib.unur_set_default_urng_aux(urng) + + _DEFAULT_URNG = urng + _DEFAULT_CALLBACK = _sample_uniform + _DEFAULT_STATE = state_handle diff --git a/src/pysatl_core/sampling/unuran/core/_unuran_sampler/utils.py b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/utils.py new file mode 100644 index 0000000..32e43d5 --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/_unuran_sampler/utils.py @@ -0,0 +1,41 @@ +""" +Utility helpers for UNU.RAN error reporting. + +Provides a single helper that queries the UNU.RAN errno and formats a +human-readable error message including the library's own error string. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from typing import Any + + +def get_unuran_error_message(lib: Any, ffi: Any, base_msg: str) -> str: + """ + Format a UNU.RAN error message with errno and the library error string. + + Parameters + ---------- + lib : Any + CFFI library handle exposing ``unur_get_errno`` and ``unur_get_strerror``. + ffi : Any + CFFI FFI instance used to decode the C string. + base_msg : str + Base error message to prepend. + + Returns + ------- + str + Formatted error message of the form ``" (errno: N): "``, + where the strerror suffix is omitted when errno is zero. + """ + errno = lib.unur_get_errno() + error_str = lib.unur_get_strerror(errno) if errno != 0 else None + error_msg = f"{base_msg} (errno: {errno})" + if error_str: + error_msg += f": {ffi.string(error_str).decode('utf-8')}" + return error_msg diff --git a/src/pysatl_core/sampling/unuran/core/method_requirements.py b/src/pysatl_core/sampling/unuran/core/method_requirements.py new file mode 100644 index 0000000..c0e886d --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/method_requirements.py @@ -0,0 +1,59 @@ +"""Mapping UNU.RAN methods to the distribution characteristics they require.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Final + +from pysatl_core.sampling.unuran.api import UnuranMethod +from pysatl_core.types import CharacteristicName + + +@dataclass(frozen=True, slots=True) +class MethodCharacteristics: + """ + Describe which analytical characteristics and distribution properties a UNU.RAN + method depends on. + + requires_support indicates the method needs a finite, known support (e.g., HINV + requires a right boundary). + """ + + required: frozenset[CharacteristicName] + optional: frozenset[CharacteristicName] = frozenset() + requires_support: bool = False + + def __str__(self) -> str: + return f""" + Mandatory requirements: {self.required}. + Optional requirements: {self.optional}. + Requires support: {self.requires_support}. + """ + + +METHOD_CHARACTERISTIC_REQUIREMENTS: Final[Mapping[UnuranMethod, MethodCharacteristics]] = { + UnuranMethod.AROU: MethodCharacteristics( + required=frozenset({CharacteristicName.PDF, CharacteristicName.DPDF}) + ), + UnuranMethod.TDR: MethodCharacteristics( + required=frozenset({CharacteristicName.PDF, CharacteristicName.DPDF}) + ), + UnuranMethod.PINV: MethodCharacteristics( + required=frozenset({CharacteristicName.PDF}), + optional=frozenset({CharacteristicName.CDF, CharacteristicName.PPF}), + ), + UnuranMethod.HINV: MethodCharacteristics( + required=frozenset({CharacteristicName.PPF}), + optional=frozenset({CharacteristicName.CDF}), + requires_support=True, + ), + UnuranMethod.NINV: MethodCharacteristics( + required=frozenset({CharacteristicName.CDF}), + optional=frozenset({CharacteristicName.PDF}), + ), + UnuranMethod.DGT: MethodCharacteristics( + required=frozenset({CharacteristicName.PMF}), + requires_support=True, + ), +} diff --git a/src/pysatl_core/sampling/unuran/core/unuran_sampler.py b/src/pysatl_core/sampling/unuran/core/unuran_sampler.py new file mode 100644 index 0000000..647ae0d --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/unuran_sampler.py @@ -0,0 +1,373 @@ +""" +UNU.RAN Default Sampler +======================= + +This module provides the default UNU.RAN sampler implementation that uses +the UNU.RAN library for efficient random variate generation from probability +distributions. The sampler automatically selects appropriate sampling methods +based on available distribution characteristics. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +import contextlib +from typing import TYPE_CHECKING, Any, cast + +import numpy as np +import numpy.typing as npt + +from pysatl_core.distributions.strategies import DefaultSamplingUnivariateStrategy +from pysatl_core.sampling.unuran.api import UnuranMethod, UnuranMethodConfig +from pysatl_core.sampling.unuran.core._unuran_sampler import ( + UnuranSamplerInitializer, + ensure_default_urng, +) +from pysatl_core.sampling.unuran.core.method_requirements import METHOD_CHARACTERISTIC_REQUIREMENTS +from pysatl_core.types import ( + CharacteristicName, + EuclideanDistributionType, + Kind, +) + +if TYPE_CHECKING: + from pysatl_core.distributions.distribution import Distribution + + +class DefaultUnuranSampler: + """ + Default UNU.RAN sampler implementation. + + This sampler provides a default implementation for generating random variates + from probability distributions using the UNU.RAN library. It automatically + selects appropriate sampling methods based on available distribution + characteristics (PDF, CDF, PPF, PMF). + + The sampler supports both continuous and discrete univariate distributions + and uses various UNU.RAN methods (PINV, HINV, NINV, DGT, etc.) depending on + the available characteristics and configuration. + + Attributes + ---------- + distr : Distribution + The probability distribution to sample from. + config : UnuranMethodConfig + Configuration for method selection and parameters. + method : UnuranMethod + The sampling method currently used by this sampler. + + Notes + ----- + - Only univariate Euclidean distributions are supported + - The sampler automatically selects the best method if ``method=AUTO`` + - Callbacks are kept alive to prevent garbage collection issues with CFFI + - Resources are automatically cleaned up when the object is deleted + """ + + def __init__(self, distr: Distribution, config: UnuranMethodConfig | None = None): + """ + Initialize the UNU.RAN sampler. + + Parameters + ---------- + distr : Distribution + The probability distribution to sample from. Must be a univariate + Euclidean distribution (dimension=1). + config : UnuranMethodConfig, optional + Configuration for method selection and parameters. If None, uses + default configuration. + + Raises + ------ + RuntimeError + If the distribution type is not supported (not Euclidean or + dimension != 1), or if initialization fails. + + Notes + ----- + The initialization process: + 1. Validates distribution type and dimension + 2. Determines available characteristics + 3. Selects appropriate sampling method (if AUTO) + 4. Creates UNURAN distribution object + 5. Sets up callbacks for available characteristics + 6. Initializes the UNURAN generator + """ + self._distr = distr + self._config = config or UnuranMethodConfig() + method = self._config.method + + from pysatl_core.sampling.unuran.bindings import _unuran_cffi + + if _unuran_cffi is None: + raise RuntimeError("UNURAN CFFI bindings are not available. ") + + ensure_default_urng() + + self._ffi: Any = _unuran_cffi.ffi + self._lib: Any = _unuran_cffi.lib + + distr_type = distr.distribution_type + if not isinstance(distr_type, EuclideanDistributionType): + raise RuntimeError( + f"Unsupported distribution type: {distr_type}. " + "Only univariate Euclidean distributions are supported." + ) + + if distr_type.dimension != 1: + raise RuntimeError( + f"Unsupported distribution dimension: {distr_type.dimension}. " + "Only univariate (dimension=1) distributions are supported." + ) + + self._kind = distr_type.kind + + self.available_chars: set[CharacteristicName] = self._resolve_available_chars( + distr, self._config.use_registry_characteristics + ) + + if method == UnuranMethod.AUTO: + method = self._select_best_method(self.available_chars, self._kind, self._config) + + self._method: UnuranMethod = method + self._check_method_suitability() + self._cleaned_up: bool = False + self._fallback_strategy: DefaultSamplingUnivariateStrategy | None = None + self._unuran_distr: Any | None = None + self._unuran_par: Any | None = None + self._unuran_gen: Any | None = None + self._callbacks: list[Any] = [] + + self._initializer = UnuranSamplerInitializer(self._distr, method, self._lib, self._ffi) + try: + result = self._initializer.initialize_unuran_components(self.available_chars) + except RuntimeError: + if not self._config.use_fallback_sampler: + raise + self._fallback_strategy = DefaultSamplingUnivariateStrategy() + return + + self._unuran_distr, self._unuran_par, self._unuran_gen, self._callbacks = result + + def _check_method_suitability(self) -> None: + """Validate that the chosen UNURAN method is supported and has required inputs.""" + if self._method not in METHOD_CHARACTERISTIC_REQUIREMENTS: + raise RuntimeError(f"Unsupported sampling method: {self._method}") + + requirements = METHOD_CHARACTERISTIC_REQUIREMENTS[self._method] + + if not self.available_chars.issuperset(requirements.required): + raise RuntimeError( + f"Method {self._method} requires the following characteristics: " + f"{', '.join(sorted(requirements.required))}. " + f"Available characteristics: {', '.join(sorted(self.available_chars))}" + ) + + # NOTE: Now support is always finite, in future it may be updated + if requirements.requires_support and self._distr.support is None: + raise RuntimeError( + "Method " + f"{self._method} requires a finite support, but the distribution has an " + "infinite support." + ) + + @staticmethod + def _resolve_available_chars( + distr: Distribution, use_registry_characteristics: bool + ) -> set[CharacteristicName]: + """Return the set of UNU.RAN-relevant characteristics available for the distribution. + + Parameters + ---------- + distr : Distribution + The distribution to probe. + use_registry_characteristics : bool + If ``True``, include characteristics derivable via the registry graph + (delegates to ``query_method``). If ``False``, include only those + present in ``analytical_computations``. + + Returns + ------- + set[CharacteristicName] + Subset of UNU.RAN-relevant characteristics that can be resolved. + """ + _unuran_chars = [ + CharacteristicName.PDF, + CharacteristicName.DPDF, + CharacteristicName.CDF, + CharacteristicName.PPF, + CharacteristicName.PMF, + ] + chars: set[CharacteristicName] = set() + if use_registry_characteristics: + for char in _unuran_chars: + try: + distr.query_method(char) + chars.add(char) + except RuntimeError: + pass + else: + for char in _unuran_chars: + if char in distr.analytical_computations: + chars.add(char) + return chars + + def _cleanup(self) -> None: + """Release UNURAN resources held by the initializer.""" + self._initializer.cleanup() + self._cleaned_up = True + + def __del__(self) -> None: + """ + Cleanup on object deletion. + + Automatically calls ``_cleanup()`` when the object is garbage collected. + All exceptions during finalization are silently ignored. + """ + with contextlib.suppress(Exception): + self._cleanup() + + def sample(self, n: int) -> npt.NDArray[np.float64]: + """ + Generate random variates from the distribution. + + Parameters + ---------- + n : int + Number of samples to generate. Must be non-negative. + + Returns + ------- + npt.NDArray[np.float64] + 1D array of shape ``(n,)`` containing the generated samples. + + Raises + ------ + RuntimeError + If the sampler is not initialized. + ValueError + If ``n < 0``. + + Notes + ----- + Uses UNURAN's sampling functions: + - ``unur_sample_cont()`` for continuous distributions + - ``unur_sample_discr()`` for discrete distributions + + Samples are generated sequentially in a loop. For large ``n``, this + may be slower than vectorized operations, but it's necessary due to + UNURAN's C API design. + """ + if n < 0: + raise ValueError(f"Number of samples must be non-negative, got {n}") + + if self._fallback_strategy is not None: + return cast(npt.NDArray[np.float64], self._fallback_strategy.sample(n, self._distr)) + + if self._unuran_gen is None or self._lib is None: + raise RuntimeError("UNURAN sampler is not initialized") + + if n == 0: + return np.empty(0, dtype=np.float64) + + match self._kind: + case Kind.CONTINUOUS: + sample_func_name = "unur_sample_cont" + case Kind.DISCRETE: + sample_func_name = "unur_sample_discr" + case _: + raise RuntimeError(f"Unsupported distribution kind: {self._kind}") + + sample_func = getattr(self._lib, sample_func_name, None) + if sample_func is None: + raise RuntimeError( + f"UNURAN sampler is not initialized (missing {sample_func_name} binding)" + ) + + samples = np.empty(n, dtype=np.float64) + + match self._kind: + case Kind.CONTINUOUS: + samples = np.fromiter( + (sample_func(self._unuran_gen) for _ in range(n)), + dtype=np.float64, + count=n, + ) + case Kind.DISCRETE: + samples = np.fromiter( + (float(sample_func(self._unuran_gen)) for _ in range(n)), + dtype=np.float64, + count=n, + ) + case _: + raise RuntimeError(f"Unsupported distribution kind: {self._kind}") + + return samples + + @staticmethod + def _select_best_method( + available_chars: set[CharacteristicName], + kind: Kind, + config: UnuranMethodConfig, + ) -> UnuranMethod: + """ + Select the best UNU.RAN method based on available characteristics. + + This function implements heuristics for method selection when + ``method=AUTO``. + + Parameters + ---------- + available_chars : set[CharacteristicName] + Set of available characteristic names. + kind : Kind + Distribution kind (continuous or discrete). + config : UnuranMethodConfig + Method configuration. + + Returns + ------- + UnuranMethod + The selected method. + + Notes + ----- + - If PPF is available and ``use_ppf=True``, prefer PINV or HINV + - If PDF is available, prefer rejection methods (TDR, ARS) + - If CDF is available, prefer numerical inversion (NINV) + - For discrete distributions, prefer discrete-specific methods + """ + match kind: + case Kind.CONTINUOUS: + if CharacteristicName.PPF in available_chars and config.use_ppf: + return UnuranMethod.PINV + elif CharacteristicName.PDF in available_chars and config.use_pdf: + # PINV works with PDF (and optionally CDF/mode) + # AROU and TDR require dPDF which we may not have + return UnuranMethod.PINV + elif CharacteristicName.CDF in available_chars and config.use_cdf: + return UnuranMethod.NINV + elif CharacteristicName.PDF in available_chars: + return UnuranMethod.PINV # PINV works with PDF + else: + raise RuntimeError( + "No suitable method found. Need at least PDF, CDF, or PPF " + "for continuous distributions." + ) + case Kind.DISCRETE: + if CharacteristicName.PMF in available_chars: + return UnuranMethod.DGT + else: + raise RuntimeError( + "No suitable method found. Need at least PMF for discrete distributions." + ) + case _: + raise RuntimeError(f"Unsupported distribution kind: {kind}") + + @property + def method(self) -> UnuranMethod: + """The sampling method used by this sampler.""" + return self._method diff --git a/src/pysatl_core/sampling/unuran/core/unuran_sampling_strategy.py b/src/pysatl_core/sampling/unuran/core/unuran_sampling_strategy.py new file mode 100644 index 0000000..77255df --- /dev/null +++ b/src/pysatl_core/sampling/unuran/core/unuran_sampling_strategy.py @@ -0,0 +1,109 @@ +""" +UNU.RAN Default Sampling Strategy +================================= + +This module provides the default UNU.RAN sampling strategy implementation that +creates UNU.RAN samplers for distributions and converts the output to the +standard Sample format. The strategy supports caching of samplers to improve +performance with repeated sampling from the same distribution. +""" + +from __future__ import annotations + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" + +from typing import TYPE_CHECKING, Any, Final + +from pysatl_core.distributions.strategies import DefaultSamplingUnivariateStrategy +from pysatl_core.sampling.unuran.api import ( + IUnuranSampler, + IUnuranSamplingStrategy, + UnuranMethodConfig, +) +from pysatl_core.sampling.unuran.core.unuran_sampler import DefaultUnuranSampler + +if TYPE_CHECKING: + from pysatl_core.distributions.distribution import Distribution + from pysatl_core.sampling.unuran.api import IUnuranSampler + from pysatl_core.types import NumericArray + + +# NOTE It implies using one SamplingStrategy WITH ONLY ONE Distribution. +# In the future, this limitation will be in the code. +class DefaultUnuranSamplingStrategy(IUnuranSamplingStrategy): + """ + Default UNU.RAN sampling strategy implementation. + + This strategy creates UNU.RAN samplers for distributions and converts + the output to the standard Sample format. + + Notes + ----- + - Supports caching of samplers to improve performance with repeated sampling + """ + + def __init__( + self, + config: UnuranMethodConfig | None = None, + sampler_class: type[IUnuranSampler] | None = None, + ): + """ + Initialize the sampling strategy. + + Parameters + ---------- + config : UnuranMethodConfig | None, optional + Method configuration. If None, uses UnuranMethodConfig() + with default values (AUTO method selection). + sampler_class : Type[IUnuranSampler] | None, optional + Class to use for creating samplers. If None, uses DefaultUnuranSampler. + """ + self._config_value: Final[UnuranMethodConfig] = config or UnuranMethodConfig() + self._sampler_class: type[IUnuranSampler] = sampler_class or DefaultUnuranSampler + self._sampler: IUnuranSampler | None = None + + def sample(self, n: int, distr: Distribution, **options: Any) -> NumericArray: + """ + Generate a sample from the distribution using UNU.RAN. + + Parameters + ---------- + n : int + Number of observations to draw. + distr : Distribution + The distribution to sample from. + **options : Any + Additional options that may override the default configuration: + - ``method``: override the sampling method + - Other method-specific parameters + + Returns + ------- + Sample + A 2D sample of shape ``(n, 1)`` for univariate distributions. + + Raises + ------ + RuntimeError + If the distribution type is not supported, or if UNU.RAN + cannot create a sampler with the available characteristics. + ValueError + If the configuration is invalid. + """ + if n < 0: + raise ValueError(f"Number of samples must be non-negative, got {n}") + + if self._sampler is None: + try: + self._sampler = self._sampler_class(distr, self.config) + except RuntimeError: + return DefaultSamplingUnivariateStrategy().sample(n, distr, **options) + + return self._sampler.sample(n) + + @property + def config(self) -> UnuranMethodConfig: + """Method configuration.""" + return self._config_value diff --git a/src/pysatl_core/types.py b/src/pysatl_core/types.py index c63702d..b98a464 100644 --- a/src/pysatl_core/types.py +++ b/src/pysatl_core/types.py @@ -12,7 +12,10 @@ from dataclasses import dataclass from enum import Enum, StrEnum, auto from math import inf -from typing import Any, cast, overload +from typing import TYPE_CHECKING, Any, cast, overload + +if TYPE_CHECKING: + from pysatl_core.distributions.computation import AnalyticalComputation, FittedComputationMethod import numpy as np from numpy.typing import NDArray @@ -233,6 +236,9 @@ def shape(self) -> ContinuousSupportShape1D: return ContinuousSupportShape1D.BOUNDED_INTERVAL +type Method[In, Out] = AnalyticalComputation[In, Out] | FittedComputationMethod[In, Out] +"""Type alias for a distribution computation method (analytical or fitted).""" + type GenericCharacteristicName = str """Type alias for characteristic names (e.g., 'pdf', 'cdf').""" @@ -258,6 +264,7 @@ class CharacteristicName(StrEnum): """ PDF = "pdf" + DPDF = "dpdf" CDF = "cdf" PPF = "ppf" PMF = "pmf" @@ -295,4 +302,5 @@ class FamilyName(StrEnum): "NumericArray", "CharacteristicName", "FamilyName", + "Method", ] diff --git a/tests/unit/sampling/__init__.py b/tests/unit/sampling/__init__.py new file mode 100644 index 0000000..9f2882f --- /dev/null +++ b/tests/unit/sampling/__init__.py @@ -0,0 +1,11 @@ +""" +Sampling Tests +============== + +Unit tests for the sampling subsystem, covering method selection, +resource lifecycle, and integration with distribution characteristics. +""" + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" diff --git a/tests/unit/sampling/unuran/__init__.py b/tests/unit/sampling/unuran/__init__.py new file mode 100644 index 0000000..264a6cd --- /dev/null +++ b/tests/unit/sampling/unuran/__init__.py @@ -0,0 +1,12 @@ +""" +UNU.RAN Integration Tests +========================= + +Unit tests for the UNU.RAN C library integration, covering CFFI bindings, +sampler initialization, callback setup, domain constraints, and discrete +method configuration. +""" + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" diff --git a/tests/unit/sampling/unuran/bindings/__init__.py b/tests/unit/sampling/unuran/bindings/__init__.py new file mode 100644 index 0000000..6b86c99 --- /dev/null +++ b/tests/unit/sampling/unuran/bindings/__init__.py @@ -0,0 +1,11 @@ +""" +UNU.RAN Bindings Tests +====================== + +Unit tests for the UNU.RAN CFFI bindings layer, covering the build +configuration and the compiled extension availability. +""" + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" diff --git a/tests/unit/sampling/unuran/bindings/test_cffi_build.py b/tests/unit/sampling/unuran/bindings/test_cffi_build.py new file mode 100644 index 0000000..2dfedd0 --- /dev/null +++ b/tests/unit/sampling/unuran/bindings/test_cffi_build.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +import pysatl_core.sampling.unuran.bindings._cffi_build as cffi_build + + +class FFIStub: + def __init__(self) -> None: + self.set_source_calls: list[dict[str, Any]] = [] + self.compile_calls: list[dict[str, Any]] = [] + + def set_source(self, module_name: str, header: str, **kwargs: Any) -> None: + self.set_source_calls.append({"module": module_name, "header": header, **kwargs}) + + def compile(self, verbose: bool = False) -> None: + self.compile_calls.append({"verbose": verbose}) + + +@dataclass +class BuildScenario: + label: str + find_result: dict[str, str | None] + script_exists: bool + expect_run: bool + run_exception: Exception | None = None + expect_exception: type[BaseException] | None = None + + +@dataclass +class MainScenario: + label: str + os_name: str + platform: str + expect_skip: bool = False + find_side_effect: Exception | None = None + build_side_effect: Exception | None = None + compile_side_effect: Exception | None = None + expect_exception: type[BaseException] | None = None + + +class TestCallbacks: + @pytest.mark.parametrize( + "scenario", + [ + { + "label": "pyproject_same_dir", + "chain": ["repo", "pkg"], + "py_index": 0, + "expect_index": 0, + }, + { + "label": "pyproject_parent", + "chain": ["repo", "pkg", "module"], + "py_index": 1, + "expect_index": 1, + }, + { + "label": "pyproject_grandparent", + "chain": ["repo", "pkg", "subpkg", "module"], + "py_index": 0, + "expect_index": 0, + }, + { + "label": "pyproject_deep_chain", + "chain": ["repo", "layer1", "layer2", "layer3", "leaf"], + "py_index": 2, + "expect_index": 2, + }, + { + "label": "missing_pyproject", + "chain": ["repo", "pkg"], + "py_index": -1, + "expect_exception": RuntimeError, + }, + ], + ids=lambda s: s["label"], + ) + def test_get_project_root_traverses_directories( + self, scenario: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ + Exercises _get_project_root over five directory chains (pyproject at current + dir, parent, grandparent, deep ancestor, and missing pyproject edge case), + ensuring the search ascends correctly and raises when no configuration file + exists. + """ + base = tmp_path / scenario["chain"][0] + current = base + dirs = [base] + base.mkdir() + for name in scenario["chain"][1:]: + current = current / name + current.mkdir() + dirs.append(current) + + if scenario.get("py_index", -1) >= 0: + py_dir = dirs[scenario["py_index"]] + (py_dir / "pyproject.toml").write_text("[tool.poetry]") + + target_file = dirs[-1] / "_cffi_build.py" + target_file.write_text("print('dummy')") + monkeypatch.setattr(cffi_build, "__file__", str(target_file)) + + if scenario.get("expect_exception"): + with pytest.raises(scenario["expect_exception"]): + cffi_build._get_project_root() + return + + root = cffi_build._get_project_root() + assert root == dirs[scenario["expect_index"]] + + @pytest.mark.parametrize( + "name, expected", + [ + ("libunuran.so", "unuran"), + ("libspecial.dylib", "special"), + ("libarchive.a", "archive"), + ("libcustom.dll", "custom"), + ("plainfile", "plainfile"), + ], + ) + def test_extract_library_name_handles_various_suffixes(self, name: str, expected: str) -> None: + """ + Validates _extract_library_name with five library filename patterns (Linux + .so, macOS .dylib, static .a, Windows .dll, and no prefix edge case), ensuring + prefix removal and suffix stripping behave consistently. + """ + assert cffi_build._extract_library_name(Path(name)) == expected + + @pytest.mark.parametrize( + "scenario", + [ + {"label": "static_archive", "suffix": ".a", "expected_key": "extra_objects"}, + {"label": "shared_so", "suffix": ".so", "expected_key": "libraries"}, + {"label": "shared_dylib", "suffix": ".dylib", "expected_key": "libraries"}, + {"label": "shared_dll", "suffix": ".dll", "expected_key": "libraries"}, + { + "label": "custom_name", + "suffix": ".so", + "filename": "customlib.so", + "expected_key": "libraries", + }, + ], + ids=lambda s: s["label"], + ) + def test_configure_from_paths_adjusts_ffi_source( + self, scenario: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ + Covers _configure_from_paths across five library types (static archive and + four shared variants including custom names), ensuring static archives route + through extra_objects while shared libraries populate libraries/library_dirs. + """ + ffi_stub = FFIStub() + monkeypatch.setattr(cffi_build, "ffi", ffi_stub) + + include_file = tmp_path / scenario["label"] / "include" / "unuran.h" + library_filename = scenario.get("filename", f"libunuran{scenario['suffix']}") + library_file = tmp_path / scenario["label"] / "lib" / library_filename + include_file.parent.mkdir(parents=True, exist_ok=True) + library_file.parent.mkdir(parents=True, exist_ok=True) + include_file.write_text("") + library_file.write_text("") + + cffi_build._configure_from_paths(include_file, library_file) + assert ffi_stub.set_source_calls, "Expected set_source to be invoked" + call = ffi_stub.set_source_calls[-1] + assert call["module"] == cffi_build.MODULE_NAME + assert scenario["expected_key"] in call + + @pytest.mark.parametrize( + "scenario", + [ + { + "label": "linux_paths_present", + "lib_name": "libunuran.so", + "header": True, + "expect_exception": None, + }, + { + "label": "mac_paths_present", + "lib_name": "libunuran.dylib", + "header": True, + "expect_exception": None, + }, + { + "label": "missing_lib_no_raise", + "lib_name": None, + "header": True, + "raise_on_error": False, + }, + { + "label": "missing_header_no_raise", + "lib_name": "libunuran.so", + "header": False, + "raise_on_error": False, + }, + { + "label": "missing_lib_with_raise", + "lib_name": None, + "header": True, + "expect_exception": ImportError, + }, + ], + ids=lambda s: s["label"], + ) + def test_find_unuran_handles_platform_variants( + self, scenario: dict[str, Any], tmp_path: Path + ) -> None: + """ + Tests find_unuran with five filesystem states (Linux shared, macOS shared, + missing library without raising, missing header without raising, and missing + library with ImportError), covering cases where files are absent but the + caller may opt out of exceptions. + """ + unuran_dir = tmp_path / scenario["label"] + lib_dir = unuran_dir / "out" + include_dir = unuran_dir / "unuran" / "src" + lib_dir.mkdir(parents=True) + include_dir.mkdir(parents=True) + + if scenario.get("lib_name"): + (lib_dir / scenario["lib_name"]).write_text("") + + if scenario.get("header"): + (include_dir / "unuran.h").write_text("") + + raise_on_error = scenario.get("raise_on_error", True) + + if scenario.get("expect_exception"): + with pytest.raises(scenario["expect_exception"]): + cffi_build.find_unuran(unuran_dir, raise_on_error=raise_on_error) + return + + results = cffi_build.find_unuran(unuran_dir, raise_on_error=raise_on_error) + if scenario.get("lib_name"): + assert results["library_path"] is not None + else: + assert results["library_path"] is None + + if scenario.get("header"): + assert results["include_path"] is not None + else: + assert results["include_path"] is None + + @pytest.mark.parametrize( + "scenario", + [ + BuildScenario( + label="already_built", + find_result={"include_path": "inc", "library_path": "lib"}, + script_exists=True, + expect_run=False, + ), + BuildScenario( + label="needs_build_runs_script", + find_result={"include_path": None, "library_path": None}, + script_exists=True, + expect_run=True, + ), + BuildScenario( + label="missing_script_raises", + find_result={"include_path": None, "library_path": None}, + script_exists=False, + expect_run=False, + expect_exception=RuntimeError, + ), + BuildScenario( + label="subprocess_failure", + find_result={"include_path": None, "library_path": None}, + script_exists=True, + expect_run=True, + run_exception=subprocess.CalledProcessError(1, "cmd"), + expect_exception=subprocess.CalledProcessError, + ), + BuildScenario( + label="partial_paths_still_run", + find_result={"include_path": "inc", "library_path": None}, + script_exists=True, + expect_run=True, + ), + ], + ids=lambda s: s.label, + ) + def test_build_unuran_handles_cached_and_missing_artifacts( + self, scenario: BuildScenario, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ + Validates build_unuran under five conditions (already built, needs build, + missing script edge case, subprocess failure, and partial discovery requiring + rebuild), ensuring subprocess invocation and exception handling behave as + expected. + """ + unuran_dir = tmp_path / "vendor" + unuran_dir.mkdir() + build_script = unuran_dir / "build_unuran.py" + if scenario.script_exists: + build_script.write_text("print('build')") + + find_calls: list[dict[str, Any]] = [] + + def fake_find(target_dir: Path, raise_on_error: bool) -> dict[str, str | None]: + find_calls.append({"dir": target_dir, "raise": raise_on_error}) + return scenario.find_result + + monkeypatch.setattr(cffi_build, "find_unuran", fake_find) + + run_calls: list[list[str]] = [] + + def fake_run(cmd: list[str], check: bool) -> None: + run_calls.append(cmd) + if scenario.run_exception: + raise scenario.run_exception + + monkeypatch.setattr(cffi_build, "subprocess", SimpleNamespace(run=fake_run)) + + if scenario.expect_exception: + with pytest.raises(scenario.expect_exception): + cffi_build.build_unuran(unuran_dir) + return + + cffi_build.build_unuran(unuran_dir) + assert bool(run_calls) is scenario.expect_run + + @pytest.mark.parametrize( + "scenario", + [ + MainScenario(label="linux_success", os_name="posix", platform="linux"), + MainScenario( + label="linux_find_error", + os_name="posix", + platform="linux", + find_side_effect=ImportError("missing"), + expect_exception=ImportError, + ), + MainScenario( + label="linux_build_error", + os_name="posix", + platform="linux", + build_side_effect=RuntimeError("fail"), + expect_exception=RuntimeError, + ), + MainScenario( + label="linux_compile_error", + os_name="posix", + platform="linux", + compile_side_effect=RuntimeError("compile fail"), + expect_exception=RuntimeError, + ), + ], + ids=lambda s: s.label, + ) + def test_main_controls_build_flow( + self, + scenario: MainScenario, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """ + Tests main() across five environments (Windows skip, Linux success, Linux + find failure, Linux build failure, Linux compile failure), covering supported + and unsupported OS flows and ensuring failure modes propagate while restoring + directories. + """ + project_root = tmp_path / "repo" + project_root.mkdir() + vendor_dir = project_root / "vendor" / cffi_build.UNURAN_DIR_NAME + vendor_dir.mkdir(parents=True) + include_file = vendor_dir / "unuran" / "src" / "unuran.h" + lib_file = vendor_dir / "out" / "libunuran.so" + include_file.parent.mkdir(parents=True, exist_ok=True) + lib_file.parent.mkdir(parents=True, exist_ok=True) + include_file.write_text("") + lib_file.write_text("") + + monkeypatch.setattr(cffi_build, "_get_project_root", lambda: project_root) + + def fake_build(target: Path) -> None: + if scenario.build_side_effect: + raise scenario.build_side_effect + + monkeypatch.setattr(cffi_build, "build_unuran", fake_build) + + def fake_find(target: Path) -> dict[str, str]: + if scenario.find_side_effect: + raise scenario.find_side_effect + return { + "include_path": str(include_file), + "library_path": str(lib_file), + } + + monkeypatch.setattr(cffi_build, "find_unuran", fake_find) + + recorded_configures: list[tuple[Path, Path]] = [] + monkeypatch.setattr( + cffi_build, + "_configure_from_paths", + lambda inc, lib: recorded_configures.append((inc, lib)), + ) + + ffi_stub = FFIStub() + + def fake_compile(verbose: bool = False) -> None: + if scenario.compile_side_effect: + raise scenario.compile_side_effect + ffi_stub.compile_calls.append({"verbose": verbose}) + + ffi_stub.compile = fake_compile # type: ignore[method-assign] + monkeypatch.setattr(cffi_build, "ffi", ffi_stub) + + chdir_calls: list[Path] = [] + + def fake_chdir(target: Path | str) -> None: + chdir_calls.append(Path(target)) + + monkeypatch.setattr( + cffi_build, + "os", + SimpleNamespace(name=scenario.os_name, chdir=fake_chdir), + ) + monkeypatch.setattr(sys, "platform", scenario.platform) + + if scenario.expect_exception: + with pytest.raises(scenario.expect_exception): + cffi_build.main() + return + + cffi_build.main() + assert recorded_configures + assert ffi_stub.compile_calls diff --git a/tests/unit/sampling/unuran/core/__init__.py b/tests/unit/sampling/unuran/core/__init__.py new file mode 100644 index 0000000..8e4e4f9 --- /dev/null +++ b/tests/unit/sampling/unuran/core/__init__.py @@ -0,0 +1,11 @@ +""" +UNU.RAN Core Tests +================== + +Unit tests for the UNU.RAN core layer, covering sampler initialisation, +method selection, resource lifecycle, and the sampling strategy integration. +""" + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" diff --git a/tests/unit/sampling/unuran/core/_unuran_sampler/__init__.py b/tests/unit/sampling/unuran/core/_unuran_sampler/__init__.py new file mode 100644 index 0000000..67e92b5 --- /dev/null +++ b/tests/unit/sampling/unuran/core/_unuran_sampler/__init__.py @@ -0,0 +1,11 @@ +""" +UNU.RAN Sampler Internals Tests +================================ + +Unit tests for the internal UNU.RAN sampler components: callbacks, +domain inference, DGT setup, and the initializer orchestration logic. +""" + +__author__ = "Artem Romanyuk" +__copyright__ = "Copyright (c) 2025 PySATL project" +__license__ = "SPDX-License-Identifier: MIT" diff --git a/tests/unit/sampling/unuran/core/_unuran_sampler/test_callbacks.py b/tests/unit/sampling/unuran/core/_unuran_sampler/test_callbacks.py new file mode 100644 index 0000000..76e39ea --- /dev/null +++ b/tests/unit/sampling/unuran/core/_unuran_sampler/test_callbacks.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any, TypedDict + +import pytest + +from pysatl_core.distributions.computation import AnalyticalComputation +from pysatl_core.sampling.unuran.core._unuran_sampler.callbacks import ( + _CONT_SIG, + _DISCR_SIG, + UnuranCallback, +) +from pysatl_core.types import CharacteristicName, Kind + +CharacteristicFunc = Callable[[float], float] + + +def _wrap_characteristics( + characteristics: dict[CharacteristicName, CharacteristicFunc] | None, +) -> dict[CharacteristicName, AnalyticalComputation[float, float]]: + wrapped: dict[CharacteristicName, AnalyticalComputation[float, float]] = {} + if not characteristics: + return wrapped + + for name, func in characteristics.items(): + wrapped[name] = AnalyticalComputation( + target=name.value, + func=lambda value, _func=func, **kwargs: _func(value), + ) + return wrapped + + +class DummyCallback: + def __init__(self, signature: str, func: Callable[..., float]): + self.signature = signature + self._func = func + + def __call__(self, *args: Any) -> float: + return float(self._func(*args)) + + +class DummyFFI: + def __init__(self) -> None: + self.created_callbacks: list[DummyCallback] = [] + + def callback(self, signature: str, func: Callable[..., float]) -> DummyCallback: + cb = DummyCallback(signature, func) + self.created_callbacks.append(cb) + return cb + + +@dataclass +class DummyLib: + cont_pdf_return: int = 0 + cont_dpdf_return: int = 0 + cont_cdf_return: int = 0 + cont_invcdf_return: int = 0 + discr_pmf_return: int = 0 + discr_cdf_return: int = 0 + + def __post_init__(self) -> None: + self.cont_pdf_calls: list[tuple[Any, DummyCallback]] = [] + self.cont_dpdf_calls: list[tuple[Any, DummyCallback]] = [] + self.cont_cdf_calls: list[tuple[Any, DummyCallback]] = [] + self.cont_invcdf_calls: list[tuple[Any, DummyCallback]] = [] + self.discr_pmf_calls: list[tuple[Any, DummyCallback]] = [] + self.discr_cdf_calls: list[tuple[Any, DummyCallback]] = [] + + def unur_distr_cont_set_pdf(self, distr: Any, callback: DummyCallback) -> int: + self.cont_pdf_calls.append((distr, callback)) + return self.cont_pdf_return + + def unur_distr_cont_set_dpdf(self, distr: Any, callback: DummyCallback) -> int: + self.cont_dpdf_calls.append((distr, callback)) + return self.cont_dpdf_return + + def unur_distr_cont_set_cdf(self, distr: Any, callback: DummyCallback) -> int: + self.cont_cdf_calls.append((distr, callback)) + return self.cont_cdf_return + + def unur_distr_cont_set_invcdf(self, distr: Any, callback: DummyCallback) -> int: + self.cont_invcdf_calls.append((distr, callback)) + return self.cont_invcdf_return + + def unur_distr_discr_set_pmf(self, distr: Any, callback: DummyCallback) -> int: + self.discr_pmf_calls.append((distr, callback)) + return self.discr_pmf_return + + def unur_distr_discr_set_cdf(self, distr: Any, callback: DummyCallback) -> int: + self.discr_cdf_calls.append((distr, callback)) + return self.discr_cdf_return + + +def linear(a: float, b: float) -> Callable[[float], float]: + return lambda x: a * x + b + + +def polynomial(coefficients: Sequence[float]) -> Callable[[float], float]: + def evaluator(x: float) -> float: + acc = 0.0 + for power, coeff in enumerate(coefficients): + acc += coeff * (x**power) + return acc + + return evaluator + + +class TestCallbacks: + @staticmethod + def _make_subject( + *, + kind: Kind, + characteristics: dict[CharacteristicName, CharacteristicFunc] | None = None, + lib: DummyLib | None = None, + ffi: DummyFFI | None = None, + ) -> UnuranCallback: + wrapped_characteristics = _wrap_characteristics(characteristics) + return UnuranCallback( + unuran_distr=object(), + kind=kind, + lib=lib or DummyLib(), + ffi=ffi or DummyFFI(), + characteristics=wrapped_characteristics, + ) + + def test_callbacks_property_exposes_internal_storage(self) -> None: + """Checks callbacks property returns live list by appending sentinels + and reading them back, covering edge case of multiple manual insertions.""" + subject = self._make_subject(kind=Kind.CONTINUOUS) + sentinels = [object() for _ in range(5)] + for sentinel in sentinels: + subject.callbacks.append(sentinel) + + assert subject.callbacks[-5:] == sentinels + + # --- _create_callback --- + + @pytest.mark.parametrize("char_name", list(CharacteristicName)) + def test_create_callback_returns_none_when_characteristic_absent( + self, char_name: CharacteristicName + ) -> None: + """Ensures _create_callback returns None for any characteristic that is not + registered, covering the full CharacteristicName enum.""" + subject = self._make_subject(kind=Kind.CONTINUOUS) + assert subject._create_callback(char_name, _CONT_SIG) is None + + def test_create_callback_evaluates_correctly_for_float_input(self) -> None: + """Confirms the callback wraps the characteristic function correctly for + float inputs by comparing five evaluated points including negative values.""" + func = polynomial((1.0, 0.5, -0.1)) + subject = self._make_subject( + kind=Kind.CONTINUOUS, + characteristics={CharacteristicName.PDF: func}, + ) + cb = subject._create_callback(CharacteristicName.PDF, _CONT_SIG) + assert cb is not None + + for x in (-3.0, -0.5, 0.0, 1.2, 4.7): + assert cb(x, None) == float(func(x)) + + def test_create_callback_converts_int_arg_to_float(self) -> None: + """Verifies that integer arguments (discrete case) are widened to float + before being forwarded to the characteristic function.""" + func = polynomial((1.0, 0.4, 0.05)) + subject = self._make_subject( + kind=Kind.DISCRETE, + characteristics={CharacteristicName.PMF: func}, + ) + cb = subject._create_callback(CharacteristicName.PMF, _DISCR_SIG) + assert cb is not None + + for k in (-4, -1, 0, 3, 7): + assert cb(k, None) == float(func(float(k))) + + @pytest.mark.parametrize( + ("char_name", "signature"), + [ + (CharacteristicName.PDF, _CONT_SIG), + (CharacteristicName.PMF, _DISCR_SIG), + (CharacteristicName.CDF, _CONT_SIG), + ], + ) + def test_create_callback_registers_correct_signature_on_ffi( + self, char_name: CharacteristicName, signature: str + ) -> None: + """Checks that the CFFI signature string passed to _create_callback is + forwarded verbatim to ffi.callback, covering continuous and discrete sigs.""" + ffi = DummyFFI() + subject = self._make_subject( + kind=Kind.CONTINUOUS, + characteristics={char_name: linear(1, 0)}, + ffi=ffi, + ) + subject._create_callback(char_name, signature) + assert len(ffi.created_callbacks) == 1 + assert ffi.created_callbacks[0].signature == signature + + def test_setup_continuous_callbacks_sets_all_available(self) -> None: + """Integration-tests continuous setup by running full pipeline and asserting + four setter invocations while probing edge value -2.0 in callbacks.""" + lib = DummyLib() + characteristics = { + CharacteristicName.PDF: polynomial((1.0, 0.5)), + CharacteristicName.DPDF: polynomial((0.0, 1.5)), + CharacteristicName.CDF: polynomial((0.2, 0.4)), + CharacteristicName.PPF: polynomial((0.0, 2.0)), + } + subject = self._make_subject(kind=Kind.CONTINUOUS, characteristics=characteristics, lib=lib) + subject.setup_continuous_callbacks() + + assert len(subject.callbacks) == 4 + assert len(lib.cont_pdf_calls) == 1 + assert len(lib.cont_dpdf_calls) == 1 + assert len(lib.cont_cdf_calls) == 1 + assert len(lib.cont_invcdf_calls) == 1 + + test_values = (-2.0, -0.5, 0.0, 0.75, 3.3) + pdf_cb = lib.cont_pdf_calls[0][1] + dpdf_cb = lib.cont_dpdf_calls[0][1] + cdf_cb = lib.cont_cdf_calls[0][1] + ppf_cb = lib.cont_invcdf_calls[0][1] + for value in test_values: + assert pdf_cb(value, None) == float(characteristics[CharacteristicName.PDF](value)) + assert dpdf_cb(value, None) == float(characteristics[CharacteristicName.DPDF](value)) + assert cdf_cb(value, None) == float(characteristics[CharacteristicName.CDF](value)) + assert ppf_cb(value, None) == float(characteristics[CharacteristicName.PPF](value)) + + class ContinuousLibOverrides(TypedDict, total=False): + cont_pdf_return: int + cont_dpdf_return: int + cont_cdf_return: int + cont_invcdf_return: int + + class DiscreteLibOverrides(TypedDict, total=False): + discr_pmf_return: int + discr_cdf_return: int + + @pytest.mark.parametrize( + ("lib_kwargs", "expected_fragment"), + [ + ({"cont_pdf_return": 1}, "PDF callback"), + ({"cont_dpdf_return": -2}, "dPDF callback"), + ({"cont_cdf_return": 3}, "CDF callback"), + ({"cont_invcdf_return": 4}, "PPF callback"), + ], + ) + def test_setup_continuous_callbacks_raises_on_failed_setters( + self, lib_kwargs: ContinuousLibOverrides, expected_fragment: str + ) -> None: + """Checks error propagation by forcing setter failures via DummyLib overrides + and asserting RuntimeError message contains component, covering each setter.""" + lib = DummyLib(**lib_kwargs) + characteristics = { + CharacteristicName.PDF: linear(1, 0), + CharacteristicName.CDF: linear(1, 0), + CharacteristicName.PPF: linear(1, 0), + CharacteristicName.DPDF: linear(1, 0), + } + subject = self._make_subject(kind=Kind.CONTINUOUS, characteristics=characteristics, lib=lib) + + with pytest.raises(RuntimeError) as excinfo: + subject.setup_continuous_callbacks() + + assert expected_fragment in str(excinfo.value) + + def test_setup_discrete_callbacks_sets_all_available(self) -> None: + """Integration-tests discrete setup by wiring PMF/CDF and evaluating five + integer points, covering edge case at negative support.""" + lib = DummyLib() + characteristics = { + CharacteristicName.PMF: polynomial((1.0, 0.4)), + CharacteristicName.CDF: polynomial((0.0, 0.6)), + } + subject = self._make_subject(kind=Kind.DISCRETE, characteristics=characteristics, lib=lib) + subject.setup_discrete_callbacks() + + assert len(subject.callbacks) == 2 + assert len(lib.discr_pmf_calls) == 1 + assert len(lib.discr_cdf_calls) == 1 + + test_values = (-3, -1, 0, 2, 5) + pmf_cb = lib.discr_pmf_calls[0][1] + cdf_cb = lib.discr_cdf_calls[0][1] + for value in test_values: + assert pmf_cb(value, None) == float( + characteristics[CharacteristicName.PMF](float(value)) + ) + assert cdf_cb(value, None) == float( + characteristics[CharacteristicName.CDF](float(value)) + ) + + @pytest.mark.parametrize( + ("lib_kwargs", "expected_fragment"), + [ + ({"discr_pmf_return": 2}, "PMF callback"), + ({"discr_cdf_return": -1}, "CDF callback"), + ], + ) + def test_setup_discrete_callbacks_raises_on_failed_setters( + self, lib_kwargs: DiscreteLibOverrides, expected_fragment: str + ) -> None: + """Validates discrete setup raises by injecting setter error codes and + asserting message substring, covering edge case for each setter type.""" + lib = DummyLib(**lib_kwargs) + characteristics = { + CharacteristicName.PMF: linear(1, 0), + CharacteristicName.CDF: linear(1, 0), + } + subject = self._make_subject(kind=Kind.DISCRETE, characteristics=characteristics, lib=lib) + + with pytest.raises(RuntimeError) as excinfo: + subject.setup_discrete_callbacks() + + assert expected_fragment in str(excinfo.value) diff --git a/tests/unit/sampling/unuran/core/_unuran_sampler/test_dgt.py b/tests/unit/sampling/unuran/core/_unuran_sampler/test_dgt.py new file mode 100644 index 0000000..3da9db2 --- /dev/null +++ b/tests/unit/sampling/unuran/core/_unuran_sampler/test_dgt.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, cast + +import pytest + +from pysatl_core.distributions.support import ( + ExplicitTableDiscreteSupport as RealExplicitTableSupport, + IntegerLatticeDiscreteSupport as RealIntegerLatticeSupport, +) +from pysatl_core.sampling.unuran.core._unuran_sampler.dgt import DGTSetup +from pysatl_core.types import CharacteristicName + +if TYPE_CHECKING: + from pysatl_core.distributions.distribution import Distribution + from pysatl_core.sampling.unuran.core._unuran_sampler.domain import UnuranDomain + + +class FakeFFI: + def __init__(self) -> None: + self.NULL = object() + + def string(self, ptr: Any) -> bytes: + if isinstance(ptr, bytes | bytearray): + return bytes(ptr) + return str(ptr).encode("utf-8") + + +@dataclass +class FakeLib: + domain_result: int = 0 + pv_length: int = 5 + errno_value: int = 0 + error_text: bytes | None = None + include_pmfsum: bool = True + + def __post_init__(self) -> None: + self.unur_distr_discr_set_domain_calls: list[tuple[Any, int, int]] = [] + self.unur_distr_discr_set_pmfsum_calls: list[tuple[Any, float]] = [] + self.unur_distr_discr_make_pv_calls: list[Any] = [] + self.unur_get_errno_calls: int = 0 + self.unur_get_strerror_calls: list[int] = [] + if self.include_pmfsum: + self.unur_distr_discr_set_pmfsum = self._set_pmfsum + + def unur_distr_discr_set_domain(self, distr: Any, left: int, right: int) -> int: + self.unur_distr_discr_set_domain_calls.append((distr, left, right)) + return self.domain_result + + def _set_pmfsum(self, distr: Any, value: float) -> None: + self.unur_distr_discr_set_pmfsum_calls.append((distr, value)) + + def unur_distr_discr_make_pv(self, distr: Any) -> int: + self.unur_distr_discr_make_pv_calls.append(distr) + return self.pv_length + + def unur_get_errno(self) -> int: + self.unur_get_errno_calls += 1 + return self.errno_value + + def unur_get_strerror(self, errno: int) -> bytes | None: + self.unur_get_strerror_calls.append(errno) + return self.error_text + + +@dataclass +class FakeDomain: + domain_info: tuple[int | None, int | None] | None + + def __post_init__(self) -> None: + self.determine_calls: int = 0 + + def determine_discrete_domain(self) -> tuple[int | None, int | None] | None: + self.determine_calls += 1 + return self.domain_info + + +@dataclass +class FakeDistribution: + support: Any = None + analytical_computations: dict[CharacteristicName, Callable[[float], float]] = field( + default_factory=dict + ) + fallback_methods: dict[CharacteristicName, Callable[[float], float]] = field( + default_factory=dict + ) + + def query_method(self, name: CharacteristicName) -> Callable[[float], float]: + if name in self.analytical_computations: + return self.analytical_computations[name] + if name in self.fallback_methods: + return self.fallback_methods[name] + raise RuntimeError(f"Characteristic {name!r} not available") + + +class TestDGTSetup: + @staticmethod + def _make_setup( + *, + lib: FakeLib | None = None, + ffi: FakeFFI | None = None, + domain: FakeDomain | None = None, + distr: FakeDistribution | None = None, + ) -> DGTSetup: + return DGTSetup( + lib=lib or FakeLib(), + ffi=ffi or FakeFFI(), + domain=cast("UnuranDomain", domain or FakeDomain((0, 1))), + unuran_distr=object(), + distr=cast("Distribution", distr or FakeDistribution()), + ) + + def test_require_attr_accepts_present_attributes(self) -> None: + """Verifies _require_attr passes when all required C symbols exist, covering + optional helper detection.""" + attr_names = [ + "unur_distr_discr_set_domain", + "unur_distr_discr_make_pv", + "unur_get_errno", + "unur_get_strerror", + "unur_distr_discr_set_pmfsum", + ] + lib = type("Lib", (), {name: object() for name in attr_names})() + setup = self._make_setup(lib=lib) + + for name in attr_names: + setup._require_attr(name) + + def test_require_attr_raises_when_missing_attributes(self) -> None: + """Ensures _require_attr raises for missing attribute names, covering edge case + of incomplete CFFI modules.""" + missing_attrs = [ + "unur_distr_discr_set_domain", + "unur_distr_discr_make_pv", + "unur_distr_discr_set_pmfsum", + "unur_get_errno", + "unur_get_strerror", + ] + setup = self._make_setup(lib=type("Lib", (), {})()) + + for name in missing_attrs: + with pytest.raises(RuntimeError) as excinfo: + setup._require_attr(name) + assert name in str(excinfo.value) + + @pytest.mark.parametrize( + ("support", "domain_left", "domain_right", "expected_points"), + [ + # Dense range — no special support + (None, 0, 4, [0, 1, 2, 3, 4]), + # Lattice modulus=1 falls back to dense range + ( + RealIntegerLatticeSupport(residue=0, modulus=1, min_k=0, max_k=10), + 0, + 4, + [0, 1, 2, 3, 4], + ), + # Lattice modulus=3, residue=0: points 0, 3, 6, ... + ( + RealIntegerLatticeSupport(residue=0, modulus=3, min_k=0, max_k=10), + 0, + 9, + [0, 3, 6, 9], + ), + # Lattice modulus=3, domain_left not aligned to residue — start shifts + (RealIntegerLatticeSupport(residue=0, modulus=3, min_k=0, max_k=10), 1, 9, [3, 6, 9]), + # Explicit table: only points within [domain_left, domain_right] + (RealExplicitTableSupport([1, 3, 5, 7, 9]), 3, 8, [3, 5, 7]), + # Explicit table: domain covers no points + (RealExplicitTableSupport([1, 3, 5]), 6, 10, []), + ], + ) + def test_domain_points_visits_only_support( + self, + support: Any, + domain_left: int, + domain_right: int, + expected_points: list[int], + ) -> None: + """Verifies _domain_points yields exactly the right integers for each + support type: dense fallback, unit-step lattice, strided lattice + (aligned and unaligned starts), and explicit table (partial and empty slices).""" + setup = self._make_setup(distr=FakeDistribution(support=support)) + assert list(setup._domain_points(domain_left, domain_right)) == expected_points + + def test_calculate_pmf_sum_handles_varied_probability_outputs(self) -> None: + """Ensures _calculate_pmf_sum aggregates PMF over five behaviors (positive, + negative, NaN, exceptions, missing PMF) covering cases where invalid outputs + must be skipped.""" + + def valid_pmf(x: float) -> float: + return 0.1 * (x + 5) + + def negative_pmf(x: float) -> float: + return -0.2 + + def nan_pmf(x: float) -> float: + return float("nan") + + def raise_pmf(x: float) -> float: + return (_ for _ in ()).throw(ValueError("bad")) + + def inf_pmf(x: float) -> float: + return float("inf") + + pmf_funcs = [valid_pmf, negative_pmf, nan_pmf, raise_pmf, inf_pmf] + expected_totals = [3.5, 0.0, 0.0, 0.0, 0.0] + + for func, expected in zip(pmf_funcs, expected_totals, strict=True): + distr = FakeDistribution(analytical_computations={CharacteristicName.PMF: func}) + setup = self._make_setup(distr=distr) + total = setup._calculate_pmf_sum(0, 4) + assert pytest.approx(total, rel=1e-9) == expected + + setup = self._make_setup() # no PMF available + with pytest.raises(RuntimeError, match="PMF is unavailable"): + setup._calculate_pmf_sum(0, 4) + + def test_calculate_pmf_sum_skips_non_lattice_points(self) -> None: + """Confirms _calculate_pmf_sum only evaluates PMF at lattice points by + counting calls — with modulus=5 over [0, 20] only 5 calls are expected, + not 21.""" + call_log: list[int] = [] + + def counting_pmf(x: float) -> float: + call_log.append(int(x)) + return 0.1 + + support = RealIntegerLatticeSupport(residue=0, modulus=5, min_k=0, max_k=20) + distr = FakeDistribution( + support=support, + analytical_computations={CharacteristicName.PMF: counting_pmf}, + ) + setup = self._make_setup(distr=distr) + setup._calculate_pmf_sum(0, 20) + + assert call_log == [0, 5, 10, 15, 20] + + def test_setup_dgt_method_configures_domain_and_probability_vector( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Checks setup_dgt_method configures domain/pmf/pv tuples, covering edge case + of negative lower bounds.""" + scenarios = [ + ((0, 5), 1.0, 7), + ((-3, 2), 0.9, 6), + ((2, 10), 0.8, 5), + ((-10, -2), 0.7, 11), + ((4, 8), 1.2, 9), + ] + + for domain_bounds, pmf_sum, pv_length in scenarios: + domain = FakeDomain(domain_bounds) + lib = FakeLib(domain_result=0, pv_length=pv_length) + setup = self._make_setup(lib=lib, domain=domain) + monkeypatch.setattr(DGTSetup, "_calculate_pmf_sum", lambda self, lo, hi, s=pmf_sum: s) + setup.setup_dgt_method() + + assert domain.determine_calls == 1 + assert lib.unur_distr_discr_set_domain_calls == [(setup._unuran_distr, *domain_bounds)] + assert lib.unur_distr_discr_set_pmfsum_calls == [(setup._unuran_distr, pmf_sum)] + assert lib.unur_distr_discr_make_pv_calls == [setup._unuran_distr] + + def test_setup_dgt_method_raises_when_domain_missing(self) -> None: + """Validates setup_dgt_method refuses undefined domains, covering edge case of + unbounded discrete support.""" + domain_variants: list[tuple[int | None, int | None] | None] = [ + None, + (None, 5), + (5, None), + (None, None), + (0, None), + ] + + for variant in domain_variants: + domain = FakeDomain(variant) + setup = self._make_setup(domain=domain) + + with pytest.raises(RuntimeError) as excinfo: + setup.setup_dgt_method() + assert "Failed to determine domain" in str(excinfo.value) + + def test_setup_dgt_method_raises_when_domain_setting_fails( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Ensures domain setter errors propagate for non-zero return codes, covering C + library rejections.""" + error_codes = [1, -2, 5, 11, 99] + domain_bounds = (0, 5) + + for code in error_codes: + domain = FakeDomain(domain_bounds) + lib = FakeLib(domain_result=code) + setup = self._make_setup(lib=lib, domain=domain) + monkeypatch.setattr(DGTSetup, "_calculate_pmf_sum", lambda self, lo, hi: 1.0) + + with pytest.raises(RuntimeError) as excinfo: + setup.setup_dgt_method() + assert f"error code: {code}" in str(excinfo.value) + + def test_setup_dgt_method_skips_pmfsum_when_function_missing( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Confirms pmf sum setter is optional when attribute is missing, covering + legacy UNURAN builds.""" + pmf_values = [0.5, 0.8, 1.0, 1.3, 2.0] + + for pmf_sum in pmf_values: + domain = FakeDomain((0, 4)) + lib = FakeLib(include_pmfsum=False) + setup = self._make_setup(lib=lib, domain=domain) + monkeypatch.setattr(DGTSetup, "_calculate_pmf_sum", lambda self, lo, hi, s=pmf_sum: s) + setup.setup_dgt_method() + + assert not hasattr(lib, "unur_distr_discr_set_pmfsum") + assert lib.unur_distr_discr_set_pmfsum_calls == [] diff --git a/tests/unit/sampling/unuran/core/_unuran_sampler/test_domain.py b/tests/unit/sampling/unuran/core/_unuran_sampler/test_domain.py new file mode 100644 index 0000000..38f38f0 --- /dev/null +++ b/tests/unit/sampling/unuran/core/_unuran_sampler/test_domain.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +import numpy as np + +from pysatl_core.distributions.support import ( + ExplicitTableDiscreteSupport as RealExplicitTableSupport, + IntegerLatticeDiscreteSupport as RealIntegerLatticeSupport, +) +from pysatl_core.sampling.unuran.core._unuran_sampler.domain import UnuranDomain + +if __name__ == "__main__": + from pysatl_core.distributions.distribution import Distribution + + +@dataclass +class FakeDistribution: + support: Any = None + + +class ContinuousBoundsSupport: + def __init__(self, left: float | None = None, right: float | None = None): + self.left = left + self.right = right + + +def make_explicit_support(points: list[float]) -> RealExplicitTableSupport: + return RealExplicitTableSupport(points) + + +def make_empty_explicit_support() -> RealExplicitTableSupport: + support = RealExplicitTableSupport([0.0]) + raw_support = cast(Any, support) + raw_support._points = np.array([], dtype=float) + return support + + +def make_integer_support(first: int | None, last: int | None) -> RealIntegerLatticeSupport: + return RealIntegerLatticeSupport(residue=0, modulus=1, min_k=first, max_k=last) + + +class TestUnuranDomain: + def test_determine_discrete_domain_covering_all_paths(self) -> None: + """Ensures determine_discrete_domain inspects five support shapes + (explicit, lattice finite/half, callable fallback, missing) covering empty + tables and partially bounded lattices.""" + scenarios = [ + (make_explicit_support([-2.4, -0.1, 3.6]), None), + (make_empty_explicit_support(), None), + (make_integer_support(0, 5), (0, 5)), + (make_integer_support(-7, None), (-7, None)), + ] + + for support, expected in scenarios: + distr = FakeDistribution(support=support) + domain = UnuranDomain(cast("Distribution", distr)) + assert domain.determine_discrete_domain() == expected + + distr_missing = FakeDistribution(support=None) + domain_missing = UnuranDomain(cast("Distribution", distr_missing)) + assert domain_missing.determine_discrete_domain() is None + + def test_determine_continuous_domain_handles_multiple_sources(self) -> None: + """Validates determine_continuous_domain retrieves limits via attributes, + callable fallbacks, and handles five edge inputs including None and callable + errors.""" + supports = [ + ContinuousBoundsSupport(left=-1.5, right=2.5), + ContinuousBoundsSupport(left=0.0, right=10.0), + ContinuousBoundsSupport(left=None, right=None), + ] + + expected = [ + (-1.5, 2.5), + (0.0, 10.0), + None, + ] + + for support, exp in zip(supports, expected, strict=False): + distr = FakeDistribution(support=support) + domain = UnuranDomain(cast("Distribution", distr)) + assert domain.determine_continuous_domain() == exp + + no_support_domain = UnuranDomain(cast("Distribution", FakeDistribution(support=None))) + assert no_support_domain.determine_continuous_domain() is None diff --git a/tests/unit/sampling/unuran/core/_unuran_sampler/test_initialization.py b/tests/unit/sampling/unuran/core/_unuran_sampler/test_initialization.py new file mode 100644 index 0000000..9c79b09 --- /dev/null +++ b/tests/unit/sampling/unuran/core/_unuran_sampler/test_initialization.py @@ -0,0 +1,671 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from types import MethodType, SimpleNamespace +from typing import Any, Literal, TypedDict, cast + +import pytest +from mypy_extensions import KwArg + +from pysatl_core.distributions.computation import AnalyticalComputation +from pysatl_core.distributions.distribution import Distribution +from pysatl_core.sampling.unuran.api import UnuranMethod +from pysatl_core.sampling.unuran.core._unuran_sampler import initialization as init_module +from pysatl_core.sampling.unuran.core._unuran_sampler.domain import UnuranDomain +from pysatl_core.sampling.unuran.core._unuran_sampler.initialization import UnuranSamplerInitializer +from pysatl_core.types import CharacteristicName, EuclideanDistributionType, Kind, NumericArray + + +def _wrap_callable( + name: CharacteristicName, func: Callable[[float], float] +) -> AnalyticalComputation[Any, Any]: + def _wrapped(value: Any, **_: Any) -> Any: + return float(func(float(value))) + + wrapped_func = cast("Callable[[Any, KwArg(Any)], Any]", _wrapped) + return AnalyticalComputation(target=name.value, func=wrapped_func) + + +class DummyFFI: + def __init__(self) -> None: + self.NULL = object() + + +class GeneratorInitScenario(TypedDict): + param: Any + gen: Any + expect: Literal["param", "gen", None] + + +class CleanupScenario(TypedDict): + gen: Any + par: Any + distr: Any + gen_null: bool + + +@dataclass +class FakeDistribution: + distribution_type: EuclideanDistributionType + support: Any = None + sampling_strategy: Any = None + computation_strategy: Any = None + _analytical_store: dict[str, AnalyticalComputation[Any, Any]] = field(default_factory=dict) + _fallback_store: dict[str, AnalyticalComputation[Any, Any]] = field(default_factory=dict) + + @property + def analytical_computations( + self, + ) -> Mapping[str, AnalyticalComputation[Any, Any]]: + return self._analytical_store + + @analytical_computations.setter + def analytical_computations( + self, mapping: Mapping[CharacteristicName, Callable[[float], float]] + ) -> None: + self.set_analytical(mapping) + + @property + def fallback_methods(self) -> Mapping[str, AnalyticalComputation[Any, Any]]: + return self._fallback_store + + @fallback_methods.setter + def fallback_methods( + self, mapping: Mapping[CharacteristicName, Callable[[float], float]] + ) -> None: + self.set_fallback(mapping) + + def query_method(self, characteristic_name: str, **_: Any) -> AnalyticalComputation[Any, Any]: + if characteristic_name in self._analytical_store: + return self._analytical_store[characteristic_name] + return self._fallback_store[characteristic_name] + + def calculate_characteristic(self, characteristic_name: str, value: Any, **options: Any) -> Any: + method = self.query_method(characteristic_name, **options) + return method(value, **options) + + def sample(self, n: int, **options: Any) -> NumericArray: + raise NotImplementedError + + def set_analytical( + self, mapping: Mapping[CharacteristicName, Callable[[float], float]] + ) -> None: + self._analytical_store = { + name.value: _wrap_callable(name, func) for name, func in mapping.items() + } + + def set_fallback(self, mapping: Mapping[CharacteristicName, Callable[[float], float]]) -> None: + self._fallback_store = { + name.value: _wrap_callable(name, func) for name, func in mapping.items() + } + + +class CallbackDouble: + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.args = args + self.kwargs = kwargs + self.callbacks: list[Any] = [] + self.setup_cont_calls: int = 0 + self.setup_discr_calls: int = 0 + + def setup_continuous_callbacks(self) -> None: + self.setup_cont_calls += 1 + self.callbacks.append(("cont", self.setup_cont_calls)) + + def setup_discrete_callbacks(self) -> None: + self.setup_discr_calls += 1 + self.callbacks.append(("discr", self.setup_discr_calls)) + + +class DGTSetupDouble: + def __init__( + self, lib: Any, ffi: Any, domain: UnuranDomain, unuran_distr: Any, distr: Any + ) -> None: + self.created_with = (lib, ffi, domain, unuran_distr, distr) + self.calls = 0 + + def setup_dgt_method(self) -> None: + self.calls += 1 + + +class DomainDouble(UnuranDomain): + def __init__(self, bounds_queue: list[tuple[float, float] | None] | None = None) -> None: + dummy_distr = FakeDistribution( + distribution_type=EuclideanDistributionType(kind=Kind.CONTINUOUS, dimension=1), + ) + super().__init__(cast(Distribution, dummy_distr)) + self.bounds_queue = bounds_queue or [] + self.calls = 0 + + def determine_continuous_domain(self) -> tuple[float, float] | None: + self.calls += 1 + if self.bounds_queue: + return self.bounds_queue.pop(0) + return None + + +def make_initializer( + *, + kind: Kind, + method: UnuranMethod = UnuranMethod.PINV, + lib: Any, + ffi: DummyFFI, + distribution_support: Any = None, + analytical: dict[CharacteristicName, Callable[[float], float]] | None = None, + fallback: dict[CharacteristicName, Callable[[float], float]] | None = None, +) -> UnuranSamplerInitializer: + distr = FakeDistribution( + distribution_type=EuclideanDistributionType( + kind=kind, + dimension=1, + ), + support=distribution_support, + ) + if analytical is not None: + distr.set_analytical(analytical) + else: + distr._analytical_store.clear() + + if fallback is not None: + distr.set_fallback(fallback) + else: + distr._fallback_store.clear() + return UnuranSamplerInitializer(distr, method, lib, ffi) + + +class TestCallbacks: + def test_create_unuran_distribution_handles_multiple_constructors(self) -> None: + """Validates _create_unuran_distribution uses proper lib constructors by + iterating over five pointer outputs, covering cases where discrete and + continuous choices share sentinel pointers.""" + ffi = DummyFFI() + pointer_values = ["cont-0", "cont-1", "discr-0", "discr-1", ("tuple", 2)] + results = [] + + for idx, ptr in enumerate(pointer_values): + kind = Kind.CONTINUOUS if idx % 2 == 0 else Kind.DISCRETE + cont_calls: list[Any] = [] + discr_calls: list[Any] = [] + + def cont_new(value: Any = ptr, store: list[Any] = cont_calls) -> Any: + store.append(value) + return value + + def discr_new(value: Any = ptr, store: list[Any] = discr_calls) -> Any: + store.append(value) + return value + + lib = SimpleNamespace(unur_distr_cont_new=cont_new, unur_distr_discr_new=discr_new) + initializer = make_initializer(kind=kind, lib=lib, ffi=ffi) + initializer._create_unuran_distribution() + results.append(initializer._unuran_distr) + assert (cont_calls if kind == Kind.CONTINUOUS else discr_calls) == [ptr] + assert (discr_calls if kind == Kind.CONTINUOUS else cont_calls) == [] + + assert results == pointer_values + + def test_create_unuran_distribution_raises_on_null_pointer(self) -> None: + """Ensures _create_unuran_distribution fails fast when lib returns NULL by + cycling five scenarios where either constructor yields ffi.NULL, covering + improper lib builds.""" + ffi = DummyFFI() + scenarios = [ + (Kind.CONTINUOUS, ffi.NULL), + (Kind.DISCRETE, ffi.NULL), + (Kind.CONTINUOUS, ffi.NULL), + (Kind.DISCRETE, ffi.NULL), + (Kind.CONTINUOUS, ffi.NULL), + ] + for kind, return_value in scenarios: + lib = SimpleNamespace( + unur_distr_cont_new=lambda rv=return_value: rv, + unur_distr_discr_new=lambda rv=return_value: rv, + ) + initializer = make_initializer(kind=kind, lib=lib, ffi=ffi) + with pytest.raises(RuntimeError, match="Failed to create UNURAN distribution object"): + initializer._create_unuran_distribution() + + def test_create_parameter_object_selects_method_specific_creators(self) -> None: + """Checks _create_parameter_object dispatches to five method-specific + constructors by invoking each and capturing returned pointers, covering cases + where the same pointer would otherwise be reused.""" + ffi = DummyFFI() + method_factories = [ + (UnuranMethod.AROU, "arou"), + (UnuranMethod.TDR, "tdr"), + (UnuranMethod.HINV, "hinv"), + (UnuranMethod.PINV, "pinv"), + (UnuranMethod.NINV, "ninv"), + ] + + for method, token in method_factories: + calls: list[str] = [] + + def factory(*_args: Any, name: str = token, store: list[str] = calls) -> str: + store.append(name) + return name + + lib = SimpleNamespace( + unur_distr_cont_new=lambda value=token: value, + unur_distr_discr_new=lambda value=token: value, + unur_arou_new=factory, + unur_tdr_new=factory, + unur_hinv_new=factory, + unur_pinv_new=factory, + unur_ninv_new=factory, + unur_dgt_new=factory, + ) + initializer = make_initializer(kind=Kind.CONTINUOUS, method=method, lib=lib, ffi=ffi) + initializer._unuran_distr = object() + result = initializer._create_parameter_object() + assert result == token + assert calls == [token] + + def test_create_parameter_object_handles_dgt_and_unsupported_methods(self) -> None: + """Ensures _create_parameter_object covers DGT constructor and raises for + unsupported sentinel methods, covering cases where the enum is extended.""" + ffi = DummyFFI() + dgt_calls: list[str] = [] + + def record_dgt(*_args: Any, **_kwargs: Any) -> str: + dgt_calls.append("dgt") + return "dgt" + + lib = SimpleNamespace(unur_dgt_new=record_dgt) + initializer = make_initializer( + kind=Kind.DISCRETE, method=UnuranMethod.DGT, lib=lib, ffi=ffi + ) + initializer._unuran_distr = object() + assert initializer._create_parameter_object() == "dgt" + assert dgt_calls == ["dgt"] + + class FakeMethod: + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return self.value + + __repr__ = __str__ + + unsupported_methods = [FakeMethod(f"unsupported-{i}") for i in range(5)] + for fake_method in unsupported_methods: + init = make_initializer( + kind=Kind.CONTINUOUS, method=UnuranMethod.PINV, lib=SimpleNamespace(), ffi=ffi + ) + init._method = cast(UnuranMethod, fake_method) + init._unuran_distr = object() + with pytest.raises(ValueError) as excinfo: + init._create_parameter_object() + assert fake_method.value in str(excinfo.value) + + def test_create_and_init_generator_handles_success_and_failures( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Validates _create_and_init_generator success path and two failure modes by + iterating over five cases (NULL param, NULL gen, three successes), covering + situations where get_unuran_error_message is consulted twice.""" + ffi = DummyFFI() + + def fake_error(lib: Any, ffi_obj: Any, msg: str) -> str: + return f"{msg} :: formatted" + + monkeypatch.setattr(init_module, "get_unuran_error_message", fake_error) + + scenarios: list[GeneratorInitScenario] = [ + {"param": ffi.NULL, "gen": object(), "expect": "param"}, + {"param": "par1", "gen": ffi.NULL, "expect": "gen"}, + {"param": "par2", "gen": "gen2", "expect": None}, + {"param": "par3", "gen": "gen3", "expect": None}, + {"param": "par4", "gen": "gen4", "expect": None}, + ] + + for scenario in scenarios: + created_params: list[Any] = [] + init_calls: list[Any] = [] + + def unur_init( + par: Any, scen: GeneratorInitScenario = scenario, store: list[Any] = init_calls + ) -> Any: + store.append(par) + return scen["gen"] + + def create_param_method( + self: UnuranSamplerInitializer, + scen: GeneratorInitScenario = scenario, + store: list[Any] = created_params, + ) -> Any: + store.append(True) + return scen["param"] + + lib = SimpleNamespace(unur_init=unur_init) + initializer = make_initializer(kind=Kind.CONTINUOUS, lib=lib, ffi=ffi) + cast(Any, initializer)._create_parameter_object = MethodType( + create_param_method, initializer + ) + + if scenario["expect"] == "param": + with pytest.raises(RuntimeError, match="Failed to create UNURAN parameter object"): + initializer._create_and_init_generator() + continue + + if scenario["expect"] == "gen": + with pytest.raises(RuntimeError, match="Failed to initialize UNURAN generator"): + initializer._create_and_init_generator() + continue + + result = initializer._create_and_init_generator() + assert result == scenario["gen"] + assert init_calls == [scenario["param"]] + + def test_requires_finite_support_reflects_method_requirements(self) -> None: + """Checks _requires_finite_support over five methods (HINV/DGT vs others), + covering cases where optional requirements exist without the support flag.""" + ffi = DummyFFI() + lib = SimpleNamespace() + methods = [ + (UnuranMethod.HINV, True), + (UnuranMethod.DGT, True), + (UnuranMethod.PINV, False), + (UnuranMethod.AROU, False), + (UnuranMethod.NINV, False), + ] + + for method, expected in methods: + initializer = make_initializer(kind=Kind.CONTINUOUS, method=method, lib=lib, ffi=ffi) + assert initializer._requires_finite_support() is expected + + def test_apply_continuous_domain_constraints_handles_various_failures( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Ensures _apply_continuous_domain_constraints enforces bounds by iterating + five cases (skip, missing, infinite, invalid order, setter failure) plus a + success edge case.""" + ffi = DummyFFI() + lib = SimpleNamespace(unur_distr_cont_set_domain=lambda *args: 0) + + # Skip when discrete or when support not required + initializer = make_initializer( + kind=Kind.DISCRETE, method=UnuranMethod.PINV, lib=lib, ffi=ffi + ) + initializer._apply_continuous_domain_constraints() + + initializer = make_initializer( + kind=Kind.CONTINUOUS, method=UnuranMethod.PINV, lib=lib, ffi=ffi + ) + initializer._apply_continuous_domain_constraints() + + domain = DomainDouble([None]) + monkeypatch.setattr(init_module, "UnuranDomain", lambda distr: domain) + initializer = make_initializer( + kind=Kind.CONTINUOUS, method=UnuranMethod.HINV, lib=lib, ffi=ffi + ) + initializer._domain = domain + with pytest.raises(RuntimeError, match="requires finite support bounds"): + initializer._apply_continuous_domain_constraints() + + domain = DomainDouble([(float("inf"), 1.0)]) + initializer._domain = domain + with pytest.raises(RuntimeError, match="requires finite support bounds"): + initializer._apply_continuous_domain_constraints() + + domain = DomainDouble([(-1.0, -2.0)]) + initializer._domain = domain + with pytest.raises(RuntimeError, match="Invalid support bounds"): + initializer._apply_continuous_domain_constraints() + + domain = DomainDouble([(-1.0, 1.0)]) + failing_lib = SimpleNamespace(unur_distr_cont_set_domain=lambda *args: -5) + initializer = make_initializer( + kind=Kind.CONTINUOUS, method=UnuranMethod.HINV, lib=failing_lib, ffi=ffi + ) + initializer._domain = domain + with pytest.raises(RuntimeError, match="Failed to set continuous domain"): + initializer._apply_continuous_domain_constraints() + + success_domain = DomainDouble([(-2.0, 3.0)]) + captured_calls: list[tuple[Any, float, float]] = [] + + def set_domain(distr: Any, left: float, right: float) -> int: + captured_calls.append((distr, left, right)) + return 0 + + lib = SimpleNamespace(unur_distr_cont_set_domain=set_domain) + initializer = make_initializer( + kind=Kind.CONTINUOUS, method=UnuranMethod.HINV, lib=lib, ffi=ffi + ) + initializer._domain = success_domain + initializer._unuran_distr = "DIST" + initializer._apply_continuous_domain_constraints() + assert captured_calls == [("DIST", -2.0, 3.0)] + + def test_cleanup_handles_pointer_combinations(self) -> None: + """Verifies cleanup releases generator/parameter/distribution for five pointer + setups, covering cases where the generator already freed but parameters remain.""" + ffi = DummyFFI() + scenarios: list[CleanupScenario] = [ + {"gen": "g1", "par": "p1", "distr": "d1", "gen_null": False}, + {"gen": ffi.NULL, "par": "p2", "distr": "d2", "gen_null": True}, + {"gen": None, "par": "p3", "distr": "d3", "gen_null": False}, + {"gen": "g4", "par": ffi.NULL, "distr": "d4", "gen_null": False}, + {"gen": "g5", "par": "p5", "distr": ffi.NULL, "gen_null": False}, + ] + + for scenario in scenarios: + freed: dict[str, list[Any]] = {"gen": [], "par": [], "distr": []} + + def unur_free(ptr: Any, store: dict[str, list[Any]] = freed) -> None: + store["gen"].append(ptr) + + def unur_par_free(ptr: Any, store: dict[str, list[Any]] = freed) -> None: + store["par"].append(ptr) + + def unur_distr_free(ptr: Any, store: dict[str, list[Any]] = freed) -> None: + store["distr"].append(ptr) + + lib = SimpleNamespace( + unur_free=unur_free, unur_par_free=unur_par_free, unur_distr_free=unur_distr_free + ) + initializer = make_initializer(kind=Kind.CONTINUOUS, lib=lib, ffi=ffi) + initializer._unuran_gen = scenario["gen"] + initializer._unuran_par = scenario["par"] + initializer._unuran_distr = scenario["distr"] + initializer.cleanup() + + if scenario["gen"] and scenario["gen"] != ffi.NULL: + assert freed["gen"] == [scenario["gen"]] + else: + assert freed["gen"] == [] + + def test_initialize_unuran_components_continuous_flow( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Checks initialize_unuran_components for continuous distributions by + iterating five characteristic sets, covering cases where query_method supplies + missing entries.""" + ffi = DummyFFI() + lib = SimpleNamespace( + unur_distr_cont_new=lambda: "distr", + unur_distr_discr_new=lambda: "distr", + unur_distr_cont_set_domain=lambda *args: 0, + unur_init=lambda par: "gen", + unur_free=lambda *args: None, + unur_par_free=lambda *args: None, + unur_distr_free=lambda *args: None, + ) + + domain = DomainDouble([(-1.0, 1.0)]) + monkeypatch.setattr(init_module, "UnuranDomain", lambda distr: domain) + callback_instances: list[CallbackDouble] = [] + + def capture_callback(*args: Any, **kwargs: Any) -> CallbackDouble: + callback = CallbackDouble(*args, **kwargs) + callback_instances.append(callback) + return callback + + monkeypatch.setattr(init_module, "UnuranCallback", capture_callback) + + char_funcs = { + CharacteristicName.PDF: lambda x: x, + CharacteristicName.CDF: lambda x: x, + CharacteristicName.PPF: lambda x: x, + } + + initializer = make_initializer( + kind=Kind.CONTINUOUS, + method=UnuranMethod.PINV, + lib=lib, + ffi=ffi, + analytical=char_funcs, + fallback={}, + ) + + def fake_create_and_init(self: UnuranSamplerInitializer) -> Any: + return "GEN" + + cast(Any, initializer)._create_and_init_generator = MethodType( + fake_create_and_init, initializer + ) + initializer._unuran_distr = "DIST" + initializer._domain = domain + + available_sets = [ + {CharacteristicName.PDF}, + {CharacteristicName.PDF, CharacteristicName.CDF}, + {CharacteristicName.PPF}, + {CharacteristicName.PDF, CharacteristicName.PPF}, + {CharacteristicName.PDF, CharacteristicName.CDF, CharacteristicName.PPF}, + ] + + for available in available_sets: + initializer._unuran_distr = "DIST" + components = initializer.initialize_unuran_components(available) + assert components is not None + _, _, _, callbacks = components + assert callbacks # ensures callback list returned + + def test_initialize_unuran_components_discrete_with_dgt( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Ensures initialize_unuran_components configures discrete callbacks and DGT + over five PMF providers, covering cases where fallback query supplies the + method.""" + ffi = DummyFFI() + lib = SimpleNamespace( + unur_distr_discr_new=lambda: "distr", + unur_distr_cont_new=lambda: "distr", + unur_init=lambda par: "gen", + unur_free=lambda *args: None, + unur_par_free=lambda *args: None, + unur_distr_free=lambda *args: None, + ) + + domain = DomainDouble([(0.0, 1.0)]) + monkeypatch.setattr(init_module, "UnuranDomain", lambda distr: domain) + + callbacks: list[CallbackDouble] = [] + + def capture_callback(*args: Any, **kwargs: Any) -> CallbackDouble: + cb = CallbackDouble(*args, **kwargs) + callbacks.append(cb) + return cb + + monkeypatch.setattr(init_module, "UnuranCallback", capture_callback) + + dgt_instances: list[DGTSetupDouble] = [] + + def capture_dgt(*args: Any, **kwargs: Any) -> DGTSetupDouble: + instance = DGTSetupDouble(*args, **kwargs) + dgt_instances.append(instance) + return instance + + monkeypatch.setattr(init_module, "DGTSetup", capture_dgt) + + pmf_variants = [ + {CharacteristicName.PMF: lambda x: x + 1}, + {}, + {}, + {}, + {}, + ] + fallback_variants = [ + {}, + {CharacteristicName.PMF: lambda x: x + 2}, + {CharacteristicName.PMF: lambda x: x + 3}, + {CharacteristicName.PMF: lambda x: x + 4}, + {CharacteristicName.PMF: lambda x: x + 5}, + ] + + initializer = make_initializer( + kind=Kind.DISCRETE, + method=UnuranMethod.DGT, + lib=lib, + ffi=ffi, + ) + + def fake_create_and_init(self: UnuranSamplerInitializer) -> Any: + return "GEN" + + cast(Any, initializer)._create_and_init_generator = MethodType( + fake_create_and_init, initializer + ) + initializer._unuran_distr = "DIST" + initializer._domain = domain + + for analytical, fallback in zip(pmf_variants, fallback_variants, strict=False): + distr_double = cast(FakeDistribution, initializer._distr) + distr_double.set_analytical(analytical) + distr_double.set_fallback(fallback) + components = initializer.initialize_unuran_components({CharacteristicName.PMF}) + assert components is not None + _, _, _, cb = components + assert cb # callbacks registered + assert dgt_instances[-1].calls == 1 + + def test_initialize_unuran_components_cleans_up_on_failure( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Validates initialize_unuran_components triggers cleanup when callbacks fail + by simulating five exceptions from discrete setup, covering recurrent + failures.""" + ffi = DummyFFI() + cleanup_calls: list[bool] = [] + + lib = SimpleNamespace( + unur_distr_discr_new=lambda: "distr", + unur_distr_cont_new=lambda: "distr", + unur_init=lambda par: "gen", + unur_free=lambda *args: None, + unur_par_free=lambda *args: None, + unur_distr_free=lambda *args: None, + ) + + domain = DomainDouble([(0.0, 1.0)]) + monkeypatch.setattr(init_module, "UnuranDomain", lambda distr: domain) + + class FailingCallback(CallbackDouble): + def setup_discrete_callbacks(self) -> None: + raise RuntimeError("callback failure") + + monkeypatch.setattr(init_module, "UnuranCallback", FailingCallback) + + initializer = make_initializer( + kind=Kind.DISCRETE, + method=UnuranMethod.PINV, + lib=lib, + ffi=ffi, + analytical={CharacteristicName.PDF: lambda x: x}, + ) + initializer._unuran_distr = "DIST" + + def cleanup(self: UnuranSamplerInitializer) -> None: + cleanup_calls.append(True) + + cast(Any, initializer).cleanup = MethodType(cleanup, initializer) + + for _ in range(5): + with pytest.raises(RuntimeError, match="callback failure"): + initializer.initialize_unuran_components({CharacteristicName.PDF}) + + assert cleanup_calls == [True] * 5 diff --git a/tests/unit/sampling/unuran/core/_unuran_sampler/test_utils.py b/tests/unit/sampling/unuran/core/_unuran_sampler/test_utils.py new file mode 100644 index 0000000..8de8d1c --- /dev/null +++ b/tests/unit/sampling/unuran/core/_unuran_sampler/test_utils.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pysatl_core.sampling.unuran.core._unuran_sampler.utils import get_unuran_error_message + + +class DummyFFI: + def __init__(self) -> None: + self.NULL = object() + + def string(self, value: Any) -> bytes: + if isinstance(value, bytes | bytearray): + return bytes(value) + return str(value).encode("utf-8") + + +class FakeLib: + def __init__( + self, + errno_sequence: list[int], + strerror_mapping: Mapping[int, bytes | bytearray | str | None], + ) -> None: + self.errno_sequence = errno_sequence + self.strerror_mapping: dict[int, bytes | bytearray | str | None] = dict(strerror_mapping) + self.errno_calls = 0 + self.strerror_calls: list[int] = [] + + def unur_get_errno(self) -> int: + value = self.errno_sequence[min(self.errno_calls, len(self.errno_sequence) - 1)] + self.errno_calls += 1 + return value + + def unur_get_strerror(self, errno: int) -> bytes | bytearray | str | None: + self.strerror_calls.append(errno) + return self.strerror_mapping.get(errno) + + +class TestCallbacks: + def test_get_unuran_error_message_includes_errno_and_message_variants(self) -> None: + """Checks get_unuran_error_message formats five scenarios by varying errno and + strerror combos, covering cases like zero errno and None strerror.""" + ffi = DummyFFI() + scenarios = [ + (5, b"fatal", "issue occurred", "issue occurred (errno: 5): fatal"), + (0, None, "no error", "no error (errno: 0)"), + (10, b"overflow", "problem", "problem (errno: 10): overflow"), + (3, None, "warning", "warning (errno: 3)"), + ] + + for errno_value, strerror_bytes, base_msg, expected in scenarios: + lib = FakeLib([errno_value], {errno_value: strerror_bytes}) + assert get_unuran_error_message(lib, ffi, base_msg) == expected + + def test_get_unuran_error_message_handles_multiple_errno_calls(self) -> None: + """Ensures get_unuran_error_message respects successive errno values by feeding + five increments, covering cases where error state changes mid-loop.""" + ffi = DummyFFI() + errno_sequence = [1, 2, 3, 4, 5] + lib = FakeLib(errno_sequence, {value: f"msg-{value}".encode() for value in errno_sequence}) + + messages = [get_unuran_error_message(lib, ffi, f"base-{idx}") for idx in range(5)] + + expected = [ + "base-0 (errno: 1): msg-1", + "base-1 (errno: 2): msg-2", + "base-2 (errno: 3): msg-3", + "base-3 (errno: 4): msg-4", + "base-4 (errno: 5): msg-5", + ] + assert messages == expected + + def test_get_unuran_error_message_fallbacks_when_strerror_missing(self) -> None: + """Validates behavior when strerror mapping lacks entries by iterating five + errno values, covering partially populated mappings.""" + ffi = DummyFFI() + errno_values = [10, 11, 12, 13, 14] + mapping = {10: b"a", 12: b"c", 14: b"e"} + lib = FakeLib(errno_values, mapping) + + outputs = [get_unuran_error_message(lib, ffi, f"case-{idx}") for idx in range(5)] + expected = [ + "case-0 (errno: 10): a", + "case-1 (errno: 11)", + "case-2 (errno: 12): c", + "case-3 (errno: 13)", + "case-4 (errno: 14): e", + ] + assert outputs == expected + + def test_get_unuran_error_message_handles_non_bytes_strerror(self) -> None: + """Ensures helper tolerates string outputs from unur_get_strerror by wrapping + five string values via DummyFFI.string, covering CFFI char* proxy edge cases.""" + ffi = DummyFFI() + mapping = {i: f"text-{i}" for i in range(1, 6)} + lib = FakeLib(list(range(1, 6)), mapping) + + for idx, errno in enumerate(range(1, 6)): + msg = get_unuran_error_message(lib, ffi, f"case-{idx}") + assert f"text-{errno}" in msg diff --git a/tests/unit/sampling/unuran/core/test_unuran_sampler.py b/tests/unit/sampling/unuran/core/test_unuran_sampler.py new file mode 100644 index 0000000..29dcb6e --- /dev/null +++ b/tests/unit/sampling/unuran/core/test_unuran_sampler.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any, cast + +import numpy as np +import pytest + +import pysatl_core.sampling.unuran.core.unuran_sampler as unuran_sampler_module +from pysatl_core.distributions.distribution import Distribution +from pysatl_core.sampling.unuran.api import UnuranMethod, UnuranMethodConfig +from pysatl_core.sampling.unuran.core.unuran_sampler import DefaultUnuranSampler +from pysatl_core.types import CharacteristicName, EuclideanDistributionType, Kind + + +@dataclass +class DistributionDouble: + distribution_type: Any + support: Any = None + analytical_computations: dict[Any, Callable[[float], float]] = field(default_factory=dict) + fallback_methods: dict[Any, Callable[[float], float]] = field(default_factory=dict) + + def query_method(self, name: Any) -> Callable[[float], float]: + if name in self.analytical_computations: + return self.analytical_computations[name] + if name in self.fallback_methods: + return self.fallback_methods[name] + raise RuntimeError(f"Characteristic {name!r} not available") + + +class CFFIStub: + def __init__(self) -> None: + self.NULL = object() + + +class UnsupportedMethod: + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return self.value + + __repr__ = __str__ + + def __hash__(self) -> int: + return hash(self.value) + + +def make_initializer_double( + storage: list[dict[str, Any]], + return_value: tuple[Any, Any, Any, list[Any]] | None = None, + err: Exception | None = None, +) -> type[Any]: + class _Initializer: + def __init__(self, distr: Any, method: UnuranMethod, lib: Any, ffi: Any) -> None: + self._distr = distr + self._method = method + self._lib = lib + self._ffi = ffi + storage.append({"distr": distr, "method": method, "lib": lib, "ffi": ffi, "calls": []}) + self._storage_ref = storage[-1] + + def initialize_unuran_components(self, chars: set[Any]) -> tuple[Any, Any, Any, list[Any]]: + self._storage_ref["calls"].append({"chars": set(chars)}) + if err is not None: + raise err + return return_value or ("D", "P", "G", ["cb"]) + + return _Initializer + + +class TestCallbacks: + @pytest.mark.parametrize( + "scenario", + [ + { + "label": "auto_success_continuous", + "config": UnuranMethodConfig(method=UnuranMethod.AUTO), + "kind": Kind.CONTINUOUS, + "dimension": 1, + "analytical": {CharacteristicName.PPF: lambda x: x - 1.0}, + "fallback": {}, + "support": (-2.0, 2.0), + "select_result": UnuranMethod.HINV, + "expect_exception": None, + }, + { + "label": "manual_success_discrete", + "config": UnuranMethodConfig(method=UnuranMethod.DGT), + "kind": Kind.DISCRETE, + "dimension": 1, + "analytical": {CharacteristicName.PMF: lambda x: x + 1.0}, + "fallback": {}, + "support": (0, 10), + "select_result": UnuranMethod.DGT, + "expect_exception": None, + }, + { + "label": "missing_cffi_bindings", + "config": UnuranMethodConfig(method=UnuranMethod.PINV), + "kind": Kind.CONTINUOUS, + "dimension": 1, + "analytical": {CharacteristicName.PDF: lambda x: x}, + "fallback": {}, + "support": (-1.0, 1.0), + "select_result": UnuranMethod.PINV, + "expect_exception": RuntimeError, + "exception_msg": "CFFI", + "force_no_cffi": True, + }, + { + "label": "unsupported_distribution_type", + "config": UnuranMethodConfig(method=UnuranMethod.PINV), + "distribution_type": object(), + "analytical": {CharacteristicName.PDF: lambda x: x}, + "fallback": {}, + "support": (-1.0, 1.0), + "select_result": UnuranMethod.PINV, + "expect_exception": RuntimeError, + "exception_msg": "Unsupported distribution type", + }, + { + "label": "invalid_dimension", + "config": UnuranMethodConfig(method=UnuranMethod.PINV), + "kind": Kind.CONTINUOUS, + "dimension": 2, + "analytical": {CharacteristicName.PDF: lambda x: x}, + "fallback": {}, + "support": (-1.0, 1.0), + "select_result": UnuranMethod.PINV, + "expect_exception": RuntimeError, + "exception_msg": "Unsupported distribution dimension", + }, + ], + ids=lambda s: s["label"], + ) + def test_init_handles_various_distribution_states( + self, scenario: dict[str, Any], monkeypatch: pytest.MonkeyPatch + ) -> None: + """ + Validate DefaultUnuranSampler.__init__ over five configurations: AUTO success, + DGT discrete init, missing CFFI bindings, unsupported distribution type, and + invalid dimension. Ensures characteristic fusion and dimension guards trigger + before touching UNURAN. + """ + storage: list[dict[str, Any]] = [] + cffi_stub = SimpleNamespace(ffi=CFFIStub(), lib=SimpleNamespace()) + + bindings_module = "pysatl_core.sampling.unuran.bindings._unuran_cffi" + if scenario.get("force_no_cffi"): + monkeypatch.setattr(bindings_module, None, raising=False) + monkeypatch.setattr( + "pysatl_core.sampling.unuran.bindings._unuran_cffi", None, raising=False + ) + else: + monkeypatch.setattr( + "pysatl_core.sampling.unuran.bindings._unuran_cffi", cffi_stub, raising=False + ) + + monkeypatch.setattr( + DefaultUnuranSampler, + "_select_best_method", + staticmethod( + lambda available, kind, config: scenario.get("select_result", UnuranMethod.PINV) + ), + ) + + distr_type = scenario.get( + "distribution_type", + EuclideanDistributionType( + kind=scenario.get("kind", Kind.CONTINUOUS), dimension=scenario.get("dimension", 1) + ), + ) + + distr = DistributionDouble( + distribution_type=distr_type, + support=scenario.get("support"), + analytical_computations=scenario.get("analytical", {}), + fallback_methods=scenario.get("fallback", {}), + ) + + initializer_cls = make_initializer_double(storage) + monkeypatch.setattr(unuran_sampler_module, "UnuranSamplerInitializer", initializer_cls) + + if scenario["expect_exception"]: + with pytest.raises( + scenario["expect_exception"], match=scenario.get("exception_msg", "") + ): + DefaultUnuranSampler(cast(Distribution, distr), scenario["config"]) + return + + sampler = DefaultUnuranSampler(cast(Distribution, distr), scenario["config"]) + assert sampler._method == scenario["select_result"] + assert storage and storage[-1]["calls"][0]["chars"] + + @pytest.mark.parametrize( + "scenario", + [ + { + "label": "hinv_success", + "method": UnuranMethod.HINV, + "available": {CharacteristicName.PPF.value}, + "support": (0.0, 1.0), + "expect_exception": None, + }, + { + "label": "unsupported_method", + "method": UnsupportedMethod("custom"), + "available": {CharacteristicName.PDF.value}, + "support": (0.0, 1.0), + "expect_exception": RuntimeError, + "exception_msg": "Unsupported sampling method", + }, + { + "label": "missing_required_char", + "method": UnuranMethod.PINV, + "available": {CharacteristicName.CDF.value}, + "support": (0.0, 1.0), + "expect_exception": RuntimeError, + "exception_msg": "requires the following characteristics", + }, + { + "label": "support_required_missing", + "method": UnuranMethod.HINV, + "available": {CharacteristicName.PPF.value}, + "support": None, + "expect_exception": RuntimeError, + "exception_msg": "requires a finite support", + }, + { + "label": "dgt_requires_support", + "method": UnuranMethod.DGT, + "available": {CharacteristicName.PMF.value}, + "support": None, + "expect_exception": RuntimeError, + "exception_msg": "requires a finite support", + }, + ], + ids=lambda s: s["label"], + ) + def test_check_method_suitability_validates_requirements( + self, scenario: dict[str, Any] + ) -> None: + """ + Exercise _check_method_suitability across five paths (valid HINV, unsupported + fake method, PINV missing PDF, HINV missing bounds, DGT missing support) to + ensure requirement table stays in sync and guards fail before setup. + """ + sampler = object.__new__(DefaultUnuranSampler) + sampler._method = scenario["method"] + sampler.available_chars = set(scenario["available"]) + sampler._distr = cast(Distribution, SimpleNamespace(support=scenario["support"])) + + if scenario["expect_exception"]: + with pytest.raises(RuntimeError, match=scenario["exception_msg"]): + sampler._check_method_suitability() + return + + sampler._check_method_suitability() + + @pytest.mark.parametrize( + "exception", + [ + ValueError("val"), + RuntimeError("run"), + Exception("generic"), + LookupError("lookup"), + ArithmeticError("arith"), + ], + ) + def test_del_suppresses_cleanup_exceptions( + self, exception: Exception, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ + Ensure __del__ suppresses cleanup exceptions by running five exception classes + (ValueError, RuntimeError, Exception, LookupError, ArithmeticError) to mimic + interpreter shutdown edge cases. + """ + sampler = object.__new__(DefaultUnuranSampler) + monkeypatch.setattr(sampler, "_cleanup", lambda: (_ for _ in ()).throw(exception)) + DefaultUnuranSampler.__del__(sampler) + + @pytest.mark.parametrize( + "scenario", + [ + { + "label": "not_initialized", + "n": 1, + "expect_exception": RuntimeError, + }, + { + "label": "negative_n", + "is_initialized": True, + "n": -1, + "expect_exception": ValueError, + }, + { + "label": "continuous_sampling", + "n": 5, + "continuous": True, + "values": [0.1, 0.2, 0.3, 0.4, 0.5], + }, + { + "label": "discrete_sampling", + "n": 5, + "continuous": False, + "values": [1, 2, 3, 4, 5], + }, + { + "label": "zero_samples", + "n": 0, + "continuous": True, + "values": [], + }, + ], + ids=lambda s: s["label"], + ) + def test_sample_covers_state_and_domain_edges(self, scenario: dict[str, Any]) -> None: + """ + Validate sample() for five behaviors: uninitialized (raises), negative n, + continuous sampling, discrete sampling, and zero-length outputs. Ensures pointer + guards plus lib.unur_sample_* loops stay correct. + """ + sampler = object.__new__(DefaultUnuranSampler) + sampler._ffi = SimpleNamespace(NULL="NULL") + sampler._unuran_distr = "DIST" + sampler._unuran_gen = "GEN" + sampler._lib = SimpleNamespace() + sampler._kind = Kind.CONTINUOUS if scenario.get("continuous", True) else Kind.DISCRETE + sampler._fallback_strategy = None + + if scenario.get("values") and scenario["n"] > 0: + provided_values = list(scenario["values"]) + iterator = iter(provided_values) + + if sampler._kind == Kind.CONTINUOUS: + sampler._lib.unur_sample_cont = lambda *_args: next(iterator) + else: + sampler._lib.unur_sample_discr = lambda *_args: next(iterator) + + sampler._unuran_distr = "DIST" + sampler._unuran_gen = "GEN" + + if scenario.get("expect_exception"): + with pytest.raises(scenario["expect_exception"]): + sampler.sample(scenario["n"]) + return + + result = sampler.sample(scenario["n"]) + assert isinstance(result, np.ndarray) + if scenario["n"] == 0: + assert result.size == 0 + else: + assert np.allclose(result, scenario["values"]) + + @pytest.mark.parametrize( + "method_value", + [ + UnuranMethod.PINV, + UnuranMethod.NINV, + UnuranMethod.HINV, + UnuranMethod.DGT, + UnuranMethod.AROU, + ], + ) + def test_method_property_exposes_current_method(self, method_value: UnuranMethod) -> None: + """ + Assert method property mirrors internal _method pointer for five enum values so + attribute access remains stable even as enum grows. + """ + sampler = object.__new__(DefaultUnuranSampler) + sampler._method = method_value + assert sampler.method == method_value diff --git a/tests/unit/sampling/unuran/core/test_unuran_sampling_strategy.py b/tests/unit/sampling/unuran/core/test_unuran_sampling_strategy.py new file mode 100644 index 0000000..8d90db5 --- /dev/null +++ b/tests/unit/sampling/unuran/core/test_unuran_sampling_strategy.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, cast + +import numpy as np +import numpy.typing as npt +import pytest + +from pysatl_core.sampling.unuran.api import IUnuranSampler, UnuranMethod, UnuranMethodConfig +from pysatl_core.sampling.unuran.core.unuran_sampling_strategy import DefaultUnuranSamplingStrategy + +if TYPE_CHECKING: + from pysatl_core.distributions.distribution import Distribution + + +class DistributionDouble: + def __init__( + self, + name: str, + *, + eq_behavior: str = "identity", + eq_result: bool = False, + eq_exception_factory: Callable[[], Exception] | None = None, + ) -> None: + self.name = name + self.eq_behavior = eq_behavior + self.eq_result = eq_result + self.eq_exception_factory = eq_exception_factory + + def __eq__(self, other: object) -> bool: + if self.eq_behavior == "identity": + return self is other + if self.eq_behavior == "value": + return bool(self.eq_result) + if self.eq_behavior == "exception": + factory = self.eq_exception_factory or (lambda: TypeError("bad equality")) + raise factory() + return False + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f"DistributionDouble({self.name})" + + +class SamplerRecorder(IUnuranSampler): + instances: list[SamplerRecorder] = [] + + def __init__( + self, + distr: Any, + config: UnuranMethodConfig | None = None, + **options: Any, + ) -> None: + self.distr = distr + self.config = config or UnuranMethodConfig() + self.options = options + self.sample_calls: list[int] = [] + self.output_factory: Callable[[int], list[float]] = options.get( + "output_factory", lambda n: [float(i) for i in range(n)] + ) + self._method: UnuranMethod = self.config.method + self._is_initialized = True + self.reset_calls: list[int | None] = [] + SamplerRecorder.instances.append(self) + + def sample(self, n: int) -> npt.NDArray[np.float64]: + self.sample_calls.append(n) + return np.asarray(self.output_factory(n), dtype=np.float64) + + def reset(self, seed: int | None = None) -> None: + self.reset_calls.append(seed) + + @property + def method(self) -> UnuranMethod: + return self._method + + @property + def is_initialized(self) -> bool: + return self._is_initialized + + +class TestCallbacks: + @pytest.mark.parametrize( + "scenario", + [ + { + "label": "defaults_applied", + "config": None, + "sampler_class": None, + }, + { + "label": "custom_config_retained", + "config": UnuranMethodConfig(use_pdf=False, use_cdf=False), + "sampler_class": SamplerRecorder, + }, + { + "label": "custom_sampler_without_cache", + "config": UnuranMethodConfig(use_ppf=False), + "sampler_class": SamplerRecorder, + }, + { + "label": "explicit_registry_disabled", + "config": UnuranMethodConfig(use_registry_characteristics=False), + "sampler_class": None, + }, + ], + ids=lambda s: s["label"], + ) + def test_init_configures_attributes_across_inputs(self, scenario: dict[str, Any]) -> None: + """Validate __init__ across varied configs and sampler classes.""" + strategy = DefaultUnuranSamplingStrategy( + config=scenario["config"], + sampler_class=scenario["sampler_class"], + ) + assert strategy.config.method is not None + assert strategy._sampler_class is not None + + def test_sample_raises_for_negative_n(self) -> None: + """Ensure sample() validates input size.""" + strategy = DefaultUnuranSamplingStrategy(sampler_class=SamplerRecorder) + with pytest.raises(ValueError): + strategy.sample(-1, cast("Distribution", DistributionDouble("neg"))) + + def test_sample_instantiates_sampler_each_call(self) -> None: + """sample() should instantiate the sampler and delegate to it.""" + SamplerRecorder.instances.clear() + strategy = DefaultUnuranSamplingStrategy(sampler_class=SamplerRecorder) + distr = DistributionDouble("target") + + result = strategy.sample(4, cast("Distribution", distr)) + + assert result.tolist() == [0.0, 1.0, 2.0, 3.0] + assert len(SamplerRecorder.instances) == 1 + assert SamplerRecorder.instances[0].sample_calls == [4] + + def test_sample_ignores_runtime_options(self) -> None: + """Options passed to sample() do not mutate the cached sampler.""" + SamplerRecorder.instances.clear() + strategy = DefaultUnuranSamplingStrategy(sampler_class=SamplerRecorder) + distr = DistributionDouble("opt-target") + + result = strategy.sample( + 2, + cast("Distribution", distr), + output_factory=lambda n: [42.0] * n, + seed=123, + ) + + assert result.tolist() == [0.0, 1.0] + assert "seed" not in SamplerRecorder.instances[0].options + + @pytest.mark.parametrize( + "config", + [ + UnuranMethodConfig(), + UnuranMethodConfig(use_ppf=False), + UnuranMethodConfig(use_pdf=False), + UnuranMethodConfig(use_cdf=False), + UnuranMethodConfig(use_registry_characteristics=False), + ], + ) + def test_config_property_returns_internal_object(self, config: UnuranMethodConfig) -> None: + """ + Ensures config property returns the stored configuration for five setups, + covering cases where toggling flags might otherwise clone or reinstantiate configs. + """ + strategy = DefaultUnuranSamplingStrategy(config=config) + assert strategy.config is config diff --git a/vendor/unuran-pysatl b/vendor/unuran-pysatl new file mode 160000 index 0000000..7e2344e --- /dev/null +++ b/vendor/unuran-pysatl @@ -0,0 +1 @@ +Subproject commit 7e2344e93eb688acc430393c12228419fa130b9b